OpenAI APIのストリーミングが途中で切れる原因|Node.js・Next.jsのタイムアウト対策

OpenAI APIのストリーミングが途中で切れる原因|Node.js・Next.jsのタイムアウト対策 AI開発

OpenAI APIで長い回答をストリーミングすると、文章の途中で表示が止まったり、ブラウザにTypeError: terminatedAbortErrorが表示されたりすることがあります。

この問題は、OpenAI APIだけが原因とは限りません。

OpenAI SDK、Node.jsサーバー、Next.jsのRoute Handler、Vercelなどの実行環境、NginxやCDN、ブラウザのいずれかが通信を切断すると、画面上では同じように「回答が途中で止まった」ように見えます。

OpenAIのResponses APIは、stream: trueを指定するとServer-Sent Events、通称SSEを使って生成途中のイベントを返します。テキストはresponse.output_text.deltaとして届き、正常終了時にはresponse.completedが送信されます。エラー時にはerrorresponse.failed、出力上限などで完了できなかった場合にはresponse.incompleteが返されます。

この記事では、OpenAI APIのストリーミングが途中で切れる主な原因と、Node.js・Next.jsで安全に中継する実装方法を解説します。基本的なストリーミングの受信方法から確認したい場合は、【TypeScript】OpenAI API入門を先にご覧ください。エラー全般の切り分けについてはOpenAI APIの429エラーを直す方法もあわせて参考にしてください。

スポンサーリンク

OpenAI APIのストリーミングが途中で止まる主な原因

ストリームが途中で止まる原因は、大きく分けると通信のタイムアウト、クライアントからのキャンセル、実行環境の制限、プロキシのバッファリング、OpenAI側の生成失敗です。

特に注意したいのは、同じ「途中で止まった」という症状でも、解決方法が異なることです。

たとえば、Vercelの実行時間制限で終了している場合、OpenAI SDKのtimeoutを延ばしても直りません。反対に、OpenAI SDKが30秒でタイムアウトする設定になっている場合、VercelのmaxDurationを300秒にしても30秒で終了します。

ブラウザ、Next.js、OpenAI SDK、ホスティング環境のタイムアウトは、それぞれ独立していると考える必要があります。

OpenAI SDKのtimeoutが短すぎる

OpenAIの公式JavaScript・TypeScript SDKには、リクエスト単位のタイムアウトが設定されています。

現在の公式SDKでは、デフォルトのタイムアウトは10分です。timeoutオプションを指定している場合は、その時間を超えるとAPIConnectionTimeoutErrorが発生します。また、タイムアウトしたリクエストは、標準設定では自動リトライの対象になります。

次のように20秒を設定していると、モデルが長い回答を生成している途中でも通信が終了する可能性があります。

短すぎるtimeoutの例
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  timeout: 20_000,
});

長文生成やツール実行を含む処理では、現実的な時間へ変更します。

適切はtimeoutの例
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  timeout: 120_000,
  maxRetries: 1,
});

この例では、OpenAI APIとの通信に120秒の上限を設定しています。

タイムアウトを無制限に延ばすのではなく、通常の応答時間より少し長い値にすることが重要です。異常なリクエストがいつまでもサーバー上に残ると、同時接続数や実行料金にも影響します。

max_output_tokensによる終了を通信切断と誤認している

文章が毎回ほぼ同じ長さで途切れる場合は、ネットワークではなく出力トークン数の上限に達している可能性があります。

Responses APIでは、正常に最後まで生成されたときにresponse.completedが返されます。一方、出力上限などによって完全な回答を生成できなかった場合はresponse.incompleteが返され、incomplete_detailsから理由を確認できます。

次のようにmax_output_tokensを小さく設定すると、長い回答は途中で終了します。

max_output_tokensの例
const stream = await openai.responses.create({
  model: process.env.OPENAI_MODEL!,
  input: "Next.jsの認証機能について詳しく説明してください。",
  max_output_tokens: 300,
  stream: true,
});

この終了はタイムアウトではありません。

単純に値を増やす方法もありますが、回答を複数の章へ分割する、要点だけを生成する、続きの生成機能を用意するといった設計も検討します。

