跳到主要内容

聊天历史是数据库。别再把它当成滚动回溯了。

· 阅读需 12 分钟
Tian Pan
Software Engineer

针对 Agent 类产品,生产环境下最常见的投诉通常是某种形式的“它忘记了我们刚才说的话”。这种投诉往往出现在第 8 轮、第 15 轮或第 30 轮——绝不会在第 2 轮出现。团队的第一反应往往如出一辙:扩大上下文窗口。但这其实是错误的直觉,因为 Bug 不在模型本身,而在于团队将对话历史视为了终端的滚动回放(scrollback)——追加一行、渲染尾部、满了就截断。实际上,他们不知不觉中构建的是一个读多写少的数据库,具有仅追加写入、热工作集、隐藏在截断规则中的淘汰策略,以及取决于所提问题类型的查询模式。一旦你接受了这一点,整个问题的本质就改变了。

滚动回放模式之所以如此诱人,是因为聊天界面看起来就像一份对话记录。消息向下流动,用户自上而下阅读,而喂给模型的自然方式就是将最新的 N 轮对话拼接到提示词中。这种数据结构感觉是“免费”的:没有 Schema,没有索引,没有查询——只需追加、渲染、重复。在最初的几轮对话中,任何架构都表现良好。模型拥有完整的上下文,费用低廉,演示效果极佳。

然后,产品进入了实际生产环境。一个用户进行了长时间的支持会话;另一个用户第二天回来,期望对话能保持连贯;第三个用户要求 Agent 总结一个已经持续了两小时的线程。突然间,Agent 开始自相矛盾,丢掉了用户在 6 轮前设定的约束,或者仅仅在历史记录上就耗尽了上下文预算,导致回答实际问题时无以为继。团队提高了截断限制,导致成本激增,有人指出,即使在按 Token 线性计费的供应商那里,将上下文长度翻倍也会使注意力计算量增加四倍——而且模型在上下文中间部分使用 Token 的能力衰减得很快,以至于更多的字节并不等同于更多的记忆。

没人会在白板上画出的成本模型

滚动回放模式掩盖的第一件事是你的 Token 到底买到了什么。在长对话中,账单上的主要支出项不是模型的输出,甚至不是系统提示词,而是对话历史。由于 API 是无状态的,客户端不断将相同的对话轮次重复拼接到提示词中,导致每一轮都要全量重新发送。

在没有提示词缓存的情况下,成本大致随对话长度呈平方级增长。到第 30 轮时,早期的消息已经被重新发送给供应商 30 次了。缓存降低了常数——例如 Claude 的提示词缓存和 OpenAI 的自动提示词缓存——但它并没有改变成本增长的曲线,因为只有当前缀完全稳定时,缓存才会生效。任何工具调用的结果、检索到的文档或注入到提示词中的时间戳都会使后续内容失效,并迫使缓存重新预热。没有量化过这一点的团队往往认为他们的 Token 账单随输出量增长。事实并非如此。它随对话长度的积分增长,而历史记录的重复读取通常占总量的三分之二。

这一点至关重要,因为它反转了优化目标。最便宜的 Token 是你没有放入提示词中的那个。本文后续部分的所有架构选择,从根本上说都是在讨论:应该在何时、以何种保真度加载过去的哪一部分片段?而“每一轮都全量加载”这种暴力方案,只有在对话很短时才可行。

当你称其为数据库时,一切都变了

一旦你不再称其为记录,而是称其为数据库,问题的空间就会围绕你已经知道如何解决的问题重新排列。数据库有索引、查询计划、物化视图、淘汰策略和一致性模型。这些概念在对话历史中都有对应物,但在任何“仅追加和渲染”的系统中,这些概念都被隐式地处理了——而且通常处理得很糟。

轮次索引(turn index)是你首先要构建的东西。它不需要很复杂:每轮一个整数 ID、一个时间戳、角色、由小型分类器生成的意图标签(如 questioncorrectioninstructiontool_resultchitchat)以及内容哈希就足够开始了。有了这个索引,你就可以编写类似“给我用户发出指令的最后三轮对话”之类的查询,而无需扫描整个日志。大多数 Agent 从不编写此类查询,因为其数据结构不支持——它们只是按顺序获取尾部,而不考虑相关性。

意图层面的总结变成了基于索引的物化视图。你不需要重播每一轮对话,而是携带一个运行中的压缩表示:用户正在寻找航班,指定了周二出发,排除了红眼航班,对价格敏感但对航空公司要求灵活。这种总结是对底层日志的反规范化处理,按照应用程序控制的计划进行异步刷新。与任何物化视图一样,它可能会与源数据失去同步——下文提到的评估规范就是为了让你正视这种偏移。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates