AB
AiBoss
Tutorials

LLM 推理变慢的排查与修复:从路由、排队到 Prefill 与 Decode

Tutorials

LLM 推理变慢的排查与修复:从路由、排队到 Prefill 与 Decode

服务明明全部在线,用户却盯着空白等十几秒——这是推理服务最典型的「静默故障」。本文按一次请求经过的四个阶段(路由、排队、Prefill、Decode)拆解延迟来源,给出可落地的排查顺序、KEDA 基于队列深度的扩缩容配置、GPU 碎片与共享方案取舍,以及 Ingress 超时与缓冲的修正方法。

集群里所有节点 Ready、Pod 全部 Running、CPU 只用了两成、没有重启、没有 CrashLoopBackOff、告警一条没响——从 Kubernetes 给出的每一个信号看,系统都是健康的。但用户已经等了十一秒还没看到回答的第一个字,然后他关掉页面,回去用老办法干活,并且不会再回来。这篇教程讲的就是这种故障:不是宕机,而是「一切技术指标正常、产品却不可用」的静默故障。它适合正在运维自建 LLM 推理服务的工程师、负责集群的平台团队,以及被「模型是不是太慢了」这类问题反复追问的人。

准备工作

在动手排查之前,先把下面这些东西准备好,否则很容易在错误的方向上浪费半天。

  • 一次完整请求的端到端链路视图。你需要能说清一个请求从客户端发出后,依次经过负载均衡、推理服务队列、Prefill、Decode 四个环节,各自由哪个组件负责。没有这张图,后面所有判断都是猜。
  • 推理服务暴露的 Prometheus 指标。以 vLLM 为例,它已经暴露了正在运行的请求数和等待中的请求数。这是判断「有没有人在等」的唯一可靠信号,务必先确认指标端点可达。
  • KEDA 或同类基于外部指标的扩缩容器。如果只有 Horizontal Pod Autoscaler 且只吃 CPU 指标,这一节的内容你无法落地,需要先补上。
  • Ingress 控制器的配置访问权限。读超时和响应缓冲这两个参数通常不在应用仓库里,而在集群的 Ingress 配置中,需要平台侧配合。
  • GPU 节点的实际拓扑信息。包括每张卡的显存、每台节点上有几张卡、当前哪些卡被占用。GPU 是按整卡分配的,碎片问题只有看到拓扑才能判断。
  • 一个能复现长输出的测试提示词。用一句话的短提示词永远复现不了超时问题,因为短请求根本跑不到超时阈值。

另外提醒一句:下文涉及的镜像大小、权重体积、超时默认值、扩缩容参数等,不同版本和不同部署方式差异很大,落地前请以各组件的官方文档当前信息为准。

操作步骤

第一步:先排除「模型有问题」这个直觉

出现延迟时,第一反应通常是怀疑模型:版本不对、量化有问题、上下文太长、有人改了系统提示词。但绝大多数情况下模型本身没问题。

把一次请求走完整条链路,你会看到四个阶段:请求被路由到某个副本;请求在队列里等待;模型读取提示词(Prefill);模型流式输出回答(Decode)。只有后两个阶段涉及模型真正在做事,前两个阶段是排队和路由,也就是基础设施。当有人说「模型很慢」,模型通常是清白的,等待发生在别的地方。

顺带说一个背景:这是整个领域最常见的抱怨,不是你的配置特别倒霉。一项覆盖 200 名生产环境 AI 从业者的调研显示,接近一半(49.5%)的人把「峰值负载下的延迟」列为他们最难解决的扩展问题——不是准确率,不是幻觉,也不是每 token 成本。同一份调研里还有两个数字值得放在一起看:59.5% 的人认为把推理部署在更靠近用户或决策点的位置是关键的,但仍有 45.5% 的人只从单一云区域提供服务。大家知道就近部署重要,却没能做到,这本身就说明了多区域 GPU 容量的搭建成本。

