全量跑不动、人肉挑不完:代码量涨8倍之后,测试选择策略该先改哪一步
假设你团队里的AI编码搭档干了一整夜:第二天一早,四十多个PR同时挤进流水线,CI从提交到出结果排起三个小时的队。数字是打的比方,但方向不是危言耸听——当写代码的不再只有人,“每条PR跑全量测试”这条CI铁律,正在以肉眼可见的速度失效。
真实的样本来了。Anthropic工程师Sachin Malhotra在2026年9月14日的官方博客里公开了一组数字:Claude编写了公司80%的合并代码;工程师人均每季度合并的代码量,是2021—2025基线的8倍;测试数量涨到10倍;CI任务量半年内暴涨25倍。而他们用来“只跑受影响测试”的那套测试影响分析(Test Impact Analysis,TIA)服务,恰恰成了25倍洪峰里最先垮掉的一环:三次续命补丁一次比一次撑得短,最后推倒重构。
一句交代:本次写作时,原文页面直连抓取超时。下文涉及的架构细节与数字,取自对该文的详实转述,并经多家媒体的标题层信息交叉印证;官方博客原文未能逐字核验,特此说明。

一、这套系统原本长什么样:一个有状态的“记仇本”
Anthropic这套TIA服务分两个部件。Listener记录每次CI运行的每个测试结果,按代码变更不断积累“哪个测试在哪次改动之后通过或失败”的历史;Selector读这份历史,给定一个PR的diff,决定这个改动到底要跑哪些测试。
问题出在起点。按原始设计,测试结果基本由单一写入者按序处理——单写者、有状态,全部历史攒在一个服务的进程里。这个前提在小流量时代完全成立;可当CI任务量半年翻25倍,这个“记仇本”就成了整条流水线的漏斗口。
四组数字各自在增长什么,值得拆开看:
| 数字 | 口径(转述原文) | 增长的是谁 | 最先撞上的瓶颈 |
|---|---|---|---|
| 80% | Claude编写的合并代码占比 | 代码的作者结构 | PR数量不再受人类打字速度约束,触发量失控 |
| 8倍 | 工程师人均每季度合并代码量,对比2021—2025基线 | 人的产出速率 | 构建与测试任务总量 |
| 10倍 | 测试数量 | 测试资产本身 | 单次流水线时长、失败重跑面 |
| 25倍 | CI任务量(半年内) | 以上三条叠加的总账 | 记录与选择测试结果的服务本身 |
注意最后一行:压垮的不是测试,是“管理测试的那个系统”。这正是很多团队规划CI时的盲区——只预算跑测试的机器,不预算记录测试结果的系统。
二、三次续命补丁:70天、29天、不到1天
面对25倍,团队最初做的全是“标准操作”:加机器、分片、重启。转述原文给出的时间衰减曲线,比任何容量规划课都刺眼:
| 补丁 | 做法 | 撑了多久 | 失效原因 | 对测试侧的含义 |
|---|---|---|---|---|
| ① | 给Listener换更大的机器 | 约70天 | 单机吞吐天花板还在,流量按复利来 | 纵向扩容对指数增长只是买时间 |
| ② | 按package分片、一片一worker | 约29天 | 分片负载不均,热点package先垮 | 静态分片假设分布稳定,AI生成的代码不信这个假设 |
| ③ | 每日定时重启绕内存上限 | 不到1天,积压越滚越大 | 内存是症状,状态无处安放才是病根 | 用运维动作掩盖架构问题,代价是数据缺口静默扩大 |
三次补丁有一个共同点:全是线性手段——更多的核、更多的片、更勤的重启——去对付指数压力。70天→29天→不到1天的衰减不是运气差,是数学:每次补丁买来的余量,都会在下一个增长周期被同一斜率原样吃掉。
这就是“为什么传统CI做法会漏”的完整证据链:全量跑不是不忠实,是扛不住测试10倍、任务25倍的增长;人肉挑测试不是没经验,是经验更新的速度追不上PR生成的速度;而给CI系统打线性补丁,等于用上周期的想象力规划下周期的容量。

