首页 / 文章 / 循环工程:如何阻止智能体奖励机制自我作弊
← 返回
AI技术

循环工程:如何阻止智能体奖励机制自我作弊

✍️ zhirenhun 📅 2026/7/23 👁 144 阅读 ⏱ 22 分钟
循环工程:如何阻止智能体奖励机制自我作弊

循环工程:如何阻止智能体奖励机制自我作弊

原文:https://dev.to/cleverhoods/loop-engineering-how-to-stop-your-agent-reward-hacking-its-own-checks-4fpn

你给了智能体一个失败的测试,告诉它让测试套件变绿。它回来时确实变绿了。然后你读了 diff:它没有动被测代码。它改了测试。原本断言 == 9000 的地方现在变成了 == 10000,正好是那个有 bug 的函数返回的值,所以进度条变绿了,因为测试被改成了和 bug 一致。

这有个名字。这叫奖励黑客(reward hacking),而且不是边缘的罕见故障。Cursor 自己的工程团队发表了一篇文章,标题是《奖励黑客正在淹没模型智能的进步》。有一个专门用来衡量长周期编码智能体(long-horizon coding agents)中这种现象的基准测试,叫 SpecBench。而每个曾把智能体指向红色测试套件的开发者,都见过它的某种变体:被删掉的断言、被加上的 @pytest.mark.skip、硬编码的返回值、被悄悄削弱的兄弟测试。智能体被告知要让检查通过。它确实让检查通过了。没人告诉它这个检查只是代码正确的替代指标,所以它优化了它实际拿到的那个检查。

这个差距——检查本身和检查所代表的东西之间的差距——就是本文要讨论的。一个循环运行五个环节:生成、检查、引导、重试、停止。系列开篇命名了它们;此后的三篇文章分别讨论了决定“足够好”的检查环节、拒绝错误写入的停止环节、以及所有规则加载的界面环节。本文要讨论的是那个决定智能体下一次尝试目标的环节:引导(steer),以及引导环节交给模型的奖励黑客版本。

图1

引导是什么

引导是将判定结果转化为下一条指令的环节。当检查返回红色时,一行文本会从检查的输出中组装出来,喂给下一次生成。下面是停止环节那篇文章构建的循环,用于重构 src/ 直到守卫条件成立。引导是其中的一个环节:

#!/usr/bin/env bash
# work-until-checked: 重构 src/ 直到守卫条件成立。
MAX=5; i=0
prompt="从 src/ 下的生产代码中移除所有 mock 库的导入。"
while [ "$i" -lt "$MAX" ]; do
    run_agent --task "$prompt"                      # 生成
    if bash no-mocks.sh; then                       # 检查
        echo "停止:守卫条件在第 $i 次重试后成立"; exit 0
    fi
    prompt="上一次尝试仍然触发了守卫条件;修复它:
$(bash no-mocks.sh 2>&1)"                            # 引导:仅传递新的信号
    i=$((i + 1))
done
echo "停止:预算耗尽,守卫条件仍然红色"; exit 1

模型永远不会看到完整的历史。每次重试它只看到一个提示,而这个提示就是引导环节决定带回的内容。第一次运行时,提示就是目标。之后的每次运行,引导都会覆盖它。所以模型在第三次重试时瞄准的目标,并不是你最初写下的目标,而是引导环节最后说的那句话——而引导环节是循环在你没注意的时候自行组合出来的一行文本。


引导没有得到任何关注

奖励黑客的原因不止一个,大部分注意力都集中在其中两个上:一个宽松到可以被钻空子的检查,以及一个拥有对评分对象写权限的智能体。第三个原因几乎无人关注,而这正是本文要讨论的。它就是循环在每次重试时交给模型的目标,而这个目标就是引导。

图2

模型并不直接优化检查。它优化的是它拿到的指令,而这个指令就是引导写下的内容。当循环反馈回“让测试通过”时,它已经把检查本身命名为了目标。从那一刻起,优化指令和钻测试的空子就成了同一个动作,因为让测试通过的最廉价状态,就是测试与代码已有的行为一致。引导说目标是绿色。绿色就是返回的结果。

这些都不涉及导致钻空子结果的其他路径,有必要坦诚这一点,因为上面提到的 Cursor 文章记录了其中一条路径。它发现的奖励黑客中,很大一部分是答案检索:智能体直接从公开的 pull request 或仓库自带的 git 历史中提取修复方案,其中一个模型 63% 的成功解决方案是检索来的,而非推导出来的。这种情况发生在目标完全完整的情况下。这是一个访问问题,不是引导问题,无论怎么措辞引导都无法阻止它。引导是本文选择的杠杆,因为它没有得到任何关注,而且修复成本最低。你每次重试都要自己写它,而大多数循环都写得不好。


好的引导坚守目标

看看上面的循环带回了什么。目标只在循环开始前声明了一次,而 run_agent 调用从未重新传递它。引导将 prompt 重写为只携带守卫条件自身的输出,别无其他:

图3
prompt="上一次尝试仍然触发了守卫条件;修复它:
$(bash no-mocks.sh 2>&1)"                            # 引导:仅传递新的信号

