接下来我将复现 10 篇强化学习算法:第 3 篇,一杯喜茶,搞定 Search-R1

代码与复现资源

这是"接下来我将复现 10 篇强化学习算法"系列的第三篇。

前面三篇分别讲了:

这一次终于轮到我一直很想做的 Agentic RL:让模型在回答问题的过程中,自己决定什么时候搜索、搜索什么、看到搜索结果后要不要继续搜,最后再给出答案。

先说最让我开心的一件事:这个实验很便宜,少喝一杯奶茶就行了。

我这次截图里的完整训练会话(第一个是 Evaluation,第二个是 Training),PyTRIO 一共花了 13.30 元。北京一杯喜茶大概 17.5~20.5 元,所以标题并没有夸张,我确实用不到一杯喜茶的钱,完成了一次真正的 Search-R1 训练。

更重要的是,学习者不需要把训练跑到 100 或 200 step 才能知道代码有没有效果。

在搜索环境稳定的前提下,运行 20 个 step,就已经能够看到模型的 Format Rate 和回答行为发生变化。

这是我们的 checkpoint 评测结果。后面还会再次展开这张图,现在可以先看 Step 20 和 Step 50: 在这次固定 70 题的评测里,可以在早期的 50 个 step 看到明显提升:

text 复制代码
Base Model:  Macro EM 28.57% · Format 58.57%
RL Step 20:  Macro EM 31.43% · Format 87.14%
RL Step 50:  Macro EM 45.71% · Format 94.29%

这里的 EMExact Match(精确匹配率) 。评测程序会从模型最终输出的 Answer: 中提取答案,统一转成小写、去除标点和英文冠词、合并多余空格,然后与任意一个参考答案比较。两者完全一致记为 1,其余情况记为 0,不会按关键词重合程度给部分分数。

Macro EM 会先分别计算 7 个 benchmark 的 EM,再对 7 项结果做等权平均,避免题量更大的数据集主导总分。本文的固定评测集中,每个 benchmark 都有 10 道题,因此这里的 Macro EM 也等于全部 70 道题的整体正确率。

这篇 Blog 想做的事情很简单:不要求你准备一台 8 卡机器,不要求你先研究几个月 veRL,也不要求你下载一个解压后 160 GB 的检索数据库。我们只保留 Search-R1 最重要的算法结构,用 Qwen3.5-4B、PyTRIO 和一个免费的在线搜索 API(感谢知乎),把完整训练闭环跑起来。

Search-R1 是从哪里来的?

Search-R1 是 Bowen Jin 等人在 2025 年提出的一项工作,全名是:

Search-R1: Training LLMs to Reason and Leverage Search Engines with Reinforcement Learning

它想解决的问题很现实。

传统 RAG 通常是在模型回答前先检索一次:

text 复制代码
问题 → 检索 → 把结果塞进 prompt → 模型回答

这对于简单事实题很好用,但面对多跳问题,搜索通常需要进行多轮。比如:

text 复制代码
《小王子》的作者出生在哪个国家?

模型可能要先搜索:

text 复制代码
The Little Prince author

得到作者是 Antoine de Saint-Exupéry,再搜索:

text 复制代码
Antoine de Saint-Exupéry birthplace country

最后才能回答 France。

搜索引擎能搜到信息只是前提,真正困难的是让模型学会一连串决策:

  • 当前信息够不够?
  • 下一条 query 应该写什么?
  • 搜索结果里哪个实体才是下一跳?
  • 需要继续搜索,还是应该停止并回答?

如果这些决策只写在 prompt 里,模型是在推理时临时照着说明书操作。Search-R1 更进一步:它让模型通过强化学习,真正把"推理与搜索交替进行"学进参数里。

论文里最核心的变化可以概括成一句话:

模型不再把搜索当成回答前的固定步骤,要把它变成模型在整条推理轨迹中可以反复选择的动作。

用人话解释 Search-R1

可以把模型想象成一个参加开卷考试的学生。

普通 RAG 是老师先帮他查好一页资料,再让他作答。