调研里还提到有组织把「低于 250 毫秒的响应时间」和「99.9% 可用性」并列写进要求。这句话要小心读:字面理解是不可能实现的,任何有意义的 LLM 回答都不可能在一刻钟的四分之一秒内完成。它只能理解为首 token 时间,也就是回答的第一个字出现之前要等多久。这其实也是唯一值得拿来考核的指标——一旦 token 开始以可读的速度流出,用户就不数秒了;真正让人流失的是第一个 token 出现之前的那段沉默。

第二步:理解为什么监控什么都没报

这里有个部署时没人会告诉你的前提。普通的 Web 请求短、小,而且这一次的开销和上一次差不多。Kubernetes 的调度假设如此,Service 的负载均衡假设如此,Horizontal Pod Autoscaler 假设如此,Ingress 控制器的默认超时也假设如此。没人把这个假设写下来,因为过去十五年它一直成立。

LLM 请求在三个地方打破了它:

  • 它分两个阶段运行,且两个阶段的特征完全不同。Prefill 阶段模型一次性读完整个提示词,是计算密集、突发型的,它决定了用户要盯着空白等多久;Decode 阶段一次只生成一个 token,每个 token 都需要此前所有 token 累积下来的状态,也就是 KV cache。KV cache 和模型权重一起放在 GPU 显存里,并且随着回答变长而增长。
  • 请求不短。一次生成可以持续一分钟。
  • 请求成本不一样。一个粘贴了整篇文档的提示词,成本可能是单行提示词的五十倍。

而真正稀缺的资源是 GPU 显存,它不出现在你接手的那一堆仪表盘里的任何一个上。这就是为什么监控说一切正常——它在测量错误的机器。

第三步:按四个阶段定位故障

跟着请求走完四个阶段,故障模式会依次浮现。下面这六种是从业者最常提到的,它们和请求路径一一对应。

阶段一,路由:负载均衡器在瞎猜。一旦请求成本出现分化,轮询就不再公平。三个重提示词落到同一个 Pod 上,而隔壁 Pod 在处理单行提示词。被压住的 Pod 队列变长,尾部延迟攀升,但整个集群的平均值看起来依然完全合理——这就是为什么今天之前没人发现。

长连接会让情况更糟。使用 HTTP/2 或 keepalive 时,客户端打开一条连接后所有请求都走这条连接,而 Kubernetes Service 是按连接而不是按请求做负载均衡的。所有流量被钉在同一个后端上,并且一直待在那里。

还有一种大多数团队从未考虑过的成本:如果某个副本的 KV cache 里已经存有当前提示词的前缀,把请求路由到那里就能省掉真实的 Prefill 计算。由于生产环境里大多数提示词共享一段很长的系统前言,这不是边缘情况,而是你流量的大头。轮询每次都在随机地把这份收益扔掉。

阶段二,排队:没有救兵会来。扩缩容器理论上可以加容量,但它一定迟到,原因很朴素:新副本要拉取一个数 GB 的推理镜像,通过网络获取 20GB 到 70GB 的权重,再把它们加载进 GPU 显存。这是分钟级而不是秒级,而且前提是已经存在一台有空闲卡的节点。如果没有,还要加上节点供给的时间,以及你的云厂商今天在那个可用区有没有库存。

更麻烦的是信号本身通常是错的。CPU 在这里说明不了任何问题,因为 GPU 干活的时候 CPU 是闲的。GPU 利用率也好不到哪去:它只报告采样窗口内有 kernel 在执行,一个处理单个请求的服务器和一个处理六十个请求的服务器都会读到 100%。它无法告诉你有没有人在等。

队列深度可以。vLLM 已经把运行中请求数和等待中请求数暴露为 Prometheus 指标。基于它们做扩缩容,用 KEDA 的 ScaledObject 大致是这样:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-server
spec:
  scaleTargetRef:
    name: llm-server
  minReplicaCount: 2
  maxReplicaCount: 8
  cooldownPeriod: 600
  triggers:
    - type: prometheus
      metadata:
        serverAddress: <你的 Prometheus 地址>
        query: sum(vllm:num_requests_waiting{app="llm-server"})
        threshold: "5"

请务必对照你所用 vLLM 版本的文档核对指标名称。多个指标在版本之间被改过名,而改名的失败是静默的——查询返回空,扩缩容悄无声息地失效。

上面两个数值是刻意选的。最小值设为 2,是因为冷启动意味着你不能停在零副本上还指望能服务。冷却期设为十分钟,是因为急着缩容意味着很快又要再付一次冷启动的代价。

由此得出一个不太舒服的结论:对推理服务来说,反应式扩缩容永远迟到,迟到的时间正好等于你的冷启动时间,换任何触发器都改变不了这一点。解法是保留比「感觉舒服」更多的空闲容量——始终多跑几个副本,比你认为需要的更早扩容,缩容则更慢一些。这也意味着缩容到零不适合面向用户的推理服务,把它留给批处理任务,那里没有人在屏幕前等结果。

阶段三,Prefill:GPU 就在那里,却用不上。Kubernetes 分配 CPU 用毫核,分配 GPU 却是整卡。一个 Pod 申请一张卡就拿到整张卡,无论它只需要 10% 还是全部。浪费是显而易见的问题,而真正毁掉一个周四的是碎片化。

一个 70B 模型用 16 位精度大约需要 140GB 权重,所以一个副本需要同一节点上的多张 GPU。你可能手上有六张空闲卡,分散在四台节点上,比例是二二一一,而副本会无限期停在 Pending。纸面上有容量,实际上一分都用不了,而且没有任何告警,因为没有任何东西坏掉。

共享一张卡有三种方案,每种都有你选择之前就该知道的坑:

  • 时间切片(time-slicing)开启简单,但不提供显存隔离,一个贪心的 Pod 可以把邻居拖垮。适合开发环境,不适合任何面向客户的服务。
  • MIG 提供真正的硬件隔离,但切片几何是固定的,意味着你需要在还没有真实负载的时候预测自己的负载组合。
  • 动态资源分配(Dynamic Resource Allocation)是结构上正确的答案,它把加速器请求正式带进了 Kubernetes 核心 API。

这三种默认都不开启,每一种都需要由理解负载的人做出明确决策,而这个人通常在集群搭建的时候不在会议室里。

阶段四,Decode:Ingress 在掐断用户。很多 Ingress 控制器默认带 60 秒读超时,并且默认开启响应缓冲。超时会把长生成在句子中间掐断,用户会把它报告成「崩溃」,而你复现不了,因为你的测试提示词太短。缓冲则会把 token 攒起来成团释放,模型表现完全正常,但流式输出的手感被彻底毁掉。两行配置,写于一个已经不存在的负载假设。

贯穿四个阶段的底层问题:突发流量,以及没人认领这个问题。内部 AI 工具不会得到平稳的流量,它得到的是突然的尖峰:早上九点大家开始上班时,全员大会结束后的那一小时,或者有人在热闹的频道里分享链接并说「这个其实挺好用」的时候。固定副本数只能应对其中一种情况。大多数团队在某个清闲的星期设了一次就再也没回来看过,于是在同一天里既浪费又崩溃。

还有第六种故障模式,它完全不是技术问题,却是前五种能存活好几个季度的原因。平台工程团队拥有集群,集群是绿的,从他们的位置看工作已经完成而且完成得很好。应用开发者能看到延迟很糟,却看不到原因,因为每一个成因都落在他们既不控制也不观测的调度、扩缩容和路由里。MLOps 拥有模型产物,却几乎不拥有任何决定它在周四下午两点一刻表现如何的东西。三个团队说的都是真话,而问题就活在他们的值班表之间的缝隙里,那里没有配置告警,也没有仪表盘指向它。

第四步:修正 Ingress 与路由配置

先处理最容易改、见效最快的一层。把 Ingress 的读超时调高到足以覆盖你最长的一次生成,并关闭响应缓冲,让 token 能逐个透传。具体参数名依控制器而异,改之前先确认你的控制器版本对应的字段名。

路由层面,如果条件允许,优先选择能感知缓存状态和请求重要性的路由方案,而不是继续用轮询。让请求落到 KV cache 里已经存有该提示词前缀的副本上,可以直接省掉真实的 Prefill 计算,这是阶段一那个「瞎猜的负载均衡器」的直接解药。

