用 LangGraph 重写自己手写的 Agent:框架替你解决了什么,什么它不管

用 LangGraph 重写自己手写的 Agent:框架替你解决了什么,什么它不管

摘要:P2 结项后带着问题进 P3 第一周------用 LangGraph 把手写了三轮迭代的项目①(ReAct 正则 → Function Calling → 多轮历史)推倒重写。本文逐项清点"删掉的代码":while 循环变成回边、终止条件变成条件边、JSON 参数解析被归一化 tool_calls 吃掉、历史持久化变成 compile(checkpointer=...) 一行;同时清点"原样迁移的代码":工具 Schema、错误翻译回传、业务级防死循环一行没动------框架管的是图结构,模型侧工程它一寸不管,两个维度正交。另有三个手写版没有的能力实测:并行节点四路 fan-out 0.5s 收工(串行 2s)、SQLite 检查点崩溃后恢复且不重做已完成步骤、Time-travel 同一存档点重放可复现/分岔改结局。附跨进程会话恢复实录------week6 的"重启即失忆"就此终结。

背景

上篇结尾留的话:带着"框架替你解决了什么、没解决什么"的问题去看框架,手写过六站链路的人看框架,看到的东西不一样。本周兑现这句话。

重写的对象是项目①:一个从零手写、经历三次迭代的 Agent------week4 正则解析版(30 行提示词协议的代价)、week5 Function Calling 版(Pydantic 三合一消灭契约错位)、week6 多轮历史版(token 预算 + 裁剪 + 滚雪球摘要)。重写之前有三个悬而未决的问题:

第一,重写的验收标准是什么。"换种写法"不是标准。标准是能逐项说清:手写版哪段代码在图版里去哪了、哪些代码根本不该动。说不清任何一件,就还是"只会调框架"。

第二,week6 的遗留问题。多轮历史管理的收尾文档里写着:历史只在内存------重启即失忆,多会话并发无从谈起,"P3 的 Checkpoint 持久化正是解这个的"。这是一句承诺,本周要兑现成代码。

第三,手写版从来没碰过的三个能力:并行、持久化、回放。while 循环里塞不进这些------塞进去就等于自己造框架。它们是"什么时候该用框架"这个问题最直接的答案。

实现

1. 先把 while 拆成零件:State / Node / Edge / 条件边

动框架之前先做一个零成本练习:不调任何 API,用图重写 while 循环本身。对照表:

手写版零件 LangGraph 零件 本质变化
messages 列表 State(共享状态) 不止存消息,还能存任意业务字段
一段函数调用 Node(节点) 每个节点只做一件事,可独立测试
顺序执行 Edge(普通边) 声明"A 完了到 B",不再靠代码顺序
if/elif 分支 条件边(路由函数) 函数返回"下一站的名字"
while 循环 回边(边指回上游) 循环成为图结构的一部分

最关键的约束是路由函数:只看 State,返回下一站的名字,不执行任何东西。分支逻辑从"调用谁"变成"报出名字"。这个约束初看别扭,实则是后面一切的地基------执行计划变成显式的图之后,才能被绘制、被静态分析、被逐超步存档。可视化、checkpoint、time-travel 全部建立在这上面。

循环的图形态:work 节点的条件边指回自己,外加一个出口。week4 的 while step <= MAX_STEPS 变成一条回边加一个路由函数;MAX_STEPS 变成 recursion_limit(下文有换算的坑)。

还有一个后面反复出场的主角------reducer:

python 复制代码
class LoopState(TypedDict):
    count: int
    log: Annotated[list[str], operator.add]   # 声明"多次写入怎么合"

节点返回的永远是增量而非全量,框架负责合并。同一个机制两种用法:同一节点多次执行(循环)时拼接历史;多个节点同时执行(并行)时合并结果。

2. 重写主体:删掉了什么,留下了什么

图的形状:

rust 复制代码
             ┌──────────────────────────────┐
             │                              │
    START -> agent --(tool_calls 为空)--> END
             │  ▲
    (重复3次)│  │ (回边 = while 循环本体)
             ▼  │
           abort  tools -> agent

框架替你解决的(手写版被删掉的代码)

手写版代码 图版落点
while True: tools -> agent 回边
if not msg.tool_calls: return 条件边返回 END
messages.append(...) 状态维护 add_messages reducer(节点只返回增量)
json.loads(tc["arguments"]) + 截断重试 langchain 归一化 tool_callsargs 直接是 dict
历史存取(week6:内存变量) compile(checkpointer=...) 一行
手写 print 每步调试 stream(updates) / get_state 内建

第四行值得展开:week5 手写的 JSON 解析层(参数被截断、不是合法 JSON、重试提示)被框架整个吃掉------这是实打实少写的一层防御,也是"框架替你解决了什么"最诚实的答案之一。

框架不替你解决的(原样迁移的代码)

