2026 年 4 月の中ごろ、運営している癒し系の壁紙アプリに組み込んだインアプリ AI チャットが、突然 1 セッションあたりの API コストを倍に押し上げ始めました。月のグラフで気付いたわけではありません。アプリ内のレスポンスがじわじわ重くなっていることをユーザーレビューで指摘されて、ようやくダッシュボードを開いたのです。原因は単純で、長く会話するユーザーほど履歴が膨らみ、毎回その全文を messages に詰めて送り続けていました。直近 40 ターンで input_tokens が 18,000 を超えるセッションも珍しくない状態です。
2014 年から個人でアプリを作り続けて、累計 5,000 万ダウンロードを超えるあいだに、サーバー側の課金事故は何度か味わってきました。けれども、Claude API の場合は「成功しているのに焼ける」というのが特徴です。会話は止まっておらず、ユーザー満足も悪くありません。それでも履歴が線形に伸びていく以上、input_tokens のグラフは静かに右肩上がりです。私は数日かけて、履歴を捨てるのではなく階層的に圧縮する設計に切り替え、結果として入力トークンを 7 週間の平均で 70 % 削減できました。
この記事は、その階層サマライゼーションを 3 層構造で組み直した実装記録です。Tier 1(直近ターンの忠実保持)、Tier 2(中期記憶の Haiku 要約)、Tier 3(長期記憶の Sonnet 抽象化)を分業させ、トリミング 1 つでは到達できない圧縮率と意味保持の両立を狙います。コードは Cloudflare Workers + TypeScript で動かしている本番版から再構成しました。
個人開発 12 年で見えた「履歴問題」の正体
履歴問題は、messages 配列に過去のターンを足し続けるだけのチャット設計につきまといます。短いセッションでは無視できる量ですが、ヘビーユーザーは平気で 80 ターン、100 ターンと続けます。私の壁紙アプリでも、長期利用者の上位 5 % が API コストの 38 % を消費していました。AdMob 経由の広告売上でこのコストを吸収する設計だったので、上位ユーザーが課金で報われない構造になり、放置できないと判断しました。
トリミング戦略の落とし穴
「履歴を 10 ターンで切る」という単純なトリミングを最初に検討しましたが、これは捨てたくない情報まで捨てる可能性があります。たとえば「あなたが先に教えてくれたカラーパレットの好み」「過去に却下したテーマ」のような、会話の文脈を作っている要素は、20 ターン前に出てきても次のリクエストで参照したいものです。トリミングは安いですが、ユーザーの「覚えていてくれない感」を生みます。実体験として、レビュー欄で「設定を毎回聞いてくる」「前に話したことを忘れる」と書かれることが何度かありました。
「忘却」ではなく「圧縮された記憶」
階層サマライゼーションは、この「直近の流れ」と「長期の文脈」を別々に扱う発想です。直近ターンは生のまま保ち、古いターンは要約に置き換え、さらに古いものは抽象化したエンティティ集合に落とします。トリミングが「忘却」だとすれば、階層サマライゼーションは「圧縮された記憶」だと考えています。1997 年にインターネットに触れたころに憧れた「コンピュータが自分のことを覚えていてくれる」という感覚を、現実的なコストで実現するための設計とも言えます。
3 層構造で履歴を圧縮する全体像
私の本番設計は、会話履歴を次の 3 つの Tier に分けています。
Tier 内容 圧縮方法 想定トークン
Tier 1 直近 6 ターン 生のまま保持 約 2,500
Tier 2 中期エピソード(要約済み) Haiku 4.5 で 200 トークンに要約 各 200、合計 800〜1,200
Tier 3 長期エンティティ・意図 Sonnet 4.6 で抽象化した JSON 約 600
Tier 1 の役割
Tier 1 は会話の連続性を保つために生のまま送ります。直近 6 ターンを残しているのは、ユーザーが「さっきの返信を変えてほしい」「3 つ前の提案に戻して」と参照する範囲が、私のアプリでは 5〜7 ターンに収まることが多かったからです。これより短いと文脈飛びが起き、これより長いとトークン削減効果が頭打ちになります。
Tier 2 の役割
Tier 2 は中期記憶です。会話が伸びるにつれて「8 ターン前から 14 ターン前までの 1 セット」を 1 つのエピソードとして扱い、Haiku 4.5 で 200 トークン程度に要約します。Haiku を選んでいるのは、要約という比較的軽い推論タスクに対して費用対効果が圧倒的に高いからです。私のメトリクスでは、Haiku の要約品質は Sonnet と比べて主観評価で 88 % 相当を保てており、要約 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 % を超えたら裏側のキューが詰まっているサインとして扱っています。
実測したコスト削減と品質維持
7 週間の運用データを並べると、効果は次の通りでした。
指標 階層化前 階層化後 差分
1 セッション平均 input_tokens 5,840 1,720 −70.5 %
月額 API 費用 $480 $145 −69.8 %
平均応答レイテンシ (p50) 2.1 秒 1.7 秒 −19 %
「覚えていない」とのレビュー件数(週次) 11 件 2 件 −82 %
圧縮処理の追加コスト(Haiku+Sonnet) — $11.4 / 月 —
体感品質が逆に上がった理由
注目したいのは、トークンが減っただけでなく「覚えていない」というレビューが減った点です。これは 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 を埋め込みベクトル化して類似検索を組み合わせる方向を考えています。さらに、複数アプリ間でユーザーの長期記憶を共有する仕組みも構想中です。たとえば壁紙アプリで好みを学習したカラーパレットを、別の癒し系アプリにも連携できれば、ユーザー側の「説明し直す手間」を省ける可能性があります。
実装の参考になれば幸いです。お読みいただきありがとうございました。