作者用本地测试环境验证:当 AI Agent 工具循环忽略 429 响应头中的 Retry-After,会在限流时无意义重试直至服务崩溃,需主动将头信息注入工具结果。
这是来自本地测试环境的 48 小时实战记录,不是客户案例,也不是基准测试。问题很具体:如果 AI 工具循环把所有非 2xx 响应一视同仁,Retry-After 能否到达下一个模型轮次?简短的回答是:不能,除非你主动把响应头拷贝到工具结果里。
我搭建了一个小小的状态服务,接入一个朴素轮询器,然后把它和一个真正尊重 Retry-After 响应头的循环进行比较。这个服务故意表现得"无礼"——来自同一客户端 key 的四次成功 GET 之后,开始返回 429 并带上 Retry-After: 3。这足以让一个工具调用型 agent 在无需负载生成器的情况下打爆一个免费服务器。
那个一直挥之不去的类比是:Retry-After 就像一个交警在路口伸出手掌。而一个只返回状态码和响应体的工具 schema,就像一个只看信号灯颜色的司机。掌依然在。路口依然在。
第一版用的是标准库服务器,所以唯一的可变部分就是 HTTP 和一个字典。没有框架,没有队列。一个进程,一把锁,一个从客户端 key 到命中次数的映射。
# retry_after_lab.py — run: python retry_after_lab.py
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from collections import defaultdict
import threading, json, time
HITS = defaultdict(int)
LOCK = threading.Lock()
LIMIT = 4 # 429 after this many GETs per client key
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def do_GET(self):
if self.path != "/status":
self.send_response(404); self.end_headers(); return
key = self.headers.get("X-Client-Key", "anon")
with LOCK:
HITS[key] += 1
n = HITS[key]
if n > LIMIT:
self.send_response(429)
self.send_header("Retry-After", "3")
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({
"error": "rate_limited",
"hits": n,
}).encode())
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({
"state": "open",
"hits": n,
"ts": time.time(),
}).encode())
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
用 curl 确认了契约之后才让任何模型进入场景。四次 200,然后是一个带响应头的 429,shell 可以打印出这个头,JSON body 看起来像一个普通错误。
python retry_after_lab.py &
for i in 1 2 3 4 5 6; do
echo "--- $i"
curl -sD - -H "X-Client-Key: lab" http://127.0.0.1:8765/status
echo
done
这部分无聊得很,但正是有用的那种无聊。响应头是稳定的。body 很小。我关心的故障点不是服务器,而是下一跳:一个只把 JSON body 序列化进模型对话记录的工具包装器。
大多数 agent 运行时把 HTTP 暴露为一个函数,有名字、有 URL、有解析后的 body。状态码有时能透传过去。响应头往往不能。我把下面的包装器标注为一个提案,符合我在追踪中看到的样子;它不是从生产 agent 里导出的 dump。
# Proposed tool result — headers dropped on purpose
def http_get(url: str, headers: dict | None = None) -> dict:
import urllib.request
req = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return {
"ok": True,
"status": resp.status,
"body": resp.read().decode(),
}
except urllib.error.HTTPError as e:
return {
"ok": False,
"status": e.code,
"body": e.read().decode(),
}
把这个字典作为工具结果喂回去,模型收到的是 {"ok": false, "status": 429, "body": "{\"error\": \"rate_limited\", \"hits\": 5}"}。这个对象里没有任何睡眠指令。合理的下一个 token 序列是再次对同一个 URL 调用 http_get。交警的手掌从未进入对话。
然后我运行了第二个客户端,做了一件仓促的 agent 会做的事:任何失败都重试,间隔 200ms,无抖动,用同一个客户端 key。两秒内额外收到了六次 GET。服务器尽职了。客户端没有。
# naive_poller.py — this is the broken loop under test
import json, time, urllib.request, urllib.error
URL = "http://127.0.0.1:8765/status"
HEADERS = {"X-Client-Key": "naive"}
def once():
req = urllib.request.Request(URL, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, dict(resp.headers), resp.read().decode()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read().decode()
for i in range(10):
status, headers, body = once()
print(f"{i} status={status} retry_after={headers.get('Retry-After')} body={body}")
if status == 200:
break
time.sleep(0.2) # the bug: ignores Retry-After
这个打印输出就是全部的教训。retry_after=3 和 sleep(0.2) 出现在同一行。这个数字对 Python 可见。只要工具结果不拷贝它,它对模型就不可见。这就是那道接缝。
一个正确的工具结果不需要什么精巧设计。它只是更宽一些。
# Proposed tool result — header survives into the next model turn
{
"ok": False,
"status": 429,
"retry_after_seconds": 3,
"instruction": "Do not call http_get again until retry_after_seconds have elapsed.",
"body": {"error": "rate_limited", "hits": 5}
}
即便如此,如果运行时在模型层之下重试工具,那还是不够。有些编排器会捕获 ok: false 然后在请求新的 completion 之前重新调用同一个函数。这时响应头已经在 JSON 里了但仍然没被用上。sleep 必须放在编排器里,而不是提示词里。模型做节拍器很糟糕。
第二天做的是测试,不是仪表盘。探针记录来自同一客户端 key 的 429 时间戳。如果两个 429 之间的距离小于 Retry-After 减去 150ms 时钟偏差,测试就失败。这是我会真正遵守的契约。
# test_retry_after.py — run after the server is up
import time, urllib.request, urllib.error, json, sys
URL = "http://127.0.0.1:8765/status"
KEY = "probe"
def hit():
req = urllib.request.Request(URL, headers={"X-Client-Key": KEY})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, dict(resp.headers), time.monotonic()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), time.monotonic()
# Warm the limiter
for _ in range(5):
status, headers, ts = hit()
assert status == 429, status
wait = float(headers.get("Retry-After", "0"))
assert wait >= 1, headers
# Immediate retry must still be 429
status2, _, ts2 = hit()
assert status2 == 429, status2
gap = ts2 - ts
if gap < wait - 0.15:
# This assert is the point of the lab.
print(f"FAIL: second 429 arrived after {gap:.3f}s, header asked for {wait}s")
sys.exit(1)
time.sleep(wait)
status3, _, _ = hit()
print(json.dumps({"after_wait_status": status3, "gap_before_sleep": round(gap, 3)}))
# Naive pollers never reach this line with a 200 unless LIMIT resets.
我不会把这个作为负载测试来交付。它是一个针对客户端的契约测试。服务器是一个 fixture。如果你把同一个探针指向一个你并不拥有的公开 API,你就不再是在调试工具循环了。你正在为你声称要研究的 429 做贡献。
接下来的时间里,桌上一直放着一张紧凑的决策表。状态码 429 加 Retry-After 意味着在编排器里 sleep,然后重试一次,然后停止。状态码 429 没有响应头意味着指数退避加上限,然后停止。状态码 200 但 body 里有应用层错误不在 HTTP 层重试。状态码 204 是带空 body 的成功,不是解析失败。把这四种情况混在一起,就是一个 agent 如何把一个礼貌的限流器变成重试风暴的方式。
上面的测试环境跑在一台笔记本上。把它从笔记本上移出来的原因是追踪过夜日志:你想要模型轮次、工具 JSON 和 HTTP 日志放在一起,在你睡觉的时候也能看到。披露:本文是 MonkeyCode 产品推广的一部分。
MonkeyCode 是一个开源编程 agent 项目,就本次写作所提供的版本,提供免费模型访问和免费服务器选项。我用这个组合作为一个地方,重新对同一个 fixture 运行同样的轮询器,而不需要编一个 GPU 故事。我不在这里附上模型名称、token 上限、硬件或持续时间声称。这些数字一周内就会过时,而这篇笔记讲的是一个响应头,不是一份计划表。
有用的部分是 logistical 的。启动 fixture,启动朴素轮询器,启动第二个把 Retry-After 拷贝进工具结果的轮询器,然后保留对话记录。如果免费服务器正忙或容量未定,测试在本地依然成立。产品为方法提供了便利,而不是方法本身。
我会重复做的一件事:用原始字符串记录工具参数,在 JSON 解析之前。一些追踪显示函数调用看起来像 {"url": "http://127.0.0.1:8765/status" 没有右花括号。运行时重试了解析器,然后重试了 HTTP 调用,然后限流器介入了。被截断的工具 JSON 和被忽略的响应头会叠加。它们看起来像两个 bug。它们是一条没有背压的管道。
我会保持 fixture 的简洁。四次 200 和一个 429 就够了。我会把 Retry-After 作为一级字段放在工具 schema 里,而不是寄希望于模型去读 body。我会把 sleep 放在进程代码里。我会保留那个在两个 429 太近时失败的探针。
我不会让模型凭感觉选择睡眠时长。我不会只解析 body。我不会对不是我操作的 host 运行这个。我不会把一个免费的共享服务器当作 SLO。未知的上限不是无限的上限。它们就是未知的。
局限性很直白。这个实验室不测量任何模型的吞吐量、成本或质量。它不能证明每个 agent 框架都会丢弃响应头;它只是展示了一个常见的包装器形状如何使丢弃变得必然。时钟偏差、HTTP/2、剥离 Retry-After 的代理、以及发送日期而不是时间增量 delta 的服务器都不在讨论范围内。如果你的限流器返回 503 而且没有响应头,这个探针救不了你。
谁应该跳过这个做法:用轮询器作为武器攻击第三方 API 的人;需要保证远程容量的人;指望用提示词替换互斥锁的人。如果你的工具结果已经是 {status, headers, body} 而且你的编排器会 sleep,你不需要这篇文章。你需要那个 assert。
四十八小时后,fixture 在第五次命中时仍然返回 429。朴素的循环仍然忽略交警。探针仍然失败闭口。这就是整篇笔记。如果你想要一个已经接好免费模型访问和免费服务器车道的沙盒,MonkeyCode 是重新运行这个测试环境的一个地方——上面的测试不依赖它。