Search-R1 则是把搜索框交给学生:他可以先想一会儿,输入一条 query,阅读结果;如果信息仍然不够,就换一个 query 再查;等他认为证据足够了,再写最终答案。

考试结束后,我们只检查最终答案:

  • 答对了,reward 高;
  • 答错了,reward 低;
  • 连规定的答案格式都没有写出来,再扣一点分。

然后把同一道题的多条轨迹放在一起比较。比组内平均水平好的轨迹被鼓励,比平均水平差的轨迹被压低。

整个过程如下: 这里有一个很容易误解的点:

Search-R1 的训练对象是大模型的 LoRA 权重;搜索引擎和 embedding 模型都作为固定环境保留。

模型学习的是:什么时候调用工具、怎样构造 query、如何使用返回的证据,以及什么时候停止搜索并回答。

我们是怎么复现的?

原版 Search-R1 建立在 veRL 上,官方 Quick Start 默认下载 Wikipedia corpus 和 E5 索引,再单独启动一个本地 retriever server。

我最开始也想照着原版做。但把数据库下载并解压后,体积大约 160 GB;如果希望检索服务在训练期间流畅运行,服务器还需要大约 180 GB 内存。对于严谨复刻论文环境来说,这些资源当然有意义,但对于第一次学习 Search-R1 的人,它们反而会把注意力从算法本身移开。

很多人还没来得及深入 reward、advantage 或 loss mask 等关键算法部分,就先卡在了一系列与算法无关的工程问题上:

text 复制代码
索引怎么下载?
为什么解压后磁盘不够?
Faiss 为什么启动失败?
retriever server 为什么 OOM?
训练卡和检索卡应该怎么分?

这些问题都是真实的工程问题,但它们并不会帮助我们更快理解 Search-R1 的核心算法。

所以我做了一个替换:

text 复制代码
原论文:本地 Wikipedia corpus + E5 retriever
本文:  知乎全局搜索 API

Search-R1 官方代码本身也支持把本地检索器替换成在线搜索引擎,因为算法只要求存在这样一个接口:

text 复制代码
search(query) → observations

只要模型仍然需要自己生成 query、读取 observation、继续推理并获得最终 reward,Search-R1 的核心训练闭环就没有改变。

我们的实现配置如下:

项目 本文实现
Base Model Qwen/Qwen3.5-4B
训练参数 LoRA rank 32
训练框架 PyTRIO
搜索环境 知乎全局搜索 API,Top 3
训练数据 NQ + HotpotQA
每个 step 8 道问题
每道问题 8 条轨迹
最大搜索次数 4
最大 assistant turns 6
最大轨迹长度 8,192 tokens
Reward Exact Match + Format
Advantage reward - group_mean
Loss importance_sampling

这次复现聚焦于核心算法,不追求逐项复刻原论文的基础设施和最终分数:保留问题、工具、环境交互、group-relative advantage、retrieved-token mask 和 policy update,把最重的本地检索基础设施换成更容易获得的在线搜索服务。

我认为这对于学习算法更有价值。你可以先用极小的成本理解 Search-R1,等真正需要追求论文指标时,再把 search(query) 换回本地 Wikipedia retriever。

为什么我觉得 PyTRIO 很适合 Agentic RL?

我以前也尝试过用 veRL 等框架做强化学习。它们当然很强,但如果我只是想研究一个算法,往往要先处理训推一体、模型权重同步、vLLM、分布式训练和多卡资源调度。

Agentic RL 又比普通数学 RL 更麻烦。一次 rollout 里面不只有模型生成:

text 复制代码
模型生成 → 工具调用 → 等待环境 → 模型继续生成 → 再调用工具 → ......

如果自己租一台 8 卡机器,模型等待搜索 API 时 GPU 可能空着;训练侧等待长尾 rollout 时 GPU 也可能空着,但机器仍然按整段时间计费。

