Verl Agent化改造过程中的一万个小细节

向量数据库大模型云存储
  
知乎:https://zhuanlan.zhihu.com/p/1910330901201688247   
(已授权)  

昨天分享了,DeepEyes-端到端RL复刻o3的Thinking With Images能力,今天来填坑。

对DeepEyes训练代码框架开发过程中踩过的坑做个总结,可以配合 https://github.com/Visual-Agent/DeepEyes 一起食用~

技术选型、目标、前置acknowledgement

这个代码在开发之初,立了一些flag:

  • obs部分必须支持动态加载多模数据:这个是DeepEyes的基础,公司里业务上跑训练也有这样的要求;
  • 支持混合训练多种agent数据以及非agent类型的数据,不能把环境交互的逻辑写死在生成逻辑里;
  • 训练效率比较高,并且从单机训练到多机多卡训练,能够比较好地scaling;
  • 尽量开发成插件形式,保证和verl官方版本代码的兼容性,不影响任何verl原本的工程(目前为止已经几次merge verl官方主干分支的改动,除了有一次verl官方丧心病狂地把所有单引号改成了双引号以外,其他几次都没什么conflict);

回过头来看,虽然中间过程中踩了很多坑,但这几个目标都比较好地达成了,除DeepEyes以外,这套代码在R1-Searcher、frozenlake、以及一些内部的场景都做过验证,应该说是一个经受了实战检验的框架。

Loss mask问题

Agent RL训练与传统RL训练最大的区别在于,response中间会包含observation tokens,这部分token不是模型自己生成的,而是来源于环境返回;

对于DeepEyes来说,obs token包括image token,以及和image token相关的一些文本token和special token;

从RL的视角来看,由于这部分token不是由模型自己生成的,不能看作action space的一部分,计算policy loss时需要mask掉;

verl原生只有一个attention mask(后来比较新的版本中定义了response mask),这个attention mask在代码中用到的地方非常多,同时负责处理eos token之后的pad tokens,以及各种masked_mean的计算等等,理论上所有涉及到attention mask的地方都要改,确保attention mask只发挥attention mask字面意义上的功能;

代码指针:

https://github.com/Visual-Agent/DeepEyes/blob/main/verl/workers/agent/parallel\_env.py#L306

Agent GAE问题

大部分人跑agent RL都是GRPO或者reinforce++,即使用到PPO,讨论GAE问题的人也比较少,大部分人直接lambda=1跳过了这个问题,但从更general的角度来说,在0 < lambda < 1 && 0 < gamma < 1的情况下,GAE应该怎么计算,这个问题是需要展开讨论的

这个问题,最早在今年年初的一次讨论中由 知乎@何枝 提出,放一张当时讨论时大佬画的图:

picture.image

  • 从t4到t7是来自环境返回的obs tokens,由于这部分token算loss时会被mask掉,PPO的critic网络对这部分token的value预测一定是不准的;
  • 由于GAE的本质是将TD Error从后向前传播,obs token产生的错误的价值估计,也会传导到前面的token上;

当时讨论了几种实现方式,最后发现只有唯一一种正确的实现方式,就是GAE计算要跳过中间所有的obs token,对应图中,TD Error应该直接从t8传导到t3;

为什么我们发现这是唯一一种正确的实现方式?这里放一段检测GAE是否实现正确的代码,如果你的GAE实现正确,放到下面的compute_gae_advantage_return处,应该是可以通过所有assert正确返回的,而verl官方版本的GAE,以及其他的GAE思路都会触发AssertionError

  
import torch  
import random  
  
gamma = random.uniform(0.0, 1.0)  
lam = random.uniform(0.0, 1.0)  
  
rewards = torch.tensor([  
    [ 0.0, 0.0, 0.1, 0.1, 0.1, 0.0, 0.0, 0.1, 1.0, 0.0, 0.0 ]  
], dtype=torch.float)  
  
values1 = torch.tensor([  
    [ random.uniform(-100.0, 100.0), random.random(), 4.0, 5.0, 6.0, random.uniform(-100.0, 0), random.random(), 7.0, 9.0, 0.0, 0.0 ]  
], dtype=torch.float)  
  
