AI 辅助开发

Whisper 的提示会从开头被静默截断至 224 个词元

Whisper 的 prompt 参数有一个隐藏的 224-token 限制。溢出部分会从开头被截断,且没有警告,这可能将关键词偏置变成一个 bug。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

我们为 Whisper 的转录功能提供了一个关键词偏好提示 (keyword biasing prompt),结果却让转录效果变得更糟。不是稍微变糟:该提示主动将模型引向了错误的品牌名称。根本原因在于 API 从未报告过的一个单元不匹配问题。Whisper 的 prompt 参数有一个 224 个 token 的有效上限,超出部分会被静默丢弃,而且被丢弃的是开头部分。这篇文章将详细介绍我们如何通过黑盒 A/B 测试证实了这一行为,以及修复该问题的预算算法。

prompt 参数的实际作用

Whisper 在处理每个转录请求时都接受一个可选的 prompt。官方指南称,它可用于“引导模型的风格或指定不熟悉单词的拼写”。对于领域性强的音频,例如医疗咨询、法律口述或产品支持电话,这是您在使用托管 API 时可用的主要手段:在 prompt 中列出您的领域词汇,模型正确拼写这些术语的可能性就会大大增加。

在底层,prompt 并非供模型推理的自由文本。它被编码并放置在解码器的上下文窗口中,就好像它是前一个音频片段的转录文本一样。模型会从它后面继续生成。这种框架解释了两个令我们惊讶的特性:

  • prompt 与输出竞争上下文空间。Whisper 的文本上下文为 448 个 token,其实现最多为 prompt 保留其中的一半减一,即 223 到 224 个 token。
  • 当 prompt 过长时,参考实现会保留其尾部而非头部。在原始源代码中,这是一行切片代码:prompt_tokens[-(n_ctx // 2 - 1):]。该边界之前的所有内容在模型看到它之前就消失了。

托管 API 并未揭示这两个特性。我们使用的提供商会验证一个独立的限制(896 个字符),当您超出该限制时会返回错误。没有任何东西会警告您有关 token 限制的信息,因为截断发生在模型运行时内部,而不是在请求验证中。

有益的提示语如何变得有害

我们的提示语是从一个术语库数据库中组合而成的:首先是一个手写的、包含最重要品牌名称的种子列表,然后附加数据库中的术语,直到达到字节预算上限。该预算为 850 字节,是根据服务提供商错误消息中的“896”限制设定的,我们曾以为这个限制是字节数。

三个问题叠加在一起:

  1. 896 的限制是字符数,而不是字节数。韩文文本在 UTF-8 编码中每个字符占 3 个字节,因此 850 字节的提示语大约只有 280 个字符。我们当时使用的字符数不到预算的三分之一,却以为已经接近上限了。
  2. 这 280 个字符大约是 380 个 token。根据我们的测量,在 Whisper 的 BPE 词汇表中,中日韩(CJK)文字的 token 化成本很高,大约每个字符对应 1.2 到 1.4 个 token。每个请求都超出了 224 个 token 的上限 70%。
  3. 溢出的部分是从前面被截断的,而我们恰恰把最重要的术语放在了最前面。

这种失败模式是恶性的。我们的种子列表以用户最常说的品牌名称开头。这些名称最先被截掉。而存活下来的,纯粹是由于数据库行顺序的偶然性,是提示语末尾一堆发音相似但错误的术语。因此,当用户说出类似“Sonalift”(本文中所有品牌名称均为化名)的内容时,模型由于被存活下来的相似词“Sonaline”所引导,会自信地转录出错误的品牌。一个完全没有偏向性引导的提示语比我们的表现更好,因为我们的提示语正在引导模型犯错。

还有一个值得注意的转折:组合提示语的查询没有 ORDER BY 子句。1200 个候选术语中哪 40 个能进入提示语,取决于数据库中的物理行顺序,而这个顺序在执行清理(vacuum)和批量写入后会发生变化。同样的代码、同样的音频,在不同日期会产生不同的转录结果。如果你的偏向性引导表现出不确定性,那么在责怪模型之前,请先检查你的术语选择过程是否真的是确定性的。

使用位置性 A/B 测试证明前端截断

你不需要访问模型内部来验证截断方向。你只需要一个超限的提示和它的两种排列方式。

我们构建了一个约 460 个字符、大约 400 个 token 的提示,这个长度轻松超过了 224 个 token 的上限,但低于提供商 896 个字符的验证限制。然后,我们用同一个音频文件对它运行了两次:

  • 排列 A:将三个关键术语放在最前面,后面是填充词汇。
  • 排列 B:先是相同的填充内容,最后是同样的三个术语。

结果是明确无误的。排列 A 转写错了所有三个术语,完全复现了我们的生产环境 bug。排列 B 则正确转写了所有三个术语。相同的内容、相同的 token 数量、相同的音频;唯一改变的是位置。这就是从外部观察到的前端截断,无需访问源代码。

实用的规则立刻就显现出来了:在 Whisper 提示中,末尾是黄金地段。如果发生截断,你放在最后的内容会保留下来。把必须保留的术语放在末尾,并将开头部分视为牺牲区。

在不附带分词器的情况下进行令牌预算

根本的解决方案就是从一开始就绝不超出限制,这意味着在发送前要计算令牌数。我们不想仅仅为了这一个目的,就在 Go 服务中嵌入一个 BPE 分词器,因此我们构建了一个估算器,它带有按脚本计算的上限系数,这些系数是根据我们的生产词汇表,对照真实的分词器测量得出的:

func estimateTokens(s string) int {
    sum := 0.0
    for _, r := range s {
        switch {
        case r > 0xFFFF: // beyond BMP: surrogate pairs, emoji
            sum += 2.0
        case unicode.Is(unicode.Hangul, r) || unicode.Is(unicode.Han, r) ||
            unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r):
            sum += 1.4
        case unicode.Is(unicode.Thai, r):
            sum += 1.0
        case unicode.Is(unicode.Cyrillic, r) || unicode.Is(unicode.Arabic, r):
            sum += 0.55
        case (unicode.IsLetter(r) && unicode.Is(unicode.Latin, r)) ||
            unicode.IsDigit(r) || r == ' ':
            sum += 0.45
        default: // punctuation and symbols
            sum += 1.0
        }
    }
    return int(math.Ceil(sum))
}

有三个设计决策比确切的数字更重要:

  • 逐个字符扫描,而不是对整个字符串进行分类。真实的词汇会在单个术语中混合使用不同文字(例如 “V-line”、“3D lift”、带数字的品牌名称)。单一的按语言设置的系数会恰好错误估计你所关心的那些术语。
  • 系数必须是上限值,而不是平均值。一个低估 15% 的平均值估算器会悄悄地重新引入你试图避免的截断问题。我们将内部预算设置为 200 个 token,而上限是 224,因此估算器可以高估,但绝不能低估。
  • 标点符号不是“其他拉丁字符”。我们的初稿对所有非 CJK(中日韩)字符都使用了 0.45 的系数。像 “A/B/C-123” 这样的字符串和商标符号,其 token 化结果接近于每个字符一个 token,因此符号类字符获得了自己的 1.0 系数通道。

测试套件固定了一些 fixture 字符串,这些字符串的 token 数量由实际的 Whisper tokenizer 离线测量得出,然后对每个 fixture 断言 estimate >= actual。该断言在部署前捕获到了一个真实存在的 bug:我们的泰语系数 0.7 来自于对普通词典词汇的平均计算,但实际填充 biasing prompt 的音译品牌名称,其 token 化结果约为每个字符 1.0 个 token。上限测试失败了,我们提高了该系数,从而避免了一整类针对泰语用户的静默截断问题被发布出去。应将断言写成严格的不等式;一个“上下浮动 15%”的容差会放过那个 bug。

在填充其余部分前,先预留尾部预算

一旦你能够计算 token 数量,组装顺序就仍然很重要。有两条规则使最终的提示词变得稳健:

首先,在接纳任何其他内容之前,为你的关键术语预留预算。我们计算尾部块(种子示例加上固定的必备术语)的成本,从 200 个 token 的预算中减去它,然后才将排序后的词汇表术语放入剩余空间。简单的顺序,即贪婪填充然后最后附加固定项,存在一种失败模式:低价值术语会消耗掉预算,而固定项会被你自己的防护机制丢弃。

其次,按重要性升序排列术语,这样字符串的结尾就是最关键的那些。在预算范围内,这不会改变任何事情。但如果流水线中的任何组件出现计数错误,从头部截断会首先删除你最不重要的术语。这将最坏情况下的灾难性故障转变为优雅降级。

我们系统中最终组装的提示词是:25 个排序后的词汇表术语,然后是示例种子,最后是三个固定在最末尾的品牌名称,总计约 170 个估算 token。引发本次调查的两个生产环境中的识别错误都消失了,这一点通过比对之前和之后的原始音频剪辑得到了验证。

针对托管 Whisper API 进行提示词偏置的核对清单

  • 有效的提示词限制是 224 个 token。服务提供商端的字符验证(在我们使用的 API 上是 896 个字符)是一个独立的、宽松得多的约束。应根据 token 来规划预算。
  • 超出部分会从开头被静默截断。将关键术语放在提示词的末尾,绝不要放在开头。
  • 通过位置 A/B 测试亲自验证截断行为:使用一个超限的提示词,将关键术语分别放在前面和后面,并使用相同的音频。
  • 如果你在没有分词器(tokenizer)的情况下估算 token 数量,那么就用各脚本(script)的系数来逐个符文(rune)地计算,并通过与真实分词器输出的比对测试来强制将这些估算值作为上限。要预料到,音译的外国品牌名在分词时会比字典文本消耗多得多的 token。
  • 首先为必须包含的术语预留预算,然后按排名填充剩余部分,并保持选择的确定性:使用稳定的排序键,一直到唯一的 ID。
  • 记录每个组装好的提示词的估算 token 数,并在估算值接近上限时发出警报。API 绝不会告诉你这些信息。

一个令人不快的总结是,偏置提示词是一种有预算的数据结构,而不是一个字符串。要像对待固定大小的缓冲区一样小心地处理它,因为模型就是把它变成了那样的东西。