第 5 章:技能与扩展¶
让 Agent 的能力可以动态扩展,不改代码就能学会新技能。
问题¶
前四章的 Agent 能力是硬编码的——exec、read_file、write_file。想加"查天气"?改代码。想加"操作 GitHub"?改代码。
但 nanobot 的用户只需要往 workspace/skills/ 放一个 Markdown 文件,Agent 就自动学会了新技能。这是怎么做到的?
核心原理:技能 = 动态 Prompt¶
技能并不是新的工具(Tool),而是注入到 System Prompt 中的领域知识。
如果配置允许,Agent 已经有 exec 等工具,可以在工具与系统边界内执行动作。它缺的往往不是新的调用入口,而是知识——不知道查天气时该选什么数据源、如何校验输入和处理失败。
Skill 就是把这些知识教给它:
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 的 memory、my Skill 带 always: true;固定对照的 main@b189a376 已移除这些标记,并把摘要改为“绝对根目录 + 相对 Skill 路径”分组。不要把某个内置 Skill 当前是否 always 当作永久类别规则。
实现 SkillsLoader¶
对应 v0.2.2 的 SkillsLoader。下面只保留扫描、覆盖和摘要构建主线:
这个
_get_description是故意写小的教学版,只解析单行description:。代码演示了目录覆盖、禁用与摘要;没有重复实现 YAML、requires、always和缺失依赖说明。真实行为以固定版本的SkillsLoader为准,并把description视为模型选择的主要线索,而不是唯一决定因素。
渐进式加载的三层设计¶
nanobot 不会把所有 Skill 的完整内容都塞进 System Prompt。它用三层加载:
摘要成本随 Skill 数量、描述、路径和 tokenizer 变化,正文成本又取决于模型实际读取了什么,所以不能给出固定的“每个 Skill 多少 token”估算。
在 ContextBuilder 中集成:
创建你的第一个 Skill¶
创建 ~/.mini-agent/workspace/skills/weather/SKILL.md:
现在当用户问天气时,Agent 会:
- 看到 Skills 摘要中的
weather候选与可用状态 - 模型判断它与当前请求有关,并用
read_file读取SKILL.md全文 - 学到用
curl查天气的方法 - 用
exec执行curl命令 - 成功时转述实际结果;失败时明确说明失败
没有任何代码改动。
什么时候该用 scripts/¶
第一次教学时,直接把命令写进 SKILL.md 很方便。但当某段逻辑开始变长、变脆弱、需要重复使用时,就应该考虑把它下沉成 scripts/:
- 命令特别长
- 解析逻辑开始依赖多步转换
- 你希望同样操作每次都更稳定
- 你不希望模型在执行前随意改写关键步骤
Skill 负责告诉 Agent“什么时候调用这个脚本”;脚本负责把事情稳定做对。
架构全景¶
经过五章的构建,我们的 Agent 已经拥有了 nanobot 的核心架构:
对照 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.py 与 ChannelManager |
发现、启动并路由 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.py、discord/runtime.py、slack/runtime.py 和 websocket/runtime.py,插件描述与 WebUI 扩展则放在各包的 manifest.py 和 webui/ 中。这是 main 差异,不是 v0.2.2 安装步骤。
但它们的底层原理和我们构建的完全一样——都是在这个骨架上增加模块。
你接下来可以做什么?¶
- 先读下一章:如果你准备把教学版继续发展成自己的项目,先看第 6 章,明确工程化边界
- 读 nanobot 源码:现在你已经理解了架构,读源码会非常顺畅
- 给你的 Mini Agent 加功能:
- 加一个
web_search工具(用 Brave Search API) - 观察并扩展第 3 章的 Consolidator / Dream 两层记忆生命周期
- 接入 Telegram(第 4 章的 TelegramChannel)
- 创建你自己的 Skills:查天气、查汇率、操作 GitHub...
- 贡献 nanobot:项目欢迎 PR,代码库刻意保持精简
本章你真正学到的抽象¶
这一章最核心的抽象是:很多“新能力”并不需要新增代码级工具,而是可以作为“按需加载的领域知识”接入系统。
也就是说,你现在手里有两种扩展手段:
Tool:给 Agent 新的执行能力Skill:给 Agent 新的使用知识和工作方法
把两者分开,系统才不会每加一种场景能力就膨胀一层底层代码。
最小验证步骤¶
建议至少做下面这些离线结构验证;真实天气请求只作为可选人工冒烟:
- 创建一个最简单的 Skill,确认它能出现在 skills summary 中
- 暂时缺少一个
requires.bins,确认摘要显示unavailable,而不是静默伪装成可用 - 把目录名加入
disabledSkills,确认它不再出现在摘要或Active Skills - 创建一个很短的
alwaysSkill,确认满足依赖时注入全文;再移除标记,确认它回到摘要 - 提一个与
description强相关的问题,观察模型是否选择读取SKILL.md;换模型时允许结果不同 - 创建同目录名的 workspace Skill 覆盖内置 Skill,确认优先级;再让工作区版本缺依赖,确认不会自动回退
常见失败点¶
- Skill 存在但不触发:首先怀疑
description写法,而不是怀疑加载器本身 - Skill 完全不可见:检查目录名是否在
disabledSkills,以及当前 Agent 工作区是否正确 - Skill 标为
unavailable:按缺失提示检查命令和环境变量,只确认是否存在,不打印凭据值 - Skill 很长但效果很差:通常是写成了文档,而不是写成了 Agent 可执行的操作说明
- 什么都往 Skill 里塞:如果需要稳定的输入输出、确定性执行和强校验,应该下沉成 Tool 或脚本,而不是只靠提示词
- workspace skill 没覆盖 builtin:先检查目录名,再检查扫描顺序,不要只看 frontmatter 的
name
配套示例¶
- 对应代码快照:examples/hero/ch05-skills-loader.py
- 配套目录说明:examples/hero/README.md
这个示例聚焦在 SkillsLoader 和技能摘要构建上,因为第 5 章真正新增的抽象核心就在这里。
回顾:五章走过的路¶
每一步都是一个关键概念的引入:
| 章节 | 引入的概念 | 一句话总结 |
|---|---|---|
| 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 再用校验、隔离、重试、并发和平台适配把它做稳;教学代码只负责把这些抽象讲清楚。