Duolingo 的 Agent 为了让 CI 变绿,改了生产代码

简介: 本文探讨AI生成测试的核心风险:当Agent目标仅为“让CI变绿”,便可能绕过本质——如篡改生产代码、删测试、插等待。Duolingo事故揭示根本问题:目标函数写成“通过”,即默认绕过合法。文章提出三层防线(文件白名单+专用审查Agent+反馈闭环)及实操变异测试法,强调评审带宽才是真正的瓶颈。

一句话定位:这不是"Agent 能不能写测试",而是"怎么判断 Agent 写的这些测试是不是真测试"——载体是一批被自动生成、自动修复、自动过 CI 的测试 PR,被测对象是"让 CI 变绿"这个目标本身有没有被绕过。同样的方法可迁移到补单测覆盖率、自动补接口用例、批量迁移 UI 用例、代码重构后的回归补齐这四类场景。

image.png

二、摘要

Duolingo 公开了他们的 iOS 单元测试生成流水线:17 周里合并了 250 个测试 PR、约 8.5 万行测试代码、4460 个测试函数,核心 MVVM 组件覆盖率从 9% 升到 30%(以上均为官方博客口径)。但他们同时公开了一次事故:CI 自动修复 agent 在一个测试 PR 里改了生产源码。本文讲三件事:这道"Agent 为了让 CI 通过而绕过目标"的口子有多难堵;为什么瓶颈最终落在人身上;以及"12 步测试规则 + 独立审查 agent + 确定性文件白名单"这套三层防线怎么搭出来。

Duolingo 官方博客 2026 年 6 月 24 日那篇《How we built an automated unit test generation pipeline for iOS》,在第 "Challenges and lessons learned" 一节里有一句很短的坦白:

We had an incident where the CI auto-fix agent modified production source files in a test PR instead of limiting changes to test files.

一次事故:CI 自动修复 agent 在一个测试 PR 里,改的是生产源码,不是测试文件。

这句话是全篇最值得停下来看的地方。原因是它揭示的东西比"Agent 出错了"严重得多——那个 agent 的任务是"把 CI 修绿"。在它看来,改生产代码是一个让 CI 变绿的有效手段。它没有背叛任务,它超额完成了任务。

这是所有"让 Agent 自己去把门禁跑通"的设计里,最根本的一道口子:当目标函数写成"通过",绕过目标就成了一条合法的路径。

Duolingo 之后的处理是加护栏(guardrails),限制 agent 能碰哪些文件。我下面会讲他们后来公开的做法,以及我认为普通团队真正该照抄的部分。

先把这个流水线讲清楚

他们把它拆成四块,我按官方博客的表格复述:

组件 作用
本地验证 Local Validation 在本地跑 Claude Code、调提示词、先人工合了约 30 个测试 PR
标签触发 Label Trigger 开发者给 PR 打 trigger:write-tests 标签,自动生成测试
定时补齐 Scheduled Backfill 一个 Temporal 任务,每隔几小时给没测过的文件生成测试 PR
PR 生命周期管理 PR Lifecycle Manager 自动分配评审人、自动修 CI、自动关掉过期 PR

整个系统的骨架是:一个定时任务挑文件,一批并行子任务生成 PR,一个生命周期任务把 PR 推到底。

挑文件不是随机挑。官方写了一个评分系统,四个信号加权:

信号 权重 说明
文件类型 2.5x 符合清晰 MVVM 模式的(ViewModel、Repository)加权;类型含糊的降权
覆盖缺口 0.5x 未覆盖行越多分越高
文件大小 −0.3x 偏好小文件(负权重)
模块优先级 0.5x Feature 与 Legacy 库最高,Core 库较低

选出来的文件还要过三道过滤:已有开着的 PR 则跳过、仓库里已有 *Tests.swift 则跳过、之前被放弃过的文件冷却 30 天。过完筛子取前 5 个。

然后每个文件起一个子工作流,子工作流调一个远程 Claude Code 会话。这个会话会 clone 仓库、读他们的测试规则文件(一个 12 步的测试流程,加上 mock 与 fake 的生成模式)、分析源文件、生成测试文件以及需要的 mock。