这里最关键的区别是计费单位。租 GPU 机器通常按占用时间收费:从任务启动到任务结束,8 张卡无论在训练、等待搜索,还是等待最慢的 rollout,整段 wall-clock time 都要付费。Agentic RL 的工具调用和轨迹长度又很不稳定,GPU 空转很容易成为一笔真实成本。

PyTRIO 的训练侧按实际处理的训练 Token 计费。模型等待知乎搜索 API、本地 Python 执行工具、训练侧等待长尾 rollout 的这段时间都不会产生训练 Token,因此也不会继续累积训练费用。算力本身依然有成本,但我们无需为搜索和调度造成的 GPU 空转买单,这种计费方式天然适合 Search-R1 一类"环境交互时间长、有效训练时间短"的 Agentic RL 任务。

PyTRIO 把这个分工拆得很舒服:

  • 本地 Python:数据、工具环境、rollout 状态机、reward、advantage、实验循环;
  • PyTRIO:模型采样、前向、反向、LoRA optimizer、checkpoint 和 sampler weights。

本地代码只需要像普通 Python 一样调用:

python 复制代码
sampling_client = training_client.save_weights_and_get_sampling_client()

training_client.forward_backward(
    datums,
    loss_fn="importance_sampling",
).result()

training_client.optim_step(adam_params).result()

完整训练循环在 train.py

我不需要持有一台 8 卡机器,也不需要把 rollout 和 trainer 塞进同一个集群。对这类"生成很慢、工具调用很多、真正 backward 时间反而不长"的任务,按实际训练 Token 使用远端训练服务会自然很多。

这也是我觉得 PyTRIO 很像一种属于未来的模型训练基础设施的原因:研究者写的是算法和实验,底层训练服务更像随用随取的算力 API。

先看实验结果

我们使用固定的 70 道题做开发评测:7 个 benchmark,每个 benchmark 固定抽取 10 道题。完整结果如下:

模型 / Checkpoint Macro EM Format 2Wiki Bamboogle HotpotQA MuSiQue NQ PopQA TriviaQA
Qwen3.5-4B 28.57% 58.57% 30% 70% 30% 10% 20% 20% 20%
RL Step 20 31.43% 87.14% 30% 50% 40% 10% 20% 10% 60%
RL Step 50 45.71% 94.29% 60% 70% 50% 30% 30% 20% 60%
RL Step 100 35.71% 100% 40% 70% 30% 0% 20% 30% 60%
RL Step 150 38.57% 100% 40% 70% 30% 20% 20% 30% 60%
RL Step 200 40.00% 100% 40% 70% 40% 10% 30% 30% 60%

20 step 已经能够看到效果

如果你的目标是理解和验证算法,不需要一上来就跑 200 step。

在我们的 20-step 小规模实验里:

text 复制代码
Macro EM: 28.57% → 31.43%
Format:   58.57% → 87.14%

Format 的变化尤其明显。模型开始学会在多轮搜索结束后,用规定的:

text 复制代码
Answer: <short answer>

收束整条轨迹,避免在搜索次数耗尽后继续输出 tool call,也避免只写了一段解释却没有留下可评分答案。

Step 20 来自一条早期小规模 run,实时搜索条件与主实验不完全相同,所以不能把这几个百分点当成严格的论文级提升。但它已经足够完成教学目标:确认真实 rollout、reward、group advantage 和参数更新确实改变了模型行为。

Step 50 是当前可靠窗口里的最好结果

主实验里,Step 50 的 Macro EM 达到 45.71%,即固定 70 题中答对 32 题;Format Rate 达到 94.29%

更重要的是,Step 50 checkpoint 在评测时的搜索成功率为 96.41%,因此它是目前最值得观察的一组结果。

下面是一条真实的 MuSiQue 评测轨迹。Base Model 搜索 4 次后仍然停在 tool call;Step 50 用 3 次搜索保留了 John Cabot → child 的实体链,最后回答 Sebastian Cabot 这张图只展示一条真实轨迹,不能替代整体评测,但它把模型到底学到了什么表现得很直观:模型从单纯生成更长的文字,变得更像一个知道什么时候查、什么时候停的搜索 Agent。

