一套能扛住慢速生成的 LLM API 超时策略
为什么单一固定超时会拖垮 LLM 调用,以及如何设置连接、首字与空闲三层截止时间、用流式保持连接存活,并借助 AbortController 干净地取消请求。
一次通常三秒就能回复的对话模型请求,偶尔会耗上九十秒。给客户端设一个粗暴的 30 秒统一超时,你会掐断那些慢但正确的生成,却对真正卡死的请求毫无办法。真正的修法不是把数字调大——而是设置几个各自衡量不同事物的截止时间:你愿意等多久去建立连接、愿意等多久才拿到第一个 token,以及在 token 已经流动起来后你能容忍多久的沉默。本文将展示如何构建这种分层超时、为什么流式改变了这道算术题,以及如何在截止时间到达或用户喊停的那一刻立即取消工作。
LLM 的延迟很不寻常,而这正是问题的根源。普通的 REST 调用延迟分布紧紧聚集在均值附近,因此设一个高于均值几倍的固定超时是安全的。模型生成的延迟则是重尾的,且取决于客户端看不到的因素:模型自己选择的输出长度、它是不是一个在写下一个字之前要花费隐藏 token 的推理模型、上游排队情况,以及冷路由。一个按中位数调校的超时会把长尾截肢;一个按长尾调校的超时又会让真正卡死的请求把连接和配额占用得太久。
把一个超时试图覆盖的各种截止时间拆开
"超时"这一个词,指的其实是至少四个相互独立的限制。分别给它们命名,就是全部诀窍所在。
- 连接超时——建立 TCP/TLS 连接的上限。这个应该很短(几秒钟);连接慢是网络或 DNS 问题,而不是模型慢。
- 首字时间(TTFT)截止——你等待模型开始响应的时长。在推理模型上,这个时间合理地会达到数十秒,因为模型可能会在吐出任何内容之前先思考。
- 空闲 / token 间超时——一旦流开始,你能容忍相邻两个数据块之间的最大间隔。健康的流会持续发送 token;长时间的沉默意味着上游卡住了。
- 总时长 / 墙钟截止——对整个请求的一个绝对上限,好让某个病态情况不至于无限运行下去。
单一固定超时把这四者压缩成一个数字,因此哪一个都服务不好。长生成的可靠性来自:设置慷慨的 TTFT 和总预算,同时把空闲超时保持紧凑,这样你能在卡住时快速失败,却又能在真正工作时保持耐心。
流式把硬性的总超时变成软性的空闲超时
你能做的最有效的改动就是采用流式。当你发送 stream: true 时,模型会通过 Server-Sent Events 逐个返回 token,而不是把整个回答缓冲起来,于是你不再衡量"整个响应是否在 N 秒内完成",而开始衡量"下一个数据块是否在 N 秒内到达"。这把问题反转到对你有利的方向:一个五千 token 的回答不再有被总时长截断的风险,因为每个数据块都会重置空闲计时器。如果你还没构建过流式客户端,用 SSE 实现 LLM 流式响应讲解了线路格式与解析;这里我们只关心它所解锁的计时行为。
在 fetch 流上的空闲超时逻辑长这样——一个看门狗,每收到一个数据块就重置,只在真正沉默期间才触发:
async function streamWithIdleTimeout(url, body, { idleMs = 20000 } = {}) {
const controller = new AbortController();
let timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer AVE_GATEWAY_API_KEY" },
body: JSON.stringify({ ...body, stream: true }),
signal: controller.signal,
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
clearTimeout(timer); // a chunk arrived: reset the watchdog
timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
process.stdout.write(decoder.decode(value, { stream: true }));
}
} finally {
clearTimeout(timer);
}
}
注意空闲预算(20 秒的沉默)与总时长是解耦的。只要请求持续产出 token,它就可以运行几分钟,但真正的卡住会在一个空闲窗口内被触发。这正是固定总超时无法表达的行为。
用 AbortController 取消,并读取错误名称
每个超时都需要一个真正停止工作的机制,而在浏览器和现代运行时里,那个机制就是 AbortController。你把它的 signal 传给 fetch,调用 abort() 就会拆掉连接。如果你只想要一个纯粹的墙钟上限、而不想手工搓一个控制器,AbortSignal.timeout(ms) 会返回一个在给定毫秒数后自我中止的信号:
// Hard 60s cap on a non-streaming call:
const res = await fetch(url, { method: "POST", signal: AbortSignal.timeout(60000), /* ... */ });
这两条取消路径报告的是不同的错误,而区分它们正是让你决定是否重试的关键。手动的 controller.abort() 会让 fetch 以一个名为 AbortError 的 DOMException 拒绝;来自 AbortSignal.timeout() 的信号则以一个名为 TimeoutError 的拒绝。要把"用户按了停止"和"硬性截止时间"结合起来,用 AbortSignal.any([userSignal, AbortSignal.timeout(60000)]) 合并信号,然后按 err.name 分支处理:
try {
await callModel();
} catch (err) {
if (err.name === "TimeoutError") retryOrEscalate(); // our deadline fired
else if (err.name === "AbortError") stopQuietly(); // the user cancelled
else throw err; // a real failure
}
把用户的取消当成失败去重试,是一个经典 bug:你重新发起了一个用户明确终止过的请求,为 token 付了钱,还污染了你的指标。请按名称分支。
服务端有它自己的超时,而且它说了算
如果你的客户端是与一个网关或你自己的后端通信,而不是直接与提供方通信,那么这一跳就会施加它自己的截止时间,而路径上最短的那个决定结果。一个常见的故障是:客户端耐心地等待五分钟,而中间某个代理在六十秒时切断了连接;或者由于响应缓冲把第一个字节延迟到超过了上限,负载均衡器的空闲超时关闭了一个"卡住"的流式响应。三条服务端规则能防止这种情况咬到你:
- 把取消向上游传播。 当客户端断开时,中止你向提供方发起的那个请求。取消只是尽力而为——断开后,提供方可能还会短暂地继续生成和计费——但只有把中止传播上去,才有机会停止为无人读取的 token 付费。这是
AbortController在服务端的镜像。 - 不要缓冲流式响应。 一个在转发前累积整个响应体的代理会延迟第一个字节,并可能触发一个在已刷新的流上永远不会触发的上游空闲超时。
- 把截止时间交给运行时。 在 Go 里,
context.Context的截止时间是惯用的载体;当它过期时,出站的 HTTP 请求被取消,资源被释放。任何语言都有等价物——关键在于超时必须抵达套接字,而不是只停留在包装层。
网关是执行一致策略的天然场所,因为它本就终结客户端连接并持有提供方凭证。把每个上游注册到网关后,同一套截止时间和取消路径就能作用于所有提供方;中止前实际消耗的 token 也会记录在使用日志中,使被取消的调用仍得到核算。请注意,本地计数只是尽力而为:如果最终的上游 usage 没有返回,它可能是估算值;涉及关键计费时,应与提供方自己的用量数据对账。
超时与重试是一个系统,而不是两个
一个触发了的超时是给你重试逻辑的一个信号,所以两者必须一起设计,否则它们会互相打架。两条规则可以防止常见的病症:
- 只有在快速失败之后重试才有意义。 如果你的总超时很长且在过期时重试,那么一个慢请求就会变成好几个背靠背堆叠的慢请求,成倍地放大延迟和成本。把每次尝试的截止时间保持得足够紧,让重试比继续等待更划算,并对尝试次数设上限。
- 一个超时的请求可能其实已经执行了。 当你切断连接时,提供方也许已经产出了那个你从未读到的响应。盲目重试可能会双重扣费或重复副作用。给超时配上一套幂等策略,让超时之后的重试是安全的——具体机制见 LLM API 重试与幂等性。
超时还会与速率限制相互作用。一连串超时后紧跟立即重试,从上游的视角看就像一个负载尖峰,可能把你推向 429 响应。把截止时间与背压协调起来——排队而不是猛砸——这在 LLM API 速率限制与背压中有讲。当慢的是整个提供方而不是单个请求时,正确的做法往往是转移流量;见 LLM API 故障转移策略。
推理模型需要更长的耐心预算
推理模型对计时特征的改变足够大,值得给它们单独的数字。因为它们在产出可见输出之前会花费隐藏的推理 token,即使模型工作完全正常,TTFT 也可能很长,而一个为快速对话模型校准的 TTFT 截止会过早地终止它们。有两处调整有帮助:专门为推理模型路由提高 TTFT 和总预算,并优先使用流式,这样一旦可见 token 开始出现,管辖请求的就是空闲超时,而不是总时长上限。推理参数应单独调校,并按 LLM 上下文窗口管理中的策略为隐藏 token 预留足够空间。
常见问题
我该给一次 LLM API 调用用什么超时值?
没有单一的值,这正是分层策略的意义所在。用一个短的连接超时(几秒钟)、一个按你最慢的模型家族来设定的慷慨 TTFT 截止、一个针对流式响应的紧凑空闲超时(数十秒的沉默),以及一个作为兜底的绝对总上限。调校单个数字会逼你在长尾正确性和卡死检测之间做权衡;分开的截止时间让你两者兼得。
我怎么分辨一次截止时间超时和一次用户取消?
检查错误的 name。来自 AbortSignal.timeout() 的信号以 TimeoutError 拒绝;手动的 controller.abort() 以 AbortError 拒绝。在 TimeoutError 上重试或升级,在 AbortError 上静默停止,其它一律重新抛出。把两者混为一谈会导致你重试用户故意终止的请求。
中止一个请求真的能阻止提供方向我计费吗?
只有在取消抵达提供方时才可能停止计费,而且即便如此也只是尽力而为。在浏览器里,controller.abort() 关闭的是通向你服务器的连接;你的服务器随后必须中止自己的上游请求,否则生成很可能继续,而你会为无人读取的 token 付费。把取消传播到每一跳,才能让中止有机会成为真正的成本控制;由于本地 token 计数可能是估算值,请通过提供方自己的用量数据或账单交叉确认它是否生效。
为什么我的流式响应会一直挂起直到完成,而不是流式输出?
几乎总是某个中间环节的响应缓冲。一个在转发前累积响应体的反向代理或框架会击垮流式,还会因为延迟第一个字节而触发空闲超时。对 text/event-stream 响应禁用缓冲,并在数据块到达时立即刷新它们。
把一条路由拆成四个截止时间
拿一条生产路由,把它单一的超时拆成四个:连接超时、TTFT 截止、流空闲超时和总时长上限。把该路由切换到 stream: true,接上上面的空闲看门狗,然后故意在流中途终止一个请求。本地记录的输出 token 应该下降,这说明截止时间可能已经抵达套接字;但请把它视为信号而不是证明,并在成本重要时与提供方自己的用量数据对账,因为断开连接后,上游仍可能短暂继续计费。