跳至内容
14/30第 14 章,共 30 章

你的第一次生产级 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 学会这些东西。你不能要求它在某个指定时刻给你一个 429,或给你一个接受连接却永不响应的 socket,或给你一个在单词中间停止的 stream——而且每一次实验你都要付费,可真正有意思的实验恰恰是那些要跑一百遍的实验。

所以课程后半部分的第一个程序不是 client。它是一个敌意 server:四十行朴素的 Node,讲着和 chat completions endpoint 一样的 wire protocol,并按需作恶。本章里的每一个数字都来自它。

mock-provider.mjsJS
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

运行它,章节剩下的部分就是测量。

terminalBASH
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"
TEXT
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 章讨论它会花多少钱,所以这里先只看形状。

call.tsTS
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。

TEXT
blocking   first visible =  791 ms   complete =  791 ms   finish_reason = stop

两个数字一样,而这就是全部问题。791 ms 里,用户看到的是 spinner,没有一个词能更早出现——server 已经逐 byte 拥有答案,却选择什么也不说。

第二,使用 streaming。 同一个 server,同一个答案,同样的总工作量。差异只是一个 parser。

sse.tsTS
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

TEXT
streaming  first visible =   65 ms   complete =  793 ms   finish_reason = stop

到第一个词快了十二倍,到最后一个词却慢了两毫秒。Streaming 没有让任何东西变快。它改变的是用户在同样的 790 ms 里做什么:阅读,而不是等待。这就是全部收益,它巨大无比,也是每个 chat 产品都使用 streaming 的原因。

第三,同时有二十个 client。 mock provider 一次服务三个请求。发起二十个:

TEXT
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 后干净地关闭连接:

TEXT
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 太长修代码永远不等
401key 错误、缺失或已被撤销修部署永远不等
429rate limit:每分钟请求太多,或 token 太多重试Retry-After,然后 backoff
500provider 坏了重试backoff
503provider 过载——它还在线,但满了重试backoff,并 shed load

关键的线划在 4xx 和其余 code 之间。如果你把 400 或 401 发送一千次,它每次都会返回完全相同的答案,因为两端在两次尝试之间都没有任何改变。重试它不是谨慎,而是绕了几个弯的延迟。测量如下:一个 client 做六次尝试——五次 retry 加 exponential backoff——另一个先读 code。

TEXT
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 里——于是六秒变成六分钟,一个永久坏掉的部署看起来像是慢。

分诊只有九行,而且应该只放在一个地方:

classify.tsTS
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。

重试很容易。何时重试,才是有可测正确答案的部分。

Exponential backoff 是标准做法:等待一个 base delay,每失败一次就翻倍,到上限停止。它存在的原因是:如果刚刚失败的 client 立刻回来,过载的 server 会变得更糟。

问题是,所有人都从同一个起点开始翻倍。如果一百个 client 在同一时刻撞上限制——它们会的,因为这就是流量尖峰的样子——那么一百个全都等 200 ms,一百个一起 retry,一百个一起失败,一百个再等 400 ms。retry schedule 把它们同步了。这就是惊群,而随机性就是修复办法。2

sleep=random(0, min(cap, base2n))\text{sleep} = \mathrm{random}\big(0,\ \min(\text{cap},\ \text{base} \cdot 2^{\,n})\big)

这个单一改动——从区间里均匀随机挑选,而不是取上端点——叫做 full jitter。它只是一次 Math.random() 调用,而且值得测量,而不是相信:

backoff.tsTS
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 requestsrejections最差 client最忙 50 ms 窗口wall clock
no jitter, run 149139110 tries46 arrivals65.6 s
no jitter, run 278068019 tries72 arrivals245.7 s
no jitter, run 377067018 tries97 arrivals225.6 s
full jitter, run 13242245 tries32 arrivals2.2 s
full jitter, run 23132136 tries31 arrivals2.3 s
full jitter, run 33182186 tries25 arrivals1.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,会变成这样:

TEXT
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms

二十个请求换二十个答案,零次拒绝,快八倍。retry 是道歉;gate 是不需要道歉。

当 provider 返回 429 时,它通常会在 Retry-After header 里告诉你要等多久。3 这个数字不是建议:provider 是这场交换里唯一知道自己的 window 何时 reset 的一方。

所以等待时间取两者中的较大值:永远不少于 Retry-After,也永远不少于你自己的 backoff,因为 header 告诉你 limiter 何时原谅你,而不是 server 何时有空。

wait.tsTS
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 抽样都低于一秒,而四次都被覆盖了:

TEXT
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 看起来一样;修复办法不一样。

向 mock provider 请求 /hang。它接受连接,然后什么也不做:没有 header,没有 body,没有 close。这并不罕见——当负载均衡器背后的进程已经死掉却没有关闭 socket 时,它就是这样。

两个 client,一个区别:

TEXT
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。

deadline.tsTS
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 的形式到达,所以把它们组合起来,并记录是哪一个触发了:

cancel.tsTS
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();

Abort 的意义不只是整洁:在你不再监听时,token 仍然在生成并计费。第 16 章会给它标上价格。

现在看一个花钱而不是花时间的失败。一个请求在 client 侧 timeout,显而易见的动作是再发一次——但 timeout 完全没有告诉你 server 是否收到了它。很多时候它收到了,而且仍在工作。

测量如下。mock provider 需要 780 ms 才能生成答案。client 在 300 ms 后放弃并 retry。server 统计自己实际生成了多少个答案,也就是它会计费的东西:

TEXT
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

idempotent.tsTS
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。

switch.tsTS
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 更适合教学。

  1. Server-Sent Events,WHATWG HTML Living Standard,第 9.2 节。wire format——data: 字段、用空行分隔的 event、id:retry:——在那里定义,同时定义的还有 EventSource interface。EventSource 不能发送 request body 或 custom headers,这就是为什么每个 LLM client 都在 fetch 之上手写 parser,而不是使用它。

  2. 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 章节。

  3. 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。

  4. 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 的最佳完整示例。全部同日读取。


作者

David Vicente Campos

NeuraLIA Labs 创始人、MyRealFood 联合创始人

我是莱昂大学毕业的计算机工程师。我共同创立了 MyRealFood,并在那里作为 CTO 打造了一款数百万人用来吃得更健康的应用;我还创立了 NeuraLIA Labs,在这里我打造 AI 产品。我在本站写下一路走来所必须理解的内容,就像我希望当初有人向我讲解的那样。

了解作者更多信息

由 NeuraLIA Labs 发布。

新文章直达你的收件箱

AI 新闻、指南和产品更新——有值得你花时间阅读的内容时,我们会发一封简短邮件。

更喜欢用消息接收?同样的内容,也在这里:WhatsApp 社群 (在新标签页打开)Telegram 频道 (在新标签页打开)

课程目录

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev10 分钟阅读

Jev AI 模型为决策而生,而非写作

TypeSafe AI 的 Jev 正受到关注,因为它把软件智能视为一个概率问题:选择正确分支,附上置信度,并避免在代码只需要决策时还花钱让 LLM 写文本。

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering12 分钟阅读

Context engineering for long-horizon AI agents

Long-running agents do not fail only because the window is small. They fail when files, tool outputs and stale history crowd out the task the agent was supposed to finish.

准备好让 LIA 替你选模型了吗?

所有 AI 模型都在一处——今天就免费开始。