把 2,082 个测试守着的 Python 语音智能体搬进 C#,怎么证明「搬对了」

前一篇聊了 speech-to-speech-dotnet 怎么把「东拼西凑」改成「一条命令开聊」,评论区有朋友问:这个项目最硬核的部分到底是什么。不是管线,不是协议,是那些占了仓库小半篇幅的账本和语料。

这篇文章认真拆一遍。先给结论:这个仓库最值钱的东西不是四万行 C#,而是一套回答「两个不同语言的程序,凭什么说它们行为一样」的完整方法。这套方法对任何做移植、重写、重构的人都适用,哪怕你一辈子不碰语音。

项目是什么,三十秒版

huggingface/speech-to-speech 是一个跑在本地的低延迟语音智能体:VAD 切话、STT 转写、LLM 生成、TTS 出声,四段级联,对外讲 OpenAI Realtime 协议(WebSocket 和 WebRTC 双传输)。speech-to-speech-dotnet 是它的 C# 行为等价重写,.NET 10,116 个源文件,src 加 tests 约 4 万行。

注意措辞:行为等价重写,不是移植。README 第一行就把验收标准写死了------「可观察行为与上游一致,而不是源码相似」。

这句话说起来容易。难的是:行为一不一样,谁说了算?

第一难:把「上游」变成一个可核对的东西

移植项目最常见的死法是边移植边跟着上游跑,最后不知道自己对的是哪个版本。这个项目的第一本账 porting/00-reference.md 就干一件事:把参照物钉死。

钉到什么程度:上游锁定在 commit 0835002(2026-10-22 的 main,pyproject 版本 1.1.0),然后用脚本对上游整棵树算指纹,385 个文件的哈希落进 reference-lock.json。从此「上游行为」不是一个模糊的印象,是一份可以 check 的文件。

这本账里还有一个我很佩服的区分:参照实现、运行时来源、同域参考,三个概念分得清清楚楚。

  • 行为基线只有上游一处,别无分店;
  • ManySpeech、TensorSharp、ElBruno.QwenTTS 这些 NuGet 包是「运行时来源」------代码依赖它们,但它们不参与定义行为;
  • ElBruno.Realtime、tryAGI.WebRTC 这些是「同域参考」------只回答「C# 生态惯用的工程形态是什么」,明确标注「不取行为语义」。

账里甚至记了一条纪律原文:「当本仓库的形态与上游行为冲突时,上游为准」。一句话,杜绝了无数「我觉得 C# 这边这么写更优雅」引发的悄悄走样。

顺手还立了两条工程纪律,后面会反复出现:预发布包不进默认构建 、依赖只用 NuGet 正式版。NAudio 的 macOS 包只有 preview,宁可绕路 PortAudioSharp2(上游 sounddevice 底层就是 PortAudio,同引擎对齐)也不引预览版;SIPSorcery 官方那个 OpenAI Realtime 客户端因为带 BY-NC-SA 非商业附加限制,被明确标注「不可作依赖,仅形态参照」。

这种洁癖,是做严肃移植的基本功。

第二难:上游 2,082 个测试,不能照抄

第二本账 01-baseline.md 处理的问题:把上游行为变成可执行证据。

上游 tests/ 目录有 119 个测试文件、2,082 个测试函数。照抄一遍?不行,也没意义。账里把 2,082 个函数逐类过了一遍,做了明确的取舍:

  • 各后端内部行为的 633 个------C# 侧后端走统一契约或根本没运行时,照抄等于替别人的库写测试,不搬;
  • WebRTC 相关的 588 个------功能移植了,但 pytest 不照抄,改用 C# 表征单测加 aiortc 跨实现冒烟对拍;
  • GIL、torch 线程数这类 Python 运行时特有的 30 来个------直接判不适用。

最后留下来的是「跨语言边界上可复现的语义」:41 个测试文件、518 个方法、参数化展开后 713 例。数量不对称是刻意的,每一项不搬的都写了原因和归属的差异编号。

第三难:单测锁不住的,让两个实现互相抓

这是整个仓库我最喜欢的部分:差分语料。

单测有个先天缺陷------用例是人写的,人写用例的时候会带上自己对行为的理解。如果理解本身就是错的,测试全绿也没用。解法是:语料只能由参照实现生成,不能手写。

具体做法是探针对拍。同一个行为,写一对探针:Python 探针调上游的真实代码,C# 探针调本实现的代码,两边各自对同一批输入输出同形 JSON,然后逐字节比对。六对探针,六类语料,共 115 条:

语料 守的行为 条数
text 文本规范化与分句 15
protocol 26 类服务端事件的线路形状与 JSON 编码 43
turns 投机轮次状态迁移(虚拟时钟逐值比对) 13
lang 助手输出语言识别 15
smart-turn 端点判定到宽限/延迟的映射 13
latency 响应时延账与 metadata 字符串 16

轮次状态机那组最夸张:上游的 SpeculativeTurnTracker 在虚拟时钟下跑,每一个时钟刻度两个实现的状态逐值比对。metadata 那组是逐字符比对,连日志行的格式都算行为。

这套东西不是摆设。账本里老老实实记了它对拍抓出来的七个真实缺陷,挑三个感受下:

  1. JSON 编码差了十万八千里 。C# 默认的 JavaScriptEncoder 会把中文转成 \uXXXX,base64 里的 + 转成 \u002B;上游走 Starlette,不转义。单看各功能都对,逐字节一比,全露馅。修法是换 UnsafeRelaxedJsonEscaping。
  2. 转写完成事件缺 usage 字段。这不是少个统计数字------官方 schema 里它是必填,严格客户端会把整条事件拒收。
  3. 助手消息项的生命周期整段缺失 。output_item.added 开项时 content 必须是空数组、content_part.added 两种模态各有形状、done 要按 output_index 排序......官方客户端建不出消息项,就是连得上、用不了。

