Agent CLI源码剖析 - 全局架构与数据流

Agent CLI 源码剖析 01:全局架构与数据流

先定边界

这组源码剖析没有复制 Claude Code 源码,也没有复述某个泄露/反编译仓库。你给过的 chauncygu/collection-claude-code-source-code 可以作为“公开目录结构和架构信号”的参考,但笔记里的代码是 clean-room 原创最小实现(clean-room 的意思是:不看原实现的代码,只根据公开的行为和接口自己重写一份,避免版权问题)。

这件事很重要。值得学的是它背后的工程骨架,某个私有源码里的变量名换个项目就没用了。工程骨架指这几件事:

  • CLI 如何接收输入。
  • Agent loop 如何推进任务。
  • 工具如何注册、校验、执行、审计。
  • 上下文如何构建、裁剪、压缩。
  • MCP(Model Context Protocol,一个标准协议,外部程序按它把自己的能力暴露成工具,Agent 调起来就像调本地工具)如何把外部能力接进本地工具表。
  • Trace(执行过程的详细日志,记录每一步调了什么、结果如何)和 Eval(自动化评测,用一批固定任务跑分,改动后对比效果有没有退步)如何让系统可复盘。

Agent CLI 是什么

一句话:

Agent CLI 是一个运行在终端里的 Agent Runtime(专门跑 Agent 的运行时环境,地位类似 JVM 之于 Java 程序):它把自然语言任务转换成“模型推理 + 工具执行 + 状态记录 + 结果输出”的循环,工具结果会喂回模型,模型再决定下一步,直到任务完成。

它不是一个普通聊天壳。普通聊天壳只有:

输入 → 模型 → 输出

Agent CLI 是:

输入 → 上下文构建 → 模型 → 工具调用 → 权限 → 工具结果 → 模型 → 输出 → 记录

如果用户说“帮我修这个 bug”,Agent CLI 不是只给建议。它可能会:

  1. 读取项目规则。
  2. 查看 git 状态。
  3. 搜索错误信息。
  4. 读取相关文件。
  5. 修改代码。
  6. 运行测试。
  7. 根据测试结果继续修改。
  8. 汇报改了什么。

这一连串动作里有任务分解、权限控制、失败重试、资源限制,已经是一个小型操作系统级的调度问题了。

总架构

Agent CLI 分层架构:从用户输入到可审计执行 交互层 REPL / --print Slash Command / Renderer 调度层 QueryEngine StateMachine / StopReason 模型视野层 ContextBuilder / Memory RAG / Tool Result / Budget 模型适配层 ModelAdapter Streaming / Tool Call Parse 执行安全层 ToolRegistry / Executor Permission / Audit / Sandbox 扩展层 MCP Client / Transport Tool Wrapper / Plugin 记录层 Transcript / Trace Cost / Eval / Debug Report 读源码顺序:入口 -> 主循环 -> 记录。

这张图要反复看。后面所有模块都能放回这张图。先解释图里几个词:REPL 是 Read-Eval-Print Loop,就是终端里你输一行它答一行的交互模式;—print 是非交互模式,执行完任务打印结果就退出,方便脚本调用;Slash Command 是 / 开头的内置命令,比如 /help。

分层读法:下表每行对应图里一层,中间列是这一层该先抓的主线,右列是容易把人拖进去、但第一遍可以跳过的细节。

先看什么不要先陷进去的细节
交互层输入如何分流到 command 或 query主题、快捷键、终端样式
调度层一轮任务如何推进状态单个工具内部实现
模型视野层messages 怎样构造和裁剪prompt 文案润色
执行安全层工具是否统一过权限和审计某个 read/write API
扩展层MCP 如何包装成本地 Tool具体 server 的业务逻辑
记录层trace/transcript/cost 是否完整UI 展示格式

表里的 transcript 指完整对话记录:用户说了什么、模型回了什么、工具返回了什么,按顺序全存下来。它和 trace 的分工:transcript 是对话内容本身,trace 是给开发者 debug 用的执行日志。

源码阅读的第一原则:先找主链路

读大型 Agent CLI 源码,不要先看:

  • UI 组件。
  • 主题。
  • 快捷键。
  • 某个具体工具。
  • 几百个 util。
  • 供应商 SDK 细节。

