正文

这个 Skill 解决什么具体问题

Deer-flow 是一个能处理“持续几分钟到几小时长周期任务”的 Agent 系统。它通过 sandbox(沙箱)、memory(记忆)、tools(工具)、skill(技能)、subagent(子代理)和 message gateway(消息网关)来应对复杂任务。

但大部分开发者关注的不是整个框架,而是它里面那个 skill 模块——这是 Deer-flow 提炼的最有价值的设计模式。

Skill 解决的问题很直接:当 AI Agent 需要执行重复的、可预测的子任务时(比如“提取网页标题”“格式化 JSON”“搜索并总结”),你不需要每次写全新的 Prompt,而是可以预先封装一个稳定、带输入输出定义、可测试的“技能”。

简单说:Skill = 可复用的 Prompt + 工具调用 + 后处理逻辑,打包成一个独立模块。

在我之前做内部工具开发时,我见过太多人每次让 AI 做同样的事情都重写 Prompt,而且经常忘记加约束、忘记处理边界情况。有了 Skill,你可以把“正确的那一次”固化下来,团队内共享,甚至跨项目复用。

Skill 的触发条件和适用场景

什么样的任务适合封装成 Skill?Deer-flow 的设计给了我一个判断清单(也是我自己的经验):

  • 任务边界清晰:输入输出明确。比如“给定一段代码,生成单元测试”,而不是“帮我改善项目”。
  • 频率高:这个子任务会在不同流程中反复出现。
  • 需要特定工具或约束:比如需要访问文件系统、调用 API、设置特定输出格式。
  • 错误处理可预期:你可以预判失败场景(如 API 超时、格式不正确)并做兜底。

场景举例:

  • 在写文章时,每次插入代码块都需要格式化并添加语言标识 → 可以封装一个 format_code_block 的 Skill。
  • 在分析日志时,需要提取时间戳、级别、消息 → parse_log_line Skill。
  • 在数据处理流程中,需要将 Markdown 转换为纯文本 → md_to_text Skill。

触发条件:Skill 的触发可以是显式的(用户请求“运行技能 X”),也可以是隐式的(工作流引擎检测到当前状态匹配 Skill 的前置条件)。Deer-flow 中使用的是消息网关加子代理调度,但你可以简单用“if-elif”或函数调用。后面我会给实际例子。

完整 Skill 结构(SKILL.md 示例)

Deer-flow 中每个 Skill 对应一个目录,包含描述文件 SKILL.md 和实现代码。为了让你直接上手,我给出一个精简但完整的目录结构(Python 项目):

text
1 2 3 4 5
skills/
  format_code/
    __init__.py
    SKILL.md
    skill.py

SKILL.md(人类可读的描述+触发条件)

markdown
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
# Format Code Block

## 描述
将任意代码片段格式化为规范化的 Markdown 代码块,自动识别语言,并添加行号(可选)。

## 输入
- `code` (str): 原始代码
- `language` (str, optional): 语言名称,自动检测如果未提供
- `add_line_numbers` (bool, default=false): 是否添加行号

## 输出
- `formatted_code` (str): 格式化后的代码块

## 触发条件
当用户提供代码片段且要求“格式化”或“美化”时自动触发。也可直接通过名称调用。

## 错误处理
- 代码为空:返回错误信息
- 语言无法识别:默认使用 `plaintext`
- 行号生成失败:单独失败补上(回滚行号)

skill.py(实际实现,包含 Prompt + 逻辑)

python
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
import re

