```html900行与3000行:重试风暴事故复盘900行与3000行:重试风暴事故复盘
周六早上,周报表格里只有900行,而不是3000行,仪表盘却是一片让人安心的绿色。任务以退出码0结束,死信文件涨到了12MB,三周没人打开过。真正发现这次失败的,是损失已经造成四十分钟后才到的一封配额邮件。
这样的任务你大概也写过:把几千条数据摊开,让模型给每条写两句摘要,写回表格,退出。没人盯着它,因为它已经稳定跑了几个月,唯一接到人身上的信号就是退出码。而当重试循环藏在一个以continue结尾的try块里时,这个信号就开始撒谎了。
下面这条时间线可以对着本地桩服务在二十分钟左右复现出来。重点在于:重试风暴是设计缺陷,不是运气差,而且证明它应该很便宜。
时间线还原
T+0:八个worker在一条3000条的队列上启动,共用一个API key,没有共享的花费计数器。每个worker都以为自己的重试预算就是全局预算。
T+6m:接口开始返回429。退避策略是2 ** attempt,没有抖动也没有上限,于是每个worker睡一样长、一起醒来,把一次限流变成了同步踩踏。
T+22m:每换一条新数据,重试计数就重置,单条数据能烧掉六次尝试,而整轮运行始终没触发自己的任何限制。图上看吞吐很健康,因为图统计的是请求数,不是行数。
T+41m:配额没了。服务方开始返回硬性配额错误,分类器因为它仍是一个HTTP错误而判定可重试,风暴继续全速撞墙。
T+6h:运行以退出码0结束,71%的行缺失。没有任何告警,因为从来没有东西被接到告警上:失败的数据被追加进一个文件,而文件不会叫醒任何人。
三个促成因素,一个根因
第一个因素是分类器。把所有非200响应都当成可重试,和把所有异常都当成临时故障是同一个错误。401、403、404和硬性配额错误都是终态,重试它们只是花钱去确认显而易见的事。
第二个因素是缺失的账本。T+41m时没人能回答唯一重要的问题——这轮运行到目前为止花了多少token——因为这个数字只存在于一个每小时刷新一次的服务商仪表盘里。
第三个因素是沉默的死信。一条失败数据落进文件,没有计数、没有时间、没有告警,这不叫处理,叫藏起来。三者之下的根因是:这个任务没有“运行级预算”的概念,代码和人读不到同一个数。
可落地的修法:账本、上限、软车道
修法是一个可审计的token账本,在你需要它之前就写好。每次尝试一行,按运行ID索引,用量取自响应本身,而不是来自一个你没锁版本的tokenizer估算。
# guard.py — a token ledger you can query at 2amimport random, sqlite3, timeDDL = """CREATE TABLE IF NOT EXISTS attempts (id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL,item_id TEXT NOT NULL,attempt INTEGER NOT NULL,model TEXT NOT NULL,ptok INTEGER NOT NULL DEFAULT 0,ctok INTEGER NOT NULL DEFAULT 0,status TEXT NOT NULL,err TEXT,ts REAL NOT NULLRETRYABLE = {"429", "500", "502", "503", "504"}BUDGET = 200_000 # tokens per run, deliberately below the monthly ceilingMAX_ATTEMPTS = 4class BudgetExhausted(RuntimeError):passdef open_ledger(path="ledger.sqlite"):conn = sqlite3.connect(path, isolation_level=None)conn.execute(DDL)return conndef spent(conn, run_id):q = "SELECT COALESCE(SUM(ptok + ctok), 0) FROM attempts WHERE run_id = ?"return conn.execute(q, (run_id,)).fetchone()[0]def log(conn, run_id, item_id, attempt, model, ptok, ctok, status, err=None):conn.execute("INSERT INTO attempts (run_id,item_id,attempt,model,ptok,ctok,status,err,ts)"" VALUES (?,?,?,?,?,?,?,?,?)",(run_id, item_id, attempt, model, ptok, ctok, status, err, time.time()),)调用点随后会拒绝启动付不起的工作,并用状态码而不是异常类型来区分“等一等”和“停下来”。
def summarize(client, conn, run_id, item, model="free-lane"):for attempt in range(1, MAX_ATTEMPTS + 1):if spent(conn, run_id) >= BUDGET:log(conn, run_id, item.id, attempt, model, 0, 0, "budget_stop")raise BudgetExhausted(run_id)try:r = client.chat.completions.create(model=model,max_tokens=160,messages=[{"role": "user", "content": item.prompt}],u = r.usagelog(conn, run_id, item.id, attempt, model,u.prompt_tokens, u.completion_tokens, "ok")return r.choices[0].message.contentexcept Exception as e: # narrow this to your SDK's error basecode = str(getattr(e, "status_code", None) or type(e).__name__)log(conn, run_id, item.id, attempt, model, 0, 0, "error", code)if code not in RETRYABLE or attempt == MAX_ATTEMPTS:raisetime.sleep(min(2 ** attempt, 20) + random.uniform(0, 1))抖动在这里不是锦上添花,它是阻止八个worker变成一个非常昂贵的worker的关键。二十秒的上限则防止长尾把夜间任务拖成早间任务。
一次预检只花一个请求,却能避免最糟的情形:一批任务拿着一个已经死掉的key就开跑。
curl -sS -o /dev/null -w '%{http_code}\n' \"$BASE_URL/models" -H "Authorization: Bearer $KEY"最后,把车道分开。摘要任务留在你信任质量的模型上,第二条更便宜的车道负责校验:重跑一小批摘要样本、检查JSON外壳还能不能解析、确认行数能和队列对上。免费模型端点真正适合的就是这条车道,因为它的职责是怀疑,不是权威。
MonkeyCode也出现在这个位置,契合度窄到可以如实描述。该项目开源,其材料宣传免费模型额度最高约1000万token,外加一个免费服务器选项,这使它成为那条低风险校验车道的合理去处——容量、限制和时长由项目方设定,依赖它跑任何定时任务之前请先读当前条款。
披露:本文作为MonkeyCode产品推广的一部分撰写。
证明防护有效的测试计划
你没法对着一个健康的端点验证重试防护,所以要造一个故障注入器。标准库四十行就够,而且不花一个token。
# stub.py — a 429 machine, so you can watch the guard behavefrom http.server import BaseHTTPRequestHandler, HTTPServerclass H(BaseHTTPRequestHandler):def do_POST(self):body = b'{"error":{"message":"rate limited","type":"rate_limit_error"}}'self.send_response(429)self.send_header("Content-Type", "application/json")self.send_header("Content-Length", str(len(body)))self.end_headers()self.wfile.write(body)def log_message(self, *args):passHTTPServer(("127.0.0.1", 8787), H).serve_forever()把BASE_URL指向本地桩,跑一个二十条的夹具。然后断言四件事,而不是用眼睛看日志:尝试总行数不超过二十乘以最大尝试次数;进程以非零码退出而不是0;死信数等于二十;告警触发。再把桩调成让30%的请求失败,断言成功行加死信仍然等于二十——这一条检查就能一行抓住最初那次事故。
真实运行之后,账本可以直接回答审计问题:按状态分组,数行数,加token。
SELECT status, COUNT(*) AS n, SUM(ptok + ctok) AS tokensFROM attempts WHERE run_id = ? GROUP BY status;局限,以及谁该跳过账本是按运行、按文件的,扩到多台机器就需要真正的数据库和行锁,而不是共享卷上的SQLite。流式响应在不同服务商那里上报用量的方式可能不同,这意味着ptok/ctok两列会和账单有偏差,应当当作上限估算而非会计数字。免费额度会不打招呼地变化,所以永远别让校验车道成为你和数据静默缺失之间的唯一屏障。
如果你需要合同级可用性或数据驻留保证,如果负载对延迟敏感,或者你的量级大到这套防护的工程时间比它省下的token更贵,就完全跳过这个方案。对于你还没批准送往第三方端点的受监管数据,这也是错误的模式,无论那个端点多免费。
无论用哪家服务商,值得保留的习惯是:让运行本身回答它花了多少。如果你想找个零成本的地方排练校验车道,该项目的免费额度是个合理的沙盒——只是账本要自己留,让桩服务而不是配额邮件,成为告诉你真相的那个东西。
热门跟贴