一次代码审查命令的背后,是三个 AI 子代理的并发协作。本文从使用出发,逐层剥开它的实现原理。
初识 /simplify:一个会自动修代码的审查命令
在 Claude Code 里输入 /simplify,它会审查你当前改动的代码,从三个维度------复用、质量、效率------找出问题并直接修复。

执行过程大概是这样:
- 先跑
git diff拿到变更内容 - 同时派出三个子代理,分别审查代码复用、代码质量、运行效率
- 三个代理各自读文件、搜代码,形成审查报告
- 主模型汇总报告,直接动手改代码

跑完之后你会看到一段总结,告诉你修了什么、跳过了什么。
用下来有几个感受:
- 审查维度比较全面,不是简单 lint 能覆盖的
- 会自动改代码,不是只提建议
- 有时会过度删注释(后面会分析原因)
- 对重复代码的识别能力不错,能跨文件找到已有的工具函数
接下来,我想搞清楚它到底是怎么运作的。入口在哪?三个子代理怎么启动的?它们之间怎么通信?代码都没看,光靠一个 diff,它凭什么能审查出问题?
找到入口:69 行代码撑起整个 Skill
一切从 src/skills/bundled/simplify.ts 开始。这个文件只有 69 行,核心就是两个东西:一个提示词常量 SIMPLIFY_PROMPT,一个注册函数 registerSimplifySkill()。
先看注册:
typescript
export function registerSimplifySkill(): void {
registerBundledSkill({
name: 'simplify',
description:
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
userInvocable: true,
async getPromptForCommand(args) {
let prompt = SIMPLIFY_PROMPT
if (args) {
prompt += `\n\n## Additional Focus\n\n${args}`
}
return [{ type: 'text', text: prompt }]
},
})
}
userInvocable: true 意味着用户可以在对话里直接用 /simplify 触发。getPromptForCommand 把提示词模板返回出去------如果用户在命令后面跟了额外参数,比如 /simplify 重点看性能,就拼到提示词末尾。
注册发生在 initBundledSkills() 里,应用启动时就把 simplify 和其他内置 skill 一起注册好,出现在系统提示词的可用 skill 列表中。
当用户输入 /simplify,调用链是这样的:
scss
用户输入 /simplify
↓
REPL 解析 → 模型识别为 Skill 调用
↓
SkillTool.call({ skill: "simplify" })
↓
getPromptForCommand(args) → 返回 SIMPLIFY_PROMPT
↓
注入为 meta user message 到对话
↓
主模型读取提示词,开始执行
simplify 没有设置 context: 'fork',所以它是 inline 模式------提示词展开后直接注入当前对话,由主模型亲自调度,不会 fork 出一个新对话。
提示词才是灵魂:三阶段审查模型
整个 simplify 的逻辑几乎都在 SIMPLIFY_PROMPT 里。这是一个约 50 行的模板字符串,定义了三个执行阶段。
Phase 1:识别变更
javascript
Run `git diff` (or `git diff HEAD` if there are staged changes) to see
what changed. If there are no git changes, review the most recently
modified files that the user mentioned or that you edited earlier in
this conversation.
第一步很朴素:跑 git diff。没有 git 变更的话,就审查最近改过的文件。diff 是审查的输入范围,但不是唯一上下文------子代理后续会自己探索代码库。
Phase 2:并行启动三个审查 Agent
这是关键部分。提示词要求主模型在 同一条 assistant message 中 发出三个 Agent 工具调用:
css
Use the Agent tool to launch all three agents concurrently
in a single message. Pass each agent the full diff so it has
the complete context.
为什么强调"single message"?因为 Claude Code 的工具调度机制会把同一条消息中的并发安全工具调用同时执行。三个 Agent 调用如果是分三条消息发出,就会变成串行,耗时会翻三倍。
三个 Agent 各自的职责:
每个 Agent 收到的 prompt = 审查任务描述(下面列出)+ 完整的
git diff:
Agent 1:Code Reuse Review------代码复用审查
搜索已有工具函数能否替代新写的代码。具体来说:
- 搜索已有的 utility 和 helper,看新代码是不是重复造轮子
- 标记跟已有函数功能重复的新函数
- 标记可以用已有工具替代的内联逻辑------手写字符串处理、手动拼路径、自定义环境检查、临时类型守卫,都是常见目标
Agent 2:Code Quality Review------代码质量审查
审查 7 种"hacky"模式:
| # | 模式 | 含义 |
|---|---|---|
| 1 | Redundant state | 状态重复------可推导的缓存值、可改为直接调用的 observer/effect |
| 2 | Parameter sprawl | 参数蔓延------不停加新参数而不是重构 |
| 3 | Copy-paste with slight variation | 近似复制粘贴------应提取共享抽象 |
| 4 | Leaky abstractions | 抽象泄露------暴露内部细节或破坏已有边界 |
| 5 | Stringly-typed code | 字符串类型滥用------已有常量/enum 却还用裸字符串 |
| 6 | Unnecessary JSX nesting | 多余 JSX 嵌套------内层组件 props 已经提供了所需布局 |
| 7 | Unnecessary comments | 无用注释------删掉解释"做了什么"的注释,只保留"为什么" |
第 7 条值得单独拿出来说。原文是:
comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller --- delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds)
翻译过来:注释如果解释的是"代码做了什么事"(命名良好的标识符已经说明了)、叙述变更内容、引用任务编号------删掉;只保留那些不明显的"为什么这样做"(隐藏的约束、微妙的不可变规则、临时规避措施)。
这条规则就是 simplify 运行时经常误删注释的根源。WHAT 和 WHY 的边界有时候很模糊,模型往往过于激进,把有工程价值的注释也判为无用。如果你发现 simplify 删了不该删的注释,问题出在这。
Agent 3:Efficiency Review------效率审查
| # | 模式 | 含义 |
|---|---|---|
| 1 | Unnecessary work | 冗余计算、重复文件读取、重复网络调用、N+1 |
| 2 | Missed concurrency | 可并行却串行的独立操作 |
| 3 | Hot-path bloat | 启动路径或热路径加了阻塞工作 |
| 4 | Recurring no-op updates | 轮询循环里无条件触发的 state 更新 |
| 5 | Unnecessary existence checks | TOCTOU 反模式------先检查再操作,应直接操作并处理错误 |
| 6 | Memory | 无界数据结构、缺失清理、事件监听器泄漏 |
| 7 | Overly broad operations | 读取整个文件只为用一小段、加载全部元素只为筛一个 |
Phase 3:聚合修复
vbnet
Wait for all three agents to complete. Aggregate their findings
and fix each issue directly. If a finding is a false positive or
not worth addressing, note it and move on --- do not argue with
the finding, just skip it.
三个子代理完成后,主模型汇总结论,逐个修复。误报就跳过,不用纠结。
到这里有个问题很自然地冒出来:同一份提示词给了三个 Agent,每个 Agent 的任务描述不同,但提示词是同一份。这跟同一个 Agent 跑三次有什么区别?
区别在于 任务描述不同。提示词虽然完整描述了三个维度的审查规则,但每次 Agent 工具调用时传入的 prompt 是不同的------"Code Reuse Review"、"Code Quality Review"、"Efficiency Review"。模型根据任务描述聚焦到对应的部分,而不是把全部 21 条检查项都跑一遍。
三个子代理不是三个进程
接下来我想搞清楚:三个子代理到底是怎么并发运行的?是开三个进程?三个线程?
答案:都不是。它们是 同一个 Node.js 进程内的三个并发 async generator 协程。
并发安全的标记
首先,Agent 工具被标记为并发安全。在 AgentTool.tsx 中:
typescript
isConcurrencySafe() {
return true
}
这意味着 Agent 工具调用可以被并行执行,不用排队。
工具分区:谁并行,谁串行
src/services/tools/toolOrchestration.ts 的 runTools() 是工具调度的总入口。它的核心逻辑是 partitionToolCalls()------把同一条 assistant message 中的所有 tool_use block 按并发安全性分区:
typescript
function partitionToolCalls(toolUseMessages, toolUseContext) {
return toolUseMessages.reduce((acc, toolUse) => {
const isConcurrencySafe = /* 查工具的 isConcurrencySafe 方法 */
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1].blocks.push(toolUse) // 翻入上一批次
} else {
acc.push({ isConcurrencySafe, blocks: [toolUse] }) // 新建批次
}
return acc
}, [])
}
三个 Agent 调用全部 isConcurrencySafe: true,连续出现,归入同一个并发批次。然后交给 runToolsConcurrently():
typescript
async function* runToolsConcurrently(toolUseMessages, ...) {
yield* all(
toolUseMessages.map(async function* (toolUse) {
toolUseContext.setInProgressToolUseIDs(prev =>
new Set(prev).add(toolUse.id),
)
yield* runToolUse(toolUse, ...)
markToolUseAsComplete(toolUseContext, toolUse.id)
}),
getMaxToolUseConcurrency(), // 默认 10
)
}
关键点:toolUseMessages.map(...) 把 3 个 Agent 工具调用包装成了 3 个 async generator 。每个 generator 内部通过 yield* runToolUse(...) 产出该 Agent 在多轮 API 调用中产生的所有中间消息(进度、工具调用、最终结果)。3 个 generator 一起丢给 all()。
all():Promise.race 驱动的并发调度器------源码逐行拆解
核心是 src/utils/generators.ts 中的 all() 函数。不到 40 行,实现了一个经典的多任务竞态调度器。
先看类型定义:
typescript
type QueuedGenerator<A> = {
done: boolean | void
value: A | void
generator: AsyncGenerator<A, void> // 哪个 generator 产出的
promise: Promise<QueuedGenerator<A>> // 它自己对应的 Promise(自引用)
}
这个类型是理解整个机制的关键。generator.next() 返回的 Promise resolve 时,结果里自带 generator 引用 ------这样 Promise.race 选出最快完成的 Promise 后,我们就知道"是哪个 generator 产出的数据",然后可以立刻叫它产出下一条。
下面是 all() 的完整实现,分成四个阶段来拆:
typescript
export async function* all<A>(
generators: AsyncGenerator<A, void>[],
concurrencyCap = Infinity,
): AsyncGenerator<A, void> {
第一步:定义 next()------包装 generator.next() 让它"记住自己"
typescript
const next = (generator: AsyncGenerator<A, void>) => {
const promise: Promise<QueuedGenerator<A>> = generator
.next() // ① 推进这个 generator 一步
.then(({ done, value }) => ({ // ② 拿到 { done, value } 后,把 generator 身份包进去
done,
value,
generator, // ← "记住我是谁"
promise, // ← 自引用,后面用来从 Set 里删自己
}))
return promise
}
这里的精髓是 generator 引用被打包进了 resolve 值。Promise.race 只会告诉你"哪个 Promise 赢了",但不会告诉你赢家对应的 generator 是谁。通过在 resolve 值里附带 generator 引用,下游拿到结果就能精确知道该推进哪个 generator。
第二步:启动初始批次------所有 generator 同时 next()
typescript
const waiting = [...generators] // 待启动的 generator 队列
const promises = new Set<Promise<QueuedGenerator<A>>>()
while (promises.size < concurrencyCap && waiting.length > 0) {
const gen = waiting.shift()!
promises.add(next(gen)) // 调用 next(),Promise 入池
}
simplify 场景下 concurrencyCap = 10,只有 3 个 generator,所以 3 个全部同时启动。next(gen) 调用 generator.next(),这会推进到 runToolUse() 内部------构造 HTTP 请求、发送出去、然后 await 在 I/O 上挂起。三个 HTTP 请求在同一 microtask tick 内全部发出。
第三步:主循环------Promise.race 竞态 + 立即重新入池
typescript
while (promises.size > 0) {
const { done, value, generator, promise } = await Promise.race(promises)
promises.delete(promise) // 把自己从池里移除
// ↑
// 这里用到了第一步塞进去的 self-referencing promise,
// 否则没法从 Set 里精确删除"当前胜出的那个"
if (!done) {
promises.add(next(generator)) // ← 关键:立即把同一个 generator
if (value !== undefined) { // 的下一轮 next() 加回池里!
yield value as Awaited<A> // 产出一条消息给上层(UI / 主循环)
}
} else if (waiting.length > 0) {
const nextGen = waiting.shift()! // 这个 generator 跑完了(done=true),
promises.add(next(nextGen)) // 从 waiting 队列取下一个替补
}
}
}
这是整个调度器的核心循环。需要注意这三个操作是原子连续 的------在一个 await Promise.race 被解除到下一次 await Promise.race 之间,JS 单线程不做任何其他事情:
await Promise.race(promises)------阻塞,等任意一个 generator 的next()完成promises.delete(promise)------把自己从竞态池移除promises.add(next(generator))------立刻 再次next()同一个 generator,把新 Promise 加回池
这就构成了一个自持续循环:generator 完成一次产出 → 删旧 Promise → 立即 next() 生成新 Promise → 新 Promise 入池 → 回到 Promise.race 等待。每个 generator 的每次产出都遵循这个模式,直到 done=true。
从 generator 视角看------一次 next() 到底发生了什么
每个 generator 被创建时是匿名的:
typescript
toolUseMessages.map(async function* (toolUse) {
// ① 第一次 next() 进入这里
yield* runToolUse(toolUse, assistantMessage, canUseTool, toolUseContext)
// ② runToolUse 内部会多次 yield 中间消息
// ③ runToolUse 结束时 generator done=true
markToolUseAsComplete(toolUseContext, toolUse.id)
})
runToolUse() 本身又是一个 async generator。对 Agent 工具来说,它内部会调用 runAgent(),而 runAgent() 内部跑 query() 循环。query() 每产出一条消息(LLM 的文本、tool_use block、工具调用结果),都通过 yield 逐层上传。
所以一次 generator.next() 推到哪?每次调用 next() 实际上是推 generator 前进到下一个 yield 或 return:
scss
all() 调用 gen.next()
↓
runToolUse() 的 generator 前进一步
↓
runAgent() 的 generator 前进一步
↓
query() 的 generator 前进一步
↓
发送 HTTP 请求 → await 挂起(Promise pending)
↓
... 网络传输中 ...
↓
HTTP 响应到达 → Promise resolve
↓
query() yield { type: 'stream_event', ... }
↓
runAgent() yield 这条消息
↓
runToolUse() yield 这条消息
↓
gen.next() 的 Promise resolve → { value: 这条消息, done: false, generator }
↓
all() 的 await Promise.race 解除 → 拿到的就是这个 value
每次 next() 只前进到下一层的下一个 yield 。runAgent 内部可能跑很多轮(LLM 文本 → tool_use → 执行工具 → LLM 文本 → tool_use → ...),每一轮都会有多次 yield,每次 yield 都触发一次 gen.next() 的 resolve。
为什么这是一个经典的多任务竞态模式
这个 all() 实现本质上是一个 用户态的协作式多任务调度器。拿它跟操作系统概念类比:
| 操作系统概念 | all() 对应 |
|---|---|
| 进程 / 协程 | 每个 async generator |
| 就绪队列 | promises Set |
| I/O 等待 | generator.next() 内部 await fetch() |
| 调度器 | Promise.race |
| 时间片 | 一个 yield 到下一个 yield |
| 上下文切换 | next(generator) 重新入池 |
区别在于:没有抢占、没有时间片轮转------用的是完全由 I/O 完成事件驱动的 "抢占式竞态"。谁的网络包先到,谁的 JavaScript Promise 就先 resolve,谁就获得 CPU 处理权。
放在更大的 Node.js 视角下,这个流程更清楚:
perl
┌─────────────────────────────────────────┐
│ Node.js 事件循环 │
│ │
│ poll 阶段: │
│ 3 个 HTTP 请求的 socket 都在 epoll 中 │
│ ↓ │
│ 请求① socket 可读 → 回调入队 │
│ ↓ │
│ check 阶段: │
│ 执行回调 → P① resolve │
│ → Promise.race 解除 │
│ → yield value → promises.add(next()) │
│ → 新 next() 又发出 HTTP 请求 │
│ → 回到 poll 阶段等待下一个 I/O │
└─────────────────────────────────────────┘
所有 generator 的推进都发生在同一个事件循环的大循环里。CPU 只在 I/O 事件触发的微任务阶段短暂介入,其余时间完全 idle。这就是 Node.js 处理高并发网络请求的标准模型------all() 不过是把这个模型封装成了一个简洁的 async generator 调度原语。
单线程下的"并行"------多轮请求如何交织
JavaScript 是单线程的,真正的并行发生在 网络 I/O 层。每个子代理是一个独立的 Claude API 会话,每轮对话都要发 HTTP 请求给 API。三个 HTTP 请求在操作系统网络栈层面是真正并行发出的。
但更关键的问题是:每个 Agent 内部可能会有多轮 API 调用(LLM 返回 tool_use → 执行工具 → 再调 API → 再 tool_use → ...直到 LLM 返回纯文本才算完)。三个 Agent 的多个请求之间怎么调度?
答案是:完全由网络响应速度决定,快者先行,互相交织。
用 all() 里的关键循环来分析:
typescript
while (promises.size > 0) {
const { done, value, generator, promise } = await Promise.race(promises)
promises.delete(promise)
if (!done) {
yield value // 产出这条消息给上层
promises.add(next(generator)) // 立刻再调这个 generator(触发下一轮请求)
}
}
注意 promises.add(next(generator))------谁的响应先到,谁就立刻发起下一轮请求,无论其他 Agent 还在等第一轮。三个 Agent 的请求是交织的:
python
Tick 0: 3 个 gen.next() 同时触发
──────────────────────────────────
请求①-1 ──────────────────────→ API (等响应...)
请求②-1 ──────────────────────→ API (等响应...)
请求③-1 ──────────────────────→ API (等响应...)
Tick 1: 请求①-1 的响应先到了(假设它最快)
LLM 返回: tool_use → "Grep 'handleRedirect'"
→ 执行 Grep,结果写入 Agent① 的 messages[]
→ gen①.next() 立即触发请求①-2 ───→ API (等响应...)
此时请求②-1、请求③-1 还在等响应(网络层并行中)
Tick 2: 请求③-1 的响应到了
LLM 返回: tool_use → "Read 'router.ts'"
→ 执行 Read,结果写入 Agent③ 的 messages[]
→ gen③.next() 立即触发请求③-2 ───→ API (等响应...)
Tick 3: 请求①-2 的响应到了(Agent① 已经跑了两轮)
LLM 返回: 纯文本 → Agent① done,gen① 退出 races
此时 promises Set 里还有 gen② 的请求②-1、gen③ 的请求③-2
Tick 4: 请求②-1 终于到了(Agent② 网络慢,第一轮才刚回来)
...
核心规则只有一条:**Promise.race 选出当前完成最快的那一个,处理完立刻把同一个 generator 的下一轮 next() 加回竞态池。**不存在"等大家都跑完第一轮再一起发第二轮"------谁的 API 响应快,谁就多跑几轮。
所以三个子代理的并行本质是 I/O 并行------CPU 只在数据到达时处理,其余时间 idle。跟 Node.js 处理高并发 HTTP 请求是同一个模型。
三个子代理的详细管理机制(runAgent 生命周期、createSubagentContext 隔离、fan-out/fan-in 通信模式)后续单独展开分析。这里只提三点关键信息:每个子代理有独立的 agentId、messages、toolUseContext,但共享父级的 AbortController(按 Escape 全停);子代理跑的是跟主模型相同的 query() 循环;系统提示词是 agent 专属的简化版,侧重于搜索能力。
总结
回顾一下我们挖到的整个链路:
/simplify的核心是SIMPLIFY_PROMPT------一个三阶段提示词模板- 主模型在同一条消息中发出三个 Agent 工具调用,利用
isConcurrencySafe标记实现并发 - 三个子代理通过
all()函数并发调度,底层是Promise.race驱动的 async generator - 并行发生在网络 I/O 层,CPU 依然是单线程事件循环
- 子代理的管理由
runAgent()负责,每个子代理有独立的 agentId、toolUseContext、系统提示词,按 Escape 全停 - 子代理通过 Grep、Read、Glob 三个文件系统工具实时探索代码库,对话历史充当工作记忆
从一个 69 行的文件出发,我们摸清了从用户输入到代码修复的完整链路。这种"提示词即逻辑"的设计很值得关注------整个多 Agent 协作的核心不是什么复杂的调度框架,而是一段精心设计的提示词加上一个简洁的并发原语。
整体交互流程
回顾全文,主模型和三个审查 Agent 的协作全景如下。这张图把子代理的多轮 query() 循环和单线程并发控制都画进去了:
git diff 拿到变更内容"] P1 --> P2_HEADER["🧵 Phase 2: 主模型在一条 assistant message 中
并发发出 3 个 Agent 工具调用
isConcurrencySafe = true
partitionToolCalls() → 同一批次
↓
runToolsConcurrently() → all()
同一 microtask tick 触发 3 个 gen.next()
3 个 HTTP 同时发出(网络层真并行)
JS 单线程 Promise.race 驱动 "] P2_HEADER --> A1["🔍 Agent①
Code Reuse Review
3条复用规则 + diff"] P2_HEADER --> A2["✨ Agent②
Code Quality Review
7条质量规则 + diff"] P2_HEADER --> A3["⚡ Agent③
Efficiency Review
7条效率规则 + diff"] A1 --> RUN1["runAgent() 构建隔离环境"] A2 --> RUN2["runAgent() 构建隔离环境"] A3 --> RUN3["runAgent() 构建隔离环境"] subgraph ISOLATE["🔐 每个 Agent 的隔离环境"] direction LR I1["独立 agentId"] I2["克隆 readFileState"] I3["独立 messages[]"] I4["独立 toolUseContext"] I5["工具池 resolveAgentTools()"] I6["AbortController 共享父级"] end RUN1 --> ISOLATE RUN2 --> ISOLATE RUN3 --> ISOLATE ISOLATE --> LOOP subgraph LOOP["🔄 每个 Agent 内部的 query() 多轮循环"] INIT["initialMessages
(prompt + diff)"] --> CALL_API CALL_API["发送给 Claude API"] --> STREAM["HTTP 流式响应"] STREAM --> DECIDE{"LLM 返回"} DECIDE -->|"纯文本
本轮结束"| DONE["✅ 产出审查报告
(tool_result)"] DECIDE -->|"tool_use block"| EXEC["执行工具
· Grep 'function handleXxx'
· Glob 'src/**/utils*'
· Read 'utils.ts' L120-150"] EXEC --> APPEND["tool_result 追加到 messages[]
对话历史 = 工作记忆"] APPEND --> CALL_API end LOOP --> R1["📄 审查报告"] LOOP --> R2["📄 审查报告"] LOOP --> R3["📄 审查报告"] subgraph RACE["⏱️ Promise.race 等待三个 gen 全部 done"] direction LR RC["谁先响应 → 先 yield
→ 立即 next() 等下一轮
→ 直到 gen.done"] end R1 --> RACE R2 --> RACE R3 --> RACE RACE --> P3["📝 Phase 3: 主模型汇总三份 tool_result
→ 汇总发现 → 逐个修复代码 → 输出总结"] P3 --> DONE_ALL["🎉 完成"] style U fill:#e1f5fe style MAIN fill:#fff3e0 style P1 fill:#f3e5f5 style P2_HEADER fill:#ffecb3 style A1 fill:#c8e6c9 style A2 fill:#b3e5fc style A3 fill:#ffccbc style RACE fill:#fce4ec style P3 fill:#d1c4e9 style DONE_ALL fill:#a5d6a7
几个关键细节对应回去:
- 三个 Agent 并发 :
partitionToolCalls()把连续isConcurrencySafe: true的调用归入同一批次,all()同一 microtask tick 触发所有gen.next(),3 个 HTTP 请求同时发出。JS 单线程通过Promise.race驱动------I/O 完成后 Promise resolve,yield 该 Agent 的消息,再立即next()等下一轮 - 多轮循环 :每个 Agent 跑独立的
query()循环,LLM 自己决定搜什么、用什么工具、搜几轮。Grep/Read/Glob 结果追加到自己的messages[],下一轮 API 调用带完整历史 - 子代理隔离:独立的 agentId、messages、toolUseContext,但共享父级 AbortController------按 Escape 三个 Agent 全停