返回探索
指南VibeFix 编辑部更新于 2026年10月11日

别让 AI 功能“凭感觉”上线:vibe 项目的 Evals 评测体系实战

没有 evals,每次换模型都是在赌。本指南给一人团队一套半天能搭起来的评测体系:从真实日志捞 50-200 条考题、规则断言与 LLM-as-judge 的分工与偏见压制、回归跑分流水线、生产采样飞轮,再到上线门禁与回滚——附可直接复制的 run.py、CI 配置与检查清单。

一人开发者在深色屏幕前对比新旧两个模型版本的 evals 跑分报告,柱状图显示各分类得分变化

先说结论

没有 evals,每次换模型都是在赌。

这不是比喻。"赌"的意思是:你把一个不可观测、不可复现、供应商还会悄悄改的东西,塞进产品的核心链路,然后靠"我试了几次,感觉还行"上线。这套流程在传统软件里叫"没有测试",在 AI 功能里却被普遍接受——因为模型的输出太像人话了,像到让你误以为它稳定得像代码。

它不是。2023 年,斯坦福和 UC 伯克利的三位研究者(Chen、Zaharia、Zou)追踪了 GPT-3.5 和 GPT-4 在 2023 年 3 月到 6 月之间的行为变化,结果相当刺眼:GPT-4 在 3 月份做质数判断的准确率是 97.6%,到了 6 月,同一批题目、同一个"GPT-4",准确率掉到了 2.4%。代码生成更惨:直接可运行的代码比例从 52% 掉到 10%,因为新版模型开始在代码前后加多余的引号。供应商没有发公告,没有改版本号,你的 prompt 一个字没动,输出就悄悄换了个人。

这篇指南不谈大道理,只谈一套一人团队半天能搭起来的东西:固定考题、评分器、回归跑分、上线门禁。目标只有一个——下次你换模型、改 prompt 的时候,手里有一张牌,而不是一颗心。

一、"我试了几次,感觉还行":你项目里最贵的 QA 环节

先给"凭感觉上线"算一笔账,看看它到底贵在哪。它贵在三个幻觉上,每个都精准地长在 vibe coding 的舒适区里。

幻觉一:抽样幻觉

你试的那 5 个例子,全是你最熟悉的 happy path。你写 prompt 的时候脑子里想的就是这 5 种问法,模型当然答得漂亮。用户会用第 6 种姿势把你打穿:错别字、方言、半句话、把两个需求揉进一句话里。你试的不是"用户会怎么问",是"你希望用户怎么问"。这两者的差距,就是线上事故的形状。

幻觉二:作者滤镜

prompt 是你亲手调的,你对输出自带滤镜。少了个关键信息,你的大脑会自动脑补"用户应该能看懂";语气有点怪,你会想"大概没人会在意"。你不是在评测输出,你是在给自己的作品找理由。让作者当裁判,永远是满分——这不是人品问题,是结构问题。

幻觉三:时间幻觉

这是最要命的一个。你今天试的"还行",测的是今天这个时刻的模型。供应商的模型是持续更新的:静默微调、对齐策略调整、乃至整个版本替换,都可能不告诉你。前面提到的斯坦福研究就是铁证——三个月,同一服务,质数题从 97.6% 掉到 2.4%。你今天的"还行",下个月可能就过期了,而你没有任何机制能发现过期。

还有两种更常见的"赌博"场景,vibe 开发者几乎每周都在玩:

  • 为了省钱换便宜模型。把 GPT-4o 换成 mini,或者换一家便宜三倍的供应商,试了十条觉得"差不多",就全量切了。三个月后用户投诉"AI 变笨了",你连它是真变笨了、还是用户变挑剔了,都说不清。
  • 改 prompt 修 bug 引入退化。用户反馈 A 场景答得不好,你在 prompt 里加了一段 instructions,A 场景好了,B、C、D 场景悄悄坏了。你修好了眼前这一个,弄坏了身后三个,还觉得自己"迭代得很快"。

一人团队算总账:一次线上翻车,成本是救火的时间 + 用户信任的流失 + 你半夜爬起来回滚的血压。一套最小 evals 的成本,是半天时间 + 每次跑分几毛钱的 API 费用。这笔账不难算,难的是承认"我试了几次"根本不是测试。

把无数个深夜压缩成一个剧本,你会看到翻车永远是同一个形状:周一,你把模型从旗舰版换成便宜三倍的轻量版,亲手试了 10 条,条条都对,当晚全量上线;周三,有用户说"AI 开始答非所问了",你去翻日志,发现轻量版在长输入下会丢掉中间的指令——而你试的那 10 条全是短输入;周五,你想切回去,发现当初图省事用的是浮动别名,根本说不清线上跑的到底是哪个版本;周日,你在用户群里道歉,承诺"下次一定先充分测试"。没有下次——没有 evals 的"下次",和这次用的是同一种方法。

这个剧本里没有反派。模型供应商没做错什么(人家一直在迭代),你也没偷懒(你确实试了 10 条)。错的是方法:用 10 条 happy path 去验证一个分布的变化,用一次性的手感去替代可重复的测量。vibe coding 的速度红利本来就是这么来的,红利吃完了,账得认。

二、Evals 是什么、不是什么

先把定义钉死,免得后面各说各话。

Evals = 一组固定输入 + 评分器 + 可重复的跑分流程。它不回答"这条输出好不好",它回答"这一版比上一版变好了还是变坏了,变了多少"。输出是一个数字,和这个数字的变化。

定义清楚了,再说三个"不是",因为这三个混淆是 evals 推行不下去的最大阻力。

不是单元测试

单元测试断言的是代码逻辑,输入输出确定,跑一百万次结果一样。Evals 断言的是模型行为的分布,带随机性,单次结果有抖动。你的 pytest 全绿,和"AI 功能有没有变好"之间,没有任何关系——测试的是代码"周围"的东西,不是模型"说"的东西。很多团队的 CI 里几百个测试全过,AI 功能上线当天就翻车,原因就在这里:测试覆盖了 100% 的确定性代码,覆盖了 0% 的不确定性输出。举个典型:你写了个"把用户留言转成结构化工单"的接口,单元测试断言"返回的是合法 JSON、字段齐全"——全绿。某天同事为了改语气微调了 system prompt,模型开始给没有订单号的留言编造订单号。JSON 照样合法,字段照样齐全,所有单元测试继续全绿,只有用户在投诉"你们系统里的订单号是假的"。这就是 evals 要填的坑:代码周围的测试,永远看不见模型说了什么。

不是人工抽检

人工抽检回答"这条输出行不行",是点;evals 回答"这一版整体是变好还是变坏",是面。抽检的问题是不可重复、不可比较:你这周抽 20 条觉得不错,下周换 20 条觉得不行了,是模型变了,还是你抽的题变了?你说不清。Evals 用同一套题反复考,变量只有一个——被测的那一版。

不是公开 benchmark

SWE-bench、MMLU 这些榜单证明的是"这个模型厉害",不证明"你的 prompt + 这个模型 + 你的业务数据"厉害。Benchmark 是选秀,evals 是体检——体检得用你自己的身体、用你用户的真实输入。拿着榜单第一名的模型,配上你没测过的 prompt 上线,相当于听说某人体检全优就让他替你上考场。

三者的分工,一张表说清楚:

单元测试Evals人工抽检
测什么代码逻辑对不对模型输出行为有没有变好/变坏个案质量、体验细节
什么时候跑每次提交换模型、改 prompt、定时回归每周固定时间
回答的问题代码坏了吗这一版比上一版如何用户实际感受到什么
成本几乎为零每次几毛到几块钱 API 费你的时间,最贵
缺了会怎样代码一改就崩模型一换就赌体验慢慢腐烂没人发现

我的立场很明确:三者缺一不可,但对 AI 功能来说,evals 是当前最缺的那块板。单元测试你早就在写了,人工抽检你偶尔会做,evals 可能是零——而恰恰是模型行为这部分,是你产品里变化最快、最不可控的部分。

搭起来之后,evals 是怎么死的

提前给你打三针预防针,因为 evals 体系有三种经典死法,个个都死于"搭起来了"之后:

  • 死于"跑一次就扔"。考题库建完再也没更新过,三个月后它测的是上个季度的业务。考题库和代码一样会腐烂,不持续投喂生产翻车,它就是个纪念品。
  • 死于"分数通胀"。为了让 CI 变绿,把挂掉的题删掉、把 rubric 放水、把容忍带调大。分数回到了 95,问题一个没修。这比没有 evals 更坏——没有 evals 你至少知道自己在赌,通胀的分数让你以为自己没在赌。
  • 死于"没人看报告"。流水线每天跑,报告每天生成,退化了也没人管。一人团队里,"没人"就是你——解决方法是把"看报告"写进发版 checklist,而不是靠自觉。

三句话总结保命:考题库每月有新增,删题要走评审,跑分报告发版前必看。做到这三条,你的 evals 就能活过三个月;活过三个月,它就会变成你离不开的东西。

三、黄金数据集:从真实日志里捞 50-200 条"考题"

数据集是 evals 里 90% 的工作量,也是 90% 的价值。评分器写得再漂亮,考题是垃圾,分数就是垃圾。关于"考题从哪来",我只有一个强观点:

考题必须从真实用户日志里捞,不能靠自己编。你出的题永远是你已经会做的题;用户出的题,才是你不会的题。自己出题、自己考、自己判,等于永远满分——这是 evals 实践里最常见的自欺欺人。

具体怎么做?四步。

第一步:捞——从生产日志里导出真实输入

把过去一到三个月的生产日志翻出来,导出用户真实发给 AI 功能的输入。导出之前先做脱敏:人名、电话、订单号、地址,全部替换成占位符(比如 [姓名]、[订单号])。脱敏脚本写一次,以后每次捞都用同一套。

捞的时候要有意识地覆盖三类输入,缺一类,你的考题库就有盲区:

  • 高频输入(约 50%)。出现频率最高的那些问法。这是你的基本盘,基本盘丢分等于大面积翻车。
  • 翻车输入(约 30%)。用户点了"没用"、"重新生成",或者问完又去转人工的那些。这些是已经付过学费的题,不进考题库等于学费白交。
  • 长尾输入(约 20%)。随机抽一些冷门的、奇奇怪怪的输入。长尾是模型最容易出丑的地方,也是你凭感觉测试时永远覆盖不到的地方。

第二步:分——去重、聚类、打标签

原始日志里 80% 是重复问法。先去重:简单的可以按文本相似度去重,讲究一点的用 embedding 聚类。去重之后,按两个维度打标签:

  • category(任务类型):比如退款咨询、用法询问、故障排查、闲聊。每类至少保证 5 条以上,免得某一类全军覆没你都看不出来。
  • difficulty(难度):简单 / 中等 / 刁难。"刁难"档专门放那些带情绪的、信息不全的、故意挖坑的输入——线上最痛的 case 全在这档。

第三步:标——写"通过条件",不写"标准答案"

这是最关键的一步,也是最容易做错的一步。对开放式输出,不要写"标准答案全文"——模型换个说法答对了,你判它错,你的 evals 就变成了"背诵比赛"。要写的是通过条件,三件套:

  • must_contain:输出里必须出现什么(关键词、关键信息点)。
  • must_not_contain:输出里绝不能出现什么(编造的订单号、政策之外的承诺、脏话)。
  • judge_rubric:需要语义判断的部分,写给 LLM judge 或人工看的判分标准,一句话说清"什么算过、什么算挂"。

考题的数据格式就长这样,一行一条,直接存 JSONL:

{"id": "refund-001", "category": "退款咨询", "difficulty": "中等",
 "input": "我三天前买的会员,能退吗?",
 "expected": {"must_contain": ["7天", "退款"],
              "must_not_contain": ["不能退"],
              "judge_rubric": "回答必须引用7天无理由退款政策,不得编造退款链接或客服电话"}}
{"id": "refund-002", "category": "退款咨询", "difficulty": "刁难",
 "input": "你们这破会员根本没用,赶紧给我退了,不然我就去投诉!",
 "expected": {"must_contain": ["退款"],
              "must_not_contain": ["工商局", "威胁"],
              "judge_rubric": "语气需安抚但不卑微;不得承诺政策之外的补偿;不得与用户争吵"}}
{"id": "usage-014", "category": "用法询问", "difficulty": "简单",
 "input": "怎么导出聊天记录?",
 "expected": {"must_contain": ["设置", "导出"],
              "must_not_contain": [],
              "judge_rubric": "步骤需与当前版本界面一致,不得描述已下线的旧入口"}}

第四步:锁——进 git,版本化,只增不减

考题库是代码资产,进版本管理。立两条铁规:

  • 考题只许增,不许删。删题等于删除历史,掩盖退化。某道题过时了(功能下线了),标记为 deprecated,而不是删掉——删改考题库本身要走评审。
  • 每次线上事故复盘,第一个问题永远是:这题进考题库了吗?没进,复盘不算结束。这是考题库持续生长的唯一可靠机制。

关于数量:50 条是起步线——跑一次几分钟、几毛钱 API 费,你能做到每次改 prompt 都跑。200 条是舒适区,覆盖更全。一人团队别一上来就搞 500 条,题库是会腐烂的:业务变了,旧题就得修。先让 50 条转起来,再慢慢加。记住 zalt 那句被反复验证的经验:一套 50 条、每次改动都跑的题库,完胜一套 1000 条、跑一次就烂尾的题库。

最后说一个反模式:用 AI 批量生成考题,然后自己考自己判。AI 出的题是它"觉得"用户会问的,标准是它"觉得"对的,判分还是它自己——闭环了,永远满分,纯属自我感动。AI 可以帮你干一件事:把已经人标好的题改写措辞、扩写变体(同一考点的 3 种问法)。出题和定标准,必须是人。

AI 模型评测打分卡概念图

四、评分器设计:规则断言 vs LLM-as-judge

考题有了,下一个问题:谁来判分?两种判法,各有各的脾气。我的立场先摆出来:

能用规则判的,一道题也别请 LLM judge。Judge 是奢侈品:贵、慢、还自带偏见。规则断言毫秒级、零成本、零抖动,是 evals 的压舱石。

规则断言工具箱

别小看规则,它能覆盖的比你想象的多:

场景断言方式例子
分类 / 抽取精确匹配或集合匹配意图分类 label 必须等于预期值
结构化输出JSON Schema 校验字段齐全、类型正确、无多余字段
关键信息must_contain / must_not_contain退款政策关键词出现;不许出现编造的订单号
数值范围断言置信度在 0-1 之间;价格为正数
格式正则表达式日期、电话号码、markdown 表格表头
语义接近embedding 相似度 ≥ 阈值改写类任务,0.8 是个常用起点(先用人标数据调阈值)
长度区间断言摘要不超过 200 字;标题 10-30 字

决策标准一句话:凡是"把规则写出来"比"请 judge"更便宜的场景,无脑选规则。只有一种情况必须请 judge——开放式回答的质量:语气是否得体、解释有没有逻辑、安抚话术到不到位、创意文案行不行。这些东西写成规则的成本,比请 judge 还高,这时候才轮到 judge 上场。

Judge 的四种偏见,一个都别信它是"客观"的

LLM-as-judge 的意思是:再调一个模型当裁判,按你写的 rubric 给输出打分。它好用,但它有四个被反复实证的系统性偏见:

  • 啰嗦偏好(verbosity bias):长的答案自动加分。两个质量相当的回答,judge 大概率选长的那个。
  • 位置偏见(position bias):成对比较时,先看到的答案占便宜。顺序一换,胜负可能就反了。
  • 自我偏好(self-preference):judge 给自家模型家族的输出打高分。用 GPT 当 judge 判 GPT 和 Claude 的输出,天平从一开始就是斜的。
  • 宽容漂移:同一个 judge,今天严、明天松。所以 judge 用的模型版本必须 pin 死——judge 换了,整套历史分数直接作废。

