各リクエストに対して Auto、TokenLab Verified、または Official を選択でき、価格は事前に表示されます。 新機能を見る

出力の重複を避けながらストリーミングLLMレスポンスをリトライする方法

CryptoCrypto
·2026年9月28日·約 5 分で読了·更新日 2026年9月28日·32 回表示
#ストリーミング#レスポンスAPI#信頼性#WebSocket
出力の重複を避けながらストリーミングLLMレスポンスをリトライする方法

ストリーミングリクエストを安全にリプレイできるのは、以下の3つの条件が同時に満たされている場合のみです。「クライアントに何も到達していないこと」「観測可能な課金が発生していないこと」、そして「リクエストがサーバーサイドの状態を持たないこと」。最初の出力イベントが発生した後は、リプレイするのではなく失敗として報告するのが正しい対応です。

TokenLabは、HTTPおよびWebSocketの両方において、Responses APIストリーミングのゲートウェイでこのルールを適用しています。WebSocketのパスは、HTTPと合わせるために2026年9月28日に変更されました。

なぜストリームは通常のリクエストと異なるのか

非ストリーミング呼び出しは、ボディまたはエラーを返します。何も受け取っていないため、エラーをリトライすることができます。

ストリームは、リクエストが完了する前に出力を提供します。最初の出力イベントが「後戻りできない地点」です。それ以降に接続が切断された場合、手元には部分的なテキストが残ります。リクエストをリプレイすると、同じ回答を再度生成することになり、二重に料金が発生します。また、エージェントがすでに実行したツール呼び出しを重複させてしまう可能性もあります。

TokenLabストリーミングガイドには、直接的にこう記されています:

最初のイベントが到着した後、中断されたストリームは不完全なものであり、自動的に再開されることはありません。

そのため、クライアント側でsaw_outputというローカル状態を1ビット保持する必要があります。これは、何らかの出力がコードに到達した瞬間にtrueになります。リトライの判断はすべて、まずこのビットを確認してから行います。

response.completedなしで終了するストリームは失敗です。手元にあるテキストが完全であると想定してはいけません。response.failed、response.incomplete、およびerrorイベントを適切に処理してください。

リプレイ判断のポイント

TokenLabは、以下の条件がすべて揃っている場合に限り、別の利用可能なルートでリクエストを一度だけリプレイします。リクエストがステートレスであること。クライアントに何も到達していないこと。失敗した試行に対して結果や使用量が観測されていないこと。そして、失敗が「リトライ可能な出力前のイベント」または「最初のイベント発生前のアップストリーム読み取りエラー」であること。リプレイはリクエストごとに最大1回です。代替リクエストが出力前に失敗した場合、その失敗は再リプレイされません。

出典: TokenLabストリーミングガイドおよびゲートウェイの動作(2026年9月28日観測)

失敗のポイント TokenLabによるリプレイ 理由
リトライ可能な出力前イベント(response.failed、または過負荷や内部的なアップストリームエラーなど、リトライ可能とマークされたerrorイベント) はい(1回のみ、リクエストがステートレスの場合) クライアントに何も到達しておらず使用量も観測されていないため、2回目の実行は不可視となります。
最初のイベント前のアップストリームストリーム切断(読み取りエラー) はい(1回のみ、リクエストがステートレスの場合) 同上。クライアントは出力も課金も保持していません。
1回のリプレイ後の、出力前の2回目の失敗 いいえ リプレイの予算はリクエストごとに1回です。
クライアントに出力が到達した後のあらゆる失敗 いいえ クライアントはすでに部分的なテキストを保持しています。リプレイすると出力とコストが重複します。
保存されたレスポンス(store)、継続(previous_response_id)、または起点固定のリクエスト いいえ 2回目の実行により、2つ目の保存済みレスポンスが作成されたり、会話状態が分岐したりする可能性があるためです。
最初のイベントのタイムアウト いいえ アップストリームがまだ生成中の可能性があるためです。リプレイすると、最初の試行が継続している間に同じ作業を2回実行してしまう可能性があります。
出力前のバッファオーバーフロー いいえ 制限はゲートウェイのローカルなものです。同じ過大なプレフィックスは、次のルートでも同様に制限に達する可能性が高いためです。
クライアントの切断 いいえ クライアントが受信を停止したためです。
決定論的な失敗(例:不正なリクエスト) いいえ リトライしても結果は変わりません。そのまま配信されます。
すでに使用量が記録された失敗 いいえ 試行が課金対象となったためです。そのまま配信されます。
他のルートが残っていない場合 いいえ 送信先がないためです。クライアントは独自のコードで失敗を受け取ります。

失敗がリプレイされない場合、または他のルートが残っていない場合は、独自のエラーコードとともに失敗を受け取ります。公開されている例として、アップストリームのストリームが切断された場合のstream_read_errorや、バッファオーバーフロー時のupstream_stream_buffer_limitがあります。リプレイ判断後にルート選択自体が失敗した場合、WebSocketのターンはwebsocket_response_failed(ステータス500)で終了し、予約されていた料金は返金されます。

課金も同様のルールに従います。配信された試行に対してのみ支払います。リプレイされたリクエストはアップストリームで2回実行された可能性がありますが、最初の試行からは何も到達していないため、その追加のアップストリームコストはTokenLabの負担となります。何も配信されなかった失敗したターンは返金されます。

エラーハンドリングにおいて、タイミングに関する詳細が1点重要です。出力が開始される前、ゲートウェイは最初の出力イベントまたは失敗が到着するまで(最大10秒間)、response.createdとresponse.in_progressを保持します。これらの保持されたイベントは、最初の出力または終了イベントと一緒にクライアントに到達します。順序と内容は変わりません。単に少し遅れて表示されるだけです。この10秒は最大値であり、典型的な遅延ではありません。

2026年9月28日のWebSocketの変更点

TokenLabは、HTTPストリーミング("stream": true、サーバー送信イベント)およびwss://api.tokenlab.sh/v1/responsesでのWebSocket経由でResponses APIを提供しており、クライアントはここでresponse.createイベントを送信します。WebSocketのレスポンスは常にストリーミングされます。これらはbackgroundやresponse.cancelをサポートしていません。各接続は、最大60分間、一度に1つのアクティブなレスポンスを処理します。

変更前は、これら2つのパスで動作が異なっていました。HTTPはライフサイクルイベントを保持し、ステートレスな出力前の失敗をリプレイしていましたが、WebSocketはresponse.createdを即座に転送し、出力前の失敗をクライアントに配信して返金していました。同じアップストリームの不具合が、HTTPではクリーンな回答を生成し、WebSocketではエラーを生成していました。

WebSocketパスは現在、HTTPのルールに従っており、イベントが到着する前に切断されたストリームのリプレイも含まれます。内部的には、WebSocketのターンで見られるアップストリームの失敗のほとんどは、出力が発生する前に発生していました。これこそが、リプレイが安全なウィンドウです。

ゲートウェイは出力前の失敗ケースを改善しましたが、ストリームが必ず完了することを保証するものではありません。

他の動作を壊さずに変更をリリースした方法

この作業は、意図しない動作の変化を検知するために構築されたプロセスに従いました。

  • 動作のロック。変更前、すべてのWebSocketターンのシナリオがフィクスチャとして記録されました。クライアントが受信するフレーム、行われたアップストリーム呼び出し、および課金結果です。この作業中にスイートは63の記録済みシナリオにまで成長しました。動作の変更は事前に宣言する必要があり、その宣言で指定されたフィクスチャのみが変更可能です。それ以外のすべてのフィクスチャはバイト単位で同一である必要があります。
  • 変異チェック。各新しい決定ルールは、最初のイベントのタイムアウトをリプレイする、あるいは読み取りエラーをリプレイしないなど、意図的にルールを反転させてロックが失敗することを確認することでテストされました。
  • レビューによる修正。最初のバージョンでは、HTTPとの整合性を理由にバッファオーバーフローケースもリプレイ可能にしていました。しかし、レビューの結果、HTTPでは表にある理由からそのケースをリプレイしないことが判明しました。フォローアップで以前の動作を復元し、境界シナリオ(2回目の読み取りエラーはリプレイしない、ルートが残っていない、保持されたresponse.created後の失敗、および中断した代替ストリーム)を追加しました。

