当前位置:首页>排行榜>多模态输出质量评测工程

多模态输出质量评测工程

  • 更新时间 2026-10-15 08:54:25
多模态输出质量评测工程

标题:多模态输出质量评测工程

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
输出内容是否有原文没有的信息
幻觉率、grounding rate
编造发票号、瞎填税率
完整性 Completeness
该提取的字段是否有缺漏
Recall、字段覆盖率
20 个字段只提取了 15 个

三个维度都要看,因为它们互相冲突:

  • 保守模型缺漏多(完整性差)但幻觉少(忠实度好)
  • 激进模型什么都填(完整性好)但幻觉多(忠实度差)
  • 单看准确性会忽略"没提取的那部分",也没法区分"错值" 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 设计原则:

  1. 给足上下文
    :把原始文档、参考答案、系统输出全传进去,让 Judge 有据可依
  2. 拆分维度打分
    :不要只让它给"总分",而是分别打准确性、忠实度、完整性
  3. 要求 JSON 输出
    :结构化结果才能纳入统计
  4. 温度设 0
    :保证评分可复现
  5. 交叉验证
    :至少在 100 条人工标注样本上验证 LLM-Judge 与人工的一致性,Kappa > 0.7 才算可用

五、幻觉检测工程

幻觉检测的核心逻辑很简单:输出中的每个具体值(数字、名称、日期),都要能在原始文档中找到出处。

实现分三步:

  1. 原子提取
    :递归拆解 prediction 里的每个字段,把值的最小可查证单元提取出来(例如 "12580.00"、"上海智研科技")
  2. 归一化匹配
    :源文档去空格、去逗号、小写化;提取出的原子也做同样处理,然后做子串匹配
  3. 分类统计
    :命中的记为 grounded,未命中的记为 hallucinated;输出幻觉率和明细列表

进阶版可以加"近似匹配"(编辑距离 ≤2)来容忍 OCR 噪声,或用 embedding 检索来处理语义等价但字面不同的情况(如"沪"vs"上海")。但生产环境从字面匹配开始就够用了——先能测出 80% 的明显幻觉,比追求 100% 完美却不上线要有价值得多。

六、评测集构建建议

评测集不是越大越好,但结构必须合理:

数量:初期 100~300 条足够。核心场景每类 30~50 条,边缘 case 覆盖到即可。分布比数量更重要。

标注策略:

  • 每条样本双人标注 + 第三方仲裁不一致的字段
  • 字段结构固定,避免"自由填写"(否则聚合不了指标)
  • 标注时同步记录"这条为什么难",形成难度分层

采样策略:

  • 60% 来自线上真实流量(按业务场景分层)
  • 30% 精心构造的边缘 case(模糊图、非常规格式、长尾字段)
  • 10% 对抗样本(故意干扰、易混淆)

持续更新:每个季度回顾一次线上 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 预算做质量-成本联合优化的完整方法论。


随机文章