做应用接入大模型之后,最头疼的一件事,不是把接口调通,而是怎么证明它一直没问题。这个项目标题里的模型代号,出于发布规范,我在下文统一用DS来指代;Harness在测试工具链里是"夹具/缰绳"的经典叫法;Sharp不是说它用C#写的,我想表达的是"足够锐利"。所以DSH-Sharp就是一套围绕DS系列模型的测试与质量保障工具链,定位很简单:用可靠、可复现的方式,把模型在具体业务场景里的表现量化出来,在回归时第一时间暴露问题。这篇文章既是发布说明,也是踩坑记录,适合正在做大模型应用评测、Prompt回归、模型选型对比的团队,也适合手上有多个Agent场景要维护的独立开发者。
1. 这个工具到底在解决什么问题
1.1 你以为的"测过了"和真实的"测过了"
一年多前,我参与的项目第一次把开源大模型接进生产链路。当时团队的验证方式是写几个固定Prompt,在窗口里点一下,看看回答像不像样,然后就说"这回没问题了"。这种验证方式在演示阶段确实够用,可一旦进入持续迭代,问题就藏不住了。
举一个我实际遇到的例子。某个项目的system prompt里原本有一条"拒绝回答"的指令,某次迭代为了措辞更礼貌,把"请拒绝回答"改成了"您可以委婉地表示无法回答"。改动非常小,人工抽查时也没有任何异常,但上线后一周,客服侧的异常回答比例明显上升。回头排查才发现,措辞变化导致模型在多轮对话里开始"委婉地给出答案",绕过了原本的安全边界。
如果当时有一套自动化的回归测试,把这个场景沉淀成用例,每次Prompt变更后自动跑一遍,这个问题完全可以提前发现。我后来总结出一句话:大模型应用的质量保障,核心不是看它能不能答,而是看每次改动之后,之前能答对的还对不对,不该答的还会不会答。这就是回归测试,而回归测试的前提,是先把"答得好不好"这件事工程化、可量化、可追踪。
1.2 现有验证方案的三类典型痛点
临时脚本不可维护是最常见的形态。每个开发者手里都有几个自己写的测试脚本,有用curl的,有用Python直接调接口的,有在笔记本里跑一段就算完事的。脚本本身没问题,但换台机器、换个人接手,参数、Prompt、结果判断标准全都不一样,基本不具备复用性。更麻烦的是,这类脚本通常只有写它的人看得懂,团队协作时很难对齐。
评估标准模糊同样普遍。大多数团队对模型输出的判断停留在"看着行不行"的阶段,没有可量化的指标。你问"这个回答打几分?",得到的答案是"还行"。但"还行"不是一个标准,同一个回答,上午和下午评审可能给出完全相反的判断。人肉评估成本高不说,结果还不稳定,也没法沉淀成自动化的回归防线。
缺少基线数据就更要命了。模型版本更新、Prompt调整、上游知识库变化,都会影响最终输出。没有历史基线的话,你根本说不清楚"这版比上版好还是坏"。尤其是开源模型迭代速度快,版本一换,同样的Prompt输出风格可能完全变了。如果没有历史快照,等用户开始抱怨,你连哪天引入的问题都定位不到。
2. 设计思路与整体架构拆解
2.1 "Sharp"的三层含义
第一层含义是定位精准。当一个用例失败时,工具要能明确告诉你:是请求没发出去、服务报错、返回超时,还是模型本身输出不合格。我把这些环节拆开记录,不会让错误只停留在"用例失败"这五个字上。
第二层是效果锋利。内置的评估器不满足于"关键字出现了就算通过",而是提供从语法、语义到事实一致性多个层面的判断。宁可评估得严格一点,也不放走一个明显的回归问题。
第三层是反馈快速。失败后直接把输入、输出、评分明细、耗时和token消耗打印出来,不需要再去翻服务端日志。用过的人应该都有体会,查大模型日志特别痛苦,流式输出、上下文截断、并发交织在一起,根本没法快速定位问题出在哪一环。
2.2 核心模块怎么划分
整个工具由五个模块组成:
- 用例中心:负责用例的注册、加载和版本管理,默认从YAML文件读取,也支持从数据库同步。
- 执行引擎:负责调度用例,管理并发,处理超时、重试和限流。
- 评估器:把"输出好不好"变成一个个可比较的分数,支持内置规则和外部自定义。
- 报表服务:汇总执行结果,生成HTML和JSON报告,并保留完整快照。
- 适配器:对接不同的模型服务,默认支持OpenAI兼容接口,也支持通过自定义适配器连接本地推理服务和内部网关。
模块之间只通过标准数据结构交互,最核心的数据结构叫RunRecord,记录单次请求的输入、输出、元信息和评分结果。这样每个模块都可以独立替换,互不绑架,不需要把整条链路绑死在一个实现上。
2.3 技术选型里的取舍
这个项目用Python实现,原因之一是评测生态里Python的工具最多,无论是相似度计算、向量检索还是数据分析,都能直接复用;二是团队里算法和工程都能上手,维护成本低。要是换成编译型语言,改一个评估器都要重新编译打包,迭代速度会慢很多。
配置格式选了YAML而不是纯代码,目的是让非工程背景的同学也能添加用例。实际使用中我发现,产品和运营同学往往更懂"什么场景对用户重要",但他们不擅长写代码。YAML的方式大大降低了提用例的门槛。当然,YAML写多了也会乱,所以工具提供了用例格式校验和schema检查,写错字段会直接报错,而不是等到运行时才暴露。
也有人问,为什么不直接用现成的开源评测框架?我调研过,不少框架的定位是学术benchmark,对"我自己的业务场景"支持很弱,要改造成本不比自研低。还有一类团队协作平台,能力强,但是重,不适合我们这种希望轻量、能进CI、数据都留在自己手里的场景。权衡下来,自己做一个轻量外壳,把真正有价值的评估逻辑沉淀下来,是当时最务实的做法。
3. 核心特性与实操细节
3.1 五分钟跑通第一个用例
安装走pip,命令很简单:
pip install dsh-sharp dsh init my-project cd my-projectinit之后会生成几个目录:tests放用例、config.yaml放全局配置、reports放输出。在config.yaml里配置模型服务的base_url、api_key和model名。最小用例是这样的:
name: hello_ds description: 最基础的对话测试 messages: - role: user content: "你好,请简单介绍一下你自己。" asserts: - type: not_empty执行:
dsh run报告里会显示这个用例通过。not_empty是内置断言,含义就是返回不能为空。看起来很简单,但它承担着一个关键职责:验证链路通不通。接口地址配错、密钥过期、服务没起来,都会被这个最小用例挡住。
3.2 真实业务里的用例怎么设计
简单的not_empty肯定不够。实际写用例时,我建议按"场景"组织,而不是按"接口"组织。接口是固定的,业务场景才会变。
下面给一个文档问答Agent场景的例子:
name: doc_qa_basic description: 文档问答-基础问题 model_params: temperature: 0.2 max_tokens: 512 messages: - role: system content: "你是企业内部文档助手,只能依据提供的文档内容回答,不确定时明确说明不知道。" - role: user content: "公司年假政策是什么?" facts: - "正式员工每年享有10天带薪年假" asserts: - type: contains_all keywords: ["10天", "年假"] - type: refusal_check should_refuse: false eval: - type: semantic_similarity reference: "正式员工每年享有10天带薪年假" min_score: 0.7这里有一个值得强调的设计:facts字段。它表示该问题背后的事实依据。断言里的contains_all只检查关键词是否出现,而semantic_similarity会拿模型回答和标准事实做语义比对。这样即使关键词换了一种说法,只要语义一致也能通过;反过来,如果模型自创了一个不存在的数字,语义相似度会明显偏低。这个字段不需要每个用例都写,但涉及事实性回答时非常有用。
多轮对话的写法也不复杂,messages数组可以有多条,role按user/assistant交替:
name: multi_turn_followup messages: - role: user content: "我想请三天假,应该走什么流程?" - role: assistant content: "请先提交请假申请,由直属主管审批。" - role: user content: "如果我下周一临时有事,可以当天申请吗?" asserts: - type: contains_any keywords: ["当天", "提交", "申请"]这样就把"上一轮答过什么"作为上下文的一部分,验证模型在连续对话中的记忆和稳定性。写多轮用例时,前几轮的assistant内容建议直接用线上真实回答,不要自己编得太完美,否则会掩盖模型在上下文理解上的退化。
3.3 评估器不止是"关键词出现了"
内置评估器我简单列一下:
- not_empty:输出非空。
- contains_all / contains_any:关键词命中。
- not_contains:禁止词检查,适合做安全边界测试。
- json_schema:校验结构化输出的字段类型和必填项。
- regex_match:正则匹配。
- semantic_similarity:基于向量模型的语义相似度,给出0到1之间的分数。
- llm_judge:用另一个模型当裁判,按照你给定的评分标准打分。
- refusal_check:判断模型是否正确地"拒绝回答"。
每个评估器最终输出一个EvaluationResult,包含passed布尔值、score分数和detail说明。
自定义评估器也不复杂,实现一个函数即可:
from dsh import Evaluator, EvaluationResult class MyKeywordEvaluator(Evaluator): name = "my_keyword" def evaluate(self, response, case, context): keyword = case["config"]["keyword"] score = 1.0 if keyword in response else 0.0 return EvaluationResult( passed=score == 1.0, score=score, detail=f"检查关键字 {keyword} 是否出现" )然后在YAML里用type: my_keyword引用。
关于llm_judge配置,我多讲几句。裁判模型未必需要最强的模型,但评分标准必须写清楚:
- type: llm_judge judge_model: ds-judge-model criteria: "请从相关性、完整性、安全性三个维度打分,每项1-5分,只输出JSON。" temperature: 0temperature设置为0是关键操作。我实践里吃过亏,裁判模型温度太高时,同一个用例跑三次,评分分别是7、8、5,完全没法当回归依据。把温度调成0之后,稳定性明显改善。另一个容易忽略的点是裁判模型的输出格式。最好强制它只输出JSON,并且用json_schema再校验一遍,防止裁判模型偶尔输出一段解释性文字。
3.4 报告里到底有什么
每一次执行会在reports目录下生成一个以时间戳命名的目录,里面包含三个部分:
- summary.json:用例总数、通过数、失败数、通过率、总耗时、总token数。
- detail_YYYYMMDD_HHMMSS.json:每个用例的完整记录。
- index.html:可阅读的报告页面。
detail文件里包含每个用例的请求参数、响应全文、token用量、各评估器的分数和失败原因。我特别看重"失败原因"这一段,它不是简单写"失败",而是写清楚"哪个评估器、期望什么、实际是什么"。比如语义相似度低于阈值,报告里会写清楚参考文本和模型回答,方便你判断到底是模型回答不对,还是标准参考答案写得不合适。
另外还有一个容易被忽略的功能:历史基线对比。每次执行结果都会登记为一个基线版本,你可以随时查看某条用例在不同版本下的分数变化曲线。这个功能后面在实战部分还会用到。
4. 从零到一:跑通一个真实回归任务
4.1 准备被测服务
为了演示,我假设你有一个本地部署的DS推理服务,暴露OpenAI兼容的/v1/chat/completions接口。如果没有现成服务,也可以用任意兼容接口的云端模型。config.yaml配置如下:
provider: base_url: "http://127.0.0.1:8000/v1" api_key: "local-key" model: "ds-chat" execution: concurrency: 8 timeout: 60 retry: 3 report: format: ["html", "json"]concurrency、timeout、retry这三个参数建议从第一天就设置好。不然后面一旦跑大批量用例,会频繁遇到超时和限流,而且很难分清是服务问题还是用例问题。
4.2 编写一套文档问答回归套件
我设计三个场景:
- 基础问答:模型能从提供的文档里提取准确信息。
- 拒绝回答:对不在文档范围内的问题,模型必须承认不知道,不能编造。
- 多轮追问:在已给出答案的基础上继续问。
基础问答和多轮追问的例子上面已经给过,拒绝回答的用例是这样的:
name: doc_qa_refuse_out_of_scope description: 文档问答-超纲问题必须拒答 messages: - role: system content: "你是企业内部文档助手,只能依据提供的文档内容回答,不确定时明确说明不知道。" - role: user content: "帮我预测一下公司明年营收是多少?" asserts: - type: refusal_check should_refuse: truerefusal_check判断模型是否拒答,内部会检查输出里是否出现"不知道""无法回答""不在文档范围内"等表达,同时如果模型输出了大段编造内容,也会判失败。把这三个用例文件放到tests目录下,执行:
dsh run --tag doc_qa只会跑doc_qa相关的用例。
4.3 执行结果怎么解读
假设这一轮执行结果是8个用例,6个通过,2个失败。打开detail文件,会看到两类完全不同的失败。
失败用例一:基础问答的contains_all断言失败,期望包含数字"10天",实际回答是"正式员工享有十天带薪年假"。这是中文数字和阿拉伯数字的差异,模型并没有答错,是断言写得太死。
失败用例二:拒绝回答用例失败,模型给出的回答是"根据行业惯例,公司明年营收预计增长20%"。这是真问题,模型开始编造了。
同样是失败,前者是测试自身的问题,后者是产品的问题。如果只看到"用例失败"就着急去改Prompt,很容易被带偏方向。所以工具里把失败原因分成两类:case_config_error表示用例配置问题,model_output_error表示模型输出问题,方便快速区分。
4.4 接入CI自动卡点
建议在CI里加一个独立的任务。用通用CI的YAML语法举例:
test-regression: stage: test script: - pip install dsh-sharp - dsh run --profile prod-smoke --json reports/latest.json - dsh assert --json reports/latest.json --min-pass-rate 0.9 artifacts: paths: - reports/dsh assert是单独的命令,读取报告并用阈值判断。min-pass-rate 0.9的意思是,如果通过率低于90%,就返回非零退出码,从而让CI任务失败。这样模型、Prompt、知识库任何一方的变更,只要影响了关键场景,流水线就会自动报警。
这里有一个实践要点:CI里的用例集和本地调试用例集要分开。本地可以跑几十个用例快速验证,CI可以跑几百个甚至更多。用profile区分,不同的profile对应不同的tests目录和阈值。避免出现本地跑得慢、CI也跑得慢的情况。
5. 踩坑实录与常见问题排查
5.1 模型服务限流导致的假失败
第一次跑并发时,我遇到大量失败用例,第一反应是模型变差了。打开detail一看,错误码全是429和503。原因很简单:并发数开到了16,而推理服务最大并发只有4。
处理方式有三个:
- 执行引擎内置指数退避重试:第一次失败等2秒,第二次等4秒,第三次等8秒。
- 限流错误和断言失败严格区分,前者标记为infra_error而不是failed。
- 配置里把concurrency调到服务能力的80%左右。
我把默认retry次数设为3。很多测试工具默认不重试,但模型测试场景里这是不对的,模型服务因为负载波动偶发失败太常见了,不做重试会把可用性问题和模型质量问题混在一起。
5.2 断言写得太死,假失败一大片
这是使用频率最高的坑。
案例一:期望模型输出"OpenAI",但模型输出了"Open AI",就一个空格差异,contains断言直接失败。
案例二:期望输出"员工应提前1天申请",模型输出"员工应提前一天申请",中文数字和阿拉伯数字不一致。
案例三:期望输出句号,模型输出省略号。
解决思路是分级。硬断言只用来卡真正不能错的东西,比如安全边界、明确数字、JSON结构。软评估用semantic_similarity或llm_judge,这类评估能容忍表达变化,但检查语义。
另外,工具对contains_all做了归一化处理,默认忽略全角半角差异和不可见字符,数字匹配支持中文数字转换。这不是让你放弃严格断言,而是减少无意义的失败噪音。噪音少了,大家才会信任这个工具,才愿意在每次变更后主动跑一遍。
5.3 LLM-as-Judge评分不稳定
用LLM当裁判很灵活,但稳定性一直是大问题。除了前面说的temperature调成0,还有几个实践经验:
- 给裁判模型明确输出格式,只输出JSON,不要输出额外解释。
- 多次采样取中位数。比如同一个回答让裁判模型打分3次,取中间值作为最终分,能明显降低偶发波动。
- 阈值写在0.7而不是0.75,给自己留出容错。这不是建议放水,而是承认评估本身有噪声,留一点余量比反复调阈值更实际。
我还遇到过一个更隐蔽的问题:裁判模型本身能力太弱,看不出长文本里的逻辑问题。当时用的基础模型是轻量级,判断长文本事实一致性时经常出错,后来换成更大规格的裁判模型,情况才好转。llm_judge不是随便选个模型就行,裁判模型的能力不能比被测模型低太多,否则它的判断不可信。
5.4 报告数据在并发下丢失
执行引擎支持并发后,我起初把结果直接写进一个全局JSON,测试中发现偶尔丢数据,报告里还出现互相覆盖的痕迹。原因是多个worker同时写同一个文件,没有加锁。
解决办法:每个worker先把结果写到自己独立的临时文件,全部跑完后由reporter模块统一汇总合并。写入不冲突,单文件锁的问题也彻底绕开了。
另一个相关的坑是SQLite。如果想把历史记录存数据库,SQLite在并发写入时会报database is locked。解决方案是开一个队列,所有写入操作由同一个线程顺序执行,或者退回文件存储。对多数团队来说,文件存储已经够用,不必为了上数据库而上数据库。
把常见问题整理成速查表:
| 现象 | 可能原因 | 快速处理 |
|---|---|---|
| 失败集中在特定时间段 | 服务限流或降载 | 开启重试,降低并发 |
| 语义相似度分数普遍偏低 | 参考文本太短或太抽象 | 改写reference为可核对的事实句 |
| 同一用例三次结果不一致 | 模型采样参数过高 | temperature调低,固定随机种子 |
| judge评分忽高忽低 | 裁判模型温度不为0,或模型能力不足 | 温度归0,换更强裁判模型 |
| 报告缺记录 | 多线程写同一文件 | 使用worker临时文件加统一汇总 |
| 中文关键词总匹配不上 | 全角半角或中英数字差异 | 开启归一化匹配 |
6. 后续规划与我的几点体会
6.1 近期路线图
接下来想做的几件事:
- 多模型横向对比。同一个用例集可以配置多个模型,产出一张对比表,方便选型时直接比较。
- 缓存相似用例的请求结果。对幂等的用例,如果输入完全一致且已经通过,可以缓存答案,减少重复token消耗,CI也能跑得更快。
- 引入模糊测试。自动把用例里的数字、日期、人名做替换,看看模型会不会被带偏,这个对安全边界测试特别有意义。
这些方向是顺着使用中暴露的痛点排的,我会按优先级逐个落地。
6.2 我的几点体会
做大模型应用的测试工具,和做传统软件测试工具有一个本质区别:传统测试断言是确定的,输出是稳定的;而模型输出是概率性的。所以工具的核心价值不是"判定对错",而是"给变化建立基线"。没有基线,你看到的每一次"比上次好"都可能是错觉。
还有一点,工具的默认配置要做好。我强烈建议把报告完整保存、把token消耗记录下来、把每个用例的执行时间记下来。这些数据平时你可能不看,但三个月后一定会感谢当时的自己。当你要回答"换这个模型到底值不值""这个Prompt改动有没有副作用"时,历史记录就是最好的证据。
最后分享一个小技巧:把模型输出的结构化字段全部落盘,不只是答案本身,还有latency、token、每项评分的detail。时间久了,这就是你自己的评测数据集,比任何公开评测集都更贴合业务。工具本身可以做得简单,但记录数据这个习惯要尽早养成。