用 TRL 和 OpenEnv 训练代码模型绘制水彩画
8月23日,Surya Narreddi 发布了一段精美的视频,展示了由语言模型绘制的水彩画。该模型通过 p5.brush 编写 JavaScript,这是一套"为 p5.js 增添自然绘画工具"的库。视频迅速走红,截至撰写本文时浏览量已超150万。
该视频附带了一篇博客文章,介绍了该项目早期、范围较窄阶段的训练方法——绘制特写花朵,而非视频中完整的构图,遗憾的是当时尚未公开任何开源产物。他的网站上提到完整技术报告即将发布,值得关注。原始创意来自他那边,源自艺术与设计的视角,而在这方面他的功力远在我之上。我则是在工程侧尝试复现这一思路,将所有组件逐一开源。
注:想了解项目背景,可以观看他的论文答辩视频。
本文中我尝试使用 TRL 和 OpenEnv 来复现他的想法。参考池化数据集、RL 环境、训练脚本以及训练好的模型,全部开源。
整个流程在 Hugging Face 上端到端运行:
- 在 Jobs 上进行训练
- RL 环境和评分模型作为 Spaces 托管
- 通过 Inference Providers 实现成对判官
- 所有产物均存放在 Hub 上,汇总在这个集合中
hf jobs uv run train/watercolour_grpo.py --flavor h200 --timeout 48h --secrets HF_TOKEN -- \
--env-url https://<you>-watercolour-env.hf.space \
--model Qwen/Qwen3.5-35B-A3B --lora --all-linear --bf16 --gradient-checkpointing \
--subject 'a peach hibiscus' --references 4 \
--top-p 0.95 --top-k 20 \
--lr 5e-5 --lr-scheduler constant_with_warmup --warmup-steps 5 \
--scale-rewards none \
--steps 110 --n-episodes 240 --num-generations 8 \
--per-device-batch-size 1 --gradient-accumulation-steps 8 \
--max-completion-length 8192 \
--run-tag my-run --out <you>/watercolour-grpo --push-to-hub
这篇文章的其余部分讲述的是抵达这一步的过程,每一步的代码都在这个仓库里。
我基本是按照原博客的步骤一步步来的,只在必要时做了调整。所有自己的想法都先列了个清单,没放进实验里——这份清单最终变成了结尾的"下一步想试的",旁边还附有完整的已发布资源列表。如果你已经读过他的帖子,关于任务框架和奖励设计的内容你会很熟悉。本文的新内容是开放的实现、人工标注的样本池,以及三种奖励混合方案的训练与对比,从你需要构建的 RL 环境开始。
为什么大家喜欢它
这些画作笔触松散、略带瑕疵,透着手工的温度,而当下的图像模型却总在产出完美(统计意义上的平均)的图片。我猜测正是这种反差让视频走红。它让我想起生成式 AI 艺术的早期——那时人们探索的是媒介本身。DeepDream(2015)本是个调试工具,却被人们玩成了艺术;Edmond de Belamy(2018)这类作品出自艺术家对 GAN 能力的试探;而 Mario Klingemann 等艺术家则在那个年代用神经网络绘制梦幻肖像。
这个项目更接近那个时代。Surya 在论文中描述了通往这里的路径。他最初通过提示词驱动文本到图像模型,提示词是唯一可调的杠杆,而增加细节带来的控制力也有上限。训练模型本身则能走得更远。想法的另一半在于媒介——模型会生成一段约 150 行的 JavaScript 程序来绘制图像。模型的输出是代码。你可以阅读它、修改它、重新运行它,每一笔刷画的决策都清晰可见。而风格则来自一种限制:模型仅被允许使用该库的十个方法。下文会详细介绍。
在同一时期,Anna Ridler拍摄了数千朵郁金香,逐一张手工标注,将整个数据集本身作为艺术品展出,后来又在此基础上训练了一个模型。我是在构建这个项目时,由 AI agent 引用的文献中发现了她的作品。我很喜欢,因为这个项目做着类似的事——手工筛选一组图像,然后基于它们进行训练。
对品味做强化学习
大多数近期针对语言模型的 RL 工作,使用的奖励都是可验证的——例如答案确定的数学题、能通过测试的代码,或是便宜且能判断对错的评分器。这个项目更接近更早的先例 RLHF,模型从人类偏好中学习奖励函数。
这里的奖励是审美偏好。没有标准答案。这个项目的真正问题是:你能否对品味做强化学习。
奖励函数,按他的博客定义,也由我搭建的 RL 环境实现:
| term | weight | what it measures |
|---|---|---|
gate |
0.05 | 草图能编译、能绘制内容、不作弊 |
length |
0.05 | 对更长代码片段的软性引导 |
| pairwise judge | 0.60 | 风格评估,与从参考库中抽取的样本对比 |
| HPSv3 | 0.30 | 渲染结果的美学评分 |
HPSv3 是一个开源的 7B 偏好模型。输入一张图像和一段文字描述,它会给出一个分数,表示普通人对该图像的偏好程度。该模型在大量人类图像对选择数据上训练而成,因此其分数是许多人品味的均值。pairwise judge 使用的是 Qwen3-VL-30B-A3B-Instruct, 一个通过 HF Inference Providers 调用的通用视觉模型。pairwise judge 将候选画作与从参考库中随机选出的四幅参考图并列展示,并根据文字描述(如溢色、半透明薄涂、柔和边缘)判断权重,两种排列顺序均比较,最终分数以候选画作获胜的比较占比计算。它的唯一标准是参考库,因此其分数本质上是我个人品味的编码体现。
以上是 Narreddi 收敛得到的权重。参考库在此定义了品味。这让工作重心从调参转向构建决定何为美的数据集。
我用这套奖励训练了三组实验,差异仅在于两个模型 judge 之间的权重分配:
| run | pairwise judge | HPSv3 | role |
|---|---|---|---|
judge-led |
0.60 | 0.30 | 原始配比,在第 110 步停止 |
hps-led |
0.30 | 0.60 |
hps-only我先从 hps-only 入手,验证整个管线确实能学到东西。一旦奖励开始上升、各项指标也趋于健康,就没有理由继续跑更久了,于是转而启动两个更长的实验。这些长跑要回答的问题是:你能把 HPSv3 多少能力交给成对判审模型?判审模型的权重越高,奖励就越代表我的品味而非大众的平均水平,优化难度也相应增加。顺便一提,如果推得太远或者你的风格偏离平均水平太远,模型可能会完全停止学习。
幸运的是,模型并没有停止,两个带成对判审模型的实验都成功学到了东西。人工评级的样本池至少从指标和最终画作来看,能够引导策略。具体数据如下。
免责声明。如果使用前沿模型,它已经能根据提示生成绘制水彩画的 JavaScript 代码。这正是我们的起点。这里的重点是让一个小模型在结合个人艺术偏好的情况下完成这项任务。
你需要构建的 RL 环境
这个环境封装了模型与奖励之间的所有环节,包括模型用来绘画的 JavaScript 库、约束其行为的系统提示词、渲染每张草图的无头 Chromium,以及防止作弊的检查机制。
这个库承担的工作比表面看起来更多。
p5.brush 由
@acamposuribe 开发,模拟的是绘画媒介而非单纯绘制形状:颜料会溢出填充区域、纸张带有纹理、笔触具有质量、流场会牵引笔刷运动。当模型调用 brush.fillBleed(0.25) 时,它实际是在决定墨迹晕染的范围。
注。 p5.brush 的作者早在这一切之前就已经在尝试教会机器绘画。2022 年,他创作了一组生成艺术作品,其中隐藏着一本日记,记录着他教 p5.js 像孩子一样画画的过程:"它 barely 能使用蜡笔……它连简单的指令都无法遵循。今天就到这里吧,非常令人抓狂。"这组作品原计划有三件,他只完成了两件。当 Surya 的视频走红时,他引用了那段话,分享了那本日记,并表示这部作品是第三件自行到来的作品。
p5.brush 暴露了 47 个方法,但提示词只允许使用 10 个:scaleBrushes、noStroke、fill、noFill、fillBleed、fillTexture、beginShape、vertex、endShape 和 circle。其余 37 个方法中那些线条、排线、自定义画笔之类的东西,都会破坏水彩质感。有了这十个方法,模型只能绘制填充形状,而库会给每个形状都加上晕染效果。
draw() 内容及其渲染结果。注释是模型自己生成的。奖励值 0.864,129 行,第 22 步。每幅作品的完整源码均在 rollouts 数据集中。他的博客文章帮我省了很多时间,否则我本会浪费在提示词的反复迭代上。冗长的 API 文档会让模型发明出不存在的方法,而他的 200 次 GEPA 迭代最终收敛到了一个无文档的严格白名单。我遇到了同样的问题,便手动编写了白名单。我在他的方案基础上只加了一句话:每片花瓣涂两到三遍,先涂一大遍,再在里面涂一小遍更浓的。这个小改动让我的输出色彩丰富了很多。
门控(gate)是最后一块拼图。草图必须通过编译、改用库调用而非直接的 p5 函数、在画布上渲染出真实的颜料效果,并且不能试图欺骗评分器——比如在画布上写字。注。 如果你第一次听说 GEPA,它是一种自动提示词优化器。 语言模型会用通俗的语言反思当前提示词的失败之处,并提出更好的版本,然后循环往复。
奖励池(reward pool)即奖励函数
奖励池由 178 幅画作组成,按我个人偏好分为两个档位:love(喜爱)和 okay(尚可)。它们实际上全部由模型生成。四个开源模型通过 Inference Provider 调用,各自生成了 p5.brush 草图,每幅都基于一张来自 iNaturalist 的真实且开放许可的木槿花照片。一个视觉模型对每份草图给出了书面反馈,经过三轮迭代优化。最终的每幅作品都由我单独打分,入选的共 178 幅。
| 生成模型 | 画作数量 |
|---|---|
| GLM-5.2 | 64 |
| Kimi-K3 | 57 |
| Qwen3-Coder-Next | 35 |
| Qwen3.5-122B-A10B | 22 |
这里选择了四个不同架构系列的模型,以测试它们各自不同的风格。这四个是在快速可靠性检查中每次都能产出有效草图的开源模型,另有两个候选模型因未通过检查而被剔除。如果你打算构建自己的奖励池,可以选择其他模型。
这两个档位在实际奖励计算中发挥着重要作用:当配对比较的裁判抽取四幅参考作品时,其中一半来自 love,一半来自 okay,使得策略始终面临一些时有机会胜出的对手,且无论对手来自哪个档位,胜利带来的奖励相同。这是我少数几个刻意做出的改动之一:原始方案仅与顶层档位对比,而我保留了较低档位,确保早期的弱策略仍能获得有效的训练信号。
再跑一次碰碰运气
在最终跑通之前,奖励曲线经历了漫长的平台期。如果你曾尝试在没有开源配套代码的情况下复现某篇论文或博客,大概会深有同感。每一次实验都在验证我对故障原因的推测。由于单次运行耗时较长,我往往还在分析上次结果时,就已经把下一轮排进队列了。和以往一样,答案始终是“先跑通一个简单版本,再逐步迭代”。最初成功的那个实验其实只是个基础控制任务——没有浏览器,也没有评审器,最终发现问题出在我的学习率设置得过低。
另一个费了我不少时间才找到的问题是正确调整 LoRA 参数。默认的 target_modules 列表假设是密集模型,而 Qwen/Qwen3.5-35B-A3B 是一个混合专家模型,它对其投影层的命名方式不同,导致 adapter 在四十层中只训练了十层。解决方法是将 target_modules 改为 all-linear,这样能覆盖所有线性层。该架构中的路由专家是融合张量,all-linear 会保留其冻结状态,但其余部分都能获得 adapter,这就足以完成学习了。
修复涉及 TRL 的 GRPOTrainer 中的四项改动:
| 设置 | 改前 | 改后 | 原因 |
|---|---|---|---|
| 学习率 | 2e-5 | 5e-5 | LoRA Without Regret 对 GRPO 使用的上限值 |
| 调度器 | linear |
constant_with_warmup |
线性衰减在前中期就把学习率消耗完了,导致奖励始终没有起色 |
scale_rewards |
group |
none |
单个 gate rejection 在缩小组内其他所有样本的优势 |
target_modules |
手动列表 | all-linear |
覆盖所有线性层 |
这四项改动解锁了首次成功的 run(hps-only),奖励曲线明显上升。
采用该配置后,三个实验均成功学习。两个含 judge 的实验原计划运行 200 步,但在 110 步时停止,此时奖励仍在缓慢上升。每步耗时 15~18 分钟,且各配置间的对比趋势已趋于稳定,为节省算力便提前中止。各实验首尾三分之一的平均群体奖励如下:
| 实验 | 步数 | 前 1/3 | 后 1/3 | 增量 |
|---|---|---|---|---|
hps-only |
60 | 0.58 | 0.71 | +0.13 |
judge-led |
110 | 0.45 | 0.72 | +0.27 |
hps-led |
110 | 0.57 | 0.82 | +0.24 |
三条曲线的走势印证了我主观偏好在其中扮演的角色。judge 权重越高,起步越低,攀升过程也越抖动。`judge-led` 在前 30 步几乎停滞,之后才开始上升。这与解决调试问题的思路如出一辙:先把问题规模压缩到能学动的程度,再逐步把难点加回来。
在两个使用了 pairwise judge 的实验里,该项指标本身也呈上升趋势。随着训练推进,模型在对比池中的胜率持续提升,而这是 `hps-only` 实验无法做到的。任意实验中均未出现 group 全员奖励趋同的情况,成功规避了 GRPO 典型的梯度抹除故障。好奇的同学可以在仓库中查看各分项指标的曲线数据(CSV 格式):HPSv3、画作覆盖率、熵值。
完整的启动命令、硬件配置,以及将该实验切换为另外两个版本所需的环境变量,均记录在该配方中。
它真正学到了什么
每次运行中,模型最先学会的都是**停止产出糟糕的画作**——那些几乎空白的画布和不成形的色块,总奖励得分低于 0.3。在 `hps-only` 中,群体均值提升的四分之三来自糟糕画作变得罕见。在 judge 训练的组里,这一骤降更为明显:`judge-led` 的三个阶段里,0.3 分以下的 rollout 从 99 降至 16,`hps-led` 则从 37 降至 4。
既然奖励机制部分取决于我的个人品味,那么最后以观众的身份来评判,也算公平。在我看来,judge-led 这次运行最终呈现出最多的多样性与艺术趣味。hps-led 能画出逼真的水彩画,但其佳作都带有柔和的湿画技法风格,几乎自成一体。hps-only 收敛最快,但大多数画作都采用相似的色彩。你可以在画廊中自行评判,那里收录了每次运行的所有画作,支持按步骤和奖励排序。
基础设施的复杂性
这个项目本质上是在搭建基础设施。一次运行需要训练器、两个Space、推理路由器和websocket持续稳定运行数小时,而任何一处故障都会悄无声息地在别处产生错误的数值。一半的工作量都用来验证读取的数值是否与实际情况相符。
基础设施故障导致奖励被错误地录入为零。 超时渲染或未作答的评分器,在组内得分与糟糕画作相同——均为0.0分。所有运行中这类情况约占1.5%的rollout,最坏情况下达5.2%。这会让模型学习噪声数据,因此现在这些路径均返回None,对应的rollout将被排除。
我还发现OpenEnv中存在bug,已提交修复到上游。 客户端维护一个持久化websocket连接,但远端关闭的socket却被缓存下来,导致后续每次调用都失败,尽管环境本身正常运行。这个bug导致两次未完成的运行。修复已提交到上游,使用该修复的run从此运行正常。
每一步的奖励取决于它抽到了哪些参考图。两两比较的评审器每步采样四张参考图,因此每一步面对的对手都不同,有些抽签确实更难。GRPO本身相对稳定,因为优势值在组内计算,遇到难样本时整个组会一起被拉低。但我观察到的曲线却不够稳健,某些看起来像是表现不佳的步骤,其实只是抽签结果不好。上图就是一个例子。第12步的得分比第11步低了半分,主要是因为它抽到了本轮最难的一组参考图,而两者的画作本身看起来相差无几。
成本几何
取整数字,仅统计完成运行的开销。
| 组件 | 所需资源 |
|---|---|
| 训练器 | 1 块 H200。60 步约 18 小时,110 步约 34 小时 |
| HPSv3 | 一个 a100-large Space,需全程保持运行 |
| 渲染环境 | 一个 cpu-upgrade Space,可舒适地完成实时渲染 |
| 两两比较评审器 | Qwen/Qwen3-VL-30B-A3B-Instruct 的 Inference Providers 配额 |
| 图片池(一次性) | 来自 iNaturalist 的开放授权照片、四台生成模型的 Inference Providers 配额,以及你自己的标注工时 |
每一步包含 8 次 rollout,耗时十五到十八分钟,其中 70% 到 80% 花在渲染上。单次渲染耗时 69 到 96 秒,而截止时间是 90 秒。部分原因是这个 Space 没有 GPU,Chromium 以软件方式渲染 WEBGL 画布,p5.brush 的晕染和纹理效果又是沉重的像素运算。即便如此,我预期它会更快一些,但还没找到根本原因。
一个评分器的开销甚至可能超过训练本身:HPSv3 必须全程在线,所以运行结束后记得暂停 Space 或设置睡眠定时器。
所有服务都运行在 HF Jobs 上,环境部署为一个 Docker Space,指标数据则记录在 trackio 中。
下一步的尝试方向
本项目的规则是完整复现现有方案,而非改进它,因此在过程中积累了一份未尝试的创意清单。以下是我真正会去尝试的方向,按证据充分程度排序。
多步训练,让模型能看到自己画出的内容。 这是我会优先考虑的事。原始博客采用的是单轮训练,所以我也是单轮训练;在这种设置下,模型相当于闭着眼睛作画。没有图像输入,唯一反馈只有一个数值。而反馈循环有效的证据就来自这个循环本身:参考画作来自模型在视觉评判者指导下迭代三轮的结果,后续轮次的作品质量更高;定义奖励的材料正是通过一个策略模型从未参与过的循环生成的。
更小的模型。 有证据表明 35B 已超出所需规模。在我的辅助实验中,4B 模型已经能写出通过筛选的有效草图。如果 4B 就能学会这项任务,实验成本将降低一个数量级。
清单上的其他想法包括:在开始 RL 之前先在池化来源数据上做 SFT、显式奖励颜料的使用、随训练推进逐步提高评判参考的混合复杂度直到只剩下 love、扩展十种方法的白名单以获得更丰富的视觉效果(我在这方面的尝试曾破坏更多草图并破坏水彩质感),以及通过对同一图像评分两次来验证成对评判的一致性。
这种方法并不局限于画花。Alex Yango 用同样的机制画了动物,Brendan Hogan 训练了画布动画 ,以人工评分的视频片段作为参考池。在这之前,我也尝试过类似的东西——用 Simon Willison 的 pelican 基准,将代码渲染为图像并打分。
而贯穿所有这些工作的核心问题是:这个项目无法回避这一点。**由模型生成的 178 幅画作,定义了本次训练的模型所认为的「美」。** 参考池是瓶颈所在,也是整个流程中无法给出原则性答案的一环。
与原始方法的差异
对于任何想要复现此工作的人,这里有两处对 Narreddi 方案的有意改动,文章前面已做说明:
- 成对比较的评判者从
love和okay各取一半参考,而不是只对比顶层作品,这样早期的弱策略也能获得信号。 - 提示词里加了一句小小的技巧:每片花瓣画两到三遍,先大幅铺色,再叠加一层更厚实、不透明的小笔触。
其余的——LoRA 使用 all-linear、基础设施故障返回 None 而非 0.0、在步骤 110 停止评判运行——都是我根据博客未明确说明的部分自行做的决定。由于他的实现未开源,我无法判断这些是否与他的选择一致,还是有分歧。
全部内容均已开源
| 内容 | 位置 |
|---|---|
| 方案说明及复现方法 | 02-watercolour/ |
| 参考池(含每张源草图) | watercolour-reference-pool |
| 可直接复制的环境 | watercolour-env |
| 可直接复制的 HPSv3 评分器 | watercolour-hpsv3 |
watercolour-grpo-hps-only · watercolour-rollouts-hps-onlywatercolour-grpo-judge-led · watercolour-rollouts-judge-ledwatercolour-grpo-hps-led · watercolour-rollouts-hps-ledwatercolour-galleryjudge-led · hps-led · hps-only,以及 results/ 目录下的 CSV 文件本文中的各 rollout 数据均可从已发布的数据集重新计算得出,无需依赖任何 Space 保持在线。
方法与核心创意来自 Surya Narreddi,相关库由 Alejandro Campos Uribe 开发。