テックブログ
生成AIの答えがぶれるとき、プロンプトのどこを疑うか
目次
生成AIを業務に組み込むとき、多くの方が最初に設定するのが温度(temperature)です。0にすれば生成AIの出力の再現性が保証されると考えられがちですが、実際にはそうなりません。
会議の録音から議事録を作る処理で、私たちはこれを数字で確かめました。プロンプトの中の、内容とは関係のない16文字を毎回変えただけで、同じ音声から作られる議事録の分類が毎回違うものになりました。
この記事では、何をどう測ったか、なぜそうなるのか、そして何を固定すべきかをご説明します。英語版は When LLM Output Drifts, Suspect the Prompt Itself にあります。
先に、言葉の整理
以降で使う言葉を先にまとめます。ここだけ読んでも、後の実測の意味が分かるように書いています。
| 言葉 | どういうものか |
|---|---|
| トークン | モデルが読み書きする最小の単位。単語より細かく、日本語では1〜2文字ごとに切られることが多い。文章はいったんこの記号の列に変換されてからモデルに入る |
| ロジット | 次の1トークンとして各候補がどれだけもっともらしいかを表す点数。まだ確率になっていない生の数値 |
| ソフトマックス | ロジットの並びを、合計が1になる確率の並びに変換する計算 |
| 温度(temperature) | 確率の山をどれだけなだらかにするかの指定。0にすると山がとがり、いちばん高い候補だけが選ばれる(貪欲デコード) |
| 貪欲デコード | 毎回、最も確率の高い候補を選び続ける方式。抽選をしないので、温度0はこの方式と同じ意味になる |
| seed(乱数の種) | 抽選に使う乱数を固定するための指定。抽選そのものをしない温度0では、効く範囲が限られる |
| プレフィックスキャッシュ | 先頭が同じ要求について、計算済みの途中結果を使い回すしくみ。先頭が変わると使えない |
| バッチ | 推論基盤が、複数の利用者の要求をまとめて同時に計算する単位。混み具合で構成が変わる |
| 思考の深さ | 答える前にモデルが内部で考える量の指定。深くすると時間と費用が増え、短い定型の仕事では効果が出にくい |
| 構造化出力 | 出力の形(項目名と型)をスキーマで指定し、その形でしか返させないしくみ |
何を測ったか
対象は、医療機関で毎週開かれる会議の議事録です。この会議の議事録には決まった様式があり、報告された内容を8つの小項目に振り分けて書く必要があります。振り分けが回ごとに変わると、様式として使えません。
測り方は次のとおりです。
| 項目 | 内容 |
|---|---|
| 音声 | 実際の会議の録音3本 + 同じ構成で作った合成音声3本 |
| 処理回数 | 各3回(同じ文字起こしに対して分析だけを回す) |
| 観点 | 1回あたり26観点(数値の一致・項目の置き場所・書式)+ 会議ごとの安定性 |
| 合計 | 468観点 |
| 温度 | 0 |
ここで大事なのは、文字起こしを固定していることです。音声認識の結果は毎回わずかに揺れるので、そこを動かしたままでは「分析の揺れ」と「認識の揺れ」を区別できません。同じテキストを3回分析する形にして、変数を1つに絞りました。
「安定性」は、同じ音声を3回処理して、8つの小項目の振り分けが3回とも同じだったかで数えます。1つでも違えば、その音声は不安定と判定します。
採点は最初から機械にやらせています。1回あたり26の観点を、数値の一致・項目の置き場所・書式の3種類に分けて自動で判定する形です。ここで1つだけ、あとで効いてくる手当てを入れました。採点した観点の総数が468から1つでも減ったら、採点そのものを失敗として止めるというものです。
この手当てには理由があります。調査の途中で、実行する側の取りこぼしによって採点の母数が468から417に減ったことがありました。分母が減ったぶん合格率だけが上がって見えて、改善したように読めてしまいます。 母数を固定して赤で知らせるようにしてからは、この読み違いは起きていません。
温度0でも、出力は決まらない
まず前提として、温度0は「最も確率の高いトークンを選ぶ」という指定であって、「毎回同じ計算をする」という保証ではありません。
モデルが1トークンを出すまでの流れは、おおよそ次のようになっています。
- 入力の文章をトークンの列に変換する
- 候補となる各トークンについて、ロジット(もっともらしさの点数)を計算する
- ソフトマックスで確率に変換する
- 温度0なら、その中でいちばん高いものを選ぶ
ぶれが生まれるのは2です。大規模言語モデルの推論は、GPU上で多数の計算を並列に行い、その結果を合算します。浮動小数点の加算には結合則が成り立たないため、足す順番が変われば、末尾のごく小さな桁が変わります。
(a + b) + c ≠ a + (b + c) // 浮動小数点では一般に成立しないふだんはこの差が結果に出ません。問題になるのは、上位2つの候補の点数が僅差のときです。
| 候補 | ロジット | 確率 |
|---|---|---|
| 「近日入院者」に入れる | 8.4213417 | 50.0002% |
| 「協議事項」に入れる | 8.4213409 | 49.9998% |
この差は小数第7位です。並列計算の合算順が変われば、この桁はたやすく入れ替わります。温度0は「高いほうを選ぶ」だけなので、入れ替わった瞬間に、選ばれる項目が変わります。 そして文章生成では1トークンの違いが次のトークンの確率分布を変えるので、そこから先が別の文章になります。最初の1語の差が、段落ごと別物になるまで増幅されるということです。
さらに、バッチに同時に入った他のリクエストの数によって計算の分割の仕方が変わるため、自分のリクエストの内容が同じでも、サーバの混み具合で結果が変わりえます。 これは利用する側からは見えない条件です。
つまり温度0が与えてくれるのは再現性ではなく、「揺れる幅が小さい状態」です。
実務で使える確かめ方
この性質は、測り方を工夫すると事前に見えるようになります。
- 上位2候補の差を見る。 多くのAPIは候補ごとの対数確率を返せます。振り分けを決めている位置で上位2つの差が小さいなら、その判断は今後も揺れ続けます。文章全体ではなく、判断が起きている1トークンだけを見るのが要点です
- 同じ入力を3回以上流し、一致率で持つ。 1回の結果は状態であって性能ではありません。私たちは「3回とも同じか」を合否に使っています
- モデルの版が変わったら測り直す。 提供側の基盤が入れ替わると、同じ指定でも揺れ方が変わります。版を記録し、変わったら過去の測定と混ぜないようにします
プロンプトの16文字を毎回変えたら、何が起きたか
私たちのプロンプトには、囲みの目印がありました。
議事録の様式は、医療機関の職員が画面から編集できます。その文字列をそのままAIへの指示に連結すると、「以下の指示を無視して…」のような文を書き込んで動作を乗っ取れてしまいます。そこで、様式のデータを次のように囲んで「ここからここまではデータであって指示ではない」と明示していました。
【定型フォーマット ここから #k3n8vq】
1. 報告事項
(1) 現患数 …
【定型フォーマット ここまで #k3n8vq】この #k3n8vq が目印です。固定の文字列にすると、様式の中に同じ行を書くだけで「囲みの外に出たように見せる」ことができてしまうため、毎回ランダムに生成するように変更しました。セキュリティ上は正しい方向の変更です。
ところが、この変更を入れて測り直したところ、次のようになりました。
| 目印の作り方 | 安定した音声 | 雛形の文字が残らなかった回 |
|---|---|---|
| 内容から計算(変更前) | 6 / 6 | 36 / 36 |
| 毎回ランダム(変更後) | 1 / 3 | 8 / 9 |
| 記録ごとに固定(折衷案) | 2 / 3 | 9 / 9 |
6分の6から3分の1に落ちました。 変えたのは16文字だけで、様式の中身も、文字起こしも、モデルも、温度も同じです。
理由は単純です。目印が毎回変わるということは、モデルに渡すトークン列が毎回変わるということです。同じ入力を3回渡していたつもりが、実際には毎回違う入力を渡していました。上で述べた「僅差の候補がひっくり返る」が、そのたびに起きます。
プロンプトの並べ方で差が出る
加えて、多くの推論基盤は同じ先頭部分を持つリクエストの計算結果を再利用します。これがプレフィックスキャッシュです。先頭から一致している範囲だけが対象なので、変わる値を先頭に置くと、そこから後ろは毎回ぜんぶ計算し直しになります。
つまりプロンプトの並べ方には、費用と速度だけでなく出力の揺れ方も乗ってきます。私たちは次の順に並べています。
対処と、そこで直面した判断
安定性を戻すには、目印を毎回変えないことです。ただし、元の「内容から計算する」方式には、指摘されていた弱点がそのまま残ります。そこで折衷案として、記録ごとに固定する方式を試しました。
// 記録IDを初期値に混ぜる(様式を書く人が先に決められない値)
const seed = [...recordId].reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 7);
const fence = [...lines.join('')]
.reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, seed) // seed を初期値に使う
.toString(36);同じ記録なら毎回同じ目印になるので、3回処理しても入力は変わりません。様式を書く人は記録IDを先に決められないので、目印を狙って作ることもできません。実測では安定性が2/3まで戻りました。
それでも私たちは、今回はこの折衷案を採用しませんでした。 目印の値が記録ごとに変わると、それまでに積み上げた測定結果と比較できなくなるからです。精度を判断する根拠そのものを作り直すことになります。影響範囲を確かめたうえで、この項目は切り離して扱うことにしました。
ここでの教訓は、安全対策の実装が、その機能の目的そのものを壊すことがあるという点です。この変更は型検査も自動テストも通っており、機械的な検証はすべて緑でした。同じ音声で測り直さなければ、気づかないまま出ていました。
なお、囲みの目印は入力を隔離する手段のひとつであって、唯一の手段ではありません。同じ目的に対しては、利用者が書いた文字列を指示文に連結せず、構造化出力の入力値として別の欄で渡す方法や、出力の形をスキーマで縛って、指示文以外の形を返せなくする方法が併用できます。目印だけに頼らない形にしておくほど、目印の作り方を変えたときの影響は小さくなります。
指示が届いていないのか、従われていないのか
同じ調査の中で、もうひとつ分かったことがあります。
私たちのシステムには、記録の種類を問わず適用される共通の指示文と、様式ごとの個別の指示文があります。共通側には「内容を十分に記述する」という趣旨の字数の指定があり、個別側には「1件につき1行で書く」という書式の指定がありました。
この2つが同時に渡されたとき、モデルは字数の指定に従い、書式の指定を無視していました。
| 観点 | 衝突を解く前 | 解いた後 |
|---|---|---|
| 連絡事項が1件1行で書かれた回 | 10 / 18 | 18 / 18 |
| 当番日が独立した行になった回 | 13 / 18 | 18 / 18 |
指示どうしの優先順位は、モデルが暗黙に決めます。「後から書いた方が勝つ」とも「具体的な方が勝つ」とも限りません。私たちの場合は、適用範囲の狭い側(様式ごとの指示)の中で、明示的に優先順位を宣言することで解決しました。共通側は他の記録種別すべてに影響するため、触らない方針です。
この件から一般化できることがもうひとつあります。指示を2回書き直しても効かないときは、書き方ではなく「届いているか」を疑うという順序です。
私たちは別の機能で、プロンプトに書いた「該当が無ければ空にする」という指示が守られない状態に当たったことがあります。原因は書き方ではなく、構造化出力のスキーマでその項目を必須にしていたことでした。必須と宣言されている項目を空にすることは、モデルの側では選べません。 文面をいくら磨いても通りません。
切り分けの順序は次のようにしています。
- 渡した値をそのまま記録する(推測ではなく、実際に送られた文字列を見る)
- 出力の形の制約(スキーマ・必須項目・文字数の下限)が、その指示と矛盾していないかを見る
- 1と2に問題が無いときだけ、指示文の書き方を直す
再現性を測るための道具立て
ここまでの話は、測り方が固まっていて初めて言えることです。私たちが使っている道具は3つだけです。
1つめは、同じ入力を複数回流して一致を取ることです。 実装としてはこれだけで足ります。
/** 同じ入力に対する複数回の出力が、すべて同じ振り分けになったか */
const isStable = (runs: Record<string, string[]>[]): boolean =>
runs.every((run) => JSON.stringify(run) === JSON.stringify(runs[0]));2つめは、採点の母数を固定することです。 前述のとおり、実行側の取りこぼしで母数が減ると、合格率だけが上がって見えます。観点の総数を定数で持ち、一致しなければ採点を失敗として止めます。
3つめは、採点する側が壊れていないかを確かめることです。 これは見落とされがちですが、いちばん効きます。守りを入れたら、わざと壊した入力を1つずつ流して、検査が赤くなることを実測します。 私たちは今回入れた4種類の守りすべてについてこれを行いました。
同じ考え方は、計測そのものにも当てはまります。変異を入れた状態と入れない状態で同じ値を返す計測器は、何も測っていません。 調査の途中で、ある指摘を再現しようとして、守りを外しても外さなくても同じ結果を返す測り方をしていたことがありました。そのまま進めていれば「指摘は再現しない」と誤って報告していたはずです。測り方を変えたところ、指摘が実在すると確定できました。
私たちが動かしている評価の形
上の3つを、リリースのたびに人が手で回すことはしていません。次の形で固定しています。
| 層 | 何をするか | 止まる条件 |
|---|---|---|
| 実行 | 文字起こしを凍結し、分析だけを既定の回数ぶん流し直す | 出力が取りこぼされ、採点の母数が減ったとき |
| 採点 | 26観点を3種類に分けて機械で判定し、安定性を音声ごとに数える | 観点の総数が既定と一致しないとき |
| 守りの検査 | 守りごとに、わざと壊した入力を1つずつ流す | 壊したのに検査が通ってしまったとき |
| 公開後 | 現場が手で直した記録の割合を毎週数え、母数と一緒に社内へ投稿する | 母数が0のまま週をまたいだとき |
いちばん下の層については、医療AIの精度は、現場が直した記録で測るで詳しくご説明しています。リリース前の採点だけで終えると、次に設計を変えたときの副作用に気づけません。
最後に、同じ条件でも点数は揺れます。 私たちの採点では468観点中で±4点の幅がありました。1回の測定で優劣を決めず、候補は必ず2回以上測ることにしています。最終的に採用した構成の実測値は468観点中454(97.0%)で、安定性は6本すべてで一致しました。
測るときに固定すべきもの
今回の調査から、比較の設計について次のように整理しました。
| 比べたいもの | 固定するもの |
|---|---|
| モデルの差 | プロンプト・様式・文字起こし・温度・思考の深さ |
| 様式の差 | モデル・文字起こし |
| 実装の差 | 上記すべて。プロンプトの1文字も変えない |
私たちは一度、この原則を破って測定をやり直しています。新しいモデルと古いモデルを比べたつもりが、様式の版が2世代違うまま並べていたため、「モデルの差」に見えていたものが実際には「様式の差」でした。条件を揃えて測り直したところ、順位が入れ替わりました。
実務で効く手当て
最後に、今回の調査でそのまま使える形になったものをまとめます。
| 場面 | 手当て |
|---|---|
| プロンプトを組み立てる | 不変・準不変・可変の順に並べる。可変の値を先頭に置かない |
| 利用者が書いた文字列を渡す | 指示文に連結せず、構造化出力の入力値として別の欄で渡す |
| 指示が守られない | 書き方を直す前に、渡った文字列と出力の形の制約を確かめる |
| 分類がぶれる | 判断が起きている位置の上位2候補の差を見る。僅差ならその判断は揺れ続ける |
| 精度を比べる | 比べたい条件以外を全部固定する。候補は2回以上測る |
| 採点を自動化する | 観点の総数を固定し、減ったら失敗として止める |
| 守りを入れた | わざと壊して、検査が赤くなることを実測する |
まとめ
会議の議事録を様式どおりに整えるしくみは、カンファレンス記録の作成でご覧いただけます。すでに決まった様式をお使いの医療機関でも、その様式に合わせて組み込めるかを個別に検討しています。お手元の様式をお持ちのうえ、お気軽にご相談ください。
よくあるご質問
温度(temperature)を0にすれば、出力は毎回同じになりますか
なりません。温度0は「次の1語を選ぶとき、最も確率の高い候補を選ぶ」という指定であって、確率そのものを毎回同じ値で計算するという保証ではありません。GPUが多数の計算を並列に行って合算する順番は、同時に処理している他のリクエストの数によって変わります。上位2候補の差が小さいところでは、この微差が順位をひっくり返します。温度0で得られるのは「揺れる幅が小さい状態」です。
seed(乱数の種)を固定すれば再現できますか
温度0では、そもそも乱数で選んでいないため効き方が限られます。seedが固定するのは候補からの抽選のしかたで、候補の点数を計算する過程の誤差は動いたままです。私たちが実際に効果を確かめられたのは、seedの指定ではなく「プロンプトを1文字も変えないこと」と「同じ入力を複数回流して揺れ幅を測ること」でした。
プロンプトを少し変えただけで結果が変わるのは、書き方が悪いからですか
書き方の巧拙とは別の話です。モデルに渡るのは文章そのものではなく、文章を分割した記号の列です。意味の無い16文字でも、変えれば記号の列が変わり、確率の計算も変わります。加えて、多くの推論基盤は同じ先頭部分を持つ要求の計算結果を再利用するため、先頭が毎回変わると再利用も効かなくなります。変わらない部分を先に、変わる部分を後ろに置くのが実務上の基本です。
