手上有 8,000 張歷史客訴工單,要一次做「分類加三句話摘要」,結果寫回資料庫給營運看。

這種任務的第一版寫法幾乎是反射動作:撈出來、跑 for 迴圈、每筆打一次 Messages API、寫回去。用 Haiku 4.5,照手上那個 tier 的 rate limit 粗算,個位數分鐘就該跑完,午餐前交差。

跑不到兩分鐘,429。

第一次修:加 sleep

最直覺的修法是塞個 time.sleep()。跑是跑得動了,但你會發現 RPM 根本不是瓶頸,先爆的是 input tokens per minute。每張工單一千五百個 token,八千張就是一千兩百萬,那個額度撐不住。

於是 sleep 從 0.5 秒調到 2 秒,八千筆變成四個半小時。而且中間只要斷一次,你不知道跑到第幾筆。

第二次修:寫一個像樣的跑批器

第二版就會開始長出正經的東西:併發控制、429 指數退避、失敗重試、斷點續傳、進度記錄。寫到後來你會發現一件有點好笑的事,這段「怎麼把工作餵進去」的程式碼,比真正呼叫 AI 的那幾行長得多。

然後客服部門打電話來。

線上那個客服機器人開始間歇性回不了話,log 裡滿滿的 429。你的跑批跟線上服務共用同一個組織的 RPM 與 ITPM 池,你在後台把額度吃光,前台的使用者就在排隊。

這一步才是真正的坑。前面兩個問題頂多讓你加班,這個問題會讓你的離線任務去傷害線上使用者,而且它不會出現在你的測試環境裡,因為測試環境沒有真實流量在跟你搶。

切進來的地方:它有另一套額度

Message Batches API 解掉的第一件事,官方 FAQ 用一句話寫死:

Usage of the Batches API does not affect rate limits in the Messages API.

Batches API 有自己一套獨立的 rate limit,跟 Messages API 分開計算。Start tier 是 1,000 RPM、佇列中可以有 200,000 筆請求、單一批次最多 100,000 筆;Build tier 2,000 RPM、佇列 300,000;Scale tier 4,000 RPM、佇列 500,000。

多數介紹文的第一句都是「打五折」。折扣當然好,但如果你只記一件事,記這個:跑批不再跟線上使用者搶號碼牌。省下來的錢是次要的,省下來的 on-call 才是。

順帶一提,這篇是上一篇 Prompt Caching 教學結尾留的那個伏筆。那篇最後說「下一步可以看 Batch API」,就是這一步。兩種折扣官方明說可以疊,後面會講怎麼疊、以及為什麼別對疊出來的數字太樂觀。

臨櫃和投件箱

同步 API 像臨櫃辦事。你站在窗口前面,行員當場處理、當場給你結果。好處是立刻拿到,代價是你得排隊,而且窗口一次只服務一個人。

Batches API 像投件箱。你把 8,000 份表格整箱塞進去,櫃檯給你一張回執單(msgbatch_01Hkc...),你回家睡覺,隔天回來領一整包結果。

代價很明確:當下什麼都拿不到。好處有兩個,一個是櫃檯可以挑離峰時段用最有效率的方式處理,所以收你半價;另一個是這個投件箱有自己的處理人力,不占用臨櫃窗口的號碼牌。

理解了這個,剩下的都是細節。

最小可行的三步

包裝方式簡單到有點無聊。你平常丟給 Messages API 的那包參數,原封不動放進 params,外面套一層 custom_id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
curl https://api.anthropic.com/v1/messages/batches \
--header "x-api-key: $ANTHROPIC_API_KEY" \
--header "anthropic-version: 2023-06-01" \
--header "content-type: application/json" \
--data \
'{
"requests": [
{
"custom_id": "my-first-request",
"params": {
"model": "claude-opus-5",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello, world"}
]
}
},
{
"custom_id": "my-second-request",
"params": {
"model": "claude-opus-5",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hi again, friend"}
]
}
}
]
}'

custom_id 的規則是「1 到 64 個字元,只能有英數字、連字號、底線」,比對式 ^[a-zA-Z0-9_-]{1,64}$,同一批次內不可重複。

送出去會拿到一張回執:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"id": "msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d",
"type": "message_batch",
"processing_status": "in_progress",
"request_counts": {
"processing": 2,
"succeeded": 0,
"errored": 0,
"canceled": 0,
"expired": 0
},
"ended_at": null,
"created_at": "2024-09-24T18:37:24.100435Z",
"expires_at": "2024-09-25T18:37:24.100435Z",
"cancel_initiated_at": null,
"results_url": null
}

