在 CLI 发一条消息,内部发生了什么
昨天 Claude Code 虽然紧急撤回了一次「开源」,但是现场已经被有心人保留了下来,我们当然不能放过这绝好的机会,肯定得来凑凑热闹。
让我们来一次酣畅的源码解密之旅吧!
先建立心智模型
我:如果只用一句话概括,Claude Code 在干嘛?
它不是一个“终端版聊天框”,而是一个小型 runtime。你发进来的是一句话,但它内部会把这句话放进一条完整的执行流水线里:
- 先判断输入是什么
- 再补齐上下文
- 再决定要不要调用模型
- 模型如果要调工具,runtime 就去执行
- 工具结果再喂回模型
- 反复循环,直到这一轮真的结束
总图先看这个:
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: 生成后续执行计划
这里常见的工作有四类。
第一类是基础校验。空输入直接丢掉,一些像 exit、quit 这种别名会先被重写成统一入口。
第二类是引用展开。如果你在输入里引用了之前粘贴的文本、图片或者某个资源,占位符会先被替换掉,后续流程看到的是展开后的内容。
第三类是多模态归一化。图片不是原样就直接发给模型,运行时通常会先处理尺寸、格式和元信息。
第四类是命令分流。不是所有输入都走“普通聊天”路径。/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。最重要的几种是:
textthinkingtool_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()
这段伪代码里最值得记住的,还是三个结论:
- 一条消息提交后,先发生的是输入归一化和路由,不是立刻调模型。
- 模型真正看到的是“动态装配后的上下文”,不是你刚刚敲下的那一句原文。
- 工具调用里,LLM 负责意图,Runtime 负责执行计划和调度。
理解了这三点,后面再看 Slash Command、Skill、权限系统、子 Agent、MCP,都会顺很多。