在基于大语言模型做应用时,平均延迟并不是可靠的体验指标。真正让用户感知到“卡住”的,往往是那些极少数特别慢的请求,这类延迟在工程上通常称为 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,因此天然是串行的。
从客户端视角看,最值得关注的不是总耗时,而是两个观察点:
| 指标 | 含义 | 说明 |
|---|---|---|
| TTFT | Time to First Token,首 token 返回耗时 | 用户按下发送键后,多久能看到第一个字 |
| TPOT | Time 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_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_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 上线时如何灰度
即使实验通过,也不建议直接全量开启双发。特别是有成本压力的系统,可以先按以下顺序灰度:
- 只对非核心请求关闭双发,对核心请求开启。
- 把
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 阈值双发是最容易落地的第一道优化:它不用改模型,不用改推理服务,只需要在现有请求层加一段异步调度逻辑就能看到效果。