第 09 章:AI说要执行命令

个人公众号

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

AI 的回答不是普通的文字。在流式数据中,出现了一个特殊的标记:tool_use。AI 想执行一条命令。

上一章里,我们看着 AI 的回答一个字一个字地流回来——像水龙头一样,一滴一滴。但你有没有想过:如果 AI 只会”说话”,它怎么帮你修 bug?

真正的助手会说:”让我来看看”,然后自己去翻代码;会说:”我来帮你运行一下测试”,然后自己去敲命令。Claude Code 里的 AI 就是这样的助手。它不只是说话——它会”动手”。

但”动手”这件事,在代码世界里怎么表达?一条流式传输的数据流里,怎么区分”AI 在说话”和”AI 要做事”?

答案就藏在一个小小的标记里:tool_use


路线图

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说要执行命令<br/>⬅ 你在这里"]
CH09 --> CH10["⑩ 命令真的被执行了"]
CH10 --> CH11["⑪ 你确定吗"]
CH11 --> CH12["⑫ 结果回到AI手中"]
CH12 --> CH13["⑬ 对话越来越长"]
CH13 --> CH14["⑭ 屏幕上的每一帧"]
CH14 --> CH15["⑮ 循环的终点与起点"]
CH15 --> CH16["⑯ 你的第一次追踪"]

style CH09 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 CH10 fill:#e1f5fe,stroke:#333
style CH11 fill:#e1f5fe,stroke:#333
style CH12 fill:#e1f5fe,stroke:#333
style CH13 fill:#e1f5fe,stroke:#333
style CH14 fill:#e1f5fe,stroke:#333
style CH15 fill:#e1f5fe,stroke:#333
style CH16 fill:#e1f5fe,stroke:#333

一条消息,两种内容

AI 的回答像一条河流一样流回来。这条河里流的不是普通的水,而是一个一个”内容块”(content block)。

有些内容块是文字——AI 在跟你说话:

1
2
3
4
{
"type": "text",
"text": "让我先看看你的代码,找找 bug 在哪里。"
}

但有些内容块是另一种东西——“工具使用请求”:

1
2
3
4
5
6
7
8
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "Bash",
"input": {
"command": "cd /Users/nadav/my-project && npm test"
}
}

同样是内容块,但 type 字段不一样。一个是 "text",一个是 "tool_use"

在 TypeScript 的世界里,这种”一个值可以是 A 也可以是 B”的概念,叫做联合类型(union type):

1
type ContentBlock = TextBlock | ToolUseBlock

两个类型都有一个 type 字段,但值不一样。这种共享字段用来区分类型的设计模式,叫做 discriminated union(可辨识联合)。


tool_use 块里装了什么

让我们拆开 tool_use 块的三个关键字段:

name:谁来做。 告诉 Claude Code “AI 想用哪个工具”。"Bash" 意思是执行 Bash 命令。Claude Code 内置了很多工具——Read 读文件、Write 写文件、Edit 编辑文件、Bash 执行命令,等等。

input:怎么做。 告诉 Claude Code “AI 想怎么用这个工具”。Bash 工具的 input 里有 command 字段;Read 工具的 input 里有 file_path 字段。不同工具的 input 结构不同。

id:这是哪一单。 唯一标识,像快递单号。AI 一次回答可能触发多个工具调用,每个调用都会产生一个结果。这些结果需要跟对应的调用匹配上——id 就是匹配的纽带。

这三个字段组成了一个完整的”行动指令”:谁来做、怎么做、这是哪一单。


源码入口

本章追踪的调用链:

1
2
3
4
src/query.ts 的 for await 循环
→ src/query.ts (msgToolUseBlocks 过滤 — 识别 tool_use)
→ src/services/tools/StreamingToolExecutor.ts (addTool — 流式执行器)
→ src/services/tools/toolExecution.ts (runToolUse — 工具查找)

逐行阅读

9.1 从流中识别 tool_use

当一条完整的助手消息到达后,代码扫描它的所有内容块,用类型守卫(type guard)把 tool_use 块挑出来:

1
2
3
4
// → src/query.ts 的 tool_use 识别逻辑
const msgToolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]

