第 2 课:对话历史管理:追加、截断、摘要
学习目标:
- 解释为什么对话历史在默认情况下只增不减
- 说出截断策略会丢掉什么、保留什么,以及它会破坏什么结构
- 区分摘要压缩(compaction)和工具结果清理(tool-result clearing)各自解决的问题
- 能根据窗口用量和内容类型,判断该用哪种机制应对历史膨胀
前置要求:完成第 1 课,理解上下文窗口的构成 | 上一课 第 1 课 << | 下一课 第 3 课 >>
追加是默认动作:历史为什么会一直涨
第 1 课说过,模型看到的历史是宿主应用每次重新发过去的。那具体是怎么发的?最朴素的实现是追加:每一轮结束,把这一轮新产生的消息(用户的话、模型的回复、工具调用和结果)接在已有的 messages 数组末尾,下一轮把整个数组原样发出去。
官方文档把这个默认行为说得很直白:随着对话推进,每一轮的用户消息和模型回复都会在上下文窗口里累积,前面的每一轮都被完整保留下来。1 没有人主动去删,历史就只会往上涨——十轮对话之后,窗口里装的是十轮的全部内容,不是最近一轮的摘要,也不是自动筛选过的重点。
这在短对话里毫无问题。但对一个跑很久的 Agent 来说,问题会越滚越大:每一次工具调用的完整参数和返回结果都会被塞进历史,一个反复读文件、跑命令的任务,几十轮下来 messages 数组能轻松涨到几万字。第 1 课讲过窗口是有容量上限的,装得越满离撞上限就越近;更麻烦的是上下文腐化——历史越长、越杂,模型从里面找到当前真正有用的那句话就越难1。任由历史只增不减,迟早要为这两笔账付出代价。
截断:最简单也最粗暴的办法
最直接的应对办法是截断:窗口快满了,就把最早的一批消息直接砍掉,只保留最近的 N 轮。这个思路实现起来最简单,不需要额外调用模型,不需要设计摘要格式,写一行 messages.slice(-N) 就能生效。
但截断丢掉的东西是不可逆的。如果被砍掉的那批消息里,有一句用户在第 3 轮说过的关键限定条件("预算不超过 5000 元"),而 Agent 现在在第 40 轮准备下单——这个信息就这么消失了,模型不会知道自己曾经"看过"又"忘了"它,只会表现成好像从来没被告知过。
截断还有一个更隐蔽的坑,跟上一门课《Agent 工具调用基础》第 2 课讲过的往返协议直接相关:如果截断只按"最近 N 条消息"简单粗暴地切,很可能切在一对 tool_use / tool_result 中间——留下了模型发起调用的那条 assistant 消息,却把紧跟着的 tool_result 消息切没了。这样的历史发给模型,协议本身就是断裂的:官方文档明确要求 tool_result 块必须紧跟在对应的 tool_use 块之后,收到"tool_use ids were found without tool_result blocks immediately after"这类报错,就是配对断裂的信号2——模型会看到自己"发起过一次调用",却永远等不到那次调用的结果,下一轮请求会直接报错。
摘要压缩(compaction):把窗口压成一份摘要
截断的问题是"整段丢弃",那有没有办法既清出空间、又不是彻底丢掉信息?这就是摘要压缩要解决的问题。官方 Cookbook 给出的定义是:摘要压缩把上下文窗口的内容提炼成一份高保真摘要,让 Agent 在对话变长之后,依然能以最小的性能损耗继续下去。3
跟截断的"整段删除"不同,摘要压缩是"整体重写":早前的对话历史被压缩成一份高保真摘要,取代原来那一长串原始消息,继续留在窗口里的开头位置。这份摘要保留的是"发生过什么、结论是什么",丢掉的是逐字逐句的原始对话细节。
官方文档里给出了这套机制的具体参数:它有一个默认的触发阈值——窗口用量达到 150K token 时自动触发;阈值可以自己配置,但最低不能低于 50K token,这是服务器端强制的下限34。每次触发都是一次离散的替换动作:原来那一大段历史被替换成摘要,后续的新消息继续正常追加在摘要之后。注意这不是"只会发生一次"——官方明确说长对话可能发生多次压缩,最后一个压缩块反映提示词的最终状态4;再次压缩时,先前的压缩块也会和其余历史一起被并入新的摘要3。
摘要压缩不是没有代价的。压缩这个动作本身要消耗一次额外的模型调用(摘要模型要跑一遍)3,而且压缩出来的摘要不管写得多细,终究是原始内容的有损版本——如果后面某一步任务恰好依赖一个被摘要掉的极细节(比如某个变量的精确拼写),这个细节有可能在摘要里被丢掉。这也是为什么它更适合"整体上下文变得太大"这种粗粒度的问题,而不是所有历史膨胀场景的万能解。
工具结果清理(tool-result clearing):只清理会过时的那部分
历史膨胀有一大块贡献者是工具调用本身。Agent 每读一次文件、每跑一次命令,完整的返回内容都会被塞进历史——读一个几千行的文件,那几千行原样留在 messages 数组里,哪怕十轮之后早就没人再需要这份内容的细节。官方 Cookbook 把这个问题单独点了出来:工具使用本身带来的膨胀,Agent 每次调用工具、结果就往上堆一层,决定该留多少工具输出,是管理上下文里越来越重要的一环。3
工具结果清理就是专门针对这一块的机制:它只丢弃那些陈旧且可重新获取的工具结果,同时保留"这次调用发生过"的记录。3 关键区别在这里——清理丢掉的是工具返回的具体内容(比如那几千行文件内容),但不会把"Agent 曾经调用过 read_file,参数是这个路径"这条记录也抹掉。如果后面又需要这份内容,Agent 知道自己调用过什么工具、传过什么参数,可以决定要不要重新调用一次去把它取回来。
它的触发阈值和保留策略,同样有明确的默认值:窗口用量达到 100K token 时触发,触发后默认保留最近 3 次工具调用的完整结果,更早的工具结果被清理掉。3 100K 的触发阈值比摘要压缩的 150K 更低,这也符合它的定位——先把工具输出这块"最容易膨胀、又最容易重新获取"的部分处理掉,不够的话再交给摘要压缩去处理整体窗口。
三种机制怎么选:一个心智模型
到这里已经出现了两种机制,再加上第 3 课要讲的外部记忆,官方 Cookbook 给出了一个简洁的心智模型,把三者的分工说清楚了:摘要压缩在窗口整体涨得太大时压缩整个窗口,工具结果清理丢弃窗口内陈旧且可重新获取的数据,记忆把信息移出窗口、让它能跨会话留存下来。3
三者的优先级和适用场景不是互相竞争的,而是分层的:
- 工具结果清理处理的是"这部分内容还在窗口里,但已经过时、丢了也能重新拿回来"——针对性最准,代价最小。
- 摘要压缩处理的是"整个窗口都涨得太大了",不区分内容来源,统一重写成摘要——处理范围更广,但是有损的、要多付一次模型调用的代价。
- 记忆(下一课细讲)处理的是"这些信息不该只活在这一次对话里,得留到下一次会话还能用"——它解决的根本不是"窗口装不下"的问题,而是"这次对话结束后,窗口里的一切都会消失"的问题。
回到本课开头的问题:历史只增不减,原因是没有人主动去清理它。截断、摘要压缩、工具结果清理,是三种不同代价、不同适用场景的清理方式——选哪一种,取决于你想保留什么、愿意为保留付出多大代价。
小结
- 对话历史默认只增不减:每一轮的消息都会累积在窗口里,前面的轮次被完整保留,没人主动清理就会一直涨
- 截断最简单,但丢弃的信息不可逆,而且如果切割点落在一对
tool_use / tool_result 中间,会破坏工具调用的协议结构
- 摘要压缩把整个窗口的历史重写成一份高保真摘要,默认在 150K token 触发(阈值最低不能低于 50K,服务器端强制),代价是有损、且要多付一次模型调用;长对话可能发生多次压缩,先前的摘要块会被并入新摘要34
- 工具结果清理只丢弃陈旧且可重新获取的工具输出、保留调用记录,默认在 100K token 触发、保留最近 3 次调用结果,针对性比摘要压缩更强
- 三者分工不同:清理处理陈旧可重取数据,压缩处理整体窗口过大,记忆处理跨会话留存——选哪种取决于膨胀的具体来源和能不能承受丢失细节
>> 第 3 课:外部记忆:文件与检索