AB
AiBoss
Tutorials

用 DeepEval 搭建可持续维护的 Gold Dataset 评测体系

Tutorials

用 DeepEval 搭建可持续维护的 Gold Dataset 评测体系

DeepEval 能让评测跑起来,但跑起来不等于能长期维护。这篇教程从决定论指标与 LLM judge 的分层讲起,带你用 Git 管理 JSONL 格式的 Gold Dataset,按 Core / Regression / Production 三层组织用例,接入 pytest 与 CI,并用 SQLite 保存执行历史,最后给出 judge 与人工标注一致性的校验方法。

很多团队说「我们上了评测」,实际做的只是把评测框架 pip install 了一遍:测试文件多了一个,CI 里能跑,头一个月看起来一切正常。问题通常在第二个月出现——用例还是最初写的那二十条,线上新出现的失败模式没人补进测试,换了模型之后也没人说得清「是不是还在用同一把尺子量」。框架能让评测变得可执行,但不会让它变得可持续。这中间的差距,要靠团队自己持有的 Gold Dataset(带标准答案的数据集)来填。

这篇教程讲的是怎么在 DeepEval 上把这件事落地:以决定论指标为地基,把 LLM judge 有节制地叠加上去;数据集用 Git 做版本管理,执行历史留在自己手里;新增用例的入口对准线上真实失败。顺序反了,成本和可复现性会一起丢掉。

准备工作

环境与依赖

DeepEval 是一个 Python 包,要求 Python 3.9 及以上。本文示例在 Python 3.12 环境下验证。安装命令:

pip install -U deepeval

具体版本号、依赖范围与许可证信息请以官网当前信息为准。安装完成后可以用 deepeval --help 确认命令行可用。

密钥:不是所有指标都需要

这是规划阶段最该先搞清楚的一件事。DeepEval 的指标按「是否调用 LLM」分成两类,只有后者需要模型 API 密钥:

指标打分方式初始化是否需要 LLM 客户端
ExactMatchMetric完全一致,必须提供 expected_output不需要
PatternMatchMetric正则 fullmatch不需要
AgentLoopDetectionMetric检测重复、停滞、循环,需要 trace不需要
JsonCorrectnessMetric用 pydantic 做 schema 校验需要
ToolCorrectnessMetric默认比对工具名,属决定论判定需要
GEval自定义标准的 LLM judge需要

注意表里有一处容易被忽略的错位:JsonCorrectnessMetric 的判定本身是决定论的,但初始化时要传 LLM 客户端。所以「决定论指标 = 零密钥」这个等式并不成立,规划 CI 任务时要按实际初始化需求来分。

仓库结构

建议一开始就把评测数据和测试代码分开放,数据集按层分目录,一个文件对应一次事故或一个来源:

evals/
├── core/
│   └── refund.jsonl
├── regression/
│   ├── 2026-10-inc1042.jsonl
│   └── 2026-11-inc1103.jsonl
└── production/
    └── 2026-11.jsonl
tests/
├── deterministic/
│   ├── test_core.py
│   └── test_regression.py
└── judge/
    └── test_production.py

操作步骤

第一步:先写一个能跑的最小测试

DeepEval 的核心设计是把测试用例和指标转换成 pytest 的断言,入口是 assert_test(test_case, [metric])。这意味着你不需要学一套新的执行体系,现有 CI 直接就能接。

# tests/test_refund.py
from deepeval import assert_test
from deepeval.metrics import ExactMatchMetric
from deepeval.test_case import LLMTestCase

def test_refund_policy_wording():
    assert_test(
        LLMTestCase(
            input="返品は何日以内ですか",
            actual_output="30日以内です",
            expected_output="30日以内です",  # ExactMatch 必需
        ),
        [ExactMatchMetric(threshold=1.0)],
    )

运行方式:

deepeval test run tests/test_refund.py

如果用了 ExactMatchMetric 却没传 expected_output,会直接抛 MissingTestCaseParamsError,提示该指标下 expected_output 不能为 None。测试因为参数缺失立刻失败,比「悄悄通过」对 CI 更健康,但复制示例代码时要注意这一点。

第二步:把决定论指标铺成地基

完全一致、正则匹配这类判定,结果每次都一样、成本为零、出问题容易定位,应该作为整个评测体系的第一层。只用决定论指标跑四条用例,输出大致是这样:

