cc压缩机制之-toolResultBudget源码解读
cc压缩机制之-toolResultBudget源码解读的重点在于把前置条件、操作顺序和容易误判的地方分清楚。
0. 目标实际怎么用
cc压缩机制之-toolResultBudget源码解读不能只看功能名称,更要看它在什么场景下能解决问题。下面按0. 目标、1. 触发时机、2. 策略等重点拆开说明,方便直接对照使用。
1. 触发时机的使用场景
toolResultBudget 会在每次主查询循环准备发起模型请求前自动执行,对压缩层次而言,在microcompact 、 autocompact 之前。它不是等 API 报错后触发,也不是按全局 token 阈值触发;而是每轮都会检查当前即将发送给模型的消息里,单个 API-level user message 的 tool_result 总量是否超过预算,超过才做落盘替换。
对应cc源码:
- 在每轮ReactLoop中执行
- 在microcompact之前执行
- 超预算才会处理,阈值默认 200_000 chars
2. 策略实际怎么用
toolResultBudget不是摘要,也不是直接删除,其策略为:如果同一轮工具结果的总量超过预算,就把最大的几个新tool_result原文保存到磁盘,然后在上下文里只保留一个稳定的预览和文件路径。
toolResultBudget 对应的主代码在这几处:
- 入口调用: src/query.ts:L369-L394
- 预算阈值: src/constants/toolLimits.ts:L36-L49
- 策略主体: src/utils/toolResultStorage.ts:L739-L909
- 候选结果收集: src/utils/toolResultStorage.ts:L551-L638
- 最大项选择: src/utils/toolResultStorage.ts:L669-L692
- 落盘与预览: src/utils/toolResultStorage.ts:L137-L199
- 替换 tool_result 内容: src/utils/toolResultStorage.ts:L699-L726
代码解读:
2.1 它在压缩流水线的位置
入口在 query.ts :
messagesForQuery=awaitapplyToolResultBudget(messagesForQuery,toolUseContext.contentReplacementState,...)见 src/query.ts:L379-L394 。
注意注释:
//RunsBEFOREmicrocompact
见 src/query.ts:L369-L372 。
执行顺序是:
toolResultBudget→HistorySnip→microcompact→contextcollapse→autocompact所以它是最早的一层降噪,处理的是“刚产生的大工具结果”(其他的后面会讲)。
2.2 触发条件:单个 user message 内 tool_result 总和超过 200K chars
阈值定义在:
exportconstMAX_TOOL_RESULTS_PER_MESSAGE_CHARS=200_000见 src/constants/toolLimits.ts:L36-L49 。
这里非常关键:它不是全会话总量,而是 单个 API-level user message 里的 tool_result 总和。
为什么是 user message?因为 Claude 的工具结果最终以 user message 的 tool_result block 形式发给模型。
源码注释解释了场景:
10个并行工具,每个结果40K单个工具都没超过per-toollimit但合起来是10×40K=400K
这就会触发 toolResultBudget。
2.3 它为什么按“API-level user message”分组
候选收集逻辑在:
collectCandidatesByMessage(messages)见 src/utils/toolResultStorage.ts:L575-L638 。
/ * Extract candidate tool_result blocks grouped by API-level user message. * * normalizeMessagesForAPI merges consecutive user messages into one * (Bedrock compat; 1P does the same server-side), so parallel tool * results that arrive as N separate user messages in our state become * ONE user message on the wire. The budget must group the same way or * it would see N under-budget messages instead of one over-budget * message and fail to enforce exactly when it matters most. * * A "group" is a maximal run of user messages NOT separated by an * assistant message. Only assistant messages create wire-level * boundaries — normalizeMessagesForAPI filters out progress entirely * and merges attachment / system(local_command) INTO adjacent user * blocks, so those types do NOT break groups here either. * * This matters for abort-during-parallel-tools paths: agent_progress * messages (non-ephemeral, persisted in REPL state) can interleave * between fresh tool_result messages. If we flushed on progress, those * tool_results would split into under-budget groups, slip through * unreplaced, get frozen, then be merged by normalizeMessagesForAPI * into one over-budget wire message — defeating the feature. * * Only groups with at least one eligible candidate are returned. */function collectCandidatesByMessage(messages: Message[],): ToolResultCandidate[][] {const groups: ToolResultCandidate[][] = []let current: ToolResultCandidate[] = []const flush = () => {if (current.length > 0) groups.push(current)current = []}// Track all assistant message.ids seen so far — same-ID fragments are// merged by normalizeMessagesForAPI (messages.ts ~2126 walks back PAST// different-ID assistants via `continue`), so any re-appearance of a// previously-seen ID must NOT create a group boundary. Two scenarios:// • Consecutive: streamingToolExecution yields one AssistantMessage per// content_block_stop (same id); a fast tool drains between blocks;// abort/hook-stop leaves [asst(X), user(trA), asst(X), user(trB)].// • Interleaved: coordinator/teammate streams mix different responses// so [asst(X), user(trA), asst(Y), user(trB), asst(X), user(trC)].// In both, normalizeMessagesForAPI merges the X fragments into one wire// assistant, and their following tool_results merge into one wire user// message — so the budget must see them as one group too.const seenAsstIds = new Set<string>()for (const message of messages) {if (message.type === 'user') {current.push(...collectCandidatesFromMessage(message))} else if (message.type === 'assistant') {if (!seenAsstIds.has(message.message.id)) {flush()seenAsstIds.add(message.message.id)}}// progress / attachment / system are filtered or merged by// normalizeMessagesForAPI — they don't create wire boundaries.}flush()return groups}源码注释说:
normalizeMessagesForAPI会把连续usermessages合并成一个。如果预算检查不按同样规则分组,多个看似分散的小tool_result到API层会合成一个大usermessage。所以它不是简单遍历每条本地消息,而是模拟 API 最终看到的消息结构:
本地:usertool_resultA80Kprogressusertool_resultB80Kattachmentusertool_resultC80KAPI视角:usermessage:A+B+C=240K如果不这样分组,就会漏掉真实超预算情况。
2.4. 哪些 tool_result 可以被处理
候选提取在:
见 src/utils/toolResultStorage.ts:L551-L573 。
它只收集:
是 user messagecontent 是数组block.type === 'tool_result'block.content 存在不是已经 compacted 的内容不包含 image block也就是说,图片类 tool result 不会走这个落盘预览策略。
已经被替换过的内容也不会再次处理,因为它以:
<persisted-output>
开头,见 src/utils/toolResultStorage.ts:L29-L31 。
2.5. 状态设计:seen / replacements
状态定义在:
export type ContentReplacementState = {seenIds: Set<string>replacements: Map<string, string>}见 src/utils/toolResultStorage.ts:L390-L393 。
它有两个核心集合:
seenIds:这个 tool_result 已经被预算逻辑看过。replacements:这个 tool_result 已经被落盘,并且上下文中应该替换成哪段预览文本。为什么要这么设计?为了保持 Prompt Cache 稳定。
一旦某个 tool result 已经完整发给模型,后面就不能突然把它换成预览,否则历史 Prompt 前缀变了,缓存会失效。
所以状态分三类:
mustReapply:以前替换过,每轮继续用同一个预览frozen:以前完整发过,不能再替换fresh:第一次看到,可以决定是否替换对应代码在 src/utils/toolResultStorage.ts:L641-L667 。
2.6. 核心策略:只从 fresh 里选最大的落盘
源码:
/ * Pick the largest fresh results to replace until the model-visible total * (frozen + remaining fresh) is at or under budget, or fresh is exhausted. * If frozen results alone exceed budget we accept the overage — microcompact * will eventually clear them. */function selectFreshToReplace(fresh: ToolResultCandidate[],frozenSize: number,limit: number,): ToolResultCandidate[] {const sorted = [...fresh].sort((a, b) => b.size - a.size)const selected: ToolResultCandidate[] = []let remaining = frozenSize + fresh.reduce((sum, c) => sum + c.size, 0)for (const c of sorted) {if (remaining <= limit) breakselected.push(c)// We don't know the replacement size until after persist, but previews// are ~2K and results hitting this path are much larger, so subtracting// the full size is a close approximation for selection purposes.remaining -= c.size}return selected}选择逻辑:
const sorted = [...fresh].sort((a, b) => b.size - a.size)见 src/utils/toolResultStorage.ts:L675-L692 。
它不是随机删,也不是全部落盘,而是:
按大小从大到小排序选择最大的 fresh tool_result直到剩余可见内容 <= 200K
伪代码:
remaining = frozenSize + sum(freshSize)for result of fresh.sort(desc size):if remaining <= 200K:breakselected.push(result)remaining -= result.size注意:这里的 frozen 不能动。
如果 frozen 自己已经超过 200K,代码接受超预算,交给后续 microcompact 处理。注释在 src/utils/toolResultStorage.ts:L669-L673 。
2.7. 落盘:完整内容保存到 session tool-results 目录
落盘函数:
persistToolResult(content, toolUseId)见 src/utils/toolResultStorage.ts:L137-L184 。
/ * Persist a tool result to disk and return information about the persisted file * * @param content - The tool result content to persist (string or array of content blocks) * @param toolUseId - The ID of the tool use that produced the result * @returns Information about the persisted file including filepath and preview */export async function persistToolResult(content: NonNullable<ToolResultBlockParam['content']>,toolUseId: string,): Promise<PersistedToolResult | PersistToolResultError> {const isJson = Array.isArray(content)// Check for non-text content - we can only persist text blocksif (isJson) {const hasNonTextContent = content.some(block => block.type !== 'text')if (hasNonTextContent) {return {error: 'Cannot persist tool results containing non-text content',}}}await ensureToolResultsDir()const filepath = getToolResultPath(toolUseId, isJson)const contentStr = isJson ? jsonStringify(content, null, 2) : content// tool_use_id is unique per invocation and content is deterministic for a// given id, so skip if the file already exists. This prevents re-writing// the same content on every API turn when microcompact replays the// original messages. Use 'wx' instead of a stat-then-write race.try {await writeFile(filepath, contentStr, { encoding: 'utf-8', flag: 'wx' })logForDebugging(`Persisted tool result to ${filepath} (${formatFileSize(contentStr.length)})`,)} catch (error) {if (getErrnoCode(error) !== 'EEXIST') {logError(toError(error))return { error: getFileSystemErrorMessage(toError(error)) }}// EEXIST: already persisted on a prior turn, fall through to preview}// Generate a previewconst { preview, hasMore } = generatePreview(contentStr, PREVIEW_SIZE_BYTES)return {filepath,originalSize: contentStr.length,isJson,preview,hasMore,}}保存路径来自:
getToolResultPath(toolUseId, isJson)/ * Get the filepath where a tool result would be persisted. */export function getToolResultPath(id: string, isJson: boolean): string {const ext = isJson ? 'json' : 'txt'return join(getToolResultsDir(), `${id}.${ext}`)}目录名是:
tool-results
见 src/utils/toolResultStorage.ts:L26-L34:
// Subdirectory name for tool results within a sessionexport const TOOL_RESULTS_SUBDIR = 'tool-results'// XML tag used to wrap persisted output messagesexport const PERSISTED_OUTPUT_TAG = '<persisted-output>'export const PERSISTED_OUTPUT_CLOSING_TAG = '</persisted-output>'// Message used when tool result content was cleared without persisting to fileexport const TOOL_RESULT_CLEARED_MESSAGE = '[Old tool result content cleared]'落盘后,它生成一个约 2KB 预览:
PREVIEW_SIZE_BYTES = 2000
最终模型看到的 replacement 是:
<persisted-output>Output too large (...). Full output saved to: /path/to/tool-results/toolu_xxx.txtPreview (first 2KB):...</persisted-output>构造逻辑见 src/utils/toolResultStorage.ts:L189-L199 。
2.8. 替换:不删 tool_result,只替换 content
替换代码:
return { ...block, content: replacement }见 src/utils/toolResultStorage.ts:L699-L726:
/ * Return a new Message[] where each tool_result block whose id appears in * replacementMap has its content replaced. Messages and blocks with no * replacements are passed through by reference. */function replaceToolResultContents(messages: Message[],replacementMap: Map<string, string>,): Message[] {return messages.map(message => {if (message.type !== 'user' || !Array.isArray(message.message.content)) {return message}const content = message.message.contentconst needsReplace = content.some(b => b.type === 'tool_result' && replacementMap.has(b.tool_use_id),)if (!needsReplace) return messagereturn {...message,message: {...message.message,content: content.map(block => {if (block.type !== 'tool_result') return blockconst replacement = replacementMap.get(block.tool_use_id)return replacement === undefined? block: { ...block, content: replacement }}),},}})}也就是说:
tool_use 还在tool_result 还在tool_result.content 从完整大文本变成预览文本这样可以保持 Claude API 需要的 tool_use/tool_result 配对结构。
它不是:删除整条 tool_result 而是:保留结构,替换内容
2.9. 为什么 Read 经常会被跳过
query.ts 传了一个 skipToolNames :
new Set(toolUseContext.options.tools.filter(t => !Number.isFinite(t.maxResultSizeChars)).map(t => t.name),)见 src/query.ts:L389-L393 。
在 enforceToolResultBudget 里:
// Tools with maxResultSizeChars: Infinity (Read) — never persist.见 src/utils/toolResultStorage.ts:L816-L823 。
// Tools with maxResultSizeChars: Infinity (Read) — never persist.// Mark as seen (frozen) so the decision sticks across turns. They don't// count toward freshSize; if that lets the group slip under budget and// the wire message is still large, that's the contract — Read's own// maxTokens is the bound, not this wrapper.const skipped = fresh.filter(c => shouldSkip(c.toolUseId))skipped.forEach(c => state.seenIds.add(c.toolUseId))const eligible = fresh.filter(c => !shouldSkip(c.toolUseId))理由是: Read 自己已经有 maxTokens 控制。把 Read 结果落盘,然后让模型再用 Read 读取落盘文件,会形成循环。
2.10. Resume 时如何保持一致
ContentReplacementState 被挂在 ToolUseContext 上:
contentReplacementState?: ContentReplacementState
见 src/Tool.ts:L284-L292 。
REPL 初始化时创建:
provisionContentReplacementState(initialMessages,initialContentReplacements)见 src/screens/REPL.tsx:L1496-L1505 。
Resume 时重建:
reconstructContentReplacementState(messages,log.contentReplacements??[])见 src/screens/REPL.tsx:L1918-L1925 。
query.ts 还会把新 replacement 记录到 transcript:
recordContentReplacement(records,toolUseContext.agentId)见 src/query.ts:L376-L388 。
这保证恢复会话后,同一个 tool_result 仍然被替换成完全相同的预览文本,避免 Prompt Cache 前缀漂移。
3. 代价实际怎么用
0llm,仅仅是多了字符串替换的开销。
4. 与MC(micro Compact)是如何配合的
在每次发起模型请求前,先检查当前消息里工具结果是不是太大。如果某个 API user message 里的多个 tool_result 加起来超过预算,就把最大的结果落盘,并在上下文里替换成预览。
它必须放在 microcompact 前面,因为这是处理“新产生的大结果”,而 microcompact 处理“旧结果清理”。cached microcompact 只看 tool_use_id ,不看具体内容,所以即使这里把内容替换成预览,也不会影响 microcompact 后续按 ID 清理,两者可以安全组合。
如果功能没启用, contentReplacementState 不存在,这段逻辑直接跳过。替换记录只会在可恢复的会话里持久化:主会话写 session transcript,AgentTool 写 sidechain;临时 fork agent 不写,因为它们不会 resume。
-
09.02
ai免费写作一键生成,轻松搞定文案创作
-
09.02
从FDA清单看医疗AI正在进入规模化监管时代讲了什么-主要信息和内容重点
-
09.02
Atoms多模型同时调用请求头设置完整代码怎么做-执行顺序和关键限制
-
09.02
OpenAI正放缓人工智能训练速度有哪些重点-关键信息和实际影响
-
09.02
智慧交通讲了什么-主要信息和内容重点
-
09.02
火山引擎豆包API请求超时故障排查方案怎么看-事实变化和判断依据
-
-
下载
- |
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- |
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