跳转至

第 5 章:技能与扩展

让 Agent 的能力可以动态扩展,不改代码就能学会新技能。

问题

前四章的 Agent 能力是硬编码的——exec、read_file、write_file。想加"查天气"?改代码。想加"操作 GitHub"?改代码。

但 nanobot 的用户只需要往 workspace/skills/ 放一个 Markdown 文件,Agent 就自动学会了新技能。这是怎么做到的?

核心原理:技能 = 动态 Prompt

技能并不是新的工具(Tool),而是注入到 System Prompt 中的领域知识

如果配置允许,Agent 已经有 exec 等工具,可以在工具与系统边界内执行动作。它缺的往往不是新的调用入口,而是知识——不知道查天气时该选什么数据源、如何校验输入和处理失败。

Skill 就是把这些知识教给它:

1
2
3
4
5
6
7
# Weather Skill

用 curl 查天气(不需要 API Key;失败时必须停止):
\```bash
curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
  "https://wttr.in/Beijing?format=3"
\```

Agent 读到这个 Skill 后,下次用户问"北京天气怎么样",它就知道该用 exec 执行 curl 命令了。

网络型 Skill 仍要验证输入、设置超时并处理非成功响应。它只能作为可选人工冒烟测试;文档 CI 不访问真实天气或汇率服务。

为什么 Skill 不是 Tool

这里一定要把边界掰开:

  • Tool 解决的是“Agent 能不能做 这件事”
  • Skill 解决的是“Agent 知不知道什么时候该做、该怎么做 这件事”

比如 exec 这个工具早就已经给了 Agent“执行命令”的能力;weather Skill 做的,是补上“查天气时应该执行什么命令”这部分知识。

如果你把两者混为一谈,系统就会越来越臃肿:每新增一个场景,都要往底层代码里塞一个新工具。

真实生命周期:加载器筛选,模型选择

“Skill 触发”不是一条写死在 Python 里的关键词路由。v0.2.2 的实际顺序是:

阶段 SkillsLoader 的行为 对模型的结果
扫描与覆盖 先收集 Agent 工作区,工作区同目录名覆盖内置 Skill 同名时只保留工作区版本
禁用 agents.defaults.disabledSkills 按目录名排除 不进入摘要或 Active Skills
依赖 检查 metadata.nanobot.requires.bins/env 缺失依赖的普通 Skill 标为 unavailable
always 未禁用、依赖满足且标记为真时加载完整正文 每轮进入 Active Skills,不再列入普通摘要
普通选择 摘要提供目录名、描述、位置和可用状态 模型结合用户请求决定是否调用 read_file

因此 description 是主要选择线索,不是唯一条件;显式点名 Skill 可以用于诊断,也不等于系统建立了硬绑定。disabledSkills 和依赖检查管理可见性/可用性,同样不是 Tool 权限或系统沙箱。

main 差异:always 与摘要格式都会演进

v0.2.2 的 memorymy Skill 带 always: true;固定对照的 main@b189a376 已移除这些标记,并把摘要改为“绝对根目录 + 相对 Skill 路径”分组。不要把某个内置 Skill 当前是否 always 当作永久类别规则。

实现 SkillsLoader

对应 v0.2.2 的 SkillsLoader。下面只保留扫描、覆盖和摘要构建主线:

import re
from pathlib import Path

class SkillsLoader:
    """技能加载器——对应 nanobot/agent/skills.py"""

    def __init__(
        self,
        workspace: Path,
        builtin_dir: Path | None = None,
        disabled_skills: set[str] | None = None,
    ):
        self.workspace_skills = workspace / "skills"
        self.builtin_skills = builtin_dir
        self.disabled_skills = disabled_skills or set()

    def list_skills(self) -> list[dict]:
        """扫描候选 Skill,返回名字、描述和路径。"""
        skills = []
        # 工作区技能优先
        if self.workspace_skills.exists():
            for d in self.workspace_skills.iterdir():
                skill_file = d / "SKILL.md"
                if d.is_dir() and skill_file.exists():
                    skills.append({
                        "name": d.name,
                        "path": str(skill_file),
                        "description": self._get_description(skill_file),
                    })
        # 然后加载内置技能(不覆盖同名的工作区技能)
        if self.builtin_skills and self.builtin_skills.exists():
            existing = {s["name"] for s in skills}
            for d in self.builtin_skills.iterdir():
                skill_file = d / "SKILL.md"
                if d.is_dir() and skill_file.exists() and d.name not in existing:
                    skills.append({
                        "name": d.name,
                        "path": str(skill_file),
                        "description": self._get_description(skill_file),
                    })
        # disabledSkills 同时作用于工作区与内置同名 Skill。
        return [s for s in skills if s["name"] not in self.disabled_skills]

    def build_skills_summary(self) -> str:
        """构建教学版的普通候选摘要。"""
        skills = self.list_skills()
        if not skills:
            return ""
        lines = ["<skills>"]
        for s in skills:
            lines.append(f'  <skill>')
            lines.append(f'    <name>{s["name"]}</name>')
            lines.append(f'    <description>{s["description"]}</description>')
            lines.append(f'    <location>{s["path"]}</location>')
            lines.append(f'  </skill>')
        lines.append("</skills>")
        return "\n".join(lines)

    def _get_description(self, path: Path) -> str:
        """从 frontmatter 提取 description"""
        content = path.read_text(encoding="utf-8")
        if content.startswith("---"):
            match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
            if match:
                for line in match.group(1).split("\n"):
                    if line.startswith("description:"):
                        return line.split(":", 1)[1].strip().strip("\"'")
        return path.parent.name

这个 _get_description 是故意写小的教学版,只解析单行 description:。代码演示了目录覆盖、禁用与摘要;没有重复实现 YAML、requiresalways 和缺失依赖说明。真实行为以固定版本的 SkillsLoader 为准,并把 description 视为模型选择的主要线索,而不是唯一决定因素。

渐进式加载的三层设计

nanobot 不会把所有 Skill 的完整内容都塞进 System Prompt。它用三层加载

1
2
3
普通摘要:目录名 + 描述 + 位置 + 可用状态  → 每轮提供候选信息
Active Skills:always Skill 的完整正文     → 仅在未禁用且依赖满足时注入
按需内容:普通 SKILL.md 与附加资源          → 模型决定是否读取或执行

摘要成本随 Skill 数量、描述、路径和 tokenizer 变化,正文成本又取决于模型实际读取了什么,所以不能给出固定的“每个 Skill 多少 token”估算。

在 ContextBuilder 中集成:

class ContextBuilder:
    def __init__(self, workspace: Path):
        self.workspace = workspace
        self.skills = SkillsLoader(workspace)

    def build_system_prompt(self) -> str:
        parts = [self._get_identity()]

        # ... bootstrap files, memory (同第 3 章) ...

        # 教学版只演示普通摘要。真实实现会先完整注入满足条件的
        # always Skills,再从摘要中排除它们。
        summary = self.skills.build_skills_summary()
        if summary:
            parts.append(
                "# Skills\n\n"
                "以下技能扩展了你的能力。需要时用 read_file 读取 SKILL.md 获取详情。\n\n"
                + summary
            )

        return "\n\n---\n\n".join(parts)

创建你的第一个 Skill

mkdir -p ~/.mini-agent/workspace/skills/weather

创建 ~/.mini-agent/workspace/skills/weather/SKILL.md

---
name: weather
description: Get current weather and forecasts from an external source. Use when the user asks for weather data that must be fetched rather than guessed.
metadata: {"nanobot":{"requires":{"bins":["curl"]}}}
---

# Weather

Free weather via wttr.in (no API key):

\```bash
# 简洁格式
curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
  "https://wttr.in/CityName?format=3"

# 详细格式
curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
  "https://wttr.in/CityName?format=%l:+%c+%t+%h+%w"
\```

Accept only a city/location value, URL-encode it, and never splice raw user text into a shell command. If curl fails or times out, report that no weather result was obtained; do not invent one.

现在当用户问天气时,Agent 会:

  1. 看到 Skills 摘要中的 weather 候选与可用状态
  2. 模型判断它与当前请求有关,并用 read_file 读取 SKILL.md 全文
  3. 学到用 curl 查天气的方法
  4. exec 执行 curl 命令
  5. 成功时转述实际结果;失败时明确说明失败

没有任何代码改动。

什么时候该用 scripts/

第一次教学时,直接把命令写进 SKILL.md 很方便。但当某段逻辑开始变长、变脆弱、需要重复使用时,就应该考虑把它下沉成 scripts/

  • 命令特别长
  • 解析逻辑开始依赖多步转换
  • 你希望同样操作每次都更稳定
  • 你不希望模型在执行前随意改写关键步骤

Skill 负责告诉 Agent“什么时候调用这个脚本”;脚本负责把事情稳定做对。

架构全景

经过五章的构建,我们的 Agent 已经拥有了 nanobot 的核心架构:

┌─────────────────────────────────────────────────┐
│                  config.json                     │
│          (providers, channels, tools)            │
└────────────────────┬────────────────────────────┘
    ┌────────────────┼────────────────┐
    ↓                ↓                ↓
Provider        AgentLoop         Channels
(LLM 连接)    (核心引擎)       (平台集成)
    │                │                │
    │     ┌──────────┼──────────┐     │
    │     ↓          ↓          ↓     │
    │  Context    ToolReg    Session   │
    │  Builder    istry      Manager  │
    │     │          │                │
    │  ┌──┼──┐    ┌──┼──┐            │
    │  ↓  ↓  ↓    ↓  ↓  ↓            │
    │ SOUL mem skills exec read write │
    │ .md  ory       file  file      │
    │                                 │
    └─────────── MessageBus ──────────┘

对照 nanobot 源码

源码会持续变化,固定行数很快失真。这里改用类名和固定 commit 永久链接,比较“教学版保留了什么”与“真实实现还负责什么”:

我们构建的 教学版保留 nanobot v0.2.2 固定源码 真实实现还包含
LLM 调用 单 Provider 请求 providers/ 原生 / OpenAI-compatible / OAuth / 本地后端与流式协议适配
Tool + Registry schema 与执行入口 agent/tools/ 参数校验、运行时上下文、隔离、MCP 与更多工具
模型/工具循环 有上限的顺序循环 AgentRunner 流式输出、工具事件、取消、错误与停止原因
Session JSONL 读写 SessionManager metadata、缓存、迁移、归档状态
Context Bootstrap + Memory ContextBuilder 内置工具契约、Skills、Recent History 与运行时上下文
Memory 文件读写与归档思想 MemoryStore / Consolidator Consolidator + Dream + 游标与可选版本历史
MessageBus 入站/出站队列 MessageBus 队列状态及进度、运行时事件协作
Channel 统一 start/stop/send BaseChannel ACL/配对、媒体、流式输出与集中路由
Skills 扫描、覆盖、禁用和摘要 SkillsLoader YAML frontmatter、依赖状态、always 注入与模型驱动选择

教学版与真实 nanobot 的差距不在某个行数,而在工程化健壮性: - 参数校验与类型转换 - 错误恢复与重试 - 多平台的边界情况处理 - 安全防护(ACL、路径限制、命令过滤) - 并发控制(多个用户同时对话) - 记忆整合(上下文窗口管理)

这些是从"能跑"到"能用"的距离。

nanobot 还有什么我们没做的?

功能 v0.2.2 固定源码映射 作用
定时任务 CronService 让 Agent 执行持久化的计划任务
Heartbeat Gateway 在 cli/commands.py 注册受保护的系统 Job,再由 CronService 调度 定期读取 HEARTBEAT.md;没有独立的 Heartbeat service 模块
子 Agent agent/subagent.py 后台派生子任务
MCP 协议 agent/tools/mcp.py 连接外部工具服务器
多个 Channel channels/registry.pyChannelManager 发现、启动并路由 Telegram / Discord / Slack 等平台适配器
Provider 注册表 providers/registry.py 声明式配置多种 LLM Provider
OpenAI-compatible API api/server.py 让外部程序通过 /v1/chat/completions 调用 nanobot
Python SDK nanobot/nanobot.py 在 Python 代码中使用 Nanobot.from_config()
WebSocket / WebUI Channel registry + webui/ 给浏览器或自定义客户端提供实时会话入口

main 差异:Channel 源码改为包式 runtime

上表仍以 v0.2.2 的可操作基线解释职责。固定对照的 main@b189a376 已把每个平台改成自包含插件包;实现入口应查看 telegram/runtime.pydiscord/runtime.pyslack/runtime.pywebsocket/runtime.py,插件描述与 WebUI 扩展则放在各包的 manifest.pywebui/ 中。这是 main 差异,不是 v0.2.2 安装步骤。

但它们的底层原理和我们构建的完全一样——都是在这个骨架上增加模块。

你接下来可以做什么?

  1. 先读下一章:如果你准备把教学版继续发展成自己的项目,先看第 6 章,明确工程化边界
  2. 读 nanobot 源码:现在你已经理解了架构,读源码会非常顺畅
  3. 给你的 Mini Agent 加功能
  4. 加一个 web_search 工具(用 Brave Search API)
  5. 观察并扩展第 3 章的 Consolidator / Dream 两层记忆生命周期
  6. 接入 Telegram(第 4 章的 TelegramChannel)
  7. 创建你自己的 Skills:查天气、查汇率、操作 GitHub...
  8. 贡献 nanobot:项目欢迎 PR,代码库刻意保持精简

本章你真正学到的抽象

这一章最核心的抽象是:很多“新能力”并不需要新增代码级工具,而是可以作为“按需加载的领域知识”接入系统。

也就是说,你现在手里有两种扩展手段:

  • Tool:给 Agent 新的执行能力
  • Skill:给 Agent 新的使用知识和工作方法

把两者分开,系统才不会每加一种场景能力就膨胀一层底层代码。

最小验证步骤

建议至少做下面这些离线结构验证;真实天气请求只作为可选人工冒烟:

  1. 创建一个最简单的 Skill,确认它能出现在 skills summary 中
  2. 暂时缺少一个 requires.bins,确认摘要显示 unavailable,而不是静默伪装成可用
  3. 把目录名加入 disabledSkills,确认它不再出现在摘要或 Active Skills
  4. 创建一个很短的 always Skill,确认满足依赖时注入全文;再移除标记,确认它回到摘要
  5. 提一个与 description 强相关的问题,观察模型是否选择读取 SKILL.md;换模型时允许结果不同
  6. 创建同目录名的 workspace Skill 覆盖内置 Skill,确认优先级;再让工作区版本缺依赖,确认不会自动回退

常见失败点

  • Skill 存在但不触发:首先怀疑 description 写法,而不是怀疑加载器本身
  • Skill 完全不可见:检查目录名是否在 disabledSkills,以及当前 Agent 工作区是否正确
  • Skill 标为 unavailable:按缺失提示检查命令和环境变量,只确认是否存在,不打印凭据值
  • Skill 很长但效果很差:通常是写成了文档,而不是写成了 Agent 可执行的操作说明
  • 什么都往 Skill 里塞:如果需要稳定的输入输出、确定性执行和强校验,应该下沉成 Tool 或脚本,而不是只靠提示词
  • workspace skill 没覆盖 builtin:先检查目录名,再检查扫描顺序,不要只看 frontmatter 的 name

配套示例

这个示例聚焦在 SkillsLoader 和技能摘要构建上,因为第 5 章真正新增的抽象核心就在这里。


回顾:五章走过的路

1
2
3
第 1 章  →  第 2 章  →  第 3 章  →  第 4 章  →  第 5 章
聊天       能做事      有记忆      多平台      可扩展
Chatbot     Agent      有个性      Gateway     Skills

每一步都是一个关键概念的引入:

章节 引入的概念 一句话总结
1 LLM API 调 API 就能对话
2 Function Calling + ReAct 让 LLM 能调用工具,循环直到完成
3 Session + Context + Memory 记住对话、组装 Prompt、持久化记忆
4 MessageBus + Channel 解耦 I/O,一个 Agent 服务多平台
5 Skills 动态注入领域知识,不改代码扩展能力

这就是一个 AI Agent 框架的核心主线。nanobot 再用校验、隔离、重试、并发和平台适配把它做稳;教学代码只负责把这些抽象讲清楚。