Skip to content

任务状态机设计

想象一下:你让 Agent 写一篇技术文档,它写到一半,模型调用超时了。你重新发起请求,它开始从头问你要写什么。或者更糟,它接着之前的草稿继续写,但完全忘了「刚才还在等材料确认」。

这种断裂感在 Agent 任务里特别常见。大模型擅长一次生成、一次判断,却很难自动记住「任务现在走到哪一步」「哪些东西已经确认过」「暂停的原因又是什么」。如果这些信息全部塞进 Prompt 和对话历史,恢复时只会一团乱麻。

所以我们需要一份独立于单次 LLM 调用的任务状态,把任务阶段、资料确认、证据覆盖、审稿阻塞、人工确认和交付检查都显式记录下来。这份状态就是任务状态机要维护的核心。

1. 为什么任务状态不能只靠 Prompt

一个 Agent 系统通常会拆出多个角色:需求澄清、资料检索、结构生成、产物生成、事实核查、编辑审稿、格式检查、交付检查。每个角色都可以由 Agent 或工具节点承担,但任务本身还需要一份独立于模型上下文的运行状态。

同一个任务经常会遇到几类中断:

  1. 资料不足,需要回到用户那里确认来源或口径。
  2. 产物已经生成,但核查节点发现事实不一致。
  3. 标题和正文主题发生偏移,需要人工确认是否改结构。
  4. 交付前检查失败,例如元数据缺字段、链接失效、产物格式错误。
  5. 模型调用或外部检索失败,需要从最近一次可用结果恢复,避免整段重跑。

如果系统只把这些信息塞进对话历史,下一次恢复时会先遇到三个问题:当前任务处在哪个阶段,由哪个节点判断可以进入下一步,任务暂停的原因如何解释给用户和后续节点。任务状态机要维护的,就是这些和 LLM 输出分离的任务事实。

可以把它理解成一个任务级别的「运行记录」:

text
agent_task
  phase: executing
  sourceStatus: confirmed
  evidenceStatus: covered
  reviewStatus: blocked
  humanApproval: pending
  deliveryCheck: not_started
  retryCount: 1
  lastCheckpointId: ckpt_20260718_001

LLM 负责生成、判断和建议;状态机负责记录任务当前位置、允许哪些事件推进、哪些事件必须暂停。

2. FSM 和状态图各自解决什么

有限状态机(Finite State Machine, FSM)关注一件事:系统在某个时刻处于一个明确状态,并在事件触发后进入下一个状态。对 Agent 任务来说,主阶段可以用 FSM 表达:

text
intake -> researching -> planning -> executing -> reviewing -> delivering -> done

这个模型能避免一些常见的「不可能状态」。例如资料还没有确认时,任务不应该进入交付检查;审稿已经阻塞时,系统不应该继续自动交付。状态机用于列出状态、事件和转移,这种枚举方式也适合任务里容易漏掉的异常路径。

Agent 任务又不止一个维度。主阶段可能是 reviewing,同时资料状态是 needs_confirmation,人工确认是 pending,交付检查是 not_started。这时可以把 FSM 扩展成状态图(statechart),用多个区域描述同一个任务的不同面向:

text
phase: reviewing
source: confirmed | needs_confirmation
review: clean | blocked
evidence: uncovered | partial | covered
approval: not_required | pending | approved | rejected
deliveryCheck: not_started | running | passed | failed

这种拆法可以避免把所有状态压成一个巨大的枚举,例如 reviewing_with_confirmed_sources_and_pending_approval。主阶段仍然清晰,资料、证据、审稿、人工确认和交付检查也有自己的状态边界。

3. 状态集合:围绕 Agent 任务建模

一个任务从需求进入系统,到交付完成,可以先拆成 8 个主状态:

状态任务含义允许的下一步
intake收集目标、对象、约束和交付格式资料检索、人工补充需求
researching检索一手资料、项目文档、代码证据或历史产物资料确认、结构生成、阻塞
source_confirming等待用户确认来源、口径或引用边界继续结构、退回检索、取消
planning生成任务结构和关键论点产物生成、退回需求澄清
executing生成产物、示例、图表说明和段落衔接审稿、退回结构
reviewing做事实核查、证据覆盖、风格检查和结构检查修改、人工确认、交付检查
delivery_checking检查格式、链接、元数据和交付风险交付、退回修改
done任务完成并记录结果

