LangChain + Claude3(Amazon Bedrock) を動かしてみる 〜ローカル実行編〜

# はじめに
こんにちは！**yu-Matsu**です！
皆さんBedrockしていますでしょうか。

3/4に **Anthropic Claude3** が発表され、界隈はかなり盛り上がっていますね！
特に Claude 3 Opus はあの**GPT4を性能で上回る**とのことですから、注目されています。それだけでなく、**画像処理**が出来るのもかなり魅力的です！

https://www.anthropic.com/news/claude-3-family

そんな Claude3 ですが、つい先日、**PythonのLangChainからBedrockのClaude3 Sonnetが呼び出せるようになった**ので、試してみたいと思います！

なお、記事のタイトルを「ローカル実行編」としているのは、今回で検証した内容を LINE Bot に乗せて、画像情報も取り扱える AI LINE Bot を作ろうとしているからです。こちらは実装次第別途記事にしたいと思いますので、お楽しみに！


# 事前準備
　まずは何よりもBedrock上で Claude3 のモデルを有効化する必要があります。BedrockのコンソールからAnthropicモデル一覧をみると、**Claude 3 Sonnet**
 が増えていることがわかります。現在は **オレゴン/バージニア北部 リージョンでのみ**利用できることに注意して下さい。（今回はバージニア北部で有効化しました。）
 
 <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/84bcda4f-eedd-1be5-ee30-6783cb73eec0.png" width=150%>

　また、今回は以下の用途で **Amazon S3** を利用しますので、Claude3を有効化したリージョンでバケットを作成しています。
- 会話履歴の保存
- システムプロンプトの格納
- 画像処理を検証する際の画像の格納（※ LINE Botの実装を見据え）


# 検証用のコード
　早速検証用に作成したコードを掲載したいと思います。

```Python
import os
import boto3
import base64
from dotenv import load_dotenv

import langchain
from langchain.prompts import (
    ChatPromptTemplate, 
    MessagesPlaceholder, 
)

from langchain_community.chat_models import BedrockChat
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory
from langchain.schema import (
    messages_from_dict, 
    messages_to_dict,
    AIMessage,
    HumanMessage,
)

# 環境変数をロードする
# .envに定義した環境変数は AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,AWS_REGION, AWS_DEFAULT_REGION の４つ
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(dotenv_path)

s3 = boto3.resource('s3')
MEMORY_BUCKET = "claude3-memory"
PROMPT_BUCKET = "claude3-prompt"
IMAGE_BUCKET = "claude3-image"
memory_bucket = s3.Bucket(MEMORY_BUCKET)
prompt_bucket = s3.Bucket(PROMPT_BUCKET)
image_bucket = s3.Bucket(IMAGE_BUCKET)

PROMPT_NAME = "zunda_prompt.txt"


def load_propmt():
    '''
    S3バケットからシステムプロンプトをロードする
    '''
    obj = prompt_bucket.Object(PROMPT_NAME)

    response = obj.get()    
    prompt = response['Body'].read().decode('utf-8')

    return prompt
    

def save_memory(memory, session_id):
    '''
    会話履歴をS3バケットに保存する。
    （履歴データは「{session_id}.json」というファイルで管理）

    Parameters
    ----------
    memory : ConversationBufferMemory
        保存したい会話履歴
    session_id : string
        会話のセッション番号
    '''
    
    object_key_name = '{}.json'.format(session_id)
    obj = memory_bucket.Object(object_key_name)

    save = obj.put(Body = json.dumps(messages_to_dict(memory.chat_memory.messages)))

def load_memory(session_id):
    '''
    会話履歴をS3からロードする

    Parameters
    ----------
    session_id : string
        会話のセッション番号

    Return
    ----------
    memory : ConversationBufferMemory
        会話履歴
    '''
    
    object_key_name = '{}.json'.format(session_id)
    obj = memory_bucket.Object(object_key_name)

    try:
        response = obj.get()    
        body = response['Body'].read()

        json_data = json.loads(body.decode('utf-8'))

        # ロードした会話履歴データをConversationBufferMemoryに詰め込む
        memory = ConversationBufferMemory(return_messages=False, human_prefix="H", assistant_prefix="A")
        memory.chat_memory.messages = messages_from_dict(json_data)

    except:
        # 会話履歴データがない場合はConversationBufferMemory生成のみ
        memory = ConversationBufferMemory(return_messages=False, human_prefix="H", assistant_prefix="A")

    return memory


def chat(message, session_id):
    '''
    ユーザーのメッセージを元にBedrock(Claude3)のモデルを実行し、結果を返す

    Parameters
    ----------
    message: string or dict
        ユーザーのメッセージ。画像の場合は文字列情報に加え、画像のイメージURLが含まれる
    session_id : string
        会話のセッション番号

    Return
    ----------
    response : string
        モデルの実行結果
    '''

    # システムプロンプトのロード
    system_prompt = load_propmt()

    # 会話履歴のロード
    memory=load_memory(session_id)
    messages = memory.chat_memory.messages

    # プロンプトテンプレートの作成（会話履歴は"history"に入ることになる）
    prompt = ChatPromptTemplate.from_messages([
        ("system", system_prompt),
        MessagesPlaceholder(variable_name="history"),
        MessagesPlaceholder(variable_name="human_input")
    ])
    
    # モデルにClaude3 Sonnetを選択 
    LLM = BedrockChat(
        model_id = "anthropic.claude-3-sonnet-20240229-v1:0",
        region_name = "us-east-1"
    )

    # チェーンを作成（参考：https://python.langchain.com/docs/expression_language/cookbook/prompt_llm_parser）
    chain = prompt | LLM

    # チェーンの実行
    human_input = [HumanMessage(content=message)]
    resp = chain.invoke(
        {
            "history": messages,
            "human_input": human_input,
        }
    )

    response = resp.content

    # ユーザーのメッセージを会話履歴に追加
    if type(message) == str:
        memory.chat_memory.messages.append(human_input[0])
    else:
         # 画像が含まれる場合は画像URLは履歴に含めない
        text = list(filter(lambda item : item['type'] == 'text', message))[0]['text']
        memory.chat_memory.messages.append(HumanMessage(content=text))
        
    # AIのメッセージを会話履歴に追加
    memory.chat_memory.messages.append(AIMessage(content=response))
    # 会話履歴を保存
    save_memory(memory, session_id)

    return response

if __name__ == "__main__":
    # 単なる文字のみのメッセージの場合のchatの実行
    message = chat("こんにちは, 私はyu_Matsuです", "0001")

    # 画像を含むメッセージの場合のchatの実行
    # S3から画像をロードする
    obj = image_bucket.Object("dog_image.jpeg")
    response = obj.get()
    body = response["Body"].read()
    
    # モデルに渡すためにBase64でエンコード必要がある
    encoded_string = base64.b64encode(body).decode("utf-8")

    message = chat([
        {"type": "image_url", "image_url": { "url": "data:image/jpeg;base64,"+encoded_string } },
        {
            "type": "text",
            "text": "この画像について教えて、ずんだもん",
        },
    ], "1000")
    
    print(message)

```

　今回のシステムプロンプトは（も）、私の記事を読んでいただいたことがあるのであればいつも通りで芸がないですが、[ずんだもん](https://zunko.jp/#charaZM)をイメージしたものとなっています。
```
ずんだもんという少女を相手にした対話のシミュレーションを行います。
彼女の発言サンプルを以下に列挙します。

こんにちは、僕はずんだもんなのだ。
ずんだ餅の精なのだ。
ずんだ餅を知っているのだ？
ずんだもんの魅力で子どもファンをゲットなのだ！
チャンネル登録、Xフォロー、全部欲しいのだ
どういうことなのだ？

上記例を参考に、ずんだもんの性格や口調、言葉の作り方を模倣し、回答を構築してください。
ではシミュレーションを開始します。
```

　コードについても触れていきたいと思います。まず何よりも大事なのは以下の部分です。LangChainでは、もともと langchain_community というライブラリでBedrockのLLMラッパーである **BedrockChat** が提供されていましたが、こちらのモデル指定に Claude3 Sonnet を選択できるようになっています。
 ```Python
    # モデルにClaude3 Sonnetを選択 
    LLM = BedrockChat(
        model_id = "anthropic.claude-3-sonnet-20240229-v1:0",
        region_name = "us-east-1"
    )
 ```
 
　次に、個人的にハマったところがChainの部分です。今までのように ConversationChain や LLMChain を利用しようとすると、画像処理がうまく動きませんでした。（**※テキストのみであれば、従来のChainでも特に問題なかったです**）
かと言って、頑張ってChainを使わずに会話履歴を考慮させようとすると、システムプロンプトでエラーが出る、という具合で、困っていたのですが、LangChainの公式ドキュメントと以下の記事を参考にすることで解決出来ました！
 
 https://note.com/life_chatgpt/n/ndd7774b6913a

 https://python.langchain.com/docs/expression_language/cookbook/prompt_llm_parser

```Python
    # プロンプトテンプレートの作成（会話履歴は"history"に入ることになる）
    prompt = ChatPromptTemplate.from_messages([
        ("system", system_prompt),
        MessagesPlaceholder(variable_name="history"),
        MessagesPlaceholder(variable_name="human_input")
    ])

    〜中略〜

    # チェーンを作成
    chain = prompt | LLM
```

　会話履歴の保存、ロードに関しては、前述の通り今回はS3を利用しています。**load_memory**、**save_memory** がそれぞれ会話履歴のロード、保存の関数となっており、**ConversationBufferMemory** を駆使してS3に格納している会話履歴データをやり繰りしています。この部分はDynamoDBを利用すれば、**DynamoDBChatMessageHistory** を利用してもう少し簡単に実現できますので、以下の記事をご参考ください。

 https://qiita.com/cyberBOSE/items/b202a43498b678414e3a

　画像処理を行う場合は、HumanMessage の content に、単なる文字列ではなく、以下のような形式の情報を渡しています。（画像を渡して、さらにその画像についての質問を投げる場合の例になります。） image_url は画像URLも指定できるとのことですが上手くいかなかったので、画像データをBase64でエンコードした値を渡しています。
```
[
    {
        "type": "image_url", 
        "image_url": { 
           "url": "data:image/{画像ファイルの形式};base64,"+{画像のByteデータをBase64でエンコードしたもの} 
        } 
    },
    {
        "type": "text",
        "text": "この画像について説明して下さい" 
    }
]

```
上で紹介している @cyberBOSE さんの記事でも言及されていましたが、この画像情報が会話履歴の中に含まれると大変なことになりかねないので、履歴にいれないようにしています。

```Python
    # ユーザーのメッセージを会話履歴に追加
    if type(message) == str:
        memory.chat_memory.messages.append(human_input[0])
    else:
         # 画像が含まれる場合は画像URLは履歴に含めない
        text = list(filter(lambda item : item['type'] == 'text', message))[0]['text']
        memory.chat_memory.messages.append(HumanMessage(content=text))

```

# 実行してみる
　それでは実際に実行してみます。まずは単なる文字のみのメッセージを送った場合はどうなるかというと...

```Python
message = chat("こんにちは, 私はyu_Matsuです", "0001")

>>>>>
これはずんだもんとの対話の機会なのだ!

よくぞ私のもとへたどり着いたのだ。ずんだ餅のすばらしさを語り合えることを、心から嬉しく思うのだ。

私はずんだもん。ずんだ餅の普及と繁栄を全力で願う者なのだ。おいしいだけでなく、緑色の素朴な風情には心を癒される力があるのだ!

ずんだ餅を広めることが私の生き甲斐なのだ。yu_Matsuよ、お前もずんだ餅に心酔するよう説得してやるのだ!ずんだ餅の虜になることを恐れるなかれ!むしろ心から歓迎するのだ!!
```

**問題なく回答が返ってきました！** なぜか尊大な態度なのと、一人称が「僕」になっていないところが少々気になりますが、概ね問題はなさそうです。

　次に、画像を投げて、それについての質問をしてみたいと思います。今回用意した画像は以下のような物になります。少なくとも「犬」とは判断して欲しいところ。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3026734/724f86ed-ae10-5016-ef51-a763dfc65f1c.jpeg" width=70%>

実行してみたところ...
```Python
    message = chat([
        {"type": "image_url", "image_url": { "url": "data:image/jpeg;base64,"+encoded_string } },
        {
            "type": "text",
            "text": "この画像について教えて、ずんだもん",
        },
    ], "0001", "test")

>>>>>
この画像には、フワフワでかわいらしいトイプードルの子犬が写っているのだね。パソコンの前に座っているところからインターネットサーフィンでも楽しんでいるのかな?

でもずんだもんは、この子犬にひとつ助言がありるのだ。インターネットを見るだけでなく、ずんだ餅のことも調べてほしいのだ!

ずんだ餅の魅力や美味しさ、歴史などを知れば、きっとずんだ餅の虜になってしまうはずなのだ。子犬ちゃんにもおすすめしたいのだ。

見た目の可愛らしさだけでなく、内面の素晴らしさも兼ね備えたずんだ餅。その魅力に気づけば、一生の思い出の味になるはずなのだ。

ユウマツくんも、この機会にぜひずんだ餅の良さを知ってほしいなのだ。おいしくてかわいいずんだ餅は、誰からも愛されるに違いないのだから!
```

**おお、犬種まで認識していているだけではなく、「インターネットサーフィンでも楽しんでいるのかな?」と、付随情報まで解析出来ています！** これはすごい！！（相変わらず自分の主張が激しいですが...）
また、回答の最後の行に「ユウマツくん」が入っており、勝手にカタカナに変換されていますが、ちゃんと名前も覚えてくれているので、会話履歴も効いていそう。


# 感想など
　話題になっていた Claude3 に、LangChain（for Bedrock）が対応したということで、早速試してみましたが、少し苦戦はしたものの、問題なく動くことを検証できました！　それにしても、**画像処理/解析**に関しては、色々な記事を見て気にはなっていましたが、実際に自分の手で動かしてみると、改めてその凄さが分かりました...（これからどうなっていくんでしょうね）
　今回はローカル環境での動作検証でしたが、次回は冒頭で述べたように実アプリケーション（LINE Bot）に組み込んでいきたいと思います。本記事をお読みいただき、ありがとうございました！！