本文是 AgentScope 2.0 源码解析系列 第 11 篇(Skill 模块)。
已发:Agent / Event / Message / Model / Tool / Permission / Middleware / RAG / Workspace / MCP | 下一篇:待定。
关注追更,后续会持续更新 Session / Memory 等模块。
整个 skill模块只有 3 个类、211 行:Skill(数据类)、SkillLoaderBase(抽象基类,仅一个抽象方法)、LocalSkillLoader(文件系统实现)。它可能是全系列最小的一个模块。它不负责 skill 的增删改查、不负责去重、不负责持久化、不负责冲突处理——这些全部压到了 workspace层的LocalWorkspace。skill 模块只定义「一个 skill 是什么」「一个 loader 怎么列出 skill」这两件事。Skill是个@dataclass,5 个字段全是只读数据(name/description/dir/markdown/updated_at),没有方法、没有状态机——它是一份「读进内存的说明书快照」。LocalSkillLoader的缓存键是文件的 mtime(os.path.getmtime),不是文件内容哈希。读 SKILL.md 前先比 mtime,没变就直接返回缓存对象——这是「文件未改动就不解析」的标准做法。Skill 不是 Tool。agent 不能直接调用 skill,必须先通过内置工具 SkillViewer读出 skill 的markdown正文,再按正文里的指令去用别的工具——这是「skill = 按需加载的说明书」这条核心隐喻在代码里的落地。
skill 模块把「什么是 skill」「怎么列出 skill」抽象成最小接口(一个 dataclass + 一个抽象方法),然后把所有脏活(去重、冲突、持久化、对账)全部留给上层 LocalWorkspace 去实现——这套「接口极薄、实现下沉到最需要它的那一层」的分层哲学,搬到任何「想提供开放扩展点、又不想把策略绑死在核心层」的框架都成立。
适合谁读:正在做 Agent 框架、想给 agent 加「可插拔能力包」的人,或想搞清楚 AgentScope 的 skill 跟 Anthropic Agent Skills 是什么关系的人。预计阅读:主线约 12 分钟(字段表较少,无需附录另计)。
一、这个模块到底在解决什么问题?
一句话:让 agent 能在运行时按需加载一份「说明书」,而不是把所有能力一股脑塞进系统提示。
如果你给 agent 塞过几十页的 prompt,多半遇到过两件事——一是 token 爆炸、上下文被挤爆;二是 agent 在一堆指令里挑错该用哪条。Anthropic 在 2025 年 10 月推出的 Agent Skills 概念就是冲着这两点来的:把一组「指令 + 脚本 + 资源」打包成一个目录,目录里放一份 SKILL.md(YAML frontmatter 写 name/description + 正文写详细指令),agent 启动时只把所有 skill 的 name/description 塞进系统提示,等真正要用时再按需把某份 SKILL.md 的正文读出来。
AgentScope 的 skill 模块就是这个机制在框架里的落地。它要做的事其实很少——只回答两个问题:
「一个 skill 长什么样」 → Skilldataclass「从哪里、怎么把 skill 列出来」 → SkillLoaderBase+LocalSkillLoader
至于 skill 怎么加、怎么删、怎么去重、怎么防路径穿越——这个模块一概不管。这是它和前面读过的 mcp、tool、permission 模块最大的不同:它刻意把自己做得很薄。
二、这个模块在整个框架中的位置
skill 模块是个「被四面八方依赖、自己几乎不依赖别人」的底座。

