数据截至 (上游 commit e8fd37796aca)
LLM 方言路由与 Sampling:多模型如何统一
30 秒导读: nanobot 想让上层逻辑「只写一次对话代码」,却能对接 Anthropic、OpenAI(两套 API)、Bifrost 网关等风格迥异的 LLM。本章讲它怎么做到:先把所有请求收敛成一个内部结构
CompletionRequest,再靠resolveProvider选出 provider、按 Dialect(方言) 把请求分发给四个子客户端,每个子客户端把内部结构翻译成对应厂商的 wire 格式。后半章讲 MCP sampling——它让「一个 agent 当工具被另一个 agent 调用」成为可能,Sampler负责在多个 agent-模型里选一个、把 MCP 请求转成CompletionRequest再走同一条链路回来。
本章是 02-agent-loop.md(Agent 主循环)和 03-tools-and-mcp.md(工具与 MCP 层)的「底座」:主循环每一步要「问一次模型」,最终都落到这里;工具层里「调用另一个 agent」这件事,也落到这里的 Sampler。配置怎么声明 provider/agent,见 01-config-and-agents.md。
1. 这是什么(零基础也能懂)
一句话定义: 这是 nanobot 的「翻译层 + 分诊台」——把上层统一的对话请求,分诊到正确的 LLM 厂商,并翻译成对方听得懂的格式;顺带让 agent 之间能互相当工具调用。
它解决什么问题? 各家 LLM 的 HTTP 接口长得完全不一样:
- Anthropic 用
/messages,消息里用tool_use/tool_result块,鉴权走x-api-key头。 - OpenAI 有两套:老的 Chat Completions(
/chat/completions)和新的 Responses(/responses),字段命名和流式事件都不同,鉴权走Authorization: Bearer。 - 还有像 Bifrost 这种统一网关,能代理转发到 bedrock/openai 等后端。
如果上层代码为每家写一遍,agent 循环会被 if-else 淹没。nanobot 的办法:上层只认识一种请求,厂商差异全压到这一层屏蔽掉。
给谁用: nanobot 内部。写 nanobot 配置的人只需在 YAML 里声明「这个 provider 是什么方言、key 从哪个环境变量取」,剩下交给这层。
一句话直觉: 把它想成机场的分诊台 + 翻译官。旅客(请求)说的是同一种「机场普通话」(CompletionRequest);分诊台看你飞哪家航空(resolveProvider),翻译官再把你的话翻成那家航空的行话(各方言的 toRequest)。
用起来什么样(概念示意): 上层只写一句「用某个模型完成这段对话」,这层负责其余:
// 示意,非源码:上层永远只调这一个方法
resp, err := completer.Complete(ctx, types.CompletionRequest{
Model: "default", // 别名,或 "anthropic/claude-..." 这种带前缀的
Input: messages, // 统一的内部消息结构
})
// resp 也是统一的 CompletionResponse——不管背后是 Anthropic 还是 OpenAI
2. 顶层全景(它大概怎么转)
这层有两个入口,共用后半段的「完成链路」:
- 普通完成:agent 主循环要问模型 → 走
Client.Complete。 - MCP sampling:某个工具/子 agent 反过来要「借用一个模型/agent」 → 走
Sampler.Sample,它内部再调Client.Complete。
怎么读下面这张图: 从上到下是一次请求的流向;左边是普通完成,右边是 sampling,两条路在 Client.Complete 汇合,再按方言分叉到四个子客户端。
agent 主循环 工具层(某 agent 当工具被调用)
│ │
▼ ▼
┌───────────────────┐ ┌──────────── ──────────────┐
│ Client.Complete │◀─────────────│ Sampler.Sample │
│ (pkg/llm) │ 转成 │ 选 agent-模型 + 转请求 │
└─────────┬─────────┘ Completion │ (pkg/sampling) │
│ Request └──────────────────────────┘
① resolveProvider 选出 model + provider
② dynamicConfig 合成本次可用的 provider 表
③ 查 provider.Dialect,按方言分发:
│
┌─────────┼───────────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
Anthropic ChatCompl. Bifrost Responses (default=Responses)
/messages /chat/... 网关转 发 /responses
│ │ │ │
└─────────┴───────────┴──────────────┘
各自 toRequest:内部结构 → 厂商 wire 格式
各自流式解析 + toResponse:wire → 内部 CompletionResponse
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
Client.Complete | 统一入口:解析 provider、合成配置、按方言分发 | pkg/llm/client.go:71 |
resolveProvider | 展开模型别名、解析 {provider}/{model}、claude 启发式 | pkg/llm/client.go:55 |
dynamicConfig | 合成本次请求可用的 provider 表(静态 + YAML + env) | pkg/llm/client.go:155 |
| Dialect 常量 | 五种方言的字符串枚举,DialectDefault = OpenAIResponses | pkg/types/dialect.go:5 |
| anthropic 子客户端 | 翻译并调用 Anthropic Messages API | pkg/llm/anthropic/client.go |
| completions 子客户端 | 翻译并调用 OpenAI Chat Completions | pkg/llm/completions/client.go |
| responses 子客户端 | 翻译并调用 OpenAI Responses(也是兜底) | pkg/llm/responses/client.go |
| bifrost 子客户端 | 翻译并调用 Bifrost 统一网 关 | pkg/llm/bifrost/client.go |
Sampler.Sample | MCP sampling:选 agent-模型、转请求、回填 ui:// | pkg/sampling/sampler.go:118 |
3. 核心原理(逐个机制,由浅入深)
3.1 resolveProvider:从一个模型字符串里同时读出「模型」和「provider」
它要解决的小问题: 上层写的 Model 可能是别名("default")、可能带前缀("anthropic/claude-sonnet")、也可能只是裸名("gpt-4o")。得从这一个字符串里既解析出真正的模型名,又解析出该找哪个 provider。
思路: 三步依次尝试。
- 展开别名。
"default"或空串 → 换成配置里的DefaultModel;"mini"→ 换成DefaultMiniModel。 - 切前缀。 用
strings.Cut(model, "/"):若有/,前半是 provider、后半是 model(注意顺序——返回(m, provider))。 - 启发式兜底。 没前缀时:以
claude开头 → provider 猜"anthropic";否则一律猜"openai"。
真实实现:
// pkg/llm/client.go:56 resolveProvider
switch model {
case "default", "":
model = cfg.DefaultModel
case "mini":
model = cfg.DefaultMiniModel
}
if provider, m, ok := strings.Cut(model, "/"); ok {
return m, provider // "{provider}/{model}" 命中
}
if strings.HasPrefix(model, "claude") {
return model, "anthropic" // claude 启发式
}
return model, "openai" // 其余默认 openai
关键细节: 这里只决定「找哪个 provider 名」,并不决定用哪套 API。API 由那个 provider 的 Dialect 字段决定(见 3.3)。所以「provider 名叫 openai」和「用 OpenAI 方言」是两回事——你完全可以配一个叫 openai 但方言是 Bifrost 的 provider。
3.2 dynamicConfig:每次请求现算一份「可用 provider 表」
它要解决的小问题: provider 配置来自三处,且优先级不同——编译进二进制的静态默认、YAML 里为本会话定义的 llmProviders、以及运行时 session 的环境变量。还得把配置里的 ${VAR} 占位符替换成真实的 key。
思路:分层叠加(后者覆盖前者):
静态 provider(NewClient 时传入)
│ 拷贝为基底
▼
+ YAML llmProviders(ConfigFromContext,本会话) ← 同名则覆盖
│
+ session env 覆盖默认模型(NANOBOT_DEFAULT_MODEL / _MINI_MODEL)
│
+ ${VAR} 解析(用 session env 把 APIKey/BaseURL/Headers 里的占位符替换)
▼
本次请求实际使用的 Config
真实实现要点(pkg/llm/client.go:155 dynamicConfig):
- 先把
c.cfg.LLMProviders逐个克隆为基底(client.go:173)。 - 从
mcp.SessionFromContext(ctx)取 session;没有 session 就直接返回静态基底(client.go:182)——这是纯离线/测试路径。 - 用
types.ConfigFromContext(ctx).LLMProviders覆盖同名 provider(client.go:191)——YAML 定义优先于静态默认。 - 用 env 覆盖
DefaultModel/DefaultMiniModel(client.go:201)。 - 最后一轮把每个 provider 的
APIKey/BaseURL/Headers用envvar.ReplaceString/ReplaceMap解析${VAR}(client.go:209)。
关键细节: key 直到这一刻才从环境变量落地成明文,配置里存的一直是 ${OPENAI_API_KEY} 这样的占位符。好处是配置可安全序列化/分享,secret 只在请求当下、按 session env 解析。
3.3 按 Dialect 分发:同一个请求,四种翻译官
它要解决的小问题: provider 表拿到了,该调哪套 API?
思路: provider 配置里有个 Dialect 字段(types.Dialect,枚举在 pkg/types/dialect.go:5)。Complete 查出本 provider 的 Dialect,switch 到对应子客户端。找不到 provider 直接报错「unknown LLM provider」(client.go:127)。
方言 → 子客户端映射表:
| Dialect 常量 | 值 | 子客户端 | 打到的路径 |
|---|---|---|---|
DialectAnthropicMessages | AnthropicMessages | anthropic.NewClient | /messages |
DialectOpenAIChatCompletions | OpenAIChatCompletions | completions.NewClient | /chat/completions |
DialectBifrostRequest | BifrostRequest | bifrost.NewClient | Bifrost 网关 |
DialectOpenAIResponses / DialectOpenResponses | OpenAIResponses / OpenResponses | responses.NewClient | /responses |
| (default 兜底) | — | responses.NewClient | /responses |
真实实现:
// pkg/llm/client.go:130 switch providerCfg.Dialect
case types.DialectAnthropicMessages:
return anthropic.NewClient(...).Complete(ctx, req, opts...)
case types.DialectOpenAIChatCompletions:
return completions.NewClient(...).Complete(ctx, req, opts...)
case types.DialectBifrostRequest:
// provider 名(如 "bedrock")作为目标后端转发给 Bifrost
return bifrost.NewClient(bifrost.Config{..., Provider: provider}).Complete(...)
case types.DialectOpenAIResponses, types.DialectOpenResponses:
fallthrough // 两者当前共用 responses 实现
default:
return responses.NewClient(...).Complete(ctx, req, opts...)
两个关键细节:
DialectDefault = DialectOpenAIResponses(dialect.go:10):没写方言时按 Responses 处理,default 分支也落到 responses。- Bifrost 的 provider 名被复用:
provider(来自resolveProvider的前缀,如bedrock)被塞进bifrost.Config.Provider(client.go:150),告诉网关最终转发到哪个后端。这是唯一一个「provider 名有二次语义」的方言。 OpenAIResponses与OpenResponses故意分成两个枚举但当前共用实现(client.go:152注释「may diverge」)——为将来分化留口子。
3.4 方言内部:把内部结构翻译成厂商 wire 格式
每个子客户端都是同一套骨架:Complete → toRequest(内部→wire)→ HTTP 流式 → toResponse(wire→内部)。差异全在 toRequest / toResponse 的字段映射和流式事件解析里。
内部消息模型(pkg/types/completer.go):一条 Message 有 Role + 一串 CompletionItem(completer.go:86/94);每个 item 是三选一——普通 Content、ToolCall(模型要调工具)、ToolCallResult(工具结果回喂)。各方言就是把这三类翻成自己的表示。
Anthropic 方言(pkg/llm/anthropic/translate.go):
toRequest(anthropic/translate.go:59):SystemPrompt提到顶层System字段;每个 item 按类型翻成 content 块——ToolCall→{type: "tool_use"}(role 强制 assistant),ToolCallResult→{type: "tool_result", tool_use_id: ...}(role 强制 user)。MaxTokens缺省补64000(anthropic/translate.go:63)。- 流式解析在
client.go:117起:逐行读 SSE,按content_block_start/content_block_delta(text_delta累计正文、input_json_delta累计工具入参 JSON)/content_block_stop组装;每段增量都通过progress.Send推给前端做打字机效果。 toResponse(anthropic/translate.go:15):把tool_use块还原成内部ToolCall、text块还原成Content、image块还原成图片 content。
OpenAI Responses 方言(pkg/llm/responses/translate.go):
toRequest(translate.go中):强制Stream=true、Store=false(client.go:78)。toResponse(responses/translate.go:14):遍历resp.Output,按类型还原——FunctionCall→ToolCall、ComputerCall→ 匹配computer_use_preview工具后转ToolCall(responses/translate.go:31)、Message→ 文本、Reasoning带EncryptedContent时保留推理块(responses/translate.go:57)。- 有专门的错误识别:非推理模型不支持时返回可读错误(
client.go:126unsupportedNonReasoningModelError)。
Chat Completions 方言(pkg/llm/completions/translate.go:79):MaxTokens 缺省 4096,用 max_completion_tokens 字段(适配新模型),工具翻成 {type: "function", function: {...}}。
要点: 上层永远拿到同一种 CompletionResponse(completer.go:256),不管背后哪家、走的流式还是非流式。方言的全部价值就是「进来时翻过去、出去时翻回来」,把 wire 差异关在包内。
4. Sampling:一个 agent 作为工具被另一个 agent 调用
这是本章的重头戏,也是 nanobot「万物皆 MCP」哲学(见 05-reflexive-mcp-design.md)在 LLM 层的落点。
4.1 为什么需要 sampling
MCP 协议里,server 可以反过来向 host 发起 sampling/createMessage 请求——意思是「我这个工具/子 server 需要借你的 LLM 想一下」。在 nanobot 里,agent 本身就被暴露成 MCP server,于是这条协议路径天然变成了「agent A 把 agent B 当成一个工具来调用」:B 收到调用,发起 sampling,host 侧的 Sampler 挑一个模型/agent 去完成,再把结果作为工具输出还给 A。
入口在工具层: 每个 MCP client 连接注册了 OnSampling 回调(pkg/tools/service.go:564),回调直接调 s.sampler.Sample(...)。另有 sampleCall(service.go:630)在把 agent 当工具直接调用时也走 Sample。
4.2 getMatchingModel:sampling 请求怎么选中一个模型/agent
它要解决的小问题: sampling 请求里带的是「模型偏好」(ModelPreferences),不是一个确定的模型名。得从当前配置的所有 agent 里挑一个来响应。
三级选择(依次尝试):
- 按 agent 名精确命中 hints。 遍历
req.ModelPreferences.Hints,若某个 hint 的名字正好是一个已配置 agent 的 key,直接选它(sampler.go:73)。 - 按 alias 命中 hints。 再遍历一轮,若某 agent 的
Aliases列表包含这个 hint 名,选那个 agent(sampler.go:80)。 - 加权打分排序,取第一。 前两级都没命中,就调
sortModels给所有 agent 打分,取分最高的(sampler.go:88)。
真实实现:
// pkg/sampling/sampler.go:71 getMatchingModel
for _, model := range req.ModelPreferences.Hints { // ① agent 名
if _, ok := config.Agents[model.Name]; ok {
return model.Name, true
}
}
for _, model := range req.ModelPreferences.Hints { // ② alias
for _, modelKey := range slices.Sorted(maps.Keys(config.Agents)) {
if slices.Contains(config.Agents[modelKey].Aliases, model.Name) {
return modelKey, true
}
}
}
models := s.sortModels(config, req.ModelPreferences) // ③ 打分
if len(models) == 0 { return "", false }
return models[0], true
4.3 sortModels:cost / speed / intelligence 加权打分
思路: 每个 agent 在配置里可标三个属性——Cost、Speed、Intelligence(pkg/types/hooks.go:50-52)。sampling 请求可带三个对应的优先级权重(CostPriority 等)。分数 = 三项各自「属性值 × 权重」之和,分越高越优先。
// pkg/sampling/sampler.go:37 sortModels(节选)
cost := model.Cost
if preferences.CostPriority != nil { cost *= *preferences.CostPriority }
speed := model.Speed
if preferences.SpeedPriority != nil { speed *= *preferences.SpeedPriority }
intelligence := model.Intelligence
if preferences.IntelligencePriority != nil { intelligence *= *preferences.IntelligencePriority }
scoredModels = append(scoredModels, scored{
score: cost + speed + intelligence, // 三项加权求和
model: modelKey,
})
// 之后按 score 降序排序,返回 model key 列表
关键细节: 权重为 nil 时该项不缩放(按原始属性值计入)。遍历 agent 时用 slices.Sorted(maps.Keys(...)) 保证顺序确定,打分可复现。
4.4 Sample:把 MCP 请求转成 CompletionRequest,再回填 ui:// 资源
选中模型后,Sample(sampler.go:118)做三件事:
第一步:选模型并搭骨架。 getMatchingModel 选中 → 建 CompletionRequest{Model: model};把 MCP 请求里的 MaxTokens / SystemPrompt / Temperature 有值的搬过来(sampler.go:127)。
第二步:翻译消息。 遍历 req.Messages,按 role 分组(role 变了或遇到隐藏附件就另起一条 Message,sampler.go:159);每条 content 按类型翻成内部 item——tool_use → ToolCall、tool_result → ToolCallResult、其余 → 普通 Content(sampler.go:184)。这一步正好是 4.2/4.3 里那些方言 toRequest 的镜像:MCP 的 content 类型 → nanobot 内部结构。
第三步:走完成链路并回填。 调 s.completer.Complete(sampler.go:228)——注意这个 completer 就是 agentsService(见 4.5),所以「跑一次子 agent」会完整地走一遍 agent 循环 + 本章前半的方言分发。完成后:
- 若选中的确实是一个 agent,把
resp.Agent标回该 agent 名(sampler.go:233)。 - 用
CompletionResponseToCallResult(sampler.go:240)把结果转成 MCP 的CallResult。
ui:// 资源回填是这里的巧妙处(sampler.go:331):当 includeMessages=false(sampling 默认路径),它扫描所有工具结果的 content,只把 URI 以 ui:// 开头的资源挑出来附加到结果里。这样子 agent 内部产生的 MCP-UI 组件能穿透工具边界、冒泡回外层展示,而普通中间消息不会污染调用方。
4.5 SetSampler 的循环依赖
这是设计上一个诚实的妥协。 看 pkg/runtime/runtime.go:115:
// pkg/runtime/runtime.go:112
agentsService := agents.New(completer, registry)
sampler := sampling.NewSampler(agentsService) // Sampler 的 completer = agentsService
// This is a circular dependency. Oh well, so much for good design.
registry.SetSampler(sampler) // registry 又持有 sampler
环是这样的:
registry (tools.Service)
│ 持有 sampler(SetSampler)
▼
sampler ──持有──▶ agentsService ──持有──▶ registry
(作为 completer)
为什么必须成环? 因为「agent 当工具」是双向的:
- 工具层(registry)要发起 sampling → 需要
sampler。 - sampler 要完成一次请求 → 需要
agentsService(它才是真正跑 agent 循环的 completer)。 - agentsService 跑 agent 时又要调用工具 → 需要
registry。
所以只能先建对象、再用 SetSampler 事后注入,打破构造期的死结。源码里那句「so much for good design」是作者自嘲——但这正是「一切皆 MCP、agent 与工具对等」这套反身式设计的必然代价。
5. 巧妙之处(可借鉴的技术)
- 一个字符串编码「provider + model」。
resolveProvider用{provider}/{model}前缀 + claude 启发式,让配置既能简写(gpt-4o)又能精确指定(anthropic/claude-...),无需额外字段。pkg/llm/client.go:55。 - 配置晚绑定 secret。
${VAR}直到dynamicConfig的最后一轮才解析成明文 key,配置全程可安全序列化。pkg/llm/client.go:199。 - 方言枚举 + 兜底 default。 新增一家 API 只需加一个
case;没写方言的 provider 自动落到 Responses,不会静默失败(除非 provider 名根本没定义)。pkg/llm/client.go:129。 - sampling 用打分而非硬编码选模型。 cost/speed/intelligence 三维加权,让「同一个 sampling 请求在不同偏好下选不同 agent」成为纯数据驱动。
pkg/sampling/sampler.go:37。 ui://资源穿透工具边界。 子 agent 的 UI 组件能冒泡回外层,普通中间消息不污染调用方——sampling 结果转换里的一处精细过滤。pkg/sampling/sampler.go:331。