☰
LangGraph断点恢复与幂等执行:生产级Agent稳定性实践
2026/10/1 5:25:30 网站建设 项目流程

做完十几个 Agent demo 之后,你会发现一个残酷的事实:在 Jupyter Notebook 里跑得挺顺的智能体,一上生产就原形毕露。用户刷新一下页面,任务重跑一遍,钱扣了两次;凌晨三点模型调用超时,整个流程从头再来,前面写进数据库的状态全得重建;更头疼的是人工审批环节——Agent 运行到一半要等领导点个按钮,结果进程一重启,图的状态没了。LangGraph 断点恢复和幂等执行,就是专门治这三类毛病的。这篇文章我把这套东西从原理到落地一次讲透,代码能直接抄。

在正式动手之前,有必要先说说 LangGraph 在整个技术栈里的位置。现在搜 LangGraph 教程,十个里有八个会纠结它和 LangChain 的区别。我的理解很简单:LangChain 是一把瑞士军刀,里面全是工具函数、模型封装、Prompt 模板这些零件;而 LangGraph 是装刀的战术背心,它关心的是你身上这些装备怎么按顺序掏出来、掏到一半被打断能不能塞回去、事后能不能从某个位置继续掏。它通过状态图的方式组织 Agent 的执行流,让每一步都有迹可循、可停可续,而且把"状态"和"检查点"作为一等公民内置进了框架。

本文会从断点恢复的底层机制讲起,然后进入幂等执行这个工程化绕不开的命题,最后用一个 FastAPI + LangGraph 的实战项目把它们串起来。这个方案适合正在把 Agent 从 Demo 推向生产的开发者,也适合被"AI 下地干活"折磨得怀疑人生的后端工程师。

1. 先搞清楚一个前提:LangGraph 到底在解决什么问题

1.1 LangGraph 和 LangChain 的本质区别

很多人问 LangChain 和 LangGraph 的面试题怎么答,其实就是一行话的事:LangChain 提供了与模型、工具、文档交互的抽象能力,LangGraph 则是把这些能力组织成一张可执行、可暂停、可恢复的状态图。

LangChain 的 Chain 也有顺序执行的逻辑,但这种执行是"一把梭"的——链一旦启动,要么跑完要么失败重来,中间不保留可以被外部干预的状态。LangGraph 则把执行过程建模成由节点(Node)和边(Edge)构成的有向图,每个节点就是一次计算或一个工具调用,边定义了流转规则。关键差异在于,LangGraph 里的图在每次节点运行前后都会生成一个状态快照,也就是 checkpoint,你可以通过它实现时间旅行、条件分支重放以及人工介入。

这种差异带来的直接感受是:用 LangChain 写 Agent,像在流水线上干活,从头到尾一气呵成;用 LangGraph 写 Agent,像在玩有存档的游戏,任何时刻都能存一档、读一档、改一档再继续玩。

建议学习路径也很直接:先用pip install langgraph跑通官方手册中文版里的 ReAct 示例,理解节点、边、状态这三个核心概念,再看本篇文章涉及的状态持久化和中断机制,最后再上手真实业务改造。

1.2 断点恢复的本质:把图执行变成可中断事务

断点恢复,听起来很高端,本质上就是数据库事务里 Savepoint 和 Rollback 的思想搬到了 Agent 编排层。LangGraph 在执行一个节点前会检查是否有可恢复的 checkpoint,如果有,它不会从头跑,而是直接恢复到上次中断后的节点继续执行。

这里涉及三个核心概念:thread_id、checkpointer、checkpoint_id。thread_id 是会话标识,LangGraph 用它区分不同的对话和任务,同一个 thread 的多次执行共享状态;checkpointer 是存储后端,决定 checkpoint 和状态持久化到哪里,比如内存、SQLite、Postgres;checkpoint_id 则是每次图执行生成的状态版本号,相当于游戏存档的时间戳。

你可以这样理解:thread_id 是游戏存档的文件名,checkpointer 是硬盘,checkpoint_id 是存档的时间点。一个 Agent 应用对应多个 thread_id,每个 thread 有自己的一系列 checkpoint_id,当你用同一个 thread_id 再次调用图时,LangGraph 会找最新的 checkpoint 作为起点。

这套机制天然适合 human-in-the-loop 场景:Agent 执行到需要人工审批的节点,先暂停,把状态写好存档,然后告诉外部"我需要人来确认";人类做出决定后,应用再用同一个 thread_id 调用续跑接口,LangGraph 会从断点接着走,而不是把之前的工作推倒重来。

