サポート問い合わせのログを眺めていて、手が止まりました。
壁紙アプリの問い合わせ窓口です。同じ利用者のプロファイルに billing_plan: pro と記録されているのに、当人は「無料のままです」と書いてきています。App Store 側の購読状況を照会すれば済む話ではありますが、そもそも記録が信じられないなら、プロファイルを持つ意味が半分なくなります。記録の更新日時を見ると3週間前。3週間前には確かに Pro でしたし、その時の記録は本人の明言に基づいた確度の高いものでした。
つまり、間違った値が入っていたわけではありません。正しかった値が、正しかったまま古くなっていた のです。
Memory Bank の Memory profiles が一般提供へ移ったのは 2026年8月1日の changelog でのことでした。静的なスキーマを定義しておくと、モデルがそれを埋めて更新してくれる。自由記述の記憶より後段の処理で扱いやすい形になる。読んだときは素直に嬉しかったのですが、実際に組み込む段になって、いちばん難しいのはスキーマの形ではなく更新をいつ受け入れ、いつ拒むか だと分かってきました。
その判断を勘で決めるのが嫌だったので、手元に小さな比較ハーネスを書いて測りました。結果は予想と逆でした。
構造化が持ち込んだのは、記憶の形式ではなく更新の責任
自由記述の記憶と Memory profiles の差は、しばしば「文章か JSON か」として語られます。ただ、運用してみると本質はそこではありませんでした。
自由記述のメモリは、矛盾をそのまま抱えられます。「7月上旬には Pro だと述べていたが、7月下旬には無料だと述べている」という2行を並べて置いておけば、読む側のモデルが文脈で判断してくれます。冗長ですが、判断を先送りできる余地がある。
構造化プロファイルには、その逃げ場がありません。billing_plan というフィールドは1つの値しか持てないため、書き込みの瞬間に「どちらが正しいか」を決め切る 必要があります。この決定を誰がどう下すかを設計しないまま構造化すると、最後に書き込まれた値がそのまま正解として下流へ流れていきます。
私自身、最初はここを軽く見ていました。スキーマさえ丁寧に切れば、あとはモデルが埋めてくれると考えていたのです。
確度をスキーマに持たせる
そこでまず、値そのものだけでなく「その値がどこから来たか」をスキーマに入れました。
{
"type" : "object" ,
"properties" : {
"billing_plan" : {
"type" : "object" ,
"properties" : {
"value" : { "type" : "string" , "enum" : [ "free" , "pro" , "ultra" ] },
"confidence" : { "type" : "string" , "enum" : [ "confirmed" , "inferred" ] },
"observed_at" : { "type" : "string" , "format" : "date-time" },
"source" : { "type" : "string" , "description" : "発話ID または推論の根拠" }
},
"required" : [ "value" , "confidence" , "observed_at" ]
}
}
}
confirmed は本人が明示的に述べた値、inferred はモデルが文面から推測した値です。この区別さえ入れておけば、あとは「推測値で確定値を上書きしない」というガードを書けば安全だろう。そう考えたのが、この記事で覆される仮説でした。
まず注入トークンを測る
ポリシーの話に入る前に、そもそも構造化がどれだけ効くのかを確かめました。20フィールドのプロファイルと、同じ20件の事実を文章で保持した自由記述メモリを、それぞれトークン化して比べます。
計測には cl100k_base を使いました。Gemini のトークナイザとは異なるため、絶対値ではなく比率として読んでください 。日本語混じりのテキストにおける相対的な傾向は十分に見えます。
import json, tiktoken
enc = tiktoken.get_encoding( "cl100k_base" )
tok = lambda s: len (enc.encode(s))
fields = {
"primary_language" : "Swift" , "target_platform" : "iOS" ,
"billing_plan" : "pro" , "preferred_locale" : "ja" ,
"build_tool" : "Xcode" , "notify_channel" : "push" ,
"team_size" : "1" , "ci_provider" : "Xcode Cloud" ,
"min_os_version" : "iOS 26" , "analytics_tool" : "App Store Connect" ,
"paywall_style" : "hard" , "review_prompt_timing" : "3回目起動" ,
"asset_pipeline" : "Figma→SwiftGen" , "crash_tool" : "Crashlytics" ,
"localization_langs" : "ja,en" , "monetization" : "サブスク" ,
"test_device" : "iPhone 15" , "design_system" : "独自" ,
"backend" : "Cloudflare Workers" , "push_provider" : "APNs" ,
}
profile = json.dumps(fields, ensure_ascii = False , separators = ( "," , ":" ))
def freeform (history_depth):
"""同じ20件を文章で保持し、更新履歴を history_depth 回分ぶら下げる"""
out = []
for k, v in fields.items():
out.append( f "- ユーザーの { k } は { v } です。" )
for i in range (history_depth):
out.append( f " ( { i + 1 } 回前の会話では別の値を述べていましたが、上記が最新です)" )
return " \n " .join(out)
print ( f "profile: { tok(profile) } tok" )
for h in ( 0 , 1 , 2 , 3 ):
f = tok(freeform(h))
print ( f "freeform(history= { h } ): { f } tok ratio= { f / tok(profile) :.1f } x" )
手元での出力は次のとおりです。
形式 トークン数 プロファイル比
構造化プロファイル(20フィールド) 137 1.0x
自由記述・履歴なし 296 2.2x
自由記述・履歴1回分 896 6.5x
自由記述・履歴2回分 1,496 10.9x
自由記述・履歴3回分 2,096 15.3x
履歴を持たない自由記述との差は 2.2倍にとどまります。派手な数字ではありません。差が開くのは、更新履歴が積み上がったとき です。
Gemini 3.6 Flash の入力単価 $1.50 per Mtok で、1日1,000リクエストを30日回した場合を試算すると、137トークンなら月 $6.17、1,496トークンなら月 $67.32。差は約 $61 です。個人開発の規模だと無視できない額になってきます。
ただ、この差は「構造化が偉い」という話ではありません。自由記述側も定期的に要約して圧縮すれば近づきます。むしろ圧縮を自前で運用し続けなくてよくなる ことが、Memory profiles を選ぶ実務上の理由でした。
更新ポリシーを3種類つくって、同じ条件でぶつける
ここからが本題です。プロファイルへの書き込みを、次の3ポリシーで比較しました。
naive — 最後に書き込まれた値を常に採用する(last-write-wins)
guard — 確定値が入っているフィールドを、推測値で上書きしない
guard + TTL — guard を基本にしつつ、最終更新から一定ターン数が過ぎた確定値は推測値でも更新を許す
評価は単純です。シミュレーション終了時点で、プロファイルの各フィールドが「真の現在値」と一致している割合を測ります。真の値は一定確率で時間とともに変化させ、発話の25%を確定値、残りを推測値としました。推測値は20%の確率で真の値から外れます。
import random
VALUES = list ( "abcd" )
CONFIRM_P = 0.25 # 発話が明示的な確定値である確率
NOISE = 0.20 # 推測値が真の値から外れる確率
def run (policy, ttl, change_p, seed, n_keys = 20 , turns = 2000 ):
r = random.Random(seed)
keys = [ f "f { i :02d } " for i in range (n_keys)]
truth = {k: r.choice( VALUES ) for k in keys}
# store[k] = [値, 確定フラグ, 最終更新ターン]
store = {k: [truth[k], 1 , 0 ] for k in keys}
for t in range ( 1 , turns + 1 ):
# 世界のほうが勝手に変わる
for k in keys:
if r.random() < change_p:
cur = truth[k]
truth[k] = r.choice([v for v in VALUES if v != cur])
k = r.choice(keys)
if r.random() < CONFIRM_P :
v, confirmed = truth[k], 1
else :
v = truth[k] if r.random() > NOISE else \
r.choice([x for x in VALUES if x != truth[k]])
confirmed = 0
cur = store[k]
if policy == "naive" :
store[k] = [v, confirmed, t]
elif policy == "guard" :
if not (cur[ 1 ] == 1 and confirmed == 0 ):
store[k] = [v, confirmed, t]
elif policy == "guard_ttl" :
stale = (t - cur[ 2 ]) > ttl
if stale or not (cur[ 1 ] == 1 and confirmed == 0 ):
store[k] = [v, confirmed, t]
hit = sum ( 1 for k in keys if store[k][ 0 ] == truth[k])
return hit / len (keys)
def avg (policy, ttl, change_p, trials = 40 ):
return sum (run(policy, ttl, change_p, s) for s in range (trials)) / trials * 100
for change_p, label in (( 0.001 , "変わりにくい" ), ( 0.004 , "標準" ), ( 0.016 , "変わりやすい" )):
print ( f "== change_p= { change_p } ( { label } ) 事実の平均寿命≈ { 1 / change_p :.0f } ターン ==" )
print ( f " naive { avg( 'naive' , None , change_p) :.1f } % "
f "guard { avg( 'guard' , None , change_p) :.1f } %" , end = "" )
for ttl in ( 50 , 100 , 200 , 400 ):
print ( f " TTL { ttl } : { avg( 'guard_ttl' , ttl, change_p) :.1f } %" , end = "" )
print ()
change_p は「そのフィールドの事実が1ターンあたりに変わる確率」です。0.001 なら平均寿命はおよそ1,000ターン、0.016 ならおよそ62ターンになります。前者は使用言語や対象プラットフォームのような腰の重い属性、後者は課金状態や現在取り組んでいる作業のような移ろいやすい属性を想定しています。
このハーネスは Gemini API を呼びません。測っているのは自分が書いたマージ層の振る舞い であって、モデルの記憶精度ではない点は正直に書いておきます。それでも、ポリシーの優劣を勘で決めるよりはるかに確かな材料になりました。
結果 — 守ろうとした手が、いちばん外した
実行結果です。
事実の変わりやすさ naive guard TTL 50 TTL 100 TTL 200 TTL 400
変わりにくい(寿命≈1,000ターン) 83.7% 93.0% 90.4% 92.1% 93.0% 93.0%
標準(寿命≈250ターン) 77.9% 78.5% 82.0% 81.8% 81.0% 78.6%
変わりやすい(寿命≈62ターン) 68.9% 52.5% 66.5% 60.0% 54.8% 53.0%
いちばん下の行で手が止まりました。
変わりやすいフィールドでは、guard が naive を 16.4ポイント下回っています。 68.9% から 52.5% へ。確定値を守るという、どう考えても正しそうな防御が、精度をはっきり悪化させていました。
理屈は後から追えます。確定値は一度書き込まれると、次に本人が明言するまで固定されます。発話の25%しか確定値でない設定なので、平均すれば数十ターンに1回しか更新の機会が来ません。ところが事実のほうは62ターンで入れ替わる。守っているつもりの値が、守っている間に賞味期限を過ぎている わけです。
冒頭の billing_plan: pro は、まさにこれでした。記録は正しく、防御も効いていて、それでも答えは外れていた。
そして TTL の効き方も直感に反していました。変わりやすいフィールドでは TTL を短くするほど良く、TTL 400 まで伸ばすと guard 単体(52.5%)とほとんど変わらない 53.0% に落ちます。一方で変わりにくいフィールドは逆で、TTL 50 だと 90.4% と、guard 単体の 93.0% より悪化します。せっかくの確定値を早すぎるタイミングで手放してしまうためです。
つまり TTL に「安全な既定値」は存在しません 。フィールドが持つ事実の寿命に合わせるしかない。
フィールドごとに寿命を持たせる
現実のプロファイルは、寿命の異なるフィールドが1枚に同居しています。そこで安定8・標準6・揮発6の計20フィールドを混在させ、単一TTLとフィールド別TTLを比べ直しました。フィールド別TTLは、そのフィールドの平均寿命の約1/4を目安に置いています(安定=200、標準=50、揮発=15ターン)。
ポリシー 正解率 naive比
naive(last-write-wins) 76.0% —
guard のみ 76.0% ±0.0pt
guard + 単一TTL 50 79.5% +3.5pt
guard + 単一TTL 100 79.3% +3.3pt
guard + 単一TTL 200 77.3% +1.3pt
guard + フィールド別TTL 81.8% +5.8pt
混在させた途端、guard 単体の優位が消えます。安定フィールドでの利得と揮発フィールドでの損失が相殺して、naive と同じ 76.0% に着地しました。プロファイル全体をひとつのポリシーで扱うかぎり、平均は良くならない ということです。
フィールド別TTLにすると 81.8%。単一TTLの最良値(79.5%)をさらに 2.3ポイント上回りました。
そこでスキーマに寿命の宣言を足しました。
FIELD_POLICY = {
# 腰の重い属性: 本人の明言を長く信じる
"primary_language" : { "ttl_turns" : 200 , "guard" : True },
"target_platform" : { "ttl_turns" : 200 , "guard" : True },
"min_os_version" : { "ttl_turns" : 200 , "guard" : True },
# 中間: 数十ターンで見直す
"build_tool" : { "ttl_turns" : 50 , "guard" : True },
"ci_provider" : { "ttl_turns" : 50 , "guard" : True },
# 移ろいやすい属性: 守りすぎない
"billing_plan" : { "ttl_turns" : 15 , "guard" : True },
"current_task" : { "ttl_turns" : 15 , "guard" : True },
"notify_channel" : { "ttl_turns" : 15 , "guard" : True },
}
DEFAULT_POLICY = { "ttl_turns" : 50 , "guard" : True }
def accept_update (field, current, incoming, now_turn):
"""True を返したときだけプロファイルへ書き込む"""
pol = FIELD_POLICY .get(field, DEFAULT_POLICY )
if current is None :
return True
if not pol[ "guard" ]:
return True
if incoming[ "confidence" ] == "confirmed" :
return True # 本人の明言は常に通す
if current[ "confidence" ] == "inferred" :
return True # 推測値どうしは新しい方
# 確定値を推測値で上書きするのは、賞味期限が切れているときだけ
return (now_turn - current[ "updated_turn" ]) > pol[ "ttl_turns" ]
本番運用に入ってから気づいた落とし穴が、ここにひとつあります。ttl_turns をターン数ではなく実時間(例: 30日)で持ちたくなりますが、対話頻度が利用者ごとに大きく違うため、実時間だとよく使う人ほど古い値を長く抱える という逆転が起きました。会話の回数を基準に置くほうが、体感と合っていました。回避策として、運用上は「ターン数」と「実時間」の両方を持ち、どちらかが超えたら失効させる二重条件に落ち着いています。片方だけでは、休眠していた利用者が戻ってきたときに古い値をそのまま渡してしまう不具合が残りました。
スキーマに何を入れ、何を入れないか
計測を通して、構造化する対象の線引きも変わりました。私が現在採っている基準は3つです。どれも絶対的なものではありませんが、判断に迷ったときはこの順に見ることを推奨します。
1. 後段が機械的に読む値だけを入れる
プロファイルの値でコードが分岐するもの — 課金状態、言語設定、通知チャネル — はスキーマに入れます。逆に「この人はデザインにこだわりが強い」といった、モデルが読んで解釈するだけの情報は自由記述側に残しました。構造化しても分岐に使えないなら、決め切るコストだけが増えます。私はこの線引きを、実装前ではなく実装後に一度見直すやり方を好みます。机上では分岐しそうに見えて、書いてみると誰も読まないフィールドが必ず出てくるためです。
2. 値の取れる範囲が閉じているものを優先する
enum で書ける項目は構造化と相性が良い一方、自由入力に近い項目は誤った確定値が入り込みやすくなります。開発中に一度、端末名フィールドへ「たぶん iPhone のどれか」という文字列がそのまま確定値として保存され、下流の分岐が静かに壊れたことがありました。例外が飛ぶわけでもエラーログが出るわけでもないため、気づいたのは数日後です。解決は単純で、書き込み前に enum 検証を挟むだけでした。列挙できない項目は、まず enum に落とせる粒度まで抽象化してから入れるようにしています。
3. 空欄を許す
これがいちばん効きました。必須フィールドを増やすと、モデルは空欄を嫌って推測で埋めにきます。埋まった値は下流から見れば確定値と区別がつかず、そのまま流れていきます。required は本当に無いと処理が進まないフィールドだけに絞り、それ以外は未取得のまま持てるようにしました。構造化の目的は、空欄をなくすことではなく、空欄を空欄として扱えるようにすること だったと思っています。
明日から変えられること
まず、いま動かしているプロファイルのフィールドを一覧に書き出して、それぞれの横に「この事実は何日くらいで変わるか」を書き足してみてください。数字を入れられないフィールドが出てくるはずです。そこが、いまいちばん危ういところです。
この記事のハーネスは50行ほどで、change_p に自分のフィールドの実感値を入れるだけで走ります。手元のワークロードで測れば、私の 16.4ポイントとは違う数字が出るはずですし、それこそが判断材料になります。
Memory profiles は記憶を「持てる」ようにしてくれましたが、記憶を「捨てる」判断までは肩代わりしてくれません。何をいつ忘れるかを決めるのは、まだ私たちの仕事のようです。
なお Memory profiles の提供状況や仕様は変動しますので、実装前に Gemini API の changelog で最新の記述をご確認ください。
長い計測記録にお付き合いいただき、ありがとうございました。私自身まだ手探りの段階ですので、皆さんのワークロードで別の結果が出ましたら、ぜひ教えていただければ嬉しく思います。