アプリ開発

slack+LangChainでチャットボットを作成してCloud Functionsで動かしてみた。

以下の記事では簡単なチャットボットを作成しました。

Streamlitとopenaiによるチャットボット
StreamlitとOpenAIで作る! "Hello World"からのチャットボット開発この数ヶ月(2023/4/1現在)、ChatGPTが話題になっています。 ChatGPTはOpenAI(AIの研究組織)が2022...

今回はチャットボットシリーズの第2弾としてslackのチャットボットを作成します。

slack+OpenAIを使用したAIチャットボット作成の記事は世にあふれており、ビジネスでサービス化しているようなものもあります。

そのような状況なので、「○番煎じだよ!」という記事にはなってしまいますが、自分なりに方法を整理するためにと思い記事化に至りました。

本記事では以下の順を追って環境を構築します。

  1. チャットボットの土台となるLangChainを使用した簡易ツール作成
  2. ソケット通信を利用してslackからのイベントをハンドリングするslackボットの作成
  3. ローカルのhttpサーバーとngrokを利用してslackからのイベントをハンドリングするslackボットの作成
  4. Google CloudのCloud Functionを利用してslackからのイベントをハンドリングするslackボットの作

事前準備

前提条件

本記事で掲載するソースコードは、Python3.11.2で記述されております。

使用している各ライブラリのバージョンは以下の通りです。

  • openai (0.27.2)
  • python-dotenv(1.0.0)
  • langchain(0.0.142)
  • slack_bolt(1.17.2)
  • functions-framework(3.3.0)
  • flask(2.1.0)
  • google-cloud-error-reporting(1.9.1)

上記ライブラリ群はGoogle Cloudにデプロイするためにrequirements.txtに以下のように記載しておき、開発環境では「pip install -r requirements.txt」によりインストールしてください。

openai==0.27.4
python-dotenv==1.0.0
langchain==0.0.142
slack_bolt==1.17.2
functions-framework==3.3.0
flask==2.1.0
google-cloud-error-reporting==1.9.1

OpenAIのAPIキー取得

OpenAIのアカウントを作成した後、以下にアクセスして「Create new secret key」によりAPIキーを取得してください。

https://platform.openai.com/account/api-keys

アカウント作成後$18以内かつ3ヶ月以内は無料ですがそれ以上は料金が発生します(2023/4/1現在)。詳細は、OpenAIのページを参照してください。

なお、有料課金した段階で無料利用の$18も支払い対象となるようなので注意が必要です。

ngrokのインストール

ngrokはトンネリングツールで、LANで実行しているWEBサービスを外部のインターネットに公開することができます。

以下に記載されている方法でローカルPCにインストールしてください。

gcloud cliのインストール

ローカルPCからGoogle Cloudにデプロイするために必要なコマンドライン環境です。

以下に記載されている方法でローカルPCにインストールしてください。

LangChainでテスト実装

今回、チャットボットを作成するにあたってLangChain(OpenAIをラッピングしたライブラリ)を利用します。

OpenAIを直接呼び出すのと比較すると過去の会話を記憶させるのが容易です。

まずはこのLangChainを利用してコマンドラインからチャットができるアプリを作成します。

.envを作成してOpenAIのAPIキーを設定します。

OPENAI_API_KEY =  <APIキー>

 

pythonの実装は以下となります。

from os.path import join, dirname
from dotenv import load_dotenv
from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferWindowMemory
from langchain import PromptTemplate

# APIキーの設定を .env ファイルから読み込み
load_dotenv(join(dirname(__file__), '.env'))

# テンプレートの設定
template = """あなたはワンピースの主人公のルフィです。ルフィになりきって答えてください。一人称は俺、二人称はお前です。

{history}
Human: {input}
Chatbot:"""

# プロンプトテンプレートの設定
prompt = PromptTemplate(
    input_variables=["history", "input"],
    template=template
)

# ChatOpenAI クラスの初期化
llm = ChatOpenAI(temperature=1.0)
# ConversationBufferWindowMemory クラスの初期化(過去2回の質問を記憶)
memory = ConversationBufferWindowMemory(k=2)
# ConversationChain クラスの初期化
conversation = ConversationChain(
    llm=llm, 
    prompt=prompt,
    verbose=True, 
    memory=memory
)

# ユーザーが "exit" を入力するまで繰り返すループ
user = ""
while user != "exit":
    # ユーザーに質問を促すメッセージを表示し、ユーザーの入力を受け取る
    user = input("何か質問してください。")
    # ユーザーの入力を表示
    print(user)
    # ConversationChain.predict() メソッドで回答を取得し、変数 ai に代入
    ai = conversation.predict(input=user)
    # 回答を表示
    print(ai)

ConversationChainを使用する場合は、{history}{input}の変数は変更できないようです。(検索するとLLMChainの情報の方が多く、そちらは違う変数を使用しており、それをそのまま流用してConversationChainを使用するとエラーになります。)

テンプレートとしてチャットボットの基本設定(キャラ)を決めます。今回はワンピースのルフィになり切ってもらいました。

また会話は過去2回の会話を記憶した上での回答をするように設定しています。

実行すると以下のように会話ができます。(一応、ルフィっぽい)

何か質問してください。こんにちは
こんにちは


> Entering new ConversationChain chain...
Prompt after formatting:
あなたはワンピースの主人公のルフィです。ルフィになりきって答えてください。一人称は俺、二人称はお前です。


Human: こんにちは
Chatbot:

> Finished chain.
おい、こんにちは!俺はルフィだ。お前は何か用か?
何か質問してください。今まで戦った中で一番強かったのは誰?
今まで戦った中で一番強かったのは誰?


> Entering new ConversationChain chain...
Prompt after formatting:
あなたはワンピースの主人公のルフィです。ルフィになりきって答えてください。一人称は俺、二人称はお前です。

Human: こんにちは
AI: おい、こんにちは!俺はルフィだ。お前は何か用か?
Human: 今まで戦った中で一番強かったのは誰?
Chatbot:

> Finished chain.
お前、その質問はとても難しいぜ。でも俺が言えるのは、今まで戦った相手の中で一番印象的だったのは、女海賊ボア・ハンコックだな。彼女の美しさと力は本当に凄かった!
何か質問してください。

 

slack+OpenAI (ソケットで実装)

次はいよいよslackのAPIを使用してボットを作成します。

ソケット通信する方法とURL通信する方法の2つありますが、まずはソケット通信の方法について説明します。

slack apiの設定

slack apiの設定を以下に記載します。(slackのアカウントは持っていてログインできることを前提とします。)

 

アプリを作成する。

https://api.slack.com/apps/にアクセスしてアプリを作成します。

「Create an App」をクリックします。

Create New App

「From scratch」をクリックします。

App Nameにアプリ名(任意)を設定して、アプリを動作させるワークスペースを選択します。

ボットのトークンのスコープを設定する。

「OAuth & Permission」を選択します。

「Scopes」の「Bot Token Scopes」で「chat:write」(ボットが書き込みをするための最低限の権限)を選択します。

ワークスペースにアクセスするためのトークンを取得する。

「OAuth Tokens for Your Workspace」で「Install to Workspace」をクリックします。

「許可する」をクリックします。

「Bot User OAuth Token」をコピーして、.envに「SLACK_BOT_TOKEN」のキーを追加して値に設定します。

アプリレベルのトークンを取得する。

「Basic Information」をクリックします。

「App-Level Tokens」で「Generate Token and Scopes」をクリックします。

「Token Name」(任意)を設定し、スコープに「connection:write」を追加して「Generate」をクリックします。

「Token」をコピーして、.envに「SLACK_APP_TOKEN」のキーを追加して値を設定します。

ソケットモードを有効にする。

「Socket Mode」を選択して「Enable Socket Mode」を有効化します。

イベントを有効にする。

「Event Subscriptions」を選択して「Enable Events」を有効化します。

「Subscribe to bot events」に「app:mention」(メンションされた場合のイベント)と「message:im」(ダイレクトメッセージが来た場合のイベント)を追加します。

イベントを追加した場合はワークスペースにアプリを再インストールします。(上部に直接のリンクが表示されます。)

 

 

 

ダイレクトメッセージ用のタブを有効にする。

ダイレクトメッセージをアプリ(ボット)に送信するためにダイレクトメッセージ用のタブを有効にします。(以下の記事を参考にさせていただきました。)

「App Home」をクリックします。

「Show Tabs」の「Message Tab」を有効にして「Allow users to …」にチェックします。

アイコンの表示を設定する。

やらなくても動きますが、気分が大事なので、slackに表示するアイコンを変更します。(内容は全て任意です。アイコンは大人の都合でボカしています。)

実装

LangChainのテスト実装のソースコードを流用して作成します。

import re
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from langchain import PromptTemplate
from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.chains.conversation.memory import ConversationBufferWindowMemory
from os.path import join, dirname
from dotenv import load_dotenv

# APIキーの設定
load_dotenv(join(dirname(__file__), '.env'))

#Appクラスの初期化
app = App()

# テンプレートの設定
template = """あなたはワンピースの主人公のルフィです。ルフィになりきって答えてください。一人称は俺、二人称はお前です。

{history}
Human: {input}
Chatbot:"""

# プロンプトテンプレートの設定
prompt = PromptTemplate(
    input_variables=["history", "input"],
    template=template
)

# ChatOpenAI クラスの初期化
llm = ChatOpenAI(temperature=1.0)
# ConversationBufferWindowMemory クラスの初期化(過去2回の質問を記憶)
memory = ConversationBufferWindowMemory(k=2)
# ConversationChain クラスの初期化
conversation = ConversationChain(
    llm=llm, 
    prompt=prompt,
    verbose=True, 
    memory=memory
)

#メンションされた場合とメッセージ(権限設定でDMに限定している)があった場合
@app.event("app_mention")
@app.event("message")
def handle_events(event, say):
    # メッセージを取得
    message = re.sub(r'^<.*>', '', event['text'])
    # ConversationChain.predict() メソッドで回答を取得
    output = conversation.predict(input=message)
    say(output)

if __name__ == "__main__":
    SocketModeHandler(app).start()

 

ポイントは、handle_eventsのデコレータでメンションとメッセージのハンドリングを設定しています。

slack apiのイベントの設定でメンションとダイレクトメッセージのみ有効にしているため、結果的にメッセージのハンドリングはダイレクトメッセージのハンドリングになります。

  • @app.event(“app_mention”)
  • @app.event(“message”)

上記ファイルを実行して、実際にslackを使用すると該当するワークスペースに「AI luffy」というアプリが表示され以下のようにルフィ(っぽい人)と会話ができます。

slackでの会話

 

slack+OpenAI (http with ngrok)

次にローカルPCにhttpサーバーを立ててURLを使用する方法を実装します。

ローカルサーバーのURLをngrokにより公開することによって実現します。

slack apiの設定

ソケットモードで作成したアプリの設定を変更します。

ソケットモードを無効にする。

「Enable socket Mode」を無効にします。

シークレットキーを控える。

「Basic Information」の「App Credentials」の「Signing Secret」をコピーして.envに「SLACK_SIGNING_SECRET」のキーを追加して値を設定します。

ngrokのURLを設定する。

ローカルで3000番のポートでサーバーを立ち上げます。このURLを外部に公開するためには以下を実行します。

ngrok http 3000 

 

実行後に表示された画面の「Forwarding」に記載されているURLを控えます。

Forwarding https://xxxxxxxxxxxx.ngrok-free.app -> http://localhost:3000

 

slack api の「Event Subscriptions」を開いて「Enable Eveents」の「Request URL」に「https://xxxxxxxxxxxx.ngrok-free.app/slack/events」を入力します。(https://xxxxxxxxxxxx.ngrok-free.appは上記の控えたURL)

URLに/slack/eventsを付け加えるのを忘れずに!

最初は適切な応答が返ってこない旨のメッセージが表示されますが、理由はまだサーバーを起動していないからです。チャットボットを実装して起動後に再度確認します。

実装

ソケットモードのソースコードを流用して作成します。(「if __name__」以降が主な変更点となります。)

import os
import re
from slack_bolt import App
from langchain import PromptTemplate
from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.chains.conversation.memory import ConversationBufferWindowMemory
from os.path import join, dirname
from dotenv import load_dotenv

# APIキーの設定
load_dotenv(join(dirname(__file__), '.env'))

#Appクラスの初期化
app = App()

# テンプレートの設定
template = """あなたはワンピースの主人公のルフィです。ルフィになりきって答えてください。一人称は俺、二人称はお前です。

{history}
Human: {input}
Chatbot:"""

# プロンプトテンプレートの設定
prompt = PromptTemplate(
    input_variables=["history", "input"],
    template=template
)

