跳到主要内容

创建日期:2026-10-13 | 最近更新:2026-10-13 事实核对基于 sherpa-onnx 1.13.8(sherpa-onnx-node;node v24.19.0、Linux x64),热词与同音词替换的机制描述逐字核对了官方文档与 C++ 源码(留档在 source/sherpa-onnx-lab/official-sources/,含抓取日期与方式)。所有识别结果为同一次 run.sh 的原始输出。

sherpa-onnx 101(第 3 篇 · 定制):让模型听懂你们组的黑话

上一篇结束时留下一句话:没有一个通用语音模型认识 useEffect 或 vitest。

这不是模型选得不好,是统计规律:训练语料里 "virus" 的出现次数比 "vitest" 高几个数量级,发音又近,模型当然选前者。要改这件事,只能在解码阶段给模型塞上下文。sherpa-onnx 给了两个互不相干的工具:

  • 热词(hotwords / contextual biasing):给 transducer 模型一份词表,解码时给包含这些词的路径加分;
  • 同音词替换(homophone replacer):识别完之后按拼音规则改写汉字。

它们解决不同的问题、用法完全不同、也各有各的坑。这一篇把两个都跑通,并且把踩到的三个坑写清楚。

0. 先给结论​

#结论证据
1热词能把一句语法和语义都错了的话整个拉回来:LIBRAR / LIVE AFTER TOMORROW → 礼拜二 THE DAY AFTER TOMORROW§2
2热词只支持 transducer 模型,且必须切到 modified_beam_search(greedy_search 不支持)——这是官方的硬约束§1、§2
3热词文件里给词配分数时,冒号前少一个空格会让整个 Node 进程 abort(C++ std::terminate,不是 JS 异常,try/catch 拦不住)§3.1
4热词对词表不匹配的模型完全无效:X-ASR 的 tokens.txt 里汉字带 ▁ 前缀,写裸汉字进去是 OOV,日志会明说 Cannot find ID for token 礼§3.2
5英文热词在这类模型上基本不可用:编码路径是"逐字符过 BPE",而模型实际把 VIRUS 切成 ▁VI RU S,对不上§3.3
6同音词替换只对汉字生效,但效果极好:官方示例 玄界芯片 → 玄戒芯片,我自建的 10 条规则把 家买点 修成 埋点、密等 修成 幂等§4
7同音词规则的左边写的是**「模型听错时输出的那个词」的拼音**,不是正确词的拼音——这一条写反了规则就永远不触发§4.3

1. 热词是什么:先从原理说起​

官方文档把动机说得很直白(逐字引用,原文见 sherpa-onnx 文档 · Hotwords):

Current ASR systems work very well for general cases, but they sometimes fail to recognize special words/phrases (aka hotwords) like rare words, personalized information etc. Usually, those words/phrases will be recognized as the words/phrases that pronounce similar to them (for example, recognize LOUIS FOURTEEN as LEWIS FOURTEEN). So we have to provide some kind of contexts information (for example, the LOUIS FOURTEEN) to the ASR systems to boost those words/phrases.

实现方式是在解码时构造一个 Aho-Corasick 自动机(多模式串匹配的经典结构),把匹配到热词前缀的路径加分;如果这条路径最后没走完整个热词,加的分再取消掉。文档里给了一张图和逐帧的分数推演表(SHELF / HI / THE 三个例子),值得一读——它解释了一个反直觉点:加分不是只在整词命中时给的,而是沿着匹配路径逐帧给,失败时才回退。

三条硬约束(官方原文,加粗是我加的):

Only transducer models support hotwords in sherpa-onnx. That is, only models from Offline transducer models and Online transducer models support hotwords. All other models don't support hotwords.

Also, you have to change the decoding method to modified_beam_search to use hotwords. The default decoding method greedy_search does not support hotwords.

还有一条很好的性质:

Note: We implement the hotwords feature during inference time, you don't have to re-train the models to use this feature.

1.1 词表长什么样​

# 中文(cjkchar 词表的模型)
语音识别
深度学习

# 英文(bpe 词表的模型)
SPEECH RECOGNITION
DEEP LEARNING

# 中英混(cjkchar+bpe 词表的模型)
SPEECH 识别
深度学习

带分数就写成 语音识别 :3.5。注意冒号的位置——这是我踩的第一个坑,见 §3.1。

1.2 调用方式​

一次配置,全局生效:

const recognizer = new sherpa.OnlineRecognizer({
featConfig: { sampleRate: 16000, featureDim: 80 },
modelConfig: {
transducer: { encoder, decoder, joiner },
tokens,
numThreads: 2,
// 中英混训的模型是 cjkchar+bpe,用热词必须告诉它这两件事
modelingUnit: 'cjkchar+bpe',
bpeVocab: '.../bpe.vocab',
},
decodingMethod: 'modified_beam_search', // ← 必须
maxActivePaths: 4,
hotwordsFile: 'hotwords.txt',
hotwordsScore: 2.0,
});

这里的 bpeVocab 有讲究:它是「token + 分数」两列的文本文件,不是 sentencepiece 的 bpe.model。模型包里通常只给 bpe.model,得自己导一份,否则会看到这句报错:

Each line in vocab should contain two items (seperate by space), the first one is bpe token,
the second one is score, given : ...

导出只要三行 Python:

import sentencepiece as spm
sp = spm.SentencePieceProcessor(model_file='bpe.model')
with open('bpe.vocab', 'w') as f:
for i in range(sp.get_piece_size()):
f.write(f'{sp.id_to_piece(i)} {sp.get_score(i)}\n')

(这一步写进了 run.sh 的准备阶段,可复现。)

2. 实测:一句话从「全错」到「全对」​

场景:0.wav 说的是「昨天是 Monday,today is 礼拜二,the day after tomorrow 是星期三」——一句典型的中英混说。

用 2023 版双语模型,三种模式跑同一段音频:

模式RTF识别结果
greedy_search0.088昨天是 MONDAY TODAY IS LIBRAR THE DAY AFTER TOMORROW是星期三
modified_beam_search(无热词)0.111昨天是 MONDAY TODAY IS LIVE AFTER TOMORROW是星期三
modified_beam_search + 热词「礼拜二」0.110昨天是 MONDAY TODAY IS礼拜二 THE DAY AFTER TOMORROW是星期三

热词文件就一行:

礼拜二

注意第二行的错误比第一行更严重:LIBRAR THE DAY AFTER TOMORROW 至少把词都吐出来了,LIVE AFTER TOMORROW 直接把 "THE DAY" 吞了,句子语法都崩了——这是 beam search 在没有约束时"自信地走错路"的典型表现。而加了热词之后,不只是把 礼拜二 找回来,整个句子的切分也回到正轨。

原因正是 §1 说的机制:热词在图里给包含「礼拜二」的路径持续加分,这条路径一路活到最后,顺带把周围的词也带对了。

代价:RTF 从 0.088 涨到 0.110(greedy_search → modified_beam_search 本身就要维护多条路径),多花 25% 算力。热词本身几乎不额外增加开销(0.111 → 0.110,在噪声范围内)。

3. 三个坑​

热词这个功能很值,但坑比想象的多。三个都是我实际撞到的。

3.1 冒号前少一个空格 → 进程直接 abort​

我想给「礼拜二」加个权重,顺手写成这样:

礼拜二:5.0

结果:

utils.cc:EncodeBase:65 Cannot find ID for token 礼 at line: 礼 拜 二 : 5 . 0.
terminate called after throwing an instance of 'std::invalid_argument'
what(): stof
run.sh: line 74: 2342009 Aborted (core dumped) node scripts/06-hotwords.js ...
退出码 134

退出码 134(SIGABRT),core dumped。 这不是 JS 异常——try/catch 完全拦不住,整个进程就没了。

根因在 C++ 侧的解析逻辑(sherpa-onnx/csrc/utils.cc 的 EncodeHotwords,留档在 official-sources/):

  1. 第一遍按空格切分,以 : 开头的 token 才被当作分数。所以 礼拜二:5.0 整体被当成一个"词";
  2. 这个词再被逐字符拆开(cjkchar 分支),变成 礼 拜 二 : 5 . 0,: 变成了独立的 token;
  3. EncodeBase 里遇到以 : 开头的 token,执行 std::stof(word.substr(1))——对 ":" 来说 substr(1) 是空串,stof("") 抛 std::invalid_argument,没人接,terminate。

正确写法是 礼拜二 :5.0——冒号前留空格,冒号后紧跟数字。 文档里那句 "the score should follow the predefined character :" 说的就是这个格式,但第一次写很容易漏掉空格。

