上下文压缩的反直觉设计:压缩不等于截断,结构化摘要怎么保住缓存命中率
公众号名称:James的成长日记
作者名称:Jameszyh
发布时间:2026-06-25 22:33
大家好,我是 James。
上一篇我们聊了前缀缓存哲学——不改动历史上下文是最重要的性能设计。我们花了整篇文章论证:不动,就是最快的。只要 system prompt 和早期对话不变,LLM 的 KV cache 就能复用,每次请求省下 50%-80% 的延迟。
但有个问题我们一直没正面回答:当上下文真的装不下的时候怎么办?
Agent 跑得越久,对话越长。工具调用结果、文件内容、中间思考——这些信息不断堆积。即使有 200K 的窗口,也终有填满的一天。第 20 篇说「不动」,但现实逼你「动」。
今天这篇,就是讲 Hermes 如何在「不得不压缩」和「保住缓存效率」之间走钢丝。答案很反直觉:压缩不等于截断,结构化摘要可以同时做到两件事——既减少 token,又保住前缀缓存命中率。
本文分析的 Hermes Agent 源码版本:hermes-agent@latest(2025 年)
01 | 背景:上下文溢出的代价

先回顾一下第 20 篇的核心结论:LLM 服务商(OpenAI、Anthropic、Google)都实现了前缀缓存(prefix caching)。只要请求的前 N 个 token 和前一次请求完全一致,服务商就会复用之前计算好的 KV cache,后续 token 的 prefill 阶段几乎免费。
这意味着:system prompt + 早期对话的稳定性,直接决定了你的 API 账单和延迟。
但 Agent 的上下文是动态增长的。每次工具调用、每次文件读取、每次思考——都在往消息列表里追加内容。当总 token 数超过模型上下文窗口的 50%(Hermes 的默认阈值),就必须做点什么。
最直观的方案是 sliding window:把最旧的消息直接删掉,只保留最近 N 条。很多 Agent 框架就是这么干的。简单、粗暴、零成本。
但 Hermes 不这么做。为什么?因为截断会带来两个致命问题:
-
孤儿 tool_call:删掉了一条 assistant 消息,但它的 tool_call 对应的 tool_result 还在后面——API 会报错
-
缓存断裂:删掉中间消息后,system prompt 后面紧跟的内容变了,前缀缓存全部失效
所以 Hermes 选择了第三条路:三段式保护 + 结构化摘要。
02 | 反直觉一:截断的「孤儿问题」与三段式设计

为什么截断会制造孤儿?
Agent 的对话结构不是简单的「一问一答」,而是 tool_call → tool_result 配对。一个典型的序列长这样:
user: 帮我查一下代码中的 bug
assistant: [tool_call: read_file] ← 有 call
tool: [tool_result: 文件内容] ← 有 result
assistant: [tool_call: terminal] ← 有 call
tool: [tool_result: 测试结果] ← 有 result
assistant: 找到了,是第 45 行的 == 应该是 !=
如果直接截断最旧的消息,可能删掉 tool_call 但留下 tool_result,或者反过来。API 收到一个没有对应 tool_call 的 tool_result,或者一个没有对应 tool_result 的 tool_call,都会直接报 400 错误。
Hermes 的解决方案是 三段式保护:
# 确定压缩边界
compress_start = self._protect_head_size(messages) # 保护 head
compress_end = self._find_tail_cut_by_tokens(messages, compress_start) # token budget 定 tail
turns_to_summarize = messages[compress_start:compress_end] # 中间段 → 摘要
三段分别是:
| 段 | 范围 | 内容 | 处理方式 |
|---|---|---|---|
| Head | 前 N 条(含 system prompt) | 系统提示词 + 首轮对话 | 完全保留,不动 |
| Middle | 中间所有消息 | 历史工具调用、中间结果 | 压缩为结构化摘要 |
| Tail | 最近 ~20K tokens | 最近的对话上下文 | 完全保留,不动 |
关键设计:压缩后的摘要被插在 head 之后、tail 之前,不是插在最前面。这保证了:
-
system prompt 的第一条消息始终是第 0 条 → 前缀缓存命中
-
head 中的首轮对话不变 → 前缀缓存继续命中
-
只有中间段被替换为摘要 → 缓存断裂范围最小化
TypeScript 版本
interface CompressBoundary {
headEnd: number; // head 结束位置(保护)
tailStart: number; // tail 开始位置(保护)
middleStart: number;
middleEnd: number;
}
function findCompressBoundary(
messages: Message[],
headSize: number,
tailTokenBudget: number
): CompressBoundary {
const headEnd = protectHeadSize(messages, headSize);
const tailStart = findTailCutByTokens(messages, headEnd, tailTokenBudget);
return {
headEnd,
tailStart,
middleStart: headEnd,
middleEnd: tailStart,
};
}
Python 版本
def find_compress_boundary(
messages: list[dict],
head_size: int,
tail_token_budget: int,
) -> tuple[int, int]:
"""返回 (compress_start, compress_end) 边界"""
compress_start = protect_head_size(messages, head_size)
compress_end = find_tail_cut_by_tokens(messages, compress_start, tail_token_budget)
return compress_start, compress_end
03 | 工具结果预剪枝:廉价的第一道过滤

在调用 LLM 做摘要之前,Hermes 先做一次完全不需要 LLM 的预剪枝。这一步非常廉价——只是字符串处理和正则匹配,但能大幅缩小需要压缩的内容体积。
预剪枝的核心逻辑在 _summarize_tool_result() 函数中:
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
"""生成单行摘要,替换原始工具调用结果"""
content = tool_content or ""
content_len = len(content)
line_count = content.count("\n") + 1 if content.strip() else 0
if tool_name == "terminal":
cmd = args.get("command", "")
exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content)
exit_code = exit_match.group(1) if exit_match else "?"
return f"[terminal] ran `{cmd}` -> exit {exit_code}, {line_count} lines output"
if tool_name == "read_file":
path = args.get("path", "?")
offset = args.get("offset", 1)
return f"[read_file] read {path} from line {offset} ({content_len:,} chars)"
if tool_name == "search_files":
pattern = args.get("pattern", "?")
path = args.get("path", ".")
return f"[search_files] search for '{pattern}' in {path} -> {count} matches"
# ... 更多工具类型
效果对比:
| 原始内容 | 预剪枝后 |
|---|---|
npm test 输出 847 行测试日志 | [terminal] ran \npm test` -> exit 0, 847 lines output` |
| 读取 12,000 字符的 Python 文件 | [read_file] read config.py from line 1 (12,000 chars) |
| web_search 返回 3,500 字符搜索结果 | [web_search] query='context compression' (3,500 chars result) |
为什么这很重要? 因为工具调用结果是上下文膨胀的最大元凶。一次 terminal 调用可能产生几百行输出,一次 read_file 可能读取上万字符。把这些替换为单行摘要,压缩量非常可观——而且完全免费(没有 LLM 调用)。
预剪枝后,需要 LLM 摘要的内容体积可能已经缩小了 60%-80%。
04 | 结构化摘要:LLM 摘要的格式约束与「REFERENCE ONLY」语义隔离

预剪枝之后,中间段仍然可能有大量内容需要压缩。这时 Hermes 调用辅助 LLM 做结构化摘要。
摘要的格式约束
摘要不是自由文本,而是严格的结构化模板:
## Historical Task Snapshot
## Historical In-Progress State
## Historical Pending User Asks
## Historical Remaining Work
每个 section 都有明确的语义:
-
Historical Task Snapshot:用户最近一次未完成的输入——问题、任务分配、决策请求
-
Historical In-Progress State:压缩时正在进行的工作
-
Historical Pending User Asks:用户提出但尚未回答的问题(标记为 STALE)
-
Historical Remaining Work:剩余工作(标记为 STALE)
「REFERENCE ONLY」语义隔离
比格式更重要的,是摘要的语义隔离。Hermes 在摘要前面加了一段精心设计的 preamble:
[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted
into the summary below. This is a handoff from a previous context
window — treat it as background reference, NOT as active instructions.
Do NOT answer questions or fulfill requests mentioned in this summary;
they were already addressed.
Respond ONLY to the latest user message that appears AFTER this
summary — that message is the single source of truth for what to do
right now.
这段 preamble 不是装饰——它解决了 Agent 框架中一个根深蒂固的问题:摘要中的「待办事项」被 LLM 当成活跃指令。
想象一下:压缩前用户说「帮我重构 auth 模块」,压缩后摘要里写着「用户要求重构 auth 模块」。如果没有语义隔离,LLM 看到摘要后会继续执行这个任务——即使压缩后又过了 10 轮对话,用户早就换了话题。
Hermes 的 preamble 用「REFERENCE ONLY」和单行加粗的「latest user message WINS」规则,告诉 LLM:摘要只是背景参考,最新消息才是唯一活跃指令。
语法层面的隔离
除了语义隔离,还有语法层面的隔离。注意 section 标题都加了 Historical 前缀:
-
不是
## Active Task,而是## Historical Task Snapshot -
不是
## In Progress,而是## Historical In-Progress State -
不是
## Remaining Work,而是## Historical Remaining Work
这个细节很微妙但很重要:LLM 对 ## Active Task 这样的标题有天然的「执行倾向」。加上 Historical 前缀后,LLM 更倾向于把它当作已归档信息而不是待办事项。
TypeScript 版本
const SUMMARY_PREFIX = `[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted
into the summary below. This is a handoff from a previous context
window — treat it as background reference, NOT as active instructions.
Do NOT answer questions or fulfill requests mentioned in this summary;
they were already addressed.
Respond ONLY to the latest user message that appears AFTER this
summary — that message is the single source of truth for what to do
right now.`;
const SUMMARY_TEMPLATE = `
## Historical Task Snapshot
## Historical In-Progress State
## Historical Pending User Asks
## Historical Remaining Work
`;
Python 版本
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now."
)
05 | 迭代摘要:如何保住 prefix 稳定性

