开头先交代一下背景。我们团队在把 DeepSeek 系列模型接入 .NET 服务之前,天真地以为最难的环节是搞定模型调用的接口封装。真正开始写测试才发现,接口调用反而是最简单的部分,难的是“怎么证明模型输出是对的”。你没法用一个Assert.Equal(expected, actual)去校验一段自然语言,模型每次回答的措辞都可能不一样。折腾了大概三周,我们把实践中验证有效的套路提炼成了一个 .NET 工具库,名字就叫 DeepSeek-Harness-Sharp,简称 DSH-Sharp。这篇文章既是这个版本的发布说明,也是一份相对完整的上手参考,我会把设计思路、核心用法、参数取舍和踩过的坑一次讲清楚,适合已经在 .NET 里做大模型集成、或者正准备把模型输出纳入自动化测试体系的朋友。
1. LLM 应用测试为什么难:没有 harness 硬写测试的尴尬
1.1 传统断言在大模型输出面前失效
先回忆一下我们最初的挫败感。当时有一个分类场景,要求模型判断用户消息属于“售前咨询”“售后投诉”还是“其他”。模型 80% 的情况下会乖乖输出“售后投诉”,但偶尔会在后面补一句解释,比如“售后投诉,因为用户申请换货”。字符串相等断言当场就红了。这不是模型不合格,是我们用错了测试方式——拿软件的确定性子集硬套在概率模型的输出上。
接着我们试过快照对比,把第一次输出的内容存成基线,后面每次跑测试都比对差异。这个方案死得更惨。模型只要换一个同义词,把“换货”改成“退货”,diff 就是一片红。为了压制这类无意义差异,我们写了一大堆归一化正则:去掉标点、忽略大小写、折叠空白。即便如此,模型偶尔用了一个从未出现过的表达方式,快照照样报错,而且这类报错没有业务价值,看多了团队对测试结果就麻木了。
这几次尝试让我意识到一个问题:大模型应用的测试重点不应该放在“比对结果文本”,而应该放在“验证输出的行为是否满足业务约束”。约束可以是“必须包含订单号”“必须输出合法枚举值”“语义上接近参考答案”“长度不得超过三句话”。这些约束的验证逻辑,传统测试框架的默认断言器全都没有覆盖。
1.2 DSH-Sharp 的定位:给 .NET 测试生态补上语义断言层
xUnit、NUnit 这些成熟框架,负责的范畴是“如何组织测试”“如何调度用例”“如何聚合报告”,但到断言这一层,它们默认只提供数值、字符串、集合相关的确定性比较。LLM 场景需要的是语义级断言、结构约束断言、生成质量指标断言。这就是 DSH-Sharp 要补齐的位置。
DSH-Sharp 不是要替代现有测试框架,而是寄生在它们里面工作的一个“测试 harness”。硬件行业里 harness 指电路测试时用来连接仪器和被测设备的线束,可以给被测对象提供稳定供电和信号观测能力。借用这个词,DSH-Sharp 就给大模型被测对象提供了一套“发起调用、记录输入输出、执行语义断言、生成回放报告”的标准线束。你仍然用 xUnit 组织测试用例,但用例内部传话给模型、验证输出的工作都交给 DSH-Sharp。
提示:不要在项目里复制粘贴同一套“调 API → 判断关键词 → 输出日志”的测试代码。把这些逻辑集中在 harness 层里,业务测试代码会变得非常干净。
2. 三层核心设计:Runner、Scenario 与 Assertion 是怎么配合的
2.1 Runner 管理一次完整对话生命周期的全过程
DSH-Sharp 的主入口是DshRunner。它负责创建模型客户端、维护并发信号量、执行超时控制、执行重试策略,以及为每一次模型调用建立可追踪的日志记录。Runner 把一次测试视为一个Scenario场景,场景内可能有多次模型调用,比如先让模型抽取关键信息,再基于抽取结果做进一步判断。
Runner 内部保证同一个场景里的多次调用共享同一个上下文容器,前一步的输出可以直接作为后一步的输入参数。这个设计对“多阶段 Agent”类测试很重要。更关键的是,Runner 每次调用前后都会记录请求体、响应体、耗时、token 用量,把这些数据挂到场景结果上。一旦用例失败,你拿到的不是一句“断言失败”,而是一份完整的现场记录。
我们最初没有这个记录能力,遇到失败用例只能重新跑,模型输出又不可能稳定复现,排查效率极低。后来补上了上下文快照之后,很多偶发性问题只要看报告里的响应原文就能定位,这也成了 DSH-Sharp 最受团队欢迎的特性之一。
2.2 四类断言器的分类与阈值语义
DSH-Sharp 内置的断言器按能力分层,我不建议一上来就把所有断言都用成“语义相似度”,那是把武器库里的重炮当匕首用。实际场景里,按下面这个表选型效率最高:
| 断言器类型 | 能力说明 | 典型适用场景 |
|---|---|---|
| 硬性存在断言 | 检查输出是否包含指定关键词、正则模式 | 必须出现订单号、日期、JSON 字段名 |
| 格式结构断言 | 校验 JSON Schema、Markdown 结构、标签闭合 | 要求模型输出可解析的结构化内容 |
| 语义相似断言 | 基于嵌入向量余弦相似度判定语义接近程度 | 摘要、改写、开放问答类场景 |
| 阈值范围断言 | 检查长度、Token 数、置信度是否落在区间 | 控制回答长度、限制枚举取值 |
语义相似断言是这里面最需要谨慎用的。默认阈值 0.75 听起来不错,但中文本就是多义词丰富,同一意思可以表达成非常不同的句子。我们拿真实样本测过,人工判断“语义一致”的两句话,向量相似度可能只有 0.6。所以我的建议是:先跑出几十条历史样本的分布,再根据分布定阈值,不要直接信默认值。阈值定太高会频繁误报,定太低又会让回归失去意义。
2.3 报告输出与失败用例回放策略
Runner 执行完一批场景后产出DshReport,包含总通过率、每个场景的耗时、失败断言、模型调用次数、token 消耗。在本地调试阶段,用控制台表格输出比较直观;在 CI 环境里,我们一般把报告序列化成 JSON,再对接现有的报告看板。
回放策略是排查偶发失败的神器。报告里的每个失败用例都保留了请求签名,也就是当时用的模型参数、prompt 结构、配置指纹。通过--replay <case-id>可以把这一个用例单独拉出来重跑,完全复用当时的设置,同时打印详细日志。这样你不需要为了让一个失败用例复现而重跑整个套件,既省时间也省 token。
3. 从零跑通第一个 DSH-Sharp 用例:安装、配置与完整代码
3.1 安装与初始化:API Key 千万别硬编码
安装就是一条命令。项目已经发布到 NuGet 源,在解决方案目录执行:
dotnet add package DSHSharp离线环境的话,把 nupkg 文件放到内部 feed,再用dotnet nuget add source指向内部地址,然后正常dotnet add package就行。
配置阶段的第一条铁律是凭证不要进仓库。源代码里出现ApiKey = "sk-..."等于把密钥公开给所有能访问仓库的人。我们的做法是读环境变量,开发机上也可以用dotnet user-secrets管理:
export DSH_API_KEY="你的模型服务密钥"代码侧这样初始化配置:
using DSHSharp; var config = new DshConfig { ApiKey = Environment.GetEnvironmentVariable("DSH_API_KEY") ?? throw new InvalidOperationException("缺少 DSH_API_KEY 环境变量"), Model = "deepseek-chat", BaseUrl = "https://your-gateway.example.com/v1", Timeout = TimeSpan.FromSeconds(30), MaxRetries = 2, MaxConcurrency = 4 };BaseUrl没有固定值,按你的实际部署情况来:使用公有模型服务时,向服务商申请开放接口地址;私有化部署时填内网网关地址。Model 字段也一样,以你开通或者本地部署的模型名为准,示例里的 deepseek-chat 只是一个常见选项。
3.2 一个最小可用用例的完整代码拆解
现在回到前文那个分类场景。我们希望模型只输出一个合法枚举值,不允许附加解释文字。DSH-Sharp 的写法是:
using DSHSharp; using DSHSharp.Assertions; using Xunit; public class TicketCategoryTest : DshScenario { protected override async Task RunAsync(DshContext ctx) { var userMessage = "我上周买的耳机右耳没声音了,想申请换货,请问流程怎么走?"; var instruction = "请判断以下用户消息属于哪个分类,只输出一个词:售前咨询、售后投诉、其他。"; var answer = await ctx.CompleteAsync(instruction + "\n\n" + userMessage); ctx.Then("输出必须严格落在合法枚举值内", () => { Assert.True( answer is "售前咨询" or "售后投诉" or "其他", $"分类结果非法:{answer}" ); }); ctx.Then("输出不应包含解释性文字", () => { Assert.False( answer.Contains("因为") || answer.Contains("说明"), "模型输出了额外解释,不符合单枚举值要求" ); }); } }为什么不直接写一个普通测试方法然后调用 DSH-Sharp 的 API?因为DshScenario基类让 Runner 能自动感知场景生命周期,把输入输出、耗时、token 消耗全部收进报告。你只关注业务断言,调用细节全部下沉到 harness。这个用例可以直接被 xUnit 识别执行,CI 里没有任何额外适配成本。
3.3 从单用例到测试套件:批量执行与过滤
场景数量多起来以后,就要用 Runner 统一执行,而不是一个个类手动跑:
var runner = new DshRunner(config); var result = await runner.RunAsync(new[] { typeof(TicketCategoryTest), typeof(OrderExtractTest), typeof(ReplySummaryTest) }); result.WriteSummary();Runner 支持按场景名过滤、按标签过滤。我们通常给核心链路打上[DshTag("critical")]特性标记,在 CI 的快速验证阶段只跑 critical 标签,完整回归留给夜间任务。这个习惯很重要,因为模型测试是真金白银消耗 token 的,每次提交全量跑既不经济也没必要。
4. 实测中避不开的坑:超时、限流与中文截断
4.1 超时与重试配置:一组经过压测的默认参数
模型推理响应时间波动很大。我们最初设Timeout = 10s,以为大部分请求都能秒回,结果长文摘要场景频繁超时。后来把超时调到 30 秒,日志显示大部分短任务集中在 2—5 秒返回,说明 30 秒对绝大多数场景已经足够,而对超长生成任务,需要单独设置更大的值而不是全局一刀切。
超时之外还要区分“真超时”和“假超时”。假超时是指模型其实已经在服务端生成完毕,只是网络抖动导致客户端等得不耐烦主动断开。我们给CompleteAsync内置了重试机制,上限 2 次,采用指数退避,第一次失败后等待 1 秒,第二次失败后等待 4 秒。这套参数是我们在压测环境里反复试出来的,效果最稳。
注意:重试次数不是越多越好。模型接口连续报错时,重试只会加剧服务端压力,更容易触发账号级限流。重试 2 次基本是生产环境的经验上限。
重试必须配合幂等设计。好在大模型补全请求天然幂等,同一条 prompt 重发一次不会产生副作用,所以在测试工具里放心重试是没有问题的。
4.2 并发度太高会触发限流:完整的排查链路
第一次跑全量回归时,我们开了 16 个并发,结果跑到第 40 个用例左右,屏幕上开始冒出一片 429 状态码。
排查过程分三步走:
- 先把报告按时间排序,发现 429 全部集中在高并发时间窗口,初步判断是测试套件的并发请求把账号配额打满。
- 做对照验证:把并发数从 16 降到 4,重跑同一批用例,429 全部消失,定位确认为并发过高。
- 最终方案是在
DshConfig里设置MaxConcurrency,让 Runner 内部用SemaphoreSlim把请求数限制在配置值以内。
var config = new DshConfig { // 单账号配额有限时,保守一点更稳 MaxConcurrency = 4 };如果确实需要高吞吐,可以配置多个凭证,Runner 支持给不同场景分配不同的 API Key,这样能绕过单账号限制又不牺牲总体吞吐量。
4.3 中文上下文被截断导致的断言误报
这类问题最隐蔽,我们花了大半天才揪出来。有一个“商品评论摘要”场景,要求模型输出 100—150 字的摘要。有一天有一个断言突然失败,提示“摘要必须包含商品名称”。让人困惑的是,把完整响应打出来,商品名称明明在文本里,为什么断言还挂?
检查报告里的响应体,发现末尾缺少正常的结束符号,再查调用参数,max_tokens被设成了 128,而模型实际计划生成的文本更长,生成到一半被硬生生截断。截断的位置正好把商品名后的一句话切得支离破碎,语义向量计算时连累了整个相似度分数。
定位之后,解决方案分两步:第一,给场景基类里的所有调用统一设置足够大的MaxTokens,不要把预算卡得太紧;第二,在 Runner 层加了截断检测,如果响应为空或者以明显不完整符号结束,直接标记为“截断异常”并在断言执行前失败,避免脏数据污染断言结果。
5. 和 Python 生态的同类方案比,DSH-Sharp 的优势与边界
5.1 为什么不建议在 .NET 项目里引入 Python 测试进程
聊到 LLM 评测,很多人的第一反应是“用 Python 那套现成工具”。如果你的技术栈本来就是 Python,这么做没问题。但我们服务的团队是一套 .NET 单体应用,为了测试一个模型接口再往 CI 里塞一个 Python 运行时,随之而来的是一整套环境依赖、包管理与跨进程通信问题。一个小版本升级,可能让整个测试管道变得脆弱。
DSH-Sharp 的优势在于它与 .NET 测试生态的无缝衔接:普通 xUnit/NUnit 项目加一个引用就能用;失败报告的结构化数据可以直接对接现有 CI 插件;团队不需要维护两套语言技能。本质上,它把社区中一些成熟的大模型评测思路用 C# 重新做了实现,并且针对业务测试场景做了极简设计。
5.2 DSH-Sharp 目前不适合做什么
必须说清楚边界。DSH-Sharp 在 v0.1 版本里更适合做“在线验证”,也就是测试当前模型服务在具体业务场景下是否满足约束,但它不适合做“离线评测”。离线评测是指研究者拿几千条样本跑出全局指标,做模型之间的横向对比,这种需求需要更强的数据组织、批量调度与统计分析能力,我们暂时不打算塞进这个版本。
它也不支持多模态输入,对图片输入需要自己先做转换,我们留了扩展点但没有内置示例。流式输出同样不在 v0.1 的能力范围内,如果场景依赖 Token 流式返回,你需要自己处理事件流。这些问题都在路线图里,但我不希望你带着错误预期开始用。
6. 路线图与贡献方式:下一阶段想解决的问题
6.1 v0.2 计划加入快照基线管理
用了一段时间之后我发现,语义相似度阈值是会漂移的。模型服务端升级一次,同样 prompt 的输出风格可能整体变化,原先 0.75 的阈值慢慢就不合适了。v0.2 计划加入“快照基线”能力:自动记录一批历史输出的相似度分布,生成基线值,新版本跑完用例后与基线对比,超过合理波动的用例单独标记出来,而不是用一个固定阈值一刀切。这个功能可以显著降低维护成本,也是社区里呼声最高的需求之一。
6.2 如何参与:Issue 规范与本地跑测试
项目对贡献者很友好。提 Issue 的时候希望附上三样东西:脱敏后的完整配置、失败报告、最小可复现场景。这三样缺任何一样都很难定位问题,尤其是模型输出这一类问题,没有现场数据基本无从下手。
本地开发流程不复杂。克隆仓库后先跑自检测试集,它不依赖真实模型,所有用例走 Mock 执行器。Mock 执行器是 v0.1 的一个亮点,它让项目本身的单元测试完全不依赖外部模型服务,任何一次代码改动都能在几十秒内验证是否破坏了既有功能。确认全绿之后,再针对你关心的真实模型场景跑一次完整回归。
我个人的体会是,DSH-Sharp 与其说是一个“测试模型的工具”,不如说是一个“测试我们与模型交互方式的工具”。模型输出不稳定是客观现实,我们要做的是把不稳定约束在业务可控的边界内。固定阈值的断言只是第一步,随着使用场景复杂化,基线管理、对比回归、多维度质量指标这些能力会逐渐变成刚需。如果你也正在 .NET 里做 LLM 集成,欢迎拿这个项目跑一跑你们的核心场景,遇到问题带着报告到仓库里聊,后续版本的优先级会优先参考真实使用反馈来排。