第5章:上下文工程 —— 在有限窗口里保住任务连续性
一、上下文不是消息数组
对一个长期 Agent 来说,“上下文”至少由五个来源组成:
静态身份 + 工具 schema + 项目上下文文件 + 当前会话历史 + 动态记忆/环境信息
这些来源拥有不同生命周期:工具 schema 可能随环境变化,skills 需要按需加载,记忆只在相关时注入,历史要持久化但可能压缩,环境提示要防止把宿主信息泄漏到远端执行环境。
因此 Hermes 将上下文做成策略层,而不是在 run_conversation 里拼一条长字符串。
二、系统提示词的组装顺序
agent/system_prompt.py:152 的 build_system_prompt_parts 和 :549 的 build_system_prompt 是入口;它们依赖 prompt_builder.py 的多个构建器:
- Agent identity / personality;
- 当前平台和交互方式提示;
- 工具使用规则与工具说明;
- 环境 hints、cwd、终端 backend;
SOUL.md、HERMES.md、AGENTS.md、.cursorrules等上下文文件;- skills manifest 和动态能力;
- Nous subscription / tool gateway 等条件能力说明。
prompt_builder.py 会沿当前目录向上寻找上下文文件,并做 frontmatter 去除、字符截断和来源标记。这里的核心策略是:指令可以继承,但每一层都要知道它来自哪里、能占多少预算。
三、动态记忆不直接改历史
MemoryManager.prefetch_all 在 turn 开始前根据用户输入查询 MemoryProvider;conversation_loop 在 API call 处把结果放进当前 user message 的 api_content sidecar。干净的 messages 保持不变。
这样做解决了三个问题:
- 记忆可能随 provider 延迟返回,不应改写持久化 transcript;
- 重试同一 turn 时,应该发送同一份 composed content,而不是重复嵌套记忆;
- 重新打开 session 时,历史仍是事实记录,不会混入当时动态召回的偶然结果。
四、ContextEngine:把策略抽象出来
agent/context_engine.py:89 的 ContextEngine 抽象出:
should_compress/should_compress_info;compress;prune_tool_results_only;select_context;on_turn_complete、session start/end;- 当前模型变化和工具 schema。
这说明压缩并不是 Loop 的一个 if,而是可替换的 context policy。ContextCompressor(context_compressor.py:1312)是当前主实现,但接口允许将来加入不同的窗口策略。
五、什么时候压缩:真实 usage 与 preflight 的双保险
压缩触发不能只依赖消息字符数。Hermes 同时使用:
- 模型元数据中的 context length;
- API 返回 usage 的 prompt tokens;
- preflight 的粗略 token 估计;
- 当前 tail budget、summary budget 和 anti-thrash guard;
- 压缩失败冷却、连续 fallback 次数和 session 持久化状态。
ContextCompressor.update_from_response 在真实响应后更新 token 状态;should_compress_preflight 负责在发出很可能失败的请求前拦截。对于 token 估算不可靠的模型,should_defer_preflight_to_real_usage 允许等待真实 usage,避免过度压缩。
六、切哪里:不能把消息数组从中间随便砍
工具调用要求 assistant tool call 和 tool result 成对;任务又要求保留最近的用户目标和最新的 assistant tail。因此压缩器有一组边界函数:
_align_boundary_forward/backward:把切点对齐到合法消息边界;_find_last_user_message_idx、_find_last_assistant_message_idx:保护最近语义锚点;_ensure_last_assistant_message_in_tail、_ensure_last_user_message_in_tail:避免压缩后没有可继续的当前任务;_sanitize_tool_pairs:修复或丢弃不完整的工具对。
七、摘要不是自由发挥,而是交接协议
ContextCompressor._generate_summary 会把旧 turn 序列化后交给辅助模型;失败时还可以 _build_static_fallback_summary。摘要的有效性不只看“有没有文字”,还会检查:
- 是否有真实 user turn,防止合成摘要伪造用户意图;
- 是否保留目标、约束、进度、决定和下一步;
- 是否记录相关文件路径;
- 是否保留 skill 的 pruned/ghost marker;
- 是否能在下一轮被识别为 context summary。
这是一个很重要的设计:摘要是下一阶段 Agent 的 handoff 文档,而不是聊天记录的文学改写。
八、Micro-compaction 与大压缩
Hermes 不只有“大压缩”。context_compressor.py 后半部分实现 micro-compaction:针对单个 exchange 或过长 tool result 做滚动摘要/拼接,尽量不触发一次昂贵的全局摘要。
可把两者理解成:
| 策略 | 作用域 | 成本 | 风险 |
|---|---|---|---|
| tool result prune | 单个结果 | 低 | 丢失局部输出 |
| micro-compaction | 一小段历史 | 中 | 需要维护 cursor/marker |
| full compaction | 多个旧 turn | 高 | 摘要错误会影响任务连续性 |
测试文件名中的 anti_thrash、tool_pair_floor、summary_continuity、zero_user_provenance,正好对应这些容易出错的边界。
九、为什么压缩失败也要有状态机
如果每次压缩失败都立即再试,长会话会进入“压缩失败 → 继续膨胀 → 再失败”的死循环。ContextCompressor 持久化 fallback streak、ineffective count、failure cooldown,并允许 fallback 到主模型或静态摘要。
所以压缩是:
观察 usage → 判断阈值 → 选择切点 → 摘要/裁剪
→ 校验摘要来源 → 写入 session → 更新 guard
→ 下一轮重新计算,而不是相信一次结果
十、可迁移的三条原则
- 上下文工程同时包含加法和减法:加载相关信息,也要删除过期或过大的信息。
- 合法切点比最大压缩率重要:不破坏 tool pair、用户目标和当前 tail,才不会把 Agent 压缩成失忆状态。
- 压缩结果必须可验证、可恢复、可观测:摘要 metadata、session entry、telemetry 和 fallback guard 缺一不可。