LLM流水线失败恢复:DAG检查点与逆向BFS断点续跑 如果你维护过超过十个节点的LLM流水线一定经历过这种崩溃跑了一个多小时前面都很顺利最后一个节点突然因为网络超时抛了异常然后整个流程只能从头再来。我最初搭建AI提示流编排器的时候就把这种“全盘重跑”的惩罚挨过好几轮。后来我才明白问题不在于模型不稳定而在于我把流程当成了“一条直线”没有给它建立失败恢复能力。这篇文章要聊的就是我在这个开源系列里打磨的一层恢复机制把整个提示流建模成DAG有向无环图在节点粒度上落检查点失败后用逆向BFS做拓扑回溯精确定位哪些上游还能信任、哪些必须重跑再按拓扑序断点续跑。核心结果是一般长链路挂一次恢复成本能从“全量重跑”降到“只重跑失败节点及其受影响分支”。适合正在手写流程编排器、或者维护多节点LLM流水线的人参考。我会把设计思路、代码骨架和踩过的坑一并放出来。1. 为什么提示流编排器需要“失败恢复”这个一等公民功能1.1 提示流编排器到底在编排什么很多刚接触提示流编排器的人第一反应是“这不就是个按顺序调接口的脚本吗”。确实最简单的情况下它是一条线抓取资料、提炼要点、生成大纲、逐章成文、整体润色、格式转换每一步都是一个“提示词 模型调用 输出解析”的节点前一个节点的输出作为后一个节点的输入。但真实场景很快就不线性了。比如我做的一个行业分析报告生成器拆成节点之后是这样的资料收集节点从几个来源抓取原始文本要点提炼节点把原始文本压成结构化要点大纲生成节点依赖要点提炼的结果也可能直接依赖某一路原始资料分章撰写节点每个章节独立成节点共享大纲但彼此没有依赖全文校验节点汇聚所有章节检查一致性格式转换节点把最终文本转成特定排版这里已经有明显的分叉和汇合大纲生成下面挂了五六个章节节点它们可以并行全文校验又把它们聚回一起。这种结构不是一个数组能描述的需要用图来描述。每条边的含义很明确“A的输出是B的输入依赖”。节点是加工步骤边是数据流转。提示流编排器真正要做的不是把节点按顺序跑一遍而是根据这张依赖图决定调度顺序、传递数据、收集结果并在任何一步出问题时不给用户“要么全成、要么全废”的体验。这也是我为什么坚持自己写而不是直接用现成流程引擎LLM节点的输入输出是结构化JSON失败模式跟普通计算任务完全不同需要深度定制。1.2 全盘重来的真实代价时间、成本、结果漂移先说时间。一个完整的报告流程十几个节点每个节点平均调用两次模型接口总耗时随内容长度从几十分钟到两三个小时不等。跑了一小时五十分钟最后一个节点挂了如果直接全盘重跑意味着用户再等两小时并且前置的所有调用全部重新花钱。这还不是最烦的。LLM接口有随机性哪怕同一个输入只要温度参数不为0重跑一遍的输出就可能有差异。差异会顺着依赖图往下传导第二章重跑之后的文本跟第一章核对时可能发现前后风格不一致或者某个数据点对不上。也就是说全盘重跑不只是多花时间和钱它会让整个结果跟上一版产生“漂移”。如果用户之前看过一个中间版本并确认过某些内容重跑之后那些承诺就失效了。我最初做第一版编排器时就是这么干的try/except 包住整个流程失败就 print 一行错误然后从头跑。当时觉得“反正模型总有概率挂重试是最简单的”。结果被现实教育了网络波动导致的重试往往重试三次就成功但数据质量问题、超时导致的半截输出、下游解析失败这些不是重试能解决的。重试解决“瞬时故障”解决不了“结构性失败”。结构性失败需要的是定位失败点确认哪些中间结果是可信的然后只重跑必须重跑的部分。这就是为什么我说失败恢复必须是一等公民功能而不是事后补救。设计阶段如果没考虑“节点可能挂”后面补恢复机制的成本至少翻倍。2. 先把流程建模成DAG节点、边、检查点2.1 一个DAG节点该有哪些信息要在节点粒度上做检查点和断点续跑第一步是把流程用图表达清楚。图里的每个节点除了“干活的函数”之外还必须携带足够的元信息。我习惯用一个Node类来描述节点。节点上有四个核心信息node_id稳定不变不能因为重跑就变化、prompt_template提示词模板用于构造最终请求、model_config模型名、温度、最大token等参数、upstreams/downstreams上下游邻居。其中node_id和prompt_template是检查点哈希计算的关键输入后面会细说。边我用两个列表存每个节点记录自己的直接上游和直接下游。在DAG很小的时候这种邻接表比全局矩阵直观得多而且后面做逆向BFS遍历、拓扑排序都方便。光有图结构还不够执行时每个节点还需要一个状态机。我用的状态是pending还没轮到、ready上游全部完成可以执行、running正在调用模型、success成功且输出已落盘、failed失败等待恢复处理。断点续跑的核心判断依据就是看一个节点处于什么状态、它的检查点是否有效。2.2 为什么必须是“有向无环”拓扑序是续跑的地基DAG里那个“无环”不是摆设。一个提示流如果出现环意味着存在“A依赖BB又依赖A”的死循环这在提示流编排里基本等于建模错误。为什么特别强调拓扑序因为断点续跑的前提是“我知道哪个节点先跑、哪个节点后跑”。有向无环图一定存在至少一个拓扑序按这个顺序执行每个节点执行时它的上游都已经有了结果。当我们从失败节点反推出一个待恢复的子图时最终还是要回到“按拓扑序重放”否则恢复逻辑没法保证依赖顺序。如果一个图有环拓扑排序做不出来调度器也无法判断“某个上游是否已完成”。实际项目中我遇到过有人把“用户反馈”也建模成节点跟生成节点互相引用结果恢复的时候陷入死循环。解决办法很简单要么把环里的节点合并成一个复合节点要么把外部输入当成固定的数据源不允许数据源反向依赖流程内部的节点。2.3 检查点设计节点粒度上做文章检查点可以做成流程级快照也可以做成节点级记录。流程级快照就是把整条执行路径的所有中间输出打包存一份好处是恢复逻辑简单坏处是存储膨胀而且一旦某个节点改了提示词整条快照基本就废了。我用的是节点级检查点每个节点成功之后立刻把它的输出和上下文摘要落盘。这样一个长流程跑完存储目录里会有十几个检查点文件每个节点一个。恢复时谁有效用谁谁失效重跑谁互不牵连。节点级检查点还有一个额外的好处它让“并行失败恢复”成为可能。假设失败节点F的下游有多个分支恢复F之后下游各分支可以重新并行执行。但如果用的是流程级快照整个子图被当成一个整体并行性就被压没了。检查点不是越细越好。如果你在一个节点内部又细分了“模型调用前半段”“模型调用后半段”会让设计复杂到没法维护。我的经验是节点就是最小恢复单元节点内部的重试交给执行器节点之间的恢复交给编排器职责分开代码才清爽。3. 逆向BFS拓扑回溯失败之后别瞎猜3.1 正向排查的盲区节点失败之后最本能的排查方式是从头看执行日志哪个节点报错了但正向排查有一个盲区——它默认“失败的节点是错误源头”。真实情况往往是一个节点报了错但它只是个受害者真正的坏数据来自某个看起来成功了的祖先节点。举个例子失败节点F依赖上游CC又依赖B和D。B返回了一段格式合法但语义残缺的文本C拿到之后依然成功处理了只是生成的摘要明显偏题最后F的解析器因为内容不符合schema直接崩了。如果只在失败节点F上打log你看到的是“解析异常”根本看不到B的问题。正向扫描时B的状态是success你很容易放过它。所以失败排查应该反过来从失败节点出发沿着依赖关系向上游扩散。这就是逆向BFS的基本出发点。它的目标是回答一个问题哪些上游节点可能对这次失败有责任确定了这个候选集合再逐个验证它们的检查点是否有效、输出质量是否合格就能把根因从“受害者”挖到“源头”。3.2 逆向BFS到底怎么搜先说BFS广度优先搜索。在提示流编排器里节点和边构成了图BFS就是从某个起点出发一层一层向外扩散的搜索方式。常规BFS是从根节点往下游搜适用于“我要看这个节点影响谁”。逆向BFS则是把方向反过来从失败节点往上游搜适用于“我要看这个节点被谁影响”。具体做法以失败节点为起点把它所有的直接上游节点加入队列。每弹出一个节点就标记为“已访问”并把该节点的上游节点再加入队列。一直循环到队列为空所有能回溯到的上游节点都被找到了。用文字描述一个简单DAG[A] - [B] - [C] - [F(失败)] [D] -- [C]F失败逆向BFS的第一层是C第二层是B和D第三层是A。最终候选集合就是{A, B, C, D, F}。注意这里和正向扫描不一样正向扫描从A开始可能先处理一堆无关节点逆向BFS直接锁定了“从根到F的所有可能路径”一个不漏。还有一类情况值得注意D节点如果是个外部数据源节点没有上游那它就是一条路径的根。逆向后它也会进入候选集恢复时如果D没有有效检查点就需要重新拉取外部数据。3.3 为什么选BFS而不是DFS这个问题我在实现时纠结过。DFS深度优先搜索也可以回溯上游一条链追到底为什么最后选了BFS核心原因是恢复时我们希望“从离失败节点最近的、还可以信的位置开始续跑”。BFS逐层扩散天然优先检查离失败节点最近的上游节点DFS会一条路追到根把离失败点很远的祖先也翻出来恢复子图被人为扩大。这在依赖链很长的场景下影响很大。假设失败节点F的直接上游C有有效检查点那理论上从C的检查点开始续跑就够了C的上游完全不用碰。BFS第一层就发现C可用候选集合在很早就收敛了。DFS则可能一层一层追到底把A、B全部拉进候选集合虽然最终也能靠检查点过滤掉它们但白白遍历和判断一遍。另外BFS的访问顺序和“按层回溯”的语义也符合人的直觉先怀疑离失败最近的节点再一步步往外查。这对日志排查是有帮助的。3.4 回溯和拓扑序怎么配合逆向BFS收集到的候选集合是一个“节点集合”它不是执行顺序。集合里某个节点可能在拓扑序上排在另一个节点后面也可能有交叉依赖。如果直接拿着集合按任意顺序重跑很容易出现“下游先跑上游还没结果”的荒唐局面。所以完整的处理流程分两步第一步用逆向BFS确定“受影响的子图”第二步在这个子图上做拓扑排序得到正确的恢复执行顺序。我把这个组合叫做“逆向BFS拓扑回溯”本质是逆推定位加正播放行先回答哪些节点可能有问题再回答按什么顺序重放。拓扑排序的思路不复杂对候选子图统计每个节点的入度有多少上游在子图内入度为0的节点可以执行执行完把指向下游的边消掉。如果子图里还有节点没被排出来说明子图内部有环这时候必须报错。4. 断点续跑实现检查点 最小重算子图4.1 检查点里该存什么别只存一个输出我之前犯过的一个错误是检查点文件里只存了node_id和output。恢复时看起来一切正常但只要上游提示词改过一行旧的输出就悄悄被复用了下游还浑然不觉。直到有一次结果明显不对我逐文件比对才发现问题。现在我的检查点结构是下面这样每个字段都有用途字段说明为什么要存node_id节点唯一标识定位是哪个节点的缓存statussuccess / failed只有success才可能被复用input_hash输入摘要的哈希判断本次输入与缓存时是否一致output模型的JSON输出断点续跑复用的核心数据model_version模型名与参数指纹模型配置变了缓存必须失效created_at落盘时间审计、过期清理extra扩展元信息比如人工审核标记这里最关键的字段是input_hash。它需要覆盖所有会影响输出的因素包括prompt_template本身、模型名、温度、最大token数以及上游传入的变量内容。我把这些内容拼成一个JSON字符串再做一次SHA-256。这样哪怕上游输出变了一个字节哈希都会变检查点自动失效不会造成脏复用。一个容易忽略的细节是model_version同样是“生成摘要”这个节点今天用的模型和上周不一样输出风格完全不同。如果只靠input_hash判断而上游输入又没变化旧检查点会被错误复用。所以model_version也得进哈希或者单独比较两者不能只取其一。4.2 断点续跑的完整流程把前面所有机制串起来断点续跑的完整流程是八步执行器捕获到某个节点的失败异常记录failed_node_id。用逆向BFS从失败节点出发收集所有上游节点得到候选集合。对候选集合做拓扑排序得到恢复子图的执行顺序。从拓扑序的最前面开始逐个检查候选节点的检查点是否存在且有效。检查点有效直接复用存储的输出不调用模型。检查点无效或不存在重新执行该节点成功后立即写入新检查点。按拓扑序一直推进到失败节点本身直到它也成功。失败节点成功后把它在原始DAG中的下游节点继续加入调度队列整个流程继续。用拼乐高来类比你拼到一半发现一块关键部件不对不是把整座城堡拆了而是找到“依赖这块部件的上一层”把坏件换掉。已经确认没问题的部分继续保留上面原本搭好的结构只要不受影响也不用动。4.3 核心代码一个可参考的恢复器下面给出一个简化但能跑的恢复器骨架按工程习惯做了注释。这不是生产级完整实现但核心逻辑都在里面。先看检查点存储。我用本地文件系统每个节点一个JSON文件写的时候用临时文件加原子替换避免进程崩溃把检查点写坏import json, os, time class CheckpointStore: def __init__(self, root): self.root root os.makedirs(root, exist_okTrue) def _path(self, node_id): return os.path.join(self.root, f{node_id}.json) def save(self, node_id, payload): tmp self._path(node_id) .tmp with open(tmp, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse) os.replace(tmp, self._path(node_id)) # 原子替换 def load(self, node_id): if not os.path.exists(self._path(node_id)): return None with open(self._path(node_id), encodingutf-8) as f: return json.load(f)然后是输入哈希计算。注意要把prompt模板和模型配置都包含进去变量按key排序序列化保证相同输入得到相同哈希from hashlib import sha256 def input_hash(node, resolved_inputs): payload { prompt_template: node.prompt_template, model_config: node.model_config, inputs: resolved_inputs, } raw json.dumps(payload, sort_keysTrue, ensure_asciiFalse) return sha256(raw.encode(utf-8)).hexdigest()逆向BFS收集上游候选集。这里用双向队列保证BFS层序扩散from collections import deque def collect_dependencies(failed_node_id, rev_adj): visited set() queue deque([failed_node_id]) while queue: cur queue.popleft() if cur in visited: continue visited.add(cur) for up in rev_adj.get(cur, []): if up not in visited: queue.append(up) return visited拓扑排序只针对候选子图。偷懒的做法是全局排序再过滤但如果图很大直接在子图上算更高效def topo_sort_subgraph(graph, node_set): indeg {nid: 0 for nid in node_set} for nid in node_set: for up in graph[nid].upstreams: if up in node_set: indeg[nid] 1 queue deque([nid for nid in node_set if indeg[nid] 0]) res [] while queue: cur queue.popleft() res.append(cur) for down in graph[cur].downstreams: if down in node_set: indeg[down] - 1 if indeg[down] 0: queue.append(down) if len(res) ! len(node_set): raise RuntimeError(候选子图存在环恢复终止) return res最后是恢复主流程。它把“逆向BFS定位”和“拓扑序重放”接在一起核心是复用有效检查点def resume_from_failure(failed_node_id, graph, store): rev_adj {nid: node.upstreams for nid, node in graph.items()} candidates collect_dependencies(failed_node_id, rev_adj) topo_order topo_sort_subgraph(graph, candidates) cache {} # node_id - output for nid in topo_order: node graph[nid] inputs gather_inputs(node, cache) # 从上游的实际输出取输入 ckpt store.load(nid) if checkpoint_valid(ckpt, node, inputs): cache[nid] ckpt[output] log(freuse {nid} from checkpoint) else: output execute_node(node, inputs) # 真正的模型调用 store.save(nid, { status: success, input_hash: input_hash(node, inputs), output: output, model_version: node.model_config, created_at: time.time(), }) cache[nid] output log(frecompute {nid}) # 到这里失败节点本身已经重新成功把下游重新交给调度器继续跑 return cachecheckpoint_valid 的判断逻辑在代码里很直白检查点存在、状态是success、输入哈希匹配、模型版本一致四个条件同时满足才算有效。4.4 结果一致性续跑会不会让结果漂移一个很现实的疑问断点续跑之后重新计算的那部分节点结果跟原来不一样怎么办这个问题分两种情况。第一种情况你重算节点是因为它压根没成功过那没有“跟原来不一样”的问题它本来就没有合法输出。第二种情况某个上游节点的输入哈希变了导致下游检查点失效这时候重算下游是合理的因为上游输出变了下游本来就应该跟着变。真正需要小心的是第三种检查点明明有效但因为某处代码改了prompt模板却没改版本号导致旧缓存被误用。所以我在input_hash里强制包含prompt_template和model_config就是为了尽量减少这种“隐性失效”。如果你觉得哈希还不够稳可以再加一层显式的version字段每次修改节点逻辑时手动bump一下。如果希望续跑结果尽可能稳定一个实用策略是对负责提取JSON、格式化、抽取字段这类的确定性节点把模型温度设为0对创意生成节点接受轻微差异但下游尽量做schema校验不让漂移无限传导。检查点机制天然让“成功的节点不重跑”这本身就比其他方案更稳定。5. 实操踩坑与排查实录5.1 检查点普遍失效输入哈希算少了这是我最开始遇到的高频问题。现象是恢复时明明只改了一个节点的提示词结果所有下游节点的检查点全部失效整个子图几乎全部重跑恢复效率跟全量重跑差不多。排查后发现input_hash只把“上游变量”纳入了计算没把prompt_template本身算进去。节点B的输入其实没变但它内部用的提示词模板改了输出语义应该变检查点却认为“输入没变缓存可用”。这就会造成两类bug一是该失效的没失效二是prompt改了之后所有下游连锁失效想靠缓存兜底也兜不住。解决方式就是把prompt_template、model_config、上游输出全量纳入哈希。宁可哈希算得慢一点也不能漏字段。哈希计算本身开销很小一般也就几百字节JSON跟一次模型调用比起来完全不是量级。5.2 并发写检查点原子写和锁一个都不能少提示流DAG天然支持并行。当两个分支节点都完成时它们可能同时尝试读取同一个上游检查点或者某个共享节点被两条路径同时触发重算。如果检查点文件不是原子写极端情况下会出现一半旧内容一半新内容的损坏文件。我的处理是两个措施一起上写入时先写临时文件再通过os.replace做原子替换读取和写入的关键路径加文件锁。os.replace在同一个文件系统下是原子的进程崩溃也不会让目标文件处于半写状态。并发场景还有一个坑同一个节点被并行重算两次两个线程都发现检查点无效都去调模型最后互相覆盖。我在执行器层加了“单节点互斥”用一个字典存每个节点当前是否在跑避免重复计算。模型调用贵这个去重非常值。5.3 空壳输出成功检查点救了执行没救质量断点续跑只保证“节点执行成功”不保证“输出质量合格”。有个真实教训一个“总结抽取”节点返回了合法JSON但内容全是空数组节点状态是success检查点也写了。下游拿到空数组照样跑最后整个报告只有标题没有正文。因为所有节点都是success恢复机制根本不会触发。从那以后我在检查点有效性判断之外又加了一层输出质量校验。规则可以很简单必填字段是否存在、数组是否为空、文本长度是否低于阈值、JSON schema是否匹配。质量校验失败时直接把该节点标记为failed强制走断点续跑流程重算。这个经验想特别提醒别完全信任模型输出。哪怕你在prompt里写了“必须返回非空数组”模型也有可能在极端情况下返回空。质量校验是检查点体系的一部分它不是可选项。5.4 恢复顺序不对候选集必须重新拓扑排序我在开发恢复器早期犯过一个错误逆向BFS拿到候选集合之后直接遍历集合去恢复没做拓扑排序。看起来大部分时候也能跑通因为依赖链通常较短。直到有一次候选集里出现两个节点互相之间隔着分支对某个节点先执行了而它依赖的上游还没重跑结果读到了旧缓存下游结果明显不对。这个问题的根源在于“集合是无序的”。visited集合或者dict的遍历顺序不代表依赖顺序。我后来把恢复逻辑改成标准的“逆向BFS定位 子图拓扑排序 正放执行”再也没出过这类问题。这也是为什么我在标题里强调“拓扑回溯”BFS找范围拓扑定顺序两者缺一不可。5.5 日志与可视化恢复过程要能被看见断点续跑是个相对复杂的流程用户需要知道“哪些节点用了缓存、哪些节点重算了”否则看到执行时间突然变短会怀疑系统是不是偷偷跳过了步骤。我的做法是结构化日志。每个节点执行开始和结束都要输出node_id、状态、耗时、token消耗、错误类型。恢复阶段额外输出两类日志一类是reuse表示该节点从检查点复用一类是recompute表示该节点被重新计算。这样一份恢复日志看起来大概是这样INFO [restore] candidate subgraph: [src, extract, outline, chapter_2] INFO [reuse] extract from checkpoint, input_hashf3a... INFO [recompute] outline, reasoncheckpoint_invalid_hash INFO [reuse] chapter_2 from checkpoint INFO [restore] failed node recovered, resume downstream如果图比较复杂还可以在排查模式下手动导出DAG结构用简单的文本格式去比对依赖关系。不一定要上完整可视化平台但至少要能回答两个问题失败时这条路径到底经过了哪些节点恢复时重算了哪些节点最后分享一点个人体会做了这套失败的恢复机制之后我最大的感受是失败恢复不应该是“出问题时才去补”的救火功能而是编排器架构里的第一公民。从第一天就假设任何节点都可能挂把状态落盘、依赖哈希、断点续跑当成基础能力来设计后面省心非常多。还有一个小技巧给正在做类似系统的人给每个节点写一个dry_run自检函数在真正调用模型之前先校验上游输出结构。很多低级错误比如上游返回了空数组、字段名不匹配、格式没解析出来其实在还没花钱调模型之前就能暴露出来。它跟检查点体系是互补的dry_run负责“尽可能别跑错”检查点负责“跑错了也别全盘重来”。两件事都做好长链路LLM流水线才真正能让人省心。