从「一锅炖」到「模块化」:重塑 Agent 的逻辑骨架
🎯 学习目标
- 硬编码 system prompt 的三个痛点是什么?
- 五个 section 的固定顺序?哪些始终有、哪些按需?
- Context 为什么必须是严格 JSON?为什么不能用 default=str?
- 缓存为什么必须属于会话(实例字段)而非模块全局?
- 第 10 章为什么要把 MemorySession 的消息注入关掉?
- 动态 Prompt 的缓存命中 = 供应商 API prompt cache 命中吗?
🧠 核心概念
1 · 三个痛点:硬编码 prompt 的问题 ▸
- 换项目要重写整个 prompt,不知道哪些该改:身份/行为规范可能复用,但工作目录/工具变了,边界完全不可见。
- 改一处悄悄影响全局:加段工具描述可能和行为规范语义冲突,LLM 怎么理解不可预测,不报错只变奇怪。
- 每次请求带全部内容但不是每次都需要:记忆只在选中相关记录时有意义,没选中保留占位说明只引入噪音。
2 · 解法:固定顺序,运行态取值 ▸
| Section | 策略 | 来源 |
|---|---|---|
| identity | 始终 | 基础行为指令 + 规范化 JSON context |
| tools | 始终 | ToolRegistry.names 当前快照 |
| workspace | 始终 | 已解析的 workspace 路径 |
| skills | 按需 | SkillRegistry 有界目录(不含正文) |
| memory | 按需 | MemorySession 本轮选中记忆正文 |
顺序是契约:identity → tools → workspace → skills → memory。没工具时 tools 显示 (none);没 Skill/选中记忆时对应 section 整段省略。生效边界:tools 与 memory 在下一次模型请求就生效(每轮重取);workspace 是构建期解析的固定值;磁盘上装了新 Skill 也不会热加载——要重建 Agent 才可见。
3 · Context 必须是严格 JSON ▸
Context 放章节号、identity、运行模式等小型结构化状态。缓存键和展示文本都来自同一份规范 JSON,不能用 default=str 把未知对象悄悄变字符串。
只接受 JSON object,值递归限制为 scalar/array/object。拒绝 array 外的非普通对象、symbol key、NaN、±Infinity、循环引用。合法值按 key 排序+紧凑 JSON+UTF-8 字符规范序列化——键插入顺序不同但语义相同得到同一 key。
4 · 缓存必须属于会话 ▸
旧写法把 lastContextKey/lastPrompt 放模块全局,两 Agent 实例同进程会共享,把一个 workspace 的 Prompt 复用给另一个会话。
DynamicPromptRenderer 把缓存放实例字段。每次 buildAgent(P10) 新建 renderer,缓存天然会话隔离。缓存键覆盖全部可见输入:identity+context+tools+workspace+skills+selected memory,全部相等才复用并 cacheHits++。
5 · 零参数 Provider 接入唯一 Loop ▸
AgentRunner 不该知道 Prompt 每个数据源,只依赖零参数 provider:每次模型请求前调 render() 获当下 Prompt。DynamicPromptProvider 在构建阶段绑定 renderer 和运行态对象。
AgentRunner.#renderSystemPrompt() 对返回值做类型与空值校验:返回空串或非字符串立即抛错,不回退到构建期固定 systemPrompt,避免静默用过期指令。
6 · 记忆只注入一次 ▸
第 9 章 MemorySession.beforeModel() 额外返回一条 system context 消息。到第 10 章 selected memory 已进动态 Prompt 的 memory section;若原路径还开,同一正文出现两次。
Bootstrap 显式切换所有权:createMemorySession(dependencies, !dynamicPrompt)。P09 没动态 prompt 能力时仍启用消息注入,P10 才关闭。生命周期仍负责选择/提取/整理,只关独立消息注入。测试直接统计 <relevant_memories> 出现次数。