强化学习训练流程解析:Long-CoT vs Delethink
整个流程可以看作一个清晰的"生成-评估-学习"循环,涉及多个核心文件。
第一步:配置与启动 (The Setup)
当您运行一个训练脚本时,例如 examples/reproduce_rl_training/longcot_24k.sh(尽管我们已经修改了它,但原理适用于原始版本),它最终会调用 verl/trainer/main_policy_iteration.py。
- 加载配置:
main_policy_iteration.py的核心作用是使用 Hydra 加载指定的配置文件。对于 Long-CoT,它会加载类似verl/trainer/config/r1d-1.5b_deepscaler.yaml这样的父配置文件。 - 启动训练器: 它会实例化并运行一个训练器。根据配置,这个训练器通常是
verl/trainer/ppo/ray_trainer.py中的RayPPOTrainer。注意,Delethink 有一个专门的子类RayTreetunePPOTrainer,但 Long-CoT 使用的是更基础的 PPO 训练器。
第二步:生成思考链 (Rollout - State & Action)
这是 RL 循环的核心,也是体现 Long-CoT 特点的地方。
- 训练循环入口: 训练的核心逻辑在
verl/trainer/ppo/ray_trainer.py的fit()方法中。 - 生成数据: 在
fit()方法的循环里,您会看到这样一行关键代码:
# verl/trainer/treetune_ppo/ray_trainer.py:267
gen_batch_output = self.actor_rollout_wg.generate_sequences(gen_batch)
这行代码通过 Ray 的工作组(actor_rollout_wg)调用 generate_sequences 方法,让 Actor 模型(也就是我们的 LLM 策略)根据输入的 prompt gen_batch 来生成思考链。
-
Rollout Worker 的实现: 这个调用最终会由
verl/workers/rollout/sglang_rollout/sglang_rollout.py中的SGLangRolloutWorker来处理。- 核心函数:
generate_sequences
- 核心函数:
- 关键逻辑: 在这个函数内部,它会调用 SGLang 引擎来进行文本生成:
# verl/experimental/agent_loop/delethink_agent_loop.py:111 (逻辑类似)
response: dict[str, Any] = await self.server_manager.generate(
request_id=...,
prompt_ids=prompt_ids.tolist(), # <--- 这里是关键
sampling_params=sampling_params,
)
- 状态的体现: 这里的
prompt_ids就是 Long-CoT RL 中"状态"的直接代码体现。对于 Long-CoT 来说,这个prompt_ids包含了初始问题和所有已经生成的思考文本。SGLang 引擎会处理这个完整的、越来越长的序列。 - 动作的体现:
self.server_manager.generate的执行过程就是"动作",它会生成一个新的文本片段(response_ids)。
- 二次方复杂度的根源: 在 Long-CoT 模式下,如果模型需要进行多轮思考(虽然在这个项目的实现中,一次
generate_sequences调用通常会生成完整的轨迹直到 EOS),那么下一次调用的输入将会是prompt_ids + response_ids。序列的长度不断增加,导致 SGLang 引擎内部的注意力计算和 KV Cache 呈二次方增长。这就是 OOM 错误的直接代码根源。
第三步:计算奖励 (Reward)
生成完成后,我们需要评估这个思考链的质量。
- 奖励计算入口: 回到
verl/trainer/ppo/ray_trainer.py的fit()方法中,在生成gen_batch_output之后,代码会调用奖励计算函数:
# verl/trainer/treetune_ppo/ray_trainer.py:315
reward_tensor, reward_extra_infos_dict = compute_reward(batch, self.reward_fn)
- 奖励函数的定义:
self.reward_fn是从哪里来的?它是由配置文件verl/trainer/config/r1d-1.5b_deepscaler.yaml指定的:
custom_reward_function:
path: verl/utils/reward_score/treetune_math_verify.py
- 奖励函数的实现: 这就指向了
verl/utils/reward_score/treetune_math_verify.py。这个文件里的compute_score函数就是实际的奖励函数。- 核心逻辑:
- 它接收模型生成的完整文本
solution_str。 - 使用
parse_predicted_answer_fn从文本中解析出\boxed{}里的最终答案。 - 使用
verify函数将模型答案与ground_truth进行数学等价性比较。 - 返回 1.0(正确)或 0.0(错误)。这就是稀疏的、终端的奖励信号。
- 它接收模型生成的完整文本
- 核心逻辑:
第四步:学习与更新 (PPO)
拿到奖励后,模型需要根据这个反馈进行学习。
- 计算优势 (Advantage): 在
fit()方法中,紧接着奖励计算,代码会调用compute_advantage函数。
# verl/trainer/treetune_ppo/ray_trainer.py:393
batch = compute_advantage(
batch,
adv_estimator=self.config.algorithm.adv_estimator, # 通常是 'gae'
...
)
这个函数(位于 verl/trainer/ppo/ray_trainer.py)使用 GAE (Generalized Advantage Estimation) 算法,根据刚刚计算出的奖励,为整个生成序列中的每一步(每个 token)估算一个"优势值",告诉 模型哪些动作是"好"的,哪些是"坏"的。
- 更新模型参数: 计算完优势后,就到了最后一步——更新模型。
# verl/trainer/treetune_ppo/ray_trainer.py:431
actor_output = self.actor_rollout_wg.update_actor(batch)
这个调用 会触发 verl/workers/actor/dp_actor.py 中的 update_actor 方法。
- PPO 核心算法: 在 update_actor 中,PPO 的核心 loss 被计算出来。
- 它会使用当前的策略(模型)和旧的策略(生成轨迹时的模型)计算 log-probabilities。
- 结合优势值和 PPO 的裁剪(clipping)机制(由配置中的
clip_ratio_high控制),计算出最终的 policy loss。 - 通过反向传播和优化器步骤(
optimizer.step()),更新 Actor 模型的权重。
总结:代码层面的 Long-CoT RL
| RL 概念 | 代码实现 (文件和函数) | 核心逻辑 |
|---|---|---|
| 环境/状态 | verl/workers/rollout/sglang_rollout.py - generate_sequences | 将完整的 prompt_ids (问题 + 全部历史) 传递给 SGLang 引擎。状态随生成而增长。 |
| 动作 | verl/workers/rollout/sglang_rollout.py - self.server_manager.generate | LLM 生成下一个 token/文本片段。 |
| 奖励 | verl/utils/reward_score/treetune_math_verify.py - compute_score | 在序列生成结束后,比较最终答案和标准答案,返回 1.0 或 0.0 的稀疏奖励。 |
| 学习/策略更新 | verl/workers/actor/dp_actor.py - update_actor | 基于 GAE 计算出的优势值,使用 PPO 裁剪目标函数来更新模型参数。 |
这个流程清晰地展示了 Long-CoT RL 的实现方式及其固有的二次方复杂度瓶颈,因为它始终操作着一个不断增长的状态序列。
Delethink RL 深入解析
Delethink 的核心思想非常巧妙:它承认并接受了 Transformer 模型处理长序列的二次方复杂度瓶颈,但它并没有尝试去优化注意力机制本身,而是从强化学习的环境定义入手,彻底改变了游戏的规则。
核心理念:从"无限增长的状态"到"固定大小的马尔可夫状态"
Long-CoT 的问题在于状态 不断变长。
Delethink 提出:为什么状态一定要包含所有历史记录?一个真正高效的"思考者",应该能够将之前步骤的关键信息总结下来,然后基于这个总结和原始问题继续下一步,而不是每次都从头回顾每一个细节。
这就是"马尔可夫状态"的核心:当前状态 包含了所有与未来决策相关的历史信息。Delethink 强迫模型学会创建这样的状态。
它是如何做到的呢?通过分块生成 (Chunked Generation) 和上下文重置 (Context Reset)。
Delethink RL 在代码层面的实现
我们同样按照"生成-评估-学习"的流程来分析,并重点关注它与 Long-CoT 的不同之处。
第一步:配置差异 (The Delethink Switch)
当您运行 delethink_24k.sh 时,加载的配置文件是 verl/trainer/config/r1d-1.5b_deepscaler_delethink_24k.yaml。这个文件里有几个关键参数,它们就是开启 Delethink 模式的"开关":
# verl/trainer/config/r1d-1.5b_deepscaler_delethink_24k.yaml
algorithm:
delethink:
# (C-m) - 每个新思考块的预算
intermediate_max_new_tokens: ${eval:'int(${data.max_response_length})//2'}
# 头部保留长度
keep_head: 100
# 尾部保留长度 (m)
keep_tail: ${eval:'int(${data.max_response_length})//2 - int(${algorithm.delethink.keep_head})'}
actor_rollout_ref:
rollout:
mode: async # <-- 异步模式,通常与 Agent Loop 一起使用
multi_turn:
max_assistant_turns: 5 # (I) - Delethink 的总轮数
multi_turn.max_assistant_turns: 这不再是对话轮次,而是 Delethink 分块生成的总轮数 (I)。intermediate_max_new_tokens: 除了第一轮,后续每一轮思考块的最大长度。keep_head/keep_tail: 这是 Delethink 的魔法所在。它定义了在每一轮生成结束后,从生成的文本中保留哪些部分作为下一轮的"记忆"。
第二步:分块生成 (The Delethink Agent Loop)
这是与 Long-CoT 根本性的不同之处。Delethink 的生成过程不再是一次性完成的,而是一个循环。这个循环的逻辑位于 verl/experimental/agent_loop/delethink_agent_loop.py 中的 DelethinkAgentLoop 类。
让我们看看它的 run() 方法中发生了什么:
- 循环开始:
# verl/experimental/agent_loop/delethink_agent_loop.py:102
while True:
# ...
assistant_turns += 1
这个 while 循环就是分块生成的核心。assistant_turns 记录当前是第几个思考块。
- 第一轮生成 (Chunk 1):
assistant_turns为 1。- 输入
prompt_ids仅包含原始问题。 max_new_tokens被设置为一个较大的值,即配置中的data.max_response_length(例如 8192)。- 调用 SGLang 引擎生成第一个思考块
response_ids。
- 上下文裁剪与重构 (The "Dele" in Delethink):
- 当第一轮生成结束(或者达到长度上限),代码不会简单地将
response_ids拼接到下一次输入中。 - 相反,它会调用
_get_next_prompt_ids方法(或者一个更复杂的trimmer类)。这里的逻辑至关重要:
- 当第一轮生成结束(或者达到长度上限),代码不会简单地将
# verl/experimental/agent_loop/delethink_agent_loop.py:194
tail = curr_response_ids[-self.keep_tail :]
head = curr_response_ids[: self.keep_head]
next_prompt_ids = np.concatenate([curr_prompt_ids, head, tail])
- 它从刚刚生成的
response_ids中,取出头部的一小部分 (keep_head) 和尾部的一大部分 (keep_tail)。 - 然后,它构造下一轮的输入:
next_prompt_ids = [ 原始问题 ] + [ head ] + [ tail ]。 - 这就是核心!模型丢弃了中间生成的大部分内容,只保留了开头和结尾。它必须学会将中间思考的"状态"压缩进这个结尾部分(
tail),这就是它的马尔可夫状态。
- 后续轮次生成 (Chunks 2 to I):
assistant_turns> 1。- 输入
prompt_ids是上一轮重构好的、长度固定的next_prompt_ids。 max_new_tokens被设置为较小的值intermediate_max_new_tokens。- 生成下一个思考块。
- 循环执行"生成 -> 裁剪 -> 重构"的过程,直到达到
max_assistant_turns或者模型生成了 EOS 符。
最终结果:通过这个循环,generate_sequences 不再返回一个长序列,而是返回一个列表的列表,记录了每一轮分块生成的 trace_prompt_ids 和 trace_response_ids。
第三步:奖励计算 (几乎不变)
奖励计算本身没有变化,但输入变了。
- 轨迹重组: 在计算奖励之前,系统需要将分块的轨迹重新"拉平"。这个逻辑可以在
delethink_tracing_demo.py中看得很清 楚:
# delethink_tracing_demo.py:196
trace_response_ids = sum(trace_response_ids, [])
response = llm.tokenizer_manager.tokenizer.decode(response_ids, ...)
它将所有轮次生成的 response_ids 拼接成一个完整的、连贯的思考链文本。
- 调用奖励函数: 然后,这个完整的文本被送入我们之前讨论过的同一个奖励函数
verl/utils/reward_score/treetune_math_verify.py中的compute_score。
所以,从奖励函数的角度看,它处理的仍然是一个完整的思考链。它并不知道这个链条是"分块"生成的。
第四步:学习与更新 (感知轨迹结构)
PPO 的更新机制也基本相同,但它现在要从这些"被打断又被重组"的轨迹中学习。
- Loss 聚合 模式: 在配置文件中,有一个关键参数:
# verl/trainer/config/r1d-1.5b_deepscaler_delethink_24k.yaml
actor:
loss_agg_mode: seq-mean-token-norm-trace-length
这个 loss_agg_mode 告诉 PPO 的 loss 计算函数 (verl/trainer/ppo/core_algos.py 中的 agg_loss_with_trace_lengths),它在计算 loss 时需要考虑到 Delethink 的轨迹结构。例如,它可能会根据整个轨迹的总长度而不是单个序列的长度来做归一化,确保 loss 的计算是公平的。
- 学习目标: 模型学习的目标变得更加复杂和有趣了:
- 它不仅要学会解决问题。
- 它还必须学会在
keep_tail这段有限的文本中,编码和压缩中间步骤的思考状态,以便在下一轮中能够顺利地接续下去。模型被逼着成为一个"马尔可夫思考者"。
总结:代码层面的 Delethink vs Long-CoT
| RL 概念 | Long-CoT RL 实现 | Delethink RL 实现 (关键变化) |
|---|---|---|
| 状态表示 | 不断增长的序列 [问题, 思考...] | 固定大小的序列 [问题, head, tail] |
| 生成过程 | 一次性、连续的长序列生成 | 循环分块生成 (DelethinkAgentLoop),伴随上下文裁剪和重构 |
| 计算复杂度 | , n 不断增长 | , C 是固定的分块大小。总计算成本近似线性 。 |
| 核心代码 | ppo/ray_trainer.py 中的标准生成 | experimental/agent_loop/delethink_agent_loop.py |
| 学习目标 | 学会解决问题 | 学会解决问题 + 学会将思考状态压缩进固定大小的文本中 |
通过这种方式,Delethink 以一种非常工程化的方式,从 RL 环境设计的层面,优雅地绕过了 Transformer 的二次方复杂度诅咒,实现了线性的计算成本,从而使得训练能处理超长推理任务的模型成为可能。