前置讲 P3 · 并发、缓存与可靠的调用 · Concurrency, Caching, and Reliable Calls

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

课程源码 ZIP ↓

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

本篇目录

这是一篇前置讲,不对应 CS329Z 的某一次课。它补的是”同时发很多个调用、不重复付费、不被限速、不花超预算”这一层。支撑第 2 讲(LM 封装)、第 3 讲(检索)、第 10 讲(数据)、第 13 讲(评估基础设施),以及原课作业 1 里”把一天约 280 封邮件并发分类""共享状态必须并发安全""缓存 / 账本 / 预算”这几处要求。 依据说明:并发、锁、令牌桶的内容是通用的计算机知识,用 Python 标准库演示。涉及智能体工程的具体数字和要求,引用的是原课作业 1 的公开 handout(并发分类的提示、.lm_cache 的缓存与账本、CS329Z_BUDGET_USD 预算、评估的成本)。价格是编的(量级参考常见的大 / 小模型),只用来说明”按步骤分摊”这件事。 配套代码 Code:code/prereq3/(只用 Python 标准库;4 个脚本 + 1 个模块;不联网、不需要密钥;最慢的脚本约 6 秒) 预计阅读时间:2.5–3.5 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 为什么智能体工程师要懂这些

前两篇前置讲让智能体够到了本机(P1)和网络(P2)。但 P2 的客户端是一个一个发请求的,而智能体的工作量常常是成百上千次调用:

  • 原课 HW1 的”每日摘要”要给一天的邮件各做一次分类。handout 直接提示:要并发地分类,一个接一个会让那个测试跑上一分四十秒;档案里一天平均约 280 封邮件。
  • handout 还有一条要求:有几个 live test 会从好几个线程同时调用你的代码,所以你保存的任何共享状态(索引、缓存)都必须能被并发安全地使用。
  • HW1 的 LM 封装自带三件事:缓存(重复的调用免费)、账本(每次调用的 token 和费用)、预算(超了就抛异常)。handout 说,用参考答案跑一次完整的评估只要 0.18 美元,而重复运行是免费的——全靠缓存。

这一讲把这些从零实现一遍(conc_lab.py,约 400 行含注释),最后叠成一个 CachedLM:缓存 → 预算 → 限速 → 重试 → 调用 → 记账。它就是 HW1 那个 LM 封装的结构,第 2 讲会在它上面接真实的模型。

0.2 学习目标 Learning goals

  1. 分清等待型(I/O 密集)和计算型(CPU 密集)的慢,知道 GIL 为什么让线程对前者有效、对后者无效。
  2. 会用线程池和 asyncio 并发地跑一批调用,会用 ⌈n/并发⌉×延迟 这个公式先规划再写代码,并说出 asyncio 里混入阻塞调用的后果。
  3. 认识竞态:丢失更新、check-then-act;会用锁、分区合并、先建后发布三种方式修它;知道死锁和”锁的范围不对”这两种用错。
  4. 会设计缓存键(该放什么、不该放什么),知道缓存在开发流程里省掉的是什么。
  5. 会用账本回答”钱花在哪一步了”,并知道调用次数多 ≠ 花钱多。
  6. 会用预算给失控的循环兜底,知道为什么预算用完后缓存里的调用还能继续。
  7. 理解令牌桶限速,知道”主动限速”比”被拒再重试”好在哪,并会把限速、重试、超时的时间加起来算进任务的时间预算。

0.3 本讲地图 Map

§1  两种慢:等待 vs 计算,以及 GIL                                    ← 代码 01
§2  三种跑法:串行、线程池、asyncio;先规划再写                         ← 代码 01
§3  并发开多大:服务端的限额                                          ← 代码 01
§4  竞态与锁:丢失更新、check-then-act、三种修法、两种用错              ← 代码 02
§5  缓存:键的设计,以及它在开发流程里的价值                            ← 代码 03
§6  账本与预算                                                       ← 代码 03
§7  限速:令牌桶,主动 vs 被动                                        ← 代码 04
§8  把几层叠起来:CachedLM,以及给一个任务做时间 / 成本规划              ← 代码 03、04
§9  小结   §10 常见误区   §11 练习   §12 延伸阅读   §13 下一讲

1. 两种慢 · Two kinds of slow

代码 01 的演示 1 把两种慢放在一起比(8 个任务,分别串行和用 8 个线程):