50 step 之后发生了什么?

如果只看 reward 曲线,很容易得到一个结论:模型在中后期退化了。 但把搜索指标放在旁边看,事情就不一样了: 这次实验使用了三个知乎搜索 key。随着训练不断调用工具,三个 key 陆续触发额度限制,后面的搜索环境开始大量返回错误。至少前 50 step 仍然处在我们认为可靠的训练窗口;继续往后的 correctness reward 已经被外部工具环境污染。

这也解释了一个非常有意思的现象:

text 复制代码
reward/format 继续上升并稳定接近 1
reward/correct 和 reward/mean 在搜索环境恶化后下降

Format reward 不依赖搜索结果。只要模型最后正确输出一行 Answer:,这个信号就能被稳定计算,所以模型确实学会了格式。

Correctness reward 则依赖工具环境。API 失败以后,即使模型生成了一条合理 query,也可能拿不到正确证据;这时 reward 的差异就不再只反映策略好坏,还混入了搜索服务的运气。

对于 group-relative RL,这个问题会被进一步放大:

  • 合理轨迹可能因为 API 错误得到低 reward;
  • 同组轨迹的相对差异可能来自搜索服务,与模型策略无关;
  • 如果一整组都因为环境失败得到相同 reward,advantage 全部变成 0,这道题不会提供有效梯度。

因此,这次实验后半段的下降不能简单解释成"Search-R1 算法失效"或"代码写错了"。前 50 step 的 EM 变化,以及不依赖搜索 API 的 Format 学习,共同说明核心训练链路已经工作。现在真正缺少的是一个能够长期稳定调用的搜索环境。

Search-R1 的训练闭环详细拆解

到这里,如果只想知道 Search-R1 是什么,其实已经够了。想直接跑代码的朋友可以直接去 GitHub 仓库 :github.com/KMnO4-zx/ll...

后面这一部分会按真实代码,把一次训练 step 拆开。完整目录如下:

text 复制代码
03-search-r1/
├── prepare_data.py   # 下载并整理训练与评测数据
├── data.py           # 读取本地 JSONL
├── protocol.py       # search tool schema、prompt 和 tool-call 解析
├── search.py         # 知乎搜索客户端
├── rollout.py        # 多轮工具调用状态机
├── reward.py         # EM + Format reward
├── train.py          # PyTRIO Search-R1 训练循环
├── eval.py           # Base / checkpoint 统一评测
└── analyse.py        # 绘制 checkpoint 结果图

第一步:准备问题和答案

数据来自 Search-R1 官方公开的 PeterJinGo/nq_hotpotqa_train,我们通过 ModelScope 镜像下载固定版本。

准备脚本在 prepare_data.py

训练集包含:

数据源 题目数
NQ 79,168
HotpotQA 90,447
合计 169,615

训练时只需要四个字段:

json 复制代码
{
  "id": "...",
  "question": "...",
  "answers": ["..."],
  "data_source": "nq"
}

这里没有人工标注的搜索 query,也没有人工写好的推理轨迹。模型只拿到问题,后面的搜索过程由当前策略自己生成。

这点非常重要:

我们没有用 SFT 教模型模仿某条固定搜索路径。最终结果提供 reward,让模型自己探索更有效的搜索路径。

第二步:把搜索声明成模型工具

工具协议在 protocol.py

搜索工具的 schema 很短:

python 复制代码
SEARCH_TOOL = {
    "type": "function",
    "function": {
        "name": "search",
        "description": "Search Zhihu for evidence. Use a concise English query.",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
            },
            "required": ["query"],
        },
    },
}

每一轮都使用 Qwen3.5 自己的 chat template 注入工具:

python 复制代码
prompt_tokens = tokenizer.apply_chat_template(
    messages,
    tools=[SEARCH_TOOL],
    tokenize=True,
    add_generation_prompt=True,
    enable_thinking=False,
)

这里无需靠 stop word 猜模型是否想搜索。模型会生成结构化的 <tool_call>protocol.py 再把它解析成 query;环境执行真实搜索后,以 role="tool" 把 observation 写回对话。

