跳到主要内容

数据截至 (上游 commit 4ac938ddecce)

上下文工程 —— 系统提示怎么拼、上下文怎么省

30 秒导读: 每次调模型,Hermes 都要往请求里塞一大坨东西——身份、行为守则、项目里的 AGENTS.md、技能索引、记忆快照、几十个工具的 JSON schema、整段对话历史。这章讲这坨东西是怎么攒出来的,以及攒到装不下时按什么顺序往外扔

本章接着 一轮对话是怎么跑完的 往下讲:主循环负责"发请求、收响应、跑工具",本章负责"请求里那段最长的文本从哪来"。技能内容本身怎么被创造和策展,见 自我进化闭环


1. 先建立直觉:一次请求里到底有什么

把模型的上下文窗口当成一块固定大小的白板。每轮对话,Hermes 要在白板上写四类东西:

写什么谁产生的会不会随轮次变长
系统提示本章第 2-4 节讲的拼装流程不会——一场会话内逐字不变
工具 schema工具注册表导出的 JSON不会,但可能被"延迟披露"缩小(第 6.1 节)
对话历史用户消息 + 助手回复 + 工具结果会,而且是主要膨胀源
本轮新增最新用户消息、刚跑完的工具输出单条可能极大(读了个 5 万行文件)

白板写满了会怎样?提供方直接返 400。所以"上下文工程"这个词在 Hermes 里落地成两件具体的工程活:

  1. 拼装侧——系统提示怎么组,组完为什么绝不能中途改(第 2-5 节)。
  2. 回收侧——白板快满时,按什么顺序擦掉哪些内容(第 6 节)。

这两件事共用一条暗线:便宜的手段优先,昂贵的手段(再调一次 LLM 去做摘要)留到最后


2. 系统提示的三层结构

2.1 为什么要分层

系统提示不是一根字符串拼到底,而是先拼成三个"层",再用 \n\n 顺序连起来。分层的唯一目的是把变化频率不同的内容排开:越不变的放越前面。

build_system_prompt_parts 返回的正是这三层(agent/system_prompt.py:340,返回值构造在 :463-467):

层名装什么变化频率
stable身份、行为守则、技能索引、环境探测、平台提示进程生命周期内固定
context调用方传入的 system_message + cwd 下的项目上下文文件换一个工作目录才变
volatile记忆快照、USER.md、外部记忆块、日期/会话/模型行每场会话变一次

三层拼接发生在 build_system_prompt(agent/system_prompt.py:903),一行代码:

joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)

注意 volatile 层名字叫"易变",但它也只在会话开始算一次。名字描述的是"内容的性质",不是"重算的频率"——这个区别是第 5 节整套缓存策略的前提。

2.2 一张图看清拼装顺序

怎么读这张图:从上往下就是最终文本里各块的先后顺序;带 [?] 的块表示有开关,条件不满足就整块不出现

┌─ stable ───────────────────────────────────────────────┐
│ ① 身份 SOUL.md ─┐(没有则) │
│ └→ DEFAULT_AGENT_IDENTITY │
│ ② 通用守则 自我介绍指引 / 任务做完再停 / 并行调工具 │
│ ③ 按工具开 [?] 记忆·会话检索·技能·看板·中途插话·电脑操作│
│ ④ 按模型开 [?] 强制真调工具 + Gemini / GPT 专属纪律 │
│ ⑤ 技能索引 [?] 分类 + 名字 + ≤60 字描述(第 4 节) │
│ ⑥ 环境事实 本机或远端后端探测 + Python 工具链 + 档案名 │
│ ⑦ 平台提示 [?] whatsapp / 其它渠道的排版约束 │
└────────────────────────────────────────────────────────┘
↓ "\n\n"
┌─ context ──────────────────────────────────────────────┐
│ 调用方 system_message + 项目上下文文件(第 3 节) │
└────────────────────────────────────────────────────────┘
↓ "\n\n"
┌─ volatile ─────────────────────────────────────────────┐
│ MEMORY.md 快照 + USER.md + 外部记忆块 + 日期/会话/模型行 │
└────────────────────────────────────────────────────────┘

2.3 每块的开关条件

"按需拼装"不是省事,是省钱:没装记忆工具的会话,多塞一段记忆守则就是纯浪费。下表是几个有代表性的开关:

