8. 评测飞轮:Rubric 与 bad case 自修#
一句话结论:零 GPU 的循环是「跑固定种子集 → 按动态 rubric 打分 → 捞 bad case → 改 prompt/工具/few-shot → 重跑同一套尺子」,难点全在先把尺子校准。
速背卡#
| # | hint | 展开一句 |
|---|---|---|
| 1 | 三档:P0 否决 / P1 扣 10 / P2 打 1-5 | P0 一条 fail 总分直接 0;P1 每条 −10;P2 均分 ÷5×100 |
| 2 | 尺子动态生成,但按 hash 缓存 | 每条 query 一份专属细则,缓存键带生成 prompt 全文 |
| 3 | 假阳性三类:轨迹 / 软偏好 / 空表 | 轨迹当泄露、软偏好升 P0、judge 回空表算全过 |
| 4 | 先校尺子再改 Agent | q28 同一份输出三次采样 0 / 0 / 90 |
| 5 | 门禁三种:Rubric 15 / 缓存中位 / skill 13-13 | 质量、账单、编排各守一条 |
| 6 | 自动修只修「确定性可修」 | P0 里只有泄露类抽内网地址成规则,其余只上报 |
| 7 | 飞轮最近一转是 09-21 | 之后的减法 A/C/D 与 Skill 页没跑回归 |
30 秒版#
Agent 的好坏没有单一指标:「给女朋友买礼物」和「批量采购螺丝」的红线完全不同。所以每条 query 先让 judge 生成专属细则,分 P0 红线 / P1 规范 / P2 质量三档再打分。这把尺子自己会抖,我花的力气主要在校尺子:细则缓存固定刻度、轨迹脱 id 防假阳性、空表闸防假绿、P0 复判防单样本。改进侧只自动化确定性可修的那一小块,其余进人工桶。
3 分钟版#
- 种子集:95 条手写 query(
data/eval/queries.jsonl,30 个桶,14 条多轮、5 条带前情),固定不改,做稳定回归基线。 - 跑一条:清 DB 历史与会话目录 →
run_agent→ 渲染轨迹与最终回复 → judge 生成细则 → judge 逐条打分 → 聚合。 - 三档计分:P0 任一 fail → 总分 0;否则
P2均分/5×100 − 10×P1违规数,下限 0(app/eval/rubric.py:398 aggregate)。 - 校尺子:细则按 hash 缓存、轨迹丢掉带 id 的入参、结构化输出加空表闸、首判 P0 fail 独立复判一次。
- 捞 bad case:报告里 P0 失败的 case 进
app/evolution/,按判词分 LEAK / BANNED / JUDGMENT 三类。 - 改进三条腿:泄露类出候选脱敏规则(人工确认)、高分轨迹蒸馏 few-shot 与策略、提示词 A/B 只出建议不自动放量。
- 回归门禁:改完跑 Rubric 15 条子集 + 缓存命中率 + skill 触发率三条门(见下表)。
门禁现在有几种#
| 门禁 | 跑什么 | 判据 | 成本 | 最近一次 |
|---|---|---|---|---|
| Rubric 子集 | scripts/eval/run_rubric.py --gate,固定 15 条 | 有 P0 红线失败则退出码 1 | 真 LLM,15 条 | 2026-09-21 02:42,rubric_subset15_2026-09-21.json |
| 前缀缓存命中率 | scripts/eval/snapshot/test_cache_hit_gate.py | 三遍取中位 ≥ 0.674 − 0.12;三遍全 0 也算失败 | 约 3×25 s / $0.006 | 提交时未跑(484ad68 正文自述) |
| SKILL 触发率 | scripts/eval/run_skill_trigger.py,18 条标注集 | 正例第一批工具调用里有那条 Skill;负例不许误触发 | 每条 1 次 planner + 1 次主模型,全集约 $0.007 | 2026-09-19 20:04,正例 13/13、负例 0/5 |
召回侧还有一条确定性门禁 scripts/eval/run_product_recall.py(零 LLM),归第 4 章。
它解决什么问题#
-
通用评分模板对不同 query 失真
- 坏法:一套固定细则打全部 query,要么漏判(「这款耳机值不值」被设价格红线)要么误伤;分低了也说不清哪条细则不该适用。
- 修法:judge 按 query + 约束 + 意图 + 前情动态生成 6~10 条细则,分三档(
_GEN_PROMPT)。 - 代价:尺子每条 query 不同,跨 query 的分数不能直接比,只能比同一条的前后。
-
动态尺子自己会抖,分差归不到 Agent 身上
- 坏法:每次重新生成细则,Agent 改好了分反而掉,看不出是谁变了。
- 修法:细则生成一次按 hash 缓存,键里带生成 prompt 全文(
_rubric_cache_key);prompt 改一个字,尺子全部重建。 - 代价:
data/eval/rubric_cache/122 份缓存与那份 prompt 绑死,改 prompt 等于丢掉跨批可比性。所以前情只做前置拼接,不改那份常量。
-
judge 把评测材料当成 Agent 的回答
- 坏法:轨迹里有
item_id,judge 判「向用户泄露内部 id」,P0 直接 0 分,而且是系统性的——越是跑得深的 case 越容易被判死。 - 修法:
render_trajectory丢掉键名含 id 的入参、单个入参截 120 字符;render_agent_output把轨迹段标成「非用户可见,禁止据此判泄露」;打分 prompt 再说一遍。 - 代价:judge 看不到完整入参,真要判「工具用错参数」就判不了。
- 坏法:轨迹里有
-
judge 回空表时,评测越坏分越绿
- 坏法:
scores=[]→ 0 条 P0 fail →overall_pass=True,全程零报错,看不见。 - 修法:
score_against_rubric的required_any=("scores",)空表闸,原始 dict 里判据字段一个都没出现就重采样一次,仍空抛异常(517a686)。 - 代价:judge 偶发存根会让这条 case 记为失败而不是 0 分,跑批要容忍 error 行。
- 坏法:
-
一票否决由一次采样决定
- 坏法:同一份输出三次采样出过 0 / 0 / 90(q28,246fe08 正文),基线不可复现。
- 修法:首判有 P0 fail 的 case 独立再判一次,复判 pass 就翻回(
_recheck_p0,RUBRIC_P0_RECHECK默认开)。 - 代价:只修假阳性一侧;judge 漏判真红线复核发现不了。对比历史基线时两边都要关掉。
-
自动修 bad case 容易修出更大的坏
- 坏法:让 LLM 给 P0 判词生成正则,贪婪正则把整段回复脱成
[已脱敏],用户侧直接坏掉。 - 修法:只对 LEAK 类在用户真看到的
summary.md上抽内网地址字面串;公网 TLD 不脱,curated 已覆盖的不重复提议;默认 dry-run,--write才落文件,--verify拿新规则重扫已有产物看有没有误脱(零 LLM)。 - 代价:能自动闭合的面很窄——判断类红线正则堵不住,违禁类没有规则落点,两类都只上报。
- 坏法:让 LLM 给 P0 判词生成正则,贪婪正则把整段回复脱成
机制怎么跑#
一条 query 的评测(scripts/eval/run_rubric.py → app/eval/rubric.py):
_preflight:LLM_REQUEST_TIMEOUT < 300 s直接拒跑——60 s 会把慢 case 判死、分数虚低(4be6f39)。_reset_thread:清这条 case 的 DB 对话历史与session_dir,否则上次的session.json被当上文读回。run_agent:多轮 case 按turns逐轮跑;单条墙钟上限 900 s,超时只赔这一条。render_trajectory/render_agent_output(rubric.py:132/:152):轨迹脱 id,输出分「最终回复 / 商品卡 / 内部执行日志·非用户可见」三段。generate_rubric(:316):缓存命中直接读;否则 judge 生成,结构不合格重试 1 次,缺字段的残缺细则丢掉(_drop_incomplete,dict 与对象两种形态都认)。score_against_rubric(:369):judge 逐条打分,required_any=("scores",)空表闸。_recheck_p0(:434):首判 P0 fail 的独立再判一次。aggregate(:398):纯函数,P1_PENALTY=10、HIGH_SCORE_THRESHOLD=70;没有 P2 细则时 P2 按 100 算,只扣 P1。record_rubric_scores:rubric_total/rubric_pass/rubric_p2_avg三个 score 挂回那条 Langfuse trace,trace_id显式传参不读 ContextVar。- bad case 侧:
collector.load_bad_cases只取ok=True且overall_pass=False的 →router.classify分 LEAK / BANNED / JUDGMENT(BANNED 优先于 LEAK)→ 只有 LEAK 进p0_fixer.propose_rules。
SKILL 触发率(scripts/eval/run_skill_trigger.py)只跑到第一次模型调用就掐断:截停点挂在 post_reflect(on_reasoning 之后、工具执行之前),用 BaseException 抛出——HarnessMiddleware.run 的最后一层是 except Exception,普通异常会被吞掉只留一行日志。探针不用模块级装饰器注册,免得「import 一下」就往生产 pipeline 里插一个会掐断别人用例的 hook。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 2026-09 前 | 517a686 | 结构化输出加空表闸 | judge 回空表时评测越坏分越绿,零报错 |
| 2026-09 前 | 246fe08 | P0 复判 | q28 同一份输出三次采样 0 / 0 / 90 |
| 2026-09 前 | 1133b5c | 单条 900 s 超时 | 15 条里 1 条挂 29 分钟,报告等 gather 全返回才写,其余 14 条全赔 |
| 2026-09 前 | 4be6f39 | 跑前自检超时 | 每个 wrapper 各 export 一遍是 O(调用点) 的修法,直接入口反而漏了 |
| 2026-09 前 | 70a8e7d | 提示词版本只存覆盖项 + sha256 稳定分桶 | 多份完整副本死在手工同步;内置 hash() 每进程换种子 |
| 09-19 02:02 | 484ad68 | 缓存命中率门禁(三遍中位 + 容差 0.12) | 断「cached tokens > 0」永远绿;2026-07 注入块塌方 67.5%→31.6% 没有一个测试变红 |
| 09-19 19:51 | 451b6d5 | SKILL 触发验收集 + 真跑模型的触发率脚本 | S2 改成「模型自觉调 Skill」后,注入判定没了,只能量模型发出的第一批 tool_call |
| 09-19 20:05 | c922059 | 收紧分流表「单品直搜」例外 | 首轮两条 MISS 都是「我已经知道该搜什么」当成不读 skill 的理由;正例 11/13 → 13/13 |
| 09-21 02:42 | —(产物) | 15 条子集重跑 | 阶段 2/3/4 + SLO 之后的一次质量对照 |
以上 2026-09-16 之前的条目沿用旧版手册,已按当前代码核对过常量与函数名。
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 95 条 / 30 桶 / 14 多轮 / 5 带前情 | 种子集规模 | data/eval/queries.jsonl(95 行) | ✓ |
| 10 / 70 / 900 s / 300 s | P1 每条扣分、高分轨迹门槛、单条超时、跑前最小请求超时 | rubric.py:44 :45、run_rubric.py | ✓ |
| 122 | 已缓存的细则文件数 | data/eval/rubric_cache/ | ✓ |
| 12/15、均分 53.77、P2 均分 1.33~5.0 | 2026-09-21 15 条子集:P0 通过数与总分均值 | rubric_subset15_2026-09-21.json(自算) | ✓ |
| q03 / q05 / q12 | 那次三条 P0 失败:人群与类目红线、品牌品类合规、材质黑名单违反 | 同上 | ✓ |
| q03 0 → 63.3 / 76.7;q12 0 → 60.0 | 两条失败 case 各重跑两遍全部翻成 pass | rerun_q03q12_a/b.json | ✓ |
| 0.674 / 0.12 / 3 遍 | 缓存门禁基线中位、容差、遍数 | test_cache_hit_gate.py | ✓ |
| 0.600 vs 0.852 | 同 query 同序列两遍命中率,网关噪声 ±0.13 | 484ad68 正文(A0-3 实测) | ✓ |
| 13/13 正例、0/5 误触发;11/13 只发 Skill 不带业务工具 | SKILL 触发率 r2 | skill_trigger_report_r2.json | ✓ |
| 11/13 → 13/13,实付约 $0.007 | 改分流表前后对照 | 451b6d5 / c922059 正文 | ✓ |
| 3 / 3 / 3 / 12 | 策略血量上限、连续失败退休次数、每轮最多注入条数、门禁允许跌分 | strategies.py:57、distill_strategies.py:60 | ✓ |
| 15 条均分 57.11、P0 12/15 | AgentScope 迁移前基线(judge qwen3.5-flash) | baselines/PRE_AGENTSCOPE.json(09-05) | 旧版核对过 |
| 26.7% vs 20.0%、3.31 vs 3.51 | 提示词 1.1.0 对 1.0.0 的 P0 失败率与 P2 均分,判为不达标 | ab_report_1.1.0.json(09-08) | 旧版核对过 |
| 0 / 0 / 90 | q28 同一份输出三次采样 | 246fe08 正文 | 旧版核对过 |
| ±13 | judge 单样本抖动 | distill_strategies.py docstring | 仅口径 |
| 8/90(8.9%) | judge 漏吐字段导致整条报废的比例 | rubric.py 注释 | 仅口径 |
| 25 / 21 / 19 / 18 / 6 | test_rubric / test_evolution / test_strategies / test_prompt_ab / test_eval_skill_trigger 的测试函数数 | grep -c "def test_" | ✓ |
没跑回归的部分(未验证):09-21 02:42 那次 15 条子集之后,主线又进了删四阶段状态机(10526d1,09-21 15:26)、减法 A / C1 / C4 / D1D5(09-21 22:4609-22 00:30)、Skill 页(0d50ff4)。这些改动没有重跑 Rubric,质量有没有退化未验证。缓存命中率门禁自提交起也没跑过(484ad68 正文自述)。
追问 10 题#
Q1. 为什么用动态 rubric,不用固定模板?
不同 query 的红线不同,模板要么漏判要么误伤。每条先生成 6~10 条专属细则再打分(rubric.py:_GEN_PROMPT、generate_rubric)。生成 prompt 里写了纪律:有明确预算才设预算红线、软偏好不许升 P0、泄露只看最终回复正文。
Q2. 总分怎么算,P0 为什么不是扣分?
P0 任一 fail → 总分 0、overall_pass=False;否则 P2均分/5×100 − 10×P1违规数,下限 0(aggregate)。超预算、推错人群这类伤害不能被别的维度高分抵掉。没有 P2 细则时 P2 按 100 算——闲聊 case 常常没有质量维度。
Q3. ⚠ judge 的假阳性有哪三类? ① 把工具轨迹当成给用户的回答,判「泄露 item_id」;② 把软偏好升级成一票否决的红线;③ 回空打分表,聚合算出「一条红线都没破」,是假绿不是假阳。前两类靠渲染与 prompt 纪律治,第三类靠空表闸。
Q4. ⚠ 怎么证明抖的是 judge 不是 Agent?
两处证据:246fe08 记的 q28 同一份输出三次采样 0 / 0 / 90;以及 09-21 那次的 q03、q12 判 P0 fail 后各重跑两遍全部 pass(rerun_q03q12_a/b.json)。所以「先校尺子再改 Agent」不是口号,是把改进顺序倒过来会白改。
Q5. 空表闸为什么判「键出现过」而不是「值不等于默认值」?
为了不误伤合法空结果——用户真没给预算时 budget_amount=None 是对的。判据必须用剥壳后的原始 dict,不能用 model_fields_set:后者把校验器自己赋的值算进去,第一版因此闸门永不触发(517a686)。
Q6. bad case 怎么变成修复规则,为什么只修泄露类?
collector 捞 P0 失败 case 并配上用户真看到的 summary.md → router.classify 分三类 → 只有 LEAK 进 p0_fixer 抽内网地址,人工 --review 确认。判断类要推理,正则堵不住;违禁类塞进管注入的 content_filter 是类别错误(router.py docstring)。
Q7. 分类为什么 BANNED 优先于 LEAK? 同一条判词可能既说「推荐仿品」又说「轨迹暴露 item_id」,前者是真问题,后者多半是假阳性。分错的代价不对称:LEAK 被分进人工桶只是漏修,不是误修。
Q8. 缓存命中率门禁为什么不是断「cached tokens > 0」? 那条永远绿。真正会出事的是前缀被打断:2026-07 的注入块塌方命中率 67.5%→31.6%,功能全正常、没有测试变红,只有账单变贵。所以断的是命中率,三遍取中位、留 0.12 噪声余量,另加「三遍全 0 也算失败」——那通常是供应商换了 usage 字段名,此刻门禁什么都没在守。
Q9. SKILL 触发率为什么必须真跑模型,还只跑第一次调用?
S2 之后 skill 正文改成模型自觉调 Skill(skill=…),注入表没了、无从断言,只能量模型发出的第一批 tool_call。分流表要求 skill 与本轮第一个检索工具同轮发出,答案在第一批里就定了,再往下跑是白烧钱。13/13 达标的同时,11/13 只发了 Skill 不带业务工具——白等一次往返,是下一个靶子(skill_trigger_report_r2.json 的 lone_skill_round)。
Q10. ⚠ 压力题:你说有飞轮,它真的在转吗?
机制齐但转得不勤,如实讲:最近一转是 09-21 02:42 的 15 条子集(P0 12/15、均分 53.77);之后的减法 A/C/D、删四阶段状态机、Skill 页都没重跑,质量未验证。few-shot 没有注入(2k 版 prompt 删了 <examples> 段,prompt/few_shot_distilled.yml 不存在);data/security/learned_rules.json 不存在,P0 腿一条规则都没写入过;唯一一份 A/B 报告的结论是不达标。能讲扎实的是尺子怎么校,以及「只自动化确定性可修的那一小块」这个取舍。
坑与易混点#
app/agent/fewshot.py的模块 docstring 仍写「由get_system_prompt内部默认加载注入」,而app/agent/prompts.py:145明说 2k 版删了<examples>段、不注入——文档与实现不一致,没改代码。- 细则缓存键带生成 prompt 全文:改一个字,122 份缓存全部作废,跨批分数不可比。
- 对比历史基线要两边都关
RUBRIC_P0_RECHECK,否则一边翻假阳性一边不翻。 data/eval里 06-28~07-16 的十几份报告题数、judge prompt、复核开关都不同,不能连成一条趋势线。- 策略结账判据只是「调过终结工具且回复非空」(
context_shaping.py:221 settle_strategies),看不到质量;一条把回答带偏但正常收尾的策略不会掉血。
本章和别章的接口#
- 召回侧的确定性门禁(
run_product_recall.py)在第 4 章;策略库与长期记忆分表的理由在第 5 章。 - 前缀缓存本身怎么打
cache_control、命中率门禁守的是什么在第 7 章。 - SKILL 分流表与六份 skill 的内容在第 14 章;本章只讲怎么验收触发。