Skip to main content

强化学习训练流程解析:Long-CoT vs Delethink

代码

整个流程可以看作一个清晰的"生成-评估-学习"循环,涉及多个核心文件。

第一步:配置与启动 (The Setup)

当您运行一个训练脚本时,例如 examples/reproduce_rl_training/longcot_24k.sh(尽管我们已经修改了它,但原理适用于原始版本),它最终会调用 verl/trainer/main_policy_iteration.py

  1. 加载配置: main_policy_iteration.py 的核心作用是使用 Hydra 加载指定的配置文件。对于 Long-CoT,它会加载类似 verl/trainer/config/r1d-1.5b_deepscaler.yaml 这样的父配置文件。
  2. 启动训练器: 它会实例化并运行一个训练器。根据配置,这个训练器通常是 verl/trainer/ppo/ray_trainer.py 中的 RayPPOTrainer。注意,Delethink 有一个专门的子类 RayTreetunePPOTrainer,但 Long-CoT 使用的是更基础的 PPO 训练器。

第二步:生成思考链 (Rollout - State & Action)

这是 RL 循环的核心,也是体现 Long-CoT 特点的地方。

  1. 训练循环入口: 训练的核心逻辑在 verl/trainer/ppo/ray_trainer.pyfit() 方法中。
  2. 生成数据: 在 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 来生成思考链。

  1. 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)。
  1. 二次方复杂度的根源: 在 Long-CoT 模式下,如果模型需要进行多轮思考(虽然在这个项目的实现中,一次 generate_sequences 调用通常会生成完整的轨迹直到 EOS),那么下一次调用的输入将会是 prompt_ids + response_ids。序列的长度不断增加,导致 SGLang 引擎内部的注意力计算和 KV Cache 呈二次方增长。这就是 OOM 错误的直接代码根源。

第三步:计算奖励 (Reward)

生成完成后,我们需要评估这个思考链的质量。

  1. 奖励计算入口: 回到 verl/trainer/ppo/ray_trainer.pyfit() 方法中,在生成 gen_batch_output 之后,代码会调用奖励计算函数:
# verl/trainer/treetune_ppo/ray_trainer.py:315
reward_tensor, reward_extra_infos_dict = compute_reward(batch, self.reward_fn)
  1. 奖励函数的定义: self.reward_fn 是从哪里来的?它是由配置文件 verl/trainer/config/r1d-1.5b_deepscaler.yaml 指定的:
custom_reward_function:
path: verl/utils/reward_score/treetune_math_verify.py
  1. 奖励函数的实现: 这就指向了 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)

拿到奖励后,模型需要根据这个反馈进行学习。

  1. 计算优势 (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)估算一个"优势值",告诉模型哪些动作是"好"的,哪些是"坏"的。

  1. 更新模型参数: 计算完优势后,就到了最后一步——更新模型。
# 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 方法。

  1. 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.generateLLM 生成下一个 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() 方法中发生了什么:

  1. 循环开始:
# verl/experimental/agent_loop/delethink_agent_loop.py:102
while True:
# ...
assistant_turns += 1

这个 while 循环就是分块生成的核心。assistant_turns 记录当前是第几个思考块。

  1. 第一轮生成 (Chunk 1):
    • assistant_turns 为 1。
    • 输入 prompt_ids 仅包含原始问题。
    • max_new_tokens 被设置为一个较大的值,即配置中的 data.max_response_length (例如 8192)。
    • 调用 SGLang 引擎生成第一个思考块 response_ids
  2. 上下文裁剪与重构 (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),这就是它的马尔可夫状态。
  1. 后续轮次生成 (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_idstrace_response_ids

第三步:奖励计算 (几乎不变)

奖励计算本身没有变化,但输入变了。

  1. 轨迹重组: 在计算奖励之前,系统需要将分块的轨迹重新"拉平"。这个逻辑可以在 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 拼接成一个完整的、连贯的思考链文本。

  1. 调用奖励函数: 然后,这个完整的文本被送入我们之前讨论过的同一个奖励函数 verl/utils/reward_score/treetune_math_verify.py 中的 compute_score

所以,从奖励函数的角度看,它处理的仍然是一个完整的思考链。它并不知道这个链条是"分块"生成的。

第四步:学习与更新 (感知轨迹结构)

PPO 的更新机制也基本相同,但它现在要从这些"被打断又被重组"的轨迹中学习。

  1. 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 的计算是公平的。

  1. 学习目标: 模型学习的目标变得更加复杂和有趣了:
    • 它不仅要学会解决问题。
    • 它还必须学会在 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 的二次方复杂度诅咒,实现了线性的计算成本,从而使得训练能处理超长推理任务的模型成为可能。