Agent CLI深水区 - 从输入到落盘全链路复盘

Agent CLI 深水区 11:从输入到代码落盘全链路复盘

前面几篇分别拆了主循环、工具、上下文、MCP(Model Context Protocol,让 Agent 接入外部工具和数据源的标准协议,可以类比成后端里统一的 RPC 接口规范)、模型适配、编辑、搜索、插件和评测。
这一篇不按模块讲,改成沿着一次真实任务的执行链,把之前的模块串起来过一遍。

目标:看完后能在脑子里跑通一个 Agent CLI。

一次任务到底发生了什么

用户输入:

帮我把默认模型配置改成 claude-sonnet-4-6,更新相关测试,跑一下验证。

一个成熟 Agent CLI 大致会经历:

Agent CLI 全链路 主路径、工具回路、编辑验证回路分层展示。 输入 路由 上下文 模型流 最终回答 命令处理 工具执行 权限门控 结果回填 文件编辑 Diff/审计 测试验证 Trace/Memory tool call slash edit path

这张图里的每一条边都很重要。
Agent CLI 真正难做的地方在于模块协同:单看每个模块的算法都不难,难的是路由、上下文、工具、权限、编辑、验证串在一起之后,任何一环出错,整条链还能稳住并恢复。

核心对象地图

先给一组最小对象,后面所有链路都围绕它们转。

代码块TS · 47 行收起展开
// Session = 一次 CLI 会话的总上下文。
// 它不是“一轮对话”,而是从打开 CLI 到退出 CLI 的整个工作区状态。
type Session = {
  id: string;                  // 会话唯一 ID,用来关联 trace、日志、成本统计、历史消息。
  cwd: string;                 // 当前工作目录。所有文件工具默认只能在这个 workspace 内工作。
  startedAt: string;           // 会话开始时间,方便审计和恢复。
  messages: Message[];         // 已发生的对话消息,包括 system/user/assistant/tool。
  memory: MemoryState;         // 长期/项目记忆,例如用户偏好、项目结构、常用命令。
  budgets: BudgetState;        // token、上下文长度、成本等预算控制。
  permissions: PermissionState;// 当前会话已批准/拒绝的工具权限。
};

// Turn = 用户输入一次后的处理单元。
// 你可以把它理解成 Java 后端里的“一次请求 request”。
type Turn = {
  id: string;                         // 本轮请求 ID。
  sessionId: string;                  // 所属 Session,方便把一次请求挂回会话。
  userInput: string;                  // 用户这一轮的原始目标。
  selectedSkills: Skill[];            // 本轮命中的技能/规程,例如 code-review、debugging。
  contextPackage: ContextPackage;     // 搜索、Git、记忆整理出的证据包。
  trace: TraceSpan[];                 // 本轮内部链路追踪:搜索、模型、工具、测试等。
};

// Message = 发给模型或由模型/工具返回的消息。
// Agent 主循环本质上就是不断追加 Message,再重新调用模型。
type Message =
  | { role: "system"; content: string }                              // 系统规则、工具说明、技能规则。
  | { role: "user"; content: string }                                // 用户输入。
  | { role: "assistant"; content: string; toolCalls?: ToolCall[] }    // 模型文本和它想调用的工具。
  | { role: "tool"; toolCallId: string; content: string; result: ToolResult }; // 工具执行结果。

// ToolCall = 模型发起的一次工具调用请求。
// 注意 input 是 unknown,必须经过 schema 校验后才能执行。
type ToolCall = {
  id: string;       // 工具调用 ID,用来把 ToolResult 对回这次调用。
  name: string;     // 工具名,例如 read_file、edit_file、shell。
  input: unknown;   // 模型生成的参数,运行前必须 validateSchema。
};

// ToolResult = 工具执行后的标准返回。
// 它既给用户看,也会回灌给模型作为下一轮事实。
type ToolResult = {
  ok: boolean;                         // 是否成功。失败时也要返回可恢复信息。
  summary: string;                     // 一句话摘要,给模型快速理解结果。
  content?: string;                    // 详细内容,例如文件片段、diff、命令输出。
  metadata?: Record<string, unknown>;  // 结构化信息,例如行号、hash、耗时、返回数量。
};