先找这几类文件:

想找什么常见名字
程序入口main, start, cli, repl
输入处理processInput, handleInput, commandRouter
主循环query, QueryEngine, runAgent, turnLoop
模型层model, client, adapter, stream
工具层Tool, tools, toolRegistry, executeTool
上下文context, prompt, memory, history
权限permission, approval, policy
记录transcript, trace, cost, telemetry
MCPmcp, transport, server, client

拿到一个陌生仓库,直接全局搜表里这些名字,定位入口后顺着调用链往下读。如果主链路没看懂,直接看具体工具实现会迷路。

最小实现的模块切分

一个足够解释 Agent CLI 的 clean-room 项目可以这样拆:

agent-cli 最小实现模块切分

这个切法背后的判断:

  • QueryEngine 只做调度:决定下一步调模型还是调工具。校验、渲染、权限都别塞进来,塞成万能类以后没法测试。
  • ModelAdapter 负责屏蔽供应商差异:各家模型 API 的请求格式、流式协议都不同,适配层统一成一个接口,和 JDBC 屏蔽各家数据库差异是同一个思路。
  • ToolExecutor 负责安全边界。
  • ContextBuilder 负责模型视野。
  • MCP 只是外部工具来源之一,接进来的工具包一层适配后和本地工具一样用,不应该污染主循环。
  • Trace/Eval 从第一天就要设计进架构,等出了问题再补日志就来不及了。

核心类型:先统一数据形状

没有统一类型,系统很快会乱。

代码块TS · 43 行收起展开
// Message = 模型能看到的上下文。
// Agent 的所有记忆、工具结果、用户输入最终都要变成 Message。
export type Message =
  | {
      // system/user/assistant 是普通文本消息。
      role: "system" | "user" | "assistant";
      // 消息正文。
      content: string;
    }
  | {
      // tool 消息是工具执行后的观察结果。
      role: "tool";
      // 对应哪次工具调用。
      toolCallId: string;
      // 工具返回内容,例如文件片段、grep 结果、测试输出。
      content: string;
    };

// ToolCall = 模型提出的动作请求。
// 它还没有被程序批准和执行。
export type ToolCall = {
  id: string;        // 调用 ID,用来和工具结果匹配。
  name: string;      // 工具名,例如 grep、read_file。
  input: unknown;    // 工具参数,必须做 schema 校验。
  sessionId: string; // 当前会话 ID,用于 trace/audit。
};

// ToolResult = 程序执行工具后的结果。
export type ToolResult = {
  summary: string;                    // 一句话摘要。
  content: string;                    // 详细输出,会回填给模型。
  metadata?: Record<string, unknown>; // 行号、数量、hash、是否截断等结构化信息。
};

// AgentEvent = Agent 对 UI/终端/测试输出的事件。
// 事件化后,同一个 QueryEngine 可以接 CLI、Web、JSONL。
export type AgentEvent =
  | { type: "text_delta"; text: string } // 模型文本增量。
  | { type: "tool_start"; call: ToolCall } // 工具开始。
  | { type: "tool_result"; call: ToolCall; result: ToolResult } // 工具完成。
  | { type: "state"; name: string } // 状态机状态变化。
  | { type: "error"; error: AgentErrorShape } // 结构化错误。
  | { type: "turn_end"; reason: StopReason }; // 本轮结束。

代码里还引用了两个这篇没展开的类型:StopReason 是本轮停下来的原因(比如给出了最终回答、达到最大轮数),AgentErrorShape 是带分类信息的结构化错误对象。这两个都属于状态机的内容,下一篇讲状态机时细讲,这里先知道它们是干什么的就行。

这几个类型分别代表:

类型作用
Message给模型看的上下文
ToolCall模型提出的动作请求
ToolResult程序执行工具后的观察结果
AgentEventAgent 对外输出的流式事件

这四个边界清楚,系统就不容易乱。这一步很像后端项目先定好 DTO:各层之间传什么形状的数据先说死,后面每层才能独立改动。

数据流拆解

下面按一个 turn(一轮:从用户输入到 Agent 给出最终回答的完整过程,中间可能夹多次工具调用)的时间顺序,把数据的流向拆开讲。

