ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

LLM推理服务尾延迟治理:从超时、重试到排队控制的实战指南

LLM推理服务尾延迟治理:从超时、重试到排队控制的实战指南 这次聊一个生产环境会很快暴露的问题LLM 推理服务的平均延迟很好看P99 却会突然起飞紧接着就是接口超时、用户重试、服务雪崩。很多团队把 LLM 接口接进业务后第一个关注的是“平均响应多快”真正上线后才会被上一课平均延迟不高但总有一部分请求比平均慢好几倍。这些慢请求就是尾延迟。它不一定是模型变笨了更多时候是排队、批处理、调度、共享资源共同作用后的结果。从分布式系统角度看尾延迟是经典问题。LLM 推理场景又给它加了几个放大器解码阶段必须逐 token 生成单个长请求会占住推理资源动态批次里的长短请求还会互相干扰。所以“换更强的显卡”不一定能解决问题真正的修法往往在模型执行层、调度层和客户端这一整条链路上。这篇文章给出一个可以照着落地的“简单修复”思路先量化尾延迟再在客户端与网关之间做三层控制——按业务 SLO 设置超时、对关键请求引入 hedge requests竞速请求先到先得、在服务入口控制排队深度并做容量隔离。如果这些还不够再往推理框架的执行层推进。全程会给出可改的代码和验证流程不涉及改模型权重也不需要重训模型。1. 这篇要解决的问题与核心结论速览先给结论后面再拆步骤。维度说明讨论对象LLM 推理服务的尾延迟重点看 p95 / p99 表现典型症状平均延迟正常少量请求高达均值数倍超时集中在高负载时段简单修复客户端/网关超时 hedge requests 服务端排队与容量控制进阶修复动态批处理、chunked prefill、prefill/decode 分离、推测解码等执行层优化涉及环节客户端 SDK、网关、推理服务、GPU 资源、可观测指标依赖环境Python 3.9、异步 HTTP 客户端、推理服务日志或指标系统验证方式压测前后对比 p50 / p95 / p99、成功率、超时率和排队长度适合读者负责 LLM 接口稳定性、推理服务性能优化、分布式系统设计的研发这里把“简单”二字说清楚它不是“调一个参数就彻底解决所有人尾延迟”的魔法而是先处理成本最低、见效最快的那几层。做分布式系统的人都熟悉一句话不能让一个慢副本或一个慢请求拖垮整体 SLA。LLM 服务也一样。2. LLM 尾延迟是怎么产生的要理解修复方案得先看清 LLM 推理和普通 HTTP 服务的差别。普通服务处理一个请求通常是一个有限的计算过程读取参数、查询数据、返回结果。耗时大概率是稳定的尾延迟主要来自排队和机器抖动。LLM 推理请求不是这样。一个生成请求内部有两个明显阶段Prefill 阶段处理完整 prompt计算量很大但可以并行计算。Decode 阶段逐个生成 token当前 token 依赖前一个 token天然串行。Decode 阶段对显存带宽很敏感而且生成多少个 token 完全不确定。同样是“写一段总结”有的请求生成 100 个 token有的生成 1000 个 token。输出长度方差一大请求的占用时间方差就大尾延迟自然出现。再叠加一个因素推理服务为了提升吞吐通常会把多个请求动态打包到同一个 GPU 上执行。一个 batch 里如果混入一个超长请求它占用的 compute 和 KV cache 就会拖住其他请求。即使框架做了 continuous batching让短请求可以提前退出长请求仍然会在某一段时间内独占或挤压资源。所以 LLM 服务的尾延迟至少由四段组成请求在队列里的等待时间。Prefill 阶段的计算时间。Decode 阶段的逐 token 生成时间。网络传输、反序列化、框架调度等额外开销。只看总延迟很难定位问题。后面所有修复手段都要先能拆出这几段。3. 从六个来源排查尾延迟3.1 排队效应和队头阻塞推理服务通常有一个线程池或任务队列。服务一忙新请求先排队。队列越深等待越久。如果队首是一个超长请求后面的短请求全部被压住就会出现“前面那个任务不走后面全堵住”的队头阻塞。排查时不要只看 GPU 是否打满。队列长度高但 GPU 利用率不饱和往往说明瓶颈在调度而不是算力。3.2 动态批次里的长短请求互相干扰现在主流推理框架都支持动态批处理但长度差异的影响仍然存在。长请求的 decode 会持续占用显存带宽和计算资源。一个 2048 token 的请求和一个 64 token 的请求混在同一个 batch 中框架要等前者逐步完成资源空转在短请求上会很明显。3.3 Prefill 和 Decode 混跑很多推理框架默认把 prefill 和 decode 放在同一批里执行。prefill 是计算密集型decode 是访存密集型。两者混在一起会产生资源互踩prefill 抢计算decode 抢显存带宽。如果调度策略不好个别请求的首 token 延迟就会被 prefill 顶上去。3.4 请求长度本身就是长尾分布用户输入长度极不均匀。有人发一句话有人粘贴一整篇文档。Prompt 越长prefill 时间越长输出 max_tokens 设置越大单请求占住资源的时间越长。这个变量很容易被忽略但它常常是 p99 突然飙高的原因。3.5 硬件异构和资源争抢多副本部署时不同机器的 GPU 型号、驱动、邻居负载不一定相同。共享节点上如果还有别的任务在抢 CPU 或内存带宽某些副本就是会比别人慢。客户端每次路由到慢副本的概率虽然不高但 p99 恰恰就是这部分请求。3.6 框架调度和日志链路开销Python 推理服务在高并发下会有 GIL 争抢、锁竞争、序列化开销。日志如果同步写磁盘也可能成为延迟黑洞。这类问题不是模型造成的但会直接体现在总延迟上。4. 修复前先做好观测指标和日志要能拆阶段没有指标就动优化等于闭眼开车。建议在接入任何修复手段之前先为每个推理请求记录结构化日志至少包含这些字段字段含义request_id请求唯一 ID用于链路追踪start_ts请求到达网关/服务的时间queue_start_ts进入推理队列的时间prefill_start_tsprefill 开始时间first_token_ts首 token 返回时间finish_ts请求完成时间prompt_tokens输入 token 数completion_tokens输出 token 数status成功、超时、失败、被限流有了这些字段至少能算三个关键指标TTFTtime to first token从请求发出到第一个 token 返回的时间主要反映排队和 prefill。TPOTtime per output token/ decode 速度反映 decode 阶段是否稳定。端到端总延迟影响用户整体体验。采集示例如下import time import uuid def make_record(): return { request_id: str(uuid.uuid4()), start_ts: time.perf_counter(), } def mark(record, key): record[key] time.perf_counter() def save_record(record): # 生产环境建议写入 Prometheus / ClickHouse / Loki不要同步写本地磁盘 print(record)日志字段先不追求全但 request_id 和 finish_ts 必须有。没有 request_id后面所有耗时分析都无法串联。GPU 资源观察可以在压测时开一个终端watch -n 1 nvidia-smi如果部署在 Kubernetes 里可以看节点维度的资源占用kubectl top node kubectl top pod -l appllm-server这里的重点是“先量化再修复”。你接下来做的每一个改动都要回到 p50、p95、p99 上去验证。5. 简单修复一给客户端和网关加上超时第一个修复不需要动推理服务先把用户侧的等待上限卡住。很多服务被拖垮是因为客户端没有超时请求一直挂在远端不放。一个请求在服务端排队 30 秒客户端也等 30 秒这一个慢请求就会占住一个连接、一个线程池任务、一个协程。并发一高整个服务连接池被占满所有请求都开始排队。所以第一步是按业务 SLO 设置请求超时。如果业务要求 5 秒内返回就不要等服务端自己把任务跑完。超过预算就立即降级或返回超时错误。下面是一个异步客户端超时示例import httpx async def call_llm( client: httpx.AsyncClient, payload: dict, timeout_seconds: float 5.0, ): try: response await client.post( http://127.0.0.1:8000/v1/completions, jsonpayload, timeouttimeout_seconds, ) return response.status_code, response.json() except httpx.ReadTimeout: # 超时后走降级逻辑返回缓存、降级文案或标记失败 return 504, {error: llm request timed out}这里有一个常见误区客户端超时只是不让调用方继续等待不代表服务端请求被取消。很多推理框架并不会因为客户端断开连接就主动停止生成任务仍然在 GPU 上跑资源照样被占住。所以超时要配合服务端取消能力。网关如果支持把客户端的取消信号传递到推理服务就用不支持的话超时只解决“调用方体验”不解决“服务端负载”。必要时还需要在推理服务侧加请求级取消或最大排队时间避免请求进来后无限等待。建议超时设置要有梯度读缓存或降级接口给较小超时。LLM 生成接口按历史 p95 再加一点余量。不要给所有接口同一个超时时间。超时本身不能降尾延迟但它能把尾延迟对业务的影响限制在可控范围内。6. 简单修复二Hedged Requests让请求竞速如果你已经排除了排队问题但个别请求就是会因为机器抖动、网络抖动而特别慢可以考虑第二个手段hedged requests。这个思路来自大规模分布式系统中的经典实践不要只发一个请求死等而是在等待超过某个阈值后向另一个副本发起同样的请求哪个先返回就用哪个。放到 LLM 场景使用方式不是每次请求都双发那样成本会翻倍。更合理的是先向首选副本发请求。请求超过一个阈值后仍未返回默认选择从历史 p95 或 SLO 预算推导出该阈值。再向另一副本发起同样请求。先返回的成功结果胜出取消其他未完成请求。代码示意如下import asyncio import httpx async def call_one(url: str, payload: dict) - dict: async with httpx.AsyncClient(timeout10.0) as client: response await client.post(url, jsonpayload) response.raise_for_status() return response.json() async def call_with_hedge( url_a: str, url_b: str, payload: dict, start_hedge_after: float 0.3, ): task_a asyncio.create_task(call_one(url_a, payload)) # 先给首选副本一点时间 done, _ await asyncio.wait({task_a}, timeoutstart_hedge_after) if task_a in done: # 首选副本已返回 return task_a.result() # 未返回则向备用副本发起第二个请求 task_b asyncio.create_task(call_one(url_b, payload)) done, pending await asyncio.wait( {task_a, task_b}, return_whenasyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() for task in done: try: return task.result() except Exception: continue raise RuntimeError(all hedge requests failed)这段代码是演示用生产环境还要处理几个细节两个请求最好打到不同副本否则无法解决副本级故障。LLM 请求不是天然幂等的同一个请求重复执行会增加 token 消耗。建议只对“只读类、关键链路、用户可容忍一定成本”的请求开 hedge。如果数据有合规边界不要把数据发到不同域名的外部服务。合理做法是在同一套内部集群的不同副本之间做竞速避免扩大数据暴露范围。必须设置整体预算。不能让 hedge 请求无限等下去要在 SLO 内截断。这个方案的优点是逻辑简单不需要改推理服务内部实现缺点是会带来额外的 token 成本和算力消耗。所以它适合作为“兜底手段”而不是默认策略。7. 简单修复三服务端排队深度与优先级隔离客户端超时和 hedge requests 只是在入口做拦截服务端自身的排队策略如果没控制好请求还是会大量堆积。一个典型的坏现象是推理服务已经处理不过来了网关仍然把请求全部塞进来。任务队列无界增长每个请求都等到超时才走最后服务彻底不可用。正确做法是“有界队列 快速失败”。当服务处于过载状态时不再让新请求进入队列而是直接返回 429 或 503让上游去重试或降级。对 LLM 推理这种高成本服务来说快速失败比重试等待更健康。一个简单的容量门控可以用信号量实现import asyncio class CapacityGate: def __init__(self, max_inflight: int, max_waiting: int): self._semaphore asyncio.Semaphore(max_inflight) self._max_waiting max_waiting self._waiting 0 async def acquire(self, wait_timeout: float 1.0): if self._waiting self._max_waiting: raise OverloadedError(queue is full, please retry later) self._waiting 1 try: await asyncio.wait_for(self._semaphore.acquire(), timeoutwait_timeout) except asyncio.TimeoutError: raise OverloadedError(wait queue timeout) from None finally: self._waiting - 1 def release(self): self._semaphore.release()在业务服务里可以这样使用gate CapacityGate(max_inflight16, max_waiting64) async def run_inference(payload): await gate.acquire() try: # 这里调用真正的 LLM 推理服务 return await inference_client.generate(payload) finally: gate.release()这个方案的实质是宁可让请求快速失败也不要让请求在队列里堆积成山。配合客户端超时效果会非常明显因为服务端不再消耗资源处理那些注定要超时的请求。除了容量控制还要考虑多租户场景下的优先级隔离。线上实时请求和离线批量任务如果共用同一个服务应该分队列或分实例。批量任务可以容忍慢线上请求不能。常见做法是线上实时请求高优先级队列小 batch严格 SLO。离线批任务低优先级大 batch可以排队等待。如果两者必须混跑至少要做优先级抢占或权重调度。这里的核心是“隔离”。不隔离的后果是离线任务一跑线上 P99 立刻变差。8. 再进一步执行层优化前几层修复做完客户端不会无限等服务端队列也不会无界增长。但如果 GPU 执行本身低效那么 p99 可能还是偏高只是用户感知到的超时变少了。执行层的问题通常要从推理框架入手。以下方向都需要根据实际负载验证而不是一概而论8.1 动态批处理与 continuous batching让推理框架在 batch 内动态加入新请求、动态退出已完成请求而不是等整个 batch 结束。这个能力能明显提高吞吐但如果框架调度粒度粗长请求仍然会拖慢同 batch 的其他请求。主流框架一般都实现了类似机制具体开关和参数以你所用的框架版本为准。8.2 Chunked Prefill把 prefill 阶段拆成小 chunk避免一个超长 prompt 的 prefill 长时间阻塞其他请求的 decode。适合 prompt 长度差异大的场景。启用后首 token 延迟会改善但调度开销可能上升需要压测确认。8.3 Prefill 与 Decode 分离部署把 prefill 阶段和 decode 阶段安排到不同实例上因为两者计算特征不同分离后可以各自调优。架构更复杂适合大流量、长上下文场景。如果只是一个内部小服务没有必要一开始就上。8.4 推测解码用小模型/草稿模型推测多个 token再由大模型验证能减少 decode 步数对延迟和吞吐都有帮助。但依赖模型类型、batch 大小和硬件是否有效需要实测。8.5 KV Cache 和显存管理KV cache 会随并发和上下文长度快速增长。显存碎片、KV cache 淘汰策略、长请求显存占用过高都会影响调度。如果经常出现显存不足或反复重新调度优先检查 KV cache 管理。这些执行层优化没有一个能保证在所有场景下都有效。正确流程是先做前面三层简单修复再通过压测看瓶颈是否还在 GPU 执行阶段。如果 GPU 平均利用率已经很高p99 仍然高才值得继续深入执行层。9. 验证闭环压测与指标对比做任何修复都要用同一套压测脚本、同一份数据、同一批并发模型去对比修复前后。否则很容易把环境差异误判成优化效果。下面是一个简单的异步压测脚本框架用来采集各个请求的延迟import asyncio import aiohttp import time ENDPOINT http://127.0.0.1:8000/v1/completions PAYLOAD { prompt: 用三句话总结这篇文章, max_tokens: 128, temperature: 0.7, } CONCURRENCY 50 TOTAL_REQUESTS 500 def percentile(sorted_data, p): if not sorted_data: return 0.0 k (len(sorted_data) - 1) * (p / 100.0) f int(k) c min(f 1, len(sorted_data) - 1) return sorted_data[f] (sorted_data[c] - sorted_data[f]) * (k - f) async def single_request(session): start time.perf_counter() async with session.post(ENDPOINT, jsonPAYLOAD) as response: status response.status return status, time.perf_counter() - start async def worker(session, results): status, elapsed await single_request(session) results.append((status, elapsed)) async def main(): results [] sem asyncio.Semaphore(CONCURRENCY) async def limited(): async with sem: await worker(session, results) async with aiohttp.ClientSession() as session: tasks [asyncio.create_task(limited()) for _ in range(TOTAL_REQUESTS)] await asyncio.gather(*tasks) latency [r[1] for r in results if r[0] 200] latency.sort() success_ratio len(latency) / len(results) print(fsuccess_ratio: {success_ratio:.2%}) if latency: print(fp50: {percentile(latency, 50) * 1000:.1f} ms) print(fp95: {percentile(latency, 95) * 1000:.1f} ms) print(fp99: {percentile(latency, 99) * 1000:.1f} ms) asyncio.run(main())这个脚本只是框架压测时要注意几点压测时间不能太短至少持续数分钟让排队效应稳定下来。数据集要覆盖不同 prompt 长度和 max_tokens不能全是短请求。对比修复前后保留同一份历史请求分布。除了延迟还要统计成功率、超时率、token 吞吐和 GPU 利用率。判断成功的标准要提前定好。例如p50 基本不变或略有上升但 p95 / p99 明显下降。超时率下降且没有拖垮吞吐。成功请求的总 token 数没有因为重试而大幅增加。看到这组指标后再决定是否需要继续做执行层优化还是当前修复已经够用。10. 常见问题与排查方法下面是生产环境常见的尾延迟问题排查表可以对照检查。问题现象可能原因排查方式解决方案p99 高但 GPU 平均利用率不高排队不均或队头阻塞看队列长度、单请求耗时分布增加并发控制拆分长短请求所有请求在同一时刻集中超时服务端过载或队列被打满看队列深度和服务端日志返回限流状态快速失败限制并发客户端超时后服务端仍在生成断开连接后任务没有被取消检查框架是否支持 cancel在服务端实现请求取消或最大执行时长开启重试后 token 消耗暴涨没有限制重试预算统计重试率、重试导致的 token只对关键请求重试加退避和限量hedge requests 后延迟下降但不明显两个请求打到了同一副本或同一网络路径检查路由策略确保请求发往不同副本使用内部多副本p95 正常p99 异常高少数副本变慢或长文本请求按副本、按 prompt 长度分组统计解决副本冷热不均限制超长请求优先级一个长请求拖慢一批短请求batch 内请求长度差异大对比长短请求的 TPOT启用 chunked prefill 或拆分长短请求队列压测结果波动很大环境噪声或压测时间太短加长压测时长固定请求分布多次压测取稳定结果这里强调一点不要一遇到 p99 高就立刻怀疑推理框架有问题。先看请求长度分布和队列。很多尾延迟的根源其实早就在进入 GPU 之前就埋下了。11. 最佳实践与落地顺序结合上面的内容推荐按下面的顺序落地修复方案。11.1 先补观测把所有请求加上 request_id记录各阶段时间戳。没有观测数据后续每一步优化都没有依据。11.2 客户端超时先于一切所有调用 LLM 接口的地方都必须设置超时并明确超时后的降级行为。这是成本最低、收益最直接的一步。11.3 控制服务端入站流量通过有界队列、信号量和最大并发数避免服务端进入无界排队状态。高负载时宁可快速失败也不要无限堆积。11.4 关键请求再用竞速方案默认不双发。只有当历史数据显示某些请求会明显慢于预期且业务允许更高的 token 消耗时才对这部分请求启用 hedge requests。11.5 压测对比不要凭感觉调参每做一步修改都跑同一套压测流程对比 p50、p95、p99、成功率、超时率和 token 成本。用数据决定是否需要进入执行层优化。11.6 涉及数据使用的合规提醒修复过程中涉及请求转发、多副本调用时要确保数据只流向有授权的内部服务。不要为了降低延迟把包含敏感信息的数据发送到未确认合规的外部接口。人脸、声音、隐私文本等数据都要先确认授权边界再进入多副本链路。11.7 线上发布前做小流量验证不要一次性全量发布修复策略。先让 5% 到 10% 流量走新逻辑观察成功率、延迟和成本确认稳定后再扩大。从长期看尾延迟治理不是一次性项目。请求分布会变、流量模型会变、模型版本会换治理方案也需要持续调整。最稳妥的起点永远是那三件成本最低的事情超时、有界队列、先到先得的竞速兜底。它们不解决所有问题但能帮你把“不可控的慢”变成“可感知、可拦截、可降级的慢”这一步做完整个系统就已经稳了一大半。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表