この記事は 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 も同じです。ツール呼び出しのあとの次の上流リクエストが始まる印なので、そのステップでは先頭の時計をやり直します。
もう一つ、「上流が受け付けたか」の判定に response.created を使わない方がいいです。同じ問題でレスポンスヘッダは 2.0 秒で着いたのに、created は 6.7 秒でした。しかもこの差は安定しません。6 秒早いことも、3ms しか早くないこともあります。後者に当たると、受付信号が実質ありません。
安定していたのは HTTP レスポンスヘッダが手元に届いた瞬間です。90 秒沈黙して本当に死んでいた事故は、ヘッダ自体が来ていませんでした。「ヘッダが来た = 経路は通った、あとの沈黙は上流の思考」と切ると、誤検知が減ります。
実装
ポイントは4つです。
- タイムアウトは throw する。silent return は呼び出し側が完了イベントを送り、途中までの応答を成功として残します
-
iterator.next()は1回だけ取る。待ちをスライスしても、同じ Promise を race し続ける - 判定した瞬間に abort する。切らないと上流は生成を続け、課金だけが進みます
-
finallyでiterator.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";
}
使い方はこうです。AbortController は onStall で必ず落とします。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 なら wrapLanguageModel の wrapStream で取れます。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 を延ばして誤爆した、という話があれば聞きたいです。

