目标
s01 到 s09 让智能体具备了完整的能力闭环:工具调用、权限、Hook、Todo、子智能体、技能、压缩、记忆。但系统提示的组装一直是分散的——基础提示词是 AgentLoop 里的一个固定字符串,skills 索引经 SkillInjectionHook 在请求末尾追加,memory 索引经 MemoryInjectionHook 在请求末尾追加。三块内容最终都被 AnthropicProvider 抽出来并进 API 顶层的 system 字段,但组装逻辑散落三处:顺序不可控、可观测性差、无法独立测试。
Index 10 引入统一的分段系统提示组装机制:
PromptSegment—— 一段可命名的系统提示片段(命名、有序、动态内容)SystemPromptBuilder—— 运行时按序拼接所有片段,空白片段自动跳过- 注入迁移 —— skills/memory 从 PRE_LLM_REQUEST Hook 迁移为 builder 片段注册
- AgentLoop 无感知 —— 构造函数从
systemPrompt: String换成systemPromptBuilder,每次请求由 builder 组装
完成后的效果——新增一类提示片段(工具说明、团队上下文、自治循环指令)只需要一行 register(),不用再写一个 Hook、不用动 AgentLoop:
// s10 之后,新增系统提示来源只需注册一段:
val builder = SystemPromptBuilder().apply {
register(PromptSegment("base", 0) { systemPrompt })
register(PromptSegment("skills", 10) { buildSkillsMetadataText(skillStore.getAll()) })
register(PromptSegment("memory", 20) { buildMemoryIndexText(memoryStore.loadAll(), memoryConfig.maxInjectedSummaries) })
}为什么需要
现实问题
s09 完成时,系统提示的组装是这样的:
AgentLoop.buildRequestMessages()
= [Message(SYSTEM, systemPrompt)] ← base,固定字符串(Main.kt 传入)
+ messagesHistory.toList() ← 对话历史
+ onPreLlmRequest 追加 ← PRE_LLM_REQUEST Hook 的产物
= [SYSTEM skills 索引] ← SkillInjectionHook
= [SYSTEM memory 索引] ← MemoryInjectionHookAnthropicProvider 把所有 SYSTEM 消息过滤出来 \n\n 拼接,放进 API 的 system 字段。所以三块最终汇入同一个 system 字段,但有三处独立代码在维护它:
- 顺序靠注册时序而非声明 —— skills 先注册先追加,想调整顺序得改 ReplLoop 的注册顺序,没有显式声明
- 新增片段 = 新写一个 Hook —— s20 要加团队上下文,就得再造第三个 InjectionHook
- 无法独立测试 —— 文本构建逻辑藏在 Hook 类内部,测试要经过 HookManager 的事件分发才能触达
- base 与其他片段不对称 —— base 是构造函数参数,skills/memory 是 Hook 追加,两种机制并存
一个具体的困境
假设 s20 要注入"团队上下文"(当前有几个 agent 在协作)。s10 之前,你只能:
- 复制
SkillInjectionHook写一个TeamInjectionHook,改文本构建逻辑 - 在 ReplLoop 里手动排好注册顺序(还要祈祷顺序对了)
- 决定"团队上下文"应该排在哪——是 skills 之前还是之后?没有声明式的答案,只能靠注册时序碰运气
这还不是最糟的。真正的问题是系统提示的顺序不可见:你在 ReplLoop 里看到两行 Hook 注册,但完全无法确定最终 system 字段里三块内容谁先谁后、中间有没有空行、某个 store 为空时会不会输出一个孤零零的标题。
s10 把这些全部变成声明式:order 字段显式声明顺序,build() 确定性输出,空白自动跳过。
设计原则
- 统一组装入口 ——
SystemPromptBuilder是系统提示的唯一组装点,片段 = 注册,不再散落 - 片段运行时求值 —— provider 每次
build()重新执行,memory 索引在会话中增长后(memory_write)下一次请求立即反映,无需手动失效 - 空白即禁用 —— 片段不设 enabled 开关,provider 返回空白就跳过(YAGNI)
- 保持行为等价 —— 注入位置从"请求末尾"移到"系统提示顶部",因 AnthropicProvider 会合并所有 SYSTEM 消息,API 收到的内容完全一致
skill → system依赖间接满足 —— 设计文档标注的依赖通过 ReplLoop 组装实现,skill/memory 包不 import system 包
核心设计与实现
架构全景
s10 之前(分散) s10 之后(统一)
AgentLoop SystemPromptBuilder
│ systemPrompt: String │ register(PromptSegment(...))
│ │ base (order 0) ← systemPrompt
│ buildRequestMessages │ skills (order 10) ← buildSkillsMetadataText(store)
│ [SYSTEM base] + history │ memory (order 20) ← buildMemoryIndexText(store)
│ + onPreLlmRequest 追加 │ build() → 按 order 升序拼接
│ [SYSTEM skills] ← Hook │
│ [SYSTEM memory] ← Hook │ AgentLoop
│ │ │ systemPromptBuilder
│ AnthropicProvider 合并所有 SYSTEM │ │ buildRequestMessages()
│ → system 字段 │ │ [SYSTEM builder.build()] + history
│ │ │
│ │ AnthropicProvider → system 字段重构前:两个 Hook 的全貌
理解这次重构的价值,先看被删掉的代码长什么样。这是 s09 之前的 SkillInjectionHook:
class SkillInjectionHook(private val store: SkillStore) {
fun registerTo(manager: HookManager) {
val handler: HookHandler = handler@{ event ->
if (event.type != HookEvent.Type.PRE_LLM_REQUEST) {
return@handler HookResult.Continue
}
val skills = store.getAll()
if (skills.isEmpty()) {
return@handler HookResult.Continue
}
val text = buildSkillsMetadataText(skills)
HookResult.AppendMessages(listOf(Message(Role.SYSTEM, text)))
}
manager.on(HookEvent.Type.PRE_LLM_REQUEST, handler)
}
internal fun buildSkillsMetadataText(skills: List<SkillManifest>): String = buildString {
append("# Available Skills\n\n")
append("The following skills are available. Call the `load_skill` tool with the skill's name to retrieve its full instructions, then follow them.\n\n")
for (skill in skills) {
append("## ${skill.name}\n")
append("${skill.description}\n\n")
}
}.trimEnd()
}MemoryInjectionHook 是几乎相同的形状。这类 Hook 的问题:
- 三个职责揉在一个类里:事件分发(
registerTo)、空库判断、文本构建(buildSkillsMetadataText)——后两者与"事件"毫无关系 - 测试要绕道:想测文本格式,得先构造 HookManager + 注册 handler + fire 事件,才能拿到 append 结果
- 顺序隐式:skills 先注册所以先注入,改顺序 = 改注册时序
s10 把事件分发留给 HookManager(保留为通用扩展点),把空库判断 + 文本构建提取为纯函数,把注入位置统一到 builder。职责各归其位。
核心模型:PromptSegment
一段系统提示片段,三个字段各司其职:
data class PromptSegment(
val name: String, // 片段标识(测试/日志)
val order: Int = 0, // 组装顺序,升序(base=0 → skills=10 → memory=20)
val provider: () -> String // 内容提供者;空白内容在 build 时跳过
)三个设计点:
provider是() -> String而非suspend—— 文本构建只读内存 store(SkillStore / MemoryStore),无 I/Oorder用间距 10 —— 预留插入空间(以后要在 base 和 skills 之间插一段,order 5 即可)name不做去重 —— MVP 允许重复注册,按注册顺序 + order 稳定排序
SystemPromptBuilder
组装器只有两个方法,职责单一:
class SystemPromptBuilder {
private val segments = mutableListOf<PromptSegment>()
fun register(segment: PromptSegment) {
segments.add(segment)
}
fun build(): String = segments
.sortedBy { it.order } // 稳定排序:同 order 保持注册顺序
.map { it.provider() } // 每次 build 重新求值(动态)
.filter { it.isNotBlank() } // 空白片段跳过
.joinToString(SEGMENT_SEPARATOR)
companion object {
private const val SEGMENT_SEPARATOR = "\n\n" // 片段间空行分隔
}
}四条管线操作各自承担一个语义:
| 操作 | 语义 |
|---|---|
sortedBy { it.order } | 确定性顺序(base 在最前,memory 在最后) |
map { it.provider() } | 每次 build 动态求值——memory 索引会话中增长后立即反映 |
filter { it.isNotBlank() } | 空白即禁用——无 skills / 无 memory 时不输出空段落 |
joinToString("\n\n") | 片段间空行分隔,人类可读 |
文本构建函数提取
被删 Hook 的核心逻辑提取为独立函数,留在各自的包(internal 模块内可见,ReplLoop 可直接调用):
skill/SkillPrompt.kt —— 原 SkillInjectionHook.buildSkillsMetadataText:
internal fun buildSkillsMetadataText(skills: List<SkillManifest>): String {
if (skills.isEmpty()) return "" // 空列表返回空串,让 builder 跳过
return buildString {
append("# Available Skills\n\n")
append("The following skills are available. Call the `load_skill` tool with the skill's name to retrieve its full instructions, then follow them.\n\n")
for (skill in skills) {
append("## ${skill.name}\n")
append("${skill.description}\n\n")
}
}.trimEnd()
}memory/MemoryPrompt.kt —— 原 MemoryInjectionHook.buildMemoryIndexText:
internal fun buildMemoryIndexText(entries: List<MemoryEntry>, maxInjected: Int = Int.MAX_VALUE): String {
val capped = entries.take(maxInjected) // 截断下沉到函数内(最终审查修复)
if (capped.isEmpty()) return ""
return buildString {
append("# Persistent Memory\n\n")
append("The following memories were saved from previous sessions. Call `memory_read` with a memory's name to retrieve its full content.\n\n")
for (e in capped) {
append("## ${e.name}\n")
append("${e.description}\n\n")
}
}.trimEnd()
}空列表返回 "" 是关键行为保持 —— 原 Hook 在 store 为空时返回 Continue(不注入);提取后加 if (isEmpty()) return "" 守卫,配合 builder 的空白跳过,行为完全等价。
maxInjected 参数是最终审查的产物 —— 最初截断逻辑内联在 ReplLoop 的 lambda 里,Hook 删除后该规则失去测试覆盖;下沉到函数内后 MemoryPromptTest 能直接锁定"5 条只注入 3 条"。
AgentLoop 集成
构造函数从字符串换成 builder(这是一次有意的 breaking change):
class AgentLoop(
private val llmProvider: LLMProvider,
private val systemPromptBuilder: SystemPromptBuilder, // 原 systemPrompt: String
private val config: AgentConfig = AgentConfig(),
private val hooks: AgentLoopHooks = AgentLoopHooks(),
private val toolRegistry: ToolRegistry = ToolRegistry(),
private val compactor: ContextCompactor = ContextCompactor(llmProvider)
)buildRequestMessages() 用 builder 组装,并处理"全部空白"的退化路径:
private fun buildRequestMessages(): List<Message> {
val system = systemPromptBuilder.build()
.takeIf { it.isNotBlank() } // 全部片段空白 → build() 返回 "" → null
?.let { Message(Role.SYSTEM, it) }
return listOfNotNull(system) + messagesHistory.toList()
}listOfNotNull 保证:builder 返回空串时不产生 SYSTEM 消息(AnthropicProvider 对空 system 字段处理为 null,API 请求省略该字段)——这是最终审查要求补测试锁住的分支。
SubagentFactory:子智能体隔离
子智能体需要隔离上下文(s06 的契约:独立 messagesHistory、独立工具、独立 Hook)。s10 后子智能体只注册 base 片段,不注入 skills/memory:
val loop = AgentLoop(
llmProvider = llmProvider,
systemPromptBuilder = SystemPromptBuilder().apply {
register(PromptSegment("base", 0) { SubagentPrompts.DEFAULT })
},
config = config,
hooks = AgentLoopHooks(
onPreToolUse = { tc -> hookManager.firePreToolUse(tc) },
onPostToolUse = { tc, result -> hookManager.firePostToolUse(tc, result) }
),
toolRegistry = toolRegistry
)ReplLoop:三段组装
记忆系统构建之后、AgentLoop 构造之前,组装完整的三段系统提示:
// s10: 组装系统提示片段(base → skills → memory,order 升序)
val systemPromptBuilder = SystemPromptBuilder().apply {
register(PromptSegment("base", 0) { systemPrompt })
register(PromptSegment("skills", 10) { buildSkillsMetadataText(skillStore.getAll()) })
register(PromptSegment("memory", 20) {
buildMemoryIndexText(memoryStore.loadAll(), memoryConfig.maxInjectedSummaries)
})
}组装后的系统提示(三条终态):
You are a helpful coding assistant. Answer concisely.
# Available Skills
The following skills are available. Call the `load_skill` tool with the
skill's name to retrieve its full instructions, then follow them.
## code-review
Review code changes for correctness and quality.
# Persistent Memory
The following memories were saved from previous sessions. Call `memory_read`
with a memory's name to retrieve its full content.
## user-prefers-vim
用户偏好 vim 编辑器关键机制:AnthropicProvider 的 SYSTEM 合并
这次重构的"行为等价"依赖于 AnthropicProvider 的一个实现细节——它会把请求里所有 SYSTEM 消息过滤出来,\n\n 拼进 API 顶层的 system 字段:
val systemPrompt = messages
.filter { it.role == Role.SYSTEM } // 抽出所有 SYSTEM 消息
.joinToString("\n\n") { it.content } // 空行分隔拼接
.takeIf { it.isNotBlank() } // 全空白 → null → 请求省略 system 字段
val apiMessages = messages
.filter { it.role != Role.SYSTEM } // 剩余消息转为 API 消息列表
.map { msg -> convertToAnthropicMessage(msg) }这带来两个推论:
- 注入位置不影响内容 —— 无论是"base 在头部 + skills/memory 追加在请求末尾"(s10 前),还是"builder 拼成单个 SYSTEM 放在头部"(s10 后),
filter + joinToString之后 API 收到的system字段完全一致。唯一受影响的只是 system 内部三段的相对顺序,而这正由 builder 的order声明式控制 - s08 的压缩摘要不受影响 ——
[Conversation Summary]系统消息仍留在messagesHistory(它是会话状态,不是静态片段),同样被filter抽出并入 system 字段,无需改动
Hook 删除
SkillInjectionHook / MemoryInjectionHook 及其测试全部删除。HookManager 的 PRE_LLM_REQUEST 事件类型与 AgentLoopHooks.onPreLlmRequest 机制保留——当前无 handler 注册时 firePreLlmRequest 返回空列表,行为不变;它是未来的请求级注入扩展点。
注意:
onPreLlmRequest在上下文压缩触发时,单次迭代内会触发两次(压缩前与压缩后重建)——这是 s08 遗留行为,已在AgentLoopHooksKDoc 注明(最终审查 F4)。
端到端流程
一次"技能/记忆进入系统提示"的完整生命周期:
启动时:
ReplLoop.start()
→ skillStore = SkillLoader(config.skillPaths).load()
→ memoryStore = MemoryStore("~/.cat-code/memory")
→ systemPromptBuilder = SystemPromptBuilder().apply {
register(base, 0, { systemPrompt })
register(skills, 10, { buildSkillsMetadataText(skillStore.getAll()) })
register(memory, 20, { buildMemoryIndexText(memoryStore.loadAll(), maxInjected) })
}
→ AgentLoop(systemPromptBuilder = ...)每次 LLM 请求(AgentLoop.run 的每次迭代):
buildRequestMessages()
→ systemPromptBuilder.build()
→ 对每个片段重新执行 provider()
→ skillStore.getAll() → 当前技能索引
→ memoryStore.loadAll().take(maxInjected) → 当前记忆索引
→ 按 order 升序拼接 → "base\n\nskills\n\nmemory"
→ [Message(SYSTEM, 拼接结果)] + history
→ AnthropicProvider 放入 API system 字段会话中 memory 变化:
用户会话中 LLM 调 memory_write 新增一条记忆
→ memoryStore 立即持久化
→ 下一次请求 build() 重新求值 memory 片段
→ 新记忆自动出现在系统提示中(无需重启、无需手动刷新)设计取舍深挖
几个值得展开的设计决策,为什么这么选:
为什么 provider 是 lambda 而非类
一个备选方案是给每个片段定义子类(BaseSegment、SkillsSegment、MemorySegment),各实现一个 content() 方法。但那需要:
- 每个片段一个文件 + 一个类
- 状态注入(store 引用)靠构造
- 测试要逐个类写
lambda 方案把"片段"降维成一个 data class + 一个闭包,内容来源(store 查询)在闭包里闭包捕获,测试直接构造 lambda 就能测。这是"最简单模式解决问题"的体现——不需要为三行逻辑引入类层次。
为什么 order 间距 10
不是 1、2、3,而是 0、10、20。间距是预留的插入空间:
- 将来要在 base 和 skills 之间插一段"工具说明",用 order 5,不用改任何已有片段的 order
- 如果间距是 1,插入就得把后面的全部重排
间距 10 与 s09 的 MemoryConfig 各参数默认值一样,是"稍大但够用"的宽松设计。
为什么"空白即禁用"而不是 enabled 开关
备选是给 PromptSegment 加 enabled: Boolean 字段,ReplLoop 里 register(PromptSegment(..., enabled = skillStore.size() > 0))。但那引入两个问题:
enabled在注册时求值,而 builder 要支持运行时动态(memory 会话中增长)——开关反而限制了动态性- 一个布尔字段 + 一个内容字段,语义重叠(内容为空不就是禁用吗)
让 provider 返回空白 = 禁用,一个机制覆盖两种情况,语义更一致。最终审查的 maxInjected 截断测试正是靠"空白跳过"锁住的。
为什么 skill → system 依赖通过 ReplLoop 间接满足
设计文档 §5.2 标注 skill → system, tool。如果严格实现,skill 包要 import system 包(比如 SkillPrompt 返回 PromptSegment)。但我们让 buildSkillsMetadataText 返回纯 String,由 ReplLoop 包成 PromptSegment:
skill.buildSkillsMetadataText(skills) : String ← 纯函数,不感知 system
repl: PromptSegment("skills", 10) { buildSkillsMetadataText(...) } ← 组装在 ReplLoop好处是 skill/memory 包保持零依赖,system 只被组装方(repl)消费。代价是少了一个编译期约束——但这符合项目"用最简单模式、不为对齐依赖图加空引用"的原则。
与 s09 的衔接:截断规则的下沉
s09 里 maxInjected 截断逻辑在 MemoryInjectionHook 的 registerTo(store.loadAll().take(maxInjected))。Hook 删除后,这条"防 context 膨胀"的安全规则一度只剩 ReplLoop 里的一行内联 .take()——没有任何测试锁定。最终审查发现后,把截断下沉到 buildMemoryIndexText(entries, maxInjected) 函数内部:
internal fun buildMemoryIndexText(entries: List<MemoryEntry>, maxInjected: Int = Int.MAX_VALUE): String {
val capped = entries.take(maxInjected) // 规则从调用点下沉到函数内,可被测试
if (capped.isEmpty()) return ""
...
}安全规则从"藏在调用点的临时逻辑"变成"函数签名的一部分",测试直接锁定。
测试策略
| 测试 | 覆盖点 |
|---|---|
SystemPromptBuilderTest | 顺序组装(乱序注册按 order 输出)、空白跳过、全空白返回空串、无片段空串、动态重求值(provider 结果变化)、同 order 稳定排序 |
SkillPromptTest | buildSkillsMetadataText 格式(# Available Skills / ## name / description)、空列表返回空串 |
MemoryPromptTest | buildMemoryIndexText 格式、只输出索引不输出正文(哨兵值)、空列表返回空串、maxInjected 截断(5 条注入 3 条) |
AgentLoopTest | 构造用 builder、全部空白时无 SYSTEM 消息(none { it.role == Role.SYSTEM }) |
SubagentFactory / 命令测试 | 子智能体 base-only builder;命令测试空串语义保持 |
测试设计要点:
- 动态重求值用闭包计数器验证 ——
PromptSegment("dynamic", 0) { "value-$counter" },两次 build 之间counter++,断言输出变化。这锁住了"memory 会话中增长后自动反映"的核心机制 - 空白分支用
listOfNotNull验证 —— AgentLoop 层测试构造空 builder,断言lastMessages.none { it.role == Role.SYSTEM },锁住退化路径 - maxInjected 截断下沉后可直接测 ——
buildMemoryIndexText(5 条, maxInjected = 3)断言第 4 条不出现,锁住"防 context 膨胀"的安全规则
开发过程:设计取舍与踩坑记录
s10 经历了与 s09 相同的完整流程(spec → plan → subagent-driven execution + 任务级审查 + 全分支审查),真实踩过的坑:
计划阶段的三个决策(经用户确认):
- 迁移注入进 builder(而非只加基础设施)—— 单一注入路径,无冗余
- 删除 Hook、逻辑进 builder(而非保留)—— 文本构建函数提取为独立函数
- AgentLoop 构造换 builder(而非保留 String + 可选 builder)—— 无双路径
实现中发现并处理的计划偏差:
- AgentLoopTest 实际 28 处构造(计划写 10)—— 实现者 grep 发现并全部迁移
- SkillCommandTest / MemoryCommandTest 也直接构造 AgentLoop(计划漏列)—— 实现者补齐,否则编译不过
- AgentLoopHooks KDoc 展示旧签名 —— Task 4 附带修正
最终全分支审查发现并修复(2 Important + 2 Minor):
- F1 过期注释 —— AgentLoop 里 "skill 元数据经 PRE_LLM_REQUEST" 的注释已过时(skill 注入迁移了),改为"保留扩展点"
- F2 maxInjected 截断失去测试 —— Hook 删除后"5 条只注入 3 条"的规则无测试锁定,下沉到
buildMemoryIndexText(maxInjected)并补测试 - F3 全空白分支未测 —— 补 AgentLoop 层测试
- F4 PRE_LLM_REQUEST 压缩双触发 —— 压缩时该回调在单次迭代内触发两次(压缩前/后),KDoc 说明
几个被采纳的关键设计决策:
- 空白即禁用(不做 enabled 开关)—— provider 返回空白就跳过,YAGNI
skill → system依赖通过 ReplLoop 间接满足 —— 与设计文档 §5.2 的偏差,但避免了为对齐依赖图而增加空引用- 注入位置从请求尾部移到系统提示顶部 —— 语义等价(AnthropicProvider 合并所有 SYSTEM),组装更整洁
- order 间距 10 预留插入空间 —— 避免后续加片段就要重排所有 order
下一站
s10 让系统提示的组装可组合、可扩展、可测试。这是阶段三(系统可靠性)的开端:
- s11 Error Recovery —— 系统提示分段后,错误恢复(token 升级、fallback 模型、重试)可以在更稳定的请求结构上构建;未来可加"错误诊断"片段动态注入
- s12 Task System —— 任务上下文可作为新的 PromptSegment 注册,与 skills/memory 平级
- s20 Comprehensive Agent —— 全机制集成的"大系统提示"由 builder 统一组装,新增来源只需
register()
s10 留给后续最大的礼物是一个可组合的提示组装范式:任何"该让 LLM 知道的东西"(技能、记忆、任务、团队上下文)都变成一段 PromptSegment,按 order 声明顺序,运行时求值,空白自动跳过——系统提示从此不再是写死的字符串,而是声明式的片段集合。
下一篇:Index 11: Error Recovery —— token 升级 / fallback 模型 / 重试策略,让智能体在出错时自愈。


