工程实践 · 2026 · agent 规模验证图号 GP-PROVE-ITSELF · 环境施工图

每个 PR 都得
自证

Abnormal AI 的 agent 每天生成并请求 review 200 多个 PR,多到人已经读不过来。他们改的不是 review 速度,是送去 review 的东西:每个 PR 必须附上这段改动在近生产真实环境里跑起来的证据。

项目agent 规模下的验证环境 图别共享底座 · 只覆盖一跳 口径公司自述,单一来源,数字逐条核过 日期2026-08 · 源文 2026-07-31
共享基座 · 一条正在跑的流水线 消息接入 信号富化 检测引擎 裁定 AGENT A 信号富化′ AGENT B 检测引擎′ 每个 agent 独有的只有被改的那个服务,其它全是借的

GenAI Playbook · 忠实还原 Abnormal Builders 官方工程博客(2026-07-31)· 单一来源

起点 · CI 全绿说明不了什么

diff 显示的是 agent 打了什么字

Abnormal AI 拦截传统安全工具漏掉的网络攻击。公司把 AI 驱动的工程方式当作默认选项,而不是试点项目。它的 AI coding agent 每天生成并请求 review 200+ 个 PR

这个数字来自内部自动化给 agent 开出的 PR 所打的 GitHub label,不是合并量,也不是全公司的 PR 总量。

当改动多到人已经读不过来,最直接的反应是加快 review:增加 AI reviewer,自动分流 findings,或者给低风险改动自动批准。Abnormal 也采用了其中一些做法,但在本文作者、云基础设施工程师 Michel Chatmajian 的记述里,这些办法碰不到 diff 难 review 的根因。

PR 里的 diff 只展示代码增加了什么、删除了什么。单元测试通常只验证一小块逻辑,并不连接真实数据库和真实服务。即使 CI 全绿,reviewer 看到的仍然只是代码文本,不知道它是否能在真实服务上正常运行。

作者原话

a diff shows you what the agent typed, not whether it works

作者说,reviewer 缺少运行证据时往往只能把分支拉到本地跑一遍,把剩下的验证补完。慢和低效之外,他更强调的代价是工程师被强行切换一次上下文。

Abnormal 改的不是 review 的速度,而是送去 review 的东西。

过去交上来的是 diff,reviewer 得从代码里推断这段改动会做出什么行为。现在每个由 Nora(Abnormal 内部编排 AI coding agent 的那层 harness,负责派任务、递工具、管 context)开出的 PR,都带着这个行为已经跑过一遍的痕迹:前端改动交一张来自运行实例的截图,API 改动给出包含预期字段的真实响应,后端流水线和 consumer 的改动展示记录已按预期处理的那行日志,指标改动提供真实请求路径上正在增长的新指标。

作者原话 · 机制核心

The reviewer checks evidence against the claim instead of simulating the program in their head.

Reviewer 拿证据核对声明,而不是在脑子里模拟程序怎么跑。

生产这些证据并不容易。每个 coding agent 都会拿到一套自己的近生产环境,里面有真实服务、真实端点和真实数据,而且这样的环境一天要创建几百次。Abnormal 为此自建了一套系统,名叫 testbox。

200+
agent 每天生成并请求 review 的 PR 数(按 GitHub label 计)
3,000+
最近 30 天 agent 用掉的 testbox,横跨 64 个工程团队
<5 分钟
testbox 起一个服务连真数据库和依赖的时间,p50 = 112 秒

两个口径来自不同记录:PR 量按 GitHub label 数,testbox 与团队数来自 2026 年 7 月的 testbox 创建记录。谁也推不出谁。

第一节 · 顺序

先定义证据,再生产它

改动设计得好不好仍然重要,但这件事不在 PR 上定。设计在更早的 plan 阶段就定了,那时候代码还不存在。等 PR 开出来,待回答的只剩正确性,而正确性正是证据能回答的。

