はじめに
今回は、Next.js + AWS Amplify + Amazon Bedrock AgentCore(以降、Agent Coreと記載) の構成で簡単なチャットアプリを作ってみました。
ソースコードはこちら
モチベーション
当初はAgentCoreのフロントエンドにStreamlitを採用することを検討していましたが、いくつかの課題を感じたため、別の方法を試してみることにしました。
1. フロントエンド開発の自由度を高めたい
- StreamlitではCSSによるスタイル調整も可能だが、柔軟性に限界があるため、より自由にカスタマイズしたい
- TypeScript 対応の OSS ライブラリが使えないため、もっとフロントエンドのエコシステムを活用したい
- (ここはPythonだから使えるOSSもあるので一丁異端と思います)
2. フロントエンド開発者の経験を活かしたい
- フロントエンド開発者は、普段使い慣れているReactやTypeScriptで開発したい(と思う)
3. 認証機能を簡単に組み込みたい
- 不特定多数にAIアプリを提供するならば、セキュリティ観点で考えても、ユーザー管理機能や認証機能が欲しい
4. 既にある資産を流用したい
- 例えば、社内で管理しているデザインシステムから UI コンポーネントやスタイルを流用したい
- 他の Web システムのソースコードなども再利用したい
アーキテクチャ
アーキテクチャは下記になります。
ポイント
1. フロントエンドはNext.js
を採用
- AgentCoreとストリーミングで通信するための要件(Server-Sent Events)に対応可能
- Amplify Hostingを使えば簡単にデプロイ可能
2. 認証機能はAWS Amplify Auth
を採用
- AgentCoreはJWTベアラートークンによる認証を提供しており、Cognitoのユーザープールと連携することで認証ができる
- AmplifyのAuth機能を使えば、ログインUIの構築やトークン取得が簡単に実装でき、アクセストークンを使って、AgentCoreの認証ができる
3. Next.jsはVercel
へデプロイ
- 本当はAmplify Hostingへデプロイしたかったが、Amplify HostingがNext.jsのストリーミングに未対応だった
- そのため、今回はVercelにデプロイ
やってみる
本実装については「VibeCoding」→「調べる」→「VibeCoding」→「調べる」のような流れで行っています。
前提:利用環境
- Windows11
Step0. 環境準備
詳細はこちら
uvのプロジェクトを初期化します
uv init agent
cd agent
Pythonの仮想環境を作成します (Python 3.12でバージョン指定)
uv venv --python 3.12
仮想環境を有効化します。
.\.venv\Scripts\Activate.ps1
ローカル実行用にAWS認証情報を環境変数に設定します。
$env:AWS_PROFILE="xxxx"
Step1. StrandAgentでエージェントを作る
AgentCoreで動作させるAIエージェントを作成するため、strands-agents
をインストールします。
uv add strands-agents
StrandsAgentsのコードを実装します。モデルIDは任意のモデルを使用してください。
from strands import Agent
from strands.models import BedrockModel
MODEL_ID = "anthropic.claude-3-5-sonnet-20240620-v1:0"
agent = Agent(model=BedrockModel(model_id=MODEL_ID))
agent("Tell me about agentic AI")
ローカル環境で先ほどのコードを実行して、結果が返ってくることを確認します。
python -u main.py
結果が返ってくればOKです。
ここまででStrandsAgentをローカル環境で実行できました。
Step2. AWS Amplify + Next.jsの初期設定を行う
続いてAmplify Authの設定値をAgentCoreデプロイ時に使いたいので、先にAmplifyとNext.jsの設定を行います。
Next.jsを初期設定する
Next.jsのプロジェクトを作成します。
cd..
npx create-next-app@latest next-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd next-app
必要な依存関係をインストールします。
npm install aws-amplify @aws-amplify/ui-react @aws-amplify/adapter-nextjs
ローカルで開発サーバーを起動します。
npm run dev
ブラウザでlocalhostを開いて下記の画面が表示されればOKです。
AWS Amplify Gen2のセットアップ
AWS Amplify Gen2のプロジェクトを作成します。
npm create amplify@latest
サンドボックス環境で実行します。
デフォルト状態だとAmplify Authが設定されているので、Amazon CognitoがAWS上に構築されます。
npx ampx sandbox
Amplify Auth(Amazon Cognito ユーザープール)のuser_pool_id
とuser_pool_client_id
を控えておきます。(後ほどAgentCoreの認証設定で使います)
{
"auth": {
"user_pool_id": "us-east-1_jzxxxxx",
"aws_region": "us-east-1",
"user_pool_client_id": "2rj3rf2j30ifjxxxxx301",
// 省略
Step3. フロントエンドに認証機能を設定する
Next.jsとAmplifyが接続できるように設定する
Amplifyのクライアント設定ファイルを新規作成します。
- npx ampx sandboxでデプロイすると、Amplifyの設定がamplify_outputs.jsonへ出力される
- Amplify.configureに渡すことで、設定がNext.js側からでも参照できるようになる
import { Amplify } from 'aws-amplify';
import outputs from '../../amplify_outputs.json';
Amplify.configure(outputs);
Next.jsとAmplifyの接続設定をimportします。
// Next.jsとAmplifyの接続設定
import AuthProvider from '../components/AuthProvider';
// 省略
フロントエンドに認証機能を追加する
認証プロバイダーコンポーネントを作成します。
'use client';
import { Authenticator } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';
import '../lib/amplify';
export default function AuthProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<Authenticator>
{children}
</Authenticator>
);
}
先ほど作成した<AuthProvider>
で囲めばログイン画面などの認証機能が使えるようになります。
// Next.jsとAmplifyの接続設定
import AuthProvider from '../components/AuthProvider';
// Amplifyのデザイン定義
import '@aws-amplify/ui-react/styles.css';
// ~~省略~~
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
これでloaclhostを開くと、ログイン画面が表示されればOKです。
Step4. AgentCoreでAWS環境へデプロイする
localhostでAgentCore Runtimeで動作させる
bedrock-agentcore
をインストールします。
uv add bedrock-agentcore
AgentCoreで動作させるため、StrandsAgentsのコードを修正します。
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
from strands.models import BedrockModel
MODEL_ID = "anthropic.claude-3-5-sonnet-20240620-v1:0"
def create_agent():
"""エージェントを作成"""
return Agent(
model=BedrockModel(model_id=MODEL_ID, region="us-east-1"),
)
app = BedrockAgentCoreApp()
agent = create_agent()
@app.entrypoint
async def invoke(payload):
"""エージェントに質問を投げてレスポンスを取得する"""
user_prompt = payload.get("prompt", "No prompt found in input, please guide customer to create a json payload with prompt key")
agent_stream = agent.stream_async(user_prompt)
async for event in agent_stream:
if "event" in event:
yield event
if __name__ == "__main__":
app.run()
ローカルで実行します。
python -u main.py
INFO: Started server process [33940]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)
疎通確認し、応答が返ってくればOKです。
curl -X POST "http://localhost:8080/invocations" -H "Content-Type: application/json" -d "{\"prompt\": \"北見の7月の平均気温は?\"}"
"北見市の7月の平均気温についてお答えします。\n\n北見市は北海道の東部に位置する都市で、夏は比較的涼しい気候として知られています。\n\n北見市の7月の平均気温は、およそ20°C(摂氏)です。\n\nただし、気象条件は年によって変動することがあり、また近年の気候変動の影響で、平均気温が徐々に上昇している傾向にあることにも注意が必要です。\n\n詳細なデータは以下の通りで す:\n\n1. 7月の平均最高気温:約25°C\n2. 7月の平均最低気温:約16°C\n\n北見市は内陸部に位置するため、日中と夜間の温度差が大きくなる傾向があります。また、7月は北見市でも最も暑い月の一つですが、他の本州の都市と比べると比較的涼しく過ごしやすい気候といえます。\n"
AWS環境のAgentCore Runtimeへデプロイする
bedrock-agentcore-starter-toolkit
をインストールします。
uv add bedrock-agentcore-starter-toolkit
bedrock-agentcore-starter-toolkitは、いい感じにAWS環境のリソース作成や設定を行ってくれる便利なツールです
requirements.txt
ファイルを作成します。
strands-agents
bedrock-agentcore
AgentCoreを設定します。
agentcore configure --region us-east-1 --entrypoint main.py
上記のコマンドを実行すると、いろいろと質問されます。
下記IAMロールについては、空欄のままとします。空欄の場合、自動でIAMロールが作成されます。
🔐 Execution Role
Press Enter to auto-create execution role, or provide execution role ARN/name to use existing
Previously configured: arn:aws:iam::123456789012:role/AmazonBedrockAgentCoreSDKRuntime-us-east-1-1q1q1q1qq1q1
Execution role ARN/name (or press Enter to auto-create):
下記ECRリポジトリについては、空欄のままとします。空欄の場合、自動でECRリポジトリが作成されます。
🏗️ ECR Repository
Press Enter to auto-create ECR repository, or provide ECR Repository URI to use existing
Previously configured: 123456789012.dkr.ecr.us-east-1.amazonaws.com/bedrock-agentcore-main
ECR Repository URI (or press Enter to auto-create):
下記、依存関係を聞かれますが、空欄のままとします。空欄の場合、デフォルトでrequirements.txt
が指定されます。
🔍 Detected dependency file: requirements.txt
Press Enter to use this file, or type a different path (use Tab for autocomplete):
Path or Press Enter to use detected dependency file:
以下、認証設定を行うとAgentCore Runtimeを呼び出すために、JWTトークンが必要となります。
コードに不安がある場合は、動作確認をしてから、設定を起こった方が良いです。
ここからはCognitoで発行するJWTトークンを使用した認証設定を行います。
認証設定をするかと聞かれるためyes
と入力します。
🔐 Authorization Configuration
By default, Bedrock AgentCore uses IAM authorization.
Configure OAuth authorizer instead? (yes/no) [no]:yes
続けて、先ほどメモしたAmazon CognitoのID情報を入力します。
-
Enter allowed OAuth client IDs
- 「https://cognito-idp.
<リージョン>
.amazonaws.com/<user_pool_id>
/.well-known/openid-configuration」を指定する
- 「https://cognito-idp.
-
Enter allowed OAuth client ID
- 「user_pool_client_id」を指定する
📋 OAuth Configuration
Enter OAuth discovery URL: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxxx/.well-known/openid-configuration
Enter allowed OAuth client IDs (comma-separated): 123456789012
Enter allowed OAuth audience (comma-separated):
Windowsで実行すると「PermissionError: [WinError 32] プロセスはファイルにアクセスできません。別のプロセスが使用中です。」が発生する場合があります。その場合、Wslで実行すれば回避できます。
以下のコマンドを実行して、AWS環境のAgentCore Runtimeにデプロイします。
agentcore launch
AWSマネジメントコンソールから「Amazon Bedrock AgentCore > Agent Runtime」見てみるとエージェントが追加されていることがわかります。
Step5. APIでAgentCoreを呼び出してサーバー送信イベントで結果を貰う
Next.jsのAPI Routesを実装する
APIは、Next.jsのAPI Routesを使用して構築します。
基本的には、fetchでAgentCoreのAPI/invocations
を呼び出すような形になります。
コードはこちら
import { NextRequest } from 'next/server';
import { verifyJWT } from '@/lib/auth-utils';
import { getErrorMessage, logError } from '@/lib/error-utils';
const BEDROCK_AGENT_CORE_ENDPOINT_URL = "https://bedrock-agentcore.us-east-1.amazonaws.com"
/**
* リクエストからIDトークンを抽出・検証する
*/
async function validateIdToken(request: NextRequest): Promise<string> {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('Missing ID token');
}
const idToken = authHeader.substring(7);
const isValid = await verifyJWT(idToken);
if (!isValid) {
throw new Error('Invalid ID token');
}
return idToken;
}
/**
* リクエストからアクセストークンを抽出する
*/
function extractAccessToken(request: NextRequest): string {
const accessToken = request.headers.get('x-access-token');
if (!accessToken) {
throw new Error('Missing access token');
}
return accessToken;
}
/**
* AgentCore Runtimeとの通信を処理する
*/
async function streamFromAgentCore(
accessToken: string,
prompt: string,
_sessionId: string,
controller: ReadableStreamDefaultController<Uint8Array>
): Promise<void> {
const encoder = new TextEncoder();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
};
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
let isClosed = false;
const safeClose = () => {
if (!isClosed) {
isClosed = true;
try {
controller.close();
} catch (error) {
console.warn('Controller already closed:', error);
}
}
};
const safeEnqueue = (data: Uint8Array) => {
if (!isClosed) {
try {
controller.enqueue(data);
} catch (error) {
console.warn('Failed to enqueue data:', error);
isClosed = true;
}
}
};
try {
const encodedEndpoint = encodeURIComponent(process.env.AGENT_CORE_ENDPOINT || '');
const fullUrl = `${BEDROCK_AGENT_CORE_ENDPOINT_URL}/runtimes/${encodedEndpoint}/invocations`;
console.log("fullUrl:", fullUrl)
const agentResponse = await fetch(fullUrl, {
method: 'POST',
headers,
body: JSON.stringify({
prompt: prompt.trim(),
}),
});
if (!agentResponse.ok) {
throw new Error(`AgentCore returned ${agentResponse.status}: ${agentResponse.statusText}`);
}
if (!agentResponse.body) {
throw new Error('No response body from AgentCore');
}
reader = agentResponse.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!isClosed) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 即座に処理するため、改行ごとに分割して順次処理
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line || isClosed) continue;
// SSE形式の処理
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') {
safeClose();
return;
}
try {
const parsed = JSON.parse(data);
safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
} catch {
// JSONパースエラーは無視
}
} else {
// JSON形式の直接レスポンスの場合
try {
const parsed = JSON.parse(line);
safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
} catch {
// JSONパースエラーは無視
}
}
}
}
// バッファに残ったデータを処理
if (buffer.trim() && !isClosed) {
try {
const parsed = JSON.parse(buffer);
safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
} catch {
// JSONパースエラーは無視
}
}
safeClose();
} catch (error) {
if (reader) {
try {
reader.releaseLock();
} catch {
// リーダーのリリースに失敗しても続行
}
}
throw error;
}
}
export async function POST(request: NextRequest) {
try {
// IDトークンを検証
await validateIdToken(request);
// アクセストークンを取得
const accessToken = extractAccessToken(request);
const { prompt, sessionId } = await request.json();
// プロンプトの検証
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
return new Response('Bad Request: Empty or invalid prompt', { status: 400 });
}
// AgentCore Runtimeとの通信用ストリーム
const stream = new ReadableStream({
async start(controller) {
try {
await streamFromAgentCore(accessToken, prompt, sessionId, controller);
} catch (error) {
logError('AgentCore通信', error);
const errorMessage = getErrorMessage(error);
const encoder = new TextEncoder();
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: `AgentCore通信エラー: ${errorMessage}` })}\n\n`));
controller.close();
} catch (controllerError) {
console.warn('Controller operation failed:', controllerError);
}
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Access-Token',
},
});
} catch (error) {
// 認証エラーの場合
if (error instanceof Error &&
(error.message.includes('Missing') || error.message.includes('Invalid'))) {
return new Response(`Unauthorized: ${error.message}`, { status: 401 });
}
// その他のエラー
logError('SSEエンドポイント', error);
const errorMessage = getErrorMessage(error);
return new Response(`Internal Server Error: ${errorMessage}`, { status: 500 });
}
}
フロントエンドで表示する
フロントエンド側でデータを受け取って、Reactのカスタムフックで表示するように加工します。
コードはこちら
import { useState, useCallback } from 'react';
import { useAuth } from './useAuth';
/**
* SSEチャット機能のオプション設定
*/
interface SSEChatOptions {
maxRetries?: number; // 最大再試行回数
retryDelay?: number; // 再試行間隔(ミリ秒)
}
/**
* SSE形式の行からデータ部分を抽出する
* @param line SSEの1行
* @returns 抽出されたデータ、または null
*/
const extractDataFromLine = (line: string): string | null => {
if (line.startsWith('data: ')) {
return line.slice(6).trim();
}
return null;
};
/**
* パースされたJSONからメッセージ内容を抽出する
* @param parsed パースされたJSONオブジェクト
* @returns 抽出されたテキスト内容、または null
*/
const extractMessageContent = (parsed: Record<string, unknown>): string | null => {
// エラーチェック
if (parsed.error && typeof parsed.error === 'string') {
throw new Error(parsed.error);
}
if (parsed.event && typeof parsed.event === 'object' && parsed.event !== null) {
const event = parsed.event as { contentBlockDelta?: { delta?: { text?: string } } };
if (event.contentBlockDelta?.delta?.text) {
return event.contentBlockDelta.delta.text;
}
}
return null;
};
/**
* SSEレスポンスを処理してメッセージを更新する
* @param response Fetchレスポンス
* @param onMessageUpdate メッセージ更新時のコールバック
* @param onComplete 完了時のコールバック
*/
const processStreamingResponse = async (
response: Response,
onMessageUpdate: (message: string) => void,
onComplete: (finalMessage: string) => void
): Promise<void> => {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let currentMessage = '';
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// バイナリデータをテキストに変換
buffer += decoder.decode(value, { stream: true });
// 改行で分割して各行を処理
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const dataToProcess = extractDataFromLine(line);
if (!dataToProcess) continue;
try {
const parsed = JSON.parse(dataToProcess);
const content = extractMessageContent(parsed);
if (content) {
currentMessage += content;
onMessageUpdate(currentMessage);
}
} catch {
// JSONパースエラーは無視して続行
}
}
}
onComplete(currentMessage);
} finally {
reader.releaseLock();
}
};
/**
* SSE(Server-Sent Events)を使用したチャット機能のカスタムフック
*
* @param options 設定オプション
* @returns チャット機能のstate と関数
*/
export function useSSEChat(options: SSEChatOptions = {}) {
const { maxRetries = 3, retryDelay = 1000 } = options;
// State管理
const [messages, setMessages] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 認証管理
const { getAuthTokens } = useAuth();
/**
* メッセージを送信してAIからの応答を受信する
* @param prompt ユーザーからの入力プロンプト
* @param retryCount 現在の再試行回数(内部使用)
*/
const sendMessage = useCallback(async (
prompt: string,
retryCount = 0
): Promise<void> => {
if (!prompt?.trim()) return;
setIsLoading(true);
setError(null);
// 認証トークンを取得
const { idToken, accessToken } = await getAuthTokens();
if (!idToken || !accessToken) {
setError('認証トークンが取得できません');
setIsLoading(false);
return;
}
try {
// SSE APIにリクエストを送信
const response = await fetch('/api/agent-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Authorization': `Bearer ${idToken}`,
'X-Access-Token': accessToken,
},
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('レスポンスボディがありません');
}
// 新しいメッセージスロットを追加
setMessages(prev => [...prev, '']);
// ストリーミングレスポンスを処理
await processStreamingResponse(
response,
// メッセージ更新時
(currentMessage) => {
setMessages(prev => [...prev.slice(0, -1), currentMessage]);
},
// 完了時
(finalMessage) => {
if (finalMessage) {
setMessages(prev => [...prev.slice(0, -1), finalMessage]);
} else {
setMessages(prev => prev.slice(0, -1));
}
}
);
} catch (fetchError) {
// 自動再試行(指数バックオフ)
if (retryCount < maxRetries) {
setTimeout(() => {
sendMessage(prompt, retryCount + 1);
}, retryDelay * Math.pow(2, retryCount));
} else {
const errorMessage = fetchError instanceof Error ? fetchError.message : 'Unknown error';
setError(`通信エラー: ${errorMessage}`);
}
} finally {
setIsLoading(false);
}
}, [getAuthTokens, maxRetries, retryDelay]);
/**
* メッセージ履歴をクリアする
*/
const clearMessages = useCallback(() => {
setMessages([]);
setError(null);
}, []);
return {
messages, // メッセージ履歴
isLoading, // 送信中フラグ
error, // エラーメッセージ
sendMessage, // メッセージ送信関数
clearMessages, // 履歴クリア関数
};
}
これでAgentCoreからストリーミングでフロントエンドに結果が返ってくるようになりました。
あとは、好きなUIを構築して、データを表示するだけとなります。
まとめ
今回は、簡単なAIチャットアプリを作ってみました。
元々やりたかったのはAmplify HostingでNext.jsでデプロイするところまで行きたかったのですが、上手くいかず...今度は実際にどこかへデプロイしてみたいと思います。
PoCでもUIとAIエージェントでロジックを分離した方が良い
以前、StrandsAgentsとStreamlitで簡単なAIアプリを試した際、StreamlitのフロントエンドロジックとStrandsAgentsのAIエージェントロジックを複雑に絡めて記述してしまっていました。
今回は、StrandsAgentsのロジックのみをAgentCoreにデプロイしています。
PoCレベルだと、あまり深く考えずにStreamlitとStrandsAgentsでサクッと作ってしまうことが多いと思いますが、事前にファイルやクラス単位でロジックを分離しておいた方が、後々本番相当のAWS環境にデプロイしやすいと感じました。
AIアプリにNext.jsが合うかは、まだわからない
開発体験としてはStreamlitが楽
今回は Next.js というフレームワークを使ってフロントエンドを構築していますが、正直、Pythonの Streamlit を使った方が圧倒的に開発は楽だと思います!
Next.jsは業務経験があれば選択肢に
業務でNext.jsをがっつり使っている方であれば、それほど苦ではないと思うので、選択肢にはなるかなと。
ただ、個人的にNext.jsのデプロイがうまくいっていない部分もあるので、そのあたりも踏まえてもう少し検討したいと思います。
将来性と柔軟性を見越して
将来、フロントエンド技術がAIアプリ開発に追いついてくんだろうなと思います。少し待てば、Reactなどのなじみ深いライブラリで簡単にAgentCoreと結合できるようになるはずなので、様子見するのも一つの手かと思います。