🧠

LLMアプリのコンテキストエンジニアリング:アプリケーション状態を構造化して渡す設計

に公開

1. はじめに:LLMに渡す状態をどう設計するか

LLMアプリケーションでは、プロンプトの文面だけでなく、モデルに渡す情報の選び方や整え方が出力品質に大きく影響します。

特に、継続的な支援やパーソナライズが必要な機能では、会話履歴だけでなく、解析結果、課題、改善傾向、ユーザー状態など、アプリケーション側に蓄積された情報を踏まえて次の応答を生成する必要があります。

一方で、これらの情報をすべてそのままプロンプトに入れると、トークンコストやレイテンシが増えるだけでなく、古い情報やノイズによって判断が不安定になります。必要なのは、情報をできるだけ多く渡すことではなく、次の推論に必要な状態を選び、扱いやすい形に整えて渡すことです。

私が開発している継続支援型のAI機能でも、過去の文脈をどう扱うかが大きな課題になりました。ユーザーは単発の回答ではなく、「前回の課題は改善しているのか」「最近の取り組み状況を踏まえると、今は何を優先すべきか」といった継続的な支援を期待します。

本記事では、アプリケーションDB上のユーザー状態を集約・正規化・圧縮し、LLMが次の推論で使いやすい構造化コンテキストとして渡す設計について紹介します。

ここで扱うのは、プロンプト本文だけを調整する話ではありません。アプリケーション側でユーザー状態を管理し、どの情報をLLMに渡すか、どの情報を渡さないか、どのタイミングで更新するかを設計する、アプリケーションレベルのコンテキストエンジニアリングです。

2. アーキテクチャ設計:RAGと状態集約の使い分け

LLMに外部の文脈を参照させる手法として、RAG(Retrieval-Augmented Generation)があります。過去の会話履歴や分析結果をVector DB(ベクトルDB)に保存し、ユーザー入力や現在のタスク文脈をもとに関連情報を検索してプロンプトに含める方法です。

RAGは、特定の過去情報や外部知識を検索して取り出したい場合に有効です。一方で、今回必要だったのは「関連する過去情報を検索すること」だけではありませんでした。会話、解析結果、課題、改善傾向、ユーザーの取り組み状況などを集約し、次の推論に必要な 現在の状態 としてLLMに渡す必要がありました。

そこで、過去の履歴そのものではなく、次の判断に必要なユーザー状態を要約・構造化した CoachMemorySnapshot を作成し、プロンプトに含める設計にしました。

これは、類似リクエストに対して過去の回答を返すセマンティックキャッシュとは目的が異なります。また、エージェントフレームワークにおける checkpoint / state snapshot、つまり実行途中の状態を保存する仕組みに近い考え方ではありますが、本記事で扱うのはワークフローの巻き戻しではなく、継続的な支援に必要なユーザー状態をLLMに渡すためのコンテキスト表現です。

この設計を選んだ理由は、主に以下の3つです。

  1. 検索クエリに依存せず、現在の状態を安定して渡したかった
    RAGは、ユーザーの質問や現在のタスクに関連する情報を検索する用途に向いています。一方、継続支援型のAI機能では、ユーザーが明示的に質問していなくても、過去の課題や現在の状態を踏まえた支援が必要になります。

  2. 時系列と状態遷移を扱いたかった
    「以前はできなかったが、最近は改善している」といった状態の変化は、単純なベクトル検索だけでは扱いにくいと感じました。もちろん、メタデータやグラフ構造を組み合わせれば時系列を扱うこともできます。ただ、今回必要だったのは関連情報の検索ではなく、現在の状態、未解決の課題、改善傾向を毎回安定してLLMに渡すことだったため、ユーザー状態をあらかじめ集約・構造化する設計にしました。

  3. 個人開発における運用コストを抑えたかった
    既存のアプリケーションDBとは別にベクトルDBを運用・保守するのは、個人開発の限られたリソースではオーバーヘッドが大きいと判断しました。

RAGが不要という意味ではありません。大量の履歴や外部知識から関連情報を検索する用途ではRAGが有効です。現在状態や直近の支援方針は構造化コンテキストで渡し、過去の詳細な履歴や特定のイベントを参照したい場合はRAGで検索する、といった併用も考えられます。

3. なぜマネージドメモリではなく、アプリケーション状態を自前で組み立てたのか

LLMのメモリ機能としては、Memory BankやMem0のようなマネージドサービス、あるいはエージェントフレームワークが提供するメモリ機能もあります。これらは、会話履歴やユーザーに関する事実・好み・関係性を抽出し、次回以降のやり取りで検索・想起する用途に向いています。

