不是所有任务都要实时:批处理、离峰调度与降级队列
本文定位:「单 Agent 成本与性能工程」系列第 11 篇,也是本簇收官篇。前十篇都在改 Agent 本身——改轨迹、改装配顺序、改模型、改上下文。本篇是唯一一个不用动 Agent 一行代码的杠杆:换个时间跑。
上篇回顾:「第 10 篇 压缩省下的钱与丢掉的质量」讲的是上下文压缩,代价是丢信息;本篇的代价是等,以及等待期间外部世界可能变了。「第 4 篇 成本与性能 延迟预算」的延迟预算是本篇的对照项——那里讨论的是「怎么让它更快」,这里讨论的是「哪些可以慢」。
下一篇:「第 12 篇 Agent成本与性能 系列总结」,本簇收官。回到「 系列导航 」看整簇的依赖与三条阅读路线;本篇没覆盖的稳定性限流(SA-O)与评测门禁(SA-E)属于相邻簇。
引子:换个时间跑,账能差多少?
一个客服 Agent 每晚要处理 5,000 条任务。全部实时跑,月账单 10.7 万元,而其中大约一半的任务没有人坐在屏幕前等——它们是日报生成、索引预热、批量改写这类「提交完就走」的活。
把这些任务挪到批量队列、离峰窗口,单价直接打对折。听起来是白捡的钱,但实际做完会发现三件事:批量 API 有最小批次要求,凑不够就没有折扣;延迟太久会让结果失效——外部世界在等待期间变了,任务得重跑;而用户如果不知道自己在等,会把「慢」记成「坏」。
先划边界:本篇管单价——换个时间更便宜,外加把这套机制跑起来的最低实现要求(批量 API 对接、降级阈值、完成通知)。限流排队保命(配额打满时怎么不让系统崩)属于可观测与运维那一簇(SA-O07):那里排队是为了活下来,这里排队是为了省钱,两者可以共用一套设施,但目标函数不同。背压算法与熔断链不展开,本篇也不写通用任务队列教程。
一、可延迟性是业务判断,不是技术判断
三类省钱机制——批量 API 折扣、离峰算力折扣、异步队列削峰——全都建立在同一个前提上:这批任务可以晚点做。而这个前提是业务给的,不是工程能决定的。
判断顺序只有三步:先问「有没有人在等」,再问「结果会不会过期」,最后问「有没有副作用」:
#mermaid-svg-kLWV9Fgf7mCiR0us{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kLWV9Fgf7mCiR0us .error-icon{fill:#552222;}#mermaid-svg-kLWV9Fgf7mCiR0us .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kLWV9Fgf7mCiR0us .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kLWV9Fgf7mCiR0us .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kLWV9Fgf7mCiR0us .marker.cross{stroke:#333333;}#mermaid-svg-kLWV9Fgf7mCiR0us svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kLWV9Fgf7mCiR0us p{margin:0;}#mermaid-svg-kLWV9Fgf7mCiR0us .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-kLWV9Fgf7mCiR0us .cluster-label text{fill:#333;}#mermaid-svg-kLWV9Fgf7mCiR0us .cluster-label span{color:#333;}#mermaid-svg-kLWV9Fgf7mCiR0us .cluster-label span p{background-color:transparent;}#mermaid-svg-kLWV9Fgf7mCiR0us .label text,#mermaid-svg-kLWV9Fgf7mCiR0us span{fill:#333;color:#333;}#mermaid-svg-kLWV9Fgf7mCiR0us .node rect,#mermaid-svg-kLWV9Fgf7mCiR0us .node circle,#mermaid-svg-kLWV9Fgf7mCiR0us .node ellipse,#mermaid-svg-kLWV9Fgf7mCiR0us .node polygon,#mermaid-svg-kLWV9Fgf7mCiR0us .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kLWV9Fgf7mCiR0us .rough-node .label text,#mermaid-svg-kLWV9Fgf7mCiR0us .node .label text,#mermaid-svg-kLWV9Fgf7mCiR0us .image-shape .label,#mermaid-svg-kLWV9Fgf7mCiR0us .icon-shape .label{text-anchor:middle;}#mermaid-svg-kLWV9Fgf7mCiR0us .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kLWV9Fgf7mCiR0us .rough-node .label,#mermaid-svg-kLWV9Fgf7mCiR0us .node .label,#mermaid-svg-kLWV9Fgf7mCiR0us .image-shape .label,#mermaid-svg-kLWV9Fgf7mCiR0us .icon-shape .label{text-align:center;}#mermaid-svg-kLWV9Fgf7mCiR0us .node.clickable{cursor:pointer;}#mermaid-svg-kLWV9Fgf7mCiR0us .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kLWV9Fgf7mCiR0us .arrowheadPath{fill:#333333;}#mermaid-svg-kLWV9Fgf7mCiR0us .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kLWV9Fgf7mCiR0us .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kLWV9Fgf7mCiR0us .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kLWV9Fgf7mCiR0us .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kLWV9Fgf7mCiR0us .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kLWV9Fgf7mCiR0us .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kLWV9Fgf7mCiR0us .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kLWV9Fgf7mCiR0us .cluster text{fill:#333;}#mermaid-svg-kLWV9Fgf7mCiR0us .cluster span{color:#333;}#mermaid-svg-kLWV9Fgf7mCiR0us div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kLWV9Fgf7mCiR0us .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kLWV9Fgf7mCiR0us rect.text{fill:none;stroke-width:0;}#mermaid-svg-kLWV9Fgf7mCiR0us .icon-shape,#mermaid-svg-kLWV9Fgf7mCiR0us .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kLWV9Fgf7mCiR0us .icon-shape p,#mermaid-svg-kLWV9Fgf7mCiR0us .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kLWV9Fgf7mCiR0us .icon-shape .label rect,#mermaid-svg-kLWV9Fgf7mCiR0us .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kLWV9Fgf7mCiR0us .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kLWV9Fgf7mCiR0us .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kLWV9Fgf7mCiR0us :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是
否
会
否
有
无
分钟级
小时级
不确定
否
是
任务进
有没有人在等 ?
实时队列
结果会过期吗 ?依赖实时外部状态
有副作用吗 ?会修改外部系统
能接受多久 ?
批量队列(短窗口)
离峰窗口 + 批量
实时队列,但标记可降级
合并批次 → 提交 → 结果回传
失效检查:上下文还成立吗 ?
重跑(成本翻倍)
回传并告知完成时间
判据落到具体任务特征上是这样的:
| 用户在对话框里等 | 否 | 0 | 唯一的硬约束 |
| 结果会被立刻消费(写工单 / 触发下游) | 否 | 0 | 下游有 SLA |
| 有明确 SLA(如 5 分钟内) | SLA 决定 | ≤ SLA | 按 SLA 反推批量窗口 |
| 定时批处理(日报 / 巡检) | 是 | 小时级 | 天然候选 |
| 预计算 / 预热(缓存填充、索引构建) | 是 | 小时级 | 最佳候选,结果不怕晚 |
| 用户提交后离开,回来再看 | 是 | 分钟~小时 | 必须告知预计时间 |
| 结果依赖实时外部状态(价格 / 库存) | 否 | 0 | 延迟即失效 |
| 任务会修改外部系统(下单 / 发消息) | 否 | 0 | 副作用不可延迟 |
这三问全是业务问题。工程侧能提供的只有两个数字:延迟多久、失效率多高;「这个延迟能不能接受」要业务签字。把这一步跳过、直接按技术可行性分流,是这套机制最常见的失败方式——技术上能延迟的任务,业务上可能一秒都不能等。
二、三类省钱机制各值多少?
基线是「全部实时」:日均 5,000 条,白天 12 小时占 70%,实时队列容量 240 条 / 小时。四条路径对比:
| P0 全部实时 | 107,617 | 0.0 | 58.8 | 146.2 | — | 77.7% | 22.3% |
| P1 可延迟走批量 | 72,773 | 32.4 | 0.7 | 0.7 | 90 | 100.0% | 2.7% |
| P2 可延迟走离峰 | 88,659 | 17.6 | 0.7 | 0.7 | 631 | 100.0% | 16.0% |
| P3 离峰 + 批量 | 71,398 | 33.7 | 0.7 | 0.7 | 361 | 100.0% | 10.0% |
(月 150,000 条,单次 29,635 token,混合单价 19.8 元 / 百万;批量折后单价系数 0.50,离峰 0.75,批量窗口 120 分钟。「实时 P50/P95」是必须实时那部分的排队等待,即用户可感知延迟;「可延迟完成」是可延迟任务从提交到拿到结果的时间。以上为脚本内置合成数据。)
三个结论:
第一,离峰折扣单独用不划算。 P2 只省 17.6%,比批量的 32.4% 差一大截,而可延迟任务要等 631 分钟(10.5 小时)。原因是离峰折扣只有 25%,却带来了 16% 的失效重跑——省下的钱有一半以上又赔进了重跑(第八节会拆这个数)。离峰真正的价值不在折扣,在于它给批量提供了一个容量充足的窗口,所以 P3(离峰 + 批量)才是最好的。
第二,P3 相对 P1 只多省 1.3pp,延迟却从 90 分钟涨到 361 分钟。 多等 4.5 小时换 1.3pp 不值。默认选 P1:批量折扣为主,离峰只是容量兜底,不是省钱手段。
削峰是一笔独立的收益
这一点最容易被漏掉。看表 1 的「实时 P50/P95」那两列:基线 P0 是 58.8 / 146.2 分钟,完成率 77.7%——因为一半任务在队列里等,另一半任务的用户等不及走了。而一旦把 45% 的任务挪出实时队列,实时侧立刻畅通(0.7 分钟),完成率回到 100%。
也就是说,即使完全没有折扣,只做削峰也有收益。表 3 里那两行「窗口 15 / 30 分钟、拿不到折扣」的方案仍然省 17.3% / 17.1%,省的全是放弃重试的浪费(P0 有 22.3% 的任务因用户放弃而被重跑一次)。
所以这套机制的账要分两笔记:
① 单价账:批量 / 离峰折扣 × 可延迟任务量
② 削峰账:实时队列不再拥塞 → 放弃重试消失 → 完成率回升
只算①会低估它,尤其是当你的实时队列本来就接近打满的时候。
三、批量窗口该怎么定?
批量 API 通常有最小批次要求(本例 100 条)。窗口太小凑不满,折扣拿不到;窗口太大,上下文失效重跑变多。两头夹住:
| 15 | 0.0% | 38 | 2.6% | 89,034 | 17.3% |
| 30 | 0.0% | 45 | 3.1% | 89,235 | 17.1% |
| 60 | 69.6% | 60 | 4.1% | 75,287 | 30.0% |
| 120 | 84.0% | 90 | 6.1% | 72,773 | 32.4% |
| 240 | 100.0% | 150 | 9.9% | 70,171 | 34.8% |
| 480 | 100.0% | 270 | 17.1% | 71,598 | 33.5% |
| 960 | 100.0% | 510 | 29.8% | 74,119 | 31.1% |
窗口越长越便宜吗?
不是。折扣在 240 分钟就吃到 100% 了,再拉长窗口只是把延迟从 150 分钟推到 510 分钟、把失效重跑率从 9.9% 抬到 29.8%,净收益反而降了 3.7 个百分点。
而窗口太短的问题更隐蔽:15 分钟和 30 分钟这两行的折扣生效率是 0——每个窗口只有 23~47 条任务,凑不满 100 条。这时你以为在用批量 API,实际付的是全价,还平白多等了 38~45 分钟。这两种失败方式(太短拿不到折扣、太长失效太多)在监控上长得很像:都是「上了批量但没省钱」,但修法完全相反。
判据可以写成一行:
窗口下限 = MIN_BATCH ÷ (每小时可延迟任务量) # 保证凑得够
窗口上限 = TAU × ln(1 ÷ (1 − 可接受失效率)) − 服务时间 # 保证不过期
本例:下限 ≈ 100 ÷ 130 ≈ 46 分钟;上限(可接受失效 10%)≈ 1440 × 0.105 − 30 ≈ 121 分钟
两个约束的交集就是可用区间。本例数据里最优落在 240 分钟,比上面按 10% 失效率算出的上限略宽,因为折扣生效率的跃迁(69.6% → 100%)带来的收益大于那一点的失效成本。实操上不要拍一个数,直接扫一遍这张表。
四、批量 API 该怎么对接?
窗口定好之后,是工程对接。三种取结果的方式,按你的回传通道选:
| 提交 + 轮询 | 提交拿 batch_id,定时查状态直到完成 | 没有公网回调入口的内部系统 | 轮询本身也是 API 调用,要计入成本与限流;退避从 30 秒起(经验值) |
| 提交 + 回调 | 提交时注册 webhook,完成时厂商推送 | 有公网入口的在线服务 | 回调会重发,消费端要幂等;验签防伪造 |
| 提交 + 结果文件 | 完成后拉取 JSONL 结果文件解析 | 大批次、结果量大的场景 | 文件有有效期(常见 24 小时,以厂商文档为准),要及时归档 |
失败重试与死信队列的最低要求——缺一条,批量就会从「省钱」变成「丢任务」:
- 重试只对幂等失败生效:限流(429)、5xx、网络超时可以重试;批次内单条失败要逐条重提,不能整批重跑,否则已成功的任务会再付一次钱。
- 退避与上限:指数退避,上限 5 次左右(经验值);超过上限进死信。
- 死信队列:存原始请求、失败原因、批次号,人工兜底或脚本补偿;死信要有积压告警——批量任务失败是静默的,没人会来投诉。
- 每日对账:核对「提交条数 = 成功 + 失败 + 死信」,差额就是丢失的任务。
五、可延迟占比决定天花板
同样的机制,可延迟任务占比不同,天花板完全不同(P3 方案):
| 15% | 90,947 | 15.5 | 20.2 | 100.0% | 3.3% |
| 30% | 76,938 | 28.5 | 1.6 | 100.0% | 6.7% |
| 45% | 71,398 | 33.7 | 0.7 | 100.0% | 10.0% |
| 60% | 59,406 | 44.8 | 0.5 | 100.0% | 13.3% |
| 75% | 52,253 | 51.4 | 0.4 | 100.0% | 16.6% |
占比每提高 15pp,节省多 6~8pp,但边际收益在下降(15→30 涨 13pp,60→75 只涨 6.6pp),因为失效重跑率同步上升。
注意 15% 那一行:实时 P95 仍有 20.2 分钟——占比太低时削峰不够,队列还是会堵。这时别急着上批量,先去「第 4 篇 成本与性能 延迟预算」查分层超时,或者重谈一遍业务口径:许可占比是谈出来的,不是测出来的。
六、降级队列 vs 升配额:两条扩容路径
实时 P95 太高时,常规做法是加配额。但还有一条路:在高峰小时把更多任务降级进异步队列。
| P0 基线:全实时,配额 240 | 146.2 | 77.7% | 107,617 | 19,200 | 126,817 | 0.0% |
| 升配额到 320 | 2.0 | 100.0% | 88,016 | 25,600 | 113,616 | 10.4% |
| 升配额到 400 | 0.5 | 100.0% | 88,016 | 32,000 | 120,016 | 5.4% |
| P1:45% 走批量,配额不变 | 0.7 | 100.0% | 72,773 | 19,200 | 91,973 | 27.5% |
| P1 + 高峰再降 30% | 0.4 | 100.0% | 65,700 | 19,200 | 84,900 | 33.1% |
(配额月费按 8,000 元 / 百条·小时估算。)
两条对比:
- 升到 400 比升到 320 更贵,收益几乎为零(P95 2.0 → 0.5 分钟)。过度扩容就是为用户感知不到的延迟付钱。
- 降级队列在延迟上打赢升配额,账单还降了 27.5%。 因为两者买的东西不同:升配额是用钱换容量,降级队列是用延迟换容量——而这部分延迟本来没人等。
「高峰再降 30%」这一行是这套机制的完整形态:平时按业务口径分流,高峰时临时把阈值再推高一档。它把实时 P95 压到 0.4 分钟,总成本降到 84,900 元(−33.1%)。代价是高峰期的可延迟任务要多等一会儿——而这正是它们被标记为「可延迟」的意义。
七、降级阈值该按什么调?
固定比例(「高峰降 30%」)的问题是不看队列状态:队列空空时是白扔延迟预算,队列堵死时又降得不够。正确的输入是积压量和预计等待时间:
实时队列预计等待 = 当前积压 ÷ 实时容量 (与第十一节 sim_rt 同口径)
降级档位 = 使「实时等待 + 降级任务的额外等待」不超过业务上限的最小档位
三步落地:
对应三档配置(档位与 pp 数为构造示例,按你的容量和 SLA 标定):
| T0 常态 | < 1 分钟 | 按业务口径分流 | 0 |
| T1 | 1~5 分钟 | 降级阈值 +15pp | 约一个批量窗口 |
| T2 | > 5 分钟 | 降级阈值 +30pp,暂停非关键实时任务 | 约两个批量窗口 |
八、延迟的隐性成本
把延迟换算成钱,主要靠一个函数:
失效概率 p(延迟) = 1 − exp(−延迟 / TAU) TAU = 上下文失效时间常数(本例 1,440 分钟)
失效的任务要重跑一次 → 成本翻倍
拆解三档方案的毛节省与失效重跑:
| P1 可延迟走批量 | 16,635 | 1,392 | 8.4% | 34,844 |
| P2 可延迟走离峰 | 9,902 | 10,545 | 106.5% | 18,958 |
| P3 离峰 + 批量 | 20,794 | 4,176 | 20.1% | 36,219 |
(毛节省只算单价差。净节省 = 基线实际成本 − 方案成本,它还包含削峰带来的放弃重试减少。)
P2 那一行是本节重点:失效重跑成本超过了毛节省(106.5%)。离峰折扣带来的钱被「等待期间世界变了、任务得重跑」吃光还要倒贴。所以「离峰」必须和「批量」一起用——单靠离峰折扣覆盖不了它的延迟成本。
TAU(上下文失效时间常数)是这套模型里最需要标定的参数。它取决于你的任务依赖什么:依赖代码库的 TAU 可能是几小时,依赖业务数据的可能是一天,依赖新闻的可能只有几十分钟。
用户不知道自己在等,算不算成本?
答案是:算。而且这是最贵的一项。它在账单上看不见,却出现在留存和满意度里。具体到实现上,只有三件套:
九、四件套结构该怎么搭?
#mermaid-svg-7iiwGCwIbw7smeRb{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7iiwGCwIbw7smeRb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7iiwGCwIbw7smeRb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7iiwGCwIbw7smeRb .error-icon{fill:#552222;}#mermaid-svg-7iiwGCwIbw7smeRb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7iiwGCwIbw7smeRb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7iiwGCwIbw7smeRb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7iiwGCwIbw7smeRb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7iiwGCwIbw7smeRb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7iiwGCwIbw7smeRb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7iiwGCwIbw7smeRb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7iiwGCwIbw7smeRb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7iiwGCwIbw7smeRb .marker.cross{stroke:#333333;}#mermaid-svg-7iiwGCwIbw7smeRb svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7iiwGCwIbw7smeRb p{margin:0;}#mermaid-svg-7iiwGCwIbw7smeRb .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-7iiwGCwIbw7smeRb .cluster-label text{fill:#333;}#mermaid-svg-7iiwGCwIbw7smeRb .cluster-label span{color:#333;}#mermaid-svg-7iiwGCwIbw7smeRb .cluster-label span p{background-color:transparent;}#mermaid-svg-7iiwGCwIbw7smeRb .label text,#mermaid-svg-7iiwGCwIbw7smeRb span{fill:#333;color:#333;}#mermaid-svg-7iiwGCwIbw7smeRb .node rect,#mermaid-svg-7iiwGCwIbw7smeRb .node circle,#mermaid-svg-7iiwGCwIbw7smeRb .node ellipse,#mermaid-svg-7iiwGCwIbw7smeRb .node polygon,#mermaid-svg-7iiwGCwIbw7smeRb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7iiwGCwIbw7smeRb .rough-node .label text,#mermaid-svg-7iiwGCwIbw7smeRb .node .label text,#mermaid-svg-7iiwGCwIbw7smeRb .image-shape .label,#mermaid-svg-7iiwGCwIbw7smeRb .icon-shape .label{text-anchor:middle;}#mermaid-svg-7iiwGCwIbw7smeRb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7iiwGCwIbw7smeRb .rough-node .label,#mermaid-svg-7iiwGCwIbw7smeRb .node .label,#mermaid-svg-7iiwGCwIbw7smeRb .image-shape .label,#mermaid-svg-7iiwGCwIbw7smeRb .icon-shape .label{text-align:center;}#mermaid-svg-7iiwGCwIbw7smeRb .node.clickable{cursor:pointer;}#mermaid-svg-7iiwGCwIbw7smeRb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7iiwGCwIbw7smeRb .arrowheadPath{fill:#333333;}#mermaid-svg-7iiwGCwIbw7smeRb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7iiwGCwIbw7smeRb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7iiwGCwIbw7smeRb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7iiwGCwIbw7smeRb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7iiwGCwIbw7smeRb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7iiwGCwIbw7smeRb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7iiwGCwIbw7smeRb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7iiwGCwIbw7smeRb .cluster text{fill:#333;}#mermaid-svg-7iiwGCwIbw7smeRb .cluster span{color:#333;}#mermaid-svg-7iiwGCwIbw7smeRb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7iiwGCwIbw7smeRb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7iiwGCwIbw7smeRb rect.text{fill:none;stroke-width:0;}#mermaid-svg-7iiwGCwIbw7smeRb .icon-shape,#mermaid-svg-7iiwGCwIbw7smeRb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7iiwGCwIbw7smeRb .icon-shape p,#mermaid-svg-7iiwGCwIbw7smeRb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7iiwGCwIbw7smeRb .icon-shape .label rect,#mermaid-svg-7iiwGCwIbw7smeRb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7iiwGCwIbw7smeRb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7iiwGCwIbw7smeRb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7iiwGCwIbw7smeRb :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
结果回传通道
批量合并器
按窗口聚合批次
检查 ≥ MIN_BATCH ?
提交批量 API / 离峰算力
调度器
实时队列(FIFO + 优先级)
批量队列(窗口聚合)
离峰窗口(时间窗 + 容量检查)
降级开关:高峰时把阈值推高一档
分类器
任务特征:有没有人在等 / 会不会过期 / 有没有副作用
可延迟标签 + 可接受延迟时长
优先级:关键 / 普通 / 可降级
失效检查:上下文还成立吗 ?
完成通知(站内信 / 回调)
预计完成时间的实测校准
L1 的「可延迟标签」必须由业务侧维护,不能由工程侧推断——第一节那张表就是它的规格。L4 最下面那格(预计完成时间的实测校准)是让「用户不知道自己在等」可控的唯一手段:告诉用户的时间要来自实测 P95,每次跑完回写一次。
十、数据怎么流过去?
#mermaid-svg-YUtQyHAqmqDlBO15{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YUtQyHAqmqDlBO15 .error-icon{fill:#552222;}#mermaid-svg-YUtQyHAqmqDlBO15 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YUtQyHAqmqDlBO15 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YUtQyHAqmqDlBO15 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YUtQyHAqmqDlBO15 .marker.cross{stroke:#333333;}#mermaid-svg-YUtQyHAqmqDlBO15 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YUtQyHAqmqDlBO15 p{margin:0;}#mermaid-svg-YUtQyHAqmqDlBO15 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-YUtQyHAqmqDlBO15 .cluster-label text{fill:#333;}#mermaid-svg-YUtQyHAqmqDlBO15 .cluster-label span{color:#333;}#mermaid-svg-YUtQyHAqmqDlBO15 .cluster-label span p{background-color:transparent;}#mermaid-svg-YUtQyHAqmqDlBO15 .label text,#mermaid-svg-YUtQyHAqmqDlBO15 span{fill:#333;color:#333;}#mermaid-svg-YUtQyHAqmqDlBO15 .node rect,#mermaid-svg-YUtQyHAqmqDlBO15 .node circle,#mermaid-svg-YUtQyHAqmqDlBO15 .node ellipse,#mermaid-svg-YUtQyHAqmqDlBO15 .node polygon,#mermaid-svg-YUtQyHAqmqDlBO15 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YUtQyHAqmqDlBO15 .rough-node .label text,#mermaid-svg-YUtQyHAqmqDlBO15 .node .label text,#mermaid-svg-YUtQyHAqmqDlBO15 .image-shape .label,#mermaid-svg-YUtQyHAqmqDlBO15 .icon-shape .label{text-anchor:middle;}#mermaid-svg-YUtQyHAqmqDlBO15 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YUtQyHAqmqDlBO15 .rough-node .label,#mermaid-svg-YUtQyHAqmqDlBO15 .node .label,#mermaid-svg-YUtQyHAqmqDlBO15 .image-shape .label,#mermaid-svg-YUtQyHAqmqDlBO15 .icon-shape .label{text-align:center;}#mermaid-svg-YUtQyHAqmqDlBO15 .node.clickable{cursor:pointer;}#mermaid-svg-YUtQyHAqmqDlBO15 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YUtQyHAqmqDlBO15 .arrowheadPath{fill:#333333;}#mermaid-svg-YUtQyHAqmqDlBO15 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YUtQyHAqmqDlBO15 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YUtQyHAqmqDlBO15 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YUtQyHAqmqDlBO15 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YUtQyHAqmqDlBO15 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YUtQyHAqmqDlBO15 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YUtQyHAqmqDlBO15 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YUtQyHAqmqDlBO15 .cluster text{fill:#333;}#mermaid-svg-YUtQyHAqmqDlBO15 .cluster span{color:#333;}#mermaid-svg-YUtQyHAqmqDlBO15 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YUtQyHAqmqDlBO15 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YUtQyHAqmqDlBO15 rect.text{fill:none;stroke-width:0;}#mermaid-svg-YUtQyHAqmqDlBO15 .icon-shape,#mermaid-svg-YUtQyHAqmqDlBO15 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YUtQyHAqmqDlBO15 .icon-shape p,#mermaid-svg-YUtQyHAqmqDlBO15 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YUtQyHAqmqDlBO15 .icon-shape .label rect,#mermaid-svg-YUtQyHAqmqDlBO15 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YUtQyHAqmqDlBO15 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YUtQyHAqmqDlBO15 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YUtQyHAqmqDlBO15 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
实时
批量
离峰
否
是
任务请求
可延迟标签 + 可接受延迟
实时 / 批量 / 离峰 ?
实时队列状态:积压量 / 预计等待
批次清单:条数 ≥ MIN_BATCH ?
离峰窗口:等待时长
用量记录:实时单价
节省统计:单价账 + 削峰账
放弃率:等待 > 阈值 ?
重跑成本计入
失效检查:上下文还成立吗 ?
结果回传 + 完成通知
窗口与阈值的回扫重算
S8 「单价账 + 削峰账」的分离是本篇最重要的一条记账纪律:只看单价账会低估这套机制,只看削峰账会让你放弃它。S13 是让参数跟着数据走的回边——窗口、降级阈值、预计完成时间都要定期回扫重算。
十一、这套东西怎么跑起来?
核心是三个函数:排队模拟、失效概率、方案计价。
def sim_rt(load, cap):
"""实时队列 FIFO 模拟:返回逐小时等待(分钟)与放弃率。"""
q, waits, ab_sum, done_sum = 0.0, [], 0.0, 0.0
for a in load:
backlog = q
q += a
served = min(q, cap)
if a >= cap: # 到达超过容量:积压继续增长
w = (backlog + (a – cap) / 2.0) / cap + 1.0 / (cap * 0.1)
else: # 有富余容量:排队 + 服务等待
w = backlog / cap + 1.0 / (cap – a)
q -= served
waits.append(w * 60.0)
ab_sum += abandon_rate(w * 60.0) * served
done_sum += served
return ab_sum / done_sum, waits
def stale_prob(delay_min):
"""延迟执行导致上下文失效的概率:外部世界在等待期间变了。"""
return 1.0 – math.exp(–delay_min / TAU)
def plan(mode, delayable=0.45, window_min=120, cap=240, degrade=0.0):
"""mode: rt / batch / offpeak / both。degrade > 0:高峰小时再降级一档。"""
hit = batch_hit_rate(window_min, delayable) # 能凑够 MIN_BATCH 的量占比
price = {"rt": 1.0, "batch": hit * BATCH_DISC + (1 – hit),
"offpeak": OFFPEAK_DISC,
"both": hit * BATCH_DISC * OFFPEAK_DISC + (1 – hit)}[mode]
ab, waits = sim_rt(arr_rt, cap)
stale = stale_prob(dl_delay) if mode != "rt" else 0.0
cost = n_rt * (1 + ab) * UNIT + n_dl * (1 + stale) * UNIT * price
return dict(cost=cost, total=cost + cap / 100 * QUOTA_FEE,
rt_p95=pct(waits, 0.95), done=1 – ab, stale=stale)
输出长什么样
完整脚本 batch_sched.py 只依赖标准库,无参数运行时用内置合成数据演示输出格式:
— 表1 四方案对照(月 150,000 条,可延迟占比 45%,批量窗口 120 分钟)—
P0 全部实时 107,617 0.0% P95 146.2 完成率 77.7% 重跑率 22.3%
P1 可延迟走批量 72,773 32.4% P95 0.7 完成率 100% 重跑率 2.7%
P2 可延迟走离峰 88,659 17.6% P95 0.7 完成率 100% 重跑率 16.0%
P3 离峰 + 批量 71,398 33.7% P95 0.7 完成率 100% 重跑率 10.0%
— 表2 批量窗口敏感性(P1)—
15 分 折扣生效 0.0% 延迟 38 失效 2.6% 89,034 17.3%
240 分 折扣生效 100.0% 延迟 150 失效 9.9% 70,171 34.8%
960 分 折扣生效 100.0% 延迟 510 失效 29.8% 74,119 31.1%
— 表4 降级队列 vs 升配额(含配额月费)—
P0 基线 配额 240 P95 146.2 77.7% 总成本 126,817 0.0%
升配额到 320 P95 2.0 100% 总成本 113,616 10.4%
升配额到 400 P95 0.5 100% 总成本 120,016 5.4%
P1 45% 走批量 P95 0.7 100% 总成本 91,973 27.5%
P1 + 高峰再降 30% P95 0.4 100% 总成本 84,900 33.1%
— 表5 隐性成本拆解 —
P1 毛节省 16,635 失效重跑 1,392 8.4% 净节省 34,844
P2 毛节省 9,902 失效重跑 10,545 106.5% 净节省 18,958
P3 毛节省 20,794 失效重跑 4,176 20.1% 净节省 36,219
以上是脚本内置合成数据的运行结果(截至 2026-10,Python 3.13 + 标准库跑通),仅用于演示口径与量级。
调度策略配置长什么样?
# 离峰调度与降级队列配置(构造示例:数值对应本文基线场景)
queues:
realtime:
capacity_per_hour: 240 # 实时容量(条/小时)
sla_p95_min: 5 # 实时侧 P95 业务上限(分钟,经验值)
batch:
min_batch: 100 # 批量 API 最小批次
window_min: 120 # 批量窗口
offpeak: "01:00-07:00" # 离峰窗口
batch_discount: 0.50 # 批量折扣系数
offpeak_discount: 0.75 # 离峰折扣系数
degrade: # 降级档位:按实时队列预计等待动态调
hysteresis_min: 1 # 回落迟滞(分钟,经验值)
tiers:
– when_wait_min: [1, 5]
raise_delayable_pp: 15 # 构造示例
– when_wait_min: [5, null]
raise_delayable_pp: 30 # 构造示例
notify:
eta_source: measured_p95 # 预计完成时间按实测 P95
channels: [webhook, site_message]
escalate_after_factor: 1.5 # 超预计 1.5 倍未完成则升级(经验值)
dlq_alert_backlog: 50 # 死信积压告警线(构造示例)
四组字段各有出处:queues 来自第一节的业务口径,batch 对应第三节的窗口判据,degrade 是第七节的动态档位,notify 落实第八节的三件套。
十二、什么时候不该上这套?
- 可延迟占比低于 15%。 削峰不够、折扣吃不饱,收益只剩 15.5% 且实时队列仍然堵。这时的钱应该花在第 1、2 篇的轨迹与缓存上。
- 结果依赖实时状态。 价格、库存、在线人数这类任务延迟即失效,TAU 太短,失效重跑会吃掉全部折扣。
- 没有回传通道。 异步任务做完了没人知道,等于不存在。先有通知,再上异步——顺序反了会把体验做成负增长。
- 可延迟标签没人维护。 业务口径会变(新的下游接进来、SLA 改了),标签过期后这套机制会静默地把不该延迟的任务延迟掉。
- 你在用它解决稳定性问题。 如果动机是「配额打满会崩」,那要的是限流与背压,不是批量折扣——那是 SA-O07 的领地。
分工声明:本篇只算单价账与削峰账,批量对接只写到「不丢任务」的最低要求;背压、限流算法、熔断链归可观测与运维那一簇(SA-O07)——共用设施,目标函数不同:那边是「别崩」,这边是「更便宜」。
十三、小结
这套机制的完整形态是四件套:业务侧定可延迟标签,调度侧管实时 / 批量 / 离峰三条队列,合并侧凑批次,回传侧把「等」变成可预期的等待。账要分两本记:单价账(折扣 × 可延迟量)和削峰账(放弃重试消失、完成率回升)——只算第一本会低估它。至此本篇作为系列收尾:从把成本写成一个能算的量(C01),到不让它花冤枉钱(C02–C10),再到换个时间花(C11)。
几句话带走:
- 可延迟性是业务判断:有没有人在等、会不会过期、有没有副作用,三问都要业务签字;工程侧只提供「延迟多久、失效率多高」两个数字。
- 削峰是一笔独立的收益:即使没有折扣也省 17.3%,省的是放弃重试。
- 批量为主、离峰兜底:批量省 32.4%,离峰单独用只省 17.6% 且重跑率 16%;窗口有最优值(本例 240 分钟),两端失败的现象很像、修法相反。
- 降级阈值按队列深度动态调:看积压量和预计等待,不用固定比例;回落要迟滞。
- 失效重跑是最大的隐性成本:离峰那一档达毛节省的 106.5%;TAU 必须用自己的数据标定。
你的 Agent 里,有多少比例的任务没有人坐在屏幕前等?如果没统计过,这个数字大概率被默认成了 0——而它是唯一决定天花板的变量。
上一篇:上下文压缩的成本账:省下的钱与丢掉的质量。 待做项:导出最近一周的任务,按第一节那三问打一遍标签,先拿到可延迟占比;超过 30% 再上批量队列,不到就先回第 1 篇 轨迹成本模型与长尾归因压轨迹——那是零一次性成本的杠杆。
本系列到此收尾。回到系列导航,十一篇的依赖与三条阅读路线都在那一页;系列外的「稳定性限流」与「评测门禁」,是相邻的两个系列。还在从笔记里改写,可以先收藏一下、加个关注,以免错过后面的笔记系列。
参考
- Anthropic Batch Processing 文档
- AWS Batch 用户指南
- Amazon SQS 开发者指南
网硕互联帮助中心






评论前必须登录!
注册