做 LLM 应用的人,几乎都会被尾延迟(tail latency)咬上一口。你辛辛苦苦把聊天机器人调通,上线后监控里却总有几次请求像是被按住了暂停键:页面转圈 20 秒,浏览器报超时,用户刷新后又被重复问了一遍。更气人的是,这类问题不会均匀出现在每一天,它偶尔出现、随机出现,偏偏最容易在演示的时候出现。
我刚开始接入大模型 API 时也犯过一个很常见的错误:把stream参数留成默认的false,然后在代码里等一整个 completion 返回。单条请求测试时一切正常,一旦上了生产,p99 延迟就开始像一条失控的尾巴,把监控图和用户体验一起拖下水。后来我意识到,真正需要改的可能不是模型、不是并发、不是机器,而是一个更简单的接入方式:把“等一个完整响应”改成“持续接收流式响应”,并且重新定义超时。
下面我会把这个修复的来龙去脉讲清楚,再给你一份可以直接拿去验证的最小实现,以及它不能解决哪些问题。
1. 尾延迟不是“慢”,而是“不可等待”
1.1 完整响应模式把模型当成只有一个返回值的黑盒
很多人第一次接 LLM 接口时,习惯和调普通 REST API 一样:
completion = client.chat.completions.create(...) return completion.choices[0].message.content在非流式模式下,服务端要等所有 token 都生成完,才会把完整文本放进 HTTP response 里返回给你。这一段时间是被整体计费的,内部其实是两段延迟相加:
- TTFT(Time to First Token):从请求发出到第一个 token 生成的时间。
- TPOT / Decode 时间:从第一个 token 之后,逐步生成后续 token 的总时间。
对客户端来说,这两种时间混在一起,你根本不知道请求当前是什么状态。它到底是排队了、卡住了、上下文太长还在 prefill,还是已经生成了 300 个 token 只是还没传完?你都不知道。你只能等,等到超时那一刻。
这就造成了一个很尴尬的局面:一个本来 3 秒就能完成的请求,因为 p99 的少数长尾请求需要等 20 秒,你为了保证成功率,会把客户端超时调得很大。可一旦把超时调大,所有请求的“最坏等待预期”也跟着变大,用户体验就像在排队,永远不知道前面还有多少人。
1.2 LLM 的长尾到底是从哪里冒出来的
这里要先说清楚:尾延迟往往不是模型一个变量造成的,而是多个因素叠加后的结果。
从实际排查经验看,LLM 请求出现长尾,常见原因有这么几类:
| 表现 | 可能原因 | 容易出现的误判 |
|---|---|---|
| TTFT 突然变高 | 输入 prompt 过长、服务端排队、前缀缓存未命中 | 误以为是模型能力变差了 |
| token 间隔不稳定 | 请求并发过高、服务端 batch 被其他长任务塞满 | 误以为是网络问题 |
| 完整响应超时 | 输出max_tokens设定过大,或模型一直不结束 | 误以为是接口故障 |
| 偶发失败 | 应用层/网关超时触发,但服务端其实还在生成 | 误以为是模型不可用 |
这也就解释了为什么“把超时调大”不是一个好方案。因为超时调大只是在掩盖问题,并没有改变请求真正耗时。你多等的那十几秒,服务端可能并不是在正常生成内容,而是因为长上下文、共享 GPU、排队或者某个 batch 里的慢请求而被拖住了。
2. 先别换 GPU:在接入层把“等完整结果”改成“持续接收”
2.1 流式为什么能立刻缓解问题
前面说到,非流式模式最大的问题,是把一次“持续生成”的交互,变成了一个“一次性结果”请求。这等于把 LLM 当成普通数据库查询:问一下,等几秒,拿到全部答案。但对于大模型来说,答案不是瞬间生成的一整块数据,而是一个接一个 token 不断流出来的过程。
如果开启流式,客户端的体验会变成:
- 服务端一旦完成 prefill 并生成第一个 token,就会立刻把数据通过 SSE 等方式推给客户端;
- 后续 token 每生成一批,就推送一批;
- 客户端不需要等完整生成,就能感知到“这次请求活着”。
这个变化对尾延迟最直接的影响,是把“一直没反应”的静默等待,变成了“需要有进度信号”的持续连接。你用 HTTP 客户端调流式接口时,read timeout不再限制整次请求的总耗时,只限制“两次数据之间的最大空闲时间”。只要模型还在正常吐 token,连接就会一直有数据流入,超时不会触发。
换句话说,流式没有压缩生成时间,但它能让请求不再像一个容易超时的巨型黑盒。对于用户或下游系统来说,只要看到内容在动,就不会把每 30 秒无响应都当成失败。
2.2 超时语义也要跟着改细一点
普通接口只需要两个超时:
- 连接超时
- 整体读超时
但 LLM 流式请求建议分成更细的三类:
- 连接超时:TCP/HTTP 建立连接的最大时间。
- 首包超时:从请求发出到收到第一个 SSE 数据块的最大时间。如果超过这个时间还没收到任何内容,说明服务端可能在排队,或者 prefill 太慢。
- 流空闲超时:相邻两个数据块之间的最大时间。如果模型生成到一半突然卡住超过空闲阈值,说明进程可能被杀、连接断掉、或者上游 batch 被某个长请求堵住。
开启流式后,“整体读超时”反而可以设置得更宽松,因为重点已经不是“总共必须在 N 秒内给完结果”,而是“每次必须有数据推进”。
2.3 接口兼容性:你要先确认这几件事
在动手改造前,还有几个前置条件需要确认:
- 上游 API 是否支持流式响应字段。大部分 OpenAI-compatible 接口都支持,但不同平台可能字段不同,不能盲目照搬。
- 中间网关是否会对 SSE 做缓冲。有些企业内网代理、API 网关默认会攒满才返回数据,导致你在客户端开不进流式,仍然要等完整响应。这种情况要先排查网络链路上的缓冲策略。
- 业务逻辑是否真的需要“完整结果”。如果你只是后端调用模型、攒完整文本后存数据库,那流式对最终用户并不会有太多感知提升;流式更适合聊天、生成式搜索、AI 写作这类可以逐步展示内容的场景。
- 是否需要同时接收 usage 等元数据。很多流式 API 默认不返回 token 数量,只有关闭流或显式开启
stream_options才可能拿到。你需要提前确认。
3. 落地一个最小版本:流式读取 + 首包/空闲超时
3.1 一个 OpenAI-compatible 接口的示意实现
下面是一个比较常见的 Python 流式调用写法,我加了超时记录和首 token 检测。代码结构上偏“示意”,因为不同服务商的接口字段会有差异,但核心思路是通用的:
import json import time import requests endpoint = "https://api.example.com/v1/chat/completions" headers = { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", # 部分服务商要求显式声明接收 SSE "Accept": "text/event-stream", } payload = { "model": "your-model-name", "messages": [ {"role": "system", "content": "你是一个输出简洁结论的助手。"}, {"role": "user", "content": "用 200 字总结这段内容的重点"}, ], # 关键点:不开流式,后面的处理逻辑不会生效 "stream": True, # 如果不是非要长文,一定要限制输出长度 "max_tokens": 600, } start = time.monotonic() first_token_ready = False collected = [] # 第一个参数是连接超时 # 第二个参数是流空闲超时,也是等待第一个数据块的最大时间 response = requests.post( endpoint, headers=headers, json=payload, stream=True, timeout=(5, 20), ) if response.status_code != 200: # 记录响应体、request_id、模型名,便于后续排查 response.close() raise RuntimeError(f"LLM request failed: {response.status_code}") try: for line in response.iter_lines(decode_unicode=True): if not line: continue # SSE 协议里冒号开头通常是注释 if line.startswith(":"): continue if not line.startswith("data:"): continue data_text = line[len("data:"):].strip() if data_text == "[DONE]": break event = json.loads(data_text) choices = event.get("choices", []) if not choices: continue delta = choices[0].get("delta", {}).get("content") if not delta: continue if not first_token_ready: first_token_ready = True print(f"首 token 延迟 TTFT: {time.monotonic() - start:.2f}s") collected.append(delta) # 这里可以做实时转发、日志打印、或者段落切分 # 如果流空闲超过 20s,下面的 for 循环会抛出 requests.exceptions.ReadTimeout except requests.exceptions.ReadTimeout: raise TimeoutError("LLM stream idle timeout") from None finally: response.close() if not first_token_ready: raise RuntimeError("LLM response is empty: no content token received")这段代码里最重要的一点是:timeout=(5, 20)并不是限制整次生成必须在 20 秒内完成。它限制的是“连接最多 5 秒”和“流中两次数据到达的时间间隔最多 20 秒”。如果模型生成一篇长文总共用了 50 秒,但只要每 10 秒还能收到新 token,连接就不会超时。
实际落地时,你不一定真的要把 HTTP 底层写成这样。很多官方 SDK 都封装了流式迭代。关键是理解底层语义,不要以为 SDK 帮你把流式打开后就万事大吉。
3.2 超时值怎么给,不能照抄
关于超时时间,我给不了所有场景都适用的数字,因为不同模型的推理速度和不同任务上下文长度差太多了。但可以参考这个思路来定:
| 参数 | 参考范围 | 设置逻辑 |
|---|---|---|
| 连接超时 | 3s - 5s | 如果 TCP 连接都建立不了,问题基本在网络层,没必要多等 |
| 首包超时 / 流空闲超时 | 10s - 30s | 结合你的 prompt 长度和任务复杂度;越复杂的任务可以越宽松 |
| 整体最大时长 | 一般不设硬上限 | 如果业务必须控制总耗时,则需要配合max_tokens设定 |
max_tokens | 根据业务内容长度给 | 不限制的话,模型可能会一直生成到上下文上限,直接拉高延迟和成本 |
这里最容易踩的坑是:不要为了追求“首包不超时”就把首包超时调到 60 秒甚至更高。那样等于又退回到了非流式模式。流式修复的意义不是无限地等,而是让你在模型“毫无动静”时快速失败、快速降级。你的首包超时应该设在业务可接受的最长等待附近,这样一旦超过这个值,系统就会走降级路径,而不是让用户面对一只转圈按钮。
3.3 超时之后的三种处理路径
超时不应该只是抛出一个异常就结束。更合理的方式是区分阶段处理:
| 超时阶段 | 建议处理方式 |
|---|---|
| 首个 token 都没收到 | 可以重试一次,但最好降低复杂度;如果持续超时,说明上游排队或容量不足,应该触发熔断 |
| 已经收到部分 token,然后流中断 | 不要盲目重试同一请求,否则可能重复计费;先判断部分输出是否可用于展示,或者直接标记为失败 |
| 多次连续超时 | 启动退避,降低并发,同时通知维护方检查模型服务状态 |
举个例子,如果你是做聊天产品,首包超时可以重试一次,并选择更短的 prompt 或更稳的模型;如果是已经流到一半再超时,用户往往已经看到了部分内容,这时候更好的做法可能是提示“生成中断”,而不是无脑重发造成内容重复和额外成本。
4. 流式修复有边界,它不降低模型本身时延
4.1 什么场景适合用流式,什么场景不适合硬上
流式修复看上去很美好,但它不是万能的。
适合用流式:
- 聊天机器人、AI 助手、文档写作:需要逐步展示内容,用户能感知到进度;
- 前端页面有交互要求:内容边出边显示,用户等待焦虑明显降低;
- 后端需要做实时翻译/转写摘要之类有“边生成边处理”需求的场景。
不适合硬上流式:
- 下游只接受完整 JSON 结果,且没有向最终用户展示中间过程;
- 你只是做离线批处理,跑完一批算一批;
- 上游接口不支持 SSE,或中间网关会缓冲导致流式退化为非流式;
- 你的逻辑里必须一次性拿到完整文本再做判断,例如用模型做高质量分类、结构化抽取。
对于不适合硬上流式的场景,更重要的处理手段反而是控制max_tokens、压缩 prompt、启用服务端 prompt cache、以及做好服务端队列和容量设计。
4.2 如果开了流式,为什么 p99 还是很高
这是很多人改造完以后最容易遇到的问题。我当然也遇到过:明明把stream=True打开了,监控上请求耗时还是很高,p99 依然难看。
原因通常出在下面几层:
- 你只是把响应改成流式,但业务还是等完整文本才入库、才返回。流式只能改善“过程可感知”,并不会让长文本生成时间从 30 秒变成 3 秒。
- 首 token 延迟本身很长。如果你的输入上下文中塞进了 5 万字,服务端 prefill 阶段就需要处理很久。你不记录 TTFT,就只会看到整体请求变慢,不知道慢在开头。
- 服务端并发饱和。流式请求占用连接时间更长,如果上游 batch 容量已经打满,新增请求会排队,TTFT 自然会上涨。
- 长输出不受控。
max_tokens设得太大,模型会持续生成内容,完成时间随之增加。即使每条数据都在流,用户等待总时长并不短。
所以我在生产项目里,通常会再额外记录三个指标:
- TTFT:首 token 时间;
- 每次 SSE chunk 的时间间隔;
- 首 token 之后平均每秒 token 数。
把这三个指标拆开以后,才能判断流式修复到底起没起作用,以及下一次应该优化 prompt、缓存还是服务端容量。
4.3 从应用层到推理服务的排查链路
如果你已经开了流式,超时也合理,但 p99 还是压不住,这时就需要顺着一条链路往下查:
- 先看调用侧:是否还在等完整结果?是否在超时后乱重试?是否有并发限制?
- 再看输入侧:prompt 上下文是不是越来越长?有没有命中 prefix cache?
- 再看推理服务:队列长度多少?最大 batch 是多少?是否使用了连续批处理?
- 再看资源侧:GPU 显存是否被打满?同一卡上有没有其他高延迟请求正在跑?
- 最后看容量:请求并发是否已经超过服务商/GPU 的承受能力?
这里有一个很实用的判断表:
| 现象 | 优先排查项 |
|---|---|
| TTFT p99 高 | 输入长度、前缀缓存命中率、服务端排队 |
| TTFT 正常但 token 速度慢 | max_tokens设得太大、decode batch 过长、GPU 算力不足 |
| 请求偶发超时,重试后变好 | 服务端队列瞬时积压、共享实例被其他流量挤占 |
| 加入流式后仍然整体超时 | 下游端到端仍在等全量文本,流式没有真正被消费 |
这套排查链路我每次都会先让团队跑一遍。很多情况下,问题并不在模型推理那一层,而是在应用把流式当成了“假流式”,数据收到了但最后一环仍然等汇总。
5. 长期来看,真正能稳住尾巴的是“分层治理”
5.1 用缓存消除重复生成
流式是接入层最便宜的一刀,但长期来看,想要让服务稳定,还应该把会重复出现的请求挡在进入模型之前。
一个常见场景是:用户反复请求同一份文档的摘要,只是 prompt 的措辞略有不同。如果你每次都调模型,不仅浪费钱,还会给尾延迟制造机会。更好的方式是,在应用层做一个 response cache:对请求内容做哈希,如果语义完全相同或高度相似,就直接返回缓存结果。
如果你的模型服务商支持 prefix cache 或 prompt cache,尽量复用同一套系统提示词和长上下文前缀。因为长 prompt 的 prefill 时间是大头,命中前缀缓存能直接降低 TTFT。
5.2 用并发控制和容量预算保护共享池
另一个容易忽略的点是:很多应用在上线初期,拿到一个看起来不错的 API Key 后就开始疯狂并发调用。结果并发一上去,p99 马上崩。这通常不是模型能力问题,而是服务端 batch 在超负荷时只能让某些请求先跑,其他请求排队;一旦排队,TTFT 就变长,尾延迟自然出现。
我一般建议先做一个小规模压测,找到“延迟还不恶化”的并发上限,然后在应用层强制限制最大并发数。可以加一个信号量、连接池限制或简单的队列调度。虽然高并发能提高吞吐,但如果业务要求 p99 稳定,就应该牺牲一部分并发换延迟。
这里有一个很朴素的原则:容量是有限的,不要把 retry 当成扩容手段。当上游已经开始排队时,客户端疯狂重试只会加重拥塞,正确做法是退避、降级、或者换到另一个模型实例。
5.3 把修复变成监控指标,而不是一次性动作
最后,这个“简单修复”不能只在代码里埋一次就结束。你需要让团队形成习惯:每次调整 prompt、模型、并发量、服务商之后,至少重新看一轮 p50、p95、p99 和 TTFT。
我会在应用里给每次 LLM 请求打结构化日志,至少包含:
- request_id
- 模型名
- prompt 字符数
max_tokens- 是否流式
- TTFT
- 总耗时
- 结束原因:正常结束 / 首包超时 / 流空闲超时 / 业务断流
不要小看这些日志。没有这些数据,你只能看到“p99 变高了”,却说不清是哪一类请求、哪一个阶段、哪一个模型造成的。
回到最开始的问题。如果你的 LLM 应用也正在被尾延迟折磨,我的建议是:先别急着换更大的模型卡,也别把超时时间调到 60 秒。先在接入层做三件事:开启流式、重新定义超时语义、给超时后的行为一个明确的降级路径。这是最便宜、最不容易翻车的一刀。
等这一刀落地后,如果你的 p99 还是往上飘,再顺着 TTFT、上下文长度、前缀缓存和服务端并发一层层往上查。到那时你手里已经有数据,而不是像现在这样,只能盯着监控里的尾巴发呆。