14 · 精细化上下文管理:外科手术,而非大刀阔斧

自动压缩是钝器——它摘要掉一整段、还击穿缓存。有时你只想精准处理“那个占了两万 token 的巨型工具结果”。讲清三把手术刀:工具结果预算(超限外置)、按 tool_use_id 定点删除的 microcompact、以及内容外置引用,以及它们如何在减负的同时保住缓存。

作者 David更新于

14 · 精细化上下文管理:外科手术,而非大刀阔斧

上一篇的自动压缩很有效,但它是钝器:一刀把边界前的所有历史摘要成一段,损失了全部细节,还击穿了缓存。很多时候,上下文膨胀其实只源于几个具体的“胖子”——你想要的是精准切除它们,而不是把整段历史推平。这一篇,讲三把外科手术刀。

一、背景:膨胀往往是“局部”的

观察真实 Agent 的上下文,你会发现膨胀高度不均匀:

  • 大部分消息(用户的话、模型的推理、小工具结果)都不大。
  • 但个别工具结果是巨无霸:一次 read_file 读进来一个五千行的文件、一条 npm test 刷了两千行日志、一次 git diff 几百个改动。单单几个这样的结果,就可能占掉上下文的大半。

对这种“局部肥胖”,用全量压缩是杀鸡用牛刀:你为了削掉那两万 token 的日志,把整段对话的细节全摘要没了,还废了缓存。更好的办法是精准地只处理那几个胖子。

二、三把手术刀

手术刀一:工具结果预算(超限就外置)

最前置的一招:给每个工具结果设一个大小上限。 还记得04 篇工具接口里那个 maxResultSizeChars 吗?就是干这个的。当一个结果超过上限:

  1. 把完整结果写到外部(磁盘上一个临时文件)。
  2. 只给模型一个截断预览 + 一个指针:“输出太长,已存到 /tmp/xxx,前 50 行如下……需要完整内容请用 read_file 读取。”
工具结果预算:超限则外置,只给模型预览 + 指针
function budgetResult(tool: Tool, fullText: string): string {
  if (fullText.length <= tool.maxResultSizeChars) return fullText
  const path = writeToTemp(fullText)                    // 完整结果存盘
  const preview = fullText.slice(0, tool.maxResultSizeChars)
  return `${preview}\n\n[输出过长,已截断。完整内容(${fullText.length} 字符)见 ${path},可用 read_file 读取。]`
}

这一招的精妙之处:信息没有丢,只是从“上下文里”挪到了“够得着的地方”。 模型依然知道有这么个结果、知道它的开头、知道完整版在哪——真需要细节时,它能自己去 read_file 取回来。你用一个廉价的“指针”换掉了昂贵的“全文”,而且把要不要花上下文去看全文的决定权交还给了模型。这比一刀切地截断(直接丢掉后半截、模型永远够不着了)高明得多。

注意这又是04 篇“两个受众”的体现:给模型的是截断预览,给人的 UI 里仍可展示完整输出——人看到的和模型看到的,本就该是两个版本。

一个反直觉的边界:有些工具的结果绝不能外置。典型就是 read_file 自己——如果它的输出也被“存盘 + 让你用 read_file 去读”,就成了一个 read_file → 存盘 → read_file → …… 的死循环。所以这类工具要把上限设成“无穷大”(它自己已经通过别的方式自我约束了)。设预算时,先想清楚这个工具的结果被外置后,会不会绕回来咬自己。

手术刀二:microcompact —— 按 tool_use_id 定点删除

第二把刀更精细:在不动对话其余部分的前提下,把某几个特定的、老的工具结果“就地掏空”。

关键抓手是 tool_use_id(02 篇那个配对 ID)。你可以精确地定位到“第 8 轮那次 read_file 的结果”,把它的 content 替换成一个小占位符("[早期输出已省略]"),而保持 tool_use / tool_result 的配对结构完好(不破坏历史合法性):

