用 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_inputintent 两个 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 变化。

几个实用的辅助手段:

  1. 每个节点入口打 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
    
  2. 用 LangSmith,这是 LangChain 官方的可观测性平台,接入成本低,能看到每一步的 state 完整快照、token 消耗、延迟分布。

  3. 写一个可视化 dump 工具,把 graph 渲染成 mermaid 图,存到 state 里方便排查。

  4. 单步执行模式,开发时把图改成一次只跑一个节点,方便观察每个节点的输入输出。

关于 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 的设计原则:

  1. State 字段越少越好。每个字段都是潜在的同步、序列化、命名冲突问题。能用局部变量解决的不要塞到 state 里。
  2. 明确哪些字段是累加的、哪些是覆盖的。Annotated + reducer 写清楚,不要依赖隐式行为。
  3. 节点函数只做一件事:读 state、做处理、return 新 state。不要在里面塞条件分支。
  4. Conditional edge 函数保持纯函数,不写副作用。
  5. 一定有 max_iterations 类的熔断机制
  6. 可观测性从第一天就接,不要等出问题了再补。

LangGraph 本身是个不错的抽象,能把复杂的 Agent 流程梳理成清晰的数据流图。但这个抽象不是银弹,状态设计的复杂度从「代码里散落的 if-else」转移到了「State 结构和 reducer 的设计」。如果前期不把状态设计清楚,后期改起来一样痛苦。

posted @ 2026-07-07 10:00  疯狂的yang  阅读(0)  评论(0)    收藏  举报