Amazon BedrockとLangChainをサーバレスで動かす！（ついでにフロントも作る！）

# この記事について
Amazon Bedrockはサーバレスの良い感じのサービスですが、それをLangChainから使う場合、お手軽な方法としては、コンテナランナーで稼働させることになります。
しかし、AWSの場合にはお手軽なコンテナランナーがなく、Fargate一択（言い過ぎ）になります。

この記事では、**バックエンド（LangChainからのBedrock呼び出し）にLambdaを使うお手軽な方法**を試すことを主眼としています。

（しかし、バックエンドだけあっても微妙なので、ついでにフロントも作っています。こちらは、生成AIをフル活用してほとんどコードを書かない。というか**何も考えない**ということを重視しています！）

:::note warn
結果的にあまりお手軽ではなくなっています。
:::

# 全体の構成

図にしようかと思いましたが面倒なので文字で。

- フロントエンド
    - React
    - ビルドツール：vite
- バックエンド
    - AWS Lambda
    - FastAPI
    - LangChain
    - Amazon Bedrock (Claude3.5 Sonnet)

# ソースコードリポジトリ
以下に全体のソースコードがあります。
https://github.com/tis-abe-akira/langchain-react-lambda

どうしたことか、TypeScriptのプロジェクトとして認識されてしまいました。
![Screenshot 2024-09-04 at 19.18.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130262/1e72ce35-2c58-44dc-711d-0dfb4418f359.png)


# バックエンドを開発する！

## 参考にしたサイト
以下のサイトを思い切り参考にしています。このサイトがなければこの記事は存在しなかったと思います。
[【生成AI】AWS Lambda(Python) と LangChain(LCEL) を使ってストリーミング出力したい](https://blog.serverworks.co.jp/gen-ai-aws-lambda-streaming)

[FastAPI Response Streaming](https://github.com/awslabs/aws-lambda-web-adapter/blob/main/examples/fastapi-response-streaming-zip/README.md)

## デプロイの手順
ここではSAMを使います。
API_KEYの検証は、Lambda上で動くPythonスクリプトで実施しています。したがって、適当な文字列を指定してビルド＆デプロイしてください。

```bash
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によるテスト

```bash
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のテンプレート
```yaml:template.yaml
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のコード
```python:main.python
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）にアウトラインを相談しスケルトンコードを作ってもらう

生成AIにコードを作ってもらっている様子です。
![v0-create-front.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130262/43a88cd8-d0f8-fb21-d3cb-95f7611c1266.gif)

プロンプトは以下のような内容を投げています。（画像で参考になりそうな画面スクショを渡しています）
```markdown
添付ファイルのようなシンプルなUIのAIチャットを作ってください。
プロトタイプなので、会話は一連の流れではなく、リクエスト単位に一往復で完結します。
現在はリセットやクリアのボタンがないですが、それを追加してください。
AIの回答内容はMarkdownで返却されることを想定しReactMarkdownを使ってください。

```
## 実行の様子（一応の完成形）

![react-front.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130262/4e92b4ce-3b02-9341-8815-3b8828fb9f47.gif)


## プロジェクトのセットアップ手順

プロジェクトの初期化
```
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を以下の内容で置き換えます。
```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;
```

## テスト実行

```bash
npm run dev

# ブラウザで http://localhost:5173 を開く
```

## ソースコード（メインのコンポーネントのみ）

```typescript:AIChat.tsx

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）での実行というのは有力な選択肢になると思います。