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:它把自然语言任务转换成“模型推理 + 工具执行 + 状态记录 + 结果输出”的闭环。
它不是一个普通聊天壳。普通聊天壳只有:
代码块收起展开
输入 -> 模型 -> 输出Agent CLI 是:
代码块收起展开
输入 -> 上下文构建 -> 模型 -> 工具调用 -> 权限 -> 工具结果 -> 模型 -> 输出 -> 记录如果用户说“帮我修这个 bug”,Agent CLI 不是只给建议。它可能会:
- 读取项目规则。
- 查看 git 状态。
- 搜索错误信息。
- 读取相关文件。
- 修改代码。
- 运行测试。
- 根据测试结果继续修改。
- 汇报改了什么。
这就已经是一个小型操作系统级的调度问题了。
总架构
这张图要反复看。后面所有模块都能放回这张图。
分层读法:
| 层 | 先看什么 | 不要先陷进去的细节 |
|---|---|---|
| 交互层 | 输入如何分流到 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 |
| MCP | mcp, transport, server, client |
如果主链路没看懂,直接看具体工具实现会迷路。
最小实现的模块切分
一个足够解释 Agent CLI 的 clean-room 项目可以这样拆:
代码块收起展开
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 是一等公民,不是事后补日志。
核心类型:先统一数据形状
没有统一类型,系统很快会乱。
代码块收起展开
// 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 | 程序执行工具后的观察结果 |
AgentEvent | Agent 对外输出的流式事件 |
这四个边界清楚,系统就不容易乱。
数据流拆解
1. 用户输入进入 CLI
代码块收起展开
> 帮我看看为什么测试失败REPL 不应该直接调模型,而是先分流:
代码块收起展开
// 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 构建上下文
代码块收起展开
// buildContext = 构建模型上下文。
// 它把用户输入、项目规则、记忆、历史和检索结果整理成 messages。
const messages = await buildContext({
userInput, // 当前用户任务。
cwd, // 工作区路径,用于读取项目规则。
sessionId, // 当前会话,用于读取 transcript/memory。
model // 模型名,用于选择 token budget。
});这里会收集:
- system prompt。
- 项目规则。
- memory。
- 最近 transcript。
- RAG / 搜索结果。
- 当前用户输入。
3. 模型返回文本或工具调用
代码块收起展开
// callModel = 调模型的统一入口。
// messages 是上下文,tools 是工具 schema。
const response = await model.complete({
messages,
tools: getToolSchemas()
});模型可能返回:
代码块收起展开
// 模型直接返回文本:本轮可以结束。
{ type: "text", text: "测试失败原因是..." }也可能返回:
代码块收起展开
{
// 模型请求调用工具。
type: "tool_call",
id: "call_1", // 工具调用 ID。
name: "grep", // 工具名。
input: { pattern: "Error:", include: "src/**/*.ts" } // 工具参数。
}4. 工具调用进入安全门
代码块收起展开
// executeToolCall = 工具执行统一入口。
// 内部负责参数校验、权限、超时、截断和审计。
const result = await executeToolCall(call);里面必须做:
- 工具存在性检查。
- JSON schema 校验。
- 权限判断。
- 超时控制。
- 输出截断。
- 审计记录。
5. 工具结果回填给模型
代码块收起展开
// 工具结果必须回填给模型。
// 否则模型下一轮不知道 grep/read_file/shell 看到了什么。
messages.push({
role: "tool",
toolCallId: call.id,
content: result.content
});不回填就不是 Agent loop。模型必须“观察”工具结果,才能继续推理。
为什么要事件流,而不是直接 return string
直接 return string 的问题:
- 用户看不到工具正在执行。
- Web / CLI / JSONL 难复用。
- 不能中途显示权限请求。
- 不能记录细粒度 trace。
事件流:
代码块收起展开
// query 输出 AgentEvent 流。
// 终端、Web、测试都可以用同一组事件驱动。
for await (const event of query(input)) {
renderEvent(event);
}可以输出:
代码块收起展开
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 turns | QueryEngine |
| 修改错文件 | 搜索/编辑锚点不准 | Search/Edit |
| 输出过长爆上下文 | 工具结果不截断 | ToolExecutor |
| 改 prompt 后质量下降 | 无 eval | Eval |
| debug 靠猜 | 无 trace | Trace |
这张表比某个具体源码函数更重要。它告诉你每个模块为什么存在。
源码验收门槛
全局架构不是画出模块图就算懂。要能从源码里证明“输入、模型、工具、输出、trace”都走同一条受控链路。
| 验收项 | 必须看到的源码证据 | 不合格表现 |
|---|---|---|
| 单一主链路 | run/query/turn 之类入口统一创建 TurnContext | REPL、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,都按这个顺序:
- 找入口:怎么进入 REPL 或 print mode。
- 找主循环:一次用户输入如何变成模型请求。
- 找工具表:工具如何注册给模型。
- 找工具执行器:是否统一校验权限。
- 找上下文构造:模型到底看见了什么。
- 找流式事件:输出如何被 UI 消费。
- 找 transcript/trace:过程是否可复盘。
- 找 MCP:外部工具如何包装成本地工具。
不要被文件数量吓住。大型源码只是这个模型的放大版。