写作 Agent 状态机设计详解
LangGraph 门禁流程 · 检查点与恢复机制 · 源码走读

一个「人在环上」(Human-in-the-loop)的写作 Agent:流程怎么跑、状态怎么转、崩了怎么恢复
github.com/yuanxujia8090/simple-write-agent · LangGraph + SQLite · 2026-11-04 更新

一句话定义(TL;DR)

写作 Agent 状态机是用有限状态机(FSM)约束 LLM(大语言模型)创作流程的工程结构:生成节点产出候选产物,门禁(Gate)节点通过 interrupt(中断)机制暂停等待人类审批,阶段推进只能由携带三重指纹(artifactId + artifactHash + confirmationHash)的确认提交驱动,业务状态与图状态(Graph State)在同一个 SQLite 事务中原子落库,崩溃后凭检查点(Checkpoint)+ 事件流(Event Stream)对账恢复。

三个核心概念一次说清:① 任务账本(Task Ledger)= append-only 的完整事实记录,管审计;② 检查点(Checkpoint)= 覆盖式运行时快照,管秒级恢复;③ 阶段原子(Stage Atomic)= 只在阶段边界提交状态,中断则重做整个阶段。检查点是书签,账本是史书。

CHAPTER 01

全景:五层,各管各的

小白版:像一家出版社——前台接单(CLI)、总编排流程(Runner)、流水线车间(Graph)、档案室(Service)、保险柜(Store)。
专业版:五层单向依赖,每层只认识下一层的接口(TypeScript interface),换数据库、换模型都不用动上层。

1
CLI 入口接你的命令:/start /confirm /resume /list
src/cli/main.ts
2
GraphRunner 运行入口start 跑到第一个门禁停;resume 验指纹后续跑;openSession 重开会话
src/agent/runner.ts
3
LangGraph 编排图12 个节点的流水线;门禁节点用 interrupt 暂停整个图
src/agent/graph.ts
4
WorkflowService 业务规则阶段/产出版本/门禁记录/Trace 的唯一写入者,管指纹校验
src/workflow/service.ts
5
SQLite 存储业务表 + LangGraph checkpoints 表,一次事务一起提交
src/store/sqlite-store.ts
关键设计:图的运行状态(走到哪个节点)和业务状态(哪个产物被批准了)分开存两份,在门禁处用同一事务对齐。
CHAPTER 02

整体流程:12 个节点,5 道门禁

节点顺序写死在 graph.ts:40 FLOW_NODES。蓝色=生成节点(调模型),黄色=门禁(停下来等你拍板),绿色=落盘。

START→ intake
创作简报
→ research
搜素材
→ materialsGate
素材采纳?
→ thesis
出论点
→ thesisGate
论点确认?
→ outline
出大纲
→ outlineGate
大纲确认?
→ drafting
写主稿
→ review
核查报告+终稿
→ factGate
核查确认?
→ finalGate
终稿确认?
→ export
MD+HTML
→ END
生成节点:调模型,产物先记为 temporary 门禁节点:interrupt 暂停,等人决定 输入/落盘

两种模式,门禁数量不同

domain/gates.ts:18 一张表定义全部门禁:

normal 普通5 道全过:素材→论点→大纲→核查→终稿

fast 快速只留 2 道:论点→终稿(跳过素材/大纲/核查门禁)

拒绝回退到哪,也是表定的

每个门禁有 rollbackStage:gates.ts:15

论点门禁拒 → 回 research(重找素材)
大纲门禁拒 → 回 outline(重写大纲)
终稿门禁拒 → 回 drafting(重写主稿)
回退目标 = STAGE_NODES 里的重跑节点 graph.ts:60

CHAPTER 03

核心机制:门禁怎么"停下来等你"

专业版:门禁节点调 LangGraph 的 interrupt() 把确认卡抛给调用方,整个图的执行被冻结并写进 checkpoint;你拍板后 Command({resume}) 注入决定,图从冻结点继续。graph.ts:189 gateStep

1. 生成节点交"临时候选"

模型产出 → saveTemporaryArtifact() 存为 status:"temporary",算出 contentHash 内容指纹 service.ts:250

