把LLM技能当成普通函数来做自动化测试,这个想法我第一次听的时候觉得有点“想多了”——模型输出本来就是概率性的,同一句话问两次答案都能不一样,怎么测?但后来真正开始在Agent项目里维护二十多个技能模块,每次改一句提示词都要把十几个场景手工点一遍,我才意识到:不把这件事自动化,后面只会越来越痛苦。这篇就是用skillprobe这套工具把LLM技能测试落地的全记录,从测试用例设计到指标判定,再到接进CI流水线,踩过的坑和验证过的思路都会写到。
如果你正在做Agent类应用,或者给LLM封装了各种“技能”(RAG检索、工具调用、结构化抽取、内容生成),你大概率也会遇到同一个问题:技能看着能用,但改一版提示词之后,到底有没有改坏别的地方,基本靠运气。skillprobe就是拿来解决这个问题的,它的定位很像pytest之于Python——只不过被测对象是LLM技能,而不是普通函数。下面直接进入正题。
1. 为什么必须给LLM技能上自动化测试
1.1 LLM技能不是代码,但它也需要“单元测试”
先对齐一下概念。这里说的“LLM技能”,指的是你在Agent或应用里给大模型封装好的能力模块。举个例子,一个给客服机器人用的“订单查询技能”,它的输入可能是用户原话和用户ID,内部逻辑是把用户原话做意图识别、抽取出订单号,再去调用订单系统API,最后把结果整理成自然语言回答。整个链路是“模型+提示词+外部工具调用”的组合,而不是一段普通的函数代码。
普通函数测试很简单:输入A,断言输出是B。但LLM技能不一样,它的输出是概率性的,同一个输入配上稍微不一样的采样参数,结果就会有波动。而且它的“正确”也不只是数值对错,还有语义是否正确、格式是否规范、引用是否能对上原文。所以传统的单元测试框架直接用不了,需要一个能适配“非确定性输出”的测试方案。
skillprobe做的事,就是把LLM技能测试拆成几层:技能注册和加载是否正常、输入输出schema是否匹配、端到端行为是否正确、多次执行是否稳定。每一层都有对应的判定逻辑,而不是简单拿一个字符串去比对。
我自己的体会是,LLM技能的维护成本其实比普通代码高得多。普通代码出问题,报错堆栈一看就明白了。LLM技能出问题,往往是“回答得挺流畅,但内容里有小错”或者“这个场景回答得很好,换一个说法就不行了”。这些问题单靠开发时手动测两条路径,根本发现不了。自动化测试不是锦上添花,而是把质量底线兜住。
1.2 手工验证的三个致命问题
先说说我最早是怎么干的。每个技能写完之后,打开一个测试面板,把样例输入粘贴进去,点一次,看输出是否合理。流程看起来没毛病,但实际用下来有三个绕不开的问题。
第一个问题是覆盖度太低。手工测试天然偏向happy path,你会下意识地测那些“自己知道应该有结果”的输入。但真实用户不会按测试用例说话,他们可能一句话里带口音、可能只给一半信息、可能上来就问一个完全超出技能范围的问题。这些边界情况,手工测试基本覆盖不到。
第二个问题是回归很难发现。LLM技能里面的提示词是脆弱的,你为了修A问题改了一句话,很可能B问题就跑偏了。手工测试的记录都在脑子里,时间一长,你根本想不起来上一版在某个输入上的输出长什么样。等你发现B问题挂了,往往是在线上被用户投诉之后。
第三个问题是对“状态”几乎无法验证。很多技能是有上下文的,比如多轮对话场景里的“追问订单号”技能,需要在多轮里累积信息。手工测试时状态在你脑子里,你记得上一轮说过什么。但自动化测试要验证的是“技能自己在多轮之间对不对”,这需要把对话历史当输入参数显式管理起来,手工方式很难稳定复现。
这三个问题叠加起来,结论很清楚:LLM技能的测试必须自动化,而且必须是可重复执行的自动化,否则你没法在版本迭代里建立信心。
2. skillprobe能测什么:从技能注册到端到端行为
2.1 技能核心要素拆解
在用skillprobe之前,我先把一个LLM技能拆成了四个要素,这样才知道该测什么。
第一个要素是技能声明。每个技能都有一个描述文件,里面写清楚技能名称、一句话描述、输入参数的定义、输出结果的定义。这个文件的作用是让Agent的主模型知道“什么情况下该调用这个技能”,同时让外部系统知道“该传什么进来、能拿什么回去”。技能声明要是写错了,模型根本不会在合适的时机调用这个技能。
第二个要素是实现逻辑。包括提示词模板、内部的工具调用链、后处理逻辑。这是整个技能的核心,也是最容易改坏的部分。提示词模板改了,输出风格可能变;工具调用顺序变了,结果可能延迟或者出错;后处理逻辑改了,格式可能不符合schema。
第三个要素是运行配置。模型名称、温度、最大token数、超时时间、是否启用流式输出等。这类配置直接影响输出的稳定性和性能。温度太高,同一输入每次输出都不同;超时太短,长文本生成直接失败。
第四个要素是外部依赖。RAG技能依赖向量库和检索器,工具调用技能依赖下游API。这些依赖的状态变化也会影响技能行为,比如向量库里新增了一批文档,技能的引用来源就会变。测试时需要考虑这些依赖是否可用、是否可控。
skillprobe的测试设计基本就围绕这四个要素展开,每个要素对应一类测试项,而不是只盯着“输出内容像不像”。
2.2 skillprobe的四个核心能力
具体来说,skillprobe做了下面四类事情。
第一类是技能注册健康检查。它会验证技能的描述文件是否能被正常解析,输入输出的schema定义是否合法,技能能否被模拟的Agent运行时加载。这一步很像传统的冒烟测试——跑不通就不往下走了,省得后面所有用例都因为技能加载不了而失败。
第二类是输入输出schema校验。执行技能之后,先不看内容质量,先看返回的结果结构和定义是否一致。比如你定义输出是一个JSON对象,包含“订单号”和“状态”两个字段,那么返回结果就必须是一个合法的JSON,而且这两个字段必须存在,类型必须正确。这层校验能抓出一大类“模型没按格式输出”的脏数据问题。
第三类是端到端行为断言。这是核心部分。给定一组输入,执行整个技能链路,然后对输出做语义和结构层面的判定。判定方式可以是结构化断言、语义相似度、LLM评分等,后面会细讲。
第四类是稳定性与性能采集。同一个用例执行多次,收集输出的波动情况、耗时分布、token消耗。LLM技能上线前,最好知道它在“大概率情况下”表现如何,而不是只测一次看个结果。
把这几类能力组合起来,skillprobe对技能的覆盖就比较完整了。实践下来,技能注册和schema校验抓的问题最多,因为提示词一改,输出格式经常悄悄变了;端到端行为断言的价值在回归测试里最明显。
3. 如何设计一份能抓Bug的测试用例
3.1 测试用例的最小单元:样例输入加参考输出加容忍度
测试用例设计是整个方案里最需要花心思的部分。skillprobe里一个用例的最小单元由三部分组成:样例输入、参考输出、容忍度设置。
样例输入要好理解,就是一个能被技能执行的输入。对于订单查询技能,样例输入可能是“帮我查一下单号SD20240513的物流进度”。对于内容生成技能,样例输入可能是一段产品需求和目标用户描述。样例输入的覆盖策略,要兼顾正常场景、边界场景和异常场景。
参考输出是“这个输入在理想情况下应该返回什么”。这里有个关键点:LLM技能的参考输出不应该是一个字都不能差的固定文本,而应该是一个带语义空间的结果。你可以定义参考输出的核心要素,比如“必须包含订单状态字段”“必须提及最新的物流节点”“语气必须礼貌”,而不是规定整个句子长什么样。
容忍度设置就是告诉skillprobe“偏差多少算可以接受”。语义相似度阈值设0.8还是0.9,结构化字段允许哪些字段缺失,调用外部API的次数允许几次,都在这里配置。
一个完整的用例设计好了之后,相当于把“这个技能在这个输入上应该表现成什么样”这个模糊的判断,变成了可以被机器执行和复现的规则。
3.2 参考输出怎么生成:人工种子加LLM辅助
构造参考输出是新手最容易卡住的地方。一开始我天真地想,让我一个一个手工写参考输出,写出来应该很精准吧。结果写了十几个用例之后发现,速度太慢,而且我写参考输出的语气和技能真实输出的语气总有偏差,导致语义相似度算出来很低,误报率很高。
后来我换了个策略,三步走。
第一步,人工标注种子集。每个技能先挑5到10个典型输入,手工写出参考输出。不需要长,关键是包含该有的信息要素和正确的格式。
第二步,用LLM辅助扩充参考输出。把种子集喂给一个辅助模型,让它按照样例的风格和结构,给一批新的输入生成参考输出。这一步能快速扩充用例库,但生成出来的东西只能当候选,不能直接用。
第三步,人工审阅。所有辅助生成的参考输出都要过一遍眼,确认信息要素正确、格式符合预期、没有幻觉。这一步很重要,因为参考输出本身如果有错,后面所有判定就失去了基准。
这里有个容易忽视的坑:生成参考输出时用的模型和技能运行时用的模型,最好不要是同一次调用、同一个温度配置。否则“测试参考输出”和“被测输出”之间可能会存在隐性的同源偏见,导致在生成模型上测试结果虚高,换一个模型就崩了。
3.3 负面用例和对抗性样本必须单独建一批
很多人做LLM技能测试时,因为参考输出不知道怎么定,就直接放弃了负面用例。这是不对的。负面用例反而不需要复杂的参考输出,只需要一个预期行为:要么拒绝,要么触发澄清,要么明确返回“无法处理”。
我维护的用例集里,负面样本大概占三成。常见的有这么几类:空输入、超长输入、格式错误的输入、明显超出技能范围的问题、含有诱导内容的输入(比如让模型忽略系统指令、输出Prompt原文等)。
负面用例最典型的验证方式是:执行技能后,断言输出里是否包含“无法回答”之类的拒绝语义,或者断言技能是否正确返回了某个错误标识。你不需要它答得漂亮,只需要它不要“硬答”。很多线上事故就是负面输入触发了模型的胡编乱造,而手工测试又从来不会拿这些输入去点一遍。
对抗性样本这东西,一开始没那么复杂。先把项目里用户反馈过的问题话术沉淀成用例,再周期性跑一遍,比专门去构造随机攻击句子实用得多。
3.4 测试用例也要做版本管理
测试用例不是一次性写完了就完事。技能版本迭代,用例也要跟着更新。skillprobe的用例集建议直接用Git管理,和技能代码放在同一个仓库里。这样每次改动技能代码的时候,能直接看到用例集有没有同步更新。
用例文件本身建议用可读性强的格式组织,每个用例有个唯一ID,有标签标明场景类型,有创建时间,有最后更新人。我在项目里遇到过一次很尴尬的情况:一个用例从创建起就没人维护,技能行为已经合法变更了两次,用例的参考输出却还在描述最原始的版本,每次跑测试必挂。最后排查了很久才发现不是技能的问题,是用例过期了。
所以用例也要有一个生命周期管理,定期Review、清理和更新。我会在每次技能版本发布前,跑一遍全量用例,同时看一眼有没有用例被跳过或者被标记为“已知过时”。
4. 指标与判定逻辑:别让测试自己变成玄学
4.1 先做结构化断言,再做语义判断
判定逻辑是skillprobe的核心设计。我把它分成三层,优先做结构化断言,再做语义判断,最后才用LLM评分。
结构化断言是最硬的一层。它校验输出是否满足明确的规则,比如:输出是否是合法JSON,关键字段是否存在、类型是否正确、数值是否在一定范围内、枚举值是否合法。这层判断不需要模型参与,纯代码就可以做,速度极快,准确率百分百。
我在项目里见过太多“模型一本正经地输出了一坨格式错误的内容”的案例。尤其是让模型输出JSON的场景,经常出现末尾多一个逗号、字段名被引号包错、嵌套结构错乱这类问题。结构化断言能把这些全部拦下来。
结构化断言通过之后,再看语义。语义判断通常用向量相似度:把模型输出和参考输出分别转换成向量,计算余弦相似度。但这里要有心理准备:语义相似度是一个很粗糙的指标,它适合“生成型任务”的粗筛,不适合“抽取型任务”的细判。抽取型任务建议直接用结构化断言加信息要素比对,比如检查输出里是否包含指定实体。
给不同任务设定合适的阈值很重要。生成型任务比如邮件撰写、内容总结,语义相似度阈值设在0.75到0.85之间比较合理,因为同一句话可以有多种合法表达方式。抽取型任务比如订单信息抽取、实体识别,阈值要往高了设,或者干脆不用语义相似度,改用要素级别的匹配。
提示:如果发现测试结果频繁在“通过”和“失败”之间横跳,先别急着怀疑模型,先看一下阈值是不是设得太紧。阈值不是越大越好,要结合技能的真实输出分布来定。
4.2 LLM-as-Judge的引入与偏见规避
有些技能没有标准答案,比如“帮用户把一段投诉内容改写得更委婉”。这种任务用语义相似度和结构化断言都很难覆盖全,这时候就需要引入LLM-as-Judge——用另外一个模型来当评审,给输出打分。
用LLM当评审是有用的,但它有自己的偏见,这个必须得防。常见偏见有三个:一是立场偏好,评分模型容易给“和自己生成风格相似的输出”打高分;二是长度偏好,越长越容易得高分,哪怕内容冗余;三是位置偏好,在多条候选输出对比时,排在前面或后面的结果会影响评分。
规避这些偏见,skillprobe的做法是:评分Prompt里明确给出评分维度,比如“信息完整性”“格式规范性”“语气恰当性”,每个维度单独打分,最后再看总分。同时把参考输出也作为提示的一部分给评分模型,让它做的是“对照参考标准来打分”,而不是“凭感觉给个分”。
另外一个细节是,评分模型最好和被测技能用的模型不是一个。虽然成本会高一点,但至少能避免“自己夸自己”的嫌疑。我自己常用一个相对便宜但指令跟随能力强的模型做评分,效果是够用的。
4.3 可复现性才是自动化的命根子
LLM技能测试最让人头大的就是今天通过了明天挂了,而且找不到原因。要根治这个问题,必须把可复现性当成一等公民。
可复现性主要靠两件事。第一件是固定采样配置。skillprobe执行用例时,会把模型名称、温度、top_p、seed等参数全部记录在测试报告里。如果你的模型接口支持固定seed,就一定要固定下来;如果不支持,那就把温度设成0,降低随机性。我在实践里发现,温度设0也不意味着完全确定,因为有些模型服务端还有其他随机因素,这时候就要靠多次执行取结果分布。
第二件是固定依赖环境。RAG类的技能要固定检索器和知识库版本,工具调用类的技能要mock掉外部API,或者记录下真实API在测试时刻的返回结果。如果不固定这些外部依赖,那你的测试结果根本没法定位问题——到底是技能本身坏了,还是检索器数据变了,还是外部API抽风了,完全说不清。
skillprobe支持给用例配置一个“依赖环境指纹”,跑测试的时候自动记录当前检索器版本、API mock版本、模型版本。这样出了问题,先看报告里环境指纹有没有变化,能节省大量排查时间。
5. 把skillprobe接进CI流水线
5.1 流水线的最小闭环设计
测试用例设计好了,指标判定逻辑也清晰了,下一步就是让它跑起来。我建议的最小闭环是:触发事件、执行测试、生成报告、结果判定。
触发事件可以根据团队情况选。最常见的是PR合并前跑,也就是代码变更时触发;另外还可以加一个定时触发,比如每天夜里跑一次全量用例,防止模型侧或者依赖侧悄悄变化导致技能行为漂移。
执行测试这一步,skillprobe会按照配置去加载技能代码、读取测试用例集、逐个执行。整个执行过程必须是无状态的,每次跑之前从Git拉取指定版本,跑完之后清理临时文件。这样才能保证测试结果可复现。
生成报告是很多人容易忽略的环节。报告里除了“通过率”,还必须有失败用例的完整上下文:输入是什么、输出是什么、参考输出是什么、哪一项断言没过、耗时多少、当时的环境指纹是什么。没有这些信息,测试挂了你也只能干瞪眼。
结果判定要严格,但也要留出人工确认的通道。我的做法是:通过率达到设定阈值就放行,比如95%;但如果低于阈值,CI就直接失败,不允许合并代码。同时配置了一个白名单机制,允许维护者手动标记某些用例为“已知问题”,避免一个长期存在的小问题卡住整个团队的发布流程。
5.2 一个可以直接抄的配置文件示例
skillprobe的配置用YAML格式组织,整体结构比较直观。下面这个是我在一个类似订单查询技能的项目里实际用的配置骨架:
project: name: order-query-skill version: "1.4.2" model: base: "gpt-4-turbo" # 技能运行时使用的模型 temperature: 0 # 低温度,优先保证可复现 max_tokens: 1024 timeout_seconds: 30 tests: dir: "./tests/cases" pass_threshold: 0.95 report: output_dir: "./reports" format: ["json", "html"] judge: mode: "hybrid" # 混合模式:结构化断言 + 语义相似度 + LLM评分 semantic_similarity: 0.80 llm_judge: model: "gpt-4o-mini" dimensions: - "information_completeness" - "format_compliance" - "tone_appropriateness" dependency_environment: retriever_version: "2024.06.01" mock_api: true几个关键参数说一下。pass_threshold: 0.95的意思是全量用例集里至少九成五的用例要通过,否则CI失败。这个值一开始不要定太高,建议先跑一周看基线,再逐步收紧。semantic_similarity: 0.80是语义相似度的全局默认阈值,个别用例可以在用例文件里覆盖。
5.3 CI流水线脚本的关键步骤
流水线脚本本身不复杂,核心逻辑就四步:装环境、拉代码、跑测试、上传报告。
# 装依赖 pip install skillprobe # 拉技能代码和用例集 git checkout <commit_id> git pull --recurse-submodules # 执行测试,指定配置文件和输出目录 skillprobe run --config ./config.yaml \ --report ./reports/ci_${BUILD_ID}.json # 检查退出码:0表示通过,非0表示未达阈值 exit_code=$? # 把报告上传到团队的可视化平台 skillprobe upload --report ./reports/ci_${BUILD_ID}.json \ --project order-query-skill exit $exit_code用skillprobe CLI的时候,有一点要特别提醒:它支持两种执行模式。一种是快速模式,只跑结构化断言和schema校验,速度快,适合PR阶段快速反馈;另一种是全量模式,会带上语义判定和LLM评分,耗时较长,适合夜间定时任务跑。
我在实际项目里就是两档并行:PR阶段跑快速模式,保证格式类问题不会流进去;每天晚上跑全量模式,把语义和稳定性问题捞一遍。这样既能快速迭代,又不会把质量底线放掉。
6. 常见问题与排查技巧实录
6.1 测试总是飘红,先查三个东西
如果你刚开始用skillprobe,跑出来的测试结果今天红一片、明天绿一片,别急着去调模型提示词,先按顺序查三件事。
第一,查用例集的参考输出是不是过期了。这是最常见的原因。技能行为已经迭代了,用例参考输出还停留在老版本,那今天通过了反而奇怪。把参考输出同步一下,问题大概率就解决了。
第二,查模型配置是不是变了。可能是技能代码里默认温度改了,可能是模型服务端把版本升级了,也可能是你从GPT-4换到了GPT-4-Turbo但没有同步更新配置。这些都算环境漂移,报告里的环境指纹一看便知。
第三,查外部依赖的状态。RAG技能要去看检索器是否加了新数据,工具类技能要看下游API是否改了返回格式。我遇到过一个问题:下游API在某个字段里多返回了一个“customer_note”,结果技能原本的字段补全逻辑没跟上,输出里把备注拼到订单号里去了。这种问题如果不固定依赖环境,排查起来会非常痛苦。
6.2 提示词改一句,二十个用例挂了十八个
这种大面积回归是最吓人的,但也是自动化测试价值最大的时刻。我碰到过一次,不过不是技能提示词的问题,而是基础模型的版本被服务商悄悄升级了,输出风格大变,导致所有依赖“固定措辞”的用例全部挂掉。
遇到这种大面积失败,第一件事是冷静看报告,不要直接改提示词。先把失败用例集中起来,看它们共同的特征是什么。如果全部和“语气”相关,那大概率是模型风格漂移;如果全部和“字段缺失”相关,那可能是输出格式变了;如果零散的挂,才需要去怀疑具体用例。
大面积回归往往意味着要调整的是“默认阈值”或者“参考输出”这层,而不是逐条修用例。skillprobe支持批量更新参考输出,但一定要走人工审阅,尤其是生成型任务,不要让测试数据自己修改自己。
注意:提示词里加任何内容之前,先想一想这个改动会不会影响输出格式或者风格。LLM技能的脆弱就在这里,加一句“你是一个亲切的客服”可能就会让JSON输出多一句客套话,直接破坏结构化断言。
6.3 并发执行时API配额被瞬间打爆
用例上百个之后,串行执行肯定等不起,于是你开并发。然后问题来了:LLM服务的API配额被瞬间打爆,测试跑一半开始大量报超时,整个流水线红掉。
这是我自己踩过的坑。解决方案是加限流,而不是无脑提高并发数。skillprobe里可以配置最大并发数和每秒请求数。建议先把并发数设成4~6,每秒请求数设成一个保守值,跑完一轮看总耗时,再逐步往上调。
还有一点,PR阶段的快速模式尤其适合开高并发,因为结构化断言不需要调用大模型,几乎不占配额;全量模式里的语义相似度和LLM评分是重头戏,并发要控制得更严格。如果你用的是按token计费的服务,建议给评分模型单独开一个便宜一点的模型,成本能省不少。
6.4 历史用例逐渐失真,测试集需要体检
用例集不是资产,用得不好就是负债。跑了一段时间后你会发现,一些旧用例的输入已经和当前业务不太匹配了,或者参考输出描述的场景已经下线了。这类用例就会变成“永久红色”,第一周你还会点开看看,第二周开始就变成“已知问题”了。
我的建议是每个迭代都做一次用例体检。看这么几类:长时间重复失败的用例、执行耗时异常的用例、输入场景已经被业务废弃的用例。该更新的更新,该标记的标记,该删的删。宁可用例数量少而精,也不要让一坨僵尸用例在测试集里发臭。
7. 实操心得与后续扩展
最后聊一点我个人在项目里摸爬滚打总结的东西。
第一个建议是,别一开始就想把全部技能都纳入自动化测试。挑一个你正在频繁迭代的技能,先用skillprobe把基线跑起来。所谓基线,就是你有把握它当前行为是对的,然后把当前测试结果保存为基线版本。以后任何改动,拿新结果和基线对比,多出来的红点就是改进方向。
第二个建议是,LLM技能的自动化测试,本质上是在建立一种“行为契约”。你给技能设定了输入和输出的预期,契约越清晰,技能就越像一个可以被维护的系统。我见过很多同事在调提示词的时候完全靠感觉,跑一遍发现输出还不错就提交了。但感觉不能成为质量保障的手段,尤其是在Agent应用开始承载真实业务数据之后。
第三个建议是关于扩展的。skillprobe目前已经能支撑单技能的端到端测试和CI集成,但它还能往几个方向延伸。一个是多模型对比测试,同一个技能用例集在多个模型上跑,看哪个模型表现得最稳定;另一个是技能版本A/B测试,新老两版技能在同一个用例集上对比,量化评估改动带来的收益或者损失。这两个方向对做Agent框架选型和技能优化都特别有价值。
如果你正在维护任何形式的LLM技能,我真的建议花一个下午把skillprobe的闭环搭起来。它能解决的问题非常明确:让技能迭代从“靠感觉”变成“看数据”。这个转变,越早发生越好。