定位:这是本讲义为计算机系统背景较少的读者补充的前置讲,原课没有对应的一节。原课的作业要求学生用云端 LLM 的 API 跑实验(原课给每人 50 美元的 OpenAI 额度),这一篇讲清楚”调用一次 API”在工程上意味着什么,并写出后面各讲共用的调用接口。 依据说明:HTTP、SSE、指数退避与抖动是通用的工程知识(退避与抖动的分析见 Brooker 2015);关于 Claude API 的事实(默认模型、价格、不接受采样参数、思考与 effort、拒答与回退、token 计数接口、SDK 的默认重试)来自 Anthropic 的官方文档,写作时间 2026 年 10 月。模拟服务器的接口形状模仿 Anthropic Messages API,但不是它的精确复刻。全部实验是本讲义自己设计的。 配套代码 Code:
code/prereq2/(只用 Python 标准库;6 个脚本 + 2 个模块;在本机 127.0.0.1 起模拟服务器,不联网;最慢的脚本约 5 秒) 预计阅读时间:2–3 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 为什么提示工程师要懂这些
在聊天网页里用 LLM,你看不到它背后是一个网络服务。可一旦要做实验——3 个提示模板、200 道题、每道题采样 5 次,就是 3000 次调用——你马上会遇到这些问题:
- 第 1137 次调用返回了 “429 Too Many Requests”,整个脚本崩了,前面的结果没存下来。
- 跑完一看账单,比预想的多了三倍——多轮对话每轮都在重发全部历史。
- 一周后导师问:“表里这个 84.4% 是用哪个版本的提示跑出来的?” 你答不上来。
- 改了一个模板想重跑,可另外两个模板没改,也要再付一遍钱吗?
- 流式输出的中文时不时变成 ”���”。
- 模型的回答被截断了一半,你的代码却把半截 JSON 当成”模型格式错误”去处理。
这些都不是”提示写得好不好”的问题,却直接决定了你的实验结论靠不靠得住。原课的第三条学习目标是”设计实验来评估提示设计”——这一篇讲的就是做实验需要的那层基础设施。
0.2 学习目标 Learning goals
- 说出一次 LLM API 调用的完整过程,读懂原始的 HTTP 请求和响应,知道 API 是无状态的。
- 会区分”值得重试”和”不值得重试”的错误,知道 API key 应该放在哪里、不应该出现在哪里。
- 知道请求里每个字段的作用:消息与角色、系统提示、max_tokens 与 stop_reason、停止序列、温度与 seed;知道今天的真实模型在这些参数上有哪些限制。
- 理解流式输出的首字时间优势,以及接收流式数据时的字节边界问题。
- 会设计重试策略(指数退避 + 抖动 + retry-after),理解惊群效应和超时的副作用。
- 会为一次提示实验搭起记账、缓存、日志、预算这层基础设施,并知道温度 > 0 时缓存的陷阱。
0.3 本讲地图 Map
§1 一次调用的一生:客户端、服务器、无状态 ← 代码 01 演示 1
§2 HTTP、状态码与 API key ← 代码 01 演示 2–3
§3 请求里的每个字段:消息、max_tokens、停止序列、温度、系统提示 ← 代码 02
§4 流式输出:首字时间、SSE、字节边界 ← 代码 03
§5 可靠性:重试、退避、抖动、惊群、超时 ← 代码 04
§6 实验的基础设施:记账、缓存、日志、预算 ← 代码 05
§7 统一接口,以及换上真实的模型 ← 代码 06(可选)
§8 小结 §9 常见误区 §10 练习 §11 延伸阅读 §12 下一篇预告
0.4 术语 Terms
| 术语 | 意思 |
|---|---|
| 客户端 / 服务器(client / server) | 发请求的一方(你的程序)/ 处理请求并返回结果的一方(模型服务) |
| API(application programming interface) | 服务对外约定的”怎么调用我”:地址、请求格式、响应格式、错误格式 |
| 端点(endpoint) | 一个具体的地址,例如 POST /v1/messages |
| HTTP | 客户端和服务器之间传递请求和响应的文本协议 |
| 状态码(status code) | 响应开头的三位数:2xx 成功,4xx 客户端的错,5xx 服务端的错 |
| API key | 证明”这个请求是谁发的、该给谁记账”的密钥 |
| 无状态(stateless) | 服务器不记得你之前的请求;每个请求都要带上全部必要的信息 |
| 流式输出(streaming) | 模型一边生成、服务器一边把生成的部分发回来 |
| 首字时间(TTFT, time to first token) | 从发出请求到收到第一个字的时间 |
| 限速(rate limit) | 服务器规定的每分钟最多多少请求 / 多少 token;超了返回 429 |
| 退避(backoff) / 抖动(jitter) | 重试前等待一段时间 / 在等待时间里加入随机性 |
| 幂等(idempotent) | 同一个请求执行一次和执行多次效果相同 |
1. 一次调用的一生 · The life of a request
配套代码:
01_http_anatomy.py演示 1、模块mock_server.py
调用一次 LLM,从你的程序的角度看只有一行:resp = client.messages.create(...)。这一行背后发生了这些事:
你的程序 ──① 把请求写成 JSON,加上 HTTP 头 ──→ 网络 ──→ 模型服务的网关
② 检查 API key、限速、请求格式
③ 排队,把消息渲染成 token,交给模型
④ 模型逐个 token 生成回答
你的程序 ←── ⑥ 解析 JSON,取出文字和用量 ←── 网络 ←── ⑤ 把回答、停止原因、用量写成 JSON
为了不联网、不花钱也能看清楚每一步,mock_server.py 在本机起了一个模拟的 LLM API 服务器:接口的形状模仿 Anthropic 的 Messages API(POST /v1/messages、x-api-key 头、content 块、stop_reason、usage),回答由一个模拟模型生成。01 号脚本演示 1 用最底层的 socket 手写一个请求发过去,把来回的原始文字打印出来:
> POST /v1/messages HTTP/1.1
> host: 127.0.0.1:<端口>
> content-length: 145
> connection: close
> content-type: application/json
> x-api-key: sk-te…2e
>
> {"model": "sim-small", "max_tokens": 50, "system": "Answer briefly.", "messages": [{"role": "user", "content": "What is the capital of volel?"}]}
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 226
<
< {"id": "msg_000001", "type": "message", "role": "assistant", "model": "sim-small",
< "content": [{"type": "text", "text": "The answer is grorken."}],
< "stop_reason": "end_turn", "usage": {"input_tokens": 55, "output_tokens": 11}}
(API key 已经打码,响应的 JSON 为了好读重新排了版。)几点值得记住:
- HTTP 只是有格式的文字:一行请求行(方法、路径、协议版本),若干行头部(
名字: 值),一个空行,然后是请求体。官方 SDK、requests、urllib在底下做的都是拼出这样一段文字。 - 回答在
content里,而且是一个”块”的列表。这里只有一个 text 块;真实 API 里还可能有思考块、工具调用块(第 8 讲)。取文字时要遍历块、按类型挑出 text,而不是假设”第一个块就是答案”。 stop_reason和usage和回答一样重要:前者告诉你模型为什么停下(自然结束、被截断、碰到停止序列、拒答),后者是计费的依据(§3、§6)。- API 是无状态的:服务器不记得你上一次问了什么。多轮对话的”记忆”是你的程序每次把全部历史重新发过去造成的(§3.1)。
2. HTTP、状态码与 API key · HTTP, status codes, and secrets
配套代码:
01_http_anatomy.py演示 2–3
2.1 状态码:服务器怎样告诉你出了什么错
响应的第一行带一个三位数的状态码。演示 2 故意制造了 7 种情形:
| 情形 | 状态码 | 错误类型 | 值得重试? |
|---|---|---|---|
| 一切正常 | 200 | – | – |
| 没带 API key / key 写错了 | 401 | AuthError | 否 |
| 模型名写错了 | 404 | NotFoundError | 否 |
| 消息以 assistant 开头 | 400 | BadRequestError | 否 |
请求体是合法的 JSON 但不是对象(null) | 400 | BadRequestError | 否 |
| 服务器限速 | 429(带 retry-after: 1) | RateLimitError | 是 |
| 服务器内部错误 | 500 | ServerError | 是 |
规律很简单:
- 4xx 是你的错:请求本身、配置、权限有问题。原样重试一万次也还是错,要改请求。
- 429 和 5xx 是服务端的暂时状况:限速、过载、内部错误。等一等再试通常就好了。429 往往还带一个
retry-after头,告诉你至少等多久。(有的服务商还用 529 之类的状态码表示”过载”,也属于可以重试的一类。)
错误响应的正文也是 JSON({"type": "error", "error": {"type": "rate_limit_error", "message": "…"}})。HttpLLM 把状态码翻译成 llm_api.py 里的错误类型,每种错误有一个 retryable 属性。这样外层的重试代码只需要问”这个错误能不能重试”,而不必关心底下是哪一家的 API、用的什么协议。
写模拟服务器时还踩了一个坑:json.loads("null") 是合法的,返回 None,紧接着检查 "model" in body 就抛异常、服务器线程直接崩溃。合法的 JSON 不等于合法的请求——任何接收外部输入的程序都要检查类型,这一点在第 8 讲解析模型写出的工具调用参数时还会遇到。
2.2 API key 放在哪里
API key 就是你的钱包:谁拿到它,谁就能用你的额度。三条规则:
- 永远不要把 key 写进代码。代码会被提交到 git、被复制给同事、被贴进聊天里。把 key 放在环境变量里(或者专门的密钥管理工具),代码只负责去读。演示 3 里,
HttpLLM在没有传api_key时从环境变量MOCK_LLM_API_KEY读取。Anthropic 的官方 SDK 也是这样:默认读环境变量ANTHROPIC_API_KEY,或者读ant auth login登录后保存的凭证。 - 不要让 key 出现在日志和报错信息里。调试时把整个请求(含头部)打印出来,key 就留在了终端历史和日志文件里。
LoggedLLM只记录请求体(payload),不记录头部;必须显示时用redact打码(sk-te…2e)。 - 泄露了就作废。key 被提交进公开的仓库后,删掉那一行代码是不够的——git 历史里还在。要去服务商的控制台作废这个 key,再换一个新的。
3. 请求里的每个字段 · What each field does
配套代码:
02_messages_params.py、模块llm_api.py
llm_api.LLMRequest 把一次调用的输入写成一个数据类:messages、system、model、max_tokens、temperature、stop、seed,外加一个不发给模型的 metadata(记录这次调用属于哪个模板、哪道题,用于记账和日志)。这一节逐个看它们。
3.1 消息、角色与 chat 模板
messages 是一个列表,每条消息有 role(user 或 assistant)和 content。规则(取各家 API 里最严格的版本):从 user 开始,两种角色交替。系统提示放在单独的 system 字段。
在模型内部,这个列表被渲染成一段文档(第 1 讲 §9.4)。render_chat 给出一个示意性的模板:
<|system|>You are a geography assistant. Answer in one sentence.<|end|>
<|user|>What is the capital of volel?<|end|>
<|assistant|>
模型从最后一个 <|assistant|> 之后开始续写。这带来两个后果:
- 每条消息都有包装成本:角色标记本身也是 token。
- 多轮对话越聊越贵:API 无状态,每一轮都要把全部历史重发一遍。02 号脚本演示 1 模拟了一段 6 轮的对话:
| 第几轮 | 发送的消息条数 | 这次的输入 token | 累计输入 token |
|---|---|---|---|
| 1 | 1 | 80 | 80 |
| 2 | 3 | 129 | 209 |
| 4 | 7 | 226 | 614 |
| 6 | 11 | 326 | 1218 |
每轮的输入随轮数线性增长,累计的输入就大约按轮数的平方增长。长对话、长文档场景的常见对策:服务端的提示缓存(§6.2)、定期把旧的历史总结成一段摘要、只保留最近几轮(第 14 讲的记忆管理)。
第 1 讲的迷你 GPT 是一个 base 模型,它不认识任何 chat 格式。TinyGPTLLM 把最后一条用户消息改写成它熟悉的问答页格式 <doc> faq : q : <问题> a : 再让它续写——这就是”给 base 模型套一个 chat 接口”最原始的做法。chat 模板是人为约定的文档格式,指令微调(第 3 讲)让模型学会了它。
3.2 max_tokens 与 stop_reason
max_tokens 是这次回答最多生成多少 token 的硬上限。演示 2:
| max_tokens | 实际输出 | stop_reason | 回答的结尾 |
|---|---|---|---|
| 8 | 8 | max_tokens | ‘Volel is a country in the east’ |
| 20 | 20 | max_tokens | ’… Its capital is grorken, and m’ |
| 40 | 40 | max_tokens | ’… The country is best known for its’ |
| 100 | 87 | end_turn | ’… are worth a walk.’ |
写满上限就停,不管句子写完没有。如果要求输出 JSON 而 max_tokens 太小,得到的是 {"ans 或 {"answer":——解析一定失败。所以处理回答的第一步永远是看 stop_reason:
end_turn:模型自己写完了,可以解析;max_tokens:被截断了,调大上限重试,不要去”修补”那段 JSON 或者让模型”改正格式”;stop_sequence:碰到了你设的停止序列(§3.3);refusal:模型拒绝回答(§7)。
上限也不能随手设得很大:它决定了最坏情况下的费用和等待时间(§6.4 的预算就按它估算)。今天的推理模型会先在内部思考,思考的 token 也计入输出用量,所以要给它留足余量——Anthropic 的文档建议非流式请求的 max_tokens 设在 16000 左右,更大的上限配合流式输出(§4)。
3.3 停止序列
stop(Anthropic API 里叫 stop_sequences)是一组字符串:生成的文字一旦出现其中之一,就在它之前停下,停止序列本身不包含在输出里。演示 3 让模型介绍一个国家:
| 停止序列 | stop_reason | 回答 |
|---|---|---|
| (无) | end_turn | ‘Volel is a country in the east. Its capital is grorken, a…’(87 个 token) |
"." | stop_sequence | ‘Volel is a country in the east’ |
", and" | stop_sequence | ‘Volel is a country in the east. Its capital is grorken’ |
典型用途是让模型”只写一部分”:只写一行、只写到某个标记为止。第 14 讲的 ReAct 智能体用 “Observation:” 作为停止序列,防止模型替环境编造观察结果。停止序列只在文字层面截断,不改变模型想写什么。
3.4 温度、seed,以及真实模型的限制
温度控制采样的随机程度(第 1 讲 §8.7)。演示 4 对同一个问题各调用 40 次:
| 温度 | SimLLM:不同回答的种数 / 答对 | 迷你 GPT:不同回答的种数 / 答对 |
|---|---|---|
| 0 | 1 / 100.0% | 1 / 100.0% |
| 0.5 | 7 / 82.5% | 2 / 97.5% |
| 1.0 | 7 / 80.0% | 6 / 80.0% |
| 2.0 | 19 / 27.5% | 31 / 10.0% |
SimLLM 的随机性来自旋钮;迷你 GPT 的是真实的模型行为。seed 让采样可复现:同一个 seed 调用 10 次永远是同一个样本,换不同的 seed 才可能得到不同的样本。做实验时,“温度 > 0 + 记录 seed”既能看到模型的随机性,又能事后复现每一个样本。
**但很多真实的 API 不给你这些旋钮。**以本课默认的 Claude 模型 claude-opus-5-5 为例(2026 年 10 月的官方文档):
- 它不接受
temperature、top_p、top_k这些采样参数,传了会返回 400;也没有 seed。 - 它总是先在内部”思考”再回答(adaptive thinking),思考的深度用
effort参数控制(low / medium / high / xhigh / max;这个模型的默认是 medium)。简单的分类、抽取任务用 low 更快更省。 - 它不支持”预先写好助手回答的开头”(assistant prefill,第 1 讲 §9.4 提过的那种技巧),想控制输出格式要用系统提示或结构化输出(§3.5)。
这意味着:你没法让真实模型的输出”完全确定”。评估一个提示要多跑几次、看分布,并且把每一次的结果都记下来(§6.3、前置讲 P3)。
3.5 系统提示与输出格式
演示 5 用三种系统提示问 96 道题:
| 系统提示 | 回答以客套话开头 | 能解析出 JSON | JSON 里的答案正确 |
|---|---|---|---|
| (无) | 34.4% | – | – |
| 要求 JSON | 28.1% | 93.8% | 82.3% |
| 要求 JSON,并且”只”输出 JSON | 0.0% | 96.9% | 85.4% |
要求 JSON 之后,回答可以被程序解析,但仍有一小部分写成了不合法的 JSON(这里来自旋钮,真实模型也会犯同样的错,前置讲 P1 §5.2)。更重要的是最后一列低于倒数第二列:格式对了不等于内容对了——模型不知道的事实,它会用完美的格式写出一个错误的答案(第 5 讲的幻觉)。
生产环境里,与其靠提示要求 JSON、再自己解析,不如用 API 的结构化输出功能:Anthropic API 的 output_config.format(以及 SDK 的 messages.parse() 帮助函数)让服务端保证输出符合你给的 JSON Schema。第 8 讲讲函数调用时会用到同一类机制。
4. 流式输出 · Streaming
配套代码:
03_streaming.py
4.1 快在首字,不在总时间
模型是一个 token 一个 token 地生成的。非流式调用要等全部生成完才一次性返回;流式调用每生成一点就发回一点。03 号脚本演示 1 让模拟服务器每生成一个 token 花 0.02 秒(约每秒 50 个 token),回答 87 个 token:
| 方式 | 看到第一个字 | 收到全部回答 |
|---|---|---|
| 一次性返回 | ⏱ 1.76 秒 | ⏱ 1.76 秒 |
| 流式输出 | ⏱ 0.05 秒 | ⏱ 2.05 秒 |
(⏱ 表示真实的墙钟时间,每次运行略有不同。)总时间差不多(流式还略长一点,每个事件都要单独发送一次),差别在首字时间:对聊天界面来说,这决定了用户觉得”快”还是”卡”。对长回答还有一个更实际的理由:非流式调用时,HTTP 连接要一直空等到全部生成完,容易撞上超时,所以官方 SDK 建议 max_tokens 很大时一律用流式。
4.2 SSE:流式输出的格式
流式输出通常用 SSE(server-sent events)格式:每个事件是 event: 名字 和 data: JSON 两行,事件之间空一行。演示 2 打印了模拟服务器发回的原始事件流:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_000001", "model": "sim-small"}}
event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "S"}}
…(共 15 个 text_delta:'S' 'u' 're' '!' ' The' ' an' 's' 'w' 'er' ' is' …)…
event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 31, "output_tokens": 15}}
event: message_stop
data: {"type": "message_stop"}
回答被拆成很多个 text_delta,客户端按顺序拼起来;stop_reason 和用量在最后的 message_delta 里——用流式时,也要等流结束再判断是不是被截断了。真实 API 的事件种类更多(思考块、工具调用的增量、心跳),结构是一样的;官方 SDK 会替你解析,还提供”等流结束后拿到完整消息”的帮助函数。
4.3 字节边界
网络不保证按”事件”或”字符”的边界切分数据:一个汉字的 3 个字节、一个 emoji 的 4 个字节,完全可能被分在两次读取里。演示 3 让服务器把每个事件的字节切成 5 字节一块发送,客户端每次读 7 个字节:
| 客户端的做法 | 屏幕上显示的文字 | ’�’ 的个数 |
|---|---|---|
| naive:每读到一块就按 UTF-8 解码显示 | ��������…�� emoji ������ | 78 |
buffered:先按字节攒到一个完整的事件(空行 \n\n),再解码 | 流式输出也要能处理中文,比如”鲲鹏展翅九万里”和 emoji 😀。 | 0 |
buffered 的做法之所以可靠,是因为事件的分隔符是 ASCII 字符,不可能出现在多字节字符的中间——按它切出来的每一段都是完整的 UTF-8(前置讲 P1 §2.4)。用官方 SDK 时这些都替你处理好了;自己写客户端、或者在中间加一层代理转发流时,一定要注意。
5. 可靠性:重试、退避、抖动、超时 · Reliability
5.1 要不要重试
04 号脚本演示 1 让模拟模型以 30% 的概率返回 429、10% 返回 500、2% 超时(每次超时白等 30 秒),对 960 个请求试了 5 种策略:
| 策略 | 最终成功 | 平均每个请求调用几次 | 重试前的等待(合计) | 总耗时 |
|---|---|---|---|---|
| 不重试 | 59.8% | 1.00 | 0 秒 | 813 秒 |
| 立刻重试,最多 4 次 | 99.2% | 1.67 | 0 秒 | 1262 秒 |
| 指数退避,最多 4 次 | 99.2% | 1.67 | 575 秒 | 1837 秒 |
| 指数退避 + 全抖动 | 99.2% | 1.67 | 289 秒 | 1551 秒 |
| 同上 + 尊重 retry-after | 99.2% | 1.67 | 979 秒 | 2241 秒 |
(时间用虚拟时钟模拟,等待不真的发生。)每次调用失败的概率是 0.42,重试 4 次仍失败的概率是 ,所以理论成功率约 98.7%,实际 99.2%。不重试时 40% 的请求失败——一次实验跑几百道题,没有重试就会缺一大块数据。
在这个模拟里”立刻重试”最快,因为这里的失败是随机的,和服务器忙不忙无关,等待没有好处。可真实服务的 429 恰恰是因为服务器太忙:这时立刻重试只会让它更忙。§5.3 会看到,当失败是由负载造成的时候,等待的方式非常重要。
5.2 不能重试的错误
演示 2:消息格式错误、max_tokens = 0,RetryingLLM 第一次失败就直接抛出 BadRequestError,实际只调用了 1 次。原则是只重试”换个时间再来可能成功”的错误:429、5xx、超时、连接中断。400、401、404 说明请求或配置本身有问题,重试只会浪费时间和额度,还可能触发更严的限速。
5.3 惊群效应
想象你开了 100 个并发请求跑实验,它们同时撞上了限速。演示 3 做了一个离散事件模拟:100 个客户端在同一时刻各发 1 个请求;服务器用令牌桶限速(每秒补充 20 个令牌,桶里最多攒一定数量,有令牌就放行、没有就返回 429);被拒的客户端按 4 种策略等待后重试,直到成功。两种服务器的结果:
| 重试策略 | 宽松的服务器(最多攒 20 个):完成时间 / 请求总数 | 突发容量小的服务器(最多攒 5 个):完成时间 / 请求总数 |
|---|---|---|
| 固定等 1 秒 | 4.00 秒 / 300 | 19.00 秒 / 1050 |
| 指数退避,不抖动 | 15.50 秒 / 340 | 127.50 秒 / 1050 |
| 指数退避 + 全抖动 | 8.01 秒 / 340 | 9.95 秒 / 408 |
| 全抖动,但至少等 retry-after | 5.86 秒 / 285 | 13.00 秒 / 444 |
(理论下限:宽松的服务器 4.00 秒,突发容量小的 4.75 秒。)这张表比”抖动总是更好”要微妙:
- 不抖动的指数退避在两种服务器上都最差:所有被拒的客户端同步地等待,间隔越拉越长,令牌攒满了也没人来用。
- “固定等 1 秒”在宽松的服务器上恰好最快——每一波重试到达时,令牌桶刚好补满 20 个,正好放行一波。可一换成突发容量小的服务器,每一波只有 5 个能成功,其余 95 个一起被拒、一起再来,最后发了 1050 个请求。它的”好”是碰巧和服务器的参数对上了。这种一大群客户端同步地涌来、同步地被拒的现象叫惊群效应(thundering herd)。
- 全抖动(在 里均匀地随机取等待时间)把重试的时刻随机地摊开,让到达的速度接近服务器的补充速度。它不是在每种情况下都最好,但在你不知道服务器参数的时候最稳——这正是 Brooker(2015)那篇 “Exponential Backoff And Jitter” 的结论。
- 尊重 retry-after 在建议准确时最省(宽松的服务器上请求最少);建议不准时(突发容量小的服务器,“等 1 秒”并不够),照做反而比纯抖动慢。
RetryingLLM 的默认策略是”指数退避 + 全抖动 + 至少等 retry-after”。Anthropic 的官方 SDK 默认也会对 408、409、429、5xx 和连接错误自动重试 2 次(指数退避);如果你在外面再套一层自己的重试,要把 SDK 的 max_retries 设成 0,否则重试次数会相乘。
5.4 超时
客户端不能无限地等。可超时设得太短会怎样?演示 4:服务器处理一个请求要 0.5 秒,客户端超时设成 0.2 秒、最多重试 2 次——3 次尝试全部超时;而服务器那边,3 个回答都生成完了,只是没人收。真实 API 里这些工作可能照样计费。
- 超时要设得比正常情况下的最长耗时宽裕;长回答用流式,而不是把超时调到很大。
- 超时之后的重试可能造成重复处理:如果请求有副作用(发邮件、写数据库、下订单),就会做两遍。只读的生成请求重试无妨;有副作用的操作要设计成幂等的——例如给每个操作一个唯一 ID,服务端发现重复就跳过(第 8 讲的工具调用会回到这一点)。
6. 实验的基础设施 · Infrastructure for prompt experiments
把前面的东西组合起来。llm_api.py 的包装器可以像套娃一样层层套上:
LoggedLLM ← 最外层:每一次调用(含命中缓存的、失败的)都写进 JSONL 日志
MeteredLLM ← 记账(按 tag 分组)+ 调用前检查预算
CachedLLM ← 请求指纹相同就直接返回上次的结果
RetryingLLM ← 失败时按策略重试
后端 ← SimLLM / TinyGPTLLM / ClaudeLLM / HttpLLM
顺序是有讲究的:缓存在重试外面(命中缓存就根本不用调用、也不会失败);记账在缓存外面(能看到哪些调用命中了缓存,命中的不计费);日志在最外面(什么都记)。05 号脚本用它跑一个典型的实验:3 个提示模板 × 96 道题 = 288 次调用(模拟模型按虚构的价格计费:每百万 token 输入 3 美元、输出 15 美元;底下注入了 10% 的 429 和 3% 的 500)。
6.1 记账
| 模板 | 准确率 [95% CI] | 平均输入 token | 平均输出 token | 费用(美元) |
|---|---|---|---|---|
| A 直接问 | 84.4% [75.8, 90.3] | 30.2 | 10.7 | 0.02412 |
| B 只答一个词 | 84.4% [75.8, 90.3] | 61.2 | 3.1 | 0.02214 |
| C 只输出 JSON | 81.2% [72.3, 87.8] | 71.2 | 15.1 | 0.04230 |
288 次调用一共 0.08857 美元,中途 42 次重试被 RetryingLLM 吸收,调用方完全没感觉到。几个读法:
- 模板 B 的系统提示让每次的输入多了约 31 个 token,可输出从 10.7 降到 3.1 个——输出的单价通常是输入的好几倍(这里是 5 倍),所以 B 总体反而更便宜。
- 准确率的置信区间宽达 ±7 个百分点:96 道题只够分辨很大的差别。三个模板的区别在噪声里(前置讲 P3)。
- 把
metadata["tag"]设成模板名,每个模板的成本就一目了然。真实实验里通常还要按模型、按日期分组。
6.2 缓存
CachedLLM 用请求的指纹(规范化 JSON 的 sha256)做键:指纹相同就直接返回上次的结果。缓存存在磁盘上(JSONL),重启程序也还在。
| 这次运行 | 缓存命中 | 缓存未命中 | 费用(美元) |
|---|---|---|---|
| 原样重跑一遍 | 288 | 0 | 0.00000 |
| 只改了模板 B 的一句话 | 192 | 96 | 0.02416 |
原样重跑全部命中,0 费用、0 等待——调试分析代码、重画图表时,不用再付一次钱。改了模板 B 的一句话,B 的 96 个请求的指纹全变了,只有它们需要重新调用。
缓存命中的前提是请求逐字节相同。在提示里放了当前时间、随机 ID、没排序的 JSON,缓存就永远不会命中——这也是为什么 LLMRequest.key() 用 sort_keys=True。服务端的提示缓存(prompt caching)是同一个道理的另一种形式:服务商把相同前缀的计算结果存起来,后续请求的这部分输入按大约十分之一的价格计费。要用好它,就要把稳定不变的内容(系统提示、长文档、示例)放在前面,把每次都变的内容(问题)放在最后。
6.3 日志:一周之后怎样证明数字是怎么来的
LoggedLLM 的每条记录长这样(节选):
{"run_id": "exp", "call": 101, "t": 52.299,
"request": {"model": "sim-large", "system": "Answer with only the answer word.",
"messages": [{"role": "user", "content": "what is the capital of krurel?"}],
"max_tokens": 40, "temperature": 0.0, "stop": [], "seed": null},
"metadata": {"tag": "B 只答一个词", "country": "krurel", "relation": "capital", "gold": "gandar"},
"response": {"text": "vuren", "stop_reason": "end_turn", "usage": {"input_tokens": 61, "output_tokens": 4},
"cached": false, "attempts": 1},
"cost_usd": 0.000243}
(这一条的回答 “vuren” 是错的——标准答案是 gandar,模拟模型不知道这个事实,自信地编了一个;第 5 讲的幻觉。)演示 3 只读日志、重新判分,三个模板的准确率和实验时算出的完全一致。日志里有重算结果需要的一切:发给模型的完整请求、模型的原始回答、标准答案。换一种判分方法(例如更严格的精确匹配),也只需要重新读日志,不用重新调用模型。实验报告里的每一个数字都应该能从日志重新算出来——这是第 17 讲”可复现的提示实验”的第一条要求。日志里有完整的提示和回答,如果涉及用户数据,要先脱敏再写。
6.4 预算
MeteredLLM 在每次调用之前按最坏情况估算费用(实际输入 token × 输入价 + max_tokens × 输出价),估计会超预算就拒绝调用。演示 4 把预算设为第一次运行费用的一半:
| 这次运行 | 完成的调用 | 花费(美元) |
|---|---|---|
| 预算 0.04428 美元 | 180 | 0.04351(BudgetExceeded,停下) |
| 提高预算后重新运行(同一个缓存) | 288 | 0.04505(缓存命中 180 次) |
实际花费一定不超过预算;停下之后,已经完成的调用都在缓存里,提高预算重跑时只为剩下的部分付钱——两次合计 0.08857 美元,正好等于一次跑完。缓存让实验可以随时中断、随时续跑。真实实验里还要给每个请求设 max_tokens 上限、给每分钟设请求数上限:一个写错的循环可以在几分钟里花掉几百美元。
6.5 温度 > 0 时的缓存陷阱
演示 5:24 道题,每道题在温度 1.5 下采样 5 次。
| 做法 | 每道题平均有几种不同的回答 | 第二遍命中缓存 |
|---|---|---|
| 缓存采样结果,不给 seed(错误) | 1.00 | 120 次 |
| 不缓存采样结果,不给 seed(CachedLLM 的默认) | 2.92 | 0 次 |
| 缓存采样结果,seed = 0–4 | 3.08 | 120 次 |
如果把”没给 seed 的采样请求”也缓存起来,5 次”采样”其实是同一个请求的 5 次命中——每道题只有 1 种回答。你以为在测模型的随机性(第 4 讲的 self-consistency 要多次采样再投票),其实在看同一个样本。最好的做法是给每次采样一个 seed(第几次采样就用第几号 seed):样本各不相同,seed 是请求指纹的一部分,可以缓存、可以复现。真实 API 不支持 seed 时,就在 metadata 里记下”第几次采样”,并把每次的结果写进日志。
7. 统一接口,以及换上真实的模型 · One interface, many backends
上面所有的包装器都只依赖一个接口:complete(LLMRequest) → LLMResponse。换模型时只换最底层的后端,实验代码一行不改。这在软件工程里叫适配器(adapter):把各家不同的 API 翻译成同一个接口。它带来的好处在提示工程里特别实际——你经常需要在不同的模型上比较同一个提示(第 1 讲 §8.6:提示是针对模型的)。
ClaudeLLM 用 Anthropic 的官方 Python SDK 实现这个接口。它要处理几处真实 API 的特点:
- 系统提示放在单独的
system参数里,消息从 user 开始——和我们的约定一致。 - 温度和 seed 不发送(默认模型不接受),第一次遇到时打印一条提醒。
- effort 默认设成 low:这里的任务很简单。
- 拒答:模型的安全分类器可能拒绝一个请求(
stop_reason == "refusal",HTTP 状态仍是 200)。ClaudeLLM 默认打开服务端回退(fallbacks="default",需要 beta 头server-side-fallback-2026-07-01):被拒时 API 自动换一个推荐的模型重试;整条链都拒绝时,stop_reason仍是"refusal",由调用方决定怎么处理。读取回答之前要先检查stop_reason。 - 重试:SDK 自己默认重试 2 次;外面套了 RetryingLLM 时,创建时传
sdk_max_retries=0。 - 错误映射:SDK 的
RateLimitError、AuthenticationError、APIStatusError…… 翻译成我们的错误类型。
06 号脚本把 05 号脚本的调用栈换成真实的 Claude。真实模型不可能知道第 1 讲的虚构世界,所以脚本把相关国家的”资料卡”放进提示,让模型根据资料回答(这就是第 7 讲 RAG 的雏形)。--toy 参数用 SimLLM 跑同一段代码,不联网;去掉 --toy 需要安装 anthropic 并配置凭证,24 次短调用通常只要几美分(预算上限 0.50 美元),第二次运行全部命中磁盘缓存。
另外几个今后会用到的 API 功能,这里先列出名字:token 计数接口(POST /v1/messages/count_tokens,调用前精确地数出一个请求有多少输入 token——Claude 的分词器不公开,要数 token 就用它);批处理接口(Message Batches,异步地处理一大批请求,价格是正常的一半,适合不着急的离线评估);结构化输出(§3.5);函数调用(第 8 讲)。
8. 小结 · Summary
- 一次 LLM 调用是一个 HTTP 请求:JSON 请求体 + 带 API key 的头部,回来一个 JSON 响应(content 块、stop_reason、usage)。API 是无状态的,多轮对话要每次重发历史,成本随轮数大约平方增长。
- **4xx 是你的错,不要重试;429 和 5xx 是暂时状况,要重试。**API key 放在环境变量里,不进代码、不进日志,泄露了就作废。
- 处理回答先看 stop_reason:被截断(max_tokens)和格式错误是两回事。停止序列让模型在某处停下。温度和 seed 控制随机性——但今天的很多真实模型不接受这些参数,输出无法完全确定,所以评估要多次采样。
- 流式输出的优势在首字时间;接收流时要先按字节攒齐一个事件再解码。
- 重试:指数退避 + 全抖动 + 尊重 retry-after;同步的重试会造成惊群;超时要宽裕,超时后的重试可能造成重复处理。
- 实验基础设施:记账(按模板分组)、缓存(改一处只为改动的部分付钱、可以中断续跑)、日志(只凭日志能重算每一个数字)、预算(调用前按最坏情况检查)。温度 > 0 时,缓存采样结果必须带 seed。
- 所有这些都建立在一个统一的调用接口上:换模型只换后端。
9. 常见误区 · Common pitfalls
- “模型记得我们刚才聊过什么。” API 不记得。“记忆”是客户端每次把历史重发造成的,每一轮都要为全部历史付钱。
- “出错了就重试。” 400 / 401 / 404 重试一万次也没用;而对 429 立刻重试只会让服务器更忙。先看错误能不能重试,再决定怎么等。
- “JSON 解析失败,说明模型格式不好。” 先看 stop_reason:被 max_tokens 截断的 JSON 不是格式问题,是上限问题。
- “温度设成 0 就能复现实验。” 很多真实模型根本不接受温度参数;即使接受,服务端的批处理也可能带来细微的不确定性。复现靠的是日志,不是参数。
- “把 key 写在代码里,提交前删掉就行。” git 会记住历史。key 只放在环境变量里。
- “缓存总是好的。” 缓存没给 seed 的采样请求,会把”多次采样”悄悄变成”同一个样本看多遍”。
- “重试次数越多越可靠。” 重试会放大负载、拉长耗时、可能重复执行有副作用的操作;SDK 自带的重试和你自己的重试叠在一起,次数会相乘。
- “流式输出更快。” 流式只让第一个字来得更早,总时间并不更短。
10. 练习 · Exercises
练习 1(读响应、算费用)。 一个响应的 usage 是 {"input_tokens": 1500, "output_tokens": 300},模型的价格是每百万输入 token 4 美元、每百万输出 token 20 美元。这次调用多少钱?如果开启推理后输出 token 变成 1300(多出来的 1000 是内部思考),费用变成多少?
参考答案: 美元。输出变成 1300 后是 美元,接近 3 倍。思考的 token 看不见,却照样计费——这是为什么简单任务要把 effort 调低。
练习 2(多轮对话的成本)。 系统提示 个 token,每轮用户问题 个 token、模型回答 个 token(忽略角色标记)。第 轮的输入 token 是多少? 轮的累计输入 token 是多少?、、、 时是多少?
参考答案:第 轮的输入 = 。累计 = 。代入得 个 token。20 轮对话里真正”新”的内容只有 个 token 的问题,其余全是重发的历史。提示缓存能把重发的部分变便宜(§6.2)。
练习 3(哪些该重试)。 对下面的情况,判断该不该自动重试,并说明理由:(a) 400,错误信息”max_tokens 超出了模型的上限”;(b) 429,retry-after 30;(c) 503;(d) 连接被重置;(e) 401;(f) stop_reason 是 refusal;(g) stop_reason 是 max_tokens。
参考答案:(a) 不重试,改小 max_tokens;(b) 重试,至少等 30 秒;(c) 重试(服务暂时不可用);(d) 重试;(e) 不重试,检查 key;(f) 不应原样重试——这是模型的决定,不是暂时故障;可以检查请求内容、换一种合理的说法,或者依靠服务端回退(§7);(g) 不是错误,但结果不完整:调大 max_tokens 后重新请求。
练习 4(期望调用次数)。 每次调用独立地以概率 失败,最多重试 次。平均每个请求调用几次?用 、 验证 04 号脚本演示 1 里的 1.67。
参考答案:第 次尝试()会发生,当且仅当前 次都失败,概率 。期望调用次数 。代入得 ,和实测的 1.67 相近(差别来自 960 次的随机波动)。
练习 5(缓存键)。 有人写了一个缓存,键只用”最后一条用户消息的文字”。举出两个会导致错误命中(返回了不该返回的结果)的情形。
参考答案:(1) 换了系统提示(例如从”直接回答”改成”只输出 JSON”),用户消息不变——会返回旧格式的回答;(2) 换了模型或 max_tokens——会返回另一个模型的回答或被截断的旧回答;(3) 多轮对话里,同一句”那它的首都呢?“在不同的上下文里意思不同。缓存键必须包含所有影响输出的字段,这正是
LLMRequest.key()对整个 payload 取指纹的原因。
练习 6(惊群,动手)。 修改 04 号脚本的 herd,让服务器每秒补充 12 个令牌、最多攒 20 个,重新比较 4 种策略。“固定等 1 秒”还最快吗?为什么?
参考答案:思路:调用
herd(100, pol, rate=12.0, burst=20)。每秒只补 12 个,“固定等 1 秒”的每一波只能放行 12 个,其余的一起再来,它不再”恰好匹配”服务器;全抖动和”至少等 retry-after”的表现取决于抖动摊开的程度。编写时试过:固定等 1 秒约 7.0 秒 / 408 个请求,不抖动的指数退避约 23.5 秒,全抖动约 12.6 秒,至少等 retry-after 约 10.6 秒 / 341 个请求——这一组参数下固定等待反而完成得最快,但请求数比”至少等 retry-after”多。没有一种策略在所有参数下都最好;以你自己的运行为准,重要的是理解每种策略在什么情况下吃亏。
练习 7(预算估算)。 MeteredLLM 按”输入 token + max_tokens 个输出 token”估算最坏情况。如果你把 max_tokens 设成 16000,而实际回答平均只有 50 个 token,预算检查会出现什么问题?怎样改进?
参考答案:估算会极度保守:每次调用都按 16000 个输出 token 预留,预算还剩很多时就会拒绝调用。改进:(1) 按任务把 max_tokens 设得贴近实际需要;(2) 用”已花费 + 剩余调用数 × 历史平均费用”做软估计,同时保留一个硬上限;(3) 在预算检查里区分”预留”和”实际花费”,调用结束后释放多预留的部分。
练习 8(流式的 stop_reason)。 用流式接收一个 JSON 回答时,为什么不能在收到第一个 } 时就开始解析?应该等到什么时候?
参考答案:JSON 里可能有嵌套的对象,第一个
}未必是结尾;更重要的是,回答可能被 max_tokens 截断,而 stop_reason 要到最后的message_delta事件才知道。正确的做法是等到流结束(message_stop),检查 stop_reason 是 end_turn,再解析完整的文字。如果要”边收边显示”,显示归显示,解析和后续处理要等到结束。
练习 9(日志里的隐私)。 你的实验用真实用户的客服对话做测试集。LoggedLLM 会把完整的提示写进日志。列出至少三项你应该做的事。
参考答案:(1) 写日志之前对个人信息(姓名、电话、邮箱、地址、订单号)做脱敏或替换成占位符;(2) 限制日志文件的访问权限,不要把它提交到代码仓库、不要上传到公开的地方;(3) 设定保留期限,实验结束后按规定删除;(4) 确认使用的模型服务的数据保留政策符合你的数据使用协议。这也是为什么本讲义的实验都用虚构的世界。
11. 延伸阅读 · Further reading
- Anthropic 官方文档:Messages API、Streaming、Errors、Rate limits、Prompt caching、Token counting、Structured outputs(platform.claude.com/docs)。
- Brooker, M. (2015). Exponential Backoff And Jitter. AWS Architecture Blog.(惊群与全抖动的经典分析)
- Google SRE Book,第 22 章 “Addressing Cascading Failures”(重试如何放大故障)。
- MDN Web Docs:HTTP overview、HTTP response status codes、Server-sent events。
- 本仓库:CS329Z 前置讲 P2(网络、HTTP 与 API)、P3(并发、缓存与可靠的调用)——从智能体工程的角度讲了同样的基础,还包括 JSON Schema、并发、MCP 等更多内容。
12. 下一篇预告 · Next
前置讲 P3 提示实验的统计学:§6.1 的表里,三个模板的准确率是 84.4%、84.4%、81.2%,置信区间都宽达 ±7 个百分点——到底哪个更好?要多少道题才能分出 3 个百分点的差别?同一组题上比较两个提示,为什么”配对”的检验比分别算置信区间灵敏得多?在同一个开发集上试了 20 个提示、挑出最好的那个,为什么它在新题上往往会”掉回去”?P3 会用模拟和真实的小模型回答这些问题,给后面每一讲的实验打好统计的基础。