ねこの人格を持ったAIと会話ができるWebアプリケーションの作成
概要
https://zenn.dev/keitakn/scraps/fb037ce188cd5a でSlackbotを作ったので今度はOpenAIのAPIを使ったWebアプリケーションの開発を実施していく。
作る物は「ねこの人格を持ったAIと会話できるサービス」。
LangChainを使うとLLMでGoogle検索を使った回答を生成したり、Webページ上のドキュメントをロードした上で回答を生成する等が比較的簡単に実施できる事が分かったのでそのあたりもチャレンジしていく。
利用技術(フロントエンド)
Next.js + Vercelで行く。今回はNext.js 13から有効になった app
ディレクトリを使っていく。
バックエンド
Python + FastAPIでLangChainを使って開発を行う。
バックエンドのアプリケーションは https://fly.io でホスティングする。
https://zenn.dev/keitakn/scraps/fb037ce188cd5a で作ったコードをベースにさらに発展させていく。
Pythonはまだあまり慣れていないが、テストコードの作成なども今回は実施していくつもりだ。
JSONを返すエンドポイントを実装
とりあえず https://zenn.dev/keitakn/scraps/fb037ce188cd5a で作ったSlackbotと同じプロジェクト内にLLMのレスポンスを返すエンドポイントを実装。
実装が雑な部分も多いがそれは専用プロジェクトに分離した時点で本格的に作り込んでいく。
対応時のPRを貼っておく。
UI側の仮実装
Tailwind CSSのTemplateなどを参考に実装。現時点ではただ固定値を入れているだけ。
一応レスポンシブになっている。
Shift + Enterでメッセージを送信させるとかそのあたりのアクセシビリティは現時点では考慮していない。
対応時のPRのURLも貼っておく。
Layoutの追加
対応時のPRを貼っておく。
今回初めてNext.jsのappディレクトリを使う訳だが、なるべくServerComponentで作るのが推奨されているようだ。
なのでLayoutの部分をServerComponentとして分離した。
ついでにデザインを色々変更した。(黄色をテーマカラーとした)
Componentの設計はこの時点で全く定まっていない。
下記のような疑問が頭に浮かんでいる状態。
- ServerComponentとClientComponentはディレクトリ分けたほうが良いのか?
- なるべくServerComponentが推奨されているようだが、そもそもどうやってComponentを分ける?
- formからメッセージが送信された際の動作はServerAction使って実装出来るのか?
これらは段階的に解決していく。
ちなみに現時点では以下のような警告が出ている。
これも詳しく調べていないが解消する必要がありそう。
app-index.js:32 Warning: Extra attributes from the server: data-locator-client-url
at html
at InnerLayoutRouter (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:225:11)
at RedirectErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:65:9)
at RedirectBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:72:11)
at NotFoundBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/not-found-boundary.js:40:11)
at LoadingBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:330:11)
at ErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/error-boundary.js:87:11)
at InnerScrollAndFocusHandler (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:139:9)
at ScrollAndFocusHandler (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:211:11)
at RenderFromTemplateContext (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/render-from-template-context.js:15:44)
at OuterLayoutRouter (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:340:11)
at body
at html
at RedirectErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:65:9)
at RedirectBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:72:11)
at NotFoundErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/not-found-boundary.js:33:9)
at NotFoundBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/not-found-boundary.js:40:11)
at ReactDevOverlay (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/react-dev-overlay/internal/ReactDevOverlay.js:66:9)
at HotReload (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:276:11)
at Router (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/app-router.js:92:11)
at ErrorBoundaryHandler (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/error-boundary.js:62:9)
at ErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/error-boundary.js:87:11)
at AppRouter (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/app-router.js:333:13)
at ServerRoot (webpack-internal:///(app-client)/./node_modules/next/dist/client/app-index.js:154:11)
at RSCComponent
at Root (webpack-internal:///(app-client)/./node_modules/next/dist/client/app-index.js:171:11)
あとUI変更する時のデグレが怖い。
ビジュアルリグレッションテストを早めに出来るようにしたほうが良さそう。
Chromaticがビジュアルリグレッションテストを利用出来るのでStorybookの環境構築なども進めていく。
ただ一旦Chatが出来る状態まで実装してしまうのが優先なので次はAPIとの繋込みを実装していく。
この時はServerActionとかは一旦無視して作ろうと思う。
ESLint, Prettierの設定
後回しにしようかとも思ったがコード量が増えてから修正するとそれはそれで大変なので、先にやってしまう事にした。
対応時のPRを貼っておく。
ESLintのルールは りあクト! という本をベースに eslint-plugin-tailwindcss を加えた形に落ち着いた。
PRの中でJestやStorybookのルールが設定してあるが、これは後でどうせ追加するので今のうちにやっておこうという考えから。
ESLintに違反するコードを修正
以下で対応済。
eslint-config-next
が next/image
を使うように警告を出してくるので next/image
を使うようにした。
昔の next/image
は <img />
タグに <span />
タグをラップした形で出力するのでスタイリングが少々やりにくかったのだが今は素直に <img />
タグを出力してくれるのでCSSの調整などは必要なかった。
この過程で https://zenn.dev/link/comments/b3c7779dac836f のコメントの警告も解消された。
Chat周りのComponentのリファクタリングを実施
API呼び出し処理を作る前にリファクタリングを実施した。以下はその時のPR。
ServerComponentとClientComponent(従来のReactComponent)の使い分けに悩んでいたが 公式ドキュメント の以下の記述にある方針に従ってシンプルに考えるのが良さそう。
以下は公式ドキュメントの抜粋。
Moving Client Components to the Leaves
To improve the performance of your application, we recommend moving Client Components to the leaves of your component tree where possible.
For example, you may have a Layout that has static elements (e.g. logo, links, etc) and an interactive search bar that uses state.
Instead of making the whole layout a Client Component, move the interactive logic to a Client Component (e.g. <SearchBar />) and keep your layout as a Server Component. This means you don't have to send all the component Javascript of the layout to the client.
LeavesというのはComponentツリーの末端を示す、上位で 'use client';
を使ってしまうとその配下が全てClientComponentになってしまうのでダウンロードされるJSファイルのサイズが増えてしまうので、インタラクティブな動きが必要な末端のComponentだけをClientComponentに変えるのが良さそう。
13.4で使えるようになった Server Actions を使えばもしかしたらFormの部分もServerComponentに出来るかもだが、さすがにアルファ版なのでまだ利用はやめておこうと思う。
しかし将来的には Server Actions を使った書き方に書き換える可能性がある。
ちなみに余談だが今回ChatのForm部分のComponentは結局ClientComponentとして定義したが、ReactのState等で状態管理をしない非制御Componentとして実装した。
理由としては Server Actions が安定版になったらFormは非制御Componentとして実装する必要があるから。
現時点では制御Componentとしても良いのだが、このFormはそれほど複雑にはならないので非制御Componentと実装しても問題ないと判断したのもある。
実際にAPIへリクエストを行うように改修
メモリ不足への対応
https://fly.io にデプロイしたアプリに対してリクエストを送ったが502エラーが返ってくる。
ログを確認すると以下のようにメモリ不足でサーバーが停止していた。
2023-05-15T07:55:56.305 app[xxxxxxxxxxxxxxxx] nrt [info] [ 13. 1111111] Out of memory: Killed process 518 (gunicorn) total-vm:270936kB, anon-rss:170900kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:536kB oom_score_adj:0
アプリを再起動してみたが状況が改善されない。
flyctl apps restart アプリ名
このアプリは 以前実験で作ったSlackbot 上にAPIのエンドポイントを足した物だ。(https://zenn.dev/link/comments/9b1602cddeca8c を参照)
なので後々は専用のアプリケーションとして独立させる予定だがとりあえずメモリを増やして動くようにした。
fly scale memory 512 -a アプリ名
メモリを512MBまで増やす事でとりあえずAPIサーバーが正常動作するようになった。
APIへのリクエストを実装
PRのURLを貼っておく。
このPRの説明にも書いてあるがLLMからの返答はそれなりに時間がかかる。(PR内の動画を見るとどのくらい応答に時間がかかるか感覚が分かると思う)
ローディングメッセージなどを表示させようかと思ったが今のUIだとローディングメッセージを出すとどうしても不自然な形になってしまう。
その為、バックエンド側でストリーミングで返すようにする等の対応が必要かもしれない。
このあたりを読みながらチャレンジしてみようかな。
サーバー側にストリーミング形式でレスポンスを返すAPIを実装
https://zenn.dev/keitakn/scraps/fb037ce188cd5a で作ったリポジトリはFlaskを利用しているので、専用のリポジトリを作成した。
この新しいリポジトリはサービスがリリースされた後も正式に運用していく。
対応時のPRは下記の通り。
PR内に動画が添付してあるのでリクエストの様子が分かるようになっている。
フロントエンドの実装も以下を参考にすれば問題なく実装出来そう。
しかしストリーミング形式なのでエラーハンドリングが複雑そうだったり、LangChainのpredictメソッドがストリーミング形式に対応していないっぽいので、ここが欠点だなと思った。
ただLLMのレスポンスを返すだけなら良いが、後々LangChainのToolsなどを利用してGoogle検索APIから回答を生成したり、エンドユーザーがアップロードしたPDFを文章化して読み込んだ上で回答を生成させるような事をイメージしていたが、このままだとLangChainが使えないので悩みどころ。
LangChainで履歴の管理も割と簡略化出来たりもするので、ストリーミング形式を諦めるという選択肢も頭に浮かんでいる。
ちなみにこれはServer-Sent Eventsという手法。(通称SSE)
これに関しては以下の記事がとても分かりやすかった。
APIから応答が返ってくるまでローディングメッセージを表示するように変更
色々と調べたがやはりLangChainのpredictメソッドがストリーミング形式に対応していないのは痛い。
LangChainは比較的簡単にLLMのカスタマイズが出来るので、ストリーミングに拘るよりもLangChainの恩恵を受けやすい設計にするという意志決定をした。
という訳でUI側でAPI通信中にローディングメッセージを出したり、通信中は連続で送信出来ないように制御を入れたりした。以下は対応時のPR。
PR内の動画を見るとローディング中の様子が見れる。
そろそろフロントエンドも正式版のリポジトリで開発しようと思う。
初期リリースに必要なissueの作成
一旦必要なissueを洗い出した。
ZenHubを利用する事でアジャイルボードUIで閲覧出来るようにしてある。
ストーリーポイントの見積もりも行っている。
初期リリースではサービス開始の為、最低限必要な機能に絞り実装する。
ログイン機能や会話履歴の保存などは時間がかかるので後回し。
フロントエンド側の正式プロジェクトにプロトタイプのコードを移植
以下は対応時のPR。
PR内にも書いてある事だが、今回以下の変更をしている。
ReactComponentの書き方
今回 React.FC
を使わない形にしてみた。
きっかけは下記の記事を読んだ事。
- https://tech.locaop.jp/entry/2023/03/23/120623
- https://kray.jp/blog/dont-have-to-use-react-fc-and-react-vfc/
- https://zenn.dev/funteractiveinc/articles/quit-react-fc
理由は Generics
が使いにくいという理由もあるので今回は React.FC
を使わない書き方を試してみようと思った程度。
ただ React.FC
の利用が必ずしもBad Practice とは思っていない。
型を明示したほうがTypeScriptのトランスパイルのパフォーマンスが良いので JSX.Element
を明示的に指定している。
ディレクトリ構成について
偶然Next.jsの中の人のTweetを知った。
Next.jsの中の人いわく以下のように分けるのが良いらしいので今回はこの構成を試してみる事にした。
-
src/app/_components/
(汎用的に利用するComponent) -
src/app/◯◯/_components/
(対象ページでしか利用しないComponent)
2023年6月4日 再開
ちょっと仕事で似たような事をやる必要があった事もあり、こちらを一時中断して LGTMeow のCSS Modules 移行を1週間ほどやっていた。
これが昨日終わったので今日から再開。
とりあえず初期リリースに必要な課題は前回全てissue化したので、それを消化していく。
最初はLinterやFormatterの設定を行った。
以下は対応した際のPR。
この手の対応は最初にやっておいたほうがトータルの工数が減るので最初に設定するようにしている。
コードが巨大化した後で直すのは結構キツイ。
基本は https://github.com/keitakn/ai-cat-prototype の設定内容やpackage構成を移植したが prettier-plugin-sort-imports
に関しては @ianvs/prettier-plugin-sort-imports
を利用する事にした。
こちらのほうが設定内容がシンプルだったり side effect imports の順番を変更によって起こる副作用などの考慮が行われているのでより安全に利用出来るのでこちらを利用する事にした。
Storybookの導入
こちらを参考に実施していく。
npx storybook@latest init
npx storybook@latest init
で生成されたファイルはsampleのStoryファイルを除いてコミットする。
次にTailwind CSSを動作させる為の設定を実施していく。
公式で紹介されている以下の手順に従う。
postcss
, autoprefixer
の2つは既に導入済なので @storybook/addon-styling
を導入する。
npm install -D @storybook/addon-styling
.storybook/main.ts
に @storybook/addon-styling
の設定を追加する。
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
// ここから↓を追加
{
name: '@storybook/addon-styling',
options: {
postCss: true,
},
},
// ここまで
],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: 'tag',
},
};
export default config;
次に .storybook/preview.ts
に import '../src/app/globals.css';
を追加する。
以下のように正常に表示されるようになった。
(globals.css
にTailwind CSSの設定以外にcreate-next-appが生成したCSSが入っているので背景が不自然になっているがこれは後で直す)
@storybook/addon-a11y
を追加。
アクセシビリティ対応が義務化される話もあるので、最低限これは導入しておきたい。
自分はアクセシビリティの知識はまだまだなので徐々に LGTMeow やこのサービスでアクセシビリティ対応を進めながら習得していきたい。
以下を参考に viewport
の設定を追加。
これはStorybook上でモバイル端末画面で確認が出来るようになるので個人的には必須の設定。
標準で用意されている INITIAL_VIEWPORTS
を設定。
iPhone6などの古い機種からiPhone 12 Pro Maxあたりまでの機種が用意されている。
主要ComponentのStoryを追加
主要なComponent(src/app/chat/_components/ChatContent/ 内のComponent)にStoryを追加。
API通信の結果をMockに置き換えられるように msw
, msw-storybook-addon
を導入した。
Storybookのデプロイ
以前自分が書いた以下の記事と同じ事を実施する。
Storybookは運用コストが高いという意見もあるが、Chromatic と組み合わせる事でビジュアルリグレッションテストを簡単に実現出来たり、デザイナーとのコミュニケーションにもデプロイしたStorybookは役に立つので自分はStorybooはを書く価値はあると思っている。
以下は対応時のPR。
テストを実行出来る環境を構築(フロントエンド)
Jest自体は前回ESLintを設定した時に導入済み(eslint-plugin-jest
を設定したかったので)だったが設定はまだだったので実施していく。
テスト環境の動作確認の為Componentのテストを1件追加した。
アクセシビリティも考慮しつつComponentのテストをしっかりと書いていきたい。
このあたりの記事を読んでおく。
そして以下が対応時のPR。
CIの設定
CIの設定を忘れていたので GitHubActions を作っていく。
とりあえずLinterに違反しているコードやテストが通っていないコードがマージされないようにする。
CIを後回しにする現場もあるが個人的にはオススメ出来ない。
開発初期の段階でCIを用意したほうが、コードが壊れる事を過度に恐れなくて良くなるので(もちろんテストをちゃんと書いている前提)リファクタリングが捗る。
最初は時間がかかるかもだが、トータルで開発コストを削減出来るし、デプロイ回数も増える傾向がある。
という訳で自分は新規プロジェクトを開始したらなるべく早めにCIの設定をする。
以下は対応時のPR。
API側のコード を fly.io
にデプロイする
https://zenn.dev/link/comments/190a0d70204b21 に書いたがストリーミングだとLangChainのAgentが利用できなくなり、将来的にやりたい事が実現出来なくなるので、普通にJSONでLLMのレスポンスを返すAPIを実装した。
このコードをベースに fly.io
にデプロイしていく。
Slackbotを作った時の手順を参考にする。
Dockerfileの作成と動作確認
Dockerfileを元にBuildを実施する。
docker build -t ai-cat-api .
以下で先程Buildしたイメージが存在する事を確認。
docker images
コンテナを起動する。
docker container run -d -p 5002:5000 -e OPENAI_API_KEY=$OPENAI_API_KEY -e API_CREDENTIAL=$API_CREDENTIAL ai-cat-api
コンテナが正常動作するか確認。
curl -v \
-X POST \
-H "Content-Type: application/json" \
-d '
{
"userId": "user_12345678",
"message": "こんにちは!"
}' \
http://localhost:5002/cats/moko/messages | jq
fly.io
の設定を実施
以下で認証を行う。
flyctl auth login
以下でプロジェクトの初期化を行う。
flyctl launch
対話式のIFになるので以下のように回答。
? Choose an app name (leave blank to generate one): ai-cat-api
? Select Organization: nekochans (nekochans)
? Choose a region for deployment: Tokyo, Japan (nrt)
? Would you like to set up a Postgresql database now? No
? Would you like to set up an Upstash Redis database now? No
? Create .dockerignore from 3 .gitignore files? Yes
基本は前回のSlackbotの時と同じだが今回はアプリをOrganizationに対して作成するので Select Organization
で作成したOrganizationを選んでいる。
ここまででアプリの作成は完了。次はSecretsの登録を行う。
以下でSecretsを登録。
flyctl secrets set -a ai-cat-api OPENAI_API_KEY=実際の値を指定
flyctl secrets set -a ai-cat-api API_CREDENTIAL=実際の値を指定
Slackbotの時にメモリエラーでサーバーが停止してしまったので以下でメモリ割り当てを増やしている。
fly scale memory 512 -a ai-cat-api
ここまで出来たらデプロイを実施する。
flyctl deploy
以上でデプロイまでの手順は完了。
ここまでの手順で作成したPRは下記の通り。
API側のコード でCIの設定
CI用のWorkflowを追加。現時点でPythonのテストは実装されていないがLinter違反のコードが存在しないかだけチェックしている。今後テストコードの追加予定があるので、その時は当然テストの実行もCIに含める。
Vercelにデプロイする準備を行う
バックエンドのAPIを正式版に変更する
もうとっくに切り替えていたつもりだったがAPIの参照先が https://github.com/keitakn/chat-gpt-slack-bot で作成したプロトタイプに向いていた。
正式版のAPI に向けるように変更。
レートリミットの設定
初期リリースだとログイン機能は実装しない予定。
しかしAPIがノーガードだとDDoS攻撃を受けるとOpenAIの利用上限に一瞬に達成してしまう恐れがあるのでIPによるレートリミットの設定を行う。
非常に安価で利用出来る Upstash が開発している @upstash/ratelimit を使って実現した。
非常に簡単にレートリミットが実装出来た。以下はその時のPR。
Vercelへのデプロイ
GitHubリポジトリとVercelを連携してデプロイ。
Vercelはとても簡単に連携が出来る上に自動デプロイはもちろんの事、コミット毎のプレビュー環境URLの発行、プレビュー環境で画面上でレビューコメントを追加出来る機能など非常に開発体験が良い。
以下は対応時のPR。
LLMがユーザー毎に会話履歴を保持するようにする
現状の実装だと、他のユーザーとの会話内容を別のユーザーに対して話してしまう可能性がある。
初期リリースでは履歴の保存などを行う予定はないが、さすがにこれはマズイのでリクエストで受け取った userId
毎に会話履歴を保有するように変更した。
以下はその時のPR。
ただし会話履歴はサーバーのオンメモリ上に保存しているだけなので再起動やデプロイで履歴は消える。
とは言え一旦これで十分。
初期リリース完了後にログイン機能実装時にDBに永続化するようにする。
gpt-3.5-turbo-0613
に変更
LLMのモデルを LangChainで新しい言語モデルが利用出来るようになったので https://openai.com/blog/function-calling-and-other-api-updates で発表された新しい言語モデル gpt-3.5-turbo-0613
に変更。
目玉は Function calling だがこの機能はまだLangChainでは利用出来ない模様。
しかし単純な応答速度も上がっているので、それだけでも十分な恩恵があると判断した。
以下は対応時のPR。
空文字でメッセージが送信出来てしまうので改善
さすがにこのままリリースはまずいので、解消した。
Slackなどのアプリと同じくCommand+ Enter or Control + Enter でメッセージを送信するように変更
よく使われているチャット系のアプリと同じくCommand+ Enter or Control + Enter でメッセージを送信するように変更。
トップページのデザインを追加
まずどんな情報の乗せるかでかなり迷ったが、Tailwind CSS公式が出しているシンプルなTemplateがあったのでそれを参考にした。
メニューの実装を楽に終わらせる為に以下のpackageも追加した。
この2つは共にTailwind CSSとの相性も良い点も良いと思う。
雰囲気は↓な感じ。(アイコンとサービス名が仮なので後で直す)
以下は対応時のPR。
notFound
を使った
/chatのURLを変更、元々 /chat
でChat画面に遷移していたが、後々ねこの種類を増やす予定なので /chat/moko
のようにねこのIDをURLに含めるようにした。
初期リリースだと /chat
で問題ないがURLを後で変更するとリダイレクト処理等をずっと残さないといけないので可能な限りリリース後にURLは変更したくないので今のうちに対応した。
存在しないIDを指定された際に404ページを出したいがAppRouterだとどうやってやるんだろう?と思っていたところ notFound
関数を利用するのが良さそうなので利用した。
以下は対応時のPR。
正式なサービス名を考える
ChatGPTに案を出してもらったりしたが、なかなか思い浮かばない。
もうAI Catでいいかと思ったが以下のサイトで検索したら商標登録されていそうだったので 「AI Meow Cat」とする事にした。
という訳でドメインを購入して正式なドメインでアクセス出来るようにする。
独自ドメインでアクセス出来るようにする
ai-meow-cat.com
が欲しいので探す。
料金面や利便性で有利なCloudflareで購入する事にした。
Vercelと連携して独自ドメインでアクセス出来るようにした。
そんなに難しくはなかったが少しハマりポイント(SSL/TLS 暗号化モードを「フル」に設定しないとリダイレクトループになる)があるのと、今後Cloudflareを使う人が増えると思っているので需要あるかなと思い記事としてまとめた。
404エラーページを作成
src/app/not-found.tsx
を追加する事で404ページのデザインをカスタマイズ出来る。
以下のようなページを追加した。
ねこのイラストは https://www.shigureni.com から拝借した。
利用規約 を見る限りAI Meow Catでの利用も問題なさそう。
以下は対応時のPR。
500エラーページを作成
AppRouterだと error.js(error.tsx) を参考を配置すれば良いので error.tsx
を作成。
404ページと同じくねこのイラストは https://www.shigureni.com から拝借した。
(キーボードを占領してる姿が何とも言えないかわいさw)
再度Streamingに挑戦
やはりユーザーから見るとLLMからの応答はどうしても遅く感じるので、Streamingでレスポンスを返す事にした。
https://zenn.dev/link/comments/190a0d70204b21 でLangChainはStreamingに未対応と書いてあるがこれは誤りで単に predict
メソッドがStreamingに未対応なだけで run
メソッドを使えば対応する事が出来た。
しかし大きな課題がある。
今の実装だとOpenAI APIでエラーが発生した時に200 OKが返ってきてしまう。
これはPythonの仕様で子スレッドで発生した例外を親スレッドで補足出来ない事が原因。
書き方を工夫するか、LangChainを使わないで実装する等の何らかの工夫が必要。
フロントエンド側をStreamingで表示させるように対応
以下のPR内に動画が貼ってあるが、Streamingで動作している事が確認出来る。
しかし課題も多い。
まずmsw がSSE(Server-Sent Events)に未対応なので対応するまでテストをスキップするようにした。
この部分のテストが動かないのは結構痛いので、何とかしたいところ。
ちなみに https://github.com/mswjs/msw/issues/156#issuecomment-1050789351 に書いてある通りにやってみたが動作しなかった。
元々公式が非対応でさらに自動生成された mockServiceWorker.js
を上書きする必要があるので、別の手段を検討したい。
しかしともかくStreamingで動作するようにはなった。
API側でエラー時もStreamingで返していたが、エラー時は素直にJSONを返したほうがフロント側で扱いやすい。
また、Streamingが途中で止まった場合の対応もバックエンド側で実施する必要がありそう。
あとフロント側のStreamからデータを取り出している部分だがもう少し整理した書き方が出来ないかも検討中。
API側でエラー時もStreamingで返していたが、エラー時は素直にJSONを返したほうがフロント側で扱いやすい。
これはStreamingの途中でエラーが起きた場合、HTTPステータス 200でエラー内容を返すしかないので通常のJSONレスポンスが返ってくるパターンを追加するとかえって複雑になるのでやめた。
LangChainを辞めた
理由は以下の通り。
- バージョンアップが激しく使い方がコロコロ変わるのがキツイ
- このサービスで実現する機能(実現予定の機能も含む)は素のOpenAIライブラリで実現出来る
- LangChainとStreamingを組み合わせた実装方法に関する情報ほとんど見つからないので今後トラブルが起きた時に解決不能になってしまう可能性がある
以下はLangChainを辞めた時のPR。
会話履歴の引き継ぎとか面倒かなと思ったが以外と何とかなった。
やっぱり新しい事を学ぶ時はいきなり抽象化された便利な物を使うのではなく、シンプルな手段で実装出来る物から触って徐々に基礎を身に着けながら高度な物に手を出していくのが良いなと改めて思った。
API側のログを実用的な形に変更した
詳しい内容に関しては以下を参照。
これで必要な情報は全て出しているし、エラー時のスタックトレースも出るし、レスポンスHeaderに ai-meow-cat-request-id
というuniqueIDも追加したのでログの検索も出来る、Sentryに飛ばしておけば最低限の調査も出来るようになった。
Pythonのログ事情が分からずに調査に結構苦戦した標準のLoggerが割と奥が深いのと、サードパーティのpackageもいくつか見てみたが決定版的なモノがある訳ではなさそうだったので標準の機能を利用する事にした。
PyCharmの設定メモ
全てのPythonファイルを src
に移動したので Settings > Project: [プロジェクト名] > Project Structure を選択。
src
ディレクトリを右クリックして Sources
をクリックする。
こうしておかないとプロジェクト内の自作packageから同じプロジェクト内の別自作packageをimportした時にPyCharmがパスを解決出来ずにエラー表記になってしまう。(あくまでもPyCharmの表示がエラーになるだけで正常に実行は可能)
API側のバリデーションを実装
しばらく別件の開発をやっていたが今日から再開。
API側に不正な値が送信された際は以下のようなレスポンスを返すように変更。
< HTTP/1.1 422 Unprocessable Entity
< date: Sun, 27 Aug 2023 14:31:27 GMT
< server: uvicorn
< content-length: 384
< content-type: application/json
<
{ [384 bytes data]
100 496 100 384 100 112 38937 11356 --:--:-- --:--:-- --:--:-- 70857
* Connection #0 to host 0.0.0.0 left intact
{
"type": "UNPROCESSABLE_ENTITY",
"title": "validation Error.",
"invalidParams": [
{
"name": "userId",
"reason": "Value error, 'user_12345678' is not in UUID format"
},
{
"name": "message",
"reason": "Value error, message must be at least 2 character and no more than 5,000 characters"
},
{
"name": "conversationId",
"reason": "Value error, '88bdab85-fa77-4676-9e14-c2dcf71900f2--' is not in UUID format"
}
]
}
このエラーフォーマットは RFC7807
を参考にしている。
ちなみに最近自分はこのフォーマットを採用する事が多いのだが、これを知るきっかけは下記の記事だった。
以下は対応時のPR
会話履歴をPlanetScaleのDBに保存
今だとデプロイをする度に会話履歴が削除されてしまう。
なのでDBに保存する事にする。安価で利用できる
最近は PostgreSQL
のほうが優勢なので PostgreSQL
を利用するのも良いかなと思ったが今回は安価で開発体験の良い PlanetScale
を使う。
※ このプロダクトはただでさえ初挑戦の技術が多いのでRDBは慣れた物を使いたいという事情もあり。
テーブル構造は以下の通り。
CREATE TABLE `guest_users_conversation_histories` (
`id` BIGINT UNSIGNED AUTO_INCREMENT,
`conversation_id` CHAR(36) NOT NULL,
`cat_id` varchar(255) NOT NULL,
`user_id` CHAR(36) NOT NULL,
`user_message` LONGTEXT NOT NULL,
`ai_message` LONGTEXT NOT NULL,
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
KEY `idx_guest_users_conversation_histories_01` (`conversation_id`),
KEY `idx_guest_users_conversation_histories_02` (`cat_id`),
KEY `idx_guest_users_conversation_histories_03` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;
テーブル名を guest_users_conversation_histories
としたのは後々ログイン機能を実装予定でログインユーザーの保存領域と分けたかったから。
cat_id
はねこの名前が入る、今はもこちゃんしかいないが、後々ねこを増やす予定なので、これも保存しておく。
試しに以下のようなデータを登録してみたが、無事に登録出来たので一旦この構造で進める。
INSERT INTO `guest_users_conversation_histories`
(conversation_id, cat_id, user_id, user_message, ai_message) VALUES('b57af258-e1c8-45af-8993-447a451ad52a', 'moko', '1926e98f-3200-40a9-a7f6-291e2c7f58d9', 'Hello cat!', 'Hello user!');
SQLAlchemyをインストール
WEB+DB PRESS Vol.136 でも特集が組まれていたが SQLAlchemy
が人気らしい。非同期IOにも対応しているので特に問題はなさそう。
poetry add sqlalchemy
でpackageを追加する。
SQLAlchemy
は非同期IOに対応しているが、PlanetScale
は接続にTSLを必須としている為、非同期IOとTSL接続の両方を満たす方法が分からず一旦以下のPlanetScale公式ブログで紹介されていた aiomysql
を利用する事にした。
SQLAlchemy
を使う方法に関しては後で再チャレンジしてみる予定だが今は機能の完成を優先させる。
その時のPRは下記の通り。
API側のステージング環境を構築
さすがに本番用だけだと色々やりにくいので、ステージング用のAPIを用意した。
手順に関しては https://zenn.dev/link/comments/5f23ac7869d1d2 に書いてある通りで可能だった。
main
にマージされたタイミングでステージングにデプロイして、本番リリースに関してはGitHub上でリリースページが公開されたタイミングでデプロイされるように設定した。
久しぶりの更新
ここにコメントするのは久しぶりだが既にメイン機能の開発はかなり前に終えており、AIエージェントもツールの利用等が出来るようになっている。
LLMが利用可能なツールは天気を取得したり、現在の時刻を取得する単純な物だが複数のツールを組み合わせる事で高度なタスクもこなせるようになっている。
今後はどう進化させるか方向性を模索しているが、別のねこの人格を増やしてそれぞれ何かに特化させた機能を追加していくと思う。
利用規約、プライバシーポリシー、外部送信ポリシーの追加
ここ1年、機能追加等の開発は継続していたが利用規約、プライバシーポリシー、外部送信ポリシーがなかったので正式に外部公開出来ずにいた。
重い腰を上げてそれらを追加した。
これでサービスとして公開出来るようになった。
sitemap.xml, robots.txt を追加
最低限のSEO対策。
これでサービスを公開出来る準備が整った。
今後も機能改善を続けていくがこのスクラップはクローズとする。