この記事について
Amazon Bedrockはサーバレスの良い感じのサービスですが、それをLangChainから使う場合、お手軽な方法としては、コンテナランナーで稼働させることになります。
しかし、AWSの場合にはお手軽なコンテナランナーがなく、Fargate一択(言い過ぎ)になります。
この記事では、バックエンド(LangChainからのBedrock呼び出し)にLambdaを使うお手軽な方法を試すことを主眼としています。
(しかし、バックエンドだけあっても微妙なので、ついでにフロントも作っています。こちらは、生成AIをフル活用してほとんどコードを書かない。というか何も考えないということを重視しています!)
結果的にあまりお手軽ではなくなっています。
全体の構成
図にしようかと思いましたが面倒なので文字で。
- フロントエンド
- React
- ビルドツール:vite
- バックエンド
- AWS Lambda
- FastAPI
- LangChain
- Amazon Bedrock (Claude3.5 Sonnet)
ソースコードリポジトリ
以下に全体のソースコードがあります。
https://github.com/tis-abe-akira/langchain-react-lambda
どうしたことか、TypeScriptのプロジェクトとして認識されてしまいました。
バックエンドを開発する!
参考にしたサイト
以下のサイトを思い切り参考にしています。このサイトがなければこの記事は存在しなかったと思います。
【生成AI】AWS Lambda(Python) と LangChain(LCEL) を使ってストリーミング出力したい
デプロイの手順
ここではSAMを使います。
API_KEYの検証は、Lambda上で動くPythonスクリプトで実施しています。したがって、適当な文字列を指定してビルド&デプロイしてください。
sam build
export OUR_API_KEY=ランダムな文字列を指定
sam deploy --parameter-overrides ApiKey=${OUR_API_KEY} --guided
# 以下は入力例です。
Configuring SAM deploy
======================
Looking for config file [samconfig.toml] : Not found
Setting default arguments for 'sam deploy'
=========================================
Stack Name [sam-app]:
AWS Region [ap-northeast-1]: us-east-1
Parameter ApiKey: #APIキーを入力
#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
Confirm changes before deploy [y/N]: y
#SAM needs permission to be able to create roles to connect to the resources in your template
Allow SAM CLI IAM role creation [Y/n]: Y
#Preserves the state of previously provisioned resources when an operation fails
Disable rollback [y/N]: N
FastAPIFunction Function Url has no authentication. Is this okay? [y/N]: y ← APIキーをLambda内で検証
Save arguments to configuration file [Y/n]: Y
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
~ しばらく待つと以下のメッセージが表示される ~
Previewing CloudFormation changeset before deployment
======================================================
Deploy this changeset? [y/N]: y
~ 最終的にデプロイが成功すると以下のように、LambdaのARNおよびFunction URLが表示される ~
CloudFormation outputs from deployed stack
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Key FastAPIFunction
Description FastAPI Lambda Function ARN
Value arn:aws:lambda:us-east-1:999999999999:function:sam-app-FastAPIFunction-XXXXXXXXXX
Key FastAPIFunctionUrl
Description Function URL for FastAPI function
Value https://XXXXXXXXXX.lambda-url.us-east-1.on.aws/
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
curlによるテスト
curl -X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: ${OUR_API_KEY}" \
-d '{"question": "カナダの首都は?"}' \
https://XXXXXXXXXX.lambda-url.us-east-1.on.aws/api/qa
# 以下のようなレスポンスが返ってくる
カナダの首都はオタワ(Ottawa)です。
オタワに関する主な情報:
1. 場所:オンタリオ州東部に位置し、ケベック州との州境に近い。
2. 人口:約100万人(都市圏を含む)
3. 公用語:英語とフランス語(カナダは公式に二言語国家)
4. 主要な政府機関:連邦議会議事堂、首相官邸、総督官邸など
5. 観光名所:リドー運河、カナダ国立美術館、平和の塔など
6. 気候:大陸性気候で、夏は暖かく冬は寒い
7. 経済:政府機関や技術産業が主要な雇用源
オタワは1857年にビクトリア女王によってカナダの首都に選ばれました。トロントやモントリオールなどの大都市と比べると小さめですが、政治の中心地として重要な役割を果たしています。
SAMのテンプレート
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
fastapi response streaming
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 60
Parameters:
ApiKey:
Type: String
NoEcho: true
Description: API Key for authentication
Resources:
FastAPIFunction:
Type: AWS::Serverless::Function
Properties:
Policies:
- Statement:
- Sid: BedrockInvokePolicy
Effect: Allow
Action:
- bedrock:InvokeModelWithResponseStream
Resource: "*"
CodeUri: app/
Handler: run.sh
Runtime: python3.11
MemorySize: 256
Environment:
Variables:
AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
AWS_LWA_INVOKE_MODE: response_stream
PORT: 8000
API_KEY: !Ref ApiKey
Layers:
- !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:22
FunctionUrlConfig:
AuthType: NONE
InvokeMode: RESPONSE_STREAM
Cors:
AllowOrigins:
- "*"
AllowMethods:
- "*"
AllowHeaders:
- X-API-Key
- content-type
MaxAge: 300
Outputs:
FastAPIFunctionUrl:
Description: "Function URL for FastAPI function"
Value: !GetAtt FastAPIFunctionUrl.FunctionUrl
FastAPIFunction:
Description: "FastAPI Lambda Function ARN"
Value: !GetAtt FastAPIFunction.Arn
Lambdaのコード
from fastapi import FastAPI, Security, HTTPException
from fastapi.security import APIKeyHeader
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from langchain_aws import BedrockChat
from langchain_core.output_parsers import StrOutputParser
import os
app = FastAPI()
API_KEY = os.environ.get("API_KEY")
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_api_key(api_key_header: str = Security(api_key_header)):
if api_key_header == API_KEY:
return api_key_header
raise HTTPException(status_code=403, detail="Could not validate API KEY")
class RequestBody(BaseModel):
question: str
def bedrock_stream(question: str):
model = BedrockChat(
model_id="anthropic.claude-3-5-sonnet-20240620-v1:0",
model_kwargs={"max_tokens": 1000},
)
chain = model | StrOutputParser()
for chunk in chain.stream(question):
yield chunk
@app.post("/api/qa", dependencies=[Security(get_api_key)])
async def api_qa(body: RequestBody):
return StreamingResponse(
bedrock_stream(body.question),
media_type="text/event-stream",
)
@app.get("/api/health-check", dependencies=[Security(get_api_key)])
async def health_check():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
フロントエンドを開発する
生成AI(v0)にアウトラインを相談しスケルトンコードを作ってもらう
プロンプトは以下のような内容を投げています。(画像で参考になりそうな画面スクショを渡しています)
添付ファイルのようなシンプルなUIのAIチャットを作ってください。
プロトタイプなので、会話は一連の流れではなく、リクエスト単位に一往復で完結します。
現在はリセットやクリアのボタンがないですが、それを追加してください。
AIの回答内容はMarkdownで返却されることを想定しReactMarkdownを使ってください。
実行の様子(一応の完成形)
プロジェクトのセットアップ手順
プロジェクトの初期化
npm create vite@latest frontend -- --template react-ts
cd frontend
依存関係のインストール
npm install
npm install react-markdown @radix-ui/react-slot class-variance-authority clsx tailwind-merge lucide-react
Tailwind CSSの設定
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
tailwind.config.jsを以下の内容で置き換えます。
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
src/index.cssを以下の内容で置き換えます。
@tailwind base;
@tailwind components;
@tailwind utilities;
テスト実行
npm run dev
# ブラウザで http://localhost:5173 を開く
ソースコード(メインのコンポーネントのみ)
import { useState } from "react";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "../components/ui/card";
import ReactMarkdown from "react-markdown";
import API_CONFIG from "../config";
export default function AIChat() {
const [input, setInput] = useState("");
const [answer, setAnswer] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch(API_CONFIG.ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_CONFIG.API_KEY,
},
body: JSON.stringify({ question: input }),
});
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value && value.length > 0 && isLoading) {
setIsLoading(false); // valueが空でない場合にisLoadingをfalseに設定
}
const chunk = decoder.decode(value);
setAnswer((prevAnswer) => prevAnswer + chunk);
}
} catch (error) {
console.error("Error:", error);
setAnswer("An error occurred while fetching the answer.");
} finally {
setIsLoading(false);
}
};
const handleReset = () => {
setInput("");
setAnswer("");
};
const handleClear = () => {
setAnswer("");
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle>質問応答アプリ</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="質問を入力してください。"
className="w-full h-32"
/>
<Button type="submit" disabled={isLoading || !input.trim()} className="bg-blue-700 text-white">
{isLoading ? "送信中..." : "送信"}
</Button>
</form>
{answer && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-2">回答:</h3>
<Card className="p-4 bg-muted">
<ReactMarkdown>{answer}</ReactMarkdown>
</Card>
</div>
)}
</CardContent>
<CardFooter className="flex justify-end space-x-2">
<Button onClick={handleClear} variant="outline">
クリア
</Button>
<Button onClick={handleReset} variant="outline">
リセット
</Button>
</CardFooter>
</Card>
);
}
まとめ
ここまでお読みいただき誠にありがとうございました。
このレベルの機能であれば、Streamlitのスクリプト中にLangChainのコードを埋め込んだ方が、お手軽だと思います。
しかしながら、いろいろな機能を付加するとか、一連の処理の中で生成AIの呼び出しをするといったユースケースにおいては、サーバレス(Lambda)での実行というのは有力な選択肢になると思います。