見出し画像

LangChainとPydanticAIで作るレストラン注文エージェント:フレームワーク比較と実装の要点


はじめに

ここでは、あるレストランのウェイター役となるAIエージェントを作成するにあたり、LangChainとPydanticAIという2つのフレームワークを比較した内容をもとに解説を行う。レストランの注文受付を想定した簡単なシナリオだが、利用する機能としてはモデル選択の動的化、ツールの呼び出し、会話履歴の管理、構造化された出力の生成など多岐にわたる。本稿では、これらの実装例を題材に両フレームワークの特徴や実装手順の大まかな流れを示しながら、それぞれの利点と注意点を整理する。

まず、このAIウェイターは次のような対話を想定している。

「AIウェイターが挨拶し、食事制限の有無を尋ねる」 「利用者が“黄色い食べ物しか食べない”などの要望を返す」 「AIウェイターがメニューを確認するツールを呼び出す」 「ユーザの要望に合ったメニューを紹介する」 「最終的に確定した注文内容を別のツールで記録する」

この一連の流れを成立させるためには、次のような要素が必要となる。

  • 会話中に“ツール呼び出し”として外部サービスを参照し、メニューを取得したり注文を作成したりできる機能

  • システムプロンプトによる役割設定(今回であれば“ウェイターとして”の立場)

  • 応答の最終出力を構造化し、ユーザに返すタイミングを制御する仕組み

  • 適切な時点で会話が終了するフラグを持たせる手法

LangChainは複数モデルの利用や豊富なコンポーネントが特徴のフレームワークだが、歴史が長いため多くの機能が追加されており、使い方や実装パターンが頻繁に変更されてきた。一方のPydanticAIはデータバリデーションライブラリであるPydantic由来の設計思想を持ち、直感的な型注釈や構造化出力を利用しやすい設計になっている。本稿では、まず両フレームワークの概要を整理し、それからPydanticAIとLangChainの実装手順を順に見ていく。

なお、ここで示すコード例は英語のまま記載しており、関数名やクラス名も引用元に準拠している。実際の利用シーンでは、必要に応じて日本語や好みのネーミングに変更可能である。また、今回例示するエージェントはあくまでサンプル実装のため、本番環境に導入する際にはセキュリティや拡張性の観点で追加の検討を要する場合がある。

以上の流れを踏まえながら、次の見出しでLangChainとPydanticAIの全体像や本サンプルにおける構造を順に解説していく。

LangChainとPydanticAIの概要

LangChainは大規模言語モデル(LLM)と各種ツールやデータソースの組み合わせを容易にするためのフレームワークである。特に複数モデルの利用や、ChainやAgentと呼ばれる部品単位での再利用可能な設計、さらにグラフ構造を用いて柔軟にチャットフローを構成するLangGraphなど、多岐にわたる機能を提供している。

しかしバージョンアップのサイクルが早く、仕様変更や新機能の追加が頻繁に行われている。そのため、過去のドキュメントとの不整合や同じ目的を実現する複数の実装方法が混在しがちで、導入時に戸惑うことがある。実際にLangChainでエージェントを作成しようとすると、AgentExecutorやcreate_tool_calling_agentなど多様なAPIが存在し、同じようなことを実現するのに複数の手法があるため最適解を探すのにやや手間がかかる。

一方でPydanticAIはPydanticをベースに、Generative AIを使ったアプリケーション開発を支援する比較的新しいフレームワークである。Pydanticによる構造化データ・バリデーションの仕組みを自然に取り込んでおり、ツール呼び出しや出力整形などを型注釈やデコレータを用いて比較的シンプルに定義できるのが利点である。また、ライブラリのサイズが小さい点も特徴で、Logfireなどの統合が用意されているなど、モダンな設計が印象的と言える。

今回のデモでは、次の実装要素を両フレームワークで共通して行っている。

  1. システムプロンプトを動的に構成し、ウェイターとしての振る舞いを指示

  2. LLMが最終的に返すべきレスポンスを、あらかじめ定義した構造体に沿って返す

  3. メニュー取得用のツールと注文作成用のツールを別々に用意

  4. 会話の途中でユーザの発言やLLMのレスポンスをヒストリーに蓄積し、継続的な文脈を維持

  5. ツール呼び出しにより返ってきた情報をもとに、ユーザへ追加の問いかけや最終確定した注文情報を渡す

  6. end_conversationフラグがTrueになったら会話を終了し、最終的に注文内容を表示

LangChain実装とPydanticAI実装の大きな相違点は、ツールの登録や、構造化出力を行うための仕組みがどの程度自動的かつ直感的に設定できるかという点にある。LangChainは柔軟なぶん設定箇所やクラスの階層が多く、PydanticAIは型情報を利用することで簡潔な記述を実現している傾向がある。

次の見出しでは、まずPydanticAIの実装全体を確認し、具体的なコード例を通じて流れを解説する。

PydanticAI実装のポイント

PydanticAIでは、dependenciesやtools、system_promptなど、メインとなる要素を定義し、最終的にAgentクラスを生成してエージェントを動作させる。

大まかな流れは以下のようになる。

  1. 依存関係(Dependencies)クラスの作成:MenuServiceやOrderService、レストラン名やテーブル番号などをひとまとめにしたデータクラスを定義する。

  2. ツール関数の作成:実行時に外部のメニュー取得や注文作成を行う関数を用意する。これらの関数には型注釈やdocstringをきちんと書くことで、PydanticAIが自動でスキーマを認識し、LLMに正しい引数形式を教示する。

  3. Agentの生成:モデルの指定、ツール一覧の登録、system_promptの登録などを行い、最終的にAgentオブジェクトを返す。

  4. AgentRunnerで実際の対話を進める:依存関係とともにAgentを初期化し、ユーザの入力を受け取ってAgentに渡し、LLMの応答を受け取る。PydanticAIでは、各やり取りごとにmessage_historyを明示的に渡すことでコンテキストを保持する。

たとえば以下のような依存クラスを想定するとする。

@dataclass
class Dependencies:
    menu_service: MenuService
    order_service: OrderService
    restaurant_name: str
    table_number: int

menu_serviceやorder_serviceは外部へのアクセスを担うサービス層となっている。これらを参照して、下記のようなツール関数を定義する。

def create_order(
    ctx: RunContext[Dependencies],
    table_number: int,
    order_items: Annotated[list[str], "List of food menu items to order"],
) -> str:
    """Create an order for the table"""
    ctx.deps.order_service.create_order(table_number, order_items)
    return "Order placed"

def get_menu(ctx: RunContext[Dependencies]) -> dict[str, list[str]]:
    """Get the full menu for the restaurant"""
    return ctx.deps.menu_service.get_menu()

これらの関数により、LLMが「メニューを取得したい」とか「注文を作成したい」と判断した際に呼び出される。

次にAgentの生成では、modelとtools、そしてsystem_promptを定義する。PydanticAIはメインのAgentを生成したあとに、@agent.toolや@agent.system_promptといったデコレータを使うパターンがドキュメントにある。しかし、動的な構成を行いたい場合、@agent.system_promptを使いつつ関数として定義して返す手段が紹介されている。

def get_agent(model_name: KnownModelName, api_key: str | None = None) -> Agent[Dependencies, LLMResponse]:
    model = build_model_from_name_and_api_key(
        model_name=model_name,
        api_key=api_key,
    )
    agent = Agent(model=model, deps_type=Dependencies, tools=[get_menu, create_order])

    @agent.system_prompt
    def system_prompt(ctx: RunContext[Dependencies]) -> str:
        return PROMPT_TEMPLATE.format(
            restaurant_name=ctx.deps.restaurant_name,
            table_number=ctx.deps.table_number
        )

    return agent

最後に、AgentRunnerを用意して実際の対話を行う。

class PydanticAIAgentRunner(AgentRunner):
    agent: Agent[Dependencies, LLMResponse]
    deps: Dependencies
    message_history: list[ModelMessage]

    def __init__(
        self,
        menu_service: MenuService,
        order_service: OrderService,
        args: argparse.Namespace,
    ):
        self.agent = get_agent(model_name=args.model, api_key=args.api_key)
        self.deps = Dependencies(
            menu_service=menu_service,
            order_service=order_service,
            restaurant_name=args.restaurant_name,
            table_number=args.table_number,
        )
        self.message_history = []

    def make_request(self, user_message: str) -> LLMResponse:
        ai_response = self.agent.run_sync(
            user_message,
            deps=self.deps,
            message_history=self.message_history,
        )
        self.message_history = ai_response.all_messages()
        return ai_response.data

以上のように、PydanticAIではツール呼び出しが自動的に行われ、その戻り値をLLMの応答に取り込みやすい仕組みが整備されている。型を厳密に記述することで、LLMへの指示や出力データの一貫性が保たれるのが特徴だ。

LangChain実装のポイント

LangChainを用いた場合、AgentExecutorとToolを組み合わせつつ、構造化された最終出力を行いたい時は独自の工夫が必要になる。さらに会話履歴を保持するためにはRunnableWithMessageHistoryなどを使う必要があり、初学者には少々複雑に映るかもしれない。

まず、メニューを取得するためのToolクラスと注文を作成するためのToolクラスを別々に定義する。LangChainにおける@toolデコレータを使うことも可能だが、依存関係の注入がやや面倒になるため、以下のようにクラスを継承して書いたほうが明確に整理しやすい。

class GetMenuTool(BaseTool):
    name: str = "get_menu"
    description: str = "Get the full menu for the restaurant"
    menu_service: MenuService

    def _run(self) -> dict[str, list[str]]:
        return self.menu_service.get_menu()


class CreateOrderInputSchema(BaseModel):
    table_number: int
    order_items: Annotated[list[str], "List of food menu items to order"]

class CreateOrderTool(BaseTool):
    name: str = "create_order"
    description: str = "Create an order for the table"
    args_schema: type[BaseModel] = CreateOrderInputSchema
    order_service: OrderService

    def _run(self, table_number: int, order_items: list[str]) -> str:
        self.order_service.create_order(table_number, order_items)
        return "Order placed"

また、最終的にユーザに返す構造化出力についてもツールとして定義し、そこから返された文字列を解析してLLMResponse型に復元する仕組みが使われている。LangChainにもwith_structured_output()という仕組みはあるが、他のツールを組み合わせたい場合は少々複雑になる。

class StructuredResponseTool(BaseTool):
    name: str = "respond_to_user"
    description: str = (
        "ALWAYS use this tool to provide a response to the user..."
    )
    args_schema: type[BaseModel] = LLMResponse
    return_direct: bool = True

    def _run(self, message: str, end_conversation: bool) -> str:
        return LLMResponse(
            message=message,
            end_conversation=end_conversation
        ).model_dump_json()

エージェントを生成するためにAgentExecutor.from_agent_and_toolsを用いるが、単純な会話のやり取りを想定しているため、最終的にはagent_executor.invoke()を呼び出している。さらにLangChainで会話履歴を保持するにはRunnableWithMessageHistoryにAgentExecutorを包み込むというアプローチをとっている。

def get_agent_executor(
    tools: Sequence[BaseTool],
    model_name: str,
    api_key: str | None = None
) -> RunnableWithMessageHistory:
    model = build_model_from_name_and_api_key(...)
    prompt = ChatPromptTemplate.from_messages([
        ("system", PROMPT_TEMPLATE),
        ("placeholder", "{chat_history}"),
        ("human", "{input}"),
        ("placeholder", "{agent_scratchpad}"),
    ])

    llm_with_tools = model.bind_tools(tools, tool_choice=True)
    agent = (
        RunnablePassthrough.assign(...)
        | prompt
        | llm_with_tools
        | ToolsAgentOutputParser()
    )

    agent_executor = AgentExecutor.from_agent_and_tools(...)
    message_history = ChatMessageHistory()

    agent_with_chat_history = RunnableWithMessageHistory(
        runnable=agent_executor,
        get_session_history=lambda _: message_history,
        input_messages_key="input",
        history_messages_key="chat_history",
    )
    return agent_with_chat_history

実際の会話実行部分は、PydanticAIのときと同様にrunnerクラスを作り、ユーザから受け取ったテキストと保持している静的情報(restaurant_nameなど)を合わせてinvokeに渡し、結果が返ってきたらJSON文字列として扱い、LLMResponse型にパースする流れになる。

class LangchainAgentRunner(AgentRunner):
    def __init__(self, menu_service: MenuService, order_service: OrderService, args: argparse.Namespace):
        tools = [
            GetMenuTool(menu_service=menu_service),
            CreateOrderTool(order_service=order_service),
            StructuredResponseTool(),
        ]
        self.agent_executor = get_agent_executor(...)
        self.static_input_content = {
            "restaurant_name": args.restaurant_name,
            "table_number": args.table_number
        }
        self.config: RunnableConfig = {"configurable": {"session_id": "not-even-used"}}

    def make_request(self, user_message: str) -> LLMResponse:
        result = self.agent_executor.invoke(
            self.static_input_content | {"input": user_message},
            config=self.config,
        )
        response = LLMResponse.model_validate_json(result["output"])
        return response

このようにLangChainの実装は、PydanticAIのシンプルさに比べてクラス構造が複雑であり、会話履歴の保持や構造化出力を伴うツール呼び出しを含むシナリオでは少々コード量が増える傾向があると言える。ただし、LangChainにはその分だけ豊富な拡張性や既存機能があるので、大規模システムとの連携や特定のコンポーネントの使いまわしをする場合には強力な選択肢となる。

まとめ

今回のデモ例では、PydanticAIとLangChainを用いて同様の機能を持つAIエージェント(レストランのウェイター役)を構築した。それぞれのフレームワークは以下のような特徴を持つといえる。

  • PydanticAI:

    • Pydanticが持つ明確な型情報とバリデーション仕組みを活用しやすい

    • シンプルなAPIでツール呼び出しと構造化出力を実現可能

    • ライブラリサイズが比較的小さい

    • 新しいプロジェクトのためドキュメントやコミュニティがLangChainほど多くはない

  • LangChain:

    • 歴史が長く豊富な機能を備えている

    • 多様なエージェント構成やチャットフロー設計に対応できる柔軟性

    • 頻繁なアップデートによりドキュメントや実装例がやや混在気味

    • フレームワーク内部構造が複雑で初学者には取り組みにくい部分もある

いずれも大型LLMを活用したプロジェクトを後押しする強力なフレームワークだが、どちらを選ぶかは「要求される拡張性」と「シンプルに実装をまとめたいか」のバランスやチームのスキルセットに左右される。PydanticAIは型駆動でわかりやすく、コード量が少なく済みやすい。一方でLangChainは多数の既存機能が用意され、複雑な要件に対応しやすい利点がある。

いずれにしても、今回紹介したレストランのウェイターエージェントのように、LLMを使いながら複数の外部ツールと連携してインタラクティブなサービスを構築する事例は今後さらに増えていくと考えられる。LangChainとPydanticAIのどちらを使うにせよ、ツールの定義や会話の管理、システムプロンプトの設計など、AIエージェントを形作る全体像を把握することが重要だ。

いいなと思ったら応援しよう!

D × MirAI
この記事を最後まで読んでくださり、ありがとうございます。少しでも役に立ったり、楽しんでいただけたなら、とても嬉しいです。 もしよろしければ、サポートを通じてご支援いただけると、新たなコンテンツの制作や専門家への取材、さらに深いリサーチ活動に充てることができます。