🗣️

Next.jsでClaude3 Sonnet (Bedrock) の実行環境をつくる (Stream版)

2024/03/20に公開

こんにちは!@Ryo54388667です!☺️

普段は都内でフロントエンドエンジニアとして業務をしてます!
主にTypeScriptやNext.jsといった技術を触っています。

今回はNext.jsでClaude3 Sonnet(Bedrock)の実行環境をつくる方法を紹介したいと思います。
自分はChatGPTには課金していますが、Claude Proに課金するかどうかは悩み中の身です。この実行環境で触ってみて検討したいと思っています。

📌 準備

いくつか準備が必要です。

  1. AWS IAMユーザーの準備
  2. BedrockのClaude3 Sonnetモデルの利用申請
  3. Bedrockを操作できるように、IAMユーザーにポリシーをアタッチする

これらの準備が済んでいる人は先に進んでください〜

AWS IAMユーザーの準備

既存の開発者IAMユーザーを作成済みの方はスキップして大丈夫です。
コード上でBedrockを操作できるように権限の箱のようなものを準備します。こちらに必要に応じて様々な権限を付与していきます。

今回の詳細を話していくと冗長になりそうなので、こちらの記事が参考になるかと思います。
https://dev.classmethod.jp/articles/aws-cli-install/

AWS IAMユーザーのページにユーザーが作成されればひとまずOKです。
ここで、コードで実行できるようにアクセスキーを作成しておきます。下記の記事が親切で分かりやすいです〜
https://dev.classmethod.jp/articles/iam_user_create_access_keys_2023/
後ほど、環境変数のAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYの値になります。コピーしておきます。

BedrockのClaude3 Sonnetモデルの利用申請

僕は、こちらを忘れていて、通らないリクエストを何度も投げていました。。😇お忘れなく。
利用するために申請が必要です!特に何時間もかかるものではありません。簡単なアンケートに答えるものです。
僕の場合は5分くらいでアクセス可能になりました。

詳しくはこちらのページの「Anthropic モデルを初めて使用する場合は~」を参考に進めるのが良いかと思います!

緑色のチェックマークが表示されれば、アクセス可能になります👍

Bedrockを操作できるように、IAMユーザーにポリシーをアタッチする

先ほど権限の箱であるIAMユーザーを作成したので、そこにアタッチするポリシーを作成します。
ポリシー名は任意のもので大丈夫です👌
JSON形式で下記のものを貼り付けてください。リージョンはus-east-1であることに注意です。今回、Actionの箇所にはstream対応のリクエストと通常のリクエストの2種類のアクションを入れました。streamのみでも全く構いません。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "bedrock:InvokeModel",
                "bedrock:InvokeModelWithResponseStream"
            ],
            "Resource": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0"
        }
    ]
}

こちらのポリシーが作成できたら、今度はIAMユーザーのページに遷移し、先ほど作成したポリシーをアタッチすれば準備OKです👌

なかなか大変ですね。。😅

📌 コードについて

あとはコードを書いて準備していきます。

Package

name version
Next 14.0.0
React 18.2.0
Tailwindcss ^3
@aws-sdk/client-bedrock-runtime ^3.529.1

ディレクトリ構造

├── app
│   ├── api
│   │   └── chat
│   │       └── route.ts
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── lib
    └── bedrock.ts

Next.jsのプロジェクトは下記のページを参考に作成すると良いかと思います。
https://nextjs.org/docs/getting-started/installation

大まかな流れとしては、

  1. promptの入力
  2. 送信ボタンの押下時に/api/chatのAPIコール(route.tsに関わる内容)
  3. Stream形式のレスポンスをその都度画面に表示させる

Bedrockに関わる内容

ライブラリをインストールします。

npm i @aws-sdk/client-bedrock-runtime

続いて、準備のセクションでコピーした環境変数を用意します。
プロジェクトのルートにenv.localを作成し、ファイル内に以下のコードを書きます。
/env.local

AWS_ACCESS_KEY_ID=ここにコピーした値を貼ります(key)
AWS_SECRET_ACCESS_KEY=ここにコピーした値を貼ります(secret)

蛇足ではありますが、こちらも簡易的な環境変数の設定方法なのでデプロイする際にはセキュリティに配慮した方法を選択する必要があるかと思います。

/lib/bedrock.ts

import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand } from '@aws-sdk/client-bedrock-runtime';

const bedrock = new BedrockRuntimeClient({
  region: 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
  }
});

export const postMessageWithRiouteHandler = async (prompt: string) => {
  const payload = {
    anthropic_version: "bedrock-2023-05-31",
    max_tokens: 1000,
    messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
  };
  const response = await bedrock.send(
    new InvokeModelWithResponseStreamCommand({
      modelId: 'anthropic.claude-3-sonnet-20240229-v1:0',
      contentType: 'application/json',
      body: JSON.stringify(payload)
    })
  );
  return response;
}

Route Handlerに関わる内容

/api/chatのエンドポイントにアクセスした時に処理されます。リクエストした時ですね。
/app/api/chat/route.ts

import { postMessageWithRiouteHandler } from "@/lib/bedrock"

export const runtime = 'edge'

export async function POST(request: Request) {
  const data = await request.json()
  const readableStream = new ReadableStream({
    async start(controller) {
      const response = await postMessageWithRiouteHandler(data.prompt)
      if (response.body) {
        for await (const stream of response.body) {
          controller.enqueue(stream.chunk?.bytes)
        }
        controller.close()
      }
    }
  })
  return new Response(readableStream, { headers: { "Content-Type": "text/plain" } })
}

Next.js公式参考

UIに関わる内容

あとはリクエストする部分とレスポンスを受け取る箇所を実装します。
/app/layout.tsx

import "./globals.css"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ja" className='max-w-[768px] mx-auto'>
      <body>{children}</body>
    </html>
  )
}

/app/page.tsx

"use client"

import { useState, useTransition } from "react"

export default function Home() {
  const [isPending, startTransition] = useTransition()
  const [prompt, setPrompt] = useState("")
  const [userChat, setUserChat] = useState("")
  const [botChat, setBotChat] = useState("")

  const onSend = async (e: any) => {
    e.preventDefault()
    const _prompt = e.target[0].value
    setUserChat(_prompt)
    setPrompt("")

    startTransition(async () => {
      const response = await fetch(`/api/chat`, {
        "method": "POST",
        "headers": {
          "Content-Type": "application/json"
        },
        "body": JSON.stringify({ "prompt": _prompt })
      })
      if (response.body) {
        const reader = response.body.getReader()
        const decoder = new TextDecoder()

        try {
          while (true) {
            const { done, value } = await reader.read()

            if (done) {
              break
            }

            if (value) {
              const chunk = JSON.parse(decoder.decode(value, { stream: true }))
              const chunk_type = chunk.type;
              switch (chunk_type) {
                case "message_start":
                  console.log(chunk["message"]["id"]);
                  console.log(chunk["message"]["model"]);
                  break;
                case "content_block_delta":
                  const currentText = chunk["delta"]["text"]
                  setBotChat(prev => prev + currentText)
                  if (chunk["delta"]["stop_reason"] === "max_tokens") {
                    return
                  }
                  break;
                case "message_delta":
                  if (chunk["delta"]["stop_reason"] === "end_turn") {
                    return
                  }
                  break;
                case "message_stop":
                  const metrics = chunk["amazon-bedrock-invocationMetrics"];
                  console.log(metrics);
                  break;
                default:
                  null
              }
            }
          }
        } finally {
          window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
          reader.releaseLock()
        }
      }
    })
  }

  return (
    <div className="h-screen flex flex-col gap-4">
      <header className="p-4 grid place-items-center">
        <h1 className="text-2xl font-semibold">Chat</h1>
      </header>
      <main className="flex-1 flex flex-col p-4">
        <div className="grid gap-4">
          {userChat &&
            <div className="flex flex-col items-end gap-1">
              <div className="flex flex-col max-w-[75%] rounded-lg p-4 bg-gray-100">
                <div className="flex items-center gap-2 text-sm">
                  <div className="font-medium bg-slate-500 text-white px-2 py-1 rounded-full">You</div>
                  <time className="opacity-70">{new Date().getHours()}</time>
                </div>
                <div className="mt-2">{userChat}</div>
              </div>
            </div>
          }
          {botChat &&
            <div className="flex flex-col items-start gap-1">
              <div className="flex flex-col max-w-[75%] rounded-lg p-4 bg-gray-100">
                <div className="flex items-center gap-2 text-sm">
                  <div className="font-medium bg-green-600 text-white px-2 py-1 rounded-full">Bed Rock</div>
                  <time className="opacity-70">{new Date().getHours()}</time>
                </div>
                <div className="mt-2 whitespace-pre-wrap">{botChat}</div>
              </div>
            </div>
          }
          {isPending && botChat.length === 0 && <div className="flex flex-col items-start gap-1">
            <div className="flex flex-col max-w-[75%] rounded-lg p-4 bg-gray-100">
              <div className="flex items-center gap-2 text-sm">
                <div className="font-medium">Bed Rock</div>
              </div>
              <div className="mt-2">考え中...</div>
            </div>
          </div>}
        </div>
      </main>
      <div className="border-t p-4">
        <form className="flex gap-4" onSubmit={onSend}>
          <input
            placeholder="Type a message"
            className="flex-1 p-3 rounded-lg border border-gray-300 focus:outline-none focus:ring focus:ring-gray-400"
            type="text"
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
          />
          <button type="submit" className="px-6 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition-colors">Send</button>
        </form>
      </div>
    </div>
  )
}

これでひとまず、実行環境はできました!✨
ただ、連続でチャットができないので、コードを書き足す必要があります。今回は最小限の実行環境を作成しました。

今回は手弁当でStreamの部分を作成しましたが、Vercelから便利がライブラリが出ていました。こちらも活用すると、より早く作成できるのではないかと思います!
https://sdk.vercel.ai/docs/guides/providers/aws-bedrock

📌 最後に

今回はNext.jsでClaude3 Sonnet(Bedrock)の実行環境をつくる方法をまとめました。
なにせ準備が大変です笑

いろいろと雑に作ってしまったので、これからリファクタリングしていこうと思います!
素振りしたリポジトリを少しずつ更新していきますので、参考になれば幸いです。
https://github.com/ryota-09/bedrock-sample

より良い方法があれば教えてください〜

最後まで読んでいただきありがとうございます!
気ままにつぶやいているので、気軽にフォローをお願いします!🥺
https://twitter.com/Ryo54388667/status/1733434994016862256

Discussion