LangGraph 状态管理:State、Reducer 与 Message
建 LangGraph 的第一件事不是写节点,而是划分 State。
State 就像一块公共笔记板:prompt、历史消息、中间结果、计数器……所有节点需要共享的数据都放在这里。字段又叫 Channel。
搞懂 State 的读写规则和 Reducer 的合并逻辑,后面写复杂 Agent 才不会乱。
State 的三层视角
LangGraph 允许你为不同节点定义不同的 State 视角,但底层框架为了注入和高可用,实际是把所有 State 混合在一起的。
| 类型 | 作用 |
|---|---|
| InputState | 约束 invoke() 的入参,调用时只填这里定义的字段 |
| OutputState | 约束 invoke() 的返回值,result = graph.invoke(...) 只能拿到这些字段 |
| PrivateState | 内部字段,不用传给 StateGraph(),框架会自动映射 |
几个容易误解的点:
- 节点 return 时,不需要严格按 OutputState 来写——只要 return 的 key 在底层混合 State 里存在,框架就会自动更新
- PrivateState 只是声明,用到了框架会自动处理,不用手动传
- 真正影响对外接口的是 InputState 和 OutputState
用一个例子说明 InputState 和 OutputState 的边界:
1 | class InputState(TypedDict): |
调用时:
1 | result = graph.invoke({"messages": [HumanMessage("你好")]}) |
中间过程的 llm_calls、debug_trace 对外不可见,但节点内部可以自由读写。这就是 Input/Output State 的价值——控制 Agent 的对外接口。
Reducer:字段怎么更新?
默认情况下,节点 return 某个字段会直接覆盖旧值。但很多场景需要合并而不是替换——比如多个子任务各自返回一份分析结果,父图要汇总成列表。
Reducer 就是决定「这个字段怎么更新」的函数:
1 | def merge_analyses(left: list, right: list): |
节点 return {"analyses": [new_item]} 时,框架会自动调用 merge_analyses(old, [new_item]),而不是直接覆盖。
常见坑:父图要汇总多个子图结果(比如多份 RCA 分析),但没写 Reducer → 后写的顶掉先写的,汇总就「失效」了。遇到列表聚合,第一反应应该是加 Reducer。
LangChain 内置了 operator.add 用于列表追加,简单场景直接用:
1 | from operator import add |
Reducer 的工作原理
Annotated 的写法是 Annotated[字段类型, reducer函数]。节点 return 时,框架自动拿旧值和新值调用 reducer:
1 | 旧值 = state["analyses"] # 比如 [A, B] |
没指定 reducer 的字段,return 会直接覆盖旧值。写图之前对每个字段想一句:这个字段是替换还是合并? 合并就必须加 reducer。
Message:LLM 应用最常见的 State 字段
Message 在 LLM 应用里太常见了,框架单独做了优化。
追加 vs 覆盖
- 历史消息一般追加即可
- 人工介入修改某条消息时,需要覆盖原来的——带上消息的
id,框架会用新内容替换旧内容而不是追加
框架提供了 add_messages Reducer 来处理这两种情况。如果自己管理 Message,需要手动指定;用 MessagesState 则自动适配,开箱即用。
MessagesState 是 LangGraph 最常用的 State 基类,Agent 项目几乎都会用到:
1 | from langgraph.graph import MessagesState |
继承 MessagesState 后,messages 字段自动带 add_messages reducer——追加、覆盖都帮你处理好了。
人工介入:覆盖某条消息
Human-in-the-loop 场景下,用户可能修改 AI 的某条回复再让 Agent 继续。带上消息的 id 就能覆盖而不是追加:
1 | return {"messages": [AIMessage(content="修改后的内容", id="msg_123")]} |
1 | {"messages": [HumanMessage(content="你好")]} |
流式传输的安全问题
流式返回时,框架为了方便会暴露所有用过的 State 字段——这意味着敏感中间数据可能在传输过程中泄露。
两种防护手段:
1 | stream_events(..., output_keys=["symptoms", "final_report"]) |
生产环境如果 Agent 处理的是用户隐私或内部数据,这一点必须检查。
两种 stream 模式对比
| 模式 | 行为 | 适用场景 |
|---|---|---|
stream_mode="values" |
每步返回 State 全量快照 | 调试、UI 展示完整状态 |
stream_mode="updates" |
每步只返回本次节点的增量 | 生产环境、减少数据暴露 |
推荐生产环境默认用 updates,调试时切换 values 看全量。
Node 与 State 的关系
Node 是一个同步或异步的 Python 函数,接收 config、State、Runtime 等参数。
几个实操注意点:
1. Checkpoint 重试要幂等
用了 checkpointer 后,节点可能被执行多次。如果节点里有写数据库、发请求等副作用,必须保证幂等,否则重试会出脏数据。
2. @task 和 interrupt() 的 replay 机制
一旦在 node 里用了 @task 或 interrupt(),恢复执行不是从断点那一行继续,而是从头 replay + 用缓存填结果。
所以代码顺序必须稳定,否则 replay 时会拿错缓存。
3. END 节点
所有执行路径的最终节点都应该是 END。漏了 END,图可能跑不完或行为不确定。
节点缓存
LangGraph 支持在编译时给节点加缓存,参数由两部分组成:
- key_func:组织缓存键的字符串(默认字符串相等即命中)
- 持续时间:缓存过期时间
框架没有提供自定义命中策略的入口——输入字符串一样就算命中。如果需要更精细的缓存控制,得在节点内部自己实现。
实战:SRE Agent 里的 State 设计
用一个真实场景来理解 State 和 Reducer 的配合——SRE 故障诊断 Agent:
- Supervisor 节点拆出多个故障主题 → 写入
subjects: list[str] - Send 并行派多个 RCA Worker → 每个返回一份
rca_analysis - 父图 Reducer 把多份分析合并到
rca_analyses_list
如果第 3 步没写 Reducer,后返回的 Worker 会直接覆盖前面的结果,汇总就「失效」了——这是实际项目里最常见的 State 设计失误。
1 | def merge_rca_analyses(left: list, right: list): |
实战:设计 State 的检查清单
开始写图之前,先回答这几个问题:
- 哪些数据要在节点间共享?→ 放进 State
- 哪些字段需要合并而不是覆盖?→ 加 Reducer
- 哪些字段只给内部用?→ PrivateState
- 对外
invoke()只需要暴露什么?→ OutputState - 流式输出会暴露哪些字段?→ 检查敏感数据
- 有没有 checkpoint 重试?→ 副作用操作要幂等