一方で、今回必要だったのは、会話から記憶を抽出することだけではありませんでした。アプリケーションDBに散らばった目標、活動ログ、課題履歴、改善傾向、ユーザーごとの反応傾向などを集約し、LLMが次の判断に使える状態として渡す必要がありました。

つまり、この処理は一般的なメモリサービスというより、アプリケーション固有の状態をLLM向けの構造化コンテキストへ変換するための、アプリケーションレベルのコンテキストエンジニアリングだと捉えています。

マネージドメモリサービスを使っても、目標進捗の計算、直近の活動量の集計、課題の未解決・解決ステータス管理、優先度付けなどのビジネスロジックは、基本的にはアプリケーション側に残ります。

そのため、今回の実装では、汎用メモリサービスへ置き換えるのではなく、アプリケーションレベルで必要な状態を集約し、CoachMemorySnapshot としてLLMに渡す設計にしました。

もちろん、マネージドメモリサービスが不要という意味ではありません。会話からユーザーの好みや長期的な傾向を抽出する部分では、将来的に補完的に使える可能性があります。ただし、ドメイン固有の状態を組み立てる中核部分は、引き続きアプリケーション側の責務だと考えています。

また、マネージドメモリサービスは料金体系や提供形態が変わる可能性があるため、導入時点の公式ドキュメントでコストと運用条件を確認する必要があります。

4. CoachMemorySnapshotの設計

実際にプロンプトに注入している CoachMemorySnapshot のスキーマを抽象化すると、以下のようになります。

type CoachMemorySnapshot = {
  // ユーザーの目標と進捗
  goal: {
    description: string;
    focusAreas: string[];
  };
  overallGoalProgress: number;
  todaysFocus: GoalMetricSummary[];

  // 直近の活動量や取り組み状況
  recentActivitySummary: {
    sessionsLast7Days: number;
    totalDurationMinutesLast7Days: number;
    sessionsLast28Days: number;
  };

  // 現在の課題状態
  issueState: {
    latestFocusTags: string[];
    unresolvedIssues: string[];
    resolvedIssues: string[];
  };

  // 過去の分析から得られたインサイトと学習パターン
  activeImprovements: ImprovementSummary[];
  keyInsights: InsightSummary[];
  learnedPatterns: PatternSummary[];

  generatedAt: string;
};

このスナップショットは、アプリケーションDBの複数コレクションからデータを集約して生成します。たとえば、目標、過去の活動ログ、課題履歴、改善傾向などを集約し、LLMに渡しやすい形へ整えます。

重要なのは、履歴をそのまま保存して渡すのではなく、次の推論に使うための 状態 として再構成することです。

コンテキストに含める情報・含めない情報

コンテキスト設計において「何を含めるか」と同じくらい重要なのが、何を含めないか です。すべての会話履歴や分析結果をそのまま含めることは避けました。理由は、トークンコストが増えるだけでなく、古い情報やノイズが混ざることで、LLMの判断が不安定になるためです。

そのため、LLMに渡すコンテキストには、以下の情報を中心に含める方針にしました。

  • 現在の目標
  • 直近のフォーカス領域
  • 未解決の課題
  • 改善傾向
  • コーチングに使える代表的なインサイト
  • 再利用価値のあるパターン

逆に、以下のような情報はコンテキストに含めすぎない方針にしています。

  • 過去の会話全文
  • 古くなった指摘
  • 重要度の低い一時的なやり取り
  • 次回のコーチングに使わない詳細な生データ
  • 不要にセンシティブになり得る情報

特に、ユーザーの状態やモチベーションに関する情報は、ユーザー体験の向上に役立つ一方で、扱い方を誤るとセンシティブな情報になり得ます。そのため、構造化コンテキストには「次回のコーチングに必要な要約」だけを残し、不要に詳細な会話履歴や生データは含めない方針にしました。

コンテキストをいつ更新するか

CoachMemorySnapshot は、API呼び出しのたびに必ず再構築するのではなく、ユーザーの状態が変わるタイミングで更新します。

たとえば、以下のようなタイミングです。

  • 新しい入力データの解析が完了したとき
  • 活動ログが追加されたとき
  • ユーザーの目標が変更されたとき
  • 新しい課題や改善傾向が検出されたとき
  • パターン学習に使えるシグナルが得られたとき

一方で、単なるチャット応答のたびに全体を再構築すると、DBの読み取りや正規化処理のコストが増えます。そのため、実装では「状態が変わったときに更新する処理」と「一定時間内は既存スナップショットを再利用する処理」を分けています。

