作者:Ian Moersen
本博客系列揭示了我们的现场工程团队如何使用 Elastic stack 和生成式 AI 开发出一款可爱而高效的客户支持聊天机器人。如果你错过了本系列的其他文章,请务必查看第一部分、第二部分和第四部分。
通过 Web 应用聊天的想法已经存在了很长时间。因此,你可能会认为这意味着 GenAI 聊天机器人将是一个标准的、无聊的界面。但事实证明,AI 聊天机器人提出了一些有趣而新颖的挑战。我将在这里提到其中的一些,希望如果你希望构建自己的聊天界面,你可以使用这些技巧和窍门来帮助你。
作为一名 UI 设计师,我喜欢对小事大惊小怪。头像的十六进制颜色是否太暗?我肯定会抱怨。此工具提示上的动画是否没有正确缓动?让我们花时间寻找正确的贝塞尔曲线。不,不,相信我,这绝对值得。新页面上的字体渲染是否略有不同?哦,是的,你肯定会从 Ian(本文作者) 那里听到这个消息。
因此,当我的团队开始开发新的自动化支持助手时,我们必须决定:我们是否从架子上取下一个库来处理聊天界面?我们要从头开始开发自己的聊天机器人吗?对我来说,我几乎不想考虑前者。为我们的聊天机器人做好每一个小事是设计师的梦想。让我们开始吧。
1. 选择库
因此,当我之前说 “从头开始开发我们自己的” 时,我的意思并不是从头开始。抱歉,现在是公元 2024 年,大多数人不再从头开始开发 UI 组件。许多开发人员依靠组件库来构建新事物,在 Elastic 我们也不例外。虽然我们在一个方面非常出色:我们有自己的 Elastic UI 组件库,任何人都可以免费使用。
EUI 目前没有 “ChatBot” 组件,但它确实提供了头像、“面板”、文本区域等,可能需要创建一个漂亮的小聊天窗口。
如果你想继续阅读这篇文章的其余部分,请随时在另一个选项卡中打开我制作的这个示例 EUI 聊天界面,你可以自己试用一下。玩得开心!
2. 动画……借助一些意想不到的帮助
在设计和组装聊天界面的主要构建块(你可以在上面的沙盒链接中查看)之后,我们面临的下一个挑战是如何在聊天机器人响应的漫长时间内保持用户的参与度。更糟糕的是,我们使用的第一个 LLM 端点(用于内部 alpha 测试)没有流式传输其响应;它只是生成并将整个答案以单个 HTTP 响应主体的形式发送回给我们。这花了很长时间。不太好。
操作 | 从 -> 到 | 观察到的近似延迟 |
---|---|---|
Initial request | Client -> server | 100 - 500ms |
RAG search | Server -> cluster | 1 - 2.5s |
Call to LLM | Server -> LLM | 1 - 2.5s |
First streamed byte | LLM -> server -> client | 3 - 6s |
总 | 5.1 - 11.5 seconds |
我们的第一道防线是引人注目的 “加载” 动画。我想要一些自定义的、看起来有趣的东西,但同时也要非常符合 Elastic 的整体品牌指导方针。为此,我决定使用 Elastic 现有的 EuiIcon 组件来显示三个点,然后使用 Elastic 品牌颜色和 EUI 的默认动画贝塞尔曲线(这些数学描述动画如何加速和减速)来让事物在脉动、闪烁和改变颜色时感觉 “Elastic”。
在 CSS 中编排弹跳、颜色变化和不透明度淡化有点超出我的舒适范围。因此,与其花一整天时间猜测要使用的值,我突然想到我可以问坐在我面前的人。没错,我要求聊天机器人(早期版本)编写自己的加载动画。
它在第一次尝试时就得出了近乎完美的结论。经过一些微调和代码重构后,结果如下:
// An animation cycle will have three "keyframes", describing how the animation
// will look at the beginning, middle and end of a cycle.
@keyframes loadingPulsate {
// Beginning
0% {
opacity: 0.85;
color: #0077cc;
}
// Middle
50% {
transform: scale(0.5);
opacity: 0.55;
color: #00bfb3;
}
// End
100% {
opacity: 0.85;
color: #f04e98;
}
}
// A class applied to each "dot": it calls the animation with an appropriate
// bezier curve to describe how to animate the properties between keyframes
.typing-dots-animation {
animation: loadingPulsate 1.2s cubic-bezier(0.694, 0.0482, 0.335, 1) infinite;
}
// A class applied to the second "dot"—it just delays the start of the animation
.typing-dots-animation1 {
animation-delay: -0.4s;
}
// A class applied to the third and final "dot", delaying the start as well
.typing-dots-animation2 {
animation-delay: -0.2s;
}
如果你能找出在上面的沙盒链接中要编辑哪些属性以亲自查看这些加载点,则可以获得加分。所有代码都在那里!)
这产生了一个令人愉悦的小加载动画,我仍然喜欢一次看几秒钟;这正是我们所需要的!现在,聊天机器人编程本身是否存在令人担忧的问题?......这个问题我将留给哲学家。但作为一名网络开发人员,我需要关注更实际的问题。比如,如果 LLM 的回复时间太长或完全中断,我们应该怎么做。
3. Killswitch 启动
在大多数传统 Web 应用中,处理网络超时和故障非常简单。只需检查响应的错误代码并进行适当处理即可。任何额外的超时处理都可以在 try/catch 块或类似块中捕获。通常,典型的 HTTP 提取将知道如何处理超时,超时通常配置为在相当短的时间后发生,并且发生的频率相对较低。
生成式 AI API 端点的当前状态并不完全如此。是的,偶尔,你会收到带有错误代码的快速故障响应,但请记住,我们在这里流式传输 LLM 的响应。很多时候,我们会快速从 API 端点收到 200 OK,这告诉我们大型语言模型已准备好开始流式传输其响应……但随后可能需要非常长的时间才能接收任何数据。或者,在流式传输过程中,路径变冷,连接挂起。
无论哪种情况,我们都不想依赖传统的网络超时来为用户提供重试问题的选项。在尝试失败时设置短暂的超时时间,然后快速、成功地重试,这比花费太长时间的成功响应要好得多。
因此,在我们发现大多数失败的流需要一分钟以上的时间才能解决后,我们开始寻找最短的时间,以确保流可能会失败(或需要过多的时间来解决)。我们不断地缩短时间,直到我们发现,在仅仅 10 秒的无线电静默之后,我们几乎可以肯定流最终会失败,或者需要一分钟以上的时间才能恢复。
以下是一些说明这一概念的伪代码。这是你可能在用户提出问题后调用流式 LLM API 的主要函数中找到的代码类型的示例。只要巧妙地使用 AbortController 信号和 setTimeout,你就可以在 fetch() 函数上实现 “killswitch”,以便在流停止超过 10 秒时快速向用户返回错误:
const KILLSWITCH_TIMEOUT_IN_SECONDS = 10;
const abortController = new AbortController();
const firstGenerationTimeoutId = setTimeout(() => {
abortController.abort();
}, KILLSWITCH_TIMEOUT_IN_SECONDS * 1000); // Convert seconds to milliseconds for setTimeout()
return {
promise: fetchStreamingAPI("/api/streaming-ai-endpoint", {
body: JSON.stringify(request),
method: "POST",
onmessage: ({ event, data }) => {
clearTimeout(firstGenerationTimeoutId);
// Handle actual streaming response data here
},
openWhenHidden: true,
signal: abortController.signal,
}),
stopGeneration: () => abortController.abort(),
};
因此,在解决了这些问题以及大约一百个其他问题之后,是时候关注站点范围的生成 AI 界面所独有的另一个挑战:上下文。
4. 聊天历史上下文
在与 AI 助手聊天时,你希望它了解你之前消息的上下文。例如,如果你要求它澄清答案,它需要 “记住” 你问它的问题以及它自己的回答。你不能只将 “你能澄清一下吗?”单独发送给 LLM 并期望得到有用的回复。
在对话中,上下文很容易找到和发送。只需将所有以前的聊天消息转换为 JSON 对象,并将其与最新问题一起发送到 LLM 端点即可。虽然可能需要考虑一些较小的问题(例如如何序列化和存储元数据或 RAG 结果),但相对来说并不复杂。下面是一段伪代码,说明如何使用对话上下文丰富默认提示。
// An example prompt
const defaultPrompt = `Role: Expert Elastic Support Engineer.\
- Your goal is to help Elastic customers.\
- Include the relevant url references at the end of your response.\
- Answer only things you're sure about.\
// ...etc
`;
// Here's one way to inject that prompt with context from earlier messages in the conversation
const addContextToPrompt = (defaultPrompt: string, chatHistory: ChatMessage[]) => (
`${defaultPrompt}
Additionally, this JSON object describes your conversation with the customer to this point: ${JSON.stringify(chatHistory)}`;
)
但是其他类型的上下文呢?例如:当你阅读支持案例并在页面上看到聊天小部件时,询问 AI 助手 “此案例已开庭多久了?” 不是很有意义吗?好吧,为了提供这个答案,我们需要将支持案例本身作为上下文传递给 LLM。但是,如果你正在阅读该支持案例,并且其中一个回复包含你不理解的术语,该怎么办?要求助手解释这个高度技术性的术语是有意义的。好吧,为此,我们需要向 LLM 发送不同的上下文(在我们的例子中,是从我们的知识库中搜索该技术术语的结果)。
我们如何向用户传达像上下文这样复杂而独特的东西,以便在对话中引导他们?我们如何让用户选择发送哪些上下文?也许最难的是,我们如何用如此有限的像素数量完成所有这些工作?
在设计和评估了相当多的选项(面包屑?聊天窗口内的粘性警报栏?小徽章??)之后,我们决定在文本输入区域添加一个 “前置” 元素。这样可以将上下文放在它描述的 “action item - 操作项” 旁边;上下文仅附加到你的下一个问题,而不是你的最后一个答案!
UI Element | 优点 | 缺点 |
---|---|---|
面包屑(Elastic 图标) | 占用空间小,易于交互 | 更适合表示 URL 和路径 |
顶部横幅 | 不碍事,可以进行长描述 | 不易互动,容易迷路 |
微徽章 | 轻松显示多个上下文 | 难以编辑上下文 |
带数字标记的前置菜单 | 靠近输入字段,易于交互 | 挤占可用空间 |
此外,可以使用 EUI 上下文菜单让高级用户编辑他们的上下文。比方说,你想向助手询问一些需要同时参考案例历史记录和彻底搜索 Elastic 知识库的问题;这是两个非常不同的上下文。比如:“How do I implement the changes the Elastic engineer is asking me to make- 我该如何实现 Elastic 工程师要求我进行的更改?”此时,你可以使用上下文菜单,确保这两种信息来源都被用于助手的回答。
这也为我们提供了更大的灵活性。例如,如果我们希望 LLM 本身在回答每个问题后确定上下文,我们可以轻松地将其显示给用户,并且小粉色通知徽章可以在有任何更新时提醒用户。
这些只是我们在开发 GenAI 支持助手界面时需要解决的众多小问题中的一小部分。尽管现在似乎每个人都在发布聊天机器人,但我并没有看到很多在设计界面和体验时可能遇到的实际问题的细分。构建一个无摩擦的界面,重点是让流媒体体验感觉流畅,为意外超时提供便利,并为聊天上下文等复杂概念设计只有几个像素的空闲,这些只是我们需要解决的几个问题。
实现 AI 聊天机器人自然会将大部分工程重点放在 LLM 和后端服务上。但是,重要的是要记住,新工具的 UX/UI 组件也需要足够的时间和注意力。即使我们正在构建使用 AI 技术的产品,为人类设计始终很重要。
准备好自己尝试一下了吗?开始免费试用。
想要获得 Elastic 认证吗?了解下一次 Elasticsearch 工程师培训何时开始!
原文:GenAI with Elastic ELSER for Customer Support — Search Labs