在[海光 DCU](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E6%B5%B7%E5%85%89+DCU&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLmtbflhYkgRENVIiwiemhpZGFfc291cmNlIjoiZW50aXR5IiwiY29udGVudF9pZCI6MjgyMjQ2MDAzLCJjb250ZW50X3R5cGUiOiJBcnRpY2xlIiwibWF0Y2hfb3JkZXIiOjEsInpkX3Rva2VuIjpudWxsfQ.YLLwAO5myuWT7qWayxQqDQVotiKYFbkmOVFG09QlR0w&zhida_source=entity) 八卡机上部署多模态模型 [MiniCPM-V 4.6](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=MiniCPM-V+4.6&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiJNaW5pQ1BNLVYgNC42IiwiemhpZGFfc291cmNlIjoiZW50aXR5IiwiY29udGVudF9pZCI6MjgyMjQ2MDAzLCJjb250ZW50X3R5cGUiOiJBcnRpY2xlIiwibWF0Y2hfb3JkZXIiOjEsInpkX3Rva2VuIjpudWxsfQ.ttlMNOgZwPGyR_1jcYS1mc0zyaYqBgsSS-SurZnjRro&zhida_source=entity) 的集群后,我们撞上了一个最凶险级别的 bug:**并发图片请求时,[vLLM](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=vLLM&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiJ2TExNIiwiemhpZGFfc291cmNlIjoiZW50aXR5IiwiY29udGVudF9pZCI6MjgyMjQ2MDAzLCJjb250ZW50X3R5cGUiOiJBcnRpY2xlIiwibWF0Y2hfb3JkZXIiOjEsInpkX3Rva2VuIjpudWxsfQ.mJfT--We2KvEJKdyvCAl9eSCmI6mWAzDZhDg_IBDvk4&zhida_source=entity) 实例会随机崩溃,一崩就是整张卡的服务全灭**。[串行请求](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E4%B8%B2%E8%A1%8C%E8%AF%B7%E6%B1%82&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLkuLLooYzor7fmsYIiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.IxqxpmTYt8mL82F5MQ7ebISLmnHcAO2avByrl-_KYXg&zhida_source=entity)永远不崩、自测永远全绿,只在真实批量流量下偶发——时序相关、不可稳定复现,这类 bug 的排查难度是最高的。
更麻烦的是约束:出问题的是 DCU 定制版 vLLM(`0.20.0+das.dtk2604`),**不能 pip 升级**——升到上游新版会丢掉 [HIP 适配](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=HIP+%E9%80%82%E9%85%8D&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiJISVAg6YCC6YWNIiwiemhpZGFfc291cmNlIjoiZW50aXR5IiwiY29udGVudF9pZCI6MjgyMjQ2MDAzLCJjb250ZW50X3R5cGUiOiJBcnRpY2xlIiwibWF0Y2hfb3JkZXIiOjEsInpkX3Rva2VuIjpudWxsfQ.feDRTeuoEXzGkf3w61iDKsXNRJQoNUExgpo_IQE3FkQ&zhida_source=entity),直接跑不起来。而上游修复在 v0.22 才合入。等厂商适配是唯一「正规」出路,但生产等不起。
这篇记录完整的排查链路:从一条 ValueError 日志出发,读进 vLLM V1 的源码,搞清楚两个数字(327 和 303)各自怎么来的,最后给出双层修复——一层规避、一层源码补丁。核心内容是那个补丁的思路:**当不能修好它时,如何让它死不了**。
## 症状:一崩就是一张卡
集群形态是 8 个 vLLM 实例各占一张 DCU(端口 8002–8009),前置[反向代理](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLlj43lkJHku6PnkIYiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.1Mh2v1-S7Bpr8y8Rh7dyWX0BDEykyEl6ZzEE3wlJhkQ&zhida_source=entity)。上线压测时发现:并发 ≥12 张图片的请求打进来,某些实例会崩在这样一行日志上:
```text
ValueError: Attempted to assign 303 = 303 multimodal tokens to 327 placeholders
```
然后 EngineCore 子进程退出,整个实例宕机。特征归纳:
| 特征 | 表现 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| 触发条件 | 多个图片请求落在同一个 scheduler step 里批量处理 |
| 串行/单请求 | 从不崩 |
| 复现稳定性 | 偶发,时序相关——同样的请求序列重放不一定崩 |
| [爆炸半径](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E7%88%86%E7%82%B8%E5%8D%8A%E5%BE%84&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLniIbngrjljYrlvoQiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.GiOxwmFnHUq4caMhPj9CXCNoCKQxt16ruuYamFGfThI&zhida_source=entity) | 一个 raise 杀死 EngineCore → 整张卡的实例消失 |
「偶发 + 并发相关 + 进程死亡」这个组合已经足够画出嫌疑画像:**批处理路径上有个尺寸对不齐的检查,而且这个检查失败的方式是自杀**。
## 排查:两个数字各是谁算的
顺着报错信息 grep vLLM 源码,找到 raise 的位置,再往上追调用链,落在 vLLM V1 引擎的 `gpu_model_runner.py`(`_gather_mm_
embeddings` 一带)。多模态请求的执行流程里有两个关键数据,分别由**两条完全不同的路径**算出来:
1. **占位符数(327)**:文本 prompt 进 chat-template 后,图片被展开成一串占位 token,掩码 `is_mm_embed` 里 true 的数量。对 MiniCPM-V 4.6,这个数由模型侧的 `_compute_visual_tokens`**纯数学公式**算出(图片宽高 × 切片方案 → 预估 token 数);
2. **编码器实际输出(303)**:图片真正过一遍 vision encoder 后,实际产出的 embedding [向量](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E5%90%91%E9%87%8F&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLlkJHph48iLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.-aienxqUE7dDB1cHgVLiXDtwbVT1Jf_PDJELeGzRvpI&zhida_source=entity)个数。
两条路径在单请求时「碰巧」总是相等的——但 MiniCPM-V 4.6 有个特性:**每张图的 token 数是动态的**(`max_slice_nums=9`,按图片长宽比动态切片),且 chat-template 展开时会混入 `
`、换行、列分隔等**结构 token**。vLLM 0.20 的占位符替换逻辑没有把这两边对齐:单 sequence 的 scatter 蒙对了,**多个 sequence 在同一个 scheduler step 里批量 scatter 时,只要任何一个 sequence 的计数不齐,就抛异常**。
而 vLLM V1 的[进程模型](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E8%BF%9B%E7%A8%8B%E6%A8%A1%E5%9E%8B&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLov5vnqIvmqKHlnosiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.yL9ZjsPVjbZSuk0YqbThBE1MKasDefaq4VupbrJWV6Q&zhida_source=entity)决定了这个 raise 的代价:EngineCore 是独立进程,engine loop 里未捕获的异常 = 进程退出 = 整张卡的服务蒸发。**一个请求的数据问题,惩罚是一张卡的[宕机](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=2&q=%E5%AE%95%E6%9C%BA&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLlrpXmnLoiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MiwiemRfdG9rZW4iOm51bGx9.7CIXTDMY0ePswM2tXIQYLMwsSvfoDJuTDte7tq_nNBk&zhida_source=entity)**——这个错误处理的设计,比 bug 本身更值得批评。
(后来确认上游已有人踩过同一坑,修复在 vLLM v0.22 合入,对应 PR #43213——我们的定位和上游修的方向一致,这在「不敢相信自己结论」的时刻是很重要的外部印证。)
## 约束:为什么不能等官方、也不能升级
修复路径理论上有三条,先排除两条:
* **pip 升级 vLLM**:不可行。DCU 版 vLLM 带厂商补丁(HIP 适配、算子注册),升级到上游版 = 丢适配 = 服务起不来。这是[国产卡](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E5%9B%BD%E4%BA%A7%E5%8D%A1&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLlm73kuqfljaEiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.HPaj385MVJtRc6DTOK5IdnBE2FjWWh70NFsNpSJDRK0&zhida_source=entity)生态的常态约束:**你的版本坐标系是「上游版本 × 厂商适配」的二维组合,单看上游版本号没有意义**;
* **等厂商出带修复的 DCU 版**:已提需求,但排期不可控,生产在跑的每一天都在承担风险。
所以只剩下:**在自己这套不能动的二进制栈里,把伤害降到最低**。
## 修复 A:让 bug 没有触发条件(规避层)
[根因](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E6%A0%B9%E5%9B%A0&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLmoLnlm6AiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.5RrVR2s1-q3KP5p1gXdvVs2F5mQcFDBRNb041B3nqz0&zhida_source=entity)的触发条件是「多 sequence 同一 step 批量 scatter」。那就让这个条件永不成立:
```text
--max-num-seqs 1 --mm-processor-cache-gb 0
```
每个实例一次只处理一个图片请求,scatter 退化为单 sequence——就是那个「碰巧总是对的」路径,**确定性不触发**。并发行不行?行,8 张卡 × 1 = 集群级并发 8,由反向代理调度承担。
代价是吞吐:每卡并发被钉死在 1。实测 8 卡集群短回答 \~21 张/s、详细描述 \~1.16 张/s——够当时业务用,但天花板明显。这个方案作为**默认配置**上线:先保证不死,再谈快。
## 修复 B:把 raise 改成降级(保命层)
规避层的问题是它靠「不给 bug 触发机会」活着,一旦有人改了配置、或者某个流量形态绕过了假设,就没有退路。所以需要第二层——直接改 vLLM 源码里的那个 raise。
位置在 site-packages 的 `vllm/model_executor/models/utils.py`,`_merge_multimodal_embeddings` 函数的异常分支。原逻辑是计数不齐就 `raise ValueError`;补丁改成:
```text
# 原版:raise ValueError(f"Attempted to assign {n} ... to {m} placeholders")
# 补丁版:不匹配时降级,保住 EngineCore 进程
if actual < expected:
logger.warning("mm placeholder mismatch: pad %d zeros", expected - actual)
padding = torch.zeros(expected - actual, *actual_embeds.shape[1:],
dtype=actual_embeds.dtype, device=actual_embeds.device)
merged = torch.cat([actual_embeds, padding], dim=0)
elif actual > expected:
logger.warning("mm placeholder mismatch: truncate %d -> %d", actual, expected)
merged = actual_embeds[:expected]
else:
merged = actual_embeds # 计数相等:走原路径,行为零变化
```
设计要点:
1. **正常请求零影响**。计数相等时走的分支和原版完全一致——补丁只在不匹配这个「本来就要死」的分支上生效。这是给[生产系统](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E7%94%9F%E4%BA%A7%E7%B3%BB%E7%BB%9F&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLnlJ_kuqfns7vnu58iLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.WDm2f7JBe93eqCy1PJf97g2Wxy45QJCxAwAnsgnk8Mw&zhida_source=entity)打补丁的第一原则:**只在故障路径上改变行为,happy path 一个比特都不动**;
2. **降级的代价是局部而明确的**:少了 24 个 embedding 就补 24 个[零向量](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E9%9B%B6%E5%90%91%E9%87%8F&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLpm7blkJHph48iLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.JBV5xT41KYFditGx2szs7reMgW69gOlXlifNHroJxx4&zhida_source=entity),对应请求里尾部的图片 token 拿到零 embedding,该请求输出质量轻微降级——**一个请求变笨,好过一个实例死亡**。质量换可用性,在风控识图这类「宁可答得糙、不能没得答」的场景里是笔划算的交易;
3. **留了 warning 日志**:补丁不是修复,是[缓期执行](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E7%BC%93%E6%9C%9F%E6%89%A7%E8%A1%8C&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLnvJPmnJ_miafooYwiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.mWe9RZAYGa_YQITgDBMhP9YqHonsIhOrfMbywRU3m7I&zhida_source=entity)。每次触发都记下来,触发率就是一个「上游 bug 在我们流量上的真实发病率」仪表盘,也是日后向厂商申请升级的依据。
打完双层补丁后压测:并发 12/16/24 图片请求全部正确、8/8 实例零崩溃。之后还试过把 `MAX_SEQS` 开到 4(每卡 4 并发、集群 32),安全网兜住了偶发的不匹配——吞吐上去了,代价是极少数请求轻微降级,这在我们的业务里可接受。
## 在 site-packages 里改源码的工程纪律
直接改生产环境依赖的源码是危险动作,需要纪律配套:
* **可逆**:补丁尽量小而局部(只动异常分支),原代码注释掉而不是删除,`git diff` 式的改动记录存在部署文档里;
* **可发现**:在部署 README 顶部显著标注「本环境 site-packages 含本地补丁 N 处,升级 vLLM 时必须重放或确认已包含官方修复」——半年后没人记得改过什么,文档是唯一的记忆;
* **有退役条件**:补丁的存在是为了桥接官方修复到位之前的[空窗](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E7%A9%BA%E7%AA%97&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLnqbrnqpciLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.pbPUCrIxr4XO4MjbgO1lgRarWMUfc53qxnuonxD8Aro&zhida_source=entity)。拿到带上游修复的 DCU 版 vLLM 那天,第一件事就是撤掉它,让环境回归「纯官方」状态。
顺带一提,这个补丁能打、打得干净,得益于我们此前把服务从容器搬到了[裸机](https://zhida.zhihu.com/search?content_id=282246003&content_type=Article&match_order=1&q=%E8%A3%B8%E6%9C%BA&zd_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ6aGlkYV9zZXJ2ZXIiLCJleHAiOjE3OTA5MDI0NjEsInEiOiLoo7jmnLoiLCJ6aGlkYV9zb3VyY2UiOiJlbnRpdHkiLCJjb250ZW50X2lkIjoyODIyNDYwMDMsImNvbnRlbnRfdHlwZSI6IkFydGljbGUiLCJtYXRjaF9vcmRlciI6MSwiemRfdG9rZW4iOm51bGx9.jS9akmif1rd_MnN5odkAq-Z9N9shjNd9HkKfaqMeXjc&zhida_source=entity) conda 环境——**环境是你自己的,你才有资格对它负责**。在容器黑盒里,这类「改一行源码救命」的操作连可能性都不存在。
## 小结
这次排查的价值超出单个 bug:它示范了在国产卡生态里面对「上游已修、本地不能升」困境的标准动作——**读源码定位根因(两条路径算出的两个数字)→ 规避层让 bug 失去触发条件(max-num-seqs=1)→ 保命层把死亡改成降级(raise → 补零/截断)→ 纪律层保证补丁可发现、可退役**。bug 是厂商的,但止血是自己的:等待官方修复的窗口期里,服务一天都没停。
---
*环境信息:海光 K100\_AI × 8 / vLLM 0.20.0+das.dtk2604(DCU 定制版,不可 pip 升级)/ MiniCPM-V 4.6 多模态集群 / 上游对应修复:vLLM PR #43213(v0.22)。*