TL;DR
- 仲間内のLINEグループに「人間っぽく雑談するBot」を遊びで参加させたくて、PHP + Cloud Run + Groq + Geminiで作りました
- オウム返しの疎通確認から、キャラ設定、検索連携、「覚えといて」で予定を覚えて朝に通知する機能まで拡張しています
- 一番ハマったのは「LLMのtool callingは1往復で終わるとは限らない」という話で、無言バグの原因になりました
きっかけ
仲間内で作っているLINEグループがあって、そこにBotを参加させて、あたかも人のように会話させることができたら面白そうだな、というだけの思いつきから始めました。真剣なプロジェクトというよりはお遊びのつもりでしたが、やってみるとLINE Messaging APIの実績にもなりそうだったので、記録がてら手を動かしてみることにしました。
やったこと(技術構成・使用ツール)
完成イメージとしては以下のような動きをするBotです。
- グループでメンション/名前呼びすると返事する(呼ばれなくても稀に絡む)
- 雑談はGroq(
openai/gpt-oss-120b) - 天気・ニュースなどリアルタイム情報が必要なときだけGemini + Google検索
- 「覚えといて」と言うと予定を覚え、前日・当日の朝にPush通知
- 会話履歴・予定はFirestore(Cloud Runのスケールゼロ対策)
技術スタックはこちらです。
| 層 | 採用 |
|---|---|
| 言語 | PHP 8.x |
| 実行基盤 | Google Cloud Run(コンテナ) |
| ベースイメージ |
php:8-apache(ポート8080) |
| LLM | Groq Chat Completions(function calling) |
| 検索 | Gemini Interactions API + google_search
|
| 永続化 | Firestore Native(REST API) |
| 定期実行 | Cloud Scheduler → 内部エンドポイント |
| デプロイ |
gcloud builds submit + gcloud run deploy
|
全体のアーキテクチャはこんな形です。
LINE Platform
│ Webhook POST
▼
Cloud Run (PHP / Apache)
├─ 署名検証 (x-line-signature)
├─ ReplyDecider(反応するか判定)
├─ ConversationStore(Firestore: line_conversations)
└─ ResponseGenerator
├─ Groq(雑談 + tools)
│ ├─ search_web → GeminiSearchClient
│ └─ remember_event → EventStore (line_events)
└─ LINE Reply API
Cloud Scheduler (毎朝 8:00 JST)
│ POST + X-Internal-Secret
▼
/internal/notify-events.php
├─ 前日・当日の予定を Push
└─ 過去日付のイベントを削除
ディレクトリ構成は以下の通りです。
line_bot/
├── public/
│ ├── index.php # LINE Webhook
│ └── internal/notify-events.php
├── src/
│ ├── SignatureVerifier.php
│ ├── ReplyDecider.php
│ ├── ResponseGenerator.php # Groq ⇔ ツールのループ
│ ├── GroqClient.php
│ ├── GeminiSearchClient.php
│ ├── ConversationStore.php
│ ├── FirestoreRestClient.php
│ ├── EventStore.php / EventNotifier.php
│ ├── CharacterPrompt.php
│ └── ...
├── scripts/deploy-cloud-run.sh
├── Dockerfile
├── composer.json
└── README.md
依存は最小限にしています(コールドスタート対策)。LINE / Groq / GeminiはPHPのcURLで直接呼び出し、FirestoreだけREST + google/auth + guzzlehttp/guzzleという構成にしました。
技術的な深掘り:Groqで外部検索をやろうとしてハマった話
一番苦労したのは、大きく括ると「Groqで外部検索(リアルタイム情報の取得)をやろうとしたところ」でした。ここは1つの不具合というより、原因調査→方針転換→新しい実装での別の不具合、という一連の流れだったので、順を追って書きます。
まずCompoundで「Request too large」にハマった
検索連携は、最初からGroq+Geminiのハイブリッドにするつもりだったわけではありません。最初はGroq側だけで完結させようとして、Groqが提供している「Compound」というシステムを使いました。これはgroq/compoundやgroq/compound-miniというモデルを指定するだけで、モデルが自動的にWeb検索やサイト閲覧を行ってくれる仕組みです。
ところが実際にBotで動かすと、「天気を聞いてもリアルタイムの天気は答えられない」と拒否されたり、原因を切り分けるために直接curlで叩いてみると、こんなエラーが返ってきました。
{"error":{"message":"Request Entity Too Large","type":"invalid_request_error","code":"request_too_large"}}
最初は自分のcurlコマンドがおかしいのかと思いました。複数行のコマンドをシェルにコピペした際に改行がおかしくなって、意図せず巨大なペイロードを送っていないか疑ったのですが、-vオプションを付けて確認するとcontent-length: 103とごく小さいリクエストでした。
> content-length: 103
...
< HTTP/2 413
{"error":{"message":"Request Entity Too Large", ... "code":"request_too_large"}}
103バイトのリクエストが「大きすぎる」というのは明らかにおかしいので、Groq側の問題を疑って調べたところ、Groqのコミュニティフォーラムに「compound / compound-miniがシンプルなWeb検索でも“Request too large”を返す」という、まさに同じ症状の投稿が立っていました。つまりこちらの実装ミスではなく、Compoundシステム側の既知の不具合(当時ベータ的に不安定だった)だったようです。
ここで方針を変えました。「検索が必要になりがちな決まった用途をGroqの自動ツール任せにするのはリスクが高い」と判断し、天気やニュースのような調べ物は、Google検索グラウンディング機能を持つGeminiに切り出すことにしました。雑談は引き続き安価なGroqで処理しつつ、検索が必要な時だけGeminiのAPIを呼び出す、というハイブリッド構成に落ち着いたのはこの経緯があってのことです。
具体的には、Groqのopenai/gpt-oss-120bにfunction callingでsearch_webというツールを持たせておき、モデルが「これは検索が必要だ」と判断してこのツールを呼び出したら、実際にはGeminiのInteractions APIを叩いて検索結果を取得し、その結果をGroqにrole: toolとして渡し返す、という2段階の構成にしています。Compoundが内部でやろうとしていたことを、判断とキャラ生成はGroq、実際の検索はGeminiに役割分担させたイメージです。
切り替えた先で今度は「tool callingが1往復で終わらない」
Compoundをやめてこの2段階構成に切り替えたことで検索自体はうまくいくようになったのですが、今度は別のところでハマりました。
Groqのopenai/gpt-oss-120bにfunction callingでツール(検索用のsearch_web、予定記憶用のremember_event)を持たせているのですが、最初は「1回目のリクエストでtool_callsが返ってきたら、ツールを実行して2回目のリクエストをすれば必ず最終回答が返る」という前提で実装していました。
ところが実際に動かしていると、たまに返事が来ないことがありました。Cloud Loggingを見ると、こんなログが出ていました。
Groq generation failed: Groq returned empty content after tool result
原因は、2回目のリクエストでもモデルがまたtool_callsを返すことがある、というものでした。例えば検索した上でさらに別のツールを呼ぼうとしたり、同じツールをもう一度呼び直そうとしたりするケースです。この場合レスポンスはcontentが空でtool_callsだけが入った状態になりますが、実装は「2回目は最終回答のはず」という前提でcontentだけを見ていたため、空文字を検知して例外を投げ、結果としてBotが無言になっていました。
対策として、ツール呼び出しをループ構造にしました。最大ラウンド数を決めておき、finish_reasonがtool_callsである限りツールを実行して次のラウンドに進み、stopになった時点のcontentを最終回答として返す形です。上限に達してもまだツールを呼ぼうとする場合は、無言にするのではなくキャラの口調のフォールバック文を返すようにしました。
public const MAX_TOOL_ROUNDS = 3;
public const FALLBACK_REPLY = 'ごめん、ちょっとうまく言葉にできんかったわ';
for ($round = 1; $round <= self::MAX_TOOL_ROUNDS; $round++) {
$result = $this->groqClient->chatCompletion($messages, self::TOOLS);
error_log(sprintf(
'ResponseGenerator: round=%d finish_reason=%s tools=[%s]',
$round,
$result->finishReason,
implode(',', array_column($result->toolCalls, 'name'))
));
if (!$result->hasToolCalls()) {
return $result->content !== null && $result->content !== ''
? $result->content
: self::FALLBACK_REPLY;
}
if ($round === self::MAX_TOOL_ROUNDS) {
return self::FALLBACK_REPLY; // 打ち切り(無言にしない)
}
$messages[] = $result->assistantMessage;
foreach ($result->toolCalls as $call) {
$messages[] = [
'role' => 'tool',
'tool_call_id' => $call['id'],
'name' => $call['name'],
'content' => $this->executeTool($call),
];
}
}
各ラウンドのfinish_reasonとツール名をログに残すようにしたので、以降は「無言になった」報告があってもログから原因を追いやすくなりました。「LLMのツール呼び出しは1往復で終わるとは限らない」というのは、実装する前は正直あまり意識していなかった点で、良い教訓になりました。
その他のハマりどころ
上のほど大掛かりではないですが、いくつか実装中に詰まった点があります。
Cloud Runで Webhook が403になる。 公式のphp:8-apacheイメージはポート80・DocumentRootが/var/www/htmlという前提なので、Cloud Runが見に来る8080番や、Composerで入れたpublic配下の構成と噛み合っていませんでした。Listen 8080、<VirtualHost *:8080>、DocumentRootを/var/www/html/publicに変更する調整が必要でした。
Firestoreが403 PERMISSION_DENIEDになる。 IAMのroles/datastore.userは付与済みだったのですが、PHP側のGuzzleクライアントに'auth' => 'google_auth'オプションを付け忘れていました。ApplicationDefaultCredentials::getMiddleware()をハンドラースタックに積んでも、このauthオプションがないとAuthorizationヘッダーが付かない、というのが落とし穴でした。
$this->http = new Client([
'handler' => $stack,
'auth' => 'google_auth', // ← これ必須
'base_uri' => 'https://firestore.googleapis.com/v1/',
]);
余談ですが、最初は公式のgoogle/cloud-firestore(ext-grpc必須)を使おうとしてDockerビルドが極端に重くなったので、REST + google/authに切り替えました。
Groqのモデルが404・decommissionedになる。 ドキュメントや過去の記事で見かけたモデルID(llama-3.1-8b-instantなど)が、時点によって使えなくなっていました。GET https://api.groq.com/openai/v1/modelsで一覧を取り、候補に小さなchat completionsを当てる検証スクリプトを用意して対処しました。最終的にfunction calling用にopenai/gpt-oss-120bを採用し、モデル名は環境変数GROQ_MODELで差し替え可能にしています。
Botが自分の名前を他人扱いする。 「タロウこんばんは」と話しかけると「タロウさんはどうしてますか?」と返してくる、という現象が起きました。システムプロンプトに自己認識が弱かったのが原因で、BOT_NAMEを明示し「自分を三人称で呼ばない」という例示を加えることで解決しました。会話履歴のユーザー発言も、LINEのuserIdをそのまま載せると混乱の元になるので「他の参加者(末尾6桁): 本文」というラベルに統一しました。
相対日付がズレる。 「来週の土曜」のような表現が正しいYYYY-MM-DDに変換されない問題がありました。原因はコンテナがUTCで動いていたことと、モデルに「今日」の情報を渡していなかったことです。DockerfileでTZ=Asia/Tokyoを設定し、システムプロンプト生成のたびに「今日は〇年〇月〇日(〇曜日)です」という一文を埋め込むようにして解決しました。
結果・現状
一通りの機能(雑談、検索連携、予定記憶と通知)は実装が終わり、Cloud Run上で動いている状態です。ただ実際に仲間内のグループで本格的に使い始めたのはこれからで、まだ検証中の段階です。キャラの口調や返信確率のチューニング、記憶した予定の通知精度などは、これから仲間内で触ってもらいながら詰めていく予定です。
まとめ・今後
PHPでもCloud Run + cURL + 薄いSDKの組み合わせで、LINE Bot × LLM × 検索 × 予定通知まで一気通貫で作れることが分かりました。一番つらかったのは「動いているように見えて無言」になるケースで、Cloud Loggingにerror_logを仕込んでおいたのが効きました。
今後やりたいこととしては、以下を考えています。
- 表示名の取得(今はuserId末尾のみ)で会話履歴をより自然にする
- Secret Managerへの移行(環境変数直書きからの脱却)
- 通知文面のGroq生成(現状はテンプレ寄り)
- OIDCによるCloud Scheduler認証(共有秘密からの強化)
- 会話から拾った内容を蓄積していく「記憶」機能や、Googleカレンダー・TimeRexといった外部サービスとの連携も検討中
同じような構成でLINE Botを作ってみたい方の、チェックリストになれば幸いです。