跳到主要内容

数据截至 (上游 commit 0261ea4f33d4)

打分器:启发式指标与 LLM 裁判的两套工程范式

30 秒导读: 第 4 章讲的评估引擎负责"跑",本章讲的是"打分"本身。Opik 把几十个打分器 全部收进 sdks/python/src/opik/evaluation/metrics/,用同一个 BaseMetric.score(...) -> ScoreResult 契约兜住两种完全不同的东西:一种是不联网、确定性的启发式算法(BLEU、正则、JSON 校验), 另一种是要花钱调模型的 LLM 裁判(幻觉检测、答案相关性、G-Eval)。


1. 这是什么(零基础也能懂)

一句话定义: 这是一个打分器库——给它一条样本(输入、模型输出、参考答案、检索到的上下文), 它还你一个 0~1 的分数加一句理由。

为什么需要它。 你做了个 RAG 问答系统,改了一版 prompt。新版更好还是更差? "我看着还行"不是答案,你需要一个能对 200 条样本自动跑出数字的东西。这就是 metric。

两种打分器,两种代价。 这是理解整章的第一个分岔:

启发式指标(heuristics/)LLM 裁判(llm_judges/)
怎么算纯本地算法:编辑距离、n-gram 重叠、正则把样本塞进 prompt,问另一个 LLM
要不要参考答案大多要 reference大多不要,靠 context 或评分标准
确定性是,同输入同输出否,同输入可能不同分
花钱不花每条样本至少一次 API 调用
延迟毫秒
能判断什么"字面像不像""语义对不对、有没有编造、有没有帮上忙"
适合放哪CI 断言(阈值一挂就能卡 PR)离线回归评估、线上抽样

用起来什么样。 打分器可以脱离评估引擎单独调用,这是它设计上的一个关键点:

from opik.evaluation.metrics import LevenshteinRatio, Hallucination

# 启发式:本地算,立刻出分
LevenshteinRatio(track=False).score(output="Hello, World", reference="Hello, World!")
# -> ScoreResult(name='levenshtein_ratio_metric', value=0.96)

# LLM 裁判:会真的调一次模型
Hallucination().score(
input="法国首都是哪?",
output="巴黎,人口两千万。",
context=["巴黎是法国首都,人口约 210 万。"],
)
# -> ScoreResult(value=1.0, reason='OUTPUT 把人口夸大了十倍…')

一句话直觉: 把 metric 想成单元测试的 assert——启发式指标是 assert a == b 这种硬断言, LLM 裁判是"请一位有经验的同事看一眼,给个 1-10 分并说说理由"。两者装在同一个插座上。


2. 顶层全景(它大概怎么转)

怎么读这张图: 从上往下是调用方向。左右两支是两种打分范式,它们在最上面共享同一个契约, 在最下面 LLM 裁判这一支才多出一层"模型接入"。

评估引擎(第 4 章) / 单独手动调用 / 线上规则(第 6 章)


┌──────────────────────────────────────┐
│ 统一契约 BaseMetric │
│ score() / ascore() → ScoreResult │
│ track=True 时把自己包成一条 span │
└──────────────┬───────────────────────┘

┌──────────────────┴──────────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────────┐
│ 启发式 heuristics/ │ │ LLM 裁判 llm_judges/ │
│ 本地算法,无网络 │ │ template + parser + │
│ 确定性、可当断言 │ │ metric 三件套 │
└────────────────────┘ └───────────┬────────────┘

┌───────────▼────────────┐
│ 模型接入层 models/ │
│ LiteLLM 统一网关 / │
│ Anthropic 原生分流 │
└────────────────────────┘

部件一句话职责:

部件干什么在哪个文件
BaseMetric所有打分器的父类,定义 score/ascore,顺手把自己接进追踪sdks/python/src/opik/evaluation/metrics/base_metric.py
ScoreResult打分结果的唯一数据结构(值 + 名 + 理由 + 失败标记)sdks/python/src/_opik/_score_result.py
arguments_validator / arguments_helpers跑之前先检查"这个 metric 要的参数你给全了没"sdks/python/src/opik/evaluation/metrics/arguments_validator.py
AggregatedMetric把多个 metric 的结果按自定义函数合成一个sdks/python/src/opik/evaluation/metrics/aggregated_metric.py
heuristics/20 个确定性指标sdks/python/src/opik/evaluation/metrics/heuristics/
llm_judges/12 个 LLM 裁判 + G-Eval 预设sdks/python/src/opik/evaluation/metrics/llm_judges/
conversation/线程级(多轮对话)指标sdks/python/src/opik/evaluation/metrics/conversation/
models/裁判用的模型网关与能力探测sdks/python/src/opik/evaluation/models/

主线走一遍(高层): 引擎拿到 dataset item 和 task 输出 → 合并成一个扁平 dict 并做键名映射 → 校验每个 metric 需要的参数是否齐全 → 逐个调 metric.score(**kwargs) → 收集 ScoreResult 列表 → 任何一个 metric 抛异常都被兜成 scoring_failed=True 的 0 分,不让一颗老鼠屎坏一锅粥。


3. 统一契约:一个基类兜住所有打分器

这节讲这个库最重要的骨架——为什么几十个形态各异的打分器能被引擎无差别地调用。

3.1 BaseMetric:一个基类,两个入口

契约极简。score 是同步入口,ascore 是异步入口,默认实现就是转调 scoresdks/python/src/opik/evaluation/metrics/base_metric.py:55-69,符号 BaseMetric.score / BaseMetric.ascore)。 需要真并发的裁判自己覆写 ascore(例如 ConversationalCoherenceMetricasyncio.gather 并发跑窗口)。

签名是 *args, **kwargs,具体子类各写各的参数名——EqualsoutputreferenceHallucinationinput/output/context。这种"签名自由"是刻意的,代价是引擎必须靠反射校验参数(见 3.3)。

底层还有一个更轻的 ABC:sdks/python/src/_opik/_base_metric.py:9_opik._base_metric.BaseMetric), 它只有 name/track/project_name 三个字段,不 import 任何重依赖。SDK 里的 BaseMetric 继承它再加追踪能力 ——目的是让"只需要 metric 接口"的场景(如轻量插件)不必拖进整个 opik 配置栈。

3.2 track=True:打分本身也变成一条 span

这是本章第一个值得带走的设计。 构造函数里,BaseMetricopik.track 装饰器直接套到自己的 scoreascore

# sdks/python/src/opik/evaluation/metrics/base_metric.py:47-53
if not track and project_name is not None:
raise ValueError("project_name can be set only when `track` is set to True")

if track and config.check_for_known_misconfigurations() is False:
track_decorator = opik.track(name=self.name, project_name=project_name)
self.score = track_decorator(self.score) # type: ignore
self.ascore = track_decorator(self.ascore) # type: ignore

注意这里是实例属性覆盖,不是类装饰:self.score = ... 把绑定方法换成了被包装的版本。

这意味着什么: 一次打分——包括 LLM 裁判内部对模型的那次调用——会作为一棵 span 子树被记录下来 (@track 的机制见 追踪层)。于是你能在 UI 里回看"这个裁判到底给模型发了什么 prompt、 模型回了什么、为什么打 0.3 分"。评估系统自己也是被观测对象。

两个细节:

  • track=Falseproject_name 互斥,不填 track 却指定项目名会直接抛 ValueError——因为不追踪时项目名无处可去。
  • 还有一道 config.check_for_known_misconfigurations() 闸门(sdks/python/src/opik/config.py:420): 配置明显有问题(比如没有 API key)时装追踪,避免每打一次分就报一次错。

3.3 ScoreResult:六个字段,其中一个是"我失败了"

# sdks/python/src/_opik/_score_result.py:20-25,符号 ScoreResult
name: str
value: float
reason: Optional[str] = None
category_name: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
scoring_failed: bool = False

value 是数值分,reason 是给人看的理由(LLM 裁判几乎都会填),metadata 装结构化诊断信息 (例如 PromptInjection 会把命中的正则和关键词都塞进去)。

scoring_failed 是整个评估流水线的容错开关。引擎在 _compute_metric_scores 里给每个 metric 包了 try/except, 任何异常(模型超时、JSON 解析失败、限流)都被翻译成一个失败结果而不是让整轮评估崩掉:

# sdks/python/src/opik/evaluation/engine/metrics_evaluator.py:87-102
def _build_failed_score_result(metric_name, exception) -> score_result.ScoreResult:
return score_result.ScoreResult(
name=metric_name,
value=0.0,
reason=str(exception),
metadata={"error_info": error_info_collector.collect(exception)},
scoring_failed=True,
)

默认唯一炸出的例外是参数缺失ScoreMethodMissingArguments 在默认容错级 METRIC_ERRORS 下被 raise 原样抛出(metrics_evaluator.py:331-338;显式选 ALL_SCORING_ERRORS 才降级)。设计意图很 清楚——模型不稳定是运行时噪声,参数写错是你的 bug,后者必须立刻炸给你看。

3.4 参数校验:靠反射,而不是靠类型

因为每个 metric 的 score 签名都不一样,引擎在调用前得先问一句"你要什么"。这活儿分两层:

引擎准备好的 kwargs


arguments_validator.validate_score_arguments(metric, kwargs, mapping)

├── metric 是 ScoreArgumentsValidator 子类?→ 调它自己的 validate_score_arguments
│ (AggregatedMetric 用这条:逐个校验子 metric)

└── 否 → arguments_helpers.raise_if_score_arguments_are_missing
用 inspect.signature 找出"无默认值的必填参数",缺一个就抛

底层那个函数很朴素但很有用(sdks/python/src/opik/evaluation/metrics/arguments_helpers.py:13, 符号 raise_if_score_arguments_are_missing):遍历签名,凡是 param.default is empty 且属于 POSITIONAL_OR_KEYWORD/KEYWORD_ONLY 的,就是必填(arguments_helpers.py:30-35)。

它还做了一件体贴的事:报错时把你配了但没用上的 key mapping 一并列出(arguments_helpers.py:38-44)。 实际踩坑最多的就是"我明明映射了 answer 却说缺 output"——多半是映射写反了方向,这条提示直接指向病灶。

配套的 create_scoring_inputsarguments_helpers.py:122)负责把 dataset item 和 task 输出合并, 并应用 scoring_key_mapping;映射值可以是字符串(改名)也可以是 callable(现算,arguments_helpers.py:132-134)。

3.5 AggregatedMetric:组合子

把 N 个 metric 打包成一个,用你给的 aggregator 函数合成最终 ScoreResultsdks/python/src/opik/evaluation/metrics/aggregated_metric.py:70-90,符号 AggregatedMetric.score)。 它同时实现了 ScoreArgumentsValidator,把参数校验递归下发给每个子 metric(aggregated_metric.py:92-103)。

一个子 metric 返回列表时会被 extend 展开(aggregated_metric.py:85-88)——因为契约允许 score 返回 ScoreResult List[ScoreResult],一次调用产出多个维度的分是合法的。


4. 启发式一支:确定性、无网络、可当 CI 断言

这节讲 heuristics/ 下的 20 个指标——它们的共性比差异更重要。

三条共性:

  • 确定性。 同样的输入永远同样的分,可以直接写进 assert result.value > 0.8
  • 不联网。 纯本地计算,没有 API key、没有配额、没有限流。
  • 重依赖延后 import。 需要 nltk / rapidfuzz / scipy / textstat / bert_score 的指标一律 在 score() 里或模块顶部 try-import(例如 heuristics/sentiment.py:6-11try: import nltkheuristics/distribution_metrics.py:24_load_jensen_shannon_distance)。装了才能用,没装不影响 import opik。

按"用什么方法比"分五类:

类别指标比什么文件:行
字符串匹配Equals完全相等heuristics/equals.py:7
字符串匹配Contains参考串是否出现在输出里heuristics/contains.py:6
字符串匹配RegexMatch正则是否命中heuristics/regex_match.py:8
字符串匹配LevenshteinRatio编辑距离归一化(走 rapidfuzz.distance.Indelheuristics/levenshtein_ratio.py:7
n-gram 重叠SentenceBLEU / CorpusBLEU机器翻译经典 n-gram 精确率heuristics/bleu.py:94 / :191
n-gram 重叠ROUGE摘要经典召回率(rouge1/2/L/Lsum)heuristics/rouge.py:11
n-gram 重叠ChrF字符级 n-gram F 值,对形态丰富语言更稳heuristics/chrf.py:19
n-gram 重叠GLEUBLEU 的句级变体heuristics/gleu.py:15
n-gram 重叠METEOR带同义词/词干对齐的重叠heuristics/meteor.py:22
语义相似BERTScore用 BERT 向量算 token 级相似度heuristics/bertscore.py:19
格式校验IsJsonjson.loads 成功即 1.0heuristics/is_json.py:7
文本属性ReadabilityFlesch 阅读易读度(textstat)heuristics/readability.py:17
文本属性Sentiment / VADERSentiment / Tone情感极性与语气heuristics/sentiment.py:14 / vader_sentiment.py:17 / tone.py:50
文本属性LanguageAdherenceMetric输出语言是否符合预期(fastText 语言识别)heuristics/language_adherence.py:20
文本属性PromptInjection正则库匹配注入/越狱话术heuristics/prompt_injection.py:83
分布与排名JSDivergence / JSDistance / KLDivergence两段文本的词分布距离heuristics/distribution_metrics.py:86 / :195 / :254
分布与排名SpearmanRanking两个排序的秩相关(用于评排序质量)heuristics/spearman.py:12

4.1 最简单的那个:IsJson

值得看一眼,因为它把这一支的全部哲学压缩成了 6 行:

# sdks/python/src/opik/evaluation/metrics/heuristics/is_json.py:49-53,符号 IsJson.score
try:
json.loads(output)
return score_result.ScoreResult(value=1.0, name=self.name)
except Exception:
return score_result.ScoreResult(value=0.0, name=self.name)

没有模型、没有配置、没有失败态——能解析就是 1,不能就是 0。这种指标放进 CI 是零成本的。

4.2 稍微有点意思的那个:PromptInjection 的两级风险

它维护了一张约 30 条的正则表(heuristics/prompt_injection.py:12,符号 _INJECTION_PATTERNS, 覆盖 "ignore previous instructions"、"reveal the system prompt"、"DAN mode" 等), 外加一张可疑关键词表,然后分三档给分:

# sdks/python/src/opik/evaluation/metrics/heuristics/prompt_injection.py:133-138
if matches: # 命中正则 = 强信号
score = 1.0
elif keyword_hits: # 只命中关键词 = 弱信号
score = 0.5
else:
score = 0.0

可借鉴的是那个 0.5 档。 二值判断在安全场景太粗——"只出现了可疑词"和"出现了完整的越狱句式" 风险量级不同,中间档让下游可以按阈值选择告警还是拦截。命中的具体模式全放进 metadataprompt_injection.py:143-149),方便复盘。


5. LLM 裁判一支:三件套(有时四件)范式

这节讲 llm_judges/——它的价值不在某个裁判本身,而在目录结构就是架构

5.1 一个裁判目录 = 三个文件

打开任何一个裁判目录,你会看到同样的三份文件(结构化输出约束复杂时再加第四份 schema.py):

llm_judges/hallucination/
├── template.py ← prompt 长什么样(system prompt + few-shot + 组装函数)
├── metric.py ← 参数、模型初始化、调用编排;pydantic 响应格式常放这里
├── parser.py ← 把模型回的字符串变成 ScoreResult,含校验与失败翻译
└── (schema.py) ← 输出结构复杂时才单独拆,见 structure_output_compliance/

为什么这么拆值得学: prompt 是最常被改的(调效果)、parser 是最容易出 bug 的(模型不听话)、 metric 是最稳定的(接口)。三者变更频率完全不同,所以拆开。 改 prompt 不用碰解析逻辑, 换模型不用碰 prompt。

各文件的真实职责:

文件里面有什么例子
template.pysystem prompt 常量 + few-shot 类型 + build_messages()llm_judges/hallucination/template.py:14_CONTEXT_SYSTEM_PROMPT)、:6FewShotExampleHallucination
metric.pypydantic 响应格式、_init_modelscore/ascorellm_judges/hallucination/metric.py:10HallucinationResponseFormat)、:15Hallucination
parser.pyJSON 提取 → 范围校验 → ScoreResult,失败翻译成 MetricComputationErrorllm_judges/hallucination/parser.py:10parse_model_output
schema.py复杂结构化输出的 pydantic 模型llm_judges/structure_output_compliance/schema.py:13StructuredOutputComplianceResponseFormat

score 的骨架在每个裁判里几乎一模一样——建 messages → 调模型(带 response_format)→ 交给 parser

# sdks/python/src/opik/evaluation/metrics/llm_judges/hallucination/metric.py:99-109
messages = template.build_messages(
input=input, output=output, context=context,
few_shot_examples=self.few_shot_examples,
)
message = self._model.generate_chat_completion(
messages=messages, response_format=HallucinationResponseFormat
)
return parser.parse_model_output(content=message["content"], name=self.name)

5.2 覆盖了哪些评估维度

裁判判什么主要入参目录
Hallucination输出有没有超出 context 编造input / output / contextllm_judges/hallucination/
AnswerRelevance答案切不切题input / output / contextllm_judges/answer_relevance/
ContextPrecision检索到的上下文有多少是有用的input / output / expected_output / contextllm_judges/context_precision/
ContextRecall该检索到的有没有都检索到同上llm_judges/context_recall/
Moderation有没有不安全内容outputllm_judges/moderation/
Factuality事实正确性(按 claim 拆解)input / outputllm_judges/factuality/
Usefulness对用户有没有实际帮助input / outputllm_judges/usefulness/
StructuredOutputCompliance输出符不符合给定 schemaoutput / schemallm_judges/structure_output_compliance/
TrajectoryAccuracyagent 的工具调用轨迹合不合理input / trajectory / final_resultllm_judges/trajectory_accuracy/
SycEval谄媚度:被反驳后会不会改口input / outputllm_judges/syc_eval/
GEval / GEvalPreset任意自定义评分标准outputllm_judges/g_eval/
LLMJuriesJudge多裁判投票透传给各裁判llm_judges/llm_juries/

两个值得单独点名的:

  • SycEvalllm_judges/syc_eval/metric.py:18)不是简单打一次分,它会主动生成反驳(可选四种强度: simple / ethos / justification / citation),再看模型改不改口。响应格式区分了 progressive(改对了)和 regressive(改错了)两种谄媚(syc_eval/metric.py:10-15,符号 SycEvalResponseFormat)。 这是少数需要**额外调一个模型(rebuttal_model)**的裁判。
  • Factualitymetrics/__init__.py:83被注释掉的导出——代码在,但没进公共 API。 想用得直接从 llm_judges.factuality.metric import。

5.3 解析层的共同护城河

所有 parser 都走同一个 JSON 提取函数:llm_judges/parsing_helpers.py:6extract_json_content_or_raise)。 它是三级降级:

1. json.loads(content) ← 模型很听话,直接成
│ 失败

2. 截取第一个 { 到最后一个 } 再 loads ← 模型在 JSON 外面裹了段废话
│ 失败

3. JSONDecoder().raw_decode(content[first:]) ← 模型吐了两个 JSON 对象粘一起,只取第一个
│ 失败

抛 JSONParsingError

第 3 级是后加的补丁,注释里写明了原因:推理模型开着 response_format 时偶尔会把答案输出两遍 (parsing_helpers.py:34-41)。这类"模型不守规矩"的补丁是 LLM 裁判工程里最真实的部分。

解析后还有范围校验。以幻觉为例,分数必须落在 [0,1],否则视为解析失败 (llm_judges/hallucination/parser.py:15-18)——不信任模型的输出边界是这一层的默认立场。

还有一处细节值得学:conversational_coherence/schema.py:6-12 的注释解释了为什么 reason 字段 不给默认值却标成 Optional——因为 OpenAI 的 strict 结构化输出模式下,带默认值的字段会被移出 required, strict 模式随之失效,模型就可能吐出重复或漂移的 JSON。"用 Optional 而非默认值"是为了迁就 provider 的 schema 规则。


6. 重点解剖 G-Eval:全项目最值得带走的一处设计

G-Eval 是"通用裁判"——你不写 prompt,只写两句话:这个评估任务是干什么的(task_introduction)、 按什么标准打分(evaluation_criteria)。剩下的它自己搞定。

6.1 两段式 prompt:先让模型自己写评分步骤

要解决的小问题: 你只给了一句"评价礼貌程度,1 到 5 分"。模型直接打分会很随意——因为它没想清楚 "礼貌"要看哪几点。

思路: 先让模型把评分标准展开成一串具体步骤(chain of thought,思维链),再带着这串步骤去打分。

第一次调用(每个 metric 只做一次)
system: _COT_SYSTEM_PROMPT ← "根据任务描述和评分标准,生成详细的评估步骤"
user: 任务介绍 + 评分标准
──────────────────────────────► 模型返回:一段"评分步骤 1/2/3…"

│ 缓存起来

第二次调用(每条样本一次)
system: _QUERY_SYSTEM_PROMPT ← 任务介绍 + 评分标准 + 上面那段步骤 + "返回 JSON {score, reason}"
user: *** INPUT: <要评的那段输出>
──────────────────────────────► 模型返回:{"score": 7, "reason": "…"}

两个 prompt 常量在 llm_judges/g_eval/template.py:6_COT_SYSTEM_PROMPT)和 :15_QUERY_SYSTEM_PROMPT), 组装函数分别是 build_chain_of_thought_messages:28)和 build_query_messages:47)。

为什么要拆 system / user: build_query_messages每个 metric 固定不变的部分(任务介绍、标准、CoT、 输出格式)全放 system,只把每条样本变化的输出放 user(template.py:58-66)。 这样 provider 端的 prompt 缓存能命中那段稳定前缀——省钱省延迟。这个意图在 base_model.generate_chat_completion 的 docstring 里写得很直白(sdks/python/src/opik/evaluation/models/base_model.py:120-124)。

_COT_SYSTEM_PROMPT 里还塞了一句强制归一化:如果用户的量表不是 0-10,要求模型自己换算到 0-10 并且必须是整数 (template.py:10-12)。这句话是下面 6.3 那个 logprobs 技巧成立的前提——分数必须是单个十进制整数 token。

6.2 CoT 缓存:别为每条样本都重生成一遍步骤

第一次调用是纯浪费——同一个 metric 跑 200 条样本,评分步骤是同一份。所以 GEval 用了一个 类级别的 LRU 缓存(不是实例级,跨实例共享):

# sdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/metric.py:66-70
_CHAIN_OF_THOUGHT_CACHE: "OrderedDict[Tuple[str, str, str, Any], str]" = OrderedDict()
_CHAIN_OF_THOUGHT_LOCK: Lock = Lock()
_MAX_CHAIN_OF_THOUGHT_CACHE = 128

llm_chain_of_thought()g_eval/metric.py:98-111)先查缓存,未命中才调模型再写回; 读写都在 Lock 保护下,命中时 move_to_end 实现 LRU,超过 128 条从头淘汰(g_eval/metric.py:151-173)。

