HonoとAmazon Bedrock & LangChainを利用した簡単な生成AIチャットの作り方

この記事では、JavaScriptを利用した生成AIチャットの作り方が紹介されています。LangChainやAmazon Bedrock、そしてHonoを使用することで実装が容易になります。また、Honoを使うことで複雑なフロントエンド実装を行う必要がなくなり、AWS Lambdaなどのホスティングサービスの違いに影響を受けにくくなります。技術選定の背景やAPIの実装方法などが詳細に説明されています。

広告ここから
広告ここまで

目次

    この記事では、JavaScriptを利用した簡単な生成AIチャットの作り方を紹介します。LangChainとAmazon Bedrock、そしてHonoを利用することで、実際のプロダクト投入に向けた実装などにも活かせる内容になっているかと思います。これは「Amazon Bedrock Advent Calendar 2024」1日目の記事として作成しました。

    技術選定の背景

    デモアプリを開発する際、よほど複雑なフロントエンド実装を行う時以外はHonoを利用しています。これはAWS Lambda / Cloudflare / Vercelなどホスティングサービスの仕様の違いによる影響を受けにくいフレームワークで、JSXまでサポートしているのが個人的な採用理由です。Amazon BedrockとLangChainは、生成AIのモデルを後から変更したりモックを差し込んだりするのがやりやすい点から採用しています。とはいえどちらか片方だけ採用する形でも問題はないと思いますので、この辺りは好みもあるでしょう。

    Honoで雛形を作る

    まず雛形を用意しましょう。ウェブアプリを表示するGETのパスと、Amazon Bedrockを呼び出すためのPOST APIの2つを用意しています。また、script.jsのパスでブラウザ向けのJavaScriptファイルを配信する設定も追加しています。

    import { Hono } from 'hono'
    import { FakeListChatModel } from "@langchain/core/utils/testing";
    import { StringOutputParser } from "@langchain/core/output_parsers";
    import { streamText, streamSSE } from 'hono/streaming'
    import { BedrockChat } from "@langchain/community/chat_models/bedrock/web";
    import { env } from 'hono/adapter'
    import script from '../assets/script.js'
    
    
    const app = new Hono()
    
    
    app.get('/script.js', (c) => {
      return c.body(script, 200, {
        'Content-Type': 'text/javascript'
      })
    })
    
    app.get('/', (c) => {
      return c.render(
      <main class="mx-auto max-w-7xl sm:px-6 lg:px-8 mt-5">
        <div class="flex flex-col lg:flex-row gap-8">
            <section class="lg:w-1/2">
            <h3 class="text-base font-semibold leading-6 text-gray-900">Chat</h3>
    
            <div>
              <form id="input-form" autocomplete="off" method="post" class="mt-2 flex rounded-md shadow-sm">
                <div class="relative flex flex-grow items-stretch focus-within:z-10">
                  <input type="text" name="query"  class="block w-full rounded-none rounded-l-md border-0 py-1.5 pl-2 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" placeholder="Hello"/>
                </div>
                <button type="submit" class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
                  Start
                </button>
                </form>
            </div>
            <pre
              id="ai-content"
              style={{
                'white-space': 'pre-wrap'
              }}
              class='mt-4'
            ></pre>
    
          </section>
        </div>
      </main>
      )
    })
    
    app.post('/ai', async (c) => {
      // @TODO
    })
    
    export default app
    

    script.jsファイルはこのような中身です。ページ読み込み時にフォーム送信のイベントをリッスンする形とし、fetchChunked関数で生成AIからのStreamで返却されるレスポンス文字列を処理しています。

    document.addEventListener('DOMContentLoaded', function () {
      const target = document.getElementById('ai-content')
      document.getElementById('input-form').addEventListener('submit', function (event) {
        event.preventDefault()
        const formData = new FormData(event.target)
        const query = formData.get('query')
        fetchChunked(target, query)
      })
    })
    
    function fetchChunked(target, query) {
      target.innerHTML = 'loading...'
      fetch('/ai', {
        method: 'post',
        headers: {
          'content-type': 'application/json'
        },
        body: JSON.stringify({ message: query })
      }).then((response) => {
        const reader = response.body.getReader()
        let decoder = new TextDecoder()
        target.innerHTML = ''
        reader.read().then(function processText({ done, value }) {
          if (done) {
            return
          }
          target.innerHTML += decoder.decode(value)
          return reader.read().then(processText)
        })
      })
    }
    

    LangChain.jsとAmazon Bedrockで生成AIによる回答生成APIを実装する

    続いてAPIの中身を実装しましょう。といってもシンプルなチャットですので、あまり複雑な使い方は行いません。LangChainが用意するモデルクラスから、Bedrockを選択し、AWSのアクセスキーなどを渡しています。アクセスキーについては、AWSアカウントにデプロイする場合は、LambdaやEC2などにIAMロールを適切に設定すれば省略可能です。

    その後pipe関数を使ってLangChainのLCELを実装します。モデルにpipeを設定し、生成された文字列だけをレスポンスに返すためのStringOutputParserを追加します。その後Streamでレスポンスを受けるため、stream()関数へ受け取った文字列を渡しましょう。あとはHonoが提供するstreamText関数にStreamで取得できる文字列をstream.writeでブラウザへ返却するだけです。

    
    app.post('/ai', async (c) => {
      const { AWS_ACCESS_KEY, AWS_SECRET_KEY } = env(c)
      const { message } = await c.req.json<{ message: string }>()
    
      const model = new BedrockChat({
        model: "anthropic.claude-3-sonnet-20240229-v1:0",
        region: "us-east-1",
        credentials: {
          accessKeyId: AWS_ACCESS_KEY,
          secretAccessKey: AWS_SECRET_KEY,
        },
        streaming: true,
      });
      
    
      const response = await model
        .pipe(new StringOutputParser())
        .stream(message)
      return streamText(c, async (stream) => {
        for await (const chunk of response) {
          stream.write(chunk);
        }
      })
    })
    

    おまけ: 生成された文字列の長さを使った従量課金を設定する

    Stripeで従量課金プランとサブスクリプションの設定が済んでいる状態であれば、生成された文字列の長さなどを使って従量課金のメーターをまわすこともできます。チャットを利用中のユーザーと紐づいたStripeのcustomer_idが取得できている状態であれば、POSTのAPIに次のような実装を追加するだけです。

      return streamText(c, async (stream) => {
        for await (const chunk of response) {
          stream.write(chunk);
          stripe.billing.meterEvents.create({
            event_name: 'api_request',
            payload: {
              value: chunk.length.toString(),
              stripe_customer_id: STRIPE_CUSTOMER_ID
            }
          })
        }
      })

    文字生成に応じて請求金額を計算するデモをYouTubeに公開していますので、マネタイズまで興味がある方はぜひご覧ください。

    まとめ

    BedrockとLangChainを組み合わせることで、JavaScriptで簡単に生成AIアプリが作れます。そしてHonoを利用することで、AWS Lambda上ですべての処理を完結させることも可能となり、AWSに詳しくない方でも比較的簡単にアプリのデプロイができるようになります。AWS Amplifyが生成AIをサポートしたとのニュースもありましたので、この辺りはAWSに寄せるか、他のプラットフォームでも動かせる形を優先するかの技術・経営的な判断を行うと良いでしょう。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark