Agent CLI深水区 - 完整最小实现地图
Agent CLI 深水区 01:完整最小实现地图
这篇解决什么
前面的 06-10 讲的是骨架:入口、主循环、工具、上下文、MCP(Model Context Protocol,一个让 Agent 用统一协议接入外部工具和数据源的开放标准)。骨架知道以后,还差一层“代码落地感”:
- 一个最小 Agent CLI 到底需要哪些类型?
- 文件之间怎么依赖?
- 一次请求的数据怎么从入口流到模型,再流到工具,再回到模型?
- 哪些地方必须抽象,哪些地方不要过度设计?
这篇给一份完整的 clean-room 最小实现地图。clean-room 的意思是不抄任何现成源码,只凭对外部行为的理解从零重写一份。所以下面的代码并非 Claude Code 源码,讲的是 Agent CLI 这类程序的通用工程形态。
最小实现的目标
我们先做一个能跑通主链路的小系统:
流程最后提到的 transcript 是会话记录:把这轮对话的每条消息按顺序存进文件,之后恢复会话、继续追问都靠它。
暂时不做:
- 复杂 UI。
- 多 Agent。
- 插件市场。
- 高级权限推断。
- 自动修复。
- 远程桥接。
因为这些都建立在主循环之上。主循环不清楚,后面全是堆功能。
文件地图
两个补充,免得你后面对不上号:下面 repl.ts 的代码里会 import { runCommand } from "./commands",文末的映射表里也会出现 commands.ts 和 mcp.ts,但上面这棵树里没有它们,这不是你漏看了哪一节。
commands.ts 负责 slash command 路由(处理 /exit 这类内置命令),mcp.ts 负责接入 MCP 外部工具,它们属于骨架跑通之后第一批要加的文件,这篇为了聚焦主循环,目录树只画了最小集合。
你照着敲的时候,可以先建一个只导出空 runCommand 函数的 commands.ts 占位,或者暂时注释掉 repl.ts 里相关的两行。
依赖方向应该是:
flowchart TD
main["main.ts"] --> repl["repl.ts"]
repl --> qe["queryEngine.ts"]
qe --> Core["核心模块<br/>context / llm / transcript"]
Core --> Tooling["工具模块<br/>tool / permissions / tools/*"]
Tooling --> Ops["运行支撑<br/>cost / errors / renderer"]
注意:
llmClient.ts不依赖工具实现。tools/*不依赖queryEngine.ts。renderer.ts不参与业务逻辑。permissions.ts不调用模型。context.ts不执行工具。
这叫边界干净。跟你在 Spring 项目里坚持 Controller 不写 SQL、DAO 不管 HTTP 响应是一个道理:依赖只朝一个方向流,改一个模块时不用担心牵连别处。
核心类型:先把数据形状定清楚
用 TypeScript 写这套系统,第一步先把数据流里的类型定义清楚,再动手写功能,就像 Java 后端先把 DTO 和实体类定好再写 Service。类型定歪了,后面每个模块都跟着歪。
动手敲之前先打个预防针:这份 types.ts 引用了三个没在本文件里定义的类型。
代码块收起展开
`TokenUsage` 的定义在后面「成本记录」一节的 cost.ts 里;
`JsonSchema` 和 `AgentErrorShape` 全文没有单独给出定义,想让代码跑起来的话你可以先自己补上 `type JsonSchema = Record<string, unknown>;`(真实项目会换成更严格的 JSON Schema 类型库)和 `type AgentErrorShape = { code: AgentErrorCode; message: string; details?: unknown };`,其中 AgentErrorCode 在后面 errors.ts 一节有定义,这个形状和那一节 toErrorShape 的返回值刚好对齐。
另外 sessionId 那行注释里提到 trace/audit/transcript 三个词,先解释清楚再往下读:trace 是一次任务的完整执行轨迹,每次模型调用、每次工具执行都留一条记录,类似后端的调用链日志,出问题靠它复盘;audit 是审计留痕,说白了就是把「谁在什么时候执行了什么敏感操作」记下来备查,相当于后端数据库里的操作日志表;transcript 前面说过,是用来恢复会话的消息记录。
三份记录都需要 sessionId 把同一次会话的条目串起来。
代码块收起展开
// src/types.ts
// Role = 一条 message 在对话协议里的身份。
// system 是规则,user 是用户,assistant 是模型,tool 是工具结果。
export type Role = "system" | "user" | "assistant" | "tool";
// Message = 发给模型的上下文消息。
// Agent 每执行一步,本质上就是往 messages 里追加新事实。
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,用于和 tool result 对应。
name: string; // 工具名,例如 read_file、grep、write_file。
input: unknown; // 工具参数,模型生成的,必须先校验 schema。
sessionId: string;// 当前会话 ID,用于 trace/audit/transcript。
};
// ModelRequest = 调模型时传入的统一请求对象。
export type ModelRequest = {
messages: Message[]; // 当前上下文。
tools: ToolSchema[]; // 暴露给模型的工具 schema。
abortSignal?: AbortSignal;// 用户 Ctrl+C 或超时取消时用。
};
// ModelResponse = 模型返回的两类结果:
// 1. 直接回答文本;2. 请求调用工具。
export type ModelResponse =
| {
type: "text";
text: string; // 模型最终回答或中间文本。
usage?: TokenUsage; // token 用量,用于成本统计。
}
| {
type: "tool_call";
id: string; // 工具调用 ID。
name: string; // 工具名。
input: unknown; // 工具参数。
usage?: TokenUsage; // 这次模型调用的 token 用量。
};
// ToolSchema = 给模型看的工具说明。
// 它决定模型知道有哪些工具、每个工具怎么传参数。
export type ToolSchema = {
name: string; // 工具名,必须稳定。
description: string; // 什么时候用这个工具,直接影响模型选择。
input_schema: JsonSchema; // 参数 JSON Schema,用于模型生成和运行时校验。
};
// ToolResult = 工具执行后返回给 QueryEngine/模型的结果。
export type ToolResult = {
summary: string; // 一句话摘要,例如 "Read 20 lines from README.md"。
content: string; // 详细内容,例如文件内容、diff、命令输出。
metadata?: Record<string, unknown>; // 结构化信息,例如行号、总匹配数、是否截断。
};
// AgentEvent = QueryEngine 对外产出的事件流。
// Renderer、测试、桌面 UI 都可以消费这组事件。
export type AgentEvent =
| { type: "text_delta"; text: string } // 模型输出的一段文本。
| { type: "tool_start"; call: ToolCall } // 工具开始执行,UI 可显示状态。
| { type: "tool_result"; call: ToolCall; result: ToolResult } // 工具执行完成。
| { type: "turn_end"; reason: StopReason } // 本轮结束及原因。
| { type: "error"; error: AgentErrorShape }; // 结构化错误。
// StopReason = 本轮为什么结束。
// 不要只写 done,否则无法区分正常完成、超限、用户取消、模型错误。
export type StopReason =
| "final_answer"
| "max_turns"
| "user_abort"
| "tool_error"
| "model_error";为什么 ModelResponse 要区分 text 和 tool_call
因为 Agent loop 的下一步动作完全不同:
| response | 下一步 |
|---|---|
text | 输出给用户,结束 |
tool_call | 执行工具,把结果回填,继续 |
如果只用一个大对象,例如 { content?: string, tool?: any },后面代码会充满 if (response.tool && !response.content) 这种含糊判断。
错误类型:不要只 throw Error
AI 工程里错误来源很多:
- 模型请求失败。
- 模型输出格式错。
- 工具不存在。
- 参数校验失败。
- 用户拒绝权限。
- 工具执行失败。
- 上下文过长。
- 成本超限。
所以要定义错误分类:
代码块收起展开
// src/errors.ts
// AgentErrorCode = Agent 系统内部统一错误码。
// 分类的目的:让上层知道错误来自模型、工具、权限、上下文还是用户中断。
export type AgentErrorCode =
| "MODEL_AUTH_FAILED"
| "MODEL_RATE_LIMITED"
| "MODEL_TIMEOUT"
| "MODEL_BAD_RESPONSE"
| "TOOL_NOT_FOUND"
| "TOOL_INPUT_INVALID"
| "TOOL_PERMISSION_DENIED"
| "TOOL_EXECUTION_FAILED"
| "CONTEXT_TOO_LARGE"
| "USER_ABORTED";
// AgentError = 带 code 和 details 的业务错误。
// 普通 Error 只有 message,不够做恢复和审计。
export class AgentError extends Error {
constructor(
// 错误分类,例如 TOOL_NOT_FOUND。
public code: AgentErrorCode,
// 给用户/日志看的错误说明。
message: string,
// 附加结构化信息,例如 toolName、input、path。
public details?: unknown
) {
super(message);
this.name = "AgentError";
}
}
// toErrorShape = 把任意 unknown 错误转成可序列化错误对象。
// 事件流和 trace 里不要直接塞 Error 实例,因为它不可稳定 JSON 化。
export function toErrorShape(error: unknown): AgentErrorShape {
// 如果本来就是 AgentError,就保留 code/details。
if (error instanceof AgentError) {
return {
code: error.code,
message: error.message,
details: error.details
};
}
// 非 AgentError 统一归类为工具执行失败。
// 真实系统可以按来源再细分。
return {
code: "TOOL_EXECUTION_FAILED",
message: error instanceof Error ? error.message : String(error)
};
}这一步非常重要。trace 在「核心类型」一节解释过:一次任务的完整执行轨迹记录,每次模型调用、每次工具执行都留一条,类似后端的调用链日志,出问题全靠它复盘。
没有错误分类,trace 里只能看到 “failed”,无法知道是模型、工具、权限还是上下文炸了。
这和后端接口不能全靠一个 500、要定义业务错误码,是同一个道理。
main.ts:程序入口只做分流
代码块收起展开
// src/main.ts
import { startRepl } from "./repl";
import { runOnce } from "./queryEngine";
// main = CLI 进程入口。
// 只负责解析启动模式,不负责 Agent 业务逻辑。
async function main() {
// 去掉 node 和脚本路径,只保留用户传入参数。
const args = process.argv.slice(2);
// --print 表示一次性执行,不进入交互式 REPL。
const printIndex = args.indexOf("--print");
if (printIndex >= 0) {
// --print 后面的内容拼成用户 prompt。
const prompt = args.slice(printIndex + 1).join(" ");
await runOnce(prompt);
return;
}
// 默认进入交互模式。
await startRepl();
}
// 顶层兜底:入口错误要打印并用非 0 退出码结束进程。
main().catch(error => {
console.error(error);
process.exit(1);
});入口不要做这些事:
- 不要直接拼 prompt。
- 不要直接调模型。
- 不要直接执行工具。
- 不要写一堆业务判断。
否则主文件会很快变成垃圾桶。
repl.ts:只负责输入和渲染
REPL 是 Read-Eval-Print Loop 的缩写:读一行输入、执行、打印结果、再等下一行的交互式命令行。MySQL 客户端和 JDK 自带的 jshell 都是这种形态。
代码块收起展开
// src/repl.ts
import readline from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { query } from "./queryEngine";
import { runCommand } from "./commands";
import { renderEvent } from "./renderer";
export async function startRepl() {
// 创建命令行输入输出接口。
const rl = readline.createInterface({
input: stdin,
output: stdout
});
while (true) {
// 读取一行用户输入,并去掉首尾空格。
const input = (await rl.question("> ")).trim();
// 空输入直接跳过。
if (!input) continue;
// /exit 是确定性命令,直接退出循环。
if (input === "/exit") break;
// slash command 由命令系统处理,不进入模型。
if (input.startsWith("/")) {
await runCommand(input);
continue;
}
// 普通自然语言输入进入 QueryEngine,返回事件流。
for await (const event of query(input)) {
// REPL 不理解业务,只把事件交给 renderer。
renderEvent(event);
}
}
// 退出前关闭 readline 资源。
rl.close();
}这里的边界也很重要:
| 事情 | 谁负责 |
|---|---|
| 读用户输入 | REPL |
| 判断 slash command | REPL / Command Router |
| Agent 推理 | QueryEngine |
| 输出格式 | Renderer |
表里的 slash command 指以 / 开头的输入,比如 /exit,属于程序内置命令,REPL 直接拦截、交给 Command Router(命令路由,把不同命令分发给对应处理函数的模块)处理,不会发给模型。
整张表在说一件事:REPL 只做输入分发,推理归 QueryEngine,展示归 Renderer。
queryEngine.ts:最小主循环
代码块收起展开
// src/queryEngine.ts
import { buildContext } from "./context";
import { callModel } from "./llmClient";
import { getToolSchemas, executeToolCall } from "./tool";
import { appendTranscript } from "./transcript";
import { recordUsage } from "./cost";
import { toErrorShape } from "./errors";
import type { AgentEvent, Message } from "./types";
// 最多允许模型-工具循环几轮。
// 防止模型一直调用工具无法结束。
const MAX_TURNS = 8;
// runOnce = 非交互模式入口。
// 它消费 query() 事件流,并把文本/错误直接打印到终端。
export async function runOnce(prompt: string) {
for await (const event of query(prompt)) {
if (event.type === "text_delta") process.stdout.write(event.text);
if (event.type === "error") console.error(event.error.message);
}
}
// query = Agent 主循环。
// 输入用户一句话,输出事件流:文本、工具开始、工具结果、结束、错误。
export async function* query(userInput: string): AsyncGenerator<AgentEvent> {
// buildContext 会创建初始 messages:system prompt + 历史 + 当前用户输入。
const messages: Message[] = await buildContext(userInput);
// transcript 是会话记录,用来恢复对话,不等同于 trace。
await appendTranscript({ role: "user", content: userInput });
try {
// 模型可能多次请求工具,所以这里是循环。
for (let turn = 0; turn < MAX_TURNS; turn++) {
// 调模型时带上当前 messages 和可用工具 schema。
const response = await callModel({
messages,
tools: getToolSchemas()
});
// 记录 token 用量,避免工具循环导致成本失控。
if (response.usage) recordUsage(response.usage);
// 如果模型返回最终文本,本轮结束。
if (response.type === "text") {
// text_delta 让 UI 可以流式渲染。
yield { type: "text_delta", text: response.text };
// 保存助手回答到 transcript,便于后续对话。
await appendTranscript({ role: "assistant", content: response.text });
// 明确停止原因:正常最终回答。
yield { type: "turn_end", reason: "final_answer" };
return;
}
// 如果模型返回 tool_call,先发 tool_start 事件给 UI/trace。
yield {
type: "tool_start",
call: {
id: response.id,
name: response.name,
input: response.input,
sessionId: "current"
}
};
// 执行工具。executeToolCall 内部应做 schema 校验、权限判断、审计。
const toolResult = await executeToolCall({
id: response.id,
name: response.name,
input: response.input,
sessionId: "current"
});
// 工具执行成功后,把结果作为事件发出去。
yield {
type: "tool_result",
call: {
id: response.id,
name: response.name,
input: response.input,
sessionId: "current"
},
result: toolResult
};
// 把“模型请求了哪个工具和参数”追加进 messages。
// 真实 provider 可能有专门的 tool_call message 格式,这里用 JSON 简化表达。
messages.push({
role: "assistant",
content: JSON.stringify({
tool_call: response.name,
input: response.input
})
});
// 把工具结果追加为 tool message。
// 关键:如果不回填,模型下一轮不知道工具看到了什么。
messages.push({
role: "tool",
toolCallId: response.id,
content: toolResult.content
});
}
// 循环超过 MAX_TURNS 仍没最终回答,就强制结束。
yield { type: "turn_end", reason: "max_turns" };
} catch (error) {
// 任何异常都转成结构化错误事件,交给 renderer/UI 处理。
yield { type: "error", error: toErrorShape(error) };
// 教学版统一标成 tool_error;真实系统可按来源分 model_error/tool_error。
yield { type: "turn_end", reason: "tool_error" };
}
}这段代码已经比“伪代码”更像一个真正的 Agent 内核:轮数上限、成本记录、工具结果回填、错误转事件,这几样真实系统必备的东西都在了。
这段主循环的隐藏约束
主循环必须满足几个 invariant。invariant(不变式)指不管循环跑到第几轮、走哪条分支,都必须始终成立的条件,和你写并发代码时“锁保护的共享变量任何时刻都要一致”是同类约束。下表左边是必须成立的条件,右边是它存在的理由:
| invariant | 为什么 |
|---|---|
| 每次模型调用都带当前 messages | 模型靠上下文推理 |
| 工具调用前必须校验权限 | 安全边界 |
| 工具结果必须回填 messages | 模型需要观察结果 |
| 每轮必须有退出条件 | 防止无限循环 |
| 错误必须转成事件 | UI / 日志才能处理 |
| usage 必须记录 | 成本可控 |
读复杂源码时,你就找这些 invariant 在哪里被保证。
Renderer:输出层不要污染主循环
代码块收起展开
// src/renderer.ts
import type { AgentEvent } from "./types";
// renderEvent = 终端渲染器。
// 它只关心“怎么显示事件”,不参与 Agent 决策。
export function renderEvent(event: AgentEvent) {
switch (event.type) {
case "text_delta":
// 文本增量直接写 stdout,不额外换行,保留流式体验。
process.stdout.write(event.text);
break;
case "tool_start":
// 工具开始时给用户一个状态提示。
console.log(`\n[tool:start] ${event.call.name}`);
break;
case "tool_result":
// 工具结束时显示摘要,不直接倾倒所有 content。
console.log(`[tool:done] ${event.result.summary}`);
break;
case "turn_end":
// 本轮结束原因。
console.log(`\n[turn:end] ${event.reason}`);
break;
case "error":
// 结构化错误输出,带错误码。
console.error(`[error:${event.error.code}] ${event.error.message}`);
break;
}
}为什么不直接在 queryEngine 里打印?
因为未来可能有:
- Terminal renderer:现在这个终端版。
- SSE renderer:SSE 即 Server-Sent Events,服务端通过一条 HTTP 长连接单向往浏览器推消息,网页版 UI 做流式输出常用它。
- JSONL renderer:每行输出一个独立 JSON 对象,方便其他程序逐行解析。
- Desktop renderer:桌面客户端界面。
- Test renderer:测试时把事件收集起来做断言,不真的打印。
主循环产出事件,渲染器决定怎么显示,这是更稳的边界。思路和 Java 日志框架一样:业务代码只管调 log.info,最终输出到控制台还是文件由 appender 配置决定,业务代码一行不用改。
成本记录
代码块收起展开
// src/cost.ts
import { appendFile } from "node:fs/promises";
// TokenUsage = 一次模型调用的 token 和成本统计。
export type TokenUsage = {
model: string; // 使用的模型名。
inputTokens: number; // 输入 token。
outputTokens: number; // 输出 token。
totalTokens: number; // 总 token。
costUsd?: number; // 可选:折算成本。
};
// recordUsage = 追加一条成本记录。
// 使用 JSONL 是因为模型调用是一条一条发生的,追加写最简单。
export async function recordUsage(usage: TokenUsage) {
await appendFile(
".agent/cost.jsonl",
JSON.stringify({
// 展开 usage 字段。
...usage,
// 补充记录时间。
time: new Date().toISOString()
}) + "\n"
);
}这里的 token 是模型处理文本的计量单位,可以粗略理解为把文本切成的小片,一个汉字大约对应 1 到 2 个 token,模型 API 按输入加输出的 token 数收费。
不要觉得 cost 是后期才做。Agent 一旦有工具循环,一次用户任务可能连续调用五六次模型,而且每次都带着越滚越长的 messages,token 消耗是滚雪球式的,不记录成本很快会失控。
最小实现的运行时序
- main 发现不是
--print,启动 REPL - REPL 读取用户输入
- 输入不是 slash command,调用
query() - query 构建 messages
- query 调
callModel(messages, tools) - 模型返回
tool_call: read_file - query 发出
tool_start事件 executeToolCall校验参数和权限- 工具读取文件,返回
ToolResult - query 发出
tool_result事件 - query 把 tool result 追加进 messages
- query 再次
callModel - 模型返回 text
- query 保存 transcript
- renderer 输出最终答案
这 15 步就是源码级理解的基本单位:以后读任何 Agent CLI 的源码,本质上都是在几千个文件里找这 15 步分别写在哪。
为什么它不像普通后端
普通后端一般是:
request → service → db → response
Agent CLI 是:
request → model → tool → model → tool → model → response
这里最关键的差异是“模型在中间做决策”。普通后端里每一步走哪个分支是你代码写死的;这里下一步调什么工具、传什么参数是模型现场生成的,输出带随机性,你没法像信任自己写的 Service 那样信任它。所以必须有:
- 工具白名单:模型只能调注册过的工具,调不存在的直接报 TOOL_NOT_FOUND。
- 权限门禁:写文件、执行命令这类高风险动作先过权限检查。
- 最大步数:就是上面的 MAX_TURNS,防止模型无限循环调工具。
- 上下文预算:messages 会越滚越长,要设总量上限,超了就压缩或截断。
- trace:每一步留痕,出事能复盘。
- 成本:token 花销有记录、能设上限。
这些防线缺一个,都等于把执行权直接交给一个输出带随机性的文本生成器。
读大型源码时怎么映射
你看到一个复杂仓库时,不要被几千个文件吓住。先映射到这个最小模型,下表左边是大型仓库里常见的模块名,右边是本文最小实现里对应的文件:
| 大型源码模块 | 最小实现对应 |
|---|---|
main.tsx | main.ts |
query.ts / QueryEngine.ts | queryEngine.ts |
Tool.ts / tools/ | tool.ts + tools/* |
commands/ | commands.ts |
context.ts / history.ts | context.ts + transcript.ts |
cost-tracker.ts | cost.ts |
cli/transports | renderer.ts / SSE renderer |
mcp handler | mcp.ts |
注意表里的 commands.ts 和 mcp.ts 在本文的最小目录树里没有,这不是漏写:它们是最小骨架之外的第一批扩展文件(命令路由和 MCP 接入,文件地图一节说过),放进映射表是为了让你在大型仓库里见到 commands/ 目录或 mcp handler 时,知道该往哪个位置归类。
先把大型源码压缩成这张表,再去看细节。
最小实现验收门槛
这一篇的价值在于把复杂系统压成可运行骨架。验收时别停留在“目录结构长得像”,要看骨架能不能完整跑通一次真实的 Agent turn(一个 turn 指从用户输入到模型给出最终回答的完整一轮,中间可能夹好几次工具调用)。下表每行一个验收项:中间列是合格时你能拿出来的证据,右列是常见的糊弄写法。
| 验收项 | 必须看到的工程证据 | 不合格表现 |
|---|---|---|
| 可启动 | main 能进入交互或单次执行模式 | 只有类型定义,没有运行入口 |
| 可替换模型 | model adapter(模型调用的适配层)是接口,不把某家供应商的 SDK 写死进主循环 | 换模型要改 queryEngine |
| 可注册工具 | tool registry 能暴露每个工具的 name、参数 schema、执行函数 handler、风险等级 risk | 工具是 if/else 写死 |
| 可执行闭环 | model 返回 tool call 后,executor 执行并把 result 回填 | 工具结果只打印,不进下一轮模型 |
| 可失败 | 权限拒绝、工具异常、max turns 都有明确 stop reason | 出错直接进程崩溃 |
| 可复盘 | transcript/trace 至少能还原输入、工具、输出、错误 | 只能看最后一句回答 |
最小端到端样例:实现 read_file、grep、shell_echo 三个低风险工具,再跑一次“找到 README 中某个词并解释”的任务。合格的标准看 trace:你能从里面复原出模型为什么选这个工具、传了什么参数、工具返回了什么、最终回答引用了哪段证据。回答内容漂不漂亮反而次要。