可以把它理解成后端里的请求上下文:

  • Session 像用户会话。
  • Turn 像一次请求。
  • Message 像请求链上的事件记录。
  • ToolCall 像服务调用。
  • TraceSpan 像链路追踪。

Step 0:启动阶段

CLI 启动时,不应该立刻进模型。它要先初始化运行时。

CLI 启动装配链路 Start Config Workspace Tools Commands Skills MCP Memory Ready

最小启动代码:

代码块TS · 40 行收起展开
// bootstrap = CLI 启动入口。
// 职责:把配置、工作区、工具、命令、技能、MCP、记忆都装配成 runtime。
async function bootstrap(argv: string[]): Promise<AppRuntime> {
  // 读取命令行参数、配置文件、环境变量,得到模型、MCP、权限等设置。
  const config = await loadConfig(argv);

  // 判断当前项目根目录。后续 read/edit/shell 都围绕 cwd 做边界限制。
  const cwd = await resolveWorkspace(argv, process.cwd());

  // 加载权限状态,例如本轮是否允许 shell、是否允许写文件。
  const permissions = await loadPermissionState(config);

  // 注册内置工具,例如 read_file、grep、edit_file、shell。
  const tools = await createBuiltinTools({ cwd, permissions });

  // 注册确定性命令,例如 /help、/cost、/mcp list。
  const commands = await createBuiltinCommands();

  // 这里只加载 skill 元数据或正文,后面会按任务选择是否注入。
  const skills = await loadSkills(config.skillDirs);

  // 启动配置好的 MCP server,并把它们暴露的 tools/resources 接进来。
  const mcp = await startConfiguredMcpServers(config.mcpServers);

  // 恢复历史记忆,例如项目结构、用户偏好、常用测试命令。
  const memory = await loadSessionMemory(config.sessionId);

  return {
    config,
    cwd,
    permissions,
    // 把内置工具和 MCP 工具合并到统一 registry。
    // 后续模型只看到一个工具列表,不关心工具来自内置还是 MCP。
    tools: mergeToolRegistries(tools, mcp.tools),
    commands,
    skills,
    mcp,
    memory
  };
}

启动阶段最常见的问题、后果和对应防线,见下表:

问题后果防线
配置读取失败但吞掉后面模型行为异常fail fast + 明确错误
MCP server 启动卡住CLI 一直无响应timeout + lazy start
skill 全量注入上下文爆炸只加载元数据,命中后再读正文
工具注册重名模型调用歧义禁止重名或显式 override
cwd 判断错误读写错项目workspace detect + 用户可见

解释一下防线那列的两个说法。fail fast 指配置一有问题就立刻报错退出,别带着坏配置继续跑,等模型行为异常了再排查就晚了。lazy start 指 MCP server 先注册不真正启动,第一次用到时再连接,避免启动阶段被某个卡死的 server 拖住整个 CLI。

Step 1:输入路由

用户输入先过 Input Router。这里的 slash command 指以 / 开头的内置命令(/help、/cost 这类),程序自己就能确定性地执行,不需要模型参与。

代码块TS · 14 行收起展开
// handleInput = 用户输入总入口。
// 先判断是不是 slash command;不是命令才进入模型主循环。
async function handleInput(raw: string, runtime: AppRuntime) {
  // 标准化输入:去掉多余空白、处理多行、统一换行等。
  const input = normalizeInput(raw);

  // /help、/cost、/mcp list 这类命令由程序直接执行,不交给模型。
  if (isSlashCommand(input)) {
    return runCommand(input, runtime);
  }

  // 普通自然语言任务进入 Agent Turn。
  return runAgentTurn(input, runtime);
}

为什么这一步重要?

  • /help/cost/mcp list 不该消耗模型。
  • /clear 这种会话控制不能让模型猜。
  • 普通自然语言任务才进入 Agent 主循环。

输入标准化要处理:

  • 前后空白。
  • 多行输入。
  • pasted code block(用户粘贴进来的大段代码)。
  • Windows 路径。
  • 用户提到的文件链接。
  • 图片或附件引用。

Step 2:构建 Turn

进入 Agent 主循环后,先构建一个 Turn。

