实战方案)
在基于大语言模型做应用时平均延迟并不是可靠的体验指标。真正让用户感知到“卡住”的往往是那些极少数特别慢的请求这类延迟在工程上通常称为 tail latency尾延迟。尾延迟在普通微服务里已经很难处理放到 LLM 场景后会更加明显因为一个带超长上下文的请求可能让同一批次的后续 token 一起变慢而客户端能观察到的现象就是请求发出去了但迟迟没有收到第一个 token。这篇文章围绕 LLM 接口尾延迟展开先说明尾延迟为什么不能只用“调大超时”来兜底再给出一个成本可控、代码量不大的修复思路按首 token 到达时间做阈值超过阈值后不再死等而是向备用副本发起一次限量的双发请求也就是 hedged request。文中会给出 Python 异步实现、参数调优方法、验证实验和常见坑适合正在做 LLM 网关、Agent 调用层或模型服务治理的工程师参考。1. 平均延迟正常不代表服务正常先看清 LLM 的时间指标1.1 时间都花在哪儿prefill、decode 与首 token普通 HTTP 接口的延迟通常是一次请求到一次响应的往返时间。LLM 接口则不同一次生成过程由多个阶段组成如果只看最终完成时间很难定位慢请求到底慢在哪一步。大模型推理的典型过程可以拆成两段prefill处理用户输入的 Prompt生成 Key-Value Cache这一步的计算量与输入长度强相关。decode逐个生成输出 token每生成一个 token 都依赖前面已经生成的 token因此天然是串行的。从客户端视角看最值得关注的不是总耗时而是两个观察点指标含义说明TTFTTime to First Token首 token 返回耗时用户按下发送键后多久能看到第一个字TPOTTime Per Output Token平均每个输出 token 的耗时反映生成阶段是否平稳端到端延迟首 token 时间加上后续 token 总时间最终完成整段回复的时间如果请求没有开启流式输出用户会一直在等待直到完整内容返回。此时一旦后端排队前端感受到的就是整个请求超时问题会被进一步放大。因此排查 LLM 尾延迟时第一步就应该把 TTFT 和 TPOT 分开记录而不是只记录一个总耗时。1.2 一个典型的长尾现象一个长 Prompt 拖慢整批请求LLM 服务端通常使用动态批处理提升 GPU 利用率。请求会被按相似长度拼进同一个 batch但这个 batch 中只要出现一个长度远大于其他请求的 Promptprefill 阶段就会消耗远高于其他请求的计算资源。一个比较典型的场景是大部分请求只带几百个 token 的上下文TTFT 平均在 1 秒左右。某个请求带有几万 token 的 RAG 上下文prefill 需要 10 秒以上。这个长请求进入批量调度后同批次的短请求一直在等它完成 prefill然后才能一起进入 decode 阶段。结果就是整体的平均延迟变化不大但 P95、P99 会突然飙升。用户端看到的并非“网络慢”而是队列和调度共同作用下的排队延迟。1.3 尾延迟和错误率不是一回事这里需要区分两个概念tail latency 描述的是慢请求的分布P99 高说明仍有一小部分请求异常慢。error rate 描述的是失败比例只有请求超时或返回错误时才会计入。很多团队在排查时只盯着错误率。错误率没有明显变化就认为系统稳定这其实是漏掉了最影响体验的尾延迟。LLM 应用里用户往往能接受生成结果稍慢但很难接受等了 20 秒后才看到第一个字或者等到一半被强制超时。因此优化目标是压低尾部延迟而不是简单地把超时时间往大调。2. 为什么调大超时和盲目重试是错误方向2.1 常见处理方式超时 60 秒不行就调到 120 秒在最初接入 LLM 接口时很多团队会配置一个全局超时比如 30 秒或 60 秒。收到“请求超时”的反馈后第一反应往往是把超时时间调大。这样做的确可以减少报错数量但并没有解决慢请求的根因只是让慢请求有机会拖更久。如果某次后端已经出现排队调节超时时间只会让更多请求堆积在队列中。等到超时真正触发时客户端已经等待了很久用户体验更差而服务端资源也已经被这些慢请求占住了。2.2 盲目重试会放大负载也治不了排队另一种常见做法是收到超时就重试。重试本身不是问题问题在于盲目重试超时发生时服务端可能已经完成了部分推理只是结果没有来得及返回。立即重试会让同一个请求在服务端出现两个副本占用更多显存和计算资源。如果所有客户端都因为服务端性能下降而同时重试会形成重试风暴反而把服务打得更慢。如果两个请求仍然打到同一个排队队列重试并不会让延迟变好。它只是把一次慢请求变成两次或三次慢请求成本翻倍用户体验却没有改善。2.3 简单修复用 hedged request 的思路解决排队不均衡Google 在大型分布式系统中有过一个经典做法当一个请求没有在规定时间内返回时不继续等待原请求而是同时向另一台副本发送一份相同请求谁先返回就用谁的结果后到的请求会被取消。这种方案叫 hedged request翻译过来可以叫“慢请求双发”或“阈值双发”。它解决的是分布式系统中的“排队不均”问题原始请求可能分配到一台负载很高的实例而备用实例此时是空闲的。在 LLM 场景下这个思路适合做服务治理但不适合直接套用最激进的全量双发原因是生成接口成本高而且同一请求可能得到不完全一致的结果。实际落地时可以只做“首 token 阈值双发”先发送一个主请求如果在hedge_after秒内没有收到第一个 token就再发送一个备用请求如果主请求已经正常开始返回内容就不再发送重复请求。方案触发条件优点缺点固定双发每个请求都发两份尾延迟最低成本翻倍请求结果不一致超时后重试完整响应超时实现简单无法消除排队容易造成重试风暴首 token 阈值双发超过阈值仍未收到首 token只针对慢请求成本可控需要流式接口和异步调度支持简单修复并不是“无脑双发”而是把等待资源从“主请求全流程等完”改成“首 token 阶段快速发现异常并给请求一个备用路径”。3. 在 OpenAI 兼容服务上实现首 token 双发3.1 环境准备与接口约定实现这套逻辑要求 LLM 服务接口支持流式响应。大部分 OpenAI 兼容服务都支持在请求体中设置stream: true然后通过 SSE 数据流返回data: {json}。环境依赖如下pip install httpx本文示例使用 Python 3.10 及以上的异步语法。需要准备两个端点一个是主端点PRIMARY_ENDPOINT另一个是备用端点BACKUP_ENDPOINT。在实际生产系统中这两个端点最好指向不同的实例、不同可用区或不同模型服务否则双发仍然会落到同一个队列里。3.2 核心实现LLMCall 与阈值调度先用一个LLMCall对象封装单次流式请求。它需要完成三件事发起 HTTP 流式请求。解析 SSE 行提取增量文本。在收到第一个 token 时设置first_token事件。import asyncio import json import uuid import httpx class LLMCall: 封装一次 LLM 流式调用。 def __init__(self, endpoint, api_key, body, request_id): self.endpoint endpoint self.api_key api_key self.body body self.request_id request_id self.first_token asyncio.Event() self.text_parts [] async def run(self): headers { Authorization: fBearer {self.api_key}, Content-Type: application/json, X-Request-ID: self.request_id, } first True try: async with httpx.AsyncClient(timeout60.0) as client: async with client.stream( POST, self.endpoint, jsonself.body, headersheaders, ) as resp: if resp.status_code ! 200: raw (await resp.aread())[:200] raise RuntimeError(fstatus{resp.status_code}, body{raw}) async for line in resp.aiter_lines(): if not line.startswith(data: ): continue data line[6:].strip() if data [DONE]: break try: payload json.loads(data) content payload[choices][0][delta].get(content) except Exception: continue if content: if first: self.first_token.set() first False self.text_parts.append(content) finally: # 防止请求在首个 token 前直接结束时, 外部等待一直没有被唤醒 self.first_token.set() return .join(self.text_parts)这里在finally中再次调用first_token.set()主要目的是防止一种边界情况请求在首个 token 出现前已经明确失败或结束此时外部等待方不能被永久阻塞。后续代码中会根据任务状态决定是否还继续等待或启动备用请求。再写一个调度函数第一次请求先等待首 token超过指定阈值后再创建备用请求async def call_with_first_token_hedge( primary_endpoint, backup_endpoint, api_key, body, hedge_after2.0, ): request_id str(uuid.uuid4()) primary LLMCall( primary_endpoint, api_key, body, f{request_id}-primary, ) primary_task asyncio.create_task(primary.run()) try: await asyncio.wait_for(primary.first_token.wait(), timeouthedge_after) except asyncio.TimeoutError: # 主请求首 token 没有在阈值内到达, 此时才启动备用请求 if primary_task.done(): return primary_task.result() backup LLMCall( backup_endpoint, api_key, body, f{request_id}-backup, ) backup_task asyncio.create_task(backup.run()) done, pending await asyncio.wait( {primary_task, backup_task}, return_whenasyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() for task in done: try: return task.result() except Exception: continue raise RuntimeError(all LLM calls failed) # 主请求在阈值内返回了首 token, 正常等待主请求完成 return await primary_task这段代码的基本逻辑是前hedge_after秒只等待主请求的首 token如果主请求已经收到首 token就继续等它生成完整结果如果一直没有收到首 token说明主请求大概率已经被塞入慢队列此时发起备用请求谁先整体完成就返回谁。3.3 参数含义与调参建议核心参数主要有两个hedge_after和外部 HTTP 客户端的timeout。参数含义设置建议hedge_after主请求首 token 的最长等待时间超过则触发备用请求先观察 P75 或 P90 的 TTFT并留出少量余量HTTP timeout单次流式请求的全局超时应大于模型最长生成时间通常设为 60 秒到 120 秒max_tokens输出长度上限控制在业务实际需要范围内避免生成过长导致尾部明显偏大model主备使用的模型如果允许差异备用模型可以选响应更快的小模型hedge_after并不是越小越好。设置过小会导致很多本来正常的请求被误判成慢请求双发比例上升成本和流量都会明显增加。设置过大则备用请求触发太晚用户仍然会感知到过长的等待时间。建议先通过日志记录一周内的 P50、P75、P90 TTFT 数据再根据可用成本决定阈值。注意不要把备用请求无差别发到同一个服务提供商的同一个模型入口。如果前面只有一个排队队列备用请求只会继续堆积不会带来真正的容错。4. 验证这个修复是否真的有效4.1 先给接口加可观测字段双发逻辑上线前建议先让日志能够回答三个问题有多少请求触发了首 token 超时。触发超时后备用请求是否真的更快。主请求完成后备用请求是否造成额外成本。在真实调用中可以在返回结果上附带一个元信息例如used_backup、wait_time、ttft_ms、total_ms便于区分每条请求来自主请求还是备用请求。result { text: final_text, used_backup: primary_seconds hedge_after, primary_latency_ms: latency_ms, request_id: request_id, }如果对接的是自建 vLLM 或 TGI可以在请求头中添加X-Request-ID并在服务端日志中关联同一请求 ID这样能判断主备请求是否真的进入了不同实例。4.2 本地构造慢实例做对照实验在没有现成压测环境时可以写一个本地模拟服务来验证调度逻辑。用一个端点模拟慢实例让它在首 token 前固定睡眠 5 秒另一个端点模拟正常实例首 token 在 0.5 秒内返回。将hedge_after设为 1 秒然后观察是否触发备用请求。模拟服务的伪代码如下async def fake_llm_endpoint(request: dict, slow: bool): if slow: await asyncio.sleep(5) # 模拟排队导致的 TTFT 长尾 return ok在实际运行前不需要真的依赖外部大模型先验证双发调度本身是否正常python - PY import asyncio from your_gateway import call_with_first_token_hedge asyncio.run( call_with_first_token_hedge( primary_endpointhttp://127.0.0.1:9001/v1/chat/completions, backup_endpointhttp://127.0.0.1:9002/v1/chat/completions, api_keytest, body{ model: mock, messages: [{role: user, content: hi}], stream: True, }, hedge_after1.0, ) ) PY预期结果是主端点慢备用端点快最终返回内容来自备用端点整体耗时接近备用端点的响应时间。4.3 上线时如何灰度即使实验通过也不建议直接全量开启双发。特别是有成本压力的系统可以先按以下顺序灰度只对非核心请求关闭双发对核心请求开启。把hedge_after设置为当前 P90 TTFT观察双发比例。如果双发比例超过 10%说明阈值偏低或者主服务不稳定需要排查主链路。确认双发确实降低 P99 后再逐步把阈值调低观察成本和收益。这个验证流程重点不是看平均耗时而是对比同一时间段内 P95、P99 的变化同时记录备用请求成功率和额外 token 消耗。5. 常见坑与排查清单5.1 四个常见的坑第一个坑是把双发做成“全局全量”。部分请求即使慢 2 秒业务也没有实际损失如果全部做首 token 双发双发比例很高生成的 token 费用会明显增加。更合理的做法是针对核心业务或单一长 prompt 请求开启。第二个坑是把主备请求配置到同一个服务队列。双发起作用的前提是两个请求有独立排队的可能。如果主备地址只是同一个负载均衡下的两个实例但负载均衡逻辑是“同一 Session 固定到同一实例”两个请求仍可能落到同一台机器上起不到兜底作用。第三个坑是忘记模型输出的不一致风险。LLM 是概率模型同一个 prompt 发两次可能得到不同结果。对于“摘要、分类、内容改写”这类允许结果略有差异的场景双发是可行的对于“扣款、发通知、写数据库”这类副作用明显的场景不能直接对同一请求做双发否则需要在上游按request_id做幂等控制。第四个坑是只优化首 token不关心后续 token 的尖刺。首 token 快只是保证了用户能快速看到内容开始流动但如果模型在生成中途因为资源争抢突然停顿几十秒用户同样会感受到卡顿。这时候需要监控相邻两个 token 的间隔并在服务端或网关层增加“无新 token 超时”的检测。5.2 排查清单现象常见原因检查方式处理建议双发比例很高hedge_after设置过低或主服务整体异常查看主请求 TTFT P50 是否已接近阈值调高阈值同时检查主服务负载双发后 P99 没有下降主备请求落在同一个排队队列对比主备实例的实例 ID、队列长度指标更换备用端点或使用不同可用区副本首 token 很快但整体很慢输出 token 过多或 TPOT 出现尖刺拆分 TTFT 与总耗时看吞吐变化限制max_tokens对相邻 token 间隔做二次超时请求失败后仍触发备用网络错误被当成慢请求看异常类型和错误统计对连接失败应先快速失败不必等hedge_after成本明显上升双发请求实际生成了完整内容对比主备请求的 token 消耗在备用请求返回后立即取消未完成的主请求5.3 更进一步的修复方向hedged request 属于客户端或网关层的“对症处理”它解决的是排队不均衡。如果自己管理推理服务端还可以配合几个方向对长 Prompt 请求做单独的 prefill 通道避免短请求被长 prompt 堵住。对长上下文开启 prefix caching减少重复 prefill。在 vLLM 等推理框架中调整最大 batch size 或队列长度让慢请求不至于无限堆积。将 prefill 和 decode 拆分到不同阶段或不同实例避免一个 prefill 过长的请求拖慢同批 decode。可以简单修复的是调用层策略但真正要稳定支撑高并发还需要把推理引擎的调度、缓存和服务治理放在一起设计。对于大多数使用第三方 LLM 接口的团队而言本文的首 token 阈值双发是最容易落地的第一道优化它不用改模型不用改推理服务只需要在现有请求层加一段异步调度逻辑就能看到效果。