跳到主要内容

技能与工具:SKILL.md 渐进式披露 + MCP 集成

30 秒导读: 这一章讲 CowAgent 的「手」。模型只会说话,真正干活要靠工具(读文件、跑 bash、搜网页——原子能力),而技能是更高一层的「可插拔能力包」:用一份 SKILL.md 写清「什么时候用、怎么做」,把多个工具编排成一套工作流。两者接入 prompt 的方式截然不同:工具整份 schema 注入、模型可直接调用;技能只把一行 <description> 放进 prompt,模型判断相关后再用 read 工具读全文——这就是「渐进式披露」。本章还讲工具体系怎么被 ToolManager 单例管理、MCP 外部工具怎么热插拔进来、工具太多时怎么按需向量检索只注入最相关的那几个。


1. 这是什么(零基础也能懂)

先把两个最容易混的词分清楚。它们不是一回事,接入 agent 的方式也完全不同。

概念是什么粒度模型怎么用
工具 (tool)一个原子能力:读文件 / 跑 shell / 搜网页 / 发消息单个函数prompt 里给完整 schema,模型直接发 tool call 调用
技能 (skill)一份 SKILL.md 描述的高层工作流,内部编排多个工具一整套流程prompt 里只给一行描述+路径;相关时先 read 全文再照做

一句话类比: 工具像厨房里的单件电器(烤箱、搅拌机),插上就能用;技能像一张菜谱卡——它本身不会做菜,而是告诉你「先用搅拌机、再进烤箱 180 度 20 分钟」。菜谱不能"执行",只能"读了照做"。

为什么要这么分? 因为工具数量有限、都得放进 prompt;而技能可以有几十上百个,全文塞进 prompt 会撑爆上下文。于是 CowAgent 让技能先只露一行简介,模型觉得用得上了才去读细节——省 token,又能挂载海量能力。

用起来什么样(技能视角): 系统提示里模型会看到这样一段(由 agent/prompt/builder.py 拼装):

<available_skills>
<skill>
<name>knowledge-wiki</name>
<description>Manage the personal knowledge wiki. Use when the user shares
articles, documents, or asks to organize knowledge...</description>
<location>/path/to/skills/knowledge-wiki/SKILL.md</location>
</skill>
</available_skills>

模型读到用户说「帮我把这篇文章整理进知识库」,匹配上 knowledge-wiki 的描述,于是它调用 read 工具打开那个 <location>,拿到完整 SKILL.md,再按里面的步骤(用 read/write/bash 等真实工具)去落地。

本节到此为止不碰底层。记住这张心智图就行:工具=能直接调的手;技能=读了才照做的说明书。


2. 顶层全景(它大概怎么转)

CowAgent 的「能力供给」有两条独立管线,最后在一次 LLM 调用里汇合。

┌──────────────── 技能管线(说明书)────────────────┐
skills/ (内置) ─┐ │ SkillManager │
workspace/skills ├──▶│ ①扫两处目录 → ②合并启用状态 → ③过滤(平台/依赖) │
(自定义) ─┘ │ → 只把 <description>+<location> 拼进 prompt │──┐
└───────────────────────────────────────────────────┘ │

