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?

fallback が生まれた瞬間に aborted になる:LLM ストリームの AbortSignal は二層いる

0
Posted at

AbortSignal は runSignal と attemptSignal の二層

沈黙を検知したあと、どの AbortController を切るか で次の事故が起きます。

前回はストリームの沈黙を first-token / thinking / idle の三相で切る話を書きました。

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

Qiita にも AbortSignal の解説はあります。AbortSignal.timeout() を1本渡して無限待ちを止める、というところまでです。本番で足りなかったのはその先です。

ストールした経路を切って、別のモデルへフォールバックする。そのとき親の AbortControllerabort() すると、次の経路の AbortSignal.any()生まれた瞬間に aborted = true になります。フォールバック先は一文字も吐きません。ユーザーから見ると「切ったのに、まだ回っている」。ログには AbortError しか残らず、上流の 503 は見えません。

自分の現場はリアルタイム面接の回答ストリームです。面接官が次の質問を口にしたら親を切る。経路が黙ったら、その経路だけ切る。この二つを同じコントローラーに載せると、フォールバックが即死します。

結論

AbortSignal は二層です。

  • runSignal:ユーザーが止めた、次の質問が来た。ラウンド全体を終わらせる
  • attemptSignal:この経路がストールした / ヘッジに負けた。この fetch だけ 止める

1本目を切るときは attemptSignal.abort() だけです。親は触りません。2本目は 新しい AbortController と、新しい AbortSignal.any([runSignal, attempt2]) を作ります。合成済みの signal は再利用しません。

誤りは親を abort、正しさは本経路だけ切る

AbortSignal.any() 自体は MDN の通りです。どれか一つが中断したら合成先も中断する。問題は「ストール」をそのどれに載せるかです。親に載せると、次の any() の入力が最初から死んでいます。

誤りの再現

上流が黙るストリームと、すぐ文字を吐くフォールバック先を用意します。ストール検知の時計は 40ms です。本番の first-token は 15 秒ですが、仕組みは同じです。

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

async function* silentStream(_signal: AbortSignal): AsyncGenerator<string> {
  await new Promise((r) => setTimeout(r, 20));
  await new Promise(() => {});
}

async function* okStream(
  text: string,
  signal: AbortSignal,
): AsyncGenerator<string> {
  for (const ch of text) {
    if (signal.aborted) throw new DOMException("Aborted", "AbortError");
    await new Promise((r) => setTimeout(r, 5));
    yield ch;
  }
}

async function consume(
  source: AsyncIterable<string>,
  signal: AbortSignal,
  budgetMs: number,
): Promise<string> {
  const iterator = source[Symbol.asyncIterator]();
  let out = "";
  const started = Date.now();
  while (true) {
    if (signal.aborted) throw new DOMException("Aborted", "AbortError");
    const waited = Date.now() - started;
    if (waited >= budgetMs && out.length === 0) {
      throw new StreamStalledError(waited);
    }
    const slice = Math.max(1, budgetMs - waited);
    let timer: ReturnType<typeof setTimeout> | undefined;
    const next = iterator.next();
    try {
      const settled = await Promise.race([
        next.then((v) => ({ v })),
        new Promise<null>((resolve) => {
          timer = setTimeout(() => resolve(null), slice);
        }),
      ]);
      if (!settled) {
        next.then(
          () => {},
          () => {},
        );
        throw new StreamStalledError(Date.now() - started);
      }
      if (settled.v.done) return out;
      out += settled.v.value;
    } finally {
      clearTimeout(timer);
    }
  }
}

ストールしたときに 親を abort すると、こうなります。

async function wrong(): Promise<void> {
  const parent = new AbortController();
  try {
    await consume(silentStream(parent.signal), parent.signal, 40);
  } catch (err) {
    if (err instanceof StreamStalledError) parent.abort("stalled");
    else throw err;
  }
  const fallback = new AbortController();
  const signal = AbortSignal.any([parent.signal, fallback.signal]);
  console.log("wrong: fallback.aborted =", signal.aborted);
  try {
    const text = await consume(okStream("fallback-ok", signal), signal, 200);
    console.log("wrong: text =", JSON.stringify(text));
  } catch (err) {
    console.log(
      "wrong: fallback threw",
      err instanceof Error ? err.name : err,
    );
  }
}

