👏

【LangServe】2024年12月時点での書き方

2024/12/30に公開

以前こちらの記事で書いたのですが、色々バージョンアップしていました
https://zenn.dev/yuta_enginner/articles/81b4eaf578bdd9

立ち上げ

python3.12を想定しています

$ langchain app new .

以下のようなファイルができています

.
├── Dockerfile
├── README.md
├── app
│   ├── __init__.py
│   ├── __pycache__
│   └── server.py
├── packages
│   └── README.md
└── pyproject.toml

さて、この時点でpyproject.tomlを見てみると、いろいろおかしいことに気づきます

[tool.poetry]
name = "langchain_test"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
readme = "README.md"
packages = [
    { include = "app" },
]

[tool.poetry.dependencies]
python = "^3.11"
uvicorn = "^0.23.2"
langserve = {extras = ["server"], version = ">=0.0.30"}
pydantic = "<2"


[tool.poetry.group.dev.dependencies]
langchain-cli = ">=0.0.15"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

まずpython3.12で立ち上げたはずなのに、勝手にpython3.11を指定しています。

なので、3.12を使うよう書き換えてやりましょう。

[tool.poetry.dependencies]
python = "^3.12" #書き換え
% poetry env use python3.12
% poetry env info          

Virtualenv
Python:         3.12.7
Implementation: CPython
・・・

# 3.12であることが確認できたら、パッケージをアップデートする
% poetry update

ローカルでの動作確認

さて、この時点でローカルで立ち上げてもエラーが発生します

% poetry run langchain serve

# 以下のようなエラー
・・・
ImportError: cannot import name 'VerifyTypes' from 'httpx._types'

理由はlangserveのREADMEに書かれている通り、pydanticのバージョンがあっていないからです。

Versions of LangServe <= 0.2.0, will not generate OpenAPI docs properly when using Pydantic V2 as Fast API does not support mixing pydantic v1 and v2 namespaces. See section below for more details. Either upgrade to LangServe>=0.3.0 or downgrade Pydantic to pydantic 1.

※ そういえばpython3.13でpydanticの不一致が解消されたような気がします

対処法として、langserveのバージョンを0.3以上にすることを明記し、pydanticのバージョン指定を消します

[tool.poetry.dependencies]
python = "^3.12"
uvicorn = "^0.23.2"
langserve = {extras = ["server"], version = ">=0.3"} # 書き換え
- pydantic = "<2" # 消去

再度poetry updateを実行して、langchainを立ち上げてみましょう。

今度は問題なくswaggerが立ち上がったはずです。

LLMの導入

% poetry add langchain-openai langchain
% poetry add python-dotenv
.env
OPENAI_API_KEY="sk-*******"
server.py
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from langserve import add_routes
from fastapi.middleware.cors import CORSMiddleware

from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate

from dotenv import load_dotenv
load_dotenv()


app = FastAPI()

# 追加
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get("/")
async def redirect_root_to_docs():
    return RedirectResponse("/docs")


# Edit this to add the chain you want to add
template = """
    あなたはお笑い芸人です。ユーザーのメッセージに対して、面白い返答を返すようにしてください。
    お題:{user_message}
"""
prompt = ChatPromptTemplate.from_template(template)

llm = ChatOpenAI(model="gpt-4o-mini")

add_routes(
    app,
    prompt | llm,
    path="/chat",
)

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8000)

準備ができたら、langchainを立ち上げてください
/chatルートに色々できているはずです。

Dockerfileの編集とコンテナ作成

Dockerfileはlangserve立ち上げ時にデフォルトで作成されますが、内容が色々間違っているので修正します。

FROM python:3.12-slim # python3.11から書き換え

RUN pip install poetry==1.8.4 # 実際に使用しているpoetryのバージョンに合わせる

poetryのバージョンは、以下のコマンドで確認できます

% pip show poetry

「Build Image」でビルドできたら、コンテナで動かしてみましょう

GCPへデプロイ

以下のようなコマンド実行ファイルを作成すると簡単です。

deploy.sh
#!/bin/bash

PROJECT_ID="ここにPROJECT_IDを入れる"
REGION="asia-northeast2"
SERVICE_NAME="何か適当に名前をつける"

echo "Building and pushing Docker image..."
gcloud builds submit --tag gcr.io/${PROJECT_ID}/testfunc