工程建议:热词文件是用户/运营可能手改的输入,别让它直接进 new OnlineRecognizer()。上线前做一次格式校验(每行匹配 ^(.+) :\d+(\.\d+)?$ 或纯词),否则一个空格就能打挂你的服务进程。

3.2 同一个词表,换个模型全 OOV​

同一份热词文件拿到 X-ASR 模型上:

Cannot find ID for token 礼 at line: 礼 拜 二. (Hint: Check the tokens.txt see if 礼 in it)
Cannot find ID for token 拜 at line: 礼 拜 二. (...)
Cannot find ID for token 二 at line: 礼 拜 二. (...)
online-recognizer-transducer-impl.h:InitHotwords:455 Failed to encode some hotwords, skip them already

看一眼两个模型的 tokens.txt 就明白了:

# X-ASR(2026):汉字带 ▁ 前缀
<blk> 0
<sos/eos> 1
▁的 2
▁频 885

# bilingual mobile(2023):汉字是裸字符
<blk> 0
<sos/eos> 1
频 699

热词里写的是裸汉字 频繁,代码查表找的是 频,而 X-ASR 的表里只有 ▁频——查不到,直接 OOV 跳过。而且失败是静默的:只打一行 Failed to encode some hotwords, skip them already,识别照跑,只是热词完全没生效。如果你不看日志,会以为热词"没效果"。

顺带一个信息:debug: 1 时打印的配置里有个 modeling_unit="cjkchar"——这是默认值。也就是说,即使模型是 cjkchar+bpe 训练的,你不显式传 modelingUnit: 'cjkchar+bpe' 的话,热词里的英文也不会走 BPE 编码。

3.3 英文热词在这类模型上基本不可用​

我试了 VIRUS(模型的实际输出)和 VITEST(想要的词)作为热词,分数从 1.5 一路加到 6.0,输出纹丝不动。原因看 getResult() 里的 token 就清楚了:

"text": "八 USE EFFECT 依赖数组补上然后跑一遍 VIRUS",
"tokens": [" 八"," USE"," EFFECT"," 依"," 赖"," 数"," 组"," 补"," 上"," 然"," 后"," 跑"," 一"," 遍"," VI","RU","S"]

模型把 VIRUS 切成 ▁VI RU S 三个 token。而 EncodeHotwords 对 cjkchar+bpe 的处理是——把词按 UTF-8 字符拆开,每个字符单独过一次 BPE(源码里的 SplitUtf8 + isalpha 分支):

