能离线跑的活别在线跑:批处理怎么省钱,离线任务怎么跑稳
上个月帮一个团队看账单,他们把三周时间花在压提示词长度上,从 900 token 砍到 700 token,省了两成多。这个活干得挺漂亮。但翻他们的调用日志时我发现,占了六成调用量的那条链路,是每天凌晨给昨天新增的十几万条商品描述打标签——没有任何人在等这个结果,它却和用户实时问答走同一条同步接口、同一套常驻资源。
这是我见过最普遍的一类浪费:优化成本的时候,大家条件反射地盯着单价和 token 数,很少有人先退一步问一句「这个任务真的需要实时吗」。而这个问题的答案一旦是「不需要」,可选的省钱路径立刻多出好几条,而且这几条路径里有一条格外特别——批处理大概是所有省钱手段里副作用最小的一种。
砍 token 会掉信息,换小模型会掉质量,加缓存要处理一致性,降并发要牺牲吞吐。这些都是在做交换。而把一个本来就没人等的任务从「在线逐条」改成「离线批量」,你付出的只是延迟,而那个延迟本来就没人感知。
一、先分清:哪些任务其实不需要实时
判断标准不是任务的类型,而是两个可观察的特征:
特征一:用户不在等结果。 请求发出的那一刻,没有任何人盯着屏幕上的转圈动画。任务的产出会先落到某个存储里,等到有人真正需要的时候再读出来。
特征二:有明确的截止时间,而不是即时性要求。 「明早八点前算完」是截止时间,「三秒内返回」是即时性。截止时间给你的是一个窗口,你可以在窗口里自由安排;即时性给你的是一堵墙,你只能常备资源随时待命。
拿这两条去筛,多数团队会发现能转离线的活比想象中多:
| 任务 | 为什么能转离线 | 常见的错误做法 |
|---|---|---|
| 数据清洗与标注 | 结果进数据仓,下游按天消费 | 挂在数据入库的同步链路上,一条卡住整个管道 |
| 批量摘要与分类 | 存量文档一次性处理,增量按天追加 | 用户打开列表页时才现算,既慢又重复算 |
| 索引构建与向量化 | 检索侧读的是索引,不是生成过程 | 每次写入触发一次同步 embedding 调用 |
| 报表生成 | 报表本身就是「截至某时刻」的快照 | 每次刷新页面重跑一遍 |
| 离线评测 | 评测是给自己看的,晚一小时没人在意 | 跟线上服务抢同一份配额 |
反过来说,判断的时候有一个容易搞混的地方:「用户会看到结果」不等于「用户在等结果」。用户明早会打开报表,但他现在不在等。这两者的区别,正好就是你能省下的那部分钱。
我一般的做法是拉一份最近 7 天的调用明细,按调用来源(哪个服务、哪条链路)分组统计调用量,然后逐组问一句「这一组的调用发生时,有人在等吗」。这个盘点通常半天能做完,收益却比抠一个月提示词大得多。相关的批量推理链路怎么组织,可以参考 批量推理怎么做才不烧钱 里的思路。
二、离线带来的三类省钱机会
任务转成离线之后,省钱不是自动发生的,你得主动去拿。以下三条路径可以叠加使用。
路径一:平台的批处理档位
多数推理平台都提供某种形式的异步或批量接口,代价是你要接受更长的完成时间,回报是更低的单位成本。这条路径的好处是改造量最小——通常只是换一个提交入口、加一个取结果的步骤,模型和提示词都不用动。
**具体的折扣幅度、接口路径、参数名、完成时间承诺,各家差别很大,而且经常调整,请以你所用平台的官方文档和控制台为准。**我在这里不写任何数字,因为写死了反而会误导你:我见过团队照着一篇过期文章的参数去调,报错报了两天。
这条路径实际落地时,真正的工作量不在调接口,而在于你的业务代码要能接受「提交」和「拿结果」在时间上分开。如果你现有的函数签名是「传进去一段文本,返回一个标签」,那它天然是同步的,改造要动的是调用它的上层逻辑,不是这个函数本身。这一步的工作量通常比想象中小,但一定不为零,排期时别按「改个配置」估。
路径二:任务式按秒计费,只为实际运行的秒数付钱
有一类平台不按 token 计费,而是按 GPU 实际运行的秒数计费。Replicate 就是这样一家,它的定价页同时给出两套口径:GPU 按秒计费,以及部分语言模型按 token 计费。
按秒计费的部分(数据来自官方定价页):
| GPU | 标识 | 价格 |
|---|---|---|
| Nvidia L40S | gpu-l40s | $0.000975/sec |
| Nvidia A100 (80GB) | gpu-a100-large | $0.001400/sec |
| 2x Nvidia A100 (80GB) | gpu-a100-large-2x | $0.002800/sec |
| Nvidia H100 | gpu-h100 | $0.001525/sec |
按 token 计费的部分,官方页面上有个细节值得单独说:输入按「每百万 token」标价,输出按「每千 token」标价,两个单位不统一。比如 anthropic/claude-3.7-sonnet 是 $3.00 / million input tokens 与 $0.015 / thousand output tokens,deepseek-ai/deepseek-r1 是 $3.75 / million input tokens 与 $0.01 / thousand output tokens。做预算表的时候如果不留神,很容易把 $0.015 当成「每百万」直接填进去,算出来的输出成本会差三个数量级。
本文按官方标价做一次单位换算(这是本文的算术,不是官方原文表述):$0.015 / 千 = $15 / 百万,$0.01 / 千 = $10 / 百万。换算完再进你的成本表,口径才对得齐。
按秒计费对离线批处理特别友好,原因很直白:任务跑完,计费就停。你不需要为「等待请求到来的那段空闲」买单。这一点和常驻实例的区别,下一节的算例会算给你看。
Replicate 的接入范式和多数平台不一样,它不是 OpenAI 兼容端点,而是自有的 predictions API,请求体的核心字段是 version(模型版本 ID)和 input(输入参数对象),鉴权用 Authorization: Bearer $REPLICATE_API_TOKEN。这意味着从别家迁过来不是「改 base_url 就能切」,而是要改调用范式。详细的接入写法见 Replicate 接入实操。
路径三:错峰使用自建算力
如果你已经有自建 GPU 在跑在线服务,那批任务就是一份天然的填谷负载。在线流量有明显的日内波谷(多数 to B 场景是深夜到清晨),把批任务排进这个窗口,相当于用同一份固定成本干了两份活。
这条路径的关键不是技术,是优先级隔离。批任务必须是可被抢占的:在线流量一旦回升,批任务要能立刻让路、并且让路之后能从断点继续,而不是整批失败重来。做不到这一点的话,你迟早会遇到「凌晨的批任务把早高峰的在线服务拖垮」这种事故,那一次的损失比省下来的钱多。
如果你没有自建算力,Serverless 形态是另一种「只为使用付费」的思路,取舍见 Serverless 推理值不值。
三、异步任务的工程要点
把 Replicate 的字段拿来当实例讲,因为它把异步范式做得比较显式,讲清楚了别家也是一个道理。
拿结果:webhook 优先,轮询兜底。 提交请求时带上 webhook(完成时的回调 URL)和 webhook_events_filter(触发 webhook 的事件类型数组)。webhook 的好处是你不用维护一个轮询循环,坏处是你得有一个公网可达、能扛住并发的接收端。批量场景下这个接收端会在短时间内被打得很密集,别用一个单进程脚本去接。
短任务可以切同步。 请求头 Prefer: wait 会启用同步模式,还能指定等待秒数,比如 wait=5。这个开关在批处理里有个特定用途:调试期用同步,跑批用异步。调试的时候你希望立刻看到报错,不想为了看一条结果去搭一整套回调;等参数调对了,再切回异步走量。
防跑飞:Cancel-After。 这个请求头设置预测的自动取消超时,格式像 1m30s、2h。批处理场景下我认为它是必填项,不是可选项。理由在下一节的算例里。
webhook 接收端必须幂等。 这一条我要重点说,因为它是批处理里最容易被忽略、事后最难查的坑。
回调这种东西,天然存在两个你无法控制的情况:
- 重复投递。 你的接收端返回慢了、或者返回了非 2xx、或者网络抖了一下,发送方合理的行为就是重试。同一个任务的完成事件到达你这里两次、三次,都是正常的。
- 乱序。 同一个任务的多个事件(开始、输出、完成),或者不同任务的事件,到达顺序不保证和发生顺序一致。你不能写「收到完成事件时,前面的开始事件一定已经处理过了」这种假设。
对应的做法是:接收端拿到回调后,第一件事是用任务的稳定标识去查「这条我处理过没有」,处理过就直接返回 2xx 走人;没处理过再往下走,并且写结果和记状态要在同一个事务里。乱序的处理靠状态机——只允许状态往前走,收到一个比当前状态更早的事件就丢弃。这套东西的通用写法见 幂等设计怎么落地。
顺带一提:接收端要尽快返回 2xx,重活扔到队列里异步做。接收端处理慢会触发发送方重试,重试又加重接收端负担,批量场景下这个正反馈能把你的接收服务打死。
四、批处理的可靠性设计(这一节最值钱)
在线服务错了,用户会立刻投诉,你半小时内就知道。批处理错了,没有人会告诉你——它安安静静地把错误结果写进了下游存储,一周后有人发现报表不对,你回头查,发现已经污染了七天的数据。
所以批处理的工程重心和在线服务不一样:在线拼的是延迟和可用性,离线拼的是可重跑性和可验证性。四件事,一件都不能省。
4.1 断点续跑:任务清单必须持久化
最常见的错误实现是「读一个文件,for 循环调 API,结果 append 到另一个文件」。这套东西跑到第 8 万条挂了,你只有两个选择:从头再来(前面 8 万条的钱白花),或者手工数到第几条了(然后数错)。
正确的做法是把批任务建模成一张任务清单表,每条一行,至少有这几列:任务键、输入引用、状态(待处理/处理中/成功/失败)、重试次数、失败原因、结果引用。跑批的过程变成「取一批待处理的 → 处理 → 回写状态」。
这样做之后,挂了就重启,重启后自动只捞待处理和失败的。失败的单条可以单独重跑,不用整批重来。你也随时能回答「跑到哪儿了、失败了多少、失败的都是些什么」这三个问题——在线服务有监控面板,批任务的面板就是这张表。
存哪儿不重要,SQLite、Postgres、一张 Redis Hash 都行,重要的是它必须在进程外,进程内的变量重启就没了。
4.2 幂等:每条任务一个稳定的键
任务键要满足两个条件:由输入内容决定(同样的输入永远得到同样的键),在整个批次里唯一。常见做法是对「输入内容 + 任务类型 + 模型版本」做一次哈希。
为什么要把模型版本放进去?因为你换模型重跑的时候,希望它是一条新任务而不是复用旧结果。为什么不能用「行号」当键?因为你下次的输入文件可能多了两行,行号就全错位了。
有了稳定的键,重跑就是安全的:写结果的时候用这个键做 upsert,重跑一百次结果也只有一份。不然的话,一次重跑就给你的下游表塞进去一批重复行,而重复行造成的统计偏差比缺数据更难发现。
4.3 分片与限速:别把一整批一次性打出去
我见过一个团队半夜把 30 万条请求一次性并发打出去,结果是全批失败——撞了限速之后,服务端开始大面积拒绝,客户端没有退避逻辑,继续猛打,把自己彻底锁死了。他们那晚不但没跑完任务,还顺带把白天在线服务的配额也用掉一部分。
正确的姿势:
- 分片。 把清单切成固定大小的块,一块一块推进。块的大小选一个「失败了重跑不心疼」的量级。
- 限并发。 用一个固定大小的工作池,而不是「for 循环里全部 spawn 出去」。并发数是个可调参数,从小往大调。
- 退避重试。 遇到限速类错误要指数退避 + 随机抖动,不能定长重试(定长重试会让所有 worker 同步撞墙)。
- 失败要区分。 限速错误值得重试,参数错误重试一万次也没用,直接标失败进人工队列。
你多半不知道自己账号的具体限速阈值——这很正常,别为此卡住。稳妥的起步方式是:并发数从个位数起,跑一小片观察错误率和耗时,没有限速错误就翻倍,出现限速错误就退回上一档并留出余量。跑几次你就有了一份属于自己账号、比任何文档都准的经验值。
4.4 结果校验:批量场景没人盯着,必须自动查
这是四件事里最常被跳过的,也是代价最大的。校验至少要覆盖三层:
格式层。 输出能不能解析?如果你要的是 JSON,就真的解析一遍,别只看长度不为零。这一层可以做到 100% 覆盖,成本极低。
取值层。 输出的值在不在允许的集合里?分类任务的标签是不是那几个预定的标签之一?数值是不是在合理区间?模型输出「基本正确但多了个句号」这类问题,只有取值层能拦住。
分布层。 这一层最容易被忽略但最有价值。把这一批的结果分布和上一批比一比:某个标签的占比从 12% 变成 71%,即使每一条单看都「像是对的」,也一定出了问题——可能是提示词改动的副作用,可能是模型版本变了,也可能是输入数据本身出了问题。格式层和取值层查的是单条,分布层查的是整批,而批处理事故往往是整批性质的。
再加一条:留一个小比例的人工抽检。一批抽个几十条人眼过一遍,成本可控,能发现自动校验永远发现不了的问题(比如结果格式完全合法、值也合法、但语义整个跑偏)。
五、算一笔账
下面这些数字用的都是假设参数,只有 GPU 秒价来自 Replicate 官方定价页。目的是演示算法,不是给你一个可以直接套用的结论,你自己的单条耗时必须实测。
假设
- 一批离线任务,共 20,000 条
- 单条 GPU 实际运行时间 1.2 秒
- 用 L40S,$0.000975/sec
场景 A:在线逐条跑,实例常驻
任务零散地分布在一整天里到达,实例不能缩到零,得整天待命:
- 常驻时长:24 小时 = 86,400 秒
- 成本:86,400 × $0.000975 = $84.24
- 实际有效利用率:20,000 × 1.2 ÷ 86,400 = 24,000 ÷ 86,400 = 27.8%
也就是说,七成多的钱付给了「等活来」。
场景 B:离线批量跑,按秒计费
任务攒起来集中跑,只为实际运行的秒数付钱:
- 有效 GPU 秒数:20,000 × 1.2 = 24,000 秒
- 成本:24,000 × $0.000975 = $23.40
- 相比场景 A 省下:$84.24 − $23.40 = $60.84,降幅 60.84 ÷ 84.24 = 72.2%
注意一个反直觉的点:加并发不改变这个成本。 按秒计费下,8 路并发把墙钟时间从 24,000 秒(6 小时 40 分)压到 3,000 秒(50 分钟),但 GPU 秒总数还是 24,000,账单还是 $23.40。并发买的是时间,不是钱。(实际会有排队与冷启动的额外开销,这部分官方未给出数据,本文不做估计。)
场景 C:换更贵的卡划不划算
同一批任务如果改跑 A100 (80GB),$0.001400/sec,假设单条耗时不变 1.2 秒:
- 成本:24,000 × $0.001400 = $33.60,比 L40S 贵 $10.20,即贵 43.6%
但单条耗时当然不会不变,这个假设只是为了引出真正有用的那个公式:
换卡的盈亏平衡点 = 单价比的倒数。
L40S 与 A100 的单价比是 $0.000975 ÷ $0.001400 = 0.696。也就是说,A100 必须把单条耗时压到 1.2 × 0.696 ≈ 0.84 秒以内,换卡才开始省钱。压不到,换了就是多花钱。
同理换 H100($0.001525/sec):0.000975 ÷ 0.001525 = 0.639,需要压到 1.2 × 0.639 ≈ 0.77 秒以内。
这个公式的价值在于,它把「哪张卡更划算」这个含糊问题,变成了一个你可以用小批量实测去回答的具体问题:跑 200 条,量出两张卡各自的单条耗时,代进去比一比就完了。不要凭卡的型号做判断——同一张卡在不同批大小、不同精度、不同显存占用下的实际耗时差别很大。
场景 D:不设超时的代价
这一笔账我建议每个做批处理的人都算一遍。假设批次里有 2% 的任务因为异常输入卡住、一直跑到被人工发现才杀掉,平均跑 30 分钟:
- 卡住的条数:20,000 × 2% = 400 条
- 浪费的 GPU 秒:400 × 1,800 = 720,000 秒
- 成本:720,000 × $0.000975 = $702.00
对比一下,这批任务本身的正常成本才 $23.40。跑飞的那 2% 花掉的钱,是整批正常成本的 30 倍。
如果加上 Cancel-After: 1m30s(90 秒自动取消):
- 浪费的 GPU 秒:400 × 90 = 36,000 秒
- 成本:36,000 × $0.000975 = $35.10
- 省下:$702.00 − $35.10 = $666.90
一个请求头,省下的钱是任务本身成本的 28 倍还多。这就是我说 Cancel-After 在批处理场景里是必填项而不是可选项的原因——在线服务有用户在等,跑飞了几秒钟就有人喊;离线任务没人喊,它可以安安静静烧一整夜。
六、什么时候不该批
批处理不是万能钥匙,下面三种情况硬转会出问题:
需要用户即时反馈的。 这个不用多解释。但有个变体值得提:有些交互看起来需要即时反馈,其实只需要即时确认。用户点「生成报告」,你立刻返回「已提交,完成后通知你」,然后走离线批量——这在体验上完全可以接受,而且比让用户干等一分钟强。把「即时响应」和「即时完成」拆开看,很多任务就能转离线了。
数据时效性强的。 风控、实时推荐、行情相关的判断,攒一小时再跑,结果出来时前提已经变了。这类任务省的那点钱远不如决策失误的代价。
任务之间有强依赖的。 如果第 N 条的输入依赖第 N−1 条的输出(比如多轮迭代式的处理),批量并发就无从谈起,强行改造只会得到一堆调度复杂度。这种情况下,先想办法把依赖解开——通常依赖是实现方式带来的,不是问题本身要求的——解不开就老实串行。
还有一个不算「不该批」但值得警惕的情况:批处理会放大错误。同一个提示词 bug,在线跑一天影响几百个用户请求,离线批量跑一次就污染二十万条数据。所以第四节那套可靠性设计不是锦上添花,是转离线的准入条件。没有校验就敢跑大批量,省下的钱大概率会被数据修复的工作量吃回去。
自查清单
准备把一条链路转离线之前,逐条过一遍:
- 确认没人在等。 拉最近 7 天调用明细按来源分组,逐组问「调用发生时有人在等吗」;只有「用户会看到」不等于「用户在等」。
- 任务清单落到进程外的存储。 每条至少有:任务键、状态、重试次数、失败原因、结果引用。跑批变成「捞待处理 → 处理 → 回写状态」。
- 任务键由输入内容 + 任务类型 + 模型版本哈希得到,写结果用它做 upsert;绝不用行号当键。
- 并发从个位数起步,小批量试跑后翻倍,出现限速错误退回上一档;重试用指数退避 + 随机抖动,参数类错误不重试直接标失败。
- 超时必须设。 用平台提供的自动取消能力(Replicate 是
Cancel-After,格式如1m30s、2h),别指望人工发现跑飞的任务。 - webhook 接收端做幂等 + 状态机,先查「处理过没有」再决定是否往下走,快速返回 2xx,重活扔队列。
- 三层校验全部自动化:格式(真解析一遍)、取值(在不在允许集合里)、分布(和上一批比占比变化),再加一个小比例人工抽检。
- 先用小批量跑出自己的单位成本(每条多少钱、多少秒),再决定批大小、并发数和用哪张卡;换卡的盈亏平衡点 = 单价比的倒数,用实测耗时去比,不要凭型号猜。
本文涉及的价格与接口字段来自各平台官方文档与定价页,其余为按官方标价所做的算术演示与工程判断。价格、限速、参数与模型清单随时可能调整,落地前请以官方文档和控制台的当前值为准。