2. 断点恢复的落地姿势:从图级断点到节点级中断

2.1 编译期断点 interrupt_before / interrupt_after

用 LangGraph 实现断点,最直观的方式是在编译图的时候指定断点位置。interrupt_before表示进入指定节点前暂停,interrupt_after表示离开指定节点后暂停。这种方式适合流程固定的场景,比如"必须先审核、后下单"。

代码层面非常简单:

from langgraph.graph import StateGraph, START, END # 假设我们定义了一个简单的 Agent 图 graph = StateGraph(MyState) graph.add_node("plan", plan_node) graph.add_node("execute", execute_node) graph.add_node("review", review_node) graph.add_edge(START, "plan") graph.add_edge("plan", "execute") graph.add_edge("execute", "review") graph.add_edge("review", END) checkpointer = SqliteSaver.from_conn_string("checkpoints.db") # 编译时指定断点 app = graph.compile( checkpointer=checkpointer, interrupt_before=["review"], # 进入 review 前停住 interrupt_after=["execute"], # execute 完成且写入状态后停住 )

这样编译完之后,你调用app.invoke(input, config={"configurable": {"thread_id": "order_001"}}),图会一路执行到 execute 节点,然后停在进入 review 之前。此时图的状态保存在 checkpoints.db 里,进程挂了也不怕。

要恢复,只需要再次调用同一个图,不需要重新传完整输入。很多人这里会踩坑,以为恢复是重新调用一次原来的输入,其实 LangGraph 的逻辑是:只要 thread_id 一致,且该 thread 存在未完成的执行,调用就会尝试从最近的 checkpoint 续跑。可以想象成一个"断点续传"的过程,恢复时只需要传你需要注入的外部决定,比如审批结果:

