← 返回上一页

tech

DeepEval 的原理:裁判模型打判断,Python 做算术

从源码看 DeepEval 怎么给 LLM 应用打分:测试用例怎么装箱、指标怎么把裁判判断变成 0–1,以及 G-Eval 为什么要先生成评分步骤。

DeepEval 是给 LLM 应用打分的框架。它不训练模型,也不替你跑 RAG 或 Agent——应用已经答完,它只负责判这一次答得好不好。

原理就一句:另开一个 LLM 当裁判,按提示词给出结构化判断;Python 把这些判断聚成 0–1 分,再和阈值比较。

项目常把自己比成 Pytest。Pytest 是 Python 最常用的测试运行器:你写 test_*.py,里面 assert 条件,跑 pytest 就会执行全部用例,失败标红。Go 里对上的是标准库 testing 和 go test:TestXxx(t *testing.T) 里 got != want 就 t.Fatal。DeepEval 像它们,只因为最后一步同样是断言——assert_test 失败就抛错,所以能挂进测试套件。中间那一步并不像 got == want:开放文本没法精确比对,才把「像不像、有没有编造」交给裁判模型。

整条链因此是:装箱 → 裁判判断 → 算术 → 断言。下面按源码把这四步看完。


一次打分怎么走

应用已经产出一次结果
        ↓
塞进 LLMTestCase
        ↓
Metric.measure():填模板 → 调裁判 LLM → 解析 JSON
        ↓
_calculate_score():计数或归一化,得到 0–1
        ↓
score >= threshold → 通过,否则断言失败

先装箱:评的是这一次交互

要判「这一次」,就得把这一次的材料放进同一个对象。定义在 deepeval/test_case/llm_test_case.py:

class LLMTestCase(BaseModel):
    input: str
    actual_output: Optional[str] = None      # 实际回答
    expected_output: Optional[str] = None    # 金标,可选
    retrieval_context: Optional[List] = None # RAG 检索到的片段
    tools_called: Optional[List] = None      # Agent 实际调了哪些工具
    expected_tools: Optional[List] = None    # 期望调哪些工具

框架默认不会去调你的应用。你先跑完,再把字段填上。多轮对话用 ConversationalTestCase,道理一样,只是装的是一整段对话。

后面的指标是按字段工作的。例如忠实度要从检索文档里抽「事实」,retrieval_context 为空就会直接报错——不是调度写错了,是盒子里缺材料。


再调度:跑完指标,做一次是否通过

入口在 deepeval/evaluate/evaluate.py。evaluate() 批量打分只汇总;assert_test() 在失败时抛 AssertionError,相当于 Go 测试里的 t.Fatal:

def assert_test(test_case=None, metrics=None, ...):
    test_result = execute_test_cases([test_case], metrics, ...)[0]
    if not test_result.success:
        raise AssertionError(...)

「算不算通过」不在调度里另写一套逻辑,而在指标基类 deepeval/metrics/base_metric.py:

def is_successful(self) -> Optional[bool]:
    if self.error is not None:
        self.success = False
    else:
        self.success = self.score >= self.threshold  # 默认 0.5
    return self.success

到这里,Pytest / go test 那一层已经结束了:有用例、有失败即红。真正决定分数的,是每个 Metric.measure()。


指标:裁判给出判断,Python 做成比例

