Amazon BedrockとLangChain(LCEL記法)によるLLMアプリケーション開発ことはじめ

1. はじめに(この記事は何か)

Amazon BedrockとLangChainを用いたアプリケーション開発について、LangChainでどのようなことができるかを軸にして解説します。

2. なぜ書くか

「Bedrock」と調べると、AgentsやKnowledge basesを含め、AWSが提供しているインターフェースを使用しての記事がまだまだ多くあります。
しかし、後述するLangChainというLLMのフレームワークを使用することで、LLMアプリケーション開発がより簡素にできるようになるということをお伝えしたいです。

3. LangChainとは何か

LangChainは、LLM(大規模言語モデル)アプリケーション開発のフレームワークであり、LLMアプリケーション開発に必要な部品を、抽象化したモジュールとして提供しています。
LangChainを使用することで、以下のようなメリットがあります。

  • LLMアプリケーションの抽象化、簡素化
    LangChainは、LLMとのインタラクションを抽象化し、複雑なAPI呼び出しやデータ処理の詳細を隠蔽します。これにより、開発者はモデルの統合や応用に集中でき、実装の複雑さを低減できます。

  • LLMアプリケーションコードの、再利用性の強化
    既存のコンポーネントを簡単に再利用し、カスタマイズできます。これにより、開発時間を短縮し、異なるプロジェクト間での知識の移転を容易にします。

  • コミュニティとサポート
    LangChainはオープンソースプロジェクトであり、活発なコミュニティと豊富なリソースが利用可能です。これにより、問題の解決、アイデアの共有、最新のベストプラクティスの学習が容易になります。

4. LangChainを学ぶことはなぜ重要なのか

LLMアプリケーション開発は、かなり新しい分野です。
新しい手法が次々と確立され、LLM自体がどんどんアップデートされていますが、LangChainはこのようなアップデートに素早く追従し、日々アップデートが繰り返されています。
そのため、LangChainを学ぶことは、LLMを用いた開発のトレンドを理解することにつながります。

5. 事前準備

5.1. Cloud9

記事を読んでくださっている皆さんとの環境差分を最小限にとどめるため、 開発は、AWS上で利用できる統合開発環境AWS Cloud9上で行います。 以下を参考にして、Cloud9のセットアップを行ってみてください。

docs.aws.amazon.com

5.2. モデルアクセス

Bedrockで用意されているモデルは最初から利用できるわけではありません。 Bedrockで使用可能なモデルについて、アクセス管理を行い、利用可能な状態にします。 docs.aws.amazon.com

5.3. Python関連(オプション)

Cloud9では初めからPythonが使用できます。
しかし、実際の開発では、プロジェクトごとに複数のPythonのバージョンを切り替えたり、 パッケージ、依存関係を管理したりします。
「pip install」でもパッケージを追加できますが、 環境をきれいに使用したい場合は、必要に応じて、以下をインストールしてください。

  • pyenv

github.com

  • Poetry

python-poetry.org

Poetryまたはpipを使用して、パッケージをインストールしてください。 今回使用するのは以下です。

boto3 = "1.34.60"
langchain = "0.1.11"
langchain-core = "0.1.30"
langchain-community = "0.0.27"

これで事前準備は完了です。 Poetryを使用している場合、以下のコマンドを実行したうえで、pythonを実行してください。

poetry shell

6. Bedrockの生APIとLangChainの使用感比較(1)

6.1. Bedrockの生APIを使用した方法

// app.py
import boto3
import json
bedrock = boto3.client(service_name='bedrock-runtime')

dish = "カレー"
prompt = f"\n\nHuman:料理のレシピを教えてください。{dish}\n\nAssistant:"
body = json.dumps({
    "prompt": prompt,
    "max_tokens_to_sample": 300,
    "temperature": 0.1,
    "top_p": 0.9,
})

modelId = 'anthropic.claude-v2:1'
accept = 'application/json'
contentType = 'application/json'


def invoke() :
    response = bedrock.invoke_model(body=body, modelId=modelId, accept=accept, contentType=contentType)
    response_body = json.loads(response.get('body').read())
    print(response_body.get('completion'))
    
invoke()

出力結果

python app.py
 カレーのレシピをご紹介します。

材料(4人分)
・にんじん 1本
・じゃがいも 2個 
・玉ねぎ 1個
・牛肉 300g
・カレールー 150g 
・水 500ml

作り方
1. にんじんは薄切り、じゃがいもは1cm角に切る
2. 玉ねぎはみじん切りにする
3. 鍋に油を熱し、にんじん、じゃがいも、玉ねぎを炒める
4. 牛肉を加えて炒める
5. 水とカレールーを加え、煮込む
6. じゃがいもが柔らかくなったら完成

コツ:
- にんじん、じゃがいもは柔らかくなるまで煮込む
- スパイスの量で辛さを調整できます

6.2. LangChainを使用した方法

LangChainでは、LCELという記法を利用して、処理を記述します。
LCEL とは、プロンプトや LLM を | で繋げて書き、処理の連鎖 を表す記法、およびInterfaceを指します。
以降、処理の連鎖のことを便宜的に「Chain」と呼びます。*1
また、LCELがどのように実現されているかは、以下を参考にしてみてください。

python.langchain.com

// app.py
from langchain.globals import set_debug
from langchain_community.llms import Bedrock
from langchain_core.prompts import PromptTemplate

prompt = PromptTemplate.from_template("""
以下の料理のレシピを教えてください。
料理名:{dish}
""",
)


# LLMの定義
LLM = Bedrock(
    model_id="anthropic.claude-v2:1",
    model_kwargs={"max_tokens_to_sample": 1000},
)

def invoke() :
    # chainの定義
    chain = prompt | LLM
    
    # chainの実行
    answer = chain.invoke({"dish": "カレー"})
    print(answer)
    
invoke()

ポイントは以下です。

chain = prompt | LLM

このように、コンポーネント(Chainするための各要素)を | でつなぐことで、 宣言的に、また、簡潔に記述できます。
ここでは、PromptTemplate(LLMに渡すためのプロンプト)*2と、LLMそのものをコンポーネントとして、Chainしています。 また、この実装はBedrockには依存せず、例えば、OpenAIが用意しているモデルを利用する際にも可能なものになっています。

出力結果

python app.py
材料(4人分)
・にんじん 1本
・じゃがいも 2個 
・お肉 300g(牛肉や鶏肉)
・玉ねぎ 1個
・油 大さじ1 
・カレールー 小さじ4-5 
・水 400ml

作り方
1. にんじんは薄切り、じゃがいもは1cm角の大きさに切る
2. 鍋に油を熱し、にんじんとじゃがいもを炒める
3. お肉と玉ねぎを加えてさらに炒める
4. カレールーと水を加え、煮込む
5. 火が通ったら完成

参考にするスパイス:
・シナモン、クローブ、コリアンダー、カルダモンなど好みで追加

付け合せにはご飯やナンがおすすめです。アレンジ次第で様々なバリエーションが楽しめます。

7. Bedrockの生APIとLangChainの使用感比較(2)

(1)のような簡単な実装では、そこまでLangChainの恩恵を理解できなかったと思います。

この章では、LangChainを使用することでどのような恩恵を享受できるかを、利用できるモジュール、そして、記法による2つの観点で解説していきます。

7.1. OutputParser

OutputParserはLLM の出力を取得し、それをより適切な形式に変換するモジュールです。これは、LLM を使用してあらゆる形式の構造化データを生成する場合に非常に便利です。
ここでは、OutputParserの一種、 PydanticOutputParserについて解説します。
このOutputParserを使用すると、ユーザーは任意の Pydantic モデルを指定し、そのスキーマに準拠する出力について LLM をクエリできます。
(1)で使用した質問について、回答を特定のオブジェクトに変換してみましょう。

// app.py
import recipe
from langchain.globals import set_debug
from langchain_community.llms import Bedrock
from langchain_core.prompts import PromptTemplate
from langchain.output_parsers import PydanticOutputParser

output_parser = PydanticOutputParser(pydantic_object=recipe.Recipe)

prompt = PromptTemplate.from_template("""
以下の料理のレシピを教えてください。

{format_instructions}

料理名:{dish}
""",
partial_variables={"format_instructions": output_parser.get_format_instructions()},
)

# LLMの定義
llm = Bedrock(
    model_id="anthropic.claude-v2:1",
    model_kwargs={"max_tokens_to_sample": 1000},
)



def invoke() :
    # chainの定義
    chain = prompt | llm | output_parser
    
    # chainの実行
    answer = chain.invoke({"dish": "カレー"})
    print(type(answer))
    print(answer)
    
invoke()
// recipe.py
from langchain_core.pydantic_v1 import BaseModel, Field, validator

class Recipe(BaseModel):
    ingredients: list[str] = Field(description="料理の材料")
    steps: list[str] = Field(description="料理の作り方")

ポイントは、「format_instructions」周辺です。

output_parser = PydanticOutputParser(pydantic_object=recipe.Recipe)

prompt = PromptTemplate.from_template("""
以下の料理のレシピを教えてください。

{format_instructions}

料理名:{dish}
""",
partial_variables={"format_instructions": output_parser.get_format_instructions()},
)

PromptTemplate.from_templateに指定した「partial_variables」はプロンプト中の変数に対して、静的に値を埋めるための指定です。
また、埋め込み対象のformat_instructionsに指定している「output_parser.get_format_instructions()」の中身は以下のようになっています。

The output should be formatted as a JSON instance that conforms to the JSON schema below.

As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}
the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.

Here is the output schema:

{"properties": {"ingredients": {"title": "Ingredients", "description": "\u6599\u7406\u306e\u6750\u6599", "type": "array", "items": {"type": "string"}}, "steps": {"title": "Steps", "description": "\u6599\u7406\u306e\u4f5c\u308a\u65b9", "type": "array", "items": {"type": "string"}}}, "required": ["ingredients", "steps"]}

これは、Recipeクラスをもとに、出力形式を指定するものになっています。
プロンプト側の対応に加え、chainを定義する箇所でも、オブジェクトの変換処理を入れる(chainに追加する)必要があります。

chain = prompt | llm | output_parser

では、これらを踏まえ実行してみます。
出力結果

python app.py
<class 'recipe.Recipe'>
ingredients=['にんじん', 'じゃがいも', 'たまねぎ', 'ひき肉', 'カレー粉', 'ガーリック', '水'] steps=['にんじん、じゃがいも、たまねぎを切る', 'ひき肉とガーリックを炒める', 'カレー粉を入れて炒める', '水を入れて煮込む', '野菜を入れて煮込む']

Recipeクラスのオブジェクトを取得することができました。

7.2. ChainとChainをつなぐ

ChainとChainをつなぐような記述も可能です。 例えば、ユーザーからの入力に対して、LLMがある整形を施した上で、 最終的な回答をLLMに出力させる、といったケースで有効です。

from langchain.globals import set_debug
from langchain_community.llms import Bedrock
from langchain_core.prompts import PromptTemplate
from langchain.output_parsers import PydanticOutputParser



pre_prompt = PromptTemplate.from_template("""
以下の質問を単語に区切ってください。
質問:{question}
"""
)

answer_prompt = PromptTemplate.from_template("""
以下の質問に回答してください。
質問:{formatted_question}
"""
)

# LLMの定義
llm = Bedrock(
    model_id="anthropic.claude-v2:1",
    model_kwargs={"max_tokens_to_sample": 1000},
)



def invoke() :
    # chainの定義
    chain = {"formatted_question": (pre_prompt | llm) } | answer_prompt | llm
    
    # chainの実行
    answer = chain.invoke({"question": "東京都の人口について教えてください。"})
    print(answer)
    
invoke()
python your-app.py
 東京都の人口についてですが、

東京都の人口は約1,400万人と日本で最も多く、世界でもニューヨーク、メキシコシティに次いで3番目に多い巨大都市です。 

より詳細な内訳としては、

- 2022年10月1日時点の東京都の人口は約13,960,000人
- 23区内の人口は約9,358,000人
- 東京23区のうち最も人口の多い区は渋谷区の約24万人
- 最も人口の少ない区は島しょ部の大島町の約2,200人

といったデータがあります。

以上、東京都の人口の概要についてご説明させていただきました。不明な点があれば遠慮なくお聞きください。

LLMを使用して複雑な処理を記述する際、
Bedrockを素で使用すれば、invoke_modelメソッドを何度も明示的に呼ぶ必要があります。
LangChain(LCEL)を使用し、ChainとChainをつなぐことで、簡潔に処理を記述できます。

8. おわりに

この記事では、LangChainでの記法、および一部の機能しか紹介していません。 前述のとおり、LangChainのアップデートを追従することは、LLMのアップデートを追従することに等しいです。 LangChainでは、今回紹介した以上に膨大なことが出来ます。 ぜひ、ご自分でLangChainについてキャッチアップしてみて、楽しいLLMライフを送りましょう。

*1:LCEL以前は、実際に「~Chain」というモジュールがあったため、相当するものとしてそう呼びます。

*2:https://api.python.langchain.com/en/latest/prompts/langchain_core.prompts.prompt.PromptTemplate.html