response.completedを確認していない

for awaitのループが終了したことだけを見て、生成が正常に完了したと判断する実装も問題になります。

OpenAIのストリーミングでは、テキスト以外にも複数のイベントが流れます。テキストを受け取る場合に重要なイベントは、response.createdresponse.output_text.deltaresponse.completederrorです。

途中でネットワーク接続が失われた場合、response.completedを受信しないまま読み取り処理が終了する可能性があります。

そのため、正常終了を示すイベントを受信したかどうかを記録します。

完了イベントを確認する例
let completed = false;

for await (const event of stream) {
  if (event.type === "response.output_text.delta") {
    process.stdout.write(event.delta);
  }

  if (event.type === "response.completed") {
    completed = true;
  }

  if (event.type === "response.failed") {
    throw new Error(
      event.response.error?.message ?? "OpenAIの生成に失敗しました。",
    );
  }

  if (event.type === "response.incomplete") {
    const reason =
      event.response.incomplete_details?.reason ?? "unknown";

    throw new Error(`回答が未完了です: ${reason}`);
  }

  if (event.type === "error") {
    throw new Error(event.message);
  }
}

if (!completed) {
  throw new Error(
    "response.completedを受信する前に接続が終了しました。",
  );
}

この確認を入れることで、正常終了、出力上限、OpenAI側の失敗、ネットワーク切断を区別できます。

ブラウザのAbortControllerが通信を止めている

ブラウザ側でAbortControllerを使用している場合、abort()が呼ばれると、レスポンス本文の読み取りを含む通信が中断されます。

ユーザーが停止ボタンを押した場合だけでなく、Reactコンポーネントのクリーンアップ処理や、画面遷移、二重送信を防ぐ処理から意図せずキャンセルされることがあります。

AbortControllerの例
const controller = new AbortController();

const response = await fetch("/api/chat", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    prompt: "OpenAI APIについて説明してください。",
  }),
  signal: controller.signal,
});

// この処理が実行されるとストリームも終了する
controller.abort();

ReactのuseEffect内でストリームを開始している場合は、依存配列の変更によってクリーンアップが何度も実行されていないか確認します。

キャンセルをエラーとして表示したくない場合は、signal.abortedを確認して処理を分けます。

キャンセルとエラーを区別する例
try {
  await startStreaming(controller.signal);
} catch (error) {
  if (controller.signal.aborted) {
    console.log("ユーザー操作により生成を停止しました。");
    return;
  }

  throw error;
}

Next.jsのRoute Handlerでストリームを中継する方法

Next.jsのApp Routerでは、Route HandlerがWeb標準のRequestResponseを使用します。ReadableStreamをレスポンス本文として返すことで、生成結果をブラウザへ段階的に送信できます。

次のコードは、OpenAIのResponses APIから届いたイベントを、ブラウザ向けのSSEへ変換する実装です。

app/api/chat/route.tsを作成します。

app/api/chat/route.ts
import OpenAI from "openai";

export const runtime = "nodejs";

// 実際に利用できる最大値はデプロイ先のプランによって異なる
export const maxDuration = 300;

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  timeout: 120_000,
  maxRetries: 1,
});

const encoder = new TextEncoder();

type BrowserEvent =
  | {
      type: "delta";
      text: string;
    }
  | {
      type: "done";
    }
  | {
      type: "error";
      message: string;
    };

function encodeSse(event: BrowserEvent): Uint8Array {
  return encoder.encode(
    `data: ${JSON.stringify(event)}\n\n`,
  );
}

function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }

  return "ストリーミング中に不明なエラーが発生しました。";
}

export async function POST(
  request: Request,
): Promise<Response> {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return Response.json(
      {
        error: "リクエストのJSONが正しくありません。",
      },
      {
        status: 400,
      },
    );
  }

  const prompt =
    typeof body === "object" &&
    body !== null &&
    "prompt" in body &&
    typeof body.prompt === "string"
      ? body.prompt.trim()
      : "";

  if (!prompt) {
    return Response.json(
      {
        error: "promptを入力してください。",
      },
      {
        status: 400,
      },
    );
  }

  const model = process.env.OPENAI_MODEL;

  if (!model) {
    return Response.json(
      {
        error: "OPENAI_MODELが設定されていません。",
      },
      {
        status: 500,
      },
    );
  }

  const upstreamController = new AbortController();

  const abortUpstream = (): void => {
    if (!upstreamController.signal.aborted) {
      upstreamController.abort(
        request.signal.reason ??
          new Error("ブラウザとの接続が終了しました。"),
      );
    }
  };

  if (request.signal.aborted) {
    abortUpstream();
  } else {
    request.signal.addEventListener(
      "abort",
      abortUpstream,
      {
        once: true,
      },
    );
  }

  const responseBody = new ReadableStream<Uint8Array>({
    async start(controller) {
      let completed = false;

      const safeEnqueue = (chunk: Uint8Array): void => {
        try {
          controller.enqueue(chunk);
        } catch {
          abortUpstream();
        }
      };

      const safeClose = (): void => {
        try {
          controller.close();
        } catch {
          // 接続切断後にcloseしても処理を継続しない
        }
      };

      // 最初のデータをすぐ返し、プロキシの待機を避ける
      safeEnqueue(
        encoder.encode(": connected\n\n"),
      );

      // SSEコメントを定期的に送り、アイドル切断を防ぐ
      const heartbeat = setInterval(() => {
        safeEnqueue(
          encoder.encode(`: ping ${Date.now()}\n\n`),
        );
      }, 15_000);

      try {
        const openAIStream =
          await openai.responses.create(
            {
              model,
              input: prompt,
              stream: true,
              max_output_tokens: 2_000,
            },
            {
              signal: upstreamController.signal,
              timeout: 120_000,
              maxRetries: 1,
            },
          );

        for await (const event of openAIStream) {
          if (event.type === "response.output_text.delta") {
            safeEnqueue(
              encodeSse({
                type: "delta",
                text: event.delta,
              }),
            );

            continue;
          }

          if (event.type === "response.completed") {
            completed = true;

            safeEnqueue(
              encodeSse({
                type: "done",
              }),
            );

            continue;
          }

          if (event.type === "response.failed") {
            throw new Error(
              event.response.error?.message ??
                "OpenAIの生成に失敗しました。",
            );
          }

          if (event.type === "response.incomplete") {
            const reason =
              event.response.incomplete_details?.reason ??
              "unknown";

            throw new Error(
              `OpenAIの回答が未完了です: ${reason}`,
            );
          }

          if (event.type === "error") {
            throw new Error(event.message);
          }
        }

        if (
          !completed &&
          !upstreamController.signal.aborted
        ) {
          throw new Error(
            "完了イベントを受信する前に接続が終了しました。",
          );
        }
      } catch (error) {
        if (!upstreamController.signal.aborted) {
          console.error(
            "OpenAIストリームの中継に失敗しました。",
            error,
          );

          safeEnqueue(
            encodeSse({
              type: "error",
              message: getErrorMessage(error),
            }),
          );
        }
      } finally {
        clearInterval(heartbeat);

        request.signal.removeEventListener(
          "abort",
          abortUpstream,
        );

        safeClose();
      }
    },

    cancel() {
      abortUpstream();

      request.signal.removeEventListener(
        "abort",
        abortUpstream,
      );
    },
  });

  return new Response(responseBody, {
    headers: {
      "Content-Type":
        "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  });
}

この実装では、ブラウザとの接続が終了したときにOpenAI APIへの通信も停止します。

ユーザーが別ページへ移動したあともOpenAI APIの生成だけが続く状態を防げるため、不要なトークン消費を抑えられます。

request.signalは環境によって発火しないことがある
Next.jsのRoute Handlerでは、クライアントが切断するとrequest.signalにabortイベントが通知される仕組みが用意されています。ただし、ブラウザ側のキャンセルに対して必ずabortイベントが発火するとは限らず、ホスティング環境やNext.jsのバージョンによって挙動が変わる場合があると案内されています。この仕組みだけに依存せず、後述するハートビートやサーバー側のタイムアウト設定と組み合わせて、切断を多重に検知できる構成にしておくと安全です。

SSEのハートビートを送る理由

モデルが回答を生成していても、推論やツール処理によって次のテキストが届くまで時間が空くことがあります。

その間にプロキシやホスティング環境が通信をアイドル状態と判断すると、ストリームが切断される可能性があります。

SSEでは、行頭がコロンのデータはコメントとして扱われ、クライアント側にはメッセージとして通知されません。このコメントを定期的に送る方法は、接続のタイムアウトを防ぐ用途にも利用できます。

先ほどのコードでは、15秒ごとに次のデータを送っています。

SSEハートビートの例
: ping 1785720000000

ハートビートはアイドル切断への対策です。

Vercel Functionsの最大実行時間や、OpenAI SDKに設定した総タイムアウトを延長するものではありません。

ブラウザ側でSSEストリームを読み取る

今回のRoute HandlerはPOSTリクエストを使用するため、ブラウザ標準のEventSourceではなくfetch()ReadableStreamで読み取ります。

src/stream-chat.ts
type StreamEvent =
  | {
      type: "delta";
      text: string;
    }
  | {
      type: "done";
    }
  | {
      type: "error";
      message: string;
    };

export async function streamChat(
  prompt: string,
  onDelta: (text: string) => void,
  signal?: AbortSignal,
): Promise<void> {
  const response = await fetch("/api/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      prompt,
    }),
    signal,
  });

  if (!response.ok) {
    const message = await response.text();

    throw new Error(
      message || `HTTP ${response.status}`,
    );
  }

  if (!response.body) {
    throw new Error(
      "ストリーミングレスポンスを取得できません。",
    );
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  let buffer = "";
  let completed = false;

  const processFrame = (frame: string): void => {
    const dataLine = frame
      .split("\n")
      .find((line) => line.startsWith("data: "));

    if (!dataLine) {
      return;
    }

    const event = JSON.parse(
      dataLine.slice(6),
    ) as StreamEvent;

    if (event.type === "delta") {
      onDelta(event.text);
      return;
    }

    if (event.type === "error") {
      throw new Error(event.message);
    }

    if (event.type === "done") {
      completed = true;
    }
  };

  try {
    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        buffer += decoder.decode();
        break;
      }

      buffer += decoder.decode(value, {
        stream: true,
      });

      const frames = buffer.split("\n\n");
      buffer = frames.pop() ?? "";

      for (const frame of frames) {
        processFrame(frame);
      }
    }

    if (buffer.trim()) {
      processFrame(buffer);
    }

    if (!completed && !signal?.aborted) {
      throw new Error(
        "完了通知を受信する前にストリームが終了しました。",
      );
    }
  } finally {
    reader.releaseLock();
  }
}

