
字节笔记本
2026年10月24日 · 约 17 分钟读完
屏蔽一切大模型差异:Model 接口抽象

本文是《从 0 写 Agent》系列第 2 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Model 接口:屏蔽一切大模型差异」。
Agent 循环的第一步是"调用大模型"。但"大模型"是什么?OpenAI 的 GPT、Anthropic 的 Claude、本地的 Llama、甚至 ChatGPT 订阅,它们的 API 格式各不相同。
一个好的 Agent 运行时,应该用一个接口屏蔽掉这些差异,让 Agent 循环不关心"调的是哪个模型"。这一章拆解 agents-go 的
Model接口,只有两个方法,却撑起了整个模型层。
引子:模型差异有多大
假设你的 Agent 要支持三个模型后端:
| OpenAI | Anthropic | 本地 Ollama | |
|---|---|---|---|
| 端点 | api.openai.com/v1/responses | api.anthropic.com/v1/messages | localhost:11434/api/chat |
| 认证 | Authorization: Bearer sk-... | x-api-key: sk-ant-... | 无 |
| 请求格式 | {input:[...], instructions:...} | {messages:[...], system:...} | {messages:[...], model:...} |
| 流式格式 | response.output_text.delta | content_block_delta | message.delta |
| 工具调用 | function_call in output | tool_use in content | tool_calls |
几乎每一层都不一样。 如果 Agent 循环直接写死 OpenAI 的调用,想换成 Claude 就要改循环本身,这是灾难性的耦合。
解法是接口。 Agent 循环只跟接口打交道,不关心背后是哪个模型。