Python 那邊是三段可以直接接起來的程式碼。建立:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

client = anthropic.Anthropic()

message_batch = client.messages.batches.create(
requests=[
Request(
custom_id="my-first-request",
params=MessageCreateParamsNonStreaming(
model="claude-opus-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": "Hello, world",
}
],
),
),
]
)

print(message_batch)

輪詢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import time

client = anthropic.Anthropic()

MESSAGE_BATCH_ID = "msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d"

message_batch = None
while True:
message_batch = client.messages.batches.retrieve(MESSAGE_BATCH_ID)
if message_batch.processing_status == "ended":
break

print(f"Batch {MESSAGE_BATCH_ID} is still processing...")
time.sleep(60)
print(message_batch)

讀結果,用串流的方式一筆一筆吃,不要整包載進記憶體:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
client = anthropic.Anthropic()

# Stream results file in memory-efficient chunks, processing one at a time
for result in client.messages.batches.results(
"msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d",
):
match result.result.type:
case "succeeded":
print(f"Success! {result.custom_id}")
case "errored":
if result.result.error.error.type == "invalid_request_error":
# Request body must be fixed before re-sending request
print(f"Validation error {result.custom_id}")
else:
# Request can be retried directly
print(f"Server error {result.custom_id}")
case "expired":
print(f"Request expired {result.custom_id}")

官方註解裡那個分岔值得放大:invalid_request_error 必須改請求本體才能重送,其他 server error 可以直接重試。這一行決定了你的重試邏輯該不該無腦 retry,寫錯的話你會拿同一個壞掉的 payload 重打三次,然後三次都失敗。

Java 這邊是 builder 風格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AnthropicClient client = AnthropicOkHttpClient.fromEnv();

BatchCreateParams params = BatchCreateParams.builder()
.addRequest(
BatchCreateParams.Request.builder()
.customId("my-first-request")
.params(
BatchCreateParams.Request.Params.builder()
.model(Model.CLAUDE_OPUS_5)
.maxTokens(1024)
.addUserMessage("Hello, world")
.build()
)
.build()
)
.build();

MessageBatch messageBatch = client.messages().batches().create(params);

輪詢的判斷式在 Java 是 enum 不是字串,這點跟 Python 版不一樣:

1
2
3
if (messageBatch.processingStatus().equals(MessageBatch.ProcessingStatus.ENDED)) {
break;
}

常用的端點有六個:建立 POST /v1/messages/batches、查詢單一批次 GET /v1/messages/batches/{message_batch_id}、列出 GET /v1/messages/batches?limit=20、取消 POST .../cancel、刪除 DELETE /v1/messages/batches/{batch_id}、下載結果 GET .../results

還不想寫程式?有 CLI

官方文件每個範例都有一個 CLI 分頁,用的是 Anthropic 自己的 CLI 工具 ant

1
2
3
4
5
# macOS
brew install anthropics/tap/ant

# Go(需 Go 1.22+)
go install github.com/anthropics/anthropic-cli/cmd/ant@latest
1
2
ant --version
ant auth login

開一個批次是 YAML heredoc,比手寫 JSON 好讀太多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ant messages:batches create <<'YAML'
requests:
- custom_id: my-first-request
params:
model: claude-opus-5
max_tokens: 1024
messages:
- role: user
content: Hello, world
- custom_id: my-second-request
params:
model: claude-opus-5
max_tokens: 1024
messages:
- role: user
content: Hi again, friend
YAML

查狀態跟讀結果:

1
2
3
4
5
6
7
8
ant messages:batches retrieve \
--message-batch-id "$MESSAGE_BATCH_ID" \
--transform processing_status --raw-output

ant messages:batches results \
--message-batch-id msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d \
--transform '{custom_id,"type":result.type,"error":result.error.error.type}' \
--format jsonl

--transform 是內建的 GJSON 查詢,官方明說「so you don’t need a separate tool such as jq」。想在自己機器上先試一次完整流程再決定要不要寫程式,這條路是門檻最低的。

現在來看官方自己的範例,順便看一個坑

結果檔是 .jsonl,一行一筆。下面是官方文件裡的範例輸出,custom_id 與兩行的先後順序原樣照抄,中間的 message 內容為了好讀省略掉:

1
2
{"custom_id":"my-second-request","result":{"type":"succeeded","message":{"id":"msg_014VwiXbi91y3JMjcpyGBHX5",...}}}
{"custom_id":"my-first-request","result":{"type":"succeeded","message":{"id":"msg_01FqfsLoHwgeFbguDgpz48m7",...}}}

看第一個欄位。my-second-request 排在 my-first-request 前面。