Reactコンポーネントなどからは、受信した差分を既存の文章へ追加します。

呼び出し例
const controller = new AbortController();

let answer = "";

await streamChat(
  "Next.jsについて説明してください。",
  (text) => {
    answer += text;
    console.log(answer);
  },
  controller.signal,
);

画面上に文章が表示されていても、doneイベントを受信していなければ正常終了とは限りません。

クライアント側でも完了通知を確認することで、途中まで生成された文章を完成済みとして保存する問題を防げます。

Node.js・Expressで中継する方法

Next.jsを使わず、Node.jsとExpressでAPIを作成している場合は、レスポンスヘッダーを先に送信し、OpenAIから届いた差分をres.write()で書き込みます。

また、クライアントが接続を切ったときは、OpenAIへの通信も中止します。

src/server.ts
import { once } from "node:events";
import express from "express";
import OpenAI from "openai";

const app = express();

app.use(express.json());

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  timeout: 120_000,
  maxRetries: 1,
});

app.post("/api/chat", async (req, res) => {
  const prompt =
    typeof req.body?.prompt === "string"
      ? req.body.prompt.trim()
      : "";

  const model = process.env.OPENAI_MODEL;

  if (!prompt) {
    res.status(400).json({
      error: "promptを入力してください。",
    });

    return;
  }

  if (!model) {
    res.status(500).json({
      error: "OPENAI_MODELが設定されていません。",
    });

    return;
  }

  const controller = new AbortController();

  res.status(200);
  res.setHeader(
    "Content-Type",
    "text/event-stream; charset=utf-8",
  );
  res.setHeader(
    "Cache-Control",
    "no-cache, no-transform",
  );
  res.setHeader("X-Accel-Buffering", "no");
  res.flushHeaders();

  const writeSse = async (
    event: unknown,
  ): Promise<void> => {
    const chunk =
      `data: ${JSON.stringify(event)}\n\n`;

    if (!res.write(chunk)) {
      await once(res, "drain");
    }
  };

  const heartbeat = setInterval(() => {
    if (!res.writableEnded) {
      res.write(`: ping ${Date.now()}\n\n`);
    }
  }, 15_000);

  res.on("close", () => {
    if (!res.writableEnded) {
      controller.abort(
        new Error("クライアントが切断しました。"),
      );
    }
  });

  let completed = false;

  try {
    const stream = await openai.responses.create(
      {
        model,
        input: prompt,
        stream: true,
        max_output_tokens: 2_000,
      },
      {
        signal: controller.signal,
        timeout: 120_000,
      },
    );

    for await (const event of stream) {
      if (event.type === "response.output_text.delta") {
        await writeSse({
          type: "delta",
          text: event.delta,
        });
      }

      if (event.type === "response.completed") {
        completed = true;

        await writeSse({
          type: "done",
        });
      }

      if (event.type === "response.failed") {
        throw new Error(
          event.response.error?.message ??
            "OpenAIの生成に失敗しました。",
        );
      }

      if (event.type === "response.incomplete") {
        throw new Error(
          `回答が未完了です: ${
            event.response.incomplete_details
              ?.reason ?? "unknown"
          }`,
        );
      }

      if (event.type === "error") {
        throw new Error(event.message);
      }
    }

    if (!completed && !controller.signal.aborted) {
      throw new Error(
        "完了イベントを受信する前に接続が終了しました。",
      );
    }
  } catch (error) {
    if (!controller.signal.aborted) {
      await writeSse({
        type: "error",
        message:
          error instanceof Error
            ? error.message
            : "不明なエラーが発生しました。",
      });
    }
  } finally {
    clearInterval(heartbeat);

    if (!res.writableEnded) {
      res.end();
    }
  }
});

