03-想做 Code Agent,但不想只会调 API?我把 Claude Code 源码拆成了一套教程

4 阅读6分钟

关键词:queryLoop / StreamingToolExecutor / Permission System / Hooks / Agent Loop / 安全闸门

真正让 Agent 跑起来的是循环,让它不出事的是权限系统

到这里,Claude Code 的主骨架已经很清楚了,但还差两块最关键的硬件:

  1. 它为什么能自己把任务一轮一轮推进下去;
  2. 它为什么拥有写代码、跑命令的能力,却不至于一路失控。

这正是第五章和第六章要解决的问题。

一、queryLoop() 的真正价值:它不是多轮问答,而是状态机

Claude Code 的核心逻辑都收敛在 queryLoop() 里。
它本质上是一个持续更新状态的 while (true)

while (true) {
  let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]
  const { compactionResult } = await deps.autocompact(...)

  for await (const message of deps.callModel({...})) {
    yield message
    if (message.type === 'assistant' && 含有 tool_use) {
      streamingToolExecutor.addTool(toolBlock, message)
    }
  }

  for await (const update of toolUpdates) {
    yield update.message
  }

  if (!needsFollowUp) return { reason: 'completed' }
  state = { messages: [..., ...toolResults], ... }
}

关键点不在语法,而在顺序:

  1. 准备消息;
  2. 预处理压缩;
  3. 调模型;
  4. 发现工具调用;
  5. 执行工具;
  6. 把结果回写;
  7. 再进下一轮。

这就是 Agent 和聊天产品的本质分界线。

二、Claude Code 不是“先生成完,再执行工具”,而是流式并发

很多实现会这么做:

  1. 等模型整段回复结束;
  2. 解析出工具调用;
  3. 再开始执行工具。

Claude Code 不是。
它在流式输出过程中,一旦看到 tool_use 块,就立刻把工具加入执行队列:

for await (const message of deps.callModel({...})) {
  yield message
  if (message.type === 'assistant') {
    const toolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
    if (toolUseBlocks.length > 0) {
      needsFollowUp = true
      for (const toolBlock of toolUseBlocks) {
        streamingToolExecutor.addTool(toolBlock, message)
      }
    }
  }
}

这意味着:工具执行和模型输出是并行的。

sequenceDiagram
    participant Q as queryLoop
    participant M as Claude API
    participant S as StreamingToolExecutor
    participant T as Tool Layer

    Q->>M: callModel()
    M-->>Q: text_delta
    M-->>Q: tool_use
    Q->>S: addTool()
    S->>T: 启动工具
    M-->>Q: 继续输出 text_delta
    T-->>S: 返回结果
    Q-->>Q: 收集 tool_result

这不是体验优化,而是架构选择。
只要工具启动足够早,整轮任务延迟就会显著收缩。

三、StreamingToolExecutor 最核心的事,是并发安全分层

Claude Code 在工具调度上很克制。
它不会因为工具多就一股脑并发,而是先问每个工具:

isConcurrencySafe(input): boolean

调度规则很简单:

  • 如果当前没有正在执行的工具,可以启动新工具;
  • 如果当前执行中的工具全是并发安全的,而且新工具也是并发安全的,也能启动;
  • 只要涉及非并发安全工具,必须串行。

对应的判断大概是这样:

private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0 ||
    (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  )
}

这背后的逻辑很明确:

  • 搜索、读取、列目录,这类工具适合并发;
  • 写文件、跑副作用命令、改仓库,这类工具必须保守。

所以 Claude Code 的并发不是“追求越多越好”,而是“在可证明安全的范围内并发”。

四、终止条件不是一个 break,而是一组明确的 Terminal reason

成熟系统不会只关心“成功结束”,还要区分“为什么结束”。

Claude Code 的 queryLoop() 至少有这些终止原因:

  • completed
  • aborted_streaming
  • prompt_too_long
  • model_error
  • blocking_limit
  • stop_hook_prevented

这非常重要,因为它让上层能知道:

  • 是模型自然结束;
  • 是用户中断;
  • 是上下文太长;
  • 还是被 Hook 主动拦下来了。

没有这层终止语义,系统就很难做可靠恢复。

五、权限系统真正的第一原则:不是“能不能”,而是 allow / deny / ask

Claude Code 的权限系统没有直接把工具分成允许和禁止,而是设计成三态:

export type PermissionBehavior = 'allow' | 'deny' | 'ask'

这一步非常关键,因为:

  • allow 代表系统自动放行;
  • deny 代表系统自动拒绝;
  • ask 代表系统把决策权交给更高一层。

