跳转到主要内容
两个可选实验在一个真实的预训练模型上运行第 19 章的训练方案。它们共享任务、隐藏测试奖励、组优势、裁剪损失以及 token-in/token-out 的 rollout,区别在于模型如何行动:
  • qwen_grpo_bugfix.py 保留 gym 的动作菜单:模型用某个预先编写的编辑操作调用 edit,由 gym 写出修改。
  • qwen_grpo_tools.py 给模型以 Claude Code 工具命名的通用编程工具(Read、Edit、Write、Bash),由模型自己写出修改。
我们保留了两个脚本,而没有重写第一个。第 19 章比较它们的结果,而每个结果文件都必须能由产生它的脚本复现。

编辑菜单实验

qwen_grpo_bugfix.py 从本书生成的 agentrl 库中取得 gym、任务、隐藏测试奖励、组优势和裁剪损失,只补充了真实模型所需要的部分:
  • Qwen3 自带的、包含工具 schema 的对话模板,使用非思考模式。模型输出 <tool_call>{"name": ..., "arguments": {...}}</tool_call>;一个解析器把这段 JSON 映射为 gym 的 <call> tool [op] </call>,其他任何输出都算作格式错误的调用,并消耗一轮。
  • token-in/token-out 的 rollout(第 5 章)。采样得到的 id 按采样原样保留。每个工具结果以模板中工具响应块的 token id 形式追加;脚本启动时会断言这些 id 与模板对同一段对话的渲染结果完全相等。
  • 批量化的 Hugging Face generate,温度为 1,关闭 top-k 和 top-p,使采样器的分布正是训练器求导的那个分布。未使用 vLLM。
  • 显存友好的更新。 LM head 只在策略位置上计算,梯度在多个微批次上累积,每个小批次使用同一个 token 均值归一化因子(第 14 章)。优势为零的行在反向传播中被跳过,但它们的 token 仍计入归一化因子,因此结果是精确的。
工具调用只在进程内执行 gym 中预先编写好的编辑操作。模型从不编写会被执行的代码。运行模型所写代码的系统需要第 4 章所述的隔离;下面的工具实验迈出了第一步。

如何运行

你需要一块 CUDA GPU(实测运行的显存峰值为 32.8 GB)、一个已经包含 Qwen/Qwen3-0.6B 的本地 Hugging Face 缓存(不会下载任何内容),以及 torch 和 transformers。在仓库根目录下运行:
可用选项有 --steps、--group-size(G,默认 8)、--prompts(每步的任务数,默认 8)、--lr、每轮的 --max-new-tokens(默认 64)、--micro(微批次行数,默认 8)、--eval-n(训练前后每个任务的留出回合数)以及 --seed。其余设置都取自 agentrl.RLConfig():函数重命名、每回合一次编辑、最多 6 轮、隐藏测试奖励、adv_mode='grpo'、对称裁剪 0.2、1 个轮次(epoch)、2 个小批次、无 KL 项,以及梯度范数裁剪 1.0。优化器为不带权重衰减的 AdamW,模型以 fp32 运行。 本书的检查器不执行这个脚本。第 19 章读取它的结果文件。

测得了什么

只有一次运行,于 2026-10-14 在项目共享的 H200 主机上(torch 2.9.1,transformers 5.5.3)使用上面的命令完成。结果保存在 results/qwen_grpo_bugfix.json(逐步指标、训练前后的评测及逐任务计数、解码后的示例轮次以及配置),控制台日志保存在 results/qwen_grpo_bugfix.log。 区间是任务级自助法(bootstrap)区间。配对差值为 +0.600 [+0.325, +0.875]。总实际耗时为 9.5 分钟。 如何解读:提升主要来自模型学会了 gym 的工具调用格式以及 read、edit、test、submit 的流程。策略的熵在第 15 步降到 0.000,此后大多数组的优势都为零。最终策略是确定性的:它每次都能解决 10 个留出任务中的 8 个,而两个 range 任务每次都失败,因为它对它们使用了 fix_init。这只是一个随机种子、未做任何调参的结果,因此它演示的是机制,而不是基准测试结果。在同一主机上重跑一次,前几步采样得到的奖励完全复现,而采样器与训练器之间的对数概率差在第二位有效数字上有所不同,这表明 GPU 内核存在非确定性。

