一个让所有Agent开发者头疼的问题
想象一下这个场景:你正在构建一个全能型的AI Agent,希望它能写代码、做数据分析、处理PDF、识别图像、操作数据库……你精心为它配置了50个工具函数,每一个都写好了详细的描述和参数说明。然后你信心满满地启动Agent,结果发现——工具越多,模型越笨。
这不是段子,而是真实发生的问题。当一次性把所有工具描述都塞进系统提示词时,Token消耗急剧上升、模型决策质量下降、响应延迟加剧。更糟糕的是,90%的任务其实只需要1-2个工具,但模型却要在几十个选项中反复筛选。
那怎么办?不给工具?Agent就变成了一个只会聊天的大模型,什么实际任务也完不成。
这就是Agent Skill(技能) 机制要解决的核心问题。本文将带你从零开始,深入理解Skill的设计哲学、加载原理,并用Go语言完整实现一个电商订单处理场景的Skill系统。
第一部分:Skill是什么?——给Agent的”工作手册”
1.1 从Function Calling到Skill的进化
先来理清两个容易混淆的概念。
Function Calling(函数调用) 是大模型的一种原生能力——模型能够输出结构化的JSON来表示”我要调用某个函数,参数是什么”。这是一种底层机制,是模型本身就具备的能力。
而Skill是建立在Function Calling之上的上层应用抽象。如果说Function Calling是模型”伸出手”的能力,那Skill就是告诉模型”手应该往哪伸、怎么伸、伸完之后怎么办”的完整操作手册。
打个比方:Function Calling就像给你一把螺丝刀,你知道它能拧螺丝;而Skill则是一份完整的”家具组装说明书”,告诉你第一步做什么、第二步做什么、用哪个工具、有什么注意事项。
1.2 Skill的诞生背景
大模型很聪明,但它有一个根本性的问题:没有你的私域知识和专属能力。
你团队的代码规范是什么?做Code Review要看哪几个维度?处理退款订单应该走什么流程?这些东西不在模型的训练数据里,每次对话都重新教一遍,既不高效也不稳定。
更现实的问题是,即使你通过MCP给了Agent工具调用能力,能查数据库、能调API、能发邮件,它依然不知道该按什么流程、什么顺序、什么标准去使用这些工具。
Skill要做的,就是把你的经验和流程结构化地交给Agent,让它像拿到工作手册一样自主执行。
1.3 Skill的物理形态:一个文件夹
从技术实现上看,一个Skill就是一个文件夹,里面至少包含一个SKILL.md文件:
1 2 3 4 5 6
| order-processing/ ├── SKILL.md # 必需:技能定义文件 ├── references/ # 可选:参考资料 │ └── api-spec.md └── scripts/ # 可选:可执行脚本 └── process.py
|
SKILL.md是技能的核心,它包含两部分:
- YAML前置元数据:定义技能的名称(name)和描述(description),这两项是必需的
- Markdown正文:详细的指令、工作流程、注意事项和示例
一个典型的SKILL.md长这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| --- name: order-refund-processor description: 处理电商订单退款申请,包括验证、审批和执行退款。当用户询问退款、退货或取消订单时使用。 ---
当用户发起退款申请时,按以下流程处理...
1. 验证订单状态 2. 检查退款资格 3. 执行退款操作 4. 发送通知
- 已发货订单需先确认退货 - 超过30天的订单需人工审核 ...
|
第二部分:核心设计原理——渐进式披露(Progressive Disclosure)
2.1 为什么不能一次性全加载?
你可能会想:”既然Skill这么有用,那把所有的Skill一次性全加载进去不就好了?”
答案很直接:上下文窗口是有限的。
假设你有20个Skill,每个Skill的完整指令平均3000个Token,一次性加载就是60000个Token。这不仅会迅速撑爆上下文窗口,还会带来三个严重问题:
- Token消耗激增:每次调用都要传输大量冗余信息
- 决策质量下降:模型在大量信息中筛选相关内容的难度大增
- 注意力分散:研究表明,模型在长上下文中的注意力会显著衰减
2.2 渐进式披露:用多少,加载多少
Skill最精妙的设计就是渐进式披露(Progressive Disclosure) 机制——不是一次性把内容全塞给模型,而是分层按需加载。
这个机制分为三个层级:
| 层级 | 内容 | 加载时机 | 典型大小 |
|---|
| Level 1 | 元数据(名称+描述) | 启动时常驻 | ~100 Tokens |
| Level 2 | SKILL.md完整指令 | 意图匹配时加载 | ~1000+ Tokens |
| Level 3 | 引用文件(脚本、参考资料等) | 执行时按需读取 | 视文件大小 |
Level 1:元数据——始终可见的”菜单”
每个Skill的SKILL.md文件开头的YAML元数据(name和description)会在Agent启动时被预加载到系统提示词中。
这就像餐厅里的菜单——你不需要知道每道菜的具体做法,只需要知道菜名和简短描述,就能决定要点什么。Agent拿到用户请求后,拿请求内容与所有Skill的description做匹配,判断哪些Skill可能有用。
这个设计的妙处在于:你可以同时挂载几十个Skill,而激活判断的成本只是几十行短文本的比对。
Level 2:完整指令——确定需要时才展开
当Agent判断某个Skill与当前任务相关时,它会主动加载该Skill的完整SKILL.md内容。
这就像你点了菜之后,厨师才开始按照菜谱的详细步骤制作。此时,完整的指令、工作流程、注意事项才进入上下文窗口。
Level 3:附加资源——真正用到时才读取
对于更复杂的场景,Skill文件夹中可以包含脚本(如.py文件)或额外文档(如references/目录下的参考资料)。
这些内容只有当SKILL.md中明确引用且确实需要时,Agent才会去读取或执行。比如一个PDF处理技能的SKILL.md引用了forms.md,但Agent只有在遇到填表任务时才会去读这个文件。
2.3 为什么渐进式披露如此重要?
这个设计解决了两个实际问题:
- Token效率:不把所有知识一股脑塞进上下文,避免信息过载和Token浪费
- 注意力聚焦:模型在每个阶段只关注最相关的信息,决策质量更高
相比全量加载,三级渐进式披露可以节省80%以上的上下文开销。
2.4 类比理解:塞尔达传说与Skill
有人用《塞尔达传说》来类比这个机制:
游戏世界里有很多神庙,每个神庙都包含复杂的谜题和挑战。但游戏不会在玩家进入某个区域时就把所有神庙的内部结构都加载进内存——那样内存早就爆了。相反,游戏只在大地图上显示神庙的位置和名称(Level 1:元数据),当玩家走到某个神庙门口并选择”进入”时,才加载该神庙的完整内部结构(Level 2:完整指令),而神庙里的具体机关和宝箱则在玩家真正到达时才渲染(Level 3:按需资源)。
Skill的加载机制异曲同工:按需加载,精准控制上下文。
第三部分:Skill加载机制的实现原理
3.1 整体架构
一个完整的Skill系统由以下几个核心模块协同工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ┌─────────────────────────────────────────────────┐ │ Agent Runtime │ │ │ │ ┌──────────┐ ┌──────────────────────┐ │ │ │ Skill │───▶│ Request Processor │───▶ │ │ │Repository│ │ Pipeline │ │ │ └──────────┘ └──────────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────┐ ┌──────────────┐ │ │ │ Session │ │ Model │ │ │ │ State │ │ Request │ │ │ └──────────┘ └──────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────┐ ┌──────────────────┐ │ │ │ Skill │───▶│ Tool Execution │ │ │ │ Tools │ │ (StateDelta) │ │ │ └──────────┘ └──────────────────┘ │ └─────────────────────────────────────────────────┘
|
3.2 两条核心数据流
数据流一:模型决策 → 工具调用 → 状态写入
- Agent接收到用户请求
- 模型根据系统提示词中的Skill元数据(Level 1),判断需要哪个Skill
- 模型调用
skill_load工具 - 工具执行,产生状态变更(StateDelta)
- 状态变更写入Session State
数据流二:Processor读取State → 注入Prompt
- 新一轮请求到来
- Request Processor从Session State读取已加载的Skill
- 组装完整内容(Level 2的SKILL.md正文)
- 注入到模型请求中
3.3 加载的触发方式
Skill的加载通常有两种触发方式:
方式一:模型自主决策。Agent在系统提示词中被告知”你有这些Skill可用,当遇到相关场景时主动调用”。模型根据用户输入和Skill描述做匹配,自主决定是否加载。
方式二:客户端主动触发。客户端(如前端应用)可以在发送请求时直接指定要加载的Skill,无需等待模型判断。
3.4 热加载:无需重启的动态更新
在实际生产环境中,Skill需要支持热加载(Hot Reload) ——即Skill文件更新后,Agent能够自动发现并加载新版本,无需重启服务。
热加载的实现通常依赖文件监听器(File Watcher):当SKILL.md或相关文件发生变化时,监听器检测到变更,重新解析和注册Skill。这使得在长运行Agent中动态添加、修改Skill成为可能。
3.5 Skill与Function Calling的关系再梳理
理解了加载机制后,我们可以更清晰地梳理Skill和Function Calling的关系:
- Function Calling是模型的原生能力,让模型能输出结构化的工具调用请求
- Skill是Agent框架层的工程抽象,它封装了完整的”什么时候用、怎么用、用完怎么办”的流程
- 一个Skill内部可以绑定多个Function Calling工具
- Skill的加载过程本身,就是通过Function Calling机制实现的——模型调用
skill_load工具来加载Skill内容
简单说:Skill是”说明书+工具集”的完整包,而Function Calling是让这个包能被模型调用的底层通道。
第四部分:实战——电商订单处理Skill系统(Go语言实现)
理论讲完了,现在我们来写代码。我们将用Go语言实现一个完整的电商订单处理Skill系统。
4.1 场景设定
假设我们经营一个电商平台,Agent需要处理以下类型的订单相关任务:
- 查询订单状态:用户问”我的订单12345到哪了?”
- 处理退款:用户说”我要退款,订单号67890”
- 修改收货地址:用户要求”帮我改一下订单12345的收货地址”
传统做法是把这三个功能写成三个独立的Function Calling工具,全部注册给Agent。但我们采用Skill方案,创建一个order-processor Skill,把三个功能打包在一起,并附上完整的处理流程说明。
4.2 项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| order-agent/ ├── main.go # 主程序入口 ├── go.mod ├── skills/ │ └── order-processor/ │ ├── SKILL.md # 技能定义 │ ├── references/ │ │ └── api-spec.md # API参考文档 │ └── scripts/ │ └── helper.go # 辅助脚本(实际部署时编译为可执行文件) ├── pkg/ │ ├── skill/ │ │ ├── loader.go # Skill加载器 │ │ ├── registry.go # Skill注册表 │ │ └── types.go # 类型定义 │ └── agent/ │ └── agent.go # Agent核心逻辑
|
4.3 核心类型定义
首先定义Skill的核心数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| package skill
import "time"
type SkillMeta struct { Name string `yaml:"name"` Description string `yaml:"description"` Version string `yaml:"version,omitempty"` Author string `yaml:"author,omitempty"` }
type Skill struct { Meta SkillMeta Content string Resources map[string][]byte Scripts map[string][]byte LoadedAt time.Time SourcePath string }
type LoadedSkill struct { Skill *Skill LoadedAt time.Time ExpiresAt time.Time }
|
4.4 Skill加载器实现
加载器负责从文件系统扫描和解析Skill:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
| package skill
import ( "io/fs" "os" "path/filepath" "strings"
"gopkg.in/yaml.v3" )
type Loader struct { rootDir string }
func NewLoader(rootDir string) *Loader { return &Loader{rootDir: rootDir} }
func (l *Loader) LoadAll() ([]*Skill, error) { var skills []*Skill entries, err := os.ReadDir(l.rootDir) if err != nil { return nil, err } for _, entry := range entries { if !entry.IsDir() { continue } skillDir := filepath.Join(l.rootDir, entry.Name()) skill, err := l.loadSkillMeta(skillDir) if err != nil { continue } skills = append(skills, skill) } return skills, nil }
func (l *Loader) loadSkillMeta(dir string) (*Skill, error) { skillMdPath := filepath.Join(dir, "SKILL.md") data, err := os.ReadFile(skillMdPath) if err != nil { return nil, err } meta, content, err := parseFrontmatter(data) if err != nil { return nil, err } return &Skill{ Meta: *meta, Content: content, SourcePath: dir, Resources: make(map[string][]byte), Scripts: make(map[string][]byte), }, nil }
func (l *Loader) LoadFull(skill *Skill) error { if skill.Content != "" && len(skill.Resources) > 0 { return nil } skillMdPath := filepath.Join(skill.SourcePath, "SKILL.md") data, err := os.ReadFile(skillMdPath) if err != nil { return err } _, content, err := parseFrontmatter(data) if err != nil { return err } skill.Content = content refsDir := filepath.Join(skill.SourcePath, "references") if err := l.loadResources(refsDir, skill.Resources); err != nil && !os.IsNotExist(err) { return err } scriptsDir := filepath.Join(skill.SourcePath, "scripts") if err := l.loadResources(scriptsDir, skill.Scripts); err != nil && !os.IsNotExist(err) { return err } skill.LoadedAt = time.Now() return nil }
func (l *Loader) loadResources(dir string, target map[string][]byte) error { return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } relPath, _ := filepath.Rel(dir, path) data, err := os.ReadFile(path) if err != nil { return err } target[relPath] = data return nil }) }
func parseFrontmatter(data []byte) (*SkillMeta, string, error) { str := string(data) if !strings.HasPrefix(str, "---\n") { return nil, "", fmt.Errorf("missing YAML frontmatter") } endIdx := strings.Index(str[4:], "\n---\n") if endIdx == -1 { return nil, "", fmt.Errorf("invalid frontmatter format") } frontmatter := str[4:4+endIdx] content := str[4+endIdx+5:] var meta SkillMeta if err := yaml.Unmarshal([]byte(frontmatter), &meta); err != nil { return nil, "", err } return &meta, content, nil }
|
4.5 Skill注册表实现
注册表管理所有已发现的Skill,并提供按需加载的能力:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| package skill
import ( "sync" )
type Registry struct { mu sync.RWMutex skills map[string]*Skill loader *Loader }
func NewRegistry(loader *Loader) (*Registry, error) { r := &Registry{ skills: make(map[string]*Skill), loader: loader, } if err := r.refresh(); err != nil { return nil, err } return r, nil }
func (r *Registry) refresh() error { skills, err := r.loader.LoadAll() if err != nil { return err } r.mu.Lock() defer r.mu.Unlock() for _, s := range skills { r.skills[s.Meta.Name] = s } return nil }
func (r *Registry) List() []SkillMeta { r.mu.RLock() defer r.mu.RUnlock() metas := make([]SkillMeta, 0, len(r.skills)) for _, s := range r.skills { metas = append(metas, s.Meta) } return metas }
func (r *Registry) Get(name string) (*Skill, error) { r.mu.RLock() skill, exists := r.skills[name] r.mu.RUnlock() if !exists { return nil, fmt.Errorf("skill %s not found", name) } if skill.Content == "" { if err := r.loader.LoadFull(skill); err != nil { return nil, err } } return skill, nil }
func (r *Registry) GetMeta(name string) (SkillMeta, error) { r.mu.RLock() defer r.mu.RUnlock() skill, exists := r.skills[name] if !exists { return SkillMeta{}, fmt.Errorf("skill %s not found", name) } return skill.Meta, nil }
|
4.6 会话状态管理
会话状态负责追踪当前会话中已加载的Skill:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| package agent
import ( "sync" "time" )
type SessionState struct { mu sync.RWMutex loadedSkills map[string]*skill.LoadedSkill maxLifetime time.Duration }
func NewSessionState(maxLifetime time.Duration) *SessionState { return &SessionState{ loadedSkills: make(map[string]*skill.LoadedSkill), maxLifetime: maxLifetime, } }
func (s *SessionState) LoadSkill(skill *skill.Skill) { s.mu.Lock() defer s.mu.Unlock() s.loadedSkills[skill.Meta.Name] = &skill.LoadedSkill{ Skill: skill, LoadedAt: time.Now(), ExpiresAt: time.Now().Add(s.maxLifetime), } }
func (s *SessionState) GetLoadedContent() string { s.mu.RLock() defer s.mu.RUnlock() var sb strings.Builder for name, ls := range s.loadedSkills { if time.Now().After(ls.ExpiresAt) { continue } sb.WriteString("\n=== Skill: ") sb.WriteString(name) sb.WriteString(" ===\n") sb.WriteString(ls.Skill.Content) sb.WriteString("\n") } return sb.String() }
func (s *SessionState) CleanExpired() { s.mu.Lock() defer s.mu.Unlock() now := time.Now() for name, ls := range s.loadedSkills { if now.After(ls.ExpiresAt) { delete(s.loadedSkills, name) } } }
|
4.7 Agent主循环
Agent主循环实现了”观察→思考→行动”的完整流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
| package agent
import ( "context" "encoding/json" "fmt" "strings"
"your-project/pkg/skill" )
type Agent struct { registry *skill.Registry session *SessionState llmClient LLMClient }
type LLMClient interface { Chat(ctx context.Context, messages []Message, tools []Tool) (*Response, error) }
type Message struct { Role string `json:"role"` Content string `json:"content"` }
type Tool struct { Name string `json:"name"` Description string `json:"description"` Parameters interface{} `json:"parameters"` }
type Response struct { Content string ToolCalls []ToolCall }
type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Arguments map[string]interface{} `json:"arguments"` }
func (a *Agent) Run(ctx context.Context, userInput string) (string, error) { systemPrompt := a.buildSystemPrompt() messages := []Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userInput}, } if loadedContent := a.session.GetLoadedContent(); loadedContent != "" { messages = append(messages, Message{ Role: "system", Content: "以下是你已加载的技能详细说明:\n" + loadedContent, }) } tools := a.buildTools() resp, err := a.llmClient.Chat(ctx, messages, tools) if err != nil { return "", err } for _, tc := range resp.ToolCalls { if tc.Name == "skill_load" { skillName, _ := tc.Arguments["name"].(string) if err := a.handleSkillLoad(skillName); err != nil { return "", err } return a.Run(ctx, "我已加载了"+skillName+"技能,请继续处理") } } return resp.Content, nil }
func (a *Agent) buildSystemPrompt() string { var sb strings.Builder sb.WriteString("你是一个智能订单处理助手。你有以下技能可用:\n\n") for _, meta := range a.registry.List() { sb.WriteString(fmt.Sprintf("- %s: %s\n", meta.Name, meta.Description)) } sb.WriteString("\n当用户的需求匹配某个技能时,调用 skill_load 工具加载该技能。") sb.WriteString("加载后你会获得该技能的详细操作指南。") return sb.String() }
func (a *Agent) buildTools() []Tool { return []Tool{ { Name: "skill_load", Description: "加载指定名称的技能,获取详细的操作指南", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "name": map[string]interface{}{ "type": "string", "description": "要加载的技能名称", }, }, "required": []string{"name"}, }, }, } }
func (a *Agent) handleSkillLoad(skillName string) error { s, err := a.registry.Get(skillName) if err != nil { return err } a.session.LoadSkill(s) return nil }
|
4.8 Skill定义文件
最后,来看我们的电商订单处理Skill的SKILL.md:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| --- name: order-processor description: 处理电商订单相关操作,包括查询订单状态、处理退款申请、修改收货地址。当用户询问订单状态、发起退款或要求修改地址时使用。 version: 1.0.0 author: 电商技术团队 ---
# 电商订单处理技能
## 适用场景 当用户提到以下内容时,应加载此技能: - 查询订单状态("我的订单到哪了"、"订单发货了吗") - 申请退款("我要退款"、"订单取消") - 修改信息("改一下收货地址"、"修改订单信息")
## 核心能力 此技能封装了以下三个订单处理能力:
### 1. 查询订单状态 **触发条件**:用户询问订单进度或状态 **处理流程**: 1. 从用户输入中提取订单号(格式:ORD + 8位数字) 2. 调用 `query_order` 工具查询订单信息 3. 将结果格式化为友好回复
### 2. 处理退款申请 **触发条件**:用户明确要求退款或取消订单 **处理流程**: 1. 提取订单号 2. 调用 `check_refund_eligibility` 检查退款资格 3. 如果符合条件,调用 `process_refund` 执行退款 4. 发送退款确认通知
**重要规则**: - 已发货订单必须先确认退货才能退款 - 订单超过30天需转人工审核 - 退款金额 = 实付金额 - 已使用优惠
### 3. 修改收货地址 **触发条件**:用户要求修改收货信息 **处理流程**: 1. 提取订单号和新的地址信息 2. 检查订单状态(已发货不可修改) 3. 调用 `update_address` 更新地址 4. 发送修改确认
## 参考资料 详细的API接口文档请参考 [references/api-spec.md](references/api-spec.md)
## 错误处理 - 如果订单号不存在,提示用户核对订单号 - 如果操作失败,记录错误日志并告知用户联系客服
|
4.9 主程序入口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| package main
import ( "context" "fmt" "log" "time"
"your-project/pkg/agent" "your-project/pkg/skill" )
func main() { loader := skill.NewLoader("./skills") registry, err := skill.NewRegistry(loader) if err != nil { log.Fatal(err) } session := agent.NewSessionState(10 * time.Minute) llmClient := NewOpenAIClient(os.Getenv("OPENAI_API_KEY")) ag := agent.NewAgent(registry, session, llmClient) ctx := context.Background() fmt.Println("🤖 订单助手已启动,请输入您的问题...") for { var input string fmt.Print("\n> ") fmt.Scanln(&input) if input == "exit" || input == "quit" { break } resp, err := ag.Run(ctx, input) if err != nil { log.Printf("Error: %v", err) continue } fmt.Println("\n" + resp) } }
|
4.10 运行流程演示
假设用户输入:”我要退款,订单号ORD20240815”
步骤1:Agent启动时,系统提示词中包含了所有Skill的元数据:
1 2
| 你是一个智能订单处理助手。你有以下技能可用: - order-processor: 处理电商订单相关操作,包括查询订单状态、处理退款申请、修改收货地址...
|
步骤2:用户输入后,模型判断”退款”匹配order-processor技能的描述,于是调用skill_load工具。
步骤3:Agent执行skill_load,从注册表获取order-processor的完整内容(Level 2),加载到会话状态。
步骤4:Agent带着已加载的Skill内容再次调用模型。此时模型的上下文中包含了完整的退款处理流程。
步骤5:模型按照Skill中的流程指引,依次调用check_refund_eligibility和process_refund工具,完成退款。
步骤6:如果在执行过程中需要查阅API文档,模型可以读取references/api-spec.md(Level 3)。
整个过程,只有order-processor这一个Skill被加载到了上下文中。其他未使用的Skill始终只保留元数据(~100 Tokens),不会造成上下文浪费。
第五部分:进阶话题与最佳实践
5.1 多Skill协同
在实际场景中,一个复杂任务可能需要多个Skill协同工作。比如”分析订单数据并生成报表”可能需要order-processor和data-analyst两个Skill。
处理方式有两种:
- 顺序加载:Agent先加载第一个Skill完成部分任务,再加载第二个
- 并行加载:Agent在初始决策时同时加载多个相关Skill
建议根据任务复杂度选择,原则是”够用就好,不要过度加载”。
5.2 Skill的自动过期与清理
为了防止会话中累积过多已加载的Skill撑爆上下文,需要设置自动过期机制。在我们的实现中,每个加载的Skill都有ExpiresAt字段,CleanExpired()方法会定期清理过期的Skill。
建议的过期策略:
- 简单任务:5-10分钟
- 复杂任务:30分钟
- 长期会话:根据对话轮数动态调整
5.3 热加载实现
生产环境中,Skill的热加载至关重要。实现思路:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| type Watcher struct { registry *Registry loader *Loader }
func (w *Watcher) Watch(ctx context.Context) { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() var lastModTime map[string]time.Time for { select { case <-ticker.C: for _, skill := range w.registry.skills { info, _ := os.Stat(filepath.Join(skill.SourcePath, "SKILL.md")) if info.ModTime().After(lastModTime[skill.Meta.Name]) { w.registry.refreshSkill(skill.Meta.Name) log.Printf("Skill %s hot-reloaded", skill.Meta.Name) } } case <-ctx.Done(): return } } }
|
5.4 编写高质量Skill的要点
1. description要精准:description是模型判断是否加载Skill的唯一依据。写得模糊,模型可能乱用或不用;写得太泛,容易误触发。
❌ 差:”处理订单”
✅ 好:”处理电商订单的查询、退款和地址修改。当用户询问’我的订单’、’退款’、’改地址’时使用。”
2. 流程要清晰:SKILL.md正文应该像SOP(标准作业程序)一样,步骤明确、逻辑清晰。
3. 控制Skill大小:虽然渐进式披露解决了上下文问题,但单个Skill的完整指令最好控制在5000 Token以内。过大的Skill应该拆分成多个子Skill。
4. 引用而非复制:对于大段的API文档、数据库Schema等,放在references/目录下引用,而不是写在SKILL.md里。
5.5 Skill vs MCP:别再混淆了
MCP(Model Context Protocol)解决的是”连接”问题——让Agent能标准化地调用外部工具和数据源。而Skill解决的是”流程”问题——告诉Agent该按什么顺序、什么标准去使用这些工具。
两者是互补关系,不是替代关系:
- MCP:Agent的手和脚(能触及什么)
- Skill:Agent的大脑中的”工作手册”(知道怎么用)
一个完整的Agent架构应该是:Skill提供专业指导 → Agent Loop做决策 → Agent Runtime执行 → MCP连接外部。
心得
Agent Skill机制的精髓,在于它用渐进式披露解决了大模型上下文管理的核心矛盾——既想让Agent拥有丰富的专业知识,又不想让上下文被撑爆。
从本质上看,Skill是对人类流程性知识的结构化封装。它把”一个经验丰富的员工该怎么做这件事”变成了”一个AI Agent该怎么一步步完成这件事”。这不是什么黑科技,而是一种优雅的工程实践——用文件夹和Markdown文件,就实现了一套可扩展、可复用、按需加载的Agent能力扩展体系。
回到我们开篇的问题——“工具越多,模型越笨”。有了Skill机制,你可以放心地给Agent配置几十个Skill,而模型每次只需要”看到”真正相关的那个。这就像你不需要把整本百科全书背下来才能回答问题——你只需要知道书在哪、怎么查,需要的时候翻到对应的页面就够了。
希望这篇文章能帮助你真正理解并掌握Agent Skill的加载机制。现在,去给你的Agent写第一个Skill吧!