跳到正文
Arion
博客

工程

当演示总是成功:我们为什么像做实验一样做语音输入法

当大模型和编程智能体让写代码和做演示变得廉价,真正稀缺的是证伪。从三次与直觉相悖的实验讲起,看 Arion 如何用留出集、消融实验和成文的规范避免自欺。

假设你正在给同事张伟口述一条消息。就在你说话的时候,电脑屏幕上刚好停着另一个人发来的聊天窗口,那个人叫张巍。两个名字拼音完全一样。

为了让本地语音输入法少写错人名和术语,我们给润色模型接上了屏幕上下文:它会用 OCR 读取当前窗口里的文字,交给本地语言模型参考。第一版跑通只花了几个小时,拿几句测试话一试,原本会写错的专有名词都被顺利修正了。

如果当时只看这几句演示就上线,这个功能会在两个方向同时出问题。

在参数量更大的 Qwen3 4B 模型上,它看到了屏幕上张巍发来的消息,直接把你口述里关于张伟的句子替换成了屏幕上的内容,而当时的输出校验器放行了这次替换。而在我们默认上线的 Qwen3 1.7B 模型上,打开屏幕上下文、光标上下文和历史记录之后,它在 30 条留出测试里通过了 9 条。把所有上下文全部关掉,它同样通过 9 条。1

屏幕上的聊天窗口、关于张伟的口述,以及被 Qwen3 4B 替换后的结果
促成校验器改动的那条反例:Qwen3 4B 把屏幕上张巍发来的消息抄进了关于张伟的口述,校验器予以放行。给上下文抵扣额度加上限后,同样的改写会被拒绝,口述原样保留。

换句话说,这个在演示里很顺畅的功能,在大模型上会改写说话人的本意,在默认模型上则没有产生任何作用。

三次直觉与数据相悖的实验

Arion 是一个运行在本地的语音与拼音输入法。按住快捷键说话,中英文混杂的语音在本地完成识别,经可选的本地语言模型润色(比如把口头改口“周三,不对,周四”整理成“周四”),再输入到光标位置。

做这样一个产品,从来不缺听起来合理的想法。张伟和张巍的例子只是数据第一次否定我们的直觉。为了弄清楚润色到底该读取哪些上下文,我们没有停留在几句演示上,而是先冻结了 30 条留出集(held-out set),其中包含 7 条反例:干扰窗口、屏幕上伪造的提示词注入、像张伟和张巍这样的同音人名,以及无关的历史听写。随后我们把三种上下文拆开,逐一做消融实验:1

  • 光标周围的文字在这组用例里带来的提升是 0。
  • 最近的历史听写只多通过 1 到 2 条。
  • 真正起作用的是屏幕文字。 配合 macOS Vision 的高精度 OCR(中位耗时 107 ms),它在 4B 模型上多通过了 5 到 6 条,大多是窗口里出现的专有名词。如果换成 30 ms 的快速 OCR,因为无法识别中文,会少通过 2 条。
  • 决定上限的是模型体积,而不是上下文来源。 1.7B 模型不管接收哪种格式的上下文,得分都保持在 9 条;只有换到约两倍延迟和内存的 4B 模型,得分才从 9 条升到 16 条。
Qwen3 4B 与 Qwen3 1.7B 在有无各类上下文时的留出集通过条数点图
留出集 30 条中,不给与给各类上下文时的通过条数。4B 的提升几乎全部来自屏幕文字;默认上线的 1.7B 即使打开全部来源也几乎不动。4B 各行为提取术语模式,1.7B 一行为第一轮结果。

那条张伟变张巍的失败用例,促使我们在校验器里限制了上下文拼写能抵扣的修改额度,使上下文无法再替代听写的主体内容;同时把整段传入上下文改为只传入提取出的术语列表,将 4B 的中位耗时从 1.84 秒降到 0.86 秒。也是因为消融实验表明 1.7B 会忽略上下文,我们没有在默认档位上开启一个只消耗算力却不改善结果的功能。

第二次实验同样违背了直觉。

语音识别难免会把发音相近的词听错。一个很自然的设想是:既然语言模型理解上下文,如果我们把发音线索也一并提供,比如拼音、从原始音频提取的国际音标(IPA),或者识别器输出的 token 置信度分数,它是不是就能修正听错的词?

我们在 154 条语音用例上测试了 6 种纠错条件,共 924 次实测生成。为了有明确的参照,我们在表格里加入了一行完全不使用语言模型的对照组:换一个离线声学模型,对同一段音频做一次二次解码。2

条件中文 CER英文 WER中英混合 MER
原始流式识别8.17%7.63%36.39%
离线声学二次解码(对照)5.82%3.97%22.10%
仅用文本纠错(Qwen3 1.7B)8.42%7.53%36.39%
文本 + 识别器 token 分数85.77%39.27%231.27%
仅用音频 IPA 音标314.73%334.69%275.20%

看到最后两行时,第一反应往往是统计脚本写错了:错误率怎么会超过 100%,甚至达到 300%?

因为评测规则不允许把错误率截断在 100%,也不允许在模型输出异常时回退成原文。把原始的 log-softmax 置信度分数直接传给未经专门适配的 1.7B 模型后,模型开始重复输出、原样吐出提示词里的字段名,甚至照抄示例,直到触及 256 个 token 的长度上限,把原本完全正确的 60 句听写改错了 26 句。即便是表现最稳定的“仅用文本纠错”,改善也微乎其微,还在 4 条纯静音用例上全部输出了一对引号,而不是空字符串。

再看对照组:用离线声学模型对音频做一次二次解码,计算中位耗时只需 0.13 秒,三项错误率全部下降,改善了 60 句,且没有改坏任何一句原本正确的听写。如果没有这一行对照,我们很可能会对着文本纠错的微小变化继续调整好几周 prompt,去处理一个声学模型花十分之一时间就能解决的问题。

各纠错条件下变差与变好的句数,以及改坏原本正确句子的数量
154 条测试语音中,各条件相对原始流式识别变差和变好的句数,以及 60 条原本正确的句子被改坏了多少。只有不用大模型的对照组改善多于损害。

到了第三次实验,我们意识到公开方案里的数字同样需要亲自复现。

在拼音输入法一侧,社区里有一个公开的 Rime 大模型重排插件,介绍中提到可以用本地小模型对候选词重排序,延迟在 1 ms 左右。我们保留它的原版 Lua 脚本,接上本地 oMLX 运行的 Qwen3.5-0.8B,在 100 个真实的 Rime 候选词快照上做了一次复现。3

结果首选准确率不仅没有提升,反而从 100 条中对 56 条降到了 45 条。它一共救回 3 条,改坏了 14 条:因为 Rime 第一页候选里混有完整词和只消耗部分拼音的单字前缀,模型频繁把单字前缀排到第一位,导致日常输入里的“朋友”变成“彭”,“学习”变成“学”,“电脑”变成“点”。

那“1 ms 延迟”又是怎么来的?实测中,每次模型请求的中位墙钟时间(wall time)是 288.85 ms。查看上游代码后才找到原因:脚本使用了 Lua 的 os.clock() 记录耗时。这个函数返回的是当前进程的 CPU 时间。当 Lua 脚本阻塞在 socket 上等待模型服务返回时,Rime 进程处于休眠状态,CPU 计时器几乎不走(中位数 0.787 ms)。日志里记录着不到 1 毫秒,而打字的人实际等待了将近三分之一秒。

一次重排请求的时间线:模型服务生成期间 Rime 进程处于休眠,对比 CPU 时间与墙钟时间
一次重排请求。os.clock() 统计的是 Rime 进程的 CPU 时间,进程阻塞在 socket 上休眠时它就停走,所以日志不到 1 毫秒,打字的人却等了将近三分之一秒。

检查原始响应时,我们还发现了另一个问题:原版解析器在 Lua 的方括号字符集里直接写了多字节的中文标点。Lua 按单字节匹配字符集,普通汉字的 UTF-8 字节只要与标点中的某个字节重合,就会被当成切分符截断。在“婚礼上许下”后面输入 shi yan,模型正确输出了“誓言”,解析器却因为字节重合丢掉了它,屏幕上依然显示“实验”。89 次真实模型响应里,有 38 次因为这个解析问题未能匹配。3

「誓言」与「、」的 UTF-8 字节,共享的一个字节把词切断,Rime 仍显示「实验」
「誓言」为什么变成了「实验」:Lua 方括号字符集按字节匹配,「言」与「、」共有的 0x80 字节被当成分隔符,解析器于是丢掉了模型的首选。

真正稀缺的不是代码,而是证伪

把这三件事放在一起看,脉络就很清楚了。

为什么每一个方案在完整测量之前,看起来都很有希望?

  • 在上下文实验里,用 4B 模型挑几个窗口做演示,确实每次都能生效。
  • 在发音纠错里,如果不放声学二次解码那一行对照,只看文本模型的个别成功样本,很容易觉得再调一调就能用。
  • 在 Rime 重排器里,终端日志确实打印出了低于 1 ms 的数字。

