学习笔记 · Obsidian
TypeScript 持久化、记忆、容错与时间旅行
两种持久化不要混淆
LangGraph 把状态持久化分成两个不同概念:
| 概念 | 主键与范围 | 用途 |
|---|---|---|
| Checkpointer | thread_id + checkpoint namespace | 保存某个 thread 的图执行状态、任务和恢复位置 |
| Store | namespace 数组 + key | 跨 thread 保存任意长期记忆和业务数据 |
短期记忆通常就是 messages 等 State 随 checkpoint 保存在同一 thread;长期记忆使用 Store,并用经过认证的用户/租户身份构建 namespace。
Agent Server 会管理平台持久化。进程内直接运行图时,必须显式提供 checkpointer;遗漏后使用 interrupt、getState 或 durable execution 会触发 MISSING_CHECKPOINTER。
thread 与 checkpoint
调用带持久化的图时,需要:
const config = { configurable: { thread_id: "stable-thread-id" }, };
thread_id 是恢复指针,不是用户 ID。生产设计应区分:
- user/tenant:认证与授权主体;
- conversation/thread:一次持续会话或流程;
- run:thread 上的一次调用;
- checkpoint_id:某个历史状态;
- checkpoint_ns:根图或子图的持久化命名空间。
Postgres 文档提醒 thread_id 长度应受限,建议小于 255;不要把超长业务对象或明文敏感信息直接作为 thread_id。
StateSnapshot 与查询接口
每个 checkpoint 对应 StateSnapshot,关键字段包括:
- values:当前 channel 值;
- next:下一步将执行的节点;
- config:可用于恢复或 replay 的 checkpoint config;
- metadata:来源、step、writes 等元数据;
- createdAt;
- parentConfig:父 checkpoint;
- tasks:当前 Pregel task、错误和 interrupt 信息。
常用接口:
- getState(config):读取最新或指定 checkpoint;
- getStateHistory(config):从新到旧遍历历史;
- updateState(config, values, options):使用 reducer 写入修订并创建新 checkpoint;
- deleteThread(threadId):删除 thread 数据。
updateState 不是原地篡改历史。它从指定 checkpoint 创建一个新分支。asNode 决定这次 update 被视为由哪个节点产生,也决定后续从哪个 successor 继续。
superstep、pending writes 与恢复
LangGraph 通常在每个 superstep 形成 checkpoint。若同一 superstep 有多个并行节点,其中一个失败,已经成功分支的写入会作为 pending writes 保存;恢复时不必重新执行成功分支。
这降低了并发图的重复成本,但不替代外部副作用幂等。写入第三方系统发生在 checkpoint 提交前,进程仍可能在两者之间崩溃。
生产节点应使用:
- 稳定业务幂等键;
- 数据库唯一约束或 upsert;
- outbox/inbox;
- 外部请求 ID;
- 可查询的执行状态。
durability 模式
运行时可在吞吐和崩溃窗口之间选择:
| 模式 | 持久化时机 | 权衡 |
|---|---|---|
| exit | 只在结束、interrupt 或 error 时 | 最快,中间崩溃可能丢失全部进度 |
| async | 与下一步并行写 checkpoint | 通常折中,但存在小的进度丢失窗口 |
| sync | 下一步开始前完成 checkpoint | 最强恢复语义,延迟最高 |
支付、审批、长任务和高成本调用通常更偏向 sync;可重算、无副作用的批处理可评估 async/exit。不能只依据性能选择,必须测进程终止和存储故障。
checkpointer 实现
内存实现只适合开发与测试。生产可使用官方集成包对应的 Postgres、SQLite、MongoDB 或 Redis 等实现。真实部署要单独完成:
- schema/setup 或 migration;
- 连接池与超时;
- 高可用与备份;
- checkpoint retention 和清理;
- 加密、租户隔离与访问审计;
- 大 State 容量和序列化性能验证。
自定义 checkpointer 需要正确实现 put、putWrites、getTuple、list 等接口,尤其不能丢失 pending writes 和 namespace。
文档的不同页面在内存类名上出现 MemorySaver 与 InMemorySaver 混用。应以当前安装版本的导出和类型检查为准,不要只凭错误页片段复制 import。
Store 与长期记忆
Store 使用 namespace 数组和 key:
namespace = ["tenant", tenantId, "users", userId, "memories"]
key = memoryId
常见操作包括 put、get、delete、search、listNamespaces。
普通 search
不带 query 时,可按 namespace 前缀、filter、limit 和 offset 列出记录。要注意:
- limit 截断可能是静默的;
- 不同 backend 默认排序不一定一致;
- 大集合必须分页;
- namespace 必须来自服务端认证 context,不能直接信任模型或浏览器传入的 userId。
语义 search
Store 可配置 embeddings、向量维度与需要索引的字段。单条 put 可以 index: false 或只索引指定字段。
向量维度必须与 embedding 模型一致;更换模型需要重建索引或版本化 namespace。原文、向量和元数据保留策略也要满足隐私删除要求。
文档字段不一致
Store 示例在不同位置混用 userId 与 user_id。它们不是自动等价的协议字段。项目应为 runtime context 定义一个 schema,并在 namespace 构造函数中统一字段名。
短期消息记忆
MessagesValue 配合 checkpointer 保存 thread 消息。长对话不能无限增长,常用策略:
- trim:按 token 或消息数保留窗口;
- delete:使用 RemoveMessage 删除指定消息;
- summarize:把旧历史压缩为摘要后保留近期消息;
- 新建 thread:业务上结束后不要永久续用一个 thread。
修剪时必须保持聊天协议完整:AIMessage 中的 tool_calls 与对应 ToolMessage 不能被拆开。
INVALID_CHAT_HISTORY 的典型根因是 AI tool call 没有匹配 ToolMessage,常见于:
- 手工构造输入漏掉工具结果;
- graph 在工具执行中断后,应用直接追加新的 HumanMessage;
- 消息裁剪删除了 tool result;
- 工具节点异常退出但状态被错误写入。
修复方式是补齐匹配 ToolMessage,或用 updateState 修复消息后再恢复原流程。不要在未完成 tool turn 上直接追加普通用户消息。
retry、timeout 与 errorHandler
固定组合顺序
节点执行失败时:
- timeout 可以产生 NodeTimeoutError;
- retryPolicy 判断是否重试;
- 重试耗尽或没有 policy 后,errorHandler 执行;
- 没有 handler 或 handler 失败时,异常向外冒泡。
per-node timeout、errorHandler、setNodeDefaults 和 cooperative drain 需要 @langchain/langgraph >= 1.4.0。
retryPolicy
重试是 opt-in;只有 node 或 graph default 配置了 policy 才会重试。空对象也会启用内置判断。默认参数:
- maxAttempts:3,含第一次;
- initialInterval:500ms;
- backoffFactor:2;
- maxInterval:128000ms;
- jitter:true;
- logWarning:true。
内置逻辑通常不重试:
- Abort/cancel;
- GraphValueError;
- ECONNABORTED;
- 多数 4xx,包括 400、401、402、403、404、405、406、407、409;
- provider 的 insufficient_quota。
408、5xx 和 NodeTimeoutError 默认可重试。GraphInterrupt 与 Command 等控制流异常会绕过 retry。
TypeScript 没有导出的 Python defaultRetryOn 对应 helper。自定义 retryOn 时必须自己明确白名单/黑名单,不要重试权限错误、参数错误和不可幂等写操作。
timeout
节点 timeout 可写成毫秒数,也可区分:
- runTimeout:单次 attempt 的硬墙钟上限,不因进度刷新;
- idleTimeout:没有可观察进度时触发;
- refreshOn: auto:State 写入、custom stream、子任务调度、LangChain callback 等刷新 idle;
- refreshOn: heartbeat:仅显式 runtime.heartbeat 刷新。
NodeTimeoutError 提供 node、elapsed、kind、timeout、idleTimeout、runTimeout。用 isNodeTimeoutError 做 TypeScript narrowing。
Send 可以为单个动态 dispatch 覆盖目标节点 timeout。超时 attempt 的 buffered writes 会清理,但外部系统已经发生的副作用不会自动回滚。
errorHandler
StateGraph.addNode 可配置 errorHandler,接收 State 和 NodeError;返回 State update 或 Command 进入补偿路径。它适合 Saga 式退款、释放库存、标记流程失败,而不是把所有异常吞成“成功”。
限制:
- 只支持 StateGraph,不支持低层 Graph;
- Functional task/entrypoint 没有 errorHandler;
- 每个 node 只有一个 handler;
- handler 自身失败会冒泡;
- interrupt 不进入 handler;
- 子图未处理异常会作为父节点异常到达父 handler。
失败来源会 checkpoint;若进程在 node 失败和 handler 完成之间崩溃,恢复后的 handler仍能看到同一 NodeError。
setNodeDefaults
setNodeDefaults 可以统一 retryPolicy、timeout、errorHandler 与 cachePolicy。单 node 配置优先,defaults 在 compile 时解析,因此调用顺序不影响优先级。
边界:
- defaults 不传给子图;每个子图单独配置;
- handler node 可以继承 retry 和 timeout;
- handler node不能再继承 errorHandler,避免自捕获;
- handler node不继承 cachePolicy,避免缓存补偿结果。
cooperative graceful shutdown
RunControl.requestDrain 在 superstep 边界请求停止:
- 正在执行的 node 和 retry loop 不被抢占;
- 边界处保存可恢复 checkpoint;
- 若图自然完成,正常返回;
- 若仍有步骤,抛出 GraphDrained;
- 之后用同一 thread config 和 null 输入恢复。
drain 不取消正在运行的异步工作。SIGTERM 处理应同时设置 supervisor 的宽限期,并在需要硬上限时配合 AbortSignal。runtime.control 可让 node 感知 drainRequested,主动跳过昂贵工作。
replay、fork 与 time travel
Replay
从历史 StateSnapshot.config 调用 invoke(null, checkpointConfig),checkpoint 之前的节点不执行,之后的节点重新执行。
Replay 不是读取缓存:LLM、API、interrupt 都会再次发生。对最终 checkpoint replay 因 next 为空而没有动作。
Fork
对历史 checkpoint 调用 updateState,得到新 checkpoint config,再 invoke(null, forkConfig)。原历史保留,新历史从该点分叉。
需要显式 asNode 的情况:
- 同一 step 有多个并行 writer,无法推断最后节点;
- 新 thread 上预置测试 State;
- 希望把 update 视为某个后续节点已经完成。
Time travel 的副作用边界
回放节点必须安全重复调用。生产界面提供 time travel 前,应标出哪些节点会:
- 发邮件或支付;
- 修改数据库;
- 消耗外部配额;
- 触发审批和通知。
对不可重复动作,使用幂等键、dry-run、人工确认或在 fork 中替换 adapter。
子图持久化的三种模式
| 模式 | compile 配置 | 能力 | 主要限制 |
|---|---|---|---|
| Per-invocation | 默认继承 | 当前调用可 interrupt/恢复;每次调用独立 namespace | 不跨调用积累子图记忆 |
| Per-thread | checkpointer: true | 跨调用保留子图 State | 同一子图不支持并行多次调用 |
| Stateless | checkpointer: false | 无 checkpoint 开销 | 无 interrupt、恢复、state inspection |
MULTIPLE_SUBGRAPHS 通常发生在同一个有 per-thread persistence 的子图被并行或多次调用,多个调用写相同 namespace。
解决顺序:
- 不需要长期子图记忆:使用默认 per-invocation;
- 不需要 interrupt/恢复:显式 stateless;
- 必须 per-thread:禁止同一子图并行调用;
- 多个不同 per-thread 子图:给它们稳定、唯一的 node name,防止调用顺序变化导致 namespace 串线。
官方 TypeScript 页面部分表格和说明仍写 True、False、None 或 checkpointer=True,这是 Python 表述残留。TypeScript 实际值是 true、false、undefined/省略。
子图 time travel 粒度
- 默认继承 checkpointer 时,父图把整个子图视为一个 parent superstep;父级只能从子图前回放,整个子图重跑。
- 子图
checkpointer: true时,子图内部有自己的 checkpoint history,可以在内部节点之间 fork。 - 读取子图 checkpoint 需 getState(config, { subgraphs: true }),再从 task state 提取子 config。
生产验收清单
- thread_id、userId、tenantId、runId 明确分离。
- 内存 saver 仅用于测试;生产 backend 完成 migration、备份和恢复演练。
- 根据业务选择 durability,并测试强制终止窗口。
- State 有大小上限、retention、PII 删除与加密策略。
- Store namespace 只由服务端身份构造。
- message trim 不破坏 tool_call/ToolMessage 配对。
- retry 仅覆盖暂时性且幂等错误;timeout 不替代取消与资源清理。
- handler 记录失败且补偿可重复执行。
- SIGTERM 使用 drain + 合理宽限期,不假设 requestDrain 会中止网络调用。
- replay/fork 前标记外部副作用,避免重复真实操作。
- per-thread 子图禁止同一实例并行调用,并使用稳定 namespace。