前两篇我们搓出了一个能执行命令、读写文件、还会列计划的 agent。功能上够用了,但有个尴尬的问题:它什么都知道一点,什么都不精通。
你让它写个 React 组件,它写得出来,但可能用的是过时的 class 组件写法。你让它配置 Nginx,它也能给你一份配置,但可能漏掉了安全相关的 header。
这不怪模型。模型的知识来自训练数据,它没法在每个领域都保持专业深度。但我们可以在需要的时候,给它"临时补课"。
Claude Code 里有个 skill 机制。用户可以写一些 markdown 文件,描述某个领域的专业知识、最佳实践、工作流程。agent 在处理相关任务时,先加载对应的 skill,再动手干活。这就像一个程序员在写代码前先翻翻文档一样。
这篇文章来实现这个机制。
Skill 长什么样
一个 skill 就是一个 markdown 文件,放在 skills/ 目录下。文件开头用 YAML front matter 写元信息,后面是正文:
---
name: planning-with-files
description: Transforms workflow to use persistent markdown files for planning and progress tracking.
---
# Planning with Files
Work like Manus: Use persistent markdown files as your "working memory on disk."
## Quick Start
Before ANY complex task:
1. Create `task_plan.md` in the working directory
2. Define phases with checkboxes
...
name 和 description 是给 agent 看的摘要,帮它判断什么时候该加载这个 skill。正文才是真正的知识内容,加载后会注入到对话上下文里。
这个设计有一个好处:description 很短,可以全部塞进 system prompt,让模型知道有哪些 skill 可用;正文可能很长,只在需要时按需加载,不浪费 token。
SkillLoader:解析和管理
SkillLoader 负责在启动时扫描 skills/ 目录,解析所有 markdown 文件,然后提供两个接口——获取描述列表和获取正文内容。
先看解析逻辑。YAML front matter 的格式很固定:以 --- 开头,以 --- 结尾,中间是 key: value 格式的元信息。我们不需要引入 PyYAML,手动解析就够了:
def _parse_skill_md(self, text: str) -> tuple[dict, str]:
meta = {"name": "", "description": ""}
body = text
if not text.startswith("---"):
return meta, body
end = text.find("---", 3)
if end == -1:
return meta, body
front_matter = text[3:end].strip()
body = text[end + 3:].strip()
for line in front_matter.splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip().lower()
value = value.strip()
if key in meta:
meta[key] = value
return meta, body
找到两个 --- 之间的部分,逐行用 partition(":") 分割 key 和 value。partition 比 split 好在这里——如果 value 本身包含冒号(比如 URL),split 会切碎,partition 只在第一个冒号处分割。
启动时,_load_all_skills 遍历目录下所有 .md 文件,解析后存入字典:
def _load_all_skills(self):
if not self.skills_dir.exists():
raise ValueError("Skills_dir not exists, please check path!")
for f in sorted(self.skills_dir.glob("*.md")):
name = f.stem
text = f.read_text()
meta, body = self._parse_skill_md(text)
self.skills[name] = {"meta": meta, "body": body, "path": str(f)}
两个接口
get_descriptions 把所有 skill 的名字和描述拼成文本,用于注入 system prompt:
def get_descriptions(self) -> str:
if not self.skills:
return "no skills"
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "No description")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
get_body 根据名字返回正文,用 XML 标签包裹:
def get_body(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
为什么用 <skill> 标签包裹?因为这段内容会作为工具返回值注入到消息历史里。用标签包裹能让模型明确区分"这是 skill 知识"和"这是工具执行结果",减少混淆。
注入 system prompt
在 system prompt 里告诉模型有哪些 skill 可用,以及什么时候该用:
SYSTEM_PROMPT = f"""
You are a coding agent, {os.getcwd()} is your working directory, you can use bash tools.
Use load_skill to access specialized knowledge before tackling unfamiliar topics.
Skills available:
{skill_loader.get_descriptions()}
Current runtime system os is {platform.system()}
"""
模型看到的效果类似于:
Skills available:
- planning-with-files: Transforms workflow to use persistent markdown files for planning...
模型会根据用户的问题和这些描述来判断要不要加载某个 skill。
load_skill 工具
工具本身非常简单,就是一层薄封装:
@tool
def load_skill(name: str) -> str:
"""load skill knowledge by name"""
return skill_loader.get_body(name)
模型调用这个工具后,skill 的正文会作为 ToolMessage 进入消息历史。之后模型在回答问题时,就能参考这些专业知识了。
调试日志的改进
这一版还改进了终端输出。之前只打印最终回复,现在把每一轮的工具调用过程都展示出来:
round_num = 1
while response.tool_calls:
print(f"\n[工具调用 - 第 {round_num} 轮]")
for tool_call in response.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
print(f" - 调用工具: {tool_name}")
print(f" 参数: {tool_args}")
result = tools_dict[tool_name].invoke(tool_args)
result_preview = result if len(result) <= 200 else result[:200] + "..."
print(f" 结果: {result_preview}")
...
round_num += 1
工具结果超过 200 字符就截断显示,避免 skill 正文刷屏。这个改动看起来不起眼,但在调试时特别有用——能直观看到模型在每一步做了什么决策,调用了什么工具,拿到了什么结果。
实际运行的样子
假设 skills 目录下有一个 planning-with-files.md,用户输入"帮我规划一个新项目的目录结构":
[用户输入] 帮我规划一个新项目的目录结构
[工具调用 - 第 1 轮]
- 调用工具: load_skill
参数: {'name': 'planning-with-files'}
结果: <skill name="planning-with-files">
# Planning with Files
Work like Manus: Use persistent markdo...
[模型回复 - 第 1 轮后] 根据规划最佳实践,建议先创建以下文件...
[工具调用 - 第 2 轮]
- 调用工具: run_bash
参数: {'command': 'mkdir -p src tests docs'}
结果: (no output)
[最终回复] 项目目录结构已创建完成...
模型先加载了 planning 相关的 skill,获取了专业知识,然后根据这些知识来执行任务。整个过程对用户透明。
怎么写一个好的 Skill
写 skill 和写文档不一样。文档是给人看的,skill 是给模型看的。几个经验:
指令要明确。“建议使用 TypeScript"不如"所有新文件必须使用 TypeScript,不要用 JavaScript”。模型对模糊的建议经常忽略,对明确的指令执行得更好。
结构要清晰。用标题分层,用列表枚举,用代码块给示例。模型解析结构化内容比解析长段落准确得多。
长度要克制。skill 正文会占用上下文窗口。一个 skill 控制在几百行以内,把核心知识讲清楚就够了。如果某个领域的知识太多,拆成多个 skill。
和前两篇的对比
第一篇的 agent 只有"手"——能执行操作但没有规划。第二篇加了"计划本"——能拆解任务按步骤推进。这一篇加了"参考书"——能在动手前先查阅专业知识。
三篇加在一起,一个 coding agent 的基本骨架就搭起来了:接收指令、加载知识、制定计划、执行操作。