LINEからのBedrockエージェント呼び出し①

Agents for Amazon Bedrockの振る舞いがなんとなく分かってきたのでLINEから呼び出してみます。まずは基本形として（？）LINEから自然言語で乗換検索を行えるようにします。

<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/826a00c0-58fe-d11a-3069-c746f59a84d8.jpeg">　<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/e656433c-7933-9fe8-fa3c-d8c6acf9e996.jpeg">

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/fc1aa428-583b-34dc-b0b4-f818e3395724.png)

過去に作ったものを流用しているものが多いですが、少し手を入れている部分もあるので関係ある部分を一通り掲載（再掲）します。

# 乗換案内用のLambda関数の作成

requestsとbeautifulsoup4をLambdaレイヤーで追加し、以下のLambda関数を作成します。

```Python
import requests
from bs4 import BeautifulSoup
import json

def lambda_handler(event, context):

	print(event) # パラメータの出力（確認用）
	
	# パラメータの取得
	parameters = event.get('parameters')
	dep_station = next(p for p in parameters if p['name'] == 'dep_station')['value']
	dest_station = next(p for p in parameters if p['name'] == 'dest_station')['value']
	yyyy = next(p for p in parameters if p['name'] == 'yyyy')['value']
	mm = next(p for p in parameters if p['name'] == 'mm')['value']
	dd = next(p for p in parameters if p['name'] == 'dd')['value']
	H = next(p for p in parameters if p['name'] == 'H')['value']
	M = next(p for p in parameters if p['name'] == 'M')['value']
	type = next(p for p in parameters if p['name'] == 'type')['value']
	
	# Yahoo乗換案内のURL設定
	url = "https://transit.yahoo.co.jp/search/print?from="+dep_station+"&to="+dest_station+"&y="+yyyy+"&m="+mm+"&d="+dd+"&hh="+H+"&m1="+M[:1]+"&m2="+M[1:]+"&type="+type+"&ticket=ic&expkind=1&userpass=1&ws=3&s=0&al=1&shin=1&ex=1&hb=1&lb=1&sr=1"
	print(url) # 確認用
	
    # 乗換案内のページからメイン部分のみ取得
	page = requests.get(url).text
	body = str(BeautifulSoup(page, 'html.parser').select('#srline'))

	# agentの形式でreturn
	response_body = {"application/json": {"body": body}}
	action_response = {
		"actionGroup": event["actionGroup"],
		"apiPath": event["apiPath"],
		"httpMethod": event["httpMethod"],
		"httpStatusCode": 200,
		"responseBody": response_body,
	}
	api_response = {"messageVersion": "1.0", "response": action_response}
	return api_response
 ```
念の為にタイムアウトを30秒ほどに伸ばしておきます。

以下の感じでリソースベースのポリシーを追加します。プリンシパルは`bedrock.amazonaws.com`を指定し、Bedrockから実行可能にします。
「AWSのサービス」を選択したくなりますが、一番左の「AWSアカウント」で大丈夫です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/6e3f30f4-7db5-6060-aa0e-09b82cd9c98e.png)

# Agentの作成

`Amazon Bedrock`の`Agents`から`Create Agent`を実行します。
名前を適当に付けつつ、セッションタイムアウトは私は最小の1分にしました。無操作の場合にセッション（今回はLINEのユーザー単位）を維持する時間であり、この時間の間、過去の入力履歴がプロンプトに残り続けます。あまり長くすると回答が過去の問合せに左右される事になり、応答がちょっと不安定になる（再検索して欲しい時に履歴から回答してきたりする）ので、私は短い方が好みです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/ac74f815-33e4-7c57-9093-40b0a4207827.png)

Modelはこんな感じにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/a014a37d-c6a7-566e-22d6-e4041d802855.png)

Claude2ではなくInstantを選んでいる部分ですが、Agentはプロンプトが相当長く課金額的な意味もありますが、それ以上に、Agentの実行時間はLLMの実行時間に左右されるので速度優先の意味でClaude Instantを選んだ方が良いと思います。
Instructionは以下です。
```
ユーザーからの質問に対して回答します。現在日時が必要な場合は<question>のSystem:から取得します。
言語の指定が無い場合は日本語で回答します。
Functionを呼び出す必要が無い場合は知っている知識で回答します。知識では回答出来ないものはWeb上の情報を検索するFunctionを呼び出して回答します。
乗換案内を回答する際は、時刻、路線、乗換駅、料金、所要時間を含めて箇条書きで回答します。
```
後はAction groups（後で設定します）とKnowledge baseに何も設定せず`Create Agent`まで進んで完了します。
後で使用するので作成したAgentのIDをメモしておきます。

# Agentの設定

`Working draft`から設定していきます。

## Pre-processingの無効化
`Advanced prompts`の`Edit`から、`Pre-processing`を無効にしていきます。

`Override pre-processing template defaults`をON（右側）にします。
`Activate pre-processing template`が選択可能になるので、OFF（左側）にします。
`Save and exit`で保存します。

