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

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

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-10-01
- 原文：https://daiw.org/manual/llm-to-agent/context-compaction
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

[上一篇的自动压缩](https://daiw.org/manual/llm-to-agent/auto-compaction)很有效，但它是**钝器**：一刀把边界前的所有历史摘要成一段，损失了全部细节，还[击穿了缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)。很多时候，上下文膨胀其实只源于**几个具体的“胖子”**——你想要的是精准切除它们，而不是把整段历史推平。这一篇，讲三把外科手术刀。

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

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

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

对这种“局部肥胖”，用[全量压缩](https://daiw.org/manual/llm-to-agent/auto-compaction)是杀鸡用牛刀：你为了削掉那两万 token 的日志，把整段对话的细节全摘要没了，还废了缓存。**更好的办法是精准地只处理那几个胖子。**

## 二、三把手术刀

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

最前置的一招：**给每个工具结果设一个大小上限。** 还记得[04 篇](https://daiw.org/manual/llm-to-agent/tool-abstraction)工具接口里那个 `maxResultSizeChars` 吗？就是干这个的。当一个结果超过上限：

1. 把**完整结果写到外部**(磁盘上一个临时文件)。
2. 只给模型一个**截断预览 + 一个指针**：“输出太长，已存到 `/tmp/xxx`，前 50 行如下……需要完整内容请用 `read_file` 读取。”

```ts title="工具结果预算:超限则外置,只给模型预览 + 指针"
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 读取。]`
}
```

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

注意这又是[04 篇“两个受众”](https://daiw.org/manual/llm-to-agent/tool-abstraction)的体现：**给模型的是截断预览，给人的 UI 里仍可展示完整输出**——人看到的和模型看到的，本就该是两个版本。

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

### 手术刀二：microcompact —— 按 `tool_use_id` 定点删除

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

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

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

<Callout type="info">
  **为什么值得这么费劲地“定点”？为了[缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)。** 全量压缩重写了边界后的一切，缓存从边界起全废。而 microcompact 只改动**特定几个、而且尽量是很老的** `tool_use_id`——如果被改的都在缓存前缀的很靠前处(本来也快过期)，对热缓存的破坏就降到最低。**这是“减负”和“保缓存”两个目标之间的精细平衡**：能定点切除，就别整段推平。挑“老的、大的、且已经不太可能再被用到的”结果下刀，收益最大、代价最小。
</Callout>

### 手术刀三：内容外置引用

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

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

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

三把刀，加上[全量压缩](https://daiw.org/manual/llm-to-agent/auto-compaction)，构成一个**从轻到重**的减负梯度：

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

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

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

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

| 篇章 | 在这场战争里的角色 |
| --- | --- |
| [10 Token 预算](https://daiw.org/manual/llm-to-agent/token-budget) | 侦察：量出用量，设水位线 |
| [11 上下文注入](https://daiw.org/manual/llm-to-agent/context-injection) | 编排：每轮的上下文是分层拼出来的 |
| [12 Prompt 缓存](https://daiw.org/manual/llm-to-agent/prompt-caching) | 省：稳定前缀复用，守住字节红线 |
| [13 自动压缩](https://daiw.org/manual/llm-to-agent/auto-compaction) | 减(钝器)：历史太长，折叠成摘要 |
| **14 精细化管理** | 减(手术刀)：定点切除胖子，少伤缓存 |

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

## 五、下一站

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

---

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