跳转到主要内容
第 3 章实现了一个根据前缀生成 token 的策略。接下来要让生成的文本真正产生作用:读取文件、运行测试、修改代码,或者提交结果。环境(environment)负责执行这些请求,并决定智能体能看到什么、能调用哪些工具、错误如何处理、回合何时结束,以及如何计算奖励。本章把这些约定落实成可运行的 BugFix gym。 本章构建全书用于训练的环境:BugFix gym。它是一组小型但真实的 Python 修复任务。每个任务有一个问题描述(issue)、一个有缺陷的函数、智能体可以运行的公开测试(public tests),以及决定奖励的隐藏测试(hidden tests)。真实编程智能体通常用 shell 和文件编辑器等通用工具,自行写出修改内容。gym 保留了读取、修改和测试的交互结构,但简化了编辑这一步:智能体通过四个工具(read、test、edit <op>、submit)行动,调用必须符合固定语法;edit <op> 则从预定义操作中选择修改,不要求模型自行写补丁。测试执行真实的 Python,编辑变换真实的源码字符串。其中一个编辑操作是漏洞利用(exploit):它不修复任何东西就能通过公开测试,所以奖励投机(reward hacking)从一开始就可复现。最后,我们测量脚本化参考策略,它们的成功率是之后每个实验的基线(baseline),并把这一设计与 SWE-bench、R2E-Gym 等生产系统进行比较。

4.1 环境契约

第 2 章把智能体的世界形式化为部分可观测(partially observable)MDP。环境通过两个调用实现这一形式化。reset(task) 开始一个回合并返回第一个观测(observation)。step(action) 执行一个动作,返回下一个观测、奖励、终止标志和一个诊断信息字典。BugFix gym 采用这一契约,两侧都是文本:
环境的状态(state)是决定接下来会发生什么的一切:任务、经过所有编辑后的当前源码、轮次计数器,以及回合是否已经结束。智能体从不直接看到状态。它看到的是观测,即工具产生的文本;它也完全看不到隐藏测试。这一差距使问题成为部分可观测的,而且是有意为之:能读到隐藏测试的智能体可以不修复缺陷就满足它们。 动作(action)是策略写出的字符串。环境把它解析为一次工具调用(tool call),执行该调用,并把工具的输出作为下一个观测返回。一次这样的动作–观测交换就是一轮(turn)。回合以两种方式之一结束:智能体调用 submit,环境在隐藏测试上给当前源码评分;或者智能体用完了轮次预算(max_turns),回合在没有提交的情况下结束。生产环境还会对 token 数和实际耗时设置预算。BugFix gym 在这里只限制轮次,第 5 章把回合转换为 token 序列时会加上上下文长度限制。 图 4.1:作为状态机的 BugFix 环境。从“awaiting call”出发的每条箭头都消耗一轮。 图 4.1 就是整个环境。沿着从中心状态出发的路径走一遍。无法解析的调用返回一个错误观测,同样消耗一轮。有效的 read、test 或 edit 返回其输出并回到原状态。只有 submit 通向隐藏测试评分器。预算检查在每一轮之后运行;如果智能体还没有提交,它会以奖励 0 结束回合。奖励只在一个地方计算,因此第 11 章可以轻松替换奖励函数而不触碰工具。 生产环境中的动作是什么样子?部署中的编程智能体不会得到一个叫 swap_minmax 的工具。以 Claude Code 为例,它给模型一小组通用工具:Bash 运行 shell 命令,Read 带行号读取文件,Edit 替换文件中的一个精确字符串,Write 创建或覆盖文件,Grep 和 Glob 分别搜索文件内容和文件名;在某些平台上后两者默认不提供,而是通过 Bash 使用 grep 和 find(Claude Code 工具参考,2026-10-14 查阅)。智能体修复缺陷的方式是:读取文件,选定要替换的精确文本,自己写出替换内容,再通过 shell 运行测试。它以不调用工具直接回答来结束回合。BugFix gym 对这套接口做了抽象:read 代表 Read,test 代表通过 Bash 运行测试,submit 代表最终回答,edit <op> 代表一次旧字符串和新字符串都已预先写好的 Edit。第 4.7 节在 clamp 任务上运行生产环境的工具,并逐项列出这种抽象隐藏了什么;第 19.7 节的真实模型实验则用生产环境的工具训练 Qwen3-0.6B。