这些状态先回答一个调度问题:当前任务应该交给哪个节点处理,处理后能不能进入下一步。更细的业务差异可以放到资料、审稿、人工确认和交付检查等并行维度里。

可以用一个轻量的状态字段承接主流程:

typescript
type TaskPhase =
  | "intake"
  | "researching"
  | "source_confirming"
  | "planning"
  | "executing"
  | "reviewing"
  | "delivery_checking"
  | "done"
  | "failed";

interface AgentTaskState {
  taskId: string;
  phase: TaskPhase;
  goal: string;
  targetAudience: string;
  sourceStatus: "unknown" | "candidate" | "confirmed" | "rejected";
  evidenceStatus: "uncovered" | "partial" | "covered";
  reviewStatus: "not_started" | "clean" | "blocked";
  humanApproval: "not_required" | "pending" | "approved" | "rejected";
  deliveryCheck: "not_started" | "running" | "passed" | "failed";
  retryCount: number;
  blockedReason?: string;
  lastStablePhase?: TaskPhase;
  updatedAt: string;
}

这里有三个字段容易被忽略:

  1. blockedReason 用来解释任务为什么停住。它应该写给系统和人看,例如「缺少官方来源」或「审稿发现目标承诺和产物证据不一致」。
  2. evidenceStatus 用来区分「已经找到资料」和「核心判断已有证据覆盖」。有资料不等于产物里的每个核心判断都能被来源、代码或项目文档支撑。
  3. lastStablePhase 用来支持失败恢复。节点失败后,恢复逻辑可以回到最近一个稳定阶段,避免从任务入口重新执行。

4. 转移规则:把节点接到任务状态上

转移规则决定状态机能接受哪些推进事件。Agent 任务里的事件通常来自四类输入:Agent 节点结果、用户决策、系统检查结果、确定性的内容校验结果。

typescript
type TaskEvent =
  | { type: "requirements.completed" }
  | { type: "sources.found"; confidence: number }
  | { type: "sources.need_confirmation"; reason: string }
  | { type: "sources.confirmed" }
  | { type: "evidence.covered" }
  | { type: "plan.accepted" }
  | { type: "product.completed" }
  | { type: "review.blocked"; reason: string }
  | { type: "review.passed" }
  | { type: "human.approved" }
  | { type: "human.rejected"; reason: string }
  | { type: "delivery_check.passed" }
  | { type: "delivery_check.failed"; reason: string }
  | { type: "node.failed"; reason: string };

function transition(
  state: AgentTaskState,
  event: TaskEvent,
): AgentTaskState {
  switch (state.phase) {
    case "intake":
      if (event.type === "requirements.completed") {
        return { ...state, phase: "researching", updatedAt: now() };
      }
      return state;

    case "researching":
      if (event.type === "sources.need_confirmation") {
        return {
          ...state,
          phase: "source_confirming",
          sourceStatus: "candidate",
          humanApproval: "pending",
          blockedReason: event.reason,
          updatedAt: now(),
        };
      }
      if (event.type === "sources.found" && event.confidence >= 0.8) {
        return {
          ...state,
          phase: "planning",
          sourceStatus: "confirmed",
          updatedAt: now(),
        };
      }
      return state;

    case "reviewing":
      if (event.type === "review.blocked") {
        return {
          ...state,
          reviewStatus: "blocked",
          humanApproval: "pending",
          blockedReason: event.reason,
          updatedAt: now(),
        };
      }
      if (event.type === "evidence.covered") {
        return { ...state, evidenceStatus: "covered", updatedAt: now() };
      }
      if (
        event.type === "review.passed" &&
        state.evidenceStatus === "covered"
      ) {
        return { ...state, phase: "delivery_checking", updatedAt: now() };
      }
      return state;

    case "delivery_checking":
      if (event.type === "delivery_check.passed") {
        return {
          ...state,
          phase: "done",
          deliveryCheck: "passed",
          updatedAt: now(),
        };
      }
      if (event.type === "delivery_check.failed") {
        return {
          ...state,
          phase: "executing",
          deliveryCheck: "failed",
          blockedReason: event.reason,
          updatedAt: now(),
        };
      }
      return state;

    default:
      if (event.type === "node.failed") {
        return {
          ...state,
          phase: "failed",
          blockedReason: event.reason,
          updatedAt: now(),
        };
      }
      return state;
  }
}

