用 LangGraph 写多步骤 Agent,状态没设计好就是给自己挖坑
用 LangGraph 写多步骤 Agent,状态没设计好就是给自己挖坑
最近在用 LangGraph 重构一个内部的数据分析 Agent,从原来的链式调用改成图结构。重构的过程里踩了不少状态相关的坑,整理一下分享给同样在做 Agent 编排的同行。
为什么从 Chain 换到 Graph
原来的实现是用 LangChain 的 Chain 把几个 LLM 调用串起来:先意图识别,再选工具,再执行工具,最后生成回答。流程上没问题,但有两个痛点:
一是分支处理很别扭。比如工具执行失败时需要重试,某些情况下又需要直接跳过工具调用甚至回退到上一步。在 Chain 里只能写一堆 if-else 把流程揉在一起,逻辑越改越乱。
二是状态追踪靠猜。每一步的输入、中间结果、错误信息都得手动塞到 dict 里传来传去,字段命名全靠自觉,三个月后再看代码完全不知道哪个字段是哪个步骤塞的。
LangGraph 的 StateGraph 就是冲着这两个问题来的。状态用 TypedDict 显式声明,节点之间的流转通过 add_edge / add_conditional_edges 显式配置,看起来很美好——但用起来才知道,状态设计本身才是最大的坑。
第一个坑:State 不是简单的数据容器
刚开始的时候我直接把原来 Chain 里传的 dict 平移过来,定义了一个很朴素的 State:
from typing import TypedDict, List
class AgentState(TypedDict):
user_input: str
intent: str
tool_results: List[dict]
final_answer: str
跑起来发现两个问题:节点 A 写入的字段,节点 B 读不到;或者读到了,但是被某个节点的覆盖写丢失了。
根本原因是 LangGraph 的 State 默认是全量覆盖(annotated reducer 除外)。如果某个节点 return {"intent": "query_db"},整个 state 里其他字段不会保留,只剩 user_input 和 intent 两个 key。
正确的做法是用 Annotated + reducer 显式声明合并策略:
from typing import Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
user_input: str
intent: str
# messages 字段使用 add_messages reducer,自动追加而不是覆盖
messages: Annotated[list, add_messages]
# tool_results 需要累加而不是覆盖
tool_results: Annotated[List[dict], lambda a, b: a + b]
final_answer: str
或者更稳的方式:每个节点 return 完整的 state,而不是只 return 变化的部分。这听起来很反直觉,但能避免 90% 的状态丢失问题。
第二个坑:Conditional Edge 的判断条件不写文档就等于自杀
Graph 的优雅在于 add_conditional_edges 可以根据状态动态决定下一步去哪:
def should_retry(state: AgentState) -> str:
if state["retry_count"] >= 3:
return "fallback"
if state["last_error"]:
return "retry"
return "continue"
graph.add_conditional_edges(
"execute_tool",
should_retry,
{
"retry": "execute_tool",
"fallback": "generate_answer",
"continue": "process_result",
},
)
看起来一目了然。但实际项目里这种判断函数会迅速膨胀——retry_count 要不要重置?last_error 什么时候清?某个特定错误码(比如 rate limit)是不是要跳到完全不同的节点?
我的经验是:判断函数里不要做任何副作用(不要改 state,不要打日志,不要触发外部调用),它就是一个纯函数,输入 state 输出路由 key。所有副作用都放在节点里。
另外强烈建议把每个路由的触发条件写成 docstring 或者注释,否则一周后你自己都看不懂为什么这里会跳到 fallback 而不是 continue。
第三个坑:循环和终止条件
Graph 比 Chain 强的地方是可以有循环,弱的地方也在这里——循环写不好就是死循环。
我用的一个模式是引入 iteration 字段作为硬性上限:
class AgentState(TypedDict):
user_input: str
messages: Annotated[list, add_messages]
iteration: int # 显式计数器
max_iterations: int
def should_continue(state: AgentState):
if state["iteration"] >= state["max_iterations"]:
return END
if state["task_completed"]:
return END
return "agent_step"
Agent 类应用一定要有 max_iterations 这种硬性熔断,不要相信 LLM 会在合适的时候说『我完成了』。它会陷入重复调工具的循环里出不来,token 烧得飞快。
第四个坑:调试 Graph 几乎没工具
LangGraph 的调试体验是公认的老大难。节点内部可以 print,但图整体跑起来的时候你看到的只是节点间跳转的 trace,看不到完整的 state 变化。
几个实用的辅助手段:
-
每个节点入口打 state snapshot
def my_node(state: AgentState): logger.debug(f"[my_node] state in: {state}") # ... 业务逻辑 result = ... logger.debug(f"[my_node] state out: {result}") return result -
用 LangSmith,这是 LangChain 官方的可观测性平台,接入成本低,能看到每一步的 state 完整快照、token 消耗、延迟分布。
-
写一个可视化 dump 工具,把 graph 渲染成 mermaid 图,存到 state 里方便排查。
-
单步执行模式,开发时把图改成一次只跑一个节点,方便观察每个节点的输入输出。
关于 Checkpoint 和 Memory
LangGraph 的 Checkpointer 机制(MemorySaver、PostgresSaver 等)让 Graph 支持中断恢复,这在生产环境几乎是必须的——一个 Agent 调用可能跑几十秒甚至几分钟,中间网络抖动、用户取消、模型限流都需要能从断点继续。
但 Checkpointer 也带来新的复杂度:state 里的字段必须是可序列化的。你以为加个 from datetime import datetime 当前时间进去很合理?Pickle 的时候直接报错,告诉你 datetime 对象不是 JSON serializable。
State 字段尽量用基础类型:str、int、float、bool、list、dict。复杂对象(自定义类、datetime、Decimal)要么转成 str 存,要么在节点入口处重新构造。
整体的设计原则
踩了一圈坑之后,我总结几条 State Graph 的设计原则:
- State 字段越少越好。每个字段都是潜在的同步、序列化、命名冲突问题。能用局部变量解决的不要塞到 state 里。
- 明确哪些字段是累加的、哪些是覆盖的。Annotated + reducer 写清楚,不要依赖隐式行为。
- 节点函数只做一件事:读 state、做处理、return 新 state。不要在里面塞条件分支。
- Conditional edge 函数保持纯函数,不写副作用。
- 一定有 max_iterations 类的熔断机制。
- 可观测性从第一天就接,不要等出问题了再补。
LangGraph 本身是个不错的抽象,能把复杂的 Agent 流程梳理成清晰的数据流图。但这个抽象不是银弹,状态设计的复杂度从「代码里散落的 if-else」转移到了「State 结构和 reducer 的设计」。如果前期不把状态设计清楚,后期改起来一样痛苦。

浙公网安备 33010602011771号