本博客系列揭示了我们的现场工程团队如何将 Elastic Stack 与生成式 AI 结合使用,开发了一个可爱且有效的客户支持聊天机器人。如果您错过了该系列的其他部分,请务必查看 第一部分、第二部分、第四部分、发布博客 以及 第五部分。
通过网络应用进行聊天的想法已经存在很长时间了。因此,您可能会认为这意味着生成式 AI 聊天机器人将是一个标准的、无聊的界面来构建。但事实证明,AI 聊天机器人带来了一些有趣且新颖的挑战。我将在这里提到其中的一些,并且希望,如果您正在寻求构建自己的聊天界面,您可以使用其中的一些提示和技巧来帮助您。
作为一名 UI 设计师,我喜欢对微不足道的事情大做文章。头像的十六进制颜色是否太深了一点?我肯定会抱怨。此工具提示上的动画是否没有正确缓动?让我们花时间找到合适的贝塞尔曲线。不不,相信我,这绝对值得。新页面上的字体渲染是否略有不同?哦,是的,你肯定会从 Ian 那里听到关于它的事情。
因此,当我的团队开始着手开发一个新的自动化支持助手时,我们必须做出决定:我们是否要从货架上提取一个库来处理聊天界面?我们是否要从头开始开发?对我来说,我几乎不想考虑前者。为我们的聊天机器人做好小事是设计师的梦想。让我们来做吧。
1. 选择库
因此,当我之前说“从头开始开发”时,我的意思并不是从完全零开始。对不起各位,现在是公元 2024 年,大多数人不再从头开始开发 UI 组件了。许多开发人员依靠组件库来构建新事物,而 Elastic 也不例外。尽管我们在一个方面非常特殊:我们拥有自己的 Elastic UI 组件库,并且它对任何人都免费使用。
EUI 目前没有“ChatBot”组件,但它确实提供了创建漂亮的聊天窗口可能需要的头像、“面板”、文本区域等。
如果您想继续阅读本文的其余部分,请随时在另一个标签页中打开 我制作的这个 EUI 聊天界面示例,您可以自己试用一下。玩得开心!
2. 动画……在一些不太可能的帮助下
在设计和组装聊天界面的主要构建块(您可以在上面的沙盒链接中查看)之后,我们的下一个挑战之一是如何在聊天机器人响应有时很长的期间内保持用户参与度。更糟糕的是,我们使用的第一个 LLM 端点(用于内部 Alpha 测试)没有流式传输其响应;它只是生成并将其完整的答案发送回给我们,作为一个单一的 HTTP 响应正文。这花费了很长时间。不太好。
操作 | 从 -> 到 | 大约观察到的延迟 |
---|---|---|
初始请求 | 客户端 -> 服务器 | 100 - 500 毫秒 |
RAG 搜索 | 服务器 -> 集群 | 1 - 2.5 秒 |
调用 LLM | 服务器 -> LLM | 1 - 2.5 秒 |
第一个流式传输字节 | LLM -> 服务器 -> 客户端 | 3 - 6 秒 |
总计 | 5.1 - 11.5 秒 |
我们在这里的第一道防线是一个引人注目的“加载”动画。我想要一些自定义的、有趣的动画,但也要非常紧密地遵循 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;
}
(如果您能弄清楚在上面的沙盒链接中编辑哪些属性以查看这些加载点,则可以获得加分。所有代码都在那里!)
这产生了一个令人愉悦的小加载动画,我仍然喜欢偶尔看几秒钟;这正是我们需要的!现在,聊天机器人是否会自我编程存在着是否存在焦虑?……这个问题我留给哲学家们。但作为一名 Web 开发人员,我需要专注于更实际的事情。比如,如果 LLM 的响应花费的时间过长或完全中断,我们应该怎么办。
3. 启用断路器
在大多数传统的 Web 应用中,处理网络超时和故障非常简单。只需检查响应的错误代码并适当地处理它们。任何额外的超时处理都可以用 try/catch
块或类似的方法捕获。通常,典型的 HTTP 获取将知道如何处理超时,这些超时通常配置为在合理的时间段后发生,并且相对很少发生。
生成式 AI API 端点的当前状态并不完全像那样。是的,偶尔,您会收到带有错误代码的快速故障响应,但请记住,我们在这里正在流式传输 LLM 的响应。与通常情况相比,我们更常从 API 端点快速收到 200 OK
,这告诉我们大型语言模型已准备好开始流式传输其响应……但随后可能需要非常长的时间才能收到任何数据。或者,在流的中途,线索中断,连接只是挂起了。
在这两种情况下,我们都不希望依赖传统的网络超时来让用户可以选择重试他们的问题。与花费过长时间的成功响应相比,在失败尝试上设置短暂的超时,然后进行快速成功的重试,是一种更好的用户体验。
因此,在我们发现大多数失败的流需要超过一分钟才能解决后,我们开始着手寻找最短的时间,以确保该流很可能即将失败(或需要过长时间才能解决)。我们不断缩短它,直到发现,在 10 秒的无线电静默之后,我们几乎可以肯定该流最终会失败或需要超过一分钟才能恢复。
以下是一些说明该概念的伪代码。这是一个示例,说明您可能在用户提问后调用流式 LLM API 的主要函数中找到的代码类型。只需对 AbortController
信号和 setTimeout
稍微巧妙地使用,您就可以在 fetch()
函数上实现一个“断路器”,如果流在 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 发送不同的上下文(在我们的例子中,是针对该专业术语在我们知识库中搜索的结果)。
我们如何向用户传达像上下文这样复杂且独特的概念,以便他们在对话中定位自己?我们如何让用户选择要发送的上下文?也许最困难的是,如何在如此有限的像素下完成所有这些操作?
在设计和评估了许多选项(面包屑?聊天窗口内的固定警报栏?微小的徽章?)之后,我们选择了一个“前置”元素添加到文本输入区域。这使得上下文紧挨着它描述的“操作项”;上下文仅附加到您的下一个问题,而不是您的上一个答案!
UI 元素 | 优点 | 缺点 |
---|---|---|
面包屑 | 占用空间小,易于交互 | 更适合表示 URL 和路径 |
顶部横幅 | 不碍事,允许长描述 | 不易交互,可能会被忽略 |
微型徽章 | 易于显示多个上下文 | 难以编辑上下文 |
带数字徽章的前置菜单 | 靠近输入字段,易于交互 | 在可用空间中挤得很紧 |
此外,可以使用 EUI 上下文菜单允许高级用户编辑其上下文。假设您想询问助手一些需要案例历史记录 *和* Elastic 知识库的全面搜索才能回答的问题;这两者是截然不同的上下文。例如,“如何实现 Elastic 工程师要求我进行的更改?”然后,您可以使用上下文菜单确保助手响应时使用了这两个信息源。
这也给了我们更大的灵活性。例如,如果我们希望 LLM 本身在每个问题之后确定上下文,我们可以轻松地将其显示给用户,并且粉红色的通知徽章可以提醒用户是否存在任何更新。
这些只是我们在开发 GenAI 支持助手界面时需要解决的众多较小问题中的一小部分。尽管现在似乎每个人都在发布聊天机器人,但我还没有看到许多关于在设计界面和体验时可能遇到的现实问题的细分。构建一个无摩擦的界面,重点是使流式体验感觉快速流畅,为意外超时提供便利,以及仅使用少量像素来设计像聊天上下文这样复杂的概念,这些只是我们需解决的部分问题。
实施 AI 聊天机器人自然会将大部分工程重点放在 LLM 和后端服务上。但是,务必记住,新工具的 UX/UI 组件也需要足够的时间和关注。即使我们正在构建使用人工智能技术的下一代产品,设计以人为本始终非常重要。