把 Skill 做成 Markdown 文件:Agent 框架的”低代码”设计决策
作者:Agent Dev 实践笔记 | 发布日期:2026-09-21 | 标签:Agent, Skill, Markdown, 低代码, Routing
引子
你有没有想过:如果你的 Agent 可以”根据用户输入自动切换工作模式”,它会是什么样?
比如:
– 用户说”帮我规划并写一篇关于 AI Agent 的科普文章” → Agent 自动进入”规划-调研-分步骤写作-交付”模式
– 用户说”计算一下 2^20 是多少” → Agent 只调 calculator 工具,算完给答案
– 用户说”我想了解一下” → Agent 进入”RAG 检索 + 知识回答”模式
上面这三种场景,工具不一样、Prompt 不一样、迭代次数也不一样。怎么实现?
三种常见做法
做法 A:全部靠一个巨大的 System Prompt + LLM 自己判断
SYSTEM_PROMPT = """你是一个万能助手。
如果用户说规划 → 用 plan_task;
如果用户说计算 → 用 calculator;
如果用户说查知识 → 用 search_knowledge;
如果用户说..."""
问题:Prompt 越来越长,路由指令越多 LLM 越容易漏看;所有工具始终一次性暴露给 LLM(21 个工具让它选一个,它会犹豫很久)。
做法 B:硬编码条件分支
if "规划" in user_input:
prompt = PLAN_PROMPT
tools = PLAN_TOOLS
elif "计算" in user_input:
prompt = CALC_PROMPT
tools = ["calculator"]
# ...
问题:每加一个”场景”都要改 Python 代码、加分支、重启。非开发者(产品经理、行业专家)根本无法贡献新场景。
做法 C(我们选的):Skill = Markdown 文件
让 Skill 变成一个声明式、可独立维护、非开发者也能写的单元。新增一个 Skill = 在 skills/ 目录下加一个 .md 文件,重启 Agent 自动加载。
这篇文章就来讲 Skill 的设计——为什么选 Markdown Frontmatter、关键词路由怎么实现、Skill 激活时到底发生了什么。
一、先建立认知:Tool vs Skill
| 维度 | Tool(工具) | Skill(技能) |
|---|---|---|
| 定义方式 | Python 函数 + OpenAI Function Schema | Markdown 文件 + YAML Frontmatter |
| 粒度 | 原子操作(查天气、算数学、读文件) | 复合工作流(规划调研→分步骤执行→交付) |
| 触发方式 | LLM 在工具列表里自由选择 | 关键词匹配 → 自动激活 |
| Prompt | 全部共享同一个 SYSTEM_PROMPT | 激活时完全替换 system prompt |
| 工具可见性 | 一次性暴露全部 21 个 | 只暴露 Skill 声明的 required_tools |
| 迭代上限 | 全局 max_iterations=5 |
Skill 可以自定义 max_rounds |
一句话概括:Tool 是手,Skill 是一套编排好的”手脚配合动作”。
二、Skill 文件长什么样
先看一个真实例子——skills/example_plan.md:
---
name: plan_and_write
description: 把复杂任务拆解为有序子任务并逐个完成,产出结构化成果
trigger_keywords:
- 规划
- 规划一下
- 规划并写
- 规划任务
- 规划方案
- 拆解
- 拆解一下
- 拆解复杂
- 分步骤
- 写大纲
- plan
- outline
required_tools:
- search_knowledge
- web_search
- file_write
- file_read
- extract_structured
max_rounds: 5
entry_mode: auto
---
## 你是一个专业任务规划与执行者
### 工作流
1. **明确范围**:如果用户没有说清楚具体要求(比如文章主题、字数、读者画像),先用一句话反问确认
2. **多路调研**:
- 优先查本地知识库(search_knowledge)获取基础概念和已有资料
- 补充互联网搜索(web_search)获取最新信息和不同观点
- 交叉对比,标注信息来源
3. **分步骤执行**:
- 每个子任务独立完成,不要一口气输出全部内容
- 子任务之间累积上下文,避免重复劳动
- 用 extract_structured 提取关键数据点,用 file_write 保存阶段性成果
4. **最终交付**:
- 用 file_write 保存完整成果到本地文件
- 返回文件路径 + 核心要点摘要
### 注意事项
- 工具调用失败时要灵活降级:search_knowledge 返回"不可用"→ 换 web_search
- 每个子任务的产出要具体可执行,不要写空泛的套话
- 不要让用户等太久,到 max_rounds 时交付阶段性成果也比超时好
就这么简单。Frontmatter 里写元数据(名字、触发词、需要哪些工具、迭代上限),Markdown 正文就是 Skill 激活时完全替换掉原来 System Prompt 的内容。
三、为什么选 Markdown Frontmatter
不是 YAML,不是 JSON Schema,不是 TOML。原因有三个:
3.1 零外部依赖
我们用一个正则表达式解析 Frontmatter,不引入 PyYAML:
# skill.py — 整个 Skill 定义文件的解析入口
_FM_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
def _parse_md(file_path: str) -> SkillDef:
with open(file_path, encoding="utf-8") as f:
content = f.read()
fm_match = _FM_RE.match(content)
if not fm_match:
raise ValueError(f"缺少 Frontmatter 的 --- 分隔: {file_path}")
fm_text = fm_match.group(1)
instructions = fm_match.group(2).strip() # ← Markdown 正文直接当 system prompt
meta = _parse_frontmatter(fm_text)
return SkillDef(
name=meta.get("name", ""),
trigger_keywords=tuple(meta.get("trigger_keywords", [])),
required_tools=tuple(meta.get("required_tools", [])),
max_rounds=int(meta.get("max_rounds", 5)),
instructions=instructions,
source_path=file_path,
)
PyYAML 体积约 2MB,引入它只为了解析几个 Skill 文件,不划算。
3.2 非开发者可写
产品经理、行业专家、领域专家可能不会写 Python,但会写 Markdown。他们可以直接在 VSCode / Obsidian / Notion 里写 Skill 定义,保存为 .md 文件放进 skills/ 目录,Agent 下次启动自动加载。
3.3 Markdown 正文就是 Prompt
Frontmatter 下方的 Markdown 正文,我们直接当 System Prompt 用。你可以用 ## 分章节、用 - 列清单、用代码块给 LLM 提供 few-shot 示例——这些 Markdown 语法 LLM 天然能理解(它就是用 Markdown 训练的)。
四、关键词路由怎么实现
Skill 加载后,用户输入来了,我们需要判断”该激活哪个 Skill”。我们没有用 LLM meta-routing(让 LLM 从候选里选)——那样每次多一次 API 调用。我们用的是纯字符串匹配 + 打分算法,微秒级完成。
4.1 核心算法
# skill.py match() — 简化版
def match(self, user_input: str) -> SkillDef | None:
best_score = 0.0
best_skill = None
user_lower = user_input.lower()
input_len = max(len(user_input), 1)
for skill in self.skills.values():
# 1. 子串匹配(不区分大小写)
matched_keywords = [kw for kw in skill.trigger_keywords
if kw and kw.lower() in user_lower]
if not matched_keywords:
continue
# 2. 计算"关键词覆盖字符数"
covered_positions = set()
for keyword in matched_keywords:
# 找到所有出现位置(去重重叠)
start = 0
while True:
found = user_lower.find(keyword, start)
if found < 0:
break
covered_positions.update(range(found, found + len(keyword)))
start = found + 1
hit_chars = len(covered_positions)
score = hit_chars / input_len # 分数 = 命中字符 / 总字符
# 3. 多关键词奖励(≥2 个同时命中 × 1.3)
hit_count = len(set(matched_keywords))
if hit_count >= 2:
score *= 1.3
# 4. 阈值:必须 > 0.3 才算有效命中
if score > best_score and score > 0.3:
best_score = score
best_skill = skill
return best_skill
4.2 实际打分示例
假设用户输入:”帮我规划并写一篇关于 AI Agent 的文章大纲”(共 28 字符)
| Skill | 触发词 | 命中 | 覆盖字符 | 基础分 | 多词奖励 | 最终分 |
|---|---|---|---|---|---|---|
| plan_and_write | 规划、规划并写、写大纲 | ✅ | 13 字符 | 13/28 = 0.464 | × 1.3 → 0.603 | ✅ 激活 |
| search_rag | 查知识库、RAG | ❌ | 0 | – | – | – |
- 单个短关键词误触的典型场景:用户输入 “这个代码规划一下”(6 字符)。命中”规划”(2 字符)。基础分 = 2/6 = 0.333。但只命中 1 个词,没有 ×1.3 奖励。最终分 0.333 > 0.3,刚好过阈值。这是一个边界 case,偶尔会被激活,但因为 Skill 本身的 instructions 里有”先确认范围”,即使误激活也不会出大问题。
- 阈值 0.3 + 多关键词奖励的组合,就是为了让”规划”这种单独短关键词刚好卡在边界,而”规划并写”+”写大纲”组合明显超过边界。
4.3 为什么不用 LLM meta-routing
| 方案 | 优点 | 缺点 |
|---|---|---|
| 关键词匹配(我们选的) | 零 API 调用、微秒级、可解释 | 语义差(”用 planner 帮我写”vs”用规划帮我写”覆盖不同) |
| LLM meta-routing | 语义精准 | 每次多一次 API 调用(~500ms + 额外 token) |
| 混合方案(预筛 Top-3 → LLM 选) | 平衡 | 稍复杂 |
注释里已标注”后面可以升级为混合方案”——先关键词预筛拿 Top-3 候选,再让 LLM 从 Top-3 里选。但在当前规模(1 个 Skill,默认场景)下,关键词匹配已经完全够用。
五、Skill 激活时到底发生了什么
AgentRuntime.run() 里 Skill 激活是破坏性替换——完全隔离:
# runtime.py L56-L67 — 激活的完整效果链
if selected_skill is not None:
# 1. 替换 system prompt(原来的路由指令全部消失)
history = self._apply_skill_prompt(history, selected_skill.instructions)
# 2. 收窄工具集(只保留 Skill 声明的)
required = set(selected_skill.required_tools)
active_map = {name: fn for name, fn in tool_map.items()
if not required or name in required}
# 3. 覆盖迭代上限
max_iterations = selected_skill.max_rounds
对比:激活 vs 不激活
| 维度 | 默认状态(不激活 Skill) | plan_and_write Skill 激活后 |
|---|---|---|
| System Prompt | 通用路由 Prompt(~1500 字) | “你是一个专业任务规划与执行者”(Skill 正文) |
| 可见工具数 | 21 | 5(search_knowledge, web_search, file_read, file_write, extract_structured) |
| 迭代上限 | 5 | 5(相同,但 Skill 里可以改成 10) |
| 历史对话保留 | ✅ | ✅(只替换第一个 system message) |
为什么是”替换”不是”追加”
追加 Prompt 会让 LLM 收到两份指令,容易冲突。比如原来的通用 Prompt 说”执行破坏性操作前调 human_confirm”,Skill 追加的 Prompt 没提安全。LLM 面对两份指令时行为不可预测。
替换的好处是 Skill 有完全的身份控制权——它决定自己是谁、该怎么做、能用什么工具。代价是通用安全指引(human_confirm 等)不会自动继承到 Skill 里,必须在 Skill instructions 里自己写一遍。
六、写一个 Skill 需要多久
答:10 分钟。 流程是:
- 想清楚场景:这个 Skill 解决什么问题?(”帮我查资料→提炼关键数据点→写入文件”)
- 写 Frontmatter:5-10 个触发词、3-5 个必需工具、迭代上限(默认 5)
- 写正文:Markdown 格式的工作流描述 + 注意事项(给 LLM 当 system prompt)
- 保存为
.md文件:放skills/目录下,重启 Agent 自动加载
就这么简单。不需要改 Python、不需要改 Prompt 字符串、不需要重启服务进程(CLI 重启一次、Web UI 自动重新扫描 skills 目录)。
七、演进方向
当前 Skill 机制还有几个已知局限,可以逐步增强:
| 局限 | 现状 | 演进方向 |
|---|---|---|
| YAML 子集有限 | 不支持嵌套字典、多行字符串 | 如果未来需要,升级为 PyYAML(但目前 Skill 元数据用不到) |
| 路由是纯关键词 | 语义差 | 关键词预筛 Top-3 → LLM meta-routing |
| 替换丢失安全指引 | Skill Prompt 里没写 human_confirm 就没了 | Skill instructions 支持 {{base_safety_rules}} 占位符自动注入 |
| Skill 之间无法串联 | 激活了 Skill A 就不能再激活 Skill B | 支持 Skill 链式触发(Skill A 执行完后让 LLM 决定要不要激活 Skill B) |