1. 用户输入进入 CLI

代码块PLAINTEXT · 1 行收起展开
> 帮我看看为什么测试失败

REPL 拿到输入先分流,不要直接丢给模型:

代码块TS · 11 行收起展开
// REPL 输入分流。
// slash command 是确定性程序命令;普通文本才进入 Agent 主循环。
if (input.startsWith("/")) {
  // 例如 /help、/cost、/mcp list。
  await runCommand(input);
} else {
  // query 返回事件流,renderEvent 决定如何展示。
  for await (const event of query(input)) {
    renderEvent(event);
  }
}

为什么 slash command 不交给模型?

因为 /help/mcp list/cost 是确定性程序行为,让模型生成既慢又不稳定。

2. QueryEngine 构建上下文

代码块TS · 8 行收起展开
// buildContext = 构建模型上下文。
// 它把用户输入、项目规则、记忆、历史和检索结果整理成 messages。
const messages = await buildContext({
  userInput, // 当前用户任务。
  cwd,       // 工作区路径,用于读取项目规则。
  sessionId, // 当前会话,用于读取 transcript/memory。
  model      // 模型名,用于选择 token budget。
});

这里会收集:

  • system prompt。
  • 项目规则。
  • memory。
  • 最近 transcript。
  • RAG / 搜索结果。
  • 当前用户输入。

逐个解释:system prompt 是写在最前面的固定指令,规定模型的角色和行为规则;memory 是跨会话的长期记忆,比如“这个项目用 pnpm 别用 npm”;transcript 是本次会话到目前为止的对话记录;RAG(Retrieval Augmented Generation)说白了就是先从代码库或文档里检索出相关片段塞进上下文,让模型照着答,省得它凭记忆瞎编。
代码里的 model 参数用来查这个模型的 token 预算:token 是模型计量文本的单位,大致一个英文单词或半个汉字算一个 token,预算就是上下文窗口的上限,超了就得裁剪。

为什么每轮都要重新构建上下文?因为模型本身完全无状态,和 HTTP 一样:它不记得上一轮发生过什么,每次请求都得把它需要知道的东西全部装进 messages 带过去。

3. 模型返回文本或工具调用

代码块TS · 6 行收起展开
// callModel = 调模型的统一入口。
// messages 是上下文,tools 是工具 schema。
const response = await model.complete({
  messages,
  tools: getToolSchemas()
});

模型可能返回:

代码块TS · 2 行收起展开
// 模型直接返回文本:本轮可以结束。
{ type: "text", text: "测试失败原因是..." }

也可能返回:

代码块TS · 7 行收起展开
{
  // 模型请求调用工具。
  type: "tool_call",
  id: "call_1", // 工具调用 ID。
  name: "grep", // 工具名。
  input: { pattern: "Error:", include: "src/**/*.ts" } // 工具参数。
}

4. 工具调用进入安全门

代码块TS · 3 行收起展开
// executeToolCall = 工具执行统一入口。
// 内部负责参数校验、权限、超时、截断和审计。
const result = await executeToolCall(call);

里面必须做:

  1. 工具存在性检查。
  2. JSON schema 校验。
  3. 权限判断。
  4. 超时控制。
  5. 输出截断。
  6. 审计记录。

这层的思路和 Servlet 过滤器链一样:所有工具调用必须穿过同一串拦截器,校验、鉴权、日志各做各的,任何调用都绕不过去。
其中 JSON schema 校验相当于后端的参数校验(类似 Spring 里的 @Valid),模型生成的参数完全可能格式不对,不校验就会把工具直接搞崩。
输出截断则是因为工具结果要回填给模型:一次 grep 匹配几万行的话,全塞进上下文会超出 token 预算,所以要截断并在 metadata 里标记。

5. 工具结果回填给模型

代码块TS · 7 行收起展开
// 工具结果必须回填给模型。
// 否则模型下一轮不知道 grep/read_file/shell 看到了什么。
messages.push({
  role: "tool",
  toolCallId: call.id,
  content: result.content
});

不回填,循环就断了:模型下一轮根本看不到 grep 找到了什么,只能凭空瞎猜。模型必须“观察”工具结果,才能继续推理。

为什么要事件流,而不是直接 return string

