第 09 章:AI说要执行命令

源码验证日期:2026-05-15,基于 commit
0d81bb6
AI 的回答不是普通的文字。在流式数据中,出现了一个特殊的标记:tool_use。AI 想执行一条命令。
上一章里,我们看着 AI 的回答一个字一个字地流回来——像水龙头一样,一滴一滴。但你有没有想过:如果 AI 只会”说话”,它怎么帮你修 bug?
真正的助手会说:”让我来看看”,然后自己去翻代码;会说:”我来帮你运行一下测试”,然后自己去敲命令。Claude Code 里的 AI 就是这样的助手。它不只是说话——它会”动手”。
但”动手”这件事,在代码世界里怎么表达?一条流式传输的数据流里,怎么区分”AI 在说话”和”AI 要做事”?
答案就藏在一个小小的标记里:tool_use。
路线图
1 | graph LR |
一条消息,两种内容
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
4src/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
4if (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(搜索内容)。每一只”手”都有自己的能力范围。
整个流程:
- AI 思考:”我需要运行测试”
- AI 在回答里插入
tool_use块:name: "Bash",input: { command: "npm test" } - Claude Code 从流式数据中识别出这个
tool_use块 - Claude Code 用
findToolByName找到 Bash 工具 - 工具准备被执行
常见错误与检查方法
| 常见错误 | 检查方法 |
|---|---|
| 工具找不到 | 在 findToolByName 加日志,查看 tools.map(t => t.name) 列表 |
| 工具输入格式不对 | 检查 inputSchema.safeParse 的返回值和错误信息 |
| 工具请求被忽略 | 检查 needsFollowUp 是否被正确设为 true |
| 并发执行冲突 | 检查 isConcurrencySafe 标记是否正确设置 |
| 流式工具执行失败 | 检查 StreamingToolExecutor.addTool 是否被正确调用 |
试试看
修改 1:观察工具识别
在 src/query.ts 的 msgToolUseBlocks 过滤之后加:1
2
3if (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.ts 的 findToolByName 函数中加:1
console.log('[DEBUG] Looking for tool:', name, 'found:', !!found)
修改 3:观察并发执行
在 src/services/tools/StreamingToolExecutor.ts 的 canExecuteTool 方法中加:1
2const 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,看看一条命令是怎么真正在你的电脑上运行的。