用 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_calls,args 直接是 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,signatures 用 operator.add 自声明------把死循环计数从 run() 局部变量升格为 State 一等字段,副产品是它自动获得被 checkpoint 持久化的资格。条件边三岔路由(tools / abort / END):abort 是个正式节点,因为图版没有"函数可跳",终止原因写成一条 AIMessage 进入消息历史------也因此进入检查点,重启后仍可追溯"当时为什么停"。
防死循环由此变成三层,各拦不同的失败模式:
| 防线 | 层级 | 机制 |
|---|---|---|
| 业务熔断 | 语义层 | 同一动作签名连续 3 次 → abort 节点 |
| 物理熔断 | 框架层 | 超步上限 recursion_limit → GraphRecursionError |
| 设计层 | 提示词层 | 工具描述写清适用边界,减少绕圈 |
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_weather 和 calculator 两个 tool_calls,tools 节点逐个执行后一并回传。week5 手写版要自己写"解析多个 tool_calls 逐个执行"的循环,图版这个能力是白送的。
踩坑记录
按"卡住的时间"排序:
operator不在typing里 。from typing import Annotated, TypedDict, operator静态不报错、运行直接炸------operator是独立标准库模块,必须单独 import。顺带的经验:Annotated的第二个参数(reducer)是运行时才求值的,这行错误在 import 阶段就炸,还算是走运的。- 快照的
.next是元组 。get_state_history拿到的next形如('review',),直接f"{nxt:<16}"格式化抛TypeError: unsupported format string passed to tuple.__format__。先",".join(nxt)。 - fork 注入的是输入通道,不是状态 。带 checkpoint_id 的 invoke 传入新输入时,被注入的是图的输入字段,因此消费它的节点会重新执行(注入
draft_input,draft节点重跑)------不是从字面意义的"那一格"直接续跑。想给中间状态打补丁,应该用update_state()。两种"回到过去"改的东西不一样:一个改输入,一个改状态。 ChatOpenAI构造期就校验 key 。不能在模块顶层无条件实例化,否则无 key 的零成本自测在 import 阶段就炸------沿用 RAG 周的惰性初始化(get_llm()单例)。bind_tools吃扁平格式 。{"name", "description", "parameters"},不是 OpenAI 的嵌套{"type":"function","function":{...}}。这也是工具层迁移时的唯一改动点:消费者变了,格式跟着变,内容一字未动。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 两种传输------工具侧工程不变,交付方式升级。