代码块TS · 30 行收起展开
// buildTurn = 把用户一句话加工成一次可执行的 Agent 请求。
// 它会选技能、读 Git 状态、取记忆、检索代码上下文。
async function buildTurn(input: string, runtime: AppRuntime): Promise<Turn> {
  // 根据用户输入选择相关 skill,例如 review/debugging/TDD。
  const selectedSkills = await selectSkills(runtime.skills, input);

  // 读取当前 Git 状态,防止覆盖用户未提交或正在修改的文件。
  const git = await readGitContext(runtime.cwd);

  // 从长期记忆里挑与当前任务相关的部分,而不是全量塞入上下文。
  const memories = await selectRelevantMemory(runtime.memory, input);

  // 搜索项目文件、测试、错误日志,整理成模型能用的证据包。
  const contextPackage = await retrieveContext({
    input,
    cwd: runtime.cwd,
    git,
    selectedSkills,
    memories
  });

  return {
    id: newId("turn"),              // 本轮唯一 ID。
    sessionId: runtime.session.id,  // 归属哪个会话。
    userInput: input,               // 保留原始目标,最终总结和 trace 都会用到。
    selectedSkills,                 // 本轮注入给模型的规程。
    contextPackage,                 // 本轮已知事实。
    trace: []                       // 后续搜索、模型调用、工具执行会逐步追加。
  };
}

这一步的目标是把“用户的一句话”变成“模型可以行动的请求”。

Turn Builder 要汇总的信息不止用户那句话。下表左列是输入来源,右列是它解决什么问题:

输入用途
用户原话任务目标
Git status避免覆盖用户改动
Skill当前任务流程规则
Memory项目历史和长期偏好
Search context真实代码事实
Budget控制 token 和成本

表里的 token 是模型处理文本的基本单位,一个汉字大约对应 1 到 2 个 token。模型的上下文窗口大小和 API 计费都按 token 算,所以往上下文里塞多少东西必须有预算控制,超了轻则费钱,重则请求直接被拒。

Step 3:上下文检索

上下文检索不是一次性完成的。模型可能边做边搜。

第一轮检索要解决三个问题:

  1. 这个任务大概属于哪类?
  2. 最可能相关的文件有哪些?
  3. 当前上下文够不够让模型做第一步?
代码块TS · 28 行收起展开
// retrieveContext = 上下文检索调度器。
// 它不是简单 grep,而是“分类 -> 召回候选 -> 排序 -> 按预算读取”。
async function retrieveContext(req: RetrievalRequest): Promise<ContextPackage> {
  // 判断任务类型:bugfix、review、feature、config 等。
  // 类型不同,优先搜索的东西不同。
  const taskKind = classifyTask(req.input);

  // 召回候选文件:来自 grep、glob、Git changed files、错误栈、测试名等。
  const candidates = await generateCandidates(taskKind, req);

  // 按相关性排序。比如错误栈命中文件 > 普通关键词命中文件。
  const ranked = rankCandidates(candidates, req);

  // 按 token/文件数量预算读取片段,不把所有候选文件都塞给模型。
  const slices = await readBudgetedSlices(ranked, req.budget);

  return {
    task: req.input, // 原始任务。
    workspace: {
      cwd: req.cwd,                        // 当前项目根目录。
      branch: req.git.branch,              // 当前分支。
      changedFiles: req.git.changedFiles   // 已修改文件,模型需要优先尊重。
    },
    evidence: slices.map(toEvidenceBlock),       // 读到的关键证据。
    openQuestions: inferOpenQuestions(slices, req), // 上下文仍不足时要提醒模型。
    omitted: summarizeOmitted(ranked, slices)    // 因预算没读的候选,方便后续继续查。
  };
}

如果用户说“更新默认模型配置”,第一轮可能搜索:

  • defaultModel
  • model
  • sonnet
  • config
  • settings
  • 测试里对默认值的断言

检索结果不只是文本,还要带理由:

代码块PLAINTEXT · 5 行收起展开
Evidence E1: src/config/modelConfig.ts:12-35
Reason: contains defaultModel and current value "claude-sonnet-4"

Evidence E2: test/config/modelConfig.test.ts:8-24
Reason: asserts default model value

Step 4:构建模型请求

模型请求不是把所有东西拼起来这么简单。它要分层。