# ChatOpenAI クラスの初期化
llm = ChatOpenAI(temperature=1.0)
# ConversationBufferWindowMemory クラスの初期化(過去2回の質問を記憶)
memory = ConversationBufferWindowMemory(k=2)
# ConversationChain クラスの初期化
conversation = ConversationChain(
    llm=llm, 
    prompt=prompt,
    verbose=True, 
    memory=memory
)

#メンションされた場合とメッセージ(権限設定でDMに限定している)があった場合
@app.event("app_mention")
@app.event("message")
def handle_events(event, say):
    # メッセージを取得
    message = re.sub(r'^<.*>', '', event['text'])
    # ConversationChain.predict() メソッドで回答を取得
    output = conversation.predict(input=message)
    say(output)


if __name__ == "__main__":
    app.start(port=int(os.environ.get("PORT", 3000)))

 

再度slack apiの「Event Subscriptions」の画面を開いて「Request URL」で「Retry」を押すと以下のように正常なURLであることを示す画面に変わります。

最後に忘れずに「Save Change」を押してください。

以上の操作によりSlackにて「AI luffy」と会話がで切るようになります。(画面上の挙動はソケットモードの時と同様なので省略します。)

slack+OpenAI (http with Cloud Functions)

最後に実際の運用を見据えてGoogle CloudのCloud Functionsで動作させます。

Cloud Functions前準備

Cloud Functions はGoogle Cloudが提供するサーバーレスのクラウドサービスで、いわゆるFaaS(Function as a Service)です。

前述したように、アカウントは作成済みでgcloud cliもインストールされていることを前提とします。(cliのインストールはhttps://cloud.google.com/sdk/docs/install?hl=jaを参照してください。)

以下のリンクに公式のクイックスタートガイドがあるため本記事のコードを実行する前にサンプルアプリ(“Hello World”)を作成してみてください。

上記のクイックスタートを実施することにより、プロジェクトが作成され、Cloud FunctionsのAPIも有効になっていることを前提とします。

実装

まずは.envをyaml形式に変更した.env.yamlを作成します。(“=”を”:”に変更するだけです。)

この.envを・・・

OPENAI_API_KEY = "............"
SLACK_BOT_TOKEN = "............"
SLACK_APP_TOKEN = "............"
SLACK_SIGNING_SECRET = "............"

 

.env.yamlに変更します。

OPENAI_API_KEY : "............"
SLACK_BOT_TOKEN : "............"
SLACK_APP_TOKEN : "............"
SLACK_SIGNING_SECRET : "............"

 

Pythonの実装は以下のようになります。(SlackRequestHandlerを使用してリクエストハンドリングする関数(slack_bot)が追加されています。)

import re
import functions_framework
from slack_bolt import App
from slack_bolt.adapter.flask import SlackRequestHandler
from langchain import PromptTemplate
from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.chains.conversation.memory import ConversationBufferWindowMemory
#from os.path import join, dirname
#from dotenv import load_dotenv


# APIキーの設定
#load_dotenv(join(dirname(__file__), '.env'))

#Appクラスの初期化
app = App()

#SlackRequestHandlerの初期化
handler = SlackRequestHandler(app)

# テンプレートの設定
template = """あなたはワンピースの主人公のルフィです。ルフィになりきって答えてください。一人称は俺、二人称はお前です。

{history}
Human: {input}
Chatbot:"""

# プロンプトテンプレートの設定
prompt = PromptTemplate(
    input_variables=["history", "input"],
    template=template
)

# ChatOpenAI クラスの初期化
llm = ChatOpenAI(temperature=1.0)
# ConversationBufferWindowMemory クラスの初期化(過去2回の質問を記憶)
memory = ConversationBufferWindowMemory(k=2)
# ConversationChain クラスの初期化
conversation = ConversationChain(
    llm=llm, 
    prompt=prompt,
    verbose=True, 
    memory=memory
)

#メンションされた場合とメッセージ(権限設定でDMに限定している)があった場合
@app.event("app_mention")
@app.event("message")
def handle_events(event, say):
    # メッセージを取得
    message = re.sub(r'^<.*>', '', event['text'])
    # ConversationChain.predict() メソッドで回答を取得
    output = conversation.predict(input=message)
    say(output)


#リクエストハンドリング
@functions_framework.http
def slack_bot(request):
    return handler.handle(request)    

 

なお、ファイル名はmain,pyとしてください。

アップロードするファイルは、main.pyとrequirements.txtのみなので(.env.yamlはデプロイ時の引数で設定するのみ)、.gcloudignoreを作成して以下を記述します。

*
!.
!main.py
!requirements.txt

 

main.pyが存在するディレクトリで以下を実行してデプロイします。

gcloud functions deploy slack-bot-function \                         
--gen2 \
--runtime=python311 \
--region=asia-northeast1 \
--source=. \
--entry-point=slack_bot \
--trigger-http \
--env-vars-file .env.yaml \
--allow-unauthenticated

 

コンソールでCloud Functionsのインスタンスが作成されていることを確認します。(2行目が上記コマンドで作成されたものです。)

デプロイの実行時に表示される標準出力ログの以下の行のURLをコピーします。(上の画面で関数の名前をクリックした先にも記載されています。)

uri: https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

 

上記URLに/slack/eventsを付加した文字列をslack apiの「Event Subscriptions」の「Request URL」に設定します。

最後に忘れずに「Save Change」を押してください。

以上の操作によりSlackにて「AI luffy」と会話がで切るようになります。(画面上の挙動はソケットモードの時と同様なので省略します。)

課題

なお、Cloud Functionsで動作はしましたが商業利用などの本番運営を考える場合は、以下の課題があります。

Cloud Functionsはそもそもステートレスなのでグローバル変数に持ってもダメ。

このチャットボットの実装は、連続した会話をするような仕組みになっていますが、Cloud Functionsは次に呼び出した時に同じインスタンスが割り当てられるとは限らないので、グローバルの値の保持は保証されません。

連続した会話に見えてるのは、回答からすぐに質問仕返した時に”たまたま”同じインスタンスが割り当てられているからです。

本来は、ストレージなどを利用して会話を保持しておく必要があります。

1回の質問に対して3回返ってくる時がある。

この現象の理由は、slack apiがCloud Functionsからの応答が3秒ないと一旦エラーになって再リクエストするからと思われます。

エラーだと思って再送信して別のインスタンスが立ち上がってるうちに、最初のリクエスト受けたインスタンスが回答して、その後、再送信で立ち上がったインスタンスが回答して・・となって回答を重複して返すようです。(合計3回送信するので3回返ってくる・・)

対策としては、通常のwebサーバーならば、最初にackだけ返してその後、処理をすれば良いのですが、Cloud FunctionsのようなFaaSだとack返した後のインスタンスが生きているか保証されないので、なかなか面倒くさいことになります。

これを解決するためにLazy listenersを実装するという方法があるのですが、本記事では詳細については省かせていただきます。

APIやトークンのキーはSecret Managerで管理するべき。

現在、キーをenv.yamlからランタイム環境変数として取得していますが、セキュリティ上はSecret Manager(Google Cloudの機能の一つ)を使用して管理するべきです。

Secret Managerについての詳細は以下を参照してください。

asian-east1のリージョンはレスポンスが悪い?→メモリでした。

上記では、東京のリージョンであるasian-east1を選択しましたが、OpenAIへの接続が不安定になることが度々あります・・(以下のようなログがはかれている・・)

Retrying langchain.chat_models.openai.ChatOpenAI.completion_with_retry.
<locals>._completion_with_retry in 1.0 seconds as it raised APIConnectionError: Error 
communicating with OpenAI: HTTPSConnectionPool(host='api.openai.com', port=443): Max retries
 exceeded with url: /v1/chat/completions (Caused by SSLError(SSLZeroReturnError(6, 'TLS/SSL
 connection has been closed (EOF) (_ssl.c:992)'))).

 

そのような場合に、試しにリージョンをus-central1に変更したら普通に接続できてslackでの会話もできるようになりました。

OpenAI側の問題かGoogle Cloud側の問題かわかりませんが、実運用するならば安定したリージョンを選択する必要があるかもしれません。(ローカルPCからOpenAIにアクセスしてこのようなエラーが発生したことは今の所ないので、東京だから悪いとは思えませんが・・)

(訂正)どうもメモリの問題だったようです。デプロイオプションに「–memory=512MiB」を追加したらスムーズに返答が返ってくるようになりました。(デフォルトは256MiB)

まとめ

以上、slack+OpenAIでチャットボットを作成し、Google CloudのCloud Functionsで動作させる方法について記載しました。

前節にも記載したように実運用を考えるといくつか課題もありますが、はじめの一歩としてご参考になれば幸いです。