app.listen(3000, () => {
  console.log(
    "http://localhost:3000で起動しました。",
  );
});

res.write()falseを返した場合は、書き込み先のバッファに空きがありません。

そのまま大量のデータを書き続けるのではなく、drainイベントを待つことで、遅いクライアントに対するバックプレッシャーを処理できます。res.on("close", ...)によるクライアント切断検知は、Route Handlerのrequest.signalより歴史が長く、実行環境による差が出にくい方法です。

Node.jsのkeepAliveTimeoutを延ばせば直るとは限らない

ストリーム切断の対策として、server.keepAliveTimeoutを変更する例もありますが、この値は通常、回答をストリーミングしている最中の制限ではありません。

Node.jsのserver.keepAliveTimeoutは、最後のレスポンスを書き終えたあと、次のリクエストを待つアイドル接続の維持時間です。現在のNode.jsではデフォルトが5秒ですが、実行中の長い回答が5秒で切れるという意味ではありません。

一方、server.timeoutはソケットの無通信時間を扱います。Node.js自体の現在のデフォルトは0で、タイムアウトなしです。ただし、Expressの周辺ライブラリ、ロードバランサー、クラウドサービス側で別の値が設定されている可能性があります。

server.requestTimeoutも混同しやすい設定です。

これはクライアントからリクエスト本文全体を受信するまでの時間であり、OpenAIから長い回答を受け取る時間を直接制限するものではありません。

VercelのmaxDurationを確認する

ローカル環境では正常なのにVercelへデプロイすると途中で切れる場合は、Functionの最大実行時間を確認します。

