Skip to content
This page has been auto-translated and may contain errors.View in English

评估 ​

docs.scrimba.com

你改了个 prompt,试一次,觉得答案好像变好了,于是就上线了,结果一周后用户碰到的那些情况反而悄悄变差了。评估(evaluation,简称 eval)就是解决这个问题的办法:对你的 AI 功能是否真的有效进行可重复的测试——一组输入,配上"好答案应该是什么样"的标准,每次都用同样的方式打分,这样你才能判断一次改动到底是不是真的有帮助。模型是个概率黑盒,同一个问题每次运行给出的答案都可能不一样,所以只有靠测量才能真正知道结果如何,而这种严谨的态度正是"能用的 demo"和"真正的产品"之间最大的区别。

为什么凭感觉判断行不通 ​

一开始靠手动检查 AI 的答案感觉还挺好。你跑一个 prompt,看看回复,觉得不错,就往下走了。问题是有三件事在悄悄和你作对,而这三件事你其实都已经遇到过了。

这三件事是:

  • 输出不稳定。 同一个 prompt 每次运行可能会给出不同的答案,所以这次结果好,不代表下次也一定好。
  • 改动会引发回归。 修好了一个 prompt 让某个情况没问题了,往往会悄悄把你没注意到的另一个情况搞坏。
  • 规模上撑不住。 五条答案你能一条条看完,但每次改一行代码就要看五百条答案,那是不可能靠人力做到的。

这些都不代表你做错了什么,只是说明凭感觉判断这个工具,用来应付这种规模的工作根本不够用,而这个问题其实有更省心的解法。

Juno为什么凭感觉判断行不通 靠手动读答案这套方法之所以行不通,根源都在模型的工作方式上:输出每次运行都不一样,改动会悄悄搞坏你没盯着的情况,而一旦答案数量到了几百条,靠人根本跟不上。这不是态度问题,而是工具和任务规模不匹配。好消息是,接下来要说的解决办法很友好。

凭感觉看一条回复几乎什么都说明不了,原因就在模型的工作方式里。因为输出是非确定性的(同一个 prompt 每次运行可能给出不同的答案),一条回复只是一个样本量为 1 的样本。它没法告诉你**通过率**——也就是结果正确的案例占比——因为你需要在同一批案例上多次运行,才能估算出模型到底"经常"对到什么程度。

回归问题隐藏的原因也类似。你手动修好一个案例,却没有留下其他案例的记录,于是下一次改动很可能悄悄搞坏了你已经不再盯着的地方。要发现这一点,唯一的办法就是用一组固定的案例,每次都以同样的方式打分,这样一旦分数下降,你看到的是一个明确的数字,而不是上线后才发现的意外。

除了这些数学层面的问题,还有人为因素。人工审查会因人而异、因心情而异:同一个答案,星期一看着还行,星期五看着就不行了。所以真正该做的,是把"看起来不错"变成在固定案例集上测出的一个通过率。把感觉变成同一批案例上的数字。

Juno为什么凭感觉判断行不通 一条回复只是样本量为 1 的样本,它只能告诉你模型这一次成功了,不能告诉你模型平均能成功多少次。要看到真实的通过率,就得跑很多案例;要抓住回归,就得固定这批案例,每次都用同样的方式打分。人工审查还会随审查者的状态飘忽不定,所以要做的事就是用一个测出来的数字,取代"看起来不错"这种感觉。

抽查失败不是因为你不细心,而是因为它天生带有选择偏差:你查看的是你预期会正常工作的那些情况,于是那些你从来不检查的行为,恰恰就是会在用户面前出问题的那个。你没有跟踪的那个指标,恰恰就是那个会变差的指标。

没有基线分数,任何质量上的变化都只是你讲给自己听的故事。你没法把某个变化归因到某次具体的改动上,也没法通过二分排查来找出是哪一次改动让**通过率**(案例中结果正确的占比)掉下去的,因为你压根没测过之前的分数。"感觉变好了"这种说法之所以能一直站得住,只是因为没有任何东西反驳它。

凭感觉迭代还会导致过拟合。你反复输入同样那几个例子,调到它们看起来没问题为止,于是你的 prompt 悄悄被训练成只适配那一小批私人样本,而真实流量早就飘到别的地方去了。人工审查看不到这种分布偏移,因为新出现的输入恰恰是你没有去输入的那些。一个带版本管理、有跟踪分数的评估集,才是唯一能证明改动"确实有帮助"而不是"感觉上有帮助"的方式,这就是迭代和瞎猜的区别。

Juno为什么凭感觉判断行不通 我也凭感觉上线过,然后眼看着那条我从来没抽查过的路径把错误日志刷爆——这就是选择偏差在起作用。没有基线就没法归因,也没法二分排查:如果你从来没有过一个分数,那你根本没法证明是哪次改动让分数变了。留一个带版本管理的案例集,跟踪一个分数,否则你就是在过拟合你反复输入的那五个例子,而真实流量早就走到你看不见的地方去了。

构建评估集 ​

评估集就是一份测试案例清单。每个案例包含一个输入,以及"好答案应该是什么样"。先从小规模开始。十到二十个覆盖常见情况、再加几个刁钻边界情况的案例,比什么都没有强太多了,而随着你在实际使用中发现新的失败情况,这个集合可以慢慢扩大。

python
eval_set = [
    {"input": "菜又冷又上得慢。", "expected": "NEGATIVE"},
    {"input": "这辈子吃过最棒的一餐!", "expected": "POSITIVE"},
    {"input": "还行,没什么特别的。", "expected": "NEUTRAL"},
    # ...随着你发现真实案例和边界情况,持续把它们加进来
]

当用户反馈一个不好的答案时,把它加入案例集,同时标注它本该给出的答案。这样,你的评估集就会逐渐变成一份不断增长的"记忆",记录着你再也不想犯的每一个错误——就像修好的 bug 会变成一个保留下来的测试一样。

Juno构建评估集 评估集就是一份测试案例清单:一个输入,加上"好答案应该是什么样"。先从十到二十个案例开始,覆盖日常情况,再加几个刁钻的。每次用户遇到不好的答案,就把它连同"本该给出的答案"加进去,你的案例集就会悄悄记住每一个你不想再犯的错误。

**评估集**是你收集的一批测试案例,每个案例都是一个输入,配上你能接受为"好"的输出。真正重要的不是数量,而是覆盖面。二十个精心挑选的案例,胜过五十个随便凑的案例。

覆盖面要瞄准三个方面:用户真实会遇到的常见路径、两个标签结果非常接近的决策边界(那些正好卡在分界线上的模糊案例),以及你已经见过出问题的已知失败模式。挑那些讨好的、容易过关的输入会让分数好看,但什么都说明不了,所以要让案例集尽量贴近真实流量。

用户反馈的每一个 bug,都应该变成案例集里的一条永久案例。这就把它变成了一套回归测试组:一旦一个 bug 被修好,暴露它的那个案例现在每次都会运行,从而保证它不会再复发。当你的预期输出是结构化的而不是自由文本——比如一个标签或一个 JSON 字段——你就可以用精确匹配来打分,这种方式干净、没有歧义,具体可以参考结构化输出。

留出一部分单独的数据,永远不要拿它来调参。如果你调整 prompt 一直到通过你自己正盯着的那些案例,那你其实是在用你已经研究过的答案给自己打分,这个分数就没有意义了。

Juno构建评估集 覆盖面比单纯的数量更重要:要覆盖常见路径、标签之间的模糊边界,以及你已经见过的失败情况。把每一个被反馈的 bug 都变成一条永久案例,这样案例集就能起到回归测试组的作用。留一部分数据永远不要拿去调参,否则你就是在用自己研究过的答案给自己打分。

要把**评估集**当作和 prompt 一起做版本管理的资产,因为这两者是同步演变的,一次 prompt 改动可能悄悄改变了什么才算"好答案"。案例集的标注质量决定了你这个指标的上限:一个标错的"expected"值就会拖低你的分数上限,还会悄悄给你上错误的一课,所以精心维护标注的重要性,不亚于收集案例本身。

要防范测试泄露。如果你调参一直调到通过你拿来汇报的那批固定案例,这个分数就只是表演。留一部分数据在迭代过程中完全不去看,并且随着时间推移轮换这部分数据,否则它迟早还是会悄悄泄露进你的调参循环里。

留意类别平衡。一个九成都是明显正例的案例集,会给出一个很好看但很虚的分数,同时把真正重要的那百分之十的失败情况全都藏起来,所以取样时要让那些困难和罕见的案例占据实际的权重。案例尽量从生产日志里取,而不是凭空想象,因为真实流量会暴露你坐在桌前根本想不出来的表达方式。

要接受案例集会"过期"这个事实。随着产品的演变,旧案例会渐渐不再反映用户实际发送的内容,同时会出现案例集从未见过的新失败模式。维护它是一项持续的工作,不是一次性的搭建。

Juno构建评估集 案例集是和 prompt 一起做版本管理的资产,它的标注质量就是你指标的硬上限——一个错误的预期值就能拖低整体分数。留一部分数据并定期轮换,否则你汇报的那个分数就只是表演。留意类别平衡,从日志里取真实案例,并接受案例集会随产品演变而过期,所以维护它这件事永远不会真正结束。

如何打分 ​

不同的任务需要不同的打分方式,所以第一步是把检查方式和你期望的答案类型对应起来。

  • 精确匹配适用于只有一个正确答案的情况:分类标签、提取出的字段、是或否。你直接把输出和预期值做比较。
  • 基于标准的打分适用于没有唯一正确答案的情况,比如摘要或回复。这时你检查输出是否满足某些条件:摘要有没有提到关键事实?是不是在 50 字以内?语气是否礼貌?
  • 以 LLM 作评委用另一次模型调用,按你的标准给输出打分。它能应对那些简单检查无法捕捉的模糊质量问题,规模化起来也很方便,但评委本身也是一个模型,也可能出错,所以要时不时拿它和人工判断做个对照检查。
python
# 在评估集上做精确匹配打分
def run_evals(eval_set, classify):
    passed = 0
    for test in eval_set:
        output = classify(test["input"])
        if output == test["expected"]:
            passed += 1
        else:
            print(f'FAIL: "{test["input"]}" -> got {output}, wanted {test["expected"]}')
    print(f"{passed}/{len(eval_set)} passed")

精确匹配是最容易上手的方式,而且和前面章节讲的分类、提取任务正好契合。以 LLM 作评委这个思路能奏效,是因为判断一个答案是否达标,往往比生成这个答案本身更容易预测——就像审阅一篇文章比自己写一篇文章要容易一样。但评委也是另一个具有同样缺陷的预测器,所以要把它给出的分数当作一个有用的估计,给它一份清晰的评分标准,并时不时拿它的评分和你自己的判断做一次抽样对照。

Juno如何打分 选择和答案类型匹配的检查方式:只有一个正确答案时用精确匹配,有多个都算好的答案时用标准检查,模糊的质量判断则交给另一个模型当评委。精确匹配是最温和的起点,也正好和你之前练习过的分类、提取任务对得上。但评委本身也是个模型,所以偶尔要自己瞅一眼它打的分。

要让打分方式和任务匹配,然后选那个最便宜、又还能捕捉到你真正关心的东西的方式。**评分器**是那段判断输出是否通过的代码或模型调用,选错了评分器,结果就是漏掉真正的失败,或者把明明没问题的答案标记为失败。

精确匹配适用于标签和提取出的字段:你把输出和预期值做比较,结果是确定性的,所以可以放心信任它。它和结构化输出天然契合,因为模型返回固定的结构(schema)时,你拿到的是一份干净的可比对内容,而不是自由散漫的文字。

程序化标准可以低成本地覆盖中间地带:检查长度、确认某个必要字符串是否存在、验证 JSON 能否正常解析、跑一个正则表达式。这些检查只要几毫秒、完全不需要调用模型,所以在你为半结构化的内容花钱请评委之前,先用这些检查就好。

以 LLM 作评委适用于开放式的质量判断场景,那种一个摘要或一个回复有很多种"好"的形式、又没有唯一目标答案的情况。它需要一份书面的评分标准,这样评委才能保持一致的判断尺度,还需要做一次校准检查,把它的评分拿去和你自己在样本上打的标签做比较。别忘了评分器本身也是需要你信任的代码:一个不稳定的评分器,会给你一个"看起来很自信但其实错了"的分数。

Juno如何打分 用能捕捉到关键信息的、成本最低的评分方式:标签和字段用精确匹配,长度、有效 JSON 或必要字符串这类用程序化检查,只有开放式的质量判断才交给模型当评委。精确匹配和结构化输出很搭,因为固定结构给了你一份干净的可比对内容。不管选哪种,评分器本身也是你要信任的代码,一个不稳定的评分器会给你一个自信但错误的分数。

以 LLM 作评委这种方式出问题的方式最有意思,所以在依赖它之前,先规划好该如何应对它的偏见。**评委**偏爱更长的答案,比较一对答案时倾向于挑第一个选项,也会更青睐读起来像自己风格的文本,而且它给出的绝对分数校准得很差,所以这次跑出来的 8 分和下次跑出来的 8 分不是一回事。

要把评委当作一个你自己也要评估的模型来对待。把评委模型的版本固定下来,因为它一旦更新就会漂移,你的分数也会随之变动。给它一份严格的评分标准,配上几个锚点示例,然后在一份标注过的样本上,测量评委和人工判断的一致程度,这样你才知道该在多大程度上信任它。

尽可能改用两两比较("A 和 B 哪个更好?")而不是绝对的 1 到 10 分打分;相对判断通常比让模型给出一个校准准确的绝对数字要靠得住得多。固定评委版本、给它严格的评分标准、拿它和人工做对照。

要留意循环论证的问题,以及成本问题。同一个模型系列给自己的输出打分,会共享同样的盲点,也就是说生成模型犯的某个错误,评委模型很可能也发现不了;而且要对每个案例都做规模化的评分,就是在原有模型调用的基础上再多花一份模型的钱。

Juno如何打分 评委有一些你没要求它有的"个人偏好":它喜欢长答案、喜欢第一个选项、喜欢读起来像自己风格的文字,而且它的绝对分数在不同运行之间会飘忽不定。把它的版本固定下来,给它一份带锚点示例的严格评分标准,并跟踪它和人工判断的一致程度——因为现在你其实是在给评委本身打分。尽量用两两比较,别单靠孤零零的 1 到 10 分,也要意识到,一个模型给自己家族的输出打分,会共享同样的盲点,而且账单也会翻倍。

在每次改动时都跑一次 ​

评估集只有在你真正去运行它的时候才有用。把你的 prompt 当代码一样对待:在把某个改动(不管是改 prompt、换模型,还是调整检索方式)上线之前,跑一次评估,把分数和之前的分数做个对比。如果分数下降了,那就说明你引入了一次回归,就算你自己手动测的那一次感觉还不错也一样。

正是这一步,把 prompt 工程从瞎猜变成了一件你可以放心迭代的事。改动一点东西,测量一下,保留分数更高的版本,丢掉分数更低的版本。这跟让普通软件变得可靠的循环是一样的,只是现在把它用在了 AI 功能里那些模糊的部分。

如果分数下降了,那就是你引入了一次回归。一次手动测试碰巧看起来不错,并不能推翻整个案例集给出的结论,所以每次都让这个数字来当最终裁判。

Juno在每次改动时都跑一次 评估集只有在你真正运行它的时候才有用,所以要养成习惯:任何改动上线之前,先跑一遍评估,把分数和上一次比一比。如果分数下降了,那是需要修的回归,不是可以挥挥手忽略的意外。改动、测量、保留分数更高的版本——这样你才是在迭代,而不是在瞎猜。

把评估流程接入到你的开发循环里,让它像测试套件把关合并一样,把关每次改动。对 prompt、模型或检索环节(拉取支撑文档的那一步)做的任何改动,都要先跑一遍案例集,把分数和上一次的结果做对比,才能上线。真正做决定的是这个数字,而不是你手动测一次之后的直觉。

一次只改一个变量。如果你在同一次改动里既改了 prompt 又换了模型,那分数的变化就有两个可能的原因,你没法把它归到具体的哪一个上。先隔离出单个改动,跑一遍案例集,看结果,再继续下一个改动。

不只要看**总分**(所有案例平均出来的那一个数字),还要看每个案例的差异明细。总分保持稳定,可能是因为一个案例修好了、同时另一个案例坏了,两边正好抵消,结果显示总分没变,但你的实际行为其实已经悄悄变了。看看到底哪些案例的结果翻转了,才能知道这次改动真正带来了什么影响。

把分数随时间的变化记录下来,这样进步才看得见,回归也才显眼。一份记录下来的历史,能把"感觉变好了"变成一条你能指着看的曲线,一旦分数突然下降,也能很快追溯到是哪次改动引起的。

Juno在每次改动时都跑一次 用分数把关改动,就像用测试把关合并一样:不跑评估,就不上线。每次只改一个变量,这样分数的变化才有唯一的原因,还要看每个案例的差异明细,因为总分不变也可能掩盖了一个案例修好、另一个案例坏掉这种情况。把分数随时间记录下来,这样成果看得见,回归也藏不住。

要把它做成一个真正的关卡,而不是一种仪式性的动作。在 CI 里设一个分数阈值,这样一旦某次改动让分数跌破这条线,就会直接让构建失败,而不是指望有人记得去手动检查。而且每次模型版本升级都要重新跑一遍评估,因为同一个 prompt 在新的权重上表现可能完全不一样;固定你测试时用的模型版本,在采用新版本之前先重新评估一遍。

要记住两个陷阱。第一,评估集(你那份带预期结果的固定测试案例集)只是一个代理指标,不是绝对真理,所以死磕着这个案例集去调参会导致过拟合:你能把分数往上拉,但真实的表现却停滞不前。要不断扩充和轮换案例集,这样你测的才是真正的任务,而不是在死记硬背这份测试。

第二,离线评估并不是全部。要把它和线上信号搭配起来看,比如抽样的生产流量、用户反馈、投诉率,并把真实出现的失败案例反馈回案例集里,作为新的案例。

因为输出是非确定性的,同一个输入在不同运行之间的得分可能不一样。每个案例要跑足够多次采样,用一个容差区间去判断,而不是死抠单次运行的噪声;两次运行之间一个点的波动只是测量本身的正常抖动,不是回归。把阈值设在一个能扛住这种波动的水平上,否则你一整周都得花在追查根本不存在的"鬼影"问题上。这种事我不止一次亲身经历过。

Juno在每次改动时都跑一次 在 CI 里设一个阈值,让糟糕的改动直接让构建失败,并且每次模型版本升级都重新评估一遍,因为新权重对同一个 prompt 的反应会不一样。案例集只是一个代理指标,所以要不断轮换它,并搭配线上信号一起看,否则你就是在过拟合自己的测试。而且因为输出是非确定性的,要用容差区间去判断,而不是去追查根本不存在的"鬼影"——这种事,我干过,不止一次。

实战演练 ​

上一节里的 run_evals 函数,就是一个完整的(虽然很小)评估工具。你把它指向你的分类器和评估集,它就会告诉你有多少案例通过了,还会准确指出哪些案例失败了:

python
run_evals(eval_set, classify_review)
# FAIL: "还行,没什么特别的。" -> got POSITIVE, wanted NEUTRAL
# 2/3 passed

那一条失败的记录,比十几次成功的手动测试都更有价值,因为它指出了一个你现在可以着手修复、然后重新测量的真实弱点。最后一章安全与边界会讲到,在把这些东西真正交给用户使用之前,你需要设计防范哪些风险。

Juno实战演练 像 run_evals 这样一个小小的工具就已经很有价值了:它给你一个分数,还给你明确出问题的案例。修一个,再跑一次,看着这个数字慢慢变化。接下来,安全与边界会讲到,在真实用户到来之前你需要防范什么。

一个小小的**评估工具**——那段负责跑完评估集里每一个案例、并把输出和预期结果做对比打分的代码——就是那个能把"感觉还行"变成一个你可以推动改变的数字的循环。你跑一遍案例集,得到一个分数,加上一份失败案例清单,然后修好其中成本最低的一个真实失败案例,再跑一遍。

每一个失败的案例都是一份有明确完成标准的工作:这个案例通过了,就算完成。这比"把 prompt 改好"这种目标要清晰得多,因为你能准确看到什么时候达到了目标,也能准确看到什么时候又出现了回归。

在这一切真正面对真实用户之前,下一章安全与边界会讲到你需要提前设计防范的风险。

Juno实战演练 这个工具能把感觉变成一个你可以推动改变的数字:跑一遍案例集,看分数和失败情况,修好成本最低的一个真实失败案例,再跑一遍。每一个失败案例都是一份有明确完成标准的任务,这比"改好一点"这种目标清晰得多。接下来是安全与边界,真实用户看到之前要先读一读。

到了生产环境,那个小工具就不再只是一个你手动跑一跑的脚本,而是变成了整个系统的一部分。它会逐渐演变成一个 CI 关卡——一旦分数跌破你设定的那条线,就阻止发布——再加上一个你在各个版本发布之间持续关注的仪表盘,用来捕捉单次运行永远看不出来的那种缓慢漂移。

离线评估(上线前用固定案例集打分)和线上监控(上线后从真实流量里收集的信号)会互相反馈:生产环境中出现的失败案例,会变成新的离线案例;而离线案例集,则能告诉你你的修复到底有没有真正生效。在这方面能胜出的团队,不是那些 prompt 写得最巧妙的团队,而是那些在别人都凭感觉判断时,坚持做测量的团队。

这就是那种严谨态度。下一章安全与边界会讲到,在这一切真正触达真实用户之前,你需要设计防范的风险。

Juno实战演练 把这套东西真正成熟起来,它就会变成一个 CI 关卡加一个仪表盘,离线评估和线上监控彼此反馈。我也曾经凭着"我觉得这样挺好"就上线,后来吃了亏,所以我可以负责任地告诉你:真正胜出的团队,是那些在别人都凭感觉判断时坚持做测量的团队。真实用户到来之前,先读一读安全与边界。