TL;DR
- 個人開発の電気プラン比較サイト「エネジェント」 (https://enegent.jp) に、 LINE で何でも相談できる AI エージェントを乗せました
- 動機は「シミュレーター・解説記事・約款の3つの情報源を行ったり来たりするのがしんどい」 という、 自分でサイトを作って初めて気づいた UX の限界です
- 構成はざっくり、 LINE Webhook → Cloud Run → Gemini 2.5 Flash + 3 ツール (
recommend_plans/search_knowledge/query_plans_by_condition)。 同じツールセットを MCP プロトコル経由でも呼び出せる形にしてあります - ポイントは「Gemini が会話の流れで複数ツールを自由に組み合わせる」 ことで初めて、 3 つの情報源が 1 つの会話に統合される、 という設計です
1. 動機: 自分のサイトなのに、 自分が使うのが面倒だった
エネジェントは経産省登録の小売電気事業者 800 社超をマスタに取り込み、 主要 49 社・345 プランを約款ベースで構造化して比較する個人開発サービスです。
サイトを作っていく過程で、 ユーザーが情報にアクセスする経路が 3 つ 並列で生えていました。
- シミュレーター — エリア・アンペア・月額を入れて推薦結果が出る
- 解説記事 — 157 記事の電気代解説 (基礎知識・各社レビュー・エリア別 etc.)
- 約款 (ファクトカード) — 49 社の運営会社・燃調方式・解約金条件・登録番号まで構造化したナレッジ
サイトとしてはこれで「全部揃ってる」 つもりでした。 けれど、 自分が実際に「電気代見直すか…」 と思ってサイトを開いてみると、 欲しい答えにたどり着くまでに 3 つの UI を行き来している自分 がいました。
「東京で月 1 万円ぐらいで安いプラン何?」
→ シミュレーターを開く
「あ、 1 位の会社って燃調どうなってるんだろ」
→ 解説記事を開く
「市場連動嫌だから外したいな」
→ 解説記事と約款を開く
「で、 結局市場連動を外した上での 1 位は?」
→ シミュレーターに戻って、 絞り込み条件を再入力
これ、 自分が作った張本人ですら毎回つらい。 800 社のデータを構造化してファクトカードまで用意したのに、 ユーザーがその恩恵を受けるには UI を 3 つ往復する根性が要る。 ライト層が「電気代見直したい」 と思って訪れて、 ここまで往復してくれる確率は低いです。
「1 つの会話の中で全部聞けたら、 そのまま答えが出る」 のが正解だな、 と素直に思いました。 そこで LINE Bot に AI エージェントを乗せました。
2. できあがったもの: LINE で 1 つの会話で済む
実際の会話の流れはこんな感じです。 (実際の応答文をベースに、 個人特定情報を伏せて再現)
ユーザー: 東京で月 1 万円ぐらい、 30A です。 安いプラン教えて
エージェント: 東京エリア・30A・月 10,000 円の条件で比較しました。
1 位は ◯◯電気の△△プラン (年間約 5,400 円お得)、 2 位は…
[プランカードを Flex Message で表示]
ユーザー: 1 位のプラン、 燃調どうなってるの? 上限ある?
エージェント: ◯◯電気の△△プランは、 燃料費調整の上限を撤廃しています。
燃料価格が上がったときに請求にそのまま反映される仕組みです。
ご参考まで、 公式約款のリンクは…
ユーザー: それ嫌だな。 燃調上限あるプランで絞り込んで
エージェント: 燃調上限ありの条件で再検索しました。
新しい 1 位は ◇◇電気の□□プランで…
3 ターンの中で、 エージェントは裏でこういう動きをしています。
| ターン | 呼ばれたツール | 何をしているか |
|---|---|---|
| 1 | recommend_plans |
シミュレーターと同じ計算ロジックを呼んで上位 5 件を返す |
| 2 | search_knowledge |
約款ファクトカードを意味検索して、 燃調方式の事実を引っ張る |
| 3 |
recommend_plans (絞り込み付き) |
「燃調上限あり」 を絞り込み条件に入れて再計算 |
3 つの情報源 (シミュレーター・記事・約款) が、 1 つの会話の中で自然に行き来している。 ユーザーは UI を切り替えていません。 ずっと LINE で話してるだけです。
3. 構成図 (シンプル版)
LINE ユーザー
↓ メッセージ送信
LINE Webhook (Cloud Run / FastAPI)
↓ 履歴 + 状態をサマリ化して system prompt に注入
Gemini 2.5 Flash (function calling)
↓ 必要なツールを呼ぶ
├─ recommend_plans → Supabase Postgres (プラン DB)
├─ search_knowledge → Supabase pgvector (374 ファクトカード chunks)
└─ query_plans_by_condition → Supabase Postgres (条件絞り込み)
↓ 結果を Gemini に返す
↓ 自然な日本語に整形 (2〜4 文)
LINE Bot
↓ 返信 + Flex Message プランカード
LINE ユーザー
Cloud Run + Supabase + Vercel の Free 枠で月 0 円 運営です。 個人開発なので無理はしない方針。
実装は 1 コンテナに同居しています。 LINE Webhook (/webhook) と、 Web 用同期版 (/api/chat) と、 MCP サーバ (/mcp/mcp) が同じプロセスで動いていて、 全員が同じツール定義 (bot/tools.py) を共有しています。 自分の Claude Desktop からも同じツールが呼べる形にしてあります (この部分の詳細は こちらの記事 に書きました)。
4. キモ: ツール 1 セットを、 LLM に「会話の流れで自由に組み合わせさせる」
実装のキモは「3 つのツールを LLM が会話の流れで自由に組み合わせて呼ぶ」 ところに尽きます。 ルーター的なロジックでツールを振り分けるのではなく、 ツールの仕様をちゃんと書いて Gemini にまるっと渡す。
# bot/tools.py (抜粋)
TOOL_SPECS = [
ToolSpec(
name="recommend_plans",
description="エリア・契約状況から最適な電力プラン上位5件を返す。 月額や使用量から逆算して比較。",
params=[
ToolParam("area", "string", "エリア識別子 (tokyo / kansai 等)", required=True),
ToolParam("ampere", "string", "契約アンペア (30A 等)", required=False),
ToolParam("monthly_kwh", "number", "月の使用量 kWh", required=False),
ToolParam("monthly_yen", "number", "月の請求額 円", required=False),
ToolParam("current_retailer", "string", "現契約電力会社", required=False),
],
handler=_tool_recommend_plans,
),
ToolSpec(
name="search_knowledge",
description="各電力会社のファクトカードを意味検索。 運営会社・燃調方式・解約金・約款の事実回答に必須。",
params=[
ToolParam("query", "string", "検索クエリ", required=True),
ToolParam("retailer_slug", "string", "対象会社 (任意で絞り込み)", required=False),
],
handler=_tool_search_knowledge,
),
ToolSpec(
name="query_plans_by_condition",
description="DB をプラン条件で検索 (例: 燃調上限あるプラン・解約金なしプラン)",
params=[
ToolParam("area", "string", "エリア識別子", required=True),
ToolParam("require_fuel_cap", "boolean", "燃調上限ありに限定", required=False),
ToolParam("require_no_cancel_fee", "boolean", "解約金なしに限定", required=False),
],
handler=_tool_query_plans_by_condition,
),
]
これを Gemini に渡しておくと、 ユーザーの「燃調上限ありで絞って」 という発話に対して Gemini が勝手に「query_plans_by_condition を require_fuel_cap=True で呼べばいいな」 と判断してくれます。 こちらが if 文で書く必要はありません。
「会話の状態」 を毎ターン system prompt に再注入
LLM は会話履歴を持っていますが、 それだけだと「3 ターン前にユーザーが言った絞り込み条件」 をしばしば忘れます。 そこで毎ターン、 こういう状態サマリを system prompt の先頭に注入しています。
# 現在の会話状態サマリ (これを踏まえて応答する)
- エリア: 東京 / 月額: 10,000円 / 世帯: 未確定 / 現契約: 未申告
- 絞り込み条件: 燃調上限あり必須
- 既提示プランID (再提示禁止): ['abc-123', 'def-456', ...]
- 直前 1 位: ◯◯電気 (switch_recommended)
これだけで、 「さっきと同じプランをまた提示してしまう」「さっき言った絞り込み条件を忘れて緩めた結果を返す」 系の事故が大幅に減りました。 ChatGPT 時代の「LLM をシンプルに使い倒す」 流儀に倣って、 状態管理は LLM の頭の中ではなくプロンプトの目に見える形に置く。
5. ハマったところ (ライト版)
LINE の Markdown は素朴
LINE bot で **強調** をそのまま送ると ** が文字列として表示されます。 こちらで **...** を 【...】 に置換してから送ってます。
def render_for_line(text: str) -> str:
return re.sub(r"\*\*(.+?)\*\*", r"【\1】", text)
Free 枠のレート制限
Gemini の Free 枠は 1 分あたりのリクエスト数に限りがあります。 ピークタイムに連投されたときの保険として、 LINE 側も Web 側もユーザー単位の 1 日上限 を入れています。 個人開発で「課金破滅」 しないための最低限の防御です。
Free 枠コールドスタート
Cloud Run は min-instances=0 で動かしているので、 LINE で初回メッセージが来た瞬間にコンテナが起き上がる数秒間、 ユーザーを待たせます。 これは Free 枠運営の宿命なので諦めています。 (Web LP 側ではボタン hover で warmup を投げる UX 工夫を入れていますが、 LINE 側からは仕掛けられないので)
6. やってみての教訓
- シミュレーター + 解説記事 + 約款を別々に置くだけでは、 ユーザーは恩恵を受けない。 統合する UI が要ります。 そして 2026 年現在、 統合の最も自然な UI は 会話 です
- 3 ツールを LLM に渡して自由に組み合わせさせる、 というのが今のベタープラクティスでした。 ルーターを自前で書こうとすると分岐が爆発します
- 状態は LLM の頭ではなくプロンプトに書く。 「絞り込み条件」「既提示プラン ID」 のような会話を跨ぐ情報は、 毎ターン system prompt に再注入するのが安定します
- 同じツール定義を MCP プロトコルでも喋れる形にしておくと、 LINE 以外のクライアント (自分の Claude Desktop 等) からも同じロジックを呼べて、 個人の生活でも便利
エネジェント (https://enegent.jp) は完全に趣味の個人プロジェクトで、 月 0 円で運営しています。 「AI に電気代相談したい」 と思ったときに、 中立な比較サービスが LINE で答えてくれる、 ぐらいの体験を目指しています。 個人開発で似たようなものを作っている方の参考になれば。
過去記事もあわせてどうぞ:
- 4-layer のチャットエージェントを「Single Agent + Native Tool Calling」 に作り直したら、 ハルシネーションも文脈分断も消えた話 — エージェントの内部設計
- 個人開発サービスを MCP サーバーにして「外部 AI から呼ばせる」 側に回った話 — MCP サーバ部分の実装詳細
- Gemini function calling で LINE Bot を「AI エージェント」 にする実装パターン — LINE Bot のスケルトン