第二十二章:大输出为什么不能只靠截断
解释 Claude Code 怎样治理大输出,既控 token 压力,又尽量不打碎 prompt cache。
1. 为什么这一层值得单独拆
前一篇我们已经把 compact / microcompact / session memory / snip 这条压缩链拆开了。
但那条链其实还不是最前面的一层。
Claude Code 真正最先做的,是先拦“大输出”。
也就是:
- 单个工具结果太大怎么办
- 一轮并发工具结果加起来太大怎么办
- 这些替换决策怎么跨 turn、跨 resume 保持稳定
这套东西集中在:
src/utils/toolResultStorage.tssrc/constants/toolLimits.tssrc/query.tssrc/utils/sessionStorage.tssrc/utils/sessionRestore.tssrc/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 重放"]
最关键的不是“存文件”,而是这三件事同时成立:
- 模型还能拿到足够用的 preview
- 真正的大输出没有丢,只是挪到磁盘
- 同一个
tool_use_id以后永远得到同一个 replacement,保证 prompt cache 稳定
3. 第一层:单个工具结果过大时,先做 per-tool 持久化
源码锚点:
src/services/tools/toolExecution.ts:1406src/utils/toolResultStorage.ts:190src/constants/toolLimits.ts:11
3.1 它不是所有工具统一一个硬值
每个工具自己会声明 maxResultSizeChars。
例如这份快照里能看到:
BashTool:30_000GrepTool:20_000FileReadTool: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:38src/utils/toolResultStorage.ts:410src/utils/toolResultStorage.ts:924src/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 它不是每次都重新洗牌
候选结果会先分成三类:
mustReapplyfrozenfresh
含义分别是:
mustReapply:之前已经替换过,这次必须再用同一个 replacementfrozen:之前见过但没替换,以后也不准再补替换fresh:第一次见到,当前这次才有资格参与新决策
这是整个设计里最核心的一条纪律:
同一个结果一旦被模型看过,它的命运就冻结。
5.2 为什么要这么死板
因为 prompt cache。
如果某个 tool_result:
- 第一次完整发出去了
- 第二次你又把它换成 preview
那对模型来说,前缀就变了。
Claude Code 明确不允许这种“事后改口”。
所以它宁可接受某些历史 over-budget 结果以后变成 frozen,也不允许为了省 token 去改已经发过的上下文。
这就是很典型的运行时工程取舍:
一致性优先于局部最优。
5.3 真正参与新替换决策的,只有 fresh
然后系统会:
- 先把
mustReapply原样放回 replacement map - 统计
frozen + fresh的总大小 - 如果超预算,就从
fresh里按体积从大到小挑 - 挑到预算大致回落为止
也就是说,它的策略不是复杂启发式,而是非常务实的:
优先搬走最大块的新内容。
这很合理,因为这一层的目标本来就不是“保最重要的语义”,而是“在不破坏缓存稳定性的前提下尽快瘦身”。
6. 为什么 replacement 决策还要写进 transcript
源码锚点:
src/utils/sessionStorage.ts:1494src/utils/sessionRestore.ts:454src/types/logs.ts:48
6.1 因为 resume 后还得保持同一个前缀
Claude Code 会把 replacement 决策作为 contentReplacements 单独写进 transcript。
恢复时再通过:
reconstructContentReplacementState()provisionContentReplacementState()reconstructForSubagentResume()
把这套状态重新搭起来。
这背后的目标很明确:
resume 不是“尽量长得像原来”,而是“下一次发给模型时,尽量还是原来的那条 prompt 前缀”。
6.2 它存的不是算法输入,而是算法结果
这点也非常重要。
transcript 里保存的是:
toolUseIdreplacement
也就是“模型当时实际看到的 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 里会把 maxResultSizeChars 是 Infinity 的工具名收进 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-commit和marble-origami-snapshotsessionRestore.ts会在 resume 时调用restoreFromEntries()TokenWarning.tsx和ContextVisualization.tsx会展示 collapse 统计信息- transcript 恢复时会把 commit log 和 staged snapshot 一起还原
10.2 当前快照里缺的部分
这份仓库里看不到:
src/services/contextCollapse/index.tssrc/services/contextCollapse/persist.tssrc/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. 这一层最值得抄的设计习惯
最后收一下,我觉得最值钱的是这五个习惯:
- 大输出优先改“携带方式”,不是优先改“内容语义”。
- 预算分组必须按真实 API 线格式来,不按本地数组想当然。
- 已经被模型见过的内容,后面不要再改命运。
- resume 恢复要恢复“当时实际发给模型的字符串”,不是只恢复原始数据。
- 多 agent / fork 场景下,上下文治理状态也要继承,不只是消息历史要继承。
13. 一句话总结
Tool Result Budget 这层本质上是在做一件事:
把“工具输出很多”从一个上下文灾难,改造成一个可持久化、可恢复、可缓存的运行时协议问题。