AI应用中断恢复实战:连接解耦与状态持久化
1. 从一次线上事故说起为什么“断点续传”在AI应用里格外棘手去年冬天我负责的一个智能文档处理系统在凌晨两点崩了。用户上传了一份八十多页的合同系统已经跑完了前六十页的摘要生成结果因为一次网络抖动整个任务从头再来。用户第二天早上看到的是“处理失败请重试”而我们的GPU账单上那六十页的算力已经白烧了。这件事让我彻底想明白一个问题AI应用的中断恢复和传统Web服务的断点续传根本不是一个物种。传统接口的“重试”很简单——请求失败了重新发一次就行因为它是无状态的。但AI应用不一样。一次大模型调用可能持续几十秒一次多轮对话可能跨越十几分钟一次Agent任务链可能包含十几个串行步骤。这里面有状态有上下文有已经花掉的算力成本。你不可能简单地“重发一次”因为重发意味着重新计费、重新等待、重新消耗配额。所以这篇内容我想把“连接解耦 状态持久化”这套组合拳拆开讲透。它不是什么高深的新技术而是把两个老思路用在AI场景里连接层不持有业务状态状态层不依赖连接存活。听起来像废话但真正落地的时候坑比想象中多得多。这篇文章适合谁看如果你正在做AI对话产品、Agent工作流、批量推理服务或者任何“一次请求要跑很久”的系统那这套思路你大概率用得上。如果你只是调个单轮API那可以先收藏等业务复杂了再回来翻。2. 连接解耦让“谁在等”和“谁在算”彻底分家2.1 同步等待模式为什么会把系统拖垮大多数AI应用起步阶段都是同步的客户端发一个HTTP请求服务端调用模型等模型返回再把结果塞进响应里发回去。这个模式在Demo阶段没问题但它有三个致命伤。第一连接寿命等于任务寿命。一个Agent任务跑三分钟这个HTTP连接就得挂三分钟。中间任何一次网络切换、代理超时、负载均衡器的空闲回收都会把连接掐断。用户那边看到的就是“请求失败”但服务端其实还在傻乎乎地跑。第二并发能力被连接数锁死。每个等待中的请求都占着一个连接槽位。假设你的网关最多撑一千个长连接那同时最多就只能有一千个任务在跑。但AI任务的特点是“算得慢、占资源多”真正瓶颈在GPU而不在连接这种绑定完全是浪费。第三重试语义模糊。连接断了客户端不知道服务端到底算没算完。重试吧可能重复计算不重试吧可能任务其实成功了。这种不确定性在计费场景下是灾难。我见过一个团队的做法是“客户端超时设长一点”设到十分钟。结果就是大量僵尸连接堆积网关内存暴涨最后整个服务雪崩。这不是超时设多长的问题是架构本身把两件不该绑在一起的事情绑在了一起。2.2 异步任务化把“请求”变成“工单”解耦的核心动作只有一个客户端提交的不再是一个“请求”而是一个“任务”。服务端收到后立刻返回一个任务ID然后把这个任务丢进队列由后台的工作进程去消费。客户端拿着任务ID去轮询或者订阅结果。这个转变带来的好处是连锁的。连接层变得极薄——它只负责收任务、发ID、查状态每个操作都是毫秒级的连接可以立刻释放。任务层变得可控——队列可以削峰填谷工作进程可以按GPU数量精确扩容。状态层变得独立——任务跑到哪一步、中间产出了什么全部落在持久化存储里和连接没有任何关系。具体到接口设计我习惯用三个端点POST /tasks提交任务返回task_id立即响应不阻塞GET /tasks/{task_id}查询任务状态和结果轻量级GET /tasks/{task_id}/stream可选用SSE推送进度但断线不影响任务本身这里有个容易忽略的细节提交任务时要把幂等键带上。客户端生成一个唯一ID服务端用它去重。这样即使用户手抖点了两次提交或者网络重试导致重复请求也只会创建一个任务。幂等键的生成建议用“用户ID 业务标识 时间窗口”的组合不要用随机数否则重试时对不上。2.3 轮询、SSE、WebSocket三种回传方式怎么选任务提交出去之后结果怎么回到客户端三种主流方式各有适用场景。轮询是最简单也最稳的。客户端每隔一两秒查一次状态。缺点是实时性差、有无效请求。但对于大多数AI任务来说用户对“秒级延迟”并不敏感——一个要跑三十秒的任务多等两秒查状态完全可接受。我的建议是默认用轮询间隔用指数退避比如1秒、2秒、4秒、8秒封顶10秒。这样既不会打爆服务端又能及时拿到结果。**SSEServer-Sent Events**适合需要展示进度的场景。比如文档逐段生成、Agent逐步执行用户想看到“正在处理第3步”。SSE是单向的、基于HTTP的比WebSocket轻。但要注意SSE连接断了之后客户端要能自动重连并且重连后要能“续上”之前的进度流。这就需要在服务端把进度事件也持久化重连时从上次的游标继续推。WebSocket适合双向交互比如实时对话。但它的复杂度最高连接管理、心跳、重连、消息顺序都要自己处理。我的经验是除非真的需要双向实时通信否则不要上WebSocket。很多团队用WebSocket只是为了推进度这是杀鸡用牛刀SSE完全够用。方式实时性实现复杂度断线恢复难度适用场景轮询低极低无大多数异步任务SSE中高中中进度展示、流式输出WebSocket高高高双向实时对话选型的原则是从最简单的开始不够用再升级。我见过太多项目一上来就WebSocket结果光连接管理就写了一堆代码业务逻辑反而没时间打磨。3. 状态持久化任务跑到哪了必须有个地方说得清3.1 任务状态机的设计别用“成功/失败”两个状态糊弄连接解耦之后任务的生命周期就完全由状态来驱动了。这时候状态机设计得好不好直接决定了恢复逻辑能不能写清楚。很多人的第一版状态机只有两个状态pending和done。这在简单场景下能用但一旦要支持中断恢复就完全不够。因为你不知道任务是“还没开始”、“正在跑”、“跑到一半断了”还是“跑完了但结果没存”。我推荐的状态机至少包含这几个状态queued已入队等待工作进程领取running正在执行此时要记录started_at和当前步骤paused主动暂停或被动中断保留中间结果succeeded全部完成结果已持久化failed不可恢复的错误记录错误原因cancelled用户主动取消关键在running和paused之间的转换。当工作进程检测到要中断时比如收到优雅停机信号、或者步骤之间主动检查点它应该把任务置为paused并把当前进度写进去。下次恢复时从paused重新入队工作进程读取进度从断点继续。这里有个设计决策进度粒度多细才合适太粗了恢复时重复计算多太细了写状态的频率太高。我的经验是按“不可分割的最小工作单元”来设检查点。比如文档处理一页是一个单元对话生成一轮是一个单元Agent任务一个工具调用是一个单元。每个单元完成后写一次状态这样恢复时最多重复一个单元的工作量。3.2 检查点写什么不只是“第几步”还有“算出了什么”状态持久化最容易犯的错是只存了一个进度数字比如current_step: 5。然后恢复的时候发现第5步的输入是什么、中间产出的向量是什么、已经调用的工具返回了什么全都没存。结果就是“知道跑到第5步了但没法从第5步继续”。所以检查点里至少要包含四类信息第一类是位置信息。当前在第几个步骤、第几个单元、循环的索引值。这是最基础的。第二类是累积产出。比如已经生成的文本、已经提取的实体列表、已经调用的工具结果。这些是后续步骤的输入丢了就得重算。第三类是上下文状态。比如对话历史、Agent的中间推理、模型的KV Cache引用如果用了缓存的话。这部分往往体积大需要权衡存多少。第四类是元信息。检查点创建时间、版本号、对应的代码版本。版本号很重要——如果代码逻辑变了旧检查点可能不兼容需要能识别出来并决定是迁移还是丢弃。存储介质的选择也有讲究。Redis适合存轻量级的状态和进度读写快适合高频更新。对象存储适合存大块的中间产出比如生成的文档、向量数据。数据库适合存最终结果和任务元数据方便查询和审计。我通常的组合是Redis存实时状态对象存储存大产出数据库存最终结果三者用task_id关联。3.3 幂等性恢复时最怕“做了一半又做一遍”中断恢复的本质是“重放”而重放最大的风险是副作用被重复执行。比如一个任务已经给用户发了通知邮件恢复时又发一遍已经扣了费恢复时又扣一次。解决这个问题的唯一办法是幂等。每个有副作用的操作都要有一个幂等键执行前先检查“这个操作是不是已经做过了”。具体做法有两种。一种是状态标记法在检查点里记录“第3步的邮件已发送”恢复时先读这个标记已发送就跳过。另一种是去重表法每个副作用操作往一张去重表里写一条记录键是task_id step_id operation_type执行前先查表。我倾向于两者结合轻量的用状态标记重量的比如涉及外部系统的用去重表。去重表要设过期时间不然会无限膨胀。过期时间设成“任务最大可能恢复窗口”就行比如任务最多跑一天那就设两天。还有一个隐蔽的坑外部API的幂等性不一定可靠。有些第三方接口号称支持幂等键但实际上只在短时间内去重。所以关键操作最好在自己的系统里再做一层去重不要完全依赖外部。4. 把两者拼起来一次完整的中断恢复流程长什么样4.1 正常执行路径从提交到完成的每一步先走一遍没有中断的正常流程把每个环节的数据流理清楚。用户提交任务带上幂等键。接入层校验参数生成task_id把任务元数据写入数据库状态置为queued然后把task_id推入消息队列。整个过程在几十毫秒内完成立即返回task_id给客户端。工作进程从队列里领取任务把状态改为running记录started_at。然后开始执行第一步。每完成一个工作单元做三件事把产出写入对象存储把进度和关键状态写入Redis检查是否收到中断信号。全部步骤完成后把最终结果写入数据库状态改为succeeded清理Redis里的临时状态。客户端轮询到succeeded去数据库拉结果。这个流程里工作进程不持有任何不可恢复的状态。它随时可以被杀掉因为所有需要延续的东西都已经落盘了。4.2 中断发生时的现场保护优雅停机的几个关键动作中断可能来自很多地方部署时的滚动更新、机器的OOM Killer、云厂商的抢占式实例回收、用户主动取消。不管哪种工作进程都应该有机会做“现场保护”。优雅停机的标准动作是收到终止信号后不再领取新任务但把当前任务跑到最近的检查点。具体来说工作进程在每一步开始前检查一个shutdown_requested标志如果为真就完成当前这一步或者如果这一步太长就在可中断的地方停下写检查点把状态置为paused然后退出。这里的关键是检查点的位置要选在“可中断点”。比如调用大模型API请求发出去了但还没返回这时候中断就会丢失这次调用的结果。所以要么等它返回要么在调用前就记下“即将调用”恢复时重新调用。我通常选择等返回因为大模型调用通常几十秒内能完成等一等比重新调用划算。如果进程是被强杀的比如OOM那就没有优雅停机的机会。这时候依赖的是最后写入的检查点。所以检查点的写入频率决定了最多丢失多少工作。这也是为什么前面说“按最小工作单元设检查点”——单元越小丢失越少。4.3 恢复时的状态校验怎么判断“这个检查点还能不能用”恢复不是简单地把paused改成running就完事。检查点可能已经失效了原因有很多代码版本变了、依赖的外部数据变了、检查点本身损坏了。所以恢复流程的第一步是校验。校验内容包括检查点的版本号是否和当前代码兼容、检查点引用的中间产出是否还存在、任务是否已经超时超过最大允许时长就该放弃、幂等键是否已经被其他实例处理过。校验通过后工作进程读取检查点把状态恢复到中断前的样子然后从下一个单元继续。校验不通过就要决定是“从头重跑”还是“标记失败”。我的原则是如果重跑成本可接受就重跑如果成本很高就标记失败并通知用户。不要试图做复杂的检查点迁移那是个无底洞。还有一个细节恢复时要防止多个工作进程同时恢复同一个任务。这需要分布式锁用task_id作为锁的键拿到锁的进程才能恢复。锁要设超时防止进程崩溃后锁不释放。5. 那些文档里不会写的坑我在实操中踩过的五个跟头5.1 坑一检查点写太频繁把Redis打爆了第一个项目我设的检查点粒度是“每生成一个token写一次”想着这样恢复时零丢失。结果一个长文本生成任务几万个token就是几万次Redis写入。Redis的QPS直接飙到上限其他业务全被拖慢。后来改成按“段落”写一段几百个token写入频率降了两个数量级恢复时最多重生成一段完全可接受。检查点的粒度不是越细越好要算“写入成本”和“重算成本”的平衡点。一般来说重算成本在一个工作单元以内是可接受的。5.2 坑二状态和产出分开存结果对不上有段时间我把进度存在Redis产出存在对象存储结果恢复时发现进度说“第5步完成”但对象存储里第5步的产出是空的。原因是写Redis成功了写对象存储失败了两者不在一个事务里。解决办法是调整写入顺序先写产出确认成功后再写进度。这样最坏情况是“产出写了但进度没更新”恢复时会重算一步产出被覆盖结果正确。反过来就会丢数据。如果产出写入本身可能部分成功那就用临时文件加原子重命名的方式保证要么全有要么全无。5.3 坑三恢复后重复调用外部接口被限流了一个任务调用了某个外部服务中断恢复后重新调用结果因为短时间内重复请求被对方限流任务又失败了。更糟的是有些接口重复调用会产生重复的业务数据。后来我在检查点里加了“外部调用记录”记录每次调用的请求ID和结果。恢复时先查记录已经成功的不再调用。对于不支持幂等的外部接口这个记录就是唯一的防线。记录本身也要持久化不能只放内存。5.4 坑四任务状态和实际执行不一致出现“幽灵任务”有一次排查问题发现数据库里有个任务状态是running但队列里没有它工作进程也没在跑它。这就是“幽灵任务”——状态说在跑实际没人跑。原因是工作进程在更新状态为running之后、真正开始执行之前崩溃了。状态更新了但执行没开始。解决办法是加一个心跳机制工作进程定期更新任务的heartbeat_at监控程序发现某个running任务的心跳超过阈值没更新就把它重新入队。阈值一般设成“正常单步执行时间的3倍”。5.5 坑五恢复逻辑本身有bug越恢复越乱最惨的一次是恢复逻辑写错了把一个已经succeeded的任务又恢复了一遍导致结果被覆盖用户看到了错误的内容。原因是状态判断的条件写漏了一种情况。教训是恢复逻辑要有完备的测试。我后来建了一个测试集覆盖各种中断点、各种状态组合、各种异常情况每次改恢复逻辑都跑一遍。这个测试集的价值极高因为它模拟的都是线上真实发生过的场景。6. 进阶思路让恢复从“能用”变成“好用”6.1 分级恢复不是所有中断都值得恢复不是每个任务都值得花力气恢复。一个跑了两秒的任务重跑比恢复还快。所以可以按任务时长分级短任务直接重跑中等任务从检查点恢复长任务才值得做精细的恢复。分级的标准可以基于历史执行时间的分位数。比如P50在一分钟以内的任务直接重跑P50超过五分钟的必须支持恢复。这样能把恢复逻辑的复杂度用在真正需要的地方。6.2 预写日志把“将要做什么”也记下来检查点记录的是“已经做了什么”但有时候还需要知道“将要做什么”。比如一个多步骤任务恢复时不仅要回到第5步还要知道第5步原本计划调用哪个工具、传什么参数。预写日志Write-Ahead Log就是干这个的在执行每一步之前先把这一步的计划写下来。恢复时读取计划就知道下一步该干什么。这和数据库的WAL是一个思路用“先记录意图再执行”来保证可恢复性。6.3 状态压缩别让检查点越滚越大如果一个任务的检查点包含所有历史产出那它会越来越大最后写入和读取都变慢。解决办法是压缩只保留恢复所必需的最小状态历史产出可以归档到冷存储。比如对话任务恢复只需要最近几轮对话和系统提示更早的历史可以摘要化或者丢弃。Agent任务恢复只需要当前步骤的输入和关键中间结果之前的工具调用记录可以精简。压缩策略要根据业务特点来定原则是“恢复时能重建出正确的执行上下文”。7. 落地建议从现有系统改造的实操路径如果你现在的系统还是同步模式想改成这套架构我的建议是分三步走不要一次全改。第一步先把接口异步化。加一个任务提交端点返回任务ID后台用队列消费。这一步不改状态持久化任务失败了就失败先让连接解耦跑起来。这一步的收益最明显——网关不再被长连接拖垮。第二步加状态持久化。在关键步骤写检查点支持从检查点恢复。这一步可以先只支持“进程重启后恢复”不处理复杂的异常情况。跑稳了再逐步完善。第三步加优雅停机和心跳。让部署更新不再丢任务让幽灵任务能被自动发现。这一步做完系统的健壮性就上了一个台阶。每一步都要有监控。关键指标包括任务成功率、平均恢复次数、检查点写入延迟、幽灵任务数量。这些指标能告诉你恢复逻辑是不是真的在工作。最后说一个我自己的体会中断恢复这件事做的过程比结果重要。你在设计恢复逻辑的时候会被迫把任务的每个步骤、每个状态、每个依赖都想清楚。这个过程本身就会让系统设计变得更清晰。很多bug不是恢复逻辑引入的而是恢复逻辑把原本就存在的设计问题暴露出来了。所以别把它当成一个负担当成一次梳理系统的机会。