Article / 评测与交付
LLM 应用的评测与测试
从 gold50 检索评测到全 mock 测试,讲清 LLM 应用如何定义口径、构造评测集,并把指标变成设计约束。
给 LLM 应用写测试,最容易犯的错是拿传统断言去测答案质量:同一个问题,模型这次和下次的回答措辞不同,逐字断言只会不断假失败;测试全绿也不代表回答有据。两个项目里我最后都做了两层验证:确定性管线用 mock 测试钉住,概率性质量用评测集量化。案例来自 CitedRAG 的 gold50 检索评测和 NovaCode 的 mock 测试体系。
断言测试的局限
传统单元测试的模式是「给定输入、断言输出」,对解析器、状态机、权限矩阵依然有效。LLM 应用的输出是一个分布:换一种措辞、换一个引用顺序都可能是对的,错误的表现也不一样——检索漏了、回答没有依据、引用对不上,这些都不会抛异常。即使把所有函数的调用顺序都断言正确,也回答不了「回答有没有依据」这个问题。
所以测试目标要分层。确定性部分——事件流、状态迁移、权限判定、压缩恢复、协议序列化——用 mock 替换模型客户端,断言控制流与结果形状;概率性部分——检索质量、回答忠实度、引用正确性、拒答合理性——离线评测,用真实模型与真实语料跑,按定义好的口径打分。NovaCode 的测试几乎都在前一层:替换 LLM 客户端后断言事件流与工具结果,它能证明管道通了、边界处理对了,但证明不了回答是否变好。CitedRAG 的评测脚本相反,必须接真实 Provider,测的是检索与编排的质量。两层解决的问题不同,缺了哪层都有盲区。
gold50 检索评测的构造与口径
评测集是「什么算好」的正式定义。CitedRAG 的检索评测语料是 10 篇论文,50 道题一题一行 JSON,字段包括问题、期望来源、期望页码、期望关键词与一条答案备注。
{
"id": "gold-001",
"question": "What inference throughput advantage does Mamba have over Transformers of similar size?",
"expected_source": "Mamba",
"expected_pages": [1, 2, 15],
"expected_keywords": ["5 × higher throughput"],
"answer_note": "Mamba enjoys fast inference..."
}
字段设计里有三个细节。期望来源用文档别名,先经语料清单解析成文档 id 再比对,避免改一次文件名就让整题失真——评测集的引用必须比代码更稳定。期望页码把「页码级命中」变成可判定的条件,这是引证式问答的核心质量维度。期望关键词(或任一变体组)把内容命中做成确定性检查:前 k 条里期望来源的证据文本,经 NFKC、连字符转空格、空白折叠、小写四步归一化后,字面覆盖关键词。报告里还有来源级召回率与 MRR,以及「证据充分所需的最小 top_k」——它把「要不要再检索深一点」也变成一个可比较的数字。
50 题的构成是:45 题可答、5 题文档外(期望拒答)、45 题带页码约束、12 题中文提问。拒答题的判分要特别小心:合理拒答定义在 cosine 尺度上(最大相似度低于 0.50),但 RRF 是排名倒数之和、重排 sigmoid 在无关句上接近 0.5,这两个尺度的拒答题直接记为不判分。默认组合(hybrid + rerank + parent expansion,top_k 取 8)的结果是:可答题 41/45,来源命中 100%,页码命中 93%;dense 模式 44/50,其中 5 道拒答全部正确。41/45 与 44/50 的分母不同,不能直接比较;口径不写清楚,这两组数字很容易被读成同一回事。
评测脚本还有一个实用设计:逐题深扫一次取全量候选,再在内存里按不同 top_k 截断判分,一次扫描复用出多组指标;矩阵模式在真实管线上跑十种开关组合,通过临时配置覆盖生效、不落盘。参数选择因此都能回溯到实验:切块大小从 1200 降到 800,top_k 从 5 提到 8 让结果从 40 到 41,MMR 系数从 0.75 调到 0.88 让结果从 38 到 40;查询改写实测「高方差、无净增益且每题多 1.4 到 2.1 秒」,默认关闭。这些结论写在配置注释里,和评测记录一一对应。
编排评测的路径与成本
检索评测看的是找得对不对,编排评测看的是路径和成本。15 个任务覆盖五类场景:简单事实 4、多文档对比 3、多步 3、证据不足 3、工具失败 2,期望直答 9 条、走 planner 6 条。判定分两层:grounded 要求回答非拒答、角标结构合法、期望来源命中,期望拒答的题以语义拒答为准;success 在 grounded 之上再加关键词覆盖。两层口径对应两种用途——关键词覆盖对语言和转述敏感,用来判定最终成功;路径质量用 grounded,不受措辞噪声影响。
评测可以强制指定路径(直答、planner 检索、planner ReAct),通过评测专用的配置覆盖实现,生产代码从不设置——测试能力不能改变生产行为;还可以模拟检索工具故障,复现失败路径。报告不只记录结果,还有路由层级统计(过早路由、路由不足、升级)、planner 任务数与重复任务率,以及从 trace 统计的成本:模型调用数、token、检索调用、节点调用与延迟。
结果里最有信息量的是对比:默认路径 15/15 有据、14/15 成功;planner 任务在不逐轮调用模型的检索模式下 6/6 通过,平均检索调用数约为 ReAct 模式的 1/6。原因不复杂——planner 已经把问题拆成带检索词的任务,researcher 不需要再让模型一轮轮选工具,省下的不只是调用次数,还有每轮工具结果回灌。这个数字同时也是架构决策的证据:如果检索模式只有质量优势而没有成本优势,默认路径就该重新考虑。成本指标还有一个作用:它把「架构升级」和「成本优化」放进同一张报告比较,避免用质量换成本的改动悄悄溜过去。
答案层还有独立的评测脚本,检查关键词、角标结构(无畸形标记且每个编号都落在返回的引用范围内)、拒答与单题延迟。应用内评测页把同一套口径带进界面:用例管理、单题调试(先给检索指标与分阶段耗时,可选跑一次真实图)、批量评测(线程池、可取消、结果落库),并与上一批次对比指标差;每次结果都记录 Provider 快照与 RAG 管线快照,保证任何数字都能归因到当时的配置。
NovaCode 的全 mock 测试策略
NovaCode 的测试目标是:660 多个用例在无网络、无 API Key 的前提下十几秒跑完,并且不碰真实用户数据。当前基线是 662 通过、1 跳过(共收集 663 个用例),全量约 15 秒。做法是替换边界:模型客户端、工具、文件系统环境都可以在测试里换掉;压缩恢复、权限矩阵、会话配对这类纯逻辑模块则直接构造对象断言。
@pytest.mark.asyncio
async def test_single_step_tool_call(mock_client):
events = [e async for e in agent.run(conversation)]
assert any(isinstance(e, ToolUseEvent) for e in events)
工程约束里有三个细节。其一,异步用例必须显式标记:项目没有配置自动异步模式,仓库里 110 处显式标记分布在 17 个测试文件。显式的好处是「异步」是一个有意识的决定;代价是漏标的用例可能静默地不按异步执行。其二,主目录隔离:conftest 里有一个自动生效的夹具,把团队模块解析到的主目录定向到每个用例的临时目录,保证团队状态不会写进真实主目录;只替换这一个模块,避免影响其他模块对主目录的解析——测试隔离只补最窄的缝,全局替换环境容易把「测试通过」变成「环境凑巧」。其三,真实模型用例只有一条:设置环境变量才运行,未设置即跳过,这就是那 1 个 skip 的来源;另有一个手动验证脚本不被测试收集,用来跑真实的子 Agent 链路。mock 测试给出秒级反馈,手动脚本和真实 Provider 评测给出真实性,两者按修改的风险分层使用。
盲区也要说清楚:跨进程路径——操作系统沙箱、窗格队友、远程取消——没有集成测试;用例里的超时标记因为没有对应的 pytest 插件而没有实际生效,这是测试输出里唯一的 warning。这是 mock 优先策略的边界:mock 覆盖管道与矩阵,覆盖不了进程、内核与网络。修复方向是给三条路径补集成测试,并让超时保护真正可用。
LLM-as-judge 与评测框架的定位
确定性判分便宜、可复现,但表达不了「回答是否忠实于证据」「是否答非所问」这类语义质量。LLM-as-judge 用模型来打分:把答案、证据和评分标准交给一个通常更强的模型,让它做判定或成对比较,适合主观、开放、无法用字符串匹配表达的质量维度。它的偏差也明确:位置偏差、长度偏好、自我偏好、对评分提示词敏感。工程上要先校准 judge:在人工标注的样本上对齐,固定并版本化评分提示词,能做成对比较就不做绝对打分。
RAGAS 是 RAG 场景的评测框架,提供忠实度、答案相关性、上下文精确率与召回率等指标,定位是「无参考评测」:不需要人工写标准答案,用模型做判定,适合快速起步与横向比较。它的数字依赖 judge 模型与提示词,换一个 judge 结果就会变,因此只适合在相同配置下比较,不能当绝对真理。CitedRAG 把它作为评测层之一接入,同时保留确定性判分作为主口径,并用快照记录运行配置:确定性检查做地基,模型判分补语义,两者都记录配置。分层上有一条实用规则——能用规则判分的绝不交给模型(来源、页码、引用结构、拒答阈值),规则表达不了的再上 judge(忠实度、相关性、语气),离线用 judge 批量跑,线上抽样人工复核做校准。评测框架也给不了领域知识:什么是「有据」、什么是「合理拒答」,只能由项目和数据集定义,框架只提供指标词汇与计算管线。
评测驱动开发
当指标成为设计约束,开发方式会变。prompt、模型、检索参数的每次改动都是一次发布,都应复跑评测集并与上一版对比;报告带时间戳落盘,应用内评测页直接给出与上一批次的指标差,配置快照让「这个数字是什么配置下跑出来的」都能回答。灰度则把评测口径带进真实使用:界面上的 A/B 对比真实流量,离线用矩阵在真实管线上比较开关组合;默认关闭的可选项只有在矩阵证明净收益后才调整默认值,这类改动影响所有用户,按产品决策对待。
评测集本身也要维护,方式和代码类似:从真实失败开始(每次 bug 补一道题),混合可答与拒答,覆盖难例(多文档对比、证据不足、工具失败、中文提问),引用必须稳定(别名字段、页码约束、归一化规则),并做版本管理。评测集也有维护成本:语料更新、引用漂移、判分规则变更都会让旧报告失去可比性,版本管理决定了历史数字是否还能用。子集的存在说明还需要一套快跑集:日常迭代跑子集,发版前跑全量。评测放在哪跑也有取舍:需要真实模型与语料的评测不进 CI,CI 只跑便宜稳定的检查,昂贵评测在本地按需执行、参数与结果可追溯;代价是仓库里没有报告文件,文档中的数字来自 README 记录,这是一个透明性缺口,路线图上的改进正是让评测报告入库或由 CI 定期产出。
评测驱动开发做的事,是把「感觉变好了」换成可复算的数字:41/45、页码命中 93%、planner 检索 6/6、检索调用约 1/6。这些数字是检索与编排架构决策的依据,评测集则是系统对「好」给出的正式定义。