リプレイ後に成功したターンのリクエストログには、HTTPですでにそうであったように、以前の失敗した試行も記録されるようになりました。

リトライ判断を担うクライアントコード

ストリーミング呼び出しのSDK自動リトライは0に設定してください。これにより、判断をコード内に保持できます。リプレイの判断はハンドラー全体に分散させず、一箇所にまとめてください。HTTPエラーについては、エラーハンドリングガイドで説明されているretryableおよびretry_afterを尊重し、リクエストIDを保持してください。

HTTP上のSSE

import os

from openai import OpenAI

with OpenAI(
    api_key=os.environ["TOKENLAB_API_KEY"],
    base_url="https://api.tokenlab.sh/v1",
    timeout=30.0,
    max_retries=0,  # 半分読み取ったストリームを再送するのではなく、リトライ判断を自分で行う
) as client:
    completed, saw_output = False, False
    with client.responses.create(
        model="gpt-5.6-terra",
        input="Reply with one short sentence about retries.",
        stream=True,
    ) as stream:
        for event in stream:
            if event.type == "response.output_text.delta":
                saw_output = True
                print(event.delta, end="", flush=True)
            elif event.type == "response.completed":
                completed = True
            elif event.type in {"response.failed", "response.incomplete", "error"}:
                raise RuntimeError(f"{event.type} after_output={saw_output}")
    if not completed:
        raise RuntimeError(f"stream closed before response.completed, after_output={saw_output}")
    print()

この例では、OpenAI SDK 2.15.0を使用し、max_retries=0でhttps://api.tokenlab.sh/v1に対してリクエストを行っています。saw_outputを追跡し、response.failed、response.incomplete、errorイベント、およびresponse.completedの前にストリームが閉じた場合に例外を発生させます。2026年9月28日にgpt-5.6-terraを使用して本番環境で検証済みです。

失敗がsaw_output == falseで到着し、リクエストが適格(ステートレスでリプレイ可能な失敗)であった場合、TokenLabはすでに一度リプレイを行っています。保存されたレスポンス、継続、最初のイベントのタイムアウトはリプレイされていません。新鮮なリクエストは新しい生成となるため、アプリレベルで許容できるかどうかを判断してください。saw_output == trueの場合は、失敗を報告して手元にあるものを表示するか、部分的なテキストを意図的に破棄してください。

WebSocket

import asyncio
import json
import os

import websockets

URL = "wss://api.tokenlab.sh/v1/responses"
TERMINAL = {"response.completed", "response.failed", "response.incomplete", "error"}


async def run_turn(prompt: str) -> str:
    headers = {"Authorization": f"Bearer {os.environ['TOKENLAB_API_KEY']}"}
    async with websockets.connect(URL, additional_headers=headers, max_size=None) as ws:
        await ws.send(json.dumps({
            "type": "response.create",
            "model": "gpt-5.6-terra",
            "input": prompt,
            "store": False,
        }))
        text, saw_output = [], False
        async for raw in ws:
            event = json.loads(raw)
            kind = event.get("type")
            if kind == "response.output_text.delta":
                saw_output = True
                text.append(event["delta"])
            elif kind in TERMINAL:
                if kind != "response.completed":
                    # 出力が開始された後の失敗は、このターンにとって最終的なものです。
                    # アプリが部分的なテキストを破棄できる場合のみ再送してください。
                    raise RuntimeError(f"{kind} after_output={saw_output}: {json.dumps(event)[:300]}")
                return "".join(text)
        raise RuntimeError(f"socket closed before a terminal event, after_output={saw_output}")


