适用读者:已经写出第一版 Agent 循环、开始被「历史越跑越长」折磨的人;想知道 compact 到底该保留什么的人;准备给 Agent 加记忆、加检索、加多代理,但先想把上下文预算算清楚的人
结论先行:上下文不是聊天记录,而是「下一步还需要的工作集」。留什么的标准只有一个——下一条消息里,模型会不会用到它。NanoHermes 用四层上限把这件事变成可计算的:单个文件 4 MB 就拒绝、单个工具结果 20,000 字节就截断、项目文档 20,000 字符封顶、会话总量 120,000 字符就压缩;压缩时保留最近 10 条消息,把更早的部分换成一段不超过 300 词的摘要。本文拆开这套预算、切点和摘要策略,并说明哪些信息必须留、哪些可以丢
阅读时间:约 30 分钟 | 难度:进阶 | 收获:一套可复用的上下文预算表 + 压缩算法的逐行解释 + 摘要该保留的四类信息 + 七个会让上下文悄悄坏掉的陷阱
目录
- 前言:上下文不是聊天记录
- 一次请求里到底装了什么
- 四层预算:从单文件到整个会话
- 一条工具结果为什么能吃掉半个窗口
- 稳定前缀:系统消息为什么应该只读
- 系统提示词:短,而且分层
- 项目文档:拼接顺序与 20,000 字符截断
- 记忆注入:2,400 字符的硬预算
- 工具输出:每个工具都必须有上限
- 三种截断语义:拒绝、截断、摘要
clamp 的实现:字节、字符边界与省略说明
- 压缩触发:120,000 字符是怎么算的
- 为什么按字符算,不按 token 算
- 保留最近 10 条,而不是最近 10 轮
- 切点:为什么不能落在 tool 消息中间
- 摘要:一次额外的模型请求
- 摘要输入里有什么,没有什么
- 摘要提示词要求保留的四类信息
- 摘要失败时的降级
- 摘要之后的消息序列
- 什么该留:五类信息
- 什么该丢:四类内容
- 上下文、记忆、会话:三个不同的东西
- 压缩的代价:延迟、损失与漂移
- 一个 12 轮任务的示意账本
- 七个常见上下文陷阱
- 如何验证压缩没有坏
- 收尾:下一篇讲记忆
- 参考
前言:上下文不是聊天记录
第一篇讲循环时,我们把消息列表当成一个整体:模型回什么,就往后追加什么。只要历史合法,循环就能一直转。
但历史不会免费。模型没有记忆,每一轮请求都要把完整历史重新发一遍。所谓「上下文窗口」,是一个每轮都被重新填充的容器,不是一份持久保存的聊天记录。
Agent 上下文的难点从来不是「存不下」,而是「带什么」。
这带来一个和普通程序完全不同的约束:
上下文的成本不是存储成本,而是每轮重复支付的输入成本。 你把一段 20,000 字符的工具结果留在历史里,不是占了一次磁盘,而是让接下来的每一次模型请求都多带 20,000 字符。跑十轮,就是二十万字符的重复输入。
所以「留什么,丢什么」不是整理癖,而是 Agent 的核心算法之一。留得太多,模型抓不住重点,延迟和账单一起上涨;丢得太多,模型忘了目标、忘了改过哪个文件、忘了刚才哪条路走不通,开始原地打转。
这一篇的答案可以压成一句话:
留下来的是「下一步还需要的工作集」,不是「已经发生过的事」。
判断标准也很简单:下一条消息里,模型会不会用到它?会,就留;不会,就丢;不确定,就压成一句摘要。
一次请求里到底装了什么
一次模型请求的
messages 数组,通常由七种东西组成。
前三项和技能清单都属于系统消息,只在启动或配置变化时重建。记忆块虽然也在系统消息里,但每轮都会按标记替换。对话消息是增长的主体,压缩摘要则是为了截住这个增长。
大多数人的直觉是「对话消息最多」,实际不一定。一个
read_file 返回 2,000 行,经
clamp 截到 20,000 字节;五次这样的读取就是 100,000 字节,已经接近整个会话预算。真正吃掉窗口的往往不是模型说的话,而是工具的输出。
这张表解释了一个常见现象:
上下文爆炸通常不是因为聊得多,而是因为某几次工具调用返回了太多东西。 一个没有上限的
cat、一次全仓库
grep、一个把整页 HTML 塞回来的
web_fetch,就能让预算瞬间见底。
四层预算:从单文件到整个会话
NanoHermes 没有用一个统一的「上下文管理器」,而是在四个层次上分别设上限。每一层负责不同的失败模式。
为什么要分四层,而不是只在会话层做一次压缩?
第一,压缩太晚。 如果只在 120,000 字符时才处理,模型已经为前面那些巨型结果付过很多轮输入费了。工具层的上限在结果产生时就生效,成本立刻被截住。
第二,压缩太贵。 压缩本身是一次模型请求。如果每次都要把 4 MB 的文件内容读进来再摘要,摘要请求本身就可能超窗。工具层先拒绝,压缩层才有机会处理「值得摘要」的内容。
第三,压缩会丢信息。 一个 20,000 字节的工具结果被截断,模型至少还能看到前 20,000 字节;如果整段被摘要掉,细节就没了。能在工具层解决的,不要留到压缩层。
这四层的关系可以概括成一句话:
越靠近产生点,越应该硬截断;越靠近会话层,越应该做摘要。 硬截断便宜、无损于「存在性」,但会丢细节;摘要贵、保留语义,但会引入模型误差。两者不能互相替代。
一条工具结果为什么能吃掉半个窗口
假设模型调用
read_file 读一个 2,000 行的文件,每行平均 80 字符。原始内容约 160,000 字符。
fs::read_file 不会把 160,000 字符直接交给模型。它先按
limit 取前 2,000 行,再交给
dispatch;
dispatch 的最后一步是
clamp(s, ctx.max_output),默认 20,000 字节。所以模型最终看到的是前 20,000 字节,加上一行
… output truncated (N chars omitted)。
这 20,000 字节进入历史后,还会被 JSON 序列化一次。换行变成
\n,引号变成
\",再套上
{"role":"tool","tool_call_id":"...","content":"..."} 的外壳。体积会再涨几个百分点。假设涨 3%,就是 20,600 字符。
在 120,000 字符的预算里,这一条结果占 17%。五次这样的读取,就是 85%。如果再叠加系统提示词、项目文档和模型自己的解释,预算很快就满了。
注意最后一行:
web_fetch 自己还有一层
max_chars,默认也是 20,000。也就是说,网页抓取和工具截断的上限恰好撞在一起。这不是巧合,而是同一个预算在两层各设了一次闸。
这也解释了一个设计选择:
所有内置工具的结果都要经过 dispatch 的 clamp,而不是让每个工具自己决定要不要截断。 单个工具可能忘记,也可能低估自己的输出;统一在出口截断,才能保证「任何工具都不会一次性灌爆上下文」。
稳定前缀:系统消息为什么应该只读
系统消息在
messages[0],每一轮都排在最前面。它的内容直接影响两件事:模型的行为,和推理服务的缓存命中。
NanoHermes 的系统消息由
system_content 构建,包含内置提示词、
NANOHERMES.md 项目上下文和技能清单。它在三种时机重建:
注意「恢复会话」这一行:加载会话文件时,NanoHermes 不会原样使用文件里的系统消息,而是用当前配置重新生成并替换。系统消息是配置,不是对话。用户的权限模式、人设、AGENTS.md 都可能变;原样恢复会让会话带着过期规则跑。
但系统消息也不是完全只读的。记忆块每轮都会被
set_memory_block 替换,而记忆块挂在系统消息的末尾。这意味着系统消息的
前半段稳定,后半段每轮变化。
对推理服务来说,前缀缓存通常按 token 前缀匹配。稳定的前半段可以命中缓存,记忆块之后的部分则每轮重新计算。这是一个可以接受的折中:把变化的东西放在系统消息末尾,至少保住了前面那一大段稳定前缀。
反过来,如果把记忆块插在系统提示词中间,或者每轮在系统消息开头加时间戳,前缀缓存就会整段失效。
上下文设计不只是「放什么」,还包括「放在哪、多久变一次」。
系统提示词:短,而且分层
第一篇已经讲过系统提示词的五个来源,这里从「体积」角度再看一遍。
这里有两个不对称,值得单独说。
第一,AGENTS.md 有 20,000 字符上限,NANOHERMES.md 没有。 prompt::project_docs 会在拼接后截断,并追加
… project guidance truncated;
system_content 读
NANOHERMES.md 时是整文件读入,没有单独的 cap。它最终会被 120,000 字符的会话预算兜住,但在那之前,它已经进入了每一次请求。这是一个真实的边界:项目上下文文件应该保持短,不要把它当成文档仓库。
第二,技能清单只放名字和描述,正文按需加载。 skills::listing 为每个技能生成一行,真正的说明在模型调用
skill 工具时才读取,读取时还有 20,000 字符上限。这个设计把「知道有什么」和「读到全部」分开了。清单很便宜,正文很贵,只在需要时支付。
fn system_content(cfg: &Config, skills: &[crate::skills::Skill]) -> String {
let mut system = prompt::system_prompt(cfg);
let project_ctx = cfg.workspace.join("NANOHERMES.md");
if let Ok(text) = std::fs::read_to_string(&project_ctx) {
if !text.trim().is_empty() {
system.push_str("\n\n=== Project context (NANOHERMES.md) ===\n");
system.push_str(text.trim());
system.push('\n');
}
}
if !skills.is_empty() {
system.push('\n');
system.push_str(&crate::skills::listing(skills));
}
system
}
系统提示词每轮都重发。一段 2,000 token 的提示词,跑 20 轮就是 40,000 token 的输入。提示词里的每一句都要为「每轮都值得」辩护:身份和工具规则值得,项目约定值得,三千字的公司介绍不值得。
项目文档:拼接顺序与 20,000 字符截断
AGENTS.md 不是只读工作区根目录的那一个。NanoHermes 会从全局配置目录开始,沿着文件系统根到工作区,逐层收集。
fn project_docs(workspace: &Path) -> Option<String> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(home) = std::env::var_os("HOME") {
candidates.push(PathBuf::from(home).join(".nanohermes").join("AGENTS.md"));
}
let mut chain: Vec<&Path> = workspace.ancestors().collect();
chain.reverse();
for dir in chain {
for name in DOC_NAMES {
candidates.push(dir.join(name));
}
}
// canonicalize 去重,读文件,拼成 parts
let mut joined = parts.join("\n\n");
if joined.len() > MAX_PROJECT_DOCS {
let mut end = MAX_PROJECT_DOCS;
while end > 0 && !joined.is_char_boundary(end) {
end -= 1;
}
joined.truncate(end);
joined.push_str("\n… project guidance truncated");
}
Some(joined)
}
顺序有两层含义。
「越具体越靠后」不是形式主义。模型读长文本时,靠后的内容通常更容易被当成当前指令。把工作区自己的规则放在最后,等于让它覆盖上层的通用规则。这不是硬编码的优先级,而是通过顺序表达的软优先级。
去重则处理另一种情况:同一个文件通过符号链接或多条路径到达时,只读一次。
canonicalize 失败时退回原路径,宁可重复也不漏读。
20,000 字符的截断发生在拼接之后,所以它截掉的是「最后面」的内容——也就是最具体的项目规则。这是一个有意的取舍:全局偏好和上层约定先保住,工作区规则如果太长,只能牺牲尾部。更好的做法当然是把工作区的
AGENTS.md 写短,而不是依赖截断。
记忆注入:2,400 字符的硬预算
记忆块是系统消息里唯一每轮变化的部分。它的预算很小,但很硬。
pub fn render_block(entries: &[&Entry]) -> String {
if entries.is_empty() {
return String::new();
}
let mut out = String::from(BLOCK_START);
out.push_str("\n以下是从过往会话中检索到的相关记忆(由对话自动提炼,仅供参考;用户当前指令优先):\n");
let mut used = 0usize;
for e in entries {
let line = format!("\n- [{}] {}", e.kind.label(), e.text);
if used + line.chars().count() > INJECT_CHAR_BUDGET {
break;
}
used += line.chars().count();
out.push_str(&line);
}
out.push('\n');
out.push_str(BLOCK_END);
out
}
这段代码的关键是
break,不是
continue。预算不够时,它不会跳过这一条去尝试更短的后面几条,而是直接停止。原因很简单:检索结果已经按相关度排好序,越往后越不重要;为了塞进一条低相关记忆而跳过一条高相关记忆,是得不偿失的。
set_block 则负责替换。它先找到旧块的起止标记,删掉整段,再把新块追加到系统消息末尾;新块为空时只删除,不追加。这个「替换而不是追加」的设计,是第一篇里提过的那个坑:如果每轮追加,系统提示词会随轮次线性增长,几轮之后就吃掉整个预算。
记忆块的文案里有一句「仅供参考;用户当前指令优先」。这不是客套。检索是按相关度算的,相关不等于正确;一条过期的偏好可能比当前指令更「相关」。把优先级写进注入文本,是让模型在冲突时有明确的裁决规则。
工具输出:每个工具都必须有上限
一个不能限制输出的工具,就是一个上下文炸弹。它可能来自一次
cat 大文件、一次全仓库搜索、一次网页抓取,或者一条输出日志的构建命令。
NanoHermes 在每个可能产生大输出的地方都设了闸。
这些数字不是拍脑袋来的,它们对应不同的失败模式。
4 MB 是「读不读」的边界。 超过这个大小,读进来也没有意义:模型不可能在 20,000 字节的截断里看到全貌,不如直接告诉它「文件太大,用 offset/limit 分块读」。这是一个拒绝,不是截断。
1 MB 是「搜不搜」的边界。 grep 会遍历整个工作区。单文件超过 1 MB 时跳过,避免把压缩包、打包产物、日志文件当源码扫描。跳过是静默的,但结果里会体现「没搜到」,模型可以通过
glob 或
read_file 再确认。
200 条命中是「够不够」的边界。 搜索的目的通常是定位,不是穷举。200 条足够让模型找到目标文件;再多,模型也读不完。到顶后停止,并在结果末尾注明,避免模型误以为只有这些。
256 KB 是「命令能喊多大声」的边界。 一条构建命令可能输出几 MB 日志。
read_capped 只保留前 256 KB,剩下的一边读一边丢——必须继续读,否则管道会阻塞,子进程会卡住。这是「丢弃但不能停止读取」的典型场景。
20,000 字节是「一条结果能占多少窗口」的边界。 它不关心工具是什么,所有内置工具的结果都从这里过。它是最外层的保险,防止某个工具忘记自己的上限。
三种截断语义:拒绝、截断、摘要
「截断」不是一个动作,而是三种不同的语义。混用它们,模型就会收到自相矛盾的信号。
三种语义的共同要求是:
必须留下痕迹。 静默截断是最坏的选择。模型看不到「被截断」的提示,就会把不完整的结果当成完整结果,基于缺失的信息做决定。一个
grep 如果悄悄只返回前 200 条,模型可能以为仓库里只有这些匹配,然后改错文件。
NanoHermes 的每条截断路径都带说明:
clamp:… output truncated (N chars omitted)
grep:… stopped after 200 matches
glob:… capped at 500 files
read_file:… N more lines (total M)
shell:… stdout truncated (N bytes not shown)
- 后台任务:
… output truncated (N bytes not shown)
web_fetch:[response truncated at 2097152 bytes]
这些说明本身也占体积,但它们是上下文里性价比最高的几十个字符。没有它们,模型会基于错误的完整性假设行动。
clamp 的实现:字节、字符边界与省略说明
clamp 只有十几行,但它处理了三个容易出错的细节。
fn clamp(s: String, max: usize) -> String {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n… output truncated ({} chars omitted)",
&s[..end],
s.len() - end
)
}
第一,上限按字节算。 s.len() 返回字节数,不是字符数。对纯 ASCII 输出(日志、代码、命令结果)两者一致;对中文输出,一个汉字占 3 个字节,20,000 字节大约只能装 6,600 个汉字。
第二,切点必须落在 UTF-8 字符边界。 如果直接
&s[..max],而
max 恰好落在某个三字节汉字的中间,Rust 会 panic。
while !s.is_char_boundary(end) 往前退,最多退两个字节,保证切片合法。
第三,省略说明按字节算,却写成 chars。 这是代码里的一个小不一致:
s.len() - end 是字节数,文案却写
chars omitted。对 ASCII 没问题,对中文会高估省略的字符数。这个不一致不影响安全,但会让模型对「还剩多少」判断偏保守。知道这一点,比假装它不存在好。
这也是为什么
max_output 的单位在文档里应该写「字节」而不是「字符」。源码里
Ctx.max_output 是
usize,没有单位;
clamp 用
len(),实际是字节。文章里说「20,000 字节」比说「20,000 字符」准确。
压缩触发:120,000 字符是怎么算的
压缩的入口是
compact,它在每轮工具结果回填之后调用。
fn compact_with(&mut self, force: bool) -> usize {
let size: usize = self.messages.iter().map(|m| m.to_string().len()).sum();
if !force && (size <= CONTEXT_CHAR_BUDGET || self.messages.len() <= KEEP_RECENT + 1) {
return 0;
}
let mut start = self.messages.len().saturating_sub(KEEP_RECENT);
while start < self.messages.len() && self.messages[start]["role"] == "tool" {
start += 1;
}
if start <= 1 {
return 0;
}
let dropped: Vec<Value> = self.messages.drain(1..start).collect();
let note = match self.summarize_messages(&dropped) {
Some(summary) => format!("[Earlier conversation summary]\n{summary}"),
None => "[Earlier conversation was omitted to keep the context small]".to_string(),
};
self.messages.insert(1, json!({ "role": "system", "content": note }));
dropped.len()
}
这里有四个判定。
size 的计算方式是
messages.iter().map(|m| m.to_string().len()).sum()。它量的是
序列化后的 JSON 长度,不是消息内容的长度。
/compact 命令走的是
compact_now(),它调用
compact_with(true) 强制压缩,跳过预算判定;交互式用户可以在预算还没爆之前主动整理一次。
这很重要。一条
tool 消息的 JSON 包含
role、
tool_call_id、
content 三个键,加上引号、冒号和转义。内容里的每个换行会变成两个字符
\n,每个双引号会变成
\"。所以
size 总是比「所有 content 长度之和」大一些。对代码和日志这种换行密集的内容,差距可能到 5%–10%。
这也意味着
CONTEXT_CHAR_BUDGET 的 120,000 是「JSON 字符数」,不是「模型 token 数」,也不是「对话内容字符数」。把它当成一个保守的近似值,而不是精确的窗口大小。
为什么按字符算,不按 token 算
精确的上下文管理应该按 token 算。每个模型有自己的 tokenizer,同样的文本在不同模型上 token 数不同。按 token 算,才能精确卡住窗口。
NanoHermes 选择按字符算,原因有三个。
第一,tokenizer 是重依赖。 一个模型对应一份词表,几十 MB 到几百 MB。引入 tokenizer 意味着要么绑定某个模型家族,要么为每个模型维护一份。NanoHermes 的依赖只有三个 crate,不想为了估算引入第四第五个。
第二,字符数足够做保守近似。 对英文和代码,大约 3–4 个字符一个 token;对中文,大约 1–2 个字符一个 token。120,000 字符大致落在 30,000–60,000 token 之间。只要预算取得比真实窗口小,误差方向就是安全的。
第三,字符数可解释。 用户看到「上下文 120,000 字符」,能直观理解;看到「上下文 37,842 token」,反而不知道还剩多少空间。对一个交互式 CLI 来说,可解释性比精确性更重要。
注意中文那一行:如果上下文里全是中文,120,000 字符可能已经接近 120,000 token,超过很多模型的窗口。这是字符预算的盲区。NanoHermes 的主要场景是代码和英文技术文本,中文占比通常不高;如果你的场景以中文长文为主,应该把预算调低,或者换成 token 估算。
保留最近 10 条,而不是最近 10 轮
压缩保留的是最近 10
条消息,不是最近 10 轮对话。
这两个单位差别很大。一轮对话的消息数取决于模型调用了多少工具:
如果按「最近 10 轮」保留,最后一种情况会保留 110 条,预算根本压不住。按「最近 10 条」,无论一轮调了多少工具,保留的消息数都有上限。
let mut start = self.messages.len().saturating_sub(KEEP_RECENT);
while start < self.messages.len() && self.messages[start]["role"] == "tool" {
start += 1;
}
saturating_sub 处理「消息数少于 10」的情况,结果是 0;后面的
start <= 1 会直接返回,不会误删系统消息。
保留 10 条的目的是保证「最近一次工具交互」还在。一次带 5 个工具调用的轮次会占 7 条;再加一条 user、一条 assistant,刚好 9 条。10 条足够覆盖一次完整的工具往返,让模型知道刚才发生了什么。
切点:为什么不能落在 tool 消息中间
切点的第一版逻辑是「从倒数第 10 条开始,前面的都丢掉」。这会产生一个致命问题:如果倒数第 10 条正好是一条
tool 消息,丢掉它前面的 assistant 工具调用、却保留这条结果,历史里就会出现一条没有对应调用的工具结果。
下一轮请求,API 会直接拒绝:
messages with role 'tool' must be a response to a preceding message with 'tool_calls'
更隐蔽的情况是:某些兼容层不拒绝,但模型会看到一组对不上的结果,开始胡言乱语。
NanoHermes 的处理是把切点往后推,直到它不是
tool 消息:
while start < self.messages.len() && self.messages[start]["role"] == "tool" {
start += 1;
}
这个循环最多推过整轮的工具结果。推过之后,被丢掉的区域里包含完整的 assistant 调用和它的全部结果;保留的区域从一条非 tool 消息开始,历史仍然合法。
还有一个边界:
start <= 1 时直接返回。
messages[0] 是系统消息,
messages[1] 是摘要或第一条对话。如果切点落在 1 或更前,说明没有足够的旧消息可丢,压缩应该放弃,而不是把系统消息也丢掉。
这个「切点不能落在 tool 上」的规则,是第一篇里「历史必须合法」不变量的直接延伸。压缩不是随便删一段,它必须维持消息序列的配对关系。
摘要:一次额外的模型请求
压缩不是把旧消息删掉就完事。它要让模型用一段话概括被丢掉的内容,这段话再作为一条
system 消息插回历史。
fn summarize_messages(&self, messages: &[Value]) -> Option<String> {
if messages.is_empty() {
return None;
}
let mut excerpt = String::new();
for m in messages {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
let content = match m.get("content") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join(" "),
_ => String::new(),
};
let content = content.trim();
if content.is_empty() {
continue;
}
excerpt.push_str(&format!("{role}: {}\n", content));
}
if excerpt.trim().is_empty() {
return None;
}
match llm::summarize(&self.cfg, &excerpt) {
Ok(s) if !s.is_empty() => Some(s),
_ => None,
}
}
这段代码先做一件事:把消息压成
role: content 的纯文本。多模态消息只取
text 部分,图片 URL 不进入摘要;内容为空的消息直接跳过。
然后调用
llm::summarize:
pub fn summarize(cfg: &Config, text: &str) -> Result<String, String> {
let mut summarizer_cfg = cfg.clone();
summarizer_cfg.stream = false;
let system = "You are a conversation summarizer for a coding agent. Write a concise summary \
of the excerpt below, preserving: the user's goal, key decisions, file paths \
changed, and current task state. Use at most 300 words. Reply with the summary only.";
let messages = vec![
json!({ "role": "system", "content": system }),
json!({ "role": "user", "content": text }),
];
let turn = chat(&summarizer_cfg, &messages, &Value::Array(vec![]), |_| {})?;
Ok(turn.content.trim().to_string())
}
注意三个参数:
stream = false(摘要不需要流式)、
tools = [](不允许摘要器调用工具)、
temperature 沿用主配置(默认 0)。摘要是一次普通的、非流式的、无工具的模型请求。
摘要输入里有什么,没有什么
这是整篇文章最容易被忽略的一节。
summarize_messages 只读取每条消息的
content。它
不读取 tool_calls 里的
function.name 和
function.arguments。
这意味着摘要器看到的序列可能是这样的:
user: 把 README 里的安装命令改成 pnpm
assistant: 我先看看 README。
tool: 1→# Project
2→
3→## Install
4→npm install
...
assistant: 已经改成 pnpm。
它能看到「读了 README」「结果是这些行」,但看不到「调用的是
read_file,参数是
path=README.md」。如果工具结果本身没有包含文件名,摘要就会丢失「改的是哪个文件」这条关键信息。
这是一个真实的局限,也是摘要提示词要求「保留 file paths changed」的原因:既然结构化字段进不来,就只能靠工具结果里的路径和模型的文本描述来补。NanoHermes 的
read_file 输出不带文件名,所以摘要器只能从上下文推断;
edit_file 的结果带路径,通常能补上。
如果你要改进这一点,有两个方向:
- 在摘要输入里保留工具名和参数。 把 assistant 消息的
tool_calls 渲染成 assistant: [read_file] {"path":"README.md"},而不是跳过。
- 让工具结果自带身份。 每个工具返回时带上「我在哪个文件上做了什么」,例如
read_file README.md。
两种做法都会增加摘要输入的长度,但能显著减少「摘要后不知道改了哪个文件」的问题。
摘要提示词要求保留的四类信息
摘要提示词很短,只列了四类必须保留的信息:
the user's goal, key decisions, file paths changed, and current task state. Use at most 300 words.
这四类信息有一个共同点:
它们都是「下一步」需要的,而不是「过去」的完整记录。 摘要不保留逐字对话,不保留每个工具的输出,也不保留模型的解释过程。它保留的是能让下一个模型接上手的「工作交接单」。
300 词的上限也不是随便定的。一段 300 词的摘要大约 1,500–2,500 字符,占 120,000 预算的 1%–2%。如果摘要写到 2,000 词,它就变成了另一种上下文膨胀:压缩一次,塞回去一大段。
摘要提示词里还有一句
Reply with the summary only。这是为了防止模型在摘要前后加「好的,以下是摘要」这类客套话。客套话也会进入上下文,而且没有任何信息量。
摘要失败时的降级
摘要是一次网络请求,可能失败:API 超时、限流、返回空内容、摘要器把内容全滤掉。
summarize_messages 的返回类型是
Option<String>,失败就返回
None。
compact_with 对
None 的处理是插入一行占位说明:
let note = match self.summarize_messages(&dropped) {
Some(summary) => format!("[Earlier conversation summary]\n{summary}"),
None => "[Earlier conversation was omitted to keep the context small]".to_string(),
};
为什么失败也要插入一行,而不是什么都不插?
因为模型需要知道「有一段历史被省略了」。如果压缩后历史直接从系统消息跳到最近的工具结果,模型会以为自己一直只有这么点上下文,可能重复已经做过的工作,或者对用户的指令感到困惑。一行
[Earlier conversation was omitted ...] 至少告诉它:前面还有东西,只是没带过来。
这也是「截断必须留痕」原则在压缩层的体现。失败不可怕,静默失败才可怕。
摘要之后的消息序列
压缩前后的消息序列对比,能直观看出这套算法的效果。
压缩前(示意):
压缩后:
注意三点。
第一,系统提示词仍然是 0。 摘要插在 1,不替换系统提示词。这样配置、规则、记忆块都保留。
第二,摘要是 system 角色,不是 assistant。 它是「背景知识」,不是「模型说过的话」。用 system 角色能让模型把它当成指令性上下文,而不是对话历史。
第三,最近 10 条原样保留。 摘要负责旧历史,最近的消息负责「刚才发生了什么」。两者分工明确。
多次压缩时,旧摘要会被再次摘要。第二次压缩的
dropped 区域包含第一次插入的 system 摘要,
summarize_messages 会把它的 content 也拼进 excerpt。这意味着摘要会「滚动合并」,不会越积越多,但也会有
摘要的摘要带来的信息漂移:第一次摘要丢掉的细节,第二次无法恢复;第二次摘要还可能把第一次的错误结论继续放大。
什么该留:五类信息
把「下一步会不会用到」这个标准展开,可以落成五类信息。
这五类里,前四类可以进摘要,第五类必须留在最近消息里。原因是「当前状态」变化最快,摘要一次就可能过期;而目标、决策、路径、失败尝试相对稳定,适合压缩成一段话。
一个反例是「完整的文件内容」。很多人觉得「模型读过这个文件,应该留着」,于是把 2,000 行代码留在历史里。但模型下一步真正需要的是「这个文件里哪个函数要改」,而不是整份文件。完整内容应该留在磁盘上,需要时再读;上下文里留路径、行号和结论就够了。
什么该丢:四类内容
「成功命令的长输出」这一条最容易判断错。
cargo build 成功时输出几百行编译日志,模型通常只需要知道「成功了」;但如果输出里有 warning,warning 可能是下一步的线索。NanoHermes 的 shell 工具不会自动区分,它把退出码和输出一起返回,由模型自己判断。压缩时,摘要器可能会把成功的日志概括成「构建通过」,warning 就丢了。
这就是为什么「什么该丢」不能完全交给摘要器。摘要器只看 content,不懂任务;它可能把一条关键的 warning 当成噪音。更稳妥的做法是:在工具层就把「成功但啰嗦」的输出压短,比如只保留退出码和最后几行;而不是留给压缩层去猜。
上下文、记忆、会话:三个不同的东西
这三个概念经常被混为一谈,但它们的生命周期和用途完全不同。
上下文是「这一次请求要带什么」,
记忆是「跨会话值得记住什么」,
会话是「这次对话的完整存档」。
会话文件不设上限,因为它保存在磁盘上,不参与每次请求。加载会话时,它会被读回
messages,然后由上下文预算接管。记忆则是另一个方向:它从会话里提炼,但不保存全文;下次会话按当前指令检索,只注入最相关的几条。
replace_messages 是这三个概念在 daemon 模式下的交汇点:一个 Agent 实例可以服务多个隔离会话,通过替换
messages 切换上下文。记忆和会话文件仍然是各自独立的。
pub fn replace_messages(&mut self, msgs: Vec<Value>) -> Vec<Value> {
std::mem::replace(&mut self.messages, msgs)
}
这也解释了为什么 token 用量要上报增量而不是累计值:同一个 Agent 可能在不同会话之间切换,累计值会把这些互不相关的任务加在一起。上下文可以替换,账本必须按轮次分清。
压缩的代价:延迟、损失与漂移
压缩不是免费的。它至少有三类代价。
延迟是最容易被低估的。压缩发生在工具结果回填之后、下一轮请求之前,用户会感觉「这一轮特别慢」。如果压缩又触发了一次摘要请求,延迟就是「工具时间 + 摘要时间 + 下一轮模型时间」。在长任务里,这可能多出几秒到十几秒。
信息损失则是结构性的。摘要器只看到
role: content,看不到工具名和参数;它可能把「改了
src/main.rs 的第 42 行」概括成「修改了主文件」。下一次模型想继续改,就得重新读文件。这不是摘要器的错,是输入里就没有这些信息。
摘要漂移更隐蔽。第一次摘要保留了「用户要求用 pnpm」,第二次摘要可能把它合并成「包管理器相关改动」,第三次就只剩「依赖调整」。每一轮压缩都在做有损压缩,损失会累积。缓解办法是:
在任务边界主动压缩,而不是等到预算爆掉;在摘要提示词里把「用户目标」放在第一位;关键约束不要只依赖摘要,放进 AGENTS.md 或记忆。
一个 12 轮任务的示意账本
下面是一个按默认上限估算的账本,用来说明压缩在什么时候发生。数字是示意,不是某次真实运行的精确值。
几个观察:
- 压缩不是均匀发生的。 第 10 轮之前一直在增长,第 10 轮一次掉回 32,000,然后重新增长。
- 压缩后不是「很小」,而是「大约最近 10 条 + 摘要」。 最近 10 条可能就有 20,000–30,000 字符,加上摘要 2,000,就是 30,000 左右。
- 总输入成本远大于最后的消息数。 每一轮都要重发当时的历史。前 9 轮的平均历史约 60,000 字符,9 轮就是 540,000 字符的输入;压缩后又要重新累积。
- 压缩只能降低单次请求大小,不能降低总成本。 总成本是各轮历史之和,压缩把曲线压下去一次,但曲线还会继续涨。
这也解释了为什么「工具输出上限」比「压缩」更重要:压缩只在超预算时发生一次,而工具输出上限在每一次工具返回时都生效。前者是消防栓,后者是防火门。
七个常见上下文陷阱
最后一条值得展开。
compact 的调用点在工具结果回填之后;如果一轮对话没有工具调用,循环在
turn.tool_calls.is_empty() 处就
break 了,根本走不到
compact。所以纯聊天会话不会自动压缩,直到某一轮调用了工具,或者用户手动执行
/compact。
这是一个真实的边界。对 coding agent 来说,长任务几乎都伴随工具调用,所以影响不大;但如果你把 NanoHermes 当纯聊天用,就要知道这个限制。
还有一个更隐蔽的陷阱:
把压缩当成「删除」。压缩不是删除,是替换成摘要;摘要本身也在上下文里占体积。如果摘要写得比原文还长,压缩就变成了负优化。300 词的上限就是防止这件事。
如何验证压缩没有坏
压缩是少数「看起来跑通了,其实已经坏了」的机制。它不会立刻报错,模型会在几轮之后开始重复劳动或答非所问。
可以用下面这组检查。
其中「模型知道目标」和「知道路径」是最实用的两项。它们直接检验摘要提示词的四类信息有没有被保留。如果模型答不出,说明摘要丢了关键内容,应该调整摘要提示词,而不是继续压缩。
自动化测试也可以覆盖前五项。NanoHermes 的
memory.rs 里有
block_round_trip_replaces_instead_of_accumulating 这样的测试,验证记忆块替换而不是追加;
compact 也可以写类似的测试:构造 20 条消息,强制压缩,断言系统消息还在、最近 10 条没变、中间多了一条摘要、没有孤立 tool。
收尾:下一篇讲记忆
上下文解决的是「这一次请求带什么」,记忆解决的是「下一次会话还记得什么」。
两者共享同一个约束:
体积有限,必须选择。 上下文的选择发生在每一轮,记忆的选择发生在会话结束和下一次会话开始。上下文用 120,000 字符的预算和 10 条最近消息;记忆用 4 条、2,400 字符的注入预算,以及 500 条、每条 400 字符的存储上限。
下一篇《Agent 记忆:不接向量库也能长期记忆》会拆开
memory.rs:BM25 怎么打分、中英文怎么分词、记忆怎么从对话里提炼、为什么用标记替换而不是追加、以及「不接向量库」到底损失了什么。
参考
agent/src/agent.rs:compact_with、summarize_messages、system_content、replace_messages
agent/src/llm.rs:summarize
agent/src/prompt.rs:system_prompt、project_docs、MAX_PROJECT_DOCS
agent/src/memory.rs:INJECT_TOP_K、INJECT_CHAR_BUDGET、render_block、set_block
agent/src/tools/mod.rs:Ctx.max_output、dispatch、clamp
agent/src/tools/fs.rs、search.rs、shell.rs、tasks.rs、web.rs:各工具的输出上限
agent/src/skills.rs:MAX_BODY_CHARS、listing
仓库:
D:\NanoHermes(Rust 2021,
agent crate 无 async 运行时)