Agent CLI深水区 - 从输入到落盘全链路复盘
Agent CLI 深水区 11:从输入到代码落盘全链路复盘
前面几篇分别拆了主循环、工具、上下文、MCP(Model Context Protocol,让 Agent 接入外部工具和数据源的标准协议,可以类比成后端里统一的 RPC 接口规范)、模型适配、编辑、搜索、插件和评测。
这一篇不按模块讲,改成沿着一次真实任务的执行链,把之前的模块串起来过一遍。
目标:看完后能在脑子里跑通一个 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 + 用户可见 |
解释一下防线那列的两个说法。fail fast 指配置一有问题就立刻报错退出,别带着坏配置继续跑,等模型行为异常了再排查就晚了。lazy start 指 MCP server 先注册不真正启动,第一次用到时再连接,避免启动阶段被某个卡死的 server 拖住整个 CLI。
Step 1:输入路由
用户输入先过 Input Router。这里的 slash command 指以 / 开头的内置命令(/help、/cost 这类),程序自己就能确定性地执行,不需要模型参与。
代码块收起展开
// 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 和成本 |
表里的 token 是模型处理文本的基本单位,一个汉字大约对应 1 到 2 个 token。模型的上下文窗口大小和 API 计费都按 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
}
];
}分层原因:
- 工具说明和行为规则属于系统层。
- 搜索证据属于上下文层。
- 历史对话需要被预算压缩。
- 用户最后一句必须清晰可见。
拼 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 应该只看统一事件:
代码块收起展开
// 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);
}
}工具调用的硬边界如下表。这些全部由运行时代码强制执行,思路和 Web 后端把鉴权、参数校验放在过滤器/拦截器里一样,靠代码兜底,不指望模型自觉:
| 边界 | 说明 |
|---|---|
| schema validation | 模型输入必须校验 |
| path resolution | 文件路径必须限制 workspace |
| permission gate | 副作用工具必须审批 |
| timeout | 工具不能无限挂起 |
| audit | 高危动作必须记录 |
| result limit | 输出不能撑爆上下文 |
| error normalization | 错误要可恢复 |
Step 7:编辑文件
如果工具是 edit_file 或 apply_patch,会进入编辑事务。这里的事务取的就是数据库事务那层意思:要么校验通过、完整写入并留下 diff 证据,要么什么都不改,绝不允许写一半。
编辑事务返回:
代码块收起展开
// 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 |
表里的 typecheck 指只跑类型检查器(比如 TypeScript 的 tsc),不执行代码,几秒钟就能确认类型没被改坏,类似 Java 里只做编译不跑测试。
验证结果要结构化:
代码块收起展开
// 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
图里的 Compacting 状态指上下文压缩:对话和工具输出积累到超出 token 预算时,把旧消息总结成摘要再继续,腾出空间,类似日志滚动。
状态机的价值是控制复杂度。
没有状态机,代码会变成一堆 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 |
信号那列有两个值得展开。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
读真实源码时,不要从文件名机械翻译。按链路追:
- 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(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 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 | 管理端点 |
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 源码的主线。