Abnormal 全公司共享一个 plan skill:agent 写代码前先调它,产出技术方案和测试计划。测试计划会先落到 PR 上,与人类 reviewer 对齐,之后任务才交给后台 agent 实现。

agent 随后按这份计划配置 testbox,执行已有或临时生成的集成测试。集成测试会把服务、数据库和消息队列真正连接起来运行,最后交回计划要求的截图、响应、日志或查询结果。

PR #205049 上 nora[bot] 在写码前贴出的技术方案,含 Summary、Motivation、Design、Constraints / Non-goals 与 Test Plan (Testbox Proof) 各段
来源PR #205049nora[bot] 在写代码之前贴出的技术方案,最后一段是 Test Plan (Testbox Proof),逐行写明要播哪几行数据、断言什么。原图标注为 demo,作者说真实计划常含几十个甚至更多测试脚本或叙述 · 出处 Make Every PR Prove Itself

所有测试计划使用同一套模板,把验证分成三段。

Devbox Checks

devbox 可以是本地机器,也可以是 Modal 沙箱,两者可互换。Modal 是第三方云端沙箱和计算平台,在这里承担与本地开发环境相同的作用。

Testbox Checks

testbox 使用真实 AWS credentials 和活的基础设施,服务跑在各自的 K8s pod 里,一个 pod 一套隔离的进程。API 或 gRPC 改动要向活服务发出真实请求,核对状态码、响应体结构、认证和负例。UI 改动需要展示有数据状态和空状态的前后截图,页面必须由真实前端渲染。Kafka consumer 改动则要向输入 topic 写入代表性消息,运行 consumer,再断言它实际驱动了什么结果。

Manual Checks

只放需要人类判断的内容。凡是可以通过执行命令验证的项目,都应该归入前两个阶段。这一段可以为空,但只要列出检查项,就必须说明为什么 devbox 加 testbox 仍然不够。

第二节 · 形态

证据没有单一形态

证据的形态取决于改动对象。原文用一张表把六类变更各要什么证据列了出来,这张表只存在于图片里,正文没有重复它

变更类型reviewer 看到的证据
前端 / UI来自运行实例的前后截图
邮件 / 模板带测试数据的渲染输出
后端流水线 / consumer显示预期处理已经发生的一条日志
API 改动真实响应,且预期字段在里面
指标 / 告警显示指标变化的查询输出
数据库 / 配置显示预期数据状态的查询输出

按原图六行逐行还原(原图为深色底表格,此处重画为站点样式,内容一字未改)。

PR #205557 修改的是部署看板。agent 增加了一列版本漂移指示器,用来显示每个 cell 比机群目标版本落后多少。browser agent 在 testbox 中启动前端,让它读取隔离数据库中的状态,再抓取修改前后的页面。

PR #205557 上 nora-testbox[bot] 贴出的 UI verified in testbox 证据:BEFORE 的看板没有漂移信息,AFTER 多出 Drift 列,标出 behind 1 · 7d 与 behind 2 · 21d
来源同一个视图、同一份播种数据的前后对照。AFTER 多出的 Drift 列把 c02 标为 behind 1 · 7dc03 标为 behind 2 · 21d,后者是一个落后目标版本 21 天的 prod cell,越过了 14 天窗口,而这在改动之前看不见。原图图注写明 AFTER 那张来自真实运行实例,打向测试环境里的分布式系统,不是 mock 数据 · 出处 Make Every PR Prove Itself

作者复盘时最强调的一类失败,不是干净地成功或干净地失败,而是看着成功、其实没跑起来。

早期的一次 UI 验证中,页面返回 HTTP 200,标题也正确,主体却没有挂载起来。结果是一张空白页,旁边带着绿色的成功评论,并被当作证据上传。

看着成功的失败

Blank page, green comment, uploaded as evidence.

