🔍

LLMの出力を追跡可能にする:正規化・ハッシュ・状態遷移の設計

に公開

はじめに:LLMの出力を追跡可能なデータに変換する

LLMをプロダクトに組み込むとき、単なる対話インターフェースとして使うだけであれば、出力の表現揺れはそこまで大きな問題になりません。しかし、LLMの出力をアプリケーションの状態として保存し、時系列で追跡しようとした瞬間に、ひとつの課題にぶつかります。

それは、同じ事象に対しても、LLMの表現が毎回少しずつ変わるという問題です。たとえば、ドメイン特化型のAI分析機能が、ある日は「安定性が不足している」と指摘し、別の日には「バランスが崩れている」と表現することがあります。人間から見れば近い問題でも、文字列としては別物として扱われます。

この問題を放置すると、以下のような課題が起きます。

  • 同じ課題が毎回「新しい問題」として提示される
  • 改善したのか、残っているのかを自動追跡できない
  • 課題の蓄積データをパーソナライズに活用しにくい
  • LLM出力の再現性や品質を検証しにくい

本記事では、この「LLM出力の同一性判定」問題を、テキスト正規化とハッシュベースのID生成、状態遷移の管理によって扱う設計を紹介します。


全体アーキテクチャ:LLM出力を時系列で追跡する

まず、システムの全体像は以下のようなイメージです。

              ┌──────────────────────────┐
              │        Gemini API         │
              │  入力データ分析 / テキスト解析 │
              └────────────┬─────────────┘
                           │ 非構造化テキスト
                           │ 「安定性が不足している」
                           │ 「動作後のリカバリーが遅い」

              ┌──────────────────────────┐
              │    正規化パイプライン       │
              │  ┌────────────────────┐  │
              │  │ ラベル除去           │  │
              │  │ Unicode正規化        │  │
              │  │ ドメイン正規化        │  │
              │  │ CANONICAL_PATTERNS   │  │
              │  └────────────────────┘  │
              └────────────┬─────────────┘
                           │ 正規化済みキー
                           │ "stability_issue"
                           │ "timing_delay"

              ┌──────────────────────────┐
              │  SHA-256 ハッシュID生成    │
              │  normalizedKey → issueId │
              └────────────┬─────────────┘

                  ┌────────┴────────┐
                  ▼                 ▼
        ┌────────────────┐  ┌────────────────┐
        │  新規課題       │  │  既存課題と照合  │
        │  → Firestore    │  │  → ステータス更新│
        │    に保存       │  │  残存 / 改善 / 新規 │
        └────────────────┘  └────────────────┘

このパイプラインの核心は、LLMの自然言語出力を、アプリケーション側で扱える安定したIDに変換することです。自然言語のままだと表現が揺れます。しかし、同じ意味の出力を同じ正規化キーに寄せ、そのキーから決定論的にIDを生成できれば、セッションをまたいだ課題の同一性を追跡しやすくなります。


LLM出力の正規化:なぜルールベースを主軸にしたか

設計判断のトレードオフ

LLMが出力する課題テキストの同一性を判定する方法として、大きく3つのアプローチがあります。

アプローチ 精度 コスト 再現性 運用負荷
Embeddingベースの類似度 高い API呼び出しコストが発生 △ モデル・閾値に依存 閾値チューニングが必要
LLMによる都度判定 高い 高い △ 出力揺れやプロンプト依存あり プロンプト管理が必要
ルールベース正規化 + ハッシュ 中〜高 追加APIコストなし ✅ 同じ入力なら安定 パターン追加の運用が必要

本実装では、ルールベース正規化 + ハッシュを主軸とし、LLMによるマッチングを補助的に使うハイブリッド方式を採用しました。理由は主に3つあります。

1つ目は、同じ入力からは同じIDを生成したかったからです。Embeddingベースでは、モデル更新や閾値設定によって境界ケースの判定が変わる可能性があります。LLMによる都度判定も、プロンプトやモデルの出力揺れの影響を受けます。一方で、ルールベース正規化とハッシュであれば、同じ正規化キーからは常に同じIDを生成できます。

2つ目は、追加のAPIコストを抑えたかったからです。セッションごとに複数の課題を処理する場合、EmbeddingやLLM判定を毎回呼び出すと、コストとレイテンシが積み上がります。正規化処理はアプリケーション側のCPU処理だけで完結します。

3つ目は、デバッグしやすいからです。ルールベースであれば、「なぜこの課題とあの課題が同一と判定されたのか」を正規化キーから追いやすくなります。ドメイン固有の判断をコード上のルールとして管理できる点も、運用上のメリットです。


正規化パイプラインの実装

正規化は、大きく3段階で行います。

ステップ1:ラベル除去

LLMの出力には、「最優先改善点:」「改善ポイント:」「課題:」のような構造的ラベルが付くことがあります。これらは課題の本質ではなく、同一性判定においてノイズになるため、最初に除去します。

const ISSUE_CATEGORY_LABEL_PATTERN =
  /^(?:最優先改善点|改善ポイント|影響分析|改善期待効果|意識するポイント|課題)[::]\s*/;

const stripIssueCategoryLabel = (text: string): string =>
  (text || '').replace(ISSUE_CATEGORY_LABEL_PATTERN, '');

たとえば、以下の2つは意味としては同じです。

改善ポイント:安定性が不足している
安定性が不足している

このラベルを残したままID生成すると、同じ課題が別のIDになってしまう可能性があります。

ステップ2:Unicode正規化と空白の統一

日本語テキストでは、全角・半角、記号、スペース、句読点などの揺れが発生します。そのため、基本的な表記揺れを吸収します。

const normalizeBaseText = (text: string): string => {
  return stripIssueCategoryLabel(text)
    .normalize('NFKC')
    .replace(/[\s、。,.・/]+/g, ' ')
    .toLowerCase()
    .trim();
};

ここでは、全角・半角、記号、スペースなどの表記揺れを減らすために NFKC を使っています。実際には、扱う文字種や入力元に応じて正規化方針を決めるのがよいです。

なお、句読点や記号をどこまで空白に置換するかは、扱うドメインによって調整する必要があります。たとえば / が識別子や専門用語の一部として意味を持つ場合は、置換対象から外す方が安全です。

ステップ3:ドメイン固有の正規化

最も重要なのは、ドメイン固有の表現揺れを吸収することです。LLMは同じ概念をさまざまな表現で出力します。たとえば、ある課題を「安定性が不足している」と表現することもあれば、「バランスが崩れている」と表現することもあります。

そこで、ドメイン知識に基づく正規化パターンを定義します。

const CANONICAL_PATTERNS: Array<{ pattern: RegExp; token: string }> = [
  { pattern: /(安定性|安定|ぶれ|ブレ|バランス)/, token: 'stability_issue' },
  { pattern: /(遅れ|遅い|タイミング|リカバリー|復帰)/, token: 'timing_delay' },
  { pattern: /(不足|足りない|弱い|不十分)/, token: 'insufficient_control' },
  { pattern: /(一貫性|ばらつき|バラつき|再現性)/, token: 'consistency_issue' },
  { pattern: /(集中|意識|注意|見落とし)/, token: 'attention_issue' },
];

正規化の結果、複数のトークンがマッチした場合は、ソートして :: で連結します。

const normalizeIssueText = (text: string): string => {
  const stripped = normalizeBaseText(text);
  const tokens = new Set<string>();

  CANONICAL_PATTERNS.forEach(({ pattern, token }) => {
    if (pattern.test(stripped)) {
      tokens.add(token);
    }
  });

  if (tokens.size > 0) {
    return Array.from(tokens).sort().join('::');
  }

  return stripped;
};

この設計により、以下のように表現の揺れを吸収できます。

LLMの出力テキスト 正規化キー
安定性が不足している insufficient_control::stability_issue
バランスが崩れている stability_issue
動作後のリカバリーが遅い timing_delay
タイミングにばらつきがある consistency_issue::timing_delay
再現性が不十分 consistency_issue::insufficient_control

トークンをソートしているのは、語順に依存しない正規化を行うためです。LLMは同じ内容でも、「安定性が不足している」と言うこともあれば、「不足しているのは安定性です」と言うこともあります。トークンの出現順に依存してキーを作ると、同じ意味でも異なるキーになる可能性があります。


ハッシュベースのID生成:課題に安定した識別子を与える

正規化キーが得られたら、SHA-256ハッシュの先頭40文字を issueId として使います。

import { createHash } from 'node:crypto';

const buildIssueId = (text: string): string => {
  const normalized = normalizeIssueText(text) || text.toLowerCase();
  const hash = createHash('sha256').update(normalized).digest('hex');

  return hash.slice(0, 40);
};

以下の例は Node.js 環境を想定しています。ブラウザや Edge Runtime で実行する場合は、Web Crypto API など環境に応じた実装に置き換える必要があります。

この設計の利点は3つあります。1つ目は、冪等性です。同じ正規化キーからは必ず同じIDが生成されます。リトライやエラー復旧時にも、重複した課題ドキュメントが作られにくくなります。

2つ目は、実用上十分な衝突耐性です。SHA-256の先頭40文字、つまり160ビット相当を使うため、今回の用途では十分な衝突耐性があると判断しました。

3つ目は、生テキストをIDにしなくて済むことです。課題文そのものをドキュメントIDやログ識別子に使わずに済みます。ただし、短い定型文は推測可能な場合があるため、ハッシュ化をプライバシー対策として過信しないようにしています。


状態遷移:課題のライフサイクル管理

Firestoreに保存された課題データは、状態を持ちます。

                      初回検出


                   ┌───────────┐
            ┌──────│ candidate │◄──────────────┐
            │      └─────┬─────┘               │
            │            │ 再検出 / 信頼度確認   │ 再検出
            │            ▼                      │
            │      ┌───────────┐          ┌─────┴─────┐
            │      │   open    │─────────►│ resolved  │
            │      └─────┬─────┘ 改善確認  └───────────┘
            │            │
            │            ▼
            │      ┌───────────┐
            └─────►│  ignored  │
                   └───────────┘
                  ユーザーが無視

各ステータスの意味は以下です。

  • candidate:初回検出された課題。まだ誤検出の可能性があるため、確定扱いにはしない。
  • open:対応中の課題。再検出や信頼度などをもとに、継続的に追跡する対象。
  • resolved:改善が確認された課題。再検出された場合は open に戻す。
  • ignored:ユーザーが意図的に無視した課題。再検出されても状態を変えない。

なぜ candidate 状態を設けたのか

LLMは入力データのノイズや一時的な揺れを課題として検出することがあります。初回検出をすぐに open にすると、偽陽性の課題がユーザーの課題リストに増えてしまう可能性があります。

そこで、candidate 状態を設けています。初回検出されたものをすぐに確定課題として扱わず、再検出や信頼度などを見て open に昇格できるようにしています。


ルールベースでは捕捉できないケースへの対処

ルールベースの正規化だけでは限界があります。たとえば、「操作の開始が遅い」と「最初の反応に時間がかかっている」は、特定ドメインの文脈では近い課題かもしれません。しかし、テキスト上の共通トークンが少ない場合、単純な正規表現では同一性を判定できません。

このようなケースに対応するため、LLMを使ったセマンティックマッチングを補助的に使っています。

const instructions = [
  'You are a domain-specific analysis assistant that decides whether a newly detected issue matches an existing issue.',
  'Use the provided existingIssues list as the only pool of possible matches.',
  'Consider semantic meaning beyond wording, including synonyms and different phrasing.',
  'Only output matchType "existing" when confidence is high.',
];

このハイブリッド設計のポイントは、LLMマッチングを追加の精度向上として位置づけることです。

処理フロー:
1. ルールベース正規化
   → 同一キーならマッチ確定

2. ルールベースで未マッチの新規課題
   → LLMに既存課題との照合を依頼

3. LLMがマッチを返さない、またはAPI障害
   → そのまま新規課題として保存

LLMマッチングが失敗しても、課題は新規として保存されるだけで、既存データを壊すことはありません。ただし、この方針では誤マージを避けやすい一方で、類似課題が重複して残る可能性があります。そのため、後から手動マージやバッチ統合を行える余地を残しておくと運用しやすくなります。

高い信頼度閾値を設定する

マッチングの誤りは、課題のマージを意味します。一度マージされた課題を後から分離するのは難しいため、閾値は意図的に高めにしています。

const ISSUE_MATCH_CONFIDENCE_THRESHOLD = 0.78;

さらに、改善点と既存課題のマッチングでは、改善の誤帰属が起きると進捗データが壊れます。そのため、より高い閾値を使っています。

const RESOLUTION_MATCH_CONFIDENCE_THRESHOLD = 0.9;

これらの値は絶対的な正解ではなく、誤マッチを避けることを優先して、実データを見ながら調整した経験的な閾値です。

未マッチの課題は新規として保存され、後から統合できます。一方で、誤マッチによる課題の統合は、ユーザーの進捗追跡データを壊す可能性があります。


改善の自動検出:前回比較のアルゴリズム

課題の追跡だけでなく、「改善が起きたこと」を検出する仕組みも重要です。基本的な考え方は、前回のセッションで検出された課題と、今回のセッションで検出された課題を比較することです。

ここでは、トークンの重なりをもとに、前回と今回の課題が同じ系統かどうかを判定します。

type NormalizedIssue = {
  canonical: string;
  tokens: string[];
};

const normalizeIssueKey = (text: string): NormalizedIssue => {
  const canonical = normalizeIssueText(text);
  const tokens = canonical.split('::').filter(Boolean);

  return { canonical, tokens };
};

type ProgressComparisonPayload = {
  improvements: NormalizedIssue[];
  remainingIssues: NormalizedIssue[];
  newIssues: NormalizedIssue[];
};

const tokensMatch = (a: string[], b: string[]): boolean => {
  if (a.length === 0 || b.length === 0) return false;

  const setA = new Set(a);
  const overlapCount = b.filter(token => setA.has(token)).length;
  const minLength = Math.min(a.length, b.length);

  return overlapCount / minLength >= 0.5;
};

function buildProgressComparison(
  previousInsights: string[],
  currentInsights: string[],
  historicalIssues: Map<string, IssueDoc>,
): ProgressComparisonPayload {
  const prevEntries = previousInsights.map(normalizeIssueKey);
  const currEntries = currentInsights.map(normalizeIssueKey);

  const improvements: NormalizedIssue[] = [];
  const remainingIssues: NormalizedIssue[] = [];
  const newIssues: NormalizedIssue[] = [];

  currEntries.forEach(curr => {
    const matchedPrevious = prevEntries.some(prev =>
      tokensMatch(prev.tokens, curr.tokens)
    );

    if (matchedPrevious) {
      remainingIssues.push(curr);
      return;
    }

    if (historicalIssues.has(curr.canonical)) {
      remainingIssues.push(curr);
      return;
    }

    newIssues.push(curr);
  });

  prevEntries.forEach(prev => {
    const stillDetected = currEntries.some(curr =>
      tokensMatch(prev.tokens, curr.tokens)
    );

    if (!stillDetected) {
      improvements.push(prev);
    }
  });

  return { improvements, remainingIssues, newIssues };
}

ここでは簡略化のため、短い方のトークン集合に対して50%以上が一致すれば同じ系統の課題として扱う例にしています。実際の閾値は、誤マッチをどこまで許容するかによって調整します。

ただし、「今回出力されなかった = 必ず改善した」とは限りません。LLMの見落としや入力品質の差もあり得ます。そのため、実運用では複数回の未検出、信頼度、入力条件などを組み合わせて resolved を確定する方が安全です。

ポジティブな言及を課題として扱わない

LLMは、「前回より安定性が向上しています」のようなポジティブなコメントを、課題リストの一部として出力してしまうことがあります。これをそのまま課題として扱うと、改善した内容を再び課題として保存してしまいます。

そのため、ポジティブ表現を検出し、課題候補から除外します。

const positivePattern =
  /(向上|改善傾向|改善が見られ|良好|安定している|できている)/;

const negativePattern =
  /(課題|不足|不安定|遅れ|崩れ|問題|弱さ|リスク)/;

const isPositiveSummary = (summary: string): boolean => {
  return positivePattern.test(summary) && !negativePattern.test(summary);
};

このように、LLMの出力をそのまま信じるのではなく、アプリケーション側で追跡対象として扱える形にフィルタリングします。


Firestoreのデータモデル

課題は、ユーザーごとのサブコレクションに保存しています。

interface IssueDoc {
  title: string;
  normalizedKey: string;
  status: 'candidate' | 'open' | 'resolved' | 'ignored';
  firstDetectedAt: Timestamp;
  lastDetectedAt: Timestamp;
  resolvedAt?: Timestamp;
  occurrenceCount: number;
  lastSessionId?: string;
  focusTags?: string[];
}

normalizedKey をドキュメントに保存している理由は、正規化ルールが今後も更新される可能性があるからです。CANONICAL_PATTERNS を変更すると、同じテキストから生成される正規化キーが変わる可能性があります。normalizedKey を保存しておけば、ルール更新前に生成されたキーとの照合やデータ移行がしやすくなります。

また、issueId だけでなく normalizedKey も残しておくことで、デバッグ時に「なぜこの課題が同一扱いになったのか」を追いやすくなります。


実運用で見えてきた課題と対策

1. 新規課題の数を制限する

LLMは一度の分析で多くの課題を出力することがあります。しかし、ユーザー体験の観点では、一度に多くの新規課題を提示すると、認知負荷が高くなります。そのため、一度に提示する新規課題は少数に制限しています。

if (newIssueEntries.length > 1) {
  const overflow = newIssueEntries.splice(1);

  // 溢れた分は、必要に応じて次回以降の候補として扱う
}

ユーザーにとって重要なのは、課題を大量に列挙されることではなく、次に何を改善すべきかが明確になることです。

2. タイトルを安定させる

課題の title は初回作成時にLLMの出力から生成しますが、一度決まったタイトルは原則として上書きしない方針にしています。

const shouldUpdateTitle = !existing || !existing.title;

if (shouldUpdateTitle && desiredTitle) {
  nextData.title =
    desiredTitle.length > 100
      ? `${desiredTitle.slice(0, 97)}...`
      : desiredTitle;
}

理由は、ユーザーがある課題名で認識しているものが、次の分析で別の表現に変わると混乱するためです。内部的には同じ issueId で追跡していても、ユーザーに表示されるラベルが毎回変わると、継続的な改善体験が損なわれます。

3. ignored ステータスを尊重する

ユーザーが意図的に無視した課題は、たとえLLMが再度検出しても状態を変更しません。

if (existingStatus === 'ignored') {
  nextStatus = 'ignored';
}

これは、ユーザーの判断を尊重するためです。AIが何度も同じ指摘を繰り返すと、ユーザーにとっては「しつこい」「分かっていない」という体験になります。ignored を尊重することで、AIが過剰に干渉する印象を避けやすくなります。


まとめ:LLMの出力を時系列データとして扱う価値

LLMの出力をその場限りのテキストとして扱うのではなく、構造化された時系列データとして蓄積すると、プロダクト上の価値が大きく変わります。具体的には、以下のようなことが可能になります。

  1. ユーザーの変化を可視化できる
    過去に検出された課題が、現在どの程度残っているのかを追跡できます。

  2. パーソナライズの精度を上げられる
    蓄積された課題の傾向をもとに、ユーザーごとの支援内容を組み立てやすくなります。

  3. LLM出力の品質を検証しやすくなる
    同じ課題が繰り返し検出されるか、過去の出力と整合しているかを追跡することで、LLM出力の再現性を間接的に評価できます。

本記事で紹介した「正規化 → ハッシュ → 状態遷移」のパターンは、ドメイン特化型の分析に限らず、LLMの出力を縦断的に追跡したいさまざまな領域に応用できます。

たとえば、以下のような用途です。

  • 学習アプリにおける弱点の追跡
  • カスタマーサポートAIにおける問い合わせ課題の追跡
  • 業務支援AIにおけるタスク課題の経時変化

LLMの出力は、適切に構造化すれば「一度きりの回答」から「価値ある時系列データ」に変わります。そして、その構造化に必要なのは必ずしも高度なMLモデルだけではありません。ドメイン知識に基づいた正規化ルール、安定したID設計、状態遷移の管理を組み合わせることで、LLMの揺らぐ出力をアプリケーションの状態として扱いやすくなります。

Discussion