microcompact:只掏空指定的老结果,其余分毫不动
掏空前:...[tool_use:read_file][tool_result: <5000 行文件内容>]...
掏空后:...[tool_use:read_file][tool_result: "[早期文件内容已省略,如需请重读]"]...
        └─ 配对结构、id、位置全都不变,只有 content 被换小了 ─┘

为什么值得这么费劲地“定点”?为了缓存。 全量压缩重写了边界后的一切,缓存从边界起全废。而 microcompact 只改动特定几个、而且尽量是很老的 tool_use_id——如果被改的都在缓存前缀的很靠前处(本来也快过期),对热缓存的破坏就降到最低。这是“减负”和“保缓存”两个目标之间的精细平衡:能定点切除,就别整段推平。挑“老的、大的、且已经不太可能再被用到的”结果下刀,收益最大、代价最小。

手术刀三:内容外置引用

第三把刀是前两把的一般化:大块内容根本不进上下文,而是活在外部,只在上下文里留一个句柄(handle),模型按需拉取。

这适合那些“可能要用、但大概率不用全部”的大数据:一次大规模搜索的完整结果、一个大目录的完整清单、一份长文档。上下文里只放“有这么个东西,句柄是 X”,模型真要用时通过工具把它(或它的一部分)取回来。本质上,这是把上下文当“寄存器”,把外部存储当“内存”,按需换入换出——和操作系统管理内存的思路如出一辙。

三、一条统领性原则:用最小破坏换取足够空间

三把刀,加上全量压缩,构成一个从轻到重的减负梯度:

手段破坏性何时用
工具结果预算最小(源头就截)每个大结果产生时,预防性地
microcompact 定点删除小(只动几个老结果)上下文偏高,且膨胀源集中在几个胖子
内容外置引用小(数据本就在外部)已知会产生大数据的工具
全量压缩大(推平一段 + 废缓存)前几招都不够,历史整体太长

原则:永远优先用能腾出足够空间的、破坏性最小的那一招。 别一上来就全量压缩——先看看是不是几个巨型工具结果在作祟,能定点切除就定点切除;实在是“整体都长了”,才动用压缩这把钝器。这个“阶梯式减负”的思路,是精细上下文管理的核心心法。一个成熟的 Agent 会把这几招组合起来,层层递进地维持上下文健康,而不是简单粗暴地一压了之。

四、第五阶段总结:上下文工程全景

第五阶段到此完整。 这是整个专栏里格外硬核的一章,值得把五篇串起来回望——它们共同回答了那个核心问题:在有限的窗口里,如何始终装着模型此刻最需要的东西?

篇章在这场战争里的角色
10 Token 预算侦察:量出用量,设水位线
11 上下文注入编排:每轮的上下文是分层拼出来的
12 Prompt 缓存省:稳定前缀复用,守住字节红线
13 自动压缩减(钝器):历史太长,折叠成摘要
14 精细化管理减(手术刀):定点切除胖子,少伤缓存

如果这一整章只留一句话:上下文是 Agent 最稀缺的资源,而管理它是一组互相拉扯的权衡——省(缓存)要字节稳定,减(压缩)必然破坏字节;要空间就得牺牲细节,要细节就得占空间。没有银弹,只有针对具体情形的、在多个约束间的通盘取舍。这也是为什么“上下文工程”是区分玩具 Agent 和生产 Agent 的分水岭。

五、下一站

上下文管住了,我们的 Agent 已经能撑起很长的任务而不崩。但真实世界还有另一类挑战我们一直在往后推:各种各样的失败——网络抖动、限流、模型过载、输出被截断。下一篇进入第六阶段:健壮性,看一个生产级 Agent 如何把“出错”当成常态来优雅应对。


本篇对应代码:配套仓库加入 src/toolResultBudget.ts(工具结果超限外置),并给工具接口补上 maxResultSizeChars。git checkout article-14。

本页目录