AGAgent 学习路线
第 10 / 20
CHAPTER 10 · GPT 生成学习页

模块化逻辑骨架

让 Prompt、工具、记忆和压缩各自拥有边界,通过固定顺序组合。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

模块化逻辑骨架

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 固定顺序比一个巨型字符串更容易审查。
  • 启动信息与运行态信息分开注入。
  • 模块边界让能力可以替换、测试和迁移。
1系统 Prompt
2工具注册表
3动态 Prompt
4运行态组合根
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

10 个小节9 组代码14 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

01导读:问题背景与本章目标第 9 章做完之后,Agent 已经有了相当完整的能力栈:记忆系统、上下文压缩、技能懒加载、权限管道、子 Agent 调度。每加一个能力,system prompt 就多一段。到第 9 章结束时,那个字符串大概长这样:

第 9 章做完之后,Agent 已经有了相当完整的能力栈:记忆系统、上下文压缩、技能懒加载、权限管道、子 Agent 调度。每加一个能力,system prompt 就多一段。到第 9 章结束时,那个字符串大概长这样:

const systemPrompt = [
  "You are a coding agent.",
  "Use tools when needed and inspect their results.",
  "For complex tasks, keep the TODO snapshot current.",
  // 工具、Skill、workspace 和记忆不在这里硬编码。
].join(" ");

没人敢改它。不知道哪段是必须的,哪段是某次调试留下的,改一处会不会影响其他地方。项目切换时要整个重写,但又不确定哪些该留、哪些该扔。

这不是 prompt 写得好不好的问题,是结构问题。一大段硬编码字符串本质上是把配置藏在代码里,和把数据库连接字符串写死在业务逻辑里是同一类错误。

图片
图片

02三个真实的痛点在拆解解法之前,先把问题说清楚——不是泛泛的"不够优雅",是三个具体会出问题的地方。

在拆解解法之前,先把问题说清楚——不是泛泛的"不够优雅",是三个具体会出问题的地方。

第一个:换项目要重写整个 prompt,但不知道哪些该改。

当前这个 Agent 是 coding agent,prompt 里有工作目录、工具列表、行为规范。换一个项目,工作目录变了,工具可能变了,但身份定义和行为规范也许还能复用。硬编码成一段字符串之后,这些边界完全不可见,只能全部重写或者小心翼翼地做字符串替换。

第二个:改一处可能悄悄影响全局。

加一段工具描述可能和前面的行为规范在语义上冲突,LLM 会怎么理解这个冲突完全不可预测。这类 bug 不报错,只是 Agent 行为变得奇怪,排查起来极其困难。

第三个:每次请求带全部内容,但并不是每次都需要。

记忆内容只在本轮选中了相关记录时才有意义;如果没有选中记忆,保留一段占位说明只会引入噪音。System prompt 每轮都会发送,无关指令不只是浪费,还会稀释 LLM 的注意力。


03解法:固定顺序,运行态取值当前实现没有再维护一份 PROMPTSECTIONS 字符串字典,也没有手写 enabledTools 列表。DynamicPromptRenderer 直接读取已经参与运行的对象,并按固定顺序输出五个 section:

当前实现没有再维护一份 PROMPT_SECTIONS 字符串字典,也没有手写 enabledTools 列表。DynamicPromptRenderer 直接读取已经参与运行的对象,并按固定顺序输出五个 section:

Section策略当前来源
identity始终基础行为指令 + 规范化 JSON context
tools始终ToolRegistry.names 当前快照
workspace始终已解析的 workspace 路径
skills按需SkillRegistry 的有界目录,不含 Skill 正文
memory按需MemorySession 本轮选中的记忆正文

顺序是契约,不依赖对象键插入顺序:identity -> tools -> workspace -> skills -> memory。即使没有工具,tools section 也会明确显示 (none);没有 Skill 或选中记忆时,对应 section 整段省略。

