模型调用的延迟预算:默认无超时、退避、Retry-After 与重试叠加实测
上线评审里常有这样一句话:“我们把 timeout 设成了 30 秒,所以一次模型调用最多卡 30 秒。”这句话在四个地方是错的:默认客户端根本没有超时;重试之间的退避会让总时长远大于 timeout × 次数;服务端返回的 Retry-After 会决定你等多久;而流式回复里,timeout 约束的是“两次读取之间的沉默”,不是总时长。
本页写给要为模型调用估算最坏耗时、或者正在排查“请求卡了几分钟”的开发者。每个结论都配了一个本地可重复的实验,最后给出一个可以放进发布流程的预算脚本。
本页没有测试什么
本页没有连接任何真实的模型服务。 实验里的服务端是一个只用标准库写的本地程序,只负责按脚本制造延迟、错误状态码和流式停顿。因此以下内容不在本页范围:真实提供方的延迟分布与限流策略、价格与计费(超时的请求提供方是否继续生成并计费,客户端无法控制,本页不测)、推理类模型很长的首包延迟、Responses API、Anthropic 等其他提供方的 SDK(它们的重试规则各不相同)、代理与 DNS 引入的延迟、连接池耗尽,以及 LangGraph 节点级的重试与超时配置。
本页只回答一个问题:当 ChatOpenAI 的下游出问题时,一次调用最多会占用多少时间、发出多少请求。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.12.12 |
| 包版本 | langchain==1.4.7、langchain-core==1.7.0、langchain-openai==1.7.2、openai==3.37.0、httpx2==2.16.0、langgraph==1.2.17 |
| 被测对象 | ChatOpenAI(langchain-openai)及其内部的 openai SDK 重试循环,外加 ModelRetryMiddleware;服务端是站点仓库 labs/model-call-latency-budget/latency_server.py 提供的本地程序,不联网、不需要密钥 |
| 官方来源 | ChatOpenAI 集成文档 (opens in a new tab)、内置 middleware 文档 (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16);重试细节来自已安装的 openai-python (opens in a new tab) 源码 _base_client.py 与 _constants.py |
| 实验代码 | 站点仓库 labs/model-call-latency-budget/,依赖锁定在 labs/requirements/v1.txt |
重试的退避公式、Retry-After 的处理规则都属于 openai SDK 的实现细节,不同大版本之间可能变化。升级 SDK 后请重跑本页的实验,不要沿用这里的数字。
先说结论
ChatOpenAI()默认没有任何超时。 HTTP 层是Timeout(timeout=None),连接、读取、写入、连接池全部不限;这和直接使用openai.OpenAI()时的默认值(读写 600 秒、连接 5 秒)不同。重试次数则沿用 SDK 的默认值 2(实验 1)。- 最坏延迟不是
timeout × 尝试次数。 超时 0.3 秒、max_retries=3时,朴素估算是 1.2 秒,实测落在 3.8 到 4.7 秒,因为每次重试之间还有指数退避,从 0.5 秒起步、每次翻倍、8 秒封顶(实验 2)。 - 服务端的
Retry-After直接决定等多久。 返回Retry-After: 2,客户端就等 2 秒;retry-after-ms: 1500等 1.5 秒;Retry-After: 0不被当作“立刻重试”,仍回到约 0.5 秒的退避;超过 120 秒的值会让 SDK 直接放弃重试并抛错(实验 3)。 x-should-retry响应头会覆盖状态码判断。 503 加false不重试,400 加true会重试;网关或代理加这个头时要知道它的后果(实验 3)。timeout不是总时长上限。 一个持续 2 秒的流式回复,在 0.5 秒的timeout下顺利完成;流在中途停住则抛出超时错误,不会重试,已收到的片段留在客户端(实验 4)。- 要限制整次调用的总时长,必须在外面加截止时间。
asyncio.wait_for取消调用后,SDK 的重试循环真的停了,不会留下继续发请求的“僵尸重试”(实验 5)。 - 两层重试会相乘。 SDK 默认 2 次重试,再套一层
max_retries=2的ModelRetryMiddleware,一次失败的调用会向服务端发出 9 个请求(实验 6)。
实验 0:本地服务端与计时工具
服务端可以按参数制造延迟、对前 N 个请求返回指定状态码和响应头、在流式回复中途停住。它记录收到的请求数,实验用这个数字判断“发了几次请求”:
"""A local OpenAI-compatible server with scriptable latency, error statuses and streaming stalls.
It exists to measure how long one model call can take when the provider misbehaves. Only the
standard library is used; the server binds to 127.0.0.1 and never sees a real credential.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class LatencyServer:
"""Behavior knobs (all optional):
delay seconds to wait before answering every request
fail_first list of (status, headers) used for the first N requests, then 200
always_status answer every request with this status (headers from always_headers)
stream_chunks number of content chunks in a streamed reply
stream_interval seconds between streamed chunks
stall_after stop sending after this many chunks (None = never stall)
stall_seconds how long the stall lasts before the stream resumes
"""
def __init__(self, delay=0.0, fail_first=(), always_status=None, always_headers=None,
stream_chunks=5, stream_interval=0.0, stall_after=None, stall_seconds=0.0):
self.delay = delay
self.fail_first = list(fail_first)
self.always_status = always_status
self.always_headers = dict(always_headers or {})
self.stream_chunks = stream_chunks
self.stream_interval = stream_interval
self.stall_after = stall_after
self.stall_seconds = stall_seconds
self.count = 0
self._lock = threading.Lock()
outer = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # keep stdout clean
pass
def do_POST(self):
try:
self._handle()
except (BrokenPipeError, ConnectionResetError):
pass # the client gave up (timeout or cancellation)
except Exception as exc: # a bug in this fake must surface as a readable 500
self._json(500, {"error": {"message": f"latency server bug: {exc!r}"}})
def _handle(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
with outer._lock:
outer.count += 1
seen = outer.count
if outer.delay:
time.sleep(outer.delay)
if outer.always_status is not None:
return self._error(outer.always_status, outer.always_headers)
if seen <= len(outer.fail_first):
status, headers = outer.fail_first[seen - 1]
return self._error(status, headers)
if body.get("stream"):
return self._stream()
return self._json(200, {
"id": "c1", "object": "chat.completion", "created": 0, "model": "fake-model",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
def _error(self, status, headers):
self._json(status, {"error": {"message": f"scripted status {status}", "type": "scripted"}}, headers)
def _json(self, status, payload, headers=None):
data = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(data)
def _stream(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
for index in range(outer.stream_chunks):
if outer.stall_after is not None and index == outer.stall_after:
time.sleep(outer.stall_seconds)
chunk = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": "fake-model",
"choices": [{"index": 0, "delta": {"content": f"w{index} "}, "finish_reason": None}]}
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
time.sleep(outer.stream_interval)
done = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": "fake-model",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
self.wfile.write(f"data: {json.dumps(done)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.server.server_address[1]}/v1"
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.server.shutdown()
self.server.server_close()common.py 构造指向本地服务端的 ChatOpenAI,并提供计时函数。所有 API 密钥都是占位字符串;实验运行器还会清除 OPENAI_、LANGSMITH_ 等环境变量,确保脚本不会误连真实服务:
"""Shared helpers: build a client against the local server and time one call."""
import os
import time
os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")
from langchain_openai import ChatOpenAI
KEY = "placeholder-api-key-0001"
def make_llm(server, **kwargs) -> ChatOpenAI:
return ChatOpenAI(model="fake-model", base_url=server.base_url, api_key=KEY, **kwargs)
def timed(call):
"""Run `call()` and return (elapsed_seconds, 'ok' or the exception class name)."""
start = time.perf_counter()
try:
call()
outcome = "ok"
except Exception as exc:
outcome = type(exc).__name__
return time.perf_counter() - start, outcome
def half(seconds: float) -> float:
"""Round to the nearest half second so jitter does not make recorded output unstable."""
return round(seconds * 2) / 2实验 1:一个新建的客户端到底带着什么设置
defaults_probe.py 不发任何请求,只读取客户端对象上的实际配置,并和官方 SDK 的默认值放在一起:
"""What timeout and retry settings does a freshly built client really carry?"""
import openai
from langchain_openai import ChatOpenAI
KEY = "placeholder-api-key-0001"
def describe(label: str, sdk_client, lc_timeout="-", lc_retries="-") -> None:
http_timeout = sdk_client._client.timeout
print(f"{label}")
print(f" LangChain request_timeout / max_retries : {lc_timeout} / {lc_retries}")
print(f" SDK client.timeout / max_retries : {sdk_client.timeout} / {sdk_client.max_retries}")
print(f" HTTP layer timeout : {http_timeout}")
describe("openai.OpenAI() with defaults", openai.OpenAI(api_key=KEY))
for label, kwargs in [
("ChatOpenAI() with defaults", {}),
("ChatOpenAI(timeout=30)", {"timeout": 30}),
("ChatOpenAI(max_retries=0)", {"max_retries": 0}),
("ChatOpenAI(timeout=(3, 30))", {"timeout": (3, 30)}),
]:
llm = ChatOpenAI(model="fake-model", api_key=KEY, **kwargs)
describe(label, llm.root_client, llm.request_timeout, llm.max_retries)openai.OpenAI() with defaults
LangChain request_timeout / max_retries : - / -
SDK client.timeout / max_retries : Timeout(connect=5.0, read=600, write=600, pool=600) / 2
HTTP layer timeout : Timeout(connect=5.0, read=600, write=600, pool=600)
ChatOpenAI() with defaults
LangChain request_timeout / max_retries : None / None
SDK client.timeout / max_retries : None / 2
HTTP layer timeout : Timeout(timeout=None)
ChatOpenAI(timeout=30)
LangChain request_timeout / max_retries : 30.0 / None
SDK client.timeout / max_retries : 30.0 / 2
HTTP layer timeout : Timeout(timeout=30.0)
ChatOpenAI(max_retries=0)
LangChain request_timeout / max_retries : None / 0
SDK client.timeout / max_retries : None / 0
HTTP layer timeout : Timeout(timeout=None)
ChatOpenAI(timeout=(3, 30))
LangChain request_timeout / max_retries : (3.0, 30.0) / None
SDK client.timeout / max_retries : (3.0, 30.0) / 2
HTTP layer timeout : Timeout(connect=3.0, read=30.0, write=None, pool=None)openai.OpenAI():连接超时 5 秒,读、写、连接池 600 秒,重试 2 次。这是 SDK 作者给的保守默认值。ChatOpenAI():LangChain 把request_timeout=None原样交给 SDK,HTTP 层成了Timeout(timeout=None),在 httpx 里这个值表示关闭超时。官方集成文档的示例代码把timeout=None、max_retries=2写成注释里的默认值,与这里的实测一致。也就是说,默认的ChatOpenAI面对一个永远不回包的服务端,会一直等下去。ChatOpenAI(timeout=30):这个数字同时作用于连接、读取、写入和连接池四项。ChatOpenAI(max_retries=0):只关闭了重试,超时仍然是“无限”。关掉重试不会让一次调用变得有界。ChatOpenAI(timeout=(3, 30)):元组的含义是(连接,读取)。写入和连接池仍是None,不受限。
实验 2:最坏延迟到底是多少
先把模型写成一个小函数。一次失败的调用,每次尝试最多占用 timeout,两次尝试之间 SDK 睡眠 min(0.5 × 2^k, 8) 秒,再被随机减去 0 到 25%。budget.py 同时是预算计算器和发布门禁,不带参数时打印一张参考表:
"""Worst-case latency of one failing model call, and a release gate built on it.
The model mirrors the OpenAI Python SDK retry loop observed in this lab (see retry_budget.py):
attempt i is cut off after `timeout`; between attempts the SDK sleeps min(0.5 * 2**k, 8) seconds
reduced by a random 0-25% jitter. A server-sent Retry-After is deliberately not modelled: it can
only replace the backoff, never shorten the timeout (see retry_after.py).
Usage:
python budget.py # print the reference table
python budget.py --timeout 30 --retries 2 --limit 120 # gate: exit 1 when the worst case exceeds the limit
"""
import argparse
import math
import sys
INITIAL_DELAY = 0.5
MAX_DELAY = 8.0
JITTER = 0.25
def backoff_total(retries: int) -> float:
"""Nominal (no jitter) sleep time spent between the attempts of one SDK call."""
return sum(min(INITIAL_DELAY * 2**k, MAX_DELAY) for k in range(retries))
def latency_range(timeout: float, retries: int, wrapper_retries: int = 0, wrapper_delay: float = 0.0):
"""Return (low, high) seconds for a call that fails on every attempt.
`wrapper_retries` is an outer retry layer (for example ModelRetryMiddleware) that re-runs the
whole SDK call, so the two layers multiply.
"""
calls = 1 + wrapper_retries
attempts = calls * (1 + retries)
nominal = calls * backoff_total(retries)
waiting = wrapper_retries * wrapper_delay
low = attempts * timeout + nominal * (1 - JITTER) + waiting
high = attempts * timeout + nominal + waiting
return low, high
def fmt(seconds: float) -> str:
return "unbounded" if math.isinf(seconds) else f"{seconds:.1f}s"
def parse_timeout(text: str) -> float:
return math.inf if text.lower() == "none" else float(text)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--timeout", type=parse_timeout, help="per-attempt timeout in seconds, or 'none'")
parser.add_argument("--retries", type=int, default=2, help="SDK max_retries (default 2)")
parser.add_argument("--wrapper-retries", type=int, default=0, help="outer retry layer attempts")
parser.add_argument("--wrapper-delay", type=float, default=0.0, help="sleep between outer attempts; use the largest one for a conservative bound")
parser.add_argument("--limit", type=float, help="latency budget in seconds; enables gate mode")
args = parser.parse_args()
if args.limit is None:
print(f"{'timeout':>9} {'max_retries':>12} {'attempts':>9} {'worst case (no Retry-After)':>30}")
for timeout, retries in [(math.inf, 2), (600.0, 2), (30.0, 2), (30.0, 0), (10.0, 1), (5.0, 2), (5.0, 0)]:
low, high = latency_range(timeout, retries)
span = fmt(high) if math.isinf(high) else f"{fmt(low)} .. {fmt(high)}"
shown = "none" if math.isinf(timeout) else f"{timeout:g}s"
print(f"{shown:>9} {retries:>12} {1 + retries:>9} {span:>30}")
return 0
if args.timeout is None:
parser.error("--timeout is required in gate mode")
low, high = latency_range(args.timeout, args.retries, args.wrapper_retries, args.wrapper_delay)
attempts = (1 + args.wrapper_retries) * (1 + args.retries)
verdict = "PASS" if high <= args.limit else "FAIL"
print(f"requests per failed call: {attempts}")
print(f"worst case: {fmt(high)} (best of the worst: {fmt(low)}) limit: {fmt(args.limit)} -> {verdict}")
return 0 if verdict == "PASS" else 1
if __name__ == "__main__":
sys.exit(main()) timeout max_retries attempts worst case (no Retry-After)
none 2 3 unbounded
600s 2 3 1801.1s .. 1801.5s
30s 2 3 91.1s .. 91.5s
30s 0 1 30.0s .. 30.0s
10s 1 2 20.4s .. 20.5s
5s 2 3 16.1s .. 16.5s
5s 0 1 5.0s .. 5.0s这张表是计算结果,不是实测。读法:
- 第一行是默认的
ChatOpenAI():没有上界; - 第二行把
timeout设成 SDK 自己的默认值 600 秒:三次尝试加退避约 30 分钟; - 其余各行是你显式设置后的最坏时长。注意
timeout=30, max_retries=2的最坏值是 91 秒出头,而不是 30 秒。
模型对不对,要用实验验证。retry_budget.py 让服务端每次都比 timeout 慢(每次尝试都超时),分别测 max_retries 为 0 到 3 时的总耗时,并检查它是否落在模型预测的区间内:
"""Measure how long a call that times out on every attempt really takes, and compare with budget.py."""
from budget import latency_range
from common import make_llm, timed
from latency_server import LatencyServer
TIMEOUT = 0.3
SERVER_DELAY = 1.0 # always longer than TIMEOUT, so every attempt times out
SLACK = 0.5 # scheduling overhead allowed above the model's upper bound
print(f"every attempt times out: timeout={TIMEOUT}s, server delay={SERVER_DELAY}s")
for retries in (0, 1, 2, 3):
with LatencyServer(delay=SERVER_DELAY) as server:
elapsed, outcome = timed(lambda: make_llm(server, timeout=TIMEOUT, max_retries=retries).invoke("hi"))
sent = server.count
low, high = latency_range(TIMEOUT, retries)
naive = (1 + retries) * TIMEOUT
print(
f"max_retries={retries} requests={sent} outcome={outcome} "
f"naive (1+retries)*timeout={naive:.1f}s model={low:.1f}..{high:.1f}s "
f"measured within model: {low <= elapsed <= high + SLACK} "
f"measured above naive by more than 0.3s: {elapsed - naive > 0.3}"
)every attempt times out: timeout=0.3s, server delay=1.0s
max_retries=0 requests=1 outcome=OpenAITimeoutError naive (1+retries)*timeout=0.3s model=0.3..0.3s measured within model: True measured above naive by more than 0.3s: False
max_retries=1 requests=2 outcome=OpenAITimeoutError naive (1+retries)*timeout=0.6s model=1.0..1.1s measured within model: True measured above naive by more than 0.3s: True
max_retries=2 requests=3 outcome=OpenAITimeoutError naive (1+retries)*timeout=0.9s model=2.0..2.4s measured within model: True measured above naive by more than 0.3s: True
max_retries=3 requests=4 outcome=OpenAITimeoutError naive (1+retries)*timeout=1.2s model=3.8..4.7s measured within model: True measured above naive by more than 0.3s: True- 请求数等于
1 + max_retries,每次都是完整的一次尝试。 - 实测值落在模型区间内。 随着重试次数增加,朴素估算
(1 + retries) × timeout和实际值的差距越拉越大:max_retries=3时朴素估算 1.2 秒,模型区间 3.8 到 4.7 秒,退避时间已经是超时时间的两倍多。 timeout越小,退避占比越大。 本实验里timeout只有 0.3 秒,所以退避显得特别突出;真实场景里timeout往往是几十秒,退避的占比会小一些,但仍然存在,而且封顶 8 秒会让后几次重试的退避保持不变。- 模型的前提:每次尝试都耗尽
timeout,且服务端没有返回Retry-After。如果服务端返回了,等待时间由它决定,见下一个实验。
实验 3:服务端的响应头会改变等待时间
retry_after.py 让服务端先失败一次,再正常回复,观察不同响应头下客户端等了多久、发了几个请求。耗时四舍五入到 0.5 秒,避免退避的随机抖动让记录不稳定:
"""How do server headers change retry timing? One scripted failure, then success."""
from common import half, make_llm, timed
from latency_server import LatencyServer
CASES = [
("429, no header", 429, {}),
("429, Retry-After: 0", 429, {"Retry-After": "0"}),
("429, Retry-After: 2", 429, {"Retry-After": "2"}),
("429, retry-after-ms: 1500", 429, {"retry-after-ms": "1500"}),
("429, Retry-After: 130", 429, {"Retry-After": "130"}),
("503, x-should-retry: false", 503, {"x-should-retry": "false"}),
("400, x-should-retry: true", 400, {"x-should-retry": "true"}),
]
print("one scripted failure, then a normal 200 reply; client uses timeout=5, default max_retries")
print(f"{'scripted failure':<30} {'requests':>8} {'outcome':<22} {'elapsed (to 0.5s)':>17}")
for label, status, headers in CASES:
with LatencyServer(fail_first=[(status, headers)]) as server:
elapsed, outcome = timed(lambda: make_llm(server, timeout=5).invoke("hi"))
sent = server.count
print(f"{label:<30} {sent:>8} {outcome:<22} {half(elapsed):>17.1f}")one scripted failure, then a normal 200 reply; client uses timeout=5, default max_retries
scripted failure requests outcome elapsed (to 0.5s)
429, no header 2 ok 0.5
429, Retry-After: 0 2 ok 0.5
429, Retry-After: 2 2 ok 2.0
429, retry-after-ms: 1500 2 ok 1.5
429, Retry-After: 130 1 OpenAIRateLimitError 0.0
503, x-should-retry: false 1 OpenAIAPIError 0.0
400, x-should-retry: true 2 ok 0.5逐行解读:
- 429,没有响应头:重试了一次,等了约 0.5 秒,这是第一次退避。
- 429,
Retry-After: 0:和没有响应头一样,仍然等约 0.5 秒。SDK 只采纳大于 0 的值,0不会被当作“立刻重试”。 - 429,
Retry-After: 2:等了 2 秒。服务端说等多久,客户端就等多久。 - 429,
retry-after-ms: 1500:等了 1.5 秒。毫秒版本的响应头优先于秒版本。 - 429,
Retry-After: 130:只发了 1 个请求,立刻抛出OpenAIRateLimitError。这个 SDK 版本不接受超过 120 秒的等待要求,宁可放弃重试。 - 503,
x-should-retry: false:只发 1 个请求,没有重试。 - 400,
x-should-retry: true:本来不该重试的 400,被重试了一次,第二次成功。
这几行对预算的影响:按这个版本的 SDK 源码,每次重试最多可以等 120 秒,所以在提供方持续限流时,默认的两次重试仅仅等待就可能接近 4 分钟,budget.py 里的退避模型不包含这一项。限流时的延迟上界取决于提供方的响应头,不取决于你的 timeout。
实验 4:timeout 不是总时长上限
streaming_timeout.py 比较三种情况:普通调用在服务端迟迟不回包时,一个总共持续 2 秒、但每 0.2 秒就有数据到达的流,以及一个中途停住的流:
"""Is `timeout` a cap on total time? Compare a plain call with streamed replies."""
import time
from common import half, make_llm, timed
from latency_server import LatencyServer
TIMEOUT = 0.5
def drain(llm, sink: list) -> None:
for chunk in llm.stream("hi"):
sink.append(chunk.content)
print(f"client timeout={TIMEOUT}s; max_retries=0 for the plain call, 2 for the streams")
with LatencyServer(delay=1.0) as server:
_, outcome = timed(lambda: make_llm(server, timeout=TIMEOUT, max_retries=0).invoke("hi"))
print(f"plain call, server needs 1.0s before replying -> {outcome}, requests={server.count}")
with LatencyServer(stream_chunks=10, stream_interval=0.2) as server:
received: list = []
elapsed, outcome = timed(lambda: drain(make_llm(server, timeout=TIMEOUT, max_retries=2), received))
print(
f"stream, 10 chunks 0.2s apart (2.0s in total) -> {outcome}, chunks={len([c for c in received if c])}, "
f"requests={server.count}, elapsed~{half(elapsed):.1f}s, longer than timeout: {elapsed > TIMEOUT}"
)
with LatencyServer(stream_chunks=10, stream_interval=0.05, stall_after=3, stall_seconds=2.0) as server:
received = []
_, outcome = timed(lambda: drain(make_llm(server, timeout=TIMEOUT, max_retries=2), received))
time.sleep(0.5) # give a hypothetical retry the chance to show up
print(
f"stream, stalls for 2.0s after 3 chunks -> {outcome}, chunks kept={len([c for c in received if c])}, "
f"requests={server.count}"
)client timeout=0.5s; max_retries=0 for the plain call, 2 for the streams
plain call, server needs 1.0s before replying -> OpenAITimeoutError, requests=1
stream, 10 chunks 0.2s apart (2.0s in total) -> ok, chunks=10, requests=1, elapsed~2.0s, longer than timeout: True
stream, stalls for 2.0s after 3 chunks -> OpenAITimeoutError, chunks kept=3, requests=1- 普通调用:服务端 1 秒后才回复,
timeout=0.5在 0.5 秒时就中断了。 - 持续 2 秒的流:
timeout同样是 0.5 秒,却完整收到 10 个片段,耗时约 2 秒,是timeout的四倍。timeout约束的是两次读取之间的最长沉默,每来一块数据就重新计时。 - 中途停住的流:收到 3 个片段后服务端停住,客户端在 0.5 秒沉默后抛出
OpenAITimeoutError;只发了 1 个请求,没有重试,已收到的 3 个片段保留在你的代码里。SDK 的重试只发生在响应开始之前,流开始以后的失败要由你的代码处理。
这意味着:在流式场景里,timeout 应该按“能接受的最长沉默”来设定,总时长要靠外层截止时间约束;而且用户可能已经看到了半截回答,前端和业务逻辑要决定是展示、丢弃还是重新发起。
实验 5:用外层截止时间限制总时长
deadline.py 使用异步调用,timeout=0.3、max_retries=4,如果不加限制,实验 2 的模型说明它会跑 7 秒以上。这里在外面套一个 1.5 秒的 asyncio.wait_for,并在取消之后继续观察 2.5 秒,看重试循环是否真的停了:
"""timeout does not bound the whole call. Does an outer deadline stop the retries too?"""
import asyncio
import time
from common import half, make_llm
from latency_server import LatencyServer
TIMEOUT = 0.3
RETRIES = 4
DEADLINE = 1.5
SERVER_DELAY = 1.0 # every attempt times out
async def main() -> None:
with LatencyServer(delay=SERVER_DELAY) as server:
llm = make_llm(server, timeout=TIMEOUT, max_retries=RETRIES)
start = time.perf_counter()
try:
await asyncio.wait_for(llm.ainvoke("hi"), timeout=DEADLINE)
outcome = "ok"
except asyncio.TimeoutError:
outcome = "deadline hit"
elapsed = time.perf_counter() - start
at_deadline = server.count
await asyncio.sleep(2.5) # long enough for several more attempts if the retry loop were still alive
after = server.count
print(f"timeout={TIMEOUT}s, max_retries={RETRIES}, outer deadline={DEADLINE}s")
print(f"outcome: {outcome}, stopped at ~{half(elapsed):.1f}s")
print(f"requests sent by the deadline: {at_deadline}")
print(f"requests sent 2.5s after cancellation: {after} (retry loop stopped: {after == at_deadline})")
asyncio.run(main())timeout=0.3s, max_retries=4, outer deadline=1.5s
outcome: deadline hit, stopped at ~1.5s
requests sent by the deadline: 2
requests sent 2.5s after cancellation: 2 (retry loop stopped: True)- 截止时间生效:约 1.5 秒时调用被取消,而不是等到所有重试结束。
- 取消时只发了 2 个请求:第三次尝试要在约 1.7 秒以后才会开始,来不及发出。
- 没有僵尸重试:取消之后又等了 2.5 秒,服务端收到的请求数没有再增加,说明取消连同 SDK 内部的重试循环一起终止了。
同步的 invoke 没有对应的取消手段,Python 线程也不能被安全地杀掉;需要硬性时限的同步调用,可以改用异步接口,或者把调用放进可终止的独立进程。本页没有实测线程和进程这两种方案。
实验 6:两层重试会相乘
retry_stacking.py 让服务端对每个请求立刻返回 503,把 SDK 自带的重试次数(max_retries)和外层 ModelRetryMiddleware 的重试次数两两组合,数服务端收到的请求数。中间件不加延迟,因此耗时里只剩 SDK 的退避:
"""Two retry layers multiply: the SDK inside ChatOpenAI and ModelRetryMiddleware around it."""
from budget import latency_range
from common import make_llm, timed
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRetryMiddleware
from langchain_core.messages import HumanMessage
from latency_server import LatencyServer
ASK = {"messages": [HumanMessage("hi")]}
SLACK = 0.5 # scheduling overhead allowed above the model's upper bound
print("the provider answers 503 to every request at once; one agent call, no tools; the middleware adds no delay")
print(f"{'SDK retries':>11} {'middleware retries':>19} {'requests':>9} {'(1+sdk)*(1+mw)':>15} {'backoff model':>15} {'within model':>13}")
for sdk_retries, mw_retries in [(0, 0), (2, 0), (0, 2), (2, 2)]:
with LatencyServer(always_status=503) as server:
llm = make_llm(server, timeout=5, max_retries=sdk_retries)
middleware = [ModelRetryMiddleware(max_retries=mw_retries, on_failure="error", initial_delay=0.0, jitter=False)]
agent = create_agent(llm, tools=[], middleware=middleware)
elapsed, outcome = timed(lambda: agent.invoke(ASK))
sent = server.count
expected = (1 + sdk_retries) * (1 + mw_retries)
low, high = latency_range(0.0, sdk_retries, mw_retries) # failures are instant, so only backoff remains
print(
f"{sdk_retries:>11} {mw_retries:>19} {sent:>9} {expected:>15} "
f"{low:>6.1f}..{high:.1f}s {str(low <= elapsed <= high + SLACK):>13}"
)the provider answers 503 to every request at once; one agent call, no tools; the middleware adds no delay
SDK retries middleware retries requests (1+sdk)*(1+mw) backoff model within model
0 0 1 1 0.0..0.0s True
2 0 3 3 1.1..1.5s True
0 2 3 3 0.0..0.0s True
2 2 9 9 3.4..4.5s True- 请求数等于
(1 + SDK 重试) × (1 + 中间件重试)。 默认的 SDK 重试加上max_retries=2的中间件,一次失败的调用会产生 9 个请求,而不是直觉中的 3 个。官方文档里ModelRetryMiddleware的max_retries默认值也是 2,所以两边都用默认值时,正好就是这一行。 - 耗时也相乘。 9 个请求是 3 次完整的 SDK 调用,每次都要经历一遍退避,所以退避时间是单层的三倍。
- 只想要一层重试时,关掉另一层。 要么
ChatOpenAI(max_retries=0)把重试交给中间件,要么不用中间件重试、只保留 SDK 的。两层同时开,提供方限流时请求量会被放大,反而加重限流。 - 默认行为再提醒一次:官方文档里
ModelRetryMiddleware的on_failure默认值是"continue",重试耗尽后返回一条带错误信息的AIMessage,不会抛出异常。本实验设为"error"只是为了让结果直观;这个默认值对 Agent 的影响见Agent 中间件与安全护栏。
起点配置决策表
下表的数字全部来自上面的 budget.py 参考表。它们是评审的起点,不是推荐值:按你的服务等级和提供方的真实延迟调整,再用门禁脚本验证。
| 场景 | 起点配置 | 最坏耗时与请求数 | 外层截止时间 | 说明 |
|---|---|---|---|---|
| 用户在线等待,非流式短回答 | timeout=10,max_retries=1 | 约 20.5 秒,2 个请求 | 比最坏值略宽,例如 25 秒 | 交互场景不适合长时间无反馈 |
| 希望快速失败并走降级 | timeout=5,max_retries=0 | 5.0 秒,1 个请求 | 不需要 | 降级由 ModelFallbackMiddleware 或业务逻辑接管 |
| 后台批处理、离线任务 | timeout=30,max_retries=2 | 约 91.5 秒,3 个请求 | 按任务粒度设置 | 把整批任务的总预算也算进去 |
| 流式长回答 | timeout 取“能接受的最长沉默” | 总时长不受 timeout 约束 | 必须设置 | 并设计半截回答的处理方式 |
| 推理类模型、首包很慢 | 先测首包延迟分布,再定 timeout | 本页没有实测 | 必须设置 | 不要照搬上面的小超时 |
默认的 ChatOpenAI() | 不要用于线上 | 没有上界 | 无 | 至少显式设置 timeout |
一次 Agent 运行包含多次模型调用,整次运行的最坏时长要在单次调用的预算上再乘以步数;步数上限和中间件占用的递归步数见Agent 中间件与安全护栏的实测。
把它用在你的系统上
- 先看你的客户端实际带着什么设置。 参照实验 1,读取
llm.root_client.timeout、llm.root_client.max_retries和llm.root_client._client.timeout;_client是私有属性,升级后请重新确认。 - 盘点所有的重试层。 SDK 自带的、
ModelRetryMiddleware、业务代码里的重试装饰器、网关或代理的自动重试,都要算进“一次失败调用到底发几个请求”。本页只实测了前两层。 - 把预算写进发布门禁。 例如
python budget.py --timeout 10 --retries 1 --limit 25:最坏值超过限制时进程退出码为 1,可以直接接到 CI。有外层重试时加--wrapper-retries和--wrapper-delay,后者填两次重试之间最长的那次间隔,得到的是保守上界。 - 给调用加外层截止时间。 用异步接口配合
asyncio.wait_for,截止时间应略大于门禁算出的最坏值,只在“提供方持续异常”时才会真的触发。 - 监控请求数而不仅是耗时。 一次失败调用的请求数偏离
1 + max_retries,说明有人叠加了另一层重试,或者提供方返回了你没预期的响应头。
门禁脚本对不安全的配置会给出失败结果,下面两个例子分别是没有超时和两层重试叠加:
requests per failed call: 3
worst case: unbounded (best of the worst: unbounded) limit: 60.0s -> FAILrequests per failed call: 9
worst case: 94.5s (best of the worst: 93.4s) limit: 60.0s -> FAIL故障树与排障:请求卡了很久,该查什么
| 现象 | 最可能的原因 | 怎么验证 | 处理 |
|---|---|---|---|
| 请求卡住几分钟,没有任何报错 | 没有设置 timeout,或者提供方一直不回包 | 读取客户端的实际超时设置(实验 1) | 显式设置 timeout,再加外层截止时间 |
| 收到 429 之后等了很久 | 提供方返回了很大的 Retry-After | 抓取响应头,看 Retry-After 与 retry-after-ms | 按提供方的速率限制降低并发;需要硬上限时用外层截止时间(实验 3、5) |
| 一次故障,服务端日志里却有很多请求 | 多层重试叠加 | 数请求数,对照 (1 + SDK) × (1 + 中间件) | 只保留一层重试(实验 6) |
| 设了 10 秒超时,流式输出却持续了两分钟 | timeout 只约束读取间隔 | 看片段到达的间隔,而不是总时长 | 加外层截止时间;按“最长沉默”设置 timeout(实验 4) |
| 用户看到半截回答,系统没有自动重试 | 流开始后的失败不会被 SDK 重试 | 看是否抛出了读取超时,以及已收到多少片段 | 在应用层决定展示、丢弃还是重新发起,并保证重新发起是幂等的(实验 4) |
| 收到的重试比预期少,服务端明明还能恢复 | 响应头 x-should-retry: false,或 Retry-After 超过 120 秒 | 抓取响应头 | 检查网关或代理是否加了这些头(实验 3) |
| 门禁脚本通过,线上仍然偏慢 | 模型只算了超时和退避,不含提供方的 Retry-After、排队和首包延迟 | 对比线上延迟分布与门禁的最坏值 | 把门禁当成“配置自身的上界”,线上延迟还要用监控确认 |
相关阅读
- 新接入一个模型服务时,如何验收工具调用、结构化输出和重试请求数:OpenAI 兼容模型接入验收;
- 中间件重试、降级和递归步数的实测与取舍:Agent 中间件与安全护栏;
- 打开追踪以后,追踪服务故障会如何拖慢进程退出:LangSmith 追踪上线前核对;
- 把超时预算写进发布前的可执行检查:LangChain 生产发布检查表;
- 线上问题从症状回溯到原因的入口:LangChain 生产故障诊断中心。
来源
- ChatOpenAI 集成文档 (opens in a new tab):构造参数示例中
timeout=None与max_retries=2作为默认值列出; - 内置 middleware 文档 (opens in a new tab):
ModelRetryMiddleware的参数、默认延迟与on_failure默认值; - openai-python (opens in a new tab) 已安装版本的
_base_client.py与_constants.py:默认超时 600 秒与连接超时 5 秒、默认重试 2 次、退避min(0.5 × 2^k, 8)秒并带 0 到 25% 抖动、Retry-After与retry-after-ms的采纳规则及 120 秒上限、x-should-retry的优先级; - httpx 超时文档 (opens in a new tab):
Timeout(None)表示关闭超时,读取超时针对的是一次数据块的等待;本环境中 SDK 使用的是httpx2,其行为以本页实验为准。
以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/model-call-latency-budget/ 在上表版本下对本地服务端的实际运行;budget.py 的参考表是计算结果,已在实验 2 中用实测区间验证。