做 LLM serving 的人,应该都被 GPU 显存折腾过。模型权重、KV cache、临时 activation、请求上下文,全都挤在有限的 HBM 里;推理引擎一崩,整个状态基本归零。最近读到的这篇 Dynamo 论文,把问题一下子点透了:把 GPU 显存生命周期从推理引擎里解耦出来,故障恢复才能做到秒级。这里的核心关键词不是“快”,而是“归谁管”——显存不再跟着推理引擎进程生死,而是由独立的资源层托管。这个思路对自研推理引擎、LocalAI 这类轻量封装,还有 vLLM/TGI 生产部署,都有参考价值。想搞清楚 LLM serving 的高可用怎么做、故障恢复怎么设计,这篇值得慢慢拆。
1. 先说痛处:LLM serving 的故障恢复为什么这么慢
1.1 本质是有状态服务,不只是进程重启
很多人在设计推理服务高可用时,第一反应是“进程挂了就拉起一个新进程”。这个想法放在普通 Web 服务上没问题,放在 LLM serving 上就完全不够。
LLM serving 的有状态性体现在三块:模型权重通常有几十 GB 到上百 GB,从磁盘加载一次就是漫长的等待;KV cache 是请求执行过程中不断增长的动态状态,长上下文请求可能占掉十几 GB 显存;调度器还记着每个请求的进度、优先级、显存块分配关系。这些状态全都在 GPU 显存里,推理引擎进程一退出,显存就会被驱动回收,状态等于全部蒸发。
恢复代价最隐蔽的是 KV cache。你可能会想,KV cache 本来就是推理动态算出来的,丢掉后让客户端重试不就行了?对于短请求确实可以,但长上下文、多轮对话场景代价非常高:用户的上下文可能几百上千 token,重新 prefill 一遍,GPU 又要从头跑一遍 attention 计算。越长的上下文,恢复成本越高,客户端体感也越差。所以 LLM serving 的故障恢复,绝不能简单理解成“重启进程”。
1.2 常规高可用方案为什么显得笨重
目前生产环境里常用的恢复手段,我大概梳理了一下,都有明显的短板。
| 方案 | 恢复时间 | 显存开销 | 核心问题 |
|---|---|---|---|
| 冷备重启 | 分钟级 | 低 | 模型权重重新加载,KV cache 全部丢失 |
| 主备热备 | 亚秒到秒级 | 接近 2 倍 | 成本太高,显存同步本身复杂 |
| 检查点快照 | 秒到分钟级 | 额外磁盘/内存 | 快照与实时状态有时间差,回放会丢增量 |
| 客户端重试 | 看请求长度 | 低 | 长上下文重复 prefill,代价可能超过恢复本身 |
主机热备看着快,但等于把所有请求的状态再复制一份。检查点快照又受 PCIe 带宽和显存容量的双重限制,快照频率和恢复精度只能二选一。更麻烦的是,这些方案都在“重新建立一个状态”上做文章,没有人去质疑一个更底层的问题:原来的显存状态为什么一定要丢?
Dynamo 这篇论文的核心反而不是什么新奇的加速技巧,而是把“恢复”重新定义成“重新挂载”。如果崩溃前显存里的数据还在,那恢复就不是从磁盘拉权重,而是让新引擎接管这块还热着的显存。这个思路绕开了重加载、重 prefill、重算 KV cache 这些最费时的环节。
2. Dynamo 的核心设计:把显存生命周期从引擎里拿出来
2.1 解耦的不是模块,是资源所有权
传统推理引擎同时承担两件事:算 attention 和管显存。引擎启动时创建显存池,运行中申请、释放、整理显存,退出时驱动把这些资源一并回收。这看起来没问题,但它把资源的生命周期和计算进程的生命周期死死绑在了一起。
Dynamo 的做法是引入一个独立于推理引擎的显存生命周期管理者。推理引擎从“显存的主人”变成“显存的租户”。正常工作时,引擎向资源层申请显存块、加载权重、写入 KV cache;崩溃时,资源层并不会因为引擎进程退出就把显存释放,而是继续保留这些显存块和对应的元数据。新引擎起来后,通过资源层提供的句柄重新挂载到同一批显存块上。
我用一个厨房的类比来理解这件事:传统方式是厨师把所有备好的食材、调料、灶台都放在自己面前,厨师一离开,后厨就开始清台,下一个厨师来必须从头准备。Dynamo 相当于把备料间独立出来,厨师换人了,已经切好的菜、配好的料都还在,新厨师直接上手炒就行。
2.2 “生命周期”具体包含哪几段
显存生命周期被拆出来以后,至少包含四个阶段,每个阶段都有自己的管理职责。
- 申请与预留:资源层预先划出若干显存块,记录块的大小、地址、归属引擎、用途。
- 挂载与使用:推理引擎启动后通过资源层接口 attach 到显存块,开始读写权重和 KV cache。
- 接管与重挂载:引擎故障后,资源层冻结相关显存块,等待新引擎按元数据重新挂载。
- 释放与回收:请求全部结束,引擎正常下线后,显存块才真正归还给资源池。
这四个阶段里,最容易做砸的是第三段。因为“接管”不是说资源层什么都不做就行,它需要保证显存块不被其他请求占用、元数据不被覆盖,还要在引擎恢复期间维持一个一致的资源视图。这个设计和操作系统的进程间共享内存很像,只不过对象从普通内存换成了 GPU 显存,操作粒度从字节变成了大块 tensor 和 KV cache 页。
2.3 秒级恢复成立的三个前提
想做到秒级恢复,不能只嘴上说“显存还在”,还需要三个前提同时成立。
- 模型权重所在的显存块没有被释放,且分配拓扑可重建。
- KV cache 的分配关系能从共享元数据恢复,而不是靠扫描全卡内存。
- 调度状态、请求上下文和显存对象之间有一份可恢复的账本。
第一个前提相对容易满足,只要资源层不主动释放即可。第二个前提需要显存块管理做到足够细,KV cache 被切分成若干固定大小的块,资源层知道每个块属于哪个请求、哪一层、哪个 token 区间。第三个前提最容易被忽略,引擎崩溃时,如果只有一个“显存还活着”的结论,而不知道每个请求跑到哪一步,新引擎还是没法接管。
很多工程实现会把这份账本放到 CPU 内存或者独立的状态服务里,每次显存分配、释放、KV cache 扩容都先写日志。故障恢复时先恢复账本,再恢复显存映射。这个思路和数据库的 WAL 很像,不是重新算,而是靠日志重放到崩溃前的位置。
3. 秒级恢复的实操机制:从故障检测到请求续跑
3.1 先拆一段可量化的恢复链路
“秒级恢复”是一句很漂亮的话,但落到工程上必须拆成可测量的环节。我一般会把恢复链路切成五段:故障检测、资源接管、引擎重启、显存重挂载、服务就绪。
| 阶段 | 我比较关注的点 | 参考量级 |
|---|---|---|
| 故障检测 | 心跳超时还是主动探测,会不会误判 | 500ms 到 1s |
| 资源接管 | 显存冻结、租约转移、新引擎认领 | 数百毫秒 |
| 引擎重启 | CUDA context 初始化、引擎模块加载 | 1s 左右 |
| 显存重挂载 | 从资源层枚举显存块并重新映射 | 1s 以内 |
| 服务就绪 | KV cache 账本恢复、调度器对外提供地址 | 数百毫秒 |
论文里给出的秒级结果,应该是这五段叠加后的整体数字,而不是单指“进程重新启动”的时间。这里我建议不要只看平均值,重点要看 P99。故障恢复如果 95% 的请求都做到 1 秒,但某一次因为显存块定位慢了 30 秒,线上照样会被放大成事故。
3.2 故障检测:宁可慢一点,也别误杀
故障恢复的第一步是确认“谁挂了”。听起来简单,做起来全是细节。GPU 卡住的时候,心跳线程本身可能还在跑,因为它是 CPU 侧的;而一个进程如果进入了死循环,TCP 连接可能还正常,普通健康检查根本探不出来。
我的经验是,故障检测需要分层。CPU 侧做进程存活检测,GPU 侧做运行状态探针,资源层再做租约超时判断。租约机制尤其重要:引擎在正常运行时周期性续约,一旦超时未续约,资源层才判定该引擎已经失去对显存块的控制权。租约时间不能设得太短,否则 CUDA context 初始化瞬间、容器迁移、瞬时线程卡顿都会造成误杀;也不能太长,不然真正的故障会拖到很晚才被发现。很多系统把租约设在 1 到 3 秒之间,再配合 CPU 侧的秒级心跳。
3.3 显存重挂载与 KV cache 元数据重建
新引擎起来后,首先要做的是向资源层查询显存块列表。这里的关键不是把显存“复制”一遍,而是“重新建立映射”。资源层保存了每个显存块的 ID、大小、偏移和用途,新引擎拿到这些信息后,在自身进程里重建指向这些显存块的 tensor 视图。
KV cache 的恢复比权重更麻烦。权重是只读的,只要显存块还在,映射回来就能用。KV cache 是动态的,每个请求的块分配情况、每个序列的长度、block table 的位置,都要从元数据账本里恢复。我见过比较稳的实现是:显存资源层只负责保存“哪些物理块属于哪个逻辑块”,调度器负责保存“逻辑块和请求序列的对应关系”。两层账本结合起来,新引擎才能准确知道当前可以继续 decode 到哪个 token 位置。
这一步听起来抽象,本质就一句话:恢复的是“地址关系”和“逻辑关系”,不是再做一遍推理计算。这也是 Dynamo 能比检查点回放快出一个量级的原因。
3.4 客户端视角:请求重放与幂等
显存和 KV cache 恢复好以后,还有最后一个工程细节:客户端怎么继续请求。如果引擎故障时某个请求已经生成了 30 个 token 但还没返回给客户端,新引擎恢复后不能把这个请求当作全新请求从头生成一遍。否则客户端会收到前面 29 个 token 的重复内容,语义直接错乱。
我比较推荐的做法是在接入层做一个“已发送偏移量”的记录。每个请求在流式返回时,由接入层记录最后成功发给客户端的 token 位置;故障恢复后,新引擎从 KV cache 里对应的位置继续生成,接入层从记录位置开始续传。这类似于消息队列里的 consumer offset 管理,只是 offset 对应的不是消息序号,而是生成序列的 token 位置。很多做故障恢复的团队会在这一步栽跟头,只恢复了模型服务,没恢复客户端连接状态,最后照样被用户投诉。
4. 评估体系与实验设计:别只盯着“秒级”
4.1 恢复时间不是唯一指标
一个故障恢复方案是不是好用,至少有六个维度要看。恢复时间只是其中之一。
| 指标 | 为什么重要 |
|---|---|
| 平均恢复时间(MTTR) | 最容易宣传,但容易被极少数极端情况拉偏 |
| P99 恢复时间 | 决定线上真实体感,比平均值更值得盯 |
| 正常推理性能损失 | 持久化显存分配器可能带来额外开销 |
| 显存预留开销 | 预留的显存池会压缩可用于 batch 的容量 |
| 恢复成功率 | 恢复路径越长,失败概率越高,必须单独统计 |
| 故障类型覆盖 | engine 崩溃、OOM、GPU 卡死、整节点故障要分开测 |
很多论文都擅长把恢复时间画得很漂亮,却不告诉你为了让显存可恢复,平时要多占多少显存、每次显存分配要多做多少元数据写入。真正要落地的时候,这些才是决定方案能不能上的关键。
4.2 故障场景要覆盖“最坏情况”
我在评估这类系统时,会特别看两件事:故障注入到底打了哪些场景,以及恢复过程的“最后一次成功状态”离故障点有多近。
最基础的场景是主动 kill 掉推理引擎进程,这部分 Dynamo 思路大概率表现很好,因为显存没有被回收。但 GPU 硬件故障、CUDA error 导致 context 失效、显存 ECC 错误这类更贴近生产的场景,问题就复杂了。硬件故障发生时,显存里的数据可能已经损坏,资源层就算把显存块冻结住,也没法保证恢复出来的 KV cache 是可用的。所以如果团队做高可用设计,别把 Dynamo 当成所有故障的万能药,它更擅长的是“引擎级故障”和“软件级崩溃”。
另外,像 LocalAI 这类把推理引擎、模型文件、启动脚本打包在一起的轻量方案,直接套用这种架构会比较吃力。因为解耦的前提是显存资源层能独立于引擎存在,如果本地封装把所有东西都塞在一个进程里,故障恢复时依然要从头拉起整条依赖链。
4.3 从论文指标到生产结论
读到一篇论文,不能只记住“秒级恢复”这个结论。我会先看它的 baseline 是什么:如果对比的是冷重启,那秒级恢复并不稀奇,冷重启本来就要重新加载权重;如果对比的是热备和检查点,那意义就不一样。
还要看它的负载类型。长上下文请求占比越高,KV cache 的恢复价值就越大;短请求占比高,客户端重试的成本本身就不高,恢复快慢的收益会被削弱。也就是说,同一个方案在 chatbot 和 RAG 场景里的收益完全不同。决策的时候,先把业务流量结构摸清楚,再判断这套架构值不值得引入。
5. 落地时的避坑指南:从论文到生产环境
5.1 显存预留和资源碎片的权衡
第一个坑来自显存池预留。想要故障后还能接管显存,资源层通常要预先保留一部分显存,或者保证显存分配的元数据足够轻量。预留做多了,正常推理能开的最大 batch 就变小,吞吐直接受影响;预留做少了,引擎峰值显存增长时又可能申请不到资源,反而增加 OOM 风险。
实际操作中,我建议先做显存曲线采集,统计不同并发、不同上下文长度下的显存峰值分布,再决定预留水位。不要在没数据的情况下拍脑袋预留 20%。预留的粒度也很重要,建议按“显存块”而不是按“整卡”预留,这样多个请求可以共享预留区间,避免单请求放大开销。
显存碎片是另一个隐蔽问题。故障恢复后,新引擎重挂载显存块的顺序如果和原引擎不一致,某些本可以连续分配的块会变成碎片,长期运行后可用显存逐渐缩小。比较稳的做法是资源层在正常运行时尽量维持确定的分配顺序,并把顺序信息写进账本;恢复时严格按账本里的顺序重建映射,不重新做一次贪心分配。
5.2 跨容器/跨实例的显存共享限制
Dynamo 这种设计能够成立,有一个技术前提:新引擎进程能访问旧引擎持有的显存块。这在同一进程内没问题,跨进程依赖 CUDA IPC 之类的共享机制。落地到容器环境以后,情况会复杂不少。
同一台物理机、同一个 GPU、容器运行时配置一致,共享显存句柄通常可行。但一旦涉及 MIG 切分、GPU 虚拟化、不同容器镜像、不同 CUDA 版本,显存句柄能不能跨容器传递就是未知数。不要看论文里有个漂亮的架构图,就直接认为所有环境都能这么干。上线前先做一个最小验证:进程 A 申请一块 GPU 显存并写入数据,进程 B 在同一环境里能否通过句柄读取同一块显存。这一步不通过,后面的设计全得改。
5.3 一致性与日志顺序:别把恢复做成数据损坏
恢复方案里还有个经常翻车的点:显存写入和元数据日志并不是天然一致的。引擎可能刚往显存里写完一段新的 KV cache,还没来得及更新账本里的序列长度,就崩溃了。恢复时账本显示旧长度,显存里却已经有新数据,新引擎如果按旧长度继续生成,就会覆盖一部分已经计算好的状态。
解决思路有两种。一种是写日志和写显存放在同一个事务里,但 GPU 侧很难做原子操作。另一种是接受“丢失最后一段增量”的代价,恢复后回退到账本记录的最后一个一致点,再让接入层从那里续跑。这比追求完全无损失更现实。我的建议是明确“可丢失窗口”是多少,别把恢复系统设计成必须在崩溃瞬间达到严格一致,否则复杂度会失控。
5.4 先说一个最值得做的最小闭环
如果你也想在项目里尝试这套思路,我不建议一上来就做完整的资源层、租约机制、KV cache 账本。先从最小闭环开始:单机部署两个推理引擎实例,公共资源层只负责管理一份模型权重的显存块,不做 KV cache 恢复;故障注入只做引擎进程 kill。等这个闭环跑通了,再逐步加入 KV cache 账本、请求续传、跨实例共享。
这个顺序是我实际做过故障恢复之后的体会。第一步能验证最关键的技术假设:进程退出后显存是否真的可以被接管。如果这一步都因为驱动、容器或加速库的限制走不通,后面做再多都是白费。反过来,第一步跑通后,你会对“显存生命周期解耦”这件事有非常具体的感知,再去看论文里的各种设计决策,也能一眼判断哪些是核心、哪些是包装。
这套方案的边界也很清楚:它擅长处理推理引擎崩溃,不能替代整机热备和训练侧容错。把合适的工具放到合适的位置,比追求一个“万能秒级恢复”靠谱得多。