知乎搜索实现位于 search.py。它会轮转多个 key,记录成功率、错误率、429 和延迟,每次只把 Top 3 的标题、内容片段、来源和 URL 返回给模型。

第三步:让一组轨迹在搜索后真正分叉

这是多轮 Search-R1 实现里,我觉得最容易被忽略的细节。

同一道题第一次采样时,8 条轨迹拥有完全相同的 prompt,因此可以直接:

python 复制代码
sample_async(
    prompt=shared_prompt,
    num_samples=8,
)

但第一轮之后,8 条轨迹可能生成 8 个不同 query,也会得到 8 份不同搜索结果:

text 复制代码
query_i 不同
→ observation_i 不同
→ 下一轮 prompt_tokens_i 不同

这时已经不能继续对一个 shared prompt 使用 num_samples=8。代码必须把每条轨迹独立推进,用多个 num_samples=1 的请求并发执行。 完整状态机在 rollout.py。核心结构是:

text 复制代码
第一轮:
每道题 1 个 shared prompt × num_samples=8

第一次搜索之后:
每条未结束轨迹 1 个独立 prompt × num_samples=1
多个 sample_async 用 asyncio.gather 并发

一条轨迹内部仍然严格串行:

text 复制代码
assistant generation
→ search(query)
→ tool observation
→ next assistant generation

不同轨迹之间才可以并发。这样既保留了每条 Agent 轨迹的因果顺序,又不会让 64 条轨迹完全串行等待。

第四步:只用最终答案计算 Reward

Reward 实现在 reward.py

模型最终必须留下且只留下一个非空答案:

text 复制代码
Answer: <short answer>

我们使用三档 reward:

最终结果 Reward
格式合法且答案正确 1.0
格式合法但答案错误 0.0
格式非法,或没有最终答案 -0.1

代码只有几行:

python 复制代码
def score_answer(text: str, references: list[str]) -> RewardResult:
    answer = extract_answer(text)
    if answer is None:
        return RewardResult(-0.1, False, False, None)
    exact_match = any(
        normalize_answer(answer) == normalize_answer(reference)
        for reference in references
    )
    return RewardResult(float(exact_match), True, exact_match, answer)

这里没有给"搜索了几次"单独奖励,也没有给中间 query 加人工分数。

原因是中间 reward 很容易诱导 reward hacking。比如奖励搜索次数,模型可能学会不停搜索;奖励关键词命中,又可能让模型只优化 query 表面形式。本文保持最简单的 outcome reward:最后答对就行,至于中间如何搜索,让策略自己探索。

第五步:在同一道题内部计算 Advantage

每道题采样 8 条轨迹,得到 8 个最终 reward。然后计算:

math 复制代码
A_i = r_i - \operatorname{mean}(r_1, r_2, \ldots, r_8)

代码在 rollout.py

python 复制代码
mean_reward = sum(item.reward for item in group) / len(group)
for item in group:
    item.advantage = item.reward - mean_reward

例如某一组 reward 是:

text 复制代码
[1.0, 0.0, 0.0, -0.1, 1.0, 0.0, 0.0, -0.1]

组内平均值为 0.225,那么三类轨迹的 advantage 分别是:

text 复制代码
1.0  → +0.775
0.0  → -0.225
-0.1 → -0.325

同一道题里,相对更好的轨迹会被提高概率,相对更差的轨迹会被压低。

如果一整组 reward 完全一样,所有 advantage 都是 0。这一组没有相对优劣,代码会跳过更新。这也是为什么搜索环境必须稳定:如果 API 错误让整组轨迹一起失败,我们就白白完成了一轮昂贵 rollout,却没有得到有效梯度。

第六步:搜索结果保留在上下文,但不参与 Loss

这是 Search-R1 和普通单轮 GRPO 最值得讲清楚的地方。

一条多轮轨迹里同时存在四类 token:

text 复制代码
system + user
assistant search(query)
tool observation
assistant final Answer

