Agent CLI深水区 - QueryEngine状态机

Agent CLI 深水区 02:QueryEngine 状态机

QueryEngine 为什么必须当状态机看

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

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

这个理解太粗。先说清楚对象:QueryEngine 就是 Agent CLI 里从「收到用户输入」到「给出最终回答」的那套主流程引擎。
真正的 QueryEngine 是状态机(state machine):任何时刻它都处在一个明确的状态里,收到事件后按固定规则转移到下一个状态,直到进入结束状态。
你可以类比 Java 线程的 Thread.State:NEW、RUNNABLE、WAITING、TERMINATED,状态之间只有固定几条转移路径,不会乱跳。
QueryEngine 也一样,每一步都有状态、事件、转移条件和停止原因。

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

  • 到处 if/else
  • 不知道什么时候结束。
  • 工具失败后不知道怎么恢复。
  • 用户中断后状态不一致。
  • trace(运行日志,记录每一步发生了什么)无法复盘,出了问题查不出当时走到哪一步。

状态图

下面这张图是 QueryEngine 的 FSM(finite state machine,有限状态机)全貌。看图的方法:实线是正常路径,虚线是失败和重试相关的路径。模型返回纯文本就走上面那条线直接到 Done;返回工具调用就走下面一排:解析、校验、权限、执行、回填结果,然后回到 CallModel 再问一次模型。

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

图里的状态名是简写,和下一节代码里的完整名字一一对应:Build 对应 build_context,CallModel 对应 call_model,Stream 对应 stream_text,Parse 对应 parse_tool_call,Validate 对应 validate_tool,Permission 对应 ask_permission,Execute 对应 execute_tool,Append 对应 append_tool_result,Recover 对应 recover
入口在 Build:用户输入一到,先构建上下文再进 CallModel。右下角 Recover 那条虚线 retry 回到 CallModel,对应后面 recoverFromError 的做法:把错误信息回填进 messages,再让模型跑一轮。

状态类型

写状态机的第一步是把所有状态定义出来,循环后面再写。

代码块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 }; // 本轮结束。

代码注释里的 discriminated union(可辨识联合类型)说白了就是:用 name 字段区分当前是哪个状态,每个状态只携带自己需要的数据。Java 里最接近的写法是 sealed interface 加一组 record 实现,switch 到哪个分支就能拿到哪个分支独有的字段。

为什么这样定义?

因为你可以明确回答:

  • 当前在做什么?
  • 当前状态需要哪些数据?
  • 这个状态能转移到哪里?
  • 失败后怎么处理?

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;// 用户取消/超时取消信号。
};

TurnContext 的角色类似你在 Spring 里给一次 HTTP 请求建的 RequestContext:一次请求内所有环节要共享的数据都放这里,而不是在方法之间传一堆散参数。

先解释代码注释里的几个词。trace 是状态机的运行日志,每次状态转移记一条;transcript 是对话记录,用户说了什么、模型回了什么,下一轮对话要接着用;audit 是审计日志,记录每次工具执行的输入输出,出事后用来查责任。
AbortSignal 是 JS 里的取消信号,作用相当于 Java 线程的 interrupt 标志位:外面设置一下,里面各处检查到就尽快退出。

这些字段都不是装饰,下面这张表是每个字段存在的理由:

字段用途
sessionId用同一个 ID 把 trace、transcript、audit 三种日志串起来,方便按会话查问题
messages发给模型的完整上下文,工具结果会不断追加进来
turn记录已经调了几轮模型,配合 maxTurns 防止无限循环
toolCallCount记录已经执行了几次工具,防止模型一轮里疯狂调工具
startedAt记录开始时间,用来控制整个任务的总超时
abortSignal用户按 Ctrl+C 或前端取消时,让所有状态尽快退出

更真实的主循环

代码块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(),主循环就只剩「拿当前状态,算出下一个状态」,更容易读。

补充两个语法点。async function*yield 的意思是这个函数边跑边往外吐事件(AgentEvent),调用方一边消费一边渲染,类似阻塞队列的生产者消费者。
吐出来的事件有三类消费者:UI(实时展示进度)、trace(落盘方便复盘)、eval(自动化评测,说白了就是拿一批固定任务跑 Agent,自动打分看效果有没有退步)。

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
    }
  };
}

另外注意 recordUsage 那行:token 是模型的计量单位,计费和上下文窗口长度都按 token 算(一个汉字大约一到两个 token)。每次响应都要累计用量,只记最后一次的话账单和统计都对不上。

