
チャットボットの作り方の話をする前に、そのチャットボットを組み込みたかったサービスの話から始めます。ここを飛ばすと、あとの話が全部ぼやけるからです。
一言で言うと、ホテルに泊まっている旅行者が、スマートフォンのチャットから荷物の配送を申し込めるサービスです。
なぜチャットボットを入れたかったのかというと、配送の申し込みは、入力する項目がとにかく多いからです。
→ 送り先の住所
→ 受取人の氏名と連絡先
→ 集荷の日時
→ 受け渡しの日時
→ 荷物の個数、サイズ、中身の申告
→ 支払い方法
フォームにすると、画面が何枚にもなります。 そして画面が増えるほど、途中でやめる人が増え、離脱していきます。 旅行中の人が、慣れない言語で、小さな画面で、10項目以上を埋めるのは無理があります。
チャットなら、この手続きを1問ずつガイドできる。 そう考えたのが出発点でした。
ここで、チャットボットの作り方が問題になります。ひと口にチャットボットと言っても、あらかじめ質問と答えの組を用意しておく旧来型と、LLMを使った自由度の高い会話型があります。そして後者が主流になりつつある。だから、LLMベースで作りました。
もうひとつ、前提を補足しておきます。いまのチャットボットは、会話だけを担当していません。 会話をもとに、実際の手続きまで実行します。料金を計算し、集荷枠を押さえ、決済まで走らせる。
そこまでやるものを「チャット」ボットと呼び続けるのは、そのうち無理が出るのかもしれません。 会話は入口で、本体は手続きの実行だからです。いまならチャットエージェントと呼んだほうが自然だと思います。
前置きはさておき、本題に入ります。
作るのは、驚くほど簡単でした。数日で動きます。 そして業務に載せてから、2つのことが起きました。ひとつは事故で、ひとつは費用です。
今日は、その2つに気づいてから何を直したかという話をしていこうと思います。
① 裏側では、3つのシステムが並んで動いている

旅行者から見えているのは、チャット画面だけです。裏側では3つが同時に動いています。
→ チャット — 旅行者とやりとりし、料金と集荷時間を決める
→ 決済 — カードの利用枠を押さえ(与信)、注文が確定したら実際に請求する。2段階です
→ 業務システム — 運営スタッフが見る管理画面。今日どの部屋から何個の荷物を集荷して、どこへ送るのか。その一覧を見て、人が動きます
申し込みが成立すると、この3つに同じ1件の記録が並びます。チャットに会話が残り、決済に与信が立ち、業務システムに注文が立つ。
3つが揃って、はじめて取引が成立したことになります。
標準構成で作る
チャットボットの作り方を調べると、出てくる手順はほぼ共通です。
→ LLMのAPIを取得する
→ 手元の文書やサービス情報を検索できるようにする(RAG)
→ 会話の入口を作る
構成としてはこうなります。
利用者の入力 → キーワード判定 → LLM(外部API) → 回答/申し込み処理
この構成で作ったものは、きちんと動きました。数日で立ち上がり、料金を答え、集荷時間を決め、決済まで進む。デモも通ります。
② 詰まりに気づくまで — 深夜の1件

動くものができたので、実際の業務に接続します。
最初の異常は、警告として現れませんでした。 現れたのは、3つのシステムのあいだのズレです。
深夜、決済側には3万円を超える与信が1件立っています。 旅行者のカードの利用枠が、その金額ぶん押さえられている、ということです。
ところが、業務システムの注文一覧に、その1件だけが出てきません。 前後の時間帯の注文は、普通に並んでいます。1件だけ、無い。
集荷に行く先が分からない。そもそも注文があったことに気づけない。旅行者はカードを押さえられたまま、荷物は誰にも引き取られない。
最初は表示の不具合を疑いました。次にキャッシュ、次に権限。どれも違いました。
たどり着いた答えは、想定していたどの仮説とも違いました。その注文は、チャットのLLM経由で作成されていました。しかも、そのとき荷物預かりのサービスは、システム上は受付を止めていたのです。
つまり、こういう順番で起きていました。
→ ① 荷物預かりは、システムとしては受付を閉じていた
→ ② それでも旅行者が「荷物を預けたい」と打った。LLMは、良かれと思って案内した
→ ③ 話が進んで決済まで到達し、カードの与信は押さえられた
→ ④ ところが業務システム側は、閉じているサービスの注文を受け付けない。注文レコードが作られなかった
決済だけが成立して、注文が存在しない。 客のカードは押さえられているのに、運営側のどこを見ても、その取引の痕跡が無い。いちばん気持ちの悪い壊れ方です。
この時点で、疑いが確信に変わりました。LLMは99.9%までなら持っていける。ただし、100%が要求される処理では、予期しない動き方をすることがある。
精度の問題ではありません。 99.9%は達成できている。問題は、残った0.1%がエラーとして飛ばない形で残ることでした。
この1件は、監視では見つからない
なぜ発見が遅れたのかというと、そもそも監視の対象になっていなかったからです。
→ 例外は出ていない
→ 画面も落ちていない
→ ログにもエラーが無い
壊れたものを探す仕組みは、全部すり抜けます。 見つかったのは、決済の明細と業務システムの注文一覧を、人が並べて突き合わせたからでした。
「無いものを数える」作業をしていなければ、この事故は翌月も翌々月も起き続けていたことになります。
③ 請求書が届いて、2つ目に気づく