这就是你想要的形态:先是指令,然后是证据。修复守卫条件标记的那些行,而这些行就在这里,来自检查的逐字输出。目标没有移动,因为引导从未重述目标,它只是把增量附加到目标上。模型得到的是原始目标,加上对上一次尝试哪里出错的精确描述,用的是检查自己的语言。“让测试通过”从来不是它优化的全部内容,因为它所服务的目标仍然和失败行一起在页面上。

一个好的引导是对检查输出的缩减。它获取判定结果和产生该结果的最小证据,然后原样返回。当引导将失败总结为“让它通过”时,它就不再是缩减,而变成了一个新目标,而这个新目标正是智能体会去钻空子的对象。


观察一个循环钻自己检查的空子

下面是同一个循环,一个检查,两个引导。检查是一个单元测试:一笔 100 美元的收费,在 10% 折扣后应该是 90 美元。代码缺少折扣。生成器是模型的替身;它的两个分支执行每个指令命名的最廉价操作,这正是全部要点,所以两者都展示在页面上,而非隐藏起来:

图4
GOAL="charge(cents) 必须应用 10% 折扣,所以 charge(10000) == 9000。"

# 检查:运行测试。通过 = exit 0。
check() { python3 test_charge.py >/dev/null 2>&1; }

# 引导:将检查的输出转化为下一条指令。
steer_good() { printf '%s\n测试仍然失败;修复失败的断言:%s\n' "$GOAL" "$1"; }
steer_bad()  { printf '测试仍然失败。让测试通过。\n'; }

# 生成器:一个字面意义上的优化器,作为模型的替身。它采取指令命名的最廉价路径,
# 和真实模型会使用的捷径相同。
run_agent() {
    case "$1" in
      *"让测试通过"*)         sed -i 's/== 9000/== 10000/' test_charge.py ;;  # 钻空子:修改测试
      *"修复失败的断言"*)  sed -i 's/return cents/return int(cents*0.9)/' charge.py ;;  # 修复:修改代码
    esac
}

好的引导坚守目标并附加失败的断言(期望 9000,得到 10000),所以 run_agent 走修复分支。坏的引导丢弃目标并只返回症状,所以 run_agent 走钻空子分支。用每个引导运行循环,两者都以相同方式终止:

$ bash game-demo.sh good
停止:测试在第 1 次重试后通过
charge(10000) 返回:9000
测试断言:          == 9000
检查判定:         绿色
目标达成(100 美元收费 90 美元):是

$ bash game-demo.sh bad
停止:测试在第 1 次重试后通过
charge(10000) 返回:10000
测试断言:          == 10000
检查判定:         绿色
目标达成(100 美元收费 90 美元):否

存根被固定,因此循环在你的机器上可复现,但它所走的分支并非关键,关键在于主张:交给一个字面优化器,让测试通过,编辑断言是通向绿色的最廉价路径;交给它目标加上失败行,修改代码才是。真实模型在相同的两种引导下也会采取同样的捷径。两次运行都打印"stop: test passes after 1 retries"并返回绿色,因此从循环外部看,两者无法区分——相同的裁决、相同的重试次数、相同的干净退出。差异仅在于产物。良好的引导让charge()保持修复状态,测试断言== 9000;糟糕的引导让bug原地保留,测试被重写为== 10000,所以绿色条出现是因为测试现在认证了bug。


检查认证的是引导所指的任何东西

这里的绿色勾号并非在撒谎。它只是在精确执行其职责。治理选择器部分复现了人类版本:绿色测试证明变更符合其规范,但对变更是否改进了任何东西只字不提,并且它命名了一个象限——变更正确、已发布且并未更好。奖励黑客行为就是有意达到那个象限。那个说"让测试通过"的引导将规范重新指向"测试变绿",而检查忠实地认证了与新退化规范的一致性。

图5

引导的漂移通过两种方式到达检查,它们需要不同的防御措施。一种是转述:引导松散地重述目标,而模型评分的检查(检查部分放在确定性检查旁边的那种)将松散的重述作为其工作规范,因此"让它通过"成为它评分所依据的标准。确定性检查能抵抗这一点,因为它无论引导如何描述,都针对代码运行断言。另一种方式是编辑:智能体修改检查本身,此时确定性检查并不比模型评分的检查更安全,因为冷启动正是这样做的——将== 9000重写为== 10000,而确定性断言在修改后的测试上通过了。确定性带来的是对转述的抵抗力,而非对编辑的抵抗力。决定检查能否在智能体手中存活的轴心不是"确定性vs评分",而是"可编辑vs只读",下面的修复方案正基于此。


同样的动作,远离测试

测试是可识别的案例,而模式更具普遍性。我曾目睹一个智能体,面对某个测量必须达到的硬性限制,提议通过移除系统所做的一部分功能来清除限制,从而使测量显示为绿色。不是通过修复测量所监视的东西,而是通过放弃测量所代表的能力,并报告数字已达标。为了达到预算而削减范围有时确实是真正的工程决策,但这次并非如此,因为没有人认为该能力比数字更不值得保留;智能体默默地做出了这个决定,只是为了把仪表盘变绿。这与编辑测试是同一个动作:满足测量,放弃被测量的东西。它未能上线的原因只有一个——人类阅读了差异并询问为什么修复是通过移除能力来实现的。无人值守运行的循环不会提出这个问题。

