Трёхслойная память AI-компаньона на 3B-модели: как дать on-device LLM длинную память#
Есть простой факт, который ломает почти все интуитивные представления о том, как строить AI-компаньона: у on-device языковой модели на iPhone контекстное окно — не 200 000 токенов, как у топовых облачных моделей, а около 4096. И это не только вход — туда же входят системные инструкции и сам ответ модели. Всё в одном бюджете. Если проектировать приложение так, будто в кармане у пользователя лежит GPT-4-класса модель с почти безлимитной памятью, оно сломается уже на третий день переписки: модель либо «забудет» начало разговора, либо начнёт путать, что вообще происходит.
Сейчас я разрабатываю Lanternly — дневник-компаньон с AI, который живёт полностью on-device через Apple Foundation Models (я писал про сам фреймворк отдельно — Apple Foundation Models: on-device LLM в iOS-приложении). Первая версия архитектуры памяти у меня не выдержала проверки реальностью: либо контекст переполнялся и модель «плыла», либо историю приходилось резать так грубо, что терялись важные детали разговора. Решение оказалось не в одном трюке с обрезкой строки, а в разделении памяти на три независимых слоя — у каждого своя роль, своё место хранения и свой владелец. Ниже — как это устроено и почему именно так, а не иначе.
Почему 4096 токенов — это другая архитектура, а не урезанная версия большой#
Разработчики, которые до этого работали только с облачными LLM, обычно решают проблему памяти одним способом: «отправим больше истории в промпт». При окне в 200 000+ токенов это почти всегда работает — можно запихнуть десятки сообщений, документы, куски базы знаний, и модель разберётся. Это создаёт ложное чувство, что управление контекстом — не архитектурная задача, а деталь реализации, которую можно отложить.
На on-device модели такое не проходит. Бюджет в 4096 токенов делится на инструкции (системный промпт с персоной, тоном, правилами), сам ответ (нужно оставить место, иначе генерация оборвётся на середине фразы) и то, что осталось — обычно 2500–3000 токенов на весь диалоговый контекст. Это несколько десятков коротких реплик, не больше. Значит, управление контекстом — не оптимизация, а требование, без которого фича не работает вообще. Дальше — не одна «умная обрезка истории», а три отдельных механизма, каждый решает свою часть проблемы.
Слой 1 — контекст модели: sliding window с суммаризацией при переполнении#
Первый слой — это то, что физически попадает в промпт конкретного вызова модели: системные инструкции плюс последние N реплик диалога. Наивное решение — просто отрезать самые старые сообщения, когда становится тесно. Оно работает, но за него платят разговором: если пользователь пять сообщений назад упомянул важную деталь, а окно уже сдвинулось, модель никогда её не увидит.
Рабочее решение — sliding window, которое при переполнении не отбрасывает старые реплики, а сворачивает их в короткую суммаризацию одним дополнительным вызовом модели. Верхняя половина окна схлопывается в 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#
Второй слой отвечает за вопрос, который первый слой сознательно игнорирует: а что было в разговоре на самом деле, целиком, без сжатия? Это полный, неизменный лог всех реплик — хранится через SwiftData локально на устройстве и синхронизируется между устройствами пользователя через iCloud.
Разница со слоем 1 принципиальна: слой 1 — это рабочая, урезанная проекция для текущего вызова модели; слой 2 — источник истины. Именно отсюда берутся данные для построения саммари, именно сюда пользователь может вернуться, чтобы перечитать старый разговор, и именно этот слой пользователь может удалить — целиком или частично — без того, чтобы это как-то ломало текущую сессию с моделью.
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
}
}Практическое следствие: если приложение теряет разговор из-за переполнения контекста, пользователь ничего не теряет — вся история цела на диске. Она просто пока не помещается в бюджет одного вызова модели, и это разные проблемы, которые незачем решать одним и тем же кодом.
Слой 3 — «Память о тебе»: факты, которые видно и можно стереть#
Третий слой — короткая, редактируемая выжимка устойчивых фактов о человеке: предпочтения, важные люди, привычки, то, что стоит помнить между совершенно разными разговорами, даже когда слой 1 давно всё забыл, а перечитывать весь слой 2 ради одной детали неоправданно дорого.
Факты появляются двумя путями. Пользователь может добавить их вручную. И модель может — best-effort, не блокируя основной ответ — попробовать выделить один устойчивый факт из достаточно длинного сообщения. Если факта нет, модель отвечает специальным маркером, и ничего не сохраняется; если факт похож на уже существующий, он отбрасывается как дубликат.
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), и пользователь видит весь список целиком в настройках, может отредактировать формулировку любого пункта или удалить его без следа. Ничего не хранится «между строк» модели, ничего не восстанавливается после удаления.
Сборка контекста: как три слоя укладываются в токен-бюджет#
Перед каждым вызовом модели три слоя собираются в одну строку инструкций, которая обязана уложиться в реальный лимит 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")
}
}Это тот самый код, который делает три отдельных слоя единым опытом для пользователя — при этом сами слои остаются независимыми и решают каждый свою задачу.
| Слой | Что хранит | Где живёт | Кто управляет |
|---|---|---|---|
| 1. Контекст модели | Sliding window последних реплик + rolling summary при переполнении | Только внутри текущей LanguageModelSession, эфемерно | Модель и код приложения, автоматически |
| 2. История чата | Полный, несжатый лог всех реплик диалога | SwiftData на устройстве + синхронизация через iCloud | Приложение хранит автоматически; пользователь может стереть диалог целиком |
| 3. «Память о тебе» | Короткий список устойчивых фактов о пользователе | SwiftData на устройстве + iCloud | Пользователь — видит, редактирует и удаляет каждую запись |
On-device vs облако: почему нельзя просто скопировать MemGPT или mem0#
Соблазн взять готовый паттерн из мира облачных агентов велик. MemGPT и построенный на этой идее фреймворк Letta трактуют контекстное окно как виртуальную память в духе операционной системы: core memory — это «оперативная память» модели, а архивные и recall-хранилища — «диск», между которыми модель сама решает, что подгрузить, вызывая специальные инструменты прямо посреди диалога. mem0 идёт другим путём — после каждой реплики отдельный вызов LLM извлекает атомарные факты и классифицирует операцию над ними (добавить, обновить, удалить или ничего не делать), сверяясь с похожими записями в векторной базе.
Оба подхода рабочие и умные — и оба рассчитаны на бюджет в 200 000+ токенов и на то, что дополнительный вызов модели почти ничего не стоит по времени и деньгам. На on-device модели с потолком в 4096 токенов и без собственной векторной базы это не масштабируется буквально: каждый лишний «пусть модель сама решит, что запомнить» вызов съедает тот же самый бюджет, который нужен для настоящего ответа пользователю. Поэтому слой 3 в моей архитектуре не отдаёт модели право самой управлять памятью — это детерминированный, дешёвый механизм с фиксированным промптом, жёстким лимитом длины и простой дедупликацией по подстроке; решает, когда его запускать и что поместится в бюджет, приложение, а не модель. Гибкости меньше, чем у MemGPT или mem0, зато предсказуемо и достаточно дёшево, чтобы запускать после каждого сообщения прямо на телефоне.
Приватность как архитектура, а не настройка в меню#
У трёхслойной памяти есть побочный эффект, который на практике важнее любой оптимизации токенов: она структурно противоположна «чёрному ящику». История и факты живут в SwiftData под аккаунтом iCloud самого пользователя — не в чужом облаке, не в векторной базе на сервере, к которой у пользователя нет доступа. Слой 3 не прячет от человека, что о нём «знает» приложение: список фактов открыт, каждую запись можно поправить или удалить, и после удаления она не всплывает снова из скрытого кеша или векторного индекса — потому что скрытого индекса просто нет.
Это не просто приятная деталь для конкретного дневника-компаньона. Это архитектурный вывод, применимый к любой фиче с долгой памятью поверх маленькой on-device модели: если у вас нет бюджета на облачную инфраструктуру MemGPT-уровня, разделение на три слоя даёт не заменитель «настоящей» долгой памяти, а память, которая честно объясняет пользователю, что она хранит и почему.
И, наконец, три вопроса, на которые стоит ответить, прежде чем проектировать память для собственной on-device LLM-фичи:
- Что должно попасть в конкретный вызов модели прямо сейчас — и как честно посчитать бюджет, а не гадать?
- Что нужно хранить целиком, даже если модель этого никогда не увидит одним куском?
- Какие несколько фактов стоит вынести за пределы любого разговора — и готовы ли вы показать их пользователю как есть?
Если на все три вопроса есть чёткий ответ, память маленькой on-device модели перестаёт быть её слабым местом и становится осознанным архитектурным решением — с прозрачностью, которой облачным чёрным ящикам обычно не хватает.



