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?

老技術者が作ったFunction Callingによるマルチエージェント会話の仕組み(Gemini × LangChain)

Last updated at Posted at 2025-10-01

はじめに

最近の大規模言語モデル(LLM)は Function Calling という仕組みをサポートしています.
これは「モデルが直接ツール(関数)を呼び出す」ことで,より制御された振る舞いを実現できる機能です.

この記事では,Google Gemini API と LangChain を使って,複数のキャラクター(マルチエージェント)がテーマについて会話を続けるシミュレーションを作ります.
出力例を見ると,まるでキャラクター同士が本当に議論しているかのように見えるはずです.

それにしてもこんなAI家族の会話を夜中の3時に眺める老技術者の侘しさよ.秋の虫たちも盛んに会話しています.孤独の身にはそれとてうらやましき情景なのでした.


ルールベースとFunction Callingの違い

まずは従来の「ルールベース」な会話制御と今回の仕組みを比べてみます。

特徴 ルールベースのプログラム Function Calling エージェント
会話の流れ ifswitchで固定(例:「ジイヤの次はハルゾウ」) LLMが会話ログを解析し、自律的に「誰に話させるか」を判断
発言内容 テンプレートやDBから抽出 LLMがキャラ設定を元に、その場で創作
制御方法 コードが全て決定し、LLMはテキスト生成だけ LLMFunction Callingを通じて次のアクションを指示

つまり、このコードの面白さは キャラクターの意思決定(発言者の選択)を LLM に任せる ところにあります。


完成コード

以下が今回の最終版のコードです。

import os
import time
from typing import List, Dict, Union

# Gemini用
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import AIMessage, ToolMessage, HumanMessage
from langchain.schema.runnable import Runnable

# =================================================================
# 1. 設定情報の反映とLLMの初期化
# =================================================================
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GEMINI_MODEL = "gemini-2.5-flash"

if not GEMINI_API_KEY:
    raise ValueError("環境変数 'GEMINI_API_KEY' が設定されていません。Google AI Studioで取得してください。")

LLM = ChatGoogleGenerativeAI(
    model=GEMINI_MODEL,
    temperature=0.5,
    google_api_key=GEMINI_API_KEY,
    request_timeout=60
)

# =================================================================
# 2. キャラクターと会話制御ツールの定義
# =================================================================
CHARACTERS = {
    "ジイヤ": "陽気で明るいが皮肉家のおじいさん。一人称は『ワシ』。相手を呼ぶときは『おぬし』『あんた』など。語尾は『~じゃ』『~じゃよ』。",
    "ハルゾウ": "真面目な少年。一人称は『自分』。相手を呼ぶときは『あなた』『君』など。論理的に話すが男の子言葉。",
    "アーチャン": "天然な女の子。一人称は『あーちゃん』。相手を呼ぶときは『ねえねえ』『ねえ』など。語尾は『~なの?』『~だよ』。",
    "ママ": "真面目な画家のママ。一人称は『わたし』。相手を呼ぶときは『あのさ』『あなた』など。語尾は『~なの?』『~だよ』。",
}
CHARACTER_NAMES = list(CHARACTERS.keys())

@tool
def designate_next_speaker(next_speaker: str, dialogue: str) -> str:
    """
    発言内容と次に発言するキャラクターを指名します。
    """
    if next_speaker not in CHARACTERS:
        return f"エラー: キャラクター名'{next_speaker}'は存在しません。"
    return f"次の発言者は{next_speaker}に、発言内容は'{dialogue}'に決定されました。"

TOOLS = [designate_next_speaker]

# =================================================================
# 3. 会話チェーンの構築
# =================================================================
def create_dialogue_chain(llm: ChatGoogleGenerativeAI, character_name: str, character_desc: str, tools: List) -> Runnable:
    other_speakers = [name for name in CHARACTER_NAMES if name != character_name]
    
    system_prompt = f"""あなたは'{character_name}'です。{character_desc}

    【最重要ルール】
    1. 他のキャラクターの名前を**発言内容に一切含めない**。
    2. 他のキャラクターの立場や役割に言及せず、**常に自分の意見**だけを述べる。
    3. 一人称や口調は絶対に崩さない。

    【指名ルール】
    1. 発言後、必ず designate_next_speaker ツールを呼び出す。
    2. 次の発言者は必ず {', '.join(other_speakers)} のいずれか。
    """

    prompt = ChatPromptTemplate.from_messages([
        ("system", system_prompt),
        MessagesPlaceholder(variable_name="chat_history"),
        ("human", f"'{character_name}'として発言し、designate_next_speakerツールで次の発言者と発言内容を決定してください。"),
    ])
    
    return prompt | llm.bind_tools(tools)

CHAINS = {
    name: create_dialogue_chain(LLM, name, desc, TOOLS)
    for name, desc in CHARACTERS.items()
}

# =================================================================
# 4. 会話シミュレーションの実行ロジック
# =================================================================
def run_simulation(topic: str, max_turns: int):
    dialogue_history: List[Union[AIMessage, ToolMessage, HumanMessage]] = []
    current_speaker = "ジイヤ"
    turn_count = 0
    
    print(f"--- 💡 会話シミュレーション開始: テーマ='{topic}', 全{max_turns}ターン ---\n")
    
    # 初手
    initial_dialogue = f"ワシから始めるぞ!今日のお題は【{topic}】じゃ!"
    print(f"ターン {turn_count + 1}: ジイヤ -> {initial_dialogue}\n")
    dialogue_history.append(AIMessage(content=f"{current_speaker}: {initial_dialogue}"))
    
    current_speaker = "ハルゾウ"
    turn_count += 1
    
    while turn_count < max_turns:
        print("--------------------------------------------------")
        print(f"ターン {turn_count + 1} ({current_speaker}の番)\n")
        
        chain = CHAINS[current_speaker]
        try:
            response = chain.invoke({"chat_history": dialogue_history})
            tool_calls = response.tool_calls
            
            if not tool_calls:
                print(f"{current_speaker}: ツール呼び出しなし。終了します。")
                break
            
            tool_call = tool_calls[0]
            next_speaker = tool_call['args'].get('next_speaker')
            dialogue = tool_call['args'].get('dialogue') 
            
            if not next_speaker or not dialogue:
                print(f"{current_speaker}: 不正な引数。終了します。")
                break
            
            if next_speaker == current_speaker:
                available = [n for n in CHARACTER_NAMES if n != current_speaker]
                next_speaker = available[turn_count % len(available)]
                print(f"⚠️ 自己指名を検出 → {next_speaker} に変更\n")
            
            dialogue_history.append(response)
            tool_result = designate_next_speaker.invoke({"next_speaker": next_speaker, "dialogue": dialogue})
            dialogue_history.append(ToolMessage(tool_call_id=tool_call['id'], content=tool_result))
            
            print(f"  {current_speaker}: {dialogue}")
            print(f"  次の発言者: {next_speaker}\n")
            
            current_speaker = next_speaker
            turn_count += 1
            
            if turn_count < max_turns:
                time.sleep(6.5)  # レート制限対策
            
            if turn_count >= max_turns:
                print("=== 会話終了: 最大ターン数に達しました ===\n")
                break
        
        except Exception as e:
            print(f"\n❌ エラー発生 ({current_speaker}): {e}")
            break

# =================================================================
# 5. 実行
# =================================================================
CONVERSATION_TOPIC = "来世について"
MAX_TURNS = 20

run_simulation(topic=CONVERSATION_TOPIC, max_turns=MAX_TURNS)

コードの仕組みを解説

1. キャラクター設定

  • CHARACTERS にそれぞれのキャラクターの性格や口調を定義します.
  • 例:ジイヤは「ワシ」と自称し,「~じゃ」と語尾をつける,など.
  • この設定を system prompt として LLM に与えることで,一貫したキャラ表現を維持できます.

2. Function Callingによる「発言者の指名」

  • @tool デコレータで designate_next_speaker を定義しています.
  • このツールの役割はシンプルで,**「次の発言者」と「今の発言内容」をJSONで返す」**こと.
  • LLMは発話を生成したあと,自動的にこのツールを呼び出します.

これにより,「会話が途切れず,キャラクター同士の掛け合いが続く」仕組みになります.


3. 会話チェーン

  • create_dialogue_chain で,それぞれのキャラクター専用の 会話制御チェーン を作ります.

  • system_prompt の中で,

    • 「キャラの口調を絶対に崩さない」
    • 「他キャラの名前を出さない」
    • 「必ずツールを呼び出す」
      などを強制しています.

これで LLM が勝手にキャラ崩壊したり,指名を忘れたりするのを防ぎます.


4. 会話シミュレーション

run_simulation 関数で実際の会話を進行させます.
会話ログを保持しつつ,

  1. 現在のスピーカーを決定
  2. LLMに会話ログを渡す
  3. LLMが発言+ツール呼び出しを生成
  4. 次のスピーカーを決めてターンを進める

という流れです.


実行例(抜粋)

--- 💡 会話シミュレーション開始: テーマ='来世について', 全20ターン ---

ターン 1: ジイヤ -> ワシから始めるぞ!今日のお題は【来世について】じゃ!

ターン 2 (ハルゾウの番)
  ハルゾウ: 来世、か。自分は、科学的な根拠がない以上、明確に存在すると断言するのは難しいと考えているよ。
  次の発言者: アーチャン

ターン 3 (アーチャンの番)
  アーチャン: ねえねえ、来世かぁ。あーちゃんはね、ふわふわの雲になりたいな!
  次の発言者: ママ

このように,キャラクターが順番に会話を展開していきます.
ジイヤは「~じゃ」,アーチャンは「ねえねえ~」など,キャラごとの一貫した口調が保たれているのがわかります.


学べるポイント

  • Function Callingを使えば,LLMに「会話の進行役」を任せられる
  • system promptとツール定義でキャラクターの一貫性を保つことが可能
  • マルチエージェントの基盤として流用できる(例:AI議論シミュレーション,キャラチャットゲーム,教育用ロールプレイなど)

まとめ

今回の例では 4人のキャラクターがテーマについて議論する会話シミュレーションを構築しました.
ポイントは次の通りです:

  • キャラ設定を system prompt に埋め込み,口調を固定する
  • Function Calling で「次の発言者」をLLMに決めさせる
  • 会話ログを保持し,ターン制で進行させる

この仕組みは,マルチエージェント対話システムの基礎として非常に有用です.
例えば「仮想会議」「AIキャラ小説」「議論の自動化」など応用範囲は広大です.


💡 次のステップとしては:

  • キャラクターを増やす(パパ,先生など)
  • 会話テーマをユーザー入力から動的に変更
  • SlackやLINEと連携して実際の会話ログに組み込む

などが考えられます.


おわりに

Function Calling を活用することで,LLMをただの応答生成器ではなく,「自律的に会話を進めるエージェント」に変えることができます.
ぜひこのコードを改造して,自分だけのマルチエージェント会話シミュレーションを作ってみてください!


付録:Function Calling の技術的解説(本質編)

1.Function Callingとは何か

Function Callingは,大規模言語モデル(LLM)がテキスト出力だけでなく「構造化された命令」を返す仕組みです.
通常のLLMは「文字列」を返すだけですが,Function Callingを使うと以下のようにJSON形式で関数呼び出しを指示できます.

{
  "tool_calls": [
    {
      "id": "call_12345",
      "name": "example_function",
      "args": {
        "param1": "value",
        "param2": 42
      }
    }
  ]
}

アプリケーション側はこれを受け取り,example_function(param1="value", param2=42) を実行します.


2.構造の基本要素

  • ツール定義:アプリケーション側で定義した関数群(名前・引数・説明を含む)
  • バインド:ツールを LLM に「呼び出し可能な関数」として宣言
  • 呼び出し:LLM が会話やタスクの文脈に基づき,関数を呼び出す JSON を出力
  • 実行結果:アプリ側で関数を実行し,その結果を再びモデルの履歴に返す

3.仕組みの流れ(コード付き詳細)

3-1.プロンプト設計とツールの公開(bind)

まず,どんな状況で関数を呼ぶべきかsystem_prompt に明示し,公開するツールを LLM にバインドします.これにより,モデルは自然文だけでなく**ツール呼び出し(Function Calling)**という出力行動を選べるようになります.

# 1) ツールを定義(Function Schema)
from langchain_core.tools import tool

@tool
def designate_next_speaker(next_speaker: str, dialogue: str) -> str:
    if next_speaker not in CHARACTERS:
        return f"エラー: キャラクター名'{next_speaker}'は存在しません。"
    return f"次の発言者は{next_speaker}に、発言内容は'{dialogue}'に決定されました。"

TOOLS = [designate_next_speaker]

# 2) プロンプトに「行動規範」を明示
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

def create_dialogue_chain(llm, character_name: str, character_desc: str, tools):
    other_speakers = [n for n in CHARACTER_NAMES if n != character_name]

    system_prompt = f"""あなたは'{character_name}'です。{character_desc}

    【最重要ルール】
    1. 他のキャラクターの名前(ジイヤ、ハルゾウ、アーチャン、ママ)を発言内容に一切含めない.
    2. 他のキャラクターの立場や役割に言及せず,常に自分の意見だけを述べる.
    3. 一人称や口調は絶対に崩さない.

    【指名ルール】
    1. 発言後,必ず designate_next_speaker ツールを呼び出す.
    2. 次の発言者は必ず {', '.join(other_speakers)} のいずれか.
    """

    prompt = ChatPromptTemplate.from_messages([
        ("system", system_prompt),
        MessagesPlaceholder(variable_name="chat_history"),
        ("human", f"'{character_name}'として発言し,designate_next_speakerツールで次の発言者と発言内容を決定してください。"),
    ])

    # 3) ツールをバインド:LLMは必要に応じてこの関数を呼び出すJSONを返せる
    return prompt | llm.bind_tools(tools)

ポイント

  • ツールの引数名・型が,そのままモデルのJSON出力スキーマになります.
  • 「いつツールを呼ぶか」を system に明記することで,出力行動が安定します.
  • MessagesPlaceholder("chat_history") で会話履歴を参照可能にします.

3-2.推論とFunction Callingの出力(tool_calls)

次に,会話履歴を与えて LLM を呼び出します.LLM は発話内容を内部で生成した上で,ツール呼び出し用のJSONtool_calls として返します.

# 直前までの履歴を渡して,LLMを1ターン分だけ推論
response = chain.invoke({"chat_history": dialogue_history})

# Function Calling の結果(構造化命令)を取得
tool_calls = response.tool_calls
if not tool_calls:
    print("❌ ツール呼び出しなし.終了します.")
    return

# 先頭のツール呼び出しを利用(本実装は1つ想定)
tool_call = tool_calls[0]

# ツールへ渡す引数を抽出(JSONのargs)
next_speaker = tool_call["args"].get("next_speaker")
dialogue     = tool_call["args"].get("dialogue")

# 必須引数のバリデーション
if not next_speaker or not dialogue:
    print("❌ 引数不足(next_speaker / dialogue).終了します.")
    return

ポイント

  • response.tool_calls は,LangChain がベンダ差を吸収した統一インタフェースです.
  • temperatureが高いと JSON の安定性が落ちる傾向があるため,必要に応じて温度やプロンプト規約を調整します.

3-3.関数の実行とガードレール(アプリ側の責務)

アプリケーションは,受け取った JSON を厳密に検証し,安全に関数を実行します.また,LLM の規約違反(例:自分を次の話者に指定)をガードレールで矯正します.

# 自己指名の矯正(会話破綻の典型)
if next_speaker == current_speaker:
    available = [n for n in CHARACTER_NAMES if n != current_speaker]
    next_speaker = available[turn_count % len(available)]
    print(f"⚠️ 自己指名を検出 → {next_speaker} に変更")

