TL;DR: 私はAIの営業チャットボットのデバッグに丸一日費やしました。ひとつのバグに見えたものが、実はその上に積み重なった5つのバグでした。1つ直すたびに、下から次の問題が露わになっていきました。ここからが全貌です。
バグを直したら、アプリが悪化する——あの感覚、分かりますか?
「あっ、回帰(リグレッション)を混入しちゃった」みたいな話じゃありません。「いやいや、前のバグが別のバグを隠していた」っていう方向です。で、それを直したら、さらにその下に別の問題がある。セーターから糸を一本ずつ引っ張っていった結果、手の中に毛糸の山ができて、「そもそも自分は本当にセーターを持っていたのか?」と疑いたくなるような感じです。
それが、Proviaを作っていたSession 6の間に私の身に起きました。Proviaは、EC向けのAIプラットフォームで、店舗オーナーが完全自律型の営業チャットボットを持てるようになります。チャットボットはWhatsApp経由で顧客と会話し、実データベースから商品を提案し、反論に対応し、そして成約につなげます。裏側は、関数呼び出しを備えたGPT-4o-miniで、セマンティックな商品検索のためにpgvectorの埋め込みがPostgreSQLに実装されています。
最初は「手早いデバッグセッション」のはずでした。でも、連鎖する5層のバグを掘り起こす、8時間に及ぶ考古学調査になってしまいました。ここからが全貌です。
セットアップ:ProviaのAIがやっていること
まず最初に、高レベルでこのシステムが行うことを説明します:
- 顧客がメッセージを送る(例:「結婚式用に何か見せて」)
- AIがセマンティック埋め込みを使って商品データベースを検索する
- AIが商品レコメンドを含む応答を生成する
- 会話は続き、AIがコンテキスト・嗜好・会話の段階を追跡する
商品データベースではpgvectorを使用します。各商品には、OpenAIのtext-embedding-3-smallモデルで名前・説明・カテゴリ・雰囲気(バイブ)・その他のメタデータから生成された1536次元の埋め込みが付いています。顧客が何かを求めると、私たちはその問い合わせを埋め込みにして、ベクトル空間内で最も近い商品を見つけます。
シンプルですよね? でも、悪魔は実装にいます。
バグ1:要約の汚染 — 記憶が汚染になるとき
症状
テスターがボットとスーツについてチャットしていました。会話に10メッセージほど入ったところで、話を切り替えて「いや、パーカーを見せて」
ボットの返答は……スーツでした。自信満々に。まるで「パーカー」という言葉が話されていないかのように。
調査
私はログに飛び込みました。pgvectorに送られていた検索クエリは、顧客のメッセージだけではありませんでした。顧客のメッセージに加えて、システムが維持していた会話の要約がプラスされていました。
要約は次のような形でした:
Customer is looking for a $300 formal suit for a wedding occasion.
They prefer dark colors and slim fit. Budget is flexible for the right piece.
この要約が、顧客の最新メッセージに連結されてから埋め込み(embedding)に使われていました。つまり、実際の検索クエリはこうなっていました:
Customer is looking for a $300 formal suit for a wedding occasion.
They prefer dark colors and slim fit. Budget is flexible for the right piece.
show me hoodies
このテキストブロックを埋め込むと、何が得られるでしょう? 80%が「フォーマルなスーツ」で、20%が「パーカー」です。ベクトルの計算は、顧客が気を変えたことなど気にしません。気にするのはトークンの出現頻度と、意味的な重みです。そして要約は、長くて詳細だったため、埋め込みを完全に支配していました。
修正
私は会話の要約を殺しました。完全に。丸ごと取り除きました。
でも「記憶(memory)」という概念そのものを捨てたわけではありません。代わりに、構造化された顧客プロファイル(Customer Profile)を導入しました。スタイルの好み、色、予算、好き・嫌いを追跡するための、軽量な箇条書きのセットです:
interface CustomerProfile {
style_preferences: string[];
colors: string[];
budget: string | null;
likes: string[];
dislikes: string[];
occasion: string | null;
}
重要な設計判断:このプロファイルは、AIが応答をパーソナライズできるように応答(response)プロンプトに注入されますが、検索クエリには決して触れません。検索と記憶を、完全に別の経路に分けました。
気持ちよかったです。バグは潰せた。あとはテストするだけ。
その感覚が続いたのは、だいたい4分でした。
バグ2:生メッセージは検索クエリとして最悪
症状
要約を消したことで、検索は顧客の生のメッセージをクエリとして使うようになりました。次のテストメッセージはこうです:
acctaly i dont want a hoodie i have a wedding ocation
検索結果は、パーカーと結婚式の服のミックスでした。理屈としてはあり得ますが、顧客がパーカーは要らないと明示していることに気づくと、納得できません。
調査
これは、改めて冷静に見たらすぐに分かるタイプでした。顧客のメッセージにはこういう要素があります:
- "hoodie" — 明確に要らないもの
- "wedding" — 明確に欲しいもの
- "acctaly", "dont", "ocation" — どこもかしこもタイプミス
テキスト埋め込み(text embeddings)は否定(negation)を理解しません。「パーカーは欲しくない」というのが「パーカー」の逆の意味だとは分からないのです。埋め込みモデルにとっては、「パーカー」という単語は、「大好き」でも「欲しくない」でも、同じ意味近傍(semantic neighborhood)を発火させます。
そしてタイプミス? text-embedding-3-smallは、単体で見ると意外とうまく処理します。しかし、誤字っている否定と、誤字っている対象を1つのクエリにまとめてしまうと、埋め込みはセマンティックなスムージーになります。何もかも拾って、何も確定しないのです。
修正
私は専用のSearch Call(検索コール)を導入しました。別の、軽量なAI呼び出しです。その役割はただひとつ、顧客が何を求めているかを解釈し、クリーンな検索クエリを生成することです。
const searchInterpretation = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `You are a search query interpreter. Given a customer message,
extract ONLY what they want to find. Ignore negations (what they don't want).
Output a short, clean search phrase.`
},
{
role: "user",
content: `Customer said: "${customerMessage}"`
}
],
max_tokens: 150,
});
入力: 約60トークン。出力: 約20トークン。コスト: ほぼ無視できます。
「acctaly i dont want a hoodie i have a wedding ocation」の場合、検索呼び出しは次を返します: "wedding occasion outfit"。クリーンで正しく、タイプミスなし。
バグを2つ潰した。システムはかなり堅い。検索呼び出しを助けるために、少しだけ文脈を追加してみよう...
バグ 3: ボットの返信優勢 — 部屋で一番うるさい声
症状
検索呼び出しは、少しの文脈があると役立つのではと思いました。そこで、ボットの直前の返信と、顧客の最新メッセージの2つを与えました。
顧客の発言は: "hoodies"
ボットの直前の返信は:
Great choice! For a wedding, I'd recommend our Premium Wool Blend Suit in charcoal —
it's $289 and perfect for formal occasions. We also have the Classic Navy Blazer Set
at $245 which pairs beautifully with dress pants. Would you like to see more formal options?
検索結果: スーツとブレザーです。パーカーは一切見当たりません。
調査
トークン数を数えます。ボットの返信: 約50語分で、スーツ、価格、フォーマルウェアについて書かれている。顧客のメッセージ: 1語 — 「hoodies」。
それらを結合したテキストを埋め込むと、スーツ関連のトークンが「hoodies」のトークンをおよそ50対1で上回ります。埋め込みは「フォーマルなメンズウェア」のベクトル空間にきれいに着地し、「hoodies」はほとんど寄与していない(約ゼロに近い)状態になります。
これは埋め込みがどう機能するかに根本的に関わる問題です。埋め込みは、入力テキスト全体の平均的な意味を表現します。たった1語では、段落に対抗できません。
対策
検索呼び出しには履歴をゼロにします。絶対にゼロ。
// SEARCH CALL — customer's latest message ONLY
const searchMessages = [
{
role: "system" as const,
content: "Extract what the customer wants to search for. Short phrase only."
},
{
role: "user" as const,
content: `Customer said: "${latestCustomerMessage}"`
}
];
これで私がTwo-Context Architecture(2つのコンテキスト・アーキテクチャ)と呼び始めたものができました:
| 検索コンテキスト | 応答コンテキスト | |
|---|---|---|
| 目的 | 何を検索するかを決める | どう応答するかを決める |
| 入力 | 顧客の最新メッセージのみ | 6つのメッセージ + プロフィール + 検索結果 |
| 履歴 | なし | 直近のセッションウィンドウ |
| コスト | 約60トークン | 約500トークン |
検索呼び出しは意図的に無記憶(amnesiac)です。応答AIがコンテキストを扱います。検索AIが意図を扱います。関心の分離ですが、AI呼び出しに対してです。
バグ 4: パジャマ問題 — 「夜」がすべてを意味してしまうとき
症状
検索呼び出しは完璧に動いていました。ですが、ある商品が本来出てこない場所でずっと出てくるようになりました: "Cozy Night Deluxe Loungewear Set."
パジャマです。家でくつろぐための快適なパジャマ。
以下の結果に出てきてしまいました:
- 「date night outfit」(「night」があるため)
- 「evening wear」(「night」が意味的に「evening」に近いため)
- 「casual summer outfit」(「cozy」と「casual」が近い単語同士のため)
調査
これは埋め込みの類似度スレッショルド(閾値)問題でした。私は閾値を0.1に設定していました — つまり、コサイン類似度が0.1を超える任意の商品を一致として返していた、ということです。
文脈として、text-embedding-3-small では、本当に関連のある商品は0.3〜0.5程度のスコアになり、やや関連のある商品は0.15〜0.3程度、ノイズは0.15未満に収まります。
0.1では、膨大な量のノイズをすくい上げていました。パジャマセットは、非常に幅広いクエリに対して、類似度がだいたい0.15〜0.22程度に落ち着いていました。
対策
閾値は0.3で単一に固定します。ニアマッチ枠はなし。きれいに線を引く。
ただし閾値を高くすると、時々結果がゼロになることがあります。そこで私はフォールバック・チェーンを作りました:
async function searchProducts(query: string, storeId: string) {
// Tier 1: Semantic search with strict threshold
let results = await semanticSearch(query, storeId, 0.3);
返却形式: {"translated": "翻訳されたHTML"}if (results.length === 0) {
// Tier 2: ILIKE のテキスト一致(完全なキーワード一致を拾う)
results = await textSearch(query, storeId);
}
if (results.length === 0) {
// Tier 3: 利用可能なカテゴリを返す
const categories = await getStoreCategories(storeId);
return { results: [], categories, fallback: true };
}
return { results, categories: null, fallback: false };
}
4つのバグを修正しました。検索パイプラインはこれで、きれいに、速く、そして正確になりました。次に、実際のレスポンスを確認しました。
バグ 5: 自分のデータを無視するレスポンス
症状
顧客との会話、10メッセージ深さ。すべてスーツの話。顧客が言います:「実は、パーカー(フーディー)を見せて。」
検索呼び出しはパーカーを返します(正しく!)。パーカーは検索結果としてレスポンスのプロンプトに注入されます。
ボットはこう返します:「フォーマルな場では、クラシック・チャコール・スーツを気に入っていただけると思います…」
検索は正しい商品を見つけていました。ところがレスポンスはそれを完全に無視していました。
調査
モデルが実際に見ていたものは次のとおりです。
- システムプロンプト: ストアの人格、販売の指示、トーンのガイダンス
- チャット履歴: スーツに関する10メッセージ(約400トークン)
- 検索結果: パーカー3点(約150トークン)
- 最新の顧客メッセージ: 「実は、パーカーを見せて」(6トークン)
モデルは支配的な話題に従いました。スーツの会話が10メッセージ分あることで強い引力が生まれていたのです。検索結果にあるパーカーは、フォーマルウェアの海に浮かぶ小さな島でした。
修正
私は、顧客の最新メッセージをシステムプロンプトに直接注入し、明確な指示を追加しました。
const systemPrompt = `あなたは ${persona.name}、 ${storeName}の販売アシスタントです。
${persona.instructions}
---顧客の最新メッセージ: "${latestCustomerMessage}"
重要: あなたの返答はこの最新メッセージを直接扱う必要があります。
顧客が新しい話題や商品について尋ねた場合は、それに焦点を当ててください。
以前の会話には焦点を当てないでください。
---
${searchResults ? `顧客のリクエストに一致する利用可能な商品:
${formatProducts(searchResults)}` : ''}
`;
システムプロンプトは、言語モデルから過剰なほど注目されます。会話履歴に入れるだけでなく、顧客の最新メッセージをそこに入れることで、それがモデルにとって「実際に従うべき指示」になります。
最終アーキテクチャ
顧客メッセージ
|
v
SEARCH CALL(約60トークン)
入力: "顧客が言った: '[msg]'. search_products を呼び出してください。"
履歴: なし
|
v
検索パイプライン:
セマンティック検索(閾値0.3)
-> ILIKE によるフォールバック
-> カテゴリによるフォールバック
|
v
RESPONSE CALL(約500トークン)
システム: persona + profile + "Latest: [msg]" + 検索結果
履歴: セッションの直近6メッセージ
|
v
レスポンス + 商品カード
メッセージごとにAI呼び出しを2回。1回は愚直(検索)、もう1回は賢い(レスポンス)。それぞれ、慎重にスコープを絞ったコンテキストウィンドウを持ちます。
数字
| 指標 | 変更前 | 変更後 |
|---|---|---|
| メッセージあたりのトークン数 | 約1,820 | 約830 |
| 100Kメッセージあたりのコスト | 約$30 | 約$14 |
| 削減 | — | 55% |
2つ目のAI呼び出しを追加したことで、総トークン使用量は減少し、55%でした。コンテキストが少なくなり、結果はより良くなり、コストも下がりました。
学び
1. AIのバグは、タマネギのように層になっている
それぞれのバグは、上にある1つを直すまで見えませんでした。これは従来のソフトウェアとは違います。AIのバグはスタックのように積み重なり、ある不適切な振る舞いが別の振る舞いを隠してしまうからです。
2. 埋め込み(Embeddings)は否定を理解できない
「Xは欲しくない」と「Xが欲しい」は、ほぼ同じ埋め込みになります。生のテキストを埋め込まないでください。まずは言語モデルを使って意図を解釈してください。
3. 関心の分離はAI呼び出しにも当てはまる
検索には無記憶が必要です。レスポンスには記憶が必要です。混ぜてしまうと、「パーカーを見せて」と言われたときにスーツになる、ということが起きます。
4. システムプロンプトはハンドルだ
長い会話履歴がモデルをある方向へ引っ張るとき、それを軌道修正するのに十分強いのはシステムプロンプトだけです。
5. トピックの継続だけでなく、切り替えをテストする
バグは顧客が考えを変えたときにだけ現れました。トピックの切り替えこそが、AIシステムが壊れるところです。最重要のテストケースにしてください。
5つのバグ。5つの修正。8時間。実際に動くアーキテクチャ。
そしてたぶん、その下にもさらに5つのバグが隠れていて、適切なクエリが来るのを待っているのでしょう。
私はガザから、AIを活用した販売プラットフォーム Provia を構築しています。すべてのバグ、すべての修正、そしてすべてのアーキテクチャ判断を記録しています。公開して作る実物版については@AliMAfanaにフォローしてください。
関連記事:
