手写 Agent ⑧,Skills 与 MCP 接入

通过 Skills 渐进式加载长流程,并接入 MCP Server,组合出具备文件、命令、记忆和扩展能力的迷你 coding agent。

outlier 14 分钟 5566 字 1 0
系列教程Agent从入门到放弃 第 30 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:一份越写越长的 AGENTS.md

第 17 篇写完项目记忆之后,我尝到了甜头,开始往 AGENTS.md 里加东西。

先是构建命令和踩过的坑,没问题,这些本来就该放在那里。然后是"发布新版本的步骤":打 tag、写 changelog、更新版本号、推送……十几行。然后是"新增一个接口要改哪些地方":路由、handler、参数校验、文档、测试……又是二十几行。然后是"排查线上问题的套路"、"写数据库迁移的规范"……

两周后,AGENTS.md 有四百行。每一次请求都带着它,每一圈都要为它付钱。而在大部分任务里,那些"发布步骤""迁移规范"根本用不上。我让它改个错别字,它的上下文里也躺着一份详细的发布流程。

更糟的是,它开始变笨了:第 16 篇说的 context rot 在一份四百行的记忆文件里同样成立,真正每次都要遵守的那几条约定,被淹没在了一堆流程说明中间。我想要的是:这些详细的操作说明,平时不要放进上下文,需要的时候再拿出来。 但"需要的时候"是什么时候?我不想每次手动告诉它"去读一下发布流程"。

这就是 skills 解决的问题:上下文里只放每个 skill 的名字和一句话描述,模型自己判断什么时候用得上,用得上的时候再去读完整的说明。 这一篇给 mini-agent 加上 skills,再把第 22 篇写的 MCP server 接进来。写完之后,一个能用的迷你 coding agent 就诞生了。读完本篇,你应该能回答这几个问题:

  • 什么是"渐进式披露"?skills 是怎么实现它的?
  • 一个 skill 长什么样?
  • mini-agent 是怎么接入 MCP server 的?
  • MCP 工具在权限系统里应该怎么对待?
  • skill 和 MCP server 一样,为什么也要审查?
  • 写到这里,mini-agent 一共有哪些能力?还缺什么?

一、Skills:渐进式披露

1.1 两层信息

渐进式披露(progressive disclosure)是界面设计里的一个老概念:不要一开始就把所有信息都摆出来,先给概要,用户需要时再展开细节。

skills 把这个思路用在了上下文上:

flowchart TB subgraph A["一直在上下文里(几十个 token)"] A1["- release:发布新版本时使用"] A2["- add-api:新增一个 HTTP 接口时使用"] A3["- migration:写数据库迁移时使用"] end subgraph B["需要时才加载(几百到几千 token)"] B1["release/SKILL.md
完整的发布流程"] end A1 -->|"模型判断:这次要发布
调用 load_skill(release)"| B1 style A fill:#e8f5e9,stroke:#2e7d32 style B fill:#fff9c4
  • 第一层:名字和描述。所有 skill 的名字和一句话描述,放在 system prompt 里,每次请求都带着。每个 skill 只占几十个 token,有几十个 skill 也不会撑爆上下文;
  • 第二层:完整的说明。模型读到描述,判断这次任务用得上,就调用一个 load_skill 工具,把完整的说明读进来。

开场那份四百行的 AGENTS.md,就可以拆成:一份短的 AGENTS.md(构建命令、约定、坑),加上几个 skill(发布流程、新增接口、数据库迁移)。改错别字时,上下文里只有几行 skill 的描述;要发布新版本时,模型会自己去读发布流程。

1.2 一个 skill 长什么样

一个 skill 就是一个目录,里面有一份 SKILL.md。文件开头是一段 frontmatter,写名字和描述;后面是正文,写详细的操作说明:

---
name: release
description: 发布新版本时使用:打 tag、写 changelog、更新版本号
---

# 发布流程

1. 确认 main 分支的 CI 是绿的:`gh run list --branch main --limit 1`
2. 更新 `version.go` 里的版本号
3. 在 CHANGELOG.md 顶部加一节,列出这个版本的改动(从上一个 tag 到现在的 git log)
4. 提交:`git commit -am "release: vX.Y.Z"`
5. 打 tag:`git tag vX.Y.Z`
6. 推送:先 `git push`,再 `git push --tags`

注意:不要跳过第 1 步。上次有人在 CI 红着的时候发布,回滚花了一个小时。

目录里还可以放别的文件:脚本、模板、示例。正文里可以引用它们,模型读到说明后,会用 read_file 或 run_bash 去用这些文件。

mini-agent 会扫描两个地方的 skills:

  • 项目级:工作目录下的 .agents/skills/*/SKILL.md,跟着项目走,提交到 git,团队共享;
  • 用户级:你家目录下的 ~/.agents/skills/*/SKILL.md,你自己的通用技能,所有项目都能用。

同名的 skill,项目级的优先。

(说个小插曲:我第一次在自己的电脑上启动 mini-agent,启动信息里赫然写着"skills 13 个",而项目里一个 skill 都没有。原来我家目录下的 ~/.agents/skills/ 里,早就躺着之前别的工具装进去的十几个 skill,mini-agent 照单全收了。用户级的 skill 对所有项目都生效,这既是方便,也是要留意的地方。)

1.3 实现

agent/skills/skills.go 一共一百多行,做三件事。

加载: 扫描目录,解析每一份 SKILL.md 的 frontmatter:

// Load 扫描若干目录下的 */SKILL.md。同名 skill 以先出现的为准(项目级优先于用户级)。
func Load(dirs ...string) ([]Skill, error) {

解析时有一个硬性检查:

if s.Description == "" {
    return Skill{}, fmt.Errorf("%s 的 frontmatter 缺少 description——没有描述,模型就不知道什么时候该用它", path)
}

description 是 skill 的灵魂。 模型完全是凭这一句话判断要不要加载它的。没有描述的 skill,等于不存在,所以直接报错,而不是悄悄地忽略。

放进 system prompt 的那一段: 只有名字和描述:

// PromptSection 是放进 system prompt 的那一小段:只有名字和描述。
func PromptSection(list []Skill) string {
    // ……
    sb.WriteString("<skills>\n下面是可用的技能包。当任务和某个技能的描述相符时,先调用 load_skill 读取它的完整说明,再按说明操作。\n")
    for _, s := range list {
        fmt.Fprintf(&sb, "- %s:%s\n", s.Name, s.Description)
    }
    sb.WriteString("</skills>")
    return sb.String()
}

第 24 篇讲过,system prompt 由几段拼成:基础 prompt → 项目记忆 → skills 列表 → 环境信息。skills 列表放在项目记忆之后、环境信息之前,它也是比较稳定的内容。

load_skill 工具: 按名字返回完整的正文:

func (t Tool) Run(_ context.Context, input json.RawMessage) (string, error) {
    // ……
    for _, s := range t.Skills {
        if s.Name == in.Name {
            return fmt.Sprintf("<skill name=%q dir=%q>\n%s\n</skill>", s.Name, s.Dir, s.Body), nil
        }
    }
    // 找不到:列出所有可用的 skill
    return "", fmt.Errorf("没有名为 %q 的技能。可用的有:%s", in.Name, strings.Join(names, ", "))
}

返回的内容里带着 skill 所在的目录 dir,这样正文里引用"同目录下的 template.md"时,模型知道去哪里找。工具定义的参数描述里,还会列出所有 skill 的名字作为可选值。

sequenceDiagram participant U as 用户 participant M as 模型 participant L as load_skill Note over M: system prompt 里有:
- release:发布新版本时使用 U->>M: 帮我发布 v1.2.0 M->>L: load_skill(release) L-->>M: 完整的发布流程 M->>M: 按流程:查 CI、改版本号、写 changelog……

1.4 加载一个 skill,也要问你

第一次在 default 模式下用 skill,你可能会有点意外:模型调用 load_skill 时,终端里弹出了一句"允许调用 load_skill 吗?"。

原因在第 27 篇的权限系统里:只读工具的名单里只有 read_file 和 list_files 两个,其他不认识的工具,在 default 模式下一律要问。load_skill 不在名单里,第 30 篇的 task 也不在。选一次 a,本次会话就不会再问了。

要不要把 load_skill 加进只读名单?从功能上看,它确实只读:它只是把一份磁盘上的说明文档返回给模型。但下一节会说到,skill 的内容本身也值得审视。问一次,至少让你知道"它刚刚加载了哪个 skill"。这是一个安全和便利之间的取舍,mini-agent 选了保守的那一边。

1.5 skill 也是一段会进上下文的文字

skill 的正文,会作为 load_skill 的结果原样进入上下文,模型会照着它去做。这意味着:一个 skill,就是一段你预先交给模型的指令。

项目级的 skill 跟着仓库走,改动有 git 记录、有人审查,问题不大。用户级的就要小心一点:1.2 节那个"skills 13 个"的小插曲说明,别的工具可能会往 ~/.agents/skills/ 里装东西,而你未必读过它们。网上分享的 skill 也一样:它的正文可能写着"执行这个脚本",而那个脚本你从没打开过。

所以对待 skill,要像对待第 23 篇的 MCP server 一样:装之前读一遍,尤其是它让模型去运行的那些命令和脚本。 好在 skill 是一份 Markdown,读一遍只要几分钟,比审查一个 MCP server 的代码容易得多。

1.6 skills 越来越多之后

渐进式披露让几十个 skill 也不会撑爆上下文,但它并没有让问题消失,只是换了个形式:

  • 描述的总量还是会涨:每个 skill 一行描述,一百个 skill 就是一百行,它们每次请求都在;
  • 描述会互相打架:一个"新增接口时使用",另一个"修改 API 时使用",模型面对一个"给接口加个参数"的任务,该加载哪个?第 20 篇说过工具功能重叠的问题,skill 也一样;
  • 模型可能不加载:描述写得不够贴切,模型觉得"这事我自己会",就直接上手干了,没有去读你精心写好的流程。

应对的办法和工具设计一样:描述写清楚"什么时候用",功能相近的合并,用不上的删掉。还有一个简单的检验办法:拿几个典型的任务跑一下,看看模型有没有在该加载的时候加载、不该加载的时候不加载。

1.7 skills、项目记忆、MCP 怎么分工

到这里,我们有了三种给 Agent "加知识、加能力"的方式,它们的分工是:

放什么什么时候进上下文
项目记忆(第 17 篇)每个任务都需要的:构建命令、约定、坑一直在
skills某些任务才需要的:操作流程、规范、套路描述一直在,正文用到时才加载
MCP(第 21-23 篇)新的动作:查工单、发消息、收藏书签工具定义一直在

第 23 篇最后说"什么时候不需要 MCP:一个 CLI 加一段说明往往就够了"。那段"说明"放在哪里?放进一个 skill 正合适:描述写"需要收藏网址时使用",正文写 bm add 和 bm search 的用法。平时只占一行,用到时才展开,比 MCP 那种"所有工具定义每次都带"省得多。

二、MCP 接入

2.1 配置

第 22 篇写了一个书签 MCP server,第 21 篇手写了一个 MCP 客户端。这一节把它们接进 mini-agent。

mini-agent 读取一个 .mcp.json 格式的配置文件,默认是工作目录下的 .mcp.json,可以用 -mcp 参数指定:

{
  "mcpServers": {
    "bookmark": {
      "command": "/tmp/mcp-bookmark",
      "args": ["-db", "/tmp/bookmarks.json"]
    }
  }
}

这是很多 Agent 工具都认的格式,同一份配置可以在不同的工具里共用。mini-agent 只支持 stdio 类型的 server(本地子进程),远程 HTTP 类型的会被跳过并给出提示。

2.2 连接

agent/mcp/mcp.go 的 Connect,对配置里的每一个 server:启动子进程、握手、列出工具。

// Connect 启动配置里的所有 server,握手并拿到工具列表。某个 server 失败不影响其他的。
func Connect(ctx context.Context, cfg *Config, warn func(string)) []*Server {

注意那句"某个 server 失败不影响其他的"。你接了三个 server,其中一个的路径写错了,不应该让整个 mini-agent 都启动不了。失败的那个打一行警告,其余的照常连接。

用的就是第 21 篇那个两百来行的 internal/mcpclient:Start 启动子进程,Initialize 握手,ListTools 拿工具列表。

2.3 把 MCP 工具包装成 mini-agent 的工具

关键的一步:每一个 MCP 工具,被包装成一个实现了 tools.Tool 接口的 remoteTool,注册进 mini-agent 的工具注册表:

// remoteTool 把一个 MCP 工具包装成 mini-agent 的 tools.Tool。
type remoteTool struct {
    server string
    client *mcpclient.Client
    def    mcpclient.Tool
}

func (t *remoteTool) Definition() llm.Tool {
    // ……
    return llm.Tool{
        Name:        ToolName(t.server, t.def.Name),
        Description: fmt.Sprintf("[来自 MCP server %s] %s", t.server, t.def.Description),
        InputSchema: schema,
    }
}

func (t *remoteTool) Run(ctx context.Context, input json.RawMessage) (string, error) {
    res, err := t.client.CallTool(ctx, t.def.Name, input)
    if err != nil {
        return "", fmt.Errorf("MCP 调用失败:%w", err)
    }
    if res.IsError {
        return "", errors.New(res.Text())
    }
    return res.Text(), nil
}

Definition 把 MCP 的工具定义翻译成模型 API 的格式(第 21 篇说过,几乎就是改个字段名);Run 把调用转发成 MCP 的 tools/call。第 21 篇区分过两种错误:协议错误(err)和工具执行出错(res.IsError),在这里都变成了 Run 返回的 error,再由注册表包装成 is_error: true 的 tool_result 交给模型。

第 25 篇把工具设计成接口的回报,在这里兑现了:对注册表、对 Agent 循环来说,remoteTool 和 read_file 没有任何区别。接入 MCP,Agent 的核心代码一行都没改。

flowchart LR M(("模型")) -->|"tool_use:
mcp__bookmark__add_bookmark"| R["Registry"] R --> RT["remoteTool.Run"] RT -->|"tools/call add_bookmark"| C["mcpclient"] C |"JSON-RPC over stdio"| S["mcp-bookmark 子进程"] style RT fill:#fff3e0,stroke:#e65100

2.4 工具名:mcp__server__tool

MCP 工具注册进 mini-agent 时,名字被改成了 mcp__<server>__<tool> 的形式:

// ToolName 生成 mcp__<server>__<tool> 形式的名字:一眼能看出工具来自哪个 server,也避免和内置工具重名。
func ToolName(server, tool string) string {
    n := "mcp__" + invalidChars.ReplaceAllString(server, "_") + "__" + invalidChars.ReplaceAllString(tool, "_")
    if len(n) > 64 {
        n = n[:64]
    }
    return n
}

三个原因:

  • 避免重名:两个 server 都有一个叫 search 的工具,或者某个 server 的工具也叫 read_file,加上前缀就不会冲突;
  • 一眼看出来源:模型和用户看到 mcp__bookmark__add_bookmark,就知道这是书签 server 的工具。描述前面也加了 [来自 MCP server bookmark];
  • 方便权限系统识别:下面马上讲。

名字里不合法的字符替换成下划线,总长度限制在 64 个字符以内,因为很多模型 API 对工具名的格式和长度有限制。

2.5 MCP 工具的权限

第 23 篇讲过,MCP server 是第三方的代码,它的工具描述可能被投毒,它的返回结果也可能藏着指令。所以在权限系统里,MCP 工具被当成"不可信"的来源:

  • 它们不在只读白名单里:默认模式下,每次调用 MCP 工具都要用户确认(第 27 篇)。哪怕 server 自己声称这个工具是只读的(第 22 篇的工具注解),mini-agent 也不信;
  • 调用过 MCP 工具之后,上下文就被"污染"了:之后任何可能把数据发到外部的操作,都必须由人逐次确认。这是第 38 篇"致命三件套"防护的一部分,在 agent/permission/permission.go 里,所有 mcp__ 开头的工具,既算"不可信的来源",也算"可能往外发数据的操作"。

mcp__ 这个前缀,让权限系统只用一行 strings.HasPrefix 就能识别出所有的 MCP 工具。

这两条叠在一起,会产生一个你在实验里马上就能看到的效果。第一次调用 MCP 工具时,它和其他工具一样问你,你可以选 a;但这一次调用一完成,上下文就被标记成"被污染了",而 MCP 工具本身又算"可能往外发数据的操作"。于是从第二次开始,每一次 MCP 调用都会走"逐次确认"的那条路,之前选过的 a 不再管用,提示前面还多了一个 ⚠。

这不是 bug,是有意的:第 23 篇的投毒演示里,危险的恰恰是"第二次调用",即模型在读了一个工具的结果之后,把数据塞进另一个工具的参数里。每一次都让人看一眼参数,是代价最低的防线。代价是用 MCP 工具时要多按几次 y,如果这个 server 是你自己写的、完全可信,可以用 -no-guard 关掉这道防护,但第 38 篇会告诉你,为什么最好不要。

2.6 几个没有处理的边角

MCP 接入这部分代码不长,有几种情况它是刻意没有处理的:

  • 名字截断后可能撞车:ToolName 把名字截到 64 个字符。如果一个 server 有两个工具,名字前面很长一段都一样,截断之后可能变成同一个名字,而注册表的 Add 是按名字存的,后注册的会悄悄覆盖先注册的。实际中很少遇到,但如果你接的 server 工具名特别长,留意一下启动时的工具数量对不对;
  • server 中途崩了,不会自动重连:子进程退出之后,之后每次调用它的工具,都会得到一个"MCP 调用失败"的错误。模型看到错误,通常会换个办法或者告诉你;要恢复,只能重启 mini-agent;
  • 只支持 stdio:远程的 HTTP server 会被跳过。第 23 篇说过,远程 server 要处理鉴权,这超出了一个教学用客户端的范围;
  • server 的 instructions 没有用:第 21 篇 6.1 节说过,握手时 server 返回的使用说明,mini-agent 读到了,但没有放进 system prompt。

这些都不影响"接一个本地的、可信的 MCP server 来用"这个最核心的场景。如果你要把 mini-agent 用在更复杂的环境里,它们就是你的改进清单。

三、组装:main.go

把所有东西组装起来,是 cmd/mini-agent/main.go 的事。按顺序:

reg := tools.NewRegistry(
    tools.ReadFile{WS: ws},
    tools.ListFiles{WS: ws},
    tools.EditFile{WS: ws},
    tools.RunBash{WS: ws},
    tools.FetchURL{}, // 第 38 篇
)
reg.MaxOutput = *maxOutput

// 第 31 篇:skills。system prompt 里只放名字和描述,需要时 load_skill 读全文。
home, _ := os.UserHomeDir()
skillList, err := skills.Load(filepath.Join(wd, ".agents", "skills"), filepath.Join(home, ".agents", "skills"))
// ……
if len(skillList) > 0 {
    reg.Add(skills.Tool{Skills: skillList})
}

// 第 31 篇:MCP。配置里的每个 server 的工具都注册成 mcp__<server>__<tool>。
mcpCfg, err := mcp.LoadConfig(*mcpConfig)
// ……
servers = mcp.Connect(context.Background(), mcpCfg, func(w string) { fmt.Fprintln(os.Stderr, w) })
defer mcp.Close(servers)
for _, s := range servers {
    for _, t := range s.Tools {
        reg.Add(t)
    }
}

// ……
system := agent.SystemPrompt(wd, agent.ProjectMemory(wd), skills.PromptSection(skillList))
a := agent.New(model, system, reg, ui)

内置工具、skill 工具、MCP 工具,全部加进同一个注册表。system prompt 由基础 prompt、项目记忆、skills 列表、环境信息拼成。没有 skill 时不注册 load_skill,没有 MCP 配置时不连任何 server。

defer mcp.Close(servers):mini-agent 退出时,关掉所有 MCP server 的子进程,不留下孤儿进程。

四、里程碑:一个能用的迷你 coding agent

写到这里,第五部分的八篇就完成了。回头看看 mini-agent 现在有什么:

能力实现篇目
循环Agent.Send:调模型 → 跑工具 → 喂结果,直到模型说完成24、25
工具read_file、list_files、edit_file、run_bash、task、load_skill、MCP 工具25-27、30、31
权限三种模式、白名单、黑名单、人工确认、限制工作目录25、27
上下文输出截断、token 预算、自动压缩、子 Agent 隔离28、30
稳定性流式输出、指数退避重试、截断与中断处理24、29
扩展项目记忆、skills、MCP17、31
flowchart TB U["你"] --> REPL["REPL / headless"] REPL --> A["Agent 循环"] A --> P["权限"] P --> T["工具注册表"] T --> T1["读 / 列 / 改 / 跑命令"] T --> T2["task → 子 Agent"] T --> T3["load_skill"] T --> T4["MCP 工具"] A --> C["上下文管理
截断 / 预算 / 压缩"] A --> L["llm:流式 + 重试
DeepSeek / 演示模型"] style A fill:#fff3e0,stroke:#e65100

它能读一个陌生的项目,能定位 bug,能改代码,能跑测试验证,能在上下文快满时自己压缩,能派出分身去调研,能按需加载操作手册,能用别人写的 MCP 工具。所有测试都不需要 API Key:

go test ./agent/... ./internal/...

当然,它和 Claude Code、Codex 这样的成品还有很大的差距:没有计划模式,没有待办清单,没有 IDE 集成,没有操作系统级的沙箱,工具也少得多。下一部分我们就去拆解成品,看看这些差距在哪里,以及哪些"最佳实践"其实是从设计里自然推出来的。

这八篇里,我们还顺手攒下了一份"已知的不足",每一条都被写成了某一篇的练习:

不足在哪一篇
按 Ctrl+C 显示成"错误"第 24 篇坑 7
edit_file 返回的片段可能找错位置第 26 篇 6.1 节
run_bash 继承全部环境变量,echo 能打印密钥第 27 篇 1.6 节
只有整段压缩,没有"只清旧工具结果"第 28 篇 4.6 节
没有空闲超时、没有上下文超长后的自动恢复第 29 篇 2.7、2.8 节
子 Agent 有 run_bash,能改文件第 30 篇 2.5 节
MCP 的 instructions 没用上、server 崩了不重连第 21 篇 6.1 节、本篇 2.6 节

一个"能用"的 Agent,和一个"没有已知问题"的 Agent,是两回事。 成品 Agent 也一样有它们的已知问题,只是你看不到它们的代码。自己写过一遍,你至少知道该从哪里开始怀疑。

五、动手

要看模型自己决定加载 skill、调用 MCP 工具,需要接真实模型:先 export DEEPSEEK_API_KEY=sk-...。MCP 要启动子进程,在线 Playground 演示不了。

实验 1:写一个 skill

在配套仓库的根目录(或者你自己的项目里),新建一个 skill:

mkdir -p .agents/skills/add-example
cat > .agents/skills/add-example/SKILL.md <<'EOF'
---
name: add-example
description: 给某一篇文章新增配套实验时使用
---

# 新增实验

1. 目录名是 examples/chNN-<主题>,NN 是两位数的篇号;
2. 里面放一个 main.go 和一个 README.md;main.go 开头的注释写清楚怎么运行;
3. 如果实验不需要子进程、git、键盘输入,在 web/experiments.js 里加一项,让它能在 Playground 上跑;
4. 改完跑 go vet ./... 和 bash web/build.sh。
EOF

启动 mini-agent,看启动信息里的 skills 数量。然后说:"给第 41 篇加一个配套实验,演示一下 prompt caching 的命中率。"看看它是不是先调用了 load_skill,再按说明去做。

(.agents/ 这个目录不想提交的话,记得加进 .gitignore。)

实验 2:接入书签 server

go -C mcp-bookmark build -o /tmp/mcp-bookmark .   # 独立模块,用 -C 进它的目录编译
go run ./cmd/mini-agent -mcp agent/mcp/example.mcp.json

启动信息里会显示"MCP server 1 个",工具数量也多了 3 个。说:"把 https://go.dev/blog 收藏一下,打上 go 标签。"留意两件事:

  1. 它调用的工具名是 mcp__bookmark__add_bookmark;
  2. 调用之前,mini-agent 问你"允许调用 mcp__bookmark__add_bookmark 吗?"。MCP 工具每次都要确认。

实验 3:用 skill 代替 MCP

按 1.7 节表格下面那段的思路,试试"CLI 加 skill"这条路:

  1. 假装你有一个命令行工具 bm(不用真的写,或者用一个打印参数的小脚本代替);
  2. 新建一个 skill,描述写"需要收藏网址或查找已收藏的网址时使用",正文写 bm add <url> --tag <标签> 和 bm search <关键词> 的用法;
  3. 不带 -mcp 参数启动 mini-agent,说同样的"把 https://go.dev/blog 收藏一下"。

对比实验 2:启动时的工具数量、每次请求的工具定义大小、模型调用的是 load_skill 加 run_bash,还是 mcp__bookmark__add_bookmark。第 23 篇说过,对只给自己用的工具,后一种往往不是必须的。

实验 4:跑测试

go test ./agent/skills ./agent/mcp -v

TestLoadAndProgressiveDisclosure 检查了:项目级的 skill 覆盖了用户级的同名 skill;system prompt 里只有名字和描述,没有正文;load_skill 能返回正文;找不到时会列出可用的 skill。TestMissingDescriptionIsAnError 检查了缺少描述时会报错。

六、踩坑记录:扩展能力的六个坑

坑 1:skill 的描述写得太含糊。
"一些有用的发布相关内容",模型不知道什么时候该加载它。描述要写清楚"什么时候用":发布新版本时使用、新增接口时使用。描述是模型唯一的判断依据。

坑 2:把该放项目记忆的东西放进了 skill。
构建命令、测试命令,几乎每个任务都要用,放进 skill,模型每次都要先加载一次,多绕一圈。每次都需要的,放项目记忆;某些任务才需要的,放 skill。

坑 3:一个 MCP server 连不上,整个 Agent 起不来。
某个 server 的路径写错了,或者它启动时崩了。连接失败要隔离:打一行警告,其他的照常工作。

坑 4:MCP 工具名和内置工具撞了。
某个 server 也有一个 read_file,注册进来,把内置的覆盖了,行为完全变了还不知道。加上 mcp__<server>__ 前缀。

坑 5:信任 MCP server 自己声称的"只读"。
给它的工具开了免确认。第 23 篇的投毒演示,就是利用这一点。对第三方的工具,按"它可能做任何事"来对待。

坑 6:用户级目录里装了一堆没读过的 skill。
别的工具、网上分享的合集,往 ~/.agents/skills/ 里一装就是十几个。它们的描述每次都在你的上下文里,正文随时可能被加载、被照着执行。装之前读一遍,不用的删掉。

一句话总结

skills 是渐进式披露:system prompt 里只放每个 skill 的名字和一句话描述,模型判断用得上时再用 load_skill 加载完整说明,描述是它唯一的判断依据;MCP 接入是把每个远程工具包装成一个 remoteTool,名字加上 mcp__server__ 前缀,权限上按不可信来源对待。至此,一个能读、能改、能跑、能管上下文、能派分身、能扩展的迷你 coding agent 诞生了。

flowchart LR S["skills
名字 + 描述 → 按需加载正文"] --> R["工具注册表"] M["MCP server
→ mcp__server__tool"] --> R B["内置工具"] --> R R --> A["mini-agent
里程碑达成"] style A fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