🦜

Vercel AI SDK 3.0のGenerative UIでLangChain.jsのAgent用チャットUIを作る

2024/04/22に公開

はじめに

LLMにツールを与え、課題解決してもらうAgent。

つい1年くらい前はツールへのインプットが不正だったり、コンテキスト長が足りなかったり、レスポンスが遅かったりしたものですが、モデルの能力向上たるや目覚ましく、いまや安定して、なおかつ日本語でも素早い回答ができるようになってきました。

ある程度実務に使えそうな見込みが出てくるとともに問題になるのが、作ったAgentをどうデプロイするか、UIをどうするか、です。

ツールを動かしていることがユーザーに伝わらなければ、フリーズしていると思われるし、ツールを要する質問には回答も長くなりがちなので、回答の完成を待たず、Streamで一文字一文字順次に返していく必要が出てきます。このUI実装がなかなかに困難です。

そこでこの記事では、2024年3月に発表されたVercel AI SDK 3.0を使ったLangChain AgentのUI実装を行います。

https://sdk.vercel.ai/docs

Vercel AI SDKを使うとEdge RuntimeでのStreamを簡単に実装でき、3.0ではGenerative UIが追加され、クライアントサイドの表示を動的に変えることが可能になりました。

さらにテンプレートとなるサンプルアプリも公開されています。

https://chat.vercel.ai

このサンプルアプリをいじり、LangChain Agentを載せていきます。

本記事の目標・スコープ

Vercelのサンプルアプリをベースに下記のようなAgentに適したUIを作成します。

  • ツールが使われていることがわかること
  • ツールの使用開始、終了のタイミングで表示が変わること
  • 回答が一文字一文字Streamされること


結果イメージ

一方で、どのAgent、ツールを使うかは本記事の問題とはせず、OpenAIFunctionsAgentに簡単なCalculatorツールをモックとして与え、これを動かすことを目標とします。[1]

0. 環境

  • M1 MacbookAir(2020)
  • node: v20.12.0
  • pnpm: 8.14.1
  • 2024年4月下旬
    • Vercel AI SDK: 3.0.12
    • Next.js: 14.1.3

1. Vercelのサンプルアプリを動かす

準備:環境変数の用意

事前に以下を用意します。

  • OPENAI_API_KEY
    • OpenAI APIのコンソールから。
  • AUTH_SECRET
    • ターミナルでopenssl rand -base64 32を実行して取得。

方法A:VercelへDeploy

VercelのアカウントがあればワンクリックでDeployができます。


Deployを選択

https://vercel.com/templates/next.js/nextjs-ai-chatbot

  • Create Git Repository
  • Add Storage
    • ログインやチャット履歴で用いるVercel KVが作成されます。
  • Configure Project
    • 環境変数の設定

Vercel KVやCI/CD設定もよしなにしてくれるので、これが一番楽です。

方法B:Vercelを使わずローカルで動かす

筆者は無料枠分のVercel KVを使い切っており、Vercel依存も気になるので、この方法を選びました。
手間はかかります。

Gitリポジトリのclone

$git clone https://github.com/vercel/ai-chatbot.git
$cd ai-chatbot
$pnpm install

Upstash Redisのインスタンス作成

Vercel KVHow Vercel KV worksのページに書かれている通り、Upstashと連携しています。

Vercel KV is powered by a partnership with Upstash.
https://vercel.com/docs/storage/vercel-kv#how-vercel-kv-works

したがって、いまのところはUpstash Redisで代替することができます。

https://console.upstash.com/login

より、

  • Upstashのアカウント作成
  • Redisインスタンスの作成

を行います。

作成後、さまざまな方法での構成方法が表示されますが、Edge Runtimeで使えるREST APIを使います。

ここで

  • UPSTASH_REDIS_REST_URL
  • UPSTASH_REDIS_REST_TOKEN

を取得、保管します。

.envの用意

.env.sampleに従って.envを作成します。

OPENAI_API_KEY=XXXXXXXX
AUTH_SECRET=<ターミナルで`openssl rand -base64 32`を実行して取得>

# Vercel KVの代わりにUpstashを使う
UPSTASH_REDIS_REST_URL=XXXXXXXX
UPSTASH_REDIS_REST_TOKEN=XXXXXXXX

依存パッケージの変更

$pnpm remove @vercel/kv
$pnpm add @upstash/redis

https://github.com/upstash/upstash-redis

@vercel/kvのimportの修正

import { kv } from '@vercel/kv'を下記で全置換します。[4]

import { Redis } from "@upstash/redis";

const kv = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL || '',
  token: process.env.UPSTASH_REDIS_REST_TOKEN || ''
});

pnpm devで確認

ローカルで動くか検証します。

$pnpm dev

挨拶してみます。

You are a stock trading conversation bot and you can help users buy stocks, step by step.とプロンプトで指定されているので、このような返答になります。

2. LangChainの導入

$pnpm add langchain @langchain/core @langchain/openai @langchain/community

3. サンプルアプリの調整

3-1. components/ui/icons.tsx

LangChainのIconを追加します。

https://en.m.wikipedia.org/wiki/File:LangChain_logo.svg

icons.tsx
icons.tsx
export function IconLangchain({ className, ...props }: React.ComponentProps<'svg'>) {
  return (
    <svg
      width="320"
      height="160"
      viewBox="0 0 320 160"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M50.0124 119.351C50.0124 119.351 53.0624 131.901 52.5874 133.663C52.1124 135.426 43.4374 136.826 38.3874 139.413C35.0749 141.113 29.7374 144.901 32.9874 147.963C34.7499 149.626 39.6749 145.426 54.4624 144.576C69.4749 143.713 72.1249 144.938 71.3499 141.763C70.5874 138.601 66.5999 138.238 63.2499 136.838C62.1374 136.376 60.8499 135.638 60.3124 134.838C59.7999 134.076 60.5499 121.113 60.5499 121.113L50.0124 119.351Z"
        fill="#FEBA02"
      />
      <path
        d="M77.7 128.149C77.7 128.149 78.05 140.837 78.05 141.649C78.05 142.837 75.15 144.262 70.8375 145.224C66.525 146.187 57.9875 147.787 57.875 153.249C57.8 156.612 74.275 152.062 83.6875 152.199C93.1 152.337 99.1375 153.574 98.7 149.624C98.35 146.462 93.45 145.024 90.8125 144.449C88.175 143.874 86.975 142.587 86.9 141.012C86.85 139.987 87.075 128.424 87.075 128.424L77.7 128.149Z"
        fill="#FEBA02"
      />
      <path
        d="M52.3626 10.013L63.6876 9.25049C63.6876 9.25049 69.9876 11.288 75.9751 19.963C83.9251 31.5005 82.8751 52.6005 82.8751 52.6005L104.7 110.788L107.338 130.025C107.338 130.025 87.8126 133.1 68.6876 130.613C46.1626 127.675 22.9376 107.263 23.2876 76.763C23.6376 46.263 47.8126 16.813 47.8126 16.813L52.3626 10.013Z"
        fill="url(#paint0_linear_1_89)"
      />
      <path
        d="M26.4378 21.1631C26.4378 21.1631 17.7503 23.8631 15.2878 33.6006C12.8253 43.3381 16.9378 52.4006 18.7503 53.0131C19.4503 53.2506 21.6253 48.8506 21.6253 48.8506C21.6253 48.8506 24.6753 52.3756 26.2003 53.1881C27.7253 54.0131 28.4878 54.6256 29.8378 54.5381C32.4753 54.3631 36.3503 46.6756 36.2378 41.5131L37.5878 16.2256L26.4378 21.1631Z"
        fill="#FFD51D"
      />
      <path
        d="M37.3123 35.1131C37.3123 35.1131 47.8998 33.3506 52.1248 29.7131C56.3498 26.0756 61.2748 22.7881 62.9123 16.2256C63.9123 12.2256 63.9623 9.4256 63.9623 9.4256C63.9623 9.4256 49.1998 0.863096 35.8248 7.9006C22.4498 14.9381 25.6123 24.5006 25.6123 24.5006L37.3123 35.1131Z"
        fill="#FF2B23"
      />
      <path
        d="M36.8747 36.5259C36.8747 36.5259 28.3122 41.5759 27.3747 42.2759C26.4372 42.9759 23.3747 44.5009 21.9747 45.6759C19.7497 47.5509 17.7497 51.7759 18.5747 52.8384C19.3997 53.8884 20.5747 50.6134 23.8497 48.6134C27.1247 46.6134 32.7622 43.4509 34.2872 42.6259C35.8122 41.8009 36.2872 41.3384 36.2872 41.3384L36.8747 36.5259Z"
        fill="#FF600D"
      />
      <path
        d="M39.7875 34.325C40.375 33.0875 40.6375 30.25 36.65 25.2625C31.9625 19.4 25.3875 19.1625 25.3875 19.1625L24.6875 21.5125L31.9625 28.7875L36 37.5C36 37.5 38.575 36.875 39.7875 34.325Z"
        fill="#A02A1B"
      />
      <path
        d="M19.8627 25.5004C19.8627 25.5004 25.2627 25.8504 29.0127 29.1379C31.2377 31.0879 34.6002 35.2629 35.4252 37.1504C36.2502 39.0379 39.0627 33.8129 36.8377 30.2879C34.6127 26.7629 31.9502 23.2254 27.0252 21.5129C23.4752 20.2879 20.1002 23.2754 19.8627 25.5004Z"
        fill="#EC80AF"
      />
      <path
        d="M61.8625 32.425C61.8625 32.425 57.525 21.4 48.6 21.8625C39.6 22.3375 38.2125 29.025 38.625 33.0125C39.1 37.625 42.15 40.175 49.1875 39.9375C56.2375 39.6875 61.8625 32.425 61.8625 32.425Z"
        fill="#FBD3B3"
      />
      <path
        d="M55.2999 31.0113C55.2999 33.8613 52.1999 36.4113 49.3749 36.4113C46.5624 36.4113 44.2749 34.0988 44.2749 31.2488C44.2749 28.3988 45.9124 25.2613 49.8999 25.0363C54.1124 24.7738 55.2999 28.1613 55.2999 31.0113Z"
        fill="#312F2A"
      />
      <path
        d="M107.975 100.938C107.975 100.938 110.787 107.275 121.587 114.312C130.75 120.287 135.2 123.938 141.538 126.163C147.875 128.388 148.113 128.975 147.875 130.15C147.638 131.325 137.787 138.95 126.987 138.013C116.2 137.075 111.037 132.85 107.75 130.625C104.462 128.4 98.25 112.675 98.25 112.675L107.975 100.938Z"
        fill="#01AB46"
      />
      <path
        d="M82.925 52.3C82.925 52.3 67.9625 56.7 61.4 66.675C55.9 75.025 56.3875 89.225 61.9875 99.7625C68.9125 112.787 83.225 122.05 90.85 125.575C98.475 129.1 108.912 130.85 113.487 130.387C118.062 129.925 109.387 98.3625 101.05 81.4625C92.925 64.975 82.925 52.3 82.925 52.3Z"
        fill="#B7D019"
      />
      <path
        d="M189.888 130.025C181.763 121.737 181.763 108.512 189.888 100.225L213.313 76.7998C215.863 74.2498 219.213 72.3373 222.713 71.3873C226.213 70.4373 229.888 70.4373 233.226 71.2248C236.726 72.1748 240.076 73.9373 242.788 76.4873C242.951 76.6498 242.951 76.6498 243.101 76.7998C251.388 85.0873 251.388 98.3123 243.101 106.6L219.676 130.025C211.401 138.312 198.013 138.312 189.888 130.025ZM256.176 63.7373C240.726 48.2873 215.388 48.2873 199.926 63.7373L176.663 87.1623C161.213 102.612 161.213 127.95 176.663 143.412C192.113 158.875 217.451 158.862 232.913 143.412L256.338 119.987C271.626 104.525 271.626 79.1998 256.176 63.7373Z"
        fill="#84B0C1"
      />
      <path
        d="M243.575 60.5998C247.775 58.4873 251.788 59.9623 251.788 59.9623C243.113 53.5498 233.65 51.7248 225.5 52.2373C225.45 52.2998 225.413 52.3623 225.363 52.4373C221.788 57.9248 220.988 65.1748 223.213 71.2748C226.563 70.4498 230.038 70.4748 233.213 71.2373C234.513 71.5873 235.5 71.9248 237.45 72.8373C237.463 72.8248 236.775 64.0248 243.575 60.5998Z"
        fill="#2F7889"
      />
      <path
        d="M218.452 73.0749C218.614 71.8624 218.689 70.6374 218.764 69.4124C219.114 64.1374 220.014 58.8874 221.439 53.7999C221.539 53.4374 221.639 53.0499 221.739 52.6624C217.877 53.2749 214.089 54.4499 210.527 56.1999C204.977 63.3624 206.802 72.6374 213.314 76.8124C214.802 75.3249 216.552 74.0624 218.452 73.0749Z"
        fill="#2F7889"
      />
      <path
        d="M290.113 29.9748C298.238 38.2623 298.238 51.4873 290.113 59.7748L266.688 83.1998C264.138 85.7498 260.788 87.6623 257.288 88.6123C253.788 89.5623 250.113 89.5623 246.776 88.7748C243.276 87.8248 239.926 86.0623 237.213 83.5123C237.051 83.3498 237.051 83.3498 236.901 83.1998C228.613 74.9123 228.613 61.6873 236.901 53.3998L260.326 29.9748C268.601 21.6873 281.988 21.6873 290.113 29.9748ZM223.826 96.2623C239.276 111.712 264.613 111.712 280.076 96.2623L303.338 72.8373C318.788 57.3873 318.788 32.0498 303.338 16.5873C287.888 1.12481 262.551 1.13731 247.088 16.5873L223.676 40.0123C208.376 55.4748 208.376 80.7998 223.826 96.2623Z"
        fill="#84B0C1"
      />
      <path
        d="M223.062 49.2747C223.112 49.2372 223.162 49.1872 223.212 49.1497C223.874 48.5747 224.612 48.0247 225.474 47.8747C226.337 47.7247 227.349 48.1122 227.649 48.9372C227.874 49.5497 227.674 50.2247 227.462 50.8372C224.174 60.7747 221.537 73.3872 224.737 83.6747C225.049 84.6747 225.349 85.9622 224.512 86.5872C224.024 86.9622 223.299 86.8997 222.762 86.6122C216.887 83.4372 215.862 74.8372 215.587 68.9372C215.262 61.4872 217.324 54.3122 223.062 49.2747Z"
        fill="#A8E3F0"
      />
      <path
        d="M249.152 89.1749C248.352 89.0749 247.502 88.9124 247.502 88.9124C247.502 88.9124 247.377 94.2499 243.114 98.6749C238.389 103.575 233.677 103.45 233.677 103.45C236.164 104.887 241.127 106.375 242.927 106.8L243.114 106.612C247.902 101.812 249.902 95.3624 249.152 89.1749Z"
        fill="#2F7889"
      />
      <path
        d="M273.45 76.4368L266.787 83.0993C268.45 90.5243 268 98.3243 265.425 105.524C268.662 104.374 271.775 102.787 274.675 100.774C279.462 92.5743 278.412 82.8868 273.45 76.4368Z"
        fill="#2F7889"
      />
      <path
        d="M259.863 87.5123C259.076 85.5248 257.538 83.5123 255.613 83.6998C254.413 83.8123 252.551 84.9373 252.676 88.3748C252.763 91.0373 253.776 93.6373 252.326 97.1248C250.201 102.225 250.688 103.6 251.201 104.412C251.763 105.3 252.801 105.7 253.763 105.687C256.301 105.675 258.401 103.35 259.551 100.8C261.413 96.7248 261.526 91.6873 259.863 87.5123Z"
        fill="#A8E3F0"
      />
      <path
        d="M221.563 135.225C222.363 134.563 224.438 132.35 225.4 133.75C225.05 137.6 222.088 140.738 218.838 142.838C214.838 145.425 210.1 146.95 205.338 146.813C201.388 146.7 194.838 145.1 192.538 141.438C190.663 138.463 194.625 137.95 196.825 138.775C204.663 141.763 213.525 141.825 221.563 135.225Z"
        fill="#A8E3F0"
      />
      <defs>
        <linearGradient
          id="paint0_linear_1_89"
          x1="58.5671"
          y1="121.322"
          x2="82.3217"
          y2="42.0614"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0.1362" stopColor="#79DA88" />
          <stop offset="0.3039" stopColor="#57CD75" />
          <stop offset="0.6343" stopColor="#19B553" />
          <stop offset="0.7914" stopColor="#01AB46" />
        </linearGradient>
      </defs>
    </svg>
  )
}

3-2. components/stocks/message.tsx

すでにあるコンポーネントを微調整し、ツール名の表示や終了時のチェックマークを追加します。

message.tsx
export function SpinnerMessage({
  content,
  icon,
  isFinished = false
}: {
  content?: string | StreamableValue<string>
  icon?: React.ReactNode
  isFinished?: boolean
}) {
  let text = useStreamableText(content ? content : '')

  return (
    <div className="group relative flex items-start md:-ml-12">
      <div className="flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm">
        {icon ? icon : <IconOpenAI />}
      </div>
      <div className="ml-4 h-[24px] flex flex-row items-center flex-2 space-y-2 overflow-hidden px-1">
        {isFinished ? <IconCheck /> : spinner}
      </div>
      {text && (
        <div className="flex-1 p-2 break-words text-xs text-gray-500">
          {text}
        </div>
      )}
    </div>
  )
}

3-3. components/tool-message.tsx(作成)

ツールの状態を表示するコンポーネント。

tool-message.tsx
import { SpinnerMessage } from './stocks/message'
import { IconLangchain } from './ui/icons'

export const ToolMessage = ({
  header,
  isFinished = false
}: {
  header: string
  isFinished?: boolean
}) => {
  return (
    <SpinnerMessage
      content={header}
      icon={<IconLangchain />}
      isFinished={isFinished}
    />
  )
}

ここに後でtoolNameをPropsに追加すれば、ツールごとに表示するコンポーネントを変えることもできます。

3-4. components/prompt-form.tsx

1回の応答でツール用のメッセージとbotの回答の2つを返したいので、下記のように変更します。

prompt-form.tsx
-        setMessages(currentMessages => [...currentMessages, responseMessage])
+        setMessages(currentMessages => [...currentMessages, ...responseMessage])

3-5. lib/chat/langchain.tsx(作成)

メインとなるロジックを追加します。

langchain.tsx
import { Message, nanoid } from 'ai'
import {
  getMutableAIState,
  createStreamableValue,
  createStreamableUI
} from 'ai/rsc'
import { AI } from '../chat/actions'
import { runAsyncFnWithoutBlocking } from '../utils'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { ChatOpenAI } from '@langchain/openai'
import { AgentExecutor, createOpenAIFunctionsAgent } from 'langchain/agents'
import {
  AIMessage,
  SystemMessage,
  HumanMessage
} from '@langchain/core/messages'
import { Calculator } from '@langchain/community/tools/calculator'
import { BotMessage } from '@/components/stocks'
import { ToolMessage } from '@/components/tool-message'

export async function langchainMessage(content: string) {
  const aiState = getMutableAIState<typeof AI>()

  aiState.update({
    ...aiState.get(),
    messages: [
      ...aiState.get().messages,
      {
        id: nanoid(),
        role: 'user',
        content
      }
    ]
  })

  const llm = new ChatOpenAI({
    model: process.env.OPENAI_MODEL as string,
    temperature: 0,
    streaming: true,
    verbose: false
  })

  const prompt = ChatPromptTemplate.fromMessages([
    [
      'system',
      `You are a helpful assistant. You are helping a user with math calculation.`
    ],
    ['placeholder', '{chat_history}'],
    ['human', '{input}'],
    ['placeholder', '{agent_scratchpad}']
  ])

  const tools = [new Calculator()]
  const agent = await createOpenAIFunctionsAgent({
    llm,
    tools,
    prompt
  })

  const agentExecutor = new AgentExecutor({
    agent,
    tools,
    verbose: false
  }).withConfig({ runName: 'Agent' })

  let textStream: undefined | ReturnType<typeof createStreamableValue<string>>
  let textNode: undefined | React.ReactNode

  let toolStream = createStreamableUI()
  let closed = false

  runAsyncFnWithoutBlocking(async () => {
    if (!textStream) {
      textStream = createStreamableValue('')
      textNode = <BotMessage content={textStream.value} />
    }

    const response = agentExecutor.streamEvents(
      {
        input: content,
        chat_history: aiState
          .get()
          .messages.map(message => convertMessageToChatMessage(message))
      },
      { version: 'v1' }
    )

    for await (const event of response) {
      const eventType = event.event

      if (eventType === 'on_llm_stream') {
        if (event.name === 'ChatOpenAI') {
          const content = event.data?.chunk?.message?.content
          textStream.update(content)
        }
      } else if (eventType === 'on_chain_end') {
        if (event.name === 'Agent') {
          console.log('\n-----')
          console.log(`Finished agent: ${event.name}\n`)
          console.log(`Agent output was: ${JSON.stringify(event.data.output)}`)
          console.log('\n-----')
          const message = event.data.output
          if (!closed) {
            textStream.done()
            toolStream.done()
            closed = true

            aiState.done({
              ...aiState.get(),
              messages: [
                ...aiState.get().messages,
                {
                  id: nanoid(),
                  role: 'assistant',
                  content: message
                }
              ]
            })
          }
        }
      } else if (eventType === 'on_tool_start') {
        const toolName = event.name
        console.log('\n-----')
        console.log(
          `Starting tool: ${toolName} with inputs: ${event.data.input}`
        )
        toolStream.update(
          <ToolMessage
            header={`Starting tool: ${toolName} with inputs: ${event.data.input}`}
            isFinished={false}
          />
        )
      } else if (eventType === 'on_tool_end') {
        const toolName = event.name
        console.log('\n-----')
        console.log(`Finished tool: ${toolName}\n`)
        console.log(`Tool output was: ${event.data.output}`)
        console.log('\n-----')
        const output = toolOutputParser({
          toolName,
          output: event.data.output
        })
        console.info(output)
        toolStream.update(
          <ToolMessage
            header={`Finished tool: ${toolName}`}
            isFinished={true}
          />
        )
      }
    }
  })

  return [
    {
      id: nanoid(),
      type: 'tool',
      display: toolStream.value
    },
    {
      id: nanoid(),
      display: textNode
    }
  ]
}

