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

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

前面几篇分别拆了主循环、工具、上下文、MCP、模型适配、编辑、搜索、插件和评测。
这一篇不再按模块讲,而是按一次真实任务的执行链讲。

目标:看完后能在脑子里跑通一个 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 + 用户可见

Step 1:输入路由

用户输入先过 Input Router。

代码块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 和成本

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
  • 测试里对默认值的断言

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

代码块TEXT · 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
    }
  ];
}

分层原因:

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

常见错误:

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

Step 5:模型流式输出

模型输出可能同时包含自然语言和工具调用。

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);
  }
}

工具调用的硬边界:

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

Step 7:编辑文件

如果工具是 edit_fileapply_patch,会进入编辑事务。

编辑事务:先验证,再落盘 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 也可以给模型提示:

代码块TEXT · 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

验证结果要结构化:

代码块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. 是否还有风险?

示例:

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

如果失败:

代码块TEXT · 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 可能是:

代码块TEXT · 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

状态机的价值是控制复杂度。
没有状态机,代码会变成一堆 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

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

失败恢复
model timeoutretry with same messages / smaller context
invalid tool JSONask model to repair call or retry parser
tool not foundreturn available tools
wrong filere-run search with new query
stale contextre-read file
permission rejectedtell model rejection and ask alternative
test failedfeed failure output into next turn
command unknowninspect README/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

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。
  • tool list。
  • schema 转换。
  • tool call 转发。
  • server 生命周期管理。

Phase 6:能长期协作

  • context budget。
  • compaction。
  • memory。
  • trace。
  • eval。
  • command / skill / plugin。

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

Java 后端视角总类比

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管理端点

所以你不是从零学一个玄学系统。
你是在把后端工程里的分层、权限、事务、可观测性、外部服务治理,迁移到“模型作为推理节点”的新架构里。

全链路验收门槛

这一篇是总复盘,验收也要按全链路看。单点模块都能跑,不代表一次真实任务可靠。

链路段必须留下的证据常见断点
输入路由原始输入、模式、命令/自然语言分类命令误入模型循环
上下文构造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把临时事实永久记住
最终回答改了什么、验证结果、残留风险用户看不出任务是否真的完成

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

这一篇的核心结论

Agent CLI 的本质:

代码块TEXT · 9 行收起展开
对话入口
+ 上下文检索
+ 模型推理
+ 工具执行
+ 权限审计
+ 文件事务
+ 验证反馈
+ 记忆压缩
+ 可观测性

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

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