┌──────────────── 工具管线(真正的手)────────────┐ 系统
agent/tools/* ─┐ │ ToolManager (进程级单例) │ prompt
(内置工具) ├──▶│ ①load_tools 扫类 → ②_load_mcp_tools 后台起 MCP │──▶ +
~/cow/mcp.json ─┘ │ → tool_classes{} + _mcp_tool_instances{} │ tools[]
(MCP 外部工具) └───────────────────────────────────────────────────┘ │

每一轮:_select_tools_for_injection()
(工具太多时按语义只注入 top_k 个 MCP)

怎么读这张图:左边是两类"能力来源",各自被一个 Manager 收编;技能只贡献文字描述、工具贡献可调 schema;两者在每一轮推理前拼进发给 LLM 的请求。

各部件一句话职责:

部件干什么在哪个文件
SkillManager加载/合并/过滤技能,产出技能 promptagent/skills/manager.py:17
SkillLoader从两处目录扫描 SKILL.md 与根级 .mdagent/skills/loader.py:13
format_skills_for_prompt把技能渲染成 <available_skills> XMLagent/skills/formatter.py:9
should_include_skill按平台/依赖/API key 判断技能可不可用agent/skills/config.py:69
ToolManager单例;加载内置工具类 + MCP 工具实例agent/tools/tool_manager.py:31
McpTool把一个 MCP 工具包成 BaseToolagent/tools/mcp/mcp_tool.py:5
select_mcp_tools按语义相似度选出本轮要注入的 MCP 工具agent/tools/mcp/tool_retrieval.py:84
_select_tools_for_injection执行器里决定这一轮注入哪些工具agent/protocol/agent_stream.py:711

技能描述具体怎么被塞进系统提示的哪个区块、每轮 prompt 如何从零重建,见 02-system-prompt.md;工具调用如何驱动多轮循环,见 01-agent-loop.md。本章只讲「手」和「能力包」本身。


3. 核心原理之一:技能的渐进式披露

3.1 它要解决的小问题

技能可能有几十个,每份 SKILL.md 动辄几百行。全塞进 prompt → token 爆炸、还稀释注意力。可完全不给 → 模型不知道有这能力。

CowAgent 的解法是「渐进式披露 (progressive disclosure)」:分两级曝光。第一级极便宜——只给每个技能一行简介和文件路径;第二级才昂贵——模型确认相关后,自己用 read 工具去读全文。

3.2 思路:技能不是工具,是「读了照做」的说明书

这是全章最关键的一点,builder.py 在系统提示里对模型明说了这条规矩(agent/prompt/builder.py:330):

重要: 技能不是工具,不能直接调用。使用技能的唯一方式是用 read 读取 SKILL.md 文件,然后按文件内容操作。

也就是说,knowledge-wiki 不是一个可以 tool_call("knowledge-wiki") 的东西。它只是一段文字 + 一个路径。模型要用它,唯一动作是「read 那个路径」。

流程走一遍:

用户: "把这篇文章整理进知识库"


① 模型扫 prompt 里 <available_skills> 各条 <description>
│ (便宜:每条就一句话)

② 命中 knowledge-wiki 的描述


③ 模型调用 read 工具, path = <location> ← 第二级披露在此发生


④ 拿到完整 SKILL.md,按其中步骤用 read/write/bash 落地

3.3 原理演示(示意,非源码)

把「只给描述」这一层用几行代码演出来——重点看:进 prompt 的不是全文,是 description

# 示意,非源码:技能只曝光"目录卡片",全文留在磁盘
def build_skills_prompt(skills):
lines = ["<available_skills>"]
for s in skills:
lines.append(" <skill>")
lines.append(f" <name>{s.name}</name>")
lines.append(f" <description>{s.description}</description>") # 就这一句
lines.append(f" <location>{s.file_path}</location>") # 全文在这个路径
lines.append(" </skill>")
lines.append("</available_skills>")
return "\n".join(lines)
# 重点看:s.content(几百行正文)从不进 prompt,靠 <location> 让模型按需 read

3.4 真实实现

渲染逻辑就在 format_skills_for_prompt,一眼可见它只取 name / description / location / base_dir,正文 skill.content 完全不写入(agent/skills/formatter.py:30-37):

for skill in visible_skills:
lines.append(" <skill>")
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
lines.append(f" <location>{_escape_xml(skill.file_path)}</location>")
lines.append(f" <base_dir>{_escape_xml(skill.base_dir)}</base_dir>")
lines.append(" </skill>")

SkillManager.build_skills_prompt 是入口:它先 filter_skills 拿到可用技能,渲染成上面这段,再把「已安装但依赖没配好」的技能也附一小段设置提示(agent/skills/manager.py:253)。

builder._build_skills_section 则在这段 XML 前面加上那条铁律说明,并动态解析出 read 工具的真实名字填进指令里(工具重命名了指令也不会失效,agent/prompt/builder.py:289-301)。

3.5 关键细节:调用不存在的「技能名」会被反向引导

模型有时会误以为技能能直接调用,发出一个名为 knowledge-wiki 的 tool call。CowAgent 没有粗暴报错,而是把这当成一次教学机会:_build_tool_not_found_message 发现「这不是工具,但存在同名技能」,就当场把整份 SKILL.md 读出来塞回错误信息里,引导模型改用真实工具照做(agent/protocol/agent_stream.py:1307)。

skill_entry = skill_manager.get_skill(tool_name) # 用"找不到的工具名"去查技能
...
return (
f"Tool '{tool_name}' is not a built-in tool, but a matching skill "
f"'{skill.name}' is available. You should use existing tools (e.g. bash with curl) "
f"to accomplish this task following the skill instructions below:\n\n"
f"--- SKILL: {skill.name} (path: {skill_md_path}) ---\n"
f"{skill_content}\n" # 全文喂回去
f"--- END SKILL ---\n\n"
f"Available tools: {available_tools}"
)

妙在:一次「误调用」反而替模型完成了第二级披露——它想调的东西没有,但它需要的知识被直接送到手上。


4. 核心原理之二:技能怎么被加载、合并、过滤

模型看到的那份技能清单,背后是三步:加载 → 合并启用状态 → 过滤

4.1 加载:两处目录 + 两种发现规则

SkillLoader.load_all_skills 扫两个来源,同名时自定义覆盖内置(agent/skills/loader.py:220-265):

来源目录source 标记优先级
内置项目根 skills/builtin
自定义workspace/skills/custom高(覆盖同名)

发现规则有两种(agent/skills/loader.py:19 的 docstring 与 _load_skills_recursive):

  • 根目录直接的 .md 文件 → 每个算一个技能。
  • 子目录里的 SKILL.md → 这个子目录被当作一个自包含技能,一旦命中 SKILL.md停止再往下递归——防止技能合集内部的子技能(如 style-collection/style-anjing)被当成独立顶层技能列出来(agent/skills/loader.py:76-85)。

每个文件由 _load_skill_from_file 解析:parse_frontmatter 抽出 YAML frontmatter 的 name/description,没有 description 的技能会被丢弃并记一条诊断(agent/skills/loader.py:160-162)。

4.2 合并:skills_config.json 记住用户开关

技能可以被用户在控制台里启用/停用。这份「开不开」的状态存在 workspace/skills/skills_config.json,由 _sync_skills_config 每次刷新时与磁盘扫描结果对账(agent/skills/manager.py:83):

磁盘扫到的技能 × 上次存的 skills_config.json
│ │
▼ ▼
┌───────────────────────────────────┐
│ 新技能 → 用 metadata.default_enabled 定初始开关 │
│ 老技能 → 保留用户之前存的 enabled │
│ 已删除的 → 从 config 里移除 │
│ 描述/来源 → 永远用最新扫描刷新 │
└───────────────────────────────────┘

关键取舍:新技能的初始开关由 SKILL.md frontmatter 的 default_enabled 决定(agent/skills/manager.py:100-103,类型见 agent/skills/types.py:32),而已存在技能则以用户意愿为准——升级带来的新技能不会悄悄默认开启,用户手动关掉的也不会被"复活"。

4.3 过滤:平台 / 依赖 / API key 决定"可不可用"

filter_skills 在渲染前再筛一遍,核心判据是 should_include_skill(agent/skills/config.py:69):

判据规则不满足时
metadata.os当前平台要在支持列表里直接排除
metadata.always置 true 则无视其他要求一律纳入
requires.bins所有二进制都要在 PATH排除
requires.anyBins至少一个二进制存在排除
requires.env所有环境变量(API key)都要设置排除
requires.anyEnv至少一个环境变量存在排除

设计哲学很直白(见 docstring):要求满足就自动启用,不满足就不给;key 填错则照样启用、留给运行时报错让 LLM 处理。被"依赖没配好"卡住的技能不会消失,而是走 filter_unavailable_skills + get_missing_requirements(agent/skills/config.py:142),在 prompt 末尾以 <unavailable_skills> 形式附一句「缺什么、怎么配」,让模型能引导用户补齐(agent/skills/formatter.py:54)。

4.4 新技能即时生效

用 skill-creator 造完新技能后不用重启:执行器发现 bash 跑了 init_skill.py 就自动 refresh_skills(),重新走一遍加载→合并→过滤(agent/protocol/agent_stream.py:1275-1280)。


5. 核心原理之三:工具体系与 ToolManager 单例

5.1 一个工具长什么样

所有工具继承 BaseTool,契约极小:类属性 name / description / params(JSON Schema),加一个 execute(params) -> ToolResult(agent/tools/base_tool.py:30)。以 read 为例(agent/tools/read/read.py:22):

class Read(BaseTool):
name: str = "read"
description: str = "Read or inspect file contents. ..."
params: dict = {
"type": "object",
"properties": {"path": {...}, "offset": {...}, "limit": {...}},
"required": ["path"],
}
def execute(self, args): ... # 真正读文件

description + params 就是发给 LLM 的工具定义;execute 是模型发来 tool call 后真正跑的逻辑。工具是能直接调的——这正是它和技能的根本区别。

5.2 ToolManager:进程级单例

ToolManager__new__ 实现单例(agent/tools/tool_manager.py:31-43)。为什么必须单例?因为 MCP 子进程很贵——起一个 npx/uvx 要几秒到几十秒,绝不能每个会话都重起一遍。单例让所有 per-session agent 共享同一批 MCP 工具实例

load_tools 分两步(agent/tools/tool_manager.py:91):

  1. _load_tools_from_initagent.tools.__all__ 反射出所有 BaseTool 子类,实例化一次拿到 name,把(不是实例)存进 tool_classes{}。需要特殊初始化的(记忆工具、McpTool)在此跳过,后面动态注册。
  2. _load_mcp_tools 触发 MCP 加载(下一节)。

取用时 create_tool(name) 先查内置 tool_classes,查不到再落到 _mcp_tool_instances(agent/tools/tool_manager.py:709-732)——内置与 MCP 工具对调用方是统一命名空间


6. 核心原理之四:MCP 集成(可插拔的外部工具)

MCP(Model Context Protocol,一套让 agent 连外部工具服务器的标准协议)让 CowAgent 能挂载第三方能力:一个 MCP server 暴露若干工具,每个被包成一个 McpTool

6.1 McpTool:把协议工具伪装成本地工具

McpTool 继承 BaseTool,__init__ 时把 MCP 返回的 schema(name/description/inputSchema)填进标准字段,execute 转发给 client.call_tool(agent/tools/mcp/mcp_tool.py:5-31)。对上层而言,一个 MCP 工具和一个内置工具长得一模一样

6.2 后台异步启动,绝不阻塞首条消息

_load_mcp_tools 的关键设计:立刻返回,把起服务器丢进后台线程(agent/tools/tool_manager.py:332)。理由——内置工具不依赖 MCP,没必要让用户为几十秒的 npx 启动等待。

它还是幂等的:_mcp_lock 里检查 _mcp_loaded,第一个加载线程一派发就翻标志位,后续 per-session agent 初始化再调用直接 no-op,不会重复 fork 子进程(agent/tools/tool_manager.py:344-372)。

后台 worker _load_mcp_tools_async 逐个把 server 拉起来,故障隔离——一个坏 server 不影响其它,也绝不把异常抛出线程(agent/tools/tool_manager.py:460):

for cfg in servers:
client = McpClient(cfg)
if not client.initialize(): # 握手失败
status = "needs_auth" or "failed"; continue # 跳过,不拖累别人
for schema in client.list_tools():
_mcp_tool_instances[name] = McpTool(client, schema, server_name) # 就绪即发布
registry._clients[server_name] = client # 工具可见后才登记 client
status = "ready"

注意顺序:工具先进 _mcp_tool_instances,client 才登记进 registry,保证调用方永远看不到「半加载」的 server(agent/tools/tool_manager.py:504-510)。

6.3 热重载:改了 mcp.json 不用重启

refresh_mcp_if_changed 在每次 agent 创建时被调,快路径是单次 os.stat + sha256——~/cow/mcp.json 没变就立即返回,零成本(agent/tools/tool_manager.py:378)。变了才做 diff 式重载:

变化动作
新增 server后台起它
删除 server_teardown_mcp_server 关进程、摘掉它的工具
改了配置先拆后起(重启)
没动的原样运行

它由 bridge/agent_bridge.py:661用户回复发出之后的后台线程里触发,任何成本都不加进用户延迟。

6.4 运行中并入新工具:sync_mcp_into_agent

一个 agent 创建时 MCP 可能还没加载完。sync_mcp_into_agent 在每轮 LLM 调用前做一次廉价字典对账(微秒级):把加载完成的新 MCP 工具加进这个 agent、把已下线的摘掉,内置工具不动(agent/tools/tool_manager.py:556,调用点 agent/protocol/agent_stream.py:803-807)。这让 agent 无需重启会话就能中途拿到新上线的 MCP 工具。

它同时兼容两种工具容器:AgentStream.toolsdict、默认 Agent.toolslist,两条分支分别处理。还有一道安全闸:自进化审查 agent(_evolution_restricted)一律不注入 MCP 工具,以免绕过它被刻意收窄的工具边界(见 06-self-evolution.md)。


7. 核心原理之五:按需工具检索(MCP 太多时只注入 top_k)

7.1 问题:MCP 工具会多到撑爆 schema

挂几个 MCP server,工具轻松上百。全部注入 → tool schema 巨大、烧 token、还干扰模型选择。CowAgent 的解法:语义检索,每轮只注入最相关的 top_k 个 MCP 工具;内置工具永远全量注入(技能和核心流程硬依赖它们)。

默认关闭,超阈值才启用(config.py:277-279):

配置默认含义
mcp_tool_retrieval_enabledFalse总开关
mcp_tool_retrieval_threshold20MCP 工具数超过才检索
mcp_tool_retrieval_top_k10每轮最多注入几个 MCP 工具

7.2 三个纯函数 + 一个执行器编排

检索逻辑拆成 agent/tools/mcp/tool_retrieval.py 里的无状态纯函数,向量的预计算和缓存则留在 ToolManager(工具生命周期的主人):

  • build_retrieval_query:把最近 5 条消息的文本拼成检索 query(tool_retrieval.py:36)。为什么不用最初那句用户问题?因为多轮工具循环里需求会漂移,近窗口才抓得住当前意图,又不会被大段序列化的 tool payload 污染(只保留 text 块)。
  • select_mcp_tools:算 query 与各工具向量的余弦相似度,取 top_k(tool_retrieval.py:84)。
  • _rank_by_similarity:有 numpy 走向量化、没有则纯 Python 兜底(tool_retrieval.py:134)。

执行器 _select_tools_for_injection 负责编排(agent/protocol/agent_stream.py:711):没开 / 没超阈值 / 拿不到 embedding provider / 任何异常 → 一律全量注入,工具绝不被静默丢弃

7.3 两个救命的不变量

这套检索最精妙的是两条「安全网」:

① 失败即降级为全注入。 select_mcp_tools 在没有 query 向量、索引为空、维度不匹配(说明索引是用另一个 embedding 模型建的)或任何异常时返回 None,调用方据此回退全量注入(tool_retrieval.py:107-131)。检索永远不会因为出错而让 agent 少一个工具。

② 只增不减(union-accumulate)。 每轮选出的集合会和「本 run 之前已选的」求并集(_retrieved_mcp_names),存回执行器(agent/protocol/agent_stream.py:752-763)。为什么?因为一个已经在消息历史里产生过 tool_use 的工具,若中途从 schema 里消失,Claude/MiniMax 会直接报「消息格式错误」。只增不减保证任何已用过的工具不会半途蒸发。

# 示意,非源码:union-accumulate 的直觉
selected_this_turn = top_k_by_similarity(query, tool_vectors) # 本轮排名
injected = already_selected_in_this_run | selected_this_turn # 并集:只增不减
# 这样上一轮调过的工具即使这轮相似度掉出 top_k,仍留在 schema 里

7.4 向量从哪来

ToolManager.get_mcp_tool_vectors 懒加载地为尚未缓存的 MCP 工具算 embedding(MCP 异步上线、工具会陆续出现),没有 embedding provider 或出错就返回空 dict、让调用方回退全注入,从不抛异常(agent/tools/tool_manager.py:637)。query 侧则用 embed_query(agent/tools/tool_manager.py:651)。用于嵌入的文本就是 "{name}: {description}"(agent/tools/tool_manager.py:690-695)——所以一个 MCP 工具的 description 写得好不好,直接影响它能不能被检中。


8. 巧妙之处(可借鉴的技术)

  • 技能 ≠ 工具,用「读文件」当第二级披露。 不发明新调用机制,复用已有的 read 工具就实现了按需加载海量能力包。载体是结构与 prompt 约定,不是代码开关。agent/skills/formatter.py:30agent/prompt/builder.py:330
  • 误调用反哺教学。 模型把技能名当工具调,系统不报错,反而把 SKILL.md 全文喂回去——把一次错误变成一次成功的知识投递。agent/protocol/agent_stream.py:1307
  • 单例 + 后台异步 + 幂等 三件套让昂贵的 MCP 子进程只起一次、不阻塞首条消息、多会话共享。agent/tools/tool_manager.py:332-372
  • 工具就绪先于 client 登记,杜绝调用方看到半加载 server。agent/tools/tool_manager.py:504-510
  • 热重载走「stat 快路径」,没改文件时几乎零成本,却支持改 mcp.json 即时生效。agent/tools/tool_manager.py:378
  • 检索的两条不变量(失败降级全注入、只增不减)把「省 token」这个优化做成了永不损害正确性的优化。agent/tools/mcp/tool_retrieval.py:84

9. 边界与局限

  • 技能靠模型自觉。 是否读、读哪个 SKILL.md 全凭模型判断 <description> 的匹配度;描述写得含糊,技能就可能被漏用。系统层面只有那条「优先用技能」的指令在推动。
  • 技能不能直接执行。 它没有独立的执行沙箱,一切落地都得借道现有工具(bash/read/write…);SKILL.md 里写的步骤模型也可能不完全照做。
  • MCP 是最终一致,不是即时。 服务器后台加载,agent 靠每轮 sync_mcp_into_agent 才逐渐拿到工具;首条消息时新 MCP 工具可能还没就绪。
  • 检索质量取决于 embedding 与描述。 没有 embedding provider 就退化成全量注入;"name: description" 写得差会检不准。且它只裁剪 MCP 工具,内置工具恒定全量。
  • 检索维度必须一致。 换了 embedding 模型而索引没重建,维度不匹配会让 select_mcp_tools 直接返回 None 退回全注入(安全但失去省 token 效果)。

10. 代码地图(导航索引)

主题文件路径符号名
技能加载/合并/过滤/出 promptagent/skills/manager.pySkillManager / refresh_skills / _sync_skills_config / filter_skills / build_skills_prompt
两处目录扫描 + 发现规则agent/skills/loader.pySkillLoader / load_all_skills / _load_skills_recursive
只渲染 description+locationagent/skills/formatter.pyformat_skills_for_prompt / format_unavailable_skills_for_prompt
frontmatter / metadata 解析agent/skills/frontmatter.pyparse_frontmatter / parse_metadata
技能可用性(平台/依赖/env)agent/skills/config.pyshould_include_skill / get_missing_requirements
技能类型(default_enabled 等)agent/skills/types.pySkillMetadata / Skill / SkillEntry
技能 prompt 段 + 「技能非工具」铁律agent/prompt/builder.py_build_skills_section
误调用技能名 → 反哺 SKILL.mdagent/protocol/agent_stream.py_build_tool_not_found_message
工具基类契约agent/tools/base_tool.pyBaseTool / ToolResult / get_json_schema
内置工具样例agent/tools/read/read.pyRead
工具/MCP 单例管理器agent/tools/tool_manager.pyToolManager / load_tools / _load_mcp_tools / _load_mcp_tools_async / refresh_mcp_if_changed / sync_mcp_into_agent / get_mcp_tool_vectors / embed_query
MCP 工具包装agent/tools/mcp/mcp_tool.pyMcpTool
MCP 客户端(握手/列举/调用)agent/tools/mcp/mcp_client.pyMcpClient.initialize / list_tools / call_tool
按需工具检索(纯函数)agent/tools/mcp/tool_retrieval.pybuild_retrieval_query / select_mcp_tools / _rank_by_similarity
每轮工具注入决策agent/protocol/agent_stream.py_select_tools_for_injection
MCP 热重载触发点bridge/agent_bridge.pyrefresh_mcp_if_changed(后台调用)