沈黙を検知したあと、どの AbortController を切るか で次の事故が起きます。
前回はストリームの沈黙を first-token / thinking / idle の三相で切る話を書きました。
「思考中」と回線死を混ぜない:LLMストリーミングの沈黙を三相で切る
Qiita にも AbortSignal の解説はあります。AbortSignal.timeout() を1本渡して無限待ちを止める、というところまでです。本番で足りなかったのはその先です。
ストールした経路を切って、別のモデルへフォールバックする。そのとき親の AbortController を abort() すると、次の経路の AbortSignal.any() は 生まれた瞬間に aborted = true になります。フォールバック先は一文字も吐きません。ユーザーから見ると「切ったのに、まだ回っている」。ログには AbortError しか残らず、上流の 503 は見えません。
自分の現場はリアルタイム面接の回答ストリームです。面接官が次の質問を口にしたら親を切る。経路が黙ったら、その経路だけ切る。この二つを同じコントローラーに載せると、フォールバックが即死します。
結論
AbortSignal は二層です。
-
runSignal:ユーザーが止めた、次の質問が来た。ラウンド全体を終わらせる -
attemptSignal:この経路がストールした / ヘッジに負けた。この fetch だけ 止める
1本目を切るときは attemptSignal.abort() だけです。親は触りません。2本目は 新しい AbortController と、新しい AbortSignal.any([runSignal, attempt2]) を作ります。合成済みの signal は再利用しません。
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 に載せると、この分岐が消えます。
本番でやっていること
呼び出し側には signal と abortAttempt を別々に渡します。signal は AbortSignal.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 = true、AbortError、文字なし |
| ストール時に attempt だけ abort |
fallback.aborted = false、本文 fallback-ok
|
| ユーザーが親を abort | 次の attempt は最初から aborted |
三相ストールの時計は前回の記事のままです。今回足したのは、判定したあと abort の宛先 です。宛先を間違えると、沈黙検知が正しくてもフォールバックが動きません。
皆さんの現場では、ストールしたときに親の AbortController を切っていませんか。fallback が AbortError だけで、上流の 503 がログに残らない、という症状があれば聞きたいです。