阅读契约:跟踪“修复测试”这条输入怎样变成模型请求、经过检查的工具动作和可恢复记录。读完应能分清排队、执行、记录与目标验证的时点。

先把场景说小一点。你在项目里输入:“帮我修一下这个测试失败。”Claude Code 没有立刻把这句话原样扔给模型。 它先经过入口分流,进入交互界面;再和 slash command、本地命令、后台通知、权限请求一起排队; 然后 REPL 组装这一轮要用的系统提示词、用户记忆、仓库状态、工具上下文;最后才进入 query() 这条 agent loop。

所以第一篇不急着拆某个机制。我们先建立一张地图:一次任务到底经过哪些阶段, 每个阶段怎样改变输入、由哪段程序接着处理,后续文章又应该从哪一层继续往下读。 只要这条主线清楚,后面看上下文、工具、权限、MCP、subagent、hooks、compact 和 resume,都不会迷路。

这篇的结论先放在前面:Claude Code 不是“一个大函数里问模型、执行工具、打印结果”。 它更像一条分层流水线:入口负责分流,队列负责排序,REPL 负责装配回合,query() 负责模型循环,工具层负责检查并执行本地动作,记录层负责让 UI、磁盘和恢复路径各自拿到需要的事实。

取材范围也先说明。本文的源码依据来自 Claude Code 公开源码镜像。 本文核对的是该镜像的 2026-03-31 源码快照,源码链接固定到同一提交。它不是 Anthropic 官方开源实现,也不能代表当前发行版;本文只把快照内可见的客户端行为写成直接事实。 产品概念和使用方式则参考 Claude Code 官方概览、 Claude Code memory 文档 和 Anthropic 的 tool use 文档。 遇到服务端内部行为、功能开关背后的策略和 provider 实现,本文只描述客户端能看到的请求内容和状态变化。

这篇只回答五个问题:

  1. CLI 接到参数以后,哪些路径会提前返回,哪条才是普通交互主线?
  2. 为什么用户输入、slash command、后台通知和权限请求要先进统一队列?
  3. REPL 在调用 query() 前,要把哪些上下文和工具环境装好?
  4. 模型吐出 tool_use 以后,为什么不是直接执行,而是要先检查工具和权限?
  5. 屏幕消息、transcript、resume 记录为什么要分开看?
type NormalTurnRoute = {
  cli: "early-return flags" | "interactive REPL"
  queue: "user prompt" | "slash command" | "permission response"
  repl: { systemPrompt; memory; tools; permissionContext }
  queryLoop: { modelVisibleMessages; apiStream; toolUseBlocks }
  toolGate: { validate; permission; execute; toolResult }
  transcript: { uiMessages; durableRows; resumeState }
}
源码形状:第一篇先让读者记住负责这些步骤的模块和交接点,后面每篇再放大其中一个阶段。

一、先把一条任务拆成七个阶段

先不要打开文件树乱逛。把一次任务想成一张路线图,会更容易看清每段源码负责什么。 下面这七个阶段,就是后面几篇文章反复会回来的骨架。

阶段 它做什么 先看哪些源码
入口分流 处理版本、远程控制、MCP host、恢复参数,再把普通交互交给 REPL。 entrypoints/cli.tsx、main.tsx、replLauncher.tsx
统一队列 把用户输入、slash command、后台消息和权限补偿放进同一条可排序队列。 commands.ts、messageQueueManager.ts
REPL 回合装配 为一轮请求准备系统提示词、用户记忆、仓库快照、工具上下文和权限函数。 screens/REPL.tsx、context.ts、Tool.ts
query() 循环 在每轮 API 调用前整理 model-visible view,处理流式响应和 follow-up。 query.ts、utils/api.ts、services/api/claude.ts
模型流 把 assistant message、文本、thinking、tool_use 和 API 错误变成可消费事件。 query.ts、services/api/claude.ts
工具检查与执行 把模型提出的工具调用变成经过校验、权限、并发策略和进度事件的本地动作。 toolOrchestration.ts、toolExecution.ts、useCanUseTool.tsx
记录与恢复 让屏幕消息、transcript、内容替换、compact 标记和 resume 路径服务不同目的。 sessionStorage.ts、conversationRecovery.ts、sessionRestore.ts

