本节摘要:上下文工程的第一设计铁律是 prompt caching 神圣不可侵犯:LLM provider 按前缀命中缓存,缓存命中的输入 token 计价约为全价的零头——但前缀必须字节级稳定,任何一字节抖动(时间戳乱入、记忆中途刷新、工具 schema 变形)都会从抖动点起击穿整段缓存,放大成本与延迟。Hermes 为此建立三层防线:①三层组装(
agent/system_prompt.py):系统提示按 stable(身份/工具指引/技能索引/环境提示)→context(项目上下文文件)→volatile(记忆快照/时间戳行)排序,越稳定越靠前;②冻结注入:记忆快照开局定格、会话中写盘不刷新 prompt,技能索引用 mtime 清单快照缓存——一切"会变的东西"要么排到最后要么干脆不进缓存提示(ephemeral 层走 API 调用时附加);③缓存计划(agent/prompt_caching.py):对 Anthropic 风格 provider 精确规划 4 个cache_control断点——静态系统前缀+系统提示末尾+最近 2 条可携带消息,断点只落在"工具事务完成边界"上,agent/prompt_cache_boundary.py的前缀注册表还让技能/webhook 大脚手架把易变调用尾巴切出缓存边界。这与 Reasonix 的 Cache-first 哲学异曲同工:先保缓存,再谈其他。
内容来源:原项目源码
agent/system_prompt.py(三层 build_system_prompt_parts)、agent/prompt_caching.py(缓存计划)、agent/prompt_cache_boundary.py(builder 声明边界)、agent/prompt_builder.py(技能索引快照)、website/docs/developer-guide/prompt-assembly.md(官方组装文档)。
⚠️ 注意:铁律的适用范围是整个会话生命周期内不变的消息前缀。TTL 按 provider 收敛:Anthropic 支持 5m/1h,而 Qwen/Alibaba 系路由只认 5 分钟窗并拒绝 1h 档——
effective_cache_ttl会把 1h 钳回 5m,避免发出一个被 provider 丢弃的标记造成虚假的"1 小时缓存"预期(#84733)。
阅读完本节,你应当能够:
apply_anthropic_cache_control 的 4 断点布局与事务边界选择。Anthropic/OpenAI 等 provider 的 prompt cache 是严格前缀匹配:请求与缓存块从头逐字节比对,首个不同字节之后全部 miss。于是成本模型变成非线性——系统提示加工具 schema 动辄一两万 token,一个长会话每轮都重发,命中时按约一折计价;一旦某个中途字节变了,从该点起全部按全价重算,而且用户毫无感知,只在账单上显形。所以 Hermes 把"prefix 稳定"当作跨模块不变量来守护:agent/context_engine.py 的 select_context 钩子注释里直接写着 "prompt-cache stability (an AGENTS.md invariant)"——它已经上升到仓库宪法(AGENTS.md)层面。三个典型破缓存陷阱及其解法:时间戳行→排进 volatile 尾层;记忆中途写入→冻结快照(第 5-01 节);工具 schema 漂移→第 3 章的 registry 顺序稳定。官方文档 prompt-assembly.md 的开篇即结论:"Hermes deliberately separates cached system prompt state / ephemeral API-call-time additions. This is one of the most important design choices in the project"。
agent/system_prompt.py:341 的 build_system_prompt_parts 把系统提示切成三层拼装:
345 * ``stable`` — the cross-session-stable prefix, through the coding 348 * ``context`` — session-stable guidance, context files, and caller-supplied 350 * ``volatile`` — skills index, memory snapshot, user profile, ... 376 stable_parts: List[str] = [] 388 stable_parts.append(_soul_content) # SOUL.md 身份(或默认身份) 400 stable_parts.append(...) # 工具/行为指引 412 stable_parts.append(TASK_COMPLETION_GUIDANCE) 423 stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
各层内容(官方 prompt-assembly.md 的十层实例图浓缩):stable 层=SOUL.md 身份、持久记忆使用指引、工具执法指引(仅 GPT/Codex)、平台提示(platform_hints 覆盖在构建期解析,"byte-stable for a fixed config")——跨会话都一样;context 层=调用方 system_message 加项目上下文文件(.hermes.md→AGENTS.md→CLAUDE.md→.cursorrules 优先级取一,全部过安全扫描+截断,长文件按 70/20 头尾切+截断标记);**volatile** 层=MEMORY/USER 冻结快照、外部 provider 静态块、**时间戳/会话/模型行**——会话内不变但跨会话必变,所以放最后:它变了,前面的 stable 前缀照旧命中。技能索引虽然体积大且随技能库演进,但对同一会话字节稳定,同样位于缓存提示内。哪些必须稳/哪些可以变,总表:
| 内容 | 所属层 | 会话内 | 跨会话 | 进缓存提示 |
|---|---|---|---|---|
| SOUL 身份/工具指引/平台提示 | stable | 冻结 | 稳定 | 是(最前) |
| 项目上下文文件 | context | 冻结 | 可变(换仓库) | 是 |
| 记忆/画像快照、技能索引 | volatile | 冻结(写盘不刷新) | 可变 | 是(最后) |
| 时间戳/会话/模型行 | volatile | 冻结 | 必变 | 是(压尾) |
| ephemeral/prefill/召回/pre_llm_call | API 时层 | 每轮变 | — | 否 |
与之相对,五类内容永不写入缓存系统提示,只在 API 调用时附加到当前轮用户消息:ephemeral_system_prompt、prefill 消息、网关会话覆盖层、后续轮的 provider 召回上下文、pre_llm_call 插件上下文——"This separation keeps the stable prefix stable for caching"。子 agent 委派场景(skip_context_files)则整个换用硬编码默认身份,不读 SOUL.md——委派上下文从源头就不与主会话共享前缀,也就无所谓破坏。
agent/prompt_caching.py(501 行,纯函数无类状态)负责给 Anthropic 风格请求布防。模块 docstring 给出默认布局:"4 cache_control breakpoints: the static system prefix, the end of the system prompt, and the last 2 non-system messages. When a static system prefix is unavailable, it falls back to one system breakpoint plus the last 3 messages"。主入口 apply_anthropic_cache_control(434 行):
479 breakpoints_used = 0 481 if messages[0].get("role") == "system": 482 messages[0] = copy.deepcopy(messages[0]) 483 breakpoints_used = _apply_system_cache_markers( 484 messages[0], marker, static_system_prefix, 485 native_anthropic=native_anthropic, 486 ) 490 remaining = 4 - breakpoints_used 491 non_sys = [ 492 i for i in range(len(messages)) 493 if messages[i].get("role") != "system" 494 and _can_carry_marker(messages[i], native_anthropic=native_anthropic) 495 ] 497 for idx in non_sys[-remaining:]: 498 messages[idx] = copy.deepcopy(messages[idx]) 499 _apply_cache_marker(messages[idx], marker, native_anthropic=...)
处处是碎步防守:系统提示在存储里仍是一个字符串,只在出站请求里切两块(前缀块+易变尾块),会话持久化与非 Anthropic 通道不受影响;前缀恰等于全文时整块单标(两块切分会出现空文本块,Anthropic HTTP 400);_can_carry_marker 跳过载不动标记的消息(envelope 布局下纯 tool_calls 的空 assistant 轮、OpenRouter 对 role:tool 顶层标记会静默挂起),不让断点浪费;函数幂等——先剥旧标记再布新,两次调用不会累积超 4 个。轮中 provider 故障切换时还有一套"脱衣重穿":strip_anthropic_cache_control 先剥掉旧 provider 的标记并按可证明字节等价的形状还原字符串(仅限本函数产生的装饰形状,有机多段文本保持原结构),再按新 provider 的缓存策略重布(#72626)——build_prompt_cache_plan(385 行)为此把消息与工具深拷贝成请求局部计划(PromptCachePlan),一个请求一份,互不污染;direct_native_tool_cache 布局下工具数组末端也吃一个断点(静态前缀省下的预算转投 tools)。断点位置也有讲究:_completed_transaction_endpoint_indexes(333 行)只把断点放在完整工具事务的收尾(assistant 带 tool_calls 且其 tool 结果齐)或普通轮次边界——断点落在事务中间,缓存命中的前缀会以悬空 tool_call 结尾,下一请求还得修复。TTL 钳制(effective_cache_ttl,145 行)处理 Qwen/Alibaba 家族:"Their context cache documents a five-minute window (renewed on hit) and rejects the Anthropic 1h tier ... a configured 1h regresses to 5m instead of shipping a marker the provider drops"。
第 4 章说过技能激活是"大脚手架(激活注记+技能全文)+小尾巴(工单号/时间戳/运行上下文)"拼成的一条用户消息。整条消息当一块缓存,尾巴一变整块 miss。agent/prompt_cache_boundary.py(94 行)的解法是注册表:
56 def register_stable_prefix(prefix: str) -> None: 57 """Record ``prefix`` as the stable scaffold of a just-built message.""" ... 69 def find_stable_prefix(content: str) -> Optional[str]: 72 """Longest registered prefix that is a *proper* prefix of ``content``. 74 Proper (``len(content) > len(prefix)``) so the split never produces an 75 empty volatile text block, which Anthropic rejects on the wire.
技能/webhook/cron 的构建器在拼消息时知道自己脚手架的确切字节位置,顺手注册;缓存计划器在标记该消息时查表,把断点精确落在边界上——易变尾巴不进缓存块,"a changed ticket ID or timestamp no longer invalidates the whole skill body"(#81867)。为什么不请求时再解析标记字符串?docstring 回答:分隔符可能合法出现在技能正文或事件载荷里(工单里引用 agent 转写),任何启发式要么缩水缓存要么把易变字节吞进缓存。注册表进程内 LRU(32 条/4MB 字符上限,命中刷新位置——每分钟被 cron 打一次的脚手架不能被突发一次性调用挤掉),窗口正好覆盖"同一进程构建并发送"的 webhook/cron 场景;长期交互会话里消息转出标记窗口后一次性回到整串形态,代价是一次前缀重摄取,远小于每次调用全量 miss。

哪些必须稳定/哪些可以变,一句话总纲:stable 与 context 层会话内不可动;volatile 层会话内冻结、跨会话可换;一切轮级变化走 ephemeral 通道,绝不回写缓存提示。
💡 循环要点:prompt caching 铁律是内循环的"宪法条款",前三章的一切器官都被它约束:技能索引要快照缓存、记忆要冻结快照、工具 schema 要顺序稳定、后台评审要用辅助客户端免碰主缓存。组装流水线的本质是一场按变化频率排序的游行——越稳的越靠前,越易变的越靠后,轮级的干脆不进队伍。理解了这条铁律,才能看懂下一节的难处:压缩要改写历史,而改写历史正是铁律的头号违犯者。
下一节处理铁律最大的张力来源:上下文压缩——context_engine 的可替换 ABC、compressor 的头部/尾部保护策略,以及 micro-compaction 如何用"每轮吸收一个交换"摊销压缩成本,又为何默认关闭。