Workflow 与状态机
学习目标
- 能把一个 Agent 任务拆成有明确状态、节点和转移的流程,而不是把所有逻辑塞进一个不可观察的 Loop。
- 能区分“当前状态是什么”和“下一步怎么走”,并为成功、失败、取消和恢复分别定义出口。
- 能判断哪些节点可以重放,哪些节点已经产生外部副作用,必须使用幂等键或补偿。
前置知识
- 已完成第 02–08 章,知道
State、Checkpoint、Tool、Memory、Context和 RAG 的基本边界。 - 推荐先读Agent Loop和Checkpoint、Suspend 与 Resume。
Workflow 是什么
Workflow 是一组有明确顺序、条件和终点的任务流程。它可以包含普通函数、工具调用、模型决策和人工审批,但每个步骤都应该能说明输入、输出、状态变化和失败处理。
白话说,Workflow 像一张流程图:模型可以参与某个节点的判断,不能因此让整张流程图失去边界。固定流程用代码表达,开放决策才交给模型。
Workflow 与自由 Agent Loop 的区别
自由 Agent Loop 适合目标不明确、需要探索的任务;Workflow 适合步骤、权限和完成条件相对稳定的任务。工程中经常把二者组合:Workflow 控制大方向,某个节点内部再运行一个有步数上限的 Agent Loop。
状态机、Node 与 Edge
StateMachine 用有限状态表示运行阶段;Node 是一次可执行的处理单元;Edge 是从一个节点到下一个节点的转移规则。边可以是无条件的,也可以根据状态、工具结果或审批结果选择目标。
状态机的关键不是画图,而是让这些问题有唯一答案:谁写状态、什么条件允许转移、失败是否重试、恢复从哪个版本继续。
最小状态机
阅读这张图时,先看每个节点的责任,再看每条边的条件。Retryable 不是“失败后必然重试”,它仍然要经过重试预算、幂等性和取消状态检查。
Node 的输入输出契约
节点至少要声明读取哪些状态、产生哪些状态,以及是否可能产生外部副作用。把返回值约束为结构化结果,后续的边才不会依赖异常字符串或日志文本。
Node 的纯计算边界
纯节点只根据输入计算输出,例如解析、校验、选择路由。它可以安全重放,测试也容易写。
Node 的副作用边界
副作用节点会写数据库、发消息、调用支付或修改文件。它必须携带 idempotency_key,并在状态里记录执行结果;仅仅把函数包在 try/except 中不能让副作用变得可重放。
下面的案例用本地字典模拟订单审批流程:校验和计划是纯节点,执行节点通过幂等键避免重复扣库存。
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Literal
Status = Literal["new", "validated", "executed", "succeeded", "failed", "cancelled"]
@dataclass(frozen=True)
class WorkflowState:
order_id: str
quantity: int
status: Status = "new"
idempotency_key: str = ""
error: str | None = None
inventory = {"book": 3}
executed_keys: set[str] = set()
def validate(state: WorkflowState) -> WorkflowState:
if state.status == "cancelled":
return state
if state.quantity <= 0:
return replace(state, status="failed", error="quantity must be positive")
return replace(state, status="validated", idempotency_key=f"{state.order_id}:reserve")
def execute(state: WorkflowState) -> WorkflowState:
if state.status == "cancelled":
return state
if state.status != "validated":
return replace(state, status="failed", error="unexpected state")
if state.idempotency_key in executed_keys:
return replace(state, status="succeeded")
if inventory["book"] < state.quantity:
return replace(state, status="failed", error="out of stock")
inventory["book"] -= state.quantity
executed_keys.add(state.idempotency_key)
return replace(state, status="succeeded")
state = WorkflowState(order_id="o-100", quantity=1)
state = validate(state)
state = execute(state)
retry_state = validate(WorkflowState(order_id="o-100", quantity=1))
again = execute(retry_state)
cancelled = execute(validate(WorkflowState(order_id="o-101", quantity=1, status="cancelled")))
print(state.status, inventory["book"])
print(again.status, inventory["book"])
print(cancelled.status)
# 输出:succeeded 2
# 输出:succeeded 2
# 输出:cancelled这里的输入包括两个正常请求和一个已经取消的请求;正常请求都先经过 validated 状态检查,再由同一个幂等键识别第二次请求。取消是不可逆终态,validate 和 execute 都保持 cancelled,不会把它重新验证、改成失败或继续产生副作用。
Edge 如何表达转移
边应该读取结构化状态,而不是重新猜测模型输出。常见边类型如下。
无条件边
当前节点成功后固定进入下一个节点,适合“解析 → 校验”这类确定顺序。
条件边
根据 state.status、state.retry_count 或业务结果选择目标。条件函数要保持纯,不能在判断时偷偷写数据库。
错误边
把可恢复错误、不可恢复错误、取消和超预算分别送到不同出口。所有错误都跳到一个 failed 节点,会丢失重试和补偿语义。
终点、失败与取消
Succeeded 表示满足业务完成条件,不等于“函数没有抛异常”;Failed 表示流程无法继续,并应携带可审计原因;Cancelled 表示外部请求主动停止,不能被误报成业务失败。
完成条件
完成条件应该验证最终状态,例如“库存预留成功且审计事件已写入”,不能只用“最后一个节点返回了 None”。
取消检查点
长任务应在节点开始前、外部调用返回后和批处理分段之间检查取消信号。已经提交的外部副作用不能靠取消回滚,必须进入补偿或人工处理。
Checkpoint 与恢复
Checkpoint 至少保存状态版本、当前节点、输入摘要和已完成副作用的幂等键。恢复时先检查版本是否仍匹配,再决定从当前节点重试还是走补偿分支。
可重放性与幂等
重放是再次执行相同的状态机记录;幂等是同一个业务操作执行一次或多次,外部可观察结果相同。两者相关但不相同:纯节点通常可重放,副作用节点必须额外实现幂等。
事件记录
记录 run_id、node_id、输入摘要、状态版本、开始/结束时间和结果分类,才能解释恢复为何重复执行某个节点。
不要把日志当状态
日志适合诊断,不适合作为恢复唯一依据。恢复需要结构化 checkpoint 或 append-only 事件记录,并且要能判断某个副作用是否已经提交。
源码阅读锚点
LangGraph:图、节点和检查点
阅读 LangGraph 时先找 StateGraph 的节点/边定义,再看编译后的执行器如何推进状态和写 checkpoint。重点不是记住某个装饰器,而是确认 reducer 如何合并并行节点输出、何时保存版本,以及恢复时从哪个节点继续。
smolagents 与 OpenAI Agents SDK:Loop 内嵌 Workflow
smolagents 的 run/工具执行循环和 OpenAI Agents SDK 的 Runner 都可以作为“节点内部的 Agent Loop”来观察。沿调用链确认 stop condition、tool result 回填和异常传播,避免把每一次模型循环都误认为持久化 Workflow 节点。
易混点
- 状态机不是只有一张流程图:真正的状态机还要定义状态结构、转移条件、错误出口和恢复规则。
- 重试不等于幂等:网络重试可能让支付、写库等副作用重复发生,必须用幂等键或查询已提交结果。
- 异常不等于取消:异常说明节点失败,取消说明调用方停止;两者的审计和补偿策略不同。
- 模型决定不等于状态转移:模型只能提出候选动作,运行时仍要验证边条件和权限。
- Checkpoint 不会撤销副作用:它只记录可恢复位置;已提交的外部操作需要幂等查询或补偿。
课后小问(含解析)
为什么
execute节点要携带幂等键?答案:因为节点可能在超时、恢复或消息重复投递后再次执行。
解析:状态机只能控制自己的执行记录,不能保证外部系统只收到一次请求。幂等键让外部系统返回已有结果,或让本地先判断已提交记录,从而避免重复副作用。
Failed和Cancelled为什么要分开?答案:前者是流程无法完成,后者是调用方主动停止。
解析:失败可能触发重试、降级或告警;取消通常应尽快停止后续工作,并记录取消者和取消时间。混在一起会让运营无法判断是系统故障还是用户行为。
一个节点抛出异常后,是否可以直接从下一个节点恢复?
答案:只有在确认该节点没有未确认的副作用,或已经通过幂等查询确认结果后才可以。
解析:超时并不代表远端没有执行。没有确认副作用状态就跳过节点,可能造成状态和外部事实不一致。
本节小结
- Workflow 用状态、节点和边把 Agent 的开放决策放进可观察的流程边界。
- 节点要区分纯计算和外部副作用,副作用必须考虑幂等、审计和补偿。
- 成功、失败、取消和恢复是不同的终态或转移,不应靠一个布尔值混在一起。
- Checkpoint 记录恢复位置,不会自动撤销已经提交的外部操作。
快速回顾
- 能画出一个包含成功、可重试失败和不可重试失败的状态机。
- 能说出 Node、Edge、Checkpoint、Idempotency Key 各自负责什么。
- 能判断一个节点是否适合重放,以及需要什么保护。
- 下一篇阅读 Planning 与 Plan-and-Execute。
阿源的知识库