代码块TS · 28 行收起展开
// buildModelMessages = 把本轮信息拼成模型 API 所需 messages。
// 顺序很重要:系统规则 -> 技能 -> 项目上下文 -> 历史 -> 当前用户任务。
function buildModelMessages(turn: Turn, runtime: AppRuntime): Message[] {
  return [
    {
      role: "system",
      // 基础规则:模型身份、工具使用规范、安全边界、输出格式等。
      content: renderSystemPrompt(runtime.config, runtime.tools)
    },
    {
      role: "system",
      // 本轮命中的 skill,例如 code-review 的具体流程。
      content: renderSelectedSkills(turn.selectedSkills)
    },
    {
      role: "system",
      // 检索到的代码证据、Git 状态、相关记忆。
      content: renderWorkspaceContext(turn.contextPackage)
    },
    // 历史对话不能无限增长,要由预算系统选择/压缩。
    ...selectConversationHistory(runtime.session, runtime.budgets),
    {
      role: "user",
      // 用户最后一句必须放清楚,避免模型被历史任务带偏。
      content: turn.userInput
    }
  ];
}

分层原因:

  • 工具说明和行为规则属于系统层。
  • 搜索证据属于上下文层。
  • 历史对话需要被预算压缩。
  • 用户最后一句必须清晰可见。

拼 messages 时的常见错误和代价:

错误后果
把工具 schema 写在 user message模型不稳定
把历史全部塞进去预算爆炸
证据无路径行号后续编辑困难
skill 注入太多模型规则互相冲突
不写权限规则模型会尝试危险工具

第一行的工具 schema 指工具参数的格式定义(有哪些字段、各是什么类型),作用类似 Java 里 Bean Validation 那套注解规则。它属于系统层的稳定配置,混进 user message 会让模型时而遵守时而无视。

Step 5:模型流式输出

模型输出可能同时包含自然语言和工具调用。而且模型 API 的响应是流式的:服务端通过 SSE(Server-Sent Events,一种 HTTP 长连接机制,服务器单向、分片地往客户端推数据)这类机制,把内容一小段一小段吐出来,每一小段叫一个 delta(增量),客户端要边收边拼。
不同 provider(模型服务商,Anthropic、OpenAI 这些)的流式格式细节还各不相同。

sequenceDiagram
    participant Q as QueryEngine
    participant M as ModelAdapter
    participant P as StreamParser
    participant UI as Renderer

    Q->>M: messages + tools
    M-->>P: delta chunks
    P-->>UI: text delta
    P-->>Q: partial tool call
    P-->>Q: completed tool call

流式解析器必须处理:

  • 文本 delta。
  • tool call name 分片。
  • tool input JSON 分片。
  • 多个 tool call。
  • provider-specific event。
  • 中途断流。
  • JSON 半截。

不要把 provider 的原始事件结构泄漏到 QueryEngine,否则以后每接一家服务商都要改一遍主循环。
QueryEngine 应该只看统一事件:

代码块TS · 13 行收起展开
// ModelEvent = 统一后的模型流事件。
// 不同 provider 的原始 SSE/stream 格式不同,但 QueryEngine 只处理这种统一事件。
type ModelEvent =
  | { type: "text_delta"; text: string } // 模型普通文本的一小段。
  | {
      type: "tool_call_delta";
      id: string;              // 正在拼接的工具调用 ID。
      name?: string;           // 工具名可能分片到达,所以可选。
      inputChunk?: string;     // 工具参数 JSON 的一段文本。
    }
  | { type: "tool_call_done"; call: ToolCall } // 工具名和参数都拼完整了。
  | { type: "message_done"; usage: TokenUsage } // 一次模型响应结束,带 token 用量。
  | { type: "error"; error: ModelError };        // 模型侧错误,例如超时/限流/格式错误。

Step 6:工具调用

模型提出工具调用后,Tool Executor 接管。

