Claude API を実運用に組み込んでいると、どこかのタイミングで「あのとき返ってきたレスポンスをもう一度見たい」という瞬間が必ず訪れます。ユーザーから「先週の出力がおかしかった」と問い合わせを受けたとき、429 が断続的に起きていた時間帯のリクエストを後追いしたいとき、プロンプトのリグレッションを疑ったとき。私の場合は、AdMob の eCPM 監視ボットがある朝だけ妙な出力を返していて、その原因を追うのに丸一日かかりました。console.log の断片しか手元になく、結局推測で再現を試みるしかなかったのです。
それ以来、Claude API の入出力は 必ず Cloudflare R2 にアーカイブする という方針で運用しています。Lacrima と Mystery を含む 6 サイトの自動投稿パイプライン、それから 5,000 万 DL の個人開発アプリのバックエンドで、いま動いている設計を実装メモとして残しておきます。読み終えたあと、すぐに Workers のソースに archiveResponse(...) の 5 行を足せる粒度を目標にしました。
なぜ Claude API のレスポンスをアーカイブするのか — 3つの実運用ニーズ
最初に整理しておきたいのは「何のために残すのか」です。アーカイブを目的なく集めても、ストレージ代を払い続けるだけのデータ墓場になります。私が R2 に保存するときは、次の 3 つのいずれかに合致するときだけ書き込むようにしています。
第一に 監査ログ 。生成 AI が出力したテキストは、特に有料機能の裏側で動かしているとき、後日「本当に AI がそう書いたのか、こちらが改変したのか」を示す必要が出てきます。私のアプリでも、ユーザーレビュー返信機能の出力が「事実と異なる」と指摘されたことがあり、当時のレスポンス本体をそのまま提示できたことで誤解を解いた経験があります。stop_reason が refusal だったケースをあとから集計するときも、レスポンスそのものが残っていないと議論になりません。
第二に 再現性 。プロンプトキャッシュを有効にしているプロジェクトでは、キャッシュヒット率の改善や、システムプロンプトの差分による出力変化を観測したくなります。インプットとアウトプットの両方がアーカイブされていれば、ゴールデンデータセットを後追いで構築できます。私は _documents/_quality_audit/ のリグレッションテストを R2 アーカイブから半自動で組み立てており、過去のレスポンスを tools/build_golden.ts に流して、Sonnet 4.6 → Opus 4.6 アップグレード時の品質変動を測りました。
第三に 障害分析 。529 Overloaded が断続的に起こる時間帯のリクエスト傾向、max_tokens で打ち切られているケースの分布、ストリーミング途中で切れた接続の特徴。リアルタイムにアラートを出すための observability は別途必要ですが、根本原因を突き詰めるときには「あのとき本当に何が送られて、何が返ってきたか」のフル本文が手元にあるかどうかで作業時間が桁違いに変わります。
この 3 つのうち、自分のプロジェクトで該当するものが 1 つでもあるならアーカイブの価値があります。逆に、すべてが「いまの自分には不要」だと感じるなら、まだ書き込む必要はありません。
アーカイブのデータモデル設計 — キー戦略とパーティション粒度
R2 はオブジェクトストレージなので、ディレクトリの概念は実体としては存在せず、キー名がそのまま検索のすべてです。ここを雑に設計すると、後日「先週の特定ユーザーのレスポンスだけ取り出したい」というときに、全件 LIST して舐めることになって悲しい気持ちになります。
私が運用しているキー命名規則はこうです。
claude-archive/
{YYYY}/
{MM}/
{DD}/
{HH}/
{request-id}.json
例えば claude-archive/2026/05/24/02/req_01HXYZ.json のように展開されます。日時のパーティショニングを 1 時間粒度で切るのは、後述するライフサイクル管理(古いものを Infrequent Access に落とす)と、R2 の LIST 上限(1000 件 / リクエスト)を意識した結果です。1 日 1 ディレクトリに集めると 24 時間ぶんが一気に増え、月 100 万リクエスト規模になると 1 ディレクトリ 3.3 万件で LIST が面倒になります。1 時間粒度なら平均 1400 件で、たいていは 1 回の LIST で収まる範囲です。
request-id は Anthropic のレスポンスヘッダー request-id(または x-request-id を環境によって名乗ります)から取り、それをそのままキーに使います。これによって、ユーザーから「サポート対応で送られた req_01HXYZ... の出力を見たい」と頼まれたら、年月日時さえ分かれば一発で引けます。request-id が手元にないときは、後述する D1 のメタデータインデックスから引きます。
オブジェクト本体には、リクエストとレスポンスを JSON でまとめて入れます。最低限保存するフィールドは次の通りです。
{
"schema_version" : "v1" ,
"request_id" : "req_01HXYZ..." ,
"ts" : "2026-05-24T02:00:13.412Z" ,
"model" : "claude-sonnet-4-6" ,
"request" : {
"system" : "<masked or hashed>" ,
"messages" : [ ... ],
"tools" : [ ... ],
"max_tokens" : 4096 ,
"metadata" : { "user_id" : "u_abc" }
},
"response" : {
"id" : "msg_01HXYZ..." ,
"stop_reason" : "end_turn" ,
"usage" : {
"input_tokens" : 1234 ,
"cache_read_input_tokens" : 800 ,
"output_tokens" : 567
},
"content" : [ ... ]
},
"headers" : {
"anthropic-organization-id" : "org_..." ,
"retry-after" : null
},
"client" : {
"site" : "claudelab" ,
"version" : "2026.05.24-abc1234"
}
}
schema_version を最初から入れておくのは、ほぼ確実に「あとでフィールドを足したくなる」からです。私のプロジェクトでは v1 → v2 で cache_read_input_tokens を後追いで足したことがありました。最初に v1 と書いておけば、リプレイ側で「このフィールドが無いのは古い形式」と判断できます。これを入れ忘れて、4 ヶ月後にメタデータの後方互換で痛い目を見たので、本当に最初の段階で入れておくのをお勧めします。
Cloudflare Workers から R2 に非同期で書き込む実装
Claude API のレスポンスを返す Worker のホットパスを伸ばさないために、R2 への書き込みは ctx.waitUntil で非同期化します。waitUntil は Worker のレスポンスをユーザーに返したあとも、登録された Promise が完走するまで Worker のライフタイムを延ばしてくれる仕組みで、レイテンシをほぼ犠牲にせずに副作用を済ませられます。
まずは何もしていない素朴な実装から。
// 何もしていない素朴な実装(archive なし)
export default {
async fetch ( request : Request , env : Env , ctx : ExecutionContext ) {
const body = await request. json < ChatRequest >();
const anthropic = new Anthropic ({ apiKey: env. ANTHROPIC_API_KEY });
const response = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 4096 ,
messages: body.messages,
});
return Response. json (response);
} ,
} satisfies ExportedHandler < Env > ;
ここから 5 行足すだけで、R2 アーカイブが有効になります。レスポンスは先にユーザーへ返してしまい、書き込みは裏で走らせるのがポイントです。
// archive を組み込んだ実装
import Anthropic from "@anthropic-ai/sdk" ;
interface Env {
ANTHROPIC_API_KEY : string ;
ARCHIVE_BUCKET : R2Bucket ;
ARCHIVE_META : D1Database ;
}
export default {
async fetch ( request : Request , env : Env , ctx : ExecutionContext ) {
const body = await request. json < ChatRequest >();
const anthropic = new Anthropic ({ apiKey: env. ANTHROPIC_API_KEY });
const response = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 4096 ,
messages: body.messages,
metadata: { user_id: body.user_id },
});
// 先にレスポンスを返してしまう
const httpResponse = Response. json (response);
// 副作用は裏で走らせる
ctx. waitUntil (
archiveResponse ({
env,
request: body,
response,
userId: body.user_id,
}),
);
return httpResponse;
} ,
} satisfies ExportedHandler < Env > ;
async function archiveResponse ( args : {
env : Env ;
request : ChatRequest ;
response : Anthropic . Message ;
userId : string ;
}) {
const { env , request , response , userId } = args;
const requestId = response.id; // msg_01HXYZ...
const now = new Date ();
const yyyy = now. getUTCFullYear ();
const mm = String (now. getUTCMonth () + 1 ). padStart ( 2 , "0" );
const dd = String (now. getUTCDate ()). padStart ( 2 , "0" );
const hh = String (now. getUTCHours ()). padStart ( 2 , "0" );
const key = `claude-archive/${ yyyy }/${ mm }/${ dd }/${ hh }/${ requestId }.json` ;
const record = {
schema_version: "v1" ,
request_id: requestId,
ts: now. toISOString (),
model: response.model,
request: maskPII (request),
response,
client: { site: "claudelab" , version: env. BUILD_VERSION ?? "dev" },
};
await env. ARCHIVE_BUCKET . put (key, JSON . stringify (record), {
httpMetadata: { contentType: "application/json" },
customMetadata: {
userId,
model: response.model,
stopReason: response.stop_reason ?? "unknown" ,
},
});
// メタデータインデックスを D1 に書く
await env. ARCHIVE_META . prepare (
`INSERT INTO archive_index
(request_id, user_id, ts, model, stop_reason, input_tokens, output_tokens, r2_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ,
)
. bind (
requestId,
userId,
now. toISOString (),
response.model,
response.stop_reason,
response.usage.input_tokens,
response.usage.output_tokens,
key,
)
. run ();
}
ここで気をつけたいのは waitUntil の挙動です。waitUntil に渡した Promise が reject すると、Workers ダッシュボードの Logs には例外が出ますが、レスポンスはすでに返却済みなのでユーザーには影響しません。ただし、アーカイブが片側だけ書かれる(R2 は成功、D1 は失敗)状況は起こりえます。私のプロジェクトでは、ストレージの順序を「R2 → D1」に固定し、D1 失敗時には deadletter 用の Queues に request_id を流す設計にしています。後追いでバッチ修正できる経路を必ず残しておくと、長期運用で気が楽になります。
なお、ストリーミング(anthropic.messages.stream(...))の場合は、完了後の finalMessage() を待ってからアーカイブします。SSE の途中で接続が切れたときは「途中まで」を stream-truncated フラグ付きで書き込むようにしておくと、後日その種類の障害だけを抽出できて便利でした。
PII マスキング — ユーザーの個人情報をアーカイブに残さない
監査ログとして残すなら、当然 PII(個人を特定できる情報)の扱いは慎重にする必要があります。とくに B2C アプリで Claude API を使うときには、ユーザーが書き込んだ文章にメールアドレスや電話番号がそのまま入っているケースがあります。私が運営している壁紙アプリの返信支援機能でも、レビュー文に住所が混じっていることがあり、アーカイブに残すかは慎重に判断しなければなりませんでした。
私のプロジェクトでは、保存前に maskPII() で次のように加工しています。
function maskPII ( request : ChatRequest ) : MaskedRequest {
return {
... request,
messages: request.messages. map (( m ) => ({
... m,
content: typeof m.content === "string"
? maskText (m.content)
: m.content,
})),
};
}
function maskText ( text : string ) : string {
return text
// メールアドレス
. replace ( / [\w.+-] + @ [\w.-] + \. [A-Za-z] {2,} / g , "<email>" )
// 電話番号(日本国内・国際フォーマット)
. replace ( / \+ ? \d[\d\- \s ] {8,} / g , "<phone>" )
// 16 桁のカード番号らしき並び
. replace ( / \b (?: \d[ -] ? ) {13,16}\b / g , "<card>" )
// Stripe のセッショントークン
. replace ( /cs_(?:live | test)_ [a-zA-Z0-9_] + / g , "<stripe_session>" );
}
ここで重要なのは「マスキングを完璧にしようと頑張りすぎない」ことです。完璧な PII 検出は事実上不可能で、頑張れば頑張るほど false positive で本来残したい本文まで壊します。私の判断基準は次の通りです。
構造的に検出できるもの (メール・電話・カード番号・トークン)は機械的にマスクする
構造化されていない PII (住所の自由記述・名前の地の文)はマスクしない代わりに、保持期間を短くする(後述)
どうしても残せない種類のユーザー (年齢確認の必要な機能など)は、そもそも metadata フラグでマークし、アーカイブ対象外にする
最後のフラグは Anthropic API の metadata.user_id ではなく、Worker 側で持つ別のフィールド archive_policy として持っています。archive_policy: "skip" のリクエストはそもそも archiveResponse を呼ばないようにスキップします。
障害発生時のリプレイ手順 — request-id からアーカイブを引き出す
実際に障害が起きたときの作業フローを、サンプルで示します。私がこのフローで助かったのは、Cloudflare Queues の連携部分で 529 が断続的に起きていた日でした。ログには「○時○分にエラー数が増えた」しか出ておらず、その時間帯に何がリクエストされていたかを掘り起こすのに、R2 アーカイブが効きました。
# 1. D1 で「この時間帯の 429/529 を起こしたリクエスト」を抽出
wrangler d1 execute claudelab-archive \
--command "SELECT request_id, r2_key
FROM archive_index
WHERE ts BETWEEN '2026-05-24T02:00:00Z'
AND '2026-05-24T02:30:00Z'
AND stop_reason = 'unknown'" \
--remote --json | jq -r ".result[0].results[] | .r2_key" > keys.txt
# 2. R2 から本体をダウンロード
while read key ; do
wrangler r2 object get "claudelab-archive/${ key }" \
--file "./pulled/$( basename $key )"
done < keys.txt
# 3. リプレイスクリプトに流す
node tools/replay.mjs ./pulled
tools/replay.mjs の中身は、取得した JSON のリクエスト部だけを Claude API に再投入し、再現するか確認する素朴なスクリプトです。
// tools/replay.mjs(抜粋)
import { readdir, readFile } from "node:fs/promises" ;
import Anthropic from "@anthropic-ai/sdk" ;
const dir = process.argv[ 2 ];
const anthropic = new Anthropic ();
for ( const file of await readdir (dir)) {
const archived = JSON . parse (
await readFile ( `${ dir }/${ file }` , "utf-8" ),
);
const { request , response : original } = archived;
try {
const replayed = await anthropic.messages. create ({
model: original.model,
max_tokens: original.usage.output_tokens + 100 ,
messages: request.messages,
});
console. log (file, "ok" , replayed.stop_reason);
} catch (err) {
console. log (file, "still-fails" , err.status, err.message);
}
}
実体験として、このリプレイで分かったのは「同じプロンプトでも 529 は再現しない」ことでした。529 は瞬間的なバックエンド過負荷であり、リクエスト内容に起因していありません。逆に stop_reason: "max_tokens" で打ち切られていたケースは、リプレイでも max_tokens の上限に達することが多く、max_tokens を増やすか、システムプロンプトで「○○字以内」と制約するなどの対応に直結しました。アーカイブがあって初めて、こうした「再現する障害」と「しない障害」の切り分けが冷静にできます。
D1 でメタデータインデックスを併用する設計
R2 だけで完結させようとすると、検索のたびに LIST を叩くことになり、これは Class A 操作(後述)の料金を押し上げます。私は必ず D1 にメタデータインデックスを併設しています。テーブル定義はシンプルです。
CREATE TABLE archive_index (
request_id TEXT PRIMARY KEY ,
user_id TEXT NOT NULL ,
ts TEXT NOT NULL ,
model TEXT NOT NULL ,
stop_reason TEXT ,
input_tokens INTEGER ,
output_tokens INTEGER ,
r2_key TEXT NOT NULL
);
CREATE INDEX idx_archive_user_ts ON archive_index (user_id, ts);
CREATE INDEX idx_archive_ts_stop ON archive_index (ts, stop_reason);
CREATE INDEX idx_archive_model_ts ON archive_index (model, ts);
D1 はストレージあたりは安いとはいえ、無限ではありません。私は 90 日経過したレコードを archive_index_history に移し、r2_key だけ残して本体行は削除する運用にしています。D1 を「直近の高速検索層」、R2 を「長期保管層」と役割分担すると、コスト感がきれいになります。
検索ユースケースは、たいてい次の 3 パターンに収まります。
ユーザーから「この request_id を調べて」と頼まれた → WHERE request_id = ? で一発
特定ユーザーの直近 24 時間のリクエストを見たい → WHERE user_id = ? AND ts > ? で取得
モデル別の max_tokens 打ち切り率を集計したい → WHERE model = ? AND stop_reason = 'max_tokens' を期間で
3 番目のような分析クエリは、頻度が低ければ D1 で十分です。頻度が上がってきたら Workers Analytics Engine か Snowflake への定期エクスポートに移すのが本筋ですが、ほとんどの個人開発・小規模 SaaS では D1 の範囲で済んでしまいます。
R2 のコスト試算 — Class A 操作とストレージ料金
「アーカイブは安いから全部残せ」は半分正しく、半分間違いです。R2 の課金軸は主に 3 つあり、規模が大きくなると Class A 操作(PUT / LIST / DELETE)の積み重ねが目立ってきます。
ざっくり 2026 年 5 月時点の R2 料金(一般リージョン)を前提に、月 100 万リクエストの Claude API ワークロードを想定すると、
ストレージ : 1 レスポンス平均 8 KB として 100 万件で 8 GB / 月。1 ヶ月 0.015 USD/GB の従量制で、ストレージだけなら 0.12 USD と非常に安い
Class A 操作(書き込み) : PUT が 100 万回。1000 リクエストあたり 4.5 USD で計算すると 4.5 USD。これがコストの主役
Class B 操作(読み込み) : 障害分析でたまに数百件取得する程度なら誤差。仮に月 1 万 GET でも 1000 件あたり 0.36 USD で 3.6 USD
月の総コストは 10 USD 弱というのが私の実感値です。AdMob の月収から見れば誤差の範囲なのですが、油断するとぐっと膨らむ落とし穴があります。
最大の罠は「LIST を頻繁に叩く」運用です。例えば「過去 1 週間のレスポンスをすべて舐めて統計をとりたい」を毎日やると、1 日 168 個のディレクトリ × 数十 LIST 呼び出しで、簡単に Class A を浪費します。これを避けるために、統計用途では必ず D1 のメタデータインデックスから攻めるようにしています。R2 は「キーが分かっているものを取り出すストレージ」、D1 は「キーを発見するインデックス」と割り切るのが、私の中の運用方針です。
もう 1 つ、ライフサイクルルール で古いオブジェクトを Infrequent Access ストレージクラスに自動移動するのも効きます。wrangler r2 bucket lifecycle add ... で、たとえば 30 日経過したものを Infrequent Access に、90 日経過したものを削除、というルールを書けます。Infrequent Access のストレージ単価は標準の約 6 割で、読み出し時の Class B 料金が高くなる代わりに、めったに読まないアーカイブのトータルコストを下げられます。
ハマりどころと、運用してから気づいたこと
ここまでは設計の話ですが、実際に半年ほど運用してから気づいたハマりどころを、3 つだけ書き留めておきます。
1 つ目。R2 への PUT は完全な強整合性ではない ことです。書いた直後に LIST すると、稀に新しいキーが見えていないことがあります。waitUntil で書いた直後に「同じ Worker で再度確認」のような流れを組むと、新規キーが見えずに二重 PUT してしまうケースがあります。私のプロジェクトでは、二重 PUT 自体は許容(同じ JSON が上書きされるだけ)し、課金影響は監視で吸収する方針にしました。アーカイブ用途で強整合性が必要になる状況は、よく考えるとほとんどありません。
2 つ目。ストリーミング途中でクライアントが切断したとき の挙動です。messages.stream() の途中でユーザーがブラウザを閉じると、finalMessage() を待たずに Worker のリクエスト処理が終わってしまうことがありました。これを防ぐには、stream を for-await で消費しながら、message_stop イベントが来たら明示的に waitUntil(archive(...)) を登録する必要があります。for await (const event of stream) の event.type === "message_stop" を検出してアーカイブを発火させると、クライアント切断後でも Worker の waitUntil が走り、ちゃんとアーカイブされます。
3 つ目。プロンプトキャッシュの中身までは残らない ことです。Anthropic の API レスポンスには cache_read_input_tokens がメタ情報として返ってきますが、「実際にキャッシュからどの部分が読まれたか」までは出てきません。リプレイ時にキャッシュヒット率を完全再現するのは原理的に難しい、と最初から割り切るのが精神衛生上よいです。私は usage.cache_read_input_tokens / usage.input_tokens のヒット率だけを D1 に記録しておき、傾向値で扱うようにしています。
ここまでで設計の全体像と、実装のはまりどころを一通り共有できたかと思います。次に手を動かすなら、まず archiveResponse(...) を Workers のホットパスに ctx.waitUntil で組み込み、1 日ぶんのデータが R2 に積まれることを確認してみてください。D1 のメタデータインデックスは、後から追加してもキーから戻し入れができるので、最初は R2 だけで始めても構いません。
私自身、最初の半年は R2 だけで運用していて、サポート対応のたびに wrangler r2 object get で 1 件ずつ引いていました。途中から D1 インデックスを足したことで、サポート問い合わせの応答時間が体感で 1/5 になりました。アーカイブは「最初から完璧」を狙うより、「まず残し始めて、必要になったら検索層を足す」の段階的な育て方が現実的だと感じています。同じように Cloudflare 上で Claude API を運用している方の参考になれば嬉しいです。お読みいただきありがとうございました。