
本文是「Agent 工程实战」系列的第 04 篇属于保命线。系列索引21 篇阅读路线Agent 放权前先补权限、出站、发现这三道口子摘要一个 9,000 条的同步任务撞上平台抽风夜裸奔组 12 分半烧穿 20,000 次的月额度任务只完成七成五收尾还对着墙撞了五分多钟守护组用六成额度把任务跑完三十几条按重试上限主动放弃、留痕。这篇按现象→排查→根因→方案→边界完整复盘整场风暴把重试这层最容易被忽略的放大器拆开。全部参数与代码来自本机沙箱实测可复跑。文章目录开场额度归零的早晨先摊参数一份任务两组客户端三门机制现象三块屏幕三个视角平台侧一条均匀到让人心虚的曲线任务侧四分之三你的视角第二天早上排查三个镜头锁死凶手根因三层合理的放大器方案守护组的四处补丁边界这套改造不解决什么总结重试是替对面做的决定开场额度归零的早晨交代结果一个晚上一个定时同步任务把数据平台整个月的调用额度烧到了零。从开跑到额度见底实测十二分半。没有攻击、没有入侵、没有一行代码抛异常——所有逻辑都在按预期运行请求失败 → 重试 → 还失败 → 再重试。它只是太勤奋了。这是上篇《给 Agent 补一张出站责任清单》里预算那一格的现场版——清单结尾我提过一句预算防的是总量爆炸不防短时间内打太快。这篇就是把打太快完整跑出来三层单看都合理的逻辑叠成一场十二分半的风暴。顺便交个底全文数字来自本机沙箱——一个模拟数据平台加两组客户端跑的是同一份任务参数全部公开脚本可复跑。不涉及真实生产环境。先摊参数一份任务两组客户端三门机制复盘的规矩是把牌摊开。沙箱不玩黑盒——平台机制、客户端策略、故障注入概率全在下面这张表里角色配置模拟平台月配额 20,000 次计费调用边缘令牌桶每秒补 30 个、突发 60随机注入 25% 的 500 与 8% 的软限流带Retry-After: 2正常延迟 150-250ms裸奔组客户端9,000 条同步任务每批 50 条并发 12每条每轮最多裸试 2 次即到即重试批次整体重放上限 3 次即每批最多 4 轮无预算、无断点守护组客户端同一份任务并发 8每条最多 5 次尝试指数退避 抖动 服从Retry-After逐条断点成功即记账任务预算 15,000 次共同条件同一台机器Node.js v25.8.2、同一份任务清单、同一个抽风夜——每轮开跑前重置平台故障注入对所有客户端一视同仁两组的差异集中在失败之后怎么办——重试几次、等不等、有没有上限。接下来发生的所有事——压力的流向、额度的去向、第二天早上谁在翻日志——都从这一个分岔口开始。现象三块屏幕三个视角平台侧一条均匀到让人心虚的曲线先把平台自己的分钟级统计原样搬过来计费口径请求进入后端就算一次500 也算被拒绝的不算分钟总请求计费调用边缘拒绝软限流01,5801,475010511,7431,612013121,7741,635013931,7341,615011941,7591,608015151,7351,606012961,7911,644014771,7521,617013581,7371,608012991,7431,5890154101,7641,6220142111,7641,6140150122,674755834159133,41601,616152143,42401,626153153,43001,627147163,41801,620142172,71601,274121看计费调用这一列前十二分钟每分钟稳定消费 1,450-1,650 次——像打卡一样均匀。没有攻击者会这么礼貌也没有哪个任务该这么重。额度见底的那一刻服务端记在了第 748 秒日志字段quotaHitAtSec。再看烧穿之后那几行请求速率不降反升每分钟总请求从一千七涨到三千四。被拒的响应回来得快客户端循环得更起劲了。边缘限流开始替它踩刹车每分钟拦掉约一千六百次软限流也一直在发等两秒的信号——全都白发了。任务侧四分之三180 个批次里一百三十多个跑完了全程9,000 个条目真正拿到数据六千七百多个——七成五。剩下四分之一没跑完的条目里绝大多数是被额度烧穿拖死的四十多个批次在那之后全军覆没。有个残酷的细节它并不是没拿到数据。日志里躺着近一万五千次成功响应——只是没有断点、没有账本成功过的条目下一秒又被当成未完成的重新取了一遍。数据拿到过等于没拿到。你的视角第二天早上我早上打开平台控制台第一反应是额度怎么是 0被盗刷了“然后我把凌晨的请求日志翻了一遍越翻越沉默每一笔都是自己人。时间、频率、模式全对得上自家的任务。它把整月的额度用完这件事它自己从头到尾都不知道”——客户端统计里没有额度这个概念。排查三个镜头锁死凶手把现场倒着放一遍。镜头一额度消耗曲线。分钟桶拉出来——均匀、坚决、无爆点。这一下就排除了被盗刷和突发攻击指向一个持续全速运转的自家客户端。镜头二请求构成对账。总账拆开看是这样的39,954 次总请求 ├─ 20,000 次计费——整月的额度全部耗尽 ├─ 8,852 次额度拒绝——烧穿之后一头撞在墙上 ├─ 8,597 次边缘拒绝——打超每秒 30 次的桶容量 └─ 2,505 次软限流——平台说等两秒没等将近两万次被拒一次都没停手。镜头三客户端代码。把裸奔组的失败处理摊开三个硬伤一目了然——直接进下一节。顺带说一句上面这些数服务端和客户端两边各记了一份能对得上口径——两边互为印章。日志该怎么设计成能对账上一篇里展开过这里不重复。根因三层合理的放大器上篇预告过重试叠重试这里是现场版两层重试都是这个客户端自己写的——单条一层即到即重试调度一层整批重放——再叠上最后一块拼图没有人在管总数。第一层即到即重试。裸奔组处理失败的代码长这样asyncfunctionattemptItem(id){for(leta0;aATTEMPTS;a){awaitsleep(PREP_MS);C.attempts;constrawaitonce(id);if(rok){C.ok;C.itemsDone.add(id);returntrue;}// 对 429/500 等分类计数的几行从略// 裸奔无退避、不理会 Retry-After下一次尝试立刻继续}returnfalse;}跑起来的样子控制台节选[naive] items9000 batch50 batches180 conc12 attempts2 replays3 [naive] batch 10/180 complete passes2 attempts1604 ok1144 limit429106 quota4290 e500354 [naive] batch 20/180 complete passes2 attempts3179 ok2227 limit429223 quota4290 e500729 [naive] batch 50/180 complete passes2 attempts8012 ok5585 limit429586 quota4290 e5001841单条逻辑听起来毫无攻击性“失败了立刻再要一次。” 它撞上的每一次限流、每一次抽风都会被立刻再问一遍接住。全篇近两万次被拒的响应里它一次也没有读过响应头里的Retry-After: 2。平台说等我两秒它翻译成了现在不行那我马上再问一次。第二层批次整体重放。把判定和重放两行连起来看awaitpool(ids,attemptItem);// 注意每轮把整批 50 条全部重新请求doneids.every((id)C.itemsDone.has(id));// 实现里没有断点已成功的也会被再取一遍跑到底的样子最后三个批次[naive] batch 178/180 FAILED passes4 attempts39154 ok14955 limit42910691 quota4298463 e5005045 [naive] batch 179/180 FAILED passes4 attempts39554 ok14955 limit42910894 quota4298660 e5005045 [naive] batch 180/180 FAILED passes4 attempts39954 ok14955 limit42911102 quota4298852 e5005045这一批里有一条没回来整批全部重新取。已经成功的条目被反复重复请求。看上面那三行ok冻在 14,955 一动不动attempts每批还在 400——重放机器一直空转到最后一刻。再对总账近一万五千次成功响应只对应六千七百多个条目——平均每个成功条目被重复取了两次多。为什么 50 条里总有一条失败单条在一个轮次里的成功率约九成——50 条全成的概率不足 1%。于是几乎所有批次都在重放。第三层没有预算账本。客户端里没有一个数字问过“这个任务允许花多少” 所以额度归零后它根本没停——把剩下的批次挨个重放到底又对着墙撞了五分多钟、一万八千多次直到最后一个批次跑完它才收工。三层拆开看每一层都是合理的容错——叠起来就是这场风暴的全部账目第一层 即到即重试 失败立刻再要一次不读 Retry-After 第二层 批次整体重放 有一条没成整批 50 条重新请求 第三层 没有预算账本 额度归零照旧重放直到跑完 ────────────────────────────────────────────── 合 计 39,954 次请求 / 20,000 次计费 / 12 分半额度归零 / 进度停在七成五预答一个大概率会出现的挑刺裸奔组是不是为了复盘故意造出来的稻草人不是。三层里的任何一层都能在真实代码里找到原型——即到即重试是手写循环的默认形态整批重放是批处理最省事的写法没有上限是脚本的出厂状态沙箱做的只是把它们塞进同一个客户端、端到端跑一遍。两组参数并不完全一致比如并发数但全部摊在开头那张表里真正把两边账目拉开的是失败之后的那几步。想复现、想质疑表就是入口。方案守护组的四处补丁同一个平台、同一份任务换守护组客户端。改动就四处全部集中在失败之后的那几步——针对的正是上一节那三层放大器。补丁一Retry-After优先。这是 HTTP 标准响应头平台在 429 里用它说等多久先照做if(r.status429r.xl!quota){// 计数与留痕的几行从略constwaitSMath.min(parseInt(r.ra||3,10)||3,10);awaitsleep(waitS*1000);// 平台说等多久就等多久}跑起来后审计日志里留下的记录{t:854,task:report-sync-20261016-01,id:14,attempt:1,status:429,wait_s:2,event:retry_after} {t:1967,task:report-sync-20261016-01,id:16,attempt:2,status:429,wait_s:2,event:retry_after}补丁二指数退避加抖动。500 没给任何等待提示——自己按次数翻倍等待、再加点抖动constwaitMath.min(300*Math.pow(2,a-1)jitter(),5000);// 计数与留痕从略awaitsleep(wait);再看一个完整案例——同一条目、两次退避、第三次拿下{t:357,task:report-sync-20261016-01,id:2,attempt:1,status:500,wait_ms:447,event:backoff} {t:1119,task:report-sync-20261016-01,id:2,attempt:2,status:500,wait_ms:721,event:backoff} {t:2137,task:report-sync-20261016-01,id:2,attempt:3,status:200,event:ok}补丁三逐条断点。成功即记账itemsDone——成功过的条目不会再被请求第二次失败的只重试它自己不存在整批重放。补丁四任务预算加熔断。调用数封顶、到线立刻停、拒绝留痕。机制本身上一篇展开过这里补一笔线该定在哪的实测账。插一句实话。这条预算线的第一版我拍脑袋设了给 9,000 条任务预留 9,000 次调用——结果它真在六千两百多条处熔断了约七成进度停得干净BUDGET_EXCEEDED留得明明白白。熔断没做错错的是线按熔断那一刻的数据回推这个故障率下光正常跑完就要一万三千次上下。把线调到 15,000 重跑才有了下面这组对照。指标裸奔组守护组总请求39,95413,114其中计费调用20,000整月额度归零12,042约六成被限流后的等待0 次近两万次拒绝全部硬撞1,072 次全部服从 Retry-After任务完成6,747 / 9,000七成五8,966 / 9,00099.6%余 34 条按重试上限放弃、留痕用时12 分半烧穿尾部再空转五分多钟总 17 分 44 秒16 分 37 秒含四千多次计划内的等待结局额度归零这个月再没额度可用任务跑到底额度还剩约 8,000四成对照表背后是它留下的一摞审计日志换个采样点再抓几行{t:358,task:report-sync-20261016-01,id:3,attempt:1,status:500,wait_ms:380,event:backoff} {t:367,task:report-sync-20261016-01,id:8,attempt:1,status:200,event:ok} {t:1071,task:report-sync-20261016-01,id:3,attempt:2,status:200,event:ok} {t:2461,task:report-sync-20261016-01,id:12,attempt:3,status:200,event:ok}两组客户端读起来的气质完全不一样一边是撞墙、撞墙、还是撞墙另一边是被叫停、等两秒、继续干活每一步都留了字据。边界这套改造不解决什么说边界之前先把一笔最容易看错的账挑明两组的墙钟时长几乎打平裸奔组十七分多守护组十六分半——但一边烧光额度停在七成五一边用六成额度把活干到了收尾。守护组没快多少因为它把该等的时间真的等完了上千次Retry-After说两秒就两秒每一次 500 背后都跟着一次退避——这不是损耗是按平台的节奏走。真正该比的是代价每完成一个条目花掉的计费调用裸奔组将近三次守护组连一次半都不到。一退避会拉长任务时长。对实时性敏感的任务用户等着结果的那种要压缩等待上限、并接受部分条目稍后重试的折中——不能照抄本沙箱的等待参数。二沙箱不是真实平台。不同平台的限流口径按 IP、按 token、按账号和Retry-After的实现细节都不一样接入前要用小流量实测别迷信文档。三单机数字不能当容量规划。这份对照证明的是机制方向和数量级你的真实放大倍数取决于你的故障率、重试层数和并发——要自己在预发环境测一遍。四参数调优救不了设计缺陷。如果任务本身能错峰、能分批、能做成增量同步优先级高于把退避参数调到完美。重试治理是兜底不是主策略。总结重试是替对面做的决定复盘写完说点感性的。我自己也写过裸重试循环。早些年给一个回调对账写轮询while (fail) { retry(); }就这几行代码扔上去跑了半天被对方限流限到怀疑人生——那时候我才知道重试不是一个技术细节是你替对面的服务器做的决定决定给它多少压力、替它消耗多少寿命、以及你打算为它准备多少耐心。这场沙箱里裸奔组的十二分半不是 bug 的速度是**“没有治理的默认速度”**。它甚至没做错什么——它只是不知道自己的边界在哪里。而治理的全部意义就是把这条边界写进代码里等它说等停在该停的地方然后留一份账。「Agent 工程实战」系列路线保命线权限/出站/发现上一篇03 出站责任清单下一篇05 发现链路改完配置又改回去系列索引21 篇阅读路线Agent 放权前先补权限、出站、发现这三道口子专栏Agent 工程实战如果这篇对你有用欢迎收藏想追更可以关注「Agent 工程实战」专栏。