工具实验

编辑菜单不是编程智能体的行动方式。qwen_grpo_tools.py 用 Claude Code 的工具接口取代它。工具名和参数遵循 Claude Code 的工具参考文档(2026-10-14 查阅)。工具描述是我们自己写的简短版本,脚本不会向模型提及 Claude Code。

接口

每个回合都有一个由某个 arl.make_tasks 任务构建的内存仓库。任务先用 arl.randomize_names 换成一个无关的 gym 函数名,因为这是本书默认的强化学习设置(9.4 节):函数名不携带任何关于缺陷的提示。仓库包含两个文件:有缺陷的源文件 <fn>.py,以及每个公开测试对应一个 pytest 函数的 test_<fn>.py。隐藏测试从不进入仓库。系统提示说明仓库位于 /repo,并说明不含工具调用的回复会结束会话。用户消息给出函数名、它所在的文件以及测试文件。 要求在 Edit 或覆盖式 Write 之前先 Read 是我们的选择。Claude Code 的参考文档只对部分模型这样要求。Grep 和 Glob 没有包含在内,因为参考文档说它们在 Linux 上默认不存在,Claude Code 在那里通过 Bash 搜索。 模型在一轮中最多可以发出四个工具调用。它们按顺序执行,结果以连续的 <tool_response> 块返回,这正是 Qwen3 模板渲染多条工具消息的方式。JSON 无法解析或与任何工具 schema 不匹配的调用是格式错误的调用。格式正确但被工具拒绝的调用(例如 old_string 不存在的 Edit)是失败的调用。两者都返回一条错误观测,并耗掉这一轮。不含 <tool_call> 的助手轮次是最终回答,会结束回合,与真实智能体一样。否则回合在 6 轮之后结束。两种情况下,只要最终的 <fn>.py 通过全部隐藏测试,奖励就是 1。脚本启动时会断言:对于带四个工具 schema 的提示,以及带一个和两个工具结果的观测,它的 token 路径都与 Qwen3 的模板一致。

执行及其局限

在这个实验中,模型写的代码会被运行,这正是第 4 章所说需要隔离的情形。脚本从不在训练器进程中用 exec 运行模型输出。每次测试运行、每次 python <file>.py 以及隐藏测试评分都经过 sandbox_run:
  • 一个新的临时目录,存放该回合文件的副本,用完即删除(评分器只复制 <fn>.py);
  • python -I -S -B,环境中只有 PATH、HOME(即临时目录)和 LANG,因此不继承任何变量或密钥;
  • 10 秒的实际运行超时(Bash 的 timeout 可以把它调低);
  • 子进程在导入任何仓库代码之前设定的 resource 限制:5 个 CPU 秒、1 GiB 地址空间、每个写出的文件 1 MiB,以及 RLIMIT_NPROC=0,使它无法启动新进程。
训练前在运行主机上检查过工具层:fork 尝试抛出 BlockingIOError,一个 10^10 元素的列表抛出 MemoryError,一个死循环被 CPU 限制终止,子进程的环境中只有上述三个变量。 这是在受控主机上带资源限制的进程隔离,不是安全沙箱。 子进程以同一用户运行,因此可以读取该用户能读取的任何文件,也可以建立网络连接。评分器信任一个运行模型代码的进程的输出,所以恶意模块可以打印伪造的结果行。它必须猜中每次调用随机生成的标记,这提高了门槛,但没有消除风险。生产系统在没有网络访问的容器或虚拟机中运行模型写的代码,并在新的容器中评分(第 4 章)。RLIMIT_NPROC 限制对 root 无效,所以不要以 root 身份运行该脚本。

