Appearance
第 13 章 上下文的剪裁与压缩
本章要解决的问题
第 11 章让 Agent 按需读取代码,第 12 章又用分层和渐进加载阻止无关内容过早进入。但与任务相关的 grep、read、测试日志和工具调用仍会不断进入对话历史。前两章解决“取什么、什么时候取”,本章处理“已经取进来以后怎么管”。任务运行得足够久以后,模型不仅会遇到长度上限,还会被大量已经失效的细节干扰。
本章不只回答“怎样生成一份摘要”,而是把完整过程讲清楚:什么内容在进入历史时就该限长,什么时候开始清理,旧历史从哪里切开,摘要失败后怎样恢复,以及 codex、grok-build、dsh、pi 分别怎样取舍。
13.1 先看一个会翻车的任务
用户让 Agent 修一个测试失败。Agent 先读了配置文件,又运行了全量测试,随后搜索了十几个调用点。到第 30 轮时,对话里可能同时存在:
- 用户最初的目标和后来补充的限制;
- 已经被推翻的三个猜测;
- 多次重复读取的同一段代码;
- 一份几万行的测试日志;
- 已修改文件、当前差异和还没跑完的命令;
- 最近一次失败真正需要的二十行输出。
最容易想到的做法是“窗口满了就总结一下”。这还不够,因为此时至少有四个不同问题:
| 动作 | 处理对象 | 常见做法 | 主要风险 |
|---|---|---|---|
| 限长 | 单次过大的工具结果 | 只返回开头、结尾和“省略了多少内容” | 关键内容可能恰好在中间 |
| 剪裁 | 已经进入历史的低价值内容 | 删除或替换旧日志、重复结果 | 删除后可能无法重取 |
| 压缩 | 一段仍有价值、但原文太长的历史 | 让 LLM 生成可继续工作的摘要 | 摘要会误解、遗漏或截断 |
| 隔离 | 预计会产生大量中间信息的子任务 | 交给子 Agent,只返回结论和产物位置 | 子 Agent 得不到主任务的隐含背景 |
这四种动作不是同义词。限长和剪裁可以完全由程序执行,结果稳定、便宜;压缩需要模型理解任务,信息保留得更聪明,但也更慢、更难验证;隔离是在信息进入主上下文之前解决问题。
13.2 一套完整的处理流程
这张图里最重要的不是某个百分比,而是处理顺序:
- 入口先限长。 一次命令返回十万行时,不要先把十万行全部塞进历史,再指望后面压缩。
- 每轮重新计量。 模型窗口不仅要容纳已有输入,还要为下一次回复、工具参数和错误修复留空间。
- 先做确定性的剪裁。 能从文件重新读取的日志、重复工具结果,优先用程序缩短。
- 仍有压力再生成摘要。 摘要处理的是“不能直接丢,但没必要保留全部原文”的历史。
- 摘要成功后再切换。 生成摘要和替换历史是两个步骤;中间崩溃时,旧历史必须仍然可用。
一个最小但不草率的控制逻辑可以写成下面这样:
text
工具结果进入历史前先限长
重新计算下一轮需要的 token
如果空间足够:继续执行
否则:
剪裁可重取的旧工具结果
重新计算
如果仍然不足:
选择不会拆开工具调用和结果的旧区间
生成“任务交接摘要”
检查摘要是否完整、是否真的更短
完整保存成功结果后,下一轮改用“摘要 + 近期原文”真正困难的部分都藏在这些普通动词里:怎样估算、什么算“可重取”、切点是否合法、摘要是否合格、保存到一半崩溃怎么办。下面逐项展开。
13.3 什么时候开始处理:不要等窗口已经爆掉
假设模型窗口是 128K token,当前输入已经占了 120K。理论上还剩 8K,但这 8K 未必够用:模型可能需要输出 6K,又调用一次测试工具带回 10K,随后还要读文件修错。
因此触发条件更接近:
text
已有上下文
+ 下一轮最大输出预留
+ 可能进入历史的工具结果余量
+ 失败恢复余量
>= 可用窗口四个 Agent 并没有采用同一个固定比例:
- codex 主要使用模型配置给出的自动压缩上限,同时受模型硬窗口约束;切换到更小窗口的模型时,也会重新判断是否要先压缩。它不是统一写死一个百分比。
- grok-build 默认在窗口使用率约 85% 时自动触发,并同时在模型请求前检查工具结果是否已经把估算值推过硬上限。
- dsh 默认在窗口的 80% 产生压力时启动处理。这个阈值可以按具体 provider 和模型覆盖。
- pi 使用
contextWindow - reserveTokens。默认预留 16,384 token 给模型输出,而不是把触发点表达为固定百分比。
这些数字反映的是产品选择,不是行业标准。长输出模型应该多留空间;工具结果可控、任务较短时可以晚一点;模型服务经常少报或迟报 token 时,应该更保守。
13.4 第一道防线:先处理单次过大的工具结果
工具输出通常是上下文增长最快的来源。一次全量测试、构建日志或大文件读取,可能比之前几十轮对话还长。
dsh:先剪工具结果,再决定要不要调用模型摘要
dsh 把工具结果剪裁做成独立步骤。默认情况下,一条文本工具结果超过 8,192 个 Unicode 字符时,保留前 4,096 个和后 1,024 个,中间替换为明确的省略标记。
这里有三个容易忽略的细节:
- 它按 Unicode 字符处理,不会把 emoji 这类字符从中间切坏;
- 非文本块仍保留原来的顺序;
- 剪裁结果作为新的历史节点保存,并记录它替换了哪一条原始结果,而不是偷偷修改内存里的字符串。
剪完以后会重新计量。如果已经回到安全范围,就不需要支付一次 LLM 摘要调用。这是 dsh 的一个核心取舍:便宜、确定性的处理先做,昂贵、有损的语义压缩最后做。
pi:主会话保留原结果,摘要请求只看短预览
pi 在为“摘要模型”序列化历史时,会把每条工具结果限制在前 2,000 个字符,并说明还有多少字符被省略。它解决的是另一个问题:避免“为了压缩主上下文而发出的摘要请求”自己先超出窗口。
这不等于主会话里的原始工具结果已经被删除。pi 的完整会话树仍保留原事件,摘要模型只收到一个更短的文本版本。
codex:正常请求限长,远程压缩前还会再瘦身
codex 在组装模型历史时会按模型策略限制函数调用输出。走远程压缩路径前,如果完整历史仍然放不进请求,还会先重写过大的函数输出,再把处理后的历史交给压缩服务。
grok-build:压缩输入有逐级降级,而不是一次粗暴截断
grok-build 支持让摘要模型先看尽量接近原始的对话;启用这一配置时,如果摘要请求本身超出窗口,会按下面的顺序降级。未启用时则直接从精简表示开始:
text
完整输入
→ 仍保留原意,但裁到“窗口 - 摘要输出预留 - 工具说明”以内
→ 改用更精简的对话表示,并压到约 70% 窗口这比“失败后再发送完全相同的请求”更合理:上下文超限是确定性错误,同样的输入重试多少次都不会成功。
grok-build 还实现了一种可选的“两段式预压缩”。在真正到达自动压缩线之前,它可以在后台先总结大约 95% 的旧前缀,得到一份临时笔记,并记录这段前缀的指纹。真正需要压缩时,再把临时笔记与刚刚新增的尾部一起处理。只有当前历史前缀仍与指纹一致,临时结果才会被采用。
这样能把一部分摘要延迟移到后台,但也会产生没有用上的模型调用:用户可能在到达压缩线前就结束任务,或者历史被编辑,导致预先生成的笔记作废。这是一种用额外成本换关键路径延迟的做法,不适合直接照搬到所有 Agent。
13.5 第二道防线:切点不能只按 token 一刀切
假设历史是:
text
assistant: 调用 read_file,call_id = 42
tool: call_id = 42,返回文件内容
assistant: 根据文件内容得出结论如果在工具调用和工具结果之间切开,下一次请求里就会出现“没有结果的调用”或“没有调用的结果”。很多模型 API 会直接拒绝这种消息序列。
成熟实现至少要满足:
- 不拆开工具调用和对应结果;
- 尽量从完整用户轮次的边界切开;
- 单个轮次本身太大时,要有专门的降级方案;
- 近期正在修改的代码和运行中的任务不能只靠泛化摘要保留;
- 切点选择后到摘要提交前,如果历史发生变化,不能继续覆盖旧范围。
四家的切点策略差异很明显。
codex:本地压缩后只保留近期用户消息和交接摘要
codex 的本地压缩并不是简单保留“最近若干条完整消息”。摘要生成成功后,它会重建模型可见历史:保留最近的真实用户消息,总量最多约 20K token,然后追加交接摘要。旧的 assistant 回复和工具结果不再逐条保留。
这样做的好处是压缩幅度大、结构简单,用户后来追加的要求仍有原文。代价是最近工具结果也要依赖摘要表达;如果摘要漏掉某个精确错误或路径,原始消息不会继续出现在模型窗口中。
压缩发生在任务中途时,codex 还会把当前项目说明和运行状态重新放到合适位置;如果是轮次之间的压缩,则由下一轮正常的上下文组装重新注入。也就是说,动态历史可以重建,但系统要求和当前环境不能跟着旧历史一起丢掉。
dsh:保留按 token 计量的近期尾部
dsh 从历史末尾向前累加,默认至少保留窗口的 16% 作为近期原文,更早的区间交给摘要模型。切点会向前调整,直到工具调用和结果在两侧都保持完整。
它不强制只能在完整用户轮次之间切开,所以一个特别大的轮次也可以分段压缩;但任何工具调用和结果都不能被拆散。这样的取舍比“整轮保留”更节省空间,也比任意按 token 切断更安全。
grok-build:摘要整段历史,但保留当前工作的尾巴
grok-build 的编码 Agent 采用“整段替换”思路:摘要模型总结准备好的整段对话,成功后重新组装一份新的历史,而不是选一个长期保留的普通尾部比例。
新历史会重新放入:
- 原系统要求;
- 用户和项目环境信息;
- 项目级说明文件;
- 最后一个真实用户问题;
- 最后一个用户问题之后仍在进行的工具或子任务结果;
- 压缩摘要;
- 已修改文件、运行任务、子 Agent 等当前状态提醒。
因此它虽然叫“整段替换”,也不会只剩一条摘要。稳定规则、最后需求、当前工作和运行状态由程序重新装配,长期历史才交给模型归纳。
pi:正常按轮次切,单轮过大时单独总结前半段
pi 默认从后向前保留约 20K token 的近期内容。合法切点可以是用户消息、assistant 消息、Shell 执行或自定义消息,但不会选择工具结果作为切点。
通常它会在完整轮次边界切开。如果一个用户轮次本身就超过 20K,pi 不会被迫保留整个超大轮次,而是把它拆成两部分:
text
更早的完整历史 → 生成历史摘要
超大轮次的前半段 → 生成“本轮早期进展”摘要
超大轮次的后半段 → 保留原文最后把两个摘要合并。这样最近的工具调用仍有原文,而模型也知道这一轮最初要解决什么、前面已经做了什么。
13.6 第三道防线:摘要必须像交接文档,而不是聊天摘要
“请总结上面的对话”通常会得到一段看似流畅、却无法继续工作的文字。编码任务真正需要保留的是:
- 用户目标怎样变化,最新要求是什么;
- 已完成、进行中、被阻塞和未开始的工作;
- 改了哪些文件,关键位置和当前差异是什么;
- 哪些方案试过但失败,为什么不能再走一遍;
- 仍在运行的命令、等待中的审批和外部副作用;
- 精确路径、标识符、错误文本、数值和下一步。
摘要还必须知道当前任务
同一份原始内容,在不同任务里不能生成同一份摘要:
- 修测试失败时,要保留错误栈、相关文件、已排除的原因和最近修改;
- 做安全审查时,不能因为某条可疑路径暂时没用上就删除;
- 做资料调研时,结论后面还要保留来源链接,否则摘要一旦写错就无法回查;
- 做文案创作时,关键例子和语气要求可能比中间推理更重要。
设计压缩方案时,至少要比较四个变量:逐条摘要还是合并摘要、是否带当前任务、是否保留引用、达到阈值后是否批量处理。逐页孤立摘要容易重复,也可能保留了局部细节却丢掉它与当前任务的关系。因此有一条更可靠的原则:压缩提示要带上当前目标和已经掌握的事实,不能只把一段旧消息孤立地交给摘要模型。
对于可以重新取得的内容,摘要最好保留一个无损索引:网页保留 URL,代码保留文件路径和符号名,长日志保留文件位置和查询方法。自然语言摘要可以有损,回查入口不要一起丢掉。
四家都在用结构化提示约束摘要,但侧重点不同:
- codex 要求生成面向下一位模型的简洁交接,重点是进度、决定、限制、剩余工作和关键数据。
- dsh 固定为八个部分:用户意图、技术要点、文件与代码、错误与修复、待完成工作、当前工作、下一步和关键背景。缺少的部分也不能省略。
- grok-build 的结构更细,还要求保留所有用户消息、问题解决过程和最近工作。已有旧摘要时,必须把仍然有效的内容继续带入,避免多次压缩后逐轮失真。
- pi 按目标、限制、进度、关键决定、下一步和关键背景组织,并由程序额外维护“读过哪些文件、改过哪些文件”,不把文件追踪完全交给摘要模型猜。
这里可以看到两种思路:
- 让摘要模型自己回忆一切:实现简单,但精确状态容易丢;
- 模型总结语义,程序维护结构化事实:例如文件列表、运行任务、最后用户问题和项目说明,可靠性更高,但实现更复杂。
grok-build 和 pi 都明显采用了第二种思路。dsh 的持久化事件也让程序能够核对被替换范围和 token 数,而不只相信一段自然语言。
13.7 四个 Agent 压缩后,模型实际看见什么
为了避免被“都用了摘要”这句话误导,可以直接比较模型下一轮收到的历史。
codex,本地压缩
text
[系统与项目上下文:当前轮注入,或下一轮重新注入]
[最近的真实用户消息,合计最多约 20K token]
[一份交接摘要]特点:压得最干净,最近的 assistant 和工具原文也会退出模型窗口。
dsh
text
[一份替代旧区间的结构化摘要]
[按 token 保留的近期原始消息,默认约为窗口的 16%]特点:这是对历史表面的区间替换;完整替换关系、计量和摘要调用仍记录在会话日志里。
grok-build
text
[原系统要求]
[用户与项目环境]
[项目级说明]
[最后一个真实用户问题]
[当前轮仍需保留的工具或子任务结果]
[整段历史摘要]
[当前文件、任务、子 Agent 等状态提醒]特点:不是普通的“摘要 + 固定比例尾部”,而是按信息职责重新装配一份新上下文。
pi
text
[系统要求]
[旧历史摘要,包含累计文件信息]
[从记录的近期起点开始的原始分支]特点:模型视图变短,但原始 JSONL 会话树没有被物理删除;切换分支时还有独立的分支摘要。
| Agent | 默认自动触发 | 压缩前先做什么 | 近期内容怎样保留 | 重复压缩怎样处理 |
|---|---|---|---|---|
| codex | 模型配置上限或硬窗口 | 限制工具输出;远程路径还会预先瘦身 | 本地路径保留近期用户原文,最多约 20K token | 生成新的交接摘要并重建可见历史 |
| dsh | 窗口 80% | 先剪超过 8,192 字符的工具结果并重新计量 | 默认保留窗口 16% 的近期原始消息 | 旧检查点与新消息合并成一份新检查点 |
| grok-build | 窗口约 85%,另有溢出检查 | 摘要输入按完整、适配、精简三级降级 | 保留最后用户问题和当前工作尾部 | 旧摘要被视为早期历史的权威来源,再次归纳 |
| pi | 窗口减去 16,384 token 预留 | 摘要请求把每条工具结果缩到 2,000 字符 | 默认保留约 20K token;超大单轮单独处理 | 上一份摘要与新淘汰的历史一起更新 |
13.8 摘要生成成功,不等于压缩已经成功
生产实现至少要防住下面五类失败。
1. 摘要自己被截断
模型因为输出上限只返回半份摘要时,不能把它当作成功。dsh 会把“达到摘要输出上限”视为不完整检查点并拒绝提交;grok-build 会把过短或空摘要当作可重试失败;pi 会拒绝摘要请求中出现工具调用或明确的模型错误。
仅检查“字符串不为空”还不够。自建 Agent 至少应该检查必需标题、当前任务、下一步和关键路径是否存在,并确认摘要明显短于被替换内容。
2. 摘要期间历史发生变化
生成摘要可能需要几十秒。期间用户又发来消息、后台任务结束,或者另一个流程修改了会话。如果仍然用旧切点覆盖,就会把新内容一起遮掉。
dsh 在提交前重新检查选中的历史范围。手动压缩期间如果范围变化,会关闭这次失败记录,但不替换模型可见历史。
3. 写到一半进程崩溃
dsh 把压缩开始、摘要结果、替换范围和结束分别记录。只有完整结果落地后,模型视图才使用新检查点;程序重启时可以识别只有开始、没有结束的遗留操作。
pi 的做法是向只追加、不覆盖旧节点的会话树写入一条压缩记录,记录摘要、近期分支起点和压缩前 token。旧节点仍在,因此模型视图可以重建,分支也不会被破坏。
4. 压缩请求本身超出窗口
codex 的本地路径会逐步移除最旧的压缩输入,直到请求能够发送;grok-build 会从完整输入降到适配窗口的输入,再降到有损精简输入。两者都说明:摘要器也必须有自己的输入预算。
5. 失败后无限重试
网络断开可以重试,认证错误、消息结构错误和同一份输入必然超限则不应该原样重试。
四家的共同原则是重试有上限,且区分“换个输入再试”与“原样重试”——上限数字与降级方式各不相同:
- grok-build 默认最多尝试三次;确定性超限时改用更小输入,其他确定性失败会暂停自动压缩,直到相应条件变化。
- pi 对摘要调用沿用有上限的临时错误重试;因为上下文溢出而压缩时,只做一次“压缩后重试原任务”,再次失败就停止循环。
- dsh 分开限制普通压缩重试和溢出恢复重试,默认都只额外尝试一次。
- codex 对可重试的流错误使用有上限的退避;如果压缩输入仍超限,则改变输入而不是只等待后重发。
13.9 压缩与前缀缓存:省 token,也会让一段缓存失效
前缀缓存要求请求开头的字节保持一致。压缩会把旧历史换成新摘要,因此替换位置之后的旧缓存通常不能继续使用。
text
压缩前: [系统要求 | 工具说明] [旧历史 80K] [近期历史]
压缩后: [系统要求 | 工具说明] [新摘要 8K] [近期历史]
└ 共同前缀可能复用 ┘ └ 从摘要位置开始重新计算 ┘需要分清两笔成本:
- 生成摘要的辅助调用;
- 主 Agent 下一轮从新摘要位置开始重新计算。
dsh 特意让摘要调用重放原请求的系统要求、工具定义和旧消息,再把压缩指令放在最后,争取复用压缩前的前缀缓存。pi 采取相反取舍:摘要是一条独立、一次性的请求,使用新的路由会话标识;服务支持时还会关闭这次请求的缓存写入,因为它大概率不会再次复用。
两种做法都合理:前者降低本次摘要延迟和输入计算成本,但摘要器与主请求绑定更紧;后者隔离清楚、扩展容易,但不能指望复用整段主会话缓存。
压缩是否划算,取决于后面还有多少轮。如果任务马上结束,生成摘要可能比直接完成更贵;如果还要执行几十轮,或者不压缩就无法继续,重建一次缓存通常值得。
13.10 自建时先做到什么
普通团队不需要一次实现四家的全部能力。可以分三步推进。
第一版:能避免直接爆窗
- 每条工具结果有明确上限和省略标记;
- 记录模型窗口、当前输入和输出预留;
- 超过阈值时在完整轮次边界生成结构化摘要;
- 摘要失败就保留旧历史,不做替换;
- 提供手动压缩入口,方便用户在阶段边界触发。
第二版:能在真实编码任务里继续工作
- 切点不拆工具调用与结果;
- 摘要保留目标、限制、文件、失败路径、当前工作和下一步;
- 最近一段工作保留原文;
- 文件清单、运行任务和审批状态由程序维护;
- 上下文溢出后只允许有限次数的“压缩并重试”。
第三版:能恢复、能审计、能调优
- 原始会话与模型可见视图分开保存;
- 压缩有开始、结果、替换范围和结束记录;
- 提交前检查历史没有变化;
- 区分临时失败、确定性失败和取消;
- 记录压缩前后 token、摘要调用成本、后续任务成功率和再次压缩间隔;
- 用真实任务比较摘要格式、保留尾部大小和触发阈值,而不是凭感觉调参。
判断实现是否可靠的一个问题
压缩完成后,换一个完全不知道前情的工程师,只看“摘要 + 保留内容 + 当前工作区”,能不能接着完成任务?如果不能,窗口虽然变短了,Agent 也已经失去继续工作的能力。
本章小结
- 上下文管理不是一次 LLM 总结,而是入口限长、历史剪裁、区间选择、摘要生成、原子切换和失败恢复组成的流程。
- codex 本地路径会重建为“近期用户原文 + 交接摘要”;dsh 先剪工具结果,再替换安全旧区间;grok-build 按职责重新装配整份历史;pi 根据压缩记录重建“摘要 + 近期分支”,完整会话树仍保留。
- 摘要最容易丢的是用户后来补充的限制、失败路径、精确标识符和当前工作。可以由程序维护的事实,不要全部交给模型回忆。
- 压缩会让摘要插入点之后的旧前缀缓存失效,也会产生一笔额外摘要调用成本;触发时机要同时考虑窗口安全、任务阶段和后续轮数。
- 最可靠的压缩不是“摘要写得像”,而是失败时不丢历史、重启后能重建、压缩后真的能继续完成任务。
到这里,上下文的问题——装什么、怎么取、怎么压——都有了答案。下一章转向另一个维度:程序崩溃、断线之后,怎样从记录恢复会话;外部资料(最新文档、issue、网页)怎样进入 Agent,见第 18 章。