Skip to content

03 · LangGraph 编排

学完本章,你应能从任务需求中列出图的状态字段,说明节点返回值如何写回状态,解释普通边与条件边的区别,并沿着一次具体执行追踪有界循环。你还要能区分“图的逻辑允许恢复”和“状态已持久保存”:仓库练习使用内存 checkpointer,不能据此推断进程重启后仍可恢复。

如果程序只是依次调用 A、B、C,普通函数足够清楚。Agent 工作流通常有分支和回边:工具执行失败后决定重试还是结束;检索之后判断证据是否足够;有证据才回答,否则改写问题再检索。把流程摊在一段循环与条件语句里,状态、分支条件、失败出口容易散落在多处。LangGraph 用节点与边显式表达这些关系,并让运行时按图推进。

图描述的是可执行的转移结构,不是自动正确的决策策略。节点里写错了判断,图仍会照常执行;回边也不会自动有终止保证。设计时要回答三个问题:每一步需要读写什么状态?什么条件选择下一步?每条循环路径在什么条件下停止?

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 保存。只放后续节点确实需要、且适合保留的数据;体积、敏感度和生命周期都应纳入设计。

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 来定义如何合并更新。比如计数值可以设计为求和,事件列表可以设计为追加,集合可以做并集。Reducer 是状态字段的合并规则,不是路由函数,也不是“把所有东西自动累加”的开关。

本练习的 SearchState 没有显式 reducer。_retrieve 返回 trace: [*old_trace, "retrieve"],也就是节点自己根据旧值构造完整的新列表,再由新值替换旧值;attempts 也由节点读取旧数后加一。不要把这里的手工列表复制误称为 LangGraph 的 reducer。真实图若使用列表追加 reducer,还要考虑重放或并发更新是否会让事件重复;选择合并操作时应检查它是否满足所需的顺序、幂等性与冲突语义。

运行仓库里的入口:

Terminal window
uv sync --extra exercises
uv run --extra exercises python lessons/03_langgraph/main.py
uv run --extra exercises python -m unittest discover -s lessons/tests -v

main() 分别运行 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 就声称已实现跨重启持久化。

遇到循环不终止,先查计数在哪里更新、计数是否和实际尝试定义一致、路由判断是否能到达终点,以及节点是否可能在失败时抛异常而绕开预期分支。遇到过早无答案,检查是否先判断证据、次数是否在正确时机增加、上限是允许尝试次数还是重试次数。遇到回答缺少内容,检查检索节点是否找到证据,而不是先怀疑生成节点。

确定性图适合用输入—输出测试验证路由边界。至少覆盖:一次检索成功、需要改写后成功、完全无匹配耗尽上限、上限为 1、无效上限(练习对小于 1 的值抛 ValueError)。对外部检索器还要独立测试超时和异常处理。固定语料的单元测试证明的是规则与转移,不是线上检索质量。

  1. 不改代码,写出 run("invalid inputs") 的 trace、最终 attempts 和 answer 来源。再解释为何第一轮不命中。
  2. 预测 run("lunar potatoes", max_attempts=1) 的轨迹与结束原因。说明 rewrite 为什么不会在这次运行中发生。
  3. 设计练习,不要求改造现有图:若改写由外部模型完成,列出两种模型失败(超时、空查询等)时的终止路径,以及重试预算如何计入成本。
  4. 概念伪代码,不可直接运行:设计一个 sources 字段,在每条来源上保存文档 ID 与段落位置;写出节点更新规则,说明同一来源重复出现时是覆盖、去重还是保留多次命中,并解释理由。
  5. 给一个图加入两条并行分支,它们都会追加 trace。说明如果无 reducer 时让两条分支都改同一字段,合并语义为何不可忽略;再设计一个可重复运行而不重复事件的策略。
  1. 轨迹为 retrieve → rewrite → retrieve → answer,检索两次。第一轮只按查询词与代码中的固定领域词集合求交;invalid inputs 不在集合中。第二轮查询被映射成验证领域词,命中固定句子。答案来自 CORPUS["tool-errors"],没有 LLM 参与。
  2. 轨迹为 retrieve → no_answer,attempts=1。第一次检索后文档为空,路由检查到次数已达上限,直接选无答案出口。若出现 rewrite,说明路由条件或上限解释与当前代码不一致。
  3. 将外部模型错误作为显式状态或节点结果处理,而非无限重试。设总调用上限和总时间预算;重试前检查查询是否有变化、是否仍有剩余预算。超时可有限重试,空查询应修复或结束;预算用尽时返回可观察的错误或无答案状态。
  4. 每个来源至少保留稳定 ID 和片段位置,答案节点据此回溯原文。若希望 sources 表示唯一来源,用 ID 做去重键;若要保留排序和命中次数,则把检索结果作为有序列表,不能未经定义就并集去重。
  5. 并行节点可能同时读到同一旧列表并各自返回更新;没有明确 reducer 时不能假设运行时会按期望顺序追加。可先收集带唯一事件 ID 的结果,在汇合节点排序并去重,再写入 trace;如果使用追加 reducer,也要评估重放导致的重复记录。

问:State、Node、Edge 和 reducer 各自解决什么问题? State 定义节点共享的数据契约;Node 读取状态并执行一步,通常返回部分更新;Edge 表示固定控制流,条件边根据状态选择后继;Reducer 定义多个更新合并到同一字段时的规则。路由决定“去哪”,reducer 决定“值怎么合并”,二者不能混为一谈。

问:怎样证明带回边的 Agent 图会在预算内停止? 指出一个单调消耗且有上限的量,例如检索次数或总工具调用数;验证每次回边都消耗它;在上限分支设置终点;测试达到上限、边界上限和节点异常。还要检查并行分支、重试中间件或异常处理是否能绕开预算。只看一次运行结束,不构成终止保证。

问:checkpoint 与长期记忆有什么不同? checkpoint 面向一条图运行,保存恢复所需的状态快照,通常按 thread 标识访问;长期记忆面向未来任务的知识复用,需要选择哪些事实写入、谁能读、何时过期以及如何删除。InMemorySaver 在进程内可用,但不支持从该演示推导出进程重启后的持久恢复。

  • 能沿实际代码说明每个节点读写的状态字段。
  • 能区分静态边、条件边和回边,并给循环指出明确上限。
  • 能解释示例 trace 是节点手动更新,不是 reducer 自动追加。
  • 能准确说出 InMemorySaver 的进程内限制。
  • 能区分测试覆盖控制流与测试证明检索质量。