values2 = torch.tensor([  
    [ random.random(), random.uniform(-100.0, 100.0), 4.0, 5.0, 6.0, random.random(), random.uniform(0.0, 100.0), 7.0, 9.0, 0.0, 0.0 ]  
], dtype=torch.float)  
  
eos\_mask = torch.tensor([  
    [ 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0 ]   
], dtype=torch.float)  
  
def compute\_gae\_advantage\_return(token\_level\_rewards: torch.Tensor, values: torch.Tensor, eos\_mask: torch.Tensor,  
                                 gamma: torch.Tensor, lam: torch.Tensor):  
    # your GAE implementation here ...  
    pass  
  
adv1, ret1 = compute\_gae\_advantage\_return(rewards, values1, eos\_mask, gamma, lam)  
adv2, ret2 = compute\_gae\_advantage\_return(rewards, values2, eos\_mask, gamma, lam)  
  
ret1 *= eos\_mask  
ret2 *= eos\_mask  
assert torch.equal(adv1, adv2), f"{adv1=}, {adv2=}"  
assert torch.equal(ret1, ret2), f"{ret1=}, {ret2=}"  
print(f' [CORRECT] \n\n{adv1=}, \n\n{ret1=}')  

代码指针:

https://github.com/Visual-Agent/DeepEyes/blob/main/verl/trainer/ppo/core\_algos.py#L67

其他trivial小细节:verl的reinforce++实现中有这么一行代码,

https://github.com/volcengine/verl/blob/main/verl/trainer/ppo/core\_algos.py#L329

这行代码会把第一个eos token之后的advantage全部mask掉

这行代码不改,reinforce++训个寂寞

mrope问题

mrope是一个Qwen-VL底座独有的东西,用途是在position encoding的层面让VLM对图片和视频的空间、时序、位置信息有更好的感知

picture.image

mrope的原理在report图中已经非常清晰,这里主要讲讲代码层面的问题

由于response部分会包含crop出来的图片,所以response部分的position_ids也必须要按照mrope的方式来计算

  • verl在纯文本不带多模数据的情况下,position ids的shape为 (n*bs, seq_len)
  • verl在Qwen-VL多模态底座的情况下,position ids的shape为 (n*bs, 3, seq_len),其中,中间数值为3的维度分别对应 时序ids、高度ids、宽度ids;

在我们的代码中,为了支持文本多模统一训练,我们把所有的position ids的shape都统一成 (n*bs, 3, seq_len),并且用统一的mrope覆盖了verl原本的position ids;

mrope在多模agent RL中是一个非常容易被忽视的细节,如果你不对mrope做任何处理(或者错误地处理了mrope),代码跑起来一切正常,没有任何报错,甚至模型的输出看起来也不会有什么异常,唯一的影响就是你的评测指标上不去;

代码指针:

https://github.com/Visual-Agent/DeepEyes/blob/main/verl/workers/agent/parallel\_env.py#L285

vllm并行采样的细节

一些比较早的agent RL代码框架(比如 RAGEN-AI/RAGEN,后续有一些其他的工作基于RAGEN二次开发,继承了RAGEN的缺点),它们的采样逻辑是写在最顶层ray_trainer里面的

这样做的好处在于:verl是single controller架构,在ray trainer内部实现采样逻辑,可以不用关心各种分布式调度的细节(所有的脏活都让fsdp workers来干)

这样做的坏处在于:

  • 少数交互流程特别长,采样时间很长的样本会阻塞大部分样本,大部分样本都不得不等待这部分样本返回,才能继续执行下一步的环境交互;

picture.image

  • 打开参数offload的情况下,每次环境交互前需要把vllm engine offload掉,环境交互完再把engine拉起来,这部分耗时非常久,如果跑一些早期的agent RL代码框架,你会发现大部分时间里vllm engine都在反复地offload、拉起、再offload、再拉起。。。;

所以在我们的代码中,环境交互的逻辑放在vllm_rollout_spmd里,这样的代码实现方式,GPU tp group拆得越散,环境交互之间就越少有同步阻塞,整体效率会更高

这里不太严谨地唠一唠vllm的spmd这个东西,vllm在创建engine的时候会fork出来若干个子进程,主进程和子进程交互通信,每个子进程完成在多张GPU上的inference逻辑,这些逻辑在vllm内部实现
从用户的视角来看,用户只操控主进程,不需要关心分布式的代码,就可以完成整个inference流程;