模型生成的 assistant token 是动作,应该参与策略更新;搜索 API 返回的 tool observation 是环境状态,它必须保留在上下文里,但不能当成模型生成的 token 去训练。 我们的处理方式无需额外发送 mask 字段,直接给 observation token 填入零 advantage 和零 old logprob:

text 复制代码
system / user / tool observation: advantage = 0
assistant tool call / final answer: advantage = trajectory_advantage

完整实现位于 train.pybuild_datum()

核心逻辑如下:

python 复制代码
full_tokens += delta_observation + turn.completion_tokens
old_logprobs_by_token += [0.0] * len(delta_observation) + turn.logprobs
advantages_by_token += (
    [0.0] * len(delta_observation)
    + [trajectory.advantage] * len(turn.completion_tokens)
)

input_tokens = full_tokens[:-1]
target_tokens = full_tokens[1:]
old_logprobs = old_logprobs_by_token[1:]
advantages = advantages_by_token[1:]

随后构造 PyTRIO Datum

python 复制代码
datum = trio.Datum(
    model_input=trio.ModelInput.from_ints(input_tokens),
    loss_fn_inputs={
        "target_tokens": np.asarray(target_tokens, dtype=np.int64),
        "logprobs": np.asarray(old_logprobs, dtype=np.float32),
        "advantages": np.asarray(advantages, dtype=np.float32),
    },
)

input_tokenstarget_tokenslogprobsadvantages 必须经过同一次自回归右移,并且长度严格一致。old logprobs 必须来自 rollout 当时的 Student sampler,不能在模型更新后重新计算。

这样,搜索结果仍然影响后面的 assistant 决策,却不会被错误地当成模型动作参与 policy loss。

第七步:完整 group 算完,再拆 micro-batch 更新

一个训练 step 包含:

text 复制代码
8 questions × 8 trajectories = 64 trajectories

每条轨迹最长 8,192 tokens,不可能把 64 条长轨迹一次全部送进远端训练请求。

但是也不能先随便拆 batch,再在小 batch 内计算 reward mean。Group-relative advantage 必须先在完整的同题 8 条轨迹上算完,否则算法已经变了。

所以顺序是:

text 复制代码
完整 rollout group
→ reward
→ group-relative advantage
→ 每条完整轨迹构造 Datum
→ 按 padding 后的矩形大小拆 micro-batch
→ 累积 forward/backward
→ 整个逻辑 step 只做一次 optimizer step

代码会限制:

text 复制代码
单条 Datum ≤ 8,192 tokens
单个 micro-batch ≤ 32 Datums
micro-batch items × max_sequence_length ≤ 64,000

PyTRIO 服务端对每次 forward_backward 的样本取 mean,因此不同大小的 micro-batch 还要按照 n_k / N 缩放 advantage,保证累积结果等价于完整 logical batch 的全局均值。

这部分代码都在 train.pypack_micro_batches()weight_micro_batch_for_global_mean() 中。

如何用 20 step 跑出效果?

下面是我建议读者实际复现的路径。它直接运行完整的 Search-R1 训练,没有为了测试额外增加简化分支。

1. 安装项目并登录

bash 复制代码
git clone https://github.com/KMnO4-zx/llm-agent-rl-lab.git
cd llm-agent-rl-lab

uv sync
trio login
swanlab login

训练和采样由 PyTRIO 远端服务完成,本地只需要 CPU 环境。

2. 下载并整理数据

bash 复制代码
uv run python 03-search-r1/prepare_data.py

完成后会生成:

text 复制代码
03-search-r1/datasets/
├── train.jsonl    # 169,615 道训练题
├── dev.jsonl      # 固定 70 道评测题
└── test.jsonl     # 完整评测池

3. 配置搜索 Key

打开知乎数据开放平台,登录后点击页面右上角的「个人中心」,按照页面提示申请搜索 API Key。当前页面提供「注册免费获取 1000 次/天调用」的入口,实际额度以平台页面为准。 申请完成后,复制环境变量模板:

