Coding Agent Harness 拆解 · 模型层:一套接口关住所有 provider 差异(四)
《Coding Agent Harness 拆解》系列的一篇,可独立阅读。本篇讲模型层:一个 coding agent 想同时支持 OpenAI、Anthropic、Gemini,绕不开各家 API 的线格式差异,这些差异该在哪一层抹平,抹平之后内核凭什么能不依赖任何大模型 SDK。落点是 agentcore 的 ChatModel 接口和它下面的 agentcore/llm 适配层。
各家协议到底差在哪
“把不同 provider 统一起来”这句话,只有把差异摆出来才有分量。同一个“工具调用”概念,三家主流协议的线格式不止于字段改名,还在于结构本身。
先看工具调用这条最典型的链路。模型要调一个工具,得先知道工具长什么样(定义),然后发出调用(请求),你执行完把结果送回去(回传)。三家的做法:
| 环节 | Anthropic Messages | OpenAI Chat Completions | OpenAI Responses |
| --- | --- | --- | --- |
| 工具定义 | input_schema(JSON Schema 对象) | parameters,嵌在 function 下 | 同 Chat,但作为顶层 tool item |
| 模型发出的调用 | 消息里的 tool_use 内容块,input 是已解析的 JSON 对象 | assistant 消息上的 tool_calls,arguments 是JSON 字符串,要自己 parse | 独立的 function_call item |
| 调用信号 | stop_reason: "tool_use" | finish_reason: "tool_calls" | 输出流里出现 function_call item |
| 你回传结果 | tool_result 内容块,放在一条 user 消息里,用 tool_use_id 对应 | tool 角色的消息,用 tool_call_id 对应 | function_call_output item,用 call_id 对应 |
差异不止工具。三家对“一条消息”本身的建模就不一样:
- Anthropic 把一切都做成消息里的 typed content block:文本、思考、工具调用、图片都是
content 数组里的一个块。工具结果也是块,塞进 user 消息。
- OpenAI Chat Completions 用角色 + 专用字段:assistant 消息上挂
tool_calls 字段,工具结果是独立的 tool 角色消息。它是无状态的,每次请求都要把完整历史重发一遍。
- OpenAI Responses 换了一套模型:输入输出都是typed item 数组(
message、reasoning、function_call、function_call_output)。它有状态,可以用 previous_response_id 串起上一轮,并且会跨轮保留 reasoning:推理模型的思考过程不再每轮丢弃,官方数据是缓存利用率比 Chat Completions 高 40–80%。
这些不是边角料。谁想同时支持这几家,就得在某一层把它们收敛成一种内部表示,否则差异会顺着调用链渗进上层的每一处。codebot 把这一层放进内核的 agentcore/llm,上面则是 agentcore 的 ChatModel 接口。下面自底向上看这层怎么搭。
ChatModel:三个方法就是全部契约
内核对“模型”的全部要求,是 agentcore/model.go 里这个接口:
// ChatModel is the LLM provider interface.
type ChatModel interface {
Generate(ctx, messages []Message, tools []ToolSpec, opts ...CallOption) (*LLMResponse, error)
GenerateStream(ctx, messages []Message, tools []ToolSpec, opts ...CallOption) (<-chan StreamEvent, error)
SupportsTools() bool
}
一个同步生成、一个流式生成、一个问“支不支持工具调用”。主循环跨越 LLM 边界时握着的只是这个接口,它不知道对面是 HTTP 还是本地进程,也不知道请求体是 Anthropic 的 content block 还是 OpenAI 的 tool_calls。内核不引任何 LLM SDK 的底气就在这里:谁实现了这三个方法,循环就能转。
接口之外有两个可选契约,用类型断言探测:
type ProviderNamer interface { ProviderName() string } // "openai" / "anthropic" / ...
type ModelNamer interface { ModelName() string } // "claude-sonnet-4-5"
有则用、无则退化。循环拿 ProviderName() 去回调 harness 解析对应 provider 的 API key;UI 和遥测拿 ModelName() 做展示。还有一个包装器 SwappableModel,自己也是 ChatModel,内部用读写锁裹着一个当前模型,Swap(next) 在下次调用生效。会话里 /model 切模型不必重建整个 Agent,就靠它。
::: tip 接口注入,而非 switch provider
如果内核自己 switch provider,那每加一个后端都要改内核,内核也就得 import 每一家 SDK。接口注入把这件事外包出去:内核对 provider 的认知归零,新增后端完全不碰循环代码。这是“内核提供机制、harness 决定策略”在模型层的落地。
:::
LiteLLMAdapter:把接口落到 litellm
agentcore/llm/litellm.go 的 LiteLLMAdapter 是内核自带的唯一 ChatModel 实现,用 litellm 触达各后端。NewModel(provider, model, opts...) 建模型时做两步:newProvider 按名字建一个 litellm.Provider,再包成 litellm.Client。分发是一张朴素的 switch:
switch name {
case "openai": return openai.New(openai.Config{ API: firstStringFromExtra(cfg.Extra, "api", "api_mode"), ... })
case "anthropic": return anthropic.New(anthropic.Config{ Beta: stringFromExtra(cfg.Extra, "anthropic_beta"), ... })
case "gemini": return gemini.New(gemini.Config{ ... })
case "bedrock": ... // AWS credentials via provider Extra
default: return compat.New(...) // deepseek/glm/grok/minimax/ollama/openrouter/qwen 走 OpenAI 兼容层
}
knownProviders 这张 map 是支持后端的白名单。注意 OpenAI 兼容的一批(deepseek、glm、qwen…)共用 compat.New,只有 BaseURL 和 header 不同。这就是“把差异压进一层”的字面意思:它们的线格式本就是 OpenAI Chat Completions,代码里也就没有各自的分支。
LiteLLMAdapter 内嵌 BaseModel(agentcore/llm/model.go),后者持有 GenerationConfig(温度、MaxTokens 等)和 ModelInfo,实现 ModelName() / SupportsTools()。适配器另外实现 Capabilities(),把 litellm 的能力视图翻成内核中立的 Capabilities 结构(capabilities.go),供 UI 预检和 thinking 策略读取。
消息互转:把内部表示翻成各家线格式
前面那张差异表,落到代码里就是这一节。转换分两个方向、两组函数:
- 出站(agentcore → litellm,发请求前):
convertMessages → convertSingleMessage → convertAgentBlocks,把内核的 ContentBlock(text / thinking / toolcall / image / toolref)翻成 litellm 的 Block。litellm 再按目标 provider 决定它是变成 Anthropic 的 tool_use 块、OpenAI 的 tool_calls 字段还是 Responses 的 function_call item。
- 入站(litellm → agentcore,收响应后):同步走
convertResponse / convertResponseContent;流式走下一节 GenerateStream 里的内联 handler。mapStopReason 把各家的收尾信号(Anthropic 的 stop_reason、OpenAI 的 finish_reason)统一收敛到内核的 StopReason:stop / length / tool_use / error。
出站方向藏着两处“为兼容而生”的丢弃,convertMessages 的注释写得很清楚:完全空的 assistant 轮、以及只有思考且自然停止的 assistant 轮都会被跳过。原因就是上面的协议差异:严格的 OpenAI 兼容 provider 会拒绝一条“没有 content 也没有 tool_calls”的 assistant 消息,而 Anthropic 允许纯思考块;丢掉一个什么都没产出的思考轮,语义等价,又不会把内部思考泄回下一次请求。这类分支就是模型层替上层挡下的差异。
流式事件两边互转
这是适配层最吃重的一段,也是它和内核那条单一事件流的接缝。litellm 吐出自己的 Event,GenerateStream 把它们翻成内核的 StreamEvent,同时在本地累积一个 partial 消息:
| litellm 事件 | 翻成的 agentcore.StreamEvent | 边翻边攒的状态 |
| --- | --- | --- |
| ReasoningDelta | StreamEventThinkingStart / ThinkingDelta | 追加/更新思考块 |
| ContentDelta | StreamEventTextStart / TextDelta | 追加/更新文本块 |
| ToolUseStart/Delta/Done | StreamEventToolCallStart/Delta/End | 按 key 定位工具块、拼 toolArgs |
| 流结束 | StreamEventDone(带 Usage/StopReason) | 收尾未完成的工具块、算 Cost |
每个事件的 Message 字段挂的都是累积到当前的完整 partial,Delta 才是这一步增量。内核事件流里那份“完整快照 + 增量”的双份信息,源头就在这里:
case litellm.ContentDelta:
if textIdx < 0 {
partial.Content = append(partial.Content, agentcore.TextBlock(""))
textIdx = len(partial.Content) - 1
eventChan <- agentcore.StreamEvent{Type: StreamEventTextStart, ContentIndex: textIdx, Message: partial}
}
partial.Content[textIdx].Text += e.Text // accumulate into the partial
eventChan <- agentcore.StreamEvent{Type: StreamEventTextDelta, Delta: e.Text, Message: partial}
工具调用的 delta 拼接尤其细致:不同 provider 用 ID / ItemID / Index 里的不同字段标识同一个工具块(又是协议差异),toolUseEventKey 把它们收敛成一个 key,findPendingToolCallBlock 兜底定位。前面表格里“Anthropic 给 JSON 对象、OpenAI 给 JSON 字符串”的差异,也在这一步被抹平成内核统一的 toolArgs。
OpenAI Responses 模式怎么选
开头讲过,Responses 和 Chat Completions 是两套不同的协议,不是同一 API 的参数开关。codebot 让用户选哪一套,走的是 provider Extra 的顺风车:newProvider 里 openai.New 读 firstStringFromExtra(cfg.Extra, "api", "api_mode"),交给 openai.Config.API。
这个 Extra["api"] 从哪来?codebot 的 config.ProviderConfig 有一个一等字段 API(internal/config/settings.go,只接受 "" / chat / responses),ProviderExtra() 把它塞进返回的 map:
func (pc ProviderConfig) ProviderExtra() map[string]any {
extra := cloneExtra(pc.Extra)
if pc.API != "" {
extra["api"] = pc.API // codebot's first-class field -> agentcore Extra
}
return extra
}
于是配置里写 providers.openai.api = "responses",经 ProviderExtra → CreateModel → NewModel → newProvider 透传,最终切换 OpenAI 客户端的协议模式。codebot 用一等字段给用户一个清晰旋钮,agentcore 用通用 Extra 保持 provider 无关。选 Responses 的实际收益,就是前面说的跨轮保留 reasoning 和更高的缓存命中;代价是它有状态、计费模型也不同,这些由 litellm 那一层处理,不冒到内核。
applyCallConfig:一次调用的旋钮
CallOption 是每次调用的临时配置(WithThinking、WithMaxTokens、WithToolChoice、WithJSONSchema…),applyCallConfig 把解析出的 CallConfig 落到 litellm 请求上。两处值得记:
default: // an explicit thinking effort
req.Thinking = &litellm.Thinking{Mode: ThinkingEnabled, Effort: string(callCfg.ThinkingLevel)}
if callCfg.ThinkingBudget > 0 { req.Thinking.BudgetTokens = &callCfg.ThinkingBudget }
req.Temperature = nil // let each provider own its thinking-time sampling constraint
一是开启思考时清掉温度:Anthropic 的预算思考要求 temperature=1,有的兼容后端干脆禁止自定义温度,与其塞一个 Anthropic 专属值,不如清空让 provider 自己决定。二是能力门控:prompt_cache_key 只有当 caps.Cache.PromptKey == SupportYes 才写进 ProviderOptions,litellm 对不支持的 provider option 校验很严,得在这里丢掉,而不是发过去挨拒。
cache-control:单向的写断点提示
出站时 cacheControlFromMetadata 从 msg.Metadata["cache_control"](形如 "ephemeral" 或 "ephemeral:1h")解析出 litellm.CacheControl,convertAgentBlocks 只把它盖在最后一个可缓存的 block 上。消息级 cache_control 的语义是“在这条消息之后写一个缓存断点”,逐块都标会白白烧掉 provider 的断点额度(Anthropic 每请求至多 4 个),思考块又不能带断点,得往前找。入站方向没有对称的“读断点”,缓存是否命中体现在 Usage.CacheRead / Usage.CacheWrite 的 token 数上,最后由 CalculateCost 折算成钱(缓存读取只按 cache-read 计价,避免和 Input 双重计费)。这套断点怎么配合系统提示分块省钱,是《上下文管理》的主题,这里只负责把提示正确翻给 provider。
错误分类:不 import litellm 也能判错
模型调用出错时,wrapProviderError(agentcore/llm/errors.go)把 litellm 的原始错误裹进一个 providerError,让内核不 import litellm 也能读到重试与分类事实:
Retryable() / RetryAfter() 转发 litellm 的判断(网络、超时、限流、过载可重试;限流响应的 Retry-After 折算成时长)。
Is(target) 把 litellm 的分类映射到内核 sentinel:ErrContextOverflow、ErrProviderRateLimit、ErrProviderQuota、ErrProviderAuth…,一个 errors.Is 就能分流。
有一处刻意的例外:context.Canceled / DeadlineExceeded 原样返回、不包装。循环用 errors.Is 直接匹配它们,若把控制信号也归类成“provider 失败”,会错误地翻转它们的可重试位。主循环的重试与溢出恢复读的就是这些 sentinel。
thinking level 如何统一
内核用一把统一枚举描述推理深度(agentcore/message.go):off / minimal / low / medium / high / xhigh / max,空串表示交给模型默认。各家 provider 的 effort 词汇被 agentcore/llm/thinking.go 收敛到这把枚举上:ThinkingPolicyFor(model) 读模型 Capabilities().Thinking.Efforts,算出该模型真正支持的等级集合;Resolve(level) 归一化后校验是否被允许,不允许就退回默认。
codebot 的 internal/provider/registry.go 在此之上做 UI 约束:ResolveThinkingLevel 委托 llm.ThinkingPolicyFor(model).Resolve;ClampThinkingLevel 拿用户选的等级比对可用集合,不匹配就拒绝而非降级,避免悄悄改动用户选的深度。有意思的是 codebot 的 IsValidThinkingLevel / ThinkingLevelOrder 只列了 off / low / medium / high / xhigh / max,没有 minimal:内核认得 minimal,codebot 的 UI 层没把它做成用户可选项,是产品层对内核能力的一次有意收窄。
codebot 的 provider 选择层
内核给的是“怎么调一个模型”,codebot 的 internal/provider 给的是“调哪个模型”。核心是 createModel:
func createModel(prov, name, apiKey, baseURL string, providerExtra map[string]any, clientOpts ...litellm.ClientOption) (agentcore.ChatModel, error) {
model, err := llm.NewModel(normalizedProvider, name, ...) // kernel adapter
if err != nil { return nil, ... }
applyProviderDefaults(normalizedProvider, name, model) // fill provider defaults
return WrapStreamSafe(model), nil // one more safety layer
}
它把内核适配器的构建包起来,加了三件产品层的事:
applyProviderDefaults:给缺省值兜底。Anthropic 的 MaxTokens 必须落在模型上限内,否则 provider 直接 400,anthropicMaxOutputTokens 按模型名算出安全上限写回。
WrapStreamSafe(stream_wrapper.go):流式失败时自动降级到 Generate,把同步结果合成一个 StreamEventDone 发出去。对不稳定或不支持流式的后端,上层照样只见一条流。
NewModelFactory:返回一个携带 litellm ClientOption(如观测钩子)的工厂,装配时用它把遥测焊进每一个模型。
模型选择由 ModelRegistry.Resolve 承担,它解析用户写的模型串:
| 输入 | 解析结果 |
| --- | --- |
| claude-sonnet-4-5 | 精确 / 部分匹配一个 ModelEntry |
| anthropic/claude-sonnet-4-5 | provider/model 显式指定 |
| sonnet:high | 部分匹配 + thinking level 后缀 |
Resolve 先切出 :level 后缀,再按 provider/model → 精确 ID → 部分匹配的顺序查表,偏好不带日期后缀的别名。查到的 ModelEntry 带着 ContextWindow(喂给上下文引擎)和计价字段。这张表编译期由 gen_models.go 生成,运行期 StartPricingRefresh(pricing.go)还会后台拉新价覆盖进来,冷启动不阻塞。
一次模型构建的完整链路
sequenceDiagram
participant Boot as bootstrap
participant Reg as ModelRegistry
participant Cfg as config.ProviderConfig
participant P as provider.createModel
participant Llm as llm.NewModel
participant Lite as litellm.newProvider
Boot->>Reg: Resolve("openai/gpt-x:high")
Reg-->>Boot: ModelEntry{ContextWindow, pricing} + level=high
Boot->>Cfg: ProviderExtra("openai") // {"api":"responses", headers...}
Boot->>P: createModel(prov, name, key, baseURL, extra, WithHook)
P->>Llm: NewModel(...)
Llm->>Lite: newProvider("openai", {Extra:{api:responses}})
Lite-->>Llm: openai client (responses mode)
Llm-->>P: *LiteLLMAdapter
P->>P: applyProviderDefaults + WrapStreamSafe
P-->>Boot: agentcore.ChatModel
内核最终只收到最后那个 ChatModel。这一路发生过的 provider 分发、协议选择、默认兜底、流式降级,它一概不知道。
三问回顾
- 这个机制解决了什么问题? 三家 provider 的工具调用、消息建模、流式事件、错误语义、thinking 词汇各不相同,模型层把它们全收敛成内核的一套内部表示,内核因此可以用三个方法的接口表达“模型”,并且不 import 任何 LLM SDK。
- 不做这一层会怎样? 协议差异会顺着调用链渗进上层:主循环里出现
if provider == "anthropic",流式累积、错误重试、缓存断点、能力门控散落各处,换一家模型或加一种协议都要动核心循环。
- 它和别处怎么接? 向下,
ChatModel 的实现由装配层经 internal/provider 注入;向上,它产出的 StreamEvent 汇入内核那条单一事件流,错误 sentinel 与 thinking level 被主循环消费。
延伸阅读