0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「思考中」と回線死を混ぜない:LLMストリーミングの沈黙を三相で切る

0
Posted at

LLMストリームの3相ストール

この記事は Anthropic TypeScript SDK の Issue #867 を起点にしています。ストリームが途中で沈黙したあと、クライアントの for await が終わらない、という報告です。同じ症状は Qwen Code の inactivity timeout 設計 でも「200 のあとにチャンクが一個も来ない」と書かれています。

自分の現場でも同じ穴を踏みました。HTTP は 200、SSE は切れていない、エラーイベントもない。なのにフロントは永遠に「生成中」のままです。SDK のリクエスト timeout は接続確立しか見ていないので、ここには効きません。

ただし、沈黙を全部同じアイドルタイムアウトで切ると、別の事故が起きます。reasoning モデルはレスポンスヘッダのあとに何秒も黙ります。それを回線死と判定すると、通っている経路を切り替えて、同じ思考を最初からやり直させることになります。

なので時計は1本では足りません。3本です。

  • first-token:上流がまだリクエストを受けていない。ゲートウェイ待ち、あるいは経路が死んでいる
  • thinking:HTTP レスポンスヘッダは来た。モデルが考えているだけ
  • idle:一度はトークンが出たあと、チャンクが止まった

本番の数字だけ置きます。こちらの LLM TTFT は p50 が約 2.3 秒、p95 が約 4.7 秒です。一方で、本当に死んでいたラウンドは最初のトークンまで 43 秒、63 秒待たされたことがあります。健康なストリームのチャンク間隔は百ミリ秒台、劣化しても 2〜5 秒です。なので本番値は first-token 15 秒、idle 12 秒、thinking はモデルの思考予算に合わせます。

ローカルの start を最初のトークンに数えない

Vercel AI SDK の streamText / Agent.stream を使っていると、もう一段ハマります。

result.stream の最初のチャンクは type: "start" です。これは消費を始めた瞬間にローカルで出る制御フレームで、上流が1バイトも返していなくても来ます。実測では上流が 5 秒後に最初のトークンを吐いたラウンドで、start は +8ms に到着していました。

これを「チャンクが来た」と数えると、first-token の時計が即リセットされます。30 秒の先頭予算が、実質 25 秒のアイドル時計に化けます。長い思考は全部「途中で死んだ」扱いになります。

start-step も同じです。ツール呼び出しのあとの次の上流リクエストが始まる印なので、そのステップでは先頭の時計をやり直します。

first token の定義

もう一つ、「上流が受け付けたか」の判定に response.created を使わない方がいいです。同じ問題でレスポンスヘッダは 2.0 秒で着いたのに、created は 6.7 秒でした。しかもこの差は安定しません。6 秒早いことも、3ms しか早くないこともあります。後者に当たると、受付信号が実質ありません。

安定していたのは HTTP レスポンスヘッダが手元に届いた瞬間です。90 秒沈黙して本当に死んでいた事故は、ヘッダ自体が来ていませんでした。「ヘッダが来た = 経路は通った、あとの沈黙は上流の思考」と切ると、誤検知が減ります。

実装

ポイントは4つです。

  1. タイムアウトは throw する。silent return は呼び出し側が完了イベントを送り、途中までの応答を成功として残します
  2. iterator.next() は1回だけ取る。待ちをスライスしても、同じ Promise を race し続ける
  3. 判定した瞬間に abort する。切らないと上流は生成を続け、課金だけが進みます
  4. finallyiterator.return() を await しない。await すると、ハングした next() の後ろに並んで、また無限待ちになります
export type StreamStallPhase = "first-token" | "thinking" | "idle";

export class StreamStalledError extends Error {
  constructor(
    readonly phase: StreamStallPhase,
    readonly waitedMs: number,
  ) {
    super(`stream stalled at ${phase} after ${waitedMs}ms`);
    this.name = "StreamStalledError";
  }
}

export type StallTimeoutOptions<T> = {
  firstTokenMs: number;
  idleMs: number;
  thinkingMs?: number;
  isUpstreamAlive?: () => boolean;
  onStall?: (phase: StreamStallPhase, waitedMs: number) => void;
  isStepBoundary?: (chunk: T) => boolean;
};

const BUDGET_RECHECK_MS = 1_000;