echo "Deploying to Cloud Run..."
gcloud run deploy ${SERVICE_NAME} \
    --image gcr.io/${PROJECT_ID}/testfunc \
    --platform managed \
    --region ${REGION} \
    --allow-unauthenticated \
    --memory 512Mi \
    --cpu 1 \
    --min-instances 0 \
    --max-instances 10 \
    --port 8080 \
    --timeout 300 \
    --set-env-vars="GOOGLE_CLOUD_PROJECT=${PROJECT_ID},OPENAI_API_KEY=${OPENAI_API_KEY}"

echo "Deployment completed. Checking service URL..."
gcloud run services describe ${SERVICE_NAME} \
    --platform managed \
    --region ${REGION} \
    --format='value(status.url)'
% zsh deploy.sh

フロントの画面

メッセージアプリにしました。

VueとReactはTailwindCssとdaisyuiを使っています。

Vue

クリックして展開
App.vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { SystemMessage, HumanMessage, AIMessage } from '@langchain/core/messages';

type Message = SystemMessage | HumanMessage | AIMessage

const chatHistory = reactive<Message[]>([
  new SystemMessage('あなたは人工知能です'),
  new AIMessage('何かお題をください'),
])

const humanNewMessage = ref('')

const isComposing = ref(false) // 日本語入力中かどうか

// キーボードの入力を監視
const handleKeyDown = async(e: KeyboardEvent) => {
  // Shift + Enterの場合は改行を許可
  if (e.shiftKey && e.key === 'Enter') {
    return
  }

  // 通常のEnterの場合
  if (e.key === 'Enter') {
    // 日本語入力中は送信しない
    if (isComposing.value) {
      return
    }

    e.preventDefault() // デフォルトの改行を防ぐ

    // メッセージが空の場合は送信しない
    if (!humanNewMessage.value.trim()) {
      return
    }

    chatHistory.push(new HumanMessage(humanNewMessage.value))

    // メッセージを送信
    await handleSendMessage(humanNewMessage.value)
    humanNewMessage.value = '' // 入力欄をクリア
  }
}

const streamingAiMessage = ref('')

const handleSendMessage = async(message:string) => {

  const response = await fetch('http://127.0.0.1:8000/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      input: {user_message: message},
      config: {},
      kwargs: {}
    }),
  })

  if (!response.ok) throw new Error('Network response was not ok')

  const reader = response.body?.getReader()
  if (!reader) throw new Error('Reader not available')

  while (true) {
      const { done, value } = await reader.read()
      if (done) break
      
      // 受信したチャンクをデコード
      const chunk = new TextDecoder().decode(value)

      if(chunk.startsWith('event: end'))break
      if(!chunk.startsWith('event: data'))continue
      
      // SSEフォーマットからデータ部分を抽出
      const lines = chunk.split('\n').filter(line => line.trim() !== '')
      for (const line of lines) {
          if (line.startsWith('data: ')) {
              try {
                  const jsonStr = line.slice(6) // 'data: ' の部分を除去
                  const jsonData = JSON.parse(jsonStr)
                  if (jsonData.content) {
                      streamingAiMessage.value += jsonData.content
                  }
              } catch (e) {
                  console.error('Failed to parse JSON:', e)
              }
          }
      }
  }

  chatHistory.push(new AIMessage(streamingAiMessage.value))

  streamingAiMessage.value = ''
}

</script>

<template>
  <div class="h-screen p-4">

    <div class="flex flex-col w-[500px] h-full">
      <div class=" flex-1" >
        <div v-for="message in chatHistory" :key="message.id">
          <div v-if="message.getType()!=='system'" class=" chat" :class=" message.getType() === 'ai' ? 'chat-start' : 'chat-end'">
            <div class="chat-bubble">
              {{ message.content }}
            </div>
          </div>
        </div>

        <div v-if=" streamingAiMessage">
          <div class=" chat chat-start">
            <div class="chat-bubble">
              {{ streamingAiMessage }}
            </div>
          </div>
        </div>
      </div>

      <div class="flex items-center">
        <textarea class=" textarea textarea-bordered flex-1"
        v-model="humanNewMessage"
        @keydown="handleKeyDown"
        @compositionstart="isComposing = true"
        @compositionend="isComposing = false"
        ></textarea>
      </div>

    </div>

  </div>
</template>

Discussion