前两篇我们搓出了一个能执行命令、读写文件、还会列计划的 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
...

namedescription 是给 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。partitionsplit 好在这里——如果 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 的基本骨架就搭起来了:接收指令、加载知识、制定计划、执行操作。