
把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的闭环搭起来。它能解决的问题非常明确让技能迭代从“靠感觉”变成“看数据”。这个转变越早发生越好。