Claude Code 源码深度解析
第1章 为什么需要理解 Claude Code
第1章 为什么需要理解 Claude Code
1.1 AI 编程助手的演进
从 GitHub Copilot 的单行补全,到 Cursor 的多文件编辑,再到 Claude Code 的完全自主 Agent 模式——AI 编程助手在短短三年内经历了三次范式跃迁。
每一次跃迁的背后,不只是模型能力的提升,更是系统架构的根本性变化:
| 阶段 | 代表产品 | 架构模式 | 核心挑战 |
|---|---|---|---|
| 补全式 | Copilot | 请求-响应 | 上下文窗口有限 |
| 对话式 | Cursor, Windsurf | 多轮对话 + 工具调用 | 工具编排、权限控制 |
| Agent 式 | Claude Code | 自主循环 + 多 Agent | 安全边界、状态管理、任务分解 |
Claude Code 代表的是第三阶段:模型不再只是"建议者",而是"执行者"。它可以自主决定读哪个文件、执行什么命令、创建什么分支,甚至可以生成子 Agent 并行处理任务。
graph LR
subgraph "第一阶段: 补全"
C1["用户写代码"] --> C2["模型补全一行"]
end
subgraph "第二阶段: 对话"
D1["用户描述需求"] --> D2["模型生成代码"] --> D3["用户确认/修改"]
end
subgraph "第三阶段: Agent"
A1["用户描述目标"] --> A2["Agent 自主规划"]
A2 --> A3["读代码 / 搜索"]
A3 --> A4["编辑 / 执行"]
A4 --> A5["验证 / 迭代"]
A5 -->|"未完成"| A3
end
style C1 fill:#dbeafe,stroke:#3b82f6
style D1 fill:#fef3c7,stroke:#f59e0b
style A1 fill:#dcfce7,stroke:#22c55e
这种能力的实现,需要一套远比传统 CLI 工具复杂得多的架构。
Claude Code 的规模
让我们用数字感受一下这个系统的规模:
源码总量: ~510,000 行 TypeScript
内置工具: 40+ 个(Read, Write, Edit, Bash, Grep, Glob, Agent...)
CLI 命令: 80+ 个
MCP 协议集成: ~120,000 行(客户端 + OAuth + 服务发现)
权限规则引擎: ~30,000 行(5 种模式 + 动态分类器)
终端 UI: ~50,000 行(React + Ink 组件库)
作为对比:一个典型的"30 行 Agent Demo"只有模型调用 + 工具循环。Claude Code 在这之上多出的 51 万行代码,就是 Harness——让模型变成可靠产品的一切工程。
1.2 为什么选择 Claude Code 作为学习对象
市面上有很多 AI 编程工具,为什么要深入 Claude Code?
第一,它的源码是完整可见的。 Cursor、Copilot、Windsurf 的核心代码都是闭源的。2026 年 Claude Code 的完整源码通过 npm source map 泄露,这是第一次有生产级 AI 编程助手的内部实现被完整披露,你可以看到每一行实现。
第二,它的架构足够复杂。 51 万行代码不是堆砌出来的——工具系统、权限模型、MCP 集成、多 Agent 协调、IDE Bridge,每个子系统都经过了深思熟虑的设计。这些设计模式可以直接迁移到你自己的 Agent 项目中。
第三,它在真实生产环境中经受了考验。 这不是一个 demo 或 POC,而是被数百万开发者日常使用的工具。每一个"看起来奇怪"的设计决策背后,通常都有一个真实的 bug 或性能问题驱动。
1.3 你将学到什么
读完本书,你将理解:
系统架构层面
- 如何设计一个流式 Agent 循环,让 AI 的思考过程实时可见
- 如何构建可扩展的工具系统,支持 40+ 工具的注册、校验、执行和权限控制
- 如何实现多模式权限模型,在安全和效率之间找到平衡点
- 如何集成 MCP 协议,让第三方工具无缝接入
工程实践层面
- 如何用 async generator 实现可组合、可取消的流式管道
- 如何用 Zod 做运行时类型校验,让工具输入类型安全
- 如何用 React + Ink 构建复杂的终端 UI
- 如何通过并行预加载和特性标志优化 CLI 启动速度
- 如何用 JWT 实现 CLI 与 IDE 之间的安全通信
设计思维层面
- 为什么选择"工具即对象"而不是类继承?
- 为什么用 generator 而不是回调?
- 为什么权限检查要分静态规则和动态分类器两层?
- 为什么 MCP 客户端要实现完整的 OAuth 流程?
这些问题的答案,比代码本身更有价值。
1.4 本书的组织方式
本书按照从外到内、从宏观到微观的顺序组织:
- 架构总览(第2章)——先看全景图,理解各子系统的关系
- 启动与核心循环(第3-5章)——从 CLI 入口到 Agent 循环,理解主干流程
- 工具系统(第6-8章)——深入最核心的工具类型、编排和实现
- 权限与安全(第9-10章)——理解安全模型的设计哲学
- 协议与集成(第11-13章)——MCP、IDE Bridge、LSP 的集成架构
- Agent 进阶(第14-16章)——多 Agent、Skill、上下文管理
- UI 与工程(第17-18章)——终端 UI 和可迁移的设计模式
每一章的结构是:设计意图 → 代码实现 → 可迁移的模式。
让我们开始吧。
1.5 一窥源码——Tool 类型的 30 个字段
为了让"51 万行代码"不是抽象数字——给你一个具体切片:Claude Code 的 Tool 类型(src/Tool.ts:362+)定义了30 个字段和方法——每一个都对应一类真实产品需求:
export type Tool<Input, Output, P> = {
// 身份相关
readonly name: string
aliases?: string[] // 重命名兼容
readonly inputSchema: Input // Zod schema
readonly inputJSONSchema?: ToolInputJSONSchema // MCP 原生 JSON Schema
searchHint?: string // ToolSearch 关键词
// 执行相关
call(args, context, canUseTool, parent, onProgress): Promise<ToolResult>
description(input, options): Promise<string> // 动态描述、每次调用生成
validateInput?(input, context): Promise<ValidationResult>
checkPermissions(input, context): Promise<PermissionResult>
// 能力声明
isConcurrencySafe(input): boolean // 能和其他工具并发吗
isReadOnly(input): boolean // 是读操作吗
isDestructive?(input): boolean // 不可逆吗
isOpenWorld?(input): boolean // 访问外部系统吗
isSearchOrReadCommand?(input): {...} // UI 折叠展示
isEnabled(): boolean
isMcp?: boolean // 来自 MCP server
isLsp?: boolean // 来自 LSP
// UI 和交互
interruptBehavior?(): 'cancel' | 'block' // 被打断时的行为
requiresUserInteraction?(): boolean
// 性能和 token
maxResultSizeChars: number // 结果持久化门槛
readonly strict?: boolean // API 严格模式
readonly shouldDefer?: boolean // 延迟加载 schema
readonly alwaysLoad?: boolean // 总是加载
// 生命周期 hook
backfillObservableInput?(input): void // observer 看到前修饰
inputsEquivalent?(a, b): boolean // 等价性判断
// MCP 元信息
mcpInfo?: { serverName; toolName }
outputSchema?: z.ZodType<unknown>
// 文件路径
getPath?(input): string
// hook 准备
prepareMatcher?(rule: string): (input) => boolean
}
30 个字段,每个都 answer 一个具体的工程问题:
- 没有
isConcurrencySafe——并发执行两个 Edit 会 race condition - 没有
interruptBehavior——用户 Ctrl-C 后长命令依然跑到完成 - 没有
shouldDefer——所有工具 schema 全塞 prompt、token 爆掉 - 没有
backfillObservableInput——hook 看不到 legacy 字段 - 没有
checkPermissions——所有工具都能无条件执行、安全崩 - 没有
prepareMatcher——每次 hook 判断都重新 parse 规则、性能崩
30 个字段 = 30 类真实产品问题的沉淀。这是本书第一个 takeaway——简单的"工具"概念在生产级 Agent 系统里变成 30 维空间。学会识别这 30 个维度、你就知道评估任何 Agent 框架时要问什么问题。
1.6 TypeScript 类型系统作为架构语言
注意 Tool 类型的签名:
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = { ... }
三个泛型参数——让每个工具精确表达自己的输入、输出、进度类型。这不是装饰——它让 TypeScript 编译器在工具调用处做静态类型检查:
// FileReadTool 的 call 接受 { file_path: string, offset?: number, limit?: number }
// FileEditTool 的 call 接受 { file_path: string, old_string: string, new_string: string }
// 类型错了编译期就挂掉
为什么不用 any 省事?——因为 Claude Code 内部有 40+ 工具——手动记住每个的参数不现实。TypeScript 泛型让 IDE 自动补全、重构一处改 30 处——开发效率和正确性双赢。
这是 TypeScript 作为"架构语言"的核心价值——不只是 JS + 类型检查、是让代码的架构意图能被编译器理解。Claude Code 的 51 万行里、类型定义占了可观比例——这些类型是活的文档、也是编译期防御。
对比 Python agent 框架(没有强类型)——每个工具的参数要读文档或 docstring 才知道——LLM 输出错了、要运行时才能发现。TypeScript + Zod 让这些问题提前到编译期——这对 51 万行代码的可维护性是决定性的。
1.7 从 30 字段看产品级 vs demo 级的差距
网上有大量 "30 行代码写一个 Claude Code-like agent" 的教程——它们长这样:
# 30 行版
TOOLS = {
"read_file": lambda path: open(path).read(),
"write_file": lambda path, content: open(path, "w").write(content),
"bash": lambda cmd: subprocess.run(cmd, shell=True).stdout.decode(),
}
def agent(prompt):
while True:
response = llm.call(prompt, tools=TOOLS)
if response.is_final:
return response.text
result = TOOLS[response.tool_name](**response.tool_args)
prompt += f"\nTool result: {result}"
这 30 行能跑——对 hello-world 够用。但生产级 Claude Code 的 51 万行包含的每一块都对应 demo 里的一个失败模式:
- Demo 失败:
bash("rm -rf /")——Claude Code 的 checkPermissions 拦 - Demo 失败:并发
write_file到同一路径 race ——Claude Code 的 isConcurrencySafe 管 - Demo 失败:read 一个 10GB 文件内存爆 ——Claude Code 的 maxResultSizeChars 持久化到磁盘
- Demo 失败:用户 Ctrl-C 长命令继续跑 ——Claude Code 的 interruptBehavior 中止
- Demo 失败:每次 prompt 重传 40 工具 schema、token 爆 ——Claude Code 的 shouldDefer 延迟加载
- Demo 失败:LLM 写错工具名叫 old name ——Claude Code 的 aliases 兼容
- Demo 失败:第三方 MCP server 注入恶意工具 ——Claude Code 的 isMcp + OAuth 隔离
- Demo 失败:tool 输出污染下次 prompt ——Claude Code 的 backfillObservableInput + hook 控制
一百个小问题 × 真实生产的放大系数 = 51 万行。这就是为什么这本书要花几百页讲 Claude Code——每一万行都解决一类你在 demo 里想不到的问题。
读这本书的回报是什么?——下次你自己写 Agent 时、知道要考虑这些维度——不用等到生产环境被用户发现 bug 再回炉。
1.8 Harness 这个词的由来
本书反复用到 "Harness"(挽具)这个词——它是 Anthropic 社区对 "让模型真的能用起来的所有工程代码" 的惯用说法。
词源自 Harness horse——马的挽具、让马的原始力量能被有意图地用。LLM 模型是"马"、Harness 是"让马能拉车"的所有周边工程:
- Prompt engineering——怎么写系统提示、few-shot 例子
- Tool orchestration——40+ 工具的注册、权限、并发
- Memory / Context——对话历史、工作区状态的管理
- Safety rails——什么能做、什么不能做
- Error recovery——出错怎么 retry、怎么降级
- Observability——日志、metrics、用户可见性
- UX layer——终端 UI、流式输出、中断响应
Harness 是比"prompt"更深的功夫——prompt engineering 工程师多、Harness engineering 工程师少。Anthropic 自己内部 Claude Code 团队称自己是 "harness team"——这暗示了整个产品的技术密度。
读 Claude Code 源码、就是学世界顶级的 harness engineering。
1.9 和本系列其他书的关联
这本书是 15 本系列的一本——和其他几本有紧密关联:
- 《MCP 协议设计与实现》——Claude Code 内置完整的 MCP 客户端(~12 万行)、本书第 11 章会反复引用 MCP 书的协议设计
- 《LangChain 设计与实现》——LangChain 的 Agent 概念是 Claude Code 早期灵感之一、本书第 2 章会对比两者的设计哲学
- 《LangGraph 设计与实现》——LangGraph 的 Pregel 模型和 Claude Code 的 Agent 循环是两种不同的 agent runtime 范式——本书第 4 章会深入对比
- 《React 18 源码解析》——Claude Code 用 React + Ink 做终端 UI、本书第 17 章的内容直接建立在 React 源码理解上
- 《Serde 元编程》——Claude Code 大量用 Zod 做 schema validation、和 Serde 的 derive 机制同构——本书第 6 章会关联对比
读这几本合起来、才能完整把握 "Agent 工程" 这个新兴领域的工程全景。
1.10 阅读建议
这本书不是 Claude Code 的使用手册——想学怎么用、看官方文档。这本书是源码内幕——假设读者:
- 用过 Claude Code 至少几次、理解基本概念(对话、工具调用、权限)
- 懂 TypeScript(或至少 JavaScript + 基本类型概念)
- 有一定的架构直觉(至少写过几千行代码的项目)
如果你是前端工程师——React + Ink + async 部分会很顺、工具系统部分需要多点想象。
如果你是后端工程师——工具系统 / 权限 / 协议部分会很顺、前端 UI 部分可以快读。
如果你是 AI 工程师但没大项目经验——Harness 部分是核心财富、会补齐你从 "会 prompt" 到 "能写生产 agent" 的关键一跃。
读完这一章、你应该对 "Claude Code 到底是什么"、"为什么复杂"、"学它有什么价值" 有了一个骨架认识。下一章进入架构全景——从入口到循环到工具、把整个系统串成一张图——让你在深入任何细节前、先有全局视角。
1.11 一个真实对话的全链路——窥探系统内部
用一个简单的对话示例——"请找出项目里所有待办注释"——展开看 Claude Code 内部发生的事:
Step 1:用户输入进入终端——React + Ink 组件接收 keystroke、累积到 prompt buffer。按 Enter 后 prompt 被 serialize、送到 main query loop。
Step 2:QueryEngine 启动(src/QueryEngine.ts:1295 行代码、巨石一块)——拼装 system prompt、user message、注入工具 schema(部分 defer-loaded)、调用 Anthropic API。
Step 3:LLM 响应流开始——Server-Sent Events 一个 token 一个 token 进来。React UI 通过 async generator 实时渲染——用户看到"打字机效果"。
Step 4:LLM 输出 tool_use block——"I'll use Grep to search task markers"——同时输出 tool call:grep("FIXME|待办", { include: "*.{ts,tsx}" })。
Step 5:tool_use block 触发 QueryEngine 的 tool dispatch——找到 GrepTool、调用 validateInput → checkPermissions → call。
Step 6:GrepTool 的 call 方法——用 ripgrep 系统调用、流式返回匹配行。每批匹配通过 onProgress 推到 UI——用户看到搜索进度。
Step 7:tool_result 回填——GrepTool 执行完毕、结果按 maxResultSizeChars 判断——小的直接回给 LLM、大的持久化到磁盘再把文件路径回给 LLM。
Step 8:LLM 拿到 tool_result 继续生成——可能继续调 Read 查看几个匹配、继续调 Grep 缩范围、或者直接生成最终总结。
Step 9:LLM 输出 final text——回到 React UI 流式渲染——用户看到 "找到 47 个待办标记、主要集中在 src/utils/... 和 tests/..."。
Step 10:对话留存——user message + assistant messages 都 append 到 conversation history、写到磁盘的 .claude/history/ 供下次会话恢复。
这 10 步每一步都是 Claude Code 源码里的几百到几千行代码——整个 51 万行就是这 10 步的各种路径和边界情况。本书的 18 章就是把这 10 步拆开讲——每一步的设计动机、代码实现、工程权衡。
1.12 Claude Code 和传统 IDE 插件的边界
一个常见的比较——Claude Code vs GitHub Copilot / Cursor——三者都是 "AI 辅助编程"、但架构和定位完全不同:
Copilot(IDE 内嵌):
- 触发:每次输入后自动触发
- 输出:单行/多行补全、inline 展示
- 交互:被动 ——你打字它补、你接受或拒绝
- Tool:只有"补全代码" 这一个 tool
- 架构复杂度:主要在补全 UI 和 ranking 算法
Cursor(IDE Fork):
- 触发:对话框 + 文件选择器
- 输出:多文件 diff
- 交互:半主动——你描述需求、AI 产 diff、你 review
- Tool:读文件、写文件、运行命令——基础但没到 Agent 级
- 架构复杂度:编辑器集成 + 多文件上下文管理
Claude Code(终端 CLI):
- 触发:显式命令(
claude) - 输出:对话 + 自主工具调用
- 交互:主动 ——你描述目标、AI 自主规划 + 执行、你监督
- Tool:40+ 工具——读写、搜索、命令、权限、MCP 扩展、Agent 子进程
- 架构复杂度:完整的 agent runtime + harness 全栈
三者服务不同的心智距离——Copilot 是 "加速你打字"、Cursor 是 "协助你编辑"、Claude Code 是 "替你执行任务"。距离越远、需要的 harness 越多——这就是 Claude Code 比另外两个复杂度高一个量级的根本原因。
选哪个?——看任务类型:
- "补完这个函数"——Copilot 最快
- "重构这个模块、改 5 个文件"——Cursor 最顺
- "调研项目、找 bug、自动修复、写测试"——只有 Claude Code 能自主完成
三者是互补而非替代——真实开发者往往三个都用、按场景切换。
1.13 为什么讲 TypeScript 而不是 Go/Rust 实现
有读者可能问——Anthropic 为什么用 TypeScript 写 Claude Code、不用更"性能的" Go 或 Rust?
几个原因:
① 生态成熟度——LLM 生态的 SDK 以 Python 和 TypeScript 为主、Rust/Go 的 LLM SDK 是二等公民。Anthropic 自己的 SDK 用 TS 先发。
② 快速迭代——Claude Code 2024-2026 是产品高速迭代期——TypeScript 的类型推导 + 热重载比 Rust 编译时间友好得多。
③ UI 生态——React + Ink 让终端 UI 能用前端的组件思维——Rust 的 tui-rs / Ratatui 要自己手写布局、开发效率低 3-5 倍。
④ LLM 消费者的生态定位——Claude Code 的用户主要是前端 / 后端 / AI 工程师——TypeScript 是最大公约数、开源后二次开发的门槛低。
⑤ 性能不是瓶颈——Claude Code 的瓶颈是LLM API 延迟(秒级)、不是 JS 执行速度(毫秒级)。用 Rust 重写能省 10ms——但用户感知是 LLM 的 2 秒响应——优化 UI 状态比优化底层有意义。
⑥ 招聘池——全球 TypeScript 开发者比 Rust 多 10×——维护团队扩容容易。
TypeScript 的劣势——运行时类型擦除、需要 Zod 做 runtime validation、Node.js 内存占用高于 Rust 进程——但对这个产品形态,这些劣势可接受。
工具选择永远是 "用最适合场景的"——不是 trendy 的、也不是极限性能的、而是 "能快速交付优质产品的"。Anthropic 的选择给所有团队一个参考——追求产品速度时、别让 Rust 迷恋拖慢你。
1.14 Claude Code 源码目录结构全景
在进入后续章节前、扫一眼源码目录——让你对后续章节对应的文件位置有个地图。claude-code-main/src/ 下面 53 个顶层目录/文件——按职责分组如下:
核心 runtime(~100k 行):
QueryEngine.ts(1295 行)——主查询循环、消息编排Tool.ts(792 行)——工具类型定义Task.ts(125 行)——后台任务管理commands/、commands.ts——slash commands 注册hooks/——各类 pre/post hook 的协议context/——对话上下文、消息流coordinator/——多 agent 协调
工具实现(~150k 行):
tools/(40+ 子目录)——每个工具独立文件夹services/mcp/——MCP 协议客户端实现services/lsp/——LSP 客户端(第 12 章)
UI 层(~50k 行):
components/——React + Ink 组件ink/、ink.tsx——Ink 集成dialogLaunchers.tsx——对话框触发器keybindings/——快捷键系统
入口和配置(~30k 行):
entrypoints/——不同入口(cli、sdk、bridge)cli/——CLI 解析和路由bootstrap/——启动前的预加载和初始化constants/——常量定义
持久化和迁移(~20k 行):
memdir/——会话记忆目录history.ts——对话历史migrations/——老版本数据迁移
bridge 和集成(~40k 行):
bridge/——和 IDE 的桥接协议native-ts/——原生 TypeScript 集成assistant/——assistant 配置和 profile
其他(~120k 行):
buddy/——另一种 agent 模式(第 14 章)cost-tracker.ts、costHook.ts——成本追踪interactiveHelpers.tsx——交互辅助
这53 个顶层条目映射到本书的 18 章——每一章深入 1-3 个模块。你在读某一章时、可以直接翻到对应源码文件对照看——是本书的核心学习方式。
1.15 源码可见性的来源
这本书的契机是 2026 年 Claude Code 完整源码通过 npm source map 泄露。npm 默认发布的 .js.map 文件包含原始 TypeScript 源码作为 sourceContent —— 用 source-map-explorer 类工具几分钟就能 reverse 回 .ts。
为什么没 strip source map?推测原因:
- Strip 后 debugging 体验受损:用户报告 bug 时栈信息变乱
- Minify 后的 JS stack trace 对用户无意义,map 是关键
- Product velocity 优先于代码隐私:每天发多版,strip 流程会拖慢
这种意外公开的边界很灰——source map 本身是默认公开的 artifact,但 Anthropic 没有显式同意它被系统性研究。本书的处理方式是:只讲设计原则、不发布完整代码、引用最小必要片段,用于教学而非竞品复制。
1.16 术语约定
全书会用到一些特定术语——先统一约定、避免混淆:
- Agent——指 Claude Code 的某次运行实例(类似"进程")
- Sub-agent——指
AgentTool启动的子进程(第 14 章) - Tool——指 Claude Code 的工具实现(区别于"工具"泛指)
- Session——指一次完整的对话会话(磁盘持久化)
- Harness——让模型变成可靠产品的所有工程
- Turn——一次 user → assistant 的来回(对话的基本单元)
- Stream——LLM 的 token-by-token 输出流
- Hook——插入到特定生命周期点的扩展代码
- MCP——Model Context Protocol、第三方工具协议
这些术语在第 2 章会扩充,每个术语后都有精确定义。
1.17 三个真实数字——感受代码的密度
再给你三个数字感受 Claude Code 的工程密度:
数字一:Tool.ts 单文件 792 行、30 个字段和方法——这只是类型定义、不是实现。真实工具实现在 40 个子目录里、每个 500-3000 行——光 FileEditTool 的实现就 ~2000 行、因为要处理:
- 文件不存在的 create
- 文件已存在的 edit
- 多 replace 的 batch
- LSP 诊断集成
- git rename detection
- 权限/hook 调用
- UI diff 渲染
- 错误恢复
- 测试 mock 接口
FileEdit 这一个工具的复杂度、就超过你想象的"用 fs.writeFile 一行搞定"几十倍。每个工具都如此——40 × 2000 = 80 万行工具代码(实际统计约 60 万行、算上 shared utilities)。
数字二:80+ CLI 命令——包括 /help、/clear、/compact、/status、/review、/fast、/worktree、/schedule、/sidebar、/logout、/debug 等。每个命令后面都有完整的 React 组件、slash 解析、参数校验——平均每个 300 行、合计约 24k 行。
数字三:40+ 键盘快捷键和 shortcuts——按键组合 + 上下文映射——支持 Vim 模式、Emacs 模式、默认模式——三种模式下同一键位可能有完全不同含义——键绑定系统本身就是一个小型状态机。
这三个数字合计约 70 万行——还没算 MCP、权限引擎、UI、bridge、bootstrap 等——51 万行只是核心 src/、所有 build artifacts 加起来应该是 80-100 万行级。
这个量级的代码量在 CLI 工具里是极端罕见的——Git 的核心约 200 万行(主要 C)、VSCode 的核心约 1300 万行(TS)——Claude Code 作为一个不到 2 年的项目、51 万行是惊人的工程产出密度。
1.18 第 2 章的骨架图预告
下一章会给出一张一屏图——把 Claude Code 整个系统画成这样:
┌──────────────────────────────────────────────────┐
│ CLI entry (claude) │
│ ↓ │
│ Bootstrap (parallel preload, feature flags) │
│ ↓ │
│ QueryEngine (main loop) │
│ ↓ ↑ │
│ ┌──────┴──────┐ │
│ │ LLM API │ ←→ Streaming (SSE) │
│ └──────┬──────┘ │
│ ↓ │
│ Tool Dispatch │
│ ↓ │
│ ┌──────────────────────┐ │
│ │ validateInput() │ │
│ │ checkPermissions() │ ←→ Permission Engine │
│ │ Hooks (pre/post) │ │
│ │ call() │ │
│ └──────────────────────┘ │
│ ↓ │
│ Tool Result │
│ ↓ │
│ React + Ink UI (streaming) │
│ ↓ │
│ History / Persistence │
└──────────────────────────────────────────────────┘
这张图的每一条线都对应一章——18 章读完、这张图就从简化的 box-and-arrow 变成带代码记忆的真实系统。
1.19 代码阅读方法论
分享一套读复杂 TypeScript 项目的方法论——在后续章节反复用到:
① 先读 type 定义、再读 implementation——Tool.ts 的 792 行里一半是类型定义——看懂这些类型就懂了 80% 的设计意图。实现只是填充类型骨架的血肉。
② 入口优先——先看 main.tsx / cli/ 入口——顺着调用链下沉——比随机翻某个文件理解得快 10 倍。
③ 找 central hub——QueryEngine / Tool / Task 这种被所有地方引用的核心类型——是理解整体的锚点。
④ 按"数据流"读、不是按"调用栈"读——跟踪用户输入怎么变成 LLM 响应、工具调用怎么回传结果——比读每个函数的调用关系顺得多。
⑤ 大量用 grep / search——找一个函数在哪被调——ripgrep + context lines——几秒知道一个函数的生命周期。
⑥ 画图同步——一边读一边在纸上/白板画 box-and-arrow——主动构造心智模型——比单纯看代码记住多 5 倍。
⑦ 对比类比——遇到新概念问自己 "像别的什么"——比如 Tool 的 30 字段类似于Rust trait 的多 method、或Java Servlet 的 lifecycle methods——类比能快速建立理解。
⑧ 不懂就跳过——第一遍读完整个目录允许 30% 不懂——第二遍重点攻这 30%。线性读完一遍 > 前 20% 读透但永远没读后面——先拿到整体感最关键。
这套方法论在读 React / Tokio / serde 等复杂项目时同样适用。
1.20 一段真实源码——ToolPermissionContext
在结束前、再给你一段真实源码切片感受 Claude Code 的密度——Tool.ts:123-148 的 ToolPermissionContext:
export type ToolPermissionContext = DeepImmutable<{
mode: PermissionMode
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
alwaysAllowRules: ToolPermissionRulesBySource
alwaysDenyRules: ToolPermissionRulesBySource
alwaysAskRules: ToolPermissionRulesBySource
isBypassPermissionsModeAvailable: boolean
isAutoModeAvailable?: boolean
strippedDangerousRules?: ToolPermissionRulesBySource
/** When true, permission prompts are auto-denied
* (e.g., background agents that can't show UI) */
shouldAvoidPermissionPrompts?: boolean
/** When true, automated checks (classifier, hooks) are awaited before
* showing the permission dialog (coordinator workers) */
awaitAutomatedChecksBeforeDialog?: boolean
/** Stores the permission mode before model-initiated plan mode entry,
* so it can be restored on exit */
prePlanMode?: PermissionMode
}>
短短 25 行代码、至少藏着 8 个深层工程决策:
① DeepImmutable<{...}> wrapper——让整个类型递归只读(包括 Map、包括嵌套对象)——防止中间层代码 mutate context。这比 Rust 的 Arc<T> 还严格——TypeScript 类型层面就禁止改动。
② Map<string, AdditionalWorkingDirectory> 而非 Record<string, ...>——Map 保持插入顺序、允许非字符串 key(未来扩展)、iteration 更清晰——Record 是 plain object、有 prototype pollution 风险。
③ 三种规则分类 alwaysAllow/Deny/Ask——对应 三种权限决策——比"allow/deny"两态丰富、让"ask once then remember" 这类交互成为可能。
④ ToolPermissionRulesBySource——权限规则按来源分组(workspace 配置、用户全局配置、CLI flag 等)——让用户知道哪条规则来自哪里、易于 debug。
⑤ isBypassPermissionsModeAvailable——运行时 feature flag——某些环境(企业部署)禁用 bypass 模式——让权限系统有 defense-in-depth。
⑥ shouldAvoidPermissionPrompts——区分 "可以弹 UI 问用户" vs "后台 agent 不能问"——后台 agent 看到权限问题只能拒绝、不能阻塞等用户。
⑦ awaitAutomatedChecksBeforeDialog——coordinator workers 的特殊需要——先跑 classifier + hook、再决定要不要问用户——让 dialog 只出现在真正需要人决策的场景、减少打扰。
⑧ prePlanMode——Plan mode 进出的状态保存——从 plan mode 出来后能回到进入前的状态——避免用户丢失上下文。
25 行代码 = 8 个产品决策 = 多年用户反馈的沉淀。这就是本书要拆解的东西——让你看到代码之下的产品心智。
1.21 searchHint 字段——ToolSearch 机制的开端
Tool 类型里有一个小字段、承载着 Claude Code 最精妙的"token 管理策略"——searchHint:
/**
* One-line capability phrase used by ToolSearch for keyword matching.
* Helps the model find this tool via keyword search when it's deferred.
* 3–10 words, no trailing period.
* Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).
*/
searchHint?: string
这个字段存在的原因——Claude Code 的40+ 工具如果 schema 全塞 prompt、几千 token 都不够——每次调用都浪费 context window。
解法是 ToolSearch 机制——把一部分工具标记为 deferred、它们的 schema 不进初始 prompt——LLM 要用时调 ToolSearch tool 搜索、搜到后才加载 schema。
searchHint 就是供 ToolSearch 做关键词匹配的——每个工具写 3-10 个关键词、让 LLM 能 "I need to edit a jupyter notebook" → ToolSearch 匹配到 NotebookEdit。
两个细节约束:
- 3–10 words——太短关键词不够、太长 schema 发送时反而更贵
- "no trailing period"——避免拼接时出现 "jupyter. use for..." 的标点问题
- "Prefer terms not already in the tool name"——别重复工具名本身、扩展搜索命中率
这个策略的数字效果:
- 无 ToolSearch:40 工具 × 每工具 ~500 token = 20,000 token 的工具 schema
- 有 ToolSearch:5 核心工具常驻 × 500 + 35 deferred × 20 token searchHint = 3,200 token
- 节省 83% 工具相关 token
省下的 token 给对话历史 + 代码上下文,让 Claude Code 能在同样 context window 下处理更长的任务。
这个小字段背后是整个 token 经济学——40+ 工具、8-token 的 searchHint 规范、ToolSearch 的三阶段调用,合起来是 Claude Code 能轻松使用这么多工具而不炸 context window 的关键。本书第 8 章会完整讲 ToolSearch 机制。
1.22 maxResultSizeChars 字段——输出爆炸的防护
再看一个字段——maxResultSizeChars:
/**
* Maximum size in characters for tool result before it gets persisted to disk.
* When exceeded, the result is saved to a file and Claude receives a preview
* with the file path instead of the full content.
*
* Set to Infinity for tools whose output must never be persisted (e.g. Read,
* where persisting creates a circular Read→file→Read loop and the tool
* already self-bounds via its own limits).
*/
maxResultSizeChars: number
问题场景:Grep 搜索一个大代码库、匹配 10000 行——直接塞进 LLM prompt 会爆 context。
解法是output persistence——结果超过 maxResultSizeChars(典型 4000-8000)时、写到磁盘、只回给 LLM 一个预览 + 文件路径。LLM 想看完整输出、用 Read 工具读那个文件——按需加载。
Infinity 的特殊值——Read 工具的输出不能持久化——因为 "持久化 Read 的结果到文件、再 Read 那个文件" 会形成无限循环。Read 有自己的 offset/limit 机制自 self-bound。
这种"结果太大自动持久化、LLM 按需加载" 的模式是LLM agent 必备机制——否则一次 grep 能把整个 context 吃光。
实现细节——Claude Code 内部有 ContentReplacementState 机制(utils/toolResultStorage)——把持久化的内容替换成 placeholder、后续 LLM 可以 reference。
这个机制的巧妙处——对 LLM 透明——LLM 看到的 "结果太大、存到 /tmp/xxx.txt" 就当作正常的工具输出、自然地继续 chain-of-thought——Read 一下就拿到详情。
一个 number field + 几行持久化逻辑 = LLM agent 能处理任意大小输出的关键 trick。
1.23 interruptBehavior ——用户 Ctrl-C 的哲学
最后一个字段——interruptBehavior:
/**
* What should happen when the user submits a new message while this tool
* is running.
*
* - `'cancel'` — stop the tool and discard its result
* - `'block'` — keep running; the new message waits
*
* Defaults to `'block'` when not implemented.
*/
interruptBehavior?(): 'cancel' | 'block'
问题场景:Bash 工具正在跑 npm test(10 分钟)、用户突然说 "算了先写个 README"——怎么办?
两种策略:
- cancel——立刻停 test、开始写 README——响应快、但 test 状态丢失
- block——让 test 跑完、完了再处理新 message——state 不丢、但用户等太久
Claude Code 的选择是让每个工具自己决定——默认 block、特定工具可以 override 成 cancel。
典型选择:
- Bash 工具——可能 cancel(比如 watch mode 的 bash)
- FileEdit——block(编辑是原子的、半途停会留损坏文件)
- Grep / Read——默认 block、但大部分执行很快、用户几乎感知不到
- AgentTool(子 agent)——block、子 agent 的结果珍贵
这种 "允许工具自定义中断语义" 的设计比 "统一 cancel / 统一 block" 更合理——因为不同工具的重启代价不同。
用户视角——在终端按 Ctrl-C、期望 "应该立刻响应"、但 Claude Code 的工具可能选择 block——这看似违反用户预期。
Claude Code 的妥协——强 Ctrl-C(两次连按)强制 cancel 所有、一次只是 "优雅中断请求"、让当前工具自己选——两种级别的中断让用户能 fine-tune。
这个字段的存在提醒我们——Agent 系统的中断语义比想象复杂——写一个 Agent 要想好"正在执行的工作被打断时该怎样"。
1.24 backfillObservableInput 字段的故事
最后再讲一个 Tool 里的细节——backfillObservableInput 字段:
/**
* Called on copies of tool_use input before observers see it (SDK stream,
* transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
* to add legacy/derived fields. Must be idempotent. The original API-bound
* input is never mutated (preserves prompt cache). Not re-applied when a
* hook/permission returns a fresh updatedInput — those own their shape.
*/
backfillObservableInput?(input: Record<string, unknown>): void
这个字段回答的问题——tool 的 input schema 升级了、老代码还传老字段、怎么 observer 看到完整信息?
场景:假设 FileEditTool 老版本接受 { path, old, new }、新版本改成 { file_path, old_string, new_string }。Claude API 用新 schema、但有的 MCP 插件可能还传老字段。
解法——backfillObservableInput 在 input 传给 observers 前填入 legacy 字段:
backfillObservableInput(input) {
if (!input.file_path && input.path) input.file_path = input.path
if (!input.old_string && input.old) input.old_string = input.old
if (!input.new_string && input.new) input.new_string = input.new
}
observer 们(hook、transcript、UI)看到的 input 既包含新字段也包含老字段——老代码依然能读到期望的字段、新代码也能读新字段。
注意注释里的三个约束:
- "Mutate in place"——直接改 input 对象、不返回新 object——省内存
- "Must be idempotent"——可能被多次调用、第二次调用不该产生不同结果
- "The original API-bound input is never mutated (preserves prompt cache)"——API 发给 Anthropic 的 input 不被 mutate——保持 prompt 的缓存 hash 稳定
第 3 点最妙——Anthropic API 对相同 prompt 有缓存(节省 token 成本)——如果每次请求时 input 里都 backfill 新字段、prompt hash 会变、缓存失效。所以 backfillObservableInput 只对 observer 副本调用、不对 API 副本调用——既兼容观察者、又保缓存。
"一个 input 两份副本、分别服务不同消费者"的架构是生产 AI 产品的常见优化——cache hit 能让成本降低 5-10×。
这个字段的存在体现了 Anthropic 对 token 成本的执着:每一个可能影响缓存的代码路径都经过精心设计。下一章进入架构全景图。