项目开源地址:https://github.com/GuoBug/PatchCat
在线体验:https://guobug.github.io/PatchCat/
往期回顾:
📖 《从 0 到 1 打造 AI 提示流编排器:告别“有进无出的上下文黑洞” —— 双锚点滑动窗口与原子事务裁剪实战(开源系列 17)》
📖 《从 0 到 1 打造 AI 提示流编排器:用一次“假药对照”,我们在大模型自愈中抓出了真凶(番外系列 2)》
📖 《从 0 到 1 打造 AI 提示流编排器:合规暴涨 23.9% 语义却跌 7.1%?自愈病理诊断与双轴归因报告(番外系列 1)》
📖 《从 0 到 1 打造 AI 提示流编排器:代码越写越脏?架构纯度清洗与 42 样本统计检验复盘(开源系列 16)》
📖 《从 0 到 1 打造 AI 提示流编排器:别把大模型当机械拼图!Case #7 字段冻结陷阱与代码回滚复盘(开源系列 15)》
📖 《从 0 到 1 打造 AI 提示流编排器:搞 Eval 评测前,先让输出闭合!结构化约束与业务契约防线(开源系列 14)》
导读:在复杂长链路 AI 工作流中,最令人沮丧的体验莫过于流水线运行了数分钟、消耗了大量 Token,却在接近终点的节点因第三方网络超时而报错中断。传统引擎往往只能全量推倒重来。本文真实复盘我们在 PatchCat 中落地端侧不可变快照与断点续跑的完整历程:为什么我们拒绝了重型 Event Sourcing,如何依托 Linux 组合哲学设计自包含快照,怎样通过逆向 BFS 遍历化解经典菱形依赖空洞,以及如何在单流 5 记录 FIFO 淘汰约束下守住浏览器存储红线。

一、 生产痛点:长链路下的“单点崩溃恐慌”
在真实业务场景中,一个典型的自动化流水线往往包含多个异构节点:从前端输入分发,经过知识库检索切片,再经代码节点清洗数据,随后喂入大语言模型执行深度分析,最终输出格式化报文。
整条链路可能由七八个乃至十几个节点串联或并行组成,耗时数分钟,单次运行消耗数万 Token。在未实现容错恢复的早期阶段,系统暴露出一个极具杀伤力的痛点:
当流水线的前六个节点均顺畅执行,偏偏在第七个节点遭遇第三方接口瞬时超时或服务商 429 限流时,整张画布瞬间变红。如果调度器只支持从零重放,开发者就不得不面对极其尴尬的处境:
- 算力与资金的无谓损耗:上游已成功清洗的数据和耗费昂贵 Token 产出的思考结果被强行废弃,重跑意味着对同一批稳定数据重复扣费;
- 调试时效坍塌:开发者为了微调报错节点的 Prompt 参数或超时阈值,每次都必须枯坐数分钟等待上游节点缓慢重算;
- 心理防线受挫:面对缺乏容错保障的系统,使用者会对长流程产生天然的不信任感,甚至不敢设计超过三个节点的复杂工作流。
我们迫切需要一种工程机制:在节点遭遇异常中断时,系统支持从该节点就地断点续跑,上游已经稳定产出的结果直接复用,实现时间与费用的零浪费。
二、 关键节点双向共创:Event Sourcing 的诱惑与 Linux 工具哲学的定音
在讨论“如何记录执行历史并支持时光倒流”时,团队内展开了深入的技术方案辩证。
1. 人机协同中的方案碰撞
当时,很多同行技术方案倾向于采用类似 Temporal 的事件溯源架构(Event Sourcing):不记录最终状态,仅记录一个接一个发生的细粒度动词事件(如 NODE_STARTED、TOKEN_STREAMED、NODE_COMPLETED),在需要恢复时,通过重新回放这一串事件日志还原内存状态。
然而,在 AI 伙伴对底层边界的推演中,Event Sourcing 在纯端侧工作流系统中的三项隐藏代价被逐一揭开:
- 粒度失控风险:大模型生成的逐字流式 Token 每秒产生数十次更新,若将其作为不可变事件持续写盘,浏览器的本地存储会在极短时间内由于高频 I/O 发生卡顿;
- Schema 演进负担:不可变事件一旦落盘,只要后续节点配置调整了字段格式,恢复系统就必须维护复杂的向上兼容转换器(Upcasters),代码复杂度显著上升;
- 重放计算开销:每次打开历史记录,前端必须调用 Reducer 将成百上千条事件重新折叠计算一遍,拖慢页面冷启动速度。
2. 作者以 Linux 工具哲学破局
面对是否引入重型事件总线的犹豫,作者郭强(GuoBug)从系统工程本质提出了关键取舍:
一个工具做好一件事即可,复杂的任务靠极简工具的组合完成。存储数据本身必须自包含,一个检查点就是一个自包含的静态 JSON 快照,开箱即用,不要引入不可预测的重放解释器。
这一判断奠定了 PatchCat 核心容错基调:不是依赖事件重放黑盒,而是全面采用轻量、独立的不可变检查点快照机制(Immutable Checkpointing)。
| 评估维度 | 方案 A: 纯内存临时缓存 | 方案 B: 依赖云端重型服务 | 方案 C: 端侧不可变快照 + 逆向拓扑剪枝 (采纳) |
|---|---|---|---|
| 持久化生存期 | 页面刷新即丢失 | 依赖云端数据库 | 浏览器 IndexedDB 持久化 |
| 离线支持度 | 纯本地但脆弱 | 破坏 Local-First 原则 | 100% 纯本地运行 |
| 故障重跑范围 | 仅支持浅层重试 | 调度复杂度高 | 局部子图精准剪枝重排 |
| 系统认知负荷 | 极低但无法生产可用 | 运维与网络依赖沉重 | 极简自包含 JSON,无重放黑盒 |