Bun で回した結果です。

wrong: fallback.aborted = true
wrong: fallback threw AbortError

フォールバック先の生成器は健全です。okStream は 5ms ごとに文字を吐きます。死んでいるのは signal の方です。AbortSignal.any の入力に、すでに aborted な親が入っています。

SDK に渡す signal が最初から aborted だと、上流には新しいリクエストが飛びません。課金は止まります。その代わり、切り替えたつもりが即 AbortError です。監視は「キャンセルされた」としか見えません。

正しさ:この経路だけ切る

親は残します。切るのは 1 本目の attempt だけです。2 本目は controller も any() も作り直します。

async function right(): Promise<void> {
  const run = new AbortController();
  const attempt1 = new AbortController();
  const signal1 = AbortSignal.any([run.signal, attempt1.signal]);
  try {
    await consume(silentStream(signal1), signal1, 40);
  } catch (err) {
    if (err instanceof StreamStalledError) attempt1.abort("stalled");
    else throw err;
  }
  const attempt2 = new AbortController();
  const signal2 = AbortSignal.any([run.signal, attempt2.signal]);
  console.log("right: fallback.aborted =", signal2.aborted);
  const text = await consume(okStream("fallback-ok", signal2), signal2, 200);
  console.log("right: text =", JSON.stringify(text));
}
right: fallback.aborted = false
right: text = "fallback-ok"

run.signal は生きています。だから 2 本目の any() は生きたまま始まります。1 本目の上流は attempt1.abort("stalled") で切れます。ここを忘れると、黙った経路が裏で生成を続けて課金します。切る対象を親から attempt に移すのであって、abort 自体をやめるわけではありません。

ユーザーが止めたときは、逆です。親を切ります。

async function userCancelKillsAll(): Promise<void> {
  const run = new AbortController();
  run.abort("user-cancel");
  const attempt = new AbortController();
  const signal = AbortSignal.any([run.signal, attempt.signal]);
  console.log("user-cancel: next attempt aborted =", signal.aborted);
}
user-cancel: next attempt aborted = true

次の質問が来たのに、前の経路のフォールバックが裏で走り続ける、という状態はこれで止まります。ストールとユーザー中断を同じ abort に載せると、この分岐が消えます。

本番でやっていること

呼び出し側には signalabortAttempt を別々に渡します。signalAbortSignal.any([runSignal, attempt.signal]) です。SDK の abortSignal にはこれを渡します。ストール判定のコールバックでは abortAttempt(reason) だけ呼びます。親の runSignal は触りません。

type AttemptCtx = {
  signal: AbortSignal;
  abortAttempt: (reason: string) => void;
};

async function runTrip(
  runSignal: AbortSignal,
  attempt: (ctx: AttemptCtx) => Promise<string>,
): Promise<string> {
  const controller = new AbortController();
  return attempt({
    signal: AbortSignal.any([runSignal, controller.signal]),
    abortAttempt: (reason) => controller.abort(reason),
  });
}

フォールバックするたびに runTrip をもう一度呼びます。中の controller は毎回新しいものです。1 本目の any() を変数に残して 2 本目へ回す、というのが一番やりがちな再利用バグです。合成済み signal は、片方が abort した時点で終わりです。

ヘッジ(Hedged Requests)も同じです。負けた経路は自分の attempt だけ切る。勝った経路の signal は残す。親を切ると、勝者まで即死します。

AbortSignal.any は Node.js 20.3 / 18.17 からです。それより前なら、attempt の abort イベントで親ではない側だけを中継する必要があります。ポリフィルで any を足すより、二層の境界を先に決めた方が事故は減ります。

確認したケース

上のコードを Bun で実行して、次を確認しています。

ケース 期待
ストール時に親を abort fallback.aborted = trueAbortError、文字なし
ストール時に attempt だけ abort fallback.aborted = false、本文 fallback-ok
ユーザーが親を abort 次の attempt は最初から aborted

三相ストールの時計は前回の記事のままです。今回足したのは、判定したあと abort の宛先 です。宛先を間違えると、沈黙検知が正しくてもフォールバックが動きません。

皆さんの現場では、ストールしたときに親の AbortController を切っていませんか。fallback が AbortError だけで、上流の 503 がログに残らない、という症状があれば聞きたいです。

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?