Checkpoint 系统设计:Agent 保存什么,又凭什么继续?
一个研究任务读完两份资料,完成一次计算,保存了第一段报告,接下来准备核对第三份文件。此时关掉页面、重启服务、收到用户停止指令,会发生三件不同的事。
要把它们处理清楚,系统既要记得研究走到了哪里,也要认得哪些事情已经发生。检查点(checkpoint)参与前一项工作,但后者往往还依赖工具回执、报告版本、授权、请求记录和执行租约。
一个好用的检查点系统,应该让正常研究容易接着做,也让中断后的系统能够准确判断哪些材料可以复用、哪些动作还需要重新获得执行资格。 它不需要把整个应用变成一张永远可重放的图。
本文沿一个教学研究任务讲完整路径,再回看我们在 ETF Agent 中引入并移除 LangGraph 的经过。项目实践来自私有系统的设计与受控验证;文末另给公开教学仓库的可运行切面,两者的能力范围分别说明。
先理解 checkpoint:一份会更新的进度快照
Checkpoint(检查点)是在选定的运行边界保存的一份进度快照:这次 Run 走到了哪里,下一步从哪个阶段进入,还需要哪些工作状态和原始结果引用。 它不是 Run 本身,也不是整段聊天记录或最终报告。
可以把它理解为交接单。例如:“原始资料 A 已读过,结果在原回执中;目前还有一个问题没确认;下一步回到模型思考阶段。”接手者先读交接单,再去核对原回执,而不是把上一个进程的全部内存恢复出来。
谁发起保存?它是模型可调用的工具吗?
本文的 Recovery 检查点由 Runtime 按执行协议发起保存,Recovery 组织快照与保存结果,Store 写入所选存储。它没有作为内建的 checkpoint 工具暴露给模型。 例如模型提议读取资料或发送初稿,Host 执行业务动作并确认原回执,Runtime 再在对应边界更新快照。模型影响研究动作,但不是由它另发一次“存档”工具调用。
需要区分另一个同名概念:Workspace checkpoint 是研究进展记录。 在 Host 开启相应能力并提供 Workspace 目标时,模型可以在 send_answer_part 中附带可选的 workspaceCheckpoint,提议更新问题状态、摘要和历史正文引用。Host 核验原发布回执与来源后再决定是否保存;这份可选提议无效,不应丢弃已经有效的正文。它不是执行接续游标,也不授予恢复运行的权限。
为什么有了数据库和日志,还需要检查点?
动作回执回答“哪些事情已经发生”,报告版本回答“哪些正文已经发布”,日志记录运行中出现过的事件。但接下来怎样组织工作,还需要当前阶段、待解决问题和接续位置。检查点把这部分可继续使用的工作状态集中成一个有格式、有身份的读取入口,减少从零重建上下文的需要。
没有检查点,简单短任务仍可以从头运行;但面对长任务、受支持的等待与接续,系统就可能只能重新推导进度,或无法证明该从哪里继续。重新调用工具也不能替代核对原效果:请求可能已经执行。检查点的价值是帮助准确交接,不是承诺任何崩溃都能原地续跑。纯内存适配器保存的快照会随进程消失;只有相应持久存储才能跨进程保留它。
什么时候触发保存?
一般由执行协议在明确的阶段边界调用保存接口,不是模型说一句“我记住了”,也不必每产生一个 token、事件或日志就保存。ETF Agent 当前实现有以下几类调用点:
- 初始化:没有已有检查点时,尝试保存初始运行位置。
- 更新接续位置:例如模型请求前准备下一步状态,或在一个模型回合及有序提议完成封存之后、派发 Host 效果之前,更新接续提示。模型回合和提议本身仍有各自的持久记录。
- 已确认动作或正文提交之后:保存包含新工作进度的辅助快照。真实效果先由原负责人确认,不能用快照代替它的回执。
- Main 上下文压缩状态提交:保存新状态成功后,再读取并采用这份已保存状态。
- 收尾或取消路径:尝试保存对应阶段的状态;已提交终态与取消事实仍由原负责人维护。
这是触发类别,不是每轮固定执行一次的清单。实际路径可能跳过某些点,也可能多次调用。控制等待还有另一种关系:它可以绑定已经保存的检查点的 revision 和摘要,证明获准唤醒以后该使用哪份状态;进入等待本身不等于又成功保存了一份新快照。
一次 Run 可以有好几个 checkpoint 吗?
可以有多次成功保存。 假设同一个 Run 依次保存了初始化、读完资料后的进度、发布初稿后的进度和压缩后的状态,就会出现 revision 1、2、3、4。下面只画四个便于理解的保存点;真实运行可能在其间还有接续保存,数字并不是固定步骤编号。
但“保存过多次”不等于“保留所有历史版本”。当前项目的内存和 PostgreSQL 适配器,按同一 scope + Run 保留一份最新检查点,每次成功保存让 revision 加一并替换当前快照。 它不是可随意选择 revision 1、2、3 回放的历史仓库。格式版本说明怎样解释快照,revision 说明这是该 Run 第几次成功保存,两者也不同。
如果下一次是已知写入前失败,便没有新快照,先前成功保存的版本仍在;若写入结果未知,不能猜测旧版本一定未变。原动作回执、模型回合和已发布正文不属于这份“最新快照槽位”,不会随着旧快照被替换而自动删除,也不能通过选择旧进度来撤销已经发生的效果。
接下来再把保存动作放回一次正常研究中,看它怎样与其他记录协作。
一、先看正常运行:一份检查点怎样产生
下面用一个合成任务贯穿全文:“比较两只基金的公开披露,计算持仓重叠,先交初稿,再补齐缺失资料。”示例中的资料和结果用于解释机制,不代表一项真实投资研究结论。
1. 接纳任务,取得本次执行身份
运行时(Runtime)先确认这次研究属于哪个账户、哪个任务、允许使用什么资料,以及可使用的资源。持久层为这次运行(Run)保留身份;当前执行者取得带围栏编号的租约。
租约说明谁暂时可以操作这次运行。围栏编号用来阻止旧执行者在接管之后继续写入。它们与模型是否记得任务、页面是否仍然打开,是不同的问题。
2. 记录模型提议,再执行获准动作
模型提出读取某份文件,宿主(Host)将提议绑定到本次请求与工具调用身份,再由工具执行路径核对权限、参数和预算。读取结束以后,原动作负责人保存结果与完成回执。
这个顺序很重要。检查点中的“下一步准备读取文件”只是一条进度线索;工具回执中的“该调用已经完成”才是一次动作的持久事实。恢复时不能只看进度线索就决定再读一次,更不能靠摘要猜测外部请求是否已经执行。
3. 保存接续位置与必要工作状态
运行时把下一阶段、当前工作状态和必要引用交给检查点保存接口。一个简化的快照可以包含:
- 属于哪次运行、采用哪个格式版本、这是第几次保存;
- 下一步进入模型思考、动作处理还是完成判断;
- 后续研究仍需要的观察、缺口和工作便笺;
- 指向原动作回执、资料身份与已保存结果的引用。
这些内容不必全部复制一遍。原回执已经完整保存的观察,可以在严格匹配身份与内容的条件下只留引用。无法从原记录准确恢复的内容,则必须保留在相应持久对象中。
4. 下一步消费,而不是再次创造事实
保存成功后,运行时按自己的生命周期继续。需要旧资料时,沿原结果引用读取;需要写报告时,交给发布系统保存正文和发布回执;需要等待用户或 Child 时,控制系统建立对应的等待关系。
需要真正读回检查点时,也不只是对一段 JSON 做反序列化。系统先核对这份快照所属的运行、格式和版本,再解析接续位置;被省略的观察只能从匹配的原回执补回,资料和发布结果仍沿原身份读取。工作状态整理好之后,还要由本次接续的负责人确认执行资格,才进入相应阶段。
这样,检查点保存的是一份可解释的交接材料,而不是某个进程的全部内存。格式不兼容、引用失配或原结果不可用时,读回方会得到具体的不可用结果,不能悄悄用空对象、摘要或新调用冒充原状态。
图中画的是一次工作循环。实际系统可能多次保存进度,也可能在某次调用前要求持久安全点;具体边界由对应执行协议决定,并不是每一个内部变量变化都要写一份快照。
二、检查点旁边,还有哪些不能混在一起的记录?
研究系统里的几个“保存”,看起来都在写数据库,含义却不同。
| 对象 | 主要回答的问题 | 谁负责解释 |
|---|---|---|
| 执行检查点 | 下一步在哪里,哪些工作状态可以接着用? | 检查点与恢复边界 |
| 动作、模型请求回执 | 调用是否派发,结果已知还是未知,是否已经结算? | 原执行与效果负责人 |
| 发布正文及版本 | 用户现在能读取哪一版成果? | 发布系统与其读取接口 |
| 控制等待及唤醒 | 哪个回复或事件允许接回哪一个安全点? | 原控制负责人 |
| 会话历史与长期记忆 | 用户说过什么,哪些信息适合下一次任务使用? | 会话与记忆系统 |
| 研究工作区进度 | 长期研究做完什么,还缺什么材料? | 研究工作区 |
最后一项也可能被命名为“checkpoint”。它描述的是研究目标和阶段成果,不是执行器的续跑游标。命名相同不意味着应该合并成同一个模块。
可以把执行检查点理解成一张工作交接单。交接单能指向已完成的计算和已保存的报告,却不能替代这些原件。它记录“下一步检查基金 B”,也不会自动给下一位执行者开通读取基金 B 私人资料的权限。
三、一次中断之后,系统实际要走哪条路?
页面断开。 服务进程与原任务仍然有效时,研究可以继续。页面重新打开,读取原正文和进度即可,无须为了恢复显示再调用模型。
服务进程中断。 先判断是否仍有有效执行者,再由原恢复边界核对围栏、已提交成果和未确认请求。ETF Agent 当前允许普通中断丢失有限的未提交进度,不默认在新进程中重建原主任务(Root)、子任务(Child)和等待关系。原任务按实际成果结算,用户明确继续时,再进入有自身准入的新 Run。
合法控制唤醒。 一次获准的人工回复或 Child 通知,可以在原等待、安全点、当前支持格式和执行身份都匹配时,接续它被允许的工作。这里既需要可用检查点,也需要原控制负责人接受的唤醒;只找到一条旧数据库记录还不够。
用户要求停止。 停止请求先由原控制通道接纳,再由当前执行接缝采纳。取消记录与运行收尾有自己的负责人,检查点保存失败不能把真实的取消改写成成功续跑;已经提交的报告和已经确认的工具效果也不会因此回滚。尚未确认的外部效果,仍按原请求事实处理。
这四条路径共同保留已经发生的事实,但对“是否继续原执行”的承诺不同。设计时先选产品需要哪种承诺,再决定检查点要保存多少状态,比默认追求所有步骤无损恢复更有效。
四、我们为什么用过 LangGraph,后来又移除了它?
2026 年 9 月,ETF Agent 从固定语义链迁移到持续研究 Runtime。任务不再只是按预设步骤走一遍,而是要不断读取、判断、调用工具、补充材料,直到可以交付。
当时采用薄 LangGraph.js StateGraph 是合理的:用三个逻辑阶段表达循环,接入持久 checkpointer,同时保留已有模型网关、工具与业务存储。图负责执行位置,业务系统负责工具和发布回执。我们没有要求框架接管整个产品。
随着系统发展,恢复所需的关键事实逐渐由应用自己的 Recovery 保存:模型轮次与提议、工具回执、正文提交、预算、租约、围栏,以及接续游标。图检查点则刻意只保存有界投影,完整的活动对象仍在当前进程中。
这时出现了一个具体问题:新进程要安全接续,仍然必须先依赖 Recovery;图检查点自己并不能完成这件事。 维护者却要同时理解图的 thread、cursor 和应用的运行身份、回执与续跑位置。
10 月初,我们移除了这层已经重复的图执行与 checkpointer 接线。三个逻辑阶段继续保留,变成 ResearchRuntime 内部的普通控制循环;持久接续仍由原 Recovery 提供。我们也没有再创建一个名字不同、职责相同的通用流程管理器。
这次选择的依据是当时项目的职责迁移,而不是“图框架不适合 Agent”。如果应用真正需要图分支、并行 super-step、状态分叉和时间旅行,而且愿意让框架承担相应的执行模型,LangGraph 仍然提供了完整而有价值的能力。
与 LangGraph 放在同一张地图上
先把我们的迁移经历放在一边,用同一个教学任务比较:读取资料 A 和 B → 写初稿 → 等人确认 → 发布报告。 下面的过程是按官方机制推演的示例,不是本项目跑过的跨框架测评。LangGraph 使用 JavaScript Graph API 口径;Microsoft Agent Framework 使用 Python 工作流口径;Temporal 则代表另一类持久工作流方案。
LangGraph:由图运行时保存,边界由节点划分决定
开发者先定义状态、节点与边,为图配置 checkpointer;应用调用图时传入 thread_id。随后由图运行时调用保存器,不需要模型另选一个“保存 checkpoint”工具。模型可以决定走哪个业务分支,但保存协议属于执行层。这里的 thread 能承载多次图调用的累计状态,不能直接等同于本文应用里的一次 Run。图 API 设计说明、线程与检查点
保存的也不只是一段聊天记录。对应用暴露的状态快照包含状态值、下一批待执行节点,以及关联的执行信息。节点返回的是状态更新,由图的更新规则合并;只有进入这套状态和持久协议的内容,才能成为接续依据。 节点函数随手放在进程变量里的对象,并不会自动变成完整的进程快照。
保存粒度是 super-step,可以理解为“一批已经排定的节点共同推进一轮”。若读取 A、读取 B 被安排在同一轮并行执行,它们结束后才形成这一轮完整的状态边界;下一轮才消费这些更新去写初稿。顺序节点则分属不同轮次。把“读取”和“写初稿”塞进同一节点,就失去二者之间的完整检查点边界。super-step 与状态快照、节点边界的取舍
这一轮并非必须全成或全废。假设 A 已完成,B 失败,LangGraph 还会保存已完成节点的任务级写入,也就是 pending writes。在这些写入已成功持久化的前提下,接续这一轮时可复用 A 的结果,不再调用 A。任务级写入与完整的 super-step 快照是两种粒度,不能把“一个节点完成”一律说成“多了一份可时间旅行的完整检查点”。保存接口中的 put 与 putWrites
等审批、进程重启、回看历史,分别怎样继续?
在审批节点里,应用代码调用 interrupt() 提交需要人确认的问题。图暂停后,宿主用同一 thread 和 Command({ resume: ... }) 带回回答。要注意,恢复时从该节点开头重新执行,不是复活一段停在某行的调用栈。因此,把“发送报告”放在 interrupt() 前面,会面临那段代码再次执行的问题;把审批与副作用边界拆清楚,才有可控的恢复路径。中断与恢复
进程重启需要先有跨进程可读的保存器。MemorySaver 只在内存里保留状态;换成持久保存器以后,应用才能凭原 thread 找回已保存的进度。长期记忆又是另一层:checkpointer 管线程状态,Store 管跨线程的应用数据,两者可以一起使用。持久化与 Store
历史回放也有明确含义。应用可以选择保留的旧检查点继续或修改状态后分叉;选定点之前已有结果的节点被跳过,之后的节点重新执行,可能再次调用模型和 API。回放不是观看一段完全无副作用的录像,更不会让外部网站上的已发布报告自动撤回。时间旅行的执行语义
有检查点,是否等于每一步都已经落盘?
还要看保存器和 durability 模式。async 允许下一步与保存重叠,进程突然退出时可能丢失最近尚未完成的写入;exit 把保存推迟到执行退出,包括正常结束、错误或人工中断,因此不提供执行中途崩溃时的逐步进度保护。本文对 sync 采用 LangGraph.js 1.6.1 的固定实现:完成一轮后,运行时等待该轮已安排的检查点写入,再推进下一轮。它等待的是保存器的完成结果,真实持久性仍取决于保存器与存储配置。durability 说明、1.6.1 的轮次写入屏障
这也是需要锁定版本的细节:本次查阅时,在线页对 JavaScript sync 的一段描述与上述固定源码不一致;这里按固定源码解释,不能据模式名字推定所有 SDK、所有版本都有相同等待边界。
最后看发布报告:远端已经接受正文,节点却在收到确认前中断,图里仍可能缺少“发布成功”的状态。安全重试需要稳定的业务幂等身份、远端支持去重,或者先查询原操作结果。LangGraph 的容错文档本身就强调这一点。我们的原回执设计解决的是同类问题,而非框架遗漏了一个显而易见的原则。外部副作用与确认丢失
Microsoft Agent Framework:把执行器和待传消息一起交接
Microsoft Agent Framework 的工作流同样以 super-step 推进,但更直接围绕消息投递与执行器组织:收集上一轮消息,路由给目标执行器,等待本轮执行结束,再处理新产生的下一轮消息。保存由配置了 checkpoint storage 的工作流运行时触发,模型无须主动调用存档工具。官方执行模型
放回示例,初稿执行器已经生成内容,审批执行器还在等回答。要接起来,除了正文状态,还要知道哪条消息将送给谁、哪个请求在等回应。因此快照涵盖执行器状态、待投递消息、待处理请求与响应,以及共享状态。自定义执行器通过 on_checkpoint_save 和 on_checkpoint_restore 明确交出、读回自己的状态;不能指望所有实例字段自动被抓走。Python 保存与恢复接缝
开发者可以选择某个 checkpoint ID,在匹配的工作流中恢复,也可以重建实例后恢复,但必须保留相容的拓扑与执行器身份。内存存储、文件存储、Cosmos DB 存储又分别对应进程内、本机与持久后端,不能把“开启 checkpoint”直接写成“换台机器也能恢复”。检查点、存储与重建要求
保存点也会随版本演进。Python 1.13.0 加入了首次执行前的输入检查点,以及送回请求回应后、消费它之前的入口检查点。这样原输入和人的回答也成为可重放的边界材料,而不是只保存处理后的结果。1.13.0 升级说明
它与 LangGraph 都把接续纳入工作流执行模型;我们的当前实现则围绕自有 Run、原回执与获准控制唤醒组织。比较时应看这些真实状态与入口,而不是只比较三家有没有一个叫 Checkpoint 的类。至于报告是否已经被外部服务接受,单凭上述工作流状态的范围,仍不能推导跨系统事务或恰好执行一次的保证。
Temporal:保存事件历史,让工作流代码重建状态
Temporal 是持久工作流平台,不是另一个同构的 Agent 图库。它展示了另一条路线:开发者用工作流代码组织步骤,把模型调用、网络请求等非确定性工作放进 Activity;服务记录工作流事件历史,Worker 按这份历史重放代码、重建执行状态。Workflow 与重放、Activity 的职责
同一份报告可以写成“读取资料 Activity → 生成初稿 Activity → 等审批信号 → 发布 Activity”。如果初稿的 Activity 完成结果已经进入事件历史,Worker 重启后,工作流代码虽然重新走到“生成初稿”,SDK 会返回历史结果,不需要只为恢复而再次调用模型。定时器与收到的信号也通过历史参与重建;普通局部变量则由确定性的工作流代码重新算出来。这里没有让模型发起一次“保存全部变量”的操作。事件历史如何驱动恢复
要把两种“再执行”分开:工作流重放复用已经记录的 Activity 结果;Activity 重试可能再次执行 Activity 函数,次数与停止条件由重试策略决定。若发布服务已经接受报告,而 Activity 的完成结果还没记入历史,后续尝试仍可能再次触及外部服务,所以 Activity 也需要幂等与结果核对。重试策略
这条路线用确定性重放和持久事件历史承担很强的执行接续职责,代价是应用要遵守对应的编程与部署模型。它不是给当前 Recovery 换一个存储适配器就能获得的能力,也不能从本文的最新快照接口推导出来。
相同的基本问题,不同的恢复承诺
把示例走完以后,差别落在三个具体选择上:
- 谁定义可继续的位置? LangGraph 的图运行时、Microsoft 的工作流运行时、Temporal 的工作流协议各自定义执行边界;本项目由自有 Runtime、Recovery 和控制协议协作。四者都需要宿主协议,而非模型一句“继续”就获得执行资格。
- 保留怎样的过去? LangGraph 支持保留的图检查点历史;Microsoft 提供按 ID 保存、选择和恢复工作流检查点;Temporal 使用事件历史。ETF Agent 当前 Store 只保留每个 scope + Run 的最新快照,原动作与发布事实另存,不能冒称具有相同的任意历史回放功能。
- 中断以后承诺什么? 前述框架提供各自模型下的执行恢复;本项目对普通中断选择按成果结算,显式继续进入新 Run,只为合法控制唤醒保留受限接续。后者是产品取舍,不是同等恢复能力的更轻实现。
共同点是:恢复工作进度,不能代替外部效果的确认。无论选择哪种框架,报告已经发布、请求结果未知、用户已经取消,都需要回到负责这些事实的协议。选型应从自己要履行的承诺出发,而不是因为某个方案更容易画成一张图。
五、检查点失败时,先确定失败发生在哪里
回到开头的任务。第一段报告已经提交,下一份检查点却失败了。至少要区分下面三种情况。
已知写前失败
例如状态中出现循环引用,JSON 序列化在进入数据库事务前就失败。此时可以确定:这份新检查点没有写入,上一份检查点没有被这次操作替换。
如果正文已由发布系统取得真实提交回执,快照失败不能让这段正文变成未发布。另一项独立读取能否继续,则仍看它自己的授权、预算、持久要求和当前租约。不能从“正文已经保存”推导后面所有动作都可随意执行。
我们在原执行接缝的受控验证中,分别检查了已完成读取、推理强度调整、已提交正文和取消请求后的这类失败。共同原则是让辅助快照错误停留在它自己的范围,但每种效果是否已经成立,仍由原负责人提供证据。
写入结果未知
数据库可能已经提交,客户端却没收到确认。这里不能报告“肯定没写”,也不能把异常当作一次安全重试的许可。需要沿原身份读取和核对持久事实;无法确认时,就保留未知状态。
同样的原则适用于外部模型或工具:未收到响应不代表没有执行,更不代表没有消耗额度。
租约、身份或必要安全点失效
如果当前执行者已经失去写入资格,即使快照本身完全能序列化,也不能继续使用旧围栏操作任务。若某次人工等待必须绑定一份精确快照,而这份快照不可用,系统可以拒绝建立那个等待或恢复动作。
拒绝一个依赖条件不足的操作,是相应模块的职责。是否结束整次研究、是否还能交付已有正文,则交回原运行和发布负责人判断。
容量治理也经历过变化
我们曾经遇到检查点重复携带大量已保存观察,触及应用设置的字节上限。先前的修复通过精确回执引用移除重复内容,同时保持原容量限制。后续又移除了人为固定的检查点字节上限,继续保留 JSON 可序列化检查与真实存储错误。
这两个阶段不要合并成“压缩以后永远不会超限”。去重减少重复表示,物理数据库和宿主仍有容量;模型上下文窗口也是另一套约束,不能用检查点能保存来证明请求一定装得下。
六、检查点应该有独立 owner,但边界要画在哪里?
这里的 owner,指负责解释某类事实并维护其不变量的模块,不要求独立进程、独立数据库或一个新的管理器类。
检查点模块有足够明确的工作:保存哪种格式、怎样核对身份与版本、哪些字段可以按原回执恢复、一次写入究竟成功还是未知、读出的快照是否适用于这次受限接续。这些规则值得集中维护。
但它不应该因为接触了运行状态,就接管运行的所有决定。
一个适合评审的分界是:
- 检查点负责人可以回答“这份快照没有保存”“身份不匹配”“本次读取不可用”,并拒绝对应的保存或读取。
- 恢复与控制负责人判断哪次唤醒和安全点可以使用这份快照,不能仅凭快照存在就开始执行。
- 动作、发布和取消负责人分别确认自己的效果。快照不能撤销这些独立事实,也不能替它们补造成功回执。
- 运行负责人综合仍然合法的路径,决定继续、收尾或停止。
这也解释了为什么单纯新增 CheckpointManager 不一定改善架构。如果它开始检查报告质量、核定预算、接管 Child、决定所有异常后的终态,我们只是给旧复杂度换了一个名称。
七、把重复判断收敛成什么样的模块?
现有实现已经分出检查点合同、编码校验、内存与 PostgreSQL 存储,以及生产恢复适配器。此前,不同调用点还要反复辨认“这是已知写前序列化失败,还是其他存储错误”;辨认之后,各自再判断本步能否继续。
这一轮收敛在 Recovery 内加入了一个独立保存模块。它只接收既有存储的检查点保存能力与原输入,统一解释两个能够确定的事实:
| 保存结果 | 模块返回什么 | 调用者还需要决定什么 |
|---|---|---|
| 原存储已返回保存回执 | 已保存,以及原检查点回执 | 按原流程消费这份快照 |
| 已确认在写入前发生序列化拒绝 | 未保存,以及原类型错误 | 本步是否已有足够独立事实,允许不依赖新快照继续 |
其他错误继续沿原失败路径传播,保留原错误身份。模块不会把租约失效、权限失败、事务异常或丢失确认统一包装成一个容易被忽略的“可选失败”。尤其是写入结果未知时,它不会声称旧状态肯定没变。
普通快照、Main 上下文压缩后的快照、接续游标这三个现有保存入口都接到同一个接口。真正的格式校验、版本约束、事务写入与围栏仍由原存储合同承担,读回协议也没有被复制一套。这个保存模块本身不持有会话、预算、正文或控制等待,不负责重试、续租或决定终态。
重复的错误分类被收敛,业务上的继续条件没有被抹平。 已确认发布之后的辅助快照、必须绑定精确快照的等待、模型调用前的游标,各自需要不同的证明。原本依赖独立已提交控制回执而允许继续的特殊路径,也保留原负责人的处理;通用分类器不能反过来把它变成整轮失败。
这比增加一个“忽略错误”开关更有用:调用者拿到明确事实,再按照自己负责的效果处理。检查点可以有独立接口,同时留在原 Recovery 的存储和权限边界之内。
上述接口与三个调用入口的迁移已合入当前实现,完成不调用真实模型的本地离线验证与独立审查,覆盖原恢复、终态和等待接缝。本轮没有改写底层存储事务,也没有扩展普通中断后的恢复承诺;这些工程结果不代表真实模型、生产数据库或部署已经验收。本次远端 CI 未通过,返回信息未说明失败原因,也不能记成远端流水线成功。
原有初始检查点路径虽然已有窄范围处理,其单独验收仍未完成;其他独立检查点工作也没有因这次接口收敛自动完成。模块边界变清楚了,不等于原系统的每一种恢复场景都已验证。
八、用公开教学代码观察两个关键出口
私有系统的完整恢复协议不是开源教学版的能力清单。想亲手观察“结果已提交”和“外部结果未知”的差别,可以使用 Open ETF Agent 的固定公开快照。这是 v0.7 开发阶段的教学实现,不是把本文全部设计打包成了库。
在 Node.js >=26.8.1 <27 环境中:
git clone https://github.com/hpoenixf/open-etf-agent.git
cd open-etf-agent
git checkout 5f70dc0b4760ad70ef211f4823828ab2fee5c525
npm ci
node --test --test-concurrency=1 --test-name-pattern='after result commit|dispatched-without-result' test/durable-runtime.test.mjs对应用例使用脚本化模型与测试拥有的子进程,实际在边界处终止进程,再由另一个进程打开原状态。一个出口消费已有结果回执,不重复原工具效果;另一个出口保留 TOOL_OUTCOME_UNKNOWN,不把未知调用重发。
观察重点是调用次数、原回执和最终状态,而不是模型生成了多少文字。这些用例不用真实模型调用;本次写作核对了固定源码,没有重新执行它们。当前项目入口见 Open ETF Agent,新版本行为应按相应版本说明阅读。
结语:从下一次交接出发设计检查点
回到那份尚未完成的基金比较:第一段报告由发布系统保留,已完成计算沿原结果回读,第三份资料是否继续获取由当前执行资格决定。检查点把这些工作线索组织成可接续的状态,但不替任何负责人重写事实。
下一次设计检查点时,先回答三个问题:谁会读这份状态,读完以后要做什么,哪些事实必须回到原记录确认? 再决定保存边界、格式与失败处理。这样得到的模块既有独立职责,也能留在整个研究系统正确的位置上。
延伸阅读
ETF Agent 技术文集,欢迎 Star 与讨论。