输入:一个本地目录路径(里面有若干 SKILL.md)。输出:一个 list[Skill]。依赖:只有标准库(os、asyncio)+ aiofiles(异步文件 IO)+ frontmatter(解析 YAML 头)。它不依赖框架里任何其他模块——这是它能被 tool、workspace、app 三层同时依赖的前提。被依赖:tool 层(ToolGroup 把 loader 和现成 Skill 一起收进工具分组;Toolkit 用 loader 列出 skill 写进系统提示;SkillViewer 是 agent 读取 skill 正文的唯一工具)、workspace 层(LocalWorkspace 在此之上建了一套完整的 skill 生命周期)、app 层(HTTP /skill 路由转发到 workspace)。
依赖方向是单向的:上面三层都往下指向 skill 模块,skill 模块谁也不指。这种「底座」定位决定了它必须薄——它一旦变重,上面三层都会跟着膨胀。
三、为什么这样设计?
这是本文想重点讲的部分。skill 模块的薄不是偷懒,是几条设计取舍共同作用的结果。
取舍一:为什么 Skill 是个 dataclass,不是个有方法的类?
看一眼 _base.py 就知道它有多朴素:
五个字段全是只读数据,没有一个方法。我一开始纳闷:为什么不给它加个 to_prompt()、is_valid() 之类的方法?
读到 SkillViewer 才明白——skill 一旦读进内存,它的唯一命运就是被原样塞进 prompt。SkillViewer.call() 里那行 return ToolChunk(content=[TextBlock(text=target_skill.markdown)]) 就是全部:把 markdown 字段当字符串吐给 agent,结束。既然「读进来 → 吐出去」中间没有任何变换,给它方法反而是过度设计。dataclass 在这里是「刚好够用」的刻度。
取舍二:为什么抽象基类只有一个方法?
SkillLoaderBase 全文:
一个抽象方法,list_skills()。我读到这里时特意去翻 tool/_tool_group.py 看 ToolGroup 怎么用它——它把 loader 和现成的 Skill 对象一起塞进 skills_or_loaders 列表(L83-95),需要时统一调 list_skills()。也就是说,上层只认 list_skills() 这一个契约,至于你是从本地磁盘扫、从数据库查、还是从远程 HTTP 拉,上层完全不关心。
这就是为什么基类只有一个方法:再多一个,就把实现策略绑死了。比如要是有个 get_skill(name),那远程 loader 就得为「按名查单条」单独实现一套;现在只有 list_skills(),远程 loader 一次拉全量就够了。抽象基类的方法数 = 你愿意承诺的不变量数,承诺越少,实现越自由。
取舍三:为什么脏活全甩给 workspace?
这可能是最反直觉的一点。你去看 LocalWorkspace(workspace 模块),它围绕 skill 干了一大堆事:
SHA-256 去重:算 SKILL.md 内容的哈希,重复的直接 skip 目录名冲突解决: _sanitize_dir_name把非法字符替换掉,重名就加_1_2agent 可见名冲突解决:重名就加 (1)(2)路径穿越防御: os.path.realpath检查目标路径没跑出skills_dirmtime 对账:比较 skills_dir的 mtime 和索引里记录的 mtime,不一致就_reconcile_skills_dir重新扫一遍,发现手动加/删的目录自动同步索引
这些事 skill 模块一件都没做。为什么?
因为这些策略只对「需要持久化的 workspace」成立。换一个场景——比如 ToolGroup 里直接传一个目录路径让 loader 扫——根本不存在去重、不存在持久化索引、不存在「手动删了目录要同步」的问题,loader 每次全量扫一遍就完事。如果把去重和对账塞进 LocalSkillLoader,那 ToolGroup 这种轻量场景就被迫背上了一堆用不上的逻辑。
所以作者的选择是:loader 只管「扫」,workspace 只管「管」。这是一条很干净的职责边界——loader 是无状态的工具,workspace 是有状态的拥有者。
workspace 层那套对账机制(mtime 驱动、SHA-256 去重、路径穿越检查)本身就是值得单独读的设计,我们已经在第 9 篇 workspace 模块里展开过,这里只点出它和 skill 模块的边界关系。
四、跟着我阅读源码
skill 模块只有三个文件,按下面顺序读最顺。
必读 1:_base.py(29 行)—— 两个核心定义
整个文件的实质就两段,一段是上面的 Skill dataclass,一段是 SkillLoaderBase。读这个文件要带一个问题:为什么作者把「数据」和「行为」彻底分开放在两个类里? 答案在取舍一、取舍二里——数据是结果,行为是过程,把过程抽象成基类、结果抽象成 dataclass,上层就能在「不关心过程」的前提下复用结果。
Skill 字段表(核心,必读)
name | str | name (1) | |
description | str | ||
dir | str | os.path.abspath 规范化 | |
markdown | str | frontmatter.loads().content 提取 | |
updated_at | float | os.path.getmtime |
这五条里最该记住的是 description 的地位:它不是给人看的备注,而是写进 agent 系统提示、决定 skill 能否被正确触发的关键文本。Anthropic 的文档反复强调「description 写得好不好直接决定 skill 命中率」,源头就在这里——Toolkit 把它原样塞进 DEFAULT_SKILL_INSTRUCTION 模板。
必读 2:_local_loader.py(171 行)—— 真正干活的就两个方法
LocalSkillLoader 有三个公开/私有方法,但真正值得读的是两个。
list_skills()(L99-171):扫描 + 并发加载
这个方法的骨架其实很短,剥掉日志和异常处理就是四步:
找目录( _find_skill_dirs,L120-134):先看directory自己有没有 SKILL.md;如果scan_subdir=True,再os.walk递归找子目录。注意它把同步的os.walk包进asyncio.to_thread——因为目录遍历是阻塞 IO,不能卡住事件循环。并发加载(L146-149): asyncio.gather(*tasks, return_exceptions=True)。这里用return_exceptions=True是关键——一个 skill 加载失败不会拖垮整批,失败的会作为 Exception 对象返回,下面循环里单独记日志。过滤(L152-161):把 Exception 和 None 都滤掉。
一个细节:它没有排序。返回顺序取决于 os.walk 的遍历顺序,不保证稳定。如果你依赖顺序,得自己排。
_load_single_skill()(L32-97):缓存键是 mtime,不是内容
这是全文最该停下来想的一段。看 L52-56:
缓存命中条件是 updated_at == 当前 mtime。也就是说——文件没被改过,就直接返回上次解析的 Skill 对象,连 SKILL.md 都不重新读。
这是个很标准但容易写错的取舍。为什么不用内容哈希做键?因为算哈希得先把文件读进来,那缓存的意义就没了——缓存的全部价值就在于「不读文件」。mtime 是文件系统免费给的元数据,拿它当键既准又快。代价是:mtime 可能被人为篡改(touch 一下),但这对 skill 这种「自己往目录里放文件」的场景影响很小。
再看 L70-76 的容错:
缺 name 或 description 的 skill 会被静默跳过,不抛异常。这跟 list_skills 里 return_exceptions=True 是一套设计哲学——加载阶段尽量容错,别让一个坏文件挡住整批。
五、代码到底是怎么运行起来的?
跟着「agent 第一次想用某个 skill」走一遍完整调用链。
前提:用户在构造 agent 时传了一个目录 /path/to/skills,里面有 my-skill/SKILL.md。这个目录被包进 ToolGroup,再进 Toolkit。
第一步:构造时收进 ToolGrouptool/_tool_group.py L84-89:如果传入的是字符串(目录路径),就 LocalSkillLoader(directory=_) 包一层;如果是现成的 Skill 或 SkillLoaderBase,直接收。这一步不读磁盘,只是把 loader 对象存起来。
第二步:agent 启动,收集 skill 写进系统提示tool/_toolkit.pyget_skill_instructions()(L426-465):
调 _get_available_skills()(L389-424)→ 遍历激活的 tool groups → 对每个 group 调group.list_skills()ToolGroup.list_skills()(_tool_group.pyL97-106)→ 遍历skills_or_loaders,对每个 loader 调loader.list_skills()—— 此时才真正读磁盘回到 get_skill_instructions(),用 Jinja2 模板DEFAULT_SKILL_INSTRUCTION(L51-63)把每个 skill 的name/description/dir渲染成一段文本,拼进系统提示
注意:写进系统提示的只有 name + description + dir,没有 markdown 正文。这是 skill 机制省 token 的关键——几十页的指令正文留在磁盘上,系统提示里只放一张「目录」。
第三步:agent 决定用某个 skillLLM 看到系统提示里的 skill 列表,判断「我需要 my-skill」,于是发起一次工具调用:Skill(skill="my-skill")。这个 Skill 就是内置工具 SkillViewer(tool/_builtin/_skill.py),它对外的名字就是 "Skill"。
第四步:SkillViewer 读出正文SkillViewer.call()(L90-127):
接收 skill="my-skill"和注入的_agent_state调 self._get_skills_method(_agent_state.tool_context.activated_groups)—— 这个回调就是Toolkit._get_available_skills,返回dict[str, Skill]skills.get("my-skill")拿到Skill对象return ToolChunk(content=[TextBlock(text=target_skill.markdown)])—— 把markdown正文整段返回给 agent
第五步:agent 读正文,照着用别的工具LLM 拿到 SKILL.md 正文(里面写着「你应该先用 X 工具,再用 Y 工具,参数怎么填」),然后自己按这份说明书去调用真正的工具。skill 本身从头到尾没有被「执行」——它只是一份被读出来的文档。
这就是为什么本文反复强调 skill ≠ tool:skill 是「知识/流程的载体」,tool 是「动作的执行者」。SkillViewer 是两者之间唯一的桥。
六、如何开始调试源码
skill 模块调试点很少,因为逻辑确实简单。三个断点够用:
断点 1:_local_loader.py L53-56(缓存判断)。观察 skill_root 和 cached_skill.updated_at,确认缓存命中/未命中符合预期。如果你改了 SKILL.md 但 agent 没刷新,多半是 mtime 没变(某些编辑器的「安全保存」会改 mtime,有些不会)。
断点 2:_local_loader.py L67-76(frontmatter 解析 + 必填校验)。观察 name 和 description 是否都被正确提取。如果你的 skill 没出现在列表里,八成是这两个字段之一为空——这里会 return None 静默跳过,只打 warning,不抛异常,容易看漏。
断点 3:tool/_toolkit.py L460-464(系统提示渲染)。观察 Template(self.skill_instruction_template).render(...) 的输出,确认 skill 目录正确写进了系统提示。如果 agent「不知道有这个 skill」,问题基本都在这里——要么 _get_available_skills 返回空,要么 group 没被激活。
如果问题出在 workspace 的增删改(add_skill/remove_skill/list_skills 持久化),断点要打在 workspace/_local_workspace.py,不在本模块——见取舍三。
七、如何扩展这个模块
✅ 应该改(推荐路径)
实现自己的 SkillLoaderBase 子类。这是官方扩展点,基类只承诺一个 list_skills(),你可以:
RemoteSkillLoader:从 HTTP API 拉 skill 列表(每次list_skills()发一个 GET)GitSkillLoader:从 git 仓库 clone/拉取 skill 目录DatabaseSkillLoader:从数据库查 skill 元数据,正文存在对象存储里
只要返回 list[Skill],上层 ToolGroup 和 Toolkit 完全无感。这是「最小接口、最大自由」的直接收益。
自定义系统提示模板。Toolkit 构造时可以传 skill_instruction_template(Jinja2),默认是 DEFAULT_SKILL_INSTRUCTION。如果你的 agent 需要不同的 skill 呈现方式(比如中文说明、带使用示例),改这个模板就行,不用动 skill 模块。
⚠️ 不应该改(有更优替代)
不要给 Skill dataclass 加方法。如果你想让 skill「能自我校验」「能格式化成特定 prompt」,在外层写工具函数或中间件,别动 Skill 本身。dataclass 的价值就是「纯数据、无行为」,加了方法它就不再是「读进来 → 吐出去」的简单快照了。
不要在 LocalSkillLoader 里加去重/持久化。这些策略属于有状态的拥有者(workspace),不属于无状态的扫描器。如果你需要这些能力,应该写一个新的 loader 或者在上层做,而不是把 LocalSkillLoader 改成有状态的。
🚫 千万不要改(动了会破坏不变量)
不要给 SkillLoaderBase 加第二个抽象方法。这是最关键的不变量。一旦基类承诺了 get_skill(name) 之类的方法,所有现有和未来的 loader 实现都被迫跟进,而很多场景(远程全量拉取、git clone)根本不天然支持「按名查单条」。基类只有一个方法 = 你只承诺一件事 = 实现层最大自由。多加一个方法,就等于把一种实现策略烧进了契约。
不要改 Skill 的字段集。SkillViewer、LocalWorkspace._load_single_skill、Toolkit 的模板渲染都依赖这五个字段的名字和类型。改字段名 = 同时改三个模块。如果确实需要扩展(比如加个 version 字段),用继承或组合,别动原 dataclass。
八、本模块最值得学习的设计
设计一:最小接口 + 实现下沉
这是 skill 模块最值得带走的一条。SkillLoaderBase 只有一个方法,但框架里关于 skill 的所有复杂策略——去重、冲突、持久化、对账——都跑得好好的。原因就是这些策略被精准地放到了「最需要它的那一层」:去重和对账放在 workspace(因为它有持久化状态),loader 只管无状态扫描。
这条思路的普适性很强:当你设计一个要被多处复用的核心抽象时,先把方法数压到最少,再看每个策略该归谁。归错层(比如把去重塞进 loader)会让轻量场景背重担;归对层(去重留在 workspace)则各层各司其职。判断归谁的标准是「这个策略依赖什么状态」——去重依赖历史索引(状态),所以归有状态的层;扫描不依赖任何状态,所以归无状态的层。
设计二:mtime 当缓存键
_load_single_skill 用文件 mtime 判断要不要重新解析。这是个很小但很经典的取舍:mtime 是文件系统免费给的,拿它当键既准又快;代价是可被人为篡改,但对「自己往目录放文件」的场景影响极小。
可迁移的结论:缓存键应该选「获取成本接近零、又能反映内容是否变化」的信号。文件场景下 mtime 几乎是唯一答案;HTTP 场景下对应的是 ETag / Last-Modified;数据库场景下是行的 version 或 updated_at 字段。这套「用便宜的代理信号代替昂贵的内容校验」的思路,到处都用得上。
设计三:加载阶段的静默容错
_load_single_skill 缺字段就 return None,list_skills 用 return_exceptions=True 让单个失败不拖垮整批。这套「批处理里单个失败不致命」的容错,在「扫描一堆外部资源」的场景里非常实用——一个坏掉的 skill 文件不该让整个 agent 起不来。
可迁移的点:对外部输入做批量处理时,把「单个失败」从异常降级为可记录事件,用 gather(..., return_exceptions=True) 这种模式收集结果,再统一过滤。这比让一个坏输入炸掉全流程健壮得多。
九、阅读建议
skill 模块本身 15 分钟能读完,但它的价值要在消费方才看得清。建议顺序:
先读 _base.py(5 分钟)。把Skill的五个字段和SkillLoaderBase的唯一方法记牢,这是后面所有代码的契约。再读 _local_loader.py(10 分钟)。重点看_load_single_skill的缓存逻辑和list_skills的并发+容错。跳到 tool/_tool_group.py的list_skills和__init__(L84-106)。看 loader 是怎么和现成Skill一起被收进工具分组的——这是 skill 模块和 tool 模块的接缝。读 tool/_toolkit.py的get_skill_instructions和_get_available_skills(L389-465)。看 skill 是怎么被收进系统提示的。读 tool/_builtin/_skill.py的SkillViewer.call(L90-127)。这是 skill 被读出来的瞬间,也是「skill ≠ tool」这句话的代码证据。
如果你已经读过第 9 篇 workspace,可以再回头对照 workspace/_local_workspace.py 的 list_skills/add_skill/_reconcile_skills_dir,看那套复杂对账是怎么建在 skill 模块这层薄接口之上的——那是「实现下沉」这条设计哲学最完整的演示。
十、阅读完成以后
读完 skill 模块,你应该能回答:
为什么需要它:让 agent 按需加载能力包,而不是把所有指令塞进系统提示。 为什么这么设计:数据( Skill)和行为(loader)分离;基类只承诺一个方法,把实现自由度最大化;脏活全留给有状态的 workspace。怎么运行:构造时收 loader → 启动时扫盘写系统提示 → LLM 决定用时调 SkillViewer→ 读出正文 → LLM 照正文用别的工具。改哪里:要新数据源就实现 SkillLoaderBase,要改提示样式就改模板,但别动Skill字段、别给基类加方法。
真正该带走的就一句话:好的扩展点是「最小接口 + 实现下沉」——核心层薄到只剩契约,复杂策略精准归到最需要它的那一层。skill 模块用 211 行证明了这条原则,workspace 层那套对账机制则证明了薄接口之上能撑起多重的实现。
留给你的问题
你在用 Claude / Cursor / 别的 agent 工具时,有没有遇到过「skill 触发不准」的情况?多半是 SKILL.md 的哪个字段没写好?(提示:本文特意点出过一个字段是「agent 决定要不要用某个 skill 的唯一依据」。) 想深入的同学再想一个:如果让你做一个「团队共享的 skill 仓库」,你会让 loader 直接连远程,还是像 workspace 那样先落盘再做对账?两种做法各怕什么?
觉得有用?点个「在看」或转发给同样在搞 Agent 的朋友。系列持续更新,关注不迷路。
夜雨聆风