有状态多轮对话基础设施:超越传递完整历史记录
每一个对话式 AI 功能的 Demo 都做同一件事:向模型传入一个消息列表,然后打印响应。这条快乐路径在 Jupyter Notebook 里运行顺畅、看起来很美,并让你顺利拿到上线许可。然后你到了生产环境——p99 延迟在高峰期开始悄悄爬升。一个月后,有客户投诉说助手"忘记"了会话早期的所有内容。六周后,你的会话存储在某次产品发布期间撞上了内存上限。
根本问题在于:「传递完整对话历史」根本不是一种会话管理策略,它是会话管理策略缺失的表现。
二次方陷阱
让大模型强大的注意力机制有其代价:计算开销随输入长度二次方增长。对话历史翻倍,推理成本大约翻四倍。在每秒 500 个请求、p95 目标为 1 秒的负载下,任何单个组件只要 p99 延迟达到 200ms,就会开始主导你的尾部延迟数字。测试阶段看起来可控的对话历史,会成为你没有规划到的瓶颈。
Token 爆炸比直觉预期的要快得多。一次 20 轮的对话,从消息内容、工具调用结果和助手推理中积累的原始 token 可达 5,000–10,000 个。而真正需要用来支撑下一次响应的信息,通常只有 500–1,000 个 token:用户当前的意图、建立上下文的几次近期交流,以及会话早期做出的任何明确承诺。其余的都是冗余,只会消耗延迟和金钱。
研究证实,性能退化遵循非线性曲线。前沿模型从约 1,000 个上下文 token 开始就出现可测量的准确率下降——远低于宣传的上下文窗口大小——有些模型在处理本可在 100 个 token 内完成的任务时,一旦将其埋在冗长历史中就会失败。"迷失在中间"效应进一步放大了这一问题:模型系统性地偏向输入窗口开头和结尾的 token,因此较早的轮次不只是在消耗金钱——它们还在主动稀释近期轮次的信号。
状态可以存放的三个地方
在选择压缩策略之前,你需要决定会话状态存放在哪里。这些选择并不等价。
内存存储(Redis) 提供亚毫秒级的读取延迟和内置的 TTL 管理。Redis 的语义会话管理器甚至可以利用嵌入相似度,只检索上下文相关的消息片段,而非完整历史。代价是持久性:如果会话存储重启,活跃对话就会消失。对于大多数聊天应用来说这是可以接受的;但对于任何涉及重要多步骤工作流(预订、购买、多日研究线程)的场景,则不然。
分布式数据库(DynamoDB、Postgres) 能持久化对话记录,比单个 Redis 实例更好地处理水平扩展。代价是 p99 延迟:DynamoDB 在正常负载下的读取没问题,但在高并发、大会话负载下,你会看到单元测试中不会出现的尾部延迟峰值。如果你在每次推理调用的热路径上执行会话状态检索,请根据 p99 而非平均性能来选择数据存储。
混合方案 将热态与冷态分离。当前会话的近期轮次存放在 Redis 以便快速访问;较早的对话历史、提取的事实和跨会话记忆存放在持久化存储中。热路径上的检索命中缓存;后台进程写穿到持久化层。这增加了运维复杂度,但这正是生产部署中既需要持久性又需要低延迟时实际采用的方案。
真正有效的历史压缩
核心决策在于截断、压缩与检索之间的选择。
截断 是大多数框架的默认行为:当上下文窗口填满时丢弃最早的消息。它简单、可预测,但对大多数应用来说是错误的。用户在对话最复杂的时候失去了上下文。模型会"忘记"会话早期做出的承诺。更糟糕的是,这种失败是无声的——模型不会告诉用户它已无法访问对话的早期部分。
滑动窗口 + 滚动摘要 是大多数团队最务实的方案。保留最后 N 轮的完整内容——通常是 8–15 次交流,取决于平均轮次长度——并维护所有更早内容的压缩摘要。一次辅助模型调用将最老的一批消息总结为 200–300 个 token 的浓缩上下文("用户正在为医疗客户构建数据管道;希望符合 HIPAA 合规的存储;因成本原因已决定不使用 Snowflake")。该摘要在每轮新对话开始时被前置到活跃窗口中。二次方爆 炸变得可控,因为窗口保持有界。
运维要注意:总结调用会在每第 N 轮增加延迟和成本。请为此做好预算。如果你的应用能容忍摘要尚未更新的短暂窗口期,可以将其设为异步。
选择性保留 采用更刻意的方式。在压缩前,按重要性对消息内容进行分类:
- 必须逐字保留: 用户明确的偏好、关键决策、助手的承诺、用户纠正过的内容
- 可以摘要: 常规的澄清交流、重复的问题、现已过时的上下文
- 可以丢弃: 确认回复、填充轮次、现在可以从来源检索的信息(如果你有 RAG,不需要存储检索到的内容块,只需存储检索键)
对于简单应用,重要性分类可以基于规则;对于复杂应用,可以基于大模型。一个异步运行的小型分类器模型,成本远低于将每个 token 都保留在上下文中。
检索增强历史 将对话状态完全移入向量存储。不再注入原始历史,而是对当前查询进行嵌入并检索语义相关的交流。好处是:有效无限的对话深度,上下文成本有界。代价是:检索延迟、嵌入基础设施,以及一个不那么明显的故障模式——向量搜索返回了错误的历史交流。用户问"我们关于截止日期的决定是什么?",结果检索回来的是关于预算截止日期的轮次,而他们说的是项目截止日期。
那个会搞垮你系统的 p99 会话
每个生产级 AI 应用都有一个没人规划到的 p99 会话。它是那 个因为用户有真正复杂的问题而进行了 200 轮的客服对话。它是那个在两小时自主运行中积累了工具调用输出的研究助手会话。它是那个三天后回来、却期望模型记住每个细节的用户。
朴素的方案会触碰硬性限制,API 返回错误。用户看到一条通用的失败消息,丢失了他们的工作。这是最糟糕的结果。
优雅的结果需要在设计会话存储时进行明确的预算核算。跟踪每轮的 token 消耗并维护一个累计总量。当预算超过某个阈值——比如上下文窗口的 60%——开始对较早的历史进行积极压缩。在 80% 时,从滚动摘要切换到关键事实提取:完全丢弃叙事结构,只保留核心事实。在 90% 时,触发用户通知,告知现在处于摘要上下文模式。
当会话存储超出其大小预算时,运维处理流程从以下问题开始:
- 这是 p99 异常值还是系统性变化?检查预算超支是集中在某一会话类型还是影响了一个群体。
- 压缩管道是否跟得上?异步总结在流量峰值期间可能落后,导致会话增长速度超过压缩速度。
- 会话存储本身是否成为瓶颈?高基数的并发会话可能在驱逐策略跟上之前耗尽 Redis 内存。
这三个问题的答案决定了修复方式是策略变更(调整压缩阈值)、基础设施变更(扩展会话存储),还是产品变更(为异常长的对话添加会话重置提示)。
大模型消息 API 实际上对你的期望
一个会晚期咬人的细节:大多数大模型 API 是无状态的。Claude Messages API、OpenAI Chat Completions API 及类似接口不维护会话状态。你每次发送完整的对话;它们返回下一条消息。状态管理完全是你的责任。
这意味着你的会话基础设施不是可选的脚手架——它是你正在构建的生产系统。你 API 调用中的"messages"数组是你会话管理层的输出,而非原始存储的透传。
具体而言:会话存储持有发生过什么的权威记录。在每次推理调用之前,你运行一个检索和压缩步骤来构建 messages 数组:系统提示、旧轮次的压缩历史、近期轮次的完整内容,以及当前用户消息。API 看到的是一个格式良好、符合预算的上下文。而实际存储的历史,可能远大于你在任何给定轮次发送的内容。
对话状态的生产监控
一旦构建了会话管理,你需要对其进行观测。重要的指标有:
每轮 token 消耗 告诉你会话是在可预测地增长还是出现了意外峰值。突然增加意味着用户在粘贴大量内容、工具调用返回了冗长输出,或某次代码变更无意中改变了历史中包含的内容。
按百分位的会话大小(p50、p90、p99)提供余量可见性。如果你的 p99 会话处于设计预算的 60%,你有余地。如果处于 85%,你距离一次生产事故只差一个病毒式传播周。
压缩管道延迟 衡量你的异步总结进程落后了多远。在流量峰值期间,如果压缩落后而会话增长速度超过压缩速度,你会在看到准确性问题之前先看到存储成本飙升。
历史检索延迟 针对混合架构,跟踪在热路径上拉取会话状态的 p99 成本。这里的任何回归都会直接影响用户可见的延迟。
将这些作为结构化遥测数据发出——OpenTelemetry spans 非常适合将会话检索和压缩步骤作为独立操作进行追踪。当你凌晨 2 点因对话质量下降而被叫醒时,你需要立刻知道问题是在检索(注入了错误的历史)、压缩(丢弃了太多内容),还是推理(模型行为本身)。
你没有设计的对话拓扑
双向聊天是最简单的情况。当对话拓扑偏离简单的用户-助手交替时,故障模式开始变得有趣:
长工具调用链,智能体依次进行数十次工具调用,用用户从未看到的工具输入和输出膨胀上下文。每个工具结果都被添加到消息历史中,原始负载可能非常巨大。解决方案:在存储工具输出之前先对其进行摘要。保留结果,而非原始 JSON。
多用户会话——多个人与同一智能体共享上下文——没有标准的基础设施模式。大多数生产 AI 产品会悄悄地放弃这个用例。根本挑战是冲突解决:两个用户同时提交消息需要串行化,而智能体的状态必须在并发写入中保持一致。
数天或数周后的会话恢复 需要不同于会话内压缩的明确处理。当用户回到一个月前的对话时,智能体应该注入一个上下文刷新摘要,而非检索完整的历史轮次。"上次你在处理 X,并已决定 Y" 比注入 200 轮历史更有用。