class FormatCodeSkill:
    def __init__(self, llm_client):  # llm_client 可以是 OpenAI 或其他
        self.llm = llm_client

    def can_handle(self, query: str) -> bool:
        """根据用户输入判断是否适用"""
        keywords = ["格式化", "美化", "代码块", "format code", "indent"]
        return any(k in query.lower() for k in keywords)

    def run(self, code: str, language: str = "", add_line_numbers: bool = False) -> dict:
        prompt = self._build_prompt(code, language, add_line_numbers)
        response = self.llm.chat(prompt)  # 假设返回字符串
        result = self._postprocess(response)
        return {"formatted_code": result}

    def _build_prompt(self, code, language, add_line_numbers):
        return f"""
你是一个代码格式化专家。将以下代码格式化为 Markdown 代码块。

要求:
- 如果未提供语言,根据代码内容推断(如 Python、JavaScript、Bash)。
- {'添加行号,格式如 `1: line`' if add_line_numbers else '不要添加行号'}。
- 统一缩进为4个空格。
- 去除多余空行(仅保留代码逻辑需要的空行)。
- 输出仅包含代码块本身,不要额外解释。

代码:

{code}

text
1 2 3 4 5 6 7 8 9 10 11 12

语言(可选):{language}
"""

    def _postprocess(self, response: str) -> str:
        # 移除 Prompt 可能产生的额外包装
        # 提取第一个代码块内容
        match = re.search(r'```(\w*)\n([\s\S]*?)```', response)
        if match:
            return f"```{match.group(1)}\n{match.group(2)}```"
        # 如果没找到代码块,就原样返回
        return response

这个结构的好处

  • can_handle 是隐式触发条件,配合外部调度器可以自动匹配。
  • _build_prompt 集中管理 Prompt 模板,易于测试和修改。
  • _postprocess 处理 LLM 的回复,确保输出格式正确。

实际案例演示

现在我们做一个更具挑战性的 Skill:从一篇技术文章中提取所有代码块并自动分类(前端/后端/脚本)

python
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
# skills/extract_code_blocks/skill.py

import json
import re

class ExtractCodeBlocksSkill:
    def __init__(self, llm_client):
        self.llm = llm_client

    def can_handle(self, query: str) -> bool:
        # 只在显式请求时触发,避免误触发
        return "提取代码" in query or "extract code" in query.lower()

    def run(self, markdown_text: str) -> dict:
        # 1. 简单正则提取代码块(作为初步预处理)
        raw_blocks = re.findall(r'```(\w*)\n([\s\S]*?)```', markdown_text)
        if len(raw_blocks) == 0:
            return {"blocks": [], "message": "未发现代码块"}
        
        # 2. 交给 LLM 进行分类和整理
        prompt = self._build_prompt(raw_blocks)
        response = self.llm.chat(prompt)
        # 期望返回 JSON 列表
        try:
            blocks = json.loads(response)
        except:
            blocks = self._fallback(raw_blocks)
        return {"blocks": blocks}

    def _build_prompt(self, raw_blocks):
        blocks_text = "\n---\n".join([f"第{i+1}个代码块 (语言: {lang})\n{code[:200]}" for i, (lang, code) in enumerate(raw_blocks)])
        return f"""
你是一个代码分析专家。以下是文章中的代码块列表。请对每个代码块进行以下处理:
1. 自动修正语言标记(如果原始标记错误)
2. 对每个代码块输出一个 JSON 对象,格式:
   {{"index": int, "language": "修正后的语言", "category": "前端|后端|脚本|数据|其他", "purpose": "简要用途(10字内)"}}
3. 返回一个 JSON 数组,不要额外内容。

代码块列表:
{blocks_text}
"""

    def _fallback(self, raw_blocks):
        # 当 LLM 解析失败时,用正则提取的信息作为兜底
        items = []
        for i, (lang, code) in enumerate(raw_blocks):
            items.append({
                "index": i,
                "language": lang or "unknown",
                "category": "其他",
                "purpose": code[:50].replace("\n", " ")
            })
        return items

对比差 Prompt 与好 Prompt

差 Prompt(我经常看到新人这样写):

text
1
请帮我提取这些代码块。

结果:输出格式可能乱七八糟,有额外解释,难以解析。

好 Prompt(上面的 _build_prompt):

  • 明确指定输出格式为 JSON 数组。
  • 给示例结构。
  • 要求不要额外内容。
  • 指定分类任务,不仅仅是提取。

为什么这样有效

  • LLM 对于结构化输出(如 JSON)的遵循程度取决于你给它的结构是否明确+示例。
  • 加上“不要额外内容”避免了它说“好的,这是提取的代码块:”这类废话。
  • 限定用途字数使输出紧凑。

复用和组合技巧

技巧 1:Skill 组合成工作流

你可以把多个 Skill 连接起来。Deer-flow 的 message gateway 就是干这个的。例如,一个“技术文章总结+代码提取”工作流:

python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
class ArticleWorkflow:
    def __init__(self, skills):
        self.skills = skills

    def run(self, article_md):
        # 先使用提取代码 Skill
        code_skill = self.skills["extract_code_blocks"]
        code_result = code_skill.run(article_md)

        # 再使用格式化代码 Skill
        format_skill = self.skills["format_code"]
        formatted = []
        for block in code_result["blocks"]:
            fmt = format_skill.run(block["code"], block["language"])
            formatted.append(fmt)

        # 然后使用总结 Skill(假设存在)
        summary_skill = self.skills["summarize_article"]
        summary = summary_skill.run(article_md)

        return {"summary": summary, "code_blocks": formatted}

技巧 2:Skill 的测试与隔离

每个 Skill 都应该可以独立测试。你可以 mock LLM 的返回值来测试逻辑分支。这也是 Deer-flow 设计强调的——隔离 sandbox 环境。我推荐在 test/ 下针对每个 Skill 写单元测试,例如测试 _fallback_postprocess

技巧 3:Skill 的错误回退机制

在上面的例子中,_fallback 就是一个简单的回退。更成熟的 Skill 可以使用 retry、降级输出、甚至切换模型。

扩展用法:Skill 与 Function Calling

如果你使用 OpenAI 的函数调用,可以定义工具描述,让 Agent 自动选择 Skill。这也是 Deer-flow 中 tools 的用法。示例:

json
1 2 3 4 5 6 7 8 9 10 11 12 13 14
{
  "name": "extract_code_blocks",
  "description": "从 Markdown 文章中提取所有代码块并分类。",
  "parameters": {
    "type": "object",
    "properties": {
      "markdown_text": {
        "type": "string",
        "description": "完整的 Markdown 文本"
      }
    },
    "required": ["markdown_text"]
  }
}

然后在 Agent 调度器中,将用户请求与这些 tools 匹配。

变体 1:带缓存的 Skill

如果输入相同的结果可复用,可以添加缓存层。比如 format_code 如果同样代码输入过,直接返回缓存。

变体 2:带验证的 Skill

在某些安全性要求高的场景(比如生成代码),可以在输出前用正则或黑名单验证,防止注入。

我的个人看法

字节跳动开源的 Deer-flow 让我最兴奋的不是它的整体架构,而是它把 skill 做成了第一等公民。很多 Agent 框架在强调“插件”“工具”,但忽略了“技能”这种更贴近人类认知的抽象。技能是有状态的(记忆),有失败处理的,有自己的 Prompt 配方。

如果你正在构建自己的 Agent 系统,我强烈建议你第一步就是定义你的 Skills 列表,而不是上来就写主流程。每个 Skill 就是一个最小产品,通过组合它们,你能在不写大量胶水代码的情况下快速迭代。

最后,记住:一个 Skill 不应意图解决所有问题。它越小越好,越单一越好。当你发现一个 Skill 的 Prompt 超过 30 行时,考虑拆分为两个。


附:完整可直接复用的 Prompt 模板(通用版本)

markdown
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
# Skill: {NAME}

## Role
你是一个{角色描述}。

## Context
{当前任务的上下文,如输入数据、前置条件}

## Task
{具体任务,用动词开头}

## Requirements
1. 输出格式必须为{格式},每个字段说明
2. 如果输入为空,返回{默认输出}
3. 不要有任何额外解释,只输出指定格式。
4. 当{某种条件}时,采用{替代策略}

## Example
输入:{示例输入}
输出:{示例输出}

## Input
{实际输入}

你可以把这个模板放到每个 Skill 的 SKILL.md 中,然后在 _build_prompt 里填入实际值。如果你使用 OpenAI 的 chat completion,把 System message 设为 Role,User message 设为 Task+Input。