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 测试等内容,侧重测试实践、工具应用与工程经验整理。

相关文章
|
14天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
6210 45
|
2天前
|
云安全 人工智能 安全
|
15天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
5134 60
|
9天前
|
Linux 开发工具 iOS开发
2026最新codex下载安装保姆级使用教程(下载+安装+登录+使用,含所有历史版本)
本文详解OpenAI Codex国内快速上手指南:提供Win/macOS/Linux全平台免安装包、npm/Homebrew等多方式安装、账号/API Key双登录、简体中文切换,覆盖下载、安装、汉化、使用及高频避坑(如微软商店失败、npm超时等),10分钟即可跑通。
2487 6
|
2天前
|
JSON 前端开发 API
阿里云最新Qwen4-Max大模型介绍:模型定位、特征、能力、适用场景解析
2026 年 11 月 11 日,阿里发布第四代旗舰模型Qwen4-Max预览版,首发千问 AI 平台 Token Plan,并同步接入千问办公、Qoder 等入口。该版本聚焦知识理解、世界感知、编程与 Agentic 四项升级,主打复杂编程、专业办公、前端设计与专业软件操作。预览期至 11 月 25 日 24:00,到期后模型标识下线;限时 2 折、新用户再享 5 折,最低 1 折可体验。
|
7天前
|
人工智能 安全 API
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(dsh)是DeepSeek AI于2026年开源的轻量级Agent运行框架,基于Cordis内核,践行“一切皆插件”理念。支持Web/桌面双端,开箱即用;内置多模型接入、丰富插件生态(超3.5万),适配编程、办公、创作等场景。(239字)
1565 1
|
2天前
|
人工智能 前端开发 API
阿里云Qwen4-Max 大模型详解:定位、特征、能力、适用场景与相对Qwen3-Max差异解析
2026年11月11日,阿里发布第四代旗舰模型Qwen4-Max预览版,首发千问AI平台Token Plan并接入千问办公、Qoder等入口。该版本聚焦知识理解、世界感知、编程与Agentic四项升级,主打复杂编程、专业办公、前端设计与专业软件操作等真实任务。预览期为11月11日10:00至11月25日24:00,仅支持Token Plan调用,到期后模型标识下线;限时2折、新用户再享5折,最低1折可体验。

热门文章

最新文章