评测体系:给评测做评测——hollow eval 事故、异步化与「全绿 ≠ 无缺陷」
语言 / Language:中文 | 系列第九章(目录)| 上一章:全链路延迟与稳定性调优
项目:Agentdemo007 —— 电商智能客服 Agent
技术栈:Java 17 / Spring Boot / 自研 eval 包(黄金集 + 强类型断言 + 异步作业)+ Nacos(黄金集托管)/ Vue3 评测页
周期:Phase 15(评测基座)→ 2026-09-17(Nacos 双源)→ 09-18(异步化)→ Phase 20 T92(hollow eval 修复)
验证规模:默认套件 9 个 stage 63 例全量加载断言(GoldenSuiteTest);eval 包 38 例单测
源码:github.com/Gavincui123/Agentdemo007
前言
评测体系最容易死在两个地方:断言是空的,测了但什么都没测;以及评测本身把系统拖垮,同步跑全量真 LLM——这两件我都真实踩过。
前者是 hollow eval 事故:黄金集字段全绿,评审时才发现 JSON 里的扩展字段被宽容反序列化静默丢弃、断言从未执行,我在 Phase 20 用 T92(内部修复工单编号,后文 T76/T88 同类)把期望收口成 13 参的编译期契约。
后者是全量评测拖到前端 axios 30s 超时,后端还在跑且全程无进度,修复靠异步作业秒回加进度轮询,顺带把黄金集从 classpath 搬进 Nacos 双源,改数据不再跟着发布走。
还有两笔账要单独记:熔断这种跨请求累积状态天生进不了"一请求一断言"的黄金集,我选择诚实登记、改由单测覆盖;而 2026-09-19 交付当天 1213 个测试 0 失败,人工走查照样翻出一串缺陷。从那份报告起,"全绿"和"无缺陷"在交付文档里分开陈述——这是本章最想留下的结论。
一、黄金集长什么样
动笔写评测之前,我先回答一个问题:断言应该长什么样。直觉方案是把期望写成Map<String, Object>,反序列化完直接比,省事——我否决了它。Map 里没有任何机制逼你为某个字段写对照逻辑,"JSON 写了这个字段"和"系统真的比了这个字段"是两回事,而第二节的事故会证明,这道裂缝连强类型都未必堵得住。
所以评测的基本单位我收口成「stage → case → expected」:每个 stage 一个 JSON(意图、路由、降级、注入、RAG、HITL、审计……),每例声明输入与一个 13 参的强类型 record——字段名就是契约。
// eval/EvalExpected.java —— 期望断言强类型收口(非 Map)publicrecordEvalExpected(Stringscenario,Stringintent,Stringroute,StringselectedModel,Booleandegraded,Stringoutcome,Booleanblocked,BooleanzeroLlm,BooleanshortCircuit,BooleanescalatedToModel,StringqueryEnrichmentContains,StringragFragmentsContain,BooleanhasFragments){/** 既有 10 参构造(Phase 15 调用方零改动;新字段缺省 null 不参与比较)。 */publicEvalExpected(…){…}}运行时执行器把流水线实际产出收口成ActualOutcome(含派生字段),与期望逐字段对照,产出 mismatches 明细。这套设计有意让期望与实际两侧都是强类型:新增一个断言维度,等于加字段、加对照逻辑、加测试三件事绑在一起,编译器逼着你接完。在这套护栏下,"JSON 里写了个字段但没人读"的幻觉理论上不存在——下一节就是幻觉真实发生的故事。
二、hollow eval:一次「全绿但什么都没测」的事故
事故是 Phase 20 检索增强做完后、评审时暴露的,发现了让整个评测体系蒙羞的一幕:早期的EvalExpected设计里就有standardQueryContains/hasFragments/summaryTriggered这些字段,JSON 里也写了——但 record 上没有这些参数。Jackson 反序列化配了FAIL_ON_UNKNOWN_PROPERTIES=false,当初是为了容忍 JSON 里的 context 前置条件字段,结果这些期望字段被静默丢弃:
发现既有
EvalExpected的standardQueryContains/hasFragments/summaryTriggered字段被FAIL_ON_UNKNOWN_PROPERTIES=false静默丢弃——文档字段从未被对照(hollow eval)。——开发计划 T92 注记
也就是说,黄金集里写着"期望 standardQuery 含’Q3 销售’"、“期望有 RAG 片段”,评测一直全绿——因为断言从未执行。宽容的反序列化配置 + 弱类型 JSON 期望,共同制造了"看起来在测"的空转。
修复分三步,全部围绕同一个原则:让文档字段变成编译期契约。
EvalExpected从 10 参扩到 13 参,新增queryEnrichmentContains/ragFragmentsContain/hasFragments;10 参的次级构造原样保留,Phase 15 以来的既有调用方零改动。ActualOutcome增 3 个派生字段,全部从PipelineContext的实际产出派生。compare增两个对照算子,再配 4 例单测把对照逻辑本身钉死:
// eval/EvalExecutor.java —— Phase 20(T92):新断言维度的对照算子// Phase 20(T92):约束改写补全槽 + RAG 片段(含时效标注)checkContains(mismatches,"queryEnrichmentContains",expected.queryEnrichmentContains(),actual.queryEnrichmentKeywords());checkAnyContains(mismatches,"ragFragmentsContain",expected.ragFragmentsContain(),actual.ragFragments());checkBoolean(mismatches,"hasFragments",expected.hasFragments(),actual.hasFragments());/** 期望某精确词在补全槽关键词列表中(T88 约束改写补全)。 */privatevoidcheckContains(List<String>mismatches,Stringfield,Stringexpected,List<String>actual){if(expected!=null&&!actual.contains(expected)){mismatches.add(field+": expected~contains="+expected+" actual="+actual);}}/** 期望某文本子串在任一 ragFragments 元素中(T89 Hybrid 命中 / T90 时效标注)。 */privatevoidcheckAnyContains(List<String>mismatches,Stringfield,Stringexpected,List<String>actual){if(expected!=null&&actual.stream().noneMatch(f->f!=null&&f.contains(expected))){mismatches.add(field+": expected~anyContains="+expected+" actual="+actual);}}有一个维度我没救回来,只能诚实登记:escalatedToModel(是否上交小模型仲裁)不可从实际产出派生,跳过比较——它被记为已知限制,而不是假装能测。hollow eval 的教训浓缩成一条:评测断言的"存在"必须在类型系统里可验证,JSON 字段 + 宽容反序列化 = 自欺欺人的温床。
三、同步评测拖爆 HTTP:异步化
第二件事不用等评审,用起来当天就会炸。评测要跑真实流水线,每例都是真实 LLM 调用;最初实现图省事,把全量评测塞进 HTTP 请求里同步跑。结果前端 axios 30s 超时报"网络连接失败",后端还在跑,而且全程无进度——用户不知道系统是死是活。排查结论不复杂:跑几十秒量级的批处理被当成了请求处理,评测结果的生命周期绑死在 HTTP 连接上,两头受气是必然。
修复是异步化三件套,时序如下:
// eval/EvalJobManager.java —— 评测异步作业管理器/** 启动评测作业;已有作业在跑 → false(调用方转 CONFLICT 话术)。 */publicbooleanstart(List<String>resources){if(!running.compareAndSet(false,true)){returnfalse;}… runner.execute(()->runAll(resources,runId,startedAtMs));returntrue;}// runAll 内(每步降级):}catch(Exceptione){// ②每步降级:单 stage 数据缺失/解析失败 → 跳过,其余照常log.warn("评测 stage 加载/执行失败,跳过:resource={} reason={}",resource,e.getMessage());skipped.add(stageName);}三个工程细节值得单独说。进度快照不可变:单写线程整体重建快照,HTTP 读线程随意读,靠不可变性换来无锁的发布安全性。重复启动收 409 CONFLICT:用 CAS 防双跑,而不是靠前端禁用按钮。单 stage 失败跳过不杀整体:评测是批处理,一个 stage 的数据问题不该清空其他 stage 的结果。
配套的安全故事也要交代:/eval/**在发布态仅限本机 loopback,且检查先于 token 鉴权——评测跑真 LLM 全量黄金集,比聊天更烧钱,必须比聊天更严(第六章闸口章的同款裁决)。
四、黄金集双源:改测评数据不重启
黄金集最初编译进 classpath,代价是每改一个期望值都要重新打包部署——评测数据的迭代节奏被发布节奏绑架。可评测数据是数据,不是代码,迭代不该跟着发布走,所以我给它做了双源:Nacos 优先、本地兜底,永不抛。
// eval/EvalContentResolver.java —— Nacos 优先、本地兜底,永不抛publicResolvedresolve(StringclasspathResource){Stringstage=stageOf(classpathResource);if(nacosEnabled){StringdataId=dataIdPrefix+"-"+stage+".json";try{Stringcontent=configService.getConfig(dataId,group,timeoutMs);if(content!=null&&!content.isBlank()){returnnewResolved(content,SOURCE_NACOS);}log.info("Nacos 无评测数据(dataId 不存在或为空),回退本地 classpath:dataId={}",dataId);}catch(Exceptione){log.warn("Nacos 评测数据读取失败,回退本地 classpath:dataId={} reason={}",dataId,e.getMessage());}}returnnewResolved(readClasspath(classpathResource),SOURCE_LOCAL);}每次POST /eval/run实时拉取,dataId 形如agentdemo-eval-<stage>.json,改完 Nacos 下一次 run 即生效;读取失败、未配置或为空,就逐 stage 回退本地 classpath。来源标签(NACOS/LOCAL)随报告透传到前端徽标,评测结果的出处可见,排查"为什么期望变了"时不用猜。两个防御性参数也交代一下:拉取超时 3s;ConfigService构造失败直接降级 local-only,不阻塞启动——评测数据源坏了,评测照常能跑。
五、评测的评测:哪些东西测不了要明说
一套评测体系可信与否,取决于它对自身边界的诚实程度。这一节记三件我明确"不测"的事,每件都有写下来的理由。
熔断语义进不了黄金集。熔断 OPEN 是跨请求累积状态,而 eval 一请求一断言,单用例内无法复现"连挂 N 次后快速失败"——这是 T76 登记的关键限制。
结论是不硬塞:不扩
eval/tool.json,改由单测覆盖(含用 AtomicLong 控时验证三态流转),并把这条限制写进开发计划。GoldenSuiteTest只验加载与跑通,不验通过率。黄金套件在测试环境用 trivial 执行器(恒返回 ok)冒烟,验证的是"63 例全部能加载、能执行、能收口";通过率依赖真实流水线,属部署门禁。这样既避免单测环境烧真 LLM,也避免"单测绿 = 业务达标"的误读。stage 失败是
skipped不是passed。异步评测里数据缺失的 stage 单独记账,进度条上它不亮绿灯——把"没测"和"测过"在 UI 层就分开。
六、全绿 ≠ 无缺陷
2026-09-19 拒答 + 知识库录入交付,走查报告专门立了一节「全绿≠无缺陷」缺陷清单。那天 1213 个测试 0 失败,人工走查照样翻出一串测试覆盖不到的问题。最要紧的四条:
- 录入主链无事务 + 换版先于索引:嵌入失败窗口内旧向量已删、新版未落库,该文档在检索侧消失;
- 版本号读-改-写无唯一约束:并发重灌可产生重复同版本 ACTIVE 行;
- 目录快照多副本盲区:多实例下新录文档在其他实例不可见(fail-closed)、PRIVATE 收紧不扩散;
- 链路异常被当作"知识未命中":RagStep 的 catch(Exception) 也置
groundingMiss,strict 模式下存储宕机会把系统故障包装成"知识库暂无资料"。
第 4 条在第五章展开过。把它们放回评测章,是因为这四条撑起了本章的核心论点:测试全绿证明"测过的行为对",走查才证明"没测的行为不存在"。两者是互补的证据,不是替代。
七、经验小结
这一章的经验都是从上面这些坑里长出来的:
- 断言必须活在类型系统里。JSON 期望 + 宽容反序列化 = hollow eval;新增断言维度要让编译器强迫你接完对照逻辑。
- 批处理别绑在请求生命周期上。作业 CAS 单实例 + 秒回 + 轮询快照,是我后来所有"跑了很久的按钮"的标准答案。
- 评测数据是数据,不是代码。放 Nacos 双源 + 来源徽标,迭代节奏与发布节奏解耦。
- 测不了的要写下来,不是假装测了。
escalatedToModel跳过比较、熔断语义归单测、trivial 套件只验加载——边界写得越清楚,评测越可信。 - 全绿与无缺陷分开陈述。评测覆盖行为回归,人工走查覆盖"没人想到要测"的缝;交付报告把两者并列,是诚实的最低要求。
八、已知边界(诚实清单)
- 通过率是部署门禁:单测环境不跑真实流水线,评测通过率依赖部署后手动/门禁触发。
- 评测进度无持久化:
EvalJobManager快照在内存,进程重启丢当前作业(评测是低频运营动作,可接受)。 - 对照仍非全量:
escalatedToModel不可派生跳过;ActualOutcome覆盖不了的输出质量问题(话术好不好听)本就不该由黄金集管——那是人工评测与用户反馈的地盘。
本文机制出处:eval/(EvalExpected / EvalExecutor / EvalJobManager / EvalContentResolver / EvalController);黄金集资源src/main/resources/eval/*.json(15 个 stage 文件);缺陷清单全文见 2026-09-19 拒答+录入报告 §7。
相关阅读:系列目录 · 第六章·eval 封锁与闸口 · 第五章·strict 模式故障误报缺陷 · 下一章:前端与流式交互