Vercel Functionsには最大実行時間があり、ストリーミングレスポンスもその対象です。上限に達するとFunctionが終了し、FUNCTION_INVOCATION_TIMEOUTなどのエラーになる可能性があります。利用できる時間はプランやFluid Computeの有無によって異なります。

Next.jsのRoute Handlerでは、次の設定をエクスポートできます。

maxDurationの設定
export const maxDuration = 300;

maxDurationは秒単位です。

ただし、コードに300と書けば必ず300秒利用できるわけではありません。デプロイ先が対応している範囲内でのみ反映されます。Next.jsのmaxDurationは、デプロイプラットフォームがRoute Segmentの実行時間を設定するために利用する値です。

OpenAI SDKのtimeoutよりmaxDurationが短いと、OpenAI SDKが待機を続けていてもVercel側が先にFunctionを終了させます。

反対に、maxDurationが300秒でもOpenAI SDKのtimeoutが30秒なら、OpenAI側への通信は30秒で中断されます。

Nginxのバッファリングを無効化する

OpenAIからデータが届いているのに、ブラウザにはしばらく何も表示されず、最後にまとめて表示される場合は、Nginxなどのプロキシがレスポンスをバッファリングしている可能性があります。

Nginxでは、proxy_bufferingが有効になっていると、上流サーバーから受け取ったデータをバッファへ保存してからクライアントへ送信します。

Nginxは、上流サーバーが返すX-Accel-Bufferingヘッダーでもバッファリングの有効・無効を制御できます。

Next.jsやExpressのレスポンスに次のヘッダーを追加します。

必要なレスポンスヘッダ
{
  "Content-Type":
    "text/event-stream; charset=utf-8",
  "Cache-Control": "no-cache, no-transform",
  "X-Accel-Buffering": "no",
}

Nginxの設定を直接変更できる場合は、ストリーミング用のパスでproxy_buffering offを設定する方法もあります。

nginx.conf
location /api/chat {
    proxy_pass http://node_app;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 180s;
}

proxy_read_timeoutは、レスポンス全体が終わるまでの時間ではなく、上流から次のデータが届くまでの待機時間として設定します。

ハートビートを送っていても、CDNや別のリバースプロキシが独自にバッファリングしている場合は、そのサービス側の設定も確認する必要があります。

ストリーム開始後の自動リトライに注意する

OpenAI公式SDKは、接続エラー、408、409、429、500番台のエラーをデフォルトで2回リトライします。429エラーそのものの切り分け方はOpenAI APIの429エラーを直す方法で詳しく解説しています。

ただし、すでに一部の文章をブラウザへ送信したあとで通信が切れた場合、同じリクエストを最初から再実行するだけでは安全に復旧できません。

再実行されたモデルの出力が、最初の出力と完全に一致する保証はないためです。すでに表示した文章へ新しい回答をそのまま連結すると、同じ内容が重複したり、文章の流れが不自然になったりします。

ストリーム開始前の接続エラーは再試行してもよいものの、一文字でもユーザーへ送信したあとの切断は、エラーとして扱うほうが実装しやすくなります。

必要であれば、「再生成」ボタンを表示するか、保存済みの文章を入力へ含めて続きだけを生成する仕組みを用意します。

途中切断を調査するためにログへ残す情報

ストリーミングの問題を調べるときは、単に「エラーが発生した」というログだけでは原因を特定できません。

OpenAI側のイベントがresponse.completedresponse.failedresponse.incompleteのどれで終了したかを記録します。

同時に、OpenAI SDKのエラー名、HTTPステータス、エラーコード、リクエストID、生成開始時刻、最初の差分を受信した時刻、最後の差分を受信した時刻、ブラウザ側の切断有無も記録します。

OpenAIの公式SDKでは、レスポンスに付与されたx-request-idを確認できます。ストリーミングでは.withResponse()を使用して、ストリームとリクエストIDを取得することも可能です。

リクエストIDを取得する例
const {
  data: stream,
  request_id: requestId,
} = await openai.responses
  .create({
    model: process.env.OPENAI_MODEL!,
    input: "テスト",
    stream: true,
  })
  .withResponse();

console.log({
  requestId,
  startedAt: new Date().toISOString(),
});