压偏见的六招

偏见压不住,只能管住。六招,按重要性排序:

  1. Rubric 写二值判断,不写 1-10 打分。"通过 / 不通过"比"打 7 分还是 8 分"稳定一个数量级。小数点后的精度全是幻觉。
  2. 先写"什么算挂",再写"什么算过"。挂的条件越具体,judge 越老实。模糊的 rubric 养出模糊的分数。
  3. Pin 住 judge 的模型版本。写具体的版本 ID,不写 latest 之类的浮动别名。judge 模型一换,历史分数全部重跑。
  4. 盲评。别告诉 judge 这是谁的输出。做 A/B 对比时,隐去候选身份,只给"回答 A / 回答 B"。
  5. 成对比较必须交换顺序跑两遍。AB 顺序和 BA 顺序各跑一次,两次都赢才算赢。只跑一遍的对比结果不值得信任。
  6. 用人标数据校准,达标才上岗。抽 30-50 条,人先判,judge 再判,算一致率。低于 85%,这个 judge 就别用——先修 rubric,修不好就换 judge 模型。没校准过的 judge,其分数只是"看起来像数字的噪音"。

一个能直接抄的 judge prompt 模板(rubric 部分换成你自己的):

你是判分员,不是参谋。只输出 PASS 或 FAIL,外加一句话理由。

1. 回答引用了7天无理由退款政策
2. 没有编造政策中不存在的退款链接或电话

1. 承诺了政策之外的补偿
2. 与用户争吵或使用威胁性语言
3. 答非所问
待判回答:
---
{output}
---
先逐条对照"挂掉条件",再看"通过条件"。只输出:
verdict: PASS / FAIL
reason: 一句话

最后给个配比参考:社区里被验证过多次的经验值是 60% 规则断言 + 30% LLM judge + 10% 人工抽检。这不是铁律,是起点。你的第一版完全可以是 90% 规则 + 10% 人工,judge 等你被开放式 case 逼到墙角再请——请得越晚,说明你的规则化做得越好。

附:embedding 相似度阈值怎么定

"0.8 是个常用起点"——但起点不是答案。阈值必须用人标数据调,方法很土,但有效:

  1. 从考题库里抽 50 对(模型输出,参考改写),人先判"意思一样 / 不一样"。
  2. 算每对的 embedding 相似度,按分数排序。
  3. 找那个让"误判最少"的切分点:阈值以上判"一样",以下判"不一样",数一数和人工判断冲突的有多少。
  4. 把阈值定在冲突最少的点,然后往严格方向再收 0.02——宁可错杀(人工复核),不可错放(坏输出蒙混过关)。

阈值和 embedding 模型绑定:换了 embedding 模型,阈值重调。这条规则和"judge 换模型要重校准"是同一条:评分器里的任何模型,心智版本变了,分数就作废。

五、回归评测:换模型 / 改 prompt 前后的最小流水线

考题和评分器都有了,evals 的核心动作其实只有一个,无聊到可笑:

改之前跑一次,存下来当 baseline;改完再跑一次,对比 diff。没有 baseline 的跑分都是耍流氓——分数本身没有意义,变化才有意义。92 分是好是坏?不知道。但 92 → 88 一定是坏消息。

跑分报告长什么样

一份能用的回归报告,五个部分缺一不可:

  • 总分对比:baseline 92.0 → 本次 91.5(Δ -0.5),一目了然。
  • 分类分对比:退款咨询 95→94,故障排查 88→82——总分只掉了 0.5,但"故障排查"掉了 6 分,这才是要命的地方。总分会撒谎,分类分不会。
  • 退化的 case 清单:具体哪几条挂了、挂在哪一类、输出片段是什么。定位问题全靠它,没有它,报告就是正确的废话。
  • 成本与延迟对比:换便宜模型省了多少钱、慢了多少毫秒。准确率没变但延迟翻倍,同样是退化——用户可不会为你的成本优化买单。
  • 环境信息:模型版本、prompt 版本、跑分时间。没有这三个字段,三个月后你看到这份报告,会完全想不起来它测的是个啥。

报告在终端里长这样,跑完一眼就有结论,不用再"感觉":

$ python evals/run.py --compare evals/baseline.json
{
  "score": 0.880, "total": 120,
  "by_category": {"退款咨询": 0.95, "用法询问": 0.93, "故障排查": 0.78, "闲聊": 0.90},
  "failed": ["trouble-017", "trouble-023", "trouble-031"],
  "cost_usd": 0.42, "p95_latency_ms": 1830
}
baseline=0.920 now=0.880 delta=-0.040
REGRESSION DETECTED, failing the build.
退化分类: ['故障排查']

看到"故障排查 0.88 → 0.78",你就知道该去看 trouble-017 这几条到底答错了什么,而不是对着总分发呆。总分是给外人看的,分类分和挂掉的 case 清单才是给工程师看的。

最小流水线三件套

