2014年から個人開発でアプリを書き続けていますが、Claude API を本番のチャットボットに組み込んで最初にハマったのが、ストリーミング応答での tool_use の引数パースでした。messages.create({ stream: true }) で受け取ったイベントを順番に JSON.parse していくと、ほぼ確実に SyntaxError: Unexpected end of JSON input で落ちます。サンプルコードを丁寧に書き写したつもりが動かない、という典型的なパターンです。同じところで詰まっている方の助けになればと思って、原因と落としどころをまとめておきます。
症状: input_json_delta を JSON.parse すると必ず SyntaxError
ストリームの中身を console.log で覗くと、こんな順番で届きます。
{ "type": "message_start", "message": { ... } }
{ "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "toolu_01...", "name": "get_weather", "input": {} } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{\"loc" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "ation\": \"" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "Tokyo\"}" } }
{ "type": "content_block_stop", "index": 0 }
{ "type": "message_delta", "delta": { "stop_reason": "tool_use" } }
{ "type": "message_stop" }ここで partial_json を 1 イベントずつパースしようとすると、{"loc の時点で SyntaxError になります。一見すると JSON のフラグメントですが、Claude API はパースしやすい区切りで送ってくれるわけではありません。中括弧の途中・キーの途中・値の途中、どこででも切ってきます。
つまり partial_json は JSON チャンクではなく、最終的に連結すると JSON になる「ただの文字列断片」 です。これを 1 件ずつパースする実装は、たまたまブロックが小さくて全部 1 通に収まったときだけ動いて、入力が長くなった途端に壊れます。
原因: ブロック単位でしかパースできない契約になっている
公式のメッセージング API ドキュメントを読み直すと、input_json_delta の項に「partial_json フィールドを順次連結し、content_block_stop を受け取った時点でパースする」と書かれています。連結のキーは content_block_delta.index で、同じ index に属するデルタを 1 本のバッファに足していくのが正解です。
text_delta のような他の delta 種別と取り違えやすいのもハマりどころです。text ブロックなら 1 文字ずつ UI に流していけますが、tool_use の partial_json はそうはいかない。「同じイベント型に見えても、delta.type で挙動を切り替える」ことを忘れると静かに壊れます。
具体的に止まるのはおおむね次の 3 ケースでした。
- 旅行系のチャットで日付範囲(
"2026-06-01"〜"2026-06-07")を 1 ブロックで受け取ったとき、"2026-06-で 1 通切られて落ちる - 並列ツール呼び出し(
disable_parallel_tool_use: false)でindex: 0とindex: 1が交互に届き、バッファを 1 本にしてしまって構文崩壊 - 連続会話の 2 ターン目で過去ターンの
tool_use.inputを Cookie に保存していて、ストリーミング途中の中途半端な値がそのまま記録される
修正: index ごとにバッファを持って、stop で初めて parse する
最小限の正しい実装はこんな形になります。Node.js + 公式 @anthropic-ai/sdk v0.39 系を想定していますが、ロジックは生 SSE を扱う場合でも同じです。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
type ToolBuffer = {
id: string;
name: string;
json: string; // partial_json を index 順に連結する
};
async function callWithStreamingTools() {
const stream = client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools: [/* ... */],
messages: [{ role: "user", content: "東京の天気と明日のニュースを教えてください" }],
});
const buffers = new Map<number, ToolBuffer>();
const completedToolUses: Array<{ id: string; name: string; input: unknown }> = [];
for await (const event of stream) {
if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
buffers.set(event.index, {
id: event.content_block.id,
name: event.content_block.name,
json: "",
});
} else if (event.type === "content_block_delta" && event.delta.type === "input_json_delta") {
const buf = buffers.get(event.index);
if (buf) buf.json += event.delta.partial_json;
} else if (event.type === "content_block_stop") {
const buf = buffers.get(event.index);
if (buf) {
// ここで初めて parse する。途中ではしない
const input = buf.json.length === 0 ? {} : JSON.parse(buf.json);
completedToolUses.push({ id: buf.id, name: buf.name, input });
buffers.delete(event.index);
}
}
}
return completedToolUses;
}ポイントは 3 つあります。
content_block_startでtool_useを見つけた時だけ index にバッファを確保する。テキストブロックと混ぜないpartial_jsonは+=で連結し、その場でパースしないcontent_block_stopで初めてJSON.parseする。空文字列のときは{}にフォールバックする(引数を取らないツールが空 input で来るケース対策)
実体験として、ここを直しただけで運用中の AI 自動応答パイプラインで週 1 件ほど出ていた 500 系エラーが消えました。
連結途中で接続が切れたときの取りこぼし対策
ストリーミングはネットワーク中断との戦いでもあります。私が公開している iOS 用壁紙アプリ(累計5,000万DL規模で運用していた経験から AdMob 周りの収益計上を Claude API で要約させる仕組みを作っていた頃)でも、機内モード復帰の瞬間に SSE が途切れ、content_block_stop を受け取らないまま終了するケースがありました。
中断検知には 2 つのシグナルを併用します。
try {
for await (const event of stream) { /* 上のロジック */ }
} catch (err) {
// 接続切れ。バッファに残っている tool_use は未完成として破棄
for (const [index, buf] of buffers) {
console.warn(`incomplete tool_use index=${index} name=${buf.name} bytes=${buf.json.length}`);
}
throw err;
}
// 正常終了でも final response の stop_reason を必ず確認
const final = await stream.finalMessage();
if (final.stop_reason !== "tool_use" && final.stop_reason !== "end_turn") {
// max_tokens で途中切断された可能性。retry もしくは継続を検討
}未完成バッファをそのままアプリ層に渡すと、後段のスキーマ検証が「location フィールドが必須なのに無い」のような分かりにくい例外を出します。「途中で切れた tool_use はアプリ層に渡さない」を徹底するだけで、障害解析の時間がだいぶ短くなりました。
ライブラリで吸収するか、生 SSE で書くか
公式 SDK は messages.stream(...) を使うと on("inputJson", (delta, snapshot) => ...) のように完全文字列のスナップショットを毎回渡してくれるイベントが用意されています。アプリ側で「ストリーミング中にツール引数の途中経過を UI に出したい」という要件が無いなら、SDK のイベントに任せて自前で連結しないほうが安全です。
一方で、Cloudflare Workers のように fetch の ReadableStream を生で扱う環境では、自分でイベントパースを書くケースもあります。その場合は次の点に注意します。
- SSE のフレーム区切り(
\n\n)でバッファをスライスする - 1 フレーム内に複数イベントが来る場合がある(小さい delta が連発する)
data:プレフィックスを取り除いてからJSON.parseするpartial_jsonの中身に改行が含まれることがある(JSON エスケープ済みなので連結時に何もしなくてよい)
Cloudflare Workers + Anthropic API の組み合わせで動かしたとき、私は手書きの SSE パーサーを書く前に SDK の BetaMessageStream を試して、要件が満たせるなら基本そちらを採用しています。Worker のバンドルサイズには影響しますが、本番のデバッグ容易性とトレードした方が安く付くという判断です。
デバッグ手順: どこで壊れているか機械的に切り分ける
実装が動かないときの私の切り分け順序は次のとおりです。
// 1) 生イベントをそのままダンプ
for await (const event of stream) {
console.log(JSON.stringify(event));
}ログを見て確認するのは以下の項目です。
content_block_startのcontent_block.typeがtool_useになっているか(textの混在で連結先を間違えていないか)partial_jsonを文字列連結した結果が、最終的に有効な JSON になるか(cat events.jsonl | jq -s '...'で確認)content_block_stopのあとにmessage_delta.stop_reasonがtool_useで来ているか- 1 メッセージで複数の
tool_useが来ているなら、index が連番(0, 1, 2 ...)になっているか
ここまで揃っていれば、あとはバッファのキー(index)の取り扱いミスを潰すだけで動きます。実装を書き直す前に、まず生イベントの形を信じることがいちばんの近道でした。
関連する落とし穴: disable_parallel_tool_use と部分継続
最後に、ストリーミング × tool_use 周りでもう 1 つ気をつけているのが並列実行の扱いです。Sonnet 4.6 以降のモデルは標準で並列ツール呼び出しが有効で、1 メッセージで複数の tool_use ブロックが返ってきます。これを直列に処理したいときは disable_parallel_tool_use: true を指定しますが、ストリーミング側のコードを変えずに切り替えると、バッファ管理は壊れません(index が 0 だけになるだけ)。
逆に並列を有効にしたまま「1 つ目の結果が出るまで 2 つ目の引数を捨てる」最適化を入れると、content_block_stop のタイミングで未来のブロックを取りこぼします。並列にする場合は すべての tool_use を受け取りきってから tool_result を返す のがいちばん安全でした。
実装中の API の挙動は変わることがあるので、本番に出す前に最新の SDK でレスポンスをダンプし直す癖をつけておくと事故が減ります。今回の話が同じところで悩んでいる方の助けになれば幸いです。