保留物 说明
6 个工具 + Pydantic 三合一 tools.py 从 week6 整文件迁移,唯一改动是 Schema 输出格式(见踩坑 5)
错误翻译回传四层路径 注册表 → 参数解析 → Pydantic 校验 → 业务执行,每层失败翻译成模型能修复的 Observation
业务级防死循环 框架只有物理熔断;"同一动作重复 3 次"是业务语义,得自己写

结论一句话:工具解决"模型怎么用对手",与"循环由谁驱动"是两个正交的维度。换了四轮循环的写法,工具层一行没动------这不是巧合,是分层正确。

新增的框架概念各有落点。State 双通道:messages 用内建 reducer,signaturesoperator.add 自声明------把死循环计数从 run() 局部变量升格为 State 一等字段,副产品是它自动获得被 checkpoint 持久化的资格。条件边三岔路由(tools / abort / END):abort 是个正式节点,因为图版没有"函数可跳",终止原因写成一条 AIMessage 进入消息历史------也因此进入检查点,重启后仍可追溯"当时为什么停"。

防死循环由此变成三层,各拦不同的失败模式:

防线 层级 机制
业务熔断 语义层 同一动作签名连续 3 次 → abort 节点
物理熔断 框架层 超步上限 recursion_limitGraphRecursionError
设计层 提示词层 工具描述写清适用边界,减少绕圈

week4 只有两道防线;框架把"物理上限"标准化了,业务判定仍然自己写。

3. 并行节点:reducer 不是可选项

测试图是一个"出行情报员":dispatch 同时派出三路天气采集 + 一路计算,fan-in 到 merge 汇总。同一节点的多条出边,目标节点在同一超步(superstep)内并发调度------LLM 时代这个特性格外值钱:三路采集就是三次模型/工具调用,串行等 3 倍延迟,并行只等最慢一路。

并行的前提是合并语义提前声明:reports: Annotated[list[str], operator.add]。把这行去掉,运行时直接 InvalidUpdateError------一个通道一个超步只能接受一次写入。这个报错不是 bug,是框架在逼你想清楚"并发写谁说了算"。手写版没有这个问题,因为手写版根本没有并发;一旦要并发,"合并语义"就是绕不开的设计决策,框架只是把它从"上线后数据诡异丢失"提前成了"跑不起来"。

4. Checkpoint:崩溃、恢复与"不重做"

演示图是一条四步流水线(intake → translate → polish → title),每步带耗时模拟,work_log 用 reducer 记录完成痕迹。四幕演示:

arduino 复制代码
run     完整跑通(对照组),work_log 四条
crash   polish 步骤中途抛异常"被杀"------work_log 停在两条
resume  invoke(None, config) 从断点继续------work_log 补齐四条,无重复
history 列出检查点时间线,每超步一个存档点

能"不重做"的机理在超步语义:checkpoint 在超步边界提交。节点执行中崩溃时,上一个超步的成果已经落盘、本节点的写入还没发生------断点恰好落在这个缝隙里。

resume 的核心只有一行:graph.invoke(None, config)------输入为 None 表示"没有新输入,从上次停的地方继续"。断点续跑、human-in-the-loop 的恢复、time-travel 的重放,用的都是这同一个入口。

thread_id 是会话的钥匙:同 thread 恢复继续,不同 thread 互不可见------多会话并发隔离的最小机制。

最有说服力的验收在 Agent 本体上:进程 1 里告诉 Agent"我叫小明,今年 28 岁",exit;进程 2 重新启动,开头打出 [已恢复会话](记忆 8 条消息),问"我叫什么名字"------答"小明,28 岁"。week6 的"重启即失忆"终结。实现上没有一行存取代码:手写版要把 history 传来传去、自己裁剪自己存;图版 invoke 的输入永远是增量,存量在存储后端。

5. Time-travel:存档读档与改写历史

checkpointer 不只是存档------它保存完整轨迹(每个超步一个快照)。于是两个手写版做不到的操作,都只是"带着 checkpoint_id 的 invoke":

重放(replay)invoke(None, 带checkpoint_id的config),从那个时刻重新执行后续节点。演示图是写作审核循环(draft → review → 不通过 → revise → 再审),烂初稿改两轮才被强制放行;回到"初稿刚完成"的存档点重放,结局相同(rounds=2)。确定性图的重放可复现------调试价值:某一步走歪了,回到走歪之前的那一格,看它到底怎么走的。

分岔(fork) :同一存档点,invoke(新输入, 带checkpoint_id的config),在那一格注入不同输入,长出一条新世界线。同一存档点注入好初稿,一轮通过(rounds=0)。最终时间线上两条世界线的检查点共存(checkpoint id 前缀肉眼可辨)。"如果当时用户换个说法,结局会怎样"------不改代码的对照实验。

运行效果

五站验收(真模型 deepseek-chat):

