做 Agent 应用最怕的不是「写不出第一版」而是「改了第三版之后发现第一版明明答对的题现在答错了」。大模型有随机性、提示词有耦合、工具调用有边界靠人肉聊天抽查既覆盖不全又无法回放。这一篇我们给 Agent 配上一套离线评测集用一份写死的黄金用例把「模型这次答得对不对」变成可以重复跑、可以量化、可以纳入改版前验收的客观指标。它完全不依赖真机联网打开 DevEco 就能跑改完代码按一下就知道质量涨了还是跌了。很多团队把 Agent 当成「调通一次就完事」的脚本结果每次模型升级、提示词微调都提心吊胆因为没有人能说清「上一版到底能答对哪些题」。评测集就是把这份模糊的直觉变成一份可版本化、可 diff、可回归的清单。一、先划清边界评测集 ≠ 运行时观测系列里有一篇第 32 篇讲运行时可观测DFX 追踪那是把 Agent 跑在真机上之后去追踪每一次链路调用的耗时、入参、出参、异常。它回答的是「线上这次为什么这样」。而本篇讲的是离线评测你在改代码之前用一组固定题目去考 Agent看它答得对不对回答的是「这一版比上一版好还是差」。两者互补但关注点不同。观测看「过程」评测看「结果」观测面向线上运维评测面向开发期回归。别把二者混为一谈——你不能靠线上追踪来发现「模型把答案改错了」因为线上流量未必覆盖那道错题你也不能靠评测集来替你发现「某次调用慢了 500 毫秒」因为评测集离线是同步、确定、无网络的。一句话评测集管「答案对不对」观测管「线上为什么这样」。二、评测集的三件套我们把一套最小可用的评测拆成三块彼此解耦谁都能单独替换用例EvalCase一道题 一个输入 一份期望。期望支持三种判定方式后面展开。评分器Scorers纯函数拿到「实际输出」和「期望」返回布尔值互不影响。评测引擎EvalHarness把用例挨个喂给被测对象调用评分器汇总成一份报告。被考的对象是一个函数agentFn(input: string): string它只承诺「吃一个字符串、吐一个字符串」。至于它内部是走 Mock、走本地规则、还是真去调大模型引擎一概不管。这个解耦意味着同一套引擎开发期用 Mock联调期换真模型回归期换线上快照代码零改动。三、数据模型EvalCase用例是评测的最小单元结构越简单越好方便你把它存成 JSON 或写进表格里批量维护exporttypeExpectationTypecontains|equals|regex;exportinterfaceExpectation{type:ExpectationType;// 判定方式value:string;// 期望内容或正则串}exportinterfaceEvalCase{id:string;// 用例编号报告里作为唯一键input:string;// 喂给被测 agent 的输入expect:Expectation;// 期望判定}type决定怎么比contains看实际输出里有没有出现期望串适合「答案里必须提到某关键词」equals要求整段完全相等适合「结构化、可枚举的标准答案」regex用正则命中适合「日期、编号、邮箱这类有格式但内容不固定的字段」。选哪种type是有讲究的。能容忍表达变化的答案「翻译」「解释」类用contains只要关键词在就放过避免同一意思换来换去天天误报答案本身是结构化、可枚举的状态码、固定话术用equals卡得严反而能抓出格式漂移字段带格式但内容浮动的日期\d{4}-\d{2}-\d{2}、订单号ORD\d用regex。一个经验法则先用最松的contains兜底防回归再对少数关键路径收紧到equals既稳又不至于噪声爆炸。四、评分器三个纯函数评分器是整套评测里最容易单测、也最该单测的部分。它们不碰网络、不碰 UI输入输出确定exportfunctionscoreContains(actual:string,value:string):boolean{returnactual.includes(value);}exportfunctionscoreEquals(actual:string,value:string):boolean{returnactualvalue;}exportfunctionscoreRegex(actual:string,pattern:string):boolean{try{constre:RegExpnewRegExp(pattern);returnre.test(actual);}catch(e){returnfalse;// 非法正则不抛错避免一套评测被一个坏用例拖垮}}这里有个工程细节值得强调scoreRegex必须把new RegExp包在try/catch里。正则表达式写错比如少写半个括号时ArkTS 运行时会抛SyntaxError。如果评分器直接崩整批评测都会中断。我们宁可让这条「判定本身非法」的用例判为不通过也要保证其余用例照常跑完——评测引擎的稳定性优先级高于单条用例的判定。评分器之间也建议保持「正交」不要在一个用例里既要求contains又要求regex那是把两个判定揉进一条期望排错时会分不清到底哪条没满足。真要组合就在评测集里放两条用例分别卡不同维度——评测报告里的「失败原因」才会指向明确。五、评测引擎run 出一份报告引擎负责把用例逐条执行、计分、汇总。agentFn抛错时该条记作空输出自然判失败绝不让一条坏用例中断整场考试exportinterfaceEvalItem{id:string;pass:boolean;score:number;detail:string;}exportinterfaceEvalReport{total:number;passed:number;failed:number;passRate:number;items:EvalItem[];}exportfunctionrun(cases:EvalCase[],agentFn:(input:string)string):EvalReport{constitems:EvalItem[][];letpassed:number0;for(constcofcases){letactual:string;try{actualagentFn(c.input);}catch(e){actual;}constok:booleanscore(c.expect,actual);if(ok){passed;}items.push({id:c.id,pass:ok,score:ok?1:0,detail:实际输出: actual});}consttotal:numbercases.length;constfailed:numbertotal-passed;constpassRate:numbertotal0?0:passed/total;return{total,passed,failed,passRate,items};}注意passRate的计算total为 0 时直接返回 0而不是做0 / 0那会得到NaN污染你的报告与阈值判断。这条分支单测里一定要覆盖——空用例列表是评测接入初期非常常见的状态你还没来得及写题。score这个分发函数对未知type统一返回false而不是抛错或默认放行。默认放行会让写错类型的用例「假绿」比默认判失败更危险——假绿会悄悄骗过你的阈值护栏。所以未知类型宁可判失败逼你在用例里把type写对。六、离线 MockAgent让 Demo 不联网也能跑演示阶段我们不想真去调模型于是造一个按关键词返回固定文案的MockAgent。它证明的是「引擎与评分器能正确工作」真实接入时整体替换即可exportfunctionmockAgentFn(input:string):string{consttable:Array[string,string][[天气,今天晴气温 22 到 28 度适合外出。],[翻译,Hello World 的中文翻译是你好世界。],[计算,2 加 3 等于 5。],[定义,Agent 是能感知环境并自主决策执行任务的智能体。]];for(constpairoftable){if(input.includes(pair[0])){returnpair[1];}}return抱歉我暂时无法处理该问题。;}七、演示页通过率卡片 逐条明细Index.ets在aboutToAppear里直接跑评测把报告渲染成一张汇总卡片加一个明细列表。这里我刻意保留了一条失败用例E05 期望「下雨」但 Mock 说「晴」就是为了让你在界面上亲眼看到「失败数」是被正确计数的而不是只显示一片绿aboutToAppear():void{constcases:EvalCase[][{id:E01,input:今天天气怎么样,expect:{type:contains,value:晴}},{id:E02,input:把 Hello 翻译成中文,expect:{type:contains,value:你好}},{id:E03,input:算一下 2 加 3,expect:{type:equals,value:2 加 3 等于 5。}},{id:E04,input:什么是 Agent,expect:{type:regex,value:智能体}},{id:E05,input:今天天气怎么样,expect:{type:contains,value:下雨}}];this.reportrun(cases,mockAgentFn);}界面上passRate用(this.report.passRate * 100).toFixed(1) %显示通过率低于 80% 时卡片数字变橙、失败数变红一眼能看出这一版「挂了哪几题」。八、接进真项目只换 agentFn当你的 Agent 真正接好大模型后把演示里的mockAgentFn换成你的被测对象即可引擎与评分器一字不改import{Agent}from../feature/agent/Agent;constagentnewAgent(client,你的系统提示,你的模型);this.reportrun(cases,(input:string):string{returnagent.send(input);// 只要返回字符串评测就能跑});如果agent.send是异步的把agentFn改成async并在run里await即可——这里为了离线演示写成同步。接入时还有一处容易忽略被测对象可能部分成功、部分失败。比如某个工具调用超时agent 兜底返回了「稍后再试」。这种输出在评测里应当如何判定要在写用例时就约定清楚——要么用contains卡「稍后再试」作为该场景的预期答案要么把它归为失败并单独统计。约定模糊报告就会自相矛盾。九、工程化把评测做成回归护栏评测集真正的价值不在「跑一次看个乐」而在「每次改代码都跑、都存档、都比对」。几条落地建议黄金用例沉淀自真实对话。用例不是凭空拍脑袋写的而是从用户真实提问与期望答案里抠出来的。覆盖高频意图、边界意图、易错意图三类。设阈值不盯绝对值。别只说「通过率 83%」而要定「低于 90% 不许合并」。阈值随用例库成熟逐步上调。纳入改版前验收。每次动提示词、动工具、动模型先跑评测集再提测把「悄悄改坏老功能」的概率压到最低。用例也要版本管理。新增能力加新用例删除能力删用例用例库和代码一起进评审。这套思路与 Hypium 单测天然契合底座层重试、解析、记忆走ohos/hypium单元测试评测层评分、聚合、阈值同样可以写进ohosTest在 CI 里一并执行。用例库本身也建议分层最上面一层是「冒烟用例」十几条覆盖核心意图每次提交必跑秒级出结果中间一层是「回归用例」把历史上踩过的坑逐条固化防止老问题复活最下面一层是「边界用例」喂极端输入超长、空串、特殊符号验证 agent 不崩。三层各管各的节奏既不拖慢日常提交又能兜住深层风险。十、避坑清单实际搭评测集时有几个坑踩过一次就够用例太少等于没测。三五行用例跑出 100% 通过率毫无意义要覆盖「易错点」才有区分度。期望写太死。用equals去卡一个会变的时间戳或随机编号等于天天误报。能容忍变化的字段用regex或contains。把评测当观测用。想看线上慢查询请去第 32 篇的 DFX 链路追踪评测集离线、同步、看不见线上真实流量。正则不兜底。忘了try/catch的正则评分器会在你最不想崩的时候崩掉整场回归。只看总分不看明细。通过率 90% 可能意味着「核心 10 题挂了 1 题」而那 1 题恰恰是最不能错的。报告要逐条打开看尤其关注failed列表里的detail字段——它存着实际输出是定位问题的第一手线索。把质量关从「上线后救火」前移到「改代码前验收」评测集就是你和 Agent 之间那份可复用的契约。十一、一份可照抄的最小落地清单如果你今天就想把评测集接进项目按这个顺序走半小时能跑通把feature/eval/四个文件EvalCase / Scorers / EvalHarness / MockAgent原样拷进工程。在Index.ets的aboutToAppear里先放 5 条contains用例跑通看报告。把mockAgentFn换成你的agent.send确认真模型能稳定吐出字符串。把高频错误意图补成equals/regex用例逐步把阈值抬到 90%。把这套用例连同ohosTest里的 Hypium 断言一起配进提交前检查。做到第 3 步你已经有了一个能反复跑的回归基线做到第 5 步质量护栏才算真正合拢。参考与出处鸿蒙开发者文档 · ArkTS 语言入门https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-get-started-V5鸿蒙开发者文档 · ArkTS 编码规范与语法约束https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-coding-conventions-V5鸿蒙开发者文档 · 应用单元测试Hypiumhttps://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/unit-test-V5最后一句把质量关从「上线后救火」前移到「改代码前验收」评测集就是你和 Agent 之间那份可复用的契约。