三、 核心算法深度拆解:逆向 BFS 扩充与增量拓扑调度
确立了快照模型后,真正的算法考验在于调度器如何确定重跑子图。
1. 经典菱形依赖空洞陷阱
初学者设计断点续跑时,往往采用朴素的前向扩散思维:用户指定节点 X 失败,那就只执行 X 以及 X 的所有后代节点: \(\text{CandidateSet} = \{X\} \cup \text{Descendants}(X)\)
在线性流水线($A \rightarrow B \rightarrow C$)中,这套逻辑看似可行。然而,一旦置身于真实的有向无环图拓扑——尤其是经典的菱形网络($A$ 同时输出给 $B$ 和 $C$,$B$ 与 $C$ 汇聚到 $D$),隐患便会发生:
假设在前次运行中,分支 $C$ 因某种原因未产生有效输出。若此时仅将 $D$ 及其下游纳入调度,$D$ 在运行解析变量引用 `` 时,会直接因为找不到上游变量而发生次生崩溃。
2. 自适应向上扩充算法
为了保障确定性工作流的输入完备性,我们在调度引擎 src/engine/browser-engine.ts 中引入了基于逆向依赖图的自适应向上扩充逻辑:
// 1. 获取目标节点的所有祖先节点
const initialAncestors = computeAncestors(targetNodeId, graph);
// 2. 逆向追溯前驱,发现依赖空洞自适应向上扩充
const targetNodesToRun = new Set<string>([targetNodeId]);
let expanded = true;
while (expanded) {
expanded = false;
for (const tId of targetNodesToRun) {
const incoming = incomingEdgesMap.get(tId) || [];
for (const edge of incoming) {
const pId = edge.source;
const hasCachedOutput = context[pId] && Object.keys(context[pId]).length > 0;
// 若前驱节点在上下文环境中缺乏有效产出,强行将其吸纳进重跑候选集
if (!hasCachedOutput && !targetNodesToRun.has(pId)) {
targetNodesToRun.add(pId);
expanded = true;
}
}
}
}
// 3. 计算最终受影响的前向子图闭包
const targetDescendants = computeDescendants(targetNodesToRun, graph);
const prunedNodeIds = new Set<string>([...targetNodesToRun, ...targetDescendants]);
这段逻辑确保了系统拥有前瞻防御力:引擎顺着入边反向核对每个前驱节点的实际输出;只要发现平行前驱未就绪,就自动将其纳入前置执行层,直至整棵前向因果树所需的数据全部具备确定性。
3. 基于 Kahn 算法的局部重排与零开销契约
确定了需要重跑的受限节点子集 prunedNodeIds 后,引擎仅对该子集内的节点与边执行 Kahn 拓扑排序,重新计算并行执行层。
对于所有未被纳入重跑集合的祖先节点,引擎履行严格的免计算契约:
- 状态直接置为
'cached'; - 执行耗时明确记录为
0ms; - Token 消耗置为
{ prompt: 0, completion: 0, total: 0 }; - 画布节点卡片显式打上缓存已复用徽标,让用户清晰看到哪些节点享受了快照保护。
四、 端侧存储防线:5-Record FIFO 淘汰与时间戳仲裁
在浏览器本地环境,IndexedDB 拥有配额上限,不能随意无限制写入。
1. 5-Record FIFO 环形淘汰契约
为了防止频繁调试产生海量历史快照撑爆存储,我们在数据持久层契约中严格限定:每个工作流在 IndexedDB 中至多保留最近 5 份检查点,执行 5-Record FIFO 环形淘汰。
每次新波次或终态快照写入时,适配器会自动查询当前工作流的所有历史快照:一旦计数超过 5 条,立即按时间戳升序将超额的老旧记录物理清除。这一策略将单个工作流的磁盘占用死死框定在数兆以内的安全红线中。
2. 毫秒级并发时间戳打破 (Tie-Breaking Resolution)
在编写高并发以及轻量自动化测试时,我们遭遇了一个罕见的边缘现象:在快速执行完毕的小型工作流中,两个连续波次的快照写入时间戳可能完全落在同一毫秒内(timestamp1 === timestamp2)。
如果查询排序仅使用 b.timestamp - a.timestamp,不同浏览器内核返回的数组元素顺序会出现偶发翻转,导致引擎错误地将较早波次的快照当作最新状态载入。
我们在 src/services/storage/indexeddb-adapter.ts 中补充了多级确定性仲裁契约:
- 第一优先级:比较创建时间戳;
- 第二优先级:若时间戳相同,已完成的终态快照(
isCompleted: true)排在未完成快照前; - 第三优先级:若均未完成,波次索引更大者(
currentWaveIndex)排在前面。
这一微小却关键的判据修正,彻底消灭了极速运行场景下的快照错序偶发偶现问题。