缓存键怎么构造是这里的精髓。 键是四元组(g_eval/metric.py:175-182,符号 _chain_of_thought_cache_key):

键成分为什么要它
task_introduction任务变了,步骤当然要重生成
evaluation_criteria标准变了同理
model_name不同模型写出的步骤不一样
_model_cache_fingerprint()同一个模型、不同采样参数也算不同

第四项 _model_cache_fingerprintg_eval/metric.py:184-198)是三级降级取指纹: 模型自己提供 cache_fingerprint() 就用它 → 否则用模型的 _completion_kwargs 字典 → 再不行退化成 id(self._model)(等于关闭跨实例复用,但绝不会错误命中)。

因为 kwargs 是 dict/list 这类不可哈希结构,还得先 _freeze_for_cache 递归转成可哈希的 tuple (g_eval/metric.py:18-29)——dict 转排序后的 tuple、list 转 tuple、set 转排序 tuple。

可借鉴的模式: 缓存键要包含"所有会影响结果的输入",包不全就宁可退化成 id()宁可少命中,不可错命中。

6.3 logprobs 加权:把离散整数分变成连续分数

这是全项目最漂亮的一处设计,值得慢慢看。

问题是什么。 让模型输出 0-10 的整数分,它只会给你 7 或 8。但模型内心其实是"7.4 分"—— 那个介于 7 和 8 之间的犹豫被四舍五入丢掉了。粗粒度分数在排序、比较两版 prompt 时分辨力很差。

思路。 OpenAI 兼容 API 可以返回 logprobs:每个输出 token 位置上,模型考虑过的候选 token 及其对数概率。 如果我们能拿到分数那个 token 位置的候选分布,就能算一个期望值:

该位置的候选: "7" (logprob=-0.22) "8" (logprob=-1.61) "6" (logprob=-3.0)
转成线性概率: 0.80 0.20 0.05
加权平均: (0.80*7 + 0.20*8 + 0.05*6) / (0.80+0.20+0.05) ≈ 7.14
再除以 10 → 0.714 ← 连续分数

第一步:探测模型支不支持。_init_model 末尾检查模型的 supported_params 里同时有 logprobstop_logprobs 才置位:

# sdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/metric.py:144-149
if (
hasattr(self._model, "supported_params")
and "logprobs" in self._model.supported_params
and "top_logprobs" in self._model.supported_params
):
self._log_probs_supported = True

第二步:命中时下发参数。 score 里只有走 LiteLLM 网关(能拿到原始 provider 响应)时才启用, 并且一次要 20 个候选(g_eval/metric.py:223-229):

provider_kwargs: Dict[str, Any] = {"response_format": GEvalScoreFormat}
if self._log_probs_supported:
provider_kwargs["logprobs"] = True
provider_kwargs["top_logprobs"] = 20

第三步:解析。这里有个大胆的假设。 parse_litellm_model_outputllm_judges/g_eval/parser.py:43直接写死分数 token 在第 4 个位置

# sdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/parser.py:63
score_token_position = 3

为什么能这么假设?因为 response_format=GEvalScoreFormat 强制模型先输出 score 再输出 reason, 于是 token 序列固定是 {"score":7。docstring 里把这个推理写清楚了 (g_eval/parser.py:46-53)。

第四步:加权平均。 遍历该位置的 top_logprobs,只要十进制且落在 [0,10] 的候选, 按 exp(logprob) 加权:

# sdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/parser.py:88-93
linear_prob = math.exp(float(log_prob))
linear_probs_sum += linear_prob
weighted_score_sum += linear_prob * score
...
final_score: float = weighted_score_sum / linear_probs_sum / 10

除以 linear_probs_sum重新归一化——因为过滤掉了非数字候选,剩下的概率和不为 1。

第五步:层层降级。 这条链是这个设计能上生产的原因:

模型不支持 logprobs ────────────────► 走纯文本解析
│ 支持

logprobs.content 条目数 ≤ 3 ────────► 走纯文本解析(拿不到第 4 个 token)
│ 够长

top_logprobs 里一个十进制候选都没有 ─► 用该位置实际吐出的 token;它也不是数字就报错
│ 有

加权平均 → 校验落在 [0,1] → 从 message 内容里再 json.loads 一次拿 reason

纯文本兜底路径是 parse_model_output_stringg_eval/parser.py:16):走通用 JSON 提取, 校验 0-10 后除以 10(g_eval/parser.py:24-29)。降级入口在 g_eval/parser.py:58-65, 无候选时的兜底在 :95-97

要带走的三点:

  • 概率分布里藏着比采样结果更多的信息——别只用模型说出口的那个 token
  • response_format 把输出结构钉死,就能对 token 位置做强假设,把一个统计技巧变得可实现。
  • 但强假设必须配全套降级;这里每一级失败都有明确的下一条路。

6.4 GEvalPreset:把提示词变成货架商品

GEVAL_PRESETS 是一张 preset key → (name, task_introduction, evaluation_criteria) 的表 (llm_judges/g_eval/presets.py:18,条目类型 GEvalPresetDefinition:9)。 GEvalPreset 拿 key 查表再转调 GEvalg_eval/metric.py:310-336),key 不存在时报错会列出全部可用 key。

g_eval_presets/ 目录再包一层,把每个 preset 做成有名字的类—— QARelevanceJudgeSummarizationConsistencyJudgeGenderBiasJudgeComplianceRiskJudgeAgentTaskCompletionJudge 等(例如 llm_judges/g_eval_presets/qa_suite.py:114)。 同一套 G-Eval 机制,靠 prompt 数据差异化出十几个"指标"——这是很划算的复用。


7. 组合与扩展:四种把 metric 玩出花的方式

7.1 LLMJuriesJudge:多裁判取平均

llm_judges/llm_juries/metric.py:12(符号 LLMJuriesJudge)接受一组裁判,逐个跑再取算术平均 (llm_juries/metric.py:69)。