任务类型                    8 个任务串行 ⏱  8 个线程并发 ⏱  快了多少 ⏱  为什么
在等(调模型)                      0.43 s          0.06 s        7.7×  等待时 GIL 被释放 → 真的能一起等
在算(数 200k 以内的质数)          1.19 s          1.16 s        1.0×  执行字节码要持有 GIL → 线程轮流跑

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 的一个实现细节:同一时刻只有一个线程能执行 Python 字节码。听起来”多线程没用”,但关键在于——线程在等 I/O(网络、磁盘、time.sleep)时会释放 GIL。所以:

任务多线程有效吗为什么
调 API、读写文件、等数据库非常有效大部分时间在等,等的时候 GIL 是放开的
纯 Python 的计算(分词、循环、字符串处理)几乎无效一直持有 GIL,线程只是轮流跑
numpy / 压缩 / 加密等 C 扩展里的计算可能有效这些库在计算时常常会主动释放 GIL

智能体的工作几乎全是等待型的——等模型生成、等工具返回。所以线程池是智能体工程里最划算的一个优化。

(注:从 Python 3.13 起有可选的”自由线程”构建,没有 GIL;但默认构建仍然有,第三方库的支持也还在跟进。按有 GIL 来设计仍然是稳妥的。真正的计算密集任务要用多进程 ProcessPoolExecutor。)


2. 三种跑法 · Serial, threads, asyncio

2.1 先规划:一个公式

在写任何并发代码之前,先用这个公式估一下:

T≈⌈nw⌉×LT \approx \left\lceil \frac{n}{w} \right\rceil \times L

nn 个调用、最多 ww 个并发、每个 LL 秒。它是一个下界(忽略了启动开销、限速、长尾延迟),但足以回答两个关键问题:这个任务是 1 分钟还是 10 分钟?加并发还有没有用?

代码 01 的演示 2(24 个调用,每个 50 ms):

做法                 最大并发  理论下界  实测 ⏱  峰值同时进行
串行(一个一个等)          1    1.20 s  1.29 s             1
线程池(4 个线程)          4    0.30 s  0.32 s             4
线程池(8 个线程)          8    0.15 s  0.17 s             8
线程池(24 个线程)        24    0.05 s  0.06 s            24
asyncio(信号量 8)         8    0.15 s  0.16 s             8

实测和理论下界非常接近(差的那点是线程启动和调度的开销)。

2.2 线程池还是 asyncio

对”等 I/O”这件事,两者是等价的(上表里 8 并发的两行几乎一样)。区别在工程上:

线程池(ThreadPoolExecutor)asyncio
写法普通的同步函数,几行就能并发整条链路都要 async / await
能用的库任何同步库(urllib、SDK 的同步客户端)需要 async 版本的库
开销每个线程有栈,几百个线程就偏重很轻,几千个并发没问题
典型用途几十到几百个并发的工具调用高并发服务端

对智能体 harness 来说,线程池通常是更简单的选择:并发数就几十,而且能直接用官方 SDK 的同步客户端。

2.3 asyncio 里的一个经典错误

协程里调用的是                     8 个任务耗时 ⏱  相当于
await lm.acall(...)(让出控制权)          0.05 s  真正并发
lm.call(...)(阻塞)                       0.43 s  完全串行

asyncio 是单线程的并发:一个事件循环轮流推进很多协程,每个协程在 await 处让出控制权。一旦某个协程调用了阻塞的函数(time.sleep、同步的 HTTP 库、读大文件),整个事件循环都停在那里——并发完全消失。

这个 bug 很隐蔽:代码看起来是 async 的,功能也正确,只是莫名其妙地慢。判断办法:这个库有 async 版本吗? 没有就用 loop.run_in_executor 把它丢进线程池。


3. 并发开多大 · How much concurrency

并发不是越大越好。真正的上限由服务端决定。代码 01 的演示 3(服务端只允许 4 个并发):

我们开的并发  成功  被限速(429)  峰值同时进行  耗时 ⏱
2               16              0             2  0.43 s
4               16              0             4  0.21 s
8                4             12             4  0.06 s
16               4             12             4  0.05 s

开 8 或 16 个线程并没有更快,只收到一堆 429——而被拒的请求通常也算进你的配额,等于白白浪费,还可能让服务商判定你在滥用。正确的做法是自己先排队(§7)。