在最新的 testbox harness 中,截图工具会额外报告两个信息:与页面最常见颜色不同的像素占多大比例,以及页面实际渲染出来的文本预览。

第三节 · 成本结构

共享一套环境,只覆盖一个服务

常见的 preview environment 会给每个代码分支启动一套临时应用环境。对一个 web app,这种方式大体够用;改动落在 Kafka consumer、指标或数据库表结构迁移上就不管用了

这些验证还要求每天几百套、数据各自隔离,不是一个分支一套。现成系统里没有一个能在 Abnormal 自己的服务拓扑上做到这几点,testbox 是为此自建的。

一个 testbox 会按近生产的配置把真实服务编排起来跑,运行在活的端点上,并为声明使用的每一个数据存储准备私有副本。作者复盘的结论是,让它能够扩展到 agent 规模的,是他们不做的那件事:不为每次改动克隆整个平台

每个 agent 都复用一套已经运行的共享基座,只覆盖自己正在修改的服务。

跟着一个请求走一遍就清楚了。改的是信号富化,那么消息接入、检测引擎、裁定三段仍然用共享的那份。请求从共享基座进来,走过共享的消息接入,只在被改的那一跳拐进 agent 自己的实例,那个实例连的是自己的隔离数据存储;出来之后重新汇回共享路径,一直走到裁定。

共享基座 · 一条正在跑的流水线 消息接入 信号富化 检测引擎 裁定 AGENT A 信号富化′ AGENT B 检测引擎′ AGENT C · 跨服务切片 信号富化′ 检测引擎′ 播种的库 默认走共享服务 只有改过的那一跳 指向自己的实例
图 1 · 按原文架构图重画:Agent A/B 各覆盖一段,Agent C 是跨两服务的切片并自带播种库
作者原话

The only thing that exists per agent is the service under change. Everything else is borrowed.

每次改动要付的,只是被覆盖的那个服务加它的数据存储。不是整条流水线四段服务各起一份、每份再配齐自己的库、然后乘上一天几百次。按一跳计费还是按整套平台计费,决定了一天几百套环境是常规操作,还是完全做不起。

同一个基座上,别的切法照样成立:另一个 agent 可以只覆盖检测引擎,改动横跨两个服务时则拉出一个 multi-service slice,把两段都换成自己的版本,再挂一个自己播种的库。

重定向对服务本身不产生任何成本,因为这些服务本来就通过 internal alias scheme 解析依赖,连接串并不写死在代码里。testbox 只需改写别名指向哪儿,让被测服务转向隔离实例。服务本身仍运行未经修改的代码。

这层间接同时是它的脆弱点。只有配置层的每一个 consumer 都遵循别名,重定向才会生效;不遵循的会静默失败。

重定向静默失效

a working connection to the wrong database looks exactly like a working connection

Abnormal 自己曾部署过 2 个 consumer,它们静默忽略了重定向。现在,每个节点都会运行一个 capture agent,记录该 testbox 实际与哪些服务和存储通信。系统再把实际连接关系与 manifest 中的声明进行核对。

第四节 · 组合

一份 manifest 拼出跨服务环境

当一次改动横跨多个服务,agent 会编写一份简短的 manifest。被点名的服务从源码构建,并能够互相发现;未被点名的服务继续回落到共享测试资源。

testbox manifest · 按原图逐字还原 services: - pac: outbound-email.custom-rules-scorer.api-service git_ref: nora/pr/expose-evaluated-at-match-log # build from the PR branch components: - pac: outbound-email.custom-rules-scorer.db # engine and version resolved from the manifest startup_flows: - type: init_schema # create the sandbox DB pac: outbound-email.custom-rules-scorer.db strategy: reflect # mirror the deployed schema - type: run_sql # shape the exact rows the proof needs pac: outbound-email.custom-rules-scorer.db sql: | INSERT INTO custom_rule_match_log (message_sent_at, evaluated_at, ...) VALUES (...);

