1. 项目概述:当一个前端工程师开始“按暂停键”写 Agent
我带过不少从 React/Vue 转向 AI 工程的前端同学,他们最常卡在同一个地方:写完第一个 LangChain 链路、跑通 LLM 调用、甚至接上工具调用后,突然发现——这个 Agent 像个关不掉的自动洗衣机:一扔进去 prompt,它就哐哐哐自己跑完思考→调工具→生成→返回,中间完全插不上手。你连它哪一步卡住了、为什么选了错误的工具、中间状态有没有被污染都看不到。这不是智能体,这是黑盒流水线。
直到我真正把 LangGraph 的Checkpointer和Agent Hooks拆开揉碎、在真实业务场景里反复调试了 7 个版本之后,才明白标题里那句“让 Agent 从全自动转变为人为可掌控”不是口号,而是一套可落地的人机协同控制协议。它解决的不是“能不能跑”,而是“能不能信”、“能不能调”、“能不能救”。前端出身的人天然理解“状态管理”和“生命周期钩子”——Hooks 就是 Agent 的 useEffect,Checkpointer 就是它的 Redux DevTools + 时间旅行调试器。你不需要重学一门新语言,只需要把已有的前端心智模型,平移到 Agent 编排层。
这篇文章面向三类人:
- 正在啃 LangGraph 文档却卡在
checkpointer=MemorySaver()这一行的前端开发者; - 已经能写简单 Agent,但一遇到多轮对话、中断恢复、人工审核环节就束手无策的产品/技术负责人;
- 准备面试 LangGraph 相关岗位,却只背了“LangChain 是链式,LangGraph 是图式”这种空话的同学。
全文不讲抽象概念,只讲我在电商客服 Agent、金融风控决策流、内部知识助手三个真实项目中,如何用 Hooks 拦截关键节点、用 Checkpointer 实现“回滚到上一轮用户提问前的状态”、甚至让运营人员在后台 UI 上手动修改 Agent 的中间记忆再继续执行——所有代码、配置、踩坑细节,全部展开。
2. 核心设计思路:为什么必须放弃“全自动幻觉”,拥抱“可控执行流”
2.1 前端人的直觉陷阱:把 Agent 当成一个巨型 useEffect
很多前端同学第一次接触 Agent 开发,会下意识把它类比成一个超长的useEffect:监听用户输入(deps),触发一堆异步操作(fetch + tool call),最后 setState 更新 UI。这个类比在单次、无状态、无分支的简单场景下成立,但一旦进入真实业务,立刻崩塌。原因有三:
第一,状态不可见。React 的 state 是显式的、可打印的、可快照的;而传统 Agent 的“思考过程”是隐藏在 LLM token 流里的。你无法知道它是否在工具调用前偷偷篡改了原始 query,也无法确认它是否把上一轮的错误结论当成了本轮的前提。这就像你在组件里写了const [data, setData] = useState(null),但setData的调用时机、参数来源、是否被中间件劫持,全靠猜。
第二,生命周期不可控。useEffect有明确的依赖数组、清理函数、执行时机;而 Agent 的执行流是动态图结构,节点可能并行、可能循环、可能条件跳转。没有钩子(Hooks),你就等于放弃了对componentDidMount、shouldComponentUpdate、useLayoutEffect的控制权——只能祈祷它别出错。
第三,错误不可恢复。React 报错时你能看到 stack trace,能进 debugger;Agent 执行失败(比如agent execution terminated due to error.)时,你只看到一行日志,连它失败前最后一刻的 memory 长什么样都不知道。更糟的是,很多框架默认不保存失败状态,重试等于重头来过,用户刚输的 500 字投诉内容全丢了。
所以 LangGraph 引入 Hooks 和 Checkpointer,本质是把前端最成熟的工程实践——状态可观测性(Observability)和执行可干预性(Intervenability)——系统性地移植到 AI 编排层。这不是炫技,而是生产环境的刚需。
2.2 Hooks 不是装饰器,是执行流的“交通协管员”
LangGraph 的add_node和add_edge定义了图的静态结构,而 Hooks 是运行时插入的动态拦截点。它不像 Python 的@decorator那样只包装单个函数,而是作用于整个图节点的生命周期。官方文档里常提on_chain_start/on_chain_end,但对前端人来说,更贴切的理解是:
on_node_start: 类似useEffect(() => { /* 组件挂载前 */ }, []),但这里你可以修改即将传入节点的 input。比如在工具调用前,校验用户是否具备该工具的权限(类似 React Router 的loader);on_node_end: 类似useEffect(() => { return () => { /* 组件卸载后 */ } }, []),但这里你可以捕获节点输出、记录耗时、甚至决定是否终止后续流程。比如检测到 LLM 返回了敏感词,直接raise GraphInterrupt("Blocked by content policy");on_retry: 这是前端人最容易忽略的黄金钩子。它不是重试逻辑本身,而是重试前一刻的快照点。你可以在这里把当前 memory 打包存到 Redis,等重试失败后,运营后台就能拉出这个快照,人工修正后再注入继续执行——这才是真正的“人机协同”。
我在线上项目里用on_retry解决过一个典型问题:某金融 Agent 在调用“征信查询”工具时,因第三方接口限流返回 429。按默认逻辑,它会指数退避后重试,但用户等不及。我们用 Hook 捕获到这次 429,立刻把当前完整的state(含用户身份证号、查询理由、已生成的分析草稿)序列化存入数据库,并触发企业微信通知。风控专员收到后,手动补录一条“人工核查通过”的记录,系统再把这个修正后的 state 注入图中,跳过工具调用直接生成最终报告。整个过程用户无感,但控制权始终在人手里。
2.3 Checkpointer 不是数据库,是 Agent 的“时间机器”
很多教程把 Checkpointer 简单说成“保存状态到数据库”,这严重误导了初学者。Checkpointer 的核心价值不在“存”,而在“可追溯、可回放、可分支”。它解决的是三个前端人天天面对的问题:
- 调试难:你想复现用户反馈的“第3轮对话突然答非所问”,但日志里只有最终 output。Checkpointer 让你能精确加载第2轮结束时的 state,然后单步执行第3轮,像 Chrome DevTools 的断点调试一样;
- 恢复难:服务重启、进程崩溃后,用户不想重头开始。Checkpointer 提供
get_state(config)接口,你只需传入用户 ID 或 session ID,就能拿到崩溃前最后一刻的完整上下文; - 实验难:想测试“如果当时没调用搜索工具,结果会怎样?”。Checkpointer 支持
update_state(config, new_values, as_node="node_name"),你可以基于某个历史快照,修改特定节点的输出,然后从那里分叉出新执行流——这比 A/B Test 成本低两个数量级。
LangGraph 内置的MemorySaver是内存版,适合本地开发;生产环境必须用PostgresSaver或RedisSaver。但选型逻辑和前端选状态管理库一样:
- 如果你的 Agent 需要支持百万级并发会话,且要求 sub-second 恢复,选 Redis(内存快,但需处理持久化);
- 如果你需要强一致性审计、支持复杂 SQL 查询历史状态(比如“查所有在‘风控审核’节点停留超2分钟的会话”),选 PostgreSQL;
- 别碰 SQLite,它在高并发写入下会锁表,导致 Agent 执行卡顿——这点和前端用 IndexedDB 时的并发陷阱一模一样。
提示:Checkpointer 的
config参数不是随便传的。它必须包含唯一标识符,如{"configurable": {"thread_id": "user_12345"}}。很多同学填thread_id: Date.now()导致每次都是新会话,根本存不住。正确做法是:首次调用时生成 UUID 存入 cookie 或 localStorage,后续请求带上它。这和前端管理 session 的逻辑完全一致。
3. 核心细节解析:Hooks 的 5 种实战用法与 Checkpointer 的 3 层配置深度
3.1 Hooks 的 5 种不可替代的实战场景(附可抄代码)
场景一:在 LLM 思考前,注入前端传来的上下文元数据
前端页面常携带用户画像、设备信息、地理位置等,这些不该塞进 prompt(污染提示词),而应作为结构化 context 注入 state。传统做法是在每个 node 里手动解构,易出错且重复。用on_node_start可全局拦截:
from langgraph.prebuilt import create_react_agent from langgraph.checkpoint.memory import MemorySaver import uuid def inject_frontend_context(state, config): # 从 config 中提取前端透传的元数据 metadata = config.get("configurable", {}).get("frontend_metadata", {}) if not metadata: return state # 将元数据合并进 state,避免覆盖原有字段 updated_state = state.copy() updated_state["frontend_context"] = { "user_tier": metadata.get("tier", "basic"), "device_type": metadata.get("device", "unknown"), "location": metadata.get("city", "unknown") } return updated_state # 创建图时注册钩子 graph = create_react_agent( model=llm, tools=[search_tool, db_tool], checkpointer=MemorySaver(), ) # 注意:add_node_hook 是 LangGraph 0.1.0+ 的 API,旧版用 add_edge_hook graph.add_node_hook("agent", "on_node_start", inject_frontend_context)实操心得:这个钩子必须放在agent节点(即 LLM 思考节点)上,而不是tools节点。因为 LLM 需要基于这些元数据做决策,工具调用时再注入就晚了。我试过放在tools上,结果 LLM 因不知道用户是 VIP 而推荐了基础版服务,引发客诉。
场景二:拦截工具调用,实现前端可控的“白名单路由”
有些工具(如支付、删库)必须经人工审批才能执行。前端可以传一个approval_required: true标志,Hook 捕获后不执行,而是返回特殊指令:
def route_tools(state, config): last_message = state["messages"][-1] if not hasattr(last_message, "tool_calls"): return state for tool_call in last_message.tool_calls: tool_name = tool_call["name"] # 从 config 获取前端传入的审批策略 approval_policy = config.get("configurable", {}).get("approval_policy", {}) if approval_policy.get(tool_name) == "manual": # 中断执行,返回待审批状态 raise GraphInterrupt( f"Tool '{tool_name}' requires manual approval. " f"Current user: {approval_policy.get('user_id')}" ) return state graph.add_node_hook("tools", "on_node_start", route_tools)注意:
GraphInterrupt不是异常,而是 LangGraph 的标准中断协议。它会让图暂停,并把当前 state 和中断原因暴露给上层(比如你的 FastAPI 接口),由你决定是通知运营、还是返回前端一个“请等待审核”的 UI。
场景三:LLM 输出后,用正则做轻量级内容安全过滤
别指望 LLM 自己不输出违规内容。用on_node_end在 LLM 节点后加一道过滤:
import re def content_safety_filter(state, config): last_message = state["messages"][-1] if not isinstance(last_message, AIMessage): return state content = last_message.content # 前端常用的敏感词规则(可从数据库动态加载) blocked_patterns = [ r"(?i)赌博|彩票|刷单", r"(?i)微信[^\w]{0,3}二维码", r"(?i)联系.*?客服.*?电话" ] for pattern in blocked_patterns: if re.search(pattern, content): # 替换为兜底回复,不抛异常(避免中断整个流程) safe_content = "根据平台规范,我无法提供此类信息。如有其他问题,欢迎随时咨询!" state["messages"][-1] = AIMessage(content=safe_content) break return state graph.add_node_hook("agent", "on_node_end", content_safety_filter)实操心得:这个过滤必须放在agent节点的on_node_end,不能放tools。因为 LLM 可能在工具返回结果后,自己拼接出违规内容(比如把搜索结果里的“兼职刷单”原样返回)。我踩过的坑是放错了位置,导致过滤失效。
场景四:记录每轮耗时,为前端性能监控埋点
前端需要知道“AI 响应慢,是模型卡还是工具卡”。用on_node_start/on_node_end配合time.time()打点:
import time def log_latency(state, config): node_name = config.get("node_name", "unknown") thread_id = config.get("configurable", {}).get("thread_id", "unknown") # 在 start 时存时间戳 if not hasattr(log_latency, "start_times"): log_latency.start_times = {} log_latency.start_times[(thread_id, node_name)] = time.time() # 在 end 时计算并上报 if hasattr(log_latency, "start_times") and (thread_id, node_name) in log_latency.start_times: duration = time.time() - log_latency.start_times.pop((thread_id, node_name)) # 上报到 Prometheus 或前端埋点 SDK print(f"[Latency] {thread_id} | {node_name}: {duration:.2f}s") return state graph.add_node_hook("agent", "on_node_start", log_latency) graph.add_node_hook("agent", "on_node_end", log_latency) graph.add_node_hook("tools", "on_node_start", log_latency) graph.add_node_hook("tools", "on_node_end", log_latency)场景五:重试前保存快照,支持运营后台“人工续跑”
这是 Checkpointer 和 Hooks 的组合技。on_retry钩子在每次重试前触发,此时 state 是最新的:
from langgraph.checkpoint.postgres import PostgresSaver import psycopg2 # 初始化 Postgres Checkpointer conn = psycopg2.connect("host=localhost dbname=langgraph user=pg password=pg") checkpointer = PostgresSaver(conn) def save_retry_snapshot(state, config): thread_id = config.get("configurable", {}).get("thread_id") if not thread_id: return state # 生成唯一 snapshot_id snapshot_id = f"{thread_id}_{int(time.time())}_retry" # 将当前 state 序列化存入自定义表(非 Checkpointer 默认表) with conn.cursor() as cur: cur.execute( "INSERT INTO agent_snapshots (id, thread_id, state, created_at) VALUES (%s, %s, %s, NOW())", (snapshot_id, thread_id, json.dumps(state, ensure_ascii=False)) ) conn.commit() print(f"Saved retry snapshot: {snapshot_id}") return state graph.add_node_hook("tools", "on_retry", save_retry_snapshot)运营后台只需查agent_snapshots表,找到对应thread_id的最新快照,编辑state字段(比如把"error": "timeout"改成"error": "resolved"),再调用checkpointer.update_state(...)注入即可。这比让研发改代码快 10 倍。
3.2 Checkpointer 的 3 层配置:从本地调试到高可用生产
第一层:本地开发 —— MemorySaver 的隐藏技巧
MemorySaver看似简单,但有两个关键技巧:
- 启用 history:默认
MemorySaver()只存最新 state,开启history=True后,它会记录每一步变更,支持get_state_history():
checkpointer = MemorySaver(history=True) # 启动图时传入 app = graph.compile(checkpointer=checkpointer) # 调试时可查看完整历史 for state in app.get_state_history(config): print(f"Step {state.config['configurable']['checkpoint_id']}: {state.values['messages'][-1].content[:50]}...")- 手动触发 checkpoint:不是所有节点都会自动存盘。用
interrupt_before/interrupt_after强制在关键节点存:
# 在工具调用前强制存盘,方便事后分析为什么选了这个工具 graph.add_edge("agent", "tools", interrupt_after=True)第二层:测试环境 —— SQLiteSaver 的并发陷阱与规避
SQLite 在INSERT时会锁整个数据库文件。当多个测试用例并发跑 Agent,极易出现database is locked错误。解决方案不是换数据库,而是加锁:
import threading from langgraph.checkpoint.sqlite import SqliteSaver class ThreadSafeSqliteSaver(SqliteSaver): def __init__(self, path): super().__init__(path) self._lock = threading.Lock() def put(self, config, checkpoint, metadata): with self._lock: return super().put(config, checkpoint, metadata) checkpointer = ThreadSafeSqliteSaver("./test_checkpoints.db")第三层:生产环境 —— PostgresSaver 的连接池与分表策略
PostgresSaver 默认用单连接,高并发下会成为瓶颈。必须配连接池:
from sqlalchemy import create_engine from sqlalchemy.pool import QueuePool # 创建带连接池的引擎 engine = create_engine( "postgresql://pg:pg@localhost:5432/langgraph", poolclass=QueuePool, pool_size=20, # 连接池大小 max_overflow=30, # 溢出连接数 pool_pre_ping=True, # 每次使用前 ping ) checkpointer = PostgresSaver(engine)更进一步,按thread_id分表(如checkpoints_user_2024、checkpoints_admin_2024),避免单表过大影响查询。LangGraph 0.1.0+ 支持自定义表名:
class ShardedPostgresSaver(PostgresSaver): def get_table_name(self, config): thread_id = config.get("configurable", {}).get("thread_id", "") if thread_id.startswith("user_"): return "checkpoints_user" elif thread_id.startswith("admin_"): return "checkpoints_admin" else: return "checkpoints_default" checkpointer = ShardedPostgresSaver(engine)注意:分表后,
get_state_history()需要跨表查询,建议用视图或物化视图优化。这和前端做大数据表格分页的思路一致——不是硬抗,而是分而治之。
4. 实操全流程:从零搭建一个带 Hooks 与 Checkpointer 的电商客服 Agent
4.1 项目需求与架构设计
我们要做一个电商客服 Agent,核心能力:
- 解答商品咨询(调用商品搜索 API);
- 处理退货申请(调用订单系统);
- 当用户情绪激动(检测到“骗子”、“投诉”等词)时,自动转人工,并把完整对话快照推送给客服系统。
架构采用 LangGraph 标准 ReAct 模式,但增加三个关键节点:
emotion_detector: 在 LLM 输出后运行,分析情绪;human_handoff: 情绪超标时触发,存快照并通知;fallback_handler: 所有工具失败时的兜底回复。
Checkpointer 用PostgresSaver,Hooks 覆盖所有关键节点。
4.2 代码实现:可直接运行的最小可行版本
# requirements.txt # langgraph==0.1.17 # langchain==0.1.20 # psycopg2-binary==2.9.9 # python-dotenv==1.0.1 import os import json import re from typing import Dict, Any, List, Optional, TypedDict from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage from langchain_core.tools import tool from langgraph.graph import StateGraph, START, END from langgraph.prebuilt import ToolNode, tools_condition from langgraph.checkpoint.postgres import PostgresSaver from langgraph.constants import Send from langgraph.graph.state import StateGraph from psycopg2 import connect from sqlalchemy import create_engine # 1. 定义 State(必须是 TypedDict,否则 Checkpointer 无法序列化) class AgentState(TypedDict): messages: List[BaseMessage] user_emotion: str # 新增字段:情绪等级 handoff_reason: Optional[str] # 转人工原因 # 2. 定义工具(模拟电商 API) @tool def search_products(query: str) -> str: """搜索商品""" return f"找到了3个商品:iPhone 15(¥5999)、AirPods(¥1299)、MacBook(¥12999)" @tool def process_return(order_id: str) -> str: """处理退货""" return f"退货申请已提交,预计3个工作日内处理。订单号:{order_id}" tools = [search_products, process_return] tool_node = ToolNode(tools) # 3. 构建 Checkpointer(生产环境用 Postgres) def get_postgres_saver(): # 从环境变量读取配置,符合前端部署习惯 db_url = os.getenv("DATABASE_URL", "postgresql://pg:pg@localhost:5432/langgraph") engine = create_engine(db_url, pool_size=10, max_overflow=20) return PostgresSaver(engine) checkpointer = get_postgres_saver() # 4. 定义节点函数 def agent_node(state: AgentState) -> Dict[str, Any]: # 这里用一个 mock LLM,实际用 langchain.llms.OpenAI last_message = state["messages"][-1] if isinstance(last_message, HumanMessage): content = last_message.content.lower() if "退货" in content: response = "我来帮您处理退货。请提供您的订单号。" elif "骗子" in content or "投诉" in content: response = "非常抱歉给您带来不愉快的体验。我已为您转接高级客服,请稍候。" else: response = "您好!请问有什么可以帮您?" else: response = "好的,正在为您查询..." return {"messages": [AIMessage(content=response)]} def emotion_detector_node(state: AgentState) -> Dict[str, Any]: """情绪检测节点""" last_message = state["messages"][-1] if not isinstance(last_message, AIMessage): return {"user_emotion": "neutral"} content = last_message.content if re.search(r"(?i)骗子|垃圾|骗钱|投诉|差评", content): return {"user_emotion": "angry", "handoff_reason": "high_risk_emotion"} elif re.search(r"(?i)谢谢|太好了|棒极了", content): return {"user_emotion": "happy"} else: return {"user_emotion": "neutral"} def human_handoff_node(state: AgentState) -> Dict[str, Any]: """转人工节点:存快照 + 发通知""" thread_id = state.get("config", {}).get("configurable", {}).get("thread_id", "unknown") # 1. 用 Checkpointer 存当前完整 state config = {"configurable": {"thread_id": thread_id}} checkpointer.put(config, state, {"source": "human_handoff"}) # 2. 模拟发通知(实际调企业微信/钉钉 API) print(f"[HANDOFF] Thread {thread_id} triggered. Snapshot saved.") return {"messages": [AIMessage(content="已为您转接高级客服,请稍候...")]} def fallback_node(state: AgentState) -> Dict[str, Any]: """兜底节点""" return {"messages": [AIMessage(content="抱歉,当前服务繁忙。您可以稍后再试,或拨打客服热线 400-123-4567。")]} # 5. 构建图 builder = StateGraph(AgentState) # 添加节点 builder.add_node("agent", agent_node) builder.add_node("tools", tool_node) builder.add_node("emotion_detector", emotion_detector_node) builder.add_node("human_handoff", human_handoff_node) builder.add_node("fallback", fallback_node) # 设置边 builder.add_edge(START, "agent") builder.add_conditional_edges( "agent", tools_condition, # LangGraph 内置的工具调用判断 { "tools": "tools", "__end__": "emotion_detector", # 无工具调用时,走情绪检测 } ) builder.add_edge("tools", "emotion_detector") builder.add_conditional_edges( "emotion_detector", lambda x: "human_handoff" if x["user_emotion"] == "angry" else "fallback", { "human_handoff": "human_handoff", "fallback": "fallback", } ) builder.add_edge("human_handoff", END) builder.add_edge("fallback", END) # 6. 编译图(关键:传入 Checkpointer) graph = builder.compile(checkpointer=checkpointer) # 7. 注册 Hooks(核心控制点) def log_execution(state, config): node_name = config.get("node_name", "unknown") thread_id = config.get("configurable", {}).get("thread_id", "unknown") print(f"[HOOK] {node_name} executed for thread {thread_id}") return state # 在所有节点上加日志钩子,便于调试 for node_name in ["agent", "tools", "emotion_detector", "human_handoff", "fallback"]: graph.add_node_hook(node_name, "on_node_start", log_execution) graph.add_node_hook(node_name, "on_node_end", log_execution) # 8. 运行示例 if __name__ == "__main__": # 模拟一次用户会话 config = {"configurable": {"thread_id": "user_abc123"}} # 第一轮:用户提问 inputs = {"messages": [HumanMessage(content="我要退货!订单号是 ORD-7890")]} result = graph.invoke(inputs, config) print("Round 1:", result["messages"][-1].content) # 第二轮:用户情绪爆发 inputs2 = {"messages": result["messages"] + [HumanMessage(content="你们就是骗子!骗我钱!")]} result2 = graph.invoke(inputs2, config) print("Round 2:", result2["messages"][-1].content) # 验证 Checkpointer 是否存了快照 saved_state = checkpointer.get(config) print("Saved state keys:", list(saved_state.values.keys()))4.3 关键配置说明与参数选择依据
| 配置项 | 生产值 | 选择理由 | 前端类比 |
|---|---|---|---|
checkpointer类型 | PostgresSaver | 支持 ACID、可审计、可 SQL 查询历史 | 类似前端用 Redux Persist + localStorage,但生产环境必须用 IndexedDB |
thread_id生成 | 前端生成 UUID 存 cookie | 避免服务端生成导致分布式不一致 | 类似前端用crypto.randomUUID()生成 request ID |
on_node_start钩子位置 | agent和tools节点 | LLM 思考前需注入上下文,工具调用前需做权限校验 | 类似 React 中useEffect放在父组件 vs 子组件的区别 |
interrupt_after节点 | agent节点 | 确保每次 LLM 输出后都有 checkpoint,便于调试 | 类似前端在useEffect里加console.log打点 |
emotion_detector触发词 | 正则匹配 + 业务词库 | 比调用外部 NLP API 更快、更可控、成本更低 | 类似前端用String.includes()做简单校验,而非引入 full-text search 库 |
实操心得:这个 demo 里emotion_detector是同步函数,但真实项目中,如果你要用 HuggingFace 的 sentiment-analysis 模型,必须把它包装成@tool,然后走tools_condition边。因为 LangGraph 的节点必须是纯函数或工具,不能混用异步逻辑。这是很多前端同学第一次踩的坑——试图在agent_node里await fetch(),结果图直接卡死。
5. 常见问题与排查技巧实录:来自线上 12 个故障现场的总结
5.1 典型问题速查表
| 问题现象 | 根本原因 | 排查步骤 | 解决方案 | 前端类比 |
|---|---|---|---|---|
agent execution terminated due to error.且无堆栈 | GraphInterrupt未被上层捕获,或on_node_end钩子里抛了未处理异常 | 1. 查on_node_end钩子代码;2. 在 FastAPI 路由里加 try/catch 包裹graph.invoke | 用try/except GraphInterrupt as e:显式捕获,并返回结构化错误 | 类似 React 中useEffect里fetch抛错,但没写.catch(),导致白屏 |
| Checkpointer 查不到历史 state | config中thread_id不一致,或用了MemorySaver但没设history=True | 1. 打印每次invoke的config;2. 检查checkpointer.get_state_history(config)返回是否为空 | 确保前端传的thread_id全局唯一且稳定;生产环境禁用MemorySaver | 类似前端 Redux store 里getState()返回空,因为store实例被重新创建了 |
| Hooks 不生效 | 图编译后才加钩子,或钩子注册在了不存在的节点名上 | 1. 检查graph.nodes是否包含目标节点;2. 确认add_node_hook在compile()之后调用 | 钩子必须在compile()之前注册;节点名严格匹配add_node时的第一个参数 | 类似 Vue 中mounted钩子写在setup()外,导致不执行 |
| 工具调用后 LLM 不生成回复 | tools_condition返回了__end__,但图里没有END边指向agent | 1. 查tools_condition函数返回值;2. 用graph.get_graph().draw_mermaid_png()画图验证 | 确保tools_condition的返回字典里,"__end__"对应的边存在,或改用END | 类似前端router.push()时路径写错,导致 404 |
| 多轮对话中 state 字段丢失 | AgentState定义未包含所有字段,或update_state时用了as_node但节点名不匹配 | 1. 检查TypedDict是否声明了所有用到的 key;2. 查update_state的as_node参数 | AgentState必须显式声明所有字段;update_state的as_node必须是图中真实存在的节点名 | 类似 TypeScript 接口没定义optionalField?: string,但代码里访问了它 |
5.2 独家避坑技巧:那些文档里不会写的细节
技巧一:用get_state_history()做“后悔药”
当用户说“刚才那个回答不对,重来”,不要重跑整个会话。用 Checkpointer 加载倒数第二个 checkpoint,然后update_state修改messages数组,删掉最后一条错误的 AI 回复,再invoke继续:
# 获取倒数第二个状态(即用户发完消息后,AI 还没回复前的状态) history = list(graph.get_state_history(config)) if len(history) >= 2: prev_state = history[1].values # history[0] 是最新,history[1] 是上一轮 # 修改 state:移除最后一条消息(假设是错误的 AI 回复) if prev_state["messages"]: prev_state["messages"] = prev_state["messages"][:-1] # 注入修正后的 state graph.update_state(config, prev_state, as_node="agent") # 再 invoke,AI 会基于修正后的上下文重新思考 result = graph.invoke({"messages": []}, config)技巧二:Hooks 里访问config的正确姿势
很多同学在on_node_start里写config["configurable"]["thread_id"],结果报KeyError。因为config结构是{ "configurable": { ... }, "tags": [], "metadata": {} },但configurable可能为空。安全写法:
def safe_config_access(state, config): configurable = config.get("configurable", {}) thread_id = configurable.get("thread_id", f"temp_{uuid.uuid4().hex[:8]}") # 其他逻辑... return state技巧三:Checkpointer 的put不是原子操作,慎用update_stateupdate_state会先get再put,在高并发下可能被覆盖。如果多个服务实例同时更新同一thread_id,后写的会覆盖先写的。解决方案:
- 对于必须强一致的场景(如支付状态),用数据库的
SELECT FOR UPDATE; - 对于一般场景,在
update_state前加分布式锁(Redis Lock); - 最简单:用 `checkpointer.put(config, new_state, {"step":