聊天机器人和智能体的根本差别是:智能体的上下文会自己长大。每执行一次工具,就往上下文里塞进几 KB 甚至几十 KB 的执行结果。几十轮之后必然撞上上下文窗口的上限。
「撞墙了怎么办」这个问题的答案质量,直接决定了一个智能体能不能干长活。
这也是技术面试里最能区分「用过智能体」和「做过智能体」的问题。前者会说「做个摘要压缩一下」;后者会告诉你摘要是最后手段,前面还有三级更便宜的处理,而且顺序不能颠倒。
每一次调用模型之前,消息历史要穿过一条固定顺序的流水线。顺序不是随意排的 —— 越便宜的越靠前。
下面这段是同一条流水线在源代码里的实际形态,附上每一级对应的函数名:
关于第 ④ 级为什么必须排在第 ⑤ 级之前,源代码里有一句精确的说明:
「Runs BEFORE autocompact so that if collapse gets us under the autocompact threshold, autocompact is a no-op and we keep granular context instead of a single summary.」
译:它跑在自动压缩之前,这样如果折叠已经把我们降到自动压缩的阈值以下,自动压缩就成了空操作 —— 于是我们保住了细粒度的上下文,而不是把它换成了一坨摘要。
这句话就是整条阶梯的设计哲学:能保住细粒度上下文,就绝不换成一坨摘要。
摘要是有损的、不可逆的 —— 一旦把 30 轮对话总结成 500 字,那 30 轮里的具体代码片段、具体行号、具体报错信息就永久丢失了。模型后面如果需要那些细节,只能重新去读文件。
折叠是可重放的、保留结构的 —— 它只是把某段内容暂时收起来,需要时能展开。
所以宁可多跑几级便宜的处理,也要尽量不触发最贵的那一级。这里的「贵」不只是钱,更是信息损失。
每个工具都在自己的定义里声明了一个 maxResultSizeChars(结果最大字符数)。超过这个上限的执行结果不会进上下文,而是:
tool-results/ 目录下的一个文件里<persisted-output> 标签包裹的前 2000 字节预览 + 那个文件的路径export const PREVIEW_SIZE_BYTES = 2000 // 预览多少字节
export const PERSISTED_OUTPUT_TAG = '<persisted-output>' // 包裹用的标签
export const TOOL_RESULT_CLEARED_MESSAGE = '[Old tool result content cleared]'
// 内容被清空后的占位文字
claude-code/src/utils/toolResultStorage.ts
有意思的是,这个上限允许被设成 Infinity(无穷大,即永不落盘)。源码注释解释了为什么需要这个例外:
「Set to Infinity for tools whose output must never be persisted (e.g. Read, where persisting creates a circular Read→file→Read loop and the tool already self-bounds via its own limits).」
译:对那些输出绝对不能落盘的工具,把它设成无穷大(比如 Read 工具 —— 落盘会造成「读文件 → 结果落盘成文件 → 又要读那个文件」的循环套娃,而且这个工具本身已经有自己的长度限制了)。
这类自引用陷阱在设计通用机制时非常容易踩:你写了一条「所有工具的大结果都落盘」的规则,却忘了其中有一个工具的职责恰好就是「读文件」。
除了单条结果的上限,还有一个「每条消息的聚合预算」机制(源码里叫 ContentReplacementState)—— 防止模型一次并行发出 20 个搜索调用,每个的结果都在单条上限之内,但加起来爆掉。
这一节需要先理解第 1.8 节的提示词缓存。如果还不清楚,请回去读一遍再回来。
目标很朴素:把上下文里那些没用了的旧工具结果删掉。比如 30 轮前读过的一个文件,模型早就用完那个信息了,那几千个 token 白占着。
但直接删有一个致命副作用:
Anthropic 的接口提供了一个叫 cache_edits(缓存编辑)的能力。它的作用是:
客户端把本地的消息历史一个字都不改,照原样发出去。同时附带一条特殊指令:「请在你的缓存里,把 id 为 X、Y、Z 的那几个工具结果删掉。」
服务端照做。因为删除是在服务端的缓存内部完成的,客户端发出的内容前缀完全没变,缓存全部命中。
回到失忆专家的类比:
你不再改动你要念的稿子(改了他就得重新听)。
你照原样念,但在念之前先跟他说一句:「另外,你笔记本上第 12 条和第 15 条那两段,划掉不用管了。」
他划掉那两条,然后照常快速对照笔记本听你念稿。稿子没变,所以他还是能快速跳过。
代码上的实现流程是:
/**
* Cached microcompact path - uses cache editing API to remove tool results
* without invalidating the cached prefix.
*
* - Does NOT modify local message content
* (cache_reference and cache_edits are added at API layer)
* - Uses count-based trigger/keep thresholds from GrowthBook config
*/
async function cachedMicrocompactPath(messages, querySource) {
// 1. 扫出所有"可压缩工具"的调用 id
// 只有这 8 种工具的结果允许被删:
// Read / Bash / Grep / Glob / WebSearch / WebFetch / Edit / Write
const compactableToolIds = new Set(collectCompactableToolIds(messages))
// 2. 按"用户消息"分组,把这些工具结果注册进状态机
mod.registerToolResult(state, block.tool_use_id)
mod.registerToolMessage(state, groupIds)
// 3. 问状态机:该删哪些?(策略是保留最近 N 个,删掉更早的)
const toolsToDelete = mod.getToolResultsToDelete(state)
// 4. 生成 cache_edits 指令块,排队交给 API 层去拼进请求
pendingCacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
// 5. 消息原样返回 —— 本地什么都没变
return { messages, compactionInfo: { pendingCacheEdits: {...} } }
}
claude-code/src/services/compact/microCompact.ts
注意第 1 步:只有 8 种特定工具的结果允许被删。这 8 种的共同特征是「结果是一次性的观察数据」—— 读了个文件、搜了个关键词、跑了个命令。模型消化完就不需要原文了。而其他工具(比如 TodoWrite 维护待办列表)的结果代表持续有效的状态,删掉会造成失忆。
因为本地什么都没改,客户端不知道实际省了多少。所以「已压缩」的通知消息被推迟到 API 响应回来之后才发,用服务端返回的真实数字:
// query.ts,流式响应结束后
const usage = lastAssistant?.message.usage
const cumulativeDeleted = usage?.cache_deleted_input_tokens ?? 0
// ★ 这个字段是"累积的"(从会话开始到现在总共删了多少),
// 不是"本次删了多少"。所以要减去请求前抓的基线值
const deletedTokens = Math.max(0, cumulativeDeleted
- pendingCacheEdits.baselineCacheDeletedTokens)
if (deletedTokens > 0) {
yield createMicrocompactBoundaryMessage(..., deletedTokens, ...)
}
这带来一个副作用:存在一个短暂的「认知偏差窗口」。从「决定删除」到「响应回来」这几秒内,客户端对上下文大小的估算是偏高的(它按没删的算)。这也是为什么后面自动压缩的阈值检查里到处是手工补偿项。
还有一条逻辑完全相反的路径。如果距离上一条模型回复的时间间隔超过了阈值(比如用户去吃了顿饭,30 分钟后才回来):
// 服务端缓存已经过期了,整个前缀无论如何都要重新处理 ——
// 那就在发请求之前先把旧工具结果清掉,缩小要重新处理的量。
// 此时跳过缓存编辑:缓存编辑的前提是缓存还热着,而我们刚确认它已经凉了。
const trigger = evaluateTimeBasedTrigger(messages, querySource)
...
const keepRecent = Math.max(1, config.keepRecent) // ★ 至少留 1 条
const keepSet = new Set(compactableIds.slice(-keepRecent)) // 保留最后几个
const clearSet = new Set(compactableIds.filter(id => !keepSet.has(id)))
// 直接把这些工具结果的 content 替换成 '[Old tool result content cleared]'
那个 Math.max(1, ...) 有一条很实在的注释:
「Floor at 1: slice(-0) returns the full array (paradoxically keeps everything), and clearing ALL results leaves the model with zero working context. Neither degenerate is sensible.」
译:下限设为 1:因为 slice(-0) 会返回整个数组(矛盾地导致什么都不删),而清空所有结果又会让模型完全没有工作上下文。这两种退化情形都不合理。
(slice(-N) 在 JavaScript 里是「取最后 N 个」。但 -0 在数值上等于 0,而 slice(0) 是「从第 0 个开始取全部」。所以配置成「保留最近 0 个」时,代码的实际行为是「全部保留」—— 一个典型的边界值陷阱。)
同一个「删除旧工具结果」的目标,Claude Code 根据缓存是热的还是凉的走两条完全相反的路:
| 缓存状态 | 做法 | 为什么 |
|---|---|---|
| 热(刚请求过) | 用缓存编辑,本地一字不改 | 改本地就毁缓存,省的钱不如损失的多 |
| 凉(超过间隔阈值) | 直接改本地内容,大刀阔斧清 | 前缀反正要全部重新处理,不清白浪费 |
绝大多数自己搭建智能体的团队完全没有「当前缓存是热还是凉」这个概念,于是压缩策略只有一套,在两种场景下各错一半。
好消息是:判断缓存冷热只需要一个时间戳,成本为零。即使你的模型供应商不提供缓存编辑能力(绝大多数情况),单靠这个冷热分流就能拿到大部分收益。这是本节最值钱的迁移点。
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000 // 警告线的缓冲量
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000 // 错误线的缓冲量
const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS
const errorThreshold = threshold - ERROR_THRESHOLD_BUFFER_TOKENS
claude-code/src/services/compact/autoCompact.ts
(20_000 是 JavaScript 里写 20000 的一种可读性写法,下划线只是千位分隔符,不影响数值。)
注意一个反直觉的设计:「硬阻断」只在自动压缩被关闭时才生效。
逻辑是这样的:自动压缩开着的时候,撞线了就自动压缩,没必要拦用户。只有当用户手动关掉了自动压缩,系统才需要预留 20,000 token 的空间 —— 因为用户可能想自己敲 /compact 命令手动压缩,而那个命令本身也要消耗上下文空间。如果不预留,用户就陷入「上下文满了 → 想手动压缩 → 但压缩命令自己也放不进去了」的死锁。
压缩这件事本身就是让模型做摘要,所以它需要调一次模型。Claude Code 用「分叉子智能体」(forked agent)的方式跑它 —— 也就是复制当前会话的状态开一个临时的子会话。
关键决定是:让这个分叉出来的子会话继承父会话的完整工具集。不是因为摘要需要用工具,而是为了让缓存的键值能匹配上,复用父会话已经建立好的缓存前缀(回顾第 5.3 节:工具清单是上下文前缀的一部分)。
这个决定带来了一个真实的生产问题,源码注释里连数字都留了:
「The cache-sharing fork path inherits the parent's full tool set (required for cache-key match), and on Sonnet 4.6+ adaptive-thinking models the model sometimes attempts a tool call despite the weaker trailer instruction. With maxTurns: 1, a denied tool call means no text output → falls through to the streaming fallback (2.79% on 4.6 vs 0.01% on 4.5).」
译:共享缓存的分叉路径继承了父会话的完整工具集(缓存键匹配的必要条件),而在 Sonnet 4.6 及之后的自适应思考模型上,即使有那条较弱的结尾指令,模型有时仍会尝试调用工具。由于最大轮次被设为 1,一次被拒绝的工具调用意味着完全没有文字输出 → 于是掉进流式请求失败回退分支(在 4.6 上发生率 2.79%,在 4.5 上只有 0.01%)。
也就是说:模型升级导致压缩功能的失败率涨了 279 倍。原因是新模型「更主动」了,看到工具就想用。
解决方案是一段措辞极其强硬的前置指令:
const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
`
claude-code/src/services/compact/prompt.ts
译:「至关重要:只用文字回复。不要调用任何工具。
· 不要使用 Read、Bash、Grep、Glob、Edit、Write,或任何其他工具。
· 你已经在上面的对话里拥有了所需的全部上下文。
· 工具调用会被拒绝,并且会浪费掉你唯一的一次机会 —— 你会任务失败。
· 你的整个回复必须是纯文字:一个 <analysis> 块,后面跟一个 <summary> 块。」
三个提示词工程手法叠在一起用:
| 手法 | 为什么有效 |
|---|---|
| 放在最前面 | 原来这条指令放在结尾(源码里叫 trailer instruction,结尾指令),效果弱。模型对开头的指令服从度明显更高。 |
| 穷举点名 | 不说「任何工具」这种抽象表述,而是把具体工具名一个个列出来。抽象禁令容易被模型解读为「大概是指别的工具,我这个应该没关系」。 |
| 说明后果 | 「会被拒绝」「会浪费你唯一的机会」「你会任务失败」—— 把违规的代价明确告诉模型,比单纯说「不许」有效。 |
摘要指令要求模型先写 <analysis>(分析)块,再写 <summary>(摘要)块。而处理函数 formatCompactSummary() 会把 analysis 块整个剥掉,只把 summary 块放进上下文。
分析块具体要求模型按时间顺序逐段过一遍对话,并逐条识别:
<analysis> 块是一块用完即弃的草稿纸:让模型有地方做详细的推理来提高摘要质量,但这些推理内容不会占用压缩之后的宝贵上下文空间。
这个账非常划算,算一下就清楚:
凡是「一次生成、后续每轮都要重读」的产物,都值得用这个模式。让模型充分思考,然后只保留结论。
如果所有预防措施都失效,API 真的返回了 413(上下文过长),还有三级恢复:
「Do NOT fall through to stop hooks: the model never produced a valid response, so hooks have nothing meaningful to evaluate. Running stop hooks on prompt-too-long creates a death spiral: error → hook blocking → retry → error → … (the hook injects more tokens each cycle).」
译:不要落到结束钩子那条路:模型从来没有产出过一个有效回复,所以钩子没有任何有意义的东西可以评估。在「上下文过长」的情况下运行结束钩子会造成一个死亡螺旋:报错 → 钩子判定不合格要求重试 → 又报错 → …(每一圈钩子自己还会往上下文里注入更多 token)。
请体会一下这个循环的形状。「结束前质量检查」这个功能本身完全合理 —— 它的作用是「如果模型的回答不合格,就把问题指出来让它重做」。但当失败原因是「上下文已经装不下了」时,这个功能会:
失败路径必须能够识别「这一类失败不该触发常规的质量重试机制」。
在你自己的系统里,至少要区分两类失败:
· 「模型答得不好」 → 可以让质量检查介入、注入反馈、重试
· 「系统层面走不通了」(上下文超限、认证失败、配额耗尽)→ 必须绕过所有质量检查,直接向上报错
混在一起处理,就会得到上面那个死亡螺旋。
Hermes 走了完全相反的路。它不定义流水线,而是定义一个可以被整体替换的抽象基类(回顾第 1.9 节:抽象基类是一份「插座标准」,规定接进来的东西必须提供哪些功能)。
class ContextEngine(ABC): # ABC = Abstract Base Class 抽象基类
threshold_percent: float = 0.75 # 用到上下文窗口的 75% 就开始压缩
protect_first_n: int = 3 # 开头保护 3 条消息(系统提示词之外)
protect_last_n: int = 6 # 结尾保护 6 条消息
# ↓ 下面三个标了 @abstractmethod,意思是"任何实现都必须提供这三个"
@abstractmethod
def update_from_response(self, usage) -> None: ...
# 每次拿到模型响应后,用其中的用量数据更新自己的记账
@abstractmethod
def should_compress(self, prompt_tokens=None) -> bool: ...
# 这一轮该压缩了吗?
@abstractmethod
def compress(self, messages, current_tokens=None, focus_topic=None,
force=False, memory_context="") -> List[Dict]: ...
# 执行压缩,返回压缩后的新消息列表
# ↓ 下面这些是可选钩子,默认实现都是安全的"什么都不做"
def prune_tool_results_only(self, messages, current_tokens=None):
return messages, 0 # 不调模型的确定性裁剪
def select_context(self, request_messages, ...): return None
def on_turn_complete(self, messages, usage=None, **kw): return None
def should_compress_preflight(self, messages): return False
def get_tool_schemas(self): return [] # 引擎可以自带工具!
def handle_tool_call(self, name, args, **kw): ...
hermes-agent/agent/context_engine.py
compress():上下文太长了 → 把它变短。
select_context():这一轮属于另一个上下文 → 换那一个来用。
「Without this hook, engines that need per-turn access to the message list have to force should_compress() to return True so that compress() is invoked every turn purely as a callback — which conflates selection with compression and degrades behaviour when the engine's backend is unavailable.」
译:没有这个钩子的话,那些需要每轮都拿到消息列表的引擎,只能强迫 should_compress() 永远返回 True,从而让 compress() 每轮都被调用 —— 纯粹把它当成一个回调用。这就把「选择」和「压缩」这两件事混为一谈了,而且当引擎的后端服务不可用时行为会变得很糟。
这是一个被真实误用逼出来的接口。故事是这样的:
should_compress() 和 compress()should_compress() 永远返回 True,把 compress() 当成「每轮回调」来用compress() 就会失败 —— 而系统以为「压缩失败了,上下文还是太长」,进入错误的恢复流程select_context()select_context() 返回的列表只作用于本次请求,不写回持久化的对话记录。所以即使引擎选错了,也不会污染后续轮次。
还有一个对称的后置钩子 on_turn_complete():轮次结束后观察实际发生了什么,更新自己的索引、路由状态、话题状态,让下一次 select_context() 能用上这些信息。选择(前)+ 观察(后)构成一个闭环。
「Ordering / cache contract: the host runs this hook before prompt cache-control and before every request sanitizer (orphaned-tool cleanup, thinking-only/role normalization, whitespace/JSON normalization). So (a) whatever the hook returns still passes through the same validation as any request — a malformed replacement cannot reach the provider — and (b) prompt-cache stability is preserved: the default no-op leaves the request byte-identical.」
译:顺序与缓存契约:宿主程序在「提示词缓存控制」之前、以及在「每一个请求净化器」之前运行这个钩子(净化器包括:孤儿工具清理、纯思考块与角色规范化、空白字符与 JSON 格式规范化)。因此:(a) 钩子返回什么,都仍然要经过和普通请求一样的全部校验 —— 一个格式错误的替换结果无法抵达模型供应商;(b) 提示词缓存的稳定性得到保持:默认的空操作实现让请求保持字节级完全相同。
翻译成设计原则:插件钩子必须跑在所有校验器之前。这样插件返回的垃圾数据也过不了校验,不会污染到模型供应商。这是「不完全信任插件」的正确姿势 —— 你给了第三方替换整个上下文的权力,但你保留了最终的把关权。
| 问题 | Claude Code | Hermes |
|---|---|---|
| 什么时候触发 | 五级各有独立的触发条件(结果大小 / 时间间隔 / 工具数量 / token 数) | should_compress() 单一决策点,阈值 75% |
| 不调模型的 廉价裁剪 |
微压缩(按工具调用 id 精确删除) | prune_tool_results_only(),独立的低成本触发器 |
| 缓存友好度 | 缓存编辑:本地不动,让服务端删 | 按不同供应商的规则重新标记缓存分界点 |
| 保护窗口 | 压缩分界点 + 保留段(preservedSegment) |
protect_first_n=3 / protect_last_n=6 |
| 能不能整体替换 | 不能。写死在代码里,没有扩展点 | 能。整个引擎可以被插件替换掉 |
| 413 之后的恢复 | 三级瀑布 + 错误扣留 | 压缩重试 + compression_attempts 上限(默认 3 次) |
| 引擎能自带工具吗 | 不能 | 能。get_tool_schemas()(比如检索型引擎可以提供一个「搜索历史」工具给模型用) |
Claude Code 的五级阶梯更强,但只对 Anthropic 的接口有效 —— 缓存编辑是它家的私有能力,换供应商就没了。
Hermes 的抽象基类更弱但更通用。它某种意义上承认了「我不知道对所有模型都最优的策略是什么」,于是把决定权交出去。
这不是谁对谁错,是不同约束下的最优解:Claude Code 只服务一家供应商,所以可以全力押注;Hermes 要服务十几种后端,只能定义契约。
面试里被问「你会怎么设计」,先问清楚约束再回答 —— 这个动作本身就是加分项。因为它表明你知道这个问题的答案取决于约束,而不是有一个标准答案。
标准答案是「摘要压缩」,但那只是最后一级。完整回答应该是一条成本递增的阶梯:
然后加两个能显著拉开差距的点:
如果对方追问保护窗口怎么定,补一句思考块的约束:思考块必须在整条模型轨迹内保持完整(一个轮次,加上它之后的工具结果,再加上紧接着的下一条模型回复)。所以压缩的切点不能随便落在「最后 6 条」这样的位置 —— 必须落在轨迹的缝隙上。