Hermes 与 Claude CodeHermes 与 Claude Code第 6 章 · 14 章Chapter 6 of 14
全文目录Contents
  1. 这个网页怎么用
  2. 1 · 零基础前置知识
    1. 1.1 大语言模型是什么
    2. 1.2 token 是什么
    3. 1.3 上下文与上下文窗口是什么
    4. 1.4 智能体与聊天机器人的区别
    5. 1.5 工具调用是什么,它到底怎么工作
    6. 1.6 什么是流式输出
    7. 1.7 什么是 API
    8. 1.8 提示词缓存 —— 全文最重要的技术概念
    9. 1.9 还会遇到的几个词
  3. 2 · 两个系统的宏观定位与架构总图
    1. 2.1 用一句话说清各自的定位
    2. 2.2 架构总图
    3. 2.3 关键数字对照
    4. 2.4 第一个值得记住的洞察
  4. 3 · 垂直分层与水平分区
    1. 3.1 垂直分层:六层模型
    2. 3.2 水平分区:同一层内部怎么切
    3. 3.3 一个反直觉的观察:巨型文件
    4. 3.4 分层之外:穿透所有层的四类逻辑
  5. 4 · 智能体主循环与错误恢复状态机
    1. 4.1 教科书版本的循环,以及它会死在哪
    2. 4.2 Claude Code 的做法:写成显式状态机
    3. 4.3 错误扣留:可恢复的错误不能立刻往外发
    4. 4.4 中断的正确处理姿势
    5. 4.5 切换备用模型:一个想不到的坑
    6. 4.6 Hermes 的做法:预算驱动的循环
    7. 4.7 Hermes 独有的能力:轮次中途插话
    8. 4.8 两家对照与取舍
  6. 5 · 工具抽象层与工具执行编排
    1. 5.1 Claude Code 的工具接口:一个教科书级抽象
    2. 5.2 渐进式工具加载:工具搜索机制
    3. 5.3 工具清单的装配:一个只有深度用过缓存才知道的坑
    4. 5.4 执行编排:并发分区
    5. 5.5 流式工具执行器:边流边执行
    6. 5.6 Hermes 的工具层:中心分发 + 参数强制矫正
    7. 5.7 两家对照与取舍
  7. 6 · 上下文治理阶梯 ★ 全文核心
    1. 6.1 Claude Code 的做法:五级流水线
    2. 6.2 第 ① 级:工具结果预算与落盘
    3. 6.3 第 ③ 级:缓存编辑 —— 全文最精彩的一处
    4. 6.4 第 ⑤ 级:自动摘要压缩的工程细节
    5. 6.5 上下文真的超了之后:三级恢复瀑布
    6. 6.6 Hermes 的做法:把整条阶梯抽象成一个插座
    7. 6.7 两家横向对照
  8. 7 · 权限模型与安全边界
    1. 7.1 Claude Code 的做法:十级决策级联
    2. 7.2 自动模式:用模型判断安全性,加三级快速通道
    3. 7.3 Hermes 的做法:正则红线 + 对抗性解析
    4. 7.4 Hermes 的第二道防线:执行环境隔离
    5. 7.5 一个常被忽略的攻击面:错误消息回灌
    6. 7.6 两家对照与取舍
  9. 8 · 多智能体协作编排
    1. 8.1 子智能体到底解决什么问题
    2. 8.2 Claude Code 的三种子智能体形态
    3. 8.3 分叉子智能体:把提示词缓存用到极致
    4. 8.4 子智能体的工具限制
    5. 8.5 Hermes 的做法:任务委派 + 看板协作
    6. 8.6 一个两家共同的硬约束:中断的级联
  10. 9 · 记忆系统与扩展体系
    1. 9.1 记忆:两种截然不同的答案
    2. 9.2 Hermes 的全息记忆值得单独看
    3. 9.3 Claude Code 的记忆预取:藏在流水线里的优化
    4. 9.4 扩展体系:四种扩展点
    5. 9.5 Hermes 的网关层:Claude Code 完全没有的一层
  11. 10 · 两个系统的横向对照总表
    1. 10.1 机制对照
    2. 10.2 两条架构路线各自的账本
    3. 10.3 两家一致的地方 = 事实上的行业共识
  12. 11 · 可以搬到自己项目里的实现范式
    1. 11.1 骨架一:带恢复状态机的主循环
    2. 11.2 骨架二:上下文治理阶梯
    3. 11.3 骨架三:工具抽象 + 并发分区
    4. 11.4 骨架四:按信任边界配置工具面
    5. 11.5 骨架五:不可绕过的安全底座
    6. 11.6 自建智能体的决策清单
    7. 11.7 一页纸检查清单
  13. 12 · 面试话术卡
    1. Q1 · 说说你理解的智能体架构
    2. Q2 · 上下文满了怎么办
    3. Q3 · 怎么防止智能体执行危险命令
    4. Q4 · 多工具并行怎么保证不出竞态
    5. Q5 · 智能体循环怎么防止无限循环
    6. Q6 · 什么时候该用子智能体
    7. Q7 · 智能体的长期记忆怎么做
    8. Q8 · 你怎么优化智能体的成本和延迟
    9. Q9 · 反问环节可以问的问题
    10. 最后:这份文档的正确用法
  14. 13 · 术语表
    1. 13.1 模型与调用
    2. 13.2 提示词缓存(全文最重要的概念组)
    3. 13.3 智能体与工具
    4. 13.4 上下文治理
    5. 13.5 编程与架构概念
    6. 13.6 两个系统的关键模块名

