はじめに
電力プラン比較サービス (エネジェント) を個人開発で運営しています。 2026 年 4 月、 「LLM 任せのチャットでは品質が出ない」と痛感し、 1 日かけて三層化アーキテクチャ + ゴールデンテスト 200 件 + 構造化ログ を入れる本気の品質ガチ化スプリントを実施しました。
結論から言うと、 アーキテクチャは正しく改善されたが、 UX は依然として不満足でした。
本記事はその記録と、 LLM チャットを個人開発でやる人が同じ罠にハマらないための知見の共有です。
⚠️ 本記事は「LLM チャットの苦しい現実を共有する」のが主目的です。 「成功事例」ではなく「設計改善しても満足できなかった事例」として読んでください。
1. 背景: 何を作っていたか
経産省登録 47 社の電力プランから、 ユーザーの月額 / 世帯人数を聞いて最適プランを推薦するチャット。
ユーザー: 「3人暮らしで関西」
Bot: (recommend_plans 実行) コスモでんきが○○円お得という結果でした。
技術スタック (個人学習プロジェクト・全 Free 枠で月 0 円運営):
| 層 | 技術 |
|---|---|
| Web LP / Chat UI | Next.js 15 + Vercel Free |
| Bot Webhook | FastAPI on Cloud Run (max-instances=1) |
| LLM | Gemini 2.0 Flash (Free 枠) |
| Embedding | gemini-embedding-001 (768 次元) |
| DB | Supabase Postgres + pgvector |
| ナレッジ | 各社ファクトカード Markdown 39 ファイル |
2. 苦しんだバグの典型例
2-1. ハルシネーション (社混同)
ユーザー: 「まちエネって燃料費調整に上限ある?」
Bot: 「まちエネには『TERASEL マーケットあんしんプラン』(上限 36 円) があります」
原因: pgvector 検索が無条件で全 chunk から類似度 top-k を返しており、 「まちエネ 燃調上限」クエリで TERASEL の chunk が上位に来ていた。 SYSTEM_PROMPT で「source_slug を確認しろ」と書いていたが Gemini Flash は遵守しなかった。
2-2. 関西エリアでアンペアを聞き続ける
ユーザー: 「3人暮らしで関西」
Bot: 「契約アンペア数を教えてください」
ユーザー: 「関西ならアンペアないでしょ」
Bot: 「30A デフォルトで試算します」 ← 関西エリアにアンペア概念なし (kVA 契約)
2-3. 「市場連動嫌」と言われても同じ Looop 系プランを再表示
T1 ユーザー: 「東京で月1万」
Bot: (Looop 系プランを上位に提示)
T2 ユーザー: 「市場連動は嫌なんだけど」
Bot: (Looop 系プランを再提示)
原因: cautions の文字列マッチで「市場連動」を判定していたが、 Looop のプラン名や cautions に「市場連動」が含まれていなかった。
2-4. 「燃調上限なしは嫌」を誤読
ユーザー: 「燃調上限なしは嫌だな」
Bot: (require_fuel_cap=False で上限なしプランを提示)
「上限なし」+ 「嫌」 = 上限ありを要求しているのに、 「上限なし」だけ拾って False に設定するロジックだった。
3. 改善アプローチ: 三層化 + 構造化フラグ
ユーザー入力
↓
Layer 1: 決定論的ヒアリング (regex + 辞書)
- エリア / 月額 / 世帯人数を抽出
- 関西/中国/四国/沖縄 → アンペアスキップ
- 「わからん」連発 → デフォルト値で即試算
↓
Layer 2: ルーティング (action 決定)
- recommend / refine_recommend / factual / ask / clear / greet
- ConversationStage 遷移マトリクス
↓
Layer 3: LLM (役割を絞る)
- 事実回答 (search_knowledge) のみ
- 短い応答テンプレ生成
3-1. PlanFeatureFlags 構造化
DB に既にあった fuel_calc_profiles.fuel_method fuel_legacy_params.cap_fuel_price plan_rates.cancellation_fee plan_system_fees から構造化フラグを生成:
@dataclass(frozen=True)
class PlanFeatureFlags:
is_market_linked: bool
fuel_adjustment_type: Literal["market_linked", "regulated_cap", "standard", "none", "other"]
has_fuel_cap: bool
has_capacity_fee: bool
cancel_fee_type: Literal["none", "fixed", "conditional", "unknown"]
risk_level: Literal["low", "medium", "high"]
これで _is_market_linked() の文字列マッチを廃止 → Looop 系除外が確実に効くようになった。
3-2. Scoped RAG (社混同事故の構造的防止)
match_chat_knowledge_chunks RPC に source_slug_filter 引数追加:
CREATE OR REPLACE FUNCTION match_chat_knowledge_chunks(
query_embedding vector(768),
match_count int DEFAULT 5,
source_slug_filter TEXT DEFAULT NULL
) RETURNS TABLE (...)
LANGUAGE sql STABLE AS $$
SELECT ...
FROM chat_knowledge_chunks c
WHERE source_slug_filter IS NULL OR c.source_slug = source_slug_filter
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
ユーザー発話から retailer slug を抽出 (bot/retailer_resolver.py) → RPC に prepopulate → 取得後 validator で他社 chunk が混入していないか検証。 これで**「まちエネ質問で TERASEL chunk がプロンプトにすら入らない」状態に**。
3-3. ゴールデンテスト 200 件
tests/test_chat_golden.py + tests/test_chat_golden_extended.py に 200 ケース固定。 0.65 秒で全 pass。 CI 必須化。
4. ところが、 やっぱりバグが出た
200 件パスの状態で本番デプロイ。 その後ユーザー (= 自分) が実機でテストしたら 新しいバグが出た:
ユーザー: 「燃調上限なしは嫌だな」
Bot: (require_fuel_cap=False のままで同じ結果を再提示)
原因: 私が書いた _extract_preferences の正規表現:
if "上限なし" in text or "上限不要" in text:
s.require_fuel_cap = False # ← 「上限なし」+ 「嫌」のニュアンスを取れていない
ゴールデンテストにも、 このパターンが入っていなかった。 想像で書いたシナリオは「市場連動嫌」のような単純パターンばかりで、 複合ニュアンスを入れていなかった。
ユーザー: 「コスモでんきって燃調なし?」
Bot: (refine_recommend 扱いで同じ結果を再提示)
原因: has_recommended=True + 「燃調」キーワード で機械的に refine 判定。 社名 + 疑問形は事実質問と判定すべきだったが、 そのルールが抜けていた。
5. 私が認めなければならない 3 つの事実
5-1. トークン量で品質は決まらない
私は 1 日で 200 万トークン以上を投入し、 1670 行の新規 Python と 200 件のゴールデンテストを書きました。 でもユーザーが実機で踏むバグはまだ出ます。
理由: LLM チャットの品質は「実ユーザー発話の網羅性」 × 「コードの解釈力」 × 「観測 → golden 化の運用ループ」の掛け算で決まる。 トークン量はどれにも直接効かない。
5-2. 正規表現と if 文のチェーンで自然言語の意図を拾うのは原理的に無理
_extract_preferences の現状:
negative_markers = ["嫌", "イヤ", "やだ", "やめ", "避け", "なし", "外し", "除外", ...]
if "上限なし" in text or "上限不要" in text:
s.require_fuel_cap = False
if "燃調上限" in text or "上限あり" in text:
s.require_fuel_cap = True
# ↑ 「上限なし嫌」のような複合表現で破綻
ユーザーは「上限ないのは嫌」「上限変動するの怖い」「燃調変わるの避けたい」など無限の言い回しで意図を表現する。 これを正規表現の if 文で拾い続けるのは継ぎ足しの永久戦争になる。
5-3. 個人開発で実機ログから golden を増やす運用は機能しない
TurnLog (構造化ログ) を Phase 3c で作りました。 chat_turn_logs テーブルに gzip 圧縮で turn ごとの全情報が残ります。 これを「週次 audit して再現性のあるバグを golden 化する」運用フロー (docs/golden-test-update-flow.md) も書きました。
でもユーザーが 1 人 (= 私) なのでサンプルが集まらない。 大規模サービスなら回るが個人開発では成立しない設計。
6. ゼロベースで考え直したら見えた選択肢
案 A: チャット UI を諦めてフォーム UI に集約 (最有力)
- フォーム入力 (エリア・アンペア・月額・絞り込み条件チェックボックス) なら意図解釈ゼロ
- 既に推薦エンジン + PlanFeatureFlags 構造化フラグは完成済 → フォームから呼ぶだけ
- 「自然言語チャット」の面白さは消えるが、 品質は確定
- 訴訟リスクも下がる (Bot が変なこと言うリスクゼロ)
案 B: LLM を Pro モデルに上げる
- Gemini 2.0 Pro / Claude Sonnet / GPT-4 mini なら指示遵守 + 意図解釈の精度が大幅向上
- 月数百円〜数千円。 Free 枠は維持できないが個人で払える額
- 同じ設計でも品質は一段上がる
案 C: 機能縮小
- チャット UI は捨てて、 「ファクトカードを質問できる検索画面」だけ残す
- Scoped RAG は完成済なので社混同ゼロは保証される
- 推薦は提供しない
7. 学んだこと: LLM チャット個人開発の現実
7-1. 「LLM が万能」前提で設計するな
ヒアリング・状態管理・絞り込み判断・事実回答全部 LLM に任せると、 SYSTEM_PROMPT 1000 行で破綻する。 Gemini Flash は 500 行超えると指示遵守が揺れる。
7-2. コードで決定論化する範囲を最大化しろ
私は Phase 2 で三層化 (intake / router / Gemini) し、 Phase 3 で構造化フラグ + Scoped RAG を入れた。 これは正しい方向だが、 「絞り込み条件の意図解釈」だけは LLM に戻すべきだった。 ヒューリスティックでは網羅できない。
7-3. ゴールデンテストは「想像で書く」と無意味
私が書いた 200 件はパターンの 30% も網羅できていなかった。 実ユーザーの言い回しは想像を超える。 必要なのは実機ログだが、 個人開発では集められない。
7-4. トライアンドエラーが収束しない時はアーキテクチャを疑え
「最新 Chat 確認して」「またおかしい」を 5 ターン繰り返したら、 それは設計の限界サイン。 PROMPT 追加では解決しない。 三層化のような構造変更が必要。
7-5. 個人開発の Free 枠縛りで「ChatGPT ライク」は無理
ChatGPT・Claude.ai が滑らかに動くのは:
- 内部に数百万件のゴールデンセット
- 役割別の異なるモデル (Opus / GPT-4 / Sonnet)
- 大規模な観測性インフラ
個人開発でこれらの代替を作るのは現実的でない。 「LLM チャット風」を諦めて用途を絞るのが現実解。
8. これからどうするか (未確定)
正直、 この記事を書いている時点で何で進めるか決まっていません。 案 A (フォーム集約) が最も現実的だと思っていますが、 「自然言語チャット」の体験を諦めるのは惜しい。
ただ確実なのは: トライアンドエラーで PROMPT を追加し続けるアプローチは終わったということ。
明日以降、 案 A / B / C のどれを選ぶか、 もう一度ゼロベースで設計します。
9. まとめ
- LLM チャットは「LLM 任せ」では品質が出ない
- アーキテクチャ改善 (三層化 + 構造化 + ゴールデン 200 件) を 1 日で本気でやっても、 UX は満足できなかった
- 正規表現と if 文で自然言語の意図を拾うのは原理的に破綻している
- 個人開発・Free 枠での LLM チャットには構造的限界がある
- 「LLM チャット風」を諦めて用途を絞るのが現実解の可能性
技術ブログでよく見る「LLM で○○を作った」系の記事は成功事例が多いですが、 やってみてダメだった事例こそ次の人の役に立つはずなのでこの記事を書きました。
同じ罠にハマっている人の参考になれば。 質問・反論・「こうすれば解決する」のコメント大歓迎です。
付録: 公開コードと関連リンク
(社名比較・批評を含まないため、 個別社のリンクは省略します)