這是文件刻意示範的,旁邊還有一段 Tip:

Batch results can be returned in any order, and may not match the ordering of requests when the batch was created. In the preceding example, the result for the second batch request is returned before the first. To correctly match results with their corresponding requests, always use the custom_id field.

為什麼這條特別危險?因為它是靜默錯誤。你用 for i, line in enumerate(results) 把摘要寫回 DB,程式不會噴任何例外,單元測試會過,小批量的時候順序甚至常常剛好對得上。等到八千筆真的跑完,工單 A 的摘要寫到了工單 B 上,而你要等營運部門有人覺得「這個摘要跟內容對不起來」才會發現,那時候整張表已經污染了。

正確做法只有一個:custom_id 放你自己系統的主鍵,例如 ticket-88231,讀結果的時候用它當 key 對回去。永遠不要用 index。

狀態只有三個,別自己補

輪詢寫錯的第二個常見原因,是把 processing_status 當成一般的工作佇列狀態機,然後憑印象加了幾個不存在的值。

它只有三個值:

意義
in_progress 建立後的初始狀態
canceling 呼叫 cancel 端點後的過渡狀態
ended 批次內每一筆都已 succeeded / errored / canceled / expired

沒有 completed、沒有 failed、沒有 queued、沒有 pending。被取消的批次最後也是 ended

request_counts 也只有五個欄位:processingsucceedederroredcanceledexpired,五個加起來永遠等於批次總數。

每一筆結果的 result.type 有四種:succeedederroredcanceledexpired。後面三種官方明說不計費,你只為成功產出的東西付錢。

ended 不等於成功,輪詢途中看到的 errored: 0 是假的

這裡有兩層。

第一層好懂:ended 的定義是「每一筆都到了終態」。被你取消掉的批次最後也是 ended,而且可能帶著部分結果。

第二層藏得比較深。request_countssucceedederroredcanceledexpired 這四個欄位,官方對每一個的說明都寫了同一句話:

This is zero until processing of the entire Message Batch has ended.

所以你在輪詢途中印出 errored: 0,覺得「目前沒有錯誤、跑得很順」,那個 0 完全沒有資訊量。它不是「沒有錯誤」,它是「還沒開始統計」。

正確做法是等 processing_status == "ended" 之後才讀 request_counts,而且四個數字都要看。

200 不代表你的請求是對的

這條是我覺得最反直覺的一個設計。

POST /v1/messages/batches 回你 200,只代表批次建立成功。裡面每一筆 params 的驗證是非同步的,官方原文:

Validation of the params object for each message request is performed asynchronously, and validation errors are returned when processing of the entire batch has ended.

翻成白話:你把某個欄位名稱打錯了,這件事可能要等到整批跑完才會以 erroredinvalid_request_error 的形式告訴你。最壞情況下,一萬筆請求全部形狀錯誤,你在 24 小時後才知道。

官方 best practice 給的解法只有一句:

Dry run a single request shape with the Messages API to avoid validation errors.

先拿一筆同樣形狀的請求去打同步的 Messages API,確認 200 且回應正常,再包成批次送出去。這一步花你三十秒,可以省掉一天。

另外幾條值得先知道的規則

結果保存 29 天,從 created_at 算,不是從跑完算。直覺會以為「結果產生之後保存 29 天」,實際上你的批次如果跑了 20 小時,可用期就先少掉將近一天。

單一批次上限是 100,000 筆請求或 256 MB,先到先算,超過會拿到 HTTP 413 request_too_large。處理時限最長 24 小時,官方說多數批次一小時內就完成。

批次送出後不能改,只能取消重送,而且取消不保證立即生效。

還有一條容易忽略的:因為批次的吞吐量高又是併發處理,官方明說批次「may go slightly over your Workspace’s configured spend limit」。如果你是靠 spend limit 當最後一道保險絲的人,這條要放在心上。

最後一條是安撫用的:一筆請求爛掉不會拖垮整批。官方 troubleshooting 原文寫「the failure of one request in a batch does not affect the processing of other requests」。

快取可以疊,但別對疊出來的數字太樂觀

Prompt caching 的折扣跟批次折扣官方明說可以 stack。問題在命中率:

because batch requests are processed asynchronously and concurrently, cache hits are provided on a best-effort basis. Users typically experience cache hit rates ranging from 30% to 98%, depending on their traffic patterns.

30% 跟 98% 是兩個完全不同的世界。官方給了三個提高命中率的做法:每一筆請求都放一模一樣的 cache_control 區塊、維持穩定的請求流讓快取條目不要過期、把請求結構設計成盡量共用同一段快取內容。

