12.1 状态机式编排
前十一章,minagent 的主线一直是循环:把消息喂给模型,模型决定下一步是调工具还是给答案,循环往复直到收敛。这个模型极其灵活——任务怎么走完全由模型临场决定,所以它特别适合开放式问题(查资料、写代码、调试)。但灵活性是一把双刃剑:当流程本身是固定的,循环反而成了负担——你没法保证「合规检查一定在发送之前」,也没法说清「这一步失败该回到哪一步」。
本节把 minagent 推进到
minagent/workflow/graph.py:手写一个最小图执行引擎,用Node(名字 + 一个函数)、Edge(源节点 → 目标节点,可带条件)、Graph(add_node/add_edge/set_entry/run)三样东西,把「固定的执行结构」显式地表达出来;节点之间用一个state字典传递数据。
12.1.1 循环式 Agent 的边界
先厘清一件事:图编排不是循环的替代品,而是它的补充。两者的根本差别在于「谁决定下一步」。
在循环式 Agent 里,下一步由模型决定。你给它一个目标,它自己判断该调哪个工具、该不该继续。这适合那些「路径不可预知」的任务——你事先根本不知道要查几次、要改几遍。
但在很多真实业务里,路径是事先定死的:
| 场景 | 固定结构 | 为什么不能交给模型 |
|---|---|---|
| 报销审批 | 录入 → 校验 → 主管批 → 打款 | 打款不可逆,必须卡在人工之后 |
| 内容生产 | 选题 → 大纲 → 撰写 → 审校 → 发布 | 顺序不能乱,漏一步就是事故 |
| 客服工单 | 分类 → 检索知识库 → 生成回复 | 分类结果决定后面查哪张表 |
| 数据管道 | 抽取 → 清洗 → 转换 → 加载 | 每一步的产物就是下一步的输入 |
这些流程的共同点是:结构比内容更重要。模型可以在某个节点里干活(比如分类、撰写),但「先做哪个、失败去哪、什么条件才往下走」这些骨架,应该由代码而不是模型来保证。把骨架写进图里,带来三个直接好处:
- 可预测:跑一万次,节点顺序完全一样,审计和复现都成立。
- 可控制:哪些节点必须人工确认、哪些失败要回炉,都是图的一部分。
- 可测试:每个节点是一个纯函数式的
state → update,能单独断言。
一句话:循环交给模型决定「做什么」,图交给代码决定「怎么连」。本节先把图的骨架立起来。
12.1.2 三个核心类型:Node、Edge、Graph
最小图引擎只需要三样东西,各自对应一个 dataclass。
Node 是「一步工作」:一个名字,加一个函数。函数的签名固定为 state -> update——吃进当前状态,吐出一份增量更新。名字用于调试和连线。
Edge 是「从哪到哪」:源节点名、目标节点名,外加一个可选的条件函数。条件为空表示「无条件走这条边」;条件存在时,只有当它返回 True 才走。
Graph 是容器:装节点和边,记住入口,提供 run(state) 从入口一路跑到没有后继为止。
State 则是节点之间流动的那份数据,本节用一个普通的 dict[str, Any]。为什么不定义成 dataclass?因为图是通用的——不同图的状态字段千差万别(有的图有 draft,有的图有 tool_call),用一个 dict 最省事,也让「哪个节点写了哪些字段」一眼可读。代价是丢了静态类型检查,等第 16 章要断言轨迹时,我们会用假模型 + 断言把这份松散补回来。
12.1.3 最小实现
完整实现放在 minagent/workflow/graph.py,不到 70 行:
# minagent/workflow/graph.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
State = dict[str, Any]
NodeFn = Callable[[State], dict[str, Any]] # 输入 state,返回「增量更新」
CondFn = Callable[[State], bool] # 输入 state,返回是否走这条边
@dataclass
class Node:
name: str
fn: NodeFn
@dataclass
class Edge:
src: str
dst: str
cond: CondFn | None = None # None 表示无条件
@dataclass
class Graph:
nodes: dict[str, Node] = field(default_factory=dict)
edges: list[Edge] = field(default_factory=list)
entry: str | None = None
def add_node(self, name: str, fn: NodeFn) -> "Graph":
self.nodes[name] = Node(name, fn)
return self
def add_edge(self, src: str, dst: str, cond: CondFn | None = None) -> "Graph":
self.edges.append(Edge(src, dst, cond))
return self
def set_entry(self, name: str) -> "Graph":
self.entry = name
return self
def _next(self, name: str, state: State) -> str | None:
for e in self.edges:
if e.src == name and (e.cond is None or e.cond(state)):
return e.dst
return None
def run(self, state: State, max_steps: int = 25, on_node=None) -> State:
cur = self.entry
steps = 0
while cur is not None:
steps += 1
if steps > max_steps:
raise RuntimeError(f"超过 max_steps={max_steps},图未收敛")
before = dict(state)
update = self.nodes[cur].fn(state) or {}
state.update(update)
if on_node is not None:
on_node(cur, before, dict(state))
cur = self._next(cur, state)
return state
逐点拆解:
add_node/add_edge/set_entry都返回self:这样能链式调用,建图时读起来像一句话(见 12.1.5)。这是 builder 模式的老技巧。_next返回「第一条满足条件的边」:这就是「状态机」的本质——任何时刻只有一个当前节点,下一步唯一。多条边同时满足时只走第一条,所以边的顺序有意义,条件要写成互斥的(12.2 讲分支时会更小心)。run里state.update(update):节点返回的是增量,不是整份状态。这样每个节点只需声明「我改了哪几个字段」,没碰的字段原样保留。update可能是None(节点只读不写),用or {}兜底。max_steps是防死循环的保险丝:线性图绝不会触发它,但一旦图里有环(12.2 会用到),这条保险丝就是最后的兜底——宁可抛错,也不能让进程卡死。on_node钩子:一个可选回调,每个节点执行完触发一次,用于打印轨迹。它就是第 3 章on_step在图上的翻版。
12.1.4 跑一条线性链:分类 → 检索 → 综合
造一个最朴素的三节点流水线:模型先把问题分类,代码按类别检索一段参考文本,模型再基于参考综合出答案。前两步里第一步用假模型,第二步是纯代码,第三步又用假模型。
from minagent.fake import FakeModel
from minagent.types import Message
from minagent.workflow.graph import Graph
DOCS = {
"python": "GIL 是 CPython 的全局解释器锁,同一时刻只允许一个线程执行字节码。",
"async": "asyncio 用单个事件循环在单线程内并发调度协程。",
}
MODEL = FakeModel([
"python", # classify 节点:模型判定类别
"GIL 是 CPython 的全局解释器锁,它限制了多线程的并行度。", # synthesize 节点:模型给答案
])
def classify(state):
resp = MODEL.complete([Message(role="user", content=state["question"])], [])
return {"category": resp.message.content.strip()}
def retrieve(state):
cat = state["category"]
return {"docs": [DOCS[cat]] if cat in DOCS else []}
def synthesize(state):
context = "\n".join(state["docs"])
resp = MODEL.complete([Message(role="user", content=context)], [])
return {"answer": resp.message.content.strip()}
g = (
Graph()
.add_node("classify", classify)
.add_node("retrieve", retrieve)
.add_node("synthesize", synthesize)
.add_edge("classify", "retrieve")
.add_edge("retrieve", "synthesize")
.set_entry("classify")
)
def show(name, before, after):
print(f"[{name}]")
print(f" in : {before}")
print(f" out: {after}")
final = g.run({"question": "Python 的 GIL 是什么?"}, on_node=show)
print("answer:", final["answer"])
三处细节值得留意。假模型脚本只有两项——因为整张图里只有 classify 和 synthesize 两个节点会调模型,retrieve 是纯代码。这正体现了图编排的分工:模型只在需要判断或生成的地方出现,其余骨架用普通函数。retrieve 只读 state["category"],不关心它是谁写的——节点之间只通过 state 的字段名耦合,不通过调用关系耦合。链式建图让「节点定义」和「边定义」分成两段,读起来清爽。
真跑一遍(Python 3.14.6),每个节点的进出 state 都打印出来:
[classify]
in : {'question': 'Python 的 GIL 是什么?'}
out: {'question': 'Python 的 GIL 是什么?', 'category': 'python'}
[retrieve]
in : {'question': 'Python 的 GIL 是什么?', 'category': 'python'}
out: {'question': 'Python 的 GIL 是什么?', 'category': 'python', 'docs': ['GIL 是 CPython 的全局解释器锁,同一时刻只允许一个线程执行字节码。']}
[synthesize]
in : {'question': 'Python 的 GIL 是什么?', 'category': 'python', 'docs': ['GIL 是 CPython 的全局解释器锁,同一时刻只允许一个线程执行字节码。']}
out: {'question': 'Python 的 GIL 是什么?', 'category': 'python', 'docs': ['GIL 是 CPython 的全局解释器锁,同一时刻只允许一个线程执行字节码。'], 'answer': 'GIL 是 CPython 的全局解释器锁,它限制了多线程的并行度。'}
answer: GIL 是 CPython 的全局解释器锁,它限制了多线程的并行度。
注意每个节点的 in 恰好是上一个节点的 out,而 out 又比 in 多出一个字段:classify 添了 category,retrieve 添了 docs,synthesize 添了 answer。这就是增量更新最直观的效果——状态像滚雪球一样,字段只增不改,谁贡献了哪个字段一目了然。调试时,只要盯住某个字段是在哪个节点第一次出现的,就能定位它是谁写的。
反过来也要提醒一句:增量更新有个隐含约定——节点只写自己「负责」的字段,别去改别人的。retrieve 不该顺手把 question 覆盖掉,synthesize 也不该去动 docs。这不是引擎强制的,而是团队约定;一旦某个节点乱写别人的字段,状态就会变成一锅粥,in/out 的对照也就失去意义。想让引擎帮忙兜底,可以在 run 里加一条断言:节点的 update 键集合必须是它声明的字段的子集。这条检查本节省略了,留作练习。
12.1.5 图编排 vs 循环式 Agent
现在把两种范式摆在一起,做个取舍。它们不是谁取代谁,而是两种表达力的互补:
| 维度 | 循环式 Agent | 图编排 |
|---|---|---|
| 下一步由谁定 | 模型临场决定 | 代码在边里写死 |
| 结构确定性 | 低(每次可能不同) | 高(逐字节可复现) |
| 适合的任务 | 开放式、路径未知 | 固定流程、步骤已知 |
| 调试难度 | 高(要看模型每一步想了啥) | 低(按节点断点) |
| 人工介入点 | 难以精确指定 | 显式标在节点上 |
| 灵活性 | 极高 | 受限于预定义的结构 |
| 典型场景 | 研究助手、代码修复 | 审批、管道、内容生产 |
一条实用的判断法则是:如果这个任务的步骤「写下来不超过一屏、且顺序基本不变」,就用图;如果步骤取决于中间结果、可能来回横跳,就用循环。 更多时候,真实系统是两者嵌套——一张图里,某个节点内部跑一个循环式 Agent。第 13 章的多 Agent 协作,本质上就是这种嵌套的展开。
还有一点常被忽略:图带来的不只是「确定性」,更是可解释性。当用户问「为什么这封邮件发出去了」,你可以指着图回答「因为它走过了 校验 节点且返回 pass」,而不是去翻模型那几百条思考日志。对要追责的业务系统,这份「结构上的可解释」往往比模型本身的可解释更重要。
图还有一个循环给不了的能力:把「人工确认」变成一个显式节点(12.3 详述),让「人在哪一步介入」成为可审计的结构,而不是散落在代码各处的 if。
12.1.6 真实模型下会怎样
本节用的是假模型,两个节点会调模型的地方都返回写死的内容。换成真实 Claude 后,图的骨架一行不用改,但节点的行为会变得不确定:classify 可能这次判成 python、下次判成 language(多吐一个词、或吐一句解释),于是 retrieve 的查表可能落空;synthesize 生成的答案也不再是固定一句,而是随上下文浮动。
这带来两个真实工程里必须处理的问题。其一,节点输出要校验:不能假设模型老老实实只返回类别名,得在 classify 里加一层解析(提取关键词、比对候选集),失败就走兜底分支——这正是 12.2 条件边的用武之地。其二,模型的不确定性被图「关进笼子」:因为结构固定,我们可以精确地说「只有 classify 和 synthesize 这两个节点受模型影响」,其余节点的行为完全可预测。相比整条链路都由模型驱动的循环式 Agent,图编排把「不确定性」限制在了几个明确的点上,测试与审计的难度因此大幅下降。这也解释了为什么生产系统偏爱图:它不消灭模型的随机性,而是把它圈在可控的边界内。
下一节 12.2 分支、合并与循环 会把本节这条笔直的线,扩成有分叉、有汇合、还有回炉重试的网。
阅读导航:上一节:11.3 重规划 · 下一节:12.2 分支、合并与循环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。