2. 门禁节点生成确认卡

requestGate() 找到最新的 temporary 产物,把「预览+推荐+风险+备选」打包,算出 confirmationHash service.ts:285。同一个产物 → 永远同一个指纹。

3. interrupt() 冻结

interrupt(confirmation) 暂停图,确认卡经 runner 的 settle() 变成 status:"awaiting_confirmation" 返回给你 graph.ts:202 / runner.ts:93。进程可以直接关掉。

4. 你拍板,三指纹核对

你提交 approve/modify/reject 时,runner 先核对 artifactId + artifactHash + confirmationHash 三个都对上才放行——对不上就是"确认已过期" runner.ts:196。

5. commitGateDecision 落库

在深拷贝上改状态(批准→draft / 拒绝→rejected + 阶段推进或回退 + 记 Trace),全部步骤成功才一次事务写库 service.ts:328。

6. 前进或回退

approve → 下一节点;modify/reject → rollbackStageFor(gateId) 对应节点重跑 graph.ts:219。

为什么要三指纹?

防偷改确认卡生成后产物内容变了 → artifactHash 对不上 → 拒绝提交
防串卡拿 A 门禁的确认卡去批 B 门禁 → confirmationHash 对不上
防重复同一决定提交两次 → 幂等检查返回 duplicate_confirmation service.ts:341

一个诚实的坑(面试可讲)

LangGraph 恢复时会从节点开头重跑整个 gateStep,所以 requestGate() 会被调用两次 graph.ts:186 注释。对策:该调用必须保持只读/幂等——确认卡由产物内容确定性推导,两次算出的指纹相同,提交用的是第二次。

CHAPTER 04

状态流转:产物的一生

小白版:每篇文章的每个阶段产物像一份稿件——先打成"草稿(temporary)"送审;你批了转正(draft),你拒了盖"作废"章(rejected)。同种稿件永远只有一份草稿在审,旧草稿自动作废。

生成节点 model.xxx() temporary 待确认 + contentHash 门禁 interrupt 等你拍板 draft 已批准 进下一阶段 rejected 已拒绝 回退重跑 approve modify / reject 新草稿来了:旧 draft → rejected(superseded) rejected → rollbackStage 对应节点重跑(如终稿拒→drafting)

temporary临时产出

同 kind 重新生成时替换上一个 temporary,但不影响已批准/已拒绝的版本 service.ts:259

draft已批准

批准时同 kind 的旧 draft 全部转 rejected(superseded)——每个 kind 最多一份生效 draft service.ts:389

rejected不删除

拒绝的版本留在 artifact_versions 表里,statusReason 记原因——这就是账本"不抹历史"

会话 stage 的推进:只有一条路

sessions.stage 这个字段只有 commitGateDecision 能改 service.ts:429。模型说得再响都没用,阶段推进必须由你的批准驱动。

intake→ research→ thesis→ outline→ drafting→ review→ exported

approve → stageAfterApprove() 前进;modify/reject → rollbackStageFor() 后退。每次变化都追一条 stage_changed Trace service.ts:448。

CHAPTER 05

保存时机:阶段原子 + 同一事务

小白版:银行不会记"转了一半"的账。这个项目里,"转正"这个动作(批准产物+推进阶段+记门禁+存检查点)要么全发生,要么全不发生。
专业版:两个提交点——① checkpoint 以阶段为原子(CONTEXT.md:阶段执行中中断则重做整个阶段);② 业务行与 checkpoint 行同事务提交(ADR 0012)。

时间 → outline 生成中 outlineGate interrupt 你 approve drafting 开始 一次事务提交(saveSession + checkpoint) ① artifact: temporary → draft ② gates 表 +1 行(三指纹绑定) ③ sessions.stage: outline → drafting ④ trace_events +N 条(append-only) ⑤ LangGraph checkpoint 行(图状态) 这里崩了:无提交点 恢复 = 整个 outline 阶段重跑 ✗ 如果允许阶段中间存盘 · 恢复看到半截大纲 → LLM 当输入续写 → 缝合怪 · checkpoint 说"大纲完成",但没有任何过指纹的产物 · 图状态和业务状态分裂,无法对账 · 两份写无法放进一个事务