写在vllm底层rollout逻辑中就不得不面对分布式代码的同步异步问题,早期训练中,我们发现只要vllm的tp_size大于1,vllm采样时就有一定概率hang,而且很难复现;

后来万能的工程大佬帮我们解决了这个问题:tp_size>1的情况下,如果你的环境是带有一定随机性的(相同输入不一定相同输出) ,环境交互后,同一个tp group中几张GPU接受的输入token不一样,就会导致hang

修复这个问题只要三行代码:

https://github.com/Visual-Agent/DeepEyes/blob/main/verl/workers/agent/parallel\_env.py#L179

Left padding问题

这个问题是一个多轮agent RL特有的问题,最终 知乎@Mghao 在讨论中给出了一个简单且有效的解决方案:

single turn RL训练时,如果你对response length做一些统计,往往会发现一些极端case:大部分response的长度都比较短(<1k token),极少数response token length会达到10k甚至20k

单轮RL训练时这不是个问题,少量长response不会影响其他样本的训练;

但是多轮RL的情况下,第二轮需要把第一轮的response,加上observation tokens一起拼回到token sequence里,这时,灾难性的事情发生了:

picture.image

由于第一轮出现了一个特别长的response #3,第二轮同一个batch中所有的样本,都只能输出很短的response就会被截断,即使他们的样本本身并没有超长

left padding和batch inference都是vllm内部做的,上层没法控制也不可能控制vllm内部的left padding行为

这个问题会导致agent更加不倾向于调用工具,因为只要调用了工具,就会有长度截断的风险

解决方法很简单:在verl原生的max_response_length基础上,额外加一个单轮response的最大长度控制,从后续实验的经验上来说,这个数值可以设置为max_response_length的二分之一、四分之一、八分之一之类的

代码指针:

https://github.com/Visual-Agent/DeepEyes/blob/main/verl/workers/agent/parallel\_env.py#L118

image zoom-in tool的细节问题

vllm采样代码中,数据中的图片经过preprocessor的处理后,存储在multi_modal_data字段中,图片的一些细节(e.g. 尺寸)经过image processor处理后可能会发生变化

一个很重要的细节是,image zoom-in操作需要在原图上操作 ,而不是image processor处理过的图像

这样做的motivation是:既然模型训练grounding时的坐标不考虑图片的preprocessing,那么image zoom-in也应该绕过processor对图片的处理,直接crop原图,然后把crop出的子图输入到image processor中

所以代码里会多一个origin\_multi\_modal\_data字段,代表未处理过的原始图片,在图像分辨率很高的情况下,这样的处理方式是很有必要的

代码指针:

https://github.com/Visual-Agent/DeepEyes/blob/main/verl/utils/dataset/rl\_dataset.py#L173

其他trivial小细节:

  • Qwen-VL输入图片长宽比超过200会报错,代码中需要拦截agent输出的crop图片长宽比非常极端的情况;
  • Qwen-VL输入图片长宽小于28(一个patch的尺寸)会报错,如果模型输出的bbox小于这个数字,需要对crop图片做resize处理;

一些细碎的边界条件

由于高分辨率图片占的token数很多,即使你设置非常大的response length,Agent RL训练中还是很容易超过context length,所以务必要确保最后一轮的token来自模型采样,而不是环境返回;

最后一轮的token是环境返回的obs token会带来两个问题:

  • 由于loss mask的存在,在最后一个token上的reward会被mask掉,导致没有任何有效reward传导到前面的token上;
  • 被截断以后shape对不上,计算mrope的时候可能会报错;

最大交互轮数设置的太小,模型也会倾向于尽可能少调用工具,机器资源允许的情况下应当尽量放宽最大交互轮数的限制;

PS:看到这里,如果觉得不错,可以来个点赞在看关注 。 给公众号添加【星标⭐️】不迷路!您的支持是我坚持的最大动力!

欢迎多多关注公众号「刘聪NLP」,加入交流群,交个朋友吧,一起学习,一起进步!

0
0
0
0
关于作者
关于作者

文章

0

获赞

0

收藏

0

相关资源
CV 技术在视频创作中的应用
本次演讲将介绍在拍摄、编辑等场景,我们如何利用 AI 技术赋能创作者;以及基于这些场景,字节跳动积累的领先技术能力。
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论