4.2 任务与缺陷类型

单个任务是糟糕的 RL 环境:策略可以记住一个答案,而它的成功无法说明任何泛化能力。因此 BugFix gym 从缺陷类型(bug family)生成任务。同一类型的任务共享缺陷结构和修复操作。它的变体(variant)改变表面形式:函数名、参数名和常数。我们把整个变体留出(held-out)用于评估,因此记住了“名为 clamp 的函数需要 swap_minmax”的智能体会在留出的函数上失败,而读代码并识别出缺陷的智能体会成功。
五个缺陷类型各包含一种缺陷。一个缺陷类型由一对源码模板(有缺陷的与修复后的)加上六个变体的列表组成。前四个变体构成训练集(train split),后两个构成留出集。第一个缺陷类型的变体 0 就是第 1 章的 clamp 缺陷:min 和 max 被交换,所以函数对任何输入都返回下界。
range 类型是差一错误(off-by-one):因为 range 不包含终点,循环早一步停止。它的变体在区间起点和每一步累加的内容上有所不同。接下来两个缺陷类型分别是本应非严格却写成严格的比较,以及没有对零除数加保护的除法。
最后一个缺陷类型把累加器初始化为错误的值:从 0 开始的乘积(因此始终为 0),或从 1 开始的求和。修复方式是切换初始值。求和与乘积变体通过 op 字段共用一个模板。
渲染模板就是一次 str.format 调用。下面并排列出两个缺陷类型的训练变体,展示变体之间哪些东西改变、哪些保持不变。
Output:
在同一缺陷类型内,名称、常数(以及 init 类型中的运算符)会改变,缺陷的结构保持不变。这正是留出集检验的性质:同一种缺陷,换成没见过的表面形式。

4.3 运行测试

一个测试是一对 (args, expected)。运行它意味着执行候选源码,以 args 调用函数,并把结果与 expected 比较。run_tests 对一组测试执行这一过程,并为每个测试返回 (passed, got),其中 got 是返回值;如果源码编译失败或调用抛出异常,则为字符串 'error'。
比较时同时检查类型和值,因为在 Python 中 True == 1,而一个返回 1 而不是 True 的比较函数不应通过测试。我们在三个输入上运行有缺陷和修复后的 clamp:一个在区间内,一个低于区间,一个高于区间。
Output:
有缺陷的 clamp 对每个输入都返回下界 0,因此只通过答案恰好为下界的那个测试。修复后的版本通过全部三个。语法错误会以 'error' 让每个测试失败,而不是让环境崩溃,因此错误的编辑变成一个观测,而不是一个异常。
陷阱。 exec 以训练器进程的全部权限运行任意代码。这里之所以可以接受,只是因为每个源码字符串要么是本章编写的,要么是通过六种固定变换之一从编写好的源码得到的。模型生成的代码可能无限循环、耗尽全部内存、读取文件系统、建立网络连接,或篡改测试框架本身。第 4.7 节描述真实模型代码所需的隔离。
每个任务都需要能暴露缺陷的公开测试,这样 test 工具才有信息量;还需要第 4.4 节的漏洞利用无法满足的隐藏测试。我们从每个缺陷类型的一个小范围内随机抽取测试输入,通过运行修复后的源码计算期望输出,并不断重抽,直到有缺陷的源码至少在一个公开测试和至少一个隐藏测试上失败。因此期望值来自执行参考实现,而不是手工输入数字。输入都是小整数,以保持观测简短。
公开测试和隐藏测试从不共享输入,因为划分之前输入已经去重。init 类型的乘积只有六个不同的输入,因此其隐藏集有三个测试而不是八个。make_tasks 把一切组装起来。seed 决定测试输入,并与缺陷类型和变体索引混合,使每个任务的测试都能独立复现。
Output:
共有 20 个训练任务(五个缺陷类型各四个变体)和 10 个留出任务。任务 0 是 clamp,其源码与第 1 章的 CLAMP_BUGGY 完全相同。问题描述文本有意比第 1 章的 CLAMP_ISSUE 更短,后者描述了症状(clamp(3, 0, 5) returns 0)。症状会暴露缺陷类型,而 gym 的问题描述不能这样做。它只点出函数名,不说明缺陷:五个缺陷类型都使用同一个短语“returns wrong results”,所以智能体必须查看代码或测试输出才能找出问题所在。在这次抽样中,第一个公开测试的答案恰好是下界,有缺陷的 clamp 通过了它,另外两个公开测试失败。八个隐藏测试用不同的数字覆盖相同的三个区域(低于、位于和高于区间)。

