数据库里有一个很少被公开承认的事实:优化器给出的「最优计划」,经常不是最优的。Postgres 的代价模型依赖统计信息估算中间结果行数,一旦估算偏差一个数量级,连接顺序就会彻底走错,一条查询从毫秒变成几十秒。
有个叫 QoRL 的开源项目,把这件事做成了强化学习问题,而且刻意选了最小的模型:一个 4B 参数的稠密模型,训练花费约 1200 美元。结果是——在 Join Order Benchmark 的 113 条重连接查询上,几何平均加速 1.81 倍,113 条查询的总延迟降低 44.7%。
它的做法值得完整拆一遍,因为它把「智能体强化学习怎么才能不塌」这个问题解决得很干净。
一、先想清楚:模型的动作空间是什么
很多「LLM 做数据库优化」的尝试死在一个地方:模型给出的是自然语言建议,没人知道怎么执行。QoRL 的第一步是把这个动作空间形式化。
模型不写 SQL,而是输出一个结构化的 PlanAction 对象。这个对象被编译成 pg_hint_plan 的 hint 注释,跟在 SQL 后面:
SELECT /*+ Leading(o c) NestLoop(o c) SeqScan(o) */ ...
这一行注释把三件事钉死:表 o 先于 c 参与连接、连接算法用嵌套循环、o 走顺序扫描。数据库必须照做,没有「优化器觉得不划算所以改了」的余地。
💡 这是整个方案能成立的关键:只有动作能被强制执行,执行时间才是干净的可信反馈。否则你测到的永远是「模型建议 + 优化器二次决策」的混合结果,噪声无法归因。
二、qo-agent 的六个工具
模型面对的是一个真正的智能体环境,六个工具构成一条从侦察到决策的完整链路:
| 工具 | 作用 |
|---|---|
inspect_relation | 查看表的行数、页数等物理信息 |
get_column_stats | 查看列级统计(直方图、n_distinct) |
get_plan | 获取当前默认执行计划与其代价估算 |
evaluate_candidate | 实际执行一个候选计划并返回耗时 |
keep_default | 主动放弃改计划,保留默认 |
finish | 提交最终计划并结束回合 |
值得注意 keep_default 和 evaluate_candidate 的存在。前者让模型有「不改」这个合法选项——这很重要,因为很多查询的默认计划本来就是对的,强行改动只会变慢。后者让模型能在回合内自己试错,而不是把全部试错都推给训练循环。
三、奖励设计:三个必须堵住的坑
这一节是整个工作里最值得抄的部分。
坑一:测量噪声。 查询执行时间受页缓存状态影响极大。作者的测量环境做了三件事:
- 先把查询预热到
shared_buffers命中稳定,让共享读块数趋近于零; - 每次评估都交错执行三组「候选、默认」配对,而不是先跑完候选再跑默认;
- 用中位数比而不是均值比,并把比值裁剪到
[0.1, 10]区间。
配置层面还有一个反直觉的细节:测量机把 shared_buffers 设为 2GB。这个值不是为了性能,而是为了降噪——让缓存命中足够稳定,减少方差。
坑二:keep_default 的奖励漏洞。 如果模型选择保留默认计划,奖励直接给 0。无效候选扣 0.1,与默认计划指纹相同的候选扣 0.02。这两条惩罚看起来微不足道,但它们是梯度信号的方向盘。
坑三:GRPO 的基线塌陷。 这是最隐蔽的一个。
原版 GRPO 用同组 rollout 的平均奖励作为基线。设想一组八个候选,其中六个都输出和默认计划等效的东西(速度比 1.00),只有两个真的更快。这时组内平均奖励被低值拉低,那些「等效默认」的候选因为略高于平均,反而拿到正优势——模型被训练成倾向于输出默认计划,而这恰恰是我们要它改掉的行为。
作者的修正叫 anchored GRPO:以默认计划的实际执行时间为锚点,直接计算每个候选相对锚点的优势。候选快于默认才有正优势,否则负优势。基线不再来自组内统计,而来自一个固定的、语义明确的参照物。
⚠️ 这条经验可以迁移到任何「对照组天然存在」的 RL 场景:只要环境里有一个默认行为,就别用组内均值当基线,用默认行为的实测值当锚点。
四、训练出来的效果
数据很坦率,包括中间那些不好看的部分。基础模型几乎不会用工具——113 条查询里只有 15 条产出有效轨迹,其中 6 个是新计划,其余全是 1.00 倍。
| 阶段 | 有效轨迹 | 几何平均加速 |
|---|---|---|
| 原始 4B 模型 | 15 / 113 | 基本为 1.00 倍 |
| SFT 1 epoch | 48 / 113 | 0.72 倍(变慢了) |
| SFT 2 epoch | 85 / 113 | 1.08 倍 |
| 补 300 条新轨迹后 2 epoch | — | 1.16 倍 |
| RL 600 更新 | — | 1.35 倍 |
| RL 1200 更新 | — | 1.41 倍 |
| 最终(best-of-15 候选) | 101 / 113 | 1.81 倍 |
两个观察。第一,SFT 第一阶段会把事情做坏:模型学会了输出格式,但还没学会判断,于是产出了一堆比默认更慢的计划。这是格式学习期必然的代价,不要在这个阶段就下结论。
第二,最终评估的 1.81 倍里包含了 best-of-15 的候选筛选,也就是允许在 15 个候选里挑实测最快的那个。这在离线场景完全合理,但如果你要的是单次推理就直接出结果,应该看 1.41 倍那一行。区分这两个数字很重要,否则上线后会失望。
作为横向参照,前沿模型在这个任务上的表现是:GPT-6 Astra 五次候选取得 2.54 倍几何平均加速,Qwen 3.8 2.4T 五次候选取得 2.26 倍。
五、复现路径
依赖三样东西:Postgres 加 pg_hint_plan、Prime Intellect 的 prime-rl 训练器和 renderers 格式转换工具,以及一个能稳定跑 Postgres 的测量机。
第一步准备候选模型与环境:
# 基础模型(4.66B,LoRA 只训 21.2M 参数)
# 权重:empero-ai/Qwen3.8-4B-Distill
# 代码:github.com/polyphilz/qorl (含 qo-agent 与 schemas)
# Postgres 侧:加载 IMDb,安装 pg_hint_plan
# shared_buffers = 2GB (关键降噪设置)
# work_mem = 4MB
# 建议起 4 个容器轮换,每个 4 核 / 8GB
第二步生成教师轨迹并蒸馏:
# 用前沿模型通过 qo-agent 生成约 420 条轨迹
# 400 条训练 + 40 条验证,过滤掉直接 keep_default 的样本
# 用 renderers 转成 Qwen 格式,做好 loss mask 与 unroll
第三步 SFT 两到三个 epoch。作者在单张 RTX 3090 上跑第一个 epoch 用了 4 小时,换到 H100 后每 epoch 约 45 分钟。
第四步替换训练器的优势估计为 anchored 版本,接上测量环境开跑。训练机用两张 H100 跑 vLLM 推理与训练器,测量机在本地跑 Postgres,两端用 Tailscale 组网——这个拓扑的好处是测量永远发生在真实硬件上,而不是被训练集群的负载干扰。
六、该有的怀疑
第一,绑定很深。 它依赖 pg_hint_plan 才能把计划钉死,换到 MySQL 或别的引擎需要各自等价的注入点。这不是通用方案。
第二,泛化未验证。 训练集是 CEB 的 13646 条合成查询,测试集是 JOB 的 IMDb 数据。换一个完全不同的 schema 与查询分布,需要重做教师轨迹和训练。别指望零样本迁移。
第三,奖励信号依赖真实执行。 这意味着每一条训练样本都要真的跑一次数据库。相比纯文本 RL,数据吞吐量天然受限,扩规模的瓶颈不在 GPU 而在测量集群。
第四,1.81 倍不是单次推理的结果。 如前所述,它包含 best-of-15 筛选。生产环境要权衡候选数量与延迟。
结语
QoRL 最值得带走的不是「4B 能打赢 Postgres 优化器」这个结论——它在特定基准和特定假设下成立。真正可迁移的是三件事:
用可强制执行的动作空间换取干净的反馈信号;用默认行为的实测值当锚点而不是组内均值当基线;把测量噪声当作一等公民来设计实验环境。
这三条放到任何智能体强化学习场景里都成立,而且都不需要更多的算力。