✓ Evaluation completed 🎉!
(time taken: 0.29s | token cost: None)
» Test Results (4 total tests):
» Pass Rate: 50.0% | Passed: 2 | Failed: 2

这里有一个实测才会发现的坑:PatternMatchMetric 用的是 fullmatch,不是部分匹配。输出整体必须匹配正则,否则就是 0 分。所以「只要出现某个词就算不合格」这种禁止语场景,不能直接拿关键词当 pattern。可以用否定先行断言,在 fullmatch 语义下实现禁止语判定:

from deepeval.metrics import PatternMatchMetric

# 出现「返金不可」或「絶対に」即判 0 分
metric = PatternMatchMetric(pattern=r"(?s)(?!.*(返金不可|絶対に)).*")

# "30日以内であれば返金できます" -> 1.0
# "返金不可です" -> 0.0
# "絶対に保証します" -> 0.0

第三步:划清 ExactMatch 的适用边界

强调决定论,很容易滑向「那就全部用 ExactMatch」,这是反方向的失败。下面这些场景不该用完全一致:

  • 「30日以内です」和「30 days」业务含义相同,但会被判 0 分;
  • 自由文本回答本来就有多个正确答案,字符串比对会误杀正确输出;
  • 客服话术一改,测试就红,久而久之没人再去修。

ExactMatch 适合的是结果唯一确定的对象:支付 ID、日期格式、选项值、禁止语、结构化字段。自由文本交给 rubric 型的 LLM judge,决定论只覆盖「机器能唯一判定」的部分。这条线划在哪里,决定了这套评测半年后还活不活着。

第四步:认清框架与数据集是两个层

用 DeepEval 最先要建立的分层意识是这个:

层实体谁维护会不会过时
框架评测逻辑、指标实现开源社区随版本更新改善
Gold Dataset带标准答案的真实用例企业自己放着不管必然过时
判定标准业务的合格线(阈值)企业自己业务变更就要重审

pip install -U deepeval 改善的只有第一层。第二、第三层不会因为框架好用就自动变好,这正是「工具装了但评测不起作用」的真正原因。

DeepEval 本身提供了 EvaluationDataset,支持从 CSV / JSON / JSONL 导入、从文档合成生成、用 create_version 做版本管理、用 evaluate 或 evals_iterator 执行。需要知道的是,push 与 pull 走的是托管平台的 API。这不是缺陷,但在数据治理上必须做判断:敏感业务数据能不能放到厂商云上、版本历史要不要自己留、是否只想用开源部分。稳妥的做法是以 Git 管理为主:CSV / JSONL 进仓库,走评审合并,版本靠提交历史追溯。这样不依赖厂商、数据位置清楚、也经得起审计。而且不用 push / pull 也能从本地 JSONL 组装数据集并执行,等真的需要多团队共享时再引入云端管理也不迟。

第五步:按三层组织数据集

把所有用例塞进一个数据集,不同目的的测试会混在一起,失败时无法判断含义。按三层拆开:

层内容执行频率失败意味着什么
Core业务上绝对不能破的中核用例每次提交不可发布
Regression历史上曾经失败过的用例每次提交回归,必须修
Production来自线上的新用例(已评审)定期 + 发布前反映当前质量水位

Regression 是企业价值的中心:它保证「修过一次的失败不会再回来」,换模型、改提示词都不能丢。每次换模型都要把同样的 bug 再踩一遍的团队,缺的就是这一层。

数量上的现实配比是:Core 20~50 条,Production 每周新增 5~10 条,Regression 只增不减。但条数本身不是质量指标——20 条里挂 1 条,通过率就动 5 个百分点,把这个差值读成「劣化」很危险。用例少的时候,看的是哪一条挂了,而不是通过率的差。

第六步:写 JSONL 用例并保留来源

JSONL 一行一条用例。additional_metadata 会被 Golden 原样保留,用来记录来源:

{"input": "返品は何日以内ですか", "expected_output": "30日以内です", "additional_metadata": {"layer": "core", "incident": "INC-1001", "owner": "cs-team", "reviewed_by": "yamada", "added_at": "2026-11-02"}}

导入 API 是按文件粒度设计的,所以按层遍历目录:

from pathlib import Path
from deepeval.dataset import EvaluationDataset