![20231226_100353.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/6b6b0503-399d-60a8-4dfb-9c84b438e941.jpeg)

何をやっているかというと、元々のAgentは実行の最初に「問い合わせ内容が関数（Lambda or Knowledge base）で解決可能か」というのをLLMで判断しており、これがPre-processingに該当します。一見、賢そうです。

が。

ちょっとここの判定が厳しめなのと、関数に関係ない（と判断された）問い合わせが跳ねられたりするので、（少し行儀が悪い気もしますが）ここをOFFにして何でも素通しにしちゃいます。また、AgentはLLMを何度も呼ぶ関係で結構処理時間が長いので、LLMの実行を1回減らす意図もあります。
前はプロンプトを書き換えて無理矢理判定OKにしていたのですが、そもそもプロンプト自体を飛ばせる事に気が付いたので飛ばすようにしました。

例えば以下のような、作成した関数に関係ない話が跳ねられないようになります。

<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/4ecd386e-8e56-854f-183e-73a75c1d63a7.jpeg">

用意した関数以外の会話をさせたくない場合もあると思うので、その場合はもちろんこの設定変更は不要です（でもプロンプトはチューニングした方が良いかも）。

## Action groupの追加

`Action groups`から`Add`していきます。
名前を適当に付けつつ、`Select Lambda function`は先ほど作成した乗換案内のLambda関数を選択します。

`Select API schema`は`Define with in-line OpenAPI schema editor`を選択し、以下の内容で上書きし、`Add`で反映します。

`Working draft`から`Prepare`で反映します。

```YAML
openapi: 3.0.0
info:
  title: Lambda
  version: 1.0.0
paths:
  /search:
    get:
      summary: Transit Search
      description: 乗換案内を検索します。<question>のSystem:の現在日時を参考に引数を考えてください。日時の指定が無い場合は今日の日付、今の時刻を使用してください。終電や始発を検索する際は、引数のH(hour)とM(minute)は現在の時間と分を数字2桁で設定してください。
      operationId: search
      parameters:
        - name: dep_station
          in: path
          description: 出発駅（日本語で指定）
          required: true
          schema:
            type: string
        - name: dest_station
          in: path
          description: 到着駅（日本語で指定）
          required: true
          schema:
            type: string
        - name: yyyy
          in: path
          description: 出発時刻で検索する場合は出発する年、到着時刻で検索する場合は到着したい年（year）
          required: true
          schema:
            type: string
            minLength: 4
            maxLength: 4
        - name: mm
          in: path
          description: 出発時刻で検索する場合は出発する月、到着時刻で検索する場合は到着したい月（month）
          required: true
          schema:
            type: string
            minLength: 2
            maxLength: 2
        - name: dd
          in: path
          description: 出発時刻で検索する場合は出発する日、到着時刻で検索する場合は到着したい日（day）
          required: true
          schema:
            type: string
            minLength: 2
            maxLength: 2
        - name: H
          in: path
          description: 出発時刻で検索する場合は出発する時刻、到着時刻で検索する場合は到着したい時刻（hourの24時間表記）。始発または終電を検索する場合は現在時刻
          required: true
          schema:
            type: string
            minLength: 2
            maxLength: 2
        - name: M
          in: path
          description: 出発時刻で検索する場合は出発する時刻、到着時刻で検索する場合は到着したい時刻の分（minute）。始発または終電を検索する場合は現在時刻
          required: true
          schema:
            type: string
            minLength: 2
            maxLength: 2
        - name: type
          in: path
          description: 検索方法。1:出発時刻を指定して検索、4:到着時刻を指定して検索、2:終電を検索、3:始発を検索
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 1
      responses:
        "200":
          description: 検索成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  body:
                    type: string
```

## Agent Aliasの作成

作成したAgentの選択直後の画面まで戻り、`Create Alias`を実行します。
名前を適当に入れて、`Create Alias`で反映します。

画面最下部の`Aliases`に反映されるので、`Alias ID`をメモしておきます。後で使用します。
これでAgentの設定は完了です。

# LINEチャンネルの作成

下記を参考にLINEチャネルを作成します。MessagingAPIを使用します。

https://developers.line.biz/ja/docs/messaging-api/getting-started/

https://developers.line.biz/ja/docs/messaging-api/building-bot/

後で使用するのでチャネルアクセストークンとチャネルシークレットをメモしておきます。
Webhook URLは最後に設定します。

# Agentを実行するLambda関数の作成

boto3とline-bot-sdkをLambdaレイヤーで追加し、以下のLambda関数を追加します。

```Python
import os
import json
import boto3
import base64
import hashlib
import hmac
from linebot import LineBotApi
from linebot.models import TextSendMessage
import datetime

def lambda_handler(event, context):
    
    # LINE用 初期処理
    LINE_CHANNEL_ACCESS_TOKEN = os.environ['LINE_CHANNEL_ACCESS_TOKEN']
    CHANNEL_SECRET = os.environ['LINE_CHANNEL_SECRET']
    LINE_BOT_API = LineBotApi(LINE_CHANNEL_ACCESS_TOKEN)

    # LINEからの正規リクエスト以外は終了
    x_line_signature = event["headers"].get("x-line-signature") or event["headers"].get("X-Line-Signature")
    req_body_str = event["body"]
    hash = hmac.new(CHANNEL_SECRET.encode('utf-8'),
        req_body_str.encode('utf-8'), hashlib.sha256).digest()
    signature = base64.b64encode(hash)
    if signature != x_line_signature.encode(): raise Exception

    # リクエストの取得
    req_body = json.loads(event['body'])

    # Agentのsession_idとしてLINEのUserIdを使用する
    session_id = req_body['events'][0]['source']['userId']
    
    # LINEからのメッセージを受信
    if req_body['events'][0]['type'] == 'message':
        if req_body['events'][0]['message']['type'] == 'text':
            line_message = req_body['events'][0]['message']['text']
            
            # Agentの定義
            agent_id =  os.environ['AGENT_ID']
            agent_alias_id =  os.environ['AGENT_ALIAS_ID']

            # Agent用Client
            agent_client = boto3.client("bedrock-agent-runtime")
            
            # 現在日時の取得（ISO8601形式）
            now = str(datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9))).isoformat())
            
            # LINEからのメッセージに現在日時をくっ付けてAgent呼出し
            query = "System: 現在の日時は "+now+"（ISO8601形式）です。現在日付や現在時刻が必要な場合は左記から取得してください。もしチャット履歴に対して質問された場合は、現在時刻に対してではなくその前のチャット履歴に対して回答してください。\n\nHuman: "+line_message
            
            # Agentの実行
            response = agent_client.invoke_agent(
                inputText=query,
                agentId=agent_id,
                agentAliasId=agent_alias_id,
                sessionId=session_id,
                enableTrace=False
            )
        
            # Agent実行結果の取得
            answer = ''
            event_stream = response['completion']
            for event in event_stream:        
                if 'chunk' in event:
                    answer += event['chunk']['bytes'].decode("utf-8")

            # Agentの回答をLINEに返却
            replyToken = req_body['events'][0]['replyToken']
            LINE_BOT_API.reply_message(replyToken, TextSendMessage(text=answer))

    return {'statusCode': 200, 'body': json.dumps('Reply ended normally.')}
```
Agentの実行時間は結構長いので、タイムアウトを5分ほどに伸ばしておきます。乗換案内のみであればこんなに長くありませんが、今後長い処理を追加した時の為に長めにしています。
また、LambdaからAgentを実行できるように、Lambdaの実行ロールにAdministratorAccessを追加しておきます（リソースベースのポリシーではなく、実行ロール）。

環境変数として、ここまでメモした以下の4つを設定します。
- AGENT_ID : AgentのID
- AGENT_ALIAS_ID : AgentのAlias ID
- LINE_CHANNEL_ACCESS_TOKEN : LINEのチャネルアクセストークン
- LINE_CHANNEL_SECRET : LINEのチャネルシークレット

前にAgentを作ったときは、現在日時の取得は専用のLambda関数を作ってAgentから実行していたのですが、LLMの実行回数が多くなると処理時間がなかなかなので、Agentを呼び出す際に毎回渡すように変更しています。

# API gatewayの作成

API GatewayからLambda関数が呼べるように設定していきます。
Lambda関数の関数URLでも良いといえば良いのですが、行儀の問題です。プロキシ統合なら同じじゃないかという気もしつつ。

`APIの作成`から`REST API`を`構築`
名前を適当に付けて`APIを作成`

`メソッドを作成`から、`POST` `Lambda関数` `Lambda プロキシ統合`を選択、Lambda関数として上で作成した「Agentを実行するLambda関数」を選択し（乗換案内では無いので注意）、`メソッドを作成`を実行。

`APIをデプロイ`から、適当なステージ名を入力して`デプロイ`

これでAPI Gatewayの準備は出来たので、`URL を呼び出す`のURLをメモしておきます。

# 最後のLINEの設定
- LINE DeveloperのWebhook URLに先ほどメモしたAPI GatewayのURLを設定します
- QRコードからご自身のLINEにチャネル登録します

これでLINEチャネルから自然言語で乗換案内が検索できるようになります。

# まとめ

これで基本形は出来たので、後は必要なLambda関数の作成とAction groupの追加を行っていけば、LINEから自然言語で情報の収集であろうがトランザクションの実行であろうが何でも出来ます！
と言いたいところですが、実際にやってみると分かるのですが、プロンプト（Claude Instant）がなかなか挙動不審で、弄っていると平均台の上を歩いているような気分が味わえます。くどくどと謎の日本語指示が書いてある部分は、何かしら特定の問い合わせをした際に平均台から落ちた名残だと思ってください。パッチワークをしている感が強く、これがスマートなノーコードなのかと聞かれるとちょっとアレですが、前にもどこかで書きましたがLangChainを使わなくて済むのとAWS純正のClaude用プロンプトをベースに弄れるのは良いような気がします。

（もうちょっとだけ続くんか？）

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