多数指标目录长得一样:templates/*.txt 是问裁判的提示词,schema.py 规定 JSON 长什么样,_calculate_score() 把解析结果变成一个数。

忠实度(Faithfulness)把这条原理写得很直。它要回答的是:模型的回答有没有超出检索文档。faithfulness.py 里分四步,每步都是一次(或一批)裁判调用,最后才做除法:

self.truths = self._generate_truths(retrieval_context, multimodal)  # 文档里的事实
self.claims = self._generate_claims(actual_output, multimodal)      # 回答里的主张
self.verdicts = self._generate_verdicts(multimodal)                 # 每条主张是否被事实支持
self.score = self._calculate_score()
def _calculate_score(self) -> float:
    if not self.verdicts:
        return 1
    ok = sum(1 for v in self.verdicts if v.verdict.strip().lower() != "no")
    return ok / len(self.verdicts)   # 没被判成矛盾的主张占比

答案相关性(Answer Relevancy)也是「相关条数 / 总条数」。算法没有隐藏模型,有设计的是提示词怎么拆:先抽、再判、再计数。换裁判模型或改提示词,同一个用例分数会变;threshold=0.5 只是「过线」的约定,不是测出来的物理量。

仓库里还有两类不走裁判的尺子,避免和上面混在一起:字符串是否一致用 ExactMatchMetric;metrics/ragas.py 则是直接调用 RAGAS 库。Agent 是否调对工具、对话是否跑题,绝大多数仍是「提示词 + JSON + 聚合」,和忠实度同一原理,只是问的问题不同。

于是会碰到下一问:忠实度问的问题是写死的;业务上要评「语气对不对」「有没有按话术来」时,尺子从哪来?


G-Eval:标准改由你写,裁判仍按同一套办法判

没有 G-Eval 时,上面那个问题其实没有现成出口。内置指标问的都是固定问题:忠实度只看回答有没有和检索文档打架,答案相关性只看有没有答到用户的问。退货政策换一种说法、客服语气硬不硬、有没有按话术来——这些都不在它们的题面里。剩下两条路更窄:ExactMatchMetric 要求字面一致,「30 天内免费退货退款」和「30 天无理由全额退款」会直接判 0;或者自己继承 BaseMetric,从提示词、JSON schema 到 _calculate_score() 全写一遍。

G-Eval 就是把这第三步收成一个现成指标:你只提供 criteria(还可以指定裁判该看哪些字段),其余仍走 LLM-as-a-judge。实现在 g_eval.py,方法来自论文 Liu et al., 2023。

还是「回答对不对」这件事,有了它就不用自己写裁判流水线:

from deepeval.test_case import LLMTestCase, SingleTurnParams
from deepeval.metrics import GEval

correctness = GEval(
    name="正确性",
    criteria="对照期望输出,判断实际回答在事实上是否正确。",
    evaluation_params=[
        SingleTurnParams.ACTUAL_OUTPUT,
        SingleTurnParams.EXPECTED_OUTPUT,
    ],
    threshold=0.5,
)

test_case = LLMTestCase(
    input="这双鞋不合适怎么办?",
    actual_output="30 天内可以免费退货退款。",
    expected_output="我们提供 30 天无理由全额退款。",
)

correctness.measure(test_case)
# 分数、理由写在 correctness.score / .reason / .success 上

裁判被要求看的只有实际输出和期望输出;criteria 就是尺子。跑起来时并不是对着这段话直接吐一个整数。论文还要求一步 Chain-of-Thought:先把 criteria 展开成 3–4 条可执行的评分步骤,再按步骤打 0–10 分(再归一化到 0–1)。若接口能返回 token 的 logprobs,还会用分数这个 token 的概率做加权,减轻「只输出 7 或 8」带来的抖动。

你写的 criteria
    → 生成 3–4 条 evaluation steps
    → 按 steps 打分

这两步对应两份提示词,由 _get_prompt() 渲染:

文件 作用
metrics/g_eval/templates/generate_evaluation_steps.txt 把 criteria 变成 steps[]
generate_evaluation_results.txt 按 steps 出分,并写 reason
def _generate_evaluation_steps(self, multimodal: bool) -> List[str]:
    if self.evaluation_steps:          # 你自己传入步骤,就跳过生成
        return self.evaluation_steps
    prompt = self._get_prompt(
        "generate_evaluation_steps",
        criteria=self.criteria,
        parameters=...,
    )
    return generate_with_schema_and_extract(...)  # 从 JSON 里取出 steps

构造 GEval 时如果已经传入 evaluation_steps,第一步不会再调模型——论文也更推荐你自己写清步骤,而不是每次让裁判现编。

所以 G-Eval 并没有另起一套打分哲学:它只是把「问什么」从写死的忠实度,换成你提供的标准,并用评分步骤把这次提问变得可重复一点。