2026 年 4 月の中ごろ、運営している癒し系の壁紙アプリに組み込んだインアプリ AI チャットが、突然 1 セッションあたりの API コストを倍に押し上げ始めました。月のグラフで気付いたわけではありません。アプリ内のレスポンスがじわじわ重くなっていることをユーザーレビューで指摘されて、ようやくダッシュボードを開いたのです。原因は単純で、長く会話するユーザーほど履歴が膨らみ、毎回その全文を messages に詰めて送り続けていました。直近 40 ターンで input_tokens が 18,000 を超えるセッションも珍しくない状態です。
個人でアプリを運営していると、サーバー側の課金事故はひと通り味わうことになります。ただ Claude API の場合は、いつもの事故と手触りが違いました。壊れていないのです。会話は止まらず、ユーザー満足も悪くありません。それでも履歴が線形に伸びていく以上、input_tokens のグラフは静かに右肩上がりでした。私は数日かけて、履歴を捨てるのではなく階層的に圧縮する設計に切り替え、結果として入力トークンを 7 週間の平均で 70 % 削減できました。
この記事は、その階層サマライゼーションを 3 層構造で組み直した実装記録です。Tier 1(直近ターンの忠実保持)、Tier 2(中期記憶の Haiku 要約)、Tier 3(長期記憶の Sonnet 抽象化)を分業させ、トリミング 1 つでは到達できない圧縮率と意味保持の両立を狙います。コードは Cloudflare Workers + TypeScript で動かしている本番版から再構成しました。
個人開発のアプリで「履歴問題」が形を現すまで
履歴問題は、messages 配列に過去のターンを足し続けるだけのチャット設計につきまといます。短いセッションでは無視できる量ですが、ヘビーユーザーは平気で 80 ターン、100 ターンと続けます。私の壁紙アプリでも、長期利用者の上位 5 % が API コストの 38 % を消費していました。AdMob 経由の広告売上でこのコストを吸収する設計だったので、上位ユーザーが課金で報われない構造になり、放置できないと判断しました。
トリミング戦略の落とし穴
「履歴を 10 ターンで切る」という単純なトリミングを最初に検討しましたが、これは捨てたくない情報まで捨てる可能性があります。たとえば「あなたが先に教えてくれたカラーパレットの好み」「過去に却下したテーマ」のような、会話の文脈を作っている要素は、20 ターン前に出てきても次のリクエストで参照したいものです。トリミングは安いですが、ユーザーの「覚えていてくれない感」を生みます。実体験として、レビュー欄で「設定を毎回聞いてくる」「前に話したことを忘れる」と書かれることが何度かありました。
「忘却」ではなく「圧縮された記憶」
階層サマライゼーションは、この「直近の流れ」と「長期の文脈」を別々に扱う発想です。直近ターンは生のまま保ち、古いターンは要約に置き換え、さらに古いものは抽象化したエンティティ集合に落とします。トリミングが「忘却」だとすれば、階層サマライゼーションは「圧縮された記憶」だと考えています。輪郭だけを残して細部を手放す——昨日の打ち合わせを思い出すときに人がしていることと、案外近いのかもしれません。
3 層構造で履歴を圧縮する全体像
私の本番設計は、会話履歴を次の 3 つの Tier に分けています。
Tier 1 の役割
Tier 1 は会話の連続性を保つために生のまま送ります。直近 6 ターンを残しているのは、ユーザーが「さっきの返信を変えてほしい」「3 つ前の提案に戻して」と参照する範囲が、私のアプリでは 5〜7 ターンに収まることが多かったからです。これより短いと文脈飛びが起き、これより長いとトークン削減効果が頭打ちになります。
Tier 2 の役割
Tier 2 は中期記憶です。会話が伸びるにつれて「8 ターン前から 14 ターン前までの 1 セット」を 1 つのエピソードとして扱い、Haiku 4.5 で 200 トークン程度に要約します。Haiku を選んでいるのは、要約という比較的軽い推論タスクに対して費用対効果がはっきり見合うからです。後述するプローブで測ると、Haiku の要約は Sonnet の要約に対して再現率で 3 ポイントほど落ちる程度に収まり、要約 1 回あたりのコストは Sonnet の 約 1/6 で済みます。
Tier 3 の役割
Tier 3 は長期記憶です。エピソードが 3 つ以上溜まったら、それらを横断的に眺めて「ユーザーが繰り返し求めていること」「明示的に拒否したこと」「進行中の選好(カラー・モチーフ・気分)」を JSON 構造に抽象化します。ここだけは Sonnet 4.6 を使います。意図抽出は要約より難しく、Haiku に任せると重要なエンティティを取りこぼすことが運用で確認できたためです。浮世絵壁紙で古い紙の写真を仕分けるときの手つきに近いもので、Tier 2 は「一枚ずつ縮めて並べる」、Tier 3 は「並べた束から繰り返し立ち上がる主題を抜き出す」という比喩が、私の中では合っています。
Tier 1:直近ターンの忠実保持
直近ターンの保持は単純ですが、しきい値の決め方だけは慎重に行いました。最初は「常に直近 10 ターン」と決めていたのですが、ユーザーごとに会話の濃さが違い、テンポの速いユーザーでは 10 ターンが冗長になり、テンポの遅いユーザーでは 10 ターンでも足りませんでした。最終的に、トークン数ベースで打ち切る方針に変えています。
// src/history/tier1.ts
import type { Message } from "@anthropic-ai/sdk/resources/messages" ;
export interface Tier1Result {
recent : Message [];
overflow : Message [];
tokenEstimate : number ;
}
const TIER1_TOKEN_BUDGET = 3_000 ;
const TIER1_MIN_TURNS = 4 ;
// 日本語混在環境では 1 トークン ≒ 0.6 文字で見積もる
function estimateTokens ( message : Message ) : number {
const content = typeof message.content === "string"
? message.content
: message.content. map (( c ) => "text" in c ? c.text : "" ). join ( "" );
return Math. ceil (content. length / 0.6 );
}
export function partitionTier1 ( history : Message []) : Tier1Result {
const recent : Message [] = [];
let used = 0 ;
for ( let i = history. length - 1 ; i >= 0 ; i -- ) {
const cost = estimateTokens (history[i]);
if (used + cost > TIER1_TOKEN_BUDGET && recent. length >= TIER1_MIN_TURNS ) {
return {
recent: recent. reverse (),
overflow: history. slice ( 0 , i + 1 ),
tokenEstimate: used,
};
}
recent. push (history[i]);
used += cost;
}
return { recent: recent. reverse (), overflow: [], tokenEstimate: used };
}
最小ターン数のガード
TIER1_MIN_TURNS で「どれだけ近い直近ターンでも 4 ターンは絶対に残す」というガードを入れているのは、ユーザーが直前のターンを参照する Continue や Refine のようなコマンドを使うときに、参照対象が消えていないことを保証するためです。私のアプリでは Refine ボタンの押下回数が 1 日 8,000 回を超えているので、ここが壊れるとレビュー欄が荒れます。
トークン見積もりの簡易化
トークン見積もりは 1 トークン ≒ 0.6 文字という日本語寄りの係数で粗く出しています。正確に必要なら messages.count_tokens エンドポイントが使えますが、毎リクエスト叩くとコストとレイテンシが増えるので、しきい値判定の段では係数推定で十分でした。本番では実コストとの差は 4 % 以内に収まっており、この精度で運用面の判断には支障ありません。
Tier 2:Haiku で中期エピソードを 200 トークンに圧縮する
Tier 2 の要約は、Tier 1 からこぼれた overflow を 6 ターン単位のエピソードに切り、Haiku 4.5 に渡します。プロンプトのコツは「要約対象だけを与え、システム側の文脈を混ぜない」ことです。会話本体のシステムプロンプトが要約処理に染み出すと、要約が「アプリの説明」になって肝心の会話内容を取りこぼします。
// src/history/tier2.ts
import Anthropic from "@anthropic-ai/sdk" ;
import type { Message } from "@anthropic-ai/sdk/resources/messages" ;
const anthropic = new Anthropic ();
const SUMMARIZE_SYSTEM = `You compress a chunk of a chat conversation.
Output a single paragraph in the user's language, ~200 tokens, written in the third person.
Preserve: explicit preferences, rejected ideas, decisions, in-progress requests.
Drop: greetings, filler, repeated acknowledgements.
Never invent facts; if uncertain, omit.` ;
export interface EpisodeSummary {
range : [ number , number ]; // 元 history のインデックス範囲
summary : string ;
createdAt : number ;
}
export async function summarizeEpisode (
episode : Message [],
baseIndex : number ,
) : Promise < EpisodeSummary > {
const transcript = episode
. map (( m , i ) => `[${ m . role }] ${ flattenContent ( m ) }` )
. join ( " \n " );
const res = await anthropic.messages. create ({
model: "claude-haiku-4-5-20251001" ,
max_tokens: 320 ,
system: SUMMARIZE_SYSTEM ,
messages: [{ role: "user" , content: transcript }],
});
const text = res.content
. filter (( c ) : c is { type: "text" ; text: string } => c.type === "text" )
. map (( c ) => c.text). join ( " \n " ). trim ();
return {
range: [baseIndex, baseIndex + episode. length - 1 ],
summary: text,
createdAt: Date. now (),
};
}
function flattenContent ( m : Message ) : string {
if ( typeof m.content === "string" ) return m.content;
return m.content
. map (( c ) => "text" in c ? c.text : "[non-text content]" )
. join ( " " );
}
エピソード境界の切り方
エピソードの境界は「6 ターン固定」より「セマンティックなまとまり」で切る方が要約品質が上がりますが、最初の実装では単純に 6 ターン固定で十分でした。境界判定に Claude をもう一度呼ぶより、要約自体に責任を持たせた方が安く済みます。
「誰が言ったか」を残す
実運用では、要約に「[user] [assistant]」を残すことで誰が何を言ったかを失わないようにしている点が地味に効きました。これがないと、ユーザー側の希望と AI 側の提案がのちのち混ざります。「ユーザーがブルーを好む」と「アシスタントがブルーを提案した」では意味がまったく違うので、ここをきちんと区別できる要約スタイルを Haiku に指示しています。
ハマったポイント:要約の言語ドリフト
Haiku に英語で system を書くと、要約も英語で返ってきがちです。日本語ユーザーが多いアプリの場合、要約が英語のまま残ると Tier 3 で意図抽出の精度が落ちます。Output a single paragraph in the user's language という指示で、システムは英語のままアウトプットだけ追随させると、品質と言語の両立がうまくいきました。
Tier 3:Sonnet でエンティティと意図を JSON に抽象化する
エピソード要約が 3 つ以上溜まったら、Tier 3 の長期記憶を更新します。ここは表現を保つよりも構造を保つことが目的なので、tool_use を使った構造化出力で JSON を直接生成させます。意図抽出は要約と違って「何を抽出するか」が決まっているため、ツール定義のスキーマで型を縛れます。
// src/history/tier3.ts
import Anthropic from "@anthropic-ai/sdk" ;
const anthropic = new Anthropic ();
const memoryTool = {
name: "record_long_term_memory" ,
description: "Record the user's stable preferences, rejected ideas, and ongoing intents." ,
input_schema: {
type: "object" ,
properties: {
preferences: {
type: "array" ,
items: { type: "string" },
description: "Stable preferences (color palette, mood, motif, style)." ,
},
rejected: {
type: "array" ,
items: { type: "string" },
description: "Ideas the user has explicitly turned down." ,
},
ongoing_intent: {
type: "string" ,
description: "The user's current goal in 1-2 sentences." ,
},
anchors: {
type: "array" ,
items: { type: "string" },
description: "Named entities worth remembering (artist, location, technique)." ,
},
},
required: [ "preferences" , "rejected" , "ongoing_intent" , "anchors" ],
},
} as const ;
export interface LongTermMemory {
preferences : string [];
rejected : string [];
ongoing_intent : string ;
anchors : string [];
updatedAt : number ;
}
export async function distillLongTermMemory (
episodeSummaries : string [],
previous : LongTermMemory | null ,
) : Promise < LongTermMemory > {
const prior = previous ? `Previous memory snapshot: \n ${ JSON . stringify ( previous , null , 2 ) } \n\n ` : "" ;
const merged = episodeSummaries. map (( s , i ) => `Episode ${ i + 1 }: \n ${ s }` ). join ( " \n\n " );
const res = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 800 ,
tools: [memoryTool],
tool_choice: { type: "tool" , name: "record_long_term_memory" },
messages: [{
role: "user" ,
content: `${ prior }New episode summaries: \n ${ merged } \n\n Merge prior memory with the new evidence. Drop superseded preferences. Keep the JSON small.` ,
}],
});
const toolUse = res.content. find (( c ) => c.type === "tool_use" );
if ( ! toolUse || toolUse.type !== "tool_use" ) {
throw new Error ( "Tier3: tool_use not returned" );
}
const input = toolUse.input as Omit < LongTermMemory , "updatedAt" >;
return { ... input, updatedAt: Date. now () };
}
tool_choice を強制するメリット
tool_choice で record_long_term_memory を強制するのが地味に大事で、これがないと Sonnet がたまに自然文で返してきます。required を埋めておくと空配列でも構造は保証されるので、後段のパースがシンプルになります。私の運用では、Tier 3 の更新頻度はおおよそ 30 ターンに 1 回で、コストは 1 回あたり $0.012 程度です。
「上書き」ではなく「マージ」する
最初の実装では、Tier 3 を毎回ゼロから作っていました。けれども、これだと過去に確立した好みが新しいエピソードに引っ張られて消えるという問題が起きました。Previous memory snapshot を入力に含めて「マージ」させる方式に変えてから、長期保持の安定性が大きく改善しました。Sonnet は明示的に与えた前提を尊重するモデルなので、過去スナップショットを渡すかどうかでこの種の挙動が大きく変わります。
圧縮タイミングを非同期に逃がす設計
階層サマライゼーションの一番のリスクは、圧縮処理そのものがユーザーの体感レイテンシを押し上げてしまうことです。Tier 2 や Tier 3 を「次のメッセージを送るときに同期で動かす」と、ユーザー側に 1〜3 秒の遅延が乗ります。これは個人開発のアプリでは致命的で、レビュー欄が「最近遅い」で埋まります。
私の本番では、圧縮は次のように非同期で動かしています。
// src/history/orchestrator.ts
import { partitionTier1 } from "./tier1" ;
import type { LongTermMemory, EpisodeSummary } from "./types" ;
export interface ChatState {
history : Message [];
episodes : EpisodeSummary [];
longTermMemory : LongTermMemory | null ;
}
const EPISODE_SIZE = 6 ;
const DISTILL_TRIGGER = 3 ;
export async function buildPromptMessages ( state : ChatState ) : Promise < Message []> {
const { recent } = partitionTier1 (state.history);
const memorySystem = state.longTermMemory
? formatMemorySystemBlock (state.longTermMemory)
: "" ;
const episodicContext = state.episodes
. map (( e , i ) => `<episode index="${ i }">${ e . summary }</episode>` )
. join ( " \n " );
return [
{ role: "user" , content: `${ memorySystem } \n ${ episodicContext }` },
... recent,
];
}
export function shouldCompressTier2 ( state : ChatState ) : boolean {
const { overflow } = partitionTier1 (state.history);
const summarized = state.episodes. length * EPISODE_SIZE ;
return overflow. length - summarized >= EPISODE_SIZE ;
}
export function shouldCompressTier3 ( state : ChatState ) : boolean {
const unsummarizedEpisodes = state.episodes. length
- (state.longTermMemory ? Math. floor (state.episodes. length / DISTILL_TRIGGER ) * DISTILL_TRIGGER : 0 );
return unsummarizedEpisodes >= DISTILL_TRIGGER ;
}
Cloudflare Queues に投げる
shouldCompressTier2 と shouldCompressTier3 は判定だけを行い、実際の API 呼び出しは Cloudflare Queues に投げます。私の構成では、ユーザーからのメッセージを受けた Worker は応答を返してから c.executionCtx.waitUntil でキュー投入だけして終わります。次のメッセージが届いたときには、もうエピソード要約や長期記憶の JSON が KV に書き戻されている、という流れです。
失敗時のポリシー
注意点として、圧縮が間に合わなかった場合の挙動を決めておく必要があります。私は「圧縮未完了の場合は Tier 1 のみで返す。トークン予算を一時的に超えてもユーザー応答を優先する」というポリシーを採用しています。失敗時にユーザーを止めるのは本末転倒なので、観測層でアラートだけ出して走り続けます。本番運用ではこの「圧縮スキップ率」を Grafana に出していて、5 % を超えたら裏側のキューが詰まっているサインとして扱っています。
圧縮で何が落ちたかを、目ではなく問いで測る
圧縮率だけを眺めていた時期が二週間ほどありました。トークンは狙いどおり下がり、レビュー欄も落ち着いていたので、私はうまくいっていると思い込んでおりました。
そこへ一通の問い合わせが届きます。最初に伝えた配色を、途中から無視されるようになった、という内容でした。ログを開くと、Tier 3 の rejected にはその配色が残っています。落ちていたのは Tier 2 のほうでした。該当のターンは、ちょうど 6 ターン区切りの境目に乗っていたのです。
要約の良し悪しは、読んで決めるものではなく、問いを立てて測るものです。 目で追うかぎり、境目で意味を落とした要約も「よく書けた要約」に見えてしまいます。私が二週間気づけなかった理由は、要約を読んでいたからでした。
生履歴にしかない事実だけを問う
測り方そのものは単純です。生の履歴から「その情報が過去のターンにしか存在しない事実」を抜き出し、それを問う質問と、正解なら必ず含まれるはずの語を決めます。そのうえで、圧縮後の messages だけをモデルに渡して答えさせ、必須語が現れるかどうかを見ます。
判定は最初から言語モデルに任せません。必須語の一致で白黒がつく問いだけを選んでおき、表記の揺れで決めきれないものだけ Haiku に回します。こうすると判定コストがほぼ定数に収まり、毎晩まわしても財布が痛みません。
// src/history/recall-probe.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { buildPromptMessages } from "./orchestrator" ;
import type { ChatState } from "./orchestrator" ;
export interface Probe {
/** その事実が述べられたターン(生履歴の添字) */
statedAtTurn : number ;
question : string ;
/** 正解ならこのいずれかを必ず含む */
mustInclude : string [];
}
export interface ProbeResult extends Probe {
answer : string ;
passed : boolean ;
judgedBy : "literal" | "model" ;
}
const anthropic = new Anthropic ();
const UNKNOWN = "UNKNOWN" ;
export async function runProbe ( state : ChatState , probe : Probe ) : Promise < ProbeResult > {
// 本番と同じ経路で圧縮後の messages を組み立てる(ここを別実装にすると測定が無意味になる)
const compressed = await buildPromptMessages (state);
const res = await anthropic.messages. create ({
model: "claude-haiku-4-5-20251001" ,
max_tokens: 200 ,
system: `Answer only from the conversation context provided. If it is not there, reply exactly: ${ UNKNOWN }` ,
messages: [ ... compressed, { role: "user" , content: probe.question }],
});
const answer = res.content. map (( b ) => ( "text" in b ? b.text : "" )). join ( "" ). trim ();
const literal = probe.mustInclude. some (
( w ) => answer. toLowerCase (). includes (w. toLowerCase ()),
);
if (literal || answer === UNKNOWN ) {
return { ... probe, answer, passed: literal, judgedBy: "literal" };
}
return { ... probe, answer, passed: await judgeByModel (probe, answer), judgedBy: "model" };
}
async function judgeByModel ( probe : Probe , answer : string ) : Promise < boolean > {
const res = await anthropic.messages. create ({
model: "claude-haiku-4-5-20251001" ,
max_tokens: 8 ,
system: "Reply with exactly YES or NO." ,
messages: [{
role: "user" ,
content: `Does the answer below convey any of ${ JSON . stringify ( probe . mustInclude ) }? \n\n Answer: ${ answer }` ,
}],
});
return res.content. some (
( b ) => "text" in b && b.text. trim (). toUpperCase (). startsWith ( "YES" ),
);
}
buildPromptMessages を測定用に書き直さないことだけは守ってください。本番と別経路で組み立てた履歴を測っても、測っているのは本番ではありません。私は一度ここで手を抜き、通るはずのない問いが全部通って、半日を無駄にしました。
落ちる場所には偏りがあります
手元の 42 セッションから 240 問を作り、事実が述べられた位置で分けて集計しました。
平均だけを見れば 86.3 % で、悪くない数字に見えます。けれども内訳は一様ではありません。境目の 63.5 % だけが、ほかの三つから明らかに外れています。
理由は要約プロンプトの出来ではありませんでした。6 ターンで機械的に切ると、ユーザーが条件を述べたターンと、モデルがそれを受けて確認したターンが、別のエピソードに割れることがあります。片方だけを見た Haiku は、宙に浮いた発言を重要でないと判断して落とします——要約としては正しい振る舞いです。
境目に一ターンだけ重なりを持たせる
対処は、エピソードの切り出しに一ターンの重なりを入れるだけでした。
const EPISODE_SIZE = 6 ;
const EPISODE_OVERLAP = 1 ;
export function sliceEpisodes ( overflow : Message [], alreadySummarized : number ) : Message [][] {
const episodes : Message [][] = [];
let start = alreadySummarized;
while (overflow. length - start >= EPISODE_SIZE ) {
// 直前エピソードの末尾 1 ターンを持ち越して、発言と応答が割れないようにする
const from = Math. max ( 0 , start - EPISODE_OVERLAP );
episodes. push (overflow. slice (from, start + EPISODE_SIZE ));
start += EPISODE_SIZE ;
}
return episodes;
}
同じ 240 問をまわし直すと、境目の再現率は 63.5 % から 86.5 % に上がりました。代償は要約 1 回あたりの入力トークンが 約 17 % 増えることで、月額にすると $1.9 の上積みです。エピソード数そのものは変わらないので、ここは素直に買える取引でした。
重なりを 2 ターンに広げても試しましたが、境目は 88.4 % までしか伸びず、入力は 34 % 増えました。重なりは一ターンで足ります。それ以上は、要約の重複にお金を払っているだけです。
何問あれば信じてよいか
240 問という数に理論的な根拠はありません。私の目安は、位置ごとの区分に最低 40 問を置くことです。40 問あれば、再現率が 10 ポイント動いたときに偶然と切り分けられる程度の粒度にはなります。20 問台だと一問の正誤で 5 ポイント揺れてしまい、しきい値を触ってよいのか判断がつきませんでした。
問い作りは最初の一回だけ手間がかかります。私は実セッションを 10 本読み、そのなかで「ここは覚えていてほしい」と自分が感じた箇所にだけ印を付けました。残りは同じ形に沿って増やしています。夜間に一度まわして、位置別の再現率を前夜と並べて眺める——それだけで、しきい値をいじった翌日に何が壊れたかが分かるようになりました。
実測したコスト削減と品質維持
7 週間の運用データを並べると、効果は次の通りでした。
体感品質が逆に上がった理由
注目したいのは、トークンが減っただけでなく「覚えていない」というレビューが減った点です。これは Tier 3 のエンティティ抽出が効いていて、たとえばユーザーが 30 ターン前に「赤と金は使いたくない」と言っていれば、Sonnet がそれを rejected に拾い続けます。生のトリミングではこれを保てないため、ユーザー側からは「ちゃんと覚えているアシスタント」に体感が変わります。私自身、アプリのアップデート方針を決めるときには「試したこと」と「見送ったこと」を分けて書き残すようにしております。同じ構造を AI 側に持たせると、ユーザーには「対話相手として誠実」に映ります。
レイテンシが下がった副次効果
レイテンシが下がったのは副次効果です。input_tokens が減ると、time_to_first_token も短くなる傾向にあります。私の計測では、3,000 トークン以下のリクエストは 1,500 ms 前後、6,000 トークン以上のリクエストは 2,400 ms 前後という差がありました。圧縮の同期実行を避けているので、ユーザー側にはこの短縮分がそのまま体感速度として返ります。AdMob の広告表示は前後の体感速度に敏感で、レスポンスが速いセッションほど広告表示までの離脱が減るので、収益面にも小さな追い風になっています。
prompt caching と組み合わせるときの注意
cache_control を併用するなら、長期記憶 JSON とエピソード一覧をシステムプロンプト相当のブロックに分けて、ephemeral キャッシュを当てると効果が大きく出ます。私の場合は次のように、長期記憶のブロックだけ cache_control をオンにしています。
const cachedSystem = [
{
type: "text" ,
text: formatMemorySystemBlock (state.longTermMemory),
cache_control: { type: "ephemeral" },
},
];
await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
system: cachedSystem,
messages: [ ... episodicAsUser, ... recent],
max_tokens: 1_024 ,
});
エピソード一覧をキャッシュしない理由
エピソード一覧は更新頻度が高いのでキャッシュには向きません。Tier 3 の長期記憶 JSON は変動が少なく、しかも複数ターンにわたって繰り返し使われるため、キャッシュ命中率が運用 7 週で 71 % に達しました。キャッシュ命中時の cached_input_tokens は通常入力の 1/10 のコストなので、ここに乗せられるかどうかで月額がさらに数十ドル動きます。
キャッシュ無効化のトリガー
長期記憶 JSON は Tier 3 が走るたびに変わるので、その瞬間にキャッシュは事実上ミスします。私はこれを「Tier 3 更新後の 1 ターンだけはキャッシュ温め直しの料金が乗る」と割り切っています。30 ターンに 1 回の費用なので、無視できる規模です。むしろ Tier 3 を必要以上に頻発させて温め直しコストを膨らませないよう、DISTILL_TRIGGER の値は控えめに設定する方が運用上は安全です。
階層サマライゼーションが向かないケース
最後に、私の経験で「これには合わない」と感じたケースを書き残しておきます。1 つ目は短時間で完結するワンショット対話です。サポート FAQ のような「1〜3 ターンで終わる」前提の体験では、階層化のオーバーヘッドが本体コストを超えます。Tier 2 を起動するしきい値(私の場合は 12 ターン以上)に届かない会話が大半なら、シンプルなトリミングで十分です。
2 つ目は法的・医療的に厳密な意図保持が必要なドメインです。要約は確率的に意味を落とすので、たとえば医療相談のように「過去の症状の表現をそのまま参照する必要がある」ケースでは、安全のためにも生履歴を保つべきです。私自身は壁紙・癒し系という主観の強いドメインなので問題になりませんが、ドメインが厳密になるほどトリミング側が安全寄りになります。
3 つ目はマルチユーザー協調セッションです。複数ユーザーが同じセッションに合流する会議系のチャットだと、誰の preference を Tier 3 に残すかという問題が増え、JSON スキーマがすぐ肥大化します。私はここを試しましたが、ユーザー単位で別 State を持つ方が運用が単純で結果も良いと感じました。
個人開発者にとっての判断基準
ここまでをまとめると、階層サマライゼーションは「履歴が線形に伸びるアプリ」「長期セッションが収益の太い柱に乗っている」「文脈を覚えてほしいというユーザー期待が強い」という条件が揃ったときに、最も効きます。私のように個人で複数アプリを運営する立場では、API コストを 70 % 押し下げつつ、ユーザーレビューの質を一段上げてくれる打ち手として手放せなくなりました。
縮める前に、測る
次に試すなら、Tier 2 のエピソード境界をセマンティックに切る最適化と、Tier 3 の意図 JSON を埋め込みベクトル化して類似検索と組み合わせる方向を考えています。ただ、そちらへ進む前に必ず先に置く手順が一つあります。
いま履歴の圧縮を検討されている方には、Tier 1 を実装するより前に、プローブを 40 問だけ作ってみていただければと思います。圧縮していない状態の再現率が手元にあれば、圧縮を入れて下がったときに気づけます。私はこの順番を逆にして、二週間を失いました。測れないものは縮めません。 この一つだけは、締め切りが近い週でも崩さないようにしております。
書き残したものが、どなたかの夜の一時間を節約できましたら嬉しく思います。