Agent CLI深水区 - 完整最小实现地图 · AI Agent
Agent CLI 深水区 01:完整最小实现地图
这篇解决什么
前面的 06-10 讲的是骨架:入口、主循环、工具、上下文、MCP。骨架知道以后,还差一层“代码落地感”:
- 一个最小 Agent CLI 到底需要哪些类型?
- 文件之间怎么依赖?
- 一次请求的数据怎么从入口流到模型,再流到工具,再回到模型?
- 哪些地方必须抽象,哪些地方不要过度设计?
这篇给一份完整的 clean-room 最小实现地图。它不是 Claude Code 源码,只是用 Agent CLI 的通用工程形态来讲。
最小实现的目标
我们先做一个能跑通主链路的小系统:
代码块收起展开
用户输入
-> 构建 messages
-> 调模型
-> 如果模型请求工具,就执行工具
-> 工具结果回填
-> 模型生成最终回答
-> 保存 transcript
-> 输出事件暂时不做:
- 复杂 UI。
- 多 Agent。
- 插件市场。
- 高级权限推断。
- 自动修复。
- 远程桥接。
因为这些都建立在主循环之上。主循环不清楚,后面全是堆功能。
文件地图
代码块收起展开
src/
├── main.ts
├── repl.ts
├── types.ts
├── queryEngine.ts
├── llmClient.ts
├── context.ts
├── tool.ts
├── permissions.ts
├── transcript.ts
├── cost.ts
├── errors.ts
├── renderer.ts
└── tools/
├── index.ts
├── readFile.ts
├── grep.ts
├── writeFile.ts
└── bash.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不执行工具。
这叫边界干净。
核心类型:先把数据形状定清楚
TypeScript 里最重要的不是一开始写功能,而是先把数据流里的类型定义清楚。
代码块收起展开
// 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 里只能看到 “failed”,无法知道是模型、工具、权限还是上下文炸了。
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:只负责输入和渲染
代码块收起展开
// 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 |
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 | 为什么 |
|---|---|
| 每次模型调用都带当前 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。
- JSONL renderer。
- Desktop renderer。
- Test renderer。
主循环产出事件,渲染器决定怎么显示,这是更稳的边界。
成本记录
代码块收起展开
// 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"
);
}不要觉得 cost 是后期才做。Agent 一旦有工具循环,一次用户任务可能调用多次模型,不记录成本很快会失控。
最小实现的运行时序
代码块收起展开
1. main 发现不是 --print,启动 REPL
2. REPL 读取用户输入
3. 输入不是 slash command,调用 query()
4. query 构建 messages
5. query 调 callModel(messages, tools)
6. 模型返回 tool_call: read_file
7. query 发出 tool_start 事件
8. executeToolCall 校验参数和权限
9. 工具读取文件,返回 ToolResult
10. query 发出 tool_result 事件
11. query 把 tool result 追加进 messages
12. query 再次 callModel
13. 模型返回 text
14. query 保存 transcript
15. renderer 输出最终答案这就是源码级理解的基本单位。
为什么它不像普通后端
普通后端一般是:
代码块收起展开
request -> service -> db -> responseAgent CLI 是:
代码块收起展开
request -> model -> tool -> model -> tool -> model -> response这里最关键的差异是“模型在中间做决策”。所以必须有:
- 工具白名单。
- 权限门禁。
- 最大步数。
- 上下文预算。
- trace。
- 成本。
否则就不是工程,是把执行权交给随机文本生成器。
读大型源码时怎么映射
你看到一个复杂仓库时,不要被几千个文件吓住。先映射到这个最小模型:
| 大型源码模块 | 最小实现对应 |
|---|---|
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 |
先把大型源码压缩成这张表,再去看细节。
最小实现验收门槛
这一篇的价值在于把复杂系统压成可运行骨架。验收时不要看目录是否“像”,要看骨架能不能承载一次真实 Agent 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 里复原:模型为什么选工具、传了什么参数、工具返回什么、最终回答引用了哪段证据。