写作 Agent 状态机是用有限状态机(FSM)约束 LLM(大语言模型)创作流程的工程结构:生成节点产出候选产物,门禁(Gate)节点通过 interrupt(中断)机制暂停等待人类审批,阶段推进只能由携带三重指纹(artifactId + artifactHash + confirmationHash)的确认提交驱动,业务状态与图状态(Graph State)在同一个 SQLite 事务中原子落库,崩溃后凭检查点(Checkpoint)+ 事件流(Event Stream)对账恢复。
三个核心概念一次说清:① 任务账本(Task Ledger)= append-only 的完整事实记录,管审计;② 检查点(Checkpoint)= 覆盖式运行时快照,管秒级恢复;③ 阶段原子(Stage Atomic)= 只在阶段边界提交状态,中断则重做整个阶段。检查点是书签,账本是史书。
小白版:像一家出版社——前台接单(CLI)、总编排流程(Runner)、流水线车间(Graph)、档案室(Service)、保险柜(Store)。
专业版:五层单向依赖,每层只认识下一层的接口(TypeScript interface),换数据库、换模型都不用动上层。
节点顺序写死在 graph.ts:40 FLOW_NODES。蓝色=生成节点(调模型),黄色=门禁(停下来等你拍板),绿色=落盘。
domain/gates.ts:18 一张表定义全部门禁:
normal 普通5 道全过:素材→论点→大纲→核查→终稿
fast 快速只留 2 道:论点→终稿(跳过素材/大纲/核查门禁)
每个门禁有 rollbackStage:gates.ts:15
论点门禁拒 → 回 research(重找素材)
大纲门禁拒 → 回 outline(重写大纲)
终稿门禁拒 → 回 drafting(重写主稿)
回退目标 = STAGE_NODES 里的重跑节点 graph.ts:60
专业版:门禁节点调 LangGraph 的 interrupt() 把确认卡抛给调用方,整个图的执行被冻结并写进 checkpoint;你拍板后 Command({resume}) 注入决定,图从冻结点继续。graph.ts:189 gateStep
模型产出 → saveTemporaryArtifact() 存为 status:"temporary",算出 contentHash 内容指纹 service.ts:250
requestGate() 找到最新的 temporary 产物,把「预览+推荐+风险+备选」打包,算出 confirmationHash service.ts:285。同一个产物 → 永远同一个指纹。
interrupt(confirmation) 暂停图,确认卡经 runner 的 settle() 变成 status:"awaiting_confirmation" 返回给你 graph.ts:202 / runner.ts:93。进程可以直接关掉。
你提交 approve/modify/reject 时,runner 先核对 artifactId + artifactHash + confirmationHash 三个都对上才放行——对不上就是"确认已过期" runner.ts:196。
在深拷贝上改状态(批准→draft / 拒绝→rejected + 阶段推进或回退 + 记 Trace),全部步骤成功才一次事务写库 service.ts:328。
approve → 下一节点;modify/reject → rollbackStageFor(gateId) 对应节点重跑 graph.ts:219。
防偷改确认卡生成后产物内容变了 → artifactHash 对不上 → 拒绝提交
防串卡拿 A 门禁的确认卡去批 B 门禁 → confirmationHash 对不上
防重复同一决定提交两次 → 幂等检查返回 duplicate_confirmation service.ts:341
LangGraph 恢复时会从节点开头重跑整个 gateStep,所以 requestGate() 会被调用两次 graph.ts:186 注释。对策:该调用必须保持只读/幂等——确认卡由产物内容确定性推导,两次算出的指纹相同,提交用的是第二次。
小白版:每篇文章的每个阶段产物像一份稿件——先打成"草稿(temporary)"送审;你批了转正(draft),你拒了盖"作废"章(rejected)。同种稿件永远只有一份草稿在审,旧草稿自动作废。
同 kind 重新生成时替换上一个 temporary,但不影响已批准/已拒绝的版本 service.ts:259
批准时同 kind 的旧 draft 全部转 rejected(superseded)——每个 kind 最多一份生效 draft service.ts:389
拒绝的版本留在 artifact_versions 表里,statusReason 记原因——这就是账本"不抹历史"
sessions.stage 这个字段只有 commitGateDecision 能改 service.ts:429。模型说得再响都没用,阶段推进必须由你的批准驱动。
approve → stageAfterApprove() 前进;modify/reject → rollbackStageFor() 后退。每次变化都追一条 stage_changed Trace service.ts:448。
小白版:银行不会记"转了一半"的账。这个项目里,"转正"这个动作(批准产物+推进阶段+记门禁+存检查点)要么全发生,要么全不发生。
专业版:两个提交点——① checkpoint 以阶段为原子(CONTEXT.md:阶段执行中中断则重做整个阶段);② 业务行与 checkpoint 行同事务提交(ADR 0012)。
// 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。
小白版:合上电脑第二天重开——档案室(session.db)告诉你"这篇稿子在等大纲确认",监控录像(LangGraph checkpoint)还原出"当时冻结在 outlineGate 节点",两边一核对,把当初那张确认卡原样递还给你。
先查业务库:service.hasSession() —— 会话不存在直接抛错,不返回半成品 runner.ts:122
graph.getState(threadConfig(sessionId)) —— SqliteSaver 读出上次冻结的图状态 runner.ts:133。图"记得"自己停在哪个节点。
从 checkpoint 的 tasks[].interrupts[] 里拿回 interrupt 时抛出的 ConfirmationPackage runner.ts:134,RunState 标为 awaiting_confirmation。
确认卡的指纹来自产物内容(确定性推导),所以隔一天重新生成也是同一张卡、同一个指纹——你昨天想批的,今天还能批 service.ts:172 confirmationHashFor。
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 有序,重放结果可复现——这就是"事件流可重建"的落地验证。
同一个 session.db 里:trace_events 是账本(事件流),checkpoints 是书签(状态快照)。一个记"发生过什么",一个记"现在停在哪"。
trace_events + artifact_versions + gatescheckpoints(LangGraph SqliteSaver)你的设计已经把答案埋在结构里:
① sessions.stage 只有 commitGateDecision 能改,而改之前必须过三指纹校验——状态字段被事件记录"锁死";
② 恢复时先 hasSession() 查业务库,再信 checkpoint——业务库没有的会话,图状态作废;
③ 两者同事务提交——结构上不给分裂留窗口(ADR 0012)。
一句话:事件流是事实源,快照是缓存;快照必须能被事件记录交叉验证,验证不过就回退重建。
Q1 · 恢复时 checkpoint 显示停在 finalGate,但 gates 表里查不到 outline 的 approve 记录,应该怎么办?
Q2 · 为什么 gateStep 里 requestGate() 被调用两次不算 bug?
Q3 · 面试官问"为什么不每个 token 都存,崩了零损失",用哪个论据反驳?
以下问答覆盖 Agent 状态管理最高频的面试与设计问题,答案均为静态正文,可被搜索引擎与 AI 引擎直接抓取引用。
任务账本(Task Ledger)是记录 Agent 任务全过程的 append-only(只追加、不可修改)事实记录,包含目标、计划步骤、每个动作的回执、验证证据、用户决策和最终状态。它回答"这个任务发生过什么、为什么走到当前状态",用于审计、重放和恢复对账。在 event sourcing(事件溯源)架构中,事件流是唯一事实源,账本状态可以从事件流完整重放重建。对应本页第 1、7 章。
检查点回答"现在停在哪、怎么最快恢复执行",是覆盖式写入的运行时状态快照,有损、易失、任务结束可清理;任务账本回答"这个任务发生过什么",是 append-only 的完整历史记录,无损、永久保留、可重放。类比:检查点是浏览器 localStorage 里的表单草稿,账本是 Git 提交历史。两者互补而非二选一。
三个原因:① 原子性——步骤中间保存会捕获半完成状态(撕裂状态),恢复时 LLM 会把残缺产物当完整输入续写,产生缝合怪;② 门禁一致性——阶段原子保证"检查点说某阶段完成"等价于"库中存在一个通过指纹校验的完整产物";③ 事务性——步骤边界是离散提交点,业务状态与图状态可放进同一个数据库事务。代价是崩溃时重跑整个阶段,对幂等的生成类步骤只损失算力,可接受。
五步恢复流程:① 先查业务库确认会话存在,不存在直接报错;② 从检查点存储(如 LangGraph SqliteSaver)读出上次冻结的图状态;③ 从检查点的 interrupts 记录取回未决确认卡;④ 用户重新决策时核对三指纹(artifactId + artifactHash + confirmationHash),确认卡由产物内容确定性推导,跨天重建指纹不变;⑤ 通过 Command({resume}) 注入决定续跑,提交时业务行与检查点行同事务落库。
信事件流。事件流是 append-only 的事实源,快照只是加速用的缓存;快照必须能从事件流重建、能被事件记录(门禁记录、内容指纹、自增序号)交叉验证。验证不通过说明状态分裂,应回退到事件流证明的最后一个一致状态。工程上靠"状态字段只能由过指纹校验的提交函数修改" + "业务行与快照同事务提交"两条约束消除分裂窗口。
门禁节点调用 interrupt(confirmation) 把确认卡抛给调用方并冻结整个图的执行,冻结点状态写入检查点,进程可以直接退出。用户拍板后调用方用 Command({resume:{decision}}) 注入决定续跑。注意 LangGraph 恢复时会从节点开头重跑门禁节点,节点内取确认卡的调用必须保持只读或幂等。