第 16 章:你的第一次追踪

个人公众号

源码验证日期:2026-05-15,基于 commit 0d81bb6

你读完了消息的旅程。现在,你要亲手追踪一次。


路线图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
graph LR
CH03["③ 准备工具箱"] --> CH04["④ 回车键之后"]
CH04 --> CH05["⑤ 消息被装进信封"]
CH05 --> CH06["⑥ 工具的注册与发现"]
CH06 --> CH07["⑦ 信封飞向远方"]
CH07 --> CH08["⑧ 文字一个字一个字地回来"]
CH08 --> CH09["⑨ AI说要执行命令"]
CH09 --> CH10["⑩ 命令真的被执行了"]
CH10 --> CH11["⑪ 你确定吗"]
CH11 --> CH12["⑫ 结果回到AI手中"]
CH12 --> CH13["⑬ 对话越来越长"]
CH13 --> CH14["⑭ 屏幕上的每一帧"]
CH14 --> CH15["⑮ 循环的终点与起点"]
CH15 --> CH16["⑯ 你的第一次追踪<br/>⬅ 你在这里"]

style CH16 fill:#4CAF50,color:#fff,stroke:#333
style CH03 fill:#e8f5e9,stroke:#333
style CH04 fill:#e8f5e9,stroke:#333
style CH05 fill:#e8f5e9,stroke:#333
style CH06 fill:#e8f5e9,stroke:#333
style CH07 fill:#e8f5e9,stroke:#333
style CH08 fill:#e8f5e9,stroke:#333
style CH09 fill:#e8f5e9,stroke:#333
style CH10 fill:#e8f5e9,stroke:#333
style CH11 fill:#e8f5e9,stroke:#333
style CH12 fill:#e8f5e9,stroke:#333
style CH13 fill:#e8f5e9,stroke:#333
style CH14 fill:#e8f5e9,stroke:#333
style CH15 fill:#e8f5e9,stroke:#333

打开源码

如果你还没有 Claude Code 的源码,现在是时候把它拿出来了:

1
2
git clone https://github.com/anthropics/claude-code.git
cd claude-code

用你喜欢的编辑器打开这个目录。唯一的要求是能方便地在文件之间跳转、搜索关键词。

准备好了吗?我们来追踪一条消息。


设定场景

假设你是 Claude Code 团队的新成员。有用户报告了一个问题:”我明明装了那个工具,但 AI 说找不到。为什么这个工具没有被加载?”

你不知道答案。但你知道消息是怎么走的——你刚刚花了十五章学完了整条链路。现在该用上了。

你的追踪策略:从入口开始,沿着消息的路径,在每个关键检查点停下来看看。


第一站:入口

打开 src/main.tsx,找到 main() 函数。这是整个程序的入口。当你在终端里输入 claude 并按下回车,控制权就到了这里。

main() 函数内部,你会看到它做了一大堆初始化工作:解析命令行参数、加载配置文件、设置日志、初始化权限系统。然后,它启动了 REPL。

先不在入口处停留太久。加一行日志,留个记号:

1
console.log("[TRACE] main() entered")

第二站:REPL

消息的旅程从用户输入开始,而用户输入发生在 REPL 里。

打开 src/screens/REPL.tsx,找到 REPL 组件的定义。在 REPL 组件里,关注用户消息的提交逻辑。当你在终端里输入”帮我修个 bug”并按下回车,这条消息就在这里被捕获。

加一行日志:

1
console.log("[TRACE] REPL received user message:", userMessage)

第三站:消息封装

你的文字被 REPL 捕获了。但它不能以原始文字的形式被发送给 AI——它需要被”装进信封”。

在消息封装函数里加日志,你会看到你的”帮我修个 bug”变成了一个带着 role: "user"content: [...] 结构的 JSON 对象。从这一刻起,它不再是一行纯文字,而是一个”信封”。


第四站:API 调用

信封封装好了,要被寄出去了。在 queryModelWithStreaming 函数的开头加:

1
console.log("[TRACE] API call started, model:", model)

你会看到这行日志在几毫秒内被触发。然后程序”暂停”——在等待网络响应。几秒后,AI 的回答开始流回来。


第五站:工具分发——关键线索

我们的场景是”为什么工具没有被加载”。所以工具分发这一站是关键。

打开 src/services/tools/toolExecution.ts,找到 runToolUse 函数。函数的第一件事是根据工具名字找到对应的工具对象:

1
let tool = findToolByName(toolUseContext.options.tools, toolName)

加两行关键日志:

1
2
console.log("[TRACE] Tool lookup:", toolName, tool ? "FOUND" : "NOT FOUND")
console.log("[TRACE] Available tools:", toolUseContext.options.tools.map(t => t.name))

运行程序,重现用户报告的问题。当 AI 尝试使用那个”找不到”的工具时,你会看到:

1
2
[TRACE] Tool lookup: SomeTool NOT FOUND
[TRACE] Available tools: ["Read", "Write", "Bash", "Glob", "Grep", "Edit", ...]

线索出现了。SomeTool 确实不在可用工具列表里。但为什么?是工具没有被注册,还是注册了但被过滤掉了?

