LLM接口尾延迟治理:首token双发(Hedged Request)实战方案
2026/9/4 19:45:51 网站建设 项目流程

在基于大语言模型做应用时,平均延迟并不是可靠的体验指标。真正让用户感知到“卡住”的,往往是那些极少数特别慢的请求,这类延迟在工程上通常称为 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 中只要出现一个长度远大于其他请求的 Prompt,prefill 阶段就会消耗远高于其他请求的计算资源。

一个比较典型的场景是:

  • 大部分请求只带几百个 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": f"Bearer {self.api_key}", "Content-Type": "application/json", "X-Request-ID": self.request_id, } first = True try: async with httpx.AsyncClient(timeout=60.0) as client: async with client.stream( "POST", self.endpoint, json=self.body, headers=headers, ) as resp: if resp.status_code != 200: raw = (await resp.aread())[:200] raise RuntimeError(f"status={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_after=2.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(), timeout=hedge_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_when=asyncio.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_backupwait_timettft_mstotal_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_endpoint="http://127.0.0.1:9001/v1/chat/completions", backup_endpoint="http://127.0.0.1:9002/v1/chat/completions", api_key="test", body={ "model": "mock", "messages": [{"role": "user", "content": "hi"}], "stream": True, }, hedge_after=1.0, ) ) PY

预期结果是:主端点慢,备用端点快,最终返回内容来自备用端点,整体耗时接近备用端点的响应时间。

4.3 上线时如何灰度

即使实验通过,也不建议直接全量开启双发。特别是有成本压力的系统,可以先按以下顺序灰度:

  1. 只对非核心请求关闭双发,对核心请求开启。
  2. hedge_after设置为当前 P90 TTFT,观察双发比例。
  3. 如果双发比例超过 10%,说明阈值偏低或者主服务不稳定,需要排查主链路。
  4. 确认双发确实降低 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 阈值双发是最容易落地的第一道优化:它不用改模型,不用改推理服务,只需要在现有请求层加一段异步调度逻辑就能看到效果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询