Agent 记忆架构:滑动窗口、Token 预算裁剪与分层摘要压缩
本节目标
- 理解为什么直接把所有历史对话无限
push到messages数组会导致成本爆炸与「中间迷失(Lost in the Middle)」 - 掌握裁剪上下文时的协议安全红线:严禁把带有
tool_calls的assistant消息与紧随其后的role: 'tool'响应切开 - 用原生 JavaScript 手写一个支持 Token 预算估算、工具消息原子组保护与增量滚动摘要(Rolling Summary)的
ConversationMemory
为什么「上下文窗口变大了」依然需要精细的记忆管理?
尽管现代大模型的最大上下文窗口已扩展到 128K 甚至 1M Token,但在 Agent 工程中,粗暴地塞满上下文会引发三大问题:
- 成本与首字延迟(TTFT)随轮次呈二次方增长:假设每步工具返回 2,000 Token,跑 15 步下来累计输入 Token 超过 $2000 imes (1 + 2 + \dots + 15) = 240,000$ Token。
- 注意力稀释(Attention Dilution):当上下文塞满大量过期的工具原始 JSON 输出时,模型对 System Prompt 核心约束和最新指令的遵循度显著下降。
- 裁剪破坏协议完整性:如果你简单地用
messages.slice(-10)截取最近 10 条消息,一旦第 1 条恰好是一条孤立的role: 'tool'消息(其对应的assistant.tool_calls被切掉了),大模型 API 会直接报 400 错误拒收。
❌ 错误裁剪(切断了原子消息组,触发 API 400 报错):
...被裁掉: [assistant (tool_calls: call_1)]
保留窗口: [tool (tool_call_id: call_1)] ──► 孤儿 tool 消息!协议非法!
✅ 正确裁剪(按原子对话轮 Turn Group 裁剪 + 滚动摘要):
[systemPrompt] + [system: 历史滚动摘要] + [完整的近期原子消息组...]手写协议安全的分层记忆管理器
下面的代码将消息流划分为不可拆分的原子单元(Atomic Turn Groups),并在超出 Token 预算时自动将旧单元提炼为增量摘要:
export class ConversationMemory {
constructor({ systemPrompt, maxBudgetTokens = 6000, summarizer }) {
this.systemPrompt = systemPrompt
this.maxBudgetTokens = maxBudgetTokens
this.summarizer = summarizer // async (oldSummary, droppedMessages) => newSummary
this.rollingSummary = ''
this.history = []
}
// 轻量级中英混合 Token 估算器(中文约 1.5 字/Token,英文/代码约 4 字符/Token)
estimateTokens(text = '') {
const str = typeof text === 'string' ? text : JSON.stringify(text)
let cjk = 0
let ascii = 0
for (const ch of str) {
if (ch.charCodeAt(0) > 0x7f) cjk++
else ascii++
}
return Math.ceil(cjk / 1.5 + ascii / 4) + 4
}
// 将扁平的 messages 聚合成不可从中间劈开的「原子消息组」
// 规则:一条含 tool_calls 的 assistant 消息必须与紧随其后的所有 role:'tool' 消息绑在同一组
#groupIntoAtomicUnits(messages) {
const groups = []
let i = 0
while (i < messages.length) {
const msg = messages[i]
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
const group = [msg]
i++
while (i < messages.length && messages[i].role === 'tool') {
group.push(messages[i])
i++
}
groups.push(group)
} else {
groups.push([msg])
i++
}
}
return groups
}
async appendAndCompact(newMessages) {
this.history.push(...newMessages)
const groups = this.#groupIntoAtomicUnits(this.history)
const sysTokens = this.estimateTokens(this.systemPrompt) + this.estimateTokens(this.rollingSummary)
let currentTokens = sysTokens + groups.reduce((sum, g) => sum + this.estimateTokens(g), 0)
if (currentTokens <= this.maxBudgetTokens || groups.length <= 2) {
return this.getMessages()
}
// 从最旧的原子组开始弹出,但至少保留最后 2 个最新交互组
const evictedMessages = []
while (currentTokens > this.maxBudgetTokens && groups.length > 2) {
const oldestGroup = groups.shift()
evictedMessages.push(...oldestGroup)
currentTokens -= this.estimateTokens(oldestGroup)
}
this.history = groups.flat()
// 将被淘汰的旧对话增量融合进 rollingSummary,保留关键事实与已确认结论
if (evictedMessages.length > 0 && this.summarizer) {
this.rollingSummary = await this.summarizer(this.rollingSummary, evictedMessages)
}
return this.getMessages()
}
getMessages() {
const out = [{ role: 'system', content: this.systemPrompt }]
if (this.rollingSummary) {
out.push({
role: 'system',
content: `【前序对话关键事实与工具结论摘要】\n${this.rollingSummary}`
})
}
out.push(...this.history)
return out
}
}课后练习
练习 1:为什么在 #groupIntoAtomicUnits 中,必须把含有 tool_calls 的 assistant 消息和紧随其后的所有 role: 'tool' 消息打包成同一个不可分割的数组单元?
答案:大模型对话接口强制要求 assistant.tool_calls 与对应的 tool 响应消息成对出现。如果滑动窗口刚好从它们中间截断,剩下的孤立 tool 消息(或缺少 tool 响应的 assistant.tool_calls 消息)会导致后续所有 API 请求直接报 400 协议错误。
练习 2:在压缩超长工具返回结果(如 500 行的文件搜索输出)时,为什么「滚动摘要(Rolling Summary)」比直接丢弃旧消息更好?
答案:直接丢弃旧消息会让 Agent 患上「顺行性遗忘」——它忘了自己在第 2 步已经确认过某个配置项的值,从而在第 8 步又去重复读取同一个文件;滚动摘要用不到 5% 的 Token 保留了「已查过什么、得出了什么关键结论」,兼顾了上下文预算与长期一致性。