验证与质量保证:别让「看起来对」蒙混过关 · Lesson 6 of 6

第 6 课:实战:给你的 Agent 搭一条评测跑道

学习目标:

  • 把评测集、分层判分和 harness 循环拼成一个能反复跑的 eval-runner.mjs,一条评测任务跑一个独立循环
  • 让报告除通过率外记下每条任务的耗时、工具调用次数、token 消耗和工具报错,并用这几列诊断问题
  • 用这条跑道测出一次系统提示词修改的真实影响,并在同一条跑道上把「验证器过严把对的判错」抓出来修掉

前置要求:读完第 1–5 课,手边能跑起本系列第 7 门课的 harness 循环 | 上一课 第 5 课 <<

前五课讲的都是零件:验终态别逐步核对(第 2 课)、确定性检查优先且当心过严的验证器(第 3 课)、自由文本才轮到 LLM 裁判(第 4 课)、评测集从二十来条真实任务起步(第 5 课)。单独看都成立,但改完提示词之后,你还是没有一个东西可以敲一行命令,让分数告诉你「变好了还是变坏了」。

这一课把零件焊起来,焊出来的是一个三百多行的文件,跑一次不到两秒。官方对「怎么跑评测」给的做法很直接:用直接的 LLM API 调用、以程序化的方式跑;用简单的 agentic 循环,也就是交替进行 LLM 调用和工具调用的 while 循环,一条评测任务一个循环1。这正是本系列第 7 门课那个 stop_reason 驱动的循环,原样搬过来就行。

先看它跑起来的样子

把后面那份完整的 eval-runner.mjs 存到本地,node eval-runner.mjs

text
=== 报告 · 提示词 v1 · 验证器 normalized(修好的) ===任务              判分       结果     分数  调用  报错   tokens    耗时-----------------------------------------------------------------------t1-total          确定性     pass     1.00     3     0    1,800   124mst2-pending        确定性     pass     1.00     1     0      995    81mst3-no-orderid     确定性     FAIL     0.00     2     1    1,550   123mst4-refund-note    LLM 裁判   FAIL     0.67     1     0    1,432   124mst5-missing-order  确定性     pass     1.00     1     1      966    83ms-----------------------------------------------------------------------通过率 3/5 (60%) · 工具调用 8 次 · 工具报错 2 次 · tokens 6,743 · 总耗时 535ms
未通过明细:  [t3-no-orderid] 判据:参数不全时应该一次工具都不调,直接反问要订单号  Agent 回答:订单 SO-1001 的状态是已完成。  [t4-refund-note] 判据:金额与订单一致,语气得体;但没写退款多久到账,客户拿不到预期,三项里缺一项。  Agent 回答:您好,订单 SO-1003(金额 ¥320.00 元)我们已经受理取消,退款会原路退回。给您带来不便,非常抱歉。
=== 报告 · 提示词 v2 · 验证器 normalized(修好的) ===任务              判分       结果     分数  调用  报错   tokens    耗时-----------------------------------------------------------------------t1-total          确定性     pass     1.00     3     0    1,800   123mst2-pending        确定性     pass     1.00     1     0      995    83mst3-no-orderid     确定性     pass     1.00     0     0      487    41mst4-refund-note    LLM 裁判   pass     1.00     1     0    1,518   123mst5-missing-order  确定性     pass     1.00     1     1      966    83ms-----------------------------------------------------------------------通过率 5/5 (100%) · 工具调用 6 次 · 工具报错 1 次 · tokens 5,766 · 总耗时 453ms
=== 分数变化 v1 -> v2 ===任务                   v1     v2  变化------------------------------------------------t1-total             1.00   1.00  持平t2-pending           1.00   1.00  持平t3-no-orderid        0.00   1.00  fail => passt4-refund-note       0.67   1.00  fail => passt5-missing-order     1.00   1.00  持平------------------------------------------------通过率 3/5 -> 5/5

这不是手写的示例,是刚才在临时目录里真跑出来的,逐行照抄;你复制完整代码跑一遍,除了「耗时」那列会差几毫秒(真实墙钟时间,机器负载不同就会抖),其余数字都一样。

这份输出里藏着这一课的全部内容:五条任务各跑各的循环、两种判分方式混在一张表里、通过率之外还有四列指标、两个版本的差异被落成一张对比表。剩下的篇幅就是把它拆开。

跑道的五个零件

  1. 被测系统:工具定义、工具的真实实现,还有它们背后那点数据。评测跑的是「Agent 用你的工具干活」,工具是被测对象的一部分。
  2. 桩 client:一个假的 messages.create,按写死的队列顺序吐响应,让整条跑道可复现。
  3. 评测集:一个 tasks 数组,每条是 {id, prompt, verify}。官方的要求是每条评测提示都要配一个可验证的结果或产出1——没有验证器的提示词不算评测任务,只算试玩。
  4. 判分:能确定性判的走 verify 函数,自由文本才交给裁判。
  5. 循环和报告:一条任务一个 while 循环,跑完把指标汇成表。

有件事先说死:任务之间不共享 messages。每条任务的 messages 都从只有自己那条 user 提示的状态起步,跑自己的循环,跑完就扔1。为什么这么重要,中间那道题会专门问。

零件一:工具和它们背后的数据

被测对象是个订单助手,四条订单,两个工具:search_orders(按客户名或状态查,返回订单号列表)和 get_order(按订单号查单笔详情)。两个细节是故意留的:search_orders 只回订单号、不回金额,这会逼着 Agent 为每个订单再调一次 get_order,报告里「调用次数」那列就会把这个设计缺陷显出来;另一个是它在两个筛选条件都为空时直接抛错:

这是「无效参数」类型的工具报错。官方说这类报错扎堆通常意味着工具描述该写得更清楚、该补例子1,等下会在报告里看到它。工具报错不是崩溃:执行工具那段要把异常接住,包成带 is_errortool_result 还给模型,同时给计数器加一。tool_usetool_resulttool_use_id 配对,这是本系列第 7 门课就打过的地基,这里只多了两个计数器。

零件二:桩 client 与验证桥段

这里要停一下,不然后面所有数字都站不住脚。

真实的 Claude 是非确定性的:同样的提示词跑两次,路径可能完全不同2。这对生产是好事,对讲课演示是灾难——你今天跑出 3/5、明天跑出 4/5,分不清是提示词改动的功劳还是模型今天状态好。所以本系列第 8、9 门课的实战都用同一个办法:把模型换成一个按固定队列吐响应的桩,让被测行为变成受控变量,这样验证的是你写的那套控制逻辑,而不是模型当天的发挥。

队列耗尽就抛错,不给兜底响应——循环多转一圈,你会立刻看到 Error: [桩] v1/t2-pending 的响应队列已耗尽(已发出 1 次请求)(这行是我把 t2 队列里最后一条响应删掉之后真跑出来的报错原文),而不是拿到一个假的 end_turn 蒙混过去。每条响应还带自己的 latency_ms,桩会真的睡一下,让「耗时」那列量得出循环转了几圈;每条任务用自己的 script 新建一个 client,跨任务不共享游标。

两个提示词版本的差异,是靠桩里的两套响应队列钉死的。 真实场景里你改系统提示词、模型行为跟着变;这里我没有模型,所以预先写了 SCRIPT_V1SCRIPT_V2,让 v2 在两条任务上给出不同响应——「假设 v2 提示词生效后模型会这么答」这个假设,被写成了数据:

用展开语法从 v1 继承、只列改掉的两条,读代码的人一眼知道差异范围。这条跑道验证的是跑道本身:验证器判得对不对、指标记得准不准、报告算得对不对、两次运行能不能比。等你换成真 client,跑道不用改,改的只是数字会开始跳。

零件三:评测集——四条常规加一条边缘

第 5 课说评测集要贴合真实分布、要覆盖边缘用例3,官方也提醒别搞那种过于简单、不给工具施加足够复杂度的沙箱环境1。这里为篇幅只放五条,但结构是照真实评测集来的:

任务考什么判分
t1-total多步聚合:先查列表再逐单取金额确定性
t2-pending集合筛选:订单号要不多不少确定性
t3-no-orderid边缘用例:用户没给订单号确定性
t4-refund-note自由文本:写给客户的退款说明LLM 裁判
t5-missing-order工具报错后如实回报,不编数据确定性

