Book 第二十二章:大输出为什么不能只靠截断
第五部分:会话续航与治理

第二十二章:大输出为什么不能只靠截断

解释 Claude Code 怎样治理大输出,既控 token 压力,又尽量不打碎 prompt cache。

1. 为什么这一层值得单独拆

前一篇我们已经把 compact / microcompact / session memory / snip 这条压缩链拆开了。

但那条链其实还不是最前面的一层。

Claude Code 真正最先做的,是先拦“大输出”。

也就是:

  • 单个工具结果太大怎么办
  • 一轮并发工具结果加起来太大怎么办
  • 这些替换决策怎么跨 turn、跨 resume 保持稳定

这套东西集中在:

  • src/utils/toolResultStorage.ts
  • src/constants/toolLimits.ts
  • src/query.ts
  • src/utils/sessionStorage.ts
  • src/utils/sessionRestore.ts
  • src/services/tools/toolExecution.ts

如果后面你想把 Claude Code 转成 C#,这一层非常值得照着抄,因为它解决的是一个很现实的问题:

真正把上下文撑爆的,很多时候不是推理本身,而是工具输出。

2. 先说结论

我对这层设计的判断是:

Claude Code 不是简单“截断 tool result”,而是把大输出变成可持久化、可重放、对 prompt cache 稳定的引用。

它实际上有两道闸门:

flowchart LR
    A["tool.call() 返回结果"] --> B["per-tool limit<br/>单个结果过大"]
    B --> C["落盘 + preview 替换"]
    C --> D["消息进入 query 主循环"]
    D --> E["per-message aggregate budget<br/>同一轮多个 tool_result 总量过大"]
    E --> F["选择最大 fresh 结果"]
    F --> G["落盘 + replacement 记忆"]
    G --> H["写 transcript replacement records"]
    H --> I["后续 turn / resume 按同样 replacement 重放"]

最关键的不是“存文件”,而是这三件事同时成立:

  1. 模型还能拿到足够用的 preview
  2. 真正的大输出没有丢,只是挪到磁盘
  3. 同一个 tool_use_id 以后永远得到同一个 replacement,保证 prompt cache 稳定

3. 第一层:单个工具结果过大时,先做 per-tool 持久化

源码锚点:

  • src/services/tools/toolExecution.ts:1406
  • src/utils/toolResultStorage.ts:190
  • src/constants/toolLimits.ts:11

3.1 它不是所有工具统一一个硬值

每个工具自己会声明 maxResultSizeChars

例如这份快照里能看到:

  • BashTool: 30_000
  • GrepTool: 20_000
  • FileReadTool: Infinity
  • 大多数其他工具:100_000

但真正生效时还会再过一层全局上限:

  • DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000

也就是说,默认情况下:

  • 工具自己可以声明更小
  • 但不能随便声明比系统上限还大
  • 除非走专门 override

Read 是特例,它直接用 Infinity 明确退出这套逻辑。

3.2 为什么 Read 要退出

源码里已经把理由写得很透了:

Read 自己已经有 maxTokens 这种读取侧约束。

如果再把 Read 的结果落盘,然后让模型再用 Read 去读它,逻辑就会变成:

为了看文件内容,再去读一个为了存文件内容而生成的文件。

这就是循环设计了。

所以 Claude Code 在这里的思路非常稳:

真正的源文件读取,靠 Read 自己限流;别再套一层“输出落盘”。

3.3 持久化后,模型看到的不是“被截断的正文”

persistToolResult() 会把完整内容写到:

projectDir/sessionId/tool-results/{tool_use_id}.{txt|json}

然后给模型返回的不是截断正文,而是一个结构化提示:

  • 输出太大
  • 完整内容保存到哪个文件
  • 预览前 2000 bytes

这里最重要的设计点是:

Claude Code 不把“大输出处理”做成 destructive truncation,而是做成 reference substitution。

也就是说,它默认认为:

  • 全量内容后面可能还会有用
  • 只是不应该继续占模型上下文

4. 第二层:同一轮多个工具结果加起来太大时,再做 aggregate budget

源码锚点:

  • src/constants/toolLimits.ts:38
  • src/utils/toolResultStorage.ts:410
  • src/utils/toolResultStorage.ts:924
  • src/query.ts:372

4.1 这一层解决的不是“单个太大”,而是“并发叠加”

Claude Code 明确有一个常量:

  • MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000

它约束的是:

同一个 API 级 user message 里,所有 tool_result 加起来最多能有多大。

为什么要单独有这一层?

因为并发工具场景下,很容易出现这种情况:

  • 每个结果各自都没超过单工具阈值
  • 但一轮里同时回来了 5 个、8 个、10 个结果
  • merge 之后一股脑塞进同一个 user message
  • 结果整体还是把上下文打爆

