2014年から個人で iOS / Android アプリを運営してきて、累計5,000万ダウンロードという規模に育つ過程で何度も痛い思いをしたことがあります。それは「半年前まで正しかった答えが、ある日いつの間にか間違っている」という事象です。AdMob のポリシー、App Store の審査ガイドライン、Google Play のターゲット API レベル、これらは静かに更新されていきます。
Antigravity でエージェントを組み立て始めた頃、私は同じ落とし穴に何度かはまりました。エージェントが「2024年11月時点では AdRequest.Builder().build() でテストデバイスを設定するのが推奨です」と自信満々に答えてくる。けれども実装してみると、新しい SDK では別のメソッドに置き換わっています。LLM の知識は止まっているのに、現場のドキュメントは動き続けているのです。
この記事は、その不一致を構造的に解決するための運用設計をまとめたものです。Antigravity エージェントを本番で動かすときに、知識の鮮度をどう扱うかという話を、設計指針と動くコードの両方から見ていきます。
知識の鮮度は三つの時間軸に分解する
最初に整理しておきたいのは、エージェントが扱う知識には少なくとも三つの独立した時間軸があるという事実です。これを混ぜて議論すると、対策が散漫になります。
一つ目はモデル本体のカットオフ日付です。LLM が訓練データを取り込んだ最終日を指し、これより新しい情報はモデルそのものには入っていません。Gemini 2.5 系であればおおむね2025年初頭、Claude 4 系であれば2025年中盤など、モデルごとに固定値が存在します。
二つ目は RAG コーパスの更新日付です。社内ドキュメント・公式マニュアル・自分のコードベースなど、エージェントに参照させる外部知識ベースには、個々のドキュメントごとに「最後に更新された日付」があります。コーパス全体ではなく、ファイル単位、できれば段落単位で持つのが理想的です。
三つ目は実世界の現在時刻です。AdMob のポリシーが今日変わったかもしれありません。Google が来週新しい API を発表するかもしれありません。エージェントの実行時点で、その情報がどの程度有効かを判断する基準時刻です。
この三つを別々に管理しないと、後で「どこが原因で古い答えを返したのか」が追えなくなります。Antigravity のエージェント設計では、最初からこの三軸を別フィールドとして持たせるのが推奨です。
Freshness Oracle というコンポーネントを置く
私が個人開発で使っているパターンは、エージェントとは別に Freshness Oracle と呼ぶ小さなサービスを置く方法です。エージェントが何かを答える前に、参照しようとしている知識ソースの鮮度を Oracle に問い合わせ、判定結果を踏まえて応答方針を決めます。
判定結果は三段階で十分です。fresh(基準内・そのまま使ってよい)、stale(基準を超えた・注意喚起付きで使う)、expired(完全に失効・使ってはいけない)。これをコーパス側のメタデータと、エージェント側のクエリトピックの両方から計算します。
実装はシンプルにできます。TypeScript で書くと、こんな具合です。
// freshness-oracle.ts
export type FreshnessStatus = "fresh" | "stale" | "expired" ;
export interface KnowledgeSource {
id : string ;
topic : string ; // e.g. "admob-policy", "ios-review-guideline"
updatedAt : Date ; // コーパス側の最終更新日
ttlDays : number ; // トピックごとに設定する有効期限
}
export interface FreshnessVerdict {
status : FreshnessStatus ;
ageDays : number ;
ttlDays : number ;
reason : string ;
}
const TOPIC_TTL : Record < string , number > = {
"admob-policy" : 30 , // 広告ポリシーは月単位で変わる
"ios-review-guideline" : 60 , // App Review は2ヶ月単位
"play-target-api" : 90 , // ターゲットAPIレベルは四半期単位
"antigravity-feature" : 14 , // Antigravity 機能変更は2週間単位
"general-tech" : 365 , // 一般的なプログラミング知識
};
export function judgeFreshness (
source : KnowledgeSource ,
now : Date = new Date ()
) : FreshnessVerdict {
const ageMs = now. getTime () - source.updatedAt. getTime ();
const ageDays = Math. floor (ageMs / ( 1000 * 60 * 60 * 24 ));
const ttlDays = TOPIC_TTL [source.topic] ?? source.ttlDays;
if (ageDays > ttlDays * 2 ) {
return {
status: "expired" ,
ageDays,
ttlDays,
reason: `Exceeded TTL by ${ ageDays - ttlDays }d (over 2x)` ,
};
}
if (ageDays > ttlDays) {
return {
status: "stale" ,
ageDays,
ttlDays,
reason: `Past TTL of ${ ttlDays }d by ${ ageDays - ttlDays }d` ,
};
}
return {
status: "fresh" ,
ageDays,
ttlDays,
reason: `Within TTL (${ ageDays }d / ${ ttlDays }d)` ,
};
}
ポイントは TTL をトピックごとに変えていることです。AdMob のポリシーは30日、iOS の審査ガイドラインは60日、ターゲット API レベルは90日、というように、現場の更新頻度から逆算します。私の場合は AdMob 関連が一番短いので30日に設定しています。これは、過去2年で月に1回くらいの頻度で関連情報が更新されてきた実感からの閾値です。
タイムアウェアプロンプトで『今いつなのか』を伝える
Oracle で鮮度を判定したあと、エージェントへのプロンプトに時刻情報を埋め込みます。これをやらないと、モデルは自分のカットオフ日を「今」だと思い込んだまま答えてしまいます。
埋め込む情報は三つです。実行時刻(YYYY-MM-DD HH:MM JST 形式)、参照しているドキュメントの最終更新日、そして Freshness Oracle の判定結果。これをシステムメッセージの先頭に固定で入れます。
// time-aware-prompt.ts
import type { FreshnessVerdict } from "./freshness-oracle" ;
export function buildSystemPrompt (
now : Date ,
source : { id : string ; topic : string ; updatedAt : Date },
verdict : FreshnessVerdict
) : string {
const nowStr = now. toLocaleString ( "ja-JP" , { timeZone: "Asia/Tokyo" });
const sourceDate = source.updatedAt. toISOString (). slice ( 0 , 10 );
const guardClause =
verdict.status === "expired"
? `⛔ 失効: このソースは ${ verdict . ageDays }日前のもので、TTL ${ verdict . ttlDays }日を大幅に超えています。回答に使わず、ユーザーに最新情報の確認を促してください。`
: verdict.status === "stale"
? `⚠️ 鮮度低下: このソースは ${ verdict . ageDays }日前のもので、TTL ${ verdict . ttlDays }日を超えています。回答に使う場合は『参照時点では〜』と明示し、最新確認を促してください。`
: `✅ 鮮度OK: このソースは ${ verdict . ageDays }日前のもので、TTL ${ verdict . ttlDays }日内です。` ;
return [
`### 時間コンテキスト` ,
`- 現在時刻: ${ nowStr }` ,
`- 参照ソース: ${ source . id } (${ source . topic })` ,
`- ソース最終更新: ${ sourceDate }` ,
`` ,
guardClause,
`` ,
`### 回答方針` ,
`- 上記の時間コンテキストを必ず踏まえて回答してください` ,
`- 訓練データのカットオフより新しい事実を断定的に語らないでください` ,
`- 判断に迷う場合は『要確認』と明示してください` ,
]. join ( " \n " );
}
私が個人開発で気づいたのは、この guardClause を入れる前と後でエージェントの振る舞いがはっきり変わるということです。入れる前は「2026年5月時点では〜」と言い切ってしまうことが多かったのですが、入れた後は「ソース最終更新が2025年12月なので、それ以降の変更は別途確認が必要です」という慎重な答え方に切り替わります。これは Gemini 2.5 Pro でも Claude 4.5 Sonnet でもおおむね同じ傾向でした。
RAG コーパスのライフサイクルを設計する
Freshness Oracle が機能するのは、コーパス側に正しいメタデータが入っているからです。逆に言うと、コーパスのライフサイクル管理をサボると、Oracle はゴミを掴まされて誤判定します。
私が運用しているコーパスでは、以下のメタデータを必ずドキュメント単位で持たせています。source_url、fetched_at(取得日時)、source_published_at(ソース側の公開日)、topic、ttl_override(個別に上書きしたい場合)、evidence_hash(中身のハッシュ)。
ライフサイクルは四段階で考えると整理しやすいです。
取り込み(Ingest) : ソースから取得し、メタデータを付けて保存する
検証(Validate) : 取得元 URL が依然として生きているか、内容ハッシュが変わっていないかを定期確認する
更新(Refresh) : TTL に達したら自動で再取得します。再取得時に内容が変わっていれば evidence_hash を更新し、変わっていなければ fetched_at だけ更新する
失効(Retire) : ソース URL が404になった、公式に廃止された、TTLの2倍を超えても更新されない、のいずれかで失効扱いにする
// corpus-lifecycle.ts
export interface CorpusDoc {
id : string ;
sourceUrl : string ;
topic : string ;
fetchedAt : Date ;
sourcePublishedAt : Date ;
evidenceHash : string ;
status : "active" | "retired" ;
}
export async function refreshIfStale (
doc : CorpusDoc ,
fetcher : ( url : string ) => Promise <{ body : string ; publishedAt ?: Date } | null >,
now : Date = new Date ()
) : Promise <{ doc : CorpusDoc ; changed : boolean ; retired : boolean }> {
const result = await fetcher (doc.sourceUrl);
if (result === null ) {
return {
doc: { ... doc, status: "retired" },
changed: false ,
retired: true ,
};
}
const newHash = await sha256 (result.body);
const changed = newHash !== doc.evidenceHash;
return {
doc: {
... doc,
fetchedAt: now,
sourcePublishedAt: result.publishedAt ?? doc.sourcePublishedAt,
evidenceHash: newHash,
},
changed,
retired: false ,
};
}
async function sha256 ( text : string ) : Promise < string > {
const enc = new TextEncoder (). encode (text);
const buf = await crypto.subtle. digest ( "SHA-256" , enc);
return Array. from ( new Uint8Array (buf))
. map (( b ) => b. toString ( 16 ). padStart ( 2 , "0" ))
. join ( "" );
}
この実装で大事なのは、内容が変わっていなければ evidence_hash を更新しないことです。これにより、いつ実質的な内容変更があったかを履歴として追えます。私のコーパスでは、AdMob ポリシー文書については過去18ヶ月で4回の hash 変更が記録されていて、いずれも自分のアプリ運用に影響する内容でした。
失敗パターンを観測する
知識鮮度が運用上どう壊れるかは、いくつか定型パターンがあります。Antigravity で本番運用していると、以下の三つはほぼ確実に遭遇します。
一つ目は 静かな陳腐化 です。ソース URL は変わらず、内容も微妙にしか変わっていないのに、その「微妙な変化」が実装に致命的な影響を与えるケース。AdMob の SDK バージョン要件などはこのパターンの典型例で、文書全体を読み比べないと気づけません。evidence_hash の変更検出が効きます。
二つ目は トピックずれ です。エージェントが扱おうとしているトピックが、コーパス側に存在しないか、存在しても TTL 設定が不適切なケース。たとえば「Antigravity の新機能リリース」というトピックは更新頻度が高いのに、general-tech の365日 TTL で扱われてしまうと、致命的に古い情報で答えてしまいます。Oracle のログを定期的に見て、トピック分類が正しく当たっているかを監視します。
三つ目は 沈黙の参照不能 です。ソース URL が404になっていて、fetched_at だけ更新されてしまうケース。これは fetcher の戻り値を厳密に判定しないと見落とします。私の実装では、HTTP ステータスが200以外、Content-Length が極端に小さい、Last-Modified ヘッダが極端に古い、のいずれかで retired 扱いに落とします。
観測ダッシュボードに乗せる指標
Antigravity エージェントを本番で運用するなら、以下の指標を Crashlytics 風のダッシュボードで毎日見ることを推奨します。私が個人開発で実際に追っている数字です。
平均ソース年齢 : 直近24時間に参照されたコーパスドキュメントの ageDays の中央値
stale 比率 : 同期間中の参照のうち、Freshness Oracle が stale と判定した割合(目標: 5%以下)
expired 参照率 : 同 expired と判定された割合(目標: 0%。1%でも出たらアラート)
TTL 違反トップ10 : 直近7日で最も多く TTL を超えたトピックのランキング
hash 変化検出回数 : 同期間で evidence_hash が変わったドキュメント数
私のアプリ事業では、AdMob トピックの hash 変化検出回数を週次で見ています。ここが急に増えた週は、たいてい AdMob 側に大きな仕様変更があったタイミングで、エージェントの応答にも注意が必要な期間です。
運用チェックリスト
最後に、Antigravity エージェントを本番に乗せる前に確認するチェックリストを置いておきます。私が個人開発で実際に使っているものです。
すべてのコーパスドキュメントに topic と fetched_at が入っているか
トピックごとの TTL を TOPIC_TTL に定義してあるか(少なくともプロダクト固有のトピックは独自設定)
Freshness Oracle の判定が expired のとき、エージェントの応答経路でブロックされているか
システムプロンプトに現在時刻・ソース日付・guardClause が必ず注入されているか
コーパスの再取得ジョブが TTL の半分の間隔で回っているか
evidence_hash の変更履歴が記録されているか
ダッシュボードに上記5指標が出ているか、expired 比率にアラートが設定されているか
このうち1〜4ができていれば、まず「自信満々に古い情報を答える」事故は大きく減ります。5〜7まで揃えば、コーパスの陳腐化に対して能動的に動けるようになります。
知識の鮮度は、性能や精度ほど派手な話題にはなりませんが、長く運用するエージェントの信頼性を地味に支える土台です。アプリを2014年から運用してきた経験から言うと、こういう「目立たない設計判断」を最初に入れておくかどうかで、半年後・1年後の運用負荷がまったく変わります。