連載「サポートチケットからナレッジ記事の下書きを自動生成する PoC」の 2 本目です。前回の[Zendesk × Gemini × BigQuery で完全自動のナレッジ蓄積パイプラインを構築]で、BigQuery にチケットを溜めるところまで作りました。今回はその続きで、溜まったチケットを Top 10 トピックに整理し、Slack で人と AI が対話しながら採用を決める仕組みを作っていきます。

⚠️ 本記事内のコード・参照名(プロジェクト ID、サービスアカウント、Slack チャンネル名、ファイルパスなど)は 架空のもの に置き換えています。お手元で動かす際はご自身の環境に合わせて読み替えてください。

1. はじめに

まずは完成形のイメージをご覧ください。

ユーザーがやることは、たったこれだけです。

  • 提案を見て 修正指示を返信する(「3 番目を初学者向けに書いて」など)
  • もしくは 「他の案を出して」 で全 10 件を別の切り口で再生成
  • 気に入ったら 「全て承認」 または 「2 番と 5 番だけ」 のように部分承認
  • 承認後はボタンで 書き出す記事を選ぶ

裏では Vertex AI Gemini 2.5 Flash + ADK + BigQuery が動いていますが、ユーザーから見れば Slack で AI と会話しているだけ です。最初に動いたとき、自分でも「あれ、思ったよりサクサク使えるな」と感じました。普段使っている Slack の延長で AI が応答してくれる、というのは想像以上に違和感がなく、「これなら誰でも気軽に使うことができるな」ということが分かりました。

連載構成の中での位置付けは以下です。

  1. ✅ データ蓄積(前回)
  2. 提案・対話(今回) ← ここ
  3. 記事生成・運用(次回)
  4. 統括(最終回)

2. なぜ Human-in-the-Loop (HITL) が必要か

このセクションを書くにあたって、改めて「自分はなぜ全自動化を諦めたんだっけ」を整理してみました。理由は 3 つあります。

  • 命名のセンス・粒度の判断は人が早い
    「請求」を 1 つの記事にまとめるか「請求代行 / 予算アラート / コスト分析」に分けるかは、現場の人がスパッと決められます。LLM だけだと粒度がブレやすく、毎月微妙に違うトピック分けで提案してきます。これを毎回プロンプトで矯正するより、人に任せたほうが圧倒的に早いと感じました。
  • 既に書いた記事との重複判定
    「先月も似たトピックで記事作ったよね?」を AI に判断させるのは、思ったより難しいです。人が「これは更新じゃなくて新規」と決める方が早く、納得感があります。後の連載で書く重複検知も、結局は「人に判断材料を提示する」止まりにしました。
  • ニッチだけど重要なトピックを拾える
    件数 1 件のニッチなトラブルでも、現場が「これは記事にしたい」と判断すれば採用される。例えばLLM が機械的に Top 3 だけを選んでしまうと、こういうのが埋もれてしまいます。実際、PoC で何度か「これは確かに記事にしておきたいな」というのが下位ランクで出てきて、人の判断の重みを実感しました。

つまり結論は 「全自動」ではなく「人が 1〜2 タップで意思決定」が体験のキモ ということです。これを Slack 上で完結させたい、というのが今回の出発点でした。

「LLM に全部やらせるのが理想」ですが、現実的に運用が回るのは『AI が下案、人が決断』の役割分担 というのが今回の感触です。AI が頑張る部分と人が頑張る部分を明確に分けると、両者の長所が活きやすいです。

3. 技術スタックと全体像

技術スタックは以下です。

レイヤ 採用技術
LLM Vertex AI Gemini 2.5 Flash
エージェント Google ADK (Agent Development Kit) の LLMAgent
Slack 連携 Slack Bolt for Python + Events API連携
データ BigQuery(前回構築)

ADK には BigQueryToolset という公式ツールがあり、エージェントから自然言語で SQL を生成して実行してくれます。これが今回かなり効きました。「LLM が自分でクエリを書いて実行する」というのは聞いたことはあったのですが、実際に組んでみると本当にラクで、プロンプトで「直近 30 日分の Top 10 を出して」と書くだけで、内部で WHERE updated_at >= TIMESTAMP_SUB(...) みたいな SQL を組み立ててくれます。

4. ADK エージェントの設計

4.1 SequentialAgent では HITL が組めなかった話

最初は ADK の SequentialAgent で組んでいました。

collector (BQ クエリ) → formatter (構造化 JSON 化)

これで「Top 10 を構造化して返す」自体は綺麗に動きます。問題はそこに HITL(修正・他の案・承認)を被せたときに起きました

ユーザーが「3 番目を初学者向けに」と返信すると、SequentialAgent はターンごとに頭から再走してしまいます。BQ にもう一度クエリが走るのは仕方ないとして、collector が会話履歴を見て「修正タスク」と勘違いし、「承知いたしました」みたいな返事を返してくる挙動になりました。さらにその下で formatter が空っぽの JSON を出してしまい、ユーザー側からは「同じ提案が繰り返されているように見える」という残念な状態に陥りました。

include_contents="none" で履歴を見せない設定もあるのですが、過去のやり取り(履歴)はAIから隠せても、ユーザーが今まさに送信したメッセージ(最新の1往復分)は当然AIにインプットとして渡されてしまうので根本解決にならず、悩んだ上に方針転換しました。

4.2 単一 LLMAgent + 会話履歴で完結させる

そこで 1 つの LLMAgent に全部やらせる 方針に切り替えました。BigQuery Toolset を持たせ、プロンプト内で 3 つの状態を自己判定 させます。

  • A: 初回 → 会話履歴に提案がない → BigQuery を叩いて Top 10 を提案する
  • B: 提案中 → 履歴に提案がある + ユーザー返信あり → 修正 / 他の案 / 承認 を判断する
  • C: 承認後 → 提案フェーズ終了マーカーを出す(再提案しない)
# agents/agent.py (抜粋)
from google.adk.agents import LLMAgent
from google.adk.tools.bigquery import BigQueryToolset, BigQueryCredentialsConfig
from google.adk.tools.bigquery.config import BigQueryToolConfig
import google.auth

creds, _ = google.auth.default()
bq_toolset = BigQueryToolset(
credentials_config=BigQueryCredentialsConfig(credentials=creds),
bigquery_tool_config=BigQueryToolConfig(
location="US",
max_query_result_rows=200,
application_name="", # BigQuery ジョブの label として記録される識別子
),
tool_filter=["execute_sql"],
)

root_agent = LLMAgent(
name="",
model="gemini-2.5-flash",
description="Top 10 抽出 + タイトル+概要 提案 + 承認 を行う HITL エージェント",
instruction=_INSTRUCTION,
tools=[bq_toolset],
)

会話履歴は ADK の Runnersession_id(後述する Slack の thread_ts)ごとに勝手に保持してくれるので、エージェント側は状態管理を意識しなくていい のがミソです。

最終的にこのアーキテクチャに落ち着いて、コード量はむしろ減りました。設計を柔軟に変更することは非常に大切であるという学びを得ました。

5. プロンプト設計の 3 つの工夫

ここがこの記事の山場です。同じ ADK + Gemini でも、プロンプトの組み方で HITL の挙動がかなり変わるのを実感しました。

5.1 thinking でブラックリスト思考を強制「他の案を出して」の落とし穴

最初の落とし穴は、ユーザーが「他の案を出して」と言ったときに、Gemini が 同じトピックを言い換えただけで返してくる 現象でした。

例えば v1 で「GCP 請求アカウント管理ガイド」を提案したあとで「他の案を」と返すと、v2 で「GCP プロジェクトの請求管理マニュアル」みたいなのが出てきます。タイトルだけ変わって、トピックはそのままという現象でした。

対策として、 procedural な思考手順を強制 しました。

「他の案」を求められたとき、以下を

...

内で必ず実行してください。

Step R-1: これまでに提案したトピックを全部列挙する (今回は採用しない)
Step R-2: 元のチケットデータを見直し、過去に挙げていない候補を 15 件以上洗い出す
Step R-3: 重複チェック (過去採用済みと候補プールに重複がないことを確認)
Step R-4: プールから 10 件を最終選択

の後に、ユーザー向けの最終提案だけを書く。

これに変えてからは、Gemini が内部で「rank 11 以下相当」「特定サービス縦割り」「問題類型」など、異なる切り口で 15 件以上洗い出す ようになりました。出てくるトピックも v1 と明確に違うものになります。プロンプト変更後の最初の v2 を見たとき「お、これは確かに別案だ!」と少し感動しました。

thinking ブロックは Slack 投稿前に正規表現で除去しているので、ユーザーには結果だけ見えます。

_THINKING_RE = re.compile(r"

.*?

", re.DOTALL | re.IGNORECASE)

def _strip_internals(text: str) -> str:
return _THINKING_RE.sub("", text).strip()

ここで学んだコツは、思考過程を「出力に残す」と Gemini が本気を出すということでした。出力に残らないと chain-of-thought が省略されがちで、結果として表面的な言い換えで済まされてしまうようです。

5.2 approved_jsonで構造化シグナルを安全に取り出す

承認のときは「Slack で人が読むテキスト」と「プログラムが処理する構造化データ」の両方が要ります。最初は人間向けの Markdown を後から正規表現でパースしていたのですが、Gemini の出力ゆらぎで結構壊れました。「### 1.### 1) になって正規表現が外れる」みたいな細かい事故が多発しました。

そこでプロンプトに承認時のフォーマットを固定させることにしました。

承認されました。最終案は以下です。

### N. ...
- **タイトル**: ...
- **概要**: ...

[
{{"title": "...", "overview": "..."}},
...
]

---END_OF_PROPOSAL---

approved_jsonを Slack 投稿前に抽出 → in-memory dict に保存 → 投稿時はタグごと除去します。

_APPROVED_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL)

def _extract_approved(text):
m = _APPROVED_RE.search(text)
if not m:
return None
return json.loads(m.group(1).strip())

承認パターンは 3 つ に分類しています。

  • A: 全件承認(「OK」「全て承認」「承認」など)
  • B: 部分承認(「2 番だけ承認」「1 と 3 を採用」など)
  • C: 残り打ち切り(「他は不要」など、直前に部分承認の意思があった場合)

プロンプト で 3 ケース全てに対し 必ずapproved_jsonを出す よう強制したら、Slack 側のロジックが驚くほどシンプルになりました。「人と AI のインターフェースを構造化する」 というのは地味ですが効きます。LLM に「人間向けと機械向けの 2 つの出力を同時に出させる」発想は、自分の中で今回大きな学びでした。

5.3 フッター文を固定 ―「次に何を返したらいいか」を毎ターン提示する

---
修正したい点があれば、何番目の記事をどう変えたいか教えてください。
すべて承認なら「全て承認」とお返事ください。
他の案も見たい場合は、「他の案を出して」とお返事ください。

毎回の提案末尾にこの 3 行が付くだけで、使用ユーザーはかなり使いやすくなります。Slack の Block Kit ボタンも併用していますが、テキストでも操作できるようにしておくのが HITL の鉄則だと思います。

実は最初フッター文を入れずに作っていたのですが、自分でテストしていても「次に何を返せばいいんだっけ?」と一瞬考えてしまうことがあり、これは UX 上よくないと感じて入れた経緯があります。AI チャットボットでよく見る「サンプル質問」みたいなもので、「自分が一瞬詰まるなら、ユーザーは確実に詰まる」 は普段の UI 設計でも使えそうな経験則だなと思いました。

6. 状態遷移:1 つのエージェントが扱う 7 つのターン

具体的な流れは以下です。

[初回] → A: 提案 v1 を出す
↓ ユーザー「3 番目を初学者向けに」
[修正] → B: v2 で 3 番目だけ書き換え
↓ ユーザー「他の案を出して」
[再生成] → B: v3 で全 10 件入れ替え ( ブラックリスト)
↓ ユーザー「2 番だけ承認します」
[部分承認] → C: + ---END_OF_PROPOSAL---
↓ Slack 側がフェーズを post_approval に遷移
[書き出し選択] → ボタン or テキストで対象選択
↓ Tech Writer が走る (次回の連載)

エージェント側は履歴を見て自分でモードを判定するので、Slack 側のロジックは「Runner に投げて返ってきた text を投稿する」だけ で済みます。状態遷移を コード側で管理するプロンプト側に押し付ける かは設計の分かれ道ですが、今回は後者にしたことで Slack 側がほぼ純粋な配管屋になり、デバッグもしやすくなりました。

7. Slack Bolt 連携

7.1連携方式に Events API を選んだ理由

Slack Bot がイベントを受け取る方式には Events API(Slack から bot の公開 URL に HTTPS POST)と
Socket Mode(bot から Slack に WebSocket を張りっぱなし)の 2 つがあります。

Cloud Run のようなサーバーレス環境で動かす前提なら、コスト的に Events API が圧倒的に有利です。

  • Events API: イベントが来た時だけ CPU 起動 → 月 $1〜5
  • Socket Mode: WebSocket 維持のため CPU を常時稼働 (–no-cpu-throttling 必須) → 月 $30〜60

今回は、「リクエストが来たときだけ稼働する」構成でよかったため、 HTTP webhook 方式を採用しました。

7.2 イベントハンドラの役割分担

3 種類のイベントを使い分けています。

@slack_app.event("app_mention")
async def on_app_mention(event, client, logger):
# チャンネルで @bot メンションされたら kickoff ボタンを返す
await client.chat_postMessage(channel=event["channel"], blocks=KICKOFF_BLOCKS)

@slack_app.event("message")
async def on_message(event, client, logger):
if event.get("bot_id"):
return # 自分のメッセージは無視
thread_ts = event.get("thread_ts")
if thread_ts and await _is_thread_we_own(channel, thread_ts):
# 既存スレッドの返信 → ADK Runner に流す
await _run_agent_and_post(...)

@slack_app.action("kickoff")
async def on_kickoff(ack, body, client):
await ack()
# スレッドを立てて HITL 開始
await _run_agent_and_post(...)

