LangChainをAPI化するLangServeをLambda上で動作させるのはめちゃ簡単デス

少し前にLangChain開発元から新しいツールとしてLangServeというものがリリースされています。

https://blog.langchain.dev/introducing-langserve/

> LangServe is the easiest and best way to deploy any any LangChain chain/agent/runnable.


生成系AIを使ったAPIを簡単に作成し利用できる仕組みで、プロダクション環境でどんどんLangChainを使ってねというメッセージと捉えました。

イメージとしてはこんな仕組みです。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/63dc3abd-a710-3717-b6b0-ffc45585644e.png)

FastAPI上で動作し、API仕様が定められている感じです。

* Invoke API
    単一の入力で処理を実行する
* Batch API
    複数の入力で処理を実行する
* Stream API
    単一の入力で処理を行い、結果をストリームで返却する
* Stream_log API
    単一の入力で処理を行い、結果だけでなく途中の経過もストリームで返却する

## ローカルでお試し実行

まずはローカル環境で動作させてみます。

1. LangServeのインストール

    pydanticはv2ではなくv1を使うため、バージョン指定でインストールしています。
    生成系AIにAmazon Bedrockを使用するため、Boto3もインストールしています。

    ```shell
    pip install langserve[server] pydantic==1.10.13 boto3
    ```

1. サーバー側ロジックを記述

    [こちら](https://github.com/langchain-ai/langserve/blob/main/examples/llm/server.py)を参考にしました。
    
    `add_routes`を使って、`BedrockChat`モデルを使用する`/bedrock`のパスを定義しています。
    
    ```python:server.py
    #!/usr/bin/env python
    
    from fastapi import FastAPI
    from langchain_community.chat_models import BedrockChat 
    
    from langserve import add_routes
    
    app = FastAPI(
        title="LangServe",
        version="1.0",
        description="LangChain Server",
    )
    
    add_routes(
        app,
        BedrockChat(model_id="anthropic.claude-instant-v1"),
        path="/bedrock",
    )
    
    if __name__ == "__main__":
        import uvicorn
    
        uvicorn.run(app)
    ```

    なんと、これだけw

3. 起動

    `python server.py`でLangServeを起動します。素敵なログが出力されます。

    ```
    INFO:     Started server process [7582]
    INFO:     Waiting for application startup.
    
     __          ___      .__   __.   _______      _______. _______ .______     ____    ____  _______
    |  |        /   \     |  \ |  |  /  _____|    /       ||   ____||   _  \    \   \  /   / |   ____|
    |  |       /  ^  \    |   \|  | |  |  __     |   (----`|  |__   |  |_)  |    \   \/   /  |  |__
    |  |      /  /_\  \   |  . `  | |  | |_ |     \   \    |   __|  |      /      \      /   |   __|
    |  `----./  _____  \  |  |\   | |  |__| | .----)   |   |  |____ |  |\  \----.  \    /    |  |____
    |_______/__/     \__\ |__| \__|  \______| |_______/    |_______|| _| `._____|   \__/     |_______|
    
    LANGSERVE: Playground for chain "/bedrock/" is live at:
    LANGSERVE:  │
    LANGSERVE:  └──> /bedrock/playground/
    LANGSERVE:
    LANGSERVE: See all available routes at /docs/
    
    INFO:     Application startup complete.
    INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
    ```

1. ブラウザで`http://127.0.0.1:8000/docs`にアクセスすると、Swagger UIでOpenAPIドキュメントが表示されます。

    ![127.0.0.1_8000_docs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/155e28ad-3585-32ac-dd0c-2fbb96555f98.png)

    先程紹介したものよりもいくつか追加でAPIが定義されました。

    この画面上からAPIのテスト実行も可能です。

    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/8ea33b8f-0202-81d8-f65a-541e511e7035.png)

    もちろんREST APIなので、表示されるcurlリクエストで呼び出すことも可能です。
    
    ```shell
    curl -X 'POST' \
      'http://127.0.0.1:8000/bedrock/invoke' \
      -H 'accept: application/json' \
      -H 'Content-Type: application/json' \
      -d '{
      "input": "ポエムを作ってください。",
      "config": {},
      "kwargs": {}
    }'
    ```
    ```
    {
      "output": {
        "content": " はい、以下は簡単なポエムです。\n\nひとゆびの距離 \n\n夜の静けさにさびしく\n窓の外は星空を照らす\n目の前はひとつの指\n地球の行方はわからない\n\nたったひとつの指の先\n無限な宇宙が広がる\n星々はとても遠いのに\nひとゆびのずれにかかる\n\n指を動かせば景色が変わる\n新しい場所が見えてくる\nでも今はここにいる\nひとゆび先で世界は広がる\n\n簡単なポエムですが、ひとゆびの距離に宇宙の広さを重ね合わせました。内容や表現が不十分な部分があると思いますが、ご要",
        "additional_kwargs": {},
        "type": "ai",
        "example": false
      },
      "callback_events": [],
      "metadata": {
        "run_id": "117fae40-1ed6-4858-bca0-c44bc2707c71"
      }
    }
    ```

:::note
* プレイグラウンド

`http://127.0.0.1:8000/bedrock/playground/`にアクセスすると、プレイグラウンド画面が表示されます。（すごい！）

ここでもAPIの動作確認ができます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/23e6e749-7b3d-4dfa-4437-13d9e6d77ec4.png)

ドキュメントによるとプレイグラウンドの画面をカスタマイズすることもできるようです。
    
[Playground](https://python.langchain.com/docs/langserve#playground)

:::

## クライアントから呼び出す

APIにできたので、ブラウザから呼び出してみようと思います。

とても単純なHTMLを作成しました。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
</head>
<body>
  <script>
    async function call() {
      const response = await fetch("http://127.0.0.1:8000/bedrock/invoke", {
        method: "POST",
        body: JSON.stringify({
          "input": "ポエムを作ってください。"
        })
      });
      console.log((await response.json()).output.content);
    }
    call();
  </script>
</body>
</html>
```

index.htmlのディレクトリで簡易HTTPサーバーを起動します。

```shell
python -m http.server 8080
```

サーバー側とクライアント側でポート番号が異なるため、CORSリクエストになります。
サーバー側でCORSを有効化してサーバーを再起動します。

```diff_python
  #!/usr/bin/env python
  
  from fastapi import FastAPI
+ from fastapi.middleware.cors import CORSMiddleware
  from langchain_community.chat_models import BedrockChat 
  
  from langserve import add_routes
  
  app = FastAPI(
      title="LangServe",
      version="1.0",
      description="LangChain Server",
  )
  
+ app.add_middleware(
+     CORSMiddleware,
+     allow_origins=["http://127.0.0.1:8080"],
+     allow_credentials=True,
+     allow_methods=["*"],
+     allow_headers=["*"],
+ )
  
  add_routes(
      app,
      BedrockChat(model_id="anthropic.claude-instant-v1"),
      path="/bedrock",
  )
  
  if __name__ == "__main__":
      import uvicorn
  
      uvicorn.run(app)
```

ブラウザで`http://127.0.0.1:8080/`にアクセスすると、consoleに出力されました。

```
はい、簡単なポエムを作ってみます。

白い雪は降り注ぎに
木々はゆっくりと眠りにつく
星が光り夜は更けて
静かに闇が訪れる  

いかがでしょうか。内容はシンプルでテーマは自然の季節の移り変わりを詠んでいます。ポエムの作成は人工知能の限界がある部分なので、改善点があれば教えていただければ幸いです。自由な表現は難しい技術なのです。
```

Streming呼び出しもあるのですが、ちょっとうまくいきませんでした。。

:::note
[ドキュメント](https://python.langchain.com/docs/langserve#client)によるとLangChain.jsのversion 0.0.166以降で、以下のような呼び出しが可能なようです。（未確認）

```typescript
import {RemoteRunnable} from "langchain/runnables/remote";

const chain = new RemoteRunnable({
    url: `http://localhost:8000/joke/`,
});
const result = await chain.invoke({
    topic: "cats",
});
```
:::

## Lambda化

ローカルでの動作が確認できたので、Lambda化します。

AWS製の`AWS Lambda Web Adapter`というFastAPIのLambda化を行う便利なライブラリーがあります。これを使います。

https://github.com/awslabs/aws-lambda-web-adapter

こんなイメージです。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/63f3b5a5-7c9f-30f0-0be8-a979509ca347.png)


`examples/fastapi-response-streaming-zip`にサンプルが用意されていますので、これを参考に作成します。

```shell
tree fastapi-response-streaming-zip
```
```
fastapi-response-streaming-zip
├── README.md
├── __init__.py
├── app
│   ├── __init__.py
│   ├── main.py
│   ├── requirements.txt
│   └── run.sh
├── samconfig.toml
└── template.yaml

1 directory, 8 files
```

AWS Lambda Web Adapterは、レイヤーで指定されています。

:::note
レイヤーを指定する **だけ** です。すごいですね。

```diff_yaml
  FastAPIFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: app/
      Handler: run.sh
      Runtime: python3.12
      MemorySize: 256
      Environment:
        Variables:
          AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
          AWS_LWA_INVOKE_MODE: response_stream
          PORT: 8000
+     Layers:
+       - !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:18
      FunctionUrlConfig:
        AuthType: NONE
        InvokeMode: RESPONSE_STREAM
```
:::

LangServeをレイヤーにして追加しましょう。

```text:fastapi-response-streaming-zip/langserve-layer/requirements.txt
langserve[server]
pydantic==1.10.13
```

Pythonのバージョンを3.12にし、Bedrockの権限も付与します。

```diff_yaml:fastapi-response-streaming-zip/template.yaml
  FastAPIFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: app/
      Handler: run.sh
-     Runtime: python3.11
+     Runtime: python3.12
      MemorySize: 256
+     Policies: 
+       - arn:aws:iam::aws:policy/AmazonBedrockFullAccess
      Environment:
        Variables:
          AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
          AWS_LWA_INVOKE_MODE: response_stream
          PORT: 8000
      Layers:
        - !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:18
+       - !Ref LangServeLayer
      FunctionUrlConfig:
        AuthType: NONE
        InvokeMode: RESPONSE_STREAM
+ LangServeLayer:
+   Type: AWS::Serverless::LayerVersion
+   Properties:
+     ContentUri: langserve-layer/
+     CompatibleRuntimes:
+       - python3.12
+   Metadata:
+     BuildMethod: python3.12
+     BuildArchitecture: x86_64

```

:::note
LambdaのPython 3.12ランタイムはBoto3のバージョンが`1.28.72`のため、Bedrockに対応しています。そのため、レイヤーなどでboto3を追加インストールする必要はありません。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">あまり話題になってないようなので？リコメンド。<br>Lambda Python 3.12サポートでboto3のバージョンも1.28.72にアップデートされました。<br>よって、Amazon Bedrockを呼び出すためにboto3を埋め込んだLambda Layerを用意したり、関数内にライブラリを埋め込む必要は無くなりました。ご参考までー<a href="https://twitter.com/hashtag/AWS?src=hash&amp;ref_src=twsrc%5Etfw">#AWS</a>… <a href="https://t.co/wGlh8BO8z8">https://t.co/wGlh8BO8z8</a> <a href="https://t.co/VXasVSxXxC">pic.twitter.com/VXasVSxXxC</a></p>&mdash; t.kizawa (@kizawa2020) <a href="https://twitter.com/kizawa2020/status/1740297592981053492?ref_src=twsrc%5Etfw">December 28, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
:::

`main.py`に先程の`server.py`の内容を反映します。

```python:fastapi-response-streaming-zip/app/main.py
#!/usr/bin/env python

from fastapi import FastAPI
from langchain_community.chat_models import BedrockChat 

from langserve import add_routes

app = FastAPI(
    title="LangServe",
    version="1.0",
    description="LangChain Server",
)

add_routes(
    app,
    BedrockChat(model_id="anthropic.claude-instant-v1"),
    path="/bedrock",
)

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app)
```

ビルドしてデプロイします。

```shell
sam build

sam deploy --guided
```

デプロイできたらFunction URLにアクセスします。

```shell
curl -X 'POST' \
  'https://xxxxx.lambda-url.ap-northeast-1.on.aws/bedrock/invoke' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "input": "ポエムを作ってください。",
  "config": {},
  "kwargs": {}
}'
```
```
{
  "output": {
    "content": " はい、思いつきました。\n\n雨のポエム\n\n空は暗い雲に覆われて\n静かに降り注ぐ雨\n一筋の光も見えません\n\n道の上は水まみれ\n傘をさして歩く人\n靴がぬかるみに沈み込んで\n\n窓から外を見下ろす\n心地よいひまわりの時間\n本をめくりながら過ごす\n\n雨はじわじわと弱まって\n虹が現れるかもしれない\n希望を持とうではないか\n\n人生にも雨期があるというのに\nその先には必ず晴れが開いていく\n信じて待つ時が来る",
    "additional_kwargs": {},
    "type": "ai",
    "example": false
  },
  "callback_events": [],
  "metadata": {
    "run_id": "8e17ccdf-bc21-4867-a20f-dd1b1f841103"
  }
}
```

ローカルと同じように動作しました。

また、すごいことに`{Function URLエンドポイント}/docs`で**OpenAPIのドキュメントページも表示できます！！**

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/344e6576-a1ec-72dd-e08b-b2eaa053178e.png)

**すごいっす！AWS Lambda Web Adapter！！**

:::note
LangServeで作成されるAPIを限定するにはこのような指定を、

```
add_routes(
    app,
    BedrockChat(model_id="anthropic.claude-instant-v1"),
    path="/bedrock",
    enabled_endpoints=["invoke", "batch"]
)
```

OpenAPIのドキュメントを無効化したい場合はこのような指定をすると良さそうです。

```
app = FastAPI(
    title="LangServe",
    version="1.0",
    description="LangChain Server",
    docs_url=None,
    redoc_url=None,
    openapi_url=None
)
```