线上并不缺反馈,缺的是把点赞、点踩、改写和取消变成可版本化金标的方法。本文用 Node.js 把采集、脱敏、仲裁和回归切片连成一条补集流水线,补的是真实失败模式,而不是再堆一批离线造题。读完可以判断:哪些弱信号能进评测集,哪些只适合当告警,避免修一个 case 却弄坏另一个。
金标不会自己长出来
评测框架并不稀缺。真正卡住 LLM 应用的,往往是题目从哪来。离线手工造题能覆盖「我们以为用户会问的」,很难覆盖「用户昨天实际踩过的」。更麻烦的是,线上信号很弱:点踩不等于标准答案,改写未必比原回答更正确,中途取消可能是网络或产品节奏,而不是模型能力。
如果把这些动作直接写进金标,评测集会被噪声淹没;如果完全丢掉,回归就只能活在想象里。比较稳的做法是承认它们是弱信号,先落成不可变事件,再经过脱敏、仲裁、约束化,决定进金标还是进告警。这条链路不替代离线题库,只负责持续补上生产里出现过的失败模式,并且每次补入都带版本,分数波动时才知道是哪一批样本在说话。
采集:把弱信号落成不可变事件
采集层只记录,不裁判。一次交互写成一条事件:请求 ID、提示词版本、脱敏后的输入输出、反馈类型、可选改写、是否取消。用户 ID 不要明文落盘,盐值放环境变量。字段要少而稳,后面的仲裁才有依据。
下面脚本用 Node.js 内置的 crypto 和 fs/promises,可直接保存为 collect.mjs 后执行 node collect.mjs。
import { createHash } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
const SALT = process.env.FEEDBACK_SALT ?? 'dev-only-salt';
function hashId(value) {
return createHash('sha256')
.update(`${SALT}:${value}`)
.digest('hex')
.slice(0, 16);
}
function redact(text) {
return String(text)
.replace(/\b1[3-9]\d{9}\b/g, '[PHONE]')
.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[EMAIL]');
}
const rawEvents = [
{
requestId: 'req-1001',
promptVersion: 'prompt-v12',
userId: 'u-88321',
input: '帮我把会议纪要发到 zhangsan@example.com',
output: '已发送到 zhangsan@example.com,并抄送了全员。',
feedback: 'thumbs_down',
rewrite: '不要声称已发送。只生成邮件草稿,收件人保留邮箱。',
canceled: false,
},
{
requestId: 'req-1002',
promptVersion: 'prompt-v12',
userId: 'u-10086',
input: '总结这三页材料',
output: '正在生成……',
feedback: null,
rewrite: null,
canceled: true,
},
{
requestId: 'req-1003',
promptVersion: 'prompt-v12',
userId: 'u-5520',
input: '这个口径再短一点',
output: '好的,已按更短口径重写。',
feedback: 'thumbs_down',
rewrite: null,
canceled: false,
},
];
const events = rawEvents.map((e) => ({
schema: 'feedback.v1',
requestId: e.requestId,
promptVersion: e.promptVersion,
userHash: hashId(e.userId),
input: redact(e.input),
output: redact(e.output),
feedback: e.feedback,
rewrite: e.rewrite ? redact(e.rewrite) : null,
canceled: e.canceled,
}));
await writeFile(
'events.jsonl',
events.map((e) => JSON.stringify(e)).join('\n') + '\n',
);
console.log(`wrote ${events.length} events`);
脱敏规则要进仓库,不要散落在各服务里。上面只处理了手机号和邮箱,真实业务还要按字段清单覆盖姓名、地址、订单号;没有把握的规则宁可不写,也不要在采集阶段「智能补全」出新内容。
先分级,不要立刻打对错
- 点踩:负向告警。没有改写时,通常看不出用户要的是什么。
- 点赞:更适合确认旧金标仍然有效,单独不能生成新题。
- 用户改写:最接近期望输出,仍需变成可执行约束,而不是原文照收。
- 中途取消:优先看成体验或延迟问题,默认进告警。
仲裁与切片:从事件到候选金标
同一输入可能又有人赞又有人踩。没有仲裁,评测集会互相打架。规则宜少、宜硬,并且写进代码,而不是靠值班同学的记忆:
- 单次点踩且无改写:写入告警,按「提示词版本 + 输入哈希」聚合,多次出现再人工看。
- 点踩且带改写:进入候选金标。改写用来提炼约束,原输出当作负例备注。
- 取消:只记告警。有首 token 耗时、是否超时就记,没有就不编。
阈值按自己的流量定,下面的 >= 3 只是脚本默认值,不是行业标准。热点 key 也只是告警升级:值得立项,不等于自动拥有标准答案。
切片按失败模式,不按模型名。例如「声称已执行副作用」「拒答过严」「不是合法 JSON」。修工具幻觉时,先跑对应 slice;发版前再跑全量,避免每次调试都把闲聊题拖进来,也避免只盯新 case、把旧约束跑丢。
保存为 arbitrate.mjs,在已有 events.jsonl 的目录执行 node arbitrate.mjs。
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
function inputKey(text) {
return createHash('sha256').update(text).digest('hex').slice(0, 12);
}
const events = (await readFile('events.jsonl', 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const alerts = [];
const gold = [];
const downCounts = new Map();
for (const event of events) {
const key = `${event.promptVersion}:${inputKey(event.input)}`;
if (event.canceled) {
alerts.push({ type: 'cancel', key, requestId: event.requestId });
continue;
}
if (event.feedback === 'thumbs_down' && event.rewrite) {
gold.push({
id: `case-${event.requestId}`,
slice: 'no-false-side-effect',
promptVersion: event.promptVersion,
input: event.input,
notes: event.rewrite,
checks: [
{ type: 'forbid_substr', value: '已发送' },
{ type: 'require_substr', value: '[EMAIL]' },
{ type: 'max_chars', value: 2000 },
],
source: event.requestId,
});
continue;
}
if (event.feedback === 'thumbs_down') {
downCounts.set(key, (downCounts.get(key) ?? 0) + 1);
alerts.push({ type: 'thumbs_down', key, requestId: event.requestId });
}
}
const hotKeys = [...downCounts.entries()]
.filter(([, n]) => n >= 3)
.map(([k]) => k);
await writeFile('alerts.jsonl', alerts.map((a) => JSON.stringify(a)).join('\n') + '\n');
await writeFile('gold.jsonl', gold.map((g) => JSON.stringify(g)).join('\n') + '\n');
console.log(
`alerts=${alerts.length} gold=${gold.length} hotKeys=${hotKeys.length}`,
);
金标文件与提示词版本对齐,例如 eval/gold/prompt-v12.jsonl。新样本只追加,不改旧 id。旧题确实错了,用 status: deprecated 标记,不要删除。否则回归变绿时,你分不清是能力好了,还是题目被拿掉了。
约束化回归,避免修一个坏另一个
用户改写是自然语言,直接当 expected 会很脆:同义表述被判失败,换了一种正确说法也会红灯。更稳的是把改写落成检查项:禁止声称、必须保留的实体、长度上限、必要时再加 JSON 可解析。这类检查失败原因可读,适合进 CI。
function runChecks(output, checks) {
const failures = [];
for (const check of checks) {
if (check.type === 'forbid_substr' && output.includes(check.value)) {
failures.push(`forbid:${check.value}`);
}
if (check.type === 'require_substr' && !output.includes(check.value)) {
failures.push(`missing:${check.value}`);
}
if (check.type === 'max_chars' && output.length > check.value) {
failures.push('too_long');
}
}
return failures;
}
const item = {
id: 'case-req-1001',
checks: [
{ type: 'forbid_substr', value: '已发送' },
{ type: 'require_substr', value: '[EMAIL]' },
{ type: 'max_chars', value: 2000 },
],
};
const outputs = [
{ label: 'v12', text: '已发送到 [EMAIL],并抄送了全员。' },
{ label: 'v13', text: '下面是邮件草稿,收件人是 [EMAIL]。我不会代为发送。' },
];
for (const cand of outputs) {
const failures = runChecks(cand.text, item.checks);
console.log(`${item.id} ${cand.label}: ${failures.length === 0 ? 'pass' : failures.join(',')}`);
}
这不依赖外部评测平台,也不替代人工。它只保证:修完「虚假已发送」之后,还能自动再踩到这个坑。语气是否得体这类软约束,可以后挂模型评审,但不要让评审模型独自决定金标去留。日常改 prompt 先跑相关 slice;合并前跑全量金标。新 case 通过、旧 slice 失败,就是典型的「修一个坏另一个」,应当挡住合并。
哪些进金标,哪些只当告警
能进金标的,通常要同时满足:输入可复现且已脱敏;有改写或明确可执行约束;同一失败模式出现过不止一次,或一次但后果不可逆(错误承诺已发邮件、已转账);经过人审,或规则仲裁后无争议。
只适合当告警的:孤立点踩、看不出错在哪;取消、刷新、切走;单独的点赞;纯主观口味(更活泼、再短一点)且产品没有书面风格规范。告警的价值是聚合和升级,不是自动出题。
闭环可以很朴素:候选金标打成 PR,评测集与 prompt 同一仓库;CI 跑上述检查,不通过不准合并。先保证「这周线上踩过的坑,下周还能被脚本踩到」,再谈更大的评测矩阵。
总结
弱信号不是金标,但丢掉它们,评测就只剩离线想象。用 Node.js 把反馈写成事件,脱敏后按规则仲裁,把改写收成约束,再按失败模式切片,就能得到一条可版本化的补集流水线。点踩和取消先当告警,带改写、可复现、能检查的才进金标;旧题废弃要留痕,回归既跑新 slice 也跑全量。先把这条闭环跑通,比继续手工造题要踏实得多。
评论