t3-no-orderid 值得单说。提示词是「帮我把那笔订单的状态查一下」——哪笔?没说。理想行为是反问要订单号,而不是自己挑一个去查。官方文档对这个行为的表述很克制:如果用户提示词没给全必需参数,Claude Opus 更有可能意识到参数缺失并主动要,但这个行为不保证,提示词越含糊、模型越弱就越不保证4「不保证」的行为恰恰是评测集该覆盖的,保证的东西不用测。

verify 拿到的 r 里不只有 answer,还有 toolCallstoolErrorstokens,所以验证器能检查「终态加关键状态」而不只是文本:t3 判的其实是「一次工具都没调」,t5 判的是「恰好报了一次错并且如实说了查不到」——第 2 课的终态优先在这里落成了具体字段。note 是给人看的,任务失败时报告会把判据和 Agent 的原话一起打出来。

如果你第 5 课的作业用的是 {id, prompt, expected, verifier, rubricRef, tags, split} 那套字段,先对个表,免得以为自己上一课做错了:第 5 课的 verifier 在这里叫 grader,而且只用来在报告里显示——实际判分类型由这条任务有没有 verify 函数、有没有 judge: true 决定;expected 里的声明式断言,在这里直接写进了 verify 函数体(五条任务各自的断言长得不一样,写成函数比设计一套通用断言格式省事);rubricRef 因为全场只有一条裁判用例,直接内联成了 JUDGE_PROMPTtagssplit 为篇幅省略,留出集的纪律在「分寸」一节照常复述。你第 5 课交付的那份 JSON 没有作废——它是这份 TASKS 的声明式版本,往下走就是把每条断言翻译成一个函数。

零件四:分层判分,确定性优先

判分方式有排序:代码判分最快、最可靠、扩展性最好,只是遇到需要细腻判断的场合不够灵;LLM 判分快而灵活,能处理复杂判断,但要先验证裁判本身可靠再放量;人工判分最灵活质量最高,但慢且贵,能免则免3

所以规矩是:能用代码判的绝不请裁判。 五条任务里四条走 verify,只有 t4-refund-note 那条自由文本交给裁判——「这段话能不能发给客户」没法用字符串比对判。裁判的形状照第 4 课来,量表写死三项,输出格式限死成 JSON,先写理由再给分:

几条都有出处:让裁判先推理再给分、然后把推理丢掉,能提高评分质量,尤其是需要复杂判断的任务3;输出要具体、可量化,别做纯定性评价3;而「单次 LLM 调用、单个提示词、输出 0.0–1.0 的分数加一个通过/不通过」这个组合,是官方在多 Agent 研究系统里试过多种裁判方案之后,发现最一致、最贴近人类判断的那一种2

裁判在这里也是桩:v1 那份回复缺了到账时间,三项中两项给 0.67 判 fail;v2 补上了,三项全中给 1.00 判 pass。分数和量表自洽——三项二元求平均,分数只能落在 0、0.33、0.67、1.00 上,出现 0.85 这种数反而说明裁判没按量表算。裁判自己也烧 token,它的用量被加进那条任务的 tokens 里,这就是为什么 t4 只调一次工具、token 却不低。

还有一条第 4 课的纪律:干活的模型不该自己当裁判。官方的说法是让一个全新的模型实例去试着反驳结果,这样干活的那个就不是打分的那个5。代码里的体现是:裁判用自己的 client、自己的 system 提示词、自己的消息数组,只看到任务提示词和待评回复,看不到 Agent 的工具调用轨迹。

零件五:循环和报告

循环就是本系列第 7 门课那个循环,骨架一行没变——只是补上了真实 API 必填的 modelmax_tokens(桩会忽略它们),再在外面套了计数:

messagesrunTask 里的局部变量,函数返回它就没了。这就是「任务之间不共享上下文」的全部实现——不需要额外机制,只要别把它提到外面去。

指标这块,官方给的清单是:除了顶层准确率,还建议收集单个工具调用和整条任务的总运行时长、工具调用总数、token 消耗总量、以及工具报错1。报告表那几列照的就是这份清单。通过率只告诉你「过没过」,这几列告诉你「怎么过的」——一条任务过了但调了十二次工具,和过了只调两次,是两种质量。这几列还自带诊断读法:冗余的工具调用一多,通常说明分页或 token 上限这类参数该重新调;无效参数导致的工具报错一多,通常说明工具描述该写清楚、该补更好的例子1。练习会直接用上。

报告能给人看,这件事本身有价值。官方文档那条建议是:让 Claude 拿出证据而不是断言成功——测试输出、它跑了什么命令、返回了什么,或者结果的截图;审阅证据比你自己重跑一遍验证快,而且对你没在旁边看的那些会话同样管用5。这张报告表就是那个证据,贴进 PR 描述或者甩给同事,对方不用重跑就能判断。(打印上唯一的坑是中文全角字符宽度算 2,padEnd 直接用会错位,所以代码里自己写了个宽度感知的 pad。)

完整的 eval-runner.mjs

复制保存成 eval-runner.mjsnode eval-runner.mjs 直接能跑。不装依赖、不要 package.json,Node 18 以上就行(用到顶层 await,所以后缀必须是 .mjs)。

回收第 3 课的坑:过严的验证器

第 3 课讲过一个陷阱,官方的原话是:别写过严的验证器,因为格式、标点、或者同样有效的不同措辞这类无关紧要的差异,把正确的回答给否了1。听着像常识,写代码时几乎躲不掉,因为过严的验证器写起来最省事。

跑道里就埋了一个。t1-total 有两版验证器,旧版是 pass: r.answer.includes("1280.00")——看上去无懈可击:正确答案就是 1280.00,那就查答案里有没有这串字符。跑 node eval-runner.mjs --strict-verify(下面只贴 v1 那一段,v2 报告和对比表照常打):

text
=== 报告 · 提示词 v1 · 验证器 strict(旧版,没做归一化) ===任务              判分       结果     分数  调用  报错   tokens    耗时-----------------------------------------------------------------------t1-total          确定性     FAIL     0.00     3     0    1,800   122mst2-pending        确定性     pass     1.00     1     0      995    83mst3-no-orderid     确定性     FAIL     0.00     2     1    1,550   124mst4-refund-note    LLM 裁判   FAIL     0.67     1     0    1,432   124mst5-missing-order  确定性     pass     1.00     1     1      966    82ms-----------------------------------------------------------------------通过率 2/5 (40%) · 工具调用 8 次 · 工具报错 2 次 · tokens 6,743 · 总耗时 535ms
未通过明细:  [t1-total] 判据:答案里要原样出现字符串 1280.00  Agent 回答:客户启明科技 2026 年 8 月的已完成订单有 2 笔(SO-1001、SO-1002),合计 ¥1,280.00 元。  [t3-no-orderid] 判据:参数不全时应该一次工具都不调,直接反问要订单号  Agent 回答:订单 SO-1001 的状态是已完成。  [t4-refund-note] 判据:金额与订单一致,语气得体;但没写退款多久到账,客户拿不到预期,三项里缺一项。  Agent 回答:您好,订单 SO-1003(金额 ¥320.00 元)我们已经受理取消,退款会原路退回。给您带来不便,非常抱歉。

这段也是真跑出来的。看 t1-total 那条明细:Agent 答的是「合计 ¥1,280.00 元」,金额算对了、订单列对了、措辞是正常中文。它唯一的罪状是在 1 和 280 之间打了个千分位逗号,于是 includes("1280.00") 返回 false,一条完全正确的回答被判 fail。

这时候要修的是验证器,不是 Agent。 报告只会告诉你「t1 fail」,不会告诉你锅在谁那儿;分辨的办法就是读明细里的 Agent 原话,那正是报告特意把答案原文打出来的原因。

修法是归一化。官方对精确匹配的描述本来就带着这一步:精确匹配评估的是模型输出与预设正确答案是否一致,通常先做空白和大小写的归一化3。金额场景要洗的更多——货币符号、千分位、单位,所以修好的验证器先洗噪声,再把数字抠出来按数值比:

去掉 --strict-verify 再跑,t1-total 从 0.00 变成 1.00,v1 基线从 2/5 回到 3/5——而这中间,Agent 一个字都没改,桩里的响应队列一个字都没动。分数变了但被测对象没变,这就是「验证器问题」的判定标准。

顺带一句分寸:归一化不是越松越好。松到「出现过 1280 就算过」,Agent 答「共 1280 笔订单,金额未知」也会判 pass。验证器要卡在「无关差异放过、实质错误拦住」的位置上,找到这个位置的唯一办法是拿真实答案去试。

改一处提示词,看分数动了没有

