1. 故障恢复为什么成了 LLM 服务的死穴
1.1 一张卡被推理引擎“长租”之后
先说一个我亲身踩过的场景。线上跑着十几个 LLM 推理实例,某天凌晨某个实例因为 OOM 直接崩了,结果新实例在调度器里足足等了接近一分钟才被拉起。原因不是镜像拉取慢,也不是模型权重加载慢,而是那张 GPU 卡上一块块“孤儿显存”迟迟不释放——前一任引擎进程已经没了,但它申请过的显存块、KV cache 页表、CUDA context 资源还赖在卡上。
做传统后端服务的人可能觉得这不可思议:进程死了,操作系统不是会自动回收内存吗?但 GPU 不是这样。显存是驱动和运行时层管理的资源,一个 CUDA context 关联的显存块,如果这个 context 没有走正常的销毁流程,驱动只会把它标记为“残留”,而不会像 CPU 内存页那样被内核立即回收。这背后的原因主要是安全隔离和硬件特性——显存里可能有其他进程的敏感数据,驱动宁可留着也不做激进清理。
更要命的是,就算驱动把这些残留块释放了——比如后面我手动调用了 cudaDeviceReset 之类的接口——上游调度器也没法立刻知道这块卡可以用了。很多集群的 GPU 资源回收流程是“周期性扫描”,扫描间隙你只能干等。所以说,在传统推理架构里,单实例故障可不只是一个实例的事,它是整张卡甚至整个节点不可用的导火索。
这就是标题那句话真正的痛点:显存的生命周期被绑死在推理引擎进程上,进程一死,显存资源跟着“陪葬”。哪怕模型权重在磁盘上随时可以加载,前向计算逻辑随时可以初始化,显存这一关不过,恢复就快不起来。
1.2 传统故障恢复流程搬到 GPU 上为什么不灵
传统微服务故障恢复为什么快?核心是“进程消亡,内核兜底”。服务崩了,内核回收它的端口、文件描述符、内存页,新实例起来后重新监听端口,流量一切就完事。整个过程里,基础设施只需要做一件事:把请求路由到新实例。至于内存里那些没有同步到下游的状态——靠日志和事件重放就能找回来。
把这个模型硬搬到 GPU 推理服务上,会遇到三个具体障碍。第一是显存的“不可隐式回收”问题,刚刚已经讲了,CUDA 资源残留在驱动层面,不会因为进程退出就立即可用。第二是 KV cache 的恢复代价。LLM 服务跟普通 Web 服务不同,它最能占显存的不是模型权重,而是运行时生成的 KV cache。一个长上下文请求在解码阶段可能占掉几十 GB 显存,这玩意一旦丢失,要么从 prefill 阶段重新算一遍,要么就切割请求——前者意味着巨大的时延回退,后者意味着用户要重新发一次请求,体验直接断裂。
第三是模型状态和请求状态的绑定关系。LLM 的推理往往带记忆、带前缀缓存、带 Beam Search 内部的拓扑状态,这些东西散落在引擎进程堆里。引擎崩了,这些中间状态就成了一堆没有主人的垃圾。你能不能重建?能。但重建的前提是先知道“这个请求之前看到了哪里、算到了哪个 token”,而这些信息通常也没随显存一起持久化。
所以传统恢复方案在 GPU 推理场景里根本不够用:你缺的不是“拉起一个新进程”的能力,而是“让新进程接管一块已知可用的显存,并在其中快速恢复请求进度”的能力。这两个问题,恰恰是 Dynamo 这个框架里最有意思的部分。
2. 解耦显存生命周期:Dynamo 的核心设计思路
2.1 把“显存资源”和“引擎进程”拆成两个生命周期
Dynamo 的做法我读下来的第一感受是:它把 LLM 推理服务里两个搅在一起的职责彻底拆开了。一个职责是“显存资源的分配、管理、释放”,另一个职责是“模型的加载、推理、调度”。在传统架构里,这两个职责是同一个进程里耦合在一起的:引擎既是显存的申请者,又是显存里数据的唯一搬运工。
Dynamo 的设计更像是把显存资源池做成一个独立生命周期体,甚至可以说是一个带状态的基础设施组件。引擎实例只是在某个时刻“认领”了池子里的一段显存,用完或者崩溃后,这段显存的控制权会回到池子手里,而不是跟着崩溃进程一起“死亡”。
这样带来的直接好处是:显存的“拥有者”不是进程,而是资源池。进程可以死,但资源池不会。同理,新引擎实例启动时,它需要做的不是去驱动层“抢”一块显存,而是去资源池“申请”一段已经被记录在册、随时可以映射的内存区域。少了扫描残留块、等待异步释放、重新初始化分配器这些环节,恢复延迟从分钟级降到了秒级。
我理解这个思路有点像内存数据库和操作系统的关系:Redis 进程崩了,操作系统并不会丢页;换一个进程起来,还是同一批页。Dynamo 希望 GPU 显存也具备这种“资源证券化”的属性——它是物理卡上的资产,而不是某个进程的私有财产。
2.2 KV Cache 外部化:从“引擎私有”到“可恢复资产”
混过 LLM 推理的人都明白,显存里最让调度器头疼的往往就是 KV cache。模型权重是静态的,加载一次就是读一遍权重文件;但 KV cache 是动态的,随着请求的每一步解码不断增长、不断释放。在 PagedAttention 一类的方案里,KV cache 被切成固定大小的块,按需分配,这已经朝“可管理”迈了一大步。但块表本身、块的分配状态、每个请求对应的页映射,这些元数据仍然保存在引擎进程内部。
Dynamo 把 KV cache 进一步“外部化”:它不仅把显存块当资源池管理,还把 KV cache 的分配状态、请求与块的映射关系、块的引用计数和版本号,全部导出到一个能被其他组件读取的位置。也就是说,系统里不止有显存池本身,还有一份显存的“账本”。
这份账本的价值在于:新引擎进程起来后,不需要扫描整张卡去猜哪些块是哪个请求的,它只要读一遍账本,就知道“哦,这个请求的 KV cache 还在那些地方”。如果请求还在处理中,新引擎就能按账本把块“认领”回来,接续解码;如果请求已经结束了,新引擎也能快速把块标空、放回资源池。
数据说不上大,但比“全量恢复”强太多。有些方案为了快速恢复干脆把整个 KV cache 同步到远端,那成本高得吓人。Dynamo 这个思路更像是拿“元数据状态”换“物理数据重建”,把一万步的计算变成了两步的操作。
2.3 秒级故障恢复的完整链路
把设计串起来看,秒级恢复本质上是一条流水线,每个环节都必须低于百毫秒才算合格。首先是故障检测,Dynamo 会通过心跳和租约同时判断引擎是否存活;接着是资源接管,资源池确认租约失效以后,立刻把该实例名下的显存块标记为“可接管”;然后是 KV cache 恢复,这一步不是从零算,而是依赖账本和预置的 cache 状态做重建或重放;最后是请求迁移,把还在排队或执行中的请求重新交到新引擎手上。
这条链路里最值得一提的设计是:故障恢复并不需要等待所有请求完成。传统方案通常是把请求全部丢弃或全部重新排队,但 Dynamo 会区分哪些请求可以接续、哪些必须重算,通过幂等策略把损失控制在一个很小的范围内。
我以前在调优推理服务时就吃过这种亏:一个实例崩了,所有正在跑的请求都被判死刑,数据库连接层的重试风暴直接把下游打挂。如果当时有类似 Dynamo 这种按请求级别做恢复接管的能力,很多事故根本不需要那么大的动静。
3. 关键机制与实操细节
3.1 故障检测:不只是“没心跳就杀掉”
故障检测是整个恢复机制的导火索,但如果只做“心跳超时,直接杀”,你会发现两个衍生问题。第一个是误杀,网络抖动一百毫秒就触发重启,结果实例没崩,服务先被自己搞崩了。第二个是脑裂,旧实例其实还活着,但你启动了新实例,两个引擎同时对着一个资源池执行,显存里的 KV cache 就成了一团浆糊。
Dynamo 的做法是引入租约机制。引擎实例必须周期性续租,但租约的有效时间会比心跳间隔留出不少余量。当一个实例的租约过期,资源池才会认定它已经失联,这时候其他实例才能去接管它名下的区块。这套机制和分布式系统里的 etcd lease 思路本质一致,但 Dynamo 把它做得更专门:租约不只是“活着”的证明,它还会在租约里携带资源控制权的版本号。
版本号这个细节很关键。新实例接管时,必须带着一个大于等于旧版本号的控制权声明,资源池才允许它操作 KV cache 块。这就能杜绝旧进程突然又活了、回来乱写数据的情况。调度参数上,我自己一般会把心跳间隔设在 500 毫秒到 1 秒之间,租约超时设在 5 秒左右,一套值下来就能覆盖大部分网络抖动,又不会让故障感知太迟钝。
3.2 显存元数据重建与接管
新实例接管显存,最怕的其实是“我不知道这一块之前在干嘛”。比如一个请求在解码到第 700 个 token 时崩了,新实例接管后如果不清楚它的 KV cache 分布,就只能从头 prefill。prefill 一遍长上下文是什么代价,相信跑过 128K 上下文的人都懂,那是按分钟算的。
Dynamo 削弱这个问题靠的是两级元数据。第一级是显存块分配表,就是上面说的账本,记录哪些物理区域被哪个请求占用、块大小是多少、块的引用计数是多少。第二级是请求级的上下文摘要,记录请求当前的状态、已经处理的 token 数量、解码阶段的采样参数等。这两级数据被放在显存之外(主存或远端存储),定期打点更新。
新实例起来以后,读元数据、凭租约接管显存块、再按请求上下文接续执行,整个过程不依赖对显存的“猜”。我实测下来,这一段的耗时主要在元数据读取和校验上,只要不是远端存储抽风,几十毫秒内就能完成。这里有一个容易被忽略的点:元数据本身也要保持一致性,如果 KV cache 账本和实际显存块状态不一致,宁可触发一次保守的全量重建,也别硬着头皮接管可能损坏的数据。
3.3 KV Cache 恢复的两种路线
KV cache 的恢复策略,Dynamo 给的不是单一路线,而是两条路可选,区别主要在一致性和恢复速度的权衡上。第一条路是“收账式”恢复,依赖上面讲的账本,把 KV cache 块直接映射回来,要求显存里的物理数据没有被破坏。这个路线最快,几乎等于“人换了、房屋结构没动”,但它要求 KV cache 不做任何本地修改,比如没写入未持久化的更新。
第二条路是重建式恢复,从最近的检查点开始,把请求的输入重新喂给模型,重新执行 prefill。这条路慢,但它不依赖显存数据的完整性。工程上通常不会二选一,而是做分层:短上下文请求走重建,长上下文请求优先走账本接管,或者把两者组合成“先接管再校正”。
我自己在考虑这类设计时会额外关注一个参数:token 重算成本。如果请求的平均上下文长度在 1K 以内,重建式恢复的耗时可能就几十毫秒,根本没必要做镜像之类的重量级同步;但如果平均上下文到了 32K 以上,别犹豫,直接把账本接管和异地 KV cache 双份写方案提上日程。Dynamo 的灵活之处在于,它没有把恢复策略焊死在框架里,而是让你根据自己的业务时延曲线去配置。
3.4 请求迁移与幂等控制
显存和 KV cache 都接管了,请求到底怎么继续执行?很多方案到这里就翻车了:虽然 KV cache 还在,但请求对象本身被丢了,恢复只能“恢复状态、不恢复业务”。Dynamo 会在引擎崩溃前把每个请求的执行阶段记录下来——它是处于 prefill、decode 还是 wait 状态,调度时分配到了哪个批,计算到第几个 token 了。
新引擎接管后,会按照记录的状态恢复请求对象,然后把还在运行中的请求重新挂到调度队列里。这里面最需要小心的是幂等性:如果旧引擎已经把结果返回给了调用方,但还没来得及记录,新引擎又把同样的结果重发了一遍,上层业务就收到重复响应了。对这种问题,工程上的处理方式是标记每个请求的“投递水位”,新引擎只投递水位以上的结果,已经在应答层发出的就标记为重复、跳过。
这一块我和团队踩过一次大坑:没有做响应去重,恢复后用户收到两条一模一样的补全结果,下游直接把数据库写重了。所以做故障恢复,不只是恢复引擎,还得恢复“请求处理进度”的语义。
3.5 落地时参考的编排参数示例
以下是我参照 Dynamo 论文思路做的一个配置示例,主要目的是说明每个参数在系统里扮演什么角色,不是官方部署文档。
fault_recovery: lease: renew_interval_ms: 500 # 心跳续租周期 lease_timeout_ms: 5000 # 租约超时判定 version_check: true # 启用版本号仲裁,防脑裂 kv_cache: external_ledger: true # 启用KV cache账本外部化 snapshot_interval_steps: 128 # 每128步做一次上下文状态打点 recovery_mode: ledger_first # 优先用账本接管,失败再走重建 request: idempotency_window: 60s # 响应去重窗口,避免重放风暴 resume_checkpoint: true # 恢复请求执行水位这里特别注意snapshot_interval_steps。打点太频繁,元数据的写入开销会反过来吃掉系统吞吐;打点太稀疏,恢复接管的精度又不够。128 步是我在长上下文场景里的折中选择,如果你跑的是短请求,可以放宽到 512 甚至 1024。核心依据就一条:恢复时能容忍的最大 token 回退量是多少,参数就往哪个方向调。
4. 常见问题与排查经验
4.1 显存碎片化:解耦不等于没有碎片
把显存生命周期从引擎里解耦后,很多人会天真地以为资源池完美无缺了,但碎片化问题没变,甚至更突出了。资源池里某块显存是空闲的,但相邻区域都被占着,一个大请求想要连续空间时照样分配不出来。PagedAttention 这类方案只是把碎片从“字节级”降到“块级”,并没消除。
处理思路是给资源池加一个回收阈值,当碎片率达到一定比例就主动触发整理。整理的方式是把活着的 KV cache 块迁移到紧凑区域,把零散空闲页合并成大块。这个操作会短暂占用引擎的计算资源和显存带宽,所以一定要放到低峰期或者故障恢复后的静默窗口。
4.2 恢复风暴:多个引擎同时挂掉
故障恢复设计得再好,也怕“连环炸”。某个上游存储抖动导致一批推理引擎同时失联,资源池在同一秒内收到几十个接管请求,元数据服务直接被打满,结果没挂的引擎反而先超时了。
应对措施是要给故障恢复设置并发水位和优先级。同一时间最多允许 N 个实例做接管;新请求优先让给健康实例,别把恢复负载全压到同一批节点上。我在做容量规划时还会留出 20% 的“恢复冗余”,专门用来兜底这种并发接管场景。另外,接管过程尽量做成异步和分片的,元数据读取按实例分批处理,避免一把梭。
4.3 多实例共享卡时,故障隔离边界怎么定
现在单卡上跑多个推理实例很常见,一张 80G 的卡被拆成三四个服务用。可问题来了:故障隔离边界是按“卡”还是按“显存分区”?Dynamo 的解耦设计虽然让显存资源可接管,但一张物理卡上的带宽、SM 计算资源仍然是共享的。如果被接管的实例是计算密集型的,它恢复后可能严重影响同卡上的其他实例。
我的经验是,故障隔离边界最好同时考虑显存和计算两个维度。显存可以按资源池切分,但计算资源要预留独立的 SM 分区或者至少设置流优先级。否则你辛辛苦苦做到秒级恢复,结果恢复后的实例和邻居抢算力,服务质量不达标,恢复就没意义了。
4.4 误判导致的频繁切换
恢复机制越灵敏,误判代价越高。特别是网络抖动剧烈时,引擎明明没崩,租约却超时了,于是系统强行接管、重启实例,等于把一个健康服务当成故障服务重启了一遍。反复几次,服务的可用性反而比不做故障恢复时更差。
排查这类问题,重点看租约超时和心跳间隔的比值。我踩过的经验是:不要低于 3:1,最好到 5:1 以上。你要是怕误判,还可以加一个“二次确认”机制:租约超时后不直接接管,先发一个探活请求,连续两次没有响应才判定为故障。当然这会增加几十毫秒的恢复延迟,但换来的稳定性通常更值。
4.5 恢复后的长尾请求问题
秒级恢复只是第一步,恢复后的引擎并不等于满血状态。KV cache 账本接管恢复的请求虽然接上了,但新引擎的解码调度队列和旧引擎不一样,原来按批调度计算的顺序被打乱了。尤其那种几百个请求同时恢复的场景,新引擎重新配批、重新做显存整理,整体吞吐会先掉一截,然后才慢慢爬升。
解决思路是给恢复后的实例设置“预热期”,先把恢复的请求限速或限并发,同时做轻量的显存预整理。等调度器攒够了批大小,再逐步加大并发,可以有效避免恢复成功后的长尾超时。这个细节论文里没怎么展开,但工程上很关键。
5. 我的一些实操心得与边界思考
5.1 哪些业务最适合吃这波红利
把显存生命周期解耦这套思路,并不是所有 LLM 服务都需要,但它对三类业务价值很大。第一类是长上下文强交互的服务,比如 Agent 场景,一个会话动不动几十万 token,恢复时 KV cache 没了等于让用户等上几十秒重新计算,根本没法接受。第二类是高可用要求严格的在线服务,像 LLM 网关后面挂的推理集群,故障恢复时间直接影响 SLO。第三类是低成本的多租户混部,一块卡上跑好几个小模型实例,如果恢复时把整块卡锁死,混部的成本优势就全没了。
反过来,如果业务本身是短请求、无状态,或者容忍一次重算,那这类复杂机制可以缓一缓。投入产出比要算清楚。
5.2 解耦带来的新复杂度
任何优秀方案都有代价。解耦显存生命周期之后,你得额外维护一个资源池的状态机、一个账本存储、一套租约协议和一套恢复编排流程。这些东西本身也可能成为故障点。我见过生产环境里资源池元数据服务挂着,导致所有实例都无法分配显存的场景——这比单个引擎崩溃严重多了。
所以不要从“单点故障”视角理解 Dynamo,它是一个分布式系统设计。你引入它的同时,必须为新增的组件补齐配套的监控、告警和降级方案。条件允许的话,账本存储最好做双副本或者三副本,毕竟它承载的是整个 GPU 资源池的可用性。
5.3 可以继续扩展的方向
以这个设计为起点,我觉得后续还可以往三个方向挖。一个是把显存资源池和 GPU 调度打通,做到跨节点的显存资源统一编排,让故障恢复的粒度从实例级进化到集群级。另一个是结合 KV cache 量化压缩技术,降低账本接管的数据量,让恢复更快、显存利用率更高。再一个是和上层的路由网关联动,做恢复后的流量渐进式切换,避免刚恢复的实例被突发流量打挂。
这些方向不是论文里都有的,但读完 Dynamo 的解耦设计,我觉得它真正打开了一个思考角度:在 GPU 推理服务里,资源到底是为谁服务的,是进程,还是业务本身?把这个问题想明白,很多架构上的难题都会有新的解法。