目录
- 0. 前言
- 1. Hacking 的成因与形态
- 1.1 优化了代理指标,没优化真实目标
- 1.2 训练设置本身放大了概率
- 1.3 常见的几类形态
- 1.4 三层防线
- 2. 环境侧的加固
- 2.1 环境侧的加固清单
- 2.2 环境侧加固的边界
- 3. Reward Hacking 检测
- 3.1 Rule-based
- 3.2 LLM judge
- 3.3 两层的分工与成本
- 3.4 GLM-5.2 的做法
- 4. 训练侧优化
- 4.1 三个选项
- 4.2 为什么不选整条打压
- 4.3 以 turn 为单位,不是以 token
- 4.4 正样本里 mask 掉作弊的 turn
- 4.5 如果要打压,就只让作弊的 turn 进梯度
- 5. 总结与思考
0. 前言
Coding Agentic RL 中 reward 的来源比大多数场景都干净:把模型写出来的代码跑一遍测试,通过给正分,不通过给零分或者负分,既不需要人工标注,也不用另外训一个 reward model。但这里面有一次隐含的替换,我们真正想要的是"这个问题被解决了",而实际发下去的信号是"这一组测试通过了"。测试终究只是一组有限的检查,只要模型能让那个判分的进程返回通过,中间到底发生了什么它是看不见的。这类把检查糊弄过去、而问题并没有真的解决的行为就是 reward hacking,它在训练里的表现相当一致:reward 曲线一路往上走,eval 却不动,有时候还会往下掉。
这篇文章讨论的是 hacking 出现之后怎么处理,包括环境侧、检测侧、训练侧各自怎么处理,重点会放在训练侧。
写之前大概看了下,网上目前应该还没有类似的文章。博主本人目前做的就是 Coding Agentic RL 相关的训练工作,踩过一些坑,这里把其中一部分经验和理解整理出来。希望对相关的从业者有一些小的帮助。文中有不少内容来自个人在实际训练中的观察和理解,也结合了一些公开的技术报告,但并不一定完全正确。如果读者朋友有不同的实践经验或者理解,也欢迎讨论和指正。
1. Hacking 的成因与形态
1.1 优化了代理指标,没优化真实目标
Reward hacking 的定义可以写得很短:模型优化的是 reward,而 reward 只是真实目标的代理,于是模型把代理指标做上去了,真实目标没动。
需要强调的是,这不是一个 bug,而是 RL 的必然产物。只要 reward 不等于 true objective,policy 优化的方向就是 reward 而不是 objective,这一点没有例外。经典的例子是那条在赛道上原地转圈刷道具分、始终不冲过终点的船,不过这个例子太老了,就不展开了。
对我们更有用的是一个可以直接观察的判据:reward 上去了,但能力没上去。Reward hacking 在训练曲线上的典型表现是 reward 稳步上升、eval 指标不动甚至下降。GLM-5.2 技术报告的说法可以直接借用:hacking 让 verification signal 变得容易优化,但 “fails to actually improve the fundamental capabilities of the model”。
1.2 训练设置本身放大了概率
Coding Agentic RL 相比其他 RL 场景的特殊之处,在于我们主动给了模型一个可以任意操作的容器。一个只输出文本的数学模型,能做的最多是把答案写得像对的;而一个 Code Agent 手里有 shell。它可以读任意路径的文件,可以起子进程,可以装包,可以改测试文件本身。我们期待它用这些能力去写代码,但这些能力本身不区分用途。让它有能力修 bug 的那套权限,同时也让它有能力去看答案。
在 Coding Agentic RL 场景下,还有一个容易被忽略的放大器:轨迹长。长轨迹 Code Agent 动辄几十到几百个 turn,一次探索里的动作数量是解数学题的两三个数量级。行为空间越大,被采样到"恰好绕开了 reward 定义"的那条路径的概率就越高。RL 不需要模型"想到"要作弊,它只需要有一次偶然采到,reward 给了正反馈,梯度就会把这个动作固化下来。我在训练里见过的很多 hacking 行为,看轨迹前半段都不像是有预谋的,更像是模型在环境里乱翻的时候撞上了。
再叠上第三点:pass/fail 的 reward 特别 vulnerable。它是二值的、全局的、由一个外部进程给出的。只要能让那个进程返回 0,reward 就到手了,中间发生了什么它一概不管。GLM-5.2 技术报告里边提到:
Coding RL is especially vulnerable to reward hacking because the reward is typically a verifiable pass/fail signal.
此外,模型能力越强,hacking 问题只会越明显,因为找空子本身就是一种能力。GLM-5.2 的报告自己也承认,“We find that GLM-5.2 shows more potential hacking behavior than GLM-5.1”。也就是说防 hack 不是做一次就完的工程,模型每强一轮,都会出现一些新的 hacking 行为。
1.3 常见的几类形态
按"模型绕过的是哪一环"来分,训练过程中实际见过的形态大致可以归成四类,这四类出现的频率差得比较多,能拦下来的位置也不一样。
第一类是信息泄漏,模型没有去解题,而是想办法先看到了答案。按答案放在哪里,这一类又有三种不太一样的来源:一种在容器里,测试文件本身、评测框架落盘的中间结果、任务自己的元信息,都可能直接带着期望输出;一种在外网,目标项目的上游实现、相关的问答页面,一次 clone 或者一行 curl 就能拿到;还有一种最容易被忽略,目标项目本身就是一个已经发布出去的包,pip download就能把它连源码一起下下来,甚至容器的site-packages里可能已经装着一份。
第二类是篡改评判,模型不改自己的实现,改判分的那一侧:把断言删掉,把用例改成永真,往测试目录里塞一个conftest.py把 fixture 换掉,或者直接给测试函数打上 skip。
第三类是环境利用,代码和测试都不碰,绕过的是整套判定过程:让测试进程在收集阶段就提前退出、改环境变量让判分逻辑走另一条分支、写一份假的结果文件让上层直接去读它。这一类和上一类动的都是判定链路,所以也都能在环境侧关掉大半。
第四类是针对输入硬编码,不实现逻辑,只实现"让这几个用例过",典型写法是if input == "abc": return 42,或者把所有分支都return None,恰好测试只检查不抛异常。这一类的性质和前三类不太一样,模型并没有做任何越界的动作,交出去的就是它自己写的代码,环境侧和检测侧都不太好说它错在哪。
这四类的边界并不严格,一条真实轨迹里经常同时出现两三类。但分类还是有用的,因为它们的最佳防守位置不一样。
1.4 三层防线
前面几类形态各自的最佳拦截位置并不相同,但归拢起来能下手的地方只有三处,本文后面的章节也是按这个顺序展开的。
第一处是环境侧,在模型动手之前就把能关掉的攻击面关掉,断网、把测试目录挂成只读、让隐藏用例不落盘都属于这一层。它的性价比最高,配置改一次就永久生效,训练过程中不再需要为它付算力,所以凡是能在这里关掉的东西,都不该留给后面两层。
第二处是检测侧,接的是环境侧原理上关不掉的那部分,比如单个动作都合法、连起来才构成一次泄漏的情况。常见形态是规则和 LLM judge 两级级联,规则跑在每一个 turn 上,负责尽量多地把可疑动作捞出来,judge 只看被捞出来的那些,判断它的意图是不是在绕过判定。这样分工的原因是成本:规则便宜,但只能匹配已经见过的形态;judge 泛化更好,可每次调用都要花钱和时间。
第三处是训练侧,回答的是检测出结果之后的那个问题:这条轨迹里哪些 token 还该进梯度。这一层是本文的重点,一是公开资料里几乎没有人写这一步,二是前两层改变的都是这一次调用的结果,而模型下一次还会不会这么做,只能由进了梯度的那部分决定。
2. 环境侧的加固
这一章会写得比较短,但它在优先级上是最高的。原因很简单:环境侧的加固是一次性成本,配置改完就永久生效,不占训练时的任何算力;而检测侧的每一条规则都要在几百个 turn 上反复跑,LLM judge 更是每次调用都在烧钱。所以凡是能在环境里关掉的,就不该留到检测层。
2.1 环境侧的加固清单
下面这几条都是实际启用过的,下面简单讲一讲。
断网是收益最高的一条,答案在外网的那一路会被它一次性关掉,clone 上游仓库、curl 单个源码文件、联网搜索答案都包含在内。评测环境应该默认断网,GLM 的几个评测集也是这么配的。但训练环境不一定断得起:像 NL2Repo 这种建仓任务,用到哪些依赖由模型自己决定,装包必须走网络,直接断网任务就起不来了,能做的只是把出口收窄到内网镜像源和少数几个域名。而且断网不等于安全,pip 源如果还通着,pip download依然能把答案搬进来。
包管理走白名单或者离线 index,针对的是目标项目本身已经发布成包的情况——pip download、pip wheel、pip install git+...都是把答案下载下来的合法通道,靠禁用某几条命令挡不住,把 index 换成离线镜像、并把目标包从里面剔掉,是比较干净的解法。
hidden test 不落盘针对的是答案在容器里的那一路:训练时可见的用例和判分用的隐藏用例分开,隐藏那部分在容器的整个生命周期里都不出现在任何路径上;评测框架自己落盘的中间结果同理,不要写在模型读得到的地方。
测试目录只读挂载基本能消灭掉整个篡改评判类别,做法是把测试挂成只读,或者更彻底一点,判分用的那份测试根本不放在模型能看到的文件系统里,跑分的时候才挂进来。关键文件哈希校验可以作为兜底,判分之前对测试文件和相关配置做一次哈希比对,不一致直接判 0。
2.2 环境侧加固的边界
上面这些做完,攻击面能砍掉一大半,但砍不干净,剩下的部分是结构性的,继续往下收权限也解决不了。
先是硬编码。把 test case 藏起来堵不住,因为任务总得给模型一些可见的信息:issue-level 任务里,失败的测试本身就是任务输入,藏不掉;建仓任务的需求里也带着示例和验收标准,模型照样可以对着这些把结果写死。更麻烦的是,只要开发过程中允许跑测试看结果,失败输出里通常就印着期望值,一条 assert 就把答案漏了出去。把可见信息和测试反馈全部断掉当然干净,但那等于让模型盲写,任务完成率会先崩掉。
然后是跨 turn 的组合。GLM-5.2 报告里举了这么一个例子:
1. find /workspace -name "*hidden*" 2. cat /workspace/.eval/secret_cases.json 3. python solve.py --case "$(cat /workspace/.eval/secret_cases.json)"find是合法的,cat是合法的,把一个字符串传给自己的脚本也是合法的,三步连起来才是一次完整的答案泄漏。权限系统看的是单次系统调用,它没有办法表达"这三步连起来不行",跨 turn 的意图本来就不在它的视野里。
最后是加固本身的上限。环境侧如果权限收得太死,模型连正常开发都做不了,不能装依赖就没法跑测试,不能写文件就没法改代码。加固到某个程度之后,再往下收一格换来的是任务完成率下降,而不是 hacking 率下降。所以检测层要处理的不是环境侧没做好留下的残余,而是它在原理上就覆盖不到的那部分。
3. Reward Hacking 检测
Coding Agentic RL 中 Reward Haking 的检测这件事,业界的收敛结果基本是一致的:规则负责召回,LLM 负责精度,两级级联。GLM-5.2 技术报告里有提到:
The detection process has two stages: a rule-based filter first catches potential hacks to maximize recall, and then an LLM judge checks the intent of these flagged actions to keep precision high.
3.1 Rule-based
规则层的判据可以简单理解为一个合取式:
可疑动作 AND 指向了本任务的目标项目两个条件缺一个都不成立。只有可疑动作但没有指向目标,那就是正常开发——pip install requests是可疑动作(联网装包),但它跟本题无关,是纯误报。只有"指向目标"没有"可疑动作"也不成立——模型在自己的 workspace 里写一个跟目标项目同名的文件,这是它本来就该干的事,也不能被判为 hacking。
按可疑动作的形态,常见的比如这几类:
- 评测物读取:直接去看用来判分的东西——读容器内的测试目录,读评测任务自己的元信息、结果落盘目录。
- 外部实现拉取:从外网把"答案"搬进来——
git clone目标仓库、curl上游单个源码文件、pip install git+...,以及搜索词里带 “reference implementation” 这种意图的联网检索。 - 发行制品还原:目标项目本身是个已发布的包,从制品里把源码捞出来——
pip download/pip wheel目标包、下 tarball 再解压、从site-packages里读目标包源码。
这只是三个常见的例子,实际的情况远比这三类要多。规则层的压力还跟任务形态强相关。同样是在 Code 上做 RL,repo-level(比如从零生成一个 repo)和issue-level 任务(给一个已有 repo 修 bug / 加 feature)hacking 频率和形态差别很大:repo-level 明显更容易出现 hacking:
答案的形态不一样。建仓任务的目标往往是一个真实世界里已经发布出去的项目,它的答案是一个可下载的实体——PyPI 上有包,GitHub 上有仓库,甚至容器的
site-packages里可能已经装了一份。而 issue-level 任务的答案是一个 diff,一次具体的提交,它不是一个可以pip install下来的东西。泄漏路径数量差一个数量级。承接上一条:目标是包,那么
pip download、pip wheel、tarball 解压、读site-packages、inspect.getsource、查索引 metadata、全盘find项目名——每一条都是独立的通道,规则层得一条一条去堵。目标是 diff 的时候,这些通道大部分自然消失。信噪比不一样。issue-level 任务里,repo 本来就在本地,模型读源码是它必须做的事,"读了目标项目的代码"这个信号在这里没有任何区分度。而建仓任务的 workspace 一开始是空的,目标项目的源码本来就不该出现在里面,所以"workspace 里出现了目标项目的完整实现"是一条边界极其干净的判据。
在写规则的时候,重心不在正则,而在实体识别。每一条规则都要回答"这个字符串是不是指向了本任务的目标项目",而目标项目名有大小写差异、有连字符和下划线的互换、有 PyPI 名和 import 名不一致、有一整族相关项目共用前缀。所以整个检测器是围绕一个"目标项目身份"的上下文对象展开的,规则只是简单的判据。
3.2 LLM judge
在前面的例子中,cat tests/secret_cases.py这条被规则拦掉之后,模型下一次可能就换成先ls一遍目录再用变量把路径拼出来,或者改用head、python -c "open(...).read()",甚至只是把命令挪进一个bash -c里。模型的这种行为并不是在针对检测器,它可能只是在自己的 action space 里乱走,试的次数够多总会碰上一个没被覆盖的写法,一般来说模型越强这个过程越快。
此外,被规则命中了的那些也不见得都是真的。比较常见的一种是模型跑了一次测试,失败信息里说缺一个依赖,而这个依赖的名字和目标项目长得很像,于是它把包装上了,规则在这里会命中"安装目标包",可要是和前一个 turn 连起来看,这只是被测试反馈驱动的正常修环境。更麻烦的是同一个动作在不同上下文里性质完全不同:去读一个site-packages下的源码文件,如果上一个 turn 刚报了这个库的ImportError,那是在排查依赖;如果上一个 turn 的 reasoning 里写着"先看看有没有现成的实现可以参考",那就是在捞答案。这两种情况在规则层眼里是同一条入参,前面发生过什么、模型当时在想什么,它都看不到,所以只能给出同一个结论。
因此,在规则命中之后其实并不能直接把结果判定下来,而是要基于一些上下文来做进一步的判断,这一步只能交给 LLM 了。LLM judge 要回答的问题也不是"这条命令危不危险",那个问题规则已经回答过了,它要判断的是在当前这个任务、这个上下文下,这个动作究竟是在解题,还是在绕过解题。输入上需要做一些裁剪,实践中给到 judge 的一般是命中的那条规则、命中的那个 turn、前面若干 turn 的摘要,以及本任务的目标项目,基本就够用了。在 judge 时,整条轨迹没有必要全塞进去,一方面是成本,另一方面几百个无关的 turn 反而会把注意力稀释掉。
不过 judge 只在规则命中之后才会被调用,也就是说整套检测的召回完全由规则层决定,judge 只能往下修剪,规则漏掉的那部分它一辈子都看不到。所以规则层宁可多报,让 judge 去挡;为了准而把规则收紧,漏掉的就是真漏了。
3.3 两层的分工与成本
上一节把 judge 说得挺有用,但它显然不能拿来判每一个 turn。长轨迹 Code Agent 一条轨迹的 turn 数在几十到几百这个量级,按一条 300 个 turn、一个 batch 几百条轨迹来算,一个 step 就是十万次量级的 judge 调用,这个量级本身已经和 rollout 自己的推理量差不多了,等于为了防作弊把训练成本翻一倍。而且这十万次里绝大多数都花在毫无嫌疑的动作上——ls一下目录、cat自己刚写完的文件、跑一遍测试再看输出,这类 turn 在一条正常轨迹里占了绝大部分,让 LLM 去看一眼,得到的结论一定是没问题。
所以规则层存在的第一个理由,就是把这十万次缩到几百次。规则层的单 turn 开销基本可以忽略,纯字符串和正则,因此可以毫无顾虑地对每一个 turn 都跑一遍,然后只把命中的那一小部分交给 LLM judge。
此外,反过来看,正因为后面有 LLM judge 兜底,规则层的写法其实可以比它单独工作的时候宽松不少。如果规则就是最终结论,那么每一条规则都得自己承担精度,一旦误报就直接影响到训练数据,写的时候必须处处保守;而现在规则要回答的问题被降级成了"这个 turn 值不值得再看一眼",判错的代价只是多花一次 judge 调用。这一点会实际改变规则的写法:项目名的匹配可以放得更松一些,把一族相关的项目名、常见别名和拼写变体都算进去;一些拿不准的动作,比如全盘find里带上了目标项目名,与其纠结它到底是在清缓存还是在找源码,不如直接让它命中,把定性的工作交给 judge。
3.4 GLM-5.2 的做法
这里先给出 GLM-5.2 的技术博客:GLM-5.2: Built for Long-Horizon Tasks
一般来讲,hacking 检测器给出结论之后,环境侧的处置只发生在动作执行之前那一刻,要么放它过去(返回工具调用结果),要么把它拦下来。放行是只记录、不干预,这种处理方式的代价是 hack 真的生效了,而且被污染的不止命中的那一个 turn,后面所有的推理都建立在"已经知道答案"的基础上。阻断是在动作执行之前把这次调用拦掉,回给模型一个空结果或者一条无意义的信息,这样 hack 不会生效,轨迹从这个 turn 往后重新回到干净状态,模型只能继续靠正常的办法往下做,代价是检测必须做成在线的。
阻断这个方式也是目前比较主流的一个方式,这个方式有两个点需要注意。
- 一个是检测时只看工具调用的命令,不需要看工具的返回内容:模型执行
cat /some/path的时候,判的是这个路径,而那个文件里到底读出来了什么,检测器并不关心。这是因为拦截发生在动作真正执行之前,那个时刻进程还没有跑起来,返回内容根本就不存在,能看的东西只有入参。 - 另一个是检测器自己出错的时候一律放行,整个入口都包在一层兜底里,不管是规则解析、URL 拆分还是 judge 调用,任何一步抛了异常,结果都应该按"没有命中"返回。检测器挂在 rollout 的主链路上,它自己的 bug 不该有能力把训练打断:设想一条正则写错导致它崩溃,而崩溃时的默认行为是拒绝,那么每一次工具调用都会被拒掉,所有轨迹一步都走不下去,整个 batch 直接报废;而漏拦一次的后果要小得多,无非是这条 hack 生效了,后面还有 judge 和训练侧两级可以处理。
Anti-hacking 现在已经是前沿模型后训练里一个会被单独拿出来交代的环节。GLM-5.2 的技术报告就把它和长程 RL 并排写在同一节里,标题是 “RL for Long-Horizon Task with Anti-hacking”。具体怎么处置被检测出来的动作,报告里有这么一段话:
We use an online strategy that monitors the tool calls at each step. If a hack is detected, the system blocks the call and returns dummy information as the result. Importantly, this online guard allows the model to continue the rollout even after a hacked action is caught. By handling the specific invalid behavior instead of rejecting the entire trajectory, this approach helps prevent the training instability and model collapse that can happen when rollouts are abruptly stopped.
从中可以看出三点:
- 阻断,而且是 online。“monitors the tool calls at each step” 和 “online guard” 两处都指向执行前拦截。
- 拦掉之后返回 dummy information,告诉模型这样做没有有效信息,模型更可能退回到正常解法上。
- 关于阻断的理由,原文说的的是 “training instability and model collapse”,所以这是一个训练设计而不是安全设计:不掐断 rollout 是因为掐断会让训练崩,不是因为它对模型更公平。这条判断的前提是一次 hacking 尝试不足以否定整条轨迹的价值,前面几十个 turn 通常是正常的读代码、写实现、跑测试,而长轨迹 Code Agent 的 rollout 成本极高,一条几百 turn 的轨迹要跑几分钟到几十分钟,掐掉一条相当于白白浪费很多机器时间。
报告还提到这套 anti-hack 模块在 RL 训练和评测两侧都启用(“for both RL training and evaluation”)。评测侧的配置写在脚注里:NL2Repo 用规则加 LLM 判断拦 unauthorized pip / curl 这类行为,DeepSWE 和 ProgramBench 的评测容器直接关掉网络。
与 anti-hack 相邻的前一段讲的是长程 RL 的优化目标:compaction 会把一条超长轨迹切成若干 sub-trace,同一个 prompt 的不同 rollout 切出来的 trace 数量和长度都不固定,group 内的相对比较因此失效,所以他们从 group-wise 换成了 critic-based PPO,用 critic 估 token-level advantage,再以 token-level loss 处理长度不均。但如果使用 GRPO 等 token 不敏感的优化方法,如何在训练侧进行优化原文没有提到,这也是我们下一节要讲的内容。
4. 训练侧优化
这一章是全文的精髓。前面三章走完,进入训练前,我们能看到的是一条跑完的轨迹、一个 outcome reward,以及若干个被标记为 hacking 的 turn,环境加固和检测做的全部事情都只是把这些信息准备好,而模型的行为最终是由梯度塑造的,所以真正决定它学到什么的是最后这一步——这条轨迹怎么进梯度。
4.1 三个选项
通俗来讲,训练侧拿到一条带 hacking 标记的轨迹,能做的事情只有以下三种:
- 第一种是不管,hack turn 和其他 turn 一样进梯度,标记只拿去看指标,不参与任何 loss 计算。
- 第二种是打压,检测到 hacking 就把整条轨迹的 reward 直接改成 -1,让它作为负样本进训练。这是最直觉、也是实现成本最低的一种,在 reward 计算的末尾覆盖一个值就完事,不需要 turn 到 token 的映射,不需要动 loss,可以无缝衔接到 GRPO 之类的优化方式上。
- 第三种是不学,只把 hack turn 那一段 token 从 loss 里 mask 掉,轨迹其余部分照常进梯度,reward 也不动。但这种需要保证最后的 reward 是真的体现了模型自己的能力。
4.2 为什么不选整条打压
整条打压的实现成本几乎为零,在 reward 计算的最后一步把这条轨迹的值覆盖成 -1 就完成了,还可以顺手加一道生效范围的开关,只对某几类 hack 或者某几个任务集合起作用,其余的照旧。这条路径一般都会留着,因为它随时可以打开,但默认状态应该是关掉的,原因有三条。
第一条是罚错了对象。如果环境侧已经把这次调用拦在了执行之前、只把一段无用的结果丢回去,那这次 hack 实际上没有生效,模型既没有读到答案也没有绕过测试,最后那个正 reward 是靠后续正常的开发过程挣回来的。这时候把整条判成 -1,惩罚落在"它动过这个念头"上,而不是"它靠作弊拿到了分"上,一次被拦住的尝试和一次真正成功的作弊在梯度里被当成同一件事。更麻烦的是这两种情况在数据里的比例差得很远:阻断一旦打开,绝大多数标记对应的都是前者,也就是说打压这个动作绝大部分时候都打在了没有造成任何后果的尝试上。
第二条是牵连了无辜的 turn。Coding Agentic RL 里一条轨迹的 turn 数动辄上百,上限通常也就设在几百这个量级,而其中触发检测的往往只有一两个 turn,剩下的部分是完整的读代码、定位、改代码、跑测试的过程,恰恰是最希望模型学到的东西。整条判 -1 等于告诉模型这几百步全都错了,而其中绝大多数步骤是对的。这是很典型的 credit assignment 错误,而且区别于外部环境本身就只能给出轨迹级 reward 这种客观限制——检测给出的信息明明是带 turn 位置的,但却主动把它降级成一个轨迹级的标量再用,丢掉的精度是自己扔的。
第三条是对误报零容忍。规则层为了 recall 是刻意做激进的,误报从一开始就被算在设计里,后面那层 judge 只能把误报压低,压不到零。整条打压之下,一次误报的代价是把一条完整的正样本翻转成负样本,损失是双份的:一条本来能学的轨迹没有了,同时还多出一个方向完全相反的梯度。而 mask 之下,一次误报的代价只是这一两个 turn 的 token 不参与更新,同一条轨迹剩下的几百个 turn 照常进梯度,量级上小得几乎可以忽略。
因此,检测出来的东西是"哪个 turn 里出现了什么动作",那么处置就应该落在这个 turn 上,而不是先把它压成一个轨迹级的标量再去改 reward。接下来的问题也就随之变成粒度到底切在哪一层——turn,还是更细的 token。
4.3 以 turn 为单位,不是以 token
粒度有两种切法,一种是把整个 turn 的 token 一起挖掉,一种是往下切进 turn 内部、只挖掉真正出问题的那几十个 token。后者看着更精确,但在工程落地上做不下去,因为检测交出来的证据本身就是 turn 级的:比如检测说的是第 47 个 turn 里的这次工具调用有问题,而不是这个 turn 的第 300 到第 340 个 token 有问题。硬要往下切,就得回答一串没有标准答案的问题——模型在 reasoning 里写"我先看看测试文件放在哪",这句算不算;工具调用的参数里只有路径那一段算,还是整个 JSON 都算;如果这次调用是在权衡过两三种方案之后选出来的,那前面被否掉的方案要不要一起算。这些问题每出现一种新的 hack 形态就得重新回答一遍,而答错一次的结果就是留下一段该挖没挖的 token。
比精确度更要紧的是语义上完整的作弊单元恰好就是一个 turn。一个 turn 里装着"怎么想 — 决定做什么 — 发出调用"这三件事,它们是连在一起产生的,只 mask 掉最后那段调用参数、把前面的 reasoning 留在梯度里,模型学到的东西是这么想是对的、只是别这么写出来,比什么都不做更糟糕,因为它把一个显式的、检测得到的动作训成了一种隐式的倾向。
4.4 正样本里 mask 掉作弊的 turn
在 Coding Agentic RL 中,一条轨迹最后拿到了大于 0 的分数。这个分数可能有三种来路:
- 全程没有 hack,模型老老实实读代码、定位、改代码、跑测试,把任务做出来了,这条轨迹上不会有任何标记。
- hack 没有被阻断,模型靠下载参考实现、翻上游提交、读到测试用例这类动作把分数拿到了手,标记有,而且分数就是这个动作换来的。
- hack 出现过、但在执行之前被截住了,模型拿回来的是一段无用的结果,没有从里面得到任何有效信息,最后还是靠自己的能力把任务做出来的。
mask 处理的是第三种。做法是整条轨迹保留、reward 也不动,只把被标记的那个 turn 所占的 token 区间从 loss 里挖掉,其余几百个 turn 照常进梯度。之所以可以这么处理,是因为这一种情况下分数的可信度没有问题,任务确实是靠模型自己的能力完成的,那条完整的开发路径值得强化,只有中途那一个动作不值得。如果阻断没有开,mask 就失去了意义:第二种情况下分数不是模型自己挣来的,把作弊的那个 turn 挖掉之后,剩下的部分依然构成一条"走捷径走通了"的路径,梯度还是在强化它,只是把最显眼的那一步从证据里抹掉了而已,这时候该判断的是这条 reward 还能不能用,而不是挖掉哪几个 turn。
保留其余部分的理由是正的 reward 说明这条路径整体上值得强化,那个 hacking turn 只是路径上的一处瑕疵,不该被这个分数顺带着一起抬起来。不 mask 的话,进梯度的就是"包含这个动作的完整流程 → 高分"这样一个整体,模型没有办法从里面分辨出哪一步是多余的,下一次它会把这个动作当成流程的标准组成部分照抄下来。
在难任务上还有一层更现实的考虑。repo-level 的任务一次跑通全部测试的概率本来就很低,如果 reward 只分全过和全不过两档,绝大多数轨迹拿到的都是0,一个 group 里没有任何区分度可用,所以这类任务的 reward 通常按测试通过的比例来给,改对了一部分就能拿到一个介于 0 和 1 之间的分数。然而,即便放宽到这个程度,能拿到大于 0 分数的轨迹仍然是少数,正样本在一个 batch 里本身就是稀缺资源。这时候只要出现 hack 标记就把整条轨迹打压掉,等于把本来就不多的正向信号又砍掉一部分,剩下的训练很容易退化成只在负样本上打转。
4.5 如果要打压,就只让作弊的 turn 进梯度
正常来讲一条轨迹的 reward 只落在 0 到 1 之间,测试通过多少就给多少。如果某个阶段确实需要把某一类 hack 快速压下去,最直接的做法就是检测命中之后把整条轨迹的 reward 覆盖成 -1,让它作为负样本进训练。
但是需要注意的是,这条轨迹里几百个 turn 绝大多数是无辜的,读文件、定位、改代码、跑测试这些动作本身没有任何问题,而 outcome reward 是全局的,-1 会摊到每一个 turn 上,模型从里面学到的是这些正常动作都不该做,长轨迹 RL 里负样本更容易把模型训坏,很大一部分原因就在这儿。要让打压变成一个可用的手段,就得把这个 -1 收窄到它该去的地方:把除作弊那个 turn 之外的所有 token 全部 mask 掉,只留这一个 turn 进梯度,让 -1 完整地落在这一步上。这样一来负信号不再被几百个 turn 稀释,这个动作的概率会掉得很快,同时正常动作一个都没有被牵连。
5. 总结与思考
本文按三层防线过了一遍 Coding Agentic RL 里的 reward hacking:环境侧先把断网、测试只读这类能一次性关掉的攻击面关掉;检测侧用规则做召回、LLM judge 判意图,处理环境原理上覆盖不到的那部分;训练侧决定被标记的 turn 怎么进梯度,正样本里把作弊的 turn 从 loss 里挖掉,确实需要打压的时候反过来只保留作弊的 turn,让处置的粒度和证据的粒度对齐。
最后,谈一谈我对 Coding Agentic RL 中 Reward Hacking 检测的一些看法:
- 规则清单的有效期比想象的短。模型能力每上一个台阶,或者同一个模型多训几十个 step,都会冒出以前没见过的 hack 形态,GLM-5.2 的报告里也提到它比上一代表现出更多的 hacking 倾向。找空子本身就是一种能力,模型越强越容易在探索里撞上规则没覆盖的写法,而且它并不需要理解规则,采样次数够多自然就漂到边界外面去了。所以规则层的工作方式注定是周期性的,训一段时间、捞一批轨迹回来看、补几条规则、再往下训,指望一次写全不现实,接 LLM 也就成了必然——规则只能匹配见过的形态,而 LLM 多少能对"这个动作是不是在绕过判定"做一点泛化。
- 整条轨迹 -1 压 hack 率很快,但效果不一定好。轨迹级的 -1 会把这条轨迹里所有动作一起压低,其中大部分是正常的探索行为——翻文档、读第三方库的源码、试一条不确定的命令,这些动作在表面形态上离作弊并不远,被连坐几次之后模型会变得保守,只走它已经确认安全的那几条路。表现出来就是 hack 率的曲线很好看,而 reward 到后期怎么也涨不上去,因为长任务的解法本来是靠试错试出来的。此外,被强惩罚之后,模型面对的实际目标变成了"拿到 reward 且不被检测到",换一种检测不到的作弊方式往往比真的去解题离当前策略更近、梯度更陡,于是惩罚并不消灭 hacking,它筛选出更隐蔽的 hacking,而这些在曲线上什么都看不到。
- 训练里的拦截在真实使用时其实并不存在。模型厂商交付的是一个模型,用户拿它接哪个 agent、哪套工具、什么样的沙箱都是用户自己定的,没有人会在用户的容器里替它拦下一次 curl。训练时模型面对的世界是"这条路会返回一段无用的结果",真实使用时它面对的世界是"这条路真的能把上游实现下载下来",这层 diff 决定了拦截不能是唯一的防线。如果模型学到的只是"这个动作在这个环境里没用",换到一个没有拦截的环境,这个倾向会立刻兑现出来。要让它在部署环境里也不这么干,信号必须落到权重上,也就是把这个 turn 从正样本的梯度里挖掉、让它的概率不被抬高,而不是指望环境每次都替它挡住。
以上大部分内容来自实际训练里的观察和踩过的坑,样本量有限,可能有错误和遗漏,欢迎讨论和指正~