正文
这个 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_lineSkill。 - 在数据处理流程中,需要将 Markdown 转换为纯文本 →
md_to_textSkill。
触发条件:Skill 的触发可以是显式的(用户请求“运行技能 X”),也可以是隐式的(工作流引擎检测到当前状态匹配 Skill 的前置条件)。Deer-flow 中使用的是消息网关加子代理调度,但你可以简单用“if-elif”或函数调用。后面我会给实际例子。
完整 Skill 结构(SKILL.md 示例)
Deer-flow 中每个 Skill 对应一个目录,包含描述文件 SKILL.md 和实现代码。为了让你直接上手,我给出一个精简但完整的目录结构(Python 项目):
skills/
format_code/
__init__.py
SKILL.md
skill.py
SKILL.md(人类可读的描述+触发条件)
# Format Code Block
## 描述
将任意代码片段格式化为规范化的 Markdown 代码块,自动识别语言,并添加行号(可选)。
## 输入
- `code` (str): 原始代码
- `language` (str, optional): 语言名称,自动检测如果未提供
- `add_line_numbers` (bool, default=false): 是否添加行号
## 输出
- `formatted_code` (str): 格式化后的代码块
## 触发条件
当用户提供代码片段且要求“格式化”或“美化”时自动触发。也可直接通过名称调用。
## 错误处理
- 代码为空:返回错误信息
- 语言无法识别:默认使用 `plaintext`
- 行号生成失败:单独失败补上(回滚行号)
skill.py(实际实现,包含 Prompt + 逻辑)
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}
语言(可选):{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:从一篇技术文章中提取所有代码块并自动分类(前端/后端/脚本)。
# 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(我经常看到新人这样写):
请帮我提取这些代码块。
结果:输出格式可能乱七八糟,有额外解释,难以解析。
好 Prompt(上面的 _build_prompt):
- 明确指定输出格式为 JSON 数组。
- 给示例结构。
- 要求不要额外内容。
- 指定分类任务,不仅仅是提取。
为什么这样有效:
- LLM 对于结构化输出(如 JSON)的遵循程度取决于你给它的结构是否明确+示例。
- 加上“不要额外内容”避免了它说“好的,这是提取的代码块:”这类废话。
- 限定用途字数使输出紧凑。
复用和组合技巧
技巧 1:Skill 组合成工作流
你可以把多个 Skill 连接起来。Deer-flow 的 message gateway 就是干这个的。例如,一个“技术文章总结+代码提取”工作流:
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 的用法。示例:
{
"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 模板(通用版本)
# 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。