Loading
BLOG 開発者ブログ

2025年4月28日

LangChainのAgent機能を試してみる②:自然言語でSQLクエリを自動生成

こんにちは、shibamです。
前回は「LangChainのAgent機能を試してみる①:APIを利用したアシスタント制作」という記事で、LangChainのエージェント機能について紹介しました。
今回は、そのエージェント機能を発展させて、データベースに対する自然言語クエリを実現する方法を解説します。

目次

  1. TL;DR
  2. LLMとデータベースの連携
  3. 環境構築
  4. SQLエージェントの実装
  5. 実行例
  6. TIPS
  7. Chainで実装してみる
  8. AgentとChainの選択
  9. LangGraphで実装してみる
  10. 最後に

1. TL;DR

  • LangChainエージェントを使って、自然言語でデータベースを操作できる
  • SQL知識がなくても複雑なデータ分析が可能になる
  • データベースのスキーマ情報を与えることで精度が向上する
  • 基本的なSQLエージェントの実装と活用例を紹介
  • Chainでの実装やLangGraphを利用した実装例も紹介

2. LLMとデータベースの連携

データベースは多くの企業で情報の保管・管理に利用されていますが、そこからデータを抽出して分析するにはSQLなどの専門知識が必要です。
LLMとデータベースを連携させることで、以下のようなメリットが生まれます。

  • アクセシビリティの向上:SQLを知らない人でも自然言語でデータを検索・分析できるようになる
  • 探索的分析の効率化:「この中で最も売れている商品は?」といった質問に直接回答を得られる
  • レポート生成の自動化:データを抽出して要約・分析したレポートを自動生成できる

前回はAPIツールをエージェントが使いこなす方法について見てきましたが、今回はSQLデータベースというツールを使いこなすエージェントを作成します。

3. 環境構築

まずは必要なライブラリをインストールしましょう。以下のコマンドで、最新のLangChainとその関連パッケージをインストールします:

pip install "langchain>=0.3.0" "langchain-community>=0.0.20" "langchain-openai>=0.0.5" "langchain-core>=0.3.0" python-dotenv openai requests

今回はサンプルとして、Chinookデータベースを使用します。これは音楽ストアのデータを含むSQLiteデータベースで、アーティスト、アルバム、曲、顧客、注文などの情報が含まれています。

4. SQLエージェントの実装

LangChain v0.3では、エージェントの実装方法が大きく進化し、より柔軟で拡張性の高い設計になりました。以下のコードで、自然言語でデータベースに問い合わせるエージェントを構築できます。

import os
import requests
import zipfile
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import Tool
from langchain_openai import ChatOpenAI
from langchain_community.utilities.sql_database import SQLDatabase
from langchain.agents import AgentExecutor, create_openai_tools_agent

# 環境変数の読み込み
load_dotenv()

# Chinookデータベースのダウンロード(初回のみ)
def setup_chinook_db():
    db_path = "chinook.db"
    if not os.path.exists(db_path):
        print("Chinookデータベースをダウンロードしています...")
        zip_path = "chinook.zip"
        url = "https://www.sqlitetutorial.net/wp-content/uploads/2018/03/chinook.zip"
        
        # ダウンロード
        response = requests.get(url)
        with open(zip_path, 'wb') as f:
            f.write(response.content)
        
        # 解凍
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            zip_ref.extractall(".")
        
        # クリーンアップ
        os.remove(zip_path)
        print("データベースのセットアップが完了しました")
    return db_path

# データベース接続
db_path = setup_chinook_db()
db = SQLDatabase.from_uri(f"sqlite:///{db_path}")

# データベース構造の確認
print("データベースのテーブル一覧:")
print(db.get_table_names())

