vLLM下LoRA挂载感叹号解决路程-1-背景介绍
type
Post
status
Published
date
Jul 7, 2026
slug
260707-vllm-lora-1
summary
记录了一次持续数周的vLLM LoRA调试过程。作为一个Java后端出身、机器学习背景有限的工程师,我希望用后端同学能听懂的语言,把这段经历讲清楚。这是第一篇,先聊聊背景和现象
tags
开发
category
技术分享
icon
password
契机
记录了一次持续数周的vLLM LoRA调试过程。作为一个Java后端出身、机器学习背景有限的工程师,我希望用后端同学能听懂的语言,把这段经历讲清楚。这是第一篇,先聊聊背景和现象
我和团队在做的是一个视觉语言理解应用。简单说,就是把视频内容喂给视觉语言大模型,让它告分析视频画面,这里有几个工程问题需要考虑:
不同的分析任务,需要模型有不同的关注点,比如:
- 文物识别:模型得重点关注画面中的器物形状、材质特征,判断属于哪个时期和类别
- 墓室结构分析:模型得关注建筑构造、空间布局、甬道走向等结构信息
- 壁画与铭文识别:模型得关注墙面图案、文字符号、颜料状态等细节
每个场景,我们都希望模型对这个场景描述更准确,那就要需要微调。一个Qwen3.6-27B模型,权重文件约51.1G,加上峰值激活2.19G、非torch内存0.24G、CUDAGraph内存1.88G,单个实例实际占用约55G显存。如果我们有20个场景,全量部署20个完整模型,光显存就需要超过1TB——这还不算推理时KV cache占用的额外显存。考虑到vllm可以动态挂载lora,并且lora参数量本来就不大,所以想到用同一个base模型,挂载多个lora,在效率和准确率之间做平衡。
vLLM解决了什么问题
团队开始调研推理框架的时候,vLLM在开源社区的热度已经很明显了。但说实话,第一次看vLLM的论文,PagedAttention、continuous batching这些概念对一个Java后端来说,就跟第一次看Spring的IoC源码一样——每个字都认识,连起来就不知道在说什么,所以这里引用几个常见的后端概念来阐述vllm到底干了啥。
- PagedAttention(显存管理)
- 在传统的Transformer推理中,KV cache是一块连续的内存。每个请求来了,预分配一大块连续空间——就像数据库连接池里,不管你用不用,先占着位置。如果请求长度差别很大(短请求只占100个token的cache,长请求占了8000个token的cache),内存碎片就来了。请求完成后,这块空间可能因为大小不合适,没法给下一个请求复用。vLLM的做法是:把KV cache切成固定大小的block,按需分配。就像连接池里把连接按固定大小分片,用完就归还。一个请求用完了它的block,这些block可以直接被下一个请求复用,不会产生碎片。
- Continuous Batching(批处理)
- 传统推理服务是这样处理请求的:请求一个一个排队,上一个处理完了,才开始处理下一个。这就像你写了一个for循环,每条SQL都单独查一次,不做任何批量优化。vLLM的做法类似于hibernate的batch fetching:多个请求可以同时处于"正在推理"状态,调度器在每个step动态决定哪些请求参与这一轮计算。一个请求生成了它的结束符(EOS token),调度器立刻把它踢出去,换一个新的请求进来。这种设计的收益是巨大的:GPU的利用率从传统方案的30%-50%直接提到80%以上。
- LoRA Adapter(动态路由)
- 主模型固定不变底层共用同一个51G基础大模型,想做文物任务就加载文物LoRA小插件,想做实验室分析就卸载旧插件、换分析 LoRA,插件才几百M,切换极快,不用多存多跑整套大模型。
lora是什么
一个LLM就像一个巨大的Excel,想象一个夸张的Excel文件,有几千行、几千列。每一个格子里都有一个数字(参数)。当你给这个Excel一个输入(这个视频里有什么?),它就用这些数字做一系列乘法和加法(前向传播),最终输出一个结果。全量微调就是你打开这个Excel,把里面每一个格子都修改一遍。一个Qwen3.6-27B模型有270亿个参数——你就是要在270亿个格子里一个一个改。先不说改不改得完,光是把改完的270亿个数存下来就要51.1 GiB。每个场景存一份,20个场景就是超过1TB。LoRA的核心思想很简单:你别动那张大表。你在大表旁边加两张小表(A矩阵和B矩阵),只改这两张小表。
假设原来大表是4096列 x 4096行(约1600万个格子)。LoRA的做法是加两张小表:
- 小表A:4096列 x 64行(约26万个格子)
- 小表B:64列 x 4096行(约26万个格子)
两张小表加起来大概 50万个格子,而大表有 1600万个格子。训练时只更新小表里的格子,大表里的数值永远不变。
每个场景只需要存一对小表(A和B矩阵),大概几百MB(rank128时约500MB),而不是整个大表(51.1 GiB)。20个场景,不再需要超过1TB,20个adapter总共约10GB——一张消费级显卡就搞定了。对比之下,27B模型全量部署20份需要超过1TB显存,LoRA的节省效果比小模型时代更加惊人。更妙的是,运行时切换adapter,就是在算到那一层的时候,从这一对小表换成那一对小表,完全不需要重新加载大模型。对我们的视频理解应用来说,这就是基础设施层面的银弹——一个基础模型+N个轻量adapter,根据视频分析任务动态路由。
Hybrid架构
基本介绍
我们选的基座模型是Qwen3.6-27B,它有32层,绝大多数开源大模型,所有层都是full attention(全注意力)机制。但Qwen3.6不是。Qwen3.6-27B的32层是这样分布的:
- 前8层:full attention(传统注意力),每个token都要看前面所有token
- 后24层:GDN(Gated DeltaNet),一种更高效的注意力机制
GDN的全称是Gated DeltaNet,它是一种线性注意力(linear attention)机制,核心思想是用循环状态空间模型(recurrent state-space formulation)来替代传统的softmax注意力。用最简单的话说:GDN不是在每个token之间做两两比较,而是维护一个不断更新的压缩状态,用这个状态来代表之前所有token的信息。
标准full attention的工作方式:每个token都要和前面所有token计算注意力分数。
GDN的工作方式:维护一个压缩状态,每个token只和这个状态交互,这里有两个关键机制:
- Gate(门控):一个学习到的sigmoid函数,输出0到1之间的值。它决定当前token的新信息有多少应该被写入状态。gate=0.1意味着这个token的信息不太重要,只更新10%到状态里;gate=0.9意味着这个token很重要,要把90%的新信息写入。
- Delta(差分):不直接存储每个token的原始信息,而是计算"当前输入"和"已有状态"之间的差异。只有真正有变化的部分才会被传播。这避免了状态里堆积冗余信息——就像git diff只存变更,不重复存整个文件。
GDN就像一个有选择性记忆的速记员——不是把每个字都记下来(full attention),而是不断维护一份浓缩摘要,只在新信息真正重要时才更新摘要。每个GDN层维护两种内部状态:
- conv_state(卷积状态):短时上下文捕捉器。像一个滑动窗口,记录最近几个token的局部模式。如果你写过后端的滑动窗口限流器,这个概念就很熟悉了——只保留最近N个元素的信息,老的自动淘汰。
- ssm_state(状态空间模型隐状态):长程依赖追踪器。像一个不断更新的全局摘要,在整个序列处理过程中持续演化。它不"遗忘"旧信息,而是把新旧信息按门控比例混合。
这对lora的意义
GDN层有一个让LoRA适配变得复杂的设计:融合投影(merged projections)。
标准attention层中,Q(Query)、K(Key)、V(Value)是三个独立的权重矩阵。lora可以分别对每一个加adapter,互不干扰。
但在GDN层中:
- in_proj_qkvz是一个融合权重,权重形状为(8192, 4096)——Q、K、V、Z四个投影被熔合成一个大矩阵4096是输入维度,8192=2048(Q)+2048(K)+2048(V)+2048(Z),四合一。
- in_proj_ba是另一个融合权重,权重形状同样为(8192, 4096)——B投影和A投影被熔合在一起。
问题在于:LoRA想要独立地修改某个子组件(比如只给V加adapter),但权重被融合了,你没法单独拆出来。 这就是为什么GitHub上的PR #36976需要把融合投影拆分成独立组件——不拆的话,vLLM在加载LoRA adapter时会因为找不到对应的weight key而直接崩溃。
Qwen3.6的GDN架构太新了,上游vLLM对它的LoRA支持,还不够。我们开始调研的时候,GitHub上有一个相关的PR(#36976),标题是"Fix Qwen3.5 startup crash with LoRA"。注意:它只修了启动崩溃,没保证推理结果正确。修法说白了就是:让GDN层的LoRA注册不抛异常,程序能跑起来。但跑起来以后,这些层的LoRA计算对不对?没人验证过。这就是做infra的日常:你以为你用的是production-ready的特性,实际上你可能是第一个真正在生产环境压测它的人。
问题复现
短文本请求(正常)
当我们发短文本请求时,一切正常:
长文本请求/视频请求(异常)
当我们把请求的prompt加长到400个token以上:
视频输入是最核心的业务场景。当我们使用官方的Qwen演示视频,将约1951个visual token喂进去的时候:
状态污染
还有一个现象让我们困惑了很久:一旦某个长请求触发了感叹号问题,之后的所有短请求也全坏了。
这就像一个请求污染了全局状态。在后端的经验里,这通常意味着某个共享的、可变的、没有被正确reset的数据结构出了问题。可能是一个static字段被意外修改了,可能是一个线程局部变量没有在请求结束后清理掉。只有重启服务才能恢复正常。
初步方向
我们把同样的模型用 --enable-lora 参数关掉,把adapter直接merge进基础模型(也就是传统部署方式),然后跑同样的测试一点问题没有,所以问题不在模型本身,不在输入数据,就在LoRA的动态加载机制上。从这些现象出发,我们有了几个关键方向:
- 序列长度是关键变量。问题只出现在长序列上,说明可能和KV cache管理、位置编码、或者attention计算中的某些buffer有关。
- GDN层是重点怀疑对象。32层中24层是GDN,而LoRA对GDN的支持是preview级别的。长序列时GDN的内部状态(conv_state、ssm_state)可能和LoRA的AB矩阵产生了什么奇怪的交互。
- 状态污染说明有可变的全局状态。这在vLLM这种高度优化的C++/CUDA代码中并不罕见——一个指针错了、一个offset算错了,后面的请求全遭殃。
写到最后
Prev
vLLM下LoRA挂载感叹号解决路程-2-排查与修复
Next
vLLM推理服务假死排查-多模态缓存幽灵Key导致死循环
Loading...