这就是追踪的价值:你不需要理解整个系统,你只需要在关键位置停下来,看看数据的实际状态。


第六站:权限检查

在工具被执行之前,还有一道关卡。打开 src/utils/permissions/permissions.ts,找到 hasPermissionsToUseTool 函数。

在我们的场景里,如果工具根本没被加载,权限检查就不会被触发——因为代码在找到工具之后才会走到权限检查。但如果追踪的是一个”工具被找到了但没执行”的问题,权限检查就是必看的地方。

加一行日志:

1
console.log("[TRACE] Permission check for:", toolName, "result:", allowed)

第七站:工具执行

以 Bash 工具为例,打开 src/tools/BashTool/BashTool.tsx。找到 call 函数,在它启动子进程的地方加日志:

1
console.log("[TRACE] BashTool executing:", command)

第八站:压缩

如果你的追踪会话很长,压缩会被触发。在 shouldAutoCompact 里加日志:

1
console.log("[TRACE] Auto-compact check:", tokenCount, "vs threshold:", threshold)

第九站:回到屏幕

一切处理完毕,最终都要显示在你的终端上。如果屏幕上显示的内容不对,问题很可能出在 REPL 的渲染逻辑里。


定位问题:为什么工具没被加载?

通过前面几站的日志,你已经知道了:

  1. 工具确实不在可用列表里(第五站的日志告诉你的)
  2. 不是权限问题(因为工具根本没进入权限检查环节)

那问题就出在更早的地方:工具的注册阶段。工具是在程序启动时被注册到列表里的。回到工具初始化的代码,搜索工具注册相关的逻辑。你可能会发现工具的注册受到某个配置项的控制——比如一个功能开关,或者一个允许/禁止工具列表。

找到那个配置项,检查它的值。修改配置,重启程序,再次追踪——这次第五站的日志应该显示 FOUND 而不是 NOT FOUND

问题定位完毕。修复方法可能只是一行配置的改动。


你刚刚做了什么

回顾你的追踪过程:

  1. 你从程序入口 main() 开始,确认程序正常启动
  2. 你检查 REPL 组件,确认用户消息被正确捕获
  3. 你查看消息封装函数,确认消息被正确打包
  4. 你检查 API 调用,确认请求被正确发送
  5. 你在工具分发处找到线索——目标工具不在可用列表里
  6. 你检查权限系统,排除了权限问题
  7. 你回溯到工具注册阶段,找到了根本原因:配置项把工具禁用了

整个过程不需要调试器,不需要复杂的开发环境,只需要几个 console.log 和对消息链路的理解。


追踪的艺术

你在这次追踪中用到的技巧,适用于任何大型代码库:

从已知点出发。 你知道消息从 main() 开始,所以从那里出发。你沿着消息的路径一步步走。

在岔路口停下来。 每次消息从一个模块传递到另一个模块,就是一个”岔路口”。在岔路口加日志,看看消息的实际状态。

排除法。 你先排除了”工具不存在”,然后排除了”权限拒绝”,最终锁定了”注册阶段”。每排除一个可能性,搜索范围就缩小一圈。

看数据,不看猜测。 你没有靠猜——你用日志看到了实际的数据。数据驱动的调试比直觉驱动的调试高效得多。


常见追踪错误

常见错误症状检查方法
日志没输出加了 console.log 但终端没看到确认在正确的分支加了日志;检查是否走了 --verbose 模式
找到错误的函数追踪到一半发现走错了模块回顾路线图中的函数速查表,确认函数名和文件路径
工具名大小写不匹配AI 调用的工具名是 Read,你找的是 read工具注册表里的名字是 PascalCase;用 grep -i 做不区分大小写的搜索
动态导入未触发修改了文件但日志没变化Claude Code 使用动态 import(),某些模块在首次使用时才加载;确认你的追踪场景确实触发了目标代码路径
修改被构建缓存覆盖改了 .ts 文件但运行的是旧的 .js确认你在运行源码(bun run dev)还是构建产物(cli.js);如果是后者,先 bun run build
日志太多看不清--verbose 输出了几千行,找不到你的 [TRACE]grep TRACE 过滤,或改用独特的前缀如 [MY_TRACE_001]

试试看

练习一:换一个场景追踪(10 分钟)

不追踪”工具找不到”,改追踪”为什么 AI 说没有权限”。在 hasPermissionsToUseTool 的入口、每个检查点、出口各加一行日志。让 AI 执行一个需要权限的操作(如 Bash(npm install)),观察日志输出的决策路径。画出你看到的实际决策流程图。

练习二:追踪流式数据(15 分钟)

queryModelWithStreamingfor await 循环内,为每种 SSE 事件类型加日志(参考第 8 章的 switch 语句)。发一条简单消息(如”hello”),记录你观察到的完整事件序列。对比第 8 章中的事件顺序图——你看到的事件类型和顺序与课文一致吗?

练习三:反向追踪(15 分钟)

