Skip to content

技能系统

本章目标:

  1. 讲清楚 skills 为什么是 DeerFlow「几乎能做任何事」的关键,以及渐进式加载(progressive loading)如何节省 context。
  2. 掌握 SKILL.md frontmatter 字段、load_skills 递归扫描、parser/validation/tool_policy 的边界与契约。
  3. 能按 validation 代码约束写出一个合法的自定义 SKILL.md,并理解 .skill ZIP 安装与 LLM 安全扫描流程。

TL;DR

技能(Skill)是放在 skills/{public,custom}/<name>/SKILL.md 下的「带 YAML frontmatter 的 Markdown 工作手册」。系统启动时只把已启用技能的 name/description/容器路径注入系统提示词(几十 token 级别),Agent 在遇到匹配任务时才用 read_file 读完整 SKILL.md 及其引用资源——这就是渐进式加载,用极小 context 换「插件式」能力扩展。安装走 .skill ZIP:经过路径穿越/zip 炸弹防护解压后,先做 frontmatter 校验,再用 LLM 逐文件做安全扫描(allow/warn/block),通过才落盘到 custom/allowed-tools 字段可把 Agent 工具集收窄到技能声明的白名单。

Overview

DeerFlow 的 Lead Agent 本身只有一组通用工具(bash、文件读写、子代理委派等)。要让它「会做 PPT」「会做学术综述」「会部署 Vercel」,如果把每种工作流的完整说明都塞进系统提示词,context 会瞬间爆炸,且大部分内容在单次对话里用不到。

技能系统解决的就是这个矛盾。它的核心设计是渐进式加载(progressive loading):系统提示词里只放一张「技能目录」,每项技能只有 namedescription(用于触发匹配)和容器内的文件路径 prompt.py:605。提示词里明确告诉模型加载步骤:用户查询匹配某技能时,先 read_file 读它的主文件,再按需读同目录下被引用的外部资源 prompt.py:612-617。技能的全文、脚本、模板都不进 context,只在真正执行那个工作流时才被读入。

这套机制让 DeerFlow「几乎能做任何事」:任何可以写成 Markdown 流程 + 辅助脚本的能力,都能作为一个目录挂进来,无需改代码。skills/public/ 是平台内置只读技能,skills/custom/ 是用户通过 .skill 包安装或自建的可编辑技能 types.py:8-16。技能目录在沙箱容器内统一挂载到 /mnt/skills,所以提示词里给的路径就是 Agent 实际能 read_file 的路径 types.py:39-53

Architecture

技能系统由「类型契约 + 解析器 + 校验器 + 存储后端 + 安装器 + 安全扫描器 + 工具策略 + 提示词注入」组成。存储层用抽象基类 SkillStorage 提供 load_skills 等模板方法流程,LocalSkillStorage 实现本地文件系统的原子操作,二者通过反射工厂 get_or_new_skill_storage 装配 storage/init.py:15-68

Source

模块路径职责
类型skills/types.pySkill dataclass、SkillCategory、容器路径推导
解析器skills/parser.py提取 frontmatter、allowed-tools 解析
校验器skills/validation.pyfrontmatter 字段白名单与命名规则
存储层skills/storage/skill_storage.py抽象基类 + load_skills 模板流程
本地存储skills/storage/local_skill_storage.py文件遍历、安装、历史 JSONL
安装器skills/installer.pyZIP 安全解压、安全扫描调度
安全扫描skills/security_scanner.pyLLM 分类 + 鲁棒 JSON 解析
工具策略skills/tool_policy.pyallowed-tools 收窄工具集
提示词注入agents/lead_agent/prompt.py缓存 + 生成 <skill_system>

Components / Subsystems

parser —— frontmatter 解析

职责:从 SKILL.md 顶部的 --- 围栏中提取 YAML,产出 Skill 对象。关键函数 parse_skill_file 用正则 ^---\s*\n(.*?)\n---\s*\n 抓 frontmatter 块,yaml.safe_load 解析,要求 name/description 均为非空字符串,任一不满足或 YAML 非 mapping 则返回 None 而不抛异常 parser.py:54-84parse_allowed_tools 单独处理 allowed-tools:省略返回 None(legacy 放行),必须是字符串列表,空字符串元素会抛 ValueError parser.py:12-32。注意:parser 产出的 enabled 恒为 True,真实启用状态由 load_skills 从 extensions 配置二次合并 parser.py:105

validation —— frontmatter 字段约束

职责:在写入/安装前校验 SKILL.md。ALLOWED_FRONTMATTER_PROPERTIES 是字段白名单:namedescriptionlicenseallowed-toolsmetadatacompatibilityversionauthor,出现其它键直接判不合法 validation.py:15_validate_skill_frontmatter 强制 name 为 hyphen-case(^[a-z0-9-]+$,不能首尾连字符或 --,≤64 字符),description 不能含尖括号且 ≤1024 字符 validation.py:70-86

storage —— 发现与启用状态合并

职责:抽象 SkillStorage 提供 final 模板方法 load_skills:遍历 _iter_skill_files() 给出的每个 SKILL.md,逐个 parse_skill_file,以 name 去重,再从 ExtensionsConfig.from_file() 每次重新读取合并 enabled 状态(让别的进程改动立即生效),按 name 排序 skill_storage.py:212-246LocalSkillStorage._iter_skill_filesos.walk(followlinks=True) 递归扫描 public/custom 两个 category 目录,跳过以 . 开头的子目录,只 yield 含 SKILL.md 的目录 local_skill_storage.py:63-74。自定义技能写入用临时文件 + replace 原子落盘 local_skill_storage.py:81-92

installer —— .skill ZIP 安全安装

职责:解压并安全校验 .skill 包。safe_extract_skill_archive 三重防护:拒绝绝对路径/.. 穿越成员、跳过 symlink 成员、流式写入并对未压缩总量设 512MB 上限防 zip 炸弹 installer.py:80-121_scan_skill_archive_contents_or_raise 强制扫描 SKILL.md,并扫描 scripts/(按可执行处理)及 references//templates/ 下的文本文件,禁止嵌套 SKILL.md installer.py:175-192LocalSkillStorage.ainstall_skill_from_archive 串起全流程:校验 .skill 后缀 → 安全解压到临时目录 → frontmatter 校验 → 目标不存在检查 → 安全扫描 → 复制到 staging 再原子搬入预留目录 local_skill_storage.py:94-155

security_scanner —— LLM 分类 + 鲁棒 JSON 解析

职责:用 LLM 把技能内容分类为 allow/warn/blockscan_skill_content 给模型一份 rubric(拦截 prompt 注入、角色覆盖、提权、数据外泄、不安全可执行代码),要求只回单行 JSON security_scanner.py:70-93_extract_json_object 对 LLM 输出做鲁棒解析:先剥 ```json 围栏直接 json.loads,失败再做「字符串感知的大括号配平」逐字符提取首个完整 JSON 对象 security_scanner.py:24-67保守失败策略:模型应答但解析不出 → block;模型调用失败 → 一律 block(可执行内容更严格)security_scanner.py:105-109

tool_policy —— allowed-tools 工具收窄

职责:按已加载技能的 allowed-tools 声明收窄 Agent 工具集。allowed_tool_names_for_skills 取所有声明的并集;关键语义:只要有任一技能声明了 allowed-tools,没声明的 legacy 技能就不再贡献工具(而不是退回放行全部);全部技能都没声明时返回 None 表示 legacy allow-all tool_policy.py:13-36filter_tools_by_skill_allowed_tools 据此过滤工具列表,在 Lead Agent 工厂中对内置/扩展工具生效 agent.py:411-436

Data Flow

下面是「.skill 安装 + 安全扫描」端到端流程:

提示词注入侧:get_skills_prompt_section 取已启用技能,生成每项的 <skill> 标签(含 get_container_file_path 给出的 /mnt/skills/... 路径)拼成 <available_skills>,无技能且未开启 skill_evolution 时返回空串 prompt.py:626-656。已启用技能列表由后台线程异步刷新并缓存,配置或技能变更时 clear_skills_system_prompt_cache 使其失效 prompt.py:114-160

Implementation Details

security_scanner 的鲁棒 JSON 解析是本系统最关键的健壮性代码,处理 LLM 输出格式不稳定:

python
def _extract_json_object(raw: str) -> dict | None:
    raw = raw.strip()
    fence_match = re.match(r"^```(?:json)?\s*\n?(.*?)\n?\s*```$", raw, re.DOTALL)
    if fence_match:
        raw = fence_match.group(1).strip()
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        pass
    start = raw.find("{")
    if start == -1:
        return None
    depth = 0; in_string = False; escape = False
    for i in range(start, len(raw)):
        c = raw[i]
        if escape: escape = False; continue
        if c == "\\": escape = True; continue
        if c == '"': in_string = not in_string; continue
        ...

解读:第一层剥 Markdown 代码围栏后直接 json.loads,覆盖「干净 JSON」与「围栏包裹 JSON」两种最常见输出。第二层是兜底——LLM 常在 JSON 前后加自然语言解说,所以从首个 { 开始做字符串感知的大括号配平:in_string/escape 标志确保字符串内的 {}、引号不被误计深度,直到 depth 归零截取出首个完整对象 security_scanner.py:24-67。这与提交记录中「make security scanner JSON parsing robust for LLM output variations」对应。

速查表

SKILL.md frontmatter 字段矩阵:

字段必填类型/约束Source
namehyphen-case ^[a-z0-9-]+$,≤64,无首尾/连续连字符validation.py:70-75
description非空字符串,无 < >,≤1024validation.py:78-86
license任意,str().strip() 归一,空→Noneparser.py:86-88
allowed-tools字符串列表,空列表=无工具,空元素报错parser.py:12-32
metadata白名单内,parser 不消费validation.py:15
compatibility白名单内validation.py:15
version白名单内validation.py:15
author白名单内validation.py:15
其它键出现即判不合法validation.py:51-53

注:public/ 内置技能(如 data-analysis、bootstrap)实际只用了 name+description,见 data-analysis/SKILL.md:1-4

扩展指南

最小自定义 SKILL.md 模板(放在 skills/custom/my-skill/SKILL.md):

markdown
---
name: my-skill
description: Use this skill when the user wants to ... (写清触发场景与能力边界,模型靠这段做匹配)
license: MIT
allowed-tools:
  - bash
  - read_file
  - write_file
---

# My Skill

## Overview
一句话说明这个技能做什么。

## Workflow
1. 第一步……
2. 引用资源放在同目录:见 `references/guide.md`
3. 脚本放在 `scripts/` 下,由 bash 调用

约束清单(均从代码读出):

Configuration

配置项来源/默认Source
技能根目录 skills.path显式 path → DEER_FLOW_SKILLS_PATH → 项目根 skills/ → legacy repo 根skills_config.py:32-59
容器挂载路径 skills.container_path默认 /mnt/skillsskills_config.py:27-30
存储实现 skills.use默认 LocalSkillStorage,反射装配skills_config.py:19-22
启用状态extensions_config.jsonskills 映射;未配置时 public/custom 默认启用extensions_config.py:190-204
安全扫描模型skill_evolution.moderation_model_name,空则用默认模型security_scanner.py:84-86

is_skill_enabled 的默认行为很关键:extensions_config.json没有某技能条目时,public 与 custom 技能默认启用;只有显式写 {"enabled": false} 才禁用 extensions_config.py:200-204

Common Pitfalls / Tips

  • frontmatter 解析失败是静默的:parse_skill_file 在 name/description 缺失或 YAML 错误时返回 None,该技能直接从列表消失,不报错。新建技能后发现没出现在列表,优先用 validation 规则逐项核对 parser.py:74-84
  • allowed-tools 是全局耦合的:只要任意一个已启用技能声明了 allowed-tools,所有没声明的技能就不再向工具集贡献工具。混用时务必给所有技能补齐声明,否则会意外丢失工具 tool_policy.py:26-36
  • 安全扫描默认从严:LLM 不可用或输出无法解析时一律 block。离线/无模型环境下安装 .skill 会失败,这是设计预期而非 bug security_scanner.py:105-109
  • 不要在 public/ 下手改内置技能:它是只读类别;要定制同名技能应在 custom/ 下新建,系统会在 ensure_custom_skill_is_editable 给出明确提示 skill_storage.py:248-254
  • 启用状态每次都重读:load_skills 每次调用都重新读 ExtensionsConfig.from_file(),外部进程改了启用开关无需重启即生效,但也意味着该文件 IO 会随调用频繁发生,提示词侧用缓存 + 后台线程刷新缓解 skill_storage.py:233-238

References

章节关系
06-MCP与Skills扩展配置.md上游:extensions_config.json 中 skills 启用开关与 MCP 的统一配置体系
20-工具系统与内置工具.md协作:allowed-tools 收窄的就是工具系统装配出的工具集
10-LeadAgent与Agent工厂.md下游:Agent 工厂调用 filter_tools_by_skill_allowed_tools 与技能提示词注入

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析