这张表不是目录,而是阅读顺序。入口层告诉你“任务从哪里来”,队列层告诉你“谁先被处理”, REPL 和 query() 告诉你“模型到底看到了什么”,工具层告诉你“本地动作怎样被检查和执行”, 记录层告诉你“为什么能继续、能恢复、能回放”。

二、入口层:CLI 负责分流,REPL 才是普通交互主路

Claude Code 的入口不是直接进入聊天循环。 entrypoints/cli.tsx 一开始就把启动路径切得很细:版本号、系统提示词 dump、MCP 原生 host、远程控制、后台 session 这类路径, 都可能在真正渲染 REPL 之前提前返回。

普通交互主线继续走到 main.tsx。这里先把 session config、commands、初始工具、MCP client、 系统提示词和 turn complete 回调装好。 这段 sessionConfig 是后面 REPL 和 query() 的公共底座。 如果用户是 --continue 或指定 resume session, 它会先加载并处理旧会话; 如果是新会话,则沿 fresh interactive path 进入 REPL。

真正的 UI 入口在 replLauncher.tsx。 它动态加载 App 和 REPL,再把 REPL 挂到应用壳里。 这一步看起来很轻,但它完成了一个重要分界:CLI 之后,普通任务开始生活在一个可以显示进度、接收快捷命令、 处理权限提示、维护消息状态的交互运行时里。

所以读入口层时,先抓住一个判断:不是所有 CLI 参数都会进入 agent loop。 真正需要沿源码往下追的,是“普通交互或恢复后的交互”这条路。

三、队列层:输入先变成可排序的 command

进入 REPL 后,下一件容易被低估的事是队列。Claude Code 不把所有输入都当成一行文本处理。 commands.ts 里可以看到大量内建 slash command;后面还会加载技能和插件提供的命令。 换句话说,用户敲的 /compact、/context、普通 prompt、本地命令、后台 task 通知, 都可能变成不同形状的 command。

统一排队的代码在 messageQueueManager.ts。 注释写得很直白:所有命令、用户输入、task notification、orphaned permission requests 都走同一条 command queue。 队列有 now、next、later 三档优先级,同档内部保持 FIFO。普通输入默认 next,后台通知默认 later;出队过滤器还可以把其他 agent 的输入留在队列中。 enqueue() 和 dequeue() 就是在维护这个顺序。

这个设计解决的不是“代码写得整齐”这种表面问题,而是交互一致性问题: 用户刚输入的下一句话、后台 agent 的结果、一个正在等待答复的权限请求,不能随便抢占彼此。 它们要先变成同一类可排序对象,REPL 才能稳定地把一轮轮请求送进 query()。

四、REPL 层:一轮请求先装好上下文和工具环境

到了 REPL,重点不是“把输入框内容传给模型”,而是准备一轮运行所需的材料。 REPL.tsx 会创建 toolUseContext,读取当前 tools 和 MCP clients,再拿默认系统提示词、用户上下文、系统上下文, 最后构造这一轮 effective system prompt。

用户上下文和系统上下文来自 context.ts。 系统侧可读取初始 git 状态,远程模式或关闭 git 指令时会跳过;用户侧可加载 CLAUDE.md 并补上日期。两类上下文都用 memoize 缓存,后续调用通常复用结果;重新调用函数不等于每轮重新读盘。 这些信息不会自动等同于聊天历史。进入 API 前, appendSystemContext() 和 prependUserContext() 会把它们分别放到系统提示词和 meta user message 的位置。

工具环境也不是一串函数名。 ToolUseContext 里放着 commands、tools、MCP clients、agent definitions、权限上下文、消息、内容替换状态、渲染后的系统提示词等东西。 这就是后面工具调用为什么能看到当前会话状态、权限设置、可用 MCP 工具和内容替换记录的原因。

材料装好后,REPL 才真正进入 for await (const event of query(...))。 从这里开始,任务进入 agent loop。

五、query() 层:真正的 agent loop 在这里

query() 是这条主线的中轴。 外层 query() 是一个 async generator,它把工作委托给 queryLoop(),并在结束时把已消费 command 标记完成。 里面的 queryLoop() 才是反复问模型、处理工具、继续下一轮的地方。

