专栏 Agent 工程

上下文:工作记忆的预算与衰减

一段对话聊到第三十轮,它开始忘记你前面说过的要求。你以为是模型变笨了。

上下文:工作记忆的预算与衰减

一段对话聊到第三十轮,它开始忘记你前面说过的要求。你以为是模型变笨了。

不是。是那段内容根本没有被带进这一轮。

一、没有记忆,只有重发

前面说过,模型是无状态的。所谓”它记得”,是客户端每次把全部历史重新拼成一个数组再发过去。

所以 Agent 的”记忆”不是一个存储系统,是一段每次都要重新付钱的文本。它有三个直接后果:

  • 记得越多,每次请求越贵、越慢;
  • 窗口有硬上限,塞不下就是塞不下;
  • “忘记”不是删除,是这次没拼进去。

理解了这一点,上下文管理就不是”怎么存更多”,而是每一轮到底该让模型看见什么。

二、四档水位线

宿主不可能等到装不下才处理,那时候请求已经被拒了。所以要提前分级。

Minis 里的策略是一个纯逻辑结构 ContextPolicy,按 token 估算值和模型窗口算出四种状态:

档位 触发线 动作
正常 低于卸载线 照常发
需卸载 超过 offloadThreshold 把大的工具结果挪到磁盘
需压缩 超过 compactThreshold 摘要旧历史
耗尽 接近或超过窗口 停止并提示

这些线的位置有个讲究:它们不是贴着窗口上限画的,而是提前留出 1 万到 4 万 token 的余量。源码注释给的道理很朴素——阈值必须留出空间,让”再来一轮 Agent 回合”仍然装得下。

因为系统的动作永远滞后于压力。你在这一轮末尾发现水位到线了,下一轮的工具结果已经在路上。阈值贴着上限设,等于每次都在赌下一轮不会太长。

三、先外置,再摘要

水位过了第一道线,第一反应不是压缩,是把大的东西挪出去。

一次 rg 可能吐出几千行,一个文件读进来上万字。这些内容对当前这一轮可能有用,对后面十轮大概率没用。所以宿主会把超大的工具结果写到磁盘,上下文里只留一段说明:结果已存到某个路径,需要时用 file_read 去读,附带前若干行摘要。

flowchart LR
    T[工具输出 80KB] --> D[写入磁盘文件]
    D --> S[上下文留 2KB 存根]
    S --> M[模型]
    M -->|需要细节| R[file_read 回读]

这个顺序很重要:外置是无损的,摘要是不可逆的。能挪走的先挪走,实在装不下才动摘要。反过来做,等于先用有损手段处理本来可以无损解决的问题。

四、压缩:切不动就分半

到了压缩这一步,要处理的是一整段历史。做法是让模型读一遍,写一份摘要,然后用摘要替换原文。

问题在于,压缩本身也要发一次请求,而这次请求同样受窗口限制。一段已经接近上限的历史,原样喂给模型照样会被拒。

所以压缩是递归的:整段发不进去,就对半切开,两半分别摘要,再把两份摘要拼起来。还不行就继续切。深度上限是 3,因为每深一层调用数翻倍——1、2、4、8,最坏情况 15 次串行请求,一次八十秒就是一小时。

光限深度不够,还得限总量。实现里另有一个调用预算:整个压缩过程最多 6 次模型调用。深度管的是”往下挖多深”,预算管的是”横向铺多宽”,两个都要有。

源码里还留着一句被删掉的优化:早期会把两份摘要再合并成一份(第三次调用),后来去掉了,因为每段摘要的输出本来就有硬上限,两份拼起来远没到窗口边界,不值得再跑一趟。

这是一条值得记住的经验:压缩链上的每一步都要问”这一步换来了什么”。答不上来就该删。

五、只有报错才知道真的超了

所有阈值都建立在 token 估算上,而估算总会偏。

真实情况是:请求发出去,服务端回一个 400,正文里写着 maximum context length is 1048576, requested ≈1090600 tokens。到这一刻你才确切知道超了多少。

这时候有个陷阱:那条请求的内容已经写进会话历史了,重试会原样重建同一条请求,再被拒一次。换成备用模型也一样——内容没变。

所以需要一个专门的守卫来识别这类错误,并且只做一件事:把超大的那一个部分卸载掉,然后继续。

它认得不是状态码,是文本。400 太宽了,参数错误、内容策略、格式问题都会是 400,对这些情况改历史只会更糟。只匹配明确提到长度的句子,中英文都收。

这个设计有个很诚实的取舍:文本匹配本来不可靠,但两个方向的代价都是可控的。误判一次,多外置一个文件,内容还在,回合继续;漏判一次,就是现在的行为,没有变差。在不确定的地方,选择后果有界的那条路。

六、窗口可能是猜的

还有一个更基础的问题:宿主怎么知道模型的窗口有多大?

正常情况,模型的元数据里有 contextWindow 字段,直接用。但总有填不上的时候——刚发布的模型、中转站自造的名字、手填的条目。

于是有了按名字猜的启发式:名字里带 claude-*-1m 的按一百万算,带 gemini-2.5-pro 的按对应档位算,认不出来的落到底线。

猜错两个方向都难受。猜大了,用户以为能用满,结果被服务端拒;猜小了,明明能装的内容被提前压缩。

所以实现里有条纪律:同一套猜测规则存在两个副本(一个给循环用,一个给设置里的滑块用),两份必须给出一样的数,并且有测试对每个模型家族断言这个一致性。理由写在注释里——滑块的上限如果高于循环的真实窗口,用户就会选到一个注定被拒的值。

七、另一种解法:分叉

压缩是把长的变短。还有一种做法是不变短,分出去。

一段探索性的对话走到岔路,与其把前面的全部摘要掉,不如从某个点分出一个新会话,主干保持原样。分叉出去的会话独立生长,需要的时候再回来。

这和后面要讲的子 Agent 是同一个思路的两种形态:一个窗口装不下的东西,可以切成几个窗口装着。区别只是分叉保留完整历史,子 Agent 只回传结论。

小结

上下文管理听起来像存储问题,其实是取舍问题:这一轮该看什么,上一轮的细节值不值得占位置,什么可以先挪走,什么必须留在眼前。

判断标准只有一个:模型下一步要做的决定,需要哪些信息。其余的,都是成本。


下一篇接着这个话题往下走:既然每一轮的内容都会被重新看见,那”看见的结果对不对”,就成了下一个要命的问题。