Prompt 从代码常量搬进数据库:改一句话,不用再发一次版
服务里散着一批 prompt:决定回答口吻的系统提示、把用户口语问题改写成检索词的、对候选结果做筛选的、给长文本生成摘要的、把长对话压成要点的......它们原先都是代码里的 static final String。
这套写法平时没事,直到你要改其中一句话。改完才发现,卡住的不是「改」这个动作,是改完之后那一串问题:这版发出去答得不对,怎么退回去?能不能先给 1% 的用户试?线上这次回答,是哪一版 prompt 生成的?
常量回答不了这些。所以我把它搬进了数据库,带版本号和灰度比例。一句话概括:像发代码一样发 Prompt。
一、常量方案到底卡在哪
| 诉求 | 常量方案 |
|---|---|
| 改一句话 | 改代码 → 构建 → 重启服务 |
| 发出去发现答得不对 | 再构建 → 再重启一次 |
| 先给一小部分用户试 | 做不到 |
| 出问题查是哪版写的 | 日志里没有版本号,只能靠猜 |
四条里最要命的是回滚。Prompt 的调整不像业务代码有明确的对错边界,它更接近调参:改一版可能好可能坏,得看线上表现。没有快速回滚的手段,谁都不敢改它。
所以这个注册表要解决的问题只有一件:把 Prompt 变成一份带版本、可灰度、可归因的数据。
二、表长什么样
后面的讨论都落在这张表上,先看结构。
sql
CREATE TABLE prompt_registry (
prompt_key VARCHAR(64), -- 业务键,如 chat_system
version INT, -- 同 key 内递增
content TEXT, -- prompt 全文
rollout_percent INT, -- 0=正式版,1--99=灰度
enabled TINYINT, -- 0=停用(退回代码常量)
remark VARCHAR(200), -- 谁改的、为什么
UNIQUE KEY uk_key_version (prompt_key, version)
)
一个 key 对应多行,一行一个版本。三种角色由 rollout_percent 和 enabled 划出来:
- 正式版 :
enabled = 1且rollout_percent = 0的行里,version最大的那条; - 灰度版 :
rollout_percent落在 1--99,按用户分流; - 代码兜底:表里没有、或总开关关掉,就用调用方传进来的常量,版本记作 0。
发布新版就是插一行:全量发就把 rollout_percent 填 0,先小范围试就填个比例。回滚也是改表,把某一版 enabled 置 0,或者把灰度的比例调回去。
三、调用方怎么用
从调用方的角度看,注册表只有一个方法:
java
ResolvedPrompt resolve(String key, String fallback, Integer userId);
// 返回 { key, content, version }
三个参数各管一件事:key 说明要哪段 prompt,fallback 是代码里那份默认文本(同时也是兜底),userId 决定灰度分给谁。返回值里,content 拿去做模型请求,version 拿去做归因。
调用点是这样写的:
java
ResolvedPrompt sys = prompt("chat_system", SYSTEM_PROMPT, userId);
answer = chat(sys.content, userPrompt, history, userId);
(prompt(...) 是各调用类里的一个私有包装:注册表没装配或抛异常时直接返回 fallback、版本记 0,保证单测里不注入它也能跑。)
这里最要紧的是:调用方只拿到一段文本和一个版本号,它不知道这段文本来自灰度版还是正式版,也不需要知道。「这次该用哪版」全在注册表内部定,调用点不为灰度写任何 if 分支。
要不要按用户分流,也是调用方决定的:需要看效果的主链路把当前用户 ID 传进去,按用户稳定分桶;那些既没有 A/B 需求、也拿不到用户上下文的调用点,直接传 null,永远走正式版。
代码里那份常量不删
容易看漏的一点:搬进数据库之后,SYSTEM_PROMPT 这些 static final String 并没有删掉 ,它多了一个新身份------注册表的 fallback 参数。
于是同一个 prompt 在代码和表里各存一份,但分工清楚:表里那份负责改,代码里那份负责兜底。 表里有就用表里的;表里没有、开关关了、或者查库失败,就用代码里这份。建表种子里的 v1 内容与常量逐字一致,所以这个机制上线时行为零变化。
加一个新 Prompt 要动三处
- 调用类里留一份
static final String,作为兜底文本; - 把调用从「直接用常量」改成
prompt("新key", 常量, userId).content; - 表里插一行种子:
version = 1、rollout_percent = 0、enabled = 1,内容与常量一致。
改完之后,这个 prompt 的后续调整都只发生在这张表里,代码不用再动。日常四种操作:
| 想做什么 | 改表 |
|---|---|
| 改文案、直接全量 | 插一行 version + 1、rollout_percent = 0 |
| 先小范围试 | 插一行 version + 1、rollout_percent = 20 |
| 提放量比例 | 改那一行的 rollout_percent,版本号不动 |
| 回滚 | 把要退掉的那版 enabled 置 0 |
以上都不需要重启服务,分钟级生效。
四、三层解析
上一节那个 resolve,内部就是按顺序找三层。
java
if (userId != null && !group.grays.isEmpty()) {
int bucket = Math.floorMod(userId, 100);
for (Row gray : group.grays) { // 按 version 降序
if (bucket < gray.rolloutPercent) return resolved(gray);
}
}
if (group.base != null) return resolved(group.base);
return null; // 调用方用自己的常量兜底
灰度是「累计」的,不是「各自」的
这是这段逻辑里最容易被读错的地方。
假设某个 key 的正式版是 v2,我又插了两个灰度版本:v3 放量 20、v4 放量 50。因为循环按版本号从大到小试、谁先命中谁算,实际分流结果不是「v3 拿 20%、v4 拿 50%」,而是:
bucket(userId % 100) |
用哪版 |
|---|---|
| 0--49 | v4 |
| 50--99 | v2(正式版) |
v3 一点都没分到。它的窗口是 0--19,而 0--49 已经被更高版本的 v4 抢先圈走了,高版本的放量范围会整个盖住低版本。
想同时跑两个灰度版,就得让高版本的放量比例更小。比如 v3 填 50、v4 填 20:v4 拿 0--19,v3 拿 20--49,剩下 50--99 落回正式版。所以这里的 rollout_percent 实质是「覆盖到第几个桶」的累计阈值,不是「分给我多少个桶」的占比。
(这也是我平时只用单灰度版本的原因:一个版本逐步提比例,从 10 到 30 再到全量,逻辑最不容易错。)
为什么按用户取模,不按请求随机
floorMod(userId, 100) 的关键不在「随机」,在稳定:同一个用户对同一个 key,永远落在同一个桶里。
如果改用随机数,同一个用户这次看到 A 版、下次看到 B 版,A/B 比的就不是「两版 prompt 谁好」,而是「这个用户被切来切去」。分流必须跟着用户走,效果才归因得了。
另一处边界:系统内部调用没有具体用户,userId 传 null,直接跳过灰度走正式版。没有用户维度,分流本身就没有意义。
五、快照,和那个 60 秒
Prompt 是每个请求都要读的东西。如果每次调用都查一次库,主链路上会平白多出一串数据库查询。但 Prompt 变更是天级操作,慢一分钟生效完全可以接受。
所以做法是本地快照 + 周期懒刷新:
- 内存里存一份全表快照,默认 60 秒过期;
- 没过期就直接用,一次库都不查;
- 过期后,下一个请求顺手刷新。它不是定时任务,是第一个发现过期的请求来刷;
- 刷新时做「双重检查」,同一时刻只有一个线程真去查库。
什么是「双重检查」
「过期了,该刷了」这个判断要做两遍,一遍在锁外,一遍在锁内------并发里管这套写法叫「双检锁」:
java
if (没过期) return; // 第一遍:锁外,快照有效的请求直接掉头
synchronized (lock) {
if (没过期) return; // 第二遍:抢到锁的线程再做一次复核
// 查库、重建快照、推进时间戳
}
第一遍刻意不加锁。绝大多数请求进来时快照都还没过期,在门口掉头就走,不产生任何锁竞争------如果把判断直接放进锁里,每个请求都得先排队抢锁,白白拖慢主链路。
只有发现过期的请求才会走到锁前,而这时候往往不止一个线程同时冲过来。第一个拿到锁的线程查完库、把时间戳推进;后面排队的线程依次进锁,第二遍检查时发现时间戳刚被刷新过,直接返回。最终只有第一个线程真查了库,其余都是空跑一次判断。
(快照和时间戳两个字段都标了 volatile,保证一个线程换上的新快照,其它线程立刻能看见。)
刷新失败,也要把时间戳推进
这里有个反直觉的点:刷库失败了怎么办?
直觉做法是「不推进时间戳,下次重试」。但数据库要是持续不可用,每个请求都会发现「过期了、该刷了」,于是每个请求都去查一次、每次都失败。一次数据库故障,被放大成每请求一次的查询雪崩。
所以失败也把时间戳推进:沿用旧快照,下一个 60 秒窗口再试一次。
java
} catch (Exception e) {
// 保留旧快照;loadedAt 也推进,避免 DB 持续不可用时每请求都重试查询
this.snapshotLoadedAt = System.currentTimeMillis();
log.warn("[prompt-registry] 刷新失败,沿用旧快照(或代码兜底): {}", e.getMessage());
}
说白了:宁可让所有人多用一会儿 60 秒前的旧版本,也不能因为刷新失败,把查询压力全堆回数据库。
六、兜底:绝不阻断主链路
注册表是一层锦上添花,绝不能因为它挂了、业务就用不了。所以整条链路是 fail-open 的。
失效入口有三个,出口只有一个:回到代码里的常量。
| 失效点 | 行为 |
|---|---|
| 总开关关闭 | 直接走常量,连库都不查(排障时一键降级) |
| 表里没这个 key / 版本 | 返回 null,调用方用自己的常量 |
| 查库抛异常 | 沿上一份快照;没快照就用常量 |
总开关那一条是特意留的:线上如果怀疑是 prompt 出了问题,把开关一关,整个系统立刻回到「全部用代码常量」的状态,和上注册表之前一模一样。敢上这套机制,一半的底气来自这把能一键回到原点的开关。
七、快照加载时的三个防御
快照就是把表里的行读出来、在内存里按 key 拼成一组候选。「每个 key 该用哪一版」全靠这一步算出来,所以有三处地方不能只是照抄数据库。
java
if (content.isBlank()) continue; // 空行不进快照
if (rollout <= 0) {
// 正式版:自己比版本号取最大,不依赖 SQL 排序
if (g.base == null || version > g.base.version) {
g.base = new Row(key, version, content, rollout);
}
} else {
// 灰度:比例上限夹到 99
g.grays.add(new Row(key, version, content, Math.min(rollout, 99)));
}
谁是「正式版」,自己算一遍
「rollout_percent = 0 的行里版本号最大的那条是正式版」,这是一条业务规则。但如果代码写成「取查询结果的第一行」,这条规则就变成了「SQL 有没有按 version 倒序排」的隐式约定:哪天有人给这个查询加个 LIMIT、换个索引、或者套一层 ORM 分页,排序悄悄变了,没有一行代码会报错,但「正式版」已经指向了另一个版本,全量用户会突然开始用一个实验版。
所以读到一行就自己跟当前候选比一次版本号,不认 SQL 的顺序。多写几行,换来的是这条规则只出现在业务代码里,不寄存在查询语句上。
100 不是全量,0 才是
rollout_percent 有个容易踩的坑:很多人习惯用 100 表示「全量上线」 ,但在这个模型里全量的标志是 0 (正式版),1--99 才是灰度,写 100 会被夹成 99 依旧按灰度处理。也就是说你想全量、实际还有 1% 的人在用旧版;反过来,如果真让 100 代表全量,「灰度」和「正式」两个概念就混在一起了。填 0 才是全量。
八、哪些 Prompt 不该搬进来
注册表建好之后,很容易产生一种冲动:把所有 prompt 都搬进来。
但有一类不该搬:安全检测那类规则。 它不是一段送给模型的自然语言,而是一组硬编码的正则,被多处调用共用同一份。放进注册表,等于把同一份规则拆到两个地方维护,迟早会漂移。
理由不是「它特殊」,而是它改的频次极低、一旦改错影响面却很大 。这类东西放进注册表,等于给它开了一条不经构建就能改动的旁路。注册表是给「需要频繁调、需要灰度看效果」的 prompt 准备的。判断标准很简单:这个 prompt 你会不会想改一版试试效果? 会,就放进去;不会,留在代码里。
九、归因:日志里得有版本号
前面说常量方案最要命的是「查不到是哪版写的」。搬进注册表后,这一步就补齐了:每次用到的 prompt,版本号都写进日志,也跟着响应返回。
java
pv.put("query_rewrite", prompt("query_rewrite", REWRITE_PROMPT, null).version);
pv.put("rerank", prompt("rerank", RERANK_PROMPT, null).version);
log.info("question={}, promptVersions={}", q, pv);
版本 0 表示走了代码兜底:看到日志里是 0,就该知道这次不是数据库里的 prompt 在起作用,排查方向立刻清晰。
有了这个,灰度才有意义:想知道新 prompt 好不好,把两版用户的回答质量各自归因到版本号上比一比就够了。
总结
这套机制收起来就是六条决定,每一条都对着一个具体的麻烦:
| 决定 | 解决什么 |
|---|---|
prompt 进表,带 version 和 rollout_percent |
改一句话不用再发版 |
取哪版由 resolve 定:灰度 → 正式版 → 代码常量 |
调用点零感知,不写灰度分支 |
| 本地快照 + 懒刷新,默认 60 秒 | 每请求不查库,改完分钟级生效 |
| 刷新失败也推进时间戳 | DB 抖动不会被放大成每请求一次查询 |
| fail-open + 总开关 | 注册表挂了业务照常,可疑时一键回到原点 |
| 版本号写进日志和响应 | 每次回答都能归因到具体版本 |
代价也说清楚:这套东西只解决「取哪一版」,改表和发布这个动作得自己接------写运维脚本也好,做个管理界面也好,它本身不提供;快照是每台机器各存各的,多实例部署下会有几十秒的版本偏差;60 秒这个延迟是拍的,要秒级生效就得调小或者改成改动后手动触发;归因只到版本号,prompt 正文还得回表里对。
要不要上这套东西,判断标准只有一条:你的 prompt 改得够勤吗? 改得勤、又想看到灰度效果,这层表就值;一年不动一次的,留在代码里更省事。