for await (const event of stream) {
  console.log({
    requestId,
    eventType: event.type,
    receivedAt: new Date().toISOString(),
  });
}

プロンプト全文をログへ保存すると、個人情報や機密情報が残る可能性があります。

調査に必要な場合でも、入力文字数、推定トークン数、機能名、匿名化したユーザーIDなどを中心に記録します。

ローカルでは動くのに本番だけ切れる場合の確認方法

ローカル環境だけで正常に動く場合、OpenAI APIそのものよりも、デプロイ先やプロキシの影響を疑います。

最初に、VercelなどのFunctionログで実行時間制限や504エラーが出ていないか確認します。

次に、ブラウザの開発者ツールでレスポンスが少しずつ届いているかを確認します。サーバーログでは差分を受信しているのに、ブラウザ側では最後にまとめて表示される場合は、プロキシや圧縮処理によるバッファリングが考えられます。

決まった秒数で切断される場合は、その時間とOpenAI SDK、Vercel、Nginx、ロードバランサーの設定値を比較します。

文章量がほぼ同じ位置で止まる場合は、max_output_tokensresponse.incompleteを確認します。

画面遷移やコンポーネントの再描画時だけ止まる場合は、ブラウザのAbortControllerやReactのクリーンアップ処理を確認します。

OpenAI APIのストリーミング切断に関するよくある質問

Qストリームが途中で止まったら、まず何を確認すべき?

A決まった秒数で毎回止まるなら、その秒数とOpenAI SDKのtimeout・VercelのmaxDuration・Nginxのタイムアウト設定を比較します。文章量がほぼ同じ位置で止まるならmax_output_tokensresponse.incompleteを疑い、画面遷移や再描画のタイミングだけ止まるならブラウザ側のAbortControllerを確認してください。

Qローカルでは問題ないのに本番環境(Vercelなど)でだけ切れます。

A実行環境の時間制限やプロキシのバッファリングが原因であることが多いです。VercelのFunctionログで実行時間超過や504エラーが出ていないか、ブラウザの開発者ツールでレスポンスが少しずつ届いているか(最後にまとめて表示されていないか)を確認してください。

Qresponse.completedを受信しないまま接続が切れた場合、途中まで生成された文章は保存してよい?

Aそのまま完成済みとして保存するのはおすすめしません。response.completedを受信していない場合は生成が正常に終わった保証がないため、エラーとして扱い、必要であれば再生成の導線を用意する方が安全です。

Qハートビートを送っていれば、ストリームは絶対に切れなくなりますか?

Aいいえ。ハートビートはアイドル状態と誤認されて切断されるのを防ぐための対策であり、Vercelの実行時間上限やOpenAI SDKに設定したtimeoutそのものを延長するものではありません。あわせてタイムアウト設定の見直しが必要です。

まとめ

OpenAI APIのストリーミングが途中で切れる場合は、OpenAI SDKだけでなく、ブラウザ、Node.js、Next.js、Vercel、Nginxまで含めて原因を切り分ける必要があります。

OpenAI SDKのtimeoutとホスティング環境のmaxDurationは別の設定です。片方だけを延長しても、もう片方の制限が短ければ通信は終了します。

Responses APIでは、response.output_text.deltaだけでなく、response.completedresponse.failedresponse.incompleteerrorを確認します。response.completedを受信しないまま接続が終了した場合は、正常終了として扱わないことが重要です。

Next.jsではReadableStreamを使ってOpenAIのイベントをブラウザへ中継し、SSEのハートビートを定期的に送ります。ブラウザが切断したときはAbortControllerでOpenAI側の生成も停止しますが、request.signalだけに依存せず、res.on("close", ...)のような環境差の少ない検知方法も併用すると安定します。

本番環境では、リクエストID、終了イベント、最終受信時刻、クライアント切断、実行時間をログへ残しておくと、ストリームが切れた場所を特定しやすくなります。基本的なOpenAI APIの使い方は【TypeScript】OpenAI API入門、429エラーへの対処はOpenAI APIの429エラーを直す方法もあわせてご覧ください。