🔗️

LangChain v0.2の使い方

2024/07/23に公開

キービジュアル

はじめに

ChatGPT などの AI API を活用し、AI エージェントなどを構築しやすくしてくれるフレームワークである LangChain 。これの最新のバージョンである v0.2 の使い方について、内部の実装なども交えながら解説していきたいと思います。

内部構造も把握しておくことで、どういうときにどこを見たらいいかなども分かるようになります。それ以外でも、LangChain Expression Language(LCEL)と言われる記法があり、それがどうして実現しているのかについても書こうと思います。

なお、今回扱うのは Python 版の解説となります。

https://blog.langchain.dev/langchain-v02-leap-to-stability/

https://python.langchain.com/v0.2/docs/versions/v0_2/

LangChain v0.2 について

LangChain は v0.1 から大幅に改修が入り、利用の仕方なども大きく変わっています。しかし、 v0.1 との互換性を極力維持した形で開発が進んでいるためすでに v0.1 で開発したことがある場合はなにもせずに動くと思います。

公式の記事から引用(翻訳)すると以下の要素が盛り込まれているようです。

特に注目すべきは、 langchainlangchain-community が完全に分離されたことでしょう。これによって利用するパッケージが変わります。 v0.1 で開発したことがある人はこのあたりを重点的にチェックするとよいでしょう。

インストール

インストールは以下のように pip を使います。

$ pip install langchain
$ pip install langchain-community

今回は OpenAI の ChatGPT を利用するため、以下も合わせてインストールします。

$ pip install -qU langchain-openai

セットアップ

ChatGPT など、外部サービスを利用する場合は API Key を設定する準備・設定する必要があります。今回は OpenAI の API Key を準備・設定します。

環境変数に設定する

以下のように os モジュールを利用してAPI Keyを設定します。

environment-setting
import os
os.environ['OPENAI_API_KEY'] = "<YOUR_API_KEY>"

しかし、毎回こうして設定するのは手間ですし、GitHub にアップするなどする場合に、API Key がコミットされてしまうのも問題と言えます。そのため、個人的には以下の dotenv などを利用してキーを別ファイルで管理し、コミットしない形にするのがオススメです。

dotenvで設定する

毎回上記のように設定するのは非効率ですし、色々なところにAPI Keyを保持することになってしまうので、 dotenv という機能を利用すると設定ファイルにまとめて保存しておくことができます。( API Key だけでなく、その他のトークンなども保存することができます)

まずはパッケージをインストールします。

$ pip install python-dotenv

そして .env ファイルを準備し、 KEY=VALUE の形で値を保存しておきます。

e.g.
HOGE_KEY=xg-hogehoge-fugafuga-foo-bar
FUGA_KEY=bot-fugafuga-piyopiyo

.env ファイルには複数の値を保存できるので改行区切りで列挙します。

そして以下のようにロード処理を実行します。

loadi-env
import dotenv
dotenv.load_dotenv()

すると自動的に環境変数に値がロードされます。

以下のようにチェックすると値が出力されるのが分かります。

check-env
import os
print(os.environ.get("HOGE_KEY")) # => xg-hogehoge-fugafuga-foo-bar

ChatGPTとやり取りしてみる

まずは基本的な、ChatGPT とのやり取りを実装する方法を見ていきましょう。

フローとしては、

  1. ChatGPT のモデルを作成する
  2. メッセージを準備する
  3. 実行

というステップです。

それぞれ見ていきましょう。

モデルを作成する

OpenAI の API を利用するためには API Key が必要になるため、そのためのセットアップを行ってからモデルを作成します。

setup
# API Key を環境変数に読み込む
import dotenv
dotenv.load_dotenv()

# モデルの作成
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o")

メッセージの準備

LLM に送信するメッセージを準備します。ここでは、いわゆるシステムプロンプトと呼ばれる事前の設定と、ユーザの発言を準備します。

prepare-messages
from langchain_core.messages import HumanMessage, SystemMessage

messages = [
    SystemMessage(content="次の発言を日本語から英語に翻訳してください。"),
    HumanMessage(content="こんにちは!")
]

実行する

準備したモデルとメッセージを使って実行してみます。実行するには invoke を呼び出すだけです。

invoke
result = model.invoke(messages)
print(result)

実行すると以下のような結果になりました。ChatGPT からのレスポンスは JSON 形式になっているのが分かります。

response
content='Hello!' response_metadata={'token_usage': {'completion_tokens': 2, 'prompt_tokens': 28, 'total_tokens': 30}, 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_d576307f90', 'finish_reason': 'stop', 'logprobs': None} id='run-0915b48e-bff0-4546-adf2-7a0a83c94de1-0' usage_metadata={'input_tokens': 28, 'output_tokens': 2, 'total_tokens': 30}

パースして利用しやすくする

前述の通り、レスポンスは JSON で返ってきます。このままだとやや扱いづらいですね。シンプルにメッセージだけを取り出したい場合はすでに準備されている機能を利用して簡単に取り出すことができます。

パースするには StrOutputparser モジュールを利用します。

parse
from langchain_core.output_parsers import StrOutputParser

parser = StrOutputParser()
parsed_result = parser.invoke(result)

print(parsed_result)

実行すると、JSON から必要な部分だけが取り出されているのが分かります。(JSON で言うと content 部分が抜き出されている)

result
Hello!

ここまでのまとめ

API Keyの準備とモデルの作成をしたら、あとは AI に処理してもらいたいメッセージを準備して invoke メソッドで呼び出すだけで結果を得ることができます。また、 invoke メソッドはチェインしていく前提になっているため、様々な部分で見かけることになるでしょう。結果的に、色々なモジュールの invoke の結果をつなぎ合わせていくと望みの結果を得ることができます。

以下に、ここまでのコード全部を掲載しておきます。

コード全文
full-code
import dotenv
dotenv.load_dotenv()

from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-4o")

from langchain_core.messages import HumanMessage, SystemMessage

messages = [
    SystemMessage(content="次の発言を日本語から英語に翻訳してください。"),
    HumanMessage(content="こんにちは!")
]

result = model.invoke(messages)

from langchain_core.output_parsers import StrOutputParser

parser = StrOutputParser()
parsed_result = parser.invoke(result)

print(parsed_result)

LCEL のチェイン機能を利用して構築する

前段では LangChain の簡単な使い方について解説しました。ここからは、 v0.2 の強みである LCEL を使って構築していきます。

まず、先程の処理を LCEL を使って再構築してみると以下のようになります。( modelparser は前段で作成したものと同一のものです)

use-LCEL
chain = (model | parser)

result = chain.invoke(messages)
print(result)

呼び出し( invoke )が一度だけになり、とてもシンプルな見た目になりました。処理の連結には | を利用しているのが特徴的です。イメージ的にはシェルのパイプでしょうか。

ここでは modelparser のふたつだけですが、例えば以下のように複数の処理を | を使って簡単に連結することができます。

other-LCEL
# LLM実行前にプロンプトテンプレートを適用してから実行するイメージ
chain = (prompt | model | parser)

チェインの使い方がなんとなくイメージできたでしょうか。

上で「 invoke の呼び出しがチェインしていく」と話していたのはまさにこの部分です。すべての処理が invoke 可能になっているため、それを連結することが容易になっている、というわけなんですね。

チェインの仕組み

ここはちょっと脱線して、なぜ | でこうしたチェインが実現できるかについて書きたいと思います。

Python に用意されたオーバーロード機能を利用している

Python には、色々な演算子をオーバーロードする機能が備わっています。今回のチェインを実現するにあたって利用されているのは or 演算のオーバーロードです。思い出してください。Python など通常のプログラミング言語では | はビットの or 演算をしていますよね。これをオーバーロードしているわけです。

オーバーロードするには __or__ メソッドを実装する必要があります。また、LCEL の秀逸な点として、チェイン可能なクラス以外もチェインに組み込めるように設計されています。例えば、適切に設定された辞書オブジェクト( dict )なども組み込むことができます。これは __ror__ メソッドをオーバーロードすることで実現しています。

__or__ は左側に置かれたオブジェクトがチェイン可能クラスの場合に呼びされます。一方、 __ror__ は右側に置かれた場合に呼び出されます。(おそらく rightr なのだと思います)

どういうことかというと、疑似コードで示すと、

right-side-dict
any_dict = { """... 必要な設定 ...""" }
chain = (model | any_dict )

model が左側に置かれているのが分かります。この場合は model インスタンスのクラスの __or__ が呼び出されます。

left-side-dict
any_dict = { """... 必要な設定 ...""" }
chain = (any_dict | model)

一方、 model を右側に置いた場合かつ any_dict が通常の dict などの「本来は実行可能ではないオブジェクト」が左側に来ると __ror__ が呼び出される、というわけです。

ちなみに、実際に LangChain で実装されているコードの一部を抜粋すると、以下のようなオーバーロードになっていました。

definition
def __ror__(
    self,
    other: Union[
        Runnable[Other, Any],
        Callable[[Other], Any],
        Callable[[Iterator[Other]], Iterator[Any]],
        Mapping[str, Union[Runnable[Other, Any], Callable[[Other], Any], Any]],
    ],
) -> RunnableSerializable[Other, Output]:
    """Compose this runnable with another object to create a RunnableSequence."""
    return RunnableSequence(coerce_to_runnable(other), self)

これを見ると、左側に置かれるオブジェクトは複数の型を許容する形になっているのが分かりますね。( Union によって定義されている)

プロンプトテンプレートを利用する

ChatGPT とのシンプルなやり取りは説明した通りです。しかし、LangChain を使うようなアプリケーションの場合、シンプルなやり取りで済むことはないでしょう。それだけであればそもそも OpenAI のモジュールを利用して API とやり取りしたほうが依存が少なくてよいです。

ここではプロンプトテンプレートを利用して、ユーザの入力などに応じてプロンプトを生成して、より柔軟に ChatGPT とやり取りできるようにしていきます。

まずは簡単に利用するコードを見てみましょう。

prepare-prompt
import dotenv
dotenv.load_dotenv()

from langchain_core.prompts import ChatPromptTemplate

system_template = "次の内容を {language} に翻訳してください。"

prompt_template = ChatPromptTemplate.from_messages([
    ("system", system_template),
    ("user", "{text}")
])

result = prompt_template.invoke({
    "language": "English",
    "text": "LangChain は便利です。",
})

print("Prompt:")
print(result)

# Message に変換もできる
print("Messages:")
print(result.to_messages())

実行すると以下の出力が得られます。

result
Prompt:
messages=[SystemMessage(content='次の内容を English に翻訳してください。'), HumanMessage(content='LangChain は便利です。')]

Messages:
[SystemMessage(content='次の内容を English に翻訳してください。'), HumanMessage(content='LangChain は便利です。')]

to_messages() によってただの配列に変換されているのが分かります。

テンプレートもチェインに組み込める

実はこのテンプレートの処理もチェインに組み込むことができます。チェイン版は以下のようになります。

combine-chain
chain = prompt_template | model | parser # model などは前段で作成したものと同じ
llm_result = chain.invoke({
    "language": "English",
    "text": "LangChain は便利です。",
})

print(llm_result) # => LangChain is convenient.

チェインの最初の入力が、テンプレートへの入力となっている点に注目です。チェインの最初の入力がなにになるかはしっかりと把握しておく必要があります。

テンプレートに会話履歴を挿入する方法

さて、プロンプトテンプレートを利用して ChatGPT とやり取りする方法を見てきました。しかし、LLM は本来会話履歴を保持しません。いわば LLM は記憶を持っていないようなものです。そのためチャットボットを作った場合、前に話していた内容を覚えていないので人間が会話するとチグハグした内容になってしまいます。例えば、最初に名前を名乗っていたのに、途中で名前を聞いても「どちら様ですか?」となってしまうわけです。

これを防ぐためには、過去の会話の履歴を保持し、それを ChatGPT に毎回送ることで文脈を維持します。会話履歴を保存する方法について公式のチュートリアルを参考に見ていきましょう。

ということで、会話履歴を挿入する方法を見ていきましょう。会話履歴部分の挿入には MessagePlaceholder を利用します。

insert-history
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are a helpful assistant. Answer all questions to the best of your ability."),
        MessagesPlaceholder(variable_name="messages"),
    ]
)

引数に指定している variable_name は、 invoke 時に渡される引数名となります。

呼び出すときは以下のように invoke メソッドの引数に、指定した名称を指定します。

invoke-history
# AIMessage と HumanMessage をインポート
from langchain_core.messages import HumanMessage, AIMessage

chain = prompt | model | parser

result = chain.invoke({
    "messages": [
        HumanMessage(content="こんにちは!"),
        AIMessage(content="こんにちは! なにかお手伝いできますか?"),
        HumanMessage(content="私はえどといいます。覚えておいてね"),
    ]
})

print(result) # => こんにちは、えどさん!お名前を覚えておきますね。今日はどんなことをお手伝いしましょうか?

messages に渡した配列の最後が最終のインプットとなり、それに対するレスポンスが得られているのが分かるかと思います。

会話履歴(メモリ)を保存する

先ほどの例では自分でメモリ配列を準備して呼び出していました。しかし実際は自動でやり取りを保持しないとなりません。ここではそれを実現する方法を見ていきます。

チュートリアルからコードを抜粋します。主なクラスは RunnableWithMessageHistory です。

setup-history
from langchain_core.messages import HumanMessage, AIMessage
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

store = {}

def get_session_history(session_id: str) -> BaseChatMessageHistory:
    if session_id not in store:
        store[session_id] = ChatMessageHistory()
    return store[session_id]
	
with_message_history = RunnableWithMessageHistory(model, get_session_history)

session_id は文字列で、「どの会話か(セッションか)」を判断するために用いられます。任意の文字列です。

そして RunnableWithMessageHistory クラスに LLM のモデルと、会話履歴を取り出す関数を渡すことで会話履歴を参照する Runnable が作られます。( Runnable については後日、別記事で解説します)

会話履歴を利用して会話する

実際に ChatGPT にメッセージを送信する部分を見ていきましょう。会話する際に、 session_id を指定する必要があるためそれ用のコンフィグオブジェクトを作成します。

config
config = {
    "configurable": {
        "session_id": "abcd",
    }
}

このコンフィグオブジェクトを利用して呼び出します。

invoke-history
response = with_message_history.invoke(
    [HumanMessage(content="こんにちは! 私はえどです。")],
    config=config,
)

print(response.content) # => こんにちは、えどさん!お会いできて嬉しいです。今日はどんなことをお手伝いできるでしょうか?
invoke-history2
response = with_message_history.invoke(
    [HumanMessage(content="私の名前はなんでしょう?")],
    config=config,
)

print(response.content) # => あなたの名前は「えど」さんですね。どうぞよろしくお願いします!今日は何か特別なことをお話ししたいですか?

ちゃんと名前を覚えていてくれました。

LCEL と RunnableWithMessageHistory と組み合わせる

次は LCEL 、つまりチェインに組み込む方法を見ていきます。これについては以下の記事を参考にさせていただきました。

https://zenn.dev/mizunny/articles/d974720d8acc6f

また、公式ドキュメントにイメージ図が掲載されています。
イメージ図
緑枠の中央にある赤い文字で書かれている Your Runnable が、達成したいチェイン部分となります。以下、実際のコード例です。

combine-history
prompt_template = ChatPromptTemplate.from_messages(
    messages=[
        SystemMessage(content=system_prompt),
        MessagesPlaceholder(variable_name="history"),
        HumanMessagePromptTemplate.from_template("{user_input}"),
    ]
)

chain = prompt_template | model | parser

with_message_history = RunnableWithMessageHistory(
    chain,
    get_session_history,
    input_messages_key="user_input",
    history_messages_key="history",
)

result = with_message_history.invoke(
    {"user_input": user_input},
    config={
        "configurable": {
            "session_id": "abcd",
        }
    },
)

print(result)

MessagesPlaceholder を利用する場合は、 RunnableWithMessageHistory のコンストラクタにhistory_messages_key を指定し、プレースホルダーに設定した名称を知らせる必要があります。

またユーザーのインプットに当たる部分は HumanMessagePromptTemplate を利用し、挿入する名前を user_input にしています。(これは任意の文字列)

そして呼び出し時にユーザーインプットとして user_input に値を設定しています。

実際にはやり取りをループ処理して会話できるようにしてやる必要があります。以下に、実際にやり取りができるコード全体を掲載しておきます。

実際の全体のコード
full-history-code
import dotenv
dotenv.load_dotenv()

from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-4o")

from langchain_core.output_parsers import StrOutputParser

parser = StrOutputParser()

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, HumanMessagePromptTemplate

store = {}

def get_session_history(session_id: str) -> BaseChatMessageHistory:
    if session_id not in store:
        store[session_id] = ChatMessageHistory()
    return store[session_id]

system_prompt = "あなたは AI アシスタントとしてユーザーの質問に答えてください。"

while True:
    user_input = input("質問は?\n")

    if user_input.lower() == "exit":
        print("さようなら!")
        break

    if user_input is None:
        continue

    prompt_template = ChatPromptTemplate.from_messages(
        messages=[
            SystemMessage(content=system_prompt),
            MessagesPlaceholder(variable_name="history"),
            HumanMessagePromptTemplate.from_template("{user_input}"),
        ]
    )

    chain = prompt_template | model | parser

    with_message_history = RunnableWithMessageHistory(
        chain,
        get_session_history,
        input_messages_key="user_input",
        history_messages_key="history",
    )

    result = with_message_history.invoke(
        {"user_input": user_input},
        config={
            "configurable": {
                "session_id": "abcd",
            }
        },
    )

    print(result)

最後に

LangChain はかなり大規模なフレームワークで、実装も複雑になっています。表面的に使うだけならそこまで大変ではないですが、実際のアプリを作成しようとすると途端に内部を把握していないとうまく構築できないことも多いです。

今回は簡単な使い方にとどめましたが、別の記事では(上でも書いた通り) Runnable についてや、その他の機能( Retriever と呼ばれる機能など)も解説していきたいと思います。

LLM はうまく活用すれば今までになかったアプリが作成できるので、ぜひみなさんも活用してみてください。

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XRのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

MESONのメンバーページからご応募いただくか、TwitterのDMなどでご連絡ください。

書いた人

えど

比留間 和也(あだな:えど)

カヤック時代にWEBエンジニアとしてリーダーを務め、その後VRに出会いコロプラに転職。 コロプラでは仮想現実チームにてXRコンテンツ開発に携わる。 DAYDREAM向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでもAR/VRの開発をしており、インディー部門でTGSに出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。

GitHub / Twitter

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

Discussion