原理:Model 接口该长什么样
设计这个接口,要想清楚两件事:
1. 阻塞 vs 流式,要不要两个方法?
阻塞调用:发请求,等模型想完整答案,一次性返回。 流式调用:发请求,模型边想边吐,逐 token 推回来。
两种都有用,阻塞适合后端批处理,流式适合给用户看打字效果。接口要么分开两个方法,要么用一个返回流的方法统一。
agents-go 选了分两个方法,因为两者的实现逻辑差异太大(流式要处理 SSE 解析、增量拼接、部分错误),强行统一会让接口含糊。
2. 入参出参长什么样?
入参不能是 OpenAI 专有的格式(否则又耦合了)。需要一个中性的"模型请求"结构,包含指令、历史、工具定义、设置。出参同样需要中性结构。
源码对照:agents-go 的 Model 接口
agents-go 的模型抽象只有两个方法,定义在 agents/model.go:54:
// agents/model.go:54
type Model interface {
// 阻塞调用:一次性返回完整响应
GetResponse(ctx context.Context, req ModelRequest) (*ModelResponse, error)
// 流式调用:返回一个事件迭代器
StreamResponse(ctx context.Context, req ModelRequest) iter.Seq2[*TResponseStreamEvent, error]
}两个方法,撑起整个模型层。 注意几个设计细节:
ModelRequest 是扁平结构体
// agents/model.go:29(简化)
type ModelRequest struct {
Instructions string // 系统提示
Input []TResponseInputItem // 对话历史
Tools []Tool // 可用工具
ToolChoice *ToolChoice // 强制调工具/自由选择
OutputType *outputSchema // 结构化输出
Settings *ModelSettings // 温度、max_tokens 等
PreviousResponseID string // 服务端历史(省 token)
}这是一个"中性"的请求结构,不绑定任何 provider 的格式。它取代了 Python SDK 里一长串的位置参数,让调用更清晰。Instructions 和 Input 分开(对应 Responses API 的 instructions 字段,而不是塞进 messages[0])。
StreamResponse 返回迭代器
StreamResponse(...) iter.Seq2[*TResponseStreamEvent, error]这是 Go 1.23 的 range-over-func 语法。iter.Seq2 是一个"事件流",消费者用 for event, err := range stream 迭代,每次拿到一个事件,迭代结束拿到终止错误。
对比 Python 的 async for event in stream,Go 的迭代器是同步的(基于 channel),但配合 goroutine 同样能流式。这是 Go 表达"流"的惯用方式。
为什么用 OpenAI 的 Responses 类型作为 lingua franca
注意 TResponseInputItem 和 TResponseStreamEvent,它们其实是 OpenAI Responses API 的类型别名(agents/items.go:10 给 openai-go 的类型起别名)。agents-go 选择用 OpenAI 的 wire format 作为内部统一格式。
这是个务实的决定,OpenAI 的类型最全、工具调用最规范、流式定义最清晰。用它作为内部格式,减少类型转换;要支持其他模型,在 provider 层做转换就行。Python SDK 也是这么做的。
ModelProvider:按名字查 Model
光有 Model 接口还不够。一个 Agent 运行时,可能同时配置了多个模型(这个 Agent 用 GPT-5,那个用 Claude)。需要一个"工厂"按名字查找:
// agents/model.go:66
type ModelProvider interface {
GetModel(modelName string) (Model, error)
}就这么简单,给个模型名,返回一个 Model 实例。OpenAI provider 的实现是"任何名字都返回同一个 OpenAI client";RouterProvider 的实现是"按名字前缀路由到不同 provider"。
// 使用示例
opts := RunOptions{
ModelProvider: openai.NewProvider(), // 默认 OpenAI
}
agent := &Agent{Name: "bot", Model: "gpt-5"}
// 运行时,runner 会调 provider.GetModel("gpt-5") 拿到具体 Model
Run(ctx, agent, "hello", opts)Agent.Model 是个字符串,不是个 Model 实例,这让 Agent 定义是可序列化的、可配置的(从数据库读出来时,模型名是个字符串,方便存储)。
谁实现了 Model 接口
agents-go 里实现 Model 接口的有:
| 实现 | 做什么 |
|---|---|
openai.NewProvider() 返回的 Model | 调 OpenAI Responses API |
NewRetryModel(m, policy) | 给任意 Model 加重试(装饰器) |
NewFallbackModel(primary, ...fallbacks) | 给任意 Model 加降级链(装饰器) |
RouterProvider 返回的 Model | 按名字前缀路由到不同 Model |
注意后三个都是"装饰器",它们包装另一个 Model,给它加能力(重试、降级、路由),但接口完全一样。这是装饰器模式的威力,任意组合、任意嵌套。第 22-24 章会详细讲这三个装饰器。
设计权衡:阻塞和流式为什么分开
有人会问:为什么不只留流式方法?阻塞调用不就是"读完整个流"吗?
理论上可以,但实际有三个问题:
1. 实现差异。 OpenAI 的 Responses API 有专门的阻塞端点(不带 stream:true),返回的是完整 JSON。流式端点返回 SSE。两者是不同的 HTTP 请求,响应解析逻辑完全不同。强行用流式模拟阻塞,要在 provider 层先请求流式、把所有 chunk 拼起来,这是浪费。
2. 错误语义不同。 阻塞调用的错误是"整个请求失败"。流式调用可能"流到一半失败",已经吐了几个 token,然后网络断了。两种错误的恢复策略不同。
3. 性能。 阻塞调用可以让 provider 做优化(比如 OpenAI 的服务端缓存、prompt 缓存命中)。强制流式会丢失这些优化。
所以 agents-go 给了两个方法,让 provider 各自实现最优的阻塞和流式路径。Agent 循环根据需求选,给用户看打字效果用流式,后台批处理用阻塞。
设计权衡:流式重试的"首 event 前"约束
流式调用有个微妙问题,如果流到一半失败了,能重试吗?
答案是不能(agents-go 的设计)。原因:你已经吐给用户几个 token 了,重试会让模型从头生成,可能产出完全不同的内容,用户会看到"前面一段话突然变了"。这比失败更糟。
所以 agents-go 的 NewRetryModel 在流式模式有个约束,只有"还没吐出任何 event 之前"的错误才能重试。一旦吐过第一个 event,后续错误直通,不重试:
// agents/model_retry.go:194(简化)
producedAny := false
for event, err := range inner.StreamResponse(ctx, req) {
if err != nil && !producedAny {
// 首个 event 前,可以重试
if retry() { continue }
}
producedAny = true
yield(event, err) // 吐过 event 后,错误直通
}这是工程纪律,"宁可让用户看到一个失败,也不要给他看一个错乱的重试结果"。 第 22 章会详细讲这个。
实战对照:mini-claude-code 的 ModelClient
mini-claude-code 没有 agents-go 那套抽象,但它有个等价物,ModelClient(model.go)。它用纯 HTTP 调 OpenAI 兼容 API,实现了阻塞(Chat)和流式(ChatStream)两个方法:
// mini-claude-code/model.go
type ModelClient struct {
APIKey, BaseURL, Model string
HTTP *http.Client
}
// 阻塞:POST /chat/completions,一次性返回
func (c *ModelClient) Chat(messages, tools) (Message, int, error)
// 流式:同端点但 stream:true,逐 SSE 事件回调
func (c *ModelClient) ChatStream(messages, tools, onEvent func(StreamEvent)) (Message, int, error)为什么 BaseURL 可配? 这样改个环境变量就能接 DeepSeek/GLM,实测接 relay.bytenote.net 的 glm-5.2,直接跑通。
glm-5.2 还多一个字段,reasoning_content(思考过程)。mini-claude-code 的 StreamEvent 多了 ReasonDelta 字段处理它,让思考过程也能流式显示。这是"纯手写"的优势,遇到新字段直接加 struct tag,不用等 SDK 更新。
小结
读完这一章,你应该能回答:
- 为什么要抽象 Model 接口?,屏蔽 provider 差异,让 Agent 循环不关心调的是哪个模型
- 两个方法是什么?,
GetResponse(阻塞)、StreamResponse(流式,返回迭代器) - 为什么阻塞和流式分开?,实现差异、错误语义、性能优化都不同
- ModelProvider 干什么?,按模型名查 Model 实例,支持多模型并存
- 流式为什么不能中途重试?,吐过的 token 改不回来,重试会让用户看到错乱
Model 接口是 Agent 和具体模型之间的"翻译层"。 有了它,Agent 循环只跟中性接口打交道,换模型只是换个 provider,循环本身一行不用改。
下一篇
第 3 章,我们看 Agent 怎么"干活",工具。一个 Go 函数,怎么变成模型能理解的工具定义?NewFunctionTool 用泛型 + struct tag 反射出 JSON Schema 的魔法,是 agents-go 最优雅的设计之一。
本文是《从 0 写 Agent》系列第 2 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

