做LLM应用和做传统后端最大的区别就是“你以为它学会了它却在下一次提问时换了个说法”。技能自动化测试框架这个词听起来很重实际你要解决的只是三个问题怎么稳定地定义被测技能怎么自动跑出一批用例怎么让结果可重放、可定位。我最近在一个客服助手项目里搭了一套基于skillharness的回归框架在这里把完整思路、代码骨架和踩坑记录都摊开聊一聊。这个内容适合谁正在做Agent、RAG问答、工具调用类LLM应用的同学尤其是被“人工测了半天还是漏问题”折磨的开发者。看完你可以搭出一个最小闭环并且能直接接到日常迭代里。1. 为什么LLM技能测试不能靠“多问几遍”1.1 从软件测试harness到LLM skillharness做过服务端测试的人对test harness不陌生被测模块外面包一层驱动准备好输入、执行逻辑、比对输出、汇总报告。skillharness的思路一模一样只是被测对象从“一个函数”变成了“一项LLM技能”。什么是LLM技能我通常定义为“模型 系统提示词 可用工具 交互规则”的组合。比如一个订单查询技能不是单纯“能回答物流问题”而是在用户没给订单号时主动追问给订单号后调用查询工具查不到时给出可理解的失败话术。任何一个环节改了技能都可能悄悄变化。所以技能测试的真正对象是这四类变化模型版本、提示词、工具声明、上下文规则。skillharness要做的就是把它们全部参数化让每次改动都跑同一批用例结果可对比。这也是我选择用harness而不是一堆散落脚本的原因散落脚本跑完只能安慰自己harness能留下可回归的记录。1.2 传统自动化脚本为什么不够用以前我们写过不少“调接口断言字段”的自动化用例这套逻辑直接套到LLM上会卡住。因为接口返回是结构化的LLM返回是自然语言。同样是告诉用户订单号缺失模型可能说“请问您的订单号是多少”也可能说“麻烦提供一下订单编号”还可能说“没有订单号我没法查”。这三句话业务含义等价但字符串断言会误杀。更麻烦的是工具调用。传统脚本只需要看最终回复技能测试必须看中间行为。比如客服技能配了一个query_order工具模型如果因为用户没给单号就拿着空参数去调工具最后回复再礼貌也是错的。这种错误用“是否包含订单号”这种断言完全查不出来。所以我在skillharness里把断言拆成三层文本层看关键事实是否出现工具层看该不该调用、参数对不对状态层看多轮对话里的意图是否被正确承接。这也是它与传统测试框架最大的分水岭不能只判最终输出必须把“行为过程”当作可断言对象。2. skillharness的组成与设计思路2.1 四个核心模块技能声明、用例描述、执行器、断言器我设计的框架核心就四个模块少一个都转不起来技能声明负责描述被测对象的静态信息。包括模型ID、temperature、超时、系统提示词、工具列表、最大工具调用轮数。它相当于被测系统的“配置单”。用例描述只做一件事给出一段会话输入以及一组期望。期望分三类一是应该出现的事实二是禁止出现的话术三是工具调用层面的预期。执行器是中间件。它的职责是组装请求、调用模型服务、解析工具调用结果、处理重试和超时最后把完整的执行轨迹交给断言器。断言器是最容易低估的部分。在早期版本里我只做了“包含关键词”和“JSON全等校验”上线后误报率特别高。后来改成多策略组合文本用事实抽取和语义容错工具参数用宽松校验才把误报压下来。这四个模块的分工如果模糊框架就会退化成“一个能跑LLM的脚本集合”。职责清晰的好处是你可以只换断言策略不用动执行器只加新用例不用改框架代码。2.2 测试用例怎么写才稳定写技能测试用例和写文章用例完全是两回事。我总结的三条原则是期望写条件不写全文事实分必须项和禁止项同一场景准备多种等价表述。拿前面说的订单查询举例。错误写法是期望模型回复“请您提供订单号”因为模型换一种方式表达就会误报。正确写法是期望“回应中应包含请求订单号的意思”并且“不应调用查询工具”。这样只要模型表达了追问意图不管怎么措辞都能过。另一个容易被忽略的是“禁止项”。有些话术看着没问题但会对业务造成风险。比如网络异常时客服技能应该说“稍后重试”而不是直接编造“物流已签收”。我把这种情况写成forbidden_facts只要输出里出现相关表述直接失败。这类用例比正向用例值钱得多因为它能抓住模型幻觉。多等价表述我也强烈建议加。同一个请求意图上午十点和晚上十点的问法不一样不同用户的口语习惯也不一样。一个技能如果只对标准问法稳定那在真实场景里大概率翻车。我在每个核心技能上至少准备三组等价问法跑通后再上生产。2.3 为什么选JSON/YAML做声明式配置一开始我也想过直接用Python函数定义用例写起来灵活但维护门槛很高。非技术人员想加一条用例看到函数就打退堂鼓。后来全部改成YAML声明式配置一个明显的改善是产品人员也能看懂并补充边界case了。声明式配置还有一个隐藏优势可diff。改了一版提示词后我能在Git提交记录里看到“这个用例的期望从必须调用工具改成了禁止调用工具”。这种变更历史在复盘模型行为漂移时特别有用。当然声明式配置有个代价表达能力受限。所以我在框架里留了一个字段叫pre_hook和post_hook允许在某些用例上挂Python回调。比如测试前先往mock数据库里塞一条订单记录测试后再清理。核心流程用配置特殊逻辑用钩子两者结合才不别扭。我还给配置文件写了schema校验。YAML格式虽然好读但写错一个缩进或字段名就会静默失效。上线校验后至少减少了一半“用例没跑以为是失败”的情况。3. 从零搭建一套可复用的技能回归框架3.1 目录结构与最小可运行case实际项目里我习惯把“技能”和“框架”分开。技能目录管业务资产harness目录管执行能力。这样换项目时只拷贝技能配置和用例框架不用动。一个可运行的目录结构大概长这样skills/ order_query/ skill.yaml cases/ missing_order_id.yaml happy_path_with_tool_call.yaml network_error.yaml harness/ runner.py assertor.py reporters.py config.py reports/ latest/skill.yaml长这样name: order_query model: your-llm-model-id temperature: 0 timeout_sec: 30 max_tokens: 1024 max_tool_rounds: 3 system_prompt: | 你是订单客服助手。用户可以查物流、查预计送达时间。 必须遵守 1. 用户不提供订单号时先追问订单号不要调用任何查询工具。 2. 查不到订单时明确告知原因不要编造物流状态。 3. 保持简洁不暴露内部工具名称。 tools: - name: query_order description: 根据订单号查询订单物流状态 parameters: order_id: type: string required: true对应的case文件id: missing_order_id description: 用户没有提供订单号时技能应追问而不是调用工具 conversation: - role: user content: 我的订单一直没到帮我查查。 expected: should_call_tool: false required_facts: - 订单号 forbidden_facts: - 包裹已签收这个case最核心的断言是should_call_tool: false。只要模型调了工具不管回复多么礼貌都得算失败。这种“行为断言”用传统接口测试思维很难想到但它恰恰是Agent类应用最关键的质量防线。3.2 关键代码执行器与断言器执行器没什么魔法就是把配置翻译成一次模型调用。# runner.py简化版 def run_case(skill_spec, case, client): messages [{role: system, content: skill_spec[system_prompt]}] for turn in case[conversation]: messages.append({role: turn[role], content: turn[content]}) resp client.chat.completions.create( modelskill_spec[model], messagesmessages, temperatureskill_spec.get(temperature, 0), toolsskill_spec.get(tools_schema), timeoutskill_spec.get(timeout_sec, 30), ) tool_calls parse_tool_calls(resp) return { text: resp.choices[0].message.content, tool_calls: tool_calls, token_usage: resp.usage, latency_ms: resp.metrics.get(latency_ms, 0), }注意这里我额外返回了latency_ms和token_usage。因为技能质量不只是“答得对不对”还有成本和速度。模型升级后如果正确率持平但token消耗翻倍这个信息必须暴露出来。断言器是框架里最需要打磨的部分。我的实现里有个基础版本# assertor.py简化版 def check_expectations(actual, expected): failures [] if expected.get(should_call_tool) is False: if actual[tool_calls]: failures.append(不应调用工具但实际调用了) else: if not actual[tool_calls]: failures.append(应调用工具但实际未调用) else: for param, required_value in expected.get(required_params, {}).items(): if param not in actual[tool_calls][0][arguments]: failures.append(f缺少参数: {param}) else: actual_value actual[tool_calls][0][arguments][param] if not values_match(actual_value, required_value): failures.append(f参数值不匹配: {param}) if expected.get(required_facts): for fact in expected[required_facts]: if fact not in actual[text]: failures.append(f缺少关键事实: {fact}) return failuresvalues_match我建议优先做“归一回退比较”而不是全等。比如用户输入“ORD123”模型参数里规范化成“ord123”大小写不同但业务含义一致。我会把两边的值都转小写、去掉首尾空格再比较必要时做模糊包含。3.3 接入真实模型前的dry-run这个建议很多教程不提但它帮我省了不少钱和时间。在框架里加一个dry-run模式用mock client代替真实模型。mock client不产生真实调用只是按case的expected字段返回一个预设结果用来验证执行器、断言器、报告链路是否正常工作。python -m harness.cli --skills order_query --source mock --report ci跑通mock模式后再切到真实模型源跑一遍。这样分两步的好处是框架崩溃时你不会浪费测试额度报告的链路问题也能第一时间暴露。不要在框架还没调通时就烧真实token那等于拿钱调试自己的代码。真实模型源跑起来后我还会开一个缓存的开关。对于一些幂等且不敏感的中性case结果缓存一天重复跑同个commit时直接读上次结果。生成类场景强烈不建议开缓存会掩盖随机性问题。4. 我最常踩的五个坑4.1 非确定性测试同一输入两次结果不同LLM的非确定性是技能自动化测试最大的干扰项。一个case昨天过了今天挂你查半天代码没发现问题最后发现是模型本次输出里把“订单号”写成“单号”。不是断言错了是模型本身就带概率性。我对此做了三件事。第一所有用于回归的技能temperature设为0或接近0降到最低。第二对文本类断言保留容错用语义相似度而不是字符串全等。第三对边界场景的case跑三遍取稳定结论三次中两次通过就标为“弱通过”并人工复核。还有一个容易忽略的点网络重试可能触发模型重复返回同一个回复看起来像稳定其实是重试机制在兜底。执行器里要对重试导致的重复内容做去重不然报告里的耗时和token口径都是错的。4.2 工具调用参数断言太严格导致误报最早我写工具参数断言时用了JSON字典全等结果模型只要多返回一个可选参数就全红。后来想明白工具调用断言的目标是验证“关键参数齐全、值合理”不是验证序列化格式一致。现在的校验策略分两层第一层检查必填参数是否都存在第二层检查必填参数的值是否来自用户输入里可推断的信息。比如用户说“订单号是12345”模型调用query_order时order_id字段就应该是“12345”我会做包含关系比对。如果模型把订单号和另一个字段搞混即使两个字段都存在也能被抓住。另外某些模型会在未得到明确授权时把时间戳填进必填参数。这种错误很隐蔽普通文本断言根本发现不了。所以我给断言器加了一个“禁止参数来源”选项可以指定某个参数的值不能与用户未提到的信息一致。这个反模式非常有用。4.3 上下文污染测试之间互相影响有一次整个技能测试集挂了大半排查后发现是执行器内部复用了同一个messages列表上一条case里用户输入的敏感内容残留在上下文中导致下一条case反而“答得更准确”。这种污染非常阴险因为它会让测试结果虚高很容易被误读成“技能变好了”。解决办法很简单每跑一个case都重新构造messageslayer之间不共享可变对象。如果你用mock数据库还要确保每个case的数据状态互不干扰。比如一个case预置了订单记录另一个case没预置两者的查询结果会完全不同不能混着跑。还有一个隐蔽的上下文问题是系统提示词里的动态字段。比如把用户所在地拼进system_prompt测试结束后忘记清理下一条case可能继续沿用错误的用户地区。我后来加了“技能运行快照”每次执行都记录本次的system_prompt、工具声明、模型版本才算彻底可回溯。4.4 长超时和token成本失控技能测试和普通接口测试不一样一次测试可能触发多轮工具调用。模型在复杂场景里可能连续调手段一轮两三秒几轮下来就超时了。如果执行器没有设超时整个CI任务会一直卡在那里。我在skill.yaml里加了max_tool_rounds限制每一轮最多三次工具调用。超过次数直接标记失败因为这个技能大概率陷入了死循环式“思考”。执行器层面再设一个总超时比如60秒超过就终止避免拖垮整个任务。token成本问题也要提前控制。同一个case在低版本模型上可能消耗500个token升级后变成2000个。我在报告里会输出每条case的token使用量并设置cost_warning阈值。超过阈值就标黄虽然不阻断回归但会提醒团队这次改动有成本风险。别小看这一步LLM自动化测试跑多了之后费用是真的会失控的。4.5 报告只有pass/fail没法定位问题最早我图省事只打印一行case名加PASS/FAIL。跑完十个人去看根本不知道模型当时到底说了什么、该断言哪里没过。这等于白跑。后来我把报告改成结构化输出每个用例至少包含输入会话、完整输出、工具调用JSON、失败原因列表、耗时、token、模型版本。失败原因必须是断言器给出的具体说明不能只是“assert false”。比如“缺少关键事实: 订单号”一眼就知道是模型没追问。再进一步我会把每次回归的reports目录打上git commit id这样prompt改动后出现的新失败可以直接对比前后两次报告找出是哪条用例从绿变红。这个能力在排查模型升级后的行为漂移时特别有价值。5. 把技能测试嵌进日常迭代里5.1 最小可用CI流程很多技能测试跑不起来不是框架不行而是接入点不对。一开始不必全量跑先挑两个核心技能接进CI逐步扩大。我的流程分三段。第一段是提交前本地跑目标技能的dry-run确认语法和断言配置没问题。第二段是合并请求阶段跑变更技能的完整case集mock源为主、真实源抽查。第三段是每日定时任务跑全部技能的回归并把结果发到团队看板。CI命令里我加了一个--changed-skills参数通过git diff自动找出哪些技能的prompt或case变了只跑这些。这是成本控制的关键否则一个几十条case的全量回归每次提交都跑会非常烧钱。python -m harness.cli --changed-skills --source real --report ci跑完如果出现fail我会先看失败原因列表区分“断言边界问题”“模型飘了”“配置写错”三类。前两类都是正常的第三类才是框架bug需要优先修。5.2 prompt变更时如何快速回归提示词改动是最容易出问题的操作。很多人只改一句话结果客服技能从“追问订单号”变成“直接瞎猜”。我在每次prompt变更时都会做一轮前后对比。操作上我会先把当前主分支的case跑一遍作为baseline再切到改动后的分支跑同一批case然后对比两份报告的矩阵哪些case从过变成不过、哪些从不过变成过。重点关注从过到不过的变化尤其是那些看起来无关的case。比如你只是优化了“礼貌度”结果工具调用频率变了这种链式影响必须靠回归暴露。如果条件允许我会同一组case跑两次候选版本确认不是模型随机性在捣乱。这也再次说明语义断言阈值不能卡得太死否则误报会淹没真实回归问题。5.3 模型升级前必跑的基线比较模型升级是最容易被忽视的回归场景。你线上用的旧模型已经稳定新模型跑几个示例看起来更好但全量技能回归可能藏着一堆隐性衰退。我在升级前会跑一次完整的量化对比pass rate、平均耗时、平均token、工具调用失败率。其中工具调用失败率我特别看重。有些模型文本能力提升很多但function calling的姿态变了参数经常丢或者结构不对。这个必须通过工具调用断言来抓。我会把新模型的case结果导出成一份“升级评估报告”里面列清新模型带来的失效case。如果核心技能有超过5%的case从过变fail即使新模型文本更像人话我也会坚持不上线。模型升级后还要重新跑一次成本评估。新模型便宜但可能更啰嗦每个case多出两三百token十亿次调用的总成本差异就很大了。skillharness的token统计在这里能直接提供决策依据。5.4 继续把技能测试做成数据资产框架跑起来之后case集本身就是资产。我习惯把每个case打上业务标签比如“追问”“幻觉风险”“工具调用”“多轮澄清”。当出现线上客诉时我会先看这个业务标签下有没有对应的回归用例没有就补一条。这样测试集不是一次性的而是随着问题不断长大。后来又给harness加了简单的sqlite存储把每次回归的报告都入库。这样想查“某个case什么时候开始挂”一条SQL就能拉出来。不用每次都翻Git历史和手工对比报告目录。我会继续往这个方向扩展的方向有两个一个是引入embedding相似度作为语义断言的一种选项另一个是把prompt版本管理直接接进harness配置里。整体来说skillharness这套思路不复杂复杂的是你要接受“测试结果天然带概率性”然后围绕这个概率性设计容错和对比机制。先把两三个核心技能跑起来比一开始追求全量覆盖更有价值。
阅读完成 · 觉得有帮助?