LangChainにはなぜMemoryという機能があるのか

大嶋勇樹氏:続いて、OpenAI Chat APIを「ちゃんと」使うには、ということをもう少しやっていこうと思います。

ここではLangChainのMemory機能を扱おうと思いますが、LangChainにはMemoryという機能があります。なぜそんな機能があるのかの背景からいきます。

そもそも、LangChainなしのOpenAIのAPIというものは、前回のやり取りを覚えたりはしてくれません。例えば「こんにちは、私は大嶋といいます」とまずAPIを呼び出して、「こんにちは、大嶋さん。私は田中です。どうぞよろしくお願いします」と返ってきたとします。

「(大嶋という)日本人っぽい名前で送ると、田中という日本人ぽい名前で返信してくるな」というのがあるんですが、それに対して別のAPIのリクエストとして「私の名前がわかりますか?」と聞くと、「いいえ、わかりません」と答えてくるわけですね。

ただ、こんなふうにOpenAIのAPIは履歴を覚えていないというか、「覚えていない」と回答してくるんですが、チャットボットとかを作りたい時は、過去の会話を踏まえて応答してほしい。つまり記憶を持たせたいことはよくあると思います。

Memory機能の使い方

そこで、LangChainのMemoryという機能を使うことがあります。Memoryという機能がどんな機能かというと、プロンプトに過去のやり取りの履歴を入れることで、過去の会話を踏まえた応答をしてくれる機能ですね。

まず「こんにちは。私は大嶋といいます」と投げて、OpenAIのAPIが「こんにちは、大嶋さん。私はAIです。私の名前は誰々です」と言ってきます。そうしたら、過去のやり取りの内容、プラス「私の名前がわかりますか?」というリクエストを投げると、「はい、大嶋さんですね」とちゃんと答えてくれるわけですね。こんな機能があります。

しかし、これをLangChainで使おうとすると……。ドキュメントに実装例があるんですが、これが良い使い方なのか怪しい点があるので、今度はそのあたりをソースコードリーディングで見ていこうと思います。

色がついているほうがわかりやすいと思うので、今映していたコードをあらためて載せようと思います。(画面を示して)手元のソースコードをコピペして次のサンプルコードを用意しました。やっていることは、「chat = ChatOpenAI」としてgpt-3.5-turboのモデルを用意しています。その後、LangChainのMemory機能を用意しています。ConversationBufferMemoryということで、本来ユーザーの入力とAIの応答を、動的にメモリに入れたりします。

「Hi I'm Oshima」「私は大嶋です」というのと、AIの応答として「What's up?」「なにがありましたか?」みたいなものを、add_user_messageやadd_ai_messageというメソッドを使って固定で入れています。その後、LangChainのドキュメントを見ると、Memory機能を使うサンプルとして、ConversationChainを使います。

「ConversationChain(llm=chat, memory=memory)」として、「conversation.predict(input="Do you know my name?")」みたいな例が載っていたりします。

これをまず1回実行してみようと思います。実行してみると、Memory機能を使っているので、「私の名前は大嶋」と最初に名乗っていることを踏まえて、「Yes, your name is Oshama」というように、ちゃんとわかっているということを答えてくれますね。

Memory機能がChat APIをちゃんと使っているか確認する

なんですが、これがChat APIをちゃんと使っているかは実はちょっと怪しいです。どういうことかというと、今のやり取りはAPIの呼び出しとして、本当はこんなふうにしてほしいわけですね。

「{ "role": "user", "content": "Hi! I'm Oshima!" }」。そして2つ目は「{ "role": "assistant", "content": "What's up?" }」「{ "role": "user", "content": "Do you know my name?" }」と、過去の履歴を含めて聞いてあげれば、次のAI側、assistans側の応答として、「はい、あなたは大嶋さんですね」みたいなことを返してくれます。

こんなふうにちゃんとAPIを呼んでいるか確認しようと思います。実際にどうやって確認するかが、今度のソースコードリーディングのポイントみたいなところですね。

実はLangChainのドキュメントなどを読むと、ソースコードリーディングと少し違う話ではありますが、LangChainの出力をverboseにすると中のプロンプトとかがちょっと見えるということがあって。その状態で少し実行してみようと思います。

すると、このコードに対して内部で指示みたいなものがあって、「Current conversation: Human: Hi! I'm Oshima.」「AI: What's up? Human: Do you know my name? AI:」と、なんとなくChat APIを使っていなさそうなプロンプトが表示されるんですね。

「Hi! I'm Oshima. AI: What's up?」の部分がChat APIの……。「Hi! I'm Oshima. 」が「"role": "user"」、「AI: What's up?」が「"role": "assistant"」、「Do you know my name?」が「"role": "user"」としてほしいのに、下手をすると「"role": "user"」に全部ぶち込んだプロンプトなんじゃないかとも見えます。本当にそうなっているのかは、ソースコードを読んだり、もうちょっと追いかけてあげないとわからなさそうなので、そういうのを見ていこうというところですね。

今はConversationChainを使っているので、このソースコードを見ていこうと思います。LangChainの使っているバージョン、0.0.215のドキュメントを開いて。langchainディレクトリの中のConversationChainを使っているので、そのソースを見ていこうと思いますが、chainsディレクトリにありそうだなと。

もちろん一発で当てる必要はないので、なんとなく見ていきます。僕は事前に見ているのでわかっているのもありますが、「conversationディレクトリとかにありそうだな」と思い見にいきます。

このconversationディレクトリのbase.pyをなんとなく見てみると、ConversationChainというものがあります。

このConversationChainが今使っているものですね。これを使う時の呼び出し方として、「"role": "user"」や「"role": "assistant"」をちゃんと使ってくれているのかを確認したいわけです。

LangChainのChainは内部で勝手にプロンプトを用意して使ってくれるんですが、1個さかのぼってconversationディレクトリを見ると、ConversationChainが定義されているbase.py以外に、prompt.pyというファイルがあります。

このprompt.pyを見てみると、何かプロンプトが書かれているわけですね。

もう1回base.pyを見てConversationChainを見ると、ConversationChainはフィールド、プロンプトとしてデフォルトで、importしたPROMPTを使っています。

このimportしたPROMPTは、langchain.chains.conversation.prompt、つまり同じconversationディレクトリのprompt.pyから、大文字のPROMPTをimportしてデフォルトで使うようになっているらしいことがわかります。

実際、ConversationChainで使われているプロンプトをprompt.pyで見てみると、こんなふうに「PROMPT =」と定義されていて、「template=DEFAULT_TEMPLATE」と書かれています。(画面を示して)つまり、先ほどログとして少し表示しましたが、ConversationChainを使った時のプロンプトは、こんなふうになっているわけですね。

これはもうまさに「"role": "user"」や「"role": "assistant"」とかをせず、先ほど__call__とpredictの呼び出し方の違いを見ましたが、言ってしまえばたぶんこのpredictのような呼び出し方で、その部分に過去の人間とAIのやり取りを全部入れているっぽいPrompt Templateが見つかったわけですね。

これはChat APIの使い方としてあまり適切ではない可能性があります。

OpenAIのAPIが実際にどうやって呼び出されているか

ですがもっと確証を得るために、APIが実際にどうやって呼び出されているかも見たいと思います。

OpenAIのAPIが実際どうやって呼び出されているかも見ないと、「LangChainの内部でいったんこうなっているから、たぶんこのプロンプトが呼ばれている」ぐらいの確証度になっちゃうので、実際にOpenAIのAPIがどうやって呼び出されているのかを見る方法はないかと考えてみます。

それをやる上で、やり方はいろいろありますが、例えばConversationChainのpredictというものを呼んでいるわけです。

なので、ConversationChainのpredictはこの中に定義されていないから、親クラスのLLMChain、predictを見にいきます。

すると、ここではやはり__call__が呼ばれている。

何かが呼ばれてそこからたどることもできるんですが、こうやってたどっていくと、実はなかなか大変だったりします。

もちろんたどっていってもいいし、実際ちゃんとたどり着けるようにはなっています。しかし、LangChainのパッケージは最終的にOpenAIのPythonのパッケージを使ってChat APIを読んでいるので、例えば「openai python」と検索して、GitHubでOpenAIのPythonのライブラリを見てみます。

このOpenAIのPythonのライブラリに、なにかAPIのリクエストのログを出す方法がないかを見てみると、ヒントになる可能性があるのではないかとかを考えてみようと思います。

速めのスピードでやっているので、なにをやっているかわかりにくいという方もいるかもしれません。ソースコードをたどる時に1個1個たどってももちろんいいんですが、そういうオプションがOpenAIのAPIにあって、その設定をしてあげればもしかしたらうまくいくかもなという発想で、OpenAIのPythonを見にきたというわけですね。

さて、OpenAIのPythonのパッケージにChat APIを呼び出す時になにかログを仕込む方法はないかを見てみようと思います。

僕がこの前……、この前といっても昨日とかですが、READMEを見た範囲では、デバッグログの出し方とかは載っていないです。なので、ソースコードを検索してそういうのがないか見てみようと思います。

例えば「log」と検索してみます。これは答えがわかった上でやっているのでちょっとアレですが……。実際に僕は思いつきでこの流れで見つけているのでアレですが、「log」と検索してみます。

なんとなく「ログを出すメソッドはさすがに『log』っていう名前をつけるだろう」ということで「log」と検索すると、openai/util.pyが見つかります。

log_debugと書いてあるんですね。デバッグ用のメソッドが定義されていて、たぶんこのOpenAI Pythonはいろいろなところでデバッグログを出すため、このlog_debug関数を呼んでいるんじゃないかと想像できます。

このlog_debugは、「_console_log_level() == "debug"」だったら表示するみたいなコードになっていますが、おそらくこの_console_log_levelという関数が、デバッグを返した時に表示されるということだろうなと思われます。

これはopenai.logという値や、「OPENAI_LOGの"debug"が"info"だったら」みたいな分岐になっているわけですね。「このopenai.logって何だろうな」とか、「OPENAI_LOGって何だろうな」という定義を見ていきます。

このOPENAI_LOGについては、実はOPENAI_LOGという環境変数で設定できるようになっています。

ほかにもopenai.logはimportしているものですね。

検索したところに戻ると、openaiの__init__.pyというところで「log = None」と定義されていて。

これがopenai.logと使われているわけです。

openai.logという名前で、debugかinfoが設定できるようになっていて、これでログを出せると読み取れるわけです。

このlogを全部読んだわけじゃないので正直まだわかりませんが、例えば、このOpenAIのPythonクライアントを「log」で検索したものを見ていきます。

今度は「log_debug」というメソッドの使いどころで検索してみると、log_debugというメソッドが定義されているところと、もう1つ、明らかにリクエストの内容を表示してくれそうなものが見つかります。

このデバッグログを有効にしたら、たぶんリクエストの内容を表示してくれそうだということが読み取れるわけですね。ということで実際にソースコードを書き換えて、「import openai」として「openai.log = "debug"」としてもう1回実行すると、OpenAIのAPIの呼び出しログが出ます。

ここで、「Request to OpenAI API」のURL、Chat CompletionsのAPIを呼んでいるのがわかって、その次の部分で「"messages"」「"role": "user"」「"content"」として……。

これは気になっていたところですが、「"role": "user"」のcontentに、過去の「Hi! I'm Oshima」や「What's up?」や「Do you know my name?」とかのやりとりが全部入っています。(画面を示して)こんなプロンプトが呼び出されているのが見て取れます。

ということで、LangChain自体のコードをそんなに読んだわけではないですが、OpenAIのパッケージのログの出し方を見て、APIのログが見られるということをのを見てきました。これを見てConversationChainをドキュメントを参考になんとなく書くと、Memory機能の「Getting Started」に例が載っています。

でもそれをそのままやるだけだと、OpenAIのChat APIをちゃんと使わず、「"role": "user"」に内容を全部入れる使われ方をしてしまいます。

本当はそうじゃなくて「"role": "user"」「"role": "assistant"」「"role": "user"」と使い分けたいのに、そうじゃない使い方がされていることを見て取ることができたということになります。そんなところですね。

一応別のソースコードとして、「例えばこんなふうに書くと、ちゃんとChat APIとして適切なプロンプトの使われ方になりますよ」みたいな例も、共有したソースコードに載せさせてもらっています。

LangChainのChainやAgentを使う時は注意が必要

実はLangChainのChainやAgentを使う時は、Chat APIの形式を活かした実装になっているか、注意が必要です。

こういうふうに、ちゃんとChat APIの形式を活かした形式になっていなくて、例えば「"role": "user"」に過去のやり取りを全部含めるというやり方を先ほどはしていたわけですが、そういう呼び出し方をしてしまうと、よくあるのが、AIの応答が「Your name is Oshima」で止まらないで、「続きの人間の応答まで生成するものなのかな」と想像してしまい、勝手に続きまで生成されて返ってくることもよくあります。

最近のアップデートで減った気もするんですが、こういうこともけっこう起こったりするので、Chat APIを使う時は、ちゃんと「"role": "user"」とか「"role": "assistant"」を使い分けているかを見てあげるのが1個のポイントです。

(次回につづく)