bash 复制代码
cp 03-search-r1/.env.example 03-search-r1/.env

.env 中填写:

dotenv 复制代码
ZHIHU_SEARCH_KEYS=your_first_key,your_second_key,your_third_key

建议准备三个有足够剩余额度的 key。搜索服务是 RL 环境的重要组成部分。开始正式训练前,一定要确认 search/success_rate 正常。

4. 运行 20-step 小规模训练

进入 Search-R1 目录:

bash 复制代码
cd 03-search-r1

然后执行:

bash 复制代码
uv run python train.py \
    --max-steps 20 \
    --questions-per-batch 8 \
    --group-size 8 \
    --save-every 5 \
    --swanlab-mode online

这 20 个 step 每步使用 8 道问题,每道问题采样 8 条真实多轮工具轨迹。每 5 step 会保存一次:

text 复制代码
*-state    # 用于断点续训
*-weights  # 用于采样与评测

20 step 已经足够观察:

  • reward/format 是否明显上升;
  • 模型是否开始稳定生成最终 Answer:
  • reward/correct 是否出现早期变化;
  • 平均搜索次数和 assistant turns 是否变化;
  • search/success_rate 是否仍然稳定;
  • degenerate group 是否过多。

5. 评测 Base Model 和 Step 20

先评测 Base Model:

bash 复制代码
uv run python eval.py \
    --batch-size 16 \
    --output eval_result/eval_results.jsonl

训练结束时终端会打印 Step 20 sampler weights 路径。把它传给 evaluator:

bash 复制代码
uv run python eval.py \
    --batch-size 16 \
    --model-path 'trio://YOUR_STEP_20_SAMPLER_WEIGHTS' \
    --output eval_result/eval_results_rl_step_20.jsonl

如果搜索额度有限,可以先用 --limit 20 检查整条链路,再决定是否运行固定 70 题评测。

评测代码在 eval.py。它对 Base 和 checkpoint 使用相同的 tokenizer、工具协议、搜索环境、轨迹限制和 EM 规则。

6. 绘制完整 checkpoint 结果

如果已经准备好 Base、Step 20、50、100、150 和 200 的评测 JSONL,可以运行:

bash 复制代码
uv run python analyse.py

绘图代码在 analyse.py,它会直接读取每个 JSONL 末尾的 summary.metrics,生成本文使用的 Macro EM 与 Format 图。

训练时最应该看哪些指标?

Search-R1 不能只看一个 reward 曲线。

指标 它在回答什么问题?
reward/mean 当前整条轨迹的平均结果如何?
reward/correct 最终答案正确率是否变化?
reward/format 模型是否学会结束搜索并按格式回答?
rollout/search_calls 每条轨迹平均搜索多少次?
rollout/turns 多轮轨迹变长还是变短?
rollout/degenerate_group_rate 多少问题没有组内相对训练信号?
train/loss_tokens_per_rollout_batch 实际有多少 assistant token 参与 loss?
search/success_rate 模型拿到的工具环境是否可靠?
search/error_rate reward 是否可能被外部错误污染?
search/latency 工具调用是否成为 rollout 瓶颈?

如果 reward/correct 下跌,同时 search/error_rate 上升,不能立刻得出模型退化的结论。先确认环境,再解释策略。

这也是 Agentic RL 和纯数学 RL 很不一样的地方:数学题的 verifier 通常是确定的,但工具环境可能超时、限流、返回空结果,甚至随着时间变化。环境质量本身就是实验变量。

这次复现有哪些边界?

第一,本文聚焦于复现 Search-R1 的核心算法,未逐项还原原论文的完整基础设施与 benchmark 分数。我们替换了模型、搜索后端、训练框架和部分训练配置,所以绝对指标不能与论文直接比较。

第二,当前评测只有固定 70 道题,每个 benchmark 10 道,适合检查行为变化和发现实现问题,但不能替代更大规模、多随机种子的正式实验。

第三,三个搜索 key 在主实验中陆续触发额度限制。我们把至少前 50 step 视为当前可信训练窗口,后续 checkpoint 只作为现象记录,不用它们证明过度训练或策略塌缩。

