← Back to Blog
Journal

自作MCPサーバのキャッシュ設計――読み取り専用・静的ワークロードでツール別に戦略を変える

MCPRedisキャッシュModel Context ProtocolNutriSearchパフォーマンス

はじめに

MCP(Model Context Protocol)サーバは、LLM(大規模言語モデル)がツールを繰り返し呼び出す構造上、同じ入力が短時間に何度も届きます。だからキャッシュが効きやすい――ここまでは直感どおりです。しかし「取りうる入力をすべてRedisに載せればよい」かというと、そうとは限りません。ツールごとに入力の性質が違うため、有効なキャッシュ戦略も変わるからです。

本記事では、筆者が運用する栄養データベースのMCPサーバ「NutriSearch MCP」(https://donut-service.com/mcp、読み取り専用・静的ワークロード)を題材に、6つのツールでキャッシュ判断をどう変えたかを整理します。以前の記事「Notion MCPで知識ベースとAIをつなぐ」がMCPを”使う側”の話だったのに対し、本記事は”作る側”の続編です。技術トピックのため、MCP・Redis・PHPの公式ドキュメントと、筆者自身の運用(一次体験)に基づいて裏付けます。

背景・課題

MCPは、AIアプリケーションとデータソースをつなぐオープン標準です。サーバー側が提供する中核要素(プリミティブ)は、プロンプト・リソース・ツールの3種類と定義されています(modelcontextprotocol.io)。NutriSearch MCPサーバは、このうち「ツール」を6つ公開したものです。

構成はシンプルで、nginx がリクエストを受け、Express(Node.js)が MCP のプロトコル層を担い、その背後の WinterCMS(Laravel)+ MariaDB が栄養データを保持します。すべて FreeBSD 上で動き、トランスポートは Streamable HTTP、認証はベアラートークンです。MCP仕様では、Streamable HTTP のサーバーは POST と GET の両方を受ける単一のエンドポイントを提供しなければならないと定められており(modelcontextprotocol.io)、本サーバもこれに従います。

このサーバの最大の特徴は、扱うデータが読み取り専用かつ静的な点です。栄養データの実体は、文部科学省の「日本食品標準成分表(八訂)増補2023年」に由来します。収載食品数は2,538食品で(文部科学省, 2023)、これをもとに約100,003件の栄養値、18の食品群、46の栄養素をMariaDBに格納しています(数値は本サーバ取り込み時点の実値)。この「書き込みがなく、値が動かない」という前提が、キャッシュ設計を大きく単純化します。書き込みとの整合(キャッシュ無効化)を考えなくてよいため、論点は「何を、どのキーで、どれだけの期間持つか」だけに絞られます。

本論

判断の2軸――カーディナリティと入力正規化

6つのツールを一律に扱わず、次の2軸で分類しました。

  • カーディナリティ: そのツールが取りうる入力の種類数。実質ゼロ(引数なし)なのか、有限(食品コードのように件数が確定)なのか、事実上無限(自由文字列)なのか。
  • 入力正規化の必要性: 同じ意味の入力が異なる文字列で届くか。表記ゆれ・全角半角・前後の空白などを吸収しないとキャッシュヒット率が落ちるか。

この2軸で並べると、ツールごとのキャッシュ方針は自然に決まります。

引数なしで不変なツール――無期限TTL

get_stats(データベース全体の統計)と list_food_groups(18食品群の一覧)は、引数を取らず、返す値も静的データが変わらない限り不変です。カーディナリティは実質ゼロなので、キーは1つで足ります。Redisでは、キャッシュ用途のデータには基本的に有効期限を設けるのが定石ですが(Amazon Web Services, 2021)、値が動かない以上ここでは実質的に無期限のTTLとし、静的データ更新時(増補の取り込み時)にだけ明示的に破棄する運用にしました。

キースペースが有限なツール――全件プリロード

get_food_details(食品IDから詳細)と get_food_by_code(成分表の食品番号から詳細)は、キーが食品を指す有限集合です。2,538件しかないため、都度キャッシュするのではなく、起動時に全件をプリロードする方式が現実的でした。これでコールドスタート時のミスを最初から潰せます。またキー総数が確定し、Redisのメモリ上限で意図しないキーが追い出される事故も避けられます。上限到達時の挙動は maxmemory-policy で決まり、noeviction は追い出さずエラーを返し、allkeys-lru は最近使われていないキーを退避します(Redis)。本サーバは追い出しに頼らない設計です。

入力の揺れが大きいツール――正規化キーが最優先

search_food(食品名の検索)は最も扱いが難しいツールです。ユーザー(実際にはLLM)は「トマト」「とまと」「トマト」「 トマト 」のように、同じ意図をさまざまな表記で送ってきます。素朴に生の入力をキーにすると、意味が同じでも別キーになり、ヒット率が落ちます。そこで、キャッシュする前に入力を正規化し、正規化後の文字列をキーにしました。

// 検索クエリを正規化してキャッシュキーを安定させる
function normalizeQuery(raw: string): string {
  return raw
    .normalize("NFKC")      // 全角/半角・互換文字を統一(トマト→トマト)
    .trim()                 // 前後の空白を除去
    .replace(/\s+/g, " ")   // 連続する空白を1つに
    .toLowerCase();         // 英字の大文字小文字を吸収
}

const cacheKey = `search_food:v1:${normalizeQuery(query)}`;

NFKC は Unicode 正規化形式の一つで、全角・半角や互換文字を代表的な字形へ畳み込みます。キーのバージョン接頭辞(v1)は、正規化ロジックを変えたときに古いキーと混ざらないための保険です。search_food は自由文字列ゆえカーディナリティが高く、キャッシュだけでは埋めきれませんが、正規化はヒット率を左右する最優先の投資でした。

組み合わせが爆発するツール――結果ではなく入力をキャッシュする

calculate_nutrition(複数の食品と分量から合計栄養を計算)は、他とは質的に異なります。入力が「食品×分量」の組み合わせで、分量は連続値に近く、組み合わせ数は事実上無限です。結果をキャッシュしても、まったく同じ組み合わせが再来する確率は低く、ヒット率は上がりません。むしろ低ヒット率のキーがメモリを圧迫します。

そこで、結果はキャッシュせず、下層の栄養ベクトル(食品ごとの100gあたり栄養値)だけをキャッシュし、合計計算は都度行う設計にしました。栄養ベクトルは食品数(2,538件)に対応する有限集合で、しかも calculate_nutritionget_food_details の両方から再利用できます。「結果ではなく入力をキャッシュする」という一見反直感的な選択ですが、組み合わせ爆発するツールでは、キャッシュの単位を計算結果より一段下げるほうが効きます。

バックプレッシャは既存機構に任せる

読み取り専用・静的ワークロードでは、リクエストキューを自前で持つ必要はありませんでした。同時実行が増えても、背後の PHP-FPM が pm.max_children で同時プロセス数(ワーカ数)に上限を設けています。上限に達すると、超過分のリクエストはソケットの待ち行列(listen backlog、listen.backlog で規定)で待たされます(PHP)。これが自然なバックプレッシャとして機能するため、アプリ層で追加のキューイングを実装するのは過剰でした。

開発と本番でRedisのバージョンを揃える

再現性のため、開発環境のRedisは redis:6.2-alpine にピン留めし、本番のRedis 6.2.20 とバージョンを揃えました。Redisはバージョンによって既定の挙動や利用可能なコマンドが異なる(例: LFU系の eviction は Redis 4.0 以降)ため、ずれは「手元では動くが本番で挙動が違う」差異の温床になります。バージョン依存の挙動が絡むキャッシュ層ほど、環境の一致が効きます。

実践への応用・考察

ここまでの判断を一段抽象化すると、MCPサーバのキャッシュ設計は「ツール単位で入力の性質を見て決める」に尽きます。素朴な全キャッシュを否定したいのではなく、ワークロードの性質から方針を導くと、結果的にツールごとに違う答えになる、ということです。

特に一般化できるのは次の2点だと考えています。第一に、カーディナリティと入力正規化の2軸は、栄養データベースに限らずMCPサーバ全般で使える分類軸です。引数なしなら無期限、有限なら全件プリロード、自由文字列なら正規化を最優先、という対応はそのまま流用できます。第二に、組み合わせが爆発するツールは、結果ではなく下層データをキャッシュするという原則は、言語や製品を超えて成り立ちます。

もっとも、これらは読み取り専用・静的ワークロードという恵まれた前提に支えられています。書き込みが混ざるサーバでは、キャッシュ無効化という別の難問が加わり、TTLの選び方も「更新頻度に合わせて短くする」(Amazon Web Services, 2021)方向へ振れます。本記事の判断をそのまま持ち込む前に、自分のワークロードが本当に静的かを確かめるのが先です。

まとめ

  • MCPサーバはLLMがツールを繰り返し叩くためキャッシュが効くが、「全部載せる」は最適ではない。ツールごとに入力の性質が違う。
  • カーディナリティ入力正規化の必要性の2軸でツールを分類すると、方針が自然に決まる。
  • 引数なしで不変なツールは無期限、キースペースが有限なら全件プリロード、自由文字列は正規化キーを最優先にした。
  • 組み合わせが爆発する calculate_nutrition は、結果ではなく下層の栄養ベクトルをキャッシュし、計算は都度行う。
  • バックプレッシャは PHP-FPM の pm.max_children に任せ、開発と本番でRedisのバージョンを揃えて再現性を担保した。

自作のMCPサーバを持っているなら、まず手元のツールを2軸で並べてみてください。「このツールは本当にキャッシュすべきか、するなら何をキーにするか」を一つずつ問い直すだけで、素朴な全キャッシュよりも無駄の少ない設計に近づけるはずです。

参考文献

公式ドキュメント

政府・公的資料