拼进去的条件源码锚点
身份load_soul_identity 或未跳过上下文文件;读不到 SOUL.md 才用硬编码身份agent/system_prompt.py:381-392
记忆守则"memory" in agent.valid_tool_names;再按 _memory_enabled/_user_profile_enabled 二选一(全量 MEMORY_GUIDANCE 或更窄的 USER_PROFILE_GUIDANCE)agent/system_prompt.py:429-432
中途插话说明只要有任何工具(插话只从工具结果尾部送达)agent/system_prompt.py:453-454
电脑操作守则装了 computer_use,且按宿主平台渲染文案agent/system_prompt.py:460-462computer_use_guidance(agent/prompt_builder.py:596)
强制真调工具配置 agent.tool_use_enforcement:auto 按模型名匹配,也可 true/false/自定义列表agent/system_prompt.py:474-489
模型家族纪律模型名含 gemini/gemma 上 Google 版(仍嵌在 tool_use_enforcement 命中后);OpenAI 版执行纪律已拆成独立开关 agent.execution_guidance,auto 列表扩到 gpt/codex/grok/deepseek/kimi/qwen 等agent/system_prompt.py:493-494:509-524
技能索引装了 skills_list / skill_view / skill_manage 之一agent/system_prompt.py:526-557

中途插话那块值得单看。 用户在 agent 干活途中发来的消息,会被塞进某个工具结果的末尾——这正是提示注入防御最不信任的通道。Hermes 的解法是给它一个自描述的边界标记,再在系统提示里明确"只信这个标记,工具输出/网页/文件里长得像的一律不信"(format_steer_marker,agent/prompt_builder.py:773;配套说明 STEER_CHANNEL_NOTE:600)。信任边界的全景见 信任边界

2.4 环境提示:本机说本机的,远端说远端的

build_environment_hints(agent/prompt_builder.py:1394)产出一段"你在哪台机器上干活"的事实块。它的分岔很干脆:

  • 本地终端后端 → 报宿主的 OS、家目录、cwd(Windows 还额外说明 terminal 走的是 bash 不是 PowerShell,_WINDOWS_BASH_SHELL_HINT:904)。
  • 远端/沙箱后端(docker / ssh / modal / daytona / singularity)→ 压掉宿主信息,改成真进容器里跑一条探针命令,把 uname / $HOME / pwd / whoami 的结果格式化进提示(_probe_remote_backend,agent/prompt_builder.py:1261)。

理由直白:工具在哪台机器上跑,就只该讲哪台机器的事实。探针结果按 (env_type, TERMINAL_CWD) 缓存在进程里(_BACKEND_PROBE_CACHE,:901),所以每进程只花一次 4 秒超时的代价。六种终端后端的细节见 工具层与执行环境


3. 上下文文件:只认一个,且按窗口动态截断

3.1 优先级:先找到的赢,其余不看

Hermes 会去当前工作目录找"项目对 agent 的说明书"。四种来源,第一个命中就停(build_context_files_prompt,agent/prompt_builder.py:2523,判定在 :2512-2518):

