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

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

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

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. 整段摘要 —— 一次完整的模型调用,有损、不可逆。成本高,最后手段。

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

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