跳到主要内容

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

· 阅读需 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 从不编写此类查询,因为其数据结构不支持——它们只是按顺序获取尾部,而不考虑相关性。

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

淘汰策略(eviction policy)是大多数团队发现其架构崩溃的地方。截断最旧的轮次是基于到达顺序的 LRU,这是错误的:关键轮次通常出现在对话早期(例如“作为审查特拉华州 LLC 运营协议的法律助理”),却被首先淘汰,而第 28 轮的一轮新鲜闲聊却被保留了下来。基于相关性的固定淘汰策略会将轮次分类为关键轮次(用户发出的系统指令、持久约束、用户引用的命名实体),并防止它们被淘汰,即使它们的位置在不断后移。对话的其余部分则可以被淘汰,淘汰策略倾向于中间部分,因为由于众所周知的“中间迷失”效应,模型无论如何也不太可能关注到那里。

每一轮的查询计划是滚动回放架构无法实现的终极方案。并非每一轮都需要相同部分的历史记录。用户问“我刚才说什么了?”需要前两轮的高保真记录;用户问“我们最后定的是哪家航空公司?”需要针对索引中约束子集的定向检索;用户开始一个新的子任务时,几乎不需要任何历史记录——系统提示词和最近的意图就足够了。查询计划决定在组装提示词之前加载哪一部分片段以及保真度如何。这一决策是整个系统中效能最高的调节旋钮,而对于那些仍在思考“长度为 N 的尾部”的团队来说,它是不可见的。

每个人都在重复发明的热/温/冷分层存储

当一个团队深入思考查询计划和数据驱逐(eviction)时,他们总会得出一个分层存储模型。在任何认真对待这个问题的框架中,这个模型看起来都大同小异。有一个热层(hot tier)——即上下文内的工作集,保持小规模、近期且承载关键任务。有一个温层(warm tier)——压缩后的摘要和意图提取,检索成本低且定期刷新。还有一个冷层(cold tier)——完整的对话记录和提取的事实,针对相似度搜索建立索引,仅在需要时调入内存。

Letta(MemGPT 系列工作的生产级演进)将这些分别命名为核心(core)、召回(recall)和存档(archival)。Mem0 和 Zep 推出的变体也具有相同的形态,只是在延迟和整合方面有不同的权衡。命名各异,但结构一致。结构之所以趋同,是因为它与 CPU 缓存层次结构或数据库缓冲池的结构相同:一个快速的小型层、一个较慢的大型层以及一个冷备份存储,它们之间有明确的页面调度操作。在数据库领域,这并无新意。新意在于意识到你正在构建一个数据库。

这里要避免的陷阱是将分层视为一种“特性”而非“契约”。热层受 Token 经济学的限制,有严格的大小预算。温层受摘要重新推导频率的限制,有新鲜度预算。冷层受检索延迟的限制,有召回预算。如果这些预算没有被显式强制执行——如果由于没人处理驱逐信号导致热层无限增长,或者由于合并任务只是“尽力而为”导致温层从不刷新——那么这些分层就不再作为层次结构发挥作用,而是变成了一个步骤繁琐、成本高昂且扁平的日志。

评测规范捕捉静默失败

“智能体忘记了我们说过的话”是最常见的生产环境投诉,但却不是最常被修复的问题,原因在于内存失败是静默发生的。没有异常报错,没有日志输出,聚合指标中也没有明显的倒退。智能体只是给出了一个稍差的回答,因为早先的关键对话轮次被换出了,或者摘要在压缩时丢失了约束,再或者检索抓取了过去错误的片段。聚合准确率看起来很正常,但用户却在悄无声息中受到损失。

捕捉这种失败的评测规范(eval discipline)在构建时就必须是长周期的。LOCOMO 及类似的基准测试会生成平均包含数百个轮次和数万个 Token 的多会话对话,并设计问题来探测单跳召回、跨轮次的多步推理、时间顺序以及早期陈述被撤销的对抗性案例。重点不在于对模型本身进行抽象评分,而是在真实的驱逐压力下对存储系统进行评分。在这种压力下,智能体必须从一个语料库中检索出第 4 会话第 37 轮次确立的事实,而此时第 37 轮次早已从热层中被驱逐出去了。

关键的设计细节是,评测应该在启用驱逐机制的情况下运行,而不是在无限上下文的情况下运行。那些在百万 Token 窗口中加载完整历史记录来进行基准测试的团队,衡量的是模型的召回能力,而不是他们系统的召回能力。他们的生产智能体是在截断、摘要和检索的情况下运行的,评测必须反映这一点,否则它提供的信息毫无用处。需要长期跟踪的问题是:当热层存满时,驱逐策略是否保留了关键轮次?当摘要过时时,智能体是会标记不确定性还是会一本正经地胡说八道?当检索抓取了错误片段时,是否有防护栏在答案发出前捕获它?大多数团队对这些问题没有答案,因为他们从未运行过提出这些问题的评测。

选择数据库

一旦你可以用数据库术语来描述聊天历史,实现问题就不再是存在性的难题,而变成了普通的工程问题。你不是在从零开始构建智能体内存系统,而是在选择一个适合你工作负载的数据库。这种工作负载是读密集型的,且带有仅追加写入(append-only writes);访问模式混合了点查询(point lookups,如最后一轮、特定意图)、范围扫描(range scans,如最近 N 轮)和相似度搜索(语义相似的历史内容);且规模随活跃会话数乘以对话长度而增长。

Redis 是一个合理的热层后端存储:低延迟的点读取、TTL 驱动的驱逐、用于对话轮次索引的哈希结构,且现在的搜索模块包含的向量索引已足以满足温层检索的需求。SQLite 是答案,当工作负载是针对单个用户的,且智能体运行在边缘侧或单租户容器中时——整个聊天历史足够小,可以存储在一个文件中,内置全文搜索是其内置模块,且运维面几乎为零。当聊天历史需要与其它应用状态一起参与事务,或者团队已经拥有 Postgres 运维经验且不想增加新系统时,带有 pgvector 的 Postgres 是首选。只有当上述方案都不匹配访问模式时(这种情况很少见),自定义的日志结构存储(log-structured store)才具有合理性。

这种选择并不神奇。这与你为任何其他读密集型工作负载所做的选择是一样的,唯一的约束是热工作集必须适配 Token 预算而非内存预算。开启这一系列选择的架构认识虽然细微但却坚定:会话状态是智能体产品的“数据层”。它不是聊天界面的副作用。它不是免费的。它有成本形态、访问模式和失败模式,值得你的团队像对待任何其他生产级存储一样投入精力。一旦你这样看待它,“智能体忘记了我们说过的话”就不再是一个谜团,而变成了一个带有已知调节杠杆、可处理的工程问题。

References:Let's stay in touch and Follow me for more thoughts and updates