这七个缺陷的共同点:单元测试全绿,对拍全红。没有语料这层网,它们会一直绿到用户骂街。

差异不是耻辱,不记账才是

第三本账是 differences.jsonl,机器可读,75 条差异:已解决 58、已接受 17、缺口 0。

注意「已接受」的定义:已知、刻意接受,不是还没做。每条差异五个要素------是什么、为什么、怎么决定、拿什么测试锁、状态。编号分 D/C/E 三族,文档里引用必须写账本形态,还有脚本 check_doc_sync.py --strict 守着四本账的正文与登记表一致。

几条代表性的:

  • D010 :上游 31 个后端名字,C# 生态多数没有对应运行时。做法是 32 个名字(含 C# 专属的 tensorsharp)全部保留注册,11 个可用,21 个选了就抛 BackendUnavailableException 并附原因------名字留着是协议兼容,报错带原因是用户体验,不静默降级是底线。
  • D001:Qwen3-TTS 在 CPU 上整句合成,被打断的那一轮仍会合成完(实测 82 到 104 秒),下一轮首包被顶住。补偿手段(按句切分加代际检查)写了,消不掉的原因也写了。账本就挂在那,谁也别装没看见。
  • C005 :唯一一条「刻意改进」。上游分句用 Punkt 的英文模型,中文标点完全不切,一段中文回复的音频会拖到最后一刻才出声。C# 侧改成按全角标点切。分歧也要记账,语料相应加了 split: false 开关:中文用例只比两个纯函数,切分这个分歧单独用单元测试表征。

代码里那些「为等价付出的努力」

翻源码的时候有几个地方值得单独说。

连接的并发模型 。RealtimeConnection 一条连接拥有六条管线线程:VAD → STT → Notifier → LLM → 输出处理器 → TTS,加一个读循环一个写循环。队列纪律三条:音频解码后重采样到 16 kHz、按 512 样本分块进 VAD;VAD 和转写事件走文本队列,不等 TTS(转写不能因为合成慢而卡住);响应事件和音频共用 TTS 输出队列,保住模型原始顺序。打断靠 CancelScope 做代际取消------新一轮说话产生新代际,旧代际的在途合成和生成被静默丢弃。

一行注释里的血泪 。会话 ID 是 session_ 加 32 位十六进制,这个形态贯穿三处客户端可见面:session.created 的 id、WebRTC 的 Location 头、DELETE 挂断的路径参数。代码注释原文:「不要改成别的形态------客户端可能按前缀过滤」。这种注释就是账本精神渗进代码的样子。

最 geek 的一笔:PythonJson.cs 。浏览器 demo 的服务端从 Python 移植成 ASP.NET Core 时,作者发现 pydantic v2 的 422 校验错误要把原始 JSON 的怪癖原样回显------NaN 和 Infinity、int 和 float 的区分、大整数、浮点 repr 的 1.0 与 1e+16。于是这个文件按 CPython json 模块的语义重写了一遍解析和序列化,连错误消息的位置都复刻。然后用对拍验证:起 Python uvicorn 和 ASP.NET 双侧,同批 45 条请求比对 status、content-type、body,零差异。

连 NoWarn 都要解释。验收账里列了全局抑制的四个编译警告,每条都有理由。比如 CA1031(宽泛 catch)------它和上游「异常只记录不终止管线线程」的语义直接冲突,抑制是有意识的决定。原话是「抑制是有意识的决定,不是疏漏」。连 warning 都在记账,这项目的强迫症程度可见一斑。

诚实的部分:什么没锁

这套体系最难得的是它对「锁不住的东西」同样诚实。

音频层不做样本级比对------模型推理受浮点累加顺序影响,两次跑都不一样,所以只锁「采样率、声道、位深、段数、时长误差带」这五样,不锁样本值。

验收账里单列了一张「未覆盖不等于跳过」的表:真人麦克风的听感(音色、全双工回声)没系统测过;Linux 和 macOS 真机没实跑;流式 STT 的真实服务端联调手册写好了,等 GPU。单元测试 12 秒跑完、语料比对百毫秒级、不依赖模型文件和网络------所以能进 CI;进不了 CI 的那些,就写明为什么进不了、风险多大。

「热态改善不等于启动改善」这种句子在这个仓库里到处都是。看多了吹牛皮的 README,再看这种每报一个数都要补一句「先别全信」的写法,你反而会信他给的每一个数。

这套方法的通用价值

把账本体系抽象出来,其实是四句话:

  1. 锁参照物。你对着哪个版本说话,用指纹钉死,不许漂移。
  2. 建基线。把参照物的行为变成可执行证据,测试不够就造语料,语料只能由参照物生成。
  3. 记账。每一条不一致登记五要素,状态流转有纪律,「刻意接受」和「还没做」是两回事。
  4. 验收写体检报告。不写「我感觉挺好」,写测了什么、没测什么、没测的风险有多大。

跨语言移植是这套方法最硬核的试炼场,但它对任何「新系统要替代旧系统」的场景都成立------重写核心服务、换框架、迁数据库,本质上都是同一个问题:你凭什么说新东西和旧东西行为一样?

大多数项目的答案是「测试都过了」。这个项目给的答案是:测试是四类证据里的一类,语料、账本、验收各管一段,加起来才叫证据链。

四本账全文都在 porting/ 目录里,方法是开源的,拿去用不用客气。

相关链接