❝当 AI Agent 需要的知识越来越多,把一切都塞进 System Prompt 显然不是个好主意。本文从架构设计的角度出发,深入探讨一种优雅的解法——「Skill 渐进式加载机制」。❞
构建一个功能丰富的 AI Agent 时,我们不可避免地面临这样的困境:
那么,如何设计一套机制,让 Agent 能够 「按需加载」 技能内容,同时 「对 Token 友好」、「对缓存友好」、「对多 Agent 安全」?
解决方案的核心借鉴了 UI 设计中的经典原则——「渐进式披露」:
❝不要一次性展示所有信息,先提供概览让用户决策,需要时再展开详情。❞
映射到 Skill 机制:
这就像餐厅里的体验——你先看菜单(概览),点了某道菜后厨师才开始做(加载),吃完自动撤盘(过期清理)。
整个 Skill 加载机制可以拆解为 「四个核心模块」 和 「两条数据流」:
┌──────────────────────────────────────────────────────────────┐
│ Agent Runtime │
│ │
│ ┌──────────┐ ┌────────────────────┐ ┌─────────────┐ │
│ │ Skill │───▶│ Request Processor │───▶│ Model │ │
│ │Repository│ │ Pipeline │ │ Request │ │
│ └──────────┘ └────────────────────┘ └─────────────┘ │
│ │ │ │
│ │ ┌───────┴────────┐ │
│ │ │ Session State │ │
│ │ │ (loaded,docs) │ │
│ │ └───────┬────────┘ │
│ │ │ │
│ ┌────┴─────┐ ┌────────┴───────┐ │
│ │ Skill │───▶│ Tool Execution │ │
│ │ Tools │ │ (StateDelta) │ │
│ └──────────┘ └────────────────┘ │
└──────────────────────────────────────────────────────────────┘
「数据流一:模型决策 → 工具调用 → State 写入」
模型判断需要某技能 → 调用 skill_load 工具 → 工具执行产生 StateDelta → 写入 Session State
「数据流二:Processor 读取 State → 注入 Prompt」
新一轮请求 → Processor 从 State 读取已加载技能 → 组装完整内容 → 注入到模型 Prompt 中
两条数据流通过 「Session State」 这个"中间介质"解耦。写入和读取可以发生在不同的请求轮次中,天然支持异步和跨轮次的状态传递。
Skill 仓库是整个系统的数据源层。它需要回答三个问题:
抽象为接口:
type Repository interface {
// 返回所有 Skill 的摘要(名称 + 一句话描述)
Summaries() []Summary
// 根据名称获取完整的 Skill 定义
Get(name string) (*Skill, error)
// 获取 Skill 关联文档的存储路径
Path(name string) (string, error)
}
设计要点:
Summaries() 返回的是 「轻量摘要」,成本极低,适合每次请求都调用Get() 返回 「完整内容」,开销大,只在确定需要时调用一种直观的组织方式是用文件夹来表示 Skill:
skills/
├── code-review/
│ ├── SKILL.md # 核心定义(YAML front matter + Markdown body)
│ ├── guide.md # 辅助文档
│ └── reference.txt # 辅助文档
└── data-analysis/
├── SKILL.md
└── templates.md
SKILL.md 使用 YAML front matter 声明元数据:
---
name: code-review
description: 代码审查技能,涵盖代码规范、安全检查和性能优化
---
## 审查指南
当你进行代码审查时,请遵循以下步骤...
文件系统实现在初始化时一次性扫描目录、解析 front matter、建立名称索引。如果 name 字段未指定,回退到文件夹名称——简单约定优于复杂配置。
Session State 是 Skill 加载状态的持久化存储。Key 的设计直接影响多 Agent 隔离和查询效率。
Key 模式 | 含义 | 典型值 |
|---|---|---|
temp:skill:loaded:{agent}/{skill} | 标记某 Skill 已被某 Agent 加载 | "1" |
temp:skill:docs:{agent}/{skill} | 记录 Agent 对某 Skill 选择的文档 | "*" 或 JSON 数组 |
temp: 前缀表示这些是临时状态,不参与 Session 的持久化归档。
在多 Agent 协作场景中,父 Agent 可能编排多个子 Agent 在同一个 Session 里工作。如果所有 Agent 的 Skill 状态都混在一起,就会出现"串台":
场景:Agent-A 加载了 code-review,Agent-B 加载了 data-analysis
❌ 不隔离:Agent-A 的 Prompt 里也会出现 data-analysis 的内容
✅ 带作用域:每个 Agent 只看到自己加载的 Skill
Key 生成函数的核心逻辑:
func LoadedKey(agentName, skillName string) string {
return "temp:skill:loaded:" + agentName + "/" + skillName
}
func LoadedPrefix(agentName string) string {
return "temp:skill:loaded:" + agentName + "/"
}
通过前缀扫描 LoadedPrefix(agentName),可以高效地获取某个 Agent 的所有已加载 Skill,而不会误读其他 Agent 的状态。
skill_load:加载 Skill这是最核心的 Tool。当模型决定需要某个技能时,会调用它。
「关键架构决策」:Tool 的执行函数(Call)和 State 写入(StateDelta)是 「分离的」。
// Call 只负责验证和返回确认
func (t *LoadTool) Call(ctx context.Context, args LoadInput) string {
if _, err := t.repo.Get(args.Skill); err != nil {
return"error: skill not found"
}
return"loaded: " + args.Skill
}
// StateDelta 声明需要写入的状态变更
func (t *LoadTool) StateDelta(agentName string, args LoadInput) map[string][]byte {
delta := map[string][]byte{
LoadedKey(agentName, args.Skill): []byte("1"),
}
if args.IncludeAllDocs {
delta[DocsKey(agentName, args.Skill)] = []byte("*")
} elseiflen(args.Docs) > 0 {
delta[DocsKey(agentName, args.Skill)] = marshalJSON(args.Docs)
}
return delta
}
「为什么要分离?」
skill_select_docs:精细化文档选择提供更精细的文档管理能力,支持三种操作模式:
模式 | 行为 | 场景 |
|---|---|---|
add | 在已选文档基础上追加 | "再帮我看看这个文件" |
replace | 替换整个文档选择(默认) | "换一组参考资料" |
clear | 清除所有已选文档 | "不需要参考资料了" |
同样通过 StateDelta 机制更新 DocsKey 的值,保持写入逻辑的一致性。
Processor 是整个机制的核心调度中心。我们设计了 「两种注入策略」,各有优劣。
「思路」:将 Skill 内容追加到 System Message 的尾部。
「处理流程」:
ProcessRequest()
│
├─ 1. 旧版 State 迁移(如需要)
│
├─ 2. 清理过期的 Skill State(Turn 模式)
│
├─ 3. 注入 Skill 概览列表(无条件执行)
│ → "Available skills:\n- code-review: 代码审查\n- ..."
│
├─ 4. 从 State 读取已加载的 Skill 列表
│
├─ 5. 限制最大加载数量(如超限,按最近使用排序保留)
│
├─ 6. 遍历已加载 Skill,构建注入内容:
│ ├─ 获取 Skill 完整 Body
│ ├─ 读取文档选择
│ └─ 拼装 "[Loaded] skill-name\n{body}\n{docs}"
│
├─ 7. 合并到 System Message
│
└─ 8. 清理一次性 Skill State(Once 模式)
注入后的 System Message 结构示例:
(原始 System Prompt 内容)
Available skills:
- code-review: 代码审查技能
- data-analysis: 数据分析技能
[Loaded] code-review
## 审查指南
当你进行代码审查时,请遵循以下步骤...
Docs loaded: guide.md
[Doc] guide.md
(guide.md 的完整内容)
「优点」:实现简单直观,模型总能看到最新的技能信息。
「缺点」:每次加载新 Skill 都会改变 System Message,导致 LLM API 的 Prompt Caching 失效。
「思路」:不修改 System Message,而是将 Skill 内容"回填"到对话历史中 skill_load 对应的 Tool Result 消息里。
「处理流程」:
ProcessRequest()
│
├─ 1. 从 State 读取已加载 Skill
│
├─ 2. 索引对话历史中所有的 Tool Call 消息
│
├─ 3. 找到每个 Skill 最后一次 Tool Response 的位置
│
├─ 4. 将 Skill 完整内容写入对应的 Tool Result 消息体
│
├─ 5. 为找不到匹配 Tool Result 的 Skill 构建回退内容
│
└─ 6. 必要时插入回退的 System Message
「为什么这样设计?」
LLM API 的 Prompt Caching 通常基于前缀匹配——如果请求的 System Message 和前几轮对话跟上次完全一致,就能命中缓存。Tool Result 注入策略保持 System Message 不变,只修改对话历史中后面的 Tool Response,从而 「最大化缓存命中率」。
「回退机制」:当对话历史被压缩(如 Session Summary 合并了多轮对话),原本的 Tool Result 消息可能已经不存在了。此时 Processor 检测到"无处回填",就会回退到 System Message 注入,确保 Skill 内容不丢失。但如果 Summary 已经涵盖了相关上下文,则跳过回退,避免重复注入。
维度 | System Message 注入 | Tool Result 注入 |
|---|---|---|
实现复杂度 | ⭐⭐ 简单 | ⭐⭐⭐⭐ 较复杂 |
Prompt Caching | ❌ 每次加载新 Skill 失效 | ✅ System Message 稳定,利于缓存 |
对话压缩兼容 | ✅ 天然兼容 | ⚠️ 需要回退机制 |
调试友好度 | ✅ Prompt 一目了然 | ⚠️ 内容分散在 Tool Results 中 |
在实际系统中,可以将两种策略做成可配置选项,让使用者根据场景选择。
不同任务场景对 Skill 内容的存活时间有不同需求。设计三种加载模式来覆盖:
模式 | 行为 | 适用场景 |
|---|---|---|
「once」 | 注入一次后立即清除 State | 一次性查询,防止 Prompt 无限膨胀 |
「turn」(默认) | 当前调用内有效,下次调用时清除 | 多轮工具调用,同一轮任务内保持上下文 |
「session」 | 跨调用持续有效,直到 Session 结束 | 整个会话都需要某技能的长期任务 |
「Turn 模式的实现细节」:
func maybeClearSkillStateForTurn(invocation Invocation) {
// 使用一次性标记防止重复清理
if invocation.GetFlag("skill_turn_cleared") {
return
}
invocation.SetFlag("skill_turn_cleared", true)
// 清除上一轮残留的 Skill State
clearAllSkillKeys(invocation)
}
关键技巧是使用 「Invocation 级别的标记位」 来保证 "一个 Invocation 只清理一次"——因为一个 Invocation 内部可能有多次 Processor 调用(多轮工具调用循环),不能每次都清理,否则同一轮内加载的 Skill 也会被误删。
当 Agent 在一个 Session 中累积加载了很多 Skill 时,Prompt 会变得过长。需要一个"容量控制阀"。
agent := NewAgent("my-agent",
WithSkills(repo),
WithMaxLoadedSkills(3), // 最多同时保留 3 个已加载 Skill
)
当已加载数量超过上限时,按以下逻辑淘汰:
skill_load / skill_select_docs 调用过的 Skill「为什么选择"最近使用"而非"最常使用"?」
所有 Processor 按特定顺序排列成 Pipeline,执行顺序直接影响正确性:
Request Processor Pipeline:
│
├─ [1] System Prompt Processor // 基础 System Message
├─ [2] Context Processor // 注入外部上下文
├─ [3] Skills Request Processor // 注入 Skill 概览 + System Message 模式的内容
├─ [4] Content Processor // 组装对话历史
├─ [5] Post-Tool Processor // 处理工具调用结果后的提示
└─ [6] Skills Tool Result Processor // Tool Result 模式的内容回填
「为什么 Skills Tool Result Processor 在最后?」
因为它需要操作已经被 Content Processor 组装好的完整对话历史。只有在对话消息列表已经构建完成后,才能定位到正确的 Tool Result 消息并回填内容。
「为什么 Skills Request Processor 在 Content Processor 之前?」
因为它需要在 System Message 被最终"冻结"之前完成概览和内容的注入。
用一个端到端的例子把所有模块串起来:
用户: "帮我做一下这段代码的审查"
→ Skills Request Processor 注入概览:
System Message += "Available skills:\n- code-review: 代码审查\n- ..."
→ 模型看到概览,判断需要 code-review 技能
→ 模型调用 skill_load(skill="code-review", include_all_docs=true)
→ Tool 执行:
- Call() 返回 "loaded: code-review"
- StateDelta() 产生:
{
"temp:skill:loaded:my-agent/code-review": "1",
"temp:skill:docs:my-agent/code-review": "*"
}
→ 框架将 StateDelta 写入 Session State
→ Skills Request Processor 处理:
a. 注入概览(每次都做)
b. 从 State 发现 code-review 已加载
c. 从 Repository 获取 code-review 的完整 Body + 所有文档
d. 注入到 Prompt:
"[Loaded] code-review\n\n## 审查指南\n...\n\n[Doc] guide.md\n..."
→ 模型现在拥有完整的审查指导,开始执行代码审查任务
→ 新的 Invocation 开始
→ maybeClearSkillStateForTurn() 清除上轮的 Skill State
→ code-review 的内容不再出现在 Prompt 中
→ 如果新任务又需要,模型会重新加载
设计点 | 决策 | 价值 |
|---|---|---|
渐进式披露 | 概览常驻 + 详情按需加载 | 节省 90%+ 的冗余 Token 消耗 |
State 作为中间介质 | 工具写入 State,Processor 读取 State | 写入和读取完全解耦,支持跨轮次传递 |
Agent 作用域隔离 | Key 中嵌入 Agent 名称 | 多 Agent 共享 Session 时互不干扰 |
双注入策略 | System Message 注入 + Tool Result 注入 | 简单场景与缓存优化场景各取所需 |
StateDelta 机制 | Tool 声明 Delta,框架统一提交 | 可测试、可审计、可事务化 |
三级生命周期 | once / turn / session | 灵活适配从一次性查询到长期任务的各种场景 |
LRU 淘汰策略 | 超限时保留最近使用的 Skill | 防止 Prompt 膨胀,保障响应质量 |
回退机制 | Tool Result 不存在时回退 System Message | 对话压缩后仍保证技能内容不丢失 |
懒迁移策略 | 检测旧格式时即时迁移 | 平滑升级,无需全量数据变更 |
回到开头的问题——当 Agent 需要"十八般武艺"时,我们的解法是:
这套机制的核心价值在于:它把"Agent 应该知道什么"这个问题,从静态配置变成了「动态决策」——让模型自身成为知识加载的决策者,框架只负责提供高效、安全、可靠的加载管道。