标题:多模态输出质量评测工程
digest:多模态输出评测三维框架:准确、忠实、完整
一、引子:没有评测的多模态系统是盲飞
某个真实场景:客户上传一张增值税发票的照片,系统调用 Vision 模型把它转成 JSON 传给财务后台。一切都很顺,上线两周,直到财务对账时发现——有 3% 的发票金额和真实金额差了一位小数,还有 1% 的开票单位名称是模型"编"出来的。
问题不是"能不能跑通",而是"你根本不知道跑得有多准"。
纯文本任务出错,你还能人工抽读几段发现问题;多模态任务的输入是图像、PDF、音频,输出是结构化的 JSON——没有系统化评测,你就是在盲飞。而多模态评测的门槛比纯文本高出一大截,很多团队因此干脆跳过这一步,直接把风险留给了用户。
这篇文章讲清楚三件事:为什么多模态评测更难;三维评测框架(准确性 / 忠实度 / 完整性)怎么落地;给出一套可以直接跑的完整代码,包含字段级、语义级、LLM-as-Judge 和幻觉检测。
二、为什么多模态评测更难
纯文本任务的评测已经不容易,但多模态又叠了三层难度:
1. Ground truth 难获取。一段文本翻译,你能找到译员标注;一张 20 页的合同 PDF,你要让标注员逐字段读原图、抄写 30 个字段,成本是纯文本任务的 5~10 倍。图表理解、图像描述这类任务,甚至连"标准答案"本身都难定义——同一张柱状图,一千个读者有一千种合理描述。
2. 幻觉形式更多样。纯文本幻觉主要是"编造事实";多模态幻觉至少有四种:编造原图没有的字段、把 A 图的信息误认成 B 图、数字识别错位(如把 12580 读成 1258)、格式误解(表格行列错读)。每一种都需要专门的检测策略。
3. 人工评测成本高、一致性差。让两个标注员分别看同一张医学影像的自动报告,Kappa 一致性经常只有 0.6~0.7,比文本任务低 15~20 个百分点。图像信息密度大,主观判断空间也大。
结论:多模态系统不能只靠"人工抽检"或"F1 一把梭",必须组合多种自动指标 + 少量高质量人工标注,形成分层评测体系。
三、三维评测框架
推荐把多模态输出的质量拆成三个正交维度,任何一个维度出问题都会直接影响下游业务:
| | | |
|---|
| 准确性 Accuracy | | | |
| 忠实度 Faithfulness | | | |
| 完整性 Completeness | | | |
三个维度都要看,因为它们互相冲突:
- 单看准确性会忽略"没提取的那部分",也没法区分"错值" vs "编造"
一个稳健的多模态系统,三个分数都要过关。
四、自动评测三层实现
4.1 字段级评测(最基础,占 60% 权重)
对结构化字段(发票号、日期、金额),直接做值比对。但要注意三种归一化:
- 数字:
"12,580"、12580.0、"12580元" 应视为相等。用相对容差(默认 1%)。 - 日期:
"2026-10-14"、"2026/10/14"、"2026年10月14日" 应视为相等。归一化到 ISO 格式。 - 字符串
4.2 语义相似度(描述类字段)
对"商品描述"、"事件摘要"这种自由文本字段,精确匹配会误伤大量语义等价的表达。可以用 embedding 计算 cosine 相似度,但更轻量的方案是 ROUGE 风格的 token 重叠 F1——不依赖任何模型、跑得飞快、结果稳定。中英混合场景下,中文按字切分、英文按单词切分即可。
4.3 LLM-as-Judge(复杂主观场景)
对于"图表描述是否准确"、"生成的图像说明是否得体"这类主观任务,让另一个 LLM 当评委是当前最实用的方案。关键 prompt 设计原则:
- 给足上下文:把原始文档、参考答案、系统输出全传进去,让 Judge 有据可依
- 拆分维度打分:不要只让它给"总分",而是分别打准确性、忠实度、完整性
- 要求 JSON 输出
- 温度设 0
- 交叉验证:至少在 100 条人工标注样本上验证 LLM-Judge 与人工的一致性,Kappa > 0.7 才算可用
五、幻觉检测工程
幻觉检测的核心逻辑很简单:输出中的每个具体值(数字、名称、日期),都要能在原始文档中找到出处。
实现分三步:
- 原子提取:递归拆解 prediction 里的每个字段,把值的最小可查证单元提取出来(例如
"12580.00"、"上海智研科技") - 归一化匹配:源文档去空格、去逗号、小写化;提取出的原子也做同样处理,然后做子串匹配
- 分类统计:命中的记为 grounded,未命中的记为 hallucinated;输出幻觉率和明细列表
进阶版可以加"近似匹配"(编辑距离 ≤2)来容忍 OCR 噪声,或用 embedding 检索来处理语义等价但字面不同的情况(如"沪"vs"上海")。但生产环境从字面匹配开始就够用了——先能测出 80% 的明显幻觉,比追求 100% 完美却不上线要有价值得多。
六、评测集构建建议
评测集不是越大越好,但结构必须合理:
数量:初期 100~300 条足够。核心场景每类 30~50 条,边缘 case 覆盖到即可。分布比数量更重要。
标注策略:
- 字段结构固定,避免"自由填写"(否则聚合不了指标)
采样策略:
- 30% 精心构造的边缘 case(模糊图、非常规格式、长尾字段)
持续更新:每个季度回顾一次线上 bad case,把新出现的失败模式加进评测集。评测集是活的资产。
七、完整代码
以下是可以直接跑的完整评测框架,包含所有前面提到的组件,无需 API key(LLM-Judge 走 mock 模式):
"""多模态输出质量评测框架 - 无需 API key 即可运行"""from __future__ import annotationsimport jsonimport refrom dataclasses import dataclass, fieldfrom datetime import datetimefrom typing import Any# ---------------- 数据结构 ----------------@dataclassclass EvalSample: """一条评测样本:输入 + 系统预测 + 人工标注参考""" sample_id: str source: str # 原始文本/OCR结果,用于幻觉检测 prediction: dict[str, Any] # 系统输出的结构化结果 reference: dict[str, Any] # ground truth meta: dict[str, Any] = field(default_factory=dict)# ---------------- 字段级评测 ----------------class FieldLevelEvaluator: """精确匹配 + 数字容差 + 日期归一化""" def __init__(self, number_tolerance: float = 0.01): self.number_tolerance = number_tolerance def _norm_date(self, s: str): s = s.strip() for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y年%m月%d日", "%d/%m/%Y", "%m/%d/%Y"): try: return datetime.strptime(s, fmt).strftime("%Y-%m-%d") except ValueError: continue return None def _try_number(self, v): if isinstance(v, (int, float)): return float(v) if isinstance(v, str): if re.match(r"^\s*-?\d+(\.\d+)?\s*$", v.replace(",", "")): return float(v.replace(",", "")) return None def match_field(self, pred, ref) -> bool: if pred is None or ref is None: return pred == ref # 数字:容差匹配 pn, rn = self._try_number(pred), self._try_number(ref) if pn is not None and rn is not None: if rn == 0: return abs(pn - rn) <= self.number_tolerance return abs(pn - rn) / abs(rn) <= self.number_tolerance # 日期归一化 if isinstance(pred, str) and isinstance(ref, str): pd, rd = self._norm_date(pred), self._norm_date(ref) if pd and rd: return pd == rd # 默认字符串比较 return str(pred).strip().lower() == str(ref).strip().lower() def evaluate(self, sample: EvalSample) -> dict: keys = set(sample.reference.keys()) | set(sample.prediction.keys()) details = {} correct = missing = extra = wrong = 0 for k in keys: in_ref, in_pred = k in sample.reference, k in sample.prediction if in_ref and not in_pred: details[k] = "missing"; missing += 1 elif in_pred and not in_ref: details[k] = "extra"; extra += 1 elif self.match_field(sample.prediction[k], sample.reference[k]): details[k] = "correct"; correct += 1 else: details[k] = f"wrong (pred={sample.prediction[k]!r}, ref={sample.reference[k]!r})" wrong += 1 ref_total, pred_total = len(sample.reference), len(sample.prediction) precision = correct / pred_total if pred_total else 0.0 recall = correct / ref_total if ref_total else 0.0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 return {"correct": correct, "wrong": wrong, "missing": missing, "extra": extra, "precision": precision, "recall": recall, "f1": f1, "details": details}# ---------------- 语义相似度(ROUGE-1 F1 风格) ----------------class SemanticEvaluator: """基于 token 重叠的语义相似度,无需 embedding""" def _tokenize(self, s: str) -> list[str]: # 中英混合:中文按字,英文按单词 return re.findall(r"[a-zA-Z0-9]+|[一-鿿]", s.lower()) def similarity(self, pred: str, ref: str) -> float: p_tokens, r_tokens = self._tokenize(pred), self._tokenize(ref) if not p_tokens or not r_tokens: return 0.0 p_count, r_count = {}, {} for t in p_tokens: p_count[t] = p_count.get(t, 0) + 1 for t in r_tokens: r_count[t] = r_count.get(t, 0) + 1 overlap = sum(min(p_count.get(t, 0), r_count.get(t, 0)) for t in p_count) precision = overlap / len(p_tokens) recall = overlap / len(r_tokens) if precision + recall == 0: return 0.0 return 2 * precision * recall / (precision + recall) def evaluate(self, sample: EvalSample, fields: list[str] | None = None) -> dict: fields = fields or list(sample.reference.keys()) scores = {} for f in fields: scores[f] = self.similarity( str(sample.prediction.get(f, "")), str(sample.reference.get(f, "")), ) scores["_avg"] = sum(scores.values()) / len(scores) if scores else 0.0 return scores# ---------------- LLM-as-Judge(mock 模式) ----------------class LLMJudge: """让另一个 LLM 打分;mock 模式基于规则给稳定评分""" JUDGE_PROMPT = """你是一位严格的多模态输出评测员。请对系统输出打分(1-5)。原始文档内容:{source}参考答案:{reference}系统输出:{prediction}请从以下三个维度打分并说明:1. 准确性(accuracy):字段值是否与参考一致2. 忠实度(faithfulness):是否有原文没有的信息(幻觉)3. 完整性(completeness):该提取的字段是否有缺漏返回 JSON:{{"accuracy": int, "faithfulness": int, "completeness": int, "reason": str}}""" def __init__(self, client=None, model: str = "qwen-max", mock: bool = True): self.client, self.model, self.mock = client, model, mock def _mock_score(self, sample: EvalSample) -> dict: field_eval = FieldLevelEvaluator().evaluate(sample) acc = 1 + round(4 * field_eval["f1"]) completeness = 1 + round(4 * field_eval["recall"]) source_lower = sample.source.lower() hits = total = 0 for v in sample.prediction.values(): if isinstance(v, str) and len(v) >= 2: total += 1 if v.lower() not in source_lower: hits += 1 faith_ratio = 1 - (hits / total) if total else 1.0 faithfulness = max(1, 1 + round(4 * faith_ratio)) return {"accuracy": acc, "faithfulness": faithfulness, "completeness": completeness, "reason": f"字段 F1={field_eval['f1']:.2f},幻觉 {hits}/{total}"} def judge(self, sample: EvalSample) -> dict: if self.mock or self.client is None: return self._mock_score(sample) # 生产模式:调用真实 LLM(DashScope 兼容格式) prompt = self.JUDGE_PROMPT.format( source=sample.source, reference=json.dumps(sample.reference, ensure_ascii=False), prediction=json.dumps(sample.prediction, ensure_ascii=False), ) resp = self.client.chat.completions.create( model=self.model, messages=[{"role": "user", "content": prompt}], temperature=0, ) return json.loads(resp.choices[0].message.content)# ---------------- 幻觉检测器 ----------------class HallucinationDetector: """检查 prediction 中的关键值是否在 source 中出现""" def __init__(self, min_len: int = 2): self.min_len = min_len def _extract_atoms(self, v: Any) -> list[str]: if v is None: return [] if isinstance(v, (int, float)): return [str(v)] if isinstance(v, str): s = v.strip() return [s] if len(s) >= self.min_len else [] if isinstance(v, (list, tuple)): return [a for x in v for a in self._extract_atoms(x)] if isinstance(v, dict): return [a for x in v.values() for a in self._extract_atoms(x)] return [] def detect(self, sample: EvalSample) -> dict: source_norm = sample.source.lower().replace(" ", "").replace(",", "") hallucinated, grounded = [], [] for field_name, v in sample.prediction.items(): for atom in self._extract_atoms(v): atom_norm = atom.lower().replace(" ", "").replace(",", "") if atom_norm and atom_norm not in source_norm: hallucinated.append({"field": field_name, "value": atom}) else: grounded.append({"field": field_name, "value": atom}) total = len(hallucinated) + len(grounded) return {"hallucination_rate": len(hallucinated) / total if total else 0.0, "hallucinated": hallucinated, "grounded_count": len(grounded)}# ---------------- 评测报告 ----------------class EvalReport: def __init__(self, system_name: str): self.system_name = system_name self.samples: list[dict] = [] def add(self, sample_id, field_result, semantic, judge, hallu): self.samples.append({"sample_id": sample_id, "field": field_result, "semantic": semantic, "judge": judge, "hallucination": hallu}) def summary(self) -> dict: n = len(self.samples) if n == 0: return {} return { "precision": sum(s["field"]["precision"] for s in self.samples) / n, "recall": sum(s["field"]["recall"] for s in self.samples) / n, "f1": sum(s["field"]["f1"] for s in self.samples) / n, "semantic_avg": sum(s["semantic"]["_avg"] for s in self.samples) / n, "judge_accuracy": sum(s["judge"]["accuracy"] for s in self.samples) / n, "judge_faithfulness": sum(s["judge"]["faithfulness"] for s in self.samples) / n, "judge_completeness": sum(s["judge"]["completeness"] for s in self.samples) / n, "hallucination_rate": sum(s["hallucination"]["hallucination_rate"] for s in self.samples) / n, } def render(self) -> str: s = self.summary() lines = [f"=== 评测报告: {self.system_name} ({len(self.samples)} 样本) ==="] if s: lines += [ f" 字段级 Precision : {s['precision']:.3f}", f" 字段级 Recall : {s['recall']:.3f}", f" 字段级 F1 : {s['f1']:.3f}", f" 语义相似度均值 : {s['semantic_avg']:.3f}", f" LLM-Judge 准确性 : {s['judge_accuracy']:.2f}/5", f" LLM-Judge 忠实度 : {s['judge_faithfulness']:.2f}/5", f" LLM-Judge 完整性 : {s['judge_completeness']:.2f}/5", f" 幻觉率 : {s['hallucination_rate']:.2%}", ] return "\n".join(lines)# ---------------- 端到端 Pipeline ----------------def run_eval(samples: list[EvalSample], system_name: str) -> EvalReport: field_ev = FieldLevelEvaluator() sem_ev = SemanticEvaluator() judge = LLMJudge(mock=True) # 生产环境:mock=False + 传入 DashScope client hallu = HallucinationDetector() report = EvalReport(system_name) for s in samples: report.add( sample_id=s.sample_id, field_result=field_ev.evaluate(s), semantic=sem_ev.evaluate(s), judge=judge.judge(s), hallu=hallu.detect(s), ) return report# ---------------- Demo:对比两个系统 ----------------if __name__ == "__main__": invoice_source = """发票号 INV-2026-0912 开票日期 2026-10-14 购买方 上海智研科技有限公司 金额 12580.00 元 项目 多模态标注服务 数量 1 单价 12580.00 """ reference = { "invoice_no": "INV-2026-0912", "date": "2026-10-14", "buyer": "上海智研科技有限公司", "amount": 12580.00, "item": "多模态标注服务", } # 系统 A:格式差异但基本正确 system_a_pred = { "invoice_no": "INV-2026-0912", "date": "2026/10/14", # 日期格式不同 → 归一化后应通过 "buyer": "上海智研科技有限公司", "amount": "12,580", # 数字字符串带千分位 → 容差通过 "item": "多模态标注服务", } # 系统 B:有幻觉 + 有缺漏 system_b_pred = { "invoice_no": "INV-2026-0912", "date": "2026-10-14", "buyer": "北京云图数据科技有限公司", # 幻觉 "amount": 12000, # 数值错 # 缺 item 字段(完整性差) "tax_rate": "13%", # 原文没有 → 幻觉 } ra = run_eval([EvalSample("s1", invoice_source, system_a_pred, reference)], "System-A (qwen-vl-max + 优化 prompt)") rb = run_eval([EvalSample("s1", invoice_source, system_b_pred, reference)], "System-B (baseline)") print(ra.render()); print() print(rb.render()); print() print("--- System-B 幻觉明细 ---") print(json.dumps(rb.samples[0]["hallucination"], ensure_ascii=False, indent=2))
预期输出:
=== 评测报告: System-A (qwen-vl-max + 优化 prompt) (1 样本) === 字段级 Precision : 1.000 字段级 Recall : 1.000 字段级 F1 : 1.000 语义相似度均值 : 1.000 LLM-Judge 准确性 : 5.00/5 LLM-Judge 忠实度 : 5.00/5 LLM-Judge 完整性 : 5.00/5 幻觉率 : 0.00%=== 评测报告: System-B (baseline) (1 样本) === 字段级 Precision : 0.500 字段级 Recall : 0.400 字段级 F1 : 0.444 语义相似度均值 : 0.610 LLM-Judge 准确性 : 3.00/5 LLM-Judge 忠实度 : 2.00/5 LLM-Judge 完整性 : 3.00/5 幻觉率 : 60.00%
System-A 完美通过;System-B 在四个维度都能被清楚地暴露问题——精度低、召回低、幻觉率高,LLM-Judge 也给了对应的低分。
八、实用建议
1. 从三条评测集开始,别追求完美。挑最核心的业务场景,各标 30 条高质量样本(合计 90 条),当天就能跑起来。等指标基线稳定了再扩容。
2. 字段级指标最先接 CI。每次 prompt 修改、模型升级前,跑一遍字段级评测,任何一个指标掉 5% 以上就阻断合并。LLM-Judge 因为成本和不稳定性,建议只在版本发布前跑。
3. 幻觉率单独看,不要平均到总分。0% 幻觉率是合规红线(尤其金融、医疗场景),任何有幻觉的输出都要能被单独筛出来审阅,不能被"总分 90 分"掩盖。
4. LLM-Judge 必须与人工对齐后再信任。找 100 条样本人工打分,用相关系数(Spearman)或 Cohen's Kappa 验证 Judge 与人工的一致性。低于 0.7 就换 prompt 或换模型,不要盲信。
九、下篇预告
评测搞定之后,第 08 篇讲成本控制的深化:图像 Token 到底怎么算的、不同分辨率的性价比曲线、多模态场景下 Prompt Cache 怎么用、图像预压缩策略、按 Token 预算做质量-成本联合优化的完整方法论。