01 - Claude Code 「开源」了,我们能从源码中学到什么

5 阅读9分钟

在 CLI 发一条消息,内部发生了什么

昨天 Claude Code 虽然紧急撤回了一次「开源」,但是现场已经被有心人保留了下来,我们当然不能放过这绝好的机会,肯定得来凑凑热闹。

让我们来一次酣畅的源码解密之旅吧!


先建立心智模型

我:如果只用一句话概括,Claude Code 在干嘛?

它不是一个“终端版聊天框”,而是一个小型 runtime。你发进来的是一句话,但它内部会把这句话放进一条完整的执行流水线里:

  1. 先判断输入是什么
  2. 再补齐上下文
  3. 再决定要不要调用模型
  4. 模型如果要调工具,runtime 就去执行
  5. 工具结果再喂回模型
  6. 反复循环,直到这一轮真的结束

总图先看这个:

flowchart TD
    U[用户在 CLI 输入一句话] --> I[输入预处理]
    I --> R{输入类型判断}
    R -->|本地立即命令| L[本地执行]
    R -->|Slash 命令 / Skill| C[命令展开]
    R -->|普通自然语言| M[构造用户消息]
    C --> A[收集附件与运行时上下文]
    M --> A
    A --> H[执行提交前 Hook]
    H --> Q[发起 Query]
    Q --> S[流式接收模型输出]
    S --> T{出现 tool_use?}
    T -->|否| E[结束本轮]
    T -->|是| X[执行工具]
    X --> Y[生成 tool_result]
    Y --> Z[补充动态附件]
    Z --> Q

这张图里最关键的不是“发起 Query”,而是这条回环:

tool_use -> tool_result -> 再次 Query

对用户来说,你只发了一条消息;对 Claude Code 来说,这更像一段有边界的事务。


第一步:回车之后,先做输入归一化

我:用户按下回车,最先发生什么?

不是立刻调用模型。第一步通常是把输入清洗成系统内部能处理的稳定格式。

sequenceDiagram
    actor User as 用户
    participant UI as Terminal UI
    participant P as 输入处理管线
    participant Router as 路由器

    User->>UI: 输入文本并回车
    UI->>P: 提交原始输入
    P->>P: 过滤空输入 / 退出别名
    P->>P: 展开粘贴引用
    P->>P: 处理图片与多模态输入
    P->>Router: 判断是不是 slash 命令
    Router-->>P: 返回路由结果
    P-->>UI: 生成后续执行计划

这里常见的工作有四类。

第一类是基础校验。空输入直接丢掉,一些像 exitquit 这种别名会先被重写成统一入口。

第二类是引用展开。如果你在输入里引用了之前粘贴的文本、图片或者某个资源,占位符会先被替换掉,后续流程看到的是展开后的内容。

第三类是多模态归一化。图片不是原样就直接发给模型,运行时通常会先处理尺寸、格式和元信息。

第四类是命令分流。不是所有输入都走“普通聊天”路径。/config 这种可能是本地立即执行,/review 这种可能要展开成一段 prompt,普通自然语言则会进入常规 query 流程。

这一段压成伪代码,大概像这样:

onSubmit(rawInput):
  if rawInput is empty:
    return

  normalized = expandReferences(rawInput)
  normalized = normalizeImages(normalized)

  if isImmediateLocalCommand(normalized):
    runLocalCommand()
    return

  if isSlashCommand(normalized):
    routeToCommandOrSkill(normalized)
    return

  continueAsRegularPrompt(normalized)

第二步:先搞清楚,这句话到底是什么

我:Slash 命令和普通自然语言,系统里有什么本质区别?

区别很大。Claude Code 至少有三条主要入口:

flowchart LR
    A[用户输入] --> B{以什么方式处理?}
    B --> C[本地立即命令]
    B --> D[Prompt Command / Skill]
    B --> E[普通用户消息]

本地立即命令的特点是:它根本不需要走 LLM。像配置界面、帮助、某些本地 UI 命令,Terminal 自己就能处理。

Prompt Command 和 Skill 则不同。它们虽然是 /xxx 形式,但目标不是“本地执行完就结束”,而是把一段额外的 prompt、规则或任务说明注入到后续流程里。

普通自然语言最直接:就是一条新的 user message。

这一步很重要,因为它决定了后面到底是:

  • 直接在本地结束
  • 先展开成命令/技能内容
  • 还是进入常规 LLM 回合

第三步:真正发给模型的,不只是你刚才那一句

我:发给模型的请求里,用户输入只占多大一部分?

通常只是其中一部分。Claude Code 真正发给模型的是一个“动态装配”的上下文包。

graph TB
    U[当前用户输入] --> Q[最终 Query]
    H[历史消息] --> Q
    S[系统提示词] --> Q
    C[用户上下文] --> Q
    T[工具列表] --> Q
    A[动态附件] --> Q
    K[Hook 追加信息] --> Q

