qorl 技术解读:用 SFT 加强化学习把 4B 模型训练成 Postgres 查询规划器
训练一个 40 亿参数的开源小模型,让它在 Postgres 默认查询计划之外找出更快的执行方案:工程师 Rohan Bansal 的 qorl 项目把这件事做完了,并公开了全部代码与训练账本。在数据库评测基准 Join Order Benchmark(JOB)的 113 条 join 密集查询上,最终模型把整套工作负载的总延迟压低了 44.7%,单条查询的几何平均加速达到 1.81 倍,113 条查询里 68 条显著变快、0 条显著变慢。整个训练支出合计 1,200 美元:约 800 美元租用一台双卡 H100 节点跑了 95 小时,约 400 美元用于调用 GPT-6 Astra 生成教师示范轨迹。

查询优化器难在哪
Leis 等人在 2015 年发表《How Good Are Query Optimizers, Really?》,2025 年又用同样的标题追问了一次。十年过去,结论没有变:主流查询优化器仍然会在相当比例的查询上选出明显偏慢的执行计划。
难点集中在 join 顺序。join 顺序问题已被证明是 NP-hard 的,而 Postgres 还要在不真正执行查询的前提下做选择。以 IMDb 数据集上一个三表查询为例:每对 join 可以用 hash join、merge join、nested-loop 三种算法,考虑外内方向共有 8 种组合,每张表又有顺序扫描、索引扫描、index-only、位图扫描 4 种读法,同一个查询存在 4,608 种执行方式。Postgres 用动态规划(12 个以上 join 时切换为遗传算法)剪枝搜索空间,再用代价模型给剩下的计划打分。
代价模型的输入是基数估计,而基数估计依赖一个关键假设:均匀分布。Postgres 从 pg_statistic 读取列的高频值、频率和直方图,遇到 join 时直接把第一张表的取值频率套用到第二张表上。这个假设一旦失真,错误会沿着 join 树级联放大。文中给出的例子:假设 5% 的公司是日本公司,按均匀分布先过滤公司表再连接电影公司表约产生 10 万行;但如果这 5% 的公司实际贡献了一半的电影,同一个 join 就会产生 100 万行,代价模型选中的顺序反而比另一条路线多处理 4 倍的行。
直接改 Postgres 的代价模型意味着改源码,qorl 选择的干预点是 pg_hint_plan 扩展:在 SQL 前面加一行注释,就能强制指定 join 算法、join 顺序和扫描方式。
/*+ HashJoin(a b) SeqScan(a) */
EXPLAIN SELECT * FROM pgbench_branches b
JOIN pgbench_accounts a ON b.bid = a.bid
ORDER BY a.aid;问题的表述随之确定:不追求在一次性查询上和 Postgres 比拼规划速度,而是针对反复执行的分析型查询,让模型离线找到更好的计划,把前期搜索成本摊销到后续的成千上万次执行里。
代理框架与基准
qorl 的基座是 Empero 蒸馏的 Qwen3.8-4B-Distill(用 Qwen 3.8 的 2.4T 模型做教师蒸馏得到)。围绕它,作者构建了名为 qo-agent 的轻量代理框架,给模型六个工具:
| 工具 | 作用 |
|---|---|
inspect_relation | 列出表的列、类型、索引与估计行数字节数 |
get_column_stats | 读取 1 到 8 列的规划器统计信息 |
get_plan | 获取默认计划的估计,或候选计划的执行计划 |
evaluate_candidate | 校验候选计划并实际执行计时 |
keep_default | 直接采纳 Postgres 默认计划 |
finish | 提交选中的候选,结束搜索 |
模型以结构化 JSON 产出 PlanAction,由框架编译成 hint 注释后交给 Postgres 执行。
训练与评测数据用了两个标准基准:CEB(Cardinality Estimation Benchmark,13,646 条查询、16 个模板)用于训练,JOB(113 条查询、33 个模板)用于验证。两者都构建在同一个 8.5 GB 的 IMDb 数据切片上。为排除「训练时见过测试题形状」的疑虑,作者把每条查询还原成去除过滤条件的 join 图拓扑做比对:JOB 的 33 个拓扑与 CEB 的 12 个拓扑零重叠,无需过滤。作者对「训练和测试都在 IMDb 上」的处理是承认它:目标本来就是让模型吃透一个具体数据库的分析型负载,而不是泛化到所有数据库。
测量噪声工程:先让 Postgres 可信
强化学习的奖励完全来自真实执行时间,测量噪声直接决定训练信号的质量。作者为此先做了一轮校准,这一段是全文对运维工程师最有参考价值的部分。
测试机 FLOPper 有 16 个物理核、64 GB 内存和 2 TB NVMe,跑 4 个 Docker 容器各装一个 Postgres,每个分 4 核、8 GB 内存上限。每条查询先预热几次,再用 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS, FORMAT JSON) 观察两个计数器:shared hit blocks(命中 Postgres 自身缓存)与 shared read blocks(向 Linux 页缓存要页面)。两组计数与上一轮相差都在 2% 以内且执行计划未变,才算「热身完成」,随后连续测 20 次取时间。
第一轮校准暴露了两个问题。其一,128 MB 的 shared_buffers 装不下 8.5 GB 数据集的一半工作集,一半查询在两次预热后就稳定了,但 shared read blocks 稳定在一个很大的非零值上,稳定不等于驻留。其二,查询 job-13b 的 20 次测量分裂成两团:14 次落在 186 到 204 毫秒,6 次落在 227 到 253 毫秒,变异系数 10.3%。这种双峰分布对训练是致命的:奖励判定采用「三对交错的(候选, 默认)测量取中位数、差异小于 5% 判平局」的策略,而对 job-13b 这样的查询,即使候选计划与默认完全等价,也有约 20% 的概率因抽样落入慢团而被误判成 14% 到 26% 的「提速」。
作者干脆放弃了变异系数,改为直接计算「误判率」:用滑动窗口在 20 次测量上模拟所有交错配对组合(每查询 120 种可能),统计无操作候选被判为非平局的比例。据此对两个内存参数做了四组校准:
| shared_buffers | work_mem | 无操作误判率(两次) | p90 查询 | 变异系数中位数 | 总时长 |
|---|---|---|---|---|---|
| 128 MB | 4 MB | 5.0% / 5.4% | 13% / 20% | 2.3% | 95 s |
| 2 GB | 4 MB | 1.8% / 1.2% | 1.3% / 0% | 1.1% | 60 s |
| 128 MB | 32 MB | 7.0% / 6.6% | 20% / 23% | 2.6% | 94 s |
| 2 GB | 32 MB | 1.7% / 1.3% | 0% / 0% | 1.2% | 60 s |
结论出乎直觉:work_mem 对噪声没有任何影响,shared_buffers 承担了全部权重。锁定 2 GB 之后,中位数查询的 shared read blocks 归零,job-13b 的变异系数从 10.3% 降到 0.9%,20 次测量全部落在 7 毫秒区间内。附带的好处是全部 113 条查询的总时长从 95 秒降到 60 秒,训练速度也跟着提上来。
教师对照与离线蒸馏
动手训练前,作者先验证了前沿模型能否玩转这套框架。在 10 条 JOB 查询的小样本上:GPT-6 Astra 只允许提交 1 个候选时几何平均加速 0.85 倍(还会产生 3 条回归),放开到 5 个候选后达到 2.54 倍、10/10 全部得分;Qwen 3.8 2.4T 在 5 候选设定下为 2.26 倍。单候选到多候选的巨大落差说明模型在连续执行候选的过程中做了上下文内学习,这决定了后续走多轮代理路线而非训练一次性输出。
教师轨迹最终选了 Astra 而不是 Qwen 3.8 2.4T,权衡点有二:Astra 单轨迹平均上下文 22,009 token,Qwen 3.8 2.4T 是 45,144 token(单轨迹最大 73,608),而最初的单卡 RTX 3090 只有 24 GB 显存,序列长度必须压在 5 万 token 以内;Astra 走 API 只提供推理摘要而非原始推理 token,训练 off 推理摘要可能拉低学生表现(《How to Steal Reasoning Without Reasoning Traces》一文的核心发现,作者把 trace inversion 列为备选补救),后来的结果显示这个担忧没有成真。
SFT 阶段是标准的离线策略蒸馏:先用 Astra 跑 120 条轨迹(100 训练、20 验证),经 Prime Intellect 的 renderers 库渲染成 Qwen 格式、做损失掩码、展开打包成 382 条训练行,再用 prime-rl 训一个 LoRA。LoRA 只有 42.5 MB、2,120 万可训练参数,相比 46.6 亿的基座参数量,让消费级显卡的训练成为可能。第一个 epoch 在单卡 3090 上跑了 4 小时,换到 H100 之后同样数据量只要 45 分钟。
| 检查点 | 有效候选 | 得分查询 | 几何平均 | 总负载 | 胜/回归 |
|---|---|---|---|---|---|
| 未训练 4B | 14/113 | 15/113 | 0.85x | 0.85x | 3 / 1 |
| 1 epoch | 48/113 | 44/113 | 0.72x | 0.76x | 5 / 16 |
| 2 epochs | 85/113 | 108/113 | 1.08x | 1.04x | 12 / 8 |
| 3 epochs | 57/113 | 99/113 | 0.82x | 0.89x | 9 / 15 |
未训练模型 113 条查询里 81 条连一个有效候选都交不出来;1 个 epoch 后模型学会了框架的语言,但整体还慢于默认;2 个 epoch 到 1.08 倍;第 3 个 epoch 反而全面退化。最扎眼的细节是:第三轮训练期间验证损失几乎纹丝不动(0.485、0.305、0.306、0.322),平坦的验证损失完全没有预示第 3 个 epoch 的行为退化。作者补了 320 条新轨迹(过滤掉 6 条未尝试候选的),在新数据上再训 2 个 epoch 后达到 1.16 倍几何平均、1.06 倍总负载,20 胜 5 回归。SFT 到此收工,模型不仅会说框架的语言,也开始 genuinely 做出好决策。
强化学习:两次奖励设计翻车与锚定 GRPO
RL 阶段的每次 rollout 都真实执行:当前策略在代理框架里跑完一条完整轨迹,产出的候选计划与默认计划各测三次取中位数对比。
第一版奖励的设计非常直觉:对无效候选从加速比的对数里扣 0.1,与默认计划同指纹扣 0.05,整条轨迹没有有效候选直接扣 3。结果模型被 -3 吓住,反复提交默认计划换取小的扣分。GRPO 把这个失败放大了:prime-rl 实现的 GRPO 用组内均值把奖励转成相对优势,当一组 4 个 rollout 全都不比默认快时,最烂的两个无效轨迹拿 -1.43,「提交了与默认等价计划」的 rollout 反而拿 +1.52 被强化。模型被训练成复制默认计划。
第二版把两处都改掉。奖励改为:加速比裁剪到 [0.1, 10] 区间、对 0.05 以内的差异软阈值化以吸收测量噪声、调用 keep_default 或提交默认计划记 0 分、无有效候选只收 0.1 的固定小罚、与默认同指纹每次收 0.02。优势计算换成自定义的「锚定」变体,以默认计划的执行时间为锚点给每个 rollout 记分,同组的 4 个 rollout 全部劣于默认时,所有优势为负,无一被强化。
工程侧同步做了两件事。一是把训练拆到两台机器:测量留在 FLOPper 的 4 个 Postgres 容器上(Lambda 节点与他人共享非 GPU 资源,噪声超标),vLLM 推理跑在租来的第一张 H100 上,第二张 H100 做优势计算与权重更新,两边用 Tailscale 打通。二是把 rollout 对 Postgres worker 的独占改为按需租约:分析发现 92% 的 rollout 时间耗在 vLLM 推理上,按需租约后 20 条 rollout 并发争用 4 个 Postgres worker,GPU 与训练器的利用率一起上来。
| 检查点 | 有效候选 | 得分查询 | 几何平均 | 总负载 | 胜/回归 |
|---|---|---|---|---|---|
| SFT 起点 | 71/113 | 107/113 | 1.16x | 1.06x | 20 / 5 |
| RL 600 updates | 99/113 | 113/113 | 1.35x | 1.16x | 34 / 0 |
| RL 1,200 updates | 101/113 | 112/113 | 1.41x | 1.29x | 38 / 2 |
第一次 RL 尝试(学习率 1e-06、批大小 8、每查询 4 条 rollout、120 次更新)完全无效;把学习率提高一个数量级到 1e-05、批大小 16、每查询 8 条 rollout、600 次更新后,获得正向优势的 rollout 占比在两个连续的 600 更新轮里持续爬升,最终 1,200 updates 检查点把几何平均推到 1.41 倍。
部署形态的收益更可观:同一检查点每查询跑 3 条轨迹、每条最多 5 个候选,在 15 个候选里按轨迹内固定规则(预评分超过 1.05 倍的最优候选,否则保留默认)跨轨迹选优,几何平均与总负载双双达到 1.81 倍,113 条查询 68 胜 0 回归。作者说明这接近真实调优负载的用法:使用者要的是多条采样里的最好候选,而非单次 rollout 的结果。
模型到底学了什么
对最终评测 339 条轨迹的分析给出了几个具体数字。1,347 个动作里,扫描类 hint 用了 1,141 次、Leading(重排 join 顺序)917 次、Parallel 572 次,而直接修正行数估计的 Rows 只有 146 次;模型稳定偏好 nested-loop 胜过 hash join、索引扫描胜过位图和顺序扫描,常用参数组合是 enable_sort=off 与 random_page_cost=1.1。收益的主要来源是三个模式:用 Leading 改写 join 顺序、不动 join 顺序只修单处扫描、启用并行。行为上,337 条提交过候选的搜索里 295 条先做了检查动作,339 条里 235 条用满了 5 次候选额度。一个代表性片段:job-01d(90 倍加速)的推理过程指出顺序扫描在 57.5 万行上做有损过滤、单次计时 11 毫秒,判断位图扫描会更快,然后实际强制了位图扫描。
这份账本的可复刻部分
整个项目的开销构成写在明面上:2x H100 SXM 节点约 95 小时约 800 美元,Astra 轨迹生成约 400 美元,合计 1,200 美元;自己的 3090 双卡机只承担了早期验证与全部测量工作,电费约每天 9 美元。作者估算如果完全不用蒸馏、直接让小模型在 RL 里自己摸索(DeepSeek-R1-Zero 路线),rollout 数量会大得多,但并非不可行。
对手里有稳定重复查询的团队,这份工作的可复刻路径相当具体:基座模型与 LoRA 权重公开、代理框架与训练代码在 GitHub 仓库 polyphilz/qorl、测量去噪方法(shared_buffers 校准与误判率脚本)不依赖特定硬件、教师轨迹的生成成本用 API 计费可以精确预估。 Leis 等人 2025 年的复查说明查询优化器的老问题还在,而 qorl 给出的答案是把「为某个数据库的某批查询训练一个小模型」做成了一项 1,200 美元的支出,每一项构成都有数字可查。
实验全文:Training a 4B model to produce 81% faster query plans than Postgres(Bansal, Rohan, 2026 年 9 月);代码:polyphilz/qorl。