事故を追いかけている最中に、その月のAPIの請求が届きました。思っていたより高い。
内訳を見て理由が分かりました。旅行者は、荷物の話だけをしているわけではなかったのです。
→ 「近くにおいしい店はある?」
→ 「明日の天気は?」
→ 「チェックアウトは何時?」
どれも荷物の配送とは関係ありません。それでも全部、外部のAPIに投げて、丁寧に答えて、きちんと課金されていました。
雑談1件あたりの金額は、わずかです。ただ、件数が多い。そして、この構成では雑談も申し込みも同じ経路を通るので、安く済ませる方法がありません。
年度で予算を組む組織にとって、「使った分だけ」はいちばん扱いにくい費用です。上限も下限も読めないからです。
④ 詰まりの原因を、1つに特定する

別々の問題に見えますが、原因は共通でした。
利用者の入力 → キーワード判定 → LLM(外部API)
↑
ここに、分岐も停止もない
→ 入力がそのままLLMに届く
→ LLMが解釈して、LLMが答える
→ 途中に、止める場所も分ける場所も無い
だから「これは答えてはいけない」を実装する場所が無く、「これは安く処理する」を実装する場所も無い。
制御点がゼロです。プロンプトで縛る対処が最初に出ますが、上限があります。プロンプトは指示であって、機構ではないからです。
⑤ 直してみる — 前段に、分類器を1枚

そこで、構成を1箇所だけ変えました。
変更後
利用者の入力 → キーワード判定 → オンデバイス分類器 → (確信度が低いときだけ)LLM
間に挟んだのは、分類専用の小さなモデルです。会話を生成する能力はありません。入力がどのカテゴリに属するかだけを判定します。
重要なのは、確信度も一緒に出させることです。
→ 確信度が閾値を超えたら、そのまま機械的に処理する
→ 確信度が閾値を下回ったら、LLMに回す
作業としては、モデルを1つ追加して分岐を1本書いただけです。
そして、事故そのものへの対処も三段で立てました。
→ 閉じているサービスを、LLMに通させない
→ 通ってしまったら、黙殺せずエラーを返す
→ それでも通過したら、運用のタスク画面に出す
現場で立てた方針は、これだけです。素朴だと思います。
あとで知った — この形には、すでに名前がついていた
素朴な解のつもりでしたが、調べてみると同じ構造が、いま最も研究されている省コスト設計そのものでした。
1つ目。安いモデルから順に試し、確信が持てないときだけ高いモデルへ渡す構成は、カスケード(LLM cascade)と呼ばれています。2023年の FrugalGPT という論文が提案した形で、安いモデルの応答に対して0〜1の信頼スコアを出し、閾値を下回ったら次のモデルへ上げていく。
報告されている効果が、なかなかのものです。最も高性能な単体モデルと同等の性能を、最大98%のコスト削減で達成した。あるいは同じコストのまま精度を4%上げた。
2つ目。入力の難易度を先に判定して、強いモデルと弱いモデルに振り分ける構成は、ルーティング(model routing)と呼ばれます。2024年の RouteLLM がよく知られていて、強いモデルが勝つ確率を予測するモデルと、コストの閾値で振り分けを決める。閾値を上げれば弱いモデルへ多く流れて安くなり、下げれば強いモデルへ流れて品質が上がる。
閾値ひとつで、コストと品質のどこに立つかを決められる。 「最初は保守的に低く置いて、実績を見て絞る」という現場のやり方は、まさにこのつまみを回す作業でした。名前を知らないまま、同じことをしていたわけです。
3つ目が、いちばん面白いところでした。
「入力の意図を分類器で判定してから処理を分ける」——これは、LLM以前の対話システムが標準でやっていた設計です。
当時のパイプラインは、こうなっていました。
→ 自然言語理解(NLU) — ドメイン判定・意図検出・スロット充填
→ 対話状態追跡 — 利用者がいま何を求めているかを保持
→ 対話ポリシー — 次に何をするかを決める
→ 応答生成
そして意図分類は、「対話管理への必須の入力」と位置づけられていました。分類できなければ、次に何をするかが決まらないからです。
LLMが賢すぎたので、この層をまるごと捨てました。 分類しなくても、全部投げれば答えてくれるからです。
→ 分類器を捨てた → 制御点が消えた
→ 制御点が消えた → 答えてはいけないものに答えるようになった
→ そして費用も、分類していないぶんだけ全部が高い経路を通った
つまり今回やったのは、発明ではありませんでした。一度捨てた層を、「確信度」という一項目だけ足して戻しただけです。
そして、この「確信度で分岐する」の一項目が、古い設計を最新のカスケードに変えているところです。昔の意図分類は、当たったか外れたかの二値でした。確信度を出させると、外れそうなときだけ賢い方に逃がせます。
事故対策の三段(通させない/エラーを返す/運用画面に出す)も、似た話でした。
ガードレールの設計は、単一のフィルタを最後に足すのではなく、リクエストの経路上に複数の制御を重ねるのが定石だとされています。入力フィルタ・出力検証・エスカレーションモジュールという層に分けておくと、片方を差し替えても他方に影響しない、という理由です。
そして3段目については、はっきりこう書かれているものがあります。「ガードレールが確信を持てないときに、人間のレビュアーへ回す」。
確信が持てないときに逃がす。カスケードでやっていることと、まったく同じ発想です。逃がす先が大きいモデルか人間かの違いしかありません。
ただし正直に書いておくと、「防止・検知・エスカレーション」という決まった三点セットの呼び名があるわけではありません。 層の切り方は文献によって違います。共通しているのは「1枚では足りない」という一点だけです。
新しい理屈は、ひとつも要りませんでした。
⑥ 結果

ルーティング精度:99%
意地悪な問題を多く用意したデータセットでも、約8割を分類器が処理しました。実利用では、外部APIの呼び出しを8割以上削減できる見込みです。
別の検証では、ホテルの問い合わせ(タオルはどこか、荷物を送りたい、観光プランを知りたい)を振り分ける用途で、容量100MB程度のモデルで、精度95%から97%まで来ています。
100MBです。 巨大なモデルは要りませんでした。
なぜ両方が同時に良くなったのかというと、理由は3つに分かれます。
→ 精度側:「答えてはいけない質問」を、プロンプトのお願いではなく分岐の条件として実装できるようになった。受付不可カテゴリに落ちれば、LLMに到達すらしない
→ 費用側:よくある問い合わせほど分類器が確信を持てる。そして、よくある問い合わせほど件数が多い。件数の多い側から順に、費用ゼロの経路に流れる
→ 予算側:分類器は外部APIを呼ばないので、使っても使わなくても費用が変わらない。従量から固定へ寄る
⑦ 現場で使うなら、このフォーマット

ここまでを、そのまま使える形に落とします。設計時にこの表を埋めてください。
表1:カテゴリ定義表
| # | カテゴリ名 | 例となる入力 | 処理 | 確信度の閾値 | LLMに回すか |
|---|---|---|---|---|---|
| 1 | 施設案内 | 「タオルはどこ」 | 定型回答を返す | 0.85 | 下回ったら回す |
| 2 | 配送依頼 | 「荷物を送りたい」 | 申込フォームへ誘導 | 0.90 | 下回ったら回す |
| 3 | 周辺情報 | 「観光プランを教えて」 | 検索して要約 | 0.70 | 下回ったら回す |
| 4 | 受付不可 | 受付終了後の申込 | 停止して有人へ | 0.60 | 回さない |
| 5 | 金銭・取消不可 | 決済、キャンセル | 停止して有人へ | 0.60 | 回さない |
| 6 | 対象外 | 「今日の天気は」 | 対象外と返す | 0.80 | 回さない |
4・5・6の行が、この表の本体です。 1〜3は誰でも作ります。答えない行を持っているかどうかが、事故の有無を分けます。
閾値は最初、保守的に低く置いてください(=LLMに回す割合を多めに)。運用実績を見て絞ります。いきなり高く置くと、確信していないのに機械処理する事故が出ます。
この表は、カスケードの設計書でもあります。閾値の列が、どこで上のモデルへ逃がすかの定義になっているからです。
閾値は最初、保守的に低く置いてください(=LLMに回す割合を多めに)。運用実績を見て絞ります。いきなり高く置くと、確信していないのに機械処理する事故が出ます。カスケードの研究でも、閾値の調整が性能とコストの分岐点になると言われています。 ここは一度で決まりません。
表2:通り抜けたときに、気づく仕組み
表1は防止の層です。ただし、防止だけでは足りません。
冒頭の3万円の事故は、表1があっても通り抜けたときに気づけません。だから同時にこれを作ります。
| 突き合わせるもの | 頻度 | 拾う条件 | 通知先 |
|---|---|---|---|
| 決済明細 ⇄ 注文台帳 | 毎日 | 片方にしか無い行 | 運用担当 |
| 問い合わせログ ⇄ 回答ログ | 毎日 | 回答が無い行 | 運用担当 |
| 分類器の確信度分布 | 週次 | 閾値付近が増えていないか | 開発担当 |
1行目が、あの事故を見つける唯一の手段でした。「壊れたものを探す」ではなく「無いものを数える」。実装は地味ですが、これしかありません。
そしてエスカレーションの層として、もう1つ足します。
| 条件 | 動作 |
|---|---|
| 表1の4・5に落ちた | 処理を止めて、運用のタスク画面に出す |
| 表2で片方にしか無い行を検出 | 同じタスク画面に出す |
| 分類器の確信度が閾値付近で振動 | 週次レポートに出す |
「エラーを返して終わり」にしないのが要点です。 止めたものが人の目に入る場所へ必ず出る。この列があるかどうかで、事故が「起きなかった」になるか「気づかれないまま続く」になるかが分かれます。
防止・検知・エスカレーション。この3層が揃って、初めて設計と呼べます。 表1だけだと、防止しかありません。
手順(この順で作る)
→ 1. 過去の問い合わせを分類し、上位で8割を占めるところまでカテゴリを作る(多くの場合5〜10)
→ 2. 表1に、答えない行(受付不可・金銭・対象外)を先に書く
→ 3. 分類器を作り、確信度を出させる。閾値は低めから
→ 4. LLMに回す経路を作る(ここで初めてLLMが出てくる)
→ 5. 表2の突き合わせを実装する
→ 6. 元データの構造を直す
最後の6が、精度で詰まったときの本命です。AIが自動生成した議事録には見出しがありません。 文章としては読めるのに構造が無いので、どこで区切るかが決まらない。ある現場での当座の対応は、見出し記号を機械的に差し込んで、そこで区切るという、およそ美しくない処置でした。
元データに構造が無い状態では、モデルを変えても検索精度は上がりません。
⑧ この設計が効き続ける理由