跑道校准好了,可以拿它干正事。我只动了一个地方——系统提示词,在 v1 后面加了两条规则:

这两条不是拍脑袋想的,是从 v1 那份报告的「未通过明细」里读出来的:t3 fail 是因为参数不全时它自己猜了个订单号,t4 被扣分是因为漏了到账时间。报告说什么,你就改什么,这是有跑道和没跑道最实在的区别。

重跑之后的对比表就是开头那份输出的最后一段:通过率从 60% 到 100%,两条任务从 fail 转 pass,另外三条纹丝不动。最后这半句和前半句一样重要——它说明这次改动没把已经对的东西改坏。没有跑道时,你改完提示词只能看一眼输出觉得「好像更好了」;有了跑道,「哪条变好、哪条没动、有没有变坏」是三行数字。

官方对这件事的表述是:有了评测,你就能更有信心地测量提示词工程带来的影响,哪怕只是对工具描述做很小的改动,也可能带来相当大的提升1。这里还有个便宜可以捡:在 Agent 开发早期,改动的影响往往很大,因为唾手可得的改进点还很多——一次提示词微调就可能把成功率从 30% 提到 80%,效应量这么大的时候,几条测试用例就足以看出差异2。你现在只有五条任务,这不是缺陷,是起点。

再看指标列:v2 的工具调用从 8 次降到 6 次,工具报错从 2 次降到 1 次,token 少了近千,因为 t3 不再瞎猜着去调工具了。同一次改动同时改善了正确率和成本——这种事只有把这几列一起记下来才看得见。

分寸:这条跑道管什么,不管什么

它管的是:一个 Agent、一批任务、在你本机跑一遍,出一张能给人看的报告。

换成真模型,跑道结构不用改。把 stubClient(...) 换成 @anthropic-ai/sdk 的真 client,runTask 里那个 while 循环一行都不用动——它本来就是照真实 API 的 stop_reason / tool_use / tool_result 形状写的,modelmax_tokens 这两个必填参数也已经带上(桩会忽略它们,真 client 正好用上)。换完有两件事会变:分数会抖,因为 Agent 在两次运行之间是非确定性的,即便提示词完全一样2,所以单次结果别看得太重;跑一轮要花钱花时间,五条任务无所谓,两百条就得考虑并发和成本了。

它不管的:把评测挂进 CI、每次提交都跑一遍、和历史版本比分数、跌破阈值就拦住合并——这些是常见的工程做法,也确实好用,但本课不展开;练习 Level 2 会带你把「两份报告比一比」这一小步做出来,剩下的编排是你自己 CI 的事。

还有一条第 5 课的纪律要复述一遍:留出集别拿来调参。你照着报告改提示词,改上几轮分数一定会涨,但涨的可能只是「在这五条任务上的分数」。官方的做法是依靠留出的测试集,确保没有对用来调优的那批评测过拟合1。所以真做起来任务该分两堆:一堆天天跑、照着改,另一堆锁起来,只在你觉得「这版应该行了」的时候开一次。第一堆的分数是导航,第二堆的分数才是结论。

最后一条老话:自动评测会漏。人工测试的人总能撞见评测漏掉的边缘情况——不寻常查询上的编造、系统性故障、隐蔽的信源偏好2。跑道跑得再顺,也别停掉自己上手用的习惯。

💻 练习

小结

  • 跑评测的标准形状是程序化的直接 API 调用加简单 agentic 循环,一条评测任务一个循环;任务之间不共享 messages,否则前一条的上下文会污染后一条,结果不再可比1
  • 每条评测提示都要配一个可验证的结果,验证器从精确字符串比对到请模型当裁判是一条光谱——能用代码判的绝不请裁判,因为代码判分最快、最可靠、最能扩展13
  • 自由文本才交给裁判,形状是单次调用、单个提示词、输出 0.0–1.0 的分数加一个通过/不通过;量表要先推理再给分,输出格式要限死23
  • 报告除通过率外还要记任务耗时、工具调用次数、token 消耗和工具报错;这几列自带诊断读法——冗余调用多指向分页/返回量参数该调,无效参数报错多指向工具描述该写清楚1
  • 验证器过严会把正确回答判死:格式、标点、合理的不同措辞都可能绊倒字面比对,精确匹配前先做归一化13。分数变了但被测对象没变,锅在验证器。
  • 有了跑道,提示词改动的影响就能被测量出来,小改动也可能带来相当大的提升;早期效应量大,几条用例就够看出差异12。报告本身就是可以给人看的证据,审阅证据比自己重跑一遍验证快5
  • 照着报告改会让分数涨,但涨的可能只是这批任务上的分数,留出集要锁起来防过拟合1;自动评测漏掉的边缘情况,仍要靠人上手用出来2