如何运行

你需要 Linux(用于 resource 限制)、一块 CUDA GPU、一个已经包含 Qwen/Qwen3-0.6B 的本地 Hugging Face 缓存,以及 torch 和 transformers。在仓库根目录下运行:
可用选项与编辑菜单实验相同,另外还有 --max-turns(默认 6)、每轮的 --max-new-tokens(默认 256,因为一次 Edit 必须重复一行代码)、--gen-batch(每次 generate 调用的回合数,默认 16,用于限制 KV cache 显存)以及 --smoke(检查模板和工具,运行 40 个留出回合,打印部分对话记录后退出)。由于回合更长,--micro 的默认值(4)以及梯度检查点的使用与编辑菜单实验不同。脚本用一个 logits 处理器记录采样器的对数概率,它只保留一步的 log-softmax,而不是存下每一步的 logits。RLConfig 的 max_edits 字段会出现在打印的配置中,但在这里不起作用:只要轮次允许,模型可以多次编辑。

测得了什么

只有一次运行,于 2026-10-14 在项目共享的 H200 主机上(torch 2.9.1,transformers 5.5.3)使用上面的命令完成。运行期间另一个任务占用了约 115 GB 的 GPU 显存。结果保存在 results/qwen_grpo_tools.json(逐步指标;训练前后的评测,包括逐任务计数和工具统计;训练前后每个留出任务以及第 0 步每个训练任务各一份完整对话记录;以及配置、工具 schema 和资源限制),控制台日志保存在 results/qwen_grpo_tools.log。 区间是任务级自助法区间。配对差值为 +0.388 [+0.150, +0.650]。逐任务计数从 [1, 0, 0, 0, 2, 1, 0, 0, 4, 0] 变为 [8, 8, 1, 2, 8, 8, 0, 0, 4, 0](依次为 minmax、range、compare、zero 和 init 各两个任务)。总实际耗时为 20.0 分钟,GPU 显存峰值为 14.2 GB。 如何解读:
  • 训练前,失败主要来自工具误用:old_string 不在文件中的 Edit(每回合 0.30 次)、先于 Read 的 Edit(0.20)、两个字符串相同的 Edit(0.25),以及覆盖源文件或测试文件、或创建新文件的 Write(每回合 0.46 次)。在保存的示例中,每个找不到的 old_string 都是编造的文本,而不是空白字符上的细微偏差。回合通常以一句“测试已通过”的声明结束。
  • 训练后,流程是 Read、一个把整行源代码复制为 old_string 的 Edit,然后给出最终回答。格式错误的调用、找不到字符串的 Edit 和未读先改的 Edit 都消失了;唯一剩下的失败是两个字符串相同的 Edit(每回合 0.21 次)。minmax 和 compare 每次都能解决,其中包括一个与 gym 编辑操作不同、但通过了隐藏测试的修复(把 score > cutoff + 1 改为 score > cutoff)。
  • 没有改善的地方:模型不再运行测试(Bash 降到 0),当编辑错误时,它的最终回答仍然声称测试通过。剩下的失败都是错误的编辑:改循环体而不是 range 的终点,用 / 而不是零值保护,设错初始值。
  • 熵从第 4 步的 0.185 降到第 17 步的 0.121,没有坍缩,这与编辑菜单的运行不同;但两次运行的长度不同,动作 token 的含义也不同。
这只是一个随机种子、未做任何调参的结果,因此它演示的是机制,而不是基准测试结果。起点估计带有噪声。更早的一次尝试使用 --gen-batch 32 --max-turns 8,在共享 GPU 上于第 2 步显存不足;它在训练前对同样的任务测得留出 pass@1 为 0.050 [0.000, 0.150]。它的日志保存为 results/qwen_grpo_tools_aborted.log。