数据截至 (上游 commit e8fd37796aca)
配置与 Agent 定义:YAML、Markdown、合并
30 秒导读: 你给 nanobot 一个
nanobot.yaml(或一个装着agents/*.md的目录),它读进来、按extends继承父配置、按profiles叠加环境差异、把相对路径改写成绝对路径,最后深度合并成一个types.Config结构体——运行时的一切(有哪些 agent、连哪些 MCP server、用什么模型)都从这个结构体里长出来。本章讲这条"文本 → 运行时对象"的流水线。
本章只讲"配置怎么变成运行时对象"。配置变成对象之后怎么跑(主循环、工具调用、LLM 路由),分别在 02-agent-loop、03-tools-and-mcp、04-llm-dialects-and-sampling 里讲。
1. 这是什么(零基础也能懂)
一句话定义: nanobot 的"配置层"是一个把人写的 YAML/Markdown 翻译成一个 Go 结构体(types.Config)的加载器。
解决什么问题: 你想搭一个 AI 聊天 agent,得说清楚三件事——用哪个大模型、连哪些工具(MCP server)、给 agent 什么指令(system prompt)。nanobot 让你把这些写进配置文件,而不是写代码。启动时它把文件读成对象,后面所有模块都读这个对象。
两种写法,各有场景:
| 写法 | 长什么样 | 适合谁 |
|---|---|---|
| 单文件 | 一个 nanobot.yaml,agent 和 MCP server 都写在里面 | 小项目、一两个 agent、想一眼看全 |
| 目录 | 一个目录,顶层 nanobot.yaml 放共享设置 + agents/*.md 每个 agent 一个文件 | 多 agent、指令(prompt)很长、想把每个 agent 当独立文档管理 |
用起来什么样(单文件): 这是 README 里的最小例子——一个叫 dealer 的 agent,用 gpt-4.1,挂上一个远程 MCP server。
agents:
dealer:
name: Blackjack Dealer
model: gpt-4.1
mcpServers: blackjackmcp
mcpServers:
blackjackmcp:
url: https://blackjack.nanobot.ai/mcp
nanobot run ./nanobot.yaml
用起来什么样(目录 + Markdown): 每个 agent 是一个 .md 文件——YAML frontmatter 写配置,正文写指令。这种写法把"agent 的人格"和"结构化字段"放在同一个文件里,读 起来像一份文档。
---
name: Shopping Assistant
model: anthropic/claude-3-7-sonnet-latest
mcpServers:
- store
temperature: 0.7
---
You are a helpful shopping assistant.
Help users find products and answer their questions.
一句话直觉: 把配置层想成编译前端——源代码(YAML/Markdown)先词法/语法分析,再经过几趟"变换"(继承、叠加、路径重写),最后产出一份统一的中间表示(types.Config),交给后端(运行时)执行。
本节不出现代码细节。目标:知道"配置层是干嘛的、有两种写法"。
2. 顶层全景(一次加载怎么转)
入口函数是 Load / LoadMany(pkg/config/load.go:24,28)。给它一个或多个路径,它吐出一个 *types.Config。中间经过的处理阶段如下——从上到下就是执行顺序:
多个路径 paths[]
│
▼
┌───────────────────────┐ loadSingle 对每个路径各跑一遍
│ ① resolve(path) │ 判断这是 本地文件 / HTTP / git / 目录?
└───────────────────────┘ 产出一个 resource(resolver.go)
│
▼
┌───────────────────────┐ resource.read → 若目录含 *.md,
│ ② 读原始配置 │ 走 loadFromDirectory 把 md 拼成 yaml
└───────────────────────┘
│
▼
┌───────────────────────┐ loadResource 主变换链:
│ ③ extends 继承 │ 先加载父配置,父 Merge 进当前
│ ④ profiles 叠加 │ 选中的 profile Merge 进当前
│ ⑤ rewriteCwd │ MCP server 的 cwd 拼上目录前缀
│ ⑥ rewriteSourceRefs │ source 相对引用 → 绝对/仓库引用
│ ⑦ 单 agent 自动入口 │ 只有一个 agent 时设为 Entrypoint
│ ⑧ Validate │ 校验引用、重名、入口
└───────────────────────┘
│
▼
┌───────────────────────┐ LoadMany:多个路径的结果两两 Merge
│ ⑨ 跨路径 Merge │
└───────────────────────┘
│
▼
┌───────────────────────┐ 若 includeDefaultAgents,
│ ⑩ loadBuiltinAgents │ 把内嵌的内置 agent 加进来
└─────────── ────────────┘
│
▼
*types.Config
怎么读这张图: 每个路径(path)先独立走完 ①–⑧ 变成一个 Config,LoadMany 再把这些 Config 依次 Merge 成一个(⑨),最后可选地补上内置 agent(⑩)。
各阶段的落点:
| 阶段 | 干什么 | 符号 · 位置 |
|---|---|---|
| resolve | 判断资源类型(path/http/git/static) | resolve · pkg/config/resolver.go:351 |
| 读原始配置 | 读文件;目录含 md 则拼装 | resource.read · resolver.go:199 |
| 目录拼装 | md agent 覆盖 yaml agent | loadFromDirectory · directory.go:238 |
| extends | 父配置合并进当前 | loadResource · load.go:104-131 |
| profiles | 选中 profile 合并进当前 | loadResource · load.go:133-146 |
| rewriteCwd | MCP server cwd 加目录前缀 | rewriteCwd · load.go:164 |
| rewriteSourceReferences | 相对 source 变绝对/仓库 | rewriteSourceReferences · load.go:174 |
| 单 agent 入口 | 唯一 agent 自动成 Entrypoint | loadResource · load.go:155-159 |
| 深度合并 | map 递归、数组拼接 | Merge/mergeObject · load.go:213,195 |
| 内置 agent | 从 embed.FS 读内置 agent | loadBuiltinAgents · load.go:235 |
3. 核心机制(逐个讲透)
3.1 两种配置形态,如何归一
要解决的小问题: 用户可能给的是单个 .yaml,也可能给的是目录。加载器不想为两种形态各写一套主逻辑,它想尽早把目录也变成一份 yaml,后面统一处理。
思路: 在读原始字节的那一步(resource.read,resolver.go:199)就分叉:如果路径是目录且 agents/ 下有 .md 文件,就调 loadFromDirectory 把它拼成一份 JSON/YAML;否则就当普通文件读。分叉只发生在这一层,再往上全是统一的 types.Config。
判断"是不是目录模式"的钩子:
// pkg/config/resolver.go:224-234(节选)
if isDir {
hasMd, err := hasMarkdownFiles(r.url)
...
if hasMd {
// Directory mode: merge nanobot.yaml (if any) with markdown agents
return loadFromDirectory(r.url, yamlData)
}
}
这段是整个"两形态归一"的枢纽:目录里有 md 才走目录模式,否则退回把 nanobot.yaml 当普通文件读。hasMarkdownFiles(directory.go:17)会跳过隐藏文件和 README.md——所以 agents/README.md 可以放心当文档用。
目录模式里 md 与 yaml 的优先级: loadFromDirectory(directory.go:238)先把 nanobot.yaml 里的 agent 存起来,清空,让 md agent 先填(loadAgentsFromMarkdown),再把 yaml 里 md 没定义过的 agent 补回去(directory.go:259-267)。结论一句话:同名 agent,markdown 覆盖 yaml。
3.2 Markdown frontmatter:一个文件 = 一个 agent
要解决的小问题: agent 的指令(system prompt)往往是一大段自然语言,塞进 YAML 字符串很难写、很难读。Markdown 的 frontmatter 格式天然适合:上半截结构化字段,下半截自由正文。
解析器长什么样: parseFrontMatter(frontmatter.go:23)非常朴素——先把 \r\n 归一成 \n,检查开头是不是 ---,找下一个单独成行的 --- 作为闭合,中间是 YAML、之后是正文。没有 frontmatter 就整个文件当正文。
// pkg/config/frontmatter.go:29-32(节选)
if !strings.HasPrefix(text, frontMatterDelimiter+"\n") && !strings.HasPrefix(text, frontMatterDelimiter+"\r") {
// No front-matter, return entire content as body
return nil, strings.TrimSpace(text), nil
}
正文变成什么: frontmatter 反序列化成 frontMatterAgent(directory.go:42,内嵌 types.Agent 加两个额外字段 Default/Mode),然后正文直接塞进 Instructions:
// pkg/config/directory.go:79-81
parsed.Instructions = types.DynamicInstructions{
Instructions: body,
}
即:frontmatter 决定 agent 的字段,正文决定 agent 的指令。文件名(去掉 .md)就是 agent 的 ID(directory.go:71-72)。
Mode 决定入口与子 agent: frontmatter 里的 mode 字段被 loadAgentsFromMarkdown(directory.go:130-140)分流:
mode 值 | 含义 |
|---|---|
空 / chat / primary / all | 普通入口 agent,加入 Publish.Entrypoint |
subagent | 子 agent,不进入口列表(且不能同时是 default) |
| 其它值 | 报错:invalid mode |
默认 agent 怎么选: 若某个文件 frontmatter 标了 default: true 就用它(多个则报错);否则在所有非 subagent 里取字典序最小的(directory.go:150-163)。选出的默认 agent 会被挪到 Entrypoint 列表第一位(directory.go:170-175)。这解释了 README 里的说法"agents/main.md 自动成为入口"——main 字典序通常最小。
3.3 内置 agent:编译进二进制的 embed.FS
要解决的小问题: nanobot 想自带一个开箱即用的通用 agent(叫 nanobot),不依赖用户配置。
思路: 用 Go 的 //go:embed 把内置 agent 的 .md 编译进二进制。pkg/config/agents/agents.go 只有三行:
package agents
import "embed"
//go:embed *.md
var Builtin embed.FS
loadBuiltinAgents(load.go:235)遍历这个 embed 文件系统里的每个 .md,用同一套 parseFrontMatter 解析,加进 cfg.Agents。两个关键行为:
- 保留字保护: 用户配置里若已有同名 agent,直接报错 "cannot override built-in agent"(
load.go:257-259)——内置名字是保留的。 - 入口注册: 非
subagent的内置 agent 会追加进Publish.Entrypoint(load.go:295-297)。
这一步只在 includeDefaultAgents 为 true 时执行(LoadMany,load.go:58-62)。内置的 nanobot.md frontmatter 很短——name、description、temperature: 0.3、permissions: {'*': allow},正文是一大段"通用业务自动化 agent"的 system prompt。