6 · 上下文治理阶梯 ★ 全文核心

为什么这是最重要的一章

聊天机器人和智能体的根本差别是:智能体的上下文会自己长大。每执行一次工具,就往上下文里塞进几 KB 甚至几十 KB 的执行结果。几十轮之后必然撞上上下文窗口的上限。

「撞墙了怎么办」这个问题的答案质量,直接决定了一个智能体能不能干长活。

这也是技术面试里最能区分「用过智能体」和「做过智能体」的问题。前者会说「做个摘要压缩一下」;后者会告诉你摘要是最后手段,前面还有三级更便宜的处理,而且顺序不能颠倒。

6.1 Claude Code 的做法:五级流水线

每一次调用模型之前,消息历史要穿过一条固定顺序的流水线。顺序不是随意排的 —— 越便宜的越靠前。

上下文治理五级阶梯
上下文治理五级阶梯 — 左侧的成本轴从 $0 递增到「一次完整的模型调用」。绿色那条旁路边是整个设计的点睛之笔:如果便宜的级别已经把上下文降到阈值以下,最贵的第 5 级就直接跳过不执行点击放大

下面这段是同一条流水线在源代码里的实际形态,附上每一级对应的函数名:

messagesForQuery = getMessagesAfterCompactBoundary(messages) (先取出"上次压缩分界点之后"的消息,之前的已经被摘要替代了) │ │ ① applyToolResultBudget() 成本:0(只有磁盘读写) │ 单条消息里的工具结果加起来超预算 → 写到磁盘文件, │ 正文替换成 <persisted-output> 文件路径引用 ▼ │ ② snipCompactIfNeeded() 成本:0 │ 删除"僵尸消息"与已失效的标记 ▼ │ ③ microcompact() 成本:0 或 极低 │ 按工具调用 id 精确删除旧的工具执行结果 │ ├ 时间触发路径:距上次响应超过阈值 → 缓存已凉 → 直接改本地内容 │ └ 缓存编辑路径:发一条 cache_edits 指令给服务端,本地一字不改 ★ ▼ │ ④ applyCollapsesIfNeeded() 成本:低(增量摘要) │ 投影式折叠,操作日志可重放,跨轮次持久 ▼ │ ⑤ autocompact() 成本:一次完整的模型调用 │ 整段摘要,"最后手段" ▼ │ ⑥ 硬阻断检查(只在自动压缩被关闭时生效) │ isAtBlockingLimit → 直接返回"上下文过长"错误 ▼ prependUserContext() + appendSystemContext() → callModel() (把用户上下文拼到前面、系统上下文拼到后面,然后调模型)

顺序为什么是这个顺序:源码里的一句注释

关于第 ④ 级为什么必须排在第 ⑤ 级之前,源代码里有一句精确的说明:

「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 轮里的具体代码片段、具体行号、具体报错信息就永久丢失了。模型后面如果需要那些细节,只能重新去读文件。

折叠是可重放的、保留结构的 —— 它只是把某段内容暂时收起来,需要时能展开。

所以宁可多跑几级便宜的处理,也要尽量不触发最贵的那一级。这里的「贵」不只是钱,更是信息损失。

6.2 第 ① 级:工具结果预算与落盘