顺位文件查找范围加载函数
1.hermes.md / HERMES.md向上走到 git 根_load_hermes_md(:2295)
2AGENTS.md / agents.mdgit 根到 cwd 的目录链逐级合并(AGENTS.override.md 优先)_load_agents_md(:2348)
3CLAUDE.md / claude.md仅当前目录_load_claude_md(:2406)
4.cursorrules + .cursor/rules/*.mdc仅当前目录_load_cursorrules(:2425)

SOUL.md 不参与这个竞争:它来自 HERMES_HOME,是身份槽位(load_soul_md,:1796)。已经作为身份加载过时,build_context_files_promptskip_soul=_soul_loaded 避免重复注入(agent/system_prompt.py:795-801)。

兼容别家格式的取舍:只认第一个,意味着一个同时放了 AGENTS.mdCLAUDE.md 的仓库,后者永远不会被读。这是刻意的——两份说明书打架比少读一份更糟。

3.2 截断上限跟着模型窗口走

一份 8 万字的 AGENTS.md 会把小模型的窗口吃掉一半。所以每个来源在注入前都过一遍 _truncate_content(agent/prompt_builder.py:2279),上限按三级优先解析(_get_context_file_max_chars,:1202):

  1. 配置里显式写了 context_file_max_chars → 用它,用户最懂。
  2. 否则用动态上限:_dynamic_context_file_max_chars(:1187),公式是 context_length × 4 字符/token × 6%,下限 20,000、上限 500,000(常量在 :1182-1184)。
  3. 都没有 → 回到历史值 CONTEXT_FILE_MAX_CHARS = 20_000(:1172)。

窗口值从哪来?build_system_prompt_parts 开头先从压缩器身上取一次 context_length,再一路传下去(agent/system_prompt.py:367-372)。注释点明了关键:这个值在会话内是稳定的,所以不威胁前缀缓存

截断方式不是"砍掉尾巴",而是留头 70% + 留尾 20%,中间插一条带路径的提示(比例常量 CONTEXT_TRUNCATE_HEAD_RATIO / TAIL_RATIO:1173-1174,拼接在 :1783-1793)。中段丢了不要紧,标记里直接告诉模型"要全文就用 read_file 读这个路径"——这就是渐进式披露:系统提示里放索引,正文按需自取

3.3 截断警告怎么送到用户眼前

截断在深处发生,用户却该知道。Hermes 用一个 ContextVar 累积警告(_truncation_warnings,:1227),由 _record_truncation_warning(:1232)写入、drain_truncation_warnings(:1241)取走,最后在 build_system_prompt 里同步排空、走正常状态通道播报(agent/system_prompt.py:926-927)。

用 ContextVar 而不是模块级 list,是为了网关场景:多个会话并发拼装提示时,各自的警告不会互相清空或串台(注释在 :1222-1226)。


4. 技能索引:渐进式披露 + 两层缓存

4.1 索引长什么样,为什么只放描述

装了技能类工具时,系统提示里会多出一段 ## Skills (mandatory)(build_skills_system_prompt,agent/prompt_builder.py:1828,正文渲染在 :1621-1674)。它的形状是分类 → 技能名 → 一句话描述:

<available_skills>
research: 查资料类技能
- arxiv-search: Search arXiv papers by keyword, author, or ID.
- web-digest: Summarize a long page into bullet points.
media [names only]: video-clip, image-upscale
</available_skills>

这就是渐进式披露的教科书形态:索引常驻、正文按需。模型看到名字和一句话,判断相关就调 skill_view(name) 把完整 SKILL.md 拉进来;不相关的技能,全文一个字都没进上下文。

4.2 为什么 description 必须 ≤60 字符

索引里那句描述不是原样搬运,而是过了一道硬截断(extract_skill_description,agent/skill_utils.py:1173,上限常量 SKILL_PROMPT_DESC_LIMIT = 60:1164):

if len(desc) > SKILL_PROMPT_DESC_LIMIT:
return desc[:SKILL_PROMPT_DESC_LIMIT - 3] + "..."
return desc

超过 60 字符的部分被静默丢弃。这条规则在技能创作侧被写成红线并且解释了原因(agent/learn_prompt.py:40-48):索引每场会话都要加载,超出的部分永远不会参与路由,所以"写完要数字符,别写一句然后祈祷"。技能管理工具在 create/update 时还会回显截断后的样子(_add_description_prompt_preview,tools/skill_manager_tool.py:897-904),作者能当场看到索引里实际会显示的那句。

4.3 两层缓存:进程内 LRU + 磁盘快照

扫一遍技能目录、逐个解析 frontmatter,对冷启动是实打实的开销。所以索引构建前有两层缓存:

build_skills_system_prompt(tools, toolsets, compact_cats)

├─① 进程内 LRU 命中 → 直接返回渲染好的字符串
│ key = 目录 + 工具集 + 平台 + 禁用名单 + 折叠分类
│ 容量 8 条,OrderedDict + 锁
│ ↓ 未命中
├─② 磁盘快照 ~/.hermes/.skills_prompt_snapshot.json
│ 校验 = 每个 SKILL.md / DESCRIPTION.md 的 (mtime_ns, size)
│ 校验通过 → 用预解析好的元数据,跳过全盘扫描
│ ↓ 校验失败 / 不存在
└─③ 冷路径 走一遍文件系统,解析后顺手写回快照

对应符号:LRU 容量与锁 _SKILLS_PROMPT_CACHE_MAX = 8(:1255-1257),缓存键构造 :1458-1466;快照清单 _build_skills_manifest(:1276)、加载校验 _load_skills_snapshot(:1289)、写回 _write_skills_snapshot(:1307)。

快照的版本号 _SKILLS_SNAPSHOT_VERSION(:1258)和清单比对是"能否复用"的唯一判据——任何一个技能文件改了大小或 mtime,整份快照作废(:1300-1303)。粗,但绝不会读到陈旧描述。

4.4 两种"少写一点",但从不隐藏

索引会因为两类原因变短,边界清楚:

手段效果判定处
条件过滤整条不进索引_skill_should_show(:1386):requires_tools/toolsets 缺了就藏;fallback_for_* 在主工具可用时就藏
分类折叠分类塌成一行只剩名字,描述被丢掉:1599-1619,来源是编码姿态给的 compact_categories

折叠那段的注释写了条铁律:永远不整条删除。理由是 agent 自己造的技能就是它的项目记忆,而模型不会主动去调 skills_list 重新发现"索引里消失的东西"(:1599-1607)。所以名字必须留着,skill_view(name) 永远能加载。


5. 缓存友好:整场会话一个字都不改

5.1 核心不变式

agent/system_prompt.py 的模块头把这条规矩写在最前面:系统提示每场会话构建一次,跨所有轮次复用,只有压缩事件才触发重建(agent/system_prompt.py:1-8:126-129:481-483)。

为什么这么狠?因为上游的前缀缓存(prefix cache)按逐字节相同的前缀匹配。系统提示是整个请求最靠前、最长的一块,改动一个字符,后面所有缓存全废。

5.2 四道具体的防线

防线做法源码
只建一次_cached_system_promptNone 才构建agent/turn_context.py:744-747
跨进程复用网关每轮新建 AIAgent,改从 SQLite 会话行读回上一轮的原文_restore_or_build_system_prompt(agent/conversation_loop.py:809),写回在 :959
时间戳只到天Conversation started: <星期, 月 日, 年> + 固定时区后缀,不含分钟agent/system_prompt.py:849-879
记忆冻结快照中途写记忆只落盘,不动系统提示;下次会话才刷新tools/memory_tool.py:11-14

时间戳那条特别值得记:分钟级精度会让每一条重建路径(压缩边界、网关新 agent、会话恢复失败)都拿到不同的字符串,前缀缓存必然失效;模型真需要精确时间时可以调工具查(注释在 :811-816)。

记忆快照那条则是"持久化"和"缓存"的一次正面冲突:记忆要立刻落盘(durable),提示要一动不动。Hermes 的裁法是双轨——文件立刻写、快照下次会话再换(tools/memory_tool.py:11-14:19-23)。这套记忆机制本身见 自我进化闭环

5.3 两个不进缓存串的东西

  • ephemeral_system_prompt:只在发请求的那一刻追加,不进 build_system_prompt_parts 的产物、不入库、不进轨迹(声明在 agent/system_prompt.py:779-780,实际拼接在 agent/conversation_loop.py:2316-2317agent/chat_completion_helpers.py:2931-2932)。
  • 工具定义:format_tools_for_system_message(agent/system_prompt.py:998)只是把工具序列化成轨迹格式,工具真正是通过 API 的 tools 参数走的,不混进系统提示文本。

5.4 Anthropic 侧的显式断点

前缀缓存对 Anthropic 要显式打标。apply_anthropic_cache_control(agent/prompt_caching.py:434)只有一种布局,叫 system_and_3:4 个 cache_control 断点 = 系统提示 + 最后 3 条非系统消息,同一个 TTL(5m1h)。

模块头声明的收益是多轮会话输入 token 成本降约 75%(agent/prompt_caching.py:1-11)。实现是纯函数、深拷贝入参,不碰 AIAgent 状态。

顺带一个细节:后台复盘 fork 在跑同一个模型时,是直接把父 agent 的缓存字符串赋过去的(agent/background_review.py:1255,条件判断在 :692),而不是重新拼——它因此继承同一条热缓存前缀;被路由到别的模型时缓存键本就不同,那份提示不再继承。这个闭环见 自我进化闭环


6. 上下文超限时的处置顺序

6.1 总图:便宜的先来

怎么读这张图:从上往下,成本递增。每一级都可能让下一级不必发生;只有走到最底下才会真的再花一次 LLM 调用

① 工具自己先截 50K 字符 / 2000 行 / 单行 2000 字符
tools/tool_output_limits.py:59
↓ 还是太大
② 单条结果落盘 超阈值就写进沙箱临时目录,上下文里只留预览 + 路径
tools/tool_result_storage.py:293
↓ 多条中等结果加起来仍超
③ 整轮结果预算 按大小排序,最大的先外溢,直到总量回到预算内
tools/tool_result_storage.py:378
↓ 是工具 schema 太占地方
④ 工具延迟披露 非核心工具收进目录,只留 3 个桥工具
tools/tool_search.py:772
↓ 是对话历史太长
⑤ 发请求前预检 粗估 token ≥ 阈值 → 先压再发(最多 3 轮)
agent/turn_context.py:860
↓ 或收到响应后按真实用量判定
⑥ 压缩(本节 6.3) 先免费手段,最后才调辅助模型做摘要
agent/context_compressor.py:6877

前三级是每轮都在跑的常规限流,第四级是装配工具数组时的一次性决策,后两级才是"上下文工程"通常指的那个压缩。

6.2 阈值是怎么算出来的

触发压缩的门限不是简单的"窗口 × 百分比"。_compute_threshold_tokens(agent/context_compressor.py:2936)依次做三件事:

  1. 扣掉输出预留:effective_window = context_length - max_tokens。提供方把 max_tokens 从同一个窗口里切走,按整窗算门限会导致压缩还没触发就被 400(:760-767)。
  2. 取百分比,再托底:默认阈值来自配置 compression.threshold,默认 0.50(agent/agent_init.py:2075);结果再和 MINIMUM_CONTEXT_LENGTH = 64_000(agent/model_metadata.py:405)取大,免得大窗口模型在 50% 就早压。
  3. 处理退化情形:托底值一旦 ≥ 有效窗口,门限就永远够不到、自动压缩永不触发。此时改用 85%(_MIN_CTX_TRIGGER_RATIO,:722)——既让小模型用掉大部分预算,又赶在提供方拒绝前压一次。

6.3 ContextEngine:可插拔的抽象与六步生命周期

压缩不是写死的,而是一个可替换的引擎接口 ContextEngine(agent/context_engine.py:89),由配置 context.engine 选择,默认 "compressor"。模块头列出的六步生命周期就是引擎与主循环的全部契约(agent/context_engine.py:18-26):

① 实例化并注册(插件 register() 或内置默认)
② on_session_start() 会话开始
③ update_from_response() 每次 API 响应后喂真实用量
④ should_compress() 每轮判定
⑤ compress() 判定为真时压缩
⑥ on_session_end() 真正的会话边界(不是每轮)

引擎必须自己维护 last_prompt_tokens / threshold_tokens / context_length 等字段,主循环直接读它们做展示与判定(:42-51)。

protect_first_n 的语义要看清:它是"在系统提示之外,额外保护的开头非系统消息条数",默认 3;系统提示本身永远隐式受保护(agent/context_engine.py:116-123,实现 _protect_head_sizeagent/context_compressor.py:5783)。

6.4 ContextCompressor 的五个阶段

内置引擎 ContextCompressor(agent/context_compressor.py:1989)的 compress()(:6877)按这个顺序走:

阶段一:免费清理(不调 LLM)。 _prune_old_tool_results(:3399)在保护尾部之外做四遍:

遍次干什么位置
Pass 1内容相同的工具结果去重,旧的换成"与更近一次调用内容相同":3492-3524
Pass 2大工具结果换成一行信息量摘要;多模态截图换成占位文本(判定逻辑在 _demote_tool_result_at,:3525-3578):3599-3601
Pass 3助手消息里过长的 tool_calls 参数缩短(逻辑在 _truncate_tool_call_args_at,:3579-3598):3603-3611
Pass 4保护尾部自己超预算时,把区内的臃肿工具结果也降级(issue #61932):3614-3640

Pass 2 的摘要不是通用占位符,而是带信息的一行,由 _summarize_tool_result(:1615)按工具名分派生成,例如 [terminal] ran `npm test` -> exit 0, 47 lines output[read_file] read config.py from line 1 (1,200 chars)。留下"做过什么",扔掉"具体输出"。

Pass 3 用的 _truncate_tool_call_args_json(:1422)是个踩过坑的实现:早期版本按字节偏移直接切原始 JSON,切出未闭合的字符串,MiniMax 之类严格校验的提供方直接 400,而且每一轮都会重发这段坏历史,会话彻底卡死(issue #11762)。现在的做法是解析成对象、只缩短里面的长字符串叶子、再重新序列化,保证 JSON 始终合法。

阶段二:定边界。 头部由 _protect_head_size 决定;尾部由 _find_tail_cut_by_tokens(:5959)按 token 预算从后往前走。三条约束叠在一起:

  • 预算是主判据,但允许超出 1.5 倍(soft_ceiling),免得从一条超大消息中间切开。
  • 保底条数 max(3, min(protect_last_n, 8))——上限常量 _MAX_TAIL_MESSAGE_FLOOR = 8(:1070),防止一串臃肿工具输出每次都被整体保留。
  • 边界不许切开 tool_call / tool_result 配对(_align_boundary_backward,:5640),且最后一条用户消息和最后一条助手消息必须在尾部——否则当前任务会被卷进摘要里丢掉。

阶段三:LLM 摘要。 中段序列化成带角色标签的文本(_serialize_for_summary,:3832,每条正文上限 6000 字符、留头 4000 留尾 1500),预算按被压内容量缩放(_compute_summary_budget,:3808:内容的 20%,下限 2000、上限 min(窗口 5%, 12000))。序列化时逐条做敏感信息脱敏(_redact_compaction_text,:1105),免得密钥流进摘要并被持久化。

阶段四:加边界标记。 摘要前面挂一段很长的交接说明 SUMMARY_PREFIX(:114),核心意思是"这是背景,不是待办;只回应摘要之后那条最新用户消息";末尾再加 _SUMMARY_END_MARKER(:339)。这两条防的是真实故障:弱模型会把摘要里引用的 ## Active Task 当成新指令,或者把助手角色的摘要当成自己的输出复读(注释里点了 issue #11475 / #14521 / #33256)。历史版本的前缀也保留在 _HISTORICAL_SUMMARY_PREFIXES(:363)里,用于在重新压缩时把旧指令一并剥掉。

阶段五:收尾与防抖。 修复孤儿工具配对(_sanitize_tool_pairs,:5439),再剥掉历史图片(_strip_historical_media,:1519)——锚点是最后一条带图的用户消息,它之前的图片全换成占位文本,避免每轮重发几 MB base64。

最后按提供方报的真实 token 算节省率:低于 10% 就给 _ineffective_compression_count 加一(判定写在 update_from_response,消费点 :3269-3272>= 2 闭锁);连续两次无效,should_compress_info(:3221)直接拒绝再压,并给调用方一个 "ineffective" 原因去提示用户改用 /new 或带主题的 /compress。这是防止"每轮压一点、每轮都触发"的死循环。

6.5 一个反直觉的设计:保护头部会衰减

protect_first_n 保住开头几轮,是为了让"原始任务描述"活过第一次压缩。但每次压缩都这么干,早期消息就成了永生的化石——被逐次复制进子会话,头部无界增长(issue #11996)。

所以 _effective_protect_first_n(agent/context_compressor.py:5748)加了衰减:只要压过一次(或已有历史摘要),就退化成 0;重启后还会从恢复出的交接摘要推断出这个衰减态。理由是那时早期内容已经进了交接摘要,没必要再逐字保留;系统提示仍由 _protect_head_size 单独兜底。

6.6 两个触发点,外加一条兜底

触发点时机用什么数位置
预检发请求粗估(含工具 schema)agent/turn_context.py:882-947
事后收到响应、跑完工具提供方报的真实 prompt_tokens,取不到才回退粗估agent/conversation_loop.py:7401-7460
兜底提供方报了溢出类错误(413 / 上下文超限 / 长上下文档位 429)——agent/conversation_loop.py:5196-5298

事后判定只看 prompt_tokens,不看 completion/reasoning——思考型模型的推理 token 会让总数虚高,导致过早压缩(注释点名 GLM-5.1、QwQ、DeepSeek R1,issue #12026)。

预检和事后判定之间有个微妙的冲突:预检的粗估故意偏高(好赶在提供方拒绝前压),于是刚压完的那一轮,粗估可能仍然超阈值,触发第二次无谓压缩。should_defer_preflight_to_real_usage(agent/context_compressor.py:3304)就是那个刹车:压缩刚跑完、真实用量还没回来时直接推迟一轮;已有"提供方证明能装下"的真实数据时,只要粗估相对基线增长在容忍范围内(max(4096, 阈值×5%))也推迟(issue #36718)。

兜底那条有个用户主权的细节:用户显式关了自动压缩(compression.enabled: false)时,溢出错误也不许偷偷压缩+轮转会话,而是报一个终止错误并提示手动 /compress/new 或换大窗口模型(agent/conversation_loop.py:5201-5233)。

6.7 压完之后:系统提示要重建

压缩是唯一会让系统提示重建的事件。compress_context(agent/conversation_compression.py:2234)在压完后做三件事(:548-550):

agent._invalidate_system_prompt()
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt

invalidate_system_prompt(agent/system_prompt.py:932)除了清缓存,还会从磁盘重新加载记忆——这样重建出来的提示能带上本场会话中途写入的记忆。换句话说:第 5.2 节说的"中途写记忆不改提示",在压缩这个天然的缓存断点上得到了补偿。

压缩失败时的分岔也值得记:默认是插一段"摘要不可用"的确定性交接文本、照样丢掉中段;但鉴权失败和网络中断一律中止,消息原样返回、会话不轮转(agent/context_compressor.py:7474-7487)。理由是拿着坏凭据轮进一个带占位摘要的子会话,只会把用户困在一个降级会话里,零收益。

6.8 手动 /compress

用户可以主动压,还能带一个聚焦主题(focus_topic),让摘要器优先保留相关信息。手动路径与自动路径的两点区别:

  • force=True 会先清掉失败冷却窗口,让用户在自动压缩失败后能立刻重试(agent/context_compressor.py:7125-7130),同时跳过"中段太小不值得摘要"的预判,手动请求永远走完整摘要路径。
  • 网关侧先用 has_content_to_compress(:6109)做前置检查:整段对话还都在保护区内就直接回"没什么可压的",省掉一次 LLM 调用。

结果反馈由 summarize_manual_compression(agent/manual_compression_feedback.py:40)统一生成,四个前端(CLI、网关、TUI、斜杠命令)共用。它诚实处理了一个反直觉情形:消息条数变少了、token 估算却上升——那是压缩把散乱对话改写成了更密的摘要,专门给一句说明(:37-42)。


7. 工具侧省 token

7.1 工具延迟披露:三个桥工具

装了几个 MCP 服务器后,工具 schema 本身就能吃掉几万 token——每轮都重发。tools/tool_search.py 的解法是把非核心工具从模型可见的数组里撤走,换成三个"桥":

桥工具干什么
tool_search按关键词检索被收起的工具,返回名字 + 描述
tool_describe取某个工具的完整参数 schema
tool_call按名字 + 参数真正调用它

schema 定义在 bridge_tool_schemas(tools/tool_search.py:641),注释里点明了写法克制的原因:这里多写一个字节,用户每一轮都要付(:634-636)。

三条设计约束,每条都对应一个真实教训(模块头 :9-32):

  1. 核心工具永不延迟。 toolsets._HERMES_CORE_TOOLS 里的一律留在可见数组(is_deferrable_tool_name,:204)。"总是加载"就是总是加载,没有例外。
  2. 只要存在可延迟工具就启用桥,门限只管目录预算。 分层披露语义(2026-07)下,should_activate(:275)不再看 schema 占比:有任一 MCP/插件工具就激活;threshold_pct(默认 5%)改成约束嵌在 tool_search 描述里的目录清单最多吃多少 token(listing_token_budget,:298,取 min(清单上限, 窗口×threshold_pct),窗口未知退回 10,000)。装不下就先降级成"只列名字",再装不下就裸桥。
  3. 目录每次重建,不跨轮持有。 教训来自 OpenClaw 的定时任务回归(openclaw#84141):按会话缓存的目录会和实时工具注册表漂移,产生静默的工具消失。所以 build_catalog(:375)每次都从当前 tool-defs 现建。

检索用的是内联的 BM25(_bm25_score,:401),索引文本只含工具名(下划线拆成词)、描述、顶层参数名——schema 正文故意不索引,实测只增噪声不提召回(_entry_search_text,:343)。

装配入口 assemble_tool_defs(:772)由 model_tools.py:627-650 在工具定义组装的最后一步调用,并且是幂等的(重复调用会先剔除已有桥工具)。桥工具的调用最终仍走 handle_function_call,所以策略、钩子、审批、结果截断的行为和直接调用完全一致(模块头 :30-32)。

7.2 工具输出上限:把硬编码搬进配置

tools/tool_output_limits.py 把原先散在两个文件里的硬编码常量集中成一个配置段:

默认值管什么
max_bytes50,000终端 stdout/stderr 字符上限
max_lines2,000read_file 分页与截断行数
max_line_length2,000单行长度上限

get_tool_output_limits(tools/tool_output_limits.py:59)绝不抛异常,读配置失败一律回默认;结果按进程缓存,避免每次工具调用都读盘(:70-72)。

7.3 工具结果三层防线

tools/tool_result_storage.py 的模块头把防线讲得很清楚(:1-23):

做法入口
1工具自己预截断(唯一由工具作者控制的一层)各工具内部
2单条结果超阈值 → 写进沙箱临时目录,上下文只留预览 + 路径,模型可 read_file 取全文maybe_persist_tool_result(:122)
3整轮所有工具结果加起来超预算 → 按大小从大到小外溢,直到回到预算内enforce_turn_budget(:181)

两个值得记的细节:

  • read_file 的阈值被钉成无穷大(PINNED_THRESHOLDS,tools/budget_config.py:11-13),否则会形成"落盘 → 读文件 → 又落盘"的死循环。
  • 预算跟着窗口缩放:budget_for_context_window(tools/budget_config.py:139)按窗口的 15%(单条)/ 30%(整轮)算,再用历史默认值 100K/200K 封顶、用 8K/16K 托底。大窗口模型行为逐字节不变,小窗口模型才被真正约束(issue #23767)。

写沙箱那步也踩过一个 Linux 坑:早期把内容嵌在命令字符串里,撞上 MAX_ARG_STRLEN 的 128 KB 单参数上限,恰好在超过 128 KB 时失败——也就是这套机制存在的唯一场景。改成走 stdin 后天花板消失(_write_to_sandbox,tools/tool_result_storage.py:249)。


8. 巧妙之处(可以带走的)

  1. "变化频率分层"比"内容分类"更有用。 三层的分界不是按主题(身份/规则/记忆),而是按"多久变一次"。这个排序直接决定了前缀缓存能命中多长的前缀(agent/system_prompt.py:340)。

  2. 时间戳只到天。 一个字符的精度选择,决定了每一条重建路径是否共享缓存前缀(agent/system_prompt.py:849-879)。

  3. 持久化和缓存冲突时,走双轨。 记忆立刻落盘(durable),系统提示里的快照下次会话再换(cache-safe),两个目标都不牺牲(tools/memory_tool.py:11-14)。

  4. 索引截断和创作规范必须互相知道。 索引按 60 字符硬切(agent/skill_utils.py:1173),创作提示就把"数字符"写成红线并解释后果(agent/learn_prompt.py:40-48)。机制与规范成对出现,才不会出现"写了没生效还没人知道"。

  5. 压缩前先做免费的事。 去重、一行摘要、裁参数、剥图片——这些都不花 LLM 调用。真正的摘要是最后一步(agent/context_compressor.py:7158-7395)。

  6. 摘要必须自带边界标记。 没有"这是背景不是待办""摘要到此为止"这两句,弱模型会把历史当新指令(SUMMARY_PREFIX,:114;_SUMMARY_END_MARKER,:339)。

  7. 保护策略要会衰减。 无限期保护开头,等于制造永生化石(_effective_protect_first_n,:5580)。

  8. 缓存必须能被判定失效。 技能快照靠 (mtime_ns, size) 清单;工具目录干脆不缓存——宁可重建也不要静默漂移(tools/tool_search.py:30-34)。


9. 边界与局限

  • 只认一个项目上下文文件(按类型)。 同时放了 AGENTS.mdCLAUDE.md 的仓库,后者永远读不到(agent/prompt_builder.py:2577-2583);顺位内文件再多,也只是各自范围内合并,不会跨类型叠加。
  • 上下文文件的中段会被静默丢弃(只有一条警告和一句"用 read_file 读全文"),不是语义压缩,是纯位置截断(:1783-1793)。
  • 技能描述超 60 字符的部分在索引里被静默截断——机制本身不告警,靠创作侧规范(agent/learn_prompt.py:40-48)和技能管理工具的 create/update 预览(tools/skill_manager_tool.py:897-904)兜着。
  • 压缩是有损的。 中段被摘要替换,细节不可逆丢失;/compress <主题> 只能改变"优先保留什么",不能不丢。
  • 防抖会主动放弃压缩。 连续两次节省不足 10% 就不再压(agent/context_compressor.py:3389-3440),此时只能 /new 或换大窗口模型。
  • token 估算是 4 字符/token 的粗规则(_CHARS_PER_TOKEN,agent/context_compressor.py:1184;CHARS_PER_TOKEN,tools/tool_search.py:72),不是真分词。所有门限判定都建立在这个近似上。

10. 代码地图

主题文件关键符号
三层拼装agent/system_prompt.pybuild_system_prompt_partsbuild_system_promptinvalidate_system_promptformat_tools_for_system_message_resolve_platform_hint
身份与上下文文件agent/prompt_builder.pyload_soul_md_load_hermes_md_load_agents_md_load_claude_md_load_cursorrulesbuild_context_files_prompt
上下文文件截断agent/prompt_builder.py_truncate_content_get_context_file_max_chars_dynamic_context_file_max_charsCONTEXT_FILE_MAX_CHARSdrain_truncation_warnings
环境与平台提示agent/prompt_builder.pybuild_environment_hints_probe_remote_backendcomputer_use_guidancePLATFORM_HINTS
中途插话标记agent/prompt_builder.pyformat_steer_markerSTEER_MARKER_OPENSTEER_CHANNEL_NOTE
技能索引与快照agent/prompt_builder.pybuild_skills_system_prompt_build_skills_manifest_load_skills_snapshot_write_skills_snapshot_skill_should_show_SKILLS_PROMPT_CACHE
描述 60 字符硬截断agent/skill_utils.pyextract_skill_description
创作侧的对应硬规agent/learn_prompt.py_AUTHORING_STANDARDSbuild_learn_prompt
提示缓存断点agent/prompt_caching.pyapply_anthropic_cache_control_apply_cache_marker_build_marker
提示复用与落库agent/conversation_loop.py_restore_or_build_system_prompt_stored_prompt_matches_runtime
记忆冻结快照tools/memory_tool.py模块 docstring、get_memory_dir
引擎抽象agent/context_engine.pyContextEngineshould_compresscompressshould_defer_preflight_to_real_usageprotect_first_n
压缩主体agent/context_compressor.pyContextCompressorcompress_prune_old_tool_results_find_tail_cut_by_tokens_protect_head_size_effective_protect_first_n
压缩辅助agent/context_compressor.py_summarize_tool_result_strip_historical_media_truncate_tool_call_args_json_estimate_msg_budget_tokens_compute_threshold_tokensSUMMARY_PREFIX
压缩编排与会话轮转agent/conversation_compression.pycompress_contextcheck_compression_model_feasibilityconversation_history_after_compression
触发点agent/turn_context.py / agent/conversation_loop.py_should_run_preflight_estimate、预检块、事后 should_compress 判定
手动压缩反馈agent/manual_compression_feedback.pysummarize_manual_compression
工具延迟披露tools/tool_search.pyassemble_tool_defsshould_activatebuild_catalogbridge_tool_schemasis_deferrable_tool_namesearch_catalog
工具装配入口model_tools.py工具搜索装配块、_resolve_active_context_length
工具输出上限tools/tool_output_limits.pyget_tool_output_limitsDEFAULT_MAX_BYTESDEFAULT_MAX_LINES
工具结果落盘tools/tool_result_storage.pymaybe_persist_tool_resultenforce_turn_budgetgenerate_preview
结果预算缩放tools/budget_config.pyBudgetConfigbudget_for_context_windowPINNED_THRESHOLDS

继续读: 一轮对话是怎么跑完的(本章产出的提示由谁发出去)· 自我进化闭环(索引里那些技能与记忆从哪来)· 工具层与执行环境(环境探测背后的六种后端)· 信任边界(注入上下文的内容怎么被扫描)· 总览