实现位于 code/chapters/ch10/src/features/prompting.ts。核心组装逻辑可以概括为:

const sections = [
  `## identity\n${identity}\ncontext: ${contextJson}`,
  `## tools\n${toolCatalog}`,
  `## workspace\n${workspace}`,
];
if (skillCatalog.length > 0) sections.push(`## skills\n${skillCatalog}`);
if (memoryBody.length > 0) sections.push(`## memory\n${memoryBody}`);
const prompt = sections.join("\n\n");

这样工具注册表、workspace、Skill 目录和已选记忆各有唯一来源。Prompt 只负责渲染,不重新实现工具发现、记忆选择或路径校验。


04Context 必须是严格 JSONContext 用于放章节号、identity 或运行模式等小型结构化状态。缓存键和展示文本都来自同一份规范 JSON,因此不能用 default=str 把未知对象悄悄变成字符串。

Context 用于放章节号、identity 或运行模式等小型结构化状态。缓存键和展示文本都来自同一份规范 JSON,因此不能用 default=str 把未知对象悄悄变成字符串。

当前边界只接受 JSON object,值递归限制为:

type JsonScalar = boolean | number | string | null;
interface JsonObject {
  readonly [key: string]: JsonValue;
}
type JsonValue = JsonScalar | readonly JsonValue[] | JsonObject;

运行时还会拒绝 array 之外的非普通对象、symbol key、NaN、正负无穷和循环 array/object。合法值按 key 排序、紧凑 JSON 和 UTF-8 字符规范序列化。因此,键插入顺序不同但语义相同的 context 会得到同一个 key。非法 context 在更新缓存前失败,不会污染上一份有效结果。

实现上,normalizeContext 会递归克隆并冻结合法 JSON,递归路径上的 active Set 用来发现循环引用;stableJson 再按 key 排序序列化同一份规范值。非法输入会在更新缓存前抛错,因此上一次有效 Prompt 不会被同一个 renderer 的失败调用覆盖。


05缓存必须属于会话旧写法把 lastContextKey 和 lastPrompt 放在模块全局。两个 Agent 实例在同一进程里运行时会共享它们,容易把一个 workspace 或 identity 的 Prompt 复用给另一个会话。

旧写法把 lastContextKeylastPrompt 放在模块全局。两个 Agent 实例在同一进程里运行时会共享它们,容易把一个 workspace 或 identity 的 Prompt 复用给另一个会话。

DynamicPromptRenderer 把缓存放在实例字段中。每次 buildAgent(P10, ...) 都创建新的 renderer,所以缓存天然会话隔离。缓存键覆盖全部可见输入:

identity + context + tools + workspace + skills + selected memory

只有这些输入的规范快照全部相等时,才复用缓存结果并增加 cacheHits。工具集合、workspace、Skill 目录内容或选中记忆任一变化,下一次渲染都会失效并生成新 Prompt。

这只是进程内避免重复组装字符串的缓存,不等价于供应商 API 的 prompt cache。当前 OpenAI 适配器没有把静态和动态 section 分成不同 cache-control block,因此不能宣称动态内容变化时仍会命中 API 级静态前缀缓存。


06零参数 Provider 接入唯一 LoopAgentRunner 的循环不应该知道 Prompt 的每个数据源。它只依赖一个零参数 provider:每次模型请求前调用 render(),获得当下 Prompt。

AgentRunner 的循环不应该知道 Prompt 的每个数据源。它只依赖一个零参数 provider:每次模型请求前调用 render(),获得当下 Prompt。

DynamicPromptProvider 在构建阶段绑定 renderer 和运行态对象:

const systemPromptProvider = new DynamicPromptProvider({
  renderer: new DynamicPromptRenderer(),
  identity: baseInstructions,
  tools,
  workspace,
  context: { chapter: 10, identity },
  skills: skillRegistry,
  memory: memorySession,
});

