はじめに
ChatGPTのもとになっているOpenAIを使ったチャットボットを作ってる人はたくさんいるものの、ステートフルに会話を成り立たせてる例がなかったので作ってみました。
会話をデータベースに保存しないと実現できないので、割と面倒ですが、LambdaとDynamoDBを使えばそこそこシンプルに実現可能です。
初期設定
下記のような流れ。
- OpenAIからキーをゲット
- LINEのBotを作ってキーをゲット
- Lambdaに関数を作る
OpenAI
Secret keyを取得する
下記URLから生成できる
https://platform.openai.com/account/api-keys
LINE
あたらしいProviderをつくる
Messaging API Channelをつくる
- Auto replyはDisabled
- Greeting messagesはEnabledにしておかないと、追加したボットを探すのが面倒らしい。
Channel access tokenを取得する
AWS Lambda
新しいLambda Functionを作る
Requestsモジュールを使うためのLayerを追加
- 適当なのを選んどけばrequestsくらいなら大体入ってるはず。
- 本当はちゃんとrequirements.txtから生成してzipしたほうがいい。
環境変数の設定
Lambdaの環境変数に下記を設定しておく
- LINE_CHANNEL_ACCESS_TOKEN
- LINE_CHANNEL_SECRET
- LINE_REPLY_ENDPOINT
- OPENAI_API_KEY
- OPENAI_COMPLETIONS_ENDPOINT
LINE側のWebhook URLを設定する
AWSで取得できるFunction URLを
LINEのWebhook URLにセットする
今回はLINEからメッセージを受け取った時のエラーハンドリングをちゃんと書かないので、多分Webhook redeliveryはしないほうがいい。
まずはオウム返し
とりあえずはOpenAI関係なく、LINEの内容をオウム返しだけさせる。
Lambdaのコード
下記をLambdaのCodeに張り付ければ動くはず。
https://github.com/michitomo/openai-line-bot/blob/main/pingpong.py
メインの関数はこんな感じ。
- メッセージを取り出す
- 返信用Tokenを取り出す
- LINEの返信用エンドポイントにPOSTする
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
}
実行結果
デバッグ
うまく動かなかったらView CloudWatch logs
からログが確認できる。
OpenAPIに問い合わせて返事を返す
オウム返しの代わりに、OpenAIにクエリして、その返答をLINEに戻すようにする。
Lambdaのタイムアウトを伸ばす
OpenAPIのレスポンスは結構遅いので、とりあえず1分にしておく。
Lambdaのコード
- OpenAIのRequest Bodyの
prompt
にLINEからのメッセージを与え、OpenAIへのリクエストを生成する - レスポンスをLINEに返す
OpenAIのAPIは結構よく壊れてるので、エラーハンドリングを入れておく。
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が壊れてました😢'
実行結果
何も指示を与えていない場合、基本的な挙動としては、続きを書いてくれる感じになることが多い。
返答をChatGPT風にする(ステートレス)
ここまではよく見るやつ。
ChatGPT風の返答にするためには、OpenAPIに以下のような初期値を与えている。
- AIに「あなたはアシスタントである」と伝える
- AIにhelpful, creative, clever, and evry friendlyな返事をするように指示する
- Temperatureを高めに設定し、返答に使うコンテキストを広げる
詳細は下記。
https://platform.openai.com/examples/default-chat
GPTに与える指示テンプレートを追加する
https://github.com/michitomo/openai-line-bot/blob/main/stateless_openai_chat.py
OpenAIに送るリクエストをちょっと変えるだけ
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,
}
実行結果
日本語の場合、Tokenが100だと全然足りないので、もっと増やす必要がある。ただしその分、費用がかさむ(ので、下記でいくらかかったかも表示するようにする)
ステートフルにする
だいぶ面倒くさいうえに、会話が長くなればなるほど、OpenAIの利用コストが高くなる。
会話の保存先はDynamoDBを利用。
DynamoDBにテーブルを作る
DynamoDBはKVSなので、LINE側のIDをキーにして、OpenID用の会話履歴をJSONでValueに丸ごと保存します。
リアルタイムにValueで検索する要件がある場合は、DynamoDBはあまり向いていないと思います。
KEY | VALUE |
---|---|
line_id | 会話履歴のJSON |
一人のユーザに複数の会話履歴を保存したい場合は、別のソートキーを作る必要があります。会話履歴を念のために保存しておきたいだけなら、会話のリセット時に{line_id}
を{line_id}-{timestamp}
のようにすれば良いです。
Lambda関数にDynamoDBへのアクセス権を与える
Lambda関数 -> 設定 -> アクセス権限
から、ロール名をクリックします。すると、ポリシーの画面が出てくるので、ここに
AmazonDynamoDBFullAccess
かAWSLambdaBasicExecutionRole
を追加します。
LambdaからDynamoDBに会話を保存するコードをかく
https://github.com/michitomo/openai-line-bot/blob/main/lambda_function.py
(要望があれば説明追記します)
実行結果
リセットを押さない限り、前の会話の内容を引き継いでくれる。会話が長くなるとどんどんAIの利用料が高くなるので、適度なタイミングでリセットを押したほうがいい。
DynamoDB側からも会話記録を参照可能。
雑記
- OpenAIのAPIは不安定すぎる。
- Lambdaの関数はzipにまとめてあげるより、Layersでやっておけば、コードをブラウザから編集できて便利。
- 普通にアクセスさせるだけなら、AWS API Gatewayは不要。そのままFunction URLにアクセスさせられる。