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

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

先定边界

这组源码剖析不是复制 Claude Code 源码,也不是复述某个泄露/反编译仓库。你给过的 chauncygu/collection-claude-code-source-code 可以作为“公开目录结构和架构信号”的参考,但笔记里的代码是 clean-room 原创最小实现。

这件事很重要。真正值得学的不是某个私有源码里的变量名,而是它背后的工程骨架:

  • CLI 如何接收输入。
  • Agent loop 如何推进任务。
  • 工具如何注册、校验、执行、审计。
  • 上下文如何构建、裁剪、压缩。
  • MCP 如何把外部能力接进本地工具表。
  • Trace/Eval 如何让系统可复盘。

Agent CLI 是什么

一句话:

Agent CLI 是一个运行在终端里的 Agent Runtime:它把自然语言任务转换成“模型推理 + 工具执行 + 状态记录 + 结果输出”的闭环。

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

代码块TEXT · 1 行收起展开
输入 -> 模型 -> 输出

Agent CLI 是:

代码块TEXT · 1 行收起展开
输入 -> 上下文构建 -> 模型 -> 工具调用 -> 权限 -> 工具结果 -> 模型 -> 输出 -> 记录

如果用户说“帮我修这个 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

读源码顺序:入口 -> 主循环 -> 记录。

这张图要反复看。后面所有模块都能放回这张图。

分层读法:

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

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

读大型 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 项目可以这样拆:

代码块TEXT · 34 行收起展开
agent-cli/
├── src/
│   ├── main.ts                 # 程序入口:判断 REPL / --print
│   ├── repl.ts                 # 交互式输入循环
│   ├── commands.ts             # slash commands
│   ├── queryEngine.ts          # Agent 主循环
│   ├── stateMachine.ts         # turn 状态机
│   ├── model/
│   │   ├── adapter.ts          # ModelAdapter 接口
│   │   ├── anthropic.ts        # 某供应商适配
│   │   ├── openaiCompatible.ts # OpenAI-compatible 适配
│   │   └── mock.ts             # 测试模型
│   ├── context/
│   │   ├── buildContext.ts     # 上下文构建
│   │   ├── budget.ts           # token 预算
│   │   ├── memory.ts           # 长期记忆
│   │   └── compaction.ts       # 压缩
│   ├── tools/
│   │   ├── registry.ts         # 工具注册表
│   │   ├── executor.ts         # 工具执行器
│   │   ├── permissions.ts      # 权限门禁
│   │   ├── readFile.ts
│   │   ├── editFile.ts
│   │   ├── grep.ts
│   │   └── bash.ts
│   ├── mcp/
│   │   ├── client.ts
│   │   ├── transport.ts
│   │   └── wrapTool.ts
│   ├── trace.ts
│   ├── transcript.ts
│   ├── cost.ts
│   ├── renderer.ts
│   └── types.ts

这个切法背后的判断:

  • QueryEngine 是“调度者”,不是万能类。
  • ModelAdapter 负责屏蔽供应商差异。
  • 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 }; // 本轮结束。

这几个类型分别代表:

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

这四个边界清楚,系统就不容易乱。

数据流拆解

1. 用户输入进入 CLI

代码块TEXT · 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 / 搜索结果。
  • 当前用户输入。

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. 审计记录。

5. 工具结果回填给模型

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

不回填就不是 Agent loop。模型必须“观察”工具结果,才能继续推理。

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

直接 return string 的问题:

  • 用户看不到工具正在执行。
  • Web / CLI / JSONL 难复用。
  • 不能中途显示权限请求。
  • 不能记录细粒度 trace。

事件流:

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

可以输出:

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

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

核心不变量

源码读到最后,要检查这些 invariant:

不变量意义
模型只能看到 messages所有上下文都必须显式注入
工具调用必须走 executor不能绕过权限
工具结果必须回填否则 Agent 断链
每轮必须有停止条件防止无限循环
输出必须事件化支持多端渲染和 trace
错误必须分类才能恢复和调试
成本必须记录Agent 多轮调用成本高

失败模式总览

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

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

源码验收门槛

全局架构不是画出模块图就算懂。要能从源码里证明“输入、模型、工具、输出、trace”都走同一条受控链路。

验收项必须看到的源码证据不合格表现
单一主链路run/query/turn 之类入口统一创建 TurnContextREPL、print、resume 各走一套逻辑
事件是事实源模型 token、tool call、tool result、error 都转成事件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:外部工具如何包装成本地工具。

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