你的第一次生产级 LLM 调用:流式传输、重试与超时
构建一个会“骗”你的 provider:429、挂起 socket、半截 stream。测量你的 client 如何应对。full jitter:2.2 秒对 226 秒。
本页内容
第 13 章以一块秒表和一个你能亲手触碰的 model 结束。权重在你的内存里,KV cache 由你决定启用或禁用,而最终得到的那个数字——time to first token——是你的硬件属性。
现在把那个 model 放到一个端口后面。每个产品都是这么做的。然后再读同一个数字。它仍然是 time to first token,但它不再是你能控制的任何东西的属性。它现在包含 TLS 握手、provider 里的队列、限流器,以及根本没有任何 token 到达的可能性。
最后这一句就是本章。你将要写的代码不计算任何东西。它打开连接、等待、解析到达的内容、在没有任何内容到达时决定做什么、在到达的是错误时再次决定,并在用户改变主意时取消自己。每一项都是关于随时间变化的状态的决定,而每一项都有一个会被上线、并且会花钱的错误答案。
问题的形状如下,本章会把它全部测出来:
| 发生了什么 | 粗心的 client 会做什么 | 代价是什么 |
|---|---|---|
| server 接受了 socket,但从不回复 | 一直等 | Node 自己放弃前要等 300.8 s |
| key 错了(401) | 重试五次 | 延迟 6,325 ms,然后还是同一个 401 |
| 一百个 client 同时撞上速率限制 | 全部按同一个时间表重试 | 226 s 才排空,而不是 2.2 s |
| 请求超时后被重新发送 | 重新发送它 | provider 生成——并计费——两次答案 |
| 连接在答案中途断开 | 显示不完整文本 | 和一个正确但很短的答案无法区分 |
这些都不是建模问题。它们全部存在于每个 LLM 产品最开始的一百行代码里。
为什么本章要换语言
链接到此部分:为什么本章要换语言再读一遍那张表,问问它描述的是哪种程序。它要让一个连接保持打开四十秒。它必须能被按钮取消。它会累积一个部分答案:可以显示,但不能保存。它运行在一个 server 进程里,或一个 edge worker 上,挨着渲染答案的东西,握着一个 socket。
那不是 notebook。不是说 Python 做不到——它可以,而且人们也在这么做——而是前十三章构建的一切属于另一类东西。第 1 到第 13 章手里拿的是权重、梯度、logits 和 tokenizer 字节。从这里开始,代码手里拿的是一个连接、一次重试、一次取消、累积状态,以及稍后还会出现的权限 prompt。课程会在对象发生变化的那条接缝上,精准地换语言。
所以规则只写一次:
如果代码手里拿着权重、梯度、logits 或 tokenizer 字节,它就是 Python。如果它握着连接、重试、取消、累积状态并请求权限,它就是 TypeScript。
这条接缝只有一条,就落在这里,第 13 章和第 14 章之间。三个独立标准把它放在这里。
一:生态系统,按数量算。 本课程左半部分引用的一切都是 Python;在为这份大纲审计过的十二门课程里,没有一个用其他语言教授 backpropagation 的先例:micrograd(17.4K stars)、nanoGPT(62.8K)、nanochat(57.8K)、minbpe(10.7K)、PyTorch(102.8K)、transformers(164.9K)。用 TypeScript 写第 5 章会切断与这些来源的连接,而对一个为了被引用、不是为了排名而存在的章节来说,这些连接就是一半价值。到了这一边,算术反过来了:Vercel 的 ai 包每月下载量 89.4M,并且直接发布了那个东西本身——一个 tool-calling agent 循环,以 ToolLoopAgent 导出——所以本课程在第 23 章触达的概念,其参考实现就在 TypeScript 里,尽管正如那章测得的,没人同意它该叫什么;Mastra 有 27.7K stars;而 Anthropic 从同一份规范生成的 SDK,在 TypeScript 里声明 202 个 endpoint,在 Python 里是 201 个——这是同等地位,不是礼貌性移植。
二:MCP 的规范来源。 Model Context Protocol 规范的 schema 是一个 schema.ts 文件。用另一种语言教授第 26 章里的 protocol,就等于教授其创始文件的译本。
三:搜索需求,并修正一个显而易见的猜测。 machine learning python 是互联网上最饱和的短语;ai agent typescript 也有自己健康的长尾。但“the MCP ecosystem is mostly TypeScript”只有在某些计数方式下才成立:官方 registry 列出 npm 上有 8,275 个 server,PyPI 上有 3,603 个;而按下载量,Python 胜出——mcp 每月 287M,加上 fastmcp 的 72M,对比 @modelcontextprotocol/sdk 的 195M。MCP 是这里唯一真正双语的领域,这就是为什么第 27 章把同一个 server 写两遍,而不是假装它只属于一种语言。
查看详情
五个明示的例外,让规则成为规则,而不是口号。
第 17、20 和 29 章会带一个第二个 Python 面板:实现 top-p sampling 需要你手里有概率向量,而 HTTP API 永远不会给你;诚实地给一次 fine-tune 定价意味着真的跑一次,而 LoRA adapter 只是十几行 nn.Module;并且 lm-eval-harness、HELM、SWE-bench 和 τ-bench 都是 Python,所以用 TypeScript 写 evaluation harness 就会成为 backpropagation 错误的镜像。第 27 章是双语的,原因就是上面测出来的那一个。第 28 章是 Markdown,因为 agent skill 就是 一个 SKILL.md 文件;给它一种编程语言,就意味着没有理解这个格式。
十三章 Python 没有被丢弃。端口另一侧的东西正是它们构建出来的,而本章最后一节会把一个 client 连到它上面。
一个你可以弄坏的 provider
链接到此部分:一个你可以弄坏的 provider你无法用真实 provider 学会这些东西。你不能要求它在某个指定时刻给你一个 429,或给你一个接受连接却永不响应的 socket,或给你一个在单词中间停止的 stream——而且每一次实验你都要付费,可真正有意思的实验恰恰是那些要跑一百遍的实验。
所以课程后半部分的第一个程序不是 client。它是一个敌意 server:四十行朴素的 Node,讲着和 chat completions endpoint 一样的 wire protocol,并按需作恶。本章里的每一个数字都来自它。
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);四种敌意行为,每种一行:/hang 接受 socket 但永不写入;/401 拒绝 key;当已经有三个请求在处理中时,容量检查会产生一个真正的 429,并带一个真正的 Retry-After header;?cut=N 则在答案中途放弃,要么 reset socket,要么——用 &how=close——有序关闭它,而事实证明这非常重要。其余部分是一个真正的 Server-Sent Events stream:每条 data: line 一个 JSON object,event 之间有一个空行,最后是字符串 [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]请求体,以及永远不离开 server 的 key
链接到此部分:请求体,以及永远不离开 server 的 key一个 chat 请求是一组 message,每条都有一个 role。这个列表就是 model 的全部状态:调用之间没有记忆,而你想让 model 知道的一切,都必须在这一次发送的 array 里。第 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 不是装饰。在 model 看到一个 token 之前,它们会被渲染进第 11 章里的 chat template;这就是为什么发送错误的 role 会悄悄降低答案质量,而不是抛出错误。
一条没有例外的规则:API key 永远不传到 client。不能放在给浏览器用的环境变量前缀里,不能放在 build-time 常量里,也不能“临时”这么做。一个进了 bundle 的 key,几天内就会变成别人账单上的 key。浏览器和你的 server 通话,你的 server 持有 key 并和 provider 通话——而因为你的 server 位于中间,它也是唯一能够计量每个用户花了多少的地方,第 16 章的记账也必须住在那里。
同一个问题,问三次
链接到此部分:同一个问题,问三次现在进入本章的核心实验。同一个问题,一个 mock provider 以每个 token 60 ms 的速度生成十三个 token,用三种方式询问。
第一,不使用 streaming。 client 发送请求,然后等待整个 JSON body。
blocking first visible = 791 ms complete = 791 ms finish_reason = stop两个数字一样,而这就是全部问题。791 ms 里,用户看到的是 spinner,没有一个词能更早出现——server 已经逐 byte 拥有答案,却选择什么也不说。
第二,使用 streaming。 同一个 server,同一个答案,同样的总工作量。差异只是一个 parser。
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);
}
}
}
}其中有三个细节是承重结构,而大多数第一次尝试会把三个都跳过。buffer 的存在,是因为 network chunk 和 event 没有任何对应关系:一次 read() 可能返回半个 event,也可能返回两个半。{ stream: true } flag 的存在,是因为一个多字节 UTF-8 字符可能被拆到两个 chunk 里;没有它,带重音的字母会随机变成 replacement character。event 之间用空行分隔,而不是换行,所以 loop 查找的是 \n\n。
streaming first visible = 65 ms complete = 793 ms finish_reason = stop到第一个词快了十二倍,到最后一个词却慢了两毫秒。Streaming 没有让任何东西变快。它改变的是用户在同样的 790 ms 里做什么:阅读,而不是等待。这就是全部收益,它巨大无比,也是每个 chat 产品都使用 streaming 的原因。
第三,同时有二十个 client。 mock provider 一次服务三个请求。发起二十个:
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: true二十个答案,七十四个请求,五十四次拒绝。没有人丢东西,每个 client 都拿到同样的文本,唯一可见的代价是时间。这是 retry policy 正常工作的样子。本章剩下的部分讨论它可能以哪三种方式失败。
finish_reason,以及两个看起来一样的结尾
链接到此部分:finish_reason,以及两个看起来一样的结尾在失败之前,先看几乎每个人第一次都会忽略的字段。每个 stream 都以一个携带 finish_reason 的 event 结束。stop 表示 model 认为自己完成了。length 表示它撞到了 token 上限,所以答案在句子中间被截断,而且这不是 model 的错。后面的章节还会加入 tool_calls(第 18 章)和内容过滤器。
现在看两个 naive client 无法区分的结尾。同一个 server,同样的 delay,一个被 max_tokens 截断,另一个是在五个 token 后干净地关闭连接:
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"仔细看前两行。相同文本。相同 chunk 数。两者都没有 exception。 for await loop 两次都正常结束,因为从 reader 的视角看,body 结束了,而 body 能做的也只有这些。整个观察里唯一的区别是:一个携带 finish_reason: "length",另一个什么都没有。
所以规则不是“streaming 时 catch error”。规则是:
一个没有
finish_reason就结束的 stream,并没有结束。它停住了。
永远把缺失的 finish_reason 当作失败处理,也永远不要把那段文本持久化为已完成答案。第三行展示了更容易的情况——被 destroy 的 socket 确实会 throw,而且它还会丢掉正在传输中的那个 chunk,所以文本比上面两行少一个词。
五个 status code,是五种不同的问题
链接到此部分:五个 status code,是五种不同的问题新产品最昂贵的习惯,就是用一个 catch block 处理 provider 返回的一切。这些 code 不是“失败了”的不同变体。它们是五条指令,而且其中四条互相矛盾。
| status | 它意味着什么 | 该做什么 | 等吗? |
|---|---|---|---|
| 400 | 你的请求格式错误——坏 JSON、未知字段、context 太长 | 修代码 | 永远不等 |
| 401 | key 错误、缺失或已被撤销 | 修部署 | 永远不等 |
| 429 | rate limit:每分钟请求太多,或 token 太多 | 重试 | Retry-After,然后 backoff |
| 500 | provider 坏了 | 重试 | backoff |
| 503 | provider 过载——它还在线,但满了 | 重试 | backoff,并 shed load |
关键的线划在 4xx 和其余 code 之间。如果你把 400 或 401 发送一千次,它每次都会返回完全相同的答案,因为两端在两次尝试之间都没有任何改变。重试它不是谨慎,而是绕了几个弯的延迟。测量如下:一个 client 做六次尝试——五次 retry 加 exponential backoff——另一个先读 code。
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401六秒 spinner,才抵达一个四毫秒就已经可用的答案。而且这还是温和版本:产品里的 retry 通常是嵌套的——一个会 retry 的 HTTP client,包在一个会 retry 的 job runner 里,再包在一个有自己 redelivery 的 queue 里——于是六秒变成六分钟,一个永久坏掉的部署看起来像是慢。
分诊只有九行,而且应该只放在一个地方:
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
}你的清单上还要加两个:402,有些 provider 用它表示“你的 credit 用完了”,它需要的是一个带购买链接的页面,而不是 retry;以及 529 或 vendor-specific 等价物,它们的行为像 503。
Backoff,以及 jitter 到底带来什么
链接到此部分:Backoff,以及 jitter 到底带来什么重试很容易。何时重试,才是有可测正确答案的部分。
Exponential backoff 是标准做法:等待一个 base delay,每失败一次就翻倍,到上限停止。它存在的原因是:如果刚刚失败的 client 立刻回来,过载的 server 会变得更糟。
问题是,所有人都从同一个起点开始翻倍。如果一百个 client 在同一时刻撞上限制——它们会的,因为这就是流量尖峰的样子——那么一百个全都等 200 ms,一百个一起 retry,一百个一起失败,一百个再等 400 ms。retry schedule 把它们同步了。这就是惊群,而随机性就是修复办法。2
这个单一改动——从区间里均匀随机挑选,而不是取上端点——叫做 full jitter。它只是一次 Math.random() 调用,而且值得测量,而不是相信:
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); 一百个 client,一个一次服务三个请求的 server,其他一切相同,每种跑三次:
| HTTP requests | rejections | 最差 client | 最忙 50 ms 窗口 | wall clock | |
|---|---|---|---|---|---|
| no jitter, run 1 | 491 | 391 | 10 tries | 46 arrivals | 65.6 s |
| no jitter, run 2 | 780 | 680 | 19 tries | 72 arrivals | 245.7 s |
| no jitter, run 3 | 770 | 670 | 18 tries | 97 arrivals | 225.6 s |
| full jitter, run 1 | 324 | 224 | 5 tries | 32 arrivals | 2.2 s |
| full jitter, run 2 | 313 | 213 | 6 tries | 31 arrivals | 2.3 s |
| full jitter, run 3 | 318 | 218 | 6 tries | 25 arrivals | 1.8 s |
这张表里有两件事,第二件更重要。
第一是中位数:226 秒对 2.2 秒,大约一百倍,而且请求数还不到一半。最忙的 retry 窗口说明了原因。没有 jitter 时,一百个 client 里最多有 97 个挤在同一个 50 毫秒 slot 里到达;server 只能处理三个,于是 94 个被拒绝,并一起睡去,仍然保持同步,然后带着更长等待再来一次。有 jitter 时,同样的一百个 client 分散到同样的窗口中,每组大约三十个,并且几乎立刻排空。
第二是方差。没有 jitter:65.6 s、245.7 s、225.6 s。有 jitter:2.2、2.3、1.8。一个没有 jitter 的系统不只是表现糟糕,它表现得不可预测,因为结果由微观级的调度意外决定:在一百个同步的 client 里,哪三个先到。这就是这个 bug 在生产环境里的特征:一个 endpoint 一直好、好、好,然后突然要四分钟,而你做过的任何改动都解释不了。
而最便宜的 retry 是从未发生的 retry。在 provider 前面放一个并发门——一个计数器,永远不允许超过 N 个请求同时 in flight——同样那二十个原本需要 74 个请求和 7.1 秒的 client,会变成这样:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms二十个请求换二十个答案,零次拒绝,快八倍。retry 是道歉;gate 是不需要道歉。
Retry-After 是下限,不是建议
链接到此部分:Retry-After 是下限,不是建议当 provider 返回 429 时,它通常会在 Retry-After header 里告诉你要等多久。3 这个数字不是建议:provider 是这场交换里唯一知道自己的 window 何时 reset 的一方。
所以等待时间取两者中的较大值:永远不少于 Retry-After,也永远不少于你自己的 backoff,因为 header 告诉你 limiter 何时原谅你,而不是 server 何时有空。
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); 二十个 client 那次运行里,最倒霉 client 的 trace 显示 header 正在发挥作用。它前四次 backoff 抽样都低于一秒,而四次都被覆盖了:
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两个实用注记。Retry-After 可能是一个 HTTP date,而不是秒数,所以两者都要 parse。而且 provider 会同时在两个轴上 rate-limit——每分钟请求数和每分钟 token 数——这就是为什么长 prompt 会在远低于文档所写请求限制的位置被拒绝。两种情况下 header 看起来一样;修复办法不一样。
没有人选择过的 timeout
链接到此部分:没有人选择过的 timeout向 mock provider 请求 /hang。它接受连接,然后什么也不做:没有 header,没有 body,没有 close。这并不罕见——当负载均衡器背后的进程已经死掉却没有关闭 socket 时,它就是这样。
两个 client,一个区别:
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_TIMEOUT三百秒。 一个 socket 开着五分钟,一个请求 slot 被占用,一个用户盯着 spinner,最后得到一个 generic 的 TypeError,完全没有说明发生了什么。这个数字不是 bug:它是 Node 的默认 headers timeout,对通用 HTTP client 来说合理,对面向用户的请求来说灾难性。每个 runtime 都有这样的默认值,大多数人从不查它;找出你自己的唯一办法,就是像我们刚才那样故意挂起一个 socket。
所以:每个 outgoing request 都要有一个由你选择的显式 deadline。
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});对于 streaming call,一个 deadline 不够,因为有两种不同的失败。第一种是stream 从未打开:一个 event 都没到,十到三十秒是合理的。第二种是stream 打开后停滞:token 流过来,然后永远停止,而 socket 仍然健康。总时长 timeout 无法把停滞的 stream 和一个很长但正确的答案区分开,所以你真正想要的是一个 idle timeout——每个 event 都 reset 的 timer,比如十五秒什么都没到才触发。
Cancellation 是同一套机制指向一个人。AbortSignal.timeout 和用户按下 Stop,都会以 AbortError 的形式到达,所以把它们组合起来,并记录是哪一个触发了:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort 的意义不只是整洁:在你不再监听时,token 仍然在生成并计费。第 16 章会给它标上价格。
什么可以安全 retry
链接到此部分:什么可以安全 retry现在看一个花钱而不是花时间的失败。一个请求在 client 侧 timeout,显而易见的动作是再发一次——但 timeout 完全没有告诉你 server 是否收到了它。很多时候它收到了,而且仍在工作。
测量如下。mock provider 需要 780 ms 才能生成答案。client 在 300 ms 后放弃并 retry。server 统计自己实际生成了多少个答案,也就是它会计费的东西:
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没有 key:两次完整 generation,付两次费,而 client 一个都没收到。有 key:server 认出第二个请求和第一个是同一个请求,并立即用已经生成好的答案回复,所以 retry 既避免了双重收费,也成为了最终成功的那次尝试。
Idempotency key 是你为每个逻辑操作生成的唯一字符串——不是为每次 attempt 生成——并在该操作的每次 retry 中原样发送。server 会把结果存到这个 key 下并 replay。支付 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");
}两个诚实的限制。不是每个 provider 都在 completions 上支持 idempotency key;而在 endpoint 不是 idempotent 的地方,一个可能已经运行过的 POST,正确的 retry 次数是零。并且,一个中途失败的 stream 在一般情况下不可 replay:你要么重新开始并再付一次钱,要么保留部分文本并标记为 incomplete。你的产品选择哪一种,是产品决定,不是网络决定,而且值得有意为之。
合上这条接缝
链接到此部分:合上这条接缝本章写出的 client 完全不知道端口后面是什么。把它的 base URL 指向一个商业 provider,它就从一个万亿参数的 model streaming tokens。把它指向一个基于第 13 章算术构建的 server——服务你在第 10 章里预训练的 model,带着它的 KV cache 和量化后的权重——同一份代码,无需改动,就会从你亲手构建的 model streaming tokens。
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; 这一行就是这门课程的接缝。它的一侧是前十三章构建的东西;另一侧是接下来十六章要构建的东西。边界很干净,因为契约是 HTTP 和 SSE,而两边除此之外对彼此一无所知。
值得注意的是,跨过去之后你失去了什么。在商业 endpoint 背后,你既不能控制权重,也不能控制 sampling 实现,不能控制你正在对话的版本,也不能控制它今天早上是否改过。你能控制的是契约:你发送的 message、你设置的 deadline、你区分的 code,以及什么都没有回来时你要做什么。这比第 5 章里你拥有的表面积更小,而剩下每一章都在讲如何用好它。
下一步去哪里
链接到此部分:下一步去哪里你现在有了一个 client:它能 streaming,能按时放弃,会 retry 正确的东西,也从不 retry 错误的东西。它发送的内容仍然只是你输入的那些。
第 15 章讨论的就是那些内容,而且它会带来一种纪律。互联网上到处都是 prompting 建议——给 model 小费、威胁它、让它深呼吸——但几乎没有任何建议附带测量。其中有些技巧会大幅移动输出,有些完全不会,而至少有一个会让分类任务变得更差,同时花掉更多 tokens。哪一个是哪一个,光读它们看不出来,争论也无法裁定。
所以下一章会构建一个 bench:六十个带已知答案的 case,同一个 prompt 的四种变体,通过你刚刚写好的这个 client 并行运行,并用第 4 章里的 confidence interval 制成表格——因为二十个 case 上的四种变体什么都区分不了。一句话统领整章:prompt 是被测量的,不是被辩论的。
来源与方法
链接到此部分:来源与方法上面的每个数字都来自 mock provider,在 Node 22 上通过 loopback interface 测得,所以 latency 比任何真实网络都更干净。这是有意为之:被测量的失败没有一个是由网络导致的,而一个你可以重启的敌意 server,比一个你必须付费且无法弄坏的真实 server 更适合教学。
参考资料
链接到此部分:参考资料-
Server-Sent Events,WHATWG HTML Living Standard,第 9.2 节。wire format——
data:字段、用空行分隔的 event、id:和retry:——在那里定义,同时定义的还有EventSourceinterface。EventSource不能发送 request body 或 custom headers,这就是为什么每个 LLM client 都在fetch之上手写 parser,而不是使用它。 ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015)。上文使用的“full jitter”公式来源,里面的模拟展示了为什么 naive version 会同步 client。关于 shed load 而不是排队的配套论证,见 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,第 15 节定义 status code classes;Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012),第 4 节定义 429 Too Many Requests。
Retry-After是 RFC 9110 第 10.2.3 节,接受秒数或 HTTP date。 ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests,2026 年 9 月 7 日读取——这是对契约最清晰的表述:每个逻辑操作一个 key,存储并 replay 结果,在第一次 attempt 仍在 in flight 时返回 conflict——而且这个模式与 provider 无关。这里使用的 request 和 event 形状,其规范参考是developers.openai.com/api/reference/resources/chat(streaming、error codes 和 rate limits)以及platform.claude.com/docs/en/api/messages(Messages API);ai-sdk.dev/docs是同类关注点被封装进 library 的最佳完整示例。全部同日读取。 ↩