实际构建只在对应能力存在时传入 skillsmemory;没有这些能力时,renderer 不会生成空的可选 section。

它的零参数方法只做适配:

render(): string {
  return this.#renderer.render({
    identity: this.#identity,
    tools: this.#tools,
    workspace: this.#workspace,
    context: this.#context,
    ...(this.#skills === undefined ? {} : { skills: this.#skills }),
    ...(this.#memory === undefined ? {} : { memory: this.#memory }),
  });
}

公共 AgentRunner 在每次模型请求前读取 provider,不复制工具执行循环。工具调用、权限、Hook、整批结果处理、压缩和 Stop 行为仍由 code/chapters/ch10/src/core/loop.ts 的同一实现负责。

AgentRunner.#renderSystemPrompt() 还会对 provider 的返回值做类型与空值校验:返回空字符串或非字符串时立即抛错,不回退到构建期的固定 systemPrompt,避免静默使用过期指令。

“运行态变化下一轮生效”也要按真实边界理解:ToolRegistry 当前名称和 MemorySession.selected 会被重新读取;workspace 使用绑定路径;Skill 目录来自构建时扫描得到的 SkillRegistry。如果磁盘上新增 Skill,需要重建 Agent,由新的构建流程重新扫描。当前实现不会悄悄监视目录。


07记忆只注入一次第 9 章的 MemorySession.beforeModel() 会额外返回一条 system context 消息。到第 10 章后,selected memory 已进入动态 Prompt;如果原路径保持开启,同一正文会出现两次。

第 9 章的 MemorySession.beforeModel() 会额外返回一条 system context 消息。到第 10 章后,selected memory 已进入动态 Prompt;如果原路径保持开启,同一正文会出现两次。

Bootstrap 因此显式切换所有权:

const memorySession = new MemorySession({
  store: new MemoryStore({ workspace }),
  selector: queries,
  extractor: queries,
  consolidator: queries,
  emitContextMessages: false,
});

实际 bootstrap 通过 createMemorySession(dependencies, !dynamicPrompt) 决定开关:P09 没有 dynamic_prompt 能力时仍启用原消息注入,P10 才关闭它,避免破坏旧章节行为。

生命周期仍负责每轮选择、最终提取与整理,只关闭它的独立消息注入。renderer 读取 memorySession.selected,在 memory section 中输出一次完整正文。测试会直接统计 <relevant_memories> 的出现次数,而不是只断言“包含某段文本”。


08与 Claude Code 的差异Claude Code 的系统提示同样按 section 组装,但规模比本教学版大得多,并且会显式区分静态与动态两类内容。标准交互模式下,静态 section(identity、doingtasks、usingtools、tonestyle 等)和动态 section(sessionguidance、memory、envinfosimple、outputstyle 等)由 SYSTEMPROMPTDYNAMICBOUNDARY 分隔;mcpinstructions 是唯一易失 section,用 DANGEROUSuncachedSystemPromptSection() 标记为不参与常规 section 缓存,因为 MCP server 可以在轮次间连接或断开。P10 的 identity -> tools -> workspace -> skills -> memory 固定顺序只把可选 section 追加到尾部。它没有静态/动态边界,也没有必须绕过缓存的 section。

Claude Code 的系统提示同样按 section 组装,但规模比本教学版大得多,并且会显式区分静态与动态两类内容。标准交互模式下,静态 section(identity、doing_tasks、using_tools、tone_style 等)和动态 section(session_guidance、memory、env_info_simple、output_style 等)由 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分隔;mcp_instructions 是唯一易失 section,用 DANGEROUS_uncachedSystemPromptSection() 标记为不参与常规 section 缓存,因为 MCP server 可以在轮次间连接或断开。P10 的 identity -> tools -> workspace -> skills -> memory 固定顺序只把可选 section 追加到尾部。它没有静态/动态边界,也没有必须绕过缓存的 section。

