03 · LangGraph 编排
学完本章,你应能从任务需求中列出图的状态字段,说明节点返回值如何写回状态,解释普通边与条件边的区别,并沿着一次具体执行追踪有界循环。你还要能区分“图的逻辑允许恢复”和“状态已持久保存”:仓库练习使用内存 checkpointer,不能据此推断进程重启后仍可恢复。
为什么把流程写成图
Section titled “为什么把流程写成图”如果程序只是依次调用 A、B、C,普通函数足够清楚。Agent 工作流通常有分支和回边:工具执行失败后决定重试还是结束;检索之后判断证据是否足够;有证据才回答,否则改写问题再检索。把流程摊在一段循环与条件语句里,状态、分支条件、失败出口容易散落在多处。LangGraph 用节点与边显式表达这些关系,并让运行时按图推进。
图描述的是可执行的转移结构,不是自动正确的决策策略。节点里写错了判断,图仍会照常执行;回边也不会自动有终止保证。设计时要回答三个问题:每一步需要读写什么状态?什么条件选择下一步?每条循环路径在什么条件下停止?
State:节点之间传递的数据契约
Section titled “State:节点之间传递的数据契约”State 描述一次图运行中可被节点读取和更新的字段。仓库练习在 lessons/03_langgraph/main.py 中定义了 SearchState,字段包括 query、attempts、max_attempts、documents、answer 和 trace。它用 TypedDict(total=False) 声明,因此类型定义本身不要求每个字段始终出现;run() 在启动时提供了完整初始值,而节点读取时也对若干字段使用默认值。
可把每个字段看作工作流的数据契约,而不是“模型记忆”。query 是当前检索词,documents 是本次检索拿到的片段,attempts 是已经执行的检索次数,max_attempts 是本次运行的预算,answer 是终点结果,trace 是此练习显式累积的节点名。若一个节点需要一个关键值,却只能通过模块全局变量或不透明副作用取得,通常说明状态契约缺了字段,或节点职责没有划清。
State 中也不该无差别塞入整个对话、全部文档或密钥。状态会在节点间传递,也可能被 checkpoint 保存。只放后续节点确实需要、且适合保留的数据;体积、敏感度和生命周期都应纳入设计。
Node:读取状态并返回更新
Section titled “Node:读取状态并返回更新”LangGraph 节点通常是接受当前状态并返回部分更新的函数。它不必构造一份全新的完整 State。练习中的 _retrieve 读取查询、执行确定性的词项匹配,然后返回 attempts、documents 与 trace 三个更新;_rewrite 仅更新 query 与 trace;_answer 根据已有文档生成答案文本。
这种“部分更新”有利于限制节点的写权限:检索节点不应该顺手重写用户问题,回答节点不应该暗中改次数上限。真实应用还常把纯计算与外部 I/O 分开:查询规范化、路由判断适合做确定性单元测试;网络检索、模型调用则需要对超时、限流、无结果和格式错误进行处理。不要把“调用成功”当作一定有证据或一定会得到有效答案。
节点返回什么,并不等于它已经决定了下一节点。节点完成后,图根据静态边或条件边继续运行。将“做一件事”与“决定接下来做什么”分开,能让路由规则更容易测试。
Edge 与条件路由:显式描述控制流
Section titled “Edge 与条件路由:显式描述控制流”练习中的 build_graph() 建立四个节点:retrieve、rewrite、answer 和 no_answer。入口边是 START → retrieve;检索后调用 _route,再由返回标签映射到 answer、rewrite 或 no_answer;rewrite → retrieve 构成回边;两个终点节点都连接 END。
_route 的顺序表达了业务优先级:先检查是否找到文档;找到就回答。没找到时,再检查 attempts 是否达到 max_attempts;达到就输出无答案,否则改写查询。顺序不能随意调换:若先按次数上限结束,最后一次检索即便已找到文档,也可能被错误丢弃。条件边返回的标签必须与映射表一致,否则图无法按预期路由。
图可以包含环,不等同于只按拓扑顺序执行一次的 DAG。一个 DAG 没有回边,通常可以按拓扑序安排节点;带回边的图可以在条件满足时多次执行相同节点。循环适合重试或逐步修正,但每次重试要么消耗预算,要么使状态朝可解释的退出条件变化。单纯“再来一次”若不更新查询、计数或外部条件,可能造成无界成本。
Reducer:多个更新如何合并
Section titled “Reducer:多个更新如何合并”每个状态字段需要明确更新规则。若同一节点步骤对字段写入一个新值,常见行为是用新值替换旧值;如果多个节点在同一步并行写同一字段,就需要字段的 reducer 来定义如何合并更新。比如计数值可以设计为求和,事件列表可以设计为追加,集合可以做并集。Reducer 是状态字段的合并规则,不是路由函数,也不是“把所有东西自动累加”的开关。
本练习的 SearchState 没有显式 reducer。_retrieve 返回 trace: [*old_trace, "retrieve"],也就是节点自己根据旧值构造完整的新列表,再由新值替换旧值;attempts 也由节点读取旧数后加一。不要把这里的手工列表复制误称为 LangGraph 的 reducer。真实图若使用列表追加 reducer,还要考虑重放或并发更新是否会让事件重复;选择合并操作时应检查它是否满足所需的顺序、幂等性与冲突语义。
沿着实际执行追踪状态
Section titled “沿着实际执行追踪状态”运行仓库里的入口:
uv sync --extra exercisesuv run --extra exercises python lessons/03_langgraph/main.pyuv run --extra exercises python -m unittest discover -s lessons/tests -vmain() 分别运行 invalid inputs 和 lunar potatoes。第一条查询的轨迹可以逐步推出:
| 步骤 | 执行节点 | 关键状态变化 | 下一步 |
|---|---|---|---|
| 初始 | — | query="invalid inputs",attempts=0,文档为空 |
retrieve |
| 1 | retrieve |
第一次查询不包含规则指定的领域词;attempts=1,无文档 |
rewrite |
| 2 | rewrite |
已知短语 invalid inputs 被映射为 tool parameter validation |
retrieve |
| 3 | retrieve |
命中工具参数验证语料;attempts=2 |
answer |
| 4 | answer |
答案取自命中的文档,轨迹结束 | END |
这里的“检索”只是硬编码语料上的确定性词项规则,不是向量搜索,也没有模型改写。答案也是把命中句子拼接出来,不是生成式 RAG。固定数据让练习能无 API Key、无网络地测试控制流。
lunar potatoes 不会命中词项,也不属于专门映射的同义改写。_rewrite 会加上 revised,随后再次检索;若仍无命中,直到第三次检索耗尽预算后走 no_answer。设置 max_attempts=1 时,只执行一次检索便结束。测试覆盖了已知改写路径、checkpoint 的已完成状态、一次检索上限及未知查询不应凭空得到无关证据。
Checkpoint 能做什么,不能证明什么
Section titled “Checkpoint 能做什么,不能证明什么”练习用 InMemorySaver() 编译图,并以 thread_id 标识运行线程。测试在图完成后用相同配置调用 get_state(),检查已保存的线程快照。它证明这个进程中的内存 checkpointer 能在该线程中保存已完成状态;它不证明中断后能继续执行,也不证明关闭 Python 进程后数据还存在。进程退出时,内存内容随之消失。
checkpoint 也不是长期记忆。checkpoint 保存的是某条工作流运行的状态,常用于查看进度或恢复运行;长期记忆是跨任务或跨会话检索的信息,需要独立的数据模型、写入规则、访问控制、过期与删除策略。选择持久存储还要检查序列化、并发、敏感字段、版本升级和故障恢复。不要仅因配置了 thread_id 就声称已实现跨重启持久化。
失败诊断与测试设计
Section titled “失败诊断与测试设计”遇到循环不终止,先查计数在哪里更新、计数是否和实际尝试定义一致、路由判断是否能到达终点,以及节点是否可能在失败时抛异常而绕开预期分支。遇到过早无答案,检查是否先判断证据、次数是否在正确时机增加、上限是允许尝试次数还是重试次数。遇到回答缺少内容,检查检索节点是否找到证据,而不是先怀疑生成节点。
确定性图适合用输入—输出测试验证路由边界。至少覆盖:一次检索成功、需要改写后成功、完全无匹配耗尽上限、上限为 1、无效上限(练习对小于 1 的值抛 ValueError)。对外部检索器还要独立测试超时和异常处理。固定语料的单元测试证明的是规则与转移,不是线上检索质量。
- 不改代码,写出
run("invalid inputs")的trace、最终attempts和answer来源。再解释为何第一轮不命中。 - 预测
run("lunar potatoes", max_attempts=1)的轨迹与结束原因。说明rewrite为什么不会在这次运行中发生。 - 设计练习,不要求改造现有图:若改写由外部模型完成,列出两种模型失败(超时、空查询等)时的终止路径,以及重试预算如何计入成本。
- 概念伪代码,不可直接运行:设计一个
sources字段,在每条来源上保存文档 ID 与段落位置;写出节点更新规则,说明同一来源重复出现时是覆盖、去重还是保留多次命中,并解释理由。 - 给一个图加入两条并行分支,它们都会追加
trace。说明如果无 reducer 时让两条分支都改同一字段,合并语义为何不可忽略;再设计一个可重复运行而不重复事件的策略。
练习答案与诊断
Section titled “练习答案与诊断”- 轨迹为
retrieve → rewrite → retrieve → answer,检索两次。第一轮只按查询词与代码中的固定领域词集合求交;invalid inputs不在集合中。第二轮查询被映射成验证领域词,命中固定句子。答案来自CORPUS["tool-errors"],没有 LLM 参与。 - 轨迹为
retrieve → no_answer,attempts=1。第一次检索后文档为空,路由检查到次数已达上限,直接选无答案出口。若出现rewrite,说明路由条件或上限解释与当前代码不一致。 - 将外部模型错误作为显式状态或节点结果处理,而非无限重试。设总调用上限和总时间预算;重试前检查查询是否有变化、是否仍有剩余预算。超时可有限重试,空查询应修复或结束;预算用尽时返回可观察的错误或无答案状态。
- 每个来源至少保留稳定 ID 和片段位置,答案节点据此回溯原文。若希望
sources表示唯一来源,用 ID 做去重键;若要保留排序和命中次数,则把检索结果作为有序列表,不能未经定义就并集去重。 - 并行节点可能同时读到同一旧列表并各自返回更新;没有明确 reducer 时不能假设运行时会按期望顺序追加。可先收集带唯一事件 ID 的结果,在汇合节点排序并去重,再写入
trace;如果使用追加 reducer,也要评估重放导致的重复记录。
问:State、Node、Edge 和 reducer 各自解决什么问题? State 定义节点共享的数据契约;Node 读取状态并执行一步,通常返回部分更新;Edge 表示固定控制流,条件边根据状态选择后继;Reducer 定义多个更新合并到同一字段时的规则。路由决定“去哪”,reducer 决定“值怎么合并”,二者不能混为一谈。
问:怎样证明带回边的 Agent 图会在预算内停止? 指出一个单调消耗且有上限的量,例如检索次数或总工具调用数;验证每次回边都消耗它;在上限分支设置终点;测试达到上限、边界上限和节点异常。还要检查并行分支、重试中间件或异常处理是否能绕开预算。只看一次运行结束,不构成终止保证。
问:checkpoint 与长期记忆有什么不同? checkpoint 面向一条图运行,保存恢复所需的状态快照,通常按 thread 标识访问;长期记忆面向未来任务的知识复用,需要选择哪些事实写入、谁能读、何时过期以及如何删除。InMemorySaver 在进程内可用,但不支持从该演示推导出进程重启后的持久恢复。
- 能沿实际代码说明每个节点读写的状态字段。
- 能区分静态边、条件边和回边,并给循环指出明确上限。
- 能解释示例
trace是节点手动更新,不是 reducer 自动追加。 - 能准确说出
InMemorySaver的进程内限制。 - 能区分测试覆盖控制流与测试证明检索质量。