所以作者在这里补了一个非常工程化的现实修正:

单点安全,不等于批量安全。

4.2 它按“API 级 user message”分组,不按本地消息数组分组

这是这层实现里我觉得最值得抄的点之一。

collectCandidatesByMessage() 不会简单按“当前数组里每条 user message”算预算,而是按:

  • assistant 作为边界
  • 连续 user message 合并成一个 API-level group

原因是 normalizeMessagesForAPI() 最后会把这些 user message 合并后发给模型。

也就是说,本地看起来是多条,线上真正吃的是一条。

如果你只按本地消息分组,就会出现一种很隐蔽的 bug:

  • 每条本地消息都没超预算
  • merge 后整条 API message 超预算
  • 结果预算系统以为自己安全,实际上已经失效

Claude Code 在这里选的是:

预算模型必须跟真正发给模型的线格式一致。

5. 它怎么决定“替换哪几个结果”

源码锚点:

  • partitionByPriorDecision()
  • selectFreshToReplace()
  • enforceToolResultBudget()

5.1 它不是每次都重新洗牌

候选结果会先分成三类:

  • mustReapply
  • frozen
  • fresh

含义分别是:

  • mustReapply:之前已经替换过,这次必须再用同一个 replacement
  • frozen:之前见过但没替换,以后也不准再补替换
  • fresh:第一次见到,当前这次才有资格参与新决策

这是整个设计里最核心的一条纪律:

同一个结果一旦被模型看过,它的命运就冻结。

5.2 为什么要这么死板

因为 prompt cache。

如果某个 tool_result

  • 第一次完整发出去了
  • 第二次你又把它换成 preview

那对模型来说,前缀就变了。

Claude Code 明确不允许这种“事后改口”。

所以它宁可接受某些历史 over-budget 结果以后变成 frozen,也不允许为了省 token 去改已经发过的上下文。

这就是很典型的运行时工程取舍:

一致性优先于局部最优。

5.3 真正参与新替换决策的,只有 fresh

然后系统会:

  1. 先把 mustReapply 原样放回 replacement map
  2. 统计 frozen + fresh 的总大小
  3. 如果超预算,就从 fresh 里按体积从大到小挑
  4. 挑到预算大致回落为止

也就是说,它的策略不是复杂启发式,而是非常务实的:

优先搬走最大块的新内容。

这很合理,因为这一层的目标本来就不是“保最重要的语义”,而是“在不破坏缓存稳定性的前提下尽快瘦身”。

6. 为什么 replacement 决策还要写进 transcript

源码锚点:

  • src/utils/sessionStorage.ts:1494
  • src/utils/sessionRestore.ts:454
  • src/types/logs.ts:48

6.1 因为 resume 后还得保持同一个前缀

Claude Code 会把 replacement 决策作为 contentReplacements 单独写进 transcript。

恢复时再通过:

  • reconstructContentReplacementState()
  • provisionContentReplacementState()
  • reconstructForSubagentResume()

把这套状态重新搭起来。

这背后的目标很明确:

resume 不是“尽量长得像原来”,而是“下一次发给模型时,尽量还是原来的那条 prompt 前缀”。

6.2 它存的不是算法输入,而是算法结果

这点也非常重要。

transcript 里保存的是:

  • toolUseId
  • replacement

也就是“模型当时实际看到的 replacement 字符串”。

而不是只存:

  • 原始大小
  • 阈值
  • 路径

这样做的好处是,哪怕后面代码里:

  • preview 模板改了
  • size formatting 改了
  • 文件路径布局改了

resume 也不会悄悄把旧 prefix 改掉。

这是一种很稳的策略:

重要的不是“能重新算出来”,而是“必须和当时完全一样”。

7. 它怎么处理 subagent / fork 的状态继承

源码锚点:

  • cloneContentReplacementState()
  • reconstructForSubagentResume()
  • src/utils/forkedAgent.ts

Claude Code 明确把 replacement state 当成:

每条对话线程自己的上下文治理状态。

但 fork 出去的 agent 又需要尽量共享同一套缓存决策,否则会出问题:

  • 主线程发 preview
  • fork 线程发 full content
  • 两边 prompt 前缀不一样
  • cache 直接打散

所以它用了两种方式:

  • 新 fork:clone 父线程当前 replacement state
  • sidechain resume:用 transcript records 重建,再用父线程 live replacements 补 gap

这个细节很值钱,因为它说明 Claude Code 已经把“多线程/多 agent 下的上下文稳定性”考虑进去了。

8. 它还专门处理了几个很容易忽略的坑

8.1 空 tool result 不能原样发

