👻

【TypeScript】trpcでChatGPTのようなstreaming responsesをReactで表示してみる

2024/07/03に公開

目的

OpenAI社のChatGPTやAnthropic社のClaudeのような生成AIによるチャットサービスでは、生成AIモデルが出力したデータをストリーミングで少しずつ受け取って徐々にテキストが表示されるようなUIを採用しています。
一般的なリクエスト/レスポンス形式だとAIによる処理が全て完了するまで待つ必要があり、クライアント側では(処理内容によっては)何十秒もローディングが表示されるだけになってしまうため、ストリーミングで少しずつデータを受け取って表示することはUXとして良いですね。
今後、AIを使ったサービスが増えていくにつれ、こうしたユースケースは増えていくかもしれません。
開発観点でいうと、こうした実装はサーバーサイド側については公式のexampleなどに記載がありますが、クライアント側でstreamingのデータを受け取って表示する実装例は調べてもあまり出てきませんでした。
そのため、今回は個人的に期待しているtrpc + React(Next.js)でOpenAIのstreamiing responsesに対応する実装例を考えてみました。

前提

trpc

Move Fast and Break Nothing.
End-to-end typesafe APIs made easy.
Experience the full power of TypeScript inference to boost productivity
for your full-stack application.

https://trpc.io/

ざっくり説明すると、trpcとはTypeScriptを利用してサーバー側で記述したリクエスト・レスポンスの型定義をクライアント側で再利用できるRPCフレームワークです。

例えば、REST APIではリクエスト・レスポンスのスキーマ定義が存在しないため、クライアント側からのリクエスト内容が間違っていたり、サーバー側からのレスポンス内容が間違っていたりしても処理は継続されてしまうので予期しないエラーが発生する可能性があります。OpenAPIなどを導入してスキーマを定義することはできますが、実際のリクエスト・レスポンスがそのスキーマ定義に合っているかどうかチェックしたりするにはさらに追加でツールを導入したりする必要があります。

また、GraphQLやgRPCではスキーマの定義が仕様に組み込まれていますが、スキーマの定義は独自の言語で行うため、そのための学習コストや手間はかかってしまいますし、実際のコードとスキーマを二重で管理する必要があるため変更の際には両方とも考慮する必要あります。
(もちろん、それぞれメリットもあるので一概には評価できませんが)

一方で、trpcではサーバー側で記述したTypeScriptの型定義がそのままクライアント側に反映されて再利用できるので、TypeScriptさえ知っていれば良く、また別途スキーマを定義する必要もないため変更容易性も高く、非常に高い生産性で開発を行えます。
サーバー側もTypeScriptで実装する必要があるという前提はありますが、近年のフロントエンドの開発ではTypeScriptを使うのは必須といっても過言はないくらい普及しているので、フロントエンドに合わせてバックエンドもTypeScriptで書くというのは良い選択なのではないかと個人的には考えています。

また、生成AIに関するライブラリはPythonだけだと思われがちですが、実はopenai-node/anthropic-sdk-typescript/langchainjsといったように生成AIのライブラリはTypeScriptでも公式がサポートしているのもバックエンドでTypeScriptを採用するメリットの一つですね。

成果物

プロンプトを入力してからボタンをクリックするとChatGPTのように徐々にレスポンスのテキストが表示されるアプリができました!

実装

今回作成したアプリはGitHub上で確認できます
https://github.com/mikan3rd/trpc-openai-stream-sample

trpcアプリのベースを作成

今回はNext.js starter with Prismaをベースに作成します

pnpx create-next-app --example https://github.com/trpc/trpc --example-path examples/next-prisma-starter trpc-prisma-starter

ref: https://trpc.io/docs/example-apps

サーバー側でopenai-nodeでstreamを有効にする

OpenAIのAPIをTypeScriptで扱うための公式ライブラリがあるので追加しておきます
https://github.com/openai/openai-node

ChatGPTのようにプロンプト渡して結果を受け取るにはopenai.chat.completions.create()を使います
streamを有効にするには引数にstream: trueを追加するだけです

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/707892551c509863dc7d5006b219a7696b729401/src/server/functions/openai.ts#L1-L6

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/707892551c509863dc7d5006b219a7696b729401/src/server/functions/openai.ts#L9-L18

APIを利用するためにあらかじめAPI KEYを発行し、支払いの設定をしておく必要があるためご注意ください
ref: https://platform.openai.com/docs/quickstart/step-2-set-up-your-api-key

サーバー側で非同期ジェネレーター関数を定義してstreamを制御する

trpcではasync function*で非同期ジェネレーター関数を定義し、AsyncGeneratorオブジェクトを返すことでstreaming responsesを実現できます
最終的なデータをDBなどに保存したい場合はfullContentの部分を参照すれば良さそうです

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/707892551c509863dc7d5006b219a7696b729401/src/server/functions/openai.ts#L20-L30

routerでは上記の非同期ジェネレーター関数をyield*で呼び出せばOKです
https://github.com/mikan3rd/trpc-openai-stream-sample/blob/707892551c509863dc7d5006b219a7696b729401/src/server/routers/_app.ts#L22-L30

ref: https://trpc.io/docs/client/links/httpBatchStreamLink#generators

そもそもジェネレーターって何?って人も実は多いんじゃないかと思うので参考記事も貼っておきます
(自分もredux-sagaくらいでしか使ったことなかったです)
https://zenn.dev/qnighy/articles/112af47edfda96

クライアント側でhttpBatchStreamLinkに置き換える

クライアント側でstreamを受け取れるようにする耐えにはcreateTRPCNextの引数のlinksunstable_httpBatchStreamLinkを使うようにします

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/707892551c509863dc7d5006b219a7696b729401/src/utils/trpc.ts#L65

ref: https://trpc.io/docs/client/links/httpBatchStreamLink#streaming-mode

unstable_とついていると不安になりますが、変更の可能性があるだけで本番環境でも使用されていて安全だそうです
(experimental_の場合はまだテストなどが不十分っぽい)

https://trpc.io/docs/faq#unstable

クライアント側でデータを表示する

通常のデータフェッチの方法と同じくuseQuery()を使えば、APIを呼んだ際に返り値のdataに随時
データが反映されるので、それをそのままレンダリングするようにすればOKです

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/707892551c509863dc7d5006b219a7696b729401/src/pages/index.tsx#L12-L25

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/431de4780d0c3f8f7494d8265f71cd686c0e55f0/src/pages/index.tsx#L173-L177

ref: https://trpc.io/docs/client/react/useQuery#streaming

ちなみに、@trpc/react-query@tanstack/react-queryをwrapしていて、任意のタイミングでデータフェッチを行いたい場合はenabled: falseにした上でrefetch()を呼んであげる必要があります

また、variables(今回の例だと{ text: inputText }の部分)が変更されるとdataがリセットされてしまうので、再度データフェッチを行うまでdataを保持したい場合はplaceholderData: keepPreviousDataの指定が必要です

以上!

trpcのセットアップさえ済んでいれば、シンプルで少ないコードでChatGPTのようなUIを実装することができました

また、データを保存したい場合はqueryではなくmutationを使うためそちらの実装も試してみましたが、dataがAsyncGeneratorオブジェクトのままになってしまっていたので次のようにfor awaitでハンドリングする必要がありました

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/431de4780d0c3f8f7494d8265f71cd686c0e55f0/src/pages/index.tsx#L27-L41

(以下のIssueで修正できないか相談中)
https://github.com/trpc/trpc/issues/5846

おまけ

折角なのでClaudeやLangChainでも同様の実装をしてみました

Claude

最近評判のAnthropic社のClaudeについても対応してみましたが、APIの仕様が少し違えどほぼ似たような実装になりました

https://github.com/anthropics/anthropic-sdk-typescript

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/431de4780d0c3f8f7494d8265f71cd686c0e55f0/src/server/functions/anthropicAI.ts#L1-L38

こちらの場合も事前にAPI KEYの発行と支払いの設定が必要です

LangChain

LangChainを使えばOpenAIやAnthropicのLLMをより共通的なコードで呼び出すことができました
複数のLLMを使い分けたり比較したりしたい場合はLangChainを使う方が便利そうですね

https://github.com/langchain-ai/langchainjs

https://github.com/mikan3rd/trpc-openai-stream-sample/blob/431de4780d0c3f8f7494d8265f71cd686c0e55f0/src/server/functions/langchain.ts#L1-L49

あとがき

trpcがもっと普及しないかなぁ

Money Forward Developers

Discussion