vLLM下LoRA挂载感叹号解决路程-1-背景介绍

📅 2026/7/8 3:24:01
vLLM下LoRA挂载感叹号解决路程-1-背景介绍
契机我和团队在做的是一个视觉语言理解应用。简单说就是把视频内容喂给视觉语言大模型让它告分析视频画面这里有几个工程问题需要考虑不同的分析任务需要模型有不同的关注点比如文物识别模型得重点关注画面中的器物形状、材质特征判断属于哪个时期和类别墓室结构分析模型得关注建筑构造、空间布局、甬道走向等结构信息壁画与铭文识别模型得关注墙面图案、文字符号、颜料状态等细节每个场景我们都希望模型对这个场景描述更准确那就要需要微调。一个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的做法是加两张小表小表A4096列 x 64行约26万个格子小表B64列 x 4096行约26万个格子两张小表加起来大概50万个格子而大表有1600万个格子。训练时只更新小表里的格子大表里的数值永远不变。每个场景只需要存一对小表A和B矩阵大概几百MBrank128时约500MB而不是整个大表51.1 GiB。20个场景不再需要超过1TB20个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层GDNGated 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的新信息有多少应该被写入状态。gate0.1意味着这个token的信息不太重要只更新10%到状态里gate0.9意味着这个token很重要要把90%的新信息写入。Delta差分不直接存储每个token的原始信息而是计算当前输入和已有状态之间的差异。只有真正有变化的部分才会被传播。这避免了状态里堆积冗余信息——就像git diff只存变更不重复存整个文件。GDN就像一个有选择性记忆的速记员——不是把每个字都记下来full attention而是不断维护一份浓缩摘要只在新信息真正重要时才更新摘要。每个GDN层维护两种内部状态conv_state卷积状态短时上下文捕捉器。像一个滑动窗口记录最近几个token的局部模式。如果你写过后端的滑动窗口限流器这个概念就很熟悉了——只保留最近N个元素的信息老的自动淘汰。ssm_state状态空间模型隐状态长程依赖追踪器。像一个不断更新的全局摘要在整个序列处理过程中持续演化。它不遗忘旧信息而是把新旧信息按门控比例混合。这对lora的意义GDN层有一个让LoRA适配变得复杂的设计融合投影merged projections。标准attention层中QQuery、KKey、VValue是三个独立的权重矩阵。lora可以分别对每一个加adapter互不干扰。但在GDN层中in_proj_qkvz是一个融合权重权重形状为**(8192, 4096)**——Q、K、V、Z四个投影被熔合成一个大矩阵4096是输入维度81922048(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的特性实际上你可能是第一个真正在生产环境压测它的人。问题复现#部署命令CUDA_VISIBLE_DEVICES3vllm serve\/path/to/Qwen3.6-27B\--enable-lora\--lora-modules artifact-adapter/path/to/adapter\--max-lora-rank128\--port8000\--gpu-memory-utilization0.9\--trust-remote-code\--served-model-name qwen-lora# 短文本请求curlhttp://localhost:8000/v1/chat/completions\-HContent-Type: application/json\-d{ model: artifact-adapter, messages: [ {role: user, content: Describe what is shown in this video.} ], max_tokens: 128 }# 视频请求使用Qwen官方demo视频curlhttp://localhost:8000/v1/chat/completions\-HContent-Type: application/json\-d{ model: artifact-adapter, messages: [ { role: user, content: [ { type: video_url, video_url: { url: https://qianwen-res.oss-accelerate.aliyuncs.com/Qwen3.5/demo/video/N1cdUjctpG8.mp4 } }, { type: text, text: How many porcelain jars were discovered in the niches located in the primary chamber of the tomb? } ] } ], max_tokens: 128, temperature: 0, chat_template_kwargs: {enable_thinking: false}, mm_processor_kwargs: { size: {longest_edge: 4000000, shortest_edge: 4096}, fps: 2, num_frames: 10 } }短文本请求正常当我们发短文本请求时一切正常# 请求约50 tokens Describe what is shown in this video. # 响应 The video shows a group of people entering an ancient underground burial chamber. The chamber has stone walls and several pottery objects are visible on the ground. #准确率正常延迟正常GPU利用率正常。看起来一切都好。长文本请求/视频请求异常当我们把请求的prompt加长到400个token以上# 请求约450 tokens Please provide a detailed analysis of the following video. The video appears to show an archaeological exploration of an ancient tomb. The scene contains a burial chamber with stone walls, visible inscriptions, pottery vessels scattered on the ground, and structural features including a vaulted ceiling and side chambers... 【省略400 tokens的详细描述】......Please give your analysis. # 响应 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! #是从头到尾全是感叹号。API返回了200状态码正常没有任何error没有任何warning。就是静默输出感叹号。视频输入是最核心的业务场景。当我们使用官方的Qwen演示视频将约1951个visual token喂进去的时候# 请求Qwen3.5官方demo视频约1951个visual tokens # 视频URLhttps://qianwen-res.oss-accelerate.aliyuncs.com/Qwen3.5/demo/video/N1cdUjctpG8.mp4 # 视频内容探险者进入古墓进行考察 # 响应 !!!状态污染还有一个现象让我们困惑了很久一旦某个长请求触发了感叹号问题之后的所有短请求也全坏了。请求1短文本50 tokensDescribe this video. → 正常 请求2长文本450 tokensPlease provide a detailed analysis... → 异常 !!!!!!!! 请求3短文本50 tokensDescribe this video. → !!!!!!!! ← 还是异常 请求4短文本50 tokensWhat objects are visible? → !!!!!!!! ← 异常这就像一个请求污染了全局状态。在后端的经验里这通常意味着某个共享的、可变的、没有被正确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算错了后面的请求全遭殃。写到最后