個人開発アプリにClaude APIを組み込んでから半年ほどが経ちます。最初の数週間は「動いている」という事実だけで十分でした。しかし月末の請求書を開くたび、胸のあたりがざわつくようになりました。
費用が増えています。でも、ユーザー数はそこまで増えていません。
リクエストログを一件ずつ辿ってみると、3つの問題が重なっていました。タスクの難易度に対してモデルが過剰でした。ストリーミングは応答体験を良くするはずが、特定の画面ではむしろ不安定さを持ち込んでいました。そして、ほぼ固定の800トークンのシステムプロンプトを、毎回そのまま送り直していました。
3つとも、単体で直そうとして一度失敗しています。とくにプロンプトキャッシングは、ブレイクポイントの置き場所を間違えたまま「効いていない」と判断しかけました。実際には効いていないどころか、キャッシュ書き込み分だけ高くついていたのです。
ここではその失敗も含めて、Haiku 4.5・ストリーミング・プロンプトキャッシングを噛み合わせるまでの過程を記録します。あわせて、当時は感覚で済ませていた「どのレバーがどれだけ効いたのか」を、公開単価から実際に計算し直しました。
なぜ3つを組み合わせる必要があったのか
まずそれぞれを個別に試した段階の話から始めます。
Haiku 4.5 単体の評価では、「軽量・高速・安価」という公式の説明は概ね正確でした。Sonnet から Haiku 4.5 に切り替えることで、同じタスクのコストが大幅に下がります。しかし、単純なモデル切り替えだけでは解決しない問題がありました。アプリの応答が速くなった代わりに、特定のプロンプトでの出力品質にばらつきが出るようになったのです。「プロンプトを最適化すれば解決できる」と分かっていながら、それがまた時間のかかる作業でした。
ストリーミング単体の評価では、ユーザーの体感が確かに改善しました。「考えている感」が伝わり、応答待ちのストレスが下がります。ところが、ストリーミングを入れたことで新しい問題が発生しました。接続の切断処理・部分的なレスポンスのハンドリング・エラー発生時の表示が、期待通りに動かないケースが出始めたのです。ストリーミングはツールとしてシンプルに見えますが、実装の細部でかなりの注意が必要です。
プロンプトキャッシング単体の評価では、理論値どおりの削減効果を得られませんでした。キャッシュが効く条件を誤解していたのが原因でした。「同じシステムプロンプトを使えばキャッシュされる」という理解は正しかったのですが、キャッシュブレイクポイントの設計を誤ると、むしろキャッシュ書き込みのコストが上乗せされてしまいます。
3つを個別に試した後、組み合わせることで相乗効果が得られると判断しました。ここからが本題です。
実装環境と対象アプリの概要
記録の対象となるアプリは、iOS/Android 向けのパーソナライズドコンテンツアプリです。ユーザーがテキスト入力した内容に対してClaudeが応答を生成し、その結果をアプリ内で表示する仕組みです。リクエストのパターンは概ね以下の通りです。
- システムプロンプト: 約800トークン(アプリ固有の指示、ほぼ固定)
- ユーザー入力: 50〜200トークン(毎リクエストで変わる)
- 期待出力: 100〜400トークン
バックエンドはNode.js + TypeScript、Claude APIとの通信には公式の Anthropic SDK を使用しています。月間のアクティブリクエスト数は数万件規模です。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY, // 環境変数から取得
});ステップ1:Haiku 4.5 への移行とプロンプト最適化
移行時に引っかかったポイント
Haiku 4.5 は軽量モデルですが、「システムプロンプトが長すぎると従わないケースがある」という挙動を経験しました。Sonnet ではほぼ無視されることのなかった指示が、Haiku では時折読み飛ばされることがありました。
調査してみると、問題は指示の優先順位と密度にありました。800トークンのシステムプロンプトに詰め込みすぎた指示のうち、Haiku は後半部分を軽視する傾向がありました。
対処として、システムプロンプトを「核心的な指示」と「補足的な文脈」に分け、前者を先頭に明確に配置しました。また、箇条書きによる指示よりも、文章的に流れる記述の方が Haiku との相性が良いことも経験的に分かりました。
// Before: 詰め込みすぎたシステムプロンプト(問題あり)
const systemPromptV1 = `
あなたは[アプリ名]のアシスタントです。
・ユーザーの入力を受け取り応答してください
・常に日本語で返答してください
・フレンドリーなトーンで
・200文字以内に収めてください
・専門用語は使わないでください
・絵文字を1〜2個使ってください
・ネガティブな発言は避けてください
・ユーザーを励ます内容を含めてください
... (以下続く)
`;
// After: 核心的な指示を前半に集約(改善後)
const systemPromptV2 = `
あなたは[アプリ名]のアシスタントです。
ユーザーの入力に対して、日本語で、200文字以内で、明るく励ます返答を生成してください。
【必須条件】
- 必ず日本語で返答する
- 200文字以内に収める
- 絵文字を1〜2個使用する
- ポジティブなトーンを保つ
`;改善後は、Haiku 4.5 での指示遵守率が明らかに安定しました。
モデル指定のコード
const response = await client.messages.create({
model: "claude-haiku-4-5-20251001", // Haiku 4.5
max_tokens: 512,
system: systemPromptV2,
messages: [
{
role: "user",
content: userInput,
},
],
});ステップ2:ストリーミング実装と落とし穴
ストリーミングを入れるべき箇所・入れるべきでない箇所
ストリーミングが体験を改善するのは、出力が長く、ユーザーが待機していることを認識している場合です。逆に、短い出力(100文字以内)をストリーミングで返すと、かえって不自然な表示になることがあります。トークンがパラパラと表示されるより、一度に出る方が自然に見えるからです。
私のアプリでは、200文字以内の出力がほとんどです。最初はすべてのリクエストにストリーミングを適用しましたが、出力が短い場合のユーザー体験を検証した結果、以下の条件分岐を設けました。
type ContentRequest = {
userInput: string;
expectedLength: "short" | "long"; // ルーティング用フラグ
};
async function generateContent(req: ContentRequest): Promise<string> {
if (req.expectedLength === "short") {
// 短い出力はストリーミングなし(一括取得の方が体験が良い)
return await generateWithoutStreaming(req.userInput);
} else {
// 長い出力はストリーミング
return await generateWithStreaming(req.userInput);
}
}ストリーミング実装のコアパターン
ストリーミングで最も重要なのは、接続断や部分的なエラーへの対処です。ネットワークが不安定な環境(モバイルアプリでは当然あり得る)では、ストリーミング中に切断が発生します。
async function generateWithStreaming(userInput: string): Promise<string> {
const chunks: string[] = [];
try {
const stream = await client.messages.stream({
model: "claude-haiku-4-5-20251001",
max_tokens: 1024,
system: systemPromptV2,
messages: [{ role: "user", content: userInput }],
});
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
chunks.push(chunk.delta.text);
// リアルタイムでUIに送信する場合はここでWebSocket/SSEに流す
}
}
const finalMessage = await stream.finalMessage();
// stop_reason のチェック("end_turn" 以外は要調査)
if (finalMessage.stop_reason !== "end_turn") {
console.warn(
`Unexpected stop_reason: ${finalMessage.stop_reason}`,
{ input: userInput }
);
}
return chunks.join("");
} catch (error) {
if (error instanceof Anthropic.APIConnectionError) {
// 接続断:キャッシュがあればキャッシュを返す、なければリトライ
console.error("Stream connection error:", error.message);
throw error;
}
throw error;
}
}ストリーミングで気づいた意外な問題:stop_reason
ストリーミング実装後、ログを見ていると stop_reason: "max_tokens" が散発的に記録されていました。
max_tokens: 512 と設定しているのに、なぜ? と思って調べると、システムプロンプトの「200文字以内」という指示が常に守られているわけではなく、400〜500文字の応答が生成されるケースがあったことが分かりました。Haiku 4.5 はトークン数で出力を制御しますが、日本語は1文字が複数トークンを消費することがあるため、文字数換算のずれが起きていました。
対処:max_tokens を 256 に下げて、短い応答を確実に得る設計に変更しました。同時に、「200文字以内」という指示を「必ず100〜150文字程度で収める」に変更し、緩衝帯を持たせました。
ステップ3:プロンプトキャッシングの設計
キャッシュが効く条件の正確な理解
プロンプトキャッシングの基本ルールを整理します。
- キャッシュが作成される条件:
cache_control: { type: "ephemeral" }を付けた最初のリクエスト - キャッシュが使われる条件:キャッシュブレイクポイント以前のコンテンツが完全に一致する後続リクエスト
- キャッシュの有効期間:5分(ephemeral の場合)
最初に誤解していたのは「キャッシュブレイクポイントの位置」です。キャッシュは、cache_control を指定した箇所までの内容が一致するときに有効になります。そのため、変動する内容よりも前にブレイクポイントを置く必要があります。
// 誤ったパターン(ユーザー入力の後にキャッシュポイントを置いてしまっている)
const badRequest = {
model: "claude-haiku-4-5-20251001",
max_tokens: 256,
system: [
{
type: "text",
text: systemPromptV2,
},
],
messages: [
{
role: "user",
content: [
{
type: "text",
text: userInput, // ← ここが毎回変わる
cache_control: { type: "ephemeral" }, // ← 変動箇所の後なので無意味
},
],
},
],
};
// 正しいパターン(固定部分にキャッシュポイントを置く)
const goodRequest = {
model: "claude-haiku-4-5-20251001",
max_tokens: 256,
system: [
{
type: "text",
text: systemPromptV2, // ← ここは固定
cache_control: { type: "ephemeral" }, // ← 固定部分にポイントを置く
},
],
messages: [
{
role: "user",
content: userInput, // ← ここは毎回変わってよい
},
],
};キャッシュヒット率のモニタリング
Anthropic APIのレスポンスには、使用トークンの内訳に cache_read_input_tokens と cache_creation_input_tokens が含まれています。これを記録することで、キャッシュが実際に効いているかを確認できます。
interface UsageStats {
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
}
function extractUsage(response: Anthropic.Message): UsageStats {
return {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
cacheCreationTokens: response.usage.cache_creation_input_tokens ?? 0,
cacheReadTokens: response.usage.cache_read_input_tokens ?? 0,
};
}
// キャッシュヒット率の計算
function calculateCacheHitRate(stats: UsageStats[]): number {
const totalRequests = stats.length;
const cacheHits = stats.filter((s) => s.cacheReadTokens > 0).length;
return (cacheHits / totalRequests) * 100;
}実装直後は、キャッシュヒット率が30〜40%程度でした。5分のキャッシュ有効期限内にリクエストが集中していない時間帯はキャッシュが使われないためです。
これを改善するために、キャッシュを能動的に温める(warm up) 処理を追加しました。
// キャッシュウォームアップ関数
// アプリ起動時やキャッシュ失効前に呼び出す
async function warmUpCache(): Promise<void> {
try {
await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 1, // 最小限のトークン(コスト抑制)
system: [
{
type: "text",
text: systemPromptV2,
cache_control: { type: "ephemeral" },
},
],
messages: [
{
role: "user",
content: "ping", // ダミーメッセージ
},
],
});
console.log("Cache warmed up at:", new Date().toISOString());
} catch (error) {
// ウォームアップ失敗はクリティカルではないため、エラーを飲み込む
console.warn("Cache warmup failed:", error);
}
}
// 4分ごとにキャッシュを更新(5分の有効期限の少し前)
setInterval(warmUpCache, 4 * 60 * 1000);この仕組みを入れた後、キャッシュヒット率は安定して70〜80%台に改善しました。
ただし、ウォームアップのリクエスト自体にもキャッシュ書き込みの料金がかかります。当時の私はここを見ていませんでした。この構成が本当に得になっているかは、後半で計算し直します。
ステップ4:3つを統合した実装パターン
最終的なリクエスト構成
3つの要素を統合したリクエスト関数です。
interface GenerateOptions {
userInput: string;
streaming: boolean;
onStreamChunk?: (chunk: string) => void; // ストリーミング時のコールバック
}
async function generateOptimized(
options: GenerateOptions
): Promise<{ text: string; usage: UsageStats }> {
const { userInput, streaming, onStreamChunk } = options;
const systemConfig = [
{
type: "text" as const,
text: systemPromptV2,
cache_control: { type: "ephemeral" } as const,
},
];
if (streaming && onStreamChunk) {
// ストリーミングモード
const chunks: string[] = [];
let finalUsage: UsageStats | null = null;
const stream = await client.messages.stream({
model: "claude-haiku-4-5-20251001",
max_tokens: 256,
system: systemConfig,
messages: [{ role: "user", content: userInput }],
});
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
chunks.push(chunk.delta.text);
onStreamChunk(chunk.delta.text);
}
}
const finalMessage = await stream.finalMessage();
finalUsage = extractUsage(finalMessage);
return { text: chunks.join(""), usage: finalUsage };
} else {
// 非ストリーミングモード(短い出力向け)
const response = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 256,
system: systemConfig,
messages: [{ role: "user", content: userInput }],
});
const text =
response.content[0].type === "text" ? response.content[0].text : "";
const usage = extractUsage(response);
return { text, usage };
}
}エラーハンドリングとリトライ戦略
本番環境では、APIの一時的なエラーやレート制限に対応するリトライ処理が必要です。
async function generateWithRetry(
options: GenerateOptions,
maxRetries = 3
): Promise<{ text: string; usage: UsageStats }> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await generateOptimized(options);
} catch (error) {
lastError = error as Error;
if (error instanceof Anthropic.RateLimitError) {
// レート制限:指数バックオフで待機
const waitMs = Math.pow(2, attempt) * 1000;
console.warn(`Rate limit hit. Waiting ${waitMs}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
if (error instanceof Anthropic.APIConnectionError) {
// 接続エラー:短い待機後にリトライ
await new Promise((resolve) => setTimeout(resolve, 500));
continue;
}
// その他のエラーはリトライしない
throw error;
}
}
throw lastError ?? new Error("Max retries exceeded");
}実装前後のコスト比較
金額は使用パターンで大きく変わります。ここでは冒頭に挙げたトークン構成を固定し、公開されている単価で計算した値を並べます。請求書そのものではなく、どのレバーがどれだけ効くのかを見るための計算です。
前提: システムプロンプト800トークン・ユーザー入力125トークン・出力250トークン・月間3万リクエスト。
| 構成 | 入力 / 出力(per MTok) | 月額 | 変更前比 |
|---|---|---|---|
| 変更前: Sonnet 3.5・キャッシュなし | $3 / $15 | $195.75 | — |
| モデルのみ変更: Haiku 4.5・キャッシュなし | $1 / $5 | $65.25 | -66.7% |
| 変更後: Haiku 4.5・キャッシュヒット率77.5% | $1 / $5 | $49.86 | -74.5% |
効いた順番がはっきりしました。削減の大半にあたる66.7%はモデルの切り替えだけで得られています。プロンプトキャッシングはその上に23.6%を積む二段目のレバーです。ストリーミングはこの表に現れません。コストではなく体感を動かす施策だからです。
順番を逆にしていた自分に気づいたのは、この表を作ったときでした。私は効果の小さいほうから手を付け、しかも置き場所を間違えていたわけです。
なお2026年9月1日に Sonnet 5 の導入価格 $2/$10 が終了し、$3/$15 になります。Sonnet 5 を使っている構成では、同じ式で入力側がちょうど1.5倍になります。Haiku 4.5 との入力単価の差は導入価格時の2倍から3倍へ開くため、「Sonnet でなくても通るタスク」の切り分けは9月以降さらに効きます。
キャッシュの損益分岐を先に計算する
ヒット率21.7%を下回るとキャッシュは損になる
プロンプトキャッシングの説明では「ヒット率が高いほど得」とだけ語られがちです。運用してみて痛かったのは、その逆側の境界でした。
料金体系はこうなっています。キャッシュへの書き込みは通常の入力単価の1.25倍、読み出しは0.1倍。書き込みが割高で、読み出しで取り返す構造です。
キャッシュ対象ブロックについて、ヒット率を h とすると1リクエストあたりの係数は 1.25(1 - h) + 0.1h になります。これが通常送信の 1.0 と等しくなる点が損益分岐です。解くと h = 0.25 / 1.15 ≒ 0.217。
同じトークン構成で計算した結果が下の表です。
| キャッシュヒット率 | 月額 | キャッシュなし($65.25)との差 |
|---|---|---|
| 0%(書き込みだけ) | $71.25 | +9.2% |
| 10% | $68.49 | +5.0% |
| 21.7%(損益分岐) | $65.26 | ±0% |
| 50% | $57.45 | -12.0% |
| 75% | $50.55 | -22.5% |
| 90% | $46.41 | -28.9% |
ヒット率0%のとき、キャッシュを入れたせいで月額が9.2%上がります。「効果が薄い」ではなく「損」です。
机上の話ではありません。ephemeral の有効期限は5分です。リクエストが5分に1回未満しか来ない時間帯は、書き込みばかりが積み上がってヒットが発生しません。時間帯別に切り出すと、ヒット率は日中と深夜でまったく別の数字になります。全体平均だけを見ていると、この分岐点を割っている時間帯の存在に気づけません。
ウォームアップは常時回すと損になることがある
前半で入れた4分間隔のウォームアップも、同じ計算にかけてみました。
4分間隔で30日回すと、月10,800回。1回あたり800トークンのキャッシュ書き込みが発生するため、ウォームアップ自体の月額は $11.29 になります。これは実リクエストの多寡に関係なく発生する固定費です。
| 月間リクエスト | キャッシュなし | 常時ウォームアップ | 差 |
|---|---|---|---|
| 5,000 | $10.88 | $18.56 | +70.7% |
| 10,000 | $21.75 | $25.84 | +18.8% |
| 20,000 | $43.50 | $40.39 | -7.2% |
| 30,000 | $65.25 | $54.94 | -15.8% |
| 60,000 | $130.50 | $98.59 | -24.5% |
常時ウォームアップがキャッシュなしを下回るのは、月およそ15,700リクエストから。1日約520件、平均すると2.8分に1件のペースです。これを下回る規模のアプリでは、ウォームアップを入れるほど高くなります。
さらに厄介なのは、自然にヒットが発生している場合との比較です。月3万リクエストの条件で、常時ウォームアップは $54.94、ウォームアップなしでヒット率77.5%なら $49.86。自然ヒット率が59.1%を超えている環境では、常時ウォームアップは足を引っ張ります。
規模ごとの境界は次のとおりです。
| 月間リクエスト | 常時ウォームアップが有利になる自然ヒット率 |
|---|---|
| 10,000 | どのヒット率でも不利 |
| 20,000 | 38.7% 未満 |
| 30,000 | 59.1% 未満 |
| 50,000 | 75.5% 未満 |
| 100,000 | 87.7% 未満 |
つまりウォームアップは「入れれば安くなる施策」ではなく、トラフィックが薄い時間帯だけに効く施策です。私は setInterval による無条件実行をやめ、直近のリクエストからの経過時間で判断する形に書き換えました。
let lastRequestAt = 0;
export function markRequest(): void {
lastRequestAt = Date.now();
}
// 直近4分間に実リクエストが無かったときだけウォームアップする
async function warmUpIfIdle(): Promise<void> {
const idleMs = Date.now() - lastRequestAt;
if (idleMs < 4 * 60 * 1000) {
return; // 自然にヒットが発生している。書き込みを足さない
}
await warmUpCache();
}
setInterval(warmUpIfIdle, 60 * 1000); // 判定は1分ごと、実行は必要なときだけ判断に必要なのは自分のリクエスト間隔の分布です。平均値では見えません。「直前のリクエストから5分以内に次が来た割合」を出してください。それがそのまま、ウォームアップ無しで到達できるヒット率のおおよその上限になります。
実装時に学んだ注意点まとめ
Haiku 4.5 移行時の注意
Sonnet から Haiku に切り替えると、複雑な指示の遵守率が下がることがあります。プロンプトを「核心的な指示を前半に」「箇条書きより文章形式で」の方針で整理するだけで、かなり改善します。
ストリーミングの注意
短い出力(100文字以下)はストリーミングより一括取得の方が体験が良いケースが多いです。stop_reason を必ずログに記録し、max_tokens に引っかかっている場合は出力長の設計を見直してください。
プロンプトキャッシングの注意
キャッシュは「固定コンテンツ」に対して設定するものです。ユーザー入力など変動するコンテンツの後にキャッシュポイントを置いても効果はありません。そしてリクエスト頻度が低いアプリでは、5分の有効期限内にヒットが起きないためキャッシュが逆に高くつきます。ウォームアップで救えるのは月15,700リクエストを超える規模から、というのが計算結果でした。それ未満なら、キャッシュを入れない判断のほうが安く済みます。
道具を入れる順番を決めるということ
3つを入れ終えて振り返ると、技術そのものの理解より、入れる順番の判断に時間を使っていました。
Haiku 4.5 が正しいモデルかどうかは、公式の説明を読んでも決まりません。自分のアプリで出力を並べ、許容できないケースがどのくらいの頻度で出るかを数えて、はじめて決まります。私はこの確認を後回しにして、先に細かいキャッシュ設定をいじっていました。効果の大きさが3倍違ったのに、です。
キャッシュの損益分岐も同じでした。「ヒット率が高いほど得」という理解のまま入れて、ヒットしない時間帯があることに長く気づけませんでした。分岐点が21.7%だと知っていれば、最初に測るべき数字が何かは明らかだったはずです。
ストリーミングも、プロンプトキャッシングも、道具です。それ自体が目的ではありません。ユーザーが「早く返ってきた」「この応答が自分に合っている」と感じる体験を作るための手段にすぎません。
もし同じようにAPIコストが気になっている方がいれば、次の順番をおすすめします。まずモデルの妥当性を出力の比較で確認する。次にリクエスト間隔の分布を出す。キャッシュに手を付けるのはその後です。この順番なら、私が回り道した2週間は要りません。
最初の一歩は、いま使っているモデルとタスクの難易度が釣り合っているかを、10件でいいので出力を並べて確かめることだと思います。私自身まだ改善の途中で、レート制限に余裕が出てきた段階でのバッチ処理との組み合わせを次の課題にしています。お読みいただきありがとうございました。