注意这张表里”被限速”的数字在你的机器上可能不同:线程的调度顺序不是确定的。这正是并发代码难测的地方——下一节讲怎样把这种不确定性变成可复现的测试。


4. 竞态与锁 · Races and locks

4.1 丢失的更新

代码 02 的演示 1:8 个线程各给一个计数器加 1,结果是 1。

写法                期望  实际  为什么
读 → 写(没有锁)      8     1  8 个线程都读到 0,各自写回 1,7 次更新丢了
with lock: 读 → 写     8     8  一次只有一个线程能读-改-写

counter += 1 不是一个动作,而是三个:读出来、加一、写回去。两个线程在”读”之后、“写”之前交错,后写的那个就覆盖了前一个的结果——这叫丢失更新(lost update)。

我们的演示用 threading.Barrier 把这个交错固定下来,所以每次运行都稳定错成 1。这是一个很实用的技巧:真实的竞态是偶发的(可能一千次里错一次,测试里看不出来,上线后在高负载下才暴露),而用屏障或显式的让出点可以把它变成一个确定会失败的测试。

4.2 哪些操作是”原子”的

操作多线程下为什么
d['k'] += 1(读-改-写)不安全读和写之间隔着好几步字节码
list.append(x)安全CPython 里 list 的方法本身是原子的
d[k] = v(纯赋值)安全单次赋值不会读旧值
if k not in d: d[k] = v不安全检查和赋值之间可能被插队(check-then-act)
d.setdefault(k, v)安全检查和赋值在同一个调用里完成
queue.Queue.put / get安全标准库里专门为并发设计的

代码 02 的演示 2 用 dis 反汇编 counter['value'] += 1:取出旧值和写回之间隔着好几条指令(具体条数随 Python 版本不同),中间都有可能被切换走。

工程上的结论:不要去猜哪一句是原子的。依赖这些实现细节很脆弱(不同实现、不同版本可能不同)。要么用锁把不变量圈起来,要么用专门为并发设计的东西。

4.3 三种修法

代码 02 的演示 3 并发构建一个共享索引(像 HW1 的 BM25 索引或 LM 缓存):

做法                      结果                         为什么
不安全(check-then-act)  桶里只剩 1 条(期望 8)      8 个线程各建了一个列表,只有最后写进字典的留下来
A. 一把锁护住整个更新     [0..7]                       正确;代价是所有线程在这里排队
B. 分区 + 合并            [0..7]                       每个线程只写自己的格子,完全没有争用
C. 先建好再发布(只读)   8 个读者看到的都一样         构建期间别人看不到它;发布后只读 → 不需要锁
修法适合什么代价
A. 锁更新很零散、无法避免共享所有线程排队;锁里绝不要做 I/O(否则并发被吃掉)
B. 分区 + 合并“一批输入各算各的”——HW1 的每日摘要正是这样需要一次合并;内存里同时有多份部分结果
C. 先建好再发布“建一次、读很多次”的索引构建期间旧版本还在用(内存翻倍)

最省心的是 C:只读的数据结构天生线程安全。最常用的是 B:没有共享可写状态,就没有竞态。

还有一个细节:修法 B 合并后要显式排序,这样结果与线程完成的顺序无关。并发代码的输出顺序不要依赖调度(需要稳定顺序就排序,或者用 executor.map 保持输入顺序)。

4.4 锁的两种用错

错误                           现象                                    怎么避免
两把锁,拿的顺序相反           互相等 → 死锁(2 / 2 个线程拿不到第二把锁)  给锁定一个全局顺序;能用一把就别用两把
锁的范围不对(分别锁读和写)   结果 1,期望 8                          把需要一起生效的操作放进同一个临界区
  • 死锁:线程 1 拿着 A 等 B,线程 2 拿着 B 等 A。症状是”卡住不动”(没有报错),比竞态更难查。避免办法:规定一个固定的获取顺序;给 acquire 加超时,至少能把死锁变成一个报错。
  • 范围不对比忘记加锁更坑:代码里到处是 with lock,看起来很安全,但两个临界区之间还有缝。写锁时要问的不是”我锁了吗”,而是”哪些操作必须一起生效”。