只要有了 ask,Agent 才能在自动执行和人工确认之间切换。
这正是生产环境里最需要的弹性。

六、权限模式不是开关,而是五档速度

Claude Code 有多种权限模式:

模式含义
default常规模式
acceptEdits自动接受文件编辑
plan只读规划模式
bypassPermissions绕过权限检查
dontAsk不弹确认,ask 直接转 deny

这个设计非常实际。
同一个 Agent 在不同环境里,本来就应该有不同速度:

  • 本地探索可以快一点;
  • 关键仓库应该慢一点;
  • 非交互环境不能依赖弹窗。

所以权限系统不是 yes/no,而是速度控制器。

七、useCanUseTool 把权限判断收口成了一条总闸门

Claude Code 每次工具调用都会经过 useCanUseTool

export type CanUseToolFn = (
  tool: ToolType,
  input: Input,
  toolUseContext: ToolUseContext,
  assistantMessage: AssistantMessage,
  toolUseID: string,
  forceDecision?: PermissionDecision,
) => Promise<PermissionDecision>

它做的事情很像总闸门:

  1. 已中止就直接短路;
  2. forceDecision 就直接采用;
  3. 否则进入正式权限检查;
  4. 对结果分成 allow / deny / ask
flowchart TD
    A["工具请求"] --> B{"已中止?"}
    B --> |"是"| X["直接返回"]
    B --> |"否"| C{"forceDecision?"}
    C --> |"是"| Y["直接采用"]
    C --> |"否"| D["hasPermissionsToUseTool()"]
    D --> E{"allow / deny / ask"}
    E --> |"allow"| F["执行"]
    E --> |"deny"| G["拒绝"]
    E --> |"ask"| H["进入确认流程"]

所有工具都走同一条总闸门,意味着权限不会分散在各个工具里各写各的逻辑。

八、真正复杂的是 ask 分支:谁来做最终授权?

ask 并不等于“弹一个确认框”。
Claude Code 的做法是把多个授权通道同时发起,然后谁先给答案谁赢:

  • 本地 UI;
  • PermissionRequest Hook;
  • Bash AI 分类器;
  • Bridge 远程响应;
  • 其他渠道。
flowchart TD
    ASK["ask"] --> R["createResolveOnce()"]
    R --> UI["本地 UI"]
    R --> HOOK["PermissionRequest Hook"]
    R --> AI["AI 分类器"]
    R --> BRIDGE["远程响应"]
    UI --> F["首个结果生效"]
    HOOK --> F
    AI --> F
    BRIDGE --> F

这说明 Claude Code 的授权模型不是“围绕终端 UI 建的”,而是“围绕决策链建的”。
只要结果能合法返回,它不在乎是谁给的答案。

九、真正的规则引擎在 hasPermissionsToUseTool()

权限判断的优先级大致是:

  1. PermissionRequest Hooks
  2. 工具自己的 checkPermissions
  3. 路径安全检查
  4. 全局权限模式过滤
  5. dontAskask 转成 deny

这是一条很像中间件链的结构。
它把权限拆成三层:

  • 外层策略;
  • 工具本地规则;
  • 环境总规则。

所以 Claude Code 的安全,不是靠某个弹窗撑起来的,而是靠整条决策链层层收口。

十、Hooks 不只是给权限用,它们是系统的可编程神经系统

Claude Code 支持大量 Hook 事件:

  • PreToolUse
  • PostToolUse
  • PermissionRequest
  • SessionStart
  • SessionEnd
  • PreCompact
  • PostCompact
  • SubagentStart
  • TaskCompleted

这意味着:

  • 工具调用前你能插逻辑;
  • 工具调用后你能补上下文;
  • 会话开始时能做环境准备;
  • 压缩前后也能被感知。

换句话说,Hooks 让 Claude Code 不只是一个封闭程序,而是一个可编排系统。

十一、第五章和第六章合起来,其实就是“行动力”和“刹车系统”

第五章讲的是:Agent 为什么真的能自己跑起来。
第六章讲的是:它为什么在有行动力之后还不至于出事故。

这两章必须一起看。因为:

  • 没有循环,Agent 只是聊天;
  • 没有权限,Agent 只是炸弹。

最后

Claude Code 在这两章最值得学的,不是“写了一个 while(true)”,也不是“加了几层确认框”。
真正值钱的是它把这两件事都做成了可维护、可恢复、可扩展的系统结构

  • 循环负责推进任务;
  • 并发规则保证执行有序;
  • Terminal reason 让系统知道为什么停;
  • 三态权限让自动化和人工确认能共存;
  • Hooks 让整套系统具备可编排性。

一个能写代码的 Agent 到这里,才算真正像样。