而在我们日常的开发方式里,这种偏差还会被进一步放大。Arion 仓库里有相当比例的代码由 AI 编程智能体(coding agents)编写,再由工程师在 Pull Request 里审查。过去做软件,写代码成本高,验证一个确定性函数的成本低;现在大模型和编程智能体把写代码、换模型、接上下文和做演示的时间缩短到了几分钟,做出一个能跑通的原型变得非常容易。

与此同时,编程智能体会倾向于让检查尽快通过。如果让智能体去跑评测或修问题,阻力最小的做法往往是:模型输出格式不对时加一行回退返回原文,音频推理丢帧时自动重试一次,本地缺少模型驱动时用 mock 数据让测试通过。

这正是我们改变工作方式的原因:当代码和演示都很便宜时,工程上的瓶颈不再是把功能写出来,而是建立一套连自己和智能体都无法绕过的证伪机制。

如果继续沿用“写功能、看演示、补单元测试”的流程,只会快速积累大量未被发现的退化。可行的办法是把代码仓库当成实验室来管理:在动手实现之前先确定评判标准,用明确的规则拦住所有美化数据的捷径。

把仓库变成可检验的实验室

从“防止自欺”的角度回看,Arion 仓库里的一系列约束就有了具体的落脚点。

1. 先冻结留出集,再调整 prompt

回到语音润色模型的选型。在修改任何一行 prompt 之前,我们先将测试集拆成两部分:24 条开发集用于迭代 prompt,30 条留出集在编写完成后立即计算 SHA-256 哈希并固定下来,只用于最终候选方案的对比。4 每个候选方案都在同一台机器上运行生产环境的 worker 和生产环境的校验器。

模型留出集通过数(不润色基线:12 / 30)变差用例数最终改写耗时 p50 / p95
Qwen3 0.6B2400.18 / 0.36 s
Qwen3 1.7B(最终上线)2700.50 / 1.67 s
Qwen3 4B2800.89 / 2.15 s

因为评判标准在事前已经确定,取舍很直接:4B 多花约两倍的内存和等待时间,只多通过 1 条用例;0.6B 最快,但少通过 3 条,而且它多次丢掉分句和数值,只是靠外层校验器拦截才没有造成结果恶化。最终我们上线了 1.7B。

同一份报告也测试了完整的音频流水线,而不只是单独的模型。当时我们尝试在用户每停顿一小段时就对该段做最终识别,以缩短松开按键后的等待时间。实测发现,这样做虽然略微降低了停止后的延迟,却提高了分段边界处的识别错误率;而在同时运行流式识别、分段 Qwen3-ASR 和润色模型时,有一条音频直接触发了 AUDIO_DISCONTINUITY 错误:三个 ONNX Runtime 线程池争用 CPU,导致音频采集队列溢出。4

由于评测规范不允许用重试掩盖失败,这次溢出被原样记录在报告中,我们也据此放弃了分段确认的方案。目前上线的流水线只在松开按键后对整句音频解码一次。后来,同样因为实测数据显示说话过程中的大模型预览在松手后总会被最终改写覆盖、从未改变过最终上屏的文字,我们在 ADR 023 中移除了说话中途的大模型预览。

2. 在同一次运行里同时测量质量与延迟

在放弃了耗时 289 ms 的第三方大模型重排器之后,拼音输入法的同音词排序问题仍然需要解决。

我们没有在按键路径上调用语言模型,而是实现了一个轻量的本地上下文调频器:只记录最近 120 秒内在同一应用里确认上屏的前后词搭配,并且单次确认最多将候选词向上移动两个位置,避免一次误选就直接占据首位。5

在 50 对同音词、3 组随机种子的测试中,首次出现时调频器不改变原有顺序(首选率同为 14%);从第二次出现开始,仅靠传统词频学习时,不同语境下的同音词会反复覆盖彼此的最近使用记录,首选率只有 32.2%;启用上下文调频后,后续出现的首选率升至 88.7%(MRR 从 0.612 升至 0.933),47 对词改善,0 对变差。

这份报告的另一个作用,是在测准确率的同时记录了每次按键的耗时。

第一轮测试显示,上下文调频算法本身每次按键只占 0.02 到 0.06 ms,但按下字母键的整体 p95 耗时却达到了 2.92 ms。通过采样分析定位发现,耗时主要花在每次上屏后候选窗口调用系统窗口管理器的隐藏与重排(orderFront)上。我们将候选窗口改为闲置时变透明并穿透点击、无操作满 2 秒后再移出窗口列表,字母键的 p95 耗时随之从 2.92 ms 降到了 0.81 ms。5 在同一次运行里同时测量质量和延迟,才能避免用一方去牺牲另一方。