還有一條很實用的提醒:因為批次可能跑超過五分鐘,官方建議在批次情境改用 1-hour cache duration。預設那個五分鐘的 ephemeral cache,在跑批的時候幾乎必定過期,你以為疊上去了,其實每一筆都是全價。

等待不是代價,等待是能力的來源

到這裡為止,非同步聽起來都是「用時間換錢」。有一個東西會翻轉這個印象。

output-300k-2026-03-24 這個 beta header,把 max_tokens 上限從標準的 128k 拉到 300,000,支援 Claude Opus 5、Opus 4.8、Opus 4.7、Opus 4.6、Sonnet 5、Sonnet 4.6。

而它只有 Batches API 有。官方原文:

Extended output is available on the Message Batches API only, not the synchronous Messages API.

(另外它目前在 Claude API 與 AWS 上的 Claude Platform 可用,Amazon Bedrock、Google Cloud、Microsoft Foundry 還沒有。)

用法就是多加一個 header:

1
2
3
4
5
6
curl https://api.anthropic.com/v1/messages/batches \
--header "x-api-key: $ANTHROPIC_API_KEY" \
--header "anthropic-version: 2023-06-01" \
--header "anthropic-beta: output-300k-2026-03-24" \
--header "content-type: application/json" \
--data '{"requests":[{"custom_id":"long-form-request","params":{"model":"claude-opus-5","max_tokens":300000,"messages":[{"role":"user","content":"Write a comprehensive technical guide to building distributed systems..."}]}}]}'

Python 那邊要走 beta 命名空間,用 betas=[...] 而不是自己塞 header:

1
2
3
4
message_batch = client.beta.messages.batches.create(
betas=["output-300k-2026-03-24"],
requests=[...],
)

代價官方也講得很清楚:一次 300k token 的生成可能要跑超過一小時,記得把 24 小時的處理視窗算進去。

想清楚它為什麼只在批次有,這件事就順了。同步請求要維持一條 HTTP 連線,那條連線就是天花板;沒有連線要顧的時候,跑一小時產三十萬個 token 才變得可能。

同樣的邏輯也出現在 server tools 上。官方說全部的 server tools(web search、web fetch、code execution、MCP connectors、advisor、tool search)在批次裡都能用,跑的是同一套 server-side agentic loop,但批次的迴圈「runs more iterations per turn」才會回傳 stop_reason: "pause_turn"。同一個 agent 任務,走批次可能一次就做完,走同步要你續好幾輪。

錢的部分,以及怎麼確認你真的有打到折

以 Haiku 4.5 為例,batch 價是每百萬 input token $0.50、每百萬 output token $2.50。

八千張工單,每張約 1,500 個 input token、300 個 output token:

  • input 共 12 MTok,12 × $0.50 = $6
  • output 共 2.4 MTok,2.4 × $2.50 = $6
  • 合計 $12

官方寫的是「All usage is charged at 50% of the standard API prices」,同步走同一批量會是兩倍。

(Sonnet 5 的 batch 價目前是導入價 $1 / $5,只到 2026-08-31,2026-09-01 起變成 $1.50 / $7.50。如果你要照抄價格做預算,記得看發文日期。)

怎麼確認你真的吃到折扣,不用等帳單。結果 .jsonl 裡每一筆的 message.usage 有一個 service_tier 欄位,值是 "standard""priority""batch"。走批次的應該是 "batch"。這是可以當天驗證的東西,比月底看帳單早得多。

回到那 8,000 張工單

同一個任務現在長這樣:撈出來、把每張的 ID 當 custom_id 包成請求、一次送出、拿回執單、隔一段時間輪詢一次、ended 之後串流讀 .jsonl、用 custom_id 對回主鍵寫回 DB。

沒有併發控制,沒有指數退避,沒有斷點續傳,因為這些現在是對面的事。線上客服的 429 也不會再出現,因為你們用的根本不是同一個額度池。

誠實講一下這篇的邊界:上面所有指令與參數都照抄官方文件,我沒有實際送出過批次,開頭那個八千張工單也是設計出來說明問題的場景;價格是照官方 batch 價目表算的,實際帳單請以你自己的 Workspace 為準。

真正想留給你的是一個問題,寫任何要跑一批資料的功能之前先問一次:這個任務需要即時回應嗎?

它需要,你就得付即時的代價,包括自己蓋一套跑批基礎設施、包括跟線上服務搶額度。它不需要,那有一整條路徑可以走,而且那條路徑上還放著一些同步做不到的事。

多數人從來沒問過自己這個問題,因為同步 API 是預設值,而預設值最擅長的事,就是讓人忘記自己做過選擇。


參考來源