跳到主要内容

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:72
resolveProvider展开模型别名、解析 {provider}/{model}、claude 启发式pkg/llm/client.go:56
dynamicConfig合成本次请求可用的 provider 表(静态 + YAML + env)pkg/llm/client.go:165
Dialect 常量五种方言的字符串枚举,DialectDefault = OpenAIResponsespkg/types/dialect.go:5
anthropic 子客户端翻译并调用 Anthropic Messages APIpkg/llm/anthropic/client.go
completions 子客户端翻译并调用 OpenAI Chat Completionspkg/llm/completions/client.go
responses 子客户端翻译并调用 OpenAI Responses(也是兜底)pkg/llm/responses/client.go
bifrost 子客户端翻译并调用 Bifrost 统一网关pkg/llm/bifrost/client.go
Sampler.SampleMCP sampling:选 agent-模型、转请求、回填 ui://pkg/sampling/sampler.go:118

3. 核心原理(逐个机制,由浅入深)

3.1 resolveProvider:从一个模型字符串里同时读出「模型」和「provider」

它要解决的小问题: 上层写的 Model 可能是别名("default")、可能带前缀("anthropic/claude-sonnet")、也可能只是裸名("gpt-4o")。得从这一个字符串里既解析出真正的模型名,又解析出该找哪个 provider。

思路: 三步依次尝试。

  1. 展开别名。 "default" 或空串 → 换成配置里的 DefaultModel;"mini" → 换成 DefaultMiniModel
  2. 切前缀。strings.Cut(model, "/"):若有 /,前半是 provider、后半是 model(注意顺序——返回 (m, provider))。
  3. 启发式兜底。 没前缀时:以 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:165 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/Headersenvvar.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 常量子客户端打到的路径
DialectAnthropicMessagesAnthropicMessagesanthropic.NewClient/messages
DialectOpenAIChatCompletionsOpenAIChatCompletionscompletions.NewClient/chat/completions
DialectBifrostRequestBifrostRequestbifrost.NewClientBifrost 网关
DialectOpenAIResponses / DialectOpenResponsesOpenAIResponses / OpenResponsesresponses.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:11):没写方言时按 Responses 处理,default 分支也落到 responses。
  • Bifrost 的 provider 名被复用:provider(来自 resolveProvider 的前缀,如 bedrock)被塞进 bifrost.Config.Provider(client.go:150),告诉网关最终转发到哪个后端。这是唯一一个「provider 名有二次语义」的方言。
  • OpenAIResponsesOpenResponses 故意分成两个枚举但当前共用实现(client.go:152 注释「may diverge」)——为将来分化留口子。

3.4 方言内部:把内部结构翻译成厂商 wire 格式

每个子客户端都是同一套骨架:CompletetoRequest(内部→wire)→ HTTP 流式 → toResponse(wire→内部)。差异全在 toRequest / toResponse 的字段映射和流式事件解析里。

内部消息模型(pkg/types/completer.go):一条 MessageRole + 一串 CompletionItem(completer.go:86/94);每个 item 是三选一——普通 ContentToolCall(模型要调工具)、ToolCallResult(工具结果回喂)。各方言就是把这三类翻成自己的表示。

Anthropic 方言(pkg/llm/anthropic/translate.go):

  • toRequest(translate.go:59):SystemPrompt 提到顶层 System 字段;每个 item 按类型翻成 content 块——ToolCall{type: "tool_use"}(role 强制 assistant),ToolCallResult{type: "tool_result", tool_use_id: ...}(role 强制 user)。MaxTokens 缺省补 64000(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(translate.go:15):把 tool_use 块还原成内部 ToolCalltext 块还原成 Contentimage 块还原成图片 content。

OpenAI Responses 方言(pkg/llm/responses/translate.go):

  • toRequest(translate.go 中):强制 Stream=trueStore=false(client.go:78)。
  • toResponse(translate.go:14):遍历 resp.Output,按类型还原——FunctionCallToolCallComputerCall → 匹配 computer_use_preview 工具后转 ToolCall(translate.go:31)、Message → 文本、ReasoningEncryptedContent 时保留推理块(translate.go:57)。
  • 有专门的错误识别:非推理模型不支持时返回可读错误(client.go:126 unsupportedNonReasoningModelError)。

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:518),回调直接调 s.sampler.Sample(...)。另有 sampleCall(service.go:584)在把 agent 当工具直接调用时也走 Sample

4.2 getMatchingModel:sampling 请求怎么选中一个模型/agent

它要解决的小问题: sampling 请求里带的是「模型偏好」(ModelPreferences),不是一个确定的模型名。得从当前配置的所有 agent 里挑一个来响应。

