这是一篇前置讲,不对应 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
- 分清等待型(I/O 密集)和计算型(CPU 密集)的慢,知道 GIL 为什么让线程对前者有效、对后者无效。
- 会用线程池和 asyncio 并发地跑一批调用,会用 ⌈n/并发⌉×延迟 这个公式先规划再写代码,并说出 asyncio 里混入阻塞调用的后果。
- 认识竞态:丢失更新、check-then-act;会用锁、分区合并、先建后发布三种方式修它;知道死锁和”锁的范围不对”这两种用错。
- 会设计缓存键(该放什么、不该放什么),知道缓存在开发流程里省掉的是什么。
- 会用账本回答”钱花在哪一步了”,并知道调用次数多 ≠ 花钱多。
- 会用预算给失控的循环兜底,知道为什么预算用完后缓存里的调用还能继续。
- 理解令牌桶限速,知道”主动限速”比”被拒再重试”好在哪,并会把限速、重试、超时的时间加起来算进任务的时间预算。
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 先规划:一个公式
在写任何并发代码之前,先用这个公式估一下:
个调用、最多 个并发、每个 秒。它是一个下界(忽略了启动开销、限速、长尾延迟),但足以回答两个关键问题:这个任务是 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 秒一个,正好 。看限速要看稳态,样本太少会得出错误的结论。
- 容量设多大?太小会浪费配额(服务商允许突发时你没用上),太大可能在一瞬间打爆对方的并发限制。常见做法:取”每秒限额”或稍多一点。
虚拟时钟(时间只在”睡眠”时前进)是测试限速、退避、超时这类时间相关逻辑的实用技巧——不用真的等,而且结果可复现。写这类代码时把时钟作为参数传进去。
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 到 8,耗时从 5.6 分钟降到 42 秒——这就是 handout 提示”要并发分类”的原因。
- 并发的上限由限额决定,不是由你的机器决定。超过约 10 之后只是在排队等令牌。
- 缓存改变的是开发阶段的成本:同一个流程重跑 10 次,有缓存只付一次钱。
9. 小结 · Summary
- 两种慢:等待型任务(调 API、读文件)用线程池非常有效,因为等待时 GIL 被释放;计算型任务要用多进程。
- 先规划再写: 能在写代码前告诉你任务是 1 分钟还是 10 分钟、加并发还有没有用。
- 线程池和 asyncio 对等 I/O 等价;asyncio 里混入阻塞调用会让并发完全消失。
- 并发的上限由服务端决定:开太大只会收到 429,而且浪费配额。
- 竞态:
+=和 check-then-act 都不是原子的。三种修法——锁(别在锁里做 I/O)、分区合并(最常用)、先建后发布(最省心)。死锁和”锁的范围不对”是两种典型用错。 - 缓存键要覆盖模型、完整消息、采样设置,且不能含每次都变的东西。改一个字的系统提示会让整轮缓存失效。
- 账本按标签分摊成本:调用次数多 ≠ 花钱多;输出 token 比输入贵几倍。预算在调用前检查,是失控循环的最后一道保险,且不影响缓存命中。
- 令牌桶允许突发、守住稳态速率(看限速要看稳态);主动限速优于被拒再重试;一次调用的耗时 = 排队 + 延迟 + 退避。
10. 常见误区 · Common pitfalls
- “Python 有 GIL,所以多线程没用。” 等待 I/O 时 GIL 是放开的——智能体的工作恰好几乎全是等待。
- “并发开得越大越快。” 真正的上限是服务端的限额;超过之后只会收到 429 并浪费配额。
- “
counter += 1就一行,不用加锁。” 它是读-改-写三步,中间会被切换走,更新会丢。 - “我到处都写了
with lock,所以是安全的。” 锁的范围要覆盖整个不变量;分别锁读和写等于没锁。 - “竞态我测过了,没问题。” 竞态是偶发的。要用屏障 / 显式让出点把交错固定下来,变成一个确定会失败的测试。
- “缓存键用问题的文本就够了。” 少了模型名、系统提示、采样设置就会串台;多了时间戳、临时路径就永远不命中。
- “调用次数最多的那一步最花钱。” 不一定——模型档位和输出长度的影响常常更大。要按标签记账。
- “我会盯着账单的。” 失控的循环几分钟就能花很多钱。要在调用之前检查预算。
- “被限速了就重试。” 被拒的请求也算配额。先主动限速,重试只做兜底。
- “退避时间不重要。” 退避常常比调用本身更长,而且会被智能体的步数乘上去(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 讲的安全与护栏就有了地基。