代码块TS · 34 行收起展开
// executeToolCall = 工具执行总入口。
// 任何工具,不管来自内置、插件还是 MCP,都应该经过这里。
async function executeToolCall(call: ToolCall, runtime: AppRuntime): Promise<ToolResult> {
  // 1. 先按名称找到工具定义。
  const tool = runtime.tools.get(call.name);
  if (!tool) {
    // 工具不存在也要返回给模型,让模型能改用正确工具。
    return toolError("TOOL_NOT_FOUND", `Unknown tool: ${call.name}`);
  }

  // 2. 校验模型给的参数。模型输出不可信,不能直接执行。
  const input = validateSchema(tool.inputSchema, call.input);

  // 3. 根据工具类型和参数评估风险,例如写文件、删文件、执行 shell 都更危险。
  const risk = assessToolRisk(tool, input, runtime);

  // 4. 需要审批的操作在这里拦住。权限不应该靠 prompt 约束。
  await runtime.permissions.requireApproval({
    tool: tool.name,
    input,
    risk
  });

  // 5. 真正执行工具,并记录耗时。
  const startedAt = Date.now();
  try {
    // createToolContext 会把 cwd、权限、trace、日志等运行时能力传给工具。
    const result = await tool.run(input, createToolContext(runtime));
    return attachToolMetadata(result, { durationMs: Date.now() - startedAt });
  } catch (error) {
    // 把各种异常统一成 ToolResult,方便模型下一步恢复。
    return normalizeToolError(error);
  }
}

工具调用的硬边界如下表。这些全部由运行时代码强制执行,思路和 Web 后端把鉴权、参数校验放在过滤器/拦截器里一样,靠代码兜底,不指望模型自觉:

边界说明
schema validation模型输入必须校验
path resolution文件路径必须限制 workspace
permission gate副作用工具必须审批
timeout工具不能无限挂起
audit高危动作必须记录
result limit输出不能撑爆上下文
error normalization错误要可恢复

Step 7:编辑文件

如果工具是 edit_fileapply_patch,会进入编辑事务。这里的事务取的就是数据库事务那层意思:要么校验通过、完整写入并留下 diff 证据,要么什么都不改,绝不允许写一半。

编辑事务:先验证,再落盘 edit call 路径解析 读旧文件 hash 检查 内存编辑 生成 Diff 权限预览 原子写入 读回确认 返回证据 拒绝则不落盘

编辑事务返回:

代码块TS · 14 行收起展开
// EditResult = 编辑工具成功后的结构化返回。
// 注意这里不只返回 ok,还要返回 diff、hash、验证信息。
type EditResult = {
  ok: true;              // 本次编辑成功。
  path: string;          // 被修改的文件路径。
  diff: string;          // 程序生成的 diff,给用户审查,也给模型继续推理。
  oldHash: string;       // 写入前文件 hash,用于审计和 stale 检查。
  newHash: string;       // 写入后文件 hash,用于确认落盘版本。
  changedLines: number;  // 改动行数,可用于风险评分。
  verification: {
    readBack: true;              // 写入后是否重新读回文件。
    expectedTextFound: boolean;  // 新内容是否确实出现在文件里。
  };
};

这里的 diff 既给用户看,也给模型看。
模型下一步要根据 diff 决定是否改测试、跑格式化、跑验证。

Step 8:测试与验证

模型改完文件后,理想情况下会调用 shell/test 工具。
但 CLI 也可以给模型提示:

代码块PLAINTEXT · 2 行收起展开
After editing code, run the smallest relevant verification command.
If no test command is known, inspect package scripts or README first.

验证命令不是越大越好:

场景优先验证
改一个函数相关单测
改配置默认值配置测试 + 启动校验
改类型typecheck
改格式formatter / lint
改跨模块行为子包测试
不知道命令读 README / package scripts

表里的 typecheck 指只跑类型检查器(比如 TypeScript 的 tsc),不执行代码,几秒钟就能确认类型没被改坏,类似 Java 里只做编译不跑测试。

验证结果要结构化:

代码块TS · 9 行收起展开
// VerificationResult = 一次验证命令的结构化结果。
// 不要只保存一大坨命令输出,否则模型很难判断下一步。
type VerificationResult = {
  command: string;          // 实际运行的命令,例如 npm test -- modelConfig。
  exitCode: number;         // 进程退出码,0 通常代表成功。
  durationMs: number;       // 耗时,用于判断卡住或性能问题。
  passed: boolean;          // 对 exitCode 的业务化判断。
  importantOutput: string;  // 截断后的关键输出:失败断言、错误栈、通过摘要。
};