每个工具都在自己的定义里声明了一个 maxResultSizeChars(结果最大字符数)。超过这个上限的执行结果不会进上下文,而是:

  1. 完整内容被写到磁盘上 tool-results/ 目录下的一个文件里
  2. 模型收到的是一个 <persisted-output> 标签包裹的前 2000 字节预览 + 那个文件的路径
  3. 如果模型确实需要看全文,它自己调 Read 工具去读那个文件
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 个搜索调用,每个的结果都在单条上限之内,但加起来爆掉。

6.3 第 ③ 级:缓存编辑 —— 全文最精彩的一处

这一节需要先理解第 1.8 节的提示词缓存。如果还不清楚,请回去读一遍再回来。

先看清楚这里的困境

目标很朴素:把上下文里那些没用了的旧工具结果删掉。比如 30 轮前读过的一个文件,模型早就用完那个信息了,那几千个 token 白占着。

但直接删有一个致命副作用:

你删掉了消息历史第 15 条里的内容 ↓ 发给 API 的上下文,从第 15 条开始就和上一次不同了 ↓ 提示词缓存是前缀匹配的(见 1.8 节) ↓ 第 15 条之后的全部内容,缓存全部失效,必须重新处理 ↓ 省下了 3,000 个 token,却让 80,000 个 token 从"缓存价(10%)" 变回了"全价(100%)" ↓ 净结果:更贵了

Claude Code 的解法:让服务端在缓存里删

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 维护待办列表)的结果代表持续有效的状态,删掉会造成失忆。

删了多少 token?得等服务端告诉你

因为本地什么都没改,客户端不知道实际省了多少。所以「已压缩」的通知消息被推迟到 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 根据缓存是热的还是凉的走两条完全相反的路:

缓存状态做法为什么
(刚请求过)用缓存编辑,本地一字不改改本地就毁缓存,省的钱不如损失的多
(超过间隔阈值)直接改本地内容,大刀阔斧清前缀反正要全部重新处理,不清白浪费

绝大多数自己搭建智能体的团队完全没有「当前缓存是热还是凉」这个概念,于是压缩策略只有一套,在两种场景下各错一半。

好消息是:判断缓存冷热只需要一个时间戳,成本为零。即使你的模型供应商不提供缓存编辑能力(绝大多数情况),单靠这个冷热分流就能拿到大部分收益。这是本节最值钱的迁移点。

6.4 第 ⑤ 级:自动摘要压缩的工程细节

阈值怎么定

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> 块是一块用完即弃的草稿纸:让模型有地方做详细的推理来提高摘要质量,但这些推理内容不会占用压缩之后的宝贵上下文空间

这个账非常划算,算一下就清楚:

  • 草稿纸大约 2,000 个输出 token —— 只付一次费用
  • 如果不剥掉,这 2,000 token 会变成输入 token,在后续每一轮都要重新付费
  • 假设压缩后还要进行 30 轮,那就是 60,000 个输入 token 的差别

凡是「一次生成、后续每轮都要重读」的产物,都值得用这个模式。让模型充分思考,然后只保留结论。

6.5 上下文真的超了之后:三级恢复瀑布

如果所有预防措施都失效,API 真的返回了 413(上下文过长),还有三级恢复:

API 返回 413(上下文过长) │ 这个错误被"扣留",不吐给外部调用方(见 4.3 节) ▼ ① collapse_drain_retry ── 排空所有暂存的上下文折叠 最便宜,保住细粒度 限次条件:上一轮的 transition ≠ collapse_drain_retry │ (已经排空过一次就别再试了) ▼ 排空了但提交数为 0(没什么可排的) ② reactive_compact_retry ── 反应式全量摘要压缩 贵,但通常有效 限次条件:hasAttemptedReactiveCompact === false │ (每轮只允许一次,幂等锁) ▼ 压缩失败,或者本轮已经压缩过了 ③ 放弃 ── 把那条被扣留的错误吐出去 executeStopFailureHooks() ★ 但明确不走"结束前检查"钩子

第 ③ 步「明确不走结束钩子」,有一条专门的注释解释

「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)。

请体会一下这个循环的形状。「结束前质量检查」这个功能本身完全合理 —— 它的作用是「如果模型的回答不合格,就把问题指出来让它重做」。但当失败原因是「上下文已经装不下了」时,这个功能会:

  1. 看到一个失败的回复
  2. 判定不合格,生成一段「你的回答有以下问题……」的反馈
  3. 把这段反馈注入上下文 —— 上下文变得更长了
  4. 重试 → 更超了 → 又失败 → 回到第 1 步
