Claude APIのストリーミングを実装してすぐ、ReadableStream is not async iterable というエラーに2時間悩みました。公式のサンプルコードは動くのに、自分の Next.js プロジェクトに組み込んだ途端に動かない——そういう経験をされた方は多いのではないでしょうか。
この記事は、実際にいくつかのプロダクトで Claude API のストリーミングを実装してきた中で、ドキュメントだけでは気づきにくかったポイントを整理したものです。「なぜ動かないのか」という視点で書いていますので、すでに基本的な実装ができている方にとっても役立てていただけると思います。
落とし穴1: ランタイム環境の違いが原因のストリーム非互換
最初の問題は、Node.js と Edge Runtime(Vercel Edge Functions や Cloudflare Workers)でストリームの扱いが異なることです。
Anthropic SDK の stream() メソッドが返すのは、SDK 独自のラッパーオブジェクトです。これは AsyncIterable として使えますが、Web 標準の ReadableStream とは別物です。Next.js の App Router では API Route がデフォルトで Edge Runtime 上で動作することがあり、そこで for await...of を使おうとすると型不一致のエラーが出ます。
// ❌ Edge Runtime で詰まりやすいパターン
import Anthropic from '@anthropic-ai/sdk';
export const runtime = 'edge'; // ← これが曲者
const client = new Anthropic();
export async function POST(req: Request) {
const stream = await client.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
});
// Edge Runtime では for await が期待通りに動かないことがある
for await (const chunk of stream) {
// ...
}
}解決策は2つあります。Node.js Runtime に明示的に切り替えるか、Web 標準の ReadableStream を自前で構築するかです。
// ✅ Node.js Runtime で動く安定パターン
export const runtime = 'nodejs'; // Edge→Node.js に変更
export async function POST(req: Request) {
const client = new Anthropic();
const stream = client.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 2048,
messages: [{ role: 'user', content: await req.text() }],
});
// SDK の toReadableStream() で Web 標準に変換
return new Response(stream.toReadableStream(), {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}私が最初にはまったのは、runtime = 'edge' の宣言を消し忘れたまま本番デプロイして、ローカルでは動くのに本番だけ壊れるという状態でした。ローカルの Next.js 開発サーバーは Node.js で動くので再現しないんです。
落とし穴2: エラーハンドリングのタイミング問題
ストリーミングでは、エラーが「ストリーム開始前」に起きるか「ストリーム中」に起きるかで対処が変わります。この2種類を同じ try-catch でまとめて処理しようとすると、片方が漏れます。
// ❌ エラーが漏れがちなパターン
async function streamMessage(content: string) {
try {
const stream = client.messages.stream({ ... });
for await (const event of stream) {
// ここの処理中にエラーが起きた場合...
process.stdout.write(event.delta?.text ?? '');
}
} catch (e) {
// 接続前のエラーは捕捉できるが、
// ストリーム途中のネットワーク断は別の扱いが必要
console.error(e);
}
}SDK が提供する stream.on('error', ...) と stream.finalMessage() を組み合わせるのが実践的な対処法です。
// ✅ 2種類のエラーを分けて処理するパターン
async function streamMessage(content: string): Promise<string> {
const stream = client.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 2048,
messages: [{ role: 'user', content }],
});
// ストリーム中のエラーを個別にハンドリング
stream.on('error', (error) => {
console.error('Stream error:', error);
// 必要に応じてリトライロジックを追加
});
// テキストだけ逐次処理
stream.on('text', (text) => {
process.stdout.write(text);
});
// 完了を待って最終メッセージを取得
// ここで接続前エラーも含めて throw される
try {
const finalMessage = await stream.finalMessage();
return finalMessage.content[0].type === 'text'
? finalMessage.content[0].text
: '';
} catch (e) {
// API エラー・タイムアウト等
throw new Error(`Stream failed: ${e instanceof Error ? e.message : String(e)}`);
}
}finalMessage() は「ストリームが正常に完了したことを確認してから最終結果を返す」メソッドなので、部分的な応答しか受け取れていない状態で処理が続かないよう防ぐことができます。
落とし穴3: トークン数の見積もりミスによるコスト超過
ストリーミングを実装すると、「レスポンスが来ている」という感覚が強くなって、実際のトークン消費量が見えにくくなります。気づいたら1リクエストあたり 5,000 トークンを超えていた、ということが起きやすいです。
stream.finalMessage() の返り値には usage フィールドが含まれていて、実際の入力・出力トークン数が確認できます。
const finalMessage = await stream.finalMessage();
console.log({
inputTokens: finalMessage.usage.input_tokens,
outputTokens: finalMessage.usage.output_tokens,
totalTokens: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
model: finalMessage.model,
});私が実際に遭遇したのは、会話履歴をそのまま全件 messages に渡し続けていて、30往復後には入力トークンが 15,000 を超えていたケースです。会話の要約や、古いメッセージの削除ロジックは最初から設計に組み込んでおくべきでした。
// ストリーミングでコスト上限を設ける実践例
const MAX_INPUT_TOKENS = 8000;
function trimMessages(
messages: Anthropic.MessageParam[],
maxTokens: number
): Anthropic.MessageParam[] {
// 概算:1メッセージ平均500トークンと仮定して直近N件を保持
const estimatedPerMessage = 500;
const maxMessages = Math.floor(maxTokens / estimatedPerMessage);
if (messages.length <= maxMessages) return messages;
// 最初のシステムコンテキスト(user メッセージ)は保持
const systemMessage = messages[0];
const recentMessages = messages.slice(-(maxMessages - 1));
return [systemMessage, ...recentMessages];
}正確なトークン数が必要な場合は client.messages.countTokens() を使えますが、ストリーム送信前に毎回呼ぶと余分なAPIコールが発生します。バランスとしては、会話が一定件数を超えたらトリムする閾値ベースのアプローチが現実的だと思っています。
ストリーミングの本質的な難しさ
振り返ると、ストリーミングが難しいのは「見た目は動いている」ことが多いからだと感じています。エラーが出ていなくて、テキストは流れてくる。でも、エラーハンドリングが不完全だったり、トークンが想定以上に消費されていたりします。
本番環境でストリーミングを安定させるには、「動く」ではなく「壊れたときに何が起きるか」を最初から設計しておくことが大切です。接続が切れたときのリトライ、トークン上限への対策、ログ収集——これらを後から追加しようとすると、アーキテクチャの変更を迫られることがあります。
Claude API のストリーミングは、きちんと実装すればユーザー体験を大きく向上させる機能です。ただ、その「きちんと」の中身は、ドキュメントには書かれていない部分が結構あります。この記事が、その「書かれていない部分」を補う一助になれば嬉しいです。