LLM 尾延迟排查与推理引擎调度优化实践指南 线上 Chatbot 服务经常会出现一个“怪异”现象监控面板里平均时延只有三四百毫秒但用户却总反馈“偶尔点一下要等好几秒”而且越到大促或者压测越明显。把链路追踪拉出来看慢请求里面往往没有数据库慢查询也没有跨机房调用大部分时间都花在了模型推理服务自身的队列和 GPU 执行上。这种问题不能简单归结为“偶发抖动”。在大语言模型Large Language ModelLLM服务中它有一个专门的名字tail latency也就是尾延迟。真正让系统难维护的不是平均时延而是 p99 甚至 p99.9 的尾延迟。平均时延正常不代表系统健康只要尾延迟失控用户投诉很快就会升级而团队很容易陷入“无脑加机器”的误区。这篇文章想强调一个判断LLM 尾延迟很多时候不是纯粹算力不够而是推理引擎内部的调度问题。一个请求在 GPU 上的执行可以分为 prefill 和 decode 两个阶段当大量 prefill 计算和 decode 计算互相抢占资源时慢请求几乎必然出现。下文会从基础概念开始分析慢请求到底从哪里来并给出一条完整的“先量化、再改配置、最后验证”的修复路径。读完你应该能定位自己服务的尾延迟属于哪一类并做出有针对性的调整。无论你是做 RAG 应用、Agent 后端还是模型服务平台本文都适用。文中不涉及复杂公式重点是可落地的配置和观测方法建议收藏后用测试环境先跑一遍。1. 先搞清楚被“平均时延”掩盖的尾延迟1.1 一次 LLM 请求的两个阶段要理解尾延迟先要理解一次请求在服务内部做了什么。LLM 服务不像普通 HTTP 接口那样“收到参数、查下数据库、返回结果”它内部至少要执行两个完全不同的计算阶段Prefill 阶段处理用户输入的提示词把整段 Prompt 并行编码成 KV Cache同时生成第一个输出 Token。这个阶段计算量大但只执行一次。对应到用户体验就是“从发出请求到看到第一个字”的等待时间常被称为首 Token 时延Time To First TokenTTFT。Decode 阶段根据已有上下文逐个 Token 生成后续内容。每个 Token 都需要读取全部 KV Cache属于访存密集操作。这个过程会循环执行直到生成结束或满足停止条件。对应到用户体验是“看到第一个字之后后续每个字到来的速度”。普通接口的耗时主要在磁盘、数据库或下游服务而 LLM 的耗时分为“处理整段输入”和“逐个生成输出”两部分。两部分的时间特性和资源需求都不一样这为尾延迟埋下了伏笔。1.2 为什么不能只看平均时延我们通常用百分位数描述时延分布p50 表示 50% 的请求比它快p99 表示 99% 的请求比它快。假设接口平均耗时 400ms但不代表所有请求都是 400ms。如果 p99 是 3 秒就意味着每 100 个请求里就有 1 个请求要等 3 秒。对一个日活几十万的 ToC 应用来说这相当于每天有成百上千名用户正在经历“卡顿”。用户感知最强烈的往往不是平均值而是最差的那几次体验。平均时延只是整体水平的体现尾延迟才是风险所在。它还直接影响错误率当某个请求超过客户端超时时间时用户会看到失败当并发升高时超过超时时间的请求会更多进一步表现为“系统越忙越不稳定”。1.3 LLM 尾延迟与传统服务尾延迟的区别传统 Web 服务的尾延迟可能来自慢 SQL、冷缓存、网络重传或 GC 停顿通常是“间歇性事件”。LLM 服务则多了一层随机性每个请求的 Prompt 长度不同、生成长度不同即使同一个 Prompt 在不同负载下也可能因为批处理编排方式不同而慢很多。LLM 服务的天然劣势在于解码是串行循环总时延约等于首 Token 时延加后续每个 Token 时延之和。生成越长的内容单个请求占用的 GPU 时间越长后面排队的请求就越容易被拖住。这正是“调度”成为核心变量的原因。2. 追根LLM 慢请求通常来自哪里排查尾延迟不要一上来就调内核参数先分清楚它发生在哪一层。我们可以把常见来源分成三层推理调度层、负载控制层、基础设施层。2.1 推理调度层prefill 与 decode 互相拖累现在主流的推理引擎普遍采用动态批处理或连续批处理也就是说一个 GPU 上同时执行多个请求当前请求生成完一个 Token 后新的请求可以插入当前批次而不是等整批结束。这个设计大幅提升了吞吐但也带来一个问题当批次里有一个超长 Prompt 请求进入 prefill 时它要一次性处理几千个 Token这个计算过程可能独占 GPU 资源很长时间让同批或后续的 decode 请求无法及时产出下一个 Token。表现就是明明很多用户只是发了一句“你好”却因为它们和一个正在解析长文档的请求被排在同一个批次里硬生生等了好几秒。只要有长 Prompt 请求存在decode 阶段的小请求就会周期性卡顿。这种相互影响是最容易被误读成“GPU 不够用”的源头。2.2 负载控制层队列堆积带来的雪崩另一个常见来源是推理服务前面的队列或异步任务堆积。很多团队会用消息队列做异步生成如果消费速率跟不上生产速率队列长度会持续增长。单个请求在队列里等待多久取决于它前面有多少请求以及每个请求的平均处理耗时而不是只看当前 GPU 负载。这里真正容易踩坑的是当模型服务已经饱和队列只能暂时吸收压力但等到队列水位超过内存上限或消费者超时就会出现整批请求同时失败。监控上看到的表象是“时延从 1 秒突然跳到 10 秒”本质是排队而不是计算变慢。2.3 上下文与基础设施导致的“二度放大”还有一类不可忽略的因素是 Prompt 长度和上下文缓存。支持前缀缓存Prefix Cache的服务如果命中缓存prefill 时间能大幅缩短如果请求的 Prompt 各不相同导致缓存命中率低GPU 就要重新计算大量前缀prefill 时间波动很大。基础设施层面GPU 的功耗限制、CPU 侧的 Tokenize、内存带宽争抢、网络代理超时也都可能让少数请求意外变慢。来源层直接表现建议观测指标调度层p95 随长 Prompt 出现周期性尖刺TTFT、ITL、批次内请求数负载层队列长度增长超时错误增加队列深度、排队等待时延上下文层缓存命中低时 prefill 显著变慢Prefix Cache Hit Rate基础设施层偶发整机抖动GPU 利用率、温度、宿主机负载做归因时不要只看一两个指标。好的做法是把服务端日志里的“排队耗时、prefill 耗时、decode 耗时”都打出来这样才能判断慢在哪个环节。设计上也可以直接把 TTFT 单独监控用户等第一个字超过 2 秒基本可以断定 prefill 或排队出了问题。3. 常见误区一慢就扩容是成本最高的解决方式看到 p99 高很多团队第一反应是加 GPU 节点、加副本。对无状态 Web 服务扩容往往立竿见影但对 LLM 服务扩容不一定有效甚至可能让问题更隐蔽。原因在于调度层面的冲突不会因为总算力增加而自动消失。假设单个 GPU 同时处理 8 个请求扩容一倍后每个 GPU 只处理 4 个请求理论上变快了但如果负载均衡策略没变、超长请求仍然随机分配到某个节点那个节点上的小请求依然会被长请求堵住。若流量再增加节点多了但长请求的绝对数量也没变少尾延迟依旧存在。另一个经常被忽视的问题是扩容会改变模型并发参数。默认配置下每个 GPU 能容纳的最大序列数可能是固定的。新节点加入后如果没有相应调整最大批处理 Token 数和最大并发序列数引擎依然会把大量请求塞进同一个 batch导致单节点更容易出现互抢现象。所以正确的技术动作不是急着扩容而是先回答三个问题慢请求是排队慢还是执行慢执行慢发生在 prefill 还是 decode服务端的批处理配置是否和请求长度分布匹配答案清晰后再决定是调整代码策略、改配置还是真的需要加机器。4. 一种简单但高效的修复思路把调度策略显式化不同的推理引擎在实现细节上有差异但底层原则是通用的。既然尾延迟主要来自 prefill 与 decode 的资源争抢和队列不可控那简单修复的核心就是不要让这两类任务毫无约束地互相打断并对它们的执行顺序和资源上限做显式控制。4.1 检查是否开启连续批处理和 Chunked Prefill连续批处理几乎是现代推理引擎的标配它避免了传统静态批处理“要等整批全部结束才能插新请求”的缺陷。真正需要检查的是 Chunked Prefill也就是把长 Prompt 的 prefill 计算拆成多个小段让 decode 请求可以插在多个 prefill 小段之间执行。这样单个大请求不会再一口气独占 GPU 很长时间。从效果上看Chunked Prefill 牺牲了一点大请求自身的响应速度换来整体 p99 的大幅下降。对交互式应用来说这个取舍非常划算。要注意的是很多框架在旧版本里需要手动开启新版本则可能是自动策略具体要看你使用的推理框架和版本。4.2 对不同请求做优先级或泳道隔离如果是服务层暴露给不同业务方使用更彻底的调度策略是“分泳道”。把短对话、长文档问答、批量生成拆成不同的服务入口或不同的模型副本。短对话场景要求低延迟应配置较小的并发和较短的队列长文档场景可以接受较高时延但需要保证超长 Prompt 不进入短对话所在的池子。如果同一份服务不得不服务所有请求可以考虑给请求打上优先级标签交互请求优先后台批量任务放低优先级。实际工程里这通常需要在网关层识别请求来源并在推理框架支持的调度策略中配置优先级。哪怕是同一批 GPU优先级调度的效果也比“后到先占资源”的默认情况好很多。4.3 控制负载而不是持续堆积很多服务在队列上缺乏上限设置导致请求无限制排队。更好的做法是为推理服务设置明确的队列深度和最大并发。当并发超过上限时直接向客户端返回 429 或提示“稍后重试”而不是让请求排队 10 秒后超时。对调用方来说快速失败并重试往往比长时间等待更容易接受因为这让客户端超时控制变得可预期。生产环境里有一类问题就是“超时设置太宽松”。客户端给 30 秒甚至 60 秒的超时服务端一旦出现队列堆积大量请求会在 20 秒左右才超时让前端用户误以为系统完全不可用。把它改成 5 秒超时加指数退避重试体验反而稳定很多。5. 动手前先量化一个最简单的 LLM 尾延迟观测脚本在改动任何配置之前建议先跑一轮基线压测。这里给一个使用 Python 标准库实现的最小观测脚本不依赖第三方库。它会以指定并发持续发起请求最终打印 p50、p95、p99 和最大时延。# latency_probe.py # 运行环境Python 3.8 import argparse import concurrent.futures import json import statistics import time import urllib.request def call_llm(url: str, prompt: str, max_tokens: int): payload { model: default, messages: [{role: user, content: prompt}], max_tokens: max_tokens, stream: False, } req urllib.request.Request( url, datajson.dumps(payload).encode(utf-8), headers{Content-Type: application/json}, methodPOST, ) start time.perf_counter() with urllib.request.urlopen(req, timeout30) as resp: resp.read() return time.perf_counter() - start def percentile(sorted_values, p: float): if not sorted_values: return 0.0 k (len(sorted_values) - 1) * p f int(k) c min(f 1, len(sorted_values) - 1) return sorted_values[f] * (c - k) sorted_values[c] * (k - f) def main(): parser argparse.ArgumentParser(description简单的 LLM 尾延迟观测脚本) parser.add_argument(--url, defaulthttp://127.0.0.1:8000/v1/chat/completions) parser.add_argument(--prompt, default用一句话介绍大语言模型。) parser.add_argument(--total, typeint, default200) parser.add_argument(--concurrency, typeint, default16) parser.add_argument(--max-tokens, typeint, default128) args parser.parse_args() samples [] errors [] with concurrent.futures.ThreadPoolExecutor(max_workersargs.concurrency) as pool: futures [ pool.submit(call_llm, args.url, args.prompt, args.max_tokens) for _ in range(args.total) ] for future in concurrent.futures.as_completed(futures): try: samples.append(future.result()) except Exception as exc: # 记录请求异常 errors.append(str(exc)) if not samples: print(所有请求都失败了请检查服务是否启动、url 是否可达) for err in errors[:5]: print(error:, err) return samples.sort() print(f成功请求数: {len(samples)}失败请求数: {len(errors)}) print(fmin {samples[0] * 1000:.1f} ms) print(fp50 {percentile(samples, 0.50) * 1000:.1f} ms) print(fp95 {percentile(samples, 0.95) * 1000:.1f} ms) print(fp99 {percentile(samples, 0.99) * 1000:.1f} ms) print(fmax {samples[-1] * 1000:.1f} ms) if __name__ __main__: main()这个脚本的逻辑并不复杂并发提交请求记录每个请求从发出到收到完整响应的耗时排序后按百分位输出。压测时建议把--total设到 200 以上--concurrency按照线上峰值并发的 0.5 到 1 倍来设置。由于脚本会让请求真正产生模型推理它会在 GPU 上增加真实负载测试前请确认环境允许。关键点在于“同样一个 Prompt 在不同并发下跑两轮”。如果你只用一个请求测时延结果基本是引擎的串行表现只有提高并发才能把调度层面的冲突暴露出来。6. 完整示例调整推理引擎配置并验证效果6.1 修改服务端推理参数接下来用一个常见推理框架 vLLM 的启动命令做演示。不同版本的参数名会有差异运行前先执行vllm serve --help确认当前版本的选项。python3 -m vllm.entrypoints.openai.api_server \ --model /opt/models/your-llm \ --tensor-parallel-size 2 \ --max-model-len 8192 \ --max-num-seqs 16 \ --enable-chunked-prefill auto \ --gpu-memory-utilization 0.9这里几个参数的含义可以这样理解--max-model-len控制模型能接受的最大上下文长度。如果业务几乎不会超过 8192 Token就没必要设置为 32768因为更长的允许长度意味着更大的显存预留和潜在的 prefill 时间波动。--max-num-seqs表示引擎单个批次最多容纳的序列数。值越大吞吐越高但每个请求获得 GPU 时间片的机会越小单请求时延会上升。--enable-chunked-prefill用于开启或关闭长 Prompt 分段处理。在默认策略下如果开启后吞吐略有下降但 p99 明显改善说明它解决了核心冲突。--gpu-memory-utilization限制 KV Cache 可用的显存比例不是越大越好设置过高会导致少量长请求把显存占满触发强制淘汰而拖累整体。如果发现长 Prompt 请求仍然导致 p99 高还可以进一步调小--max-num-seqs并在网关层给短对话场景单独设置一个 endpoint。6.2 用前后对比验证修复是否有效配置改完后不要凭个例判断效果。按下面的流程走一遍改配置前记录一轮基线数据。修改服务配置并重启。等模型加载完成后再跑一轮相同的观测脚本。比较 p50、p95、p99 的变化。# 第一轮修改前的基线 python3 latency_probe.py --url http://127.0.0.1:8000/v1/chat/completions --total 200 --concurrency 16 # 第二轮修改后确保使用相同的 Prompt 和并发 python3 latency_probe.py --url http://127.0.0.1:8000/v1/chat/completions --total 200 --concurrency 16判断成功的标准不是平均时延下降多少而是 p99 和 p95 是否明显收窄。如果 p99 从 5000ms 降到 1200ms而 p50 只是从 300ms 涨到 400ms那么修复方向基本正确如果 p95 下降了但错误率上升就要检查是否因为队列上限或超时设置过严导致负载被直接拒掉。这里必须提醒一点不要在业务高峰期直接在生产环境修改调度参数。修改会触发大量排队请求重新调度或直接涌入容易造成瞬时负载翻倍。更好的做法是先复制一小部分线上流量到测试实例或者选择低峰期小流量灰度。7. 常见问题与排查方法问题现象可能原因排查方式解决方案平均时延正常但 p99 很高少数长 Prompt 请求与 decode 请求互相抢占看 TTFT 是否周期性飙升检查请求长度分布开启 Chunked Prefill或限制单批次最大 Token 数并发一高就大量超时队列无限堆积请求等待时间过长查看服务端 queue size 和平均排队耗时设置最大并发与队列上限超过时快速返回 429加了一倍 GPU 后 p99 没改善调度策略或负载均衡没有针对长请求做隔离对比单节点和新节点的请求分布按业务场景分泳道或调整--max-num-seqs长 Prompt 请求时整个服务变卡单个 prefill 独占 GPU 时间过长查看 TTFT 与 GPU 时间线拆分长 Prompt、使用摘要或检索减少输入长度流式接口首 Token 慢但之后流畅prefill 阶段耗时过高监控 TTFT 与 Prompt 长度关系使用前缀缓存、语义缓存或调整 prefill 分块策略偶发单个请求慢很多但批次无异常宿主机抖动、GPU 功耗调度或网络层问题同时观察客户端与服务端时间戳检查宿主机负载必要时做节点迁移7.1 如何判断“该调整配置还是该扩容”一个实用经验是如果 p50 很低但 p99 很高说明整体算力充足问题在调度和负载控制优先调配置。如果 p50 和 p99 一起恶化且服务端 GPU 利用率已接近上限才考虑扩容。如果 p50 正常但错误率持续上升应优先检查客户端超时和服务端队列策略。这些判断不可能只凭一次压测完成。建议把观测脚本固化到 CI 或发布流程里每次模型版本或服务参数变更后都跑一轮避免尾延迟问题回归。8. 最佳实践与工程建议在生产环境治理 LLM 尾延迟只调一两个参数是不够的。下面几项实践是我建议团队优先落实的。8.1 指标分层TTFT 与 Token 间延迟分开统计不要只记录完整请求时延。合理做法是把链路拆成三段排队等待时延、prefill 时延、decode 生成阶段每个 Token 的间隔时间。分别统计 p50 和 p99。这样一旦出现尾延迟你能直接回答“是排到队之前就慢了还是进了 GPU 才慢”。在日志里至少包含请求 ID、Prompt Token 数、生成 Token 数、排队耗时、prefill 耗时、decode 耗时和总耗时。问题发生时可以用请求 ID 精确复现并对比同批次其他请求。8.2 为业务方设置明确的超时与重试规范调用方最容易犯的错误是不区分“服务端正在生成”和“服务端已经故障”。如果是流式输出建议客户端的首个 Token 超时设置短一些例如 5 秒首 Token 到达后再对后续流设置空闲超时。如果是非流式接口可以适当放宽但不建议超过 30 秒。重试要带退避和随机抖动避免雪崩。8.3 用请求分类代替一刀切短对话、长文档摘要、批量分析这三类请求的工作负载完全不同把它们打进同一个服务单位是很不划算的。理想状态下按场景拆分模型副本交互场景用低并发低延迟配置离线批量场景用高吞吐配置。如果无法拆副本也要在网关层按 Prompt 长度或调用方身份设置不同优先级。8.4 关注上下文工程对尾延迟的间接影响很多尾延迟问题不是“推理没优化好”而是“用户把一本书都塞进了 Prompt”。在业务侧控制输入长度往往比压榨推理引擎参数更有效。常见手段包括对文档切块后只取相关片段、先用小模型做预筛、对长对话做历史裁剪、使用检索增强而不是全量输入。Prompt 输入长度下降后prefill 压力自然降低尾延迟也会随之改善。8.5 变更前做好模型路由与回滚涉及推理框架升级、调度策略变更、显存参数调整时一定要留好回滚能力。生产环境可以采用双模型副本灰度先切 10% 流量到新配置实例观察 30 分钟以上的 p99、错误率和 GPU 利用率再逐步放大。如果新实例表现不佳把流量切回旧实例即可不需要紧急重启整个集群。还有一个容易被忽略的点修改--max-num-seqs或内存池大小会影响显存申请。如果新配置会导致模型加载后显存不足服务会启动失败。因此任何变更都要先在测试环境用与生产相同的显存规格验证一遍。9. 总结与后续学习方向LLM 尾延迟不是某一个单一原因造成的但大多数情况下都离不开 prefill 与 decode 的资源争抢、请求排队失控以及批处理参数配置不当。与其在每次告警时临时救火不如先把观测工具做起来把 TTFT、Token 间延迟、队列深度和缓存命中率纳入常规监控再根据数据决定是开启 Chunked Prefill、分泳道隔离还是调整并发上限。本文给出的“简单修复”核心思路是让调度变得可见、可控给不同的请求分配合理的执行顺序和时间片让长任务不再无限阻塞短任务。这个方法不需要改模型结构也不需要重写推理引擎大多数团队在现有框架上就能完成。需要提醒的是生产环境里没有一套参数能适配所有业务关键是通过 A/B 对比找到适合自己流量特征的取值。如果你对更深入的方案感兴趣可以继续研究几个方向一是推理框架内部连续批处理和抢占机制的源码实现二是将 prefill 与 decode 分布到不同 GPU 上的分布式推理架构三是为长上下文和短对话分别设计调度策略的工程实践。理解了这几个方向再回头看生产环境的监控曲线你会比大多数人更快定位到真正的问题。