query 循环根据 tool_use 分流:工具结果返回上下文,无工具调用时经过停止检查再继续或结束本轮
主循环的分支示意:工具结果返回下一次请求;没有 tool_use 仍要经过停止检查。图中省略异常和预算分支;结束本轮不等于任务目标已验证。

5.1 调模型之前,先整理 model-visible view

queryLoop() 不是拿到 messages 就直接发。 在发请求前, 它会先从最后一个 compact 标记之后取消息,应用工具结果预算,再按条件处理 snip、microcompact、 context collapse 和 auto compact。快照缺少部分开关后的实现模块;这里能核实的是调用顺序,不能据此推断内部选取策略。也就是说,模型看到的是运行时重新筛选和整理后的内容,不是 REPL 原始历史。

这一段会在后两篇拆开:先看 memory 这类长期材料怎样进入 user context, 再看上下文快爆时 runtime 怎样筛选、裁剪和 compact。这里先记住一个判断就够了: 保存过,不等于下一次 API 请求一定会原样带上。 Claude Code 每轮都会重新决定哪些内容进 model-visible view,哪些只留在本地记录里。

5.2 API 调用是“带工具和上下文的请求形状”

真正调用模型发生在 deps.callModel()。 这一层传进去的不只是 messages,还有完整系统提示词、thinking 配置、可用工具、模型、query source、agents、MCP tools、 task budget、cache 策略和权限上下文获取函数。 再往下,queryModel() 会把这些客户端状态变成 Anthropic API 能接收的请求参数。

消息转换也有一套明确规则。 user / assistant message 到 API param 的映射 会处理 cache control、工具结果、媒体 block、thinking block 等细节。 所以“Claude Code 调了一次模型”这句话背后,其实是把 runtime 状态整理成一次合法的 provider 请求。

5.3 流里出现工具调用,loop 不会马上结束

模型返回的是流。queryLoop() 会一边 yield assistant message,一边收集 tool_use block;只有开启流式工具执行时,才随流把完整调用交给 StreamingToolExecutor。是否需要工具后续轮次由实际收到的 block 决定,不能只看 stop_reason。 如果没有工具 follow-up,loop 还会处理输出上限恢复、停止 hook 和 token budget;这些检查可能触发继续,也可能结束本轮; 如果有工具结果或后续输入,就把工具结果整理成下一轮 messages,继续循环。

这就是 agent loop 和普通聊天最直观的差别:模型不只输出文字,它还提出“我想做某个动作”。 但这个动作只是提案,真正执行要进入下一层。

六、工具层:模型提出动作,运行时决定能不能做

工具调度有两条互斥路径。开启流式执行时,完整调用随模型流进入 StreamingToolExecutor,流结束后收取剩余结果;关闭时才调用 runTools()。 后者按原顺序把相邻且可并发的调用组成批次,其他调用各自串行;它不会把跨越写操作的读取抽出来提前执行。 并发安全性由工具对本次解析后的参数判断;例如 BashTool.isConcurrencySafe() 委托只读检查,所以不能把所有 shell 命令一概归为串行。并发资格也不等于已获执行权限。

单个工具调用会进 runToolUse()。 这里先找工具、处理未知工具和 abort,再进入权限与执行包装。 checkPermissionsAndCallTool() 会做输入校验、schema 错误处理、工具自身校验和执行前的附加判断。

权限判断在 React hook 侧也有一层。 useCanUseTool() 会创建 permission context,调用 hasPermissionsToUseTool(), 再把 allow、deny、ask 分支交给不同交互路径。 所以模型说“我要改文件”并不等于文件马上被改;运行时还要看工具是否存在、参数是否合法、当前权限策略是否允许、是否需要用户确认。

后面写工具篇时,会沿这条线继续拆:tool schema 怎样进入模型视图,MCP tool 怎样合并进工具列表, 并发策略怎样影响执行顺序,权限和 sandbox 怎样把副作用收住。

读取 A、读取 B → 两者均可并发时组成一批
写入 A        → 等待前批,单独执行
再次读取 A    → 写入完成后再开始
调度顺序示意。具体资格由本次参数决定;对应 连续批次与 Bash 只读判断。流式和批量入口在 query 工具结果收集处汇合。

七、记录层:屏幕消息、transcript 和 resume 不是同一层

