ChatGPT APIが強すぎる
N1!Machine Learning Product Engineerの中村です。
最近はみんなChatGPT APIを使っているので、何番煎じなのかという気はしますが、ChatGPT APIを使ってSlackbotを作ったら意外と評判が良かったので、作り方を書いてみます。
スレッド内の会話を覚えている機能も作ってあるので、いい感じのBotになったと思います。
たぶん慣れた人なら30分で同じものが作れると思います。
ChatGPT APIは何と言っても価格が安すぎますね。自前でホスティングしているAIサービスが弊社もあるんですが、置き換えていった方が安い気がしてます…。
SlackBotにする
弊社でもっとも使われているツールはおそらくSlackでしょう。自分も入り浸っています。
そうなってくると弊社で技術を広めるにはSlackに実装しちゃうのが手っ取り早いです。
というわけでChatGPT APIが公開されたあたりでせっせと実装して社内にデプロイしました。
構成としてはAWS LambdaでAPIを作成し、それをSlackBotと接続するというシンプルな構成です。
(最近はLambdaだけでURLを発行できるようになったので、API Gatewayもありません)
SlackBotを設定する
まずはSlackBotを設定します。最近だとmanifestファイルを読み込むことで、設定できるのでそれを使って設定していきましょう。
Slackアプリ作成画面から「From ann app manifest」を選択します。
マニフェストは以下を入力してください。request_urlは後でLambdaのAPIを作った時に変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
display_information: name: myfriendGPT features: bot_user: display_name: myfriendGPT always_online: true oauth_config: scopes: bot: - app_mentions:read - channels:history - chat:write - users:read settings: event_subscriptions: request_url: https://XXXXXXXXXXXXXXXXXX.lambda-url.ap-northeast-1.on.aws/ bot_events: - app_mention - message.channels org_deploy_enabled: false socket_mode_enabled: false token_rotation_enabled: false |
これで一通りの設定が完了したSlackBotが完成するはずです。
お好みでSlackBotの設定をする
SlackBotをお好みで設定しましょう。友達になって欲しいので、ここではmyfriendGPTと名付けます。
プロフの顔写真はThisPersonDoesNotExistで作成しました。
https://this-person-does-not-exist.com/en
AWS LambdaでAPIを作成する
SlackBotでの処理を行うために、AWS LambdaでAPIを実装します。
私はTerraform信者なので、単純な機能のLambdaは全部Terraformで作ってしまうので、ここでもTerraformで作ります。(GUIからぽちぽちしてもそんなに大変ではないと思いますが)
aws configureの実行
aws configureを実行して、AWSのprofileを設定します。
1 |
$ aws configure --profile chatgpt-profile |
Terraformファイルの設定
variable.tfを作成します。
1 |
$ vim variable.tf |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
provider "aws" { region = "ap-northeast-1" shared_credentials_files = ["~/.aws/credentials"] profile = "chatgpt-profile" } terraform { required_version = ">= 1.0.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 4.12.0" } } } variable "slack_token" { type = string } variable "openai_api_key" { type = string } locals { name = "chatgpt" SLACK_TOKEN = var.slack_token SLACK_TOKEN_NEWS = var.slack_token_news OPENAI_API_KEY = var.openai_api_key } |
次にLambdaの関数を作成するlambda.tfを同階層に作成してください。
1 |
$ vim lambda.tf |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
data "archive_file" "invoke_function" { type = "zip" source_dir = "lambda_src" output_path = "lambda_archive/invoke_function.zip" } resource "aws_lambda_function" "invoke_function" { filename = data.archive_file.invoke_function.output_path function_name = "${local.name}-invoke-function" role = aws_iam_role.lambda_role.arn handler = "lambda.lambda_handler" source_code_hash = data.archive_file.invoke_function.output_base64sha256 runtime = "python3.9" architectures = ["arm64"] memory_size = 128 timeout = 30 environment { variables = { SLACK_TOKEN = local.SLACK_TOKEN OPENAI_API_KEY = local.OPENAI_API_KEY } } } data "aws_iam_policy_document" "assume_role" { statement { actions = ["sts:AssumeRole"] effect = "Allow" principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } } } resource "aws_iam_role_policy_attachment" "lambda_policy" { role = aws_iam_role.lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } resource "aws_iam_role" "lambda_role" { name = "${local.name}-invoke-lambda-role" assume_role_policy = data.aws_iam_policy_document.assume_role.json } resource "aws_lambda_function_url" "lambda_url" { function_name = aws_lambda_function.invoke_function.function_name authorization_type = "NONE" cors { allow_credentials = true allow_origins = ["*"] allow_methods = ["*"] allow_headers = ["date", "keep-alive"] expose_headers = ["keep-alive", "date"] max_age = 86400 } } |
Lambdaのソースなどを保存するフォルダを2つ作ります。
1 2 |
$ mkdir lambda_src $ mkdir lambda_archive |
Lambdaのソースを記述します。
以下のような機能が実装されています
・Slackのチャレンジを正常に返す機能
・複数回のリクエストが来た場合に、最初の1回以外は受け入れない機能
・チャンネルにポストされた投稿では、直前の対話を入力にしてChatGPT APIで返答を作る機能
・スレッドにポストされた投稿は、スレッド内の情報を集めて返答する機能
・基本的に100文字で返す機能
それにしてもこのコードもだいぶAIに書いてもらいました。AIなしのプログラミングも考えられない時代になるかもしれないですね。
1 |
$ vim lambda_src/lambda.py |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
import json import os import re import logging import urllib.request import openai # pip3 install openai -t . openai.api_key = os.environ['OPENAI_API_KEY'] logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(data, context): # logger.info(data) body = json.loads(data["body"]) # challenge if ('challenge' in body): return { "statusCode": 200, "body": json.dumps({'challenge': body['challenge']}) } # 再送を無視する headers = data["headers"] if ('x-slack-retry-num' in headers): return { "statusCode": 200 } # slack event event = body["event"] # メンション時 if (event["type"] == "app_mention"): # メッセージを作る # 最初にChatGPTでのシステム指示を与える messages = [ { "role": "system", "content": "あなたは最高の友達であり仲間です。最大100文字で会話をしてください。" } ] # ユーザーの入力を取得する user_input = strip_angle_bracket_tags(event["text"]) if ('thread_ts' in event): # スレッドの場合には、スレッドのメッセージを取得して対話モードになるように組み立てる replies = get_conversations_replies( event["channel"], event["thread_ts"]) if (replies.get("ok")): # スレッドの内容を取得できたので、メッセージを作る(配列の最後から20件までを取得する) for reply in replies["messages"][-20:]: if (reply.get("bot_id")): # botの入力の場合 messages.append({ "role": "assistant", "content": reply["text"] }) else: # ユーザーの入力の場合 # bot以外の入力は全てuserとする messages.append({ "role": "user", "content": strip_angle_bracket_tags(reply["text"]) }) else: # スレッドの内容を取得できなかったので、ユーザーの入力のみがメッセージになる messages.append({ "role": "user", "content": user_input }) else: # スレッドでない場合には、チャンネルの過去のメッセージを取得して対話モードになるように組み立てる history = get_prev_messages(event["channel"]) # logger.info("history: " + str(history)) if (history.get("ok")): # 過去のメッセージを取得できたので、メッセージを作る(配列の最後から3件までを逆順で取得する) for message in history["messages"][-3:][::-1]: if (message.get("bot_id")): # botの入力の場合 messages.append({ "role": "assistant", "content": message["text"] }) else: # ユーザーの入力の場合 # bot以外の入力は全てuserとする messages.append({ "role": "user", "content": strip_angle_bracket_tags(message["text"]) }) else: # 過去のメッセージを取得できなかったので、ユーザーの入力のみがメッセージになる messages.append({ "role": "user", "content": user_input }) logger.info("messages: " + str(messages)) # OpenAIのAPIを呼び出して応答を取得 res = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=messages, ) # スレッド内で呼ばれた場合にはスレッドに返信する if ('thread_ts' in event): send_slack(event["channel"], res["choices"] [0]["message"]["content"], event["thread_ts"]) else: send_slack(event["channel"], res["choices"] [0]["message"]["content"]) return {"statusCode": 200} def strip_angle_bracket_tags(str): # strから<>で囲まれた箇所を削除する return re.sub(r'<[^>]*>', '', str) def get_prev_messages(channel): # チャンネルの過去のメッセージを取得する url = 'https://slack.com/api/conversations.history' token = os.environ['SLACK_TOKEN'] headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json; charset=utf-8' } params = { "channel": channel, "limit": 3 } req = urllib.request.Request('{}?{}'.format( url, urllib.parse.urlencode(params)), headers=headers) res = urllib.request.urlopen(req, timeout=5) return json.loads(res.read().decode("utf-8")) def get_conversations_replies(channel, ts): # スレッドの情報を取得する url = 'https://slack.com/api/conversations.replies' token = os.environ['SLACK_TOKEN'] headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json; charset=utf-8' } params = { "channel": channel, "ts": ts } req = urllib.request.Request('{}?{}'.format( url, urllib.parse.urlencode(params)), headers=headers) res = urllib.request.urlopen(req, timeout=5) return json.loads(res.read().decode("utf-8")) def send_slack(channel, message, thread_ts=None): url = 'https://slack.com/api/chat.postMessage' token = os.environ['SLACK_TOKEN'] headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json; charset=utf-8' } method = 'POST' data = { "channel": channel, "text": message } # thread_tsがある場合はdataに追加する if (thread_ts): data["thread_ts"] = thread_ts json_data = json.dumps(data).encode("utf-8") req = urllib.request.Request( url=url, data=json_data, headers=headers, method=method) res = urllib.request.urlopen(req, timeout=5) |
最後にoutput.tfを作成します。(Lambdaの実行URLをわかりやすくするため)
1 |
$ vim output.tf |
1 2 3 4 5 |
# Lambdaで発行されたURLを出力する output "invoke_url" { value = aws_lambda_function_url.lambda_url.function_url } |
OpenAIのライブラリをインストールする
ここまでで大体の準備が完了ですが、Lambda内ではOpenAIライブラリを使ってAPIを実行するので、OpenAIのライブラリを含めるようにします。
Lambda Layerを使うという方法もありますが、ライブラリを同梱してしまえば動くので、今回は手元でpipを実行して同梱してしまいます。
(M1/M2 Macだと問題ないことはわかっていますが、Win機などだとうまくLambdaのアーキテクチャと一致せず実行できない可能性があります…)
1 |
$ pip3 install openai -t ./lambda_src |
Terraformでデプロイする
以上のファイルを揃えたら、Terraformでapplyします。ここまでで以下のようなファイル構成になっているはずです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
. ├── lambda.tf ├── lambda_archive ├── lambda_src │ ├── aiohttp │ ├── aiohttp-3.8.4.dist-info │ ├── aiosignal │ ├── aiosignal-1.3.1.dist-info │ ├── async_timeout │ ├── async_timeout-4.0.2.dist-info │ ├── attr │ ├── attrs │ ├── attrs-22.2.0.dist-info │ ├── bin │ ├── certifi │ ├── certifi-2022.12.7.dist-info │ ├── charset_normalizer │ ├── charset_normalizer-3.0.1.dist-info │ ├── frozenlist │ ├── frozenlist-1.3.3.dist-info │ ├── idna │ ├── idna-3.4.dist-info │ ├── lambda.py │ ├── multidict │ ├── multidict-6.0.4.dist-info │ ├── openai │ ├── openai-0.27.0.dist-info │ ├── requests │ ├── requests-2.28.2.dist-info │ ├── tqdm │ ├── tqdm-4.64.1.dist-info │ ├── urllib3 │ ├── urllib3-1.26.14.dist-info │ ├── yarl │ └── yarl-1.8.2.dist-info ├── output.tf └── variable.tf |
terraform initとterraform applyを実行してデプロイします。
OPENAI_API_KEYとSLACK_TOKENを入力する必要があるため、それぞれ確認します。
OpenAIのAPI Key
https://platform.openai.com/account/api-keys
必要であればRegenerateなどを行なって、シークレットキーを入手します。
SlackのToken
SlackのOAuthのTokenを確認してください。
Terraformでapplyする
1 2 |
$ terraform init $ terraform apply |
yesを入力し、openai_api_keyとslack_tokenにはそれぞれ確認したtokenを入力してください。
applyが完了すると、URLが表示されるはずです。(invoke_url)
1 2 3 4 5 6 7 8 9 |
aws_lambda_function.invoke_function: Modifying... [id=chatgpt-invoke-function] aws_lambda_function.invoke_function: Still modifying... [id=chatgpt-invoke-function, 10s elapsed] aws_lambda_function.invoke_function: Modifications complete after 17s [id=chatgpt-invoke-function] Apply complete! Resources: 0 added, 4 changed, 0 destroyed. Outputs: invoke_url = "https://xxxxxxrcd34o3ydu6vuxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/" |
SlackBotにLambdaを設定する
LambdaがデプロイできたらSlackBotに設定していきましょう。
Event Subscriptionのページを開いて、デプロイしたLambdaのURLを設定します。
Verifiedになれば設定完了です。
実際に使ってみる
ここまで設定すればSlackBotは動くようになるはずです。有能な友達にいろいろ聞いてみましょう。
チャンネルで普通に聞くと3個前、スレッド内だとスレッド内の20個前までの発言を覚えた状態で返してくれます(メッセージを取得した時には自分の発言も含まれるので、3個前だと実際には会話1往復分になります)
スレッドを使うと20個前までの会話を覚えているので、けっこういい感じに話してくれます。
まとめ
ChatGPT APIが登場したので、勢いのままSlackBotにして実装してみました。
使ってみた感想はSlackで使えるとめちゃくちゃ気軽に使えます。また他の人が質問をしている様子をみると何に興味があるか?とか何を調べてるのか?とかがわかるので、人となりを知る手がかりにもなる気がします。
あと個人的には長いスレッドになってくると「要約して」とスレッドに投げると内容を要約してくれるのがめちゃくちゃ便利ですね。(例に挙げたのははちゃめちゃすぎますが・・・)
今後はこういう自然言語処理をうまく扱えるかが大事ですね。
ChatGPTはめちゃくちゃ安くて、弊社でも既に何千回もAPIをコールしているんですが、確保した予算を全く使い切れる気がしません。みなさんもぜひ試してみてください。