代码里的"全有或全无"

// service.ts:377 先在深拷贝上试改
const work = deepCopy(session);
// ...改产物状态、记门禁、推阶段、写 Trace
// 任何一步抛错 → work 整个丢弃,库里纹丝不动
// service.ts:458 最后一行才落库
store.saveSession(work, input.checkpoint);
// sqlite-store: INSERT OR REPLACE checkpoint
//   + 业务行 → 同一个 db.transaction

代价与兜底

代价:门禁前崩溃 = 整个阶段重跑,白花一次模型调用的 token。

为什么可接受:写作生成是幂等的——重写一篇稿子没有副作用,只花算力。撕裂状态丢的是正确性,重跑只丢钱。

再加一道保险:verifyDraftUnchanged() 在终稿门禁和导出前各查一次磁盘文件指纹——你手工改过主稿,Agent 拒绝覆盖,报"主稿已被外部修改" graph.ts:335。

CHAPTER 06

恢复流程:重开会话时发生了什么

小白版:合上电脑第二天重开——档案室(session.db)告诉你"这篇稿子在等大纲确认",监控录像(LangGraph checkpoint)还原出"当时冻结在 outlineGate 节点",两边一核对,把当初那张确认卡原样递还给你。

1. openSession(sessionId)

先查业务库:service.hasSession() —— 会话不存在直接抛错,不返回半成品 runner.ts:122

2. 从 checkpoint 还原图状态

graph.getState(threadConfig(sessionId)) —— SqliteSaver 读出上次冻结的图状态 runner.ts:133。图"记得"自己停在哪个节点。

3. 取回未决确认卡

从 checkpoint 的 tasks[].interrupts[] 里拿回 interrupt 时抛出的 ConfirmationPackage runner.ts:134,RunState 标为 awaiting_confirmation。

4. 你重新拍板 → 三指纹核对

确认卡的指纹来自产物内容(确定性推导),所以隔一天重新生成也是同一张卡、同一个指纹——你昨天想批的,今天还能批 service.ts:172 confirmationHashFor。

5. resume 续跑

graph.invoke(new Command({resume:{decision…}})) —— 图从冻结节点继续,gateStep 从节点头重跑(requestGate 第二次调用,幂等),然后 commitGateDecision 同事务落库 runner.ts:208。

跨进程恢复的前提

默认 MemorySaver 是进程内存——进程死了图状态就没了。要跨进程恢复必须传 SqliteSaver graph.ts:118 注释。runtime.ts 的 createPersistentRuntime 就是干这个装配的:一个库文件 → 业务存储 + SqliteSaver + service + runner runtime.ts:46。

还有一份"录像带"

cli/replay.ts + replay-main.ts:拿 trace_events 重放会话历史。因为 Trace 是 append-only + seq 有序,重放结果可复现——这就是"事件流可重建"的落地验证。

CHAPTER 07

收尾:账本 vs 检查点,一个库两张表

同一个 session.db 里:trace_events 是账本(事件流),checkpoints 是书签(状态快照)。一个记"发生过什么",一个记"现在停在哪"。

账本trace_events + artifact_versions + gates

  • 记什么:每条事件——生成、请求确认、拍板、阶段变化、导出
  • 写法:append-only,seq 自增钉死顺序 schema.ts:55
  • 完整性:无损。rejected 的旧版本也留着
  • 用途:审计、重放、回答"这结论谁批的、凭什么"
  • 前端类比:Redux action 日志 / Git commit 历史
VS

检查点checkpoints(LangGraph SqliteSaver)

  • 记什么:图状态快照——当前节点、WritingState 全量槽位
  • 写法:INSERT OR REPLACE 滚动覆盖,parentCheckpointId 串成链 sqlite-store.ts:32
  • 完整性:有损,只留恢复够用的当前态
  • 用途:崩溃/重启后从冻结点秒级续跑
  • 前端类比:SSR 缓存页 / localStorage 草稿

冲突时信谁?—— 信账本

你的设计已经把答案埋在结构里:

① sessions.stage 只有 commitGateDecision 能改,而改之前必须过三指纹校验——状态字段被事件记录"锁死";