第四,Format Rate 上升能够验证 reward、advantage、loss mask 和 policy update 链路确实工作,因为这个信号不依赖搜索 API;但更强的多跳正确性结论仍然需要稳定搜索环境和更大评测集。

这些边界并不影响本文最重要的目标:让大家用很小的存储、算力和金钱成本,真正把 Search-R1 从代码跑起来,并理解每一部分为什么存在。

小结

Search-R1 看起来像一个复杂的 Agent 训练系统,但拆开以后,它的核心非常朴素:

text 复制代码
1. 给模型一个问题和搜索工具。
2. 让当前策略生成多轮 search / observation / answer 轨迹。
3. 只根据最终答案计算 reward。
4. 在同一道题的多条轨迹之间计算相对 advantage。
5. 只训练模型生成的 assistant action token。
6. 用 PyTRIO importance_sampling 更新 LoRA。
7. 导出新 sampler,继续下一轮 rollout。

它真正训练的是如何主动从环境里获取知识,重点并不在于"记住更多知识"。

原论文的 160 GB 检索数据库和约 180 GB 内存需求,对于严格复刻论文当然有价值;但如果只是想理解算法,先承担这套基础设施并不划算。本文用知乎搜索替代本地检索器,把进入 Search-R1 的门槛降到了普通开发者能够接受的程度。

你不需要训练几百 step。准备稳定的搜索 key,运行 20 step,就已经能够看到 Format Rate 和模型行为发生变化。我们这次的完整训练账单是 13.30 元,比一杯喜茶还便宜。

对我来说,这也是 PyTRIO 最吸引人的地方:我终于可以把时间集中在 reward、rollout、loss mask 和实验设计上,省去维护庞大训推集群的前置负担。

我越来越相信,未来训练模型也会像今天调用云数据库或对象存储一样自然。我们写普通 Python 描述算法,把采样、训练和 checkpoint 当作远端能力使用。至少对于个人研究者和小团队来说,这会释放出非常多原本被基础设施挡住的实验。

下一篇复现什么,我再想想。可能是 GSPO or OPSD,也可能又会突然冒出一个更好玩的 Agentic RL 想法哈哈哈。

参考资料

论文与官方实现

  1. Bowen Jin et al. Search-R1: Training LLMs to Reason and Leverage Search Engines with Reinforcement Learning, 2025.
  2. Bowen Jin et al. An Empirical Study on Reinforcement Learning for Reasoning-Search Interleaved LLM Agents, 2025.
  3. PeterGriffinJin/Search-R1

数据、模型与搜索

  1. PeterJinGo/nq_hotpotqa_train
  2. Qwen/Qwen3.5-4B
  3. 知乎全局搜索 API

本文实现

  1. Search-R1 PyTRIO 完整代码
  2. PyTRIO 文档
  3. SwanLab 文档
相关推荐
嘟嘟07171 小时前
像水流一样——前端流式输出完整指南(3)
面试·github
geovindu1 小时前
CSharp: Recursion Algorithm
开发语言·后端·算法·c#·递归算法
卡提西亚1 小时前
leetcode-239. 滑动窗口最大值
算法·leetcode·职场和发展
weedsfly2 小时前
从表单校验到订单流转,彻底搞懂前端开发中的策略模式与状态模式
前端·javascript·面试
ShallWeL2 小时前
【机器学习】(20)—— 类别不平衡
人工智能·算法·机器学习
明志数科2 小时前
具身智能数据标准化:从碎片化接口到统一工作组的技术路径
人工智能·算法·机器学习
帅次2 小时前
Android 高级工程师面试:Flutter 路由导航 近1年高频追问 18 题
android·flutter·面试
旖-旎2 小时前
LeetCode 494:目标和(动态规划/01背包问题)—— 题解
c++·算法·leetcode·动态规划·01背包
spssau2 小时前
一文学会结构方程模型:从模型搭建、路径图绘制到不达标调整,实操全流程
人工智能·python·算法