async function nextWithinBudget<T>(
  next: Promise<IteratorResult<T>>,
  gotChunk: boolean,
  opts: StallTimeoutOptions<T>,
): Promise<IteratorResult<T>> {
  const startedAt = Date.now();
  while (true) {
    const thinking = !gotChunk && (opts.isUpstreamAlive?.() ?? false);
    const budget = gotChunk
      ? opts.idleMs
      : thinking
        ? (opts.thinkingMs ?? opts.firstTokenMs)
        : opts.firstTokenMs;
    const waited = Date.now() - startedAt;
    if (waited >= budget) {
      throw new StreamStalledError(
        gotChunk ? "idle" : thinking ? "thinking" : "first-token",
        waited,
      );
    }
    const slice = Math.min(budget - waited, BUDGET_RECHECK_MS);
    let timer: ReturnType<typeof setTimeout> | undefined;
    try {
      const settled = await Promise.race([
        next.then((value) => ({ value })),
        new Promise<null>((resolve) => {
          timer = setTimeout(() => resolve(null), slice);
        }),
      ]);
      if (settled) return settled.value;
    } finally {
      clearTimeout(timer);
    }
  }
}

export async function* withStallTimeout<T>(
  source: AsyncIterable<T>,
  opts: StallTimeoutOptions<T>,
): AsyncGenerator<T> {
  const iterator = source[Symbol.asyncIterator]();
  let gotChunk = false;
  try {
    while (true) {
      const next = iterator.next();
      let result: IteratorResult<T>;
      try {
        result = await nextWithinBudget(next, gotChunk, opts);
      } catch (err) {
        if (err instanceof StreamStalledError) {
          // 捨てた next() があとから reject するので、unhandledRejection を防ぐ
          next.catch(() => {});
          opts.onStall?.(err.phase, err.waitedMs);
        }
        throw err;
      }
      if (result.done) return;
      gotChunk = !opts.isStepBoundary?.(result.value);
      yield result.value;
    }
  } finally {
    void Promise.resolve(iterator.return?.()).catch(() => {});
  }
}

export function isAiSdkStepBoundary(chunk: { type: string }): boolean {
  return chunk.type === "start" || chunk.type === "start-step";
}

使い方はこうです。AbortControlleronStall で必ず落とします。phase で分岐するのが本丸で、first-token だけ経路を疑います。thinking で経路を切り替えると、同じ問題をもう一度考えさせます。

const abort = new AbortController();
const liveness = { alive: false };

try {
  for await (const chunk of withStallTimeout(upstreamStream, {
    firstTokenMs: 15_000,
    thinkingMs: 60_000,
    idleMs: 12_000,
    isUpstreamAlive: () => liveness.alive,
    isStepBoundary: isAiSdkStepBoundary,
    onStall: (phase, waitedMs) => {
      abort.abort();
      console.error({ phase, waitedMs, event: "stream_stalled" });
    },
  })) {
    process.stdout.write(chunk);
  }
} catch (err) {
  if (err instanceof StreamStalledError) {
    if (err.phase === "first-token") {
      // 経路 / ゲートウェイ。別経路へ
    } else if (err.phase === "thinking") {
      // 思考が長い。経路は落とさない
    } else {
      // 途中で止まった。ユーザーには失敗を返す。成功扱いにしない
    }
  }
  throw err;
}

isUpstreamAlive は、プロバイダの doStream() が resolve した時点(レスポンスヘッダ)で true にします。AI SDK なら wrapLanguageModelwrapStream で取れます。response-metadata は調査用に残す程度で十分でした。

確認したケース

Bun で短い遅延の偽ストリームを回して、次を確認しています。

ケース 期待
レスポンスヘッダが来ない沈黙 first-token
ヘッダは来たのにトークンが出ない thinking
トークンが出たあとに沈黙 idle
通常の完走 全文が揃う
先頭が start のあと 90ms で本文 isStepBoundary ありなら完走、なしなら idle 誤検知
ストール時 onStall が呼ばれる

thinking のケースは、40ms で alive = true に切り替えて、first-token 予算 80ms を超えても thinking 予算 200ms まで待つ、という形です。時計を1本にすると、ここで誤検知します。

切り方を間違えると何が起きるか

ストールを EOF 扱いにすると、呼び出し側は成功ログを残します。監視上は「遅いけど成功」、ユーザー側は途中で止まった文章です。この二つが同時に立つと、あとから原因を追えません。

abort を忘れると、もっと地味です。クライアントはもう読んでいないのに、上流はトークンを吐いて課金します。ストール検知は「ユーザーを待たせない」だけではなく、「死んだ接続の生成を止める」ためのものです。

first-token と thinking を混ぜると、reasoning を入れた日から誤検知が増えます。思考中は経路が通っているので、フォールバックしても同じだけ待たされます。待たされたうえに、先に払った思考トークンは捨てます。

Qiita で idle timeout 自体を解説している記事はあります。1本の時計で「次のチャンクが来なければ切る」ところまでです。本番で効かなかったのは、その先の切り分けでした。

皆さんの現場では thinking 予算を何秒にしていますか。reasoning を入れたあと first-token を延ばして誤爆した、という話があれば聞きたいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?