3. 保留失败记录,让 98 MB 原始证据可独立复现

一项实验是否可靠,取决于它允许丢弃哪些数据。

我们在评测规范(specs/008-evaluation.md)中明确了三条要求:失败和中途取消的运行保留原状态;未完成的运行不能生成完整报告;任何失败的模型输出都不能替换成原文来修饰表格。6

例如在发音纠错实验中,Qwen3 4B 模型因耗时过长被中途停止。它已完成的 2 次生成并未删除,而是标记为 cancelled_by_user 留在档案中,正式报告则只使用完整跑完的 1.7B 检查点。

为了让结果可以被独立检验,我们将评测语料、逐条原始输出、预热记录、失败尝试、录制音频以及确切的脚本和 prompt 全部保存在仓库的 benchmarks/ 目录下,每个文件都带有字节级的 SHA-256 摘要,共 1,062 个文件、约 98 MB(模型权重和个人录音除外)。7 不需要加载任何模型,只需两行命令就能校验档案并重新生成报告:

pnpm bench:archive:verify
pnpm bench:archive:report --out .workspace/archive-report-repeat

评测协议还固定了那些容易在不同运行之间发生漂移的统计细节:统一的文本归一化、按总错误数除以总参考单位计算的语料错误率、按说话人分簇的配对 bootstrap 区间。测试集的参考答案在代码层面被禁止进入推理、记忆或上下文(验收条件 EVAL-A04)。8

4. 把每次实验的结论写成约束后续开发的规则

一次测量只能回答一个具体问题。为了让后续改动不再重复踩坑,每次实验的结论都会写入三处文档:

  1. 产品规范(specs/) 说明产品必须满足的行为,每条都带有可测试的验收编号(如 EVAL-A04、LRW-A05)。功能需要在真实操作系统上通过对应场景才算完成。
  2. 架构决策记录(docs/decisions/) 记录取舍的原因,目前已有 28 篇。旧决定会被新决定取代(supersede),而不是删除。例如语音润色从 ADR 008 的“插入前手动确认”,到 ADR 011 基于留出集和校验器证据改为“实时预览并自动插入”,再到 ADR 023 移除说话中途的预览,每一步都链接到对应的实验证据。
  3. 智能体规范(AGENTS.md 与 CLAUDE.md) 将这些结论转化为编程智能体写代码前必须读取的约束:不能用模拟输出代替真实推理证据,不能写掩盖未知原因的兜底逻辑,修改代码时必须在同一次变更中同步更新规范与文档。

这最后一层规则,决定了人与编程智能体协作的质量。当缺少驱动或模型异常时,测试会直接报错退出。对智能体而言,让检查通过最省事的方式就是解决真实问题,而不是掩盖错误。

我们还没有证明的事

一套旨在避免自欺的方法,同样需要清楚说明自身的局限。仓库里每份 benchmark 报告开头都列出了局限,目前仍有三项主要缺口:

  • 语料来源与合成语音: 现有测试集大多由一位开发者编写,许多语音是合成或朗读音频。已经在仓库中公开的测试集适合作为回归测试,但不能再充当全新的盲测集。
  • 字符串匹配代理指标: 润色质量目前使用归一化后的精确字符串匹配来近似衡量。针对语义保持、文风和幻觉的独立人工盲评(LRW-A06)仍是尚未关闭的发布关卡。
  • 单一硬件环境: 绝大多数实测数据来自一台 Apple M4(32 GiB)机器;Windows 下的许多路径目前只完成了编译检查和基础验证,还没有同等级别的实测档案。

我们会对另一个团队说什么

如果你也在用本地模型和编程智能体做产品,写代码本身已经不再是瓶颈。做出一个看似合理的演示只需要几分钟,找一个能自圆其说的解释甚至更容易。

在动手实现功能之前,先定义什么叫“变好”,并用哈希锁死一份留出集。一次只改一个变量,始终保留一行不使用新模型的对照组。把失败、取消和超过 100% 的错误率原样留在档案里,把每次得出的结论写进下一个智能体会读到的规则中。这些做法本身不会直接造出好产品,但它能让你更难欺骗自己,而这正是这项工作中最核心的部分。

脚注

  1. 改写上下文消融(v1)与 ADR 013。 ↩ ↩2

  2. 发音证据消融。 ↩

  3. Rime LLM 重排器复现。 ↩ ↩2

  4. 实时改写流水线结果(v1)与 ADR 011。 ↩ ↩2

  5. 上下文调频结果。 ↩ ↩2

  6. 规范 008:评测与自动化。 ↩

  7. benchmark 研究档案。 ↩

  8. benchmark 方法论。 ↩

更多文章