AI Agent 工程化开发实战(三):用 StateGraph 给智能体搭一条可回放的状态流水线

写到 Agent 工程化的第三篇,绕不开一个现实问题:节点一多、一并行,状态就开始互相踩。检索节点刚塞进去的文档被重试节点整份覆盖,打分结果在两个分支里各写各的,日志对完根本说不清哪一步的结果进了最终上下文。

常规做法是自己维护一个全局 dict 或类属性,靠约定"只写自己负责的 key"。问题在于:增量合并要手写、并发顺序不可控、出错后想从中间某步重跑更是无从下手,约定一多就没人记得全。

本文分享一套可直接落地的状态流水线方案:类型化 State + 声明式 Reducer + 条件边路由 + SQLite 检查点,四件套串起来就能得到一条能并行、能归约、能回放的图执行链路。

一、状态失控在哪:全局变量模式的三宗罪

先把病灶钉死:多节点共享一份可变状态时,最常见的三种翻车现场如下。

  • 写覆盖:后执行的节点返回整份 state,前一个节点辛苦查到的工具结果被直接抹掉。
  • 并发竞争:并行分支同时读改写同一个 dict,最终值取决于调度顺序,复现不了。
  • 无法回放:状态散落在内存变量里,线上出错时不能从某个中间节点重跑一次。

三种常见承载方式的差别,一张表就能看清:

方式 并行安全 增量合并 断点回放 代码侵入
全局 dict / 类属性 否 手写 否 低
payload 沿链路传递 部分 手写 部分 中
StateGraph + Reducer 是 声明式 是 中

**核心结论:**状态不是一个"大家共用的变量",而是一份"每个节点只能追加、系统负责归约、随时可回放的账本"。

二、状态声明:Annotated 类型里藏着 Reducer

状态怎么定义决定后面一切:LangGraph 的 State 就是一个 TypedDict,真正的机关写在类型注解上。

python 复制代码
import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END

class AgentState(TypedDict):
    question: str                                   # 无 Reducer:后者覆盖前者
    docs: Annotated[list[str], operator.add]        # 有 Reducer:多分支结果自动拼接
    score: Annotated[float, max]                    # 自定义 Reducer:保留最高分

Annotated[类型, 归约函数] 就是状态字段的更新协议,框架在节点返回后按它合并,业务代码不用自己写 merge。

字段写法 更新语义 适用场景
question: str Last-write-wins 覆盖 输入参数、配置
Annotated[list, operator.add] 逐次追加 文档、消息、工具调用记录
Annotated[T, custom_fn] 自定义归约 计分、集合去重、布尔标志

**核心结论:**把"谁负责合并"从节点里挪到类型声明里,节点才能只关心自己的业务。

三、节点与普通边:函数签名就是状态契约

节点的写法可以压到最简:接收 state,返回增量 dict,其余交给框架。

python 复制代码
def retrieve(state: AgentState) -> dict:
    return {"docs": [f"doc-{state['question']}"]}

def grade(state: AgentState) -> dict:
    return {"score": max(state["score"], 0.8)}

g = StateGraph(AgentState)
g.add_node("retrieve", retrieve)
g.add_node("grade", grade)
g.add_edge(START, "retrieve")
g.add_edge("retrieve", "grade")
g.add_edge("grade", END)
app = g.compile()

print(app.invoke({"question": "顺序消息为何乱", "docs": [], "score": 0.0}))
  • 只返回增量:节点返回的 key 才会参与合并,没写的字段保持原样,天然避免覆盖。
  • 只连显式边 :add_edge 定义了唯一执行顺序,同一父节点下多条出边即为并行扇出。

执行链路:节点返回增量 → Reducer 归约 → 写回共享 State → 下游节点读到合并后的值。

**核心结论:**节点的入参类型和返回 dict 就是它与系统的全部契约,契约之外的字段它碰不到。

四、条件边:把分支判断从节点里剥出来

分支不该藏在 if 里,交给返回目标 key 的路由函数更可测:

python 复制代码
from langgraph.graph import END

def router(state: AgentState) -> str:
    if not state["docs"]:
        return "retrieve"      # 没检索到就重试
    return "grade"

g.add_conditional_edges(
    "retrieve",
    router,
    {"retrieve": "retrieve", "grade": "grade", "END": END},
)

路由返回值与图行为一一对应:

路由返回 后续节点 典型场景
"grade" 进入打分节点 拿到文档,正常推进
"retrieve" 回到检索节点 命中为空,换关键词重试
"END" 结束并输出 重试次数耗尽或已达标
  • 路由纯函数 :router 只读 state、不改 state,可以脱离图单独写单测。
  • 映射表显式声明 :{...} 里列全所有出口,漏写会在编译期直接报错,而不是运行时静默走偏。

**核心结论:**把 if 从节点搬到边,状态更新与流程控制彻底解耦,重试逻辑改一行映射即可。

五、Reducer 取舍:追加、去重、覆盖各有代价

三种最常用的归约写法,选错了会出现重复结果或结果丢失。

python 复制代码
import operator

def merge_set(old: set, new: set) -> set:
    return old | new          # 集合并集:天然去重

# 1) 追加:并行分支的 docs 会拼成一条长列表
docs: Annotated[list[str], operator.add]

# 2) 去重合并:适合 tag、已访问 URL
tags: Annotated[set[str], merge_set]

# 3) 覆盖:无 Reducer 字段,只给配置类参数用
question: str
  • 问题:operator.add 对 list 只会无脑拼接,重试十次就留下十份重复文档,上下文被撑爆。
  • 治理:节点内按 doc_id 先去重,或干脆把字段类型换成 set + merge_set 归约。
  • 问题:给计分字段用默认覆盖,两个并行分支的结果必然丢一个。
  • 治理:用 max、sum 这类自定义 Reducer,让"谁该赢"写进类型而不是写进运气。

**核心结论:**Reducer 是状态的合并策略表,先想清楚"多份结果该合成什么",再决定字段类型。

六、检查点与回放:把状态落库随时续跑

compile 时挂上 checkpointer,每步状态都会落盘,断点续跑只需要一行。

python 复制代码
from langgraph.checkpoint.sqlite import SqliteSaver

with SqliteSaver.from_conn_string("agent.db") as memory:
    app = g.compile(checkpointer=memory)
    cfg = {"configurable": {"thread_id": "case-001"}}
    app.invoke({"question": "顺序消息为何乱", "docs": [], "score": 0.0}, cfg)

    # 出错后沿用上一次快照,只继续没跑完的节点
    app.invoke(None, cfg)

    snap = app.get_state(cfg)
    print(snap.values["docs"], snap.next)   # 看当前状态与待执行节点
  • thread_id 是会话主键:同一个 id 共享一条状态链,换 id 就是全新会话。
  • invoke(None, cfg) 表示续跑 :框架读取最近一条 checkpoint,从 snap.next 指出的节点继续。
  • 排查顺序 :先看 snap.next 定位卡在哪,再看 snap.values 判断是数据问题还是路由问题。

**核心结论:**状态一旦落库,调试从"重跑整个链路"变成"重放一个节点",线上排障成本直接降一个量级。

结语

这一篇把状态管理拆成了四个可独立验证的动作:用 TypedDict + Annotated 声明字段协议,用节点返回增量、普通边定顺序、条件边做路由,用 Reducer 决定多份结果如何归约,最后用 SQLite 检查点把每一步状态固化下来。四步合起来,Agent 从"跑一次看运气"变成"每一步可观察、可单测、可重放",后面接人工审批、多 Agent 协作时也能直接复用这套状态底座。

状态定义清楚了,图就只是一张能被随时重放的调度表。

相关推荐
liyi_hz20081 小时前
O2OA(翱途) V10.1 即将发布|功能再升级,安全再加固,协同更高效!
java·javascript·windows·开源软件
itmigrate1 小时前
Rust 里的所有权误区:不是不写 clone 就叫高性能
开发语言·后端·rust
Brilliantwxx1 小时前
【Linux】 文件访问与路径解析:目录、文件名、dentry、路径缓冲与 dcache 全解析
linux·运维·服务器·开发语言·spring
鬼手点金1 小时前
KV Cache 未必是一次性的:可编辑、可组合的“笔记”
java·人工智能·笔记·学习·自动化·1024程序员节
摇滚侠2 小时前
《On Java 中文版 基础卷》阅读笔记 初始化和清理 08
java·开发语言·笔记
xiangyun612 小时前
C++ 手撕 shared_ptr:引用计数与控制块到底长什么样?
开发语言·c++·算法·面试·stl
海鸥-w2 小时前
Redis 配置与排错说明
java·数据库·redis·bootstrap
XiYang-DING2 小时前
P4009.和为K的子数组个数 & P5522.异或和为 k 的子数组个数
java·开发语言
TinyKey2 小时前
malloc_debug_notes
java