CC浅读
1. ReAct核心循环的深层结构
1.1 主循环真正围绕的是 messagesForQuery
从 query.ts 的控制流可以看到,系统每一轮真正操作的对象不是原始 messages,而是一个经过边界裁剪与重建的工作副本 messagesForQuery。可以把它理解成“本轮真正准备发给模型的上下文视图”。Claude Code 并不是在“整个会话历史”上反复追加,而是在这个可发送视图上不断加工。
其主要步骤如下:
- 从当前消息链中构造
messagesForQuery。 - 先执行轻量级历史裁剪
snipCompactIfNeeded()。 - 再执行工具结果压缩
microcompact()。 - 再把
systemContext拼入systemPrompt。 - 再执行
autocompact()判断与全量摘要。 - 若发生全量压缩,则通过
buildPostCompactMessages(compactionResult)重建消息链。 - 最终在发给模型前,对消息链调用
prependUserContext(...)。
这里最值得注意的地方,不只是“系统会压缩上下文”,而是“上下文本身是一个在单轮内部连续演算的中间状态对象”。这也是后续 needsFollowUp、工具回填、补救重试能够成立的前提。
如果换一个更形象的视角,可以把 messagesForQuery 理解成“Claude Code 为当前轮临时搭建的一块工作台”。原始会话历史像仓库,里面什么都有;而 messagesForQuery 像工作台,只摆放当前轮真正需要用到的材料。系统会先把历史搬上来,再做一轮清理、压缩、补充和重排,最后才把这块工作台整体递给模型。这种做法带来了两个直接收益:
- 它把“长期保存什么”和“本轮实际发送什么”分离开来,减少了上下文窗口与完整会话历史之间的直接冲突。
- 它让每一轮都能在“已有会话连续性”和“有限上下文预算”之间重新求解平衡,而不是机械地把整个历史越堆越长。
从架构上看,这也解释了为什么 query.ts 会同时承担压缩、恢复、拼接和重试的责任。因为在 Claude Code 中,一轮对话并不是“把消息数组原样送出去”,而是“围绕一份临时工作上下文进行动态编排”。
参考路径:
restored-src/src/query.tsrestored-src/src/services/compact/compact.ts
1.2 snipCompactIfNeeded() 更像一个前置裁剪器(待核实)
从 query.ts 可以直接确认,snipCompactIfNeeded() 发生在 microcompact() 与 autocompact() 之前。换句话说,snip 更像是上下文进入深度压缩前的一次“快速整理”:先用较低成本移除最边缘、最不值得保留的历史负担,再把剩余内容交给后续更昂贵的压缩步骤。
这一顺序具有严格的控制逻辑:
- 若 snip 已经释放足够 token,则
autocompact()可能完全不触发。 - 因为 snip 发生在
microcompact()之前,所以它面对的是原始消息边界,而不是已经被 tool-result 压缩后的消息。 snipTokensFreed会被继续传递给autocompact()的阈值判断逻辑,因此 snip 不是独立动作,而是会影响后续阶段的判定条件。
但是,当前快照中缺失 restored-src/src/services/compact/snipCompact.ts/js 实体文件,因此无法从源码证明以下内容:
- 裁剪的具体启发式。
- 是否按 turn、消息类型、时间或角色分层裁剪。
- snip 之后是否插入特殊标记消息。
因此,本节只能确认其在控制流中的位置与职责边界,不能确认其算法细节。
尽管内部算法尚不可见,但仅凭控制流位置仍然可以看出 snip 的战略意义。它承担的是一种“预算前置整理”角色:在进入更复杂的压缩机制前,优先处理那些最不值得保留的边缘历史。这样的层级安排很像编译器中的早期优化 pass。早期 pass 不需要理解所有高层语义,只需要先把显而易见的冗余清掉,后续更昂贵、更精细的阶段才有更干净的输入。
对外部读者来说,可以把 snip 看成 Claude Code 的第一道“上下文门卫”。它不一定最聪明,但它最靠前;它不一定最懂语义,但它最先影响后续成本。
参考路径:
restored-src/src/query.tsrestored-src/src/QueryEngine.ts
1.3 microcompact() 可以理解为“旧工具结果去负载化”
microCompact.ts 的设计目标并不是为整段对话生成摘要,而是削减历史工具输出的载荷。与 autocompact() 相比,它不负责“提炼一段对话的结论”,而更像是在做“上下文减重”:让那些已经用过、但仍占据大量 token 的工具输出逐步退出前台。
其控制思路可以分为两条路径。
1.3.1 基于时间的旧结果清空
microcompactMessages() 首先尝试 maybeTimeBasedMicrocompact()。若上一条主线程 assistant 消息与当前轮之间的时间间隔过长,系统会把旧的 tool_result 内容替换为:
[Old tool result content cleared]
这一动作不是语义重写,而是显式删除。它依赖的前提是:过期的 prompt cache 前缀已经不再具有高价值,继续保留旧工具结果只会增大重发体积,而不会显著提高当前轮推理质量。
timeBasedMCConfig.ts 的注释清楚说明,这一策略针对的是服务端 prompt cache TTL 失效后的情形。
1.3.2 基于缓存编辑的 API 层压缩
若开启 CACHED_MICROCOMPACT,且模型支持 cache editing,则会进入 cachedMicrocompactPath()。这一分支非常关键,因为它说明 Claude Code 的压缩并不全部发生在本地消息对象上。该路径:
- 不直接改写本地
messages。 - 而是在 API 层生成
cache_edits。 - 通过
cache_reference和cache_edits对远端缓存前缀做减法。
这意味着“上下文压缩”在 Claude Code 中并不是单层机制,而是同时存在:
- 本地消息对象压缩。
- 远端缓存前缀压缩。
若不区分这两层,就会错误地把所有压缩都理解成“本地删消息”。
这一点很值得强调,因为它会直接影响对 Claude Code 行为的判断。很多时候,从界面上看,会感觉“模型似乎还记得刚刚的工具结果”,但从本地消息视角又已经看不到完整内容了。原因正可能出在这里:一部分压缩发生在本地,一部分压缩发生在远端缓存控制层,两者不是完全重合的。对使用者来说,这意味着“上下文是否保留”并不是一个单一答案,而取决于是在本地视角、远端缓存视角,还是模型本轮请求体视角去观察。
1.3.3 为什么 microcompact() 会早于 autocompact()
query.ts 中明确写出 microcompact 在 autocompact 之前。这一顺序很好理解:系统会先尝试用较低代价清理旧工具结果,再决定是否需要更昂贵的摘要。如果先摘要,再清理工具结果,那么大量原本可以直接删除的工具输出就会不必要地进入摘要输入,增加 token 负担和摘要噪声。
参考路径:
restored-src/src/services/compact/microCompact.tsrestored-src/src/services/compact/timeBasedMCConfig.tsrestored-src/src/query.ts
1.4 autocompact() 更像一次结构化再建模
autoCompact.ts 的功能并不只是生成一段概括文本,而是在上下文超出阈值时,把消息链转换为新的结构性表示。compact.ts 中的 CompactionResult 至少包含:
boundaryMarkersummaryMessagesattachmentshookResults- 可选的
messagesToKeep
从这一结构可以得出两个严格结论。
1.4.1 全量压缩的输出更接近“上下文包”
如果只把 autocompact 理解成“生成 summary”,就无法解释:
- 为什么压缩后还能恢复计划文件。
- 为什么已调用技能能够重新注入。
- 为什么 hook 结果和附件可以在压缩后继续存在。
因为 autocompact 的真实输出不是一句话,而是一组后续可再组装的数据结构。
从工程角度看,这种设计比“生成一段摘要替换整个历史”要复杂得多,但它带来的好处也很明显。单纯的摘要文本虽然简单,却很容易丢掉控制信息,例如计划文件位置、技能约束、任务状态来源、hook 的上下文语义等。而 CompactionResult 把“摘要内容”和“运行态控制信息”分开保存,使系统能够在压缩之后既减少 token,又保留后续行为所需的关键结构。
1.4.2 压缩后仍保留控制语义
buildPostCompactMessages(compactionResult) 的存在表明,全量压缩并不会丢掉“这条上下文是怎么来的”。系统仍然会把边界、附件和恢复对象重新变成模型可见的消息。因此,压缩的目标不是消灭历史,而是改变历史的表示形式。
参考路径:
restored-src/src/services/compact/autoCompact.tsrestored-src/src/services/compact/compact.tsrestored-src/src/query.ts
1.5 needsFollowUp 是单轮是否闭合的最终判据
query.ts 中的 needsFollowUp 决定一轮推理是直接终止,还是继续进入“工具执行 -> 工具结果回填 -> 再次调用模型”的内部子循环。其逻辑可以概括为:
- 模型若只给出最终答复,则
needsFollowUp = false。 - 模型若输出工具调用、尚未完成的步骤或需要继续推理的状态,则
needsFollowUp = true。 - 单轮对话直到
needsFollowUp归零才真正结束。
这意味着 Claude Code 的“单轮”不是一个 LLM 请求,而是若干次 “流式读取 -> 执行 -> 回填 -> 续跑” 组成的闭包式计算单元。
这也是 Claude Code 与最朴素“聊天式 LLM 封装”之间的重要区别。许多简单应用的一轮交互只有一次请求和一次回复,而 Claude Code 的一轮更接近一个小型状态机:模型在中间阶段并不会立刻给出“最终答案”,而是先输出工具意图、等待外部结果,再继续推理。把它理解成“多步闭环中的一个轮次”会比理解成“单个模型回复”更接近真实运行方式。
参考路径:
restored-src/src/query.ts
1.6 补救路径也是主循环的一部分
当前快照可直接确认两种恢复路径:
reactive_compact_retrymax_output_tokens_escalate/max_output_tokens_recovery
这说明错误恢复并非外围异常处理,而是被编织进状态机中的正式分支。即使 reactiveCompact.ts 文件缺失,控制流中仍可看到系统把“413 触发上下文压缩重试”纳入了标准机制。
从系统设计角度看,这一点很重要。它意味着 Claude Code 并没有把“上下文超限”视作偶然失败,而是把它当成高概率、可预期的运行事件。也正因为如此,压缩并不是一个孤立模块,而是与重试、输出 token 升级、补救状态迁移共同组成一个完整的容错回路。
参考路径:
restored-src/src/query.tsrestored-src/src/services/api/withRetry.tsrestored-src/src/services/compact/reactiveCompact.ts(文件缺失,仅能确认引用)
2. Prompt 构造的真实层次
2.1 Claude Code 的模型输入并不是“单一 prompt 字符串”
从源码看,Claude Code 发给模型的输入由四类对象共同构成:
systemPrompt[]messages[]tools[]- 某些工具的运行时输入增强,例如
normalizeToolInput()
因此,“Prompt 构造”更适合被理解为“构造一整份模型请求体”。在 Claude Code 中,system prompt 只是模型输入的一部分;另一部分关键约束来自 isMeta: true 的隐藏消息、attachment 转译消息以及工具 schema。
这也是许多读者在第一次阅读 Claude Code 源码时最容易产生偏差的地方。直觉上,人们常常会把“提示词”理解成一段很长的 system prompt 文本;但在 Claude Code 中,真正影响模型行为的内容分散在多个输入通道里:
- system prompt 决定全局身份、通用规则和运行时指导。
- hidden user messages 决定局部上下文补充,例如
claudeMd、计划模式、skill 正文等。 - attachment 翻译消息决定某些系统态信息如何被模型看到。
- tools schema 决定模型可行动的边界,也会反过来塑造它的回答方式。
因此,如果只看 system prompt,很容易把 Claude Code 误解成“一个提示词驱动的聊天机器人”;而从完整请求体看,它实际上更像“提示词、消息、工具和运行态信息共同驱动的执行型代理”。
这也是为什么仅阅读 constants/prompts.ts 还不足以完整理解提示词系统,通常还需要同时阅读:
utils/systemPrompt.tsutils/api.tsconstants/system.tsprocessSlashCommand.tsxmessages.ts
参考路径:
restored-src/src/constants/prompts.tsrestored-src/src/utils/systemPrompt.tsrestored-src/src/utils/api.tsrestored-src/src/constants/system.tsrestored-src/src/utils/processUserInput/processSlashCommand.tsxrestored-src/src/utils/messages.ts
2.2 buildEffectiveSystemPrompt() 决定“这一轮以哪种身份说话”
buildEffectiveSystemPrompt() 解决的不是单纯的文案拼接问题,而是“这一轮 system prompt 以谁为主体”的优先级问题。其顺序为:
overrideSystemPromptcoordinatoragentcustomSystemPromptdefaultSystemPromptappendSystemPrompt永远尾部追加
从阅读体验上看,这套顺序更像一组“角色选择规则”:
override是完全替换机制。coordinator与agent属于角色切换机制。custom是用户自定义主体。append不是主体,它只是尾部补充约束。
因此,appendSystemPrompt 更适合被理解为“尾部补充说明”,而 customSystemPrompt 更接近“替换默认主体”。
这一层优先级其实在回答一个更底层的问题:模型这一轮究竟是谁。是普通的 Claude Code,还是一个带有协调器视角的 orchestrator,还是某个内置 agent,还是用户临时指定的一套 system rule。只有先决定“谁在说话”,后续的静态段、动态段、上下文注入才有明确归属。因此,buildEffectiveSystemPrompt() 虽然代码不长,却是整条提示词构造链中最接近“入口总开关”的函数之一。
原始源码规则如下:
/**
* Builds the effective system prompt array based on priority:
* 0. Override system prompt
* 1. Coordinator system prompt
* 2. Agent system prompt
* 3. Custom system prompt
* 4. Default system prompt
*
* Plus appendSystemPrompt is always added at the end if specified.
*/
参考路径:
restored-src/src/utils/systemPrompt.ts
2.3 getSystemPrompt() 负责生成默认提示词的“静态层 + 动态层”
getSystemPrompt() 并不是最终 system prompt,而是“默认主体”的生成器。它的输出是一个字符串数组,而不是一整段长字符串。这一点很重要,因为后续缓存切分与前缀识别都依赖“数组中的块级边界”。
其结构可以分成两层:
- 静态层:
getSimpleIntroSection()、getSimpleSystemSection()、getSimpleDoingTasksSection()、getActionsSection()、getUsingYourToolsSection()、getSimpleToneAndStyleSection()、getOutputEfficiencySection() - 动态层:
dynamicSections
二者之间由 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分隔。
原始边界标记如下:
__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__
这不是普通文本,而是供缓存切分逻辑识别的控制标记。
在很多系统里,prompt 的“段落边界”只是阅读辅助;但在 Claude Code 中,这个边界会直接影响缓存策略。换句话说,段落结构不是为了让人看得更舒服,而是为了让系统知道“哪些内容适合长期复用,哪些内容必须每轮重新计算”。
参考路径:
restored-src/src/constants/prompts.tsrestored-src/src/utils/api.ts
2.4 静态层的一个核心作用,是构造稳定缓存前缀
静态层之所以重要,不只是因为它包含规则,更因为它在缓存意义上应尽量保持稳定。splitSysPromptPrefix() 会识别边界标记,并将 system prompt 切分为:
- attribution header
- CLI sysprompt prefix
- 静态内容
- 动态内容
在启用全局缓存时:
- 静态内容之前的 system prompt 前缀会被单独处理。
- 边界前的静态内容可以使用
cacheScope: 'global'。 - 边界后的动态内容不进入全局缓存块。
从系统设计角度看,静态层未必意味着“语义上更重要”,但通常意味着“缓存上更稳定”。如果把运行时波动较大的指导误放进静态层,就会让前缀哈希更容易碎片化。
这件事背后的思路可以简单理解成:Claude Code 希望让那些“几乎每轮都一样”的内容尽量稳定,从而把更多 token 预算留给真正变化的上下文。静态层越稳定,系统越容易在多轮交互中复用已有前缀;动态层越集中,系统越容易把变化限定在较小范围内。对大模型代理来说,这不只是性能优化,也是一种规模化运行时必须考虑的成本控制手段。
参考路径:
restored-src/src/utils/api.tsrestored-src/src/services/api/claude.ts
2.4.1 静态层首先是一套“长期不变的制度文本”
如果把 getSystemPrompt() 生成的默认主体看成一份制度文件,那么静态层就是其中相对稳定、跨轮复用、跨任务通用的部分。它通常不依赖本轮任务的具体内容,也不依赖某个瞬时运行状态,而是定义 Claude Code 在大多数场景下都应遵守的基本操作原则。
从当前源码可以直接看到,静态层至少包含这些 section:
getSimpleIntroSection()getSimpleSystemSection()getSimpleDoingTasksSection()getActionsSection()getUsingYourToolsSection()getSimpleToneAndStyleSection()getOutputEfficiencySection()
这些 section 虽然都属于提示词,但其职责并不相同:
- 引言段负责定义代理的基本身份。
- system section 负责定义更高层的行为边界。
- doing tasks section 负责说明任务执行时的总体策略。
- actions section 负责高风险动作的谨慎原则。
- tools section 负责工具选择、并行和专用工具优先原则。
- tone/style section 负责用户可见表达方式。
- output efficiency section 负责压缩冗余输出,保持结果可读。
也就是说,静态层并不是“一段大而全的背景介绍”,而是一组稳定制度的组合体。它的关键词不是“当前任务”,而是“默认治理”。
2.4.2 静态层为什么必须单独存在
静态层单独存在,不只是为了缓存,更是为了让系统把“长期规则”和“短期变化”分离。这样做至少有四个好处:
-
减少角色漂移
因为身份、风险控制、工具规范等核心规则不会随每轮波动,模型更容易保持长期稳定的操作风格。 -
降低提示词碎片化
若把会话态、环境态、模式态内容都混入同一层,每次微小变化都会导致前缀整体变化,难以形成稳定行为模板。 -
便于缓存与复用
稳定制度文本天然适合被切成可复用前缀,这不仅降低成本,也让多轮交互的“基本人格”更一致。 -
便于审查与演化
一旦静态层单独成段,开发者就更容易判断:某条规则究竟属于长期政策,还是某个运行模式下的临时补充。
从提示词工程角度看,静态层其实承担着一种“宪法”功能。它不负责解释每一件具体事情,但负责规定“任何具体事情都应在什么原则下处理”。
2.4.3 静态层对模型行为的直接影响
静态层最容易被低估的地方,是它虽然“稳定”,却往往最深地塑造模型的默认行为模式。以当前源码中的几类 section 为例:
getActionsSection()决定模型遇到高风险动作时的默认谨慎阈值。getUsingYourToolsSection()决定模型遇到多个工具时是否会优先考虑专用工具与并行调用。getOutputEfficiencySection()决定模型是否倾向于输出冗长过程说明,还是尽量给出高密度结论。getSimpleToneAndStyleSection()决定模型在用户面前呈现出来的语气、简洁度和说明方式。
因此,静态层虽然不像动态层那样“每轮都在变”,但它更像基础操作系统。动态层是在这个系统上加载的当前环境参数,而静态层决定了这个系统究竟是一套什么风格、什么风险偏好、什么工具观的制度框架。
2.4.4 静态层为什么值得单独讲,而不能只附带提到
在很多源码导读里,静态层容易被一句“就是默认 prompt 的固定部分”带过。但在 Claude Code 中,这样的处理会损失大量理解力。原因在于:
- Claude Code 的许多典型行为,例如“优先专用工具”“支持并行工具调用”“高风险动作先确认”“控制答复长度”,本质上都不是运行时偶发现象,而是静态层长期塑造的结果。
- 如果不把静态层单独拿出来讲,就很容易误以为 Claude Code 的行为主要由动态注入决定。
- 实际上,动态层主要负责适配当前局面,而静态层才决定“这个代理默认像谁、习惯如何行动、哪些行为被长期鼓励或限制”。
因此,静态层在整套 Prompt 架构里不是附属物,而是底座。
参考路径:
restored-src/src/constants/prompts.tsrestored-src/src/utils/systemPrompt.ts
2.5 动态层更适合承接运行时波动
dynamicSections 包括:
session_guidancememoryant_model_overrideenv_info_simplelanguageoutput_stylemcp_instructionsscratchpadfrcsummarize_tool_results- 某些 feature gate 下的
numeric_length_anchors - 某些 feature gate 下的
token_budget brief
这些段落的共同特征是:它们依赖会话态、环境态、MCP 连接态、feature gate 或模型能力,因此更适合放在动态区域,而不是放进全局稳定前缀。
例如:
mcp_instructions依赖 MCP 连接状态。language依赖用户语言偏好。output_style依赖输出风格配置。frc依赖模型是否支持 cached microcompact。
换言之,动态层可以看作系统专门留给“运行时波动信息”的区域。
如果继续展开看,动态层的意义不仅是“放变化的东西”,更是把不同类别的变化彼此隔离。例如语言偏好、MCP 连接状态、输出风格、scratchpad 路径和 token budget 指令,其变化速度、变化来源和对行为的影响都不同。把它们统统塞进一段不可分辨的大文本里,虽然也能工作,但会让排错、缓存、实验开关和中间态观察都变得困难。动态段机制的价值,就在于把这些变化显式模块化。
参考路径:
restored-src/src/constants/prompts.ts
2.5.1 动态层不是“杂项区”,而是运行时控制面的集合
动态层表面上看像一组零散 section,但如果按功能重新整理,会发现它们大致可以分成几类:
-
会话指导类
例如session_guidance、brief,用于调节当前轮的回答策略与会话行为。 -
环境与身份补充类
例如env_info_simple、language、output_style,用于告诉模型当前环境、语言偏好与呈现偏好。 -
外部能力接入类
例如mcp_instructions,用于描述当前连接到哪些外部能力、模型应该如何使用它们。 -
上下文生存策略类
例如scratchpad、frc、summarize_tool_results,用于告诉模型上下文可能怎样被清理、哪些信息要主动保留。 -
预算与长度约束类
例如token_budget、numeric_length_anchors,用于引导模型控制输出和推理成本。
这样一看就会发现,动态层并不是一个“剩下的都放这里”的杂物箱,而是 Claude Code 专门给运行时状态预留的控制面板。静态层决定长期制度,动态层则负责把“今天这轮到底是什么情况”告诉模型。
2.5.2 动态层为什么必须从静态层中分离出来
动态层与静态层分离,原因不仅是技术性的缓存需要,更是语义层面的职责划分。因为动态层里的很多内容都具有以下特点:
- 它们变化频率高。
- 它们与当前会话或当前环境紧密相关。
- 它们不适合被误解为“永恒规则”。
例如:
- 当前连接了哪些 MCP server,会变。
- 当前输出风格是否更 brief,会变。
- 当前模型是否支持 cached microcompact,会变。
- 当前 scratchpad 路径是什么,会变。
- 当前 token budget 约束是什么,会变。
如果这些信息被混入静态层,就会造成两个问题:
- 在语义上,模型可能把暂时性条件误读为长期规则。
- 在工程上,prompt 前缀会因为小变动频繁失稳,降低复用价值。
因此,动态层的独立存在,本质上是在为“高频变化但仍然重要”的运行时信息预留合法位置。
2.5.3 动态层中最关键的一类,是“告诉模型上下文本身会变化”
动态层里有一类 section 特别重要,它们不是直接提供任务信息,而是在告诉模型:当前上下文环境本身并不稳定。例如:
frc提醒旧工具结果会被清理。summarize_tool_results要求模型主动把重要事实写回文本。scratchpad告诉模型临时文件应该写到哪里,以便后续持续访问。
这类指令的作用非常深。它们改变的不是“这轮回答什么”,而是“模型应当如何应对一个会不断丢失局部信息的工作环境”。在普通聊天系统里,模型通常默认前文相对稳定;而在 Claude Code 这种长会话代理里,系统必须明确告诉模型:某些上下文可能随时被压缩、清空或替换,重要信息不要只依赖工具结果本身存在。
也正因为如此,动态层并不只是附加事实,更是在训练模型如何适应 Claude Code 的运行生态。
2.5.4 动态层实际上承担着“把运行时状态语言化”的职责
从更一般化的角度看,动态层是在做一件很重要的事情:把原本存在于程序状态中的对象,翻译成模型能够理解的语言化信号。
例如:
- MCP 连接状态本来是程序内部对象,但
mcp_instructions会把它转成模型可读的能力说明。 - 输出样式本来是配置项,但
output_style会把它转成行为指导。 - tool result clearing 本来是上下文管理策略,但
frc会把它转成模型可理解的工作约束。
因此,动态层本质上是“运行态到提示词”的翻译层。它的存在让模型不仅知道任务本身,还知道自己正处于一个怎样的系统环境中工作。
2.5.5 动态层为什么同样值得单独讲
如果只把动态层概括成“会话相关的可变 section”,仍然太浅。之所以它值得单独展开,是因为 Claude Code 的很多高级能力,恰恰不是写死在静态规则里,而是通过动态层对当前轮进行精细调节实现的。比如:
- 连接外部工具生态时,动态层决定模型能看到哪些额外能力说明。
- 进入长会话与压缩场景时,动态层决定模型是否会主动摘要关键结果。
- 在不同输出模式下,动态层决定模型的表达密度、语言与简洁度。
- 在实验特性开启时,动态层承担 feature-gated 指令的入口。
如果静态层像底座,那么动态层更像仪表盘。底座决定机器是什么,仪表盘决定机器此刻处于什么状态、应该怎样应对当前路况。
参考路径:
restored-src/src/constants/prompts.tsrestored-src/src/utils/api.ts
2.6 原始 prompt 模板参考
2.6.1 prependUserContext() 原文模板
这是一个很典型的“模型可见,但不属于 system prompt”的注入点。源码中的原始模板如下:
<system-reminder>
As you answer the user's questions, you can use the following context:
# {key1}
{value1}
# {key2}
{value2}
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
</system-reminder>
这段模板的作用可以概括为三点:
- 它把
claudeMd、currentDate等用户上下文置于消息层,而不是 system 层。 - 它通过
<system-reminder>标签提示模型“这是系统加的上下文,不是用户刚刚说的话”。 - 它明确要求模型只在相关时使用这些内容,从而抑制无关记忆泄漏到答复正文。
进一步说,prependUserContext() 体现了一种很典型的“软约束”思路。系统没有把 claudeMd 之类内容硬写进 system prompt 的核心身份描述里,而是以 <system-reminder> 的形式把它们放到消息层。这会让模型更容易把这些内容当作“需要参考的背景资料”,而不是“必须每句都服从的顶层宪法”。这种分层可以降低背景资料对主规则的干扰,也让局部上下文更容易随轮次变化。
原始实现位置:
createUserMessage({
content: `<system-reminder>\nAs you answer the user's questions, you can use the following context:\n${Object.entries(
context,
)
.map(([key, value]) => `# ${key}\n${value}`)
.join('\n')}
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n`,
isMeta: true,
})
参考路径:
restored-src/src/utils/api.ts
2.6.2 appendSystemContext() 原始序列化规则
appendSystemContext() 本身不是自然语言模板,而更像一个序列化器。它把 systemContext 映射为 key: value 形式,并作为新的尾部块追加到 system prompt 数组。
源码如下:
export function appendSystemContext(
systemPrompt: SystemPrompt,
context: { [k: string]: string },
): string[] {
return [
...systemPrompt,
Object.entries(context)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
].filter(Boolean)
}
这里的关键结论是:systemContext 不会被包装成 # Section 结构,也不会带 <system-reminder>,而是作为一个新的 system block 直接接到 systemPrompt[] 末尾。
这反映出 systemContext 在架构中的定位与 userContext 明显不同。前者更像“系统自身对当前运行环境的补充说明”,后者更像“给模型看的背景材料”。一个留在 system 层,一个放在消息层,目的正是为了让模型在心智上区分“平台规则”和“工作背景”。
参考路径:
restored-src/src/utils/api.tsrestored-src/src/context.ts
2.6.3 CLI 前缀原文
CLI sysprompt prefix 有三种候选值:
You are Claude Code, Anthropic's official CLI for Claude.
You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.
You are a Claude agent, built on Anthropic's Claude Agent SDK.
这些前缀的职责不是提供行为细节,而是定义“当前客户端身份”。它们由 getCLISyspromptPrefix() 根据是否非交互式、是否带 append system prompt、是否是 Vertex 提供者等条件选择。
参考路径:
restored-src/src/constants/system.ts
2.6.4 Attribution Header 原文模板
这同样不是给人看的自然语言提示,而是放在 system prompt 数组最前部的标识性文本块:
x-anthropic-billing-header: cc_version={version}; cc_entrypoint={entrypoint};{cch}{workload}
它的作用可以从三个层面理解:
- 让 API 端识别调用来自 Claude Code。
- 把版本与入口点串进请求体。
- 在某些构建下加入 attestation 占位符与 workload 信息。
因此,它是“systemPrompt 中的协议性前缀”,不是语义规则。
如果把 system prompt 比作一封信,那么 Attribution Header 更像信封上的邮政标签,而不是信件正文。它不直接告诉模型“应该如何回答”,但它会影响请求在平台侧如何被识别、归档和处理。这种协议性文本放在 prompt 数组前部,也说明 Claude Code 的“提示词系统”同时承载了语义与协议两类信息。
参考路径:
restored-src/src/constants/system.tsrestored-src/src/services/api/claude.ts
2.6.5 静态 system prompt 的关键原文
以下内容并不是整个静态 prompt 的全部分支,而是当前源码中最核心、最稳定的原始模板。
A. 引言段 getSimpleIntroSection()
You are an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
{CYBER_RISK_INSTRUCTION}
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
B. 风险动作控制段 getActionsSection()
# Executing actions with care
Carefully consider the reversibility and blast radius of actions. Generally you can freely take local, reversible actions like editing files or running tests. But for actions that are hard to reverse, affect shared systems beyond your local environment, or could otherwise be risky or destructive, check with the user before proceeding.
这一段的核心作用,不只是提醒模型“要谨慎”,而是把“高风险动作需要再次确认”写进默认 system prompt,使其成为全局行为约束。
C. 工具使用段 getUsingYourToolsSection()
其中最关键的原文如下:
Do NOT use the Bash tool to run commands when a relevant dedicated tool is provided.
以及:
You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel.
这两条直接塑造了 Claude Code 的工具偏好与并行倾向。
从代理行为学的角度看,这两类规则非常关键。第一条把模型从“万物皆 Bash”的倾向拉回到“优先使用专门工具”的轨道上;第二条则鼓励模型在无依赖关系时主动并行化。一个约束工具选择,一个鼓励执行效率,它们共同决定了 Claude Code 在代码助手场景中的典型操作风格。
D. 输出效率段 getOutputEfficiencySection()
当前源码中,这一段并不是装饰性的风格文本,而是显式压缩用户可见输出长度与结构复杂度的控制段。
它的重要性往往在长对话中最明显。对于一个持续调用工具、不断回填结果的代理系统来说,如果没有额外的输出效率约束,模型很容易把每一步都解释得过长,导致用户真正想看的信息被过程性描述淹没。输出效率段可以看作 Claude Code 在“代理自主性”与“人类可读性”之间建立的一条护栏。
参考路径:
restored-src/src/constants/prompts.ts
2.6.6 动态段原文
A. Scratchpad 指令
# Scratchpad Directory
IMPORTANT: Always use this scratchpad directory for temporary files instead of `/tmp` or other system temp directories:
`{scratchpadDir}`
这段的重点并不在于“如何推理”,而在于约束临时文件的落点。
B. Function Result Clearing 指令
# Function Result Clearing
Old tool results will be automatically cleared from context to free up space. The {keepRecent} most recent results are always kept.
这一段的目的,是提前告诉模型“工具结果可能会被清理”,从而促使它把关键事实主动写进后续文本中。
C. Summarize Tool Results 指令
When working with tool results, write down any important information you might need later in your response, as the original tool result may be cleared later.
这实际上可以看作 microcompact() 的语言层补偿。系统在做“旧工具结果可能被清理”这一控制动作时,同时给模型一条显式的自我摘要指令,用来减少后续信息丢失。
这一点也体现了 Claude Code 的一个整体设计特征:很多底层机制并不会只在代码层单独实现,而会同时给模型一条配套的语言提示。代码层负责真正执行控制动作,prompt 层负责让模型提前知道这种控制动作的存在。这样做的好处是,模型在推理时不会把系统行为当成“不可预测噪声”,而会把它纳入自己的规划之中。
参考路径:
restored-src/src/constants/prompts.ts
2.7 Prompt 缓存切分是 system prompt 架构的一部分
splitSysPromptPrefix() 明确区分三类模式:
- MCP 工具存在时,系统 prompt 退回 org 级缓存。
- 启用全局缓存且存在边界标记时,静态段使用
global缓存,动态段不进入全局缓存。 - 默认模式下,前缀与主体使用
org级缓存。
因此,SYSTEM_PROMPT_DYNAMIC_BOUNDARY 的存在不只是注释性标记,而是缓存拓扑中的控制阀门。去掉该标记会影响:
- 静态前缀复用。
- 动态段隔离。
- 提示词块级缓存策略。
对外部读者来说,可以把这个边界看作一把分隔刀:它把“尽量长期稳定的部分”和“几乎每轮都会变化的部分”硬切开。没有这把刀,系统虽然仍能运行,但很难再精准地区分哪些内容值得缓存,哪些内容每轮都应该重新计算。
参考路径:
restored-src/src/utils/api.tsrestored-src/src/services/api/claude.ts
2.8 Slash Command 与 Skill 的正文主要通过 isMeta 用户消息注入
这一部分在阅读源码时很容易混淆。processSlashCommand.tsx 中,prompt 型命令不会把技能正文直接并入 system prompt,而是构造如下结构:
- 一个 metadata 消息,记录“哪个命令被调用了”。
- 一个
isMeta: true的用户消息,把技能正文作为内容送入模型。 - 若有附件,还会继续挂接 attachment 消息。
- 再追加一条
command_permissionsattachment。
核心代码如下:
const messages = [
createUserMessage({
content: metadata,
uuid
}),
createUserMessage({
content: mainMessageContent,
isMeta: true
}),
...attachmentMessages,
createAttachmentMessage({
type: 'command_permissions',
allowedTools: additionalAllowedTools,
model: command.model
})
]
因此,从模型视角看,slash command / skill 的正文更接近“模型可见的隐藏用户消息”,而不是 system prompt。
这一区分很有帮助。system prompt 更像平台赋予模型的长期规则,而 skill 正文更像“当前轮临时塞给模型的一份工作手册”。二者虽然都会影响模型行为,但影响方式不同:前者定义身份与边界,后者提供当前任务所需的具体流程、风格或约束。
2.8.1 Slash command metadata 原文
<command_message>{commandName}</command_message>
<command_name>/{commandName}</command_name>
<command-args>{args}</command-args>
2.8.2 Model-only skill metadata 原文
<command_message>{skillName}</command_message>
<command_name>{skillName}</command_name>
<skill-format>true</skill-format>
这类 metadata 的作用并不在于直接提供任务内容,而在于为消息渲染、技能身份标记和后续处理建立结构化外壳。
这也说明 slash command 在 Claude Code 中并不只是“把一段文本替换成另一段文本”那么简单。它同时承担了三个任务:向界面层提供可渲染的命令身份、向模型层提供实际正文、向后续上下文管理层提供可追踪的命令来源。metadata 正是这三层之间的连接点。
参考路径:
restored-src/src/utils/processUserInput/processSlashCommand.tsx
2.9 Coordinator 模式下的 skill 注入会被替换成“摘要提示”
processSlashCommand.tsx 中有一个非常关键的分支:当 coordinator mode 打开且当前是主线程时,不会加载完整 skill 正文,而是向协调器注入一段摘要性说明,指导它把技能委托给 worker。
原始提示中最关键的句子如下:
Skill "/{command.name}" is available for workers.
Instruct a worker to use this skill by including "Use the /{command.name} skill" in your Agent prompt.
这里的架构意图十分明确:coordinator 不消费完整技能语义,而只消费“如何把技能交给 worker”的调度性提示。也就是说,同一技能在主线程与 worker 线程中的提示词呈现形式不同。
这恰好体现了 coordinator 模式的角色设计。主线程协调器并不负责亲自执行每个技能,因此没有必要把完整技能正文塞进它的上下文中;否则既浪费上下文,也可能让协调器被大量执行细节淹没。相反,它只需要知道“这个技能存在、它适合何时使用、应该如何委托给 worker”。真正的技能正文只在需要实际执行时交给 worker。这是一种很典型的“调度层看摘要、执行层看正文”的分层设计。
参考路径:
restored-src/src/utils/processUserInput/processSlashCommand.tsx
2.10 记忆选择本身也有自己的系统提示词
长期记忆检索不是字符串匹配,而是调用 findRelevantMemories.ts 中的 sideQuery(),并给一个独立的系统提示词:
You are selecting memories that will be useful to Claude Code as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
Return a list of filenames for the memories that will clearly be useful to Claude Code as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.
这说明记忆系统并不是简单的文件加载器,而更像“辅助模型选择器 + 主模型消费器”的两级结构。
从用户体验看,这种设计还能避免一种常见问题:如果把所有记忆文件全部塞进主上下文,短期内虽然看似“信息更全”,但长期会迅速拖垮上下文预算,也会提升无关信息干扰当前任务的概率。先做筛选,再做注入,本质上是在“记忆全面性”和“当前任务相关性”之间寻找平衡。
参考路径:
restored-src/src/memdir/findRelevantMemories.ts
3. 工具执行层的深层结构
3.1 工具调度遵守“批次”逻辑
toolOrchestration.ts 先通过 partitionToolCalls() 把工具调用切成若干批次,每个批次满足以下条件之一:
- 单个非并发安全工具。
- 一组连续的并发安全工具。
这意味着并发与串行并不是对“工具种类”的简单静态分类,而是对“当前消息流中的工具块序列”做动态分批。这样的设计既能保持顺序语义,又能在局部获得并发收益。
这里可以把工具层想成一条装配线。系统不会盲目地把所有工具都并起来,也不会保守到每个工具都串行执行,而是先判断哪些步骤彼此独立、哪些步骤会相互影响,再按批次安排执行顺序。对一个需要同时操作文件、搜索代码、运行命令、调用 agent 的系统来说,这种“有条件并行”比“全并行”或“全串行”都更接近工程上的最优解。
参考路径:
restored-src/src/services/tools/toolOrchestration.ts
3.2 并发安全判定依赖 inputSchema + isConcurrencySafe(input)
partitionToolCalls() 会先对输入做 safeParse,然后调用工具定义的 isConcurrencySafe(parsedInput.data)。因此,并发安全性并不只是“这个工具天生只读”,而是“给定这次具体参数后,这次调用是否并发安全”。
这为 Bash 等工具留下了更细粒度的空间:同一工具在不同输入下可能落入不同批次。
这也是 Claude Code 工具系统比表面上看起来更细腻的地方。它判断的不是“这个工具名字是不是只读”,而是“这一轮、这个参数、这次调用是否适合并发”。这样的判定方式更贴近真实工程场景,因为很多工具的安全性与副作用都不是由名字决定的,而是由具体输入决定的。
参考路径:
restored-src/src/services/tools/toolOrchestration.ts
3.3 StreamingToolExecutor 解决的是“流式到达”与“顺序回填”之间的张力
如果 assistant 在流式输出中陆续给出多个工具调用,系统面临一个结构性矛盾:
- 希望尽早启动工具,减少等待时间。
- 又必须保证结果回填顺序与原始工具到达顺序一致。
StreamingToolExecutor 的职责正是解决这一矛盾。其注释直接说明:
- 并发安全工具可以并行执行。
- 非并发工具必须独占执行。
- 结果会按工具接收顺序缓冲并发出。
因此,它不只是普通的工具运行器,而更像一个“面向流式 assistant 输出的有序并发执行器”。
如果缺少这层执行器,Claude Code 很容易在两种坏结果之间摇摆:要么等待所有工具意图都完整出现后再统一执行,导致整体延迟变高;要么一边流式一边执行,但结果回填顺序混乱,让模型后续无法稳定消费。StreamingToolExecutor 的价值就在于把“尽早开始”和“保持秩序”同时兼顾。
参考路径:
restored-src/src/services/tools/StreamingToolExecutor.ts
3.4 三种中断原因对应三种不同语义
StreamingToolExecutor 中显式区分:
sibling_erroruser_interruptedstreaming_fallback
这三类中断并不等价:
sibling_error表示并行工具中有兄弟调用失败,其他调用应合成取消结果。user_interrupted表示用户主动打断,对 UI 和结果语义应表现为“用户拒绝”。streaming_fallback表示本轮流式执行被丢弃,结果不再可信,应直接废弃。
因此,工具层异常处理并不是“统一抛错”,而是区分错误来源与交互语义的分类处置。
这对用户体验影响很大。一个由用户主动打断的工具调用,和一个被兄弟任务连带取消的工具调用,在界面上、在后续推理里、在系统日志里都不应该被描述成同一种失败。Claude Code 在这里的分类处理,本质上是在维护“错误语义的准确性”。
参考路径:
restored-src/src/services/tools/StreamingToolExecutor.ts
3.5 Claude Code 默认内置工具总览
3.5.1 “默认自带工具”应以 tools.ts 为准,而不是以 src/tools/ 目录为准
如果希望准确回答“Claude Code 默认自带哪些工具”,最可靠的入口不是去数 src/tools/ 目录下有多少个子目录,而是看 tools.ts 里两层更关键的函数:
getAllBaseTools()getTools(permissionContext)
这两层分别回答两个不同问题:
- 当前环境下,理论上可能加入内置工具池的全部 base tools 有哪些。
- 在当前模式、当前权限上下文、当前 feature gate 下,这一轮真正会暴露给模型的 built-in tools 是哪些。
因此,目录里的工具实现文件只能说明“仓库里实现过这种工具”;而 tools.ts 才能说明“默认会不会进入工具池”。这一区分很重要,因为有些工具只在 ant 内部构建里出现,有些只在特性开关打开时出现,还有些会在 REPL/simple/coordinator 模式下被替换或隐藏。
参考路径:
restored-src/src/tools.ts
3.5.2 默认 preset 的来源:getToolsForDefaultPreset()
tools.ts 中的 TOOL_PRESETS 当前只有一个公开 preset:
default
对应的函数 getToolsForDefaultPreset() 并不是手写一份名单,而是:
- 先调用
getAllBaseTools() - 再对每个工具执行
isEnabled() - 最后取其
name
这意味着“default preset”本身也是运行时求值结果,而不是写死的一段字符串列表。某个工具是否属于默认集合,不仅取决于它是否被实现,还取决于:
- 当前环境变量
- 当前 feature gate
- 当前构建类型
- 工具自己的
isEnabled()
从设计上看,这种实现比维护一张硬编码的工具白名单更稳妥,因为它让“默认工具集”始终与真实环境保持同步。
参考路径:
restored-src/src/tools.ts
3.5.3 在标准模式下,默认内置工具的核心常驻集合
依据 getAllBaseTools() 当前快照,可以确认下列工具属于标准 built-in tool pool 的核心组成部分。这里的“核心”指的是:它们直接由 getAllBaseTools() 返回,不是 MCP 动态工具,也不是用户外接服务器提供的工具。
当前源码里可以直接看到这些主干内置工具:
AgentToolTaskOutputToolBashToolGlobToolGrepToolExitPlanModeV2ToolFileReadToolFileEditToolFileWriteToolNotebookEditToolWebFetchToolTodoWriteToolWebSearchToolTaskStopToolAskUserQuestionToolSkillToolEnterPlanModeToolSendMessageToolBriefToolListMcpResourcesToolReadMcpResourceToolToolSearchTool(在 optimistic 条件成立时)
这组工具已经足以覆盖 Claude Code 的几个核心能力面:
- 文件读取、编辑、写入
- Shell 执行
- 搜索与检索
- Web 获取与搜索
- 多 agent 协作
- 计划模式切换
- 任务状态获取与停止
- 技能调用与工具搜索
从架构角度看,这组默认工具大致构成了 Claude Code 的“执行骨架”。其他条件性工具是在这副骨架上按模式和产品线继续叠加的。
参考路径:
restored-src/src/tools.ts
3.5.4 搜索类工具在某些构建中会被收缩
getAllBaseTools() 中有一个很关键的条件分支:
- 如果
hasEmbeddedSearchTools()为真,则不再加入GlobTool与GrepTool - 否则才把这两个 dedicated search tool 放进工具池
源码注释解释得很明确:在某些 ant-native 构建里,bun 二进制已经内嵌了更快的搜索能力,因此无需再暴露单独的 Glob / Grep 工具。
这说明“默认工具”并不是一个跨所有构建都完全相同的固定集合。即使在同一个仓库快照下,不同部署环境看到的默认工具池也可能略有差异。对外介绍时,最准确的说法应当是:
- 工具池有一个稳定的主干。
- 其中少数工具会因构建能力不同而替换或折叠。
3.5.5 有些工具虽然是内置工具,但只在特定产品线或 feature gate 下出现
getAllBaseTools() 还显示出一大类“条件性内置工具”。它们不是 MCP 工具,也不是用户自定义工具,而是官方仓库内建、但只在某些条件成立时加入工具池的能力。例如:
ConfigTool:仅在USER_TYPE === 'ant'时加入。TungstenTool:仅在 ant 内部构建时加入。LSPTool:要求ENABLE_LSP_TOOL环境变量开启。EnterWorktreeTool/ExitWorktreeTool:只在 worktree mode 开启时加入。TaskCreateTool/TaskGetTool/TaskUpdateTool/TaskListTool:受 Todo v2 能力开关控制。SleepTool:受PROACTIVE或KAIROS特性影响。CronCreateTool/CronDeleteTool/CronListTool:受AGENT_TRIGGERS控制。RemoteTriggerTool:受AGENT_TRIGGERS_REMOTE控制。MonitorTool:受MONITOR_TOOL控制。WebBrowserTool:受WEB_BROWSER_TOOL控制。WorkflowTool:受WORKFLOW_SCRIPTS控制。PowerShellTool:只在 shell 平台/配置支持时加入。SnipTool、ListPeersTool、VerifyPlanExecutionTool等:都受特性开关或环境变量控制。
这类工具的存在说明,Claude Code 的默认工具池并不是“一个版本对应一张固定工具表”,而更像一个带条件拼装逻辑的官方工具家族。
3.5.6 多 agent / 团队协作类工具也是默认 built-in 的一部分
很多人第一次阅读 Claude Code 时,会把 AgentTool 当成一个额外高级能力,好像它不属于基础工具层。但从 getAllBaseTools() 看,它其实就是 base tool pool 的一部分。
与它同一族的还有:
SendMessageToolTaskStopToolTaskOutputTool- 在 swarm 开启时出现的
TeamCreateTool - 在 swarm 开启时出现的
TeamDeleteTool
这说明多 agent 协作并不是外接插件能力,而是 Claude Code 内置工具体系里的正式组成部分。也正因为如此,前文讨论的 coordinator、fork、in-process teammate 等机制,才能直接建立在统一工具池之上,而不需要另一套完全独立的调用协议。
3.5.7 REPL 模式与 simple mode 会改变默认工具视图
getTools(permissionContext) 中还有两个会显著影响工具可见性的分支。
第一类是 CLAUDE_CODE_SIMPLE:
- 在 simple mode 下,默认只保留
BashTool、FileReadTool、FileEditTool - 若同时处于 coordinator mode,还会补上
AgentTool、TaskStopTool、SendMessageTool
第二类是 REPL mode:
- 若启用了
REPLTool,某些 primitive tools 会从直接工具池里隐藏 - 它们仍然可在 REPL VM 内部被间接使用
- 也就是说,工具不是消失了,而是入口发生了变化
这说明“默认有哪些工具”不仅是环境问题,也是运行模式问题。模型在不同模式下看到的工具池,不一定与仓库中的全部 base tools 一一对应。
参考路径:
restored-src/src/tools.tsrestored-src/src/tools/REPLTool/constants.ts
3.5.8 ListMcpResourcesTool 与 ReadMcpResourceTool 的定位比较特殊
这两个工具虽然是 built-in tool,但它们服务的是 MCP 资源读取,而不是本地文件系统或 shell 本身。源码里对它们还有一个特殊处理:
- 在
getTools()中,它们会被放进specialTools集合 - 然后从普通 built-in 列表里先过滤掉
- 后续再按请求构造需要决定是否显式加入
这说明它们既是默认内置工具家族的一部分,又在请求组装阶段拥有特殊地位。之所以如此,是因为它们与 MCP 生态连接更紧密,不能简单等同于普通本地工具。
3.5.9 真正发送给模型的工具池,还会经过 deny-rule 和 isEnabled() 二次过滤
即使一个工具出现在 getAllBaseTools() 里,也不意味着它一定会被当前这轮模型请求看到。getTools(permissionContext) 还会继续做两层过滤:
filterToolsByDenyRules(...)tool.isEnabled()
第一层保证工具会受到权限上下文的整体 deny-rule 影响。第二层保证工具即使在 base pool 中存在,也只有在当前条件下真正启用时才会暴露。
因此,最准确的描述应是:
getAllBaseTools()定义了当前环境中的默认内置工具候选集。getTools(permissionContext)定义了当前会话当前模式下真正可见的 built-in tool set。
3.5.10 对 Claude Code 默认工具体系的总体理解
综合来看,Claude Code 的默认自带工具可以分成四层:
-
核心通用工具
例如Bash、Read、Edit、Write、WebFetch、WebSearch、Agent。 -
会话与控制工具
例如AskUserQuestion、EnterPlanMode、ExitPlanModeV2、Brief、TaskStop、TaskOutput。 -
协作与编排工具
例如SendMessage、TeamCreate、TeamDelete、TaskCreate/Get/Update/List。 -
条件性扩展工具
例如LSP、PowerShell、WebBrowser、Workflow、Sleep、Cron*、Monitor、RemoteTrigger。
这说明 Claude Code 的“默认工具”并不是几把零散的基础工具,而是一整套围绕软件工程代理场景设计出来的内建能力系统。它既覆盖了本地操作,也覆盖了协作控制、会话模式、搜索检索和条件性平台扩展。
如果只用一句话概括:
Claude Code 的默认自带工具,不是一份静态名单,而是一套由 tools.ts 按环境、模式、权限和 feature gate 动态装配出来的官方内置工具池。
参考路径:
restored-src/src/tools.tsrestored-src/src/Tool.ts
4. 附件层与非Prompt上下文的深层结构
4.1 Attachment 的作用,可以理解为“把结构化运行态翻译成模型可见文本”
Claude Code 的 attachment 并不是 UI 附件的同义词。在运行时,它更像一个“上下文转译器”:把计划模式、后台任务、技能恢复、计划文件、hook 输出等运行态结构翻译成模型可见消息。
因此,attachment 层位于“状态管理”和“模型输入”之间,扮演着语义适配层的角色。
如果把 Claude Code 看成一台不断变化状态的机器,那么 attachment 层就像翻译器:它把原本只有系统内部知道的状态,翻译成模型能够理解的自然语言或半结构化文本。没有这一层,很多状态虽然存在于程序里,却无法被模型继续利用;而如果直接把内部状态对象暴露给模型,又会缺少必要的语言包装和语义提示。
参考路径:
restored-src/src/utils/attachments.tsrestored-src/src/utils/messages.ts
4.2 wrapMessagesInSystemReminder() 说明很多“附加上下文”并不走 system prompt
messages.ts 提供了:
export function wrapInSystemReminder(content: string): string {
return `<system-reminder>\n${content}\n</system-reminder>`
}
以及:
export function wrapMessagesInSystemReminder(messages: UserMessage[]): UserMessage[]
这说明大量附加上下文并不是通过修改 system prompt 进入模型,而是通过包装后的 <system-reminder> 用户消息进入模型。这样设计有几个明显的好处:
- 不破坏既有 system prompt 缓存结构。
- 可以把不同来源的运行态上下文统一格式化。
- 能让模型知道“这是系统提醒,不是普通用户发言”。
从提示词工程角度看,<system-reminder> 的作用很微妙。它既不像 system prompt 那样处于绝对顶层,也不像普通用户消息那样会被模型自然理解成“用户刚刚的真实输入”。它处在两者之间,既保留了消息流顺序,又通过标签提示模型“这是系统补充说明”。正因为有这样一层中间语义,Claude Code 才能在不频繁重写 system prompt 的情况下,把大量运行时上下文平滑地插入到消息链中。
参考路径:
restored-src/src/utils/messages.ts
4.3 计划模式提示词是 attachment 生成的系统提醒,而不是常驻 system prompt
计划模式下,messages.ts 会生成大段 Plan mode is active... 的 <system-reminder> 文本。其控制作用是:
- 在计划模式激活时,覆盖性禁止非只读操作。
- 指定计划文件路径与唯一可编辑文件。
- 约束一轮的合法结束方式,只能是
AskUserQuestion或ExitPlanModeV2Tool。
因此,计划模式的强约束并不是写死在默认 system prompt 中,而是在进入该模式时通过 attachment 翻译消息注入。这保证了该约束只在相关模式下存在。
这背后反映出一种很典型的“模式化注入”思路:默认 system prompt 负责通用行为,特殊模式通过额外提醒叠加约束。这样既能保持默认场景简洁,又能在特定模式下快速切换模型行为,而不需要为每一种模式都维护一套完全独立的大 prompt。
参考路径:
restored-src/src/utils/messages.tsrestored-src/src/utils/attachments.ts
4.4 已调用技能会在压缩后被重新注入
messages.ts 中存在如下模型可见文本:
The following skills were invoked in this session. Continue to follow these guidelines:
这说明技能并不是“一次注入即结束”的短时上下文。如果会话发生 compaction,系统会把已调用技能重新恢复到后续上下文中,以减少模型遗忘早先技能正文中规范的可能性。
这也解释了为何 processSlashCommand.tsx 会在调用时把技能内容写入 invokedSkills 跟踪结构。
从长期会话的稳定性看,这一点非常关键。技能往往携带的是“当前任务如何做”的具体操作规范,一旦它们在压缩后消失,模型后续推理就可能偏离原先已经加载过的工作准则。因此,invoked skill 的恢复机制,本质上是在为长会话中的行为连续性兜底。
参考路径:
restored-src/src/utils/messages.tsrestored-src/src/utils/processUserInput/processSlashCommand.tsxrestored-src/src/bootstrap/state.ts
4.5 压缩后的“恢复”不是补丁,而是正式的数据回灌阶段
compact.ts 中的常量与辅助函数揭示了一个常被忽略的事实:Claude Code 在压缩后并不是“只留下摘要,其他全靠模型自己想起来”,而是有一套受预算约束的恢复策略。当前源码中可以直接看到:
POST_COMPACT_MAX_FILES_TO_RESTORE = 5POST_COMPACT_TOKEN_BUDGET = 50_000POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000
这组常量非常有信息量。它说明压缩后的恢复不是“能恢复多少算多少”,而是一个明确预算化的二次注入过程:系统需要在文件、技能和其他恢复对象之间重新分配 token。也就是说,Claude Code 的压缩机制并不是单向删除,而是“删减旧表示,再用更经济的方式恢复关键对象”。
从运行逻辑看,这一层恢复至少在回答三个问题:
- 哪些对象即使已经不适合原样保留,也仍然值得在压缩后重新出现。
- 每类对象最多可以占多少预算,避免恢复过程本身重新拖垮上下文。
- 恢复对象应以什么形式回到消息链中,才能既被模型理解,又不破坏后续控制流。
如果把 autocompact 比作“把原始房间收纳进压缩箱”,那么 post-compact recovery 更像“从压缩箱里先拿回最关键的工具、说明书和地图”。摘要负责告诉模型“此前发生了什么”,而恢复对象负责告诉模型“接下来继续工作还必须知道什么”。
参考路径:
restored-src/src/services/compact/compact.tsrestored-src/src/bootstrap/state.ts
5. 记忆体系的深层结构
5.1 记忆不是单文件,而是“入口索引 + 主题文件 + 选择器”
memdir.ts、memoryScan.ts 与 findRelevantMemories.ts 共同说明,长期记忆系统至少包含三层:
MEMORY.md,作为索引入口。- 多个主题
.md文件,作为分主题持久化单元。 - 一个侧向查询选择器,用于从候选主题中选出最多 5 个相关文件。
换句话说,长期记忆并不是“把内容全部堆到 MEMORY.md 里”,而是“入口索引负责常驻加载,主题文件负责分布式存储,选择器负责按查询动态召回”。
这样的结构很像图书馆而不是笔记本。MEMORY.md 像目录,主题文件像具体书目,选择器像检索员。目录负责让系统始终知道“有哪些类型的信息存在”,而具体内容并不要求每轮全部摆在模型面前。这样既能维持长期积累,又不会让当前轮被过量历史拖垮。
参考路径:
restored-src/src/memdir/memdir.tsrestored-src/src/memdir/memoryScan.tsrestored-src/src/memdir/findRelevantMemories.ts
5.2 记忆体系也存在“静态层”,它负责提供长期稳定的使用制度
这里需要特别区分两组“静态层 / 动态层”概念。
前文第 2 章讨论的是整个 system prompt 架构中的静态段与动态段;而本节讨论的是记忆子系统内部也有一组自己的静态层与动态层。二者相关,但不是同一个分析尺度。
记忆体系的静态层,主要由 memdir.ts 中的 buildMemoryLines() 与 loadMemoryPrompt() 生成。它的核心特征不是“内容固定不变”,而是“在较长时间内保持稳定,主要回答制度问题而不是事实问题”。从源码可见,这一层至少包括:
- 记忆目录在哪里。
- 哪些信息值得保存,哪些不值得保存。
- 记忆有哪些类型。
- 如何写入记忆文件与
MEMORY.md索引。 - 何时应访问记忆,何时不应把当前任务信息误写进长期记忆。
- 在某些模式下,记忆是写入
MEMORY.md主题文件,还是写入按日期组织的 daily log。
例如 buildMemoryLines() 生成的文本中,包含一整套长期规约:
You have a persistent, file-based memory system at `{memoryDir}`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).
以及:
Saving a memory is a two-step process:
还包括:
`MEMORY.md` is an index, not a memory
这些内容的重要性在于,它们并不是告诉模型“当前记忆里有什么”,而是告诉模型“这个记忆系统是什么、该如何被正确使用”。从知识组织角度看,这一层更像操作手册或制度宪章。它决定模型是否会把长期记忆当作一个结构化系统来维护,而不是把它误用成任意文本堆栈。
进一步说,记忆静态层至少承担四项深层职责:
- 定义对象边界:明确
MEMORY.md是索引,不是具体记忆正文。 - 定义写入协议:要求 frontmatter、要求主题化组织、要求避免重复。
- 定义检索协议:提示模型应先看索引,再按需进入主题文件或搜索历史上下文。
- 定义与其他持久化机制的分工:区分 memory、plan、tasks 等不同持久化手段。
这也是为什么这部分虽然看起来“像说明文”,却非常值得单独讲。对于 Claude Code 这样的代理系统来说,长期记忆是否可用,很多时候不取决于模型能不能读文件,而取决于系统是否先把“什么该记、怎么记、何时记”这套制度稳定传达给模型。记忆静态层承担的正是这个职责。
参考路径:
restored-src/src/memdir/memdir.tsrestored-src/src/context.ts
5.3 记忆体系的“动态层”则负责把本轮真正相关的记忆材料送入上下文
与静态层相对,记忆体系的动态层处理的不是制度,而是本轮实际参与推理的记忆材料。它的变化来源主要有三类:
- 当前会话到底选中了哪些记忆文件。
MEMORY.md当前内容是什么,是否发生了截断。- 当前运行模式是否改变了记忆的呈现方式,例如
KAIROS下的 daily log 模式、TEAMMEM 下的双目录模式等。
从源码可以直接看到这种动态性。
第一,buildMemoryPrompt() 会同步读取当前 MEMORY.md 的实际内容,并通过 truncateEntrypointContent() 根据 MAX_ENTRYPOINT_LINES 和 MAX_ENTRYPOINT_BYTES 做截断处理,再把本轮可见的索引内容拼进 prompt。这里进入上下文的已经不是抽象规则,而是“此时此刻这个项目真正有哪些记忆索引项”。
第二,findRelevantMemories.ts 会通过独立 side query 选择最多 5 个相关记忆文件。也就是说,动态层并不要求把所有主题文件全部搬进上下文,而是让系统在当前问题与记忆候选之间做一次相关性映射。
第三,context.ts 中的 getUserContext() 会把经过 getMemoryFiles()、filterInjectedMemoryFiles()、getClaudeMds() 处理后的记忆/说明文件集合转换成实际注入到消息层的内容。这说明“本轮真正给模型看的材料”仍然要经过一次运行时筛选与组合,而不是简单复用静态层文本。
这一层如果换成更直观的说法,可以把它理解成“当前轮的记忆工作集”。静态层像图书馆的规章、目录维护办法和借阅制度;动态层像工作人员根据当前查询,从目录和书架里真正取出来放在桌上的那几本书。没有静态层,系统不会正确维护记忆;没有动态层,系统即使维护了记忆,也无法在当前轮恰当地调用它们。
动态层至少解决了四个非常具体的问题:
- 可见性问题:哪些记忆文件本轮真的进入模型上下文。
- 预算问题:入口索引最多加载多少行、多少字节,避免记忆系统自己撑爆上下文。
- 相关性问题:哪些主题文件值得被召回,哪些应该继续留在磁盘上。
- 模式适配问题:在 auto memory、team memory、daily log 等不同模式下,记忆应如何呈现给模型。
因此,记忆动态层不是静态层的附属细节,而是记忆系统真正“参与当前推理”的部分。对外部读者来说,如果只看到静态层,会觉得记忆系统只是一个行为规范;如果只看到动态层,又会误以为它只是个文件加载器。只有把两层同时放在一起,才能看到 Claude Code 为什么既像有规章制度的知识库,又像一个会按需检索的运行时记忆系统。
参考路径:
restored-src/src/memdir/memdir.tsrestored-src/src/memdir/findRelevantMemories.tsrestored-src/src/context.ts
5.4 loadMemoryPrompt() 提供的是行为说明,而不是记忆内容本体
loadMemoryPrompt() 的职责是生成“如何使用记忆系统”的行为指导,而非直接注入 MEMORY.md 内容本体。MEMORY.md 与 claude.md 一类内容更多通过 getUserContext() 进入用户上下文前缀。
这一分工很重要:
loadMemoryPrompt()负责告诉模型“记忆目录在哪里、如何写、何时写、何时不该写”。getUserContext()负责把具体的claudeMd与当前日期塞进模型可见消息。
前者更像规约,后者更像内容。
这也解释了为什么 memory 相关代码会分散在多个文件里。Claude Code 并不是把“记忆”当成一段静态文本,而是把它拆成了“如何使用记忆”“记忆文件放在哪”“本轮选哪些记忆”“具体哪些内容进入上下文”几个问题分别处理。
参考路径:
restored-src/src/memdir/memdir.tsrestored-src/src/context.ts
5.5 记忆选择器使用独立的 Sonnet 侧查询
findRelevantMemories.ts 通过 sideQuery() 和 getDefaultSonnetModel() 选择相关记忆文件,最大召回数为 5。这意味着“长期记忆检索”不是主模型直接浏览所有记忆文件,而是由辅助模型先做候选筛选。
这个分层设计有两个直接收益:
- 控制主上下文大小。
- 避免把无关记忆常驻注入。
进一步看,它还带来第三个收益:让“记忆召回”本身变成一个可演化的子系统。因为筛选逻辑被单独封装在侧查询中,后续无论是更换模型、调整筛选标准,还是引入新的记忆类型,都不需要重写主查询流程。
参考路径:
restored-src/src/memdir/findRelevantMemories.ts
5.6 记忆系统真正解决的是“长期连续性”与“当前相关性”的冲突
从架构层面看,记忆系统之所以复杂,并不是因为加载 Markdown 很难,而是因为它必须同时满足两个彼此冲突的目标:
- 让系统在跨轮次、跨任务甚至跨会话时保留持续性。
- 又不能把所有历史材料每轮都塞进当前上下文。
如果只有第一个目标,最简单的做法是把所有记忆常驻注入;但这样很快就会带来上下文膨胀与无关信息干扰。反过来,如果只追求第二个目标,最简单的做法就是完全不保留长期记忆;但系统会失去项目偏好、长期约定和跨任务积累。Claude Code 的三层设计恰好是在这两个目标之间搭建中间结构:
MEMORY.md提供一个稳定、低成本、可常驻的入口层。- 主题文件提供高容量但按需加载的内容层。
- 选择器提供按查询动态裁剪的召回层。
这种分层意味着“记忆”在 Claude Code 中不是一个文件,而是一套检索制度。模型并不天然拥有全部记忆;它每次只能在制度允许的范围内获得一部分经过筛选的记忆。这也是长期代理系统比普通聊天记录更接近“知识管理系统”的地方。
5.7 记忆层与 userContext 的关系,是“内容进入消息层,使用规约进入提示层”
阅读 memdir.ts、context.ts 与 findRelevantMemories.ts 时,一个很容易忽略的结构是:Claude Code 并没有把“记忆内容”和“如何使用记忆”混在一起。
可以把这两层分开理解:
- 记忆内容本体,如
claudeMd、已选中的记忆文件内容,更偏向消息层上下文,是模型本轮可参考的材料。 - 记忆使用规约,如写入目录、更新原则、检索方式,更偏向提示层规则,是模型如何对待记忆的制度说明。
这种分离带来的最大好处,是系统可以在不改变主规则的情况下替换本轮具体载入的记忆,也可以在不改动记忆内容的情况下升级使用制度。对大型代理系统而言,这类“规约和内容分离”的设计通常意味着更强的可维护性。
参考路径:
restored-src/src/memdir/memdir.tsrestored-src/src/context.tsrestored-src/src/memdir/findRelevantMemories.ts
6. 投机执行机制与安全检测
6.1 投机执行并不是主查询循环的一部分,而是一条“提前计算”的侧链
Claude Code 中的投机执行并不直接写在 query.ts 的主循环里,而是落在 services/PromptSuggestion/speculation.ts。它与 promptSuggestion.ts 构成一对:前者负责生成“用户可能下一步会输入什么”,后者负责在用户真的按下回车之前,先拿这条候选输入去跑一轮受限执行。
从调用链看,顺序大致如下:
executePromptSuggestion()根据当前消息历史尝试生成一条 suggestion。- 若 suggestion 存在且 speculation 已启用,则调用
startSpeculation(...)。 startSpeculation(...)启动一条 forked sidechain,用 suggestion 作为预估用户输入。- sidechain 在严格受限的工具集合上提前执行,持续把 assistant/user 消息写入
messagesRef.current。 - 如果用户最终接受该 suggestion,则
handleSpeculationAccept(...)把这段“预先跑出来”的消息直接注入主消息链。 - 如果 speculation 未完整结束,则再补一轮正常 query;如果已经跑完,则可以直接复用已完成结果。
因此,投机执行的本质不是“给模型一个更强的提示”,而是“在用户尚未正式提交下一轮输入前,先对一个高概率输入做受限推演”。它节省的不是推理步骤,而是等待时间。
参考路径:
restored-src/src/services/PromptSuggestion/promptSuggestion.tsrestored-src/src/services/PromptSuggestion/speculation.ts
6.2 投机执行的核心目标是时间前移,而不是结果替代
acceptSpeculation() 与 handleSpeculationAccept() 的实现说明,投机执行并不试图绕过主会话,而是试图把本来会在用户提交后发生的部分工作,提前搬到用户提交之前完成。
源码中可以看到几个非常直接的设计信号:
SpeculationResult里显式记录timeSavedMs。speculationSessionTimeSavedMs会累计到会话统计中。- transcript 中会追加
speculation-accept记录。 createSpeculationFeedbackMessage()会把节省的时间作为系统反馈展示出来。
这说明投机执行在架构上被当作“延迟优化机制”,而不是“另一种模型调用模式”。系统关心的是:如果用户真的会输入这个 suggestion,那么能不能在他点击接受之前,先把一部分只读或低风险步骤做完。
6.3 投机执行为什么必须走 forked sidechain
startSpeculation() 使用的是 runForkedAgent(...),而不是直接在主消息链上继续运行。这一点决定了 speculation 具备三种性质:
- 隔离性:预执行产生的消息先进入
messagesRef.current,而不是立刻污染主会话。 - 可丢弃性:如果用户没有接受 suggestion,或者中途触发边界,整段预执行可以整体废弃。
- 可选择性注入:只有在
handleSpeculationAccept(...)里,系统才会把 clean 后的消息回灌到主消息链。
因此,它更像一条“影子会话”而不是“隐藏的主会话”。系统既获得了提前执行的时间收益,又避免了预测错误直接污染主上下文的风险。
6.4 投机执行内部有明确的边界概念,而不是一直跑到结束
AppStateStore.ts 为 speculation 定义了 CompletionBoundary,其类型包括:
completebasheditdenied_tool
这说明 speculation 的目标并不是“不惜一切代价跑完”,而是“尽量往前推进,直到遇到不能继续自动前进的边界”。在 speculation.ts 中,这些边界会被实时写入 state:
- 遇到需要权限的写工具时,边界记录为
edit。 - 遇到非只读 Bash 时,边界记录为
bash。 - 遇到不在允许集合中的工具时,边界记录为
denied_tool。 - 若整个预执行顺利完成,则边界记录为
complete。
因此,投机执行更像一个“能跑多远就跑多远”的受限执行器,而不是一个二元成败模型。
6.5 第一层安全边界:工具白名单极小
speculation.ts 里直接定义了两组最核心的工具分类:
WRITE_TOOLS = {'Edit', 'Write', 'NotebookEdit'}SAFE_READ_ONLY_TOOLS = {'Read', 'Glob', 'Grep', 'ToolSearch', 'LSP', 'TaskGet', 'TaskList'}
在 canUseTool 回调中,逻辑非常明确:
- 写工具不是默认允许,而是先检查当前 permission mode 是否可以自动接受编辑。
- 安全只读工具可以在路径验证通过后继续执行。
- Bash 不是默认允许,而是只允许“通过只读约束检查”的命令。
- 其他所有工具默认拒绝,并把边界标记为
denied_tool。
这意味着 speculation 的第一道安全策略不是复杂分类器,而是一个非常保守的默认姿态:只允许一小撮可控工具前进,其余一律在边界处停下。
6.6 第二层安全边界:文件写入不直接落到真实工作区,而是走 overlay
投机执行最有代表性的安全设计,来自 overlay 机制。
startSpeculation() 会为每次 speculation 创建一个独立目录:
getOverlayPath(id)指向.../speculation/<pid>/<id>。- 写工具不会直接改真实文件,而是把路径重写到 overlay 中。
- 若某文件第一次被 speculative write 命中,系统会先把原文件 copy 一份到 overlay,再在 overlay 中修改。
- 同轮后续读取若命中了这个文件,也会被重定向到 overlay 版本。
这是一种非常典型的 copy-on-write 策略。它意味着 speculation 可以像“真的在改文件”一样继续向前推理,但这些改动在用户接受前,并不会污染真实工作区。只有在 acceptSpeculation() 中,系统才会通过 copyOverlayToMain(...) 把 overlay 内容复制回主目录。
参考路径:
restored-src/src/services/PromptSuggestion/speculation.ts
6.7 第三层安全边界:路径必须受根目录约束
在 overlay 逻辑前,speculation 还会对路径做一层根目录约束检查:
- 系统用
relative(cwd, filePath)计算相对路径。 - 如果结果仍然是绝对路径,或以
..开头,则说明目标不在当前根目录内。 - 对写工具而言,这类路径会被直接拒绝,理由是
speculation_write_outside_root。 - 对读工具而言,读外部路径不会被重写到 overlay,但会被标注为
speculation_read_outside_root。
这说明 overlay 不是“任意位置的临时镜像”,而是“当前项目根内部的影子工作区”。
6.8 第四层安全边界:Bash 只允许只读子集
投机执行对 Bash 的态度比对一般只读工具更谨慎。speculation.ts 并不会直接复用完整 Bash 权限判定,而是调用:
checkReadOnlyConstraints({ command }, commandHasAnyCd(command))
只有当返回结果的 behavior === 'allow' 时,这条 Bash 命令才被视为 speculative-safe。否则系统会:
- 记录
bash类型边界; - 中止当前 speculation;
- 返回
speculation_bash_boundary。
从 readOnlyValidation.ts 可以看出,这套只读验证并不宽松。它维护了详细的命令 allowlist、flag 验证和额外规则,目标是只允许真正读取型的命令通过,而不是让“看起来像只读”的复杂命令蒙混过关。
参考路径:
restored-src/src/services/PromptSuggestion/speculation.tsrestored-src/src/tools/BashTool/readOnlyValidation.tsrestored-src/src/utils/shell/readOnlyCommandValidation.js
6.9 第五层安全边界:权限模式决定 speculation 是否能越过 edit 边界
即使是 overlay 写入,也不是所有情况下都允许。speculation.ts 在遇到写工具时,会先读取当前 toolPermissionContext.mode,并计算 canAutoAcceptEdits。只有这些模式下才允许继续:
acceptEditsbypassPermissionsplan且isBypassPermissionsModeAvailable
否则 speculation 会在第一次写工具处立刻停下,并标记 edit 边界。这意味着投机执行并没有绕过权限系统;它只是在权限模式已经表明“编辑可自动接受”的前提下,才允许写操作进入影子工作区。
6.10 第六层安全边界:用户和后台状态变化会主动中止 speculation
投机执行不是一条“一旦开始就跑到底”的后台线程。源码中可以看到多个明确的中止点:
abortSpeculation(setAppState)会在用户继续输入、取消 suggestion 等场景下触发。LocalAgentTask.tsx和LocalShellTask.tsx在后台任务状态变化时会主动调用abortSpeculation(...)。MAX_SPECULATION_MESSAGES = 100与MAX_SPECULATION_TURNS = 20会限制 speculative sidechain 的规模。- 若 speculation 仍在运行但用户已经接受 suggestion,系统会先
abort(),然后以当前已有结果为准做注入。
其中最值得注意的是后台任务状态变化导致的 abort。源码注释写得很清楚:当 background task state 改变时,先前 speculative 得出的结果可能已经基于过时任务输出,因此需要及时废弃。这说明 speculation 的安全性不仅关乎副作用,也关乎“它是否仍然建立在有效状态之上”。
参考路径:
restored-src/src/services/PromptSuggestion/speculation.tsrestored-src/src/tasks/LocalAgentTask/LocalAgentTask.tsxrestored-src/src/tasks/LocalShellTask/LocalShellTask.tsx
6.11 Bash 分类器的“投机检查”与投机执行是两件相关但不同的事
源码里还有一个容易混淆的点:bashPermissions.ts 中存在 startSpeculativeClassifierCheck(...)、peekSpeculativeClassifierCheck(...)、consumeSpeculativeClassifierCheck(...)。这里的 “speculative” 指的是 提前启动 Bash allow classifier,而不是整条 suggestion speculation。
它的用途是:
- 当某条 Bash 命令可能稍后需要弹权限框时,先把 allow classifier 提前跑起来。
- 真正进入权限交互时,可以直接消费已经在后台运行的分类器结果。
- 若结果是高置信度允许,则可能直接自动批准,减少权限对话框停留时间。
在 useCanUseTool.tsx 中可以看到,主线程权限处理会给这个 speculative classifier 一个大约 2 秒的 grace period。若分类器在此期间返回高置信度 allow,并匹配到了明确规则,则可以跳过交互式权限对话框,直接 allow。
因此,这里其实存在两种“投机”:
- 对下一轮用户输入的投机执行:提前跑 suggestion。
- 对 Bash 权限判定的投机检查:提前跑 allow classifier。
二者目的相似,都是把时间前移;但对象不同,一个针对会话步骤,一个针对权限判定。
参考路径:
restored-src/src/tools/BashTool/bashPermissions.tsrestored-src/src/hooks/useCanUseTool.tsx
6.12 对投机执行机制的总体判断
综合来看,Claude Code 的投机执行并不是“激进地偷跑下一轮”,而是一种非常保守的提前计算机制。它之所以能在工程上站得住,靠的是一整套层层收紧的约束:
- 候选输入必须先来自 prompt suggestion。
- 执行必须走隔离 sidechain,而不是直接写主会话。
- 工具集合默认极小,绝大多数工具不允许 speculative 使用。
- 文件写入必须先进入 overlay,而不是直写真实目录。
- 路径必须受工作根约束。
- Bash 只允许通过只读约束验证的命令。
- 权限模式不允许时,speculation 在 edit 边界立刻停下。
- 用户输入、后台任务变化、消息/轮次超限都会触发中止。
因此,投机执行的核心哲学可以概括成一句话:
尽可能提前做那些“高概率会被需要、且即使提前做也不会造成不可逆污染”的工作;一旦越过这条边界,就立即停下。
7. Bash 安全管理专题
7.1 Bash 安全不是单点校验,而是多层串联
BashTool.tsx、readOnlyCommandValidation.ts 与 yoloClassifier.ts 表明,Bash 执行前至少经过三层控制:
- 命令解析与安全审视。
- 只读约束检查。
- 模型辅助权限分类。
因此,Bash 安全模型并不是传统意义上的单一 allowlist,而更接近“静态规则 + 运行时上下文 + 模型判别”的复合架构。
从安全工程视角看,这种多层结构比纯规则系统更灵活,也比纯模型判别更可控。静态规则负责兜底明显危险的输入,运行时上下文负责补充权限与环境信息,模型判别则处理那些既不完全显然、又不能简单枚举的灰区场景。三者叠加,形成一种既保守又不至于过度僵硬的安全策略。
参考路径:
restored-src/src/tools/BashTool/BashTool.tsxrestored-src/src/utils/shell/readOnlyCommandValidation.tsrestored-src/src/utils/permissions/yoloClassifier.ts
7.2 dangerouslyDisableSandbox 的存在说明沙箱是第一等控制面
BashTool schema 暴露 dangerouslyDisableSandbox,说明“是否启用沙箱”不是外部包装逻辑,而是工具输入模型的一部分。换言之,系统在 prompt 层和工具执行层都承认“沙箱状态”是正式参数,而不是不可见实现细节。
这点的意义在于:Claude Code 并没有把沙箱看成一个完全隐藏在底层的实现,而是把它暴露成模型决策环境的一部分。对代理系统来说,这很关键,因为模型需要知道“当前动作是在什么安全边界内被允许的”,否则它很难形成稳定的工具使用策略。
参考路径:
restored-src/src/tools/BashTool/BashTool.tsx
7.3 Bash 权限系统本质上是在回答“允许执行”之外的三个问题
如果只把 Bash 安全理解成“这条命令能不能跑”,会低估其复杂度。结合 BashTool.tsx、权限工具链和 in-process agent 的权限桥接逻辑,可以把它理解为同时回答三个问题:
- 这条命令在抽象上是否危险。
- 这次具体输入在当前权限模式下是否需要升级为确认。
- 如果当前执行者是 worker,那么确认应由谁来承担。
第三点尤其重要。在 inProcessRunner.ts 中,worker 并不会各自弹出独立权限体系,而是优先借用 leader 的确认桥。这意味着 Claude Code 的权限并不是完全局部自治的,而是带有明显的“主线程治理”特征:worker 可以发起高风险动作,但真正的授权中心仍然可能在 leader。
从协作代理的角度看,这种设计很合理。否则在多 worker 并行执行时,用户将不得不面对多个彼此独立、风格不一的权限交互界面,整体控制感会迅速下降。统一桥接后的权限系统,本质上是在为多代理协作维持一个单一的人类控制平面。
参考路径:
restored-src/src/tools/BashTool/BashTool.tsxrestored-src/src/utils/swarm/inProcessRunner.tsrestored-src/src/utils/permissions/permissions.ts
7.4 Bash 的安全管理其实从提示词阶段就已经开始
如果继续往前追踪,会发现 Bash 的安全治理并不只发生在真正执行命令的那一刻,而是从工具提示词阶段就已经开始。tools/BashTool/prompt.ts 中写入了多类直接影响模型行为的规则:
- 默认优先在沙箱内运行命令。
- 只有在观察到明确的 sandbox 失败证据时,才考虑
dangerouslyDisableSandbox: true。 - 每一条 unsandboxed 命令都应单独判断,不能因为刚刚有一次越权执行就默认后续也越权。
- 不要把敏感路径随意建议加入 allowlist。
这说明 Bash 安全不是“模型自由生成命令,系统事后拦截”的纯后置架构,而是“前置规范 + 中段判定 + 末端执行”三层共同作用的架构。提示词层的职责,是尽量让模型一开始就形成较稳健的操作习惯,从而减少后续权限链路的压力。
从代理设计角度看,这很重要。因为如果完全依赖执行时拦截,模型会持续产生大量本不该出现的高风险命令;而把安全规则前置到提示词中,系统就能在“行为习惯形成”阶段先做一轮治理。
参考路径:
restored-src/src/tools/BashTool/prompt.ts
7.5 Bash 的沙箱判定是一个独立决策层,而不是简单布尔开关
tools/BashTool/shouldUseSandbox.ts 说明,Claude Code 对 Bash 的沙箱使用并不是“全局启用就一律进沙箱”,而是有一套单独判定逻辑。shouldUseSandbox(input) 至少会综合考虑:
- 当前是否全局启用了 sandbox。
- 输入里是否显式要求
dangerouslyDisableSandbox。 - 当前策略是否允许 unsandboxed commands。
- 命令是否命中了用户配置或系统配置的
excludedCommands。
这里最值得注意的一点是源码注释直接强调:
excludedCommands是用户体验层的便利机制。- 它不是最终安全边界。
- 真正的安全边界仍然是沙箱权限系统本身。
这意味着 Bash 的“是否进沙箱”本身就是一层策略决策,而不是一个无条件继承的全局状态。系统既允许某些命令因为配置或运行条件脱离沙箱,又通过后续权限和提示词机制继续控制风险。也就是说,Claude Code 并没有把“沙箱”当作唯一防线,而是把它当作多重防线中的第一层。
参考路径:
restored-src/src/tools/BashTool/shouldUseSandbox.ts
7.6 只读命令验证层的职责,是把“Bash 过于通用”的问题拆回规则系统
之所以 Bash 需要单独的只读验证层,是因为 Bash 与 Read、Glob、Grep 这类专用工具不同,它天生过于通用。一个名字相同的 shell 可以执行只读查询,也可以执行文件修改、网络访问、权限提升甚至任意代码下载执行。因此,系统必须在 Bash 内部再做一次更细粒度的语义切分。
tools/BashTool/readOnlyValidation.ts 与 utils/shell/readOnlyCommandValidation.ts 展示了这种切分方式:
- 维护大量命令级 allowlist,例如只读型
git、gh、rg、pyright等。 - 对 flag 做逐项验证,而不是只看命令前缀。
- 对某些命令增加额外正则或回调校验,防止看似只读的参数组合变成执行路径。
- 特别防范命令拼接、wrapper、环境变量前缀、PATH hijack 等边缘情况。
从安全工程角度看,这一层的目标不是“证明命令安全”,而是“尽可能严格地证明它只读”。这种设计哲学非常关键:系统不是默认相信 Bash,再寻找危险信号;而是默认不相信 Bash,只有在满足只读约束的情况下才给出通行证。
7.7 Bash 权限判定的总入口,是 hasPermissionsToUseTool()
真正把多层规则汇总起来的,是 utils/permissions/permissions.ts 中的 hasPermissionsToUseTool() 及其内部实现。对 Bash 来说,这条链路大体会经历以下阶段:
- 检查整工具 deny 规则。
- 检查整工具 ask 规则。
- 调用工具自己的
checkPermissions(),做内容级权限判定。 - 处理 bypass-immune 的内容规则与 safety check。
- 再根据当前 mode 决定是 allow、ask 还是 deny。
这意味着 Bash 的权限管理不是 BashTool 自己独占完成的,而是嵌入整个 Claude Code 权限框架之中。BashTool 负责提供命令级、安全级判断;统一权限框架负责把这些判断与 mode、规则来源、交互上下文整合起来。
这种分工的好处在于:
- Bash 仍然能保有自己的专业安全逻辑。
- 整个系统又不会因此出现“每个工具一套完全不同的批准制度”。
- 用户看到的权限体验仍然是统一的,只是 Bash 在内部拥有更丰富的安全前置步骤。
参考路径:
restored-src/src/utils/permissions/permissions.tsrestored-src/src/tools/BashTool/BashTool.tsx
7.8 自动分类器在 Bash 安全里扮演的是“减少不必要打断”,而不是“代替全部判断”
Claude Code 在 Bash 上使用分类器,并不意味着把安全完全交给模型。更准确地说,分类器承担的是一类很具体的任务:在一些本来可能需要人工确认的场景中,尽量提前识别出“这条命令其实符合已有允许规则”,从而减少权限对话框数量。
从 bashPermissions.ts 与 useCanUseTool.tsx 可以看出:
- 分类器只在特定模式下参与。
- 它主要用于 high-confidence allow 的自动批准。
- 如果置信度不够高,或者不匹配规则,系统仍会回落到人工或其他权限路径。
- 对某些 safety check、某些需要显式用户交互的场景,分类器并不能越权放行。
因此,自动分类器更适合理解为“减噪器”而不是“终极裁判”。它的目的,是减少那些明明安全、却总是打断用户的权限确认;而不是把高风险灰区全部交给一个概率模型拍板。
7.9 Bash 安全管理的真正结构,是“行为约束、环境约束、命令约束、交互约束”四层叠加
如果把这一章收束成一个更清晰的结构,那么 Claude Code 的 Bash 安全管理可以概括为四层:
-
行为约束
来自BashTool/prompt.ts,用于提前规范模型如何思考 Bash 的使用方式。 -
环境约束
来自 sandbox 与shouldUseSandbox(),用于控制命令默认运行在哪种隔离环境中。 -
命令约束
来自只读验证、路径规则、工具自身checkPermissions(),用于判断这条具体命令是否可被视为安全或可接受。 -
交互约束
来自统一权限框架、分类器、leader/worker 权限桥接和交互式确认流程,用于决定最终是否执行。
这四层叠加后,Bash 才成为 Claude Code 中一个“可被谨慎使用”的通用工具。也正因为需要这么多额外治理,Claude Code 才会在默认提示词里反复强调:若有专用工具,优先使用专用工具,而不是把 Bash 当成万能入口。
7.10 为什么 Bash 值得被单独拿一章讲
在 Claude Code 的全部工具中,Bash 的特殊性在于:它既最强大,也最难治理。Read、Glob、Grep 这样的工具本身语义边界比较清晰,而 Bash 是一个几乎可以退化成“任意系统操作入口”的超集工具。因此,系统不得不围绕它建立:
- 更强的提示词限制。
- 更细的只读验证。
- 更明确的沙箱策略。
- 更复杂的权限与分类器配合。
- 在多 agent 场景下更严格的授权桥接。
换句话说,Bash 安全管理不是 Claude Code 安全体系里的一个普通子模块,而是整个代理安全设计里最典型、最复杂、也最值得单独研究的案例。
8. Hook 与 Agent 的深层结构
8.1 Hook 更值得关注的是“何时进入模型上下文”
当前源码可确认 command、prompt、http、agent、function、callback 等形态,但更重要的一点是:hook 结果经常会通过 attachment 或 message 路径重新进入模型上下文。因此,hook 不只是执行扩展点,也会影响模型输入。
换句话说,hook 在 Claude Code 里并不是单向的“程序调用外部逻辑”,而是一个双向接口:一方面,系统把事件交给 hook;另一方面,hook 的结果又会反过来影响模型的后续推理。这种回路意味着 hook 不只是插件机制,也是一种上下文增益机制。
参考路径:
restored-src/src/utils/hooks.tsrestored-src/src/types/hooks.tsrestored-src/src/utils/messages.ts
8.2 Agent 模式之间的核心区别,在于“上下文隔离方式”
四种主要 Agent 模式之所以不同,不在于名字,而在于它们如何隔离上下文与工作结果:
- Fork,独立分支,继承父上下文。
- In-Process,使用
AsyncLocalStorage做同进程隔离。 - Split-Pane,使用外部终端布局做可视隔离。
- Coordinator,用阶段性 prompt 和 worker 代理实现角色隔离。
因此,Agent 模式的核心不只是“运行在何处”,更在于“上下文如何划界、结果如何回流、谁拥有主线程发言权”。
这一点也帮助解释了为什么 Claude Code 会同时保留 fork、in-process、split-pane、coordinator 等多种模式。它们并不是彼此重复的实现,而是在不同任务复杂度、不同可视化需求、不同上下文隔离强度之间提供不同折中。某些任务更适合后台隔离执行,某些任务更适合同进程快速协作,某些任务则需要明确的可视窗口分工。
参考路径:
restored-src/src/tools/AgentTool/forkSubagent.tsrestored-src/src/utils/swarm/inProcessRunner.tsrestored-src/src/utils/swarm/teammateLayoutManager.tsrestored-src/src/coordinator/coordinatorMode.ts
8.3 Fork 模式的关键目标,其实是“缓存前缀相同”而不只是“复制上下文”
forkSubagent.ts 中最值得注意的并不是“子 agent 继承父上下文”这一点,而是源码注释反复强调的另一点:fork child 应尽量保持 byte-identical API request prefixes。为此,系统采取了几项非常具体的策略:
- 子 agent 继承父 assistant message 中的全部内容块,而不是重新生成一份近似文本。
- 所有 tool result 位置统一使用相同的占位文本
Fork started — processing in background。 - 子 agent 的真正差异被压缩到最后一段 directive 文本中。
- system prompt 也优先复用父线程已经渲染好的字节串,而不是重新拼接。
这表明 fork 的一项核心工程目标并不是“让子 agent 拿到信息”,而是“让多个 fork child 最大化共享 prompt cache”。这是一种非常典型的代理系统优化:同一批工人拿到同样的历史、同样的工具描述、同样的系统前缀,只在最后一小段任务指令上分化。这样既保留任务独立性,又提高缓存复用率。
换言之,fork 模式不是简单的“复制会话”,而是“以缓存友好的方式复制会话”。如果忽略这一点,就会低估 Claude Code 在多 agent 设计中对性能和成本的重视。
参考路径:
restored-src/src/tools/AgentTool/forkSubagent.ts
8.4 In-Process teammate 的重点不在“更轻量”,而在“同进程隔离 + 统一治理”
inProcessRunner.ts 的文件头注释已经明确写出它提供的几项能力:
- 基于
AsyncLocalStorage的上下文隔离。 - 进度跟踪与 AppState 更新。
- 完成后的 leader 空闲通知。
- 计划模式批准流支持。
- 完成或中止后的清理。
这说明 in-process 模式并不是简单为了“少起一个进程”,而是在同一个进程中构造出多个逻辑执行域。它的优势主要体现在两个方面:
- 共享宿主进程资源,因此在状态传播、UI 更新、权限桥接上更紧密。
- 仍通过上下文隔离与 mailbox/bridge 机制,避免不同 agent 之间的状态完全混杂。
从系统角度看,这是一种介于单线程多角色和多进程多角色之间的中间方案。它牺牲了一部分物理隔离,换取更低的切换成本和更强的协作流畅性。因此,in-process 的价值并不只是“快”,更是“在较低成本下提供足够强的逻辑隔离”。
参考路径:
restored-src/src/utils/swarm/inProcessRunner.ts
8.5 Coordinator 模式真正改变的是“主线程如何使用模型”
coordinatorMode.ts 展示得非常清楚:当 coordinator mode 打开后,主线程拿到的不是普通代理视角,而是一套明确的编排者规则。其 system prompt 反复强调几件事:
- 主线程的职责是研究、综合、实现、验证的编排,而不是自己执行所有细节。
- worker 结果会以
<task-notification>的 user-role 消息返回。 - 协调器应在结果到达后综合信息,再决定继续 worker 还是向用户汇报。
- 并行是协调器的核心能力之一。
这意味着 coordinator mode 并不是“多开几个 worker”这么简单,而是把主线程从执行者重塑成调度者。对模型来说,这会带来三个显著变化:
- 它的主要工具偏好从“自己动手”转向“分解任务并委托”。
- 它消费的许多消息不再是普通对话,而是任务通知与内部结果。
- 它必须承担综合职责,即把多 worker 返回的局部事实重新组织成面向用户的整体结论。
换句话说,coordinator mode 改变的不是线程数量,而是认知分工。它让 Claude Code 的主线程从“操作员”变成“项目经理兼总编”。
参考路径:
restored-src/src/coordinator/coordinatorMode.ts
8.6 状态管理并不是单一 Store,而是“Bootstrap 全局状态 + AppState UI 状态”的双层结构
如果继续往底层看,会发现 Claude Code 的状态并没有被塞进一个统一的大对象里,而是至少分成两层:
bootstrap/state.ts中的会话级、运行级全局状态。AppStateStore.ts中面向 REPL/UI 响应式刷新的应用状态。
这两层职责并不相同。
bootstrap/state.ts 更像运行内核的状态仓库。这里保存的是那些需要跨模块共享、并且不一定直接驱动界面重绘的状态,例如:
lastAPIRequestMessages,用于保留真实发往 API 的消息集。invokedSkills,用于在压缩后恢复已调用技能。needsPlanModeExitAttachment,用于控制一次性附件的显示。pendingPostCompaction,用于标记“刚刚发生过压缩”这一事件。
而 AppStateStore.ts 更像交互层的工作状态。它基于一个非常轻量的 createStore() 实现,提供 getState / setState / subscribe 三个核心接口,并维护:
tasksnotificationsmcppluginspromptSuggestionspeculationworkerSandboxPermissionsinitialMessage
这意味着 Claude Code 的状态架构不是“单一真相源统一治理一切”,而是“运行内核状态”和“交互展示状态”并行存在。其好处在于:
- 运行态对象可以保持较强的全局可达性,便于 query、compact、hook、memory、permissions 等模块直接协调。
- UI 相关状态可以保持响应式、可订阅、适合 React/Ink 一类界面驱动。
- 不必为了界面更新而把所有内核状态都强行纳入响应式系统,降低状态耦合。
如果借用更形象的比喻,可以把 bootstrap/state.ts 看作发动机舱,把 AppStateStore.ts 看作驾驶舱。发动机舱负责让整台机器真的运转,驾驶舱负责把关键状态可视化并接受操作输入。两者必须同步,但不必合并。
参考路径:
restored-src/src/bootstrap/state.tsrestored-src/src/state/AppStateStore.tsrestored-src/src/state/store.ts
9. 本文件对 Prompt 构造的总体结论
9.1 Claude Code 的提示词系统至少由六种注入机制组成
若从“模型实际可见文本”出发,而不是从“system prompt 文件”出发,则至少存在六类注入:
prependUserContext(),以isMeta用户消息注入用户上下文。appendSystemContext(),以 system block 注入系统态上下文。getSystemPrompt(),生成默认静态段与动态段。buildEffectiveSystemPrompt(),决定主体角色与追加段。processSlashCommand(),以isMeta用户消息注入 skill/slash command 正文。messages.ts/attachments.ts,以<system-reminder>包装运行态上下文。
如果把这六类注入放到一起看,就会发现 Claude Code 的提示词系统并不是一棵树,而更像一张网。不同来源的文本并不都汇总到同一个 system prompt 中,而是分别进入 system blocks、hidden messages、attachments 和工具描述。模型最终看到的是这些输入共同叠加后的结果。
9.2 “最终 prompt”更适合被看作一份请求体
单次调用中,模型真正接收到的是:
systemPrompt[]的块序列。messages[]的消息序列。tools[]的 schema 序列。- 某些工具输入上的附加字段。
因此,如果希望更完整地观察 Claude Code 的提示词系统,就不能只看 constants/prompts.ts,还需要同时追踪:
- system prompt 数组如何形成。
isMeta: true消息如何生成。- attachment 如何翻译成模型可见文本。
- tool schema 如何改变模型的行动空间。
这也是阅读 Claude Code 这类代理系统源码时最有价值的视角转换:从“找 prompt 文本”转向“找模型实际看到的全部输入”。只有把请求体视角建立起来,很多看似分散的模块,例如 attachment、slash command、system context、tool schema,才会重新拼成一个完整整体。
9.3 对 Prompt 构造做深入阅读时,最重要的是区分“四种不同强度的控制”
从整体上看,Claude Code 中进入模型的文本虽然很多,但它们并不是同一强度的控制信号。若要更精细地理解其作用,可以把它们区分为四类:
- 身份级控制:如
buildEffectiveSystemPrompt()选择出的主体 prompt,用于决定模型“这轮是谁”。 - 制度级控制:如默认 system prompt 中的工具规则、谨慎执行规则、输出效率规则,用于决定模型“长期应遵守什么”。
- 模式级控制:如 plan mode、coordinator mode、skill invocation、task notification 等,用于决定模型“当前处于什么工作态”。
- 材料级控制:如
userContext、记忆文件、计划文件内容、附件恢复文本,用于决定模型“本轮可参考哪些事实”。
这四种控制混在一起时,看起来像一大堆 prompt;但一旦分层,就会发现它们分属不同职责。身份级最稳定,制度级次之,模式级随会话切换,材料级则高度依赖当前任务。理解这四层差异,通常比背诵某一段 prompt 原文更有助于把握 Claude Code 的真实行为。
9.4 Claude Code 的 Prompt 架构,本质上是一种“语言控制 + 数据控制”混合体
最后可以把全文收束到一个更一般化的判断上:Claude Code 并不是只靠语言提示控制模型,也不是只靠工具和状态对象控制模型,而是同时使用两条控制链。
第一条是语言控制链:
- system prompt
<system-reminder>isMetauser messages- slash command / skill 正文
第二条是数据控制链:
- tools schema
- attachment 类型
- tool input 归一化
- 压缩结果结构体
- AppState / bootstrap state 中的运行态对象
语言控制链负责告诉模型“应该如何理解、规划和表述”;数据控制链负责限制模型“实际上能做什么、会看到什么、会保留什么”。Claude Code 的执行力,正来自这两条链路在每一轮中被同时编排,而不是只依赖其中任何一条。
9.5 工具数量限制与延迟加载策略
Claude Code 并没有在运行时硬编码一个“最大工具数量”常量,而是通过“延迟加载 + 动态发现”的机制来规避工具列表过大导致的上下文膨胀问题。核心策略如下:
-
延迟加载(defer_loading)
MCP 工具与某些可延迟工具不在首轮全部展开,而是以defer_loading: true形式存在。这样工具定义不会一次性占用大量上下文。
参考:restored-src/src/utils/toolSearch.ts、restored-src/src/services/api/claude.ts -
动态发现(ToolSearchTool + tool_reference)
当启用工具搜索模式时,模型通过ToolSearchTool动态发现工具,返回tool_reference,随后只把“已发现”的工具加入可用列表,从而避免预声明所有工具。
参考:restored-src/src/utils/toolSearch.ts、restored-src/src/services/api/claude.ts -
自动阈值(按上下文比例)
系统会根据“工具定义占用的 token 是否超过上下文窗口的一定比例”自动开启工具搜索。默认阈值是10%,可通过ENABLE_TOOL_SEARCH=auto:N调整。
参考:restored-src/src/utils/toolSearch.ts -
只包含已发现工具
在工具搜索模式下,系统会过滤工具列表,仅保留:- 非延迟工具
- ToolSearchTool 本身
- 已被发现的延迟工具
这使“工具数量上限”从固定上限变成“按需扩展”的动态集合。
参考:restored-src/src/services/api/claude.ts
-
模型兼容性限制
如果模型不支持tool_reference,工具搜索会被禁用,从而退回“全量工具内联”模式。
参考:restored-src/src/utils/toolSearch.ts
综上,Claude Code 的“工具数量控制”本质上不是一个硬性数量上限,而是一套“上下文预算驱动的动态曝光策略”。当工具定义过多时,它通过延迟加载与按需发现将工具数量从“固定全集”转化为“当前轮可见子集”,从而避免因工具数量膨胀而导致请求失败或性能退化。
9.6工具发现机制(Tool Discovery)
这一节补充说明 Claude Code 的工具发现机制,解释它如何在工具数量大、MCP 动态连接、上下文预算有限的情况下仍保持稳定。
9.6.1 发现触发条件
工具发现是否启用,主要由以下因素决定:
-
工具搜索模式
由ENABLE_TOOL_SEARCH控制,存在三种模式:tst:始终启用工具搜索tst-auto:当工具定义超过阈值时启用standard:禁用工具搜索,全部工具内联
参考:restored-src\src\utils\toolSearch.ts
-
模型是否支持
tool_reference
不支持的模型无法解析工具引用,因此会强制退回standard。
参考:restored-src\src\utils\toolSearch.ts -
ToolSearchTool 是否可用
若该工具被禁用或未加入可用工具列表,工具搜索无法启用。
参考:restored-src\src\utils\toolSearch.ts -
MCP 是否存在可延迟工具
当没有任何可延迟工具且 MCP 不在连接中时,工具搜索会被关闭以避免无效模式。
参考:restored-src\src\services\api\claude.ts
9.6.2 自动阈值启用逻辑
在 tst-auto 模式下,系统会计算“延迟工具定义占用的 token 数量”,并与上下文窗口比例进行比较。
默认阈值为 10%,可通过 ENABLE_TOOL_SEARCH=auto:N 调整。
- 优先使用 token 计数 API
- 若不可用,使用字符数估算
参考:restored-src\src\utils\toolSearch.ts
9.6.3 发现流程(核心路径)
工具发现的运行流程可以理解为:
- 延迟工具以
defer_loading: true方式声明 ToolSearchTool在模型上下文中可用- 模型通过
ToolSearchTool查询需要的工具 - 工具搜索结果以
tool_reference返回 - 系统记录“已发现工具集合”
- 下一轮仅将“已发现的延迟工具”加入工具列表
关键实现点:
extractDiscoveredToolNames(...)从消息历史中提取已发现工具- 过滤工具列表时,仅保留:
- 非延迟工具
- ToolSearchTool
- 已发现延迟工具
参考:restored-src\src\utils\toolSearch.ts、restored-src\src\services\api\claude.ts
9.6.4 发现结果的上下文持久化
为了避免压缩后丢失“已发现工具集合”,系统在压缩边界上会记录工具发现集,恢复时重新注入。
机制要点:
- compact boundary 存储
preCompactDiscoveredTools - 压缩后扫描时恢复到
discoveredTools集合 - snip 模式会保护携带
tool_reference的消息不被裁剪
参考:restored-src\src\utils\toolSearch.ts
9.6.5 工具发现与提示词/缓存的关系
工具发现机制直接影响 prompt 缓存稳定性:
- 若每轮都内联大量工具 schema,会频繁打破 cache
- 通过 defer_loading + 发现式注入,可将“工具变化”控制在较小范围
- 系统还会对工具 schema 进行会话级缓存,避免重复渲染
参考:restored-src\src\utils\toolSchemaCache.ts
9.6.6 工具发现的工程意义
从工程角度看,工具发现机制解决了三类问题:
- 工具数量不受固定上限约束
- 上下文预算不会被工具定义持续吞噬
- 动态 MCP 工具上线/下线不会直接导致上下文爆炸
因此,Claude Code 的工具发现是“规模控制机制”而不是单纯的“检索功能”。
9.6.7 ToolSearchTool 的具体工作原理
ToolSearchTool 并不是“把所有工具列表返回给模型”的工具,而是一个“按需解锁工具 schema”的机制。它的完整工作链路如下:
-
工具以延迟方式声明
延迟工具(MCP 工具与 shouldDefer 工具)在首轮不会内联完整 schema,而是以defer_loading: true声明。
参考:restored-src\src\services\api\claude.ts -
ToolSearchTool 可用
当工具搜索模式开启时,ToolSearchTool 本身会出现在工具列表中。
参考:restored-src\src\utils\toolSearch.ts -
模型发起搜索
模型通过 ToolSearchTool 传入查询,ToolSearchTool 返回匹配工具的完整 JSONSchema(以<functions>块形式返回)。
参考:restored-src\src\tools\ToolSearchTool\prompt.ts -
搜索结果变成 tool_reference
ToolSearchTool 的结果会以tool_reference形式进入消息历史,表示“这些工具已被发现”。
参考:restored-src\src\utils\toolSearch.ts -
下一轮只保留已发现工具
系统通过extractDiscoveredToolNames(...)扫描历史,提取已发现工具集合。
过滤工具列表时,仅保留:- 非延迟工具
- ToolSearchTool
- 已发现的延迟工具
参考:restored-src\src\services\api\claude.ts
-
压缩后恢复已发现集合
在 compact 边界上保存preCompactDiscoveredTools,确保压缩后仍能恢复已发现工具集合。
参考:restored-src\src\utils\toolSearch.ts
总结来说,ToolSearchTool 的作用是把“工具数量控制”从硬上限转为“动态发现 + 按需暴露”,在工具规模扩大时仍保持上下文可控与缓存稳定。
9.6.8 ToolSearchTool 的匹配逻辑
ToolSearchTool 的匹配并不是“随便把所有工具列出来”,而是基于一个“延迟工具池 + 查询匹配”的机制。其逻辑可以拆成三步:
-
延迟工具池的构建
只有被标记为可延迟的工具(MCP 工具与 shouldDefer 工具)进入“可搜索池”。
参考:restored-src\src\utils\toolSearch.ts -
查询文本匹配
ToolSearchTool 接收查询文本后,会在延迟工具池中进行匹配。匹配维度包括工具名称与描述。
返回结果以<functions>block 形式输出,每条结果包含完整 JSONSchema。
参考:restored-src\src\tools\ToolSearchTool\prompt.ts -
匹配结果被确认为“已发现工具”
被匹配到的工具通过tool_reference进入消息历史,随后被加入“已发现工具集合”。
参考:restored-src\src\utils\toolSearch.ts
因此 ToolSearchTool 的匹配逻辑本质上是:
“只在延迟工具池中进行名称/描述匹配,返回匹配工具的完整 schema,并将其标记为已发现,进入后续可调用列表。”
9.6.9 TST 模式下工具是否“直接注入”的澄清
这一小节用于澄清“tst 模式下,非 MCP 工具与 shouldDefer 工具是否直接注入”的问题。结论如下:
-
非延迟工具一律直接注入
只要工具不属于“延迟工具”(isDeferredTool 为 false),就会在每次请求的 tools 列表中直接发送完整 schema。
参考:restored-src\src\services\api\claude.ts中对filteredTools的构造,非延迟工具直接保留。 -
延迟工具只在“已发现”后进入 tools 列表
延迟工具包括两类:- MCP 工具(默认延迟)
- shouldDefer = true 的工具(除非 alwaysLoad = true 进行显式豁免)
这些工具在 tst 模式下不会被直接注入完整 schema,而是通过 ToolSearchTool 搜索后,以tool_reference形式进入“已发现集合”,随后才被加入后续请求的 tools 列表。
参考: restored-src\src\tools\ToolSearchTool\prompt.ts(isDeferredTool 的判定规则)restored-src\src\services\api\claude.ts(extractDiscoveredToolNames后再筛入 deferred tools)
-
ToolSearchTool 本身始终保留
即便所有延迟工具尚未发现,ToolSearchTool 仍会被保留在 tools 列表中,以便模型能够继续搜索。
参考:restored-src\src\services\api\claude.ts中 “Always include ToolSearchTool” 的过滤逻辑。
换句话说:在 tst 模式下,“普通工具”会直接注入,“延迟工具”不会直接注入,必须经过 ToolSearchTool 发现流程才会进入可调用集合。
9.6.10 ToolSearchTool 具体匹配算法(实现级)
ToolSearchTool 在收到模型提供的 query 后,不是简单“全文检索”,而是按下列顺序做确定性匹配与排序:
-
select 直接选取
如果查询形如select:A,B,C,则按名称精确匹配:- 先在 deferredTools 中找
- 找不到时再退回全量 tools(已加载工具)
这样可以让模型“选择已加载工具”时不触发重试。
参考:restored-src\src\tools\ToolSearchTool\ToolSearchTool.ts
-
关键字匹配入口
若非 select,则进入关键词搜索:- 先处理“精确同名匹配”
- 再处理
mcp__server前缀匹配 - 最后进行关键词评分排序
参考:restored-src\src\tools\ToolSearchTool\ToolSearchTool.ts
-
名称解析规则
- MCP 工具:
mcp__server__action被拆为server action词元 - 普通工具:CamelCase 与下划线拆分为词元
参考:parseToolName(...)inToolSearchTool.ts
- MCP 工具:
-
关键词分类与必选词
+term视为必选词- 其他词为可选词
必选词必须在“工具名或描述”中全部出现,否则工具直接被剔除。
参考:searchToolsWithKeywords(...)inToolSearchTool.ts
-
匹配评分权重
评分来自四类信号:- 名称词元精确命中(MCP 权重更高)
- 名称词元部分命中
- 搜索提示词 searchHint 命中(权重高于描述)
- 工具描述 prompt 命中
最终按分数降序取前max_results个。
参考:searchToolsWithKeywords(...)inToolSearchTool.ts
-
输出形式
ToolSearchTool 返回的是tool_result,其内容是tool_reference数组。
这些tool_reference被写入消息历史,后续由extractDiscoveredToolNames(...)解析为“已发现工具集合”。
参考:ToolSearchTool.ts的mapToolResultToToolResultBlockParam(...)restored-src\src\utils\toolSearch.ts的extractDiscoveredToolNames(...)
9.6.11 未发现工具时,模型如何发起 ToolSearchTool 查询
这一节用于回答一个核心疑问:当“延迟工具尚未发现”时,模型不知道具体工具名,如何还能调用 ToolSearchTool?
答案是:模型并非在“完全无信息”的状态下搜索,而是系统会显式注入“延迟工具名列表”,并允许关键词查询。
-
系统会给出可搜索的工具名目录
启用 TST 时,系统会把延迟工具名注入到模型可见文本中:- 旧机制:
<available-deferred-tools> ... </available-deferred-tools> - 新机制:通过
<system-reminder>的 deferred_tools_delta 通知
这使得模型在未发现 schema 时,仍然知道可搜索的工具名范围。
参考:restored-src\src\services\api\claude.ts,restored-src\src\tools\ToolSearchTool\prompt.ts
- 旧机制:
-
ToolSearchTool 支持关键词检索
查询不要求精确工具名,ToolSearchTool 会对“延迟工具池”的名称与描述做关键词评分,返回最匹配的工具。
因此模型即使只知道“功能关键词”,也可以通过搜索逐步定位目标工具。
参考:restored-src\src\tools\ToolSearchTool\ToolSearchTool.ts
结论:未发现工具时的查询并非盲搜,而是“有目录 + 关键词检索”的受限搜索流程。
9.6.12 工具数量过多时,系统是否会剔除非延迟工具
这一节回答“工具数量超过上限时,系统会不会删掉一部分工具”这一问题。按当前源码,结论是:
-
未看到按数量上限二次裁剪非延迟工具的逻辑
在useToolSearch为真的路径下,filteredTools的筛选规则是:- 非延迟工具全部保留
ToolSearchTool保留- 延迟工具中,仅已发现工具保留
这意味着当前实现并不存在“因为非延迟工具太多,所以再从中删除一批”的独立裁剪步骤。
参考:restored-src\src\services\api\claude.ts
-
系统控制规模的主要方式不是“删非延迟工具”,而是“尽量把可延迟工具移出首轮注入”
工具规模治理的主策略是:- 将 MCP 工具与
shouldDefer工具判定为延迟工具 - 通过
ToolSearchTool按需发现 - 仅把已发现延迟工具重新放回 tools 列表
因此,Claude Code 对工具规模的治理逻辑,是“减少首次注入规模”,而不是“对非延迟工具做溢出淘汰”。
参考:restored-src\src\utils\toolSearch.ts,restored-src\src\tools\ToolSearchTool\prompt.ts
- 将 MCP 工具与
-
tst-auto影响的是“是否启用延迟加载”,不是“是否裁剪普通工具”
自动模式会根据延迟工具定义占用的 token 或字符数决定是否开启 Tool Search。
这是一种“模式切换”,不是“注入后再裁剪”。
参考:restored-src\src\utils\toolSearch.ts
结论:按源码实证,所有非延迟工具都会进入提示词里的 tools 列表;系统当前没有实现“超过上限后,再剔除一部分非延迟工具”的单独策略。
9.6.13 工具提示词的压缩与降载手段
除了 defer_loading 之外,Claude Code 对工具提示词还有几类重要的降载机制。这些机制并不都属于“压缩文本”,但都直接作用于工具描述的上下文体积、缓存稳定性或发送频率。
-
延迟加载:把完整 schema 从首轮请求中移出
这是最直接的体积控制方式。对于 MCP 工具与shouldDefer工具,系统不在首轮注入完整 schema,而只保留ToolSearchTool与延迟工具名目录。
作用:减少首轮 tools block 的 token 体积。
参考:restored-src\src\services\api\claude.ts,restored-src\src\utils\toolSearch.ts -
工具 schema 会话级缓存:避免重复重渲染
toolToAPISchema(...)会把工具的基础 schema 缓存在 session 级缓存中,避免因为中途配置翻转、prompt 重新渲染、MCP 重连等因素频繁改变工具块字节内容。
这不是减少工具数量,而是降低 cache bust 频率。
参考:restored-src\src\utils\toolSchemaCache.ts,restored-src\src\utils\api.ts -
延迟工具目录只传“工具名”,不传完整描述
在available-deferred-tools机制下,系统给模型的只是延迟工具名列表,而不是每个工具的完整说明与 schema。
作用:把“可搜索范围提示”压缩到极小体积。
参考:formatDeferredToolLine(...)inrestored-src\src\tools\ToolSearchTool\prompt.ts -
system prompt 侧的缓存分层
虽然这不是“工具 schema 压缩”,但它会影响工具提示词带来的缓存抖动。
当没有实际渲染 MCP 工具时,system prompt 可以走更稳定的全局缓存;只有在必须把某些工具放进动态区时,才切换到更保守的缓存路径。
作用:减少因工具变化导致的整段 prompt 失效。
参考:splitSysPromptPrefix(...)与toolToAPISchema(...)inrestored-src\src\utils\api.ts -
输入 schema 复用与字段裁减
工具 schema 构造时优先复用已有inputJSONSchema,否则才从 Zod 转换;部分场景还会裁减 swarm 字段,避免无关字段暴露给用户。
这属于“结构裁减”,不是运行时摘要压缩,但确实会减小最终 schema 体积。
参考:toolToAPISchema(...)inrestored-src\src\utils\api.ts
结论:Claude Code 对工具提示词的治理不是单一“压缩算法”,而是“延迟加载 + 名录化暴露 + schema 缓存 + 缓存边界控制 + 字段裁减”的组合方案。
9.6.14 ToolSearchTool 的评分是词级匹配,不是语义级检索
ToolSearchTool 当前的匹配方式本质上是词项级、规则化、启发式评分,而不是 embedding 语义检索或模型判别式召回。
-
名称处理是显式分词
普通工具会按 CamelCase 与下划线拆词,MCP 工具会按mcp__server__action结构拆词。
这说明搜索的第一层基础是“字符串分词”,不是语义向量。
参考:parseToolName(...)inrestored-src\src\tools\ToolSearchTool\ToolSearchTool.ts -
命中判断依赖
includes与正则词边界
搜索代码中使用了:parsed.parts.includes(term)part.includes(term)pattern.test(descNormalized)pattern.test(hintNormalized)
这些都是典型的词面匹配,而不是语义相似度计算。
参考:searchToolsWithKeywords(...)inrestored-src\src\tools\ToolSearchTool\ToolSearchTool.ts
-
排序是人工权重叠加
MCP 精确词元命中、普通名称精确命中、部分命中、searchHint命中、描述命中,分别累加不同分值,最后按总分排序。
这属于启发式 lexical ranking,而不是 semantic ranking。
参考:searchToolsWithKeywords(...)inrestored-src\src\tools\ToolSearchTool\ToolSearchTool.ts
结论:max_results 的前 N 个结果,来自词级匹配和规则加权排序,不是语义向量检索。