前置讲 P2 · 把 LLM 当作一个软件服务 · The LLM as a Software Service

Prompt Engineering · 预备知识 2。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

定位:这是本讲义为计算机系统背景较少的读者补充的前置讲,原课没有对应的一节。原课的作业要求学生用云端 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

  1. 说出一次 LLM API 调用的完整过程,读懂原始的 HTTP 请求和响应,知道 API 是无状态的。
  2. 会区分”值得重试”和”不值得重试”的错误,知道 API key 应该放在哪里、不应该出现在哪里。
  3. 知道请求里每个字段的作用:消息与角色、系统提示、max_tokens 与 stop_reason、停止序列、温度与 seed;知道今天的真实模型在这些参数上有哪些限制。
  4. 理解流式输出的首字时间优势,以及接收流式数据时的字节边界问题。
  5. 会设计重试策略(指数退避 + 抖动 + retry-after),理解惊群效应和超时的副作用。
  6. 会为一次提示实验搭起记账、缓存、日志、预算这层基础设施,并知道温度 > 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 写错了401AuthError否
模型名写错了404NotFoundError否
消息以 assistant 开头400BadRequestError否
请求体是合法的 JSON 但不是对象(null)400BadRequestError否
服务器限速429(带 retry-after: 1)RateLimitError是
服务器内部错误500ServerError是

规律很简单:

  • 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 就是你的钱包:谁拿到它,谁就能用你的额度。三条规则:

  1. 永远不要把 key 写进代码。代码会被提交到 git、被复制给同事、被贴进聊天里。把 key 放在环境变量里(或者专门的密钥管理工具),代码只负责去读。演示 3 里,HttpLLM 在没有传 api_key 时从环境变量 MOCK_LLM_API_KEY 读取。Anthropic 的官方 SDK 也是这样:默认读环境变量 ANTHROPIC_API_KEY,或者读 ant auth login 登录后保存的凭证。
  2. 不要让 key 出现在日志和报错信息里。调试时把整个请求(含头部)打印出来,key 就留在了终端历史和日志文件里。LoggedLLM 只记录请求体(payload),不记录头部;必须显示时用 redact 打码(sk-te…2e)。
  3. 泄露了就作废。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
118080
23129209
47226614
6113261218

每轮的输入随轮数线性增长,累计的输入就大约按轮数的平方增长。长对话、长文档场景的常见对策:服务端的提示缓存(§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回答的结尾
88max_tokens‘Volel is a country in the east’
2020max_tokens’… Its capital is grorken, and m’
4040max_tokens’… The country is best known for its’
10087end_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:不同回答的种数 / 答对
01 / 100.0%1 / 100.0%
0.57 / 82.5%2 / 97.5%
1.07 / 80.0%6 / 80.0%
2.019 / 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 道题:

系统提示回答以客套话开头能解析出 JSONJSON 里的答案正确
(无)34.4%––
要求 JSON28.1%93.8%82.3%
要求 JSON,并且”只”输出 JSON0.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

配套代码:04_reliability.py、llm_api.RetryingLLM

5.1 要不要重试

04 号脚本演示 1 让模拟模型以 30% 的概率返回 429、10% 返回 500、2% 超时(每次超时白等 30 秒),对 960 个请求试了 5 种策略:

策略最终成功平均每个请求调用几次重试前的等待(合计)总耗时
不重试59.8%1.000 秒813 秒
立刻重试,最多 4 次99.2%1.670 秒1262 秒
指数退避,最多 4 次99.2%1.67575 秒1837 秒
指数退避 + 全抖动99.2%1.67289 秒1551 秒
同上 + 尊重 retry-after99.2%1.67979 秒2241 秒

(时间用虚拟时钟模拟,等待不真的发生。)每次调用失败的概率是 0.42,重试 4 次仍失败的概率是 0.425≈0.0130.42^5 \approx 0.013,所以理论成功率约 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 秒 / 30019.00 秒 / 1050
指数退避,不抖动15.50 秒 / 340127.50 秒 / 1050
指数退避 + 全抖动8.01 秒 / 3409.95 秒 / 408
全抖动,但至少等 retry-after5.86 秒 / 28513.00 秒 / 444

(理论下限:宽松的服务器 4.00 秒,突发容量小的 4.75 秒。)这张表比”抖动总是更好”要微妙:

  • 不抖动的指数退避在两种服务器上都最差:所有被拒的客户端同步地等待,间隔越拉越长,令牌攒满了也没人来用。
  • “固定等 1 秒”在宽松的服务器上恰好最快——每一波重试到达时,令牌桶刚好补满 20 个,正好放行一波。可一换成突发容量小的服务器,每一波只有 5 个能成功,其余 95 个一起被拒、一起再来,最后发了 1050 个请求。它的”好”是碰巧和服务器的参数对上了。这种一大群客户端同步地涌来、同步地被拒的现象叫惊群效应(thundering herd)。
  • 全抖动(在 [0,min⁡(cap,base⋅2k)][0, \min(\text{cap}, \text{base} \cdot 2^k)] 里均匀地随机取等待时间)把重试的时刻随机地摊开,让到达的速度接近服务器的补充速度。它不是在每种情况下都最好,但在你不知道服务器参数的时候最稳——这正是 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

配套代码:05_experiment_infra.py

把前面的东西组合起来。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.210.70.02412
B 只答一个词84.4% [75.8, 90.3]61.23.10.02214
C 只输出 JSON81.2% [72.3, 87.8]71.215.10.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),重启程序也还在。

