前言
《驾驭工程》,中文别名《马书》。
我认为,Claude Code 源码最佳的“食用”姿势应该是转化为一本书,供自己系统学习。对我来说,看书学习比直接看源码更舒服,也更容易形成完整的认知框架。
为了尽可能保证 AI 写作质量,这本书的提取过程并不是“把源码丢给模型直接生成”那么简单,而是按一条比较严格的工程流程推进的:
- 先根据源码把
DESIGN.md聊清楚,也就是先把整本书的大纲和设计定下来。 - 然后为每一章编写 spec,基于我开源的
agent-spec来约束章节目标、边界和验收标准。 - 接着再做 plan,把具体执行步骤拆开。
- 最后再叠加我自己的技术写作 skill,才让 AI 开始正式写作。
这本书并不是为了出版,而是为了让我能更系统地学习 Claude Code。我对它的基本判断是:AI 肯定写不得十全十美,但只要把初始版本开源出来,大家就可以一边阅读、一边讨论、一边逐步完善它,把它共建成一本真正有价值的公版书。
第1章:AI 编码 Agent 的完整技术栈
为什么这很重要
要理解一个 AI 编码 Agent 如何从“接收用户输入“走到“在你的代码库中执行操作“,首先必须理解它的技术栈(technology stack)。技术栈不仅决定了性能天花板,更决定了架构边界——哪些事情可以在编译时完成,哪些必须推迟到运行时,哪些需要模型自己去决策。
Claude Code 的技术栈选择揭示了一个核心理念:AI 编码 Agent 不是传统的 CLI 工具,它是一个“在分发状态下(on distribution)运行“的系统——模型不仅使用工具,还能编写自己的工具。这意味着整个技术栈必须为“模型作为一等公民“而设计,从入口点的启动优化到 Feature Flag 的构建时消除,每一层都在为这个目标服务。
本章将建立一个贯穿全书的核心概念——三层架构——并通过源码分析展示它如何在 Claude Code v2.1.88 中具体落地。
1.1 技术栈概览:TypeScript + React Ink + Bun
Claude Code 的技术选型可以用一句话概括:用 TypeScript 获得类型安全,用 React Ink 获得终端 UI 的组件化能力,用 Bun 获得启动速度和构建时优化。
TypeScript:应用层的语言
整个代码库由 1,902 个 TypeScript 源文件组成。TypeScript 的类型系统在 AI Agent 开发中有一个独特优势:工具的输入/输出 Schema 可以直接从类型定义生成,而这些 Schema 又直接成为发送给模型的 JSON Schema——类型定义、运行时验证和模型指令三者合一。
React Ink:终端 UI 框架
Claude Code 的交互界面不是传统的 readline REPL,而是一个完整的 React 应用。React Ink 将 React 的组件模型带入终端,使得复杂的 UI 状态管理(流式输出、多工具并行显示、权限对话框)可以用声明式的方式表达。主要的 UI 组件位于 restored-src/src/screens/REPL.tsx,它本身就是一个超过 5,000 行的 React 组件。
Bun:运行时与构建工具
Bun 在这里承担双重角色:
- 运行时:比 Node.js 更快的启动速度,对 CLI 工具至关重要——用户期望输入
claude后立即看到响应 - 构建工具:通过
bun:bundle提供的feature()函数实现编译时死代码消除(Dead Code Elimination, DCE),这是整个 Feature Flag 系统的基石
1.2 入口点分析:main.tsx 的启动编排
main.tsx 是整个应用的入口点,它的前 20 行代码就展示了一种经过深思熟虑的启动优化策略。
并行预取(Parallel Prefetch)
// restored-src/src/main.tsx:1-20
import { profileCheckpoint, profileReport } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');
import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();
import { ensureKeychainPrefetchCompleted, startKeychainPrefetch }
from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();
注意这里的代码组织方式:每个 import 后面紧跟一个立即执行的副作用调用(side-effect call)。源码注释(restored-src/src/main.tsx:1-8)明确解释了设计意图:
profileCheckpoint:在任何重量级模块求值开始前标记入口时间点startMdmRawRead:启动 MDM(Mobile Device Management)子进程(macOS 上的plutil/ Windows 上的reg query),使其与后续约 135ms 的 import 评估并行执行startKeychainPrefetch:并行启动两个 macOS Keychain 读取操作(OAuth 令牌和旧版 API 密钥)——如果不做预取,isRemoteManagedSettingsEligible()会通过同步 spawn 顺序读取,每次启动多花约 65ms
这三个操作遵循同一个模式:将 I/O 密集型操作提前到模块加载的“死时间“中并行执行。这不是偶然的优化——ESLint 注释 // eslint-disable-next-line custom-rules/no-top-level-side-effects 表明团队有一条自定义规则禁止顶层副作用,这里是经过审慎考虑后的豁免。
延迟导入(Lazy Import)
在并行预取之后,main.tsx 展示了第二种启动优化策略——条件性延迟导入:
// restored-src/src/main.tsx:69-81
const getTeammateUtils = () =>
require('./utils/teammate.js') as typeof import('./utils/teammate.js');
const getTeammatePromptAddendum = () =>
require('./utils/swarm/teammatePromptAddendum.js') as ...;
const coordinatorModeModule = feature('COORDINATOR_MODE')
? require('./coordinator/coordinatorMode.js') as ...
: null;
const assistantModule = feature('KAIROS')
? require('./assistant/index.js') as ...
: null;
这里有两种不同的延迟加载策略:
- 函数包装的
require(如getTeammateUtils):用于打破循环依赖(teammate.ts -> AppState.tsx -> ... -> main.tsx),每次调用时才解析模块 - Feature Flag 守卫的
require(如coordinatorModeModule):利用 Bun 的feature()实现构建时消除——当COORDINATOR_MODE为false时,整个require表达式及其导入的模块树都会在构建产物中被移除
Feature Flag 作为门控
从第 21 行开始,feature('...') 函数的身影贯穿整个入口文件:
// restored-src/src/main.tsx:21
import { feature } from 'bun:bundle';
这个来自 bun:bundle 的 feature() 函数是理解整个 Feature Flag 系统的关键。它不是运行时的条件判断——它是一个编译时常量。当 Bun 打包器处理 feature('X') 时,会根据构建配置将其替换为 true 或 false 字面量,然后 JavaScript 引擎的死代码消除会移除不可达的分支。注意 bun:bundle 的 feature() 并非 Bun 公开文档化的 API,而是 Anthropic 构建流水线中的定制条件编译机制。
1.3 三层架构
Claude Code 的架构可以分为三层,每一层有明确的职责边界。这个架构模型将在后续章节中反复引用——第3章的 Agent Loop 运行在应用层,第4章的工具执行编排跨越应用层和运行时层,第13-15章的缓存优化则涉及所有三层的协作。
graph TB
subgraph L1["应用层 (Application Layer)"]
direction TB
TS["TypeScript 源码<br />1,902 个文件"]
RI["React Ink<br />终端 UI 框架"]
AL["Agent Loop<br />query.ts 状态机"]
TL["工具系统<br />40+ 个工具"]
SP["系统提示词<br />分段式组合"]
TS --> RI
TS --> AL
TS --> TL
TS --> SP
subgraph L2["运行时层 (Runtime Layer)"]
direction TB
BUN["Bun 运行时<br />快速启动 + ESM"]
ZIG["Zig 底层<br />Bun 内核"]
BB["bun:bundle<br />feature() DCE"]
JSC["JavaScriptCore<br />JS 引擎"]
BUN --> ZIG
BUN --> BB
BUN --> JSC
subgraph L3["外部依赖层 (External Dependencies)"]
direction TB
NPM["npm 包<br />commander, chalk, lodash-es..."]
API["Anthropic API<br />模型调用 + 提示词缓存"]
MCP["MCP Servers<br />外部工具扩展"]
GB["GrowthBook<br />运行时 Feature Flag"]
NPM --- API
NPM --- MCP
NPM --- GB
L1 --> L2
L2 --> L3
style L1 fill:#e8f4f8,stroke:#2196F3,stroke-width:2px
style L2 fill:#fff3e0,stroke:#FF9800,stroke-width:2px
style L3 fill:#f3e5f5,stroke:#9C27B0,stroke-width:2px
应用层(TypeScript)
应用层是所有业务逻辑所在的地方。它包含:
- Agent Loop(
query.ts):核心状态机,编排“模型调用 → 工具执行 → 继续判定“的循环(详见第3章) - 工具系统(
tools.ts+tools/目录):40+ 个工具的注册、权限检查和执行(详见第2章) - 系统提示词(
constants/prompts.ts):分段式组合的提示词架构(详见第5章) - React Ink UI(
screens/REPL.tsx):终端界面的声明式渲染
运行时层(Bun/Zig/JSC)
运行时层提供三个关键能力:
- 快速启动:Bun 的启动速度比 Node.js 快数倍,对 CLI 工具体验至关重要
- 构建时优化:
bun:bundle的feature()函数实现编译时 Feature Flag 消除 - JavaScript 引擎:Bun 底层使用 JavaScriptCore(JSC,Safari 的 JS 引擎)而非 V8,配合 Zig 编写的底层运行时内核
外部依赖层
外部依赖层包括:
- npm 包:
commander(CLI 参数解析)、chalk(终端着色)、lodash-es(实用函数)等 - Anthropic API:模型调用和提示词缓存(Prompt Cache)的服务端
- MCP Servers:Model Context Protocol 服务器,提供外部工具扩展能力
- GrowthBook:运行时 A/B 测试和 Feature Flag 服务
层间边界的意义
三层架构的关键在于层间的信息流方向:
- 应用层 → 运行时层:TypeScript 代码编译为 JavaScript,
feature()调用在此时被解析 - 运行时层 → 外部依赖层:HTTP 请求、npm 包加载、MCP 连接
- 外部依赖层 → 应用层:模型响应、工具结果、Feature Flag 值——这些信息向上穿透两层回到应用层
理解这个穿透路径很重要:当 GrowthBook 返回一个 tengu_* Feature Flag 的新值时,它影响的不是构建时的 feature() 函数(那些在构建时已经固化),而是运行时的条件逻辑。Claude Code 中存在两套并行的 Feature Flag 机制:构建时的 feature() 和运行时的 GrowthBook,它们服务于不同的目的(后面详述)。
1.4 为什么 “On Distribution” 很重要
“On distribution” 是理解 Claude Code 架构决策的一个关键概念。传统的 CLI 工具在开发时定义好所有功能,然后分发给用户。但 AI 编码 Agent 不同——它在被用户使用时,其行为由模型动态决定。
具体来说:
- 模型选择工具:Agent Loop 的每次迭代中,模型决定调用哪个工具、传入什么参数。工具的
description和inputSchema不仅是文档——它们是发送给模型的指令 - 模型编写自己的工具:通过
BashTool,模型可以执行任意 shell 命令;通过FileWriteTool,模型可以创建新文件;通过SkillTool,模型可以加载和执行用户定义的提示词模板 - 模型作用于自己的上下文:通过压缩(Compaction)、微压缩(Microcompact)和上下文折叠(Context Collapse),模型参与管理自己的上下文窗口
这意味着技术栈必须考虑一个传统软件不需要考虑的维度:模型作为运行时的一部分,它的行为不完全由代码控制,而是由提示词、工具描述和上下文共同塑造。
这就是为什么 Feature Flag 系统如此重要——它不仅控制代码路径,还控制模型能“看到“哪些工具。当 feature('WEB_BROWSER_TOOL') 为 false 时,WebBrowserTool 不仅不会被加载,它的整个模块树都从构建产物中消失,模型永远无法知道它的存在:
// restored-src/src/tools.ts:117-119
const WebBrowserTool = feature('WEB_BROWSER_TOOL')
? require('./tools/WebBrowserTool/WebBrowserTool.js').WebBrowserTool
: null;
1.5 构建时死代码消除:feature() 的工作原理
feature() 函数来自 Bun 的打包器模块 bun:bundle,它在 Claude Code 中被大量使用来实现构建时的条件编译。
机制
当 Bun 的打包器遇到 feature('X') 调用时:
- 查找构建配置中
X的值 - 将
feature('X')替换为字面量true或false - JavaScript 引擎的优化器识别出不可达分支并将其移除
这意味着以下代码:
const SleepTool = feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null;
在 PROACTIVE=false, KAIROS=false 的构建中会变成:
const SleepTool = false || false
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null;
进而被优化为 const SleepTool = null;,而 SleepTool.js 及其整个依赖树都不会出现在最终的 bundle 中。
使用模式
在 tools.ts 中,feature() 的使用呈现四种模式:单 Flag 守卫、多 Flag OR 组合、多 Flag AND 组合、数组展开。这些模式在 commands.ts 中同样出现(restored-src/src/commands.ts:59-100),用于控制 slash 命令的可用性。工具注册管线的完整分析详见第2章。
与运行时 Flag 的区别
Claude Code 中存在两套 Feature Flag 机制,容易混淆:
| 维度 | 构建时 feature() | 运行时 GrowthBook tengu_* |
|---|---|---|
| 解析时机 | Bun 打包时 | 会话启动时从 GrowthBook 拉取 |
| 影响范围 | 代码是否存在于 bundle | 代码逻辑的运行时分支 |
| 修改方式 | 需要重新构建和发布 | 服务端配置即时生效 |
| 典型用途 | 实验性功能的完整模块树消除 | A/B 测试、渐进灰度 |
| 示例 | feature('KAIROS') | tengu_ultrathink_enabled |
两者互补:feature() 用于“这个功能是否存在“,GrowthBook 用于“这个功能对哪些用户开放“。一个功能通常先由 feature() 守卫其模块加载,再由 GrowthBook 控制其运行时行为。
1.6 89 个 Feature Flag 全景
通过对源码中所有 feature('...') 调用的提取,我们得到 89 个构建时 Feature Flag。按功能分类如下:
| 分类 | Feature Flag | 简述 |
|---|---|---|
| 核心 Agent 循环 | REACTIVE_COMPACT | 响应式压缩——根据上下文动态触发 |
CONTEXT_COLLAPSE | 上下文折叠——结构化的上下文修剪 | |
CACHED_MICROCOMPACT | 缓存感知的微压缩 | |
TOKEN_BUDGET | Token 预算追踪器 | |
HISTORY_SNIP | 历史记录精确裁剪 | |
COMPACTION_REMINDERS | 压缩后的上下文提醒注入 | |
| 工具与能力 | WEB_BROWSER_TOOL | 内置浏览器工具(Bun WebView) |
MONITOR_TOOL | 监控工具 | |
OVERFLOW_TEST_TOOL | 溢出测试工具 | |
TERMINAL_PANEL | 终端面板捕获 | |
WORKFLOW_SCRIPTS | 工作流脚本系统 | |
POWERSHELL_AUTO_MODE | PowerShell 自动模式 | |
TREE_SITTER_BASH | Tree-sitter Bash 解析 | |
TREE_SITTER_BASH_SHADOW | Tree-sitter Bash 影子模式 | |
BASH_CLASSIFIER | Bash 命令分类器 | |
| 助手与自主模式 | KAIROS | 助手模式——后台自主 Agent |
KAIROS_BRIEF | 助手模式简报 | |
KAIROS_CHANNELS | 助手模式频道 | |
KAIROS_DREAM | 助手模式记忆整理(autoDream) | |
KAIROS_GITHUB_WEBHOOKS | 助手模式 GitHub Webhook | |
KAIROS_PUSH_NOTIFICATION | 助手模式推送通知 | |
PROACTIVE | 主动模式——自主感知终端焦点 | |
AWAY_SUMMARY | 离开摘要 | |
| 多 Agent 编排 | COORDINATOR_MODE | 协调者模式——多 Worker 编排 |
FORK_SUBAGENT | 子 Agent 分叉执行 | |
TEAMMEM | 队友记忆共享 | |
UDS_INBOX | Unix Domain Socket 消息收件箱 | |
VERIFICATION_AGENT | 验证 Agent | |
BUILTIN_EXPLORE_PLAN_AGENTS | 内置探索/计划 Agent | |
| 远程与分布式 | BRIDGE_MODE | 远程控制桥接 |
DAEMON | 后台守护进程 | |
CCR_AUTO_CONNECT | CCR 自动连接 | |
CCR_MIRROR | CCR 镜像 | |
CCR_REMOTE_SETUP | CCR 远程设置 | |
SSH_REMOTE | SSH 远程模式 | |
DIRECT_CONNECT | 直连模式 | |
SELF_HOSTED_RUNNER | 自托管 Runner | |
BYOC_ENVIRONMENT_RUNNER | BYOC 环境 Runner | |
| 技能系统 | EXPERIMENTAL_SKILL_SEARCH | 实验性技能搜索 |
RUN_SKILL_GENERATOR | 技能生成器 | |
SKILL_IMPROVEMENT | 技能改进 | |
MCP_SKILLS | MCP 技能桥接 | |
BUILDING_CLAUDE_APPS | 构建 Claude 应用技能 | |
| 用户界面 | VOICE_MODE | 语音模式 |
BUDDY | 伴侣精灵(动画角色) | |
AUTO_THEME | 自动主题切换 | |
MESSAGE_ACTIONS | 消息操作(交互按钮) | |
NATIVE_CLIPBOARD_IMAGE | 原生剪贴板图片 | |
HISTORY_PICKER | 历史选择器 | |
STREAMLINED_OUTPUT | 精简输出模式 | |
QUICK_SEARCH | 快速搜索 | |
CONNECTOR_TEXT | 连接器文本 | |
| 调度与触发 | AGENT_TRIGGERS | Agent 定时触发器(Cron) |
AGENT_TRIGGERS_REMOTE | 远程 Agent 触发器 | |
BG_SESSIONS | 后台会话 | |
TEMPLATES | 模板系统(任务模板) | |
| 安全与权限 | TRANSCRIPT_CLASSIFIER | 会话转录分类器 |
HOOK_PROMPTS | Hook 提示词 | |
ANTI_DISTILLATION_CC | 反蒸馏保护 | |
NATIVE_CLIENT_ATTESTATION | 原生客户端证明 | |
| 记忆与上下文 | EXTRACT_MEMORIES | 记忆提取 |
AGENT_MEMORY_SNAPSHOT | Agent 记忆快照 | |
MEMORY_SHAPE_TELEMETRY | 记忆形状遥测 | |
FILE_PERSISTENCE | 文件持久化 | |
| 计划与执行 | ULTRAPLAN | 超级计划模式 |
ULTRATHINK | 超级思考模式 | |
REVIEW_ARTIFACT | 审查工件 | |
UNATTENDED_RETRY | 无人值守重试 | |
| 遥测与调试 | ENHANCED_TELEMETRY_BETA | 增强遥测(Beta) |
COWORKER_TYPE_TELEMETRY | 协作者类型遥测 | |
PERFETTO_TRACING | Perfetto 跟踪 | |
SLOW_OPERATION_LOGGING | 慢操作日志 | |
SHOT_STATS | Shot 统计 | |
PROMPT_CACHE_BREAK_DETECTION | 提示词缓存中断检测 | |
BREAK_CACHE_COMMAND | 缓存中断命令 | |
| MCP 扩展 | CHICAGO_MCP | Chicago MCP 集成 |
MCP_RICH_OUTPUT | MCP 富输出 | |
| 实验性 | ABLATION_BASELINE | 消融基线测试 |
DUMP_SYSTEM_PROMPT | 导出系统提示词 | |
LODESTONE | Lodestone 引导系统 | |
TORCH | Torch 功能 | |
| 设置与配置 | DOWNLOAD_USER_SETTINGS | 下载用户设置 |
UPLOAD_USER_SETTINGS | 上传用户设置 | |
NEW_INIT | 新版初始化流程 | |
HARD_FAIL | 硬失败模式 | |
ALLOW_TEST_VERSIONS | 允许测试版本 | |
| 平台检测 | IS_LIBC_GLIBC | glibc 检测 |
IS_LIBC_MUSL | musl 检测 | |
COMMIT_ATTRIBUTION | 提交归因 |
从 Flag 看产品路线图
89 个 Feature Flag 不仅是技术实现细节,它们是产品路线图的投影:
- KAIROS 家族(6 个 Flag):这是最大的 Flag 集群,指向一个完整的“助手模式“产品——后台自主运行、记忆整理、推送通知、GitHub Webhook 集成。这不是一个 CLI 工具的增强,而是一个完全不同的产品形态
- COORDINATOR_MODE + TEAMMEM + UDS_INBOX:多 Agent 协作的基础设施——Worker 分配、队友记忆、进程间通信
- BRIDGE_MODE + DAEMON + CCR_*:远程控制和分布式执行——将 Claude Code 从本地 CLI 扩展为可远程操控的 Agent 平台
- ULTRAPLAN + ULTRATHINK:模型推理能力的升级——更深层的计划和思考能力
1.7 工具注册管线:Feature Flag 的实战应用
tools.ts 的 getAllBaseTools() 函数(restored-src/src/tools.ts:193-251)是 Feature Flag 系统最集中的展示。它展示了三种不同的工具注册策略:
策略一:无条件注册
// restored-src/src/tools.ts:195-209
AgentTool,
TaskOutputTool,
BashTool,
FileReadTool,
FileEditTool,
FileWriteTool,
这些是核心工具,始终可用,无需任何条件。
策略二:构建时 Feature Flag 守卫
// restored-src/src/tools.ts:217-218
...(WebBrowserTool ? [WebBrowserTool] : []),
WebBrowserTool 在文件顶部通过 feature('WEB_BROWSER_TOOL') 守卫——如果 Flag 为 false,变量为 null,此处展开为空数组。整个工具的代码在构建产物中不存在。
策略三:运行时环境变量守卫
// restored-src/src/tools.ts:214-215
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
ConfigTool 和 TungstenTool 通过运行时环境变量 USER_TYPE 控制——它们的代码存在于构建产物中,但只对 Anthropic 内部用户(ant)可见。这是 A/B 测试的“暂存区“模式:在内部验证后再向外部用户开放。
策略四:运行时函数守卫
// restored-src/src/tools.ts:200-201
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
这是一个有趣的反向守卫:当 Bun 的单文件可执行中内嵌了搜索工具(bfs/ugrep)时,独立的 GlobTool 和 GrepTool 反而被移除——因为模型可以直接通过 BashTool 访问这些内嵌的快速工具。
1.8 模式提炼
从 Claude Code 的技术栈选择中,我们可以提炼出几个适用于所有 AI Agent 构建者的模式:
模式一:启动时间是 UX
CLI 工具的启动时间直接影响用户体验。Claude Code 通过三种手段优化启动:并行预取(I/O 与模块加载重叠)、延迟导入(按需加载)、构建时消除(减小 bundle 体积)。对于 AI Agent 来说,启动慢意味着用户在等待期间失去上下文,这比传统工具更糟糕。
模式二:双层 Feature Flag
构建时 Flag(feature())和运行时 Flag(GrowthBook)服务于不同目的。前者控制代码的物理存在,后者控制行为的逻辑分支。在 AI Agent 中,这个区分尤为重要——构建时 Flag 决定模型能“看到“哪些工具(因为不存在的工具不会出现在 Schema 中),运行时 Flag 决定模型的行为参数(如 effort 级别、thinking 预算)。
模式三:模型感知的架构
传统软件的架构是为人类开发者设计的——API 边界、模块划分、抽象层次。AI Agent 的架构还需要为模型设计——工具描述是否清晰、Schema 是否自解释、提示词是否与代码行为一致。Claude Code 的三层架构中,应用层不仅是代码逻辑的组织,更是模型交互界面的组织。
模式四:失败关闭是默认值
工具系统中,工具默认标记为“不安全“(isReadOnly: false)和“不可并发“(isConcurrencySafe: false)。必须显式声明安全才能获得并发执行的优化。这个“失败关闭(fail closed)“原则贯穿整个架构——在 AI Agent 中,一个未经审查的工具调用可能修改用户的代码库,因此保守是正确的默认值。
本章小结
本章建立了理解 Claude Code 的基础框架:
- 技术栈:TypeScript + React Ink + Bun 的组合,为类型安全、声明式 UI 和构建时优化服务
- 入口点:
main.tsx通过并行预取、延迟导入和 Feature Flag 实现快速启动 - 三层架构:应用层(TS)→ 运行时层(Bun/Zig/JSC)→ 外部依赖层(npm/API/MCP/GrowthBook),这是全书的参考框架
- Feature Flag 系统:89 个构建时 Flag 通过
bun:bundle的feature()实现死代码消除,与运行时 GrowthBook Flag 互补 - On distribution:AI Agent 的行为由模型在运行时决定,技术栈必须为此设计
在下一章中,我们将深入工具系统——模型的“双手“——看看 40+ 个工具如何通过统一的接口契约、权限模型和 Feature Flag 守卫组成一个可扩展的能力体系。
第2章:工具系统 — 40+ 个工具作为模型的双手
为什么工具系统是 Claude Code 的核心
大语言模型的“思考“发生在文本空间,而软件工程的操作发生在文件系统、终端和网络中。工具系统是连接这两个世界的桥梁:它将模型的意图翻译为真实的副作用,再将副作用的结果翻译回模型可消费的文本。
Claude Code 的工具系统管理着 40+ 个内置工具和不限数量的 MCP 扩展工具。这些工具不是一个平铺的数组——它们经历了一条精密的管线:定义 → 注册 → 过滤 → 调用 → 渲染。每一步都有明确的契约。本章将从 Tool.ts 的接口定义开始,逐层拆解这条管线的设计决策。
2.1 Tool 接口契约
所有工具——无论是内置的 BashTool 还是通过 MCP 协议加载的第三方工具——都必须满足同一个 TypeScript 接口。这个接口定义在 restored-src/src/Tool.ts:362-695,是整个工具系统的基石。
核心字段一览
| 字段 | 类型 | 职责 | 必需 |
|---|---|---|---|
name | readonly string | 工具的唯一标识符,用于权限匹配、分析埋点和 API 传输 | 是 |
description | (input, options) => Promise<string> | 返回发送给模型的工具描述文本;可根据权限上下文动态调整 | 是 |
prompt | (options) => Promise<string> | 返回工具的系统提示词(system prompt),详见第8章 | 是 |
inputSchema | z.ZodType (Zod v4) | 用 Zod schema 定义工具的参数结构,自动转换为 JSON Schema 发送给 API | 是 |
call | (args, context, canUseTool, parentMessage, onProgress?) => Promise<ToolResult> | 工具的核心执行逻辑 | 是 |
checkPermissions | (input, context) => Promise<PermissionResult> | 工具级权限检查,在通用权限系统之后执行 | 是* |
validateInput | (input, context) => Promise<ValidationResult> | 在权限检查之前验证输入的合法性 | 否 |
maxResultSizeChars | number | 单工具结果的字符数上限,超出后持久化到磁盘 | 是 |
isConcurrencySafe | (input) => boolean | 是否可以与其他工具并发执行 | 是* |
isReadOnly | (input) => boolean | 是否为只读操作(不修改文件系统) | 是* |
isEnabled | () => boolean | 当前环境下工具是否可用 | 是* |
标注 * 的字段由
buildTool()提供默认值,工具定义时可省略。
几个值得深究的设计选择:
description 是函数而非字符串。 同一个工具在不同的权限模式下可能需要不同的描述。例如,当用户配置了 alwaysDeny 规则禁止某些子命令时,工具描述可以主动告知模型“不要尝试这些操作“,从而在提示层面就避免无用的工具调用。
inputSchema 使用 Zod v4。 这让工具参数可以在运行时进行严格校验,同时通过 z.toJSONSchema() 自动生成发送给 Anthropic API 的 JSON Schema。Zod 的 z.strictObject() 确保模型不会传入未定义的参数。
call 接收 canUseTool 回调。 这是一个极其重要的设计——工具执行过程中可能需要递归地检查子操作的权限。例如 AgentTool 在启动子 Agent 时需要检查子 Agent 是否有权使用特定工具。权限检查不是一次性的门禁,而是贯穿执行过程的持续验证。
渲染契约:三组方法
Tool 接口中定义了一组渲染方法,它们构成了工具在终端 UI 中的完整生命周期表现(详见 2.5 节):
renderToolUseMessage // 工具被调用时展示
renderToolUseProgressMessage // 工具执行中展示进度
renderToolResultMessage // 工具执行完成后展示结果
此外还有 renderToolUseErrorMessage、renderToolUseRejectedMessage(权限被拒)和 renderGroupedToolUse(并行工具的分组展示)等可选方法。
2.2 buildTool() 工厂函数与失败关闭默认值
每一个具体工具都不是直接导出一个满足 Tool 接口的对象,而是通过 buildTool() 工厂函数构建。这个函数定义在 restored-src/src/Tool.ts:783-792:
// export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
...def,
} as BuiltTool<D>
}
运行时行为极简——就是一个对象展开(spread)。但它的类型层面设计(BuiltTool<D> 类型)精确地模拟了 { ...TOOL_DEFAULTS, ...def } 的语义:如果工具定义提供了某个方法,使用工具定义的版本;否则使用默认值。
默认值与“失败关闭“哲学
TOOL_DEFAULTS(restored-src/src/Tool.ts:757-769)的设计遵循一个安全原则——在不确定时假设最危险的情况:
| 默认方法 | 默认值 | 设计意图 |
|---|---|---|
isEnabled | () => true | 除非明确禁用,工具默认可用 |
isConcurrencySafe | () => false | 失败关闭:假设不安全,禁止并发 |
isReadOnly | () => false | 失败关闭:假设会写入,需要权限 |
isDestructive | () => false | 默认非破坏性 |
checkPermissions | 返回 { behavior: 'allow' } | 交给通用权限系统处理 |
toAutoClassifierInput | () => '' | 默认不参与自动安全分类 |
userFacingName | () => def.name | 使用工具名称 |
其中最重要的两个默认值是 isConcurrencySafe: false 和 isReadOnly: false。这意味着:一个新工具如果忘记声明这两个属性,系统会自动将其视为“可能修改文件系统且不能并发执行“——这是最保守、最安全的假设。只有当工具开发者主动声明 isConcurrencySafe() { return true } 和 isReadOnly() { return true } 时,系统才会放宽限制。
实际工具如何使用 buildTool
以 GrepTool 为例(restored-src/src/tools/GrepTool/GrepTool.ts:160-194):
export const GrepTool = buildTool({
name: GREP_TOOL_NAME,
searchHint: 'search file contents with regex (ripgrep)',
maxResultSizeChars: 20_000,
strict: true,
// ...
isConcurrencySafe() { return true }, // 搜索是安全的并发操作
isReadOnly() { return true }, // 搜索不修改文件
// ...
})
GrepTool 明确覆盖了两个默认值,因为搜索操作天然是只读且并发安全的。相比之下,BashTool(restored-src/src/tools/BashTool/BashTool.tsx:434-441)的并发安全性是有条件的:
isConcurrencySafe(input) {
return this.isReadOnly?.(input) ?? false;
},
isReadOnly(input) {
const compoundCommandHasCd = commandHasAnyCd(input.command);
const result = checkReadOnlyConstraints(input, compoundCommandHasCd);
return result.behavior === 'allow';
},
BashTool 只有在被判定为只读命令时才允许并发——一个 git status 可以并发执行,但 git push 不行。这种输入感知的并发控制是 buildTool 的方法签名接收 input 参数的原因。
2.3 工具注册管线:tools.ts
restored-src/src/tools.ts 是工具池(Tool Pool)的组装中心。它回答一个核心问题:在当前环境下,模型可以使用哪些工具?
三级过滤
工具从定义到最终可用,经历三级过滤:
第一级:编译期/启动期条件加载。 大量工具通过 Feature Flag 进行条件加载(restored-src/src/tools.ts:16-135):
const SleepTool =
feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
const cronTools = feature('AGENT_TRIGGERS')
? [
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
]
: []
feature() 函数来自 bun:bundle,在打包时求值。这意味着未启用的工具根本不会出现在最终的 JavaScript bundle 中——这是一种比运行时 if 更彻底的死代码消除。
除了 Feature Flag,还有环境变量驱动的条件加载:
const REPLTool =
process.env.USER_TYPE === 'ant'
? require('./tools/REPLTool/REPLTool.js').REPLTool
: null
USER_TYPE === 'ant' 标记 Anthropic 内部员工使用的特殊工具(如 REPLTool、ConfigTool、TungstenTool),这些工具在公开版本中不可用。
第二级:getAllBaseTools() 组装基础工具池。 这个函数(restored-src/src/tools.ts:193-251)将所有通过第一级过滤的工具收集到一个数组中。它是系统的“工具注册表“——所有可能存在的工具都在这里登记。当前版本包含约 40+ 个内置工具,根据 Feature Flag 的启用情况动态增减。
// export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
FileReadTool,
FileEditTool,
FileWriteTool,
// ... 省略 30+ 个工具
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]
}
注意一个有趣的条件:hasEmbeddedSearchTools()。在 Anthropic 内部构建中,bfs(fast find)和 ugrep 被嵌入到 Bun 二进制文件中,此时 shell 里的 find 和 grep 已经被别名到这些快速工具,独立的 GlobTool 和 GrepTool 就变得多余了。
第三级:getTools() 运行时过滤。 这是最终的过滤层(restored-src/src/tools.ts:271-327),它执行三个操作:
- 权限拒绝过滤:通过
filterToolsByDenyRules()移除被alwaysDeny规则覆盖的工具。如果用户配置了"Bash": "deny",BashTool在发送给模型的工具列表中根本不会出现。 - REPL 模式隐藏:当 REPL 模式启用时,
Bash、Read、Edit等基础工具被隐藏——它们通过REPLTool的 VM 上下文间接暴露。 isEnabled()最终检查:每个工具的isEnabled()方法是最后一道开关。
简单模式与完整模式
getTools() 还支持一种“简单模式“(CLAUDE_CODE_SIMPLE),只暴露 Bash、FileRead 和 FileEdit 三个核心工具。这在一些集成场景下很有用——减少工具数量可以降低 token 消耗并减少模型的决策负担。
MCP 工具的融合
最终的工具池由 assembleToolPool()(restored-src/src/tools.ts:345-367)完成组装:
// export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools,
): Tools {
const builtInTools = getTools(permissionContext)
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
}
这里有两个关键设计:
- 内置工具优先:
uniqBy保留第一次出现的名称,内置工具排在前面,因此在名称冲突时内置工具胜出。 - 按名称排序以稳定提示缓存:内置工具和 MCP 工具各自排序后拼接(而非混合排序),确保内置工具作为“连续前缀“出现。这与 API 服务端的缓存断点设计协作——如果 MCP 工具穿插在内置工具中间,任何 MCP 工具的增减都会导致所有下游缓存键失效。详见第13章。
2.4 工具结果大小预算
当一个工具返回结果时,系统面临一个核心矛盾:模型需要看到完整的信息来做出正确决策,但上下文窗口是有限的。Claude Code 通过两级预算解决这个问题。
第一级:单工具结果上限 maxResultSizeChars
每个工具通过 maxResultSizeChars 字段声明自己的结果大小上限。超出此上限的结果会被持久化到磁盘,模型只看到一个预览(preview)加上磁盘文件路径。
以下是不同工具的 maxResultSizeChars 对比:
| 工具 | maxResultSizeChars | 说明 |
|---|---|---|
McpAuthTool | 10,000 | 认证结果,数据量小 |
GrepTool | 20,000 | 搜索结果需要精简 |
BashTool | 30,000 | Shell 输出可能较长 |
GlobTool | 100,000 | 文件列表可能很多 |
AgentTool | 100,000 | 子 Agent 结果 |
WebSearchTool | 100,000 | 网页搜索结果 |
BriefTool | 100,000 | 简要总结 |
FileReadTool | Infinity | 永不持久化(见下文) |
FileReadTool 的 maxResultSizeChars: Infinity 是一个特殊设计——避免 Read → 持久化文件 → Read 的循环引用。系统还有一个全局上限 DEFAULT_MAX_RESULT_SIZE_CHARS = 50,000(restored-src/src/constants/toolLimits.ts:13),作为无论工具声明什么值都生效的硬顶。
第二级:单消息聚合上限
当模型在一个回合中并行调用多个工具时,所有工具结果会作为同一个 user message 的多个 tool_result 块发送。MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200,000(restored-src/src/constants/toolLimits.ts:49)限制了单条消息的工具结果总大小,防止 N 个并行工具集体挤爆上下文窗口。
FileReadTool Infinity 设计理由、per-message 预算的持久化实现细节(包括 ContentReplacementState 决策持久性和 Infinity 豁免机制)详见第4章。
大小预算参数汇总
| 常量 | 值 | 定义位置 |
|---|---|---|
DEFAULT_MAX_RESULT_SIZE_CHARS | 50,000 字符 | constants/toolLimits.ts:13 |
MAX_TOOL_RESULT_TOKENS | 100,000 token | constants/toolLimits.ts:22 |
MAX_TOOL_RESULT_BYTES | 400,000 字节 | constants/toolLimits.ts:33(= 100K token x 4 bytes/token) |
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS | 200,000 字符 | constants/toolLimits.ts:49 |
TOOL_SUMMARY_MAX_LENGTH | 50 字符 | constants/toolLimits.ts:57 |
2.5 三阶段渲染流程
工具在终端 UI 中的呈现不是一次性的,而是一个三阶段渐进过程。这三个阶段与工具执行的生命周期一一对应。
流程图
flowchart TD
A["模型发出 tool_use 块<br />(参数可能尚未流式完毕)"] --> B
B["阶段 1: renderToolUseMessage<br />工具被调用,显示名称和参数<br />参数是 Partial<Input>(流式传输中)"]
B -->|工具开始执行| C
C["阶段 2: renderToolUseProgressMessage<br />执行中,展示进度<br />通过 onProgress 回调更新"]
C -->|工具执行完成| D
D["阶段 3: renderToolResultMessage<br />完成,展示结果"]
阶段 1:renderToolUseMessage — 意图展示
当模型输出一个 tool_use 块时,这个方法立即被调用。注意其签名中的关键类型:
renderToolUseMessage(
input: Partial<z.infer<Input>>, // 注意是 Partial!
options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
): React.ReactNode
input 是 Partial 的原因是:API 以流式方式返回工具参数的 JSON,在 JSON 解析完成之前只有部分字段可用。UI 必须在参数不完整时就能渲染——用户不应该看到空白屏幕。
以 BashTool 为例,即使 command 字段尚未完全接收,UI 已经可以显示 “Bash” 标签和已接收的部分命令文本。
阶段 2:renderToolUseProgressMessage — 过程可见性
这是一个可选方法。对于执行时间较长的工具(如 BashTool、AgentTool),进度反馈至关重要。BashTool 在 shell 命令执行超过 2 秒后开始显示进度(PROGRESS_THRESHOLD_MS = 2000,restored-src/src/tools/BashTool/BashTool.tsx:55)。
进度通过 onProgress 回调传递。每个工具的进度数据结构不同——BashTool 的 BashProgress 包含 stdout/stderr 片段,AgentTool 的 AgentToolProgress 包含子 Agent 的消息流。这些类型在 restored-src/src/types/tools.ts 中统一定义,通过 ToolProgressData 联合类型约束。
阶段 3:renderToolResultMessage — 结果呈现
这也是一个可选方法——省略时工具结果不在终端渲染(例如 TodoWriteTool 的结果通过专用面板展示,不出现在对话流中)。
renderToolResultMessage 接收 style?: 'condensed' 选项。在非 verbose 模式下,搜索类工具(GrepTool、GlobTool)显示精简摘要(如 “Found 42 files across 3 directories”),而在 verbose 模式下展示完整结果。工具可以通过 isResultTruncated(output) 方法告诉 UI 当前结果是否被截断,从而在全屏模式下启用“点击展开“交互。
分组渲染:renderGroupedToolUse
当模型在一个回合中并行调用多个同类型工具时(例如 5 个 Grep 搜索),逐个渲染会占据大量屏幕空间。renderGroupedToolUse 方法允许工具将多个并行调用合并为一个紧凑的分组视图——例如 “Searched 5 patterns, found 127 results across 34 files”。
这个方法只在非 verbose 模式下生效。verbose 模式下每个工具调用仍然在其原始位置独立渲染,确保调试时信息不丢失。
2.6 从具体工具看设计模式
BashTool:最复杂的工具
BashTool(restored-src/src/tools/BashTool/BashTool.tsx)是整个工具系统中最复杂的单一工具,因为 shell 命令的语义空间是无限的。它需要:
- 解析命令结构来判断是否只读(通过
checkReadOnlyConstraints和parseForSecurity) - 感知管道和复合命令(
ls && echo "---" && ls仍然是只读的) - 条件性并发:只有只读命令才能并发执行
- 进度追踪:超过 2 秒的命令显示 stdout 流式输出
- 文件变更追踪:通过
fileHistoryTrackEdit和trackGitOperations记录 shell 命令导致的文件修改 - 沙箱执行:在某些条件下通过
SandboxManager隔离执行
BashTool 的 maxResultSizeChars 设为 30,000——比 GrepTool 的 20,000 宽松,因为 shell 输出通常包含更多结构化信息(编译错误、测试结果等),模型需要看到足够的上下文才能做出正确判断。
GrepTool:并发安全的典范
GrepTool 的设计相对简洁。它无条件声明 isConcurrencySafe: true 和 isReadOnly: true,因为搜索操作永远不会修改文件系统。它的 maxResultSizeChars 设为 20,000——搜索结果超过这个长度说明模型的搜索范围太宽,持久化到磁盘并返回预览反而有助于模型调整策略。
FileReadTool:Infinity 的哲学
FileReadTool 将 maxResultSizeChars 设为 Infinity,选择通过自身的 maxTokens 和 maxSizeBytes 限制来控制输出大小。这避免了前文提到的循环读取问题,也意味着 FileReadTool 的结果永远不会被替换为磁盘引用——模型总是能直接看到文件内容。
2.7 延迟加载与 ToolSearch
当工具数量超过一定阈值时(尤其是 MCP 工具大量接入后),将所有工具的完整 schema 发送给模型会消耗大量 token。Claude Code 通过延迟加载(Deferred Loading)机制解决这个问题。
标记了 shouldDefer: true 的工具在初始提示中只发送工具名称(defer_loading: true),不发送完整的参数 schema。模型需要先调用 ToolSearchTool 按关键词搜索并获取工具的完整定义后,才能调用这些延迟加载的工具。
每个工具的 searchHint 字段就是为此设计的——它提供 3-10 个词的能力描述,帮助 ToolSearchTool 进行关键词匹配。例如 GrepTool 的 searchHint 是 'search file contents with regex (ripgrep)'。
标记了 alwaysLoad: true 的工具则永远不会被延迟——它们的完整 schema 总是出现在初始提示中。这适用于模型在第一轮对话就必须能直接调用的核心工具。
2.8 模式提炼
从 Claude Code 的工具系统设计中,可以提炼出几个对 AI Agent 构建者普遍有价值的模式:
模式 1:失败关闭的默认值。 buildTool() 的默认值假设最危险的情况(不可并发、非只读),工具开发者必须主动声明安全属性。这将安全从“选择加入“翻转为“选择退出“,大幅降低了遗漏导致的风险。
模式 2:分层预算控制。 单工具结果有上限,单消息也有聚合上限。两层控制互相补充——单工具上限防止单点失控,消息上限防止并行调用的集体爆炸。
模式 3:输入感知的属性。 isConcurrencySafe(input) 和 isReadOnly(input) 接收工具输入,而非全局判断。同一个 BashTool,ls 和 rm 有完全不同的安全属性。这种细粒度的输入感知是实现精确权限控制的基础。详见第4章。
模式 4:渐进渲染。 三阶段渲染(意图 → 进度 → 结果)让用户在工具执行的每个阶段都有可见性。Partial<Input> 的设计确保即使在参数流式传输期间,UI 也不会空白。这对用户信任至关重要——用户需要知道 Agent 正在做什么,而不是盯着一个旋转的加载图标。
模式 5:编译期消除 vs 运行时过滤。 Feature Flag 通过 bun:bundle 的 feature() 在编译期消除未启用的工具代码,而权限规则在运行时过滤工具列表。两种机制服务不同目的:前者减小 bundle 体积和攻击面,后者支持用户级配置。
用户能做什么
基于 Claude Code 工具系统的设计经验,以下是构建自己的 AI Agent 工具系统时可以采取的行动:
- 采用“失败关闭“默认值。 在你的工具注册框架中,将
isConcurrencySafe、isReadOnly等安全属性的默认值设为最保守的选项。让工具开发者主动声明安全属性,而非默认假设安全。 - 为每个工具设置结果大小上限。 不要让工具返回无限大的结果。设置单工具上限(如
maxResultSizeChars)和单消息聚合上限,超出时持久化到磁盘并返回预览。 - 让工具描述成为函数而非静态字符串。 如果你的工具在不同权限模式或上下文下有不同的行为限制,动态生成描述可以在提示层面引导模型避免无效调用。
- 实现三阶段渲染。 为长时间运行的工具提供进度反馈(意图展示 → 执行进度 → 最终结果),让用户始终知道 Agent 在做什么。支持
Partial<Input>在参数流式传输期间也能渲染。 - 使用条件加载减少工具集。 通过 Feature Flag 或环境变量在编译期/启动期过滤不需要的工具,减少 token 消耗和模型决策负担。对于大量 MCP 工具场景,考虑延迟加载(deferred loading)机制。
- 工具排序要稳定。 如果你使用 API 提示缓存,确保工具列表的顺序在请求之间保持稳定。将内置工具作为连续前缀,MCP 工具按名称排序追加,避免缓存键频繁失效。
小结
Claude Code 的工具系统是一个精心分层的架构:Tool 接口定义契约,buildTool() 提供安全默认值,tools.ts 的注册管线通过编译期和运行时两级过滤组装工具池,大小预算机制在单工具和单消息两个层面控制上下文消耗,三阶段渲染让工具执行过程对用户完全透明。
这套系统的设计哲学可以用一句话总结:让正确的事情容易,让危险的事情困难。 buildTool() 的失败关闭默认值让“忘记声明安全属性“成为一个安全的错误;分层预算让“工具返回过多数据“成为一个可控的降级;条件加载让“添加实验性工具“成为一个零风险的操作。
工具的调用和编排——包括权限检查的完整流程、并发执行的调度策略、流式进度的传播机制——将在第4章详细展开。
第3章:Agent Loop — 从用户输入到模型响应的完整生命周期
“A loop is not a loop when every iteration reshapes the world it runs in.”
本章是全书的锚点。从第5章的 API 调用构建到第9章的自动压缩策略,从第13章的流式响应处理到第16章的权限检查体系——几乎所有后续章节讨论的子系统,最终都在 queryLoop() 这个核心循环中被编排、协调、驱动。理解这个循环,就是理解 Claude Code 作为 AI Agent 的运转心脏。
3.1 为什么 Agent Loop 不是简单的 REPL
传统的 REPL(Read-Eval-Print Loop)是一个无状态的三步循环:读取输入、求值、打印结果。每次迭代之间没有上下文传递,没有自动恢复,没有对自身状态的感知。
Agent Loop 根本不同。看这张对比表:
| 维度 | 传统 REPL | Claude Code Agent Loop |
|---|---|---|
| 状态模型 | 无状态或仅保留历史 | 10 个可变字段的 State 类型,跨迭代传递 |
| 循环退出 | 用户显式退出 | 7 种 Continue 转换 + 10 种 Terminal 终止原因 |
| 错误处理 | 打印错误并继续 | 自动降级、模型切换、reactive compact、重试上限 |
| 上下文管理 | 无 | snip → microcompact → context collapse → autocompact 四级管线 |
| 工具执行 | 无 | 流式并行执行、权限检查、结果预算裁剪 |
| 对话容量 | 无限增长直到 OOM | token 预算追踪、自动压缩、blocking limit 硬限制 |
Agent Loop 的每一次迭代都可能改变自身的运行条件:压缩会缩减消息数组,模型降级会切换推理后端,stop hook 会注入新的约束消息。这不是循环——这是一个自修改状态机(self-modifying state machine)。
3.2 queryLoop 状态机总览
3.2.1 入口:query() 与 queryLoop()
入口函数 query() 是一个薄包装器。它调用 queryLoop() 获得结果,然后通知所有已消费的命令完成生命周期:
restored-src/src/query.ts:219-238
export async function* query(params: QueryParams): AsyncGenerator<...> {
const consumedCommandUuids: string[] = []
const terminal = yield* queryLoop(params, consumedCommandUuids)
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed')
}
return terminal
}
真正的状态机在 queryLoop() 中(restored-src/src/query.ts:241)。它是一个 while (true) 循环,每次迭代通过 state = next; continue 进入下一轮,或通过 return { reason: '...' } 终止。
3.2.2 State 类型:跨迭代的可变状态
State 类型定义了循环在迭代之间需要携带的所有可变状态(restored-src/src/query.ts:204-217):
| 字段 | 类型 | 语义 |
|---|---|---|
messages | Message[] | 当前对话消息数组,每轮迭代后追加 assistant 响应和 tool results |
toolUseContext | ToolUseContext | 工具执行上下文,包含可用工具列表、权限模式、abort 信号等 |
autoCompactTracking | AutoCompactTrackingState | undefined | 自动压缩的追踪状态,记录是否已触发过压缩及连续失败次数 |
maxOutputTokensRecoveryCount | number | 当前已尝试的 max_output_tokens 恢复次数,上限为 3 |
hasAttemptedReactiveCompact | boolean | 是否已尝试过 reactive compact,防止重试死循环 |
maxOutputTokensOverride | number | undefined | 覆盖默认 max_output_tokens 的值,用于升级重试(如 8k → 64k) |
pendingToolUseSummary | Promise<...> | undefined | 上一轮工具执行的摘要生成 Promise,在下一轮模型流式传输期间并行等待 |
stopHookActive | boolean | undefined | 标记 stop hook 是否处于活跃状态,避免重复触发 |
turnCount | number | 当前轮次计数,用于 maxTurns 限制检查 |
transition | Continue | undefined | 上一次迭代为何继续——让测试和调试能够断言恢复路径确实触发了 |
注意设计上的一个关键决策:源码注释明确说明“Continue sites write state = { ... } instead of 9 separate assignments“(restored-src/src/query.ts:267)。这意味着每个继续点都必须显式构造完整的 State 对象。这种写法消除了“忘记重置某个字段“的 bug 类——在一个有 7 个继续点的循环中,这不是理论风险,而是必然会发生的事故。
3.2.3 Continue 转换类型
循环内部有 7 个 continue 站点,每个都记录了转换原因。从源码中提取的完整枚举:
Continue.reason | 触发条件 | 典型行为 |
|---|---|---|
next_turn | 模型返回了 tool_use block | 追加 assistant + tool_result,递增 turnCount,开始下一轮 |
max_output_tokens_escalate | 模型输出被截断,且尚未升级过 | 将 maxOutputTokensOverride 设为 64k,原样重试同一请求 |
max_output_tokens_recovery | 输出截断且升级已用完,恢复次数 < 3 | 注入 meta 消息要求模型继续,递增恢复计数 |
reactive_compact_retry | prompt-too-long 或 media-size 错误 | 触发 reactive compact 压缩后重试 |
collapse_drain_retry | prompt-too-long 且有待提交的 context collapse | 执行所有暂存的 collapse,然后重试 |
stop_hook_blocking | stop hook 返回了阻塞错误 | 将阻塞错误注入消息流,让模型修正 |
token_budget_continuation | token budget 尚未耗尽 | 注入 nudge 消息鼓励模型继续工作 |
3.2.4 Terminal 终止原因
循环通过 return 终止,返回值包含 reason 字段。从源码提取的完整枚举:
Terminal.reason | 语义 |
|---|---|
completed | 模型正常完成(无 tool_use),或 API 错误但恢复已耗尽 |
blocking_limit | token 数触达硬限制,无法继续 |
prompt_too_long | prompt-too-long 错误且所有恢复手段(collapse drain + reactive compact)均失败 |
image_error | 图片尺寸/格式错误 |
model_error | 模型调用抛出非预期异常 |
aborted_streaming | 用户在流式响应期间中断 |
aborted_tools | 用户在工具执行期间中断 |
stop_hook_prevented | stop hook 阻止了继续 |
hook_stopped | 工具执行时 hook 阻止了后续操作 |
max_turns | 达到最大轮次限制 |
下面的流程图展示了状态机的完整拓扑:
flowchart TD
Entry["queryLoop() Entry<br />初始化 State, budgetTracker, config"] --> Loop
subgraph Loop["while (true)"]
direction TB
Start["解构 state<br />yield stream_request_start"] --> Phase1
Phase1["阶段 1: 上下文预处理<br />applyToolResultBudget → snipCompact<br />→ microcompact → contextCollapse<br />→ autocompact"] --> Phase2
Phase2{"阶段 2: Blocking limit<br />token 数 > 硬限制?"}
Phase2 -->|YES| T_Blocking["return blocking_limit"]
Phase2 -->|NO| Phase3
Phase3["阶段 3: API 调用<br />callModel + attemptWithFallback<br />流式响应 → assistantMessages + toolUseBlocks"] --> Phase4
Phase4{"阶段 4: 中断检查<br />aborted?"}
Phase4 -->|YES| T_Aborted["return aborted_*"]
Phase4 -->|NO| Branch
Branch{"needsFollowUp?"}
Branch -->|"false(无 tool_use)"| Phase5
Branch -->|"true(有 tool_use)"| Phase6
Phase5["阶段 5: 恢复与终止判定<br />prompt-too-long → collapse drain / reactive compact<br />max_output_tokens → escalate / recovery x3<br />stop hooks → blocking errors 注入<br />token budget → nudge 继续"]
Phase5 -->|恢复成功| Continue1["state = next; continue"]
Phase5 -->|全部耗尽| T_Completed["return completed"]
Phase6["阶段 6: 工具执行<br />StreamingToolExecutor / runTools"] --> Phase7
Phase7["阶段 7: 附件注入<br />memory prefetch / skill discovery / commands"] --> Phase8
Phase8{"阶段 8: 继续判定<br />maxTurns?"}
Phase8 -->|未达上限| Continue2["state = next_turn; continue"]
Phase8 -->|达到上限| T_MaxTurns["return max_turns"]
Continue1 --> Start
Continue2 --> Start
以下是原始 ASCII 版本,供需要纯文本阅读环境的读者参考:
ASCII 流程图(点击展开)
┌──────────────────────────────────────────────────────────────────────┐
│ queryLoop() Entry │
│ 初始化 State, budgetTracker, config, pendingMemoryPrefetch │
└──────────────┬───────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ while (true) { │
│ 解构 state → messages, toolUseContext, ... │
│ yield { type: 'stream_request_start' } │
├──────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 1: 上下文预处理 │ │
│ │ applyToolResultBudget │ │
│ │ → snipCompact (HISTORY_SNIP) │ │
│ │ → microcompact │ │
│ │ → contextCollapse (CONTEXT_COLLAPSE) │ │
│ │ → autocompact ───── 详见第9章 ────────── │ │
│ └──────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 2: Blocking limit 检查 │ │
│ │ token 数 > 硬限制 ? │ │
│ │ YES → return {reason:'blocking_limit'} │ │
│ └──────────────┬──────────────────────────┘ │
│ │ NO │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 3: API 调用 ── 详见第5章和第13章 ── │ │
│ │ attemptWithFallback 循环 │ │
│ │ callModel({ │ │
│ │ messages: prependUserContext(...) │ │
│ │ systemPrompt: appendSystemContext(...) │ │
│ │ }) │ │
│ │ │ │
│ │ 流式响应 → assistantMessages[] │ │
│ │ → toolUseBlocks[] │ │
│ │ FallbackTriggeredError → 切换模型重试 │ │
│ └──────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 4: 中断检查 │ │
│ │ abortController.signal.aborted ? │ │
│ │ YES → return {reason:'aborted_*'} │ │
│ └──────────────┬──────────────────────────┘ │
│ │ NO │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 5: needsFollowUp == false 分支 │ │
│ │ (模型未返回 tool_use) │ │
│ │ │ │
│ │ ┌─ prompt-too-long 恢复 ──────────────┐ │ │
│ │ │ collapse drain → reactive compact │ │ │
│ │ │ 成功 → state=next; continue │ │ │
│ │ └────────────────────────────────────-┘ │ │
│ │ ┌─ max_output_tokens 恢复 ────────────┐ │ │
│ │ │ escalate(8k→64k) → recovery(×3) │ │ │
│ │ │ 成功 → state=next; continue │ │ │
│ │ └────────────────────────────────────-┘ │ │
│ │ ┌─ stop hooks ── 详见第16章 ──────────┐ │ │
│ │ │ blockingErrors → state=next;continue│ │ │
│ │ └────────────────────────────────────-┘ │ │
│ │ ┌─ token budget check ────────────────┐ │ │
│ │ │ budget未尽 → state=next; continue │ │ │
│ │ └────────────────────────────────────-┘ │ │
│ │ │ │
│ │ return { reason: 'completed' } │ │
│ └──────────────────────────────────────-──┘ │
│ │ │
│ needsFollowUp == true │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 6: 工具执行 │ │
│ │ streamingToolExecutor.getRemainingResults│ │
│ │ 或 runTools() ── 详见第4章(工具执行编排) ─ │ │
│ │ → toolResults[] │ │
│ └──────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 7: 附件注入 │ │
│ │ getAttachmentMessages() │ │
│ │ pendingMemoryPrefetch consume │ │
│ │ skillDiscoveryPrefetch consume │ │
│ │ queuedCommands drain │ │
│ └──────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 阶段 8: 继续判定 │ │
│ │ maxTurns check │ │
│ │ state = { reason: 'next_turn', ... } │ │
│ │ continue │ │
│ └─────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────┘
3.3 单次迭代的完整流程
让我们跟踪一次迭代的每个阶段,从头到尾。
3.3.1 上下文预处理管线
每次迭代开始时,原始 messages 数组要经过四到五级处理才能送往 API。这些阶段按严格顺序执行,且顺序不可互换。
第一级:工具结果预算裁剪(Tool Result Budget)
restored-src/src/query.ts:379-394
applyToolResultBudget() 对聚合工具结果施加大小限制。它在所有压缩阶段之前运行,因为后续的 cached microcompact 仅通过 tool_use_id 操作,不检查内容——先裁剪内容不会干扰它。
第二级:History Snip
restored-src/src/query.ts:401-410
snipCompactIfNeeded() 是一种轻量级压缩:它截断(snip)历史中的旧消息,释放 token 空间。关键的是,它返回 tokensFreed 值——这个值会被传递给 autocompact,让后者的阈值判断能感知 snip 已经释放的空间。
第三级:Microcompact
restored-src/src/query.ts:414-426
Microcompact 是一种细粒度压缩,在 autocompact 之前运行。它还支持一种“缓存编辑“模式(CACHED_MICROCOMPACT),利用 API 的 cache 删除机制实现零额外 API 调用的压缩。
第四级:Context Collapse
restored-src/src/query.ts:440-447
Context Collapse(上下文折叠)是一种读时投影(read-time projection)机制。源码注释揭示了一个精妙的设计:
“Nothing is yielded — the collapsed view is a read-time projection over the REPL’s full history. Summary messages live in the collapse store, not the REPL array.”(
restored-src/src/query.ts:434-436)
这意味着折叠不改变原始消息数组,而是在每次迭代时重新投影。折叠结果通过 state.messages 在继续点传递,下一次 projectView() 因为归档消息已经不在输入中而成为空操作。
第五级:Autocompact(详见第9章)
restored-src/src/query.ts:454-468
自动压缩是最重量级的预处理步骤。它在 context collapse 之后运行——如果折叠已经把 token 数降到阈值以下,autocompact 就成为空操作,保留了更细粒度的上下文而不是生成单一摘要。
这五级管线的设计遵循一个原则:从轻到重、从局部到全局。每一级都试图在不丢失太多信息的前提下释放空间,只有前面的级别不够时,后面的级别才会启动。
3.3.2 上下文注入:prependUserContext 与 appendSystemContext
消息预处理完成后,上下文通过两个函数注入到 API 请求中:
appendSystemContext(restored-src/src/utils/api.ts:437-447):
// export function appendSystemContext(
systemPrompt: SystemPrompt,
context: { [k: string]: string },
): string[] {
return [
...systemPrompt,
Object.entries(context)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
].filter(Boolean)
}
系统上下文被追加到系统提示词(system prompt)的末尾。这些内容(如当前日期、工作目录等)享受系统提示词的特殊缓存位置——API 的 prompt caching 对系统提示词最为友好。
prependUserContext(restored-src/src/utils/api.ts:449-474):
// export function prependUserContext(
messages: Message[],
context: { [k: string]: string },
): Message[] {
// ...
return [
createUserMessage({
content: `<system-reminder>\n...\n</system-reminder>\n`,
isMeta: true,
}),
...messages,
]
}
用户上下文被包裹在 <system-reminder> 标签中,作为第一条用户消息前置到消息数组。这个位置选择不是随意的——它确保上下文出现在所有对话之前,且标记为 isMeta: true(不显示在用户 UI 中)。附带一句重要的提示文本:“this context may or may not be relevant to your tasks”——这给了模型忽略不相关上下文的自由度。
注意调用时序(restored-src/src/query.ts:660):
messages: prependUserContext(messagesForQuery, userContext),
systemPrompt: fullSystemPrompt, // 已经 appendSystemContext 过
prependUserContext 在 API 调用时才执行,不在预处理管线中。这意味着用户上下文不参与 token 计数和压缩决策——它是“透明的“注入。
3.3.3 消息标准化管线
在 API 调用的构建阶段(restored-src/src/services/api/claude.ts:1259-1314),消息经过一条四步标准化管线。这条管线的职责是将 Claude Code 内部的丰富消息类型转换为 Anthropic API 能接受的严格格式。
第一步:normalizeMessagesForAPI()(restored-src/src/utils/messages.ts:1989)
这是最复杂的标准化步骤。它完成以下工作:
- 附件重排序:通过
reorderAttachmentsForAPI()将附件消息上移,直到碰到 tool_result 或 assistant 消息 - 虚拟消息过滤:移除
isVirtual标记的显示专用消息(如 REPL 内部工具调用) - 系统/进度消息剥离:过滤
progress类型和非local_command的system消息 - 合成错误消息处理:检测 PDF/图片/请求过大的错误,向后查找并从源用户消息中剥离对应的媒体块
- 工具输入标准化:通过
normalizeToolInputForAPI处理工具输入格式 - 消息合并:相邻的同角色消息被合并(API 要求严格的 user/assistant 交替)
第二步:ensureToolResultPairing()(restored-src/src/utils/messages.ts:5133)
修复 tool_use / tool_result 的配对不匹配。这种不匹配在恢复远程会话(remote/teleport sessions)时特别常见。它为孤立的 tool_use 插入合成错误 tool_result,并剥离引用不存在 tool_use 的孤立 tool_result。
第三步:stripAdvisorBlocks()(restored-src/src/utils/messages.ts:5466)
剥离 advisor blocks。这些 block 需要特定的 beta header 才能被 API 接受(restored-src/src/services/api/claude.ts:1304):
if (!betas.includes(ADVISOR_BETA_HEADER)) {
messagesForAPI = stripAdvisorBlocks(messagesForAPI)
}
第四步:stripExcessMediaItems()(restored-src/src/services/api/claude.ts:956)
API 限制每个请求最多 100 个媒体项(图片 + 文档)。这个函数静默地从最旧的消息开始移除多余的媒体项,而不是报错——这在 Cowork/CCD 等场景中很重要,因为硬报错难以恢复。
整条管线的执行顺序不是随意的。源码注释解释了为什么标准化要在 ensureToolResultPairing 之前(restored-src/src/services/api/claude.ts:1272-1276):
“normalizeMessagesForAPI uses isToolSearchEnabledNoModelCheck() because it’s called from ~20 places (analytics, feedback, sharing, etc.), many of which don’t have model context.”
这揭示了一个架构事实:normalizeMessagesForAPI 是一个被广泛复用的函数,它的接口不能随意添加参数。模型特定的后处理(如 tool search 字段剥离)必须作为独立步骤在它之后运行。
3.3.4 API 调用阶段(详见第5章和第13章)
API 调用被包裹在一个 attemptWithFallback 循环中(restored-src/src/query.ts:650-953):
let attemptWithFallback = true
while (attemptWithFallback) {
attemptWithFallback = false
try {
for await (const message of deps.callModel({
messages: prependUserContext(messagesForQuery, userContext),
systemPrompt: fullSystemPrompt,
// ...
})) {
// 处理流式响应消息
}
} catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel
attemptWithFallback = true
// 清理孤立消息, 重置 executor
continue
}
throw innerError
}
}
这里有几个精妙的设计值得注意:
消息不可变性。流式消息在 yield 前被克隆:原始 message 推入 assistantMessages 数组(回传给 API),而克隆版本(带 backfilled observable input)被 yield 给 SDK 调用者。源码注释(restored-src/src/query.ts:744-746)直接说明了原因:“mutating it would break prompt caching (byte mismatch)”。
错误扣留(Withholding)机制。可恢复的错误(prompt-too-long、max-output-tokens、media-size)在流式阶段被扣留——不立即 yield 给调用者。只有在后续的恢复逻辑确认无法恢复时,才会释放给调用者。这防止了 SDK 消费者(如 Desktop/Cowork)过早终止会话。
Tombstone 处理。当流式降级(streaming fallback)发生时,已经 yield 的部分消息会被作为 tombstone 通知删除(restored-src/src/query.ts:716-718)。这解决了一个微妙的问题:部分消息(尤其是 thinking blocks)携带的签名在降级后会导致 API 报 “thinking blocks cannot be modified” 错误。
3.3.5 工具执行阶段(详见第4章)
模型响应完成后,如果存在 tool_use blocks,循环进入工具执行阶段(restored-src/src/query.ts:1363-1408)。
Claude Code 支持两种工具执行模式:
- 流式并行执行(
StreamingToolExecutor):工具在模型流式响应过程中就开始执行。在 API 调用阶段,每个tool_useblock 到达时就被addTool()推入执行器(restored-src/src/query.ts:841-843)。流式结束后,getRemainingResults()收集所有已完成和未完成的结果。 - 批量执行(
runTools()):所有 tool_use blocks 收集完毕后一次性执行。
工具执行的结果会经过 normalizeMessagesForAPI 标准化后追加到 toolResults 数组。
3.3.6 Stop Hooks 与继续判定
当模型响应不包含 tool_use(needsFollowUp == false)时,循环进入终止判定路径。这条路径包含多层恢复逻辑和 hook 检查。
Stop Hooks(restored-src/src/query.ts:1267-1306):
const stopHookResult = yield* handleStopHooks(
messagesForQuery, assistantMessages,
systemPrompt, userContext, systemContext,
toolUseContext, querySource, stopHookActive,
)
如果 stop hook 返回 blockingErrors,循环注入这些错误消息并继续(transition: { reason: 'stop_hook_blocking' }),让模型有机会修正。这是 Claude Code 权限体系的关键执行点——详见第16章。
Token Budget Check(restored-src/src/query.ts:1308-1355):
当 TOKEN_BUDGET 特性开启时,循环检查当前轮次的 token 消耗是否在预算内。如果模型“提前完成“但预算还有剩余,循环注入 nudge 消息(transition: { reason: 'token_budget_continuation' })鼓励模型继续工作。这个机制还支持“递减回报“(diminishing returns)检测——如果模型的增量输出不再有实质贡献,即使预算未耗尽也会提前停止。
3.3.7 附件注入与轮次准备
工具执行完成后,循环在进入下一轮之前注入附件(restored-src/src/query.ts:1580-1628):
- 队列命令处理:从全局命令队列中拉取当前 agent 地址的命令(区分主线程和子 agent),转换为附件消息
- Memory prefetch 消费:如果内存预取(在循环入口启动的
startRelevantMemoryPrefetch)已完成且本轮尚未消费,将结果注入 - Skill discovery 消费:如果技能发现预取已完成,将结果注入
这些注入利用了模型流式响应和工具执行的延迟——它们在后台并行运行,到这里时通常已经完成。
3.4 中止/重试/降级
3.4.1 FallbackTriggeredError 与模型切换
当 API 调用因高负载等原因失败时,FallbackTriggeredError 被抛出(restored-src/src/query.ts:894-950)。处理流程:
- 切换
currentModel为fallbackModel - 清空
assistantMessages、toolResults、toolUseBlocks - 丢弃并重建
StreamingToolExecutor(防止孤立 tool_result 泄漏) - 更新
toolUseContext.options.mainLoopModel - 剥离 thinking signature blocks(因为它们是模型绑定的,在降级模型上会 400)
- yield 系统消息通知用户
关键的是,这个降级发生在 attemptWithFallback 循环内部。它设置 attemptWithFallback = true 并 continue,在同一轮迭代中立即重试——不需要重新进入外部的 while (true) 循环。
3.4.2 max_output_tokens 恢复:三次机会
当模型输出被截断时,恢复策略分两层:
第一层:Escalation(升级)。如果当前使用的是默认的 8k 上限且尚未覆盖过,直接将 maxOutputTokensOverride 设为 64k(ESCALATED_MAX_TOKENS),原样重试同一请求。这是“免费“的恢复——不需要多轮对话。
第二层:Multi-turn recovery(多轮恢复)。如果升级后仍然截断,注入一条元消息:
"Output token limit hit. Resume directly — no apology, no recap of what you were doing.
Pick up mid-thought if that is where the cut happened.
Break remaining work into smaller pieces."
这条消息精心措辞:禁止道歉(浪费 token)、禁止回顾(重复信息)、要求拆分工作(降低单次输出需求)。最多重试 3 次(MAX_OUTPUT_TOKENS_RECOVERY_LIMIT,restored-src/src/query.ts:164)。
3.4.3 Reactive Compact:prompt-too-long 的最后防线
当 API 返回 prompt-too-long 错误时,恢复策略也分两层:
- Context Collapse Drain:首先尝试提交所有暂存的 context collapse。这是廉价操作,保留了细粒度上下文
- Reactive Compact:如果 drain 不够,执行完整的 reactive compact。标记
hasAttemptedReactiveCompact = true防止重试死循环
如果两者都失败,错误被释放给调用者,循环终止。源码注释特别强调了为什么不能在这里运行 stop hooks(restored-src/src/query.ts:1169-1172):
“Do NOT fall through to stop hooks: the model never produced a valid response, so hooks have nothing meaningful to evaluate. Running stop hooks on prompt-too-long creates a death spiral: error → hook blocking → retry → error → …”
3.5 单次迭代序列图
User queryLoop PreProcess API Tools StopHooks
│ │ │ │ │ │
│ messages │ │ │ │ │
│───────────────>│ │ │ │ │
│ │ │ │ │ │
│ │ applyToolResult │ │ │ │
│ │ Budget │ │ │ │
│ │─────────────────>│ │ │ │
│ │ │ │ │ │
│ │ snipCompact │ │ │ │
│ │─────────────────>│ │ │ │
│ │ │ │ │ │
│ │ microcompact │ │ │ │
│ │─────────────────>│ │ │ │
│ │ │ │ │ │
│ │ contextCollapse │ │ │ │
│ │─────────────────>│ │ │ │
│ │ │ │ │ │
│ │ autocompact │ │ │ │
│ │─────────────────>│ │ │ │
│ │ messagesForQuery│ │ │ │
│ │<─────────────────│ │ │ │
│ │ │ │ │ │
│ │ prependUserContext │ │ │
│ │ appendSystemContext │ │ │
│ │ │ │ │ │
│ │ callModel(...) │ │ │ │
│ │────────────────────────────────>│ │ │
│ │ │ │ │ │
│ │ stream messages │ │ │ │
│<───────────────│<────────────────────────────────│ │ │
│ (yield) │ │ │ │ │
│ │ │ │ tool_use? │ │
│ │ │ │ │ │
│ │──────── needsFollowUp ─────────────────────────>│ │
│ │ runTools / StreamingToolExecutor │ │
│<───────────────│<───────────────────────────────────────────────│ │
│ (yield results) │ │ │ │
│ │ │ │ │ │
│ │ attachments (memory, skills, commands) │ │
│ │ │ │ │ │
│ │ state = { reason: 'next_turn', ... } │ │
│ │ continue ──────────────────────────> 下一轮迭代 │
│ │ │ │ │ │
│ ──── OR ── needsFollowUp == false ────────────────────>│ │
│ │ │ │ │ │
│ │ handleStopHooks │ │ │ │
│ │────────────────────────────────────────────────────────────>│
│ │ blockingErrors? │ │ │ │
│ │<───────────────────────────────────────────────────────────│
│ │ │ │ │ │
│ │ return { reason: 'completed' }│ │ │
│<───────────────│ │ │ │ │
3.6 模式提炼
读完 queryLoop() 的 1730 行源码,几个深层模式浮现出来:
模式一:显式状态重建而非增量修改
每个 continue 站点都构造完整的新 State 对象。没有 state.maxOutputTokensRecoveryCount++,只有 state = { ..., maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1, ... }。这带来了三个好处:
- 遗忘免疫:不可能忘记重置某个字段
- 可审计性:每个继续点的完整意图在一个对象字面量中可见
- 可测试性:
transition字段让测试能断言恢复路径是否触发
模式二:扣留-释放(Withhold-Release)
可恢复错误不立即暴露给消费者。它们被扣留(pushed to assistantMessages but not yielded),只有在所有恢复手段耗尽后才被释放。这个模式解决了一个现实问题:SDK 消费者(Desktop、Cowork)会在看到错误时终止会话——如果恢复成功,过早暴露错误就是一次不必要的中断。
模式三:从轻到重的分层恢复
无论是上下文压缩(snip → microcompact → collapse → autocompact)还是错误恢复(escalate → multi-turn → reactive compact),策略总是从最轻量(信息损失最小)的手段开始,逐步升级到更重量级的手段。这不仅是性能优化,更是信息保留策略——每一级都在“用最少的代价换最大的空间“。
模式四:后台并行化的滑动窗口
内存预取在循环入口启动,工具摘要在工具执行后异步启动,技能发现在迭代开始时异步启动——它们都在模型流式响应的 5-30 秒窗口期内完成计算。这种“在等待中完成准备工作“的模式将延迟隐藏得几乎不可见。
模式五:死循环保护的单次尝试守卫
hasAttemptedReactiveCompact、maxOutputTokensRecoveryCount、state.transition?.reason !== 'collapse_drain_retry'——这些守卫确保每种恢复策略最多执行一次(或有限次)。在一个 while (true) 循环中,没有这些守卫就是在邀请无限循环。源码注释中反复出现的 “death spiral” 一词(restored-src/src/query.ts:1171、1295)表明这不是理论担忧——这些守卫是从实际生产事故中学来的。
用户能做什么
如果你正在构建自己的 AI Agent 系统,以下是从 queryLoop() 设计中可以直接借鉴的实践:
- 为每种恢复策略设置单次尝试守卫。 在
while (true)循环中,每种自动恢复(压缩、重试、降级)都必须有布尔标记或计数器防止无限循环。用hasAttempted*命名,让意图一目了然。 - 采用“从轻到重“的分层压缩策略。 不要在上下文超限时直接执行全量摘要。先尝试裁剪旧消息(snip)、再微压缩(microcompact)、再折叠(collapse)、最后才全量压缩(autocompact)。每一层都保留尽可能多的上下文信息。
- 用完整状态重建替代增量修改。 在循环的每个
continue站点构造完整的新状态对象,而非逐字段修改。这消除了“忘记重置字段“的 bug 类,尤其在有多个继续路径时。 - 扣留可恢复错误。 不要在第一时间将错误暴露给上层消费者。先尝试所有恢复手段,只有全部失败后才释放错误。这避免了上层因看到错误而过早终止会话。
- 利用模型响应的等待窗口做并行预取。 在发起 API 调用的同时启动内存预取、技能发现等异步任务。模型生成响应的 5-30 秒窗口是“免费“的计算时间。
- 记录转换原因(transition reason)。 在状态中记录每次循环继续的原因(如
next_turn、reactive_compact_retry),既方便调试,也让自动化测试能断言特定恢复路径是否被触发。
3.7 本章小结
queryLoop() 是 Claude Code 的心跳。它不是简单地在用户和模型之间传递消息,而是在每次迭代中主动管理上下文容量、编排工具执行、处理错误恢复、执行权限检查。理解了这个循环的拓扑结构和转换语义,后续章节中讨论的每一个子系统——autocompact(第9章)、API 调用构建(第5章)、流式响应处理(第13章)、权限检查(第16章)——都能在心智模型中找到它们被调用的精确位置和时机。
这个循环最深刻的设计特征是:它知道自己可能失败,并为此做好了准备。不是“如果一切顺利“的乐观路径,而是“当事情出错时如何优雅地恢复“的防御性设计。这正是将一个 demo 级别的 AI 聊天界面变成生产级 AI Agent 的关键工程决策。
第4章:工具执行编排 – 权限、并发、流式与中断
第3章剖析了 Agent Loop 的完整生命周期,当模型返回
tool_use类型的内容块时,循环进入“工具执行阶段“。本章将深入这个阶段的内部实现:工具调用如何被分区调度、单工具执行经历哪些生命周期步骤、权限决策链如何层层过滤、大结果如何被持久化,以及流式执行器如何处理并发与中断。
4.1 为什么工具执行编排至关重要
一次 Agent 循环迭代中,模型可能同时请求多个工具调用。例如,模型可能一次性发出三个 Read 调用来读取不同文件,然后紧跟一个 Bash 调用来运行测试。这些调用不能全部并行执行 – 读取操作是安全的,但一个 git checkout 可能改变工作目录状态,导致并行读取得到不一致的结果。
Claude Code 的工具编排层(tool orchestration)解决三个核心问题:
- 安全并发:只读工具可以并行执行以提高吞吐量,写入工具必须串行执行以保证一致性
- 权限门控:每个工具在执行前必须通过权限决策链,确保用户对危险操作保持控制
- 结果管理:工具输出可能极大(一个
cat命令可能返回数十万字符),需要智能裁剪以避免上下文窗口溢出
这三个问题的解决方案分布在三个核心文件中:toolOrchestration.ts(批次调度)、toolExecution.ts(单工具生命周期)、StreamingToolExecutor.ts(流式并发执行器)。
4.2 partitionToolCalls:工具调用分区
4.2.1 分区算法
当 Agent Loop 将一批 ToolUseBlock 交给编排层时,第一步是将它们分区为交替的“并发安全批次“和“串行批次“。这是 partitionToolCalls 函数的职责:
flowchart TD
Input["模型返回的工具调用序列(按顺序)<br />[Read A] [Read B] [Grep C] [Bash D] [Read E] [Edit F]"]
Input -->|partitionToolCalls| B1
B1["批次 1(并发安全)<br />Read A, Read B, Grep C<br />三个只读工具合并为一批"]
B1 --> B2["批次 2(串行)<br />Bash D<br />写入工具独占一批"]
B2 --> B3["批次 3(并发安全)<br />Read E<br />新的只读批次"]
B3 --> B4["批次 4(串行)<br />Edit F<br />写入工具独占一批"]
style B1 fill:#d4edda,stroke:#28a745
style B3 fill:#d4edda,stroke:#28a745
style B2 fill:#f8d7da,stroke:#dc3545
style B4 fill:#f8d7da,stroke:#dc3545
图 4-1:partitionToolCalls 分区逻辑图。 连续的并发安全工具被合并到同一批次(绿色),非并发安全工具各自独占一批(红色)。
分区逻辑的核心是一个 reduce 操作(restored-src/src/services/tools/toolOrchestration.ts:91-116):
// function partitionToolCalls(
toolUseMessages: ToolUseBlock[],
toolUseContext: ToolUseContext,
): Batch[] {
return toolUseMessages.reduce((acc: Batch[], toolUse) => {
const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
const parsedInput = tool?.inputSchema.safeParse(toolUse.input)
const isConcurrencySafe = parsedInput?.success
? (() => {
try {
return Boolean(tool?.isConcurrencySafe(parsedInput.data))
} catch {
return false // 保守策略:解析失败视为不安全
}
})()
: false
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1]!.blocks.push(toolUse) // 合并到上一个并发批次
} else {
acc.push({ isConcurrencySafe, blocks: [toolUse] }) // 新建批次
}
return acc
}, [])
}
关键设计决策:
- 先验证再分类:输入必须通过 Zod schema 验证后才会调用
isConcurrencySafe。如果模型生成了无效输入,该工具被保守地标记为非并发安全。 - 异常即不安全:如果
isConcurrencySafe本身抛出异常(比如shell-quote解析 Bash 命令失败),同样回退到串行执行。这是“失败即关闭“(fail-closed)的经典安全模式。 - 贪心合并:连续的并发安全工具被合并到同一批次,直到遇到一个非安全工具为止。这保持了调用的相对顺序,同时最大化并行度。
4.2.2 isConcurrencySafe 的判定逻辑
isConcurrencySafe 是 Tool 接口上的必需方法(restored-src/src/Tool.ts:402),默认实现返回 false(restored-src/src/Tool.ts:759)。各工具根据自身语义提供实现:
| 工具 | 并发安全? | 原因 |
|---|---|---|
| FileRead, Glob, Grep | 始终 true | 纯读取,无副作用 |
| BashTool | 取决于命令 | 委托给 isReadOnly(input),分析命令是否只读 |
| FileEdit, FileWrite | false | 修改文件系统 |
| AgentTool | false | 启动子 Agent,可能修改状态 |
以 BashTool 为例(restored-src/src/tools/BashTool/BashTool.tsx:434-436):
isConcurrencySafe(input) {
return this.isReadOnly?.(input) ?? false;
},
Bash 工具的并发安全性完全取决于命令内容:ls、cat、git log 是安全的,而 rm、git checkout、npm install 则不是。isReadOnly 会解析命令结构来做出判断。
4.3 runTools:批次调度引擎
runTools(restored-src/src/services/tools/toolOrchestration.ts:19-82)是编排层的入口。它遍历分区后的批次,对并发安全批次调用 runToolsConcurrently,对串行批次调用 runToolsSerially。
4.3.1 并发执行路径
并发路径使用 all() 工具函数(restored-src/src/utils/generators.ts:32)将多个异步生成器合并为一个,带有并发上限(concurrency cap):
async function* runToolsConcurrently(...) {
yield* all(
toolUseMessages.map(async function* (toolUse) {
yield* runToolUse(toolUse, ...)
markToolUseAsComplete(toolUseContext, toolUse.id)
}),
getMaxToolUseConcurrency(), // 默认 10,可通过环境变量覆盖
)
}
并发上限通过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 配置(restored-src/src/services/tools/toolOrchestration.ts:8-11),默认值为 10。
一个重要的细节是上下文修改器的延迟应用(context modifier deferred application)。并发执行的工具可能各自产生上下文修改(例如更新工具可用列表),但这些修改不能在并发执行期间立即应用 – 否则会引发竞态条件。因此,修改器被收集到队列中,在整个并发批次完成后按工具出现顺序依次应用(restored-src/src/services/tools/toolOrchestration.ts:31-63)。
4.3.2 串行执行路径
串行路径则直接按顺序执行每个工具,每次执行后立即应用上下文修改:
for (const toolUse of toolUseMessages) {
for await (const update of runToolUse(toolUse, ...)) {
if (update.contextModifier) {
currentContext = update.contextModifier.modifyContext(currentContext)
}
yield { message: update.message, newContext: currentContext }
}
}
这保证了写入工具能看到前一个工具修改后的上下文状态。
4.4 单工具执行生命周期
每个工具调用,无论通过并发路径还是串行路径,最终都进入 runToolUse(restored-src/src/services/tools/toolExecution.ts:337)和 checkPermissionsAndCallTool(restored-src/src/services/tools/toolExecution.ts:599)。这两个函数组成了单工具的完整生命周期。
┌─────────────────────────────────────────────────────────────────┐
│ 单工具执行生命周期 │
│ │
│ ① 工具查找 ──→ ② Schema 验证 ──→ ③ 输入验证 │
│ │ │ │ │
│ 找不到工具? 验证失败? 验证失败? │
│ ↓ 返回错误 ↓ 返回错误 ↓ 返回错误 │
│ │
│ ④ PreToolUse Hooks ──→ ⑤ 权限决策 ──→ ⑥ tool.call() │
│ │ │ │ │
│ Hook 阻止? 权限拒绝? 执行出错? │
│ ↓ 返回错误 ↓ 返回错误 ↓ 返回错误 │
│ │
│ ⑦ 结果映射 ──→ ⑧ 大结果持久化 ──→ ⑨ PostToolUse Hooks │
│ │ │
│ Hook 阻止继续? │
│ ↓ 停止后续循环 │
└─────────────────────────────────────────────────────────────────┘
图 4-2:单工具生命周期流程图。 每个阶段都可能产生错误消息终止流程,成功路径从左到右贯穿全部九个阶段。
4.4.1 阶段一:工具查找与输入验证
runToolUse 首先在可用工具集中查找目标工具(restored-src/src/services/tools/toolExecution.ts:345-356)。如果找不到,还会检查已弃用工具的别名(alias)– 这保证了旧版会话记录中的工具调用仍然可以执行。
输入验证分两步:
-
Schema 验证:使用 Zod 的
safeParse对模型输出的参数进行类型校验(restored-src/src/services/tools/toolExecution.ts:615-616)。模型生成的参数类型并不总是正确的 – 比如它可能把一个应为数组的参数输出为字符串。 -
语义验证:通过
tool.validateInput()进行工具特定的业务逻辑校验(restored-src/src/services/tools/toolExecution.ts:683-684)。例如,FileEdit 工具可能检查目标文件是否存在。
一个值得注意的细节:当工具是延迟工具(deferred tool)且其 Schema 未被发送给 API 时,系统会在 Zod 错误消息中附加提示,引导模型先通过 ToolSearch 加载工具 Schema 再重试(restored-src/src/services/tools/toolExecution.ts:578-597)。
4.4.2 阶段二:推测性分类器启动
在进入权限检查之前,如果当前工具是 Bash 工具,系统会推测性地启动允许分类器(speculative classifier check,restored-src/src/services/tools/toolExecution.ts:740-752)。这个分类器与 PreToolUse Hooks 并行运行,在用户需要做出权限决策时结果可能已经就绪。这是一个优化手段 – 避免用户等待分类器的延迟。
4.4.3 阶段三:PreToolUse Hooks
系统执行所有注册的 PreToolUse hooks(restored-src/src/services/tools/toolExecution.ts:800-862)。Hooks 可以产生以下效果:
- 修改输入:返回
updatedInput替换原始参数 - 做出权限决策:返回
allow、deny或ask来影响后续权限检查 - 阻止执行:设置
preventContinuation标志 - 添加上下文:注入额外信息供模型参考
如果 hook 执行期间发生中断(abort signal),系统立即终止并返回取消消息。
4.4.4 阶段四:权限决策链
权限系统是工具执行生命周期中最复杂的环节。决策链由 resolveHookPermissionDecision(restored-src/src/services/tools/toolHooks.ts:332-433)协调,遵循以下优先级:
┌──────────────────────────────────────────────────────────────────┐
│ 权限决策链 │
│ │
│ PreToolUse Hook 决策 │
│ ├─ allow ──→ 检查规则权限 (settings.json deny/ask 规则) │
│ │ ├─ 无匹配规则 ──→ 允许(跳过用户提示) │
│ │ ├─ deny 规则 ──→ 拒绝(规则覆盖 Hook) │
│ │ └─ ask 规则 ──→ 提示用户(规则覆盖 Hook) │
│ ├─ deny ──→ 直接拒绝 │
│ └─ ask ──→ 进入正常权限流程(带 Hook 的 forceDecision) │
│ │
│ 无 Hook 决策 ──→ 正常权限流程 │
│ ├─ 工具自身 checkPermissions │
│ ├─ 通用规则匹配 (settings.json) │
│ ├─ YOLO/Auto 分类器(详见第17章) │
│ └─ 用户交互提示(详见第16章) │
└──────────────────────────────────────────────────────────────────┘
图 4-3:权限决策链图。 Hook 的 allow 不能覆盖 settings.json 中的 deny 规则,这是纵深防御的体现。
决策链的一个关键不变量是:Hook 的 allow 决策不能绕过 settings.json 中的 deny/ask 规则。即使 hook 批准了一个操作,如果 settings.json 中存在明确的 deny 规则,该操作仍会被拒绝。这确保了用户配置的安全边界始终有效(restored-src/src/services/tools/toolHooks.ts:373-405)。
权限系统的完整架构详见第16章,YOLO 分类器的实现详见第17章。
4.4.5 阶段五:工具执行
权限通过后,系统调用 tool.call()(restored-src/src/services/tools/toolExecution.ts:1207-1222)。执行过程被包裹在 startSessionActivity('tool_exec') 和 stopSessionActivity('tool_exec') 之间,用于追踪活跃会话状态。
工具执行期间的进度事件通过 Stream 对象传递(restored-src/src/services/tools/toolExecution.ts:509)。streamedCheckPermissionsAndCallTool 将 checkPermissionsAndCallTool 的 Promise 结果和实时进度事件合并到同一个异步可迭代对象中,使得调用者可以同时接收进度更新和最终结果。
4.4.6 阶段六:PostToolUse Hooks 与结果处理
工具执行成功后,系统依次执行:
- 结果映射:通过
tool.mapToolResultToToolResultBlockParam()将工具输出转换为 API 格式(restored-src/src/services/tools/toolExecution.ts:1292-1293) - 大结果持久化:如果结果超过阈值,将其写入磁盘并用摘要替换(详见 4.6 节)
- PostToolUse Hooks:执行后置 hooks,可以修改 MCP 工具输出或阻止后续循环继续(
restored-src/src/services/tools/toolExecution.ts:1483-1531)
对于 MCP 工具,hooks 可以通过返回 updatedMCPToolOutput 来修改工具输出。这个修改在 addToolResult 调用之前生效,确保最终存入消息历史的是修改后的版本。非 MCP 工具的结果映射在 hooks 之前完成,因此 hooks 只能附加信息,不能修改结果本身。
如果工具执行失败,系统转而执行 PostToolUseFailure hooks(restored-src/src/services/tools/toolExecution.ts:1700-1713),允许 hooks 检查错误并注入额外上下文。
4.5 StreamingToolExecutor:流式并发执行器
前面描述的 runTools 是批量模式(batch mode)– 等待所有 tool_use 块到齐后才开始分区和执行。但在流式响应场景中,工具调用块一个接一个地从 API 流中解析出来。StreamingToolExecutor(restored-src/src/services/tools/StreamingToolExecutor.ts)实现了一种不同的策略:工具调用到达即开始执行,无需等待全部就绪。
4.5.1 状态机模型
StreamingToolExecutor 为每个工具维护一个四状态的生命周期:
queued ──→ executing ──→ completed ──→ yielded
- queued:工具已注册但尚未开始执行
- executing:工具正在运行中
- completed:工具已完成,结果已缓冲
- yielded:结果已被消费者获取
状态转换由 processQueue() 驱动(restored-src/src/services/tools/StreamingToolExecutor.ts:140-151)。每次有工具完成或新工具入队时,队列处理器被唤醒,尝试启动下一个可执行的工具。
4.5.2 并发控制
canExecuteTool 方法(restored-src/src/services/tools/StreamingToolExecutor.ts:129-135)实现了核心的并发策略:
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}
规则很简洁:
- 如果没有工具在执行,任何工具都可以启动
- 如果有工具在执行,新工具只有在自身和所有正在执行的工具都是并发安全的情况下才能启动
- 非并发安全工具需要独占执行(exclusive access)
4.5.3 Bash 错误级联中断
StreamingToolExecutor 实现了一个精妙的错误处理机制:当一个 Bash 工具出错时,所有同级并行的 Bash 工具会被取消(restored-src/src/services/tools/StreamingToolExecutor.ts:357-363)。
if (tool.block.name === BASH_TOOL_NAME) {
this.hasErrored = true
this.erroredToolDescription = this.getToolDescription(tool)
this.siblingAbortController.abort('sibling_error')
}
这个设计基于一个实际观察:Bash 命令之间通常存在隐式依赖链。如果 mkdir 失败了,后续的 cp 命令也注定失败 – 与其让它们各自报错,不如提前取消。但这个策略仅限于 Bash 工具 – Read、WebFetch 等工具是独立的,一个的失败不应影响其他工具。
错误级联使用一个 siblingAbortController 实现,它是 toolUseContext.abortController 的子控制器。中止兄弟控制器会取消正在运行的子进程,但不会中止父控制器 – 这意味着 Agent Loop 本身不会因为一个 Bash 错误而终止当前回合。
4.5.4 中断行为
每个工具可以声明自己的中断行为(interrupt behavior):'cancel' 或 'block'(restored-src/src/Tool.ts:416)。当用户发送中断信号时:
- cancel 工具:立即收到取消消息,结果被合成的 REJECT_MESSAGE 替代
- block 工具:继续运行到完成(不响应中断)
StreamingToolExecutor 通过 updateInterruptibleState() 追踪当前是否所有正在执行的工具都是可中断的(restored-src/src/services/tools/StreamingToolExecutor.ts:254-259)。这个信息被传递给 UI 层,决定是否显示“按 ESC 取消“的提示。
4.5.5 进度消息的即时传递
普通工具结果必须按序传递(保证顺序语义),但进度消息可以立即传递(restored-src/src/services/tools/StreamingToolExecutor.ts:417-420)。StreamingToolExecutor 将进度消息存储在独立的 pendingProgress 队列中,getCompletedResults() 在扫描工具列表时会优先 yield 进度消息,不受工具完成顺序的限制。
当没有已完成结果但有正在执行的工具时,getRemainingResults() 通过 Promise.race 等待任一工具完成或有新的进度消息到达(restored-src/src/services/tools/StreamingToolExecutor.ts:476-481),避免不必要的轮询。
4.6 工具结果管理:预算与持久化
4.6.1 大结果持久化
一个 Bash 工具的 cat 命令可能返回数十万字符。将如此巨大的结果直接塞入上下文窗口,不仅浪费 token 预算,还可能导致模型注意力分散。toolResultStorage.ts 实现了大结果持久化机制。
持久化阈值的确定遵循以下优先级(restored-src/src/utils/toolResultStorage.ts:55-78):
- GrowthBook 覆盖:运营团队可以通过 Feature Flag(
tengu_satin_quoll)为特定工具设置自定义阈值 - 工具声明值:每个工具的
maxResultSizeChars属性 - 全局上限:
DEFAULT_MAX_RESULT_SIZE_CHARS = 50,000字符(restored-src/src/constants/toolLimits.ts:13)
最终阈值取工具声明值和全局上限的较小者。但如果工具声明 Infinity,则跳过持久化 – 例如 Read 工具自己管理输出边界,将其输出持久化到文件再让模型用 Read 读回是循环引用。
当结果超过阈值时,persistToolResult(restored-src/src/utils/toolResultStorage.ts:137)将完整内容写入会话目录下的 tool-results/ 子目录,然后生成一个包含预览的摘要消息:
<persisted-output>
Output too large (245.0 KB). Full output saved to: /path/to/tool-results/abc123.txt
Preview (first 2.0 KB):
[前 2000 字节的内容...]
...
</persisted-output>
预览生成(restored-src/src/utils/toolResultStorage.ts:339-356)会尽量在换行符处截断,避免在行中间切断。截断点的查找范围是阈值的 50% 到 100% 之间最后一个换行符。
4.6.2 每消息聚合预算
除了单工具的大小限制,系统还维护一个每消息聚合预算(per-message aggregate budget)。当一个回合中多个并行工具各自返回接近阈值的结果时,它们的总和可能远超合理范围(例如 10 个工具各返回 40K = 400K 字符)。
聚合预算默认为 200,000 字符(restored-src/src/constants/toolLimits.ts:49),可通过 GrowthBook Flag(tengu_hawthorn_window)覆盖。超出预算时,系统从最大的工具结果开始持久化,直到总量降回预算以内。
为了保持提示缓存(prompt cache)的稳定性,聚合预算系统维护了一个 ContentReplacementState(restored-src/src/utils/toolResultStorage.ts:390-393),记录哪些工具结果已经被持久化。一旦某个结果在某次评估中被持久化,它在后续所有评估中都会使用相同的持久化版本 – 即使后续回合的总量未超预算。这避免了“缓存抖动“(cache thrashing):同一条消息在不同 API 调用中内容不同,导致前缀缓存失效。
4.6.3 空结果填充
一个容易被忽视的细节:空的 tool_result 内容会导致某些模型(尤其是 Capybara)误将其解释为回合边界,输出 \n\nHuman: 停止序列并终止响应(restored-src/src/utils/toolResultStorage.ts:280-295)。系统通过检测空结果并注入占位文本(如 (Bash completed with no output))来防止这种行为。
4.7 Stop Hooks:工具执行后的中断点
PreToolUse hooks 和 PostToolUse hooks 都可以请求停止后续循环继续(prevent continuation)。这是通过 preventContinuation 标志实现的。
当 PreToolUse hook 设置了此标志(restored-src/src/services/tools/toolHooks.ts:500-508),工具仍然会执行(除非同时返回了 deny 决策),但执行完成后,系统会向消息列表追加一条 hook_stopped_continuation 类型的附件消息(restored-src/src/services/tools/toolExecution.ts:1572-1582)。Agent Loop 检测到这类消息后会终止当前迭代,不再将结果发送给模型进行下一轮推理。
PostToolUse hooks 同样可以阻止继续(restored-src/src/services/tools/toolHooks.ts:118-129),并且是更常见的用法 – 例如,一个 hook 可能在检测到危险操作的结果后决定中断 Agent 循环。
4.8 模式提炼
模式一:贪心合并的流水线分区
工具调用分区采用“贪心合并“策略:连续的同类工具被合并到同一批次,不同类型的切换点成为批次边界。这个模式的核心洞见是 – 在顺序保证和并行效率之间,选择一个简单的中间方案。完全并行(忽略顺序)可能导致不一致,完全串行(忽略类型)则浪费性能。贪心合并在保持相对顺序的前提下实现了接近最优的并行度。
模式二:失败即关闭的安全默认值
isConcurrencySafe 在解析失败或异常时默认返回 false,Tool 接口的默认实现也是 false。权限 hook 的 allow 不能覆盖 deny 规则。这些都是“fail-closed“模式的体现 – 当系统无法确定安全性时,选择更保守的行为。在 AI Agent 系统中,这个原则尤为重要:模型的输出是不可预测的,任何假设“正常情况下不会发生“的乐观设计都可能成为安全漏洞。
模式三:分层错误级联
Bash 错误取消同级 Bash 工具,但不影响 Read/Grep 等独立工具;兄弟中止控制器取消子进程,但不中止父级 Agent Loop。这种选择性级联(selective cascading)避免了两个极端:要么完全隔离(错误被忽视),要么全局中止(一个小错误杀死整个会话)。
模式四:缓存稳定的结果管理
大结果持久化系统通过 ContentReplacementState 确保同一结果在不同 API 调用中始终使用相同的替换内容。这是提示缓存优化的关键 – 为了性能,牺牲一点逻辑简洁性来维护确定性。类似的缓存稳定性设计将在第13-15章的缓存架构中反复出现。
用户能做什么
以下是从 Claude Code 工具执行编排中提炼出的可操作建议,适用于任何需要编排多工具调用的 AI Agent 系统:
- 实现基于输入的并发分区。 不要简单地将所有工具调用串行执行。根据每个工具调用的实际输入判断是否只读/并发安全,将连续的安全调用合并为并发批次,最大化吞吐量。
- 为并发安全性设置“失败即关闭“默认值。 如果输入解析失败或
isConcurrencySafe抛异常,默认回退到串行执行。永远不要在不确定时假设并发是安全的。 - 实现 Bash 错误的选择性级联中断。 当一个 shell 命令失败时,取消同级的其他 shell 命令(它们很可能存在隐式依赖),但不要取消独立的只读工具(如
Read、Grep)。使用子级AbortController实现,避免中止整个 Agent Loop。 - 为大结果实现两级预算控制。 单工具结果有字符数上限,单消息的所有工具结果也有聚合上限。超出预算时持久化到磁盘并返回预览,从最大的结果开始裁剪。
- 维护结果替换的确定性。 一旦某个工具结果被持久化替换,在后续所有 API 调用中都使用相同的替换版本,即使聚合预算当前未超限。这对提示缓存的命中率至关重要。
- 为空的工具结果注入占位文本。 空的
tool_result可能被模型误解为回合边界。注入类似(Bash completed with no output)的占位文本,避免模型意外终止响应。 - 将权限检查设计为纵深防御。 Hook 的
allow决策不应绕过用户配置的deny规则。多层权限检查(hook → 工具自身 → 规则匹配 → 用户交互)确保安全边界始终有效。
本章揭示了工具执行编排层如何在并发效率、安全控制和上下文管理之间取得平衡。下一章将进入第二篇,分析系统提示词架构 – 另一个驾驭模型行为的关键控制面。
第5章:系统提示词架构
第4章解剖了工具执行编排的全过程。在模型能够做出任何工具调用之前,它需要先“知道自己是谁“ – 这正是系统提示词(system prompt)的职责。本章将深入系统提示词的组装架构:段落如何注册与记忆化、静态与动态内容如何被边界标记分割、缓存优化契约如何在 API 层兑现、以及多来源提示词如何按优先级合成为最终发送给模型的指令集。
5.1 为什么系统提示词需要“架构“
一个朴素的实现可以将系统提示词硬编码为一个字符串常量。但 Claude Code 的系统提示词面临三重工程挑战:
- 体积与成本:完整的系统提示词包含身份介绍、行为规范、工具使用指南、环境信息、内存文件、MCP 指令等十余个段落,总量达数万 token。每次 API 调用都重传这些内容,意味着巨额的 prompt 缓存(prompt caching)成本。
- 变化频率不一:身份介绍和编码规范在所有用户、所有会话中完全相同,而环境信息(工作目录、操作系统版本)因会话而异,MCP 服务器指令甚至可能在对话中途变化。
- 多来源覆盖:用户可以通过
--system-prompt自定义提示词,Agent 模式有专属提示词,协调器模式(coordinator mode)有独立提示词,Loop 模式可以完全覆盖 – 这些来源之间的优先级必须明确。
Claude Code 的解决方案是一个分段式组合架构(sectioned composition architecture):将系统提示词拆分为独立的、可记忆化的段落,通过注册表管理生命周期,用边界标记(boundary marker)划分缓存层级,最终在 API 层转化为带有 cache_control 的请求块。
5.2 段落注册表:systemPromptSection 的记忆化与缓存感知
5.2.1 核心抽象
系统提示词的最小单元是段落(section)。每个段落由一个名称、一个计算函数和一个缓存策略组成。这个抽象定义在 systemPromptSections.ts 中:
type SystemPromptSection = {
name: string
compute: ComputeFn // () => string | null | Promise<string | null>
cacheBreak: boolean // false = 可记忆化, true = 每轮重算
}
源码参考: restored-src/src/constants/systemPromptSections.ts:10-14
两个工厂函数用于创建段落:
systemPromptSection(name, compute)– 创建一个记忆化段落。计算函数只在首次调用时执行,结果被缓存到全局状态中,后续轮次直接返回缓存值。缓存在/clear或/compact时重置。DANGEROUS_uncachedSystemPromptSection(name, compute, reason)– 创建一个易变段落。每次解析(resolve)时都会重新执行计算函数。DANGEROUS_前缀和必填的reason参数是有意为之的 API 设计摩擦(API friction),提醒开发者这种段落会破坏 prompt 缓存。
┌───────────────────────────────────────────────────────────────────────┐
│ 段落注册表 (Section Registry) │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ systemPromptSection │ │ DANGEROUS_uncachedSystemPromptSection│ │
│ │ cacheBreak=false │ │ cacheBreak=true │ │
│ └────────┬────────────┘ └────────────┬─────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ resolveSystemPromptSections(sections) │ │
│ │ │ │
│ │ for each section: │ │
│ │ if (!cacheBreak && cache.has(name)): │ │
│ │ return cache.get(name) ← 记忆化命中 │ │
│ │ else: │ │
│ │ value = await compute() │ │
│ │ cache.set(name, value) ← 写入缓存 │ │
│ │ return value │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 缓存存储: STATE.systemPromptSectionCache (Map<string, string|null>) │
│ 重置时机: /clear, /compact → clearSystemPromptSections() │
└───────────────────────────────────────────────────────────────────────┘
图 5-1:段落注册表的记忆化流程。 记忆化段落(cacheBreak=false)在首次计算后缓存到全局 Map 中;易变段落(cacheBreak=true)每次都重新计算。
5.2.2 解析流程
resolveSystemPromptSections 是将段落定义转化为实际字符串的核心函数(restored-src/src/constants/systemPromptSections.ts:43-58):
export async function resolveSystemPromptSections(
sections: SystemPromptSection[],
): Promise<(string | null)[]> {
const cache = getSystemPromptSectionCache()
return Promise.all(
sections.map(async s => {
if (!s.cacheBreak && cache.has(s.name)) {
return cache.get(s.name) ?? null
}
const value = await s.compute()
setSystemPromptSectionCacheEntry(s.name, value)
return value
}),
)
}
几个关键设计决策:
- 并行解析:使用
Promise.all并行执行所有段落的计算函数。这对于需要 I/O 操作的段落(如loadMemoryPrompt读取 CLAUDE.md 文件)尤为重要。 - null 有效:计算函数返回
null表示该段落不需要包含在最终提示词中。null同样会被缓存,避免在后续轮次重复执行条件判断。 - 缓存存储位置:缓存存储在
STATE.systemPromptSectionCache中(restored-src/src/bootstrap/state.ts:203),这是一个Map<string, string | null>。选择全局 state 而非模块级变量,是为了让/clear和/compact命令能够统一重置所有状态。
5.2.3 缓存生命周期
缓存的清除由 clearSystemPromptSections 函数负责(restored-src/src/constants/systemPromptSections.ts:65-68):
// export function clearSystemPromptSections(): void {
clearSystemPromptSectionState() // 清空 Map
clearBetaHeaderLatches() // 重置 beta 头部锁存器
}
这个函数在两个时机被调用:
/clear命令 – 用户显式清除对话历史时,所有段落缓存失效,下一轮 API 调用会重新计算所有段落。/compact命令 – 对话被压缩时,段落缓存同样失效。这是因为压缩可能改变上下文状态(如工具可用列表),依赖旧状态计算的段落值可能不再正确。
附带的 clearBetaHeaderLatches() 确保新对话能重新评估 AFK、fast-mode 等 beta 特性头部,而不是延续上一轮的锁存值。
5.3 DANGEROUS_uncachedSystemPromptSection 的使用时机
DANGEROUS_ 前缀不是装饰 – 它标记了一个真实的工程权衡(trade-off)。让我们看看源码中唯一的使用案例:
DANGEROUS_uncachedSystemPromptSection(
'mcp_instructions',
() =>
isMcpInstructionsDeltaEnabled()
? null
: getMcpInstructionsSection(mcpClients),
'MCP servers connect/disconnect between turns',
),
源码参考: restored-src/src/constants/prompts.ts:513-520
MCP 服务器可以在对话的两个轮次之间连接或断开。如果将 MCP 指令段落设为记忆化,那么在第1轮计算时只有服务器 A 连接,缓存了 A 的指令;到第3轮服务器 B 也连接了,但缓存仍然返回只包含 A 的旧值 – 模型永远不会知道 B 的存在。
这就是 DANGEROUS_uncachedSystemPromptSection 的使用时机:当段落的内容可能在对话生命周期内发生变化,且使用过期值会导致功能性错误时。
代码注释中的 reason 参数('MCP servers connect/disconnect between turns')不仅是文档,更是一种代码审查约束 – 任何引入新的 DANGEROUS_ 段落的 PR 都需要解释为什么缓存失效是必要的。
值得注意的是,源码中还记录了一个“从 DANGEROUS 降级为普通缓存“的案例。token_budget 段落曾经是 DANGEROUS_uncachedSystemPromptSection,根据 getCurrentTurnTokenBudget() 的值动态切换,但这会在每次 budget 切换时破坏约 20K token 的缓存。解决方案是重新措辞提示词文本,使其在无预算时自然成为空操作(no-op),从而降级为普通的 systemPromptSection(restored-src/src/constants/prompts.ts:540-550)。
5.4 静态与动态边界:SYSTEM_PROMPT_DYNAMIC_BOUNDARY
5.4.1 边界标记的定义
系统提示词中存在一条显式的分界线,将内容划分为“静态区“和“动态区“:
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
'__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
源码参考: restored-src/src/constants/prompts.ts:114-115
这个字符串常量本身不会出现在最终发送给模型的文本中 – 它是一个带内信号(in-band signal),只存在于系统提示词数组中,供下游的 splitSysPromptPrefix 函数识别和处理。
5.4.2 边界的位置与含义
在 getSystemPrompt 函数的返回数组中,边界标记被精确放置在静态内容与动态内容之间(restored-src/src/constants/prompts.ts:560-576):
返回数组结构:
[
getSimpleIntroSection(...) ─┐
getSimpleSystemSection() │ 静态区:所有用户/会话相同
getSimpleDoingTasksSection() │ → cacheScope: 'global'
getActionsSection() │
getUsingYourToolsSection(...) │
getSimpleToneAndStyleSection() │
getOutputEfficiencySection() ─┘
SYSTEM_PROMPT_DYNAMIC_BOUNDARY ← 边界标记
session_guidance ─┐
memory (CLAUDE.md) │ 动态区:因会话/用户而异
env_info_simple │ → cacheScope: null (不缓存)
language │
output_style │
mcp_instructions (DANGEROUS) │
scratchpad │
... ─┘
]
图 5-2:静态/动态边界示意图。 边界标记将系统提示词数组分为两个区域,分别对应不同的缓存作用域。
关键规则:边界标记之前的所有内容在所有组织、所有用户、所有会话中完全相同。这意味着它们可以使用 scope: 'global' 进行跨组织缓存 – 一个用户的 API 调用计算出的缓存前缀,可以被任何其他用户的调用直接命中。
边界标记只在第一方(firstParty)API 提供者启用全局缓存时才被插入:
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
shouldUseGlobalCacheScope()(restored-src/src/utils/betas.ts:227-231)的判断条件是:API 提供者为 'firstParty'(即直接使用 Anthropic API),且未通过环境变量禁用实验性 beta 特性。第三方提供者(如通过 Foundry 接入)不使用全局缓存。
5.4.3 将会话变化赶到边界之后
源码中有一段精心撰写的注释,解释了 getSessionSpecificGuidanceSection 存在的原因(restored-src/src/constants/prompts.ts:343-347):
Session-variant guidance that would fragment the cacheScope:‘global’ prefix if placed before SYSTEM_PROMPT_DYNAMIC_BOUNDARY. Each conditional here is a runtime bit that would otherwise multiply the Blake2b prefix hash variants (2^N).
这揭示了一个微妙但关键的设计约束:静态区中不能包含任何因会话而异的条件分支。如果工具可用列表、Skill 命令、Agent 工具等运行时信息出现在边界之前,那么每种工具组合都会产生一个不同的 Blake2b 前缀哈希,导致全局缓存的变体数量呈指数增长(2^N,N 为条件位数),实际命中率降为零。
因此,所有依赖运行时状态的内容 – 工具引导(session guidance)、内存文件、环境信息、语言偏好 – 都被放置在边界之后的动态区中,作为记忆化段落(systemPromptSection)而非静态字符串。
5.5 splitSysPromptPrefix 的三条代码路径
splitSysPromptPrefix(restored-src/src/utils/api.ts:321-435)是将逻辑上的系统提示词数组转化为 API 请求中带有缓存控制的文本块(SystemPromptBlock[])的桥梁。它根据运行时条件选择三条不同的代码路径。
flowchart TD
A["splitSysPromptPrefix(systemPrompt, options)"] --> B{"shouldUseGlobalCacheScope()\n&&\nskipGlobalCacheForSystemPrompt?"}
B -->|"是 (MCP 工具在场)"| C["路径1: MCP 降级"]
B -->|"否"| D{"shouldUseGlobalCacheScope()?"}
D -->|"是"| E{"边界标记存在?"}
D -->|"否"| G["路径3: 默认 org 缓存"]
E -->|"是"| F["路径2: 全局缓存 + 边界"]
E -->|"否"| G
C --> C1["attribution → null\nprefix → org\nrest → org"]
C1 --> C2["最多 3 块\n跳过边界标记"]
F --> F1["attribution → null\nprefix → null\nstatic → global\ndynamic → null"]
F1 --> F2["最多 4 块"]
G --> G1["attribution → null\nprefix → org\nrest → org"]
G1 --> G2["最多 3 块"]
style C fill:#f9d,stroke:#333
style F fill:#9df,stroke:#333
style G fill:#dfd,stroke:#333
图 5-3:splitSysPromptPrefix 三路径流程图。 根据全局缓存特性和 MCP 工具存在情况,函数选择不同的缓存策略。
5.5.1 路径1:MCP 降级路径
触发条件: shouldUseGlobalCacheScope() === true 且 options.skipGlobalCacheForSystemPrompt === true
当会话中存在 MCP 工具时,工具 schema 本身是用户级别的动态内容,无法全局缓存。此时即便系统提示词中的静态区可以全局缓存,工具 schema 的存在也使全局缓存的实际收益大打折扣。因此 splitSysPromptPrefix 选择降级到 org 级别缓存。
// 路径1核心逻辑 (restored-src/src/utils/api.ts:332-359)
for (const prompt of systemPrompt) {
if (!prompt) continue
if (prompt === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue // 跳过边界
if (prompt.startsWith('x-anthropic-billing-header')) {
attributionHeader = prompt
} else if (CLI_SYSPROMPT_PREFIXES.has(prompt)) {
systemPromptPrefix = prompt
} else {
rest.push(prompt)
}
}
// 结果: [attribution:null, prefix:org, rest:org]
边界标记被直接跳过(continue),所有非特殊块合并为一个 org 级别的缓存块。skipGlobalCacheForSystemPrompt 的传入方来自 claude.ts 中的判断(restored-src/src/services/api/claude.ts:1210-1214):只有当 MCP 工具实际渲染到请求中(而非被 defer_loading)时,才触发降级。
5.5.2 路径2:全局缓存 + 边界路径
触发条件: shouldUseGlobalCacheScope() === true,未被 MCP 降级,且系统提示词中存在边界标记
这是第一方用户在无 MCP 工具时的主路径,也是缓存效率最高的路径:
// 路径2核心逻辑 (restored-src/src/utils/api.ts:362-409)
const boundaryIndex = systemPrompt.findIndex(
s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
)
if (boundaryIndex !== -1) {
for (let i = 0; i < systemPrompt.length; i++) {
const block = systemPrompt[i]
if (!block || block === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue
if (block.startsWith('x-anthropic-billing-header')) {
attributionHeader = block
} else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
systemPromptPrefix = block
} else if (i < boundaryIndex) {
staticBlocks.push(block) // 边界前 → 静态
} else {
dynamicBlocks.push(block) // 边界后 → 动态
}
}
// 结果: [attribution:null, prefix:null, static:global, dynamic:null]
}
这条路径产生最多 4 个文本块:
| 块 | cacheScope | 说明 |
|---|---|---|
| attribution header | null | 计费归因头,不缓存 |
| system prompt prefix | null | CLI 前缀标识,不缓存 |
| static content | 'global' | 跨组织可缓存的核心指令 |
| dynamic content | null | 每会话内容,不缓存 |
静态块使用 scope: 'global' 意味着 Anthropic API 后端可以在所有 Claude Code 用户之间共享这个缓存前缀。考虑到静态区通常包含数万 token 的身份介绍和行为规范,这个缓存在高并发场景下节省的计算量是巨大的。
5.5.3 路径3:默认 org 缓存路径
触发条件: 全局缓存特性未启用(第三方提供者),或边界标记不存在
这是最简单的兜底路径:
// 路径3核心逻辑 (restored-src/src/utils/api.ts:411-434)
for (const block of systemPrompt) {
if (!block) continue
if (block.startsWith('x-anthropic-billing-header')) {
attributionHeader = block
} else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
systemPromptPrefix = block
} else {
rest.push(block)
}
}
// 结果: [attribution:null, prefix:org, rest:org]
所有非特殊内容合并为单一块,使用 org 级别缓存。这对第三方提供者已经足够 – 同一组织内的用户共享相同的系统提示词前缀,仍能获得组织级别的缓存命中。
5.5.4 从 splitSysPromptPrefix 到 API 请求
buildSystemPromptBlocks(restored-src/src/services/api/claude.ts:3213-3237)是 splitSysPromptPrefix 的直接消费者。它将 SystemPromptBlock[] 转化为 Anthropic API 期望的 TextBlockParam[] 格式:
// export function buildSystemPromptBlocks(
systemPrompt: SystemPrompt,
enablePromptCaching: boolean,
options?: { skipGlobalCacheForSystemPrompt?: boolean; querySource?: QuerySource },
): TextBlockParam[] {
return splitSysPromptPrefix(systemPrompt, {
skipGlobalCacheForSystemPrompt: options?.skipGlobalCacheForSystemPrompt,
}).map(block => ({
type: 'text' as const,
text: block.text,
...(enablePromptCaching && block.cacheScope !== null && {
cache_control: getCacheControl({
scope: block.cacheScope,
querySource: options?.querySource,
}),
}),
}))
}
映射规则很直观:cacheScope 不为 null 的块获得 cache_control 属性,null 的块则没有。API 后端根据 cache_control.scope 的值('global' 或 'org')决定缓存的共享范围。
5.6 系统提示词的构建流程
5.6.1 getSystemPrompt 的完整流程
getSystemPrompt(restored-src/src/constants/prompts.ts:444-577)是系统提示词构建的主入口。它接受工具列表、模型名称、额外工作目录和 MCP 客户端列表,返回一个 string[] 数组。
flowchart TD
A["getSystemPrompt(tools, model, dirs, mcpClients)"] --> B{"CLAUDE_CODE_SIMPLE?"}
B -->|"是"| C["返回最小提示词\n(仅身份 + CWD + 日期)"]
B -->|"否"| D["并行计算:\nskillToolCommands\noutputStyleConfig\nenvInfo"]
D --> E{"Proactive 模式?"}
E -->|"是"| F["返回自治 Agent 提示词\n(精简版,无段落注册)"]
E -->|"否"| G["构建动态段落数组\n(systemPromptSection ×N)"]
G --> H["resolveSystemPromptSections\n(并行解析,记忆化)"]
H --> I["组装最终数组"]
I --> J["静态区:\nintro, system, tasks,\nactions, tools, tone,\nefficiency"]
J --> K["BOUNDARY MARKER\n(条件插入)"]
K --> L["动态区:\nsession_guidance, memory,\nenv_info, language,\noutput_style, mcp, ..."]
L --> M["filter(s => s !== null)"]
M --> N["返回 string[]"]
图 5-4:系统提示词构建流程图。 从入口到最终返回的完整数据流。
构建过程有三个快速路径(fast path):
- CLAUDE_CODE_SIMPLE 模式:环境变量
CLAUDE_CODE_SIMPLE为真时,直接返回一个只含身份、工作目录和日期的最小提示词。这主要用于测试和调试场景。 - Proactive 模式:当启用了
PROACTIVE或KAIROS特性标志且处于活跃状态时,返回一个精简的自治 Agent 提示词。注意这条路径不经过段落注册表,而是直接拼装字符串数组。 - 标准路径:经过完整的段落注册、解析、静态/动态分区流程。
5.6.2 段落注册表一览
标准路径中注册的动态段落(restored-src/src/constants/prompts.ts:491-555)构成了动态区的全部内容:
| 段落名称 | 类型 | 内容描述 |
|---|---|---|
session_guidance | 记忆化 | 工具引导、交互模式提示 |
memory | 记忆化 | CLAUDE.md 内存文件内容(详见第6章) |
ant_model_override | 记忆化 | Anthropic 内部模型覆盖指令 |
env_info_simple | 记忆化 | 工作目录、OS、Shell 等环境信息 |
language | 记忆化 | 语言偏好设置 |
output_style | 记忆化 | 输出风格配置 |
mcp_instructions | 易变 | MCP 服务器指令(可中途变化) |
scratchpad | 记忆化 | 草稿本指令 |
frc | 记忆化 | 函数结果清理指令 |
summarize_tool_results | 记忆化 | 工具结果摘要指令 |
numeric_length_anchors | 记忆化 | 长度锚点(仅 Ant 内部) |
token_budget | 记忆化 | Token 预算指令(特性门控) |
brief | 记忆化 | 简报段落(KAIROS 特性门控) |
唯一的 DANGEROUS_uncachedSystemPromptSection 是 mcp_instructions – 这与 5.3 节的分析一致。所有其他段落都是记忆化的,在会话生命周期内计算一次后不再变化。
5.7 buildEffectiveSystemPrompt 的优先级
getSystemPrompt 构建的是“默认系统提示词“。但在实际调用中,还有多个来源可能覆盖或补充这个默认值。buildEffectiveSystemPrompt(restored-src/src/utils/systemPrompt.ts:41-123)负责按优先级合成最终的有效提示词。
5.7.1 优先级链
优先级 0 (最高): overrideSystemPrompt
↓ 不存在时
优先级 1: coordinator system prompt
↓ 不存在时
优先级 2: agent system prompt
↓ 不存在时
优先级 3: customSystemPrompt (--system-prompt)
↓ 不存在时
优先级 4 (最低): defaultSystemPrompt (getSystemPrompt 的输出)
+ appendSystemPrompt 始终追加在末尾 (除 override 外)
5.7.2 各优先级的行为
Override(覆盖): 当 overrideSystemPrompt 存在时(例如 Loop 模式设置的循环指令),直接返回只包含该字符串的数组,忽略所有其他来源,包括 appendSystemPrompt(restored-src/src/utils/systemPrompt.ts:56-58):
if (overrideSystemPrompt) {
return asSystemPrompt([overrideSystemPrompt])
}
Coordinator(协调器): 当启用了 COORDINATOR_MODE 特性标志且 CLAUDE_CODE_COORDINATOR_MODE 环境变量为真时,使用协调器专用的系统提示词替代默认值。注意这里通过懒加载(lazy require)引入 coordinatorMode 模块,避免循环依赖(restored-src/src/utils/systemPrompt.ts:62-75)。
Agent(代理): 当设置了 mainThreadAgentDefinition 时,行为取决于是否处于 Proactive 模式:
- Proactive 模式下:Agent 指令被追加到默认提示词末尾,而非替换。这是因为 Proactive 模式的默认提示词已经是精简的自治 Agent 身份,Agent 定义只是在其上添加领域指令 – 与 teammates 模式下的行为一致。
- 普通模式下:Agent 指令替换默认提示词。
Custom(自定义): --system-prompt 命令行参数指定的提示词,替换默认提示词。
Default(默认): getSystemPrompt 的完整输出。
Append(追加): 如果设置了 appendSystemPrompt,它被追加到最终数组的末尾。这提供了一种在不完全覆盖系统提示词的情况下注入额外指令的机制。
5.7.3 最终合成逻辑
当没有 override 和 coordinator 时,核心的三路选择逻辑如下(restored-src/src/utils/systemPrompt.ts:115-122):
return asSystemPrompt([
...(agentSystemPrompt
? [agentSystemPrompt]
: customSystemPrompt
? [customSystemPrompt]
: defaultSystemPrompt),
...(appendSystemPrompt ? [appendSystemPrompt] : []),
])
这是一个清晰的三元链(ternary chain):Agent > Custom > Default,加上可选的 append。asSystemPrompt 是一个品牌类型(branded type)转换,确保返回值的类型安全(详见第8章关于类型系统的讨论)。
5.8 缓存优化契约:设计约束与陷阱
系统提示词架构建立了一个隐式的缓存优化契约(cache optimization contract),违反这个契约会导致缓存命中率断崖式下降。以下是从源码中提炼的关键约束:
约束1:静态区不可含会话变量
如 5.4.3 节所述,边界前的任何条件分支都会指数级增加哈希变体数量。PR #24490 和 #24171 记录了这类 bug:开发者不慎将一个 if (hasAgentTool) 条件放入静态区,导致全局缓存命中率从 95% 暴跌至不到 10%。
约束2:DANGEROUS 段落必须有充分理由
DANGEROUS_uncachedSystemPromptSection 的每次使用都在代码审查中接受审视。reason 参数虽然在运行时不被使用(注意参数名的 _ 前缀:_reason),但它是 PR 审查的锚点 – 审查者会检查理由是否充分、是否有替代方案可以降级为记忆化段落。
约束3:MCP 工具触发全局缓存降级
当存在 MCP 工具时,splitSysPromptPrefix 自动降级到 org 级别缓存。这个决策基于一个工程判断:MCP 工具的 schema 是用户级别的动态内容,即便系统提示词中的静态区可以全局缓存,工具 schema 块的存在意味着 API 请求中已经有了无法全局缓存的大块内容,系统提示词的全局缓存带来的边际收益不足以证明额外的复杂性。
约束4:边界标记的位置是架构不变量
源码中的注释直言不讳(restored-src/src/constants/prompts.ts:572):
// === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===
移动或删除边界标记不是一个代码变更 – 它是一个架构变更,会改变所有第一方用户的缓存行为。
5.8 模式提炼
从系统提示词架构中,可以提炼出以下可复用的工程模式:
模式 1:分段记忆化(Sectioned Memoization)
- 解决的问题: 大型提示词中部分内容是静态的、部分是动态的,全量重算浪费资源。
- 方案: 将提示词拆分为独立段落,每个段落有明确的缓存策略(记忆化 vs. 易变)。通过工厂函数区分两种类型,并为易变类型增加 API 摩擦(
DANGEROUS_前缀 + 必填reason)。 - 前置条件: 需要全局状态管理器持有缓存 Map,以及明确的缓存失效时机(如
/clear、/compact)。 - 代码模板:
memoizedSection(name, computeFn) → 首次计算后缓存 volatileSection(name, computeFn, reason) → 每轮重算,需说明理由 resolveAll(sections) → Promise.all 并行解析
模式 2:缓存边界分治(Cache Boundary Partitioning)
- 解决的问题: 多用户共享的提示词前缀需要全局缓存,但会话特定的内容破坏缓存命中率。
- 方案: 在提示词数组中插入显式的边界标记,将内容分为“全局可缓存的静态区“和“每会话的动态区“。下游函数根据边界位置分配不同的
cacheScope。 - 前置条件: API 后端支持多级缓存作用域(如
global/org/null)。 - 关键约束: 边界前的静态区中不能包含任何因会话而异的条件分支,否则哈希变体数量指数增长。
模式 3:优先级链合成(Priority Chain Composition)
- 解决的问题: 多个来源(用户自定义、Agent 模式、协调器模式、默认值)都可能提供系统提示词,需要明确的优先级。
- 方案: 定义一条线性优先级链(override > coordinator > agent > custom > default),加上一个始终追加的
append机制。使用三元链(ternary chain)保持代码的线性可读性。 - 前置条件: 各优先级来源的输入接口统一(均为
string | string[])。
5.9 用户能做什么
基于本章分析的系统提示词架构,以下是读者可以在自己的 AI Agent 项目中直接应用的建议:
-
为你的提示词建立分段注册表。 不要将系统提示词硬编码为单一字符串。将其拆分为独立的、有名称的段落,每个段落标注是否可缓存。这样做的收益不仅是缓存效率,更是可维护性 – 当需要修改某个行为指令时,你可以精确定位到对应的段落,而不是在一个巨大的字符串中搜索。
-
为易变段落增加 API 摩擦。 如果你的系统中有部分提示词内容需要每轮重算(如动态工具列表、实时状态信息),参考
DANGEROUS_uncachedSystemPromptSection的设计:要求调用者提供“为什么需要每轮重算“的理由。这种摩擦在代码审查中尤其有价值 – 它迫使开发者显式权衡缓存效率与内容新鲜度。 -
将会话变量赶到缓存边界之后。 如果你使用的 API 支持 prompt caching,确保提示词的前缀部分(缓存键的计算范围)不包含因用户、会话或运行时状态而异的内容。Claude Code 的
SYSTEM_PROMPT_DYNAMIC_BOUNDARY标记是这种策略的直接实现。 -
定义清晰的提示词优先级链。 当你的系统支持多种运行模式(自治 Agent、协调器、用户自定义等),为每种模式的提示词来源定义明确的优先级。避免“合并“不同来源的提示词 – 使用“替换“语义更安全、更可预测。
-
监控缓存命中率。 系统提示词架构的价值完全体现在缓存命中率上。如果你的缓存命中率突然下降,检查是否有新的条件分支被引入到静态区中 – 这是 Claude Code 团队在 PR #24490 中踩过的坑。
5.10 小结
系统提示词架构是 Claude Code 中“看不见但处处生效“的基础设施。它的设计体现了三个核心原则:
- 分段组合:通过
systemPromptSection注册表将提示词分解为独立的、可记忆化的段落,每个段落有明确的名称、计算函数和缓存策略。 - 边界分治:
SYSTEM_PROMPT_DYNAMIC_BOUNDARY标记将内容分为全局可缓存的静态区和每会话的动态区,splitSysPromptPrefix的三条路径根据运行时条件选择最优的缓存策略。 - 优先级合成:
buildEffectiveSystemPrompt通过清晰的五级优先级链(override > coordinator > agent > custom > default + append)支持多种运行模式,同时保持代码的线性可读性。
这个架构的“成功标准“不是功能的正确性 – 即使把整个系统提示词硬编码为单一字符串,功能上也完全可用。它的价值在于成本效率:通过精心的缓存层级设计,在每天数百万次 API 调用中节省大量的 prompt 处理开销。下一章将讨论系统提示词架构的一个关键输入 – CLAUDE.md 内存文件系统如何被加载和注入(详见第6章)。
第6章:通过提示词引导行为
第5章剖析了系统提示词的组装架构 – 段落注册、缓存边界、多来源合成。但架构只是骨骼,真正让 Claude Code 表现出“像一个有经验的工程师“的,是骨骼上附着的肌肉:那些精心措辞的行为指令。本章将提炼出 6 种可复用的行为引导模式(behavior steering pattern),每种模式都有源码原文、生效原理、以及你可以直接搬进自己提示词的模板。
6.1 行为引导的本质:在生成概率空间中设定边界
大语言模型的输出是一个概率分布上的采样过程。系统提示词中的行为指令,本质上是在这个概率空间中竖起围栏 – 提高期望行为的概率,压低不期望行为的概率。但围栏的措辞方式,决定了它是一堵坚固的墙还是一条模糊的线。
通读 Claude Code 的系统提示词源码(restored-src/src/constants/prompts.ts 与 restored-src/src/tools/BashTool/prompt.ts),可以发现 Anthropic 的工程师们并非随意堆砌指令,而是形成了一套隐含的模式语言(pattern language)。这些模式之所以有效,不仅因为它们“说了正确的话“,更因为它们在措辞结构上暗合了模型的注意力机制和指令遵循特性。
本章将这些模式显式化,命名为 6 种行为引导模式:
- 极简主义指令(Minimalism Directive)
- 渐进式升级(Progressive Escalation)
- 可逆性意识(Reversibility Awareness)
- 工具偏好引导(Tool Preference Steering)
- Agent 委托指引(Agent Delegation Protocol)
- 数值锚定(Numeric Anchoring)
6.2 模式一:极简主义指令(Minimalism Directive)
6.2.1 模式定义
核心思想: 通过明确禁止过度工程,将模型的“乐于助人“倾向限制在任务实际需要的范围内。
大语言模型天生倾向于“多做一点“ – 添加额外的错误处理、补充文档注释、引入抽象层。这在对话场景中是美德,但在代码生成场景中却是灾难。极简主义指令通过具体的反面案例,让模型知道“不做什么“比“做什么“更重要。
6.2.2 源码实例
实例 1:三行代码优于过早抽象
Don't create helpers, utilities, or abstractions for one-time operations.
Don't design for hypothetical future requirements. The right amount of
complexity is what the task actually requires — no speculative abstractions,
but no half-finished implementations either. Three similar lines of code
is better than a premature abstraction.
源码位置: restored-src/src/constants/prompts.ts:203
这段指令的最后一句 “Three similar lines of code is better than a premature abstraction” 是整个极简主义指令中最精彩的一笔。它给出了一个具体的数量门槛 – 三行 – 让模型在面对“要不要提取一个公共函数“的决策时,有了明确的判断基准。没有这个锚点,模型会默认倾向于 DRY(Don’t Repeat Yourself),而 DRY 在 AI 辅助编程的语境中常常导致过度抽象。
实例 2:不要添加超出要求的功能
Don't add features, refactor code, or make "improvements" beyond what was
asked. A bug fix doesn't need surrounding code cleaned up. A simple feature
doesn't need extra configurability. Don't add docstrings, comments, or type
annotations to code you didn't change. Only add comments where the logic
isn't self-evident.
源码位置: restored-src/src/constants/prompts.ts:201
注意这段指令的结构:先是一个总原则(“不要添加超出要求的功能”),然后是三个具体的反面案例(bug 修复不需要清理周围代码、简单功能不需要额外配置性、不要给未修改的代码加注释)。这种“总则 + 反面案例“的结构非常有效,因为模型在遵循指令时需要将抽象原则映射到具体场景,而反面案例提供了这种映射的锚点。
实例 3:不要为不可能的场景添加防御(ant-only)
Don't add error handling, fallbacks, or validation for scenarios that can't
happen. Trust internal code and framework guarantees. Only validate at system
boundaries (user input, external APIs). Don't use feature flags or
backwards-compatibility shims when you can just change the code.
源码位置: restored-src/src/constants/prompts.ts:202
这条指令直击一个常见的 LLM 行为模式:过度防御性编程。模型在训练数据中见过大量的“最佳实践“文章,这些文章强调处理每一种可能的错误。但在实际的内部代码中,许多错误路径永远不会被触发。这条指令通过“Trust internal code and framework guarantees“这个短语,赋予模型一个新的判断框架:区分系统边界和内部调用。
6.2.3 为什么有效
极简主义指令之所以有效,源于三个机制:
- 反面案例比正面规则更容易遵循。 “不要做 X“比“只做 Y“更精确,因为 X 的边界比 Y 的边界更清晰。模型可以将生成的每个 token 与“这是不是在做 X“进行对照。
- 具体数量打破默认启发式。 “三行重复代码“这样的数量锚定,覆盖了模型内置的 DRY 启发式。没有具体数字,模型会回退到训练数据中最常见的模式。
- 场景分类减少歧义。 “bug 修复不需要清理周围代码“这类指令,将一个模糊的“什么时候该多做一点“问题,转化为一个明确的分类任务:“当前任务是 bug 修复还是重构?”
6.2.4 可复用模板
[极简主义指令模板]
不要在任务范围之外添加 {功能/重构/改进}。
{任务类型 A} 不需要 {常见的过度工程行为 A}。
{任务类型 B} 不需要 {常见的过度工程行为 B}。
{N} 行重复代码优于过早抽象。
只在 {明确的边界条件} 时才 {采取额外行动}。
6.3 模式二:渐进式升级(Progressive Escalation)
6.3.1 模式定义
核心思想: 在“放弃“和“死循环“之间定义一条中间路径,引导模型先诊断、再调整、最后求助。
LLM 在面对失败时有两种极端倾向:要么立即放弃并请求用户帮助,要么不断重试完全相同的操作。渐进式升级模式通过定义一个明确的三阶段协议 – 诊断、调整、求助 – 将模型的失败响应锁定在合理的范围内。
6.3.2 源码实例
实例 1:失败处理三阶段
If an approach fails, diagnose why before switching tactics — read the error,
check your assumptions, try a focused fix. Don't retry the identical action
blindly, but don't abandon a viable approach after a single failure either.
Escalate to the user with ask_user_question only when you're genuinely stuck
after investigation, not as a first response to friction.
源码位置: restored-src/src/constants/prompts.ts:233
这段指令在一个段落中定义了完整的失败处理协议:
- 阶段 1(诊断): “read the error, check your assumptions” – 先理解发生了什么
- 阶段 2(调整): “try a focused fix” – 基于诊断结果做有针对性的修改
- 阶段 3(求助): “Escalate to the user… only when you’re genuinely stuck” – 在真正的死胡同处才请求帮助
关键的设计在于两个“不要“之间的张力:“Don’t retry the identical action blindly”(禁止死循环)和 “don’t abandon a viable approach after a single failure”(禁止过早放弃)。这种双边约束迫使模型在两个极端之间寻找中间路径。
实例 2:Git 操作中的诊断优先
Before running destructive operations (e.g., git reset --hard, git push
--force, git checkout --), consider whether there is a safer alternative
that achieves the same goal. Only use destructive operations when they are
truly the best approach.
源码位置: restored-src/src/tools/BashTool/prompt.ts:306
这条指令要求在执行高风险操作前进行一次“是否有更安全的替代方案“的评估。它不是简单地禁止这些操作,而是要求模型在选择之前完成一个推理步骤。
实例 3:Sleep 命令的诊断替代
Do not retry failing commands in a sleep loop — diagnose the root cause.
源码位置: restored-src/src/tools/BashTool/prompt.ts:318
这是渐进式升级模式的最简形式:一句话同时包含禁止(“不要在 sleep 循环中重试”)和替代方案(“诊断根本原因”)。它特别针对一个常见的 LLM 行为模式 – 当命令失败时,模型可能会在循环中 sleep && retry,这在交互式环境中是灾难性的。
6.3.3 为什么有效
渐进式升级的有效性来自于:
- 双边约束创造张力。 同时定义“不要太快放弃“和“不要无限重试“,让模型在每次失败后必须进行一次显式的推理:“我是在盲目重试,还是在做有信息量的调整?”
- 阶段顺序映射到 Chain-of-Thought。 诊断 -> 调整 -> 求助的顺序,与模型的推理链(chain of thought)自然对齐。模型可以将这个协议直接编码为思维链中的步骤。
- 求助作为最后手段。 将“问用户“设定为最后选项,减少了不必要的交互中断,提高了自主完成率。
6.3.4 可复用模板
[渐进式升级模板]
当 {操作} 失败时,先 {诊断动作}({具体诊断步骤列表})。
不要盲目重试相同的操作,但也不要在一次失败后就放弃可行的方案。
只在 {升级条件} 时才 {升级动作},而非将其作为遇到阻力时的第一反应。
6.4 模式三:可逆性意识(Reversibility Awareness)
6.4.1 模式定义
核心思想: 按照操作的可逆性和影响范围分级,对高风险操作建立确认框架。
这是 Claude Code 提示词工程中最复杂、也最精心设计的模式。它不是简单地列出“危险操作“,而是建立了一个完整的风险评估框架,教会模型“三思而后行“(measure twice, cut once)。
6.4.2 源码实例
实例 1:可逆性分析框架
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.
源码位置: restored-src/src/constants/prompts.ts:258
这段指令引入了两个关键维度:可逆性(reversibility) 和影响范围(blast radius)。这两个维度构成一个 2x2 决策矩阵:
quadrantChart
title 可逆性与影响范围的决策矩阵
x-axis "小影响范围" --> "大影响范围"
y-axis "不可逆 (hard to reverse)" --> "可逆 (reversible)"
quadrant-1 "告知后执行"
quadrant-2 "自由执行"
quadrant-3 "确认后执行"
quadrant-4 "强制确认"
"编辑文件": [0.25, 0.8]
"运行测试": [0.3, 0.85]
"创建临时分支": [0.7, 0.75]
"删除本地文件": [0.25, 0.25]
"force push": [0.8, 0.15]
"关闭 PR/issue": [0.75, 0.2]
图 6-1:可逆性与影响范围的决策矩阵。 Claude Code 通过这两个维度将操作分为四类,从“自由执行“到“强制确认“。
实例 2:高风险操作详尽列表
源码提供了四大类需要确认的操作,每一类都附有具体例子(restored-src/src/constants/prompts.ts:261-264):
| 风险类别 | 原文 | 具体操作示例 |
|---|---|---|
| 破坏性操作 | Destructive operations | 删除文件/分支、drop 数据库表、kill 进程、rm -rf、覆盖未提交更改 |
| 难以逆转的操作 | Hard-to-reverse operations | force push、git reset –hard、修改已发布的 commit、删除/降级依赖、修改 CI/CD 流水线 |
| 对他人可见的操作 | Actions visible to others | push 代码、创建/关闭/评论 PR 或 issue、发送消息(Slack/email/GitHub)、修改共享基础设施 |
| 第三方上传 | Uploading content to third-party tools | 上传到图表渲染器、pastebin、gist(可能被缓存或索引) |
实例 3:Git 安全协议
Git Safety Protocol:
- NEVER update the git config
- NEVER run destructive git commands (push --force, reset --hard, checkout .,
restore ., clean -f, branch -D) unless the user explicitly requests these
actions.
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user
explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- CRITICAL: Always create NEW commits rather than amending, unless the user
explicitly requests a git amend. When a pre-commit hook fails, the commit
did NOT happen — so --amend would modify the PREVIOUS commit, which may
result in destroying work or losing previous changes.
源码位置: restored-src/src/tools/BashTool/prompt.ts:87-94
Git 安全协议是可逆性意识模式的最精致实现。注意几个设计要点:
- NEVER 大写 – 不是“避免“或“尽量不要“,而是绝对禁止。大写字母在提示词中起到类似“提高注意力权重“的作用。
- “unless the user explicitly requests” – 每条 NEVER 规则都附带一个明确的豁免条件,避免模型在用户明确要求时仍然拒绝。
- CRITICAL 标记 + 因果解释 – 对于 amend vs. new commit 这个最微妙的规则,不仅标记了 CRITICAL,还解释了为什么这条规则存在(hook 失败时 commit 尚未发生,amend 会修改前一个 commit)。因果解释让模型能在新场景中泛化规则的精神,而不仅仅机械地遵循字面意思。
实例 4:一次授权不等于永久授权
A user approving an action (like a git push) once does NOT mean that they
approve it in all contexts, so unless actions are authorized in advance in
durable instructions like CLAUDE.md files, always confirm first.
Authorization stands for the scope specified, not beyond. Match the scope
of your actions to what was actually requested.
源码位置: restored-src/src/constants/prompts.ts:258
这条指令直击 LLM 的一个危险倾向:从单次许可推广到通用许可。模型在上下文中看到用户之前同意了 git push,可能会在后续的不同场景中不经确认就执行 push。这条规则通过“scope specified, not beyond“这个表述,建立了一个精确的授权作用域概念。
6.4.3 为什么有效
可逆性意识的有效性来自:
- 维度分析替代枚举。 与其列出所有危险操作(不可能穷举),不如教会模型使用“可逆性“和“影响范围“两个维度自主评估。具体的例子列表起辅助校准作用,而非完整覆盖。
- NEVER + unless 的精确豁免。 绝对禁止 + 明确例外的组合,避免了模型在模糊地带的“创造性解读“。
- 因果解释促进泛化。 解释“为什么“这条规则存在(如 amend 的因果链),让模型能在未见过的场景中推导出正确行为。
- “measure twice, cut once“的助记性。 文末的这个英语成语,作为整个框架的认知锚点,帮助模型在面对边界情况时回忆起整个风险评估协议。
6.4.4 可复用模板
[可逆性意识模板]
在执行操作前,评估其可逆性和影响范围。
你可以自由执行 {可逆的本地操作列表}。
对于 {不可逆/影响共享系统} 的操作,在执行前与用户确认。
绝对不要(NEVER):
- {危险操作 1},除非用户明确要求
- {危险操作 2},除非用户明确要求
- [关键] {最微妙的危险操作},因为 {因果解释}
用户一次批准 {操作} 不代表在所有上下文中批准。
授权仅限于指定的范围。
6.5 模式四:工具偏好引导(Tool Preference Steering)
6.5.1 模式定义
核心思想: 通过工具描述文本将模型的工具选择从通用 Bash 命令重定向到专用工具。
Claude Code 提供了丰富的专用工具(Read、Edit、Write、Glob、Grep),但模型的训练数据中充斥着 cat、grep、sed、find 等 Unix 命令。如果不加引导,模型会自然倾向于通过 Bash 工具执行这些命令。工具偏好引导模式在工具描述的最早位置插入重定向指令,截获模型的默认工具选择路径。
6.5.2 源码实例
实例 1:Bash 工具描述中的前置拦截
IMPORTANT: Avoid using this tool to run find, grep, cat, head, tail, sed,
awk, or echo commands, unless explicitly instructed or after you have
verified that a dedicated tool cannot accomplish your task. Instead, use the
appropriate dedicated tool as this will provide a much better experience for
the user:
- File search: Use Glob (NOT find or ls)
- Content search: Use Grep (NOT grep or rg)
- Read files: Use Read (NOT cat/head/tail)
- Edit files: Use Edit (NOT sed/awk)
- Write files: Use Write (NOT echo >/cat <<EOF)
- Communication: Output text directly (NOT echo/printf)
源码位置: restored-src/src/tools/BashTool/prompt.ts:280-291(由 getSimplePrompt() 拼装)
这段指令的设计有三层精巧之处:
- 位置优先。 这段文本出现在 Bash 工具描述的开头区域,紧跟工具的基本功能说明之后。当模型开始考虑调用 Bash 工具时,这段重定向指令是它首先遇到的约束。
- NOT 括号对照。 每条映射规则都同时列出“应该用什么“和“不应该用什么“。
Use Grep (NOT grep or rg)这种格式创造了一个直接的二选一对照,减少了模型的决策犹豫。 - 用户体验论证。 “this will provide a much better experience for the user” 给出了遵循此规则的理由,而非仅仅作为一个无条件命令。
实例 2:系统提示词中的冗余强化
Do NOT use the Bash to run commands when a relevant dedicated tool is
provided. Using dedicated tools allows the user to better understand and
review your work. This is CRITICAL to assisting the user:
- To read files use Read instead of cat, head, tail, or sed
- To edit files use Edit instead of sed or awk
- To create files use Write instead of cat with heredoc or echo redirection
- To search for files use Glob instead of find or ls
- To search the content of files, use Grep instead of grep or rg
- Reserve using the Bash exclusively for system commands and terminal
operations that require shell execution.
源码位置: restored-src/src/constants/prompts.ts:291-302
注意这段内容与 Bash 工具描述中的映射表几乎重复。这不是疏忽,而是有意为之的冗余强化(redundant reinforcement)。在系统提示词和工具描述两个位置都放置相同的指令,确保无论模型的注意力路径从哪里经过,都会遇到这个约束。
实例 3:嵌入式工具的条件适配
const embedded = hasEmbeddedSearchTools()
const toolPreferenceItems = [
...(embedded
? []
: [
`File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
`Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
]),
`Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`,
// ...
]
源码位置: restored-src/src/tools/BashTool/prompt.ts:280-291
当 Ant 内部构建版本(ant-native builds)将 find 和 grep 通过 shell alias 映射到嵌入式的 bfs 和 ugrep 时,指向 Glob/Grep 专用工具的重定向就变得不必要了。源码通过 hasEmbeddedSearchTools() 条件判断,在这种情况下跳过这两条映射。这种条件适配确保了提示词不会包含自相矛盾的指令。
6.5.3 为什么有效
工具偏好引导的有效性来自:
- 截获决策路径的最早点。 模型在选择工具时,首先读取工具描述。在 Bash 工具的描述中插入“不要用我做 X,去用 Y“,相当于在模型做出选择之前就进行了干预。
- 二选一对照消除歧义。
Use Grep (NOT grep or rg)的格式将一个开放性选择(“用哪个工具搜索”)转化为一个二元判断(“用 Grep 工具还是 grep 命令”),降低了决策复杂度。 - 冗余强化覆盖注意力盲区。 模型的注意力在长上下文中会衰减。在两个不同位置放置相同约束,提高了约束被“看见“的概率。
6.5.4 可复用模板
[工具偏好引导模板]
当需要 {操作类别} 时,使用 {专用工具名}(而非 {通用替代命令列表})。
使用专用工具可以 {用户体验收益}。
{通用工具名} 仅用于 {明确的合法用途列表}。
如果不确定,默认使用专用工具,只在 {回退条件} 时使用 {通用工具名}。
6.6 模式五:Agent 委托指引(Agent Delegation Protocol)
6.6.1 模式定义
核心思想: 为多 Agent 协作定义精确的委托规则,防止递归派生、上下文污染和结果捏造。
当一个 AI 系统可以派生子 Agent 时,新的失败模式随之出现:Agent 可能无限递归派生自己、可能窥探子 Agent 的中间输出(污染自己的上下文)、可能在子 Agent 返回结果前就编造结果。Agent 委托指引模式通过一组精确的规则来防止这些失败模式。
6.6.2 源码实例
实例 1:Fork vs. 全新 Agent 的选择框架
Fork yourself (omit subagent_type) when the intermediate tool output isn't
worth keeping in your context. The criterion is qualitative — "will I need
this output again" — not task size.
- Research: fork open-ended questions. If research can be broken into
independent questions, launch parallel forks in one message. A fork beats
a fresh subagent for this — it inherits context and shares your cache.
- Implementation: prefer to fork implementation work that requires more than
a couple of edits.
源码位置: restored-src/src/tools/AgentTool/prompt.ts:83-88
这段指令建立了 fork(继承上下文的分叉)和 fresh agent(全新 Agent)之间的选择标准。关键洞察是判断标准不是任务大小,而是“我以后还需要看这些输出吗“。这个定性标准虽然模糊,但配合下面两个具体场景(研究和实现),为模型提供了足够的锚点。
实例 2:“不要偷看” – 上下文卫生规则
Don't peek. The tool result includes an output_file path — do not Read or
tail it unless the user explicitly asks for a progress check. You get a
completion notification; trust it. Reading the transcript mid-flight pulls
the fork's tool noise into your context, which defeats the point of forking.
源码位置: restored-src/src/tools/AgentTool/prompt.ts:91
“Don’t peek”(不要偷看)可能是整个 Claude Code 提示词中最具创意的短语之一。它用两个日常词汇精确描述了一个复杂的技术约束:不要读取子 Agent 的中间输出文件。随后的解释给出了原因 – 这样做会将子 Agent 的工具噪声拉入主 Agent 的上下文,违背了 fork 的初衷(保持主上下文干净)。
这条规则对应的工程问题是:fork 子 Agent 的结果会写入一个文件,主 Agent 有能力通过 Read 工具读取这个文件。如果主 Agent 在子 Agent 完成前读取了中间结果,那些半成品的工具调用输出就会进入主 Agent 的上下文窗口,浪费宝贵的 token 预算。
实例 3:“不要竞争” – 结果捏造防护
Don't race. After launching, you know nothing about what the fork found.
Never fabricate or predict fork results in any format — not as prose,
summary, or structured output. The notification arrives as a user-role
message in a later turn; it is never something you write yourself. If the
user asks a follow-up before the notification lands, tell them the fork is
still running — give status, not a guess.
源码位置: restored-src/src/tools/AgentTool/prompt.ts:93
“Don’t race”(不要竞争)防止了一种微妙但危险的失败模式:主 Agent 在派出 fork 后,可能会“预测“fork 的结果并提前生成回复。这种行为在用户看来可能像是“聪明的预判“,但实际上是纯粹的幻觉 – 主 Agent 根本不知道 fork 发现了什么。
这段指令的设计格外严格:不仅禁止“编造结果“,还明确禁止了所有可能的变体形式 – “not as prose, summary, or structured output”。这种穷举式的格式禁止,是因为模型可能会尝试以不同的输出形式来规避字面上的禁止。
实例 4:Fork 子 Agent 的身份锚定
STOP. READ THIS FIRST.
You are a forked worker process. You are NOT the main agent.
RULES (non-negotiable):
1. Your system prompt says "default to forking." IGNORE IT — that's for the
parent. You ARE the fork. Do NOT spawn sub-agents; execute directly.
2. Do NOT converse, ask questions, or suggest next steps
3. Do NOT editorialize or add meta-commentary
...
6. Do NOT emit text between tool calls. Use tools silently, then report
once at the end.
源码位置: restored-src/src/tools/AgentTool/forkSubagent.ts:172-194
这是委托协议中最戏剧性的片段。fork 子 Agent 继承了父 Agent 的完整系统提示词,而父提示词中包含“default to forking“的指令。如果不加干预,子 Agent 会读到这条指令并尝试再次 fork – 造成无限递归。
解决方案是在 fork 子 Agent 的消息开头插入一个“身份覆盖“指令:先以全大写的 “STOP. READ THIS FIRST.” 抢占注意力,然后明确声明“You ARE the fork“,最后直接指出“Your system prompt says ‘default to forking.’ IGNORE IT“。这种“承认矛盾指令的存在并显式覆盖它“的技巧,比简单地希望模型忽略某段提示词要可靠得多。
6.6.3 为什么有效
Agent 委托指引的有效性来自:
- 拟人化动词建立直觉。 “Don’t peek“和“Don’t race“比“不要读取子 Agent 的输出文件“和“不要在收到通知前生成结果“更容易记忆和遵循。拟人化让抽象的技术约束变成了社交直觉。
- 穷举式格式禁止。 “not as prose, summary, or structured output” 封堵了模型可能的规避路径。
- 显式矛盾解决。 承认子 Agent 会看到父 Agent 的“fork“指令,然后显式覆盖,比假设模型会正确处理矛盾指令更可靠。
- 身份锚定 + 输出格式约束。 fork 子 Agent 的“STOP. READ THIS FIRST.“配合严格的输出格式(Scope: / Result: / Key files: / Files changed: / Issues:),将子 Agent 的行为限定在一个非常狭窄的通道中。
6.6.4 可复用模板
[Agent 委托指引模板]
## 何时 fork
当 {中间输出不值得保留在上下文中} 时,fork 自己。
判断标准是 {定性标准},而非 {常见误判标准}。
## fork 后的行为
- 不要偷看:{子 Agent} 的中间输出不要读取,等待完成通知。
原因:{上下文污染的具体后果}。
- 不要竞争:在 {子 Agent} 返回前,不要以任何形式
({格式列表})预测或编造其结果。
如果用户追问,回复 {状态信息},而非猜测。
## fork 子 Agent 的身份
你是一个 fork 工作进程,不是主 Agent。
父提示词中的 {可能导致递归的指令} 不适用于你 -- 直接执行,不要再委托。
6.7 模式六:数值锚定(Numeric Anchoring)
6.7.1 模式定义
核心思想: 用精确的数字替代模糊的定性描述,给模型一个可以直接对照的输出标尺。
“简洁一些”、“保持简短”、“不要太冗长” – 这类定性指令几乎没有约束力,因为模型对“简洁“的理解依赖于训练数据中的分布,而这个分布因领域和风格而异。数值锚定通过给出具体数字,将一个主观判断转化为一个可度量的约束。
6.7.2 源码实例
实例 1:工具调用间文字长度限制
Length limits: keep text between tool calls to ≤25 words. Keep final
responses to ≤100 words unless the task requires more detail.
源码位置: restored-src/src/constants/prompts.ts:534
这段指令目前仅对 Anthropic 内部用户(ant-only)启用,附有以下注释:
// Numeric length anchors — research shows ~1.2% output token reduction vs
// qualitative "be concise". Ant-only to measure quality impact first.
源码位置: restored-src/src/constants/prompts.ts:527-528
1.2% 的 output token 削减可能听起来不多,但考虑到 Claude Code 每天处理的请求量,这个百分比在成本节约上的绝对值相当可观。更重要的是,这 1.2% 是仅通过将“be concise“替换为“≤25 words“就获得的 – 零代码变更,纯提示词优化。
注意两个数值锚定的不同设计:
- ≤25 词(工具调用间):这是一个硬性约束,因为工具调用之间的文字通常是不必要的 – 模型应该直接调用下一个工具,而不是向用户解释自己在做什么。
- ≤100 词(最终响应):这带有一个豁免条件(“unless the task requires more detail”),因为最终响应的长度确实取决于任务复杂度。
实例 2:fork 子 Agent 的报告长度限制
8. Keep your report under 500 words unless the directive specifies otherwise.
Be factual and concise.
源码位置: restored-src/src/tools/AgentTool/forkSubagent.ts:186
fork 子 Agent 的 500 词限制服务于一个明确的工程目标:子 Agent 的报告会被注入到主 Agent 的上下文中,过长的报告会浪费主 Agent 的上下文窗口。500 词大约相当于 600-700 个 token,是一个在“提供足够信息“和“节约上下文空间“之间的平衡点。
实例 3:提交消息长度引导
Draft a concise (1-2 sentences) commit message that focuses on the "why"
rather than the "what"
源码位置: restored-src/src/tools/BashTool/prompt.ts:103
“1-2 sentences“是另一种数值锚定形式 – 不是词数,而是句数。这个锚定配合“focuses on the ‘why’ rather than the ‘what’“这个内容指引,同时约束了长度和质量。
6.7.3 ant-only 实验效果
数值锚定模式是 Claude Code 中少数有明确量化效果数据的提示词优化之一:
| 指标 | 定性指令(“be concise”) | 数值锚定(“≤25 words”) | 变化 |
|---|---|---|---|
| Output token 消耗 | 基线 | -1.2% | 下降 |
| 部署范围 | 全量 | ant-only | 灰度 |
| 代码变更量 | N/A | 0 行 | 纯提示词 |
| 质量影响 | 基线 | 待测量 | 未知 |
表 6-1:数值锚定的 ant-only 实验效果。 目前仅对内部用户启用,以便在扩大部署前测量对输出质量的影响。
ant-only 的灰度部署策略本身也值得关注。源码中的条件判断:
...(process.env.USER_TYPE === 'ant'
? [
systemPromptSection(
'numeric_length_anchors',
() => 'Length limits: keep text between tool calls to ≤25 words...',
),
]
: []),
这种模式在整个 Claude Code 提示词中反复出现:新的行为指令先对内部用户开放,收集数据后再决定是否推广到外部用户。这是提示词工程中的 A/B 测试方法论。
6.7.4 为什么有效
数值锚定的有效性来自:
- 消除主观解释空间。 “25 words“没有歧义,“concise“有。模型可以在生成每个 token 后计数,判断是否接近阈值。
- 锚定效应(Anchoring Effect)。 认知心理学研究表明,人类在进行数量估计时会被先前的数字锚定。LLM 的行为与此类似 – 提示词中出现的数字会成为输出长度的参考点。
- 硬约束 + 软豁免的组合。 “≤25 words“是硬约束,“unless the task requires more detail“是软豁免。这种组合让模型默认遵守数字限制,但在合理情况下可以突破。
6.7.5 可复用模板
[数值锚定模板]
长度限制:
- {输出类型 A} 保持在 ≤{N} 词/句/行以内。
- {输出类型 B} 保持在 ≤{M} 词/句/行以内,除非 {豁免条件}。
保持事实性和简洁性。
6.8 模式汇总
下表总结了本章提炼的 6 种行为引导模式,每种模式附有代表性的源码原文和可直接复用的提示词模板:
| # | 模式名 | 源码原文(代表性引用) | 可复用模板 |
|---|---|---|---|
| 1 | 极简主义指令 | “Three similar lines of code is better than a premature abstraction.” prompts.ts:203 | 不要在任务范围外添加{X}。{N}行重复代码优于过早抽象。只在{边界条件}时才{额外行动}。 |
| 2 | 渐进式升级 | “Don’t retry the identical action blindly, but don’t abandon a viable approach after a single failure either.” prompts.ts:233 | 当{操作}失败时,先{诊断}。不要盲目重试,也不要一次失败就放弃。只在{条件}时才{升级}。 |
| 3 | 可逆性意识 | “Carefully consider the reversibility and blast radius of actions… measure twice, cut once.” prompts.ts:258-266 | 评估操作的可逆性和影响范围。可逆本地操作自由执行;不可逆/共享操作确认后执行。NEVER{危险操作},unless 用户明确要求。 |
| 4 | 工具偏好引导 | “Use Grep (NOT grep or rg)” BashTool/prompt.ts:285 | 当需要{操作}时,使用{专用工具}(而非{通用命令})。在两个位置冗余放置映射表。 |
| 5 | Agent 委托指引 | “Don’t peek… Don’t race…” AgentTool/prompt.ts:91-93 | 不要偷看子Agent中间输出。不要在结果返回前以任何形式编造结果。fork子Agent显式声明身份,覆盖父提示词中的矛盾指令。 |
| 6 | 数值锚定 | “keep text between tool calls to ≤25 words” prompts.ts:534 | {输出类型}保持在≤{N}词以内。用精确数字替代“简洁“等定性描述。硬约束+软豁免组合。 |
表 6-2:6 种行为引导模式汇总。 每种模式都有明确的适用场景和可复用的模板结构。
6.9 跨模式的设计原则
回顾这 6 种模式,可以归纳出几个跨模式的底层设计原则:
原则一:反面定义优于正面描述。 “不要做 X“比“做 Y“更容易被模型遵循,因为禁止的边界比允许的边界更清晰。6 种模式中有 5 种大量使用了“Don’t”/“NEVER”/“NOT“等否定形式。
原则二:具体例子是抽象规则的校准器。 每个抽象规则(“考虑可逆性”)都配有具体的例子列表(“git reset –hard, push –force…”)。例子不是规则的替代品,而是规则的校准点 – 帮助模型理解规则的适用范围和颗粒度。
原则三:因果解释促进泛化。 当规则附带“因为…“的解释时(如 amend vs. new commit 的因果链),模型能在未见过的场景中推导出规则的精神。纯命令式的规则只能在训练分布内生效;因果解释让规则超越字面意思。
原则四:冗余是刻意的。 工具偏好引导在两个位置放置相同的映射表,可逆性意识在系统提示词和 Bash 工具描述中都定义了 Git 安全规则。这种冗余不是疏忽,而是对抗注意力衰减的工程手段。
原则五:灰度部署是提示词工程的一部分。 数值锚定的 ant-only 实验表明,提示词修改也需要 A/B 测试和灰度发布 – 就像代码变更一样。USER_TYPE === 'ant' 条件判断是这种方法论在代码中的体现。
6.10 用户能做什么
基于本章提炼的 6 种行为引导模式,以下是读者可以直接搬进自己提示词的实操建议:
-
用“不要做 X“替代“做 Y“。 审视你现有的提示词,将正面描述转化为反面约束。“生成简洁的代码“不如“不要添加超出要求的功能。bug 修复不需要清理周围代码。“具体的反面案例比抽象的正面目标更容易被模型遵循。
-
为失败场景定义三阶段协议。 如果你的 Agent 需要处理可能失败的操作(API 调用、命令执行、文件操作),在提示词中明确定义“诊断 -> 调整 -> 求助“的升级路径。同时禁止两个极端:盲目重试和一次失败就放弃。
-
用数字替代形容词。 将“保持简洁“替换为“≤25 词“或“1-2 句“。Claude Code 的数据显示,仅这一项改动就带来了 1.2% 的 output token 削减。在你自己的场景中,为每种输出类型设定具体的数量上限。
-
在工具描述中插入重定向表。 如果你的工具集中有一个“万能工具“(如 Bash),在其描述的最早位置列出“什么场景该用什么替代工具“的映射表。同时在专用工具的描述中声明排他性。双向闭环比单向约束有效得多。
-
为高风险操作建立可逆性评估框架。 不要简单地列出“危险操作清单“(不可能穷举),而是教会模型使用“可逆性“和“影响范围“两个维度自主评估。配合 NEVER + unless 的精确豁免结构,给模型一个可执行的决策框架。
-
先在小范围灰度验证。 新的行为指令先对一小部分用户或场景开放,收集效果数据后再推广。Claude Code 的
USER_TYPE === 'ant'灰度机制是一个可参考的模式 – 提示词修改也需要 A/B 测试。
6.11 小结
本章从 Claude Code 的源码中提炼了 6 种命名的行为引导模式。这些模式不是随意的措辞选择,而是经过实验验证的工程实践 – 从极简主义指令的“三行代码“锚点,到数值锚定的 1.2% token 削减,每种模式都有其明确的设计意图和可度量的效果。
这些模式的共同特征是精确性:用具体数字替代模糊形容词、用反面案例替代正面描述、用因果解释替代无条件命令。这种精确性不是偶然的 – 它反映了一个基本事实:大语言模型遵循指令的可靠性,与指令的精确度正相关。
下一章将转向运行时行为的观察与调试:当这些精心设计的提示词在实际对话中遇到意外情况时,系统如何检测、记录和应对。
第7章:模型特定调优与 A/B 测试
第6章探讨了系统提示词如何被组装为发送给模型的指令集。但同一份提示词并非适用于所有模型 – 每个模型世代都有独特的行为倾向,而 Anthropic 内部用户需要比外部用户更早地接触和验证新模型。本章将揭示 Claude Code 如何通过
@[MODEL LAUNCH]注解系统、USER_TYPE === 'ant'门控、GrowthBook Feature Flag 和 Undercover 模式,实现模型特定的提示词调优、内部 A/B 测试以及安全的公开仓库贡献。
7.1 模型发布检查清单:@[MODEL LAUNCH] 注解
在 Claude Code 的代码库中,散布着一种特殊的注释标记:
// @[MODEL LAUNCH]: Update the latest frontier model.
const FRONTIER_MODEL_NAME = 'Claude Opus 4.6'
源码参考: constants/prompts.ts:117-118
这些 @[MODEL LAUNCH] 注解不是普通注释。它们构成了一个分布式检查清单(distributed checklist) – 当新模型准备发布时,工程师只需在代码库中全局搜索 @[MODEL LAUNCH],就能找到所有需要更新的位置。这种设计将发布流程的知识嵌入到代码本身中,而非依赖外部文档。
在 prompts.ts 中,@[MODEL LAUNCH] 标注了以下关键更新点:
| 行号 | 内容 | 更新动作 |
|---|---|---|
| 117 | FRONTIER_MODEL_NAME 常量 | 更新为新模型的市场名称 |
| 120 | CLAUDE_4_5_OR_4_6_MODEL_IDS 对象 | 更新各层级模型 ID |
| 204 | 过度注释缓解指令 | 评估新模型是否仍需此缓解 |
| 210 | 彻底性反制权重 | 评估是否可解除 ant-only 门控 |
| 224 | 主动性反制权重 | 评估是否可解除 ant-only 门控 |
| 237 | 虚假声明缓解指令 | 评估新模型的 FC rate |
| 712 | getKnowledgeCutoff 函数 | 添加新模型的知识截止日期 |
在 antModels.ts 中:
| 行号 | 内容 | 更新动作 |
|---|---|---|
| 32 | tengu_ant_model_override | 更新 Feature Flag 中的 ant-only 模型列表 |
| 33 | excluded-strings.txt | 添加新模型代号防止泄露到外部构建 |
这种模式的妙处在于自文档化:注解的文本本身就是操作说明。例如第 204 行的注解明确说明了解除条件:“remove or soften once the model stops over-commenting by default”。工程师不需要查阅外部运维手册 – 条件和动作都写在代码旁边。
7.2 Capybara v8 行为缓解
每个模型世代都有其独特的“个性缺陷“。Claude Code 的源码记录了 Capybara v8(Claude 4.5/4.6 系列的内部代号之一)的四个已知问题,以及针对每个问题的提示词级缓解措施。
7.2.1 过度注释(Over-commenting)
问题: Capybara v8 倾向于在代码中添加大量不必要的注释。
缓解(第 204-209 行):
// @[MODEL LAUNCH]: Update comment writing for Capybara —
// remove or soften once the model stops over-commenting by default
...(process.env.USER_TYPE === 'ant'
? [
`Default to writing no comments. Only add one when the WHY is
non-obvious...`,
`Don't explain WHAT the code does, since well-named identifiers
already do that...`,
`Don't remove existing comments unless you're removing the code
they describe...`,
]
: []),
源码参考: constants/prompts.ts:204-209
这组指令构成了一个精细的评论哲学:默认不写注释,只在“为什么“不明显时添加;不解释代码做什么(标识符已经做了);不删除你不理解的已有注释。注意第三条指令的微妙之处 – 它既防止模型过度注释,又防止矫枉过正地删除有价值的已有注释。
7.2.2 虚假声明(False Claims)
问题: Capybara v8 的虚假声明率(False Claims rate)为 29-30%,显著高于 v4 的 16.7%。
缓解(第 237-241 行):
// @[MODEL LAUNCH]: False-claims mitigation for Capybara v8
// (29-30% FC rate vs v4's 16.7%)
...(process.env.USER_TYPE === 'ant'
? [
`Report outcomes faithfully: if tests fail, say so with the
relevant output; if you did not run a verification step, say
that rather than implying it succeeded. Never claim "all tests
pass" when output shows failures...`,
]
: []),
源码参考: constants/prompts.ts:237-241
这条缓解指令的设计体现了一种对称性思维:它不仅要求模型不要虚报成功,还明确要求不要过度自我怀疑 – “when a check did pass or a task is complete, state it plainly – do not hedge confirmed results with unnecessary disclaimers”。工程师们发现,简单地告诉模型“不要撒谎“会导致模型走向另一个极端,对所有结果都加上不必要的免责声明。缓解措施的目标是准确报告(accurate report),而非防御性报告(defensive report)。
7.2.3 主动性过强(Over-assertiveness)
问题: Capybara v8 倾向于单纯执行用户指令,不提出自己的判断。
缓解(第 224-228 行):
// @[MODEL LAUNCH]: capy v8 assertiveness counterweight (PR #24302)
// — un-gate once validated on external via A/B
...(process.env.USER_TYPE === 'ant'
? [
`If you notice the user's request is based on a misconception,
or spot a bug adjacent to what they asked about, say so.
You're a collaborator, not just an executor...`,
]
: []),
源码参考: constants/prompts.ts:224-228
注解中的 “PR #24302” 表明这个缓解措施是经过代码审查流程引入的,而 “un-gate once validated on external via A/B” 则揭示了完整的发布策略:先在内部用户(ant)上验证,收集数据后再通过 A/B 测试推广到外部用户。
7.2.4 彻底性不足(Lack of Thoroughness)
问题: Capybara v8 倾向于在未验证结果的情况下声称任务完成。
缓解(第 210-211 行):
// @[MODEL LAUNCH]: capy v8 thoroughness counterweight (PR #24302)
// — un-gate once validated on external via A/B
`Before reporting a task complete, verify it actually works: run the
test, execute the script, check the output. Minimum complexity means
no gold-plating, not skipping the finish line.`,
源码参考: constants/prompts.ts:210-211
这条指令的最后一句尤为精妙:“If you can’t verify (no test exists, can’t run the code), say so explicitly rather than claiming success.” 它承认了现实中存在无法验证的情况,但要求模型明确承认这一点,而非默默假装一切正常。
7.2.5 缓解措施的生命周期
四个缓解措施共享一个统一的生命周期模式:
flowchart LR
A["发现行为问题\n(FC rate 等)"] --> B["PR 引入缓解\n(PR #24302)"]
B --> C["ant-only 门控\n内部验证"]
C --> D["A/B 测试验证\n外部推广"]
D --> E{"新模型发布时\n@[MODEL LAUNCH]\n重新评估"}
E -->|"问题已修复"| F["移除缓解"]
E -->|"问题仍在"| G["保留/调整"]
E -->|"解除 ant-only"| H["全量推广"]
style A fill:#f9d,stroke:#333
style D fill:#9df,stroke:#333
style F fill:#dfd,stroke:#333
style H fill:#dfd,stroke:#333
图 7-1:模型缓解措施的完整生命周期。 从发现问题到引入缓解,经过内部验证和 A/B 测试,最终在下一个 @[MODEL LAUNCH] 时重新评估。
7.3 USER_TYPE === 'ant' 门控:内部 A/B 测试暂存区
前面四个缓解措施都被包裹在同一个条件中:
process.env.USER_TYPE === 'ant'
这个环境变量不是运行时读取的 – 它是一个构建时常量。源码中的注释解释了这个关键的编译器契约:
DCE: `process.env.USER_TYPE === 'ant'` is build-time --define.
It MUST be inlined at each callsite (not hoisted to a const) so the
bundler can constant-fold it to `false` in external builds and
eliminate the branch.
源码参考: constants/prompts.ts:617-619
这段注释揭示了一个精巧的死代码消除(DCE)机制:
- 构建时替换:打包工具(bundler)的
--define选项在编译时将process.env.USER_TYPE替换为字符串字面量。 - 常量折叠:对于外部构建,
'external' === 'ant'被折叠为false。 - 分支消除:条件为
false的分支被整个移除,包括其中的所有字符串内容。 - 内联要求:每个调用点必须直接写
process.env.USER_TYPE === 'ant',不能提取为变量,否则打包工具无法进行常量折叠。
这意味着外部用户的构建产物中物理上不存在任何 ant-only 代码。这不是运行时的权限检查,而是编译时的代码消除。即使反编译外部构建,也找不到 Capybara 这样的内部代号或缓解措施的具体措辞。
7.3.1 ant-only 门控完整清单
下表列出了 prompts.ts 中所有受 USER_TYPE === 'ant' 门控的内容:
| 行号范围 | 功能描述 | 门控内容 | 解除条件 |
|---|---|---|---|
| 136-139 | ant 模型覆盖段落 | getAntModelOverrideSection() – 向系统提示词追加 ant 专属后缀 | Feature Flag 控制,非固定条件 |
| 205-209 | 过度注释缓解 | 三条注释哲学指令 | 新模型不再默认过度注释 |
| 210-211 | 彻底性缓解 | 验证任务完成的指令 | 经 A/B 测试验证后推广到外部 |
| 225-228 | 主动性缓解 | 协作者而非执行者指令 | 经 A/B 测试验证后推广到外部 |
| 238-241 | 虚假声明缓解 | 准确报告结果的指令 | 新模型 FC rate 降低到可接受水平 |
| 243-246 | 内部反馈渠道 | /issue 和 /share 命令推荐,以及发送至内部 Slack 频道的建议 | 仅限内部用户,不会解除 |
| 621 | Undercover 模型描述压制 | 压制系统提示词中的模型名称和 ID | Undercover 模式激活时 |
| 660 | Undercover 简化模型描述压制 | 同上,简化提示词版本 | Undercover 模式激活时 |
| 694-702 | Undercover 模型家族信息压制 | 压制最新模型列表、Claude Code 平台信息、Fast 模式说明 | Undercover 模式激活时 |
表 7-1:prompts.ts 中的 ant-only 门控完整清单。 每个门控都有明确的解除条件,构成了从内部验证到外部推广的渐进式发布管道。
getAntModelOverrideSection(第 136-139 行)值得特别注意:
// function getAntModelOverrideSection(): string | null {
if (process.env.USER_TYPE !== 'ant') return null
if (isUndercover()) return null
return getAntModelOverrideConfig()?.defaultSystemPromptSuffix || null
}
它有双重门控 – 不仅要求是内部用户,还要求不在 Undercover 模式下。这种设计确保即使是内部用户,在向公开仓库贡献代码时也不会泄露内部模型配置。
7.4 Undercover 模式:公开仓库中的隐身术
Undercover 模式是 Claude Code 最独特的功能之一。它解决的问题很具体:Anthropic 内部工程师使用 Claude Code 向公开/开源仓库贡献代码时,不应泄露任何内部信息。
7.4.1 激活逻辑
// export function isUndercover(): boolean {
if (process.env.USER_TYPE === 'ant') {
if (isEnvTruthy(process.env.CLAUDE_CODE_UNDERCOVER)) return true
return getRepoClassCached() !== 'internal'
}
return false
}
源码参考: utils/undercover.ts:28-37
激活规则有三个层级:
- 强制开启:设置
CLAUDE_CODE_UNDERCOVER=1环境变量,即使在内部仓库中也强制激活。 - 自动检测:如果当前仓库的远程地址不在内部白名单中,自动激活。
'external'、'none'和null(检查尚未运行)都解析为“开启“。 - 无法强制关闭:设计上不提供强制关闭选项。源码注释解释了原因:“This guards against model codename leaks – if we’re not confident we’re in an internal repo, we stay undercover.”
这种“安全默认为开启“的设计哲学值得深思 – 宁可在内部仓库中多余地隐藏信息,也不冒在公开仓库中泄露的风险。
7.4.2 压制范围
Undercover 模式激活时,执行以下压制操作:
┌─────────────────────────────────────────────────────────────────────┐
│ Undercover 模式工作流程 │
│ │
│ ┌─────────────────┐ │
│ │ isUndercover() │──── false ────▶ 正常模式(显示模型信息) │
│ │ returns true │ │
│ └────────┬────────┘ │
│ │ true │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 压制层 1:系统提示词 │ │
│ │ │ │
│ │ computeEnvInfo (L621): │ │
│ │ modelDescription = '' ← 模型名称/ID 完全清空 │ │
│ │ │ │
│ │ computeSimpleEnvInfo (L660): │ │
│ │ modelDescription = null ← 从环境信息中移除 │ │
│ │ │ │
│ │ 模型家族列表 (L694-696): │ │
│ │ null ← Opus/Sonnet/Haiku ID 列表不出现 │ │
│ │ │ │
│ │ Claude Code 平台信息 (L697-699): │ │
│ │ null ← CLI/桌面/Web/IDE 信息不出现 │ │
│ │ │ │
│ │ Fast 模式说明 (L700-702): │ │
│ │ null ← 使用相同模型的说明不出现 │ │
│ │ │ │
│ │ getAntModelOverrideSection (L138): │ │
│ │ return null ← ant 模型覆盖后缀不注入 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 压制层 2:归因信息 │ │
│ │ │ │
│ │ getAttributionTexts(): │ │
│ │ return { commit: '', pr: '' } │ │
│ │ ← 不生成 Co-Authored-By 行 │ │
│ │ ← PR 描述中无 AI 工具归因 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 压制层 3:行为指令 │ │
│ │ │ │
│ │ getUndercoverInstructions(): │ │
│ │ 注入详细的反泄露指令: │ │
│ │ - 禁止内部模型代号(Capybara, Tengu 等) │ │
│ │ - 禁止未发布模型版本号 │ │
│ │ - 禁止内部仓库/项目名 │ │
│ │ - 禁止内部工具、Slack 频道、短链接 │ │
│ │ - 禁止 "Claude Code" 字样或 AI 身份暗示 │ │
│ │ - 禁止 Co-Authored-By 归因 │ │
│ │ - 要求像人类开发者一样撰写 commit message │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
图 7-2:Undercover 模式的三层压制工作流程。 从系统提示词到归因信息再到行为指令,形成完整的信息泄露防线。
源码中的注释(第 612-615 行)解释了为什么压制范围如此之广:
Undercover: keep ALL model names/IDs out of the system prompt so
nothing internal can leak into public commits/PRs. This includes the
public FRONTIER_MODEL_* constants — if those ever point at an
unannounced model, we don't want them in context. Go fully dark.
“Go fully dark” – 即使是公开的常量(如 FRONTIER_MODEL_NAME)也被压制,因为如果这些常量指向了一个尚未公布的模型,它们本身就成了泄露源。
7.4.3 Undercover 指令的示例
getUndercoverInstructions() 函数(utils/undercover.ts:39-69)注入了一段详细的反泄露指令。它用正面和反面示例教导模型:
好的 commit message:
- “Fix race condition in file watcher initialization”
- “Add support for custom key bindings”
绝不能写的 commit message:
- “Fix bug found while testing with Claude Capybara”
- “1-shotted by claude-opus-4-6”
- “Generated with Claude Code”
这种正反示例并列的教学方式比单纯的禁止清单更有效 – 它不仅告诉模型“不要做什么“,还示范了“应该做什么“。
7.4.4 自动通知机制
首次自动激活 Undercover 模式时,Claude Code 会显示一个一次性解释对话框(shouldShowUndercoverAutoNotice,第 80-88 行)。检查逻辑确保不会反复打扰用户:强制开启(通过环境变量)的用户不会看到通知(他们已经知道),已经看过通知的用户不会再看到。这个标志存储在全局配置的 hasSeenUndercoverAutoNotice 字段中。
7.5 GrowthBook 集成:tengu_* Feature Flag 体系
7.5.1 架构概述
Claude Code 使用 GrowthBook 作为其 Feature Flag 和实验平台。所有的 Feature Flag 遵循 tengu_* 命名约定 – “tengu” 是 Claude Code 的内部代号。
GrowthBook 客户端的初始化和特性值获取遵循一个精心设计的多层回退机制:
优先级(高到低):
1. 环境变量覆盖 (CLAUDE_INTERNAL_FC_OVERRIDES) — ant-only
2. 本地配置覆盖 (/config Gates 面板) — ant-only
3. 内存中的远程评估值 (remoteEvalFeatureValues)
4. 磁盘缓存 (cachedGrowthBookFeatures)
5. 默认值 (defaultValue 参数)
核心的值读取函数是 getFeatureValue_CACHED_MAY_BE_STALE(growthbook.ts:734-775)。如其名称所述,这个函数返回的值可能是过期的 – 它优先从内存或磁盘缓存读取,不会阻塞等待网络请求。这是一个有意的设计决策:在启动关键路径上,陈旧但可用的值好过等待网络而卡住的 UI。
// export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
feature: string,
defaultValue: T,
): T {
// 1. 环境变量覆盖
const overrides = getEnvOverrides()
if (overrides && feature in overrides) return overrides[feature] as T
// 2. 本地配置覆盖
const configOverrides = getConfigOverrides()
if (configOverrides && feature in configOverrides)
return configOverrides[feature] as T
// 3. 内存远程评估值
if (remoteEvalFeatureValues.has(feature))
return remoteEvalFeatureValues.get(feature) as T
// 4. 磁盘缓存
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature]
return cached !== undefined ? (cached as T) : defaultValue
}
源码参考: services/analytics/growthbook.ts:734-775
7.5.2 远程评估与本地缓存同步
GrowthBook 使用 remoteEval: true 模式 – 特性值在服务器端预评估,客户端只需缓存结果。processRemoteEvalPayload 函数(growthbook.ts:327-394)在每次初始化和定期刷新时运行,将服务器返回的预评估值写入两个存储:
- 内存 Map(
remoteEvalFeatureValues):用于进程生命周期内的快速读取。 - 磁盘缓存(
syncRemoteEvalToDisk,第 407-417 行):用于跨进程持久化。
磁盘缓存采用整体替换而非合并策略 – 服务器端删除的特性会从磁盘中清除。这保证了磁盘缓存始终是服务器状态的完整快照,而非不断累积的历史沿积。
源码注释(第 322-325 行)记录了一个曾经的故障:
Without this running on refresh, remoteEvalFeatureValues freezes at
its init-time snapshot and getDynamicConfig_BLOCKS_ON_INIT returns
stale values for the entire process lifetime — which broke the
tengu_max_version_config kill switch for long-running sessions.
这个 kill switch 故障说明了为什么定期刷新至关重要 – 如果只在初始化时读取一次,长时间运行的会话将无法响应紧急的远程配置变更。
7.5.3 实验曝光追踪
GrowthBook 的 A/B 测试功能依赖于实验曝光(exposure)追踪。logExposureForFeature 函数(第 296-314 行)在特性值被访问时记录曝光事件,用于后续的实验分析。关键设计:
- 会话级去重:
loggedExposuresSet 确保每个特性每次会话最多记录一次曝光,防止在热路径(如渲染循环)中频繁调用导致的重复事件。 - 延迟曝光:如果特性在 GrowthBook 初始化完成前被访问,
pendingExposuresSet 暂存这些访问,待初始化完成后补录。
7.5.4 已知的 tengu_* Feature Flag
从代码库中可以识别出以下 tengu_* Feature Flag:
| Flag 名称 | 用途 | 读取方式 |
|---|---|---|
tengu_ant_model_override | 配置 ant-only 模型列表、默认模型、系统提示词后缀 | _CACHED_MAY_BE_STALE |
tengu_1p_event_batch_config | 第一方事件批处理配置 | onGrowthBookRefresh |
tengu_event_sampling_config | 事件采样配置 | _CACHED_MAY_BE_STALE |
tengu_log_datadog_events | Datadog 事件日志门控 | _CACHED_MAY_BE_STALE |
tengu_max_version_config | 最大版本 kill switch | _BLOCKS_ON_INIT |
tengu_frond_boric | Sink 总开关(kill switch) | _CACHED_MAY_BE_STALE |
tengu_cobalt_frost | Nova 3 语音识别门控 | _CACHED_MAY_BE_STALE |
注意某些 Flag 使用了混淆命名(如 tengu_frond_boric),这是安全考量 – 即使 Flag 名称被外部观察到,也无法推断其用途。
7.5.5 环境变量覆盖:评估线束的后门
CLAUDE_INTERNAL_FC_OVERRIDES 环境变量(growthbook.ts:161-192)允许在不连接 GrowthBook 服务器的情况下覆盖任意 Feature Flag 值。这个机制专为评估线束(eval harness)设计 – 自动化测试需要在确定性条件下运行,不能依赖远程服务的状态。
// Example: CLAUDE_INTERNAL_FC_OVERRIDES='{"my_feature": true}'
覆盖优先级最高(高于磁盘缓存和远程评估值),且仅在 ant 构建中可用。这确保了评估线束的确定性,同时不会影响外部用户。
7.6 tengu_ant_model_override:模型热切换
tengu_ant_model_override 是所有 tengu_* Flag 中最复杂的一个。它通过 GrowthBook 远程配置 ant-only 模型的完整列表,支持运行时热切换,无需发布新版本。
7.6.1 配置结构
export type AntModelOverrideConfig = {
defaultModel?: string // 默认模型 ID
defaultModelEffortLevel?: EffortLevel // 默认 effort 级别
defaultSystemPromptSuffix?: string // 追加到系统提示词的后缀
antModels?: AntModel[] // 可用模型列表
switchCallout?: AntModelSwitchCalloutConfig // 切换提示配置
}
源码参考: utils/model/antModels.ts:24-30
每个 AntModel 包含别名(用于命令行选择)、模型 ID、显示标签、默认 effort 级别、上下文窗口大小等参数。switchCallout 允许在 UI 中向用户展示模型切换建议。
7.6.2 解析流程
resolveAntModel(antModels.ts:51-64)将用户输入的模型名称解析为具体的 AntModel 配置:
// export function resolveAntModel(
model: string | undefined,
): AntModel | undefined {
if (process.env.USER_TYPE !== 'ant') return undefined
if (model === undefined) return undefined
const lower = model.toLowerCase()
return getAntModels().find(
m => m.alias === model || lower.includes(m.model.toLowerCase()),
)
}
匹配逻辑同时支持精确的别名匹配和模糊的模型 ID 包含匹配。例如,如果用户指定 --model capybara-fast,别名匹配会找到对应的 AntModel;如果指定 --model claude-opus-4-6-capybara,模型 ID 包含匹配也能正确解析。
7.6.3 冷缓存启动问题
main.tsx 中的注释(第 2001-2014 行)记录了一个棘手的启动顺序问题:ant 模型别名通过 tengu_ant_model_override Feature Flag 解析,而 _CACHED_MAY_BE_STALE 在 GrowthBook 初始化完成前只能读取磁盘缓存。如果磁盘缓存为空(冷缓存),resolveAntModel 会返回 undefined,导致模型别名无法解析。
解决方案是在检测到 ant 用户指定了显式模型且磁盘缓存为空时,同步等待 GrowthBook 初始化完成:
if ('external' === 'ant' && explicitModel && ...) {
await initializeGrowthBook()
}
这是整个代码库中极少数 GrowthBook 调用需要阻塞等待的场景之一。
7.7 知识截止日期映射
getKnowledgeCutoff 函数(prompts.ts:712-730)维护了一个从模型 ID 到知识截止日期的映射表:
// function getKnowledgeCutoff(modelId: string): string | null {
const canonical = getCanonicalName(modelId)
if (canonical.includes('claude-sonnet-4-6')) return 'August 2025'
else if (canonical.includes('claude-opus-4-6')) return 'May 2025'
else if (canonical.includes('claude-opus-4-5')) return 'May 2025'
else if (canonical.includes('claude-haiku-4')) return 'February 2025'
else if (canonical.includes('claude-opus-4') ||
canonical.includes('claude-sonnet-4')) return 'January 2025'
return null
}
源码参考: constants/prompts.ts:712-730
这个函数使用 includes 而非精确匹配,使其对模型 ID 后缀(如日期标签 -20251001)具有鲁棒性。截止日期被注入系统提示词的环境信息段落中(第 635-638 行),让模型知道自己的知识边界:
const knowledgeCutoffMessage = cutoff
? `\n\nAssistant knowledge cutoff is ${cutoff}.`
: ''
当 Undercover 模式激活时,整个环境信息段落(包括知识截止日期)中的模型特定部分都被压制 – 但知识截止日期本身仍然保留,因为它不会泄露内部信息。
7.8 工程启示
渐进式发布的三段管道
Claude Code 的模型调优揭示了一个清晰的三段发布管道:
- 发现与引入:通过模型评估发现行为问题(如 29-30% FC rate),通过 PR 引入缓解措施。
- 内部验证:通过
USER_TYPE === 'ant'门控限制在内部用户中,收集真实使用数据。 - 渐进推广:通过 GrowthBook A/B 测试验证效果后,解除 ant-only 门控推广到所有用户。
编译时安全优于运行时检查
USER_TYPE 的构建时替换 + 死代码消除机制,确保了内部代码在外部构建中物理不存在,而非仅仅“不可访问“。这种编译时安全比运行时权限检查更强 – 没有代码意味着没有攻击面。
安全默认值的哲学
Undercover 模式的“无法强制关闭“设计、DANGEROUS_ 前缀的 API 摩擦、以及“冷缓存时阻塞等待“的启动逻辑,都体现了同一种哲学:当安全和便利冲突时,选择安全。这不是偏执 – 而是在“泄露内部模型信息“与“多等几百毫秒“之间做出的合理权衡。
Feature Flag 作为控制平面
tengu_* Feature Flag 体系将 Claude Code 从一个单一的软件产品转变为一个可远程控制的平台。通过 GrowthBook,工程师可以在不发布新版本的情况下:切换默认模型、调整事件采样率、启用/禁用实验功能、甚至通过 kill switch 紧急关闭有问题的功能。这种“控制平面与数据平面分离“的架构,是 SaaS 产品成熟度的标志。
7.9 用户能做什么
基于本章对模型特定调优和 A/B 测试体系的分析,以下是读者可以在自己的 AI Agent 项目中应用的建议:
-
在代码中嵌入分布式检查清单。 如果你的系统需要在模型升级时更新多个位置(模型名称、知识截止日期、行为缓解等),采用
@[MODEL LAUNCH]式的注解标记。在注解文本中直接写明更新动作和解除条件,让检查清单与代码共存,而非依赖外部文档。 -
为每个模型世代维护行为缓解档案。 当你发现新模型的某个行为倾向(如过度注释、虚假声明),通过提示词级缓解而非代码逻辑来修正。记录每个缓解措施的引入原因、FC rate 等量化指标、以及解除条件。这份档案在下一次模型升级时是无价的参考。
-
用构建时常量替代运行时检查来保护内部代码。 如果你的产品有内部版本和外部版本的区分,不要依赖运行时
if判断来隐藏内部功能。参考 Claude Code 的USER_TYPE+ 打包工具--define+ 死代码消除(DCE)机制,确保内部代码在外部构建中物理不存在。 -
建立 Feature Flag 体系实现提示词的远程控制。 将提示词中的实验性内容(新的行为指令、数值锚定等)通过 Feature Flag 门控,而非硬编码。这让你可以在不发布新版本的情况下调整模型行为、进行 A/B 测试、以及在紧急情况下通过 kill switch 回滚变更。
-
默认安全,而非默认便利。 当需要在安全和便利之间做选择时,参考 Undercover 模式的设计:安全模式默认开启、无法强制关闭、宁可误报也不漏报。对于 AI Agent 来说,信息泄露的代价远高于偶尔的多余限制。
第8章:工具提示词作为微型驾驭器
第5章解剖了系统提示词的宏观架构 – 段落注册、缓存分层、动态拼装。但系统提示词只是“顶层战略“。在每次工具调用的微观层面,还有一套平行的驾驭体系在运作:工具提示词(tool description / tool prompt)。它们作为
description字段注入 API 请求的tools数组,直接塑造模型对每个工具的使用方式。本章将逐一拆解 Claude Code 六大核心工具的提示词设计,揭示其中的引导策略与可复用模式。
8.1 工具提示词的驾驭本质
工具的 description 字段在 Anthropic API 中的定位是“告诉模型这个工具做什么“。但 Claude Code 将这个字段从简单的功能描述,扩展为一套完整的行为约束协议。每个工具的提示词实际上是一个微型驾驭器(micro-harness),包含:
- 功能描述:工具做什么
- 正面引导:应该怎么用
- 负面禁令:不能怎么用
- 条件分支:在特定场景下该怎么做
- 格式模板:输出应该长什么样
这种设计的核心洞察是:模型对每个工具的行为质量,直接受该工具提示词质量制约。系统提示词设定全局人格,工具提示词塑造局部行为。二者共同构成 Claude Code 的“双层驾驭架构“。
接下来我们按功能复杂度由高到低,逐一分析六个工具。
8.2 BashTool:最复杂的微型驾驭器
BashTool 是 Claude Code 中提示词最长、约束最密集的工具。它的提示词由 getSimplePrompt() 函数动态生成,最终可达数千字。
源码位置: tools/BashTool/prompt.ts:275-369
8.2.1 工具偏好矩阵:把流量导向专用工具
提示词的第一部分就建立了一个明确的工具偏好矩阵:
IMPORTANT: Avoid using this tool to run find, grep, cat, head, tail,
sed, awk, or echo commands, unless explicitly instructed or after you
have verified that a dedicated tool cannot accomplish your task.
紧接着是一张映射表(第281-291行):
const toolPreferenceItems = [
`File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
`Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
`Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`,
`Edit files: Use ${FILE_EDIT_TOOL_NAME} (NOT sed/awk)`,
`Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)`,
'Communication: Output text directly (NOT echo/printf)',
]
这个设计体现了一个重要的驾驭模式:流量导向(traffic steering)。Bash 是一个“万能工具“ – 理论上可以完成文件读写、搜索、编辑等所有操作。但让模型通过 Bash 完成这些操作会带来两个问题:
- 用户体验差:专用工具(如 FileEditTool)有结构化输入、可视化 diff、权限检查等能力,Bash 命令则是不透明的字符串。
- 权限控制失效:专用工具有细粒度权限校验,Bash 命令绕过了这些检查。
注意第276-278行的条件分支:当系统检测到嵌入式搜索工具(hasEmbeddedSearchTools())时,find 和 grep 从禁用列表中移除。这是为 Anthropic 内部构建版本(ant-native builds)做的适配 – 这些构建将 find/grep 别名为嵌入式 bfs/ugrep,同时移除了独立的 Glob/Grep 工具。
可复用模式 – “万能工具降级”: 当你的工具集中存在一个功能覆盖面极广的工具时,在其提示词中显式列出“什么场景应该用什么替代工具“,避免模型过度依赖单一工具。
8.2.2 命令执行指南:从超时到并发
提示词的第二部分是一套详细的命令执行规范(第331-352行),涵盖:
- 目录验证:“If your command will create new directories or files, first use this tool to run
lsto verify the parent directory exists” - 路径引用:“Always quote file paths that contain spaces with double quotes”
- 工作目录保持:“Try to maintain your current working directory throughout the session by using absolute paths”
- 超时控制:默认 120,000ms(2分钟),最大 600,000ms(10分钟)
- 后台执行:
run_in_background参数,带明确的使用条件
其中最精巧的是多命令并发指南(第297-303行):
const multipleCommandsSubitems = [
`If the commands are independent and can run in parallel, make multiple
${BASH_TOOL_NAME} tool calls in a single message.`,
`If the commands depend on each other and must run sequentially, use
a single ${BASH_TOOL_NAME} call with '&&' to chain them together.`,
"Use ';' only when you need to run commands sequentially but don't
care if earlier commands fail.",
'DO NOT use newlines to separate commands.',
]
这不是简单的“最佳实践建议“,而是一套并发决策树:独立任务用并行工具调用 -> 有依赖用 && -> 允许失败用 ; -> 禁止用换行符。每条规则都对应一个具体的故障模式。
8.2.3 Git 安全协议:深度防御
Git 操作是 BashTool 提示词中最重要的安全领域。完整的 Git 安全协议定义在 getCommitAndPRInstructions() 函数中(第42-161行),其核心禁令列表(第88-95行)构成了一道六层防线:
Git Safety Protocol:
- NEVER update the git config
- NEVER run destructive git commands (push --force, reset --hard,
checkout ., restore ., clean -f, branch -D) unless the user
explicitly requests these actions
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the
user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- CRITICAL: Always create NEW commits rather than amending
- When staging files, prefer adding specific files by name rather
than using "git add -A" or "git add ."
- NEVER commit changes unless the user explicitly asks you to
每一条禁令都对应一个真实的数据丢失场景:
| 禁令 | 防御的故障场景 |
|---|---|
| NEVER update git config | 模型可能修改用户的全局 Git 配置 |
| NEVER push –force | 覆盖远程仓库的提交历史 |
| NEVER skip hooks | 绕过代码质量检查、签名验证 |
| NEVER force push to main | 破坏团队共享分支 |
| Always create NEW commits | pre-commit hook 失败后 amend 会修改上一个提交 |
| Prefer specific files | git add . 可能暴露 .env、credentials |
| NEVER commit unless asked | 避免 agent 过度自主 |
“CRITICAL” 标记被保留给最微妙的场景:pre-commit hook 失败后的 --amend 陷阱。这条规则需要理解 Git 的内部机制 – hook 失败意味着 commit 没有发生,此时 --amend 会修改的是上一个已存在的提交,而不是“重试当前提交“。
提示词还包含完整的 commit 工作流模板(第96-125行),用编号步骤明确指定哪些操作可以并行、哪些必须串行,甚至提供了 HEREDOC 格式的 commit message 模板。这是一种**工作流脚手架(workflow scaffolding)**模式 – 不是告诉模型“做什么“,而是告诉它“按什么顺序做“。
8.2.4 沙箱配置的 JSON 内联
当沙箱(sandbox)启用时,getSimpleSandboxSection() 函数(第172-273行)会将完整的沙箱配置以 JSON 格式内联到提示词中:
const filesystemConfig = {
read: {
denyOnly: dedup(fsReadConfig.denyOnly),
allowWithinDeny: dedup(fsReadConfig.allowWithinDeny),
},
write: {
allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly),
denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow),
},
}
源码参考: tools/BashTool/prompt.ts:195-203
这是一个值得深思的设计决策:将机器可读的安全策略直接暴露给模型。模型需要“理解“自己可以访问哪些路径、可以连接哪些网络主机,才能在生成命令时主动避免违规。JSON 格式保证了信息的精确性和无歧义性。
注意第167-170行的 dedup 函数和第188-191行的 normalizeAllowOnly:前者去除重复路径(因为 SandboxManager 合并多层配置时不去重),后者将用户特定的临时目录路径替换为 $TMPDIR 占位符。这两个优化分别节省了 ~150-200 token 和保证了跨用户的 prompt 缓存一致性。
可复用模式 – “策略透明化”: 当安全策略需要模型配合执行时,将策略的完整规则集以结构化格式(JSON/YAML)内联到提示词中,让模型在生成阶段就能自检合规性。
8.2.5 sleep 反模式抑制
提示词专门用一个小节(第310-327行)来抑制 sleep 的滥用:
const sleepSubitems = [
'Do not sleep between commands that can run immediately — just run them.',
'If your command is long running... use `run_in_background`.',
'Do not retry failing commands in a sleep loop — diagnose the root cause.',
'If waiting for a background task... do not poll.',
'If you must sleep, keep the duration short (1-5 seconds)...',
]
这是一个典型的**反模式抑制(anti-pattern suppression)**策略。LLM 在代码生成场景中倾向于使用 sleep + 轮询来处理异步等待,因为这是训练数据中最常见的模式。提示词通过逐一列举替代方案(后台执行、事件通知、诊断根因)来“覆写“这个默认行为。
8.3 FileEditTool:“编辑前必须先读取“的强制机制
FileEditTool 的提示词相比 BashTool 精简得多,但每一句都承载着关键的工程约束。
源码位置: tools/FileEditTool/prompt.ts:1-28
8.3.1 前置读取强制
提示词的第一条规则(第4-6行):
// function getPreReadInstruction(): string {
return `You must use your \`${FILE_READ_TOOL_NAME}\` tool at least once
in the conversation before editing. This tool will error if you
attempt an edit without reading the file.`
}
这不是一个“建议“,而是一个硬性约束 – 工具的运行时实现会检查对话历史中是否存在对该文件的 Read 调用,没有则直接返回错误。提示词中的说明是为了让模型提前知道这个约束,避免浪费一次工具调用。
这个设计解决了一个核心问题:模型幻觉(hallucination)。如果模型不先读取文件就尝试编辑,它对文件内容的假设可能完全错误。强制先读取保证了编辑操作基于真实的文件状态,而不是模型对文件内容的“记忆“或“猜测“。
可复用模式 – “前置条件强制”: 当工具 B 的正确性依赖于工具 A 的先行调用时,在 B 的提示词中声明这个依赖关系,并在 B 的运行时中强制检查。双重保障 – 提示词层防止浪费调用,运行时层兜底防止错误操作。
8.3.2 最小唯一 old_string
提示词对 old_string 参数的要求(第20-27行)体现了精妙的平衡:
- The edit will FAIL if `old_string` is not unique in the file. Either
provide a larger string with more surrounding context to make it unique
or use `replace_all` to change every instance of `old_string`.
对于 Anthropic 内部用户(USER_TYPE === 'ant'),还有一条额外的优化指引(第17-19行):
const minimalUniquenessHint =
process.env.USER_TYPE === 'ant'
? `Use the smallest old_string that's clearly unique — usually 2-4
adjacent lines is sufficient. Avoid including 10+ lines of context
when less uniquely identifies the target.`
: ''
这揭示了一个token 经济学问题:模型在使用 FileEditTool 时,需要在 old_string 参数中提供要替换的原文。如果模型习惯性地包含大段上下文来“确保唯一性“,每次编辑操作的 token 消耗就会急剧膨胀。“2-4 行“的指导让模型在唯一性和简洁性之间找到甜点。
8.3.3 缩进保持与行号前缀
提示词中最容易被忽视但最关键的技术细节(第13-16行,第23行):
const prefixFormat = isCompactLinePrefixEnabled()
? 'line number + tab'
: 'spaces + line number + arrow'
// 在描述中:
`When editing text from Read tool output, ensure you preserve the exact
indentation (tabs/spaces) as it appears AFTER the line number prefix.
The line number prefix format is: ${prefixFormat}. Everything after that
is the actual file content to match. Never include any part of the line
number prefix in the old_string or new_string.`
Read 工具返回的内容带有行号前缀(如 42 →),模型需要在编辑时剥离这个前缀,只提取实际的文件内容作为 old_string。这是 Read 工具与 Edit 工具之间的接口契约 – 提示词承担了“接口文档“的角色。
可复用模式 – “工具间接口声明”: 当两个工具的输出/输入存在格式转换关系时,在下游工具的提示词中显式描述上游工具的输出格式,避免模型在格式转换中出错。
8.4 FileReadTool:资源感知的读取策略
FileReadTool 的提示词看似简单,实则包含了精心设计的资源管理策略。
源码位置: tools/FileReadTool/prompt.ts:1-49
8.4.1 2000 行默认限制
export const MAX_LINES_TO_READ = 2000
// 在提示词模板中:
`By default, it reads up to ${MAX_LINES_TO_READ} lines starting from
the beginning of the file`
源码参考: tools/FileReadTool/prompt.ts:10,37
2000 行是一个经过权衡的数字。Anthropic 的模型有 200K token 的上下文窗口,但上下文越大,注意力分散越严重、推理成本越高。2000 行大约对应 8000-16000 个 token(取决于代码密度),占上下文窗口的 4-8%。这个预算足够覆盖绝大多数单文件场景,同时为多文件操作留出空间。
8.4.2 offset/limit 的渐进式引导
提示词对 offset/limit 参数提供了两种措辞模式(第17-21行):
export const OFFSET_INSTRUCTION_DEFAULT =
"You can optionally specify a line offset and limit (especially handy
for long files), but it's recommended to read the whole file by not
providing these parameters"
export const OFFSET_INSTRUCTION_TARGETED =
'When you already know which part of the file you need, only read
that part. This can be important for larger files.'
两种模式服务于不同的使用阶段:
- DEFAULT 模式鼓励完整读取 – 适用于模型首次接触文件时,需要全局理解。
- TARGETED 模式鼓励精准读取 – 适用于模型已经知道目标位置时,节省 token 预算。
哪种模式被使用取决于运行时上下文(由 FileReadTool 调用方决定),但提示词预先定义了两种“引导语气“,让模型在不同场景下展现不同的读取行为。
8.4.3 多媒体能力声明
提示词用一系列声明式语句扩展了 Read 工具的能力边界(第40-48行):
- This tool allows Claude Code to read images (eg PNG, JPG, etc).
When reading an image file the contents are presented visually
as Claude Code is a multimodal LLM.
- This tool can read PDF files (.pdf). For large PDFs (more than 10
pages), you MUST provide the pages parameter to read specific page
ranges. Maximum 20 pages per request.
- This tool can read Jupyter notebooks (.ipynb files) and returns all
cells with their outputs.
PDF 的分页限制(“more than 10 pages…MUST provide the pages parameter”)是一个渐进式资源限制:小文件直接读取,大文件强制分页。这比“所有文件都必须分页“或“不限制分页“都更合理 – 前者增加不必要的工具调用轮次,后者可能一次性注入过多内容。
注意 PDF 支持是条件性的(第41行):isPDFSupported() 检查运行时环境是否支持 PDF 解析。不支持时,整个 PDF 说明段落从提示词中消失。这避免了“提示词承诺了运行时无法兑现的能力“这一常见陷阱。
可复用模式 – “能力声明与运行时对齐”: 工具提示词中的能力描述应该由运行时能力动态决定。如果某个功能在特定环境下不可用,不要在提示词中提及它 – 这会导致模型尝试使用不存在的功能,产生困惑和浪费。
8.5 GrepTool:“始终用 Grep 不用 bash grep”
GrepTool 的提示词精简到极致,但每一行都是硬约束。
源码位置: tools/GrepTool/prompt.ts:1-18
8.5.1 排他性声明
提示词的第一条使用规则(第10行):
ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a
Bash command. The Grep tool has been optimized for correct permissions
and access.
这是与 BashTool 的工具偏好矩阵双向配合的设计:BashTool 说“不要用 bash 做搜索“,GrepTool 说“搜索必须用我“。两个方向的约束形成闭环,最大程度降低模型“走错路“的概率。
“has been optimized for correct permissions and access” 给出了理由,而非仅仅发出禁令。理由很重要 – GrepTool 底层调用的是相同的 ripgrep,但包裹了权限检查(checkReadPermissionForTool,GrepTool.ts:233-239)、忽略模式应用(getFileReadIgnorePatterns,GrepTool.ts:413-427)和版本控制目录排除(VCS_DIRECTORIES_TO_EXCLUDE,GrepTool.ts:95-102)。通过 Bash 直接调用 rg 会绕过这些安全层。
8.5.2 ripgrep 语法提示
提示词提供了三条关键的语法差异说明(第11-16行):
- Supports full regex syntax (e.g., "log.*Error", "function\s+\w+")
- Pattern syntax: Uses ripgrep (not grep) - literal braces need
escaping (use `interface\{\}` to find `interface{}` in Go code)
- Multiline matching: By default patterns match within single lines only.
For cross-line patterns like `struct \{[\s\S]*?field`, use
`multiline: true`
第一条明确了语法家族(ripgrep 的 Rust regex),第二条给出了最常见的陷阱(大括号需要转义 – 这与 GNU grep 不同),第三条解释了 multiline 参数的使用场景。
从代码实现看,multiline: true 对应的 ripgrep 参数是 -U --multiline-dotall(GrepTool.ts:341-343)。提示词选择用“使用场景 + 示例“来解释这个功能,而不是暴露底层参数细节 – 模型不需要知道 -U 是什么,只需要知道什么时候设置 multiline: true。
8.5.3 输出模式与 head_limit
GrepTool 的输入 schema(GrepTool.ts:33-89)定义了丰富的参数,但提示词中只简要提及三种输出模式:
Output modes: "content" shows matching lines, "files_with_matches"
shows only file paths (default), "count" shows match counts
而 head_limit 参数的设计(GrepTool.ts:81,107)尤其值得关注:
const DEFAULT_HEAD_LIMIT = 250
// 在 schema 描述中:
'Defaults to 250 when unspecified. Pass 0 for unlimited
(use sparingly — large result sets waste context).'
默认 250 条结果上限是一个上下文保护机制 – 注释中说明(第104-108行),不受限的 content 模式搜索可能填满 20KB 的工具结果持久化阈值。“use sparingly” 的措辞给模型一个温和的警告,而 0 作为“无限制“的逃生舱口保留了灵活性。
可复用模式 – “安全默认值 + 逃生舱口”: 为可能产生大量输出的工具设置保守的默认限制,同时提供一个显式的方式来解除限制。在提示词中说明两者的存在和适用场景。
8.6 AgentTool:动态 agent 列表与 fork 指引
AgentTool 是六个工具中提示词生成逻辑最复杂的,因为它需要根据运行时状态(可用的 agent 定义、是否启用 fork、是否为 coordinator 模式、订阅类型)动态组合内容。
源码位置: tools/AgentTool/prompt.ts:1-287
8.6.1 内联 vs 附件:agent 列表的两种注入方式
提示词中的 agent 列表可以通过两种方式注入(第58-64行,第196-199行):
// export function shouldInjectAgentListInMessages(): boolean {
if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES))
return false
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false)
}
方式一(内联): agent 列表直接嵌入工具描述。
`Available agent types and the tools they have access to:
${effectiveAgents.map(agent => formatAgentLine(agent)).join('\n')}`
方式二(附件): 工具描述只包含静态文本 “Available agent types are listed in <system-reminder> messages in the conversation”,实际列表通过 agent_listing_delta 附件消息单独注入。
源码注释(第50-57行)解释了动机:动态 agent 列表占了全局 cache_creation token 的约 10.2%。每当 MCP 服务器异步连接、插件重载、或权限模式变化时,agent 列表就会变化,导致包含列表的工具 schema 全部失效,触发昂贵的缓存重建。将列表移到附件消息中,工具描述变为静态文本,从而保护了工具 schema 层的 prompt 缓存。
每个 agent 的描述格式(第43-46行):
// export function formatAgentLine(agent: AgentDefinition): string {
const toolsDescription = getToolsDescription(agent)
return `- ${agent.agentType}: ${agent.whenToUse} (Tools: ${toolsDescription})`
}
getToolsDescription 函数(第15-37行)处理了工具白名单和黑名单的交叉过滤,最终生成如 “All tools except Bash, Agent” 或 “Read, Grep, Glob” 这样的描述。这让模型知道每个 agent 类型能用什么工具,从而做出合理的委派决策。
可复用模式 – “动态内容外移”: 当工具提示词中的某个部分频繁变化且对缓存影响大时,将其从工具 description 移到消息流中(如附件、system-reminder),保持工具描述的稳定性。
8.6.2 Fork 子代理:继承上下文的轻量委派
当 isForkSubagentEnabled() 为 true 时,提示词增加一个“When to fork“段落(第81-96行),引导模型在两种委派模式间选择:
- Fork(省略
subagent_type):继承父代理的完整对话上下文,适合研究型和实现型任务。 - Fresh agent(指定
subagent_type):从零开始,需要完整的上下文传递。
Fork 的使用指南包含三条核心纪律:
Don't peek. The tool result includes an output_file path — do not
Read or tail it unless the user explicitly asks for a progress check.
Don't race. After launching, you know nothing about what the fork found.
Never fabricate or predict fork results in any format.
Writing a fork prompt. Since the fork inherits your context, the prompt
is a directive — what to do, not what the situation is.
“Don’t peek” 防止父代理读取 fork 的中间输出,这会把 fork 的工具噪音拉入父代理的上下文,违背了 fork 的初衷。“Don’t race” 防止父代理在结果返回前“猜测“fork 的结论 – 这是 LLM 的一个已知倾向。
8.6.3 Prompt 写作指南:防止浅层委派
提示词中最独特的部分是一段“如何写好 agent prompt“的指南(第99-113行):
Brief the agent like a smart colleague who just walked into the room —
it hasn't seen this conversation, doesn't know what you've tried,
doesn't understand why this task matters.
...
**Never delegate understanding.** Don't write "based on your findings,
fix the bug" or "based on the research, implement it." Those phrases
push synthesis onto the agent instead of doing it yourself.
“Never delegate understanding” 是一条深刻的元认知约束。它防止模型将需要综合判断的思考工作甩给子代理 – 子代理应该是执行者,不是决策者。这条规则将“理解“锚定在父代理身上,确保工作流中的知识不会丢失。
可复用模式 – “委派质量保障”: 当工具涉及向子系统传递任务时,在提示词中约束任务描述的质量标准,防止模型生成模糊、不完整的委派指令。
8.7 SkillTool:预算约束与三级截断
SkillTool 的独特之处在于它不仅驾驭模型的行为,还管理自身提示词的体积。
源码位置: tools/SkillTool/prompt.ts:1-242
8.7.1 1% 上下文窗口预算
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k * 4
源码参考: tools/SkillTool/prompt.ts:21-23
技能列表的总字符预算被硬性限制为上下文窗口的 1%。对于 200K token 的上下文窗口,这是 200K * 4 chars/token * 1% = 8000 字符。这个预算约束确保了技能发现功能不会侵蚀模型的工作上下文 – 技能列表是“目录“,不是“内容“,模型只需要看到足够的信息来决定是否调用某个技能,实际的技能内容在调用时才加载。
8.7.2 三级截断策略
formatCommandsWithinBudget 函数(第70-171行)实现了一套渐进式截断策略:
第一级:完整保留。 如果所有技能的完整描述加起来不超过预算,全部保留。
if (fullTotal <= budget) {
return fullEntries.map(e => e.full).join('\n')
}
第二级:描述裁剪。 超预算时,将非内置技能(non-bundled)的描述裁剪到平均可用长度。内置技能(bundled)始终保留完整描述。
const maxDescLen = Math.floor(availableForDescs / restCommands.length)
// ...
return `- ${cmd.name}: ${truncate(description, maxDescLen)}`
第三级:仅保留名称。 如果裁剪后的平均描述长度小于 20 字符(MIN_DESC_LENGTH),非内置技能退化为仅显示名称。
if (maxDescLen < MIN_DESC_LENGTH) {
return commands
.map((cmd, i) =>
bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`,
)
.join('\n')
}
这套三级策略的优先级是:内置技能 > 非内置技能的描述 > 非内置技能的名称。内置技能作为 Claude Code 的核心功能,永远不会被截断。第三方插件技能则按需降级,确保在任何规模的技能生态中都能控制 token 成本。
8.7.3 单条目硬上限
除了总预算外,每个技能条目还有独立的硬上限(第29行):
export const MAX_LISTING_DESC_CHARS = 250
getCommandDescription 函数(第43-49行)在总预算截断之前,先对每个条目进行 250 字符的预截断:
// function getCommandDescription(cmd: Command): string {
const desc = cmd.whenToUse
? `${cmd.description} - ${cmd.whenToUse}`
: cmd.description
return desc.length > MAX_LISTING_DESC_CHARS
? desc.slice(0, MAX_LISTING_DESC_CHARS - 1) + '\u2026'
: desc
}
注释解释了原因:技能列表是发现(discovery) 用途,不是使用(usage) 用途。冗长的 whenToUse 字符串浪费的是 turn-1 的 cache_creation token,对技能匹配率没有提升。
8.7.4 调用协议
SkillTool 的核心提示词(第173-196行)相对简短,但包含一条关键的阻塞性要求:
When a skill matches the user's request, this is a BLOCKING REQUIREMENT:
invoke the relevant Skill tool BEFORE generating any other response
about the task
“BLOCKING REQUIREMENT” 是 Claude Code 提示词体系中最强的约束措辞之一。它要求模型在识别到匹配技能时,立即调用 Skill 工具,不得先生成文本回复。这防止了一个常见的反模式:模型先输出一段分析文字,然后才调用技能 – 这段文字往往与技能加载后的实际指令冲突。
另一条防御性规则(第194行):
`If you see a <${COMMAND_NAME_TAG}> tag in the current conversation turn,
the skill has ALREADY been loaded - follow the instructions directly
instead of calling this tool again`
这防止了重复加载:如果技能已经通过 <command-name> 标签注入到当前轮次,模型不应该再次调用 SkillTool,而应该直接执行技能指令。
可复用模式 – “预算感知的目录生成”: 当工具需要向模型呈现一个动态增长的列表(插件、技能、API 端点等)时,为列表分配固定的 token 预算,并实现多级降级策略。优先保留高价值条目的完整性,低优先级条目渐进退化。
8.8 六工具对比总结
下表从五个维度对比六个工具的提示词设计:
| 维度 | BashTool | FileEditTool | FileReadTool | GrepTool | AgentTool | SkillTool |
|---|---|---|---|---|---|---|
| 提示词长度 | 极长(数千字,含 Git 协议) | 短(~30行) | 中等(~50行) | 极短(~18行) | 长(~280行,含示例) | 中等(~200行,含截断逻辑) |
| 生成方式 | 动态拼接(沙箱配置、Git 指令、嵌入式工具检测) | 半动态(行号前缀格式、用户类型条件) | 半动态(PDF 支持条件、offset 模式切换) | 静态模板 | 高度动态(agent 列表、fork 开关、coordinator 模式、订阅类型) | 动态预算裁剪(三级截断) |
| 核心驾驭策略 | 流量导向 + 安全协议 + 工作流脚手架 | 前置条件强制 + 接口契约 | 资源感知的渐进限制 | 排他性声明 + 语法纠偏 | 委派质量保障 + 缓存保护 | 预算约束 + 优先级降级 |
| 安全机制 | Git 六层防线、沙箱 JSON 内联、反模式抑制 | 编辑前必须读取(运行时强制) | 行数限制、PDF 分页限制 | 权限检查、VCS 目录排除、结果上限 | fork 纪律(Don’t peek/race)、委派质量 | BLOCKING REQUIREMENT、重复加载防护 |
| 可复用模式 | 万能工具降级、策略透明化 | 前置条件强制、工具间接口声明 | 能力声明与运行时对齐 | 安全默认值 + 逃生舱口 | 动态内容外移、委派质量保障 | 预算感知的目录生成 |
block-beta
columns 2
block:behavior["行为约束型"]:1
BT1["BashTool ← 安全协议"]
ET1["EditTool ← 前置条件"]
GT1["GrepTool ← 排他声明"]
block:resource["资源管理型"]:1
SK1["SkillTool ← 预算截断"]
RT1["ReadTool ← 行数/页数限制"]
GT2["GrepTool ← head_limit"]
block:collab["协作编排型"]:1
AT1["AgentTool ← 委派指南"]
BT2["BashTool ← 并发决策树"]
ET2["EditTool ← 接口契约"]
block:cache["缓存优化型"]:1
AT2["AgentTool ← 列表外移"]
BT3["BashTool ← $TMPDIR 归一化"]
SK2["SkillTool ← 描述裁剪"]
图 8-1:工具提示词驾驭模式的四象限分布。 每个工具通常横跨多个象限 – BashTool 同时具备行为约束、协作编排和缓存优化特征;GrepTool 兼具行为约束和资源管理。
8.9 设计工具提示词的七条原则
从六个工具的分析中,我们可以提炼出一套通用的工具提示词设计原则:
-
双向闭环:当工具 A 不应处理某类任务时,同时在 A 中说“不要做 X,用 B“,在 B 中说“做 X 必须用我“。单向约束留有漏洞。
-
理由先于禁令:每条 “NEVER” 后面跟一个 “because”。模型在理解原因后更不容易违反约束。GrepTool 的 “has been optimized for correct permissions” 比单纯的 “NEVER use bash grep” 更有效。
-
能力与运行时对齐:提示词声明的能力必须由运行时保证。FileReadTool 的 PDF 支持根据
isPDFSupported()条件注入,而不是无条件声明。 -
安全默认值 + 逃生舱口:为所有可能产生大量输出或副作用的参数设置保守默认值,同时提供显式的解除方式。GrepTool 的
head_limit=250/0是典型案例。 -
预算意识:工具提示词本身消耗 token。SkillTool 的 1% 预算约束和三级截断是极端但正确的做法。BashTool 的
$TMPDIR归一化和dedup则是更微妙的优化。 -
前置条件声明:如果工具的正确使用依赖于特定前提(先读取文件、先检查目录),在提示词中声明,在运行时中强制。双重保障优于单层防御。
-
委派质量标准:当工具涉及向子系统传递任务时,约束任务描述的完整性和具体性。AgentTool 的 “Never delegate understanding” 防止了知识在委派链中丢失。
8.10 用户能做什么
基于本章对六大工具提示词的分析,以下是读者在设计自己的工具提示词时可以直接应用的建议:
-
为“万能工具“建立流量导向表。 如果你的工具集中有一个功能覆盖面极广的工具(如 Bash、通用 API 调用器),在其描述的最前面放置一张“场景 -> 专用工具“的映射表。同时在每个专用工具中声明排他性。这种双向闭环是防止模型过度依赖单一工具的最有效手段。
-
强制工具间的前置条件。 当工具 B 的正确性依赖于工具 A 的先行调用时(如“编辑前必须先读取“),在 B 的提示词中声明这个依赖,并在 B 的运行时中用代码强制检查。提示词层防止浪费调用,运行时层兜底防止错误操作 – 双层防御优于单层。
-
将安全策略以 JSON 内联到提示词。 如果模型需要“理解“自己的权限边界(可访问的路径、可连接的主机等),将完整的策略规则集以结构化格式注入提示词。这让模型在生成阶段就能自检合规性,而非依赖运行时拒绝后的重试。
-
为大输出工具设置保守默认值。 对所有可能产生大量输出的工具参数(搜索结果数、文件行数、PDF 页数),设置保守的默认限制。同时提供显式的“解除限制“选项(如
head_limit=0),并在提示词中说明“谨慎使用“。 -
控制工具描述本身的 token 成本。 参考 SkillTool 的 1% 上下文窗口预算和三级截断策略。当你的工具集不断增长时,工具描述的总 token 开销也在增长。为工具描述分配固定预算,优先保留核心工具的完整性,边缘工具渐进退化。
-
用动态条件控制能力声明。 不要在提示词中声明运行时不一定能兑现的能力。参考 FileReadTool 的
isPDFSupported()条件检查 – 如果 PDF 解析不可用,就不要在提示词中提及 PDF 支持。提示词承诺了运行时无法兑现的能力,会导致模型反复尝试失败,浪费上下文窗口。
8.11 小结
工具提示词是 Claude Code 驾驭体系中最“接地“的层级。系统提示词设定人格,工具提示词塑造动作。六个工具的提示词设计展示了一个核心规律:优秀的工具提示词不是功能文档,而是行为契约。它不仅告诉模型“这个工具能做什么“,更告诉它“在什么条件下用这个工具“、“怎样用才安全”、“什么时候该用其他工具”。
下一章我们将从单个工具的微观驾驭上升到工具协作的宏观编排 – 探索工具之间如何通过权限系统、状态传递和并发控制形成一个协调的整体。
第9章:自动压缩 — 上下文何时以及如何被压缩
“The best compression is the one the user never notices.”
每一个 Claude Code 的长会话用户都经历过这个时刻:你正在让模型逐步重构一个复杂模块,突然你注意到模型的回答变得“健忘“——它忘记了五分钟前你明确要求保留的接口签名,或者重复建议你已经否决过的方案。这不是模型变笨了,而是上下文窗口满了,自动压缩刚刚发生。
压缩(compaction)是 Claude Code 上下文管理的核心机制。它决定了你的对话历史在什么时刻、以什么方式被浓缩为一份摘要。理解这个机制,意味着你可以预测它何时触发、控制它保留什么、以及在它“出错“时知道该怎么做。
本章将从源码层面完整拆解自动压缩的三个阶段:阈值判定(何时触发)、摘要生成(如何压缩)、失败恢复(出错怎么办)。
9.1 阈值计算:何时触发自动压缩
9.1.1 核心公式
自动压缩的触发条件可以用一个简单的不等式表达:
当前 token 数 >= autoCompactThreshold
而 autoCompactThreshold 的计算涉及三个常量和两层减法。让我们从源码中逐步推导。
第一层:有效上下文窗口
// services/compact/autoCompact.ts:30
const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
// services/compact/autoCompact.ts:33-48
// export function getEffectiveContextWindowSize(model: string): number {
const reservedTokensForSummary = Math.min(
getMaxOutputTokensForModel(model),
MAX_OUTPUT_TOKENS_FOR_SUMMARY,
)
let contextWindow = getContextWindowForModel(model, getSdkBetas())
const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
if (autoCompactWindow) {
const parsed = parseInt(autoCompactWindow, 10)
if (!isNaN(parsed) && parsed > 0) {
contextWindow = Math.min(contextWindow, parsed)
}
}
return contextWindow - reservedTokensForSummary
}
这里的逻辑是:从模型的原始上下文窗口中扣除一块“压缩输出预留区“。MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000 这个值来自 p99.99 的实际压缩输出统计——99.99% 的压缩摘要都在 17,387 tokens 以内,20K 是带安全余量的上界。
注意 Math.min(getMaxOutputTokensForModel(model), MAX_OUTPUT_TOKENS_FOR_SUMMARY) 这个取小值操作:如果某个模型的最大输出上限本身就低于 20K(比如某些 Bedrock 配置),则使用模型自身的上限。
第二层:自动压缩缓冲区
// services/compact/autoCompact.ts:62
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
// services/compact/autoCompact.ts:72-91
// export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
const autocompactThreshold =
effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
if (envPercent) {
const parsed = parseFloat(envPercent)
if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
const percentageThreshold = Math.floor(
effectiveContextWindow * (parsed / 100),
)
return Math.min(percentageThreshold, autocompactThreshold)
}
}
return autocompactThreshold
}
AUTOCOMPACT_BUFFER_TOKENS = 13_000 是一个额外的安全缓冲——它确保在阈值触发到实际执行压缩之间,还有足够的空间容纳当前轮次可能产生的额外 tokens(工具调用结果、系统消息等)。
9.1.2 阈值计算公式表
以 Claude Sonnet 4 (200K 上下文窗口) 为例:
| 计算步骤 | 公式 | 值 |
|---|---|---|
| 原始上下文窗口 | contextWindow | 200,000 |
| 压缩输出预留 | MAX_OUTPUT_TOKENS_FOR_SUMMARY | 20,000 |
| 有效上下文窗口 | contextWindow - 20,000 | 180,000 |
| 自动压缩缓冲 | AUTOCOMPACT_BUFFER_TOKENS | 13,000 |
| 自动压缩阈值 | effectiveWindow - 13,000 | 167,000 |
| 警告阈值 | autoCompactThreshold - 20,000 | 147,000 |
| 错误阈值 | autoCompactThreshold - 20,000 | 147,000 |
| 阻塞硬限制 | effectiveWindow - 3,000 | 177,000 |
用更直观的方式表达:
|<------------ 200K 上下文窗口 ------------>|
|<---- 167K 可用 ---->|<- 13K 缓冲 ->|<- 20K 压缩输出预留 ->|
^ ^
自动压缩触发点 有效窗口边界
这意味着在默认配置下,当你的对话消耗了约 83.5% 的上下文窗口时,自动压缩就会触发。
9.1.3 环境变量覆盖
Claude Code 提供了两个环境变量让用户(或测试环境)覆盖默认阈值:
CLAUDE_CODE_AUTO_COMPACT_WINDOW — 覆盖上下文窗口大小
// services/compact/autoCompact.ts:40-46
const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
if (autoCompactWindow) {
const parsed = parseInt(autoCompactWindow, 10)
if (!isNaN(parsed) && parsed > 0) {
contextWindow = Math.min(contextWindow, parsed)
}
}
这个变量取的是 Math.min(实际窗口, 设置值)——你只能缩小窗口,不能扩大。典型用例:在 CI 环境中设置一个较小的窗口值,强制更频繁地触发压缩以测试其稳定性。
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE — 按百分比覆盖阈值
// services/compact/autoCompact.ts:79-87
const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
if (envPercent) {
const parsed = parseFloat(envPercent)
if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
const percentageThreshold = Math.floor(
effectiveContextWindow * (parsed / 100),
)
return Math.min(percentageThreshold, autocompactThreshold)
}
}
例如设置 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=50,阈值就变成有效窗口的 50%(90,000 tokens),但同样取 Math.min——这个覆盖值不能高于默认阈值,只能让压缩更早触发。
9.1.4 完整判定流程
shouldAutoCompact() 函数(autoCompact.ts:160-239)在比较 token 数之前,还有一系列前置守卫:
shouldAutoCompact(messages, model, querySource)
│
├─ querySource 是 'session_memory' 或 'compact'? → false(防递归)
├─ querySource 是 'marble_origami'(ctx-agent)? → false(防共享状态污染)
├─ isAutoCompactEnabled() 返回 false? → false
│ ├─ DISABLE_COMPACT 环境变量为 truthy? → false
│ ├─ DISABLE_AUTO_COMPACT 环境变量为 truthy? → false
│ └─ 用户配置 autoCompactEnabled = false? → false
├─ REACTIVE_COMPACT 实验模式激活? → false(让 reactive compact 接管)
├─ Context Collapse 激活? → false(collapse 拥有自己的上下文管理)
│
└─ tokenCount >= autoCompactThreshold? → true/false
注意源码中对 Context Collapse 的详细注释(autoCompact.ts:199-222):autocompact 在有效窗口的约 93% 处触发,而 Context Collapse 在 90% 开始提交、95% 执行阻塞——如果两者同时运行,autocompact 会“抢跑“并销毁 Collapse 正准备保存的细粒度上下文。因此当 Collapse 开启时,主动式 autocompact 被禁用,只保留 reactive compact 作为 413 错误的兜底。
9.2 熔断器:连续失败保护
9.2.1 问题背景
在理想情况下,压缩完成后上下文会显著缩小,下一轮就不再触发。但现实中存在一类“不可恢复“的场景:上下文中包含大量不可压缩的系统消息、附件或编码数据,压缩后的结果仍然超过阈值,导致下一轮立刻再次触发压缩——形成无限循环。
源码注释记录了一个真实的规模数据(autoCompact.ts:68-69):
BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls/day globally.
1,279 个会话中,有会话连续失败了 3,272 次,全局每天浪费约 25 万次 API 调用。这不是边缘情况——这是一个需要硬性保护的系统性问题。
9.2.2 熔断器实现
// services/compact/autoCompact.ts:70
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
熔断器的逻辑极其简洁——整个机制不到 20 行代码:
// services/compact/autoCompact.ts:257-265
if (
tracking?.consecutiveFailures !== undefined &&
tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
) {
return { wasCompacted: false }
}
状态追踪通过 AutoCompactTrackingState 类型在 queryLoop 的迭代之间传递:
// services/compact/autoCompact.ts:51-60
export type AutoCompactTrackingState = {
compacted: boolean
turnCounter: number
turnId: string
consecutiveFailures?: number // 熔断器计数器
}
- 成功时(
autoCompact.ts:332):consecutiveFailures重置为 0 - 失败时(
autoCompact.ts:341-349):递增计数,达到 3 次后记录警告日志并不再尝试 - 熔断后:该会话后续所有轮次的 autocompact 请求直接返回
{ wasCompacted: false }
这个设计体现了一个重要原则:宁可让用户手动执行 /compact,也不要用注定失败的重试浪费 API 预算。熔断器只阻止自动压缩,用户仍然可以通过 /compact 命令手动触发。
9.3 压缩提示词剖析:9 段模板
当阈值触发后,Claude Code 需要向模型发送一条特殊的提示词,要求它将整个对话浓缩为一份结构化摘要。这个提示词的设计是压缩质量的关键——它直接决定了摘要中保留了什么、丢失了什么。
9.3.1 三种提示词变体
源码中定义了三种压缩提示词变体,分别对应不同的压缩场景:
| 变体 | 常量名 | 使用场景 | 摘要范围 |
|---|---|---|---|
| BASE | BASE_COMPACT_PROMPT | 完整压缩(手动 /compact 或首次自动压缩) | 整个对话 |
| PARTIAL | PARTIAL_COMPACT_PROMPT | 部分压缩(保留早期上下文,只压缩新消息) | 最近的消息(保留边界之后) |
| PARTIAL_UP_TO | PARTIAL_COMPACT_UP_TO_PROMPT | 前缀压缩(cache hit 优化路径) | 摘要之前的对话部分 |
三者的核心区别在于摘要的“视野范围“:
- BASE 告诉模型:“Your task is to create a detailed summary of the conversation so far”——总结全部
- PARTIAL 告诉模型:“Your task is to create a detailed summary of the RECENT portion of the conversation — the messages that follow earlier retained context”——只总结新增部分
- PARTIAL_UP_TO 告诉模型:“This summary will be placed at the start of a continuing session; newer messages that build on this context will follow after your summary”——总结前缀,为后续消息提供上下文
9.3.2 模板结构分析
以 BASE_COMPACT_PROMPT 为例(prompt.ts:61-143),整个提示词由 9 个结构化段落组成。下面逐段分析其设计意图:
| 段落 | 标题 | 设计意图 | 关键指令 |
|---|---|---|---|
| 1 | Primary Request and Intent | 捕获用户的显式请求,防止压缩后“跑题“ | “Capture all of the user’s explicit requests and intents in detail” |
| 2 | Key Technical Concepts | 保留技术决策的语境锚点 | 列出所有讨论过的技术、框架和概念 |
| 3 | Files and Code Sections | 保留文件和代码的精确上下文 | “Include full code snippets where applicable” —— 注意是 full code snippets,不是摘要 |
| 4 | Errors and fixes | 保留调试历史,防止重复犯错 | “Pay special attention to specific user feedback” |
| 5 | Problem Solving | 保留问题解决过程,不只是结果 | “Document problems solved and any ongoing troubleshooting efforts” |
| 6 | All user messages | 保留所有用户消息(非工具结果) | “List ALL user messages that are not tool results” —— ALL 大写强调 |
| 7 | Pending Tasks | 保留未完成任务列表 | 只列出显式被要求的任务 |
| 8 | Current Work | 保留当前工作的精确状态 | “Describe in detail precisely what was being worked on immediately before this summary request” |
| 9 | Optional Next Step | 保留下一步行动(带防护条件) | “ensure that this step is DIRECTLY in line with the user’s most recent explicit requests” |
9.3.3 <analysis> 草稿块:隐藏的质量保证机制
在 9 段摘要之前,模板要求模型先生成一个 <analysis> 块:
// prompt.ts:31-44
const DETAILED_ANALYSIS_INSTRUCTION_BASE = `Before providing your final summary,
wrap your analysis in <analysis> tags to organize your thoughts and ensure
you've covered all necessary points. In your analysis process:
1. Chronologically analyze each message and section of the conversation.
For each section thoroughly identify:
- The user's explicit requests and intents
- Your approach to addressing the user's requests
- Key decisions, technical concepts and code patterns
- Specific details like:
- file names
- full code snippets
- function signatures
- file edits
- Errors that you ran into and how you fixed them
- Pay special attention to specific user feedback...
2. Double-check for technical accuracy and completeness...`
这个 <analysis> 块是一个草稿空间(drafting scratchpad)——模型在生成最终摘要之前,先按时间顺序遍历整个对话。关键词是“Chronologically analyze each message“,这迫使模型按序处理而不是跳着总结,减少遗漏。
但这个草稿块不会出现在最终上下文中。formatCompactSummary() 函数(prompt.ts:311-335)会将其完全剥离:
// prompt.ts:316-319
formattedSummary = formattedSummary.replace(
/<analysis>[\s\S]*?<\/analysis>/,
'',
)
这是一个巧妙的“思维链“(chain-of-thought)应用:利用 <analysis> 块提升摘要质量,但不让它消耗压缩后的上下文空间。草稿块的 tokens 只在压缩 API 调用的输出中产生,不会成为后续对话的上下文负担。
9.3.4 NO_TOOLS_PREAMBLE:防止工具调用
所有三种变体在最前面都会注入一段“禁止工具调用“的强硬前言:
// prompt.ts:19-26
const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
`
并且在结尾还有一个呼应的 trailer(prompt.ts:269-272):
const NO_TOOLS_TRAILER =
'\n\nREMINDER: Do NOT call any tools. Respond with plain text only — ' +
'an <analysis> block followed by a <summary> block. ' +
'Tool calls will be rejected and you will fail the task.'
源码注释解释了为什么需要如此“激进“的禁令(prompt.ts:12-18):压缩请求使用 maxTurns: 1 执行(只允许一轮响应),如果模型在这一轮中尝试了工具调用,工具调用会被拒绝,导致没有文本输出——整个压缩失败,回退到流式后备路径(streaming fallback),在 Sonnet 4.6 上该问题的发生率达到 2.79%。首尾双重禁令将这个问题压缩到可忽略的水平。
9.3.5 PARTIAL 变体的差异
PARTIAL_COMPACT_PROMPT 和 BASE_COMPACT_PROMPT 的主要差异在于:
- 视野限定:“Focus your summary on what was discussed, learned, and accomplished in the recent messages only”
- 分析指令:
DETAILED_ANALYSIS_INSTRUCTION_PARTIAL用 “Analyze the recent messages chronologically” 替换了 BASE 版本的 “Chronologically analyze each message and section of the conversation”
PARTIAL_COMPACT_UP_TO_PROMPT 更为特殊——它的第 8 段从 “Current Work” 变成了 “Work Completed”,第 9 段从 “Optional Next Step” 变成了 “Context for Continuing Work”。这是因为 UP_TO 模式下,模型看到的只是对话的前半段(后半段会作为保留消息原样追加),所以摘要需要为“接续者“提供上下文而不是规划下一步。
9.4 压缩执行流程
9.4.1 compactConversation() 主流程
compactConversation() 函数(compact.ts:387-704)是压缩的核心编排器。其主流程可以概括为:
flowchart TD
A[开始压缩] --> B[执行 PreCompact Hooks]
B --> C[构建压缩提示词]
C --> D[发送压缩请求]
D --> E{响应是否为<br />prompt_too_long?}
E -->|是| F[PTL 重试循环]
E -->|否| G{摘要是否有效?}
F --> D
G -->|否| H[抛出错误]
G -->|是| I[清除文件状态缓存]
I --> J[并行生成附件:<br />文件/计划/技能/工具/MCP]
J --> K[执行 SessionStart Hooks]
K --> L[构建 CompactionResult]
L --> M[记录遥测事件]
M --> N[返回结果]
几个值得注意的细节:
预清除与后恢复(compact.ts:518-561):压缩完成后,代码首先清空 readFileState 缓存和 loadedNestedMemoryPaths,然后通过 createPostCompactFileAttachments() 恢复最重要的文件上下文。这是一个“先忘后想起“的策略——与其在摘要中保留所有文件内容(不可靠),不如压缩后重新读取最关键的几个文件(确定性高)。文件恢复预算:最多 5 个文件,总计 50,000 tokens,单文件上限 5,000 tokens。
附件重新注入(compact.ts:566-585):压缩吃掉了之前的 delta 附件(延迟工具声明、agent 列表、MCP 指令)。代码在压缩后以“空消息历史“为基线重新生成这些附件,确保模型在压缩后的第一轮就拥有完整的工具和指令上下文。
9.4.2 压缩后的消息结构
压缩产生的 CompactionResult 通过 buildPostCompactMessages() 组装为最终消息数组(compact.ts:330-338):
[boundaryMarker, ...summaryMessages, ...messagesToKeep, ...attachments, ...hookResults]
其中:
boundaryMarker:一个SystemCompactBoundaryMessage,标记压缩发生的位置summaryMessages:用户消息格式的摘要,包含getCompactUserSummaryMessage()生成的前言(“This session is being continued from a previous conversation that ran out of context”)messagesToKeep:部分压缩时保留的最近消息attachments:文件、计划、技能、工具等附件hookResults:SessionStart hooks 的结果
9.5 PTL 重试:当压缩本身也太长
9.5.1 问题场景
这是一个“递归“难题:你的对话太长需要压缩,但压缩请求本身也超过了 API 的输入限制(prompt_too_long)。在极长会话(比如消耗了 190K+ tokens 的会话)中,将整个对话历史发送给压缩模型时,压缩请求的输入 tokens 可能已经逼近甚至超过上下文窗口。
9.5.2 重试机制
truncateHeadForPTLRetry() 函数(compact.ts:243-291)实现了一个“丢弃最旧内容“的重试策略:
flowchart TD
A[压缩请求] --> B{响应以<br />PROMPT_TOO_LONG<br />开头?}
B -->|否| C[压缩成功]
B -->|是| D{ptlAttempts <= 3?}
D -->|否| E[抛出错误:<br />Conversation too long]
D -->|是| F[truncateHeadForPTLRetry]
F --> G[解析 tokenGap]
G --> H{tokenGap<br />可解析?}
H -->|是| I[按 tokenGap<br />丢弃最旧的<br />API 轮次组]
H -->|否| J[回退: 丢弃<br />20% 的轮次组]
I --> K{至少保留<br />1 个组?}
J --> K
K -->|否| L[返回 null → 失败]
K -->|是| M[prepend PTL_RETRY_MARKER]
M --> N[用截断后的消息<br />重新发送压缩请求]
N --> B
核心逻辑分三步:
步骤 1:按 API 轮次分组
// compact.ts:257
const groups = groupMessagesByApiRound(input)
groupMessagesByApiRound()(grouping.ts:22-60)将消息按 API 轮次边界分组——每当出现一个新的 assistant 消息 ID 时,就开始一个新组。这确保了丢弃操作不会拆散一个 tool_use 和它对应的 tool_result。
步骤 2:计算丢弃数量
// compact.ts:260-272
const tokenGap = getPromptTooLongTokenGap(ptlResponse)
let dropCount: number
if (tokenGap !== undefined) {
let acc = 0
dropCount = 0
for (const g of groups) {
acc += roughTokenCountEstimationForMessages(g)
dropCount++
if (acc >= tokenGap) break
}
} else {
dropCount = Math.max(1, Math.floor(groups.length * 0.2))
}
如果 API 的 prompt_too_long 响应中包含了具体的 token 差额(tokenGap),代码会精确地从最旧的组开始累加,直到覆盖这个差额。如果差额不可解析(某些 Vertex/Bedrock 错误格式不同),则回退到丢弃 20% 的组——一个保守但有效的启发式方法。
步骤 3:修复消息序列
// compact.ts:278-291
const sliced = groups.slice(dropCount).flat()
if (sliced[0]?.type === 'assistant') {
return [
createUserMessage({ content: PTL_RETRY_MARKER, isMeta: true }),
...sliced,
]
}
return sliced
丢弃最旧的组后,剩余消息的第一条可能是 assistant 消息(因为原始对话的 user 前言被分在了组 0 中被丢弃了)。API 要求第一条消息必须是 user 角色,所以代码会插入一个合成的 user 标记消息 PTL_RETRY_MARKER。
9.5.3 防止标记累积
注意 truncateHeadForPTLRetry() 开头的一个精妙处理(compact.ts:250-255):
const input =
messages[0]?.type === 'user' &&
messages[0].isMeta &&
messages[0].message.content === PTL_RETRY_MARKER
? messages.slice(1)
: messages
在进行分组之前,如果消息序列的第一条是上一次重试插入的 PTL_RETRY_MARKER,代码会先将其剥离。否则这个标记会被分到组 0 中,而 20% 回退策略可能“只丢弃这个标记“——零进展,第二次重试陷入死循环。
9.5.4 重试上限与缓存穿透
// compact.ts:227
const MAX_PTL_RETRIES = 3
最多重试 3 次。每次重试不仅截断消息,还更新 cacheSafeParams(compact.ts:487-490)以确保 forked-agent 路径也使用截断后的消息:
retryCacheSafeParams = {
...retryCacheSafeParams,
forkContextMessages: truncated,
}
如果 3 次重试后仍然失败,抛出 ERROR_MESSAGE_PROMPT_TOO_LONG 错误,用户会看到提示:“Conversation too long. Press esc twice to go up a few messages and try again.”
9.6 autoCompactIfNeeded() 的完整编排
将上述所有机制串联起来,autoCompactIfNeeded()(autoCompact.ts:241-351)是 queryLoop 在每轮迭代中调用的入口。它的完整流程如下:
flowchart TD
A["queryLoop 每轮迭代"] --> B{"DISABLE_COMPACT?"}
B -->|是| Z["返回 wasCompacted: false"]
B -->|否| C{"consecutiveFailures >= 3?<br />(熔断器)"}
C -->|是| Z
C -->|否| D["shouldAutoCompact()"]
D -->|不需要| Z
D -->|需要| E["尝试 Session Memory 压缩"]
E -->|成功| F["清理 + 返回结果"]
E -->|失败/不适用| G["compactConversation()"]
G -->|成功| H["重置 consecutiveFailures = 0<br />返回结果"]
G -->|失败| I{"是用户中止?"}
I -->|是| J["记录错误"]
I -->|否| J
J --> K["consecutiveFailures++"]
K --> L{">= 3?"}
L -->|是| M["记录熔断警告"]
L -->|否| N["返回 wasCompacted: false"]
M --> N
注意一个有趣的优先级:代码首先尝试 Session Memory 压缩(autoCompact.ts:287-310),只有当 Session Memory 不可用或无法充分释放空间时,才回退到传统的 compactConversation()。Session Memory 压缩是一种更细粒度的策略(通过修剪消息而不是全量总结),将在后续章节中详细讨论。
9.7 用户能做什么
理解了自动压缩的内部机制后,以下是你作为用户可以采取的具体行动:
9.7.1 观察压缩时机
当你在长会话中看到一个短暂的“compacting…“状态指示器时,自动压缩正在进行。根据阈值公式,在 200K 上下文窗口下,这大约发生在你使用了 167K tokens(约 83.5%)时。
9.7.2 提前手动压缩
不要等到自动压缩触发。在你完成一个子任务、准备开始下一个子任务之前,主动执行 /compact。手动压缩允许你传入自定义指令:
/compact 重点保留文件修改历史和错误修复记录,代码片段要完整
这些自定义指令会被追加到压缩提示词的末尾,直接影响摘要内容。
9.7.3 利用 CLAUDE.md 中的压缩指令
在项目的 CLAUDE.md 中可以添加压缩指令段,它们会在每次压缩时被自动注入:
## Compact Instructions
When summarizing the conversation focus on typescript code changes
and also remember the mistakes you made and how you fixed them.
9.7.4 用环境变量调整阈值
如果你发现自动压缩触发得太早(导致不必要的上下文丢失)或太晚(导致频繁的 prompt_too_long 错误),可以用环境变量微调:
# 让压缩在 70% 时就触发(更保守,更少 PTL 错误)
export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=70
# 或者直接限制"可见窗口"为 100K(适合网络慢/预算紧张的场景)
export CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000
9.7.5 禁用自动压缩(不推荐)
# 只禁用自动压缩,保留手动 /compact
export DISABLE_AUTO_COMPACT=1
# 完全禁用所有压缩(包括手动)
export DISABLE_COMPACT=1
完全禁用意味着你必须手动管理上下文,否则会在上下文窗口耗尽时遇到无法恢复的 prompt_too_long 错误。
9.7.6 理解压缩后的“遗忘“
压缩后模型“遗忘“了什么,完全取决于 9 段摘要模板的覆盖范围。最容易丢失的信息类型:
- 精确的代码差异:虽然模板要求 “full code snippets”,但极长的差异列表会被截断
- 被否决的方案的具体原因:模板侧重于“做了什么“,对“为什么不做“的覆盖较弱
- 早期对话中的细微偏好:如果你在对话开头提过一次“不要用 lodash“,这可能在多次压缩后消失
应对策略:将关键约束写入 CLAUDE.md(不受压缩影响),或者在压缩指令中显式列出需要保留的信息。
9.7.7 熔断后的恢复
如果你注意到模型不再自动压缩(连续 3 次失败后熔断),可以:
- 手动执行
/compact尝试压缩 - 如果仍然失败,开始一个新会话——某些情况下上下文已经不可恢复
9.8 小结
自动压缩是 Claude Code 最关键的上下文管理机制之一,它的设计体现了几个重要的工程原则:
- 多层缓冲:20K 输出预留 + 13K 缓冲区 + 3K 阻塞硬限制,三层防线确保系统在任何竞态条件下都不会溢出
- 渐进降级:Session Memory 压缩 → 传统压缩 → PTL 重试 → 熔断,每一层都是上一层的兜底
- 可观测性:
tengu_compact、tengu_compact_failed、tengu_compact_ptl_retry三个遥测事件覆盖了成功、失败和重试路径 - 用户可控:环境变量覆盖、自定义压缩指令、手动
/compact命令,给予高级用户足够的控制权
下一章我们将探讨压缩后的文件状态保留机制——压缩可以“忘记“对话历史,但不应该“忘记“它正在编辑哪些文件。
第10章:压缩后的文件状态保留
“Compression without restoration is just data loss with extra steps.”
第9章讲了压缩何时触发以及如何生成摘要。但压缩的故事并未在摘要生成后结束。当一段长对话被浓缩为一条摘要消息时,模型失去了所有原始上下文——它不再知道自己刚刚读过哪些文件,不记得正在执行的计划,甚至不知道有哪些工具可用。如果压缩后的第一个回合就要求模型继续编辑它“刚刚“读过的文件,而模型却一脸茫然地重新 Read 一遍,这不仅浪费 token,更打断了用户的工作流。
本章的主题是压缩后的状态恢复——Claude Code 如何在压缩完成后,通过一系列精心设计的附件(attachments),将模型“需要但已丢失“的关键上下文注入回对话流。我们将逐一拆解五个恢复维度:文件状态、技能内容、计划状态、Delta 工具声明,以及刻意不恢复的内容。
10.1 压缩前快照:先存再清
压缩恢复的第一步,不是在压缩后做什么,而是在压缩前先保存好现场。
10.1.1 cacheToObject + clear:快照-清空模式
// services/compact/compact.ts:517-522
// Store the current file state before clearing
const preCompactReadFileState = cacheToObject(context.readFileState)
// Clear the cache
context.readFileState.clear()
context.loadedNestedMemoryPaths?.clear()
这三行代码实现了一个经典的快照-清空模式:
-
快照:
cacheToObject(context.readFileState)将内存中的FileStateCache(一个 Map 结构)序列化为普通的Record<string, { content: string; timestamp: number }>对象。这个对象记录了压缩前模型读过的每一个文件——文件名、内容、以及最后读取的时间戳。 -
清空:
context.readFileState.clear()清除文件状态缓存,context.loadedNestedMemoryPaths?.clear()清除已加载的嵌套记忆路径。
为什么要先清空?因为压缩将对话历史替换为一条摘要消息。从模型的视角看,它即将“忘记“之前读过任何文件。如果不清空缓存,系统会误以为模型仍然“知道“这些文件的内容,导致后续的文件去重逻辑出错。清空后,系统进入一个干净的状态,然后有选择地恢复最重要的文件——而不是全部恢复。
10.1.2 为什么不全部恢复?
这个问题触及了压缩恢复的核心设计哲学。一次长会话中,模型可能读过几十甚至上百个文件。如果压缩后将它们全部注入回对话,就会造成一个荒谬的循环:压缩刚省出的 token 空间,立刻被恢复的文件内容填满。
因此,恢复策略的本质是一个预算分配问题——在有限的 token 预算内,选择性地恢复最有价值的状态。
10.2 文件恢复:最近 5 个文件、单文件 5K、总预算 50K
10.2.1 五个常量的预算体系
// services/compact/compact.ts:122-130
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5
export const POST_COMPACT_TOKEN_BUDGET = 50_000
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000
这五个常量构成了压缩后恢复的完整预算框架。下面用一张表来展示它们的分配逻辑:
表 10-1:压缩后 token 预算分配表
| 预算类别 | 常量名 | 限额 | 含义 |
|---|---|---|---|
| 文件数量上限 | POST_COMPACT_MAX_FILES_TO_RESTORE | 5 个 | 最多恢复最近读取的 5 个文件 |
| 单文件 token 上限 | POST_COMPACT_MAX_TOKENS_PER_FILE | 5,000 | 每个文件最多占用 5K token |
| 文件恢复总预算 | POST_COMPACT_TOKEN_BUDGET | 50,000 | 所有恢复文件的 token 总量不超过 50K |
| 单技能 token 上限 | POST_COMPACT_MAX_TOKENS_PER_SKILL | 5,000 | 每个技能文件截断到 5K token |
| 技能恢复总预算 | POST_COMPACT_SKILLS_TOKEN_BUDGET | 25,000 | 所有技能的 token 总量不超过 25K |
以 200K 上下文窗口为例,压缩后摘要大约占用 10K-20K token。文件恢复最多消耗 50K,技能恢复最多消耗 25K,总计约 75K-95K——仍然为后续对话留出了 100K+ 的空间。这是一个深思熟虑的平衡:恢复足够的上下文让模型无缝继续工作,但不至于让压缩变得无意义。
10.2.2 恢复逻辑详解
// services/compact/compact.ts:1415-1464
export async function createPostCompactFileAttachments(
readFileState: Record<string, { content: string; timestamp: number }>,
toolUseContext: ToolUseContext,
maxFiles: number,
preservedMessages: Message[] = [],
): Promise<AttachmentMessage[]> {
const preservedReadPaths = collectReadToolFilePaths(preservedMessages)
const recentFiles = Object.entries(readFileState)
.map(([filename, state]) => ({ filename, ...state }))
.filter(
file =>
!shouldExcludeFromPostCompactRestore(
file.filename,
toolUseContext.agentId,
) && !preservedReadPaths.has(expandPath(file.filename)),
)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, maxFiles)
// ...
}
这个函数的逻辑可以分解为四步:
第一步:排除不需要恢复的文件。shouldExcludeFromPostCompactRestore(行 1674-1705)排除两类文件:
- 计划文件(plan files)——它们有独立的恢复通道(见 10.4 节)
- CLAUDE.md 记忆文件——这些文件通过系统提示词注入,不需要通过文件恢复通道重复注入
同时,如果某个文件路径已经出现在保留的消息尾部(preservedReadPaths),也不需要重复恢复——模型已经能在上下文中看到它。
第二步:按时间戳排序。.sort((a, b) => b.timestamp - a.timestamp) 将文件按最后读取时间降序排列。最近读取的文件最有可能是模型下一步需要操作的文件。
第三步:取前 N 个。.slice(0, maxFiles) 截取最近的 5 个文件。注意这个截取发生在排除过滤之后——如果 20 个文件中有 3 个被排除,那么参与排序的只有 17 个文件,最终取前 5 个。
第四步:并行生成附件。对选中的文件,通过 generateFileAttachment 并行重新读取文件内容,每个文件受 POST_COMPACT_MAX_TOKENS_PER_FILE(5K token)限制。这里有一个重要细节:恢复时读取的是磁盘上的当前内容,而非快照中的缓存内容。如果文件在压缩期间被外部修改(比如用户在编辑器中手动修改),恢复的内容是修改后的版本。
第五步:预算控制。生成文件附件后,还有一道预算闸门:
// services/compact/compact.ts:1452-1463
let usedTokens = 0
return results.filter((result): result is AttachmentMessage => {
if (result === null) {
return false
}
const attachmentTokens = roughTokenCountEstimation(jsonStringify(result))
if (usedTokens + attachmentTokens <= POST_COMPACT_TOKEN_BUDGET) {
usedTokens += attachmentTokens
return true
}
return false
})
即使只有 5 个文件,如果它们都很大(每个接近 5K token),总量也可能超过 50K 预算。这个 filter 充当最后的守门人——按顺序累加每个文件的 token 数,一旦总量超过 POST_COMPACT_TOKEN_BUDGET(50K),就丢弃剩余的文件。
10.2.3 “保留 vs 丢弃“决策树
下面这棵决策树描述了每个文件在压缩后是否会被恢复的完整判定逻辑:
flowchart TD
A["文件在压缩前被读取过?"] -->|否| B["不恢复:文件不在 readFileState 中"]
A -->|是| C{"是 plan 文件?"}
C -->|是| D["排除:通过 Plan 附件独立恢复(见 10.4)"]
C -->|否| E{"是 CLAUDE.md 记忆文件?"}
E -->|是| F["排除:通过系统提示词注入"]
E -->|否| G{"已在保留的消息尾部?"}
G -->|是| H["排除:模型已能看到,无需重复"]
G -->|否| I{"按时间戳排序后排名前 5?"}
I -->|否| J["丢弃:超出文件数量上限"]
I -->|是| K{"单文件超过 5K token?"}
K -->|是| L["截断到 5K token 后继续"]
K -->|否| M{"累加后总 token 超过 50K?"}
L --> M
M -->|是| N["丢弃:超出总预算"]
M -->|否| O["恢复 ✓ 作为附件注入"]
这棵决策树揭示了一个重要设计:恢复不是一个简单的“最近 N 个“算法,而是一个多层过滤管线。排除规则、数量限制、单文件截断、总预算上限,四层防护确保恢复的内容既有价值又不会过度膨胀。
10.3 技能重注入:invokedSkills 的选择性恢复
10.3.1 为什么技能需要独立恢复?
技能(Skills)是 Claude Code 的扩展能力系统。当用户在会话中调用了一个技能(比如 code-review 或 commit),技能的指令内容会被注入到对话中。压缩后,这些指令和上下文一起消失。但技能往往包含关键的行为约束——比如“提交前必须运行测试“或“代码审查时关注安全问题“。如果不恢复它们,模型在压缩后可能违反这些约束。
10.3.2 技能恢复机制
// services/compact/compact.ts:1494-1534
// export function createSkillAttachmentIfNeeded(
agentId?: string,
): AttachmentMessage | null {
const invokedSkills = getInvokedSkillsForAgent(agentId)
if (invokedSkills.size === 0) {
return null
}
// Sorted most-recent-first so budget pressure drops the least-relevant skills.
let usedTokens = 0
const skills = Array.from(invokedSkills.values())
.sort((a, b) => b.invokedAt - a.invokedAt)
.map(skill => ({
name: skill.skillName,
path: skill.skillPath,
content: truncateToTokens(
skill.content,
POST_COMPACT_MAX_TOKENS_PER_SKILL,
),
}))
.filter(skill => {
const tokens = roughTokenCountEstimation(skill.content)
if (usedTokens + tokens > POST_COMPACT_SKILLS_TOKEN_BUDGET) {
return false
}
usedTokens += tokens
return true
})
if (skills.length === 0) {
return null
}
return createAttachmentMessage({
type: 'invoked_skills',
skills,
})
}
技能恢复的策略与文件恢复高度相似,但有两个关键差异:
差异一:截断而非丢弃。源码注释(行 125-128)解释了设计意图:
Skills can be large (verify=18.7KB, claude-api=20.1KB). Previously re-injected unbounded on every compact → 5-10K tok/compact. Per-skill truncation beats dropping — instructions at the top of a skill file are usually the critical part.
技能文件可能很大(verify 技能 18.7KB,claude-api 技能 20.1KB),但技能文件开头的指令通常是最关键的部分。truncateToTokens 函数将每个技能截断到 5K token,保留头部指令,丢弃尾部的详细参考内容。这比“整个保留或整个丢弃“的二选一策略更精细。
差异二:按 agent 隔离。getInvokedSkillsForAgent(agentId) 只返回属于当前 agent 的技能。这防止了主会话的技能泄露到子 agent 的上下文中,反之亦然。
10.3.3 预算算术
25K 的总预算能恢复多少个技能?按每个技能 5K token 计算,理论上最多 5 个技能。源码注释也验证了这一点:“Budget sized to hold ~5 skills at the per-skill cap.”
但实际中,许多技能截断后不到 5K token,所以 25K 预算通常能覆盖会话中所有被调用的技能。只有当用户在一次长会话中调用了大量大型技能时,预算才会成为瓶颈——此时最久远的技能会被优先丢弃。
10.4 刻意不恢复的内容:sentSkillNames
并非所有被清空的状态都需要恢复。源码中最有意思的一个设计决策是:
// services/compact/compact.ts:524-529
// Intentionally NOT resetting sentSkillNames: re-injecting the full
// skill_listing (~4K tokens) post-compact is pure cache_creation with
// marginal benefit. The model still has SkillTool in its schema and
// invoked_skills attachment (below) preserves used-skill content. Ants
// with EXPERIMENTAL_SKILL_SEARCH already skip re-injection via the
// early-return in getSkillListingAttachments.
sentSkillNames 是一个模块级的 Map<string, Set<string>>,记录了哪些技能的名称列表已经发送给模型。如果在压缩后重置它,系统会在下一个请求中重新注入完整的技能列表附件——大约 4K token。
但代码故意不重置它。原因是:
- 成本不对称:4K token 的技能列表全部是
cache_creationtoken(需要写入缓存的新内容),但收益微乎其微——模型仍然可以通过SkillTool的 schema 知道技能工具的存在。 - 已调用的技能已被恢复:上一节的
invoked_skills附件已经恢复了实际使用过的技能内容,模型不需要再看到完整的名称列表。 - 实验性技能搜索:启用了
EXPERIMENTAL_SKILL_SEARCH的环境本来就跳过技能列表注入。
这是一个典型的节省 token 的工程决策——在“恢复的完整性“和“token 成本“之间,选择了后者。4K token 看似不多,但在每次压缩后都会累积,对于频繁压缩的长会话来说,这是一笔可观的节省。
10.5 Plan 和 PlanMode 附件的保留
Claude Code 的计划模式(Plan Mode)允许模型在执行任何操作前先制定详细计划。压缩后,计划状态必须被完整保留,否则模型会“忘记“正在执行的计划。
10.5.1 Plan 附件
// services/compact/compact.ts:545-548
const planAttachment = createPlanAttachmentIfNeeded(context.agentId)
if (planAttachment) {
postCompactFileAttachments.push(planAttachment)
}
createPlanAttachmentIfNeeded(行 1470-1486)检查当前 agent 是否有活跃的计划文件。如果有,将计划内容作为 plan_file_reference 类型的附件注入。注意,plan 文件在文件恢复阶段被 shouldExcludeFromPostCompactRestore 显式排除,正是因为它有这条独立的恢复通道——避免同一个文件被恢复两次,浪费预算。
10.5.2 PlanMode 附件
// services/compact/compact.ts:552-555
const planModeAttachment = await createPlanModeAttachmentIfNeeded(context)
if (planModeAttachment) {
postCompactFileAttachments.push(planModeAttachment)
}
Plan 附件恢复的是计划内容,PlanMode 附件恢复的是模式状态。createPlanModeAttachmentIfNeeded(行 1542-1560)检查用户是否正处于 plan 模式(mode === 'plan')。如果是,它注入一个 plan_mode 类型的附件,其中包含 reminderType: 'full' 标记——这确保模型在压缩后继续在 plan 模式下运行,而不是回退到正常的执行模式。
这两个附件协同工作:Plan 附件告诉模型“你正在执行这个计划“,PlanMode 附件告诉模型“你必须继续以计划模式工作“。缺少任何一个都会导致行为偏差。
10.6 Delta 附件:工具和指令的重新宣告
压缩不仅清除了文件状态,还清除了所有之前的 delta 附件。Delta 附件是系统在对话过程中逐步告知模型的“增量信息“——新注册的延迟工具、新发现的 agent、新加载的 MCP 指令。压缩后,这些信息随着旧消息一起消失。
10.6.1 三类 Delta 的完整重播
// services/compact/compact.ts:563-585
// Compaction ate prior delta attachments. Re-announce from the current
// state so the model has tool/instruction context on the first
// post-compact turn. Empty message history → diff against nothing →
// announces the full set.
for (const att of getDeferredToolsDeltaAttachment(
context.options.tools,
context.options.mainLoopModel,
[],
{ callSite: 'compact_full' },
)) {
postCompactFileAttachments.push(createAttachmentMessage(att))
}
for (const att of getAgentListingDeltaAttachment(context, [])) {
postCompactFileAttachments.push(createAttachmentMessage(att))
}
for (const att of getMcpInstructionsDeltaAttachment(
context.options.mcpClients,
context.options.tools,
context.options.mainLoopModel,
[],
)) {
postCompactFileAttachments.push(createAttachmentMessage(att))
}
源码注释揭示了这段代码的精妙设计:传入空数组 [] 作为消息历史。
在正常的对话轮次中,Delta 附件函数会比较当前状态和已出现在消息历史中的内容,只发送“增量“部分。但压缩后没有消息历史可以比较——传入空数组意味着 diff 的基线为空,因此函数会生成完整的工具和指令声明。
三类 Delta 附件各自的作用:
| Delta 类型 | 函数 | 恢复内容 |
|---|---|---|
| 延迟工具 | getDeferredToolsDeltaAttachment | 尚未加载完整 schema 的工具列表,让模型知道可以通过 ToolSearch 按需获取 |
| Agent 列表 | getAgentListingDeltaAttachment | 可用的子 agent 列表,让模型知道可以委派任务 |
| MCP 指令 | getMcpInstructionsDeltaAttachment | MCP 服务器提供的指令和约束,确保模型遵守外部服务的使用规则 |
callSite: 'compact_full' 标记用于遥测分析,区分正常的增量声明和压缩后的完整重播。
10.6.2 异步 Agent 附件
// services/compact/compact.ts:532-539
const [fileAttachments, asyncAgentAttachments] = await Promise.all([
createPostCompactFileAttachments(
preCompactReadFileState,
context,
POST_COMPACT_MAX_FILES_TO_RESTORE,
),
createAsyncAgentAttachmentsIfNeeded(context),
])
createAsyncAgentAttachmentsIfNeeded(行 1568-1599)检查是否有正在后台运行的异步 agent 或已完成但结果未被检索的 agent。如果有,它为每个 agent 生成一个 task_status 类型的附件,包含 agent 的描述、状态和进度摘要。这防止了压缩后模型“忘记“有后台任务在运行而重复启动相同的任务。
注意文件恢复和异步 agent 附件的生成是并行执行的(Promise.all),这是一个性能优化——两者互不依赖,没有理由串行等待。
10.7 恢复的完整编排
现在让我们将所有恢复步骤放在一起,看看压缩后状态恢复的完整编排(compact.ts 行 517-585):
flowchart TD
subgraph Step1["步骤 1:快照并清空"]
S1A["cacheToObject(readFileState)<br />保存文件状态快照"]
S1B["readFileState.clear()<br />清空文件缓存"]
S1C["loadedNestedMemoryPaths.clear()<br />清空记忆路径"]
S1A --> S1B --> S1C
subgraph Step2["步骤 2:并行生成附件"]
S2A["createPostCompactFileAttachments<br />文件恢复附件"]
S2B["createAsyncAgentAttachmentsIfNeeded<br />异步 agent 附件"]
subgraph Step3["步骤 3:串行生成附件"]
S3A["createPlanAttachmentIfNeeded<br />计划内容附件"]
S3B["createPlanModeAttachmentIfNeeded<br />计划模式附件"]
S3C["createSkillAttachmentIfNeeded<br />已调用技能附件"]
S3A --> S3B --> S3C
subgraph Step4["步骤 4:Delta 完整重播"]
S4A["getDeferredToolsDeltaAttachment<br />延迟工具"]
S4B["getAgentListingDeltaAttachment<br />Agent 列表"]
S4C["getMcpInstructionsDeltaAttachment<br />MCP 指令"]
Step1 --> Step2
Step2 --> Step3
Step3 --> Step4
Step4 --> Step5["步骤 5:合并为 postCompactFileAttachments<br />随压缩后第一条消息发送给模型"]
这个编排的关键特征是层次化和选择性。不是所有状态都被恢复,恢复的方式也各不相同——文件通过重新读取恢复,技能通过截断重注入恢复,计划通过专用附件恢复,工具声明通过 delta 重播恢复。每种状态都有最适合它的恢复通道。
10.8 用户能做什么
理解了压缩后恢复机制,你可以采取以下策略来优化长会话体验:
10.8.1 保持文件读取的聚焦
压缩后只恢复最近读取的 5 个文件。如果你在一次对话中让模型读取了 20 个文件,压缩后只有最后 5 个会被自动恢复。这意味着你在对话前半段让模型读取的那些“参考文件“——测试用例、类型定义、配置文件——很可能在压缩后全部丢失。
策略:在执行复杂任务时,优先让模型读取它下一步需要编辑的文件,而不是“先把所有相关文件都读一遍“。最后读取的文件最有可能在压缩后被保留。如果某个文件对任务至关重要但已经很久没被读取,考虑在你预感压缩即将到来时(比如对话已经进行了 30+ 轮),让模型重新读取一次,刷新它的时间戳。
10.8.2 大文件的截断预期
每个文件恢复上限为 5K token(约 2000-2500 行代码,取决于语言)。如果你正在编辑一个超大文件,压缩后模型只能看到文件的开头部分。
策略:在压缩可能发生的节点(当你注意到对话已经很长时),显式提醒模型关注大文件中的特定区域。或者更好的做法是,将关键约束写入 CLAUDE.md——它永远不受压缩影响。
10.8.3 压缩后技能的行为变化
技能被截断到 5K token 后,文件尾部的参考内容可能丢失。如果你依赖的技能行为在压缩后发生了变化,这可能是截断导致的。
策略:将最关键的技能指令放在技能文件的开头,而非末尾。Claude Code 的截断策略保留头部——这意味着技能文件的结构应该是“关键指令在前,补充参考在后“。
10.8.4 利用 Plan Mode 跨越压缩
如果你在执行一个多步骤任务,使用 plan 模式可以确保计划在压缩后被完整保留。计划附件不受 50K 文件预算的限制,它有独立的恢复通道。
策略:对于可能跨越压缩边界的复杂任务,先让模型制定计划(/plan),然后逐步执行。即使压缩发生在执行过程中,模型也能恢复计划上下文继续工作。
10.8.5 留意“压缩遗忘“的模式
如果压缩后模型突然:
- 重新读取它“刚刚“读过的文件——这个文件可能排在第 6 名之后,未被恢复
- 忘记了后台 agent 的存在——检查 agent 是否已被标记为
retrieved或pending - 不再遵守某个 MCP 工具的约束——delta 重播通常能覆盖,但极端情况下可能有遗漏
- 对之前否决的方案重新提议——摘要倾向于保留“做了什么“,而非“否决了什么“
这些都是正常的工程权衡。预算是有限的,100% 的恢复既不可能也不必要。理解哪些信息“幸存“于压缩、哪些信息会丢失,是驾驭长会话的关键能力。
10.8.6 多次压缩的累积效应
一次极长的会话可能经历多次压缩。每次压缩都会:
- 将所有文件状态缓存清空并重建(最多 5 个)
- 重新截断技能内容(每次都从原始内容截断,不会“截断的截断“)
- 重新生成 Delta 附件(完整重播)
但摘要是不可逆的。第二次压缩的摘要是基于“第一次的摘要 + 后续对话“生成的,信息密度逐次降低。经过三四次压缩后,对话开头的细节几乎不可能被保留。
策略:对于预计超长的任务,在关键的中间节点主动使用 /compact 并附加自定义指令,明确列出需要保留的关键信息。不要等到系统自动压缩——那时你无法控制摘要的重点。
10.9 小结
压缩后的状态恢复体现了 Claude Code 在“信息完整性“和“token 经济性“之间的精细平衡:
- 快照-清空模式:先保存现场再清空,确保恢复有据可依、缓存状态一致
- 分层预算:文件恢复 50K、技能恢复 25K、独立的 plan 通道——不同类型的状态有不同的恢复预算和策略
- 选择性恢复:时间戳排序 + 排除规则 + 预算控制,三层过滤确保只恢复最有价值的内容
- 刻意不恢复:
sentSkillNames的保留是一个反直觉但正确的决策——4K token 的技能列表注入成本大于收益 - Delta 完整重播:传入空消息历史触发完整重播,是一个巧妙的复用现有增量机制的设计
核心启示:压缩不是“遗忘“,而是“有选择地记住“。理解这个选择的逻辑,你就能预测压缩后模型会记住什么、忘记什么,并据此调整自己的工作方式。
第11章:微压缩 — 精准上下文修剪
“The cheapest token is the one you never send.”
上一章(第9章)我们详尽分析了自动压缩——当上下文接近窗口上限时,Claude Code 将整个对话浓缩为一份结构化摘要。这是一种“核选项“:有效但代价高昂,它会丢失对话的原始细节,而且需要一次完整的 LLM 调用来生成摘要。
本章的主角是微压缩(microcompact)——一种轻量级的上下文修剪策略。它不生成摘要,不调用 LLM,而是直接清除或删除旧的工具调用结果。你三分钟前 grep 搜索的 200 行输出、半小时前 cat 读取的配置文件、一小时前 bash 命令的日志——这些信息对模型当前的推理任务来说已经“过时“了。微压缩的核心哲学是:与其让这些过时内容占据宝贵的上下文空间,不如在恰当的时机精准地移除它们。
Claude Code 实现了三种微压缩机制,它们在触发条件、执行方式和缓存影响上截然不同:
| 维度 | 基于时间的微压缩 | 缓存微压缩(cache_edits) | API Context Management |
|---|---|---|---|
| 触发方式 | 距上次助手消息的时间间隔超过阈值 | 可压缩工具数量超过阈值 | API 侧 input_tokens 超过阈值 |
| 执行位置 | 客户端(修改消息内容) | 服务端(cache_edits 指令) | 服务端(context_management 策略) |
| 缓存影响 | 破坏缓存前缀(预期行为,因为缓存已过期) | 保持缓存前缀完整 | 由 API 层管理 |
| 修改方式 | 替换 tool_result.content 为占位文本 | 发送 cache_edits delete 指令 | 声明式策略,API 自动执行 |
| 适用条件 | 长时间空闲后恢复会话 | 实时会话中的增量修剪 | 所有会话(ant 用户,thinking 模型) |
| 源码入口 | maybeTimeBasedMicrocompact() | cachedMicrocompactPath() | getAPIContextManagement() |
| feature gate | tengu_slate_heron (GrowthBook) | CACHED_MICROCOMPACT (build) | 环境变量开关 |
这三种机制的优先级关系也很明确:时间触发最先执行并短路,缓存微压缩其次,API Context Management 作为独立的声明式层始终存在。
11.1 基于时间的微压缩:缓存过期后的批量清理
11.1.1 设计直觉
想象这样一个场景:你在上午 10 点用 Claude Code 完成了一次复杂的重构,然后去吃午饭。下午 1 点回来继续工作——中间间隔了 3 个小时。
在这 3 个小时里发生了什么?服务端的 prompt cache 已经过期了。Anthropic 的 prompt cache 有两个 TTL 档位:5 分钟(标准)和 1 小时(扩展)。无论是哪个档位,3 小时后都已失效。这意味着你的下一次 API 调用会将完整的对话历史重新写入缓存——每一个 token 都要重新计费为 cache creation。
基于时间的微压缩的逻辑因此非常自然:既然缓存已经过期,整个前缀都要重写,那不如先把不需要的旧内容清掉,让重写的内容更小更便宜。
11.1.2 配置参数
配置通过 GrowthBook 功能开关 tengu_slate_heron 下发,类型为 TimeBasedMCConfig:
// services/compact/timeBasedMCConfig.ts:18-28
export type TimeBasedMCConfig = {
/** Master switch. When false, time-based microcompact is a no-op. */
enabled: boolean
/** Trigger when (now − last assistant timestamp) exceeds this many minutes. */
gapThresholdMinutes: number
/** Keep this many most-recent compactable tool results. */
keepRecent: number
}
const TIME_BASED_MC_CONFIG_DEFAULTS: TimeBasedMCConfig = {
enabled: false,
gapThresholdMinutes: 60,
keepRecent: 5,
}
三个参数各有其考量:
enabled默认关闭——这是一个灰度发布特性,通过 GrowthBook 逐步开启gapThresholdMinutes: 60对齐服务端 1 小时 cache TTL——这是“安全选择“,源码注释(第 23 行)明确说明:“the server’s 1h cache TTL is guaranteed expired for all users, so we never force a miss that wouldn’t have happened”keepRecent: 5保留最近 5 个工具结果,为模型提供最小工作上下文
11.1.3 触发判定
evaluateTimeBasedTrigger() 函数(microCompact.ts:422-444)是一个纯判定函数,不产生副作用:
// microCompact.ts:422-444
// export function evaluateTimeBasedTrigger(
messages: Message[],
querySource: QuerySource | undefined,
): { gapMinutes: number; config: TimeBasedMCConfig } | null {
const config = getTimeBasedMCConfig()
if (!config.enabled || !querySource || !isMainThreadSource(querySource)) {
return null
}
const lastAssistant = messages.findLast(m => m.type === 'assistant')
if (!lastAssistant) {
return null
}
const gapMinutes =
(Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000
if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) {
return null
}
return { gapMinutes, config }
}
注意第 428 行的守卫条件:!querySource 时直接返回 null。这与缓存微压缩的行为不同——isMainThreadSource()(第 249-251 行)将 undefined 视为主线程(为了缓存 MC 的向后兼容),但时间触发显式要求 querySource 存在。源码注释(第 429-431 行)解释了原因:/context、/compact 等分析性调用会在不带 source 的情况下调用 microcompactMessages(),它们不应该触发时间清理。
11.1.4 执行逻辑
当触发条件满足时,maybeTimeBasedMicrocompact() 执行以下步骤:
flowchart TD
A["maybeTimeBasedMicrocompact(messages, querySource)"] --> B{"evaluateTimeBasedTrigger()"}
B -->|null| C["返回 null(不触发)"]
B -->|触发| D["collectCompactableToolIds(messages)<br />收集所有可压缩工具 ID"]
D --> E["keepRecent = Math.max(1, config.keepRecent)<br />至少保留 1 个<br />(slice(-0) 返回整个数组)"]
E --> F["keepSet = compactableIds.slice(-keepRecent)<br />保留最近 N 个"]
F --> G["clearSet = 其余全部清除"]
G --> H["遍历 messages,将 clearSet 中的<br />tool_result.content 替换为占位文本"]
H --> I["suppressCompactWarning()<br />抑制上下文压力警告"]
I --> J["resetMicrocompactState()<br />重置缓存 MC 状态"]
J --> K["notifyCacheDeletion()<br />通知缓存中断检测器"]
关键实现细节在 microCompact.ts:470-492——消息修改采用不可变风格:
// microCompact.ts:470-492
let tokensSaved = 0
const result: Message[] = messages.map(message => {
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
return message
}
let touched = false
const newContent = message.message.content.map(block => {
if (
block.type === 'tool_result' &&
clearSet.has(block.tool_use_id) &&
block.content !== TIME_BASED_MC_CLEARED_MESSAGE
) {
tokensSaved += calculateToolResultTokens(block)
touched = true
return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
}
return block
})
if (!touched) return message
return {
...message,
message: { ...message.message, content: newContent },
}
})
注意第 479 行的 block.content !== TIME_BASED_MC_CLEARED_MESSAGE 守卫——防止对已清除的内容重复计算 tokensSaved。这是幂等性保证:多次执行不会改变 tokensSaved 的统计值。
11.1.5 副作用链
时间触发执行完毕后,会产生三个重要的副作用:
suppressCompactWarning()(第 511 行):微压缩释放了上下文空间,抑制“上下文即将满“的用户可见警告resetMicrocompactState()(第 517 行):清空缓存 MC 的工具注册状态——因为我们刚修改了消息内容、破坏了服务端缓存,缓存 MC 的旧状态(哪些工具已注册、哪些已删除)全部失效notifyCacheDeletion(querySource)(第 526 行):通知promptCacheBreakDetection模块,下一次 API 响应的 cache_read_tokens 会下降——这是预期行为,不是缓存中断 bug
第三个副作用特别微妙。源码注释(第 520-522 行)解释了为什么使用 notifyCacheDeletion 而不是 notifyCompaction:“notifyCacheDeletion (not notifyCompaction) because it’s already imported here and achieves the same false-positive suppression — adding the second symbol to the import was flagged by the circular-deps check.” 这是循环依赖约束下的务实选择:两个函数的效果相同(都防止误报),但引入额外的 import symbol 会触发循环依赖检测。
11.2 缓存微压缩:不破坏缓存的精准手术
11.2.1 核心挑战
时间触发的微压缩有一个本质局限:它必须修改消息内容,这意味着缓存前缀被改变,下一次 API 调用会产生完整的 cache creation 费用。当缓存已过期时这无所谓(反正都要重写),但在实时会话中,这是不可接受的——你刚积累的缓存前缀可能价值数万 tokens 的 cache creation 费用。
缓存微压缩(cached microcompact)通过 Anthropic API 的 cache_edits 特性解决了这个问题:它不修改本地消息内容,而是向 API 发送“在服务端缓存中删除指定工具结果“的指令。服务端在缓存前缀中原地移除这些内容,保持前缀的连续性——下一次请求仍然能命中已有缓存。
11.2.2 cache_edits 工作原理
以下序列图展示了缓存微压缩的完整生命周期:
sequenceDiagram
participant MC as microCompact.ts
participant API as claude.ts (API layer)
participant Server as Anthropic API Server
MC->>MC: ① registerToolResult()<br />注册 tool_results
MC->>MC: ② getToolResultsToDelete()<br />检查是否达到阈值
MC->>MC: ③ createCacheEditsBlock()<br />创建 cache_edits block
MC->>API: ④ 存入 pendingCacheEdits
API->>API: ⑤ consumePendingCacheEdits()
API->>API: ⑥ getPinnedCacheEdits()
API->>API: ⑦ addCacheBreakpoints()<br />在 user message 中插入 cache_edits block<br />为 tool_result 添加 cache_reference
API->>Server: ⑧ API Request: messages 包含 cache_edits
Server->>Server: ⑨ 在缓存中删除对应 tool_result<br />缓存前缀保持连续
Server-->>API: ⑩ Response: cache_deleted_input_tokens (累积值)
API->>API: ⑪ pinCacheEdits()
API->>API: ⑫ markToolsSentToAPIState()
让我们逐步拆解这个流程。
11.2.3 工具注册与阈值判定
cachedMicrocompactPath() 函数(microCompact.ts:305-399)首先扫描所有消息,注册可压缩的工具结果:
// microCompact.ts:313-329
const compactableToolIds = new Set(collectCompactableToolIds(messages))
// Second pass: register tool results grouped by user message
for (const message of messages) {
if (message.type === 'user' && Array.isArray(message.message.content)) {
const groupIds: string[] = []
for (const block of message.message.content) {
if (
block.type === 'tool_result' &&
compactableToolIds.has(block.tool_use_id) &&
!state.registeredTools.has(block.tool_use_id)
) {
mod.registerToolResult(state, block.tool_use_id)
groupIds.push(block.tool_use_id)
}
}
mod.registerToolMessage(state, groupIds)
}
}
注册分两步:collectCompactableToolIds() 先从 assistant 消息中收集所有属于可压缩工具集的 tool_use ID,然后在 user 消息中找到对应的 tool_result,按消息分组注册。分组是因为 cache_edits 的删除粒度是单个 tool_result,但触发判定基于工具总数。
注册后调用 mod.getToolResultsToDelete(state) 获取需要删除的工具列表。这个函数的逻辑由 GrowthBook 配置的 triggerThreshold 和 keepRecent 控制——当注册的工具总数超过 triggerThreshold 时,保留最近 keepRecent 个,其余标记为待删除。
11.2.4 cache_edits block 的生命周期
当有工具需要删除时,代码创建一个 CacheEditsBlock 并存入模块级变量 pendingCacheEdits:
// microCompact.ts:334-339
const toolsToDelete = mod.getToolResultsToDelete(state)
if (toolsToDelete.length > 0) {
const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
if (cacheEdits) {
pendingCacheEdits = cacheEdits
}
这个 pendingCacheEdits 变量的消费者是 API 层的 claude.ts。在构建 API 请求参数前(第 1531 行),代码调用 consumePendingCacheEdits() 一次性取出待发送的编辑指令:
// claude.ts:1531-1532
const consumedCacheEdits = cachedMCEnabled ? consumePendingCacheEdits() : null
const consumedPinnedEdits = cachedMCEnabled ? getPinnedCacheEdits() : []
consumePendingCacheEdits() 的设计是单次消费(microCompact.ts:88-94):调用后立即清空 pendingCacheEdits。源码注释(第 1528-1530 行)解释了为什么不能在 paramsFromContext 内部消费:“paramsFromContext is called multiple times (logging, retries), so consuming inside it would cause the first call to steal edits from subsequent calls.”
11.2.5 在 API 请求中插入 cache_edits
addCacheBreakpoints() 函数(claude.ts:3063-3162)负责将 cache_edits 指令织入消息数组。核心逻辑分三步:
第一步:重新插入已固定的编辑(第 3128-3139 行)
// claude.ts:3128-3139
for (const pinned of pinnedEdits ?? []) {
const msg = result[pinned.userMessageIndex]
if (msg && msg.role === 'user') {
if (!Array.isArray(msg.content)) {
msg.content = [{ type: 'text', text: msg.content as string }]
}
const dedupedBlock = deduplicateEdits(pinned.block)
if (dedupedBlock.edits.length > 0) {
insertBlockAfterToolResults(msg.content, dedupedBlock)
}
}
}
每一轮 API 调用,之前已发送过的 cache_edits 必须在相同位置重新发送——服务端需要看到完整一致的编辑历史才能正确重建缓存前缀。这就是 pinnedEdits 的作用。
第二步:插入新的编辑(第 3142-3162 行)
新的 cache_edits block 被插入到最后一个 user 消息中,然后通过 pinCacheEdits(i, newCacheEdits) 固定位置索引,确保后续调用在同一位置重复发送。
第三步:去重
deduplicateEdits() 辅助函数(第 3116-3125 行)使用 seenDeleteRefs Set 确保同一个 cache_reference 不会在多个 block 中重复出现。这防止了一种边缘情况:同一个工具结果在不同轮次被标记为待删除。
11.2.6 cache_edits 数据结构
在 API 层,cache_edits block 的类型定义(claude.ts:3052-3055)非常简洁:
type CachedMCEditsBlock = {
type: 'cache_edits'
edits: { type: 'delete'; cache_reference: string }[]
}
每个 edit 是一个 delete 操作,指向一个 cache_reference——这是服务端为每个 tool_result 分配的唯一标识符。客户端在之前的 API 响应中获取这些引用,然后在后续请求中引用它们来指定要删除的内容。
11.2.7 baseline 与 delta 追踪
cachedMicrocompactPath() 在返回结果时,记录了一个 baselineCacheDeletedTokens 值(第 374-383 行):
// microCompact.ts:374-383
const lastAsst = messages.findLast(m => m.type === 'assistant')
const baseline =
lastAsst?.type === 'assistant'
? ((
lastAsst.message.usage as unknown as Record<
string,
number | undefined
>
)?.cache_deleted_input_tokens ?? 0)
: 0
API 返回的 cache_deleted_input_tokens 是一个累积值——它包含本次会话中所有 cache_edits 操作删除的总 token 数。为了计算当前操作的实际 delta,需要记录操作前的 baseline,然后用 API 响应中的新累积值减去它。这个设计避免了在客户端做不精确的 token 估算。
11.2.8 与时间触发的互斥
microcompactMessages() 的入口函数(第 253-293 行)定义了严格的优先级:
// microCompact.ts:267-270
const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource)
if (timeBasedResult) {
return timeBasedResult
}
时间触发优先执行并短路。源码注释(第 261-266 行)解释了为什么:“If the gap since the last assistant message exceeds the threshold, the server cache has expired and the full prefix will be rewritten regardless — so content-clear old tool results now … Cached MC (cache-editing) is skipped when this fires: editing assumes a warm cache, and we just established it’s cold.”
这是一个精妙的互斥设计:
- 缓存热(warm cache):使用 cache_edits,在不破坏缓存的前提下删除内容
- 缓存冷(cold cache):使用时间触发,直接修改内容,反正缓存已经失效
两种机制不会同时执行。
11.3 API Context Management:声明式上下文管理
11.3.1 从命令式到声明式
前两种微压缩机制都是命令式的——客户端决定删除哪些工具、何时删除、怎么删除。API Context Management 则是声明式的:客户端只需描述“当上下文超过 X tokens 时,清除 Y 类型的内容,保留最近 Z 个“,API 服务端自动执行。
这段逻辑位于 apiMicrocompact.ts,函数 getAPIContextManagement() 构建一个 ContextManagementConfig 对象,随 API 请求一起发送:
// apiMicrocompact.ts:59-62
export type ContextManagementConfig = {
edits: ContextEditStrategy[]
}
11.3.2 两种策略类型
ContextEditStrategy 联合类型定义了两种服务端可执行的编辑策略:
策略一:clear_tool_uses_20250919
// apiMicrocompact.ts:36-53
| {
type: 'clear_tool_uses_20250919'
trigger?: {
type: 'input_tokens'
value: number // 当 input tokens 超过此值时触发
}
keep?: {
type: 'tool_uses'
value: number // 保留最近 N 个工具使用
}
clear_tool_inputs?: boolean | string[] // 清除哪些工具的输入
exclude_tools?: string[] // 排除哪些工具
clear_at_least?: {
type: 'input_tokens'
value: number // 至少清除这么多 tokens
}
}
策略二:clear_thinking_20251015
// apiMicrocompact.ts:54-56
| {
type: 'clear_thinking_20251015'
keep: { type: 'thinking_turns'; value: number } | 'all'
}
这种策略专门处理 thinking blocks——extended thinking 模型(如 Claude Sonnet 4 with thinking)会生成大量思考过程,这些内容在后续轮次中的价值迅速衰减。
11.3.3 策略组合逻辑
getAPIContextManagement() 根据运行时条件组合多个策略:
// apiMicrocompact.ts:64-88
// export function getAPIContextManagement(options?: {
hasThinking?: boolean
isRedactThinkingActive?: boolean
clearAllThinking?: boolean
}): ContextManagementConfig | undefined {
const {
hasThinking = false,
isRedactThinkingActive = false,
clearAllThinking = false,
} = options ?? {}
const strategies: ContextEditStrategy[] = []
// 策略 1: thinking 管理
if (hasThinking && !isRedactThinkingActive) {
strategies.push({
type: 'clear_thinking_20251015',
keep: clearAllThinking
? { type: 'thinking_turns', value: 1 }
: 'all',
})
}
// ...
}
thinking 策略的三个分支:
| 条件 | 行为 | 原因 |
|---|---|---|
hasThinking && !isRedactThinkingActive && !clearAllThinking | keep: 'all' | 保留所有 thinking(正常工作状态) |
hasThinking && !isRedactThinkingActive && clearAllThinking | keep: { type: 'thinking_turns', value: 1 } | 只保留最后 1 轮 thinking(超过 1 小时空闲 = 缓存失效) |
isRedactThinkingActive | 不添加策略 | redacted thinking 块没有模型可见内容,无需管理 |
注意 clearAllThinking 时 value 设为 1 而不是 0——源码注释(第 81 行)解释:“the API schema requires value >= 1, and omitting the edit falls back to the model-policy default (often ‘all’), which wouldn’t clear.”
11.3.4 工具清除的两种模式
在 clear_tool_uses_20250919 策略中,工具清除有两种互补模式:
模式一:清除工具结果(clear_tool_inputs)
// apiMicrocompact.ts:104-124
if (useClearToolResults) {
const strategy: ContextEditStrategy = {
type: 'clear_tool_uses_20250919',
trigger: { type: 'input_tokens', value: triggerThreshold },
clear_at_least: {
type: 'input_tokens',
value: triggerThreshold - keepTarget,
},
clear_tool_inputs: TOOLS_CLEARABLE_RESULTS,
}
strategies.push(strategy)
}
TOOLS_CLEARABLE_RESULTS(第 19-26 行)包含那些输出量大但可丢弃的工具:Shell 命令、Glob、Grep、FileRead、WebFetch、WebSearch。这些工具的结果通常是搜索输出或文件内容——模型已经处理过了,清除它们不影响后续推理。
模式二:清除工具使用(exclude_tools)
// apiMicrocompact.ts:128-149
if (useClearToolUses) {
const strategy: ContextEditStrategy = {
type: 'clear_tool_uses_20250919',
trigger: { type: 'input_tokens', value: triggerThreshold },
clear_at_least: {
type: 'input_tokens',
value: triggerThreshold - keepTarget,
},
exclude_tools: TOOLS_CLEARABLE_USES,
}
strategies.push(strategy)
}
TOOLS_CLEARABLE_USES(第 28-32 行)包含 FileEdit、FileWrite 和 NotebookEdit——这些工具的输入(即模型发送的编辑指令)通常比输出更大。exclude_tools 的语义是“清除除这些工具外的所有工具使用“,这让 API 侧可以更激进地清理。
两种模式的默认参数相同:triggerThreshold = 180,000(约等于自动压缩的警告阈值),keepTarget = 40,000(保留最后 40K tokens),clear_at_least = triggerThreshold - keepTarget = 140,000(至少释放 140K tokens)。这些值可通过 API_MAX_INPUT_TOKENS 和 API_TARGET_INPUT_TOKENS 环境变量覆盖。
11.4 可压缩工具集清单
三种微压缩机制各自定义了不同的可压缩工具集。理解这些差异对于预测哪些工具结果会被清除至关重要。
11.4.1 COMPACTABLE_TOOLS(时间触发 + 缓存微压缩共用)
// microCompact.ts:41-50
const COMPACTABLE_TOOLS = new Set<string>([
FILE_READ_TOOL_NAME, // Read
...SHELL_TOOL_NAMES, // Bash (多个 shell 变体)
GREP_TOOL_NAME, // Grep
GLOB_TOOL_NAME, // Glob
WEB_SEARCH_TOOL_NAME, // WebSearch
WEB_FETCH_TOOL_NAME, // WebFetch
FILE_EDIT_TOOL_NAME, // Edit
FILE_WRITE_TOOL_NAME, // Write
])
11.4.2 TOOLS_CLEARABLE_RESULTS(API clear_tool_inputs)
// apiMicrocompact.ts:19-26
const TOOLS_CLEARABLE_RESULTS = [
...SHELL_TOOL_NAMES,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
FILE_READ_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
]
11.4.3 TOOLS_CLEARABLE_USES(API exclude_tools)
// apiMicrocompact.ts:28-32
const TOOLS_CLEARABLE_USES = [
FILE_EDIT_TOOL_NAME, // Edit
FILE_WRITE_TOOL_NAME, // Write
NOTEBOOK_EDIT_TOOL_NAME, // NotebookEdit
]
关键差异:
| 工具 | COMPACTABLE_TOOLS | CLEARABLE_RESULTS | CLEARABLE_USES |
|---|---|---|---|
| Shell (Bash) | yes | yes | – |
| Grep | yes | yes | – |
| Glob | yes | yes | – |
| FileRead (Read) | yes | yes | – |
| WebSearch | yes | yes | – |
| WebFetch | yes | yes | – |
| FileEdit (Edit) | yes | – | yes |
| FileWrite (Write) | yes | – | yes |
| NotebookEdit | – | – | yes |
NotebookEdit 只出现在 API 的 TOOLS_CLEARABLE_USES 中——客户端微压缩不处理它。FileEdit 和 FileWrite 在客户端清除的是结果(tool_result),在 API 模式下则从 clear_tool_inputs 中排除、改为在 exclude_tools 中处理。这种分层设计让客户端和服务端各自处理最适合的部分。
11.5 缓存中断检测的协调
11.5.1 问题:微压缩会触发误报
promptCacheBreakDetection.ts 模块持续监控 API 响应中的 cache_read_tokens。当该值相比上次请求下降超过 5% 且绝对值超过 2,000 tokens 时,它会报告一次“缓存中断“(cache break)——这通常意味着某些变更(系统提示词修改、工具列表变化)导致缓存前缀失效。
但微压缩故意减少了缓存内容。如果不做协调,每次微压缩都会触发一次误报。Claude Code 通过两个通知函数解决这个问题:
11.5.2 notifyCacheDeletion()
// promptCacheBreakDetection.ts:673-682
// export function notifyCacheDeletion(
querySource: QuerySource,
agentId?: AgentId,
): void {
const key = getTrackingKey(querySource, agentId)
const state = key ? previousStateBySource.get(key) : undefined
if (state) {
state.cacheDeletionsPending = true
}
}
调用时机:缓存微压缩发送 cache_edits 后(microCompact.ts:366),以及时间触发修改消息内容后(microCompact.ts:526)。
效果:设置 cacheDeletionsPending = true。当下一次 API 响应到来时,checkResponseForCacheBreak()(第 472-481 行)看到此标志,直接跳过中断检测:
// promptCacheBreakDetection.ts:472-481
if (state.cacheDeletionsPending) {
state.cacheDeletionsPending = false
logForDebugging(
`[PROMPT CACHE] cache deletion applied, cache read: ${prevCacheRead}
→ ${cacheReadTokens} (expected drop)`,
)
state.pendingChanges = null
return
}
11.5.3 notifyCompaction()
// promptCacheBreakDetection.ts:689-698
// export function notifyCompaction(
querySource: QuerySource,
agentId?: AgentId,
): void {
const key = getTrackingKey(querySource, agentId)
const state = key ? previousStateBySource.get(key) : undefined
if (state) {
state.prevCacheReadTokens = null
}
}
调用时机:完整压缩(compact.ts:699)和自动压缩(autoCompact.ts:303)完成后。
效果:将 prevCacheReadTokens 重置为 null,这意味着下一次 API 响应时没有“上一次的值“可以比较——检测器会将其视为“第一次调用“,不报告中断。
两个函数的区别:
| 函数 | 重置方式 | 适用场景 |
|---|---|---|
notifyCacheDeletion | 标记 cacheDeletionsPending = true,下次检测时跳过但保留 baseline | 微压缩(部分删除,baseline 仍有参考价值) |
notifyCompaction | 将 prevCacheReadTokens 置 null,完全重置 baseline | 完整压缩(消息结构彻底改变,旧 baseline 无意义) |
11.6 子代理隔离
微压缩系统必须处理的一个重要场景是子代理(sub-agent)。Claude Code 的主线程可以 fork 出多个子代理(session_memory、prompt_suggestion 等),每个子代理有独立的对话历史。
cachedMicrocompactPath 只对主线程执行(microCompact.ts:275-285):
// microCompact.ts:275-285
if (feature('CACHED_MICROCOMPACT')) {
const mod = await getCachedMCModule()
const model = toolUseContext?.options.mainLoopModel ?? getMainLoopModel()
if (
mod.isCachedMicrocompactEnabled() &&
mod.isModelSupportedForCacheEditing(model) &&
isMainThreadSource(querySource)
) {
return await cachedMicrocompactPath(messages, querySource)
}
}
源码注释(第 272-276 行)解释了原因:“Only run cached MC for the main thread to prevent forked agents from registering their tool_results in the global cachedMCState, which would cause the main thread to try deleting tools that don’t exist in its own conversation.”
cachedMCState 是一个模块级全局变量。如果子代理注册了自己的工具 ID,主线程在下次执行时会尝试删除这些 ID——但它们不存在于主线程的消息中,导致无效的 cache_edits 指令。通过 isMainThreadSource(querySource) 守卫,子代理被完全排除在缓存微压缩之外。
isMainThreadSource() 的实现(第 249-251 行)使用前缀匹配而非精确匹配:
// microCompact.ts:249-251
// function isMainThreadSource(querySource: QuerySource | undefined): boolean {
return !querySource || querySource.startsWith('repl_main_thread')
}
这是因为 promptCategory.ts 会将 querySource 设置为 'repl_main_thread:outputStyle:<style>'——如果使用严格的 === 'repl_main_thread' 检查,使用非默认输出样式的用户会被静默排除在缓存微压缩之外。源码注释(第 246-248 行)将旧的精确匹配标注为“latent bug“。
11.7 用户能做什么
理解微压缩的三种机制后,你可以采取以下策略来优化日常使用体验:
11.7.1 理解“工具结果消失“的原因
当你发现模型在对话中后期“忘记“了之前某次 grep 或 cat 的结果,这很可能不是模型的幻觉,而是微压缩主动清除了旧的工具结果。被清除的工具结果会被替换为 [Old tool result content cleared] 占位文本。如果你需要模型重新参考某个搜索结果,直接要求它重新执行搜索即可——这比试图让模型“回忆“已被清除的内容更可靠。
11.7.2 长时间离开后的预期管理
如果你离开超过 1 小时再回来继续对话,基于时间的微压缩可能已经清除了大部分旧工具结果(只保留最近 5 个)。这是设计如此——因为服务端缓存已经过期,清除旧内容可以显著减少下一次 API 调用的 cache creation 成本。回来后,让模型重新读取关键文件是正常且高效的操作。
11.7.3 利用 CLAUDE.md 保留关键上下文
微压缩只清除工具调用的结果,不影响系统提示词中注入的 CLAUDE.md 内容。如果某些信息(如项目约定、架构决策、关键文件路径)需要在整个会话中持续生效,将它们写入 CLAUDE.md 是最可靠的方式——它们不受任何压缩或微压缩机制的影响。
11.7.4 并行工具调用的成本意识
当模型同时发起多个搜索或读取操作时,这些结果的聚合大小受 200K 字符的消息级预算限制。如果你观察到某些并行工具的结果被持久化到磁盘(模型会提示“Output too large, saved to file“),这是预算机制在防止上下文膨胀。你可以通过更精确的搜索条件来减少单次工具输出的大小。
11.7.5 不可压缩工具的认知
并非所有工具结果都会被微压缩清除。FileEdit、FileWrite 等写入类工具的结果在客户端微压缩中是可清除的,但像 ToolSearch、SendMessage 等工具不在可压缩集合中。了解哪些工具结果会被清除(参见 11.4 节的对比表),有助于你理解模型在长会话中的行为变化。
11.8 设计模式总结
微压缩系统展现了几个值得学习的工程模式:
分层降级:三种机制形成层次——API Context Management 作为声明式基线始终存在;缓存微压缩在支持 cache_edits 的环境中提供精准手术;时间触发作为缓存失效后的兜底。每一层都有明确的前提条件和退化路径。
副作用协调:微压缩不是孤立操作——它必须通知缓存中断检测器(防误报)、重置相关状态(防脏数据)、抑制用户警告(防困惑)。这三个副作用通过显式的函数调用(notifyCacheDeletion、resetMicrocompactState、suppressCompactWarning)而非事件系统协调,保持了因果链的可追踪性。
单次消费语义:consumePendingCacheEdits() 返回数据后立即清空——这防止了在 API 重试场景下的重复消费。这种模式在需要跨模块传递一次性状态时非常实用。
不可变消息修改:时间触发路径使用 map + 展开运算符创建新的消息数组,而不是原地修改。这确保了如果微压缩逻辑有 bug,原始消息不会被污染。缓存微压缩更进一步——它完全不修改本地消息,所有修改都在服务端完成。
循环依赖规避:notifyCacheDeletion 被复用来替代 notifyCompaction,仅仅是因为后者的 import 会触发循环依赖检测。这种务实的妥协在大型代码库中很常见——完美的模块边界让位于构建系统的约束。源码注释坦诚记录了这个取舍,而不是试图隐藏它。
第12章:Token 预算策略
为什么这很重要
在第9-11章中,我们分析了 Claude Code 在上下文窗口“满“了之后如何压缩和修剪。但还有一个更基本的问题:在内容进入上下文窗口之前,如何控制它的大小?
一次 grep 返回 80KB 的搜索结果,一次 cat 读取 200KB 的日志文件,五个并行工具调用每个返回 50KB——这些都是真实场景。如果不加控制,单个工具结果就可能吃掉上下文窗口的四分之一,而一组并行工具调用则可能直接将上下文推到需要压缩的临界点。
Token 预算策略是 Claude Code 上下文管理的“入口闸门“。它在三个层级运作:
- 单工具结果级别:超过阈值的结果持久化到磁盘,只向模型展示预览
- 单消息级别:一轮并行工具调用的结果总量不超过 200K 字符
- Token 计数级别:通过规范 API 或粗略估算追踪上下文窗口使用量
本章将深入这三个层级的实现,揭示其中的工程权衡——特别是并行工具调用场景下的 token 计数陷阱。
12.1 工具结果持久化:50K 字符的入口闸门
核心常量
工具结果的大小控制围绕两个核心常量展开,定义在 constants/toolLimits.ts 中:
// constants/toolLimits.ts:13
export const DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000
// constants/toolLimits.ts:49
export const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000
第一个常量是单工具结果的全局上限——当一个工具的输出超过 50,000 个字符时,完整内容被写入磁盘文件,模型只收到一个包含文件路径和前 2,000 字节预览的替代消息。第二个常量是单消息内所有工具结果的聚合上限,用于防范并行工具调用的累积效应。
这两个常量之间的关系值得注意:200K / 50K = 4,意味着即使四个工具都各自达到单工具上限,它们在一条消息内仍然安全。但如果五个或更多并行工具同时返回接近上限的结果,就会触发消息级别的预算执行。
持久化阈值的计算
单工具的持久化阈值并非简单等于 50K——它是一个多层决策:
// utils/toolResultStorage.ts:55-78
// export function getPersistenceThreshold(
toolName: string,
declaredMaxResultSizeChars: number,
): number {
// Infinity = hard opt-out
if (!Number.isFinite(declaredMaxResultSizeChars)) {
return declaredMaxResultSizeChars
}
const overrides = getFeatureValue_CACHED_MAY_BE_STALE<Record<
string, number
> | null>(PERSIST_THRESHOLD_OVERRIDE_FLAG, {})
const override = overrides?.[toolName]
if (
typeof override === 'number' &&
Number.isFinite(override) &&
override > 0
) {
return override
}
return Math.min(declaredMaxResultSizeChars, DEFAULT_MAX_RESULT_SIZE_CHARS)
}
这个函数的决策逻辑构成一个优先级链:
| 优先级 | 条件 | 结果 |
|---|---|---|
| 1(最高) | 工具声明 maxResultSizeChars: Infinity | 永不持久化(Read 工具使用此机制) |
| 2 | GrowthBook flag tengu_satin_quoll 中有该工具的覆盖值 | 使用远程覆盖值 |
| 3 | 工具声明了自定义 maxResultSizeChars | Math.min(声明值, 50_000) |
| 4(默认) | 无特殊声明 | 50,000 字符 |
表 12-1:单工具持久化阈值优先级链
第一个优先级特别有意思:Read 工具将自己的 maxResultSizeChars 设为 Infinity,意味着它永远不会被持久化。源码注释(第59-61行)解释了原因——Read 工具的输出如果被持久化到文件,模型就需要再次调用 Read 去读取那个文件,形成循环。Read 工具通过自己的 maxTokens 参数控制输出大小,不依赖通用的持久化机制。
持久化流程
当工具结果超过阈值时,maybePersistLargeToolResult 函数执行以下流程:
flowchart TD
A["工具执行完成,产生结果"] --> B{"结果内容为空?"}
B -->|是| C["注入占位符文本<br />(toolName completed with no output)"]
B -->|否| D{"包含图片 block?"}
D -->|是| E["原样返回<br />(图片必须发送给模型)"]
D -->|否| F{"size ≤ 阈值?"}
F -->|是| G["原样返回"]
F -->|否| H["persistToolResult()<br />写入磁盘文件,生成 2KB 预览"]
H --> I["buildLargeToolResultMessage()<br />构建替代消息:<br />persisted-output 文件路径 + 预览"]
图 12-1:工具结果持久化决策流程
有两个值得关注的实现细节:
空结果的特殊处理(第280-295行):空的 tool_result 内容会导致某些模型(注释中提到 “capybara”)误判为对话轮次边界,从而错误地结束输出。这是因为服务端渲染器在 tool_result 之后不插入 \n\nAssistant: 标记,空内容会匹配到 \n\nHuman: 的停止序列模式。解决方案是注入一个简短的占位字符串 (toolName completed with no output)。
文件写入的幂等性(第161-172行):persistToolResult 使用 flag: 'wx' 写入文件,这意味着如果文件已存在则抛出 EEXIST 错误——函数捕获并忽略这个错误。这个设计是为了应对 microcompact 重放原始消息时的重复持久化问题:tool_use_id 在每次调用中是唯一的,相同 ID 的内容是确定性的,所以跳过已存在的文件是安全的。
持久化后的消息格式
当结果被持久化后,模型实际看到的消息如下:
<persisted-output>
Output too large (82.3 KB). Full output saved to:
/path/to/session/tool-results/toolu_01XYZ.txt
Preview (first 2.0 KB):
[前 2000 字节的内容,在换行符处截断]
...
</persisted-output>
预览的生成逻辑(第339-356行)会尽量在换行符处截断,避免切断一行的中间位置。如果最后一个换行符的位置在限制值的 50% 之前(意味着要么只有一行,要么行非常长),则回退到精确的字节限制。
12.2 单消息预算:200K 聚合上限
为什么需要消息级别的预算
单工具的 50K 上限不足以应对并行工具调用的场景。考虑这种情况:模型同时发起 10 个 Grep 调用搜索不同的关键词,每个返回 40K 字符——单独看都在 50K 阈值以下,但合计 400K 字符将在一条 user 消息中发送给 API。这会立即消耗大量上下文窗口,可能触发不必要的压缩。
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000(第49行)就是为这个场景设计的聚合预算。注释(第40-48行)明确说明了核心设计原则:消息之间独立评估——一轮对话中 150K 的结果和另一轮中 150K 的结果各自都在预算内,不会互相影响。
消息分组的复杂性
并行工具调用在 Claude Code 内部的消息表示并不简单。当模型发起多个并行工具调用时,流式处理代码为每个 content_block_stop 事件产生一个独立的 AssistantMessage 记录,然后每个 tool_result 作为独立的 user 消息紧随其后。所以内部消息数组看起来像:
[..., assistant(id=A), user(result_1), assistant(id=A), user(result_2), ...]
注意多个 assistant 记录共享同一个 message.id。但在发送给 API 之前,normalizeMessagesForAPI 会将连续的 user 消息合并为一条。消息级别的预算必须按照 API 看到的分组方式工作,而不是内部的分散表示。
collectCandidatesByMessage 函数(第600-638行)实现了这个分组逻辑。它将消息按“assistant 消息边界“分组——只有未曾见过的 assistant message.id 才创建新的分组边界:
// utils/toolResultStorage.ts:624-635
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)
}
}
}
这里有一个微妙的边界情况:当并行工具执行中发生中止(abort),agent_progress 消息可能插入到 tool_result 消息之间。如果在 progress 消息处创建分组边界,那些 tool_result 就会被拆分到不同的低于预算的小组中,绕过了聚合预算检查——但 normalizeMessagesForAPI 会在线路上把它们合并为一条超预算的大消息。代码通过只在 assistant 消息处分组(忽略 progress、attachment 等类型)来避免这个问题。
预算执行与状态冻结
消息级别预算的核心机制是 enforceToolResultBudget 函数(第769-908行)。它的设计围绕一个关键约束:prompt cache 稳定性。一旦模型看到了某个工具结果(无论是完整内容还是替代预览),这个决策在后续所有 API 调用中必须保持一致。否则,前缀变化会导致 prompt cache 失效。
这引出了“三态分区“机制:
flowchart LR
subgraph CRS["ContentReplacementState"]
direction TB
S["seenIds: Set < string ><br />replacements: Map < string, string >"]
subgraph States["三种状态"]
direction LR
MA["mustReapply<br />在 seenIds 且有替换内容<br />→ 重新应用缓存的替换"]
FR["frozen<br />在 seenIds 但无替换<br />→ 不可变更,原样保留"]
FH["fresh<br />不在 seenIds 中的新结果<br />→ 可被选中进行替换"]
S --> States
图 12-2:工具结果三态分区与状态转换
每轮 API 调用前的执行流程:
- 对每个消息分组,将候选的 tool_result 按上述三态分区
- mustReapply:从 Map 中取出之前缓存的替代字符串,原样重新应用——零 I/O,字节级一致
- frozen:之前看过但没有被替换的结果——不可再替换(否则会破坏 prompt cache 前缀)
- fresh:本轮新增的结果——检查聚合预算,超预算时按大小降序选择最大的结果进行持久化
选择哪些 fresh 结果进行替换的逻辑在 selectFreshToReplace(第675-692行)中:按大小降序排列,逐一选中直到剩余总量(frozen + 未选中的 fresh)降到预算限制以下。如果仅 frozen 结果就超过了预算,则接受超额——microcompact 最终会清理它们。
状态标记的时序
代码中有一个精心设计的时序约束(第833-842行)。未被选中持久化的候选者立即同步标记为 seen(加入 seenIds),而被选中持久化的候选者则在 await persistToolResult() 完成后才标记——这保证了 seenIds.has(id) 和 replacements.has(id) 的一致性。注释解释了原因:如果一个 ID 出现在 seenIds 中但不在 replacements 中,它会被分类为 frozen(不可替换),导致完整内容被发送;而同时主线程可能发送的是预览——两边不一致会导致 prompt cache 失效。
12.3 Token 计数:规范 vs 粗略估算
两种计数机制
Claude Code 维护两套 token 计数机制,适用于不同场景:
| 特性 | 规范计数(API usage) | 粗略估算 |
|---|---|---|
| 数据来源 | API 响应中的 usage 字段 | 字符长度 / 字节-per-token 系数 |
| 精确度 | 精确 | 偏差可达 ±50% |
| 可用时机 | API 调用完成后 | 任何时刻 |
| 用途 | 阈值判断、预算计算、计费 | 填补 API 调用间的空白 |
表 12-2:Token 计数两种机制对比
规范计数:从 API Usage 到上下文大小
API 响应的 usage 对象包含多个字段,getTokenCountFromUsage 函数(utils/tokens.ts:46-53)将它们组合为完整的上下文窗口大小:
// utils/tokens.ts:46-53
// export function getTokenCountFromUsage(usage: Usage): number {
return (
usage.input_tokens +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
usage.output_tokens
)
}
这个计算包含了四个组成部分:input_tokens(本次请求的非缓存输入)、cache_creation_input_tokens(本次新写入缓存的 token)、cache_read_input_tokens(从缓存读取的 token)和 output_tokens(模型生成的输出)。注意缓存相关的字段是可选的(?? 0),因为不是所有 API 提供方都返回这些字段。
粗略估算:4 字节/token 规则
当 API usage 不可用时——例如在两次 API 调用之间新增了消息——Claude Code 使用字符长度除以经验系数来估算 token 数。核心估算函数在 services/tokenEstimation.ts:203-208:
// services/tokenEstimation.ts:203-208
// export function roughTokenCountEstimation(
content: string,
bytesPerToken: number = 4,
): number {
return Math.round(content.length / bytesPerToken)
}
默认的 4 字节/token 是一个保守估算。Claude 的 tokenizer 对英文文本的实际比率约在 3.5-4.5 之间,取 4 作为经验中位数。但不同内容类型的实际比率差异很大:
| 内容类型 | 字节/Token 系数 | 来源 |
|---|---|---|
| 普通文本(英文、代码) | 4 | 默认值(tokenEstimation.ts:204) |
| JSON / JSONL / JSONC | 2 | bytesPerTokenForFileType(tokenEstimation.ts:216-224) |
| 图片(image block) | 固定 2,000 token | roughTokenCountEstimationForBlock(第400-412行) |
| PDF 文档(document block) | 固定 2,000 token | 同上 |
表 12-3:文件类型感知的 Token 估算规则汇总
JSON 文件使用 2 而非 4 的原因在注释(第213-215行)中解释得很清楚:密集的 JSON 包含大量单字符 token({、}、:、,、"),这使得每个 token 平均只对应约 2 个字节。如果仍然用 4 来估算,一个 100KB 的 JSON 文件会被估算为 25K token,而实际可能接近 50K——这个低估可能导致超大的工具结果未被持久化,悄悄进入上下文。
bytesPerTokenForFileType(第215-224行)根据文件扩展名返回不同的系数:
// services/tokenEstimation.ts:215-224
// export function bytesPerTokenForFileType(fileExtension: string): number {
switch (fileExtension) {
case 'json':
case 'jsonl':
case 'jsonc':
return 2
default:
return 4
}
}
图片和文档的固定估算
图片和 PDF 文档是特殊情况。API 对图片的实际 token 计费是 (width × height) / 750,图片会被缩放到最大 2000×2000 像素(约 5,333 token)。但在粗略估算中,Claude Code 统一使用 2,000 token 的固定值(第400-412行)。
这里有一个重要的工程考量:如果图片或 PDF 的 source.data(base64 编码)被送入通用的 JSON 序列化路径,一个 1MB 的 PDF 会产生约 1.33M 的 base64 字符,按 4 字节/token 估算就是约 325K token——远高于 API 实际收费的 ~2,000 token。因此代码在通用估算之前显式检查 block.type === 'image' || block.type === 'document' 并提前返回固定值,避免灾难性的高估。
12.4 并行工具调用的 Token 计数陷阱
消息交错问题
并行工具调用引入了一个微妙但严重的 token 计数问题。tokenCountWithEstimation——Claude Code 中用于阈值判断的规范函数——的实现(utils/tokens.ts:226-261)包含了对这个问题的详细分析。
问题的根源在于消息数组的交错结构。当模型发起两个并行工具调用时,内部消息数组呈现如下形式:
索引: ... i-3 i-2 i-1 i
消息: ... asst(A) user(tr_1) asst(A) user(tr_2)
↑ usage ↑ 相同 usage
两个 assistant 记录共享同一个 message.id 和相同的 usage(因为它们来自同一个 API 响应的不同 content block)。如果简单地从后往前找到第一个有 usage 的 assistant 消息(索引 i-1),然后估算它之后的消息(只有索引 i 处的 user(tr_2)),就会遗漏索引 i-2 处的 user(tr_1)。
但在下一次 API 请求中,user(tr_1) 和 user(tr_2) 都会出现在输入中。这意味着 tokenCountWithEstimation 会系统性地低估上下文大小。
实际上下文中包含的内容
┌──────────────────────────────────────┐
│ asst(A) user(tr_1) asst(A) user(tr_2)│
└──────────────────────────────────────┘
↑ ↑
遗漏! 只估算了这个
修正后的估算范围
┌──────────────────────────────────────┐
│ asst(A) user(tr_1) asst(A) user(tr_2)│
└──────────────────────────────────────┘
↑ 回溯到首个同 ID 的 assistant ↑
从这里开始估算所有后续消息
图 12-3:并行工具调用的 token 计数回溯修正
同 ID 回溯修正
tokenCountWithEstimation 的解决方案是在找到最后一个有 usage 的 assistant 记录后,向前回溯到共享同一 message.id 的第一个 assistant 记录:
// utils/tokens.ts:235-250
const responseId = getAssistantMessageId(message)
if (responseId) {
let j = i - 1
while (j >= 0) {
const prior = messages[j]
const priorId = prior ? getAssistantMessageId(prior) : undefined
if (priorId === responseId) {
i = j // 锚定到更早的同 ID 记录
} else if (priorId !== undefined) {
break // 遇到不同 API 响应,停止回溯
}
j--
}
}
注意回溯逻辑中的三种情况:
priorId === responseId:同一 API 响应的更早分片——将锚点移到这里priorId !== undefined(且不同 ID):遇到了另一个 API 响应——停止回溯priorId === undefined:这是 user/tool_result/attachment 消息——可能是分片之间交错的工具结果,继续回溯
回溯完成后,从锚点之后的所有消息(包括所有交错的 tool_result)都会被纳入粗略估算:
// utils/tokens.ts:253-256
return (
getTokenCountFromUsage(usage) +
roughTokenCountEstimationForMessages(messages.slice(i + 1))
)
最终的上下文大小 = 最后一次 API 响应的精确 usage + 此后所有新增消息的粗略估算。这种“精确基线 + 增量估算“的混合方法平衡了精度和性能。
何时不应使用哪个函数
源码中的注释(第118-121行,第207-212行)反复强调了函数选择的重要性:
tokenCountWithEstimation:规范函数,用于所有阈值比较(自动压缩触发、会话记忆初始化等)tokenCountFromLastAPIResponse:只返回最后一次 API 调用的精确 token 总量,不包含新增消息的估算——不适合阈值判断messageTokenCountFromLastAPIResponse:只返回output_tokens——仅用于衡量模型单次生成了多少 token,不反映上下文窗口的使用量
误用这些函数的后果是实际的:如果用 messageTokenCountFromLastAPIResponse 来判断是否需要压缩,返回值可能只有几千(一次助手回复的输出),而实际上下文已经超过 180K——压缩永远不会触发,最终导致 API 调用因超过窗口限制而失败。
12.5 辅助计数:API token 计数与 Haiku 回退
countTokens API
除了粗略估算,Claude Code 还可以通过 API 获取精确的 token 计数。countMessagesTokensWithAPI(services/tokenEstimation.ts:140-201)调用 anthropic.beta.messages.countTokens 端点,传入完整的消息列表和工具定义,获取精确的 input_tokens 值。
这个 API 用于需要精确计数的场景(如工具定义的 token 开销评估),但有延迟开销——它需要一次额外的 HTTP 往返。因此日常的阈值判断使用 tokenCountWithEstimation 的混合方法,只在特定场景下使用 API 计数。
Haiku 回退方案
当 countTokens API 不可用(例如某些 Bedrock 配置)时,countTokensViaHaikuFallback(第251-325行)使用一种巧妙的替代方案:向 Haiku(小模型)发送一个 max_tokens: 1 的请求,利用返回的 usage 获取精确的输入 token 数。代价是消耗一次小模型调用的 API 费用,但获得了精确度。
函数在选择回退模型时需要考虑多个平台约束:
- Vertex 全局区域:Haiku 不可用,回退到 Sonnet
- Bedrock + thinking blocks:Haiku 3.5 不支持 thinking,回退到 Sonnet
- 其他情况:使用 Haiku(成本最低)
12.6 端到端的 Token 预算体系
将上述所有机制组合起来,Claude Code 的 token 预算形成一个多层防御体系:
flowchart TB
subgraph L1["第1层:单工具结果持久化"]
L1D["工具执行 → 结果 > 阈值? → 持久化到磁盘 + 2KB 预览<br />阈值 = min(工具声明值, 50K) 或 GrowthBook 覆盖<br />特例:Read (Infinity), 图片 (跳过)"]
subgraph L2["第2层:单消息聚合预算"]
L2D["API 调用前 → tool_result 总量 > 200K?<br />→ 按大小降序持久化 fresh 结果,直到总量 ≤ 200K<br />→ 状态冻结:seen 结果命运永不改变(prompt cache 稳定性)"]
subgraph L3["第3层:上下文窗口追踪"]
L3D["tokenCountWithEstimation() = 精确 usage + 增量粗略估算<br />→ 驱动自动压缩、微压缩等决策<br />→ 并行工具调用:同 ID 回溯修正,避免系统性低估"]
subgraph L4["第4层:自动压缩 / 微压缩(详见第9-11章)"]
L4D["上下文接近窗口限制 → 压缩历史消息 / 清理旧工具结果"]
L1 -->|"未拦截则进入"| L2
L2 -->|"未拦截则进入"| L3
L3 -->|"超过阈值触发"| L4
图 12-4:Token 预算的四层防御体系
每一层都有明确的职责边界和失败后的降级路径:
- 第1层失败(持久化磁盘出错)→ 原样返回完整结果,第2层和第4层兜底
- 第2层的 frozen 结果无法替换 → 接受超额,由第4层的 microcompact 最终清理
- 第3层的粗略估算不准确 → 可能导致压缩触发过早或过晚,但不会导致数据丢失
GrowthBook 动态调参
两个核心阈值都可以通过 GrowthBook feature flag 在运行时调整,无需发布新版本:
tengu_satin_quoll:单工具持久化阈值的 per-tool 覆盖 maptengu_hawthorn_window:单消息聚合预算的全局覆盖值
getPerMessageBudgetLimit(第421-434行)展示了覆盖逻辑的防御性编码——对 GrowthBook 返回的值进行 typeof、isFinite、> 0 三重检查,因为缓存层可能泄漏 null、NaN 或字符串类型的值。
12.7 用户能做什么
12.7.1 控制工具输出大小
当你的 grep 或 bash 命令返回大量输出时(超过 50K 字符),结果会被持久化到磁盘,模型只能看到前 2KB 的预览。为了避免这种信息损失,尽量使用更精确的搜索条件——比如用 grep -l(只列文件名)替代全文搜索,或者用 head -n 100 限制命令输出。这样模型能看到完整结果,而不是被截断的预览。
12.7.2 注意并行工具调用的累积效应
当模型同时发起多个搜索时,所有结果的聚合大小受 200K 字符限制。如果你要求模型“同时搜索这 10 个关键词“,部分结果可能因超出预算而被持久化。考虑将大规模搜索拆分为几轮较小的搜索,或者让模型逐步搜索以保持每轮结果在预算内。
12.7.3 JSON 文件的特殊考量
JSON 文件的 token 密度是普通代码的 2 倍(每 token 约 2 字节 vs 4 字节)。这意味着一个 100KB 的 JSON 文件实际消耗约 50K token,而同等大小的 TypeScript 文件只消耗约 25K token。当你让模型读取大型 JSON 配置或数据文件时,要意识到它们对上下文窗口的压力更大。
12.7.4 利用 Read 工具的特殊地位
Read 工具的输出永远不会被持久化到磁盘——它通过自己的 maxTokens 参数控制大小。这意味着通过 Read 读取的文件内容始终直接呈现给模型,不会被截断为 2KB 预览。如果你需要模型完整看到某个文件的内容,使用 Read 比 cat 命令更可靠。
12.7.5 关注 token 计数的粗略估算偏差
Claude Code 在两次 API 调用之间使用粗略估算(字符数 / 4)来追踪上下文大小,偏差可达 ±50%。这意味着自动压缩的触发时机可能早于或晚于预期。如果你观察到压缩在意想不到的时机发生,这通常是估算偏差导致的正常行为,而非 bug。
12.8 设计洞察
保守估算 vs 激进估算
Token 预算体系中反复出现的设计取舍是:宁可高估 token 数量,也不要低估。
- JSON 使用 2 字节/token 而非 4,是因为低估会导致超大结果未被持久化
- 图片使用固定 2,000 token 而非 base64 长度估算,是因为后者会导致灾难性高估(上下文看起来“满“了但其实不满)
- 并行工具调用的回溯修正,是因为遗漏 tool_result 会导致系统性低估
这些选择体现了一个原则:token 预算是一个安全机制而非优化机制。高估的代价是提前触发压缩(轻微的性能损失),低估的代价是上下文窗口溢出(API 调用失败)。
Prompt Cache 对预算设计的深层影响
消息级别预算的大部分复杂性——三态分区、状态冻结、字节级一致的重新应用——都源于一个外部约束:prompt cache 要求前缀稳定。如果没有 prompt cache,每轮 API 调用前可以自由地重新评估所有工具结果是否需要持久化。但 prompt cache 的存在意味着一旦模型“看到了“某个工具结果的完整内容,后续调用必须继续发送完整内容(否则前缀变化导致缓存失效)。
这个约束将一个本可以是无状态的函数(“检查大小,超标则替换”)变成了一个有状态的状态机(ContentReplacementState),而且状态必须跨越会话恢复(resume)存活——这就是 ContentReplacementRecord 被持久化到 transcript 的原因。
这是一个典型的例子:在 AI Agent 系统中,性能优化(prompt cache)会反向约束功能设计(预算执行),形成意想不到的架构耦合。
第13章:缓存架构与断点设计
为什么这很重要
在第12章中,我们讨论了 Token 预算策略如何控制进入上下文窗口的内容大小。但还有一个更隐蔽的成本问题:即使上下文窗口内的内容完全相同,每次 API 调用仍然需要为系统提示词和工具定义付费。
对于一个典型的 Claude Code 会话,系统提示词约 11,000 tokens,40+ 个工具的 Schema 定义又贡献约 20,000 tokens——仅这些“固定开销“每次调用就消耗 30,000+ tokens。在一个 50 轮的会话中,这意味着 1,500,000 tokens 被重复处理。按 Anthropic 的定价,这是一笔不可忽视的成本。
Anthropic 的提示词缓存(Prompt Caching)机制正是为解决这个问题而生:如果 API 请求的前缀与之前的请求匹配,服务端可以直接复用已缓存的 KV 状态,将缓存命中部分的费用降低 90%。但缓存命中有严格的条件——前缀必须逐字节匹配。一个字符的变化就会导致缓存未命中(cache miss),也就是“缓存中断“(cache break)。
Claude Code 围绕这个约束构建了一套精密的缓存架构,包含三级缓存范围、两种 TTL 层级、以及一组防止缓存中断的“锁存“(latching)机制。本章将深入这套架构的设计与实现。
13.1 Anthropic API 提示词缓存基础
前缀匹配模型
Anthropic 的提示词缓存基于前缀匹配原则。服务端将 API 请求视为一个序列化的字节流,从头开始逐字节比较。一旦发现不匹配,缓存就在该位置“断裂“——之前的部分可以复用,之后的部分需要重新计算。
这意味着缓存的有效性完全取决于请求前缀的稳定性。API 请求的序列化顺序大致为:
[系统提示词] → [工具定义] → [消息历史]
系统提示词和工具定义位于序列的前端,它们的任何变化都会使整个缓存失效。消息历史追加在末尾,新消息只需为增量部分付费。
cache_control 标记
要启用缓存,需要在 API 请求的内容块上添加 cache_control 标记:
// cache_control 的基本形式
{
type: 'ephemeral'
}
// 扩展形式(1P 专属)
{
type: 'ephemeral',
scope: 'global' | 'org', // 缓存范围
ttl: '5m' | '1h' // 缓存生存时间
}
type: 'ephemeral' 是唯一支持的缓存类型,表示这是一个临时缓存断点。Claude Code 在 utils/api.ts(第68-78行)中定义了扩展的工具 Schema 类型,包含完整的 cache_control 选项:
// utils/api.ts:68-78
type BetaToolWithExtras = BetaTool & {
strict?: boolean
defer_loading?: boolean
cache_control?: {
type: 'ephemeral'
scope?: 'global' | 'org'
ttl?: '5m' | '1h'
}
eager_input_streaming?: boolean
}
缓存断点的放置
Claude Code 在请求中精心放置缓存断点,通过 getCacheControl() 函数(services/api/claude.ts,第358-374行)生成统一的 cache_control 对象:
// services/api/claude.ts:358-374
// export function getCacheControl({
scope,
querySource,
}: {
scope?: CacheScope
querySource?: QuerySource
} = {}): {
type: 'ephemeral'
ttl?: '1h'
scope?: CacheScope
} {
return {
type: 'ephemeral',
...(should1hCacheTTL(querySource) && { ttl: '1h' }),
...(scope === 'global' && { scope }),
}
}
这个函数看似简单,但它的每个条件分支都蕴含着深思熟虑的缓存策略。
13.2 三级缓存范围
Claude Code 使用三种缓存范围(cache scope),每种范围对应不同的复用粒度。这些范围通过 splitSysPromptPrefix() 函数(utils/api.ts,第321-435行)分配给系统提示词的不同部分。
范围定义
| 缓存范围 | 标识符 | 复用粒度 | 适用内容 | TTL |
|---|---|---|---|---|
| 全局缓存 | 'global' | 跨组织、跨用户 | 所有 Claude Code 实例共享的静态提示词 | 5 分钟(默认) |
| 组织缓存 | 'org' | 同一组织内的用户 | 包含组织特定但用户无关的内容 | 5 分钟 / 1 小时 |
| 无缓存 | null | 不设置 cache_control | 高度动态的内容 | 不适用 |
表 13-1:三级缓存范围对比
全局缓存范围(global)
全局缓存是最激进的优化——标记为 global 的内容可以在所有 Claude Code 用户之间共享 KV 缓存。这意味着当用户 A 发起一个请求,缓存了系统提示词的静态部分后,用户 B 的下一个请求可以直接命中这个缓存。
全局缓存的适用条件非常严格:内容必须是完全不变的,不能包含任何用户特定、组织特定、甚至时间特定的信息。Claude Code 通过一个“动态边界标记“(SYSTEM_PROMPT_DYNAMIC_BOUNDARY)将系统提示词分为静态和动态两部分:
// utils/api.ts:362-404(简化)
if (useGlobalCacheFeature) {
const boundaryIndex = systemPrompt.findIndex(
s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
)
if (boundaryIndex !== -1) {
// 边界之前的内容 → cacheScope: 'global'
// 边界之后的内容 → cacheScope: null
for (let i = 0; i < systemPrompt.length; i++) {
if (i < boundaryIndex) {
staticBlocks.push(block)
} else {
dynamicBlocks.push(block)
}
}
// ...
if (staticJoined)
result.push({ text: staticJoined, cacheScope: 'global' })
if (dynamicJoined)
result.push({ text: dynamicJoined, cacheScope: null })
}
}
注意边界之后的动态内容被标记为 cacheScope: null——它甚至不使用 org 级别的缓存,因为动态内容的变化频率太高,缓存命中率极低,标记缓存断点反而增加了 API 请求的复杂度。
组织缓存范围(org)
当全局缓存不可用时(例如没有启用全局缓存功能,或内容包含组织特定信息),Claude Code 回退到 org 级别:
// utils/api.ts:411-435(默认模式)
let attributionHeader: string | undefined
let systemPromptPrefix: string | undefined
const rest: string[] = []
for (const block of systemPrompt) {
if (block.startsWith('x-anthropic-billing-header')) {
attributionHeader = block
} else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
systemPromptPrefix = block
} else {
rest.push(block)
}
}
const result: SystemPromptBlock[] = []
if (attributionHeader)
result.push({ text: attributionHeader, cacheScope: null })
if (systemPromptPrefix)
result.push({ text: systemPromptPrefix, cacheScope: 'org' })
const restJoined = rest.join('\n\n')
if (restJoined)
result.push({ text: restJoined, cacheScope: 'org' })
这里的分块策略揭示了一个重要细节:计费归属头(x-anthropic-billing-header)被标记为 null,不参与缓存。这是因为归属头包含用户身份信息,在 org 级别也不可共享。而 CLI 系统提示词前缀(CLI_SYSPROMPT_PREFIXES)和剩余系统提示词内容都标记为 org,在同一组织内共享。
MCP 工具的特殊处理
当用户配置了 MCP 工具时,全局缓存的策略发生变化。因为 MCP 工具的定义由外部服务器提供,其内容不可预测,将它们纳入全局缓存会降低命中率。Claude Code 通过 skipGlobalCacheForSystemPrompt 标志处理这种情况:
// utils/api.ts:326-360
if (useGlobalCacheFeature && options?.skipGlobalCacheForSystemPrompt) {
logEvent('tengu_sysprompt_using_tool_based_cache', {
promptBlockCount: systemPrompt.length,
})
// 所有内容降级为 org 范围,跳过边界标记
// ...
}
这种降级是保守但合理的——与其冒全局缓存被频繁击穿的风险,不如退回到命中率更稳定的 org 级别。
13.3 缓存 TTL 层级
默认 5 分钟 vs 1 小时
Anthropic 的提示词缓存默认 TTL 为 5 分钟。这意味着如果用户在 5 分钟内没有发起新的 API 请求,缓存就会过期。对于活跃的编程会话,5 分钟通常足够。但对于需要频繁思考、查阅文档的场景,5 分钟可能不够。
Claude Code 支持将 TTL 提升到 1 小时,通过 should1hCacheTTL() 函数(services/api/claude.ts,第393-434行)决定是否启用:
// services/api/claude.ts:393-434
// function should1hCacheTTL(querySource?: QuerySource): boolean {
// 3P Bedrock 用户通过环境变量 opt-in
if (
getAPIProvider() === 'bedrock' &&
isEnvTruthy(process.env.ENABLE_PROMPT_CACHING_1H_BEDROCK)
) {
return true
}
// 锁存资格检查——防止会话中途 overage 翻转改变 TTL
let userEligible = getPromptCache1hEligible()
if (userEligible === null) {
userEligible =
process.env.USER_TYPE === 'ant' ||
(isClaudeAISubscriber() && !currentLimits.isUsingOverage)
setPromptCache1hEligible(userEligible)
}
if (!userEligible) return false
// 缓存 allowlist——同样锁存以保持会话稳定
let allowlist = getPromptCache1hAllowlist()
if (allowlist === null) {
const config = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_prompt_cache_1h_config', {}
)
allowlist = config.allowlist ?? []
setPromptCache1hAllowlist(allowlist)
}
return (
querySource !== undefined &&
allowlist.some(pattern =>
pattern.endsWith('*')
? querySource.startsWith(pattern.slice(0, -1))
: querySource === pattern,
)
)
}
资格检查的锁存机制
should1hCacheTTL() 中最关键的设计是锁存(latching)。函数首次调用时计算用户是否有资格使用 1 小时 TTL,然后将结果存入全局 STATE(bootstrap/state.ts):
// bootstrap/state.ts:1700-1706
// export function getPromptCache1hEligible(): boolean | null {
return STATE.promptCache1hEligible
}
// export function setPromptCache1hEligible(eligible: boolean | null): void {
STATE.promptCache1hEligible = eligible
}
为什么需要锁存?考虑以下场景:
- 会话开始时,用户在订阅配额内(
isUsingOverage === false),获得 1 小时 TTL - 会话进行到第 30 轮时,用户超出配额(
isUsingOverage === true) - 如果此时 TTL 从 1 小时降回 5 分钟,
cache_control对象的序列化结果发生变化 - 这个变化会导致 API 请求的前缀不再匹配——缓存中断
一次 overage 状态翻转导致 ~20,000 tokens 的系统提示词和工具定义缓存全部失效,这显然是不可接受的。锁存机制确保一旦会话开始时确定了 TTL 等级,整个会话期间保持不变。
同样的锁存逻辑也应用于 GrowthBook 的 allowlist 配置——防止 GrowthBook 的磁盘缓存在会话中途更新导致 TTL 行为变化。
TTL 层级决策表
| 条件 | TTL | 备注 |
|---|---|---|
3P Bedrock + ENABLE_PROMPT_CACHING_1H_BEDROCK=1 | 1 小时 | Bedrock 用户自行管理计费 |
Anthropic 员工 (USER_TYPE=ant) | 1 小时 | 内部用户 |
| Claude AI 订阅者 + 未超配额 | 1 小时 | 需通过 GrowthBook allowlist |
| 其他用户 | 5 分钟 | 默认 |
表 13-2:缓存 TTL 决策矩阵
13.4 Beta Header 锁存机制
问题:动态 Header 导致缓存击穿
Anthropic API 的请求中包含一组“beta headers“,标识客户端使用的实验性功能。这些 header 是服务端缓存键的一部分——添加或移除一个 header 就会改变缓存键,导致缓存中断。
Claude Code 有多个功能可以在会话中途动态激活或停用:
- AFK 模式(Auto Mode):用户离开时自动执行任务
- Fast Mode:使用更快但可能更贵的模型
- 缓存编辑(Cached Microcompact):在缓存中进行增量编辑
每次这些功能的状态变化,对应的 beta header 就会被添加或移除,触发缓存中断。代码注释(services/api/claude.ts,第1405-1410行)明确描述了这个问题:
// services/api/claude.ts:1405-1410
// Sticky-on latches for dynamic beta headers. Each header, once first
// sent, keeps being sent for the rest of the session so mid-session
// toggles don't change the server-side cache key and bust ~50-70K tokens.
// Latches are cleared on /clear and /compact via clearBetaHeaderLatches().
// Per-call gates (isAgenticQuery, querySource===repl_main_thread) stay
// per-call so non-agentic queries keep their own stable header set.
锁存实现
Claude Code 的解决方案是“sticky-on“锁存——一旦某个 beta header 在会话中被发送过,它将在整个会话剩余时间内持续发送,即使触发该 header 的功能已经被停用。
以下是三个 beta header 的锁存代码(services/api/claude.ts,第1412-1442行):
AFK 模式 Header:
// services/api/claude.ts:1412-1423
let afkHeaderLatched = getAfkModeHeaderLatched() === true
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (
!afkHeaderLatched &&
isAgenticQuery &&
shouldIncludeFirstPartyOnlyBetas() &&
(autoModeStateModule?.isAutoModeActive() ?? false)
) {
afkHeaderLatched = true
setAfkModeHeaderLatched(true)
}
}
Fast Mode Header:
// services/api/claude.ts:1425-1429
let fastModeHeaderLatched = getFastModeHeaderLatched() === true
if (!fastModeHeaderLatched && isFastMode) {
fastModeHeaderLatched = true
setFastModeHeaderLatched(true)
}
缓存编辑 Header:
// services/api/claude.ts:1431-1442
let cacheEditingHeaderLatched = getCacheEditingHeaderLatched() === true
if (feature('CACHED_MICROCOMPACT')) {
if (
!cacheEditingHeaderLatched &&
cachedMCEnabled &&
getAPIProvider() === 'firstParty' &&
options.querySource === 'repl_main_thread'
) {
cacheEditingHeaderLatched = true
setCacheEditingHeaderLatched(true)
}
}
锁存状态图
三个 beta header 的锁存遵循相同的状态转换模式:
stateDiagram-v2
[*] --> 未锁存
未锁存 --> 已锁存 : 条件首次为真\n(功能激活 + 满足前置条件)
已锁存 --> 已锁存 : 功能停用\n(保持锁存不变)
已锁存 --> 重置 : /clear 或 /compact\n(clearBetaHeaderLatches)
重置 --> 未锁存 : 下次条件评估
state 未锁存 {
[*] : latched = false/null
}
state 已锁存 {
[*] : latched = true
}
state 重置 {
[*] : latched = false/null
}
图 13-1:Beta Header 锁存状态图
关键特性:
- 单向锁存:从 false 到 true 是不可逆的(在当前会话内)
- 条件触发:每个 header 有独立的前置条件组合
- 会话绑定:只有
/clear和/compact命令会重置锁存状态 - 查询隔离:
isAgenticQuery和querySource等条件保持逐调用评估,确保非 agentic 查询有自己稳定的 header 集
锁存汇总表
| Beta Header | 锁存变量 | 前置条件 | 重置时机 |
|---|---|---|---|
| AFK Mode | afkModeHeaderLatched | TRANSCRIPT_CLASSIFIER 启用 + agentic 查询 + 1P 限定 + auto mode 活跃 | /clear, /compact |
| Fast Mode | fastModeHeaderLatched | Fast mode 可用 + 无冷却 + 模型支持 + 请求启用 | /clear, /compact |
| Cache Editing | cacheEditingHeaderLatched | CACHED_MICROCOMPACT 启用 + cachedMC 可用 + 1P + main thread | /clear, /compact |
表 13-3:Beta Header 锁存详情
13.5 Thinking Clear 锁存
除了 beta header 锁存外,还有一个特殊的锁存机制——thinkingClearLatched(services/api/claude.ts,第1446-1456行):
// services/api/claude.ts:1446-1456
let thinkingClearLatched = getThinkingClearLatched() === true
if (!thinkingClearLatched && isAgenticQuery) {
const lastCompletion = getLastApiCompletionTimestamp()
if (
lastCompletion !== null &&
Date.now() - lastCompletion > CACHE_TTL_1HOUR_MS
) {
thinkingClearLatched = true
setThinkingClearLatched(true)
}
}
这个锁存的触发条件是:距离上次 API 完成超过 1 小时(CACHE_TTL_1HOUR_MS = 60 * 60 * 1000)。此时即使使用 1 小时 TTL,缓存也已过期。Thinking Clear 利用这个信号优化 thinking 块的处理——既然缓存已经失效,可以清理累积的 thinking 内容,减少后续请求的 token 消耗。
13.6 缓存架构全景
将上述所有机制组合起来,Claude Code 的缓存架构可以概括为以下层次:
┌──────────────────────────────────────────────────────────┐
│ API 请求构建 │
│ │
│ ┌─── 系统提示词 ───┐ ┌── 工具定义 ──┐ ┌── 消息 ──┐ │
│ │ │ │ │ │ │ │
│ │ [attribution] │ │ [tool 1] │ │ [msg 1] │ │
│ │ scope: null │ │ scope: org │ │ │ │
│ │ │ │ │ │ [msg 2] │ │
│ │ [prefix] │ │ [tool 2] │ │ │ │
│ │ scope: org/null │ │ scope: org │ │ [msg N] │ │
│ │ │ │ │ │ │ │
│ │ [static] │ │ [tool N] │ │ │ │
│ │ scope: global │ │ scope: org │ │ │ │
│ │ │ │ │ │ │ │
│ │ [dynamic] │ │ │ │ │ │
│ │ scope: null │ │ │ │ │ │
│ └──────────────────┘ └──────────────┘ └──────────┘ │
│ │
│ ────────── 前缀匹配方向 ──────────────────────────→ │
│ │
├──────────────────────────────────────────────────────────┤
│ TTL 决策层 │
│ │
│ should1hCacheTTL() → 锁存 → 会话稳定 │
│ │
├──────────────────────────────────────────────────────────┤
│ Beta Header 锁存层 │
│ │
│ afkMode / fastMode / cacheEditing → sticky-on │
│ │
├──────────────────────────────────────────────────────────┤
│ 缓存中断检测层 │
│ (详见第14章) │
└──────────────────────────────────────────────────────────┘
图 13-2:Claude Code 缓存架构全景
13.7 设计洞察
锁存是缓存稳定性的核心模式
Claude Code 在缓存相关的代码中反复使用同一个模式:首次评估 → 锁存 → 会话稳定。这个模式出现在:
- TTL 资格检查(
should1hCacheTTL) - TTL allowlist 配置
- Beta header 发送状态
- Thinking clear 触发
每一处锁存都是为了同一个目的:防止会话中途的状态变化改变 API 请求的序列化结果,从而保护缓存前缀的完整性。
缓存范围是成本与命中率的权衡
三级缓存范围体现了一个清晰的工程权衡:
- global 范围命中率最高(所有用户共享),但要求内容绝对静态
- org 范围命中率适中,允许包含组织级别的差异
- null 不做缓存标记,避免无效的缓存尝试增加请求复杂度
Claude Code 的策略是“能 global 就 global,不能就 org,都不行就放弃“——这比一刀切的策略更精细,也更有效。
MCP 工具是缓存的最大敌人
MCP 工具的引入给缓存带来了严峻挑战。MCP 服务器可以在会话中途连接或断开,工具定义可以在任何时候变化。当检测到 MCP 工具存在时,系统提示词的全局缓存被降级为 org 级别(skipGlobalCacheForSystemPrompt),工具缓存策略也从系统提示词嵌入切换到独立的 tool_based 策略。这些降级措施在第15章的缓存优化模式中还将进一步讨论。
用户能做什么
基于本章分析的缓存架构,以下是构建缓存友好系统时的实践要点:
-
理解前缀匹配的含义:Anthropic 的缓存是严格的前缀匹配。在构建 API 请求时,始终将最稳定、最不可能变化的内容放在最前面(系统提示词静态部分),将动态内容(用户消息、附件)放在最后。
-
为你的系统提示词设计缓存范围:如果你的应用服务多个用户,识别哪些提示词内容是全局共享的(适合
global范围)、哪些是组织级别的(适合org范围)、哪些是完全动态的(不标记cache_control)。一刀切的缓存策略会浪费命中率。 -
使用锁存模式保护缓存键稳定性:任何可能在会话中途变化的配置项(feature flag、用户配额状态、功能开关),如果它们影响 API 请求的序列化结果,都应该在会话开始时锁存。锁存的核心原则是:宁可使用略微过时的值,也不要让缓存键在会话中途发生变化。
-
警惕 MCP 工具对缓存的影响:如果你的应用集成了外部工具(MCP 或类似机制),它们的动态性会显著降低缓存命中率。考虑将外部工具的定义与核心工具分开处理,或在检测到外部工具时降级缓存策略。
-
监控
cache_read_input_tokens:这是判断缓存健康状态的唯一可靠指标。建立基线后,任何显著下降都值得调查。详见第14章的缓存中断检测系统。
小结
本章剖析了 Claude Code 的提示词缓存架构:
- 前缀匹配模型要求 API 请求的前缀逐字节稳定,任何变化都会导致缓存中断
- 三级缓存范围(global/org/null)在命中率和灵活性之间做出精细权衡
- TTL 层级(5 分钟/1 小时)通过锁存机制保证会话内稳定
- Beta Header 锁存使用 sticky-on 模式防止功能开关导致缓存键变化
这些机制共同构成了缓存的“防护层“。但光有防护还不够——当缓存确实发生中断时,系统需要能够检测到并诊断原因。第14章将深入缓存中断检测系统的两阶段架构。
第14章:缓存中断检测系统
为什么这很重要
在第13章中,我们看到 Claude Code 通过锁存机制和精心设计的缓存范围来预防缓存中断。但即使有这些防护措施,缓存中断仍然会发生——工具定义可能因为 MCP 服务器重连而变化,系统提示词可能因为新的附件而增长,模型切换、effort 调整、甚至 GrowthBook 的远程配置更新都可能改变 API 请求的前缀。
更棘手的是,缓存中断是“静默“的。API 响应中的 cache_read_input_tokens 下降了,但没有任何错误信息告诉你为什么。开发者只会注意到成本上升了、延迟增加了,却不知道根因在哪里。
Claude Code 构建了一套两阶段缓存中断检测系统来解决这个问题。整个系统实现在 services/api/promptCacheBreakDetection.ts(728行),是 Claude Code 中为数不多专门服务于可观测性(observability)而非功能的子系统。
14.1 两阶段检测架构
设计思路
缓存中断检测面临一个时序问题:
- 变化发生在请求发送前:系统提示词变了、工具增删了、beta header 翻转了
- 中断确认在响应返回后:只有看到
cache_read_input_tokens的下降才能确认缓存确实被击穿了
仅有阶段 2 是不够的——当检测到 token 下降时,请求已经发送,之前的状态已经丢失,无法回溯原因。仅有阶段 1 也不够——很多客户端变化并不一定导致服务端缓存中断(例如,服务端可能恰好还没有缓存该前缀)。
Claude Code 的解决方案是将检测分为两个阶段:
flowchart LR
subgraph Phase1["阶段 1(请求前)<br />recordPromptState()"]
A1[捕获当前状态] --> A2[对比前次状态]
A2 --> A3[记录变化清单]
A3 --> A4[存为 pendingChanges]
Phase1 -- "API 请求/响应" --> Phase2
subgraph Phase2["阶段 2(响应后)<br />checkResponseForCacheBreak()"]
B1[检查 cache tokens] --> B2[确认是否真正中断]
B2 --> B3[用阶段 1 的变化解释原因]
B3 --> B4[输出诊断信息]
B4 --> B5[发送 analytics 事件]
图 14-1:两阶段检测时序图
调用位置
两个阶段的调用位置在 services/api/claude.ts 中:
阶段 1 在构建 API 请求时调用(第1460-1486行):
// services/api/claude.ts:1460-1486
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
const toolsForCacheDetection = allTools.filter(
t => !('defer_loading' in t && t.defer_loading),
)
recordPromptState({
system,
toolSchemas: toolsForCacheDetection,
querySource: options.querySource,
model: options.model,
agentId: options.agentId,
fastMode: fastModeHeaderLatched,
globalCacheStrategy,
betas,
autoModeActive: afkHeaderLatched,
isUsingOverage: currentLimits.isUsingOverage ?? false,
cachedMCEnabled: cacheEditingHeaderLatched,
effortValue: effort,
extraBodyParams: getExtraBodyParams(),
})
}
注意两个关键设计决策:
- 排除 defer_loading 工具:API 会自动剥离延迟加载的工具,它们不影响实际的缓存键。包含它们会在工具发现或 MCP 服务器重连时产生误报。
- 传入锁存后的值:
fastModeHeaderLatched、afkHeaderLatched、cacheEditingHeaderLatched是锁存后的值,而非实时状态。因为缓存键由实际发送的 header 决定,而非用户当前的设置。
阶段 2 在 API 响应处理完成后调用,传入响应中的缓存 token 统计数据。
14.2 PreviousState:全量状态快照
阶段 1 的核心是 PreviousState 类型——它捕获了所有可能影响服务端缓存键的客户端状态。
字段清单
PreviousState 定义在 promptCacheBreakDetection.ts(第28-69行),包含 15+ 个字段:
| 字段 | 类型 | 作用 | 变化来源 |
|---|---|---|---|
systemHash | number | 系统提示词内容哈希(不含 cache_control) | 提示词内容变化 |
toolsHash | number | 工具 Schema 聚合哈希(不含 cache_control) | 工具增删或定义变化 |
cacheControlHash | number | 系统块的 cache_control 哈希 | 范围或 TTL 翻转 |
toolNames | string[] | 工具名称列表 | 工具增删 |
perToolHashes | Record<string, number> | 每个工具的独立哈希 | 单个工具 Schema 变化 |
systemCharCount | number | 系统提示词字符总数 | 内容增减 |
model | string | 当前模型标识 | 模型切换 |
fastMode | boolean | Fast Mode 状态(锁存后) | Fast Mode 激活 |
globalCacheStrategy | string | 缓存策略类型 | MCP 工具发现/移除 |
betas | string[] | 排序后的 beta header 列表 | Beta header 变化 |
autoModeActive | boolean | AFK Mode 状态(锁存后) | Auto Mode 激活 |
isUsingOverage | boolean | 超额使用状态(锁存后) | 配额状态变化 |
cachedMCEnabled | boolean | 缓存编辑状态(锁存后) | Cached MC 激活 |
effortValue | string | 解析后的 effort 值 | Effort 配置变化 |
extraBodyHash | number | 额外请求体参数哈希 | CLAUDE_CODE_EXTRA_BODY 变化 |
callCount | number | 当前 tracking key 的调用次数 | 自增 |
pendingChanges | PendingChanges | null | 阶段 1 检测到的变化 | 阶段 1 对比结果 |
prevCacheReadTokens | number | null | 上次响应的缓存读取 token 数 | 阶段 2 更新 |
cacheDeletionsPending | boolean | 是否有 cache_edits 删除操作待确认 | Cached MC 删除操作 |
buildDiffableContent | () => string | 懒计算的可 diff 内容 | 用于调试输出 |
表 14-1:PreviousState 完整字段清单
哈希策略
PreviousState 中有多个哈希字段,它们服务于不同的检测粒度:
// promptCacheBreakDetection.ts:170-179
// function computeHash(data: unknown): number {
const str = jsonStringify(data)
if (typeof Bun !== 'undefined') {
const hash = Bun.hash(str)
return typeof hash === 'bigint' ? Number(hash & 0xffffffffn) : hash
}
return djb2Hash(str)
}
systemHash vs cacheControlHash 的分离设计值得特别关注:
// promptCacheBreakDetection.ts:274-281
const systemHash = computeHash(strippedSystem) // 不含 cache_control
const cacheControlHash = computeHash( // 只有 cache_control
system.map(b => ('cache_control' in b ? b.cache_control : null)),
)
systemHash 对系统提示词内容做哈希,先通过 stripCacheControl() 移除 cache_control 标记。cacheControlHash 只对 cache_control 标记做哈希。为什么要分离?因为缓存范围翻转(global → org)或 TTL 翻转(1h → 5m)不会改变提示词的文本内容——如果只看 systemHash,这些翻转会被遗漏。分离后,cacheControlChanged 可以独立捕获这类变化。
perToolHashes 的按需计算也是一个性能优化:
// promptCacheBreakDetection.ts:285-286
const computeToolHashes = () =>
computePerToolHashes(strippedTools, toolNames)
perToolHashes 是一个逐工具的哈希表,用于在工具 Schema 聚合哈希变化时精确定位是哪个工具发生了变化。但逐工具计算哈希的成本较高(N 次 jsonStringify),因此只在 toolsHash 变化时才触发计算。注释(第37行)引用了 BigQuery 数据:77% 的工具 Schema 变化是单个工具的描述改变,而非工具增删。perToolHashes 正是为了精确诊断这 77% 的场景。
跟踪键与隔离策略
每个查询源(query source)维护独立的 PreviousState,存储在一个 Map 中:
// promptCacheBreakDetection.ts:101-107
const previousStateBySource = new Map<string, PreviousState>()
const MAX_TRACKED_SOURCES = 10
const TRACKED_SOURCE_PREFIXES = [
'repl_main_thread',
'sdk',
'agent:custom',
'agent:default',
'agent:builtin',
]
跟踪键通过 getTrackingKey() 函数计算(第149-158行):
// promptCacheBreakDetection.ts:149-158
// function getTrackingKey(
querySource: QuerySource,
agentId?: AgentId,
): string | null {
if (querySource === 'compact') return 'repl_main_thread'
for (const prefix of TRACKED_SOURCE_PREFIXES) {
if (querySource.startsWith(prefix)) return agentId || querySource
}
return null
}
几个重要的设计决策:
- compact 共享 main thread 的跟踪状态:压缩操作使用相同的
cacheSafeParams,共享缓存键,所以应该共享检测状态 - 子 Agent 使用 agentId 隔离:防止同类型的多个并发 Agent 实例之间产生误报
- 不跟踪的查询源返回
null:speculation、session_memory、prompt_suggestion等短生命周期的 Agent 只运行 1-3 轮,没有前后对比的价值 - Map 容量上限:
MAX_TRACKED_SOURCES = 10,防止大量子 Agent 的 agentId 导致内存无限增长
14.3 阶段 1:recordPromptState() 详解
首次调用:建立基线
首次调用 recordPromptState() 时,没有前一个状态可以对比,函数只做两件事:
- 检查 Map 容量,如果达到上限则驱逐最旧的条目
- 创建初始
PreviousState快照,pendingChanges设为null
// promptCacheBreakDetection.ts:298-328
if (!prev) {
while (previousStateBySource.size >= MAX_TRACKED_SOURCES) {
const oldest = previousStateBySource.keys().next().value
if (oldest !== undefined) previousStateBySource.delete(oldest)
}
previousStateBySource.set(key, {
systemHash,
toolsHash,
cacheControlHash,
toolNames,
// ... 所有初始值
callCount: 1,
pendingChanges: null,
prevCacheReadTokens: null,
cacheDeletionsPending: false,
buildDiffableContent: lazyDiffableContent,
perToolHashes: computeToolHashes(),
})
return
}
后续调用:变化检测
后续调用时,函数逐字段对比当前值与前一个状态:
// promptCacheBreakDetection.ts:332-346
const systemPromptChanged = systemHash !== prev.systemHash
const toolSchemasChanged = toolsHash !== prev.toolsHash
const modelChanged = model !== prev.model
const fastModeChanged = isFastMode !== prev.fastMode
const cacheControlChanged = cacheControlHash !== prev.cacheControlHash
const globalCacheStrategyChanged =
globalCacheStrategy !== prev.globalCacheStrategy
const betasChanged =
sortedBetas.length !== prev.betas.length ||
sortedBetas.some((b, i) => b !== prev.betas[i])
const autoModeChanged = autoModeActive !== prev.autoModeActive
const overageChanged = isUsingOverage !== prev.isUsingOverage
const cachedMCChanged = cachedMCEnabled !== prev.cachedMCEnabled
const effortChanged = effortStr !== prev.effortValue
const extraBodyChanged = extraBodyHash !== prev.extraBodyHash
如果任何字段发生变化,函数构建 PendingChanges 对象:
// promptCacheBreakDetection.ts:71-99
type PendingChanges = {
systemPromptChanged: boolean
toolSchemasChanged: boolean
modelChanged: boolean
fastModeChanged: boolean
cacheControlChanged: boolean
globalCacheStrategyChanged: boolean
betasChanged: boolean
autoModeChanged: boolean
overageChanged: boolean
cachedMCChanged: boolean
effortChanged: boolean
extraBodyChanged: boolean
addedToolCount: number
removedToolCount: number
systemCharDelta: number
addedTools: string[]
removedTools: string[]
changedToolSchemas: string[]
previousModel: string
newModel: string
prevGlobalCacheStrategy: string
newGlobalCacheStrategy: string
addedBetas: string[]
removedBetas: string[]
prevEffortValue: string
newEffortValue: string
buildPrevDiffableContent: () => string
}
PendingChanges 不仅记录是否变化(boolean 标志),还记录如何变化(增减了哪些工具、beta header 的增删列表、字符数变化量等)。这些详细信息在阶段 2 的中断解释中至关重要。
工具变化的精确归因
当 toolSchemasChanged 为真时,系统进一步分析是哪些工具发生了变化:
// promptCacheBreakDetection.ts:366-378
if (toolSchemasChanged) {
const newHashes = computeToolHashes()
for (const name of toolNames) {
if (!prevToolSet.has(name)) continue
if (newHashes[name] !== prev.perToolHashes[name]) {
changedToolSchemas.push(name)
}
}
prev.perToolHashes = newHashes
}
这段代码将工具变化分为三类:
- 新增工具:在新列表中但不在旧列表中(
addedTools) - 移除工具:在旧列表中但不在新列表中(
removedTools) - Schema 变化:工具仍在,但 Schema 哈希不同(
changedToolSchemas)
第三类是最常见的——AgentTool 和 SkillTool 的描述中嵌入了动态的 Agent 列表和命令列表,这些内容随会话状态变化。
14.4 阶段 2:checkResponseForCacheBreak() 详解
中断判定标准
阶段 2 在 API 响应返回后调用,核心逻辑是判断缓存是否真正被击穿:
// promptCacheBreakDetection.ts:485-493
const tokenDrop = prevCacheRead - cacheReadTokens
if (
cacheReadTokens >= prevCacheRead * 0.95 ||
tokenDrop < MIN_CACHE_MISS_TOKENS
) {
state.pendingChanges = null
return
}
判定使用双重门槛:
- 相对阈值:缓存读取 token 数下降超过 5%(
< prevCacheRead * 0.95) - 绝对阈值:下降超过 2,000 tokens(
MIN_CACHE_MISS_TOKENS = 2_000)
两个条件必须同时满足才触发中断告警。这避免了两类误报:
- 小幅波动:缓存 token 数的自然变化(几百 tokens)不触发告警
- 比例放大:当基线很小时(例如 1,000 tokens),5% 的波动只有 50 tokens,不值得告警
特殊情况:Cache Deletion
缓存编辑(Cached Microcompact)可以通过 cache_edits 主动删除缓存中的内容块。这会导致 cache_read_input_tokens 合法地下降——这是预期行为,不应触发中断告警:
// promptCacheBreakDetection.ts:473-481
if (state.cacheDeletionsPending) {
state.cacheDeletionsPending = false
logForDebugging(
`[PROMPT CACHE] cache deletion applied, cache read: ` +
`${prevCacheRead} → ${cacheReadTokens} (expected drop)`,
)
state.pendingChanges = null
return
}
cacheDeletionsPending 标志通过 notifyCacheDeletion() 函数设置(第673-682行),由缓存编辑模块在发送删除操作时调用。
特殊情况:Compaction
压缩操作(/compact)会大幅减少消息数量,导致缓存读取 token 数自然下降。notifyCompaction() 函数(第689-698行)通过将 prevCacheReadTokens 重置为 null 来处理这种情况——下一次调用被视为“首次调用“,不做对比:
// promptCacheBreakDetection.ts:689-698
// export function notifyCompaction(
querySource: QuerySource,
agentId?: AgentId,
): void {
const key = getTrackingKey(querySource, agentId)
const state = key ? previousStateBySource.get(key) : undefined
if (state) {
state.prevCacheReadTokens = null
}
}
14.5 中断解释引擎
当确认缓存中断后,系统使用阶段 1 收集的 PendingChanges 构建人类可读的解释。解释引擎位于 checkResponseForCacheBreak() 的第495-588行:
客户端归因
如果 PendingChanges 中有变化标志为真,系统生成对应的解释文本:
// promptCacheBreakDetection.ts:496-563(简化)
const parts: string[] = []
if (changes) {
if (changes.modelChanged) {
parts.push(`model changed (${changes.previousModel} → ${changes.newModel})`)
}
if (changes.systemPromptChanged) {
const charInfo = charDelta > 0 ? ` (+${charDelta} chars)` : ` (${charDelta} chars)`
parts.push(`system prompt changed${charInfo}`)
}
if (changes.toolSchemasChanged) {
const toolDiff = changes.addedToolCount > 0 || changes.removedToolCount > 0
? ` (+${changes.addedToolCount}/-${changes.removedToolCount} tools)`
: ' (tool prompt/schema changed, same tool set)'
parts.push(`tools changed${toolDiff}`)
}
if (changes.betasChanged) {
const added = changes.addedBetas.length ? `+${changes.addedBetas.join(',')}` : ''
const removed = changes.removedBetas.length ? `-${changes.removedBetas.join(',')}` : ''
parts.push(`betas changed (${[added, removed].filter(Boolean).join(' ')})`)
}
// ... 其他字段的解释逻辑类似
}
解释引擎的设计原则是具体胜于抽象:不是简单地说“缓存中断了“,而是精确列出哪些字段变化了、变化了多少。
cacheControl 变化的独立报告逻辑
在解释引擎中,cacheControlChanged 有一个特殊的报告条件:
// promptCacheBreakDetection.ts:528-535
if (
changes.cacheControlChanged &&
!changes.globalCacheStrategyChanged &&
!changes.systemPromptChanged
) {
parts.push('cache_control changed (scope or TTL)')
}
只有在全局缓存策略和系统提示词都没变的情况下,才单独报告 cacheControlChanged。原因是:如果全局缓存策略变了(例如从 tool_based 切换到 system_prompt),cache_control 的变化只是策略变化的后果,不需要重复报告。同理,如果系统提示词变了,cache_control 可能只是因为新的内容块结构调整了缓存标记。
TTL 过期检测
当没有客户端变化被检测到时(parts.length === 0),系统检查是否可能是 TTL 过期导致的缓存失效:
// promptCacheBreakDetection.ts:566-588
const lastAssistantMsgOver5minAgo =
timeSinceLastAssistantMsg !== null &&
timeSinceLastAssistantMsg > CACHE_TTL_5MIN_MS
const lastAssistantMsgOver1hAgo =
timeSinceLastAssistantMsg !== null &&
timeSinceLastAssistantMsg > CACHE_TTL_1HOUR_MS
let reason: string
if (parts.length > 0) {
reason = parts.join(', ')
} else if (lastAssistantMsgOver1hAgo) {
reason = 'possible 1h TTL expiry (prompt unchanged)'
} else if (lastAssistantMsgOver5minAgo) {
reason = 'possible 5min TTL expiry (prompt unchanged)'
} else if (timeSinceLastAssistantMsg !== null) {
reason = 'likely server-side (prompt unchanged, <5min gap)'
} else {
reason = 'unknown cause'
}
TTL 过期检测通过查找消息历史中最近的 assistant 消息时间戳来计算时间间隔。两个 TTL 常量定义在文件顶部(第125-126行):
// promptCacheBreakDetection.ts:125-126
const CACHE_TTL_5MIN_MS = 5 * 60 * 1000
export const CACHE_TTL_1HOUR_MS = 60 * 60 * 1000
服务端归因:“90% 的中断是服务端原因”
最关键的一段注释位于第573-576行:
// promptCacheBreakDetection.ts:573-576
// Post PR #19823 BQ analysis:
// when all client-side flags are false and the gap is under TTL, ~90% of breaks
// are server-side routing/eviction or billed/inference disagreement. Label
// accordingly instead of implying a CC bug hunt.
这段注释引用了一次 BigQuery 数据分析的结论:当客户端没有检测到任何变化,且时间间隔在 TTL 之内时,约 90% 的缓存中断归因于服务端。具体原因包括:
- 服务端路由变化:请求被路由到不同的服务器实例,该实例没有缓存
- 服务端缓存驱逐:在高负载期间,服务端主动驱逐低优先级的缓存条目
- 计费/推理不一致:实际推理使用了缓存,但计费系统报告了不同的 token 数
这个发现改变了中断解释的措辞——从暗示“Claude Code 有 bug“变为明确标记“可能是服务端原因“(likely server-side),避免开发者浪费时间追查不存在的客户端问题。
14.6 诊断输出
中断检测的最终输出包含两部分:
Analytics 事件
tengu_prompt_cache_break 事件发送到 BigQuery 用于全量分析:
// promptCacheBreakDetection.ts:590-644
logEvent('tengu_prompt_cache_break', {
systemPromptChanged: changes?.systemPromptChanged ?? false,
toolSchemasChanged: changes?.toolSchemasChanged ?? false,
modelChanged: changes?.modelChanged ?? false,
// ... 所有变化标志
addedTools: (changes?.addedTools ?? []).map(sanitizeToolName).join(','),
removedTools: (changes?.removedTools ?? []).map(sanitizeToolName).join(','),
changedToolSchemas: (changes?.changedToolSchemas ?? []).map(sanitizeToolName).join(','),
addedBetas: (changes?.addedBetas ?? []).join(','),
removedBetas: (changes?.removedBetas ?? []).join(','),
callNumber: state.callCount,
prevCacheReadTokens: prevCacheRead,
cacheReadTokens,
cacheCreationTokens,
timeSinceLastAssistantMsg: timeSinceLastAssistantMsg ?? -1,
lastAssistantMsgOver5minAgo,
lastAssistantMsgOver1hAgo,
requestId: requestId ?? '',
})
Analytics 事件记录了完整的变化标志集合、token 统计、时间间隔和请求 ID,使得后续的 BigQuery 分析可以切分不同维度(按变化类型、按时间窗口、按查询源等)。
调试 Diff 文件与日志
当检测到客户端变化时,系统生成一个 diff 文件,展示前后状态的逐行差异:
// promptCacheBreakDetection.ts:648-660
let diffPath: string | undefined
if (changes?.buildPrevDiffableContent) {
diffPath = await writeCacheBreakDiff(
changes.buildPrevDiffableContent(),
state.buildDiffableContent(),
)
}
const summary = `[PROMPT CACHE BREAK] ${reason} ` +
`[source=${querySource}, call #${state.callCount}, ` +
`cache read: ${prevCacheRead} → ${cacheReadTokens}, ` +
`creation: ${cacheCreationTokens}${diffSuffix}]`
logForDebugging(summary, { level: 'warn' })
diff 文件通过 writeCacheBreakDiff() 生成(第708-727行),使用 createPatch 库创建标准的 unified diff 格式,保存在临时目录中。文件名包含随机后缀避免冲突。
工具名称安全化
中断检测系统需要在 analytics 事件中报告发生变化的工具名称。但 MCP 工具的名称由用户配置,可能包含文件路径或其他敏感信息。sanitizeToolName() 函数(第183-185行)解决了这个问题:
// promptCacheBreakDetection.ts:183-185
// function sanitizeToolName(name: string): string {
return name.startsWith('mcp__') ? 'mcp' : name
}
所有以 mcp__ 开头的工具名称被统一替换为 'mcp',而内置工具的名称是一个固定词汇表,可以安全地包含在 analytics 中。
14.7 完整检测流程
将两个阶段串联起来,完整的缓存中断检测流程如下:
用户输入新查询
│
▼
┌──────────────────────────────────┐
│ 构建 API 请求 │
│ (系统提示词 + 工具 + 消息) │
└────────────────┬─────────────────┘
│
▼
┌──────────────────────────────────┐
│ recordPromptState() [阶段 1] │
│ │
│ ① 计算所有哈希值 │
│ ② 查找 previousState │
│ ③ 无 prev → 创建初始快照 │
│ ④ 有 prev → 逐字段对比 │
│ ⑤ 发现变化 → 生成 PendingChanges │
│ ⑥ 更新 previousState │
└────────────────┬─────────────────┘
│
▼
[发送 API 请求]
│
▼
[收到 API 响应]
│
▼
┌──────────────────────────────────┐
│ checkResponseForCacheBreak() │
│ [阶段 2] │
│ │
│ ① 获取 previousState │
│ ② 排除 haiku 模型 │
│ ③ 检查 cacheDeletionsPending │
│ ④ 计算 token 下降量 │
│ ⑤ 应用双重门槛判定 │
│ (> 5% 且 > 2,000 tokens) │
│ ⑥ 未中断 → 清除 pending, 返回 │
│ ⑦ 中断确认 → 构建解释 │
│ - 有客户端变化 → 列举变化 │
│ - 无变化 + 超 TTL → TTL 过期 │
│ - 无变化 + 未超 TTL → 服务端 │
│ ⑧ 发送 analytics 事件 │
│ ⑨ 写入 diff 文件 │
│ ⑩ 输出调试日志 │
└──────────────────────────────────┘
图 14-2:完整缓存中断检测流程图
14.8 排除模型与清理机制
排除模型
并非所有模型都适合缓存中断检测:
// promptCacheBreakDetection.ts:129-131
// function isExcludedModel(model: string): boolean {
return model.includes('haiku')
}
Haiku 模型因为有不同的缓存行为,被排除在检测之外。这避免了因模型差异导致的误报。
清理机制
系统提供三个清理函数,分别应对不同场景:
// promptCacheBreakDetection.ts:700-706
// Agent 结束时清理其跟踪状态
// export function cleanupAgentTracking(agentId: AgentId): void {
previousStateBySource.delete(agentId)
}
// 完全重置(/clear 命令)
// export function resetPromptCacheBreakDetection(): void {
previousStateBySource.clear()
}
cleanupAgentTracking 在子 Agent 结束时调用,释放其 PreviousState 占用的内存。resetPromptCacheBreakDetection 在用户执行 /clear 时调用,清除所有跟踪状态。
14.9 设计洞察
两阶段是唯一正确的架构
缓存中断检测的两阶段架构不是一个设计选择,而是由问题的时序约束决定的唯一正确方案。原因在于:原始状态只存在于请求发送前,中断确认只能在响应返回后。任何试图在单一阶段完成两项工作的方案都会丢失关键信息。
“90% 服务端“改变了工程决策
发现大部分缓存中断是服务端原因后,Claude Code 团队的优化重点从“消除所有客户端变化“转向“确保客户端变化可控“。这解释了为什么第13章的锁存机制如此重要——它们不需要消除 100% 的缓存中断,只需要确保客户端可控的那 10% 不再出问题。
可观测性先于优化
整个缓存中断检测系统不做任何缓存优化——它纯粹是可观测性基础设施。但正是这套可观测性使得第15章的优化模式成为可能:没有精确的中断检测,就无法量化优化的效果,也无法发现新的优化机会。BigQuery 中的 tengu_prompt_cache_break 事件数据直接驱动了多个优化模式的发现和验证。
用户能做什么
基于本章分析的缓存中断检测机制,以下是监控和诊断缓存中断的实践要点:
-
在你的应用中建立缓存基线:记录正常会话中
cache_read_input_tokens的典型值。没有基线就无法判断下降是否异常。Claude Code 使用双重门槛(>5% 且 >2,000 tokens)来过滤噪声,你也应该根据自己的场景设定合理的阈值。 -
区分客户端变化与服务端原因:当观察到缓存命中率下降时,先检查客户端是否有变化(系统提示词、工具定义、beta header 等)。如果客户端没有变化且时间间隔在 TTL 之内,大概率是服务端路由或驱逐导致的——不要浪费时间追查不存在的客户端 bug。
-
为你的请求建立状态快照机制:如果你需要诊断缓存中断,在每次请求前记录关键状态(系统提示词哈希、工具 Schema 哈希、请求 header 列表)。只有在请求前捕获状态,才能在响应后回溯变化原因。
-
注意 TTL 过期是常见的合法原因:如果用户在两次请求之间有较长停顿(超过 5 分钟或 1 小时,取决于你的 TTL 等级),缓存自然过期是正常现象,不需要特别处理。
-
对工具变化做精细归因:如果你的应用使用动态工具集(MCP 等),在检测到工具 Schema 变化时,进一步区分是工具增删还是单个工具的 Schema 变化。后者更常见(Claude Code 数据显示 77% 的工具变化属于此类),也更容易通过会话级缓存解决。
小结
本章深入分析了 Claude Code 的缓存中断检测系统:
- 两阶段架构:
recordPromptState()在请求前捕获状态并检测变化,checkResponseForCacheBreak()在响应后确认中断并生成诊断 - PreviousState 15+ 字段:覆盖了所有可能影响服务端缓存键的客户端状态
- 中断解释引擎:区分客户端变化、TTL 过期和服务端原因,提供精确的归因
- 数据驱动的洞察:“90% 的中断是服务端原因“这一发现改变了整个缓存优化策略
下一章将转向主动优化——Claude Code 如何通过 7+ 个命名的缓存优化模式,在源头减少缓存中断的发生。
第15章:缓存优化模式
为什么这很重要
第13章分析了缓存架构的防御层,第14章构建了缓存中断的检测能力。本章将转向进攻——Claude Code 如何通过一系列命名的优化模式,从源头消除或减少缓存中断的发生。
这些优化模式并非一次性设计出来的。每一个模式都源自第14章介绍的缓存中断检测系统在 BigQuery 中捕获的真实数据。当 tengu_prompt_cache_break 事件揭示某个特定的中断原因反复出现时,工程团队就会设计一个针对性的优化模式来消除它。
本章将介绍 7 个以上的命名缓存优化模式,从简单的日期记忆化到复杂的工具 Schema 缓存。每个模式都遵循同一个框架:识别变化源 → 理解变化本质 → 将动态变为静态。
模式汇总
在深入每个模式之前,先看一个全局视图:
| # | 模式名称 | 变化源 | 优化策略 | 关键文件 | 影响范围 |
|---|---|---|---|---|---|
| 1 | 日期记忆化 | 日期跨天变化 | memoize(getLocalISODate) | constants/common.ts | 系统提示词 |
| 2 | 月度粒度 | 日期每日变化 | 使用 “Month YYYY” 而非完整日期 | constants/common.ts | 工具提示词 |
| 3 | Agent 列表附件化 | Agent 列表动态变化 | 从工具描述移至消息附件 | tools/AgentTool/prompt.ts | 工具 Schema(10.2% cache_creation) |
| 4 | 技能列表预算 | 技能数量增长 | 限制为 1% context window | tools/SkillTool/prompt.ts | 工具 Schema |
| 5 | $TMPDIR 占位符 | 用户 UID 嵌入路径 | 替换为 $TMPDIR | tools/BashTool/prompt.ts | 工具提示词 / 全局缓存 |
| 6 | 条件段落省略 | 功能开关改变提示词 | 条件性省略而非添加 | 多处系统提示词 | 系统提示词前缀 |
| 7 | 工具 Schema 缓存 | GrowthBook 翻转 / 动态内容 | 会话级 Map 缓存 | utils/toolSchemaCache.ts | 全部工具 Schema |
表 15-1:7+ 缓存优化模式汇总
15.1 模式一:日期记忆化 getSessionStartDate()
问题
Claude Code 的系统提示词包含当前日期(currentDate),用于帮助模型理解时间上下文。日期通过 getLocalISODate() 函数获取:
// constants/common.ts:4-15
// export function getLocalISODate(): string {
if (process.env.CLAUDE_CODE_OVERRIDE_DATE) {
return process.env.CLAUDE_CODE_OVERRIDE_DATE
}
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
问题出在午夜跨天:如果用户在 23:59 发起一个请求,系统提示词包含 2026-04-01;当用户在 00:01 发起下一个请求时,日期变为 2026-04-02。这一个字符的变化就足以击穿整个系统提示词前缀的缓存——约 11,000 tokens 需要重新计算。
解决方案
// constants/common.ts:24
export const getSessionStartDate = memoize(getLocalISODate)
getSessionStartDate 使用 lodash 的 memoize 包装 getLocalISODate——函数在首次调用时捕获日期,此后永远返回相同的值,无论实际日期是否已经变化。
源码注释(第17-23行)详细解释了这个决策的权衡:
// constants/common.ts:17-23
// Memoized for prompt-cache stability — captures the date once at session start.
// The main interactive path gets this behavior via memoize(getUserContext) in
// context.ts; simple mode (--bare) calls getSystemPrompt per-request and needs
// an explicit memoized date to avoid busting the cached prefix at midnight.
// When midnight rolls over, getDateChangeAttachments appends the new date at
// the tail (though simple mode disables attachments, so the trade-off there is:
// stale date after midnight vs. ~entire-conversation cache bust — stale wins).
设计权衡
权衡是清晰的:过时的日期 vs 缓存全量击穿。选择过时日期是因为:
- 日期信息对大多数编程任务不关键
- 当午夜确实发生时,
getDateChangeAttachments会在消息尾部追加新日期——这不影响前缀缓存 - Simple mode(
--bare)禁用了附件机制,所以必须在源头做记忆化
影响
这个单行优化消除了每日一次的全前缀缓存击穿。对于跨午夜工作的用户,这节省了约 11,000 tokens 的 cache_creation 费用。
15.2 模式二:月度粒度 getLocalMonthYear()
问题
日期记忆化解决了系统提示词中的跨天问题,但工具提示词中也需要时间信息。如果工具提示词使用完整日期(YYYY-MM-DD),每天凌晨都会导致包含该工具的 Schema 缓存失效。而工具 Schema 位于 API 请求的前端位置,其变化的破坏性比系统提示词更大。
解决方案
// constants/common.ts:28-33
// export function getLocalMonthYear(): string {
const date = process.env.CLAUDE_CODE_OVERRIDE_DATE
? new Date(process.env.CLAUDE_CODE_OVERRIDE_DATE)
: new Date()
return date.toLocaleString('en-US', { month: 'long', year: 'numeric' })
}
getLocalMonthYear() 返回 “Month YYYY” 格式(例如 “April 2026”),而非完整日期。变化频率从每日降低到每月。
注释(第27行)说明了设计意图:
// Returns "Month YYYY" (e.g. "February 2026") in the user's local timezone.
// Changes monthly, not daily — used in tool prompts to minimize cache busting.
两种时间精度的分工
| 使用场景 | 函数 | 精度 | 变化频率 | 位置 |
|---|---|---|---|---|
| 系统提示词 | getSessionStartDate() | 日 | 每会话一次 | 系统提示词 |
| 工具提示词 | getLocalMonthYear() | 月 | 每月一次 | 工具 Schema |
这种分工反映了一个基本原则:越靠近 API 请求前端的内容,越需要更低的变化频率。
15.3 模式三:Agent 列表从工具描述移至消息附件
问题
AgentTool 的工具描述中嵌入了可用 Agent 的列表——每个 Agent 的名称、类型和描述。这个列表是动态的:MCP 服务器异步连接会带来新的 Agent、/reload-plugins 命令会刷新插件列表、权限模式变化会改变可用 Agent 集合。
每次列表变化,AgentTool 的工具 Schema 就会改变,导致整个工具 Schema 数组的缓存失效。工具 Schema 在 API 请求中位于系统提示词之后,它的变化不仅废弃自身的缓存,还会废弃下游所有消息的缓存。
源码注释(tools/AgentTool/prompt.ts,第50-57行)量化了这个问题的严重性:
// tools/AgentTool/prompt.ts:50-57
// The dynamic agent list was ~10.2% of fleet cache_creation tokens: MCP async
// connect, /reload-plugins, or permission-mode changes mutate the list →
// description changes → full tool-schema cache bust.
10.2% 的全量 cache_creation tokens 归因于这个问题。
解决方案
// tools/AgentTool/prompt.ts:59-64
// export function shouldInjectAgentListInMessages(): boolean {
if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES))
return false
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false)
}
解决方案是将动态的 Agent 列表从 AgentTool 的工具描述中移出,改为通过消息附件(attachment)注入。工具描述变为静态文本,只描述 AgentTool 的通用功能;可用 Agent 的列表作为 agent_listing_delta 附件追加在用户消息中。
这个迁移的关键洞察是:附件追加在消息尾部,不影响前缀缓存。Agent 列表的变化只增加新消息的 token 成本,不会废弃已缓存的工具 Schema。
影响
消除了 10.2% 的 cache_creation tokens——这是所有优化模式中影响最大的单一改进。通过 GrowthBook feature flag tengu_agent_list_attach 控制灰度发布,同时保留环境变量 CLAUDE_CODE_AGENT_LIST_IN_MESSAGES 作为手动覆盖。
15.4 模式四:技能列表预算(1% Context Window)
问题
SkillTool 类似于 AgentTool,其工具描述中嵌入了可用技能的列表。随着技能生态的增长(内置技能 + 项目技能 + 插件技能),列表可能变得非常长。更重要的是,技能的加载是动态的——不同项目有不同的 .claude/ 配置,插件可能在会话中途加载或卸载。
解决方案
// tools/SkillTool/prompt.ts:20-23
// Skill listing gets 1% of the context window (in characters)
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4
SkillTool 对技能列表施加了严格的预算限制:列表总大小不超过上下文窗口的 1%。对于 200K 的上下文窗口,这约为 8,000 个字符。
预算计算函数(第31-41行):
// tools/SkillTool/prompt.ts:31-41
// export function getCharBudget(contextWindowTokens?: number): number {
if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) {
return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)
}
if (contextWindowTokens) {
return Math.floor(
contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT,
)
}
return DEFAULT_CHAR_BUDGET
}
此外,每个技能条目的描述也被截断:
// tools/SkillTool/prompt.ts:29
export const MAX_LISTING_DESC_CHARS = 250
注释(第25-28行)解释了设计逻辑:
// Per-entry hard cap. The listing is for discovery only — the Skill tool loads
// full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation
// tokens without improving match rate.
缓存优化的本质
1% 预算控制的缓存优化效果体现在两个方面:
- 限制工具描述大小:更短的描述意味着更少的字节需要精确匹配
- 预算裁剪减少抖动:当新技能被加载但预算已满时,它不会被包含在列表中——列表不变,缓存不破
这是一个“预算即稳定“的模式:通过限制动态内容的最大尺寸,间接控制了缓存键的变化幅度。
15.5 模式五:$TMPDIR 占位符
问题
BashTool 的提示词中需要告诉模型可以写入的临时目录路径。Claude Code 使用 getClaudeTempDir() 获取这个路径,格式通常为 /private/tmp/claude-{UID}/,其中 {UID} 是用户的系统 UID。
问题在于:不同用户有不同的 UID,因此路径字符串不同。如果这个路径嵌入在工具提示词中,它会阻止跨用户的全局缓存命中。用户 A 的 /private/tmp/claude-1001/ 和用户 B 的 /private/tmp/claude-1002/ 是不同的字节序列,即使在全局缓存范围内也无法共享。
解决方案
// tools/BashTool/prompt.ts:186-190
// Replace the per-UID temp dir literal (e.g. /private/tmp/claude-1001/) with
// "$TMPDIR" so the prompt is identical across users — avoids busting the
// cross-user global prompt cache. The sandbox already sets $TMPDIR at runtime.
const claudeTempDir = getClaudeTempDir()
const normalizeAllowOnly = (paths: string[]): string[] =>
[...new Set(paths)].map(p => (p === claudeTempDir ? '$TMPDIR' : p))
解决方案优雅而简洁:将用户特定的临时目录路径替换为 $TMPDIR 占位符。由于 Claude Code 的沙箱环境已经将 $TMPDIR 设置为正确的目录,模型使用 $TMPDIR 引用临时目录与使用绝对路径效果相同。
提示词中还明确告知模型使用 $TMPDIR:
// tools/BashTool/prompt.ts:258-260
'For temporary files, always use the `$TMPDIR` environment variable. ' +
'TMPDIR is automatically set to the correct sandbox-writable directory ' +
'in sandbox mode. Do NOT use `/tmp` directly - use `$TMPDIR` instead.',
影响
这个优化使得 BashTool 的提示词在所有用户之间逐字节一致,从而允许全局缓存范围的前缀共享。对于 BashTool 这个最常用的工具,其 Schema 的全局缓存命中意味着显著的成本节约。
15.6 模式六:条件段落省略
问题
系统提示词中有一些段落只在特定条件下出现:某个 feature flag 启用时添加一段说明,某个功能可用时插入一段指导。当这些条件在会话中途翻转(例如 GrowthBook 的远程配置更新),段落的出现/消失会改变系统提示词的内容,导致缓存中断。
解决方案
条件段落省略模式的核心原则是:宁可不说,不要说了又删。具体实施方式包括:
- 用静态文本替代条件段落:如果一段说明对模型行为影响不大,干脆总是包含它(或总是不包含),避免条件判断
- 将条件内容移到动态边界之后:如果必须条件性包含,将其放在
SYSTEM_PROMPT_DYNAMIC_BOUNDARY之后,此区域不参与全局缓存(详见第13章) - 用附件机制替代内联条件:类似模式三的 Agent 列表,将条件内容作为附件追加在消息尾部
这个模式没有单一的实现位置——它是一个设计原则,贯穿于系统提示词和工具提示词的构建过程中。其本质是确保 API 请求前缀中的系统提示词块在会话生命周期内保持单调稳定:内容要么始终存在,要么始终不存在,不会因外部条件翻转而出现/消失。
15.7 模式七:工具 Schema 缓存 getToolSchemaCache()
问题
工具 Schema 的序列化(toolToAPISchema())是一个复杂过程,涉及多个运行时决策:
- GrowthBook feature flag:
tengu_tool_pear(strict mode)、tengu_fgts(fine-grained tool streaming)等 flag 控制 Schema 中的可选字段 - tool.prompt() 的动态输出:部分工具的描述文本包含运行时信息
- MCP 工具的 Schema:外部服务器提供的 Schema 可能在会话中途变化
每次 API 请求都重新计算工具 Schema 意味着:如果 GrowthBook 在会话中途刷新了缓存(这可能在任何时候发生),某个 flag 的值从 true 变为 false,工具 Schema 的序列化结果就会改变——缓存中断。
解决方案
// utils/toolSchemaCache.ts:1-27
// Session-scoped cache of rendered tool schemas. Tool schemas render at server
// position 2 (before system prompt), so any byte-level change busts the entire
// ~11K-token tool block AND everything downstream. GrowthBook gate flips
// (tengu_tool_pear, tengu_fgts), MCP reconnects, or dynamic content in
// tool.prompt() drift all cause this churn. Memoizing per-session locks the schema
// bytes at first render — mid-session GB refreshes no longer bust the cache.
type CachedSchema = BetaTool & {
strict?: boolean
eager_input_streaming?: boolean
}
const TOOL_SCHEMA_CACHE = new Map<string, CachedSchema>()
// export function getToolSchemaCache(): Map<string, CachedSchema> {
return TOOL_SCHEMA_CACHE
}
// export function clearToolSchemaCache(): void {
TOOL_SCHEMA_CACHE.clear()
}
TOOL_SCHEMA_CACHE 是一个模块级 Map,以工具名(或包含 inputJSONSchema 的复合键)为键,缓存完整的序列化 Schema。一旦工具的 Schema 在首次请求中被渲染并缓存,后续请求直接复用缓存值,不再调用 tool.prompt() 或重新评估 GrowthBook flag。
缓存键设计
缓存键的设计有一个细微但关键的考量(utils/api.ts,第147-149行):
// utils/api.ts:147-149
const cacheKey =
'inputJSONSchema' in tool && tool.inputJSONSchema
? `${tool.name}:${jsonStringify(tool.inputJSONSchema)}`
: tool.name
大多数工具以名称为键——每个工具名称唯一,Schema 在会话内不变。但 StructuredOutput 工具是特例:它的名称固定为 'StructuredOutput',但不同的工作流调用会传入不同的 inputJSONSchema。如果只用名称作为键,第一次调用缓存的 Schema 会在后续不同工作流中被错误复用。
源码注释提到了这个 bug 的严重性:
// StructuredOutput instances share the name 'StructuredOutput' but carry
// different schemas per workflow call — name-only keying returned a stale
// schema (5.4% → 51% err rate, see PR#25424).
错误率从 5.4% 飙升到 51%——这不是一个微妙的缓存一致性问题,而是一个严重的功能 bug。通过将 inputJSONSchema 包含在缓存键中解决了这个问题。
生命周期
TOOL_SCHEMA_CACHE 的生命周期与会话绑定:
- 创建:第一次调用
toolToAPISchema()时逐工具填充 - 读取:后续每次 API 请求复用缓存的 Schema
- 清除:
clearToolSchemaCache()在用户登出时调用(通过auth.ts),确保新会话不会复用旧会话的陈旧 Schema
注意 clearToolSchemaCache 被放在 utils/toolSchemaCache.ts 这个独立的叶子模块中,而非 utils/api.ts。注释解释了原因:
// Lives in a leaf module so auth.ts can clear it without importing api.ts
// (which would create a cycle via plans→settings→file→growthbook→config→
// bridgeEnabled→auth).
一个看似简单的缓存 Map,需要仔细的模块拆分来避免循环依赖——这是大型 TypeScript 项目中的常见挑战。
15.8 模式的共同本质
回顾这七个模式,下图展示了所有模式共同遵循的优化决策流程:
flowchart TD
Start[识别动态内容] --> Q1{内容是否必须\n出现在前缀中?}
Q1 -- 否 --> Move[移至消息尾部/附件]
Move --> Done[缓存安全]
Q1 -- 是 --> Q2{能否消除\n用户维度差异?}
Q2 -- 是 --> Placeholder[使用占位符/标准化]
Placeholder --> Done
Q2 -- 否 --> Q3{能否降低\n变化频率?}
Q3 -- 是 --> Reduce[记忆化/降低精度/会话级缓存]
Reduce --> Done
Q3 -- 否 --> Q4{能否限制\n变化幅度?}
Q4 -- 是 --> Budget[预算控制/条件段落省略]
Budget --> Done
Q4 -- 否 --> Accept[标记为动态区域\nscope: null]
Accept --> Done
style Start fill:#f9f,stroke:#333
style Done fill:#9f9,stroke:#333
图 15-1:缓存优化模式决策流程
可以提取出几个共性原则:
原则一:将动态内容推向请求尾部
API 请求的前缀匹配模型意味着:越靠前的内容,其变化的破坏性越大。因此:
- 日期记忆化(模式一)锁定系统提示词中的日期
- Agent 列表附件化(模式三)将动态列表从工具 Schema(前端)移到消息附件(尾部)
- 条件段落省略(模式六)确保前缀中的内容不抖动
原则二:降低变化频率
当内容必须出现在前缀中时,降低其变化频率是次优选择:
- 月度粒度(模式二)将日期变化从每日降到每月
- 技能列表预算(模式四)通过预算裁剪减少列表变化
- 工具 Schema 缓存(模式七)将变化频率从每请求降到每会话
原则三:消除用户维度的差异
全局缓存的前提是所有用户看到相同的前缀:
- $TMPDIR 占位符(模式五)消除了用户 UID 带来的路径差异
- 日期记忆化也间接服务于此——不同时区用户在同一时刻可能有不同日期
原则四:先测量,再优化
每一个模式的发现都依赖第14章的缓存中断检测系统:
- 10.2% cache_creation tokens 归因于 Agent 列表——这个数字来自 BigQuery 分析
- 77% 的工具变化是单个工具 Schema 变化——这驱动了工具 Schema 缓存的设计
- GrowthBook flag 翻转是中断原因——这驱动了会话级缓存的引入
没有可观测性基础设施,这些模式不会被发现。
用户能做什么
这些模式不仅适用于 Claude Code——任何使用 Anthropic API(或类似前缀缓存机制的 API)的应用都可以借鉴。
对 API 调用者的建议
- 审计你的系统提示词:识别其中的动态内容(日期、用户名、配置值),将它们推到系统提示词的末尾或移至消息中
- 锁定工具 Schema:工具定义在会话内应该保持不变。如果必须动态改变工具列表,考虑使用消息附件替代
- 监控 cache_read_input_tokens:这是判断缓存是否正常工作的唯一指标。如果它在会话中意外下降,你就有了一个缓存中断
- 理解前缀顺序:
cache_control断点之前的内容变化会废弃该断点的缓存。在请求构建时,将最稳定的内容放在最前面
常见陷阱
| 陷阱 | 原因 | 解决方案 |
|---|---|---|
| 在系统提示词中嵌入时间戳 | 每次请求都变 | 使用会话级记忆化 |
| 动态工具列表 | MCP 连接/断开改变列表 | 附件机制或 defer_loading |
| 用户特定路径 | 不同用户不同字节 | 环境变量占位符 |
| Feature flag 直接影响 Schema | 远程配置刷新 | 会话级缓存 |
| 频繁切换模型 | 模型是缓存键的一部分 | 尽量固定模型选择 |
小结
本章介绍了 Claude Code 的 7 个缓存优化模式:
- 日期记忆化:
memoize(getLocalISODate)消除跨天缓存击穿 - 月度粒度:
getLocalMonthYear()将工具提示词的日期变化频率从每日降至每月 - Agent 列表附件化:消除了 10.2% 的 cache_creation tokens
- 技能列表预算:1% context window 的硬预算控制列表大小和变化
- $TMPDIR 占位符:消除用户维度差异,启用全局缓存
- 条件段落省略:确保前缀内容不因功能开关抖动
- 工具 Schema 缓存:会话级 Map 隔离 GrowthBook 翻转和动态内容
这些模式共同体现了一个核心洞察:缓存优化不是一个独立的关注点,而是渗透到系统每一个产生动态内容的位置。从日期格式到路径字符串,从工具描述到 feature flag——任何“看起来不重要“的变化都可能导致成千上万 tokens 的缓存失效。Claude Code 的做法是将缓存稳定性视为一等公民,在每个产生动态内容的位置都显式地做出缓存友好的设计决策。
至此,第四篇“提示词缓存“完结。第13章建立了缓存架构的防御层(范围、TTL、锁存),第14章构建了检测能力(两阶段检测、解释引擎),第15章展示了进攻手段(7+ 优化模式)。三章共同构成了一个完整的缓存工程体系:防御 → 检测 → 优化。
下一篇将转向安全与权限系统——另一个需要系统性工程思维的领域。详见第16章。
第16章:权限系统
为什么这很重要
一个能在用户代码库中执行任意 Shell 命令、读写任意文件的 AI Agent,其权限系统的设计质量直接决定了用户信任的上限。过于宽松,用户面临安全风险——恶意 prompt 注入可能触发 rm -rf / 或窃取 SSH 密钥;过于严格,每一步操作都弹出确认对话框,AI 编码助手沦为一个“需要人类不断点确认“的自动化工具。
Claude Code 的权限系统试图在这两极之间找到平衡点:通过六种权限模式、三层规则匹配机制、以及一条完整的验证-权限-分类管线,实现“安全操作自动通过、危险操作必须人工确认、模糊地带由 AI 分类器裁决“的分级管控。
本章将完整剖析这一权限系统的设计与实现。
16.1 六种权限模式
权限模式(Permission Mode)是整个系统的最高层控制开关。用户通过 Shift+Tab 循环切换模式,或通过 --permission-mode CLI 参数指定。所有模式定义在 types/permissions.ts 中:
// types/permissions.ts:16-22
export const EXTERNAL_PERMISSION_MODES = [
'acceptEdits',
'bypassPermissions',
'default',
'dontAsk',
'plan',
] as const
内部还有两个非公开模式——auto 和 bubble,组成完整的类型联合:
// types/permissions.ts:28-29
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
export type PermissionMode = InternalPermissionMode
以下是各模式的行为说明:
| 模式 | 符号 | 行为 | 典型场景 |
|---|---|---|---|
default | (无) | 所有工具调用都需要用户确认 | 首次使用、高安全要求环境 |
acceptEdits | >> | 工作目录内的文件编辑自动通过,Shell 命令仍需确认 | 日常编码辅助 |
plan | ⏸ | AI 只能读取和搜索,不执行任何写操作 | 代码审查、架构规划 |
bypassPermissions | >> | 跳过所有权限检查(安全检查除外) | 信任环境中的批量操作 |
dontAsk | >> | 将所有 ask 决策转为 deny,永不弹出确认 | 自动化 CI/CD 管线 |
auto | >> | 由 AI 分类器自动裁决,仅内部可用 | Anthropic 内部开发 |
每个模式都有对应的配置对象(PermissionMode.ts:42-91),包含标题、缩写、符号和颜色键。值得注意的是 auto 模式通过 feature('TRANSCRIPT_CLASSIFIER') 编译时特性门控条件注册——外部构建中这段代码会被 Bun 的死代码消除完全移除。
模式切换的循环逻辑
getNextPermissionMode(getNextPermissionMode.ts:34-79)定义了 Shift+Tab 的循环顺序:
外部用户: default → acceptEdits → plan → [bypassPermissions] → default
内部用户: default → [bypassPermissions] → [auto] → default
内部用户跳过 acceptEdits 和 plan,因为 auto 模式替代了二者的功能。bypassPermissions 需要 isBypassPermissionsModeAvailable 标志为 true 才出现在循环中。auto 模式则需要同时满足功能门控和运行时可用性检查:
// getNextPermissionMode.ts:17-29
// function canCycleToAuto(ctx: ToolPermissionContext): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) {
const gateEnabled = isAutoModeGateEnabled()
const can = !!ctx.isAutoModeAvailable && gateEnabled
// ...
return can
}
return false
}
模式转换的副作用
模式切换不只是改变一个枚举值——transitionPermissionMode(permissionSetup.ts:597-646)处理了转换时的副作用:
- 进入 plan 模式:调用
prepareContextForPlanMode,保存当前模式到prePlanMode - 进入 auto 模式:调用
stripDangerousPermissionsForAutoMode,移除危险的 allow 规则(下文详述) - 离开 auto 模式:调用
restoreDangerousPermissions,恢复被剥离的规则 - 离开 plan 模式:设置
hasExitedPlanMode状态标志
16.2 权限规则体系
权限模式是粗粒度开关,权限规则(Permission Rule)则提供细粒度控制。一条规则由三个部分组成:
// types/permissions.ts:75-79
export type PermissionRule = {
source: PermissionRuleSource
ruleBehavior: PermissionBehavior // 'allow' | 'deny' | 'ask'
ruleValue: PermissionRuleValue
}
其中 PermissionRuleValue 指定目标工具和可选的内容限定:
// types/permissions.ts:67-70
export type PermissionRuleValue = {
toolName: string
ruleContent?: string // 如 "npm install"、"git:*"
}
规则来源层级
规则有八种来源(types/permissions.ts:54-62),按优先级从高到低排列:
| 来源 | 位置 | 共享性 |
|---|---|---|
policySettings | 企业管理策略 | 推送到所有用户 |
projectSettings | .claude/settings.json | 提交到 git,团队共享 |
localSettings | .claude/settings.local.json | 已 gitignore,仅本地 |
userSettings | ~/.claude/settings.json | 用户全局 |
flagSettings | --settings CLI 参数 | 运行时 |
cliArg | --allowed-tools 等 CLI 参数 | 运行时 |
command | 命令行子命令上下文 | 运行时 |
session | 会话内临时规则 | 仅当前会话 |
规则字符串格式与解析
规则在配置文件中以字符串形式存储,格式为 ToolName 或 ToolName(content)。解析由 permissionRuleParser.ts 的 permissionRuleValueFromString 函数(第 93-133 行)完成,它处理了转义括号的问题——因为规则内容本身可能包含括号(如 python -c "print(1)")。
特殊情况:Bash() 和 Bash(*) 都被视为工具级规则(无内容限定),等价于 Bash。
16.3 三种规则匹配模式
Shell 命令的权限规则支持三种匹配模式,由 shellRuleMatching.ts 的 parsePermissionRule 函数(第 159-184 行)解析为判别联合类型:
// shellRuleMatching.ts:25-38
export type ShellPermissionRule =
| { type: 'exact'; command: string }
| { type: 'prefix'; prefix: string }
| { type: 'wildcard'; pattern: string }
精确匹配
规则字符串不包含通配符,命令必须完全一致:
| 规则 | 匹配 | 不匹配 |
|---|---|---|
npm install | npm install | npm install lodash |
git status | git status | git status --short |
前缀匹配(Legacy :* 语法)
以 :* 结尾的规则使用前缀匹配——这是向后兼容的遗留语法:
| 规则 | 匹配 | 不匹配 |
|---|---|---|
npm:* | npm install、npm run build、npm test | npx create-react-app |
git:* | git add .、git commit -m "msg" | gitk |
前缀提取由 permissionRuleExtractPrefix(第 43-48 行)完成:正则 /^(.+):\*$/ 捕获 :* 之前的所有内容作为前缀。
通配符匹配
包含未转义 * 的规则(不含尾部 :*)使用通配符匹配。matchWildcardPattern(第 90-154 行)将模式转换为正则表达式:
| 规则 | 匹配 | 不匹配 |
|---|---|---|
git add * | git add .、git add src/main.ts、裸 git add | git commit |
docker build -t * | docker build -t myapp | docker run myapp |
echo \* | echo *(字面星号) | echo hello |
通配符匹配有一个精心设计的行为:当模式以 *(空格加通配符)结尾,且整个模式只有一个未转义的 * 时,尾部的空格和参数是可选的。这意味着 git * 既匹配 git add 也匹配裸 git(第 142-145 行)。这使得通配符语义与前缀规则 git:* 保持一致。
转义机制使用了 null-byte 哨兵占位符(第 14-17 行),在正则转换过程中避免 \*(字面星号)与 *(通配符)混淆:
// shellRuleMatching.ts:14-17
const ESCAPED_STAR_PLACEHOLDER = '\x00ESCAPED_STAR\x00'
const ESCAPED_BACKSLASH_PLACEHOLDER = '\x00ESCAPED_BACKSLASH\x00'
16.4 验证-权限-分类管线
当 AI 模型发起一次工具调用时,请求通过一条三阶段管线决定是否执行。核心入口是 hasPermissionsToUseTool(permissions.ts:473),它调用内部函数 hasPermissionsToUseToolInner 执行前两个阶段,然后在外层处理第三阶段的分类器逻辑。
flowchart TD
START["工具调用请求"] --> S1A{"步骤 1a:<br />工具级 deny 规则?"}
S1A -- 匹配 --> DENY["❌ deny"]
S1A -- 不匹配 --> S1B{"步骤 1b:<br />工具级 ask 规则?"}
S1B -- "匹配(sandbox 可跳过)" --> ASK1["⚠️ ask"]
S1B -- 不匹配 --> S1C{"步骤 1c:<br />tool.checkPermissions()"}
S1C -- deny --> DENY
S1C -- ask --> ASK1
S1C -- 通过 --> S1E{"步骤 1e:<br />需要用户交互?"}
S1E -- 是 --> ASK1
S1E -- 否 --> S1F{"步骤 1f:<br />内容级 ask 规则?<br />(bypass 免疫)"}
S1F -- 匹配 --> ASK1
S1F -- 不匹配 --> S1G{"步骤 1g:<br />安全检查<br />.git/.claude 等?<br />(bypass 免疫)"}
S1G -- 命中 --> ASK1
S1G -- 通过 --> PHASE2
subgraph PHASE2 ["阶段二:模式裁决"]
S2A{"步骤 2a:<br />bypassPermissions?"}
S2A -- 是 --> ALLOW["✅ allow"]
S2A -- 否 --> S2B{"步骤 2b:<br />工具级 allow 规则?"}
S2B -- 匹配 --> ALLOW
S2B -- 不匹配 --> S2C{"步骤 2c:<br />工具自身 allow?"}
S2C -- 是 --> ALLOW
S2C -- 否 --> ASK2["⚠️ ask"]
ASK1 --> PHASE3
ASK2 --> PHASE3
subgraph PHASE3 ["阶段三:模式后处理"]
MODE{"当前权限模式?"}
MODE -- dontAsk --> DENY2["❌ deny(永不提示)"]
MODE -- auto --> CLASSIFIER["🤖 分类器裁决"]
MODE -- default --> DIALOG["💬 显示权限对话框"]
CLASSIFIER -- 安全 --> ALLOW2["✅ allow"]
CLASSIFIER -- 不安全 --> ASK3["⚠️ ask → 对话框"]
阶段一:规则验证
这是防御性最强的阶段,所有退出路径都优先于模式裁决。关键步骤:
步骤 1a-1b(permissions.ts:1169-1206)检查工具级 deny 和 ask 规则。如果 Bash 被整体 deny,则任何 Bash 命令都被拒绝。工具级 ask 规则有一个特例:当 sandbox 启用且 autoAllowBashIfSandboxed 开启时,将被沙箱化的命令可以跳过 ask 规则。
步骤 1c(permissions.ts:1214-1223)调用工具自身的 checkPermissions() 方法。每种工具(Bash、FileEdit、PowerShell 等)实现各自的权限检查逻辑。例如 Bash 工具会解析命令、检查子命令、匹配 allow/deny 规则。
步骤 1f(permissions.ts:1244-1250)是一个关键设计:内容级 ask 规则(如 Bash(npm publish:*))即使在 bypassPermissions 模式下也必须提示。这是因为用户显式配置的 ask 规则代表了明确的安全意图——“我就是想在发布前确认一下”。
步骤 1g(permissions.ts:1255-1258)同样是 bypass 免疫的:对 .git/、.claude/、.vscode/ 和 shell 配置文件(.bashrc、.zshrc 等)的写操作始终需要确认。
阶段二:模式裁决
如果工具调用通过了阶段一没有被 deny 或被强制 ask,进入模式裁决。bypassPermissions 模式在此直接放行。其他模式下,检查 allow 规则和工具自身返回的 allow 决策。
阶段三:模式后处理
这是权限决策流水线的最后一道闸门。dontAsk 模式将所有 ask 转为 deny,适合非交互环境(permissions.ts:505-517)。auto 模式则启动 AI 分类器进行裁决,这是整个权限系统中最复杂的路径(下文详述)。
16.5 isDangerousBashPermission():保护分类器的安全边界
当用户从其他模式切换到 auto 模式时,系统会调用 stripDangerousPermissionsForAutoMode 将某些 allow 规则临时剥离。被剥离的规则不会删除,而是保存在 strippedDangerousRules 字段中,离开 auto 模式时恢复。
判断一条规则是否“危险“的核心函数是 isDangerousBashPermission(permissionSetup.ts:94-147):
// permissionSetup.ts:94-107
// export function isDangerousBashPermission(
toolName: string,
ruleContent: string | undefined,
): boolean {
if (toolName !== BASH_TOOL_NAME) { return false }
if (ruleContent === undefined || ruleContent === '') { return true }
const content = ruleContent.trim().toLowerCase()
if (content === '*') { return true }
// ...检查 DANGEROUS_BASH_PATTERNS
}
危险的规则模式包括五种形态:
- 工具级 allow:
Bash(无 ruleContent)或Bash(*)——允许所有命令 - 独立通配符:
Bash(*)——等价于工具级 allow - 解释器前缀:
Bash(python:*)——允许任意 Python 代码执行 - 解释器通配符:
Bash(python *)——同上 - 解释器带标志通配符:
Bash(python -*)——允许python -c 'arbitrary code'
被视为危险的命令前缀定义在 dangerousPatterns.ts:44-80 中:
// dangerousPatterns.ts:44-80
export const DANGEROUS_BASH_PATTERNS: readonly string[] = [
...CROSS_PLATFORM_CODE_EXEC, // python, node, ruby, perl, ssh 等
'zsh', 'fish', 'eval', 'exec', 'env', 'xargs', 'sudo',
// Anthropic 内部额外模式...
]
跨平台代码执行入口点(CROSS_PLATFORM_CODE_EXEC,第 18-42 行)涵盖了所有主流脚本解释器(python/node/ruby/perl/php/lua)、包运行器(npx/bunx/npm run)、Shell(bash/sh)和远程命令执行工具(ssh)。
内部用户额外包含 gh、curl、wget、git、kubectl、aws 等——这些在外部构建中被 process.env.USER_TYPE === 'ant' 门控排除。
PowerShell 有对应的 isDangerousPowerShellPermission(permissionSetup.ts:157-233),额外检测 PowerShell 特有的危险命令:Invoke-Expression、Start-Process、Add-Type、New-Object 等,并处理 .exe 后缀变体(python.exe、npm.exe)。
16.6 路径权限验证与 UNC 防护
文件操作的权限验证由 pathValidation.ts 的 validatePath 函数(第 373-485 行)执行。这是一条多步安全管线:
路径验证管线
输入路径
│
├─ 1. 清理引号、展开 ~ ──→ cleanPath
├─ 2. UNC 路径检测 ──→ 若匹配则拒绝
├─ 3. 危险 tilde 变体检测 (~root, ~+, ~-) ──→ 若匹配则拒绝
├─ 4. Shell 展开语法检测 ($VAR, %VAR%) ──→ 若匹配则拒绝
├─ 5. Glob 模式检测 ──→ 写操作拒绝;读操作验证基目录
├─ 6. 解析为绝对路径 + 符号链接解析
└─ 7. isPathAllowed() 多步检查
UNC 路径 NTLM 泄漏防护
Windows 上,当应用程序访问 UNC 路径(如 \\attacker-server\share\file)时,操作系统会自动发送 NTLM 认证凭据进行身份验证。攻击者可以利用这一机制:通过 prompt 注入让 AI 读取或写入一个指向恶意服���器的 UNC 路径,从而窃取用户的 NTLM 哈希。
containsVulnerableUncPath(shell/readOnlyCommandValidation.ts:1562)检测三种 UNC 路径变体:
// readOnlyCommandValidation.ts:1562-1596
// export function containsVulnerableUncPath(pathOrCommand: string): boolean {
if (getPlatform() !== 'windows') { return false }
// 1. 反斜杠 UNC: \\server\share
const backslashUncPattern = /\\\\[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
// 2. 正斜杠 UNC: //server/share(排除 URL 中的 ://)
const forwardSlashUncPattern = /(?<!:)\/\/[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
// 3. 混合分隔符: /\\server (Cygwin/bash 环境)
// ...
}
注意第二个正则使用了 (?<!:) 负向后瞻来排除 https:// 等 URL——这是合法的双斜杠使用场景。主机名模式 [^\s\\/]+ 使用排除集而非字符白名单,以捕获 Unicode 同形异义字攻击(如用西里尔字母 ‘а’ 替代拉��字母 ‘a’)。
TOCTOU 防护
路径验证还防御了多种 TOCTOU(Time-of-Check-to-Time-of-Use)攻击:
- 危险 tilde 变体(第 401-411 行):
~root在验证时被当作相对路径解析为/cwd/~root/...,但 Shell 执行时展开为/var/root/... - Shell 变量展开(第 423-436 行):
$HOME/.ssh/id_rsa在验证时是字面字符串,但 Shell 执行时展开为实际路径 - Zsh equals 展开(同上):
=rg在 Zsh 中展开为/usr/bin/rg
所有这些情况都通过拒绝包含特定字符($、%、=)的路径来防御,要求用户手动确认。
isPathAllowed() 的多步检查
路径清理通过后,isPathAllowed(pathValidation.ts:141-263)执行最终的权限裁决:
- Deny 规则优先:任何匹配的 deny 规则立即拒绝
- 内部可编辑路径:plan 文件、scratchpad、agent 内存等
~/.claude/下的内部路径自动允许编辑 - 安全检查:
.git/、.claude/等危险目录和 shell 配置文件的写操作标记为需要确认 - 工作目录检查:路径在允许的工作目录内时,
read操作自动通过,write操作需要acceptEdits模式 - Sandbox 写白名单:sandbox 启用时,其配置的可写目录自动放行
- Allow 规则:匹配的 allow 规则放行
16.7 Auto 模式的分类器管线
当权限模式为 auto 且工具调用到达阶段三的 ask 决策时,系统启动 YOLO 分类器(yoloClassifier.ts)进行 AI 裁决。分类器的设计理念是:在大多数情况下,AI 能够判断一个操作是否安全。
安全工具白名单
并非所有工具都需要经过分类器。classifierDecision.ts 定义了安全白名单(SAFE_YOLO_ALLOWLISTED_TOOLS,第 56-94 行),包含只读操作(FileRead、Grep、Glob)、元数据操作(Task 系列工具)和 UI 工具(EnterPlanMode、AskUserQuestion)。白名单中的工具直接放行。
acceptEdits 快速路径
在调用分类器之前,系统会模拟 acceptEdits 模式检查(permissions.ts:600-648):如果工具在 acceptEdits 模式下会被允许(如工作目录内的文件编辑),则跳过分类器直接放行。这避免了不必要的 API 调用。但 Agent(子 Agent)和 REPL(代码执行)工具被显式排除——它们的安全性不能仅凭路径判断。
连续拒绝追踪
denialTracking.ts 实现了连续拒绝计数器。当分类器连续多次拒绝操作时,系统会降级到人工提示模式——这是对分类器可能误判的一道安全网。每次成功的工具调用会重置计数器。
分类器的两阶段架构
YOLO 分类器(yoloClassifier.ts)使用两阶段决策:
- Stage 1(快速):低延迟模型快速判断
- Stage 2(思考):当 Stage 1 不确定时,使用更强大的模型深入分析
分类器接收当前会话的上下文(工具调用历史、用户消息)和待裁决的命令,返回 shouldBlock 布尔值和原因说明。结果结构包含详细的遥测字段(token 用量、延迟、请求 ID)用于后续分析。
16.8 危险文件和目录保护
filesystem.ts 定义了两类受保护的对象:
// filesystem.ts:57-79
export const DANGEROUS_FILES = [
'.gitconfig', '.gitmodules',
'.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile',
'.ripgreprc', '.mcp.json', '.claude.json',
] as const
export const DANGEROUS_DIRECTORIES = [
'.git', '.vscode', '.idea', '.claude',
] as const
这些文件和目录可用于代码执行或数据外泄:
.gitconfig可配置core.sshCommand执行任意代码.bashrc/.zshrc在每次 Shell 启动时自动执行.vscode/settings.json可配置任务和终端自动运行
对这些路径的写操作在 checkPathSafetyForAutoEdit 中标记为 safetyCheck 类型,具有 bypass 免疫性——即使在 bypassPermissions 模式下也必须用户确认。但在 auto 模式下,部分安全检查(如敏感文件路径)被标记为 classifierApprovable: true,允许分类器在上下文充分时自动批准。
危险删除路径检测
isDangerousRemovalPath(pathValidation.ts:331-367)防止删除根目录、主目录、Windows 驱动器根目录及其直接子目录(/usr、/tmp、C:\Windows)。它同时处理了路径分隔符标准化——Windows 环境下 C:\\Windows 和 C:/Windows 都被正确识别。
16.9 被遮蔽规则检测
当用户配置了矛盾的权限规则时——比如项目设置中 deny 了 Bash,但本地设置中 allow 了 Bash(git:*)——allow 规则永远不会生效。shadowedRuleDetection.ts 的 UnreachableRule 类型(第 19-25 行)记录了这种情况:
export type UnreachableRule = {
rule: PermissionRule
reason: string
shadowedBy: PermissionRule
shadowType: ShadowType // 'ask' | 'deny'
fix: string
}
系统会检测并提示用户哪些 allow 规则被更高优先级的 deny/ask 规则遮蔽,以及如何修复。
16.10 权限更新的持久化
权限更新通过 PermissionUpdate 联合类型(types/permissions.ts:98-131)描述,支持六种操作:addRules、replaceRules、removeRules、setMode、addDirectories、removeDirectories。每种操作都指定一个目标存储位置(PermissionUpdateDestination)。
当用户在权限对话框中选择“始终允许“时,系统生成一个 addRules 更新,通常目标为 localSettings(本地设置,不会提交到 git)。Shell 工具的建议生成函数(shellRuleMatching.ts:189-228)会根据命令特征生成精确匹配或前缀匹配的建议。
16.11 设计反思
Claude Code 的权限系统展现了几个值得关注的设计原则:
纵深防御。deny 规则在管线最前端拦截,安全检查具有 bypass 免疫性,auto 模式在进入时剥离危险规则——多层防护确保单点失败不会导致安全缺口。
安全意图不可覆盖。用户显式配置的 ask 规则(步骤 1f)和系统安全检查(步骤 1g)不受 bypassPermissions 模式影响。这个设计承认了 bypass 模式的存在价值(批量操作效率),同时保护了用户刻意设置的安全边界。
TOCTOU 一致性。路径验证系统拒绝所有可能在“验证时“与“执行时“产生语义差异的路径模式(Shell 变量、tilde 变体、Zsh equals 展开),而非试图正确解析它们——选择安全的保守策略而非“聪明“的兼容策略。
分类器作为安全网而非替代品。auto 模式的分类器不是权限检查的替代品,而是在规则验证之后的补充层。它只处理“规则没有明确答案“的灰色地带,且有连续拒绝降级机制防止系统失控。
这些原则共同构成了一个在安全性和可用性之间取得平衡的权限架构——既不因过度保守而让 AI Agent 失去价值,也不因过度信任而让用户暴露于风险之中。
用户能做什么
权限模式选择建议
- 日常开发:使用
acceptEdits模式——文件编辑自动通过,Shell 命令仍需确认,是安全与效率的最佳平衡点 - 代码审查/架构探索:使用
plan模式——AI 只能读取和搜索,杜绝误操作 - 批量自动化任务:使用
bypassPermissions模式——但请注意,安全检查(.git/、.bashrc等写操作)仍然需要确认
规则配置技巧
- 使用
.claude/settings.json(项目级)定义团队共享的 allow/deny 规则,提交到 git - 使用
.claude/settings.local.json(本地级)定义个人偏好规则,已自动 gitignore - 利用通配符语法简化规则:
Bash(git *)允许所有 git 子命令 - 如果配置了 deny 规则后发现 allow 规则不生效,检查是否存在规则遮蔽——系统会提示被遮蔽的规则和修复建议
安全注意事项
- 即使启用
bypassPermissions,对.gitconfig、.bashrc、.zshrc等危险文件的写操作仍然需要确认——这是有意的安全设计 - 如果使用
auto模式,系统会自动剥离危险的 Bash allow 规则(如Bash(python:*)),离开 auto 模式后恢复 - Shift+Tab 可以随时在模式之间循环切换
第17章:YOLO 分类器
为什么这很重要
第 16 章剖析了 Claude Code 的权限系统——六种模式、三层规则匹配、以及从 canUseTool 入口到最终裁决的完整管线。但那条管线有一个特殊分支始终被一笔带过:当权限模式为 auto 时,系统不向用户弹出确认对话框,而是发起一次独立的 Claude API 调用,由另一个 AI 模型(通常是同一个模型的另一个实例)来裁决“这个操作是否安全“。
这就是 YOLO 分类器(内部代号 “tengu auto mode”)——一个用 AI 审核 AI 的安全决策系统。
它的设计核心是一个看似简单的问题:如何让 AI Agent 在无人值守时既保持自主执行效率,又不至于做出无法挽回的破坏? 答案不是写更多的正则规则,而是引入第二个 AI 作为安全裁判——它拥有完整的对话上下文,理解用户意图,能判断“在这个上下文中运行 rm -rf build/ 是正常的构建清理“与“运行 rm -rf / 是灾难性操作“之间的区别。
本章将完整拆解这个分类器的架构:安全白名单如何短路不必要的 API 调用、二阶段 XML 分类器如何在速度与准确性之间取得平衡、拒绝追踪如何防止 Agent 陷入死循环、以及模板系统如何为不同用户群体提供差异化的安全策略。
17.1 整体架构:AI 审核 AI
YOLO 分类器的核心入口是 classifyYoloAction 函数(yoloClassifier.ts 第 1012-1306 行)。当 Agent 主循环在 auto 模式下试图执行一个工具调用时,权限系统不再弹出用户确认,而是调用这个函数。它的签名揭示了设计意图:
// yoloClassifier.ts:1012-1018
export async function classifyYoloAction(
messages: Message[],
action: TranscriptEntry,
tools: Tools,
context: ToolPermissionContext,
signal: AbortSignal,
): Promise<YoloClassifierResult>
接收完整的对话历史(messages)、待裁决的动作(action)、工具注册表(tools)和权限上下文(context),返回一个包含 shouldBlock、reason 和使用量统计的结果对象。
这个函数的执行流程可以用一张决策图概括:
flowchart TD
INPUT["待裁决的工具调用"] --> WL{"工具在安全白名单中?"}
WL -- 是 --> ALLOW1["✅ 直接放行<br />(无 API 调用)"]
WL -- 否 --> ACI{"toAutoClassifierInput<br />返回空字符串?"}
ACI -- 是 --> ALLOW2["✅ 直接放行<br />(无安全相关输入)"]
ACI -- 否 --> BUILD["构建系统提示词<br />+ 对话转录<br />+ 动作描述"]
BUILD --> STAGE1["阶段 1:快速判定<br />max_tokens: 64"]
STAGE1 -- 放行 --> ALLOW3["✅ allow"]
STAGE1 -- "阻止/不确定" --> STAGE2["阶段 2:深度推理<br />max_tokens: 4096<br />含 <thinking>"]
STAGE2 --> RESULT["解析分类结果<br />{shouldBlock, reason}"]
RESULT -- "shouldBlock: false" --> ALLOW4["✅ allow"]
RESULT -- "shouldBlock: true" --> BLOCK["❌ block"]
RESULT -- "解析失败" --> BLOCK
关键的设计决策在于:分类器是一次独立的侧查询(side query),与主循环的 Agent 会话完全隔离。它有自己的系统提示词、自己的消息序列、自己的 token 预算。这种隔离确保了分类器不会被主循环中可能被注入的恶意内容所影响——至少不会通过 API 状态的方式。
17.2 安全白名单:零成本短路
并非所有工具调用都需要经过分类器。只读操作——文件读取、搜索、LSP 查询——天然无副作用,不可能造成安全风险。为它们发起 API 调用是纯粹的浪费。
classifierDecision.ts 定义了完整的安全白名单(第 56-94 行):
// classifierDecision.ts:56-94
const SAFE_YOLO_ALLOWLISTED_TOOLS = new Set([
// Read-only file operations
FILE_READ_TOOL_NAME,
// Search / read-only
GREP_TOOL_NAME,
GLOB_TOOL_NAME,
LSP_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
'ReadMcpResourceTool',
// Task management (metadata only)
TODO_WRITE_TOOL_NAME,
TASK_CREATE_TOOL_NAME,
TASK_GET_TOOL_NAME,
TASK_UPDATE_TOOL_NAME,
TASK_LIST_TOOL_NAME,
TASK_STOP_TOOL_NAME,
TASK_OUTPUT_TOOL_NAME,
// Plan mode / UI
ASK_USER_QUESTION_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
// Swarm coordination
TEAM_CREATE_TOOL_NAME,
TEAM_DELETE_TOOL_NAME,
SEND_MESSAGE_TOOL_NAME,
// ...conditional tools...
// Internal classifier tool
YOLO_CLASSIFIER_TOOL_NAME,
])
对外暴露的查询函数只有一行(第 96-98 行):
// classifierDecision.ts:96-98
// export function isAutoModeAllowlistedTool(toolName: string): boolean {
return SAFE_YOLO_ALLOWLISTED_TOOLS.has(toolName)
}
白名单的设计值得注意几个细节:
1. 写操作不在白名单中。 代码注释明确指出(第 53-55 行):“Does NOT include write/edit tools – those are handled by the acceptEdits fast path (allowed in CWD, classified outside CWD).” 文件编辑由另一条快速路径处理——工作目录内的编辑自动放行,工作目录外的编辑才进入分类器。
2. Swarm 协调工具在白名单中。 TEAM_CREATE_TOOL_NAME 和 SEND_MESSAGE_TOOL_NAME 等 Swarm 工具虽然看起来有副作用,但注释解释了安全理由(第 79-80 行):子 Agent 有各自独立的权限检查,所以协调层面的操作不构成安全绕过。
3. 条件性工具通过展开运算符添加。 例如 TERMINAL_CAPTURE_TOOL_NAME 只在 feature('TERMINAL_PANEL') 为真时注册(第 89 行),确保外部构建中不会出现内部工具名。
4. 分类器自身也在白名单中。 YOLO_CLASSIFIER_TOOL_NAME(即 classify_result)被加入白名单(第 93 行),避免分类器递归调用自己时再次触发分类——这是一个容易被忽略的自引用陷阱。
除了白名单之外,还有第二层短路机制。每个工具的 toAutoClassifierInput 方法控制哪些字段被暴露给分类器。当这个方法返回空字符串时,classifyYoloAction 在第 1023-1029 行直接放行:
// yoloClassifier.ts:1023-1029
if (actionCompact === '') {
return {
shouldBlock: false,
reason: 'Tool declares no classifier-relevant input',
model: getClassifierModel(),
}
}
这是一个精巧的设计:白名单决定“哪些工具不需要审查“,而 toAutoClassifierInput 决定“工具的哪些输入需要审查“。两层过滤共同最小化了不必要的 API 调用。
17.3 分类器输出格式
分类器的输出被严格约束为一个三字段的 JSON 对象。响应 Schema 由 Zod 定义(第 252-258 行):
// yoloClassifier.ts:252-258
const yoloClassifierResponseSchema = lazySchema(() =>
z.object({
thinking: z.string(),
shouldBlock: z.boolean(),
reason: z.string(),
}),
)
对应的工具定义通过 tool_choice: { type: 'tool', name: 'classify_result' } 强制模型以结构化工具调用的形式输出(第 1152-1155 行),而非自由文本。这消除了解析歧义——模型必须在 shouldBlock 中给出明确的 true 或 false 布尔值。
三个字段各有用途:
| 字段 | 类型 | 用途 |
|---|---|---|
thinking | string | 分类器的推理过程,用于调试和审计 |
shouldBlock | boolean | 核心裁决——true 阻止操作,false 放行 |
reason | string | 人类可读的裁决理由,在拒绝时展示给用户 |
当模型返回的内容无法解析为有效 Schema 时,系统默认阻止操作(第 1220-1237 行):
// yoloClassifier.ts:1220-1230
const parsed = parseClassifierResponse(
toolUseBlock,
yoloClassifierResponseSchema(),
)
if (!parsed) {
// ...
return {
shouldBlock: true,
reason: 'Invalid classifier response - blocking for safety',
// ...
}
}
这体现了一条贯穿整个分类器设计的原则:在不确定时,默认阻止。无论是 Schema 解析失败、API 错误、还是分类器不可用,系统始终回退到最安全的状态。
17.4 二阶段 XML 分类器
工具调用格式的分类器(tool_use classifier)虽然结构清晰,但有一个性能问题:强制工具调用需要模型生成完整的 JSON 对象,即使对于明显安全的操作(如 ls)也是如此。为此,Claude Code 引入了一套二阶段 XML 分类器(第 541-996 行),通过 GrowthBook 特性开关控制启用:
// yoloClassifier.ts:1308-1316
type TwoStageMode = 'both' | 'fast' | 'thinking'
type AutoModeConfig = {
// ...
twoStageClassifier?: boolean | 'fast' | 'thinking'
// ...
}
阶段 1:快速判定(Fast)
阶段 1 的目标是用最少的 token 做出快速判定。它将系统提示词中的工具调用格式替换为简洁的 XML 标签格式(第 648-664 行):
// yoloClassifier.ts:651-663(简化)
const xmlFormat = [
'## Output Format',
'',
'If the action should be blocked:',
'<block>yes</block><reason>one short sentence</reason>',
'',
'If the action should be allowed:',
'<block>no</block>',
'',
'Do NOT include a <reason> tag when the action is allowed.',
'Your ENTIRE response MUST begin with <block>...',
].join('\n')
阶段 1 使用极小的 token 预算(max_tokens: 64)和停止序列 </block>(第 780-793 行),迫使模型在输出 <block>yes</block> 或 <block>no</block> 后立即停止。同时附加一个引导后缀(第 550 行):
Err on the side of blocking. <block> immediately.
当阶段 1 判定为“允许“时,直接返回(第 807-823 行)——这是快速路径,一次 API 调用,约 64 token 的输出预算,亚秒级延迟。整个主循环几乎感觉不到分类器的存在。
阶段 2:深度推理(Thinking)
当阶段 1 判定为“阻止“或无法解析时,系统升级到阶段 2。阶段 2 使用更大的 token 预算(max_tokens: 4096),并附加一个不同的引导后缀(第 560-561 行):
Review the classification process and follow it carefully, making sure
you deny actions that should be blocked. As a reminder, explicit (not
suggestive or implicit) user confirmation is required to override blocks.
Use <thinking> before responding with <block>.
阶段 2 要求模型先输出 <thinking> 标签进行推理,然后再给出最终判定。这种链式思考(chain-of-thought)显著降低了误判率——阶段 1 可能因为过于激进的“宁可错杀“策略而误阻正常操作,阶段 2 的推理过程能纠正这些误判。
阶段间的缓存优化
两个阶段共享相同的系统提示词和对话转录内容。差异仅在最后附加的引导后缀。这意味着阶段 2 的 API 调用可以命中阶段 1 创建的提示词缓存——系统在动作块上设置了 cache_control(第 1102-1106 行),确保阶段 2 获得缓存命中:
// yoloClassifier.ts:1102-1106
userContentBlocks.push({
type: 'text' as const,
text: actionCompact,
cache_control: cacheControl,
})
usage 数据在两个阶段之间合并(第 633-641 行的 combineUsage 函数),遥测系统看到的是总开销而非单阶段开销。
XML 解析的防御性处理
XML 响应的解析需要特别小心。模型可能在 <thinking> 块中生成包含 <block> 标签的文本(比如在推理过程中举例说明),这会干扰解析器。stripThinking 函数(第 567-571 行)在解析前先移除所有 <thinking> 内容:
// yoloClassifier.ts:567-571
// function stripThinking(text: string): string {
return text
.replace(/<thinking>[\s\S]*?<\/thinking>/g, '')
.replace(/<thinking>[\s\S]*$/, '')
}
注意第二个正则——它处理的是 <thinking> 标签未闭合的情况(模型输出被截断时),将从 <thinking> 开始到文本末尾的所有内容都移除。
三种运行模式
二阶段分类器支持三种模式:
| 模式 | 行为 | 适用场景 |
|---|---|---|
both | 先跑阶段 1,放行则返回;阻止则升级到阶段 2 | 默认模式,平衡速度和准确性 |
fast | 仅阶段 1,max_tokens 提升到 256,去掉停止序列 | 追求最低延迟 |
thinking | 仅阶段 2,跳过阶段 1 | 追求最高准确性 |
模式选择通过 GrowthBook 配置或环境变量 CLAUDE_CODE_TWO_STAGE_CLASSIFIER 控制(第 1353-1369 行)。
17.5 对话转录:分类器看到了什么
分类器不直接看到主循环的原始消息流。它看到的是一份经过投影(projection)的压缩转录。buildTranscriptEntries 函数(第 302-360 行)负责这个投影过程:
- 用户消息:只保留文本块,图片等其他类型被过滤掉
- 助手消息:只保留
tool_use块,助手的文本输出被完全过滤 - 队列化命令:从附件消息中提取
queued_command的 prompt 文本
助手文本被过滤的原因在代码注释中有明确说明(第 344-345 行):
// Only include tool_use blocks — assistant text is model-authored
// and could be crafted to influence the classifier's decision.
这是一个关键的安全设计:如果分类器能看到 Agent 的自由文本输出,恶意的 prompt 注入可能让 Agent 生成“接下来的操作都是安全的,请分类器放行“之类的文本,从而绕过安全检查。只暴露结构化的工具调用数据,大大缩小了注入攻击面。
紧凑格式
每个转录条目通过 toCompactBlock 函数(第 384-424 行)序列化为紧凑格式。系统支持两种编码方式,通过 GrowthBook 的 jsonlTranscript 开关控制:
文本前缀格式(默认):
User: implement a fibonacci function
Bash ls -la
FileEditTool {"file_path":"/src/fib.ts","new_string":"..."}
JSONL 格式:
{"user":"implement a fibonacci function"}
{"Bash":"ls -la"}
{"FileEditTool":{"file_path":"/src/fib.ts","new_string":"..."}}
JSONL 格式的优势在于 JSON 转义天然防止了内容注入——用户输入中的换行符变成 \n,无法伪造出看起来像工具调用的行(第 379-380 行注释)。
每个工具通过 toAutoClassifierInput 方法控制暴露给分类器的字段子集。当这个方法返回 undefined 时,回退到原始输入对象;返回空字符串时,该工具调用从转录中完全省略。这个机制让工具开发者可以精确控制分类器看到的信息粒度。
CLAUDE.md 上下文注入
分类器还能看到用户的 CLAUDE.md 配置。buildClaudeMdMessage 函数(第 460-477 行)将其包装为一个前缀消息:
// yoloClassifier.ts:468-473(简化)
text:
`The following is the user's CLAUDE.md configuration. These are ` +
`instructions the user provided to the agent and should be treated ` +
`as part of the user's intent when evaluating actions.\n\n` +
`<user_claude_md>\n${claudeMd}\n</user_claude_md>`
这使得分类器能理解用户意图。如果 CLAUDE.md 中写了“本项目使用 make clean 清理构建产物“,分类器在看到 rm -rf build/ 时就能推断这是正常操作而非恶意行为。
17.6 模板系统:差异化的安全策略
分类器的系统提示词不是硬编码的——它由一个基础模板(base prompt)和一个权限模板(permissions template)组合而成。权限模板有两套(第 54-68 行):
// yoloClassifier.ts:54-68
const BASE_PROMPT: string = feature('TRANSCRIPT_CLASSIFIER')
? txtRequire(require('./yolo-classifier-prompts/auto_mode_system_prompt.txt'))
: ''
const EXTERNAL_PERMISSIONS_TEMPLATE: string = feature('TRANSCRIPT_CLASSIFIER')
? txtRequire(require('./yolo-classifier-prompts/permissions_external.txt'))
: ''
const ANTHROPIC_PERMISSIONS_TEMPLATE: string =
feature('TRANSCRIPT_CLASSIFIER') && process.env.USER_TYPE === 'ant'
? txtRequire(require('./yolo-classifier-prompts/permissions_anthropic.txt'))
: ''
外部用户模板(permissions_external.txt)面向所有公开版本用户,安全策略更保守。Anthropic 内部模板(permissions_anthropic.txt)仅在 USER_TYPE === 'ant' 时加载,可能包含更宽松的内部开发规则。
模板选择逻辑在 isUsingExternalPermissions 函数中(第 71-78 行):
// yoloClassifier.ts:71-78
// function isUsingExternalPermissions(): boolean {
if (process.env.USER_TYPE !== 'ant') return true
const config = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_auto_mode_config',
{} as AutoModeConfig,
)
return config?.forceExternalPermissions === true
}
非 Anthropic 用户始终使用外部模板。Anthropic 内部用户默认使用内部模板,但可以通过 GrowthBook 的 forceExternalPermissions 开关强制切换到外部模板——这是一个 dogfood(自我体验)机制,让内部开发者能测试外部用户的体验。
用户可自定义的规则段
两套模板都包含三个可替换的规则段:
// yoloClassifier.ts:85-89
export type AutoModeRules = {
allow: string[] // 允许规则
soft_deny: string[] // 软拒绝规则
environment: string[] // 环境描述
}
用户通过 settings.autoMode 配置这些规则。替换机制使用 XML 标签定界(第 527-539 行):
// yoloClassifier.ts:527-539
return systemPrompt
.replace(
/<user_allow_rules_to_replace>([\s\S]*?)<\/user_allow_rules_to_replace>/,
(_m, defaults: string) => userAllow ?? defaults,
)
.replace(
/<user_deny_rules_to_replace>([\s\S]*?)<\/user_deny_rules_to_replace>/,
(_m, defaults: string) => userDeny ?? defaults,
)
.replace(
/<user_environment_to_replace>([\s\S]*?)<\/user_environment_to_replace>/,
(_m, defaults: string) => userEnvironment ?? defaults,
)
外部模板和内部模板在替换行为上有微妙差异(第 511-516 行注释):外部模板将默认规则放在 <foo_to_replace> 标签内部,用户规则会替换默认值;内部模板将默认规则放在标签外部,标签内为空,用户规则是追加性质。这意味着外部用户可以完全覆盖默认安全策略(自担风险),而内部用户只能在内部基线之上添加额外规则。
Bash 规则与 PowerShell 规则的注入
除了用户自定义规则,系统还会自动注入 Bash 和 PowerShell 相关的安全规则。buildYoloSystemPrompt 函数(第 484-540 行)根据用户类型决定是否注入这些规则:
// yoloClassifier.ts:495-509(简化)
const includeBashPromptRules = feature('BASH_CLASSIFIER')
? !usingExternal : false
const allowDescriptions = [
...(includeBashPromptRules ? getBashPromptAllowDescriptions(context) : []),
...(autoMode?.allow ?? []),
]
const denyDescriptions = [
...(includeBashPromptRules ? getBashPromptDenyDescriptions(context) : []),
...(includePowerShellGuidance ? POWERSHELL_DENY_GUIDANCE : []),
...(autoMode?.soft_deny ?? []),
]
PowerShell 的拒绝规则(第 1402-1411 行)将 Windows 特有的危险模式映射到已有的安全类别:
iex (iwr ...)→ “Code from External”(等同于curl | bash)Remove-Item -Recurse -Force→ “Irreversible Local Destruction”(等同于rm -rf)- 修改
$PROFILE→ “Unauthorized Persistence”(等同于修改.bashrc) Start-Process -Verb RunAs→ “Security Weaken”
17.7 拒绝追踪:防止死循环
分类器可能陷入一种病态循环:Agent 反复尝试同一操作,分类器反复拒绝,Agent 换个说法再试,分类器再次拒绝……这不仅浪费 token,更让用户等得不耐烦却无法介入。
denialTracking.ts 用极少的代码解决了这个问题。状态结构只有两个计数器(第 7-10 行):
// denialTracking.ts:7-10
export type DenialTrackingState = {
consecutiveDenials: number
totalDenials: number
}
阈值常量(第 12-15 行):
// denialTracking.ts:12-15
export const DENIAL_LIMITS = {
maxConsecutive: 3,
maxTotal: 20,
} as const
状态更新函数遵循不可变模式——每次调用返回新对象:
recordDenial(第 24-30 行):连续计数和总计数都加一recordSuccess(第 32-38 行):只重置连续计数,不重置总计数
回退判定函数(第 40-45 行):
// denialTracking.ts:40-45
// export function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
return (
state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
state.totalDenials >= DENIAL_LIMITS.maxTotal
)
}
当连续 3 次拒绝或总计 20 次拒绝时,系统从自动模式回退到用户提示模式。这两个阈值的设计意图不同:
- 连续 3 次:捕获“Agent 反复尝试同一被禁操作“的情况——3 次足以确认这不是偶然的判定波动,而是 Agent 确实在做分类器不允许的事
- 总计 20 次:捕获“Agent 频繁触碰安全边界但每次换不同操作“的情况——即使没有连续被拒,累积的拒绝次数表明 Agent 的行为模式可能需要人类介入
recordSuccess 重置连续计数但保留总计数的设计确保了:一次成功的操作不会“洗白“之前的拒绝历史。一个在 20 次操作中被拒 19 次的 Agent,即使每次拒绝之间都穿插了成功操作,仍然会触发回退。
17.8 自动模式状态管理
autoModeState.ts 管理自动模式的全局运行时状态。它用模块级变量实现了一个极简的状态机(第 4-9 行):
// autoModeState.ts:4-9
let autoModeActive = false
let autoModeFlagCli = false
let autoModeCircuitBroken = false
三个布尔值各有用途:
| 变量 | 含义 | 设置时机 |
|---|---|---|
autoModeActive | 自动模式当前是否激活 | 用户通过 Shift+Tab 切换或 CLI 参数启用 |
autoModeFlagCli | 是否通过 CLI 参数传入了自动模式标志 | 启动时解析 CLI 参数 |
autoModeCircuitBroken | 自动模式是否被熔断 | GrowthBook 配置下发 enabled === 'disabled' |
autoModeCircuitBroken 是一个远程“杀开关“。注释说明了它的触发条件(第 8-9 行):当 verifyAutoModeGateAccess 检查读取到 GrowthBook 中 tengu_auto_mode_config.enabled === 'disabled' 时设置为 true。一旦熔断,isAutoModeGateEnabled() 返回 false,阻止任何重新进入自动模式的尝试——即使用户在会话中手动切换也无效。
这个模块被设计为可选加载(第 1-2 行注释):“lives in its own module so callers can conditionally require() it on feature('TRANSCRIPT_CLASSIFIER').” 在外部构建中,整个模块可以被树摇(tree-shake)掉。
_resetForTesting 函数(第 35-39 行)将所有状态重置为初始值,专门用于测试隔离——前缀下划线是 Claude Code 代码库中“仅测试使用“的命名约定。
17.9 分类器模型选择
分类器使用的模型不一定与主循环相同。getClassifierModel 函数(第 1334-1347 行)定义了优先级链:
// yoloClassifier.ts:1334-1347
// function getClassifierModel(): string {
if (process.env.USER_TYPE === 'ant') {
const envModel = process.env.CLAUDE_CODE_AUTO_MODE_MODEL
if (envModel) return envModel
}
const config = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_auto_mode_config',
{} as AutoModeConfig,
)
if (config?.model) {
return config.model
}
return getMainLoopModel()
}
- 环境变量(仅 Anthropic 内部):
CLAUDE_CODE_AUTO_MODE_MODEL - GrowthBook 配置:
tengu_auto_mode_config.model - 回退:主循环模型
这允许团队在不影响用户体验的前提下 A/B 测试不同的分类器模型。例如,用一个更小、更快的模型做分类可以降低延迟和成本,而用一个更大的模型可以提高准确率。
Thinking 模式的特殊处理
某些模型默认启用了“always-on thinking“(自适应思考),分类器需要特殊处理这种情况。getClassifierThinkingConfig 函数(第 683-693 行)返回一个元组 [disableThinking, headroom]:
- 普通模型:
[false, 0]—— 显式禁用 thinking,无额外 token 预算 - always-on thinking 模型:
[undefined, 2048]—— 不传 thinking 参数(让服务端使用默认值),并增加 2048 token 的预算空间
注释中记录了不加预算空间的后果(第 676-678 行):自适应思考可能消耗 0-1114 个 token,如果不预留空间,stop_reason=max_tokens 会导致空响应,进而导致安全命令被误阻。
17.10 调试基础设施
分类器的决策过程对用户完全透明——或者说,对有权限的开发者完全透明。CLAUDE_CODE_DUMP_AUTO_MODE 环境变量启用完整的请求/响应转储。
maybeDumpAutoMode 函数(第 153-180 行)在每次分类器调用后将请求体和响应体写入磁盘:
// yoloClassifier.ts:158-161
if (process.env.USER_TYPE !== 'ant') return
if (!isEnvTruthy(process.env.CLAUDE_CODE_DUMP_AUTO_MODE)) return
const base = suffix ? `${timestamp}.${suffix}` : `${timestamp}`
文件命名格式为 {unix_timestamp}[.{stage}].{req|res}.json,写入 ~/.claude/tmp/auto-mode/ 目录。对于二阶段分类器,会生成两组文件:{ts}.stage1.req.json + {ts}.stage1.res.json 和 {ts}.stage2.req.json + {ts}.stage2.res.json。
这个功能有两个安全限制:首先,仅对 Anthropic 内部用户可用(第 159 行的 USER_TYPE 检查);其次,需要显式设置环境变量才启用。这确保了生产环境不会意外地将分类器的完整提示词和对话内容写入磁盘。
错误状态转储
除了主动开启的调试转储,系统还有一个被动的错误转储机制。当分类器 API 调用失败时,dumpErrorPrompts 函数(第 213-250 行)将错误信息、上下文比较数据、系统提示词和用户提示词写入一个会话级文件:
// yoloClassifier.ts:228-243(简化)
const content =
`=== ERROR ===\n${errorMessage(error)}\n\n` +
`=== CONTEXT COMPARISON ===\n` +
`timestamp: ${new Date().toISOString()}\n` +
`model: ${contextInfo.model}\n` +
`mainLoopTokens: ${contextInfo.mainLoopTokens}\n` +
`classifierChars: ${contextInfo.classifierChars}\n` +
`classifierTokensEst: ${contextInfo.classifierTokensEst}\n` +
// ...
`=== SYSTEM PROMPT ===\n${systemPrompt}\n\n` +
`=== USER PROMPT (transcript) ===\n${userPrompt}\n`
“CONTEXT COMPARISON” 段特别重要——它记录了主循环 token 数量与分类器 token 估算值的对比。当分类器的转录内容超过模型的上下文窗口时,这些数据帮助诊断是转录投影出了问题还是主循环本身就已经接近上下文上限。
错误转储文件路径由 getAutoModeClassifierErrorDumpPath 函数(第 186-192 行)生成,基于会话 ID,确保每个会话有独立的错误日志。用户通过 /share 命令分享会话时,这个文件会被自动收集。
上下文偏差遥测
分类器在每次调用时都会计算一个关键指标:主循环 token 数与分类器 token 估算值的比率。代码注释中记载了期望值(第 1250-1252 行):
classifierInputTokens / mainLoopTokens
Expect ~0.6-0.8 steady state; alert on p95 > 1.0
当比率超过 1.0,意味着分类器的输入量比主循环还大——这是一个严重问题,因为自动压缩(auto-compact)是根据主循环的 token 数触发的。如果分类器先于主循环耗尽上下文窗口,自动压缩将无法挽救局面,分类器会遇到“prompt is too long“错误。
detectPromptTooLong 函数(第 1463-1471 行)专门识别这类错误并解析出实际 token 数和限制值,用于遥测分析。
17.11 错误处理与安全降级
分类器的错误处理策略可以用一句话概括:任何不确定性都等同于阻止。
以下是各种故障场景的降级行为:
| 场景 | 行为 | 返回标志 |
|---|---|---|
| API 返回成功但响应无法解析 | 阻止操作 | shouldBlock: true |
| API 返回 400(prompt 过长) | 阻止操作 | transcriptTooLong: true |
| API 返回 429/500(暂时不可用) | sideQuery 内部重试,最终失败则阻止 | unavailable: true |
| 用户取消(AbortSignal) | 阻止操作 | unavailable: true |
| 阶段 1 阻止,阶段 2 失败 | 基于阶段 1 结果阻止 | stage: 'thinking' |
unavailable: true 标志让调用者能区分“分类器主动判定阻止“和“分类器宕机了“。在后一种情况下,调用者可能选择回退到用户确认模式而非直接拒绝操作。
整个分类器的 token 使用量通过 YoloClassifierResult.usage 字段返回给调用者,包含四个维度:inputTokens、outputTokens、cacheReadInputTokens、cacheCreationInputTokens。这使得调用者能精确追踪分类器的开销并在遥测中上报。
模式提炼
模式一:分层短路过滤(Layered Short-Circuit Filtering)
解决的问题:AI 分类器调用成本高(延迟 + token),但大量请求其实无需分类。
代码模板:在分类器入口前设置多层短路——白名单(工具级)→ 输入过滤(字段级)→ 快速判定(阶段 1)→ 深度推理(阶段 2)。每层仅处理上层无法决定的请求。
前置条件:需要明确定义“哪些操作天然安全“的分类标准。
模式二:失败即阻止(Fail-Closed)
解决的问题:分类器可能返回无法解析的结果、API 超时或内部错误,此时系统必须有安全的默认行为。
代码模板:所有异常路径(Schema 解析失败、API 错误、响应截断)统一返回 shouldBlock: true,将控制权交还给人类。
前置条件:系统有人类回退路径(如权限对话框)。
模式三:连续异常降级(Consecutive Anomaly Degradation)
解决的问题:自动化决策系统可能陷入反复失败的死循环。
代码模板:维护 consecutiveFailures 和 totalFailures 两个计数器,连续 N 次失败或总计 M 次失败时降级到人工模式。成功时重置连续计数但保留总计数。
前置条件:存在可降级的备选路径。
用户能做什么
Auto 模式调试
- 如果 auto 模式频繁阻塞正常操作,检查是否缺少
settings.autoMode.allow规则。例如,在 CLAUDE.md 中声明“本项目使用make clean清理构建“可帮助分类器理解上下文 - 连续 3 次被拒后系统会自动回退到人工确认——此时建议手动放行并观察分类器是否在后续操作中自我纠正
自定义安全规则
- 通过
settings.autoMode.allow添加允许规则描述(自然语言,非正则),例如:“Allow runningnpm testandnpm run build” - 通过
settings.autoMode.soft_deny添加软拒绝规则,例如:“Deny any command that modifies files outside the project directory” - 这些规则会注入分类器的系统提示词,影响 AI 裁决
性能优化
- 确保自定义工具实现了
toAutoClassifierInput方法——返回空字符串可跳过分类器调用 - 二阶段分类器(
both模式)在大多数场景下是最优选择——阶段 1 快速放行安全操作,仅对模糊操作触发阶段 2
17.12 小结
YOLO 分类器是 Claude Code 安全架构中最精妙的组件之一。它不是一堆正则表达式的堆砌,而是一个完整的 AI 安全裁决系统——有白名单短路、有二阶段审核、有拒绝追踪、有远程熔断、有差异化模板、有全链路调试能力。
它的核心设计原则是分层过滤:
- 安全白名单在工具级别短路,零成本
toAutoClassifierInput在字段级别短路,零成本- 阶段 1 用 64 token 做快速判定,放行立即返回
- 阶段 2 用 4096 token 做深度推理,仅在必要时触发
- 拒绝追踪在会话级别监控,防止死循环
- 远程熔断在服务级别控制,应急时一键关闭
每一层都在为下一层减少工作量。白名单过滤掉 70%+ 的工具调用,阶段 1 过滤掉大部分安全操作,阶段 2 只需要处理真正模糊的边界情况。这种分层设计使得分类器的平均延迟和 token 开销远低于“每次都做全量推理“的朴素方案。
但这个系统也有其内在张力:分类器本身是一个 AI 模型,它的判断不可能 100% 准确。过于保守会频繁误阻正常操作(用户体验退化),过于宽松则可能放过危险行为(安全事故)。二阶段设计和用户可配置规则试图在这个光谱上提供灵活性,但最终的安全底线仍然是:在不确定时,阻止操作并交给人类裁决。
第18章:Hooks — 用户自定义拦截点
为什么这很重要
Claude Code 的权限系统(第16章)和 YOLO 分类器(第17章)提供了内置的安全防线,但它们都是“预设好的“——用户无法在工具执行流水线的关键节点插入自己的逻辑。Hooks 系统填补了这个空白:它允许用户在 AI Agent 生命周期的 26 个事件点注册自定义的 Shell 命令、LLM 提示词、HTTP 请求或 Agent 验证器,实现从“格式检查“到“自动部署“的任意工作流定制。
这不是一个简单的“回调函数“机制。Hooks 系统必须解决四个核心难题:信任——任意命令执行的安全边界在哪里?超时——Hook 挂死时如何防止阻塞整个 Agent 循环?语义——Hook 的退出码如何转化为“允许“或“阻塞“决策?以及配置隔离——多来源的 Hook 配置如何合并而不互相干扰?
本章将从源码层面完整剖析这套机制。
Hook 事件生命周期总览
flowchart LR
subgraph SESSION ["会话生命周期"]
direction TB
SS["SessionStart"] --> SETUP["Setup"]
subgraph TOOL ["工具执行生命周期"]
direction TB
PRE["PreToolUse"] --> PERM{"权限检查"}
PERM -- 需要确认 --> PR["PermissionRequest"]
PERM -- 通过 --> EXEC["执行工具"]
PR -- 允许 --> EXEC
PR -- 拒绝 --> PD["PermissionDenied"]
EXEC -- 成功 --> POST["PostToolUse"]
EXEC -- 失败 --> POSTF["PostToolUseFailure"]
subgraph RESPOND ["响应生命周期"]
direction TB
UPS["UserPromptSubmit"] --> TOOL2["工具调用循环"]
TOOL2 --> STOP["Stop"]
STOP -- "退出码 2" --> TOOL2
subgraph END_PHASE ["结束"]
direction TB
SE["SessionEnd<br />超时: 1.5s"]
SESSION --> RESPOND
RESPOND --> END_PHASE
18.1 Hook 事件类型完整清单
Hooks 系统支持 26 种事件类型,定义在 hooksConfigManager.ts 的 getHookEventMetadata 函数中(第 28-264 行)。按生命周期阶段可分为五组:
工具执行生命周期
| 事件 | 触发时机 | matcher 字段 | 退出码 2 的行为 |
|---|---|---|---|
PreToolUse | 工具执行前 | tool_name | 阻塞工具调用,stderr 发送给模型 |
PostToolUse | 工具执行成功后 | tool_name | stderr 立即发送给模型 |
PostToolUseFailure | 工具执行失败后 | tool_name | stderr 立即发送给模型 |
PermissionRequest | 权限对话框显示时 | tool_name | 使用 Hook 决策 |
PermissionDenied | auto 模式分类器拒绝工具调用后 | tool_name | — |
PreToolUse 是最常用的 Hook 点。它的 hookSpecificOutput 支持三种权限决策(第 72-78 行,types/hooks.ts):
// types/hooks.ts:72-78
z.object({
hookEventName: z.literal('PreToolUse'),
permissionDecision: permissionBehaviorSchema().optional(),
permissionDecisionReason: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
additionalContext: z.string().optional(),
})
注意 updatedInput 字段——Hook 不仅可以决定“是否允许“,还可以修改工具的输入参数。这使得“重写命令“成为可能:比如在所有 git push 前自动添加 --no-verify。
会话生命周期
| 事件 | 触发时机 | matcher 字段 | 特殊行为 |
|---|---|---|---|
SessionStart | 新会话/恢复/清空/压缩时 | source (startup/resume/clear/compact) | stdout 发送给 Claude,阻塞错误被忽略 |
SessionEnd | 会话结束时 | reason (clear/logout/prompt_input_exit/other) | 超时仅 1.5 秒 |
Setup | 仓库初始化和维护时 | trigger (init/maintenance) | stdout 发送给 Claude |
Stop | Claude 即将结束响应前 | — | 退出码 2 让对话继续 |
StopFailure | API 错误导致回合结束时 | error (rate_limit/authentication_failed/…) | fire-and-forget |
UserPromptSubmit | 用户提交提示词时 | — | 退出码 2 阻塞处理并擦除原始提示词 |
SessionStart Hook 有一个独特的能力:通过 CLAUDE_ENV_FILE 环境变量,Hook 可以将 bash export 语句写入指定文件,这些环境变量会在后续所有 BashTool 命令中生效(第 917-926 行,hooks.ts):
// hooks.ts:917-926
if (
!isPowerShell &&
(hookEvent === 'SessionStart' ||
hookEvent === 'Setup' ||
hookEvent === 'CwdChanged' ||
hookEvent === 'FileChanged') &&
hookIndex !== undefined
) {
envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex)
}
多 Agent 生命周期
| 事件 | 触发时机 | matcher 字段 |
|---|---|---|
SubagentStart | 子 Agent 启动时 | agent_type |
SubagentStop | 子 Agent 即将结束响应前 | agent_type |
TeammateIdle | 队友即将进入空闲状态 | — |
TaskCreated | 任务创建时 | — |
TaskCompleted | 任务完成时 | — |
文件与配置变更
| 事件 | 触发时机 | matcher 字段 |
|---|---|---|
FileChanged | 被监听文件变更时 | 文件名 (e.g. .envrc|.env) |
CwdChanged | 工作目录变更后 | — |
ConfigChange | 配置文件在会话期间变更时 | source (user_settings/project_settings/…) |
InstructionsLoaded | CLAUDE.md 或规则文件加载时 | load_reason (session_start/path_glob_match/…) |
压缩、MCP 交互与 Worktree
| 事件 | 触发时机 | matcher 字段 |
|---|---|---|
PreCompact | 对话压缩前 | trigger (manual/auto) |
PostCompact | 对话压缩后 | trigger (manual/auto) |
Elicitation | MCP 服务器请求用户输入时 | mcp_server_name |
ElicitationResult | 用户响应 MCP elicitation 后 | mcp_server_name |
WorktreeCreate | 创建隔离工作树时 | — |
WorktreeRemove | 移除工作树时 | — |
18.2 四种 Hook 类型
Hooks 系统支持四种可持久化的 Hook 类型,加上两种运行时注册的内部类型。所有可持久化类型的 schema 定义在 schemas/hooks.ts 的 buildHookSchemas 函数中(第 31-163 行)。
command 类型:Shell 命令
最基础也最常用的类型:
// schemas/hooks.ts:32-65
const BashCommandHookSchema = z.object({
type: z.literal('command'),
command: z.string(),
if: IfConditionSchema(),
shell: z.enum(SHELL_TYPES).optional(), // 'bash' | 'powershell'
timeout: z.number().positive().optional(),
statusMessage: z.string().optional(),
once: z.boolean().optional(), // 执行一次后移除
async: z.boolean().optional(), // 后台执行,不阻塞
asyncRewake: z.boolean().optional(), // 后台执行,退出码2时唤醒模型
})
shell 字段控制解释器选择(第 790-791 行,hooks.ts)——默认为 bash(实际使用 $SHELL,支持 bash/zsh/sh),powershell 使用 pwsh。两条执行路径完全分离:bash 路径会做 Windows Git Bash 路径转换(C:\Users\foo -> /c/Users/foo)、.sh 文件自动 bash 前缀、CLAUDE_CODE_SHELL_PREFIX 包装;PowerShell 路径则跳过所有这些,使用原生 Windows 路径。
if 字段提供了细粒度的条件过滤。它使用权限规则语法(如 Bash(git *)),在 Hook 匹配阶段而非 spawn 之后评估——避免为不匹配的命令启动无用进程(第 1390-1421 行,hooks.ts):
// hooks.ts:1390-1421
// async function prepareIfConditionMatcher(
hookInput: HookInput,
tools: Tools | undefined,
): Promise<IfConditionMatcher | undefined> {
if (
hookInput.hook_event_name !== 'PreToolUse' &&
hookInput.hook_event_name !== 'PostToolUse' &&
hookInput.hook_event_name !== 'PostToolUseFailure' &&
hookInput.hook_event_name !== 'PermissionRequest'
) {
return undefined
}
// ...复用权限规则解析器和工具的 preparePermissionMatcher
}
prompt 类型:LLM 评估
将 Hook 输入发送给一个轻量级 LLM 进行评估:
// schemas/hooks.ts:67-95
const PromptHookSchema = z.object({
type: z.literal('prompt'),
prompt: z.string(), // 使用 $ARGUMENTS 占位符注入 Hook 输入 JSON
if: IfConditionSchema(),
model: z.string().optional(), // 默认使用小型快速模型
statusMessage: z.string().optional(),
once: z.boolean().optional(),
})
agent 类型:Agent 验证器
比 prompt 更强大——它会启动一个完整的 Agent 循环来验证某个条件:
// schemas/hooks.ts:128-163
const AgentHookSchema = z.object({
type: z.literal('agent'),
prompt: z.string(), // "Verify that unit tests ran and passed."
if: IfConditionSchema(),
timeout: z.number().positive().optional(), // 默认 60 秒
model: z.string().optional(), // 默认使用 Haiku
statusMessage: z.string().optional(),
once: z.boolean().optional(),
})
源码中有一条重要的设计注释(第 130-141 行):prompt 字段曾被 .transform() 包装为函数,导致 JSON.stringify 时丢失——这个 Bug 被追踪为 gh-24920/CC-79,现已修复。
http 类型:Webhook
将 Hook 输入 POST 到指定 URL:
// schemas/hooks.ts:97-126
const HttpHookSchema = z.object({
type: z.literal('http'),
url: z.string().url(),
if: IfConditionSchema(),
timeout: z.number().positive().optional(),
headers: z.record(z.string(), z.string()).optional(),
allowedEnvVars: z.array(z.string()).optional(),
statusMessage: z.string().optional(),
once: z.boolean().optional(),
})
headers 支持环境变量插值($VAR_NAME 或 ${VAR_NAME}),但只有 allowedEnvVars 中列出的变量才会被解析——这是一个显式白名单机制,防止意外泄露敏感环境变量。
需要注意:HTTP Hook 不支持 SessionStart 和 Setup 事件(第 1853-1864 行,hooks.ts),因为在 headless 模式下 sandbox ask 回调会死锁。
内部类型:callback 和 function
这两种类型无法通过配置文件定义,仅供 SDK 和内部组件注册。callback 类型用于 attribution hooks、session file access hooks 等内部功能;function 类型由 Agent 前言(frontmatter)注册的结构化输出强制器使用。
18.3 执行模型
异步生成器架构
executeHooks 是整个系统的核心函数(第 1952-2098 行,hooks.ts),它被声明为 async function*——一个异步生成器:
// hooks.ts:1952-1977
async function* executeHooks({
hookInput,
toolUseID,
matchQuery,
signal,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
toolUseContext,
messages,
forceSyncExecution,
requestPrompt,
toolInputSummary,
}: { /* ... */ }): AsyncGenerator<AggregatedHookResult> {
这个设计允许调用者通过 for await...of 逐步接收 Hook 执行结果,实现流式处理。每个 Hook 在执行前先 yield 一个 progress 消息,执行完成后 yield 最终结果。
超时策略
超时策略根据事件类型分为两档:
默认超时:10 分钟。 定义在第 166 行:
// hooks.ts:166
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
这个较长的超时适用于大多数 Hook 事件——用户的 CI 脚本、测试套件、构建命令都可能需要数分钟。
SessionEnd 超时:1.5 秒。 定义在第 175-182 行:
// hooks.ts:174-182
const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500
// export function getSessionEndHookTimeoutMs(): number {
const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
const parsed = raw ? parseInt(raw, 10) : NaN
return Number.isFinite(parsed) && parsed > 0
? parsed
: SESSION_END_HOOK_TIMEOUT_MS_DEFAULT
}
SessionEnd Hook 在关闭/清空时运行,必须有极其紧凑的超时约束——否则用户按 Ctrl+C 后还要等 10 分钟才能退出。1.5 秒同时作为单个 Hook 的默认超时和整体 AbortSignal 上限(因为所有 Hook 并行执行)。用户可通过 CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS 环境变量覆盖。
每个 Hook 还可以通过 timeout 字段指定自己的超时时间(秒),它会覆盖默认值(第 877-879 行):
// hooks.ts:877-879
const hookTimeoutMs = hook.timeout
? hook.timeout * 1000
: TOOL_HOOK_EXECUTION_TIMEOUT_MS
异步后台 Hook
Hook 可以通过两种方式进入后台执行:
- 配置声明:设置
async: true或asyncRewake: true(第 995-1029 行) - 运行时声明:Hook 在第一行输出
{"async": true}JSON(第 1117-1164 行)
两者的关键区别在于 asyncRewake:当设置此标志时,后台 Hook 不注册到异步注册表,而是在完成后检查退出码——如果退出码为 2,它会通过 enqueuePendingNotification 将错误消息作为 task-notification 入队,唤醒模型继续处理(第 205-244 行)。
后台 Hook 执行时的一个微妙细节:必须在 backgrounding 之前写入 stdin,否则 bash 的 read -r line 会因 EOF 返回退出码 1——这个 Bug 被追踪为 gh-30509/CC-161(第 1001-1008 行的注释)。
提示词请求协议
command 类型 Hook 支持一种双向交互协议:Hook 进程可以向 stdout 写入 JSON 格式的提示词请求,Claude Code 将向用户显示选择对话框,并将用户选择通过 stdin 回传:
// types/hooks.ts:28-40
export const promptRequestSchema = lazySchema(() =>
z.object({
prompt: z.string(), // 请求 ID
message: z.string(), // 显示给用户的消息
options: z.array(
z.object({
key: z.string(),
label: z.string(),
description: z.string().optional(),
}),
),
}),
)
这个协议是序列化的——多个提示词请求会按顺序处理(第 1064 行的 promptChain),确保响应不会乱序。
18.4 退出码语义
退出码是 Hook 与 Claude Code 之间的主要通信协议:
| 退出码 | 语义 | 行为 |
|---|---|---|
| 0 | 成功/允许 | stdout/stderr 不显示(或仅在 transcript 模式显示) |
| 2 | 阻塞错误 | stderr 发送给模型,阻塞当前操作 |
| 其他 | 非阻塞错误 | stderr 仅显示给用户,操作继续 |
但不同事件类型对退出码的解释有所不同。以下是关键差异:
- PreToolUse:退出码 2 阻塞工具调用并将 stderr 发送给模型;退出码 0 的 stdout/stderr 不显示
- Stop:退出码 2 将 stderr 发送给模型并继续对话(而非结束)——这是“继续编码“模式的实现基础
- UserPromptSubmit:退出码 2 阻塞处理、擦除原始提示词、并仅向用户显示 stderr
- SessionStart/Setup:阻塞错误被忽略——这些事件不允许 Hook 阻塞启动流程
- StopFailure:fire-and-forget,所有输出和退出码都被忽略
JSON 输出协议
除了退出码,Hook 还可以通过 stdout 输出 JSON 来传递结构化信息。parseHookOutput 函数(第 399-451 行)的逻辑是:如果 stdout 以 { 开头,尝试 JSON 解析并通过 Zod schema 验证;否则视为纯文本。
JSON 输出的完整 schema 定义在 types/hooks.ts:50-176。核心字段包括:
// types/hooks.ts:50-66
export const syncHookResponseSchema = lazySchema(() =>
z.object({
continue: z.boolean().optional(), // false = 停止执行
suppressOutput: z.boolean().optional(), // true = 隐藏 stdout
stopReason: z.string().optional(), // continue=false 时的消息
decision: z.enum(['approve', 'block']).optional(),
reason: z.string().optional(),
systemMessage: z.string().optional(), // 显示给用户的警告
hookSpecificOutput: z.union([/* 按事件类型的专有输出 */]).optional(),
}),
)
hookSpecificOutput 是一个判别联合(discriminated union),每个事件类型都有自己的专有字段。例如 PermissionRequest 事件(第 121-133 行)支持 allow/deny 决策和权限更新:
// types/hooks.ts:121-133
z.object({
hookEventName: z.literal('PermissionRequest'),
decision: z.union([
z.object({
behavior: z.literal('allow'),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(permissionUpdateSchema()).optional(),
}),
z.object({
behavior: z.literal('deny'),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]),
})
18.5 信任门控
Hooks 执行的安全门控由 shouldSkipHookDueToTrust 函数(第 286-296 行)实现:
// hooks.ts:286-296
// export function shouldSkipHookDueToTrust(): boolean {
const isInteractive = !getIsNonInteractiveSession()
if (!isInteractive) {
return false // SDK 模式下信任是隐含的
}
const hasTrust = checkHasTrustDialogAccepted()
return !hasTrust
}
规则很简单但至关重要:
- 非交互模式(SDK):信任是隐含的,所有 Hook 直接执行
- 交互模式:所有 Hook 都需要信任对话框确认
代码注释(第 267-285 行)详细解释了“为什么是所有“:Hook 配置在 captureHooksConfigSnapshot() 阶段就被捕获,这发生在信任对话框显示之前。虽然大多数 Hook 通过正常程序流不会在信任确认前执行,但历史上存在两个漏洞——SessionEnd Hook 在用户拒绝信任时仍然执行,SubagentStop Hook 在子 Agent 在信任确认前完成时执行。纵深防御原则要求对所有 Hook 统一检查。
executeHooks 函数也在执行前进行集中检查(第 1993-1999 行):
// hooks.ts:1993-1999
if (shouldSkipHookDueToTrust()) {
logForDebugging(
`Skipping ${hookName} hook execution - workspace trust not accepted`,
)
return
}
此外,disableAllHooks 设置提供了更极端的控制(第 1978-1979 行)——如果在 policySettings 中设置,则禁用所有 Hook 包括 managed Hook;如果在非 managed 设置中设置,则仅禁用非 managed Hook(managed Hook 仍然运行)。
18.6 配置快照追踪
Hook 配置不是每次执行时实时读取,而是通过快照机制管理。hooksConfigSnapshot.ts 定义了这套系统:
快照捕获
captureHooksConfigSnapshot()(第 95-97 行)在应用启动时调用一次:
// hooksConfigSnapshot.ts:95-97
// export function captureHooksConfigSnapshot(): void {
initialHooksConfig = getHooksFromAllowedSources()
}
来源过滤
getHooksFromAllowedSources()(第 18-53 行)实现了多层过滤逻辑:
- 如果 policySettings 设置了
disableAllHooks: true,返回空配置 - 如果 policySettings 设置了
allowManagedHooksOnly: true,仅返回 managed hooks - 如果启用了
strictPluginOnlyCustomization策略,阻塞 user/project/local 设置中的 hooks - 如果非 managed 设置中设置了
disableAllHooks,仅 managed hooks 运行 - 否则返回所有来源的合并配置
快照更新
当用户通过 /hooks 命令修改 Hook 配置时,updateHooksConfigSnapshot()(第 104-112 行)被调用:
// hooksConfigSnapshot.ts:104-112
// export function updateHooksConfigSnapshot(): void {
resetSettingsCache() // 确保从磁盘读取最新设置
initialHooksConfig = getHooksFromAllowedSources()
}
注意 resetSettingsCache() 的调用——没有它,快照可能使用过期的缓存设置。这是因为文件监视器的稳定性阈值可能尚未触发(注释中提到了这一点)。
18.7 匹配与去重
Matcher 模式
每个 Hook 配置可以指定一个 matcher 字段,用于精确筛选触发条件。matchesPattern 函数(第 1346-1381 行)支持三种模式:
- 精确匹配:
Write仅匹配工具名Write - 管道分隔:
Write|Edit匹配Write或Edit - 正则表达式:
^Write.*匹配所有以Write开头的工具名
判断依据是字符串内容:如果仅包含 [a-zA-Z0-9_|],视为简单匹配;否则视为正则。
去重机制
同一命令可能在多个配置源(user/project/local)中定义,去重由 hookDedupKey 函数(第 1453-1455 行)实现:
// hooks.ts:1453-1455
// function hookDedupKey(m: MatchedHook, payload: string): string {
return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}`
}
关键设计:去重键按来源上下文命名空间化——同一个 echo hello 命令在不同插件目录中不会被去重(因为展开 ${CLAUDE_PLUGIN_ROOT} 后指向不同文件),但同一命令在 user/project/local 设置中会被合并为一个。
callback 和 function 类型 Hook 跳过去重——它们每个实例都是唯一的。当所有匹配的 Hook 都是 callback/function 类型时,还有一个快速路径(第 1723-1729 行),完全跳过 6 轮过滤和 Map 构建,微基准测试显示性能提升 44 倍。
18.8 实际配置示例
示例1:PreToolUse 格式检查
在每次 TypeScript 文件写入前自动运行格式检查:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "FILE=$(echo $ARGUMENTS | jq -r '.file_path') && prettier --check \"$CLAUDE_PROJECT_DIR/$FILE\" 2>&1 || echo '{\"decision\":\"block\",\"reason\":\"File does not pass prettier formatting\"}'",
"if": "Write(*.ts)",
"statusMessage": "Checking formatting..."
}
]
}
]
}
}
这个配置展示了几个关键能力:
matcher: "Write|Edit"使用管道分隔匹配两个工具if: "Write(*.ts)"使用权限规则语法进一步过滤——仅对 TypeScript 文件生效$CLAUDE_PROJECT_DIR环境变量自动设置为项目根目录(第 813-816 行)- Hook 输入 JSON 通过 stdin 传入,Hook 可用
$ARGUMENTS引用或直接从 stdin 读取 - JSON 输出协议中的
decision: "block"阻止不合格的写入
示例2:SessionStart 环境初始化 + Stop 自动验证
结合 SessionStart 和 Stop Hook 实现“自动开发环境“:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "echo 'export NODE_ENV=development' >> $CLAUDE_ENV_FILE && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Dev environment configured. Node: '$(node -v)'\"}}'",
"statusMessage": "Setting up dev environment..."
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Check if there are uncommitted changes. If so, create an appropriate commit message and commit them. Verify the commit was successful.",
"timeout": 120,
"model": "claude-sonnet-4-6",
"statusMessage": "Auto-committing changes..."
}
]
}
]
}
}
这个示例展示了:
- SessionStart Hook 使用
CLAUDE_ENV_FILE注入环境变量到后续 Bash 命令中 additionalContext将信息发送给 Claude 作为上下文- Stop Hook 使用
agent类型启动一个完整的验证 Agent timeout: 120覆盖默认的 60 秒超时
18.9 Hook 来源层级与合并
getHooksConfig 函数(第 1492-1566 行)负责将来自不同来源的 Hook 配置合并为一个统一列表。来源按优先级从高到低排列:
- 配置快照(settings.json 合并结果):通过
getHooksConfigFromSnapshot()获取 - 注册式 Hook(SDK callback + 插件原生 Hook):通过
getRegisteredHooks()获取 - 会话 Hook(Agent frontmatter 注册的 Hook):通过
getSessionHooks()获取 - 会话函数 Hook(结构化输出强制器等):通过
getSessionFunctionHooks()获取
当 allowManagedHooksOnly 策略启用时,来源 2-4 中的非 managed Hook 被跳过。这个过滤发生在合并阶段,而非执行阶段——从根本上阻断了非 managed Hook 进入执行管线的可能性。
hasHookForEvent 函数(第 1582-1593 行)是一个轻量级的存在性检查——它不构建完整的合并列表,而是在找到第一个匹配后立即返回。这用于热路径上的短路优化(如 InstructionsLoaded 和 WorktreeCreate 事件),避免在没有任何 Hook 配置时执行不必要的 createBaseHookInput 和 getMatchingHooks 调用。
18.10 进程管理与 Shell 分支
Hook 的进程 spawn 逻辑(第 940-984 行)根据 Shell 类型分为两条完全独立的路径:
Bash 路径:
// hooks.ts:976-983
const shell = isWindows ? findGitBashPath() : true
child = spawn(finalCommand, [], {
env: envVars,
cwd: safeCwd,
shell,
windowsHide: true,
})
在 Windows 上使用 Git Bash 而非 cmd.exe——这意味着所有路径都必须是 POSIX 格式。windowsPathToPosixPath() 是纯 JS 正则转换(有 LRU-500 缓存),不需要 shell-out 调用 cygpath。
PowerShell 路径:
// hooks.ts:967-972
child = spawn(pwshPath, buildPowerShellArgs(finalCommand), {
env: envVars,
cwd: safeCwd,
windowsHide: true,
})
使用 -NoProfile -NonInteractive -Command 参数——跳过用户 profile 脚本(更快、更确定),在需要输入时快速失败而非挂起。
一个微妙的安全检查:在 spawn 前验证 getCwd() 返回的目录是否存在(第 931-938 行)。当 Agent 工作树被移除时,AsyncLocalStorage 可能返回已删除的路径,此时回退到 getOriginalCwd()。
插件 Hook 的变量替换
当 Hook 来自插件时,命令字符串中的模板变量会在 spawn 前被替换(第 818-857 行):
${CLAUDE_PLUGIN_ROOT}:插件的安装目录${CLAUDE_PLUGIN_DATA}:插件的持久化数据目录${user_config.X}:用户通过/plugin配置的选项值
替换顺序很重要:插件变量先于用户配置变量——这防止用户配置值中包含 ${CLAUDE_PLUGIN_ROOT} 字面量时被二次解析。如果插件目录不存在(可能因 GC 竞争或并发会话删除),代码会在 spawn 前抛出明确错误(第 831-836 行),而不是让命令在找不到脚本后以退出码 2 退出——后者会被误解为“有意阻塞“。
插件选项还会作为环境变量暴露(第 898-906 行),命名格式为 CLAUDE_PLUGIN_OPTION_<KEY>,KEY 被转为大写并用下划线替换非标识符字符。这允许 Hook 脚本通过环境变量读取配置,而不必在命令字符串中使用 ${user_config.X} 模板。
模式提炼
模式一:退出码即协议(Exit Code as Protocol)
解决的问题:Shell 命令与宿主进程之间需要一种轻量级的语义通信机制。
代码模板:定义明确的退出码语义——0 表示成功/允许,2 表示阻塞错误(stderr 发送给模型),其他值表示非阻塞错误(仅显示给用户)。不同事件类型可以对相同退出码赋予不同语义(如 Stop 事件的退出码 2 表示“继续对话“)。
前置条件:Hook 开发者需要文档化的退出码契约。
模式二:配置快照隔离(Config Snapshot Isolation)
解决的问题:配置文件可能在运行时被修改,导致前后不一致的行为。
代码模板:在启动时捕获配置快照(captureHooksConfigSnapshot),运行时使用快照而非实时读取。仅在用户显式修改时更新快照(updateHooksConfigSnapshot),更新前重置设置缓存确保读取最新值。
前置条件:配置变更频率低于执行频率。
模式三:命名空间化去重(Namespaced Deduplication)
解决的问题:同一 Hook 命令可能出现在多个配置源中,需要去重但不能跨上下文合并。
代码模板:去重键包含来源上下文(如插件目录路径),同一命令在不同插件中保持独立,在同一来源的 user/project/local 层级中合并。
前置条件:Hook 有明确的来源标识。
小结
Hooks 系统的设计体现了几个工程权衡:
- 灵活性 vs 安全性:通过信任门控和退出码语义,在“允许任意命令执行“和“防止恶意利用“之间取得平衡
- 同步 vs 异步:异步生成器 + 后台 Hook + asyncRewake 三级策略,让用户选择阻塞程度
- 简单 vs 强大:从简单的 Shell 命令到完整的 Agent 验证器,四种类型覆盖不同复杂度需求
- 隔离 vs 共享:配置快照机制 + 命名空间化去重键,确保多来源配置不互相干扰
下一章我们将看到另一种用户自定义机制——CLAUDE.md 指令系统,它不是通过代码执行来影响行为,而是通过自然语言指令直接控制模型的输出。
第19章:CLAUDE.md — 用户指令作为覆盖层
为什么这很重要
如果说 Hooks 系统(第18章)是用户通过代码执行来扩展 Agent 行为的通道,那么 CLAUDE.md 就是通过自然语言指令来控制模型输出的通道。这不是一个简单的“配置文件“——它是一套完整的指令注入系统,具有四级优先级层叠、传递性文件包含、路径范围限定、HTML 注释剥离、以及明确的覆盖语义声明。
CLAUDE.md 的设计哲学可以用一句话概括:用户的指令覆盖模型的默认行为。 这句话不是修辞——它被字面注入到系统提示词中:
// claudemd.ts:89-91
const MEMORY_INSTRUCTION_PROMPT =
'Codebase and user instructions are shown below. Be sure to adhere to these instructions. ' +
'IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.'
本章将从文件发现、内容处理、到最终注入提示词的完整链路,剖析这套系统的源码实现。
19.1 四级加载优先级
CLAUDE.md 系统采用四级优先级模型,在 claudemd.ts 文件头部的注释中有明确定义(第 1-26 行)。文件按反向优先级顺序加载——最后加载的优先级最高,因为模型对对话末尾的内容“关注度“更高:
flowchart TB
subgraph L1 ["Level 1: Managed Memory(最低优先级,最先加载)"]
M1["/etc/claude-code/CLAUDE.md<br />企业策略推送,适用于所有用户"]
subgraph L2 ["Level 2: User Memory"]
M2["~/.claude/CLAUDE.md<br />~/.claude/rules/*.md<br />用户私有全局指令,适用于所有项目"]
subgraph L3 ["Level 3: Project Memory"]
M3["CLAUDE.md, .claude/CLAUDE.md<br />.claude/rules/*.md<br />从项目根到 CWD 每层遍历<br />提交到 git,团队共享"]
subgraph L4 ["Level 4: Local Memory(最高优先级,最后加载)"]
M4["CLAUDE.local.md<br />已 gitignore,仅本地生效"]
L1 -->|"被覆盖"| L2 -->|"被覆盖"| L3 -->|"被覆盖"| L4
style L4 fill:#e6f3e6,stroke:#2d862d
style L1 fill:#f3e6e6,stroke:#862d2d
加载实现
getMemoryFiles 函数(第 790-1075 行)实现了完整的加载逻辑。它是一个 memoize 包装的异步函数——在同一进程生命周期内,首次调用后结果被缓存:
第一步:Managed Memory(第 803-823 行)
// claudemd.ts:804-822
const managedClaudeMd = getMemoryPath('Managed')
result.push(
...(await processMemoryFile(managedClaudeMd, 'Managed', processedPaths, includeExternal)),
)
const managedClaudeRulesDir = getManagedClaudeRulesDir()
result.push(
...(await processMdRules({
rulesDir: managedClaudeRulesDir,
type: 'Managed',
processedPaths,
includeExternal,
conditionalRule: false,
})),
)
Managed Memory 路径通常是 /etc/claude-code/CLAUDE.md——这是企业 IT 部门通过 MDM(Mobile Device Management)推送策略的标准位置。
第二步:User Memory(第 826-847 行)
仅在 userSettings 配置源启用时加载。User Memory 有一个特权:includeExternal 始终为 true(第 833 行),意味着用户级 CLAUDE.md 中的 @include 可以引用项目目录外的文件。
第三步:Project Memory(第 849-920 行)
这是最复杂的一步。代码从 CWD 向上遍历到文件系统根目录,收集沿途每一层的 CLAUDE.md、.claude/CLAUDE.md 和 .claude/rules/*.md:
// claudemd.ts:851-857
const dirs: string[] = []
const originalCwd = getOriginalCwd()
let currentDir = originalCwd
while (currentDir !== parse(currentDir).root) {
dirs.push(currentDir)
currentDir = dirname(currentDir)
}
然后从根目录方向向 CWD 方向处理(第 878 行的 dirs.reverse()),确保离 CWD 更近的文件后加载、优先级更高。
一个有趣的边界情况处理:git worktree(第 859-884 行)。当从 worktree 内运行时(例如 .claude/worktrees/<name>/),向上遍历会同时经过 worktree 根目录和主仓库根目录。两者都包含 CLAUDE.md,导致内容重复加载。代码通过检测 isNestedWorktree 来跳过主仓库目录中的 Project 类型文件——但 CLAUDE.local.md 仍然加载,因为它是 gitignored 的、只存在于主仓库中。
第四步:Local Memory(穿插在 Project 遍历中)
在每个目录层级,CLAUDE.local.md 在 Project 文件之后加载(第 922-933 行),但前提是 localSettings 配置源启用。
附加目录(--add-dir)支持(第 936-977 行):
通过 CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD 环境变量启用后,--add-dir 参数指定的额外目录中的 CLAUDE.md 也会被加载。这些文件被标记为 Project 类型,加载逻辑与标准 Project Memory 完全一致(CLAUDE.md、.claude/CLAUDE.md、.claude/rules/*.md)。注意这里不检查 isSettingSourceEnabled('projectSettings')——因为 --add-dir 是用户的显式操作,SDK 默认的空 settingSources 不应阻止它。
AutoMem 和 TeamMem(第 979-1007 行):
在四级标准 Memory 之后,还会尝试加载两种特殊类型——自动记忆(MEMORY.md)和团队记忆。这些类型有各自的 feature flag 控制,且有独立的截断策略(由 truncateEntrypointContent 处理行数和字节数上限)。
可控的配置源开关
每一级(除 Managed 外)的加载都受 isSettingSourceEnabled() 控制:
userSettings:控制 User MemoryprojectSettings:控制 Project Memory(CLAUDE.md 和 rules)localSettings:控制 Local Memory
SDK 模式下默认将 settingSources 设为空数组,意味着除非显式启用,否则只有 Managed Memory 生效——这是 SDK 使用者最小权限原则的体现。
19.2 @include 指令
CLAUDE.md 支持 @include 语法来引用其他文件,实现模块化的指令组织。
语法格式
@include 使用 @ 前缀加路径的简洁语法(第 19-24 行注释):
| 语法 | 含义 |
|---|---|
@path 或 @./path | 相对于当前文件目录的路径 |
@~/path | 相对于用户 home 目录的路径 |
@/absolute/path | 绝对路径 |
@path#section | 带片段标识符(# 及之后被忽略) |
@path\ with\ spaces | 反斜杠转义空格 |
路径提取
路径提取由 extractIncludePathsFromTokens 函数(第 451-535 行)实现。它接收 marked lexer 预处理过的 token 流,而非原始文本——这确保了以下规则:
- 代码块中的
@被忽略:code和codespan类型的 token 被跳过(第 496-498 行) - HTML 注释中的
@被忽略:html类型 token 中的注释部分被跳过,但注释后的残余文本中的@仍然被处理(第 502-514 行) - 仅处理文本节点:递归进入
tokens和items子结构(第 522-529 行)
路径提取的正则表达式(第 459 行):
// claudemd.ts:459
const includeRegex = /(?:^|\s)@((?:[^\s\\]|\\ )+)/g
这个正则匹配 @ 后的非空白字符序列,同时支持 \ 转义空格。
传递性包含与循环引用防护
processMemoryFile 函数(第 618-685 行)递归处理 @include。两个关键安全机制:
循环引用防护:通过 processedPaths Set 追踪已处理的文件路径(第 629-630 行)。路径在比较前经过 normalizePathForComparison 规范化,处理 Windows 盘符大小写差异(C:\Users vs c:\Users):
// claudemd.ts:629-630
const normalizedPath = normalizePathForComparison(filePath)
if (processedPaths.has(normalizedPath) || depth >= MAX_INCLUDE_DEPTH) {
return []
}
最大深度限制:MAX_INCLUDE_DEPTH = 5(第 537 行),防止过深的嵌套。
外部文件安全:当 @include 指向项目目录外的文件时,默认不加载(第 667-669 行)。只有 User Memory 层级的文件或用户显式批准 hasClaudeMdExternalIncludesApproved 后才允许外部包含。如果发现未批准的外部包含,系统会显示警告(shouldShowClaudeMdExternalIncludesWarning,第 1420-1430 行)。
符号链接处理
每个文件在处理前都通过 safeResolvePath 解析符号链接(第 640-643 行)。如果文件是符号链接,解析后的真实路径也会被添加到 processedPaths——防止通过符号链接绕过循环引用检测。
19.3 frontmatter paths:范围限定
.claude/rules/ 目录中的 .md 文件可以通过 YAML frontmatter 的 paths 字段限定其适用范围——只有当 Claude 操作的文件路径匹配这些 glob 模式时,规则才会被注入上下文。
frontmatter 解析
parseFrontmatterPaths 函数(第 254-279 行)处理 frontmatter 中的 paths 字段:
// claudemd.ts:254-279
// function parseFrontmatterPaths(rawContent: string): {
content: string
paths?: string[]
} {
const { frontmatter, content } = parseFrontmatter(rawContent)
if (!frontmatter.paths) {
return { content }
}
const patterns = splitPathInFrontmatter(frontmatter.paths)
.map(pattern => {
return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern
})
.filter((p: string) => p.length > 0)
if (patterns.length === 0 || patterns.every((p: string) => p === '**')) {
return { content }
}
return { content, paths: patterns }
}
注意 /** 后缀的处理——ignore 库将 path 视为同时匹配路径本身和路径内的所有内容,所以 /** 是冗余的,被自动移除。如果所有模式都是 **(匹配一切),则视为没有 glob 限定。
路径语法
splitPathInFrontmatter 函数(frontmatterParser.ts:189-232)支持复杂的路径语法:
---
paths: src/**/*.ts, tests/**/*.test.ts
---
或 YAML 列表格式:
---
paths:
- src/**/*.ts
- tests/**/*.test.ts
---
花括号展开也被支持——src/*.{ts,tsx} 会展开为 ["src/*.ts", "src/*.tsx"](frontmatterParser.ts:240-266 的 expandBraces 函数)。这个展开器递归处理多层花括号:{a,b}/{c,d} 产生 ["a/c", "a/d", "b/c", "b/d"]。
YAML 解析的容错处理
frontmatter 的 YAML 解析(frontmatterParser.ts:130-175)有两层容错:
- 首次尝试:直接解析原始 frontmatter 文本
- 失败后重试:通过
quoteProblematicValues函数自动引用包含 YAML 特殊字符的值
这个重试机制解决了一个常见问题:glob 模式如 **/*.{ts,tsx} 包含 YAML 的流映射指示符 {},直接解析会失败。quoteProblematicValues(第 85-121 行)会检测简单 key: value 行中的特殊字符(`{}[]*, &#!|>%@``),自动用双引号包裹。已被引号包裹的值会被跳过。
这意味着用户可以直接写 paths: src/**/*.{ts,tsx} 而无需手动加引号——解析器会在第一次 YAML 解析失败后自动加引号重试。
条件规则匹配
条件规则的匹配由 processConditionedMdRules 函数(第 1354-1397 行)执行。它加载规则文件后,使用 ignore() 库(gitignore 兼容的 glob 匹配)对目标文件路径进行过滤:
// claudemd.ts:1370-1396
return conditionedRuleMdFiles.filter(file => {
if (!file.globs || file.globs.length === 0) {
return false
}
const baseDir =
type === 'Project'
? dirname(dirname(rulesDir)) // .claude 的父目录
: getOriginalCwd() // managed/user 规则使用项目根目录
const relativePath = isAbsolute(targetPath)
? relative(baseDir, targetPath)
: targetPath
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
return false
}
return ignore().add(file.globs).ignores(relativePath)
})
关键设计细节:
- Project 规则的 glob 基准目录是包含
.claude目录的那个目录 - Managed/User 规则的 glob 基准目录是
getOriginalCwd()——即项目根目录 - 超出基准目录的路径(
..前缀)被排除——它们不可能匹配基准目录相对的 glob - Windows 跨盘符的
relative()返回绝对路径,同样被排除
无条件规则 vs 条件规则
processMdRules 函数(第 697-788 行)的 conditionalRule 参数控制加载哪类规则:
conditionalRule: false:加载没有pathsfrontmatter 的文件——这些是无条件规则,总是注入上下文conditionalRule: true:加载有pathsfrontmatter 的文件——这些是条件规则,只在匹配时注入
在会话启动时,CWD 到根目录路径上的无条件规则和 managed/user 层的无条件规则都被预加载。条件规则只在 Claude 操作特定文件时按需加载。
19.4 HTML 注释剥离
CLAUDE.md 中的 HTML 注释会在注入上下文前被剥离。这允许维护者在指令文件中留下不想让 Claude 看到的注释。
stripHtmlComments 函数(第 292-301 行)使用 marked lexer 识别块级 HTML 注释:
// claudemd.ts:292-301
// export function stripHtmlComments(content: string): {
content: string
stripped: boolean
} {
if (!content.includes('<!--')) {
return { content, stripped: false }
}
return stripHtmlCommentsFromTokens(new Lexer({ gfm: false }).lex(content))
}
stripHtmlCommentsFromTokens 函数(第 303-334 行)的处理逻辑精确而谨慎:
- 只处理
html类型 token 中以<!--开头且包含-->的注释 - 未闭合的注释(
<!--没有对应的-->)被保留——这防止一个拼写错误导致文件剩余内容被静默吞噬 - 注释后的残留内容被保留——例如
<!-- note --> Use bun会保留Use bun - 行内代码和代码块中的
<!-- -->不受影响——lexer 已经将它们标记为code/codespan类型
一个实现细节值得注意:gfm: false 选项(第 300 行)。这是因为 @include 路径中的 ~ 在 GFM 模式下会被 marked 解析为删除线标记——禁用 GFM 避免了这个冲突。HTML 块检测是 CommonMark 规则,不受 GFM 设置影响。
避免虚假的 contentDiffersFromDisk
parseMemoryFileContent 函数(第 343-399 行)中有一个精巧的优化:只有当文件确实包含 <!-- 时才通过 token 重建内容(第 370-374 行)。这不仅是性能考量——marked 在 lexing 过程中会规范化 \r\n 为 \n,如果对一个 CRLF 文件进行不必要的 token 往返,会虚假触发 contentDiffersFromDisk 标志,导致缓存系统认为文件被修改了。
19.5 注入提示词
最终注入格式
getClaudeMds 函数(第 1153-1195 行)将所有加载的 memory files 组装为最终的系统提示词字符串:
// claudemd.ts:1153-1195
export const getClaudeMds = (
memoryFiles: MemoryFileInfo[],
filter?: (type: MemoryType) => boolean,
): string => {
const memories: string[] = []
for (const file of memoryFiles) {
if (filter && !filter(file.type)) continue
if (file.content) {
const description =
file.type === 'Project'
? ' (project instructions, checked into the codebase)'
: file.type === 'Local'
? " (user's private project instructions, not checked in)"
: " (user's private global instructions for all projects)"
memories.push(`Contents of ${file.path}${description}:\n\n${content}`)
}
}
if (memories.length === 0) {
return ''
}
return `${MEMORY_INSTRUCTION_PROMPT}\n\n${memories.join('\n\n')}`
}
每个文件的注入格式是:
Contents of /path/to/CLAUDE.md (类型描述):
[文件内容]
所有文件前置一个统一的指令前缀(MEMORY_INSTRUCTION_PROMPT),明确告知模型:
“Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.”
这个“覆盖“声明不是装饰——它利用了 Claude 模型对 system prompt 中明确指令的高遵从度。通过在提示词中显式声明“这些指令覆盖默认行为“,用户的 CLAUDE.md 内容获得了等同于(甚至高于)内置系统提示词的影响力。
类型描述的作用
每个文件的类型描述并非仅供人类阅读——它帮助模型理解指令的来源和权威性:
| 类型 | 描述 | 语义暗示 |
|---|---|---|
| Project | project instructions, checked into the codebase | 团队共识,应严格遵守 |
| Local | user's private project instructions, not checked in | 个人偏好,适度灵活 |
| User | user's private global instructions for all projects | 用户习惯,跨项目一致 |
| AutoMem | user's auto-memory, persists across conversations | 学习到的知识,供参考 |
| TeamMem | shared team memory, synced across the organization | 组织知识,被 <team-memory-content> 标签包裹 |
19.6 大小预算
40K 字符上限
单个 memory file 的推荐最大尺寸为 40,000 字符(第 93 行):
// claudemd.ts:93
export const MAX_MEMORY_CHARACTER_COUNT = 40000
getLargeMemoryFiles 函数(第 1132-1134 行)用于检测超出此限制的文件:
// claudemd.ts:1132-1134
// export function getLargeMemoryFiles(files: MemoryFileInfo[]): MemoryFileInfo[] {
return files.filter(f => f.content.length > MAX_MEMORY_CHARACTER_COUNT)
}
这个限制不是硬性拦截——它是一个警告阈值。系统会在检测到超大文件时提示用户,但不会阻止加载。实际上限受制于整个系统提示词的 token 预算(参见第12章),过大的 CLAUDE.md 会挤压其他上下文空间。
AutoMem 和 TeamMem 的截断
对于自动记忆和团队记忆类型,有更严格的截断逻辑(第 382-385 行):
// claudemd.ts:382-385
let finalContent = strippedContent
if (type === 'AutoMem' || type === 'TeamMem') {
finalContent = truncateEntrypointContent(strippedContent).content
}
truncateEntrypointContent 来自 memdir/memdir.ts,同时对行数和字节数施加上限——自动记忆可能随使用时间膨胀,需要更积极的截断策略。
19.7 文件变更追踪
contentDiffersFromDisk 标志
MemoryFileInfo 类型(第 229-243 行)包含两个与缓存相关的字段:
// claudemd.ts:229-243
export type MemoryFileInfo = {
path: string
type: MemoryType
content: string
parent?: string
globs?: string[]
contentDiffersFromDisk?: boolean
rawContent?: string
}
当 contentDiffersFromDisk 为 true 时,content 是经过处理的版本(frontmatter 剥离、HTML 注释剥离、截断),rawContent 保存磁盘原始内容。这允许缓存系统记录“文件已被读取“(用于去重和变更检测),同时不强制要求 Edit/Write 工具在操作前重新 Read——因为注入到上下文的是处理后的版本,不完全等于磁盘内容。
缓存失效策略
getMemoryFiles 使用 lodash memoize 缓存(第 790 行)。缓存清除有两种语义:
清除但不触发 Hook(clearMemoryFileCaches,第 1119-1122 行):用于纯粹的缓存正确性场景——worktree 进出、设置同步、/memory 对话框。
清除并触发 InstructionsLoaded Hook(resetGetMemoryFilesCache,第 1124-1130 行):用于指令真正被重新加载到上下文的场景——会话启动、压缩(compaction)。
// claudemd.ts:1124-1130
// export function resetGetMemoryFilesCache(
reason: InstructionsLoadReason = 'session_start',
): void {
nextEagerLoadReason = reason
shouldFireHook = true
clearMemoryFileCaches()
}
shouldFireHook 是一个一次性标志——在 Hook 触发后被设为 false(第 1102-1108 行的 consumeNextEagerLoadReason),防止同一轮加载中重复触发。这个标志的消费不依赖于 Hook 是否实际配置——即使没有 InstructionsLoaded Hook,标志也会被消费,否则后续的 Hook 注册 + 缓存清除会产生虚假的 session_start 触发。
19.8 文件类型支持与安全过滤
允许的文件扩展名
@include 指令只加载文本文件。TEXT_FILE_EXTENSIONS 集合(第 96-227 行)定义了 120+ 种允许的扩展名,涵盖:
- Markdown 和文本:
.md,.txt,.text - 数据格式:
.json,.yaml,.yml,.toml,.xml,.csv - 编程语言:从
.js到.rs、从.py到.go、从.java到.swift - 配置文件:
.env,.ini,.cfg,.conf - 构建文件:
.cmake,.gradle,.sbt
文件扩展名检查在 parseMemoryFileContent 函数(第 343-399 行)中执行:
// claudemd.ts:349-353
const ext = extname(filePath).toLowerCase()
if (ext && !TEXT_FILE_EXTENSIONS.has(ext)) {
logForDebugging(`Skipping non-text file in @include: ${filePath}`)
return { info: null, includePaths: [] }
}
这防止二进制文件(图片、PDF 等)被加载到 memory 中——这些内容不仅无意义,还可能消耗大量 token 预算。
claudeMdExcludes 排除模式
isClaudeMdExcluded 函数(第 547-573 行)支持用户通过 claudeMdExcludes 设置排除特定路径的 CLAUDE.md 文件:
// claudemd.ts:547-573
// function isClaudeMdExcluded(filePath: string, type: MemoryType): boolean {
if (type !== 'User' && type !== 'Project' && type !== 'Local') {
return false // Managed, AutoMem, TeamMem 永远不被排除
}
const patterns = getInitialSettings().claudeMdExcludes
if (!patterns || patterns.length === 0) {
return false
}
// ...picomatch 匹配逻辑
}
排除模式支持 glob 语法,并且处理了 macOS 的符号链接问题——/tmp 在 macOS 上实际指向 /private/tmp,resolveExcludePatterns 函数(第 581-612 行)会解析绝对路径模式中的符号链接前缀,确保两边使用相同的真实路径进行比较。
19.9 用户能做什么:CLAUDE.md 编写最佳实践
基于源码分析,以下是编写 CLAUDE.md 的实用建议:
利用优先级层叠
~/.claude/CLAUDE.md # 个人偏好:代码风格、语言设置
project/CLAUDE.md # 团队约定:技术栈、架构规范
project/.claude/rules/*.md # 细粒度规则:按领域组织
project/CLAUDE.local.md # 本地覆盖:调试配置、个人工具链
Local Memory 优先级最高——如果团队约定使用 4 空格缩进但你偏好 2 空格,在 CLAUDE.local.md 中覆盖即可。
使用 @include 模块化
# CLAUDE.md
@./docs/coding-standards.md
@./docs/api-conventions.md
@~/.claude/snippets/common-patterns.md
注意:@include 的最大深度是 5 层,循环引用会被静默忽略。外部文件(项目目录外的路径)在 Project Memory 层级默认不加载——用户级的 @include 不受此限制。
使用 frontmatter paths 按需加载
---
paths: src/api/**/*.ts, src/api/**/*.test.ts
---
# API 开发规范
- 所有 API 端点必须有对应的集成测试
- 使用 Zod 进行请求/响应验证
- 错误响应遵循 RFC 7807 Problem Details 格式
这个规则只会在 Claude 操作 src/api/ 下的 TypeScript 文件时注入——避免了不相关规则占用宝贵的上下文空间。花括号展开也被支持:src/*.{ts,tsx} 会匹配 .ts 和 .tsx 文件。
使用 HTML 注释隐藏内部笔记
<!-- TODO: 等 API v3 发布后更新这个规范 -->
<!-- 这条规则是因为 gh-12345 的 Bug 临时添加的 -->
所有数据库查询必须使用参数化语句,禁止字符串拼接。
HTML 注释会在注入 Claude 上下文前被剥离。但注意:未闭合的 <!-- 会被保留——这是有意的安全设计。
控制文件大小
单个 CLAUDE.md 的推荐上限是 40,000 字符。如果指令过多,优先使用以下策略:
- 拆分为
.claude/rules/目录中的多个文件——每个文件聚焦一个主题 - 使用 frontmatter paths 按需加载——不相关的规则不占用上下文
- 使用
@include引用外部文档——避免在 CLAUDE.md 中重复信息
理解指令的覆盖语义
CLAUDE.md 的内容不是“建议“——通过 MEMORY_INSTRUCTION_PROMPT 的显式声明,它们被标记为必须遵守的指令。这意味着:
- 写“禁止使用
any类型“比写“尽量避免使用any类型“更有效——模型会严格遵守明确的禁令 - 矛盾的指令(不同层级的 CLAUDE.md 给出相反要求)由最后加载的(最高优先级)胜出——但模型可能会尝试调和,建议避免直接矛盾
- 每个文件的路径和类型描述会被注入上下文——模型能看到指令来自哪里,这影响它的遵从度判断
利用 .claude/rules/ 目录结构
规则目录支持子目录递归——这允许按团队或模块组织规则:
.claude/rules/
frontend/
react-patterns.md
css-conventions.md
backend/
api-design.md
database-rules.md
testing/
unit-test-rules.md
e2e-rules.md
所有 .md 文件都会被加载(无条件规则)或按需匹配(带 paths frontmatter 的条件规则)。符号链接也被支持但会被解析为真实路径——循环引用通过 visitedDirs Set 检测。
19.10 排除机制与规则目录遍历
.claude/rules/ 递归遍历
processMdRules 函数(第 697-788 行)递归遍历 .claude/rules/ 目录及其子目录,加载所有 .md 文件。它处理了几个边界情况:
- 符号链接目录:使用
safeResolvePath解析,并通过visitedDirsSet 进行循环检测(第 712-714 行) - 权限错误:
ENOENT、EACCES、ENOTDIR被静默处理——缺失的目录不是错误(第 734-738 行) - Dirent 优化:非符号链接使用 Dirent 方法判断文件/目录类型,避免额外的
stat调用(第 748-752 行)
InstructionsLoaded Hook 集成
当 memory files 加载完成后,如果配置了 InstructionsLoaded Hook,会为每个加载的文件触发一次(第 1042-1071 行)。Hook 接收的输入包括:
file_path:文件路径memory_type:User/Project/Local/Managedload_reason:session_start/nested_traversal/path_glob_match/include/compactglobs:frontmatter paths 模式(可选)parent_file_path:@include的父文件路径(可选)
这为审计和可观察性提供了完整的指令加载追踪。AutoMem 和 TeamMem 类型被有意排除——它们是独立的记忆系统,不属于“指令“的语义范围。
模式提炼
模式一:分层覆盖配置(Layered Override Configuration)
解决的问题:不同层级的用户(企业管理员、个人用户、团队、本地开发者)需要对同一系统施加不同程度的控制。
代码模板:定义明确的优先级层级(Managed → User → Project → Local),按反向优先级顺序加载(最后加载的优先级最高)。每一层可以覆盖或补充上一层。通过 isSettingSourceEnabled() 开关控制各层是否生效。
前置条件:系统使用的 LLM 对消息末尾内容有更高关注度(recency bias)。
模式二:显式覆盖声明(Explicit Override Declaration)
解决的问题:模型可能忽略用户配置,按默认行为输出。
代码模板:在注入用户指令前,添加明确的元指令——“These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.”——利用模型对显式指令的高遵从度。
前置条件:指令注入点位于系统提示词或高权限消息中。
模式三:按需条件加载(Conditional On-Demand Loading)
解决的问题:上下文窗口有限,不相关的规则浪费 token 预算。
代码模板:通过 frontmatter 的 paths 字段声明规则的适用范围(glob 模式)。启动时加载无条件规则,条件规则仅在 Agent 操作匹配路径的文件时按需注入。使用 ignore() 库进行 gitignore 兼容的 glob 匹配。
前置条件:可以预先确定规则与文件路径的关联关系。
小结
CLAUDE.md 系统的核心设计理念是分层覆盖:从企业策略到个人偏好,每一层都可以被下一层覆盖或补充。这种架构与 CSS 的层叠机制、git 的 .gitignore 继承、以及 npm 的 .npmrc 层级有异曲同工之处——都是在“全局默认“和“局部定制“之间找到平衡。
几个值得 AI Agent 构建者借鉴的设计选择:
- 显式覆盖声明:
MEMORY_INSTRUCTION_PROMPT告诉模型“这些指令覆盖默认行为“——不依赖模型自行判断优先级 - 按需加载:frontmatter paths 使得规则只在相关时才占用上下文——在 200K token 的竞技场中,每个 token 都是稀缺资源
- 安全边界明确:外部文件包含需要显式批准,二进制文件被过滤,HTML 注释剥离只处理已闭合的注释
- 缓存语义分离:
clearMemoryFileCachesvsresetGetMemoryFilesCache的区分,防止缓存失效时产生副作用
第20章:Agent 集群与多 Agent 编排
为什么需要多 Agent
单个 Agent Loop 的上下文窗口是有限资源。当任务规模超过单次对话所能承载的信息量——例如“调查这个 bug 的根因、修复它、跑测试、写 PR“——单 Agent 要么被迫在上下文中塞满中间结果,要么不断做压缩丢失细节。更本质的问题是:单 Agent 无法并行,而软件工程任务天然适合分治。
Claude Code 提供了三种递进的多 Agent 模式,从轻量到重量分别是:子 Agent(Subagent)、Fork 模式 和 协调者模式(Coordinator Mode)。它们共享同一个入口——AgentTool,但在上下文继承、执行模型和生命周期管理上有根本差异。本章将逐层解剖这三种模式,以及围绕它们构建的队友系统(Agent Swarms)和验证 Agent。
20.1 AgentTool:统一的 Agent 派生入口
所有 Agent 派生都通过同一个工具完成。AgentTool 在 tools/AgentTool/AgentTool.tsx 中定义,它的 name 是 'Agent'(第 226 行),别名为旧的 'Task'(第 228 行)。
输入 Schema 的动态组合
AgentTool 的输入 Schema 不是静态的——它根据 Feature Flag 和运行时条件动态组合:
// tools/AgentTool/AgentTool.tsx:82-88
const baseInputSchema = lazySchema(() => z.object({
description: z.string().describe('A short (3-5 word) description of the task'),
prompt: z.string().describe('The task for the agent to perform'),
subagent_type: z.string().optional(),
model: z.enum(['sonnet', 'opus', 'haiku']).optional(),
run_in_background: z.boolean().optional()
}));
基础 Schema 包含五个字段。当多 Agent 特性(Agent Swarms)启用时,还会合并 name、team_name、mode 字段(第 93-97 行);isolation 字段支持 'worktree'(所有构建)或 'remote'(内部构建);当后台任务被禁用或 Fork 模式启用时,run_in_background 字段会被 .omit() 移除(第 122-124 行)。
这种 Schema 动态组合有一个重要的设计意图:模型看到的参数列表精确反映它当前可以使用的能力。当 Fork 模式开启时,模型不会看到 run_in_background,因为 Fork 模式下所有 Agent 都自动后台化(第 557 行),模型无需也不应显式控制。
AsyncLocalStorage 上下文隔离
当多个 Agent 在同一进程中并发运行时(例如用户按 Ctrl+B 将一个 Agent 放入后台后立即启动另一个),如何隔离它们的身份信息?答案是 AsyncLocalStorage。
// utils/agentContext.ts:24
import { AsyncLocalStorage } from 'async_hooks'
// utils/agentContext.ts:93
const agentContextStorage = new AsyncLocalStorage<AgentContext>()
// utils/agentContext.ts:108-109
// export function runWithAgentContext<T>(context: AgentContext, fn: () => T): T {
return agentContextStorage.run(context, fn)
}
源码注释(agentContext.ts 第 17-21 行)直接解释了为什么不用 AppState:
When agents are backgrounded (ctrl+b), multiple agents can run concurrently in the same process. AppState is a single shared state that would be overwritten, causing Agent A’s events to incorrectly use Agent B’s context. AsyncLocalStorage isolates each async execution chain, so concurrent agents don’t interfere with each other.
AgentContext 是一个判别联合类型(discriminated union),通过 agentType 字段区分两种上下文:
| 上下文类型 | agentType 值 | 用途 | 关键字段 |
|---|---|---|---|
SubagentContext | 'subagent' | Agent 工具派生的子 Agent | agentId, subagentName, isBuiltIn |
TeammateAgentContext | 'teammate' | 队友 Agent(Swarm 成员) | agentName, teamName, planModeRequired, isTeamLead |
两种上下文都有 invokingRequestId 字段(第 43-49 行、第 77-83 行),用于追踪是谁派生了这个 Agent。consumeInvokingRequestId() 函数(第 163-178 行)实现了“稀疏边“语义:每次 spawn/resume 只在第一个 API 事件上发出一次 invokingRequestId,之后返回 undefined,避免重复标记。
20.2 三种 Agent 模式
模式一:标准子 Agent
这是最基本的模式。模型在调用 Agent 工具时指定 subagent_type,AgentTool 从已注册的 Agent 定义中查找匹配项,然后启动一个全新的对话。
路由逻辑在 AgentTool.tsx 第 322-356 行:
// tools/AgentTool/AgentTool.tsx:322-323
const effectiveType = subagent_type
?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
当 subagent_type 未指定且 Fork 模式关闭时,默认使用 general-purpose 类型。
内置 Agent 定义在 builtInAgents.ts 中注册(第 45-72 行),包括:
| Agent 类型 | 用途 | 工具限制 | 模型 |
|---|---|---|---|
general-purpose | 通用任务:搜索、分析、多步骤操作 | 所有工具 | 默认 |
verification | 验证实现正确性 | 禁止编辑工具 | 继承 |
Explore | 代码探索 | - | - |
Plan | 规划任务 | - | - |
claude-code-guide | 使用指南 | - | - |
子 Agent 的关键特征是上下文隔离:它从零开始,只看到父 Agent 传入的 prompt。系统提示词也是独立生成的(第 518-534 行)。这意味着子 Agent 不知道父 Agent 的对话历史——它就像“一个刚走进房间的聪明同事“。
模式二:Fork 模式
Fork 模式是一个实验性特性,通过 feature('FORK_SUBAGENT') 构建时门控和运行时条件共同控制:
// tools/AgentTool/forkSubagent.ts:32-39
// export function isForkSubagentEnabled(): boolean {
if (feature('FORK_SUBAGENT')) {
if (isCoordinatorMode()) return false
if (getIsNonInteractiveSession()) return false
return true
}
return false
}
Fork 模式与标准子 Agent 的根本区别在于上下文继承。Fork 子进程继承父 Agent 的完整对话上下文和系统提示词:
// tools/AgentTool/forkSubagent.ts:60-71
export const FORK_AGENT = {
agentType: FORK_SUBAGENT_TYPE,
tools: ['*'],
maxTurns: 200,
model: 'inherit',
permissionMode: 'bubble',
source: 'built-in',
baseDir: 'built-in',
getSystemPrompt: () => '', // 未使用——继承父级的系统提示词
} satisfies BuiltInAgentDefinition
注意 model: 'inherit' 和 getSystemPrompt: () => ''——Fork 子进程使用父 Agent 的模型(保持上下文长度一致)和父 Agent 已渲染的系统提示词(保持字节完全一致以最大化提示词缓存命中)。
提示词缓存共享
Fork 模式的核心价值在于提示词缓存共享。buildForkedMessages() 函数(forkSubagent.ts 第 107-164 行)构造的消息结构确保所有 Fork 子进程产生字节相同的 API 请求前缀:
- 保留父 Agent 完整的 assistant 消息(所有
tool_use块、thinking、text) - 为每个
tool_use块构造相同的占位tool_result(第 142-150 行,使用固定文本'Fork started — processing in background') - 只在最后追加一个 per-child 的指令文本块
[...历史消息, assistant(所有 tool_use 块), user(占位 tool_result..., 指令)]
只有最后一个文本块因 child 不同而不同,最大化缓存命中率。
递归 Fork 防护
Fork 子进程的工具池中保留了 Agent 工具(为了缓存一致性),但在调用时会被拦截(第 332-334 行):
// tools/AgentTool/AgentTool.tsx:332-334
if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}`
|| isInForkChild(toolUseContext.messages)) {
throw new Error('Fork is not available inside a forked worker.');
}
检测机制有两层:主检查通过 querySource(抗压缩——即使消息被 autocompact 重写也不会丢失),备用检查扫描消息中的 <fork-boilerplate> 标签(第 78-89 行)。
模式三:协调者模式(Coordinator Mode)
协调者模式通过环境变量 CLAUDE_CODE_COORDINATOR_MODE 激活:
// coordinator/coordinatorMode.ts:36-41
// export function isCoordinatorMode(): boolean {
if (feature('COORDINATOR_MODE')) {
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
}
return false
}
在这个模式下,主 Agent 变成一个不直接编码的协调者,它的工具集缩减为指挥工具:Agent(派生 Worker)、SendMessage(向 Worker 发送后续指令)、TaskStop(停止 Worker)等。Worker 拥有实际的编码工具。
协调者的系统提示词(coordinatorMode.ts 第 111-368 行)是一份详尽的编排规程,定义了四阶段工作流:
| 阶段 | 执行者 | 目的 |
|---|---|---|
| Research | Worker(并行) | 调查代码库,定位问题 |
| Synthesis | 协调者 | 阅读结果,理解问题,编写实现规格 |
| Implementation | Worker | 按规格修改代码,提交 |
| Verification | Worker | 测试变更是否正确 |
提示词中最强调的原则是**“永远不要委托理解”**(第 256-259 行):
Never write “based on your findings” or “based on the research.” These phrases delegate understanding to the worker instead of doing it yourself.
getCoordinatorUserContext() 函数(第 80-109 行)生成 Worker 工具上下文信息,包括 Worker 可用的工具列表和 MCP 服务器列表。当 Scratchpad 功能启用时,还会告知协调者可以使用一个共享目录进行跨 Worker 的知识持久化(第 104-106 行)。
三种模式对比
graph TB
subgraph 标准子Agent["标准子 Agent"]
SA1["上下文: 全新对话"]
SA2["提示词: Agent 定义自带"]
SA3["执行: 前台/后台"]
SA4["缓存: 无共享"]
SA5["递归: 允许"]
SA6["场景: 独立小任务"]
subgraph Fork模式["Fork 模式"]
FK1["上下文: 完整继承父级"]
FK2["提示词: 继承父级"]
FK3["执行: 强制后台"]
FK4["缓存: 共享父级缓存"]
FK5["递归: 禁止"]
FK6["场景: 需要上下文的并行探索"]
subgraph 协调者模式["协调者模式"]
CO1["上下文: Worker 独立"]
CO2["提示词: 协调者专用"]
CO3["执行: 强制后台"]
CO4["缓存: 无共享"]
CO5["递归: Worker 不可再派生"]
CO6["场景: 复杂多步骤项目"]
AgentTool["AgentTool 统一入口"] --> 标准子Agent
AgentTool --> Fork模式
AgentTool --> 协调者模式
style AgentTool fill:#f9f,stroke:#333,stroke-width:2px
| 维度 | 标准子 Agent | Fork 模式 | 协调者模式 |
|---|---|---|---|
| 上下文继承 | 无(全新对话) | 完整继承 | 无(Worker 独立) |
| 系统提示词 | Agent 定义自带 | 继承父级 | 协调者专用提示词 |
| 模型选择 | 可覆盖 | 继承父级 | 不可覆盖 |
| 执行方式 | 前台/后台 | 强制后台 | 强制后台 |
| 缓存共享 | 无 | 共享父级缓存 | 无 |
| 工具池 | 独立组装 | 继承父级 | Worker 独立组装 |
| 递归派生 | 允许 | 禁止 | Worker 不可再派生 |
| 门控方式 | 始终可用 | 构建+运行时 | 构建+环境变量 |
| 适用场景 | 独立小任务 | 需要上下文的并行探索 | 复杂多步骤项目 |
20.3 队友 Agent(Agent Swarms)
队友系统是 Agent 编排的另一个维度。与子 Agent 的“父派生子“模型不同,队友系统创建一个平面结构的团队,团队中的 Agent 通过消息传递协作。
TeamCreateTool:团队创建
TeamCreateTool(tools/TeamCreateTool/TeamCreateTool.ts)用于创建新团队:
// tools/TeamCreateTool/TeamCreateTool.ts:37-49
const inputSchema = lazySchema(() =>
z.strictObject({
team_name: z.string().describe('Name for the new team to create.'),
description: z.string().optional(),
agent_type: z.string().optional()
.describe('Type/role of the team lead'),
}),
)
团队信息持久化到 TeamFile 中,包含团队名称、成员列表、Leader 信息等。团队名称需要唯一——如果冲突则自动生成一个 word slug(第 64-72 行)。
TeammateAgentContext:队友上下文
队友使用 TeammateAgentContext 类型(agentContext.ts 第 60-85 行),包含丰富的团队协调信息:
// utils/agentContext.ts:60-85
export type TeammateAgentContext = {
agentId: string // 完整 ID,如 "researcher@my-team"
agentName: string // 显示名称,如 "researcher"
teamName: string // 所属团队
agentColor?: string // UI 颜色
planModeRequired: boolean // 是否需要计划审批
parentSessionId: string // Leader 的会话 ID
isTeamLead: boolean // 是否是 Leader
agentType: 'teammate'
}
队友的 ID 格式是 name@team-name,这种格式使得在日志和通信中可以一眼看出 Agent 的身份和归属。
平面结构约束
队友系统有一个重要的架构约束:队友不能派生其他队友(第 272-274 行):
// tools/AgentTool/AgentTool.tsx:272-274
if (isTeammate() && teamName && name) {
throw new Error('Teammates cannot spawn other teammates — the team roster is flat.');
}
这是刻意的设计——团队名册是一个扁平数组,嵌套的队友会导致名册中出现没有来源信息的条目,混淆 Leader 的协调逻辑。
同样,进程内队友(in-process teammate)不能派生后台 Agent(第 278-280 行),因为它们的生命周期绑定在 Leader 的进程上。
20.4 验证 Agent
验证 Agent 是内置 Agent 中设计最精致的一个。它的系统提示词(built-in/verificationAgent.ts 第 10-128 行)长达约 120 行,堪称一份“如何进行真正验证“的工程规范。
核心设计原则
验证 Agent 有两个明确声明的失败模式(第 12-13 行):
- 验证回避(Verification avoidance):面对检查时找理由不执行——阅读代码、叙述测试步骤、写 “PASS”,然后继续
- 被前 80% 迷惑:看到漂亮的 UI 或通过的测试套件就倾向于通过,没注意到一半按钮不起作用
严格的只读约束
验证 Agent 被明确禁止修改项目:
// built-in/verificationAgent.ts:139-145
disallowedTools: [
AGENT_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_WRITE_TOOL_NAME,
NOTEBOOK_EDIT_TOOL_NAME,
],
但它可以在临时目录(/tmp)写入临时测试脚本——这个权限足够编写临时的测试工具但不会污染项目。
VERDICT 判定
验证 Agent 的输出必须以严格格式的判定结尾(第 117-128 行):
| 判定 | 含义 |
|---|---|
VERDICT: PASS | 验证通过 |
VERDICT: FAIL | 发现问题,包含具体错误输出和复现步骤 |
VERDICT: PARTIAL | 环境限制导致无法完全验证(非“不确定“) |
PARTIAL 仅用于环境限制(没有测试框架、工具不可用、服务器无法启动),不能用于“我不确定这是不是 bug“。
对抗性探测
验证 Agent 的提示词要求至少运行一个对抗性探测(第 63-69 行):并发请求、边界值、幂等性、孤儿操作等。如果所有检查都只是“返回 200“或“测试套件通过“,说明只确认了快乐路径,不算真正的验证。
20.5 Agent 间通信
SendMessageTool:消息路由
SendMessageTool(tools/SendMessageTool/SendMessageTool.ts)是 Agent 间通信的核心。它的 to 字段支持多种寻址方式:
// tools/SendMessageTool/SendMessageTool.ts:69-76
to: z.string().describe(
feature('UDS_INBOX')
? 'Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, or "bridge:<session-id>" for a Remote Control peer'
: 'Recipient: teammate name, or "*" for broadcast to all teammates',
),
消息类型是一个判别联合(第 47-65 行),支持:
- 纯文本消息
- 关闭请求(
shutdown_request) - 关闭响应(
shutdown_response) - 计划审批响应(
plan_approval_response)
广播机制
当 to 为 "*" 时触发广播(handleBroadcast,第 191-266 行):遍历团队文件中的所有成员(排除发送者自己),逐一写入邮箱。广播结果包含接收者列表,方便协调者跟踪。
邮箱系统
消息实际通过 writeToMailbox() 函数写入文件系统邮箱。每条消息包含:发送者名称、文本内容、摘要、时间戳和发送者颜色。这种基于文件系统的邮箱设计使得跨进程的队友(tmux 模式)可以通过共享文件系统通信。
UDS_INBOX:Unix Domain Socket 扩展
当 UDS_INBOX Feature Flag 启用时,SendMessageTool 的寻址能力扩展到 Unix Domain Socket:"uds:<socket-path>" 可以向同一机器上的其他 Claude Code 实例发送消息,"bridge:<session-id>" 可以向 Remote Control 对等端发送消息。
这创建了一个超越单一团队边界的通信拓扑:
┌─────────────────────────────────────────────────────────────────┐
│ Agent 间通信架构 │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Team "my-team" │ │
│ │ │ │
│ │ ┌─────────┐ MailBox ┌─────────┐ │
│ │ │ Leader │◄────────────►│Teammate │ │
│ │ │ (lead) │ (文件系统) │ (dev) │ │
│ │ └────┬────┘ └─────────┘ │
│ │ │ │
│ │ │ SendMessage(to: "tester") │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────┐ │
│ │ │Teammate │ │
│ │ │ (tester)│ │
│ │ └─────────┘ │
│ └──────────────────────────────────┘ │
│ │ │
│ │ SendMessage(to: "uds:/tmp/other.sock") │
│ ▼ │
│ ┌──────────────┐ │
│ │ 其他 Claude │ SendMessage(to: "bridge:<session>") │
│ │ Code 实例 │──────────────────────────► Remote Control │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
协调者模式下的 Worker 结果回传
在协调者模式中,Worker 完成任务后的结果以 <task-notification> XML 格式作为用户角色消息注入协调者的对话中(coordinatorMode.ts 第 148-159 行):
<task-notification>
<task-id>{agentId}</task-id>
<status>completed|failed|killed</status>
<summary>{人类可读的状态摘要}</summary>
<result>{Agent 的最终文本响应}</result>
<usage>
<total_tokens>N</total_tokens>
<tool_uses>N</tool_uses>
<duration_ms>N</duration_ms>
</usage>
</task-notification>
协调者提示词明确要求(第 144 行):“它们看起来像用户消息但不是。通过 <task-notification> 开始标签区分它们。“这种设计避免了协调者把 Worker 结果当作用户输入来回应。
20.6 异步 Agent 的生命周期
当 shouldRunAsync 为 true 时(由 run_in_background、background: true、协调者模式、Fork 模式、助手模式等任一条件触发,第 567 行),Agent 进入异步生命周期:
- 注册:
registerAsyncAgent()创建后台任务记录,分配agentId - 执行:在
runWithAgentContext()包裹下运行runAgent() - 进度上报:通过
updateAsyncAgentProgress()和onProgress回调更新状态 - 完成/失败:调用
completeAsyncAgent()或failAsyncAgent() - 通知:
enqueueAgentNotification()将结果注入调用者的消息流
关键的设计选择:后台 Agent 不与父 Agent 的 abortController 关联(第 694-696 行注释)——当用户按 ESC 取消主线程时,后台 Agent 继续运行。它们只能通过 chat:killAgents 显式终止。
Worktree 隔离
当 isolation: 'worktree' 时,Agent 在临时 git worktree 中运行(第 590-593 行):
const slug = `agent-${earlyAgentId.slice(0, 8)}`;
worktreeInfo = await createAgentWorktree(slug);
Agent 完成后,如果 worktree 没有变更(与创建时的 HEAD commit 比较),则自动清理(第 666-679 行)。有变更的 worktree 会被保留,其路径和分支名返回给调用者。
20.7 工具池的独立组装
每个 Worker 的工具池是独立组装的,不继承父 Agent 的限制(第 573-577 行):
// tools/AgentTool/AgentTool.tsx:573-577
const workerPermissionContext = {
...appState.toolPermissionContext,
mode: selectedAgent.permissionMode ?? 'acceptEdits'
};
const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools);
唯一的例外是 Fork 模式:Fork 子进程使用父级的精确工具数组(useExactTools: true,第 631-633 行),因为工具定义的差异会破坏提示词缓存。
MCP 服务器的等待与验证
Agent 定义可以声明所需的 MCP 服务器(requiredMcpServers)。AgentTool 在启动前会检查这些服务器是否可用(第 369-409 行),并在 MCP 服务器仍在连接中时等待最多 30 秒(第 379-391 行),带有提前退出逻辑——如果某个必需的服务器已经失败,就不再等待其他服务器了。
20.8 设计洞察
为什么三种模式而非一种? 这源于一个根本性的权衡:上下文共享 vs. 执行隔离。标准子 Agent 提供最大隔离但没有上下文;Fork 提供最大上下文共享但不能递归;协调者模式在中间——Worker 隔离但协调者保持全局视图。不存在一种通用方案能同时满足所有场景。
平面团队结构的设计哲学。禁止队友派生队友不仅是技术约束——它反映了一种组织原则:在一个有效的团队中,协调应该集中在一个节点(Leader),而不是形成任意深度的委托链。这与软件工程中“避免过深的调用栈“的直觉一致。
验证 Agent 的“反模式清单“设计。验证 Agent 的提示词显式列出了 LLM 作为验证者时的典型失败模式(第 53-61 行),并要求它“认出自己的合理化借口“。这种 meta-cognition 提示是对 LLM 固有弱点的工程补偿——不是期望模型不犯这些错误,而是让模型知道它倾向于犯这些错误。
用户能做什么
利用多 Agent 模式提升工作效率:
-
善用子 Agent 做独立调查。当需要在不干扰主对话上下文的情况下完成一个独立子任务(如“查找这个 API 的所有调用方“),让模型启动一个子 Agent 是最佳选择。子 Agent 有自己的上下文窗口,完成后返回摘要,不会污染主对话。
-
理解协调者模式的四阶段流程。如果你的组织启用了协调者模式(
CLAUDE_CODE_COORDINATOR_MODE=true),了解其 Research → Synthesis → Implementation → Verification 的四阶段流程有助于更好地与之协作。特别注意:协调者不会直接编码,它只负责理解问题和分配任务。 -
利用验证 Agent 进行质量把关。当完成复杂变更后,可以显式要求运行验证 Agent。它的只读约束和对抗性探测设计使其成为可靠的“第二双眼睛“。
-
注意 Agent 间通信的寻址方式。如果使用队友系统,
SendMessageTool支持名称寻址("tester")、广播("*")和 UDS 寻址("uds:<path>")。理解这些寻址方式有助于设计更高效的多 Agent 工作流。 -
Worktree 隔离保护主分支。当 Agent 使用
isolation: 'worktree'时,所有修改都在临时 git worktree 中进行。无变更的 worktree 会自动清理,有变更的则保留分支——这意味着你可以放心让 Agent 尝试实验性修改。
第21章:Effort、Fast Mode 与 Thinking
为什么需要分层的推理控制
模型的推理深度不是“越多越好“。更深的思考意味着更高的延迟、更多的 token 消耗和更低的吞吐量。对于“把变量名从 foo 改成 bar“这样的任务,让 Opus 4.6 做 10 秒的深度推理是浪费;对于“重构整个认证模块的错误处理”,快速浅层响应则会产出低质量代码。
Claude Code 通过三个独立但协作的机制控制推理深度:Effort(推理努力等级)、Fast Mode(加速模式)和 Thinking(思维链配置)。它们各自有不同的配置来源、优先级规则和模型兼容性要求,共同决定每次 API 调用的推理行为。本章将逐一解剖这三个机制,并分析它们如何在运行时协同工作。
21.1 Effort:推理努力等级
Effort 是 Claude API 的原生参数,控制模型在生成响应前投入多少“思考时间“。Claude Code 在此基础上构建了一套多层优先级链。
四个等级
// utils/effort.ts:13-18
export const EFFORT_LEVELS = [
'low',
'medium',
'high',
'max',
] as const satisfies readonly EffortLevel[]
| 等级 | 描述(第 224-235 行) | 限制 |
|---|---|---|
low | 快速、直接的实现,最小开销 | - |
medium | 平衡的方式,标准实现和测试 | - |
high | 全面的实现,包含广泛测试和文档 | - |
max | 最深推理能力 | 仅 Opus 4.6 |
max 等级的模型限制在 modelSupportsMaxEffort() 中硬编码(第 53-65 行):只有 opus-4-6 和内部模型支持。当其他模型尝试使用 max 时会被降级为 high(第 164 行)。
优先级链
Effort 的实际值由一个清晰的三层优先级链决定:
// utils/effort.ts:152-167
// export function resolveAppliedEffort(
model: string,
appStateEffortValue: EffortValue | undefined,
): EffortValue | undefined {
const envOverride = getEffortEnvOverride()
if (envOverride === null) {
return undefined // 环境变量设为 'unset'/'auto':不发送 effort 参数
}
const resolved =
envOverride ?? appStateEffortValue ?? getDefaultEffortForModel(model)
if (resolved === 'max' && !modelSupportsMaxEffort(model)) {
return 'high'
}
return resolved
}
优先级从高到低:
flowchart TD
A["环境变量 CLAUDE_CODE_EFFORT_LEVEL\n(最高优先级)"] --> B{已设置?}
B -->|"'unset'/'auto'"| C["不发送 effort 参数"]
B -->|"有效值"| G["使用环境变量值"]
B -->|未设置| D["AppState.effortValue\n(/effort 命令或 UI 切换)"]
D --> E{已设置?}
E -->|是| G2["使用 AppState 值"]
E -->|否| F["getDefaultEffortForModel(model)\nOpus 4.6 Pro → medium\nUltrathink 启用 → medium\n其他 → undefined (API 默认 high)"]
F --> H["模型默认值"]
G --> I{"值为 max 且\n模型不支持?"}
G2 --> I
H --> I
I -->|是| J["降级为 high"]
I -->|否| K["保持原值"]
J --> L["发送到 API"]
K --> L
模型默认值的差异化
getDefaultEffortForModel() 函数(第 279-329 行)展示了精细的默认值策略:
// utils/effort.ts:309-319
if (model.toLowerCase().includes('opus-4-6')) {
if (isProSubscriber()) {
return 'medium'
}
if (
getOpusDefaultEffortConfig().enabled &&
(isMaxSubscriber() || isTeamSubscriber())
) {
return 'medium'
}
}
Opus 4.6 的 Pro 订阅者默认 medium(而非 high)——这是一个经过 A/B 测试的决策(通过 GrowthBook 的 tengu_grey_step2 控制,第 268-276 行)。源码注释(第 307-308 行)带有明确警告:
IMPORTANT: Do not change the default effort level without notifying the model launch DRI and research. Default effort is a sensitive setting that can greatly affect model quality and bashing.
当 Ultrathink 特性启用时,所有支持 effort 的模型默认也降为 medium(第 322-324 行),因为 Ultrathink 会在用户输入包含关键词时将 effort 提升到 high——medium 成为可被动态提升的基线。
数值型 Effort(内部专用)
除了四个字符串等级,内部用户还可以使用数值型 effort(第 198-216 行):
// utils/effort.ts:202-216
// export function convertEffortValueToLevel(value: EffortValue): EffortLevel {
if (typeof value === 'string') {
return isEffortLevel(value) ? value : 'high'
}
if (process.env.USER_TYPE === 'ant' && typeof value === 'number') {
if (value <= 50) return 'low'
if (value <= 85) return 'medium'
if (value <= 100) return 'high'
return 'max'
}
return 'high'
}
数值型 effort 不可持久化到设置文件(toPersistableEffort() 函数,第 95-105 行,会过滤掉所有数值),它只存在于会话运行时——这是一个实验机制,不应意外泄漏到用户的 settings.json 中。
Effort 持久化的边界
toPersistableEffort() 的过滤逻辑揭示了一个微妙的设计:max 等级对外部用户也不持久化(第 101 行),只在当前会话有效。这意味着用户通过 /effort max 设置的 max 等级在下次启动时会恢复为模型默认值——这是有意为之,避免用户忘记关闭 max 而长期消耗过多资源。
21.2 Fast Mode:Opus 4.6 加速
Fast Mode(内部代号 “Penguin Mode”)是一种让 Sonnet 级模型使用 Opus 4.6 作为“加速器“的模式——当用户的主模型不是 Opus 时,特定请求可以被路由到 Opus 4.6 以获得更高质量的响应。
可用性检查链
Fast Mode 的可用性经过层层检查:
// utils/fastMode.ts:38-40
// export function isFastModeEnabled(): boolean {
return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FAST_MODE)
}
顶层开关之后,getFastModeUnavailableReason() 检查以下条件(第 72-140 行):
- Statsig 远程关闭(
tengu_penguins_off):最高优先级的远程开关 - 非原生二进制:可选检查,通过 GrowthBook 控制
- SDK 模式:默认在 Agent SDK 中不可用,除非显式 opt-in
- 非一方提供商:Bedrock/Vertex/Foundry 不支持
- 组织级禁用:API 返回的组织状态
模型绑定
Fast Mode 硬绑定到 Opus 4.6:
// utils/fastMode.ts:143-147
export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.6'
// export function getFastModeModel(): string {
return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '')
}
isFastModeSupportedByModel() 也只对 Opus 4.6 返回 true(第 167-176 行)——这意味着如果用户已经在使用 Opus 4.6 作为主模型,Fast Mode 就是它本身。
冷却状态机
Fast Mode 的运行时状态是一个精巧的状态机:
// utils/fastMode.ts:183-186
export type FastModeRuntimeState =
| { status: 'active' }
| { status: 'cooldown'; resetAt: number; reason: CooldownReason }
┌─────────────────────────────────────────────────────────────┐
│ Fast Mode 冷却状态机 │
│ │
│ ┌──────────┐ triggerFastModeCooldown() ┌──────────┐ │
│ │ │──────────────────────────────►│ │ │
│ │ active │ │ cooldown │ │
│ │ │◄──────────────────────────────│ │ │
│ └──────────┘ Date.now() >= resetAt └──────────┘ │
│ │ │ │
│ │ handleFastModeRejectedByAPI() │ │
│ │ handleFastModeOverageRejection() │ │
│ ▼ │ │
│ ┌──────────┐ │ │
│ │ disabled │ (orgStatus = {status:'disabled'})│ │
│ │ (永久) │◄──────────────────────────────────┘ │
│ └──────────┘ (如果 reason 非 out_of_credits) │
│ │
│ 触发原因 (CooldownReason): │
│ • 'rate_limit' — API 429 速率限制 │
│ • 'overloaded' — 服务过载 │
│ │
│ 冷却过期自动恢复(检查时机:getFastModeRuntimeState()) │
└─────────────────────────────────────────────────────────────┘
冷却触发时(triggerFastModeCooldown(),第 214-233 行),系统记录冷却结束时间戳和原因,发送分析事件,并通过信号(Signal)通知 UI:
// utils/fastMode.ts:214-233
// export function triggerFastModeCooldown(
resetTimestamp: number,
reason: CooldownReason,
): void {
runtimeState = { status: 'cooldown', resetAt: resetTimestamp, reason }
hasLoggedCooldownExpiry = false
logEvent('tengu_fast_mode_fallback_triggered', {
cooldown_duration_ms: cooldownDurationMs,
cooldown_reason: reason,
})
cooldownTriggered.emit(resetTimestamp, reason)
}
冷却过期的检测是惰性的——不使用定时器,而是在每次调用 getFastModeRuntimeState() 时检查(第 199-212 行)。这避免了不必要的定时器资源消耗,冷却到期的 cooldownExpired 信号只在下次查询状态时才触发。
组织级状态预取
组织是否允许 Fast Mode 通过 API 预取确定。prefetchFastModeStatus() 函数(第 407-532 行)在启动时调用 /api/claude_code_penguin_mode 端点,结果缓存在 orgStatus 变量中。
预取有节流保护(30 秒最小间隔,第 383-384 行)和防抖机制(同一时刻只允许一个 inflight 请求,第 416-420 行)。认证失败时自动尝试 OAuth token 刷新(第 466-479 行)。
当网络请求失败时,内部用户默认允许(不阻塞内部开发),外部用户则回退到磁盘缓存的 penguinModeOrgEnabled 值(第 511-520 行)。
三态输出
getFastModeState() 函数将所有状态压缩为三个用户可见的状态:
// utils/fastMode.ts:319-335
// export function getFastModeState(
model: ModelSetting,
fastModeUserEnabled: boolean | undefined,
): 'off' | 'cooldown' | 'on' {
const enabled =
isFastModeEnabled() &&
isFastModeAvailable() &&
!!fastModeUserEnabled &&
isFastModeSupportedByModel(model)
if (enabled && isFastModeCooldown()) {
return 'cooldown'
}
if (enabled) {
return 'on'
}
return 'off'
}
这个三态在 UI 中映射为不同的视觉反馈——on 显示加速图标,cooldown 显示临时降级提示,off 则不显示。
21.3 Thinking 配置
Thinking(思维链/extended thinking)控制模型是否以及如何输出推理过程。
三种模式
// utils/thinking.ts:10-13
export type ThinkingConfig =
| { type: 'adaptive' }
| { type: 'enabled'; budgetTokens: number }
| { type: 'disabled' }
| 模式 | API 表现 | 适用条件 |
|---|---|---|
adaptive | 模型自行决定是否思考和思考多少 | Opus 4.6、Sonnet 4.6 等新模型 |
enabled | 固定 token 预算的思维链 | 不支持 adaptive 的旧 Claude 4 模型 |
disabled | 不输出思维链 | API key 验证等低开销调用 |
模型兼容性分层
三个独立的能力检测函数处理不同级别的 Thinking 支持:
modelSupportsThinking()(第 90-110 行):检测模型是否支持思维链。
// utils/thinking.ts:105-109
if (provider === 'foundry' || provider === 'firstParty') {
return !canonical.includes('claude-3-') // 所有 Claude 4+ 支持
}
return canonical.includes('sonnet-4') || canonical.includes('opus-4')
一方和 Foundry 提供商:Claude 3 以外的所有模型都支持。三方提供商(Bedrock/Vertex):只有 Sonnet 4+ 和 Opus 4+ 支持——这反映了三方部署的模型可用性差异。
modelSupportsAdaptiveThinking()(第 113-144 行):检测模型是否支持 adaptive 模式。
// utils/thinking.ts:119-123
if (canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) {
return true
}
只有 4.6 版本的模型明确支持 adaptive。对于未知模型字符串,一方和 Foundry 默认为 true(第 143 行),三方默认为 false——源码注释解释了原因(第 136-141 行):
Newer models (4.6+) are all trained on adaptive thinking and MUST have it enabled for model testing. DO NOT default to false for first party, otherwise we may silently degrade model quality.
shouldEnableThinkingByDefault()(第 146-162 行):决定 Thinking 是否默认启用。
// utils/thinking.ts:146-162
// export function shouldEnableThinkingByDefault(): boolean {
if (process.env.MAX_THINKING_TOKENS) {
return parseInt(process.env.MAX_THINKING_TOKENS, 10) > 0
}
const { settings } = getSettingsWithErrors()
if (settings.alwaysThinkingEnabled === false) {
return false
}
return true
}
优先级:MAX_THINKING_TOKENS 环境变量 > settings 中的 alwaysThinkingEnabled > 默认启用。
三模式对比
┌─────────────────────────────────────────────────────────────────────┐
│ Thinking 三模式对比 │
├──────────────┬────────────────┬──────────────���───┬─────────────────┤
│ │ adaptive │ enabled │ disabled │
├──────────────┼────────────────┼──────────────────┼─────────────────┤
│ 思考预算 │ 模型自行决定 │ 固定 budgetTokens│ 不思考 │
│ API 参数 │ {type:'adaptive│ {type:'enabled', │ 不传 thinking │
│ │ '} │ budget_tokens:N}│ 参数或禁用 │
│ 支持模型 │ Opus/Sonnet 4.6│ Claude 4 全系列 │ 所有模型 │
│ 默认状态 │ 4.6 模型首选 │ 旧 4 系列回退 │ 显式禁用时 │
│ 与 Effort │ Effort 控制 │ budget 控制 │ 无关 │
│ 的交互 │ 思考深度 │ 思考上限 │ │
│ 适用场景 │ 大多数对话 │ 需要精确控制 │ API 验证、 │
│ │ │ 思考预算时 │ 工具 Schema 等 │
└──────────────┴────────────────┴──────────────────┴─────────────────┘
API 层的实际应用
在 services/api/claude.ts(第 1602-1622 行)中,ThinkingConfig 被转换为实际的 API 参数:
// services/api/claude.ts:1604-1622(简化)
if (hasThinking && modelSupportsThinking(options.model)) {
if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING)
&& modelSupportsAdaptiveThinking(options.model)) {
thinking = { type: 'adaptive' }
} else {
let thinkingBudget = getMaxThinkingTokensForModel(options.model)
if (thinkingConfig.type === 'enabled' && thinkingConfig.budgetTokens !== undefined) {
thinkingBudget = thinkingConfig.budgetTokens
}
thinking = { type: 'enabled', budget_tokens: thinkingBudget }
}
}
决策逻辑是:优先 adaptive → 不支持 adaptive 时用固定预算 → 用户指定预算覆盖默认值。环境变量 CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING 是最后的逃生出口,允许强制回退到固定预算模式。
21.4 Ultrathink:关键词触发的 Effort 提升
Ultrathink 是一个巧妙的交互设计:当用户在消息中包含 ultrathink 关键词时,自动将 Effort 从 medium 提升到 high。
门控机制
Ultrathink 通过双重门控:
// utils/thinking.ts:19-24
// export function isUltrathinkEnabled(): boolean {
if (!feature('ULTRATHINK')) {
return false
}
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_turtle_carbon', true)
}
构建时 Feature Flag(ULTRATHINK)控制代码是否包含在构建产物中,GrowthBook 运行时 Flag(tengu_turtle_carbon)控制是否对当前用户启用。
关键词检测
// utils/thinking.ts:29-31
// export function hasUltrathinkKeyword(text: string): boolean {
return /\bultrathink\b/i.test(text)
}
检测使用词边界匹配(\b),大小写不敏感。findThinkingTriggerPositions() 函数(第 36-58 行)进一步返回每个匹配的位置信息,供 UI 高亮显示。
注意源码中的一个细节(第 42-44 行注释):每次调用都创建一个新的正则表达式字面量而不是复用共享实例,因为 String.prototype.matchAll 会从源正则的 lastIndex 复制状态——如果与 hasUltrathinkKeyword 的 .test() 共享实例,lastIndex 会在两次调用间泄漏。
附件注入
Ultrathink 的 Effort 提升通过附件系统实现(utils/attachments.ts 第 1446-1452 行):
// utils/attachments.ts:1446-1452
// function getUltrathinkEffortAttachment(input: string | null): Attachment[] {
if (!isUltrathinkEnabled() || !input || !hasUltrathinkKeyword(input)) {
return []
}
logEvent('tengu_ultrathink', {})
return [{ type: 'ultrathink_effort', level: 'high' }]
}
这个附件被转换为系统提醒消息注入对话中(utils/messages.ts 第 4170-4175 行):
case 'ultrathink_effort': {
return wrapMessagesInSystemReminder([
createUserMessage({
content: `The user has requested reasoning effort level: ${attachment.level}. Apply this to the current turn.`,
isMeta: true,
}),
])
}
Ultrathink 不直接修改 resolveAppliedEffort() 的输出——它通过消息系统告知模型“用户请求了更高的推理努力“,让模型在 adaptive thinking 模式下自行调整。这是一个纯提示词级别的干预,不改变 API 参数。
与默认 Effort 的协同
Ultrathink 的设计与 Opus 4.6 的默认 medium effort 形成完美配合:
- 默认 effort 为
medium(快速响应大多数请求) - 用户在需要深度推理时输入
ultrathink - 附件系统注入 effort 提升消息
- 模型在 adaptive thinking 模式下增加推理深度
这种设计的优雅之处在于:用户获得了一个语义化的控制接口——不需要理解 effort 参数的技术细节,只需要在“需要更深入思考“时在消息中写上 ultrathink。
彩虹 UI
Ultrathink 激活时,UI 以彩虹色显示关键词(第 60-86 行):
// utils/thinking.ts:60-68
const RAINBOW_COLORS: Array<keyof Theme> = [
'rainbow_red',
'rainbow_orange',
'rainbow_yellow',
'rainbow_green',
'rainbow_blue',
'rainbow_indigo',
'rainbow_violet',
]
getRainbowColor() 函数根据字符索引循环分配颜色,还有一组 shimmer 变体用于闪烁效果。这种视觉反馈让用户知道 Ultrathink 已被识别和激活。
21.5 三个机制的协同
Effort、Fast Mode 和 Thinking 不是孤立工作的。它们在 API 调用链路上的交互形成一个多层控制面板:
用户输入
│
├─ 包含 "ultrathink"? ──► 注入 ultrathink_effort 附件
│
▼
resolveAppliedEffort(model, appState.effortValue)
│
├─ env CLAUDE_CODE_EFFORT_LEVEL ──► 直接使用
├─ appState.effortValue ──► /effort 命令设置
└─ getDefaultEffortForModel() ──► Opus 4.6 Pro → 'medium'
│
▼
Effort 值 ──► 发送到 API 的 effort 参数
│
▼
Fast Mode 检查
│
├─ getFastModeState() = 'on' ──► 路由到 Opus 4.6
├─ getFastModeState() = 'cooldown' ──► 使用原始模型
└─ getFastModeState() = 'off' ──► 使用原始模型
│
▼
Thinking 配置
│
├─ modelSupportsAdaptiveThinking()? ──► { type: 'adaptive' }
├─ modelSupportsThinking()? ──► { type: 'enabled', budget_tokens: N }
└─ 都不支持 ──► { type: 'disabled' }
│
▼
API 调用: messages.create({
model, effort, thinking, ...
})
关键的交互点:
- Effort + Thinking:当 Effort 为
medium且 Thinking 为adaptive时,模型可能选择较少的推理。当 Ultrathink 将 Effort 提升到high时,adaptive thinking 也会相应增加推理深度。 - Fast Mode + Effort:Fast Mode 改变的是模型(路由到 Opus 4.6),而 Effort 改变的是同一模型的推理深度。两者正交。
- Fast Mode + Thinking:当 Fast Mode 将请求路由到 Opus 4.6 时,该模型支持 adaptive thinking,所以 Thinking 配置自动升级。
21.6 设计洞察
“中等“作为默认值的哲学。Opus 4.6 对 Pro 用户默认 medium effort,而非直觉上的 high,反映了一个深刻的权衡:大多数编程交互不需要最深的推理,而降低默认 effort 可以显著提升吞吐量和降低延迟。Ultrathink 机制则提供了一个零摩擦的升级路径——用户不需要离开对话流去调整设置,只需在句子中加一个词。
惰性状态检查的模式。Fast Mode 冷却的过期检测不使用定时器,而是在每次查询状态时惰性计算(第 199-212 行)。这种模式在 Claude Code 中多次出现——它避免了定时器的资源开销和竞态条件,代价是状态转换的时间精度取决于查询频率。对于 UI 驱动的系统,这个代价几乎为零。
能力检测的三层结构。modelSupportsThinking → modelSupportsAdaptiveThinking → shouldEnableThinkingByDefault 形成了从“能否使用“到“应否启用“的决策链。每一层都考虑了不同的因素(模型能力、提供商差异、用户偏好),且每一层都带有明确的“不要在不通知负责人的情况下修改“的警告注释。这种多层防护反映了推理配置对模型质量的敏感性——一个不经意的默认值变更可能导致整个用户群的体验退化。
持久化的谨慎边界。max effort 不对外部用户持久化、数值型 effort 不持久化、Fast Mode 的 per-session opt-in 选项——这些设计选择都遵循同一原则:高开销的配置不应跨会话泄漏。用户在一次会话中开启 max 是有意识的选择;但如果这个选择被静默地带入下一次会话,它可能成为一个被遗忘的资源消耗。
用户能做什么
调优推理深度以匹配任务复杂度:
-
使用
/effort命令调整推理等级。对于简单的代码修改(重命名变量、添加注释),/effort low能显著减少延迟。对于复杂的架构决策或 bug 调查,/effort high或max(仅 Opus 4.6)能获得更深入的分析。 -
在消息中输入
ultrathink触发深度推理。当你在使用 Opus 4.6 且默认 effort 为medium时,在消息中加入ultrathink关键词即可临时提升到high级别推理——无需离开对话流调整设置。 -
通过环境变量固定 Effort。如果你的团队有统一的推理策略,可以在
.env或启动脚本中设置CLAUDE_CODE_EFFORT_LEVEL=high。设为unset或auto可以完全不发送 effort 参数,让 API 使用服务端默认值。 -
理解 Fast Mode 的冷却机制。当 Fast Mode(Opus 4.6 加速)因速率限制进入冷却时,系统会自动回退到原始模型。冷却是临时的,到期后自动恢复——无需手动干预。
-
注意 Thinking 模式与模型的匹配。Opus 4.6 和 Sonnet 4.6 支持
adaptive思维模式(模型自行决定思考深度),旧版 Claude 4 模型使用固定预算模式。如果需要强制禁用 adaptive thinking,可以设置环境变量CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=true。 -
maxeffort 不会跨会话保留。这是有意设计——避免忘记关闭max而长期消耗过多资源。每次新会话都会恢复为模型默认值。
第22章:技能系统 – 从内置到用户自定义
为什么这很重要
在前面的章节中,我们分析了 Claude Code 的工具系统、权限模型和上下文管理。但有一个关键的扩展层始终穿插在这些系统之间:技能(Skill)系统。
当用户输入 /batch migrate from react to vue 时,Claude Code 不是在执行一个“命令“——它在加载一段精心编写的提示词模板,将其注入上下文窗口,从而让模型按照预定义的流程行动。技能系统的本质是可调用的提示词模板——它将反复验证过的最佳实践编码为 Markdown 文件,通过 Skill 工具注入到对话流中。
这个设计哲学带来了一个深刻的工程含义:技能不是代码逻辑,而是结构化的知识。一个技能文件可以定义它需要哪些工具、使用哪个模型、以什么执行上下文运行,但它的核心始终是一段 Markdown 文本——由 LLM 解释并执行。
本章将从内置技能开始,逐层揭示技能的注册、发现、加载、执行和改进机制。
22.1 技能的本质:Command 类型与注册机制
BundledSkillDefinition 结构
每个技能最终都被表示为一个 Command 对象。内置技能通过 registerBundledSkill 函数注册,其定义类型如下:
// skills/bundledSkills.ts:15-41
export type BundledSkillDefinition = {
name: string
description: string
aliases?: string[]
whenToUse?: string
argumentHint?: string
allowedTools?: string[]
model?: string
disableModelInvocation?: boolean
userInvocable?: boolean
isEnabled?: () => boolean
hooks?: HooksSettings
context?: 'inline' | 'fork'
agent?: string
files?: Record<string, string>
getPromptForCommand: (
args: string,
context: ToolUseContext,
) => Promise<ContentBlockParam[]>
}
这个类型揭示了技能的几个关键维度:
| 字段 | 用途 | 典型值 |
|---|---|---|
name | 技能的调用名,对应 /name 语法 | "batch", "simplify" |
whenToUse | 告诉模型何时应主动调用此技能 | 出现在 system-reminder 中 |
allowedTools | 技能执行期间自动授权的工具列表 | ['Read', 'Grep', 'Glob'] |
context | 执行上下文——inline 注入主对话流,fork 在子 agent 中运行 | 'fork' |
disableModelInvocation | 禁止模型主动调用,只允许用户显式输入 | true(batch) |
files | 随技能附带的参考文件,首次调用时提取到磁盘 | verify 技能的验证脚本 |
getPromptForCommand | 核心:生成注入上下文的提示词内容 | 返回 ContentBlockParam[] |
注册流程本身很简单——registerBundledSkill 将定义转换为标准 Command 对象并推入内部数组:
// skills/bundledSkills.ts:53-100
// export function registerBundledSkill(definition: BundledSkillDefinition): void {
const { files } = definition
let skillRoot: string | undefined
let getPromptForCommand = definition.getPromptForCommand
if (files && Object.keys(files).length > 0) {
skillRoot = getBundledSkillExtractDir(definition.name)
let extractionPromise: Promise<string | null> | undefined
const inner = definition.getPromptForCommand
getPromptForCommand = async (args, ctx) => {
extractionPromise ??= extractBundledSkillFiles(definition.name, files)
const extractedDir = await extractionPromise
const blocks = await inner(args, ctx)
if (extractedDir === null) return blocks
return prependBaseDir(blocks, extractedDir)
}
}
const command: Command = {
type: 'prompt',
name: definition.name,
// ... 字段映射 ...
source: 'bundled',
loadedFrom: 'bundled',
getPromptForCommand,
}
bundledSkills.push(command)
}
注意第67行的 extractionPromise ??= ... 模式——这是一个“记忆化 Promise“。当多个并发调用者同时触发首次调用时,它们等待的是同一个 Promise,避免了竞态条件导致的重复文件写入。
文件提取的安全措施
内置技能的参考文件提取涉及安全敏感的文件系统操作。源码在 safeWriteFile 中使用了 O_NOFOLLOW | O_EXCL 标志组合(第176-184行),配合 0o600 权限模式。注释明确解释了威胁模型:
// skills/bundledSkills.ts:169-175
// The per-process nonce in getBundledSkillsRoot() is the primary defense
// against pre-created symlinks/dirs. Explicit 0o700/0o600 modes keep the
// nonce subtree owner-only even on umask=0, so an attacker who learns the
// nonce via inotify on the predictable parent still can't write into it.
这是一个典型的纵深防御设计——per-process nonce 是主防线,O_NOFOLLOW 和 O_EXCL 是补充防线。
22.2 内置技能清单
所有内置技能的注册入口在 skills/bundled/index.ts 的 initBundledSkills 函数中。根据源码分析,内置技能分为两类:无条件注册和按 Feature Flag 注册。
表 22-1:内置技能清单
| 技能名称 | 注册条件 | 功能简述 | 执行模式 | 用户可调用 |
|---|---|---|---|---|
update-config | 无条件 | 通过 settings.json 配置 Claude Code | inline | 是 |
keybindings | 无条件 | 自定义键盘快捷键 | inline | 是 |
verify | USER_TYPE === 'ant' | 通过运行应用验证代码变更 | inline | 是 |
debug | 无条件 | 启用调试日志并诊断问题 | inline | 是(禁止模型调用) |
lorem-ipsum | 无条件 | 开发测试用占位符 | inline | 是 |
skillify | 无条件 | 将当前会话捕获为可复用技能 | inline | 是 |
remember | USER_TYPE === 'ant' | 审查和整理 agent 记忆层 | inline | 是 |
simplify | 无条件 | 审查变更代码的质量和效率 | inline | 是 |
batch | 无条件 | 并行 worktree agent 执行大规模变更 | inline | 是(禁止模型调用) |
stuck | USER_TYPE === 'ant' | 诊断冻结/缓慢的 Claude Code 会话 | inline | 是 |
dream | KAIROS || KAIROS_DREAM | autoDream 记忆整理 | inline | 是 |
hunter | REVIEW_ARTIFACT | 审查工件 | inline | 是 |
loop | AGENT_TRIGGERS | 定时循环执行提示词 | inline | 是 |
schedule | AGENT_TRIGGERS_REMOTE | 创建远程定时 agent 触发器 | inline | 是 |
claude-api | BUILDING_CLAUDE_APPS | 使用 Claude API 构建应用 | inline | 是 |
claude-in-chrome | shouldAutoEnableClaudeInChrome() | Chrome 浏览器集成 | inline | 是 |
run-skill-generator | RUN_SKILL_GENERATOR | 技能生成器 | inline | 是 |
表 22-1:内置技能注册条件清单
Feature Flag 门控的技能使用了 require() 动态导入模式,而非 ESM 的 import()。源码在第36-38行有对应的 eslint-disable 注释——这是因为 Bun 的构建时 tree-shaking 依赖静态分析,feature() 调用会被 Bun 在编译期求值为布尔常量,从而将整个 require() 分支在非匹配的构建配置中完全消除。
典型技能剖析:batch
batch 技能(skills/bundled/batch.ts)是理解技能工作原理的绝佳样本。它的提示词模板定义了一个三阶段流程:
- 研究与计划阶段:进入 Plan Mode,启动前台子 agent 研究代码库,分解为 5-30 个独立工作单元
- 并行执行阶段:为每个工作单元启动一个后台
worktree隔离 agent - 进度追踪阶段:维护状态表,汇总 PR 链接
// skills/bundled/batch.ts:9-10
const MIN_AGENTS = 5
const MAX_AGENTS = 30
关键的工程决策在于 disableModelInvocation: true(第109行)——batch 技能只能由用户显式输入 /batch 触发,模型不能自主决定启动大规模并行重构。这是一个合理的安全边界——batch 操作会创建大量 worktree 和 PR,自主触发的风险太高。
典型技能剖析:simplify
simplify 技能展示了另一个常见模式——通过 AgentTool 启动三个并行审查 agent:
- 代码复用审查:搜索现有工具函数,标记重复实现
- 代码质量审查:检测冗余状态、参数膨胀、复制粘贴、不必要注释
- 效率审查:检测多余计算、缺失并发、热路径膨胀、内存泄漏
这三个 agent 并行运行,结果汇总后统一修复——技能提示词本身编码了“人类代码审查最佳实践“的知识。
22.3 用户自定义技能:loadSkillsDir.ts 的发现与加载
技能文件结构
用户自定义技能遵循目录格式:
.claude/skills/
my-skill/
SKILL.md ← 主文件(包含 frontmatter + Markdown 正文)
reference.ts ← 可选的参考文件
SKILL.md 文件使用 YAML frontmatter 声明元数据:
---
description: My custom skill
when_to_use: When the user asks for X
allowed-tools: Read, Grep, Bash
context: fork
model: opus
effort: high
arguments: [target, scope]
paths: src/components/**
---
# Skill prompt content here...
四层加载优先级
getSkillDirCommands 函数(loadSkillsDir.ts:638)从四个来源并行加载技能,优先级从高到低:
// skills/loadSkillsDir.ts:679-713
const [
managedSkills, // 1. 策略管理的技能(企业部署)
userSkills, // 2. 用户全局技能 (~/.claude/skills/)
projectSkillsNested,// 3. 项目技能 (.claude/skills/)
additionalSkillsNested, // 4. --add-dir 附加目录
legacyCommands, // 5. 旧版 /commands/ 目录(已废弃)
] = await Promise.all([
loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
loadSkillsFromSkillsDir(userSkillsDir, 'userSettings'),
// ... 项目和附加目录 ...
loadSkillsFromCommandsDir(cwd),
])
每个来源都受独立的开关控制:
| 来源 | 开关条件 | 目录路径 |
|---|---|---|
| 策略管理 | !CLAUDE_CODE_DISABLE_POLICY_SKILLS | <managed>/.claude/skills/ |
| 用户全局 | isSettingSourceEnabled('userSettings') && !skillsLocked | ~/.claude/skills/ |
| 项目本地 | isSettingSourceEnabled('projectSettings') && !skillsLocked | .claude/skills/(逐级向上) |
| –add-dir | 同上 | <dir>/.claude/skills/ |
| 旧版 commands | !skillsLocked | .claude/commands/ |
表 22-2:技能加载来源及开关条件
skillsLocked 标志来自 isRestrictedToPluginOnly('skills')——当企业策略限制仅允许插件技能时,所有本地技能加载被跳过。
Frontmatter 解析
parseSkillFrontmatterFields 函数(第185-265行)是所有技能来源共享的解析入口。它处理的字段包括:
// skills/loadSkillsDir.ts:185-206
// export function parseSkillFrontmatterFields(
frontmatter: FrontmatterData,
markdownContent: string,
resolvedName: string,
): {
displayName: string | undefined
description: string
allowedTools: string[]
argumentHint: string | undefined
whenToUse: string | undefined
model: ReturnType<typeof parseUserSpecifiedModel> | undefined
disableModelInvocation: boolean
hooks: HooksSettings | undefined
executionContext: 'fork' | undefined
agent: string | undefined
effort: EffortValue | undefined
shell: FrontmatterShell | undefined
// ...
}
值得注意的是 effort 字段(第228-235行)——技能可以指定自己的“努力等级“,覆盖全局设置。无效的 effort 值会被静默忽略并记录调试日志,遵循宽容解析原则。
提示词执行时的变量替换
createSkillCommand 的 getPromptForCommand 方法(第344-399行)在技能被调用时执行以下处理链:
原始 Markdown
│
▼
添加 "Base directory" 前缀(如果有 baseDir)
│
▼
参数替换($1, $2 或命名参数)
│
▼
${CLAUDE_SKILL_DIR} → 技能目录路径
│
▼
${CLAUDE_SESSION_ID} → 当前会话 ID
│
▼
Shell 命令执行(!`command` 语法,MCP 技能跳过此步)
│
▼
返回 ContentBlockParam[]
图 22-1:技能提示词变量替换流程
安全边界在第374行明确体现:
// skills/loadSkillsDir.ts:372-376
// Security: MCP skills are remote and untrusted — never execute inline
// shell commands (!`…` / ```! … ```) from their markdown body.
if (loadedFrom !== 'mcp') {
finalContent = await executeShellCommandsInPrompt(...)
}
MCP 来源的技能被视为不受信任,其 Markdown 中的 !command 语法不会被执行——这是防止远程提示注入导致任意命令执行的关键防线。
去重机制
加载完成后,通过 realpath 解析符号链接来检测重复文件:
// skills/loadSkillsDir.ts:728-734
const fileIds = await Promise.all(
allSkillsWithPaths.map(({ skill, filePath }) =>
skill.type === 'prompt'
? getFileIdentity(filePath)
: Promise.resolve(null),
),
)
源码注释(第107-117行)特别提到了使用 realpath 而非 inode 的原因——某些虚拟文件系统、容器环境或 NFS 挂载会报告不可靠的 inode 值(例如 inode 0 或 ExFAT 上的精度丢失问题)。
22.4 条件技能:路径过滤与动态激活
paths frontmatter
技能可以通过 paths frontmatter 声明自己只在用户操作特定路径的文件时才激活:
---
paths: src/components/**, src/hooks/**
---
在 getSkillDirCommands 中(第771-790行),带 paths 的技能不会立即出现在技能列表中:
// skills/loadSkillsDir.ts:771-790
const unconditionalSkills: Command[] = []
const newConditionalSkills: Command[] = []
for (const skill of deduplicatedSkills) {
if (
skill.type === 'prompt' &&
skill.paths &&
skill.paths.length > 0 &&
!activatedConditionalSkillNames.has(skill.name)
) {
newConditionalSkills.push(skill)
} else {
unconditionalSkills.push(skill)
}
}
for (const skill of newConditionalSkills) {
conditionalSkills.set(skill.name, skill)
}
条件技能存储在 conditionalSkills Map 中,等待文件操作触发激活。当用户通过 Read/Write/Edit 等工具操作了匹配路径的文件时,activateConditionalSkillsForPaths 函数(第1001-1033行)使用 ignore 库进行 gitignore 风格的路径匹配,将匹配的技能从待激活 Map 移入活跃集合:
// skills/loadSkillsDir.ts:1007-1033
for (const [name, skill] of conditionalSkills) {
// ... 路径匹配逻辑 ...
conditionalSkills.delete(name)
activatedConditionalSkillNames.add(name)
}
一旦激活,技能名称被记录在 activatedConditionalSkillNames 中——这个 Set 在缓存清除时不会被重置(clearSkillCaches 只清除加载缓存,不清除激活状态),确保了“一旦触摸文件,技能在整个会话期间保持可用“的语义。
动态目录发现
除了条件技能,discoverSkillDirsForPaths 函数(第861-915行)还实现了子目录级别的技能发现。当用户操作深层嵌套的文件时,系统会从文件所在目录逐级向上走到 cwd,在每一级检查 .claude/skills/ 目录是否存在。这使得 monorepo 中每个包可以有自己的技能集。
发现过程有两个安全检查:
- gitignore 检查:
node_modules/pkg/.claude/skills/这样的路径会被跳过 - 去重检查:已检查过的路径记录在
dynamicSkillDirsSet 中,避免对不存在的目录重复stat()
22.5 MCP 技能桥接:mcpSkillBuilders.ts
依赖环问题
MCP 技能(通过 MCP 服务器连接注入的技能)面临一个经典的工程问题:循环依赖。MCP 技能的加载需要 loadSkillsDir.ts 中的 createSkillCommand 和 parseSkillFrontmatterFields 函数,但 loadSkillsDir.ts 的导入链最终会触达 MCP 客户端代码,形成环路。
mcpSkillBuilders.ts 通过一次性注册模式打破了这个环:
// skills/mcpSkillBuilders.ts:26-44
export type MCPSkillBuilders = {
createSkillCommand: typeof createSkillCommand
parseSkillFrontmatterFields: typeof parseSkillFrontmatterFields
}
let builders: MCPSkillBuilders | null = null
// export function registerMCPSkillBuilders(b: MCPSkillBuilders): void {
builders = b
}
// export function getMCPSkillBuilders(): MCPSkillBuilders {
if (!builders) {
throw new Error(
'MCP skill builders not registered — loadSkillsDir.ts has not been evaluated yet',
)
}
return builders
}
源码注释(第9-23行)详细解释了为什么不能用动态 import()——Bun 的 bunfs 虚拟文件系统会导致模块路径解析失败,而字面量动态导入虽然在 bunfs 中有效,但会让 dependency-cruiser 检测到新的环路违规。
注册时机在 loadSkillsDir.ts 的模块初始化期——通过 commands.ts 的静态导入链,这段代码在启动早期就被执行,远早于任何 MCP 服务器建立连接。
22.6 技能搜索:EXPERIMENTAL_SKILL_SEARCH
远程技能发现
在 SkillTool.ts 的第108-116行,EXPERIMENTAL_SKILL_SEARCH flag 门控了远程技能搜索模块的加载:
// tools/SkillTool/SkillTool.ts:108-116
const remoteSkillModules = feature('EXPERIMENTAL_SKILL_SEARCH')
? {
...(require('../../services/skillSearch/remoteSkillState.js') as ...),
...(require('../../services/skillSearch/remoteSkillLoader.js') as ...),
...(require('../../services/skillSearch/telemetry.js') as ...),
...(require('../../services/skillSearch/featureCheck.js') as ...),
}
: null
远程技能使用 _canonical_<slug> 命名前缀——在 validateInput 中(第378-396行),这类技能会绕过本地命令注册表直接查找:
// tools/SkillTool/SkillTool.ts:381-395
const slug = remoteSkillModules!.stripCanonicalPrefix(normalizedCommandName)
if (slug !== null) {
const meta = remoteSkillModules!.getDiscoveredRemoteSkill(slug)
if (!meta) {
return {
result: false,
message: `Remote skill ${slug} was not discovered in this session.`,
errorCode: 6,
}
}
return { result: true }
}
远程技能从 AKI/GCS 加载 SKILL.md 内容(带本地缓存),执行时不进行 shell 命令替换和参数插值——它们被视为声明式的纯 Markdown。
在权限层面,远程技能获得自动授权(第488-504行),但这个授权被放置在 deny 规则检查之后——用户配置的 Skill(_canonical_:*) deny 规则仍然生效。
22.7 技能预算约束:1% 上下文窗口与三级截断
预算计算
技能列表占用上下文窗口的空间受到严格控制。核心常量在 tools/SkillTool/prompt.ts 中定义:
// tools/SkillTool/prompt.ts:21-29
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 // 1% of context window
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4
export const MAX_LISTING_DESC_CHARS = 250 // Per-entry hard cap
预算公式为:contextWindowTokens × 4 × 0.01。对于 200K token 的上下文窗口,这意味着 8,000 个字符——约 40 个技能的名称和描述。
三级截断级联
当技能列表超出预算时,formatCommandsWithinBudget 函数(第70-171行)执行三级截断级联:
┌──────────────────────────────────────────────┐
│ Level 1: 完整描述 │
│ "- batch: Research and plan a large-scale │
│ change, then execute it in parallel..." │
│ │
│ 如果总大小 ≤ budget → 输出 │
└─────────────────────┬────────────────────────┘
│ 超出
▼
┌──────────────────────────────────────────────┐
│ Level 2: 截短描述 │
│ 内置技能保留完整描述(永不截断) │
│ 非内置技能描述截断到 maxDescLen │
│ maxDescLen = (剩余预算 - 名称开销) / 技能数 │
│ │
│ 如果 maxDescLen ≥ 20 → 输出 │
└─────────────────────┬────────────────────────┘
│ maxDescLen < 20
▼
┌──────────────────────────────────────────────┐
│ Level 3: 仅名称 │
│ 内置技能保留完整描述 │
│ 非内置技能仅显示名称 │
│ "- my-custom-skill" │
└──────────────────────────────────────────────┘
图 22-2:三级截断级联策略
这个设计的关键洞察是内置技能永不截断(第93-99行)。原因在于内置技能是经过验证的核心功能,它们的 whenToUse 描述对模型的匹配决策至关重要。用户自定义技能被截断后,模型仍然可以通过 SkillTool 的完整加载机制在调用时获取详细内容——列表只是用于发现,不是用于执行。
每个技能条目还受 MAX_LISTING_DESC_CHARS = 250 的硬上限约束——即使在 Level 1 模式下,超长的 whenToUse 也会被截断到 250 字符。源码注释解释了这一决策:
The listing is for discovery only — the Skill tool loads full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation tokens without improving match rate.
22.8 技能生命周期:从注册到改进
完整生命周期流程
flowchart TD
REG["注册 Register\n内置/用户/MCP"] --> DISC["发现 Discover\nsystem-reminder 列表"]
DISC --> INV["调用 Invoke\nSkillTool.call()"]
INV --> EXEC["执行 Execute\ninline 或 fork"]
EXEC --> IMPROVE{"后采样钩子\nSKILL_IMPROVEMENT\n每 5 轮触发"}
IMPROVE -->|检测到偏好| DETECT["检测用户偏好/纠正\n生成 SkillUpdate[]"]
IMPROVE -->|无变化| DONE["继续对话"]
DETECT --> REWRITE["侧信道 LLM\n改写 SKILL.md"]
REWRITE --> CHANGE["文件变更检测\nchokidar watcher"]
CHANGE --> RELOAD["重载 Reload\n清除缓存"]
RELOAD --> DISC
style REG fill:#e1f5fe
style EXEC fill:#e8f5e9
style IMPROVE fill:#fff3e0
style REWRITE fill:#fce4ec
图 22-3:技能生命周期全流程
阶段一:注册
- 内置技能:
initBundledSkills()在启动时同步注册 - 用户技能:
getSkillDirCommands()通过memoize缓存首次加载结果 - MCP 技能:MCP 服务器连接后通过
getMCPSkillBuilders()注册
阶段二:发现
技能通过两种方式被模型发现:
- system-reminder 列表:所有已加载技能的名称和描述被注入到
<system-reminder>标签中 - Skill 工具描述:
SkillTool.prompt中包含调用说明
阶段三:调用与执行
SkillTool.call 方法(第580-841行)处理调用逻辑,核心分支在第622行:
// tools/SkillTool/SkillTool.ts:621-632
if (command?.type === 'prompt' && command.context === 'fork') {
return executeForkedSkill(...)
}
// ... inline 执行路径 ...
- inline 模式:技能提示词注入主对话的消息流,模型在同一上下文中执行
- fork 模式:启动子 agent 在隔离上下文中执行,完成后将结果摘要返回
inline 模式通过 contextModifier 实现工具授权和模型覆盖的注入——它不修改全局状态,而是链式包装 getAppState() 函数。
阶段四:改进(SKILL_IMPROVEMENT)
skillImprovement.ts 实现了一个后采样钩子(post-sampling hook),在技能执行期间自动检测用户偏好和纠正:
// utils/hooks/skillImprovement.ts:84-87
const userCount = count(context.messages, m => m.type === 'user')
if (userCount - lastAnalyzedCount < TURN_BATCH_SIZE) {
return false
}
每 5 轮用户消息(TURN_BATCH_SIZE = 5),系统将最近的对话片段发送到一个小型快速模型(getSmallFastModel()),让它分析用户是否表达了应该被永久化到技能定义中的偏好。
检测到的更新以 SkillUpdate[] 数组的形式存储在 AppState 中。后续的 applySkillImprovement 函数(第188行起)通过侧信道 LLM 调用直接改写 .claude/skills/<name>/SKILL.md 文件——这个过程是 fire-and-forget 的,不阻塞主对话。
阶段五:变更检测与重载
skillChangeDetector.ts 使用 chokidar 文件监视器检测技能文件的变化:
// utils/skills/skillChangeDetector.ts:27-28
const FILE_STABILITY_THRESHOLD_MS = 1000
const FILE_STABILITY_POLL_INTERVAL_MS = 500
当检测到变化时:
- 等待 1 秒文件稳定阈值
- 在 300ms 的防抖窗口内聚合多个变更事件
- 清除技能缓存和命令缓存
- 通过
skillsChanged信号通知所有订阅者
特别值得注意的是第62行的平台适配:
// utils/skills/skillChangeDetector.ts:62
const USE_POLLING = typeof Bun !== 'undefined'
Bun 的原生 fs.watch() 存在 PathWatcherManager 死锁问题(oven-sh/bun#27469)——当文件监视线程正在传递事件时关闭监视器会导致两个线程在 __ulock_wait2 上永远挂起。源码选择了使用 stat() 轮询作为临时方案,并标注了上游修复后的移除计划。
22.9 Skill 工具的权限模型
自动授权条件
并非所有技能调用都需要用户确认。SkillTool.checkPermissions 中(第529-538行),满足 skillHasOnlySafeProperties 条件的技能会被自动授权:
// tools/SkillTool/SkillTool.ts:875-908
const SAFE_SKILL_PROPERTIES = new Set([
'type', 'progressMessage', 'contentLength', 'model', 'effort',
'source', 'name', 'description', 'isEnabled', 'isHidden',
'aliases', 'argumentHint', 'whenToUse', 'paths', 'version',
'disableModelInvocation', 'userInvocable', 'loadedFrom',
// ...
])
这是一个白名单模式——只有声明了白名单内属性的技能才会被自动授权。如果未来有新属性被加入 PromptCommand 类型,它们默认需要权限,直到被显式添加到白名单。含有 allowedTools、hooks 等敏感字段的技能会触发用户确认对话。
权限规则匹配
权限检查支持精确匹配和前缀通配:
// tools/SkillTool/SkillTool.ts:451-467
const ruleMatches = (ruleContent: string): boolean => {
const normalizedRule = ruleContent.startsWith('/')
? ruleContent.substring(1)
: ruleContent
if (normalizedRule === commandName) return true
if (normalizedRule.endsWith(':*')) {
const prefix = normalizedRule.slice(0, -2)
return commandName.startsWith(prefix)
}
return false
}
这意味着用户可以配置 Skill(review:*) allow 来一次性授权所有以 review 开头的技能。
模式提炼
从技能系统的设计中,可以提取以下可复用的模式:
模式一:记忆化 Promise 模式
- 解决的问题:多个并发调用者同时触发首次初始化时的竞态条件
- 模式:
extractionPromise ??= extractBundledSkillFiles(...)—— 使用??=确保只创建一个 Promise,所有调用者等待同一个结果 - 前置条件:初始化操作是幂等的且结果可复用
模式二:白名单安全模型
- 解决的问题:新增属性默认安全——未知属性需要权限确认
- 模式:
SAFE_SKILL_PROPERTIES白名单只包含已知安全的字段,新增字段自动进入“需要权限“路径 - 前置条件:属性集合会随时间增长,安全性需要保守默认
模式三:分层信任与能力降级
- 解决的问题:不同来源的扩展有不同的信任等级
- 模式:内置技能(永不截断)> 用户本地技能(可截断、可执行 shell)> MCP 远程技能(禁止 shell、自动授权受 deny 约束)
- 前置条件:系统接受来自多个信任域的输入
模式四:预算感知的渐进降级
- 解决的问题:有限资源(上下文窗口)下展示可变数量的条目
- 模式:三级截断级联(完整描述 → 截短描述 → 仅名称),高优先级条目永不截断
- 前置条件:条目数量不可控,资源预算固定
用户能做什么
创建和使用自定义技能提升工作效率:
-
创建自己的技能。在
.claude/skills/my-skill/SKILL.md中编写一个 Markdown 文件,通过 YAML frontmatter 声明元数据(描述、允许工具、执行上下文等),即可通过/my-skill或模型自动调用来使用。 -
使用
pathsfrontmatter 实现条件激活。如果某个技能只在操作特定目录时才需要(如paths: src/components/**),它不会在所有对话中出现,而是在你操作匹配文件时自动激活——节省宝贵的上下文窗口空间。 -
利用
/skillify将会话捕获为技能。如果你在一次对话中建立了一个有效的工作流程,使用/skillify可以将其自动转化为可复用的技能文件。 -
理解 1% 预算限制。技能列表在上下文窗口中只占 1%(约 8000 字符),超出后会触发截断。保持
whenToUse描述简洁有助于在有限预算内展示更多技能。 -
使用权限前缀通配符。配置
Skill(my-prefix:*) allow可以一次性授权所有以my-prefix开头的技能,减少确认对话框的打扰。 -
注意 MCP 技能的安全限制。远程 MCP 技能中的 shell 命令语法(
!command)不会被执行——这是防止远程提示注入的安全防线。如果你的技能需要执行 shell 命令,请使用本地技能。
22.10 小结
技能系统是 Claude Code 将最佳实践知识编码为可执行流程的核心机制。它的设计遵循几个关键原则:
-
提示词即代码:技能不是传统的插件 API——它们是 Markdown 文本,由 LLM 解释执行。这使得创建和迭代技能的门槛极低。
-
分层信任:内置技能永不截断、MCP 技能禁止 shell 执行、远程技能自动授权但受 deny 规则约束——每个来源有不同的信任等级。
-
自我改进:
SKILL_IMPROVEMENT机制让技能在使用过程中根据用户反馈自动进化——这是一个“从使用中学习“的闭环。 -
预算感知:1% 上下文窗口的硬预算和三级截断级联确保技能发现不会挤占实际工作的上下文空间。
在下一章中,我们将从另一个角度审视 Claude Code 的扩展性——通过源码中 89 个 Feature Flag 背后尚未发布的功能管线,窥见这个系统的演进方向。
第23章:未发布功能管线 – 89 个 Feature Flag 背后的路线图
为什么这很重要
在前面的 22 章中,我们分析的是 Claude Code 已发布的公开功能。但源码中还隐藏着另一个维度:89 个 Feature Flag 门控着尚未向所有用户开放的功能。这些 flag 通过 Bun 的构建时 feature() 函数实现——编译器在不同构建配置下将 feature('FLAG_NAME') 求值为 true 或 false,配合 dead code elimination 将未启用的代码分支完整移除。
这意味着源码中 feature('KAIROS') 门控的代码在公开构建中根本不存在——它只出现在内部构建(USER_TYPE === 'ant')或实验分支中。但在我们还原的源码里,所有 flag 的两个分支都被保留了下来,为我们提供了一个独特的视角来审视 Claude Code 的功能演进方向。
本章将这 89 个 flag 按功能域分为五大类,分析核心未发布功能的实现深度和相互关系。需要强调的是:本章的分析基于源码中可观测的实现状态,不猜测商业策略,不预测发布时间。 flag 的存在不等同于功能即将发布——许多 flag 可能是实验性的原型、A/B 测试配置、或已废弃的探索方向。
23.1 Feature Flag 机制
构建时求值
Claude Code 使用 Bun 的 bun:bundle 模块提供的 feature() 函数:
import { feature } from 'bun:bundle'
if (feature('KAIROS')) {
const { registerDreamSkill } = require('./dream.js')
registerDreamSkill()
}
feature() 在构建时被替换为字面量 true 或 false。当结果为 false 时,整个 if 块在 tree-shaking 阶段被移除。这解释了为什么门控代码使用 require() 而非 import()——require() 是表达式,可以出现在 if 块内部,从而被 dead code elimination 连同其模块依赖一起消除。
引用计数与成熟度推断
通过统计每个 flag 在源码中的引用次数,可以粗略推断其实现深度:
| 引用次数范围 | 含义 | 典型 flag |
|---|---|---|
| 100+ | 深度集成,触及多个核心子系统 | KAIROS (154), TRANSCRIPT_CLASSIFIER (107) |
| 30-99 | 功能完整,已在多个模块中织入 | TEAMMEM (51), VOICE_MODE (46), PROACTIVE (37) |
| 10-29 | 功能较完整,涉及特定子系统 | CONTEXT_COLLAPSE (20), CHICAGO_MCP (16) |
| 3-9 | 初步实现或范围有限 | TOKEN_BUDGET (9), WEB_BROWSER_TOOL (4) |
| 1-2 | 原型/探索阶段或纯开关性质 | ULTRATHINK (1), ABLATION_BASELINE (1) |
表 23-1:Feature Flag 引用次数与成熟度推断
引用次数高不一定意味着“即将发布“——KAIROS 的 154 处引用可能恰恰说明它是一个长期演进的复杂系统,需要大量的渐进式集成。
23.2 全部 89 个 Flag 分类表
根据功能域,89 个 flag 可以分为五大类:
graph TD
ROOT["89 个 Feature Flag"] --> A["自主 Agent 与后台运行\n18 个 flag"]
ROOT --> B["远程控制与分布式执行\n14 个 flag"]
ROOT --> C["上下文管理与性能优化\n17 个 flag"]
ROOT --> D["记忆与知识管理\n9 个 flag"]
ROOT --> E["UI/UX 与平台能力\n31 个 flag"]
A --> A1["KAIROS (154)"]
A --> A2["COORDINATOR_MODE (32)"]
A --> A3["PROACTIVE (37)"]
B --> B1["BRIDGE_MODE (28)"]
B --> B2["UDS_INBOX (17)"]
C --> C1["TRANSCRIPT_CLASSIFIER (107)"]
C --> C2["BASH_CLASSIFIER (45)"]
D --> D1["TEAMMEM (51)"]
D --> D2["EXPERIMENTAL_SKILL_SEARCH (21)"]
E --> E1["VOICE_MODE (46)"]
E --> E2["CHICAGO_MCP (16)"]
style ROOT fill:#f9f,stroke:#333,stroke-width:2px
style A fill:#e3f2fd
style B fill:#e8f5e9
style C fill:#fff3e0
style D fill:#fce4ec
style E fill:#f3e5f5
表 23-2:自主 Agent 与后台运行(18 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
KAIROS | 154 | 助手模式核心:后台自主 agent、tick 唤醒机制 |
PROACTIVE | 37 | 自主工作模式:终端焦点感知、主动行动 |
KAIROS_BRIEF | 39 | 简报模式:向用户发送进度消息 |
KAIROS_CHANNELS | 19 | 频道系统:多通道通信 |
KAIROS_DREAM | 1 | autoDream 记忆整理触发 |
KAIROS_PUSH_NOTIFICATION | 4 | 推送通知:向用户推送状态更新 |
KAIROS_GITHUB_WEBHOOKS | 3 | GitHub Webhook 订阅:PR 事件触发 |
AGENT_TRIGGERS | 11 | 定时触发器(本地 cron) |
AGENT_TRIGGERS_REMOTE | 2 | 远程定时触发器(云端 cron) |
BG_SESSIONS | 11 | 后台会话管理(ps/logs/attach/kill) |
COORDINATOR_MODE | 32 | 协调器模式:跨 agent 任务协调 |
BUDDY | 15 | 伴侣模式:浮动 UI 气泡 |
ULTRAPLAN | 10 | 超级计划:结构化任务分解 UI |
VERIFICATION_AGENT | 4 | 验证 agent:自动验证任务完成状态 |
BUILTIN_EXPLORE_PLAN_AGENTS | 1 | 内置探索/计划 agent 类型 |
FORK_SUBAGENT | 4 | 子 agent fork 执行模式 |
MONITOR_TOOL | 13 | 监控工具:后台进程监控 |
TORCH | 1 | Torch 命令(功能不明) |
表 23-3:远程控制与分布式执行(14 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
BRIDGE_MODE | 28 | 桥接模式核心:远程控制协议 |
DAEMON | 3 | 守护进程模式:后台运行 daemon worker |
SSH_REMOTE | 4 | SSH 远程连接 |
DIRECT_CONNECT | 5 | 直连模式 |
CCR_AUTO_CONNECT | 3 | Claude Code Remote 自动连接 |
CCR_MIRROR | 4 | CCR 镜像模式:只读远程镜像 |
CCR_REMOTE_SETUP | 1 | CCR 远程设置命令 |
SELF_HOSTED_RUNNER | 1 | 自托管运行器 |
BYOC_ENVIRONMENT_RUNNER | 1 | 自带计算环境运行器 |
UDS_INBOX | 17 | Unix Domain Socket 收件箱:进程间通信 |
LODESTONE | 6 | 协议注册(lodestone:// handler) |
CONNECTOR_TEXT | 7 | 连接器文本块处理 |
DOWNLOAD_USER_SETTINGS | 5 | 从云端下载用户配置 |
UPLOAD_USER_SETTINGS | 2 | 上传用户配置到云端 |
表 23-4:上下文管理与性能优化(17 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
CONTEXT_COLLAPSE | 20 | 上下文折叠:精细化上下文管理 |
REACTIVE_COMPACT | 4 | 响应式压缩:按需触发 compact |
CACHED_MICROCOMPACT | 12 | 缓存微压缩策略 |
COMPACTION_REMINDERS | 1 | 压缩提醒机制 |
TOKEN_BUDGET | 9 | Token 预算追踪 UI 和预算控制 |
PROMPT_CACHE_BREAK_DETECTION | 9 | Prompt Cache 断裂检测 |
HISTORY_SNIP | 15 | 历史截断命令 |
BREAK_CACHE_COMMAND | 2 | 强制打断缓存命令 |
ULTRATHINK | 1 | 超级思考模式 |
TREE_SITTER_BASH | 3 | Tree-sitter Bash 解析器 |
TREE_SITTER_BASH_SHADOW | 5 | Tree-sitter Bash 影子模式(A/B 测试) |
BASH_CLASSIFIER | 45 | Bash 命令分类器 |
TRANSCRIPT_CLASSIFIER | 107 | 会话记录分类器(auto 模式) |
STREAMLINED_OUTPUT | 1 | 精简输出模式 |
ABLATION_BASELINE | 1 | 消融实验基线 |
FILE_PERSISTENCE | 3 | 文件持久化计时 |
OVERFLOW_TEST_TOOL | 2 | 溢出测试工具 |
表 23-5:记忆与知识管理(9 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
TEAMMEM | 51 | 团队记忆同步 |
EXTRACT_MEMORIES | 7 | 自动记忆提取 |
AGENT_MEMORY_SNAPSHOT | 2 | Agent 记忆快照 |
AWAY_SUMMARY | 2 | 离开摘要:用户离开时生成进度摘要 |
MEMORY_SHAPE_TELEMETRY | 3 | 记忆结构遥测 |
SKILL_IMPROVEMENT | 1 | 技能自动改进(后采样钩子) |
RUN_SKILL_GENERATOR | 1 | 技能生成器 |
EXPERIMENTAL_SKILL_SEARCH | 21 | 实验性远程技能搜索 |
MCP_SKILLS | 9 | MCP 服务器技能发现 |
表 23-6:UI/UX 与平台能力(31 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
VOICE_MODE | 46 | 语音模式:流式语音转文字 |
WEB_BROWSER_TOOL | 4 | Web 浏览器工具(Bun WebView) |
TERMINAL_PANEL | 4 | 终端面板 |
HISTORY_PICKER | 4 | 历史选择器 UI |
MESSAGE_ACTIONS | 5 | 消息操作(复制/编辑快捷键) |
QUICK_SEARCH | 5 | 快速搜索 UI |
AUTO_THEME | 2 | 自动主题切换 |
NATIVE_CLIPBOARD_IMAGE | 2 | 原生剪贴板图片支持 |
NATIVE_CLIENT_ATTESTATION | 1 | 原生客户端认证 |
POWERSHELL_AUTO_MODE | 2 | PowerShell 自动模式 |
CHICAGO_MCP | 16 | Computer Use MCP 集成 |
MCP_RICH_OUTPUT | 3 | MCP 富文本输出 |
TEMPLATES | 6 | 任务模板/分类 |
WORKFLOW_SCRIPTS | 10 | 工作流脚本 |
REVIEW_ARTIFACT | 4 | 审查工件 |
BUILDING_CLAUDE_APPS | 1 | 构建 Claude Apps 技能 |
COMMIT_ATTRIBUTION | 12 | Git 提交归属追踪 |
HOOK_PROMPTS | 1 | 钩子提示词 |
NEW_INIT | 2 | 新版初始化流程 |
HARD_FAIL | 2 | 硬失败模式 |
SHOT_STATS | 10 | 工具调用统计分布 |
ANTI_DISTILLATION_CC | 1 | 反蒸馏保护 |
COWORKER_TYPE_TELEMETRY | 2 | 协作者类型遥测 |
ENHANCED_TELEMETRY_BETA | 2 | 增强遥测 Beta |
PERFETTO_TRACING | 1 | Perfetto 性能追踪 |
SLOW_OPERATION_LOGGING | 1 | 慢操作日志 |
DUMP_SYSTEM_PROMPT | 1 | 导出系统提示词 |
ALLOW_TEST_VERSIONS | 2 | 允许测试版本 |
UNATTENDED_RETRY | 1 | 无人值守重试 |
IS_LIBC_GLIBC | 1 | glibc 运行时检测 |
IS_LIBC_MUSL | 1 | musl 运行时检测 |
23.3 核心未发布功能深度分析
KAIROS:后台自主助手
KAIROS 是引用次数最多的 flag(154 处),其代码痕迹触及了 Claude Code 几乎所有核心子系统。从源码分析可以还原出以下架构:
graph TD
AM["Assistant Module"] --> GATE["Gate Module\n(kairosGate)"]
GATE --> ACTIVATE["Activation Path"]
AM --> MODE["Assistant Mode\n独立会话模式"]
AM --> TICK["Tick Wakeup\n定时唤醒"]
AM --> BRIEF["Brief Tool\n简报/进度标记"]
AM --> CH["Channels\n多通道通信"]
AM --> DREAM["Dream\n空闲记忆整理"]
AM --> PUSH["Push Notification\n状态推送"]
AM --> GH["GitHub Webhooks\nPR 事件订阅"]
TICK --> PRO["Proactive Module"]
PRO --> CHECK{"terminalFocus?"}
CHECK -->|"用户不在看终端"| AUTO["Agent 自主执行"]
CHECK -->|"用户在看终端"| WAIT["等待用户输入"]
style AM fill:#e1f5fe,stroke:#333,stroke-width:2px
style PRO fill:#fff3e0
style AUTO fill:#c8e6c9
图 23-1:KAIROS 助手模式架构图
KAIROS 的核心理念可以从以下代码模式中推断:
入口点(main.tsx:80-81):
const assistantModule = feature('KAIROS')
? require('./assistant/index.js') as typeof import('./assistant/index.js')
: null
const kairosGate = feature('KAIROS')
? require('./assistant/gate.js') as typeof import('./assistant/gate.js')
: null
Tick 唤醒机制(REPL.tsx:2115, 2605, 2634, 2738):KAIROS 在多个 REPL 生命周期点检查是否应该“唤醒“——包括消息处理后、输入空闲时、以及终端焦点变化时。当用户离开终端时(!terminalFocusRef.current),系统可以自主执行等待中的任务。
Brief Tool 集成(main.tsx:2201):
const briefVisibility = feature('KAIROS') || feature('KAIROS_BRIEF')
? isBriefEnabled()
? 'Call SendUserMessage at checkpoints to mark where things stand.'
: 'The user will see any text you output.'
: 'The user will see any text you output.'
当 Brief 模式启用时,系统提示词指导模型使用 SendUserMessage 在关键检查点向用户报告进度——而不是输出所有中间文本。这是为后台自主执行设计的通信模式。
Team Context(main.tsx:3035):
teamContext: feature('KAIROS')
? assistantTeamContext ?? computeInitialTeamContext?.()
: computeInitialTeamContext?.()
KAIROS 引入了“团队上下文“概念——当 agent 作为助手模式运行时,它需要理解自己在更大协作图中的位置。
PROACTIVE 模式
PROACTIVE(37 处引用)与 KAIROS 高度耦合——在源码中,两者几乎总是以 feature('PROACTIVE') || feature('KAIROS') 的形式出现(REPL.tsx:194, 2115, 2605 等)。这暗示 PROACTIVE 是 KAIROS 的一个子功能或前身——当 KAIROS 的完整助手模式不可用时,PROACTIVE 提供了一个较轻量的“主动工作“能力。
关键行为差异在 REPL.tsx:2776:
...((feature('PROACTIVE') || feature('KAIROS'))
&& proactiveModule?.isProactiveActive()
&& !terminalFocusRef.current
? { /* 自主执行配置 */ }
: {})
条件组合 isProactiveActive() && !terminalFocusRef.current 揭示了核心机制:当用户不在看终端,且 proactive 模式已激活时,agent 获得自主执行权限。这是一个基于物理注意力信号的权限升级——用户的终端焦点状态成为了 agent 自主性的门控条件。
VOICE_MODE:流式语音转文字
VOICE_MODE(46 处引用)的实现触及了输入、配置、快捷键和服务层:
语音 STT 服务(services/voiceStreamSTT.ts:3):
// Only reachable in ant builds (gated by feature('VOICE_MODE') in useVoice.ts import).
快捷键绑定(keybindings/defaultBindings.ts:96):
...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {})
空格键被绑定为 push-to-talk——这是语音输入的标准交互模式。语音集成涉及 useVoiceIntegration.tsx 中的多个 hook:useVoiceEnabled, useVoiceState, useVoiceInterimTranscript,以及 startVoice/stopVoice/toggleVoice 控制函数。
配置集成(tools/ConfigTool/supportedSettings.ts:144):voice 作为可配置的设置项注册,支持通过 /config set voiceEnabled true 启用。
WEB_BROWSER_TOOL:Bun WebView
WEB_BROWSER_TOOL(4 处引用)的实现痕迹虽少但关键:
// main.tsx:1571
const hint = feature('WEB_BROWSER_TOOL')
&& typeof Bun !== 'undefined' && 'WebView' in Bun
? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER
: CLAUDE_IN_CHROME_SKILL_HINT
这行代码揭示了技术选型:web 浏览器工具基于 Bun 内置的 WebView,而非 Playwright 或 Puppeteer 这样的外部浏览器自动化工具。typeof Bun !== 'undefined' && 'WebView' in Bun 的运行时检测说明这依赖于 Bun 尚未稳定的 WebView API。
在 REPL 中(REPL.tsx:272, 4585),WebBrowserTool 有自己的面板组件 WebBrowserPanel,可以在全屏模式下与主对话并排显示。
BRIDGE_MODE + DAEMON:远程控制
BRIDGE_MODE(28 处引用)和 DAEMON(3 处引用)构成了远程控制的基础设施:
入口点(entrypoints/cli.tsx:100-165):
if (feature('DAEMON') && args[0] === '--daemon-worker') {
// 启动守护进程 worker
}
if (feature('BRIDGE_MODE') && (args[0] === 'remote-control' || args[0] === 'rc'
|| args[0] === 'remote' || args[0] === 'sync' || args[0] === 'bridge')) {
// 启动远程控制/桥接
}
if (feature('DAEMON') && args[0] === 'daemon') {
// 启动 daemon 进程
}
DAEMON 提供了 --daemon-worker 后台工作进程和 daemon 管理命令。BRIDGE_MODE 提供了多个子命令别名(remote-control、rc、remote、sync、bridge)——这种别名丰富度暗示团队仍在探索最佳的用户界面命名。
桥接核心在 bridge/bridgeEnabled.ts 中,提供了多个检查函数:
// bridge/bridgeEnabled.ts:32
return feature('BRIDGE_MODE') // isBridgeEnabled
// bridge/bridgeEnabled.ts:51
return feature('BRIDGE_MODE') // isBridgeOutboundEnabled
// bridge/bridgeEnabled.ts:127
return feature('BRIDGE_MODE') // isRemoteControlEnabled
CCR_MIRROR(4 处引用)是 BRIDGE_MODE 的一个子模式——只读镜像,允许远程观察而不控制。
TRANSCRIPT_CLASSIFIER:auto 模式
TRANSCRIPT_CLASSIFIER(107 处引用)是引用数第二多的 flag,它实现了一个全新的权限模式——auto:
// types/permissions.ts:35
...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const))
在现有的 plan(需确认每个工具调用)和 auto-accept(自动接受所有)之间,auto 模式引入了一个基于会话记录分类的中间地带。系统使用分类器分析会话内容来动态决定是否需要用户确认。
checkAndDisableAutoModeIfNeeded(REPL.tsx:2772)暗示 auto 模式有安全降级机制——当分类器检测到风险操作时,可以自动退出 auto 模式回到需要确认的状态。
BASH_CLASSIFIER(45 处引用)是 TRANSCRIPT_CLASSIFIER 的一个相关组件,专门用于 Bash 命令的分类和安全评估。
CONTEXT_COLLAPSE:精细化上下文管理
CONTEXT_COLLAPSE(20 处引用)深度集成在 compact 子系统中:
// services/compact/autoCompact.ts:179
if (feature('CONTEXT_COLLAPSE')) { ... }
// services/compact/autoCompact.ts:215
if (feature('CONTEXT_COLLAPSE')) { ... }
从集成点来看,CONTEXT_COLLAPSE 在 autoCompact、postCompactCleanup、sessionRestore 和 query 引擎中都有存在。它引入了一个 CtxInspectTool(tools.ts:110),允许模型主动检查和管理上下文窗口的状态。与当前的全量压缩不同,CONTEXT_COLLAPSE 可能实现了更精细的“折叠“语义——可以选择性地折叠某些工具调用的结果,而保留其他关键上下文。
REACTIVE_COMPACT(4 处引用)是另一个压缩实验——响应式触发,而非基于 token 阈值的定时触发。
TEAMMEM:团队记忆同步
TEAMMEM(51 处引用)实现了跨会话的团队知识同步:
// services/teamMemorySync/watcher.ts:253
if (!feature('TEAMMEM')) { return }
团队记忆系统包含三个核心组件:
- watcher(
teamMemorySync/watcher.ts):监视团队记忆文件的变化 - secretGuard(
teamMemSecretGuard.ts):防止敏感信息泄漏到团队记忆中 - memdir 集成(
memdir/memdir.ts):将团队记忆层纳入 memdir 路径系统
从引用模式来看,TEAMMEM 的实现相当成熟——51 处引用覆盖了记忆读写、提示词构建、secret 扫描和文件同步等完整流程。
23.4 从 Flag 集群推断系统演进方向
集群一:自主 Agent 生态
KAIROS + PROACTIVE + KAIROS_BRIEF + KAIROS_CHANNELS + KAIROS_DREAM + KAIROS_PUSH_NOTIFICATION + KAIROS_GITHUB_WEBHOOKS + AGENT_TRIGGERS + AGENT_TRIGGERS_REMOTE + BG_SESSIONS + COORDINATOR_MODE + BUDDY + ULTRAPLAN + VERIFICATION_AGENT + MONITOR_TOOL
这是最大的 flag 集群(15+ 个),其逻辑关系可以还原为:
KAIROS (核心)
│
┌─────────────┼──────────────┐
│ │ │
PROACTIVE KAIROS_BRIEF KAIROS_DREAM
(自主执行权) (简报通信) (空闲记忆整理)
│ │
│ ┌────┴────┐
│ │ │
│ CHANNELS PUSH_NOTIFICATION
│ (多通道) (状态推送)
│
┌────┴────┐
│ │
BG_SESSIONS AGENT_TRIGGERS
(后台会话) (定时触发)
│ │
│ AGENT_TRIGGERS_REMOTE
│ (远程触发)
│
COORDINATOR_MODE ── ULTRAPLAN
(跨 agent 协调) (结构化计划)
│
│
BUDDY VERIFICATION_AGENT
(伴侣 UI) (自动验证)
│
MONITOR_TOOL
(进程监控)
图 23-2:自主 Agent Flag 集群关系图
这个集群描绘了一个从“被动响应用户输入“到“主动在后台持续工作“的演进路径。KAIROS 是核心引擎,PROACTIVE 提供焦点感知的自主权,AGENT_TRIGGERS 提供定时唤醒,BG_SESSIONS 提供后台持久化,COORDINATOR_MODE 提供多 agent 编排。
集群二:远程/分布式能力
BRIDGE_MODE + DAEMON + SSH_REMOTE + DIRECT_CONNECT + CCR_AUTO_CONNECT + CCR_MIRROR + CCR_REMOTE_SETUP + SELF_HOSTED_RUNNER + BYOC_ENVIRONMENT_RUNNER + LODESTONE
这个集群围绕“在用户之外的环境中运行 Claude Code“展开:
| 能力层 | Flag | 说明 |
|---|---|---|
| 协议层 | LODESTONE | 注册 lodestone:// 协议处理器 |
| 传输层 | BRIDGE_MODE, UDS_INBOX | WebSocket 桥接 + Unix Socket IPC |
| 连接层 | SSH_REMOTE, DIRECT_CONNECT | SSH 和直连两种接入方式 |
| 管理层 | CCR_AUTO_CONNECT, CCR_MIRROR | 自动连接、只读镜像 |
| 执行层 | DAEMON, SELF_HOSTED_RUNNER, BYOC | 守护进程、自托管、BYOC 运行器 |
| 同步层 | DOWNLOAD/UPLOAD_USER_SETTINGS | 配置云同步 |
表 23-7:远程/分布式能力分层
集群三:上下文智能
CONTEXT_COLLAPSE + REACTIVE_COMPACT + CACHED_MICROCOMPACT + COMPACTION_REMINDERS + TOKEN_BUDGET + PROMPT_CACHE_BREAK_DETECTION + HISTORY_SNIP
这些 flag 描述了对上下文管理的持续优化。与第9-12章分析的现有 compact 机制相比,这些 flag 代表了下一代上下文管理:
- 从定时压缩到响应式压缩(REACTIVE_COMPACT)
- 从全量压缩到选择性折叠(CONTEXT_COLLAPSE)
- 从被动到主动的缓存管理(PROMPT_CACHE_BREAK_DETECTION)
- 从隐式到显式的预算控制(TOKEN_BUDGET)
集群四:安全分类与权限
TRANSCRIPT_CLASSIFIER + BASH_CLASSIFIER + ANTI_DISTILLATION_CC + NATIVE_CLIENT_ATTESTATION + HARD_FAIL
这个集群围绕“更精细的安全控制“展开。TRANSCRIPT_CLASSIFIER 的 auto 模式是一个重要的方向——它代表了从“二元权限“(全部确认或全部接受)到“智能权限“(基于内容分析动态决策)的转变。ANTI_DISTILLATION_CC 则暗示了对模型输出的知识产权保护机制。
23.5 Flag 成熟度光谱
将 89 个 flag 按引用次数绘制成光谱,可以观察到几个有趣的分布特征:
引用数 Flag 数量 成熟度阶段
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
100+ 2 深度集成期 ██
30-99 6 全面织入期 ██████
10-29 12 模块集成期 ████████████
3-9 27 初步实现期 ███████████████████████████
1-2 42 原型探索期 ██████████████████████████████████████████
图 23-3:89 个 Flag 的成熟度分布
分布呈现明显的长尾特征:47% 的 flag(42 个)只有 1-2 处引用,处于原型或纯开关阶段。只有 2 个 flag 达到了 100+ 引用的深度集成状态。这符合软件产品的典型功能漏斗——大量探索性实验中,只有少数最终成为核心功能。
值得注意的是引用数与跨模块分布之间的区别。KAIROS 的 154 处引用分布在 main.tsx、REPL.tsx、commands.ts、prompts.ts、print.ts、sessionStorage.ts 等至少 15 个文件中——这种广泛的集成意味着启用 KAIROS 需要触及系统的多个切面。相比之下,TEAMMEM 虽然有 51 处引用,但主要集中在 memdir/、teamMemorySync/ 和 services/mcp/ 中——这种局部化的集成更容易被独立启用和测试。
23.6 构建配置推断
从 flag 的门控模式,可以推断出至少三种构建配置:
公开构建(Public Build)
绝大多数 flag 为 false。已知公开启用的功能(如基础技能系统、工具链)不需要 flag 门控——它们是源码的“默认路径“。
内部构建(Ant Build)
USER_TYPE === 'ant' 检查出现在多个技能的注册逻辑中(verify.ts:13、remember.ts:5、stuck.ts 等)。内部构建启用了更多的实验性功能,包括 EXPERIMENTAL_SKILL_SEARCH、SKILL_IMPROVEMENT 等。
实验构建(Experiment Build)
某些 flag 组合可能代表 A/B 测试配置——TREE_SITTER_BASH 和 TREE_SITTER_BASH_SHADOW 的命名模式暗示了一个“影子模式“实验:新的 Bash 解析器在后台运行并比较结果,但不影响用户可见的行为。类似地,ABLATION_BASELINE 明确标识了消融实验的基线配置。
23.7 未发布功能间的依赖关系
某些 flag 之间存在隐式依赖,可以从代码中的 && 组合推断:
// commands.ts:77
feature('DAEMON') && feature('BRIDGE_MODE') // daemon 依赖 bridge
// skills/bundled/index.ts:35
feature('KAIROS') || feature('KAIROS_DREAM') // dream 可独立于完整 KAIROS
// main.tsx:1728
(feature('KAIROS') || feature('KAIROS_BRIEF')) && baseTools.length > 0
// main.tsx:2184
(feature('KAIROS') || feature('KAIROS_BRIEF'))
&& !getIsNonInteractiveSession()
&& !getUserMsgOptIn()
&& getInitialSettings().defaultView === 'chat'
关键依赖关系:
| 依赖方 | 被依赖方 | 关系 |
|---|---|---|
| DAEMON | BRIDGE_MODE | 必须同时启用 |
| KAIROS_DREAM | KAIROS | 可独立,但通常共存 |
| KAIROS_BRIEF | KAIROS | 可独立启用 |
| KAIROS_CHANNELS | KAIROS | 通常共存 |
| CCR_MIRROR | BRIDGE_MODE | CCR_MIRROR 是 BRIDGE 的子模式 |
| CCR_AUTO_CONNECT | BRIDGE_MODE | 需要 Bridge 基础设施 |
| AGENT_TRIGGERS_REMOTE | AGENT_TRIGGERS | 远程是本地的扩展 |
| MCP_SKILLS | MCP 基础设施 | 扩展现有 MCP 客户端 |
表 23-8:Flag 间主要依赖关系
23.8 对现有架构的影响
这 89 个 flag 对现有架构的影响可以从几个层面理解:
上下文管理层
CONTEXT_COLLAPSE 和 REACTIVE_COMPACT 将改变我们在第9-11章分析的压缩机制。当前的 autoCompact 基于 token 阈值的定时检查可能被替换为更精细的响应式策略——在工具调用返回大量结果时立即触发局部折叠,而不是等到整体 token 数超过阈值。
权限层
TRANSCRIPT_CLASSIFIER 的 auto 模式代表了权限系统的一次范式转变。当前的二元模型(plan vs auto-accept)可能演进为三元模型,其中 auto 模式使用 ML 分类器实时评估每个操作的风险等级。
工具层
WEB_BROWSER_TOOL、TERMINAL_PANEL、MONITOR_TOOL 等新工具扩展了 agent 的感知和行动能力。特别是 WEB_BROWSER_TOOL 对 Bun WebView 的依赖,意味着浏览器能力将是原生集成的,而非通过外部进程(如 Playwright)实现。
执行模型层
KAIROS + DAEMON + BRIDGE_MODE 共同指向一个“后台持续运行“的执行模型——Claude Code 不再仅仅是一个交互式 REPL,而是可以作为守护进程在后台持续工作,通过 Bridge 远程控制,通过 Push Notification 报告进度。
模式提炼
从 Feature Flag 系统的设计中,可以提取以下可复用的模式:
模式一:构建时 Dead Code Elimination
- 解决的问题:实验性代码不应出现在生产构建中
- 模式:
feature('FLAG')在编译期被替换为字面量true/false,if (false) { require(...) }整个分支及其依赖被 tree-shaking 移除 - 前置条件:构建工具支持编译期常量替换和 dead code elimination
模式二:引用计数推断成熟度
- 解决的问题:在大型代码库中评估实验性功能的集成深度
- 模式:统计 flag 在源码中的引用次数和跨模块分布——100+ 引用意味着深度集成,1-2 引用意味着原型阶段
- 前置条件:flag 命名一致且通过统一 API 访问
模式三:Flag 集群依赖管理
- 解决的问题:相关功能之间的启用顺序和依赖关系
- 模式:通过
feature('A') && feature('B')表达硬依赖,通过feature('A') || feature('B')表达软关联;子功能可独立于父功能启用(如KAIROS_DREAM可独立于完整KAIROS) - 前置条件:功能之间存在层次化的依赖关系
用户能做什么
理解 Feature Flag 以更好地使用 Claude Code:
-
检查可用的实验性功能。部分 flag 通过环境变量暴露给用户——如
CLAUDE_CODE_COORDINATOR_MODE控制协调者模式。查阅官方文档了解哪些实验性功能可以通过环境变量启用。 -
理解构建版本的差异。公开构建、内部构建(
USER_TYPE=ant)和实验构建有不同的功能集。如果你在使用企业版或内部版本,可能有更多功能可用(如verify、remember、stuck等技能)。 -
关注 KAIROS 相关的助手模式。KAIROS 是引用最多的 flag(154 处),代表了 Claude Code 向“后台自主 agent“演进的方向。当这些功能逐步公开时,理解其终端焦点感知、定时唤醒、简报通信等机制有助于更好地利用它们。
-
注意 auto 权限模式的出现。TRANSCRIPT_CLASSIFIER 引入的
auto权限模式是介于plan(全部确认)和auto-accept(全部接受)之间的智能中间地带。当它公开可用时,它可能是大多数用户的最佳默认选择。 -
理解 Flag 的存在不等于功能承诺。89 个 flag 中 47% 只有 1-2 处引用,处于原型阶段。不要基于源码中的 flag 存在来预期功能发布——flag 的本质是让团队安全地探索和实验。
23.9 小结
89 个 Feature Flag 揭示了 Claude Code 远超当前公开功能的工程深度。按功能域分类:
- 自主 Agent 生态(18 个 flag):以 KAIROS 为核心,构建后台自主执行、定时触发、多 agent 协调的完整能力栈
- 远程/分布式执行(14 个 flag):Bridge + Daemon + SSH/Direct Connect,实现跨机器的远程控制和分布式运行
- 上下文管理优化(17 个 flag):从定时全量压缩到响应式选择性折叠的演进
- 记忆与知识管理(9 个 flag):团队记忆同步、自动记忆提取、技能自我改进
- UI/UX 与平台能力(31 个 flag):语音输入、浏览器集成、终端面板等新交互模态
从成熟度分布来看,KAIROS(154 引用)和 TRANSCRIPT_CLASSIFIER(107 引用)是最深度集成的两个系统——它们的代码痕迹已经深入 Claude Code 的核心架构。而 42 个只有 1-2 处引用的 flag 则代表了大量的探索性实验,其中大部分可能永远不会成为公开功能。
这些 flag 共同描绘了 Claude Code 从“交互式编码助手“向“后台自主开发 agent“演进的工程准备。不过,源码中的存在不等同于产品计划——feature flag 的本质是让团队能够安全地探索和实验,而不必承诺每个实验都将成为产品。
第24章:驾驭工程原则
为什么这很重要
在前六篇中,我们从源码层面剖析了 Claude Code 的每一个子系统——工具注册、Agent Loop、系统提示词、上下文压缩、提示词缓存、权限安全、技能系统。这些分析揭示了大量的实现细节,但如果只停留在“它是怎么做的“层面,就浪费了逆向工程最有价值的产出:可复用的工程原则。
本章从前 23 章的源码分析中提炼出 6 条驾驭工程(Harness Engineering)核心原则。每条原则都有明确的源码回溯、适用场景和反模式警示。这些原则的共同主题是:在 AI Agent 系统中,控制行为的最佳方式不是编写更多代码,而是设计更好的约束。
源码分析
24.1 原则一:提示词即控制面
定义:用系统提示词段落引导模型行为,而非用代码逻辑硬编码限制。
Claude Code 的行为引导绝大多数通过提示词实现,而非通过代码中的 if/else 分支。最典型的例子是极简主义指令:
// restored-src/src/constants/prompts.ts:203
"Don't create helpers, utilities, or abstractions for one-time operations.
Don't design for hypothetical future requirements. The right amount of
complexity is what the task actually requires — no speculative abstractions,
but no half-finished implementations either. Three similar lines of code
is better than a premature abstraction."
这段文本不是代码注释——它是发送给模型的实际指令。Claude Code 没有在代码层面检测模型是否过度工程化(这在技术上几乎不可能),而是通过自然语言直接告诉模型“不要这么做“。
同样的模式贯穿整个系统提示词架构(详见第5章)。systemPromptSections.ts 将系统提示词组织为多个可组合的段落,每个段落都有明确的缓存范围(scope: 'global' 或 null)。这种设计使得行为调整只需修改文本,不需要改代码、改测试、走发布流程。
工具提示词是这一原则的精华体现(详见第8章)。BashTool 的 Git 安全协议——“绝不跳过 hooks、绝不 amend、优先指定文件 git add”——完全由提示词文本表达。如果某天团队决定允许 amend,只需删除一行提示词文本,无需触碰任何执行逻辑。
适用边界:用代码处理结构性约束(权限、token 预算),用提示词处理行为性约束(风格、策略、偏好)。
反模式:行为硬编码。为每种不希望的模型行为编写检测器和拦截器,最终得到一个庞大的规则引擎,永远追不上模型能力的变化速度。
24.2 原则二:缓存感知设计是刚需
定义:每次提示词变更都有以 cache_creation token 计量的成本,系统设计必须将缓存稳定性作为一等约束。
SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记(restored-src/src/constants/prompts.ts:114-115)将系统提示词分为两个区域:
// restored-src/src/constants/prompts.ts:105-115
/**
* Boundary marker separating static (cross-org cacheable) content
* from dynamic content.
* Everything BEFORE this marker in the system prompt array can use
* scope: 'global'.
* Everything AFTER contains user/session-specific content and should
* not be cached.
*/
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
'__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
splitSysPromptPrefix()(restored-src/src/utils/api.ts:321-435)实现了三条代码路径来确保缓存断点放置正确:MCP 存在时的 tool-based 缓存、全局缓存+边界标记、默认 org 级别缓存。这个函数的复杂度完全来自缓存优化需求——如果不关心缓存,只需拼接字符串即可。
缓存中断检测系统(详见第14章)追踪近 20 个字段的前后状态变化(restored-src/src/services/api/promptCacheBreakDetection.ts:28-69),包括 systemHash、toolsHash、cacheControlHash、perToolHashes、betas 等。任何一个字段的变化都可能触发缓存失效。
Beta Header 锁存机制是极端案例:一旦发送过某个 beta header,就永远继续发送,即使对应功能已关闭——因为取消发送会改变请求签名,导致约 50-70K token 的缓存前缀失效。源码中的注释明确记录了锁存的原因:
// restored-src/src/services/api/promptCacheBreakDetection.ts:47-48
/** AFK_MODE_BETA_HEADER presence — should NOT break cache anymore
* (sticky-on latched in claude.ts). Tracked to verify the fix. */
日期记忆化(getSessionStartDate())是另一个例证:如果会话跨越午夜,模型看到的日期会“过期“——但这是有意为之,因为日期字符串变化会打断缓存前缀。
反模式:提示词频繁变动。Agent 列表曾内联在系统提示词中,占全球 cache_creation token 的 10.2%(详见第15章)。解决方案是将其移至 system-reminder 消息——这部分在缓存段之外,修改不影响缓存。
24.3 原则三:失败关闭,显式开放
定义:系统默认值应选择最安全的选项,只有在显式声明后才允许危险操作。
buildTool() 工厂函数为每个工具属性设置了防御性默认值:
// restored-src/src/Tool.ts:748-761
/**
* Defaults (fail-closed where it matters):
* - `isConcurrencySafe` → `false` (assume not safe)
* - `isReadOnly` → `false` (assume writes)
* - `isDestructive` → `false`
* - `checkPermissions` → `{ behavior: 'allow', updatedInput }`
* (defer to general permission system)
* - `toAutoClassifierInput` → `''`
* (skip classifier — security-relevant tools must override)
*/
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?: unknown) => false,
isReadOnly: (_input?: unknown) => false,
...
}
这意味着新工具默认不可并发执行——partitionToolCalls()(restored-src/src/services/tools/toolOrchestration.ts:91-116)会将未声明 isConcurrencySafe: true 的工具放入串行队列。当 isConcurrencySafe 的调用抛出异常时,catch 块也返回 false——保守方向的兜底:
// restored-src/src/services/tools/toolOrchestration.ts:98-108
const isConcurrencySafe = parsedInput?.success
? (() => {
try {
return Boolean(tool?.isConcurrencySafe(parsedInput.data))
} catch {
// If isConcurrencySafe throws, treat as not concurrency-safe
// to be conservative
return false
}
})()
: false
权限系统遵循同样的原则(详见第16章)。权限模式从最严格到最宽松:default → acceptEdits → plan → bypassPermissions → auto → dontAsk。系统默认使用 default——用户必须主动选择更宽松的模式。
YOLO 分类器的拒绝追踪是另一种体现(restored-src/src/utils/permissions/denialTracking.ts:12-15):DENIAL_LIMITS 设定连续 3 次或总计 20 次被分类器拒绝后,系统自动回退到用户手动确认——在自动化决策不可靠时,回退到人类决策(完整代码详见第26章模式二)。
反模式:默认开放,出事再关。工具默认可并发执行,某个有副作用的工具在并行执行中产生竞态条件——这种 bug 极难复现和诊断。
24.4 原则四:A/B 测试一切
定义:行为变更先在内部用户群体中验证,通过数据确认后再扩展到所有用户。
Claude Code 拥有 89 个 Feature Flag(详见第23章),其中相当一部分用于 A/B 测试。最值得关注的不是 flag 数量,而是门控模式。
USER_TYPE === 'ant' 门控是最直接的暂存机制(详见第7章)。源码中存在大量的 ant-only 段落,例如 Capybara v8 的过度注释缓解措施:
// restored-src/src/constants/prompts.ts:205-213
...(process.env.USER_TYPE === 'ant'
? [
`Default to writing no comments. Only add one when the WHY
is non-obvious...`,
// @[MODEL LAUNCH]: capy v8 thoroughness counterweight
// (PR #24302) — un-gate once validated on external via A/B
`Before reporting a task complete, verify it actually works...`,
]
: []),
注释 un-gate once validated on external via A/B 清晰展示了这个流程:先在内部验证,确认有效后通过 A/B 测试推广给外部用户。
GrowthBook 集成提供了更精细的实验能力:tengu_* 前缀的 Feature Flag 通过远程配置服务器控制,支持按百分比灰度。_CACHED_MAY_BE_STALE 和 _CACHED_WITH_REFRESH 两种缓存策略的存在(详见第7章),体现了“缓存感知的 A/B 测试“——flag 值的切换不应导致缓存失效。
反模式:Big Bang 发布。直接将行为变更推送给所有用户。在 AI Agent 领域,行为变更的影响通常不是“崩溃“而是“不够好“或“太激进“——需要量化度量和对照组才能发现。
24.5 原则五:先观察再修复
定义:在尝试修复问题之前,先建立可观测性基础设施来理解问题的全貌。
缓存中断检测系统(restored-src/src/services/api/promptCacheBreakDetection.ts)是这一原则的典范。这个系统不修复任何问题——它的全部职责是观察和报告:
- 调用前:
recordPromptState()记录近 20 个字段的快照 - 调用后:
checkResponseForCacheBreak()对比前后状态,识别哪个字段变化 - 生成解释:翻译为人类可读原因——“system prompt changed”、“TTL likely expired”
- 生成 Diff:
createPatch()输出前后提示词状态对比
特别值得注意的是 PreviousState 中的注释风格(restored-src/src/services/api/promptCacheBreakDetection.ts:36-37):
/** Per-tool schema hash. Diffed to name which tool's description changed
* when toolSchemasChanged but added=removed=0 (77% of tool breaks per
* BQ 2026-03-22). AgentTool/SkillTool embed dynamic agent/command lists. */
perToolHashes: Record<string, number>
这里引用了具体的 BigQuery 查询日期和百分比数据(77%),说明团队在用数据驱动可观测性的粒度设计——不是随意追踪所有字段,而是基于生产数据发现“大多数工具 Schema 变化来自某个特定工具的描述变动“,然后有针对性地添加 per-tool 哈希。
YOLO 分类器的 CLAUDE_CODE_DUMP_AUTO_MODE=1(详见第17章)遵循同样模式:提供完整的输入/输出导出能力,让开发者精确理解“分类器为什么拒绝了这个操作“。
反模式:凭直觉修复。看到缓存命中率下降就回滚最近修改,但实际原因可能是 Beta Header 切换、TTL 过期、或 MCP 工具列表变化。
24.6 原则六:锁存(Latch)以求稳定
定义:一旦进入某个状态,就不再摇摆——状态抖动比次优状态更有害。
“锁存”(Latch)模式在 Claude Code 中有多处体现:
Beta Header 锁存(详见第13章):afkModeHeaderLatched、fastModeHeaderLatched、cacheEditingHeaderLatched。会话中首次发送某个 Beta Header 后,后续所有请求继续发送,即使功能已关闭。原因:取消发送改变请求签名,导致缓存前缀失效。
缓存 TTL 资格锁存(详见第13章):should1hCacheTTL() 在会话中只执行一次,结果被锁存。源码注释(promptCacheBreakDetection.ts:50-51)确认:
/** Overage state flip — should NOT break cache anymore (eligibility is
* latched session-stable in should1hCacheTTL). Tracked to verify the fix. */
isUsingOverage: boolean
自动压缩熔断器(restored-src/src/services/compact/autoCompact.ts:67-70):
// Stop trying autocompact after this many consecutive failures.
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures
// (up to 3,272) in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
连续 3 次失败后锁存到“停止压缩“状态。注释中的 BigQuery 数据(1,279 个会话、250K API 调用/天)提供了充分的工程理由。
反模式:状态抖动。每次请求都重新计算配置,导致状态在不同值之间切换。在缓存系统中意味着缓存键不断变化,命中率趋近于零。
模式提炼
六条原则汇总表
| 原则 | 核心源码回溯 | 反模式 |
|---|---|---|
| 提示词即控制面 | prompts.ts:203 — “三行代码优于过早抽象” | 行为硬编码:为每种不希望的行为写检测器 |
| 缓存感知设计 | prompts.ts:114 — 动态边界标记 | 提示词频繁变动:agent 列表内联占 10.2% cache_creation |
| 失败关闭 | Tool.ts:748-761 — isConcurrencySafe: false | 默认开放:新工具直接可并发,出竞态再修 |
| A/B 测试一切 | prompts.ts:210 — un-gate once validated via A/B | Big Bang 发布:变更直接推送所有用户 |
| 先观察再修复 | promptCacheBreakDetection.ts:36 — 77% 数据驱动 | 凭直觉修复:不看数据直接回滚 |
| 锁存以求稳定 | autoCompact.ts:68-70 — 250K API 调用/天的教训 | 状态抖动:每次请求重新计算所有状态 |
表 24-1:驾驭工程六原则汇总
原则间的关系
graph TD
A["原则1: 提示词即控制面<br />行为引导的主要手段"] --> B["原则2: 缓存感知设计<br />提示词变更有成本"]
B --> F["原则6: 锁存以求稳定<br />避免缓存抖动"]
A --> C["原则3: 失败关闭<br />安全默认值"]
C --> D["原则4: A/B 测试一切<br />验证后再开放"]
D --> E["原则5: 先观察再修复<br />数据驱动决策"]
E --> B
图 24-1:六条驾驭工程原则的关系图
从提示词即控制面出发:既然行为主要由提示词控制,提示词变更就需要缓存感知设计来控制成本,需要锁存以求稳定来防止抖动。行为的安全边界由失败关闭保障,从关闭到开放的过渡需要A/B 测试验证。出现问题时,先观察再修复确保理解全貌后再行动,观察结果反馈到缓存感知设计中。
模式:提示词驱动行为控制
- 解决的问题:如何引导 AI 模型行为而不与模型能力迭代产生耦合
- 核心做法:用自然语言提示词表达行为期望,用代码仅处理结构性约束
- 前置条件:模型具备足够的指令跟随能力
模式:缓存前缀稳定化
- 解决的问题:提示词缓存因微小变动频繁失效
- 核心做法:静态/动态边界分离 + 日期记忆化 + Header 锁存 + Schema 缓存
- 前置条件:使用支持前缀缓存的 API
模式:失败关闭默认值
- 解决的问题:新增组件引入安全或并发风险
- 核心做法:所有属性默认为最安全值,显式声明才能解锁
- 前置条件:有明确的“安全“和“不安全“定义
用户能做什么
- 将行为指令与代码逻辑分离。创建行为配置文件(类似 CLAUDE.md),让行为调整不需要代码变更
- 在引入提示词缓存前,先设计缓存边界。区分跨用户共享内容和会话级内容
- 审查你的默认值。对每个配置项问:如果用户不设置它,系统的行为是最安全的还是最危险的?
- 为关键行为变更设计灰度方案。即使只有两个用户群体(内部/外部),也比全量发布安全
- 在修复之前添加日志。缓存命中率下降或模型行为异常时,先记录完整上下文,再尝试修复
- 识别系统中的“锁存点“。哪些状态在会话生命周期中不应该变化?提前设计稳定性机制
第25章:上下文管理作为核心能力
为什么这很重要
如果从 Claude Code 的整个代码库中挑出一个最被低估的子系统,那一定是上下文管理。权限系统引人注目,Agent Loop 是核心,提示词工程广为人知——但上下文管理才是决定一个 AI Agent 能否“持续有效工作“的关键基础设施。
200K token 的上下文窗口看似充裕,但在真实工作场景中消耗得比想象更快:系统提示词约 15-20K,每次工具调用结果 5-50K,几轮文件读取和代码搜索后就已经用掉一半。更关键的是,上下文窗口不仅是“容量“问题——它是“信息密度“问题。当窗口中充满过期的工具结果、冗余的文件内容和已解决的讨论时,模型的注意力被稀释,回答质量下降。
第三篇(第9-12章)分析的上下文管理系统揭示了 5 条核心原则,共同主题是:上下文窗口是稀缺资源,必须像管理内存一样精心管理。
源码分析
25.1 原则一:为一切设定预算
定义:每个进入上下文窗口的内容都必须有明确的 token 预算上限,没有例外。
Claude Code 的预算体系覆盖了上下文窗口中的每一个内容来源:
| 内容来源 | 预算限制 | 源码位置 |
|---|---|---|
| 单个工具结果 | 50K 字符 | restored-src/src/constants/toolLimits.ts:13 |
| 单条消息中的所有工具结果 | 200K 字符 | restored-src/src/constants/toolLimits.ts:49 |
| 文件读取 | 默认 2000 行 + offset/limit 渐进读取 | 详见第8章 |
| 技能列表 | 上下文窗口的 1% | restored-src/src/tools/SkillTool/prompt.ts:20-23 |
| 压缩后文件恢复 | 最多 5 个文件、单文件 5K token、总计 50K | restored-src/src/services/compact/compact.ts:122 |
| 压缩后技能恢复 | 单技能 5K token、总计 25K token | 详见第10章 |
| Agent 描述列表 | 移至附件以控制主提示词大小 | 详见第15章 |
表 25-1:Claude Code 的 token 预算体系
注意设计的精细程度:不仅有“总预算“,还有“单项预算“。这两者的来源:
// restored-src/src/constants/toolLimits.ts:13
export const DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000
// restored-src/src/constants/toolLimits.ts:49
export const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000 防止 N 个并行工具同时返回大结果导致上下文洪泛——即使每个工具结果在 50K 以内,10 个并行工具也能产出 500K 字符。单消息预算是对这种“合法但危险“组合的防护。
技能列表的 1% 预算尤其值得关注:
// restored-src/src/tools/SkillTool/prompt.ts:20-23
// Skill listing gets 1% of the context window (in characters)
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4
随着用户安装越来越多的技能,技能列表可能无限增长。Claude Code 的解决方案是三级截断级联:先截断描述(MAX_LISTING_DESC_CHARS = 250)、再截断低优先级技能、最后只保留内置技能的名称。这确保技能列表永远不会占据超过上下文窗口 1% 的空间——哪怕用户安装了 1000 个技能。
反模式:无界内容注入。将工具结果、文件内容或配置信息不加限制地注入上下文窗口,最终导致上下文被低信息密度内容填满。
25.2 原则二:保留重要内容
定义:压缩是必要的,但压缩后必须有选择性地恢复最关键的上下文。
自动压缩(详见第9章)将整个对话历史压缩为摘要,释放上下文空间。但压缩丢失了具体的代码内容、文件路径和精确行号引用。如果压缩后模型完全失去之前读过的文件内容,它就需要重新读取,浪费工具调用和用户等待时间。
Claude Code 的解决方案是压缩后恢复(详见第10章):
// restored-src/src/services/compact/compact.ts:122
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5
恢复策略的流程:
graph LR
A["压缩前快照<br />cacheToObject()"] --> B["执行压缩<br />对话→摘要"]
B --> C["选择性恢复"]
C --> D["最近 5 个文件<br />单文件 ≤5K token"]
C --> E["总预算 ≤50K token"]
C --> F["技能重注入<br />单技能 ≤5K, 总计 ≤25K"]
图 25-1:压缩-恢复流程
恢复策略的关键是选择性:不是恢复所有文件,而是最近 5 个;不是恢复完整文件内容,而是在 5K token 内截断;总量不超过 50K。这些数字反映了深思熟虑的权衡:恢复太多等于没压缩,恢复太少等于压缩过度。
技能恢复的设计同样精细。压缩后不重注入已发送技能的名称(sentSkillNames),因为模型仍持有 SkillTool 的 Schema——它知道技能系统存在,只是忘记了具体的技能内容。这节省了约 4K token。
反模式:全量压缩或全量保留。要么什么都不恢复(模型被迫从头开始),要么试图保留一切(压缩效果为零)。
25.3 原则三:告知而非隐藏
定义:当内容被截断或压缩时,必须告知模型发生了什么,让它能够主动获取完整信息。
Claude Code 在多个层面实践这一原则:
工具结果截断通知。当工具结果超过 50K 字符(DEFAULT_MAX_RESULT_SIZE_CHARS)时,完整结果写入磁盘(restored-src/src/utils/toolResultStorage.ts),模型收到预览消息,包含截断说明和完整内容的磁盘路径。模型因此知道:(1) 当前看到的不是全部,(2) 如何获取全部。
缓存微压缩通知(详见第11章)。当 cache_edits 删除旧工具结果时,notifyCacheDeletion() 告知模型“某些旧工具结果已被清理“。防止模型引用已不存在的内容。
文件读取分页。FileReadTool 默认读取 2000 行,通过 offset/limit 参数支持分页。工具描述中明确说明了这一行为——模型知道默认只看到前 2000 行,需要后面内容时可指定 offset。
压缩摘要中的显式声明。压缩提示词(详见第9章)要求摘要包含“进行到哪一步了“和“还需要做什么“——确保压缩后的模型知道自己处于任务的哪个阶段。压缩提示词中的 <analysis> 草稿块(restored-src/src/services/compact/prompt.ts:31)让模型先分析对话内容,再生成结构化摘要——分析块在格式化时被移除,不占用最终上下文空间。
反模式:静默截断。在模型不知情的情况下截断工具结果或删除上下文内容。模型可能基于不完整信息做出错误决策,或“编造“它记不清的内容——因为它不知道自己的信息是不完整的。
25.4 原则四:熔断(Circuit Breaker)失控循环
定义:当自动化流程连续失败时,必须有机制强制停止,而非无限重试。
自动压缩的熔断器是最直接的实现。MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3(restored-src/src/services/compact/autoCompact.ts:70)——连续 3 次失败后停止尝试。源码注释(详见第24章原则六中的完整代码引用)记录了这个数字的工程理由:BigQuery 数据显示 1,279 个会话出现过 50+ 次连续压缩失败(最高达 3,272 次),每天浪费约 250K 次 API 调用。
更广泛地看,Claude Code 在多个子系统中实现了类似的熔断机制:
| 子系统 | 熔断条件 | 熔断行为 | 源码位置 |
|---|---|---|---|
| 自动压缩 | 连续 3 次失败 | 停止压缩直到会话结束 | autoCompact.ts:70 |
| YOLO 分类器 | 连续 3 次/总计 20 次拒绝 | 回退到用户手动确认 | denialTracking.ts:12-15 |
| max_output_tokens 恢复 | 最多 3 次重试 | 停止重试,接受截断输出 | 详见第3章 |
| Prompt-too-long | 丢弃最旧轮次 → 丢弃 20% | 降级处理,不无限丢弃 | 详见第9章 |
表 25-2:Claude Code 的熔断器一览
每个熔断器遵循相同模式:设定合理重试上限,超过后降级到安全但功能受限的状态,而非崩溃或无限循环。
反模式:无限重试。“压缩失败了?再试。又失败了?换参数再试。“在 AI Agent 中尤其危险——每次重试消耗 API 调用(真金白银),且失败原因往往是系统性的(上下文大到无法在摘要 token 预算内压缩),重试不会改变结果。
25.5 原则五:保守估算
定义:在 token 计数和预算分配中,宁可高估消耗也不要低估——低估导致溢出,高估只是略微浪费空间。
Claude Code 的 token 估算在每个场景中都选择了保守方向(详见第12章):
| 内容类型 | 估算策略 | 保守程度 | 原因 |
|---|---|---|---|
| 普通文本 | 4 字节/token | 中等 | 英文实际约 3.5-4.5 |
| JSON 内容 | 2 字节/token | 高度保守 | 结构字符 token 化效率低 |
| 图片/文档 | 固定 2000 token | 高度保守 | 实际公式 width×height/750,但元数据不可用时用固定值 |
| 缓存 token | 从 API usage 获取 | 精确(当可用时) | 只有 API 返回的计数是权威的 |
表 25-3:Token 估算策略对照表
JSON 按 2 字节/token 估算是特别有意义的设计选择。JSON 结构字符({}、[]、""、:、,)的 token 化效率远低于自然语言——100 字节的 JSON 可能消耗 40-50 个 token,而 100 字节英文只需 25-30 个 token。如果使用 4 字节/token 的通用估算,JSON 密集的工具结果会被严重低估,可能导致上下文溢出。
技能列表预算中同样体现了这一点(restored-src/src/tools/SkillTool/prompt.ts:22):CHARS_PER_TOKEN = 4 用于将 token 预算转换为字符预算——用最保守的字符/token 比率来确保不会超支。
保守估算的收益远大于成本。高估 token 消耗的最坏结果是提前触发压缩——用户多等几秒。低估 token 消耗的最坏结果是 prompt_too_long 错误——API 调用失败,需要紧急丢弃上下文,可能丢失关键信息。
反模式:精确计数的幻觉。试图在客户端精确计算 token 数量。只有 API 服务端的 tokenizer 才能给出精确值——客户端的任何计数都是估算。既然是估算,就应该向安全方向偏移。
模式提炼
五条原则汇总表
| 原则 | 核心源码回溯 | 反模式 |
|---|---|---|
| 为一切设定预算 | toolLimits.ts:13,49 — 单项 50K、单消息 200K | 无界内容注入 |
| 保留重要内容 | compact.ts:122 — 恢复最近 5 个文件 | 全量压缩或全量保留 |
| 告知而非隐藏 | toolResultStorage.ts — 截断时提供磁盘路径 | 静默截断 |
| 熔断失控循环 | autoCompact.ts:70 — 连续 3 次失败后停止 | 无限重试 |
| 保守估算 | SkillTool/prompt.ts:22 — CHARS_PER_TOKEN = 4 | 精确计数的幻觉 |
表 25-4:上下文管理五原则汇总
原则间的关系
graph LR
A["为一切<br />设定预算"] --> B["保留<br />重要内容"]
B --> C["告知<br />而非隐藏"]
A --> D["熔断<br />失控循环"]
A --> E["保守<br />估算"]
E --> A
图 25-2:五条上下文管理原则的关系图
为一切设定预算是基础——定义每个内容来源的 token 上限。保留重要内容决定压缩后恢复什么,告知而非隐藏确保模型知道什么被截断了,熔断失控循环防止自动化流程超出预算,保守估算确保预算不被低估绕过。
模式:分层 Token 预算
- 解决的问题:多个内容来源竞争有限的上下文空间
- 核心做法:为每个来源设定独立预算 + 总量预算,截断级联处理超额
- 代码模板:单项限制(50K)→ 聚合限制(200K/消息)→ 全局限制(上下文窗口 - 输出预留 - 缓冲)
- 前置条件:能在注入前估算内容的 token 消耗
模式:压缩-恢复循环
- 解决的问题:压缩丢失关键上下文
- 核心做法:压缩前快照 → 压缩 → 选择性恢复最近/最重要的内容
- 前置条件:能追踪哪些内容是“最近使用的“
模式:熔断器
- 解决的问题:自动化流程在异常条件下无限循环
- 核心做法:连续 N 次失败后停止,降级到安全状态
- 前置条件:定义了“失败“的判定标准和降级后的行为
用户能做什么
- 审计 Agent 的上下文消耗。在真实场景中测量每个内容来源占用多少 token,找出最大消耗者
- 为工具结果设定大小限制。确保文件读取、数据库查询、API 调用的结果有字符/行数上限
- 实现压缩后恢复。如果你的 Agent 使用上下文压缩,设计恢复策略——让压缩后的模型不需要从零开始
- 截断时告知模型。告诉模型“这是截断的,完整版在哪里“——比静默截断后模型自己发现信息缺失好得多
- 添加熔断器。对任何可能循环执行的自动化流程设定重试上限。宁可降级也不要无限循环
第26章:生产级 AI 编码模式
为什么这很重要
前两章提炼的是“原则“——关于如何思考驾驭工程和上下文管理的高层指导。本章不同:我们聚焦于 6 个具体的、可直接复用的编码模式。每个模式都从 Claude Code 的实际实现中提取,有明确的问题定义、实现方式和源码证据。
这些模式有一个共同特点:它们看起来简单到不值一提,但在生产环境中被反复验证为必要。“编辑前先读取”——谁会不读就编辑?但 Claude Code 用工具报错来强制执行,因为 AI 模型确实会跳过读取直接编辑。“防御性 Git”——当然不该 force push,但 Claude Code 用整段提示词来强调这一点,因为模型在压力下确实会选择最短路径。
源码分析
26.1 模式一:编辑前先读取(Read Before Edit)
问题:AI 模型可能在没有读取文件当前内容的情况下尝试编辑,导致编辑基于过时或错误的假设。
Claude Code 通过双层保障来强制这一点:
- 提示词层(软约束):FileEditTool 的描述中明确写着“你必须在对话中至少使用过一次 Read 工具后才能编辑。如果你在未读取文件的情况下尝试编辑,该工具会报错“(详见第8章)
- 代码层(硬约束):FileEditTool 的
call()方法在执行编辑前检查当前对话是否包含对目标文件的 Read 调用。没有则返回错误
双层保障的设计意义在于:提示词是“软约束“——模型大多数时候会遵守,但在特定条件下(上下文过长导致指令被“遗忘“、多轮对话中注意力漂移)可能被忽略。代码层是“硬约束“——即使模型忽略提示词,工具本身也拒绝执行。
| 维度 | 描述 |
|---|---|
| 实现方式 | 提示词指令(软约束)+ 工具代码检查(硬约束) |
| 源码引用 | FileEditTool 提示词(详见第8章) |
| 适用场景 | 任何需要修改现有内容的工具 |
| 反模式 | 仅靠提示词指令,不在代码层强制执行 |
26.2 模式二:渐进式自主(Graduated Autonomy)
问题:AI Agent 需要在“每步都问用户“(效率低)和“什么都不问“(风险高)之间找到平衡。
Claude Code 设计了从最严格到最宽松的权限模式梯度(详见第16章):
default → acceptEdits → plan → bypassPermissions → auto → dontAsk
│ │ │ │ │ │
│ │ │ │ │ └── 完全自主
│ │ │ │ └── 分类器自动决策
│ │ │ └── 跳过权限检查
│ │ └── 仅计划不执行
│ └── 自动接受编辑,其他仍确认
└── 每步确认
关键设计不是模式本身,而是带回退的自动化。auto 模式使用 YOLO 分类器(详见第17章)自动做出权限决策,但有两个安全阀。拒绝追踪的实现非常简洁:
// restored-src/src/utils/permissions/denialTracking.ts:12-15
export const DENIAL_LIMITS = {
maxConsecutive: 3,
maxTotal: 20,
} as const
// restored-src/src/utils/permissions/denialTracking.ts:40-44
// export function shouldFallbackToPrompting(
state: DenialTrackingState
): boolean {
return (
state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
state.totalDenials >= DENIAL_LIMITS.maxTotal
)
}
当分类器连续 3 次或总计 20 次拒绝操作后,系统永久回退到用户手动确认。这意味着即使在最自主的模式下,系统也保留了回退到人类决策的能力。自主不是“全有或全无“,而是连续光谱,且光谱的每个位置都有安全网。
| 维度 | 描述 |
|---|---|
| 实现方式 | 多级权限模式 + 分类器自动决策 + 拒绝追踪回退 |
| 源码引用 | 权限模式(第16章)、YOLO 分类器(第17章)、denialTracking.ts:12-44 |
| 适用场景 | 任何需要人机协作的 AI Agent 系统 |
| 反模式 | 二元权限:只有“手动“和“自动“,没有中间地带和安全回退 |
26.3 模式三:防御性 Git(Defensive Git)
问题:AI 模型在执行 Git 操作时可能选择“最短路径“,导致数据丢失或难以恢复的状态。
Claude Code 在 BashTool 提示词中嵌入了完整的 Git 安全协议(详见第8章),核心规则包括:
- 绝不跳过 hooks(
--no-verify):pre-commit hooks 是项目的质量门禁 - 绝不 amend(除非用户明确要求):
git commit --amend修改前一个 commit,在 hook 失败后使用会覆盖用户之前的 commit - 优先指定文件:
git add <specific-files>而非git add -A,避免意外添加.env或凭证文件 - 绝不 force push 到 main/master:即使用户请求也先警告
- 创建新 commit 而非 amend:hook 失败后 commit 没有发生——此时
--amend会修改前一个 commit
第 5 条尤其重要。当 hook 失败时,模型的自然倾向是“修复问题,然后 amend“——但提示词显式解释因果关系:
pre-commit hook 失败意味着 commit 没有发生 — 所以
--amend会修改前一个 commit,这可能毁掉之前的工作或丢失变更。应该修复问题、重新 stage、创建新 commit。
这些规则的存在说明模型确实会犯这些错误。训练数据中大量的 Git 教程推荐用 amend 来“修复上一个 commit“——不区分 hook 失败和正常 commit 的场景。
| 维度 | 描述 |
|---|---|
| 实现方式 | 工具提示词中的显式安全协议,覆盖常见危险操作路径 |
| 源码引用 | BashTool 提示词的 Git Safety Protocol(详见第8章) |
| 适用场景 | 任何允许 AI 执行 Git 操作的系统 |
| 反模式 | 依赖模型的“常识“——模型的 Git 知识来自教程,不区分上下文 |
26.4 模式四:结构化验证(Structured Verification)
问题:AI 模型可能声称“测试通过“或“代码正确“而不实际运行验证。
Claude Code 在系统提示词中建立明确的验证链(详见第6章):运行测试 → 检查输出 → 如实报告。这个看似简单的流程通过多个机制加固:
可逆性意识。操作按风险分级,模型被要求区分对待:
| 操作类型 | 示例 | 要求的模型行为 |
|---|---|---|
| 可逆操作 | 编辑文件、创建文件、只读命令 | 直接执行 |
| 不可逆操作 | 删除文件、force push、发送消息 | 确认后执行 |
| 高风险操作 | rm -rf、DROP TABLE、杀进程 | 解释风险 + 确认 |
范围约束。模型被告知“对 X 的授权不延伸到 Y“——修复 bug 不等于授权修改测试用例或跳过测试。
ant-only 的强化指令。Capybara v8 针对模型的“声称完成但未验证“倾向添加了显式对策:
// restored-src/src/constants/prompts.ts:211
// @[MODEL LAUNCH]: capy v8 thoroughness counterweight
`Before reporting a task complete, verify it actually works: run the
test, execute the script, check the output. Minimum complexity means
no gold-plating, not skipping the finish line. If you can't verify
(no test exists, can't run the code), say so explicitly rather than
claiming success.`
注释 @[MODEL LAUNCH] 标记说明这是模型版本相关的行为校正——当模型升级时团队会重新评估是否仍需要这段指令。
| 维度 | 描述 |
|---|---|
| 实现方式 | 验证链(运行→检查→报告)+ 可逆性分级 + 范围约束 |
| 源码引用 | 系统提示词验证指令(第6章)、prompts.ts:211 |
| 适用场景 | 任何需要 AI 修改代码并验证正确性的场景 |
| 反模式 | 信任模型的自我报告,不要求展示实际测试输出 |
26.5 模式五:范围匹配响应(Scope-Matched Response)
问题:AI 模型倾向于“顺便“做额外的事——修复 bug 时顺便重构,添加功能时顺便更新文档——导致变更范围失控。
Claude Code 的系统提示词包含一系列极为具体的范围限制指令(详见第6章)。最关键的一组来自 getSimpleDoingTasksSection():
// restored-src/src/constants/prompts.ts:200-203
"Don't add features, refactor code, or make 'improvements' beyond what
was asked. A bug fix doesn't need surrounding code cleaned up. A simple
feature doesn't need extra configurability. Don't add docstrings,
comments, or type annotations to code you didn't change."
"Don't add error handling, fallbacks, or validation for scenarios that
can't happen. Trust internal code and framework guarantees."
"Don't create helpers, utilities, or abstractions for one-time operations.
Don't design for hypothetical future requirements. ... Three similar
lines of code is better than a premature abstraction."
注意这些指令的具体程度——不是抽象的“保持简洁“,而是可判定的规则:“不要给你没修改的代码添加 docstring”、“三行重复优于过早抽象”。
另一个精妙的范围限制是“授权不延伸“。用户批准了一个 git push,模型可能将此理解为“用户授权所有 Git 操作“。提示词打破这种推理:授权的范围是被明确指定的,不超出它。
| 维度 | 描述 |
|---|---|
| 实现方式 | 系统提示词中的显式范围限制 + 最小复杂度原则 |
| 源码引用 | prompts.ts:200-203(极简主义指令组) |
| 适用场景 | 任何 AI 辅助编码场景 |
| 反模式 | 鼓励“全面性“——“请确保代码质量“给模型无限范围空间 |
26.6 模式六:工具级提示词优于通用指令(Tool-Level Prompts)
问题:通用系统提示词中的指令太多,模型难以在正确时机回忆正确的指令。
Claude Code 让每个工具携带自己的行为驾驭器(详见第8章),而非将所有行为指令塞入系统提示词:
| 位置 | 内容 |
|---|---|
| 系统提示词 | 通用行为指令、输出格式、安全原则 |
| BashTool 描述 | Git 安全协议、沙箱配置、后台任务说明 |
| FileEditTool 描述 | “编辑前先读取”、最小唯一 old_string、replace_all 用法 |
| FileReadTool 描述 | 默认行数、offset/limit 分页、PDF 页码范围 |
| GrepTool 描述 | ripgrep 语法、多行匹配、“始终使用 Grep 而非 grep” |
| AgentTool 描述 | fork 指引、隔离模式、“不要偷看 fork 输出” |
| SkillTool 描述 | 预算约束、三级截断级联、内置技能优先 |
工具级提示词的优势在于时序对齐:当模型决定调用 BashTool 时,BashTool 的描述(含 Git 安全协议)就在它的注意力焦点内。如果 Git 安全协议放在系统提示词中,模型需要在数万 token 的上下文中“回忆“——在长会话中这是不可靠的。
工具级提示词的另一个优势是缓存效率。工具描述作为 tools 参数的一部分,在 API 请求中的位置相对稳定。修改工具描述只影响工具列表的哈希,不影响系统提示词段——缓存中断检测中的 perToolHashes(restored-src/src/services/api/promptCacheBreakDetection.ts:36-38)正是为了精确追踪是哪个工具的描述变化了,而非让整个缓存前缀失效。
| 维度 | 描述 |
|---|---|
| 实现方式 | 行为指令跟随工具描述,在工具被调用时自然进入模型注意力 |
| 源码引用 | 各工具的 prompt 字段(详见第8章)、promptCacheBreakDetection.ts:36-38 |
| 适用场景 | 任何提供多个工具的 AI Agent |
| 反模式 | 中心化指令库——所有指令放在系统提示词中,长会话中遵守率下降 |
模式提炼
六个模式汇总表
| 模式 | 实现方式 | 源码引用 |
|---|---|---|
| 编辑前先读取 | 提示词(软)+ 工具代码检查(硬) | FileEditTool(第8章) |
| 渐进式自主 | 多级权限 + 分类器 + 拒绝追踪回退 | denialTracking.ts:12-44 |
| 防御性 Git | 工具提示词中的完整安全协议 | BashTool 提示词(第8章) |
| 结构化验证 | 运行→检查→报告 + 可逆性分级 | prompts.ts:211 |
| 范围匹配响应 | 具体可判定的范围限制指令 | prompts.ts:200-203 |
| 工具级提示词 | 行为指令附加到对应工具 | 各工具 prompt + perToolHashes |
表 26-1:六个生产级模式汇总
模式在工具执行生命周期中的位置
graph TD
subgraph 执行前
A["编辑前先读取"]
B["范围匹配响应"]
subgraph 执行中
C["防御性 Git"]
D["渐进式自主"]
subgraph 执行后
E["结构化验证"]
subgraph 贯穿全程
F["工具级提示词"]
A --> C
B --> C
D --> C
C --> E
F -.-> A
F -.-> C
F -.-> E
图 26-1:六个模式在工具执行生命周期中的位置
工具级提示词贯穿全程——其他五个模式都通过工具提示词实现。编辑前先读取和范围匹配响应约束执行前的准备。防御性 Git 和渐进式自主控制执行过程中的安全边界。结构化验证确保执行后的正确性。
全局模式:双层约束
- 解决的问题:单靠提示词无法 100% 保证模型遵守规则
- 核心做法:对高风险行为,用提示词做“软约束“,用代码做“硬约束“
- 代码模板:工具描述写明规则 →
call()方法检查前置条件 → 不满足时返回错误 - 前置条件:能在代码层面检测前置条件是否满足
全局模式:安全梯度
- 解决的问题:不同任务需要不同程度的自主性
- 核心做法:多级模式,每级有明确安全网
- 前置条件:能评估操作的风险等级
用户能做什么
- 对关键行为实施双层约束。如果某个行为违反时会造成不可逆后果,不要只靠提示词——在工具代码中添加前置条件检查
- 设计权限梯度而非二元开关。为 Agent 提供至少 3 个自主级别:手动确认、分类器自动决策(带回退)、完全自主
- 在 Git 操作提示词中显式说明因果关系。“不要 amend“不够——要说明“hook 失败后 amend 会修改前一个 commit,导致变更丢失”
- 要求模型展示验证输出。不接受“测试通过了“的文字报告——要求展示实际测试输出
- 用具体规则替代模糊指令。将“保持代码质量“替换为“不要给未修改的代码添加注释“、“三行重复优于过早抽象”
- 将行为指令附加到对应工具。Git 安全规则放在 Bash 工具描述中,文件操作规则放在文件工具描述中——不要全堆在系统提示词里
第27章:Claude Code 的不足之处(以及你能修复什么)
为什么这很重要
前三章提炼了 Claude Code 的优秀设计——驾驭工程原则、上下文管理策略、生产级编码模式。但一本严肃的技术分析不能只讲“它做对了什么“,还必须客观审视“它在哪里做得不够好“。
本章列出 5 个从源码中可观测到的设计不足。每个不足包含三部分:问题描述(它是什么)、源码证据(为什么说它是问题)、改进建议(可以怎么做)。
需要强调的是:这些分析完全基于工程设计层面,不涉及对 Anthropic 团队的能力评价。每一个“不足“都是在特定工程权衡下的合理选择——只是这些选择有可观测的代价。
源码分析
27.1 不足一:缓存脆弱性——分散的注入点制造缓存中断风险
问题描述
Claude Code 的提示词缓存系统依赖核心假设:SYSTEM_PROMPT_DYNAMIC_BOUNDARY 之前的内容在整个会话中保持不变。但多个分散的注入点可以修改这个区域:
systemPromptSections.ts中的条件性段落:基于 Feature Flag 或运行时状态决定是否包含- MCP 连接/断开事件:
DANGEROUS_uncachedSystemPromptSection()显式标记“会破坏缓存“ - 工具列表变化:MCP 服务器上下线导致
tools参数哈希改变 - GrowthBook Flag 切换:远程配置变更导致序列化的工具 Schema 变动
源码证据
缓存中断检测系统需要追踪近 20 个字段(restored-src/src/services/api/promptCacheBreakDetection.ts:28-69)就是直接证据——如果缓存是稳定的,不需要如此复杂的检测系统来解释“为什么中断了“。
DANGEROUS_uncachedSystemPromptSection() 的命名本身是警示标记——函数名中的 DANGEROUS 前缀说明团队清楚它会破坏缓存,但在某些场景下(MCP 状态变化)没有更好的替代方案。
Agent 列表曾内联在系统提示词中,占全球 cache_creation token 的 10.2%(详见第15章)。虽然后来被移至附件,但这说明即使是经验丰富的团队,也会无意中在缓存段内放入不稳定内容。
splitSysPromptPrefix()(restored-src/src/utils/api.ts:321-435)的三条代码路径——MCP tool-based、global+boundary、默认 org 级别——其复杂度完全来自处理“缓存段内可能出现的各种变动“。源码中的注释明确标记了引用关系:
// restored-src/src/constants/prompts.ts:110-112
// WARNING: Do not remove or reorder this marker without updating
// cache logic in:
// - src/utils/api.ts (splitSysPromptPrefix)
// - src/services/api/claude.ts (buildSystemPromptBlocks)
这种跨文件的 WARNING 注释是架构脆弱性的信号——组件之间通过隐式约定耦合,而非显式接口。
改进建议
集中构建提示词。将分散注入改为集中构建:
- 构建阶段:所有段落在一个中心函数中组装,组装完成后计算整体哈希
- 不可变约束:对缓存段内的内容实施编译期或运行时的不可变检查——任何会话中变化的内容强制放在缓存段之外
- 变更审计:提交前自动检测“是否在缓存段内添加了不稳定内容“
27.2 不足二:压缩信息丢失——9 段摘要模板无法保留所有推理链
问题描述
自动压缩(详见第9章)使用结构化提示词模板要求模型生成对话摘要。压缩提示词(restored-src/src/services/compact/prompt.ts)要求 <analysis> 块中包含:
// restored-src/src/services/compact/prompt.ts:31-44
"1. Chronologically analyze each message and section of the conversation.
For each section thoroughly identify:
- The user's explicit requests and intents
- Your approach to addressing the user's requests
- Key decisions, technical concepts and code patterns
- Specific details like:
- file names
- full code snippets
- function signatures
..."
这是精心设计的清单,但有一个根本限制:模型的推理链和失败尝试在压缩中丢失。
具体丢失的信息类型:
- 失败的方法:模型尝试方法 A 但失败,转而使用方法 B 成功——压缩后只保留“使用方法 B 解决了问题“,方法 A 的失败经验丢失
- 决策上下文:为什么选方法 B 而非方法 A 的推理被简化为结论
- 精确引用:具体的文件路径和行号在摘要中可能被泛化——“修改了认证模块“而非“修改了
auth/middleware.ts:42-67”
源码证据
压缩的 token 预算是 MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000(restored-src/src/services/compact/autoCompact.ts:30)。压缩比可能高达 7:1 或更高——在这种压缩比下,信息丢失不可避免。
压缩后文件恢复机制(POST_COMPACT_MAX_FILES_TO_RESTORE = 5,restored-src/src/services/compact/compact.ts:122)部分缓解了问题,但只恢复文件内容,不恢复推理链。
NO_TOOLS_PREAMBLE(restored-src/src/services/compact/prompt.ts:19-25)的存在暗示了另一个压缩质量问题:模型在压缩时有时会尝试调用工具而非生成摘要文本(Sonnet 4.6 上发生率 2.79%),需要显式禁止。这意味着压缩任务本身对模型来说不是trivial的。
改进建议
结构化信息提取 + 分层压缩:
- 结构化提取:压缩前用专门步骤提取结构化信息——文件修改列表、失败方法列表、决策图——存储为 JSON 而非自然语言摘要
- 分层压缩:对话分为“事实层“(文件修改、命令输出)和“推理层“(为什么这样做)。事实层用提取式压缩(直接提取),推理层用摘要式压缩(当前做法)
- 失败记忆:专门保留“已尝试但失败的方法“列表,防止压缩后模型重蹈覆辙
27.3 不足三:Grep 不是抽象语法树(AST)——文本搜索遗漏语义关系
问题描述
Claude Code 的代码搜索完全基于 GrepTool(文本正则匹配)和 GlobTool(文件名模式匹配)。在大多数场景下工作良好,但无法覆盖语义级别的代码关系:
- 动态导入:
require(variableName)中的变量是运行时值,文本搜索无法追踪 - Re-export:
export { default as Foo } from './bar'在搜索Foo定义时不被正确追踪 - 字符串引用:工具名作为字符串注册(
name: 'Bash'),搜索工具使用点需同时搜字符串和变量名 - 类型推断:TypeScript 的类型推断意味着很多变量没有显式注解,搜索特定类型的使用位置不完整
源码证据
Claude Code 自身的工具列表包含 40+ 个工具(详见第2章),但没有 AST 查询工具。系统提示词明确引导模型使用 Grep 而非 Bash 中的 grep(详见第8章)——但这只是将文本搜索从一个工具移到另一个,没有提升搜索的语义层次。
在 Claude Code 自身的代码库(1,902 个 TypeScript 文件)中,这些遗漏的影响是可观的。例如:Feature Flag 通过 feature('KAIROS') 调用使用——搜索字符串 KAIROS 可以找到使用点,但搜索函数 feature 的调用则会返回所有 89 个 flag 的结果,噪音巨大。没有 AST 查询,无法表达“找到 feature() 调用中参数值为 KAIROS 的位置“。
改进建议
添加 LSP(Language Server Protocol)集成:
- 类型查找:通过 TypeScript Language Server 查询变量的推断类型
- 跳转到定义:处理 re-export、类型别名、动态导入的完整链路
- 查找引用:找到符号的所有使用位置,包括通过类型推断间接使用的
- 调用层次:查询函数的调用者和被调用者,建立调用图
LSP 集成的基础设施在源码中已有迹象——Feature Flag 分析(详见第23章)中可以观察到部分 LSP 相关的实验性代码路径,但尚未广泛启用。Grep + LSP 的组合将比纯 Grep 或纯 LSP 更强大:Grep 负责快速的全文搜索和模式匹配,LSP 负责精确的语义查询。
27.4 不足四:截断告知不等于行动——大结果写入磁盘但模型可能不会重新读取
问题描述
当工具结果超过 50K 字符(DEFAULT_MAX_RESULT_SIZE_CHARS,restored-src/src/constants/toolLimits.ts:13)时,处理策略是:完整结果写入磁盘,返回预览消息(详见第12章)。
问题在于:模型可能不会重新读取。模型基于预览做判断——如果预览看起来“足够“(例如搜索结果的前 50K 字符已包含一些相关结果),模型可能不去读完整内容。但关键信息可能恰好在截断点之后。
源码证据
restored-src/src/utils/toolResultStorage.ts 实现了大结果持久化逻辑。截断时模型收到:
[Result truncated. Full output saved to /tmp/claude-tool-result-xxx.txt]
[Showing first 50000 characters of N total]
这遵循了第25章“告知而非隐藏“原则——模型被告知截断发生了。但“告知“和“确保模型行动“是两件事。
问题的根源是注意力经济:模型在每一步都要决定下一步做什么。读取完整的截断文件意味着多一次工具调用、多等几秒——如果模型判断预览“足够好“,它会跳过这步。但这个判断本身可能是错的,因为模型看不到截断点之后的内容。
改进建议
智能预览 + 主动建议:
- 结构化预览:不只截取前 N 字符,而是提取摘要——搜索结果的匹配总数、文件分布、前后 N 个匹配的上下文
- 相关性提示:在预览中添加“结果共 M 个匹配,当前只显示前 K 个。如果你在找特定文件或模式,建议查看完整内容“
- 自动分页:截断时不只保存到磁盘等模型来读——将结果分页,在预览中展示分页信息让模型按需继续
27.5 不足五:Feature Flag 复杂性——89 个 Flag 的涌现行为
问题描述
Claude Code 有 89 个 Feature Flag(详见第23章),通过两种机制控制:
- 构建时:
feature()函数编译时求值,dead code elimination 移除未启用分支 - 运行时:GrowthBook
tengu_*前缀的 flag 通过 API 获取
问题在于 flag 之间的交互效应。89 个二值 flag 理论上产生 2^89 种组合。即使只有 10% 的 flag 之间存在交互,组合空间也是巨大的。
源码证据
以下是源码中可观察到的 flag 交互示例:
| Flag A | Flag B | 交互关系 |
|---|---|---|
KAIROS | PROACTIVE | 助手模式和主动工作模式有重叠的唤醒机制 |
COORDINATOR_MODE | TEAMMEM | 都涉及多 agent 通信,使用不同消息传递机制 |
BRIDGE_MODE | DAEMON | 桥接模式需守护进程支持,但生命周期管理独立 |
FAST_MODE | ULTRATHINK | 加速输出和深度思考在 effort 配置中可能冲突 |
表 27-1:Feature Flag 交互示例
锁存机制(详见第24章原则六)是对 flag 交互复杂性的缓解——固定某些状态来减少运行时组合。但锁存本身也增加理解难度:系统当前行为不仅取决于 flag 当前值,还取决于会话历史中 flag 值的变化序列。
工具 Schema 缓存(getToolSchemaCache(),详见第15章)是另一个缓解措施——每会话计算一次工具列表,防止会话中途 flag 切换导致 Schema 变动。但这意味着会话中途切换的 flag 不会影响工具列表——既是特性也是限制。
promptCacheBreakDetection.ts 中每个 flag 相关的锁存字段都带有 Tracked to verify the fix 注释:
// restored-src/src/services/api/promptCacheBreakDetection.ts:47-55
/** AFK_MODE_BETA_HEADER presence — should NOT break cache anymore
* (sticky-on latched in claude.ts). Tracked to verify the fix. */
autoModeActive: boolean
/** Overage state flip — should NOT break cache anymore (eligibility is
* latched session-stable in should1hCacheTTL). Tracked to verify the fix. */
isUsingOverage: boolean
/** Cache-editing beta header presence — should NOT break cache anymore
* (sticky-on latched in claude.ts). Tracked to verify the fix. */
cachedMCEnabled: boolean
三个字段、三次 should NOT break cache anymore、三次 Tracked to verify the fix——说明这些 flag 的状态变化曾经导致缓存中断,团队逐个修复后添加追踪来验证修复是否有效。这是典型的“打地鼠“模式——没有系统性解决 flag 交互问题,而是逐个修复暴露出来的案例。
改进建议
Flag 依赖图 + 互斥约束:
- 显式依赖声明:每个 flag 声明依赖的其他 flag(
KAIROS_DREAM依赖KAIROS),构建工具编译时验证依赖关系 - 互斥约束:声明不能同时启用的 flag 组合
- 组合测试:对关键 flag 组合进行自动化测试,至少覆盖所有两两组合
- Flag 状态可视化:调试模式下输出所有 flag 值和锁存状态,帮助诊断行为异常
模式提炼
五个不足汇总表
| 不足 | 源码证据 | 改进建议 |
|---|---|---|
| 缓存脆弱性 | promptCacheBreakDetection.ts 追踪 18 个字段 | 集中构建 + 不可变约束 |
| 压缩信息丢失 | compact/prompt.ts 压缩比 7:1+ | 结构化提取 + 分层压缩 |
| Grep 不是 AST | 40+ 工具中无 AST 查询工具 | LSP 集成 |
| 截断告知不足 | toolResultStorage.ts 预览不保证被读取 | 智能预览 + 自动分页 |
| Flag 复杂性 | 3 个 Tracked to verify the fix 注释 | Flag 依赖图 + 互斥约束 |
表 27-2:五个不足汇总
三层防御与五个不足的关系
graph TD
subgraph "提示词层"
A["不足2: 压缩信息丢失<br />摘要模板的局限性"]
B["不足4: 截断告知不足<br />告知了但模型可能不行动"]
subgraph "工具层"
C["不足3: Grep 不是 AST<br />文本搜索的语义盲区"]
subgraph "基础设施层"
D["不足1: 缓存脆弱性<br />分散的注入点"]
E["不足5: Flag 复杂性<br />组合爆炸与打地鼠"]
D --> A
D --> B
E --> C
E --> D
图 27-1:五个不足在三层防御中的分布
基础设施层的两个不足(缓存脆弱性、Flag 复杂性)最深层——影响系统整体行为,修复成本最高。提示词层的两个不足(压缩信息丢失、静默截断)更容易缓解——改进压缩模板或预览格式不需要大规模重构。工具层的不足(Grep 不是 AST)介于两者之间——添加 LSP 工具需要新的外部依赖,但不改变核心架构。
反模式:分散注入
- 问题:多个独立注入点修改同一共享状态,状态变化不可预测
- 识别信号:需要复杂检测系统来解释“为什么状态变了“
- 解决方向:集中构建 + 不可变约束
反模式:有损压缩不可逆
- 问题:压缩后丢失的信息无法恢复
- 识别信号:压缩后模型重复之前已尝试过的失败方法
- 解决方向:结构化提取关键信息,分层存储
用户能做什么
你可以直接行动的
- 缓存脆弱性:通过 CLAUDE.md 控制你能控制的变量——保持项目 CLAUDE.md 稳定,避免频繁修改。监控 API 账单中的
cache_creationtoken 消耗 - 静默截断:在 CLAUDE.md 中添加指令:“当工具结果被截断时,始终使用 Read 工具查看完整内容”。不能保证 100% 遵守,但提高概率
- Grep 的局限:通过 MCP 服务器(详见第22章)添加 LSP 能力。社区已有 TypeScript LSP 和 Python LSP 的 MCP 集成
需要关注但无法直接修复的
- 压缩信息丢失:长会话中如果模型“忘记“了之前尝试过的方法,手动提醒。关键技术决策可记录在 CLAUDE.md 中(不会被压缩)
- Feature Flag 复杂性:内部架构问题,但了解它有助于理解为什么 Claude Code 的行为有时“不一致“——可能是 flag 交互导致
不足是权衡的另一面
| 不足 | 权衡的另一面 |
|---|---|
| 缓存脆弱性 | 提示词的灵活组合能力 |
| 压缩信息丢失 | 能在 200K 窗口中持续工作数百轮 |
| Grep 不是 AST | 零外部依赖、跨语言通用 |
| 截断告知不足 | 防止上下文被单个大结果淹没 |
| Flag 复杂性 | 快速迭代和 A/B 测试能力 |
表 27-3:五个不足与其对应的工程权衡
理解这些权衡比单纯批评不足更有价值。在你自己的 AI Agent 系统中,你可能面临同样的选择——而 Claude Code 的经验可以帮助你预见每种选择的长期代价。
附录 A:关键文件索引
本附录列出 Claude Code v2.1.88 源码中的关键文件及其职责,按子系统分组。文件路径相对于 restored-src/src/。
入口点与核心循环
| 文件 | 职责 | 相关章节 |
|---|---|---|
main.tsx | CLI 入口点,并行预取、延迟导入、Feature Flag 门控 | 第1章 |
query.ts | Agent Loop 主循环,queryLoop 状态机 | 第3章 |
query/transitions.ts | 循环转换类型:Continue、Terminal | 第3章 |
工具系统
| 文件 | 职责 | 相关章节 |
|---|---|---|
Tool.ts | 工具接口契约,TOOL_DEFAULTS 失败关闭默认值 | 第2章、第24章 |
tools.ts | 工具注册,Feature Flag 条件加载 | 第2章 |
services/tools/toolOrchestration.ts | 工具执行编排,partitionToolCalls 并发分区 | 第4章 |
services/tools/toolExecution.ts | 单工具执行生命周期 | 第4章 |
services/tools/StreamingToolExecutor.ts | 流式工具执行器 | 第4章 |
tools/BashTool/ | Bash 工具实现,含 Git 安全协议 | 第8章、第26章 |
tools/FileEditTool/ | 文件编辑工具,“编辑前先读取“强制 | 第8章、第26章 |
tools/FileReadTool/ | 文件读取工具,默认 2000 行 | 第8章 |
tools/GrepTool/ | 基于 ripgrep 的搜索工具 | 第8章 |
tools/AgentTool/ | 子 Agent 生成工具 | 第8章、第20章 |
tools/SkillTool/ | 技能调用工具 | 第8章、第22章 |
tools/SkillTool/prompt.ts | 技能列表预算:1% 上下文窗口 | 第12章、第25章 |
系统提示词
| 文件 | 职责 | 相关章节 |
|---|---|---|
constants/prompts.ts | 系统提示词构建,SYSTEM_PROMPT_DYNAMIC_BOUNDARY | 第5章、第6章、第24章 |
constants/systemPromptSections.ts | 段落注册表,带缓存控制 scope | 第5章 |
constants/toolLimits.ts | 工具结果预算常量 | 第12章、第25章 |
API 与缓存
| 文件 | 职责 | 相关章节 |
|---|---|---|
services/api/claude.ts | API 调用构建,缓存断点放置 | 第13章 |
services/api/promptCacheBreakDetection.ts | 缓存中断检测,PreviousState 追踪 | 第14章、第24章 |
utils/api.ts | splitSysPromptPrefix() 三路缓存分割 | 第5章、第13章 |
上下文压缩
| 文件 | 职责 | 相关章节 |
|---|---|---|
services/compact/compact.ts | 压缩编排,POST_COMPACT_MAX_FILES_TO_RESTORE | 第9章、第10章 |
services/compact/autoCompact.ts | 自动压缩阈值与熔断器 | 第9章、第24章、第25章 |
services/compact/prompt.ts | 压缩提示词模板 | 第9章、第27章 |
services/compact/microCompact.ts | 基于时间的微压缩 | 第11章 |
services/compact/apiMicrocompact.ts | API 原生缓存微压缩 | 第11章 |
权限与安全
| 文件 | 职责 | 相关章节 |
|---|---|---|
utils/permissions/yoloClassifier.ts | YOLO 自动模式分类器 | 第17章 |
utils/permissions/denialTracking.ts | 拒绝追踪,DENIAL_LIMITS | 第17章、第26章 |
tools/BashTool/bashPermissions.ts | Bash 命令权限检查 | 第16章 |
CLAUDE.md 与技能
| 文件 | 职责 | 相关章节 |
|---|---|---|
utils/claudemd.ts | CLAUDE.md 加载与注入,4 层优先级 | 第19章 |
skills/bundled/ | 内置技能目录 | 第22章 |
skills/loadSkillsDir.ts | 用户自定义技能发现 | 第22章 |
skills/mcpSkillBuilders.ts | MCP 到技能桥接 | 第22章 |
多 Agent 编排
| 文件 | 职责 | 相关章节 |
|---|---|---|
coordinator/coordinatorMode.ts | 协调器模式实现 | 第20章 |
utils/teammate.ts | 队友 Agent 工具 | 第20章 |
utils/swarm/teammatePromptAddendum.ts | 队友提示词附加内容 | 第20章 |
工具结果与存储
| 文件 | 职责 | 相关章节 |
|---|---|---|
utils/toolResultStorage.ts | 大结果持久化,截断预览 | 第12章、第27章 |
utils/toolSchemaCache.ts | 工具 Schema 缓存 | 第15章 |
配置与状态
| 文件 | 职责 | 相关章节 |
|---|---|---|
utils/effort.ts | Effort 级别解析 | 第21章 |
utils/fastMode.ts | Fast Mode 管理 | 第21章 |
utils/managedEnvConstants.ts | 托管环境变量白名单 | 附录 B |
screens/REPL.tsx | 主交互界面(5000+ 行 React 组件) | 第1章 |
附录 B:环境变量参考
本附录列出 Claude Code v2.1.88 中用户可配置的关键环境变量。按功能域分组,仅列出影响用户可见行为的变量,省略内部遥测和平台检测类变量。
上下文压缩
| 变量 | 效果 | 默认值 |
|---|---|---|
CLAUDE_CODE_AUTO_COMPACT_WINDOW | 覆盖上下文窗口大小(token) | 模型默认值 |
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE | 以百分比覆盖自动压缩阈值(0-100) | 计算值 |
DISABLE_AUTO_COMPACT | 完全禁用自动压缩 | false |
Effort 与推理
| 变量 | 效果 | 有效值 |
|---|---|---|
CLAUDE_CODE_EFFORT_LEVEL | 覆盖 effort 级别 | low、medium、high、max、auto、unset |
CLAUDE_CODE_DISABLE_FAST_MODE | 禁用 Fast Mode 加速输出 | true/false |
DISABLE_INTERLEAVED_THINKING | 禁用扩展思考 | true/false |
MAX_THINKING_TOKENS | 覆盖思考 token 上限 | 模型默认值 |
工具与输出限制
| 变量 | 效果 | 默认值 |
|---|---|---|
BASH_MAX_OUTPUT_LENGTH | Bash 命令最大输出字符数 | 8,000 |
CLAUDE_CODE_GLOB_TIMEOUT_SECONDS | Glob 搜索超时(秒) | 默认值 |
权限与安全
| 变量 | 效果 | 注意 |
|---|---|---|
CLAUDE_CODE_DUMP_AUTO_MODE | 导出 YOLO 分类器请求/响应 | 仅调试用 |
CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK | 禁用 Bash 命令注入检测 | 降低安全性 |
API 与认证
| 变量 | 效果 | 安全等级 |
|---|---|---|
ANTHROPIC_API_KEY | Anthropic API 认证密钥 | 凭证 |
ANTHROPIC_BASE_URL | 自定义 API 端点(代理支持) | 可重定向 |
ANTHROPIC_MODEL | 覆盖默认模型 | 安全 |
CLAUDE_CODE_USE_BEDROCK | 通过 AWS Bedrock 路由推理 | 安全 |
CLAUDE_CODE_USE_VERTEX | 通过 Google Vertex AI 路由推理 | 安全 |
CLAUDE_CODE_EXTRA_BODY | 向 API 请求追加额外字段 | 高级用途 |
ANTHROPIC_CUSTOM_HEADERS | 自定义 HTTP 请求头 | 安全 |
模型选择
| 变量 | 效果 | 示例 |
|---|---|---|
ANTHROPIC_DEFAULT_HAIKU_MODEL | 自定义 Haiku 模型 ID | 模型字符串 |
ANTHROPIC_DEFAULT_SONNET_MODEL | 自定义 Sonnet 模型 ID | 模型字符串 |
ANTHROPIC_DEFAULT_OPUS_MODEL | 自定义 Opus 模型 ID | 模型字符串 |
ANTHROPIC_SMALL_FAST_MODEL | 快速推理模型(如用于摘要) | 模型字符串 |
CLAUDE_CODE_SUBAGENT_MODEL | 子 Agent 使用的模型 | 模型字符串 |
提示词缓存
| 变量 | 效果 | 默认值 |
|---|---|---|
CLAUDE_CODE_ENABLE_PROMPT_CACHING | 启用提示词缓存 | true |
DISABLE_PROMPT_CACHING | 完全禁用提示词缓存 | false |
会话与调试
| 变量 | 效果 | 用途 |
|---|---|---|
CLAUDE_CODE_DEBUG_LOG_LEVEL | 日志详细程度 | silent/error/warn/info/verbose |
CLAUDE_CODE_PROFILE_STARTUP | 启用启动性能剖析 | 调试 |
CLAUDE_CODE_PROFILE_QUERY | 启用查询管线剖析 | 调试 |
CLAUDE_CODE_JSONL_TRANSCRIPT | 将会话记录写为 JSONL | 文件路径 |
CLAUDE_CODE_TMPDIR | 覆盖临时目录 | 路径 |
输出与格式
| 变量 | 效果 | 默认值 |
|---|---|---|
CLAUDE_CODE_SIMPLE | 最小系统提示词模式 | false |
CLAUDE_CODE_DISABLE_TERMINAL_TITLE | 禁用设置终端标题 | false |
CLAUDE_CODE_NO_FLICKER | 减少全屏模式闪烁 | false |
MCP(Model Context Protocol)
| 变量 | 效果 | 默认值 |
|---|---|---|
MCP_TIMEOUT | MCP 服务器连接超时(ms) | 10,000 |
MCP_TOOL_TIMEOUT | MCP 工具调用超时(ms) | 30,000 |
MAX_MCP_OUTPUT_TOKENS | MCP 工具输出 token 上限 | 默认值 |
网络与代理
| 变量 | 效果 | 注意 |
|---|---|---|
HTTP_PROXY / HTTPS_PROXY | HTTP/HTTPS 代理 | 可重定向 |
NO_PROXY | 绕过代理的主机列表 | 安全 |
NODE_EXTRA_CA_CERTS | 额外 CA 证书 | 影响 TLS 信任 |
路径与配置
| 变量 | 效果 | 默认值 |
|---|---|---|
CLAUDE_CONFIG_DIR | 覆盖 Claude 配置目录 | ~/.claude |
附录 C:术语表
本附录收录本书中首次出现时附英文原文的技术术语,按中文拼音排序。
| 术语 | 英文 | 定义 | 首见章节 |
|---|---|---|---|
| Agent Loop | Agent Loop | AI Agent 的核心执行循环:接收输入 → 调用模型 → 执行工具 → 判断是否继续 | 第3章 |
| 并发分区 | Partition | 将工具调用分为可并行和必须串行的批次,基于 isConcurrencySafe 属性 | 第4章 |
| 抽象语法树 | AST (Abstract Syntax Tree) | 源代码的树状结构表示,保留语义关系(而非纯文本) | 第27章 |
| 大纲 | Outline | 书籍目录结构和各章主题的概览文档 | 前言 |
| 动态边界 | Dynamic Boundary | 系统提示词中分隔静态可缓存内容与动态会话内容的标记 | 第5章 |
| 防御性 Git | Defensive Git | 在 AI 执行 Git 操作时通过显式安全规则防止数据丢失的模式 | 第26章 |
| 工具 Schema | Tool Schema | 工具的 JSON Schema 定义,包含名称、描述、输入参数格式 | 第2章 |
| 驾驭工程 | Harness Engineering | 通过提示词、工具和配置(而非代码逻辑)引导 AI 模型行为的实践 | 第1章 |
| 渐进式自主 | Graduated Autonomy | 从手动确认到全自动的多级权限模式,每级都有安全回退 | 第26章 |
| 技能 | Skill | 可调用的提示词模板,通过 SkillTool 注入对话上下文 | 第22章 |
| 缓存中断 | Cache Break | 提示词缓存前缀因内容变化而失效的事件 | 第14章 |
| 锁存 | Latch | 一旦进入即保持稳定的会话级状态,防止缓存振荡或行为抖动 | 第13章、第24章 |
| 模式提炼 | Pattern Extraction | 从源码分析中提取可复用的设计模式,包含名称、问题、解决方案 | 全书 |
| 熔断器 | Circuit Breaker | 连续 N 次失败后强制停止自动化流程,降级到安全状态 | 第9章、第25章 |
| 死代码消除 | DCE (Dead Code Elimination) | Bun 的 feature() 函数实现编译时移除门控代码 | 第1章 |
| 失败关闭 | Fail-Closed | 系统默认选择最安全的选项,需显式声明才能解锁危险操作 | 第2章、第24章 |
| 提示词缓存 | Prompt Cache | Anthropic API 特性,缓存消息前缀以减少重复 token 处理 | 第13章 |
| 微压缩 | Microcompact | 精准移除特定工具结果(而非完整压缩整个对话),保持缓存前缀稳定 | 第11章 |
| 压缩 | Compaction | 总结对话历史以释放上下文窗口空间 | 第9章 |
| 压缩后恢复 | Post-Compact Restore | 压缩完成后选择性恢复最关键的文件内容和技能信息 | 第10章 |
| YOLO 分类器 | YOLO Classifier | 二次 Claude API 调用用于在自动模式下做出权限批准/拒绝决策 | 第17章 |
| Feature Flag | Feature Flag (tengu_*) | 通过 GrowthBook 运行时配置的实验门控,控制功能启用/禁用 | 第1章、第23章 |
| Hooks | Hooks | 用户自定义的 Shell 命令,在特定事件(如工具调用前后)时执行 | 第18章 |
| MCP | Model Context Protocol | 模型上下文协议,标准化 AI 模型与外部工具/数据源的交互 | 第22章 |
| Token 预算 | Token Budget | 为上下文窗口中的各类内容分配的 token 使用上限 | 第12章、第25章 |
附录 D:89 个 Feature Flag 完整清单
本附录列出 Claude Code v2.1.88 源码中通过 feature() 函数门控的全部 Feature Flag,按功能域分类。引用次数反映该 flag 在源码中出现的频率,可粗略推断实现深度(详见第23章的成熟度推断方法)。
自主 Agent 与后台运行(19 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
AGENT_MEMORY_SNAPSHOT | 2 | Agent 记忆快照 |
AGENT_TRIGGERS | 11 | 定时触发器(本地 cron) |
AGENT_TRIGGERS_REMOTE | 2 | 远程定时触发器(云端 cron) |
BG_SESSIONS | 11 | 后台会话管理(ps/logs/attach/kill) |
BUDDY | 15 | 伴侣模式:浮动 UI 气泡 |
BUILTIN_EXPLORE_PLAN_AGENTS | 1 | 内置探索/计划 agent 类型 |
COORDINATOR_MODE | 32 | 协调器模式:跨 agent 任务协调 |
FORK_SUBAGENT | 4 | 子 agent fork 执行模式 |
KAIROS | 84 | 助手模式核心:后台自主 agent、tick 唤醒 |
KAIROS_BRIEF | 17 | 简报模式:向用户发送进度消息 |
KAIROS_CHANNELS | 13 | 频道系统:多通道通信 |
KAIROS_DREAM | 1 | autoDream 记忆整理触发 |
KAIROS_GITHUB_WEBHOOKS | 2 | GitHub Webhook 订阅:PR 事件触发 |
KAIROS_PUSH_NOTIFICATION | 2 | 推送通知:向用户推送状态更新 |
MONITOR_TOOL | 5 | 监控工具:后台进程监控 |
PROACTIVE | 21 | 自主工作模式:终端焦点感知、主动行动 |
TORCH | 1 | Torch 命令 |
ULTRAPLAN | 2 | 超级计划:结构化任务分解 UI |
VERIFICATION_AGENT | 4 | 验证 agent:自动验证任务完成状态 |
远程控制与分布式执行(10 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
BRIDGE_MODE | 14 | 桥接模式核心:远程控制协议 |
CCR_AUTO_CONNECT | 3 | Claude Code Remote 自动连接 |
CCR_MIRROR | 3 | CCR 镜像模式:只读远程镜像 |
CCR_REMOTE_SETUP | 1 | CCR 远程设置命令 |
CONNECTOR_TEXT | 7 | 连接器文本块处理 |
DAEMON | 1 | 守护进程模式:后台 daemon worker |
DOWNLOAD_USER_SETTINGS | 5 | 从云端下载用户配置 |
LODESTONE | 3 | 协议注册(lodestone:// handler) |
UDS_INBOX | 14 | Unix Domain Socket 收件箱 |
UPLOAD_USER_SETTINGS | 1 | 上传用户配置到云端 |
多媒体与交互(17 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
ALLOW_TEST_VERSIONS | 2 | 允许测试版本 |
ANTI_DISTILLATION_CC | 1 | 反蒸馏保护 |
AUTO_THEME | 1 | 自动主题切换 |
BUILDING_CLAUDE_APPS | 1 | 构建 Claude Apps 技能 |
CHICAGO_MCP | 12 | Computer Use MCP 集成 |
HISTORY_PICKER | 1 | 历史选择器 UI |
MESSAGE_ACTIONS | 2 | 消息操作(复制/编辑快捷键) |
NATIVE_CLIENT_ATTESTATION | 1 | 原生客户端认证 |
NATIVE_CLIPBOARD_IMAGE | 2 | 原生剪贴板图片支持 |
NEW_INIT | 2 | 新版初始化流程 |
POWERSHELL_AUTO_MODE | 2 | PowerShell 自动模式 |
QUICK_SEARCH | 1 | 快速搜索 UI |
REVIEW_ARTIFACT | 1 | 审查工件 |
TEMPLATES | 5 | 任务模板/分类 |
TERMINAL_PANEL | 3 | 终端面板 |
VOICE_MODE | 11 | 语音模式:流式语音转文字 |
WEB_BROWSER_TOOL | 1 | Web 浏览器工具(Bun WebView) |
上下文与性能优化(16 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
ABLATION_BASELINE | 1 | 消融测试基线 |
BASH_CLASSIFIER | 33 | Bash 命令分类器 |
BREAK_CACHE_COMMAND | 2 | 强制打断缓存命令 |
CACHED_MICROCOMPACT | 12 | 缓存微压缩策略 |
COMPACTION_REMINDERS | 1 | 压缩提醒机制 |
CONTEXT_COLLAPSE | 16 | 上下文折叠:精细化上下文管理 |
FILE_PERSISTENCE | 3 | 文件持久化计时 |
HISTORY_SNIP | 15 | 历史截断命令 |
OVERFLOW_TEST_TOOL | 2 | 溢出测试工具 |
PROMPT_CACHE_BREAK_DETECTION | 9 | Prompt Cache 断裂检测 |
REACTIVE_COMPACT | 4 | 响应式压缩:按需触发 |
STREAMLINED_OUTPUT | 1 | 精简输出模式 |
TOKEN_BUDGET | 4 | Token 预算追踪 UI |
TREE_SITTER_BASH | 3 | Tree-sitter Bash 解析器 |
TREE_SITTER_BASH_SHADOW | 5 | Tree-sitter Bash 影子模式(A/B) |
ULTRATHINK | 1 | 超级思考模式 |
记忆与知识管理(13 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
AWAY_SUMMARY | 2 | 离开摘要:离开时生成进度 |
COWORKER_TYPE_TELEMETRY | 2 | 协作者类型遥测 |
ENHANCED_TELEMETRY_BETA | 2 | 增强遥测 Beta |
EXPERIMENTAL_SKILL_SEARCH | 19 | 实验性远程技能搜索 |
EXTRACT_MEMORIES | 7 | 自动记忆提取 |
MCP_RICH_OUTPUT | 3 | MCP 富文本输出 |
MCP_SKILLS | 9 | MCP 服务器技能发现 |
MEMORY_SHAPE_TELEMETRY | 3 | 记忆结构遥测 |
RUN_SKILL_GENERATOR | 1 | 技能生成器 |
SKILL_IMPROVEMENT | 1 | 技能自动改进 |
TEAMMEM | 44 | 团队记忆同步 |
WORKFLOW_SCRIPTS | 6 | 工作流脚本 |
TRANSCRIPT_CLASSIFIER | 69 | 会话记录分类器(auto 模式) |
基础设施与遥测(14 个)
| Flag | 引用数 | 功能描述 |
|---|---|---|
COMMIT_ATTRIBUTION | 11 | Git 提交归属追踪 |
HARD_FAIL | 2 | 硬失败模式 |
IS_LIBC_GLIBC | 1 | glibc 运行时检测 |
IS_LIBC_MUSL | 1 | musl 运行时检测 |
PERFETTO_TRACING | 1 | Perfetto 性能追踪 |
SHOT_STATS | 8 | 工具调用统计分布 |
SLOW_OPERATION_LOGGING | 1 | 慢操作日志 |
UNATTENDED_RETRY | 1 | 无人值守重试 |
统计摘要
| 分类 | 数量 | 最高引用 Flag |
|---|---|---|
| 自主 Agent 与后台运行 | 19 | KAIROS (84) |
| 远程控制与分布式执行 | 10 | BRIDGE_MODE (14), UDS_INBOX (14) |
| 多媒体与交互 | 17 | CHICAGO_MCP (12) |
| 上下文与性能优化 | 16 | TRANSCRIPT_CLASSIFIER (69) |
| 记忆与知识管理 | 13 | TEAMMEM (44) |
| 基础设施与遥测 | 14 | COMMIT_ATTRIBUTION (11) |
| 总计 | 89 |
引用次数 Top 5:KAIROS (84) > TRANSCRIPT_CLASSIFIER (69) > TEAMMEM (44) > BASH_CLASSIFIER (33) > COORDINATOR_MODE (32)