SlackからClaude 3 Sonnetを使いたい!Amazon BedrockとAWS Lambdaで気軽に環境を整える
はじめに
Amazon Bedrockでは、Anthropic社の生成AIモデル「Claude 3 Sonnet」を利用できます。
前回の記事ではターミナルからの活用例をご紹介しましたが、今回はSlackから誰でも気軽に利用できる環境を整えたいと思います。
作成した環境
今回は試作版として、最低限の機能を実装しています。
SlackからのイベントをLambdaで受け取り、Bedrockに渡すだけの単純なスクリプトですが、Slackのスレッド履歴を活用することにより対話的な利用に対応しています。
構築の流れ
前提条件
あらかじめ以下の準備が必要です。
- Amazon Bedrock上でClaude 3 Sonnetに対するモデルアクセス
- 参考: Anthropic’s Claude 3 Sonnet modelがAmazon Bedrockで利用可能になったので使ってみた | NHN テコラス Tech Blog | AWS、機械学習、IoTなどの技術ブログ
- 2024/03現在、東京リージョンは対応していないのでus-east-1 (バージニア北部)を選択します
Lambda関数の作成
任意のリージョンでLambda関数を作成します。以下はNode.js 20による参考実装です。
スクリプト全文
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; import { WebClient } from '@slack/web-api'; import { createHmac, timingSafeEqual } from 'crypto'; // Slack BotのトークンとAIモデルIDを環境変数から読み込む const slackBotToken = process.env.SLACK_BOT_TOKEN; const modelId = process.env.CLAUDE_MODEL_ID || 'anthropic.claude-3-sonnet-20240229-v1:0'; const characterConfiguration = process.env.CHARACTER_CONFIG || 'あなたは優れたAIアシスタントとしてユーザーの質問に答えます'; const characterThinkingText = process.env.CHARACTER_THINKING_TEXT || 'Loading'; const slackSigningSecret = process.env.SLACK_SIGNING_SECRET; // AWSとSlackのクライアントを初期化 const awsClient = new BedrockRuntimeClient({ region: 'us-east-1' }); const slackClient = new WebClient(slackBotToken); // Slack リクエストの署名を検証する関数 function verifySlackRequestSignature(event) { const requestSignature = event.headers['x-slack-signature']; const requestTimestamp = event.headers['x-slack-request-timestamp']; // 5分以上前のタイムスタンプを持つリクエストは無視 const time = Math.floor(new Date().getTime() / 1000); if (Math.abs(time - requestTimestamp) > 300) { return false; } // 署名基盤文字列を作成 const sigBasestring = `v0:${requestTimestamp}:${event.body}`; const mySignature = `v0=` + createHmac('sha256', slackSigningSecret) .update(sigBasestring, 'utf8') .digest('hex'); // 計算した署名とSlackから受け取った署名を比較 return timingSafeEqual(Buffer.from(mySignature, 'utf8'), Buffer.from(requestSignature, 'utf8')); } // Slackのスレッドからプロンプトを生成する関数 async function generatePromptsFromSlackHistory(slackEvent) { const prompts = []; // プロンプトを格納する配列 let previousRole = "user"; // 前のメッセージのロールを追跡 // スレッドのメッセージかどうかを判定 const isThread = slackEvent.thread_ts && slackEvent.thread_ts !== slackEvent.ts; const targetTs = isThread ? slackEvent.thread_ts : slackEvent.ts; // Slack APIを使ってスレッドのメッセージを取得 const response = await slackClient.conversations.replies({ channel: slackEvent.channel, ts: targetTs }); console.log(JSON.stringify({ event: "slackThreadSearched", messages: response.messages })); // 最後のメッセージがBotからのものなら削除 if (response.messages && response.messages[response.messages.length - 1].bot_id) { response.messages.pop(); } // メッセージが空かBotからのものだけなら、ユーザーメッセージを強制的に挿入 if (response.messages.length === 0 || response.messages[0].bot_id) { prompts.push({ role: "user", content: "----" }); } // メッセージごとにプロンプトを生成 response.messages.forEach((message) => { const role = message.bot_id ? 'assistant' : 'user'; let content = message.text; // アタッチメントがある場合は含める if (message.attachments) { content += message.attachments.map(a => `\n[Attachments]\n${JSON.stringify(a)}`).join("\n"); } // 前のメッセージと同じロールの場合はテキストを結合 if (prompts.length > 0 && role === previousRole) { prompts[prompts.length - 1].content += `\n----\n${content}`; } else { prompts.push({ role, content }); } previousRole = role; }); // 生成されたプロンプトをログに記録 console.log(JSON.stringify({ event: "promptGenerated", prompts })); return prompts; } // AIモデルを呼び出してテキストを生成する関数 async function generateTextWithAI(prompts) { const command = new InvokeModelCommand({ modelId, contentType: "application/json", accept: "application/json", body: JSON.stringify({ anthropic_version: "bedrock-2023-05-31", max_tokens: 2000, system: characterConfiguration, messages: prompts, }), }); try { const response = await awsClient.send(command); const responseText = Buffer.from(response.body).toString('utf8'); const parsedResponse = JSON.parse(responseText); // AIからの応答をログに記録 console.log(JSON.stringify({ event: "aiResponseReceived", parsedResponse })); return { text: parsedResponse.content?.[0]?.text ?? "AIからの応答がありませんでした。", usage: parsedResponse.usage }; } catch (error) { // AIモデル呼び出しエラーをログに記録 console.error('Error calling AI model:', error); return { text: "AIモデルの呼び出し中にエラーが発生しました。", usage: {} }; } } // 応答テキストを特定の長さで分割する関数 function splitTextIntoChunks(text, chunkSize) { const chunks = []; for (let i = 0; i < text.length; i += chunkSize) { chunks.push(text.substring(i, i + chunkSize)); } return chunks; } // トークン数と価格を計算する関数 function calculateTokenPrices(inputTokens, outputTokens) { const inputPricePer1000 = 0.00300; const outputPricePer1000 = 0.01500; const inputCost = inputTokens / 1000 * inputPricePer1000; const outputCost = outputTokens / 1000 * outputPricePer1000; return { inputCost, outputCost }; } // SlackにAIの応答とトークン使用量を投稿する関数 async function postResponseWithTokenInfoToSlack(channel, responseText, initialResponseTs, usage) { const { inputCost, outputCost } = calculateTokenPrices(usage.input_tokens, usage.output_tokens); const totalPrice = inputCost + outputCost; // 応答テキストをSlackのメッセージブロックに分割 const textChunks = splitTextIntoChunks(responseText, 3000); const blocks = textChunks.map(chunk => ({ type: "section", text: { type: "mrkdwn", text: chunk } })); // トークン使用量と想定価格を含むメッセージブロックを追加 blocks.push({ type: "context", elements: [ { type: "mrkdwn", text: `入力トークン数: ${usage.input_tokens}, 出力トークン数: ${usage.output_tokens}, 想定価格: $${totalPrice.toFixed(4)}` } ] }); // Slackに応答を投稿 await slackClient.chat.update({ channel: channel, ts: initialResponseTs, blocks: JSON.stringify(blocks), text: textChunks[0].slice(0, 2800) }); } // Slackイベントを処理するメインの関数 export async function handler(event) { if (!event.headers['x-slack-signature'] || !verifySlackRequestSignature(event)) { // 署名が不一致の場合の処理 console.error('Verification failed', event); return { statusCode: 400, body: 'Verification failed' }; } const body = JSON.parse(event.body); // URL検証リクエストに応答 if (body.type === 'url_verification') { return { statusCode: 200, headers: { 'Content-Type': 'text/plain' }, body: body.challenge }; } // リトライメッセージを無視 if (event.headers['x-slack-retry-num']) { return { statusCode: 200 }; } // Slackメンションを受信したイベントをログに記録 const slackEvent = body.event; console.log(JSON.stringify({ event: "slackMentionReceived", slackEvent })); if (slackEvent.subtype === 'bot_message') { return { statusCode: 200 }; } // 処理中のテキストをSlackに投稿 let thinkingText = characterThinkingText; const initialResponse = await slackClient.chat.postMessage({ channel: slackEvent.channel, text: thinkingText, thread_ts: slackEvent.ts }); // 処理中テキストの更新をスケジュール const interval = setInterval(async () => { thinkingText += "..."; await slackClient.chat.update({ channel: slackEvent.channel, ts: initialResponse.ts, text: thinkingText }); }, 2000); // Slackのスレッドからプロンプトを生成し、AIでテキストを生成 const prompts = await generatePromptsFromSlackHistory(slackEvent); const { text: aiResponseText, usage } = await generateTextWithAI(prompts); // 処理中テキストの更新を停止し、結果をSlackに投稿 clearInterval(interval); await postResponseWithTokenInfoToSlack(slackEvent.channel, aiResponseText, initialResponse.ts, usage); return { statusCode: 200 }; }
@slack/web-api用 Lambdaレイヤーの作成
SlackAPIを利用するため、npmパッケージをLambdaレイヤーとして準備します。
※ npmパッケージは nodejs/node_modules/*
となるようにzip化すると、Lambdaレイヤーとして利用可能になります。
ローカル環境で以下のようなzipを作成し Lambda
> レイヤー
> レイヤーの作成
からアップロードしてください。
$ mkdir nodejs $ cd nodejs $ npm install @slack/web-api $ cd .. $ zip -r layer.zip nodejs
その後、先ほど作成したLambda関数の コード
> レイヤーの追加
から、カスタムレイヤーとして追加します。
関数URLの作成
関数の作成時 または 設定
> 関数 URL
で関数 URLを作成します。
Slackからのリクエストを受け付ける必要がある為、認証タイプはNONEで設定します。
※ パブリックでのリクエストを受け付ける形となりますので、スクリプト内にリクエスト署名の検証を含めています!
参考: Slack アプリのセキュリティ強化 | Slack
タイムアウトの延長
生成AIのレスポンスには時間を要するため 設定
> 一般設定
で タイムアウト
を3分程度に延長します。
Bedrockに対するアクセス許可を設定
設定
> アクセス権限
> 実行ロール
から利用しているIAM Roleを開き、AmazonBedrockFullAccess
等のアクセス許可を追加してください。
環境変数を設定
設定
> 環境変数
でスクリプトの設定を行います。
CHARACTER_CONFIG
- Systemプロンプトとして利用されるAIのキャラクター設定で、システムプロンプトとして生成AIに渡されます
- 例: あなたはクラウディアという名前の優れたAIアシスタントです。
Slackのmrkdwn形式で回答してください。<@********>はあなた宛のメンションです。
あなたは画像や添付ファイルを読み込む機能を有していません。
分からない・知らないことは素直にそう伝えてください。誤った情報を生み出さないでください。
"----"はユーザー発言の区切り文字として利用されます。一番最後の指示に従ってください。
CHARACTER_THINKING_TEXT
- メンションを受け付けたことを伝えるために投稿するメッセージです
- 参考実装のスクリプトでは、2秒毎に末尾の
...
を増やしています - 例:
クラウディアさんが考え中です...
CLAUDE_MODEL_ID
- 利用する生成AIモデルを指定します
- 例:
anthropic.claude-3-sonnet-20240229-v1:0
SLACK_BOT_TOKEN
及びSLACK_SIGNING_SECRET
はSlack Appsの作成後に設定します
Slack Appsの作成
- Slack Appsで新規アプリを作成
- Slack APIにアクセス
Create New App
>From scratch
に進む- 必要な情報を入力:
App Name
: アプリの名前を入力Pick a workspace to develop your app in
: 利用・開発するワークスペースを選択
- Event Subscriptionsの設定
Basic Information
>Add features and functionality
セクションに進むEvent Subscriptions
を選択Enable Events
を有効化Request URL
にLambda関数のURLを入力するとリクエストテストが行われ、成功するとVerified
が表示されます- 失敗した場合は、CloudWatch Logsのエラーログ等を確認してください
Subscribe to bot events
にapp_mention
を追加
- Permissionsの設定
- 同じく
Basic Information
>Add features and functionality
セクションに進む Permissions
を選択Scopes > Bot Token Scopes
で以下のOAuth Scopeを追加:app_mentions:read
channels:history
chat:write
groups:history
OAuth Tokens for Your Workspace
でInstall to Workspace
ボタンをクリックBot User OAuth Token
が発行されるので控えてください
- 同じく
- Display Informationの設定
Basic Information
>Display Information
セクションに進む- BOTの表示名、アイコン、説明文などが設定できます
- Signing Secretの確認
Basic Information
>App Credentials
セクションに進むSigning Secret
をShow
で表示し、控えてください
- Lambda関数の環境変数を更新
SLACK_BOT_TOKEN
にBot User OAuth Token
の値を設定しますSLACK_SIGNING_SECRET
にSigning Secret
の値を設定します
以上の手順で、SlackにBOTが追加され、メンションにて利用可能となるはずです!
※ 利用したいチャンネルに招待してください
活用例
Slackから生成AIモデルが利用できると、何が嬉しいのでしょうか。具体的な事例をいくつかご紹介します!
自動通知された内容を翻訳する
各々が手元の翻訳ツールを利用するのとは違い、他のメンバーも同時に確認できるのがメリットです。また、要約やその他の指示を組み合わせることも可能です。
スレッドでの議論を要約する
スレッドでの議論が長期化した場合、後追いや議題の整理が難しくなる場合があります。要約を指示すると、状況を客観的に整理することが可能です。
ドキュメントの素案を作成する
Slackスレッド上のやり取りで問題が解決したり、適した手法が明らかになった場合、何らかの手順やナレッジにまとめたいはずです。その場でドキュメント作成を指示すると、文章構成や文体が整った素案が得られます。
内容の調整は必要ですが、白紙から作成するのと比べれば心理的なコストが大幅に下がるため、社内ナレッジ化が促進できます!
今後の展望
今回はまず実用性を判断するために、試作版として簡単な作りで運用中です。
本番運用に進める場合はブラッシュアップが必要で、例えば以下のような構想があります。
- 利用ガイドラインをアップデートし、社内での活用を促進する
- 目的に応じたプロンプトを事前に用意し、自動判別またはユーザー指定で利用可能とする
- CloudWatch Logsによる利用状況の集計やモニタリングを行う
- Slackワークフローと組み合わせ、より複雑なタスクを行う
- 長文を処理する場合は、事前により安価な
Claude 3 Haiku
で前処理を行う - 今後登場すると思われる
Claude 3 Opus
への対応と、トークン量削減などのコスト対策 (単純に5倍の価格になるため!) - (本来はSlackに3秒以内にレスポンスを返す必要がある為、Step Functions等で実装する)
- (考え中……と表示する処理のclearIntervalが、タイミングによっては悪さをしそうなので正しく実装する)
さいごに
生成AIモデルをSlackから利用する最大のメリットは、他のメンバーからも利用状況が見えることです!
どのようなプロンプトを与えると良い回答が得られるのか、どういった場面でより活用できるのか、あるいはどの程度のハルシネーションが発生するのかなど、生成AIを活用する上での暗黙のナレッジが、自然と横展開されることを期待してます。
テックブログ新着情報のほか、AWSやGoogle Cloudに関するお役立ち情報を配信中!
Follow @twitter2015年新卒入社。物理とクラウドの運用現場と、クラウド移行のチームを経て、今はCCoEチームのマネージャーです。初めてのPCはVineLinux、趣味で触るのはJavaScript……でしたが、ChatGPTのお陰で言語不問になりました。物事を手持ちの札で解決できたときが一番楽しい。最強になりたい。
Recommends
こちらもおすすめ
-
Amazon Bedrockのウォーターマーク検出を試してみた!
2024.5.23
Special Topics
注目記事はこちら
データ分析入門
これから始めるBigQuery基礎知識
2024.02.28
AWSの料金が 10 %割引になる!
『AWSの請求代行リセールサービス』
2024.07.16