三级选择(依次尝试):

  1. 按 agent 名精确命中 hints。 遍历 req.ModelPreferences.Hints,若某个 hint 的名字正好是一个已配置 agent 的 key,直接选它(sampler.go:73)。
  2. 按 alias 命中 hints。 再遍历一轮,若某 agent 的 Aliases 列表包含这个 hint 名,选那个 agent(sampler.go:80)。
  3. 加权打分排序,取第一。 前两级都没命中,就调 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 在配置里可标三个属性——CostSpeedIntelligence(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_useToolCalltool_resultToolCallResult、其余 → 普通 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:112:

// 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:56
  • 配置晚绑定 secret。 ${VAR} 直到 dynamicConfig 的最后一轮才解析成明文 key,配置全程可安全序列化。pkg/llm/client.go:209
  • 方言枚举 + 兜底 default。 新增一家 API 只需加一个 case;没写方言的 provider 自动落到 Responses,不会静默失败(除非 provider 名根本没定义)。pkg/llm/client.go:130
  • sampling 用打分而非硬编码选模型。 cost/speed/intelligence 三维加权,让「同一个 sampling 请求在不同偏好下选不同 agent」成为纯数据驱动。pkg/sampling/sampler.go:37
  • ui:// 资源穿透工具边界。 子 agent 的 UI 组件能冒泡回外层,普通中间消息不污染调用方——sampling 结果转换里的一处精细过滤。pkg/sampling/sampler.go:331

6. 边界与局限

  • provider 名不等于方言,易踩坑。 resolveProvider 猜出的 provider 名若在配置里没定义,直接报错;方言完全由 provider 配置的 Dialect 决定,和名字里的「openai/anthropic」无必然关系。
  • 无 session 时只有静态配置。 dynamicConfigmcp.SessionFromContextnil 时不加载 YAML/env 覆盖(client.go:182),纯离线场景只能用编译进去的默认 provider。
  • OpenResponsesOpenAIResponses 当前无差异。 两个枚举共用同一实现(client.go:152),仅为未来分化预留,现在选哪个效果一样。
  • sampling 无匹配模型时的降级依赖 session 能力。getMatchingModel 返回 ErrNoMatchingModel 且 session 自身声明支持 sampling,才回退到把请求转发给 session(pkg/tools/service.go:536);否则直接失败。
  • 循环依赖靠事后注入维持。 SetSampler 若忘了调用,sampling 回调里的 s.samplernil,OnSampling 不会被注册(service.go:517 有 nil 判断),sampling 静默不可用。

7. 横向对比

同 shelf 的其它 agent 框架多把「LLM provider 抽象」和「多 agent 编排」当成两层分开处理。nanobot 的独特取舍是用 MCP sampling 把两者合一:选模型和「调用子 agent」是同一个 Sampler.Sample,因为在它眼里 agent 和模型都只是「一个能完成 CompletionRequest 的东西」。代价就是 §4.5 那个坦白的循环依赖。方言分发本身则是经典的适配器模式,与多数框架的 provider adapter 思路一致,差别在于 nanobot 把「用哪套 API」显式提成了 Dialect 配置字段,而非从模型名反推。

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

主题文件路径符号名
统一完成入口 + 方言分发pkg/llm/client.goClient.Complete
模型别名/前缀/启发式解析pkg/llm/client.goresolveProvider
三层合成 provider 配置 + ${VAR} 解析pkg/llm/client.goClient.dynamicConfig
方言枚举 + 默认方言pkg/types/dialect.goDialect, DialectDefault
Anthropic 翻译 + 流式pkg/llm/anthropic/client.go, .../translate.goClient.complete, toRequest, toResponse
OpenAI Responses 翻译 + 流式pkg/llm/responses/client.go, .../translate.goClient.complete, toResponse, progressResponse
Chat Completions 翻译pkg/llm/completions/translate.gotoRequest
Bifrost 网关(provider 转发)pkg/llm/bifrost/client.goClient, Config.Provider
sampling 主流程pkg/sampling/sampler.goSampler.Sample
sampling 选模型(hints/alias/打分)pkg/sampling/sampler.gogetMatchingModel, sortModels
结果转换 + ui:// 回填pkg/sampling/sampler.goCompletionResponseToCallResult
sampling 回调注册(agent 当工具)pkg/tools/service.goOnSampling, sampleCall
循环依赖注入pkg/runtime/runtime.goNewSampler, SetSampler
内部请求/消息/响应结构pkg/types/completer.goCompletionRequest, Message, CompletionItem, CompletionResponse
agent 打分属性字段pkg/types/hooks.goAliases, Cost, Speed, Intelligence