`add()` 和 `__add__()` 到底差在哪
这两个写法表面上都在做“相加”,但适用场景并不相同。一个是直接修改当前对象,另一个是返回一个新的结果对象;理解这点,后面关于性能、副作用和线程安全的判断才不会混在一起。

`add()`:原地修改,适合连续累加
def add(self, other: "Usage") -> None:
self.prompt += other.prompt
self.completion += other.completion
self.total += other.total
self.cached += other.cached
self.calls += other.calls
self.cost += other.cost
常见用法是把多个统计值不断加到同一个对象上:
total = Usage() total.add(usage1) total.add(usage2)
这种方式的直接好处是不创建新对象,在大量累加时更省分配开销:
usage = Usage()
for item in usages:
usage.add(item)
如果你的目标就是把许多 `Usage` 汇总到一个容器里,`add()` 的执行路径通常更直接。
`__add__()`:返回新对象,表达更安全
def __add__(self, other: "Usage") -> "Usage":
return Usage(
prompt=self.prompt + other.prompt,
completion=self.completion + other.completion,
total=self.total + other.total,
cached=self.cached + other.cached,
calls=self.calls + other.calls,
cost=self.cost + other.cost,
)
它对应的是 Python 的 `+` 运算符:
total = usage1 + usage2
优点是原对象不变,结果更容易预测,也支持链式写法:
total = u1 + u2 + u3 + u4
这更接近“输入不变,输出新值”的思路。代价则是频繁相加时会持续创建临时对象:
result = Usage()
for u in usages:
result = result + u
在这个过程中,结果对象会不断被新对象替换。原文里的过程可以概括为:
第1次:Usage对象A + u1 -> B
第2次:B + u2 -> C
第3次:C + u3 -> D
为什么 `add()` 更容易埋下隐藏问题
`add()` 最大的问题不是语法,而是副作用。只要还有别的变量指向同一个对象,或者这个对象被函数直接拿去改,结果就可能超出预期。

1. 共享引用会导致意外修改
usage = Usage(prompt=100) backup = usage usage.add(Usage(prompt=50)) print(backup.prompt)
输出结果是:
150
原因不是 `backup` 被“同步更新”,而是 `backup = usage` 只复制了引用。两者实际指向同一个对象:
backup ─┐
├──> Usage(prompt=100)
usage ──┘
因此 `add()` 改到的是这块共享对象本身。
2. 作为函数参数传递时,会顺带改掉外部状态
def calculate_total(usage):
usage.add(Usage(prompt=100))
return usage
u = Usage(prompt=10)
result = calculate_total(u)
print(u.prompt)
输出:
110
这里 `calculate_total()` 并不是基于参数“算出一个新值”,而是直接把传入对象改掉了。所以调用方拿回 `result` 的同时,原来的 `u` 也已经变化。
多线程下真正要注意的点
如果多个线程同时对同一个 `Usage` 实例执行 `add()`,风险就不再只是副作用,而是会出现数据竞争。
`self.prompt += other.prompt` 不是原子操作
假设多个 LLM 请求结束后,同时更新一个全局对象:
global_usage.add(response_usage)
线程 A 可能这样执行:
读取 prompt=100
+50
写回150
线程 B 可能同时执行:
读取 prompt=100
+30
写回130
最后结果可能是:
130
而不是预期中的:
180
原因在于下面这句并不是一次不可分割的操作:
self.prompt += other.prompt
它实际可以理解成:
tmp = self.prompt tmp = tmp + other.prompt self.prompt = tmp
多个线程交错执行时,就会发生丢更新。要直接在共享对象上做原地累加,至少要加锁:
from threading import Lock
lock = Lock()
with lock:
usage.add(other)
为什么当前这份代码还能继续保留 `add()`
关键不在于 `add()` 天生安全,而在于它现在所处的并发结构,并没有让多个线程同时去写同一个 `Usage` 对象。

每个 worker 线程只处理自己的局部统计
当前结构里:
- 每个并发任务都会在 `run_source()` 里创建自己的 `Workflow`
- 每个 `Workflow` 有自己的 `self.usage`
- `source_usage` 也是每个任务自己的局部变量
- `global_usage` 只在主线程的 `for future in as_completed(futures)` 里合并
对应流程如下:
# 每个线程: run_source() -> Workflow() -> workflow.usage -> source_usage # 主线程: as_completed(futures) -> 拿到某个 source_usage -> merge_usage_map(global_usage, source_usage)
也就是说,worker 线程内部可以各自统计,但不会一起对同一份全局 `Usage` 直接执行:
usage.add(other)
并发执行,串行汇总
真正的全局合并发生在主线程,因此当前统计不会因为 `add()` 并发写入而乱掉。风险更大的其实是下面这种设计:
global_usage = {}
# 多个 worker 线程里同时执行
merge_usage_map(global_usage, workflow.usage)
如果多个线程同时改同一个 `global_usage`,同一模型下的计数就可能丢失。
而现在的汇总方式是:
for future in as_completed(futures):
title, success, error, usage = future.result()
merge_usage_map(global_usage, usage)
虽然前面的 5 个 worker 在并发运行,但每个任务只是返回自己的 `usage`;真正写入 `global_usage` 的动作,是主线程一个一个处理完成结果。
`as_completed(futures)` 的特点也决定了它不会被某个慢任务拖住全部已完成任务的汇总。谁先完成,主线程就先拿谁的结果:
任务 3 先完成 -> 主线程立刻拿到任务 3 的 usage
-> merge_usage_map(global_usage, 任务3_usage)
-> 线程池空出一个位置,开始任务 6
任务 1 完成 -> 立刻 add 任务 1 的 usage
任务 5 很慢 -> 它没完成前不会 add 它自己的 usage,但不会阻塞其他已完成任务的汇总
唯一会等待的地方,是最终总函数返回前要等全部 `future` 结束,因为最后的总计必须包含所有任务。
实际选择建议:先看语义,再看执行场景
如果你需要的是可预测、无副作用的对象运算,`__add__()` 更贴近直觉;如果你明确是在做批量累加,并且能保证对象不会被共享误改,`add()` 会更省对象创建成本。
结合这份代码的现状,可以得到一个更准确的结论:
- 当前代码没有并发问题,因为全局合并发生在主线程
- `add()` 本身不是线程安全的,不能直接据此推广到共享全局对象的并发写入场景
- 现阶段最稳的做法,仍然是让 worker 只返回各自的 `usage`,再由主线程统一合并
因此,这里的判断不该简单落在“`add()` 好还是 `__add__()` 好”,而是要先确认:你到底是在做局部汇总、值对象运算,还是跨线程共享状态更新。