第五步:把扩缩容信号换成队列深度

按第三步给出的 ScaledObject 配置,把触发器从 CPU 或 GPU 利用率换成等待中的请求数。同时把最小副本数抬到一个不会冷启动的水平,把冷却期拉长到足以避免反复冷启动。这一步做完之后,再回头检查一遍指标名称是否真的返回了数据。

一个完整示例

假设你运维的是一个内部助手服务,部署在 Kubernetes 上,用 vLLM 提供推理,前面挂 Ingress,扩缩容由 KEDA 驱动。某天下午两点一刻,支持频道开始出现抱怨。

第一步,确认集群层面没有异常。节点 Ready、Pod Running、CPU 两成、无重启、无告警。到这里可以确定:这不是宕机,是静默故障,继续往下走。

第二步,查队列深度而不是利用率。对 Prometheus 执行等待中请求数的查询,如果这个数字在抱怨出现的时间段明显抬升,说明请求确实在排队,问题不在模型。

第三步,检查扩缩容是否真的生效。如果 ScaledObject 的查询因为指标改名而返回空,副本数会一直停在最小值。把查询改成当前版本正确的指标名,确认返回非空,再观察副本数是否随队列深度上升。

第四步,检查 GPU 拓扑。如果副本数上去了但新 Pod 一直 Pending,去看节点上的空闲卡分布。六张空闲卡分散在四台节点上,对一个需要多卡同节点的 70B 副本来说等于零容量。这时要么调整调度约束,要么启用某种形式的卡共享。

第五步,用长输出提示词复现超时。如果生成在 60 秒左右被整齐截断,那就是 Ingress 读超时;如果 token 是成团出现的而不是逐个流出,那就是响应缓冲。两项都改掉,再用同样的长提示词验证一次。

第六步,把最小副本数抬高、冷却期拉长,然后在下一次早高峰或全员大会后观察一遍。如果尖峰期间队列深度不再持续攀升,这条链路就算通了。

注意事项

  • 不要用 CPU 或 GPU 利用率作为推理服务的扩缩容信号。GPU 干活时 CPU 是闲的;GPU 利用率对单请求和六十请求都读 100%,无法反映是否有人在等。队列深度才是有效信号。
  • 指标名称会随版本变化,且失败是静默的。查询返回空不会报错,只会让扩缩容悄悄失效。每次升级推理服务版本后都要重新核对一遍指标名。
  • 反应式扩缩容永远迟到。迟到时间等于冷启动时间,包括拉镜像、下载权重、加载进显存,以及节点供给和可用区库存的等待。没有任何触发器能消除这段延迟,只能靠预留更多空闲容量来吸收。
  • 缩容到零不适合面向用户的推理服务。把它留给批处理任务。
  • GPU 按整卡分配,碎片化不会告警。纸面容量充足而副本停在 Pending 时,不会有任何东西「坏掉」,因此也不会有告警。需要主动检查节点拓扑。
  • 卡共享方案各有代价。时间切片没有显存隔离;MIG 的切片几何固定,需要提前预测负载组合;动态资源分配是结构上正确的方向。三者默认都不开启。
  • Ingress 的默认超时和缓冲是为短请求写的。60 秒读超时会把长生成掐断在句子中间,用户会报告成崩溃;响应缓冲会破坏流式手感。用长输出提示词测试,短提示词复现不了。
  • 「低于 250 毫秒响应」这类指标要按首 token 时间来理解。字面意义上的完整回答不可能达到这个量级。
  • 责任归属本身就是故障的一部分。平台团队、应用开发者、MLOps 三方各自看到的都是真话,问题却落在他们的值班表缝隙里。如果没有人明确拥有「峰值负载下的端到端延迟」这个指标,前五类问题会一直存活下去。
  • 容量、性能、放置、韧性、归属,这五件事需要有人负责。火扑灭之后,按这五个方向逐项确认,而不是等下一次周四下午两点一刻。