AI Agent 开发(07):State 设计详解

QuibblerAgentQuibblerAgent 2026-10-25 约 22 分钟 1 次阅读

第 6 篇用 StateGraph 把"模型循环"变成了图:节点是函数,边是路由。但决定一个 Agent 能长多大的,往往是那个容易被一笔带过的 State——所有节点共享它、读写它,图每走一步它就变一次模样。State 设计得草率,循环计数会丢、消息会重复、并行分支会直接报错;设计得讲究,中断恢复、时间旅行、多 Agent 协作才有落点。

本篇是《AI Agent 开发》系列第 7 篇,代码基于 langgraph 1.2.x(langchain-core 1.6.x),环境沿用第 2 篇的 uv 项目。全文按"心智模型 → 覆盖陷阱 → Reducer 机制 → 定义方式选型 → 私有 State → 设计原则"展开,看完你能对自己 Agent 的每个字段做出有依据的设计决策。

1、State 是共享内存

先立结论:State 是随图流转的共享内存,不是数据库。它本质上是一份结构由你定义的 Python 字典:图内所有节点都能读全量、写局部,数据沿边整体传递;一次 invoke 结束,它的生命周期随之结束。没有查询语句、没有索引、也没有默认的持久化——持久化是 checkpointer 的职责,不是 State 的。

节点函数的约定是"收全量、返增量":入参是当前完整 State,返回值只包含本次要修改的字段,未提及的字段原样传给下游。最小示例如下:

from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END

class QAState(TypedDict):
    question: str    # 用户问题
    answer: str      # 最终回答
    docs: list[str]  # 检索到的资料

def retrieve(state: QAState) -> dict:
    docs = [f"关于 {state['question']} 的片段 A", "片段 B"]
    return {"docs": docs}            # 只写要改的字段

def generate(state: QAState) -> dict:
    ctx = "\n".join(state["docs"])   # 能读到全量 State
    return {"answer": f"综合资料作答:{ctx}"}

builder = StateGraph(QAState)
builder.add_node("retrieve", retrieve)
builder.add_node("generate", generate)
builder.add_edge(START, "retrieve")
builder.add_edge("retrieve", "generate")
builder.add_edge("generate", END)
print(builder.compile().invoke({"question": "LangGraph 是什么?"}))

retrieve 只返回 docs,question 不必重复携带;generate 却能同时读到两者——这就是"整体传递、局部更新"。再记一条性质:compile 时接上 checkpointer(本地调试用 InMemorySaver,来自 langgraph.checkpoint.memory),每个超步(superstep)结束都会生成一份不可变快照,后续的中断恢复与时间旅行全部踩在这些快照上,第 12 篇展开。

心智模型:State 是块黑板,节点轮流上前擦写,checkpointer 负责给每一步拍照。

2、同名字段覆盖陷阱

默认更新策略是 LastValue:后写覆盖先写。串行图里它通常正合心意——下游节点"改写"上游结果,覆盖就是语义。但只要两个字段的更新意图是"各自贡献一份",这个默认值就从便利变成陷阱。

看一个对照实验,两份 State 只差一行 Annotated,其余完全相同:

import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END

class Overwrite(TypedDict):
    count: int                          # 默认:覆盖

class Accumulate(TypedDict):
    count: Annotated[int, operator.add] # 声明 reducer:累加

def node_a(state) -> dict:
    return {"count": 1}

def node_b(state) -> dict:
    return {"count": 1}

for schema in (Overwrite, Accumulate):
    builder = StateGraph(schema)
    builder.add_node("a", node_a)
    builder.add_node("b", node_b)
    builder.add_edge(START, "a")
    builder.add_edge("a", "b")
    builder.add_edge("b", END)
    print(schema.__name__, builder.compile().invoke({"count": 0}))

输出如下:

Overwrite {'count': 1}
Accumulate {'count': 2}

两个节点各 +1 的意图,在默认覆盖下 node_b 的 1 直接顶掉了 node_a 的 1,最终计数停在 1——没有报错、没有日志,是最难排查的那类静默 bug。挂上 operator.add 后,LangGraph 在每次写入时执行 count = count + 1,结果才是 2。

覆盖还有个更凶的变体:如果 a、b 是从同一节点并行分出的两支(第 8 篇),两个分支在同一超步写同一个无 reducer 字段,LangGraph 无法裁决谁覆盖谁,直接抛 InvalidUpdateError。所以 reducer 不只是"想要累加时才加的糖",它是并行写入同一字段的前提。

3、Reducer 合并机制

3.1、Annotated 与合并函数

Annotated[T, reducer] 的第二个参数是一个普通二元函数:节点返回 {"count": 1} 时,LangGraph 对每个被更新的字段执行 新值 = reducer(旧值, 1),再用新值合成下一份 State;没有 Annotated 的字段等价于走 reducer = lambda 旧, 新: 新,即覆盖。reducer 只在字段被写入时触发,节点没提的字段原封不动。LangGraph 内部把每个字段实现为一个通道(channel):默认是 LastValue 通道,挂了 reducer 就变成二元聚合通道,机制详见 Graph API 文档。