工程建议:共享可写状态越少越好。实在要有,就把它连同它的锁封装进一个类,对外只暴露几个安全的方法——conc_lab 里的 DiskCache、Ledger、Budget、TokenBucket 都是这么做的。


5. 缓存 · Caching

5.1 键里该放什么

缓存键必须覆盖所有会影响回复的东西。代码 03 的演示 1:

变了什么              缓存键(前 16 位)
基准                  b74a8decdefe791e
只改了消息里一个字    c7789d5bec2a7c8c
只改了系统提示        3a31fe3df493413f
只换了模型            fc1eb33a4c7c9480
只改了温度            9b233eb167d9ff0a
只换了 seed           71ac81c2cb5a3654
完全一样(再算一次)  b74a8decdefe791e  ← 和基准相同
  • 少放一样就会串台:换了模型却读到旧模型的回复,这种 bug 很难查。
  • 放多了永远不命中:键里混进每次都变的东西(时间戳、请求 id、临时目录的绝对路径)就全废了。HW1 的 handout 专门提醒过:评估时每个会话都有一个新的临时工作区,如果提示词或工具结果里出现这个绝对路径,缓存就命中不了。
  • 温度 > 0 时同样的输入本来就该给出不同的回复,所以 seed(如果 API 支持)也要进键。在不支持 seed 的 API 上缓存温度 > 0 的调用,等于把随机性冻住了——这件事要想清楚再做。

实现上用 sha256(排好序的 JSON):同样的内容一定得到同样的键,长度固定、能直接当文件名。

5.2 缓存省掉的是什么

代码 03 的演示 2 模拟一个典型的开发流程(12 封邮件,反复重跑):

这一轮                         调用次数  缓存命中  真的发出去  这一轮花费(千分之一美元)
第 1 次跑(全新)                    12         0          12                      4.4640
第 2 次跑(什么都没改)              12        12           0                      0.0000
第 3 次跑(只改了系统提示)          12         0          12                      4.6440
第 4 次跑(改回原来的提示词)        12        12           0                      0.0000
  • 第 2 次跑完全免费:开发智能体时你会反复重跑同一个流程(调试下游代码、改判分器、看日志),缓存让这些重跑不花钱也不用等。这就是 HW1 说”重复一次完全相同的调用不花钱”的意义。
  • 改一个字的系统提示,整轮缓存全部失效:系统提示在每一条消息里,进了每一个键。HW1 的 handout 也提到这一点——改工具描述或系统提示会让所有智能体级别的测试重新付费。
  • 第 4 次跑把提示词改回去,又全部命中:缓存是按内容寻址的,不是按时间。
  • 这提示了一个设计:把稳定的部分(长系统提示、工具说明)和易变的部分分开。稳定的前缀还能让服务端的 prompt caching 生效(第 2 讲会讲,那是另一回事,但道理相通)。

6. 账本与预算 · Ledger and budget

6.1 账本:钱花在哪一步了

代码 03 的演示 3 跑了一个简化的每日摘要(12 次分类用小模型,1 次写摘要 + 1 次判分用大模型):

步骤(标签)  调用  输入 token  输出 token  花费(千分之一美元)  占比
digest           1          62          22                0.5160   43%
judge            1          34          23                0.4470   38%
triage          12         228         180                0.2256   19%

调用次数多 ≠ 花钱多:12 次分类只占 19%,而各 1 次的写摘要和判分占了 43% 和 38%——因为分类用的是便宜的小模型(我们假设便宜 15 倍)。只看调用次数会得出完全相反的结论。

两个要点:

  • 标签是账本的关键。一次任务里的调用来自不同步骤,只有打上标签才能定位成本。HW1 的测试套件就给每次调用打上测试名。
  • 输出 token 通常比输入贵几倍(我们假设大模型 3 : 15)。让模型少说废话(“只回一个词”)既快又省。
  • “便宜的步骤用小模型”是最常见的成本优化:这个玩具里把分类换成小模型,总花费从约 4.3 降到约 1.2(千分之一美元)。第 9 讲会讲怎样决定哪一步可以降级。

实现上用 JSONL 追加写:一次调用一行,崩溃也不丢之前的记录,事后用 wc -l、grep、jq 就能分析(P1 §6.4)。

6.2 预算:失控循环的最后一道保险

代码 03 的演示 4 给一个很小的预算,看它怎样停下来:

