提示词、阈值、模板文案、规则表——这些东西的改动频率远高于代码。
全写死在代码里意味着改一个词就要走一遍完整发版流程,然后等所有人重新安装。对一个内部工具来说,这个成本高到会让你干脆不改。
所以它们走远程配置。后端用 PocketBase——一个单二进制、自带管理后台的东西,对这个量级正好。
数据结构:一条记录一个域
集合:params字段:key(唯一) / code / params(json)读规则写成这样,匿名可读、但必须带对码:
@request.query.code != "" && code = @request.query.code创建 / 更新 / 删除规则全设为 null——只有超管能改。
客户端读一条记录长这样:
GET /api/collections/params/records ?perPage=1 &filter=(key='category') &code=<共享读码>多条记录用同一个 code 完全正常——读规则只校验 ?code= 和记录里的 code 列相等。所以新增一个域不需要新的码,也不需要改 PB 的任何配置。
为什么要拆
最初是一条 global 记录装所有参数。三个问题:
- 改一处要动整块 JSON——在管理后台的 JSON 编辑框里改,误删的风险跟着字数走;
- 任何一个域的脏数据会让整条记录的读取都不可信——客户端拿到的是一个大对象,某个字段坏了你不知道该不该信别的;
- 改动不可区分——两个人分别改两个域,后保存的覆盖前一个。
拆成按域一条之后,每个域独立读、独立改、独立失败。
两种下发模型,拆之前必须先选
这是最容易搞错、也最影响后续维护的一步。我们两种都在用:
| 模型 A:纯覆盖 | 模型 B:只读层 | |
|---|---|---|
| 云端值去哪 | deep-merge 进本地配置 slice | 写进一个独立的只读键 |
| 进主配置结构吗 | 进 | 不进,不在配置键清单里 |
| 设置页可编辑吗 | 可以,但下次同步被冲掉 | 不可编辑 |
| 随「导出个人偏好」导出吗 | 会 | 不会 |
| 消费方式 | 直接读配置 | [...云端, ...本地] 两层合并 |
| 适合 | 管理员说了算的参数(阈值、模板文案、租户 ID) | 既要统一基线、又要留个性化口子的(规则表、预设、同义词) |
模型 A 的代价:用户无法做个性化。他在设置页改了,看起来生效了,下次同步被覆盖回去——而他不会知道为什么。
模型 B 的好处:云端和本地互不覆盖。云端是权威基线,本地是个人补充,消费时拼在一起。用户的补充永远不会被同步冲掉。
别一刀切。 纯管理员参数用 A 更简单,B 会引入不必要的合并逻辑;而给「用户也会改」的东西用 A,你会得到一个「我明明改了但它自己变回去了」的 bug——而且很难查,因为它只在同步的那一刻发生。
抽一个底座,加一个域只要十几行
「拉取 → 超时 → 取 params → 比对 → 写只读键」这套流程对每个域都一样,抽出来:
export async function syncRemoteDomain<T>( src: RemoteSource | undefined, spec: { recordKey: string; storageKey: string; pick: (raw: Record<string, unknown>) => T },): Promise<SyncResult<T>>加一个新域:
export const CATEGORY_RECORD_KEY = 'category'; // PB 记录的 key 列export const CATEGORY_REMOTE_KEY = 'cfg:categoryRemote'; // ⚠ 本地只读键,不进配置键清单
/** 白名单挑字段 + 形状校验 */function pickCategory(raw: Record<string, unknown>): RemoteCategoryConfig { /* … */ }
export const readRemoteCategory = () => readRemoteSlot<RemoteCategoryConfig>(CATEGORY_REMOTE_KEY);
export async function syncCategory(src: RemoteSource) { return syncRemoteDomain(src, { recordKey: CATEGORY_RECORD_KEY, storageKey: CATEGORY_REMOTE_KEY, pick: pickCategory, });}最后在同一个调度里挂一句调用——同一轮次、同一套退避记账,不新开轮询。
底座里内建了四条正确处理,自己照抄反而容易漏:匿名请求、超时、记录缺失的处理、失败时保留旧缓存。
(完整可复制的实现在 /showcase/pb-remote-config。)
六条踩坑
① 用超管 token 测「能读到」是假验证
超管 token 绕过读规则。你在管理后台的 Console 里测通了,客户端照样读不到。
必须模拟客户端的真实路径:
const url = "/api/collections/params/records?perPage=1" + "&filter=" + encodeURIComponent("(key='category')") + "&code=" + encodeURIComponent('<读码>');
await (await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'omit' })).json();credentials: 'omit' 是关键——客户端就是匿名请求的。能这样读到才算通。
② 记录不存在,要回「没变化」而不是「失败」
管理员还没建那条记录时,客户端就该安静地用内置默认值。
按失败处理的后果不是本域出问题,是污染健康计数——连续几次「失败」会让健康探测判定「云端挂了」,于是其它域也跟着进入退避期。
一个还没建的记录,拖垮了整个远程配置。
③ pick 会静默丢弃未声明的字段
白名单挑字段的函数,遇到没声明的字段直接丢。
云端加了新字段却忘了在 pick 里放行 → 云端明明有、客户端拿不到、而且不报错。
加字段时两边一起改。顺带给新字段加逐条形状校验,脏数据整条丢弃——宁缺勿错。
④ PocketBase 本身也会静默丢字段
这条是同一个毛病的服务端版本,而且更隐蔽:
PB 丢弃 schema 里不存在的字段。POST 返回 200,值不落库,毫无报错。
我有一列客户端代码一直在写、schema 里根本没建,空转了几个月才被发现。
顺序永远是:先在 PB 建列,再发带新字段的客户端。 反过来做,你会看到「上报成功但查不到数据」且无从排查。
⑤ 先建新的,验证通过,最后才清理旧的
拆分的正确顺序:
备份 → 建新记录(内容原样复刻)→ 用客户端真实路径验证 → 客户端加读取逻辑→ 真机确认拉到了 → 逐字段比对新旧 → 全部一致才清理旧位置 → 最后去掉客户端的回退分支清理时只删该域的键,保留其它:
const { categoryConfig, ...rest } = old.params;await fetch(`/api/.../records/${old.id}`, { method: 'PATCH', body: JSON.stringify({ params: rest }) });⑥ 旧位置清空后,要去掉客户端的回退
迁移期「先读新 key、读不到回退旧位置」是对的。但旧位置一旦清空,那条回退就毫无意义,而且会掩盖「新 key 读取失败」这类真问题——你以为在用新配置,其实一直在用空的旧位置。
我们的做法是把退役的记录留一个墓碑(_meta: { desc, retiredAt }),客户端改成单读,明确不再回退。
提示词也放云端:三条只有实测才知道的
模型的提示词(system prompt、各个场景的指令)是改动最频繁的一类配置,也走这套远程配置。取值规则是**「云端非空 > 内置默认」**。三条教训:
① 只改代码里的默认值,线上一个字都不会变
云端那条记录一旦非空,内置默认就再也不会被用到。我改了代码里的提示词、发了版,线上行为纹丝不动——因为云端那份一直非空(而且曾经和内置默认逐字相同,让人以为它们是同一份)。
改提示词必须两步:代码里的默认值 + 云端那条记录。 只改一边等于没改。
② 云端存单行字符串,用 JSON.stringify 生成
提示词里有换行。存进 JSON 字段时要是单行、换行转义成 \n。别在管理后台手抄——一个漏掉的转义就是一段断掉的提示词。在本地用 JSON.stringify(prompt) 生成,再粘进去。
写完用客户端的真实读取路径(匿名 + 读码)回读一次,确认读到的就是你写的。
③ PATCH 时别整体覆盖那条记录
线上那条记录里的内容比代码里的内置默认丰富得多——管理员在后台陆续加了东西(比如一张专有名词的纠错对照表)。你只想改其中一个字段,却把整个 params 用本地的版本覆盖上去,那些线上才有的内容就没了。
先读后写,只改目标字段。
顺带:一个「只会干一件事」的助手
有段时间,侧栏的对话助手不管问什么都往「写工单」上扯。第一反应是去改它的基础提示词——改不好,因为基础提示词里压根没提写工单。
真正的原因在拼接顺序里:system prompt 是四段拼起来的(基础指令 + 工具说明 + 一段写工单的质量规范 + 页面背景),而那段质量规范无条件拼进了每一轮对话。用户问一个完全无关的问题,模型的上下文里也躺着一整套写工单的规矩。
修法是按需拼:只有在工单页面、或者用户的输入明显和写工单相关时,才把那段规范加进去。
模型「总往一个方向想」时,先看它的上下文里有什么,再看它的指令写了什么。 无条件拼进去的内容,也是指令。
顺带:PB 管理页批量改 schema
后台 UI 加字段要点很多次下拉框,易错且难复核。在已登录的管理页 Console 里走 admin API 更稳,还能先读后写:
const cur = await (await fetch('/api/collections/<name>', { headers: H })).json();await fetch('/api/collections/<name>', { method: 'PATCH', headers: H, body: JSON.stringify({ fields: [...cur.fields, { name: '新列', type: 'text', required: false }] }),});★ 必须是 [...cur.fields, 新列]——PATCH 的 fields 是整体替换。直接传一个只含新列的数组会抹掉已有字段,连同数据。
这是整篇里唯一一条会造成真实数据损失的坑,所以放在最后单独说。
做完这套之后最大的变化不是技术上的:改一个提示词从「发一次版」变成「改一条记录」。
而这件事的连带效应是——你会真的去改它。写死在代码里的时候,一个措辞不好的提示词会挂在那里好几个月,因为不值得为它发一次版。