API Gateway＋LambdaでBedrock を使った生成AIのAPIを実装する

## 記事の概要
Bedrock APIを実行するPythonコードをLambdaにデプロイし、API Gateway 経由で実行できるようにするまでの手順を紹介します。  

- 前提として、実行するLLMがBedrockの「モデルアクセス」でアクセス付与されていることを確認してください。  

![image (22).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/8d32ec67-2cec-d79c-da34-7aefa5d96477.png)


## ソースコードの用意 
まずは実行するソースコードを用意します。今回は公式ドキュメントに記載されている[サンプルコード](https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/bedrock-runtime_example_bedrock-runtime_ConverseStream_AnthropicClaude_section.html)をLambdaで動かしてみたいと思います。  

LambdaでPythonコードを実行する場合、Lambda関数ハンドラーを定義する必要があるため、今回はサンプルコードの処理をそのままハンドラー関数として定義しました。`lambda_handler()` が関数のデフォルト名です。  

:::note warn  
実際の開発ではLambdaハンドラーとコアロジックは分離することが推奨されます
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-handler.html#python-best-practices  
:::   

その他、実行環境に合わせてリージョン、モデルID、プロンプトをサンプルコードから変更しています。実際のコードは[記事の最後](https://qiita.com/e52yamada/items/5f72d6bf37eabf3c65a5#pythonコード)に記載しています。

### model-idの確認方法  
指定するLLMのmodel-idの確認方法ですが、現状はAWS CLIで確認するしかないと思われます。他の方法(ドキュメント等で確認)をご存じの方はぜひ教えてくださいmm  

今回はCloudShellを起動して、以下のCLIを実行して確認しました。
```sh
aws bedrock list-foundation-models | \
jq -r '.modelSummaries[] | select(.inferenceTypesSupported[] == "ON_DEMAND") | [
.modelName,
.providerName,
.modelId
] | @tsv' | column -t -s $'\t'
```  

テーブル形式で出力するように指定しているので、以下のように結果が表示されます。 今回はclaude3.5 sonnetを指定しました。  

![図1-1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/6c3cb0ba-235e-1aab-4799-83de6fee5fd7.png)


## Lambdaの作成
AWSマネジメントコンソールでLambda関数を作成します。
- Lambdaの画面で「関数を作成」ボタンをクリック
- 関数名は任意の名前、ランタイムは「Python 3.13」を指定、その他の設定はデフォルトのまま  

![image (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/b286e95f-e913-4a23-e295-299954a562d8.png)


これでLambda関数が作成されました。BedrockのLLMを実行するために、デフォルトの設定からIAMロールとタイムアウトの設定を変更します。

### IAMロールの変更
今回デプロイするコードでは、Bedrock APIを実行するため、LambdaがBedrockへアクセスできるようにIAMロールをデフォルトの設定から変更します。  
- 作成されたLambda関数の画面の下部で「設定」タブ>「アクセス権限」をクリックし、ロール名のリンクをクリック

![image (6).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/df1c9af4-555d-557c-ff68-0fc83d4bda24.png)


- IAMロールの画面に遷移するので、「許可」のタブで「許可を追加」> 「ポリシーをアタッチ」をクリック  

![image (7).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/3bb69c3a-7a51-af39-b42f-24064dfe4b56.png)


- アタッチするポリシーとして「AmazonBedrockfullAccess」という名前のマネージドポリシーを選択  

![image (8).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/bb685390-e03d-d9d1-69d0-e7bf1970cc2a.png)


:::note warn
実際の開発では最低限のアクセス許可のみを追加することが推奨されます  
::: 

### タイムアウトの変更
デフォルトのままだとタイムアウトが3秒ですが、LLMからの回答にはもう少し時間がかかるのでタイムアウトを変更します。
- 作成されたLambda関数の画面の下部で「設定」タブ>「一般設定」をクリックし、「編集」ボタンをクリックして、タイムアウトを1分に変更

![image (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/ff5e6944-99b9-7c91-574d-5e2bc59fe3cb.png)


### デプロイとテスト

ここまでで、Lambdaの設定が完了したので、Lambdaをデプロイしてみます。  
- 「コード」タブの左側の「Deploy」ボタンをクリック  

![image (9).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/45c2d25d-9e70-eed3-a162-f25b6b5c4fa0.png)


デプロイしたLambda関数の動作確認としてテストを行います。
- 「テスト」タブでイベント名に任意の名前を入力し、「テスト」ボタンをクリック   

![image (10).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/c6732ef9-4181-2880-1b7b-dd90a0f7dded.png)


成功すると、以下のように結果が表示されます。  

![image (11).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/14821153-3e9c-c645-ef65-6bfaa680ffdb.png)


## API Gatewayの作成
ここまででLambda関数はデプロイされましたが、インターネット経由でAPIとして実行できる形にはなっていないため、API GatewayでAPIとして公開できるように設定していきます。

- API Gatewayの画面で「HTTP API」>「構築」ボタンをクリック
- 統合で「Lambda」を選択し、作成したLambda関数を指定する。API名は任意の値を指定

![image (12).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/d21ace35-b84a-6f85-50e6-18d3dca00613.png)


- デプロイしているコードはレスポンスの取得のみなので、メソッドは「GET」を指定(ANYでも可)  

![image (13).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/43bcab64-aca6-cfbe-8ccf-cd7dd58dc935.png)


- その他はデフォルトのまま「作成」ボタンをクリック  

これでAPI gatewayの設定は完了です。  

### APIの実行  
実際にAPIとして実行してみたいと思います。実行する際のURLは、以下のように確認します。
- 作成したAPIの画面で、左側のセクションで「Deploy」> 「Stage」をクリックし、ステージで `$default` を選択  

![image (14).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/a18c3530-6992-7d02-31f0-9387dea4bac6.png)


「URLを呼び出す」の下にURLが記載されています。確認したURLに`/<設定したAPI名>`を付与して、ブラウザなどで実行します。CloudShellなどの環境から `curl` コマンドを実行してもOKです。  

実行すると以下のようにレスポンスが表示されます。  

![image (15).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/8d567495-9b2c-1881-5b64-a690addb154d.png)


## LLMへの問い合わせをリクエストボディに含める
ここまでで、BedrockのLLMからの回答を取得するAPIを実装しました。実装したソースコードには、あらかじめLLMへの問い合わせ内容が定義されていますが、実際にはAPIを実行する際に指定させる形式が多いです。  

ここからは、API実行時のリクエストボディにLLMへの問い合わせ内容を含めて、その問い合わせ内容へのLLMの回答を取得するAPIに変更していきます。

### Pythonコードの変更  
Lambdaのソースコードは以下のように、変数 `prompt` 部分を変更します。
```diff_python:ソースコード変更箇所
-    prompt = "Bedrockは大阪リージョンで利用可能ですか"
+    prompt = event['body']
```
変更前は事前にテキストで問い合わせを定義していましたが、変更後は `event['body']` でAPI実行時のリクエストボディから、問い合わせ内容を取得します。
ソースコード全量は[記事の最後](https://qiita.com/e52yamada/items/5f72d6bf37eabf3c65a5#python%E3%82%B3%E3%83%BC%E3%83%89)に掲載しています。（変更箇所は上記のdiffのみです）  

### Lambdaテストイベントの作成
Lambdaのソースコードをデプロイしたら、API Gatewayの設定変更に移ってもよいですが、一度テストで動作確認したいと思います。  

リクエストボディの値を受け取るようなソースコードに変更したので、それに合わせてLambdaのテストの内容も変更します。
- 「テスト」タブで「新しいイベントを作成」を選択し、イベント名は任意の名前を設定し、テンプレートに「apigateway-http-api-proxy」を選択

![image (16).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/029cf67f-bc03-230a-2938-cd308af47a4d.png)


- イベントJSONに以下を入力
```json
{
  "version": "2.0",
  "routeKey": "$default",
  "rawPath": "/path/to/resource",
  "rawQueryString": "parameter1=value1&parameter1=value2&parameter2=value",
  "cookies": [
    "cookie1",
    "cookie2"
  ],
  "headers": {
    "Header1": "value1",
    "Header2": "value1,value2"
  },
  "queryStringParameters": {
    "parameter1": "value1,value2",
    "parameter2": "value"
  },
  "requestContext": {
    "accountId": "123456789012",
    "apiId": "api-id",
    "authentication": {
      "clientCert": {
        "clientCertPem": "CERT_CONTENT",
        "subjectDN": "www.example.com",
        "issuerDN": "Example issuer",
        "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1",
        "validity": {
          "notBefore": "May 28 12:30:02 2019 GMT",
          "notAfter": "Aug  5 09:36:04 2021 GMT"
        }
      }
    },
    "authorizer": {
      "jwt": {
        "claims": {
          "claim1": "value1",
          "claim2": "value2"
        },
        "scopes": [
          "scope1",
          "scope2"
        ]
      }
    },
    "domainName": "id.execute-api.us-east-1.amazonaws.com",
    "domainPrefix": "id",
    "http": {
      "method": "POST",
      "path": "/path/to/resource",
      "protocol": "HTTP/1.1",
      "sourceIp": "192.168.0.1/32",
      "userAgent": "agent"
    },
    "requestId": "id",
    "routeKey": "$default",
    "stage": "$default",
    "time": "12/Mar/2020:19:03:58 +0000",
    "timeEpoch": 1583348638390
  },
  "body": "bedrockは東京リージョンで利用可能ですか",
  "pathParameters": {
    "parameter1": "value1"
  },
  "isBase64Encoded": true,
  "stageVariables": {
    "stageVariable1": "value1",
    "stageVariable2": "value2"
  }
}
```  

これでテストを実行すると、イベントJSONの `"body"` の値に対する回答が表示されます。  

![image (17).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/6e7d1233-882c-7bb1-025e-8d09a042aa65.png)


イベントJSONでは、Lambdaを実行した際にLambda関数へ渡されるイベントを定義しています。API Gateway+Lambdaの構成の場合、Lambdaには`event.body` の形式(Pythonコードでは `event['body']` で記載)でリクエストボディが渡されるため、`"body"` の値として `"bedrockは東京リージョンで利用可能ですか"` を定義しています。

### API Gatewayの変更  
ソースコードのデプロイ・テストができたので、API Gatewayの設定を変更します。  

最初はメソッドでGETを指定していましたが、リクエストボディを受け取るためPOSTに変更します。※はじめに「ANY」メソッドを指定している場合は変更は不要です  
- 作成したAPIの画面で、「Develop」>「Routes」>「GET」メソッドを
クリックし、「編集」ボタンをクリック  

![image (19).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/3814f146-91ab-d179-c242-da4ee74b10ee.png)


- メソッドを「POST」に変更し、「保存」ボタンをクリック  

![image (20).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/5f03074f-fb12-9b5b-179b-0dcca9120acc.png)


### APIの実行
APIを実行してみます。今回はリクエストボディを指定する必要があるため、CloudShellを起動して curl コマンドを実行しました。

```sh
curl -X POST -H "Content-Type: application/json" -d '{"<問い合わせ内容>"}' <APIのURL>/<API名>
```  

「Bedrockは東京リージョンで利用可能ですか」という問い合わせをしたところ、以下の回答がえられました。  

![image (21).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261789/bbf5429a-8383-0f85-a606-bf7c0583db43.png)

## Pythonコード
- はじめにデプロイしたPythonコードは以下です。    

```py
import boto3
import json
from botocore.exceptions import ClientError

def lambda_handler(event, context):
    # Create a Bedrock Runtime client in the AWS Region of your choice.
    client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
    # Set the model ID
    model_id = "anthropic.claude-3-5-sonnet-20240620-v1:0"
    # Define the prompt for the model.
    prompt = "Bedrockは大阪リージョンで利用可能ですか"
    # Format the request payload using the model's native structure.
    native_request = {
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": 512,
        "temperature": 0.5,
        "messages": [
            {
                "role": "user",
                "content": [{"type": "text", "text": prompt}],
            }
        ],
    }
    # Convert the native request to JSON.
    request = json.dumps(native_request)
    try:
        # Invoke the model with the request.
        response = client.invoke_model(modelId=model_id, body=request)
    except (ClientError, Exception) as e:
        print(f"ERROR: Can't invoke '{model_id}'. Reason: {e}")
        exit(1)
    # Decode the response body.
    model_response = json.loads(response["body"].read())
    # Extract and print the response text.
    response_text = model_response["content"][0]["text"]
    print(response_text)
    
    return response_text
```  

- 変更後のPythonコードは以下です   

```py
import boto3
import json
from botocore.exceptions import ClientError

def lambda_handler(event, context):
    # Create a Bedrock Runtime client in the AWS Region of your choice.
    client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
    # Set the model ID
    model_id = "anthropic.claude-3-5-sonnet-20240620-v1:0"
    # Define the prompt for the model.
    prompt = event['body']
    # Format the request payload using the model's native structure.
    native_request = {
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": 512,
        "temperature": 0.5,
        "messages": [
            {
                "role": "user",
                "content": [{"type": "text", "text": prompt}],
            }
        ],
    }
    # Convert the native request to JSON.
    request = json.dumps(native_request)
    try:
        # Invoke the model with the request.
        response = client.invoke_model(modelId=model_id, body=request)
    except (ClientError, Exception) as e:
        print(f"ERROR: Can't invoke '{model_id}'. Reason: {e}")
        exit(1)
    # Decode the response body.
    model_response = json.loads(response["body"].read())
    # Extract and print the response text.
    response_text = model_response["content"][0]["text"]
    print(response_text)
    
    return response_text
```  
---
この記事はここまでとなります。