【第4回】ブログのAIチャットボットをストリーミング応答に改修する

#Amazon Bedrock #Amazon Web Services #API #Claude #Node.js #ストリーミング #生成AI
個人開発

以前、Amazon Bedrockを用いたAIチャットボットの実装をしました。しかし、AIの返答が生成し終わるまで画面に何も表示されない状態なので、今回は既存の生成AIツールのように、その場でタイピングしているかのような応答方式(ストリーミング応答)に変更していきたいと思います。

API GatewayのREST APIがストリーミングに対応していないため、今回はバックエンドを Lambda Function URL のレスポンスストリーミング へ作り替えます。全体概要として、ランタイムをPythonからNode.jsへ移し、既存Nginxとの繋ぎ込み、公開APIとしてのコスト対策まで実施します。

1. 既存構成でのストリーミングはできない

1.1. 現状のチャットボットの懸念点

第三回で作ったチャットボットは質問を送ると、しばらく待ったあとに回答が返ってくるので動いてはいます。ただ、使ってみて気になったのが「返答が生成し終わるまで、画面に何も表示されない」という点です。

待っている間、フリーズしているように見えてしまうんですね。。。

ChatGPTやClaudeのように、文字がパラパラと流れてくる感じにしたいので、今回は、この「一括表示」を「その場でタイピングしているような逐次表示(ストリーミング応答)」に作り替えていきます。

1.2. API Gateway がストリーミングを止めていた

まず、なぜ旧構成ではストリーミングできなかったのかを整理しておきます。原因はAPI Gateway(REST API) らしいです。

API Gateway の REST API は、Lambda が返すレスポンスを いったんすべて受け取ってから、まとめてクライアントに返す という動作をします。つまり、Lambda 側でトークンを少しずつ生成しても、API Gateway でせき止められて、結局「全部そろってから一括で返る」形になってしまうのです。

加えて、REST API には統合タイムアウト29秒という上限もあり、長めの生成では途中で切れてしまうリスクもありました。

1.3. Lambda Function URL のレスポンスストリーミング

そこで今回は、API Gateway をチャットの経路から外し、Lambda Function URL のレスポンスストリーミング機能を使う ことにしました。

Function URL とは、API Gateway を経由せずに Lambda を直接 HTTP で呼び出せる URL のことです。これをレスポンスストリーミングモードで使うと、Lambda が生成したトークンを、そばから少しずつクライアントへ流せるようになります。

改修の全体像は次のとおりです。

  • ランタイムを Python から Node.js へ移行する
  • Lambda を Function URL のストリーミング対応版に作り替える
  • 既存の Nginxに繋ぎ込む
  • 公開APIとしてのコスト対策を組み込む

順番に進めていきます。


2. Lambdaの準備

2.1. IAMロールにストリーミング権限を追加する

まず、Lambda が Bedrock をストリーミングで呼べるように、実行ロールに権限を追加します。

旧構成では一括レスポンス用の InvokeModel を使っていましたが、ストリーミングでは InvokeModelWithResponseStream という別のアクションが必要です。実行ロールに、以下のインラインポリシーを追加しました。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "bedrock:InvokeModel",
        "bedrock:InvokeModelWithResponseStream"
      ],
      "Resource": [
        "arn:aws:bedrock:*:*:inference-profile/jp.anthropic.*",
        "arn:aws:bedrock:*::foundation-model/anthropic.*"
      ]
    }
  ]
}

Resource の書き方について、日本から使う場合、モデルIDは jp. から始まる「推論プロファイル」を指定します。この推論プロファイル自体(inference-profile/jp.anthropic.*)と、その先で実際に呼ばれるモデル本体(foundation-model/anthropic.*)の 両方 に権限を与えないと、呼び出し時に権限エラーになります。

2.2. なぜPythonでは不可でNode.jsなのか

実は、Lambda のレスポンスストリーミング(streamifyResponse)は Node.js ランタイム専用の機能 で、Python では使えません。

Pythonでも Bedrock からストリームを受け取ることはできますが、それを Function URL 経由でブラウザまで流し出す部分が、標準の Python ランタイムでは対応していないらしいです。

旧チャットボットは Python で書いていたので、そのままでは今回の方式が使えません。そこで、新しく Node.js の関数を作り直す ことにしました(旧関数はそのまま残しておき、動作確認できてから切り替える形が安全です)。

2.3. ConverseStreamで応答を逐次書き出す

作り直した Node.js のコードがこちらです。ハンドラ全体を awslambda.streamifyResponse で包み、Bedrock の ConverseStream の出力を、生成されるそばから書き出しています。

import {
  BedrockRuntimeClient,
  ConverseStreamCommand,
} from "@aws-sdk/client-bedrock-runtime";

const client = new BedrockRuntimeClient({ region: "ap-northeast-3" });
const MODEL_ID = "jp.anthropic.claude-sonnet-4-6";

export const handler = awslambda.streamifyResponse(
  async (event, responseStream) => {
    const stream = awslambda.HttpResponseStream.from(responseStream, {
      statusCode: 200,
      headers: {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no",
      },
    });

    try {
      const body = JSON.parse(event.body || "{}");
      const messages = body.messages ?? [
        { role: "user", content: [{ text: body.message ?? "" }] },
      ];

      const command = new ConverseStreamCommand({
        modelId: MODEL_ID,
        messages,
        inferenceConfig: { maxTokens: 1000, temperature: 0.7 },
      });

      const response = await client.send(command);

      for await (const chunk of response.stream) {
        const text = chunk.contentBlockDelta?.delta?.text;
        if (text) {
          stream.write(`data: ${JSON.stringify({ text })}\n\n`);
        }
      }
      stream.write("data: [DONE]\n\n");
    } catch (err) {
      console.error(err);
      stream.write(`data: ${JSON.stringify({ error: "生成に失敗しました" })}\n\n`);
    } finally {
      stream.end();
    }
  }
);

ポイントをいくつか補足します。

awslambdaimport 不要のグローバル変数で、Lambda の Node.js ランタイムが実行時に用意してくれます。ローカルのエディタでは「未定義」と警告が出ることがありますが、Lambda 上では正しく動くので気にしなくて大丈夫です。

また、event.body は文字列で届くので JSON.parse が必要です。旧構成とはイベントの形が少いますね。

※ 関数のタイムアウトを 5 分程度に伸ばしておきます。デフォルトの 3 秒のままだと、生成の途中で確実に切れてしまいます。


3. Function URLの作成と動作確認

3.1. RESPONSE_STREAM モードで作成する

作った関数に Function URL を付けます。ここ割と重要なポイントです。

lambda の「関数URL」から新規作成する際、呼び出しモードを必ず RESPONSE_STREAM にする 必要があります。ここが既定の BUFFERED のままだと、コードが正しくてもストリーミングになりません。

認証タイプは、公開ブログのチャットから叩けるように NONE(認証なし)を選びます。誰でも叩ける状態になるので、後述のコスト対策で運用します。

CLI で作る場合は次のとおりです。

aws lambda create-function-url-config \
  --function-name blog-chat-stream \
  --auth-type NONE \
  --invoke-mode RESPONSE_STREAM

作成すると、https://xxxxxxxx.lambda-url.ap-northeast-3.on.aws/ のような URL が発行されます。

3.2. curlでストリーミング動作を確認する

Function URL ができたら、実際にストリーミングされるか、ターミナルの curl で確認します。

curl -N -X POST \
  'https://xxxxxxxx.lambda-url.ap-northeast-3.on.aws/' \
  -H 'Content-Type: application/json' \
  -d '{"message": "こんにちは、自己紹介してください"}'

-N は「バッファリングせずに受信したそばから表示する」オプションです。これを付けないとストリーミングが見えないので、必ず付けてください。

成功すると、data: {"text":"..."} という行が、パラパラと順番に 流れてきます。最後に data: [DONE] が出れば完璧です。

もし一気にまとめて表示される場合は、呼び出しモードが RESPONSE_STREAM になっていない可能性が高いです。


4. 既存環境への繋ぎ込み

4.1. nginx-proxy でリバースプロキシする

Function URL を直接ブラウザから叩くこともできますが、それだと URL が露出し、CORS 対応も必要になります。そこで、すでに動いている Nginx にリバースプロキシさせて、https://自分のドメイン/api/chat という同一オリジンの入口を作ります。

私の環境は nginx-proxy(VIRTUAL_HOST で設定を自動生成するタイプ)だったので、vhost.d/ドメイン名_location という仕組みを使って、次の設定を差し込みました。

location /api/chat {
    proxy_pass https://xxxxxxxx.lambda-url.ap-northeast-3.on.aws/;
    proxy_ssl_server_name on;
    proxy_set_header Host xxxxxxxx.lambda-url.ap-northeast-3.on.aws;

    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
    proxy_read_timeout 300s;
}

ここでの重要なポイントが proxy_buffering off; です。これがないと、Nginx がレスポンスをいったん溜め込んでしまい、ストリーミング応答が出来ません。

また、Function URL 相手には proxy_ssl_server_name on;Host ヘッダの明示が必須です。これがないと接続に失敗します。

4.2. フロントエンドでストリームを1文字ずつ表示する

最後に、ブラウザ側でこのストリームを受け取ります。fetch の ReadableStream で受信し、届いた文字を逐次画面に追記していきます。

async function sendMessage(message, onDelta) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message }),
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const parts = buffer.split("\n\n");
    buffer = parts.pop();

    for (const part of parts) {
      const line = part.replace(/^data: /, "").trim();
      if (!line || line === "[DONE]") continue;
      const json = JSON.parse(line);
      if (json.text) onDelta(json.text); // 画面に追記
    }
  }
}

API_URL を、旧構成の API Gateway ではなく、同一ドメインの /api/chat に向けるます。同一オリジンなので、CORS ERRORも起きないはずです。

これで、ブログのチャットボットでストリーミング応答が可能になりました。


5. 公開APIとしてのコスト対策

5.1. レート制限とBudgetsによる保護

Function URL を NONE(認証なし)で公開するということは、URL を知られれば誰でも叩けるということです。放置すると、いたずらや自動アクセスで Bedrock の課金が膨らむリスクがあります。そこで、いくつか保護を入れました。

まず、Nginx でのレート制限です。IP アドレスごとに一定時間内のリクエスト数を制限します。

limit_req_zone $binary_remote_addr zone=chat:10m rate=60r/m;

そして location /api/chat の中で、このゾーンを適用します。

limit_req zone=chat burst=20 nodelay;

さらに、AWS の Budgets で「月◯◯ドルを超えたらメール通知」という予算アラートを設定しておくと、万一の課金増加にも早く気づけます。認証なしで公開する以上、この保護は入れておくべきだと思います。

今回の改修で、ブログのAIチャットボットをストリーミング応答にすることができました。この回の注意ポイントは次の3つに集約されます。

  • Function URL の呼び出しモードを RESPONSE_STREAM にし忘れる … 既定の BUFFERED のままだとストリーミングになりません。
  • Nginx の proxy_buffering off を忘れる … これがないと Nginx が溜め込んでしまい、逐次表示が崩れます。
  • 推論プロファイルID(jp.)ではなくベースのモデルIDを使ってしまう … オンデマンド呼び出しでは推論プロファイルIDが必要で、間違えると権限・呼び出しエラーになります。

この3点を押さえておけば、あとは比較的スムーズに進められるはずです。同じように「AIチャットの応答をリアルタイム表示にしたい」という方の参考になれば幸いです。

コメント