《AI Agent 开发入门》12.1 状态机式编排

循环式 Agent 适合开放式任务,但固定流程需要显式结构。本节手写 minagent 的最小图引擎:Node(名字加一个函数)、Edge(源到目标、可带条件)、Graph(add_node/add_edge/set_entry/run),节点间用 dict 状态传递数据。用假模型真跑一条分类→检索→综合的三节点线性链,打印每个节点的进出 state,并给出图编排与循环式 Agent 的取舍表。

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 分支、合并与循环 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距