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ケース

再現の手順

  1. 実行コードを保存する(.mjs)
  2. Node.js 20以上の環境で、保存したフォルダから次のコマンドを実行します。追加パッケージやAPIキーは不要です。
  3. 出力の各ケースを読み、入力を一つ変えて判断の違いを確かめてください。
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

5実験の実行記録を保存する(JSON)

この検証で分かる範囲

この例はtext_deltaの一種類だけを扱う学習用状態判定です。公式SDKの代わりではなく、ツール呼出しや全イベントの処理、実回線での切断、APIエラー網羅を保証しません。

確認課題と合格の目安

正常終了、出力上限、利用者の中止、切断の四ケースについて画面と保存状態を決めてください。

  • 文章の見た目で完了を判定しない。
  • 途中の構造化データを実行へ渡さない。
  • 画面と保存先の状態が矛盾しない。