
文章目录
先说结论
如果你的任务是"50 份周报、几百份合同、上千条工单"这类离线批量处理,别再用同步 Messages API 一条条调了。把同样的请求打包进 Anthropic 的 Message Batches API,input 和 output 的 token 计费直接打五折,全模型适用。最近大家都在讨论「DeepSeek涨价你换了吗」——与其纠结换不换模型,先看看自己账单里有多少本来就该走批量的调用,半价折扣就是现成的对冲手段。
代价只有一个:结果不保证实时回来。官方给的是 24 小时窗口,实际上大多数批次不到 1 小时就跑完。你今晚提交,睡前轮询几次,明早收结果,节奏完全够用。
下面用 Python 把整条管线拆开讲:submit 提交、poll 轮询、retrieve 取结果三阶段,外加单条失败隔离。文末附一个不需要 API key 的 dry-run 验证思路,控制流先跑通,再接真实调用。
一、三阶段状态流转:submit → poll → retrieve
Batch API 的核心流程只有三步,对应三个 endpoint:
| 阶段 | Endpoint | 说明 |
|---|---|---|
| 提交 | POST /v1/messages/batches | 一次最多 100,000 条请求,上限 256MB |
| 轮询 | GET /{batch_id} | 查 processing_status |
| 取结果 | GET /{batch_id}/results | JSONL 流式返回 |
processing_status 只有三个值:in_progress(处理中)、canceling(取消中)、ended(已结束)。轮询逻辑就是围绕这三个状态做分派,收到 ended 才去拉结果。批次处理完还有一层兜底:结果在 Anthropic 那边保留 29 天,错过当晚再取也没问题。

二、核心代码:三个函数撑起整条管线
先构建请求。每份文档一条请求,custom_id 用文件名做主键,params 里的内容和同步 Messages API 完全一样——model、max_tokens、messages,改造成本为零:
import pathlib
def build_requests(docs_dir, model="claude-sonnet-4-5"):
"""每份文档打包一条批处理请求,custom_id 用文件名做主键"""
requests = []
for path in sorted(pathlib.Path(docs_dir).glob("*.txt")):
requests.append({
"custom_id": path.stem, # weekly-01, weekly-02 ...
"params": {
"model": model,
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": f"提炼周报要点并给出一句话总结:\n{path.read_text()}"
}],
},
})
return requests
轮询函数做三态分派:ended 去 RETRIEVE,canceling 直接 ABORT,其余情况继续等。轮询间隔建议放在 60–300 秒,别每秒去戳接口——批内任务既不会因为你轮询得勤而变快,还会白白消耗请求配额:
def poll_once(client, batch_id):
"""三态分派:ended→RETRIEVE / canceling→ABORT / 其他→KEEP_POLLING"""
batch = client.messages.batches.retrieve(batch_id)
status = batch.processing_status
if status == "ended":
return "RETRIEVE"
if status == "canceling":
return "ABORT"
return "KEEP_POLLING"
取回结果后逐行处理。JSONL 里每行带 result.type,四种类型各走各的分支,这就是单条失败隔离的关键——一条坏了不会拖垮整批:
def handle_result(line):
"""四态分派:succeeded/errored/expired/canceled"""
result = line["result"]
if result["type"] == "succeeded":
return "OK"
if result["type"] == "errored":
err_type = result["error"]["type"]
# invalid_request 是校验错误,修正请求体后重发
return "FIX_AND_RESEND" if err_type == "invalid_request" else "RETRY_LATER"
if result["type"] == "expired":
return "RESUBMIT" # 24h 窗口超时,重新提交即可
return "SKIP" # canceled:主动取消的,跳过
errored 要再细分一次:invalid_request 是请求体校验问题(比如参数写错),本地修正后重发就行;server error 是对方临时故障,稍后重试即可。两者的处理动作完全不同,混在一起会把能救的请求也丢掉。
这套三阶段管线可以直接复制到你的项目里,收藏备用。
三、custom_id:整个批次唯一的秩序锚点
Batch API 的结果不保证按提交顺序返回。50 份周报按顺序发出去,结果可能是乱的——第 7 行结果对应的可能是第 23 份周报。所以 custom_id 不是可选项,是你把结果拼回输入的唯一依据。
实践上有两条铁律:
custom_id用源数据的主键或文件名,别用自增序号以外的临时值,更别在多个批次里复用导致对不上账;- 处理结果时以
custom_id做 dict 映射,写回原文件时再按主键查找,永远不要假设"第 i 行结果对应第 i 条输入"。
顺序不保证这个坑值得收藏,接 Batch 之前先定好主键,后面的隔离和写回逻辑都建立在它之上。
四、dry-run 验证:不花一分钱跑通控制流
接真实 API 之前,我先用脚本 claude_batch_pipeline.py --dry-run 把控制流验证了一遍——50 份周报的请求全部构建成功,状态机按预期走完三阶段,四种结果类型各自落到正确分支:
requests built: 50(custom_id 从 weekly-01 起)
poll actions: ['KEEP_POLLING', 'KEEP_POLLING', 'RETRIEVE']
result dispositions: ['OK', 'FIX_AND_RESEND', 'RESUBMIT', 'SKIP']
final counts: 47 ok / 2 err / 50 total
三个动作都符合预期:轮询两次后拿到 ended,进入取结果阶段;四种 disposition 各自触发——47 条直接成功,2 条走了错误分支(一条修正重发、一条重新提交,被 canceled 的跳过),50 条总输入全部有了归宿,没有一条请求被静默丢弃。
dry-run 驱动的是纯状态序列,没有任何 API 调用,也就没有任何计费。它的价值在于:等你真正拿到 key 提交批次时,代码里每个分支你都见过它怎么走。
五、成本到底省在哪
直接上图。为了避免美元单价随官方调价过时,这里用归一化成本指数:同步调用记为 100,Batch 记为 50——每一个 token 都打五折,input 和 output 都算。

更狠的是它可以和 prompt caching 叠加:cache 读取的折扣是在 Batch 五折的基础上再乘,如果你批量处理的文档有公共前缀(比如同一套周报模板、同一个 system 提示词),叠加之后的实际成本远不止减半。
还有一点常被忽略:Batch 走独立的限流池,不占你实时调用的 RPM/TPM 配额。白天跑在线业务,晚上跑批量任务,两条通道互不干扰。
六、三个踩坑点,提前避开
| # | 踩坑行为 | 后果 | 正确姿势 |
|---|---|---|---|
| 1 | 假设结果按提交顺序返回 | custom_id 没对齐,周报内容张冠李戴 | 用源数据主键做 dict 映射回写 |
| 2 | 每秒轮询状态接口 | 浪费配额,对进度毫无帮助 | 轮询间隔 60–300 秒 |
| 3 | 把 expired 当失败放弃 | 白白丢掉整批已构建的请求 | 24h 超时后原样重新提交 |
这张表建议收藏,接 Batch 前对照检查一遍,能省掉至少一次半夜排查。
最后补一个结构性的提醒:批内请求相互独立、无共享上下文。Batch 不适合跑 Agent 循环——Agent 的每一步都依赖上一步的输出,那是串行依赖,塞进批次里只会拿到一堆互相看不见的孤儿请求。批量的归批量,实时的归实时。
七、和前几篇的边界
有读者可能会问:这和之前写 Claude 状态机、循环编排、Prompt 契约的几篇是不是一回事?不是。那几篇讲的全部是实时单次调用视角——一次请求、同步返回、立刻处理。本篇是离线批量视角:请求先攒批、异步执行、按主键收割结果,两者的工程结构完全不同,甚至可以组合使用(在线链路用实时接口,夜间任务走 Batch)。如果你手上的任务没有"必须 30 秒内返回"的约束,批量视角大概率能帮你把账单砍掉一半。
参考


308

被折叠的 条评论
为什么被折叠?



