给 LLM 推理引擎找 bug,难在哪里 [testing-llm-serving-engines]
给 LLM 推理引擎找 bug,难在哪里 [testing-llm-serving-engines]
写于 2026 年 10 月
过去大半年,我一直在给 vLLM、SGLang 这类 LLM 推理引擎找 bug。到九月初,一共报了 42 个,包括一个顺手发现的 transformers bug。7 个已经修了,27 个被维护者确认或有人提了修复 PR,剩下 8 个被关闭或被拒。论文还在写,这篇先记下做这件事时遇到的困难,以及一些没走通的路。
怎么判断它有没有错
推理引擎负责把模型跑起来、调度请求、管理 KV cache,并对外提供 API。这些项目更新很快,功能经常比测试先合进去。出了 bug,服务也未必会崩:流式解析可能丢掉一个 tool call,KV cache 的数据可能被别的请求覆盖,用户照样能收到一段回答。有人统计过五个推理引擎的 929 个 bug,超过 35% 的症状不是崩溃。
麻烦的是,模型本来就不一定每次生成同样的内容。即使 temperature = 0,batch 大小、attention 实现和硬件的差异也会让 token 变化。直接拿两个引擎、两个版本的输出做 diff,很难判断差异是不是 bug。测试里把「怎么判断结果对不对」叫作 oracle problem,在这里尤其棘手。
我们改为检查 property,也就是系统必须满足的规则。比如,流式返回的 tool call 拼起来,应该和一次性解析的结果一致;模型加载完,不能还有没初始化的参数。这些规则不要求模型生成某一句标准答案。
从修过的 bug 里找规则
手写规则很慢,写的人还得同时懂实现和它应该满足的约定。我们就去翻引擎自己的修复历史:PR 里的代码改动、讨论和回归测试,往往已经说明了「这里本来应该怎样」。
我们从 vLLM、SGLang、TensorRT-LLM 的修复历史里抽了 1,500 个相关改动,让 LLM 从中提取规则,以及能在哪个接口或内部状态上检查它,一共得到 1,032 条。
LLM 写出来的东西不能直接信。我们尝试把规则变成可执行的测试,在修复前后各跑一遍:修复前应该报错,修复后应该通过,还得确认测试确实执行了检查。有些测试看起来通过了,只是因为根本没碰到要检查的东西。后来我们还故意注入错误,要求测试把这些错误全部抓出来。
通过验证后,再变着输入去测最新版本,也尝试把规则搬到另一个引擎上。一次迁移中,检查在目标引擎的历史版本上先报错、后来通过,恰好对上它自己修复同类 bug 的时间。至少有些规则是能跨引擎复用的。
跑得贵,还不一定触发得了
测一个配置就要起一次服务,加载几个 GB 的权重、准备 CUDA graph、预热,几分钟就过去了。一轮 100 个配置的随机测试要跑一个通宵。要重放历史 bug,还得装出当时的引擎和依赖版本,旧版本经常连安装都过不去。
硬件限制更直接。做 bug 可复现性调研时,我们从两个仓库大约 1,400 个候选 bug 里,只留下 101 个本地能复现的。很多问题需要多 GPU、超过 24 GB 的模型,或者特定架构的 kernel。我们手里是一张 4090,issue 的触发条件却写着 H100 或 Blackwell。
即使机器够用,也不容易凑到出错的条件。有些 bug 要特定模型架构、推测解码和 CUDA graph 的组合,再碰上请求抢占或某种调度时序才会出现。
比如我们报的 vllm#55208:在 prefill 和 decode 分开部署、使用不同张量并行度时,Mamba2 的 conv state 会错位。这个问题是读代码看出来的。本地要复现,需要两个引擎实例加 NIXL,我们根本跑不了。
出错了,回答也可能看着正常
最让我意外的是,bug 真的触发了,输出里也经常看不出来。
我之前做过一个 KV cache robustness 实验。起因是 sglang#17839:KV cache 的地址指针都错了,gsm8k 还能有 75% 的准确率。我自己试了一下,把 Qwen2.5-7B 的 30% KV cache 换成无关 prompt 的内容,准确率从 87% 掉到 50%;换成零才掉到 5%,但真实 bug 反而很少把数据整块清零。attention 会忽略无关 context,连基础设施造成的一部分错误也一起忽略了。
我们也试过比较输出概率,用 HF Transformers 的 fp32 实现作参考,希望在文本变错之前找到异常。结果 vLLM 的 bf16 路径比 HF 自己的 bf16 还接近 fp32,没找到想找的信号。倒是在参考实现里发现了一个 bug:transformers 加载 FP8 compressed-tensors checkpoint 时漏乘了 weight_scale,报上去后修了(transformers#47145)。
再往下比较每一层的 hidden state,故意注入的扰动能定位到具体层,但小于该层输出 10% 的扰动会被 bf16 的舍入噪声淹没。靠 perplexity 异常找 bug 也试了三个月,零收获。
直接检查内部状态,也有看不到的地方
既然输出不可靠,我们又试了 runtime verification:在引擎内部的方法调用上挂检查,直接看状态有没有违反规则。
它重放已知 bug 时很有效。SGLang pr_25015 修的是 EAGLE draft 的 position 差了 1。检查标出了全部 78 次错误的 draft forward,最终生成的文本却仍然正确,单看文本根本发现不了。
但用它找新 bug 就没这么顺利了。我们给 scheduler 和 KV cache 写了 33 条规则,通宵跑过几亿个事件,没有一次违规。Python 层的检查看不到 GPU 内部,CUDA graph 重放时甚至没有 Python 代码执行,而历史上一些严重的 silent bug 恰好就在这条路径上。想把数据同步出来看,又可能破坏 graph capture,或者改变原本的执行时序。
KV cache 的生命周期我们也花了不少时间,检查过引用计数、重复释放、仍在使用的 block 被回收这些问题。能接触到的路径在重放已知 bug 时会报错,跑了几个月却没找到新 bug。更底层的 kernel 读写,靠 Python 方法上的检查够不到。
后来和 SGLang 的作者聊起这件事,他们也在考虑类似的问题。SGLang 的 EAGLE draft 路径里已经有 canary 检查,能在 GPU 上随着 CUDA graph 一起执行。我们试着往 req_to_token 里注入错位,它能抓到;正式跑了 128 万个 token,没有报错。倒是顺便发现,canary 自己的 buffer 大小在 topk > 1 时会让 graph capture 崩掉。
他们提到的一种测试方法是把显卡跑一天,然后看 benchmark。按前面那个 KV cache 实验的结果,这种测法漏掉的东西不会少。
规则本身也不好写
从修复历史里提取规则,省掉了一部分手工,但没省掉理解实现的工作。有的规则需要访问拿不到的内部状态,有的必须有模型和 GPU 才能验证。
规则的范围也难把握。从一个 fix 里提取出来,往往窄到只对那个 bug 成立;让 agent 搬到另一个引擎,又容易推广过头,到处误报。而且修复历史只能告诉我们「以前有人修过什么」,那些没人发现、没人写下来的约定,还是得自己找。
底层的 bug,可能得换工具
回头看我们报的 42 个 bug,修复和确认的那些几乎都在前端和加载环节:tool-call 解析、API 请求处理、配置校验、权重加载等。这些地方状态比较小,行为确定,CPU 上就能复现,也容易说清楚什么算错。
到了 scheduler、KV cache、kernel 和多卡通信,就得同时面对昂贵的运行环境、难凑的触发条件,以及出错后仍然正常的输出。继续加测试能解决多少问题,我现在没有那么乐观了。
我现在更倾向于用 model checking 来处理这类并发问题:给调度器、KV cache 管理器或跨机器传输协议建模型,穷举可能的执行顺序,在模型里检查规则。这样可以先研究并发和协议本身的问题,不必每次都搭出 GPU 环境。之前提取的规则都带有 TLA+ 风格的形式化草稿,可以拿来作为建模的起点。
另一条路是在引擎内部加检查,像 SGLang 的 canary 那样直接在 GPU 上跑,让原本悄悄发生的错误及时报出来。前端的测试还值得继续做;后端这些难以触发、又难以观察的问题,我觉得得换工具。