学习笔记 · Obsidian
TypeScript Graph API:State、并发与错误
Graph API 心智模型
Graph API 由 State、node 和 edge 组成:
- State 是全图共享的数据契约;
- node 读取当前 State,返回局部 State update 或 Command;
- edge 决定下一个节点;
- reducer 决定一个字段如何接收一次或多次更新;
- compile 把声明转换成 Pregel 可执行图。
一个节点不应直接修改传入 State。返回局部对象,让运行时按 schema 和 reducer 合并。
推荐 StateSchema
当前推荐使用 StateSchema。它接受符合 Standard Schema 的字段,因此不绑定 Zod;Zod v3/v4、Valibot、ArkType 等都可参与。常见 TypeScript 推断方式:
type State = typeof MyState.State; type Update = typeof MyState.Update; const node: typeof MyState.Node = async (state) => ({ ... });
也可以用 GraphNode 和 ConditionalEdgeRouter 为独立函数声明精确类型。把 node 写在 builder 外时,这些类型可避免节点名、State update 和 config 退化为 any。
特殊字段原语
- ReducedValue:字段可接收多个 update,由自定义 reducer 累积;输入类型可以不同于最终 State 类型。
- MessagesValue:预构建的 ReducedValue,使用消息 reducer,支持消息 ID 覆盖、反序列化与删除。
- UntrackedValue:运行期间可用但不进入 checkpoint,适合连接、缓存和临时对象;恢复后会重置。
UntrackedValue 默认 guard: true,同一 superstep 多次写入会报错;guard: false 允许多个写入并保留最后一个值。它解决的是“不要持久化”,不是“可以随意并发写”。
旧 State API
官方仍保留:
- Annotation.Root;
- Zod v3 的
.langgraph.reducer; - Zod v4 registry;
- 低层 Channels。
这些用于兼容旧图或高级 channel 定制。新项目优先 StateSchema;迁移时不要在没有 checkpoint 兼容评估的情况下机械改写。
reducer 与并发写入
普通字段默认是覆盖语义。同一个 superstep 只有一个节点写该字段时没有问题;多个并行节点写同一字段时,运行时无法决定如何合并,会抛出 INVALID_CONCURRENT_GRAPH_UPDATE。
修复方式不是“让最后一个赢”,而是明确业务合并规则:
- 数组结果:拼接或按业务 key 去重;
- 计数:求和;
- 集合:并集;
- 评分:最大值、最小值或结构化聚合;
- 真正互斥的输出:改成不同 State key,再由后续节点汇总。
reducer 必须满足可重放要求。最好具备确定性,并尽量满足结合律;否则并发完成顺序或 replay 可能改变结果。
输入、输出与私有 schema
一个图可以分别声明 input、output 和内部/private schema:
- input schema 限制调用者可以传入的键;
- output schema 限制 invoke 最终返回的键;
- 内部 schema 允许节点交换不对外暴露的数据。
但 output schema 不是流式隐私边界。官方明确指出,streamEvents 的 values 投影可能包含全部 channel,包括私有字段。需要对外隐藏字段时,应:
- 优先使用 updates 投影;
- 显式配置 outputKeys;
- 在服务端做响应/事件过滤;
- 不把密钥、连接、token 等敏感数据写进 State。
节点返回契约
StateGraph node 必须返回:
- 与 State schema 相容的局部对象;或
- Command,用于同时更新 State 与控制流。
返回数组、字符串、null、任意消息对象,或只在部分分支返回对象,会触发 INVALID_GRAPH_NODE_RETURN_VALUE。多分支函数的每条路径都要满足同一契约。
未知字段也可能因为输出映射失败而落入这一错误路径。TypeScript 编译期检查能减少错误,但外部 JSON、any、动态对象和未经校验的模型输出仍需运行时 schema。
edge、并行与动态 fan-out
静态 edge
一个节点存在多个静态 outgoing edge 时,下游会在下一 superstep 并行执行。它们读取的是同一个提交后 State,而不是彼此中途写入的数据。
conditional edge
router 读取 State,返回下一个节点名或节点名集合。使用 ConditionalEdgeRouter 可让返回值受节点名联合类型约束。
Send
Send 用于运行时 fan-out,例如 orchestrator 根据模型生成的任务列表动态创建 worker。每个 Send 可以携带独立输入;worker 结果通过 reducer 汇总回共享 State。
生产上还要限制:
- 最大 worker 数;
- 外部模型与 API 并发;
- 单 worker 超时与重试;
- reducer 的内存增长;
- worker 的稳定任务 ID 与幂等键。
Command
Command 可以在一个返回值中同时完成:
- update:更新 State;
- goto:选择下一个 node;
- graph:跳转到父图;
- resume:恢复 interrupt。
TypeScript node 返回 Command 且路由目标是动态的时,应在 addNode 选项中声明 ends,让类型、图渲染和部署分析知道潜在目标。
若子图返回 Command.PARENT 并更新父子共享字段,父图该字段需要 reducer,否则并发子图写入会冲突。
每个 node 只应选择一种控制流来源:
- 静态 edge;
- conditional edge;
- 返回 Command.goto。
如果同时给同一 node 配静态 edge 又返回 Command,两个路由都会执行,不是后者覆盖前者。
Command 作为输入的边界
普通新一轮对话应传入普通 input object。作为 invoke/stream 输入的常规 Command 只有 new Command({ resume: ... }),用于恢复 interrupt。
new Command({ update: ... }) 单独作为输入会基于最新 checkpoint 恢复,而不是创建普通新 turn;若图已经结束,它可能表现得像“没有执行”。State 修订应使用 updateState,常规对话应传 input object。
工具与消息完整性
工具返回 Command 时,如果同时更新 messages,必须包含与原 AI tool call ID 匹配的 ToolMessage。否则聊天历史会留下未闭合的 tool call。
ToolNode 会正确传播工具返回的 Command,并处理常见并行工具执行、错误和状态注入。手工工具节点则必须自行维护消息关联。
节点重放与幂等
节点发生 retry、interrupt 或进程恢复时,会从节点函数开头重新执行。节点内部已经发生但未由 checkpoint 记录的副作用可能重复。
推荐策略:
- 写操作使用稳定幂等键;
- 把外部副作用拆成独立节点或 task;
- 写库采用唯一约束、upsert 或 compare-and-set;
- interrupt 前不做不可重复动作;
- 把成功外部操作的业务 ID 写入 State 后再进入下一步。
节点内调用可 checkpoint 的 task 时,恢复可复用已完成 task 结果,比把长流程全部写在一个 node 中更细粒度。
节点缓存
缓存需要两端同时配置:
- compile 时提供 cache backend;
- addNode 时提供 cachePolicy。
缓存只适合纯函数式或以完整输入为 key 的节点。模型调用、用户权限、当前时间、库存、资金和有副作用动作不能只按表面 State 缓存。
循环与 GRAPH_RECURSION_LIMIT
LangGraph 默认 recursionLimit 是 25 个 superstep。它是 invoke/stream config 的顶层选项,不应塞进 configurable。
触发 GRAPH_RECURSION_LIMIT 通常有两类原因:
- 图的停止条件有 bug,循环永远不结束;
- 合法复杂图确实需要更多 superstep。
处理顺序:
- 先检查 terminal condition、router 返回值和 State 是否真正变化;
- 为 evaluator-agent、重写检索等循环增加业务轮次或成本上限;
- 记录
runtime.executionInfo或 metadata 中的 step; - 只有确认流程有界后才提高 recursionLimit。
recursionLimit 是运行时安全网,不是业务终止条件。把它单纯调大只会延后失控、增加费用和延迟。
Runtime context
运行时 context 用于传入不可由模型控制、且不应写入 checkpoint 的依赖与身份,例如 tenantId、userId、数据库 client、feature flags。应为 context 定义 schema,并通过 Runtime 类型访问。
文档的不同段落偶尔把 context 与旧式 configurable 混用。新代码应以当前类型签名为准:业务身份和运行依赖走 context;thread_id 等持久化协议字段仍放 configurable。
runtime.executionInfo、runtime.serverInfo 等较新字段有版本要求;文档指出相应能力需较新的 graph/deepagents 版本。使用前要锁定并测试具体包版本。
三个错误的定位表
| 错误 | 根因 | 首选修复 |
|---|---|---|
| INVALID_CONCURRENT_GRAPH_UPDATE | 并行节点写同一普通字段 | ReducedValue/reducer 或拆 key |
| INVALID_GRAPH_NODE_RETURN_VALUE | node 返回非 State update/Command | 让所有分支返回合法局部对象 |
| GRAPH_RECURSION_LIMIT | 循环无停止或合法图超过步数 | 先修终止条件,再有界调高 limit |
生产检查清单
- StateSchema 不使用 any,外部输入做运行时校验。
- 每个并发共享字段都有明确 reducer。
- reducer 可确定重放,不依赖完成顺序。
- output schema 不被误当成 streaming 数据脱敏。
- 一个 node 只有一种路由机制。
- 动态 Command node 声明 ends。
- Send fan-out 有数量、并发、成本与超时上限。
- node 副作用幂等,retry/interrupt 后可安全重放。
- 循环有业务终止条件,并保留 recursionLimit。
- TypeScript 编译、单测和真实 checkpoint replay 都通过后再升级。