
Amazon BedrockとAWS Lambdaで長文要約botを作成してみた
Slack用の長文要約botを作成してみました
はじめに
こんにちは!クラスメソッドオペレーションズ株式会社の高浪です。AWSを使用し、生成AIアプリケーションbotを作成してみました。題材は「メンションすると長文を3行に要約してくれるSlackbot」です。
先に結論を書くと、構成自体はシンプルで、私の環境では本記事の手順に沿って1時間程度で構築できました。これからBedrockを触る方の参考になれば幸いです。
完成したもの
Slackで@長文要約botくんにニュース記事などの長文を添えてメンションすると、数秒後にスレッドへ3行要約が返ってきます。使用したモデルはAmazon Bedrock上のClaude Sonnet 4.6です。
- 月額コスト: 実測でほぼBedrockのトークン課金のみ
- 常時起動リソース: なし(すべてサーバーレス)
- 追加パッケージ: なし。検証用として、Python標準ライブラリとLambdaランタイムに同梱されているboto3を使用するため、zipパッケージやLambda Layerは不要です。
アーキテクチャ
Slack(メンション)
↓ Events API(HTTP POST)
Lambda関数URL
↓
受付Lambda――署名検証して即200 OKを返す
↓ 非同期Invoke
処理Lambda――Bedrockで要約を生成
↓ chat.postMessage
Slack(スレッドに返信)
ポイントはLambdaを2段に分けていることです。SlackのEvents APIは3秒以内に応答がないとリトライを送ってくる一方、生成AIの応答には数秒かかります。そこで受付側は署名検証だけして即座に200を返し、重い処理はInvocationType="Event"(非同期呼び出し)で処理側に委譲します。この構成ならAmazon SQSを挟まずに済み、リソースも最小限です。
今回は単一のエンドポイントでPOSTを受け付ける構成のため、API Gatewayではなく、必要な機能を満たすLambda関数URLを採用しました。
認証タイプにはNONEを使用します。Slackからのリクエストであることは署名検証によって確認します。
ただし、関数URL自体は公開されるため、不正アクセスによる呼び出し回数やコストへの影響を考慮し、本番利用では監視やレート制御も検討してください。
その前に:2025年以降のBedrockまわりの変更点
以前と現在で大きく変わっている点が2つあります。
1つ目は、モデル利用開始手順の変更です。以前はBedrockコンソールの「モデルアクセス」から手動でモデルを有効化する必要がありましたが、現在は、必要な権限や支払い方法などの前提条件を満たしていれば、サーバーレス基盤モデルの初回呼び出し時に利用手続きが自動的に開始されます。
Anthropicモデルでは、初回利用前に利用目的の提出が必要です。モデルカタログから対象モデルを開いてフォームを提出します。利用目的は提出後すぐに反映されますが、初回のサブスクリプション処理には時間がかかる場合があります。組織の権限設定によっては、管理者による事前対応が必要になる場合があります。
2つ目は、推論プロファイルIDでの呼び出しです。本記事で使用するClaude Sonnet 4.6は、東京リージョンからのIn-Region呼び出しには対応していないため、Geo推論プロファイルを指定します。日本国内で処理を完結させる場合は、東京・大阪へルーティングされるjp.プレフィックスのプロファイルを利用できます。
jp.anthropic.claude-sonnet-4-6
正確なIDはBedrockコンソールのモデルカタログにある対象モデルの詳細画面で確認できます。
構築手順
構築は「下流から上流へ」の順で進め、各段階で単体テストを挟みます。こうすると障害の切り分けが常に「直前に作ったもの」に絞れます。
Step 1:IAMロールを先に2本作る
Lambda関数の作成前に、実行ロールをインラインポリシーまで含めて完成させておきます。
処理Lambda用ロール(slack-summary-process-role)
信頼されたエンティティにAWSのサービス(Lambda)を選びAWSLambdaBasicExecutionRoleをアタッチして作成します。作成後、続けてインラインポリシーを追加します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "*"
}
]
}
検証用途のためResourceは*にしていますが、本番では推論プロファイルと基盤モデルの両方のARNに絞ることを推奨します。

受付Lambda用ロール(slack-summary-receive-role)
同様に作成し、インラインポリシーには処理Lambdaへの呼び出し許可を付けます。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:slack-summary-process"
}
]
}

123456789012はご自身のアカウントIDに置き換えてください。
Step 2:処理Lambdaを作成して単体テスト
関数名slack-summary-process、ランタイムPython 3.14、実行ロールに先ほどのロールを指定して作成します。

コードはprocess_slack_handler.pyを貼り付けてDeployします。
"""処理Lambda(Slack版):Bedrockで要約し、chat.postMessageでスレッドに返信する。
"""
import json
import os
import re
import urllib.request
import boto3
bedrock = boto3.client("bedrock-runtime")
SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
MODEL_ID = os.environ["MODEL_ID"]
SYSTEM_PROMPT = (
"あなたは要約アシスタントです。送られてきた文章を、"
"重要なポイントを落とさずに3行以内の日本語で要約してください。"
"前置きや補足は不要で、要約本文だけを返してください。"
)
def summarize(text: str) -> str:
"""Bedrock Converse APIで要約を生成する"""
response = bedrock.converse(
modelId=MODEL_ID,
system=[{"text": SYSTEM_PROMPT}],
messages=[{"role": "user", "content": [{"text": text}]}],
inferenceConfig={"maxTokens": 500, "temperature": 0.3},
)
return response["output"]["message"]["content"][0]["text"]
def post_to_slack(channel: str, thread_ts: str, text: str) -> None:
"""chat.postMessageで元メッセージのスレッドに返信する"""
req = urllib.request.Request(
"https://slack.com/api/chat.postMessage",
data=json.dumps(
{"channel": channel, "thread_ts": thread_ts, "text": text}
).encode("utf-8"),
headers={
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
},
method="POST",
)
with urllib.request.urlopen(req) as res:
result = json.loads(res.read())
# Slackは失敗でもHTTP200を返すので、okフィールドで判定する
if not result.get("ok"):
raise RuntimeError(f"Slack API error: {result.get('error')}")
def lambda_handler(event, context):
channel = event["channel"]
thread_ts = event["thread_ts"]
# メンション部分(<@U12345678>)を除去して本文だけにする
text = re.sub(r"<@[A-Z0-9]+>", "", event["text"]).strip()
if not text:
post_to_slack(channel, thread_ts, "要約したい文章と一緒にメンションしてください。")
return {"statusCode": 200}
try:
summary = summarize(text)
except Exception as e:
print(f"Bedrock error: {e}")
summary = "ごめんなさい、要約に失敗しました。もう一度試してください。"
post_to_slack(channel, thread_ts, summary)
return {"statusCode": 200}
設定のポイントは2つです。
- タイムアウトを30秒に変更する(デフォルトの3秒ではタイムアウトする可能性が高いため)

- 環境変数に
MODEL_ID(推論プロファイルID)とSLACK_BOT_TOKEN(この時点では仮の文字列で可)を設定する

作成できたらテストタブから以下のイベントで実行します。
{
"channel": "dummy",
"thread_ts": "1",
"text": "これはテストです。LambdaからBedrockを呼び出せるか確認しています。"
}
Slack API error: invalid_authが表示されれば、少なくともSlack APIの呼び出しまで処理が進んだことを確認できます。
Bedrockの呼び出しに成功したかどうかは、CloudWatch Logs Bedrock errorが出力されていないことを併せて確認してください。

Step 3:Slackアプリを作成する
Slack APIのアプリ管理画面から「Create New App」→「From scratch」でアプリを作成します。以降は順序が重要です。
1.OAuth & Permissions→Bot Token Scopes app_mentions:readとchat:writeを追加する

2.App Home→App Display Nameでボットの表示名とユーザー名を設定する

3.OAuth & Permissionsに戻り、ワークスペースへインストールする

4.表示されたBot User OAuth Token(xoxb-で始まる文字列)を処理Lambdaの環境変数SLACK_BOT_TOKENに設定する

5.Basic Information→App Credentials→Signing Secretを控えておく

手順1と2を飛ばしてインストールしようとすると「インストールするボットユーザーがありません」というエラーになります。また手順5では、同じ画面に並ぶClient SecretやVerification Tokenと取り違えないよう注意してください。
Step 4:受付Lambdaを作成して単体テスト
関数名slack-summary-receiveで作成します。実行ロールには「既存のロールを使用する」を選択し、Step 1で作成したslack-summary-receive-roleを指定してください。receive_slack_handler.pyを貼り付けてDeployします。タイムアウトはデフォルトの3秒のままで問題ありません。環境変数は以下の2つです。
関数名slack-summary-receiveで作成し、receive_slack_handler.pyを貼り付けてDeployします。タイムアウトはデフォルトの3秒のままで問題ありません。環境変数は以下の2つです。
SLACK_SIGNING_SECRET:Step 3で控えたSigning SecretPROCESS_FUNCTION_NAME:slack-summary-process

続いて「設定」→「関数URL」から認証タイプNONEで関数URLを作成し、ターミナルから疎通確認します。

"""受付Lambda(Slack版):Events APIのWebhookを受け、署名検証して即200を返す。
環境変数:
SLACK_SIGNING_SECRET :SlackアプリのSigning Secret
PROCESS_FUNCTION_NAME:処理Lambdaの関数名
"""
import hashlib
import hmac
import json
import os
import time
import boto3
lambda_client = boto3.client("lambda")
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
PROCESS_FUNCTION_NAME = os.environ["PROCESS_FUNCTION_NAME"]
def verify_signature(body: str, timestamp: str, signature: str) -> bool:
"""x-slack-signatureヘッダを検証する"""
if not timestamp or not signature:
return False
# リプレイ攻撃対策:5分以上古いリクエストは拒否
if abs(time.time() - int(timestamp)) > 60 * 5:
return False
base = f"v0:{timestamp}:{body}"
digest = hmac.new(
SLACK_SIGNING_SECRET.encode("utf-8"),
base.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"v0={digest}", signature)
def lambda_handler(event, context):
body = event.get("body", "")
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
if not verify_signature(
body,
headers.get("x-slack-request-timestamp", ""),
headers.get("x-slack-signature", ""),
):
return {"statusCode": 403, "body": "invalid signature"}
payload = json.loads(body)
# 初回のURL検証:challengeをそのまま返す
if payload.get("type") == "url_verification":
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"challenge": payload["challenge"]}),
}
# Slackのリトライは処理済みの可能性が高いので無視(要約の重複防止)
if "x-slack-retry-num" in headers:
return {"statusCode": 200, "body": "ok (retry ignored)"}
ev = payload.get("event", {})
# ボット自身の投稿には反応しない(無限ループ防止)
if ev.get("bot_id") or ev.get("subtype") == "bot_message":
return {"statusCode": 200, "body": "ok (bot message ignored)"}
if ev.get("type") == "app_mention":
lambda_client.invoke(
FunctionName=PROCESS_FUNCTION_NAME,
InvocationType="Event", # 非同期:応答を待たない
Payload=json.dumps(
{
"channel": ev["channel"],
"thread_ts": ev.get("thread_ts") or ev["ts"],
"text": ev.get("text", ""),
}
).encode("utf-8"),
)
# Slackには即座に200を返す(3秒ルール)
return {"statusCode": 200, "body": "ok"}
invalid signatureが返れば正常です。署名のないリクエストを正しく拒否できています。

Step 5:Slackと接続する
Slackアプリ設定のEvent Subscriptionsを有効化し、Request URLに関数URLを貼ります。数秒で「Verified ✓」と表示されれば、署名検証とURL検証(challenge応答)が本番のSlackリクエストで動いた証拠です。

同じページの「Subscribe to bot events」にapp_mentionを追加しSave Changesを必ず押します。ここを忘れるとイベントが一切送られず、ボットは沈黙し続けます。
Step 6:結合テスト
チャンネルにボットを招待(/invite @長文要約botくん)します。

試しに私のブログの文章を入力したところ、このように綺麗にまとめてくれました。

一度目で体験したこと
一度目の構築で私が実際に踏んだミスを、時系列で紹介します。
その1:インラインポリシーの付け忘れ
IAMロールを自作した際、ロール本体は作ったものの、BedrockやLambda Invokeのインラインポリシーを付け忘れていました。厄介なのはこれが即座には発覚しないことです。関数の作成もコードのデプロイも成功し、実際にリクエストが流れて初めてAccessDeniedExceptionが発生します。「ロールの作成からインラインポリシーの設定までを1セットで完了させる」が教訓です。
その2:Durationから失敗した処理段階を推測する
今回は通常時より明らかに短いDurationだったため、モデルによる生成処理に入る前に失敗した可能性があると推測しました。実際の原因は、その1で紹介したインラインポリシーの付け忘れによるAccessDeniedExceptionでした。Durationは、失敗した処理段階を推測するための補助情報になります。
今回のケースでは、モデルによる生成処理に入る前に失敗した可能性があると推測できました。Durationは、失敗した処理段階を推測するための補助情報になります。
その3:最終手段「エラーをSlackにしゃべらせる」
CloudWatch Logsで目当てのエラー行がなかなか見つからず迷走したとき、検証専用の非公開チャンネルで、一時的に例外内容を返信させました。例外にはARNやアカウントIDなどが含まれる可能性があるため、本番環境や共有チャンネルでは使用せず、必要に応じて機密情報を隠してください。原因の確認後は、直ちに一般向けのエラーメッセージへ戻します。
except Exception as e:
print(f"Bedrock error: {e}")
summary = f"[デバッグ] エラー詳細: {type(e).__name__}: {e}"
次のメンションで、エラーの全文(どのロールが、どのリソースへの、どのアクションを拒否されたか)がスレッドに返信され、一発で原因が確定しました。なお、これはあくまで一時的なデバッグ手段です。
まとめ
- Lambda2段構成+Bedrockで、格安のSlack要約botが作れる
- CloudWatch LogsのDurationは失敗した処理段階を推測するための補助情報になる
- 検証環境では、機密情報を隠したうえでエラー内容を確認する方法もある
生成AIボットの構築は、AIの部分よりもその手前の権限と設定の戦いでした。しかしその過程で、IAMの信頼ポリシーと許可ポリシーの違い、サーバーレスのデバッグ手法など、教科書的に学ぶより深く身についた実感があります。
本記事のアカウントID・トークン・URLはすべてダミーに置き換えています。
クラスメソッドオペレーションズ株式会社について
クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイトをぜひご覧ください。
※2026年1月 アノテーション㈱から社名変更しました。