query() yield 出来的事件先回到 REPL。 onQueryEvent 会调用 handleMessageFromStream() 更新屏幕上的 messages,还会处理 compact 标记、progress tombstone 等显示细节。 这是一条 UI 可见路径。

但 UI 可见不等于恢复所需。 recordTranscript() 先过滤记录,再按 UUID 跳过已写消息并插入新的消息链;内容替换、队列操作、compact 链条也各有记录入口。 这些记录服务的是 resume 和恢复;屏幕上出现一条事件,本身不证明这条事件已经写入磁盘。

继续会话时, loadConversationForResume() 会加载旧消息、文件历史快照、内容替换、context collapse commits 和 session metadata; 然后 processResumedConversation() 恢复 session、成本状态、worktree、content replacement state、collapse 快照等运行时状态。 这解释了为什么 Claude Code 的“继续”不是简单把旧聊天贴回输入框,而是恢复一套可继续运行的 runtime。

回到“修复测试”的例子:命令入队只说明输入等待处理;拿到 tool_use 只说明模型提出动作;拿到 tool_result 才能检查执行结果。即使 loop 返回 completed,也仍需用测试退出状态和变更结果判断目标是否达成。停止处理描述的是回合控制流,不是任务正确性的证明。

八、沿这条路线继续读

有了这条主线,后续文章就不需要各自从零开始。每一篇只是在同一条运行路径上放大一个关口。

Claude Code 源码阅读路线图,依次展示 overview、Memory、context、tools、permissions、commands/MCP、subagents、hooks、resume 和 prompt cache 十章
后续文章会沿同一条任务路径拆机制:先走主线,再看长期信息、上下文筛选、工具副作用、恢复和性能。
篇目 主问题 会回到哪一层
总览 一次任务怎样穿过入口、队列、模型循环、工具检查和恢复记录? 全过程。
Memory 层 长期规则、偏好和经验怎样写入文件,并在下一轮变成用户上下文? CLAUDE.md、auto memory、prependUserContext()。
上下文管理 上下文快爆时,为什么不是直接总结? query() 调模型前的 model-visible view。
工具调用 模型提出 tool_use 后,运行时怎样验证、排队和回填结果? runTools()、runToolUse()、streaming executor。
权限与副作用 什么时候 allow、deny、ask,权限上下文怎样影响工具执行? useCanUseTool()、permission context、tool validation。
命令、Skills 与 MCP 外部工具、技能、插件怎样进入一轮请求? commands、MCP clients、tool schema、agent definitions。
Subagent 与 fork 子任务怎样获得一份隔离的上下文,fork 为什么要复制父级前缀? AgentTool、worker tools、sidechain transcript。
Hooks 与生命周期 Hook 为什么不只是脚本回调,还必须在工具、停止和压缩发生时按规则匹配并执行? getMatchingHooks()、executeHooks()、tool / stop / compact checks。
Transcript 与恢复 继续会话为什么不是把旧聊天贴回来,而是重建可运行状态? recordTranscript()、resume loader、session restore、content replacement。
Prompt cache 与性能 为什么速度不是一个缓存开关,而是请求形状、稳定前缀和 fork 尾巴的共同结果? cache_control、cache breakpoints、fork cache-safe params、cached microcompact。

九、复盘:先记住这几个不变量

读源码时,文件名会很多,功能开关也会很多。先记住这些不变量,后面深入细节会省很多力。

不变量 为什么重要
入口不是 loop CLI 先做大量分流,普通交互只是其中一条路。
输入先排队 用户 prompt、slash command、后台通知和权限补偿要共享顺序语义。
REPL 装配回合 系统提示词、memory、git snapshot、工具上下文和权限函数都在进 query() 前准备。
query() 是中轴 上下文投影、API 调用、流式响应、工具结果和 follow-up 都围着它转。
工具调用是提案 真正执行要经过工具查找、输入校验、权限判断、并发策略和结果回填。
记录分层 屏幕显示、transcript、内容替换和 resume 状态服务不同目的,不能混成“聊天记录”。

下一篇先沿这里的 user context 往下钻:Memory 不是聊天记录, 而是长期指令层;再下一篇回到 model-visible view,继续看上下文快爆时为什么不是直接总结。

参考源码与文档