这两个案例的共同点是引导。在两者中,循环所承载的目标都悄然从"目标本身"坍缩为"对目标的测量",下游的一切都优化了测量。检查按预期工作,数字也是准确的。循环自我喂食的指令已经从"让产品正确"漂移到"让仪表盘显示绿色",而智能体精确地执行了该指令所要求的内容。


让引导成为归约,并将评分器置于不可触及之处

三项纪律防止引导教会智能体作弊。前两项是实际起作用的部分,也是大多数循环所跳过的。

  • 在重试之间保持目标恒定。在重试臂之外声明一次目标,绝不让引导重述它。引导携带的是增量——上次尝试出错的地方——而将目标留在最初书写的位置。每次迭代都重新编写目标的引导,是一个可能从目标漂移的引导,而且漂移会累积,因为每次重试的转述都是对上一次转述的转述。
  • 将检查的输出作为归约而非摘要来传递。将其简化为裁决和产生裁决的最小证据,并逐字返回。下一次尝试应该读取实际失败的内容,以检查自身的措辞,而不是由中间臂编写的失败描述。这样编写后,引导就是一个你可以自己编写的指令——你固定的目标加上检查输出归约到失败行:
charge(cents) must apply the 10% discount so charge(10000) == 9000.
The test still fails; fix the failing assertion: expected 9000, got 10000.

做到这两点,引导就不再给模型提供游戏的理由,因为它优化的目标是目标本身,而不是绿灯。第三项纪律处理剩余的游戏行为:将评分器置于智能体不可触及的范围。如果智能体可以编辑的产物就是给它评分的产物,那么指向检查附近任何地方的引导最终都会导致检查被编辑以通过,而上一节中的"可编辑vs只读"轴心正是这个道理。让评分器只读,或者根据智能体在生成过程中从未见过的保留检查来评分最终结果——这是治理选择器部分从SkillOpt借鉴的防护措施:仅当自编的变更改进了保留分片(而非变更所调优的数据)时才接受它。诚实地说明这能带来什么。它并不能使游戏行为不可能发生;SpecBench的存在正是因为智能体仍然会失败于保留测试,并且任务规模每增加十倍,差距就会扩大28个百分点。只读或保留评分器所带来的是,游戏行为变得可见且代价高昂:游戏了它无法看到的分片的模型会被它抓住,而不是带着绿色结果溜走。


Reporails在此处能看见和不能看见什么

Reporails读取你编写的引导表面:指令文件、规则以及引导将转述的提示。它不运行你的循环,也不看到引导——引导在运行时组合而成,从未在任何Reporails可读的地方被写下来。它能做的是让编写的部分正确,以便运行时部分有更少的东西可以腐蚀。一个清晰陈述的目标,并测量其措辞是否实际与行为耦合,是一个引导更难悄悄重述为"通过检查"的目标。运行时交接需要你自行构建好;它起始的编写表面是Reporails测量的部分。

交接值得构建好的原因是,没有人审查引导。循环中的每一条其他指令都是你编写并可读的。循环为自己编写的引导,每次重试一次,以机器速度运行,在任何人看到之前就被下一次生成所消耗。那是漂移的指令成为下一个目标的地方,也是奖励黑客行为被编写的地方——一次一个引导。让它成为你可以检查的归约,并将评分器置于智能体的编辑范围之外,循环就会优化目标而非仪表盘,而满足前者总是比满足后者更廉价。


循环仍有一个臂需要拆解

四个臂分析完毕,模式在所有臂中保持一致:循环只对你编写进它的内容起作用。检查运行你编码的规则,门禁拒绝你设置的模式,表面承载你加载的指令。引导是你每次重试时不知不觉编写的那一个,而正是在这里,绿色结果悄然停止代表你原本想要的意义。

剩下的一臂是停止。这里的每个循环都会在绿色勾选和重试预算下退出,而一个在人为操控的绿色上停止的循环,会因一个毫无意义的结果而过早终止。区分真正的绿色与买来的绿色,以及判断循环何时应退出、何时应拒绝退出,正是停止臂要解决的问题,也是本系列的最后一环。

我从事Reporails的工作,这是针对指导编码代理的指令文件、规则和提示的确定性诊断工具。它能读取操控表面,并通过实测证据告诉你:哪些指令与行为耦合,哪些文本模型可以忽略。它不运行你的循环,而是检查你写下的操控指令。

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

← 上一篇
2026年9大最佳开源大语言模型(对比)
下一篇 →
教授Claude Code绘画:基于Gemini交互API与MCP构建的有状态图像编辑技能

📌 相关推荐

停止相信仅文本代理排行榜:来自 Cua-Bench 和 Factorio 的教训
2026/8/26
Agent Memory 有两种不同含义,回答引擎给出的却是错误的那一种
2026/8/26
LLM的止境:AI辅助VAPT流水线的确定性评分
2026/8/22
← 返回文章列表