CCCMKホールディングス TECH LABの Tech Blog

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

LangChainを使ってGenerative Agentを試してみる!

こんにちは、CCCMKホールディングス TECH LABの三浦です。

雨の日が多くなってきました。洗濯物がなかなか乾かなくて困ってしまいます。あっという間に洗濯物が乾くソリューションが何かないかな・・・と考えてしまいます。

前回も触れたのですが、最近こちらの論文を読んでいます。

Title: Generative Agents: Interactive Simulacra of Human Behavior
Authors: Joon Sung Park, Joseph C. O'Brien, Carrie J. Cai, Meredith Ringel Morris, Percy Liang, Michael S. Bernstein
Submit: Submitted on 7 Apr 2023
URL: https://arxiv.org/abs/2304.03442

"Generative Agent"とは生成モデルを利用したより人間らしい行動を取ることが出来るAgentで、別のAgentと干渉しあうことで個々人の行動だけでなく、集団の行動もシミュレーションすることが出来ます。論文の中では例えば、あるAgentにシミュレーション開始時にバレンタインのパーティーを開きたい、という情報を与えます。するとその情報は別のAgentに徐々に伝播していき、最終的に5人のAgentがそのパーティーに参加した、という現象が見られたそうです。

25人のAgentが過ごす"Smallville"というサンドボックスゲームの環境は"Phaser"というWebゲーム開発用のflameworkで構築されています。Smallvilleの中のオブジェクトの状態を常に監視するためのサーバーも必要になります。この論文のシミュレーションを実際にやってみようとするとなかなかハードルは高そうです。

しかし少しGenerative Agentを試してみよう、というのであれば、LangChainの実験的な機能を使うことで実現できることが分かりました。今回はGenerative Agentに会社員としての一日を経験してもらい、その後インタビューに答えてもらう、といったことを試してみました。

参考

今回の実装についてはほぼLangChainの"Generative Agents in LangChain"のページを参考にしています。

https://python.langchain.com/en/latest/use_cases/agent_simulations/characters.html

Generative Agentの動きについて

試してみる前に、まず論文の内容からGenerative Agentの動きについて簡単にまとめてみたいと思います。 以下は論文に掲載されているGenerative Agentの構造について表した図です。

Generative Agents: Interactive Simulacra of Human Behavior,Joon Sung Park, Joseph C. O'Brien, Carrie J. Cai, Meredith Ringel Morris, Percy Liang, Michael S. Bernstein,https://arxiv.org/abs/2304.03442, Figure 5

Agentはまず今いる環境について認識(Perceive)し、それに関連する記憶を中心に位置するMemory Streamから検索(Retrieve)します。検索された関連する記憶を元に行動(Action)を決定します。ある程度検索が行われると、今度は自身の記憶を一度振り返る(Reflect)や今後の行動をある程度決める(Plan)という処理が行われます。そしてそれらの結果はまた、Memory Streamに格納されます。

今回私が試した範囲はこの構造の中のPerceive, Retrieve, Actionの部分に限定しています。それほど多種多様な事象が発生するような環境ではないためです。

動かしてみる

ではここからLangChainの"Generative Agents in LangChain"のコードをたどりながら実際に動かしてみます。今回使用したLLMは、Azure OpenAIサービスのChatGPT("gpt-35-turbo")です。また全ての作業はAzure DatabricksのNotebookから実行しています。

セットアップ

まず必要なライブラリのインストールです。

%pip install openai==0.27.6 langchain tiktoken faiss-gpu
dbutils.library.restartPython()

faiss-gpuはMeta社のFundamental AI Research groupが開発したベクトルの近似検索ライブラリFaissをPythonから使用するためのライブラリです。記憶を格納するMemory Streamのために使用します。インストール後にPythonのカーネルの再起動を求められるため、dbutils.library.restartPython()を実行しています。

必要となるライブラリをインポートします。また、loggingの設定も行います。

from datetime import datetime, timedelta
from typing import List
import logging
import math

import faiss
from langchain.chat_models import ChatOpenAI
from langchain.docstore import InMemoryDocstore
from langchain.embeddings import OpenAIEmbeddings
from langchain.retrievers import TimeWeightedVectorStoreRetriever
from langchain.vectorstores import FAISS
from langchain.experimental.generative_agents import GenerativeAgent, GenerativeAgentMemory

logging.basicConfig(level=logging.ERROR)

LLM

AgentのエンジンになるLLMです。

lm = ChatOpenAI(engine="gpt-35-turbo", max_tokens=2048)

FaissとRetrieverの設定

Memory StreamのコアになるFaissと、検索をするためのRetrieverの設定です。まず記憶を検索する時に使用される、近似度を測定するための関数を定義します。

def relevance_score_fn(score: float) -> float:
  """
  この関数はFAISSの類似スコアを0~1の範囲に変換し、
  かつ近いほど1に, 遠いほど0に近い値を出力します。
  """
  return 1.0 - score / math.sqrt(2)

これは埋め込み表現にOpenAIEmbeddingsを使用する場合を想定した実装になっており、他の埋め込み表現を使用する場合はベクトルのスケールの状況などに合わせて修正する必要があるようです。

次にRetrieverを作成する関数です。

def create_new_memory_retriever():
  """
  この関数はGenerative Agentが使用するMemory StreamへのRetrieverを作成して
  返します。
  """
  embeddings_model = OpenAIEmbeddings()
  embedding_size = 1536
  index = faiss.IndexFlatL2(embedding_size)
  vectorstore = FAISS(
    embeddings_model.embed_query,index, 
    InMemoryDocstore({}),
    {},
    relevance_score_fn=relevance_score_fn)

  return TimeWeightedVectorStoreRetriever(
    vectorstore=vectorstore, 
    other_score_keys=["importance"],
    k=15)

TimeWeightedVectorStoreRetrieverというRetrieverを使用します。Generative AgentのMemory Streamに対する検索では、ベクトル間の類似度だけでなく、より直近でかつ重要な意味を持つ記憶を検索するようにしています。そのためそれらの機能を持ったTimeWeightedVectorStoreRetrieverをここでは使用します。

Generative AgentとMemoryの作成

まずはGenerative Agentの記憶をつかさどるGenerativeAgentMemoryです。

miura_memory = GenerativeAgentMemory(
  llm=llm,
  memory_retriever=create_new_memory_retriever(),
  verbose=False)

そして以下の様にGenerative Agentを定義します。

miura = GenerativeAgent(
  name="三浦",
  traits="マイペース,新しい物好き,食べることが好き,会社で働いている",
  status="LLMに夢中",
  memory_retriever=create_new_memory_retriever(),
  llm=llm,
  memory=miura_memory)

ちゃんと名前を付けることが出来ます!このAgentの特徴をtraitsに、一貫させたい状態をstatusに設定するようです。このAgentの特徴を以下のコマンドで要約することが出来ます。

miura.get_summary()()
'Name: 三浦 (age: N/A)\nInnate traits: マイペース,新しい物好き,食べることが好き,会社で働いている\n
It is not possible to provide a summary as no information has been given about who or what 三浦 refers to, 
and no statements have been provided to analyze.'

内部で呼ばれる要約を生成するためのPromptが英語で記述されているため、要約も英語になってしまいます。そしてまだ何も経験していない素の状態のAgentですので、要約も取得することが出来ない状態です。

ちなみにAgentにはインタビューをすることが出来ます。まずインタビュー用の関数を定義します。

def interview_agent(agent: GenerativeAgent, message:str)->str:
  """
  agentに対しmessageを質問し、回答を生成して返します。
  """
  return agent.generate_dialogue_response(message)[1]

ではAgentにいくつか質問をしてみます。時々英語で回答するので、それを避けるために"Answer the following question in Japanese."という文章を含めています。

print(interview_agent(
  miura, 
  """
  Answer the following question in Japanese.
  ==========================================
  今日あった出来事について1つ教えて下さい。
  """))

回答は以下の通りです。

三浦 said 「今日は特に何もなかったです。ただ、LLMに夢中なので、ずっとそれに集中していました。」
(There wasn't anything special that happened today. I've just been focused on LLM the whole time.)

・・・まだ何もしてないですからね。

Memory Streamにいくつかの記憶を入れてインタビューを再実施する

それではこのAgentにいくつかの出来事を体験してもらい、そこで観測したことをMemory Streamに格納してみます。

miura_observations = [
  "三浦は朝起きたら雨が降っていることに気が付いた",
  "三浦は朝食にパンとコーヒーを食べ、コーヒー豆が残り少ないことに気が付いた",
  "三浦は会社に向かうため乗った電車の中でLLMに関する新しい記事をスマートフォンで読んだ",
  "三浦はコンビニで新作のお菓子を見つけた",
  "三浦はチームのメンバーとAM 10:00からミーティングに参加した"
]

miura.memory.add_memories(";".join(miura_observations))

まず要約を見てみます。

miura.get_summary(force_refresh=True)

force_refresh=Trueによって、要約が再作成されます。結果は以下の通りです。

'Name: 三浦 (age: N/A)\nInnate traits: マイペース,新しい物好き,食べることが好き,会社で働いている\n
三浦 is punctual and participates in team meetings. He reads articles related to his work during his commute. 
He is observant and notices changes in weather and his coffee supply. 
He enjoys trying new things, such as new snacks found at a convenience store.'

今度は観測した出来事を反映した内容になっています。では先ほどと同様の内容でインタビューをしてみます。

print(interview_agent(
  miura, 
  """
  Answer the following question in Japanese.
  ==========================================
  今日あった出来事について1つ教えて下さい。
  """))

今度は以下の様になりました。

三浦 said 「今日は会社でのミーティングが順調に進んだよ。」
(Today's team meeting at work went smoothly.)

AM10:00に参加したミーティングの話が反映されていますね。

もっとたくさんの出来事をLLMを使って用意する

今までは5つの出来事だけでしたが、せっかくなので会社員が体験することを想定した1日分の出来事をAgentに体験させてみます。先ほどの5つの出来事を元に、続きの出来事をLLM(ChatGPT)に生成させてみました。

miura_observations_str = "\n".join(miura_observations)
prompt = f"""
ある男性が1日に観測した出来事のリストを作成します。
この男性はマイペースで、新しいもの好きで、食べることが好きな会社員です。
この男性は普段夜23時に眠ります。
この男性が夜寝るまでに体験した出来事を時系列に沿って作成してください。

{miura_observations_str}
"""

flollowing_observations_str = llm.predict(prompt)
print(flollowing_observations_str.split("\n"))

するとこんな感じの出来事が生成されました。

['三浦はミーティング中に新しいプロジェクトのアイデアを思いついた', 
'三浦は昼食にラーメンを食べた', 
'三浦は午後になってからも雨が止まないことにイライラした', 
'三浦は仕事で新しいツールを使ってみた', 
'三浦は帰りの電車の中で音楽を聴きながら本を読んだ', 
'三浦は夕食に焼肉を食べた', 
'三浦は家でNetflixを見ながら新しいドラマにはまった', 
'三浦は夜23時に眠りについた。']

お昼にラーメンを食べて、夜に焼き肉を食べるという、見ているだけでお腹が一杯になりそうな一日ですね。この出来事を元になっている出来事のリストに追加して、このAgentに起こる1日分の出来事を生成します。

flollowing_observations = flollowing_observations_str.split("\n")
miura_observations.extend(flollowing_observations)
print(miura_observations)

出力結果

['三浦は朝起きたら雨が降っていることに気が付いた', 
'三浦は朝食にパンとコーヒーを食べ、コーヒー豆が残り少ないことに気が付いた', 
'三浦は会社に向かうため乗った電車の中でLLMに関する新しい記事をスマートフォンで読んだ', 
'三浦はコンビニで新作のお菓子を見つけた', 
'三浦はチームのメンバーとAM 10:00からミーティングに参加した', 
'三浦はミーティング中に新しいプロジェクトのアイデアを思いついた', 
'三浦は昼食にラーメンを食べた', 
'三浦は午後になってからも雨が止まないことにイライラした', 
'三浦は仕事で新しいツールを使ってみた', 
'三浦は帰りの電車の中で音楽を聴きながら本を読んだ', 
'三浦は夕食に焼肉を食べた', 
'三浦は家でNetflixを見ながら新しいドラマにはまった', 
'三浦は夜23時に眠りについた。']

なかなか充実した一日になりました。

出来事を観測し、リアクションをする

先ほど作った出来事一覧をまたAgentの記憶に追加します。今度はそれに加えてその出来事に対してAgentにリアクションさせ、それも含めて記憶させるようにします。

for i, obs in enumerate(miura_observations):
  _, reaction = miura.generate_reaction(obs)
  print("【",obs, "】", reaction)

すると出来事に対してAgentがどのようにリアクションしたのかが以下の様に表示されます。

【 三浦は朝起きたら雨が降っていることに気が付いた 】 三浦 三浦は傘を持っていく必要があると思って、傘を取りに行く。 (Miura realizes they need to bring an umbrella and goes to get one.)
【 三浦は朝食にパンとコーヒーを食べ、コーヒー豆が残り少ないことに気が付いた 】 三浦 三浦はコーヒー豆を買いに行くことを決める。 (Miura decides to go buy more coffee beans.)
【 三浦は会社に向かうため乗った電車の中でLLMに関する新しい記事をスマートフォンで読んだ 】 三浦 三浦は記事を読み終わって、新しい知見を得たことに満足感を感じる。 (Miura feels a sense of satisfaction from gaining new insights after finishing the article.)
【 三浦はコンビニで新作のお菓子を見つけた 】 三浦 三浦は新しいお菓子を試してみたいと思って、購入することを決めた。 (Miura decides to buy the new snack and try it.)
【 三浦はチームのメンバーとAM 10:00からミーティングに参加した 】 三浦 三浦はミーティングに参加することに準備をして、集中して取り組むことを決めた。 (Miura prepares to participate in the meeting and decides to focus on the task at hand.)
【 三浦はミーティング中に新しいプロジェクトのアイデアを思いついた 】 三浦 三浦はアイデアをメモして、後でプレゼンテーションに取り入れることを決めた。 (Miura decides to jot down the idea and incorporate it into the presentation later.)
【 三浦は昼食にラーメンを食べた 】 三浦 三浦は満足感を感じ、今度は新しいラーメン店を探してみたいと考えた。 (Miura feels satisfied and thinks they want to try finding a new ramen restaurant next time.)
【 三浦は午後になってからも雨が止まないことにイライラした 】 三浦 三浦は傘を持っていることに感謝し、残りの日を楽しむことを決めた。 (Miura feels grateful for having an umbrella and decides to enjoy the rest of the day.)
【 三浦は仕事で新しいツールを使ってみた 】 三浦 三浦は新しいツールを使ってみて、その使い方を習得することができたことに喜びを感じた。 (Miura feels happy to have learned how to use the new tool.)
【 三浦は帰りの電車の中で音楽を聴きながら本を読んだ 】 三浦 三浦はリラックスして、帰りの時間を楽しんでいるようだ。 (Miura seems to be relaxing and enjoying their commute with music and reading.)
【 三浦は夕食に焼肉を食べた 】 三浦 三浦は満足感を感じ、美味しい焼肉を食べたことに喜びを感じた。 (Miura feels satisfied and happy to have eaten delicious yakiniku.)
【 三浦は家でNetflixを見ながら新しいドラマにはまった 】 三浦 三浦は新しいドラマに夢中になり、今後も続きを見たいと思った。 (Miura is hooked on the new drama and wants to continue watching it in the future.)
【 三浦は夜23時に眠りについた。 】 三浦 三浦は普段よりも遅い時間に就寝したようだ。 (Miura seems to have gone to bed later than usual.)

読んでいるとなかなか面白いですね・・・。それではこの1日を経験してもらった後、またインタビューをしてみます。

print(interview_agent(
  miura, 
  """
  Answer the following question in Japanese.
  ==========================================
  おはようございます。昨日はどんな一日でしたか?"""))

こんな感じの回答が得られました。

三浦 said おはようございます。昨日は、新しいドラマに夢中になってNetflixを見て、夕食に美味しい焼肉を食べたり、
帰りの電車の中で本を読んだりして過ごしました。でも午後からは雨が止まなかったのでイライラしましたが、
傘があったので感謝して、楽しむことにしました。何か面白いことがあったのでしょうか?

なかなか良さそうですね。ドラマについて聞いてみます。

print(interview_agent(
  miura, 
  """
  Answer the following question in Japanese.
  ==========================================
  あなたが夢中になったというドラマについて教えて下さい。
  """))

結果

三浦 said 「あのドラマはとても面白かったです。ストーリー展開やキャラクターの魅力があって、
一気に見てしまいました。次のエピソードも楽しみにしています。」
(That drama was really interesting. The story and characters were captivating, 
and I ended up watching it all at once. I'm looking forward to the next episode.)

ちょっと調整不足なところもありますが、面白いですね!

まとめ

ということで、今回はLangChainを使って簡単なGenerative Agentを作ってみた話をご紹介しました。内部で使われているPromptが英語なこともあって、日本語で使うにはちょっと調整が要りそうだな・・・と感じたものの、全体的にはイメージしたとおりのことが出来たように思います。今回は1つのAgentだけで試しましたが、複数のAgentを用意してお互いで会話をさせる、といったことも可能なようなので、今度はそちらも試してみようと思います。