Annotated[int, operator.add] 之外,更常见的是列表累加:Annotated[list[str], operator.add] 用 + 拼接列表。多分支各自检索、在汇合点自动合并结果,第 8 篇的分支合并模式就建在它上面。类型与函数可以自由组合,只要满足"接收两个同型参数、返回同型结果"的签名。

3.2、add_messages 消息通道

messages 是 Agent State 里最特殊的字段:对话要追加,但模型重写、人工修订时又要能替换旧消息——追加与替换并存,没有任何内置函数能兼任,于是 LangGraph 为它预置了专门的 reducer add_messages。完整演示如下:

from typing import Annotated
from typing_extensions import TypedDict
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

class ChatState(TypedDict):
    messages: Annotated[list, add_messages]

def draft(state: ChatState) -> dict:
    return {"messages": [AIMessage(content="草稿回复", id="ai-1")]}

def revise(state: ChatState) -> dict:
    return {"messages": [AIMessage(content="修订回复", id="ai-1")]}

builder = StateGraph(ChatState)
builder.add_node("draft", draft)
builder.add_node("revise", revise)
builder.add_edge(START, "draft")
builder.add_edge("draft", "revise")
builder.add_edge("revise", END)
result = builder.compile().invoke(
    {"messages": [HumanMessage(content="在吗?", id="human-1")]}
)
for msg in result["messages"]:
    print(msg.id, msg.content)

输出如下:

human-1 在吗?
ai-1 修订回复

draft 与 revise 写的是同一个 id="ai-1",最终列表里只有一条 AI 消息且内容是修订版。add_messages 的规则共三条:无 id 或新 id 的消息追加到尾部;id 与已有消息相同的原位替换;RemoveMessage 按 id 删除,id=REMOVE_ALL_MESSAGES 一次清空(这几个名字都从 langgraph.graph.message 导入)。

两个使用要点。其一,不显式给 id 时 LangChain 会自动生成随机 id,等于永远走追加分支——想替换就必须自己持有并复用 id。其二,这套"按 id 去重更新"正是流式重写上一条消息、人机协同(HITL)人工修订(第 11 篇)、上下文裁剪(第 23 篇)的地基。图省事可以直接 from langgraph.graph import MessagesState 用官方预置定义。细节见 add_messages API 参考。

4、TypedDict 与 Pydantic

定义 State 的容器有两派:前面一直用的 TypedDict,以及 Pydantic BaseModel。换成 Pydantic 的写法如下:

from typing import Annotated
from pydantic import BaseModel, Field
from langgraph.graph.message import add_messages

class AgentState(BaseModel):
    messages: Annotated[list, add_messages] = Field(
        default_factory=list, description="对话消息")
    retries: int = Field(default=0, ge=0, description="重试计数")
    max_retries: int = Field(default=3, ge=1)

结构几乎没变,reducer 照样用 Annotated 挂,差异全在运行时:节点返回 {"retries": "三次"} 这类脏值时,LangGraph 按模型校验写入值,直接抛 ValidationError,错误数据进不了 State;Field(default=...) 让字段天生带默认值,invoke 不传也能跑。代价是每次更新多一层校验开销。两派对比:

维度 TypedDict Pydantic BaseModel
运行时校验 无,注解仅静态检查 写入即校验,脏值抛错
字段默认值 需调用方显式传入 Field(default=...) 声明
IDE 提示 基础补全 字段、描述、类型全量提示
运行时开销 零 每次更新多一层校验

选型建议:一次性脚本与教程 demo 用 TypedDict,零负担;生产项目与多人协作的 Agent 用 Pydantic——校验把脏数据挡在入口,默认值让 State 平滑演进(第 6 节回收这个伏笔)。字段约束语法见 Pydantic 字段文档。

5、私有 State 隐藏内部字段

State 一旦混杂"对外要暴露的"与"纯内部的"两类字段,麻烦就来了:调用方依赖了 scratchpad,以后想重构内部实现就得小心翼翼。LangGraph 的解法是给 StateGraph 加两张进出口清单——input 与 output schema:

import operator
from typing import Annotated
from typing_extensions import TypedDict
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.graph.message import add_messages

class ChatIO(TypedDict):
    """对外接口:进出只认消息列表。"""
    messages: Annotated[list, add_messages]

class AgentState(ChatIO):
    """内部全量 State:多出的字段图外不可见。"""
    scratchpad: str                          # 中间推理笔记
    tool_calls: Annotated[int, operator.add] # 工具调用计数

def think(state: AgentState) -> dict:
    return {"scratchpad": "先拆解问题,再决定检索策略",
            "tool_calls": 1}

def reply(state: AgentState) -> dict:
    return {"messages": [AIMessage(
        content=f"(基于笔记作答:{state['scratchpad']})")]}