maybePersistLargeToolResult() 里有个很细的保护:

如果 tool result 是空字符串、空数组或者纯空白,它不会原样发,而是改成:

({toolName} completed with no output)

原因不是 UI,而是模型协议。

源码注释里直接写了:某些模型会把空 tool_result 误判成 stop sequence,导致 turn 提前结束。

这说明 Claude Code 在这里盯的不是“内容对不对”,而是:

消息线格式会不会触发模型奇怪的边界行为。

8.2 图片结果不落盘

如果 tool_result 里有 image block,这层会直接跳过持久化。

因为这类内容必须按多模态原格式继续发给 Claude,不能随便转成文本 preview。

8.3 已经 compact 过的内容不再二次处理

它会检查内容是不是已经带了 <persisted-output> tag。

如果已经是 preview/reference 形态了,就不会再二次 persist。

8.4 某些工具被显式跳过 aggregate budget

query.ts 里会把 maxResultSizeCharsInfinity 的工具名收进 skipToolNames

当前最典型的就是 Read

也就是:

  • 单工具阈值层不处理它
  • 聚合预算层也不处理它

这是很明确的一种语义声明,不是漏判。

9. 这层和 compact 的关系是什么

可以这么理解:

  • Tool Result Budget 解决的是“大输出别先冲进上下文”
  • microcompact 解决的是“已经进来的旧大输出能不能后续清掉”
  • compact 解决的是“整个会话太长了,必须摘要重写”

也就是说,这层比 compact 更前,也更保守。

它不改主叙事,只改工具输出的携带方式。

所以我会把它看成:

上下文治理的 L0 层。

10. context collapse 在这份快照里能确认到什么

这里顺手补一下边界,避免把没看到的实现写成事实。

10.1 能确认的部分

这份快照里能确定:

  • query.ts 在 microcompact 之后、autocompact 之前会调用 contextCollapse.applyCollapsesIfNeeded()
  • overflow 恢复时还会调用 recoverFromOverflow()
  • sessionStorage.ts 会写 marble-origami-commitmarble-origami-snapshot
  • sessionRestore.ts 会在 resume 时调用 restoreFromEntries()
  • TokenWarning.tsxContextVisualization.tsx 会展示 collapse 统计信息
  • transcript 恢复时会把 commit log 和 staged snapshot 一起还原

10.2 当前快照里缺的部分

这份仓库里看不到:

  • src/services/contextCollapse/index.ts
  • src/services/contextCollapse/persist.ts
  • src/services/contextCollapse/operations.ts

所以现在能讲清楚的是:

  • 它在主流程里的位置
  • 它有 commit / snapshot / restore 机制
  • 它会把 summarized span 作为一种正式 runtime 状态保存下来

但还不能把具体算法细节当事实写死。

11. 如果转成 C#,这层我建议怎么拆

11.1 单结果持久化器

public interface IToolResultPersistenceService
{
    Task<ToolResultBlock> ProcessAsync(
        ToolDescriptor tool,
        ToolResultBlock block,
        CancellationToken ct);
}

它只管:

  • 单个结果是否过大
  • 是否需要落盘
  • preview/reference 怎么生成

11.2 聚合预算治理器

public interface IToolResultBudgetEnforcer
{
    Task<BudgetEnforcementResult> EnforceAsync(
        IReadOnlyList<ConversationMessage> messages,
        ContentReplacementState state,
        CancellationToken ct);
}

它只管:

  • 按 API 级 message 分组
  • mustReapply / frozen / fresh
  • 选择哪些 fresh 结果要替换

11.3 replacement 状态仓库

public sealed class ContentReplacementState
{
    public HashSet<string> SeenIds { get; }
    public Dictionary<string, string> Replacements { get; }
}

11.4 transcript 持久化记录

不要只在内存里存 replacement decision。

这一层必须能:

  • 写入 session log
  • resume 时重建
  • fork / subagent 时复制或补齐

不然 prompt cache 稳定性会在长会话里慢慢崩掉。

12. 这一层最值得抄的设计习惯

最后收一下,我觉得最值钱的是这五个习惯:

  1. 大输出优先改“携带方式”,不是优先改“内容语义”。
  2. 预算分组必须按真实 API 线格式来,不按本地数组想当然。
  3. 已经被模型见过的内容,后面不要再改命运。
  4. resume 恢复要恢复“当时实际发给模型的字符串”,不是只恢复原始数据。
  5. 多 agent / fork 场景下,上下文治理状态也要继承,不只是消息历史要继承。

13. 一句话总结

Tool Result Budget 这层本质上是在做一件事:

把“工具输出很多”从一个上下文灾难,改造成一个可持久化、可恢复、可缓存的运行时协议问题。