
はじめに
Webアプリケーションのパフォーマンスを改善するには、計測結果から課題を見つけ、修正し、その効果を再び計測する作業を繰り返します。 この一連の流れをAIに任せられないかと考え、社内でパフォーマンス最適化のためのエージェントループを作成しました。
この記事では作成したエージェントループの仕組みと、Vite+Reactで構築されているWebアプリケーションへの適用例を紹介します。
エージェントループとは
エージェントループとは、AIが目標に向けて「次の行動を決める」「ツールを使って実行する」「結果を確認する」という流れを繰り返す仕組みです。
Webアプリケーションのパフォーマンス最適化も、計測結果から課題を見つけ、修正と再計測を繰り返すため、この仕組みと相性がよいと考えました。
また、エージェントループを構築することをループエンジニアリングと呼びます。詳細は、IBMの解説記事を参考にしてください。
ループエンジニアリング
まず、Webアプリケーションのパフォーマンス最適化に必要な手順を整理し、計測・修正・再計測を繰り返す流れを以下の図にまとめました。

このループを構築するために各工程を細かくSKILLに分解しました。

| スキル | 概要 | 使用ツール、ライブラリ |
|---|---|---|
Vite React Performance Loop | ループを円滑に進めるためのスキル(レポートのフォーマット、終了条件など) | uv、python |
Vite React UseCase Generator | 対象アプリのユースケースを特定する | Playwright MCP |
Vite React Performance Script Generator | パフォーマンスを計測するe2eテストを生成する | Playwright、Web-Vitals、MSW |
Vite React Performance Optimizer | ナレッジを参照、更新しながらソースコードを1ファイルずつ最適化する | web検索ツール、uv、python |
統一された環境でツールを使用できるようにするために、実行環境を開発コンテナーで作成しました。 これにより、リポジトリをクローンして開発コンテナーを開くだけで利用できるようになっています。
実践
OSSのサンプルリポジトリや社内のプロダクトを対象にして、今回作成したエージェントループによる最適化を適用しました。
OSSを対象にした実験
この実験では、同じOSSに複数の最適化手法を適用し、手法ごとの改善効果を比較しました。 各手法の適用前後で同じ操作を行うe2eテストを実行し、JS実行時間やメモリ使用量などのパフォーマンス指標を計測しました。
最適化の対象にしたOSSは以下の3つです。
エージェントループによる最適化の効果を検証するため、以下の4つの手法を比較しました。
AIを使う手法では、いずれもベースLLMにGPT-6.1 Solを使用し、Reasoning Effortは「軽」に設定しました。
| 最適方法 | 内容 |
|---|---|
| Codex | デフォルト状態のCodexで最適化を指示 |
| エージェントループ | 今回作成したエージェントループによる最適化 |
| React Compiler | ビルド設定のみ手動で変更してビルド |
| エージェントループ+React Compiler | エージェントループの成果物のビルド設定を手動で変更してビルド |
Codexには以下のプロンプトを送信してタスクを開始させました。
{アプリのパス}のパフォーマンスを改善してください。
条件:
- 外観は変えないこと
- リント、ビルドが通ることエージェントループには以下のプロンプトを送信してタスクを開始させました。
$Vite React Performance Loop
{アプリのパス}のパフォーマンス改善ループを回してください。
レポートは日本語でまとめてください。各アプリのE2Eテストとして、以下のテストケースを事前に用意しました。
各テストケースを10回ずつ実行し、各手法の計測結果の中央値を使ってパフォーマンスを比較します。
| アプリ | テストケース |
|---|---|
| bulletproof-react |
|
| coreui-free-react-admin-template |
|
| shadcn-admin |
|
実験結果
表の削減率は各指標について、全テストケースの改善前後の比率を幾何平均して算出しました。
まず、テストケースごとに10回の計測の中央値から改善率を求めます。
改善率(%)=(改善前の中央値 − 改善後の中央値)÷ 改善前の中央値 × 100
次に、改善率を以下の比率に変換し、全テストケースの比率の幾何平均を求めます。
改善後と改善前の比率 = 1 − 改善率(%)÷ 100 = 改善後の中央値 ÷ 改善前の中央値
テストケース数をn、各テストケースの比率をr₁、r₂、…、rₙとすると、幾何平均Gは次の式で求めます。
G = (r₁ × r₂ × … × rₙ)1/n
これは全テストケースの比率を掛け合わせ、そのn乗根を取る計算です。 Gは各テストケースを同じ重みで扱い、改善後の値が改善前の何倍になったかを1つの比率にまとめたものです。
この比率を削減率に変換したものが表の値です。
表の削減率(%)=(1 − G)× 100
主要なパフォーマンス指標の結果を以下に示します。
| 最適化手法 | JS実行時間の削減率(%) | タスク実行時間の削減率(%) | INPの削減率(%) | JSヒープ・サンプルピークの削減率(%) |
|---|---|---|---|---|
| Codex | -7.99 | -5.17 | 1.20 | 6.67 |
| エージェントループ | 9.63 | 3.03 | 5.33 | 3.43 |
| React Compiler | 13.15 | 6.99 | 3.62 | 1.03 |
| エージェントループ+React Compiler | 20.31 | 9.94 | 9.86 | 4.88 |
とくに注目したいのはJS実行時間の削減率で、エージェントループ+React Compilerでは 20.31% となり、React Compiler単体の 13.15% を上回りました。
エージェントループが行った最適化には、メモ化に加えロジックを並列処理に変更するものもありました。 こうした処理の進め方の見直しとReact Compilerを組み合わせたことで、今回の比較ではエージェントループ+React Compilerの手法がJS実行時間をもっとも削減できました。
メモ化に加えて、処理の進め方まで見直せる点がエージェントループの強みといえます。
エージェントループによる最適化の抜粋として、処理を並列化した変更の差分を以下に示します。
diff
+ // bulletproof-reactで並列化された箇所
--- before/src/app/routes/app/discussions/discussion.tsx
+++ after/src/app/routes/app/discussions/discussion.tsx
@@ -23,7 +23,7 @@
const promises = [
queryClient.getQueryData(discussionQuery.queryKey) ??
- (await queryClient.fetchQuery(discussionQuery)),
+ queryClient.fetchQuery(discussionQuery),
queryClient.getQueryData(commentsQuery.queryKey) ??
- (await queryClient.fetchInfiniteQuery(commentsQuery)),
+ queryClient.fetchInfiniteQuery(commentsQuery),
] as const;
const [discussion, comments] = await Promise.all(promises);変更前はキャッシュがない場合、配列内のawaitでディスカッションの取得完了を待ってからコメントの取得を始めていました。後ろにPromise.allがあっても、この書き方では取得処理が順番に進みます。 変更後は配列内のawaitを外し、両方の取得を開始してからPromise.allで完了を待つようになりました。これにより、2つの取得処理を並行して進められます。
このほか、大きな改善が見られたテストケースについて、各手法のJS実行時間を以下のグラフに示します。


これらのテストケースでは、エージェントループによる最適化でJS実行時間の改善が見られました。
一方、CodexではJS実行時間とタスク実行時間が増加しました。 しかし、JSヒープ・サンプルピークは6.67%削減され、今回比較した手法の中で最大でした。
このように指標によって結果が分かれており、エージェントループがすべての指標で優れていたわけではありません。 また、E2Eテストの計測値は実行時のCPUやメモリの使用状況にも影響されるため、実行環境によって結果が変わる可能性があります。
各手法の特徴
各手法によるコードの追加・削除行数は以下の通りです。
| アプリ | エージェントループ | Codex | ||||
|---|---|---|---|---|---|---|
| 変更のあったファイル | 追加された行 | 削除された行 | 変更のあったファイル | 追加された行 | 削除された行 | |
| bulletproof-react | 16 | 166 | 135 | 7 | 214 | 90 |
| coreui-free-react-admin-template | 15 | 96 | 55 | 7 | 47 | 34 |
| shadcn-admin | 27 | 481 | 444 | 4 | 7433 | 60 |
表に示すように、エージェントループでは変更のあったファイル数が多くなりました。 Vite React Performance Optimizerスキルで全ソースファイルの調査とログの出力を指示したことで、修正対象を広く見つけられた可能性があります。
Codexのみを使った最適化
最適化にかかる時間が短く、低コストで実施できる点が強みでした。
ほとんどの指標では他の手法ほど改善しませんでしたが、JSヒープ・サンプルピークではもっとも高い削減率を記録しました。
エージェントループによる最適化
1つのアプリの最適化に40〜50分ほどかかり、コストが高くなる傾向がありました。 一方で、利用可能なツールを駆使し、パフォーマンス指標やレポートなどの根拠に基づいて最適化を進められる点が強みでした。
React Compilerによる最適化
導入が簡単で、メモ化も自動で行われる点が強みでした。
ただし、導入後に一部のe2eテストが失敗するようになりました。
具体的にはcoreui-free-react-admin-templateのモーダル画面のテストケースです。
jsx
<CCardBody>
<p className="text-body-secondary small">
Toggle a working modal demo by clicking the button below. It will slide down and fade
in from the top of the page.
</p>
<DocsExample href="components/modal#live-demo">{LiveDemo()}</DocsExample>
</CCardBody>こちらのコードのようにコンポーネント(LiveDemo)を関数として直接呼び出すと、コンポーネント内のuseStateは親コンポーネントのHooksとして扱われます。 この状態でReact CompilerによってJSXがキャッシュされると、親コンポーネントの再描画時にもキャッシュ済みのJSXが再利用され、状態の変更がモーダルのvisibleに反映されなくなります。 そのため、ボタンをクリックしてもモーダルが開かなくなりました。
このようにReact Compilerのみの最適化では不十分なケースがありました。
エージェントループ+React Compiler
エージェントループによるロジックの見直しや広範囲におよぶ最適化と、React Compilerによる自動メモ化を組み合わせられる点が強みでした。
今回比較した手法の中では、この手法でもっとも大きなパフォーマンスの改善が見られました。 また、React Compilerのみを使った場合に失敗していたテストケースも、エージェントループと併用した場合には成功しました。
この結果には、自動メモ化に加えて、ロジックの最適化や処理の流れを考慮したメモ化を行ったことが寄与したと考えられます。
今回の実験でこの手法の効果を確認できたため、手動で行っていたReact Compilerの導入手順をスキル化し、エージェントループに組み込みました。
社内プロダクトへの適用
社内で開発中のチャットアプリと、外部の案件で開発しているプロダクトにも適用しました。
とくに大きな改善が見られたのは、チャットアプリのチャット操作に関するテストケースで、JS実行時間を 80% 近く短縮できました。
diff
@@ -5,4 +5,3 @@ import type {
} from "../../../../../types";
-import { CodeblockViewer } from "../viewer/CodeblockViewer";
-import { MarkdownViewer } from "../viewer/MarkdownViewer";
+import { MessageHistoryItem, type TimelineMessage } from "./MessageHistoryItem";
@@ -14,12 +13,2 @@ interface MessageHistoryListProps {
-type TimelineMessage =
- | {
- type: "qroobi_reflection";
- message: QroobiReflectionMessage;
- }
- | {
- type: "user";
- message: UserMessage;
- };
-
export const MessageHistoryList = ({
@@ -33,2 +22,3 @@ export const MessageHistoryList = ({
message,
+ timestamp: new Date(message.timestamp).getTime(),
})),
@@ -37,8 +27,5 @@ export const MessageHistoryList = ({
message,
+ timestamp: new Date(message.timestamp).getTime(),
})),
- ].sort(
- (a, b) =>
- new Date(a.message.timestamp).getTime() -
- new Date(b.message.timestamp).getTime(),
- );
+ ].sort((a, b) => a.timestamp - b.timestamp);
@@ -46,34 +33,8 @@ export const MessageHistoryList = ({
<Stack gap="lg">
- {timelineMessages.map((timelineMessage) => {
- const isUserMessage = timelineMessage.type === "user";
- return (
- <Box
+ {timelineMessages.map((timelineMessage) => (
+ <MessageHistoryItem
key={`${timelineMessage.type}-${timelineMessage.message.timestamp}`}
- ml={isUserMessage ? "auto" : undefined}
- maw={isUserMessage ? "82%" : "100%"}
- w={isUserMessage ? "fit-content" : "100%"}
- px={isUserMessage ? "sm" : undefined}
- bg={isUserMessage ? "secondary.0" : undefined}
- style={{
- border: isUserMessage
- ? "1px solid var(--mantine-color-secondary-2)"
- : undefined,
- borderRadius: isUserMessage ? 8 : undefined,
- }}
- >
- {timelineMessage.type === "qroobi_reflection" && (
- <Stack gap="sm" mb="sm">
- {timelineMessage.message.source_code.map((sourceCode) => (
- <CodeblockViewer
- key={sourceCode.url}
- code={sourceCode.code}
- link={sourceCode.url}
+ {...timelineMessage}
/>
))}
- </Stack>
- )}
- <MarkdownViewer content={timelineMessage.message.message} />
- </Box>
- );
- })}
{isPendingMessage && (diff
@@ -7,2 +7,3 @@ import {
} from "@mantine/core";
+import { memo, useMemo } from "react";
import { MarkdownHooks } from "react-markdown";
@@ -38,5 +39,11 @@ const countRenderedCodeLines = (code: string): number => {
-export const CodeblockViewer = ({ code, link }: CodeblockViewerProps) => {
+export const CodeblockViewer = memo(function CodeblockViewer({
+ code,
+ link,
+}: CodeblockViewerProps) {
const currentTheme = useComputedColorScheme();
- const codeMinHeight = countRenderedCodeLines(code) * 18 + 20;
+ const codeMinHeight = useMemo(
+ () => countRenderedCodeLines(code) * 18 + 20,
+ [code],
+ );diff
@@ -1 +1,2 @@
+import { memo } from "react";
import Markdown from "react-markdown";
@@ -7,4 +8,6 @@ interface MarkdownViewerProps {
-export const MarkdownViewer = ({ content }: MarkdownViewerProps) => {
+export const MarkdownViewer = memo(function MarkdownViewer({
+ content,
+}: MarkdownViewerProps) {
return <Markdown remarkPlugins={[remarkBreaks]}>{content}</Markdown>;
-};
+});これらの差分に示すように、マークダウンの描画など処理負荷の高いライブラリを利用するコンポーネントをメモ化し、不要な再描画を抑えたことがパフォーマンスの改善につながったと考えられます。


外部案件で開発しているプロダクトについては、ソースコードの詳細は紹介できませんが、特定のテストケースでJS実行時間を約 20% 短縮できました。
案件の特性上、E2Eテスト環境の構築が難しいアプリケーションでしたが、エージェントループ内でスキルやツールを活用して環境構築の課題を解決し、パフォーマンスの計測と改善ができるようになりました。

わかったこと・課題
わかったこと
今回の実験では計測と修正を繰り返すエージェントループによって、OSSと実際のプロダクトの両方でパフォーマンスの改善を確認できました。
とくに社内のチャットアプリでは、描画負荷の高いコンポーネントの不要な再描画を抑えることで、特定のテストケースのJS実行時間を 80% 近く短縮できました。 改善の余地がある処理を見つけ、計測結果をもとに修正を進める方法として、エージェントループは有効だと考えられます。
また、エージェントループとReact Compilerを組み合わせることで、自動メモ化に加え、データ取得の並列化など処理の流れも見直せました。 OSSを対象にした比較では、この組み合わせがJS実行時間、タスク実行時間、INPの3指標でもっとも高い削減率を記録しました。 自動メモ化とソースコードの見直しを組み合わせることで、改善の幅を広げられることがわかりました。
一方でもっとも改善する手法は指標によって異なりました。 JSヒープ・サンプルピークではCodex単体の削減率が最大だったことから、最適化の効果を判断するには目的に応じて複数の指標を確認する必要があります。
課題
1つ目の課題は実行時間とコストの削減です。
今回のエージェントループでは、1つのアプリの最適化に40〜50分ほどかかりました。 全ソースファイルを調査する方法は修正対象を広く探せる一方で、調査範囲の絞り込みや改善が小さくなった時点で終了する条件には検討の余地があります。
2つ目の課題として、計測結果の再現性とテストケースの網羅性を高める必要があります。
今回は各テストケースを10回計測し、中央値を使って比較しましたが、実行時の計算リソースの使用状況による影響は残ります。 また、特定の操作で大きな改善が得られても、アプリ全体で同じ効果が得られるとは限りません。 計測条件をそろえるとともに、実際の利用状況に合ったテストケースを用意することが必要です。
さらに、最適化後も機能や外観が保たれているかを検証する必要があります。 React Compilerの導入後にモーダルが開かなくなった例からも、パフォーマンス指標だけでは変更の妥当性を判断できないことがわかります。 今回、エージェントループとの併用で該当テストは成功しましたが、他の画面や操作でも同様に問題を防げるかは、引き続き確認が必要です。
まとめ・展望
Webアプリケーションのパフォーマンス最適化エージェントループを構築し、OSSと実際のプロダクトで改善効果を確認しました。 React Compilerによる自動メモ化と組み合わせることで、処理の流れの見直しも含めた最適化を進められました。
今回作成したエージェントループの強みは、普段の開発ワークフローに組み込みやすいことです。 各工程をスキルとして整理し、開発コンテナーで実行環境をそろえているため、Codexが使える状態であれば、リポジトリをクローンして開発コンテナーを開くだけで利用できます。
また、最適化の過程でナレッジを参照・更新する仕組みがあるため、使うほどに知見を蓄積できる点も強みです。 個々の改善で得られた知見を次の最適化で参照できるため、一度の成果をその場限りにせず、継続して活用できます。 複数のプロダクトで利用を重ねることで、チームで共有できる最適化のナレッジを育てていけると考えています。
今後は、実行時間とコスト、計測の再現性といった課題に取り組みながら、活用事例や利用方法を社内で共有し、日常の開発ワークフローに定着するよう活動していきたいと考えています。
蓄積したナレッジをスキルや判断基準の改善にも反映し、利用を重ねるほど効果的に最適化を進められる仕組みを目指します。