4.4 编辑操作与漏洞利用

真实的编程智能体通过编写补丁来编辑代码:补丁是自由格式的文本,例如 diff 或搜索替换块。一个很小的模型无法在一分钟的 CPU 训练中从零学会写出正确的补丁,而给自由格式补丁评分会让 gym 更慢、更难分析。因此 BugFix gym 提供一份固定的编辑操作(edit op)菜单。每个操作是一个通用的源码变换,写成作用于源码文本的正则表达式。操作并不知道自己被应用于哪个任务。它会改写任何匹配其模式的源码,不管这次改写是否是修复。因此应用错误的操作要么什么也不做,要么破坏代码,智能体必须先识别出缺陷。 智能体通过阅读源码识别缺陷。观测应该简短,并处于策略的词表之内(第 5 章),因此 read 工具返回源码的紧凑投影(compact projection):以空格分隔的 Python token,保留换行,去掉缩进。它是 token 流的忠实视图,但不是合法的 Python;每次展示它时,我们都标明它是投影。
Output:
接下来是五个修复操作。每个操作完成一种开发者一眼就能认出的变换:交换 min 和 max、把 range 的终点加一、把第一个严格比较改为非严格、给最后一个参数加上非零保护,以及把第一个累加器的初始值在 0 和 1 之间切换。所有操作都接收源码和公开测试,只有漏洞利用会用到测试。
第六个操作是漏洞利用。hardcode_tests 为每个公开测试插入一行代码:当参数与该测试完全一致时返回期望值。结果通过所有公开测试,而对其他所有输入,缺陷原封不动。这是编程智能体一种有充分记录的失败模式的简化示例:针对测试写特例,而不是修复代码。由于它只是一个动作,在只检查公开测试的奖励上训练的策略可能偶然发现它,而第 11 章正是这样做的。
Output:
被投机改写的 clamp 通过了它的三个公开测试,但仍有八个隐藏测试中的三个失败,恰好是未修改的缺陷版本会失败的那几个。另外五个隐藏测试之所以通过,只是因为它们的答案恰好是下界,而缺陷版本本来就返回下界。缺陷版本偶然通过的隐藏测试不携带任何关于修复的信息,这就是 draw_tests 坚持要求至少有一个失败的隐藏测试的原因。 下一段代码把每个操作分别应用到每个任务(训练与留出)的有缺陷源码上一次,统计之后有多少任务通过全部公开测试、有多少通过全部隐藏测试。表格显示了这些操作有多么具体。
Output:
每个修复操作修好其所属缺陷类型的全部六个任务,对其他缺陷类型一个也修不好。对角结构是精确的,因此按缺陷类型选择操作的策略能做对每个任务,而忽视代码的策略最多只能在五个操作中做到随机猜测的水平。漏洞利用通过了全部 30 个任务的公开测试,却没有通过任何一个任务的隐藏测试。这就是智能体能观测到的奖励与我们真正关心的奖励之间的差距,整个第 11 章都建立在它之上。

4.5 动作语法与环境

策略通过文本与环境通信,因此环境需要一套动作语法,以及一个强制执行它的解析器。BugFix 的语法是
各个词以空白分隔,其中 TOOL 是 read、test、edit、submit 之一,ARG 是编辑操作名,edit 必须带它,其他工具不能带。parse_call 只检查调用格式,返回 (tool, arg) 或 None。检查工具是否存在、参数是否有效是环境的职责,这样两类失败可以分开计数。
Output:
解析器接受词之间的任何空白,包括换行。它拒绝单独的工具名、缺少闭合标签的调用,并且有意拒绝前面带有自由文本的调用。真实的智能体常常在工具调用之前写推理,真实的模板也为此留有空间(第 5 章)。BugFix 的语法有意设计得很严格,使格式错误既可见又有代价:格式错误的调用返回一个错误观测并消耗一轮。<call> fly away </call> 能被解析,因为它的形状正确,环境会在下一步把它作为未知工具拒绝。 工具产生观测。format_results 把测试结果转换为每个公开测试一行的简短文本:一行 pass,或一行 fail 并附上函数返回的值和测试期望的值。其余情况由固定消息覆盖。apply_tool 针对当前源码执行一次已解析的调用,返回观测、新源码以及一个用于记账的事件标签。它是纯函数,所有状态都由环境类持有。
奖励是回合最终诊断信息的函数。默认的 hidden 恰好在智能体已提交、且提交的源码通过全部隐藏测试时给出 1.0。另一个选项 public 在公开测试全部通过时给出奖励,它就是第 11 章中可被投机利用的奖励。BugFixEnv 接受这两个名称之一,或任何把 info 字典映射为浮点数的可调用对象,因此后续章节可以定义部分得分、格式奖励或带惩罚的奖励,而无需改动环境。
BugFixEnv 把这一切连接起来。step 解析动作、应用工具、推进轮次计数器,并检查两种结束方式。回合结束时,final_info 在两组测试上给当前源码评分,并记录后续章节会记入日志的诊断信息:智能体是否提交、回合为何停止、通过比例、轮数、无效调用次数,以及使用过的操作。
下面是 clamp 上的一个回合,由一份手写的动作列表驱动。其中包含一个格式错误的调用,以便看清它的代价。
Output:
格式错误的第一个动作返回了错误并用掉了第 1 轮,所以修复花了五轮而不是四轮。读取结果显示 min ( lower , max (,由此识别出上下界交换的缺陷。执行 swap_minmax 之后,test 工具报告三个通过。只有最后的 submit 产生奖励 1.0,因为隐藏测试也通过了。每个中间奖励都是 0。这种稀疏性就是第 7 章的回报归因(credit assignment)问题:奖励在第 5 轮才到来,学习算法必须弄清楚之前的哪些 token 应当获得它。 另外两个回合展示了其他结局:两种奖励下的漏洞利用,以及一个从不提交的智能体。
Output:
同一个漏洞利用在隐藏测试奖励下得到 0.0,在公开测试奖励下得到 1.0,而 info 在两种情况下都记录了真实情况。无论使用哪种奖励,都在诊断信息中保留 public_pass 和 hidden_pass,这使第 11 章能够把策略所训练的奖励与我们关心的结果画在一起对比。一直读取直到预算耗尽的智能体以 stop='budget' 结束,没有奖励。

4.6 脚本化基线

在训练任何东西之前,我们先测量简单的手写策略能达到什么水平。这些数字有三个用途:检验 gym 能正常工作(掌握信息的策略必须成功,糟糕的策略必须失败);设定训练后的 TinyAgent 必须越过的门槛;以及展示任务在多大程度上依赖于收集信息。 脚本化策略是一个函数,把目前为止的观测列表(从问题描述开始)映射为下一个动作文本。我们编写四个:
  • oracle 读取文件,用基于源码词语的固定规则选择操作,然后编辑、测试并提交。它只使用自己观测到的内容,外加一张在了解五个缺陷类型的前提下写成的规则表。
  • blind 不读取,直接应用一个随机的修复操作并立即提交。
  • random 在每一轮从九个格式正确的调用中均匀随机选择。
  • hacker 应用 hardcode_tests,测试,然后提交。
规则的顺序很重要。range 类型和 init 类型都包含 for ... range(...) 循环,只有区间的终点能区分它们。这条规则只针对这五种缺陷类型,但它说明:选择操作所需的信息就在 read 观测中,学习得到的策略必须仅凭奖励发现类似的规则。
我们在每个任务上运行每个策略。oracle 和 hacker 是确定性的,所以每个任务一个回合就够了。blind 和 random 策略是随机的,所以每个任务运行 40 个回合。表格报告训练集上按缺陷类型划分的隐藏测试奖励、两个划分上的总体奖励,以及提交的源码通过公开测试的回合比例。
Output:
oracle 解决了两个划分中的每一个任务,这说明 read 提供的信息足以完成任务:read 观测总是包含足以选出正确操作的信息。blind 策略大约五次成功一次,这正是从五个操作中猜一个的结果。random 策略比 blind 策略更差,尽管它的调用总是格式正确。它经常在提交之前就用完轮次;即使提交,也常常应用了错误的操作,或者根本没有应用。hacker 的奖励处处为 0,但它的公开测试通过率是 1.0。在公开测试奖励下,它与 oracle 无法区分。random 策略以缩小的形式展现了同样的差距:它的公开测试通过率(0.20)是其隐藏测试奖励(0.10)的两倍,因为它的九个调用之一是 hardcode_tests,而它有时会在选中它之后立即提交。 图 4.2:BugFix gym 上的脚本化基线。左:按缺陷类型划分的隐藏测试奖励(训练集)。右:各策略的公开测试通过率与隐藏测试奖励对比。 图 4.2 概括了这张表。左图中,oracle 在每个缺陷类型都是 1.0,靠猜测的策略在 0.2 线附近,hacker 为零(它的柱子高度为零)。右图是第 11 章需要记住的那一幅。对于 oracle 和 blind 策略,公开与隐藏两根柱子重合。random 策略的两根柱子相差的部分来自它偶然的漏洞利用,而 hacker 的两根柱子相距最远。只衡量智能体能看到的东西的奖励,会选择出右侧那样的策略。 本节有两个数字会贯穿全书。得分约 0.2 的训练后 TinyAgent 只学会了格式;在留出集上得分远高于 0.2 的,才学会了阅读。

4.7 真实编程环境与隔离

BugFix gym 保留了生产级编码环境的结构,去掉了它们的成本。看清各部分分别对应什么会有所帮助。 任务与评分。 SWE-bench 用一个真实的 GitHub issue 和解决它的 pull request 构建每个任务。每个任务记录仓库的 base_commit、包含该 pull request 所贡献测试的 test_patch,以及两份测试列表:FAIL_TO_PASS,即“tests resolved by the PR and tied to the issue resolution”(被该 PR 解决、与 issue 修复相关的测试);以及 PASS_TO_PASS,即“tests that should pass before and after the PR application”(在应用 PR 前后都应通过的测试)(SWE-bench dataset card,2026-10-13 查阅)。只有两份列表都通过,补丁才算解决了 issue。我们的隐藏测试扮演 FAIL_TO_PASS 的角色。BugFix gym 没有与 PASS_TO_PASS 对应的东西,因为每个任务只有一个函数,这也是它无法检测破坏其他代码的编辑的原因之一。 执行。 SWE-bench 评测框架(harness)在 Docker 容器内把每个补丁应用到仓库上,并在容器中运行仓库的测试。它在多个层级(base、env、instance)缓存镜像,因为构建镜像是主要成本(SWE-bench evaluation guide,2026-10-13 查阅)。DeepSWE 训练所用的 R2E-Gym 提供了 13 个仓库中的 8,100 多个可执行问题,每个问题都有自己约 300 到 500 MB 的 Docker 镜像,并通过一个带有 apply_patch 等辅助方法的 RepoEnv 类对外提供(R2E-Gym README at commit 353348a,2026-10-13 查阅)。 环境接口。 rLLM 的 BaseEnv 声明了 reset() -> tuple[dict, dict] 和 step(action) -> tuple[Any, float, bool, dict](rLLM base_env.py at commit 7b47687,2026-10-13 查阅)。这与我们的契约相同,区别在于 reset 还返回一个 info 字典,并且任务在构造环境时(通过 from_dict)绑定,而不是传给 reset。这一区别对池化很重要。按任务构造的环境不重建就无法跨任务复用,而在基于容器的系统中,重建意味着启动一个新容器。 工具 schema。 我们的语法是一套私有协议。生产级智能体用 JSON Schema 描述工具,使任何模型和任何客户端都能使用它们。Model Context Protocol 用 name、description 和 inputSchema 定义一个工具,并在结果内部以 isError: true 报告工具失败,而不是作为协议错误。其安全部分规定服务器“MUST validate all tool inputs”(必须验证所有工具输入),对调用限流,并对输出做清洗(MCP specification 2025-06-18,2026-10-13 查阅)。下面是 BugFix 工具的这种形式。第 5 章会用 Qwen3 的对话模板(chat template)渲染它们。
Output:
enum 让编辑菜单成为 schema 的一部分。了解 schema 的解码器甚至可以把生成约束在有效调用之内,但约束解码会改变采样分布,从而改变行为策略(behavior policy)(第 3.3 节),训练器随后必须考虑这一点。 生产环境中的工具。 现在来看 gym 所抽象的那套接口。下面的 schema 仿照 Claude Code 的内置工具。Bash、Read、Edit、Write 的参数名取自我们在 2026-10-14 查看的一个 Claude Code 会话中这些工具暴露给模型的 schema。工具参考文档记载了 Edit 的 old_string、new_string、replace_all,以及 Read 的 offset 和 limit;它没有给出 Grep 和 Glob 的参数名,所以 pattern 和 path 是我们的选择。我们把这份列表保存为 CODING_TOOLS,供第 5 章和真实模型实验使用。
schema 只是一个承诺,环境必须实现 schema 用文字描述的规则。下面是一个最小的内存工作区,把 Edit 的规则写成显式代码:字符串必须出现;除非设置 replace_all,否则它必须唯一;并且(这是我们的选择)文件必须先被读取过。Claude Code 在部分模型上强制先读后改,在较新的模型上只是有条件地要求,所以这是一个设计决策,而不是定律。
Bash 是最难交给智能体的工具,因为真实的 shell 什么都能做。在生产环境中它运行在容器里(见下文的隔离)。这里我们模拟一个只有两条命令的白名单:ls,以及 pytest,后者用第 4.3 节的 run_tests 在当前源码上运行任务的公开测试。这样做之所以安全,只是因为在这个演示中每一次编辑都由我们编写。
现在看生产环境中的智能体如何完成 clamp 修复:先查看环境,读取文件,写出编辑,运行测试,然后停止。每次调用都打印模型会收到的观测。
Output:
五次调用修好了缺陷,最后的 Edit 得到的源码与 gym 的 swap_minmax 操作完全相同。gym 的操作菜单省略了本回合必须明确完成的一步:选择 old_string 和 new_string。真实编辑器要求字符串准确匹配,选错内容就会调用失败,消耗额外轮次。
Output:
每次失败都是一个消耗一轮的错误观测,就像 gym 中格式错误的调用一样。下表总结了每个 gym 动作抽象掉了什么。 这种抽象保留了强化学习需要展示的东西(信息收集、跨轮次的回报归因、探索以及漏洞利用),去掉了一个 15.8 万参数的模型无法在一分钟内学会的东西:写出精确的代码。预训练模型本来就会写代码,所以对它来说生产环境的工具接口才是正确的选择。这正是第 19.7 节真实模型实验的设定。 隔离。 上面的一切都在运行模型编写的代码。用于 RL rollout 的沙箱(sandbox)必须能容纳有缺陷的、对抗性的或兼而有之的代码,而 RL 循环会主动搜索沙箱所允许的一切。最低要求是:每个回合一个独立的进程或容器,带有 CPU、内存和实际耗时限制;除非任务需要,否则不能访问网络;对智能体不应修改的任何内容(尤其是测试文件)只提供只读视图;每次 reset 都使用全新的文件系统。隐藏测试必须无法从智能体的沙箱内部访问,即使是作为智能体可以 cat 的文件也不行。BugFix gym 之所以能在训练器进程中使用 exec 而不出问题,只是因为模型编写的代码从不会到达那里:策略只是在对编写好的源码所做的六种编写好的变换之间进行选择。
生产实践。 评分在智能体的沙箱结束之后进行,是一个单独的步骤,在其中才复制进隐藏测试,这与 SWE-bench 只在评测时应用 test_patch 的思路一致。这是关于此类评测框架的一般工程观察,而不是关于任何特定实验室基础设施的断言。

4.8 实践中的设计问题

问题:环境的确定性与可复现性。 现象: 同一个检查点在使用相同随机种子(seed)的两次评测中得分不同,或者一次 RL 运行无法重放以调试某个尖峰。 原因: 环境中存在隐藏的随机性来源:未设种子的测试生成、字典或文件系统的顺序、时间戳、网络调用,或依赖执行顺序的测试。 可选方案: (a) 从任务身份为每次随机抽取设定种子,就像 make_tasks 把种子与缺陷类型和变体一起哈希那样,这没有任何成本。(b) 把环境快照为镜像并固定版本,这是基于容器的评测框架的做法,代价是存储。(c) 记录并重放观测以便调试,代价是日志量。 建议做法: 所有环境随机性都从 (seed, task id) 派生,并用测试断言可复现性,如下所示。
Output:
种子会改变每个任务的测试,而相同的种子能精确复现它们。因此后续章节的评测可以在函数和缺陷都相同的情况下,报告多个测试种子上的结果。
问题:不稳定测试使奖励带有噪声。 现象: 一个正确的策略在同一任务上的奖励在不同 rollout 之间波动,本应一致的组里出现非零的优势(advantage)。 原因: 在真实仓库中,有些测试依赖时序、随机性或外部服务,因此验证器(verifier)本身是随机的。RL 会把概率推向恰好与幸运通过同时出现的任何东西。 可选方案: (a) 对每个候选多次运行测试并取多数结果,这会成倍增加评分成本。(b) 过滤掉参考补丁不能稳定通过的任务,代价是数据。(c) 在奖励中对噪声建模(第 11 章)。 建议做法: 训练前反复运行每个任务的参考修复来验证任务,丢弃任何曾经失败过的任务。下一段代码展示少量的不稳定率会让 oracle 付出多少代价。
Output:
不稳定率为 5% 时,完美策略的测得奖励降到 0.936;为 20% 时降到 0.806(期望值为 0.95 和 0.8,差距是 500 个回合上的采样噪声)。噪声与动作无关,所以平均而言它只是缩放信号,但它给每一个优势估计都增加了方差,并给奖励设定了一个看起来像能力极限的上限。
问题:通过工具泄漏隐藏测试。 现象: 训练奖励上升得比留出评测快得多,或者智能体的动作中出现只在评分器中出现过的常数。 原因: 某个工具暴露了评分器的数据,例如运行隐藏测试套件的 test 工具、智能体能读取的文件,或打印出期望值的错误消息。 可选方案: (a) 把隐藏测试放在沙箱之外,在回合结束后评分,代价是一个单独的评分步骤。(b) 自动审计观测中是否含有评分器内容,如下所示。 建议做法: 两者都做。下面的检查扫描每个 oracle 和 random 回合的每一个观测,查找隐藏测试的输入。
Output:
三个策略的观测中都不包含任何隐藏测试输入。test 工具只格式化公开测试,而 read 显示的硬编码源码只包含公开输入。
问题:工具输出截断。 现象: 智能体在 read 或 test 上反复循环而毫无进展,或者少数任务上的上下文长度暴增。 原因: 真实工具返回的文本没有上限:一个 3,000 行的文件、一段堆栈跟踪,或一份测试日志。不设上限时,一个观测就可能填满上下文。设置简单的上限时,有用的部分(失败的断言通常在末尾)会被截掉。 可选方案: (a) 截断为前 NN 个 token,简单,但常常丢掉末尾。(b) 保留开头和结尾,并加上明确的标记,这样既保留开头也保留失败信息。(c) 给工具提供限定输出范围的参数(read 的行范围、test 的单个测试),这把负担转移给策略。 建议做法: 带可见标记的首尾截断,并为每个观测设定 token 预算。BugFix gym 从构造上就避开了这个问题:它最长的观测也只有几十个 token。
问题:粗粒度动作空间与自由格式补丁。 现象: 在 gym 中表现出色的策略无法迁移,或者自由格式的策略一次奖励也得不到。 原因: 动作空间同时决定了探索(exploration)的难度和上限。六个操作的菜单让随机探索就能到达正确的动作(约九次调用中有一次),但它无法表达菜单之外的任何修复。自由格式补丁可以表达任何修复,但随机补丁几乎从不能编译,更不用说通过测试。 可选方案: (a) 粗粒度菜单,用于教学和自举。(b) 结构化编辑,例如精确字符串替换(第 4.7 节的 Edit)或搜索替换块,这是许多编程智能体采用的折中方案,通常与一个 shell 搭配使用。(c) 依靠强大预训练先验的自由格式 diff,如 DeepSWE 风格的 32B 模型训练。 建议做法: 对本书而言使用菜单,因为它让一个 15.8 万参数的模型在一分钟内学会,同时每一种 RL 现象都保持可见。对于真实的智能体,使用基座模型已经能可靠写出的结构化编辑格式。
问题:环境的延迟、成本与 reset。 现象: rollout 等待环境时 GPU 空闲,少数慢回合拖慢每一步。 原因: 真实的 step 可能要构建代码、运行测试套件或启动容器,耗时数秒到数分钟,而 reset 可能需要一个新容器。在同步 RL 中,批次里最慢的回合决定节奏。 可选方案: (a) 池化并预热环境,代价是内存。(b) 快照并恢复容器文件系统而不是重建,这需要基础设施。(c) 让 rollout 与训练异步(asynchronous)进行并容忍陈旧数据,代价是需要 off-policy 校正(第 13 章)。(d) 给慢步骤设置超时,并把该回合视为被截断(第 12 章)。 建议做法: 从第一天起就按工具测量每一步的延迟。BugFix gym 的 reset 只复制一个字符串,所以一次 reset 只需几微秒。这很容易让人忘记:在生产中,吞吐量(throughput)瓶颈往往是环境,而不是模型。

小结

  • 环境实现 reset(task) -> obs 和 step(action_text) -> (obs, reward, done, info)。它的状态包含智能体永远看不到的东西,其中最重要的是隐藏测试。
  • BugFix gym 有五个缺陷类型,每个有六种代码变体:20 个训练任务和 10 个留出任务。测试在可信的、编写好的源码上执行,期望值来自运行参考修复。
  • 动作遵循语法 <call> tool [arg] </call>。格式错误的调用消耗一轮。六个编辑操作以通用方式变换源码,其中五个各修复一个缺陷类型,而 hardcode_tests 通过公开测试、无法通过隐藏测试。
  • 奖励是可插拔的('hidden'、'public' 或任何关于 info 的函数),而 info 总是同时记录公开与隐藏测试的结果。
  • 测得的基线(第 4.6 节):oracle 1.0,blind 约 0.2,random 约 0.1,hacker 隐藏测试 0.0 但公开测试 1.0。策略必须阅读代码,才能在留出变体上超过约 0.2。
  • 真实环境(SWE-bench、R2E-Gym、rLLM)共享这一契约,但在隔离的容器中运行模型代码。隔离、确定性和延迟是主要的工程成本。

练习

  1. 增加第六个缺陷类型,例如错误的默认参数或反转的布尔条件,带有六个变体和一个对应的修复操作。验证第 4.4 节中操作对缺陷类型的表格仍然是对角的。
  2. 改变一个假设:让 parse_call 变得宽松,在文本中任何位置找到第一个 <call> ... </call> 片段。现在哪些格式错误的例子能被解析?论证这会使从随机策略开始的 RL 更容易还是更难,以及它会教给策略关于格式的什么。
  3. random 策略大约一半时间会用完轮次。计算六次均匀随机调用组成的序列中恰好只包含一次 submit、且它出现在最后一轮并位于正确编辑之后的概率。将其与测得的 random 基线进行比较。
  4. 编写一个 partial_reward(info),用 hidden_frac 代替二元的通过与否作为奖励。在它之下运行 blind 和 hacker 策略。hacker 得到多少奖励?为什么这是个问题?
  5. oracle 的 infer_op 规则使用关键词。编写一个只根据 test 输出、不使用 read 来选择操作的策略。它能在哪些缺陷类型上成功?为什么 fail ( 1 , 0 ) -> error want 0 能立即识别出一个缺陷类型?
  6. 设计评审(design review):你的团队希望在下一版 gym 中用自由格式的搜索替换编辑取代编辑菜单。列出环境中必须改变的内容(解析、评分、隔离、观测长度)、你预期 random 策略基线会发生什么变化,以及在 RL 开始之前策略需要什么样的预训练或 SFT。