
用 DeepEval 搭建可持续维护的 Gold Dataset 评测体系
用 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)],
)
第八步:定义单条用例的完成标准
把评审条件固定下来,数据集质量才不会依赖某个人。一条用例入库前应满足:
- 输入是线上真实发生过的(或等价的复现输入);
- expected_output 已获业务负责人认可;
- 明确标注属于 Core / Regression / Production 哪一层;
- 来源(事故 ID、日期、提供者)写进 additional_metadata;
- 与已有用例不重复,包括近似重复。
第五条最容易被轻视。大量近似用例会让通过率看起来变好,把真正该挂的用例稀释掉。新增时用嵌入相似度查一下近邻,或者至少用输入归一化后的键做去重。
第九步:把线上失败变成入口
「把线上失败回流到数据集」如果没有机制,就只是口号。至少要定清三个角色:
- 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 跑出来分数每次都变的指标,不能把阈值卡在边缘。阈值应当由业务要求决定,不能为了迁就模型而放宽,这条值得写进团队规范。
防止数据集腐化的六条规则
- 线上失败经评审后回流到 Regression,入口不要堵;
- 业务要求变了就更新阈值,不要反过来迁就模型;
- 定期盘点「已经没人用的测试」,失去意义的用例会干扰判断;
- 数据集本身做版本管理,并与评测结果成对记录;
- 不要添加近似重复用例,它会稀释通过率、误导判断;
- 业务流已废弃的用例,写明理由后删除,留着只会制造误报。
第四条最常被忽略。半年前的数字和今天的数字如果不可比,就无从判断是变好了还是变差了。
需要提前知道的限制
- PatternMatchMetric 是 fullmatch 语义,禁止语场景需要否定先行断言;
- JsonCorrectnessMetric 判定是决定论的,但初始化需要 LLM 客户端;
- 数据集的 push / pull 依赖托管平台 API,纯开源方案下请以 Git + 本地 JSONL 为主;
- ExactMatchMetric 缺少 expected_output 会直接抛错,复制示例时注意补齐参数。
框架版本、指标行为与托管能力都可能变化,实际使用前请以官网当前信息为准。