Agent CLI深水区 - QueryEngine状态机 · AI Agent

Agent CLI 深水区 02:QueryEngine 状态机

QueryEngine 为什么必须当状态机看

很多人看 Agent loop,会以为就是:

代码块TEXT · 3 行收起展开
while true:
  call model
  maybe call tool

这个理解太粗。真正的 QueryEngine 是状态机,因为每一步都有状态、事件、转移条件和停止原因。

如果不用状态机思维,代码会变成:

  • 到处 if/else
  • 不知道什么时候结束。
  • 工具失败后不知道怎么恢复。
  • 用户中断后状态不一致。
  • trace 无法复盘。

状态图

QueryEngine FSM 核心是状态、事件、退出原因三件事。 Build CallModel Stream Done Parse Validate Permission Execute Append Recover

text tool denied failed retry

状态类型

先定义状态,而不是直接写循环。

代码块TS · 13 行收起展开
// 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:一次任务的运行时状态

代码块TS · 13 行收起展开
// 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;// 用户取消/超时取消信号。
};

这些字段都不是装饰:

字段用途
sessionIdtrace 和 transcript 串联
messages当前模型上下文
turn防止无限模型轮次
toolCallCount防止工具滥用
startedAt控制总超时
abortSignal用户中断

更真实的主循环

代码块TS · 32 行收起展开
// 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 函数

代码块TS · 58 行收起展开
// 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

代码块TS · 46 行收起展开
// 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

代码块TS · 27 行收起展开
// 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

代码块TS · 35 行收起展开
// 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

代码块TS · 55 行收起展开
// 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

错误恢复不是所有错误都重试。

代码块TS · 31 行收起展开
// 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。要知道为什么停:

代码块TS · 11 行收起展开
// 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 记录状态转移

代码块TS · 19 行收起展开
// 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 statecatch 后继续同一状态重试

最小回归样例:同时覆盖三条路径:正常工具调用、权限拒绝、max turns。合格实现应让三条路径分别落到不同 StopReason,并且 trace 中最后一个状态能解释为什么停止,而不是只给用户一句“失败了”。

读大型源码时的判断标准

看到复杂 QueryEngine,不要只问“它在哪里 call model”。要问:

  1. 当前 turn 状态在哪里保存?
  2. 最大 turn 怎么限制?
  3. 工具调用数怎么限制?
  4. 工具失败怎么恢复?
  5. 用户中断怎么处理?
  6. trace 在哪里记录?
  7. final answer 和 tool result 怎么区分?
  8. stop reason 是否足够细?

这些才是源码级理解。