开发者详细分享用检索增强和架构优化显著提升本地 LLM 应用性能的实战经验。高价值技术参考。
如果你读过我上一篇博客文章,应该已经知道,我喜欢让智能家居保持开源,并且尽可能在本地运行;当然,我使用的任何语音助手也不例外。如果你看过演示视频,可能还发现它……很慢。相信我,我也发现了。
Prefix caching(前缀缓存)确实有所帮助,但感觉有点像在作弊。当然,它在演示中的表现会非常惊艳,但只要我开始用 LLM 处理其他事情——而且我经常这么做——缓存就会被驱逐,第一次 prompt 依然会很慢。
我先尝试了最简单、也最昂贵的方法。在配电箱前又算了一番之后,我确认:只要使用厨房里的某个特定插座,并设置较低的功率上限(260W),就可以安全地运行两张 RTX 3090。结果是,我收获了财务顾问极其愤怒的目光,也获得了把 Whisper 卸载到 GPU 上运行的能力,以及 Llama 3 70B AWQ(它确实非常棒);但速度仍然不够快:
如果能有一个聪明得多、速度也快得多的方案,那就太好了。比如,像这样?
让我们思考一个更聪明的解决方案。为此,先来深入了解一下语言模型究竟是如何工作的!NVIDIA 有一份非常出色的 LLM inference 文档,对我帮助极大。
语言模型的运行分为两个阶段,分别叫作「prefill」和「decode」。当你向语言模型发送 prompt 时,就能看到这两个阶段的实际表现。在第一个 token 出现之前进行的是 prefill,之后每输出一个 token,进行的都是 decode。Decode 的耗时相对稳定,它带来的整体「缓慢」基本只会随着 LLM 输出长度线性增长。如果能把输出以流式方式传输给 Home Assistant,就能大幅减轻 decode 带来的主观延迟;但我实在没能弄明白 Home Assistant 的代码库。
现在先把重点放在 prefill 上,因为我发现 inference 的大部分时间都花在了这里。如果你经常使用语言模型,可能已经注意到:面对很长的 context,prefill 的扩展性非常差。这是因为 prefill 的延迟会随着 context 长度呈平方增长。有一篇很有意思的论文,解释了超大 context size 所面临的各种挑战。由于我们把整个智能家居的状态都传给了 LLM,prefill 的耗时相当糟糕。更何况,Llama 3 的 context size 只有 8k,而我甚至还没考虑加入天气信息,就已经用掉了 60%!根据我以往的经验,使用 llama.cpp 进行 CPU inference 时,最糟糕的部分永远是 prefill,所以我都不敢想象没有 GPU 时会慢成什么样。
不用说,我们必须处理一下这个庞大的 prompt。LLM 显然需要智能家居的信息,才能了解……我们的智能家居。但它真的需要所有信息吗?你上一次要求语音助手总结整栋房子,或者一次性操作多个房间里的每一台设备,是什么时候?
接下来聊聊 RAG。RAG(Retrieval Augmented Generation,检索增强生成)是一种常用方法,可以利用外部来源来扩充 LLM 的 prompt。RAG 的关键部分叫作「embeddings」。在不过度深入数学细节的前提下,embedding model 会接收一段文本输入,并将其投射到一个高维空间中。其核心思想是:语义越接近的句子,在这个空间网格中的位置也越接近。这样一来,只需计算用户 prompt 的 embedding 与每份文档 embedding 之间的 cosine similarity,就能搜索规模庞大的知识库。系统由此可以找出与用户刚刚提出的问题最相关的文章,再用这些文章扩充整个 LLM prompt,进而提高 LLM 的回答质量。
如果我们利用完全相同的技术,判断 LLM 为了回答问题究竟需要庞大 prompt 中的哪些部分,会怎么样?这样可以显著缩短 context length,或许还能解决我的速度问题!它还会让整个系统具备强得多的可扩展性,因为现在我可以不断添加更多内容,而不必担心触及 context limit。为此,我首先构建了一个 RAG API,把那个庞大的 prompt 拆成许多很小的片段。接着,我又加入了一些锦上添花的内容,比如天气预报和日历(我计划以后加入电子邮件,但这项工作的复杂度要高一些,因为还需要再加一层 RAG)。之后,我直接在一台服务器上部署了 ollama 和 mxbai-embed-large,在它们前面放置 LiteLLM proxy server,再配置 API,让所有组件协同工作。我还更新了自己的 extended_openai_conversation fork,使其能够使用新的 RAG API。
这个 API 的工作方式是:它会接收那些不太可能频繁变化的数据,例如某个区域内的所有设备名称,以及与这些设备关联的所有 entity 名称,但不包括 entity 的实际状态;然后将这些数据对应的全部 embeddings 缓存在 RAM 中。对于某些没有与 context 相关的合适标题的内容,比如天气,它会直接为一个硬编码标题计算 embeddings。API 会在后台定期更新这些 embeddings。每当用户 prompt 到来时,由于所有 embeddings 都已经预先计算并存放在 RAM 中,我们只需为用户 prompt 创建 embedding,然后计算 similarity。接下来选取排名最高的 3 个「documents」,此时再获取设备的实际状态。最后,把这些内容加入 LLM prompt,得到一个对 LLM 而言依然有意义、但长度显著缩短的 prompt!在必要时,我还会动态生成用于 in-context learning 的示例,尤其是在那些我发现 LLM 容易弄错 service 名称的地方。由于这些示例是根据智能家居的当前状态动态生成的,所以通常对 LLM 很有帮助。
经过一番实验,我最终划分出了以下类别:
未来一周内的所有日历事件。标题同样包含整个日历,因为我们希望能够匹配其中的事件。
未来一周内的所有日历事件。标题同样包含整个日历,因为我们希望能够匹配其中的事件。
未来一周的天气预报。标题是一条硬编码消息。
未来一周的天气预报。标题是一条硬编码消息。
Home Assistant 中定义的每个区域对应一个类别。标题是该区域内所有设备所关联的全部 entities 列表,其中包含名称和 ID,但不包含状态。
Home Assistant 中定义的每个区域对应一个类别。标题是该区域内所有设备所关联的全部 entities 列表,其中包含名称和 ID,但不包含状态。
购物清单对应一个类别。标题是完整的购物清单。
购物清单对应一个类别。标题是完整的购物清单。
是否还有其他人在家对应一个类别。标题是一条硬编码消息。
是否还有其他人在家对应一个类别。标题是一条硬编码消息。
所有 media players 及其正在播放的内容对应一个类别。标题是 media players 列表,但不包含它们正在播放的内容。
所有 media players 及其正在播放的内容对应一个类别。标题是 media players 列表,但不包含它们正在播放的内容。
另外还有两个分别用于 laundry 和 color loop 的类别,它们是针对我的 Home Assistant 配置高度定制的,因此在示例配置中处于禁用状态。
另外还有两个分别用于 laundry 和 color loop 的类别,它们是针对我的 Home Assistant 配置高度定制的,因此在示例配置中处于禁用状态。
好了,效果如何,你自己看吧!
当然,如果要处理非常长的 prompt 和 response,即使经过这项优化,使用起来仍然会感觉很慢。但我认为,有时这是值得的: