模型推理服务如果一直采用同步阻塞方式,客户端调用后必须等模型计算完才能收到响应。短文本分类通常几十毫秒就能返回,但换成大语言模型长文本生成、图像生成或批量推理,单次计算可能从几秒到几分钟不等。一旦客户端、API网关或负载均衡器设置的超时时间比实际推理时间短,就会出现请求被判定超时,而服务端仍在占用GPU显存继续计算的情况。

一、推理超时发生在哪些环节
推理请求的链路通常包含客户端、反向代理或API网关、Web框架、推理引擎四个环节。每个环节都可能配置独立的超时时间。客户端常见的有连接超时和读取超时;Nginx这类反向代理常用 proxy_connect_timeout 和 proxy_read_timeout;FastAPI或Flask会设置请求处理超时;推理引擎内部还有模型加载、数据预处理、前向计算和后处理等阶段。
同步推理接口最容易出现的问题,是读取超时设置得太短。比如Nginx默认的 proxy_read_timeout 是60秒,而一个长文本推理需要90秒,那么Nginx会在60秒时断开连接,但FastAPI和模型推理进程并不知情,仍然继续占用GPU。客户端拿到的是不完整的响应或504错误。要复现这个问题,可以写一个简单的同步推理接口:
import time
from fastapi import FastAPI
app = FastAPI()
@app.post("/infer-sync")
def infer_sync(prompt: str):
# 模拟模型推理耗时
time.sleep(85)
return {"result": "generated text"}
当这个接口部署在默认Nginx后面时,请求会在60秒被切断。即使把Nginx的 proxy_read_timeout 调大,也只是把问题往后推,客户端仍然要一直占用连接等待结果。真正的解法并不是无限放宽超时,而是把长耗时推理从同步链路中剥离出来。
二、超时时间应该如何分层设置
设置超时时间不能只盯着一个参数,至少要区分连接超时、首字节超时和整体完成超时三个维度。连接超时通常设置为3到10秒,它只代表网络层能否建立连接,和推理速度无关。首字节超时对推理服务来说意义不大,因为模型可能在做预处理,建议设置得宽松一些。整体完成超时则需要根据模型的实际耗时分布来设置。
以文本生成推理为例,如果监控显示P95耗时是12秒、P99耗时是35秒,客户端读取超时最好不要低于45秒,否则会有1%的正常请求被误判为超时。对于图像生成类任务,可以把超时设置到5分钟以上。超时时间不是越短越好,盲目收紧会让用户在模型即将返回结果时被迫重试,既浪费GPU又降低体验。下面是一个用httpx设置分层超时的例子:
import httpx
timeout = httpx.Timeout(
connect=5.0, # 连接超时 5 秒
read=120.0, # 读取超时 120 秒
write=30.0, # 上传超时 30 秒
pool=10.0, # 连接池超时 10 秒
)
with httpx.Client(timeout=timeout) as client:
resp = client.post(
"http://127.0.0.1:8000/infer-sync",
json={"prompt": "hello"},
)
服务端同样需要自己的超时保护。如果模型推理卡死,服务端不能无限等下去。可以在FastAPI里使用AnyIO的 fail_after 来给单个推理调用加一个硬超时,超过时间直接抛出异常并释放资源。对于更复杂的场景,可以用任务队列自带的超时参数,比如Celery的 task_time_limit,让worker在超过指定秒数后强制终止任务。
三、异步任务切换:从阻塞等待到任务ID轮询
异步任务切换的核心思路是:客户端提交推理请求后,服务端立即返回一个任务ID,推理在后台执行。客户端可以稍后通过任务ID查询状态,或者提供一个回调地址让服务端完成时通知。这样做最大的好处是,客户端不再需要为一个几十秒甚至几分钟的推理保持一条连接,API网关的超时设置也不再那么敏感。
在FastAPI中,最轻量的做法是使用 BackgroundTasks。不过 BackgroundTasks 适合短小的后台操作,如果任务执行时间较长或者需要考虑进程重启,更推荐使用独立的队列和worker。下面是一个使用Redis队列的示例,提交接口只负责把任务写入队列,执行由后台worker完成:
import uuid
import redis
from fastapi import FastAPI
app = FastAPI()
r = redis.Redis(host="127.0.0.1", port=6379, db=0)
@app.post("/infer-async")
def infer_async(prompt: str):
task_id = str(uuid.uuid4())
r.lpush("infer_queue", task_id + "|" + prompt)
r.hset("task_status", task_id, "pending")
return {"task_id": task_id, "status": "queued"}
后台worker从队列中取出任务,更新状态为running,执行模型推理,完成后把状态改为success并保存结果。客户端可以使用 GET /task/{task_id} 接口轮询。轮询间隔建议采用递增策略,比如前10秒每2秒查询一次,之后每5秒查询一次。对于长时间任务,Webhook回调比轮询更省资源,但要注意回调失败后的补偿重试。
四、异步任务的超时控制、重试与状态持久化
异步化之后,超时控制并没有消失,只是从网络层转移到了任务层。任务不能无限期停留在running状态,必须设置任务级超时。worker在执行任务时记录开始时间,如果当前时间减去开始时间超过阈值,就将任务标记为timeout,并尝试结束推理进程。这个阈值可以比同步接口的整体超时更宽松一些,但必须有上限。
任务状态建议设计成清晰的状态机:pending表示已入队,running表示正在执行,success表示成功,failed表示失败,timeout表示超时。所有状态变化都要持久化到Redis或数据库中。这样即使worker崩溃,重启后也能根据持久化状态判断哪些任务需要重新入队,哪些已经成功。下面是一段worker端的简化逻辑:
import time
import redis
r = redis.Redis(host="127.0.0.1", port=6379, db=0)
TASK_TIMEOUT = 180 # 任务级超时:3 分钟
def run_task(task_id: str, prompt: str):
r.hset("task_status", task_id, "running")
start = time.time()
try:
# 调用实际推理函数
result = model_infer(prompt, timeout=TASK_TIMEOUT)
r.hset("task_status", task_id, "success")
r.hset("task_result", task_id, result)
except TimeoutError:
r.hset("task_status", task_id, "timeout")
except Exception:
r.hset("task_status", task_id, "failed")
重试策略上,不要对所有失败都无条件重试。如果错误是因为输入长度超限或模型文件缺失,重试只会浪费资源。只有网络抖动、显存临时不足等可恢复错误才适合重试,并且要设置最大重试次数。一个常见的做法是失败后等待几秒再重试,第二次失败等待更长时间,采用指数退避。比如第一次退避5秒,第二次15秒,第三次30秒,超过三次彻底标记失败。
结果持久化同样重要。如果结果只存在worker进程内存里,进程重启或扩容缩容后客户端就查不到结果了。可以把推理结果写入对象存储或数据库,任务表只保存结果引用。对于大文本或图片结果,建议返回一个可下载的地址,而不是把所有内容塞进任务状态接口里。
五、实际改造时容易忽略的细节
从同步切换到异步后,原来的调用方代码也要调整。很多客户端库默认会等待完整响应,改成任务ID模式后,需要增加轮询或回调逻辑。前端页面不能因为请求返回202就停止处理,要展示任务进度和状态变化。服务端还要考虑任务队列堆积问题,如果并发量突然升高,队列长度增加,任务执行延迟会进一步拉大。可以给队列设置最大长度,超过阈值时直接拒绝新的推理请求,返回稍后重试。
另一个容易忽略的问题是结果过期清理。异步任务完成后,如果客户端一直不查询或者任务状态一直保存在Redis中,会逐渐堆积占用内存。可以给任务状态设置TTL,比如24小时,过期后自动删除。对于需要长期保存的结果,在客户端成功获取后主动删除中间状态,只保留必要的日志。这样既控制资源占用,也不会让任务查询接口变得臃肿。
最后要强调的是,异步化不是万能方案。对于延迟要求极高的在线推理,比如实时对话、自动补全,异步任务会增加一次网络往返和轮询成本,可能反而降低体验。这类场景更适合优化模型本身,比如使用更小的模型、量化、增加批处理或采用流式输出。只有耗时明显超过用户可接受等待时间的推理任务,才真正需要接入异步任务队列。