如果测试失败,Agent 不应该直接说完成。
它应该把失败日志加入下一轮上下文,继续修或明确说明阻塞。

Step 9:总结响应

最终响应要能回答三个问题,只回一句“好了”属于不合格:

  1. 改了什么?
  2. 怎么验证的?
  3. 是否还有风险?

示例:

代码块PLAINTEXT · 3 行收起展开
已把默认模型配置更新为 claude-sonnet-4-6,并同步更新对应测试断言。
验证:npm test -- modelConfig 通过。
注意:没有跑全量测试,因为本次只触及配置默认值和相关单测。

如果失败:

代码块PLAINTEXT · 2 行收起展开
配置和测试已修改,但验证失败在 authService.test.ts,错误与本次配置无直接关系。
我没有继续扩大修改范围。下一步建议先确认该失败是否是已有问题。

Step 10:Trace 与 Memory

Trace 记录“这次怎么走过来的”。

代码块TS · 11 行收起展开
// TraceSpan = 链路追踪中的一个节点。
// 类似 OpenTelemetry span,用来复盘“这一步为什么发生、花了多久、是否失败”。
type TraceSpan = {
  id: string;                         // 当前 span ID。
  parentId?: string;                  // 父 span ID,用来组成树。
  name: string;                       // span 名称,例如 context.retrieve、tool.edit_file。
  startedAt: string;                  // 开始时间。
  endedAt?: string;                   // 结束时间;没有结束说明可能还在运行或异常中断。
  attributes: Record<string, unknown>;// 结构化细节,例如 path、toolName、tokenUsage。
  status: "ok" | "error";             // 这一步成功还是失败。
};

一次任务的 trace 可能是:

代码块PLAINTEXT · 13 行收起展开
turn.run
  skill.select
  context.retrieve
    grep.defaultModel
    read.src/config/modelConfig.ts
    read.test/config/modelConfig.test.ts
  model.call
  tool.edit_file
    permission.diff_preview
    fs.atomic_write
  tool.shell
    npm test -- modelConfig
  response.render

Memory 只记录可复用结论,不记录流水账。

适合写入 memory:

  • 项目的测试命令。
  • 关键目录结构。
  • 用户长期偏好。
  • 已确认的架构约定。
  • 反复会踩的坑。

不适合写入 memory:

  • 某次 grep 输出。
  • 一次性错误日志。
  • 临时猜测。
  • 已经过期的中间状态。

状态机总览

QueryEngine 可以抽象成状态机:

stateDiagram-v2
    [*] --> Idle
    Idle --> BuildingTurn: user input
    BuildingTurn --> CallingModel: context ready
    CallingModel --> Streaming: provider stream starts
    Streaming --> ExecutingTool: tool call complete
    Streaming --> Responding: no tool call
    ExecutingTool --> CallingModel: tool result appended
    ExecutingTool --> WaitingApproval: permission required
    WaitingApproval --> ExecutingTool: approved
    WaitingApproval --> CallingModel: rejected result
    CallingModel --> Compacting: context over budget
    Compacting --> CallingModel: compacted
    Responding --> Persisting: final text
    Persisting --> Idle

图里的 Compacting 状态指上下文压缩:对话和工具输出积累到超出 token 预算时,把旧消息总结成摘要再继续,腾出空间,类似日志滚动。

状态机的价值是控制复杂度。
没有状态机,代码会变成一堆 if/else:流式输出、工具调用、权限等待、上下文压缩、错误恢复全混在一起。

失败拓扑

Agent CLI 的失败不止一种。

大类子类常见信号
运行失败Modeltimeout、rate limit、bad JSON
运行失败Tooltool not found、schema invalid、execution error
输入与上下文失败Contextwrong file、stale context、insufficient context
输入与上下文失败Permissionuser rejected、policy denied
落盘与验证失败Fileoutside workspace、file locked、hash changed
落盘与验证失败Verificationtest failed、command unknown

信号那列有两个值得展开。stale context 指模型拿到的文件内容已经过期,比如它读完文件后用户又手动改了,再按旧内容做编辑就会错位,这也是编辑前做 hash 检查的原因。rate limit 指被模型 API 限流,和你调第三方接口被限流是一回事。

每类失败都要有恢复策略。