第几次调用  结果             已花(千分之一美元)  剩余
1–3         成功             …                     …
4           BudgetExceeded   0.2256                0.0000
  • 预算在发出调用之前检查,所以抛异常时钱还没花出去。
  • 为什么需要它:一个写错的循环(模型复读、工具反复失败重试、反思器反复触发)能在几分钟里花掉很多钱。第 1 讲 §3.8 的 max_steps 管步数,预算管钱——两道独立的保险。
  • 缓存命中不计费,所以预算用完之后,已经缓存过的调用还能继续。这个性质很有用:预算耗尽时,测试里重复的部分仍然跑得过,只有真正新的调用会停下来。
  • HW1 有两层预算:课程发的 key 本身有额度(服务端),外加一个你自己设的 CS329Z_BUDGET_USD(客户端)。客户端这一层的意义是”让失控的循环在烧光 key 之前先停下来”。

7. 限速 · Rate limiting

7.1 令牌桶

服务商的限额通常是”每分钟多少请求 / 多少 token”。对应的标准算法是令牌桶:桶里最多 capacity 个令牌,每秒补 rate 个;每次调用取一个,没有就等。

代码 04 的演示 1(用虚拟时钟,所以结果可复现):

补充速率  桶容量  一开始能连发几个  12 个全发完用了  这 12 个的平均速率  突发之后的间隔
5/s            5                 5           1.40 s               8.6/s  0.20 s → 5.0/s
5/s            1                 1           2.20 s               5.5/s  0.20 s → 5.0/s
5/s           10                10           0.40 s              30.0/s  0.20 s → 5.0/s
  • 桶里有令牌就立刻通过 → 一开始能连发 capacity 个(突发),之后被 rate 拉平。这正是真实 API 限速的行为。
  • 注意”这 12 个的平均速率”那一列没有收敛到 5/s(8.6、5.5、30.0):只发 12 个请求时,突发那几个把平均值拉高了,容量越大拉得越狠。真正守住的是”突发之后的间隔”——三种配置都是 0.2 秒一个,正好 1/rate1/\text{rate}。看限速要看稳态,样本太少会得出错误的结论。
  • 容量设多大?太小会浪费配额(服务商允许突发时你没用上),太大可能在一瞬间打爆对方的并发限制。常见做法:取”每秒限额”或稍多一点。

虚拟时钟(时间只在”睡眠”时前进)是测试限速、退避、超时这类时间相关逻辑的实用技巧——不用真的等,而且结果可复现。写这类代码时把时钟作为参数传进去。

7.2 主动限速 vs 被拒再重试

代码 04 的演示 2(服务端只允许 4 个并发,16 个调用):

做法                           成功  被拒(429)  总共发了几次请求  耗时 ⏱
并发 16,不限速(被拒再重试)    16           36                52  0.39 s
并发 4,和服务端限额对齐         16            0                16  0.23 s

两种做法都把 16 个调用做完了,但”被拒再重试”发出了 52 次请求(而不是 16 次)。被拒的请求通常也算进配额,等于白白浪费,还可能让服务商判定你在滥用。

主动限速更省、更稳、延迟也更可预测。重试应该是兜底,不是主要手段。 怎么知道限额?服务商的文档和响应头(X-RateLimit-*、Retry-After,见 P2 §5.3)。工程上常见做法:从保守的并发开始,观察 429 的比例,再慢慢往上调。

7.3 各层的时间加起来

代码 04 的演示 3 把限速、延迟、重试叠在一起(虚拟时钟):

第几个任务  发了几次请求  这个任务用了(虚拟秒)
1                      1                  0.00 s
2                      1                  0.10 s
3                      2                  0.60 s      ← 失败一次,退避 0.5 秒
4                      1                  0.10 s

一次调用的真实耗时 = 限速排队 + 模型延迟 + 失败后的退避。注意第 3 个任务:退避时间往往比调用本身还长。所以 base_delay 不能乱设——太短会压垮正在恢复的服务端,太长会让一个 20 步的智能体任务多等几十秒。


8. 把几层叠起来 · Putting it together

8.1 CachedLM 的顺序

conc_lab.CachedLM 把各层叠成一个”消息进、字符串出”的 LM。顺序是有讲究的:

