Agent CLI深水区 - QueryEngine状态机 · AI Agent
Agent CLI 深水区 02:QueryEngine 状态机
QueryEngine 为什么必须当状态机看
很多人看 Agent loop,会以为就是:
代码块收起展开
while true:
call model
maybe call tool这个理解太粗。真正的 QueryEngine 是状态机,因为每一步都有状态、事件、转移条件和停止原因。
如果不用状态机思维,代码会变成:
- 到处
if/else。 - 不知道什么时候结束。
- 工具失败后不知道怎么恢复。
- 用户中断后状态不一致。
- trace 无法复盘。
状态图
状态类型
先定义状态,而不是直接写循环。
代码块收起展开
// EngineState = QueryEngine 当前处于哪一步。
// 用 discriminated union 表达状态机:name 决定这个状态携带哪些数据。
type EngineState =
| { name: "build_context" } // 构建 messages、记忆、工具说明等上下文。
| { name: "call_model"; turn: number } // 调模型,第几轮模型调用。
| { name: "stream_text"; text: string } // 正在输出模型文本。
| { name: "parse_tool_call"; raw: unknown } // 解析模型原始工具调用。
| { name: "validate_tool"; call: ToolCall } // 校验工具是否存在、参数是否合法。
| { name: "ask_permission"; call: ToolCall } // 根据风险请求权限。
| { name: "execute_tool"; call: ToolCall } // 真正执行工具。
| { name: "append_tool_result"; call: ToolCall; result: ToolResult } // 把工具结果回填 messages。
| { name: "recover"; error: AgentError } // 尝试从可恢复错误中恢复。
| { name: "done"; reason: StopReason }; // 本轮结束。为什么这样定义?
因为你可以明确回答:
- 当前在做什么?
- 当前状态需要哪些数据?
- 这个状态能转移到哪里?
- 失败后怎么处理?
TurnContext:一次任务的运行时状态
代码块收起展开
// TurnContext = 一次用户请求的运行时状态。
// state 是“当前走到哪一步”,ctx 是“这一路需要共享的数据”。
type TurnContext = {
sessionId: string; // 会话 ID,串联 trace/transcript/audit。
userInput: string; // 用户原始输入。
messages: Message[]; // 当前模型上下文,会随着工具结果不断追加。
turn: number; // 当前模型轮次,防止无限调用模型。
maxTurns: number; // 最大模型轮次。
startedAt: number; // 本轮开始时间,用于总超时。
toolCallCount: number; // 已执行工具次数。
maxToolCalls: number; // 最大工具调用次数,防止工具滥用。
abortSignal?: AbortSignal;// 用户取消/超时取消信号。
};这些字段都不是装饰:
| 字段 | 用途 |
|---|---|
sessionId | trace 和 transcript 串联 |
messages | 当前模型上下文 |
turn | 防止无限模型轮次 |
toolCallCount | 防止工具滥用 |
startedAt | 控制总超时 |
abortSignal | 用户中断 |
更真实的主循环
代码块收起展开
// query = 状态机版 Agent 主循环。
// 它不直接写一堆 if/else,而是不断把 state 交给 step() 转移。
export async function* query(userInput: string): AsyncGenerator<AgentEvent> {
// 初始化本轮上下文。
const ctx: TurnContext = {
sessionId: crypto.randomUUID(),
userInput,
messages: [],
turn: 0,
maxTurns: 8,
startedAt: Date.now(),
toolCallCount: 0,
maxToolCalls: 12
};
// 初始状态:先构建上下文。
let state: EngineState = { name: "build_context" };
// 只要没进入 done,就持续推进状态机。
while (state.name !== "done") {
// 把状态变化作为事件吐出去,UI/trace/eval 都能观察。
yield { type: "state", state: state.name };
// step 执行一个状态,并返回下一个状态。
state = await step(state, ctx, event => {
// step 内部需要发 text delta 或 tool event 时走这里
});
}
// done 状态包含结束原因。
yield { type: "turn_end", reason: state.reason };
}这里把“状态转移”抽成 step(),主循环就更容易读。
step 函数
代码块收起展开
// step = 状态机转移函数。
// 输入当前 state + ctx,输出下一个 state。
async function step(
state: EngineState,
ctx: TurnContext,
// emit 用于在状态内部吐出事件,例如文本增量、工具开始、工具结果。
emit: (event: AgentEvent) => void
): Promise<EngineState> {
// 用户取消优先级最高,任何状态都应该尽快退出。
if (ctx.abortSignal?.aborted) {
return { name: "done", reason: "user_abort" };
}
// 总耗时超过 120 秒则超时退出。
if (Date.now() - ctx.startedAt > 120_000) {
return { name: "done", reason: "timeout" };
}
switch (state.name) {
case "build_context":
// 构建初始 messages,然后进入 call_model。
ctx.messages = await buildContext(ctx.userInput);
return { name: "call_model", turn: 0 };
case "call_model":
// 调模型可能返回文本,也可能返回工具调用。
return await callModelState(ctx, emit);
case "validate_tool":
// 工具执行前先校验工具名和参数。
return await validateToolState(state.call);
case "ask_permission":
// 副作用工具要先过权限。
return await askPermissionState(state.call);
case "execute_tool":
// 真正执行工具。
return await executeToolState(state.call, ctx, emit);
case "append_tool_result":
// 把工具结果回填给模型,模型下一轮才能基于观察继续推理。
ctx.messages.push({
role: "tool",
toolCallId: state.call.id,
content: state.result.content
});
return { name: "call_model", turn: ctx.turn + 1 };
case "recover":
// 错误恢复集中在一个入口,避免散落在各状态里。
return recoverFromError(state.error, ctx);
default:
// 理论上不应该到这里;作为兜底,按模型错误结束。
return { name: "done", reason: "model_error" };
}
}这个写法的好处:
- 每个状态可以单独测试。
- 每个状态只关心自己的数据。
- 错误恢复有明确入口。
- trace 可以记录每次状态变化。
callModelState
代码块收起展开
// callModelState = call_model 状态的处理函数。
// 它只负责“调模型后,根据模型返回决定下一步状态”。
async function callModelState(
ctx: TurnContext,
emit: (event: AgentEvent) => void
): Promise<EngineState> {
// 达到最大模型轮次,直接结束,防止无限循环。
if (ctx.turn >= ctx.maxTurns) {
return { name: "done", reason: "max_turns" };
}
// 调模型:messages 是当前上下文,tools 是可用工具,abortSignal 支持取消。
const response = await callModel({
messages: ctx.messages,
tools: getToolSchemas(),
abortSignal: ctx.abortSignal
});
// 记录 token 用量。
if (response.usage) recordUsage(ctx.sessionId, response.usage);
// 模型返回最终文本,本轮正常结束。
if (response.type === "text") {
// 把文本交给 UI/renderer。
emit({ type: "text_delta", text: response.text });
// 保存到 transcript,方便下轮继续对话。
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
}
};
}这里有一个工程判断:
模型返回 tool_call 后,不应该马上执行工具,而应该先进入 validate_tool 状态。
这样工具校验、权限、审计可以独立处理。
validateToolState
代码块收起展开
// validateToolState = 工具校验状态。
// 这里不执行工具,只确认“工具存在”和“参数符合 schema”。
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 是否符合工具 JSON Schema。
const valid = validateJson(call.input, tool.inputSchema);
if (!valid.ok) {
// 参数错也是可恢复错误:让模型按 schema 重新生成。
return {
name: "recover",
error: new AgentError("TOOL_INPUT_INVALID", valid.message, call)
};
}
// 校验通过后再进入权限阶段。
return { name: "ask_permission", call };
}为什么工具不存在也不一定直接崩?
因为有些模型会生成接近但不完全正确的工具名。可以恢复:
- 告诉模型该工具不存在。
- 提供可用工具列表。
- 让模型重试一次。
askPermissionState
代码块收起展开
// askPermissionState = 权限判断状态。
// 它根据工具风险和输入决定 allow/deny/ask。
async function askPermissionState(call: ToolCall): Promise<EngineState> {
// 权限判断也需要工具定义,因为风险等级在 tool 上。
const tool = findTool(call.name);
if (!tool) {
return {
name: "recover",
error: new AgentError("TOOL_NOT_FOUND", `Unknown tool: ${call.name}`)
};
}
// checkPermission 可能自动允许、自动拒绝,或请求用户确认。
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 的未处理情况当成 user_abort。
// 真实系统会等待用户输入 approve/deny。
return {
name: "done",
reason: "user_abort"
};
}权限状态不要藏在工具里。否则不同工具各写一套权限,很快会不一致。
executeToolState
代码块收起展开
// executeToolState = 工具执行状态。
// 它负责计数、发事件、执行工具、写审计、决定成功/失败后的下一个状态。
async function executeToolState(
call: ToolCall,
ctx: TurnContext,
emit: (event: AgentEvent) => void
): Promise<EngineState> {
// 工具调用次数也要限制,避免模型在一轮里疯狂 grep/read/shell。
if (ctx.toolCallCount >= ctx.maxToolCalls) {
return { name: "done", reason: "max_tool_calls" };
}
// 先加计数,再执行。
ctx.toolCallCount++;
// 让 UI 知道工具开始了。
emit({ type: "tool_start", call });
try {
// 真正执行工具。内部还应包含权限、timeout、输出截断等保护。
const result = await executeToolCall(call);
// 成功后发 tool_result 事件。
emit({ type: "tool_result", call, result });
// 写工具审计,方便未来复盘。
await appendToolAudit({
sessionId: ctx.sessionId,
call,
result,
ok: true
});
// 成功后进入 append_tool_result,把结果回填给模型。
return {
name: "append_tool_result",
call,
result
};
} catch (error) {
// 任意工具异常都归一化成 AgentError。
const agentError = normalizeToolError(error, call);
// 失败也要写审计,否则最关键的错误会丢。
await appendToolAudit({
sessionId: ctx.sessionId,
call,
error: agentError,
ok: false
});
// 失败是否可恢复交给 recoverFromError 判断。
return { name: "recover", error: agentError };
}
}这里有几个深层点:
- 工具执行前发
tool_start,用户知道 Agent 在干什么。 - 工具结果进入 audit,方便复盘。
- 工具失败不一定终止,交给 recover 判断。
- 工具调用数单独限制,不只靠模型轮次。
recoverFromError
错误恢复不是所有错误都重试。
代码块收起展开
// recoverFromError = 错误恢复策略。
// 不是所有错误都重试,只对模型有机会修正的错误回填提示。
function recoverFromError(error: AgentError, ctx: TurnContext): EngineState {
// 如果已经快到最大轮次,就不要再让模型重试。
if (ctx.turn >= ctx.maxTurns - 1) {
return { name: "done", reason: "max_turns" };
}
// 工具参数不合法:把错误作为 tool message 回填,让模型重新生成参数。
if (error.code === "TOOL_INPUT_INVALID") {
ctx.messages.push({
role: "tool",
toolCallId: "validation-error",
content: `Tool input was invalid: ${error.message}. Try again with valid JSON.`
});
return { name: "call_model", turn: ctx.turn + 1 };
}
// 工具不存在:告诉模型可用工具列表,让它改用正确工具。
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(", ")}`
});
return { name: "call_model", turn: ctx.turn + 1 };
}
// 其他错误默认不可恢复,结束本轮。
return { name: "done", reason: "tool_error" };
}可恢复错误:
| 错误 | 恢复方式 |
|---|---|
| 工具名错 | 告诉模型工具列表 |
| 参数格式错 | 告诉模型 schema 错误 |
| 检索无结果 | 让模型换关键词 |
| 读文件不存在 | 让模型先 glob/grep |
不可轻易恢复:
| 错误 | 原因 |
|---|---|
| 权限拒绝 | 用户明确不允许 |
| 危险命令 | 安全边界 |
| 鉴权失败 | 配置问题 |
| 上下文过大且压缩失败 | 需要人工处理 |
StopReason 要足够细
不要只写 done。要知道为什么停:
代码块收起展开
// StopReason = 状态机结束原因。
// 这会影响最终提示、trace 统计和 eval 判断。
type StopReason =
| "final_answer" // 正常最终回答。
| "max_turns" // 模型轮次超过上限。
| "max_tool_calls" // 工具调用次数超过上限。
| "timeout" // 总耗时超时。
| "user_abort" // 用户取消。
| "permission_denied" // 权限拒绝。
| "tool_error" // 工具失败且不可恢复。
| "model_error"; // 模型请求或响应失败。为什么?
因为产品体验不同:
final_answer:正常结束。max_turns:可能要提示“任务太复杂”。permission_denied:要说明用户拒绝了工具。timeout:可以建议重试。model_error:可能要切换模型。
Trace 记录状态转移
代码块收起展开
// TraceEvent = 状态机每次转移的记录。
type TraceEvent = {
sessionId: string; // 哪个会话。
time: string; // 何时发生。
state: string; // 当前状态名。
turn: number; // 当前模型轮次。
details?: unknown; // 状态携带的数据,例如 tool call 或 error。
};
// trace = 追加状态机 trace。
async function trace(ctx: TurnContext, state: EngineState) {
await appendJsonl(".agent/trace.jsonl", {
sessionId: ctx.sessionId,
time: new Date().toISOString(),
state: state.name,
turn: ctx.turn,
details: state
});
}有 trace 后,才能回答:
- 模型为什么调用这个工具?
- 哪个工具失败了?
- 失败前上下文是什么?
- 到底是权限拒绝还是工具异常?
- 为什么停在 max turns?
状态机版本的核心价值
| 没有状态机 | 有状态机 |
|---|---|
| 控制流散乱 | 转移清晰 |
| 错误处理随意 | 恢复策略集中 |
| trace 难看 | 每步可记录 |
| 很难测试 | 每个 state 可测 |
| 容易无限循环 | 退出条件明确 |
源码验收门槛
QueryEngine 是 Agent CLI 最容易“看起来能跑、实际不可控”的模块。验收要围绕转移表、边界条件和可回放 trace。
| 验收项 | 必须看到的源码证据 | 不合格表现 |
|---|---|---|
| 转移表可列出 | 任意 state 的下一步都能写成表格 | 读源码时只能靠猜执行顺序 |
| 上下文不可丢 | TurnContext 持有 session、messages、limits、usage、abort signal | 状态函数临时传一堆散参 |
| 限制优先检查 | max turns、max tool calls、abort 在每轮前检查 | 先调用模型再发现超限 |
| tool call 原子处理 | validate、permission、execute、append result 顺序固定 | 执行成功但 result 没入 transcript |
| usage 累计准确 | 每次 model response 都更新 token/cost | 只记录最终 usage |
| 恢复策略显式 | 可恢复错误回到模型,不可恢复错误进入 terminal state | catch 后继续同一状态重试 |
最小回归样例:同时覆盖三条路径:正常工具调用、权限拒绝、max turns。合格实现应让三条路径分别落到不同 StopReason,并且 trace 中最后一个状态能解释为什么停止,而不是只给用户一句“失败了”。
读大型源码时的判断标准
看到复杂 QueryEngine,不要只问“它在哪里 call model”。要问:
- 当前 turn 状态在哪里保存?
- 最大 turn 怎么限制?
- 工具调用数怎么限制?
- 工具失败怎么恢复?
- 用户中断怎么处理?
- trace 在哪里记录?
- final answer 和 tool result 怎么区分?
- stop reason 是否足够细?
这些才是源码级理解。