◾️はじめに
前回までの記事ではwatsonx.aiとLangGraphで最小限のチャットボットを作り、LangSmithでトレーシングなどを試してみました。
これらの記事のチャットボットでは、LLMが素の状態で知っている内容しか答えられないです。そのため、今回の記事では外部ツールを使って情報を外から取ってくることをします。そのためにWeb検索機能をつけることを試してみたいと思います。
◾️前提
- ①や②のシリーズとなる記事です。
- この記事では前回の内容が出てくるので①だけでも簡単に見ていただけると幸いです。
- 初学者向けの記事です。所謂やってみた系の記事です。
- 私自身が非エンジニアなので、間違い、アドバイスあれば、コメントで指摘お願いします!
- 当記事ではプラットフォームはwatsonx.aiを使います。無償評価版などは①の記事から登録可能です。
さて、実装に入る前に、LLMと外部ツールの関係性について少し整理したいと思います。
前半は私の備忘録的な内容なので、外部ツール連携やMCPについて知ってる方はこちらからお読みいただければと思います。
◾️LLMはどうやって外部ツールを使っているのか?
LLMは本来テキストを生成するだけで、直接外部のシステムを操作することはできません。そこで、メール送信やWeb検索といった実際の行動を伴う処理を行うためには、LLMと外部ツールを連携させる仕組みが必要になります。
その代表例が、OpenAIが2023年に公開した以下の「Function Calling」です。
ざっくり言うと、プログラム側で用意した関数の名前や説明、引数の形式をあらかじめLLMに伝えておき、ユーザーの質問に応じてLLMが「どの関数を使うべきか」と「その引数は何か」を判断し、プログラムに返すというものです。
プログラムはその指示に従って実際に関数を実行し、得られた結果を再びLLMに渡します。LLMは結果とこれまでの会話履歴をもとに、最終的な回答を生成します。
例えば天気を調べる場合、LLMは「東京の天気を調べる」というユーザーのプロンプトの意図を読み取り、Web検索用の関数を適切な引数とともに呼び出し、その検索結果を使って自然な文章でユーザーに答える、という流れになります。
FunctionCallingの解説は以下が分かりやすいので、合わせてご参照ください。
このFunctionCallingの処理の流れを理解しておくと、LLMのツール利用の原因分析がしやすかったり、どういうプロンプトをLLMに投げたら上手にツールを使ってくれるのかなど、想像がつきやすくなるかと思います。
生API実装の問題点 と MCPの登場
LLMに外部ツールを使わせる方法としては、前述のOpenAI Function Callingのように「関数定義を直接モデルに渡す」アプローチが代表的ですが、これを生のAPIで実装しようとすると結構大変です。
例えばIBMのwatsonx.aiのAPIでは、以下の公式ドキュメントにあるように、ツールを使うための関数とスキーマ(引数や説明などの定義)を自分でコードに書き込む必要があります。
これだけならシンプルに見えますが、実際にはメール送信やWeb検索、ファイル操作などツールが増えるたびに、新しい関数とスキーマを追加しなければなりません。さらに、各ツールごとに書き方や構造が微妙に異なるため、実装や保守が面倒になるという課題があります。
この様な課題がある中で、 2024年11月にAnthropic社がMCP(Model Context Protocol)という共通プロトコルを発表しました。
以下の記事に、MCPが登場した背景や抱えていた課題など、経緯が解説されています。
MCPはツールとLLMをつなぐための統一規格で、これによりツールの呼び出し方や定義が標準化されます。現在では、OpenAIやGoogle DeepMindも各製品とMCPの統合、対応を進めており、同プロトコルは「AIエージェント時代のオープン標準」として急速に認知されつつあります。※ここら辺は英語版wikiにも色々まとまってます。
MCP自体の説明はすでに色々な方が挙げているので詳細は以下をご覧ください。
MCPのアーキテクチャはクライアント・サーバーモデルを採用しており、AI アプリ(MCP クライアント)とデータ提供者(MCP サーバー)を分離します。この記事でもこの内容が少し出てきますので、用語を記載しておきます。
-
ホスト(Hosts)
AIアプリ側。クライアントを管理し、ユーザー指示やセキュリティを制御。 -
クライアント(Client)
ホスト内の接続役。サーバーとの通信・やり取りを仲介する。 -
サーバー(Server)
外部ツールやデータをMCP仕様で提供するプログラム。
この辺りの解説は日本語公式ドキュメントにがわかりやすいアーキテクチャの解説がある他、英語Verの公式サイトに以下に、既存の例や詳細なコンセプト解説が載っています。
↑のわかりやすい日本語解説
上記で紹介されているMCPサーバーは基本的にLLMと同環境で動作するローカルMCPサーバー使用を想定されている説明が多いです。こちらは開発や実験向き、高速&プライベートです。
また、これとは別にクラウドや別サーバー上で動作し、ネット経由でLLMから利用されるリモートMCPというものもあります。こちらは運用向き、管理しやすく拡張性あり。
公開されている例:https://docs.anthropic.com/en/docs/agents-and-tools/remote-mcp-servers
これらを用途に合わせて組み合わせて使うことで、ツールの利用が簡単に行えるわけです。
MCPはまだ発展段階で、以下で紹介されているようなリスクがあります。サンドボックス環境で使う等、十分注意を払って使いましょう。
https://zenn.dev/sun_asterisk/articles/mcp_security_risk
さて、これらの機能を使うモチベーションがわかったところで、LangChainやMCPを活用しながら、どのように実装すれば良いか実際にみてみましょう。
◾️[本題]LangGraphを使ったチャットボットに外部ツールを使える様にしよう
というところで、以前作ったSPSSModelerQ&Aチャットボットに外部ツール機能として、Web検索機能をつけることで、外部知識を獲得できる様にしたいと思います。
準備1:Web検索サービス「Tavily」
まずは今回つける機能であるWeb検索については、APIプラットフォーム「Tavily」を使いたいと思います。こちらはAIエージェントやLLM(大規模言語モデル)向けに設計された、リアルタイムのウェブ検索およびコンテンツ抽出を簡単に行えるサービスとしてとても便利です。使える機能としては以下四つ。
-
Tavily Search
リアルタイムにWeb検索し、LLM向けに要点付きで返す検索API -
Tavily Extract
指定したURLから本文をそのまま抜き出すテキスト抽出API -
Tavily Crawl:
サイトをグラフ的に横断しながら、多数ページを並列で探索し本文も抽出するクローラーAPI -
Tavily Map
サイト構造をたどって、サイトマップを生成するβ版のマッピングAPI
↓公式ページ
上記ページのPricingを見るとFree枠があり、月当たり1000クレジットまでなら無料で使えることがわかります。早速SignUpからアカウントを作って、以下のAPI Keysの「+」ボタンからAPIキーを作成。
作ったAPIキーは後ほど使います。
準備2:Docker Desktop MCP Toolkit(MCP環境の分離)
前章で示したようにMCPによっては安全性にリスクがある場合があります。そのため、今回はMCPサーバーをDockerコンテナで起動することにします。そのために使うのが以下のDocker Desktop MCP Toolkitというわけです。
こちらを使うことで、比較的安全に、そして簡単にセットアップができるようになるのです!今回は以下の記事を参考に進めていきたいと思います。
簡易的なセットアップ手順
-
以下からDockerDesktopをインストール
https://www.docker.com/ja-jp/ -
「Tavily」のServersのページに遷移したら、「Configuration」タブから先ほどのTavilyのAPIキーを入力する。
-
「Clients」タブから繋げたいMCPクライアントを探す!…のですが、現時点(2025年8月時点)だとLangChain関連がないので、一番下に書いてある、
docker mcp gateway runコマンドを使って繋げようと思います。
このDocker Desktop MCP Toolkitは非常に便利ですが、まだ対応していない、MCPサーバー、MCPクライアントもあるようです。
これで準備完了。
実装:LangGraph
前回のチャットボットをBaseに改築しますが、初見の方にとって見にくいので、最初にコード全文を載せておきます。以下折りたたみから参照ください。
※ここをクリックでmcp_app.pyのコード全文表示
import asyncio
import os
import uuid
from typing import Annotated
from dotenv import load_dotenv
from ibm_watsonx_ai.foundation_models.schema import TextChatParameters
from langchain_core.messages import AIMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_ibm import ChatWatsonx
from langchain_mcp_adapters.client import MultiServerMCPClient
from typing_extensions import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
load_dotenv()
project_id = os.getenv("WATSONX_PROJECT_ID")
parameters = TextChatParameters(
max_tokens=2000,
temperature=0.3,
frequency_penalty=0.3,
time_limit=600000
)
model = ChatWatsonx(
model_id="meta-llama/llama-4-maverick-17b-128e-instruct-fp8",
project_id=project_id,
params=parameters,
)
prompt = ChatPromptTemplate.from_messages([
(
"system",
"あなたはIBM SPSS Modelerの専門家です。必要に応じてMCPツール(tavily)で検索し、"
"根拠が必要な箇所はURLを示し、簡潔な日本語で答えてください。",
),
MessagesPlaceholder("messages"),
])
mcp_client = MultiServerMCPClient(
{
"docker_gateway": {
"transport": "stdio",
"command": "docker",
"args": ["mcp", "gateway", "run"]
}
}
)
tools = asyncio.run(mcp_client.get_tools())
tool_node = ToolNode(tools)
class GraphState(TypedDict):
messages: Annotated[list, add_messages]
def llm_node(state: GraphState) -> dict:
chain = prompt | model.bind_tools(tools, tool_choice="auto")
result = chain.invoke({"messages": state["messages"]})
return {"messages": [result if isinstance(result, AIMessage) else
AIMessage(content=str(result))]}
memory = InMemorySaver()
builder = StateGraph(GraphState)
builder.add_node("llm", llm_node)
builder.add_node("tools", tool_node)
builder.add_conditional_edges("llm", tools_condition)
builder.add_edge("tools", "llm")
builder.add_edge(START, "llm")
graph = builder.compile(checkpointer=memory)
THREAD_ID = str(uuid.uuid4())
async def astream_updates(user_input: str, thread_id: str = None):
config = {"configurable": {"thread_id": thread_id}} if thread_id else None
async for event in graph.astream(
{"messages": [{"role": "user", "content": user_input}]},
config=config,
stream_mode="updates",
):
if "llm" in event:
msgs = event["llm"].get("messages", [])
if msgs and isinstance(msgs[-1], AIMessage):
print(msgs[-1].content)
def main():
while True:
try:
user_input = input("User: ")
except (EOFError, KeyboardInterrupt):
break
if user_input.strip().lower() in {"quit", "exit", "q"}:
break
asyncio.run(astream_updates(user_input, THREAD_ID))
if __name__ == "__main__":
main()
では、以前の記事の差分から解説していきたいと思います。
1.LangChain MCP Adapters
まず、LangChain/LangGraphでMCPを使おうとする場合、langchain_mcp_adaptersと言うツールが公式で用意されています。これが橋渡しとなってLangGraphが利用できる形にMCPを変換してくれます。
ここでは複数のMCPサーバーへの接続を管理し、そこからツール、プロンプト、リソースを読み込むためのMultiServerMCPClientクラスを提供します。
※この記事では一つしかMCPサーバー使わないのですが、ほぼ複数で使う&一つでも動くのでこちらを使います。
このMultiServerMCPClientにサーバー名と接続設定をマッピングするdictを渡しましょう。今回は、標準出力で先ほどのdocker mcp gateway runコマンドを実行してもらうように設定しましょう。
from langchain_mcp_adapters.client import MultiServerMCPClient
mcp_client = MultiServerMCPClient(
{
"docker_gateway": {
"transport": "stdio",
"command": "docker",
"args": ["mcp", "gateway", "run"]
}
}
)
2.ToolNodeにツールをセット
MultiServerMCPClientクラスのget_toolsメソッドを使って、接続されている全MCPサーバーから全てのツールのリストを取得します。get_toolsは非同期関数で、MCPサーバーが複数ある場合は 並列で効率よく取得されます。
今回は導入を手軽にするため1つのサーバーだけに対し、同期コードから一度だけ呼び出すために asyncio.run を使っています(※Jupyterなど既にイベントループが動いている環境では、await mcp_client.get_tools() に置き換えてください)。
取得したツールリストは、LangGraphのテンプレノード ToolNode に渡します。ToolNode は LLMが出した tool_calls を受け取り実行し、その結果を会話メッセージとして返すためのノードです。これにより、LLM →(必要なら)ツール実行 → 再びLLM という流れを簡単に構成できます。
from langgraph.prebuilt import ToolNode
tools = asyncio.run(mcp_client.get_tools())
tool_node = ToolNode(tools)
3.Graphの設定
Graphの設定やLLMNode、Memoryは前回と同じものを使います。
ノードの組み方はllmノードから始まってユーザー入力に対してLLMが推論します。もしその出力にツール呼び出し(tool_calls)が含まれていれば 、条件付きノードにセットしたtools_condition によって自動的に toolsノードへ遷移し、実際にMCPツールが実行されます。
その結果がメッセージに追記された後は再びllmノードに戻り、最終的な回答が生成されます。つまり「START → llm → (必要に応じて tools → llm)」という最小構成のループという感じです。tool_conditionでツールが必要なければ、ENDに行きます。
class GraphState(TypedDict):
messages: Annotated[list, add_messages]
def llm_node(state: GraphState) -> dict:
chain = prompt | model.bind_tools(tools, tool_choice="auto")
result = chain.invoke({"messages": state["messages"]})
return {"messages": [result if isinstance(result, AIMessage) else AIMessage(content=str(result))]}
memory = InMemorySaver()
builder = StateGraph(GraphState)
builder.add_node("llm", llm_node)
builder.add_node("tools", tool_node)
builder.add_conditional_edges("llm", tools_condition)
builder.add_edge("tools", "llm")
builder.add_edge(START, "llm")
graph = builder.compile(checkpointer=memory)
流れを図示すると↓のような感じです。
4.Graphのストリーミング実行
前回の記事までは Gradio を使っていましたが、今回はシンプルにターミナルで会話します。ここでは LangGraph の astream を利用し、ノード更新ごとにイベントを逐次処理しています。
イベントは {"llm": {...}} のような辞書で届くため、この実装では LLM ノードの出力だけを拾ってユーザーに表示する形にしています。
async def astream_updates(user_input: str, thread_id: str = None):
# LangGraphのcheckpointerにスレッドIDを渡す設定。
config = {"configurable": {"thread_id": thread_id}} if thread_id else None
# graph.astream(...) は非同期ジェネレータで、ノード更新イベントを順次yieldする。
async for event in graph.astream(
{"messages": [{"role": "user", "content": user_input}]},
config=config,
stream_mode="updates",
):
# eventは {"llm": {...}} や {"tools": {...}} のような辞書(dict)で来る。
if "llm" in event:
msgs = event["llm"].get("messages", [])
if msgs and isinstance(msgs[-1], AIMessage):
print(msgs[-1].content)
5.対話ループ
最後に、ターミナルから入力を受け取って上記の astream_updates を呼び出すループを定義しています。
ユーザーの入力を待ち、quit や exit などを入力すれば終了、それ以外なら非同期実行の astream_updates に渡して会話を進めます。
def main():
while True:
try:
user_input = input("User: ")
except (EOFError, KeyboardInterrupt):
break
if user_input.strip().lower() in {"quit", "exit", "q"}:
break
asyncio.run(astream_updates(user_input, THREAD_ID))
if __name__ == "__main__":
main()
完成!
◾️実際に動かしてLangSmithで挙動を見てみよう
さて、出来上がったところで実行して見ましょう。
…と、その前に今回使った環境を載せておきましょう。
OS :Mac
IDE :VSCode
python :3.11
ライブラリ
- ibm-watsonx-ai>=1.3.13
- langchain-core>=0.3.58
- langchain-ibm>=0.3.10
- langgraph>=0.4.1
まずは、出来上がったコードを実行する前にDockerDesktopを起動させておきましょう。
私はuvでローカル実行しておりますので、ターミナルから以下のように起動します。
source .venv/bin/activate
ur run mcp_app.py
うまくいけばMCP ツール (tavily)のツール実行ログがモリモリ出てきて、「User:」という感じでユーザー入力が求められます。
上記のようになったら、質問を投げかけて見ましょう。今回も前回に引き続きSPSS ModelerのQ&Aボットとして作っているので、以下例のようにプロンプトを投げて見たいと思います。
SPSSModelerの具体的な使い方をWebで検索して3、4 個教えてください。
するとAIからURL付きで以下のように返ってきました。
SPSS Modelerの具体的な使い方をいくつか紹介します。
1. データインポート:**入力**パレットを使用して、さまざまな形式のデータをインポートできます。例えば、カンマ区切りのCSVファイルの場合、区切り文字、ストレージ、データ型などの主要な項目を指定するだけでインポートできます。
2. 特徴量選択:**特徴量選択**ノードを使用して、対象と相関/連関の高い変数だけを選別して予測モデル作成に流し込むフィルタリングができます。
3. 異常値検査:**異常値検査**ノードを使用して、クラスタリングにより異常レコードを識別してストリームから除外させることができ、予測モデル作成のフィールド選択を補助します。
4. 予測モデル作成:50種類近い手法がGUIで提供されており、教師あり学習の**ニューラルネットワーク**、**線型モデル**、**決定木分析**、**ベイジアンネットワーク**、**ランダムフォレスト**などのほか、教師なし学習として、**K-Means**や**Kohonen**などのクラスター分析、因子分析や主成分分析に対応します。
これらの機能を使用することで、データ分析や予測モデルの構築が容易になります。
参考URL:
- https://www.stats-guild.com/ibm-spss/modeler/modeler-use
- https://www.niandc.co.jp/tech/20241206_56852/
- https://spssorder.com/spss-modeler/
- https://qiita.com/spssfun2017/items/b1391e524187dc9c4b77
おー!ちゃんとURLにもアクセスできるし、うまくいったみたいですね!
早速これをLangSmithで挙動を確認して見ましょう。
以下画像を見るとちゃんとtavily-searchを呼び出して指定の数だけサーチしてくれているのがわかりますね!
続いて、tavily-extractも試して見ましょう。以下例ではプロンプトで先ほど得られたURLを入力して、中に何が書いてあるのか詳細を教えてもらうような内容にしています。
https://qiita.com/spssfun2017/items/b1391e524187dc9c4b77
ここでは具体的にどう言ったことが解説されていますか?
以下のような返答が返ってきました。
https://qiita.com/spssfun2017/items/b1391e524187dc9c4b77
では、SPSS Modelerを解説するYouTube動画が紹介されています。
具体的には、以下のような動画がリストアップされています。
* 4分でわかる! IBM SPSS Modeler概要 機能篇
* IBM SPSS Modelerのデータハンドリング機能
* IBM SPSS Modelerのオートメーション機能
* IBM SPSS Modelerの機械学習・決定木分析
* SPSS Modelerの機械学習・ニューラルネットワーク
* SPSS Modelerで実践-解約・休眠防止
* SPSS Modelerで実践-新規顧客の獲得
* SPSS Modelerで実践-顧客クラスタリング
* SPSS Modelerで実践-リピート率の向上
* SPSS CADS 分析結果を利用した業務の自動化と展開
* 予測分析を始めましょう SPSS Modeler クイック・スタート・ガイド
これらの動画は、SPSS Modelerの機能や使い方、実践的な分析例などを解説しています。
実際のリンク先を見ると、ちゃんと中身を捉えていることがわかります。
これもLangSmithで挙動を確認して見ましょう!
ちゃんと指定のURLのHTMLを解析して、記事本文の内容を取ってきていることがわかります。
このようにWeb検索を使えば、本来モデルが知らないであろう、情報にも簡単にアクセスできるわけです。
そしてtavilyは一つのMCPサーバーで広く検索もできるし、一つのサイトを深掘りも可能で、非常に使い勝手が良いことがわかりますね!つまりこれをうまく使えば、単純なWeb検索ではなくDeepResearchのように詳細な調査レポートを作り出すことも可能?という希望が持てます。
時間があれば、最近Googleから出たDeepResearch手法を見ながら実装することも挑戦していきたいです。
◾️反省点と次やりたいこと
実装したチャットはひとまず動き当初の目的は達成できました。今回の実装により、単なるLLM利用よりも「情報ソースに基づいた回答」ができるようになり、信頼性が向上したかと思います。
一方で、検索クエリ生成をLLMに任せた結果、関係の薄いURLやズレた情報も混じることがあり、検索数・クエリ設計の工夫や、出力フォーマットの統一が改善点として浮かんびました。特にURL未出力や可読性の差が問題だったため、強制的に構造化する仕組みが必要と感じました。
モデル選びの重要性
上記の仕組みの問題のほか、使うモデルによって左右される部分が大きいと感じました。
今回コードに載せたllama4はツールを適切に利用できたが、他モデル(granite3など)ではツールを使わず架空のURLを生成するケースが多発。これは「モデル性能」だけでなく「ツール使用能力」の差であり、以下のようなMCP利用のベンチマークなどを参考にする必要があると考えました。
さらに、実運用を想定すると入力・出力が大規模化し、Context Windowの広さが重要になるなど高性能なモデルが必要になってきます。ただ、AIエージェントはLLMを何回も呼び出すため大規模・高性能モデルはコスト増に露骨につながります。
そう考えると効率的な仕組みで小さめのモデルでも成果を出せる工夫も必要と感しました。
やりたいこと
さて、外部ツールが使えるようになったことで色々できることが増え、AIエージェントっぽく?なってきました。
本当は次RAGをやりたかったのですが、先に出力形式を安定化させる、構造化出力(StructuredOutput)を先にやりたいと思います。
あと色々調べていて面白かったのが、IBMとTavilyが協力して、リアルタイム検索とAIを活用した強化スプレッドシートを開発しているとのことでした。これもいずれ試して見たいと思います。
◾️ まとめ
今回の記事では、MCPを使ってチャットボットにWeb検索機能を追加し、外部情報を取り込めるようにしました。
TavilyをMCPサーバーとしてDockerToolKitで動かし、LangGraphと連携することで、検索や記事要約を自然に実行できます。結果として、LLM単体よりも根拠のある回答が可能になりました。
今後は出力の安定化やRAGとの組み合わせを進め、より実用的なエージェント化を目指したいと思います。
◾️ 参考