def load(layer: str) -> EvaluationDataset:
    dataset = EvaluationDataset()
    for path in sorted(Path(f"evals/{layer}").glob("*.jsonl")):
        dataset.add_goldens_from_jsonl_file(str(path))
    return dataset

第七步:把数据集挂到 pytest 参数化上

用 pytest 的参数化驱动,一条用例失败时测试 ID 能直接指出是哪一条,人追得下去——这是评测体系能长期维持的最低条件。run_app() 是调用自家应用、走与线上相同路径的函数:

import pytest
from deepeval import assert_test
from deepeval.metrics import ExactMatchMetric
from deepeval.test_case import LLMTestCase
from evals_loader import load, run_app

dataset = load("core")

@pytest.mark.parametrize(
    "golden",
    dataset.goldens,
    ids=[g.input for g in dataset.goldens],
)
def test_core(golden):
    assert_test(
        LLMTestCase(
            input=golden.input,
            actual_output=run_app(golden.input),
            expected_output=golden.expected_output,
        ),
        [ExactMatchMetric(threshold=1.0)],
    )

第八步:定义单条用例的完成标准

把评审条件固定下来,数据集质量才不会依赖某个人。一条用例入库前应满足:

  1. 输入是线上真实发生过的(或等价的复现输入);
  2. expected_output 已获业务负责人认可;
  3. 明确标注属于 Core / Regression / Production 哪一层;
  4. 来源(事故 ID、日期、提供者)写进 additional_metadata;
  5. 与已有用例不重复,包括近似重复。

第五条最容易被轻视。大量近似用例会让通过率看起来变好,把真正该挂的用例稀释掉。新增时用嵌入相似度查一下近邻,或者至少用输入归一化后的键做去重。

第九步:把线上失败变成入口

「把线上失败回流到数据集」如果没有机制,就只是口号。至少要定清三个角色:

  • Eval Owner(开发负责人):维护分层、阈值和 CI;
  • Reviewer(业务负责人):审批 expected_output;
  • Contributor(一线、客服、支持):提供失败案例。

入口只留一条:产品内的反馈(👎)或升级记录 → Issue 模板 → 数据集 PR。Issue 模板把输入、实际输出、期望输出、来源设为必填项。评审在 PR 上做,只有通过评审的才进 Regression。

第十步:处理敏感信息

入口是线上数据,这一步绕不过去:

  • 客户名、订单号、地址、联系方式先替换成假数据再提交;
  • additional_metadata 里不放客户 ID 或内部 URL;
  • 仓库设为 private,必要时把评测数据拆到独立仓库;
  • CI 中设置 DEEPEVAL_TELEMETRY_OPT_OUT=1。

「以后再删」是不起作用的,唯一现实的管控是在入口处拦下。

第十一步:CI 里把两类任务分开

决定论指标和 LLM judge 必须拆成两个 job。PR 上只跑决定论层并阻断合并,judge 层放到夜间跑、先只做通知。没有这个隔离,LLM 的抖动会让 PR 变红,最后所有人都学会无视它。

name: evals
on:
  pull_request:
  schedule:
    - cron: "0 18 * * *"

jobs:
  deterministic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install "deepeval==4.3.2"
      - run: deepeval test run tests/deterministic --identifier "pr-${{ github.sha }}"
        env:
          DEEPEVAL_TELEMETRY_OPT_OUT: "1"

  judge:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install "deepeval==4.3.2"
      - run: deepeval test run tests/judge --identifier "nightly-${{ github.sha }}"
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          DEEPEVAL_TELEMETRY_OPT_OUT: "1"
          DEEPEVAL_LOCAL_STORE: "sqlite"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: deepeval-db
          path: .deepeval/deepeval.db

版本号请按官网当前信息固定,不要照抄示例里的数字。

第十二步:把执行历史留在自己手里

「准确率 92%」这种数字,说不清是在哪个数据集版本上测的就没有意义。设置 DEEPEVAL_LOCAL_STORE=sqlite 后,每次 deepeval test run 都会往 .deepeval/deepeval.db 写一行记录,涉及的表包括 test_runs、test_cases、metric_data、traces、spans。

# 执行列表(identifier 里放提交 SHA 便于追踪)
deepeval inspect --list

# 用 SQL 比较每次执行的指标均值
sqlite3 .deepeval/deepeval.db \
  "SELECT r.id, m.name, avg(m.score) FROM metric_data m JOIN test_runs r ON r.id = m.test_run_id GROUP BY r.id, m.name"

