Agent CLI源码剖析 - 主循环与状态机
Agent CLI 源码剖析 02:主循环与状态机
主循环是 Agent 的心脏
Agent CLI 里最核心的代码是 QueryEngine。UI、命令列表、各个工具都只是外围,QueryEngine 才是驱动一切的主流程,地位类似你写 Web 后端时那条「收请求、走业务、返回响应」的主链路。
QueryEngine 负责:
- 接收用户输入。
- 构建上下文。
- 调模型。
- 判断模型输出。
- 执行工具。
- 把工具结果回填。
- 重复直到结束。
最粗的伪代码:
代码块收起展开
// 这是最粗糙的 Agent loop 伪代码。
// 它表达的是“模型 -> 工具 -> 模型”的循环,不是最终工程写法。
while (!done) {
// 1. 把当前 messages 和工具列表发给模型。
const response = await callModel(messages, tools);
// 2. 如果模型直接回答文本,本轮结束。
if (response.text) return response.text;
// 3. 如果模型请求工具,执行工具并把结果回填。
if (response.toolCall) {
const result = await executeTool(response.toolCall);
messages.push(result);
}
}但真正工程化时,不能只写成 while。它必须是状态机。
为什么是状态机
先说清楚状态机是什么:把整个流程拆成有限个明确的状态,并且规定好每个状态只能往哪些状态转移。你在 Java 后端见过的订单状态就是典型例子,待支付、已支付、已发货、已取消,每一步只允许特定跳转,不允许「已取消」直接变「已发货」。Agent loop 用状态机,是因为每一步都有明确状态和失败处理:
不用状态机的后果:
- 错误处理散落各处。
- 工具失败后不知道要不要重试。
- max turns 写在多个地方。
- trace 不知道记录哪一步(trace 就是运行轨迹日志,记录 Agent 每一步干了什么,排查问题全靠它。没有明确的状态划分,你都不知道日志该在哪里打)。
- 用户中断可能留下半状态。
TurnContext
一次用户任务需要一个运行时上下文,把这次任务相关的所有状态集中放在一个对象里传来传去,作用类似你在 Spring 项目里给一次请求建的 Context 对象。
下面代码注释里会出现几个日志类的词,先解释一下:transcript 是完整对话记录(用户说了什么、模型答了什么、工具返回了什么,全部按顺序存下来);trace 上一节说过,是状态机每一步转移的运行轨迹,调试用;audit 是审计日志(哪个会话在什么时间执行了什么工具,安全审查和追责用)。三份记录用途不同,所以分开存。
代码块收起展开
// TurnContext = 一次用户任务的运行时状态。
// 状态机里的每个 state 都会读写这个 ctx。
export type TurnContext = {
sessionId: string; // 串联 transcript、trace、audit。
userInput: string; // 用户原始任务。
messages: Message[]; // 当前模型上下文。
turn: number; // 当前模型轮次。
maxTurns: number; // 最大模型轮次。
toolCallCount: number; // 当前已执行工具次数。
maxToolCalls: number; // 最大工具调用次数。
startedAt: number; // 本轮开始时间。
timeoutMs: number; // 总超时时间。
abortSignal?: AbortSignal; // 用户取消或系统取消信号。
};字段含义汇总一下,左列是字段名,右列是它存在的理由:
| 字段 | 作用 |
|---|---|
sessionId | 用一个 id 把 transcript、trace、audit 三份记录串起来,方便按会话查 |
messages | 当前模型上下文 |
turn | 模型轮次 |
toolCallCount | 工具调用次数 |
startedAt | 总耗时控制 |
abortSignal | 用户取消 |
这里体现一个原则:
Agent 的状态不要隐含在局部变量堆里,要结构化保存。
EngineState
代码块收起展开
// EngineState = QueryEngine 状态机的状态集合。
// 每个状态只携带自己需要的数据。
export type EngineState =
| { name: "build_context" } // 构建初始 messages。
| { name: "call_model" } // 调模型。
| { name: "validate_tool"; call: ToolCall } // 校验工具名和参数。
| { name: "ask_permission"; call: ToolCall } // 权限确认。
| { name: "execute_tool"; call: ToolCall } // 执行工具。
| { name: "append_tool_result"; call: ToolCall; result: ToolResult } // 回填工具结果。
| { name: "recover"; error: AgentError } // 错误恢复。
| { name: "done"; reason: StopReason }; // 结束。这样每个状态携带自己需要的数据。
比如:
validate_tool必须有call。append_tool_result必须有call和result。recover必须有error。done必须有reason。
这比传一个塞满可空字段的大对象清楚得多。这种写法在 TypeScript 里叫 discriminated union(可辨识联合类型),对应 Java 17 之后的 sealed interface 加一组 record 子类:每种状态是独立类型,只带自己需要的字段,switch 的时候编译器还能帮你检查有没有漏分支。
StopReason
代码块收起展开
// StopReason = 本轮停止原因。
// 用于最终提示、trace、eval,而不是只写 done。
export type StopReason =
| "final_answer" // 正常回答完成。
| "max_turns" // 模型轮次超限。
| "max_tool_calls" // 工具调用超限。
| "timeout" // 总耗时超时。
| "user_abort" // 用户取消。
| "permission_denied" // 权限拒绝。
| "tool_error" // 工具错误。
| "model_error"; // 模型错误。先说注释里的 eval:这里指对 Agent 的自动化评测(跑一批固定任务给 Agent 打分,看代码改动后成功率有没有退步,作用类似你跑的回归测试套件),和 JavaScript 里执行字符串代码的 eval() 函数没有任何关系。
评测统计失败原因时就靠 StopReason 分类,比如「10% 的任务卡在 max_turns」这种结论,只记一个笼统的 done 是得不出来的。
不要只写 done。停止原因会影响用户提示和调试判断。下面这张表就是「停止原因到用户提示」的映射:同样是停下来,不同原因给用户看的话应该不一样。
| StopReason | 用户该看到什么 |
|---|---|
final_answer | 正常结果 |
max_turns | 任务太复杂,建议拆分 |
permission_denied | 因用户拒绝工具而停止 |
timeout | 超时,可重试 |
model_error | 模型请求失败 |
主循环骨架
代码块收起展开
// query = 状态机主循环。
// 它只负责初始化 ctx、推进 state、产出事件。
export async function* query(userInput: string): AsyncGenerator<AgentEvent> {
// 初始化本轮上下文。
const ctx: TurnContext = {
sessionId: crypto.randomUUID(),
userInput,
messages: [],
turn: 0,
maxTurns: 8,
toolCallCount: 0,
maxToolCalls: 12,
startedAt: Date.now(),
timeoutMs: 120_000
};
// 初始状态:构建上下文。
let state: EngineState = { name: "build_context" };
// 不断推进状态,直到 done。
while (state.name !== "done") {
// 把状态暴露成事件,方便 UI 和 trace。
yield { type: "state", name: state.name };
await appendTrace(ctx.sessionId, state);
// 具体状态逻辑放在 step。
state = await step(state, ctx, event => {
// 复杂实现里可以把内部事件推到队列
});
}
// done 状态里包含 reason。
yield { type: "turn_end", reason: state.reason };
}这段代码有两个点值得说。第一,函数签名里的 AsyncGenerator,说白了就是能异步往外吐东西的迭代器:每执行到 yield 就抛出一个事件,调用方(UI 层)拿到事件立刻渲染,效果类似后端往前端做流式推送,用户能实时看到 Agent 走到哪一步了。
第二,主循环本身只管三件事:初始化 ctx、推进状态、发事件,每个状态的具体逻辑全部收在 step 函数里,主循环保持干净。
step 函数
代码块收起展开
// step = 状态转移函数。
// 给定当前 state 和 ctx,返回下一个 state。
async function step(
state: EngineState,
ctx: TurnContext,
emit: (event: AgentEvent) => void
): Promise<EngineState> {
// 用户取消优先。
if (ctx.abortSignal?.aborted) {
return { name: "done", reason: "user_abort" };
}
// 总耗时超限。
if (Date.now() - ctx.startedAt > ctx.timeoutMs) {
return { name: "done", reason: "timeout" };
}
switch (state.name) {
case "build_context":
// 构建模型可见上下文。
ctx.messages = await buildContext(ctx);
return { name: "call_model" };
case "call_model":
// 调模型并判断返回文本还是工具调用。
return callModelState(ctx, emit);
case "validate_tool":
// 工具存在性和参数校验。
return validateToolState(state.call);
case "ask_permission":
// 权限判断。
return askPermissionState(state.call);
case "execute_tool":
// 执行工具。
return executeToolState(state.call, ctx, emit);
case "append_tool_result":
// 工具结果回填 messages,模型下一轮才能看到。
ctx.messages.push({
role: "tool",
toolCallId: state.call.id,
content: state.result.content
});
// 完成一次工具观察后,模型轮次 +1。
ctx.turn++;
return { name: "call_model" };
case "recover":
// 错误恢复。
return recoverState(state.error, ctx);
}
}这个写法的关键价值:
- 每个状态可单独测试。
- trace 可以记录每次状态转移。
- 退出条件集中。
- 错误恢复集中。
callModelState
代码块收起展开
// callModelState = 调模型状态。
async function callModelState(
ctx: TurnContext,
emit: (event: AgentEvent) => void
): Promise<EngineState> {
// 模型轮次超限就停止。
if (ctx.turn >= ctx.maxTurns) {
return { name: "done", reason: "max_turns" };
}
// 发起模型请求。
const response = await callModel({
messages: ctx.messages,
tools: getToolSchemas(),
abortSignal: ctx.abortSignal
});
// 记录 token usage。
if (response.usage) {
await recordUsage(ctx.sessionId, response.usage);
}
// 文本响应:输出并结束。
if (response.type === "text") {
emit({ type: "text_delta", text: response.text });
await appendTranscript(ctx.sessionId, {
role: "assistant",
content: response.text
});
return { name: "done", reason: "final_answer" };
}
// 工具调用响应:进入 validate_tool,而不是直接执行。
return {
name: "validate_tool",
call: {
id: response.id,
name: response.name,
input: response.input,
sessionId: ctx.sessionId
}
};
}先解释代码里的 recordUsage:usage 指这次请求消耗了多少 token(token 是模型计量文本的单位,大致一个英文单词或半个汉字算一个,模型按 token 计费)。不记 usage,你就不知道钱和上下文额度花在了哪。
重要判断:
模型返回工具调用后,不立刻执行。先 validate,再 permission,再 execute。
这是安全边界。原因在于模型输出本质上是不可信输入:它可能编造不存在的工具名、给出格式错误的参数,甚至请求危险操作。所以要像对待前端传来的参数一样,先校验再放行,中间还隔了一道权限确认。
validateToolState
代码块收起展开
// validateToolState = 校验工具调用。
async function validateToolState(call: ToolCall): Promise<EngineState> {
// 工具必须存在于注册表。
const tool = findTool(call.name);
if (!tool) {
return {
name: "recover",
error: new AgentError("TOOL_NOT_FOUND", `Unknown tool: ${call.name}`, call)
};
}
// 模型给的 input 必须符合工具 schema。
const valid = validateJson(call.input, tool.inputSchema);
if (!valid.ok) {
return {
name: "recover",
error: new AgentError("TOOL_INPUT_INVALID", valid.message, {
call,
schema: tool.inputSchema
})
};
}
// 校验通过,进入权限判断。
return { name: "ask_permission", call };
}代码里的 inputSchema 是工具参数的 JSON Schema(一份机器可读的参数格式说明书,规定字段名、类型、哪些必填),validateJson 拿它来校验模型给的参数,作用相当于你在 Spring 里用 @Valid 校验前端传来的 DTO。
为什么工具名错、参数错可以进 recover?
因为模型有时能根据错误提示修正:
代码块收起展开
Tool "read" not found. Available tools: read_file, grep, glob.下一轮模型可能会改成正确工具。
askPermissionState
代码块收起展开
// askPermissionState = 权限阶段。
async function askPermissionState(call: ToolCall): Promise<EngineState> {
// 重新查工具,避免注册表异常或状态不一致。
const tool = findTool(call.name);
if (!tool) {
return {
name: "recover",
error: new AgentError("TOOL_NOT_FOUND", `Unknown tool: ${call.name}`)
};
}
// checkPermission 返回 allow/deny/ask。
const decision = await checkPermission(tool, call.input);
if (decision.type === "allow") {
return { name: "execute_tool", call };
}
if (decision.type === "deny") {
return { name: "done", reason: "permission_denied" };
}
// ask 表示需要用户明确批准。
const approved = await askUser(decision.prompt);
if (!approved) {
return { name: "done", reason: "permission_denied" };
}
// 用户批准后执行工具。
return { name: "execute_tool", call };
}权限状态不要藏在具体工具里。否则每个工具各写一套,很快失控。这和 Web 项目里把鉴权统一放在 Filter 或拦截器是同一个道理:如果每个 Controller 自己写权限判断,规则一变就要改几十处,还容易漏。
executeToolState
代码块收起展开
// executeToolState = 工具执行阶段。
async function executeToolState(
call: ToolCall,
ctx: TurnContext,
emit: (event: AgentEvent) => void
): Promise<EngineState> {
// 工具调用次数也要限制。
if (ctx.toolCallCount >= ctx.maxToolCalls) {
return { name: "done", reason: "max_tool_calls" };
}
// 计数 + 发开始事件。
ctx.toolCallCount++;
emit({ type: "tool_start", call });
try {
// 真正执行工具。
const result = await executeToolCall(call);
// 发工具结果事件。
emit({ type: "tool_result", call, result });
// 成功审计。
await appendToolAudit({
sessionId: ctx.sessionId,
call,
result,
ok: true
});
// 成功后进入回填状态。
return {
name: "append_tool_result",
call,
result
};
} catch (error) {
// 工具失败统一归一化。
const agentError = normalizeToolError(error, call);
// 失败也要审计。
await appendToolAudit({
sessionId: ctx.sessionId,
call,
error: agentError,
ok: false
});
// 交给 recover 判断能否恢复。
return { name: "recover", error: agentError };
}
}两个细节。一,maxToolCalls 和 maxTurns 是两道独立的保险丝:前者防模型陷入「反复调工具但任务不推进」的死循环,后者限制模型请求总轮次,类似给递归加深度上限。
二,工具失败时异常被 catch 住,归一化成 AgentError 后进入 recover 状态,由恢复策略决定是重试还是终止,而没有直接 throw 到顶层把整个循环炸掉。
recoverState
代码块收起展开
// recoverState = 错误恢复策略。
function recoverState(error: AgentError, ctx: TurnContext): EngineState {
// 快到 maxTurns 时不再恢复,直接停止。
if (ctx.turn >= ctx.maxTurns - 1) {
return { name: "done", reason: "max_turns" };
}
// 参数错误:把错误回填给模型,让模型按 schema 重试。
if (error.code === "TOOL_INPUT_INVALID") {
ctx.messages.push({
role: "tool",
toolCallId: "validation-error",
content: `Tool input invalid: ${error.message}. Retry with valid JSON.`
});
ctx.turn++;
return { name: "call_model" };
}
// 工具不存在:告诉模型可用工具列表。
if (error.code === "TOOL_NOT_FOUND") {
ctx.messages.push({
role: "tool",
toolCallId: "tool-not-found",
content: `Tool not found. Available tools: ${getToolSchemas().map(t => t.name).join(", ")}`
});
ctx.turn++;
return { name: "call_model" };
}
// 其他错误默认不可恢复。
return { name: "done", reason: "tool_error" };
}可恢复错误和不可恢复错误要分开:
| 错误 | 能否恢复 | 说明 |
|---|---|---|
| 工具名错 | 能 | 给工具列表 |
| 参数格式错 | 能 | 给 schema 错误 |
| 文件不存在 | 可能 | 让模型先 glob |
| 权限拒绝 | 不能 | 用户明确拒绝 |
| 危险命令 | 不能 | 安全边界 |
| 鉴权失败 | 不能 | 配置问题 |
「文件不存在」标「可能」的意思:可以把错误回给模型,让它先用 glob(按文件名模式搜索文件的工具)找到正确路径再重试;但文件真不存在的话,重试也没用。后三类涉及用户意愿、安全和配置,重试只会白白烧掉轮次,直接停。
状态机的测试方式
不要只测最终答案。要测状态序列。
代码块收起展开
// 状态机测试示例:验证一次工具调用链路存在。
it("runs tool then returns final answer", async () => {
// 收集 query 产出的所有事件。
const events = await collectEvents(query("read README"));
// 应该出现工具开始和工具完成。
expect(events.map(e => e.type)).toContain("tool_start");
expect(events.map(e => e.type)).toContain("tool_result");
// 最后应该正常结束。
expect(last(events)).toEqual({
type: "turn_end",
reason: "final_answer"
});
});状态机的好处在测试里最直观:断言的对象是事件序列,而事件序列直接对应状态转移。哪天重构把某个转移改坏了,这种测试能立刻指出断在哪一步;只看最终答案的端到端测试只会告诉你「结果不对」,你还得自己翻日志找原因。
源码验收门槛
验收主循环时,「能回答一次问题」只算及格线,真正要检查的点在每个状态转移是否有边界、证据和退出条件。下面这张表左列是验收项,中间列是你在源码里应该能找到的具体证据,右列是不合格的踩坑写法。
| 验收项 | 必须看到的源码证据 | 不合格表现 |
|---|---|---|
| 状态枚举清晰 | EngineState 或等价 union 覆盖 model/tool/permission/recover/final | 用多个 boolean 拼状态 |
| 转移集中 | step(state, ctx) 或等价调度点决定下一状态 | 状态跳转散在多个 callback |
| 停止原因细分 | final_answer/max_turns/user_abort/permission_denied/error | 只有 success/fail |
| 工具回填闭环 | tool result 写回 messages/transcript 后再 call model | 工具执行完直接给用户,不让模型整合 |
| 失败恢复有界 | 可恢复错误重试或改策略,不可恢复错误立即停止 | 所有异常都继续循环 |
| 每轮可观测 | turn、state、tool call、usage、error 都进入 trace | debug 只能看终端残留输出 |
最小回归样例(回归测试指改完代码后重跑一遍、确认老功能没被改坏的测试):构造一个模型先返回非法工具参数、再修正参数、最后输出答案的场景。
合格状态机应出现 call_model -> validate_tool -> recover -> call_model -> execute_tool -> call_model -> final,并且 turn 数、错误原因、修复后的参数都可在 trace 中检查。
读源码抓手
读 QueryEngine 时,问这些问题:
- 一次 turn 的状态存在哪里?
- 最大轮次在哪里限制?
- 工具调用次数是否单独限制?
- 工具失败是否可恢复?
- 用户拒绝权限后怎么停止?
- 模型 usage 是否记录?
- 状态变化是否进入 trace?
- tool result 是否回填 messages?
如果这些问题答不出来,就还没读懂主循环。