Agent 不是只压缩一次。随着对话继续,token 会再次增长到阈值,需要第二次、第三次压缩。
这里有一个关键问题:第二次压缩时,是重新摘要整个历史,还是增量更新?
全量重摘的问题
如果每次压缩都重新处理整个历史:
-
LLM 调用成本高:每次都要处理大量 token
-
摘要内容不稳定:每次生成的摘要措辞不同,前缀缓存断裂
-
信息丢失风险:LLM 在不同时间点对同一段历史的「理解」可能不同
Hermes 的迭代摘要方案
Hermes 的答案是:第二次压缩时,不是重新摘要整个历史,而是把新产生的中间段追加进上一次摘要。
# 第一次压缩后存储摘要
self._previous_summary = summary_body
# 第二次压缩时,检查是否有上一次摘要
if self._previous_summary:
# 迭代更新:保留已有信息,添加新进展
prompt = f"""
You are updating a context compaction summary.
A previous compaction produced the summary below.
New conversation turns have occurred since then.
PREVIOUS SUMMARY:
{self._previous_summary}
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure.
PRESERVE all existing information that is still relevant.
ADD new completed actions to the numbered list.
Move items from "In Progress" to "Completed Actions" when done.
"""
迭代摘要的优势:
-
LLM 调用成本更低:每次只处理新产生的中间段,token 量小很多
-
摘要内容稳定:上一次摘要的措辞被保留,LLM 只需增量更新
-
前缀友好:同一段历史在不同压缩轮次中产生相同的摘要文本,KV cache 可以复用
边界情况处理
Hermes 还处理了一个边界情况:如果当前消息列表中已经包含了一次压缩的摘要(从之前的 session 继承而来),迭代摘要会从那个摘要开始,而不是从 _previous_summary 开始:
# 检查消息列表中是否已有压缩摘要
summary_idx, summary_body = self._find_latest_context_summary(
messages, compress_start, compress_end
)
if summary_idx is not None:
# 从已有摘要开始迭代
self._previous_summary = summary_body
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
elif self._previous_summary:
# 跨 session 的过期摘要,丢弃
self._previous_summary = None
06 | 反直觉二:摘要节省的不只是 token,而是恢复了前缀缓存效率