缓存层级。 官方 API 的 prompt caching 会缓存 toolssystemmessages 前缀,只有断点前的完整前缀一致时才命中(Prompt caching)。CC 在 API 级缓存之外还有会话级 getSystemContext/getUserContext memoize 和动态 section 注册缓存。P10 只有 renderer 实例缓存,而且当前 OpenAI 适配器没有把静态和动态 section 分成不同缓存块;因此即使同一会话内组装结果被复用,也无法保证供应商侧会命中静态前缀缓存。

上下文归属。 CC 把 gitStatuscacheBreaker 等系统上下文追加到 system prompt,把 CLAUDE.md、当前日期等用户上下文前置为 <system-reminder> 用户消息。P10 把所有可见上下文都渲染进 system prompt,不区分归属。这个差异会影响静态前缀稳定性:用户消息变化不应破坏 system prompt 的缓存前缀,而 P10 当前没有利用这一层。

模式替换。 CC 支持 CLAUDE_CODE_SIMPLE、Proactive/KAIROS、Coordinator、Agent 等运行模式,部分模式会用紧凑 prompt 或专用 prompt 整体替换标准 section;CLAUDE_CODE_SIMPLE 下核心 prompt 只有约 150 字符。P10 的 Profile 只控制能力集合,渲染器始终输出同一套 section。对教学版来说这是合理简化,但真实产品通常要按运行模式切换整套指令。


09从 ai-agent-book 学到什么:先定缓存边界,再做动态提示ai-agent-book/book/chapter2.md 是本章最接近的对照:它用 KV Cache 与 Agent 状态栏说明了“什么能放进 system prompt、什么应该追加到末尾”。P10 把身份、工具、workspace、Skill 目录和选中记忆全部渲染进 system prompt。这是教学上的简化,不能宣称 API prompt cache 命中。

ai-agent-book/book/chapter2.md 是本章最接近的对照:它用 KV Cache 与 Agent 状态栏说明了“什么能放进 system prompt、什么应该追加到末尾”。P10 把身份、工具、workspace、Skill 目录和选中记忆全部渲染进 system prompt。这是教学上的简化,不能宣称 API prompt cache 命中。

ai-agent-book 的关键设计P10 对应差异与边界
静态前缀 + 动态轨迹分开P10 固定五个 section,运行态只读数据源P10 没有 static/dynamic 缓存边界
动态信息追加末尾P10 可选 skills/memory section 追加到 prompt 尾部P10 仍全在 system prompt,ai-agent-book 建议动态状态作为末尾 user 消息
Agent 状态栏由代码确定性维护P10 的 provider/renderer 每轮重新读取运行时对象P10 没有独立状态栏;workspace/skills/memory 都在 system prompt
Skills 目录常驻、正文按需加载SkillRegistry.renderCatalog() + loadSkill()P10 已与 ai-agent-book 主张一致
实验先定义指标再实现离线测试 cacheHits、顺序断言、context 规范化、记忆单次注入P10 没有真实 API cache ratio / TTFT 实验

10运行与验证第 10 章入口 code/chapters/ch10/src/chapters/ch10.ts 只绑定固定 P10 Profile。两种真实入口共用 Bootstrap 和唯一 Loop:

第 10 章入口 code/chapters/ch10/src/chapters/ch10.ts 只绑定固定 P10 Profile。两种真实入口共用 Bootstrap 和唯一 Loop:

Set-Location 'F:\笔记\Agent实操\code'
npm run ch10 -- --prompt "列出当前 Agent 的可用工具"
npm run agent-tutorial -- run --chapter 10 --prompt "列出当前 Agent 的可用工具"

不需要凭据的离线验证覆盖固定顺序、实时来源、严格 context、缓存隔离和记忆单次注入:

Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch10

当前版本完成的是 Prompt 组装边界,不包含第 11 章的恢复策略。下一章再处理输出截断、限流、瞬时服务错误、输入上下文过长和模型切换。

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始