为30亿参数的AI伙伴设计三层记忆:如何让端侧LLM拥有长期记忆#
有一个简单的事实,几乎颠覆了人们对构建AI伙伴的所有直觉:iPhone上的端侧语言模型,上下文窗口并不是顶级云端模型那样的20万token,而是大约4096个。而且这不仅仅是输入——系统指令和模型自身的回复也要共享同一个预算。如果你设计功能时假设用户口袋里装着一个几乎拥有无限记忆的GPT-4级模型,聊到第三天它就会崩溃:模型要么"忘记"对话的开头,要么开始混淆当下究竟发生了什么。
我目前正在开发Lanternly——一款完全通过Apple Foundation Models在端侧运行AI伙伴的日记应用(关于这个框架本身,我另写了一篇文章:Apple Foundation Models:iOS应用中的端侧LLM)。我最初的记忆架构在现实面前不堪一击:要么上下文溢出、模型开始答非所问,要么不得不粗暴地裁剪历史记录,导致重要细节丢失。解决方案不是某个巧妙的截断技巧,而是把记忆拆分成三个相互独立的层——每一层都有各自的职责、存储位置和管理者。下面说明它是如何搭建的,以及为什么这样设计。
为什么4096个token是一种不同的架构,而不是「缩水版」#
那些只用过云端LLM的开发者,通常只会用一种方式解决记忆问题:「把更多历史塞进prompt里」。在20万+token的窗口下,这几乎总能奏效——可以塞进几十条消息、整份文档、知识库片段,模型都能处理好。这造成了一种错觉,好像上下文管理只是一个可以往后拖延的实现细节,而不是一个架构决策。
在端侧,这套办法行不通。4096个token的预算要在指令(包含人设、语气、规则的系统prompt)、回复本身(必须留出空间,否则生成会在句子中途被截断)以及剩下的部分——通常只有2500到3000个token留给整个对话上下文——之间分配。这就是几十条简短对话轮次,不会更多。所以上下文管理不是一种优化,而是没有它功能就根本无法运作的硬性要求。接下来要讲的不是某一个「聪明的历史裁剪」技巧,而是三个各自解决不同问题的独立机制。
第一层——模型的上下文:溢出时自动摘要的滑动窗口#
第一层是物理上真正进入某次具体模型调用prompt里的内容:系统指令加上对话最近的N轮。最简单粗暴的做法是,一旦空间不够就直接丢弃最旧的消息。这样能用,但代价是对话本身:如果用户五条消息之前提到过一个重要细节,而窗口早已滑过去了,模型将永远看不到它。
真正管用的做法是滑动窗口:溢出时不丢弃旧的对话轮次,而是通过一次额外的模型调用,把它们折叠成一段简短摘要。窗口的前半部分被压缩成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内部,并在每次模型调用时,根据第二层和第三层拥有的数据从头重建。它自身不会持久化任何东西。
第二层——磁盘上的对话历史:SwiftData + iCloud#
第二层回答的是第一层刻意忽略的问题:对话中实际发生了什么,完整、未经压缩?这是一份完整、未经改动的每一轮对话日志——通过SwiftData在设备本地存储,并通过iCloud在用户的多台设备间同步。
与第一层的区别是根本性的:第一层是为当前这次模型调用服务的、被裁剪过的工作投影;第二层才是真相之源。摘要正是从这里构建出来的,用户也正是回到这里重读旧的对话,而这一层也正是用户可以删除的——整体或部分删除——且不会以任何方式破坏当前正在进行的模型会话。
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
}
}实际的好处在于:如果应用因上下文溢出而"跟丢"了对话,用户不会因此丢失任何东西——完整的历史记录依然完好无损地保存在磁盘上。它只是暂时无法塞进单次模型调用的预算里,而这是一个不同的问题,不应该用同一套代码来解决。
第三层——「关于你的记忆」:看得见、也能删除的事实#
第三层是一份简短、可编辑的关于用户的持久事实摘要:偏好、重要的人、习惯——这些内容值得在完全不同的对话之间被记住,即便第一层早已忘记,即便为了一个细节重新翻阅整个第二层的成本高得没有必要。
事实的产生有两条路径。用户可以手动添加。而模型也可以在不阻塞主回复的前提下,以「尽力而为」的方式,尝试从一条足够长的消息中提取一个持久事实。如果没有可提取的事实,模型会用一个特殊标记回应,不保存任何内容;如果某个事实与已有的相似,它会被当作重复项丢弃。
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),用户在设置里能看到完整列表,可以编辑任意条目的措辞,也可以彻底删除、不留痕迹。没有任何东西藏在模型的"字里行间",删除之后也不会有任何东西重新出现。
组装上下文:如何把三层塞进一个token预算里#
每次模型调用之前,三层都会被打包进一个单一的指令字符串里,这个字符串必须落在Foundation Models的真实上限之内——包括回复在内,整场对话总共4096个token。顺序很重要:第三层排在最前面,因为关于用户的事实很便宜(只有几行),每花一个token能给模型带来的价值也最高。接下来是第一层被压缩的部分(滚动摘要),如果存在的话。而预算剩下的部分,才用来填充最近对话轮次的逐字尾巴,从最新的开始往前排。
/// 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层 模型上下文 | 最近对话轮次的滑动窗口+溢出时的滚动摘要 | 仅存在于当前LanguageModelSession内部,短暂存在 | 模型与应用代码,自动完成 |
| 第2层 对话历史 | 每一轮对话完整、未压缩的日志 | 设备本地SwiftData + iCloud同步 | 应用自动保存;用户可以整段删除对话 |
| 第3层 「关于你的记忆」 | 一份关于用户的简短持久事实列表 | 设备本地SwiftData + iCloud | 用户——查看、编辑、删除每一条记录 |
端侧 vs 云端:为什么不能直接照搬MemGPT或mem0#
从云端智能体的世界里借用一套现成模式,这种诱惑很强。MemGPT,以及建立在同一思路之上的Letta框架,把上下文窗口当作操作系统式的虚拟内存来处理:核心记忆是模型的"内存(RAM)",而归档存储和检索存储则是"磁盘",模型自己会在对话中途调用专门的工具,决定要把什么内容调入内存。mem0走的是另一条路——每一轮对话之后,一次独立的LLM调用会提取出原子化的事实,并对其分类出一个操作(添加、更新、删除,或什么都不做),同时与向量库中相似的已有记录做比对。
这两种方法都很扎实、也很聪明——但两者都预设了20万+token的预算,以及一次额外的模型调用在时间和金钱上几乎不产生什么成本。而在一个上限只有4096个token、且没有自己向量数据库的小型端侧模型上,这种做法完全无法扩展:每一次「让模型自己决定该记住什么」的额外调用,都在消耗回复用户真正需要的那同一份预算。所以在这套架构里,第三层并没有把记忆管理权交给模型自己——它是一个确定性的、成本低廉的机制:固定的提取prompt、严格的长度上限、以子字符串比对为基础的简单去重;什么时候运行、什么内容能装进预算,由应用来决定,而不是模型。它比MemGPT或mem0灵活性更低,但足够可预测,也足够便宜,可以在手机上每条消息之后都运行一次。
隐私是一种架构,而不是设置里的一个开关#
三层记忆有一个副作用,在实践中比任何token优化都更重要:它在结构上正是"黑箱"的反面。历史记录和事实都存放在用户自己iCloud账户下的SwiftData里——不在别人的云端,也不在用户无法触及的服务器端向量库里。第三层不会隐瞒应用对用户"了解"了什么:事实列表是可见的,任何一条都可以被修改或删除,而一旦被删除,它不会从某个隐藏的缓存或向量索引中再次冒出来——因为根本不存在这样一个隐藏的索引。
这不只是某一款日记伙伴应用的贴心细节。这是一个适用于任何构建在小型端侧模型之上的长期记忆功能的架构结论:如果你没有预算去搭建MemGPT级别的云端基础设施,把记忆拆分成三层给你的,不是一个"真正的"长期记忆的廉价替代品,而是一份能够诚实告诉用户它存储了什么、为什么存储的记忆。
最后,在为你自己的端侧LLM功能设计记忆架构之前,值得回答这三个问题:
- 此时此刻,这次具体的模型调用应该包含什么内容——你能真正计算出预算,而不是靠猜测吗?
- 哪些内容需要完整保留,即便模型永远不会一次性看到它们?
- 哪几条事实值得超越任何单次对话而长久留存——你是否愿意把它们原原本本地展示给用户?
把这三个问题都想清楚,一个小型端侧模型的记忆就不再是它的弱点,而会成为一个经过深思熟虑的架构选择——具备云端黑箱通常无法企及的透明度。



