Claude APIの設計と運用
Claudeのストリーミング応答:表示開始と処理完了を区別する
文字が早く現れることと、答えが完成していることは別です。ストリーミングでは途中の内容を画面へ見せられる一方、最後まで届く前に通信が切れることもあります。表示の体感とデータの確定を分けて設計します。
編集・運営:株式会社ノースネスト · AI支援の学習解説
公開: · 更新: · 約3分(目安)
この記事の目的
最初の表示、生成終了、業務としての完了を異なる状態として扱える。
こんな方へ
Claude APIの応答をリアルタイム表示したい開発者。
完了を示す状態を明確にする
受信中、正常終了、制限による停止、通信切断などを区別します。途中の文が文法上自然に終わっていても、完了の通知を確認せずに確定しないようにします。停止理由が出力上限なら、内容が途中である可能性も考慮します。
APIのイベント名や順序は公式仕様に沿って扱い、未知のイベントを受けた際の挙動も決めます。文字列の末尾や句点を見て終了を推測する設計は、文章の内容に処理制御を依存させてしまいます。
部分出力を業務へ渡さない
生成中のJSONや引数は完成前の断片です。途中でそれらしく見えても、処理実行に使わず、全体が揃ってから検証します。画面で途中の内容を見せる場合も、「生成中」と「確定済み」が区別できる表示にします。
テキスト表示の順序と、データ保存の順序も合わせます。画面には失敗と表示したのに保存先には確定回答が残る、といった不整合を防ぐ必要があります。
キャンセルと切断を別に考える
利用者が中止した場合と、ネットワークが切れた場合では再開の扱いが異なります。どちらも既に一部の処理や課金が発生している可能性があるため、画面を閉じたことを処理の完全な取消と同一視しません。外部操作を伴う場合は、その結果も別に追跡します。
途中の表示と確定データの二層を持つ
画面で表示した内容を、そのまま確定した回答へ上書きしていく方式では、途中で切れた文章が正式な履歴として残ることがあります。受信中の表示用領域と、検証後に確定する記録を分けると、完了前に何が見えていたかを扱いやすくなります。停止理由や受信の終端を確認する前に、利用者へ業務上の成功を示さないようにします。
復旧の試験では、最初の文字が来る前、構造化出力の途中、最後の通知の直前という複数の位置で切断を考えます。どの位置でも同じ「再試行」で済むとは限りません。ツールが実行済みなら操作の状態確認が必要です。体感上の速さを改善する仕組みと、処理の一貫性を守る仕組みを独立して設計してください。
再現例で判断の境界を確認する
文字列を17文字ずつに分けて受信し、正常終了、出力上限、終端イベント欠落を比較します。出力がJSONとして読める場合でも、処理完了とは限りません。
この例はtext_deltaの一種類だけを扱う学習用状態判定です。公式SDKの代わりではなく、ツール呼出しや全イベントの処理、実回線での切断、APIエラー網羅を保証しません。
具体例で考える
説明用の抽出結果が「{"total":」まで届いた時点で通信が切れました。
画面:生成が中断しました。
保存:未完了として記録。
処理:合計値の登録は実行しない。
再試行:同じ業務要求として識別する。断片を補って0として扱うと、通信障害が実際の金額へ変換されてしまいます。受信済みの部分は診断に使えても、確定した業務データとは分けます。
ローカルで再現する:分割された受信と完了の扱い
文字列を17文字ずつに分けて受信し、正常終了、出力上限、終端イベント欠落を比較します。出力がJSONとして読める場合でも、処理完了とは限りません。
以下は公開コードを実行して得た結果です。入力は学習用に作った架空データで、Claude API・外部サービスへの通信は行っていません。モデルの回答品質や実サービスの性能を測定した結果ではありません。
実行:
Node.js v24.22.1 · darwin/arm64 · 3ケース
再現の手順
- 実行コードを保存する(.mjs)
- Node.js 20以上の環境で、保存したフォルダから次のコマンドを実行します。追加パッケージやAPIキーは不要です。
- 出力の各ケースを読み、入力を一つ変えて判断の違いを確かめてください。
node claude-api-streaming.mjs実際の入力と出力
{
"cases": [
{
"name": "正常終了",
"input": "17文字ずつに分割した架空SSE / end_turn + message_stop",
"actual": {
"state": "complete",
"text": "{\"total\":10}",
"stopReason": "end_turn",
"mayCommit": true
},
"expectedCommit": true,
"pass": true
},
{
"name": "出力上限",
"input": "読めるJSONでも max_tokens",
"actual": {
"state": "incomplete",
"text": "{\"total\":10}",
"stopReason": "max_tokens",
"mayCommit": false
},
"expectedCommit": false,
"pass": true
},
{
"name": "途中切断",
"input": "end_turnはあるがmessage_stopが欠落",
"actual": {
"state": "incomplete",
"text": "{\"total\":10}",
"stopReason": "end_turn",
"mayCommit": false
},
"expectedCommit": false,
"pass": true
}
]
}実行したコードを読む
// Learnify local experiment. Synthetic SSE only; no API or network calls.
export function inspectStream(chunks) {
let buffer='',text='',reason=null,stopped=false,invalid=false;
for(const chunk of chunks) {
buffer+=chunk;
let boundary;
while((boundary=buffer.indexOf('\n\n'))>=0) {
const frame=buffer.slice(0,boundary);buffer=buffer.slice(boundary+2);
const data=frame.split('\n').filter(l=>l.startsWith('data:')).map(l=>l.slice(5).trimStart()).join('\n');
if(!data)continue;
let event;try{event=JSON.parse(data);}catch{invalid=true;continue;}
if(event.type==='error'){invalid=true;continue;}
if(event.type==='content_block_delta'&&event.delta?.type==='text_delta')text+=event.delta.text;
if(event.type==='message_delta')reason=event.delta?.stop_reason??reason;
if(event.type==='message_stop')stopped=true;
}
}
const complete=stopped&&reason==='end_turn'&&!invalid&&!buffer.trim();
return {state:complete?'complete':'incomplete',text,stopReason:reason,mayCommit:complete};
}
function wire(reason,stop=true) {
const events=[{type:'message_start'},{type:'content_block_delta',delta:{type:'text_delta',text:'{"total":10}'}},{type:'message_delta',delta:{stop_reason:reason}},...(stop?[{type:'message_stop'}]:[])];
const body=events.map(e=>`data: ${JSON.stringify(e)}\n\n`).join('');
return body.match(/[\s\S]{1,17}/g);
}
export function run() {
return {cases:[
{name:'正常終了',input:'17文字ずつに分割した架空SSE / end_turn + message_stop',actual:inspectStream(wire('end_turn')),expectedCommit:true},
{name:'出力上限',input:'読めるJSONでも max_tokens',actual:inspectStream(wire('max_tokens')),expectedCommit:false},
{name:'途中切断',input:'end_turnはあるがmessage_stopが欠落',actual:inspectStream(wire('end_turn',false)),expectedCommit:false},
].map(c=>({...c,pass:c.actual.mayCommit===c.expectedCommit}))};
}
if(process.argv[1]&&import.meta.url===new URL('file://'+process.argv[1]).href)console.log(JSON.stringify(run(),null,2));
コードのSHA-256:037bf160c88d2d9d8cc830857c93c3d3589414a3786b39282b144e4aa3700119
この検証で分かる範囲
この例はtext_deltaの一種類だけを扱う学習用状態判定です。公式SDKの代わりではなく、ツール呼出しや全イベントの処理、実回線での切断、APIエラー網羅を保証しません。
確認課題と合格の目安
正常終了、出力上限、利用者の中止、切断の四ケースについて画面と保存状態を決めてください。
- 文章の見た目で完了を判定しない。
- 途中の構造化データを実行へ渡さない。
- 画面と保存先の状態が矛盾しない。