ClickGap:ClickHouse 的自主 QA 代理
DataHot 速览
ClickHouse 每天合并 50 到 100 个 PR。ClickGap 是一个自主 QA 代理,审查每个合并后的变更,设计并执行测试,将回归归因到具体提交,并自动提交 issue 和 PR。上线五个月,ClickGap 已提交约 500 个 issue 和约 200 个 PR,其中一半以上 issue 已关闭,超 200 个已关闭 issue 关联了修复 PR。
为什么值得关注:在每天合并数十个 PR 的数据库项目中,用 AI Agent 自动化回归测试和缺陷归因,能显著降低人工审查负担,对数据基础设施团队有借鉴意义。
本文目录 11 节
译文
AI 逐段翻译ClickHouse 在典型的工作日会合并 50 到 100 个拉取请求。每一个都进入一个在生产环境中运行的、对性能至关重要的 C++ 数据库。任何一次合并都可能引入错误结果、在未经测试的功能组合中触发崩溃,或让某个查询悄无声息地慢上 30%。
把 AI 智能体指向这个问题似乎显而易见,但这也是一条快速成为反面教材的路径。
curl 最近终止了其自 2019 年起运营的漏洞赏金计划,原因是一大波 AI 生成的报告涌入,其维护者将其描述为“垃圾”。生成一份看似合理的漏洞报告几乎不费成本;而对其进行分类却要消耗一名工程师的时间。我们的维护者并没有报名来照看一个聊天机器人。
我们仍然构建了一个。它在ClickHouse 的智能体式编码中有一个单行的客串亮相;本文是详细版本。
ClickGap是一个自主 QA 智能体,它审查每一个合并到 ClickHouse 的拉取请求。它针对真实构建设计并执行测试,将回归问题二分定位到引入它们的提交,并在公共跟踪器中提交 issue 和拉取请求,无需人工批准发送按钮。
上线五个月后,ClickGap 的 GitHub 账号已提交了大约 500 个 issue 并创建了约 200 个拉取请求,为那些在没有覆盖的情况下就已合并的代码路径增加了覆盖。超过一半的 issue 已经关闭,绝大多数是以已完成而非被驳回的方式关闭,并且有 200 多个是通过关联的修复 PR 关闭的。ClickGap 在每次拉取请求合并后对其进行审查,因为已合并的代码才是最终发布的代码。发现的问题在数小时内就会到达,远早于某个发布版本可能将该缺陷带给用户之前。
在这篇博客文章中,我们将介绍构建 ClickGap 过程中遇到的一些挑战,以及我们是如何做到当那个机器人说“这坏了”时,维护者会相信它。
一个已经被测试到极致的数据库
ClickHouse CI 每天执行数千万次测试:无状态 SQL 套件、集成集群、sanitizer 构建、压力测试、模糊测试以及统计性能对比。没有人类维护者的批准,任何东西都无法合并,而且每个拉取请求也都会被一个内部审查者检查,clickhouse-gh[bot],它会阅读 diff 并以文件和行号定位的问题请求更改。它能在合并前捕获相当一部分缺陷。
然而,有些缺陷仍然能躲过人工审查者、机器审查者和测试墙。CI 只能证明已编写的测试仍然通过;它无法说明没有任何测试覆盖到的行为。新合并的代码尤其容易暴露,因为拉取请求通常会引入测试套件从未触达的变更行。这些缺口正是 ClickGap 约 200 个覆盖 PR 的来源。
其他缺陷对结果断言来说是不可见的,因为正确性保持不变,而执行成本却上升了。一个跳过索引可能被构建出来、占用磁盘,却从未跳过任何一行。每个查询仍然返回正确结果,同时浪费掉该索引本应节省的资源。
它在 ClickHouse 中发现了什么
公共跟踪器涵盖大约 35 个组件标签,从查询分析器和 MergeTree 到复制。其发现归入四类反复出现的类别。
被夹带的语义变更(#113763)。一个添加 gini 聚合函数的拉取请求,同时改变了一大类现有 <agg>If<Combinator>(x, NULL) 表达式的结果类型和值。标题、描述或变更日志中都没有披露这一影响。当审查仍受限于拉取请求所声明的目的时,这类缺陷很容易被遗漏:该变更可能正确实现了它宣称的内容,却悄悄地破坏了它未提及的东西。
资源安全漏洞(#114987)。一个新的自适应聚合器的学习阶段在到达外部溢写分支之前就返回了。在受影响的配置下,一个原本会溢写到磁盘的查询反而可能触及内存上限。该报告并非仅仅推测会发生 OOM。它测量了三种配置下的溢写分片数量(46,然后是 7,然后是 0),并比较了新旧路径之间的内存消耗。
接下来是一场双机器人接力。groeneai,另一个 ClickHouse 机器人,复现了这些计数器并缩小了影响范围:该故障需要提高调优阈值,而不是默认配置。ClickGap 公开接受了这一更正,而不是为其最初的严重级别辩护,并且修复在一天内就被合并了。
无声的性能缺陷(#114990, #114105)。这两个缺陷都不会使服务器崩溃或损坏结果。它们都悄悄地让查询变得更昂贵。
在 #114990 中,一个跳过索引被构建、存储在磁盘上,然后被忽略了。一个原本读取十六个粒度中一个的查询,现在读取了全部十六个,而返回的结果完全相同。结果断言无法检测到这种故障;它们看到相同的答案,却看不到它现在做了十六倍的工作。
在 #114105 中,一个无条件的 std::adjacent_find 为一个热构造函数增加了又一次扫描,使数组密集型查询变慢了 14.6% 到 30.9%。性能声明需要更强的证据,因为 CI 计时天然会波动。该报告将信号与噪声区分开来:在超过一千次测量中,master 仅变化了不到一个百分点,而该变更却产生了两位数的性能回退。它还指出了确切的代码路径,并引用了与测量结果相矛盾的源码注释。“正确但更慢”是一种独立的缺陷类别,要捕捉它需要统计学而非断言。
覆盖率的生意(#114766,约 200 个之一)。机器编写的测试通常以两种方式失败:它们执行新代码,却不检测其行为是否被破坏;或者它们重复了现有套件已经提供的覆盖。
每个 ClickGap 覆盖率发现都必须通过这两道门槛。分析必须识别出一个确切的单行源码变异,使新测试失败,并解释该测试覆盖了被审查拉取请求自身测试没有覆盖的什么内容。如果它无法回答其中任何一个问题,该发现就会被丢弃。
对于最有力的论断,ClickGap 会运行实验。它故意破坏被测试的行为,构建修改后的服务器,并证明新测试失败,而随原始拉取请求提交的测试仍然通过。
工作原理
ClickGap 是一个监视合并流的单一守护进程。资格在接收时一次性决定。回退、先前已审查变更的重新移植,以及因过于庞大而无法分析的拉取请求会被跳过;其他所有内容进入流水线。对于每个符合条件的拉取请求,守护进程同步源代码,提供一个经证实包含合并提交的二进制文件(尽可能下载新的 CI 产物,否则从源代码构建),并准备一个带有预热服务器的隔离工作树。
两名分析员针对该环境运行。缺陷分析员形成假设,但每个假设都必须转化为已执行的测试。只有当观察到的行为与预期契约不一致时,假设才会推进。覆盖率分析员随后运行,以变更代码的行级覆盖率为种子。当这些行已被覆盖时,它会被跳过。
每个候选发现首先面对一个对抗性审查者,其唯一职责是拒绝它。任何存留下来的发现随后必须通过流水线强制执行的十道关卡:
- 具体后果。 缺陷必须产生可观察的影响:错误结果、数据丢失、崩溃、安全漏洞、有效输入被拒绝、可测量的性能下降或 CI 信号丢失。“这段代码是死代码”还不够。
- 有效引用。 每一个引用的文件和行都必须存在于当前源代码树中。
- 完整文件审查。 分析员必须完整阅读每个变更文件,而不只是差异。
- 调用方分析。 分析员必须检查调用变更代码的代码路径。
- 现有测试搜索。 必须以至少三种不同的方式搜索测试套件。很少有错误能比报告一个已经存在的“缺失测试”更快地摧毁信任。
- 已执行的复现器。 证明性测试必须针对真实二进制文件运行。
- 证据与论断相符。 缺陷发现必须包含观察到的失败。覆盖率发现必须将提议的测试生成为磁盘上的真实文件。
- 覆盖率杀伤测试。 覆盖率论断会面临针对性挑战:缺口是真实存在的吗,提议的测试是否能检测到它声称要保护的行为?
- 审查者批准。 对抗性审查者的裁决必须记录为通过。
- 无已知误报匹配。 每个发现都会与维护者纠正过的过往错误进行比较。一次学到的错误不能提交两次。
八道关卡完全以普通代码实现。只有覆盖率杀伤测试和对抗性裁决需要模型判断。
在全部十道关卡通过后,流水线会选择一项外部操作。覆盖率缺口会变成仅含测试的拉取请求。已在本地复现的缺陷会变成一个 issue。带有可运行测试但无法在本地确认的缺陷会变成一个证明拉取请求,让 ClickHouse CI 充当证明者。大多数候选发现根本不产生任何外部操作。
提交后责任仍在继续。ClickGap 监视每次提交的 CI,并将每次失败分类为其自身测试、已知不稳定的测试或基础设施问题。如果失败是 ClickGap 造成的,它会读取日志并推送修复,最多尝试五次,之后升级给人类。每次修复都要通过自己的关卡,包括连续两次本地运行,因为一次通过的运行可能只表明存在不稳定性。
审查反馈进入同一个闭环。当审查者请求变更时,ClickGap 更新提交、重新运行验证,并附上证据予以回复。随后一个独立的守护进程侧验证器会独立执行测试。任何分析员都不能自行认证其成功。
从已合并 PR 到已提交产物。每个发现都必须通过执行、对抗性审查和以代码强制执行的十道关卡,然后才能提交任何 issue 或拉取请求;大多数发现最终进入归档箱,而不是跟踪器。
这些发现实际上来自哪里
这些关卡解释了为什么维护者可以信任 ClickGap 提交的内容。它们没有解释它如何找到任何值得提交的内容。检测依赖于四个优势。
第一,它读取差异之外的内容。 这些关卡要求分析员完整阅读每个变更文件,检查每个调用方,并将等效逻辑追踪到同级代码路径中。这种广度揭示了早先的 gini 回归(#113763):根本原因涉及六个同级覆盖和一个原始差异从未触及的消费者。同样的模式在整个跟踪器中反复出现:一个拉取请求修复了一个分支中的不变量,却让一个等效的邻近分支保持不变。
第二,它依据检查清单而不是直觉工作。 审查提示包含大约二十个 ClickHouse 特有的缺陷模式,涵盖连接边界情况、并发危险和生命周期错误等领域。每个模式都指定要检查什么以及在哪里搜索。该列表从证据中增长。一个独立流程分析了近 2000 个已确认的 ClickHouse 缺陷,并将其经验转化为供未来审查使用的记忆。当一个缺陷不匹配任何现有模式时,系统会起草一个新的检查清单条目。
第三,覆盖率缺口是测量出来的,而不是推断出来的。 LLVM 行覆盖率会识别出没有测试执行的精确变更行。即便如此,一个未覆盖的行只有在分析员能够说出提议测试将杀死的某个变异,并解释用户可见风险时,才算数。
第四,经济学偏向广度。 假设很便宜,一次完整审查在模型使用上花费几美元,本地验证只需几分钟。分析员可以为每个拉取请求探索许多候选,并只保留那些其已执行测试与预期行为不一致的候选。每次审查还以大约三十条从先前发现中回忆起的经验开始。
检测本身也在被持续测试。基准测试从 ClickGap 此前发现并得到维护者确认的真实缺陷开始。对于每一个缺陷,仓库会被回退到被审查的拉取请求合并的那一刻,此时缺陷仍然存在,然后分析员从零开始再次审查该状态。问题只有二元答案:它能否重新发现这个缺陷?
每一项提出的效率改动,例如缩短提示词,都会在旧配置和新配置下针对同一语料库运行。如果更省成本的配置漏掉了现有配置能捕获的缺陷,它就不会上线。
刻意追求低成本,并为此接受衡量
每审查一个拉取请求花费几美元。上面的召回率基准测试就是它的护栏:削弱缺陷检测能力的成本削减不会上线。一个什么都抓不到的廉价审查者并不高效;它只是成本更低的噪音。在这一约束下,节省来自四个方面。
普通代码不消耗模型时间。具有精确契约的工作以普通代码运行:修复问题正文以匹配上游模板,通过将文件和重叠标题与 ClickGap 的历史进行比对来检测重复提交,以及渲染自审查检查清单。这些操作不消耗 token,也不会漂移。
较小的模型处理可恢复的工作。其中一个在主要分析员阅读之前压缩冗长的 CI 日志。另一个针对覆盖率声明运行变异测试。第三个在任何人被公开提及之前,就某个二分定位的提交是否可能合理地导致该失败提供第二意见。在每种情况下,错误的结果要么在下游被捕获,要么按设计可以承受。具有扩展推理能力的昂贵模型被保留给系统所依赖的两个判断:发现缺陷和拒绝薄弱发现。
不太可能证明其成本合理的工作会被跳过。覆盖充分的变更绕过覆盖率分析员。不符合条件的拉取请求永远不会进入流水线。维护者一贯忽略的发现类别会被自动抑制。
每一项优化都必须保持召回率。当我们缩短分析员的提示词时,候选版本必须在同样的已确认缺陷种子上与完整提示词进行配对基准测试。它与基线持平:其唯一的配对漏检在两次重跑中都重新发现了该缺陷,表明这是运行间差异而非可复现的回归。它还发现了完整提示词漏掉的一个缺陷,同时将成本降低 11%,会话时间缩短 17%。只有到那时它才成为默认配置。
这里还有第二种经济账,远比机器人自身的账单更大:通过尽早发现缺陷而避免的工程成本。一个在合并后数小时内报告的缺陷通常只需一个后续提交就能修复。差异在作者脑中仍然新鲜,没有任何东西发布,没有用户受到影响,修复只落在 master 上。
一旦同一个缺陷进入发布版本,成本就会成倍增加。必须有人确定哪些已发布版本受到影响,将回归二分定位到引入它的提交,并将修复回移到每个受影响的发布分支。每一个在合并与发布之间捕获的缺陷都避免了这整笔账单。这就是在合并时进行审查的真正回报。
永远不要指认错误的 PR
一份写着“这在 26.4 中发生了回归,是这个提交引入的,这些仍受支持的分支仍然带有它”的报告,抵得上十份仅仅写着“这坏了”的报告。
受影响版本矩阵是确定性代码。对于每个仍受支持的 ClickHouse 发布版本,它下载真实的二进制文件,执行复现脚本,并记录结果。复现脚本被有意设计得很窄:一个小脚本接收一个二进制文件,并在出现缺陷时退出1,在未出现缺陷时退出0。超时不计为任何一种结果。它还在环境中没有密钥的情况下运行,因为复现脚本由问题报告者塑造,不能被视为完全可信。
二分定位通过在已知良好点和已知坏点之间测试构建来确定引入提交。ClickGap 从三个来源获取这些构建:大约回溯到 25.8 的官方发布二进制文件、来自 master 的逐提交 CI 产物,以及在仍有空缺之处从源码构建。
每个候选构建最多测试三次,超时则弃权不参与投票。单个不稳定结果无法改变搜索方向。在最终边界处,可疑提交必须仍然失败,而最近的更早可测试构建必须通过。范围内每一个无法测试的提交都会降低置信度,并且永远不能通过提高置信度来补偿。
在指认任何拉取请求之前,会进行最终因果检查,询问其改动是否可能合理地产生所观察到的失败。只有当答案明确为否时,它才会否决该归因。主导规则很简单:指认错误的拉取请求比不指认任何拉取请求更糟。账本显示 ClickGap 遵循了这一规则。由于缺陷太旧或证据缺口太大,二分定位器弃权的频率大约是它找出罪魁祸首频率的三倍。
没有人会事先规划的部分:赢得信任
提交历史比任何回顾都能更好地讲述这部分故事。
第一天就有一个标题为“将提交限制提高到 10(PR、议题、每日、并发)”的提交。我们以为吞吐量会是瓶颈。有三天时间——直到第一份崩溃报告和第一个覆盖率拉取请求落地——证据甚至似乎支持我们。接下来的五个月教会了我们相反的教训。
低点出现在大约六周时,当时 ClickGap 将它的私有推理作为评论发布在一个真实的拉取请求上,而不是发布答案。修复在历史中表现为“阻止机器人发布 LLM 推理作为回复的垃圾信息”。同一天又有一个提交:“拒绝发布 LLM 元推理作为回复的垃圾信息(最终层)”。不出所料,第一层并不是最终层。
随后,我们在“关闭残余垃圾信息向量”中审计了每一条能够发布消息的路径。该审计还发现重复警报淹没了我们自己的 Slack。第二天,我们构建了从一开始就应该存在的控制机制。它跟踪过去三十天提交的问题。如果获得任何维护者响应的比例低于 25%,ClickGap 就停止提交置信度较低的发现,直到参与度恢复。
提交量不再是我们控制的一个配置值。它变成了维护者持续授予的一项特权。
度量迫使我们面对下一个羞辱。我们取一个三十天的窗口,问了一个简单的问题:在机器人提出的发现中,维护者实际接受了多少?覆盖率发现的三分之一;缺陷报告的一半。这个数字就是后果门控存在的原因,也是机器人现在提交的缺陷报告远少于其分析员所提议的原因。
一些最棘手的失败来自那些在未做任何有用工作后却返回看似有效答案的组件。受影响版本检查最初由智能体驱动。它选择的下载辅助程序仅支持 amd64,而 ClickGap 运行在 aarch64 上。每次获取都失败,因此每个版本都返回“未知”。因为“未知”在确实无法获取构建时是有效的,所以该失败看起来像是保守报告,而不是完全失去覆盖。我们用一个确定性代码替换了该检查,以区分已测试版本和获取失败。
CI 日志摘要中也出现了类似的失败。ClickHouse 的测试运行器为每个测试打印 ✅ 或 ❌。一个小模型将该模式解释为代码审查提示,并输出“最终裁决:✅ 批准”。下游解析器将“批准”视为测试通过结果,导致真实失败未被处理。类似审查格式的摘要现在会在到达解析器之前被拒绝。
这两起事件强化了同一条规则:看似合理的状态并不能证明执行成功。一个常驻协调器现在会遍历每一个进行中的条目,并将其记录的状态与底层工件和外部系统进行核对。
面向维护者的输出变化与流水线一样大。提交历史记录了演进过程:“问题正文改为符合维护者规范——TLDR、代码链接、折叠、抄送作者、无横幅”,随后是“将每个剩余的面向维护者界面精简到要点”,以及“停止让人类维护者被 CI 机器人杂讯淹没”。
一个新问题现在默认展示大约十行:一个加粗的症状、一个指向疑似根因的永久链接、一个单命令复现器,以及预期行为与实际行为。支撑分析被折叠到详细块中,其中包括一个“开放风险”部分,将已核实事实与未解决的假设分开。
每个线程的回复限制为五条,并在发布前经过审核。审核者可以重写回复,但不能丢弃对维护者的回复。存在这一限制是因为,压制一个不完美的回答看起来与忽视报告问题的人无异。
当 ClickGap 问题打开时,维护者实际看到的内容:症状、根因永久链接、单命令复现器,以及一键可及的其他所有内容。
而当机器人出错时,回应也是产品的一部分。面对一个关于自引用 system.processes 查询的误报,它回答道:“你说得对,感谢精确的诊断……我会将自引用 system.processes.query 读取添加到 oracle 的排除列表中,这样这一类问题就不会再被提交了”(#110141)。该排除项已上线。每一个这样的裁决都会进入一个分歧台账,而该台账正是机器人学习的内容。
记忆,以及 Loom 为何存在
ClickGap 在三个层级存储经验。案例记忆记录单个发现的历史和结果。经验教训从这些案例中提取可复用规则,方式可以是自动化学习流程,也可以是操作员审核。一个准则文件保存着每个分析员和审核者运行都必须接收的较小规则集;其他记忆仅在相关时才会被检索。
准则文件限制为 150 行,因为其全部内容会随每个发现一起提供。准入严格基于证据:每条规则必须引用人类维护者的裁决。机器人自身的结论不符合条件。审核者特定的偏好只有在有实际审核反馈佐证时才可纳入。
每次审核都会记录其检索到的记忆的键。一旦某个发现达到终态,正面结果会提升这些记忆未来的排名,其效果以 90 天为半衰期衰减。负面结果会被保留,但不会立即惩罚单个记忆。因为一次审核通常会检索大约 30 条经验教训,单个坏结果不足以提供证据来确定究竟是哪条记忆(如果有的话)导致了它。
记忆平面最初是一个本地 SQLite 存储。几个月后,它迁移到了 Loom,这是一个在 ClickHouse 为 AI 智能体构建的内部记忆服务。Loom 将带类型和标签的记忆存储在 ClickHouse 中,并通过 API 提供搜索、来源追踪和结果反馈。
迁移在上线前必须通过一个检索基准,并且它从两个方面带来了回报。首先,它纠正了我们的直觉。将召回限制为与缺陷同一子系统(即代码库领域:连接、复制、存储)标记的记忆似乎很合理。在盲测中,它使检索质量降低了 22%,因为最强的经验教训往往跨越子系统边界。而将这些相同标签视为软排名信号,反而将质量提高了 12%。
其次,Loom 使记忆影响变得可度量。每次检索都会留下一条痕迹,可以与维护者最终对该发现的处理结果进行关联。“这条经验教训是否曾经有帮助?”不再是一个直觉问题,而变成了一个查询。
接下来会怎样
ClickHouse 开发横跨公共和私有仓库。最近,第二个完全隔离的实例,clickgap-private,就开始针对私有仓库运作,并在一天内提交了最初的问题。它立刻面临冷启动问题:没有维护者裁决的历史记录,新机器人很可能会重蹈公共实例已经吸取过教训的每一个错误。
私有实例写入自己的命名空间,该命名空间起初为空,并通过单向连接读取公共命名空间。在查询时,它继承编码在数千条公共记忆中的经验,而它学到的一切都保持私有。
困难之处在于让“单向”变得绝对。ClickGap 会针对召回的记忆发出写入,用于结果强化和来源链接。然而在连接之后,被召回的键可能属于公共命名空间。因此,每次写入都会按键的所有权进行过滤,服务器则独立强制执行命名空间边界。公共搜索永远不会返回私有键。我们使用了两道保障,因为这类泄漏会是无声的。
经验是否能跨代码库迁移,仍是一个开放且可度量的问题。同样的从召回到结果的检测机制在两侧都运行。一个季度后再来问我们。
clickgap-private 如何从 N 而不是 0 开始。读取跨命名空间单向联合;写入按键的所有权过滤。没有任何复制,没有任何私有内容回流:在查询时继承,而非通过复制。
如果你想在自己的代码库中实现这一点
这些原则中的大多数即使你从不构建机器人也适用。它们描述了一种严谨的 QA 文化,被编码并在软件中强制执行。
- 将其范围限定在一个代码库,并且说到做到。 ClickGap 的精确性大部分来自 ClickHouse 特定的知识,而非模型。
- 在任何外部动作发生之前收取证据通行费。 要求有已执行的测试、经验证的引用,以及用户会注意到的后果。
- 在可能确定的地方使用确定性。 智能体会漂移;
for循环不会。 - 将自信的否定视为最危险的输出。 一个假阳性浪费一个下午。一个假阴性把缺陷留给下一个控制点去捕获,而有时那个下一个控制点就是用户。
- 绝不要指名未经确认的嫌疑人。 “范围不完整”好过错误的作者。
- 将每份报告设计为十秒即可扫读,然后根据结果进行节流。 如果人类停止参与,就自动减少提交。
- 只让人类的裁决成为准则。 衡量记忆是否改善结果,而不是假设它会改善。
- 为苦功预留预算。 预期每个功能需要三次修复,集中在信任、状态恢复和正确解释你自己的 CI 上。
关键在于
我们构建 ClickGap 是因为合并流已经超出了人工审查能力。结果表明这一赌注得到了回报:大约提交了 500 个问题,超过 200 个已关闭并附有链接的修复 PR,还有约 200 个测试覆盖率 PR,所有这些都在公开环境下,针对一个已经被详尽测试过的代码库。
大多数机制并非为了让机器人更聪明而设计。而是为了让机器人值得被倾听,而这最终成了真正的产品。它的报告与所有人的报告进入同一个跟踪器,并依据同样的证据成立或失败。
本文中的数字是刻意四舍五入的。公开计数可以通过针对机器人账号的 GitHub 搜索复现。
如果构建必须在公开环境中赢得信任的系统听起来像是你会面对的问题,ClickHouse 正在招聘。
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