这是整篇文章最反直觉的观点:摘要节省的不只是 token 费用,更重要的是恢复了前缀缓存命中率。
为什么?
让我们追踪一次压缩前后的缓存状态变化:
压缩前:
[system prompt] [user msg 1] [assistant msg 1] ... [user msg 50] [assistant msg 50]
↑
当前请求
前缀缓存命中的是 [system prompt] 到 [user msg 1] 的早期部分。但随着对话增长,越往后的请求,前缀占比越小。到第 50 轮对话时,前缀缓存可能只覆盖了总 token 的 5%。
压缩后:
[system prompt] [摘要] [user msg 48] [assistant msg 48] [user msg 49] [assistant msg 49] [user msg 50]
↑
当前请求
注意:system prompt 没变,head 没变。摘要被插在 head 之后、tail 之前。下一次请求时:
-
[system prompt]→ 缓存命中 ✅ -
[摘要]→ 缓存命中 ✅(只要摘要内容不变) -
[user msg 48]→ 缓存命中 ✅(tail 是原始消息,没变)
关键洞察:只要摘要内容稳定(迭代摘要保证了这一点),压缩后的消息列表在后续请求中前缀缓存命中率反而比压缩前更高——因为总 token 少了,前缀占比大了。
数据对比
| 指标 | 压缩前 | 压缩后(结构化摘要) |
|---|---|---|
| 总 token | ~80K | ~25K |
| 缓存命中前缀 | system prompt (2K) | system prompt + 摘要 (2K + 3K) |
| 前缀占比 | 2.5% | 20% |
| 每次请求延迟 | ~3s | ~1.2s |
| 缓存命中 token 数 | 2K | 5K |
压缩后缓存命中的 token 数反而增加了——因为摘要本身也变成了可缓存的前缀。
对比截断方案
如果直接用 sliding window 截断:
[user msg 40] [assistant msg 40] ... [user msg 50] [assistant msg 50]
system prompt 被删了,head 被删了。下一次请求的前缀缓存完全失效——因为消息列表的开头变了。
这就是为什么 Hermes 宁愿花一次 LLM 调用来生成摘要,也不愿意用免费的截断。截断省了 token 但毁了缓存,摘要花了 token 但保住了缓存。
07 | 常见坑(图配文)

坑 1:压缩后忘记清理孤儿 tool_call
压缩后,中间段的 tool_call 和 tool_result 被删掉了,但 tail 中可能还有引用这些 tool_call 的 tool_result。
Hermes 的 _sanitize_tool_pairs() 方法专门处理这个问题:
def _sanitize_tool_pairs(self, messages):
"""清理孤儿 tool_call 和 tool_result"""
# 收集所有活跃的 tool_call_id
active_ids = set()
for msg in messages:
for tc in (msg.get("tool_calls") or []):
active_ids.add(self._get_tool_call_id(tc))
# 删除没有对应 call 的 tool_result
cleaned = []
for msg in messages:
if msg.get("role") == "tool":
if msg.get("tool_call_id") not in active_ids:
continue # 孤儿 tool_result,丢弃
cleaned.append(msg)
return cleaned
坑 2:摘要 preamble 太短
早期版本的 preamble 只有一句话:「This is a compressed summary.」结果 LLM 经常把摘要中的待办事项当成活跃指令执行。
Hermes 现在的 preamble 长达 200+ token,包含了:
-
显式的「REFERENCE ONLY」标记
-
单行加粗的「latest user message WINS」规则
-
对每个 section 的明确处理指令
-
反向信号处理(stop、undo、roll back)
坑 3:迭代摘要的跨 session 污染
如果 _previous_summary 没有被正确清理,一个 session 的摘要可能泄漏到另一个 session。
Hermes 在 on_session_end() 和 on_session_reset() 中清空 _previous_summary:
def on_session_reset(self):
self._previous_summary = None
self._last_summary_error = None
# ...
def on_session_end(self, session_id, messages):
self._previous_summary = None
坑 4:摘要 budget 不够
如果摘要的 token budget 太小,LLM 会生成过于简略的摘要,丢失关键信息。
Hermes 使用动态 budget 计算:
# 按压缩内容的 20% 分配摘要 budget
summary_budget = min(
int(len(turns_to_summarize) * _SUMMARY_RATIO),
_SUMMARY_TOKENS_CEILING # 上限 12K tokens
)
08 | 总结

今天这篇文章的核心观点可以浓缩为三句话:
-
压缩不等于截断。 截断制造孤儿 tool_call,破坏 API 合规性;截断删除 system prompt,破坏前缀缓存。
-
结构化摘要是缓存友好的。 三段式保护(head + 摘要 + tail)让 system prompt 和早期对话保持稳定,前缀缓存继续命中。摘要本身也变成了可缓存的前缀。
-
迭代摘要比全量重摘更优。 更低的 LLM 调用成本、更稳定的摘要内容、更好的缓存复用。
Hermes 的上下文压缩设计告诉我们一个道理:在 LLM 系统设计中,「快」和「省」往往不是直接删减,而是通过更聪明的结构去实现。 花一次 LLM 调用做摘要,换来后续每次请求的延迟降低和缓存命中——这笔账怎么算都划算。
下一篇我们进入板块六——工具调用与函数编排。我们会聊 Hermes 的 tool_call 生命周期管理、并行工具调用、以及如何避免工具调用中的死锁和竞态条件。
关注我,James 的成长日记,持续分享干货,帮你在 AI 时代少走弯路。
内容效果不满意?点此反馈