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

先说结论
没有 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 种问法)。出题和定标准,必须是人。
四、评分器设计:规则断言 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 换了,整套历史分数直接作废。
压偏见的六招
偏见压不住,只能管住。六招,按重要性排序:
- Rubric 写二值判断,不写 1-10 打分。"通过 / 不通过"比"打 7 分还是 8 分"稳定一个数量级。小数点后的精度全是幻觉。
- 先写"什么算挂",再写"什么算过"。挂的条件越具体,judge 越老实。模糊的 rubric 养出模糊的分数。
- Pin 住 judge 的模型版本。写具体的版本 ID,不写 latest 之类的浮动别名。judge 模型一换,历史分数全部重跑。
- 盲评。别告诉 judge 这是谁的输出。做 A/B 对比时,隐去候选身份,只给"回答 A / 回答 B"。
- 成对比较必须交换顺序跑两遍。AB 顺序和 BA 顺序各跑一次,两次都赢才算赢。只跑一遍的对比结果不值得信任。
- 用人标数据校准,达标才上岗。抽 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 是个常用起点"——但起点不是答案。阈值必须用人标数据调,方法很土,但有效:
- 从考题库里抽 50 对(模型输出,参考改写),人先判"意思一样 / 不一样"。
- 算每对的 embedding 相似度,按分数排序。
- 找那个让"误判最少"的切分点:阈值以上判"一样",以下判"不一样",数一数和人工判断冲突的有多少。
- 把阈值定在冲突最少的点,然后往严格方向再收 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 的使命不是"好看",是"诚实"。具体步骤:
- 冻结当前线上版本(模型版本 + prompt 版本),打 tag。
- 用这一版跑完整套考题,分数原样存进 baseline.json,不许手动"优化"。
- 如果分数低得离谱(比如 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 小时的抽检计划,第三周就没人做了——包括你自己。
飞轮:你交过的学费,要变成收据
完整的飞轮长这样:
- 线上翻车(用户投诉 / 差评 / 你自己发现)。
- 5 分钟复盘:这是 prompt 问题、模型问题,还是输入本身就超纲?只分这三类,别展开。
- 写成考题进库:把翻车的输入脱敏,写好通过条件,commit 进 golden.jsonl。
- 下次同类改动发布前,回归集自动拦住它。
考题库是你交过的所有学费的收据。收据丢了,等于学费白交。给飞轮定两个健康度指标,每月看一眼:
- 每月新增考题数:连续两个月零新增,说明复盘流程死了,或者你的采样根本没在看。
- 考题库覆盖的线上事故占比:目标是 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 了,但业务说"今晚必须上",怎么办?给例外留一条有记录的路,而不是让大家学会绕过门禁:
- 走 feature flag 灰度,先放 5% 流量,盯着采样和用户反馈,24 小时后再决定全量。
- 例外单写清楚三件事:为什么这次不等分数修好(业务理由)、谁拍的板(名字)、回滚条件是什么(分数再掉 X 就切回)。
- 例外单进 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,接上 CI | baseline.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)
相关文章

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

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

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