ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LLM服务P99延迟优化:并发控制、动态批处理与超时重试实践

LLM服务P99延迟优化:并发控制、动态批处理与超时重试实践 LLM 线上服务的延迟指标里最让人头疼的往往不是平均耗时而是 p99 这个数字。一次突发的长输出、一个刚好排到慢批次前面的请求、一次显存分配抖动就会让尾部延迟从 2 秒跳到 10 秒甚至更久。很多团队的第一反应是加机器、升并发、扩大 batch结果反而让资源争用更严重尾部进一步抬高。这个问题的核心不是“算力不够快”而是“请求到达不均匀 共享资源竞争 缺少合适的排队与批处理策略”三者共同作用。这篇文章围绕“LLM 服务的尾部延迟”展开重点不讲复杂的大规模分布式改造而是介绍三类相对简单、可快速落地的修复手段限制并发请求、让推理引擎参与动态批处理、在客户端控制超时与重试。按这个顺序调整后通常会先看到 p99 回落、错误率稳定再进一步提高吞吐。适合正在维护在线 LLM 推理服务、已经能跑通基础接口、但被延迟毛刺困扰的开发者。1. 先理解 LLM 推理延迟从哪里来再谈怎么修1.1 一个请求从进入到返回经历了哪几段耗时LLM 在线服务的一次请求不是“模型算一次”就结束。它至少包含以下环节网络接入与排队等待请求到达网关或服务进程后如果没有空闲执行槽就会进入等待队列。预处理和 token 化把输入文本切成 token构造 prompt 序列。预填充阶段模型对整段输入做一次前向计算生成首个输出 token这个过程通常是一次大矩阵计算。解码阶段根据已生成的 token 逐个预测下一个 token每生成一个 token 都需要一次前向计算。从用户角度看完整延迟可以拆成两个部分TTFTTime To First Token从请求发出到收到第一个 token 的时间主要受排队、网络、预处理和预填充影响。TPOTTime Per Output Token生成后续每个 token 的平均耗时主要受解码阶段、批处理大小和显存带宽影响。如果输出长度为 N 个 token整体延迟大约是总延迟 ≈ TTFT (N - 1) × TPOT这个公式解释了为什么“平均延迟正常但 p99 很高”平均输出长度可能只有 200 token但 tail 上可能有用户请求生成了 2000 token平均排队时间可能是 50ms但某个时刻并发请求同时到达排队时间就变成 5 秒。尾部延迟的核心来源往往不是单次计算变慢了而是多个因素在最差情况下叠加。1.2 为什么 GPU 推理的尾部延迟比普通 API 更难压平普通 Web API 的尾部延迟通常来自数据库慢查询、网络抖动或垃圾回收停顿。LLM 推理服务除了这些还多了两个特殊变量。第一个是共享 GPU 显存。模型权重、KV Cache、中间激活都放在同一块显存里。一个请求的 KV Cache 会随输出变长而增长如果引擎来不及释放或分配后续请求就要等待显存整理。这种等待不是均匀的而是突发性的。第二个是批处理的“木桶效应”。GPU 推理通常会把多个请求拼成一个 batch 来提升利用率。batch 里只要有一个请求生成了特别长的序列整个 batch 的前向计算就要继续执行直到最慢的那个请求完成或达到 max_tokens。这样一个慢请求会通过共享 batch 把延迟传染给同批次的快请求。这就是典型的“延迟传染”。因此压 LLM 尾部延迟不能只看模型速度。排队策略、批处理粒度、显存预留、请求超时时间每一点都可能成为尾部毛刺的源头。2. 动手改代码前先把延迟观测清单建出来2.1 至少记录四个指标而不是只看接口平均耗时很多人排障时只盯着服务端返回的平均延迟或者只看客户端整体耗时这会导致判断方向错误。推荐至少记录如下指标指标含义优化前后主要观察什么端到端延迟客户端发起请求到完整返回的耗时p50、p95、p99 的整体变化TTFT首个 token 返回耗时排队是否严重、预填充是否成为瓶颈TPOT / ITL每个输出 token 的耗时解码阶段是否因 batch 增大而变慢错误率与排队拒绝率HTTP 429、超时、连接异常比例修复是否以牺牲成功率换延迟如果服务实现了流式输出还应该单独记录 TTFT 和 TPOT因为两者针对的优化手段完全不同。TTFT 高通常是排队和预填充问题TPOT 高通常是显存带宽和 batch size 问题。2.2 单请求级日志是低成本高收益的观测方式在自建服务里为每个请求记录阶段时间戳并不复杂。只需要在关键位置埋点import time async def handle_request(prompt: str, max_tokens: int): t0 time.perf_counter() # 排队前 t1 time.perf_counter() # 排队结束开始预处理 # 预填充 t2 time.perf_counter() # 首个 token 生成 t3 time.perf_counter() # 最后一个 token 生成 t4 time.perf_counter() return { queue_ms: (t1 - t0) * 1000, prefill_ms: (t2 - t1) * 1000, decode_ms: (t4 - t2) * 1000, prompt_tokens: ..., completion_tokens: ..., }日志输出到 JSON 后用 jq 或 Python 分析 p99。重点不是精确到微秒而是能判断尾部的段位在哪。如果 p99 的排队时间占了 80%说明问题在调度层如果排队不多但解码时间跨度极大说明批处理不均匀。2.3 准备好能复现问题的试验环境在本地或测试机上准备如下环境项目建议推理引擎vLLM、TGI、TensorRT-LLM 之一如果你是自己部署 Transformers也尽量先跑通流式接口模型选择你线上常用的模型不要为了压测换成小模型压测机与 GPU 机分离避免压测客户端本身和推理服务抢资源请求样本至少准备 200 到 500 个真实感较强的 prompt输出长度差异要大如果条件有限最小环境可以是一台带 GPU 的开发机本地脚本并发调用自己的推理服务。重点是所有改动都在同一份负载、同一模型、同一迭代次数下做对比。3. 修复一限制并发让请求排队而不是无限叠加上去3.1 无限并发为什么会制造尾部延迟直观感觉是“来的请求越多利用率越高”但对 GPU 推理服务来说无限并发通常意味着所有请求同时争抢显存和计算资源。引擎被迫把请求切成更小的 batch 来回切换显存分配失败时还要等待释放。最终结果是每个请求都变慢慢请求越来越多p99 被推高。限制并发的目标不是降低吞吐而是把请求进入 GPU 引擎的速度控制在“引擎能平滑处理的速率”。超出部分先在内存队列中等待或者直接返回 429让客户端退避重试。这样可以把随机突发转化为有界排队。3.2 在 FastAPI 服务里用信号量和队列控制并发最常见的是用一个固定并发数限制同时进入推理引擎的请求数。示例中使用asyncio.Semaphore实现import asyncio from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI() CONCURRENCY 4 sem asyncio.Semaphore(CONCURRENCY) class GenerateRequest(BaseModel): prompt: str max_tokens: int 256 app.post(/generate) async def generate(req: GenerateRequest): if sem.locked(): # 直接拒绝让客户端重试 raise HTTPException(status_code429, detailtoo many requests) async with sem: return await generate_inner(req)这种方式适合对外 API。如果希望请求进入内存队列而不是直接拒绝可以使用asyncio.Queue配合工作协程import asyncio from fastapi import FastAPI, HTTPException app FastAPI() queue asyncio.Queue(maxsize32) CONCURRENCY 4 async def worker(): while True: item await queue.get() try: await item[callable]() except Exception: pass finally: queue.task_done() async def submit_to_engine(prompt: str): loop asyncio.get_running_loop() fut loop.create_future() async def callable(): try: result await call_llm(prompt) fut.set_result(result) except Exception as e: fut.set_exception(e) try: queue.put_nowait({callable: callable}) except asyncio.QueueFull: raise HTTPException(status_code429, detailqueue full) return await fut app.on_event(startup) async def startup(): for _ in range(CONCURRENCY): asyncio.create_task(worker())这段代码的关键点在于排队发生在内存里实际进入推理引擎的请求最多只有CONCURRENCY个。队列长度需要根据慢请求耗时和超时时间来计算否则队列太长时请求虽然没被拒绝却会因排队太久而在客户端超时。3.3 参数怎么调并发数、队列长度和超时时间参数含义调小影响调大影响CONCURRENCY同时进入引擎的最大请求数p99 下降但吞吐可能下降过低会浪费 GPU利用率高但容易互相拖慢尾部重新抬高队列长度内存中等待的请求数请求容易被拒绝客户端重试压力大等待时间变长可能客户看不出 429但持续排队请求超时客户端等待总时长错误率上升用户体验变差服务端资源被慢请求长期占用推荐的起步值是先观察当前 GPU 利用率。如果利用率长期低于 50%可以适当调大并发数或 batch size如果利用率已经接近 90% 但 TTFT 很高说明处理速度已经饱和调大并发没意义。注意限制并发不是越少越好。并发太低会导致 GPU 空闲吞吐下降请求一样会因为排队而出现新的尾部。限制的目的是让每次进入引擎的 batch 尽量稳定而不是把 GPU 饿死。4. 修复二让引擎参与动态批处理而不是每个请求独立计算4.1 静态拼接和连续批处理的区别很多自封装服务只做“批量接口”前端收集多个请求后一次性拼成一个 batch再喂给模型。这种方式简单但存在两个问题一是前端必须等够请求才能发车增加等待时间二是 batch 中只要有一个请求因长输出而变慢整个批次都会等待。vLLM、TGI 等推理框架目前普遍使用连续批处理continuous batching和 PagedAttention 这类技术。连续批处理允许引擎在解码过程中动态加入新请求、完成后的请求及时退出不需要等整个 batch 一起结束。效果是短请求可以很快离开长请求不会拖累新请求。如果你的服务已经使用这些引擎修复重点不是自己实现批处理而是调好引擎参数。4.2 用 vLLM 示例说明参数怎么调vLLM 启动参数中常见影响尾部延迟的有python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --max-num-seqs 8 \ --gpu-memory-utilization 0.9 \ --max-model-len 4096参数含义参考参数作用建议max-num-seqs同时参与调度的最大序列数受限显存通常 4 到 32越大吞吐越大但单个请求延迟也可能变长gpu-memory-utilization预留显存比例0.8 到 0.9太大容易 OOM太小影响可处理的 KV Cachemax-model-len模型最大序列长度决定 KV Cache 分配上限设得过大浪费显存enable-prefix-caching是否启用公共前缀缓存如果线上请求共享 prompt 前缀收益明显这里的核心原则是不要一味调大max-num-seqs。批量过大时单次前向计算虽然利用率高但每个 token 的响应时间都会被拉长。如果 p99 TTFT 已经很高先调小max-num-seqs观察首字延迟是否回落。如果你使用的是 OpenAI 兼容接口需要注意引擎返回的 usage 字段不变但延迟分布很容易受 batch 影响。调参后必须重新压测。4.3 自研服务里可以实现的微批处理思路如果当前服务是基于 Transformers 自行开发的不想引入太重框架可以先实现一个简单的微批处理层把短时间内到达的请求收集起来达到最大 batch 或等待窗口后再统一送入模型。示例思路如下import asyncio class MicroBatcher: def __init__(self, max_batch4, window_ms30, engineNone): self.max_batch max_batch self.window window_ms / 1000.0 self.engine engine self.pending [] async def submit(self, prompt: str): fut asyncio.get_running_loop().create_future() self.pending.append((prompt, fut)) return await fut async def run(self): while True: if not self.pending: await asyncio.sleep(0.001) continue if len(self.pending) self.max_batch: await asyncio.sleep(self.window) batch self.pending[: self.max_batch] self.pending self.pending[self.max_batch:] prompts [item[0] for item in batch] results await self.engine.generate(prompts) for (_, fut), result in zip(batch, results): fut.set_result(result)这段代码是否真能改善延迟取决于底层模型是否真的支持批量推理。如果engine.generate内部还是循环处理每个 prompt那批处理只会增加等待不会带来加速。因此使用微批处理前要先确认引擎前向真的支持 batch 维度。注意连续批处理和动态批处理带来的收益不是“永远降低延迟”而是让资源分配更平均。长请求和短请求混在一起时短请求的尾部延迟会显著下降但如果所有请求都是长输出批处理只能压缩吞吐无法缩短单条延迟。5. 修复三客户端超时、重试和优先级策略5.1 超时参数真实影响在线体验很多服务的尾部和客户端的关系比想象中更大。假设服务端 p99 是 8 秒客户端超时设置为 10 秒那么最慢的 1% 请求虽然最终成功但用户体验接近于失败。如果把超时改成 5 秒错误率升高但快速失败可以让用户立刻重试或降级整体感知反而更稳定。在 OpenAI SDK 兼容场景中常见设置如下from openai import OpenAI client OpenAI( timeout60.0, max_retries2, )这里的timeout控制整个请求的等待时间max_retries控制失败后的重试次数。不要认为“超时越大越好”。超时太大客户端资源被慢请求占用超时太小正常的慢请求也被误杀。衡量标准是同时观察错误率和 p99 延迟。5.2 重试风暴会让尾部延迟更糟常见错误是客户端发现慢请求后立即重试多个客户端同时重试服务端排队瞬间爆炸。正确做法是重试时加入指数退避并且加上随机抖动避免多个请求在同一时刻重新进入服务端。import random import time MAX_RETRIES 3 BASE_DELAY 0.5 def retry_with_backoff(func): for attempt in range(MAX_RETRIES 1): try: return func() except Exception: if attempt MAX_RETRIES: raise sleep_time min(BASE_DELAY * (2 ** attempt) random.uniform(0, 0.3), 8.0) time.sleep(sleep_time)这段代码的关键是每个重试间隔都不一样不能所有客户端在同一秒重试。尤其当服务端返回 429 或连接超时时重试策略必须是谨慎的。5.3 在线小请求和离线批任务要区分优先级很多公司内部同时跑着“分析类批量任务”和“在线对话请求”。如果共用同一个推理服务批量任务会把在线请求的排队时间和 batch 资源挤占掉。比较简单的修复是在 API 网关层做优先级分流在线对话请求走高优先级队列限制并发较小保证 TTFT。离线批任务走低优先级队列超时更长允许排队。如果单个服务实例无法隔离优先级至少要在负载均衡层把两类请求分发到不同副本组。这比在推理引擎内部实现复杂的优先级调度要简单得多效果也更可控。6. 验证修复效果用同一份压测脚本对比 P996.1 最小压测脚本只依赖 aiohttp不需要完整压测平台一个最小脚本就能看出趋势。示例脚本并发发送 200 个请求计算延迟分布import asyncio import aiohttp import statistics CONCURRENCY 10 TOTAL_REQUESTS 200 URL http://127.0.0.1:8000/generate async def one(session): payload {prompt: 请用三句话解释一下什么是数据库索引。, max_tokens: 200} t0 asyncio.get_event_loop().time() async with session.post(URL, jsonpayload) as resp: await resp.json() return (asyncio.get_event_loop().time() - t0) * 1000 async def main(): async with aiohttp.ClientSession() as session: tasks [one(session) for _ in range(TOTAL_REQUESTS)] times await asyncio.gather(*tasks) times.sort() p50 times[int(len(times) * 0.50)] p95 times[int(len(times) * 0.95)] p99 times[int(len(times) * 0.99)] print(fp50{p50:.0f}ms p95{p95:.0f}ms p99{p99:.0f}ms avg{statistics.mean(times):.0f}ms) return {p50: p50, p95: p95, p99: p99} if __name__ __main__: asyncio.run(main())该脚本只适合最基础的对比。要得到更严谨的数据建议使用 wrk、GHZ 或内部压测工具并严格保证以下条件一致模型和引擎参数不变并发请求数不变请求内容和输出长度分布不变硬件环境不变测试迭代轮数不低于 3 轮取中位数或最小值对比6.2 验证报告中主要看三组变化观察项修复前修复后判断p50/p95/p99 延迟p99 明显高于 p95 数倍p99 和 p95 差距缩小尾部被压住错误率超时或 500 比例高429 变成可控比例成功请求超时下降以错误率换延迟稳定GPU 利用率起伏大空闲和打满交替利用率平滑空闲期减少资源利用更均匀如果只看到 p50 下降而 p99 仍然很高通常是排队策略还没生效或者压测时仍存在超长输出请求占据了大量时间。此时应该继续查看 TTFT而不是盲目加机器。7. 常见坑和排查链路7.1 常见问题现象、原因、检查方式和处理建议问题现象常见原因检查方式处理建议限制并发后 p99 反而更高队列太长请求虽然没被拒绝但排队时间变长查看请求日志中 queue_ms 占比缩短队列长度直接返回 429 而非无限排队调大 batch 后出现 OOMKV Cache 和激活值超出显存查看 nvidia-smi 显存使用峰值查看引擎日志 OOM 提示降低 gpu-memory-utilization 或 max-num-seqsTTFT 正常但整体延迟很高单个请求输出太长或 batch 被长请求拖住统计输出长度 p90查看 TPOT对 max_tokens 设上限把超长生成任务分流到离线服务客户端超时错误增多服务端排队时间超过客户端等待窗口计算“排队时间 预估生成时间”是否大于超时时间调大客户端超时或降低服务端并发重试导致服务端负载翻倍客户端退避策略太短没有随机抖动查看服务端每秒请求量在故障时段是否急剧上升实现指数退避 jitter7.2 排查链路从尾部日志逐段倒推当出现一个高延迟请求时按这个顺序定位确认请求日志里的 queue_ms。如果 queue_ms 高问题在并发控制或排队策略。确认 TTFT。如果 TTFT 高但 queue_ms 低问题在预填充或显存/CUDA 环境。确认 TPOT。如果 TPOT 在 batch 增大后明显上升问题在解码阶段或 KV Cache 带宽。确认是否被同批次的长请求拖住。查看批处理日志里该请求所在 batch 的最慢完成时间。确认客户端重试次数。如果重试次数多还要看重试是否放大了排队压力。不要一开始就去调 prompt 缓存或量化。先把延迟拆成“排队、首字、逐字、网络”几段找到占比最大的段落再决定优化手段。7.3 测试样本不真实也是一个常见坑压测时如果只用同一段短 prompt且 max_tokens 固定得到的结果会非常稳定。但线上用户 input 长度差异大、输出长度随机、并发到达时间不均匀。建议压测样本至少覆盖短 prompt50 token 以内中等 prompt200 到 500 token长 prompt1000 token 以上短输出10 到 50 token长输出500 到 1000 token只有样本覆盖这些情况p99 才有参考价值。8. 生产环境落地与进一步优化方向8.1 从局部修复到完整反馈链路上述三类修复本质上是把“无限争抢”改成“有界排队 稳定批处理 有策略的超时重试”。在生产环境还需要把这些措施连成一套反馈链路网关层记录每个请求的开始时间、排队时间、结果状态。推理引擎暴露指标至少包括当前排队数、正在处理的请求数、平均 batch size、显存使用率。客户端把错误率和 p99 延迟回传监控系统。当排队数持续超过阈值时网关自动扩容或返回 503/429而不是让请求无限堆积。如果只做了代码限制但没有监控配合后面很难判断参数是否合理。建议至少把延迟百分位、错误码、并发数三个指标接入告警。8.2 什么时候这些调整不够了当遇到以下情况时局部参数调整可能已经无法满足目标p99 已经稳定但绝对延迟仍然超过业务容忍线例如对话任务要求首字 500ms 内。单请求最大输出长度过大一个长请求就能拖跨整个 batch。线上请求量波动巨大高峰时段并发是低峰期的几十倍。此时需要从架构层面拆解把超长输出任务单独分配资源为在线服务准备独立 GPU 副本对 prompt 做前缀缓存甚至考虑小模型加降级链路。尾部延迟优化是一个持续性工作修复完一批问题后要用同样方法再跑一轮压测看新的瓶颈出现在哪一段。8.3 给新手的一个可执行建议不要一开始就上大改。先在一台测试机上复现尾部延迟记录日志然后按“限制并发 - 调整批处理参数 - 客户端超时重试”的顺序逐步改动。每改一步跑一轮压测记录 p50、p95、p99 和错误率。多数情况下限制并发和调整超时就足以让 p99 大幅回落。批处理参数优化需要和引擎框架深度结合适合在第一步完成后继续做。LLM 服务调优的难点不在“让模型更快”而在“让资源分配更公平”。尾部延迟是一面镜子照出的是整个请求链路中排队、批处理和重试策略最脆弱的部分。把这三处用简单方法理顺通常就能用最少的成本换取最明显的线上体验改善。
返回列表