services 段列出服务标识 pac,并用 git_ref 指向 PR 分支。components 段声明所需数据库,其引擎和版本从 manifest 解析。startup_flows 段先执行 init_schema 建出沙箱数据库,reflect 策略镜像已部署的表结构,再执行 run_sql,用 INSERT INTO custom_rule_match_log 写入证明所需的精确数据行。

agent 可以在运行时决定创建新基础设施,还是复用已有资源。如果它出了错,或者环境已经损坏,它可以再次调用工具,重新创建整套环境。

真实的集成证明也需要真实数据。Postgres、OpenSearch、Kafka、DynamoDB、Redis 都会以真实引擎在容器中启动,每个 testbox 一份,数秒内启动完成,版本与已部署服务正在跑的版本一致。

存储数据怎么来为什么这么定
Postgres / OpenSearch从已经部署的 test 实例克隆需要真实的表结构与索引状态
Kafka / DynamoDB不克隆现有内容,按模板生成合成记录截取下来的真实消息流不适合保留在沙箱中
对象存储处理成一个 keyspace,每次 run 由 harness 在真实 bucket 中动态分配独立前缀隔离边界落在前缀上,不必复制桶

播什么数据按存储分别决定,而且不总是拷贝。

第五节 · 证据密度

Reviewer 实际读到的东西

PR #205049 的任务很小:给规则命中日志 API 增加两个字段,一个是规则引擎评估一封邮件的时间,另一个是邮件从发送到被评估之间的延迟。

原文正文把这两个字段称为「数据库里本来就有的」,PR 上的计划写得更细:evaluated_at 是表上一直存在、只是从未通过 API 返回的列,evaluation_latency_ms 则由 handler 用 evaluated_at - message_sent_at 现算,并不是存储列。

证明标准很具体。API 返回值必须与存下来的数据完全一致。延迟计算在每种情况下都必须正确,包括差值为零。分页也必须继续可用。

agent 从自己的分支启动真实服务,连接真实 Postgres,并播种 4 行带有已知时间戳的 rule_match_log 数据。随后,它通过 gRPC 调用端点,并用 limit=2 强制触发分页。发送请求用的是 grpcurl

message_sent_at (ms)evaluated_at (ms)evaluation_latency_ms期望
11784721610000178472161125012501250通过
117847216005001784721600500omitted (proto3 default 0)0通过
21784721600000178472160500050005000通过
217847180000001784718120000120000120000通过

按原图断言表逐行还原。四行的期望值分别是 1250 / 0 / 5000 / 120000 毫秒,实测值逐行对上;第二行的字段在响应里被省略,原因见下。

分页证据也写得很细:第一页的 has_more=true,并返回 cursor;第二页的 has_more=false,没有下一个 cursor;两页合起来包含全部四行,每行恰好出现一次。

这份证据还保留了一段 Note on the boot path。沙箱 pod 最初连接的是共享测试数据库,而不是当前集群自己的 Postgres,因此刚播种的规则无法找到。agent 随后增加 local-DB 覆盖,重新启动同一个二进制文件,让它指向沙箱中的 Postgres。二进制和代码路径都没有变化,改变的只是数据库目标。改动因此确实在真实 Postgres 上跑过一遍。

断言表的第二行还有一个看似缺失的字段。响应中没有出现 evaluation_latency_ms,旁边标记为 omitted (proto3 default 0),期望值是 0,断言仍然通过。proto3 的规则是标量字段等于默认值时不会出现在传输结果中,int64 的默认值正是 0。因此,字段被省略代表延迟为 0,符合协议编码规则,并不是 bug。

