初めての本番 LLM 呼び出し:ストリーミング、リトライ、タイムアウト
429、ハングしたソケット、途中で切れるストリームを返すプロバイダーを作り、クライアントの挙動を測る。full jitter は226秒を2.2秒にする。
このページの内容
第13章は、手で触れられるモデルにストップウォッチを当てて終わりました。重みはあなたのメモリ内にあり、KV cache を有効にも無効にもでき、出てきた数値 — time to first token — はあなたのハードウェアの性質でした。
では、そのモデルをポートの向こう側に置いてみます。あらゆるプロダクトがそうしているように。そして同じ数値をもう一度読みます。それは依然として time to first token ですが、もはやあなたが制御できる何かの性質ではありません。そこには TLS ハンドシェイク、プロバイダー側のキュー、レートリミッター、そして token が一切届かない可能性が含まれます。
最後の一節こそが、この章です。これから書くコードは何も計算しません。接続を開き、待ち、届いたものをパースし、何も届かないときにどうするかを決め、届いたものがエラーだったときにもう一度決め、ユーザーが考えを変えたら自分自身をキャンセルします。これらはすべて時間にまたがる状態についての判断であり、それぞれに、出荷されてお金を失う間違った答えがあります。
問題の形はこうです。この章では、すべて測定します。
| 起きたこと | 不注意なクライアントがすること | かかるコスト |
|---|---|---|
| サーバーがソケットを受け入れ、何も返さない | 待つ | Node が自力で諦めるまで 300.8 s |
| キーが間違っていた(401) | 5回リトライする | 6,325 ms の遅延のあと、同じ 401 |
| 100のクライアントが同時にレート制限に当たる | 全員が同じスケジュールでリトライする | 排出まで 226 s、対して 2.2 s |
| リクエストがタイムアウトして再送される | 再送する | プロバイダーは答えを2回生成し、2回課金する |
| 接続が回答の途中で落ちる | 部分的なテキストを表示する | 正しい短い回答と区別できない |
これらのどれもモデリングの問題ではありません。どれも、これまで書かれたあらゆる LLM プロダクトの最初の100行にあります。
この章で言語が変わる理由
セクション「この章で言語が変わる理由」へのリンクもう一度この表を読み、どんな種類のプログラムを説明しているのか考えてみてください。40秒間、接続を開いたままにします。ボタンからキャンセルできなければなりません。表示するには有効だが保存するには無効な部分回答を蓄積します。そして、サーバープロセスかエッジワーカーで、回答をレンダリングするものの隣で、ソケットを保持しながら動きます。
これはノートブックではありません。Python でできないという話ではありません — できますし、実際にそうする人もいます — ただ、前の13章で作ってきたものとは種類が違うのです。第1章から第13章までは、重み、gradients、logits、tokenizer バイトを扱っていました。ここからのコードが扱うのは、接続、リトライ、キャンセル、蓄積された状態、そして後には権限を求める promptです。扱う対象が変わる継ぎ目で、コースはちょうど言語を変えます。
なので、ルールを一度だけ書きます。
コードが重み、gradients、logits、tokenizer バイトを手にしているなら、それは Python です。接続を保持し、リトライし、キャンセルし、状態を蓄積し、許可を求めるなら、それは TypeScript です。
継ぎ目はひとつで、第13章と第14章の間、ここにあります。3つの独立した基準がここを指しています。
1つ目:エコシステムを数える。 このコースの左半分が引用するものはすべて Python であり、このシラバスのために監査した12のコース全体を見ても、backpropagation を別の言語で教えている前例はひとつもありません。micrograd(17.4K stars)、nanoGPT(62.8K)、nanochat(57.8K)、minbpe(10.7K)、PyTorch(102.8K)、transformers(164.9K)。第5章を TypeScript で書けば、それらのソースとのリンクを断ち切ることになります。そして、参照されるために存在する章では、そのリンクが価値の半分です。こちら側では算術が逆になります。Vercel の ai パッケージは月間 89.4M ダウンロードで、もの自体 — tool-calling agent ループ、ToolLoopAgent としてエクスポート — を出荷しています。つまり、このコースが第23章で到達する概念は、同章で測定するように誰もまだ名前に合意していないにもかかわらず、TypeScript に参照実装を持っています。Mastra は 27.7K stars。Anthropic の SDK はひとつの仕様から生成されており、TypeScript では202エンドポイント、Python では201エンドポイントを宣言しています — これは同等性であり、親切な移植ではありません。
2つ目:MCP の規範的なソース。 Model Context Protocol 仕様のスキーマは schema.ts ファイルです。第26章のプロトコルを別の言語で教えることは、その創設文書の翻訳を教えることを意味します。
3つ目:検索需要。ただし、明らかな推測への補正つき。 machine learning python はインターネット上で最も飽和したフレーズです。ai agent typescript にも十分に健全なロングテールがあります。ただし、「MCP エコシステムはほとんど TypeScript である」というのは数え方次第でしか真ではありません。公式レジストリでは npm に8,275サーバー、PyPI に3,603サーバーがあります。一方、ダウンロード数では Python が勝っています — mcp が月間 287M、fastmcp が 72M、対して @modelcontextprotocol/sdk は 195M。MCP はここで唯一、本当にバイリンガルな領域です。だから第27章では、見せかけをせず、同じサーバーを2回書きます。
詳細を表示
宣言された5つの例外。ルールをスローガンではなくルールにするために。
第17章、第20章、第29章には、Python の第2パネルがあります。top-p sampling を実装するには確率ベクトルを手元に持つ必要があり、HTTP API がそれを渡してくれることはありません。fine-tune の価格を正直に計算するには実際に1回走らせる必要があり、LoRA アダプターは nn.Module の十数行です。そして lm-eval-harness、HELM、SWE-bench、τ-bench は Python なので、TypeScript の評価 harness は backpropagation の間違いを鏡写しにしたものになります。第27章は、上で測った理由によりバイリンガルです。第28章はMarkdownです。なぜなら agent skill は SKILL.md ファイルそのものであり、それにプログラミング言語を与えることは、形式を理解していないという意味になるからです。
13の Python 章は捨てられたわけではありません。ポートの向こう側にあるのは、それらが作ったものです。そしてこの章の最後のセクションは、クライアントをそこにつなぎます。
壊せるプロバイダー
セクション「壊せるプロバイダー」へのリンク実際のプロバイダー相手にこれを学ぶことはできません。任意のタイミングで 429 を返してくれとは頼めません。接続を受け入れて一切応答しないソケットを頼むことも、単語の途中で止まるストリームを頼むこともできません。しかも、あらゆる実験にお金を払うことになります。本当に面白い実験は、100回走らせるものなのに。
なので、このコース後半の最初のプログラムはクライアントではありません。それは敵対的なサーバーです。chat completions エンドポイントと同じワイヤープロトコルを話し、要求に応じて不正に振る舞う、素の Node の40行です。この章のすべての数値はそこから出ています。
import { createServer } from "node:http";
const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3; // how many requests it will serve at once
let inflight = 0;
const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
createServer(async (req, res) => {
const url = new URL(req.url, "http://x");
if (url.pathname === "/hang") return;
if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }
if (inflight >= CAPACITY) {
res.writeHead(429, { "retry-after": "1" });
return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
}
inflight++;
const cut = Number(url.searchParams.get("cut") ?? -1); // abandon after N chunks
const how = url.searchParams.get("how"); // "close" = orderly, else reset
const max = Number(url.searchParams.get("max_tokens") ?? 999);
const delay = Number(url.searchParams.get("delay") ?? 60); // ms per token
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
for (let i = 0; i < Math.min(WORDS.length, max); i++) {
if (i === cut) {
how === "close" ? res.end() : res.destroy();
inflight--; return;
}
await new Promise((r) => setTimeout(r, delay));
sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
}
sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
res.write("data: [DONE]\n\n");
inflight--;
res.end();
}).listen(8787);敵対的な挙動は4つで、それぞれ1行です。/hang はソケットを受け入れ、何も書きません。/401 はキーを拒否します。容量チェックは、すでに3つのリクエストが実行中なら、本物の Retry-After ヘッダーを持つ本物の 429 を返します。そして ?cut=N は回答を途中で放棄します。ソケットをリセットするか、&how=close によって整然と閉じるかのどちらかです。そして、その違いが非常に大きいことがわかります。残りは本物の Server-Sent Events ストリームです。data: 行ごとに JSON オブジェクトが1つ、イベントの間に空行、最後に文字列 [DONE]。1
実行すれば、章の残りは測定です。
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]リクエストボディと、サーバーから決して出ないキー
セクション「リクエストボディと、サーバーから決して出ないキー」へのリンクchat リクエストは、それぞれ role を持つメッセージのリストです。そのリストがモデルの全状態です。呼び出しの間に記憶はなく、モデルに知ってほしいことは、今回送る配列の中に入っていなければなりません。第15章は何を入れるかについて、第16章はそれに何がかかるかについて扱うので、ここでは形だけです。
const body = {
model: "gpt-4.1-mini",
messages: [
{ role: "system", content: "You explain instruments in one sentence." },
{ role: "user", content: "What is a tide gauge?" },
],
stream: true,
max_tokens: 200,
};これらの role は飾りではありません。モデルが1つの token も見る前に、第11章の chat template へレンダリングされます。だから、間違った role を送ると、エラーを出す代わりに、答えが静かに劣化します。
例外のないルールが1つあります。API キーは決してクライアントへ渡さない。ブラウザー向けの接頭辞が付いた環境変数にも、ビルド時定数にも、「一時的」にも入れません。バンドル内のキーは、数日以内に他人の請求書のキーになります。ブラウザーはあなたのサーバーと話し、あなたのサーバーがキーを持ち、プロバイダーと話します。そしてサーバーが真ん中にいるからこそ、各ユーザーが何を使ったかを計測できる唯一の場所でもあります。第16章の会計はそこに置かなければなりません。
同じ質問を3回
セクション「同じ質問を3回」へのリンクここで、この章の土台になる実験です。1つの質問、60 ms ごとに13 tokens を生成する1つのモックプロバイダー、尋ね方は3通り。
1つ目、ストリーミングなし。 クライアントはリクエストを送り、JSON ボディ全体を待ちます。
blocking first visible = 791 ms complete = 791 ms finish_reason = stop2つの数値は同じで、それが問題のすべてです。791 ms の間、ユーザーにあるのはスピナーだけで、1語も早く利用できませんでした。サーバーは答えをバイト単位で持っていたのに、何も言わないことを選んだのです。
2つ目、ストリーミングあり。 同じサーバー、同じ答え、同じ総作業量。違いはパーサーです。
export async function* readSSE(res: Response) {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
yield JSON.parse(payload);
}
}
}
}ここには、荷重を支えている詳細が3つあり、最初の実装ではたいてい3つとも飛ばされます。buffer が存在するのは、ネットワークチャンクとイベントには何の対応関係もないからです。1回の read() がイベントの半分を返すことも、2つ半を返すこともあります。{ stream: true } フラグが存在するのは、マルチバイトの UTF-8 文字が2つのチャンクに分かれうるからです。これがないと、アクセント付き文字がランダムに置換文字になります。そしてイベントは改行ではなく、空行で区切られます。だからループは \n\n を探します。
streaming first visible = 65 ms complete = 793 ms finish_reason = stop最初の単語までは12倍速く、最後までは2ミリ秒遅い。ストリーミングは何も速くしません。同じ 790 ms の間にユーザーがしていることを変えるのです。待つ代わりに読む。それが利点のすべてであり、巨大な利点であり、あらゆる chat プロダクトがストリーミングする理由です。
3つ目、20クライアント同時。 モックプロバイダーは同時に3リクエストを処理します。20個を投げます。
jitter=true clients=20 server capacity=3
HTTP requests made: 74 429s received: 54 200s: 20
wall clock: 7,100 ms
retries per client: 0 0 0 1 1 1 2 2 2 3 4 3 3 5 4 5 4 5 4 5
every answer identical: true20の答え、74のリクエスト、54の拒否。誰も何も失わず、すべてのクライアントが同じテキストを受け取り、目に見えるコストは時間だけでした。これはリトライポリシーが機能している状態です。この章の残りは、それが失敗する3つの方法についてです。
finish_reason と、同じに見える2つの終わり方
セクション「finish_reason と、同じに見える2つの終わり方」へのリンク失敗の前に、最初の実装でほとんど全員が無視するフィールドを見ます。すべてのストリームは finish_reason を運ぶイベントで終わります。stop は、モデルが終わったと判断したという意味です。length は token 上限に達したという意味なので、回答は文の途中で切れており、モデルのせいではありません。後の章では tool_calls(第18章)とコンテンツフィルターが加わります。
ここで、素朴なクライアントには見分けられない2つの終わり方を見ます。同じサーバー、同じ遅延、片方は max_tokens によって切り詰められ、もう片方は5 tokens の後に接続がきれいに閉じられます。
max_tokens=5 loop ended NORMALLY chunks=5 finish_reason=length text="A tide gauge is a"
socket closed cleanly loop ended NORMALLY chunks=5 finish_reason=null text="A tide gauge is a"
socket destroyed threw TypeError: terminated (UND_ERR_SOCKET)
chunks=4 finish_reason=null text="A tide gauge is"最初の2行を注意深く読んでください。同じテキスト。同じチャンク数。どちらも例外なし。 for await ループはどちらも正常に終了しました。読み手から見れば、ボディが終わっただけであり、ボディにできることはそれだけだからです。観測全体で唯一の違いは、一方が finish_reason: "length" を持ち、もう一方は何も持たないことです。
だからルールは「ストリーミング中のエラーを catch する」ではありません。こうです。
finish_reasonなしで終わるストリームは、終わっていません。止まっただけです。
finish_reason がないことは常に失敗として扱い、そのテキストを完了した回答として永続化してはいけません。3行目はより簡単なケースです。破棄されたソケットは例外を投げます。そして飛行中だったチャンクも失うため、上の2つよりテキストが1語短くなっています。
5つのステータスコードは、5つの異なる問題
セクション「5つのステータスコードは、5つの異なる問題」へのリンク新しいプロダクトが持つ最も高くつく習慣は、プロバイダーが返すすべてに対して1つの catch ブロックを使うことです。これらのコードは「失敗した」のバリエーションではありません。5つの指示であり、そのうち4つは互いに矛盾します。
| status | 意味 | すべきこと | 待つ? |
|---|---|---|---|
| 400 | リクエストが不正 — JSON が壊れている、不明なフィールド、context が長すぎる | コードを直す | 絶対に待たない |
| 401 | キーが間違っている、ない、または取り消されている | デプロイを直す | 絶対に待たない |
| 429 | レート制限:1分あたりのリクエストまたは tokens が多すぎる | リトライする | Retry-After、その後 backoff |
| 500 | プロバイダーが壊れた | リトライする | backoff |
| 503 | プロバイダーが過負荷 — 稼働はしているが満杯 | リトライする | backoff し、負荷を落とす |
重要な線は 4xx とそれ以外の間にあります。400 や 401 は、1000回送ってもまったく同じ答えを返します。試行の間に、どちらの端でも何も変わらないからです。それをリトライするのは慎重さではありません。余計な手順付きの遅延です。測定してみます。片方のクライアントは6回試行します — 指数 backoff で5回リトライ —。もう片方は先にコードを読みます。
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 4014ミリ秒で利用できた答えに到達するまで、6秒のスピナー。しかもこれは穏やかな版です。プロダクト内のリトライはたいてい入れ子になっています。リトライする HTTP クライアントの中にリトライするジョブランナー、その中に独自の再配信を持つキュー。すると6秒は6分になり、恒久的に壊れたデプロイが遅いだけに見えます。
トリアージは9行で、1か所に置くべきです。
export type Verdict = "retry" | "retry-after" | "fatal";
export function classify(status: number): Verdict {
if (status === 429) return "retry-after";
if (status === 408 || status >= 500) return "retry";
return "fatal"; // 400, 401, 403, 404, 422 — nothing changes by waiting
}リストにさらに2つ。402 は、一部のプロバイダーが「クレジット切れ」に使います。これはリトライではなく、追加購入へのリンクがある画面を必要とします。そして 529 またはベンダー固有の同等コードは、503 のように振る舞います。
Backoff と、jitter が実際に何を買うのか
セクション「Backoff と、jitter が実際に何を買うのか」へのリンクリトライは簡単です。いつリトライするかが、測定可能な正解を持つ部分です。
Exponential backoff は標準です。基本遅延だけ待ち、失敗するたびに倍にし、上限で止めます。これは、過負荷のサーバーに、失敗したクライアントがそのまま戻ってくると状況が悪化するから存在します。
問題は、全員が同じ開始点から倍にすることです。100のクライアントが同じ瞬間に制限に当たるとします — そして当たります。トラフィックスパイクとはそういうものだからです —。すると100全員が 200 ms 待ち、100全員が一緒にリトライし、100全員が一緒に失敗し、100全員が 400 ms 待ちます。リトライスケジュールが彼らを同期させたのです。これがthundering herdであり、ランダム性が修正です。2
その単一の変更 — 区間の上端を取る代わりに、区間から一様に選ぶこと — は full jitter と呼ばれます。Math.random() を1回呼ぶだけであり、信じるのではなく測る価値があります。
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
Math.min(cap, base * 2 ** n);
export const backoffFull = (n: number, base = 200, cap = 20_000) =>
Math.random() * Math.min(cap, base * 2 ** n); 100のクライアント、同時に3つ処理する1つのサーバー、他はすべて同じ、各3回の実行です。
| HTTP リクエスト | 拒否 | 最悪のクライアント | 最も混んだ 50 ms 窓 | wall clock | |
|---|---|---|---|---|---|
| jitter なし、run 1 | 491 | 391 | 10回試行 | 46到着 | 65.6 s |
| jitter なし、run 2 | 780 | 680 | 19回試行 | 72到着 | 245.7 s |
| jitter なし、run 3 | 770 | 670 | 18回試行 | 97到着 | 225.6 s |
| full jitter、run 1 | 324 | 224 | 5回試行 | 32到着 | 2.2 s |
| full jitter、run 2 | 313 | 213 | 6回試行 | 31到着 | 2.3 s |
| full jitter、run 3 | 318 | 218 | 6回試行 | 25到着 | 1.8 s |
この表には2つのことがあり、重要なのは2つ目です。
1つ目は中央値です。226秒 対 2.2秒。約100倍で、リクエスト数は半分未満です。最も混んだリトライ窓が理由を示しています。jitter なしでは、100クライアント中最大97が同じ 50 ms の枠内に到着しました。サーバーが持てるのは3つなので、94が拒否され、一緒に眠り、同期したまま、より長い待ち時間で同じことを繰り返します。jitter ありでは、同じ100が同じ窓に30程度のグループで散らばり、ほぼ即座に排出されました。
2つ目は分散です。jitter なし:65.6 s、245.7 s、225.6 s。あり:2.2、2.3、1.8。jitter のないシステムは単に性能が悪いのではありません。予測不能に性能が悪いのです。結果は、同期した100クライアントのうち最初に到着する3つを選ぶ、微視的なスケジューリング上の偶然で決まるからです。これが本番環境でのこのバグの特徴です。エンドポイントは大丈夫、大丈夫、大丈夫、そして突然4分かかる。あなたの変更では説明がつきません。
そして最も安いリトライは、発生しないリトライです。プロバイダーの前にconcurrency gateを置きます — 実行中のリクエストが N を超えないようにするカウンターです —。すると、74リクエストと7.1秒を必要とした同じ20クライアントはこう振る舞います。
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms20リクエストで20の答え、拒否はゼロ、8倍速い。リトライは謝罪です。gate は謝らずに済むことです。
Retry-After は提案ではなく下限
セクション「Retry-After は提案ではなく下限」へのリンクプロバイダーが 429 を返すとき、通常はどれだけ待つべきかを Retry-After ヘッダーで伝えます。3 その数字は助言ではありません。このやり取りの中で、自分の窓がいつリセットされるかを知っている唯一の当事者はプロバイダーです。
だから待ち時間は2つのうち大きい方です。Retry-After より短くしてはいけませんし、自分の backoff より短くしてもいけません。ヘッダーが教えてくれるのは、リミッターがあなたを許す時刻であって、サーバーに余裕ができる時刻ではないからです。
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); 20クライアント実行で最も運の悪かったクライアントのトレースを見ると、ヘッダーが仕事をしていることがわかります。最初の4回の backoff 抽選はすべて1秒未満で、その4つすべてが上書きされました。
t+ 26ms attempt 0 HTTP 429 -> sleep 1000 ms
t+ 1032ms attempt 1 HTTP 429 -> sleep 1000 ms
t+ 2034ms attempt 2 HTTP 429 -> sleep 1000 ms
t+ 3046ms attempt 3 HTTP 429 -> sleep 1000 ms
t+ 4047ms attempt 4 HTTP 429 -> sleep 2782 ms
t+ 6852ms attempt 5 HTTP 200 -> sleep 0 ms実務上の注意が2つあります。Retry-After は秒数ではなく HTTP 日付の場合があるため、両方をパースしてください。そしてプロバイダーは同時に2つの軸でレート制限します — 1分あたりのリクエスト数と、1分あたりの tokens です。だから長い prompts は、文書化されたリクエスト上限よりはるか下で拒否されます。どちらの場合もヘッダーは同じに見えますが、修正は同じではありません。
誰も選んでいないタイムアウト
セクション「誰も選んでいないタイムアウト」へのリンクモックプロバイダーに /hang を頼みます。接続を受け入れ、その後は何もしません。ヘッダーなし、ボディなし、close なし。これは珍しいことではありません。背後のプロセスがソケットを閉じずに死んだとき、ロードバランサーがすることです。
2つのクライアント、違いは1つ。
AbortSignal.timeout(5s) gave up after 5.0 s (TimeoutError: The operation was aborted due to timeout)
no timeout gave up after 300.8 s (TypeError: fetch failed)
cause: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUT300秒。 5分間、ソケットが開いたまま、リクエスト枠が占有され、ユーザーはスピナーを見つめ、最後に何が起きたのか何も語らない一般的な TypeError で終わります。この数値はバグではありません。Node のデフォルトのヘッダータイムアウトです。汎用 HTTP クライアントとしては妥当で、ユーザー向けリクエストとしては壊滅的です。どのランタイムにもこのようなデフォルトがあり、ほとんどの人は調べません。そして自分の値を知る唯一の方法は、いまやったように、意図的にソケットをハングさせることです。
だから:すべての外向きリクエストには、あなたが選んだ明示的な期限を付ける。
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});ストリーミング呼び出しでは、1つの期限では足りません。失敗が2種類あるからです。1つ目はストリームが一度も開かないことです。イベントがまったく届かず、10秒から30秒が適切です。2つ目はストリームが開いた後に止まることです。tokens は流れ、それから永遠に止まり、ソケットは健全なままです。総時間のタイムアウトでは、停止したストリームと、長いが正しい回答を見分けられません。必要なのはidle timeoutです。すべてのイベントでリセットされ、たとえば15秒間何も届かないときだけ発火するタイマーです。
キャンセルは、同じ仕組みを人に向けたものです。AbortSignal.timeout と、ユーザーが Stop を押すことは、どちらも AbortError として到着します。だからそれらを組み合わせ、どちらが発火したかを記録します。
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();中止が重要なのは、整理整頓以上の理由があります。あなたが聞いていない間にも、tokens は生成され、課金されています。第16章はそれに価格を付けます。
何なら安全にリトライできるのか
セクション「何なら安全にリトライできるのか」へのリンク次は、時間ではなくお金がかかる失敗です。クライアント側でリクエストがタイムアウトし、当然の動きとしてもう一度送ります。しかしタイムアウトは、サーバーがそれを受け取ったかどうかについて何も教えてくれません。非常によくあることに、サーバーは受け取っていて、まだ作業中です。
測定します。モックプロバイダーは回答に 780 ms 必要です。クライアントは 300 ms で諦め、リトライします。サーバーは実際に何個の回答を生成したかを数えます。つまり、請求するであろう数です。
idempotency-key: no attempt 0: TimeoutError after 300 ms | attempt 1: TimeoutError after 300 ms
answers generated (and billed): 2
idempotency-key: yes attempt 0: TimeoutError after 300 ms | attempt 1: HTTP 200 (replay) id=cmpl_1
answers generated (and billed): 1キーなし:完全な生成が2回、2回分の支払い、そしてクライアントはそのどちらも受け取りませんでした。キーあり:サーバーは2回目のリクエストを同じリクエストとして認識し、すでに生成した答えを即座に返しました。つまり、リトライは二重課金を避け、最終的に成功した試行にもなりました。
idempotency key は、論理的な操作ごとに生成する一意な文字列です。試行ごとではありません。その操作のすべてのリトライで変更せずに送ります。サーバーはそのキーに対して結果を保存し、再生します。支払い API が同じ理由で使う仕組みです。4
async function send(url: string, payload: unknown) {
const key = crypto.randomUUID(); // once per turn, not per attempt
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(payload),
headers: { "content-type": "application/json", "idempotency-key": key },
signal: AbortSignal.timeout(20_000),
});
if (res.ok) return res;
if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
await sleep(backoffFull(attempt));
}
throw new Error("out of attempts");
}正直な制限が2つあります。すべてのプロバイダーが completions で idempotency key をサポートしているわけではありません。そしてエンドポイントが idempotent でない場合、すでに実行された可能性がある POST の正しいリトライ回数はゼロです。また、途中で失敗したストリームは一般には再生できません。再開して再び支払うか、部分テキストを保持して未完了とマークするかです。あなたのプロダクトがどちらをするかは、ネットワーク上の判断ではなくプロダクト上の判断であり、意図して決める価値があります。
継ぎ目を閉じる
セクション「継ぎ目を閉じる」へのリンクこの章で書いたクライアントは、ポートの向こうに何があるかを知りません。base URL を商用プロバイダーに向ければ、1兆パラメーターのモデルから tokens をストリーミングします。第10章で pretrain したモデルを、第13章の算術にもとづいて構築したサーバーで提供し、その KV cache と量子化済み重みを使うなら、同じコードが変更なしに、あなたが作ったモデルから tokens をストリーミングします。
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; この1行が、このコースの継ぎ目です。その片側には最初の13章が作ったものがあり、もう片側には次の16章が作るものがあります。境界がきれいなのは、契約が HTTP と SSE であり、どちらの側も相手についてそれ以外を知らないからです。
越えることで何を失ったかに気づく価値があります。商用エンドポイントの向こうでは、あなたは重みも、sampling 実装も、話しているバージョンも、それが今朝変わったかどうかも制御できません。制御できるのは契約です。送るメッセージ、設定する期限、区別するコード、そして何も返ってこないときに何をするか。これは第5章で持っていた表面より小さく、残りのすべての章はそれをうまく使うことについてです。
次に向かう場所
セクション「次に向かう場所」へのリンクあなたはいま、ストリーミングし、時間どおりに諦め、正しいものをリトライし、間違ったものは決してリトライしないクライアントを持っています。それが送る内容は、まだあなたが入力したものそのままです。
第15章はその内容についてであり、規律を伴います。インターネットには prompting の助言があふれています。モデルにチップを提示する、脅す、深呼吸するように言う。しかし、そのほとんどに測定は付いてきません。そうした技法の中には出力を大きく動かすものも、まったく動かさないものもあり、少なくとも1つは分類タスクを悪化させ、さらに tokens を多く消費します。どれがどれかは読んでも明らかではなく、議論で決まるものでもありません。
だから次章ではベンチを作ります。答えがわかっている60ケース、同じ prompt の4つのバリアント、あなたがいま書いたクライアントを通じた並列実行、第4章の信頼区間付きの集計 — なぜなら20ケースで4バリアントを比べても、何も区別できないからです。章全体を支配する一文はこれです。prompt は議論するものではなく、測るものです。
Sources and method
セクション「Sources and method」へのリンク上のすべての数値は、Node 22 をループバックインターフェイス上で動かしたモックプロバイダーから来ています。そのためレイテンシーは、実ネットワークよりきれいです。これは意図的です。測定している失敗のどれもネットワークによって引き起こされていません。そして、再起動できる敵対的なサーバーは、お金を払わなければならず壊すこともできない本物のサーバーより、よく教えてくれます。
参考文献
セクション「参考文献」へのリンク-
Server-Sent Events, WHATWG HTML Living Standard, section 9.2。ワイヤーフォーマット —
data:フィールド、空行で区切られたイベント、id:とretry:— はそこで定義されており、EventSourceインターフェイスも同様です。EventSourceはリクエストボディやカスタムヘッダーを送れないため、すべての LLM クライアントはそれを使わず、fetchの上で手作業でフォーマットをパースします。 ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015)。上で使った「full jitter」定式化の出典であり、素朴な版がなぜクライアントを同期させるのかを示すシミュレーションがあります。キューに入れるのではなく負荷を落とすべきだという対応する議論は、Beyer, Jones, Petoff and Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016) の Handling Overload 章です。 ↩
-
Fielding, R., Nottingham, M. and Reschke, J. (eds.), HTTP Semantics, RFC 9110, section 15 はステータスコードのクラスを定義しています。Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4 は 429 Too Many Requests を定義しています。
Retry-Afterは RFC 9110 section 10.2.3 であり、秒数または HTTP 日付のいずれかを受け入れます。 ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, read 7 September 2026 — 契約を最も明確に述べています。論理操作ごとに1つのキー、保存された結果の再生、最初の試行がまだ実行中なら conflict を返すこと。そしてこのパターンはプロバイダーに依存しません。ここで使ったリクエストとイベントの形についての規範的参照は、ストリーミング、エラーコード、レート制限についてのdevelopers.openai.com/api/reference/resources/chatと、Messages API についてのplatform.claude.com/docs/en/api/messagesです。ai-sdk.dev/docsは、同じ懸念をライブラリで包んだ最良の実例です。すべて同日に読んでいます。 ↩