失败恢复
model timeout运行时用原样的 messages 重发一次,仍超时就压缩上下文再发
invalid tool JSON把解析错误回传给模型,让它重发一次修正过的工具调用;或运行时先换更宽容的解析策略再试一次
tool not found把当前可用工具列表回传给模型,让它改用正确的工具名
wrong file换关键词重新搜索,找对文件再继续
stale context重新读一遍文件,用最新内容替换过期证据
permission rejected明确告诉模型这次操作被用户拒绝了,让它提出替代方案
test failed把失败输出喂进下一轮上下文,让模型继续修
command unknown先去读 README 或 package scripts,找到项目真正的验证命令

这张表的共同思路:把失败原因如实回传给模型,让它下一轮能换个做法,恢复动作本身留在运行时执行。最差的错误处理是:所有失败都变成 Something went wrong

Prompt、Tool、Runtime 的分工

很多 Agent 系统失败,是因为把运行时责任推给 prompt。

问题不该只靠 Prompt应该放在哪
不要读 workspace 外文件模型可能忘path resolver
不要无审批写文件模型可能绕permission gate
工具参数格式正确模型可能错schema validator
diff 必须展示模型可能省略edit engine
输出不能太长模型可能爆result limiter
API key 不泄漏模型可能复制redaction layer(脱敏层)
命令超时模型管不了tool runtime

表里的 redaction layer 说白了就是脱敏层:在工具结果回灌给模型之前、模型输出打印给用户之前,运行时自动扫一遍文本,把 API key、密码、token 这类敏感串替换成 *** 之类的占位符。和你在日志框架里给手机号、身份证号打码是同一个思路,只是拦截点换成了模型的输入输出两端。

这个分工和后端一模一样:鉴权、参数校验、限流放在过滤器和框架层,业务代码只写逻辑。指望在 prompt 里写“请不要越权”来兜安全,就像指望在接口文档里写“请勿攻击”。

Prompt 负责意图和策略。
Runtime 负责边界和事实。

像源码剖析一样读 Agent CLI

读真实源码时,不要从文件名机械翻译。按链路追:

  1. CLI 入口在哪里?
  2. 用户输入被哪个 router 接住?
  3. Slash command 在哪里截断?
  4. 普通输入如何进入 QueryEngine?
  5. system prompt 在哪里拼?
  6. tool schema 在哪里注册?
  7. 模型流式输出在哪里解析?
  8. tool call 如何进入 executor?
  9. 权限判断在哪里发生?
  10. 文件编辑在哪里生成 diff?
  11. tool result 如何回到 messages?
  12. 上下文什么时候压缩?
  13. MCP tools 如何并入 registry?
  14. trace / usage / cost 在哪里记录?
  15. 最终响应如何渲染?

只要这条链能跑通,模块名变了也能看懂。

最小可实现版本

如果你要自己做一个 Agent CLI,最小版本可以这样分阶段:

Phase 1:能对话

  • CLI 读取用户输入。
  • 调 LLM API。
  • 流式打印文本。
  • 保存 session messages。

Phase 2:能读项目

  • read_file
  • glob
  • grep
  • 路径限制 workspace。
  • 搜索结果限制长度。

Phase 3:能安全改文件

  • edit_file oldText/newText。
  • oldText 唯一检查。
  • diff preview。
  • 用户批准。
  • 原子写入。
  • read-back verification。

Phase 4:能执行命令

  • shell tool。
  • cwd 限制。
  • timeout。
  • 高危命令审批。
  • 输出截断。

Phase 5:能接 MCP

  • stdio MCP client(stdio 指通过子进程的标准输入输出通信:CLI 把 MCP server 当子进程拉起来,用管道传 JSON 消息,本地场景最常用的接法)。
  • tool list。
  • schema 转换。
  • tool call 转发。
  • server 生命周期管理。

Phase 6:能长期协作

  • context budget。
  • compaction(上下文压缩,超预算时把旧对话总结成摘要)。
  • memory。
  • trace。
  • eval(评测:用一批固定任务定期回归测试 Agent 表现,相当于给 Agent 写的集成测试)。
  • command / skill / plugin。

不要从 Phase 6 开始。
一个可靠的 read_file + grep + edit_file + shell + trace,比一堆漂亮名词更接近真正的 Agent 工程。

