数据截至 (上游 commit 11e53e8eedb3)
第 1 章 · 地基:测试用例与指标契约
这章讲什么: 评测有两块地基——装数据的
LLMTestCase和 规定指标长什么样的BaseMetric。先把这两个搞清楚,后面所有指标(02、03 章)才有共同的词汇。本章不深入任何具体指标怎么算分。
1.1 一条评测样本:LLMTestCase
它要解决的小问题: 评测一条样本,到底需要喂哪些数据?答案取决于你用哪个指标——「答案相关性」只需要 input + actual_output;「上下文召回」还需要 retrieval_context 和 expected_output。所以 LLMTestCase 是一个字段大多可选的容器,按指标所需填。
它是一个 Pydantic 模型,核心字段(test_case/llm_test_case.py:351 起的 LLMTestCase):
| 字段 | 含义 | 谁会用到 |
|---|---|---|
input | 给被测系统的输入(唯一必填) | 几乎所有指标 |
actual_output | 被测系统的真实输出 | 几乎所有指标 |
expected_output | 理想答案(你给的参考) | 上下文召回、G-Eval 等 |
context | 理想/真值上下文 | 幻觉检测等 |
retrieval_context | RAG 实际检索到的片段 | 忠实度、上下文精度/召回 |
tools_called | agent 实际调用的工具列表 | 工具正确性等 agent 指标 |
expected_tools | 期望调用的工具 | 工具正确性 |
token_cost / completion_time | 成本与延迟 | 性能类断言 |
一个直觉: LLMTestCase 就是一张「考卷 + 学生作答 + 参考答案 + 草稿纸」。不同的阅卷标准(指标)只看其中几栏。
怎么知道某个字段必填? 每个指标声明自己要哪些字段。例如答案相关性指标在类上写死了:
# metrics/answer_relevancy/answer_relevancy.py:31 —— 该指标的必填字段
_required_params: List[SingleTurnParams] = [
SingleTurnParams.INPUT,
SingleTurnParams.ACTUAL_OUTPUT,
]
SingleTurnParams 是个枚举(test_case/llm_test_case.py:180,旧名 LLMTestCaseParams 已废弃),把字段名做成可传递的常量。运行时 check_llm_test_case_params(...)(metrics/utils.py:320)会检查用例里这些字段是否都非空,缺了就报错或跳过。这样「指标需要什么」是声明式的,引擎能在调 LLM 之前就拦下不合格的用例。
1.2 工具调用怎么表示:ToolCall
agent 指标要判断「工具调对没」,就需要一个结构化的工具调用表示。ToolCall(test_case/llm_test_case.py:248)记录 name、input_parameters、output 等(新增 type 字段区分 FUNCTION / MCP 两类调用),并且自定义了 __eq__ 和 __hash__。
为什么要自定义 hash? 因为要把工具调用放进集合做「期望集 vs 实际集」的比较,而 input_parameters 里常嵌套 dict/list 这些不可哈希的东西。它用一个递归的 _make_hashable(test_case/llm_test_case.py:213)把任意嵌套结构转成可哈希的元组/frozenset(不可哈希的叶子退化成类型标记,保证 __hash__ 与 __eq__ 一致):
# 示意,非源码:把嵌套结构变可哈希的思路
def make_hashable(obj):
if isinstance(obj, dict): # dict → 排序后的键值对元组
return tuple(sorted((k, make_hashable(v)) for k, v in obj.items()))
if isinstance(obj, (list, tuple)): # list → 元素元组
return tuple(make_hashable(x) for x in obj)
return obj # 基本类型原样
重点看:比较两个工具调用是否「相同」时,只比 name + input_parameters + output(见 ToolCall.__eq__),description/reasoning 这些解释性字段不参与,因为它们不影响「这步调用对不对」。
1.3 多轮对话:ConversationalTestCase
聊天机器人的评测样本不是单条问答,而是一串轮次。ConversationalTestCase(test_case/conversational_test_case.py:214)核心就是 turns: List[Turn],每个 Turn(:60)是 {role: "user"|"assistant", content: str},外加可选的 chatbot_role(用于「角色坚持度」这类指标)。
对应地有一套并行的 BaseConversationalMetric 基类和 MultiTurnParams 枚举——单轮、多轮两条平行世界,结构对称。
1.4 指标的统一契约:BaseMetric
它要解决的小问题: 引擎要能统一地跑「任意指标」,就必须有一份所有指标都遵守的契约——不管内部怎么算分,对外都长一个样。
BaseMetric(metrics/base_metric.py:54)规定了这份契约。它有一批结果字段和两个必须实现的方法,外加一个基类已给出默认实现的 is_successful():
结果字段(算完往这里填) 方法(前两个必须实现)
┌─────────────────────────┐ ┌──────────────────────────────┐
│ threshold 阈值 │ │ measure(test_case) → float │ 同步打分
│ score 0~1 分数 │ │ a_measure(test_case)→ float │ 异步打分
│ success 通过与否 │ │ is_successful() → bool │ 分数 vs 阈值
│ reason 一句理由 │ └──────────────────────────────┘
│ error 出错信息 │
│ evaluation_cost 花了多少钱│
└─────────────────────────┘
measure / a_measure 是 @abstractmethod,子类必须实现(metrics/base_metric.py:83、:87)。约定俗成的分工:
measure()同步入口。当async_mode=True时,它内部其实是起一个事件循环去await self.a_measure(...)——也就是说真正的算分逻辑写在异步版里,同步版只是个壳。a_measure()异步实现,是干活的地方。is_successful()现在由BaseMetric直接给出实现(metrics/base_metric.py:93):threshold为None时返回None(不判成败),否则算self.score >= self.threshold,并包了 try/except:若score没算出来(None),不会崩,而是判为失败。各子类(如GEval、AnswerRelevancyMetric)不再各自重复这份实现。
一个容易忽略的细节——指标方法会被自动「上追踪」。 BaseMetric 用了 __init_subclass__ 钩子(metrics/base_metric.py:77):每当有人继承它定义新指标,框架就调 observe_methods(cls)(:81)给它的方法套上 span。这就是为什么连指标内部的调用都能出现在追踪里——你不用手动加任何东西(这套机制见 04 章)。
1.5 指标怎么拿到 prompt:PromptMixin
BaseMetric 继承自 PromptMixin(metrics/base_metric.py:22)。它提供 _get_prompt(method, ...),把指标的 prompt 外置成模板文件,按「指标类名 + 方法名」去解析(resolve_template("metrics", self.__class__.__name__, method, ...))。
好处有二:
- prompt 和代码分离,模板是纯文本
.txt(Jinja2),改 prompt 不动逻辑。 - 同一份模板能渲染多模态变体(
multimodal=True时注入图片处理规则)。
例如 G-Eval 的模板就放在 metrics/g_eval/templates/ 下(generate_evaluation_steps.txt、generate_evaluation_results.txt),文件名正对应 _get_prompt("generate_evaluation_steps") 的方法参数。02 章会读到这些模板的真实内容。
1.6 小结:这章给了你什么
LLMTestCase= 一条样本,字段大多可选,按指标所需填;_required_params声明式地说明每个指标要哪些字段。BaseMetric= 所有指标的契约:填score/reason/success,实现measure/a_measure(is_successful基类已默认实现);同步壳 + 异步实现是通用模式。- prompt 外置成模板,按类名+方法名解析。
带着这套词汇,进入 02 章看「指标内部到底怎么把一段文本变成一个分数」。
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 单轮用例 | test_case/llm_test_case.py | LLMTestCase、SingleTurnParams、RetrievedContextData |
| 工具调用 | test_case/llm_test_case.py | ToolCall、_make_hashable |
| 多轮用例 | test_case/conversational_test_case.py | ConversationalTestCase、Turn、MultiTurnParams |
| 指标契约 | metrics/base_metric.py | BaseMetric、BaseConversationalMetric、PromptMixin |
| 必填字段校验 | metrics/utils.py | check_llm_test_case_params |
| prompt 解析 | templates/resolver.py | resolve_template |