直接 return string 的问题:

  • 用户看不到工具正在执行。
  • 想同时支持 Web、CLI、JSONL(每行一条 JSON 的日志格式,方便程序逐行解析)输出时难复用。
  • 不能中途显示权限请求。
  • 不能记录细粒度 trace。

事件流:

代码块TS · 5 行收起展开
// query 输出 AgentEvent 流。
// 终端、Web、测试都可以用同一组事件驱动。
for await (const event of query(input)) {
  renderEvent(event);
}

可以输出:

代码块PLAINTEXT · 6 行收起展开
state: build_context
state: call_model
tool_start: grep
tool_result: 发现 12 个匹配
text_delta: 我找到了失败原因...
turn_end: final_answer

这就是 Agent Runtime 的真实执行过程。

核心不变量

源码读到最后,要检查这些 invariant(不变量:无论系统跑到哪一步都必须成立的规则,类似数据库事务要保证的一致性约束)。左列是规则本身,右列是这条规则存在的理由:

不变量意义
模型只能看到 messages所有上下文都必须显式注入
工具调用必须走 executor不能绕过权限
工具结果必须回填不回填,模型下一轮看不到工具输出,循环在这一步就断了(前面第 5 步讲过)
每轮必须有停止条件防止无限循环
输出必须事件化支持多端渲染和 trace
错误必须分类分了类程序才知道该重试还是该报给用户,debug 时也能直接定位到出错的模块
成本必须记录Agent 多轮调用成本高

失败模式总览

这张表的读法:左列是你实际会观察到的症状,中列是背后原因,右列告诉你去哪个模块找问题。调试时从症状反查模块,比盲目翻代码快得多。

失败原因对应模块
模型答非所问上下文没构建好ContextBuilder
模型乱调用工具工具描述/选择策略差Tool schema
工具越权权限门禁缺失Permission
无限循环没有 max turnsQueryEngine
修改错文件搜索/编辑锚点不准Search/Edit
输出过长爆上下文工具结果不截断ToolExecutor
改 prompt 后质量下降无 evalEval
debug 靠猜无 traceTrace

这张表比某个具体源码函数更重要。它告诉你每个模块为什么存在。

源码验收门槛

光画出模块图不算懂全局架构,要能从源码里证明“输入、模型、工具、输出、trace”都走同一条受控链路。下表用法:中间列是你在源码里应该找到的证据,右列是反例,出现了就说明架构有漏洞。

验收项必须看到的源码证据不合格表现
单一主链路run/query/turn 之类入口统一创建 TurnContextREPL、print、resume(恢复历史会话继续跑)各走一套逻辑
事件是唯一事实来源(source of truth)模型 token、tool call、tool result、error 都转成事件,UI 展示和 trace 都从事件流派生UI 直接拼字符串,trace 只能事后猜
工具统一穿过 executor所有工具调用都进入同一个权限、超时、审计层某些内置命令绕过权限或无日志
上下文显式构造system、rules、memory、transcript、user input 有固定顺序prompt 由散落字符串临时拼接
写操作可追踪edit/shell/mcp 调用都有 callId/sessionId文件改了但不知道是哪轮模型触发
失败可定位错误分成模型、权限、工具、上下文、验证、传输只有一个 Error: failed

最小回归样例:让 Agent 执行一次“读文件 -> 修改一行 -> 运行测试 -> 总结”的任务。合格实现应该能用同一个 sessionId 串起用户输入、上下文构造、模型请求、工具调用、文件 diff、测试输出和最终回答;缺了任何一段,就说明架构图还停在纸面上,代码里的链路没有真正串起来。

读源码抓手

读任何 Agent CLI,都按这个顺序:

  1. 找入口:怎么进入 REPL 或 print mode。
  2. 找主循环:一次用户输入如何变成模型请求。
  3. 找工具表:工具如何注册给模型。
  4. 找工具执行器:是否统一校验权限。
  5. 找上下文构造:模型到底看见了什么。
  6. 找流式事件:输出如何被 UI 消费。
  7. 找 transcript/trace:过程是否可复盘。
  8. 找 MCP:外部工具如何包装成本地工具。

不要被文件数量吓住。大型源码只是这个模型的放大版。

延伸阅读