result = app.invoke( None, # 或传审批结果,取决于业务逻辑 config={"configurable": {"thread_id": "order_001"}} )

执行会从interrupt_before指定的节点重新开始,先执行 review,再继续后续边。编译期断点的缺点是死板,一旦编译就固定了,如果业务流程是动态的,这种方式就不够灵活。

2.2 节点内中断 interrupt() 与 Command(resume)

LangGraph 提供了更灵活的动态中断方式:在节点内部调用interrupt()函数。这个函数的作用相当于在任意指定的执行点"举手暂停",它会把一个 payload 暴露给外部系统(比如前端页面),等待外部通过Command(resume=...)把决定塞回来。

说一个真实业务:Agent 在帮用户下单前,需要确认价格是否可接受。你在"确认价格"这个节点里写:

from langgraph.types import interrupt, Command def confirm_price_node(state): # 准备好给用户看的报价信息 payload = { "order_id": state["order_id"], "total_price": state["total_price"], "items": state["items"], } # 中断,把 payload 暴露出去,等待用户确认 decision = interrupt(payload) if decision.get("approved"): return {"status": "approved"} else: return {"status": "rejected", "reason": decision.get("reason")}

interrupt()被调用后,图的执行立刻暂停,不会继续往下走。此时如果你用app.get_state(config)查看状态,会发现图处于中断状态,且可以拿到interrupt里的 payload。外部应用(比如 FastAPI 接口)把这个 payload 展示给用户,用户点"同意"或"拒绝",应用把决定作为参数传回:

from langgraph.types import Command # 用户点了“同意” app.invoke( Command(resume={"approved": True}), config={"configurable": {"thread_id": "order_001"}} )

Command(resume=...)会唤醒中断的图,把值传给interrupt()的返回值。执行会接着 confirm_price_node 往下走。这种方式实现了真正意义上的动态暂停和外部介入,LangGraph 官方叫它 human-in-the-loop,是断点恢复最强的一个用法。

2.3 状态检查、修改与跳过执行

除了暂停和恢复,我们经常需要在恢复前修改图的状态,或者临时跳过某个节点。LangGraph 提供了get_state和update_state两个接口,用途很像数据库里的查询和 UPDATE。

config = {"configurable": {"thread_id": "order_001"}} # 查看当前 state 和 checkpoint snapshot = app.get_state(config) print(snapshot.values) # 当前状态字段 print(snapshot.next) # 下一步会执行哪些节点 # 手动修改 state app.update_state(config, {"customer_name": "张三"}, as_node="plan")

修改 state 的值会生成一个新的 checkpoint,后续执行基于新的状态继续。这里有个细节:update_state时你可以通过as_node指定以哪个节点的名义写入,这会影响图的状态更新规则。如果你希望跳过某个节点,可以在中断后手动 update_state,把该节点要写入的状态直接写进去,再恢复执行即可。

日常开发里,我通常把"查看状态" "人工修改" "恢复执行"三个动作做成三个独立 API,方便前端根据业务场景自由组合。从实践来看,这种设计比把所有逻辑都塞进一次 invoke 调用更可靠。

3. 幂等执行:让 Agent 重复跑也不会出事

3.1 为什么 LangGraph 不保证幂等

断点恢复解决了流程中断的问题,但引出了下一个问题:既然执行可以被暂停、恢复、甚至重放,那么同一个节点被重复执行怎么办?

LangGraph 本身没有内置幂等机制。原因很直接:框架层面无法判断你的工具调用是不是幂等的。比如一个节点里调用了"发送短信"的接口,这个接口天然不是幂等的;但另一个节点里只是做一次本地计算,重复执行也没关系。框架不知道这些,所以它选择把决定权交给你。

但是 LangGraph 提供了实现幂等的关键信息。每次 invoke 都会生成一个 runtime run_id,每次节点执行后生成 checkpoint_id。你可以把它们当作执行某段逻辑的"流水号",结合业务侧的幂等键,就能判断某段副作用是否已经执行过。

这里强调一句:config里传入的thread_id是会话维度,不是请求维度;同一 session 里有多次 invoke,run_id 每次都会变。所以不要拿 run_id 或 checkpoint_id 当业务幂等键,它们只适合做内部调试和状态追踪。

3.2 幂等设计三件套:幂等键、副作用登记、唯一约束

要让 Agent 的执行变得幂等,我的实战经验是用"三件套":入口生成幂等键、副作用处先登记后执行、数据库加唯一约束。

第一步,在 Agent 任务创建时生成全局唯一的幂等键,这个键要贯穿整个执行流,通常叫run_id,但注意是自己生成的业务 run_id,不是 LangGraph 内部的 run。可以放在 state 最外层,一路往下传。

第二步,在节点执行副作用(发消息、扣积分、调用外部下单接口)之前,先往 operation_log 表插一条记录,字段包括 run_id、操作类型、操作参数、状态。如果插入的时候抛唯一约束冲突,说明同一 run 下这个操作已经执行过,直接跳过副作用逻辑并复用上次的结果。

第三步,在数据库里给(run_id, op_key)建立唯一索引。这里的 op_key 可以是"通知用户""扣减库存"这样的操作标识,也可以是 action 名称加参数哈希。这样即使代码逻辑漏判了,数据库也会拦住第二次执行。

落地到 LangGraph 节点里,基础设施可以做成一个装饰器:

def idempotent_node(op_key): def decorator(func): def wrapper(state): run_id = state["run_id"] log = lookup_operation(run_id, op_key) if log: return log["result"] # 已执行过,直接返回缓存结果 result = func(state) # 首次执行副作用 record_operation(run_id, op_key, result) # 写执行记录 return result return wrapper return decorator @idempotent_node("deduct_inventory") def deduct_inventory_node(state): # 真正的扣减逻辑 return {"inventory_left": state["inventory"] - 1}

这套方案的巧妙之处在于:它把幂等从框架层下沉到了业务层。无论 LangGraph 怎么重放节点、怎么断点续跑,只要 run_id 不变,任何副作用都会被数据库的唯一约束挡住。

3.3 工具调用层的幂等

LangGraph 里最常见的副作用集中在工具调用上。函数调用(tool calling)模型会给每个工具调用生成一个唯一的tool_call_id,这个 ID 在单轮对话内是唯一的。但问题是,如果图执行重放,同一个工具调用可能会被再次触发,LangGraph 自带的 ToolNode 会再次执行该工具。

好在 LangGraph 的 ToolNode 有内置的消息去重机制。当你用langgraph.prebuilt.ToolNode时,如果某个tool_call_id已经出现在历史消息里,ToolNode 会直接使用历史结果而不会再次执行工具。这个行为依赖于 checkpointer 保存的消息列表。所以,只要你的图启用了 checkpointer,并且工具节点用的是官方 ToolNode,一定程度上已经具备按 tool_call_id 去重的能力。

但如果你没用 ToolNode,而是自己写节点处理工具调用,那就必须自己实现幂等。我的做法是在工具执行前检查:当前 tool_call_id 是否已经在状态里的 tool_results 字段中出现过,出现过就直接拿结果,没出现过才执行真正的调用。

def smart_tool_executor(state): messages = state["messages"] last_ai_msg = messages[-1] results = [] for tool_call in last_ai_msg.tool_calls: existed = state["tool_results"].get(tool_call["id"]) if existed is not None: results.append(existed) # 复用历史结果 continue result = real_executor.invoke(tool_call) # 真正执行 state["tool_results"][tool_call["id"]] = result results.append(result) return {"tool_results": state["tool_results"], "messages": results}

这套逻辑在你需要给工具调用加缓存、加审计、加限流的场景下更实用,官方 ToolNode 的自动去重就不够用了。特别是当你对接的资金、积分系统有自己一套幂等凭证体系时,用 tool_call_id 做联动往往比重新建一套更直接。

4. 落地实战:FastAPI + LangGraph 的生产级断点恢复服务

4.1 架构与存储选型:从 SQLite 到 Postgres

断点恢复依赖于 checkpointer,所以选什么存储就决定了你能恢复到什么程度以及能扛多大并发。我把常用三个存储后端放在一起对比,根据部署规模直接挑:

存储后端典型场景并发能力生产可用性备注
MemorySaverDemo、本地调试单进程单线程低,进程重启状态全丢慎用于生产
SqliteSaver单机小规模服务受限于单机中,注意线程锁和文件锁配套代码简单门槛低
PostgresSaver多副本、高可用环境高高,是生产推荐方案需要数据库连接池和迁移脚本

单机场景直接上 SQLite 就够了,连接字符串传一个路径即可。多副本部署、要做负载均衡的话,建议直接上 Postgres。LangGraph 提供了 AsyncPostgresSaver,异步接口配合 FastAPI 的 async 路由非常顺滑。

需要强调一点:postgres_saver 使用前必须创建表结构,官方提供了checkpoint_ddl脚本,不要用 SQLite 的表结构去 Postgres 里跑,字段类型和索引都对不上,踩一次坑少说浪费半小时。

4.2 核心代码:构建带审批的人类介入 Agent

用 FastAPI + LangChain + LangGraph 组合,做一个"审批后通知"的 Agent。业务路径是:创建任务 -> 模拟扣减积分 -> 人工审批 -> 审批通过后发通知。重点演示断点恢复与幂等抑制。

# app.py —— 完整示例骨架 import os from typing import TypedDict, Annotated from fastapi import FastAPI from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.types import interrupt, Command import sqlite3 class AgentState(TypedDict): run_id: str user_id: str points_deducted: bool approval: str notified: bool conn = sqlite3.connect("agent_state.db", check_same_thread=False) conn.execute(""" CREATE TABLE IF NOT EXISTS operation_log ( run_id TEXT, op_key TEXT, result TEXT, PRIMARY KEY (run_id, op_key) ) """) conn.commit() checkpointer = SqliteSaver(conn)

注意连接池的check_same_thread=False,默认的 SQLite 连接不能跨线程用,FastAPI 是并发模型,不关掉会各种报 thread error。

下面定义三个节点。第一个节点扣积分(有工程量),第二个节点做人工审批中断,第三个节点发通知(幂等保护)。

def deduct_points_node(state: AgentState): run_id = state["run_id"] # 幂等:查操作日志 row = conn.execute( "SELECT result FROM operation_log WHERE run_id=? AND op_key=?", (run_id, "deduct_points"), ).fetchone() if row: return {"points_deducted": True} # 已扣过,不重复扣 # 真正扣减积分的代码,这里用 print 模拟 print(f"真实扣减积分:user={state['user_id']}") conn.execute( "INSERT INTO operation_log (run_id, op_key, result) VALUES (?, ?, ?)", (run_id, "deduct_points", "ok"), ) conn.commit() return {"points_deducted": True} def approval_node(state: AgentState): decision = interrupt({ "message": "是否允许扣减积分并发送通知?", "user_id": state["user_id"], "run_id": state["run_id"], }) return {"approval": decision.get("decision", "denied")} def notify_node(state: AgentState): if state["approval"] != "approved": return {"notified": False} run_id = state["run_id"] row = conn.execute( "SELECT result FROM operation_log WHERE run_id=? AND op_key=?", (run_id, "send_notify"), ).fetchone() if row: return {"notified": True} print(f"真实发送通知:user={state['user_id']}") conn.execute( "INSERT INTO operation_log (run_id, op_key, result) VALUES (?, ?, ?)", (run_id, "send_notify", "ok"), ) conn.commit() return {"notified": True}

构建图并编译:

builder = StateGraph(AgentState) builder.add_node("deduct", deduct_points_node) builder.add_node("approval", approval_node) builder.add_node("notify", notify_node) builder.add_edge(START, "deduct") builder.add_edge("deduct", "approval") builder.add_edge("approval", "notify") builder.add_edge("notify", END) app = builder.compile(checkpointer=checkpointer)

FastAPI 路由部分提供三个接口:

from fastapi import FastAPI, HTTPException server = FastAPI() @server.post("/runs") def create_run(user_id: str): import uuid run_id = str(uuid.uuid4()) # 启动图执行,运行到 approval 节点会自动中断 app.invoke( {"run_id": run_id, "user_id": user_id}, config={"configurable": {"thread_id": run_id}}, ) return {"run_id": run_id} @server.get("/runs/{run_id}/state") def get_run_state(run_id: str): snap = app.get_state({"configurable": {"thread_id": run_id}}) return { "status": snap.next, "values": snap.values, "interrupts": [i.payload for i in snap.interrupts] if snap.interrupts else [], } @server.post("/runs/{run_id}/resume") def resume_run(run_id: str, decision: str): snap = app.get_state({"configurable": {"thread_id": run_id}}) if not snap.interrupts: raise HTTPException(status_code=400, detail="当前状态不可恢复") app.invoke( Command(resume={"decision": decision}), config={"configurable": {"thread_id": run_id}}, ) return {"status": "resumed"}

这样前后端就能完成:创建任务 -> 查询审批信息 -> 审批 -> 恢复执行。整个生命周期里,SQLite 里的 checkpoint 保存了每一步状态,operation_log 确保了扣减积分和发通知这两个副作用不会被重复执行。

4.3 完整调用流程演示

用 curl 走一遍完整流程,你会很直观看到断点恢复和幂等是怎么协同的。

创建一次任务,图会一路跑到 approval 节点然后自动暂停:

curl -X POST "http://localhost:8000/runs?user_id=u_100" # 返回 {"run_id": "abc-123"}

查询状态,你会看到interrupts里有审批的 payload,next指向 approval 之后要执行的下一个节点:

curl "http://localhost:8000/runs/abc-123/state"

人工审批通过,恢复执行:

curl -X POST "http://localhost:8000/runs/abc-123/resume" \ -H "Content-Type: application/json" \ -d '{"decision": "approved"}'

此刻的关键点来了:如果你再次手动调用扣积分节点(比如误操作),operation_log 里已经存在(abc-123, deduct_points)这条记录,节点会直接返回{"points_deducted": True},不会真的扣第二次积分。同理,send_notify也不会重复发通知。这个就是业务层的幂等兜底。

如果把整条链路里的每一步都记录到数据库,你甚至可以做"重放审计"——某个 run 到底扣了多少次积分、哪一步是幂等跳过的、哪一步真实执行了,一查便知。

5. 实战中的坑与排查经验

5.1 断点恢复常见的报错与解决办法

工程落地会遇到各种边界情况,我把自己常遇到的几个问题整理成了速查表:

报错/现象根因处理方式
Checkpointer required图里使用了 interrupt 但在 compile 时没传 checkpointercompile(checkpointer=...)
恢复调用不生效,从头开始执行thread_id 不一致,或使用了不同的 config key确认 token 不变;检查 config 拼写
Cannot update non-existent node...update_state 时 as_node 传了一个图里不存在的节点名查看图节点列表,确保 as_node 合法
resume 后状态不是预期值Command(resume) 传参结构不对检查 interrupt() 返回值的接收语义
SQLitedatabase is locked多线程并发写同一个 SQLite 文件开启 WAL 模式或改用 PostgresSaver
恢复后节点重复执行业务副作用没有做幂等保护用 3.2 节的三件套去拦截

SQLite 的锁是最常见的坑,尤其是 FastAPI 多 worker 部署时。建议在初始化连接后执行PRAGMA journal_mode=WAL;,能明显减少并发读写的锁冲突。但说到底,多 worker 部署就别用 SQLite 了,上 PostgresSaver 是正道。

5.2 幂等没生效的几个隐蔽原因

幂等逻辑看着简单,实际有几种隐蔽情况会导致失效。最常见的是幂等键没有贯穿整个调用链。比如你的 run_id 在 create_run 接口生成了,但某个工具节点内部又自己 new 了一个 uuid,那节点侧查 operation_log 时主键永远对不上,每次都会执行真实副作用。建议把幂等键放到 state 的顶层字段,节点里只管 read,不许 write。

第二个隐蔽原因是副作用执行成功但记录写入失败。比如真实扣减积分接口调用成功了,但 operation_log 的 INSERT 因为某种异常没提交,此时 Agent 抛错重试,业务逻辑发现日志里没有记录,又扣了一次。解决办法是把"副作用执行"和"副作用登记"放在同一个事务边界里,最好的方式是把扣减积分与记录日志放到同一个数据库事务,要么都成功要么都失败。

第三个隐蔽原因是参数变化导致的幂等绕过。比如 op_key 只取了操作名,但操作里包含了金额参数,第一次扣 100,第二次改成扣 50,唯一约束认为这是同一条记录,直接跳过了扣 50 的请求。这就要求 op_key 的设计要包含关键的参数指纹,一般是用action + hashlib.md5(sorted_params)。

5.3 生产部署检查清单

最后给一份我每次上线 Agent 服务前都会过一遍的清单,照着做能少走很多弯路:

  • 生产环境 checkpointer 是否选择了 PostgresSaver,并创建了正确的 checkpoint 表?
  • 所有有外部副作用的节点是否都经过幂等装饰器或等效逻辑保护?
  • operation_log表是否建了(run_id, op_key)唯一索引?
  • 关键工具调用是否用 tool_call_id 做了去重或缓存?
  • 恢复接口是否做了并发控制,避免同一个 thread_id 被两个人同时 resume?
  • 是否对get_state、update_state做了操作审计,方便排查人工干预的记录?
  • 是否有兜底超时机制,比如 Agent 长期处于中断状态或恢复失败时,有守护任务负责清理或告警?

关于第 4 条里提到的并发控制,简单做法是在恢复接口层加一把 Redis 锁,锁的 key 是resume:{thread_id},保证同一时刻只有一个 resume 请求真正驱动图继续执行。否则两个请求同时Command(resume=...),状态会乱成一锅粥。

6. 断点状态设计与幂等键命名的一些心得

这里讲一个容易被忽略的细节:断点恢复时,interrupt()的返回值本质上是从Command(resume)里透传过来的,它不会自动做校验。如果你在审批节点里期望收到一个 dict,但恢复端传了字符串,节点代码可能直接报TypeError。所以我习惯在 interrupt 节点里加一层简单的 schema 校验,比如用 Pydantic 解析,解析失败就抛一个自定义异常,FastAPI 层捕获后返回 400。这能避免生产环境出现匪夷所思的恢复错误。

幂等键命名同样有讲究。业界惯例会区分:

  • request_id:客户端发起请求时生成的 ID,用于端到端全链路追踪。
  • idempotency_key:专门用于幂等控制的业务键,同一业务动作多次重试时保持不变。
  • run_id:Agent 任务内部流转用的标识,可以就是idempotency_key,但注意它不是 LangGraph 框架的 runtime run_id。

我通常直接让入口生成的 UUID 同时充当request_id、idempotency_key和thread_id三合一,减少概念数量,降低沟通成本。你可以在日志里同时打印这个值和 LangGraph 内部的 checkpoint_id,方便追踪状态对应关系。

# 日志示例 [RUN abc-123] checkpoint 9f2e... 到达人工审批 [RUN abc-123] checkpoint 9f2e... 恢复执行,审批结果 approved [RUN abc-123] 幂等跳过 send_notify,原因:已执行

最后再分享一个我在实战中坚持的习惯:所有 LangGraph 节点都写成纯函数风格,不直接操作外部全局状态,只通过 state 的输入输出做数据流转。副作用统一收敛到独立的"工具层"或"服务层",节点只做编排。这样做的好处是,当你要加断点、加幂等、加审计时,改动面非常小,每个节点就像积木一样可以自由插拔组合。这套理念配合 LangGraph 的 checkpointer,基本能满足绝大多数生产级 Agent 服务的需求。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询