Agent CLI深水区 - 从输入到落盘全链路复盘 · AI Agent
Agent CLI 深水区 11:从输入到代码落盘全链路复盘
前面几篇分别拆了主循环、工具、上下文、MCP、模型适配、编辑、搜索、插件和评测。
这一篇不再按模块讲,而是按一次真实任务的执行链讲。
目标:看完后能在脑子里跑通一个 Agent CLI。
一次任务到底发生了什么
用户输入:
帮我把默认模型配置改成
claude-sonnet-4-6,更新相关测试,跑一下验证。
一个成熟 Agent CLI 大致会经历:
这张图里的每一条边都很重要。
Agent CLI 的工程难度不是某个单点算法,而是这些模块的协同。
核心对象地图
先给一组最小对象,后面所有链路都围绕它们转。
代码块收起展开
// 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 启动时,不应该立刻进模型。它要先初始化运行时。
最小启动代码:
代码块收起展开
// 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。
代码块收起展开
// 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。
代码块收起展开
// 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:上下文检索
上下文检索不是一次性完成的。模型可能边做边搜。
第一轮检索要解决三个问题:
- 这个任务大概属于哪类?
- 最可能相关的文件有哪些?
- 当前上下文够不够让模型做第一步?
代码块收起展开
// 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) // 因预算没读的候选,方便后续继续查。
};
}如果用户说“更新默认模型配置”,第一轮可能搜索:
defaultModelmodelsonnetconfigsettings- 测试里对默认值的断言
检索结果不只是文本,还要带理由:
代码块收起展开
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 valueStep 4:构建模型请求
模型请求不是把所有东西拼起来这么简单。它要分层。
代码块收起展开
// 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 应该只看统一事件:
代码块收起展开
// 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 接管。
代码块收起展开
// 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_file 或 apply_patch,会进入编辑事务。
编辑事务返回:
代码块收起展开
// 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 也可以给模型提示:
代码块收起展开
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 |
验证结果要结构化:
代码块收起展开
// VerificationResult = 一次验证命令的结构化结果。
// 不要只保存一大坨命令输出,否则模型很难判断下一步。
type VerificationResult = {
command: string; // 实际运行的命令,例如 npm test -- modelConfig。
exitCode: number; // 进程退出码,0 通常代表成功。
durationMs: number; // 耗时,用于判断卡住或性能问题。
passed: boolean; // 对 exitCode 的业务化判断。
importantOutput: string; // 截断后的关键输出:失败断言、错误栈、通过摘要。
};如果测试失败,Agent 不应该直接说完成。
它应该把失败日志加入下一轮上下文,继续修或明确说明阻塞。
Step 9:总结响应
最终响应不是聊天式“好了”。它应该回答三个问题:
- 改了什么?
- 怎么验证的?
- 是否还有风险?
示例:
代码块收起展开
已把默认模型配置更新为 claude-sonnet-4-6,并同步更新对应测试断言。
验证:npm test -- modelConfig 通过。
注意:没有跑全量测试,因为本次只触及配置默认值和相关单测。如果失败:
代码块收起展开
配置和测试已修改,但验证失败在 authService.test.ts,错误与本次配置无直接关系。
我没有继续扩大修改范围。下一步建议先确认该失败是否是已有问题。Step 10:Trace 与 Memory
Trace 记录“这次怎么走过来的”。
代码块收起展开
// 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 可能是:
代码块收起展开
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.renderMemory 只记录可复用结论,不记录流水账。
适合写入 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 的失败不止一种。
| 大类 | 子类 | 常见信号 |
|---|---|---|
| 运行失败 | Model | timeout、rate limit、bad JSON |
| 运行失败 | Tool | tool not found、schema invalid、execution error |
| 输入与上下文失败 | Context | wrong file、stale context、insufficient context |
| 输入与上下文失败 | Permission | user rejected、policy denied |
| 落盘与验证失败 | File | outside workspace、file locked、hash changed |
| 落盘与验证失败 | Verification | test failed、command unknown |
每类失败都要有恢复策略。
| 失败 | 恢复 |
|---|---|
| model timeout | retry with same messages / smaller context |
| invalid tool JSON | ask model to repair call or retry parser |
| tool not found | return available tools |
| wrong file | re-run search with new query |
| stale context | re-read file |
| permission rejected | tell model rejection and ask alternative |
| test failed | feed failure output into next turn |
| command unknown | inspect 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
读真实源码时,不要从文件名机械翻译。按链路追:
- CLI 入口在哪里?
- 用户输入被哪个 router 接住?
- Slash command 在哪里截断?
- 普通输入如何进入 QueryEngine?
- system prompt 在哪里拼?
- tool schema 在哪里注册?
- 模型流式输出在哪里解析?
- tool call 如何进入 executor?
- 权限判断在哪里发生?
- 文件编辑在哪里生成 diff?
- tool result 如何回到 messages?
- 上下文什么时候压缩?
- MCP tools 如何并入 registry?
- trace / usage / cost 在哪里记录?
- 最终响应如何渲染?
只要这条链能跑通,模块名变了也能看懂。
最小可实现版本
如果你要自己做一个 Agent CLI,最小版本可以这样分阶段:
Phase 1:能对话
- CLI 读取用户输入。
- 调 LLM API。
- 流式打印文本。
- 保存 session messages。
Phase 2:能读项目
read_fileglobgrep- 路径限制 workspace。
- 搜索结果限制长度。
Phase 3:能安全改文件
edit_fileoldText/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 CLI | Java 后端 |
|---|---|
| QueryEngine | 应用服务编排层 |
| Tool Registry | Spring Bean 容器 |
| Tool Executor | Service 调用拦截器 |
| Permission Gate | RBAC / AOP 权限 |
| Context Retriever | 查询服务 / 搜索服务 |
| Edit Engine | 事务写入层 |
| MCP Client | 外部微服务 Client |
| Model Adapter | 第三方 API SDK |
| Stream Parser | WebFlux/SSE 事件解析 |
| Trace | OpenTelemetry |
| 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 的本质:
代码块收起展开
对话入口
+ 上下文检索
+ 模型推理
+ 工具执行
+ 权限审计
+ 文件事务
+ 验证反馈
+ 记忆压缩
+ 可观测性模型只是其中一个节点。
真正的工程价值在于:让这个节点能在受控边界内读上下文、调用工具、修改真实世界,并且失败后还能恢复。
如果你能把一次任务从输入跑到落盘,再从落盘跑到验证和 trace,你就已经抓住了 Agent CLI 源码的主线。