最後に、なぜこのフォーマットを推すのかを書きます。
モデルが入れ替わっても、無駄にならないからです。
これは願望ではなく、実験で確かめられています。ルーティングの研究では、ある強弱のモデルの組で訓練したルーターが、下のモデルを入れ替えてもテスト時に性能を保ったという結果が報告されています。
振り分けの判断は、振り分けられる相手が変わっても生き残る。 モデルの顔ぶれが毎月変わる市場で、これはかなり重い意味を持ちます。
→ どのモデルを使うかは、四半期ごとに最適が変わります
→ でも「どこで判断を分けるか」は、業務が変わらない限り変わりません
→ だから表1と表2は、モデルを差し替えてもそのまま使えます
賢いモデルを選ぶ作業は、選んだ瞬間から陳腐化が始まります。カテゴリと閾値と突き合わせを設計する作業は、資産として残ります。
そして、この記事で書いたことのうち本当に新しいものは、ひとつもありません。
→ カスケードは2023年に提案されている
→ ルーティングは2024年に整理されている
→ 意図分類にいたっては、LLM以前の対話システムの標準構成
新しかったのは、それを一度全部捨てたことの方でした。LLMが賢すぎたので、分類も、状態管理も、方針決定も要らないように見えた。実際、要らないように動きます。業務に載せるまでは。
標準構成が悪いのではありません。標準構成は、制御点を1つも持っていないだけです。 1枚足せば、そこから先は設計の対象になります。
なお、冒頭の「今日の天気は?」は、分類器を入れるとカテゴリ6「対象外」で一瞬で弾かれるようになります。
弾かれた画面は、少し寂しいです。丁寧に天気を答えていた頃の方が、愛嬌はありました。愛嬌に課金していたわけですが。
以上です。