チャット1往復の時間を8分の1にするまでにやったこと──Acompanyセキュアチャット速度改善の記録
公開日:2026.07.29
はじめに
この記事は、自社サービスの「遅さ」と向き合い、ユーザーの体感速度を主要な LLM サービスと同じくらいまで近づけるために何をやったか の記録です。Web アプリのバックエンドを触ったことがある人なら追えるよう、固有の専門用語は初出でひとこと補足します。
本記事は二部構成で公開しており後半部分の記事です。
はじめまして、Acompany Confidential AI Suite 事業開発部でAcompany セキュアチャット(以下、セキュアチャット)開発やプラットフォームを開発しているイナミです。
今回は二部構成の後半をお送りします。前半はこちらをご覧ください。
私たちは日頃セキュアチャットという、プライバシー保護に特化した LLM プロキシサービスを開発しています。
本記事の目的である「セキュアチャットを主要な LLM サービスの Web サイト水準に速く」するためにやったことは大きく 4 ステップに分かれます。
- そもそも「どこが遅いか」を計測できる状態をつくる(可観測性の確保)
- 自分たちのサーバーにわざと負荷をかけ、ボトルネックを観察する(負荷試験)
- 重さの原因を特定して潰す(改善)
- 改善が効いたかを負荷試験で検証する(効果測定)
前半記事ではステップ 1・2 を扱いました。後半となる本記事はステップ 3・4 の「潰して、確かめる」 工程を紹介します。
ステップ 3:原因を特定して潰す——ボトルネックは 5 つあった
ステップ 1・2 については、まず前半記事をご覧ください。
前半のおさらいを一言でいうと、負荷試験から 2 種類の遅さ が見えていました。
- 負荷ゼロでも遅い:たった 1 人で使ってもチャット 1 回に長い時間がかかる。つまり大きな要因は「混雑」ではなく、1 回あたりの処理そのものの重さ
- 混んだときに遅くなる:同時アクセスが増えると処理が「順番待ち」の列に並び、待ち時間が雪だるま式に伸びる
ここで効いてくるのが、この 2 つの切り分け です。「負荷ゼロでも遅い」なら、サーバーを増やしても速くはなりません。台数を増やせば同時に捌ける数は伸びますが、1 回の処理そのものは軽くなりません。そこでまず、1 件あたりの重さ(需要側)を削る ことから着手しました。そのうえで負荷をかけ直すと、今度は 混雑による詰まり(供給側) が見えてきました。改善して再計測するたびに、次のボトルネックが明らかになっていったのです。
なお、これらの計測はすべて プライベートモード——外部 LLM へは送らず、TEE 内で自社ホスティングしている 機密 LLM に問い合わせるモード——で行っています。外部サービス側の混雑という不確定要素を排除し、自分たちのシステムの遅さだけを純粋に測ることができます。
ここから先は、ボトルネックがシステムのどこからどこへ移動していったかを、時系列で追いかけます。
ボトルネック①:LLM まわりの重さ(負荷ゼロでも遅い)
■ 症状:1 人で使ってもチャット 1 回が遅い。混雑ではなく 1 件あたりの処理そのものが重い。
■ 原因:AI への問い合わせ本体ではなく“その周辺”に 4 つ。①遅いシリアライズ ②要らないルーティング ③常時オンの思考モード ④壊れたデータで会話が開けない。
■ 解決策:保存を高速方式+バルク化/回答モデルへ直結/思考モードを OFF/保存方式の変更で堅牢化。速度に効く 3 つを束ねてレイテンシは別物に。
.png)
対象は「単一ユーザーでも応答が遅い」状態です。1 リクエストの処理時間をトレースで分解し、区間ごとの所要時間を確認したところ、遅延要因は 4 つに分類できました。各要因の詳細は割愛し、症状と対策を整理します。
用語補足です。問題点 1・4 の シリアライズ は、メモリ上のオブジェクトを保存可能なバイト列へ変換する処理を指します。問題点 3 の 思考モード(reasoning / thinking モード)は、モデルが最終回答の前に「考え中のメモ」にあたる中間トークンを生成する仕組みです。精度が上がりやすい一方で応答時間と生成トークン数が増えます。試験ではこのモードを OFF にし、中間トークンを生成せず最終回答を直接生成する状態にしました。仕組みの詳細は OSS 推論エンジン vLLM の公式ドキュメント(Reasoning Outputs (https://docs.vllm.ai/en/latest/features/reasoning_outputs)、英語)を参照してください。
4つの要因に共通するのは、AI への問い合わせ本体の所要時間はほぼ問題なかった点です。遅延は主に前段・後段の処理(会話履歴の保存方式、不要な中継、モデル設定)に分散していました。トレースがなければ LLM 由来の遅延と誤認しやすい箇所です。
上記のうち速度に直結する 3 つ——遅いシリアライズの改善・不要なルーティングの削除・思考モードの OFF——をすべて適用して再計測したところ、レイテンシは大幅に短縮しました。
単一リクエストの計測では、LLM 単体を直接呼び出した場合とほぼ同等の応答時間まで短縮しました。個々の修正の効果は小さいものの、組み合わせることで全体の短縮幅が大きくなることを実測で確認しました。
- シリアライズ — Wikipedia(ボトルネック①の「データの変換」とは)
- Chain-of-Thought(思考の連鎖)— Prompt Engineering Guide(ボトルネック①の「答える前に思考を書き出す」仕組みの背景)
ボトルネック②:データベースの限界(エラー率 0% の罠)
■ 症状:同時アクセスを増やすと改善が頭打ち。エラー率は 0% なのに所要時間だけ静かに伸びる。
■ 原因:アプリは余裕なのに データベースの CPU が 100% に張り付き、処理が「待ち行列」に。飽和はエラーでなく“待ち”で現れる。
■ 解決策:需要側は削り切ったので供給側の番。データベースのスペックを一段引き上げ。
.png)
ボトルネック①の対策を適用した状態で、本番相当の負荷試験を実施しました。中程度の同時アクセスまでは応答時間・処理件数とも大きく改善しました。1 件あたりの処理が軽くなり、同一時間で処理できる件数が増加しました。加えて 1 件あたりの DB 書き込みも減ったため、需要側の改善が混雑耐性の向上にも寄与しました。
一方、同時アクセスをさらに増やすと改善幅は鈍化しました。監視上はアプリサーバーの CPU に余裕がある一方で、データベースの CPU がほぼ 100% に張り付いており、①の解消で次のボトルネックであるデータベースの処理能力が顕在化していました。エラー率は 0% のままでしたが、これはデータベースが処理能力を超えた分をエラーではなく待機として扱うためで、クエリは失敗せず所要時間だけが伸びていたのです。
ボトルネック③:接続プールの枯渇(速くなったが、壊れた)
■ 症状:DB 増強で速くなった——が 壊れた。ゼロだったエラーがポーリングに集中して大量発生。CPU は両方余裕。
■ 原因:接続プールの枯渇。LLM 応答が終わるまで DB 接続を 1 本握りっぱなしで、同時チャットが増えるとプール満杯。ポーリングが空き待ちの上限でエラー。
■ 解決策:「処理の間ずっと握る」のをやめ、用がある短い区間だけ借りてすぐ返す構造へ。プール上限にも余裕。
.png)
データベースを増強し、同一条件で負荷試験を再実施しました。応答時間は短縮しましたが、同時にエラーが発生するようになりました。
チャット 1 回の所要時間は短縮し処理件数も増加した一方、それまでほぼ 0 だったエラーが大量に発生しました。エラーは全件、AI 応答の完了確認を行う ポーリング(完了状態を繰り返し問い合わせる処理)のリクエストに集中していました。
このときアプリサーバー・データベースとも CPU には余裕がありました。計算資源は不足していないにもかかわらず、エラーが発生していました。
原因は 接続プールの枯渇でした。接続プールは、データベース接続の再確立コストを避けるため一定数の接続を事前に確保して使い回す仕組みで、同時に使用できる本数に上限があります。
真因はアプリケーションのコードにありました。チャット処理が、LLM のストリーミング応答が完了するまでデータベース接続を 1 本占有し続けていました。LLM の応答生成には数十秒〜数分を要する場合があり、その間は接続が占有されます。同時実行数が増えるとプールが枯渇し、短間隔で発生するポーリング(都度接続が必要)が空き待ちとなり、待機時間の上限に達してエラーとなります。
判断の根拠は、失敗したリクエストの所要時間が 「接続の空き待ちタイムアウトの設定値」とほぼ一致し、値が揃っていた点です。飽和は所要時間の分布に痕跡を残します。CPU 使用率に現れないボトルネックも、可観測性が確保されていれば特定できます。
この欠陥は以前からコードに存在していました。 ただし従来はデータベース自体が遅く流量が絞られていたため、プール枯渇に至る前に上流で処理が詰まり、顕在化していませんでした。
上流の詰まりは下流の欠陥を隠します。インフラ増強や上流最適化の直後は、隠れていた欠陥が顕在化しやすく、負荷試験を実施すべきタイミングです。
対策として構造を修正しました。処理中ずっと 1 本を占有する方式をやめ、データベースアクセスが必要な短い区間のみ接続を借用し、直後に返却する方式へ変更しました。あわせてプール上限にも余裕を持たせ、瞬間的なアクセス集中(バースト)への耐性を追加しました。
修正後の再計測では、接続の空き待ちによるエラーは 0 になりました。プール周りの修正のみでエラーが解消したことから、真因が接続プールであったことも確認できました。
ボトルネック④:接続の「作り直しすぎ」による混雑
■ 症状:エラーは消えたのに、ポーリングの遅い側が「待ち時間の上限」に張り付いたまま。
■ 原因:借用・返却が頻発。書き込み 1 回に対し事務手続きの通信が 約 10 回。枠の“枯渇”でなく手続きの“混雑”。
■ 解決策:発行回数を削り、事務手続きを 1 往復にバッチ化(③の握りっぱなし回避は維持)。
.png)
ただし、これで完了とはなりませんでした。エラーは解消しましたが、ポーリングの応答時間分布では、遅い側の一部が「待ち時間の上限」に張り付いたままでした。
次のボトルネックは、③の対策自体が生んだものでした。接続を「借用して即返却」する方式にした結果、接続の借用・返却が高頻度で発生するようになりました。接続を 1 回借用するたびに、生存確認や設定の再適用といった付随通信が数往復発生します。計測では、必要な書き込み 1 回に対し付随した通信が約 10 回発生していました。
プールに空きがあっても、付随通信の処理で借用までに時間がかかる状態でした。上限不足による「枯渇」と、処理過多による「混雑」は別の問題であり、上限を増やしても混雑は解消しません。対策として、問い合わせの間隔・回数を見直して接続の発行回数自体を削減し、接続 1 回あたりの付随通信をまとめて 1 往復に集約(バッチ化)しました。③の「処理中ずっと接続を握らない」原則は維持しています。
これにより、遅い側に残っていた「上限張り付き」は解消し、ポーリングの応答時間は短縮しました。
ボトルネック⑤:ワーカーの「一斉休憩」
■ 症状:さらに同時アクセスを上げると、ごく短い時間に集中してエラーが固まって出る。 ■ 原因:ワーカーの自動再起動の ずらし幅が狭く、全ワーカーの再起動が重なる瞬間=受付が一瞬全滅する瞬間ができていた。
■ 解決策:ランダムなずらし幅を大きく広げ、同時に止まる瞬間をなくす。
.png)
さらに同時アクセス数を上げて限界を探ると、ごく短い時間に集中してエラーが固まって出る 現象が最後に残りました。
原因は、リクエストを処理する ワーカー(アプリサーバー内で並行動作するプロセス)の再起動でした。ワーカーはメモリ肥大を防ぐため、一定回数の処理ごとに自動再起動する設定になっています。再起動タイミングをランダムにずらす仕組みはありましたが、リクエストがほぼ均等に分配されるうえずらし幅が狭かったため、複数ワーカーの再起動が重なる瞬間が生じていました。この瞬間はリクエストを受け付ける窓口がなくなり、エラーが集中します。
対策として、ランダムなずらし幅を大きく広げました。全ワーカーが同時に停止する瞬間がなくなり、集中していたエラーは大幅に減少しました。
均等な負荷分散は通常は望ましいですが、この事象では再起動タイミングまで同期させる要因になっていました。最終的に残った問題は、コードの不具合ではなくタイミングの問題でした。
ステップ 4:改善が効いたかを負荷試験で検証する
■ 結果:対策を重ねるごとにチャット 1 往復は段階的に短縮し、最終的にベースラインの約 8 分の 1 に。到達点の全体像はセクション末尾の「重要な結論」に整理しています。
ここまで読んで気づいた方も多いと思いますが、検証はステップ 3 の中で 毎回 やっていました。1 つ対策するたびに同一条件で負荷試験を回し直す——次のボトルネックが見つかったこと自体が、その測り直しの結果 です。
だからステップ 4 は「最後にまとめて検証する工程」ではなく、その積み重ねの 総決算 です。すべての対策を適用した状態で、ベースラインとまったく同じ条件(同じ環境・同じ同時アクセス数・同じ試験時間)の負荷試験を回し、どこまで来たかを確かめました。
結果を 1 枚にまとめたのが図7 です。チャット 1 往復(シナリオ 1 周:メッセージ送信 → ポーリング → 応答受信)にかかる時間は、ボトルネックを潰すごとに段階的に縮み、最終的にベースラインの約 8 分の 1 になりました。
図7 から読み取れることが 2 つあります。1 つ目は、②(データベース増強)の直後がすでにかなり速いこと。ただしこの回は③のエラーが噴出していて、「速いが信頼できない」状態でした。2 つ目は、③の直後の棒だけ少し伸びていること。これはこの回だけ機密 LLM 側の応答がたまたま遅かったという交絡(比較条件のずれ)によるもので、対策の副作用ではありません。こうした「条件が揃わなかった回」を正直に区別して記録しておくことも、計測を積み重ねるうえで大事なルールでした。
速くなったのは 1 往復の時間だけではありません。同じ時間に処理できたリクエスト数(スループット)も 大きく伸び、ポーリングの回数も激減しました。処理が「順番待ち」の列に並ばず、送られたそばから着手されるようになった証拠です。その結果、安定して捌けるユーザー規模も劇的に広がりました。
もう 1 つ、前半記事との答え合わせをしておきます。前半では「同時アクセス数(VU)を増やしても、セキュアチャット経由と LLM 単体(プロキシ層を通さず、同じモデルに直接問い合わせた場合)の差=プロキシ層のオーバーヘッドはほぼ一定」という図を載せました。この LLM 単体こそ、プロキシ層をどれだけ削っても超えられない 速さの下限 です。ただしあの図は、あくまで当時の 見立て(期待図) でした。
実測のベースラインはまったくそうなっていませんでした。同時アクセスを増やすほど LLM 単体との 差(ギャップ)は開いていき、待ち時間が急伸。最終的には、エラーを出さないまま実質的に停止する状態まで悪化していました。プロキシ層のオーバーヘッドは「一定」どころか、混雑とともに膨らみ続けていたのです。
対策後に複数の同時アクセス水準で測り直すと、今度は水準を上げても応答時間はほぼ横ばい。LLM 単体との差はわずかなまま、ほぼ一定に保たれ、接続プールの枯渇もゼロで破綻しませんでした。つまり、セキュアチャットを通しても LLM そのものを使うのとほとんど変わらない速さ に、混雑の中でも張り付き続けられるようになった。前半で「理想」として描いた形に、実測がようやく追いついたことになります。
重要な結論:
前半記事の出発点は「主要な LLM サービスの Web サイトと比べて、セキュアチャットの画面の方が遅く、この差をなんとかしたい」という課題でした。この課題は、大幅に達成できました。
- チャット 1 往復にかかる時間は、改善前の 約 8 分の 1
- 同じ時間に捌けるリクエスト数(スループット)は 大幅に増加
- 安定して捌けるユーザー規模も 劇的に拡大
- 負荷をかけてもエラーが出ない状態 に改善できた
この到達点は単一の対策の手柄ではなく、LLM まわりの速度改善(①)・供給側の増強(②)・接続の持ち方の是正(③④)・タイミングの分散(⑤)の積み上げ です。どれか 1 つでも欠けていたら届いていません。そして「速くなった」と「壊れない」は別の成果で、③〜⑤への対処は「壊れない」ための仕事でした。
そして正直に書いておくと、ボトルネックがこれで消えたわけではありません。次の詰まり候補は順次発見し改善していく必要があるのです。ボトルネックは消えない。次の場所で待っている。だから、測り続けます。
まとめ
本記事では、「主要な LLM サービスの Web サイトと比べてセキュアチャットの画面の方が遅い」という差を、勘ではなく計測に基づいて解消していきました。
- ステップ 3(潰す):ボトルネックは 5 つあり、対策のたびに位置を変えて現れました——LLM まわりの重さ → データベースの限界 → 接続プールの枯渇 → 接続の作り直しすぎによる混雑 → ワーカーの一斉再起動。1 つ対策するごとに同一条件で再計測することを繰り返し、すべてに対処しました。
- ステップ 4(確かめる):ベースラインと同一条件の負荷試験で総合的に検証し、狙いどおりの改善が得られたことを実測で確認しました(数値はステップ 4 の「重要な結論」を参照)。
速度改善では、高速化の手法そのもの以上に、速くすべき場所を正しく特定できることが重要でした。その特定を支えたのは 可観測性への投資 です。場所を正確に押さえれば対策は有効に働き、束ねた結果として LLM 単体自体にはない自社システムのボトルネックを解消でき、それを 単発の計測だけでなく負荷試験でも 確認できました。
速くすることは出発点であり、負荷下でも壊れないようにすることが仕上げです。ボトルネックは完全には消えず、対策後は次の箇所へ移動します。そのため、継続的な計測を続けていきます。
本記事中の図は傾向を伝えるための概念図で、具体的な計測値は含みません。
入力された情報は学習には使われませんが、念のため個人情報の入力はお控えください。








.png)

.png)