【AWS Skill Builder】『Building Generative AI Applications Using Amazon Bedrock』コースをやってみた - その 1

## はじめに
[AWS Skill Builder](https://aws.amazon.com/jp/training/digital/)で生成AIアプリケーションの学習をしていたところ、『[Building Generative AI Applications Using Amazon Bedrock](https://explore.skillbuilder.aws/learn/courses/17904/building-generative-ai-applications-using-amazon-bedrock/lessons)』というコースに出会いました。このコースでは、実際のコードでアプリケーションを動かせるデモがあり、アプリケーション開発の理解にとても役立ちます。

しかし、まだ**英語版のみ**なので、紹介がてら日本語で内容をまとめてみようと思います。

### 本記事の説明
- コース上のデモの内容に基づき、デモで扱われる知識・技術の説明をします
- コースにある資料の**完全な日本語訳ではありません**
- デモの概要の説明はしますが、**デモの具体的な実施やコードの記載はありません**
    - 無料のコースなので、Skill Builderに登録すれば、すぐにデモのリソースにアクセスできます
- この題のトピックは「その1~その3」の3部構成です
    **その 1（FM編）：この記事**
    その 2（RAG編）：[投稿済み](https://qiita.com/tripleO/items/4c342195027f9e37e1a0)
    その 3（Agent編）：[投稿済み](https://qiita.com/tripleO/items/cf01d9b8bbb0a50b6c10)

### コースの概要
コースのコンテンツは、
<dl>
  <dt>Module 1~4</dt>
  <dd>BedrockやLangChainに関するレッスン</dd>
  <dt>Demo 1</dt>
  <dd>LLMを用いた簡単なデモ</dd>
  <dt>Module 5</dt>
  <dd>RAGに関するレッスン</dd>
  <dt>Demo 2</dt>
  <dd>RAGを用いたデモ</dd>
  <dt>Module 6</dt>
  <dd>エージェントに関するレッスン</dd>
  <dt>Demo 3</dt>
  <dd>エージェントのデモ</dd>
</dl>

となっており、「知識の習得 → それに基づくデモ」という流れが3つある構成です。（記事もそれに倣います。）一応コースレベルとしては、*Advanced*となっており、生成AIやAWSに関する予備知識が多少必要です。

:::note
生成AIの基盤モデルに関しては以前[こちらの記事](https://qiita.com/tripleO/items/ff6cd2e13f22f565db34)でまとめています
:::

デモでは、実際にAWSのリソースを作成し、Amazon SageMaker上のJupyter環境でコードを実行します。コースは無料ですが、デモ内で利用するAWSリソースには普通に**料金が発生します**。
自分は費用をなるべく抑えたかったので、コース上の手順とは別の方法を試しました。それらも補足情報として記載したいと思います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3946195/976905fa-6584-4594-97cf-62a59bbf7e5e.png" width="300">

↑私がコースのデモを完了するまでにかかったコストは、**$0.19**でした
（追記）
*Pinecone*のAPIKeyの保存に使用した*Secrets Manager*が無料枠だったらしく、加算に漏れていました。執筆時点で1シークレットあたり、$0.40 / 月（日割り有）でその分の課金も来ます。

## BedrockとLangChainについて
その1の記事では、コースの***Demo 1***で登場する知識をまとめます。

### APIメソッド
#### *InvokeModel*
Amazon Bedrockのモデルにリクエストを送り、生成された回答を得るには、`InvokeModel`メソッドを使用します。

```python:example.py
import boto3
import json

bedrock_client = boto3.client('bedrock-runtime')
prompt = "What is LLM?" # LLMに送るユーザーの質問
body = json.dumps({
    "inputText": prompt, 
    "textGenerationConfig": {
        "maxTokenCount": 8192,
        "stopSequences": [],
        "temperature": 0,
        "topP": 1
    }
})

response = bedrock_client.invoke_model(
     body = body,
     modelId = 'amazon.titan-text-express-v1', # 回答生成させるモデルのID
     accept = 'application/json',
     contentType = 'application/json'
)
response_body = json.loads(response.get('body').read())
print(response_body.get('results')[0].get('outputText'))
```
```:出力結果
LLM stands for Large Language Model. It is a neural network architecture that is trained on large amounts of text data to generate human-like text. LLMs are used in a variety of applications, including chatbots, virtual assistants, and language translation.
```
ドキュメント → [invoke_model - Boto3 1.36.21 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-runtime/client/invoke_model.html)

#### *InvokeModelWithResponseStream*
`InvokeModel`メソッドと同様に、モデルの呼び出しを行うメソッドですが、`InvokeModelWithResponseStream`では、レスポンスがストリーム形式で返ってきます。この形式は、モデルの出力を早く取得したい場合や、より長い文章を生成させる場合に役立つそうです。

以下の例のように、回答がいくつかのチャンクに分割され、ひとつのチャンクの出力は途中で途切れています。

```python:example.py
prompt = "Write a tech blog on LLM usin 10 sentences."
# inputの他の設定はinvoke_modelと同じなので省略
response = bedrock_client.invoke_model_with_response_stream(...)
stream = response.get('body')
if stream:
     for event in stream:
          chunk = event.get('chunk')
          if chunk:
               output = json.loads(chunk.get('bytes').decode()).get('outputText')
               print(output)
```
```:出力結果
Artificial intelligence (AI) has made significant strides in recent years, with one of the most exciting developments being the rise of large language models (LLMs). LLMs are computer programs that can understand and generate
```
ドキュメント → [invoke_model_with_response_stream - Boto3 1.37.4 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-runtime/client/invoke_model_with_response_stream.html)

### 推論のパラメータ
基盤モデル（FM）にリクエストを送る際、いくつかの**推論パラメータ**を設定することでFMのレスポンスを調整することができます。ここでは5つの代表的なパラメータを紹介します。

| 名前 | 説明 | タイプ | デフォルト値 |
|:-:|:--|:-:|:-:|
| Temperature | 単語選択のランダム性を決める値で、高いほど回答が予想しづらい | `Float` | 0 |
| トップ P | 次に選択されるであろうトークンのうち、出力確率が上位<strong>何%</strong>までを候補にするかを決める値 | `Float` | 1 |
| トップ K | 次に選択されるであろうトークンのうち、出力確率が上位**何個**までを候補にするかを決める値 | `Float` | - |
| レスポンスの最大長 | 生成される回答のトークンの最大の長さ | `Integer` | 512 |
| 停止シーケンス | ここで列挙された単語をモデルが生成すると、そこで生成を停止する | `Array of strings` | - |

参考 → [Amazon Bedrock Influence response generation with inference parameters](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-parameters.html)

:::note warn
すべてのFMがすべてのパラメータに対応している訳ではなく、モデルによって利用可能なパラメータは違う。
:::

### アーキテクチャパターン
#### 大規模なテキスト要約
非常に長いドキュメントの要約をプロンプトに入れてFMに渡そうとすると、コンテキストの制限を超えてしまいリクエストできない、という問題が起こり得ます。それを回避する解決策として、以下のような要約パターンがあります。
1. `map_reduce`
    - 長いドキュメントをいくつかのチャンクに分割し、それぞれを要約した後、すべてのチャンクの要約をまとめて要約する
1. `refine`
    - ドキュメントをチャンクに分けた後、最初のチャンクを要約し、次にその要約と2番目のチャンクの内容を合わせて要約する
    - 上記のプロセスをすべてのチャンクが要約されるまで繰り返す

#### チャット履歴を持つAIアシスタント
このアーキテクチャパターンでは、AIアシスタントがユーザーとのチャット内容をチャット履歴としてストアに保存します。すると、FMはユーザーとの過去のチャット内容がプロンプトから与えられ、それに基づいて回答を生成することができます。
```plantuml
interface User

node "アプリケーション" {
  [AI アシスタント] -> [LLM] : 質問 + チャット履歴
}

database "Chat Store" {
  frame "Chat history" {
    [チャット内容]
  }
}

User --> [AI アシスタント] : 質問
[AI アシスタント] -up-> [チャット内容]
[チャット内容] --> [AI アシスタント]
[LLM] --> User : 回答
```

### LangChain
#### 概要
[**LangChain**](https://python.langchain.com/docs/introduction/)とは、LLMを利用したAIアプリケーションを、構築するためのフレームワークです。簡単にLLMによる機能を開発するための、クラスやメソッドが提供されています。

LangChainは、**Python**/**Typescript**/**Javascript**の3つのプログラミング言語に対応しています。
また、AWSなどのLLMを提供する多くのプロバイダーごとに、独立した統合パッケージがあります。AWSの場合には、[**langchain-aws**](https://python.langchain.com/docs/integrations/providers/aws/)ライブラリが用意されているので、そちらを使っていきます。

#### 基本的な使い方
```python:example.py
from langchain_aws import BedrockLLM

llm = BedrockLLM(
    model_id="amazon.titan-text-express-v1", credentials_profile_name="default"
)

response = llm.invoke(input="Where is the capital of Japan?")
```

#### プロンプトテンプレート
LangChainの`PromptTemplate`を用いると、プロンプトのベースとなるテキストを定義し、後から埋め込まれた変数を代入することで動的にプロンプトを作成できます。これにより、プロンプトの再利用や効率的なLLMの呼び出しが可能になります。

```python:example.py
from langchain_core.prompts import PromptTemplate

prompt_template = PromptTemplate.from_template(
    "Tell me a information about {topic} in {sentence_num} sentences."
)

prompt = prompt_template.invoke({"topic": "cats", "sentence_num": 3})
# → text='Tell me a information about cats in 3 sentences.'
...
response = llm.invoke(prompt)
```

#### チャット履歴
[アーキテクチャパターン](#アーキテクチャパターン)のセクションでも登場した、**チャット履歴を持つAIアシスタント**が、LangChainのライブラリを用いることで実装することができます。

:::note warn
コース内で紹介される`LLMChain`や`langchain.memory`を用いた実装は、最新のv0.3では非推奨のやり方です。
[How to migrate to LangGraph memory | 🦜️🔗 LangChain](https://python.langchain.com/docs/versions/migrating_memory/)
:::

<details><summary><em>LangGraph memory</em>のコード例</summary>

```python:example.py
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langchain_aws import BedrockLLM

llm = BedrockLLM(
    model_id="amazon.titan-text-express-v1", credentials_profile_name="default"
)

# Define the function that calls the model
def call_model(state: MessagesState):
    response = llm.invoke(state["messages"])
    return { "messages": response }

# Define a new graph
workflow = StateGraph(state_schema=MessagesState)
workflow.add_edge(START, "model")
workflow.add_node("model", call_model)

memory = MemorySaver()
app = workflow.compile(
    checkpointer = memory
)

# The thread id is a unique key that identifies this particular conversation.
config = { "configurable": { "thread_id": "abc123" } }

input_message = HumanMessage(content="hi! I'm Bob")
for event in app.stream({ "messages": [input_message] }, config, stream_mode="values"):
    event["messages"][-1].pretty_print()

input_message = HumanMessage(content="what was my name?")
for event in app.stream({ "messages": [input_message] }, config, stream_mode="values"):
    event["messages"][-1].pretty_print()
```
```:出力結果
Bot: Hello, Bob! How can I help you today?
...
Bot: Your name is Bob.
```
[コードの引用元](https://python.langchain.com/docs/versions/migrating_memory/conversation_buffer_memory/)

---
</details>

#### ベクトルストア
LangChainを用いることで、簡単にドキュメントを埋め込みしたベクトルを**ベクトルストア**に保存できます。LLMはそのドキュメントを元に回答を生成します。

1. ドキュメントの内容を分割
    ```python:example.py
    from langchain_community.document_loaders import TextLoader
    from langchain_text_splitters import CharacterTextSplitter
    
    text_documents = TextLoader('programming.txt').load()
    text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
    documents = text_splitter.split_documents(text_documents)
    ```
    &#13;&#10;

1. ドキュメントを埋め込みして、ベクトルストアに保存
    ※[*FAISS*](https://python.langchain.com/docs/integrations/vectorstores/faiss/)というライブラリのベクトルストアを用いた例
    ```python:example.py
    from langchain_aws import BedrockEmbeddings
    from langchain_community.vectorstores import FAISS

    embedding = BedrockEmbeddings(model_id="amazon.titan-embed-text-v1")

    vector_store = FAISS.from_documents(documents=documents, embedding=embedding)
    ```
    &#13;&#10;

1. ベクトルストアを使ってLLMに推論させる
    ```python:example.py
    from langchain.indexes.vectorstore import VectorStoreIndexWrapper
    from langchain_aws import ChatBedrock

    index = VectorStoreIndexWrapper(vectorstore=vector_store)

    chat_llm = ChatBedrock(
        model_id="amazon.titan-text-express-v1",
        credentials_profile_name="default"
    )
    output = index.query("What is Python?", chat_llm)
    ```

#### チェイン
チェインによって、メソッドチェインのように、続けて呼び出される一連の処理を定義できます。以下コード内の`|`は、***pipe operator***といい、チェインの定義に使います。
```python:example.py
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_aws import ChatBedrock

prompt = ChatPromptTemplate.from_template(
    "Tell me a information about {topic} in 1 sentence."
)
model = ChatBedrock(
    model_id="amazon.titan-text-express-v1",
    credentials_profile_name="default"
)

chain = prompt | model | StrOutputParser()
chain.invoke({"topic": "LangChain"})
# → '\nLangChain is a platform that allows users to create and interact with AI agents using natural language.'
```

#### エージェント
AIアプリケーションとしての**エージェント**とは、定義されたアクションやワークフローを使って複雑なタスクをこなすシステムを指します。エージェントは、関数やAPI、RAG、コーディングなどのツールが渡され、ユーザーからのインプットや状況に応じてそれらを使い分け、求められている回答を自律的に生成します。

コースのレッスンにある、`AgentExecutor`を使用した実装はレガシーな手法らしいので[^1]、推奨されている***LangGraph***を用いたコード例を紹介します。
[^1]: https://python.langchain.com/docs/concepts/agents/#legacy-agent-concept-agentexecutor

```python:example.py
from langchain_aws import ChatBedrock
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent

@tool
def magic_function(input: int) -> int:
    """Applies a particular processing to an input.
        Use this function to make sure of an output."""
    if (input % 2) == 0:
        return input // 2
    else:
        return input * 2

tools = [magic_function]

chat_model = ChatBedrock(
    model_id = "amazon.nova-lite-v1:0",
    credentials_profile_name = "dev"
)
langgraph_agent_executor = create_react_agent(chat_model, tools)

query = "what is the output of magic_function(4)?"
messages = langgraph_agent_executor.invoke({"messages": [("human", query)]})

print(messages["messages"][-1].content)
```
```:出力結果
<thinking> The tool has provided the output for magic_function(4). I can now provide this information to the User. </thinking>

The output of magic_function(4) is 2.
```

## Demo 1
### 構成
コースの*Demo 1*には、大きく分けて以下の6つの実装例が含まれています。
1. テキスト生成
    a. Bedrockの呼び出しの基礎
    b. プロンプトテンプレート
1. テキスト要約
    a. テキスト要約の基礎
    b. 大規模なテキスト要約
1. QA回答システムの基礎
1. チャット履歴やベクトルストアを使ったチャットボット
1. コード生成の基礎
1. *LangGraph*を使ったエージェント

:::note
コースには、「セットアップ」と「デモの実施」でそれぞれ手順を示す説明動画があるので、それを見ながら進めることになります。
:::

### 補足事項
- デモのコードをローカルで実行する
    Skill Builderのコースでは、Jupyter Notebookのファイルを[Amazon SageMaker AI](https://aws.amazon.com/jp/sagemaker-ai/?nc=sn&loc=1&refid=862c3ef1-b225-407c-80c7-07996f319a2e)上の環境で実行します。一部無料枠もありますが、それを超過すれば料金が発生します。
    そのため、自分はJupyter Notebookファイルをローカルの環境で実行し、FMの呼び出し料金のみに済ませました。環境構築の方法は[こちらの別記事][link-1]にまとめてますので、参考にしてください。
    <br>

- *Claude 3 Sonnet*のフォーム
    デモの環境準備の手順で、BedrockのFMを有効化します。デモの説明動画内にはありませんが、現在では*Claude 3 Sonnet*モデルを有効化する際、フォームにユースケースの詳細を記載する必要があります。
    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3946195/7b6fd7aa-c1cd-44e4-824c-3ca95f7104b5.png" width="600">
    <br>

- *Amazon Titan*モデルの有効化
    前述の記事内でも言及していますが、*Amazon Titan*モデルは一度アクセスを有効化すると、取り消すことはできないです。

## おわり
コード例のためにいい感じの質問を考えるのが難しかったです。

その2に続きます。

https://qiita.com/tripleO/items/4c342195027f9e37e1a0

## 参考リンク
https://qiita.com/elle-dia/items/c73a7074ce87a4af48f6

https://qiita.com/simakihiro/items/ac4074f5f5e632fae895

https://zenn.dev/tsuzukia/articles/05bfdcfcf5bd68

[link-1]: https://qiita.com/tripleO/items/4a53e4dd90293c474927