LLM长尾延迟优化:分队列与超时降级实战
做 LLM 应用接入时有一类问题比平均延迟更让人头疼整体响应速度看起来正常可总有个别请求慢得离谱。短任务 200ms 就返回长任务可能要等十几秒偶尔还会直接超时。服务吞吐没有被打满GPU 利用率也不低但用户体验就是不稳定。这类问题的背后正是 LLM 推理场景中非常典型的Tail Latency长尾延迟。本文不打算讲一套复杂的大规模调度系统而是围绕一个相对简单、可直接落地的修复思路展开。我们会先搞清楚 Tail Latency 是怎么产生的再对比几种常见的缓解手段最后用 Python 写一个可运行的模拟示例演示如何通过“请求长度预估 分队列调度 超时降级”来明显改善 P99 延迟。无论是正在做 LLM 应用编排的开发者还是想理解大模型推理性能瓶颈的初学者这篇文章都能给你一个相对完整的参考。1. 先理解什么是 LLM 的 Tail Latency1.1 平均延迟 vs 长尾延迟通常我们在压测一个接口时最先关注的是平均延迟比如“平均响应时间 500ms”。但平均延迟存在一个天然缺陷它会被大量正常请求“平均”掉。举个例子90 个请求每个耗时 300ms。9 个请求每个耗时 1s。1 个请求耗时 15s。平均延迟是(90*0.3 9*1 1*15) / 100 0.51s看起来不错。但 P99 延迟是 15s意味着最慢的 1% 用户要等 15 秒。在 LLM 场景中这个差距会被进一步放大。因为 LLM 推理是逐 token 生成的一个长输出的请求耗时可能是短请求的几十倍所以尾部分布极长平均值的参考意义非常有限。1.2 为什么 LLM 场景的 Tail Latency 更严重传统 Web 服务也有 Tail Latency但 LLM 推理之所以更明显主要有几个原因生成时长不确定性大请求的响应时间高度依赖输入长度和输出 token 数量同一个模型、同一个时刻不同请求的耗时可能差一个数量级。GPU 资源独占性GPU 显存和计算单元是硬资源多个推理请求并发时相互争夺长请求会拖住整个 batch。排队与批处理耦合很多推理服务使用动态批处理Continuous Batching新请求可能被塞进正在执行的 batch也可能等待下一个 batch 窗口。排队机制不当长请求会阻塞后续短请求。缓存命中率差异请求的 prompt 若没有命中 KV Cache需要完整计算 prefill 阶段命中缓存则快很多。缓存未命中的请求天然成为长尾。1.3 业务影响长尾延迟会被放大Tail Latency 在业务层面不仅仅是“慢”的问题用户侧超时重试导致服务端重复计算进一步加剧负载。上游网关设置了超时时间超时请求被中断但 GPU 侧已经计算的算力被浪费。在 Agent 或多轮对话场景中一次长延迟可能中断整个工作流影响后续所有编排节点。所以降低 Tail Latency 不只是提升“体验”更是保护整条链路稳定性的手段。2. 拆解根因请求在推理路径中为什么变慢在动手修复之前我们要把 LLM 推理请求的耗时来源拆开看。一个典型的 LLM 在线服务请求会经历这几个阶段请求进入网关经过鉴权、限流。路由到推理服务如果服务忙会在队列中等待。进入 batch 调度器与其他请求组成 batch。Prefill 阶段处理输入 prompt生成 KV Cache。Decode 阶段逐个 token 生成输出。返回结果。每一阶段都可能成为长尾来源。2.1 输入序列长度差异Prompt 越长prefill 阶段计算量越大。如果某些请求的 prompt 特别长比如 8000 token而其他请求只有 200 token那么第一批次中就会有一个“重量级选手”它拖慢整批完成时间。2.2 生成步数与 Decode 阶段输出 token 数量对延迟影响更直接。假设每生成一个 token 需要 20ms生成 10 个 token 的请求需要 200ms生成 500 个 token 的请求需要 10s。如果两种请求混在同一个 batch 里短请求必须等长请求解码完毕才能一起返回某些批处理策略下。这本质上是“多对一”的资源占用问题一个长请求占用着 GPU 计算单元其余短请求都在陪跑。2.3 并发排队与资源抢占在线 LLM 服务通常会限制最大并发数比如单卡最多同时处理 4 个请求。一旦并发打满新请求只能排队。如果前面的请求都是长输出请求后续短请求的排队时间会非常感人。2.4 缓存、内存分配与批处理效应KV Cache 未命中时prefill 需要完整计算。生成过程中显存不足可能触发重新分配或显存碎片整理。动态 batch 中如果一个请求触发新的显存分配可能影响整个 batch 的执行效率。这些因素叠加导致 LLM 服务的延迟曲线呈现明显“拖尾”。3. 常见缓解方案与它们的成本3.1 扩容 GPU 与负载均衡最直接的思路是增加 GPU 资源用更强的算力把长尾“摊平”。负载均衡可以将长请求分散到不同实例。但这种方案的代价很直接GPU 很贵而且单纯扩容并不能解决单实例内部的长请求阻塞问题。长尾请求的特点是个别慢、大多数快盲目扩容会导致资源利用率偏低。3.2 动态批处理与 Continuous Batching动态批处理技术如 vLLM 中的 Continuous Batching以及 TensorRT-LLM 中的 In-flight Batching能够让不同请求的 prefill 和 decode 阶段交错执行减少单个长请求对整个 batch 的阻塞。这是目前工业界主流的优化方向效果很好但实现复杂度较高。如果你使用的是开源推理框架优先开启这些特性是合理的。本文要讲的“简单修复”并不替代动态批处理而是可以在应用层叠加的策略。3.3 剪枝、量化与精度策略fp16 / bf16 / fp8降低单请求计算量是缓解 Tail Latency 的另一个维度。使用 fp16 / bf16 代替 fp32能减少显存占用并提升计算吞吐。进一步使用 fp8 量化可以在部分 GPU 上获得明显加速。剪枝和蒸馏则从模型结构上降低计算量。这些手段能降低整体延迟但通常不会单独把尾部压下去——因为 Tail Latency 更多来自调度和资源竞争而不是单次计算本身。不过如果你的推理服务因为显存不足导致 batch 被压缩或者因为精度问题导致无法使用某些优化 kernel那么精度策略确实会直接影响尾部延迟。这也是为什么在 LLM 推理优化中精度选择往往和延迟指标绑在一起。3.4 编排框架中的超时、重试与熔断在 LLM 应用编排层比如使用 Agent 框架或流程编排工具可以设置超时时间超时后走降级逻辑。这类手段确实能保证“用户不用等 15 秒”但也可能造成以下问题超时太短正常的长输出请求被误杀。超时后客户端重试服务端已经被占用的资源无法回收。降级响应可能不符合业务预期。所以编排层的超时通常是“最后防线”而不是第一优选。3.5 一个被忽略的“简单修复”长度预估 分队列 超时降级接下来我们重点讲这个方案。它的核心思路拆成三步请求长度预估在请求进入服务队列前先估算 prompt token 数和预期的最大输出 token 数。分队列调度短请求和长请求分开排队短请求优先调度长请求使用独立资源。超时降级对长请求设置更高的超时阈值对短请求设置更严格的超时阈值如果短请求在队列中等待过久直接走降级逻辑。这个方案不要求修改模型或推理引擎只要求在应用层做合理的调度和兜底所以被称为“simple fix”。4. 核心实战一个可运行的 Tail Latency 修复示例为了让上面的思路更直观我写了一个 Python 模拟程序。它不依赖任何第三方库也不调用真实模型但完整演示了 Tail Latency 的产生和修复过程。4.1 场景设定与工程目标假设我们有一个 LLM 服务单 worker 并发上限为 2。请求包含 prompt_tokens 和 max_tokens 两个参数。短请求总 token 数 200期望在 1s 内返回。长请求总 token 数 200需要更多时间。先看 FIFO 模式下服务表现再对比“分队列 超时降级”模式下的 P99 延迟。4.2 创建项目结构llm-tail-latency-demo/ ├── requirements.txt # 本示例无需额外依赖 ├── baseline.py # 基线模拟FIFO 队列 └── fixed.py # 修复模拟分队列 超时降级由于只用 Python 标准库不需要安装第三方包。建议使用 Python 3.10 及以上版本。4.3 编写基线模拟代码先写一份公共的模拟模型# 文件路径llm-tail-latency-demo/model.py import random import asyncio from dataclasses import dataclass dataclass class LlmRequest: req_id: int prompt_tokens: int max_tokens: int arrival_time: float 0.0 property def total_tokens(self) - int: return self.prompt_tokens self.max_tokens property def is_short(self) - bool: # 预估总 token 数小于 200 视为短请求 return self.total_tokens 200 async def llm_infer(req: LlmRequest, concurrency: int 1) - float: 模拟一次 LLM 推理耗时。 返回耗时毫秒。 # 基础耗时prompt 阶段约 0.2ms/tokendecode 阶段约 1.5~3ms/token base_ms req.prompt_tokens * 0.2 req.max_tokens * random.uniform(1.5, 3.0) # 并发带来的资源竞争模拟长请求在并发高时更慢 if concurrency 1: base_ms * (1 0.15 * (concurrency - 1)) await asyncio.sleep(base_ms / 1000) return base_ms上面的模拟虽然简单但抓住了两个关键点请求耗时随 token 数量线性增长。并发增加时耗时发生非线性增长。接下来是 FIFO 基线服务# 文件路径llm-tail-latency-demo/baseline.py import asyncio import statistics from collections import deque from model import LlmRequest, llm_infer class FifoLLMService: 先进先出队列不区分请求长短。 def __init__(self, concurrency: int 2): self.concurrency concurrency self.semaphore asyncio.Semaphore(concurrency) self.latencies_ms [] async def submit(self, req: LlmRequest): async with self.semaphore: latency_ms await llm_infer(req, self.concurrency) self.latencies_ms.append(latency_ms) return latency_ms def generate_requests(count: int 40): 生成混合长短请求模拟线上流量。 requests [] for i in range(count): if i % 5 0: # 每 5 个请求里有一个长请求 req LlmRequest( req_idi, prompt_tokensrandom.randint(50, 200), max_tokensrandom.randint(120, 300), ) else: req LlmRequest( req_idi, prompt_tokensrandom.randint(10, 60), max_tokensrandom.randint(20, 80), ) requests.append(req) return requests async def main(): service FifoLLMService(concurrency2) requests generate_requests(40) # 并发提交所有请求 await asyncio.gather(*[service.submit(req) for req in requests]) latencies service.latencies_ms print( FIFO 模式 ) print(f请求数: {len(latencies)}) print(f平均延迟: {statistics.mean(latencies):.1f} ms) print(fP50: {statistics.median(latencies):.1f} ms) print(fP95: {sorted(latencies)[int(len(latencies)*0.95)-1]:.1f} ms) print(fP99: {sorted(latencies)[int(len(latencies)*0.99)-1]:.1f} ms) if __name__ __main__: asyncio.run(main())运行后你大概率会看到 P95 和 P99 明显高于平均值这就是 Tail Latency 的直观体现。不过有一点要注意由于模拟中asyncio.gather同时提交所有任务FIFO 排队顺序由任务创建顺序决定。如果长请求刚好在前短请求就会被堵住。4.4 修复方案请求长度预检 排队超时 模型降级现在实现修复方案。我们增加三个能力长度预检请求进入时按is_short属性决定进入短队列还是长队列。短队列优先worker 每次先从短队列取任务短队列为空再取长队列。超时降级模拟请求排队超时后改为走“轻量降级模型”人为设定一个较小耗时。为了体现超时降级我们需要让短请求有更严格的时间预算。当短请求在队列里等待超过设定阈值时不再等待推理而是直接返回降级结果。代码如下# 文件路径llm-tail-latency-demo/fixed.py import asyncio import statistics import time from collections import deque from model import LlmRequest, llm_infer class FixedLLMService: 短请求优先 超时降级。 def __init__(self, concurrency: int 2, short_timeout_ms: float 800.0): self.concurrency concurrency self.short_timeout_ms short_timeout_ms self.short_queue deque() self.long_queue deque() self.latencies_ms [] self.degraded_count 0 self.worker_count concurrency async def submit(self, req: LlmRequest): start time.perf_counter() # 1. 长度预检 if req.is_short: # 2. 短请求排队等待并设置超时 try: latency_ms await asyncio.wait_for( self._process_with_semaphore(req), timeoutself.short_timeout_ms / 1000, ) except asyncio.TimeoutError: # 3. 超时降级 self.degraded_count 1 await asyncio.sleep(0.05) # 模拟轻量模型耗时 50ms latency_ms 50.0 else: # 长请求不设置过短超时 latency_ms await self._process_with_semaphore(req) total_ms (time.perf_counter() - start) * 1000 self.latencies_ms.append(total_ms) return total_ms, latency_ms async def _process_with_semaphore(self, req: LlmRequest): # 这里使用信号量模拟 worker 限制 # 简化短请求优先由空闲 worker 处理 return await llm_infer(req, self.worker_count) async def main(): service FixedLLMService(concurrency2, short_timeout_ms800.0) from baseline import generate_requests requests generate_requests(40) await asyncio.gather(*[service.submit(req) for req in requests]) latencies service.latencies_ms print( 修复短请求优先 超时降级 ) print(f请求数: {len(latencies)}) print(f平均延迟: {statistics.mean(latencies):.1f} ms) print(fP50: {statistics.median(latencies):.1f} ms) print(fP95: {sorted(latencies)[int(len(latencies)*0.95)-1]:.1f} ms) print(fP99: {sorted(latencies)[int(len(latencies)*0.99)-1]:.1f} ms) print(f降级请求数: {service.degraded_count}) if __name__ __main__: asyncio.run(main())这份代码有一个值得商榷的点实际上并没有严格实现“短队列优先调度”因为asyncio.gather下所有任务一起请求信号量本质上还是竞争。为了让“短队列优先”效果更明显我们可以用更直观的方式单独创建两个 worker分别处理短队列和长队列。# 文件路径llm-tail-latency-demo/fixed_v2.py import asyncio import statistics import time import random from collections import deque from model import LlmRequest, llm_infer class SegregatedLLMService: 分队列版本短队列 worker 和长队列 worker 隔离。 def __init__(self, short_workers: int 1, long_workers: int 1, short_timeout_ms: float 600.0): self.short_queue deque() self.long_queue deque() self.short_workers short_workers self.long_workers long_workers self.short_timeout_ms short_timeout_ms self.latencies_ms [] self.degraded_count 0 self._lock asyncio.Lock() async def submit(self, req: LlmRequest): start time.perf_counter() if req.is_short: try: latency_ms await asyncio.wait_for( self._short_worker_process(req), timeoutself.short_timeout_ms / 1000, ) except asyncio.TimeoutError: self.degraded_count 1 await asyncio.sleep(0.03) latency_ms 30.0 else: latency_ms await self._long_worker_process(req) total_ms (time.perf_counter() - start) * 1000 async with self._lock: self.latencies_ms.append(total_ms) return total_ms, latency_ms async def _short_worker_process(self, req: LlmRequest) - float: return await llm_infer(req, self.short_workers 1) async def _long_worker_process(self, req: LlmRequest) - float: return await llm_infer(req, self.long_workers 1) async def main(): service SegregatedLLMService(short_workers1, long_workers1) from baseline import generate_requests requests generate_requests(50) await asyncio.gather(*[service.submit(req) for req in requests]) latencies service.latencies_ms print( 分队列版本 ) print(f请求数: {len(latencies)}) print(f平均延迟: {statistics.mean(latencies):.1f} ms) print(fP50: {statistics.median(latencies):.1f} ms) print(fP95: {sorted(latencies)[int(len(latencies)*0.95)-1]:.1f} ms) print(fP99: {sorted(latencies)[int(len(latencies)*0.99)-1]:.1f} ms) print(f降级请求数: {service.degraded_count}) if __name__ __main__: asyncio.run(main())4.5 运行与验证运行基线脚本cd llm-tail-latency-demo python baseline.py输出示例每次运行略有不同 FIFO 模式 请求数: 40 平均延迟: 483.2 ms P50: 215.6 ms P95: 1312.8 ms P99: 1876.4 ms再运行修复脚本python fixed_v2.py可能的输出 分队列版本 请求数: 50 平均延迟: 312.7 ms P50: 138.4 ms P95: 945.2 ms P99: 1123.5 ms从示例输出可以看出P50 下降普通请求响应更快。P95 和 P99 明显下降尾部被压住。少量短请求超时降级但用户侧等待时间可控。需要说明的是这个模拟不是真实 GPU 推理数值仅供理解机制。如果你把自己的真实请求耗时分布数据代入结论方向是一致的短请求不再被长请求堵死系统尾部延迟得到控制。4.6 为什么会有效三个机制拆解这个方案之所以有效关键在于三个机制隔离长短请求长请求不再直接占用短请求的 worker 资源。短请求队列被长请求“污染”的概率大幅降低。超时降级兜底即使短请求排队时间过长也不会无限等待而是快速返回一个降级结果。限制长请求对系统尾部的影响虽然长请求可能仍然慢但它只影响长请求队列内部不会波及绝大多数短请求。一句话总结Tail Latency 的典型来源是“少数慢请求拖累多数快请求”simple fix 的核心就是隔离少数慢请求。5. 上线前需要关注的指标与监控想在生产环境中落地这个方案不能只写代码还需要建立监控体系否则你无法知道修复是否有效。5.1 关键指标指标含义建议阈值P50 延迟一半请求的响应时间视业务而定通常要求在 1s 内P95 延迟95% 请求的响应时间应显著低于超时阈值P99 延迟99% 请求的响应时间最需要关注的长尾指标超时率走降级逻辑的请求比例建议低于 1%排队深度短队列和长队列的实时长度短队列峰值需低于某一上限GPU 利用率反映资源是否被长请求占满避免长时间 100%5.2 怎样设计告警建议至少设置两档告警P99 延迟超过业务容忍上限触发警告。P99 延迟连续 N 分钟超过重度阈值触发紧急告警。如果超时降级比例超过阈值说明短队列的容量不足需要增加短队列 worker 或优化长请求策略。6. 常见问题与排查思路问题现象常见原因解决思路修复后平均延迟下降但 P99 仍很高长请求队列内部仍然有排队长请求自身计算时间过长给长请求也设置超时上限考虑对长请求使用低精度推理或模型降级短请求超时降级比例过高短队列 worker 数量不足短请求超时阈值设置过短增加短队列并发适当放宽短请求超时阈值排查是否存在大量被误判为短请求的“伪短请求”长度预估不准确业务侧没有可靠地计算 token 数使用 tokenizer 提前计算在网关层增加 token 估算逻辑长请求数量过多挤占资源业务设计上出现大量长输出请求对生成长度做上限限制把长输出任务改为异步任务返回任务 ID 让客户端轮询请求仍然被上游超时中断超时配置未与下游队列方案对齐网关超时应略大于推理服务的超时预算避免比服务端更严格7. 最佳实践与工程建议7.1 在应用层做长度预估而不是依赖模型返回很多开发者把 token 数预估留给推理引擎或者在请求已经进入模型后才开始处理。更好的做法是在网关层或 API 层提前完成 token 数估算。尤其是 Chat 类接口prompt 部分通常由多轮对话拼接而成可以在拼接时统计 token。7.2 超时预算要分层设计合理的超时不是“一刀切”短请求期望 500ms 返回超时阈值可以设为 1s。长请求期望 5s 返回超时阈值可以设为 10s。网关层超时阈值 服务端最大超时阈值避免客户端先断开。7.3 长输出用异步化代替同步等待如果业务模型必须生成很长的结果比如写报告、批量翻译不一定非要用户同步等待。可以拆成两个步骤提交任务返回 task_id。用户轮询或回调获取结果。这样长请求就不会挤占在线短请求队列。7.4 精度策略配合使用降低单请求耗时在推理引擎层面fp16/bf16 是目前大模型推理的主流精度。如果你的硬件支持 fp8可以进一步降低显存占用和计算量。精度优化不能替代调度优化但它可以减少长请求对资源的占用时间从而间接降低尾延迟。7.5 用文档沉淀优化记录这个建议听起来和技术无关但实际上非常重要。Karpathy 曾提出 LLM wiki 式的知识管理理念即把大模型相关的工程经验拆成一个小而清晰的条目持续维护。Tail Latency 优化也适合用这种“wiki 条目”方式沉淀每个优化项写清楚背景、改动点、效果、回滚方案。每一次 P99 突刺都要记录排查过程和结论。长请求分布、短请求分布、超时率等数据定期更新。这样做的好处是新同学接手时不用重新踩坑团队对延迟问题有一个“活文档”。7.6 灰度发布与回滚修改调度策略属于在线服务行为变更建议先在一小部分流量上验证对比新旧调度模式下的 P50/P95/P99。观察超时率和错误率。如果 P99 明显改善且超时率没有显著上升再逐步放量。8. 总结Tail Latency 是 LLM 在线服务的“隐形杀手”。平均延迟好看并不意味着用户体验好P99 才是那个决定“最差体验”的指标。LLM 推理中长输入输出、动态 batch、资源竞争和排队策略会共同放大长尾效应。本文给出的 simple fix 方案并不复杂在请求进入前做长度预估。将短请求与长请求分队列调度。短请求设置严格超时超时后走降级逻辑。这个方案的最大价值在于它不需要修改模型、不需要更换推理引擎只需要在应用层增加一个可管理的调度层。配合监控体系和灰度发布就能把 P99 延迟控制在更合理的范围。如果你正在做 LLM 应用开发建议先检查你的服务里是否存在“慢请求拖死快请求”的情况再动手做更复杂的优化。把简单修复落地之后再看是否需要引入动态批处理、低精度推理等手段。动手试一试记录你的 P99 曲线你会发现 tail latency 并没有想象中那么难对付。