30億パラメータのAIコンパニオンに三層記憶を与える——on-device LLMに長期記憶を持たせる設計#
AIコンパニオンを作るときの直感をほぼすべて覆す事実がある。iPhone上のon-device言語モデルのコンテキストウィンドウは、トップクラスのクラウドモデルのような20万トークンではなく、約4096トークンしかない。しかもこれは入力だけではない。システム指示とモデル自身の応答も同じ予算の中に収まる必要がある。ポケットの中にほぼ無制限の記憶を持つGPT-4クラスのモデルがあるかのようにアプリを設計してしまうと、会話が3日も続かないうちに破綻する——モデルは会話の冒頭を「忘れ」たり、今何が起きているのかを混同し始めたりする。
私は現在、Apple Foundation Modelsを使い完全にon-deviceで動くAIコンパニオン付きの日記アプリ「Lanternly」を開発している(フレームワーク自体については別の記事「Apple Foundation Models: iOSアプリでのon-device LLM」に書いた)。最初の記憶アーキテクチャは現実の前にあっさり崩れた。コンテキストがあふれてモデルの応答がぶれ始めるか、履歴を乱暴に切り詰めて重要な details を失うかのどちらかだった。解決策は巧妙な切り詰めのトリックではなく、記憶を3つの独立した層に分けることだった。それぞれの層に役割・保存場所・管理者が異なる。以下でその構造と理由を説明する。
4096トークンは「縮小版」ではなく別のアーキテクチャである#
これまでクラウドLLMしか触ったことのない開発者は、記憶の問題を一つの方法で解決しがちだ——「プロンプトにもっと履歴を送ればいい」。20万トークン以上のウィンドウがあれば、これはほぼ常にうまくいく。何十ものメッセージ、ドキュメント全体、ナレッジベースの断片を詰め込んでも、モデルが処理してくれる。これがコンテキスト管理をアーキテクチャ上の決定ではなく後回しにできる実装の細部だという錯覚を生む。
on-deviceではこれは通用しない。4096トークンの予算は、指示(ペルソナ・トーン・ルールを含むシステムプロンプト)、応答そのもの(余白を残さないと文の途中で生成が打ち切られる)、そして残り——通常2500〜3000トークン程度の会話コンテキスト全体——に分割される。これは短いやり取り数十件分にすぎない。つまりコンテキスト管理は最適化ではなく、それなしでは機能そのものが成立しない必須要件だ。以下は一つの「賢い履歴カット」ではなく、それぞれ異なる問題を解決する3つの独立したメカニズムである。
層1——モデルのコンテキスト:オーバーフロー時に要約するスライディングウィンドウ#
第一の層は、特定のモデル呼び出しのプロンプトに物理的に入るもの——システム指示に加えて会話の直近N件のやり取りだ。素朴な解決策は、窮屈になったら単純に古いメッセージを切り捨てることだ。これは動作するが、会話そのものを犠牲にする。5メッセージ前に重要な details に触れていて、すでにウィンドウがずれてしまっていれば、モデルは二度とそれを見ることがない。
実用的な解決策は、オーバーフロー時に古いやり取りを捨てるのではなく、追加のモデル呼び出し1回で短い要約に畳み込むスライディングウィンドウだ。ウィンドウの前半は2〜3文の要約に圧縮され、後半はそのまま逐語的に残る。これが「忘れる」ことと「圧縮する」ことの違いであり、モデルは正確な文言を失っても要点は失わない。
import FoundationModels
/// Layer 1 — the model's own context: a sliding window that collapses
/// into a rolling summary instead of silently dropping older turns.
struct ConversationWindow {
private let maxTokens: Int
private(set) var turns: [ChatTurn] = []
private(set) var rollingSummary: String?
init(maxTokens: Int = 3200) {
self.maxTokens = maxTokens
}
/// Rough heuristic: ~4 characters per token (good enough for budgeting,
/// not for billing — on-device models don't charge per token anyway).
private func estimatedTokens(_ text: String) -> Int {
text.count / 4
}
private var currentTokens: Int {
turns.reduce(rollingSummary.map(estimatedTokens) ?? 0) {
$0 + estimatedTokens($1.text)
}
}
mutating func append(_ turn: ChatTurn) async {
turns.append(turn)
guard currentTokens > maxTokens, turns.count > 4 else { return }
await collapseOldest()
}
/// Move the oldest half of the window into a rolling summary and keep
/// only the freshest turns verbatim. This is the difference between
/// "forgetting" and "compressing" — the model still knows the gist.
private mutating func collapseOldest() async {
let cut = turns.count / 2
let evicted = Array(turns.prefix(cut))
turns.removeFirst(cut)
rollingSummary = await summarize(evicted, previous: rollingSummary)
}
private func summarize(_ evicted: [ChatTurn], previous: String?) async -> String {
guard case .available = SystemLanguageModel.default.availability else {
return previous ?? ""
}
let session = LanguageModelSession(instructions: """
Summarize the earlier part of this conversation in 2-3 neutral \
sentences. Keep names, decisions and open questions; drop small talk.
""")
let transcript = evicted.map { "\($0.role): \($0.text)" }.joined(separator: "\n")
let prompt = previous.map { "Previous summary: \($0)\n\nNew turns:\n\(transcript)" } ?? transcript
return (try? await session.respond(to: prompt).content) ?? (previous ?? "")
}
}
struct ChatTurn {
enum Role: String { case user, assistant }
let role: Role
let text: String
}重要な点として、この層は一時的なものだ。単一のLanguageModelSessionの中でのみ存在し、モデル呼び出しのたびに層2と層3が持つデータから作り直される。これ自体は何も永続化しない。
層2——ディスク上のチャット履歴:SwiftData + iCloud#
第二の層は、層1があえて無視している問いに答える——実際に会話で何が起きたのか、圧縮せずに丸ごと知りたい場合はどうするか。これはすべてのやり取りを完全かつ不変な形で記録したログであり、SwiftDataを通じてデバイス上にローカル保存され、iCloud経由でユーザーの複数デバイス間で同期される。
層1との違いは根本的だ。層1は現在のモデル呼び出しのための、切り詰められた作業用の投影であり、層2は真実の源(source of truth)である。要約を構築する元になるのはここのデータであり、ユーザーが古い会話を読み返すために戻ってくる先もここであり、そしてユーザーがまるごと、あるいは部分的に削除できるのもここだ——それによって現在進行中のモデルセッションが何ら壊れることはない。
import SwiftData
/// Layer 2 — chat history on disk: the full, uncompressed log, kept
/// independent from whatever the model's context window currently holds.
@Model
final class ChatMessage {
var id: UUID = UUID()
var role: ChatTurn.Role = .user
var text: String = ""
var createdAt: Date = .now
init(role: ChatTurn.Role, text: String) {
self.role = role
self.text = text
}
}実務上の意味は明快だ。コンテキストのオーバーフローによってアプリが会話を見失っても、ユーザーは何も失わない。履歴の全体はディスク上に無傷で残っている。単に今この瞬間、1回のモデル呼び出しの予算に収まらないだけであり、それは別の問題であって同じコードで解決すべきではない。
層3——「あなたについての記憶」:見えて、消せる事実#
第三の層は、ユーザーに関する持続的な事実——好み、大切な人、習慣など、層1がとっくに忘れてしまった後でも、あるいは一つの details のために層2全体を読み返すのがあまりに高コストな場合でも、まったく異なる会話をまたいで覚えておく価値があるもの——を短く編集可能な形でまとめたものだ。
事実は2つの経路で加わる。ユーザーが手動で追加できる。そしてモデルは、メイン応答をブロックせずベストエフォートで、十分な長さのメッセージから持続的な事実を一つだけ抽出しようと試みる。事実がなければモデルは特別なマーカーで応答し、何も保存されない。既存の事実に似ていれば重複として破棄される。
import SwiftData
/// Layer 3 — "Memory about you": a short, editable list of durable facts.
/// Lives on disk (and syncs via iCloud), independent of any single chat
/// session. The user can see, edit and delete every entry.
@Model
final class MemoryFact {
var id: UUID = UUID()
var text: String = ""
var source: MemorySource = .manual
var createdAt: Date = .now
init(text: String, source: MemorySource = .manual) {
self.text = text
self.source = source
}
}
enum MemorySource: String, Codable {
case automatic // extracted by the model from a conversation
case manual // added directly by the user
}
/// Best-effort auto-extraction: after a long-enough message, ask the model
/// for at most one durable fact. Never blocks the reply if it fails.
enum MemoryExtractor {
static func extract(from text: String, existing: [MemoryFact]) async -> MemoryFact? {
guard text.count > 25,
case .available = SystemLanguageModel.default.availability else { return nil }
let session = LanguageModelSession(instructions: """
Extract ONE durable fact about the person from their message \
(a preference, an important person, a habit worth remembering). \
One short phrase, no quotes. If there is no durable fact, reply NONE.
""")
guard let response = try? await session.respond(to: text) else { return nil }
let fact = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !fact.isEmpty, fact.uppercased() != "NONE", fact.count < 120 else { return nil }
let isDuplicate = existing.contains {
$0.text.localizedCaseInsensitiveContains(fact)
}
return isDuplicate ? nil : MemoryFact(text: fact, source: .automatic)
}
}ここでの重要なアーキテクチャ上の決定は技術的なものではなく、プロダクト上のものだ。すべての事実には出所(automatic / manual)がタグ付けされ、ユーザーは設定画面でリスト全体を確認でき、どの項目の文言も編集でき、痕跡を残さず削除できる。モデルの「行間」に隠れて何かが保存されることはなく、削除後に何かが復活することもない。
コンテキストの組み立て:三層をトークン予算に収める#
モデル呼び出しのたびに、3つの層は1本の指示文字列にパッケージ化され、それはFoundation Modelsの実際の上限——応答を含めたやり取り全体で4096トークン——に収まらなければならない。順序が重要だ。層3が最初に来る。なぜなら、ユーザーについての事実は安価(わずか数行)であり、消費するトークンあたりの価値がモデルにとって最も高いからだ。次に層1の圧縮された部分(rolling summary)が、存在すれば続く。そして予算の残りだけが、最新のものから順に直近のやり取りの逐語的な末尾に割り当てられる。
/// Packs all three layers into one instructions string that fits inside
/// the model's real context limit (Foundation Models: ~4096 tokens total,
/// input + output). Order matters: identity facts are cheap and high-value,
/// so they're never the first thing dropped.
struct PromptBudget {
let totalTokens = 4096
let reservedForResponse = 512
let reservedForInstructions = 300
var availableForContext: Int { totalTokens - reservedForResponse - reservedForInstructions }
func assemble(memory: [MemoryFact], summary: String?, recentTurns: [ChatTurn]) -> String {
var budget = availableForContext
var blocks: [String] = []
// Layer 3 — identity facts first: small, stable, high signal.
if !memory.isEmpty {
let text = memory.map { "- \($0.text)" }.joined(separator: "\n")
blocks.append("About the person:\n\(text)")
budget -= text.count / 4
}
// Layer 1, compressed part — the rolling summary of evicted turns.
if let summary, !summary.isEmpty, budget > 200 {
blocks.append("Earlier in the conversation: \(summary)")
budget -= summary.count / 4
}
// Layer 1, verbatim tail — fill what's left, newest turns first.
var tail: [String] = []
for turn in recentTurns.reversed() {
let cost = turn.text.count / 4
guard cost < budget else { break }
tail.insert("\(turn.role.rawValue): \(turn.text)", at: 0)
budget -= cost
}
blocks.append(contentsOf: tail)
return blocks.joined(separator: "\n\n")
}
}これが、3つの独立した層を1つのシームレスなユーザー体験へとまとめ上げるコードだ。層自体は独立したまま、それぞれが自分の担当分だけを解決している。
| 層 | 何を保存するか | どこに存在するか | 誰が管理するか |
|---|---|---|---|
| 層1 モデルのコンテキスト | 直近のやり取りのスライディングウィンドウ+オーバーフロー時のrolling summary | 現在のLanguageModelSession内のみ、一時的 | モデルとアプリコードが自動で |
| 層2 チャット履歴 | すべてのやり取りの完全・非圧縮ログ | デバイス上のSwiftData+iCloud同期 | アプリが自動保存。ユーザーは会話全体を削除可能 |
| 層3 「あなたについての記憶」 | ユーザーに関する持続的な事実の短いリスト | デバイス上のSwiftData+iCloud | ユーザー——すべての項目を確認・編集・削除 |
on-device vs クラウド:なぜMemGPTやmem0をそのままコピーできないのか#
クラウドエージェントの世界から出来上がったパターンを借用したくなる誘惑は強い。MemGPTと、同じ着想の上に構築されたLettaフレームワークは、コンテキストウィンドウをOS風の仮想メモリとして扱う。コアメモリはモデルの「RAM」であり、アーカイブとリコールのストアは「ディスク」であって、モデル自身が会話の途中で専用のツールを呼び出し、何をページインするか自ら決定する。mem0は別の道を取る——各ターンのあとに別のLLM呼び出しがアトミックな事実を抽出し、それに対する操作(追加・更新・削除・何もしない)を、ベクトルストア内の類似エントリと照合しながら分類する。
どちらのアプローチも堅実で洗練されている——そしてどちらも20万トークン以上の予算と、追加のモデル呼び出しが時間的にも金銭的にもほぼコストゼロであることを前提としている。ベクトルデータベースを自前で持たず、4096トークンという上限を持つ小さなon-deviceモデルでは、これは文字通りスケールしない。「モデル自身に何を覚えるか決めさせる」という追加呼び出し一つひとつが、実際の応答に必要なのと同じ予算を食いつぶすからだ。だからこのアーキテクチャの層3は、モデルに自分自身の記憶の管理権を渡さない。固定された抽出プロンプト、厳格な長さ上限、単純な部分文字列によるデデュプリケーションを備えた、決定論的で安価な仕組みであり、いつ実行するか・何が予算に収まるかを決めるのはモデルではなくアプリ自身だ。MemGPTやmem0よりも柔軟性は劣るが、予測可能で、電話上で毎メッセージ後に実行できるほど安価だ。
プライバシーは設定のスイッチではなくアーキテクチャである#
三層記憶には、どんなトークン最適化よりも実務上重要な副次効果がある——それはブラックボックスの構造的な対極にあるということだ。履歴と事実は、他人のクラウドでもユーザーがアクセスできないサーバー上のベクトルストアでもなく、ユーザー自身のiCloudアカウントの下にあるSwiftData内で暮らしている。層3は、アプリがユーザーについて「知っている」ことを隠さない。事実のリストは可視化されており、どの項目も訂正・削除でき、削除された後に隠されたキャッシュやベクトルインデックスから再び現れることもない——なぜなら隠されたインデックスなど存在しないからだ。
これは特定の日記アプリだけの気の利いた細部ではない。小さなon-deviceモデルの上に構築される長期記憶機能すべてに当てはまるアーキテクチャ上の結論だ。MemGPT級のクラウドインフラに投じる予算がないなら、記憶を三層に分けることは「本物の」長期記憶の劣化版ではなく、何を、なぜ保存しているのかをユーザーに正直に説明できる記憶を与えてくれる。
最後に、自分自身のon-device LLM機能の記憶を設計する前に答えておくべき3つの問い。
- 今この特定のモデル呼び出しに何を含めるべきか——そして、それを推測ではなく実際に予算計算できているか。
- モデルが一度に丸ごと見ることが決してなくても、完全な形で保存しておくべきものは何か。
- どの一握りの事実が、個々の会話を超えて生き残る価値があるか——そしてそれを保存されたそのままの形でユーザーに見せる覚悟があるか。
この3つに明確に答えられれば、小さなon-deviceモデルの記憶は弱点であることをやめ、意図的なアーキテクチャ上の選択になる——クラウドのブラックボックスには通常望めないレベルの透明性とともに。