1. 缓存   ← 最先:命中就什么都不用做了,不花钱、不占限速、不用等
2. 预算   ← 确认还有钱再发请求
3. 限速   ← 主动排队,比被拒再重试有效率
4. 重试   ← 包着真正的调用
5. 调用
6. 记账   ← 成功之后才记(失败的尝试不计费,但要记次数)

这就是 HW1 那个 LM 封装的结构。第 2 讲会在它上面接真实的模型,加上流式、结构化输出和 prompt caching。

8.2 给一个任务做规划

代码 04 的演示 4 规划 HW1 的每日摘要(280 封邮件,每次分类假设 1.2 秒):

并发  理论耗时             吞吐  需要的输入 token/s
   1     336 s  5.6 min   0.8/s                 183
   4      84 s  1.4 min   3.3/s                 733
   8      42 s  0.7 min   6.7/s               1,467
  16      22 s  0.4 min  13.0/s               2,933
  32      11 s  0.2 min  25.9/s               5,867

一次全新的运行:分类 280 次 ≈ 13.16,写摘要 1 次 ≈ 12.00,合计 ≈ 25.16(千分之一美元)
开发时重跑 10 次:没有缓存 ≈ 251.60,有缓存 ≈ 25.16(千分之一美元)

如果服务商的限额是每分钟 500 个请求:
  · 平均速率 8.3/s → 令牌桶设 rate=8.3、capacity≈9
  · 280 封邮件至少要 34 秒,所以并发超过约 10 就没有意义了

三个结论:

  1. 并发从 1 到 8,耗时从 5.6 分钟降到 42 秒——这就是 handout 提示”要并发分类”的原因。
  2. 并发的上限由限额决定,不是由你的机器决定。超过约 10 之后只是在排队等令牌。
  3. 缓存改变的是开发阶段的成本:同一个流程重跑 10 次,有缓存只付一次钱。

9. 小结 · Summary

  1. 两种慢:等待型任务(调 API、读文件)用线程池非常有效,因为等待时 GIL 被释放;计算型任务要用多进程。
  2. 先规划再写:T≈⌈n/w⌉×LT \approx \lceil n/w \rceil \times L 能在写代码前告诉你任务是 1 分钟还是 10 分钟、加并发还有没有用。
  3. 线程池和 asyncio 对等 I/O 等价;asyncio 里混入阻塞调用会让并发完全消失。
  4. 并发的上限由服务端决定:开太大只会收到 429,而且浪费配额。
  5. 竞态:+= 和 check-then-act 都不是原子的。三种修法——锁(别在锁里做 I/O)、分区合并(最常用)、先建后发布(最省心)。死锁和”锁的范围不对”是两种典型用错。
  6. 缓存键要覆盖模型、完整消息、采样设置,且不能含每次都变的东西。改一个字的系统提示会让整轮缓存失效。
  7. 账本按标签分摊成本:调用次数多 ≠ 花钱多;输出 token 比输入贵几倍。预算在调用前检查,是失控循环的最后一道保险,且不影响缓存命中。
  8. 令牌桶允许突发、守住稳态速率(看限速要看稳态);主动限速优于被拒再重试;一次调用的耗时 = 排队 + 延迟 + 退避。

10. 常见误区 · Common pitfalls

  1. “Python 有 GIL,所以多线程没用。” 等待 I/O 时 GIL 是放开的——智能体的工作恰好几乎全是等待。
  2. “并发开得越大越快。” 真正的上限是服务端的限额;超过之后只会收到 429 并浪费配额。
  3. “counter += 1 就一行,不用加锁。” 它是读-改-写三步,中间会被切换走,更新会丢。
  4. “我到处都写了 with lock,所以是安全的。” 锁的范围要覆盖整个不变量;分别锁读和写等于没锁。
  5. “竞态我测过了,没问题。” 竞态是偶发的。要用屏障 / 显式让出点把交错固定下来,变成一个确定会失败的测试。
  6. “缓存键用问题的文本就够了。” 少了模型名、系统提示、采样设置就会串台;多了时间戳、临时路径就永远不命中。
  7. “调用次数最多的那一步最花钱。” 不一定——模型档位和输出长度的影响常常更大。要按标签记账。
  8. “我会盯着账单的。” 失控的循环几分钟就能花很多钱。要在调用之前检查预算。
  9. “被限速了就重试。” 被拒的请求也算配额。先主动限速,重试只做兜底。
  10. “退避时间不重要。” 退避常常比调用本身更长,而且会被智能体的步数乘上去(P2 练习 2)。