这里有一个工程判断:

模型返回 tool_call 后,先进入 validate_tool 状态,校验和权限都过了再真正执行工具。

这样工具校验、权限、审计各自是独立的一步,就像 Servlet 过滤器链:每个过滤器只做一件事,顺序固定,以后想加一道检查就加一个环节,不用改工具本身。

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 };
}

先解释代码里的 inputSchema:它是 JSON Schema,说白了就是给 JSON 参数定义的结构约束(哪些字段必填、每个字段什么类型),可以用现成库自动校验,作用类似你在 Spring 里用 @Valid 校验请求 DTO。

为什么工具不存在也不一定直接崩?

因为有些模型会生成接近但不完全正确的工具名。举个真实会发生的例子:注册的工具叫 read_file,模型却生成了 readFile。这时直接报错终止很亏,可以恢复:

  • 告诉模型该工具不存在。
  • 提供可用工具列表。
  • 让模型重试一次。

模型下一轮看到「Tool not found. Available tools: read_file, write_file, bash」这样的回填消息,大概率就会改用正确的名字。

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"
  };
}

权限状态不要藏在工具里。否则不同工具各写一套权限,很快会不一致。举个例子:ls 这种只读命令可以自动 allow,rm -rf 这种破坏性命令直接 deny,介于中间的(比如写文件)弹出来问用户。
这套分级规则放在统一的 checkPermission 里,所有工具共用一份,和你把鉴权写在 Spring 的统一拦截器里、而不用每个 Controller 自己判断,是同一个道理。

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" };
}

下面两张表是恢复策略速查。第一张是可恢复错误,共同点是模型拿到提示后自己能修正,做法都是把错误信息回填进 messages 再调一轮模型:

错误恢复方式
工具名错告诉模型工具列表
参数格式错告诉模型 schema 错误
检索无结果让模型换关键词
读文件不存在让模型先 glob/grep 找到真实路径

glob 说白了就是按通配符列文件名,比如 **/*.java 找出所有 Java 文件,相当于 Linux 里 ls 配合通配符;grep 是按内容搜。模型经常记错文件路径,读不到时先用这两个工具确认文件到底在哪,再重新读。

第二张是不可轻易恢复的错误,共同点是问题出在模型之外(用户意愿、安全边界、环境配置),模型重试多少次都没用:

错误原因
权限拒绝用户明确不允许
危险命令安全边界
鉴权失败配置问题
上下文过大且压缩失败需要人工处理

最后一行的「压缩」需要解释一下:模型一次能看的内容有上限(上下文窗口,按 token 算),任务跑久了 messages 会越堆越长。
快超限时,engine 一般会自动做一次压缩:把前面几十轮旧对话让模型总结成一小段摘要,替换掉原文来腾地方。
如果连摘要之后都塞不下(比如单个工具结果就是一个几 MB 的文件内容),程序自己救不了,只能人工介入,拆小任务或者清掉历史重新来。

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.jsonl 里每行是一条 JSON,大致长这样:{"sessionId":"abc","time":"2026-07-26T10:00:00Z","state":"execute_tool","turn":2,"details":{...}}
出问题时按 sessionId 过滤,就能按时间顺序还原状态机走过的每一步。

有 trace 后,才能回答:

  • 模型为什么调用这个工具?
  • 哪个工具失败了?
  • 失败前上下文是什么?
  • 到底是权限拒绝还是工具异常?
  • 为什么停在 max turns?

状态机版本的核心价值

最后横向对比一下,同样的功能,用不用状态机在工程上差在哪:

没有状态机有状态机
控制流散落在一堆 if/else 里,执行顺序靠读代码猜每个状态能去哪里都写在转移规则里,一张表看完
错误处理各处 catch 各写各的,行为不一致恢复策略集中在 recover 一个入口
日志只能在各处零散打点,出问题拼不出当时的执行过程每次状态转移记一条 trace,事后能完整回放
逻辑搅在一个大循环里,很难单独测某一段每个 state 的处理函数输入输出明确,可以单独测
容易写出没人发现的无限循环max turns、max tool calls、超时这些退出条件都是显式检查

源码验收门槛

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(终态,也就是 done 这种不再转移的状态)catch 后继续同一状态重试

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

读大型源码时的判断标准

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

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

这些才是源码级理解。

延伸阅读