Agent CLI深水区 - 完整最小实现地图 · AI Agent

Agent CLI 深水区 01:完整最小实现地图

这篇解决什么

前面的 06-10 讲的是骨架:入口、主循环、工具、上下文、MCP。骨架知道以后,还差一层“代码落地感”:

  • 一个最小 Agent CLI 到底需要哪些类型?
  • 文件之间怎么依赖?
  • 一次请求的数据怎么从入口流到模型,再流到工具,再回到模型?
  • 哪些地方必须抽象,哪些地方不要过度设计?

这篇给一份完整的 clean-room 最小实现地图。它不是 Claude Code 源码,只是用 Agent CLI 的通用工程形态来讲。

最小实现的目标

我们先做一个能跑通主链路的小系统:

代码块TEXT · 8 行收起展开
用户输入
-> 构建 messages
-> 调模型
-> 如果模型请求工具,就执行工具
-> 工具结果回填
-> 模型生成最终回答
-> 保存 transcript
-> 输出事件

暂时不做:

  • 复杂 UI。
  • 多 Agent。
  • 插件市场。
  • 高级权限推断。
  • 自动修复。
  • 远程桥接。

因为这些都建立在主循环之上。主循环不清楚,后面全是堆功能。

文件地图

代码块TEXT · 19 行收起展开
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 里最重要的不是一开始写功能,而是先把数据流里的类型定义清楚。

代码块TS · 87 行收起展开
// 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 要区分 texttool_call

因为 Agent loop 的下一步动作完全不同:

response下一步
text输出给用户,结束
tool_call执行工具,把结果回填,继续

如果只用一个大对象,例如 { content?: string, tool?: any },后面代码会充满 if (response.tool && !response.content) 这种含糊判断。

错误类型:不要只 throw Error

AI 工程里错误来源很多:

  • 模型请求失败。
  • 模型输出格式错。
  • 工具不存在。
  • 参数校验失败。
  • 用户拒绝权限。
  • 工具执行失败。
  • 上下文过长。
  • 成本超限。

所以要定义错误分类:

代码块TS · 50 行收起展开
// 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:程序入口只做分流

代码块TS · 28 行收起展开
// 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:只负责输入和渲染

代码块TS · 40 行收起展开
// 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 commandREPL / Command Router
Agent 推理QueryEngine
输出格式Renderer

queryEngine.ts:最小主循环

代码块TS · 116 行收起展开
// 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:输出层不要污染主循环

代码块TS · 33 行收起展开
// 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。

主循环产出事件,渲染器决定怎么显示,这是更稳的边界。

成本记录

代码块TS · 25 行收起展开
// 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 一旦有工具循环,一次用户任务可能调用多次模型,不记录成本很快会失控。

最小实现的运行时序

代码块TEXT · 15 行收起展开
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 输出最终答案

这就是源码级理解的基本单位。

为什么它不像普通后端

普通后端一般是:

代码块TEXT · 1 行收起展开
request -> service -> db -> response

Agent CLI 是:

代码块TEXT · 1 行收起展开
request -> model -> tool -> model -> tool -> model -> response

这里最关键的差异是“模型在中间做决策”。所以必须有:

  • 工具白名单。
  • 权限门禁。
  • 最大步数。
  • 上下文预算。
  • trace。
  • 成本。

否则就不是工程,是把执行权交给随机文本生成器。

读大型源码时怎么映射

你看到一个复杂仓库时,不要被几千个文件吓住。先映射到这个最小模型:

大型源码模块最小实现对应
main.tsxmain.ts
query.ts / QueryEngine.tsqueryEngine.ts
Tool.ts / tools/tool.ts + tools/*
commands/commands.ts
context.ts / history.tscontext.ts + transcript.ts
cost-tracker.tscost.ts
cli/transportsrenderer.ts / SSE renderer
mcp handlermcp.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_filegrepshell_echo 三个低风险工具,再跑一次“找到 README 中某个词并解释”的任务。合格结果不是回答内容漂亮,而是你能从 trace 里复原:模型为什么选工具、传了什么参数、工具返回什么、最终回答引用了哪段证据。