这段代码没有让 LLM 决定任意跳转。模型可以给出 review.blockedsources.need_confirmation 这类结构化事件,应用层再根据当前状态判断能不能接受这个事件。

这样做有几个直接收益:

  1. 审稿阻塞不会被下一次生成覆盖。
  2. 证据覆盖不足时,审稿通过事件不会直接进入交付检查。
  3. 人工确认不会被误当成普通聊天消息。
  4. 交付检查失败会回到可修改阶段,避免停在一个只有错误文本的状态。
  5. 日志里能看到每一次转移的输入、输出和原因。

5. 在 LangGraph 中落到节点路由

LangGraph 的图结构适合表达多节点 Agent 工作流。checkpointer 保存线程内的图状态,store 保存跨线程的长期数据。对 Agent 任务来说,任务阶段、审稿状态和人工确认状态更适合放进可 checkpoint 的图状态;长期复用的项目规范、风格偏好,可以放到任务外的 store 或业务数据库。

一个任务图可以按下面方式组织:

typescript
import { StateGraph } from "@langchain/langgraph";

const graph = new StateGraph<AgentTaskState>()
  .addNode("intake", intakeNode)
  .addNode("research", researchNode)
  .addNode("confirmSources", confirmSourcesNode)
  .addNode("plan", planNode)
  .addNode("execute", executeNode)
  .addNode("review", reviewNode)
  .addNode("deliveryCheck", deliveryCheckNode)
  .addConditionalEdges("review", routeAfterReview)
  .addConditionalEdges("deliveryCheck", routeAfterDeliveryCheck);

function routeAfterReview(state: AgentTaskState) {
  if (state.reviewStatus === "blocked") return "confirmSources";
  if (state.humanApproval === "pending") return "confirmSources";
  if (state.evidenceStatus !== "covered") return "research";
  return "deliveryCheck";
}

function routeAfterDeliveryCheck(state: AgentTaskState) {
  if (state.deliveryCheck === "failed") return "execute";
  return "__end__";
}

节点负责生成事件,状态机负责接受或拒绝事件,条件路由负责把状态映射到下一个节点。三个职责分开后,调试会更直接:如果产物进入了错误节点,先看状态字段;如果状态字段不对,再看上一个节点发出的事件;如果事件正确但转移错误,再看 transition 规则。

LangGraph 的 checkpointer 在 super-step 边界保存状态,而不是在节点函数内部中断。开发时可以用 InMemorySaver,生产环境通常使用 PostgresSaver 等持久化后端。同一个任务要通过 thread_id 来识别和恢复,否则每次调用都会变成独立的新会话。这和第 7 节说的失败恢复一致:任务推进需要被记录成可恢复的状态变更,不能只依赖一次 HTTP 请求。

6. 人工确认:暂停要成为一等状态

Agent 任务里的人不应被建模成临时异常路径。资料确认、交付确认、审稿争议处理都需要把任务暂停下来,并且让用户的决策可以恢复原任务。

LangGraph 的 interrupt / resume 机制提供了一个可参考的模式:执行到需要外部输入的位置时暂停,把上下文交给人,收到恢复输入后继续。对 Agent 任务而言,暂停时至少要保存三类信息:

字段用途
approvalRequest说明需要人确认什么,例如来源口径、结构方向、是否交付
resumeTarget记录确认后回到哪个节点
blockedReason记录暂停原因,方便审计和通知

示例状态可以这样写:

typescript
interface ApprovalRequest {
  kind: "source" | "plan" | "review" | "delivery";
  question: string;
  options: string[];
  createdAt: string;
}

interface AgentTaskState {
  phase: TaskPhase;
  humanApproval: "not_required" | "pending" | "approved" | "rejected";
  approvalRequest?: ApprovalRequest;
  resumeTarget?: "research" | "plan" | "execute" | "review" | "deliveryCheck";
}

当用户选择「批准交付」时,系统接收 human.approved,状态机把 humanApproval 更新为 approved,再把任务交回 deliveryCheck。当用户选择「需要改结构」时,系统接收 human.rejected,任务回到 executingplanning,同时保留拒绝原因。

采用状态字段保存用户决策后,暂停、恢复和拒绝都有明确记录,后续节点不需要从一段自然语言回复里重新猜测任务意图。

7. 失败恢复:从最近稳定状态继续

Agent 任务的失败分两类:

  1. 业务失败:资料不可靠、审稿不通过、交付检查失败。
  2. 系统失败:模型超时、检索服务不可用、进程重启、节点执行异常。

业务失败通常应该进入可处理状态,例如 source_confirmingexecuting。系统失败则需要恢复策略。工作流需要记录事件历史或持久状态,使执行在失败后能从最近进度继续。Agent 任务不一定要上 Temporal 这类工作流引擎,但可以保留同样的设计意识:任务推进需要被记录成一组可恢复的状态变更,不能只依赖一次 HTTP 请求的执行结果。

可以把恢复规则写得保守一些:

typescript
function recover(state: AgentTaskState): AgentTaskState {
  if (state.phase !== "failed") return state;

  if (state.lastStablePhase) {
    return {
      ...state,
      phase: state.lastStablePhase,
      retryCount: state.retryCount + 1,
      blockedReason: undefined,
      updatedAt: now(),
    };
  }

  return {
    ...state,
    phase: "intake",
    retryCount: state.retryCount + 1,
    updatedAt: now(),
  };
}

lastStablePhase 不应该在每个节点开始时更新,而应该在节点产出已经通过结构化校验后更新。例如规划节点生成了合法结构、结构与任务目标一致,再把稳定阶段推进到 planning。这样恢复时不会回到一个半写入状态。

8. 交付检查:把「可以交付」拆成可验证条件

Agent 任务的最后一步不应该只问模型「这个产物可以交付吗」。交付检查更适合拆成确定性规则和少量模型辅助判断:

检查项更适合的实现
元数据是否完整解析 frontmatter 或配置结构
更新时间是否符合规则日期校验
链接是否有效链接检查或构建检查
是否保留必要代码块或示例AST 或文本结构检查
事实引用是否有来源结构化引用列表
核心判断是否有证据覆盖引用清单、代码片段或项目文档
标题和正文是否一致LLM 辅助评审,再输出结构化结论

交付检查节点只需要返回结构化事件:

typescript
type DeliveryCheckResult =
  | { type: "delivery_check.passed" }
  | {
      type: "delivery_check.failed";
      reason: string;
      failedItems: string[];
    };

如果失败,状态机把任务退回 executingreviewing。如果通过,再进入 done。交付检查的设计目标,是把「能不能交付」拆成后续可以测试和审计的条件。

9. 小结

任务状态机解决的是任务连续性问题。多 Agent 可以拆分执行能力,但任务阶段、资料确认、证据覆盖、审稿阻塞、人工确认、交付检查和失败恢复需要由应用层保存。

这套设计可以先从很小的状态集合开始:

  1. phase 记录主阶段。
  2. sourceStatusevidenceStatusreviewStatushumanApprovaldeliveryCheck 记录并行维度。
  3. 用结构化事件驱动状态转移。
  4. 用 checkpointer、数据库或工作流引擎保存每一次稳定状态。
  5. 用交付检查和恢复规则兜住任务收尾。

当状态机稳定下来之后,LLM 输出会更容易被约束在合适的位置:它可以提出产物、判断问题、生成修改建议,但任务是否进入下一阶段,由状态和转移规则决定。

参考资料

基于 MIT 协议开源