一种基于可信赖评判员的无依赖大型语言模型评估方法
根据真实日志、代码检查以及单一标准评估器构建一个小型大语言模型评估系统,随后通过人类标注对该评估器进行校准,使其评分具有实际意义。
单元测试之所以有效,是因为确定性代码每次都能给出相同的结果:只要断言某个函数应返回4,它就一定会如此。而语言模型在每次调用时都会改变输出措辞,因此没有可对比的预期字符串,团队通常会采用另一种模型来评分的评估平台作为解决方案。问题在于,这些评分模型往往实际上已经不再进行任何真实测量,因为没人能确认它们的结果与人类判断一致。本指南仅需几小时即可用纯Python构建完整的评估系统,并将最重要的一部分用于校准评分标准,以确保所得数值具有可信度。
任何评估的三个组成部分
抛开工具因素不谈,一个评估系统由三个部分构成,且这些部分都不需要任何库:
- 输入数据集,最好来自实际使用场景而非人为编造的数据。
仪表板、追踪功能以及托管数据集都是基于这些组件构建的便利工具。其中一些确实很有用,但核心部分仅约八十行Python代码,如此简短的代码即便某个平台发生变更或消失后仍能持续运行。
第一步:收集一百个真实输入
从生产日志中获取输入数据。避免使用合成示例,避开已知能成功的案例,也不要使用经过处理的样本。100个案例的数量足以揭示真实问题,同时又足够少,便于日后手动标注,而这实际上是非常重要的。有意加入一些复杂且棘手的输入数据:如果只有简单案例的数据库,即便系统性能下降,评分也会保持不变。
加载这些数据非常简单:
import json
with open("requests.jsonl") as f:
cases = [json.loads(line) for line in f][:100]
请注意,直接截取文件的前100行会包含文件顶部的所有数据,这些数据可能都来自同一天或同一个客户。在截取之前先对数据进行随机排序,或跨不同时间周期抽样,才能得到更具代表性的数据集。
如果目前还没有日志,可以自行编写100个案例,但请预期在真实流量取代这些示例之前,测试结果会显得比实际情况更好。
第2步:将代码校验与判断逻辑分开
这是团队最常跳过的步骤,但它会影响后续的所有处理。许多属性是可以确定性地验证的,这类数据绝不应被发送给模型:
- 输出的是有效的JSON格式。
- 某个类别属于已知的集合中。
- 数值处于允许的范围内。
- 所有必填字段都存在。
- 响应长度未超过限制。
以上每一项都是一种断言。下面的函数(尽管标签显示为Python,但实际上并非)会执行其中几项校验,并返回一个包含结果名称的字典:
def code_checks(output):
checks = {}
try:
parsed = json.loads(output)
checks["valid_json"] = True
checks["has_fields"] = all(k in parsed for k in ("answer", "confidence"))
except json.JSONDecodeError:
checks["valid_json"] = False
checks["has_fields"] = False
checks["under_limit"] = len(output) < 2000
return checks
返回具体的检查结果而非单一的布尔值,这样就能在出现异常时明确是哪个属性出了问题。在完成确定性检查后剩下的部分就需要人工判断:响应是否真正回答了问题、是否加入了源数据中不存在的信息、是否在应该回应时却拒绝回应?这些情况都需要评判者,而评判者则需要依据某种标准来进行验证。
步骤3:编写只评估一项内容的评判器
为每个评判函数指定唯一的一个评判标准。如果要求同时评估五项品质,就会得到综合性的判定结果,一旦出错就无法确定是哪一项标准未被满足。下面的评判器仅检查单一属性、未经支持的声明,并要求采用固定的响应格式:
JUDGE = """You are grading one property of a response.
PROPERTY: Does the response contain any claim that is not supported by the
source text provided?
SOURCE:
{source}
RESPONSE:
{response}
Answer with exactly one word, PASS or FAIL, then a new line, then one
sentence explaining your verdict. Do not explain anything else."""
def judge(source, response, call_model):
out = call_model(JUDGE.format(source=source, response=response))
verdict = out.strip().split("\n")[0].strip().upper()
return verdict == "PASS", out
格式要求与评判标准同样重要。要求第一行仅包含“PASS”或“FAIL”这两个单词,这样就能便于解析结果。而自由形式的评判结果在结构上每次调用都会不同,如果将解析失败的情况误判为通过,那么之后报告的所有分数都会被高估,而无人察觉。此实现采取了更为谨慎的做法:除了完全匹配的PASS之外,包括PASS.或带有格式修饰的评判结果在内,均视为失败。记录此类情况发生的频率是很有必要的,因为无法解析的答案数量激增本身也是一个信号。
第4步:运行所有测试并保留原始输出
不仅要保存每个案例的评判结果,还要存储完整的输出内容以及评分者的解释。当分数发生变化时,你需要了解具体是哪些部分发生了变化,而仅保存的布尔值无法提供这些信息。
results = []
for case in cases:
output = call_model(case["prompt"])
passed, reasoning = judge(case["source"], output, call_model)
results.append({
"id": case["id"],
"output": output,
"code_checks": code_checks(output),
"judge_pass": passed,
"judge_reasoning": reasoning,
})
rate = sum(r["judge_pass"] for r in results) / len(results)
print(f"{rate:.1%} pass on {len(results)} cases")
该循环会记录确定性检查结果以及评分器的判定结果,不过总体准确率仅统计评分器的判断。实际上应当同时报告两者,因为无论评分器如何判定,JSON验证失败的响应都属于错误。还需注意,此处同样是使用同一个call_model函数来生成结果并进行评分;虽然通常会选择不同的或更强大的模型作为评分器,但无论选择哪种,接下来的步骤才是决定其可靠性的关键。
此时你会得到一个百分比数值,但它目前还没有任何实际意义。
第5步:用你自己的标签校准评分器
这是大多数指南都会省略的步骤,大约需要三十分钟时间。从一百个案例中挑选五十个,先不查看评分器的判定结果,自行将每个案例标记为“通过”或“失败”。之后再对比这两组标签:
agree = sum(1 for r, human in zip(results[:50], human_labels)
if r["judge_pass"] == human)
print(f"judge agrees with me on {agree}/50 = {agree/50:.0%}")
both_fail = sum(1 for r, h in zip(results[:50], human_labels)
if not r["judge_pass"] and not h)
judge_fails = sum(1 for r in results[:50] if not r["judge_pass"])
human_fails = sum(1 for h in human_labels if not h)
print(f"judge caught {both_fail}/{human_fails} of the failures I found")
print(f"judge flagged {judge_fails - both_fail} things I considered fine")
该脚本会显示三个数值:整体一致性、裁判也识别出的错误数量,以及裁判未识别但您认为可接受的案例数。代码假定human_labels是一个布尔值列表,其顺序与前五十个结果一致;如果您完全没有发现错误,代码将会出现除以零的错误,这本身就表明您的数据集过于简单。
为何单纯的整体一致性具有误导性
尽管整体一致性是最常被引用的指标,但实际上它是最不可靠的。如果您的案例中有90%都通过了检测,那么一个对所有案例都判定为“通过”的裁判,虽然能得到90%的整体一致性数值,但实际上并未检测到任何错误。
真正重要的指标:被识别出的错误数量
值得你关注的是,裁判也标记出的你自己所打的“FAIL”标签的比例。从分类角度而言,这就是裁判对失败类别的召回率。如果一个裁判只发现了你11个错误中的2个,那他其实并没有进行评分,而是在附上令人信服的解释后批准了所有内容。第三个指标——误报率也很重要,因为那些将正确答案标记为错误的裁判会迫使你去“修正”本不存在的问题。
当你与裁判意见不一致时
仔细查看所有你的标签与裁判不同的案例。原因只有三种,每种都有相应的解决办法。
评分标准含糊不清
裁判出错是因为属性描述存在多种解读空间。需要将其明确化。一个好的检验方法是看能否用一句话表述该属性,让新同事也能完全按照你的方式来应用。
你自己的标注有误
这种情况发生的频率比人们预想的要高。在五十个案例中,尤其是会话末尾那些边界模糊的示例,人工标注很容易出现偏差。在指责评分员之前,先以全新的视角重新标注那些有争议的案例。
该属性本质上是主观的
有些特征无法让任何人给出一致的判断。如果是这样,那就不存在可靠的评分员了。要么将该属性拆分成更小、更容易评估的子属性,要么就接受它无法被量化评分的事实。
反复迭代:调整评分标准,必要时重新标记,然后再次运行,直到评估员发现足够多的错误,迫使你需要在会议上为该数值进行辩护。之后将评分标准固定下来,并将其版本与后续的所有结果一起保存。使用不同评分标准进行的评估无法相互比较。关于如何让评估员在持续投入生产环境后仍保持状态,可参阅将LLM作为动态生产系统中的评估工具进行管理。
校准后的评估能为你提供什么
评估结果是一个数值,你可以将其标注在相应改动旁并予以信任。在修改提示词、更换模型或改变检索步骤之前,先进行一次测量;之后再测量一次。只有在你首先确认该工具的测量结果与人工评估一致时,两次数值的差异才具有参考价值。
由于该代码除了调用模型功能的部分外不依赖任何其他元素,因此即使更换服务提供商或工具生态系统发生变动,它依然能够正常运行,而这正是大多数推荐用于此任务的方案所无法做到的。
百例评估的局限性
在采用这种方法之前,有三种限制需要了解。
细微的退化现象难以察觉
从91%下降到89%,相当于百例中的两例,在这样的样本量下与随机噪声无法区分。要检测细微的变化,要么需要更多数据,要么进行配对比较:用相同的输入运行两个版本,仅统计结果发生改变的案例。配对比较可以消除大部分由案例难度带来的差异。
手动标注无法扩展
当数据行数超过几百行时,手动标注就不再实用,且随着处理过程的持续进行,标注质量也会下降。你对第48个案例的判断与对第2个案例的判断不同,这会在校准过程中引入真正的误差。将标注工作分成多个短时段完成,而非一次性完成,可以减少这种偏差。
校准失效
今天经过验证的评估标准可能不适用于明天。当你更改特征背后的模型时,其故障模式也会随之改变,因此必须重新进行验证而非直接重复使用该评估标准。这是一项繁琐的工作,也正是它将测量行为与单纯仪式区分开来。
运行后的两项补充工作
- 为所有内容标注版本。将模型版本和评分标准版本与每个结果一起存储。缺少其中任意一者的分数都无法与其他分数进行比较,正如没有配套工具的基准数据几乎无法说明问题一样。
- 保留意见分歧的案例。你与评分员意见不一致的案例是整个评估过程中最具参考价值的记录。在每次调整评分标准后重新处理这些案例,是最快判断该调整是否有效的办法。
此处的示例更注重清晰性而非实际应用的稳定性。可将它们视为模板进行阅读和修改,根据具体需求添加重试机制、错误处理功能以及持久化存储方案。
核心要点
- 评估系统由数据集、生成器和评分器组成;其余均为可选的辅助工具。