五、 自动化测试实证与极限场景压测
方案是否可靠,最终要看测试套件给出的裁决。在专用测试文件 tests/checkpoint-resumption.node.test.ts 中,我们构建了多项极限场景验证:
- 祖先节点零开销断言:在多层级串行工作流中,人为中断末尾节点并触发断点续跑,断言前序所有祖先节点全部触发
NODE_COMPLETE且其tokensUsed.total === 0,没有产生哪怕 1 次实际网络调用; - 菱形空洞自动扩充断言:构造 $A \rightarrow B, C \rightarrow D$ 结构,在测试上下文中刻意剥离 $C$ 的产出字典,向调度器申请从 $D$ 节点续跑。断言生成的执行分层中,$C$ 被成功捕获并置于第一波次先于 $D$ 运行;
- 环形 FIFO 配额清理断言:针对单一工作流连续生成 8 个 Checkpoint,断言存储检索结果始终严格收敛为 5 条,且多余的老旧记录被物理级擦除;
- 画布快照逆向回填断言:调用
restoreCheckpointToCanvas接口,验证画布各节点的输入、输出状态字典能否毫发无损地回写至组件数据槽中。
全套测试用例在 Node.js 原生测试运行器中保持全部绿灯,与全工程 360 余项测试保持一致。
六、 总结:从不确定性到工程确定性
作为深耕底层的系统设计者与面向用户的产品工程师,断点续跑特性的落地让我们深刻体会到:DAG 状态机的价值不仅体现在顺利执行时的顺畅,更体现在发生异常时的兜底底蕴。
面对大语言模型长程编排,我们无须被重型分布式事件框架绑架心智。通过轻量、自包含的不可变静态快照,结合精准的逆向 BFS 遍历与拓扑动态剪枝,端侧应用同样能构建出高可靠、省算力且具备工业级容错能力的执行骨架。
最后,写完这篇复盘正好赶上节前。马上就放假了,暂时放下复杂的代码、架构与工单,祝大家假期愉快,好好放松放空,多陪陪家人!
下一篇预告
📖 《从 0 到 1 打造 AI 提示流编排器:别让 Agent 原地鬼打墙!连续 3 次相同工具调用的柔性引导与硬熔断(开源系列 19)》
关于作者
郭强 (GuoBug),Product Engineer,做平台工程也做业务增长。目前主要在折腾 AI 工作流编排、DAG 状态机与确定性系统架构。
开源项目与主页:https://github.com/GuoBug · https://guobug.github.io
欢迎就工作流引擎架构、拓扑调度和低门槛开发体验交流指教。