【TypeScript】trpcでChatGPTのようなstreaming responsesをReactで表示してみる
目的
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.
ざっくり説明すると、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上で確認できます
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で扱うための公式ライブラリがあるので追加しておきます
ChatGPTのようにプロンプト渡して結果を受け取るにはopenai.chat.completions.create()
を使います
streamを有効にするには引数にstream: true
を追加するだけです
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
の部分を参照すれば良さそうです
routerでは上記の非同期ジェネレーター関数をyield*
で呼び出せばOKです
ref: https://trpc.io/docs/client/links/httpBatchStreamLink#generators
そもそもジェネレーターって何?って人も実は多いんじゃないかと思うので参考記事も貼っておきます
(自分もredux-sagaくらいでしか使ったことなかったです)
クライアント側でhttpBatchStreamLinkに置き換える
クライアント側でstreamを受け取れるようにする耐えにはcreateTRPCNext
の引数のlinks
でunstable_httpBatchStreamLink
を使うようにします
ref: https://trpc.io/docs/client/links/httpBatchStreamLink#streaming-mode
unstable_
とついていると不安になりますが、変更の可能性があるだけで本番環境でも使用されていて安全だそうです
(experimental_
の場合はまだテストなどが不十分っぽい)
クライアント側でデータを表示する
通常のデータフェッチの方法と同じくuseQuery()
を使えば、APIを呼んだ際に返り値のdata
に随時
データが反映されるので、それをそのままレンダリングするようにすればOKです
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
でハンドリングする必要がありました
(以下のIssueで修正できないか相談中)
おまけ
折角なのでClaudeやLangChainでも同様の実装をしてみました
Claude
最近評判のAnthropic社のClaudeについても対応してみましたが、APIの仕様が少し違えどほぼ似たような実装になりました
こちらの場合も事前にAPI KEYの発行と支払いの設定が必要です
LangChain
LangChainを使えばOpenAIやAnthropicのLLMをより共通的なコードで呼び出すことができました
複数のLLMを使い分けたり比較したりしたい場合はLangChainを使う方が便利そうですね
あとがき
trpcがもっと普及しないかなぁ
Discussion