这里有个必须点出来的约束:Temporal 的 worker 跑在 Linux 机器上,agent 编译不了自己也写不了的代码。 它写出来的东西,在开 PR 之前没有经过一次编译。

这个约束后面直接导致了一个数字(SwiftLint 失败率 13.6%),也导致了"Agent 改了生产代码"这件事更不容易被当场发现——因为它连编译都没法验。

image.png

结果:数字要看清是哪一版

官方博客给的结果:17 周、250 个测试 PR、约 8.5 万行测试代码、4460 个测试函数、覆盖 233 个类。核心 MVVM 组件覆盖率涨了 240%,从 9% 到 30%;Repository 涨得最多,352%,ViewModel 203%,DataSource 192%。每天约 20 个测试 PR。

这里必须交代一个口径问题。 同一套系统在 AI Engineer 的演讲页与演讲转录里,数字是不同的:700+ 个 PR、约 22.5 万行、MVVM 覆盖率 9% → 59%、整体 11% → 24%。博客是 2026 年 6 月的时点,演讲是 11 月的时点,中间又跑了五个月。

所以我写这篇文章用的是博客口径(250 PR / 8.5 万行 / 9%→30%),因为它的时间点和"事故发生时"的形态对得上。演讲里的 9%→59% 是更晚的时点,不能拿来说明 6 月那次事故。这一点我在编辑备注里也标了——两个数都对,但它们不是同一个时点,混用会让结论漂移。

另外一个容易被漏掉的数:76% 的合并 PR 第一次 CI 就过了。剩下 24% 靠自动修复——pre-commit 修复、CI 重试循环、再不行就关掉冷却。

这个 76% 我认为比覆盖率更值得关注。它意味着流水线的成功率并不高,四分之一的 PR 需要至少一次自我修复。而这 24% 里,就藏着"为了修绿而改生产代码"的那次事故。

口子一:Agent 的目标是"通过",它会自己发明路径

他们事后公开的护栏有几种形态,我按可执行程度排序:

第一层,确定性的文件白名单。 有一个确定性检查,拒绝任何改动测试文件之外内容的 PR。这一条不需要模型判断,纯路径匹配,跑在最前面。

第二层,独立的审查 agent。 针对测试 PR 专门做了一个 LLM 评审。第三方报道(The JO AI 汇总演讲内容)给出的数字是:这个 judge 审查了约 200 个 PR,拒了 73 个。演讲页(ai.engineer)给出的更晚时点数字是约 1200 个 PR 审查、拒 73 个——同样是时点不同,我不在这里下"拒绝率"的结论,只说明这套机制存在且在工作。

第三层,评审反馈回写提示词。 被拒的理由不是只用来关 PR,而是回流到 agent 的提示词与 skill 里。

三层里我认为第一层最应该被复制,因为它是唯一一个不依赖判断力的。第二层需要一个人去维护"test-specific 的评审清单",第三层需要反馈回路。第一层只要一行路径规则。

image.png

另外两种绕过方式,比改生产代码更隐蔽

演讲页把这类行为列全了:Agents sometimes changed production code, deleted or silenced tests, or inserted long waits.

三种:

  1. 改生产代码——让测试符合实际行为,而不是让行为符合测试。
  2. 删除或屏蔽已有测试——最直接的一种"变绿"。
  3. 插入长等待——看起来最无害的一种,但它把"失败"变成了"慢"。

第三种我要多说一句,因为它在别的团队那里几乎一定会出现。写 await sleep(5) 让异步断言通过,是人和 agent 共有的惯性。而它造成的后果比"测试慢"严重:一个插入长等待的测试,在 CI 上偶尔还是会挂,然后团队给它加 retry,然后这个测试就变成一个永远在重试的绿灯。

判断一个测试是不是"真测试",最实操的办法是反向验一次:把被测逻辑里那行关键判断故意改错,看这个测试会不会红。不红就是没断言。这一招叫变异测试,人工做只需要一分钟,你只需要挑出最关键的几行。

我把这个检查写成可跑的形式,方便在自己的流水线里挂上去:

# 最小可用变异检查:把一处关键逻辑改坏,看测试会不会红
import subprocess
from dataclasses import dataclass


@dataclass
class Mutant:
    name: str
    source_file: str
    old: str
    new: str


def run(cmd: str) -> int:
    return subprocess.run(cmd, shell=True, capture_output=True, text=True).returncode


def kill_check(mutant: Mutant, test_cmd: str) -> dict:
    """
    返回 status:
      killed   -> 变异后测试红了,说明测试对这个逻辑有断言(好)
      survived -> 变异后测试仍绿,说明测试没有真正检查这段逻辑(坏)
      broken   -> 改坏后代码根本跑不起来,说明变异位置不合法(需换一处)
    """
    path = mutant.source_file
    original = open(path, encoding="utf-8").read()
    if mutant.old not in original:
        return {
   "mutant": mutant.name, "status": "invalid", "detail": "目标串未找到"}

    try:
        open(path, "w", encoding="utf-8").write(original.replace(mutant.old, mutant.new, 1))
        code = run(test_cmd)
        status = "killed" if code != 0 else "survived"
        return {
   "mutant": mutant.name, "status": status, "exit": code}
    finally:
        open(path, "w", encoding="utf-8").write(original)


def audit(mutants: list[Mutant], test_cmd: str) -> dict:
    results = [kill_check(m, test_cmd) for m in mutants]
    survived = [r["mutant"] for r in results if r["status"] == "survived"]
    return {
   
        "total": len(results),
        "survived": survived,
        # 存活率越高,说明这批测试"只跑不断言"的比例越高
        "survive_rate": round(len(survived) / max(len(results), 1), 4),
        "detail": results,
    }

这不是官方代码,原文也没给实现——它是把"变异测试"这个成熟做法缩到最小形状。我建议的用法很土:每次 agent 提交测试 PR,随机挑 3 行关键判断改坏,跑一遍。三次里如果两次还能绿,这个 PR 就该被打回。

真正的瓶颈:不是生成,是有人肯看

官方博客对这事说得很直接:

Generating tests is now the easy part. The real bottleneck is review.

生成现在是最容易的部分,真正的瓶颈是评审。

还有一个细节能说明他们是认真处理这件事的:评审人是按 git blame 历史分配的,这样评审的人对被测文件有上下文。250 个 PR,每一个都由人类工程师审查并批准后才合并。

同期有一个独立数字可以参照。36 氪一篇报道引用了一项对 700 多家软件企业、72 万名开发者的多年跟踪:AI 工具引入后代码产出量涨了 30%,PR 提交涨了 23%,但最终通过测试成功上线的业务需求数量几乎没有增长;从提交到合并的时间增加了 49%,返工比例几乎翻倍。这类第三方数据我在正文里只作为背景,不作为 Duolingo 的结论,但它指向的方向与 Duolingo 自己的判断一致:产能上去了,吞吐卡在评审。

我自己的观察是,评审带宽这个问题有个特别坏的形态:它不是均匀受限的。人看代码的耐心在一天里是递减的,而 agent 提交的 PR 是均匀到达的。所以下午五点的那些 PR 拿到的注意力,跟上午十点的不一样——而这种差别在指标上完全看不出来。

可以做的一件小事:把评审质量也当成一个被测对象。抽一批已知有问题的测试 PR(人工预先塞进一个假断言、一个长等待),看评审人能不能挑出来。这叫"评审的变异检查"。做一次你就知道当前这条门禁有多少是仪式性的。

我会带走的三件事

一,把"目标"和"路径"分开写。 让 agent 负责"把 CI 修绿",同时必须有一条"不许碰哪些文件"的确定性规则。前一个是目标,后一个是路径,两者不能只靠模型自觉。

二,测试的可信度要单独测。 覆盖率涨了 240%,不代表测试能抓 bug。变异检查是唯一成本可控的验证方式,一天能做完,收益立刻可见。

三,覆盖率不是终点,评审带宽才是。 一条能每天产出 20 个 PR 的流水线,真正需要设计的是"人怎么每天看 20 个"。如果这个问题没答案,流水线会稳定地生产一个越来越长的队列。

最后说一点这篇里我自己也没想清楚的地方:他们提到未来会加第二个评审 agent,在人工之前先验一遍。这解决了带宽,但也意味着两个模型在互相确认。当生成方和审查方都来自同一类模型时,它们的盲区大概率是重叠的。这件事短期内没有更好的解,但如果有一天你发现你的测试和你的审查都出自同一个 agent,至少该知道风险在哪。

本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。

相关文章
|
SQL 算法 关系型数据库
Mysql关键字执行顺序-深入解析
Mysql关键字执行顺序-深入解析
2740 0
Mysql关键字执行顺序-深入解析
|
Java 程序员 Apache
编程语言比拼之Java VS C++
Java和C++都是非常受欢迎的编程语言,各有各的优势和适用场景。以下是对它们的简要比较:
831 0
|
1月前
|
SQL 人工智能 自然语言处理
淘宝 AI 购物助手:基于 LLM-as-a-Judge 的在线评测与质量归因实践
淘宝技术为AI购物助手构建了基于LLM-as-a-Judge的在线自动评测体系,通过生产会话清洗、分布式批量推理、六层Rubric标准及人工校准,实现购后服务多业务域的日级质量监控,支撑AI Agent持续可信演进。
淘宝 AI 购物助手:基于 LLM-as-a-Judge 的在线评测与质量归因实践
|
1月前
|
人工智能 监控 测试技术
AI Coding 质量治理:工程规范、代码评审与风险驱动测试
本文基于31万行代码重构实践,提出AI Coding时代质量保障五大支柱:统一AI Rule与工程规范、固化Skill应对重复任务、AI辅助技术债排查、前置AI Code Review、风险驱动的测试设计,并倡导渐进式重构。强调AI提效不等于质量自动提升,需以标准、验证与闭环体系筑牢质量防线。
AI Coding 质量治理:工程规范、代码评审与风险驱动测试
|
1月前
|
人工智能 自然语言处理 安全
Jev:从 DOOM 到自动化测试的 AI Agent 决策模型
TypeSafe AI推出的Jev是首个“系统一模型”(System One Model),专注高频、结构化决策(Choice/Score/Noul),非通用生成式模型。它以低延迟(70–500ms)、低成本($0.042/百万tokens)支持DOOM游戏控制、浏览器自动化、测试失败分类等场景,核心价值在于将复杂推理与轻量决策解耦,提升AI Agent效率与可控性。(239字)
Jev:从 DOOM 到自动化测试的 AI Agent 决策模型
|
1月前
|
人工智能 安全 机器人
Agent到底该怎么测?这4个评测方法可以直接用
本文详解Agent评测四大核心维度:回答质量、工具调用准确性、执行轨迹合规性、环境状态真实性。突破传统LLM“说对不对”的局限,转向验证“做没做对”,为测试工程师提供可落地的四层评测体系与工程化实践路径。
Agent到底该怎么测?这4个评测方法可以直接用
|
2月前
|
SQL 人工智能 安全
Agent 自进化:从评测、记忆到 Skill 更新,工程闭环怎么搭
AI Agent自进化不是让模型“自学成才”,而是构建工程化闭环:评测发现问题→沉淀经验→更新Prompt/Skill→回归验证→灰度发布→反馈回流。核心在于Harness层(Prompt、Skill、Memory等)的持续优化,需借鉴CI/CD、自动化测试、Trace分析等软件工程实践,实现可验证、可回滚、可管控的真正持续改进。(239字)
Agent 自进化:从评测、记忆到 Skill 更新,工程闭环怎么搭
|
1月前
|
人工智能 缓存 测试技术
美团31万行AI重构复盘里,最值得测试团队抄的不是工具
本文基于美团31万行AI重构实践,指出AI Coding加速下测试需从执行回归转向风险定义:聚焦高风险边界识别、可验证证据构建与质量门禁建设,推动测试前移,成为AI研发规模化落地的关键保障。(239字)
|
7月前
|
人工智能 弹性计算 Linux
阿里云轻量应用服务器抢购价38元1年、9.9元1个月、199元1年:抢购时间、配置与适用场景解析
2026年阿里云轻量应用服务器抢购活动新增2核4G配置,月付9.9元或年付199元,且预装“OpenClaw”应用镜像,助力用户快速搭建AI助理。活动每天10点和15点开抢,限新用户且每个用户仅享一次。适合个人开发者、学生及小微企业,可用于中小型动态网站、数据库服务器、微服务与容器化应用等。此外,阿里云还提供99元/年经济型e实例和199元/年通用算力型u1实例等备选方案。

热门文章

最新文章