for (const auto &w : SplitUtf8(word)) { // "VIRUS" -> "V","I","R","U","S"
if (isalpha(w[0])) {
std::vector<std::string> bpes;
bpe_encoder->Encode(w, &bpes); // 单个字母过 BPE
...

于是热词被编码成 ▁V ▁I ▁R ▁U ▁S,和实际的 ▁VI RU S 根本对不上,Aho-Corasick 图里那条路径永远不会被走到。

结论:在这类 cjkchar+bpe 模型上,多字母英文词的热词等于没有。 中文热词受 §3.2 影响也用不了。真正能用热词的,是那些 tokens.txt 用裸汉字/裸词表的老模型(比如 2023 那批)。

这个结论我只在手上这两个模型上验证过,不敢推广到全部——判断方法很简单:把它 tokens.txt 前 20 行看一眼,再拿 getResult().tokens 看你想要的词是怎么被切的,对得上才可能有戏。

4. 同音词替换:另一条路,而且更好用​

热词的坑主要在"词表要匹配"。同音词替换(文档里的标题叫「拼音词组匹配替换」)走的是完全不同的路:它不碰解码,只在识别结果出来之后做一次基于拼音的改写。

官方特别强调这俩不是一回事:

Hint: 本文所描述的方法,与 hotwords(热词)是不同的技术。它们互相独立、毫不相关。

4.1 它解决什么​

文档给的第一个例子非常典型:

举个例子,如果一个人说了下面 4 个字:xuán jiè xīn piàn,我们如何让模型输出 玄戒芯片,而不是 玄界芯片,或者 悬界心骗 呢?

同一个发音有好几种写法,模型只能按语料频率猜。 而这类专有名词在语料里出现次数极少,猜错的概率极高。

限制也写得很明白(原文重复了三遍):

只支持对汉字进行替换。只支持对汉字进行替换。只支持对汉字进行替换。

4.2 官方示例实测​

官方给了一个现成的规则文件 hr-xuan-jie-replace.fst 和配套音频,我用 Node API 跑了一遍(hr 配置挂在识别器上):

cfg.hr = {
lexicon: '.../lexicon.txt', // 汉字 → 拼音,通用
ruleFsts: '.../replace.fst', // 替换规则,自己生成
};
未使用 hr : "下面是一个测试玄界芯片湖南人工头安装机载传感器"
使用 hr : "下面是一个测试玄戒芯片湖南人弓头安装机载传感器"

玄界芯片 → 玄戒芯片,和文档描述完全一致。 顺带还改了第二处(人工头安装 → 人弓头安装),说明规则是多条的、一次能命中多处。

4.3 自己写规则:左边要写「错的那个」​

规则用 pynini 生成(Linux 上 pip install --only-binary :all: pynini 即可,其他平台官方建议"去找一台 Linux 电脑"):

import pynini
from pynini import cdrewrite
from pynini.lib import byte, utf8

sigma = utf8.VALID_UTF8_CHAR.star
rule = (pynini.cross("xuan2jie4xin1pian4", "玄戒芯片")
| pynini.cross("fu2nan2ren2", "湖南人")).optimize()
cdrewrite(rule, "", "", sigma).write("replace.fst")

拼音用 1/2/3/4 标声调,轻声写 1。

这里有个必须说清楚的认知点(我一开始搞错了):规则的左边不是"正确词的拼音",而是**"模型听错时输出的那个词"的拼音**。

我写了一条 mai2dian3 → 埋点(埋 = mái,二声),结果模型说的是"埋点"、听成的是"买点"(mǎi,三声)——声调不同,规则不匹配,没触发。改成 mai3dian3 → 埋点 之后立刻生效:

未使用 hr : "先在灰度环境家买点再做限流和熔断接口要密等"
使用 hr : "先在灰度环境家埋点再做限流和熔断接口要幂等"
差异位置 : ^ ^

(密等 → 幂等 那条一开始就生效了——因为「幂等」和「密等」完全同音,都是 mi4deng3,左边写哪个都一样。这两组词恰好把两种情形都演了一遍:同音的靠一条规则,不同音的必须按错的写。)

4.4 我的「前端黑话」规则表​

完整规则见 source/sherpa-onnx-lab/scripts/build-hr-fst.py,10 条:

左边(模型可能输出的)右边(想要的)说明
mai3dian3 / mai4dian3埋点说成 买点/卖点
hui1du4灰度说成 辉度
rong2duan4熔断说成 融断
xian4liu2限流说成 现流
mi4deng3 / mi4deng1幂等密等(同音);前后鼻音不分见左下
jiang4ji2降级说成 将级
lian2tiao2联调说成 连条
wang3guan1网关说成 往观

两个工程要点:

  1. 一个词可以写多条规则(不同口音、前鼻音后鼻音不分)。mi4deng3 和 mi4deng1 都指向「幂等」就是这个意思。
  2. 规则文件不支持运行时修改(官方原话:"目前不支持动态修改规则文件"),所以要么启动时生成,要么按业务预先编好几份换着加载。

4.5 代价与风险​

同音词替换的代价几乎为零:它在识别结束后对文本做一次 FST 转换,不参与解码。比起热词"必须开 beam search"的 25% 开销,这条路便宜太多。

但它有个本质风险:它是无差别的同音改写。如果「买点」在你们的语境里真的是"购买点"的意思,那条 mai3dian3 → 埋点 就会误伤。换句话说,这条规则是"用可能的误伤换术语正确率",规则越宽越危险。

5. 两个工具怎么选​

热词(hotwords)同音词替换(hr)
作用时机解码中(影响 beam 搜索)解码后(改写文本)
模型要求只能 transducer任何能输出中文的模型
解码方法必须 modified_beam_search不挑
语言中文、英文(但见 §3.3)只有汉字
算力代价约 25%(主要来自 beam search)接近 0
生效范围能影响整句的切分只改汉字,不动其他
典型用途把生僻词/专名"捞"回来修同音错别字
失败模式静默 OOV,没日志就看不出同音误伤

实践建议:中文技术黑话优先用同音词替换(便宜、可控、不吃模型限制),需要影响句子结构、或者要支持英文词时才考虑热词——而且上热词之前先用 getResult().tokens 确认你的词能被切成模型认得的 token。

下一篇换个方向:让模型说话(本地 TTS 实测),以及让它在你不说话的时候待机听着一个唤醒词。