Whisperのプロンプトは224トークンで先頭からサイレントに切り捨てられる
Whisperのpromptパラメータには隠れた224トークンの制限があります。超過分は警告なしに先頭から切り捨てられるため、キーワードのバイアスがバグに変わる可能性があります。
Whisperの文字起こしのためにキーワードバイアスをかけるプロンプトを導入したところ、文字起こしの精度が悪化しました。わずかに悪化したのではありません。プロンプトが、モデルを積極的に間違ったブランド名へと誘導してしまったのです。根本的な原因は、APIが報告しない単位の不一致でした。Whisperのプロンプトパラメータには実質的に224トークンという制限があり、それを超える部分は警告なく破棄されます。そして、破棄されるのは先頭部分です。この記事では、ブラックボックスA/Bテストによってその挙動を証明した方法と、それを修正したバジェット計算について順を追って説明します。
promptパラメータの実際の動作
Whisperは、各文字起こしリクエストでオプションのプロンプトを受け入れます。公式ガイダンスによると、これは「モデルのスタイルを誘導したり、馴染みのない単語のスペルを指定したりする」ために使用できるとされています。医療相談、法律関係の口述、または製品サポートの電話など、専門領域に特化した音声の場合、これはホストされたAPIで利用できる主要な手段です。プロンプトに専門用語をリストアップすると、モデルがそれらの用語を正しく綴る可能性がはるかに高くなります。
内部では、プロンプトはモデルが推論する自由なテキストではありません。それはエンコードされ、先行する音声セグメントのトランスクリプトであるかのようにデコーダーのコンテキストウィンドウに配置されます。モデルはそこから続行します。この枠組みは、私たちが驚いた2つの特性を説明します。
- プロンプトは、コンテキストスペースを巡って出力と競合します。Whisperのテキストコンテキストは448トークンであり、実装ではその最大半分から1を引いた数、つまり223から224トークンをプロンプト用に予約します。
- プロンプトが長すぎる場合、リファレンス実装は先頭ではなく末尾を保持します。元のソースでは、これは1行のスライスです:
prompt_tokens[-(n_ctx // 2 - 1):]。その境界より前のすべては、モデルがそれを認識する前に消滅します。
どちらの特性も、ホストされたAPIでは表面化しません。私たちが使用したプロバイダーは、別の制限である896文字を検証し、それを超えるとエラーを返します。トークン制限については何も警告されません。なぜなら、切り捨てはリクエストの検証時ではなく、モデルのランタイム内部で発生するためです。
役立つプロンプトが有害なものに変わる仕組み
私たちのプロンプトは、用語集データベースから組み立てられました。まず、最も重要なブランド名を手書きしたシードリストがあり、その後、バイト予算が一杯になるまでデータベースの用語が追加されました。予算は850バイトで、これはプロバイダーのエラーメッセージにある「896」という制限に対して設定したものです。私たちはこの制限がバイト単位であると想定していました。
3つの問題が重なり合っていました。
- 896という制限はバイトではなく文字数です。韓国語のテキストはUTF-8では1文字あたり3バイトなので、850バイトのプロンプトは約280文字にしかなりません。私たちは、文字数予算の上限に近いと思い込みながら、実際にはその3分の1未満しか使っていませんでした。
- その280文字は、約380トークンに相当しました。私たちの測定では、WhisperのBPE語彙においてCJK(中国語、日本語、韓国語)のスクリプトはトークン化のコストが高く、1文字あたりおよそ1.2から1.4トークンになります。224トークンという上限を、リクエストごとに70パーセントも超過していました。
- はみ出した部分は先頭から切り捨てられました。そこはまさに、私たちが最も重要な用語を配置した場所でした。
この失敗の仕方は悪質でした。私たちのシードリストは、ユーザーが最も頻繁に口にするブランド名から始まっていました。それらが最初に消えました。データベースの行の順序という全くの偶然によって生き残ったのは、プロンプトの末尾近くにあった、響きは似ているが間違っている用語の集まりでした。そのため、ユーザーが「Sonalift」のような言葉(この記事のブランド名はすべて代用です)を口にすると、生き残ったそっくりの「Sonaline」でプライミングされたモデルは、自信を持って間違ったブランドを書き起こしました。バイアスを全くかけないプロンプトの方が私たちのものよりもうまく機能しました。なぜなら、私たちのプロンプトは間違いの方向へとバイアスをかけていたからです。
もう一つ、注意すべきひねりがありました。組み立てクエリにORDER BY句がなかったのです。1,200の候補用語のうちどの40語がプロンプトに含まれるかは、データベースの物理的な行の順序に依存しており、この順序はバキュームや一括書き込みの後に変動します。同じコード、同じ音声でも、日によって異なる書き起こしが生成されました。もしバイアスが非決定論的に振る舞う場合は、モデルを責める前に、用語の選択が実際に決定論的であるかどうかを確認してください。
位置によるA/Bテストでの前方切り捨ての証明
切り捨ての方向を検証するために、モデルの内部にアクセスする必要はありません。必要なのは、制限を超えたプロンプト1つと、その2つの配置です。
私たちは約460文字、およそ400トークンのプロンプトを作成しました。これは224トークンの上限を余裕で超えていますが、プロバイダーの896文字検証は下回っています。そして、同じ音声ファイルを使い、それを2回実行しました。
- 配置A:3つの重要な用語を先頭に置き、その後にフィラーの語彙を配置。
- 配置B:最初に同一のフィラーを置き、最後に同じ3つの用語を配置。
結果は明白でした。配置Aでは3つの用語すべてが誤って文字起こしされ、本番環境のバグが正確に再現されました。配置Bでは3つすべてが正しく文字起こしされました。同じ内容、同じトークン数、同じ音声で、位置だけが変更されました。これが、ソースへのアクセスなしで外部から観測された前方切り捨てです。
実践的なルールはすぐに明らかになります。Whisperのプロンプトでは、末尾が最も重要な場所です。もし切り捨てが発生した場合、最後に配置したものが生き残ります。必須の用語は末尾に置き、先頭は犠牲になるゾーンとして扱ってください。
トークナイザーを同梱せずにトークンを予算内に収める
恒久的な解決策は、そもそも制限を超えないようにすること、つまり送信前にトークンをカウントすることです。この1つの目的のためだけにBPEトークナイザーをGoサービスに組み込みたくはなかったので、本番環境の語彙で実際のトークナイザーと比較測定した、スクリプトごとの上限係数を持つ推定器を構築しました:
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))
}
正確な数値よりも、3つの設計上の決定が重要です。
- 文字列全体を分類するのではなく、ルーン文字をスイープします。実際の語彙では、単一の用語内に複数の文字体系が混在します(「V-line」、「3D lift」、数字を含むブランド名など)。言語ごとの単一の係数では、まさにあなたが気にかけている用語を誤って見積もってしまいます。
- 係数は平均ではなく、上限値でなければなりません。15%少なく見積もる平均的な推定器は、あなたが防ごうとしている切り捨てを静かに再導入してしまいます。私たちは、224という上限に対して内部的な予算を200トークンに設定しました。そのため、推定器は上回ることはあっても、決して下回ってはなりません。
- 句読点は「その他のラテン文字」ではありません。私たちの最初のドラフトでは、CJK(日中韓)以外のすべてに0.45を使用していました。「A/B/C-123」のような文字列や商標記号は、1文字あたりほぼ1トークンでトークン化されるため、記号には独自の1.0のレーンが与えられました。
テストスイートは、実際のWhisperトークナイザによってオフラインで測定されたトークン数を持つフィクスチャ文字列を固定し、すべてのフィクスチャに対して estimate >= actual をアサートしました。そのアサーションは、デプロイ前に実際のバグを検出しました。私たちのタイ語の係数0.7は、通常の辞書にある単語を平均して得られたものでしたが、実際にバイアスプロンプトを構成する音訳されたブランド名は、1文字あたり約1.0トークンでトークン化されます。上限テストが失敗したため、係数を引き上げ、タイ語ユーザー向けのサイレントな切り捨てというクラス全体の問題が出荷されることはありませんでした。アサーションは厳密な不等式として記述してください。「どちらの方向にも15%以内」という許容範囲では、そのバグを見逃していたでしょう。
残りを埋める前に、末尾のバジェットを確保する
トークンをカウントできるようになった後も、組み立ての順序は依然として重要です。2つのルールによって、最終的なプロンプトは堅牢になりました。
第一に、他のものを受け入れる前に、重要な用語のためのバジェットを確保します。私たちは、末尾ブロック、つまりシード例とピン留めされた必須の用語のコストを計算し、それを200トークンのバジェットから差し引きます。そして、その後に初めて、ランク付けされた用語集の用語を残り部分に受け入れます。貪欲に埋めて最後にピンを追加するという素朴な順序には、価値の低い用語がバジェットを消費し、ピンが自身のガードによって削除されてしまうという失敗モードがあります。
第二に、重要度の昇順に用語を配置し、文字列が最も重要なもので終わるようにします。バジェット内では、これは何も変えません。しかし、パイプラインのいずれかのコンポーネントがカウントを誤った場合、前方からの切り捨てによって、最も重要でない用語が最初に削除されます。これにより、最悪の場合の壊滅的な失敗が、グレースフルな失敗に変換されます。
私たちのシステムで最終的に組み立てられたプロンプトは、25個のランク付けされた用語集の用語、次にシード例、そして一番最後に3つのピン留めされたブランド名で、合計で推定約170トークンになります。この調査のきっかけとなった2つの本番環境での誤認識はどちらも解消され、これは変更前後の元の音声クリップで検証済みです。
ホストされたWhisper APIに対するプロンプトバイアスのチェックリスト
- 有効なプロンプトの上限は224トークンです。プロバイダー側の文字検証(私たちが使用したAPIでは896文字)は、それとは別の、はるかに緩い制約です。トークンを基準に予算を立ててください。
- オーバーフローは、警告なく先頭から切り捨てられます。重要な語彙はプロンプトの末尾に配置し、決して先頭には配置しないでください。
- 位置によるA/Bテストで切り捨ての動作を自身で検証してください。つまり、制限を超えたプロンプトを1つ用意し、重要な用語を先頭と末尾に配置したものを、同じ音声でテストします。
- トークナイザーなしでトークンを推定する場合は、スクリプトごとの係数を用いてルーン単位でスイープし、実際のトークナイザーの出力に対するテストによって強制される上限値を設定してください。音訳された外国のブランド名は、辞書にあるテキストよりもはるかに多くのトークンを消費すると予想してください。
- まず必須の用語のための予算を確保し、残りをランク順に埋め、選択が決定論的になるようにしてください。つまり、一意のIDに至るまで安定したソートキーを使用します。
- 組み立てられたプロンプトごとに推定トークン数をログに記録し、推定値が上限に近づいたらアラートを出すようにしてください。APIが教えてくれることは決してありません。
不都合な要約をすると、バイアス付けプロンプトは文字列ではなく、予算が割り当てられたデータ構造であるということです。モデルはそれを固定長バッファに変換するため、固定長バッファを扱うのと同じ注意を払って扱ってください。