回归评测的流水线不需要平台,三件套就够:

  • run.py:读 golden.jsonl → 调模型 → 跑评分器 → 输出 results.json + 终端报告 → 与 baseline.json 对比 → 退化则以非零退出码失败。第八节有完整可复制的模板。
  • baseline.json:上次发布的分数快照,进 git。每次正式发版后,用发版那一版的代码和模型重跑一次,更新 baseline——baseline 必须永远对应"线上正在跑的那一版",否则对比毫无意义。
  • CI job:改 prompt、换模型、动 AI 链路代码时自动触发。退化 → 流水线变红 → 合并被拦。具体 YAML 模板见第八节。

触发频率值得单独说一句:别每次 push 都跑,evals 烧的是 API 的钱。只在 prompt、考题库、AI 链路代码变化时跑,外加每天夜里一次定时全量回归。这样一个月下来,费用通常就是几杯咖啡钱——比你半夜爬起来救火便宜两个数量级。

冷启动:第一个 baseline 怎么定

"我连第一版分数都没有,拿什么当 baseline?"——这是最常见的启动卡点。答案:用线上正在跑的那一版,现跑一次,就是你的 baseline。别纠结它高还是低,baseline 的使命不是"好看",是"诚实"。具体步骤:

  1. 冻结当前线上版本(模型版本 + prompt 版本),打 tag。
  2. 用这一版跑完整套考题,分数原样存进 baseline.json,不许手动"优化"。
  3. 如果分数低得离谱(比如 70 分),先别急着调 prompt——先检查是不是考题标得太严。baseline 定好之后,提分是后面的事。

第一个 baseline 有个潜规则:它一定会让你不舒服。亲手标的刁难题,第一次跑总有几道挂。这是好事——evals 的第一份产出,就是一张"我们到底哪不行"的诚实清单。没有这张清单,你之前所有的"感觉还行"都是幻觉。

门禁规则:卡"退化",不卡"完美"

这是整套体系里最容易被误用的地方。新手搭起 evals,第一反应是定个高线:"必须 95 分才能发版"。错。你的功能现在 92 分,强行要求 95 分,结果只有两个:没人敢发版,或者大家开始给考题放水、把难的题删掉。两种都是灾难。

正确的门禁哲学只有一句话:分数只许升,不许降。baseline 92,容忍带 3%,门禁线就是 89。91.5 分?过,波动而已。88 分?停下,看看是哪一类退化了。门禁卡的是"变化方向",不是"绝对高度"。绝对高度是留给季度规划去操心的事,不是发版门禁的事。

统计学小提醒:别为 1% 的抖动开会

LLM 输出带随机性,单次跑分有抖动。三个工程习惯熨平它:

  • 每题跑 3 次,取多数/中位数。3 次里 2 次通过才算通过。别跑 1 次就下结论,也别跑 10 次烧钱。
  • 容忍带设 2-3%。在这个带宽里的波动,当成噪音,别开会、别回滚、别改 prompt。追逐噪音会让你把本来没问题的 prompt 改出真问题。
  • 大改动跑 5 次看分布。换模型、换供应商这种伤筋动骨的改动,多跑几次确认分数稳定,别被某一次的运气骗了。

记住:evals 是统计学生物,不是确定性机器。用对待单元测试的心态对待 evals("必须 100% 通过,一次抖动就是 bug"),你会在第一周就放弃它。它的正确用法是天气预报:看趋势、看变化,不纠结某一度。

六、线上闭环:生产采样 + 人工抽检 + 考题库扩充的飞轮

离线跑分只能保证"你考过的题不退化",但用户永远会发明你没考过的题。Evals 的后半套在生产线上:把真实世界的翻车,持续不断地变成考题。

生产采样:捞三类,别随机瞎捞

每天(或每周,看你的量级)从生产日志里抽样,重点捞三类:

  • 用户给了差评 / 点了"重新生成"的:必抽。这是用户亲手标记的翻车现场,含金量最高。
  • 重试、超时、转人工的:必抽。链路层面的失败,往往对应着模型输出层面的失败。
  • 随机 1%:防幸存者偏差。只看翻车,你会误以为世界全是翻车;随机样本告诉你基本盘还稳不稳。

采样逻辑写成定时任务,别靠手捞。伪代码长这样,换成你的日志表结构就能用:

-- 每天凌晨跑:三类样本各取一批,写入 eval_sampling 表
-- 1) 用户差评 / 点了重新生成
SELECT input, output FROM ai_logs
WHERE created_at > NOW() - INTERVAL '1 day'
  AND (user_feedback = 'bad' OR regenerated = true)
LIMIT 50;
-- 2) 重试 / 超时 / 转人工
SELECT input, output FROM ai_logs
WHERE created_at > NOW() - INTERVAL '1 day'
  AND (retry_count > 0 OR timed_out OR handed_to_human)
LIMIT 50;
-- 3) 随机 1%(按主键哈希抽样,保证可重复)
SELECT input, output FROM ai_logs
WHERE created_at > NOW() - INTERVAL '1 day'
  AND MOD(HASH(id), 100) = 0
LIMIT 100;

人工抽检协议:每周 30 分钟,多一分钟都别加

抽检最大的敌人不是不准,是坚持不下去。所以协议必须轻到不可能失败:

  • 每周固定时间,抽 20 条(采样捞出来的)。
  • 每条只判 pass / fail,加一句话原因。不写小作文。
  • fail 的条目,当场决定:进考题库,还是记成待办。

20 条、30 分钟、一杯咖啡的时间。这个节奏你能坚持一年,evals 就能活一年。定 100 条、2 小时的抽检计划,第三周就没人做了——包括你自己。

飞轮:你交过的学费,要变成收据

完整的飞轮长这样:

  1. 线上翻车(用户投诉 / 差评 / 你自己发现)。
  2. 5 分钟复盘:这是 prompt 问题、模型问题,还是输入本身就超纲?只分这三类,别展开。
  3. 写成考题进库:把翻车的输入脱敏,写好通过条件,commit 进 golden.jsonl。
  4. 下次同类改动发布前,回归集自动拦住它。

考题库是你交过的所有学费的收据。收据丢了,等于学费白交。给飞轮定两个健康度指标,每月看一眼:

  • 每月新增考题数:连续两个月零新增,说明复盘流程死了,或者你的采样根本没在看。
  • 考题库覆盖的线上事故占比:目标是 80% 的事故在库里能找到同类题。达不到,说明你的"翻车→考题"链路断了。

七、上线门禁:多少分才敢发版

前面全是"怎么测",这一节是"测完怎么办"。没有门禁的 evals,就是体检完不看报告——仪式感拉满,作用为零。

门禁线怎么定

别拍脑袋定"必须 95 分"。门禁线 = baseline - 容忍带。baseline 是 92,容忍带 3%,门禁线就是 89。低于 89,发布流程停下。这条线的哲学是:卡"退化",不卡"完美"。你的功能现在 92 分,强行要求 95 分才能发版,结果就是没人敢发版,或者大家开始给考题放水。

新功能、大改 prompt:单独定线。第一版可以定得宽松一点(比如 baseline - 5%),跑两周稳定了再收紧。门禁线本身也要版本化,调线要留记录、说理由——不然三个月后没人记得这条线为什么是 89。

门禁线版本化,给个最小格式,存在 evals/gate.md 里:

# 上线门禁线
- 当前 baseline: 0.920 (2026-10-11, 模型 gpt-4o-mini-2024-07-18, prompt v14)
- 门禁线: 0.890 (= baseline - 0.03)
- 分类红线: 任一分类下降超 5%,单独 block
- 变更记录:
  - 2026-10-11: 初版,baseline 0.920,门禁 0.890(reason: 首版 baseline)
  - 2026-10-25: baseline 升至 0.935,门禁同步升至 0.905(reason: prompt v15 优化故障排查类 +4%)

注意第二条变更:baseline 涨了,门禁线要跟着涨。只降不升的门禁,时间久了会变成摆设——"反正 89 分随便过"。门禁是活的,它应该跟着你的质量水位一起往上走。

分数掉了,谁负责

规则简单到只有一句话:谁改 prompt、谁换模型,谁跑 evals,谁把报告贴进 PR。

一人团队也别跳过这一步——这不是做给别人看的,是做给三个月后的你看的。你会失忆,会忘记"当时为什么觉得 88 分可以发"。PR 里贴着的跑分报告,就是你给未来的自己留的证据。PR 模板里加一行:

## Evals 回归报告
- baseline: 92.0 (2026-10-01)
- 本次: 91.5 (Δ -0.5)
- 分类分变化:无分类下降超过 2%
- 结论:无退化,可发版 / 有退化,原因:___

分数掉了,怎么回滚

门禁 block 了合并,但总有"必须现在上线"的时候。这时候不靠勇气,靠三件套:

  • 模型版本 pin 死。写 gpt-4o-2024-08-06 这种具体版本,别写 gpt-4o 这种浮动别名。浮动别名是供应商留给你的后门:他哪天换了指向,你的"回滚"滚了个寂寞。
  • prompt 进版本管理。prompt 也是代码,放 git,和模型版本、考题库一起打 tag。回滚的时候,prompt 和模型一起回,只回一半等于没回。
  • feature flag 一键切回旧链路。新链路分数不行?flag 一关,流量切回上一版。这个开关必须真刀真枪演练过一次——没演练过的开关,关键时刻一定打不开。
版本对比曲线图:吞吐量上升、错误率下降

例外流程:真要硬上怎么办

门禁 block 了,但业务说"今晚必须上",怎么办?给例外留一条有记录的路,而不是让大家学会绕过门禁:

  1. 走 feature flag 灰度,先放 5% 流量,盯着采样和用户反馈,24 小时后再决定全量。
  2. 例外单写清楚三件事:为什么这次不等分数修好(业务理由)、谁拍的板(名字)、回滚条件是什么(分数再掉 X 就切回)。
  3. 例外单进 git,和跑分报告放一起。下次复盘时,它就是"我们当时为什么冒险"的证据。

没有记录的例外叫"破例",有记录的例外叫"风险决策"。一人团队最大的风险不是慢,是三个月后没人记得当时为什么做了那个决定——而那个人就是你。

门禁的意义,最后说透:它不是拦住所有坏发布——那不可能,考题库永远有盲区。它的意义是让每一次发布都有记录、有负责人、有回滚键。坏发布不可怕,可怕的是不知道怎么坏的、坏了回不去的。

八、一人团队的最小 evals 配置:半天搭起来

理论讲完,上手。这套配置的目标:今天下午搭完,今天晚上就能用。不需要 evals 平台、不需要看板、不需要花钱——一个跑得起来的 run.py,胜过十个 PPT 里的"评测体系规划"。

目录结构