Java 后端视角总类比

把前面所有模块对回你熟悉的后端概念,左列是 Agent CLI 组件,右列是职责最接近的对应物:

Agent CLIJava 后端
QueryEngine应用服务编排层
Tool RegistrySpring Bean 容器
Tool ExecutorService 调用拦截器
Permission GateRBAC / AOP 权限
Context Retriever查询服务 / 搜索服务
Edit Engine事务写入层
MCP Client外部微服务 Client
Model Adapter第三方 API SDK
Stream ParserWebFlux/SSE 事件解析
TraceOpenTelemetry
Memory配置 + 项目知识库
Skill流程策略文档
Command管理端点

Trace 那行的 OpenTelemetry 是分布式链路追踪的通用标准,你如果接触过 SkyWalking、Zipkin,就是那一类东西。Stream Parser 那行的 SSE 在 Step 5 解释过了,对应后端里解析服务端推送的事件流。

所以这套系统对你没那么陌生。
你是在把后端工程里的分层、权限、事务、可观测性、外部服务治理,迁移到“模型作为推理节点”的新架构里。

全链路验收门槛

这一篇是总复盘,验收也要按全链路看。单点模块都能跑,不代表一次真实任务可靠。下表每行对应链路的一段:中间列是这段跑完后必须能拿出来的记录,右列是缺了记录时最常见的翻车点。

链路段必须留下的证据常见断点
输入路由原始输入、模式、命令/自然语言分类命令误入模型循环
上下文构造rules、memory、search evidence、recent transcript 的顺序历史任务污染当前任务
模型请求model、messages、tool schemas、temperature、usage无法复现模型看到什么
工具执行call id、schema 校验、权限、timeout、stdout/stderr工具失败后模型不知道原因
文件编辑diff、approval、base hash、write result覆盖用户未保存改动
验证反馈test command、exit code、关键错误片段只说“已完成”但没跑验证
记忆落盘写入原因、scope/type/source/confidence把临时事实永久记住
最终回答改了什么、验证结果、残留风险用户看不出任务是否真的完成

上下文构造那行的 recent transcript 指最近几轮的对话记录。rules、memory、search evidence、transcript 这四样拼进上下文的先后顺序会直接影响模型判断,所以顺序本身也要留档,出问题时才能复现模型当时看到了什么。

模型请求那行有两个词第一次出现。temperature 是模型的采样随机度参数,跟硬件温度没有任何关系:设 0 时同样的输入几乎每次都得到同样的输出,调高则输出越来越发散,Agent 场景一般压得很低求稳定。
usage 是这次请求实际消耗的 token 统计(输入多少、输出多少),API 按 token 计费,不留 usage 就没法对账成本,也没法复现当时上下文有多满。

记忆落盘那行的 scope/type/source/confidence 是给每条写进长期记忆的条目打的四个标签:scope 是作用范围(只对当前项目生效,还是全局生效);type 是记忆类型(用户偏好、项目事实、常用命令等);source 是来源(用户亲口说的,还是模型自己推断的);confidence 是可信度打分。
要记它们的原因很直接:以后清理记忆、或者怀疑某条记忆把模型带偏了,得能追溯这条记忆凭什么被写进来、有多可信、该不该删。
没有这些标签,一条模型随手推断的临时事实就可能被当成永久真理。

最小回归样例:让 Agent 修改一个带测试的小 demo,并故意让第一次测试失败。合格全链路应表现为:失败测试进入 trace,模型读取错误,第二次编辑只改必要位置,测试通过后再总结;如果失败被吞掉、第二次编辑无 diff 依据、最终回答不提验证结果,都说明失败信息没有真正回流到下一轮,链路在中间是断开的。

这一篇的核心结论

Agent CLI 的本质:

对话入口 + 上下文检索 + 模型推理 + 工具执行 + 权限审计 + 文件事务 + 验证反馈 + 记忆压缩 + 可观测性

模型只是其中一个节点。
真正的工程价值在于:让这个节点能在受控边界内读上下文、调用工具、修改真实世界,并且失败后还能恢复。

如果你能把一次任务从输入跑到落盘,再从落盘跑到验证和 trace,你就已经抓住了 Agent CLI 源码的主线。

延伸阅读