2024 年我做一个 LLM 应用,后端要调用大模型 API 完成问答、总结这些活。调用大模型这件事,我压根没多想。第一版我做得很省事:调用大模型,不就跟调用一个普通函数一样?把 prompt 发出去,等它返回结果,拿到就用。本地开发时——真不错:我点一下,请求发出去,大模型两三秒就把答案吐回来了,顺畅得很。我心里很踏实:"调 LLM 嘛,不就是发个请求、等它返回?"可等这个应用真正上线、被一群真实用户并发地用起来,一串问题冒了出来。第一种最先把我打懵:有一个调用卡住了、迟迟不返回——我没设超时,它就一直占着一个 worker;这样的卡住请求一多,服务的并发槽全被占满,新来的请求全部排不进来,整个服务像被冻住一样。第二种最容易误伤:后来我学乖了,给调用设了超时——可我图省事,只设了一个"总超时 30 秒",结果一个本来正常、只是输出比较长的请求(它要生成 40 秒),被我这一刀切误杀了。第三种最隐蔽:流式输出的场景下,模型在第 5 秒之后就再没吐出一个新 token——上游其实已经卡死了——可我的"总超时"还有 25 秒才到期,于是我整整干等了 25 秒,守着一个早就死掉的流。第四种最烧钱:用户在前端点了"停止生成"、或者干脆关掉了页面走人,可我后端那个 LLM 调用还在继续跑、继续往上游要 token、继续计费——"取消"这个信号,根本没从用户传到我的调用上。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"调用大模型,跟调用一个普通函数没区别——调了,等它返回就行"。这句话把 LLM 调用当成了一个"很快、总会返回"的普通函数。可它不是。一个 LLM 调用,和一个普通函数调用最根本的区别,是它的耗时没有上界、而且方差极大:它可能 1 秒返回,可能 100 秒返回,也可能永远不返回。"很慢"对 LLM 调用不是异常,是常态;"永远不返回"也是真实会发生的——上游卡死、网络黑洞,都会让一个调用永久挂起。而只要一个调用还没结束,它就死死占着你服务里一个宝贵的并发槽。所以"调用大模型"这件事,从写下第一行调用代码起,就必须把"它可能很慢、可能永不返回"当成第一类问题来设计:你要给它设超时,要分清连接超时、总超时、空闲超时是三个不同的东西,对流式输出要用空闲超时判断流是不是死了,还要让"取消"信号能从用户一路传到上游。真正做好 LLM 调用,核心不是"发请求、等返回",而是承认调用耗时没有上界、给它配齐三类超时、流式场景靠空闲超时识别卡死、让取消能从用户传到上游、把超时当成一份从入口分配下来的预算。这篇文章就把 LLM 调用超时治理梳理一遍:为什么"调了就等它返回"是错的、连接超时和总超时和空闲超时分别管什么、流式场景为什么必须有空闲超时、取消信号怎么从用户一路传到上游、一个请求链路里的超时预算怎么分配,以及超时与重试怎么配合、超时值怎么定、超时怎么做可观测这些把超时治理真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一套"发请求、等返回"的 LLM 调用代码,上线后冒出一串问题:一个调用卡住不返回、没设超时,死占 worker,卡住的请求一多整个服务被冻住;后来设了超时,却用一个"总超时"一刀切,把正常的长输出请求误杀了;流式输出时上游中途断流,总超时还早,白白干等几十秒守着一个死掉的流;用户都关页面走了,后端调用还在跑、还在烧 token。
我当时的错误认知:"调用大模型,跟调用一个普通函数没区别——调了,等它返回就行。"
真相:这个认知错在它用"普通函数"的直觉去套"LLM 调用"。一个普通的函数调用,它的耗时是确定的、有上界的:几微秒、几毫秒,跑完就返回,你从不需要担心它"会不会回来"。可一个 LLM 调用完全是另一种东西:它的耗时取决于一长串你控制不了的因素——输出要生成多少 token、模型此刻负载多重、上游排队多长、网络通不通畅。这些因素叠加起来,让一次调用的耗时从 1 秒到几分钟不等,方差极大;而最关键的一点是,它有一个普通函数永远不会有的结局——永远不返回:上游进程卡死、网络中途黑洞掉,这个调用就会永久地挂在那里。问题在于,在它挂着的整段时间里,它并不是"免费"地挂着——它死死占着你服务里的一个并发槽:一个 worker、一个连接、一个协程。于是那串问题就有了根:服务被冻住,是因为没设超时的卡死调用,把并发槽一个个占光了,新请求再也排不进来;正常长输出被误杀,是因为"总超时"这一个数,管不了"慢"和"卡死"这两种本质不同的情况;守着死流干等,是因为没有一个机制专门盯"流还活着没有";用户走了还在烧 token,是因为"取消"根本没传下去。一旦你接受"LLM 调用是一个耗时无上界、可能永不返回、且占用着并发资源的调用",那串问题的解法就全有了方向:必须设超时,而且要设对——分清三类超时各管一段;必须能识别"卡死"而不只是"慢";必须能把"不等了"这个决定真正落实到上游;还必须把超时当成一份有限的、要分配的预算。
要把 LLM 调用超时治理做对,需要几块认知:
- 为什么"调了就等它返回"是错的——LLM 调用耗时无上界、可能永不返回;
- 连接超时、总超时、空闲超时——三个不同的超时,各管一段;
- 流式场景——为什么必须有"空闲超时"来识别卡死的流;
- 取消传播——超时或用户断开后,要把中止信号真正传到上游;
- 超时预算、超时与重试的配合、超时可观测这些工程坑怎么处理。
一、为什么"调了就等它返回"是错的
先把这件最根本的事钉死:一个普通函数调用和一个 LLM 调用,看起来都是"调一下、拿结果",但它们在"耗时"这件事上是两种完全不同的东西。普通函数:耗时确定、有上界,你写下调用的那一刻,就默认它一定会在极短时间内返回——这个默认,99.99% 的情况下都成立。LLM 调用:耗时由输出长度、模型负载、上游排队、网络状况共同决定,没有上界;而且它有一个真实存在的结局叫"永不返回"。"调了就等它返回"这个想法,本质是把"普通函数一定会快速返回"这个默认,不假思索地套到了 LLM 调用头上。可这个默认在 LLM 调用这里根本不成立。更要命的是,一个挂起的调用不是静静地挂着——它占着你服务的一个并发槽。你的服务能同时处理的请求数是有限的(worker 数、连接数、协程数),每一个挂死的调用,都从这个有限的池子里扣走一个名额、且永不归还。于是不设超时的代价,从来不是"这一个请求慢了",而是"卡死的调用攒够一批,你的服务再也接不了新请求"。
下面这段代码,就是我那个"一卡死就冻住整个服务"的第一版:
# 反面教材:调 LLM 就像调普通函数 —— 不设超时,调了就死等
import requests
def ask_llm(prompt):
resp = requests.post(
"https://api.example.com/v1/chat",
json={"model": "gpt-x",
"messages": [{"role": "user", "content": prompt}]},
) # 破绽:没有 timeout —— requests 默认会无限期地等下去
return resp.json()["choices"][0]["message"]["content"]
# 破绽一:上游一旦卡死,这个调用永不返回,死占一个 worker。
# 破绽二:这样的卡死调用攒够几个,并发槽全满,新请求全排不进来。
# 破绽三:本地测不出来 —— 本地上游永远飞快,调用都是秒回。
这段代码在本地开发时表现不错,因为本地你对着的是一个空闲的、近在咫尺的上游:没有别的并发请求和你抢算力,网络就是本机或同机房,每次调用都是干净利落的秒回——"调用会不会卡死"这种事,在本地的环境里根本没有土壤冒出来。它的问题不在某一行代码上——requests.post 的写法完全正确——而在一个被忽略的前提:它默认"这个调用一定会在合理时间内返回"。可线上恰恰相反:上游会排队、会过载、会卡死。于是那串问题就有了解释:服务被冻住,是因为没有 timeout 的调用一旦卡死就永久占着 worker,这样的调用一多,并发槽被占光;而这个 bug 在本地死活复现不出来,正是因为本地的上游永远是"热的、空的、快的"。问题的根子清楚了:做好超时治理的工程量,全在"承认 LLM 调用耗时无上界、且占用着并发资源"之后——你把它当普通函数,它就会在某次上游卡死时,把你的整个服务拖下水。所以第一步,是给这个调用装上超时;但超时不是"一个数",它是三个。
二、三个超时:连接超时、总超时、空闲超时
给 LLM 调用"设超时",很多人脑子里只有一个数——"就设个 30 秒呗"。这正是误杀正常长请求的根源。准确的做法是:"超时"不是一个数,它至少是三个,各管一段不同的事。第一个是连接超时(connect timeout):它管的是"和 API 建立连接"这一小段——TCP 握手、TLS 协商。这一段应该非常快,所以连接超时要设得小而狠:几秒钟连不上,基本就是上游不可达,该立刻失败,没必要再等。第二个是总超时(total timeout):它管的是"这一整次调用"从开始到结束的总时长上限,是一道硬天花板。第三个是空闲超时(idle timeout),专门用在流式场景,下一节细讲。先看前两个怎么配:
import httpx
# 给 LLM 调用配齐多类超时 —— 它们各管一段,不能合成一个数
TIMEOUT = httpx.Timeout(
connect=3.0, # 连接超时:3 秒连不上,即认为上游不可达,快速失败
write=10.0, # 写超时:把请求体发出去的时间上限
read=60.0, # 读超时:等响应数据的时间(非流式即整段响应的上限)
pool=2.0, # 从连接池拿到一个可用连接的等待上限
)
client = httpx.Client(timeout=TIMEOUT)
注意这里connect 和 read 被分开设了,而且connect 远小于 read。这不是随手写的——它们回答的是两个不同的问题。再看怎么用它发起一次非流式调用,并且把两类失败区分开:
def ask_llm(prompt, total_timeout=45.0):
"""非流式调用:connect 快速失败,整段响应有一个总的时间上限。"""
try:
resp = client.post(
"https://api.example.com/v1/chat",
json={"model": "gpt-x",
"messages": [{"role": "user", "content": prompt}]},
timeout=httpx.Timeout(connect=3.0, read=total_timeout,
write=10.0, pool=2.0),
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except httpx.ConnectTimeout:
raise RuntimeError("连接超时:上游可能挂了") # 这一类:上游不可达
except httpx.ReadTimeout:
raise RuntimeError("读超时:上游活着但太慢") # 这一类:上游活着但慢
这里的认知要点是:连接超时和总超时,回答的是两个不同的问题,所以绝不能合并成一个数。连接超时回答的是"我还能不能够到这个服务"——这件事要么很快成功,要么就是上游宕机、网络断了,没有"中间状态",所以它该设得小而决绝:三秒连不上,立即判死,把这个名额赶紧腾出来。总超时回答的是"这件事我整体愿意等多久"——它是一道兜底的硬上限,防止任何一次调用无限拖下去。把这两件事合成一个"30 秒超时",你就同时失去了两边:连接早该 3 秒失败的,被你拖到 30 秒才失败,白占 27 秒名额;而一个正常但需要 40 秒的长输出,又被这 30 秒拦腰砍断。一个数管两件性质完全不同的事,结果就是两件事都管不好。记住:超时不是"等多久"这一个旋钮,它是"连接阶段等多久"和"整体等多久"两个独立的旋钮。但即便连接超时和总超时都配对了,流式输出还有一个它俩都管不到的盲区。
三、流式场景:用空闲超时识别卡死的流
现在看流式输出——大模型应用里最常见的形态:答案一个 token 一个 token 地往外吐。这种场景下,"总超时"会暴露一个致命盲区。设想:一个流式调用,模型吐了 5 秒 token,然后上游卡死,再不吐了。这个流实际上已经死了。可你设的"总超时"是 120 秒——于是接下来整整 115 秒,你的代码傻傻地守着这个早已死掉的流,直到总超时才反应过来。问题出在:总超时管的是"整件事别太久",它根本没法回答"这个流现在还活着吗"。先看这个盲区是怎么来的:
# 反面教材:流式调用只设一个"总超时",卡死的流要等到总超时才发现
async def stream_llm_bad(prompt, total_timeout=120.0):
async with client.stream("POST", URL, json=build_body(prompt)) as resp:
async for chunk in resp.aiter_lines():
yield chunk
# 破绽:模型在第 5 秒后就不再吐 token(上游卡死),
# 可这个流要到第 120 秒(总超时)才被判失败 ——
# 中间 115 秒,你守着一个早就死掉的流干等。
判断"流是不是死了",要的是另一个指标:距离上一个 token,过去了多久。一个健康的流,token 是源源不断、间隔很短地吐出来的;一旦很久没有新 token,这个流多半就卡死了。这个指标对应的超时,就叫空闲超时(idle timeout)——也叫 token 间超时。做法是:不再"等整个流走完",而是每次只"等下一个 chunk",每次最多等 idle_timeout 秒;收到新 chunk 就刷新计时,长时间收不到就判流已死:
import asyncio
async def stream_llm(prompt, idle_timeout=8.0, total_timeout=120.0):
"""流式调用:空闲超时管'流卡死没',总超时管'整体别太久'。"""
loop = asyncio.get_event_loop()
deadline = loop.time() + total_timeout
async with client.stream("POST", URL, json=build_body(prompt)) as resp:
agen = resp.aiter_lines()
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise RuntimeError("总超时:整体生成时间过长")
try:
# 关键:每次只等"下一个 chunk",最多等 idle_timeout 秒
chunk = await asyncio.wait_for(
agen.__anext__(),
timeout=min(idle_timeout, remaining),
)
except StopAsyncIteration:
return # 流正常结束
except asyncio.TimeoutError:
# idle_timeout 内一个新 chunk 都没来 —— 判定流已卡死
raise RuntimeError("空闲超时:流已卡死,上游不再吐 token")
yield chunk # 收到新 chunk,循环回去重新计时
下面这张图,把一次流式调用里"空闲超时 + 总超时"双重把关的过程画出来:
这里的认知要点是:流式场景下,"慢"和"卡死"是两种必须分开对待的状况,而它们要用两个不同的超时来管。"慢"是指这次生成整体耗时长——可能就是输出特别多,这本身不是故障,你只需要一个总超时给它兜一个不至于离谱的上限。"卡死"是指流中途断了气——还没生成完,却长时间一个新 token 都不来,这才是真正的故障。总超时只能感知"慢",感知不了"卡死":一个第 5 秒就断流的调用,在总超时眼里和一个正在认真生成的调用没有任何区别,它只看总时长。空闲超时填的就是这个盲区——它盯的不是"过了多久",而是"距离上一个 token 过了多久"。两个超时同时在岗,你才能做到既不误杀正常的长输出(总超时给得够宽),又能在几秒内就揪出一个卡死的流(空闲超时给得够紧)。但超时触发了,只是你"决定不等了"——这个决定还得真正落到那个正在跑的调用上。
四、取消要能传播:从用户到上游的一条取消链
超时触发,只完成了一半。raise RuntimeError("空闲超时") 这行代码,只是让你自己的函数返回了——它并不会自动让那个正在跑的 LLM 调用停下来。如果你只是抛个异常、却不真正中止底层调用,你会得到一个最尴尬的局面:你这边超时返回、给用户报了错,可后端那个 HTTP 调用还连着上游、还在接收数据、还在烧 token——它变成一个你以为已经结束、其实还活着的僵尸调用。所以超时之后必须有"取消":把这个调用真正中止、把连接关掉。在 asyncio 里,取消是靠 Task 的 cancel() 来传播的——协程被取消时,httpx 的流会随之关闭连接,上游就停了:
import asyncio
async def call_with_cancel(prompt):
"""把 LLM 调用放进一个 Task,这样它能被外部干净地取消。"""
task = asyncio.create_task(stream_and_collect(prompt))
try:
return await task
except asyncio.CancelledError:
# 外部取消了我们 —— 把取消传播进 task,
# httpx 的流在协程被取消时会关闭连接,上游随之停止生成。
task.cancel()
await asyncio.gather(task, return_exceptions=True)
raise
而"取消"还有一个常被忽略的来源——用户自己。用户在前端点了"停止生成",或者直接关掉了页面。这时候后端那个调用应该立刻停下,而不是对着一个早已不在的用户,继续生成、继续计费。在 FastAPI 里,可以用 request.is_disconnected() 探测客户端是否已断开,据此取消在跑的调用:
from fastapi import Request
@app.post("/chat")
async def chat(request: Request, prompt: str):
"""边生成边盯着客户端:用户一断开,立刻取消上游 LLM 调用。"""
llm_task = asyncio.create_task(stream_and_collect(prompt))
while not llm_task.done():
if await request.is_disconnected():
llm_task.cancel() # 用户关了页面 —— 取消,别再烧 token
raise RuntimeError("客户端已断开,调用已取消")
# 等一小会儿,或等 task 先完成,二者谁先到
done, _ = await asyncio.wait({llm_task}, timeout=0.5)
return {"answer": llm_task.result()}
这里的认知要点是:超时和取消,是同一件事的两半,缺了任何一半都不成立。超时是"判断"——你根据连接超时、总超时、空闲超时,得出一个结论:"这次调用我不等了。"取消是"执行"——把这个结论真正落实到那个正在运行的调用上,关掉它的连接,让上游停下来。只做超时判断、不做取消执行,后果是僵尸调用:你的函数返回了,资源却没释放,上游还在跑、token 还在烧,你的并发槽其实并没有真的腾出来——你以为治好了超时,其实只是把"卡死"伪装成了"返回"。还要记住,取消的触发源不止超时一个:用户主动停止、用户关闭页面,同样是一次再明确不过的"取消"信号。一个把取消做扎实的系统,要让取消信号能从两个源头(超时、用户)出发,沿着 Task 一路传播到最底层的 HTTP 连接——这才是一条完整的取消链。取消链通了,还有最后一个问题:每个调用的超时数,到底该填多少?
五、超时预算:把总时长沿调用链分配下去
前面每个调用的超时,我都写了个具体的数(45 秒、120 秒)。但这些数各写各的,其实是个隐患。设想一个真实请求链路:用户的一个问答请求进来,后端要先做检索(retrieve)、再调 LLM,LLM 调用可能还要重试一次。如果检索写死 10 秒、LLM 写死 60 秒、重试再来一次 60 秒——叠起来就是 130 秒。可你对用户承诺的、或者前端网关卡的,可能只有 30 秒。各写各的超时,合起来根本不受控。正确的做法是"超时预算":在请求入口定下一个总预算,然后让这个预算沿着调用链一路传递、一路扣减,每个调用只能用"剩余预算"之内的时间:
import time
class Deadline:
"""一个请求的时间预算:入口处定下,沿调用链一路传递、扣减。"""
def __init__(self, total_budget):
self.expire_at = time.monotonic() + total_budget
def remaining(self):
return self.expire_at - time.monotonic()
def timeout_for(self, want):
"""这次调用想用 want 秒,但绝不能超出剩余预算。"""
left = self.remaining()
if left <= 0:
raise RuntimeError("预算已耗尽,本次调用直接放弃,不再发起")
return min(want, left)
def handle_request(prompt):
dl = Deadline(total_budget=30.0) # 对用户承诺:30 秒内给结果
docs = retrieve(prompt, timeout=dl.timeout_for(5.0)) # 检索最多 5 秒
# LLM 调用:想给 25 秒,但检索若已花掉不少,就只剩多少给多少
return ask_llm(prompt, docs, total_timeout=dl.timeout_for(25.0))
这里的认知要点是:超时不该是每个调用各自拍脑袋写死的一个常量,它该是从请求入口分配下来的一份"预算"。这两种做法的差别,在"组合"的时候才会暴露。各写各的常量:每个调用单看都合理,可一旦它们串成一条链,总时长就是各段之和——你完全失去了对"用户到底要等多久"的控制。预算式:请求入口先定下一个对用户负责的总时长,这个总数沿调用链传递,每经过一个调用就扣掉它实际花的时间,后面的调用只能在"剩下的"里面取。这样无论中间经过多少层、重试多少次,整个请求的总时长都被那个入口预算死死焊住。还有一个常被忽略的好处:预算式天然支持"提前放弃"——如果走到某一步,发现剩余预算已经见底,那就根本不必再发起下一个调用了,直接快速失败,把那几秒也省下来。一个调用链里每个环节都写死超时,叠起来就是没有超时;只有把超时当预算分配,总时长才真正可控。主干到这就齐了,最后是几个真正在线上长期跑 LLM 调用才会撞见的工程坑。
六、工程坑:超时与重试、超时分档、超时可观测
五块设计之外,还有几个工程坑,不处理就会让你的超时治理要么形同虚设、要么帮倒忙。坑 1:重试必须发生在总预算之内。调用超时了,很自然会想重试一次。但重试不能脱离预算:每次重试用的超时,必须是 min(单次合理超时, 剩余预算);预算一旦见底,就别再重试——否则你是在给一个可能已经倒下的上游持续加压:
async def call_with_retry(prompt, deadline, max_attempts=3):
"""重试必须在总预算之内:预算耗尽就停,别再给挂掉的上游加压。"""
for attempt in range(max_attempts):
try:
# 每次重试的超时 = min(单次合理超时, 剩余预算)
return await ask_llm(
prompt, total_timeout=deadline.timeout_for(20.0))
except (httpx.ReadTimeout, httpx.ConnectTimeout):
if deadline.remaining() <= 1.0: # 预算见底,别再重试
raise RuntimeError("预算耗尽,放弃重试")
await asyncio.sleep(min(2 ** attempt, 8)) # 退避后再试
raise RuntimeError("重试耗尽,仍未成功")
坑 2:超时值不是一个全局常量,要按"模型 + 任务"分档。一个只输出 5 个 token 的分类任务,和一个要生成 2000 token 的长文总结,合理超时天差地别。给它们用同一个超时,结果必然是两头不讨好:要么这个数对分类任务太松(它本该几秒就失败,却被允许拖很久),要么对长文任务太紧(正常的长输出被砍断)。所以超时要配置化、分档:
# 超时不是一个常量:不同任务,合理超时天差地别 —— 分档配置
TIMEOUT_PROFILES = {
# 任务类型 连接 空闲 总超时
"classify": {"connect": 3, "idle": 4, "total": 15}, # 短输出,该快
"chat": {"connect": 3, "idle": 8, "total": 60}, # 中等
"long_summary": {"connect": 3, "idle": 12, "total": 180}, # 长输出,给够
}
def timeout_of(task_type):
"""按任务类型取超时配置,而不是全局一个数糊弄所有场景。"""
return TIMEOUT_PROFILES.get(task_type, TIMEOUT_PROFILES["chat"])
坑 3:超时日志要分类,不能只记一个"timeout"。一次失败,到底是连接超时(上游不可达)、总超时(整体太久)、空闲超时(流卡死)、还是用户取消——这四种成因,排查方向和告警策略完全不同。日志里只写一个"timeout",等于什么都没说:
import logging
logger = logging.getLogger("llm.timeout")
def classify_failure(exc):
"""超时不能只记一个 timeout —— 四类成因,处理与告警完全不同。"""
if isinstance(exc, httpx.ConnectTimeout):
return "connect_timeout" # 上游不可达 —— 该查上游死没死
if isinstance(exc, httpx.ReadTimeout):
return "total_timeout" # 整体太久 —— 看是不是输出过长
if isinstance(exc, asyncio.TimeoutError):
return "idle_timeout" # 流卡死 —— 上游中途断流
if isinstance(exc, asyncio.CancelledError):
return "user_cancelled" # 用户主动取消 —— 不是故障,别误告警
return "other"
def record(task_type, exc, elapsed):
kind = classify_failure(exc)
logger.warning("llm_fail type=%s task=%s elapsed=%.1fs",
kind, task_type, elapsed)
metrics.incr(f"llm.fail.{kind}") # 分类打点:connect 飙升和 idle 飙升要分开看
坑 4:别让"超时"变成"无限重试的扳机"。超时了就重试、重试又超时、再重试……如果上游是真的挂了,你这套循环就是在给一个倒下的服务持续加压,只会让它更难恢复。超时之后的重试,必须有次数上限,并且要配合熔断——上游连续超时到一定程度,就直接快速失败一段时间,给它喘息的机会。坑 5:同步阻塞调用 + 线程池,取消是取消不掉的。如果你用同步的 requests 丢进线程池跑 LLM 调用,要清楚一件事:asyncio 的 cancel(),取消不了一个正卡在阻塞 I/O 里的线程——那个线程会一直跑到 requests 自己的超时为止。所以要让取消真正生效,要么用异步 HTTP 客户端(httpx/aiohttp 的 async 接口),要么就接受这个事实:线程池里的同步调用,"取消"只能等它自己的超时到——这种情况下,给同步调用本身设一个不太长的超时,就更加重要了。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 耗时无上界 | LLM 调用耗时方差极大,慢甚至永不返回都是常态 |
| 并发槽占用 | 未超时的卡死调用会一直占住 worker,拖垮整个服务 |
| 连接超时 | 建立连接的时间上限,设得小而狠,连不上即快速失败 |
| 总超时 | 一次调用从开始到结束的整体时间硬上限 |
| 空闲超时 | 流式场景两个 chunk 间的最长间隔,超了即判流卡死 |
| 慢 vs 卡死 | 慢用总超时兜底,卡死用空闲超时识别,两者不可混为一谈 |
| 取消传播 | 超时或用户断开后,要真正关闭上游连接,而非只本地报错 |
| is_disconnected | 检测客户端断开,据此取消正在跑的 LLM 调用 |
| 超时预算 | 请求入口定下总预算,沿调用链传递扣减,约束总时长 |
| 超时分类 | 区分 connect/total/idle/cancelled,四类成因处理各不同 |
避坑清单
- LLM 调用必须设超时,不设超时的调用会无限期占住并发槽。
- 分清连接超时和总超时:连接超时要小要狠,总超时是整体硬上限。
- 流式调用必须有空闲超时,否则卡死的流要等到总超时才被发现。
- 空闲超时管"流卡没卡死",总超时管"整体别太久",两者都要有。
- 超时之后要做取消传播,真正关闭上游连接,别留僵尸调用。
- 检测客户端断开,用户一走就取消调用,别再对着空气烧 token。
- 超时值从请求入口的总预算分配下来,别每个调用各写死一个常量。
- 重试必须在总预算内进行,预算耗尽就停止重试,别给上游加压。
- 超时值按模型与任务分档配置,分类任务和长文生成不能共用。
- 超时日志分类记录 connect/total/idle/cancelled,并分别打点告警。
总结
回头看那串"服务被卡死调用冻住、正常长请求被误杀、守着死流干等、用户走了还在烧 token"的问题,以及我后来在超时治理上接连踩的坑,最该记住的不是某一个超时参数填多少,而是我动手前那个想当然的判断——"调用大模型,跟调用一个普通函数没区别,调了等它返回就行"。这句话错在它用普通函数的直觉去套 LLM 调用。我以为调用发出去,结果总会很快回来。可我忽略了一件事:一个 LLM 调用,和一个普通函数调用是两种东西。普通函数的耗时确定、有上界,你从不担心它"会不会回来";而 LLM 调用的耗时没有上界,它可能很慢,也可能永远不返回——而且在它挂着的整段时间里,它一直死死占着你服务里一个有限的并发槽。"调了就等它返回"这句话里,藏着一个对 LLM 调用根本不成立的假设:它一定会在合理时间内回来。
所以做好 LLM 调用超时治理,真正的工程量不在"给调用补一个 timeout 参数"那一下上。补一个数,谁都会。真正的工程量,在于你要承认"LLM 调用耗时无上界、可能永不返回、且持续占用并发资源",并据此把每一类情况都安排妥当:连接阶段要快速失败,你就给连接超时设一个小而狠的值;整体不能拖太久,你就给总超时设一道硬上限;流式输出会中途卡死,你就用空闲超时去盯"距离上一个 token 多久了";超时或用户离开后调用要真停,你就把取消信号沿 Task 一路传到上游连接;整条链路的总时长要可控,你就把超时当成一份从入口分配下来的预算。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"调了就等它返回"为什么错,再讲三类超时各管什么、流式为什么要空闲超时、取消怎么传播、超时预算怎么分配,最后是超时与重试、超时分档、超时可观测这几个把治理守扎实的工程细节。
你会发现,LLM 调用超时治理,和现实里"委托一个跑腿的人,去外地替你办一件事"完全相通。一个粗心的委托人会怎么做?他把人派出去,然后就在家干等——人没消息?继续等。他默认"派出去了,事情总会办成、人总会回来"。可万一那人路上出了岔子、到了地方又失了联,他就会这样无限地等下去,什么也做不了(这就是不设超时的调用)。而一个讲究的委托人怎么做?第一,他会约定一个总时限——"天黑前必须给我回话"(这就是总超时);第二,他知道人要是连那座城都进不去(联系不上),那这事基本就黄了,不必等到天黑,立刻就该知道(这就是连接超时,小而狠);第三,也是最容易被忽略的一点——人到了地方之后,得让他每隔一阵就报个平安:"我还在办。"要是他半天音讯全无(不是还在赶路,是到了之后失了联),那多半是出事了,别再傻等(这就是空闲超时,识别"卡死"而不只是"慢");第四,这事要是临时不办了,他得有办法把消息递到那个跑腿人手上——"别办了,回来吧",而不是让他继续白跑、白花钱(这就是取消传播)。同样是委托一件自己控制不了快慢的事,可粗心的委托人把"派出去"当成了全部,讲究的委托人把功夫全下在了"派出去之后"——差别不在"事派不派得出去"这件事本身,只在他认不认"委托一件耗时不由我掌控的事,真正的治理全在派出去之后:设时限、分阶段确认、能及时叫停"这件事。
最后想说,超时治理做没做对,差距永远不会在"本地开发、对着一个空闲又近在咫尺的上游"时暴露——本地没有别的请求和你抢算力,网络就在本机,每次调用都干脆利落地秒回,"调用会卡死"这种事根本没有土壤,你会觉得"发请求、等返回"已经够用。它只在真实的、上游会排队会过载、网络会抖动会黑洞、成百上千个用户在并发地调的时候才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为几个没设超时的卡死调用,把整个服务的并发槽占光、让服务彻底冻住,会因为一个总超时一刀切,既误杀正常的长输出、又对卡死的流毫无察觉,还会对着早已关掉页面的用户,继续生成、继续计费;而做对了,你的每一次调用都有连接、总时长、空闲三道超时各司其职,卡死的流几秒内就被揪出,用户一离开调用立刻停止,整条链路的总时长被入口预算稳稳焊住。所以别等"一次上游卡死把整个服务拖垮"那一刻找上门,在你写下每一行 LLM 调用的时候就该想清楚:这个调用设超时了吗、连接超时和总超时分开了吗、流式有没有空闲超时、超时后取消传到上游了吗、它的超时是不是从总预算里分配的,这一道道工序,我是不是都替它想过了?这些问题有了答案,你交付的才不只是一段"本地能把答案问出来"的调用代码,而是一套经得起上游卡死、网络抖动和高并发考验的可靠 LLM 调用。
—— 别看了 · 2026