個人開発アプリにClaude APIを組み込んでから半年ほどが経ちます。最初の数週間は「動いている」という事実だけで十分でした。しかし月末のAPIの請求書を見るたびに、何かが引っかかるようになりました。
費用が増えています。でも、ユーザー数はそこまで増えていありません。
そこで私はアプリのリクエストログを丁寧に見直し始めました。そこで気づいたのは、3つの問題が重なり合っていたことです。モデルの選択が最適でありません。ストリーミングが応答体験を改善するはずが、特定の箇所で逆に不安定にしていた。そして、同じシステムプロンプトを毎回のリクエストで再送信していた。
2014年からiOS・Androidアプリを個人で開発してきた経験から、ツールの「使い方」よりも「使いどころ」の見極めが最終的なコストと品質を決める、という感覚があります。ここではClaude Haiku 4.5・ストリーミング・プロンプトキャッシングの3つを組み合わせて個人開発規模のアプリを最適化した過程を、失敗も含めて記録します。
なぜ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");
}実装前後のコスト比較
実際の数値は個々のアプリ・使用パターンによって大きく異なりますが、私の場合の変化を共有します。
変更前(Sonnet 3.5 + ストリーミングなし + キャッシュなし)
- 1リクエストあたりの平均入力トークン:約850(システムプロンプト+ユーザー入力)
- モデル:claude-sonnet-3-5
- キャッシュヒット率:0%(実装なし)
変更後(Haiku 4.5 + 条件付きストリーミング + プロンプトキャッシング)
- 1リクエストあたりの平均入力トークン:約850(同じ)
- モデル:claude-haiku-4-5-20251001
- キャッシュヒット率:75〜80%
- ユーザーの体感応答速度:改善(ストリーミング適用箇所)
コスト削減率を正確に「X%」と言い切るのは、Anthropicの料金設定が変わる可能性もあるため避けますが、体感的には月額費用が半分程度になりました。重要なのは数字そのものより、どのレバーが効いたかを把握した上で改善できたという点です。
Haiku 4.5 へのモデル変更が最も大きな変化をもたらしました。ストリーミングはコスト削減よりも体験改善が主目的です。プロンプトキャッシングは、リクエスト量が多い場合に効果が大きく、少量リクエストのアプリでは恩恵が薄いです。
実装時に学んだ注意点まとめ
Haiku 4.5 移行時の注意
Sonnet から Haiku に切り替えると、複雑な指示の遵守率が下がることがあります。プロンプトを「核心的な指示を前半に」「箇条書きより文章形式で」の方針で整理するだけで、かなり改善します。
ストリーミングの注意
短い出力(100文字以下)はストリーミングより一括取得の方が体験が良いケースが多いです。stop_reason を必ずログに記録し、max_tokens に引っかかっている場合は出力長の設計を見直してください。
プロンプトキャッシングの注意
キャッシュは「固定コンテンツ」に対して設定するものです。ユーザー入力など変動するコンテンツの後にキャッシュポイントを置いても効果はありません。また、リクエスト頻度が低いアプリ(数時間に1回程度)では、5分のキャッシュ有効期限内にヒットしないため、ウォームアップ処理が重要になります。
個人開発アプリに組み込むAI機能の設計哲学
2014年からアプリを個人で開発し、累計5,000万ダウンロードを超えた経験を通じて感じるのは、「技術の正しさ」よりも「使いどころの正しさ」が長期的な結果を決める、ということです。
AIをアプリに組み込む際も同じです。Haiku 4.5 が正しいモデルかどうかではなく、「自分のアプリのユースケースにどのモデルが最適か」を実際に検証するプロセスが重要です。
ストリーミングも、プロンプトキャッシングも、道具です。それ自体が目的ではありません。ユーザーが「早く返ってきた」「この応答が自分に合っている」と感じる体験を作るための手段です。
この記事で紹介した3つの組み合わせが、どなたかのアプリ開発の同じ課題に取り組んでいる方の参考になれば幸いです。私自身、まだ改善の余地があると感じており、特にレート制限の上限に余裕ができてきた段階でのバッチ処理の組み合わせを次の課題として考えています。
参考リンク
- Claude API ストリーミングの接続切断問題への対処法
- Anthropic SDK の型安全な Tool Calling 実装(TypeScript)
- Claude Haiku 4.5 完全ガイド — 軽量モデルの使いどころと限界