三个工程细节值得看:

  • 类型与范围双校验:任何裁判返回非 ScoreResult、或分数越出 [0,1],直接抛 MetricComputationErrorllm_juries/metric.py:56-63)。合议庭不容忍成员乱来。
  • precomputed 后门score 支持传一个 {judge: ScoreResult} 字典复用已算好的结果 (llm_juries/metric.py:44-52)——同一批裁判既要单独出分又要参与投票时,避免重复调模型。
  • metadata["judge_scores"] 保留每个裁判的分(llm_juries/metric.py:70-72),平均分难看时能追责到具体裁判。

7.2 conversation/:线程级指标

单轮指标看一问一答,线程级指标看整段对话。基类换成 ConversationThreadMetricconversation/conversation_thread_metric.py:7),score 的第一个参数从 output 变成 conversation——一个 [{"role": ..., "content": ...}, ...] 列表(ConversationDict 现为 TypedDict,role/content 必填、另有可选 context 键,见 conversation/types.py:12-21)。

轮次工厂是这一支的公共基建。build_conversation_turnsconversation/conversation_turns_factory.py:6) 把扁平的消息列表配对成 ConversationTurn(input=user_msg, output=assistant_msg)

[user A] [assistant A'] [user B] [assistant B'] [user C]
↓ ↓ ↓
Turn(A, A') Turn(B, B') Turn(C, None) ← 末尾落单的 user 也保留

落单处理在 conversation_turns_factory.py:35-37——最后一个没被回复的 user 消息会以 output=None 入列, 不静默丢弃。之上再叠 helpers.get_turns_in_sliding_windowconversation/helpers.py:7)做滑动窗口, 和 extract_turns_windows_from_conversation:49)把窗口再摊回消息列表。

五个线程级指标,两支:

指标类型怎么算文件
ConversationalCoherenceMetricLLM 裁判每个滑动窗口问一次模型"这轮回复跟上下文连贯吗",收集 verdict 求比例conversation/llm_judges/conversational_coherence/metric.py:21
SessionCompletenessQualityLLM 裁判用户的目标在整段会话里达成了没有conversation/llm_judges/session_completeness/metric.py
UserFrustrationMetricLLM 裁判用户有没有表现出烦躁conversation/llm_judges/user_frustration/metric.py
ConversationDegenerationMetric启发式n-gram 重复率、与上一轮的重叠、token 熵、套话短语命中conversation/heuristics/degeneration/metric.py:27
KnowledgeRetentionMetric启发式前几轮用户提到的实词,最后一轮助手还记得几个conversation/heuristics/knowledge_retention/metric.py:56

两个启发式的算法核心一句话就能说清:

  • 退化检测取的是最坏值不是平均值peak_score = max(degeneracy_scores)conversation/heuristics/degeneration/metric.py:138)。对话里有一段卡住了就该报警,被其他正常轮次平均掉就没意义了。
  • 知识保持是集合交集比score = len(retained) / len(reference_facts)conversation/heuristics/knowledge_retention/metric.py:140-141)。参考项是前 N 轮用户消息里的实词 (过滤停用词和短词),而且疑问句和请求句会被跳过_is_user_request:166)—— 因为"你能帮我写个函数吗"里没有需要记住的事实。

conversation/llm_judges/g_eval_wrappers.py:22GEvalConversationMetric)则把整段对话拍平成文本喂给 G-Eval, 于是所有 G-Eval preset 都自动获得了对话版(ConversationQARelevanceMetric 等,:146 起)。

7.3 RagasMetricWrapper:接第三方指标

ragas_metric.py:13(符号 RagasMetricWrapper)把 Ragas 的 SingleTurnMetric 包成 BaseMetric。 桥接工作有三件:

  • 字段名翻译:Opik 的 input/output → Ragas 的 user_input/responseragas_metric.py:38-43)。
  • 必填字段检查:从 ragas_metric.required_columns 读出要求,缺了抛 Opik 自己的 ScoreMethodMissingArgumentsragas_metric.py:45-58)——错误类型统一了。
  • 追踪打通:内部挂 OpikTracerragas_metric.py:80,符号 _get_opik_tracer_instance), 让 Ragas 内部的 LLM 调用也落进同一棵 span 树。

注意它只支持单轮指标,构造时就用 isinstance 卡死(ragas_metric.py:22-26)。

7.4 scorers/:函数式打分

不想写类,就写个函数。evaluation/scorers/scorer_wrapper_metric.py:9ScorerWrapperMetric) 把一个 (dataset_item, task_outputs) -> ScoreResult 的普通函数包成 metric, name 取函数的 __name__:109,符号 _scorer_name)。

有个签名嗅探的小机制:wrap_scorer_functions:113)用 scorer_function.has_task_span_in_parametersevaluation/scorers/scorer_function.py:54) 检查函数签名里有没有 task_span 参数——有就包成 ScorerWrapperMetricTaskSpan:66), 调用时额外把任务的 span 数据传进去。函数想要什么就给什么,不想要就不给。

引擎侧对这类 metric 有特判:ScorerWrapperMetric 拿的是原始未映射的 dataset item 和 task output, 不走 scoring_key_mappingengine/metrics_evaluator.py:297-313)。


8. 模型接入层 models/:裁判用的那台发动机

LLM 裁判要调模型,这层负责"调哪个、怎么调、支持什么"。

8.1 OpikBaseModel:三层接口

evaluation/models/base_model.py:55(符号 OpikBaseModel)定义了三种粒度的调用:

方法返回什么谁用
generate_string纯字符串最简单的裁判
generate_chat_completion{"role": "assistant", "content": ...}绝大多数裁判(保住 system/user 分离,利于 prompt 缓存)
generate_provider_responseprovider 原始响应对象需要 logprobs 之类元数据的(只有 G-Eval)

第三个不能直接调,必须走上下文管理器 get_provider_responsebase_model.py:192)/ aget_provider_response:223)。它做的事只有一件:把任何 provider 异常统一翻译成 exceptions.BaseLLMErrorbase_model.py:221-224)。上层于是只需要认识一种 LLM 错误。

generate_chat_completion 刻意没有标成 @abstractmethod(注释在 base_model.py:110: "Don't mark it as abstractmethod to avoid breaking existing user implementations")—— 自定义模型的用户不会因为库加了新方法而突然报错。

8.2 LiteLLMChatModel:统一网关

evaluation/models/litellm/litellm_chat_model.py:53 是默认实现,靠 LiteLLM 打通上百家 provider。 四件值得看的事:

  • 能力探测supported_params@cached_property,包 litellm.get_supported_openai_params(model)litellm_chat_model.py:117-126)。上面 G-Eval 探测 logprobs 用的就是它。
  • 不支持的参数直接扔掉_remove_unnecessary_not_supported_params:148)在调用前剔掉 response_formatreasoning_effortseed 等该模型不认的参数——宁可降级也不让请求 400
  • 合并后才能发现的冲突_resolve_provider_conflicts:214)处理"一个参数来自构造函数、 另一个来自本次调用"才暴露的矛盾(注释举了 Anthropic 的 reasoning_efforttemperature 互斥)。
  • 自带重试generate_provider_response 用 tenacity 指数退避,默认 3 次, 次数通过私有 kwarg __opik_retries 传入(:371-396)。

追踪也在这层接:track=True 且配置允许时,用 litellm_integration.track_completion() 包一层 (litellm_chat_model.py:101-109),于是裁判的每次模型调用都成为 span。

8.3 models_factory:缓存 + 分流

evaluation/models/models_factory.py:36(符号 get)是所有裁判获取模型的唯一入口,做两件事:

一是实例缓存。 缓存键是 (model_name, track, frozen_kwargs) 三元组 (models_factory.py:26-28,符号 _make_cache_key),kwargs 用 _freeze 递归转 frozenset。 同名同参就复用同一个实例(:54-57)——避免每个 metric 各建一个客户端。 (注意这个模式和 G-Eval 的 CoT 缓存指纹是同一思路的两次实现。)

二是 provider 分流。 _should_use_anthropic_native:60-74)判断:

模型名是 Anthropic 的吗?
│ 否 → LiteLLM
▼ 是
anthropic SDK 已经在 sys.modules 里? ─是─► 用原生 AnthropicChatModel
│ 否

importlib 能找到 anthropic 包? ─是─► 用原生
│ 否

log_once_at_level 警告一次「装 anthropic 体验更好」→ 退回 LiteLLM

log_once_at_level 这个细节很人性:跑 500 条样本时这条建议只会出现一次,不会刷屏。

8.4 其余接入点

模块作用文件
model_capabilities.py视觉能力探测:先问 LiteLLM,再退回前缀/后缀名单匹配evaluation/models/model_capabilities.py:68vision_capability_detector),名单在 :14VISION_MODEL_PREFIXES
anthropic/原生 Anthropic 适配器(消息适配 + 响应解析)evaluation/models/anthropic/anthropic_chat_model.py:22
langchain/把 LangChain 的 chat model 接进 OpikBaseModelevaluation/models/langchain/langchain_chat_model.py:15

model_capabilities.py 的双保险模式值得一提:_litellm_supports_vision 查库失败(未安装、 模型未收录)时不报错,直接退回硬编码名单(model_capabilities.py:59-79)。能力探测失败不该阻断评估。


9. 巧妙之处(可带走的技术)

按值得学的程度排序:

  1. 用 top_logprobs 把整数分变连续分。 让模型输出 0-10 整数,再从分数 token 的候选分布算加权期望, 分辨力凭空提升一个量级。前提是用 response_format 把输出结构钉死,才敢假设分数在第 4 个 token (llm_judges/g_eval/parser.py:63:88-93)。

  2. 打分器把自己包成 span。 构造函数里 self.score = opik.track(...)(self.score)metrics/base_metric.py:50-53),让评估过程本身可观测。

  3. 失败不是异常,是一个字段。 scoring_failed=True 让单个 metric 挂掉不影响整轮 (engine/metrics_evaluator.py:331-356,经 _build_failed_score_result :87-102);但参数缺失例外,默认必须炸(:331-338)。 区分"运行时噪声"和"用户 bug"。

  4. 缓存键宁可退化,不可错命中。 _model_cache_fingerprint 三级降级,最后退到 id(model)llm_judges/g_eval/metric.py:184-198)。

  5. 目录结构编码变更频率。 template / parser / metric 分离,因为 prompt、解析、接口的改动节奏完全不同。

  6. 参数不支持就扔掉,不要报错。 LiteLLM 层剔除不支持的参数 (models/litellm/litellm_chat_model.py:165)。评估要跑得完比跑得全更重要。

  7. 风险指标留中间档。 PromptInjection 的 1.0 / 0.5 / 0.0 三档 (heuristics/prompt_injection.py:133-138),比二值判断可用得多。

  8. 对话退化取峰值不取均值。 max(degeneracy_scores)conversation/heuristics/degeneration/metric.py:138)——异常检测就该被最坏的那一段主导。


10. 边界与局限

