🧪

LangChainとFunction callingで天気予報APIを呼び出す

2024/02/05に公開

はじめに

ChatGPTの肝になる機能の一つが Function Calling です。これはLLMで質問の内容を解析して、必要な関数を呼び出すというものです。 「必要な関数」 をどのように判定しているかといえば、それは関数の説明文から判断します。今までもキーワードに応じて何かの処理をするようなプログラムは良くあったと思いますが、LLMの強力な言語処理能力を使うことで 「呼び出すべき関数」「適切な引数」 を自動的に判定してくれます。SiriやAlexaみたいなツールが簡単に作れちゃいます。

今回はそのFunction callingをLangChain経由で使って天気予報APIをAITuberの「紅月れん」から呼べるようにしたので、その試行錯誤等を載せておきたいと思います。

なお、AITuber自体の作り方やLLMに関する全般的な話は下記の記事で取り扱っていますので、良ければ読んでみてください。
https://zenn.dev/koduki/articles/f0d8739ca87ebe

TL;DR

  • Functional Calling/Toolsでテキストに応じた処理の実行が革命的に簡単に
  • LCELを使うとよりシンプルで柔軟な記述が可能に
  • 無限ループを避けるにはAgentよりRouter
  • APIへの入力値はLLMで揺らぎを調整

Function Callingとは?

冒頭でも書いた通り、Function CallingはLLMの機能を使って、呼び出すべき関数と引数を特定する機能です。
例えばSiriのようなアプリを作り今日の天気を回答してくれるAIを作るとします。LLMは色々な知識を持っていますが、学習時点のものに限定されるため現在の情報には原理的に回答できません。そのため天気予報のように一般的な知識ではなく、リアルタイムな情報は外部の天気予報APIなどを呼び出す方法が考えられます。まず、下の図のように 「明日の福岡の天気は?」 という問いから 「天気予報APIの呼び出し」 「引数が福岡と明日」 という情報を抽出して、天気予報APIに渡し、その結果を再度LLMに渡して自然な日本語として返答させます。

従来であれば、この赤枠の部分の作りこみが自然言語解析をして言葉の揺らぎや活用を上手く調整しながら、目的の処理を推論する必要があったのですが、OpenAIのAPIではこの機能がFunctional Callingという形でbuild-inされています。本当にメチャクチャ便利ですね! リリースの時系列は前後しますがPluginやRAGによる検索も内部的にはFunctional Callingが基盤になってると考えられます。

仕組みは単純で例えば以下のような関数の情報を定義したJSONをOpenAIのAPIに渡し、通常通りプロンプトを入力します。

{
  "model": "gpt-3.5-turbo-0613",
  "messages": [
    {"role": "user", "content": "What is the weather like in Boston?"}
  ],
  "functions": [
    {
      "name": "get_current_weather",
      "description": "Get the current weather in a given location",
      "parameters": {
        "type": "object",
        "properties": {
          "location": {
            "type": "string",
            "description": "The city and state, e.g. San Francisco, CA"
          },
          "unit": {
            "type": "string",
            "enum": ["celsius", "fahrenheit"]
          }
        },
        "required": ["location"]
      }
    }
  ]
}

ref: https://medium.com/@lucgagan/understanding-chatgpt-functions-and-how-to-use-them-6643a7d3c01a

「今日のボストンの天気は?」というプロンプトを与えると以下のような戻り値で返ってきます。

{
  "id": "chatcmpl-123",
  ...
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": null,
      "function_call": {
        "name": "get_current_weather",
        "arguments": "{ \"location\": \"Boston, MA\"}"
      }
    },
    "finish_reason": "function_call"
  }]
}

天気の質問をしているので渡したFunctionsの「Get the current weather in a given location」という説明にヒットしたためget_current_weatherを返すべき、と特定され通常の応答ではなくfunction_callという形でJSON形式のレスポンスが返ります。その中にはfunction名と引数が含まれています。

OpenAIのAPIは裏側で自動的にこの関数を実行したりするのではなく、あくまで「回答するべき関数があるか?」をLLMでテキスト解析しているだけで、実際の処理自体はAPIを呼んでいるプログラム自身で行います。すなわちこれは以下のようなプロンプトエンジニアリングと等価です。

{input}が{functions}の説明に該当するものがあればfunctionの名前を返答しなさい。
同様に該当したfunctionのargumentsにマッチする値を{input}から取得してください。
返答はJSON形式。

みたいな感じですね。これを正確に応答出来るようにチューニングをしたのが、function callingだと思います。

このあたりの概念的な解説は以下の記事が分かりやすかったです。
https://zenn.dev/kazuwombat/articles/1f39f003298028
また、Functional CallingはToolsという形で新バージョンが出るようなので、今後はこちらを使う事になるでしょう。
https://platform.openai.com/docs/assistants/tools

LangChainとAgent

Functional Callingのような処理は、まさしくAIにおけるオーケストレーションの典型的な処理です。なので、そのフレームワークであるLangChainを使う事でシンプルかつ汎用的に書くことが可能です。

LangChainではFunctional Callingのように外部の機能を呼び出す場合は Agent を活用します。Agentの実行方法は色々ありますが裏側でFunctional Calling/toolsを使うOpenAI tools
を今回は利用します。

LangChainのAgentはOpenAIのFunctional Callingとは異なり、実際に処理を実行する部分も含まれています。この実行するための具体的な処理をtoolsという形で実装し、Agentはそれを自律的に判定して呼び出します。作り方は色々ありますが、最も簡単なのは@tool decoratorを使って以下のように定義することです。

@tool
def get_current_weather(location: str) -> str:
    """The city and state, e.g. San Francisco, CA."""
    return "{'weather':'rain'}"

基本的に普通のPythonの関数として記述しAPI仕様もきちんと書いておく、というだけなので非常に簡単ですね。これを以下のような形で実行します。toolsの内容は自動的にOpenAI形式に変換されてagentに渡され、実行する実態としてAgentExecutorにも渡されます。またプロンプト内にagent_scratchpadという形でtoolの実行結果の値を渡します。

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are smart agent."),
    ("user", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])
tools = [get_current_weather] 
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
agent = create_openai_functions_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

agent_executor.invoke({'input':'今日の東京の天気は?'})

agent_executorは自律的に返答内容を判定するため、戻り値がFunctional Callingの場合は自動的に対応する名前を渡されたtoolsから探して実行し、最終的に元のinputと組み合わせてLLMで処理して返答します。つまり、これを実行した場合、「今日の東京の天気は雨です」 みたいな回答が返ってきます。

AgentExecutorと無限ループ問題

さて、これでめでたしめでたしとは行かなかったのが、今回の記事の本題です。このやり方は多くのチュートリアル記事に書いてあるのですが、agent_executor何度もループしてしまって同じ質問を内部的に繰り返しお金も時間も消費するという悲しい状態を作りました。AgentExecutorは 自律的に回答が出るまで処理を繰り返し適切な状態になったら処理を返す、というのが目的です。そのためおそらくですが、回答自体に例えば今回であれば天気の情報が混じっていた場合にもう一度天気の情報を問い合わせる無限ループが発生したと思われます。

AIの文脈でエージェントというのは少し特別な意味を持つ用語です。LangChainにおけるAgentもそれを意識してるはずで自律的に回答を求めるまで処理を繰り返すのは非常に重要な機能です。

ref: https://zenn.dev/umi_mori/books/prompt-engineer/viewer/langchain_agents

AgentExecutorは便利ですが、ちょっとブラックボックスが多く制御が難しそう、というのが所見です。というか少なくとも私が今回求めてるのは天気情報を踏まえた回答というだけなので、かならず 「関数の実行 -> 関数の結果を含めたLLM処理」 の2段階で終わる自明の処理です。自律的な処理は不要明確に制御したい というニーズはAIの理想とは違うかもしれませんが、現実的には良くありそうなケースですよね? LangChain側もその辺は把握しているらしく、LangChainでは認知アーキテクチャという形でLLMをりようしたアプリケーションの段階を作っているようです。

パターン 説明 詳細
Code LLMを利用しない コードのみで処理を行う 従来のソフトウェア開発
LLM Call アプリの出力のみを決定する単一のLLMコール アプリはLLMに処理を依頼し、結果を受け取って出力 簡単なチャットボット
Chain アプリの出力のみを決定する複数のLLMコール 複数のLLMコールを順番に実行し、結果を組み合わせて出力 文章生成ツール
Router LLMをルーターとして使用 LLMが状況に基づいて、次に実行するアクション (Tool、Retrieval、Prompt) を選択 対話型システム
State Machine LLMを使用してある種のループでステップ間をルーティング コードによって許可された遷移先のみへ移動 ゲームAI
Agent 利用可能なステップのシーケンスを決定もLLMが行う LLMが自律的に行動し、状況に応じて最適なステップを選択 自動運転システム


ref: OpenAI と LangChain の認知アーキテクチャ

AgentはすべてをAIに判断させる非常に野心的な試みですが、今回私に必要なのはあくまでRouterです。という事はAgent向けの機能であるAgentExecutorは使わない方が上手くいく可能性が高そうです。という分けで、LCEL(LangChain Expression Language)を利用して、Routerを組み立ててみます。

LCELとRouter

LangChainの良いところは、カスタムチェインを簡単に作れて、それを組み合わせる事で、パイプ&フィルタアーキテクチャでシンプルにLLMアプリケーションを開発出来る事です。ただ、正直最初にカスタムチェインを作った時は、思ったよりも抽象度が低いし、構文的にもメソッドチェインではなく、コールバックチェインになるので少しいまいちに感じていました。ただ調べてみると LCEL という新しい抽象が存在し、まさにパイプを使ってUNIXのようにプログラミングが出来ます。

prompt = ChatPromptTemplate.from_template("tell me a short joke about {topic}")
model = ChatOpenAI(model="gpt-4")
output_parser = StrOutputParser()

chain = prompt | model | output_parser

chain.invoke({"topic": "ice cream"})

chainの書き方が非常にシンプルになりましたね! また、チェインの作成も関数を渡すことで簡単に実現できます。メチャクチャ便利!

def output(response):
    return response.return_values['output']

chain = prompt | model | output

ただ、シンプルすぎて正直I/Fの詳細が良く分からないし、公式マニュアルも少し分かりづらいです。なので、おすすめは以下の記事です。
https://secon.dev/entry/2024/01/11/100000/

基礎的なところから解説があって非常に分かりやすかったです。この記事の内容を一通り読むと、基本的な振る舞いが分かった感じになります。ありがたいですね。

上記の記事を踏まえた上で会話履歴+Functional Callingを組み合わせたRouterをLCELで実装すると、以下のようになります。全文はこちら に置いています。

def call_func(log):
    if isinstance(log, AgentFinish):
        return [(log, [])]
    else:
        tool = next(x for x in tools if x.name == log.tool)
        observation = tool.run(log.tool_input)
        return [(log, observation)]

def store_memory(response):
    input = {"input":response["input"]}
    output = {"output": response["return_values"].return_values['output']}
    memory.save_context(input, output)
    return output

memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
router = (
    RunnablePassthrough().assign(
        chat_history=RunnableLambda(memory.load_memory_variables) | itemgetter("chat_history"),
        scratchpad=prompt_for_tools | llm_with_tools | OpenAIFunctionsAgentOutputParser() | call_func | format_to_openai_functions
    )| RunnablePassthrough().assign(
        return_values=prompt_for_chat | llm_for_chat | OpenAIFunctionsAgentOutputParser(),
    )| store_memory
)

router.get_graph().print_ascii()でグラフにすると以下のようになります。大きく分けると3段階に分かれていて「llm_with_toolの実行及びFunctional Callingの実行」「llm_for_chatの実行」「最終的な会話の記録」です。RunnablePassthrough().assignは指定したパラメータ以外は自動的に後ろに連携するので、例えばinputなんかは最後尾のstore_memoryでも取得できるようにしています。

また、ちょっとした工夫としてFunctional Calling向けのプロンプトはコスパとレスポンスの良いgpt3.5を利用し、不要なシステムプロンプトも削っています。

prompt_for_chat = ChatPromptTemplate.from_messages([
    ("system", prompt_system),
    ("user", "{input}"),
    MessagesPlaceholder(variable_name="chat_history"),
    MessagesPlaceholder(variable_name="scratchpad"),
]).partial(format_instructions=parser.get_format_instructions())

prompt_for_tools = ChatPromptTemplate.from_messages([
    ("system", "You are agentai"),
    ("user", "{input}"),
])

tools = [weather_tool.weather_api, short_talk_tool.talk]
llm_with_tools = ChatOpenAI(temperature=0, model='gpt-3.5-turbo').bind(functions=[format_tool_to_openai_function(t) for t in tools])
llm_for_chat   = ChatOpenAI(temperature=0, model='gpt-4-0613')

toolsもagentではありませんがformat_tool_to_openai_functionを使う事でOpenAI向けのフォーマットに変換できるので従来と同じ感覚で利用可能です。

これでAgentExecutorに頼らずに「関数の実行」「実行結果を踏まえた応答」「会話の記録」の3つが実現でき、無限ループ等が起こらない明示的な処理として実装することが出来ました。

天気予報API toolの実装

さて、最後に表題にもある天気予報APIをtoolとして実装します。今回は無料で使える天気予報APIとしてOpen-MeteorのWeather Forecast APIを使いました。
https://open-meteo.com/en/docs

ただ、このAPIはロケーションを緯度経度で指定する必要があります。「東京都」みたいな地名じゃダメってことですね。また、日付もYYYY-MM-DDで渡す必要があるのですが、実際の入力は必ずしもそのフォーマットではなく、2月6日と来るかもしれませんし今日とか今週末とか揺らぎが非常に多そうな部分です。つまり、Functional Callingの引数をAPIへの入力値としてそのまま使えません。という分けでここでもLLMを使い、ユーザの入力をAPI向けの値に変換します。認知アーキテクチャをベースにすればLLM Callに相当する使い方ですね。

全体のコードはweather_tool.pyとなります。ポイントになるのは以下の部分。

prompt = ChatPromptTemplate.from_messages([
    ("system", "今日は{today}です。以下の問に答えなさい。返答のフォーマットは'YYYY-MM-DD'です。返答は'YYYY-MM-DD'のみを返してください。それ以外の値を返すと罰せられます。"),
    ("human", "「{date}」は何日ですか?"),
])
chain = LLMChain(llm=llm, prompt=prompt, verbose=True)
r = chain.invoke({"today": date.today().strftime('%Y-%m-%d'), "date": when})
target_date  = r['text']

上記のようにシステムプロントで「今日」という基準点を作りYYYY-MM-DD形式で返すことを支持しています。今日の日付はPythonから取得したものを与え、dateはFunctional Calling由来のものを与えます。これによって渡される値が2/4のような明確な日付でも一か月後のような自然言語でも明確な日付に変換してtarget_dateに代入されます。

以下の緯度経度を求めるLLMも同じです。こちらは論理的な計算ではなく純粋な知識問題なのでLLMで回答出来るかやや不安だったのですが、適当に試した限りでは適切な値が返っていたようなので良しとします。

prompt = ChatPromptTemplate.from_messages([
    ("system", """
    あなたは地理の専門家です。指定された地域の緯度と経度を
    latitude:xx.xxx, longitude:xxx.xxx
    の形式で回答してください。
    """),
    ("human", "{location}"),
])
chain = LLMChain(llm=llm, prompt=prompt, verbose=True)
r = chain.invoke({"location": location})
pos = dict((k, v) for k, v in (item.split(':') for item in r['text'].replace(" ", "").split(',')))

最終的には上記で作成したパラメータを元にURLを組み立てAPIを実行します。

url = f"https://api.open-meteo.com/v1/forecast?latitude={pos['latitude']}&longitude={pos['longitude']}&hourly=temperature_2m,weather_code&timezone=Asia%2FTokyo&start_date={target_date}&end_date={target_date}"

LLMを使わなければ、この天気予報APIを実行するのは非常に面倒ですので、こういう使い方をする良い練習になりました。まさしく色んなところに使われていきますね。

まとめ

今回はLLMの強力な機能の一つであるFunctional CallingをLangChain側からどのように使うかの試行錯誤の記録を整理してまとめてみました。ポイントとしては以下になります。

  • Functional Calling/Toolsでテキストに応じた処理の実行が革命的に簡単に
  • LCELを使うとよりシンプルで柔軟な記述が可能に
  • 無限ループを避けるにはAgentよりRouter
  • APIへの入力値はLLMで揺らぎを調整

Agentだけではなく、認知アーキテクチャのRouterという考え方やLCELをはじめとして思ったより多くの勉強になりました。特にLCELのみを使ったFunctional Callingの一連の実装はLCEL自体がまだ比較的最近のリリースという事もあってネットにも情報が少なかったですし。
また、天気予報APIの実行などの部分でもLLMを活用する箇所があり、とても面白かったですね。次はRAGあたりを試しに実装してみたいと思います。

それではHappy Hacking!

Discussion