function convertMessageToChatMessage(message: Message) {
  switch (message.role) {
    case 'user':
      return new HumanMessage(message.content)
    case 'assistant':
      return new AIMessage(message.content)
    case 'system':
      return new SystemMessage(message.content)
    default:
      return null
  }
}

// 仮実装。tool -> componentのpropsへの変換など。
async function toolOutputParser({
  toolName,
  output
}: {
  toolName: string
  output: any
}) {
  console.log(`Implement toolOutputParser here for ${toolName}`)
  return output
}

以下、ポイントです。

Vercel AI SDK

UIを動的に変更させるために、Vercel AI SDKのGenerative UIを使います。

  • createStreamableUI()でツール用のUIを宣言
  • updateで更新
  • doneでclose

createStreamableUIのシグネチャは下記のようになっています。

/**
 * Create a piece of changable UI that can be streamed to the client.
 * On the client side, it can be rendered as a normal React node.
 */
declare function createStreamableUI(initialValue?: React.ReactNode): {
    value: react_jsx_runtime.JSX.Element;
    update(value: React.ReactNode): void;
    append(value: React.ReactNode): void;
    error(error: any): void;
    done(...args: [] | [React.ReactNode]): void;
};

streamEvents

ツールの使用状況を取得するために、LangChainのstreamEventsを用います。

https://js.langchain.com/docs/modules/agents/how_to/streaming#custom-streaming-with-events

これを用いるとAgentの各フェーズごとに処理を追加できるため、on_tool_starton_tool_endイベントに応じてメッセージを更新することができます。

3-6. lib/chat/actions.tsxの修正

作成した関数で既存のサンプルロジックを置き換えます。

actions.tsx
async function submitUserMessage(content: string) {
  'use server'

  return await langchainMessage(content)
}

3-7. components/chat-list.tsx

このままだと下記のように無駄なSeparatorができてしまうので調整します。

chat-list.tsx
      {messages.map((message, index) => (
        <div key={message.id}>
          {message.display}
          {index < messages.length - 1 && message?.type !== 'tool' && (
            <Separator className="my-4" />
          )}
        </div>
      ))}

型エラーが出るので対応します。

lib/chat/actions.tsx
export type UIState = {
  id: string
  type?: string
  display: React.ReactNode
}[]

試してみる

Toolを使用時にSpinnerが表示され、

Toolが終了すると、チェックマークに変わります。

4. カスタムツールを追加する

最後にLangchain.jsのドキュメントにしたがって自前ツールを追加してみます。

https://js.langchain.com/docs/modules/agents/tools/dynamic

lib/chat/tools.ts
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";

export const ultimateTool = new DynamicStructuredTool({
  name: "ultimateTool",
  description: "This tool provides answer to the Ultimate Question of Life, the Universe, and Everything",
  func: async ({ query }: { query: string }) => {
    console.log("ultimateTool query:", query);
    return new Promise((resolve) => setTimeout(() => resolve("42"), 1000));
  },
  schema: z.object({
    query: z.string(),
  })()
})

シンプルに「42」を返すツールで引数も不要ですが、参考のため、あえてDynamicStructuredToolを使い、引数をzodでvalidationするツールとして定義しています。

ツールをモデルに追加します。

lib/chat/langchain.tsx
+ import { ultimateTool } from './tools'

+ const tools = [new Calculator(), ultimateTool]

あらためて質問をしてみます。

ツールが使われ、「42」が返ることが確認できました。

おわりに

これでサンプルアプリにLangchainのAgentを組み込むことができました。

この後は、

  • プロンプトの調整
  • ツールの追加
  • ツールごとのコンポーネントの追加
  • ツールのoutput(結果)のフォーマット関数の調整
    • たとえばツールの結果はstringで返ってくるので、JSONにしたい場合は検証とparseが必要

などをしてAgentを拡張していくことができます。

一例として、Web検索ツールの結果をGoogleライクに表示することも比較的容易に実装できます。

2024年はAgentの活用が進む年になると思いますので、UIの選択肢として参考にしていただければ幸いです。


補遺: サンプルアプリのCloud Runへのデプロイ

Vercelでのデプロイが最も簡単ですが、Hobbyプランで商用不可なこともあり、Cloud Runへのデプロイ方法を補足します。

Docker環境の整備

下記のサンプルにしたがい、Next.jsのDocker環境を作成します。

https://github.com/vercel/next.js/tree/canary/examples/with-docker

Dockerfileの作成

プロジェクトルートに下記を作成。

FROM node:18-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN yarn build

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

output: standalone指定

next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
+  output: "standalone",
  images: {

Vercel KVをUpstash Redisに変更

記事本文を確認してください。

Cloud Runにデプロイ

後はgcloud run deployでデプロイします。

筆者は下記のようにシェルスクリプトを用意して.envから読み込むようにしています。

deploy.sh
#!/bin/bash

# .env ファイルから環境変数を読み取り、引用符で囲まれている値の引用符を除去して出力する
ENV_VARS=$(awk -F '=' '{
    gsub(/^[ \t]+|[ \t]+$/, "", $2);  # 前後の空白を削除
    if ($2 ~ /^".*"$/)                # 値が引用符で囲まれている場合
        print $1"="substr($2, 2, length($2)-2);  # 引用符を除去
    else                              # それ以外の場合
        print $1"="$2;                # そのまま出力
}' .env | paste -sd ',' -)

# gcloud コマンドを実行し、環境変数を設定する
gcloud run deploy vercel_chat_sample --source . --platform managed --region asia-northeast1 --allow-unauthenticated --set-env-vars "$ENV_VARS"

なお、クライアントサイドで環境変数を参照したい場合は、NEXT_PUBLICを忘れないようにしてください。

https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser

脚注
  1. ツールに関して今回の要件特有の制約としては、Edge Runtimeで動くかどうか、が挙げられます。たとえば、axiosやfs, pathを使うツールは動かず、fetch等に書き換える必要があります。 ↩︎

  2. たとえば認証周りでGitHub認証からパスワード認証に変更になったり、永続層をPostgresからKVに変更したり、大きな変更が頻繁に起きています。
    https://github.com/vercel/ai-chatbot/issues/259 ↩︎

  3. 本記事で用いるstreamEvents APIは執筆時点でbetaとなっています。
    https://js.langchain.com/docs/modules/agents/how_to/streaming#custom-streaming-with-events ↩︎

  4. やり方として雑ですが、記事の本題に関わらないため、全置換で済ませます。 ↩︎

Discussion