ChatGPT風に会話が成り立つLINEボット on AWS Lambda

# はじめに
ChatGPTのもとになっているOpenAIを使ったチャットボットを作ってる人はたくさんいるものの、ステートフルに会話を成り立たせてる例がなかったので作ってみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/3292c6fa-d1c5-c6dc-d7e4-25bd4cae38b3.png)
会話をデータベースに保存しないと実現できないので、割と面倒ですが、LambdaとDynamoDBを使えばそこそこシンプルに実現可能です。
```mermaid
sequenceDiagram
    User ->> LINE: Message
    LINE ->> Lambda: UserID, Message
    Lambda ->> DynamoDB: UserID
    DynamoDB ->> Lambda: 会話
    Lambda ->> OpenAI: 会話 + Message
    OpenAI ->> Lambda: Response
    Lambda ->> DynamoDB: UserID,会話
    Lambda ->> LINE: Response
    LINE ->> User: Response
```



# 初期設定
下記のような流れ。
1. OpenAIからキーをゲット
1. LINEのBotを作ってキーをゲット
1. Lambdaに関数を作る

## OpenAI
### Secret keyを取得する
下記URLから生成できる
https://platform.openai.com/account/api-keys

## LINE
### あたらしいProviderをつくる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/824e6014-66af-460d-e13a-81f957983781.png)
### Messaging API Channelをつくる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/645a3241-6d89-bc1c-ea85-b3ff76b344ac.png)
必須項目をうめる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/99e77788-f57e-0032-2779-30344d170641.png)
- Auto replyはDisabled
- Greeting messagesはEnabledにしておかないと、追加したボットを探すのが面倒らしい。
### Channel access tokenを取得する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/7156ccdc-e058-deca-1d64-a0c4cbc0579a.png)


## AWS Lambda
### 新しいLambda Functionを作る
- 名前を適当につけて、Pythonを選ぶ。
- Enable function URLは必須。これでLINEのAPIがWebhookしてくるエンドポイントが生える。権限はNONEで。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/4cdb44a0-8a7b-648c-3264-ce243968c45e.png)

### Requestsモジュールを使うためのLayerを追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/c8b32037-5a99-a8ec-1cc7-9b6925f348cb.png)
- 適当なのを選んどけばrequestsくらいなら大体入ってるはず。
- 本当はちゃんとrequirements.txtから生成してzipしたほうがいい。

### 環境変数の設定
Lambdaの環境変数に下記を設定しておく
- LINE_CHANNEL_ACCESS_TOKEN
- LINE_CHANNEL_SECRET
- LINE_REPLY_ENDPOINT
- OPENAI_API_KEY
- OPENAI_COMPLETIONS_ENDPOINT
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/31597527-041f-a150-5acb-4467f00241f0.png)


### LINE側のWebhook URLを設定する
AWSで取得できるFunction URLを
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/7e286d78-dba2-4c6f-28de-a9644673da24.png)
LINEのWebhook URLにセットする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/de5f26e4-315c-b4e6-7d84-4414b2f0159a.png)
今回はLINEからメッセージを受け取った時のエラーハンドリングをちゃんと書かないので、多分Webhook redeliveryはしないほうがいい。


# まずはオウム返し
とりあえずはOpenAI関係なく、LINEの内容をオウム返しだけさせる。
```mermaid
sequenceDiagram
    User ->> LINE: Message
    LINE ->> Lambda: Message
    Lambda ->> LINE: Message
    LINE ->> User: Message
```
## Lambdaのコード
下記をLambdaのCodeに張り付ければ動くはず。
https://github.com/michitomo/openai-line-bot/blob/main/pingpong.py
メインの関数はこんな感じ。
1. メッセージを取り出す
1. 返信用Tokenを取り出す
1. LINEの返信用エンドポイントにPOSTする
```Python
def lambda_handler(event, context):
    
    message = json.loads(event['body'])['events'][0]['message']['text']
    reply_token = json.loads(event['body'])['events'][0]['replyToken']

    # message = openai_completions(message)
    
    line_reply(reply_token, message)
    return {
        'statusCode': 200
    }
```

## 実行結果
入れたものがそのまま返ってくるはず
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/7f62f667-42d4-d8ff-d919-3fe8bb8a407d.png)

## デバッグ
うまく動かなかったら`View CloudWatch logs`からログが確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/87197484-dfef-396d-04c7-ae8f5643c696.png)

# OpenAPIに問い合わせて返事を返す
オウム返しの代わりに、OpenAIにクエリして、その返答をLINEに戻すようにする。
```mermaid
sequenceDiagram
    User ->> LINE: Query
    LINE ->> Lambda: Query
    Lambda ->> OpenAI: Query
    OpenAI ->> Lambda: Response
    Lambda ->> LINE: Response
    LINE ->> User: Response
```
## Lambdaのタイムアウトを伸ばす
OpenAPIのレスポンスは結構遅いので、とりあえず1分にしておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/3facd57a-43ef-53ed-f1c8-7302f4eb080d.png)

## Lambdaのコード
https://github.com/michitomo/openai-line-bot/blob/main/basic_openai_completions.py
1. OpenAIのRequest Bodyの`prompt`にLINEからのメッセージを与え、OpenAIへのリクエストを生成する
1. レスポンスをLINEに返す

OpenAIのAPIは結構よく壊れてるので、エラーハンドリングを入れておく。

```Python
def openai_completions(prompt):
    headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer {}'.format(OPENAI_API_KEY)
    }
    data = {
        "model": "text-davinci-003",
        "prompt": prompt,
        "temperature": 0,
        "max_tokens": 100,
        "top_p": 1,
        "frequency_penalty": 0,
        "presence_penalty": 0,
    }
    try:
        print('OpenAI request: {}'.format(prompt))
        openai_response = requests.post(OPENAI_COMPLETIONS_ENDPOINT, headers=headers, data=json.dumps(data))
        print('OpenAI response: {}'.format(openai_response.json()))
        return openai_response.json()['choices'][0]['text']
    except:
        return 'OpenAIが壊れてました😢'
```


## 実行結果
何も指示を与えていない場合、基本的な挙動としては、続きを書いてくれる感じになることが多い。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/13f6c1e3-3a53-c115-5816-bf28b6133b64.png)

# 返答をChatGPT風にする（ステートレス）
ここまではよく見るやつ。
ChatGPT風の返答にするためには、OpenAPIに以下のような初期値を与えている。
- AIに「あなたはアシスタントである」と伝える
- AIにhelpful, creative, clever, and evry friendlyな返事をするように指示する
- Temperatureを高めに設定し、返答に使うコンテキストを広げる
```mermaid
sequenceDiagram
    User ->> LINE: Query
    LINE ->> Lambda: Query
    Lambda ->> OpenAI: Template + Query
    OpenAI ->> Lambda: Response
    Lambda ->> LINE: Response
    LINE ->> User: Response
```

詳細は下記。
https://platform.openai.com/examples/default-chat
## GPTに与える指示テンプレートを追加する
https://github.com/michitomo/openai-line-bot/blob/main/stateless_openai_chat.py
OpenAIに送るリクエストをちょっと変えるだけ
```Python
    data = {
        "model": "text-davinci-003",
        "prompt": "The following is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.\n\n"
        + "Human: Hello, who are you?\n"
        + "AI: I am an AI created by OpenAI. How can I help you today?\n"
        + "Human: " + prompt
        + "\nAI:",
        "temperature": 0.9,
        "max_tokens": 100,
        "top_p": 1,
        "frequency_penalty": 0,
        "presence_penalty": 0.6,
    }
```


## 実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/6e34791a-3b45-81eb-50a9-44037c0f9013.png)
日本語の場合、Tokenが100だと全然足りないので、もっと増やす必要がある。ただしその分、費用がかさむ（ので、下記でいくらかかったかも表示するようにする）

# ステートフルにする
だいぶ面倒くさいうえに、会話が長くなればなるほど、OpenAIの利用コストが高くなる。
会話の保存先はDynamoDBを利用。
```mermaid
sequenceDiagram
    User ->> LINE: Message
    LINE ->> Lambda: UserID, Message
    Lambda ->> DynamoDB: UserID
    DynamoDB ->> Lambda: 会話
    Lambda ->> OpenAI: 会話 + Message
    OpenAI ->> Lambda: Response
    Lambda ->> DynamoDB: UserID,会話
    Lambda ->> LINE: Response
    LINE ->> User: Response
```

## DynamoDBにテーブルを作る
DynamoDBはKVSなので、LINE側のIDをキーにして、OpenID用の会話履歴をJSONでValueに丸ごと保存します。
リアルタイムにValueで検索する要件がある場合は、DynamoDBはあまり向いていないと思います。
| KEY | VALUE |
| - | - |
| line_id | 会話履歴のJSON |

一人のユーザに複数の会話履歴を保存したい場合は、別のソートキーを作る必要があります。会話履歴を念のために保存しておきたいだけなら、会話のリセット時に`{line_id}`を`{line_id}-{timestamp}`のようにすれば良いです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/bc66b3d4-3fa0-4712-6fd0-dea63ae8085f.png)


## Lambda関数にDynamoDBへのアクセス権を与える
Lambda関数 -> 設定 -> アクセス権限
から、ロール名をクリックします。すると、ポリシーの画面が出てくるので、ここに
`AmazonDynamoDBFullAccess`か`AWSLambdaBasicExecutionRole`を追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/17c5e25d-4729-c4fc-3c5e-8772069c9293.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/a0d93a0d-7812-763c-0941-e4027a1156dd.png)


## LambdaからDynamoDBに会話を保存するコードをかく
https://github.com/michitomo/openai-line-bot/blob/main/lambda_function.py
（要望があれば説明追記します）

## 実行結果
リセットを押さない限り、前の会話の内容を引き継いでくれる。会話が長くなるとどんどんAIの利用料が高くなるので、適度なタイミングでリセットを押したほうがいい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/3292c6fa-d1c5-c6dc-d7e4-25bd4cae38b3.png)
DynamoDB側からも会話記録を参照可能。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85204/d55e42de-e88a-2a82-0cad-63de955bd5d3.png)

# 雑記
- OpenAIのAPIは不安定すぎる。
- Lambdaの関数はzipにまとめてあげるより、Layersでやっておけば、コードをブラウザから編集できて便利。
- 普通にアクセスさせるだけなら、AWS API Gatewayは不要。そのままFunction URLにアクセスさせられる。