0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMチャットを個人開発で本気で品質ガチ化してみた、 でもUXは満足できなかった話

0
Last updated at Posted at 2026-04-28

はじめに

電力プラン比較チャット (エネジェント) を個人開発で運営しています。 経産省登録の電力小売は 812 社ありますが、 そのうち主要 47 社のプランを約款ベースで構造化して比較対象にしています。

2026 年 4 月、 「LLM 任せのチャットでは品質が出ない」と痛感し、 1 日かけて三層化アーキテクチャ + ゴールデンテスト 200 件超 + 構造化ログ を入れる本気の品質ガチ化スプリントを実施しました。

結論から言うと、 アーキテクチャは正しく改善されたが、 UX は依然として不満足でした。

⚠️ この記事は「LLM チャットの苦しい現実を共有する」のが主目的です。 「成功事例」ではなく「設計改善しても満足できなかった事例」として読んでください。


0. この記事に出てくる電力業界の最低限の用語

LLM 設計の本筋には電力知識は不要ですが、 バグ事例で電力用語が出てくるので最初にまとめます。 戻ってきて参照してください。

用語 短い説明
新電力 大手電力会社 (東京電力 等) 以外の電力小売事業者。 経産省登録 800 社超
燃料費調整 (燃調) 化石燃料の輸入価格に応じて毎月変動する従量料金部分
燃調上限 燃調が一定額を超えたら頭打ちになる仕組み。 「上限あり」 = ユーザー保護 / 「上限なし」 = 燃調が無制限に上振れし得る
市場連動型 燃調が JEPX (卸電力市場) のスポット価格に連動するプラン。 安い時は得だが、 需給逼迫時に大きく値上がりする
アンペア / kVA 契約 関東・中部・北海道・東北・北陸・九州はアンペア契約 (10A〜60A段階制)。 関西・中国・四国・沖縄は最低料金制 / kVA 契約でアンペアの概念がない
解約金 契約期間の途中解約時に発生する違約金。 0 円のプランも多いが期間縛りありプランは 3,300 円程度かかることがある

1. 背景: 何を作っていたか

ユーザーの月額 / 世帯人数 / エリアを聞いて、 約 47 社の電力プランから最適なものを推薦する LINE / Web チャット。

ユーザー: 「3人暮らしで関西」
Bot: コスモでんきが○○円お得という結果でした。

技術スタック (個人学習プロジェクト・全 Free 枠で月 0 円運営):

層 技術
Web LP / Chat UI Next.js 15 + Vercel Free
Bot Webhook FastAPI on Cloud Run
LLM Gemini 2.0 Flash (Free 枠)
Embedding gemini-embedding-001 (768 次元)
DB Supabase Postgres + pgvector
ナレッジ 各社ファクトカード (社別の事実情報を Markdown で記述、 37 社分)

2. 苦しんだバグの典型例

実機テストで出たバグを 4 つ。 それぞれ「電力業界固有の症状」と「LLM チャット一般に通じる教訓」を併記します。

2-1. 「まちエネの燃調上限ある?」と聞いたら別社「TERASEL」のプラン名で答えた (RAG ハルシネーション)

実機ログ:

ユーザー: 「まちエネって燃料費調整に上限ある?」
Bot:    「まちエネには『TERASEL マーケットあんしんプラン』(上限 36 円) があります」

「まちエネ」と「TERASEL」は別の電力会社です。 まちエネのファクトカードを Bot は持っていたのに、 TERASEL の情報が混ざって回答されました。

原因: pgvector のベクトル検索が「全社のチャンクを混ぜた状態」で類似度トップ k を返していて、 「まちエネ 燃調上限」というクエリで TERASEL のチャンクが上位に来たから。 SYSTEM_PROMPT で「ユーザーが指定した社のチャンクだけ引用しろ」と書いていたが Gemini Flash は遵守しなかった。

LLM チャット一般の教訓: ユーザーが固有名詞を指定したら、 RAG のベクトル検索は DB レイヤーで「その固有名詞のスコープ」に絞ってから類似度検索するべき。 SYSTEM_PROMPT で「混ぜるな」と書いてもモデルは守りきれない。

2-2. 「3人暮らしで関西」と言ったのにアンペアを聞き続ける (ドメイン知識欠落)

ユーザー: 「3人暮らしで関西」
Bot:    「契約アンペア数を教えてください」
ユーザー: 「関西ならアンペアないでしょ」
Bot:    「30A デフォルトで試算します」 ← 関西は kVA 契約でアンペアの概念なし

関西電力管内は最低料金制 (kVA 契約) で、 アンペア契約という概念自体が存在しません。 LLM はこのレベルの業界ローカルルールを知らないので、 ヒアリングで存在しない属性を聞き続けます。

LLM チャット一般の教訓: ドメイン固有のローカルルール (「この条件下では Y という属性は存在しない」) はコード側でガード必須。 SYSTEM_PROMPT に書いても忘れる。

2-3. 「市場連動は嫌」と言ったのにまた Looop を勧めてくる

Looop でんきの主力プラン「スマートタイムONE」は市場連動型 (JEPX 連動) です。

T1 ユーザー: 「東京で月1万」
Bot:    (Looop でんき スマートタイムONE を 1 位推薦)
T2 ユーザー: 「市場連動は嫌なんだけど」
Bot:    (Looop でんき スマートタイムONE を再提示) ← 嫌悪条件が反映されない

原因: 「市場連動型かどうか」を判定するときに、 各プランの注意書きテキストに「市場連動」という文字列が含まれているかを見ていた。 でも Looop のプラン情報には「市場連動」というキーワードが書かれていなかった (DB の構造化フィールドには jepx_linked と入っているだけ)。

LLM チャット一般の教訓: 属性判定を「テキストヒット」でやるとデータの表記ゆれで簡単に詰む。 DB に構造化済みの値があるなら、 それを見るべき。

2-4. 「燃調上限なしは嫌」を「燃調上限なしを希望」と誤読した

ユーザー: 「燃調上限なしは嫌だな」 (= 燃調上限**あり**を希望している)
Bot:    (燃調上限**なし**プランを提示) ← 反対の意味で受け取った

ユーザーは「燃調上限なしのプランは嫌だ = 燃調上限ありが欲しい」という意図でしたが、 Bot は「燃調上限なし」のキーワードだけ拾って「上限なしを希望してる」と解釈しました。

原因: ユーザー意図解釈を「『上限なし』というキーワードがあったら『上限なしを希望』とみなす」という単純な仕組みで書いていた。 「上限なし」+ 「嫌」 という複合的な否定が処理できない。

LLM チャット一般の教訓: 「X なしは嫌」 = 「X あり」のような複合的な否定を文字列マッチで拾うのは原理的に困難。 意味解釈は LLM 本来の得意領域で、 そこをコードで頑張ろうとするのは設計の方向性ミス。


3. 改善の試行錯誤: SYSTEM_PROMPT → コード → コードの限界

ここまでのバグに対して、 私は段階的にアプローチを変えてきました。 最初から正規表現で書いていたわけではなく、 「SYSTEM_PROMPT で頑張る → 限界が見えてコード化に振り切る → コード化にも限界が見えた」 という 3 段階の経緯です。

Phase 1: SYSTEM_PROMPT で全部頑張った時期 (失敗)

最初は「Gemini は賢いから、 ルールを SYSTEM_PROMPT に書けば守ってくれる」と信じていました。

あなたはエネジェントという電気代見直し AI です。
- ユーザーが指定した社のチャンクのみを引用してください (混ぜないこと)
- 関西エリアでアンペアを聞いてはいけません (kVA 契約のため)
- 「○○嫌」と言われたら、 ○○系プランを除外してください
- 燃調上限なしは「上限なしを希望」ではなく「上限ありを希望」と解釈してください
- (... ここに延々とルールが続く ...)

SYSTEM_PROMPT は 1000 行近くまで膨らみました。 でも結果は:

  • まちエネ × TERASEL の社混同 → 直らない (Gemini Flash がスコープ無視して別社を引用)
  • 関西アンペア質問 → 直らない (たまに守るが揺らぐ)
  • 「市場連動嫌」フィルタ → 直らない (LLM の出力が安定せず、 同じプランを出す時もある)
  • 応答が崩壊する: ルールが多すぎて Gemini が混乱し、 「カードに表示済みの数字を本文にも全部書く」「同じことを繰り返す」など別のバグが頻発

最終的に「追加すればするほど別のところが壊れる」状態になりました。 これがアプローチを変えるきっかけです。

教訓 1: SYSTEM_PROMPT の長さに比例して LLM の指示遵守率は下がる。 軽量モデル (Gemini Flash 等) は特に顕著。 「書けば守ってくれる」は幻想。

Phase 2: コードで決定論化した時期 (一見うまくいった)

「LLM に任せるとブレるなら、 ルール処理はコードで確定的にやろう」と方針転換。 三層化アーキテクチャを導入しました。

ユーザー入力
    ↓
Layer 1: 決定論的ヒアリング (regex + 辞書)
  - エリア / 月額 / 世帯人数を抽出
  - 関西/中国/四国/沖縄なら「アンペア」を聞かない (ドメイン知識をコード化)
  - 「わからん」連発 → デフォルト値で即試算
    ↓
Layer 2: ルーティング (action 決定)
  - 推薦 / 絞り込み再ランク / 事実質問 / ヒアリング継続
  - 会話の状態 (Stage) 遷移マトリクスで管理
    ↓
Layer 3: LLM (役割を絞る)
  - 事実回答 (RAG 経由) のみ
  - 短い応答テンプレ生成

各層で具体的にやったこと:

3-1. プラン属性を構造化フラグで持つ (文字列マッチからの卒業)

DB に既に構造化された値があったので、 プランごとに以下のフラグを各プランに付与:

  • 市場連動型かどうか (boolean)
  • 燃調上限ありか (boolean)
  • 容量拠出金ありか (boolean)
  • 解約金: なし / 固定額 / 条件付き / 不明
  • リスク水準: 低 / 中 / 高

これで「市場連動嫌」と言われたとき、 「市場連動型のプランを除外」と確定的にフィルタできるようになりました。 注意書きテキストの曖昧マッチから卒業。

3-2. ユーザー指定の社にスコープを絞った RAG (社混同事故の構造的防止)

ベクトル検索の SQL 関数に「対象社のみに絞る」フィルタ引数を追加:

SELECT ...
FROM chat_knowledge_chunks c
WHERE c.source_slug = $対象社  -- ← 追加。 NULL なら従来通り全社検索
ORDER BY c.embedding <=> $クエリベクトル
LIMIT $トップk;

ユーザーの発話から社名を抽出 (「まちエネ」 → machiene) して、 検索の引数として明示的に渡す。 これで「まちエネ質問で TERASEL のチャンクがプロンプトにすら入らない」状態になりました。

LLM チャット一般の教訓: ユーザーが固有名詞を指定した時の RAG は、 SYSTEM_PROMPT で「混ぜるな」と書くより、 DB レイヤーで物理的にスコープを絞るのが確実。

3-3. ユーザー意図解釈も正規表現で書いた

「市場連動嫌」「解約金なしがいい」「燃調上限ほしい」のような絞り込み要望も、 正規表現と if 文で処理:

「嫌」「やだ」「避け」「外し」 → 嫌悪表現
「ほしい」「がいい」「だけ」  → 希望表現
「なし」「不要」「ゼロ」      → ○○なし表現

if 「市場連動」が入力に含まれる and 嫌悪表現が含まれる:
    exclude_market_linked = True
if 「上限なし」 or 「上限不要」が含まれる:
    require_fuel_cap = False

3-4. ゴールデンテスト 200 件超

「この入力でこの出力が出る」を CI で固定。 1 秒以内で全 pass。

Phase 2 の結果

  • まちエネ × TERASEL の社混同 → 完全に解決 (DB レイヤーで物理的に防止)
  • 関西アンペア質問 → 解決 (コードで関西時はアンペア slot をスキップ)
  • 「市場連動嫌」フィルタ → 解決 (構造化フラグで確定的判定)
  • ゴールデンテスト 200 件全 pass

一見、 全部解決したように見えました。 「LLM 任せの限界」は乗り越えた手応えがありました。

Phase 3: コードの限界が露呈した時期 (現在)

200 件パスで本番デプロイ。 その後 ユーザー (= 自分) が実機で試したら、 また新しいバグが出ました。


4. コードの限界も見えた

実機で踏んだ新しいバグは 2 つ。 どちらも正規表現で意図解釈をやろうとした副作用です。

4-1. 「燃調上限なしは嫌」誤読バグ (再掲)

ユーザー: 「燃調上限なしは嫌だな」 (= 燃調上限**あり**を希望している)
Bot:    (燃調上限**なし**プランを提示) ← 反対の意味で受け取った

私が書いたコード:

if 「上限なし」 or 「上限不要」が入力に含まれる:
    require_fuel_cap = False  # ← 「上限なしは嫌」も「上限なし」hit で False に倒れる

「上限なし」+ 「嫌」という複合表現は処理できていません。 ゴールデンテストにもこのパターンを入れていなかったから、 200 件 pass でも検知できなかった。

私が想像で書いたゴールデンは「市場連動嫌」「解約金なしがいい」のような単純パターンばかり。 「○○なしは嫌 (= ○○あり要求)」のような複合ニュアンスは入れていなかった。 200 件パスは「自分が想定した範囲内では」正しいだけでした。

4-2. 社名+疑問形が refine 扱いになるバグ

ユーザー: 「コスモでんきって燃調なし?」 (純粋に事実を聞いている)
Bot:    (前回の Bot 応答と同じ「現在ご契約中の…が最もお得」を再提示)

「すでに 1 回推薦済み」+ 「燃調」のような専門キーワードを含む短文 = 「絞り込み再ランクの要求」と判定するルールを書いていました。 でもユーザーは絞り込みではなく事実を聞いているだけ。 「社名 + 疑問形」は事実質問と判定すべきだったが、 そのルールが抜けていた。

4-3. このバグは「コードを直し続ける」では終わらない

「『燃調上限なしは嫌』を上限ありと解釈する if 文」を追加すれば 4-1 は直ります。 でもユーザーは:

  • 「上限ないのは嫌」 (「は」がない)
  • 「上限変動するの怖い」 (「燃調」と書いてない)
  • 「燃調変わるの避けたい」 (「上限」と書いてない)
  • 「上限ないと不安だな」 (婉曲的な希望)
  • 「上限ありじゃないとやだ」 (二重否定)

…と無限の言い回しで同じ意図を表現してきます。 1 つ直すと別のパターンが出てくる、 永久戦争。


5. 私が認めなければならない 3 つの事実

5-1. トークン量で品質は決まらない

1 日で相当量のトークンを Claude Code に投入し、 約 1500 行の新規 Python と 200 ケース超のゴールデンテストを書いた。 でもユーザーが実機で踏むバグはまだ出る。

LLM チャットの品質は「実ユーザー発話の網羅性」 × 「コードの解釈力」 × 「観測 → ゴールデン化の運用ループ」の掛け算で決まる。 トークン量はどれにも直接効かない。

5-2. 正規表現と if 文のチェーンで自然言語の意図を拾うのは原理的に無理

「燃調上限なしは嫌」のような表現を文字列マッチで処理しようとすると:

  • 「嫌」の同義語: 嫌、 イヤ、 やだ、 やめ、 避けたい、 怖い、 不安、 …
  • 「なし」の同義語: なし、 ない、 無い、 ゼロ、 なくて、 …
  • 否定の入れ子: 「○○なしじゃない方がいい」 = 「○○なしを希望」
  • 反語: 「○○なしの何が悪いの?」 = 場合により逆
  • 婉曲: 「○○ない方が安心するな」 = ○○なしを希望

無限の言い回しに対応するには、 意味解釈は LLM の本来の得意領域であり、 そこを正規表現で頑張るのは設計の方向性ミス。

5-3. 個人開発で実機ログから golden を増やす運用は機能しない

各ターンの全情報を構造化ログに残し、 「週次 audit して再現性のあるバグを golden 化する」運用フローも書いた。

でもユーザーが 1 人 (= 私) なのでサンプルが集まらない。 ChatGPT・Claude.ai のようなサービスは内部に膨大な実ユーザー発話ログがあり、 そこから日々ゴールデンや評価セットを更新しているが、 個人開発でこれの代替を作るのは現実的でない。


6. ゼロベースで考え直したら見えた選択肢

正規表現で意図解釈は無理、 ということが確定したうえで、 ゼロベースで取りうる打ち手を整理します。

案 A: チャット UI を諦めてフォーム UI に集約

  • フォーム入力 (エリアのプルダウン + 月額の数字 + 「市場連動を含める」チェックボックス 等) なら意図解釈ゼロ
  • 既に推薦エンジンと構造化フラグは完成済 → フォームから呼ぶだけ
  • 「自然言語チャット」の面白さは消えるが、 品質は確定
  • Bot が変なこと言うリスクもゼロ

→ 品質は最も安定するが、 チャットの体験は捨てる

案 B: LLM を上位モデルに上げる

  • Gemini 2.0 Pro / Claude Sonnet / GPT-4 mini なら指示遵守と意図解釈の精度が大幅向上
  • 月数百円〜数千円。 Free 枠は維持できないが個人で払える額
  • 同じ設計でも品質は一段上がる

→ モデル力で殴る。 シンプルだが本質的

案 C: SYSTEM_PROMPT の中身を RAG で動的注入

「SYSTEM_PROMPT が 1000 行になって LLM が守らないなら、 関連ルールだけ毎ターン RAG で取り出して注入すればよい」 という発想。 たとえば knowledge/rules/kansai-no-ampere.md knowledge/rules/fuel-cap-nuance.md のように個別ルールを Markdown で持ち、 ユーザー発話に関連するものだけ取り出してプロンプトに入れる。

良くなる点:

  • プロンプトのサイズ問題は解決する (毎ターン関連ルールだけ)
  • ルール追加コストが下がる (Markdown 1 ファイル足すだけ)
  • どのルールがこの応答に使われたか追跡可能

良くならない点 (重要):

  • 指示遵守は LLM 自体の能力に依存する。 RAG で「燃調上限なしは嫌 = 上限あり要求」というルールを取り出して Gemini Flash に渡しても、 同じ Flash がそのルールを正しく適用するかは別問題
  • 意図解釈そのものはモデルの言語理解力。 ルールの有無ではない
  • RAG の検索精度問題: 「関西で月1万」発話から「関西アンペアなしルール」を 100% 引いてくる保証がない

→ プロンプトサイズ問題は解決するが、 LLM の解釈力問題は変わらない

案 D: 意図解釈だけ別の (重い) LLM で 1 回呼ぶハイブリッド (有力)

「軽量モデル全任せ」でも「コード全任せ」でもなく、 意図解釈の場面だけ重いモデルを叩く という割り切り。

ユーザー入力
  ↓
コード判定: 「絞り込み要望っぽい」?
  ↓ Yes
  → Claude Haiku / Gemini Pro に「JSON で意図出して」と依頼:
      入力: 「燃調上限なしは嫌だな」
      出力: {
        "exclude_market_linked": false,
        "require_fuel_cap": true,    ← ニュアンス正しく解釈
        "exclude_capacity_fee": false,
        "confidence": "high"
      }
  ↓
コード: JSON を構造化フラグに反映 → 推薦エンジン呼び出し

意図解釈は重いモデルに 1 ターンだけ仕事させて JSON で受け取る。 それ以外 (推薦実行・カード生成・UI 表示) は今までのコードのまま。

良くなる点:

  • 重いモデルは「燃調上限なしは嫌」のような複合表現を正しく解釈できる
  • 意図解釈の責任をLLM 本来の得意領域に戻す
  • JSON 出力なので結果は構造化されており、 後続処理はコードで決定論的に

コスト:

  • Claude Haiku なら 1 回数円。 1 日 100 turn × 30 日 = 数百円/月
  • Free 枠は超えるが個人で払える

→ LLM の使い分けが本質的解決に最も近い

案 E: 機能縮小 (チャットを捨てる)

  • チャット UI は捨てて、 「ファクトカードを質問できる検索画面」だけ残す
  • スコープ絞り RAG は完成済なので社混同ゼロは保証される
  • 推薦は提供しない

→ 問題そのものを消す。 サービスとしては縮小


7. 学んだこと: LLM チャット個人開発の現実

7-1. 「LLM が万能」前提で設計するな

ヒアリング・状態管理・絞り込み判断・事実回答全部 LLM に任せると、 SYSTEM_PROMPT 数百行で破綻する (体感)。 軽量モデル (Gemini Flash 等) は特に指示遵守の揺らぎが大きい。

7-2. コードで決定論化する範囲を最大化しろ。 ただし「意味解釈」は LLM に戻せ

  • ✅ コード化すべき: 数値抽出 / エリアコード変換 / フィルタ実行 / 状態遷移 / カウント / 構造化属性で判定
  • ✅ LLM に任せるべき: 意図解釈 / 自然言語の表現ゆれ / 文脈依存の解釈

私は意図解釈までコードで頑張ろうとして、 「燃調上限なしは嫌」誤読のような事故を起こした。

7-3. ゴールデンテストは「想像で書く」と無意味

私が書いた 200 件超は実機パターンの 30% も網羅できていなかった。 実ユーザーの言い回しは想像を超える。

解決策候補: ① ChatGPT/Claude に「あなたが想定するユーザー発話バリエーションを 100 個出して」と依頼してテストに追加 / ② ベータユーザー募集で実機ログを集める / ③ Discord 等のコミュニティで使ってもらう

7-4. トライアンドエラーが収束しない時はアーキテクチャを疑え

「最新のチャット見て」「またおかしい」を 5 回繰り返したら、 それは設計の限界サイン。 SYSTEM_PROMPT 追加では解決しない。 三層化のような構造変更が必要。

7-5. 個人開発の Free 枠縛りで「ChatGPT ライク」は無理

ChatGPT・Claude.ai が滑らかに動くのは、 内部に数百万件のゴールデンセット、 役割別の異なるモデル、 大規模な観測性インフラがあるから。 個人開発で代替を作るのは非現実的。 「LLM チャット風」を諦めて用途を絞るのが現実解。


8. これからどうするか (案 D で進める方向)

書いていて整理が進みました。 案 D (意図解釈だけ重い LLM で 1 回呼ぶハイブリッド) が次の有力候補です。 理由:

  • 案 A (フォーム集約) は安全だがチャット体験を捨てる
  • 案 B (全部 Pro モデル) はコストが高く全ターン Pro は過剰
  • 案 C (RAG 化) は LLM の解釈力問題を解決しない
  • 案 D は意図解釈の 1 場面だけ重いモデルを使うので、 月数百円で品質を上げつつ Free 枠の良さも残せる
  • 既存の三層化アーキテクチャと自然に組み合わせられる (Layer 1.5 として「重いモデルでの意図抽出」を挟むだけ)

ただ、 トライアンドエラーで PROMPT を追加し続けるアプローチは終わったことだけは確実です。


9. まとめ

  • Phase 1: 「LLM 任せ」(SYSTEM_PROMPT で頑張る) では品質が出ない。 軽量モデルは特に揺らぐ
  • Phase 2: 「コード任せ」(三層化 + 構造化フラグ + 正規表現) で多くの問題が解決した
  • Phase 3: でも 「意図解釈」までコードでやるのは原理的に破綻。 「○○なしは嫌」のような複合表現は永久戦争になる
  • 次の打ち手: SYSTEM_PROMPT を RAG 化しても解釈力は変わらない。 意図解釈だけ重いモデルで 1 回呼ぶハイブリッド が本質的解決に最も近い
  • 結論: 「コード化すべき範囲」と「軽量 LLM に任せる範囲」と「重い LLM を呼ぶ場面」 の 3 つを見極めるのが LLM チャット設計の核心
  • 個人開発・Free 枠での LLM チャットには構造的限界 (実ユーザーログが集まらない 等) があり、 完全 Free 枠での「ChatGPT ライク」は無理。 重要な場面だけ少額課金を許容するのが現実解

技術ブログでよく見る「LLM で○○を作った」系の記事は成功事例が多いですが、 やってみてダメだった事例こそ次の人の役に立つはずなのでこの記事を書きました。

同じ罠にハマっている人の参考になれば。 質問・反論・「こうすれば解決する」のコメント大歓迎です。


付録: 関連リンク

  • サービス: https://enegent.jp
  • 関連記事 (同じプロジェクトの構造化パイプライン解説) は本アカウントの過去記事をご参照ください
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?