11. 练习 · Exercises

带 💻 的题可以用本讲的模块验证。参考答案在每题后面(先自己做再看)。

练习 1(规划)。一个评估要跑 50 个任务,每个任务平均 8 步,每步一次模型调用,每次调用 1.5 秒。(a) 串行要多久?(b) 任务之间并发 10(任务内部的步骤必须串行)要多久?(c) 如果服务商限每分钟 300 个请求,这个并发能跑满吗?

参考答案

(a) 50 × 8 × 1.5 = 600 秒 = 10 分钟。(b) 每个任务 8 × 1.5 = 12 秒;50 个任务分成 ⌈50/10⌉ = 5 批 × 12 秒 = 60 秒。(c) 并发 10、每次 1.5 秒 → 稳态速率 10/1.5 ≈ 6.7 请求/秒 = 400 请求/分钟 > 300,跑不满:会被限速挡住,实际速率降到 5/秒,总时间变成 400/5 = 80 秒。按 §7.1 的办法,应该把令牌桶设成 rate = 5/s,并把并发降到 ⌈5 × 1.5⌉ ≈ 8。

练习 2(GIL)💻。把代码 01 演示 1 里的 busy() 换成 time.sleep(0.15)(同样的单次耗时),8 个线程并发会快多少?再换成 ProcessPoolExecutor 跑原来的 busy(),会快多少?为什么?

参考答案

换成 sleep 后会快约 8 倍——time.sleep 释放 GIL,和”调模型”一样是等待型。用 ProcessPoolExecutor 跑 busy() 在多核机器上也会快好几倍(受核数限制),因为每个进程有自己的解释器和 GIL;代价是进程启动慢、参数和返回值要序列化(pickle),所以只对”计算量远大于序列化开销”的任务值得。判断标准始终是:这段代码是在等,还是在算?

练习 3(竞态)💻。代码 02 演示 3 的不安全版本用 index.get(word) 再判断。如果改成 index.setdefault(word, []).append(i)(不加锁),结果对吗?为什么?再想:如果改成 index[word] = index.get(word, []) + [i](也不加锁)呢?

参考答案

setdefault(...).append(i) 是对的:setdefault 的”检查 + 赋值”在一次 C 调用里完成(原子),返回的列表对象是唯一的那一个,append 本身也是原子的。而 index[word] = index.get(word, []) + [i] 是错的:它是读-建新列表-写回三步,两个线程会各自基于旧列表造一个新列表,后写的覆盖前一个——又是丢失更新。可以用演示 1 的 Barrier 技巧把它复现出来。这个对比正好说明:“有没有加锁”不是关键,“有没有读-改-写的缝”才是关键。

练习 4(缓存键)💻。你的 harness 在系统提示里加了一行 当前时间:2026-10-15 14:32:01。(a) 缓存命中率会变成多少?(b) 如果改成只精确到天呢?(c) 如果模型确实需要知道时间,你会怎么设计?

参考答案

(a) 0:每次调用的系统提示都不同,键也不同(演示 1 的”只改了系统提示”那一行)。(b) 同一天内可以命中,跨天失效——对开发时的反复重跑已经够用了。(c) 几种做法:① 精度降到任务真正需要的粒度(天 / 小时);② 把时间从系统提示移到用户消息的末尾,这样至少服务端的 prompt caching 还能复用前面稳定的前缀(第 2 讲);③ 把”现在几点”做成一个工具,模型需要时才调用——这样它不进提示词,也就不影响缓存。

练习 5(账本)💻。改代码 03 的演示 3:把判分也换成小模型。(a) 总花费变成多少?(b) 占比最高的步骤变成哪个?(c) 这个决定有什么风险?

参考答案

(a)(b) 自己跑一下看——把 judge = CachedLM(big, ...) 改成 CachedLM(small, ...) 即可。大模型只剩写摘要那一次,所以它的占比会升到最高。(c) 风险是判分质量:LLM-as-judge 对模型能力很敏感,换成小模型可能让评估本身变得不可靠(第 13 讲会讲判分器的偏差和校准)。这正是成本优化的一般教训:省钱的地方要看它影响什么——分类错一封邮件代价很小,判分错了会让你对整个系统的判断失准。