不从头开始,从”输出”反向追溯。找到屏幕更新函数 handleMessageFromStream,往上逐层追溯:是谁调用了它?调用者又是被谁调用的?一直追溯到 queryLoop。画出一张反向调用链图。这种”从后往前读”的能力在排查 UI 问题时特别有用。


检查点

  • 追踪完整链路:入口 main() → REPL → 消息封装 → API 调用 → 工具分发 → 权限检查 → 工具执行 → 压缩 → 屏幕渲染 → 循环
  • 关键函数速查:15 个站点的核心函数名和文件路径(见速查表)
  • 追踪技巧:从已知点出发、在岔路口加日志、用排除法缩小范围、看数据不靠猜测
  • trace 命令:用 claude --verbose 或自定义 [TRACE] 前缀日志追踪实际执行路径

完整调用链全景图

从用户按下回车到终端显示回复,我们追踪了一条完整的路径。现在是时候把所有站点串联起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
graph TB
subgraph "卷一:消息的旅程"
direction TB
E["③ 准备工具箱<br/>ch03: getTools → assembleToolPool"]
U["④ 回车键之后<br/>ch04: PromptInput → processUserInput"]
M["⑤ 消息被装进信封<br/>ch05: createUserMessage"]
T["⑥ 工具的注册与发现<br/>ch06: buildTool → Tool 类型"]
S["⑦ 信封飞向远方<br/>ch07: query → queryLoop → deps.callModel"]
ST["⑧ 文字一个字一个字地回来<br/>ch08: SSE → for await → yield"]
TU["⑨ AI说要执行命令<br/>ch09: tool_use → findToolByName"]
EX["⑩ 命令真的被执行了<br/>ch10: Zod → spawn → stdout"]
P["⑪ 你确定吗<br/>ch11: hasPermissionsToUseTool"]
R["⑫ 结果回到AI手中<br/>ch12: tool_result → while(true)"]
C["⑬ 对话越来越长<br/>ch13: shouldAutoCompact → compactConversation"]
RN["⑭ 屏幕上的每一帧<br/>ch14: handleMessageFromStream → Ink"]
LP["⑮ 循环的终点与起点<br/>ch15: 三阶段恢复 → stop hooks"]
end

E --> U --> M --> T --> S
S --> ST --> TU --> EX
EX --> P --> R
R --> C --> RN --> LP
LP -->|"continue"| S

style LP fill:#FFD54F,stroke:#F57F17,color:#000

各站关键函数速查

章节核心函数
准备工具箱ch03getTools(), assembleToolPool()
回车键之后ch04processUserInput(), createUserMessage()
消息装进信封ch05userMessageToMessageParam()
工具注册与发现ch06buildTool(), findToolByName()
信封飞向远方ch07query(), queryLoop(), productionDeps()
文字流式回来ch08queryModelWithStreaming(), updateUsage()
AI说要执行命令ch09StreamingToolExecutor.addTool(), canExecuteTool()
命令真的被执行ch10checkPermissionsAndCallTool(), spawn()
你确定吗ch11hasPermissionsToUseTool(), bashToolHasPermission()
结果回到AI手中ch12addToolResult(), while(true)
对话越来越长ch13shouldAutoCompact(), compactConversation()
屏幕上的每一帧ch14handleMessageFromStream(), onRender()
循环的终点与起点ch15State, 三阶段恢复, handleStopHooks()

卷一:你学到了什么

十五章的故事,加上一次实战追踪。你手里多了哪些能力?

你能追踪一条消息的完整旅程。 从你在终端里敲下回车键,到 AI 的回答出现在屏幕上,中间经过的每一个步骤你都知道:REPL 捕获输入、消息被封装、API 调用发起、流式数据返回、工具被识别和分发、权限被检查、命令被执行、结果被送回、对话被压缩、界面被更新。这些不再是黑箱。

你能读懂 Claude Code 的源码结构。 消息相关的代码在 src/services/api/ 里,工具相关的代码在 src/services/tools/src/tools/ 里,UI 相关的代码在 src/screens/src/components/ 里,压缩相关的代码在 src/services/compact/ 里。

你能在源码里定位问题。 在关键位置加日志,沿着消息路径排查,用排除法缩小范围。

你理解了核心设计模式。 AsyncGenerator 流式传播、不可变状态更新、分层优先级、缓存驱动设计、防御性默认值——这些不只是理论,你在真实代码里看到了它们的应用。


但你还没看到引擎室

你跟着一条消息走完了它的旅程。你知道了它从哪里来、经过了哪些站点、最终去了哪里。这是一条完整的观光路线。

但观光路线不会带你进入引擎室。想想这些问题:

工具注册表是怎么组织的,才能让新工具随时被添加进来而不用修改任何已有代码?压缩算法是怎么设计的,才能保证摘要既简洁又不丢失关键信息?权限系统是怎么实现的,才能兼顾安全性和用户友好?Ink 框架是怎么把 React 组件渲染到终端的?

这些问题有一个共同特点:它们问的不是”发生了什么”,而是”为什么这样设计”。

卷二回答这些问题。你在卷一学会了看消息的旅程。卷二,你要学会看代码的骨架。


← 上一章:循环的终点与起点

卷一完。卷二:引擎室的秘密。