内容 验收结果
1 概念四例(零成本) 循环/分支/reducer/stream 全通
2 Agent 重写 + 跨进程恢复 见下
3 并行 fan-out 三城 + 计算同一秒开始,各 0.5s;采集段总耗时 0.5s,串行至少 2.0s
4 断点续跑四幕 crash 后 work_log 两条;resume 补齐四条无重复
5 Time-travel 重放 rounds=2 可复现;分岔 rounds=0,两世界线共存

一个计划外的收获:重写后的 Agent 第一问就并行调用了两个工具 ------一条 AIMessage 里 get_weathercalculator 两个 tool_calls,tools 节点逐个执行后一并回传。week5 手写版要自己写"解析多个 tool_calls 逐个执行"的循环,图版这个能力是白送的。

踩坑记录

按"卡住的时间"排序:

  1. operator 不在 typing from typing import Annotated, TypedDict, operator 静态不报错、运行直接炸------operator 是独立标准库模块,必须单独 import。顺带的经验:Annotated 的第二个参数(reducer)是运行时才求值的,这行错误在 import 阶段就炸,还算是走运的。
  2. 快照的 .next 是元组get_state_history 拿到的 next 形如 ('review',),直接 f"{nxt:<16}" 格式化抛 TypeError: unsupported format string passed to tuple.__format__。先 ",".join(nxt)
  3. fork 注入的是输入通道,不是状态 。带 checkpoint_id 的 invoke 传入新输入时,被注入的是图的输入字段,因此消费它的节点会重新执行(注入 draft_inputdraft 节点重跑)------不是从字面意义的"那一格"直接续跑。想给中间状态打补丁,应该用 update_state()。两种"回到过去"改的东西不一样:一个改输入,一个改状态。
  4. ChatOpenAI 构造期就校验 key 。不能在模块顶层无条件实例化,否则无 key 的零成本自测在 import 阶段就炸------沿用 RAG 周的惰性初始化(get_llm() 单例)。
  5. bind_tools 吃扁平格式{"name", "description", "parameters"},不是 OpenAI 的嵌套 {"type":"function","function":{...}}。这也是工具层迁移时的唯一改动点:消费者变了,格式跟着变,内容一字未动。
  6. recursion_limit 管的是超步不是工具轮数 。agent → tools → agent 一圈是 2 个超步,默认 25 只够约 12 轮工具调用------对照 week4 的 MAX_STEPS=8 设 24。换成"轮数"心算会差一倍。

总结

项目①的第四次迭代完成:同一个 Agent,while 版 200 行核心循环,图版核心约 100 行,外加手写版给不出的三个能力(并行、持久化、回放)。

面试题"为什么要用 LangGraph / 框架替你解决了什么"现在可以这么答:我先手写了四轮------正则解析让我知道 stop 参数和格式协议在补什么,Function Calling 让我知道 Schema 设计和错误回传是模型侧工程,多轮历史让我知道上下文管理是运行时问题------然后才用 LangGraph 重写。框架替我解决的:循环变成回边、终止变成条件边、JSON 解析被归一化吃掉、持久化一行接入、可观测内建;它不管的:工具怎么设计、错误怎么翻译给模型、业务级死循环怎么判------这些原样迁移,因为它们与循环由谁驱动正交。反过来,什么场景不该用框架我也有实感:单 Agent、两三个工具、一次性问答------week4 那个计算器 Agent 手写 200 行更清楚;框架的价值在循环复杂化(分支/并行/人工介入)、状态要持久化、需要回放取证时才兑现。

下周进入 MCP:把工具从"进程内函数"变成"独立进程的服务",Host/Client/Server 三者关系、三大原语、stdio 与 HTTP 两种传输------工具侧工程不变,交付方式升级。

相关推荐
能不能静下心来看1 小时前
从 RAG 到 Agent:跑通两个 Demo 后,我终于分清了这两个词
agent
ZGi.ai1 小时前
知识库权限变了,怎样避免答错?
agent·权限管理·知识库·工作流·zgi·客服运营
大模型真好玩2 小时前
DeepSeek Harness 入门很简单(四)——DeepSeek Harness接入插件
人工智能·agent·deepseek
掰头战士2 小时前
MCP、Skill、Plugin,都是给agent拓展能力,到底有何区别?
typescript·llm·agent
嘟嘟嘟95273 小时前
AI Agent 的边缘困境
人工智能·架构·agent
HYDtomako3 小时前
Agent checkpoint设计
ai·agent·checkpoint·claude code
梦在远山后4 小时前
从需求到架构:我的企业研发 Agent 整体设计
langchain·agent
DigitalOcean4 小时前
Kimi K3 + Claude:AI Agent 多模型路由实战
llm·agent
掰头战士4 小时前
聊聊 Function Calling,你的模型是否答非所问?
前端·typescript·agent