练习 6(令牌桶)💻。用 TokenBucket(rate=2, capacity=4) 配虚拟时钟取 10 个令牌。(a) 前几个不用等?(b) 第 10 个在第几秒?(c) 如果把 capacity 改成 1,第 10 个在第几秒?

参考答案

(a) 前 4 个(桶初始是满的)。(b) 之后每个要 1/2 = 0.5 秒,第 5 到第 10 个共 6 个 → 第 10 个在 3.0 秒。(c) capacity = 1 时只有第 1 个免费,第 2 到第 10 个各 0.5 秒 → 第 10 个在 4.5 秒。可以用 VirtualClock 直接跑出来验证。注意两者的稳态速率都是 2/s,差别只在开头的突发——这就是 §7.1 那个”看稳态”的要点。

练习 7(CachedLM 的顺序)。如果把 CachedLM 里”缓存”和”限速”的顺序换一下(先取令牌、再查缓存),会有什么后果?再想:如果把”记账”放在调用之前呢?

参考答案

先限速再查缓存:缓存命中也要排队等令牌。开发时反复重跑一个全命中的流程,本来应该是瞬间完成,现在要按限速一个一个地”等”——完全没有必要,因为缓存命中根本没有发请求。所以缓存必须在最前面。把记账放在调用之前会把失败的尝试也算进花费;真实 API 里失败(尤其是 429 和 5xx)通常不计费,这样记会高估成本,还会让预算过早耗尽。不过失败的次数值得单独记——它是限速和重试设置是否合理的信号。

练习 8(虚拟时钟)💻。代码 04 演示 1 的令牌桶在虚拟时钟下曾经死循环(见代码 README 的”关于实验结果”第 1 条)。(a) 读 TokenBucket.acquire 里的 EPS,解释为什么需要它。(b) 为什么这个 bug 在真实时钟下不会出现?(c) 这说明用虚拟时钟测试有什么额外的好处?

参考答案

(a) 浮点运算让补到的令牌差一点点(实测是 0.9999999999999998),于是代码判断”还不够”,算出还需要等 4.4e-17 秒;虚拟时钟 t += 4.4e-17 在 t = 1.2 时是个无效操作(浮点精度不够),于是时间不前进、令牌不增加、永远循环。加一个 EPS = 1e-9 的容差就把这个边界吃掉了。(b) 真实时钟每次读都会前进(而且远大于 4e-17),所以循环总能跳出——bug 被掩盖了。(c) 虚拟时钟不仅让测试快而可复现,还会暴露那些被真实时间的”抖动”掩盖的逻辑错误。代价是你得像这次一样处理好浮点的边界。


12. 延伸阅读 · Further reading

  • Python 官方文档:concurrent.futures(ThreadPoolExecutor、ProcessPoolExecutor、as_completed)、threading(Lock、Barrier、Semaphore)、asyncio(gather、Semaphore、run_in_executor)、queue。
  • Python 官方 FAQ 里关于 GIL 的说明,以及 PEP 703(可选的自由线程构建)。
  • 令牌桶 / 漏桶算法:任何一本系统设计或网络教材;云服务商的限速文档(AWS、Google Cloud)都用这套术语。
  • 原课作业 1 的 handout(并发分类的提示、.lm_cache 的缓存与账本、CS329Z_BUDGET_USD、评估的成本):https://github.com/cs329z/assignment1-harness
  • 前置讲 P2 §5(超时、重试、退避、幂等)——这一讲的限速和缓存是接在那一层之上的。

13. 下一讲预告 · Next lecture

前置讲 P4 安全基础:威胁模型、最小权限与注入:到这里,智能体已经能操作本机(P1)、调用网络(P2)、高效可靠地大量调用(P3)。最后要问的是:它会不会做出我们不想要的事? P4 会讲威胁模型(资产、攻击者、信任边界)、最小权限与能力、沙箱的层次(白名单 → 超时 → 操作系统沙箱,并在 macOS 上真的跑一个被拒绝联网和写盘的进程)、经典注入(SQL 注入、Shell 注入)与”代码和数据混在一起”这个根源、以及它在 LLM 时代的新形态——间接提示词注入(第 1 讲 §6.5 那个无害的 CANARY 实验的完整版)。做完 P4,第 4 讲的工具设计和第 14 讲的安全与护栏就有了地基。

← 返回课程目录