最近在调 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 秒内完成 | 整体体验基线 |
| p95 | 95% 请求在 5 秒内完成 | 大多数用户的真实体验 |
| p99 | 99% 请求在 8 秒内完成 | 用户可感知的异常延迟 |
| p999 | 99.9% 请求在 20 秒内完成 | 极端异常,通常伴随超时重试 |
除了延迟分位数,建议同时记录输入 token 数、输出 token 数、prefill 耗时、decode 耗时和排队耗时。把它们关联起来,才能判断慢请求是慢在排队、慢在 prefill,还是慢在 decode。
用一段 Python 代码可以模拟常见的分位数统计分析:
import time import random import statistics def collect_latencies(count=10000): 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(f"p{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 要并行计算大量 token,GPU 计算单元使用率高;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, json=payload) 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_when=asyncio.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, json=task) 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 分离调度方案,但那已经属于推理引擎层面的深度优化了。先把简单修复做完,再决定是否需要深入。