这次运行缓存命中缓存未命中费用(美元)
原样重跑一遍28800.00000
只改了模板 B 的一句话192960.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 美元1800.04351(BudgetExceeded,停下)
提高预算后重新运行(同一个缓存)2880.04505(缓存命中 180 次)

实际花费一定不超过预算;停下之后,已经完成的调用都在缓存里,提高预算重跑时只为剩下的部分付钱——两次合计 0.08857 美元,正好等于一次跑完。缓存让实验可以随时中断、随时续跑。真实实验里还要给每个请求设 max_tokens 上限、给每分钟设请求数上限:一个写错的循环可以在几分钟里花掉几百美元。

6.5 温度 > 0 时的缓存陷阱

演示 5:24 道题,每道题在温度 1.5 下采样 5 次。

做法每道题平均有几种不同的回答第二遍命中缓存
缓存采样结果,不给 seed(错误)1.00120 次
不缓存采样结果,不给 seed(CachedLLM 的默认)2.920 次
缓存采样结果,seed = 0–43.08120 次

如果把”没给 seed 的采样请求”也缓存起来,5 次”采样”其实是同一个请求的 5 次命中——每道题只有 1 种回答。你以为在测模型的随机性(第 4 讲的 self-consistency 要多次采样再投票),其实在看同一个样本。最好的做法是给每次采样一个 seed(第几次采样就用第几号 seed):样本各不相同,seed 是请求指纹的一部分,可以缓存、可以复现。真实 API 不支持 seed 时,就在 metadata 里记下”第几次采样”,并把每次的结果写进日志。


7. 统一接口,以及换上真实的模型 · One interface, many backends

配套代码:06_real_llm.py、llm_api.ClaudeLLM

上面所有的包装器都只依赖一个接口:complete(LLMRequest) → LLMResponse。换模型时只换最底层的后端,实验代码一行不改。这在软件工程里叫适配器(adapter):把各家不同的 API 翻译成同一个接口。它带来的好处在提示工程里特别实际——你经常需要在不同的模型上比较同一个提示(第 1 讲 §8.6:提示是针对模型的)。