# SQLエージェントの作成
def create_sql_agent(db_path: str, verbose: bool = True):
    # データベース接続の設定
    db = SQLDatabase.from_uri(f"sqlite:///{db_path}")
    
    # LLMの初期化
    llm = ChatOpenAI(model="gpt-4o", temperature=0)
    
    # 直接SQLクエリを実行する関数(ツールとして使用)
    def run_query(query: str) -> str:
        try:
            return db.run(query)
        except Exception as e:
            return f"Error: {str(e)}"
    
    # SQLツールを作成
    tools = [
        Tool(
            name="get_schema",
            func=lambda _: db.get_table_info(),
            description="データベース内のテーブル構造(スキーマ)情報を取得します。"
        ),
        Tool(
            name="query_sql",
            func=run_query,
            description="SQLクエリを実行してデータベースから情報を取得します。"
        )
    ]
    
    # エージェントプロンプトの設定
    agent_prompt = ChatPromptTemplate.from_messages([
        ("system", """あなたはデータベース分析の専門家です。SQLiteデータベースに対してクエリを実行し、
ユーザーの質問に答えるために必要な情報を取得できます。

使用可能なツール:
1. get_schema: データベーススキーマ情報を取得します。
2. query_sql: SQLクエリを実行して結果を取得します。

まず、get_schemaツールを使ってデータベース構造を理解し、
次にquery_sqlツールで適切なSQLクエリを実行してください。
複雑な分析では、段階的にクエリを実行し、結果に基づいて次のクエリを調整することも重要です。

最終的な回答は日本語で、データの分析結果や洞察を含めて提供してください。

このデータベースはChinookという音楽ストアのデータベースです。"""),
        ("human", "{input}"),
        ("assistant", "{agent_scratchpad}")  // 注意: v0.3では "agent" ではなく "assistant" を使用
    ])
    
    # エージェントの作成
    agent = create_openai_tools_agent(llm, tools, agent_prompt)
    
    # エージェントエグゼキューターの作成
    agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=verbose)
    
    return agent_executor

# エージェントを使った問い合わせ
def ask_database(question):
    print(f"質問: {question}")
    try:
        # SQLエージェントの作成
        agent_executor = create_sql_agent(db_path, verbose=True)
        
        # エージェントを実行
        response = agent_executor.invoke({"input": question})
        
        print("\n最終回答:")
        return response["output"]
    except Exception as e:
        print(f"エラーが発生しました: {e}")
        return str(e)

# 実行例
question = "最も売上の多い顧客トップ5を教えてください"
result = ask_database(question)
print(result)

5. 実行例

実際にエージェントを使って、Chinookデータベースに対して自然言語で問い合わせを行ってみましょう。

例1: 売上の多い顧客

あなたの質問: 売上の多い顧客トップ5

I'll analyze this database to find the top 5 customers with the highest sales.

First, I need to understand the database schema.

Action: get_schema
Action Input: 

CREATE TABLE "Album" (
	"AlbumId" INTEGER NOT NULL, 
	"Title" NVARCHAR(160) NOT NULL, 
	"ArtistId" INTEGER NOT NULL, 
	PRIMARY KEY ("AlbumId"), 
	FOREIGN KEY("ArtistId") REFERENCES "Artist" ("ArtistId")
)
... (スキーマ情報が続く) ...

Now I understand the database structure. To find the top 5 customers with the highest sales, I need to look at the Invoice and Customer tables.

Action: query_sql
Action Input: SELECT c.CustomerId, c.FirstName, c.LastName, SUM(i.Total) AS TotalSpent
FROM Customer c
JOIN Invoice i ON c.CustomerId = i.CustomerId
GROUP BY c.CustomerId
ORDER BY TotalSpent DESC
LIMIT 5;

CustomerId | FirstName | LastName | TotalSpent
59         | Helena    | Holý     | 49.62
29         | Robert    | Brown    | 47.62
25         | Victor    | Stevens  | 46.62
53         | Ladislav  | Kovács   | 45.62
52         | Hugh      | O'Reilly | 45.62

Based on the query results, I can now provide the answer.

最終回答:
最も売上の多い顧客トップ5は以下の通りです:

1. Helena Holý(顧客ID: 59)- 総支出額: $49.62
2. Robert Brown(顧客ID: 29)- 総支出額: $47.62
3. Victor Stevens(顧客ID: 25)- 総支出額: $46.62
4. Ladislav Kovács(顧客ID: 53)- 総支出額: $45.62
5. Hugh O'Reilly(顧客ID: 52)- 総支出額: $45.62

これらの顧客が店舗での支出額上位5名となります。特にHelena Holýさんが最も多くの金額を支出しています。

6. TIPS

Tool Callingによるエージェント実装について
前回の記事ではReActプロンプトを利用しましたが、今回の記事ではTool Callingを利用しています。
以下、おおよその比較表です。

比較項目 Tool Callingを使うべき場面 ReActプロンプトを使うべき場面
適用シナリオ 外部ツールを明確に呼び出し、正確な処理実行が求められるタスク(例: 数値計算、ウェブ検索、データベース問い合わせ) モデル自身が自律的に思考を進め、試行錯誤しながら問題解決を行うタスク(例: 複数ステップの推論、探索型タスク)
メリット ・構造化された出力によりツール呼び出しが正確で信頼性が高い
・開発者が明示的にツール実行を制御可能
・内部推論過程が逐次出力され、解答に至る理由が明確
・デバッグやプロセスの透明性向上に寄与
デメリット ・定義済みのスキーマに厳格に従うため、予期しない状況や特殊な入力に対して柔軟に対応するのが難しくなる場合がある。
・各ツールのスキーマやdescriptionを正確に定義・更新する必要があり、プロジェクトの初期設定や継続的なメンテナンスの手間が増える可能性がある
・思考過程が冗長になりやすく、プロンプト設計次第で出力の安定性に課題が生じる可能性がある
応答の制御性 定義済みスキーマに基づいた出力で、安定したフォーマットが得られる モデルの動的な思考に依存するため、応答内容やフォーマットが変動しやすい

7. Chainで実装してみる

今回のSQLエージェント程度であれば、Chainとして実装することも可能です。
以下は実装例です。

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_community.utilities.sql_database import SQLDatabase

# データベース接続
db = SQLDatabase.from_uri(f"sqlite:///chinook.db")

# データベーススキーマ情報を取得
db_schema = db.get_table_info()

# LLMの初期化
llm = ChatOpenAI(model="gpt-4o", temperature=0)

# SQLクエリ生成チェーン
sql_generation_prompt = ChatPromptTemplate.from_messages([
    ("system", """あなたはSQLの専門家です。以下のデータベーススキーマを元に、
ユーザーの質問に答えるための適切なSQLクエリを作成してください。
SQLiteの文法を使って、読みやすく効率的なクエリを書いてください。

データベーススキーマ:
{schema}

回答は必ずSQLクエリのみを返してください。説明は含めないでください。"""),
    ("human", "{question}")
])

# 結果解釈チェーン
interpretation_prompt = ChatPromptTemplate.from_messages([
    ("system", """あなたはデータアナリストです。SQLクエリの結果を分析し、
ユーザーの質問に対する洞察を日本語で提供してください。
専門用語を避け、一般の人にもわかりやすく説明してください。"""),
    ("human", """元の質問: {question}
実行されたクエリ: {query}
クエリの結果: {result}

この結果から何がわかりますか?""")
])

# 完全なチェーンの構築
chain = (
    RunnablePassthrough.assign(
        schema=lambda _: db_schema,
        query=lambda x: (
            sql_generation_prompt 
            | llm 
            | StrOutputParser()
        ).invoke({"schema": db_schema, "question": x["question"]})
    )
    .assign(
        result=lambda x: db.run(x["query"])
    )
    | interpretation_prompt
    | llm
    | StrOutputParser()
)

# 実行例
result = chain.invoke({"question": "最も売れている音楽ジャンルは何ですか?"})
print(result)

8. AgentとChainの選択

「エージェント使うべき?それともチェーンで十分?」という点については、曖昧な質問にどこまで対応させたいかで変わってきます。用途によって使い分けるのがベストだと思います。

例2: 曖昧な質問への対応

あなたの質問: 学校で聞くのにオススメの音楽は?

質問: 学校で聞くのにオススメの音楽は?

...(about 5 lines omitted)...
3. **Samba De Uma Nota Só (One Note Samba)**
4. **Por Causa De Você**
5. **Ligia**
6. **Fotografia**
7. **Dindi (Dindi)**
8. **Se Todos Fossem Iguais A Você (Instrumental)**
9. **Falando De Amor**
10. **Angela**

これらの曲は、心地よいメロディーとリズムで、集中力を高めたり、リラックスしたりするのに役立つでしょう。ぜひ、試してみてください。

こういったSQL化を行うために「思考」が必要になるようなケースを想定する場合は、Agent型を利用する必要があります。
以下のような流れで考えてくれます(実行ログは省略):

  1. まずデータベースのスキーマを確認し、音楽関連のテーブル(tracks、genres、albums、artists)を特定
  2. 学校環境に適した音楽として「リラックスできるジャンル」や「集中力を高めるジャンル」を選定
  3. ジャズとクラシックのジャンルIDを検索(ID: 2と24)
  4. これらのジャンルに属する曲をクエリで検索し、具体的な曲のリストを生成
  5. 最終的な回答として、学校環境に適したジャズ曲のリストを提示

このように、直接的なSQLクエリに変換しにくい曖昧な質問でも、Agentは複数のステップを踏んで適切な回答を導き出します。

9. LangGraphで実装してみる

v0.2以降では、LangChainはLangGraphの利用を推奨しています。
今回のようなツールについてもLangGraphを利用して作成することも可能であり、
以下はその実装例です。

import os
import requests
import zipfile
from typing import List, Dict, Any, TypedDict, Literal, Optional, Union
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langchain_community.utilities.sql_database import SQLDatabase
from langchain_core.tools import tool
from langgraph.graph import END, START, StateGraph
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import ToolNode

# 環境変数の読み込み
load_dotenv()

# Chinookデータベースのダウンロード(初回のみ)
def setup_chinook_db():
    db_path = "chinook.db"
    if not os.path.exists(db_path):
        print("Chinookデータベースをダウンロードしています...")
        zip_path = "chinook.zip"
        url = "https://www.sqlitetutorial.net/wp-content/uploads/2018/03/chinook.zip"
        
        # ダウンロード
        response = requests.get(url)
        with open(zip_path, 'wb') as f:
            f.write(response.content)
        
        # 解凍
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            zip_ref.extractall(".")
        
        # クリーンアップ
        os.remove(zip_path)
        print("データベースのセットアップが完了しました")
    return db_path

# 状態の型定義
class AgentState(TypedDict):
    messages: List[Union[HumanMessage, AIMessage, ToolMessage]]

# SQLツールの作成
def setup_tools(db_path: str):
    # データベース接続の設定
    db = SQLDatabase.from_uri(f"sqlite:///{db_path}")
    
    # スキーマ取得ツール
    @tool
    def get_schema() -> str:
        """データベース内のテーブル構造(スキーマ)情報を取得します。"""
        return db.get_table_info()
    
    # クエリ実行ツール
    @tool
    def query_sql(query: str) -> str:
        """SQLクエリを実行してデータベースから情報を取得します。"""
        try:
            return db.run(query)
        except Exception as e:
            return f"エラー: {str(e)}"
    
    return [get_schema, query_sql]

# エージェント(LLM)ノード
def agent(state: AgentState, llm, tools):
    # メッセージの取得
    messages = state["messages"]
    
    # システムメッセージを追加(入力されていない場合)
    system_message_exists = any(
        isinstance(m, HumanMessage) and "あなたはデータベース分析の専門家です" in m.content
        for m in messages
    )
    
    if not system_message_exists:
        messages.insert(0, HumanMessage(content="""あなたはデータベース分析の専門家です。SQLiteデータベースに対してクエリを実行し、ユーザーの質問に答えるために必要な情報を取得できます。

使用可能なツール:
1. get_schema: データベーススキーマ情報を取得します。
2. query_sql: SQLクエリを実行して結果を取得します。

まず、get_schemaツールを使ってデータベース構造を理解し、次にquery_sqlツールで適切なSQLクエリを実行してください。
複雑な分析では、段階的にクエリを実行し、結果に基づいて次のクエリを調整することも重要です。

以下のガイドラインに従ってください:
- クエリを実行する前に、必ずスキーマを確認してください
- SQLiteの文法を使用してください
- 不明なテーブルやカラムに関しては、スキーマ情報を再確認してください
- 最終的な回答は日本語で、データの分析結果や洞察を含めて提供してください
- クエリと結果だけでなく、あなたの思考プロセスも説明してください

このデータベースはChinookという音楽ストアのデータベースで、アーティスト、アルバム、トラック、顧客、請求書などの情報が含まれています。"""))
    
    # LLMによる応答を取得
    response = llm.bind_tools(tools).invoke(messages)
    
    # メッセージリストに追加
    messages.append(response)
    
    return {"messages": messages}

# ツール実行ノード
def tools_executor(state: AgentState, tools):
    messages = state["messages"]
    last_message = messages[-1]
    
    # ツール呼び出しがある場合
    if isinstance(last_message, AIMessage) and hasattr(last_message, 'tool_calls') and last_message.tool_calls:
        tool_calls = last_message.tool_calls
        
        for tool_call in tool_calls:
            # 修正:ツールコール形式のパターンを追加
            if hasattr(tool_call, 'name'):
                # LangChainのオブジェクト形式
                action = tool_call.name
                args = tool_call.args
                tool_call_id = getattr(tool_call, 'id', None)
            elif isinstance(tool_call, dict) and 'function' in tool_call:
                # OpenAI標準形式
                action = tool_call['function']['name']
                args = tool_call['function']['arguments']
                tool_call_id = tool_call.get('id', None)
            elif isinstance(tool_call, dict) and 'name' in tool_call:
                # 新しく追加する形式: nameが直接dictのトップレベルにある
                action = tool_call['name']
                args = tool_call.get('args', {})
                tool_call_id = tool_call.get('id', None)
            else:
                print(f"未知のツールコール形式: {tool_call}")
                continue
            
            # 引数がJSON文字列の場合はパース
            if isinstance(args, str):
                import json
                try:
                    args = json.loads(args)
                except:
                    args = {}
            
            # ツールを検索
            for tool in tools:
                if tool.name == action:
                    # ツールを実行
                    try:
                        if isinstance(args, dict):
                            # 辞書の場合は引数として渡す
                            result = tool.invoke(args)
                        else:
                            # それ以外の場合は直接渡す
                            result = tool.invoke(args)
                    except Exception as e:
                        result = f"エラー: {str(e)}"
                    
                    # 結果をツールメッセージとして追加
                    messages.append(ToolMessage(
                        content=str(result), 
                        tool_call_id=tool_call_id if tool_call_id else "unknown_id"
                    ))
                    break  # ツールが見つかったらループを抜ける
    
    return {"messages": messages}

# 次のステップを決定する関数を修正
def decide_next_step(state: AgentState) -> Literal["agent", "tools", END]:
    messages = state["messages"]
    
    if not messages:
        return "agent"
    
    last_message = messages[-1]
    
    # ツール呼び出しの検出をより堅牢に
    has_tool_calls = False
    if isinstance(last_message, AIMessage):
        if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
            has_tool_calls = True
        elif hasattr(last_message, 'additional_kwargs'):
            if 'tool_calls' in last_message.additional_kwargs and last_message.additional_kwargs['tool_calls']:
                has_tool_calls = True
    
    # ツール呼び出しがある場合はツール実行へ
    if has_tool_calls:
        return "tools"
    # ツールからの応答がある場合はエージェントへ
    elif isinstance(last_message, ToolMessage):
        return "agent"
    # 普通のAIメッセージで終了
    elif isinstance(last_message, AIMessage):
        return END
    # その他の場合はエージェントへ
    else:
        return "agent"

# SQLエージェントグラフの作成
def create_sql_agent_graph(db_path: str):
    # LLMの初期化
    llm = ChatOpenAI(model="gpt-4o", temperature=0)
    
    # ツールの作成
    tools = setup_tools(db_path)
    
    # グラフの作成
    workflow = StateGraph(AgentState)
    
    # ノードの追加
    workflow.add_node("agent", lambda state: agent(state, llm, tools))
    workflow.add_node("tools", lambda state: tools_executor(state, tools))
    
    # エッジの設定
    workflow.add_edge(START, "agent")
    workflow.add_conditional_edges("agent", decide_next_step)
    workflow.add_conditional_edges("tools", decide_next_step)
    
    # グラフのコンパイル
    return workflow.compile()

# エージェントを使った問い合わせ
def ask_database(question: str):
    print(f"質問: {question}")
    try:
        # データベースのパスを取得
        db_path = setup_chinook_db()
        
        # SQLエージェントグラフの作成
        agent_executor = create_sql_agent_graph(db_path)
        
        # 初期状態の設定
        initial_state = AgentState(
            messages=[HumanMessage(content=question)]
        )
        
        # エージェントを実行
        result = agent_executor.invoke(initial_state)
        
        print("\n最終回答:")
        # 最後のAIメッセージを取得
        for message in reversed(result["messages"]):
            if isinstance(message, AIMessage) and not (hasattr(message, 'tool_calls') and message.tool_calls):
                return message.content
        
        return "回答が見つかりませんでした。"
    except Exception as e:
        print(f"エラーが発生しました: {e}")
        return str(e)

# メイン実行部分
if __name__ == "__main__":
    print("Chinookデータベースチャットボット(LangGraph版)へようこそ!")
    print("質問を入力してください(終了するには 'exit' と入力)")
    print("例: '最も売上の多い顧客トップ5を教えてください'")
    
    while True:
        user_input = input("\nあなたの質問: ")
        if user_input.lower() in ["exit", "quit", "終了"]:
            print("チャットボットを終了します。お役に立てて嬉しいです!")
            break
            
        result = ask_database(user_input)
        print(result) 

LangGraphは文字通り「思考のグラフ構造化」によって推論を進めます。

ノードとしてTool Callingを、エッジとして各種状態を定義し、進む先の決定関数を用意してやることで、
AIが「どのような手順で思考を進めていくか」を定義していきます。

これによって複雑な思考が必要なケースでもシンプルな実装ができるのですが、
初期実装のコストが結構高く、サンプル程度のケースの場合はこれまで用意されている各種agent関数を利用するほうがやりやすいかもしれません。
(ただ、v0.3のNOTEにもある通り、今後はLangGraphを推奨して進めていく形になるので、慣れておいた方が良さそうだとは思います。)

10. 最後に

LangChainのエージェントを使ったデータベース操作について紹介しました。
LLMの強力な言語理解能力とSQLデータベースを連携させることで、技術的な知識がなくても、データから価値ある情報を抽出できるようになります。

今回のアプローチは、以下のような場面で特に役立ちます。

  • データアナリストでない社員がデータベースにアクセスする場合
  • 複雑なデータ分析を自然言語で実行したい場合
  • SQLの知識なしでデータベースから情報を抽出したい場合

また、LangChainはかなり速い速度で更新がかかっているライブラリですので、あくまで本記事執筆時点(2025年2月)での情報になります。注意してください。

前回解説したAPIとの連携と同様に、データベースとの連携もLangChainエージェントの強力な応用例です。これにより、AIとデータベースの間の障壁が低くなり、より多くの人がデータの恩恵を受けられるようになります。

是非、皆さんの手元でも試してみてください。

shibamのブログ