PR #205049 上 nora-testbox[bot] 贴出的 testbox run:Summary、Note on the boot path、四行断言表、分页四条结论,以及 Testbox done 15m49s 76 steps 的状态条
来源reviewer 在 PR 上看到的整份证据:Summary 段说明启动了哪些服务、播了哪些数据、用什么驱动,断言表逐行给出播种值与期望值,分页四条结论逐条打勾,最后是运行状态条与逐条 exec 记录。原图标注为演示样例,作者说这里跑的测试应当与事先计划好的指令对得上 · 出处 Make Every PR Prove Itself

运行状态最终显示 Testbox · done,耗时 15m49s · 76 steps。下面保留了逐条 exec 记录,包括发出 grpcurl 请求、播种 4 行数据,以及删除沙箱集群。按原图注解,reviewer 看到的这些执行结果应当与事先计划好的指令对得上,而那份计划在写码前就已落到 PR 上并与 reviewer 对齐。

第六节 · 定位

这是一种 agent 能力,不是一道流程

testbox 不是流水线里的一道工序,而是 agent 循环随时能调的一组工具。

作者原话

Nothing about this is a "stage".

testbox 不是 CI/CD 流水线中的固定 stage,也没有一个必须在指定位置执行的 job。它提供的是 tool surface,一组可以由不同 harness 调用的工具。任何跑 agent 循环的 harness 都能直接调这组工具。

tool surface · 四个接口 testbox_cluster_apply(name, spec_yaml) # 声明服务与数据存储,并完成播种 testbox_start_service(pod, pac_id, service) # 启动当前分支中的服务代码 testbox_service_status(pod) # ready | pending | crashed | completed testbox_exec_in_pod(pod, command) # 驱动它:grpcurl、psql、往 topic 投消息

工程师可以在 PR 上评论 /testbox,针对当前 diff 触发一次运行。处理工单的后台 agent 也可以在自己的循环中调用同一批工具,读取失败结果、修改代码并重新运行。这些动作发生在任何人来看之前。同一个工具面还接入 Slack、Jira 和本地 coding agent CLI,触发点就留在工程师本来干活的地方。

走完整 CI/CD 部署单个服务可能要 20 到 30 分钟。如果只有正式部署,或者依赖人工操作,才能看到改动如何运行在真实服务上,验证就会形成瓶颈。testbox 可以在不到 5 分钟内启动同一个服务,并连接真实数据库和依赖,常常比在自己笔记本上把系统跑起来还快。harness 会一直在,后续检查可以复用已经创建的环境,不必重新承担搭建成本,agent 在同一个环境上连续工作的时间越长,单次验证的边际成本越低。

20–30 分钟
完整 CI/CD 部署单个服务要的时间
112 秒
testbox 启动的 p50(中位数),样本约 250 次 cluster run
略低于 3 分钟
同一样本的均值,量的是从 cluster apply 到所有服务启动完成

脚注口径:启动时间取自最近两周约 250 次 testbox cluster run,区间为从 cluster apply 到所有服务启动完成。

第七节 · 回写

每次漏掉的都变成一道检查

有些行为只有合并后才会出现。生产环境中的集成测试仍会捕捉这一类问题,testbox 的作用是减少漏到那里的改动

当一次事故或部署失败暴露出新的缺口,这次漏检会成为后续输入。团队把它写回 testbox 检查和 planning skill。下一个形态相同的改动出现时,agent 会在合并前执行新增检查,而不是等到合并后再次发现。按作者的说法,过去只有生产环境才能暴露的缺口就这样逐步往前挪,测试环境与生产的一致程度反复地、自动地变好。

节标题原文

Every miss becomes a check

实际采用比他们原先的推进计划快。根据 2026 年 7 月的 testbox 创建记录,最近 30 天有超过 3,000 个 testbox 被 agent 用掉,横跨 64 个工程团队。这组数据统计的是 agent 使用的 testbox 和涉及的团队,和按 GitHub label 统计的 200+ 个 PR/天属于不同口径:一个记录 agent 发起并请求 review 的 PR,另一个记录实际创建和使用的验证环境,两者各有各的统计来源,谁也推不出谁。