主题
任务状态机设计
想象一下:你让 Agent 写一篇技术文档,它写到一半,模型调用超时了。你重新发起请求,它开始从头问你要写什么。或者更糟,它接着之前的草稿继续写,但完全忘了「刚才还在等材料确认」。
这种断裂感在 Agent 任务里特别常见。大模型擅长一次生成、一次判断,却很难自动记住「任务现在走到哪一步」「哪些东西已经确认过」「暂停的原因又是什么」。如果这些信息全部塞进 Prompt 和对话历史,恢复时只会一团乱麻。
所以我们需要一份独立于单次 LLM 调用的任务状态,把任务阶段、资料确认、证据覆盖、审稿阻塞、人工确认和交付检查都显式记录下来。这份状态就是任务状态机要维护的核心。
1. 为什么任务状态不能只靠 Prompt
一个 Agent 系统通常会拆出多个角色:需求澄清、资料检索、结构生成、产物生成、事实核查、编辑审稿、格式检查、交付检查。每个角色都可以由 Agent 或工具节点承担,但任务本身还需要一份独立于模型上下文的运行状态。
同一个任务经常会遇到几类中断:
- 资料不足,需要回到用户那里确认来源或口径。
- 产物已经生成,但核查节点发现事实不一致。
- 标题和正文主题发生偏移,需要人工确认是否改结构。
- 交付前检查失败,例如元数据缺字段、链接失效、产物格式错误。
- 模型调用或外部检索失败,需要从最近一次可用结果恢复,避免整段重跑。
如果系统只把这些信息塞进对话历史,下一次恢复时会先遇到三个问题:当前任务处在哪个阶段,由哪个节点判断可以进入下一步,任务暂停的原因如何解释给用户和后续节点。任务状态机要维护的,就是这些和 LLM 输出分离的任务事实。
可以把它理解成一个任务级别的「运行记录」:
text
agent_task
phase: executing
sourceStatus: confirmed
evidenceStatus: covered
reviewStatus: blocked
humanApproval: pending
deliveryCheck: not_started
retryCount: 1
lastCheckpointId: ckpt_20260718_001LLM 负责生成、判断和建议;状态机负责记录任务当前位置、允许哪些事件推进、哪些事件必须暂停。
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;
}这里有三个字段容易被忽略:
blockedReason用来解释任务为什么停住。它应该写给系统和人看,例如「缺少官方来源」或「审稿发现目标承诺和产物证据不一致」。evidenceStatus用来区分「已经找到资料」和「核心判断已有证据覆盖」。有资料不等于产物里的每个核心判断都能被来源、代码或项目文档支撑。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.blocked 或 sources.need_confirmation 这类结构化事件,应用层再根据当前状态判断能不能接受这个事件。
这样做有几个直接收益:
- 审稿阻塞不会被下一次生成覆盖。
- 证据覆盖不足时,审稿通过事件不会直接进入交付检查。
- 人工确认不会被误当成普通聊天消息。
- 交付检查失败会回到可修改阶段,避免停在一个只有错误文本的状态。
- 日志里能看到每一次转移的输入、输出和原因。
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,任务回到 executing 或 planning,同时保留拒绝原因。
采用状态字段保存用户决策后,暂停、恢复和拒绝都有明确记录,后续节点不需要从一段自然语言回复里重新猜测任务意图。
7. 失败恢复:从最近稳定状态继续
Agent 任务的失败分两类:
- 业务失败:资料不可靠、审稿不通过、交付检查失败。
- 系统失败:模型超时、检索服务不可用、进程重启、节点执行异常。
业务失败通常应该进入可处理状态,例如 source_confirming 或 executing。系统失败则需要恢复策略。工作流需要记录事件历史或持久状态,使执行在失败后能从最近进度继续。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[];
};如果失败,状态机把任务退回 executing 或 reviewing。如果通过,再进入 done。交付检查的设计目标,是把「能不能交付」拆成后续可以测试和审计的条件。
9. 小结
任务状态机解决的是任务连续性问题。多 Agent 可以拆分执行能力,但任务阶段、资料确认、证据覆盖、审稿阻塞、人工确认、交付检查和失败恢复需要由应用层保存。
这套设计可以先从很小的状态集合开始:
- 用
phase记录主阶段。 - 用
sourceStatus、evidenceStatus、reviewStatus、humanApproval、deliveryCheck记录并行维度。 - 用结构化事件驱动状态转移。
- 用 checkpointer、数据库或工作流引擎保存每一次稳定状态。
- 用交付检查和恢复规则兜住任务收尾。
当状态机稳定下来之后,LLM 输出会更容易被约束在合适的位置:它可以提出产物、判断问题、生成修改建议,但任务是否进入下一阶段,由状态和转移规则决定。