让 Agent 拥有“无限记忆“:对话压缩中间件的工程化实现
当 LLM Agent 的对话越拉越长,上下文窗口迟早会爆炸。本文拆解
agentscope-harness中CompactionMiddleware的完整实现:一套三层渐进式压缩 + 链式总结 + 双层长期记忆的工程方案,让 Agent 在有限上下文窗口内保持长期一致性。
一、它要解决什么问题
长会话 Agent 会同时撞上三堵墙:
- 上下文窗口硬上限:模型
contextWindowSize是死的,超过就报错或被截断。 - 注意力衰减:即使窗口够大,长上下文里关键信息也会被"中间遗忘"(lost in the middle)。
- 成本爆炸:每轮推理都要把全量历史重发一次,token 成本随对话长度平方级增长。
朴素做法是简单截断头部消息,但这会丢失早期决策、用户偏好、已完成动作记录,导致 Agent 反复"原地打转"——明明已经改过某个文件,又改回去。
CompactionMiddleware 的目标是:在每次推理前,把对话压缩到一个可控规模,同时不丢失对完成当前任务真正关键的信息。
二、整体方案设计
方案由四个类协作,职责清晰分离:
| 组件 | 角色 |
|---|---|
CompactionMiddleware |
中间件入口,负责编排(拆 system、解析动态配置、写回 state、重建 ReasoningInput) |
ConversationCompactor |
压缩算法本体(触发、cutoff、flush、offload、summarize) |
CompactionConfig |
配置中心 + 默认提示词 |
MemoryFlushManager |
长期记忆抽取 + 会话 JSONL 落盘 |
核心思想是 “三层渐进式压缩 + 链式总结 + 双层记忆”:
- 三层渐进式:先用最便宜的非 LLM 操作(参数截断、工具结果裁剪)把体积降下来;命中阈值就直接跳过昂贵的 LLM 总结。
- 链式总结:每次压缩生成的 summary 会留在上下文里,参与下一轮压缩,信息逐级浓缩不丢失。
- 双层记忆:日账本(append-only)+ MEMORY.md(curated),flush 与 consolidation 各司其职,避免重复抽取。
整体流程图(含触发判定)
┌─────────────────────────────────┐
│ onReasoning(input) 被调用 │
│ 每次 LLM 推理前触发 │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ 拆分:system 消息 + conversation │
│ (system 不参与压缩) │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ resolveEffectiveConfig() │
│ trigger = contextWindow - 20k │
│ keep = min(8k, max(2k, │
│ usable × 0.25)) │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ 第 1 层:truncateArgs() │
│ 触发? ≥25 消息 或 ≥40k tokens │
│ 动作:cutoff 前 ASSISTANT 的 │
│ ToolUseBlock 字符串入参 > 2000 │
│ → 截断为 前20字符 + 占位符 │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ 第 2 层:pruneToolResults() │
│ 保护最近 40k tokens 内的工具结果 │
│ 排除 read_file 等检索类工具 │
│ 可裁剪总量 ≥ 20k 才执行 │
│ 动作:head(1k) + 占位 + tail(1k) │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ shouldCompact? │
│ ≥50 消息 或 ≥trigger_tokens ? │
└──────┬──────────────┬───────────┘
│ 否 │ 是
│ │
┌──────────▼──┐ ┌───────▼────────────────┐
│ 放行原 input│ │ determineCutoffIndex │
│ next(input) │ │ 二分搜索使 tail ≤ keep │
└─────────────┘ │ findSafeCutoffPoint │
│ 保证 tool_use/result 不拆│
└───────┬────────────────┘
│
┌─────────────▼──────────────┐
│ prefix = msgs[0:cutoff] │
│ tail = msgs[cutoff:] │
│ flushInput = prefix \旧summary│
└─────────────┬──────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Step A: │ │ Step B: │ │ Step C: │
│ flushMemories│ │ offloadMsgs │ │ summarize │
│ (可选) │ │ (可选) │ │ Prefix │
│ │ │ │ │ │
│ LLM 抽取 │ │ 全量消息 │ │ LLM 蒸馏 │
│ 长期记忆 │ │ 追加到 │ │ 旧summary │
│ → │ │ session │ │ + prefix │
│ memory/ │ │ .jsonl │ │ → 新summary │
│ YYYY-MM-DD │ │ 返回路径 │ │ (四段式) │
│ .md │ │ │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌─────────────▼──────────────┐
│ buildSummaryMessage( │
│ summary, filePath) │
│ → USER 消息, name= │
│ "__compaction_summary__" │
│ → 幂等 ID (基于内容 UUID) │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ compacted = [summaryMsg] │
│ + tail │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ applyToContext(state, │
│ compacted) │
│ state.context.clear() │
│ state.context.addAll(...) │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ next(ReasoningInput( │
│ [sys] + compacted, │
│ tools, options)) │
└────────────────────────────┘
三、压缩规则:三层流水线
压缩并非"一上来就调 LLM 总结",而是按成本由低到高分三层执行。前一层命中后可能直接让 token 降到阈值下,省掉后两层。
第 1 层:参数截断(非 LLM)
针对 ToolUseBlock 的大字符串入参——典型场景是 write_file 把整个文件内容塞进 args。
- 触发:消息数 ≥ 25 或 token ≥ 40,000
- 保留窗口:最近 20 条消息不动
- 动作:把 cutoff 之前 ASSISTANT 消息里超过 2000 字符的字符串入参替换为
前20字符 + "...(argument truncated)"
// ConversationCompactor#truncateToolUseBlock
if (entry.getValue() instanceof String s && s.length() > cfg.getMaxArgLength()) {
newInput.put(entry.getKey(), s.substring(0, 20) + cfg.getTruncationText());
anyModified = true;
}
第 2 层:工具结果裁剪(非 LLM)
针对历史 ToolResultBlock 输出——比如 read_file 返回的几千行代码。
- 保护窗口
protectTokens=40000:最近的 4 万 token 内工具结果不碰 - 排除名单
{read_file, memory_search, memory_get, session_search}:永远不裁剪(这些是检索类,输出本身就是"记忆") - 裁剪阈值
minimumTokens=20000:可裁剪总量不到 2 万 token 就整体跳过 - 裁剪动作:head+tail 预览,保留首尾各 1000 字符:
// ConversationCompactor#buildPrunePreview
int half = maxChars / 2;
String head = text.substring(0, Math.min(half, text.length()));
String tail = text.length() > half
? text.substring(Math.max(text.length() - half, half)) : "";
return head + "\n\n...(" + removed + " chars pruned)...\n\n" + tail;
示例:工具结果裁剪
历史中有 10 个 read_file 结果各 5000 字符(≈2000 tokens),分布在消息 5-30:
保护窗口:最近 40,000 tokens 内不动
排除名单:read_file 永不裁剪
→ 这 10 个 read_file 结果全部保留
历史中有 10 个 bash 结果各 5000 字符,且都在保护窗口外:
可裁剪总量 = 10 × 2000 = 20,000 tokens ≥ minimumTokens(20000) → 执行裁剪
每个结果裁剪为:head(1000) + "...(4000 chars pruned)..." + tail(1000)
裁剪后每个 ≈ 800 tokens,10 个共 ≈ 8000 tokens
释放 ≈ 12,000 tokens
第 3 层:LLM 摘要
前两层降不下来才触发,把 prefix 交给 LLM 蒸馏成结构化 summary。这是最贵但也最聪明的一步。
四、核心流程
整个流程挂在 onReasoning 钩子上,每次 LLM 推理前触发:
onReasoning(input)
│
├─ 拆分 system / conversation ← system 不参与压缩
├─ resolveEffectiveConfig() ← 动态计算触发阈值与保留量
│
└─ compactor.compactIfNeeded(conversation)
│
├─ pruneToolResults(truncateArgs) ← 第1、2层(非LLM)
├─ shouldCompact? ── no ──► 放行原 input
│ yes
├─ determineCutoffIndex ← 二分搜索 + 工具对不拆分
│
├─ flushMemories(prefix) ← LLM 抽取长期记忆 → memory/YYYY-MM-DD.md
├─ offloadMessages(全量) ← 原始会话落 JSONL,返回路径
├─ summarizePrefix(prefix) ← LLM 生成四段式 summary
│
└─ return [summaryMsg] + tail
│
├─ applyToContext(state, compacted) ← 写回 AgentState
└─ next(ReasoningInput([sys]+compacted, tools, options))
4.1 动态配置解析
不用为每个模型手调参数,直接从 model.getContextWindowSize() 推导:
// CompactionMiddleware#resolveEffectiveConfig
int effectiveTrigger = contextWindow - config.getReserved(); // 默认 reserved=20000
// 若 reserved 超过窗口,钳制为 contextWindow/2
if (effectiveTrigger <= 0) {
effectiveTrigger = Math.max(1, contextWindow / 2);
}
int usable = contextWindow - config.getReserved();
int effectiveKeep = Math.min(
config.getKeepTokensMax(), // 8000
Math.max(
config.getKeepTokensMin(), // 2000
(int)(usable * config.getKeepTokensRatio()) // 0.25
)
);
模型不报窗口时回退 FALLBACK_TRIGGER_TOKENS = 160_000。
示例:动态阈值计算
模型 gpt-4o,contextWindowSize = 128,000:
reserved = 20,000
usable = 128,000 - 20,000 = 108,000
trigger = 128,000 - 20,000 = 108,000 tokens
keep = min(8000, max(2000, 108000 × 0.25))
= min(8000, max(2000, 27000))
= min(8000, 27000)
= 8,000 tokens
模型 claude-3-5-sonnet,contextWindowSize = 200,000:
trigger = 200,000 - 20,000 = 180,000 tokens
keep = min(8000, max(2000, 180000 × 0.25))
= min(8000, 45000)
= 8,000 tokens
模型不报 contextWindow(contextWindowSize = 0):
trigger = FALLBACK_TRIGGER_TOKENS = 160,000
keep = 0 → 回退到 keepMessages = 20 条
4.2 Cutoff 切分:二分搜索 + 工具对保护
把消息切成 prefix[0:cutoff](要总结)和 tail[cutoff:](原样保留)。用二分搜索找最早的下标使 messages[mid:].tokens <= keepTokens:
// ConversationCompactor#findTokenBasedCutoff
int maxIter = Integer.SIZE - Integer.numberOfLeadingZeros(messages.size()) + 1;
for (int i = 0; i < maxIter && left < right; i++) {
int mid = (left + right) / 2;
if (TokenCounterUtil.calculateToken(messages.subList(mid, messages.size())) <= keepTokens) {
candidate = mid;
right = mid;
} else {
left = mid + 1;
}
}
示例:cutoff 二分搜索
消息列表 80 条,总 120,000 tokens,keepTokens = 8,000,trigger = 108,000 → 触发压缩。
二分搜索找最早 mid 使 messages[mid:].tokens ≤ 8000:
left=0, right=80, candidate=80
迭代1: mid=40, tokens(msgs[40:])=60000 > 8000 → left=41
迭代2: mid=60, tokens(msgs[60:])=30000 > 8000 → left=61
迭代3: mid=70, tokens(msgs[70:])=12000 > 8000 → left=71
迭代4: mid=75, tokens(msgs[75:])=5000 ≤ 8000 → candidate=75, right=75
迭代5: mid=73, tokens(msgs[73:])=9000 > 8000 → left=74
迭代6: mid=74, tokens(msgs[74:])=7000 ≤ 8000 → candidate=74, right=74
结束: cutoff=74
结果:prefix = msgs[0:74](要总结),tail = msgs[74:80](保留 6 条)。
关键约束:cutoff 永远不能切断 ASSISTANT tool_use 和它对应的 TOOL tool_result,否则下游 LLM 会报"orphan tool result"。findSafeCutoffPoint 做了保护:
// 若 cutoff 落在 TOOL 消息上
if (atCutoff.getRole() == MsgRole.TOOL) {
// 收集连续 TOOL 消息里的 toolCallIds
// 向前找含匹配 ToolUseBlock.id 的 ASSISTANT
// 把 cutoff 前移到该 ASSISTANT 之前
for (int i = cutoffIndex - 1; i >= 0; i--) {
if (msg.getRole() == MsgRole.ASSISTANT) {
for (ContentBlock block : msg.getContent()) {
if (block instanceof ToolUseBlock tu
&& toolCallIds.contains(tu.getId())) {
return i; // 前移 cutoff
}
}
}
}
}
示例:工具对保护
假设 cutoff 算出来是 75,但 msgs[75] 是 TOOL 消息:
索引: ... 73 | 74 | 75 | 76 | 77 | 78 ...
角色: ... A | U | TOOL | TOOL | A | U ...
内容: (含tool_use id="t1")
cutoff=75 落在 TOOL 上 → 危险!会切断 tool_use/tool_result 对
findSafeCutoffPoint 修正:
1. msgs[75].role == TOOL → 触发保护
2. 收集 msgs[75..76] 的 ToolResultBlock.id = ["t1", "t2"]
3. 向前找 ASSISTANT 含 ToolUseBlock.id ∈ ["t1","t2"]
→ msgs[73] 是 ASSISTANT,含 tool_use id="t1"
4. 返回 cutoff = 73(把 ASSISTANT 一起放进 prefix)
修正后:prefix = msgs[0:73],tail = msgs[73:80],工具对完整。
4.3 链式总结:让信息逐级浓缩
这是整个方案最巧妙的设计。每次压缩生成的 summary 消息会保留在 AgentState 中:
- 下次压缩时,旧 summary 属于
summaryInput,参与 LLM 总结 → 新 summary 基于旧 summary 递进 - 但旧 summary 不参与 flush(
filterSummaryMessages过滤掉),避免把已抽取过的记忆再抽一遍
List<Msg> summaryInput = new ArrayList<>(messages.subList(0, cutoff));
List<Msg> flushInput = filterSummaryMessages(summaryInput); // 剔除旧 summary
List<Msg> tail = new ArrayList<>(messages.subList(cutoff, messages.size()));
这样多轮压缩不会丢失早期信息,同时避免 flush 重复劳动。
示例:链式总结的信息流
第 1 轮压缩(第 50 条消息时触发):
prefix = msgs[0:42], tail = msgs[42:50]
summaryInput = msgs[0:42] ← 无旧 summary
新 summary S1 生成
AgentState = [S1] + msgs[42:50]
第 2 轮压缩(第 100 条消息时触发):
当前 AgentState = [S1] + msgs[42:100]
prefix = [S1] + msgs[42:90], tail = msgs[90:100]
summaryInput = [S1] + msgs[42:90] ← S1 参与总结
flushInput = msgs[42:90] ← S1 被过滤,不重复抽取
新 summary S2 生成(基于 S1 + 新内容)
AgentState = [S2] + msgs[90:100]
第 3 轮压缩(第 150 条消息时触发):
summaryInput = [S2] + msgs[90:140]
新 summary S3 生成(基于 S2 + 新内容)
AgentState = [S3] + msgs[140:150]
无论压缩多少次,AgentState 始终是 [1 个 summary] + [≤keepTokens 的 tail],体积恒定。
4.4 双层长期记忆
memory/YYYY-MM-DD.md:append-only 日账本,由MemoryFlushManager每次 flush 追加MEMORY.md:curated 长期记忆,由独立的MemoryConsolidator周期性把日账本合并、去重、限流
flush 提示词里明确告诉模型两者的职责边界,避免重复抽取。
4.5 全链路降级
任何一步失败都用 onErrorResume 兜底,最坏退化为"不压缩直接推理",永不让 Agent 卡死:
// flush 失败
.onErrorResume(e -> { log.warn(...); return Mono.empty(); })
// offload 失败
.onErrorResume(e -> { log.warn(...); return Mono.just(""); })
// summarize 失败
.onErrorResume(e -> Mono.just("(Summarization failed: " + e.getMessage() + ")"))
// 整个 compactIfNeeded 失败(在 Middleware 层)
.onErrorResume(e -> {
log.warn("Compaction failed, continuing without compaction: {}", e.getMessage());
return next.apply(input);
})
五、两个关键提示词
5.1 压缩总结提示词
强制四段式结构,确保关键信息不丢:
<role>
Context Extraction Assistant
</role>
<primary_objective>
Your sole objective in this task is to extract the highest quality/most relevant
context from the conversation history below.
</primary_objective>
<objective_information>
You're nearing the total number of input tokens you can accept, so you must extract
the highest quality/most relevant pieces of information from your conversation history.
This context will then overwrite the conversation history presented below. ...
</objective_information>
<instructions>
The conversation history below will be replaced with the context you extract in this step.
You want to ensure that you don't repeat any actions you've already completed, ...
Structure your summary using these sections (populate each or write "None"):
## SESSION INTENT
What is the user's primary goal or request?
## SUMMARY
The most important context, decisions, reasoning, and rejected options.
## ARTIFACTS
Files or resources created, modified, or accessed (with specific paths and changes).
## NEXT STEPS
Specific tasks remaining to achieve the session intent.
</instructions>
Carefully read through the entire conversation history below and extract the most
important context. Respond ONLY with the extracted context.
<messages>
{messages}
</messages>
中文翻译:
<role>
上下文抽取助手
</role>
<primary_objective>
你在本任务中的唯一目标,是从下方的对话历史中抽取最高质量、最相关的上下文。
</primary_objective>
<objective_information>
你即将达到所能接收的输入 token 上限,因此必须从对话历史中抽取最高质量、最相关的信息。
这些上下文随后将覆盖下方展示的对话历史。正因如此,请确保所抽取的上下文仅包含对推进
整体目标最重要的信息。
</objective_information>
<instructions>
下方的对话历史将被你在本步骤中抽取的上下文替换。你希望避免重复任何已经完成的动作,
因此从对话历史中抽取的上下文应聚焦于对整体目标最重要的信息。
请使用以下章节组织你的总结(每节都要填写,若无内容则写 "None"):
## SESSION INTENT
用户的首要目标或请求是什么?
## SUMMARY
最重要的上下文、决策、推理过程以及被否决的方案。
## ARTIFACTS
已创建、修改或访问的文件或资源(含具体路径与变更内容)。
## NEXT STEPS
为达成会话意图,剩余需要完成的具体任务。
</instructions>
请仔细阅读下方完整的对话历史,并抽取最重要的上下文。仅以抽取出的上下文作为回复。
<messages>
{messages}
</messages>
设计要点:
- “会覆盖原历史”——给模型压力,让它别漏关键信息
- “避免重复已完成动作”——直接对应"原地打转"问题
- 四段式结构——SESSION INTENT 保目标、SUMMARY 保决策、ARTIFACTS 保文件状态、NEXT STEPS 保待办,覆盖 Agent 续作所需全部信息
- “Respond ONLY with the extracted context”——杜绝废话
5.2 长期记忆抽取提示词
You are a memory extraction assistant. Analyze the conversation below and extract
important facts, decisions, preferences, and contextual information that should be
remembered for future conversations.
Output ONLY the extracted memories as a markdown bullet list. Each item should be
a concise, self-contained fact. Include dates, names, and specifics when available.
If there is nothing worth remembering, respond with exactly: NO_REPLY
Guidelines:
- Extract user preferences, personal information, project decisions
- Capture important technical decisions and their rationale
- Note any commitments, deadlines, or action items
- Record relationship context (who works on what, team structure)
- Ignore routine greetings, tool invocations, and ephemeral status updates
IMPORTANT — write target and append-only rules:
- You are writing to TODAY'S daily memory ledger (memory/YYYY-MM-DD.md), NOT to
MEMORY.md. The daily ledger is append-only — your output will be appended after the
entries already shown below.
- MEMORY.md is the curated long-term memory and is shown ONLY as read-only context.
Do NOT restate facts already covered by MEMORY.md or by today's earlier entries; a
separate consolidation step periodically merges new daily entries into MEMORY.md.
- Keep each bullet point independent and self-contained so entries can be searched
individually.
中文翻译:
你是一个记忆抽取助手。请分析下方的对话,并抽取对未来对话应当记住的重要事实、决策、
偏好与上下文信息。
仅以 markdown 项目符号列表的形式输出抽取到的记忆。每条都应是一个简洁、自包含的事实。
在可用时请包含日期、姓名和具体细节。
如果没有值得记住的内容,请精确回复:NO_REPLY
准则:
- 抽取用户偏好、个人信息、项目决策
- 捕捉重要的技术决策及其理由
- 记录任何承诺、截止日期或行动项
- 记录关系上下文(谁负责什么、团队结构)
- 忽略例行寒暄、工具调用本身以及临时性的状态更新
重要——写入目标与 append-only 规则:
- 你正在写入"今天的"每日记忆账本(memory/YYYY-MM-DD.md),而不是 MEMORY.md。
每日账本是 append-only 的——你的输出将被追加到下方已展示条目的后面。
- MEMORY.md 是经过策展的长期记忆,仅作为只读上下文展示。
不要复述 MEMORY.md 或今天早先条目中已涵盖的事实;一个独立的合并步骤会定期将新的
每日条目合并进 MEMORY.md。
- 保持每条要点相互独立、自包含,以便条目可以被单独检索。
要点:
NO_REPLY显式信号——避免模型硬凑记忆- 明确写入目标(日账本)与只读上下文(MEMORY.md)的边界
- bullet 独立自包含——为后续检索友好
六、Token 估算:不调 tokenizer 的轻量方案
不依赖具体模型的 tokenizer,纯字符估算,跨模型通用:
// TokenCounterUtil
private static final double CHARS_PER_TOKEN = 2.5; // 中英混合折中
private static final int MESSAGE_OVERHEAD = 5; // 角色/name/格式
private static final int TOOL_CALL_OVERHEAD = 10;
private static final int TOOL_RESULT_OVERHEAD = 8;
private static int estimateTextTokens(String text) {
return (int) Math.ceil(text.length() / CHARS_PER_TOKEN);
}
英文 ≈4 字符/token,中文 ≈1-2 字符/token,取 2.5 作为折中。估算精度足够用于触发判定和 cutoff 切分,但成本远低于真实 tokenize。
七、Summary 消息的巧思
生成的 summary 是一条 USER 角色 消息,name 标记为 __compaction_summary__,便于 hooks 识别:
// ConversationCompactor#buildSummaryMessage
String content;
if (filePath != null) {
content =
"You are in the middle of a conversation that has been summarized.\n\n"
+ "The full conversation history has been saved to " + filePath
+ " should you need to refer back to it for details.\n\n"
+ "A condensed summary follows:\n\n"
+ "<summary>\n" + summary + "\n</summary>";
} else {
content = "Here is a summary of the conversation to date:\n\n" + summary;
}
幂等 ID 是另一个细节:基于内容的 name-based UUID,相同 summary 重复 offload 不会产生重复 ID。
private static String buildSummaryMessageId(String content) {
UUID stableId = UUID.nameUUIDFromBytes(content.getBytes(StandardCharsets.UTF_8));
return SUMMARY_MSG_NAME + ":" + stableId;
}
八、成本模型:为什么是线性而非平方
文档里那句"token 随对话长度近似线性而非平方"是这套方案最核心的经济价值,下面用数学和具体例子讲清楚。
8.1 无压缩:平方爆炸
假设每次对话回合新增 c tokens(用户提问 + assistant 回答 + 工具调用结果),第 N 轮推理时上下文已累积 N·c tokens。无压缩时,第 i 轮推理要把全部历史发给模型:
第 i 轮输入 = i · c
N 轮累计输入 = Σ(i=1..N) i·c = c · N(N+1)/2 ≈ c·N²/2 ← O(N²)
举例:c = 2000 tokens/轮,N = 100 轮
- 第 100 轮单次输入:
100 × 2000 = 200,000tokens - 100 轮累计输入:
2000 × 100 × 101 / 2 ≈ 10,100,000tokens(1010 万) - 假设 1M tokens = $2,光输入就 ≈ $20/百轮,且成本随轮次平方增长
8.2 压缩后:线性模型
压缩触发后,每轮推理输入固定为:
[sys] + [summary] + tail
设:
sys_tokens= 系统提示(固定,如 2000)summary_tokens= 压缩摘要(固定,如 1500)keep_tokens= tail 保留窗口(固定,如 4000,即effectiveKeep)
每轮推理输入 ≈ K = sys_tokens + summary_tokens + keep_tokens(常数)
N 轮累计输入 = Σ(i=1..N) K = K · N ← O(N)
举例:K = 7500 tokens/轮,N = 100 轮
- 第 100 轮单次输入:
7500tokens(恒定) - 100 轮累计输入:
7500 × 100 = 750,000tokens(75 万)
对比:10,100,000 vs 750,000 → 节省 92.6%。且 N 越大差距越夸张:
| N(轮数) | 无压缩累计 | 压缩后累计 | 节省 |
|---|---|---|---|
| 50 | 2.55M | 0.375M | 85.3% |
| 100 | 10.1M | 0.75M | 92.6% |
| 500 | 250M | 3.75M | 98.5% |
8.3 关键:summary 是"滚动替换"而非"追加"
summary 不会无限变长。每次压缩:
输入 = 旧 summary + 新 prefix
输出 = 新 summary ← 替换旧 summary,不是 append
所以 summary_tokens 始终维持在 ~1500 量级,与对话进行了多少轮无关。这就是 4.3 链式总结 的核心价值:信息逐级浓缩,summary 体积恒定。
如果不做链式(每次都把新 summary 追加到旧 summary 后面),summary 会线性膨胀,整个模型就退化回 O(N²) 了。
8.4 压缩本身的成本也要算
压缩不是免费的,每次触发要调 2 次 LLM(flush + summarize),输入都是 prefix tokens ≈ trigger_tokens。但压缩只在触发阈值时执行,不是每轮都做。
设每 P 轮触发一次压缩(P ≈ trigger_tokens / c),则:
压缩成本 ≈ (N/P) × 2 × trigger_tokens
总成本 = 推理成本 + 压缩成本
= K·N + (N/P) × 2 × trigger_tokens
= N × (K + 2·trigger_tokens/P) ← 仍是 O(N)
举例:K=7500,trigger_tokens=100,000,c=2000 → P ≈ 50 轮触发一次
推理:7500 × 100 = 750k
压缩:(100/50) × 2 × 100k = 400k
总计:1.15M tokens
对比无压缩 10.1M,仍节省 88.6%。可见即使把压缩成本算进去,线性优势依然压倒性。
8.5 成本曲线对比图
累计 token
↑
│ ╱ 无压缩 O(N²)
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│
│ 压缩后 O(N) ─────────────────────────────────
│
└─────────────────────────────────────────────→ 对话轮数 N
九、完整设计方案与实现蓝图
本节给出可独立实现的完整设计,包含默认阈值、流程图、关键示例。按此方案从零实现一套压缩系统,无需依赖 agentscope-harness 的具体类。
9.1 默认阈值速查表
| 参数 | 默认值 | 说明 | 出处 |
|---|---|---|---|
triggerMessages |
50 | 消息数触发阈值 | CompactionConfig.Builder |
triggerTokens |
0(动态) | 0=动态模式;>0=静态阈值 | 动态计算为 contextWindow - reserved |
reserved |
20,000 | 给压缩过程本身留的 token 缓冲 | 动态模式专用 |
FALLBACK_TRIGGER_TOKENS |
160,000 | 模型不报 contextWindow 时的回退触发阈值 | 常量 |
keepMessages |
20 | 静态模式下保留的尾部消息数 | keepTokens=0 时生效 |
keepTokens |
-1(动态) | -1=动态;0=用 keepMessages;>0=静态预算 | 动态计算见下 |
keepTokensMin |
2,000 | 动态 keep 下限 | min/max 钳制 |
keepTokensMax |
8,000 | 动态 keep 上限 | min/max 钳制 |
keepTokensRatio |
0.25 | 动态 keep 比例:usable × 0.25 |
usable = contextWindow - reserved |
| 动态 keep 公式 | min(8000, max(2000, usable × 0.25)) |
tail 保留预算 | — |
| 动态 trigger 公式 | contextWindow - 20000 |
触发阈值;≤0 时钳制为 contextWindow/2 |
— |
flushBeforeCompact |
true | 压缩前抽取长期记忆 | — |
offloadBeforeCompact |
true | 压缩前把全量消息落 JSONL | — |
| 参数截断触发 | 25 消息 或 40,000 tokens | TruncateArgsConfig |
第 1 层 |
| 参数截断保留 | 最近 20 条消息 | cutoff 之前才截断 | — |
maxArgLength |
2,000 字符 | 单个工具入参最大长度 | 超过则截断为 前20字符 + "...(argument truncated)" |
| 工具结果裁剪保护 | 40,000 tokens | 最近 4 万 token 内工具结果不动 | PruneConfig.protectTokens |
| 工具结果裁剪阈值 | 20,000 tokens | 可裁剪总量不到此值就整体跳过 | PruneConfig.minimumTokens |
maxOutputChars |
2,000 字符 | 裁剪后 head+tail 各 1000 字符 | PruneConfig |
excludedTools |
{read_file, memory_search, memory_get, session_search} |
永不裁剪的检索类工具 | PruneConfig |
| Token 估算 | ceil(len / 2.5) |
中英混合折中,1 token ≈ 2.5 字符 | TokenCounterUtil |
MESSAGE_OVERHEAD |
5 tokens | 每条消息结构开销 | role/name/格式 |
TOOL_CALL_OVERHEAD |
10 tokens | 每个 ToolUseBlock 开销 | — |
TOOL_RESULT_OVERHEAD |
8 tokens | 每个 ToolResultBlock 开销 | — |
9.2 实现要点清单
自行实现时需注意的关键约束:
- system 消息单独处理:压缩只作用于 conversation,system 消息必须拆出来原样保留。
- cutoff 永不切断 tool_use/tool_result 对:这是 LLM API 硬约束,否则报 orphan tool result 错误。
- summary 是 USER 角色:不是 SYSTEM,因为 SYSTEM 在压缩时被单独保留了;USER 角色的 summary 会被后续压缩再次处理。
- summary 消息要打标记(如
name="__compaction_summary__"):便于 flush 时过滤,避免重复抽取记忆。 - summary 消息 ID 要幂等:基于内容的 name-based UUID,相同内容不产生重复 ID。
- 三层压缩顺序固定:truncateArgs → pruneToolResults → shouldCompact → summarize。前两层是非 LLM 操作,命中后可能直接降 token 到阈值下,省掉 LLM 调用。
- 动态阈值要钳制:
trigger ≤ 0时钳制为contextWindow/2,避免每轮都触发压缩导致 thrashing。 - 全链路降级:flush/offload/summarize 任一失败都
onErrorResume,最坏退化为不压缩直接推理,绝不让 Agent 卡死。 - offload 全量而非 prefix:落盘的是整段 conversation,不是被总结的 prefix,这样需要细节时能完整检索。
- summary 文件路径写入 summary 消息:让 Agent 知道全量历史在哪,需要时可主动检索。
- flush 只写日账本,不写 MEMORY.md:MEMORY.md 由独立的 consolidation 步骤异步合并,读写路径解耦。
- Token 估算用字符近似:
ceil(len/2.5)跨模型通用,精度足够触发判定,成本远低于真实 tokenize。
十、解决了什么问题
回到开头的三堵墙:
| 问题 | 解决方式 |
|---|---|
| 上下文窗口硬上限 | 动态阈值 + 三层压缩,每次推理前主动压到窗口内 |
| 注意力衰减 | 保留 tail(最近 keepTokens)原样,关键 recent 信息高权重;summary 把早期信息浓缩成结构化要点 |
| 成本爆炸 | 压缩后每轮推理只送 [sys] + [summary] + tail,token 随对话长度近似线性而非平方 |
| Agent 原地打转 | summary 提示词强制 ARTIFACTS + NEXT STEPS,明确已完成动作与待办 |
| 长期信息丢失 | flush 把跨会话有用信息抽到 memory/YYYY-MM-DD.md,consolidator 周期合并进 MEMORY.md |
| 早期信息被多次压缩稀释 | 链式总结:旧 summary 参与新总结,逐级浓缩而非丢弃 |
| 工具调用孤儿错误 | findSafeCutoffPoint 保证 tool_use ↔ tool_result 不被切断 |
| 单点故障让 Agent 卡死 | 全链路 onErrorResume 降级,最坏退化为不压缩直接推理 |
十一、可借鉴的工程经验
- 渐进式降本:非 LLM 操作(截断、裁剪)先打头阵,命中即省 LLM 调用。这不是炫技,是实打实的成本优化。
- 不动 recent,只压历史:保留窗口内的消息永远原样,保证当前任务上下文完整。
- 链式而非独立总结:每次总结基于上一次结果,避免信息在多次压缩中指数级丢失。
- 写入与检索分离:flush 只写日账本,consolidation 异步合并,读写路径解耦。
- 全链路降级:任何子系统失败都不应让主流程卡死,Agent 必须能继续推理。
- 提示词工程要带结构:四段式 summary、
NO_REPLY信号、明确的读写边界——结构化输出比自由文本可靠得多。 - 幂等设计:基于内容的 UUID 让重复操作安全,这在长会话里很重要。
十二、结语
CompactionMiddleware 的价值不在于某一个聪明点,而在于把"动态阈值 → 三层压缩 → 安全切点 → 链式总结 → 双层记忆 → 全链路降级"这一整套链路工程化落地。它让 Agent 在有限上下文窗口内既能保持长期一致性,又不至于为每次推理付出平方级成本。
如果你在做自己的 Agent 框架,这套方案的很多部件(参数截断、工具对保护、链式总结、双层记忆)都可以直接借鉴。提示词的设计思路——结构化输出、明确读写边界、显式空值信号——也适用于任何需要 LLM 做信息抽取的场景。
相关源码
- [CompactionMiddleware.java](file:///e:/ruantong/aiagentscope-java/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/CompactionMiddleware.java)
- [ConversationCompactor.java](file:///e:/ruantong/aiagentscope-java/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/ConversationCompactor.java)
- [CompactionConfig.java](file:///e:/ruantong/aiagentscope-java/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/CompactionConfig.java)
- [MemoryFlushManager.java](file:///e:/ruantong/aiagentscope-java/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java)
- [TokenCounterUtil.java](file:///e:/ruantong/aiagentscope-java/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/TokenCounterUtil.java)
更多推荐



所有评论(0)