LangGraph 怎样收紧 LLM 的决策权

LangGraph 怎样收紧 LLM 的决策权
Asaakii一、同一个问题,为什么会走出两条路
早期版本里,我让模型自己决定分析流程。给它工具、给它目标,让它规划下一步,这听起来很像 Agent。
后来我把同一个问题问了两次。第一次,它先做产业诊断,再查政策。第二次,它先查政策,再回头补诊断。两份结论都不离谱,却不能放在一起比较:一份把问题归因到产业结构,另一份归因到政策落地。结论的口径不同,后续也没法复盘为什么会不同。
聊天时这不算大问题。县域经济分析系统不一样,它的产出会进入决策材料。我需要的是:
- 同类请求在相同规则和相同输入下走同一条业务路径;
- 每个判断都能追到上游数据、执行节点和规则版本;
- 数据不满足条件时,系统明确说明缺什么,而不是补一段看似合理的分析。
这三个要求把模型的自由度压得很低。我转向 LangGraph,原因就在这里。它没有自动消除模型的不确定性,但它提供了一个清晰的程序骨架,让我们能把不该交给模型的决定写进节点、边和状态里。
本文分两层写。前半部分让从未用过 LangGraph 的读者跑通一个小图,弄清它到底在执行什么;后半部分再复盘项目中的路由、数据门禁与人工复核。案例做过脱敏,字段和业务链是项目规则,不是 LangGraph 的固定写法。
二、先校准预期:读完能学会什么,学不会什么
如果你是 AI 初学者,读完并亲手敲完第三节,应该能做到:
- 用
StateGraph定义状态、节点和边; - 理解节点返回的是状态更新,不必原地修改整份状态;
- 用条件边把流程分到两个出口;
- 知道列表为什么需要 reducer,知道
START、END和compile()分别做什么; - 把一个“模型决定下一步”的小流程改成“代码根据状态决定下一步”。
读完案例部分,你还会知道一套需要交付的 Agent 工作流通常有哪些部件:输入契约、数据证据、质量门禁、人工复核、执行轨迹和产物版本。
但别把这篇文章当成因果推断教程、统计建模教程或 LangGraph 全部 API 手册。这里的 readiness gate 只能决定“是否允许进入估计流程”,不能证明一个因果结论成立。模型选择、识别假设、对照组可比性和稳健性检验,仍然需要独立设计与审查。
三、先跑起来:一个不调用 LLM 的最小图
初学 LangGraph 最容易踩的坑,是第一段代码就接模型、工具、记忆和异步调用。报错时你不知道问题来自哪里。先从一个完全确定性的图开始,反而能看清框架在做什么。
安装依赖:
1 | pip install -U langgraph |
下面的程序接收一个问题。它先用普通 Python 规则打标签,再用条件边选择“分析”或“人工澄清”。没有 LLM,所以你每次运行都会得到同样的路径。
1 | from operator import add |
预期输出的 trace 是:
1 | ["classify: analysis", "analyze: done"] |
把问题改成“帮我想个标题”,就会进入 clarify。这段代码虽然小,却已经包含了 LangGraph 的主干:状态保存当前任务快照,节点计算状态更新,边安排下一步。
官方把图定义为 State、Node、Edge 的组合。节点既可以包着 LLM,也可以只是普通 Python 函数;条件边可以根据当前状态决定跳转。图需要在执行前 compile(),编译阶段会做基本的结构检查,并可在这里配置 checkpointer 等运行时能力。LangGraph Graph API
3.1 逐行理解这个例子
State 不是一个必须实例化的类,它是状态的类型契约。这里有四个键:
question是入口输入;route是分类节点产出的决策;answer是终端节点产出的文字;trace记录每一步发生了什么。
注意 trace 的类型:Annotated[list[str], add]。add 是 reducer,意思是节点返回的新列表会追加到旧列表后面。没有它,后一个节点返回的 trace 会把前一个节点的记录整个覆盖掉。
LangGraph 默认对每个键采用“新值覆盖旧值”的规则。只有需要累积、合并或去重的键,才应该显式配置 reducer。不要看到列表就机械地用 add。比如 errors 常常需要追加,current_stage 则应该覆盖;若所有字段都做追加,状态会很快失去语义。
每个节点只返回自己更新的部分。例如 classify 返回 route 与一条 trace,而没有返回 question 或 answer。框架会把这个局部更新合进当前状态。原稿中“节点返回更新后完整状态”的说法过于宽泛,初学者很容易据此在节点里原地修改字典。实践中更推荐返回局部更新:输入和输出清楚,也更便于测试并发分支。
START 与 END 是两个特殊节点。前者表示图的入口,后者表示没有后继动作的终点。add_conditional_edges() 的第一个参数是来源节点,第二个参数是读取状态后返回分支名的函数,第三个参数把分支名映射到目标节点。三者不要混在一起理解。
3.2 什么时候才该把 LLM 放进节点
当任务需要归纳、改写、从证据中组织语言时,LLM 很合适。例如:
1 | def draft_report(state: State) -> dict: |
这段函数依然只是一个节点。LangGraph 不替你保证提示词、模型参数和外部数据相同,因此同一条图路径并不代表每次生成的自然语言完全一致。要追求可比性,至少还要记录模型名、模型版本、提示词版本、温度、检索结果版本和规则版本。
我自己的划分原则很朴素:存在明确判据的事情,先写代码;需要把多份材料压缩成易读文字的事情,再让模型做。路由、权限、金额计算、字段校验、状态迁移和是否允许发布,通常属于前一类。
四、State 才是工作流的接口
很多教程把重点放在 add_node() 与 add_edge() 上,真正决定系统能否维护的却是状态设计。状态相当于所有节点共同遵守的接口:上游承诺写出什么,下游才知道能读到什么。
项目中的状态按关注点拆成下面几块:
1 | from typing_extensions import NotRequired, TypedDict |
它不是聊天记录,也不是一个随手往里塞东西的大字典。每一块都有用途:
| 区域 | 写入者 | 下游使用者 | 应保存什么 |
|---|---|---|---|
request |
接口层 | 全图 | 区域代码、周期、问题、用户选择 |
workflow |
intake | 路由节点 | 工作流 ID、目录版本、当前步骤 |
data |
数据节点 | 分析节点 | 原始指标、查询参数、数据版本 |
evidence |
证据节点 | 写作与审查节点 | 来源、引用片段、缺口、适用范围 |
analysis |
分析节点 | 草稿节点 | 可复算的中间结果与方法说明 |
review |
门禁节点 | 发布节点 | 检查结果、问题清单、复核意见 |
trace |
所有节点 | 运维人员 | 节点名、时间、输入摘要、规则版本 |
这个表里最容易被忽略的是 evidence.gaps。它专门记录“当前缺少什么”,例如“缺少 2025 年项目投资额”或“政策文本未给出实施范围”。如果缺口只存在于开发者脑中,生成节点很容易把空白补成顺口的句子;当缺口成为状态的一部分,门禁就能据此阻止正式结论。
4.1 状态字段要先写数据契约
只写 dict 能快速起步,但维护一段时间后会有两个问题:字段名漂移,和字段含义漂移。前者是 county_code、region_code 同时出现;后者更难查,比如 data.metrics 有时是原始数值,有时已经是同比增速。
业务稳定后,建议至少为关键区域定义较细的 TypedDict 或 Pydantic 模型:
1 | from typing import Literal |
这不是为了把类型写得漂亮,而是为了让“证据来自哪里”“这段证据支持什么”“缺什么”成为可检查的数据。真正要对外发布的结构,最好再做运行时校验。TypedDict 只给编辑器和类型检查器看,运行时不会拦住错误数据。
4.2 Reducer 是并发与循环的隐含规则
Reducer 规定同一个状态键如何把旧值与新值合并。它平时不显眼,遇到并行分支或循环时却会直接决定数据是否丢失。官方文档也明确说明,未设置 reducer 的键会被新值覆盖;自定义 reducer 才会合并两侧的值。State 与 reducer
例如多个节点都记录执行日志,可以定义一个专用的追加函数:
1 | from typing import Annotated |
如果日志需要按 step_id 去重,append_trace 就不该简单拼接。Reducer 是业务规则的一部分,应当像其他规则一样测试。
五、Node:把副作用当成风险点
节点可以同步也可以异步。它们接收状态,完成计算或副作用,再返回更新。听上去简单,真正落地时最容易出事故的是“副作用”:写数据库、发消息、生成文件、扣费调用外部 API。
考虑一个导出报告的节点:
1 | def export_report(state: CountyEconomyState) -> dict: |
这段代码能跑,但它有一个工程问题:节点重试或从中断恢复时,save_report() 可能再执行一次。官方文档提示,使用 checkpointer 后恢复执行时,节点会从函数开头重新运行,而不是从函数中间继续;中断前的副作用需要具备幂等性。节点重执行与幂等性
所以副作用节点至少要做到其中一种:
- 使用稳定的
report_id或幂等键,重复写入覆盖同一个草稿版本; - 先查询是否已成功写入,再决定是否执行;
- 把外部调用拆得更小,并把已完成的结果持久化;
- 只有在所有质量门禁通过后,才把草稿提升为正式产物。
还有一个更基础的建议:节点名字写动作,不写角色。load_metrics、check_readiness、draft_report 比 data_agent、reviewer 更容易理解。角色可以存在于提示词或任务配置中,节点名应该告诉维护者它会产生什么状态变化。
六、路由:确定性不等于“只用关键词”
项目里最重要的决定之一,是不让 LLM 单独选择业务链。用户的问题进来后,系统先查一个版本化的 workflow_catalog。目录记录每条链的输入要求、可用工具、输出、人工复核策略和匹配规则。路由节点只负责根据目录写入 workflow_id。
最早的简化版是关键词匹配:
1 | def find_workflows(question: str, catalog: list[dict]) -> list[dict]: |
它的优点是可解释,缺点也很明显。用户问“招商政策对产业结构有什么影响”,可能同时命中“招商”“政策”“产业”。直接取 matches[0] 并不确定,目录顺序一变,路径就变了。把 LLM 移出路由,并不代表路由天然正确。
更稳妥的做法是把匹配结果变成显式决策,并给歧义留出口:
1 | from typing import Literal |
生产环境可以把关键词替换为规则表达式、表单选项、分类器分数或 LLM 的结构化分类结果。关键不在于“绝不使用 LLM”,而在于:
- 候选、分数、目录版本和最终决定都进入状态;
- 低置信度、并列和不支持的请求有固定出口;
- 如果 LLM 参与分类,它只能提供候选,代码仍负责阈值、权限和最终状态迁移;
- 用一组冻结的真实问题做回归测试,目录改动后比较路径是否变化。
这样才能说路径可复现。更准确地说,是在固定的目录版本、匹配实现、输入规范和外部依赖版本下,路径可以重放。LLM 输出、实时数据库查询和当前时间仍然会带来变化,别把“图是确定的”误写成“整个系统完全确定”。
七、图怎样表达一条真实业务链
县域经济分析不是一次问答。以项目招商为例,系统不应跳过诊断,直接给一串项目名。项目中的一条链大致是:
flowchart LR A["运行监测"] --> B["产业诊断"] B --> C["瓶颈归因"] C --> D["项目招商"] D --> E["项目质量审查"] E --> F["专家终审"]
其他链可能在“运行监测”或“产业诊断”处结束,也可能在“瓶颈归因”后转去政策决策。共享前缀与多点分岔是图表达得很自然的一类结构:
flowchart LR
I["intake"] --> R{"workflow status"}
R -->|"matched"| M["运行监测"]
R -->|"unknown / ambiguous"| H["人工澄清"]
M --> D1{"继续?"}
D1 -->|"否"| E["专家终审"]
D1 -->|"是"| Ind["产业诊断"]
Ind --> D2{"下一步"}
D2 -->|"结束"| E
D2 -->|"瓶颈"| B["瓶颈归因"]
B --> P["政策决策 / 规划 / 招商"]
P --> E
H --> Z["END"]
E --> Z
图不是越细越好。一个实用的切分标准是:节点内的操作是否应该一起重试、一起观测、一起授权。如果“拉取指标”和“调用模型写报告”失败后的处理完全不同,它们应该是两个节点。反过来,连续的纯计算如果拆成十个节点,只会让状态在图里来回搬运,调试体验更差。
对于这个项目,我让多个业务链共享前置节点,条件边只读取已经形成的业务状态。条件函数本身不调用模型:
1 | def after_monitoring(state: CountyEconomyState) -> str: |
这让流程图能直接回答“某份报告为何经过这一步”。但它不替代业务设计。路由规则、共享节点的输入输出、每条链何时结束,仍要由懂业务的人确定。
八、证据链:不要只在提示词里写“请勿幻觉”
很多报告型 Agent 的证据管理停留在提示词:“请根据提供的材料回答,禁止编造。”这能改善输出,却不能证明输出引用了什么,也不能让程序发现材料缺失。
项目里我把证据做成独立对象。每条证据至少包含来源标识、抓取时间、原文片段、可支持的主张和适用边界。草稿节点读的是证据包,不是数据库里一大段未经整理的文本。
1 | def build_evidence_package(state: CountyEconomyState) -> dict: |
草稿生成时,把“允许写什么”和“必须保留什么不确定性”同时传进去:
1 | 只能根据 evidence.items 中的事实写作。 |
提示词仍然会失效,所以它只是一层。下一层是程序检查,例如每个结论是否带来源、缺口非空时是否强制出现边界说明、草稿是否出现未经允许的数字。涉及语义一致性的检查可以再交给 LLM 评审,但必须让它输出结构化问题清单,并由代码决定是否发布。
九、Quality Gate:把硬标准写成代码
“请你严格审查这份报告是否合格”不适合承担硬门禁。模型可以帮助找遗漏,但不能作为唯一裁判。必备章节是否存在、上游报告是否已生成、预警数量与清单是否一致,这些都有明确判据,应该由确定性代码检查。
1 | REQUIRED_SECTIONS = {"结论", "证据来源", "判断边界"} |
门禁节点的职责不是把问题吞掉,而是把问题写回状态:
1 | def quality_review(state: CountyEconomyState) -> dict: |
这里有一个需要说清楚的边界。代码门禁适合检查可枚举的规则,不会自动判断“归因是否牵强”“政策建议是否真的回应了问题”。我会把质量控制拆成三层:
| 层级 | 适合检查什么 | 失败后的动作 |
|---|---|---|
| 数据契约 | 字段、类型、时间范围、单位 | 阻断并补数 |
| 确定性门禁 | 必备章节、引用、阈值、状态一致性 | 阻断、自动修订或人工复核 |
| 语义审查 | 论证是否跳步、建议是否可执行、措辞是否越界 | 产出问题清单,交人工或受限修订节点 |
把三层混成一个“AI 评审节点”,看起来省事,出了问题却很难定位。
十、Readiness Gate:允许估计,不等于允许下结论
模型分析链在跑回归、匹配或因果估计前有一道 readiness gate。它检查请求和数据是否具备最低条件。例如指标是否已选定、区域数量是否达到该模型族的最低要求、时间范围是否完整。
1 | from typing import Literal |
门槛不过,条件边直接去 needs_data 或人工复核节点,不能继续产出正式结论。原因很直接:数据不足时,语言模型依然能写出流畅的因果叙述,而流畅并不增加证据。
不过,上面“2 个可比区域、2 个时期”只是示意性的准入条件,不是任何统计方法的充分条件。尤其是因果分析,至少还要单独说明处理变量、结果变量、识别假设、对照组、混杂因素、样本量与稳健性检验。没有这些,输出最多是待验证的分析假设,不能写成“政策 A 导致结果 B”。这条边界在报告中要明确展示给读者,而不是藏在系统内部。
十一、人工复核:业务暂停与 interrupt() 是两回事
原项目中的“暂停”是业务状态:任务写成 needs_human_review,人工补数或确认后,从原始请求重跑。它简单可靠,适合流程短、重跑成本可接受的场景。
LangGraph 还提供了另一种机制:在节点中调用 interrupt() 暂停图,并用 checkpointer 保存线程状态。恢复时通过相同的 thread_id 传入 Command(resume=...)。官方文档要求为中断配置持久化,并特别提醒恢复时节点会重新从头执行。Interrupts 与 Persistence
一个简化示例如下:
1 | from typing_extensions import TypedDict |
InMemorySaver 只适合演示和测试,进程退出后数据就没了。正式环境需要能跨进程保存状态的 checkpointer,并制定状态保留期限、访问权限和敏感字段脱敏策略。不要把原始业务数据、用户隐私和完整提示词不加筛选地塞进持久化状态或日志。
“暂停后恢复”也不是免费午餐。节点在 interrupt() 前做过的写入可能再次执行,所以把副作用放在中断后,或为它设计幂等键。人机协作的难点不只在 API,而在于谁有权批准、批准的是哪一版草稿、补充的数据如何审计,以及超时任务如何收尾。
十二、测试与观测:别等出问题才补
工作流不能只靠手动点几次。至少应覆盖下面四类测试:
| 测试 | 关注点 | 例子 |
|---|---|---|
| 节点单测 | 输入状态到局部更新的转换 | 缺指标时 build_evidence_package() 是否写入 gap |
| 路由单测 | 分支是否稳定 | “招商政策”同时命中时是否进入澄清而非随便选一条 |
| 图集成测试 | 节点顺序和终态 | 合法请求是否走到 publish_allowed=True |
| 回归测试 | 规则变更的影响范围 | 更新目录后,历史样本的路径是否改变 |
一个路由测试可以很小:
1 | def test_ambiguous_question_requires_review(): |
观测也不能只有 print()。每条 trace 至少要带任务 ID、节点名、开始和结束时间、输入摘要、输出摘要、规则版本、数据版本、错误码与重试次数。记录“摘要”而不是完整内容,既能排障,也能减少敏感数据泄漏。
对于模型节点,还建议记录模型名、温度、提示词模板版本、检索文档 ID 和 token 用量。这样当一份报告的表达突然改变时,你能判断是模型、提示词、数据还是路由规则发生了变化。
十三、几个容易写错的地方
1. 把状态当作可随意改写的全局变量
节点里直接 state.setdefault(...).append(...) 很方便,但会让输入和输出边界模糊,也不利于重试与测试。优先返回局部更新;确实需要复杂嵌套更新时,为那一块状态写清 reducer 或封装更新函数。
2. 以为加了 LangGraph 就自动可复现
图结构固定,只说明节点和边固定。实时数据、随机模型输出、网络重试、系统时间、外部工具版本都能改变结果。可复现是版本记录与输入快照共同达成的,不是框架单独提供的属性。
3. 用一个大节点包住整个 Agent Loop
把“检索、工具调用、分析、写作、审查”全放进一个节点,图表面上存在,实际还是黑箱。应按失败处理、权限边界、数据依赖和观测需求切分。也别走向另一个极端,纯计算拆得过细同样难维护。
4. 把 LLM 评审当硬门禁
模型适合找疑点,不适合独自批准发布。它可能漏掉一条简单规则,也可能把格式差异误判成问题。可枚举的规则交给代码;语义问题保留给模型和人。
5. 只记录最终报告,不记录中间依据
最终文本往往是最难审计的一层。要能解释“为什么得出这句话”,需要保存来源、数据快照、分析结果、门禁结果和路由版本。没有这些,trace 再长也只是日志。
十四、项目的当前边界与下一步
这套系统还有几处明确的妥协。
第一,它目前主要使用业务状态暂停和重跑,而不是原生 checkpoint 断点恢复。任务规模不大时这足够直接,代价是重复取数、重复生成,也无法保留人工在中间节点的精确修改。
第二,图内没有自动修订循环。质量门禁发现问题后,流程会标记人工复核,而不是自动把问题交给修订节点,再返回审查节点。后者可以减少重复劳动,但要先限定修订范围和最大循环次数,否则模型可能在错误方向上反复改写。
1 | 生成草稿 -> 质量审查 -> 通过 -> 发布 |
第三,产物还缺少完整的版本提升流程。理想情况下,每个节点的可重用结果都有版本;报告先写入草稿版本,门禁通过后才提升为正式版本;失败时保留上一个正式版本,并能用任务 ID 找回对应状态快照。
我不认为“所有 Agent 都该收紧到这个程度”。创意写作、开放式调研、头脑风暴需要探索空间,过早锁死路径反而会损失有用的分支。这里的原则只适用于结果要被比较、审计或据以决策的场景:自由度越高,越要明确它带来的风险由谁承担。
十五、结语
LangGraph 的 API 不多:状态、节点、边、编译、执行。难的部分从来不是记住函数名,而是回答这些业务问题:什么数据能进入状态,谁能修改它,哪些条件可以自动通过,哪些必须停下来找人,最终结论能追到哪份证据。
这也是我做完项目后对 Agent 的理解。Demo 往往靠让模型多试几步跑出来;需要交付的系统,则要先划清模型可以决定什么。LangGraph 给我的不是一套“更自主”的话术,而是一张能把这些边界写明白的图。