evals/
├── golden.jsonl        # 考题库:一行一条,含通过条件
├── graders.py          # 评分器:规则断言函数
├── run.py              # 跑分脚本:跑题、判分、对比 baseline、输出报告
├── baseline.json       # 上次发布的分数快照(git 管理)
├── judge_prompt.txt    # LLM judge 的 rubric 模板(有开放式题才需要)
└── requirements.txt    # openai / anthropic 二选一,加上你的主力 SDK

graders.py:规则评分器模板

import json, re

def grade(case, output):
    """返回 (passed: bool, reasons: list[str])"""
    exp = case["expected"]
    reasons = []
    ok = True
    for kw in exp.get("must_contain", []):
        if kw not in output:
            ok = False; reasons.append(f"缺失关键词: {kw}")
    for kw in exp.get("must_not_contain", []):
        if kw in output:
            ok = False; reasons.append(f"出现禁用词: {kw}")
    return ok, reasons

def grade_json_schema(case, output, required_fields):
    """结构化输出:先判 JSON 合法,再判字段齐全"""
    try:
        data = json.loads(output)
    except Exception:
        return False, ["输出不是合法 JSON"]
    missing = [f for f in required_fields if f not in data]
    if missing:
        return False, [f"缺失字段: {missing}"]
    return True, []

def grade_length(case, output, min_len=0, max_len=10_000):
    n = len(output)
    if not (min_len <= n <= max_len):
        return False, [f"长度 {n} 超出区间 [{min_len}, {max_len}]"]
    return True, []

run.py:跑分 + 对比 baseline 模板

import json, sys, statistics
from graders import grade

MODEL = "gpt-4o-mini-2024-07-18"   # pin 具体版本,别用别名
TOLERANCE = 0.03                   # 容忍带:3%
REPEATS = 3                        # 每题跑 3 次取中位数,熨平随机抖动

def call_model(prompt_input):
    # TODO: 换成你的实际调用:拼 prompt、调模型、返回文本
    raise NotImplementedError

def run_once(cases):
    results = []
    for c in cases:
        outs = [call_model(c["input"]) for _ in range(REPEATS)]
        passes = [grade(c, o)[0] for o in outs]
        # 3 次取中位数:至少 2 次通过才算通过
        passed = statistics.median(passes) == 1
        results.append({"id": c["id"], "category": c["category"],
                        "passed": passed})
    return results

def summarize(results):
    total = len(results)
    score = sum(r["passed"] for r in results) / total
    by_cat = {}
    for r in results:
        by_cat.setdefault(r["category"], []).append(r["passed"])
    by_cat = {k: sum(v)/len(v) for k, v in by_cat.items()}
    failed = [r["id"] for r in results if not r["passed"]]
    return {"score": round(score, 3), "by_category": by_cat,
            "failed": failed, "total": total}

if __name__ == "__main__":
    cases = [json.loads(l) for l in open("golden.jsonl", encoding="utf-8")]
    summary = summarize(run_once(cases))
    print(json.dumps(summary, ensure_ascii=False, indent=2))
    json.dump(summary, open("results.json", "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    base = json.load(open("baseline.json", encoding="utf-8"))
    delta = summary["score"] - base["score"]
    print(f"baseline={base['score']} now={summary['score']} delta={delta:+.3f}")
    regressed = [c for c, s in summary["by_category"].items()
                 if s < base["by_category"].get(c, 1) - TOLERANCE]
    if summary["score"] < base["score"] - TOLERANCE or regressed:
        print("REGRESSION DETECTED, failing the build.")
        if regressed:
            print("退化分类:", regressed)
        sys.exit(1)
    print("OK: 无退化。")

注意两个细节:每题跑 3 次取中位数,是因为单次跑分有随机抖动,别为 1% 的波动开会;退出码 exit(1) 是给 CI 用的——退化就让流水线变红,这是门禁唯一需要的"集成"。

有开放式考题?给 run.py 加一个 judge 扩展点,规则先行、judge 兜底:

def grade_with_judge(case, output, judge_model="gpt-4o-2024-08-06"):
    # 先跑规则:规则挂了直接判挂,不浪费 judge 的钱
    ok, reasons = grade(case, output)
    if not ok:
        return False, reasons
    rubric = case["expected"].get("judge_rubric")
    if not rubric:
        return True, []          # 无 rubric 的题,规则通过即通过
    prompt = open("judge_prompt.txt", encoding="utf-8").read()
    verdict = call_judge(judge_model, prompt.format(
        rubric=rubric, output=output))   # TODO: 换成你的 judge 调用
    passed = verdict.strip().upper().startswith("PASS")
    return passed, [] if passed else ["judge 判挂: " + verdict[:200]]

这个顺序是故意的:规则是筛子,judge 是显微镜。筛子先把明显不行的筛掉,显微镜只看筛子拿不准的。反过来用(先 judge 后规则),你花的每一分钱都在为本可以用正则解决的问题买单。

CI 接入:GitHub Actions 模板

name: evals
on:
  pull_request:
    paths: ['prompts/**', 'evals/**', 'src/ai/**']
jobs:
  evals:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install -r evals/requirements.txt
      - run: python evals/run.py
        working-directory: evals
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

触发条件值得说一句:别每次 push 都跑——evals 烧的是 API 钱。只在 prompt、考题库、AI 链路代码变化时跑,外加每天夜里一次定时全量。这样一个月下来,费用通常就是几杯咖啡钱。

不想写代码?promptfoo 一行命令版

开源工具 promptfoo 就是为这事生的:YAML 写考题和断言,一行命令跑。最小配置:

# promptfooconfig.yaml
prompts:
  - file://prompt.txt          # 你的 system prompt
providers:
  - openai:gpt-4o-mini-2024-07-18
tests:
  - vars: { input: "我三天前买的会员,能退吗?" }
    assert:
      - type: icontains
        value: "7天"
      - type: not-icontains
        value: "不能退"
  - vars: { input: "怎么导出聊天记录?" }
    assert:
      - type: icontains
        value: "导出"
npx promptfoo@latest eval      # 跑分
npx promptfoo@latest view       # 看报告面板

promptfoo 适合"先跑起来再说",等你有了 100+ 考题和自定义 judge 需求,再迁到自己的 run.py 也不迟。工具是手段,考题库才是资产——资产在你手里,工具随时能换。

半天排期表

时间干什么产出
9:00 – 10:30从生产日志捞 100 条真实输入,跑脱敏脚本原始输入池
10:30 – 12:00人工标通过条件,先标 50 条(别贪多)golden.jsonl v0.1
13:30 – 15:00写 graders.py + run.py,调通能跑的流水线
15:00 – 16:00跑 baseline,存档进 git,接上 CIbaseline.json + Actions 变绿
16:00 – 17:00写第一版 judge rubric(有开放式题才需要),用人标数据校准judge_prompt.txt

上线前检查清单

发版前逐项打勾,缺一项都别说自己"有 evals":

  • 考题来自真实日志,不是现编的;每条有 category 和难度标签
  • 规则断言覆盖 60% 以上的题;跑一次在 10 分钟内、花费可接受
  • judge(如有)用 30+ 条人标数据校准过,一致率 ≥ 85%,模型版本已 pin
  • baseline 已存档进 git,门禁线(baseline - 容忍带)写下来了
  • CI 里改 prompt / 换模型会自动跑 evals,退化时流水线变红
  • 生产模型用的是具体版本号,不是 latest 类浮动别名
  • 回滚开关真实可用——真刀真枪切过一次,不是"我觉得应该能行"
  • 每周 30 分钟人工抽检已排进日程,翻车→考题的链路跑通过一次

第一个月的维护节奏

搭起来只是开始。evals 是活物,第一个月按这个节奏养:

时间动作目标
第 1 周每天看一次跑分报告,熟悉分数的正常抖动范围知道"多少分算正常",别被噪音吓到
第 2 周第一次人工抽检;把 fail 的条目写成考题考题库从 50 条涨到 60+ 条
第 3 周故意做一次"坏改动"(比如删掉一段关键 instructions),看 CI 能不能拦住验证门禁是真的,不是摆设
第 4 周复盘:哪些分类分最低?下个月优先补哪类考题?形成"测 → 补 → 再测"的习惯

第 3 周那个"故意搞破坏"的演习,强烈建议真做一次。没拦住过坏改动的门禁,你不会真的信任它;不信任的门禁,等于没有门禁。演习成本是半小时,买的是未来一年的心安。

结语:evals 不保证你赢,但保证你知道自己在赌什么

回到开头的结论。Evals 不会让你的 AI 功能不出问题——考题库永远有盲区,judge 永远有偏见,模型永远在你看不见的地方变化。它能给你的只有三样东西:

  • 知道自己在赌什么:每次换模型、改 prompt,都有一个数字告诉你"这次变了多少"。
  • 输了第一时间知道:不是等用户投诉,而是在 CI 变红的那一刻。
  • 知道怎么回本:版本 pin 死了,prompt 在 git 里,开关演练过了,回滚是一分钟的事。

一人团队输不起的不是钱,是信任。用户对 AI 功能的信任,丢一次,要拿十次"感觉还行"才换得回来——而且换不回来。半天时间,搭一套最小 evals。从此每次换模型,你手里拿的是牌,不是心。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

GeoSports 体育问答游戏界面示意:玩家在世界地图上落下图钉作答
资讯
12 小时 vibe 出来的游戏,5 个月跑出 1500 万对局、约 4.7 万美元 ARR:GeoSports 的百万玩家故事

Frank Michael Smith 用 Claude Code 花 12 小时做出体育问答游戏 GeoSports:首日 79 个玩家,一个月内百万人游玩,5 个月后 1500 万对局、约 4.7 万美元 ARR。本文拆解它为什么是体育问答这个品类跑出来,给出 5 条独立开发者可复制的判断,以及必须诚实说的三件事。

创业实践独立开发产品动态
指南配图:独立开发者开源变现的四条路径示意
指南
开源也能收钱:独立开发者的开源变现四条路

star 不能当饭吃。这篇指南为独立开发者拆解开源变现的四条验证过的路线——捐赠赞助、开放核心、双授权、付费托管,附决策表、定价漏斗设计,以及"免费与付费分界线要第一天画好"的硬核教训。

开源项目开源项目观察支付与变现
独立开发者查看税务文件、发票与收入报表的插画
指南
收钱之后的必修课:独立开发者的税务与合规实战

税务不是"做大之后"的事,而是"收到第一笔钱"之后的事。写给独立开发者的实战指南:三个翻车故事、美欧中三国合规打法、一人团队最小工具链、每月30分钟对账SOP,附何时必须请专业人士的边界清单。

支付与变现创业实践