ByteNoteByteNote
屏蔽一切大模型差异:Model 接口抽象
字

字节笔记本

2026年10月24日 · 约 17 分钟读完

屏蔽一切大模型差异:Model 接口抽象

API中转
¥120

Claude Code 文档:Agent 循环的工作方式

本文是《从 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 要支持三个模型后端:

OpenAIAnthropic本地 Ollama
端点api.openai.com/v1/responsesapi.anthropic.com/v1/messageslocalhost:11434/api/chat
认证Authorization: Bearer sk-...x-api-key: sk-ant-...无
请求格式{input:[...], instructions:...}{messages:[...], system:...}{messages:[...], model:...}
流式格式response.output_text.deltacontent_block_deltamessage.delta
工具调用function_call in outputtool_use in contenttool_calls

几乎每一层都不一样。 如果 Agent 循环直接写死 OpenAI 的调用,想换成 Claude 就要改循环本身,这是灾难性的耦合。

解法是接口。 Agent 循环只跟接口打交道,不关心背后是哪个模型。


Agent SDK:把 Claude Code 当库用

原理:Model 接口该长什么样

设计这个接口,要想清楚两件事:

1. 阻塞 vs 流式,要不要两个方法?

阻塞调用:发请求,等模型想完整答案,一次性返回。 流式调用:发请求,模型边想边吐,逐 token 推回来。

两种都有用,阻塞适合后端批处理,流式适合给用户看打字效果。接口要么分开两个方法,要么用一个返回流的方法统一。

agents-go 选了分两个方法,因为两者的实现逻辑差异太大(流式要处理 SSE 解析、增量拼接、部分错误),强行统一会让接口含糊。

2. 入参出参长什么样?

入参不能是 OpenAI 专有的格式(否则又耦合了)。需要一个中性的"模型请求"结构,包含指令、历史、工具定义、设置。出参同样需要中性结构。


源码对照:agents-go 的 Model 接口

agents-go 的模型抽象只有两个方法,定义在 agents/model.go:54:

go
// 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 是扁平结构体

go
// 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 返回迭代器

go
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)。需要一个"工厂"按名字查找:

go
// agents/model.go:66
type ModelProvider interface {
    GetModel(modelName string) (Model, error)
}

就这么简单,给个模型名,返回一个 Model 实例。OpenAI provider 的实现是"任何名字都返回同一个 OpenAI client";RouterProvider 的实现是"按名字前缀路由到不同 provider"。

go
// 使用示例
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,后续错误直通,不重试:

go
// 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)两个方法:

go
// 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 更新。


小结

读完这一章,你应该能回答:

  1. 为什么要抽象 Model 接口?,屏蔽 provider 差异,让 Agent 循环不关心调的是哪个模型
  2. 两个方法是什么?,GetResponse(阻塞)、StreamResponse(流式,返回迭代器)
  3. 为什么阻塞和流式分开?,实现差异、错误语义、性能优化都不同
  4. ModelProvider 干什么?,按模型名查 Model 实例,支持多模型并存
  5. 流式为什么不能中途重试?,吐过的 token 改不回来,重试会让用户看到错乱

Model 接口是 Agent 和具体模型之间的"翻译层"。 有了它,Agent 循环只跟中性接口打交道,换模型只是换个 provider,循环本身一行不用改。


下一篇

第 3 章,我们看 Agent 怎么"干活",工具。一个 Go 函数,怎么变成模型能理解的工具定义?NewFunctionTool 用泛型 + struct tag 反射出 JSON Schema 的魔法,是 agents-go 最优雅的设计之一。


本文是《从 0 写 Agent》系列第 2 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: