はじめに:LLMエージェントの「出力」をどう検証するかという課題
LLMをエージェントの中核に置いたシステムを開発する際、最も厄介な問題の一つは「出力の信頼性をどう保証するか」である。多くの実装では、LLMから返ってきたJSONやテキストをそのまま業務ロジックに渡しており、出力が事実と異なる場合でも下流の処理はそれを真実として扱ってしまう。この構造では、ハルシネーションや文脈の逸脱が起きていることをシステムが検知する手段が基本的に存在しない。
WildProofというプロジェクトは、この問題に対して「Outside with a question, come back with evidence(問いを持って外に出よ、証拠を持って帰れ)」という設計思想を掲げている。これは単なる品質保証ではなく、推論プロセス自体を構造化し、どの主張が観察に基づき、どの主張が推論であり、どの部分が不明確なのかをデータレベルで明示するアプローチである(WildProofの解説記事)。
本記事では、この設計パターンをGo言語の静的型システムとTemporalワークフローエンジンに転用し、LLMの不確実性をコードレベルで制御・可視化する具体的な手法を提示する。対象読者はGoでAIエージェントを実装しており、API呼び出しのラッパーではなくシステム全体の堅牢性設計に関心を持つエンジニアである。
WildProofの核心:不確実性を「unknown」として明示する設計思想
WildProofのレポートは、生成された主張を「observed(観察)」「inferred(推論)」「unknown(不明)」の3カテゴリに強制分類する(WildProofリポジトリ)。この3分類が持つ意味を正確に理解することが、Goでの実装設計の出発点となる。
まず、これはハルシネーション検出器ではないという点に注意が必要だ。ハルシネーション対策は「正しい出力か間違っている出力か」という二値の判定を行うが、WildProofの分類は確信度のグラデーションを表現する。例えば「この場所にリスがいた」は観察(observed)だが、「リスの個体数は減少傾向にある」は複数の観察から導かれた推論(inferred)であり、「この地域の生態系は健全か」は現在のデータでは判断不能(unknown)である。このグラデーションをデータ構造として持ち出すことで、下流のシステムはリスクレベルに応じた対応(人間へのエスカレーション、追加調査のトリガー、表示の強調レベル変更など)を決定できるようになる。
従来のJSONスキーマ検証は、形式の正しさのみをチェックする。つまり「このフィールドが文字列かどうか」「数値の範囲が妥当か」といった構造的な制約である。一方、WildProofの3分類は意味的な不確実性そのものを検証対象に含める。「unknown」が返された場合、それはエラーではなく正当な出力であり、システムがそれを明示的に処理することを要求する。この設計上の区別——「形式の不正」と「意味的な不確実性」を別の層で扱う——が、Goでの実装時に最も意識すべきポイントである。
Go言語における型システムでの不確実性モデリング
WildProofの3分類をGoの型システムで表現するには、列挙型(string-based enum)と構造体の組み合わせが最も自然である。以下に最小構成を示す。
type Classification string
const (
ClassObserved Classification = "observed"
ClassInferred Classification = "inferred"
ClassUnknown Classification = "unknown"
)
type Evidence struct {
Content string `json:"content"`
Category Classification `json:"category"`
Confidence float64 `json:"confidence"` // 0.0〜1.0
}
// IsReliableは下流処理への採用可否を判定する。
// unknownは常に不可、inferredは閾値未満でも不可。
func (e Evidence) IsReliable() bool {
if e.Category == ClassUnknown {
return false
}
return e.Confidence >= 0.7
}
ここで設計上の重要な判断が2つある。第一に、`unknown`を`nil`やゼロ値ではなく明示的な列挙値として定義している点だ。Goでは`string`型のゼロ値は空文字列であり、これが「分類されていない」と「unknownと判定された」の区別を曖昧にする。明示的な定数にすることで、`switch`文での分岐が網羅的になり、`default`ケースで未分類の値を検知できる。
第二に、`Confidence`を`float64`で保持し、`IsReliable()`のような判定メソッドを構造体に束縛している点である。閾値(ここでは0.7)をメソッド内に置くことで、呼び出し側が「信頼できるか」を判断するロジックを分散させず、ポリシーの変更時に一箇所だけ修正すれば済む。もし閾値を呼び出し側に散在させると、システム全体で信頼性の定義が不整合になりかねない。
バリデーションのフローは以下のようになる。LLMから返されたJSON文字列をパースし、カテゴリの妥当性を確認し、信頼性チェックを通過したものを最終構造体として下流に渡す。不合格のものは`unknown`として格納し、呼び出し元が明示的にハンドリングすることを強制する。
flowchart LR
A["LLM出力"] --> B["JSONパース"]
B --> C["カテゴリ分類"]
C --> D{"信頼性チェック"}
D -->|合格| E["最終構造体"]
D -->|不合格| F["unknownとして格納"]
このフローにおいて、`JSONパース`の失敗と`信頼性チェック`の失敗は性質が異なる。前者は形式の不正であり、修復試行やエラー表示が適切である。後者は意味的な不確実性であり、エラーとして扱うと「unknown」という正当な出力が例外として握りつぶされるリスクがある。Goではこの2つを異なるエラー型(または異なる戻り値の形)で区別し、呼び出し側が文脈に応じた処理を行えるように設計するのが基本方針となる。
オフライン環境でのデータ整合性とIndexedDBの活用
WildProofはReactとTypeScriptで構築されたPWAであり、IndexedDBを使用してオフラインでも証拠を保存できる(WildProofリポジトリ)。この設計は、フィールドワークや野外調査のようにネットワーク接続が不安定な環境でAIエージェントを動かす際の重要な参考になる。
Goバックエンドと連携する際の設計判断は、クライアントサイドの永続化とサーバーサイドの同期を明確に分離することである。両者を一体化すると、通信途絶時にクライアントのメモリ上データが失われるリスクが生じる。IndexedDBのようなローカル永続化層を挟むことで、ネットワークの有無にかかわらずデータが保全される。
同期プロセスの設計では、オフライン中に収集した各エントリにローカルでハッシュ値(例: SHA-256)を付与し、接続回復後にサーバー側と差分比較を行う方式が有効である。これにより、データの改ざん検知と整合性維持を同時に実現できる。衝突が検出された場合は、タイムスタンプやシーケンス番号で優先順位を判断し、必要に応じて人間による裁定をトリガーする。
このパターンは、IoTデバイスやセンサーネットワークなど、安定した通信が保証されない環境でのAIエージェント実装にそのまま適用できる。重要なのは、オフライン状態を「異常」ではなく「通常の運用モードの一つ」として設計に組み込むことである。オフライン期間中に蓄積されたデータ量や、同期時の衝突頻度をメトリクスとして収集しておくと、運用上のボトルネックを早期に発見できる。
分析パイプラインの設計:テキストとメタデータの活用
WildProofの分析パイプラインは、Ollama経由のGemma 2Bに画像ピクセルではなく、テキストノートとメタデータを入力として渡す(DEVの解説記事)。これは計算コストとプライバシーの両面で合理的な選択であり、Goエージェントの設計にも直接適用できる原則である。
Goエージェントで生データ(画像、センサー値など)をLLMに直接送る設計は、以下の理由で避けるべきである。第一に、トークン数が膨大になりコストが跳ね上がる。第二に、生データにはノイズが混入しやすく、LLMの推論精度をむしろ低下させる。第三に、個人情報が含まれる生データを外部APIに送信するリスクがある。
推奨されるアプローチは、生データを事前処理して構造化テキストに変換し、メタデータを付加してLLMに渡すことである。メタデータには日時、位置情報、デバイス状態、前回の観測結果などが含まれ、LLMが文脈に基づいた推論を行えるよう支援する。前処理サービスの出力形式は、前述の3分類(observed / inferred / unknown)にマッピング可能な構造体にしておくと、下流の検証層との結合が簡潔になる。
sequenceDiagram
participant C as クライアント
participant P as 前処理サービス
participant L as LLM (Gemma 2B)
participant V as 検証層
C->>P: 生データ(ノート・センサー値)
P->>P: テキスト化・メタデータ付加
P->>L: 構造化テキスト + メタデータ
L->>V: 推論結果(JSON)
V->>V: スキーマ検証・カテゴリ分類
V->>C: 検証済み結果
このフローの各段階で失敗が起き得ることを前提に、前処理サービスはべき等(idempotent)に設計する。同一入力を何度処理しても同じ出力が得られるようにすることで、再試行時のデータ整合性を保証できる。特にメタデータ中のタイムスタンプは、前処理実行時刻ではなくデータ収集時刻を固定値として埋め込むことで、べき等性を維持しつつ追跡可能性を確保する。
Temporalワークフローによる堅牢な再試行とエラーハンドリング
WildProofはTemporalワークフローを使用して、モデルの失敗時に証拠を破棄せずに分析活動を再試行する(DEVの解説記事)。この設計は、AIパイプラインの信頼性確保において決定的に重要である。LLMの出力は本質的に非決定的であり、同一プロンプトでも異なる結果が返ってくる。一回の失敗で証拠を破棄すると、フィールドワークで収集した貴重なデータが失われる。
GoでTemporal SDKを用いて実装する場合、ワークフローを以下のように分割する。各アクティビティ(LLM呼び出し、検証、スコアリング)を独立したActivityとして定義し、ワークフロー本体はそれらを順序制御する役割に限定する。これにより、特定のアクティビティの再試行が他のアクティビティの完了状態に影響しない。
stateDiagram-v2
[*] --> Pending
Pending --> Running: ワークフロー開始
Running --> Success: 検証合格
Running --> Fail: 検証失敗
Fail --> Running: 再試行(バックオフ)
Success --> [*]: 証拠保存
Fail --> [*]: 最大再試行超過
再試行ポリシーでは指数バックオフを採用する。初期遅延を数秒、最大遅延を数分程度に設定し、最大試行回数を上限で切る。一時的なネットワーク障害やレートリミットにはこの方式で対応でき、永続的なエラー(モデルが応答しない、スキーマが根本的に不適合など)は最大回数を超過した時点で明示的に失敗としてワークフローを終了させる。
ここで重要な設計判断は、「再試行しても結果が変わらない可能性が高い」エラーをどう扱うかである。例えば、LLMが常に同一の不正なJSONを返す場合は、再試行しても同じ結果になる。Temporalの再試行ポリシーには最大試行回数を設けるだけでなく、Activity側で「このエラーは再試行しても意味がない」と判断できる場合は、Temporal SDKが再試行しないようマークして返す。これにより、無意味な再試行によるリソース浪費を防げる。具体的には、検証層が「スキーマの構造自体が不適合」と判断した場合は再試行不可とし、「JSONの形式エラー(欠落カンマ等)」の場合は再試行可能とする、という粒度で分類するのが実務的に有効である。
証拠品質スコアリング:ヒューリスティックと機械学習の融合
WildProofでは、証拠の品質をスコアリングする際に、写真数やノート長などの特徴量を用いたベースラインヒューリスティックが採用されている(WildProofの解説記事)。このアプローチの利点は、解釈可能性が高く、実装コストが低いことである。スコアの各構成要素が何に基づいているか人間が即座に理解できるため、障害時のデバッグや閾値の調整が容易になる。
Goエージェントでも同じ原則が適用できる。まず、収集された証拠の特徴量(テキストの文字数、メタデータの完全性、収集時刻の妥当性など)を数値化し、加重和や単純な閾値判定で品質スコアを算出する。このスコアは「observed / inferred / unknown」の分類と併せて出力することで、ダウンストリームシステムが「信頼できる推論結果か」「人間介入が必要か」を判断する材料となる。
より高度な分析には、TabPFNのような小規模な表データ向けモデルも検討できる。WildProofの実装では、TabPFNが利用できない場合に明示的にラベル付けされたフォールバックを使用する設計になっている(WildProofのGitHubリポジトリ)。この「利用可否を明示的に表現する」パターンは、Go実装においても重要である。モデルが利用できないことをエラーで握りつぶすのではなく、`ScoreMethod` フィールドに `"heuristic_fallback"` のような値を格納し、結果の信頼性をメタデータとして伝達する。
どちらの手法を選ぶかは、データ量と要件の明確さに依存する。以下に比較を示す。
| 手法 | 向いている場面 | 注意点 |
|---|---|---|
| ヒューリスティック(ルールベース) | データ量少・要件が明確・即時デプロイ | 特徴量の選択がスコア品質を左右する |
| MLベース(TabPFN等) | 特徴量間の非線形な相関が疑われる場合 | 利用不可時のフォールバック設計が必須 |
実務的には、ヒューリスティックで始め、スコアと人間判断の不一致が蓄積してきた時点でMLベースへの移行を検討するのが合理的な順序である。
実験と検証:合成データを用いた精度比較の実践
WildProofのリポジトリには、api/tabpfn_experiment.py というファイルが含まれており、150サンプルの合成データを用いてベースラインヒューリスティックとTabPFNの精度を比較する実験が行われている(tabpfn_experiment.py)。150サンプルという規模は、大規模な統計的有意性を保証するものではないが、手法間の相対的な傾向を把握する上では実用的なサイズである。
Goエージェント開発においても、検証パイプラインを本番投入前に合成データでテストするプロセスは不可欠である。特にLLM出力の検証ロジックは、入力パターンの多様性に対して正しく動作するかを確認する必要がある。具体的には、以下のケースをテストスイートに含めることを推奨する。
- 完全に正しいJSON(全フィールドが正しく埋まった状態)
- 一部フィールドが欠落しているJSON(スキーマ違反)
- カテゴリ分類が矛盾している場合(例:
inferredなのに確信度フィールドが空) - LLMがJSONではなく自然言語を返した場合(構造的な失敗)
合成データを用いる利点は、LLMの非決定的な出力を再現可能なテストケースに変換できる点にある。実際のLLM応答をキャプチャしてテストフィクスチャとして保存する(ゴールデンファイルテスト)手法と組み合わせることで、検証ロジックの回帰を防止できる。
新しい検証アルゴリズムを導入する際のリスク管理では、まず合成データで精度比較を行い、ベースラインに対して劣化していないことを確認してから段階的に本番に反映する。この実験的アプローチは、アルゴリズム変更時の意思決定をデータに基づいたものにする。
実装パターン:Go言語での検証ロジックとワークフロー統合
Goでの実装では、json.Unmarshal 後のバリデーションを独立した関数として切り出し、再利用可能なコンポーネントとする。これにより、Temporalのアクティビティ内でも、単体テストでも同じ検証ロジックを共有できる。
package validation
import (
"fmt"
)
type Classification string
const (
ClassObserved Classification = "observed"
ClassInferred Classification = "inferred"
ClassUnknown Classification = "unknown"
)
type Evidence struct {
Content string `json:"content"`
Category Classification `json:"category"`
Confidence float64 `json:"confidence"` // 0.0〜1.0
}
type Report struct {
Items []Evidence `json:"items"`
}
// ValidateReport はJSONパース後の構造的・意味的検証を行う。
// 検証失敗時はエラーを返す(unknownはエラーではない)。
func ValidateReport(r *Report) error {
for i, e := range r.Items {
if e.Category != ClassObserved &&
e.Category != ClassInferred &&
e.Category != ClassUnknown {
return fmt.Errorf("items[%d]: invalid category %q", i, e.Category)
}
if e.Category == ClassInferred && e.Confidence == 0 {
return fmt.Errorf("items[%d]: inferred evidence requires non-zero confidence", i)
}
}
return nil
}
Temporalとの統合では、LLM呼び出しと検証をそれぞれ独立したアクティビティとして定義する。検証失敗を通常の業務ロジックエラーとして扱えば、ワークフローレベルで再試行またはフォールバックをトリガーできる。
package workflow
import (
"encoding/json"
"fmt"
"go.temporal.io/sdk/workflow"
"github.com/your-org/agent/validation" // 適切なインポートパスに置き換えてください
)
func AnalysisWorkflow(ctx workflow.Context, question string) (*validation.Report, error) {
var rawOutput string
err := workflow.ExecuteActivity(ctx, CallLLM, question).Get(ctx, &rawOutput)
if err != nil {
return nil, err
}
var report validation.Report
if err := json.Unmarshal([]byte(rawOutput), &report); err != nil {
return nil, fmt.Errorf("json parse: %w", err)
}
if err := validation.ValidateReport(&report); err != nil {
// 検証失敗は再試行可能な業務エラーとして返す
return nil, err
}
return &report, nil
}
この設計の要点は、検証失敗を「システムエラー」ではなく「LLMの出力が期待通りになかった」という業務的な失敗として扱ることである。Temporalの再試行ポリシーが適用されることで、一時的なLLMの不安定な出力に対して自動的にリカバリが可能になる。一方、前節で述べた「再試行しても意味がない」エラー(スキーマ構造の根本的不適合)は、アクティビティ側で temporal.NewNonRetryableApplicationError を返すことで、無意味な再試行を回避する。
運用と拡張:スケーラビリティと保守性のバランス
前節までに示した検証ロジックとTemporalワークフローの統合が設計の骨格となるが、実際に運用規模に持ち上げる際には、ストレージの分離やモデル変更への耐性といった追加の設計判断が求められる。以下では、サーバーサイドの運用観点からこれらの課題に対するアプローチを整理する。
ストレージ層の分離と移行戦略
具体的には、Temporalワークフローは「処理の進行状態」のみを管理し、証拠データ本体(テキストノート、メタデータ、スコアリング結果)は外部のデータベースに格納する。Temporalのアクティビティは証拠のIDを引数として受け取り、必要に応じて外部DBから読み出す。この分離により、Temporalのイベント履歴サイズが証拠データに比例して肥大化する問題を回避できる。
移行のタイミングを判断する指標としては、Temporalのイベント履歴1万件あたりのサイズがアプリケーションの許容範囲を超えた時点で、ストレージ層の外部化を検討するのが実務的な基準となる。初期段階から外部DBを前提に設計しておけば、移行時にワークフローの再実装は不要で、アクティビティのデータアクセス層のみを差し替えるだけで済む。
モデルアップグレードへの耐性
WildProofの分析にはOllama経由のGemma 2Bが使用されている(DEVの解説記事)。モデルをアップグレードする場合、出力の形式や品質が変化することは避けられない。ここで前述の型システムが効果を発揮する。`Classification`列挙型や`Confidence`レベルはモデルに依存しない抽象的な契約であり、モデル側が出力する生テキストをこの契約にマッピングするアダプタ層を設けておけば、モデル変更時の影響範囲をマッピング関数に限定できる。
運用面上で重要なのは、検証ロジックのパフォーマンスと精度を継続的にモニタリングする指標の定義である。具体的には、検証失敗率(LLM出力がスキーマに適合しなかった割合)、`unknown`分類の割合、再試行回数などのメトリクスをワークフローレベルで収集し、モデルアップグレード前後で比較可能にしておく。これにより、「モデルを変えた結果、推論の信頼性が低下した」ことを定量的に検知できる。
まとめ:不確実性を許容し、制御するAIシステム設計
本記事では、WildProofの設計思想をGo言語の型システムとTemporalワークフローに転用する具体的なパターンを提示した。中心的なアイデアは、LLMの出力を「正しい/誤り」の二値で判定するのではなく、observed・inferred・unknownの3分類に強制マッピングし、不確実性をデータ構造として明示的に表現する点にある。
このアプローチが実務で効力を発揮するのは、下流システムが「どの程度の確信でこの結果を扱ってよいか」を判断材料として得られるからである。従来のJSONスキーマ検証(形式の正しさの確認)と本質的に異なるのは、意味的な不確実性まで検証対象に含める点である。その結果、リスクレベルに応じた人間介入や追加調査のトリガーが、システム設計の一部として組み込めるようになる。
Temporalのワークフローによる再試行メカニズムは、LLMの非決定的な挙動に対してシステム全体が耐性を持つことを保証する。証拠を破棄せずに分析を再試行できる(DEVの解説記事)という設計は、AIパイプラインの信頼性において単なる利便性ではなく、データ喪失を防ぐための必須要件である。
最後に、設計上の判断基準を整理しておく。`unknown`をエラーとして扱うか、それとも第一級の値として扱うかは、システム全体の信頼性を左右する分かれ目である。エラーとして扱えば、呼び出し元が必ずハンドリングを強制されるため安全だが、同時に「何かが壊れた」という誤ったシグナルを下流に送ることになる。一方、第一級の値として扱えば、下流が文脈に応じて判断できるが、ハンドリングを忘れた場合に静かに無視されるリスクがある。Goの型システムでは、`*Evidence`(ポインタ)と`Evidence`(値型)の使い分け、あるいは独自のパッケージで`Unknown`を専用の型として定義することで、このトレードオフを明示的に設計できる。自身のシステムが「安全に失敗する」べきか「不確実性を伝播させる」べきか、その判断を最初に下すことが、信頼性の高いAIエージェント設計の出発点となる。
関連記事
- AIエージェントの「無視された失敗」をCIで検知:tracelintを用いた決定論的品質ゲートの設計
- AIエージェントの暴走を防ぐ:while(true)ループの限界とEventStoreによる状態管理の実践
- AIエンドポイントの設計:確率的出力と従来のWeb APIの違いを理解する
参考
本記事は海外の技術トレンド「WildProof: Go Outside With a Question, Come Back With Evidence」(DEV)で話題のテーマをもとに、両儀システムソリューションズが独自に解説したものです。