可以搬走的做法:失败要分类

失败路径必须能够识别「这一类失败不该触发常规的质量重试机制」。

在你自己的系统里,至少要区分两类失败:
· 「模型答得不好」 → 可以让质量检查介入、注入反馈、重试
· 「系统层面走不通了」(上下文超限、认证失败、配额耗尽)→ 必须绕过所有质量检查,直接向上报错

混在一起处理,就会得到上面那个死亡螺旋。

6.6 Hermes 的做法:把整条阶梯抽象成一个插座

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

最精辟的一处设计:select 和 compress 是两个正交的动词

来自源码文档的定义

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() 每轮都被调用 —— 纯粹把它当成一个回调用。这就把「选择」和「压缩」这两件事混为一谈了,而且当引擎的后端服务不可用时行为会变得很糟。

这是一个被真实误用逼出来的接口。故事是这样的:

  1. 有第三方做了一个基于检索的上下文引擎 —— 它想每一轮都根据当前问题去检索最相关的历史片段
  2. 但接口只提供了 should_compress()compress()
  3. 于是它只能骗系统should_compress() 永远返回 True,把 compress() 当成「每轮回调」来用
  4. 后果:一旦这个引擎的检索后端挂了,compress() 就会失败 —— 而系统以为「压缩失败了,上下文还是太长」,进入错误的恢复流程
  5. Hermes 于是加了一个正经的每轮钩子 select_context()

select_context() 返回的列表只作用于本次请求,不写回持久化的对话记录。所以即使引擎选错了,也不会污染后续轮次。

还有一个对称的后置钩子 on_turn_complete():轮次结束后观察实际发生了什么,更新自己的索引、路由状态、话题状态,让下一次 select_context() 能用上这些信息。选择(前)+ 观察(后)构成一个闭环。

Hermes 的缓存契约写得比 Claude Code 更明确

「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) 提示词缓存的稳定性得到保持:默认的空操作实现让请求保持字节级完全相同。

翻译成设计原则:插件钩子必须跑在所有校验器之前。这样插件返回的垃圾数据也过不了校验,不会污染到模型供应商。这是「不完全信任插件」的正确姿势 —— 你给了第三方替换整个上下文的权力,但你保留了最终的把关权。

6.7 两家横向对照

问题Claude CodeHermes
什么时候触发 五级各有独立的触发条件(结果大小 / 时间间隔 / 工具数量 / 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 要服务十几种后端,只能定义契约。

面试里被问「你会怎么设计」,先问清楚约束再回答 —— 这个动作本身就是加分项。因为它表明你知道这个问题的答案取决于约束,而不是有一个标准答案。

面试追问:智能体上下文满了你怎么处理?

标准答案是「摘要压缩」,但那只是最后一级。完整回答应该是一条成本递增的阶梯:

  1. 不让它进来 —— 单条工具结果超过上限就写到磁盘,只回预览和文件路径。成本为 0。
  2. 删掉确定没用的 —— 旧的文件读取结果、搜索结果(模型早就消化完了),按工具调用 id 精确删除。成本为 0。
  3. 结构化折叠 —— 把一段交互折叠成可展开的摘要,保留结构和可重放性。成本低。
  4. 整段摘要 —— 一次完整的模型调用,有损、不可逆。成本高,最后手段。

然后加两个能显著拉开差距的点:

  • 「缓存是热的还是凉的」应该成为策略的输入。缓存热的时候要不惜代价避免改动上下文前缀(Claude Code 甚至用缓存编辑让服务端去删);缓存已经凉了的时候反而应该大刀阔斧地清,因为前缀反正要全部重新处理。同一个目标,两条相反的路。
  • 压缩失败的路径不能触发常规的质量重试机制。否则会出现「上下文超了 → 质量检查钩子要求重试 → 钩子又往上下文注入反馈内容 → 更超了」的死亡螺旋。源码里就管这个叫 death spiral。

如果对方追问保护窗口怎么定,补一句思考块的约束:思考块必须在整条模型轨迹内保持完整(一个轮次,加上它之后的工具结果,再加上紧接着的下一条模型回复)。所以压缩的切点不能随便落在「最后 6 条」这样的位置 —— 必须落在轨迹的缝隙上。