content => content.type === 'tool_use' 这个箭头函数就是一个类型守卫——检查每个内容块的 type 字段,是 "tool_use" 就保留。

as ToolUseBlock[] 是 TypeScript 的类型断言,告诉编译器”过滤出来的这些东西都是 ToolUseBlock 类型”。

如果找到了工具请求块:

1
2
3
4
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true
}

needsFollowUp = true 意味着”AI 还没说完——它提出了行动请求,需要执行这些请求,然后把结果送回给 AI”。

9.2 找到正确的工具

识别出 tool_use 块后,下一步是找到 AI 要用的那个工具:

1
2
3
4
// → src/Tool.ts 的 findToolByName() 函数
export function findToolByName(tools: Tools, name: string): Tool | undefined {
return tools.find(t => toolMatchesName(t, name))
}

tools.find(...) 是 JavaScript 数组的原生方法——遍历数组,找到第一个满足条件的元素。辅助函数 toolMatchesName 检查工具名和别名:

1
2
3
4
5
6
7
// → src/Tool.ts 的 toolMatchesName() 函数
export function toolMatchesName(
tool: { name: string; aliases?: string[] },
name: string,
): boolean {
return tool.name === name || (tool.aliases?.includes(name) ?? false)
}

为什么需要别名?因为工具有时候会改名。有了别名机制,即使用旧名字也能找到正确的工具——就像你改了手机号但做了呼叫转移。

返回类型是 Tool | undefined| undefined 意味着”可能找不到”——如果 AI 请求了一个不存在的工具,返回 undefined

9.3 工具是什么

src/Tool.ts 里,一个工具的完整定义包含这些关键字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// → src/Tool.ts 的 Tool 类型(简化版)
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = {
readonly name: string // 工具名字
readonly inputSchema: Input // 输入规则(Zod schema)
call( // 执行函数
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>
// ... 还有 isEnabled、isReadOnly、isDestructive、checkPermissions 等
}

name:匹配 tool_use 块里的 name 字段。inputSchema:验证 AI 传过来的参数对不对。call:工具真正干活的代码——最重要的字段。

每一个工具都是这个 Tool 类型的一个具体实现。Bash 工具的 call 执行命令,Read 工具的 call 读取文件,Write 工具的 call 写入文件。骨架一样,动作各不相同。

9.4 当工具不存在时

如果 AI 请求了一个不存在的工具怎么办?

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
// → src/services/tools/toolExecution.ts 的 runToolUse() 函数
export async function* runToolUse(
toolUse: ToolUseBlock,
assistantMessage: AssistantMessage,
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdateLazy, void> {
const toolName = toolUse.name
let tool = findToolByName(toolUseContext.options.tools, toolName)

if (!tool) {
// 工具不存在!
yield {
message: createUserMessage({
content: [{
type: 'tool_result',
content: `<tool_use_error>Error: No such tool available: ${toolName}</tool_use_error>`,
is_error: true,
tool_use_id: toolUse.id,
}],
}),
}
return
}
// ... 继续执行工具 ...
}

这种模式叫”提前返回”(early return)——前提条件不满足时立刻处理异常情况并返回,不让后续的正常逻辑被异常处理代码污染。

当工具不存在时,错误消息被送回给 AI,告诉它”你要用的工具不存在”。AI 看到后可以换一种方式来实现目标。

9.5 流式工具执行:不等 AI 话说完就开始动手

Claude Code 有一个精妙的设计——流式工具执行(streaming tool execution)。当 AI 还在流式输出时,已识别的只读工具就已经开始执行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// → src/services/tools/StreamingToolExecutor.ts 的 addTool() 方法
addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
const toolDefinition = findToolByName(this.toolDefinitions, block.name)

if (!toolDefinition) {
// 工具不存在 → 立即返回错误
this.tools.push({
status: 'completed',
results: [createUserMessage({ ... })]
})
return
}

// 解析输入,判断是否并发安全
const parsedInput = toolDefinition.inputSchema.safeParse(block.input)
const isConcurrencySafe = parsedInput?.success
? Boolean(toolDefinition.isConcurrencySafe(parsedInput.data))
: false

this.tools.push({ block, status: 'queued', isConcurrencySafe })

// 立即尝试执行
void this.processQueue()
}

这叫预测性工具执行(Speculative Execution)。文字和行动并行推进——就像你跟一个高效的同事合作,你说”帮我把那份报告拿来”,话还没说完,他已经起身去拿了。

9.6 并发控制:哪些工具可以并行

如果多个工具请求同时到达,StreamingToolExecutor 判断哪些可以同时执行:

1
2
3
4
5
6
7
8
9
// → src/services/tools/StreamingToolExecutor.ts 的 canExecuteTool() 方法
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 || // 没有在执行的 → 可以开始
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
// 当前工具安全 + 所有执行中的也安全 → 可以并行
)
}

