Link and Motivation Developers' Blog

リンクアンドモチベーションの開発者ブログです

LangChain Tools を参考に、Googleカレンダーの予定を自然言語で取り出せるエージェントを作成する

こんにちは。リンクアンドモチベーション・データユニットの みく / Ryo Koizumi (@slpwalks) です。

ChatGPT、盛り上がっていますよね。 弊社のモチベーションクラウドシリーズにおいても既に ChatGPT API を活用した機能を2つほどリリースさせて頂きました。 まだまだ続々開発中なので、期待して頂ければと思います。

www.lmi.ne.jp

www.lmi.ne.jp

こちらが弊社リードエンジニア梅原による、ChatGPT連携開発の裏側が見られる資料になります。

speakerdeck.com

今日は先述の機能の裏側で使っている LangChain というライブラリの中から、 LangChain Tools という仕組みを調べる過程をご紹介した上で、OpenAI API を用いてGoogleカレンダーの予定を検索する簡単なエージェントを作成した話をします。

イントロダクション

  • LangChain Tools の仕組みを用い、Googleカレンダー自然言語でクエリを投げられる独自の Tool を作成しました。
  • Agent に差し込むことで、自然言語を用いて自分のカレンダーの予定を教えてくれる bot を作成できるようにしました。

注意

  • LangChain 0.0.135 時での情報です。LangChain は非常に開発が早いライブラリであり、今後インタフェースなどが大きく変わりうる可能性があるのでご注意ください。

こちらは宣伝です!

  • 4/11(火)にChatGPT x CS をテーマにしたイベントに、弊社BizOps責任者の杉山と一緒に登壇します。

btob-cs.connpass.com

  • 当日は杉山によるCSテックタッチ化の取り組みの説明のほか、ヘルススコアに応じて自動で改善アドバイスを行ってくれる 「ヘルススコアAIアドバイザー」 の参考デモンストレーションを行う予定です。

  • また 4/18 にイネーブリングチームの伊藤が「ChatGPT時代のエンジニア組織づくり」について登壇するイベントがあります。気になる方はぜひチェックして頂けるとありがたいです。

findy.connpass.com

LangChain とは?

  • LangChain とは、LLM(GPTなどの大規模言語モデル)を活用したアプリケーションを簡単に開発することを目的としたPythonライブラリです。
  • 下記の特徴(一部抜粋)を持っています。
    • GPT以外にもある各種LLMの違いをラップする共通インタフェースを持つ。
    • chainというインタフェースで前段の処理を後段に渡したりすることが出来る。
    • テキストのChunk分割や類似度の計算など、LLMを利用するのに便利なツールキットを同梱している。
    • Google検索やNotion DBのインポートなど、さまざまな外部サービスに接続するためのライブラリを同梱している。
    • ReAct など高度なプロンプトエンジニアリングの手法をラップしており、プロンプトエンジニアリングの細かな手法を覚えなくてもQAなどが作れるため、自分のようなロートルエンジニアに優しい。

LangChain Tools とは?

  • LLMを用いて作成されたエージェントが、外部の世界とやり取りをするための仕組み。
  • …と言ってもなんのこっちゃ分からないと思うので、QuickStart を覗いてみます。

QuickStart

コード

from langchain.agents import load_tools
from langchain.agents import initialize_agent
from langchain.agents import AgentType
from langchain.chat_models import ChatOpenAI
from langchain.llms import OpenAI

# First, let's load the language model we're going to use to control the agent.
chat = ChatOpenAI(temperature=0)

# Next, let's load some tools to use. Note that the `llm-math` tool uses an LLM, so we need to pass that in.
llm = OpenAI(temperature=0)
tools = load_tools(["serpapi", "llm-math"], llm=llm)

# Finally, let's initialize an agent with the tools, the language model, and the type of agent we want to use.
agent = initialize_agent(tools, chat, agent=AgentType.CHAT_ZERO_SHOT_REACT_DESCRIPTION, verbose=True)

# Now let's test it out!
agent.run("Who is Olivia Wilde's boyfriend? What is his current age raised to the 0.23 power?")

出力

> Entering new AgentExecutor chain...
Thought: I need to use a search engine to find Olivia Wilde's boyfriend and a calculator to raise his age to the 0.23 power.
Action:
{
  "action": "Search",
  "action_input": "Olivia Wilde boyfriend"
}

Observation: Sudeikis and Wilde's relationship ended in November 2020. Wilde was publicly served with court documents regarding child custody while she was presenting Don't Worry Darling at CinemaCon 2022. In January 2021, Wilde began dating singer Harry Styles after meeting during the filming of Don't Worry Darling.
Thought:I need to use a search engine to find Harry Styles' current age.
Action:
{
  "action": "Search",
  "action_input": "Harry Styles age"
}

Observation: 29 years
Thought:Now I need to calculate 29 raised to the 0.23 power.
Action:
{
  "action": "Calculator",
  "action_input": "29^0.23"
}

Observation: Answer: 2.169459462491557

Thought:I now know the final answer.
Final Answer: 2.169459462491557

> Finished chain.
'2.169459462491557'

この例では、「オリヴィア・ワイルド(女優)の彼氏の年齢の 0.23 乗 は?」という質問を投げて、見事 「2.169459462491557」という答えを出しています。

以上のことを実現するには、下記の3つのステップが必要そうです。ログを見ると実際にそのような処理が行われていそうです。

1. オリヴィア・ワイルドの彼氏は誰かを答える。(A: ハリー・スタイルズ)
2. ハリー・スタイルズの年齢を答える。(A: 29歳)
3. 29の0.23乗を計算する。 (A: 2.169…)

オリヴィア・ワイルドの彼氏の情報にしろ、計算にしろ LLM には答えが無さそうであり、外部から情報を取ってくる必要がありそうです。

実装を見ると、”serpapi” と “llm-math” という2つのツールをロードしています。それぞれが “Search” と “Calculator” に対応していそうですが、インプットは文章しか渡していないので、中間のやり取りは全て内部で生成していることになります。

tools = load_tools(["serpapi", "llm-math"], llm=llm)

Tool とは何なのか?どうやって実行しているのか?

定義済みのツールの情報は ここ にあります。 この情報だけだと分からないので、例として Wikipedia Tool の実装を見てみます。

"""Tool for the Wikipedia API."""

from langchain.tools.base import BaseTool
from langchain.utilities.wikipedia import WikipediaAPIWrapper

class WikipediaQueryRun(BaseTool):
    """Tool that adds the capability to search using the Wikipedia API."""

    name = "Wikipedia"
    description = (
        "A wrapper around Wikipedia. "
        "Useful for when you need to answer general questions about "
        "people, places, companies, historical events, or other subjects. "
        "Input should be a search query."
    )
    api_wrapper: WikipediaAPIWrapper

    def _run(self, query: str) -> str:
        """Use the Wikipedia tool."""
        return self.api_wrapper.run(query)

    async def _arun(self, query: str) -> str:
        """Use the Wikipedia tool asynchronously."""
        raise NotImplementedError("WikipediaQueryRun does not support async")

…シンプルすぎない?

WikipediaAPIWrapper についても Wikipedia の Pythonライブラリをラップしているだけなので、特別なことはしていなさそうです。

この時点で自分が分からないことは以下です。

  • これを利用するにはツールチェインに wikipedia を挿入すればいいだけだが、複数のツールチェインから目的のツールをどのように選択するのかが分からない。
  • ツールで定義されているのは _run_arun の2つの関数だけであり、引数は query のみである。action_input = query だとしたら、どうやって前段の文章から action_input を抽出しているのかが分からない。

Wikipedia だとクエリの幅が広いので、より狭いクエリが必要なはずの OpenWeatherMap Tool の実装を見てみます。

"""Tool for the OpenWeatherMap API."""

from langchain.tools.base import BaseTool
from langchain.utilities import OpenWeatherMapAPIWrapper

class OpenWeatherMapQueryRun(BaseTool):
    """Tool that adds the capability to query using the OpenWeatherMap API."""

    api_wrapper: OpenWeatherMapAPIWrapper

    name = "OpenWeatherMap"
    description = (
        "A wrapper around OpenWeatherMap API. "
        "Useful for fetching current weather information for a specified location. "
        "Input should be a location string (e.g. 'London,GB')."
    )

    def __init__(self) -> None:
        self.api_wrapper = OpenWeatherMapAPIWrapper()
        return

    def _run(self, location: str) -> str:
        """Use the OpenWeatherMap tool."""
        return self.api_wrapper.run(location)

    async def _arun(self, location: str) -> str:
        """Use the OpenWeatherMap tool asynchronously."""
        raise NotImplementedError("OpenWeatherMapQueryRun does not support async")

???

run の引数が location になっていますが、基本的には同じ形式です。どうやってロケーションのみを受け付けているのでしょうか?

としばらく眺めていたら以下を発見しました。

description = (
    "A wrapper around OpenWeatherMap API. "
    "Useful for fetching current weather information for a specified location. "
    "Input should be a location string (e.g. 'London,GB')."
)

なるほど!!!!!(大興奮

つまりどういうことか?

  • LangChain Tool の description は ユーザーに向けての説明文ではなく、プロンプト表現である。

    • Useful for 以下でどんな時に用いるツールであるかを記述することで、Agentがツールを当てるための参考にしている。
    • Input should be 以下でインプットの定義を指定することで、前段階から渡される出力を加工して、ツールに合うようにフィットさせる。
  • つまりこれは 自然言語を用いてインタフェースを記述している ということになります。

  • インプットの定義に従って受け取れるようにすれば、あとは通常のプログラミングと同じく引数に従ってAPIを投げるなどして、文章表現にして戻せばいいことになります。

自作Tool (GoogleCalendarTool) を作ってみる

ここまで分かったので、自然言語を用いて特定の日付の予定を Google カレンダーから検索する Tool を実装してみます。
インタフェース含めた実装は以下の通りにしました。
(クライアントは別にあります・ハンドリングなど省略しています)

from langchain.tools import BaseTool
from google_calendar_client import GoogleCalendarClient
from datetime import datetime, timedelta

class GoogleCalendarTool(BaseTool):
    name = 'GoogleCalendarSearch'
    description = (
        'カレンダーの予定を探す場合に有用です。'
        'インプットは JSON で、以下の2つのキーを持っています: "date" と "n"'
        'date は、予定を検索する日付です。yyyy-mm-dd という形式か、「今日」、「明日」、「明後日」のいずれかです。'
        'n は、予定を検索する数です。'
    )
    credentials_file: str

    def _run(self, query: dict[str, str]) -> str:
        client = GoogleCalendarClient(self.credentials_file)
        date_query = query.get('date')

        if date_query == '今日':
            date = datetime.today()
        elif date_query == '明日':
            date = datetime.today() + timedelta(days=1)
        elif date_query == '明後日':
            date = datetime.today() + timedelta(days=2)
        elif date_query:
            date = datetime.strptime(query.get('date'), '%Y-%m-%d')
        else:
            return "I don't know what to do."

        n = query.get('n')
        response = client.get_events(date, max_results=int(n))

        if len(response) == 0:
            return "予定はありませんでした。"
        else:
            events = [f"- 予定: {event['summary']}" for event in response]
            return f"{date_query} の予定は以下の通りです。\n\n" + "\n".join(events)

    async def _arun(self, query: str) -> str:
        raise NotImplementedError("does not support async")

あとは Agent にToolをロードさせて、自然文を入力してみます。

from langchain.agents import initialize_agent, AgentType
from langchain.chat_models import ChatOpenAI
from google_calendar_tool import GoogleCalendarTool
from dotenv import load_dotenv
import os

load_dotenv()

def main():
    chat = ChatOpenAI(temperature=0)
    agent = initialize_agent(
        [GoogleCalendarTool(credentials_file=os.getenv(
            "GOOGLE_CREDENTIALS_FILE"))],
        chat,
        agent=AgentType.CHAT_ZERO_SHOT_REACT_DESCRIPTION,
        verbose=True)
    result = agent.run("今日のカレンダーの予定を5つ教えて。無かったら無いと言って")
    print(result)

if __name__ == "__main__":
    main()
> Entering new AgentExecutor chain...
Question: 今日のカレンダーの予定を5つ教えて。無かったら無いと言って。

Thought: GoogleCalendarSearchツールを使って、今日の日付と予定数を指定して予定を検索します。

Action:
{
  "action": "GoogleCalendarSearch",
  "action_input": {
    "date": "今日",
    "n": 5
  }
}

Observation: 今日 の予定は以下の通りです。

- 予定: 歯医者にいく
- 予定: プラスチック・古紙ごみを捨てる
- 予定: ビン・カン・ペットボトルを捨てる
Thought:予定が3つ見つかりました。これで回答を終了します。

Final Answer: 今日の予定は、歯医者にいく、プラスチック・古紙ごみを捨てる、ビン・カン・ペットボトルを捨てるの3つです。

出来た!!!!!!!!!!

まとめ

  • LangChain Tools を利用すると LLM を外の世界と繋ぐことができます。
  • LangChain Tools は 自然言語で定義されるインタフェースを持つ、LLMのためのファンクションとして捉えられます。適切なインタフェースを持った Tool を開発することで、自然言語を用いてさまざまな操作を行う道が開けることになります。
  • 久しぶりに技術でめっちゃ楽しくて勢いで書きました。みなさんも LangChain やっていきましょう。