# 実際の関数を実行(ここはアプリの安全境界)
tool_result = designate_next_speaker.invoke({
    "next_speaker": next_speaker,
    "dialogue": dialogue
})

# 進行用の表示やログ
print(f"  {current_speaker}: {dialogue}")
print(f"  次の発言者: {next_speaker}")

ポイント

  • スキーマ検証(型,列挙チェック)と意味検証(自己指名禁止など)を二重化するのが実務的に堅牢です.
  • ツール側は最小権限にし,副作用の強い操作は承認付きルートに分離します.

3-4.結果のフィードバックと履歴への反映(次ターンの文脈生成)

最後に,「LLM がこう命令し,関数がこう応答した」という事実を,AIMessage/ToolMessageとして履歴に残します.これが次ターンの推論文脈になります.

from langchain_core.messages import AIMessage, ToolMessage

# 1) LLMからのメッセージ(構造化出力を含む)を履歴へ
dialogue_history.append(response)

# 2) ツール実行の結果を ToolMessage として履歴へ
dialogue_history.append(
    ToolMessage(
        tool_call_id=tool_call["id"],   # LLMが返したcall idで関連付け
        content=tool_result
    )
)

# 3) 会話のバトンを次の話者へ
current_speaker = next_speaker
turn_count += 1

ポイント

  • tool_call_idこの結果はどのツール呼び出しに対応するかを明示します.
  • 履歴の質が次ターンの思考の質を決めます.不要なノイズは入れず,必要な事実だけを簡潔に残すのがコツです.

3-5.全体像の要約

  • 設計:プロンプトで行動規範を宣言し,呼べる関数のスキーマを公開する.
  • 推論:LLM は自然文ではなくJSON の tool_callsを返す.
  • 実行:アプリがスキーマ検証・意味検証を経て関数を実行する.
  • 反映:結果を ToolMessage として履歴に積み,次ターンの文脈にする.

これにより,自然文の自由さプログラム的制御の厳密さを両立したワークフローが成立します.


4.技術的メリット

  • 構造化された制御

    • 「どの関数を呼ぶか」「どの引数を渡すか」が明示され,従来の曖昧な自然文より堅牢
  • LLMの責務分離

    • LLMは「判断」と「JSON生成」に集中し,実際の副作用操作(DBアクセスやAPI呼び出し)は外部コードに委ねられる
  • 安全性向上

    • スキーマ検証やホワイトリスト化により,危険な操作を防げる

5.実装上の注意点

  • 引数設計は最小限に:LLMに複雑なオブジェクトを組み立てさせると失敗しやすい

  • プロンプトで禁止事項を明示:誤った呼び出しや連続呼び出しを避けるためにルールを列挙

  • ガードレール必須

    • 引数が欠落していたらエラー処理
    • 不適切な関数呼び出しはリカバリ(例:デフォルト値や代替関数を選択)
  • ログと検証:LLMの tool_calls と実行結果は監査可能に残す


6.設計パターン

  • シンプル関数型
    → 1つのツールで単純な動作(例:天気取得,DB検索)を行う
  • ワークフロー型
    → 複数のツールを連携させ,LLMが次に呼ぶべき関数を逐次判断
  • 二段階型
    → まず候補を生成するツール,次に選択するツール,と段階を分けて安定性を高める

7.本質的なポイント

Function Calling の肝は「自然文を構造化した決定に変換する」ことです.

  • 従来:

    • LLM → 曖昧な文章 → アプリが正規表現などで解釈 → エラー多発
  • Function Calling:

    • LLM → JSON(関数呼び出し仕様に準拠) → アプリが厳密に解釈 → 安定した動作

これにより,LLMを「テキスト生成器」から「タスク指示エージェント」に進化させられます.


まとめ

  • Function Callingは,LLMの出力を自然文から構造化JSONに昇華する仕組み
  • プロンプト設計+スキーマ検証+ガードレールの三位一体が安定化の鍵
  • シンプルなAPI呼び出しから複雑なマルチエージェントシステムまで応用可能

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?