走完这门课之后

回头看这条主线其实很短。第 1 课分清了「看起来做完了」和「做完了」——没有可跑的检查时,「看起来做完了」是唯一可用的信号,而你自己就成了那个验证环节5。第 2 课定了验什么:Agent 可能走完全不同的合理路径到达同一个目标,所以评终态,别逐步核对轨迹2。第 3 课把「检查」落成能跑出 pass/fail 的确定性验证器,也提醒了过严的验证器会把对的判错1。第 4 课处理自由文本那一块——量表、输出格式,以及干活的模型不该自己当裁判25。第 5 课解决「拿多少条用例验」:二十来条真实任务就能起步,别等攒够几百条再开始2。这一课把前五课焊成了一个三百行的文件。

那个文件不复杂,跑一次不到两秒,但它改变的东西是具体的:从今天起你改一版提示词,不用再凭「读了几段输出感觉更好了」下判断——敲一行命令,v1 到 v2 那张对比表会替你说话,就像这次 t3t4 翻绿、其余三条纹丝不动那样。下次你的 Agent 说「做完了」,你手里有两条命令和一个退出码去核实这句话。

下次你的 Agent 说「做完了」,你手里有一条能跑的跑道去核实。

Footnotes

  1. Writing effective tools for agents — with agents — Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17

  2. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5 6 7 8 9 10 11

  3. Define success criteria and build evaluations — Claude API 文档 — https://platform.claude.com/docs/en/test-and-evaluate/develop-tests 2 3 4 5 6 7 8

  4. Tool use with Claude — Claude API 文档 — https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview

  5. Best practices for Claude Code — Claude Code 官方文档 — https://code.claude.com/docs/en/best-practices 2 3 4 5

Exercises

01

不写代码。回到正文开头那两份报告(v1 基线和 v2 改动后),汇总行是:

Level 1:读报告,别急着改代码
text
v1: 通过率 3/5 (60%)  · 工具调用 8 次 · 工具报错 2 次 · tokens 6,743v2: 通过率 5/5 (100%) · 工具调用 6 次 · 工具报错 1 次 · tokens 5,766

对着完整的两张表回答三个问题,每题写三到五句:

  1. t1-total 两份报告里都 pass,但它的工具调用次数是全场最高的 3 次。这说明什么问题?该动哪里?
  2. v1 里有 2 次工具报错,v2 剩 1 次。这两次报错是同一类问题吗?各自意味着什么、各自该不该修?
  3. 正文里还有第三份报告(--strict-verify 那份),里面 t1-total 是 0.00。同一条任务在两份报告里一个 0.00 一个 1.00,你凭什么判断这个分数差是验证器的问题而不是 Agent 的问题?
Done criteria · checked locally
02

写代码,必须能跑。给 eval-runner.mjs 加两件事:

Level 2:给跑道加「两次运行对比」
  1. 报告落盘:加一个 writeJsonAtomic(file, obj),用本系列第 9 门课的原子写法(先写 .tmprename)把一次运行的报告写成 JSON。命令行支持 --version v1 --out reports/v1.json
  2. 写一个 compare.mjs:读两份报告 JSON,按任务打印分数差(基线分、新分、差值、状态),最后打印通过率变化;只要有任何一条任务从 pass 变成 fail,就往 stderr 打一条摘要并以非零码退出。

跑通这四条命令并贴出输出:

text
node eval-runner.mjs --version v1 --out reports/v1.jsonnode eval-runner.mjs --version v2 --out reports/v2.jsonnode compare.mjs reports/v1.json reports/v2.json   # 应该退出码 0node compare.mjs reports/v2.json reports/v1.json   # 应该退出码 1

(把参数反过来传,就等于人为制造一次「新版比基线差」的场景,用来验证非零退出这条路径真的走得通。)

Done criteria · checked locally

My note

Jot down thoughts, sticking points, things you didn't get. Written to this course's appendix only — the lesson file is never touched.