SlackからClaude 3 Sonnetを使いたい!Amazon BedrockとAWS Lambdaで気軽に環境を整える

AWS

2024.3.31

Topics

はじめに

Amazon Bedrockでは、Anthropic社の生成AIモデル「Claude 3 Sonnet」を利用できます。

前回の記事ではターミナルからの活用例をご紹介しましたが、今回はSlackから誰でも気軽に利用できる環境を整えたいと思います。

関連記事
$ cat してAmazon Bedrock ~ターミナルからClaude 3 Sonnetを活用~

作成した環境

作成した環境の概要

今回は試作版として、最低限の機能を実装しています。

SlackからのイベントをLambdaで受け取り、Bedrockに渡すだけの単純なスクリプトですが、Slackのスレッド履歴を活用することにより対話的な利用に対応しています。

動作例

構築の流れ

前提条件

あらかじめ以下の準備が必要です。

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関数の コード > レイヤーの追加 から、カスタムレイヤーとして追加します。

Lambdaレイヤー

関数URLの作成

関数の作成時 または 設定 > 関数 URL で関数 URLを作成します。

Slackからのリクエストを受け付ける必要がある為、認証タイプはNONEで設定します。
※ パブリックでのリクエストを受け付ける形となりますので、スクリプト内にリクエスト署名の検証を含めています!
参考: Slack アプリのセキュリティ強化 | Slack

タイムアウトの延長

生成AIのレスポンスには時間を要するため 設定 > 一般設定タイムアウト を3分程度に延長します。

Bedrockに対するアクセス許可を設定

設定 > アクセス権限 > 実行ロール から利用しているIAM Roleを開き、AmazonBedrockFullAccess等のアクセス許可を追加してください。

環境変数を設定

設定 > 環境変数 でスクリプトの設定を行います。

Lambda環境変数

  • 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の作成

  1. Slack Appsで新規アプリを作成
    • Slack APIにアクセス
    • Create New App > From scratchに進む
    • 必要な情報を入力:
      • App Name: アプリの名前を入力
      • Pick a workspace to develop your app in: 利用・開発するワークスペースを選択
  2. Event Subscriptionsの設定
    • Basic Information > Add features and functionalityセクションに進む
    • Event Subscriptionsを選択
      • Enable Eventsを有効化
      • Request URLにLambda関数のURLを入力するとリクエストテストが行われ、成功するとVerifiedが表示されます
        • 失敗した場合は、CloudWatch Logsのエラーログ等を確認してください
      • Subscribe to bot eventsapp_mentionを追加
  3. 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 WorkspaceInstall to Workspaceボタンをクリック
        • Bot User OAuth Tokenが発行されるので控えてください
  4. Display Informationの設定
    • Basic Information > Display Informationセクションに進む
    • BOTの表示名、アイコン、説明文などが設定できます
  5. Signing Secretの確認
    • Basic Information > App Credentialsセクションに進む
    • Signing SecretShowで表示し、控えてください
  6. Lambda関数の環境変数を更新
    • SLACK_BOT_TOKENBot User OAuth Tokenの値を設定します
    • SLACK_SIGNING_SECRETSigning Secretの値を設定します

以上の手順で、SlackにBOTが追加され、メンションにて利用可能となるはずです!
※ 利用したいチャンネルに招待してください

活用例

Slackから生成AIモデルが利用できると、何が嬉しいのでしょうか。具体的な事例をいくつかご紹介します!

自動通知された内容を翻訳する

各々が手元の翻訳ツールを利用するのとは違い、他のメンバーも同時に確認できるのがメリットです。また、要約やその他の指示を組み合わせることも可能です。

翻訳例

スレッドでの議論を要約する

スレッドでの議論が長期化した場合、後追いや議題の整理が難しくなる場合があります。要約を指示すると、状況を客観的に整理することが可能です。

議論の要約

ドキュメントの素案を作成する

Slackスレッド上のやり取りで問題が解決したり、適した手法が明らかになった場合、何らかの手順やナレッジにまとめたいはずです。その場でドキュメント作成を指示すると、文章構成や文体が整った素案が得られます。

手順作成

内容の調整は必要ですが、白紙から作成するのと比べれば心理的なコストが大幅に下がるため、社内ナレッジ化が促進できます!

今後の展望

今回はまず実用性を判断するために、試作版として簡単な作りで運用中です。

本番運用に進める場合はブラッシュアップが必要で、例えば以下のような構想があります。

  • 利用ガイドラインをアップデートし、社内での活用を促進する
  • 目的に応じたプロンプトを事前に用意し、自動判別またはユーザー指定で利用可能とする
  • CloudWatch Logsによる利用状況の集計やモニタリングを行う
  • Slackワークフローと組み合わせ、より複雑なタスクを行う
  • 長文を処理する場合は、事前により安価なClaude 3 Haikuで前処理を行う
  • 今後登場すると思われるClaude 3 Opusへの対応と、トークン量削減などのコスト対策 (単純に5倍の価格になるため!)
  • (本来はSlackに3秒以内にレスポンスを返す必要がある為、Step Functions等で実装する)
  • (考え中……と表示する処理のclearIntervalが、タイミングによっては悪さをしそうなので正しく実装する)

さいごに

生成AIモデルをSlackから利用する最大のメリットは、他のメンバーからも利用状況が見えることです!

どのようなプロンプトを与えると良い回答が得られるのか、どういった場面でより活用できるのか、あるいはどの程度のハルシネーションが発生するのかなど、生成AIを活用する上での暗黙のナレッジが、自然と横展開されることを期待してます。

sdat

2015年新卒入社。物理とクラウドの運用現場と、クラウド移行のチームを経て、今はCCoEチームのマネージャーです。初めてのPCはVineLinux、趣味で触るのはJavaScript……でしたが、ChatGPTのお陰で言語不問になりました。物事を手持ちの札で解決できたときが一番楽しい。最強になりたい。

Recommends

こちらもおすすめ

Special Topics

注目記事はこちら