AI 评测结果双轮对比:稳定配对、语义差异与安全导出
本文目录
一个 AI 评测工作台往往要把同一批样本在两次运行中的结果并排展示。真正容易出错的地方不是画两列,而是如何证明“左边这一项”和“右边这一项”代表同一个样本、如何表达空值和缺项,以及导出的内容是否与屏幕上看到的差异一致。
本文给出一套通用实现思路。示例使用普通 JSON 数据,不绑定模型供应商或业务系统;接入时应把字段映射到自己的 API 契约,并明确记录 ID 的生成与生命周期。
先定义结果模型与身份规则
两次运行的数组位置没有身份含义。排序、过滤或并发完成顺序都可能变化,所以不能用数组下标把结果配对。每个结果必须有稳定、非空且在一次运行中唯一的 ID;不要用展示标题、数组下标或生成时间临时拼 ID。
type EvaluationResult = {
id?: unknown;
response?: unknown;
trend?: unknown;
currentData?: unknown;
gateStatus?: unknown;
gateReasons?: unknown;
rating?: unknown;
weightedScore?: unknown;
explanation?: unknown;
[key: string]: unknown;
};
type ResultGroup = Map<string, EvaluationResult[]>;
function groupByStableId(items: EvaluationResult[]) {
const groups: ResultGroup = new Map();
const missing: Array<{ index: number; item: EvaluationResult }> = [];
items.forEach((item, index) => {
const id = typeof item.id === 'string' ? item.id.trim() : '';
if (!id) {
missing.push({ index, item });
return;
}
const group = groups.get(id) ?? [];
group.push(item);
groups.set(id, group);
});
return { groups, missing };
}
配对时合并两侧 ID 集合,并按规则生成行:两边各有且只有一条时才是 matched;只在一边出现时是 left-only 或 right-only;任意一侧同 ID 出现多条时是 ambiguous,必须把该组完整展示为异常,不能挑第一条偷偷配对。缺 ID 的项也各自保留为未配对记录,并带上来源侧与原始序号,便于定位上游数据问题。
这样做会让异常数据显眼,而不是把错误的配对伪装成一个可信的“差异”。若业务允许重复 ID,说明 ID 的作用域定义不完整,应先把运行 ID、样本 ID 等身份层级写进契约,而不是在 UI 里用位置猜测。
把缺字段、空值和真实值分开
null 可能表示评测明确给出了空结果;字段不存在则通常表示旧数据、部分响应或数据契约缺失。把二者都转成空字符串、0 或“无”会丢失信息,也容易让读者误判为一致。
type FieldValue = { present: boolean; value: unknown };
function readField(record: EvaluationResult, key: keyof EvaluationResult): FieldValue {
return {
present: Object.hasOwn(record, key),
value: record[key],
};
}
function comparePresence(left: FieldValue, right: FieldValue) {
if (left.present !== right.present) return 'changed';
if (!left.present) return 'same';
return 'compare-values';
}
上面的 Object.hasOwn 需要目标运行环境支持,旧环境可用 Object.prototype.hasOwnProperty.call(record, key)。读取 API JSON 时,建议在边界处验证字段类型;不要把任意对象、undefined、循环引用或特殊类实例直接交给通用序列化器。
标量可以用严格、无隐式类型转换的比较策略。Object.is 能明确表达 null、NaN 和正负零等边界,但对象按引用比较不适合判断来自两次请求的结构化 JSON 是否等价。结构化值可以先按确定性规则规范化:对象键排序、数组顺序保留,再比较规范化后的 JSON;数组顺序是否有业务含义必须由字段契约决定。
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
function canonicalJson(value: JsonValue): string {
if (value === null || typeof value !== 'object') {
const encoded = JSON.stringify(value);
if (encoded === undefined) throw new TypeError('值不是受支持的 JSON 数据');
return encoded;
}
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(',')}]`;
}
const entries = Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
return `{${entries.join(',')}}`;
}
function valuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (left === null || right === null) return false;
if (typeof left !== 'object' || typeof right !== 'object') return false;
try {
return canonicalJson(left as JsonValue) === canonicalJson(right as JsonValue);
} catch {
return false;
}
}
生产实现不应靠 JSON.stringify 的偶然输出格式定义业务语义。上例特意限制输入为 JSON 值,并将对象键顺序规范化;如果字段是集合、带单位的数值或浮点评分,还要分别定义排序、舍入精度和容差规则。比如 0.7000001 与 0.7 是否算相同,不应由 UI 的 toFixed() 决定。
让差异成为明确的数据结构
先定义字段清单和差异结构,再让表格、高亮、筛选、摘要和导出共同消费它。这样不会出现页面标红了一个字段、下载文件却漏掉它的情况。常用字段可以固定在前,自定义业务字段则从两侧记录的键集合中补入并排序;id 只负责身份配对,不参与字段差异。接入真实接口时应先用白名单挑出可展示字段,不要把可能含个人信息、提示词或内部元数据的原始对象整体送到读者浏览器。
const comparisonFields = [
'response',
'trend',
'currentData',
'gateStatus',
'gateReasons',
'rating',
'weightedScore',
'explanation',
] as const;
type FieldDiff = {
field: string;
left: FieldValue;
right: FieldValue;
changed: boolean;
};
function compareMatched(left: EvaluationResult, right: EvaluationResult): FieldDiff[] {
const extraFields = [...new Set([...Object.keys(left), ...Object.keys(right)])]
.filter((field) => field !== 'id' && !comparisonFields.includes(field as (typeof comparisonFields)[number]))
.sort();
return [...comparisonFields, ...extraFields].map((field) => {
const leftValue = readField(left, field);
const rightValue = readField(right, field);
const presence = comparePresence(leftValue, rightValue);
const changed = presence === 'changed'
|| (presence === 'compare-values' && !valuesEqual(leftValue.value, rightValue.value));
return { field, left: leftValue, right: rightValue, changed };
});
}
差异视图至少要能区分:值相同、值发生变化、字段缺失、只在一轮出现、ID 重复无法判定。复杂文本不要只用颜色标红;给出字段名、左右值和可访问的状态文案。长文本可以折叠,打开后使用差异高亮,但保留查看完整原文的入口。评分、门禁结果和解释文本应分别呈现,不能用一个“总分变化”掩盖原因变化。
导出从同一份对比结果生成
不要在点击导出时重新跑一遍不同的配对逻辑。导出应消费已经生成的比较行,至少保留状态、稳定 ID、左右两侧的完整参与比较字段、差异字段清单和必要的运行元信息。若导出只包含当前筛选结果,要在文件名或工作表说明中标出筛选范围;否则默认导出全部结果,并清楚显示总行数。
type ComparisonRow = {
status: 'matched' | 'left-only' | 'right-only' | 'ambiguous' | 'missing-id';
id: string | null;
left: EvaluationResult[];
right: EvaluationResult[];
diffs: FieldDiff[];
};
function toExportRows(rows: ComparisonRow[]) {
return rows.map((row) => ({
status: row.status,
id: row.id,
leftResults: row.left,
rightResults: row.right,
changedFields: row.diffs.filter((diff) => diff.changed).map((diff) => diff.field),
}));
}
function safeExportFilename(label: string): string {
const stem = label.trim()
.replace(/\.xlsx$/i, '')
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '')
.slice(0, 80);
return `${stem || 'evaluation-comparison'}.xlsx`;
}
文件名清理只解决路径分隔符、控制字符和长度问题,不等于内容安全。写入 XLSX 时应使用明确的文本单元格类型,不把用户可控字符串当公式;若导出 CSV,必须把分隔符、引号、换行和电子表格公式注入纳入策略与回归测试。CSV 的防护会影响后续程序化导入,且不同表格软件行为不同,不要宣传某个转义技巧对所有客户端都绝对安全。OWASP:CSV Injection
测试边界
把身份、语义比较和导出分别测试,再补一个端到端的视图/导出一致性测试:
| 用例 | 预期行为 |
|---|---|
| 两次运行顺序不同,但 ID 唯一相同 | 正确配成一行,不依赖数组下标 |
| 一侧缺少 ID | 保留为未配对记录,不猜测另一侧对象 |
| 任一侧出现重复 ID | 整个 ID 组标为歧义,不按第一条配对 |
字段缺失与显式 null 对比 | 显示为不同状态 |
| 结构化对象只有键顺序不同 | 按既定规范化规则比较为相同 |
| 数组元素顺序改变 | 默认视为变化,除非字段契约明确说明是无序集合 |
| 记录只存在于一轮 | 保留完整单侧内容,并注明来源 |
| 用户可控标题含路径符号、超长文本或控制字符 | 生成受限文件名,不丢失导出行 |
| 导出包含以公式字符开头的文本 | 按选定格式和目标客户端的安全策略验证,不执行公式 |
| 页面筛选后导出 | 行数、ID 和差异与页面明确标注的范围一致 |
组件测试之外,再用一组固定样例做手工验收:一条无差异、一条文本变化、一条门禁原因变化、一条分数变化、一条左侧独有、一条重复 ID 和一条缺 ID。确认键盘用户可以定位差异状态、打开完整值并触发导出;小屏下左右比较可切换字段或横向浏览,不要让整页被一个超宽表格挤出视口。
参考资料
- MDN:
Object.is():了解无类型转换的同值判断及NaN、正负零语义。 - MDN:
JSON.stringify():了解 JSON 序列化、不可序列化值和循环引用边界。 - OWASP:CSV Injection:导出用户可控字段时的公式注入风险与不同客户端的局限。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。