一个长时间运行的智能体(agent)会话最初以 2K 上下文开启,现在却在为 40K token 的“死状态”买单。第三轮的检索结果、智能体早已跳过的目录列表、工具调用返回的 JSON 转储(即便其答案只是一个整数)—— 所有这些都在随后的每一次推理调用中如影随形,全额计费,并拖累注意力。这种模式在结构上与内存泄漏完全一致:无引用的数据无限制增长。但没有剖析器(profiler)能发现它,因为泄漏并不存在于进程内存中。它存在于对话历史里,而大多数智能体框架在发布时都没有配备回收机制。
成本同时体现在两个地方。token 账单呈二次方增长 —— 一个 20 步的循环,每一步贡献 1,000 个 token,累计产生约 210,000 个输入 token,而不是 20,000 个,因为之前的每一轮对话都会在后续的每一次调用中重新计费。而且模型本身也开始退化:当积累了 50K token 的噪声时,即使是拥有 1M token 窗口的模型,在实际任务上的准确度也会出现两位数的下降。你在花更多的钱,让模型更差地去思考它在三轮前就已经解决的问题。
这篇文章讨论的是如何像处理堆(heap)一样对待这些历史数据:如何进行逐轮 token 归因以区分关键上下文与积累的废弃数据,何时对工具输出运行可达性分析(reachability analysis),生产环境中的“上下文垃圾回收(context GC)”环节到底长什么样,以及为什么架构上的修复方案是意识到对话历史是可变状态(mutable state)—— 而非审计日志(audit log)。
泄漏是真实的,而且代价双倍
第一个成本是金钱。Transformer 推理是无状态的:每次调用都会发送整个上下文、之前的每一条消息、每个工具结果以及完整的系统提示词。如果第三轮出现了一个 4KB 的 JSON 对象,它将在后续的每次调用中占据 token,并按全额输入价格计费。如果独立建模每轮成本,一旦计入上下文积累,从业者往往会低估多步工作流成本 3 到 5 倍。正确的思维模型应该是等差数列 n(n+1)/2,而不是 n。
第二个成本是质量。Chroma 在 2025 年的研究测试了 18 种前沿模型在不同输入长度下的表现,发现每一种模型 —— GPT-4.1、Claude Opus 4、Gemini 2.5 —— 都会随着输入的增加而退化,无论其宣称的窗口大小是多少。经典的“迷失在中间”(lost-in-the-middle)效应会导致埋在长上下文主体中的信息准确率下降 30% 以上。单个干扰项就会显著降低基准性能;四个干扰项则会产生复合影响。1M token 的窗口在 50K token 时仍会发生性能腐化。模型卡片上的数字并不是你实际得到的性能。
结构性的洞察在于,这两个成本是耦合的。最便宜的 token 是你从未发送过的 token;最清晰的推理建立在模型真正能关注到的上下文之上。修剪(Pruning)并不是牺牲质量换取成本优化的权衡 —— 它本身就是一种质量优化,顺便降低了成本。将其视为成本项目的团队最终会投入不足,因为单个产品功能的资金节省很少能证明专门工程投入的合理性。而将其视为可靠性项目的团队最终会完成这项工作,因为如果不做,智能体就会随着每一轮对话变得越来越笨。
为什么你无法通过 Grep 发现它
传统的内存泄漏是可以找到的,因为数据存在于你可以附加剖析器的进程中。你可以导出堆,按保留大小排序,泄漏的对象就会赫然在目。对话历史泄漏没有任何这类便利。那个“对象”是你正在与之通信的服务器上 JSON 数组中的一个轮次。没有剖析器,没有垃圾回收根集(GC root set),没有保留大小列。智能体框架交给你一个 messages: [...] 数组,你的工作就是不断向其中追加内容。
更糟糕的是,这种泄漏在大多数团队追踪的指标中是无声的。延迟逐渐增加,但每一轮对话仍能完成。成本在月度账单上表现为流量的固定倍数,这看起来像业务增长,而不是浪费。第一个信号通常是质量回退 —— 智能体忘记了第一轮给出的约束,忽略了用户重复的指令,或者根据与用户声明偏好相矛盾的检索结果产生幻觉。当有人将其与上下文长度联系起来时,团队已经多付了几个月的冤枉钱。
打破这种沉默的诊断举措是逐轮 token 归因。为对话构建器增加监控,给每条消息打上来源标签:system_prompt、user_turn、tool_result:<tool_name>、agent_reasoning、retrieval:<source>。然后,在每次推理调用时,按标签记录 token 计数。在生产环境中运行一周后,你会看到一个看起来极其像内存泄漏图的柱状图 —— 一两个标签类别单调增长,而 user_turn 类别则保持大致恒定。那就是你的泄漏点。
膨胀的类别是可以预见的。来自宽泛操作的工具结果 —— list_files、search_codebase、read_documentation —— 是最严重的,因为它们返回的内容超出了智能体的需求,而智能体只使用了其中一小部分。其次是检索结果的膨胀,因为向量搜索无论 top-1 是否足够,都会返回 top-k。第三是智能体推理轨迹的膨胀,因为早期步骤的思维链(chain-of-thought)几乎从不影响后续步骤。系统提示词本身很少膨胀,但往往分解得不好 —— 无论条件是否触发,每一条条件指令在每次调用时都要付费。
工具输出的可达性分析
一旦你发现了泄漏,接下来的问题就是哪些条目已经失效了。垃圾回收(GC)的类比非常贴切:如果一个工具结果仍在影响决策,它就是“活跃”的;反之,它就是“死亡”的。诀窍在于,对话历史中的活跃性不是静态可确定的——它取决于智能体如何使用该结果,而非结果包含的内容。
可达性的粗糙版本是“最近性”:丢弃早于 N 轮的任何内容。这对于聊天助手非常有效,因为用户的最新消息几乎总是最相关的信号。但对于必须记住会话开始时所述约束的智能体,这种方法就会失效。修复方法是采用带有“固定例外”的最近性策略:被显式标记为约束的消息(如系统提示词、用户陈述的偏好、任务定义)无论存续多久,都不符合回收条件。
可达性的更优版本是“引用计数”。当智能体的下一轮对话引用了之前的工具结果时——通过引用、推理或将其内容传递给另一个工具——就在该轮次上增加一个计数器。在连续 M 轮无引用后,该条目即可被回收。这需要智能体框架能够跟踪引用,而大多数框架并不具备此功能。因此,大多数团队通过检查工具结果文本是否作为子字符串出现在后续的助手回答中来进行近似判断。虽然误报很常见,但精度已足以安全地丢弃那些智能体看了一眼便跳过的、长尾的一次性工具结果。
成本最高昂的版本是“分代式”,模拟了 JVM 风格的垃圾回收。最近的轮次存在于“新生代”中,回收成本较低——任何在过去几轮中未被引用的内容都会被丢弃。较旧的轮次会被提升到“老年代”,它们在基础回收中幸存,但在更长周期的“主回收”中会被处理。主回收步骤是一个总结过程,它将多个旧轮次合并为一个结构化产物:这不再是自然语言摘要的“大杂烩”,而是一个带有 decisions_made(已做决策)、constraints_learned(学到的约束)、tools_used_with_outcomes(工具使用及其结果)等字段的类型化记录。这种结构化形式保留了后续轮次可能需要引用的内容,同时去除了导致上下文膨胀的冗长形式。
去重、截断与固定:手术刀式的工具 在考虑压缩之前,三种成本更低的干预措施可以消除大部分冗余。
“去重”针对的是幂等工具调用。如果智能体在一个会话中列出了三次相同的目录,只有最近的一次列表包含活跃信息;前两次只是同一状态的过时快照。在推理前进行预处理,检测并删除参数完全相同的重复工具调用(仅保留最新结果),通常可以在智能体工作流中回收 15-30% 的累积上下文。同样的模式也适用于检索查询——智能体经常原封不动地重新发布相同的查询,最终得到三份完全相同的 Top-k 结果列表。
“截断”针对的是过大的工具输出。将每个工具结果限制在可配置的 Token 预算内——Alyx 风格的实现会将上限设为 10K Token,并使用二分查找来寻找适合的最大切片。将此与“按需重新获取”相结合:如果后面的轮次需要被截断的部分,智能体将重新发起调用。这本质上是用额外的推理成本换取上下文的纪律性。考虑到上下文成本的二次方增长与单次重新获取的线性成本,这是一个明智的权衡。
“固定”则相反:显式标记那些绝不能被回收的消息。系统提示词始终被固定。用户陈述的约束被固定。任务定义被固定。一小部分“既定事实”——即智能体已明确承诺的输出——也被固定。除此之外的一切都可以被回收。这种纪律强制提示词工程团队思考哪些信息在结构上起承重作用,而不是让对话历史盲目累积。大多数团队发现,他们需要固定的内容比预想的要少得多,这本身就是一个很有用的发现。
压缩:主回收过程 当去重、截断和固定仍不足够时,就需要启动“压缩”。在生产环境中,这种模式是一致的:当活跃上下文超过模型窗口的某个阈值时触发,向后遍历历史记录并保留最近约 20K Token 完整无损,将较旧的部分发送给具有结构化总结提示词的 LLM,并在下一次推理调用中用结构化总结替换旧部分。
有两个实现细节至关重要。首先,“工具调用/工具结果对”绝不能被拆分——回收一个工具结果而丢弃触发它的调用,会使对话处于不连贯的状态,许多模型 API 会直接拒绝此类输入。生产环境中的实现在“可总结”与“不可总结”的边界上进行遍历,以确保这些配对保持完整;安全的切分点始终是在轮次之间,绝不在轮次内部。
其次,总结提示词是起承重作用的产物。自由发挥的“总结目前的对话”所产生的摘要虽然比原文短,但依然松散、冗长,且在多次压缩后仍会膨胀。而要求类型化字段(做了什么决定、应用了哪些约束、使用了哪些工具以及哪些结果重要)的结构化提示词,则能生成智能体在后续轮次中真正可以利用的摘要。没有结构化的压缩只是对冗余的有损压缩。具有结构化的压缩则是将可达性分析转化为类型化的记录,这才是你真正需要的。
需要防范的故障模式是多次压缩之间的“总结偏移”。如果一个会话压缩了两次,第二次压缩就是在总结一个总结。信息损失会不断累加。运营长会话的生产团队要么限制每个会话的压缩次数并强制在达到界限后重新开始,要么将规范事实(偏好、约束、决策)持久化在独立的记忆库中,使其独立于压缩而存续。该存储库在每一轮开始时进行查询,而不是包含在对话历史中,从而完全避开了“对总结进行总结”的问题。
将历史视为可变状态 这背后隐藏的架构认知是:对话历史并非审计日志——它是一种可变状态,将其视为不可变才是问题的根源。大多数 Agent 框架将历史建模为“仅追加”(append-only),因为最初的聊天 API 就是这样设计的。“仅追加”对于计费和可观测性来说是正确的语义,因为你确实需要记录发送的所有内容。但对于当前上下文(active context)来说,它是错误的语义,因为你只需要向模型发送它真正需要的内容。
解决方案是将审计日志与当前上下文分离。审计日志存储在你的可观测性仓库中,采用“仅追加”模式,保持全保真且可查询。当前上下文则是你实际发送给模型的数组,它是对审计日志的一种经过筛选、GC(垃圾回收)、去重、截断,甚至可能经过压缩后的投影。这两者并不是同一种数据结构,也不应具有相同的生命周期。将二者混为一谈的团队所交付的 Agent,会为那些不需要的上下文支付高昂成本;而将二者分离的团队所交付的 Agent,即使在长对话中也能保持敏锐,因为模型读取的是“工作集”(working set),而非“谈话记录”。
随之而来的更难的问题是:究竟应该由 Agent 框架本身负责上下文筛选,还是由应用程序负责?由框架负责会带来便捷的默认设置,但也会固化一些可能并不符合特定产品需求的策略。将其委托给应用程序则能提供完全的控制权,但框架本身不包含回收机制,迫使每个团队都要重复造轮子。正确的答案可能是分层设计:框架提供原语——Token 归属、去重、截断、固定(pinning)、带结构化摘要的压缩——而应用程序则将这些原语组合成特定的策略。这与 JVM 在经历了十年关于内存管理应该是语言的任务还是应用程序的任务的争论后,最终为 GC 确定的形态是一致的。Agent 治理生态系统目前大致正处于这场争论的起点。
今年交付的 Agent 运行的会话将持续数小时而非数分钟,并且会积累数万个 Token 的工作上下文。那些像对待“堆”(heap)一样对待上下文的团队——进行测量、归属分析和回收——将能保持其 Agent 的敏锐度并使账单可控。而那些将其视为谈话记录的团队将会缓慢而代价高昂地发现,他们在自己拥有的每一个产品层面上都交付了一个“内存泄漏”。
会员专享
余下内容仅对会员开放。 会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
— 完整文章,包含未公开存档的部分 — 可落地的工作框架,附带权衡与决策依据 — 新文章抢先看,先于公开发布 登录以继续阅读→ 随时取消 · 一次订阅,畅读全部