ClaudeLLM 用 Anthropic 的官方 Python SDK 实现这个接口。它要处理几处真实 API 的特点:

  1. 系统提示放在单独的 system 参数里,消息从 user 开始——和我们的约定一致。
  2. 温度和 seed 不发送(默认模型不接受),第一次遇到时打印一条提醒。
  3. effort 默认设成 low:这里的任务很简单。
  4. 拒答:模型的安全分类器可能拒绝一个请求(stop_reason == "refusal",HTTP 状态仍是 200)。ClaudeLLM 默认打开服务端回退(fallbacks="default",需要 beta 头 server-side-fallback-2026-07-01):被拒时 API 自动换一个推荐的模型重试;整条链都拒绝时,stop_reason 仍是 "refusal",由调用方决定怎么处理。读取回答之前要先检查 stop_reason。
  5. 重试:SDK 自己默认重试 2 次;外面套了 RetryingLLM 时,创建时传 sdk_max_retries=0。
  6. 错误映射: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

  1. “模型记得我们刚才聊过什么。” API 不记得。“记忆”是客户端每次把历史重发造成的,每一轮都要为全部历史付钱。
  2. “出错了就重试。” 400 / 401 / 404 重试一万次也没用;而对 429 立刻重试只会让服务器更忙。先看错误能不能重试,再决定怎么等。
  3. “JSON 解析失败,说明模型格式不好。” 先看 stop_reason:被 max_tokens 截断的 JSON 不是格式问题,是上限问题。
  4. “温度设成 0 就能复现实验。” 很多真实模型根本不接受温度参数;即使接受,服务端的批处理也可能带来细微的不确定性。复现靠的是日志,不是参数。
  5. “把 key 写在代码里,提交前删掉就行。” git 会记住历史。key 只放在环境变量里。
  6. “缓存总是好的。” 缓存没给 seed 的采样请求,会把”多次采样”悄悄变成”同一个样本看多遍”。
  7. “重试次数越多越可靠。” 重试会放大负载、拉长耗时、可能重复执行有副作用的操作;SDK 自带的重试和你自己的重试叠在一起,次数会相乘。
  8. “流式输出更快。” 流式只让第一个字来得更早,总时间并不更短。

10. 练习 · Exercises

练习 1(读响应、算费用)。 一个响应的 usage 是 {"input_tokens": 1500, "output_tokens": 300},模型的价格是每百万输入 token 4 美元、每百万输出 token 20 美元。这次调用多少钱?如果开启推理后输出 token 变成 1300(多出来的 1000 是内部思考),费用变成多少?

参考答案:1500×4/106+300×20/106=0.006+0.006=0.0121500 \times 4/10^6 + 300 \times 20/10^6 = 0.006 + 0.006 = 0.012 美元。输出变成 1300 后是 0.006+0.026=0.0320.006 + 0.026 = 0.032 美元,接近 3 倍。思考的 token 看不见,却照样计费——这是为什么简单任务要把 effort 调低。

练习 2(多轮对话的成本)。 系统提示 ss 个 token,每轮用户问题 qq 个 token、模型回答 aa 个 token(忽略角色标记)。第 kk 轮的输入 token 是多少?nn 轮的累计输入 token 是多少?s=200s = 200、q=50q = 50、a=150a = 150、n=20n = 20 时是多少?

参考答案:第 kk 轮的输入 = s+kq+(k−1)as + kq + (k-1)a。累计 = ∑k=1n[s+kq+(k−1)a]=ns+qn(n+1)2+an(n−1)2\sum_{k=1}^{n} [s + kq + (k-1)a] = ns + q\frac{n(n+1)}{2} + a\frac{n(n-1)}{2}。代入得 20×200+50×210+150×190=4000+10500+28500=4300020 \times 200 + 50 \times 210 + 150 \times 190 = 4000 + 10500 + 28500 = 43000 个 token。20 轮对话里真正”新”的内容只有 20×50=100020 \times 50 = 1000 个 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(期望调用次数)。 每次调用独立地以概率 pp 失败,最多重试 RR 次。平均每个请求调用几次?用 p=0.42p = 0.42、R=4R = 4 验证 04 号脚本演示 1 里的 1.67。

参考答案:第 kk 次尝试(k=0,…,Rk = 0, \dots, R)会发生,当且仅当前 kk 次都失败,概率 pkp^k。期望调用次数 =∑k=0Rpk=1−pR+11−p= \sum_{k=0}^{R} p^k = \frac{1 - p^{R+1}}{1 - p}。代入得 (1−0.425)/0.58≈1.70(1 - 0.42^5) / 0.58 \approx 1.70,和实测的 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 会用模拟和真实的小模型回答这些问题,给后面每一讲的实验打好统计的基础。

← 返回课程目录