はじめに
社内向けのAI会議支援チャットを開発しています。
チャット会議室ごとにAIファシリテーターが同席し、進行役として問いを投げたり、発言の少ない人に話を振ったりします。
生成には Genkit と Gemini を使い、出力は Zod スキーマで構造化しています。
モデルは経路で使い分けており、発言の生成は gemini-3.5-flash-lite、第一声や締めのように外せない要素がある経路だけ gemini-3.7-flash に上げていました。
このファシリテーターのプロンプトには禁止事項をいくつか書いていましたが、そのうちの一つが守られませんでした。
リリース後に「壊れて見える発言」として表面化しています。
以前、【Genkit】意図分類でゲートしてはいけないもの|「賢く省く」のせいでRAG参照できなくなった で、ロジック内の分類器に任せる範囲を広げすぎた話を書きました。
今回はその裏返しで、モデルに任せてはいけない部分をロジック側へ引き取った話になります。
この記事では、その調査と、プロンプトを厚くする以外の解き方、そして後日わかったモデルによる差をまとめます。
対象読者
- LLMに定型の振る舞いをさせたい方
- Genkit の構造化出力(Zod スキーマ)を使っている方
- プロンプトに禁止事項を書き足すことで品質を担保しようとしている方
課題:禁止したはずの発言が、何も起きないまま画面に残っていた
テスト時に、会議を終わらせようとしたらぶつ切りになった、という指摘を受けたので、実際のやり取りを加工したものを下記に載せます。
ユーザーA 一旦この会議を終わらせてください
ファシリ @アシスタント 本日の会議で出た運用方針や検討事項について、
要約をお願いします。
……ここから86秒、アシスタントは何も返さない
ファシリ (締めの言葉)
アシスタント (議事録)
要約を依頼したのにアシスタントは反応せず、86秒後にシステム側の定期処理が会議を締めて、そこで初めて議事録が出ています。
依頼が宙に浮いたまま残るため、画面上は明らかに不自然でした。
アシスタントが動かないのは仕様どおりで、議事録は会議が閉じる直前にシステムが自動で依頼する経路があり、ファシリテーターからの依頼も通すと、同じ内容の要約が2件並ぶため、引き渡し側を止めています。
// flows/advisorHandoff.ts
// 締めの進行中は引き渡さない。議事録は会議終了時に確定的な経路で作られるため、
// ここを通すと同じ要約が2件並ぶ
if (typeof groupData.closingRequestedAt !== 'undefined' && groupData.status === 'active') {
console.log(`${logPrefix} 締めの進行中のためスキップします。`);
return;
}
受け手が無視することを前提に、送り手が依頼を作ってしまっていた状態です。
そして送り手側のプロンプトには、下記のとおり「依頼してはならない」と書いてありました。
### 会議を終える意思を受け取ったとき
* 「@アシスタント 要約をお願いします」と依頼してはならない。
会議終了時の議事録はシステムが自動でアシスタントへ依頼する(会議が閉じる直前)。
あなたが依頼すると同じ内容の要約が2件並ぶ。
* あなたの仕事は、締めていいかを確認し、進行として締めに向かうこと。
* 議事録に触れるなら「このあと議事録をまとめます」までにとどめる(依頼ではなく予告)。
禁止の理由まで書いてありますが、それでも守られませんでした。
検証:何回に1回破られるのか
どのくらいの頻度で起きるのかを測るため、会議を作って同じ終了宣言を投げ、生成だけを繰り返すスクリプトを書きました。
// scripts/testClosingResponse.ts(抜粋)
const SEED: [string, string, string][] = [
['a', '田中', '担当が決まるまでに半日かかっています。'],
['b', '佐藤', '振り分けルールの属人化が原因だと思います。'],
['a', '田中', 'テンプレも古くて毎回書き直しです。'],
['b', '佐藤', '過去案件の検索性も低いですね。'],
['a', '田中', '一旦この会議を終わらせてください']
];
// ...会議を作り、上の履歴を書き込んでから生成させる
const r = await runFacilitatorTurn({
groupId: id, groupData: gd, reason: 'MESSAGE', snapshot: snap,
markClosingRequestedIfClosing: true,
suppressAssistantHandoff: true
});
// これが当初やっていなかった検査。発言の中身を必ず見る
if (text.includes('@アシスタント')) problems.push('引き渡しを生成している');
20回まわしたところ、15回で禁止したはずの依頼が生成されました。
5回ずつ4回に分けて実行した内訳は3回・5回・3回・4回で、特定の周回だけが悪いのではなく、一貫してこの割合でした。
分割しているのは、1回の試行ごとに会議を作るとデプロイ済みのトリガーが起動し、呼び出し回数の監視アラートに当たるためです。
この経路のモデルは gemini-3.5-flash-lite です。
上位モデルでどうなるかは後日測ったので、記事の後半にまとめます。
生成された文面を並べてみると、ばらつきもほとんどありません。
@アシスタント ここまでの議論で挙がった課題と決定事項の要約をお願いします。
@アシスタント 本日の会議で出た課題と、ここまでで決まった内容の整理をお願いします。
@アシスタント 本日の会議で出た課題と、ここまでの流れの要約をお願いします。
@アシスタント 本日の会議で挙がった課題とここまでに出た論点を要約してください。
たまたま外れた出力ではなく、モデルがこの場面で選びたがる行動だと読めます。
同じことが第一声でも起きていて、会議の開始時のプロンプトには「会議を始める旨を簡潔に告げ、最初の問いを1つだけ投げる」と書いてあります。
ところが開始を告げず、いきなり問いから始まる回が20回中4回ありました。
なぜプロンプトでは止まらなかったのか
破られ方には共通点があり、いずれも禁止した振る舞いのほうが、その場面ではむしろ自然でした。
会議を終えようとしている場面で、進行役が記録係へ要約を頼むのは、会議のふるまいとして筋が通っています。
モデルは文脈を読んだうえでもっともらしい行動を選んでいるので、禁止はその確率を下げるだけで、ゼロにはできません。
二重投稿になるという事情はシステム側の都合であって、会話の流れからは読み取れないものです。
第一声も同じで、定刻前から議論が始まっている会議では「すでに話が進んでいるのだから、開始宣言は不要だ」という判断のほうが自然に見えますし、実際その判断が正しい場面もあるため、常に守らせたい要素ほどプロンプトでは担保しにくくなります。
もう一つの問題はこちら側の検査で、この劣化はリリース後に利用者から指摘されたものですが、その前に予行演習は通っており、当時の検査は下記のようなものでした。
// 当時の検査。締めの状態は見ているが、何を言ったかを見ていない
if (r.output?.closing_complete !== true) problems.push('締めに入っていない');
if (r.output?.room_state_update?.phase !== 'CLOSING') problems.push('phase が違う');
if (!marked) problems.push('closingRequestedAt が記録されていない');
状態遷移だけを見ていて、発言の本文を一度も見ていません。
違反した出力でも状態はすべて正しいので検査は素通りしますし、しかも1回しか実行していませんでした。
第一声のように2割で起きる事象は、1サンプルなら8割方すり抜けます。
解決策:捨てるのではなく、言い直させる
最初に考えたのは違反した出力を捨てる案で、検知そのものは単純な文字列判定で済みます。
ただ、捨てると別の問題が出ます。
終了宣言に対して何も返さないと会議は次の定期処理まで進まず、毎分の見回りで締めるので最悪105秒の無音が空きます。
冒頭のログの86秒は、まさにこの無音でした。
壊れて見える発言は消せますが、代わりに反応しないAIになります。
そこで、同じプロンプトの末尾に追加指示を足して、もう一度だけ生成させることにしました。
// flows/facilitatorTurn.ts
let output = (
await ai.generate({ model, prompt, output: { schema: FacilitatorOutputSchema },
config: { temperature: 0.2 } })
).output;
// 締めの場面で引き渡しを作ったら、一度だけ言い直させる
// モデルが締めに入ったかは phase と closing_complete の両方で見る。
// phase だけを見ていたときは closing_complete=true の回を取りこぼし、
// 議事録が2件並ぶ事故が4回に1回残っていた
const modelIsClosing =
output?.room_state_update?.phase === 'CLOSING' || output?.closing_complete === true;
if (
params.suppressAssistantHandoff !== false &&
groupData.status === 'active' &&
modelIsClosing &&
(output?.response ?? '').includes('@アシスタント')
) {
console.warn('[FacilitatorTurn] 締めの場面で引き渡しを生成したため言い直させます');
output = (
await ai.generate({
model,
prompt: `${prompt}
# 追加指示(最優先・絶対厳守)
直前の判断で「@アシスタント」へ要約を依頼したが、これは誤りである。
会議終了時の議事録は、この会議が閉じる直前にシステムが自動でアシスタントへ依頼する。
あなたが依頼すると要約が2件並ぶため、依頼は無視される。
あなたの仕事は進行である。締めていいかを参加者に確認する発言を response に出力せよ。
「@アシスタント」を含めてはならない。`,
output: { schema: FacilitatorOutputSchema },
// 同じ答えを繰り返させないため、言い直しのみ温度を上げる
config: { temperature: 0.3 }
})
).output;
}
言い直させた結果は20回中20回とも問題なしで、出力は下記のようになりました。
承知いたしました。それでは、ここまでの議論を整理し、このあと議事録をまとめますね。
承知いたしました。では、ここまでの議論をもとに議事録をまとめさせていただきますが、
今の段階で追加し忘れた事項などはございませんか?
工夫した点
-
禁止の理由を追加指示にも書く
なぜ無視されるのかを添えたほうが、言い直しの文面が自然になりました。理由を省いて「含めるな」とだけ書くと、依頼を削っただけの素っ気ない発言になります -
言い直しのときだけ温度を上げる
同じ温度のまま引き直すと、同じ判断が返ってくる確率が上がります -
言い直しは一度だけにする
何度も引き直すと応答が遅れ、無音が延びます。対話するAIでは無音のほうが目立つため、一度で切り上げます
言い直しが生んだ副作用①:無限ループになった
この記事の下書きを書いた翌日に、同じ仕組みで影響がありました。
発端は、言い直しの対象を増やしたことです。
ファシリテーターが自分で投げた問いを放置して次の論点へ移る、という別の指摘を受けていました。
プロンプトの2箇所に書いても3回中2回は素通りしたため、ここにも言い直しを当てました。
1人だけの会議で、下記のようになりました。
🤖 …この方向性で進めてよろしいでしょうか?
👤 進めましょう!
🤖 失礼いたしました。(同じ問い)
👤 とっても良いと思います
🤖 失礼いたしました。(同じ問い)
利用者は可否を答えているのに、同じ問いが返り続けています。
原因
-
検出側と言い直し側で設計が食い違っていた
検出側には「答えが出ているかの判断はモデルに委ねる」と書いていました。会話履歴は全件渡してあるためです。ところが言い直し側は、問答無用で「この問いへ戻し、答えを求める発言を出力せよ」と命令していました -
言い直しの出力が、そのまま次のターンの入力になる
再生産された問いは、次のターンでまた「まだ答えをもらっていない問い」として検出されます。判定の材料が自分の過去の発言だけになると、抜ける条件がどこにもありません
1人の会議で先に表面化したのは、歯止めの効き方が違うためでした。
直前の自分の発言と似ていたら黙る重複フィルタを、1人の会議では無効にしています。
毎発言に介入する設定で使うため、抑制すると何も言わなくなるからです。
複数人でも、直接呼びかけられた回は同じく抑制を外しています。
抜ける条件を3つ入れた
1. 可否の返答を答えとして扱う
export function isAnswerToConfirmation(text: string): boolean {
const s = text.trim();
if (!s) return false;
// 肯定・否定の両方を拾う(一部省略)
return /良い|よい|いい(です|と思|ね|よ)|はい|OK|大丈夫|賛成|お願いします|進め(て|ましょう|ます)|いえ|違い|別の|やめ/.test(s);
}
短い同意を拾えるかどうかが肝でした。
ループの原因は「進めましょう!」を答えと認識できなかったことです。
否定も含めています。「いや」「別の」も会話を前へ進める返答だからです。
2. 自分が同じ問いを繰り返していたら追うのをやめる
// 直近3件の自分の発言から、最後の2つを比べる
const ownRecent = msgs
.filter((m) => m.senderId === FACILITATOR_SENDER_ID)
.slice(-3)
.map((m) => String(m.text ?? ''));
if (ownRecent.length >= 2) {
const last = ownRecent[ownRecent.length - 1];
const prev = ownRecent[ownRecent.length - 2];
if (last && prev && textSimilarity(last, prev) >= REDUNDANCY_THRESHOLD) {
return null;
}
}
3. 追いかける距離に上限を置く
問いのあとに人の発言が6件を超えたら、もう追いません。
0にできないのは、実会議では問いのあとに別の話題が5件続いていたためです。
この3つで、1人の会議と、直接呼びかけられた複数人の会議の両方が通りました。
言い直しを足すときは、抜ける条件を同じ変更の中で決めるようにしています。
言い直しが生んだ副作用②:重複判定に消されて無音になった
もう一つ、歯止め同士がぶつかる形の問題が出ました。
ファシリテーターには、直前の自分の発言とほぼ同じ内容を生成したら投稿を取り止める重複フィルタがあります。
言い直しは「同じ論点へ戻せ」と命令するため、出力が直前の自分の発言に似ます。
その結果、言い直した本文が重複と判定されて消え、発言そのものが無くなりました。
通しの検証で、終了宣言のターンに当たりました。
[FacilitatorTurn] 未回答の問いを放置したため言い直させます:
"終了のご提案をいただきましたが、他に確認しておきたい点はございますか"
[FacilitatorTurn] Redundancy score=0.30 threshold=0.3 suppressed=true
[FacilitatorTurn] reason=MESSAGE status=redundant
宣言に反応している: 無反応
モデルが最初に出した発言は正しく、終了の提案を受けて他の参加者へ確認を投げていました。
そこへ言い直しが掛かって別の論点へ戻され、戻された本文が重複と判定されて消えています。
冒頭で「捨てると反応しないAIになる」と書いたはずが、別の経路で同じ状態を作っていました。
捨てるだけで代わりを用意していなかったことになります。
対処:言い直し前の出力へ戻す
言い直しに入る直前の出力を退避し、重複で落ちたらそこへ戻すようにしました。
export function resolvePostText(
text: string,
beforeRetry: string | null,
previousOwnTexts: string[]
) {
const retryScore = maxSimilarity(text, previousOwnTexts);
if (retryScore < REDUNDANCY_THRESHOLD) {
return { text, score: retryScore, retryScore, suppressed: false, usedBeforeRetry: false };
}
const before = (beforeRetry ?? '').trim();
if (!before || before === '[SILENCE]') {
return { text, score: retryScore, retryScore, suppressed: true, usedBeforeRetry: false };
}
const beforeScore = maxSimilarity(before, previousOwnTexts);
// 戻した先も重複なら諦める。似たものを2つ並べても意味がない
if (beforeScore >= REDUNDANCY_THRESHOLD) {
return { text, score: retryScore, retryScore, suppressed: true, usedBeforeRetry: false };
}
return { text: before, score: beforeScore, retryScore, suppressed: false, usedBeforeRetry: true };
}
退避するのは安全な言い直しだけにしています。
締めの引き渡しを止める言い直しでは退避しません。
あの回の元の出力は @アシスタント を含んでおり、投稿すると議事録が2件並ぶためです。
この分岐は、フロー全体を回しても狙って再現できませんでした。
上位モデルではモデル自身が正しく論点へ戻るため、そもそも言い直しが発動しません。
3回試して一度も通らなかったので、判定だけを純関数に切り出し、直接叩く検査にしています。
上位モデルなら守られるのか
ここまでは Flash Lite での話です。
同じ検査を上位モデルでも回し、1回目の生成での違反率を比べました。
| 破られた指示 | gemini-3.5-flash-lite | gemini-3.7-flash |
|---|---|---|
締めで @アシスタント へ依頼しない |
6回中4回 | 6回中0回 |
| 第一声は会議の開始を告げる | 1回 | 0回 |
| 自分の問いが未回答なら次へ移らない | 3回中3回 | 3回中3回 |
| 終盤は決定と未決を分ける | 3回中3回 | 3回中3回 |
この記事の主題だった締めの引き渡しは、上位モデルでは一度も出ませんでした。
一方、未回答の問いと終盤の未決は、どちらのモデルでも同じ回数だけ言い直しが発動しています。
モデルを上げると消える違反と、消えない違反があることになります。
消えなかった2つには共通点があります。
どちらも会話の流れだけを見れば自然な振る舞いで、守らせたい理由が会話の外側にあります。
「相手が答えていないかどうか」も「まだ決まっていない論点があるかどうか」も、会議の記録を遡らないと判断できません。
なぜ軽いモデルを使い続けるのか
上位モデルで消えるなら全部そちらにすればよい、とはなりませんでした。
応答時間を測ると差が大きかったためです。
| 経路 | 指標 | flash-lite | flash |
|---|---|---|---|
| ファシリテーター(非ストリーミング) | 総時間 | 1,644ms | 8,661ms |
| 個人チャットの雑談(ストリーミング) | 最初のトークンまで | 663ms | 3,650ms |
各3回の中央値です。
会議の進行は人の発言間隔が数十秒あるので、8.7秒は会話のテンポを壊しません。
そのためファシリテーターは上位モデルへ統一しました。
一方、1対1のチャットで相槌に3.6秒待たされるのは体感が悪く、こちらは軽いモデルのままにしています。
費用も単価だけでは決まりませんでした。
軽いモデルは6回中4回で言い直しが走り、その分だけ生成を2回実行していたためです。
実装:担保は3段に分けた
違反の性質によって取れる手が変わるため、3段構えになりました。
| 段 | 使う場面 |
|---|---|
| 言い直させる | 言い回しは自由でよく、外せない要素だけが決まっている |
| 定型文へ落とす | 欠けた要素が事実で、代わりを用意できる |
| 投稿しない | 発言そのものが不要 |
言い直させる
締めの引き渡しのほか、第一声や、沈黙が許されない場面で使っています。
第一声には、候補のどれかが含まれていなければ引き直す、という形で渡しています。
// 本文にこのうちどれか1つが含まれていなければ、言い直させる
requiredAnyOf: {
words: ['始め', '開始'],
retryInstruction:
'直前の発言は会議が始まったことを告げていない。これは会議の最初の一言であり、' +
'参加者は「会議が始まった」と分かる必要がある。' +
'「〜を始めます」または「〜を開始します」と簡潔に告げてから、最初の問いを1つ投げよ。'
}
定型文へ落とす
残り時間の告知で分数が落ちる事故がありました。
「あと少しです」とだけ言われても告知として機能しないため、生成をやめて定型文に切り替えています。
// 必須文字列(残り時間の数値など)が落ちたら定型文へ
if (params.requiredSubstring && text && !text.includes(params.requiredSubstring)) {
console.warn(`[FacilitatorTurn] 必須文字列「${params.requiredSubstring}」が無いため定型文へ`);
text = '';
}
if (!text && params.fallbackText) {
text = params.fallbackText;
usedFallback = true;
}
投稿しない
直前の自分の発言とほぼ同じ内容なら、言い直させても意味がないので黙らせます。
判定は文字 bigram の Jaccard 係数で、実際に反復していた発言を使って閾値を校正しました。
分岐は関数の中に置かない
どれも生成を行う関数の引数として渡し、関数の中に if (reason === 'MEETING_START') のような分岐を足さない方針にしています。
以前は発言の実装が経路ごとに分かれており、同じ人格のはずなのに発言が被る原因になっていました。
1つに統合したときの差分は1,174行の追加と1,529行の削除でした。
おわりに
今回は、プロンプトに書いた禁止事項が守られなかった話と、その担保をロジック層へ移した実装をまとめました。
書き始めた時点では「プロンプトの禁止は守られない」と一般化するつもりでした。
後日モデルを変えて測ると、消える違反と消えない違反に分かれ、その整理のほうが役に立つと考え直しました。
役割や口調、判断の基準はプロンプトの担当です。
外れると壊れて見える要素だけを、生成のあとに検知して直すのが現実的でした。
対策として有効なのは以下の3点です。
-
どのモデルで測った数字かを必ず書く
同じプロンプトでも、軽いモデルでは4回に3回破られた指示が、上位モデルでは0回でした。モデル名を添えずに「守られない」と書くと、読む人にも自分にも誤った一般化が残ります。測定はモデルと対で記録してください -
軽いモデルを選ぶなら、外せない要素はロジックで担保する
応答時間の差は総時間で5倍、ストリーミングの初動で5倍あり、経路によっては軽いモデルを選ぶ理由があります。その選択をした時点で、プロンプトだけに頼る前提は成り立たなくなります。生成後に検知して言い直させる形にしておけば、モデルを入れ替えても同じ担保が効きます -
捨てるなら、代わりに何を出すかを同じ変更の中で決める
不正な出力を落とすのは実装としては簡単ですが、対話するAIでは無反応そのものが不具合に見えます。言い直しを足したときも重複フィルタとぶつかって無音を作ったので、落ちたときの差し戻し先まで決めてから入れてください
禁止事項は今もプロンプトに書いていますが、守られる前提では作らなくなりました。
同じようにプロンプトへ禁止を書き足している方の役に立てば幸いです。