也就是说,模型看到的不是“用户刚刚说了什么”,而是:

  • 这次用户说了什么
  • 之前聊了什么
  • 你是谁、现在在哪、当前会话状态如何
  • 当前有哪些工具可用
  • 有哪些临时上下文需要在这轮额外提醒它

这一步里,动态附件很关键。它们不是稳定写死在 system prompt 里的,而是每轮现算的运行时信息,比如:

  • skills 列表
  • 文件变化提示
  • memory 相关补充上下文
  • 计划模式状态
  • 待处理通知
  • 任务提醒
  • 队友 / 子 agent 的上下文

这也是为什么 Claude Code 更像 runtime,而不是 prompt 模板。它每一轮都在重新拼装一个适合当前局面的上下文快照。


提交前还有一道关:Hooks

我:上下文拼完就立刻调模型了吗?

还不一定。很多 agent runtime 都会在提交前留一层 Hook,Claude Code 也一样。

sequenceDiagram
    participant Input as 已处理输入
    participant Hooks as Submit Hooks
    participant Runtime as Runtime

    Input->>Hooks: 准备提交
    Hooks->>Hooks: 校验 / 补充 / 拦截
    alt Hook 阻止继续
        Hooks-->>Runtime: 返回阻止原因
        Runtime-->>Runtime: 终止本轮
    else Hook 追加上下文
        Hooks-->>Runtime: 返回附加信息
        Runtime-->>Runtime: 写入附件
    else 允许继续
        Hooks-->>Runtime: 继续
    end

它的作用通常有三类:

  • 阻止继续
  • 注入额外上下文
  • 修改原始输入或后续流程

这一步说明一个事实:Claude Code 不是“收到输入就直接问模型”,而是把每次提交都当成一个可扩展的工作流节点。


第四步:Query 不是一个请求函数,而是一轮事件流

我:终于到调模型了。这里的 Query 本质上是什么?

不是简单的 call LLM once。更像一个带状态的回合执行器。

sequenceDiagram
    participant UI as Terminal UI
    participant Q as Query Runtime
    participant LLM as Model API

    UI->>Q: 提供消息、上下文、工具
    Q->>Q: 组装最终请求
    Q->>LLM: 发起流式请求
    LLM-->>Q: text delta / thinking / tool_use
    Q-->>UI: 实时更新显示
    LLM-->>Q: assistant message 完成
    Q-->>UI: 写入 transcript

这里有三个要点。

第一,Query 是流式的。你在终端里看到的“一行一行往外长”,不是 UI 特效,而是 runtime 在消费流式事件。

第二,Query 是带状态的。它持有消息历史、权限上下文、工具列表、模型选择、会话状态。

第三,Query 不只负责“拿文字”。它还要识别 tool_use、处理 fallback、处理中断、处理后续回合。

所以更准确地说,Claude Code 里的 Query 不是“请求 API 的函数”,而是“驱动一整轮 agent 行为的主循环”。


第五步:模型返回后,怎么知道该继续说还是调用工具

我:模型返回的数据结构里,怎么区分普通回复和工具调用?

在 Claude Code 这种系统里,模型返回的不是一整块纯文本,而是一组结构化 block。最重要的几种是:

  • text
  • thinking
  • tool_use

如果这一轮只产生 text,而且 stop reason 代表结束,那本轮就可以收束。

如果中间出现 tool_use,runtime 就不会把它当普通文本,而会切换到工具执行路径。

可以把它理解成:

assistant_output =
  [text(...), tool_use(...), tool_use(...)]

if contains tool_use:
  switch to tool execution
else:
  finish turn

真正的复杂度,不在“识别到了 tool_use”,而在识别之后该怎么执行。


第六步:工具调度到底谁说了算

我:工具的调度是 LLM 决定的,还是 LLM 只负责提供入参和决定调用哪些工具,具体执行由 Runtime 决定?

更准确地说,是“LLM 负责决策,Runtime 负责调度”。

LLM 负责的部分是:

  • 要不要调用工具
  • 调哪个工具
  • 给什么入参
  • 一次返回几个 tool_use
  • 想先读、再搜、再改,还是先跑命令再读文件

Runtime 负责的部分是:

  • 这个工具当前到底存不存在
  • 入参有没有通过 schema 校验
  • 权限是否允许
  • 这个工具该并发还是串行
  • 当前是否要等别的工具执行完
  • 工具失败后是否取消同批次其他工具
  • 怎么把结果包装成 tool_result
  • 何时进入下一轮 query

可以画成这样:

graph LR
    subgraph LLM[LLM 负责意图]
        A[要不要调工具]
        B[调哪个工具]
        C[填写参数]
        D[一次吐出几个 tool_use]
    end

    subgraph RT[Runtime 负责执行计划]
        E[检查工具是否存在]
        F[校验输入 schema]
        G[检查权限]
        H[决定串行还是并行]
        I[执行工具]
        J[生成 tool_result]
    end

    A --> E
    B --> E
    C --> F
    D --> H
    E --> F --> G --> H --> I --> J

这意味着,LLM 当然会影响“工具计划”,但它并不真正掌控“执行调度权”。

举个最实际的例子。如果模型一次吐出三个 tool_use

1. Read(file_a)
2. Grep(pattern_b)
3. Bash(edit_something)

LLM 表达的是:我现在想做这三件事。

但 Runtime 不一定会按“模型想象中的方式”立刻执行。它可能会这样处理:

if tool is read-only:
  可以并发批量执行

if tool may mutate state:
  串行执行

before any execution:
  先校验参数
  先做权限判断
  先看当前上下文是否允许中断

所以一个更准确的说法是:

LLM 决定“想做什么”,Runtime 决定“怎么安全、怎么高效地做”。

还有一个很容易误解的点:LLM 可以通过“一次吐出多个 tool_use”影响候选并行性,但它不能直接指定“并发度 = 3”或者“这个工具绕过权限立刻执行”。这些都还是 Runtime 的权限。


第七步:为什么一条消息,经常会变成多轮模型调用

我:如果模型调了工具,整条链路接下来怎么继续?

工具执行完,不是“把结果打印给用户就结束”。真正重要的是把结果写回上下文,再进入下一轮。

sequenceDiagram
    participant User as 用户
    participant Runtime as Runtime
    participant LLM as 模型
    participant Tool as 工具

    User->>Runtime: 提交一条消息
    Runtime->>LLM: 第 1 轮 Query
    LLM-->>Runtime: tool_use
    Runtime->>Tool: 执行工具
    Tool-->>Runtime: 结果
    Runtime->>LLM: 第 2 轮 Query(携带 tool_result)
    LLM-->>Runtime: 继续 text / 再次 tool_use
    Runtime->>Tool: 继续执行
    Tool-->>Runtime: 继续返回结果
    Runtime->>LLM: 第 3 轮 Query
    LLM-->>Runtime: end_turn

压成流程图就是:

flowchart TD
    A[用户输入一次] --> B[模型第 1 轮]
    B --> C{有 tool_use?}
    C -->|否| Z[结束]
    C -->|是| D[执行工具]
    D --> E[写回 tool_result]
    E --> F[模型第 2 轮]
    F --> G{还有 tool_use?}
    G -->|有| D
    G -->|否| Z

所以对用户来说是一条消息,对 runtime 来说往往是:

  • 多次模型流式输出
  • 多个工具调用
  • 多轮上下文重组
  • 多次 UI 局部刷新

第八步:为什么终端看起来像“它正在工作”

我:Claude Code 的终端体验为什么和普通聊天框很不一样?

因为 UI 不是旁观者,它也是这条链路的一部分。

UI 不只负责显示最终答案,它还负责:

  • 先把刚提交的用户输入挂出来
  • 在流式输出时实时刷新文本
  • 在工具执行时显示进度
  • 在中断、回退、重试时切换状态
  • 把这轮 query 的中间事件沉淀成最终 transcript

这就是为什么你会感觉它不像“等半天吐一段”,而像“正在干活”。

从用户视角看,终端里的现象和内部动作大概是这样对应的:

  • 回车后立刻出现你的输入:UI 先挂出正在处理的 user message
  • 回复一行一行长出来:runtime 正在消费流式输出
  • 出现某个工具的进度:模型已经发出 tool_use,runtime 正在执行
  • 工具执行完又继续回复:tool_result 已经回写,模型进入下一轮

把整条链路压成一段伪代码

最后把整篇文章压成一段最短的伪代码:

handleOneInput(input):
  normalized = preprocess(input)

  if isLocalImmediateCommand(normalized):
    runLocally()
    return

  newMessages = buildUserMessages(normalized)
  newMessages += collectAttachments()
  newMessages += runSubmitHooks()

  while true:
    response = runQuery(
      systemPrompt,
      userContext,
      history + newMessages,
      availableTools
    )

    streamResponseToUI(response)

    if response.hasNoToolUse():
      finishTurn()
      return

    toolResults = runtimeExecute(response.toolUses)
    newMessages = toolResults + collectInterTurnAttachments()

这段伪代码里最值得记住的,还是三个结论:

  1. 一条消息提交后,先发生的是输入归一化和路由,不是立刻调模型。
  2. 模型真正看到的是“动态装配后的上下文”,不是你刚刚敲下的那一句原文。
  3. 工具调用里,LLM 负责意图,Runtime 负责执行计划和调度。

理解了这三点,后面再看 Slash Command、Skill、权限系统、子 Agent、MCP,都会顺很多。