示例——模型同时返回三个工具调用:

工具isConcurrencySafe执行策略
Read(“file1.ts”)true立即并行执行
Grep(“pattern”)true立即并行执行
Write(“file2.ts”)false等待 Read 和 Grep 完成后独占执行

只读工具可以并行——互不干扰。写操作必须串行——防止互相覆盖。


大脑和手:AI 与工具的关系

AI 模型就像一个大脑——能思考、能分析、能做决策,但没有手。工具就是 AI 的手。

Claude Code 给 AI 准备了一整套”手”:Bash(执行命令)、Read(读文件)、Write(写文件)、Edit(编辑文件)、Glob(搜索文件)、Grep(搜索内容)。每一只”手”都有自己的能力范围。

整个流程:

  1. AI 思考:”我需要运行测试”
  2. AI 在回答里插入 tool_use 块:name: "Bash"input: { command: "npm test" }
  3. Claude Code 从流式数据中识别出这个 tool_use
  4. Claude Code 用 findToolByName 找到 Bash 工具
  5. 工具准备被执行

常见错误与检查方法

常见错误检查方法
工具找不到findToolByName 加日志,查看 tools.map(t => t.name) 列表
工具输入格式不对检查 inputSchema.safeParse 的返回值和错误信息
工具请求被忽略检查 needsFollowUp 是否被正确设为 true
并发执行冲突检查 isConcurrencySafe 标记是否正确设置
流式工具执行失败检查 StreamingToolExecutor.addTool 是否被正确调用

试试看

修改 1:观察工具识别

src/query.tsmsgToolUseBlocks 过滤之后加:

1
2
3
if (msgToolUseBlocks.length > 0) {
console.log('[DEBUG] tool_use blocks found:', msgToolUseBlocks.map(b => b.name))
}

运行后你应该看到类似输出:

1
2
[DEBUG] tool_use blocks found: [ 'Bash' ]
[DEBUG] tool_use blocks found: [ 'Read', 'Grep' ]

修改 2:追踪工具查找

src/Tool.tsfindToolByName 函数中加:

1
console.log('[DEBUG] Looking for tool:', name, 'found:', !!found)

修改 3:观察并发执行

src/services/tools/StreamingToolExecutor.tscanExecuteTool 方法中加:

1
2
const executing = this.tools.filter(t => t.status === 'executing')
console.log('[DEBUG] canExecute:', isConcurrencySafe, 'executing:', executing.map(t => t.block.name))

运行后你应该看到类似输出:

1
2
3
[DEBUG] canExecute: true executing: []                  ← 第一个工具,无人在执行
[DEBUG] canExecute: true executing: [ 'Read' ] ← Read 是只读的,可以并行
[DEBUG] canExecute: false executing: [ 'Read', 'Grep' ] ← 写操作,等待只读完成

检查点

你现在已经理解了:

  • 联合类型和类型守卫content.type === 'tool_use' 从流中识别工具请求
  • tool_use 块结构:name(谁来做)、input(怎么做)、id(这是哪一单)
  • 工具查找findToolByName 在工具列表里根据名字找到对应工具
  • Tool 类型:name、inputSchema、call 构成工具的核心定义
  • 提前返回:工具不存在时立即返回错误消息给 AI
  • 流式工具执行:AI 还在输出时,只读工具已经开始执行
  • 并发分区算法:isConcurrencySafe 判断——安全工具并行、非安全工具串行

下一站预告:第 10 章将深入工具执行的内部——子进程、spawn、stdout/stderr,看看一条命令是怎么真正在你的电脑上运行的。


← 上一章:文字一个字一个字地回来 | 下一章:命令真的被执行了 →