线上并不缺反馈,缺的是把点赞、点踩、改写和取消变成可版本化金标的方法。本文用 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`);

脱敏规则要进仓库,不要散落在各服务里。上面只处理了手机号和邮箱,真实业务还要按字段清单覆盖姓名、地址、订单号;没有把握的规则宁可不写,也不要在采集阶段「智能补全」出新内容。

先分级,不要立刻打对错

  • 点踩:负向告警。没有改写时,通常看不出用户要的是什么。
  • 点赞:更适合确认旧金标仍然有效,单独不能生成新题。
  • 用户改写:最接近期望输出,仍需变成可执行约束,而不是原文照收。
  • 中途取消:优先看成体验或延迟问题,默认进告警。

仲裁与切片:从事件到候选金标

同一输入可能又有人赞又有人踩。没有仲裁,评测集会互相打架。规则宜少、宜硬,并且写进代码,而不是靠值班同学的记忆:

  1. 单次点踩且无改写:写入告警,按「提示词版本 + 输入哈希」聚合,多次出现再人工看。
  2. 点踩且带改写:进入候选金标。改写用来提炼约束,原输出当作负例备注。
  3. 取消:只记告警。有首 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 也跑全量。先把这条闭环跑通,比继续手工造题要踏实得多。