ChatIO 是对外契约,AgentState 继承它并追加两个私有字段;节点一律按 AgentState 读写。组装与验证如下:

from langgraph.graph import StateGraph, START, END

builder = StateGraph(AgentState, input=ChatIO, output=ChatIO)
builder.add_node("think", think)
builder.add_node("reply", reply)
builder.add_edge(START, "think")
builder.add_edge("think", "reply")
builder.add_edge("reply", END)
graph = builder.compile()

result = graph.invoke({"messages": [
    HumanMessage(content="查一下 LangGraph 最新版本", id="q-1")
]})
print(result)  # 只有 messages 键,scratchpad 与 tool_calls 不在其中

invoke 的返回值只剩 messages——scratchpad 与 tool_calls 依旧在图内正常流转,但出了图就不可见。要说清一个细节:input/output schema 收窄的是图的进出口,不是节点的可见性,节点拿到的始终是全量 AgentState,别指望用它做字段级权限隔离。它的真实收益是对外接口稳定:内部加多少临时字段、计数器,调用方与下游测试都不受打扰;子图作为节点嵌入父图时(第 24 篇),窄接口也让两边的 State 互不污染。更多用法见 Use the Graph API 指南。

6、State 设计三原则

原则一,字段最小化。只放"跨节点共享"的数据;只在单个节点里用的中间变量,留在函数局部,别上 State。字段每多一个,读代码的人就要多问一句"谁写它、谁读它"。

原则二,一个字段一种合并语义。累加语义配 operator.add,消息语义配 add_messages,"最新值生效"的快照语义用默认覆盖。反模式是一个字段时而想累加、时而想覆盖——那说明它其实是两个字段,拆开。

原则三,演进兼容。新增字段务必给默认值(Pydantic 的 Field(default=...) 或入口节点兜底写入),保证接了 checkpointer 的旧线程在新代码上仍能加载;删字段则要确认旧快照里的数据可以一起丢。配套还有一条体积纪律:checkpointer 对每一步全量快照,把大 JSON 塞进 State 等于把快照成本乘以步数。

三条原则收成一句话:State 的每个字段,都要说清"谁写、谁读、怎么合"。想不清楚的字段,先别加。

7、总结

回望全文:State 是随图流转的共享内存,节点"收全量、返增量",checkpointer 为每个超步拍照;默认覆盖既制造静默丢数也触发并行报错,Annotated 挂 reducer 才能声明合并语义;add_messages 以"追加 + 按 id 替换"统治消息通道;Pydantic 带来校验与默认值;input/output schema 把内部字段关在图里。

留一个练习:为"研究任务 Agent"设计 State,要求三个字段——messages 存对话与工具结果,todos 存待办子任务,done_count 记已完成数量。

  • messages 用 add_messages,done_count 用 Annotated[int, operator.add]
  • 思考题:todos 配 operator.add 还是默认覆盖?提示——完成一项要移出列表,"只进不出"打不了勾
  • 验收:两个节点先后写 done_count,终值是两次之和
  • 加分项:用 Pydantic 定义并给全部字段默认值;再用 output schema 藏起内部字段

对于新起的 Agent,建议从最小 State 起步:先 TypedDict 跑通主链路,出现共享字段后逐个补 reducer,生产化时换 Pydantic 并用 input/output schema 一次定好对外边界。下一篇进入控制流:条件边、循环与递归上限,把"决策 → 行动 → 回到决策"的环真正画进图里,届时第 2 节的并行陷阱会再次登场,不妨先想好对策。

相关推荐

Hermes Agent 详解:自进化学习型 AI 代理
Agent

Hermes Agent 详解:自进化学习型 AI 代理

Hermes Agent 详解:自进化学习型 AI 代理Hermes Agent 是由 Nous Research 于 2026 年 2 月开源的自主 AI 代理框架,其核心定位是"与你共同成长的智能体"。与传统的 IDE 编程助手或单一 API 聊天机器人不同,Hermes 运行在你的服务器上,能够记住所学内容,运行时间越长能力越强。它采用 MIT 开源许可,支持 Linux、macOS 和 W

393
Hermes Agent:与你一起成长的开源 AI 代理
Agent

Hermes Agent:与你一起成长的开源 AI 代理

Hermes Agent:与你一起成长的开源 AI 代理大多数 AI 助手是"健忘的租客"——换个窗口就失忆、换个平台就重学、干过的活不留下任何沉淀。Nous Research 出品的 Hermes Agent 反其道而行,口号是"The Agent That Grows With You"(与你一起成长的代理):它是一个开源(MIT)、自托管的 AI 代理,具备持久记忆(学习你的项目、自动生成技

322
speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线
AI

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线语音 Agent 的"全开源"路线一直有个尴尬:框架开源了,模型却离不开云端 API。huggingface/speech-to-speech 把这条路走通了:VAD → STT → LLM → TTS 四段低延迟流水线,每个组件都可换成开源模型本地跑——Apple Silicon 一台 Mac 就能撑起

264