ここで気を付けたのは チャンネルのトップレベルメッセージは無視する ようにしたことです。最初これを忘れて、bot 招待後にチャンネルで誰かが何か発言するたびに反応する挙動になり、邪魔がられました。「bot に反応してほしい場面とそうでない場面の境界を明確にする」のは、Slack ボット設計の地味だけど大事なところです。

7.3 ADK Runner で会話を継続する工夫

セッション ID をどう振るかで会話の継続性が決まります。

def _slack_user_for(channel_id):
# channel ごとに固定 → スレッド内なら誰の返信でも同じセッションとして扱える
return f"slack_{channel_id}"

async def _run_agent_and_post(client, channel, session_id, text, thread_ts):
content = genai_types.Content(role="user", parts=[genai_types.Part(text=text)])
async for event in runner.run_async(
user_id=_slack_user_for(channel),
session_id=session_id, # = thread_ts
new_message=content,
):
if event.is_final_response() and event.content:
text_out = "".join(p.text for p in event.content.parts if p.text)
text_out = _strip_internals(text_out)
if text_out:
await client.chat_postMessage(
channel=channel, text=text_out, thread_ts=thread_ts,
)

ポイントは:

  • user_id = f"slack_{channel_id}"channel 単位 にセッションを紐付け → スレッド内で誰が返信しても同じ ADK セッションになる
  • session_id = thread_ts1 スレッド = 1 会話

これでチームの誰でも他人のスレッドに横入りで「全て承認」と返信できる、という地味に大事な UX が実現できます。最初は Slack の user_id をそのまま ADK 側にも渡していたのですが、それだと「最初に押した人しか会話を続けられない」ことに気付き、設計を変えました。「user_id はシステム内部のセッション識別子であって、必ずしも実ユーザーと 1:1 である必要はない」 というのは、こういう統合系を作って初めて実感した発見でした。

8. ハマったポイント 3 件

8.1 f-string + JSON サンプル

プロンプトを f-string で組み立てているとき、JSON サンプルの {, } が format placeholder と解釈されて ValueError: Invalid format specifier が出ました。{{, }} でちゃんとエスケープしましょう。

8.2 gemini-2.5-flash の長配列カウント苦手問題

80 行のチケットを context に入れた状態で「total_tickets は何件?」と聞くと、Gemini は 平気で 60 と答えたりしました。LLM は長配列の正確なカウントが苦手なようです。

解決策は「SELECT COUNT(*) を別クエリで先に実行して、その結果の数字を使ってください」と プロンプト に書くことでした。LLM に計算させず、決定的な値はクエリで取る、という方針に切り替えてからは安定しました。「LLM の苦手分野を諦めて、得意な人(≒道具)に任せる」 という割り切りができるようになったのは、PoC を通しての小さな成長だと思います。

8.3 Sequential vs 単一エージェントの設計

4.1 で書いた通りです。一度 Sequential で組んでから単一に作り直したので、ある意味 PoC で一番多く時間を使った部分です。ただ、おかげで「Sequential はワンショットの構造化向き、HITL は単一会話エージェント向き」という肌感覚がつきました。最初に正解にたどり着くより、間違えて書き直す経験 のほうが残ると感じました。

9. まとめ

最後に、今回の PoC で Google ADK (Agent Development Kit) を本格的に触ってみての全体的な感想をまとめておきます。
率直に言って、「エージェント開発の大変な部分を、驚くほど肩代わりしてくれるフレームワーク」 だと感じました。

特に恩恵を感じたのは以下の 2 点です。

▪️ツールの強力さ
BigQueryToolset のように「自然言語の指示から勝手に SQL を組んで実行し、結果をプロンプトのコンテキストに乗せる」という一連の流れを、数行のコードで実装できるのは感動的でした。エージェントに「道具(ツール)を持たせる」ことの威力を手軽に実感できます。

▪️会話履歴(状態)管理の丸投げ
今回のような Human-in-the-Loop を Slack 上で実装する場合、「どのスレッドの、どの段階の会話か」を管理するのが通常はかなり面倒です。しかし ADK の Runner が session_id ベースで会話履歴をよしなに保持してくれるため、開発側は「今どんな文脈か」をエージェント自身に判断させる設計に集中できました。

一方で、「フレームワークが強力だからといって、LLM の手綱を握らなくていいわけではない」 というのが最大の学びでもあります。

SequentialAgent からの設計変更や、「思考過程を出力させる」といったプロンプトの工夫で触れたように、LLM特有の「近道を探す癖」や「文脈の取り違え」はどうしても発生します。ADK はあくまで「LLM を動かすための優秀な舞台装置」であり、その上で LLM にどういう役割を与え、どう律し、どこで人間の助け(HITL)を借りるか を設計するのは、人間側の仕事です。

今回は、「全自動化」ではなく「人が 1〜2 タップで意思決定する」ための最高のサポート役として ADK と Gemini を配置したことで、結果的に実用性の高いシステムに着地できたと思います。