把提交 SHA 传给 --identifier,就能从执行历史反查这次结果对应哪个数据集版本。数据集放 Git,结果放 SQLite 或 artifact,两者都在自己管理之下。

一个完整示例

把上面的步骤串成一个最小可跑通的流程。假设要评测一个退货政策问答应用。

1. 建目录与数据文件

mkdir -p evals/core tests/deterministic

写入 evals/core/refund.jsonl:

{"input": "返品は何日以内ですか", "expected_output": "30日以内です", "additional_metadata": {"layer": "core", "incident": "INC-1001", "owner": "cs-team", "reviewed_by": "yamada", "added_at": "2026-11-02"}}
{"input": "返金は可能ですか", "expected_output": "30日以内であれば返金できます", "additional_metadata": {"layer": "core", "incident": "INC-1002", "owner": "cs-team", "reviewed_by": "yamada", "added_at": "2026-11-02"}}

2. 写加载器(evals_loader.py,内容即第六步的 load(),另加一个 run_app() 调用自家应用)

3. 写测试(tests/deterministic/test_core.py,内容即第七步的参数化测试)

4. 本地执行

DEEPEVAL_TELEMETRY_OPT_OUT=1 deepeval test run tests/deterministic --identifier "local-$(git rev-parse --short HEAD)"

5. 查看历史

deepeval inspect --list

如果两条用例都通过,输出里会显示 100% 通过率;把其中一条的 expected_output 改错再跑一次,就能看到失败用例以输入文本作为测试 ID 出现在报告里。这一步验证的是「失败可定位」这个前提是否成立。

注意事项

judge 本身需要被校验

LLM-as-a-Judge 是 DeepEval 的核心能力,但企业使用需要三重管控。第一重是先测 judge 本身是否可信:不验证 judge 与人类判断的一致程度,就拿它的分数做业务决策是危险的。做法是让两个人各标注约 100 条,再与 judge 的判定比对,除了看一致率,还要看扣除偶然一致后的 Cohen's κ:

def cohen_kappa(human: list[int], judge: list[int]) -> float:
    n = len(human)
    agree = sum(h == j for h, j in zip(human, judge)) / n
    ph = sum(human) / n
    pj = sum(judge) / n
    expected = ph * pj + (1 - ph) * (1 - pj)
    return (agree - expected) / (1 - expected)

经验上 κ 低于 0.6 就该回头调 judge 的提示词或换模型;judge 换模型后必须重新测量,κ 下降就按回归处理。judge 是评测的尺子,尺子本身不做检定是不行的。

决定论优先于 judge

能用完全一致或正则判定的部分交给 LLM judge,在成本和可复现性上都是亏的。顺序是:能判定的用决定论,判定不了的才用 LLM。只守住这一条,评测体系的稳定性就会明显不同。

测抖动与控成本

# 同一测试跑 3 次,观察分数波动
deepeval test run tests/judge -r 3

# 未修改的测试结果走缓存,压低成本
deepeval test run tests/judge -c

用 -r 跑出来分数每次都变的指标,不能把阈值卡在边缘。阈值应当由业务要求决定,不能为了迁就模型而放宽,这条值得写进团队规范。

防止数据集腐化的六条规则

  1. 线上失败经评审后回流到 Regression,入口不要堵;
  2. 业务要求变了就更新阈值,不要反过来迁就模型;
  3. 定期盘点「已经没人用的测试」,失去意义的用例会干扰判断;
  4. 数据集本身做版本管理,并与评测结果成对记录;
  5. 不要添加近似重复用例,它会稀释通过率、误导判断;
  6. 业务流已废弃的用例,写明理由后删除,留着只会制造误报。

第四条最常被忽略。半年前的数字和今天的数字如果不可比,就无从判断是变好了还是变差了。

需要提前知道的限制

  • PatternMatchMetric 是 fullmatch 语义,禁止语场景需要否定先行断言;
  • JsonCorrectnessMetric 判定是决定论的,但初始化需要 LLM 客户端;
  • 数据集的 push / pull 依赖托管平台 API,纯开源方案下请以 Git + 本地 JSONL 为主;
  • ExactMatchMetric 缺少 expected_output 会直接抛错,复制示例时注意补齐参数。

框架版本、指标行为与托管能力都可能变化,实际使用前请以官网当前信息为准。