诚实说说它做不到什么:

  • G-Eval 的 logprobs 路径只在 LiteLLM 上通。 score 里显式判断 isinstance(self._model, models.LiteLLMChatModel)llm_judges/g_eval/metric.py:223); 用自定义 OpikBaseModel 或原生 Anthropic 走的都是纯文本解析路径,拿到的是离散整数分。
  • 第 4 个 token 的假设不是防弹的。 模型改变 JSON 输出格式(多个空格、字段顺序变化、 两位数分数拆成两个 token)都会让这个位置失准。代码只用"候选里有没有十进制数"做隐式校验 (g_eval/parser.py:74-90),拿不到就降级——但拿到一个错位置的数字理论上会静默算错。(inferred)
  • CoT 缓存是进程内的。 类变量 _CHAIN_OF_THOUGHT_CACHEg_eval/metric.py:66), 不跨进程、不落盘。分布式跑评估时每个 worker 都要自己生成一遍。
  • Factuality 没进公共 APImetrics/__init__.py:83 被注释)。
  • 启发式指标的重依赖需要自己装。 nltk、rapidfuzz、scipy、textstat、bert_score、fasttext 都是可选依赖,没装就在打分时抛错。
  • 线程级启发式指标是词表级的。 KnowledgeRetentionMetric 用集合交集判断"记住了" (conversation/heuristics/knowledge_retention/metric.py:140),同义改写会被判为遗忘。
  • 参数校验只看"必填参数在不在",不看类型、不看值域(metrics/arguments_helpers.py:25-35)。 传了 output=NoneLevenshteinRatio 只会在 score 内部撞上手写的 None 检查。

11. 相关章节

想知道去哪章
@track 怎么把一次函数调用变成 span 树(本章 3.2 依赖它)01-tracing-decorator.md
打分结果怎么异步上报到后端02-ingestion-pipeline.md
分数在 ClickHouse 里怎么存怎么查03-storage-and-query.md
dataset × task × metric 怎么编排成一次 experiment、scoring_key_mapping 全貌04-evaluation-engine.md
这些 metric 怎么被搬到线上做实时评估、prompt 怎么按分数自动优化06-online-scoring-and-optimizer.md

12. 代码地图(导航索引)

路径相对克隆根 opik/

主题文件路径符号名
打分器契约sdks/python/src/opik/evaluation/metrics/base_metric.pyBaseMetricBaseMetric.scoreBaseMetric.ascore
轻量契约(无重依赖)sdks/python/src/_opik/_base_metric.pyBaseMetric
结果结构sdks/python/src/_opik/_score_result.pyScoreResult
参数校验分发sdks/python/src/opik/evaluation/metrics/arguments_validator.pyScoreArgumentsValidatorvalidate_score_arguments
必填参数反射检查sdks/python/src/opik/evaluation/metrics/arguments_helpers.pyraise_if_score_arguments_are_missingcreate_scoring_inputs
多指标合成sdks/python/src/opik/evaluation/metrics/aggregated_metric.pyAggregatedMetric
失败兜底与调用循环sdks/python/src/opik/evaluation/engine/metrics_evaluator.py_compute_metric_scores
启发式:字符串匹配sdks/python/src/opik/evaluation/metrics/heuristics/EqualsContainsRegexMatchLevenshteinRatio
启发式:n-gramsdks/python/src/opik/evaluation/metrics/heuristics/SentenceBLEUCorpusBLEUROUGEChrFGLEUMETEOR
启发式:格式与属性sdks/python/src/opik/evaluation/metrics/heuristics/IsJsonReadabilitySentimentToneLanguageAdherenceMetricPromptInjection
启发式:分布与排名sdks/python/src/opik/evaluation/metrics/heuristics/JSDivergenceJSDistanceKLDivergenceSpearmanRankingBERTScore
裁判三件套样板sdks/python/src/opik/evaluation/metrics/llm_judges/hallucination/Hallucinationbuild_messagesparse_model_output
通用 JSON 提取(三级降级)sdks/python/src/opik/evaluation/metrics/llm_judges/parsing_helpers.pyextract_json_content_or_raise
G-Eval 两段式 promptsdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/template.py_COT_SYSTEM_PROMPT_QUERY_SYSTEM_PROMPTbuild_query_messages
G-Eval 编排与 CoT 缓存sdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/metric.pyGEvalllm_chain_of_thought_chain_of_thought_cache_key_model_cache_fingerprint_freeze_for_cache
G-Eval logprobs 加权sdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/parser.pyparse_litellm_model_outputparse_model_output_string
G-Eval 预设sdks/python/src/opik/evaluation/metrics/llm_judges/g_eval/presets.pyg_eval_presets/GEVAL_PRESETSGEvalPresetDefinitionQARelevanceJudge
多裁判投票sdks/python/src/opik/evaluation/metrics/llm_judges/llm_juries/metric.pyLLMJuriesJudge
谄媚检测sdks/python/src/opik/evaluation/metrics/llm_judges/syc_eval/metric.pySycEvalSycEvalResponseFormat
对话基类与轮次工厂sdks/python/src/opik/evaluation/metrics/conversation/ConversationThreadMetricbuild_conversation_turnsConversationTurnget_turns_in_sliding_window
对话启发式sdks/python/src/opik/evaluation/metrics/conversation/heuristics/ConversationDegenerationMetricKnowledgeRetentionMetric
对话裁判sdks/python/src/opik/evaluation/metrics/conversation/llm_judges/ConversationalCoherenceMetricSessionCompletenessQualityUserFrustrationMetricGEvalConversationMetric
第三方桥接sdks/python/src/opik/evaluation/metrics/ragas_metric.pyRagasMetricWrapper
函数式打分sdks/python/src/opik/evaluation/scorers/ScorerWrapperMetricScorerWrapperMetricTaskSpanwrap_scorer_functionshas_task_span_in_parameters
模型抽象与异常统一sdks/python/src/opik/evaluation/models/base_model.pyOpikBaseModelget_provider_responseaget_provider_response
LiteLLM 网关sdks/python/src/opik/evaluation/models/litellm/litellm_chat_model.pyLiteLLMChatModelsupported_params_remove_unnecessary_not_supported_params_resolve_provider_conflicts
模型工厂与分流sdks/python/src/opik/evaluation/models/models_factory.pyget_make_cache_key_should_use_anthropic_native
能力探测sdks/python/src/opik/evaluation/models/model_capabilities.pyvision_capability_detectorVISION_MODEL_PREFIXES