ChatGPT API + Next.jsで3000ページの静的ページを生成: YAMAP2023振り返り機能開発の反省会
この記事はYAMAPアドベントカレンダー15日目の記事です。
はじめに
2023年12月、YAMAPはベータ機能として「活動記録で振り返る2023」機能を限定したユーザー向けにリリースしました。
機能に込めた思いや開発全体の話はプロダクトマネージャーのトキタクミさんが別記事でまとめてくれているので、そちらをご覧ください。
今回、機能提供にあたり約3000ページをNext.jsで静的生成しました。
静的ページ生成のシステム構成と自分のケアレスミスで開発効率が非常に悪くなってしまったので、その問題点と失敗を踏まえた新たなシステム構成についてまとめました。
同じようにChatGPT APIを使った静的ページ生成を検討している人が同じ失敗を踏まない為の参考になってくれたら幸いです。
機能の紹介
生成した振り返りページはこんな感じです。
この機能は、ChatGPTによりユーザーの活動日記を褒めるテキストを自動生成して、ユーザーごとに異なる角度で2023年のYAMAPでの登山活動を振り返ることができる機能です。
機能の提供方法の検討
今回の振り返り機能を開発するにあたり、どのように機能提供するか悩んでおりました。
候補としては、HTMLメール・Webアプリケーション・静的ページの3つがありました。
HTMLメールの見送り理由
最初はメールで情報をまとめて機能提供する話が出ていましたが、デザイン修正や要件が増えた時に苦しくなるだろうという判断でメールでの提供は見送りました。
情報リーチという意味だとダイレクトで中身を見れるのが魅力でしたが、クロスプラットフォームの対応と検証を考えた時に最後に絶望する予感しかしなかったので、この選択はしなくて良かったなと本当に思っています。
Webアプリケーションの見送り理由
実装コストだけを考えると、ユーザーがページにアクセスした時にChatGPT APIをリクエストして都度テキスト生成するのが一番楽だなと思っていました。
しかし、GPT4モデルを利用することもありChatGPT APIはレスポンスに20秒ほど掛かる場合もあるため、ページを表示するタイミングでChatGPT APIでテキスト生成するのはユーザー体験を考えるとかなり厳しいため、Webアプリケーションによる提供も見送りました。
また1回のAPIリクエストごとに一定の料金コストが発生するので、この方針だと必要以上に料金が発生する可能性があります。
また、ChatGPT APIはレスポンスが途中で止まってしまうこともあり、ページの再現性が担保できないのもネックでした。
静的ページを採用
約3000ページを生成する見込みだったので、ビルド時間を考慮すると地味にキツいなという気持ちがありましたが、インフラ構築の手間や料金コスト、ユーザー体験などを全て加味した結果、静的ページとして機能提供することにしました。
結果として考えると最小限の開発コストでベータ版として提供する観点では、静的ページとして機能提供したのは正解でした。
静的生成のシステム構成
今回の静的ページ生成のシステム構成として、静的ページをビルドする前のデータフェッチのタイミングでChatGPT APIにリクエストをしてテキスト生成をしてページを描画する構成になっていました。
開発中に遭遇した問題と対応
今回のシステム構成はChatGPT APIと相性が良くなく、かなりの開発効率の低下に繋がりました。
また、他にも考慮できていなかった部分が多くあったので、開発中に
実際に遭遇した問題点を列挙していきます。
App Routerで静的生成が2回実行されるバグに遭遇
最初はApp Routerによる静的生成を試していましたが、1ページあたりのビルドプロセスが2回呼ばれる問題に直面しました。システム構成として静的ビルドのタイミングでChatGPT APIをリクエストしているので単純にAPIリクエスト回数が2倍になると料金コストも2倍になってしまうので、かなり厳しい問題でした。
最新のcanaryバージョンでも問題が発生したので、諦めてPages Routerによる静的ビルドに切り替えました。
APIエラーと例外処理
Next.jsは静的ビルドでデータフェッチをするタイミングでAPIでエラーが発生した時に、例外処理を書いていないとNext.jsの静的ビルド自体がエラーとなり、途中で生成したページも全て出力されません。
時間をかけて一定数のページをビルドした後にこの問題にハマり、無駄に時間を浪費しました。
最低限、データフェッチでの例外処理は書くようにしておきましょう。
また、発生したエラーはログに残して再ビルドが必要なページとエラー理由を確認できるようにしておく必要があります。
export const getStaticProps = (async (context) => {
try {
const data = await fetch('...');
return {
props: {
data,
}
}
} catch(error) {
logger.error({userId, message: `データフェッチ中にエラーが発生しました。: ${error}`});
return { notFound: true };
}
}) satisfies GetStaticProps<Props>;
ChatGPT APIのレート制限によるビルド時間の増加
ChatGPT APIにはアカウントの階層と利用するモデルによってAPIのレート制限が存在します。
例えば、tier3でgpt-4-1106-previewを利用する場合には5000RPM(1分あたりのリクエスト数)、30万TPM(1分あたりのトークン数)となっています。
同時リクエスト数は大きな問題にはなりませんが、TPMの方が問題になってきます。
例えば、1ページの生成に1万トークンを消費する場合に1分間で30リクエストまでしかAPIリクエストすることができず、3000ページを生成する場合には最低でも100分(3000/30)のビルド時間がかかります。
生成するテキストによっては、同時リクエスト数はさらに減少するため、必然的にビルド時間が非常に長くなります。
ビルド後にミスが発覚しても再生成が困難
ビルド完了後に実装漏れが見つかりました。
ビルドごとに料金が発生する作りになってしまっていたので、再ビルドができず静的生成された3000ページのHTMLファイルを正規表現で無理やり置換する作業が必要になりました。デグレの危険性もあり、この対応が一番システム構成として失敗したな実感する要因でした。
不完全なテキストが生成される
ChatGPTを利用していると途中でテキストが途切れる現象に遭遇した事があると思います。その現象ははAPIでも同様に発生するため、不完全な状態でテキストが生成される可能性があります。
ページ生成後の検証
不完全なテキストが生成される問題があったので、ビルド後にPlaywrightで全ページのスクリーンショットを作成して目視による検証を実施しました。この段階でテキストが途中で途切れているページが100ページほど発見されました。
目視でのチェックはかなり面倒な作業で、ページ数がさらに膨大になった時に現実的に不可能になってきます。そのためテキストの不完全な生成を自動で検知する仕組みは、ChatGPT APIでシステムを構築する上で非常に重要になります。
理想のシステム構成
以上の失敗を振り返るとページレンダリングのタイミングで同期的にデータ生成をしているのが一番の問題だと分かりました。
次回、改めて開発をする場合は次の構成図のようにデータ生成のパートを完全に別にしてDBに生成データを保持して、レンダリング時に生成済みのデータを参照する仕組みが最適かなと考えています。
この構成にすればChatGPTのAPIリクエストによるレスポンス時間の問題も解消できるので、静的生成をせずに普通のWebアプリケーションとして構築ができます。
DBはインフラ構築の手間と一時的な期間で提供する機能であることを考慮して、SQLiteなどの軽いDBを採用すると良さそうだなと思っています。
Discussion