跳到主要内容

配置与 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 agentloadFromDirectory · directory.go:238
extends父配置合并进当前loadResource · load.go:104-131
profiles选中 profile 合并进当前loadResource · load.go:133-146
rewriteCwdMCP server cwd 加目录前缀rewriteCwd · load.go:164
rewriteSourceReferences相对 source 变绝对/仓库rewriteSourceReferences · load.go:174
单 agent 入口唯一 agent 自动成 EntrypointloadResource · load.go:155-159
深度合并map 递归、数组拼接Merge/mergeObject · load.go:213,195
内置 agent从 embed.FS 读内置 agentloadBuiltinAgents · 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 很短——namedescriptiontemperature: 0.3permissions: {'*': allow},正文是一大段"通用业务自动化 agent"的 system prompt。

3.4 extends 与 profiles:继承 + 环境叠加

这两者都发生在 loadResource(load.go:92)里,机制上都是"把另一份 Config Merge 进来",但方向不同。

extends = 继承父配置(父在下,子覆盖父): 当前配置的 extends 列出若干父配置引用,逐个加载并 Merge 成一个 lastParent,再把当前配置盖在父之上(load.go:126-131):

// pkg/config/load.go:126-131(节选)
if lastParent != nil {
last, err = Merge(*lastParent, last) // 父在前(base),当前在后(overlay)
...
}

顺序很关键:Merge(base, overlay)overlay 覆盖 base,所以"当前配置"作为 overlay 能覆盖父的同名字段。校验里还禁止 extends 用绝对路径(config.go:134-138)。

profiles = 按环境叠加(选中的 profile 覆盖): 配置里可以定义多个 profiles(每个 profile 本身就是一个完整 Config,见 config.go:87)。启动时传入 profile 名,匹配到就 Merge 进来:

// pkg/config/load.go:133-146(节选)
for _, profile := range profiles {
profileName, _, optional := strings.Cut(profile, "?")
profileConfig, found := last.Profiles[profileName]
if !found && !optional {
return nil, "", fmt.Errorf("profile %s not found", profileName)
} else if !found {
continue
}
last, err = Merge(last, profileConfig) // profile 作为 overlay 覆盖
}

细节两处:profile 名后跟 ?(如 dev?)表示可选——没找到就跳过而不报错;这里 Merge(last, profileConfig) 让 profile 作为 overlay,覆盖基础配置。

3.5 深度合并:map 递归、数组拼接

要解决的小问题: extends、profiles、多路径,处处都在"把两份配置合成一份"。这个合并不能是浅覆盖(整个 agents map 被替换就糟了),得递归到叶子

巧妙实现——绕道 JSON: Merge(load.go:213)不逐字段写合并逻辑,而是把两个 Config 各自 json.Marshalmap[string]any(toMap,load.go:186),对这两个通用 map 做递归合并(mergeObject),再 json.UnmarshalConfig。好处:加新字段不用改合并代码,合并规则只写一次。

合并规则就是 mergeObject(load.go:195)三条:

两边类型合并方式
都是 map(对象)递归合并每个 key,overlay 的 key 覆盖/深入 base
都是 array(数组)拼接(slices.Concat),base 在前 overlay 在后
其它(标量/类型不同)overlay 直接覆盖 base
// pkg/config/load.go:205-210(节选)
if baseArray, ok := base.([]any); ok {
if overlayArray, ok := overlay.([]any); ok {
return slices.Concat(baseArray, overlayArray) // 数组是拼接,不是覆盖!
}
}
return overlay // 标量:overlay 覆盖

必须记住的坑:数组是拼接不是覆盖。 例如一个 agent 的 mcpServers: [a],被 overlay 的 mcpServers: [b] 合并后是 [a, b] 而非 [b]。这一点在写 extends/profiles 时很容易踩。map 型字段(如 agentsmcpServers 顶层)则是逐 key 深合并,同名 key 再往下递归。

3.6 路径重写:让配置能被移动

要解决的小问题: 配置里写的 cwdsource.subPath 都是相对路径,相对的是"配置文件所在目录"。但运行时 cwd 可能变,直接用相对路径会错。所以加载时就把相对路径钉死

两个重写函数,都在 loadResource 尾段调用(load.go:148-153):

  • rewriteCwd(load.go:164): 把每个 MCP server 的 Cwd 拼上配置目录前缀(filepath.Join(cwd, mcpServer.Cwd))。这样不管进程从哪启动,MCP 子进程的工作目录都对。
  • rewriteSourceReferences(load.go:174): 把 MCP server 的 Source(仓库来源)从相对引用解析成绝对引用,委托给 resource.SourceRel(resolver.go:154)。它按资源类型分流:本地路径 → 拼成绝对路径 + 处理 ../;git 资源 → 拼成 https://.../.git 仓库 URL;HTTP 资源的相对 source 则直接报错(无法解析)。

一句话:加载时就把"相对某个上下文"的引用固化,之后 Config 可以被自由传递、合并而不丢上下文。

3.7 单 agent 自动成为入口

要解决的小问题: 只有一个 agent 时,还强制用户写 publish.entrypoint 太啰嗦。

loadResource 结尾有一段贴心逻辑:

// pkg/config/load.go:155-159
if len(last.Agents) == 1 && len(last.Publish.Entrypoint) == 0 {
for agentName := range last.Agents {
last.Publish.Entrypoint = append(last.Publish.Entrypoint, agentName)
}
}

只有一个 agent 且没显式设入口 → 它自动当入口。 这也和校验规则呼应:Validate(config.go:130-132)只在"agent 多于一个却没设入口"时才报错——单 agent 永远不会因缺入口而失败。Publish.Entrypointtypes.Publish(config.go:231-242)的字段,决定 nanobot 对外暴露哪个 agent 当默认对话对象(运行时用 CurrentAgent 读它,config.go:55-70)。


4. 核心类型:types.Config 长什么样

配置层的产物是 types.Config(pkg/types/config.go:79-92)。它就是一张"运行时该知道的一切"的清单。各段一句话职责:

类型干什么对应 YAML 键
Agentsmap[string]Agent定义每个 agentagents
MCPServersmap[string]mcp.Server定义连哪些 MCP servermcpServers
LLMProvidersmap[string]LLMProvider自定义模型提供方llmProviders
Promptsmap[string]Prompt提示词模板prompts
PublishPublishnanobot 自身作为 MCP server 时对外暴露什么publish
Envmap[string]EnvDef环境变量声明(默认值/描述)env
Profilesmap[string]Config环境 profile(每个是完整 Config)profiles
ExtendsStringList继承的父配置引用extends
Hooksmcp.Hooks生命周期钩子hooks
Auth*AuthOAuth/鉴权auth

4.1 Agent 与 HookAgent

Agent(config.go:406-409)只是 HookAgent 内嵌 + 一个 Output 字段。真正的字段几乎全在 HookAgent(pkg/types/hooks.go:19-53)里——之所以叫这名字,是因为 agent 配置能被 config hook 动态改写(见 05-reflexive-mcp-design)。核心字段:

字段作用
Model模型名,如 anthropic/claude-3-7-sonnet-latest,{provider}/{model} 格式路由提供方
Instructionssystem prompt(DynamicInstructions 类型,见 §4.2)
MCPServers这个 agent 能用哪些 MCP server(名字引用)
Tools / Agents引用的具体工具 / 可委派的子 agent
Temperature/TopP/MaxTokens/ContextWindow采样与上下文参数
ToolChoice强制/禁用工具选择
Permissions工具权限白/黑名单(AgentPermissions,有序 */allow/deny)
Reasoning推理努力度(effort/summary)

4.2 DynamicInstructions:指令的两种来源

Instructions 不是普通字符串,而是 DynamicInstructions(config.go:688-722)。它能表达两种来源:

  • 静态字符串: frontmatter 正文或 YAML 里直接写的一段文本(存 Instructions 字段)。
  • 动态 prompt: 指向某个 MCP server 上的一个 prompt 模板(MCPServer + Prompt + Args)——运行时才去那个 server 拉取指令。IsPrompt()(config.go:695)判断是不是这种。

它的 UnmarshalJSON(config.go:703)有个便利:如果 YAML 里写的是裸字符串,就当静态指令;如果是对象,就按 prompt 引用解析。这就是为什么 frontmatter 正文能直接变指令(§3.2)。

4.3 Output schema:让 agent 输出结构化

Agent.Output*OutputSchema(config.go:724-730)。你可以直接给 Schema(原始 JSON Schema),也可以用更省事的 Fields 简写——buildSimpleSchema(config.go:787)会把简写编译成 JSON Schema。简写支持类型后缀,颇为巧妙:

字段名写法生成的 JSON Schema 类型
age(int)integer
score(float)number
active(bool)boolean
tags[]array of string
color(red,green,blue)enum
其它string

字段默认 required,除非显式 required: false(config.go:857-859)。

4.4 mcp.Server:MCP server 的配置

mcp.Server(pkg/mcp/client.go:137-167)描述一个 MCP server 怎么连。它同时支持三种连法(远程 URL / 本地命令 / 容器镜像),关键字段:

字段用途
BaseURL(YAML url)远程 HTTP MCP server 地址
Command / Args本地 stdio 子进程方式启动
Image / Dockerfile / Sandboxed容器化隔离运行(见 03-tools-and-mcp)
Source从 git 仓库拉取 server 源码(ServerSource,client.go:187-194)
Env / Headers传给 server 的环境变量 / HTTP 头
ToolOverrides / ToolPrefix重命名工具 / 给工具名加前缀
Cwd / Workdir工作目录(会被 §3.6 的 rewriteCwd 改写)

MCP server 到底怎么连接、映射工具、被调用,是 03-tools-and-mcp 的主题;本章只关心它作为配置字段如何被读进来。

4.5 其它配置段

  • llmProviders: 自定义模型提供方。内置 openai/anthropic 无需配置,设 API key 即可;要接 Azure/Bedrock/Ollama 就在这里加,每项一个 Dialect(API 协议方言)+ 凭证 + BaseURL。方言路由见 04-llm-dialects-and-sampling
  • env: 声明环境变量(EnvDef,config.go:209-216)——带默认值、描述、是否敏感。裸字符串会被当成 Description(config.go:218-229)。Redacted()(config.go:96)在展示配置时脱敏这些值。
  • prompts: 提示词模板(Prompt,config.go:178-197),可带输入参数,转成 MCP 的 Prompt
  • publish: nanobot 自己作为 MCP server 对外时暴露什么——入口 agent、工具、prompt、资源(Publish,config.go:231-242)。这是 nanobot "万物皆 MCP" 反身设计的一环,详见 05-reflexive-mcp-design

5. 巧妙之处(可借鉴)

  • 绕道 JSON 做深合并: Merge 不写字段级合并逻辑,而是 marshal 成通用 map、递归合并、再 unmarshal 回来(load.go:186-231)。加字段零成本,合并规则只维护一份。代价是两趟 JSON 序列化。
  • 数组拼接语义: mergeObject 对数组用 slices.Concat(load.go:207)而非覆盖——让 extends/profiles 能"追加"而不是"替换"列表项。是特性也是陷阱,取决于你是否知道。
  • 两种形态尽早归一: 目录模式在最底层的 read 就被 loadFromDirectory 拍平成一份 yaml(resolver.go:230-233),上层逻辑无需感知形态差异。
  • 单 agent 免配置入口: load.go:155-159 让最常见的"就一个 agent"场景零样板。
  • 内置 agent 编译进二进制: //go:embed *.md(agents/agents.go)+ 保留名保护(load.go:257-259),开箱即用且不可被静默覆盖。
  • frontmatter 正文即指令: 让 system prompt 用 Markdown 自然书写,而非 YAML 里塞长字符串(directory.go:79-81)。

6. 边界与局限

  • HTTP 配置无法解析相对 source: 从 HTTP 加载的配置,若 MCP server 用相对 source,SourceRel 直接报错(resolver.go:160-161)。
  • mcp-servers.yaml/.json 已废弃: 目录模式仍支持,但仅在 nanobot.yaml 未定义 mcpServers 时生效,且会打 warning(directory.go:182-234)。
  • git 读取只支持 GitHub: gitRead(resolver.go:321-324)对非 github.com 的 provider 直接返回"not implemented"。
  • extends 不能用绝对路径: 校验期拒绝(config.go:134-138)。
  • 内置 agent 名保留: 用户配置不能定义同名 agent,否则加载失败(load.go:257-259)。
  • 数组合并不可"减": 深合并只能追加数组项,没有"移除某项"的语义(load.go:205-209)——想去掉继承来的某个 server 只能重构配置层级。

7. 代码地图(导航索引)

主题文件路径符号名
加载入口pkg/config/load.goLoad, LoadMany
单路径主变换链pkg/config/load.goloadSingle, loadResource
extends 继承pkg/config/load.goloadResource(:104-131)
profiles 叠加pkg/config/load.goloadResource(:133-146)
路径重写pkg/config/load.gorewriteCwd, rewriteSourceReferences
单 agent 自动入口pkg/config/load.goloadResource(:155-159)
深度合并pkg/config/load.goMerge, mergeObject, toMap
内置 agentpkg/config/load.goloadBuiltinAgents
内置 agent embedpkg/config/agents/agents.goBuiltin(embed.FS)
frontmatter 解析pkg/config/frontmatter.goparseFrontMatter
目录/md agentpkg/config/directory.goloadFromDirectory, loadAgentsFromMarkdown, parseMarkdownAgent, frontMatterAgent
md 文件检测pkg/config/directory.gohasMarkdownFiles
资源解析pkg/config/resolver.goresolve, resource.read, SourceRel, Rel, Cwd
核心配置类型pkg/types/config.goConfig, Agent, Publish, DynamicInstructions, OutputSchema, buildSimpleSchema
Agent 字段pkg/types/hooks.goHookAgent
MCP server 配置pkg/mcp/client.goServer, ServerSource
配置校验pkg/types/config.goConfig.Validate, Agent.validate