理解并消除LLM推理尾延迟:从分位数监控到调度优化

发布时间:2026/8/30 14:12:21
理解并消除LLM推理尾延迟:从分位数监控到调度优化 最近在调 LLM 在线服务遇到一个很典型的现象平均延迟看着还行但总有一部分请求特别慢慢到用户反复重试网关也频繁超时。一查监控p50 只有 1 秒出头p99 却冲到 20 多秒。这个分布不均匀的问题就是 LLM 推理里的 tail latency也就是尾延迟。这次我们就针对 LLM tail latency 来做一次系统的拆解。文章会先解释它为什么在自回归模型场景里特别突出再给出从观测到修复的完整路径重点放在几个不需要重写推理框架的“简单修复”手段上。比较适合正在做 LLM 在线服务、想优化接口响应时间的读者。1. 核心问题速览问题维度说明问题类型LLM 在线推理性能优化服务层工程问题核心指标p50、p95、p99、p999不是只看平均延迟主要成因请求排队、prefill 与 decode 混跑、动态批处理木桶效应、输入长度抖动、前缀缓存命中差异、资源争抢修复方向超时控制、优先级队列、调度权重调整、生成长度限制、前缀缓存、复制请求、在线离线任务分离模型影响无需重训模型不需要改权重属于推理服务配置与调度优化适用场景在线对话服务、Agent 应用、RAG 问答、批量生成任务硬件门槛取决于模型规模尾延迟问题通常在 GPU 推理服务上更明显部署方式一般配合开源推理框架或自研推理服务使用属于服务端配置层优化需要先说明一个前提tail latency 不是单靠某一招就能根治的它往往是多个因素叠加的结果。简单修复的目标是先消除最大的几个不平滑因素。2. 为什么 LLM 服务更容易出现尾延迟传统 Web 服务的 tail latency 来源通常是数据库慢查询、网络抖动、GC 停顿。LLM 推理服务在这之上又叠加了几层特殊性。第一个特殊点在于自回归生成过程。一个请求生成 500 个 token就要做 500 次前向计算而且第 n 个 token 依赖前 n-1 个 token 的输出无法并行。这意味着单个慢请求会长时间占据 GPU 计算资源同批的其他请求也跟着等。第二个特殊点是 prefill 和 decode 的计算特征完全不同。prefill 阶段要并行处理整个输入序列计算密集decode 阶段逐 token 生成更多是访存密集。两者混跑时调度策略稍有不当计算密集的 prefill 就会挤占 decode 的带宽导致部分请求延迟飙升。第三个特殊点是动态批处理带来的木桶效应。现代推理框架普遍采用连续批处理一个 batch 内只要有一个请求还在生成这个请求就会占用 batch 内所有已被分配的显存和计算资源。即使新请求可以插入 batch也依旧要等待当前正在执行的 token 步完成。短请求一旦和长输出请求分到同一批延迟就会被明显拉长。再叠加输入长度差异。一个输入 2000 token 的请求和输入 20 token 的请求prefill 耗时可能相差两个数量级。如果调度器没有做长度感知长输入请求会把短请求堵在后面甚至让 p99 直接失控。所以 LLM 服务的 tail latency 不是偶发现象而是系统设计层面的必然产物。理解了这一点才知道该从哪些角度下手。3. 先正确观测再讨论修复没有量化就没有优化。很多团队一上来就调调度参数结果越调越乱因为没有把问题定位清楚。观测 tail latency 时不能只看平均值。平均值会把那些 20 秒的慢请求藏起来。必须看分位数分布指标含义关注点p50一半请求在 1 秒内完成整体体验基线p9595% 请求在 5 秒内完成大多数用户的真实体验p9999% 请求在 8 秒内完成用户可感知的异常延迟p99999.9% 请求在 20 秒内完成极端异常通常伴随超时重试除了延迟分位数建议同时记录输入 token 数、输出 token 数、prefill 耗时、decode 耗时和排队耗时。把它们关联起来才能判断慢请求是慢在排队、慢在 prefill还是慢在 decode。用一段 Python 代码可以模拟常见的分位数统计分析import time import random import statistics def collect_latencies(count10000): latencies [] for _ in range(count): # 模拟大多数请求较快少数请求很慢 base random.uniform(0.8, 1.5) if random.random() 0.02: base random.uniform(5, 20) latencies.append(base) return latencies def print_percentiles(latencies): sorted_lat sorted(latencies) n len(sorted_lat) for p in [50, 90, 95, 99, 999]: idx min(n - 1, int(n * p / 100)) print(fp{p:4} {sorted_lat[idx]:.3f}s) latencies collect_latencies() print_percentiles(latencies)这里只是示意的统计方法。生产环境建议直接上报到 Prometheus 这类时序数据库用 Histogram 指标存储分位数Grafana 里配置 p50、p95、p99 面板。每次调整调度参数后对比同一时间段的分位数变化而不是对比平均值。另一个容易被忽视的点是超时观测。网关侧超时时间、框架侧超时时间、客户端重试策略这三者阈值不同会让同一批请求产生完全不同的延迟观测结果。比如客户端 10 秒超时重试而框架 p99 是 20 秒那么最大的延迟永远被切在 10 秒表面看 p99 变好了实际是请求被重复打进来了。4. LLM tail latency 的关键成因分析4.1 请求排队机制很多推理服务默认就是先来先服务队列。这个策略对普通 Web 服务问题不大但对 LLM 服务影响很大。假设队列里来了一个输入 4000 token、需要生成 1024 token 的请求后面跟着 10 个输入很短、只需生成 64 token 的请求。先来先服务会导致后面 10 个请求全部等待长请求完成短请求的 tail latency 直接爆掉。改进方式是引入长度感知的队列调度让短请求可以插队或者在队列入口就限制单请求处理上限。4.2 prefill 与 decode 相互干扰prefill 要并行计算大量 tokenGPU 计算单元使用率高decode 阶段则更依赖显存带宽每次只生成一个 token。如果批量调度时把大量 prefill 任务和大量 decode 任务混在一起就可能出现“计算把带宽打满”的窗口decode 请求在某个时间片内迟迟得不到足够的资源。从观测数据看这个现象通常表现为 decode 耗时的 p99 明显高于 p50且 GPU 利用率波动剧烈。修复思路是限制单批中 prefill 请求占比或者把 prefill 和 decode 拆到不同执行阶段。4.3 动态批处理内部的木桶效应连续批处理允许请求动态加入和离开 batch但核心限制仍然在batch 内所有请求共享同一个计算步骤。只要 batch 里有一个长输出请求其他短请求就必须陪着它跑完当前步骤。更麻烦的是有些框架配置了最高的 batch token 上限长请求占用了大量 token 预算后新请求插入 batch 的空间会变小整体吞吐随之下降。4.4 输入长度抖动与 max_tokens 设置不当输入 token 数差异过大会让 prefill 耗时出现几个数量级的波动。有些业务场景中用户把整篇文章粘贴进对话输入长度从几十 token 跳到几千 token调度器如果不知道这个信息就很难做出合理分配。输出长度控制同样重要。如果 max_tokens 设置过大即使大部分请求几十个 token 就结束了调度器也会预留大量空间导致 batch 能容纳的请求数变少单位时间吞吐下降排队延迟上升。4.5 前缀缓存命中率差异RAG、Agent 等场景中系统提示词和知识库上下文往往很长。如果推理框架支持前缀缓存那么缓存命中的请求 prefill 耗时极低缓存未命中的请求则要从头计算。请求前缀差异大时命中率波动会直接反映在延迟分布上。4.6 多租户与资源争抢多业务共享同一个 GPU 集群时显存带宽、PCIe 带宽、CPU 内存带宽都会成为竞争点。某个业务跑大数据量 batch 时其他业务的 decode 延迟就会被拉高。这种系统级干扰最难排查需要靠监控数据里面“同时间段其他业务负载”来定位。5. 简单修复方案不重写框架也能降尾延迟很多团队没有能力改推理框架底层所以“简单修复”的价值在于用配置和上层调度策略先把最明显的 tail latency 压下去。以下是按优先级排列的修复项。5.1 加超时并且分层限流这是成本最低、见效最快的修复手段。在网关层、推理服务层、客户端三层分别设置超时时间层级之间保持递减关系# 客户端 / 网关超时配置示例 CLIENT_TIMEOUT_SECONDS 60 GATEWAY_TIMEOUT_SECONDS 55 INFERENCE_SERVER_TIMEOUT_SECONDS 50设置超时不是简单拒绝慢请求还要配合错误分类。超时有两种排队超时和生成超时。排队超时可以更激进比如队列等待超过 10 秒直接返回 503客户端立即换一个实例请求。生成超时则要根据业务容忍度设置一般建议不超过 60 秒。同时要对重复请求做限流。客户端超时后重试会放大并发进一步恶化服务端负载。建议在网关层记录同一会话的重试次数超过阈值直接返回。5.2 短作业优先配合优先级队列把请求分成几个队列小请求优先长请求有独立通道避免互相阻塞。比如queue: priority_high: max_input_tokens: 512 max_output_tokens: 128 weight: 2 priority_normal: max_input_tokens: 2048 max_output_tokens: 512 weight: 1 priority_low: max_input_tokens: 8192 max_output_tokens: 2048 weight: 0.5关键思路是让高优先级队列的请求总是先被调度低优先级大请求只在系统空闲时才执行。这个方案不需要改推理框架只要在请求入口做分类即可。5.3 控制单请求输入输出规模限制输入长度和输出长度是另一个立竿见影的手段。LLM 推理中输出 token 数和耗时几乎线性相关减少最大输出长度就能显著降低单请求最长耗时。建议在 API 入口层对 prompt 做截断或摘要对 max_tokens 做白名单限制。例如在线对话场景固定 max_tokens 为 512批量写作场景单独走离线通道设置 2048。5.4 利用前缀缓存提升命中率如果使用支持前缀缓存特性的推理框架要确保系统提示词和公共知识库前缀尽量一致提高缓存命中概率。工程上建议将系统提示词做成固定模板不要每个请求拼接不同的开头文本。5.5 复制请求取最快返回结果谷歌在传统分布式系统里广泛使用的 hedged requests在 LLM 场景也能用。对于延迟敏感的关键请求同时发送两个请求到不同实例先返回的结果胜出。代价是成本翻倍所以只适合低并发高价值的请求例如用户主动触发的敏感操作不适合所有请求。import asyncio async def call_llm(session, url, payload): async with session.post(url, jsonpayload) as resp: return await resp.json() async def hedged_call(session, urls, payload): tasks [call_llm(session, url, payload) for url in urls] done, pending await asyncio.wait( tasks, return_whenasyncio.FIRST_COMPLETED ) for task in pending: task.cancel() return done.pop().result()需要注意的是复制请求同样也会放大后端压力。建议只对 p99 超时集中出现的高价值请求启用并且设置全局比例上限例如 5% 的请求使用复制策略。5.6 限制单批请求数量预留吞吐余量动态批处理虽然能提高吞吐但 batch 过大会导致每一步计算时间变长单请求延迟变大。简单修复方式是调低单批最大 token 数或者在高峰期限制并发请求数。这里的原则是在延迟和吞吐之间找到平衡点。优先保证 p95 达标再逐步增大 batch 参数观察 p99 变化。6. 批量任务与请求调度策略在线服务和离线批量任务应该彻底分离。批量生成文章、批量评测、批量翻译都尽量不要打到在线推理服务上。这类任务允许较长时间运行可以单独部署一套实例走独立的队列。批量任务场景中同样存在 tail latency 问题但处理方式不同于在线请求。离线任务不需要严格超时反而应该记录每个任务的耗时分布把异常慢的任务单独捞出来分析。常见原因包括单条数据超长、模型退化产生无限循环、显存碎片导致 OOM 重试。推荐为批量任务增加以下机制机制作用超时重试单任务超过阈值则标记失败延迟重试断点续跑任务队列持久化服务重启后继续处理并发限制控制同时运行的批量任务数避免打满显存日志采样对慢任务记录完整输入输出 URL便于复盘一个简单的批量任务提交脚本可以这样设计import asyncio import aiohttp async def submit_batch(session, tasks, endpoint): results [] semaphore asyncio.Semaphore(4) async def process_one(task): async with semaphore: async with session.post(endpoint, jsontask) as resp: return await resp.json() for future in asyncio.as_completed([process_one(t) for t in tasks]): results.append(await future) return results其中 Semaphore 控制并发数避免一次性把 GPU 显存占满。7. 资源占用与性能观察方法在优化 tail latency 的过程中有几个资源指标需要重点观察。7.1 GPU 利用率和显存占用首先区分 GPU 利用率和显存占用。显存占用高不代表计算繁忙LLM decode 阶段显存占用通常很高但 GPU 计算单元利用率可能并不高。真正影响延迟的是显存带宽和计算资源而不是显存剩余量。使用 nvidia-smi 或 DCGM 指标观察时重点看 SM 利用率、显存带宽利用率和温度。如果 SM 利用率在 decode 阶段很低说明请求可能在等待数据加载存在带宽瓶颈。7.2 队列长度和排队耗时推理服务应该暴露队列长度和每个请求的排队耗时。排队耗时增加说明服务能力已经达到上限此时单纯调超时参数无法解决需要扩容或限流。7.3 批处理大小动态变化观察每个 batch 的平均 token 数和请求数。如果 batch 内 token 数波动很大说明输入长度不均衡调度器可能经常处于“大请求挤占小请求”的状态。7.4 如何降低显存占用如果显存不足导致频繁 OOM 或重试会制造大量隐性的 tail latency。可以调整以下参数# 通用推理服务配置模板实际参数按部署框架调整 max_batch_tokens: 4096 max_batch_requests: 32 max_input_length: 2048 max_output_length: 512 gpu_memory_utilization: 0.85其中 gpu_memory_utilization 控制显存预留比例max_batch_tokens 控制单批 token 上限。调低这些参数会降低吞吐但也会减少每步计算时间让短请求更快结束。8. 常见问题与排查方法问题现象可能原因排查方式解决方案平均延迟正常但部分请求超时少数长请求挤占资源看 p99、p999 和请求耗时分布限制 max_tokens、短作业优先p99 持续走高请求排队严重查看队列长度和排队耗时限流、扩容、短请求插队首个请求特别慢前缀缓存未命中、冷启动对比缓存命中率固定系统提示词预热公共前缀批量任务中单条数据极慢单条输入或输出过长查看任务时长日志设置单任务长度上限和超时重试多业务共用 GPU 时延迟波动资源争抢查看同时间段其他业务负载在线离线分离业务限流增加并发后延迟快速恶化batch 过大或显存不足观察 GPU 利用率、显存余量调低 batch token 上限预留显存复制请求没有降低 p99后端实例同时过载检查两个实例的负载是否均衡确保复制请求发往不同实例和可用区调大并发后 OOM 频繁并发数或 batch token 数设置过高看显存占用曲线降低并发开启交换或分片策略排查 tail latency 时建议按照“排队耗时 → prefill 耗时 → decode 耗时”的顺序定位不要一上来就改调度参数。每一步改动都要有监控数据支撑。9. 最佳实践与部署建议基于上面这些分析这里整理一套比较稳妥的落地顺序。9.1 先建立分位数监控任何优化开始前先确认 p50、p95、p99 能被完整记录。没有分位数监控后面的优化全是盲调。9.2 一次只改一个变量同时调整超时、队列、batch 大小和前缀缓存很难判断哪个改动真正起了作用。建议每次只改一个参数持续观察至少 30 分钟对比分位数变化。9.3 在线离线任务严格分离在线服务追求低延迟离线任务追求高吞吐两者资源模型完全不同。统一混跑会让在线服务的 tail latency 被离线任务严重干扰。9.4 预留资源余量不要让 GPU 显存接近 100%也不要让队列持续积压。建议将单实例利用率控制在 70% 到 80%超过阈值就触发扩容或限流。9.5 做好合规和数据安全如果在企业内部部署 LLM 服务要关注请求数据的隐私和合规问题。输入输出日志中可能包含敏感信息日志系统需要做脱敏和权限控制。涉及外部用户数据时必须确认是否具备合法授权处理和使用这些数据。尤其在 RAG 和 Agent 场景不要把未授权的数据引入模型上下文。9.6 建立容量评估机制每次上线新模型或升级推理框架都需要重新评估容量。模型参数量变大显存占用和 decode 延迟都会变化tail latency 特征也会跟着变。固定的超时和并发参数不能一直沿用。10. 总结与下一步LLM tail latency 是推理服务上线后最先暴露出来的性能问题之一。它不要求你重写模型也不要求你精通底层 CUDA 优化而是考验服务层的设计是否足够“平滑”。先量化分位数再逐个处理排队、prefill/decode 干扰、单请求长度和批处理配置大部分场景都能在配置层拿到明显改善。建议你从两件事开始第一确认监控面板能完整看到 p50/p95/p99 和排队耗时第二在请求入口加上分层超时和短作业优先队列。这两步基本不涉及框架改造但能解决掉相当一部分异常慢请求。下一步可以继续关注推理框架的连续批处理调度、前缀缓存命中率优化以及多实例负载均衡策略。如果业务对延迟极其敏感还可以研究更细粒度的 prefill/decode 分离调度方案但那已经属于推理引擎层面的深度优化了。先把简单修复做完再决定是否需要深入。