② 恢复时先 hasSession() 查业务库,再信 checkpoint——业务库没有的会话,图状态作废;

③ 两者同事务提交——结构上不给分裂留窗口(ADR 0012)。

一句话:事件流是事实源,快照是缓存;快照必须能被事件记录交叉验证,验证不过就回退重建。

检验一下(面试自测)

Q1 · 恢复时 checkpoint 显示停在 finalGate,但 gates 表里查不到 outline 的 approve 记录,应该怎么办?

Q2 · 为什么 gateStep 里 requestGate() 被调用两次不算 bug?

Q3 · 面试官问"为什么不每个 token 都存,崩了零损失",用哪个论据反驳?

整个项目一句话:模型只管提案,推进必须过门禁;每道门禁是一次事务提交;账本记全史,检查点记落脚点——恢复时先对账,再续跑。
CHAPTER 08

常见问题 FAQ

以下问答覆盖 Agent 状态管理最高频的面试与设计问题,答案均为静态正文,可被搜索引擎与 AI 引擎直接抓取引用。

Q1 · Agent 开发中的任务账本是什么?

任务账本(Task Ledger)是记录 Agent 任务全过程的 append-only(只追加、不可修改)事实记录,包含目标、计划步骤、每个动作的回执、验证证据、用户决策和最终状态。它回答"这个任务发生过什么、为什么走到当前状态",用于审计、重放和恢复对账。在 event sourcing(事件溯源)架构中,事件流是唯一事实源,账本状态可以从事件流完整重放重建。对应本页第 1、7 章。

Q2 · 检查点(Checkpoint)和任务账本有什么区别?

检查点回答"现在停在哪、怎么最快恢复执行",是覆盖式写入的运行时状态快照,有损、易失、任务结束可清理;任务账本回答"这个任务发生过什么",是 append-only 的完整历史记录,无损、永久保留、可重放。类比:检查点是浏览器 localStorage 里的表单草稿,账本是 Git 提交历史。两者互补而非二选一。

Q3 · 为什么检查点只在步骤边界保存,不在执行中间保存?

三个原因:① 原子性——步骤中间保存会捕获半完成状态(撕裂状态),恢复时 LLM 会把残缺产物当完整输入续写,产生缝合怪;② 门禁一致性——阶段原子保证"检查点说某阶段完成"等价于"库中存在一个通过指纹校验的完整产物";③ 事务性——步骤边界是离散提交点,业务状态与图状态可放进同一个数据库事务。代价是崩溃时重跑整个阶段,对幂等的生成类步骤只损失算力,可接受。

Q4 · Agent 进程崩溃后如何恢复任务状态?

五步恢复流程:① 先查业务库确认会话存在,不存在直接报错;② 从检查点存储(如 LangGraph SqliteSaver)读出上次冻结的图状态;③ 从检查点的 interrupts 记录取回未决确认卡;④ 用户重新决策时核对三指纹(artifactId + artifactHash + confirmationHash),确认卡由产物内容确定性推导,跨天重建指纹不变;⑤ 通过 Command({resume}) 注入决定续跑,提交时业务行与检查点行同事务落库。

Q5 · 事件流和状态快照冲突时应该信哪个?

信事件流。事件流是 append-only 的事实源,快照只是加速用的缓存;快照必须能从事件流重建、能被事件记录(门禁记录、内容指纹、自增序号)交叉验证。验证不通过说明状态分裂,应回退到事件流证明的最后一个一致状态。工程上靠"状态字段只能由过指纹校验的提交函数修改" + "业务行与快照同事务提交"两条约束消除分裂窗口。

Q6 · LangGraph 的 interrupt 机制怎么实现人在环上审批?

门禁节点调用 interrupt(confirmation) 把确认卡抛给调用方并冻结整个图的执行,冻结点状态写入检查点,进程可以直接退出。用户拍板后调用方用 Command({resume:{decision}}) 注入决定续跑。注意 LangGraph 恢复时会从节点开头重跑门禁节点,节点内取确认卡的调用必须保持只读或幂等。

延伸阅读(权威外部来源)