print(asyncio.run(run_turn("Reply with one short sentence about retries.")))

この例ではwebsockets 16.0を使用し、Bearerヘッダー付きでwss://api.tokenlab.sh/v1/responsesに接続し、store: falseでresponse.createを送信してresponse.output_text.deltaを収集します。完了以外の終了イベントや早期終了が発生した場合はafter_outputとともに例外を発生させます。2026年9月28日にgpt-5.6-terraを使用して本番環境で検証済みです。

after_outputフラグはsaw_outputと同じ考え方です。副作用を重複させることなく新しいターンが可能かどうかを呼び出し元のコードに伝えます。

独自のリトライロジックのためのチェックリスト

  • response.completedなしで終了するストリームは、毎回失敗として扱ってください。
  • 出力がコードに到達したかどうかを追跡するブール値を1つ保持してください。最初のライフサイクルイベントではなく、最初の出力イベントで反転させます。
  • 適格なリクエストに対する出力前の失敗は、すでにゲートウェイで1回リプレイされています。それ以上の試行はあなたの判断です。
  • 部分的な出力の後、再送するのはアプリが部分的なテキストを破棄でき、2回分の生成コストを受け入れられる場合のみにしてください。
  • エージェントループでは、部分的なストリームにすでにコードが実行したツール呼び出しが含まれていないか確認してください。副作用を取り消せないターンはリプレイしないでください。
  • 保存されたレスポンスやprevious_response_idの継続については、再送する前にどのような状態が存在するかを確認してください。
  • SDKのストリーミングリトライを0に設定し、リプレイの判断を一箇所にまとめてください。
  • リクエストIDをログに記録し、配信された回答とそれ背後の試行を照合できるようにしてください。

FAQ

TokenLabは部分的な出力の後にストリームを再開しますか?

いいえ。出力がクライアントに到達した後は、失敗が報告され、リプレイされることはありません。部分的なテキストを保持しているため、再開すると出力とコストが重複してしまいます。手元にあるものを表示するか、切り捨てるか、破棄するかはアプリが決定します。

ゲートウェイがリクエストをリプレイした場合、二重に課金されますか?

いいえ。配信された試行に対してのみ支払います。リプレイされたリクエストはアップストリームで2回実行された可能性がありますが、最初の試行からは何も到達しておらず、その追加のアップストリームコストはTokenLabの負担となります。何も配信されなかった失敗したターンは返金されます。

なぜ最初のイベントのタイムアウトはリトライされないのですか?

アップストリームがまだ生成中の可能性があるためです。リプレイすると、最初の試行が継続している間に同じ作業を2回実行してしまう可能性があります。最初のイベントのタイムアウトは、最初のイベントの前にストリームが切断される読み取りエラーとは別物として扱われます。

保存されたレスポンスやprevious_response_idの継続をリトライできますか?

自動的にはできません。TokenLabは、保存されたレスポンス、継続、または起点固定のリクエストをリプレイしません。2回目の実行により、2つ目の保存済みレスポンスが作成されたり、会話状態が分岐したりする可能性があるためです。再送する前に状態を確認し、アプリがその状態を調整できる場合にのみ再送してください。

生のイベントストリームを自分で確認したい場合は、APIキーを作成し、クライアントが受信するすべてのイベントタイプをログに記録してください。ストリーミングガイドとエラーハンドリングガイドですべてのイベントセットを網羅しています。ゲートウェイのルーティングと回復の詳細については、TokenLab AI API reliability infrastructureおよびResponses API vs Chat Completions for agentsを参照してください。

出典

関連モデル

最近公開されたモデル

この記事のモデルで構築を開始

価格を比較し、ルートを試し、調査内容を実際の API 呼び出しへ進めます。