実装の工夫:鮮度管理と maxAgeMinutes

このスナップショットの生成では、複数のコレクションを読み込み、データの正規化や重複排除を行います。そのため、毎回再生成するとレイテンシやDBの読み取りコストが増えます。

そこで、スナップショットに 鮮度管理(maxAgeMinutes を導入しました。

async function getServerCoachMemorySnapshot(userId: string, maxAgeMinutes = 15) {
  const storedSnapshot = await loadStoredCoachMemorySnapshot(userId);
  const maxAgeMs = maxAgeMinutes * 60 * 1000;

  // ※ `generatedAt.toMillis()` はFirestore Timestampを想定。
  // 汎用的なDateオブジェクトの場合は `new Date(storedSnapshot.generatedAt).getTime()` となります。
  if (storedSnapshot && Date.now() - storedSnapshot.generatedAt.toMillis() <= maxAgeMs) {
    return storedSnapshot;
  }

  const rebuiltSnapshot = await buildServerCoachMemorySnapshot(userId);

  await saveCoachMemorySnapshot(userId, rebuiltSnapshot);
  return rebuiltSnapshot;
}

この仕組みにより、1回の利用セッション中に複数回LLMを呼び出した場合でも、重い集約処理は最初の1回だけで済みます。

なお、上記コードは考え方を示すための簡略化した例です。generatedAt.toMillis() はFirestore Timestampを想定した例であり、利用するDBに応じてタイムスタンプの扱いは調整してください。実際には、スナップショットの更新タイミング、DBの型、同時更新時の競合なども考慮する必要があります。

5. パターン学習の仕組み

CoachMemorySnapshot のもう一つの特徴は、LLMの出力やユーザーの反応から再利用できる傾向を抽出し、次回以降のコンテキストに反映する仕組みです。

ここでいう「学習」は、LLMそのものを再学習するという意味ではありません。アプリケーション側で観測できるシグナルをもとに、次回のコーチングに使える傾向を構造化して保存する、という意味です。

たとえば、同じような課題に対してどのような説明が行動につながりやすかったか、どの粒度のアドバイスが継続しやすかったか、といった情報を抽象化して learnedPatterns として保存します。

type PatternSummary = {
  trigger: string;
  response: string;
  outcome: string;
  confidence: number;
  frequency: number;
};

trigger は、特定の状況や課題を表します。response は、その状況に対してどのようなコミュニケーションを行ったかを表します。outcome は、その後に観測された結果の要約です。

confidencefrequency は、実際にはアプリ側で観測した回数や結果をもとに更新する想定です。この記事では、具体的な算出ロジックや重みづけは抽象化しています。

ここでいう「ユーザーの反応」は、明示的なフィードバックに限りません。次回の利用、改善傾向、チャットでの返答など、アプリ側で観測できる範囲のシグナルを指します。

このデータをコンテキストに含めることで、LLMはユーザーごとの反応傾向を踏まえたコーチングを行いやすくなります。たとえば、あるユーザーには改善履歴を示した方が行動につながりやすい、別のユーザーには次にやることを短く提示した方が継続しやすい、といった傾向を次回以降の応答に反映できます。

6. 構造化コンテキストをどう圧縮するか

集約したスナップショットは、モデルへの指示コンテキストの一部としてJSON形式で渡します。実際には、利用するAPIに応じて system instruction やプロンプト本文に含める形になります。

ここで問題になるのが、コンテキストウィンドウ(トークン上限)推論コスト の制約です。過去の全履歴をそのままテキストとして詰め込むと、あっという間にトークン上限に達し、APIコストも跳ね上がります。また、情報量が多すぎると、プロンプトの中盤にある重要情報が相対的に拾われにくくなる、いわゆる「Lost in the middle」に近い問題が起きる可能性があります。

そのため、限られたトークン数の中で 何を捨て、何を残すか の意思決定が重要になります。

徹底した重複排除とテキストの刈り込み

コンテキストを圧縮するために、スナップショット生成時に正規化と刈り込みを行っています。

  • 文字数制限(Truncation)
    各要素は、意味を損なわない範囲で文字数を制限します。
  • 同義語の重複排除(Deduplication)
    表現が違うだけで同じ内容の課題が複数存在する場合は、表記揺れを吸収して重複を排除します。
  • 優先度による絞り込み
    すべての履歴を渡すのではなく、現在のコーチングに使う可能性が高い情報を優先して残します。

たとえば、テキストの正規化と重複排除は以下のように行います。

const normalizeComparisonText = (value: string) =>
  value
    .normalize('NFKC') // 日本語の全角英数・揺らぎを吸収
    .toLowerCase()
    .replace(/[\s、。,:;!?()]+/g, '')
    .trim();

const dedupeSimilarTexts = (items: string[], maxItems: number) => {
  const results: string[] = [];
  const normalizedResults: string[] = [];

  for (const item of items) {
    const normalized = normalizeComparisonText(item);

    const isDuplicate = normalizedResults.some(existing =>
      existing.includes(normalized) || normalized.includes(existing)
    );

    if (!isDuplicate) {
      results.push(item);
      normalizedResults.push(normalized);
    }

    if (results.length >= maxItems) break;
  }

  return results;
};

この刈り込み処理により、たとえば過去30件の活動ログがあったとしても、抽出されるのは「現在進行中の課題」と「直近の成功パターン」だけに絞られます。実際には、短すぎる文字列を比較対象から除外したり、ドメインごとの正規化ルールを併用したりして、誤マッチを減らす必要があります。

このように、LLMに渡す前のデータの前処理・圧縮を地道に実装することで、トークン数を抑えつつ必要な文脈を維持しています。

なぜ全文履歴ではなく、要約された状態を渡すのか

LLMに継続的な文脈を渡すとき、最も単純なのは過去の会話履歴や分析結果をそのままプロンプトに入れる方法です。しかし、この方法ではトークンコストが増えるだけでなく、古い情報や重要度の低い情報が混ざり、現在の判断に必要な文脈が埋もれやすくなります。

そのため、本実装では「過去の履歴そのもの」ではなく、「現在のコーチングに必要な状態」をスナップショットとして渡す方針にしました。

つまり、コンテキスト設計では、履歴を保存するだけでなく、次の判断に使える状態へ圧縮することが重要だと考えています。

7. スナップショット型コンテキストの弱点

ユーザー状態を要約・構造化して渡す設計にはメリットがある一方で、弱点もあります。

過去の履歴を要約された状態として扱うため、元の会話や分析結果に含まれていた細かいニュアンスは失われます。また、スナップショット生成ロジックが不十分だと、本来残すべき重要な情報を落としてしまう可能性があります。

たとえば、短期的には重要ではないように見えた情報が、後から文脈として効いてくることもあります。逆に、古い情報を残しすぎると、現在の状態と矛盾したアドバイスにつながる可能性もあります。

そのため、この設計では以下を継続的に見直す必要があります。

  • どの情報を残すか
  • どの粒度で要約するか
  • どのタイミングで更新するか
  • 古くなった情報をいつ捨てるか
  • 誤ったパターンをどう修正するか

コンテキスト設計は、一度作って終わりではありません。ユーザーの利用状況やLLMの出力傾向を見ながら、継続的に調整する前提で設計する必要があります。

8. まとめ:コンテキスト設計はLLMアプリのプロダクト品質に直結する

LLMアプリでは、プロンプト本文だけでなく、モデルに渡すコンテキスト全体をどう設計するかが重要です。

特に、継続的な支援やパーソナライズが必要な機能では、過去の会話履歴をそのまま渡すだけでは不十分です。解析結果、課題、改善傾向、ユーザー状態などを集約し、次の推論に使える構造化コンテキストとして渡す必要があります。

今回の実装では、RAGやマネージドメモリを主軸にするのではなく、アプリケーションDB上の状態を集約・正規化・圧縮し、CoachMemorySnapshot としてLLMに渡す設計にしました。

この設計により、以下のような効果を得やすくなります。

  1. 継続的に状態を理解している体験を作りやすくなる
    過去の課題や活動状況を踏まえたアドバイスが可能になり、単なる一問一答のボットではなく、継続的に見てくれているコーチに近い体験を作りやすくなります。

  2. パーソナライズの精度を上げやすくなる
    パターン学習をコンテキストに含めることで、ユーザーの反応傾向を踏まえたコミュニケーションがしやすくなります。

  3. コストとレイテンシを制御しやすくなる
    maxAgeMinutes によるキャッシュとテキスト圧縮により、LLMのトークンコストを抑えつつ、応答速度を実用的なレベルに保ちやすくなります。

LLMを組み込んだプロダクトを作る際、「LLMにプロンプトを投げて結果を受け取る」だけではなく、LLMに渡すアプリケーション状態をどう設計するか が、プロダクト体験を大きく左右します。

この記事が、LLMを活用したサービス開発に取り組む方々の参考になれば幸いです。

Discussion