> ## Documentation Index
> Fetch the complete documentation index at: https://agent-rl.tacetai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 真实模型实验：用 Qwen3-0.6B 在 BugFix 任务上运行 GRPO

两个可选实验在一个真实的预训练模型上运行[第 19 章](/tutorial/zh/chapters/19-capstone)的训练方案。它们共享任务、隐藏测试奖励、组优势、裁剪损失以及 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`。在仓库根目录下运行：

```bash theme={null}
python3 scripts/build_library.py                      # generates agentrl/ from the chapters
HF_HUB_OFFLINE=1 python tutorial/labs/qwen_grpo_bugfix.py --steps 30 --lr 1e-6 --eval-n 8 \
    --out tutorial/labs/results/qwen_grpo_bugfix.json
```

可用选项有 `--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`。

| | 留出 pass\@1（10 个任务 × 8） | 每回合格式错误调用数 | 提交率 |
| - | - | - | - |
| 训练前 | 0.200 \[0.062, 0.388] | 1.69 | 0.61 |
| 30 步之后 | 0.800 \[0.500, 1.000] | 0.00 | 1.00 |

区间是任务级自助法（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 的[工具参考文档](https://code.claude.com/docs/en/tools-reference)（2026-10-14 查阅）。工具描述是我们自己写的简短版本，脚本不会向模型提及 Claude Code。

### 接口

每个回合都有一个由某个 `arl.make_tasks` 任务构建的内存仓库。任务先用 `arl.randomize_names` 换成一个无关的 gym 函数名，因为这是本书默认的强化学习设置（9.4 节）：函数名不携带任何关于缺陷的提示。仓库包含两个文件：有缺陷的源文件 `<fn>.py`，以及每个公开测试对应一个 pytest 函数的 `test_<fn>.py`。隐藏测试从不进入仓库。系统提示说明仓库位于 `/repo`，并说明不含工具调用的回复会结束会话。用户消息给出函数名、它所在的文件以及测试文件。

| 工具 | 参数 | 行为 |
| - | - | - |
| `Read` | `file_path`、`offset`、`limit` | 以 `cat -n` 格式返回文件：行号、一个制表符，然后是该行。把文件标记为已读。 |
| `Edit` | `file_path`、`old_string`、`new_string`、`replace_all` | 精确字符串替换。如果本回合尚未读过该文件、`old_string` 不存在、`old_string` 出现多次且 `replace_all` 为 false，或者两个字符串相同，就会失败。成功时返回带行号的编辑后文件。 |
| `Write` | `file_path`、`content` | 在仓库根目录创建或覆盖文件。覆盖已有文件需要先 Read。 |
| `Bash` | `command`、`timeout` | 白名单模拟，不是 shell：`pytest [file]` 和 `python -m pytest [file]` 运行公开测试，`python test_<fn>.py` 运行该测试文件中的测试，`python <file>.py` 运行脚本，`ls`、`cat <file>` 和 `grep [-n] [-i] <pattern> [file]` 读取内存仓库。接受 `cd /repo` 和 `&&` 连接。其他任何命令都返回 `command not available in this sandbox`。 |

要求在 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`。在仓库根目录下运行：

```bash theme={null}
python3 scripts/build_library.py
HF_HUB_OFFLINE=1 python tutorial/labs/qwen_grpo_tools.py --steps 18 --lr 1e-6 --eval-n 8 \
    --out tutorial/labs/results/qwen_grpo_tools.json
```

可用选项与编辑菜单实验相同，另外还有 `--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`。

| | 留出 pass\@1（10 个任务 × 8） | 每回合格式错误调用 | 每回合失败调用 | 每回合 Bash 调用 | 以回答结束的比例 |
| - | - | - | - | - | - |
| 训练前 | 0.100 \[0.025, 0.212] | 0.09 | 0.85 | 0.07 | 0.99 |
| 18 步之后 | 0.488 \[0.237, 0.750] | 0.00 | 0.21 | 0.00 | 1.00 |

区间是任务级自助法区间。配对差值为 +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`。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.