LangChain マルチツール個人アシスタント
本稿では、天気予報、スケジュール管理、メール送信を一体化した個人アシスタントAgentを構築し、マルチツール連携と構造化出力の完全な使い方を紹介します。
システム設計
- 3つのツール:天気予報、スケジュール管理、メール送信
- 構造化出力:スケジュール概要をMarkdownとして整形
- ストリーミング出力:AIの思考と処理過程をリアルタイム表示
- 対話メモリ:ユーザーの好みと文脈を記憶
完全なコード
実行前に依存関係をインストールし、.env内で設定してください。DEEPSEEK_API_KEY:
pip install langchain langchain-deepseek langgraph-checkpoint-sqlite python-dotenv
実例
# ファイルパス:personal_assistant.py
from dotenv import load_dotenv
load_dotenv()
import sqlite3
from datetime import datetime
from pydantic import BaseModel, Field
from langchain.tools import tool
from langchain.agents import create_agent
from langchain.agents.middleware import dynamic_prompt
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
from langgraph.checkpoint.sqlite import SqliteSaver
# ========== 1. モックデータ ==========
calendar_events = [
{"id": 1, "title": "Python 学習", "date": "2024-03-25",
"time": "14:00", "duration": "2時間"},
{"id": 2, "title": "チーム週会", "date": "2024-03-25",
"time": "10:00", "duration": "1時間"},
{"id": 3, "title": "コードレビュー", "date": "2024-03-26",
"time": "15:00", "duration": "1.5時間"},
]
weather_db = {
"杭州": {"condition": "晴れ", "temp": 25, "humidity": 60},
"北京": {"condition": "曇り", "temp": 18, "humidity": 45},
"上海": {"condition": "小雨", "temp": 22, "humidity": 80},
}
# ========== 2. ツールを定義 ==========
@tool
def get_weather(city: str) -> str:
"""指定都市のリアルタイム天気を照会します。
Args:
city: 都市名(例:杭州、北京、上海)
"""
data = weather_db.get(city)
if not data:
return f"現在 {city} の天気は照会できません。対応都市:{', '.join(weather_db.keys())}"
return (f"{city}の天気:{data['condition']}、"
f"温度 {data['temp']}°C、湿度 {data['humidity']}%")
@tool
def query_schedule(date: str = None) -> str:
"""指定日のスケジュールを照会します。日付を指定しない場合は今日のスケジュールを照会します。
Args:
date: 日付。形式は YYYY-MM-DD(例:2024-03-25)。未指定の場合は今日を照会します
"""
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
events = [e for e in calendar_events if e["date"] == date]
if not events:
return f"{date} のスケジュールはありません。"
events.sort(key=lambda e: e["time"])
# ここでは emoji 文字を直接書く必要があり、📅 のような HTML エンティティで書いてはいけません——
# これは Python 文字列なので、そのまま出力され、ブラウザによって画像として解析されることはありません
lines = [f"📅 {date} のスケジュール:"]
for e in events:
lines.append(f" - {e['time']} {e['title']}({e['duration']})")
return "\n".join(lines)
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""メールを送信します(シミュレーション)。
Args:
to: 受信者のメールアドレス
subject: メールの件名
body: メール本文
"""
# 送信をシミュレーション
email_id = f"MSG-{datetime.now().strftime('%Y%m%d%H%M%S')}"
return f"メールを送信しました!宛先:{to}、件名:{subject}、メールID:{email_id}"
# ========== 3. 構造化出力モデル ==========
class DailySummary(BaseModel):
"""毎日の概要"""
date: str = Field(description="日付")
weather_summary: str = Field(description="天気概要")
event_count: int = Field(description="スケジュール数")
key_events: list[str] = Field(description="重要なスケジュール一覧")
suggestion: str = Field(description="今日のアドバイス")
def to_markdown(summary: DailySummary) -> str:
"""構造化された DailySummary を Markdown テキストにレンダリングし、
システム設計の「構造化出力:スケジュール概要をMarkdownとして整形」の項目に対応します。"""
lines = [
f"## {summary.date} 今日の概要",
"",
f"- **天気**:{summary.weather_summary}",
f"- **スケジュール数**:{summary.event_count}",
"",
"**重要な事項:**",
]
if summary.key_events:
lines += [f"- {event}" for event in summary.key_events]
else:
lines.append("- なし")
lines += ["", f"**今日のアドバイス**:{summary.suggestion}"]
return "\n".join(lines)
# ========== 4. Middleware を定義 ==========
@dynamic_prompt
def inject_date_context(request) -> str:
"""システムプロンプトの末尾に現在の日付情報を動的に追加します。
以前、@before_model で日付メッセージを messages リストに挿入する書き方には2つの問題がありました:
1. before_model が返すメッセージの更新は add_messages reducer を経由しますが、reducer は単に
"新メッセージを末尾に追加"という処理だけを行い、返されたリスト内の位置は認識しません。insert(-1, ...)
ユーザーメッセージの前に挿入しようとする意図は実際には有効になりません;
2. before_model は複数回のツール呼び出しループで繰り返し起動されるため、このメッセージを挿入しやすく、
AIMessage(tool_calls=...) とそれに対応する ToolMessage の間に、
厳格なメッセージ順序の要件を乱します。
代わりに @dynamic_prompt でシステムプロンプト文字列を直接書き換えます。モデル呼び出しのたびに
再計算されるため、messages リストには触れず、蓄積や位置ずれのリスクがありません。
"""
now = datetime.now()
weekday = ["月", "火", "水", "木", "金", "土", "日"][now.weekday()]
date_hint = (f"\n\n[システムプロンプト] 現在の日付は {now.strftime('%Y年%m月%d日')}、"
f"曜日{weekday}。ユーザーが日付を指定しない場合は、デフォルトで今日を照会します。")
return request.system_prompt + date_hint
# ========== 5. Agent を作成 ==========
# SqliteSaver を使って対話を永続化し、システム設計の「対話メモリ」を実現します。
# 自分で接続を確立して SqliteSaver コンストラクタに渡すのではなく、
SqliteSaver.from_conn_string()(これは with 文にのみ適した、
使い終わったら閉じるコンテキストマネージャです。詳細は『LangChain スマートカスタマーサービスロボット』の記事を参照してください)。
conn = sqlite3.connect("personal_assistant.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)
model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0.3)
agent = create_agent(
model=model,
tools=[get_weather, query_schedule, send_email],
middleware=[inject_date_context],
response_format=DailySummary,
checkpointer=checkpointer,
system_prompt="""あなたは個人アシスタント"小助"です。天気の確認、スケジュール管理、メール送信ができます。
## 作業方法
1. ユーザーが「今日はどう?」などの質問をした場合:
- まず今日の天気を照会する(get_weather)
- 次に今日のスケジュールを照会する(query_schedule)
- その後、毎日の概要を生成する
2. ユーザーがメール送信を要求した場合は、send_email ツールを使用する
3. ユーザーが天気またはスケジュールのみを尋ねた場合は、対応するツールだけを呼び出す
## スタイル
- 口調は親しみやすく自然に
- リアルタイムデータの取得にはツールを優先して使い、捏造しないこと。""",
)
# ========== 6. 対話関数 ==========
def chat(message: str, thread_id: str = "xiaoming"):
"""アシスタントと対話します:
- stream_mode="values" を使って AI の思考と処理過程をストリーミング表示する(「ストリーミング出力」に対応)
- thread_id を渡すと、同じ thread_id の対話は SqliteSaver によって記憶される(「対話メモリ」に対応)
- このターンで構造化された概要が生成された場合は、Markdown にレンダリングしてまとめて出力する(「構造化出力」に対応)
"""
config = {"configurable": {"thread_id": thread_id}}
print(f"\n{'='*60}")
print(f"あなた: {message}")
print(f"{'='*60}")
seen = 0
final_state = {}
for state in agent.stream(
{"messages": [HumanMessage(content=message)]},
config=config,
stream_mode="values",
):
final_state = state
msgs = state.get("messages", [])
# 新しいメッセージがいくつか増えるたびに、Agent が一歩進んだことを示すので、リアルタイムに出力する
for msg in msgs[seen:]:
if msg.type == "ai" and getattr(msg, "tool_calls", None):
for call in msg.tool_calls:
print(f"🤔 ツール呼び出しを決定: {call['name']}、パラメータ: {call['args']}")
elif msg.type == "tool":
tool_name = getattr(msg, "name", "") or "ツール"
print(f"🔧 {tool_name} 戻り値: {str(msg.content)[:80]}")
elif msg.type == "ai" and msg.content:
print(f"🤖 アシスタント: {msg.content}")
seen = len(msgs)
# 注意:response_format は Agent レベルで設定されているため、毎回の呼び出し(メール送信や
フォローアップのような「スケジュール概要」とは無関係なリクエスト)でも、DailySummary の生成が強制的に試みられます。
これはグローバルな response_format の既知の制限です。本当にマルチインテントアシスタントにする場合、より良い方法は、
「毎日の概要を生成する」を独立したツールにし、Agent 自身に呼び出すかどうかを判断させることであり、
Agent 全体に常時有効な出力スキーマを設定することではありません。
if "structured_response" in final_state:
summary = final_state["structured_response"]
print("\n--- 構造化サマリー(Markdown) ---)
print(to_markdown(summary))
return final_state
# ========== 7. テスト ==========
if __name__ == "__main__":
chat("杭州の今日の天気はどうですか?私のスケジュールを見て、今日のまとめをください")
chat("[email protected] にメールを送ってください。件名は「今日のサマリー」、内容は今日のスケジュールが確認済みです")
# 第3ラウンドは新しい情報を一切含まず、純粋にエージェントが前のラウンドの会話を覚えているかを試す——
# 同じ thread_id を渡しているため、チェックポインターが完全な履歴を持ち帰る
chat("さっき送ってもらったあのメールの件名は何でしたっけ?")
from dotenv import load_dotenv
load_dotenv()
import sqlite3
from datetime import datetime
from pydantic import BaseModel, Field
from langchain.tools import tool
from langchain.agents import create_agent
from langchain.agents.middleware import dynamic_prompt
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
from langgraph.checkpoint.sqlite import SqliteSaver
# ========== 1. モックデータ ==========
calendar_events = [
{"id": 1, "title": "Python 学習", "date": "2024-03-25",
"time": "14:00", "duration": "2時間"},
{"id": 2, "title": "チーム週会", "date": "2024-03-25",
"time": "10:00", "duration": "1時間"},
{"id": 3, "title": "コードレビュー", "date": "2024-03-26",
"time": "15:00", "duration": "1.5時間"},
]
weather_db = {
"杭州": {"condition": "晴れ", "temp": 25, "humidity": 60},
"北京": {"condition": "曇り", "temp": 18, "humidity": 45},
"上海": {"condition": "小雨", "temp": 22, "humidity": 80},
}
# ========== 2. ツールを定義 ==========
@tool
def get_weather(city: str) -> str:
"""指定都市のリアルタイム天気を照会します。
Args:
city: 都市名(例:杭州、北京、上海)
"""
data = weather_db.get(city)
if not data:
return f"現在 {city} の天気は照会できません。対応都市:{', '.join(weather_db.keys())}"
return (f"{city}の天気:{data['condition']}、"
f"温度 {data['temp']}°C、湿度 {data['humidity']}%")
@tool
def query_schedule(date: str = None) -> str:
"""指定日のスケジュールを照会します。日付を指定しない場合は今日のスケジュールを照会します。
Args:
date: 日付。形式は YYYY-MM-DD(例:2024-03-25)。未指定の場合は今日を照会します
"""
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
events = [e for e in calendar_events if e["date"] == date]
if not events:
return f"{date} のスケジュールはありません。"
events.sort(key=lambda e: e["time"])
# ここでは emoji 文字を直接書く必要があり、📅 のような HTML エンティティで書いてはいけません——
# これは Python 文字列なので、そのまま出力され、ブラウザによって画像として解析されることはありません
lines = [f"📅 {date} のスケジュール:"]
for e in events:
lines.append(f" - {e['time']} {e['title']}({e['duration']})")
return "\n".join(lines)
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""メールを送信します(シミュレーション)。
Args:
to: 受信者のメールアドレス
subject: メールの件名
body: メール本文
"""
# 送信をシミュレーション
email_id = f"MSG-{datetime.now().strftime('%Y%m%d%H%M%S')}"
return f"メールを送信しました!宛先:{to}、件名:{subject}、メールID:{email_id}"
# ========== 3. 構造化出力モデル ==========
class DailySummary(BaseModel):
"""毎日の概要"""
date: str = Field(description="日付")
weather_summary: str = Field(description="天気概要")
event_count: int = Field(description="スケジュール数")
key_events: list[str] = Field(description="重要なスケジュール一覧")
suggestion: str = Field(description="今日のアドバイス")
def to_markdown(summary: DailySummary) -> str:
"""構造化された DailySummary を Markdown テキストにレンダリングし、
システム設計の「構造化出力:スケジュール概要をMarkdownとして整形」の項目に対応します。"""
lines = [
f"## {summary.date} 今日の概要",
"",
f"- **天気**:{summary.weather_summary}",
f"- **スケジュール数**:{summary.event_count}",
"",
"**重要な事項:**",
]
if summary.key_events:
lines += [f"- {event}" for event in summary.key_events]
else:
lines.append("- なし")
lines += ["", f"**今日のアドバイス**:{summary.suggestion}"]
return "\n".join(lines)
# ========== 4. Middleware を定義 ==========
@dynamic_prompt
def inject_date_context(request) -> str:
"""システムプロンプトの末尾に現在の日付情報を動的に追加します。
以前、@before_model で日付メッセージを messages リストに挿入する書き方には2つの問題がありました:
1. before_model が返すメッセージの更新は add_messages reducer を経由しますが、reducer は単に
"新メッセージを末尾に追加"という処理だけを行い、返されたリスト内の位置は認識しません。insert(-1, ...)
ユーザーメッセージの前に挿入しようとする意図は実際には有効になりません;
2. before_model は複数回のツール呼び出しループで繰り返し起動されるため、このメッセージを挿入しやすく、
AIMessage(tool_calls=...) とそれに対応する ToolMessage の間に、
厳格なメッセージ順序の要件を乱します。
代わりに @dynamic_prompt でシステムプロンプト文字列を直接書き換えます。モデル呼び出しのたびに
再計算されるため、messages リストには触れず、蓄積や位置ずれのリスクがありません。
"""
now = datetime.now()
weekday = ["月", "火", "水", "木", "金", "土", "日"][now.weekday()]
date_hint = (f"\n\n[システムプロンプト] 現在の日付は {now.strftime('%Y年%m月%d日')}、"
f"曜日{weekday}。ユーザーが日付を指定しない場合は、デフォルトで今日を照会します。")
return request.system_prompt + date_hint
# ========== 5. Agent を作成 ==========
# SqliteSaver を使って対話を永続化し、システム設計の「対話メモリ」を実現します。
# 自分で接続を確立して SqliteSaver コンストラクタに渡すのではなく、
SqliteSaver.from_conn_string()(これは with 文にのみ適した、
使い終わったら閉じるコンテキストマネージャです。詳細は『LangChain スマートカスタマーサービスロボット』の記事を参照してください)。
conn = sqlite3.connect("personal_assistant.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)
model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0.3)
agent = create_agent(
model=model,
tools=[get_weather, query_schedule, send_email],
middleware=[inject_date_context],
response_format=DailySummary,
checkpointer=checkpointer,
system_prompt="""あなたは個人アシスタント"小助"です。天気の確認、スケジュール管理、メール送信ができます。
## 作業方法
1. ユーザーが「今日はどう?」などの質問をした場合:
- まず今日の天気を照会する(get_weather)
- 次に今日のスケジュールを照会する(query_schedule)
- その後、毎日の概要を生成する
2. ユーザーがメール送信を要求した場合は、send_email ツールを使用する
3. ユーザーが天気またはスケジュールのみを尋ねた場合は、対応するツールだけを呼び出す
## スタイル
- 口調は親しみやすく自然に
- リアルタイムデータの取得にはツールを優先して使い、捏造しないこと。""",
)
# ========== 6. 対話関数 ==========
def chat(message: str, thread_id: str = "xiaoming"):
"""アシスタントと対話します:
- stream_mode="values" を使って AI の思考と処理過程をストリーミング表示する(「ストリーミング出力」に対応)
- thread_id を渡すと、同じ thread_id の対話は SqliteSaver によって記憶される(「対話メモリ」に対応)
- このターンで構造化された概要が生成された場合は、Markdown にレンダリングしてまとめて出力する(「構造化出力」に対応)
"""
config = {"configurable": {"thread_id": thread_id}}
print(f"\n{'='*60}")
print(f"あなた: {message}")
print(f"{'='*60}")
seen = 0
final_state = {}
for state in agent.stream(
{"messages": [HumanMessage(content=message)]},
config=config,
stream_mode="values",
):
final_state = state
msgs = state.get("messages", [])
# 新しいメッセージがいくつか増えるたびに、Agent が一歩進んだことを示すので、リアルタイムに出力する
for msg in msgs[seen:]:
if msg.type == "ai" and getattr(msg, "tool_calls", None):
for call in msg.tool_calls:
print(f"🤔 ツール呼び出しを決定: {call['name']}、パラメータ: {call['args']}")
elif msg.type == "tool":
tool_name = getattr(msg, "name", "") or "ツール"
print(f"🔧 {tool_name} 戻り値: {str(msg.content)[:80]}")
elif msg.type == "ai" and msg.content:
print(f"🤖 アシスタント: {msg.content}")
seen = len(msgs)
# 注意:response_format は Agent レベルで設定されているため、毎回の呼び出し(メール送信や
フォローアップのような「スケジュール概要」とは無関係なリクエスト)でも、DailySummary の生成が強制的に試みられます。
これはグローバルな response_format の既知の制限です。本当にマルチインテントアシスタントにする場合、より良い方法は、
「毎日の概要を生成する」を独立したツールにし、Agent 自身に呼び出すかどうかを判断させることであり、
Agent 全体に常時有効な出力スキーマを設定することではありません。
if "structured_response" in final_state:
summary = final_state["structured_response"]
print("\n--- 構造化サマリー(Markdown) ---)
print(to_markdown(summary))
return final_state
# ========== 7. テスト ==========
if __name__ == "__main__":
chat("杭州の今日の天気はどうですか?私のスケジュールを見て、今日のまとめをください")
chat("[email protected] にメールを送ってください。件名は「今日のサマリー」、内容は今日のスケジュールが確認済みです")
# 第3ラウンドは新しい情報を一切含まず、純粋にエージェントが前のラウンドの会話を覚えているかを試す——
# 同じ thread_id を渡しているため、チェックポインターが完全な履歴を持ち帰る
chat("さっき送ってもらったあのメールの件名は何でしたっけ?")
実行結果:
============================================================
あなた: 杭州今天天气怎么样?看看我的日程,然后给我一个今日总结
============================================================
🤔 决定调用工具: get_weather,参数: {'city': '杭州'}
🔧 get_weather 返回: 杭州天气:晴,温度 25°C,湿度 60%
🤔 决定调用工具: query_schedule,参数: {}
🔧 query_schedule 返回: 📅 2024-03-25 日程安排: - 10:00 团队周会(1小时) - 14:00...
🤖 助手: 早上好!今天杭州是大晴天,气温 25°C,湿度 60%,很适合出门活动。
今天您有两项日程:上午 10:00 的团队周会和下午 14:00 的 Python 学习,祝您今天顺利!
--- 结构化摘要(Markdown) ---
## 2024-03-25 今日摘要
- **天气**:杭州晴,25°C,湿度 60%
- **日程数量**:2
**重要事项:**
- 10:00 团队周会
- 14:00 Python 学习
**今日建议**:上午先参加团队周会,下午集中精力学习 Python,注意劳逸结合
============================================================
あなた: 帮我发一封邮件给 [email protected],主题是'今日总结',内容是今天日程已确认
============================================================
🤔 决定调用工具: send_email,参数: {'to': '[email protected]', 'subject': '今日总结', 'body': '今天日程已确认'}
🔧 send_email 返回: 邮件已发送!收件人:[email protected],主题:今日总结,邮件ID:MSG-20240325143000
🤖 助手: 邮件已经发送成功啦!收件人是 [email protected],主题"今日总结"。
--- 结构化摘要(Markdown) ---
## 2024-03-25 今日摘要
...(这里的摘要和邮件内容其实没什么关系,是 response_format 强制生成的,
属于前面提到的已知局限)
============================================================
あなた: 我刚才让你发的那封邮件,主题是什么来着?
============================================================
🤖 助手: 你刚才让我发的那封邮件主题是"今日总结",收件人是 [email protected]。
第3ラウンドはまったくツール呼び出しをトリガーせず、アシスタントが答えられたのは純粋に
thread_id同じ thread_id であるため、SqliteSaver が前2ラウンドの完全なメッセージ履歴を持ち帰りました——これこそが「対話記憶」が本当に機能している姿です。もしthread_id新しい値に変えると、第3ラウンドは孤立した会話になり、アシスタントは「さっき」何が起こったのか分からなくなります。
プロジェクトまとめ
この個人アシスタントは次のことを示しています:
| 特徴 | 実装方法 |
|---|---|
| マルチツール連携 | 天気+スケジュール+メール、エージェントが自動で呼び出し順序を選択 |
| 構造化出力 | DailySummary Pydantic モデル + to_markdown() で Markdown テキストにレンダリング |
| ストリーミング出力 | agent.stream(stream_mode="values") で、ツール呼び出しと思考プロセスを段階的に表示 |
| 対話記憶 | SqliteSaver + thread_id で、複数ラウンドの呼び出しをまたいでコンテキストを記憶 |
| 日付注入 | @dynamic_prompt でシステムプロンプトを動的に書き換え、メッセージ履歴の汚染を回避 |
| 自然言語インタラクション | ユーザーが自然言語で要件を説明し、エージェントが自律的に計画 |