🐢

LangchainとPlaywright MCPでエージェントを実装してみた

に公開

めあて

  • Playwright MCPでブラウザ操作を自動実行するエージェントを実装してみる
  • langchainとlanggraphを活用
  • ReAct型エージェントに要求をインプットし、MCP経由でPlaywrightを操作させる

方法① create_agent

langchainのcreate_agent関数を呼び出すだけでStateGraphを構築する方法です。

# pip install langchain langchain-mcp-adapters langchain-ollama ollama langchain-google-genai langgraph langfuse
import asyncio
import os
from langchain.agents import create_agent
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.tools import load_mcp_tools
from langchain_ollama import ChatOllama
from langchain_google_genai import ChatGoogleGenerativeAI
from langfuse import get_client
from langfuse.langchain import CallbackHandler

# langfuse向け設定
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = get_client()
langfuse_handler = CallbackHandler()

# 生成AIモデル(エージェント推論用モデル)
# Google AI StudioのGemini と Ollamaのgpt-oss を使って検証
os.environ["GOOGLE_API_KEY"] = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash")
# llm = ChatOllama(model="gpt-oss")

# エージェント実装は、langchain.agentsのcreate_agentを使用します


async def main():

    # MCPクライアントの生成
    client = MultiServerMCPClient(
        {
            "playwright": {
                "command": "npx",
                "args": [
                    "@playwright/mcp@latest",
                ],
                "transport": "stdio",
            }
        }
    )

    # エージェントのメイン処理ではMCPクライアントのsessionを維持します
    # そうしないと、Playwrightで「ブラウザでの一連の操作」を扱えません
    async with client.session("playwright") as session:

        # MCPクライアントのsessionからMCPで定義されているtoolを取得します
        # langchainではMCPサーバをToolと同様に利用することができます
        tools = await load_mcp_tools(session)

        # create_agentをつかってSteteGraphを構築します
        agent = create_agent(llm, tools, debug=True)

        # 実行
        result = await agent.ainvoke(
            {
                "messages": [
                    {
                        "role": "user",
                        "content": "Access http://localhost:8501/ . And set browser size to Full HD. And submit chat twice. And save screenshot. Output he path for the screenshot file.",
                    }
                ]
            },
            config={"callbacks": [langfuse_handler]},
        )

        # 結果をプリント
        for m in result["messages"]:
            m.pretty_print()
            print("--------------------------------------------")


if __name__ == "__main__":
    asyncio.run(main())

実行して、langfuseのトレースから動作を確認すると、下記のようにllmとtoolを交互に繰り返して、一連の操作が実行されているのがわかります。llm(エージェント推論用モデル)の役割は、「次のツール呼び出しの内容」または「エージェント実行を終了」を決定することです。

  • llm (Access http://localhost:8501/ . And set browser size to Full HD. And submit chat twice. And save screenshot. Output he path for the screenshot file.)
  • llm
    • tool (browser_snapshot - 画面の状態を把握するためスナップショットを取得)
  • llm
    • tool (browser_type - チャットに入力)
  • llm
    • tool (browser_type - チャットに入力 2回目)
  • llm
    • tool (browser_take_screenshot - スクリーンショットの保存)
  • llm (エージェントループを終了)

方法② langgraphでStateGraphを構築

# pip install langchain langchain-mcp-adapters langchain-ollama ollama langchain-google-genai langgraph langfuse
import asyncio
import os
import operator
from typing import TypedDict, Annotated
from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.tools import load_mcp_tools
from langchain_ollama import ChatOllama
from langchain_google_genai import ChatGoogleGenerativeAI
from langfuse import get_client
from langfuse.langchain import CallbackHandler
from langgraph.graph import StateGraph, START, END

# langfuse向け設定
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = get_client()
langfuse_handler = CallbackHandler()

# 生成AIモデル(エージェント推論用モデル)
# Google AI StudioのGemini と Ollamaのgpt-oss を使って検証
os.environ["GOOGLE_API_KEY"] = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash")
# llm = ChatOllama(model="gpt-oss")

# langgraphによるエージェント実装は、概ね公式のquickstartの内容を流用しています
# https://docs.langchain.com/oss/python/langgraph/quickstart


# langgraphのノード間で共有されるstateの定義です
# 重要なのは各種Messageを格納するリストです(各種=System, Human, AI, Tool)
class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]


async def main():

    # MCPクライアントの生成
    client = MultiServerMCPClient(
        {
            "playwright": {
                "command": "npx",
                "args": [
                    "@playwright/mcp@latest",
                ],
                "transport": "stdio",
            }
        }
    )

    # エージェントのメイン処理ではMCPクライアントのsessionを維持します
    # そうしないと、Playwrightで「ブラウザでの一連の操作」を扱えません
    async with client.session("playwright") as session:

        # MCPクライアントのsessionからMCPで定義されているtoolを取得し、
        # チャットモデルにbindします
        # langchainではMCPサーバをToolと同様に利用することができます
        tools = await load_mcp_tools(session)
        model_with_tools = llm.bind_tools(tools)

        # エージェントの推論用ノード
        # チャットモデルの推論により、次に実行すべきTool、または、
        # エージェントループを終了するべきか、を判断します
        async def llm_call(state: dict):
            """LLM decides whether to call a tool or not"""

            return {
                "messages": [
                    await model_with_tools.ainvoke(
                        [
                            SystemMessage(
                                content="You are a helpful assistant tasked with performing e2e test on input."
                            )
                        ]
                        + state["messages"]
                    )
                ]
            }

        # Tool実行用ノード
        async def tool_node(state: dict):
            """Performs the tool call"""

            result = []
            for tool_call in state["messages"][-1].tool_calls:
                tool = next(
                    (item for item in tools if item.name == tool_call["name"]), None
                )
                observation = await tool.ainvoke(tool_call["args"])
                result.append(
                    ToolMessage(content=observation, tool_call_id=tool_call["id"])
                )
            return {"messages": result}

        # graphの定義
        workflow = StateGraph(AgentState)

        workflow.add_node("llm_call", llm_call)
        workflow.add_node("tool_node", tool_node)

        workflow.add_edge(START, "llm_call")
        workflow.add_conditional_edges(
            "llm_call",
            lambda state: (
                "end" if not state["messages"][-1].tool_calls else "tool_node"
            ),
            {"end": END, "tool_node": "tool_node"},
        )
        workflow.add_edge("tool_node", "llm_call")

        app = workflow.compile()

        # graph (エージェントループ) の実行
        result = await app.ainvoke(
            {
                "messages": [
                    HumanMessage(
                        content="Access http://localhost:8501/ . And set browser size to Full HD. And submit chat twice. Finally save screenshot. Output the path for the screenshot file."
                    )
                ]
            },
            config={"callbacks": [langfuse_handler]},
        )

        # 結果をプリント
        for m in result["messages"]:
            m.pretty_print()
            print("--------------------------------------------")


if __name__ == "__main__":
    asyncio.run(main())

実行時のループのまわり方は①と違い、1回目のllmでスナップショット取得までの3つのTool実行が一気に行われましたが、それ以外のシーケンスは同じでした。

推論用モデルの能力

上記の動作確認ではgemini-2.5-flashを使用して、一連の操作はうまく動きました。

一方、生成AIのモデルをOllama上のgpt-ossに変更してみたところ、Tool実行がエラーとなり、処理を完遂できませんでいた。違いは生成AIモデルのみですので、gpt-ossが判断した「ツール呼び出しの内容」の精度がgemini-2.5-flashに比べて低い、と言えると思います。

おまけ:langfuseについて

langchainのCallbackHandlerとして適用することで、生成AIとのやり取りや、Tool実行の様子などを記録して確認することができます。

https://langfuse.com/self-hosting/deployment/docker-compose

の通り、docker composeでセルフホスト実行し、http://localhost:3000でコンソールにアクセスできます。最初は、適当にアカウントを作成して、プロジェクトを作成し、langchainのコードで使用するSECRET_KEYとPUBLIC_KEYを発行します。

おまけ:方法③ cagentでの実装

Dockerのオープンソース プロジェクトcagentを使用してエージェントを動かしてみる。

  • releasesのページからダウンロード
  • /usr/local/binなどにcagentとして配置
  • agent.yamlファイルを作成しcagent run agent.yaml
  • これだけ
#!/usr/bin/env cagent run
version: 2

models:
  local-gpt:
    provider: dmr
    model: ai/gpt-oss:20B-UD-Q4_K_XL
agents:
  root:
    model: local-gpt
    description: E2E test automation agent
    instruction: |
      You are a professional e2e tester.
      Operate playwright according to user input (test senario).
    toolsets:
      - type: mcp
        command: npx
        args:
          - "@playwright/mcp@latest"

Discussion