三、重构:状态搬进数据库,写入端从此无状态
最终方案是把“进程内的记仇本”改成数据库支撑的journal,两部件的职责被重新切分:
- 任意listener worker都作为无状态追加者,把每次CI的每个测试结果直接append进journal——不要求顺序、不需要协调,扛不住就横向加worker;
- 一个独立消费者每隔几秒把journal滚动归并成per-test的通过/失败历史,供selector读取。
写入与计算分离、追加优于更新、读路径只消费归并好的视图——“单写者”这个原始前提就此消失。据转述,这次重构由一名工程师在约3周内完成;作者自评,同样的工作放在一年前大约需要一个季度。工具链先把作者自己的产能也提了一个量级——这是整篇复盘里很Anthropic的一句注脚,也提醒测试团队:重构的成本曲线同样在被AI改写,过去“等明年排期”的架构债,如今可能三周就能还掉。
四、工程转译:中等规模Python团队的最小自建版
先看清楚Anthropic思想里哪些是“规模特权”、哪些是“普适结构”:横向扩展的worker池是前者(多数团队根本到不了25倍),而“结果历史要持久化、要能被选择器消费、写入要能被多来源追加”是后者。下面的最小自建版属于我方工程推导,不是官方实现,按你们的技术栈酌情取用:
- 依赖记录:单机起步直接用pytest-testmon,它基于测试执行痕迹记录“哪个测试依赖哪些源文件”,把依赖从每次现算变成一份持久资产;
- 历史结果持久化:CI每次运行结束,把“test→本次变更文件→pass/fail”三元组追加进一张只增不改的结果表——这就是journal的最小形态,放数据库或对象存储,别留在runner的内存或进程本地文件里;
- 基线刷新:main分支每天(或每N次合并)跑一次全量,重建依赖关系与通过历史的可信基线。selector只敢在新鲜基线附近做减法,基线一过期就整体降权;
- 盲区兜底:给依赖图看不见的测试(数据库迁移、外部API、共享fixture)打tag列白名单,diff命中相关路径即强制运行;依赖算不出、历史太薄、或属于新增测试的,一律自动回退全量——宁可多跑,不可漏跑。
四条合起来,就是一张“CI测试选择路线对照表”:
| 路线 | 每次PR成本 | 随代码量与PR量增长 | 漏测风险主要来源 | 适合阶段 |
|---|---|---|---|---|
| 无脑全量跑 | 高,且只会更高 | 线性甚至更差 | 几乎没有,主要是flaky干扰 | 测试数千以内、PR低频 |
| 人肉分层挑选 | 中,靠资深同学记忆 | 受人力与经验带宽硬约束 | 挑选者知识盲区、交接断层 | 团队约10人以内、模块稳定 |
| TIA+journal最小版 | 一次性研发投入 | 写入端可加机器,基本免疫 | 依赖图盲区、历史数据陈旧 | PR高频、测试资产持续膨胀 |
五、回归/CI落点:journal思路的最小实现
下面两段是可落地的示意代码(我方示例方案):第一段对应journal的“追加+滚动归并”,第二段用pytest钩子把结果写进去。字段和表名按你们CI替换即可。
# journal_min.py —— 结果日志只追加,消费者定期归并出 test→文件 历史,selector 只读归并结果
import json, sqlite3, time
def init(db_path):
con = sqlite3.connect(db_path)
con.execute("CREATE TABLE IF NOT EXISTS runs ("
"ts REAL, run_id TEXT, test_id TEXT, outcome TEXT, changed_files TEXT)")
con.execute("CREATE TABLE IF NOT EXISTS test_history ("
"test_id TEXT, src_file TEXT, passes INTEGER, failures INTEGER)")
return con
def append_result(con, run_id, test_id, outcome, changed_files):
# 任意 CI runner 都可调用:无状态、不要求顺序,扩容=加 runner
con.execute("INSERT INTO runs VALUES (?,?,?,?,?)",
(time.time(), run_id, test_id, outcome, json.dumps(changed_files)))
def merge_journal(con, window_days=90):
# 独立消费者:每几秒(或每次合并后)滚动归并,selector 永远不直接读原始 runs
con.execute("DELETE FROM test_history")
con.execute(
"INSERT INTO test_history "
"SELECT r.test_id, j.value, "
"SUM(r.outcome = 'passed'), SUM(r.outcome = 'failed') "
"FROM runs r, json_each(r.changed_files) j "
"WHERE r.ts > ? GROUP BY r.test_id, j.value",
(time.time() - window_days * 86400,))
def select_for_diff(con, changed_files):
"""返回可跳过的测试集合:diff 没碰它的任何依赖文件、且历史够干净够厚;其余一律照跑。"""
marks = ",".join("?" * len(changed_files))
hit = {t for (t,) in con.execute(
f"SELECT DISTINCT test_id FROM test_history WHERE src_file IN ({marks})",
changed_files)} # diff 命中的测试,无条件执行
skippable = set()
for test_id, p, f in con.execute(
"SELECT test_id, SUM(passes), SUM(failures) FROM test_history "
"GROUP BY test_id"):
if test_id not in hit and f == 0 and p >= 3:
skippable.add(test_id) # 无依赖命中+历史厚且零失败,才允许跳过
return skippable # 从未出现在历史里的新测试,不在跳过集内,照跑
# conftest.py —— pytest 钩子:把每个测试的结果连同本分支 diff 追加进 journal
import os, subprocess
from journal_min import init, append_result
def pytest_runtest_logreport(report):
if report.when != "call" and report.outcome == "passed":
return # setup/teardown 的 error 仍要记录,只跳过纯 setup 通过噪声
changed = subprocess.run(
["git", "diff", "--name-only", "origin/main...HEAD"],
capture_output=True, text=True).stdout.splitlines()
con = init("test_journal.db")
append_result(con, run_id=os.environ.get("CI_PIPELINE_ID", "local"),
test_id=report.nodeid, outcome=report.outcome, changed_files=changed)
选择规则刻意保守:diff命中依赖的测试无条件执行;只有“没被diff碰到、窗口内至少3次通过、0次失败”的测试才进跳过集,没有历史或历史太薄的新测试一律照跑。要清楚它会漏什么:
- IO与数据层副作用:测试读写数据库、缓存、消息队列时,依赖的是“数据状态”而非“文件”,import图看不见;
- 动态加载与反射:
importlib、插件注册、配置驱动的行为切换,静态与运行时痕迹都可能漏记; - 共享状态与执行顺序:单独跑通过、并行时互相污染的用例,历史记录会替它们“作伪证”;
- flaky污染:一次偶发失败会永久压低该测试的跳过资格,一次偶发通过又可能放行真正危险的跳过——官方如何处理这个抖动,恰恰没有公布。
六、边界:官方没有公布的三件事
先把边界讲清楚:selector的选择准确率与漏测率、flaky测试的处理策略、这套系统的运行成本,官方均未披露,本文不做任何外推,读者看到别处引用这三个维度时请保持警惕。
其次,“25倍”是Anthropic特定组织形态与技术栈的处境——一家把80%合并代码交给AI写的公司,不是每个团队明年都该给自己立这个KPI,更不该据此恐慌。它的正确用法是当“压力测试的思想实验”:如果你的PR产量翻倍,你的测试结果记录系统会先在哪一步跪下?
最后一条留给所有打算抄作业的人:selector省下的每一分钟,都是拿漏测风险换的。兜底策略必须先于省时间上线——没有“未知即全量”的回退开关之前,任何测试跳过优化都不该进main分支。
七、作者留下的四条建议
转述原文末尾,作者给出四条复盘建议:
- v0架构就按未来10—20倍余量设计,别等第三次补丁再承认现实;
- 容量规划按复利增长做,不按线性增长做——25倍只用了半年;
- 把服务做成agent能靠日志、指标、trace自诊断的形态,因为以后半夜处理告警的可能不是你;
- 避免单实例关键服务,状态从一开始就别放在进程里。
第四条送给所有还把测试报告、缓存、中间状态写在runner本地磁盘的团队:这条铁律的适用规模,远比你想的低。
结语:明天上班可以先做的一件事
不必先立项建平台。第一步,今晚就能做:给你的pytest工程加一张append-only的结果表和一个钩子,让历史先攒起来;第二步,用一周时间接入pytest-testmon或等价机制,算出你们工程的“可跳过测试面”到底有多大;第三步,用两周把“未知即全量、过期即回退”的门禁立进CI。Anthropic用25倍证明了这条路的终点长什么样,而你要做的只是别让下一个70天从加机器开始。
- 点赞
- 收藏
- 关注作者
评论(0)