はじめに
最近の大規模言語モデル(LLM)は Function Calling という仕組みをサポートしています.
これは「モデルが直接ツール(関数)を呼び出す」ことで,より制御された振る舞いを実現できる機能です.
この記事では,Google Gemini API と LangChain を使って,複数のキャラクター(マルチエージェント)がテーマについて会話を続けるシミュレーションを作ります.
出力例を見ると,まるでキャラクター同士が本当に議論しているかのように見えるはずです.
それにしてもこんなAI家族の会話を夜中の3時に眺める老技術者の侘しさよ.秋の虫たちも盛んに会話しています.孤独の身にはそれとてうらやましき情景なのでした.
ルールベースとFunction Callingの違い
まずは従来の「ルールベース」な会話制御と今回の仕組みを比べてみます。
| 特徴 | ルールベースのプログラム | Function Calling エージェント |
|---|---|---|
| 会話の流れ |
ifやswitchで固定(例:「ジイヤの次はハルゾウ」) |
LLMが会話ログを解析し、自律的に「誰に話させるか」を判断 |
| 発言内容 | テンプレートやDBから抽出 | LLMがキャラ設定を元に、その場で創作 |
| 制御方法 | コードが全て決定し、LLMはテキスト生成だけ | LLMがFunction 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 関数で実際の会話を進行させます.
会話ログを保持しつつ,
- 現在のスピーカーを決定
- LLMに会話ログを渡す
- LLMが発言+ツール呼び出しを生成
- 次のスピーカーを決めてターンを進める
という流れです.
実行例(抜粋)
--- 💡 会話シミュレーション開始: テーマ='来世について', 全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 は発話内容を内部で生成した上で,ツール呼び出し用のJSONを tool_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呼び出しから複雑なマルチエージェントシステムまで応用可能