ChatModel 组件:一个接口,装下所有大模型
系列「企业级 AI Agent 实现拆解」补充篇。前面 E6、E7 讲的是「流」——水流怎么造、怎么接。这一篇往源头走一步:水是哪来的。
答案是:从一个叫
ChatModel的东西里来的。它是 Eino 里最基础的一块积木——所有和「大模型」打交道的事,最后都得经过它。不管是 GPT、Claude、豆包(Ark)还是本地跑的 Ollama,外面看着各不一样,在 Eino 内部,它们都长得一模一样:都是ChatModel。读完这篇你会知道:
ChatModel长什么样(一个接口,两个方法)- 为什么它的名字其实「过时了」——真正该用的是
ToolCallingChatModel- 两种调法
Generate/Stream:一桶水 vs 边接边喝- 换模型为什么只改一行:四家 Provider 怎么接进来
Option模式:调用时怎么临时塞参数(温度、工具、最大长度)ToolCalling:让模型学会「调工具」,以及一个并发会炸的旧写法
一、先看一段能跑的代码
讲接口之前,先看它实际怎么用。下面这段,是 Eino 官方示例里和模型说一次话的最小写法(去掉错误处理后):
import "github.com/cloudwego/eino-ext/components/model/openai"
// 1. 造一个模型(指定用哪家、哪个型号、密钥)
cm, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{
APIKey: "sk-...",
Model: "gpt-4o-mini",
BaseURL: "https://api.openai.com/v2",
})
// 2. 准备对话:一条系统指令 + 一句用户提问
messages := []*schema.Message{
schema.SystemMessage("你是一个简洁的助手。"),
schema.UserMessage("用一句话解释什么是递归。"),
}
// 3. 问它,等它把整句答完
reply, _ := cm.Generate(ctx, messages)
fmt.Println(reply.Content)
// 输出:递归就是一个函数在内部调用自己。
三步:造模型 → 组消息 → 问它。
关键在第三步的 cm.Generate(...)。这里的 cm 是什么类型,其实代码根本不关心——它只关心「这玩意儿有个 Generate 方法,传消息进去,能吐回答出来」。这个「能吐回答」的约定,就是 ChatModel 接口。
小白把这句话记住就够了:接口就是一份「我保证我能干这些事」的合同。
ChatModel是合同,openai.NewChatModel造出来的东西是签了这份合同的实物。
二、ChatModel 到底是个什么东西
打开 Eino 源码 components/model/interface.go,接口定义其实很短:
// 一个泛型接口,M 是「消息」的类型
type BaseModel[M messageType] interface {
Generate(ctx context.Context, input []M, opts ...Option) (M, error)
Stream(ctx context.Context, input []M, opts ...Option) (*schema.StreamReader[M], error)
}
两个方法,一进一出,就这些。翻译成人话:
| 方法 | 进 | 出 | 像什么 |
|---|---|---|---|
Generate |
一串对话 []M |
一句完整的回答 M |
烧一壶水,烧开了一起给你 |
Stream |
一串对话 []M |
一个「水龙头」StreamReader[M] |
水龙头,边流边接边喝 |
输入永远是「一串消息」[]M(因为对话有来有回,要带上历史)。输出,要么是「整句」(Generate),要么是「一个能逐字取的流」(Stream)。
那个尖括号 M 是什么?
BaseModel[M] 里的 M 是「消息类型」的占位符,有点像填表格时的「姓名:______」。Eino 只允许填两种:
type messageType interface {
*schema.Message | *schema.AgenticMessage
}
*schema.Message:普通消息(E2 讲过,文本、图片、工具调用都装得下)。*schema.AgenticMessage:「加强版」消息,能把「先思考、再调工具、再接着思考」这种有序过程完整记录下来。
为了不用每次都写一长串,Eino 给它们各起了短名字:
type BaseChatModel = BaseModel[*schema.Message] // 普通消息的 ChatModel
type AgenticModel = BaseModel[*schema.AgenticMessage] // 加强版消息的 ChatModel
小白可以先把 M 当成「消息」两个字,不影响理解。 真正写代码时,绝大多数场景用的就是 *schema.Message,也就是 BaseChatModel。
⚠️ 重要澄清:三个「ChatModel」,只有一个被废弃
先说结论:eino 废弃的是
model.ChatModel这个「接口」,不是NewChatModel这些「构造函数」,更不是openai.ChatModel这个「实现类型」。后两个照常用、没废弃。 很多新手看到「ChatModel 已废弃」就以为连openai.NewChatModel都不能调了——这是误解,本节专门把它讲透。
ChatModel 这个名字在 eino 里指三种完全不同的东西,只有第一种被废弃了:
| 叫 ChatModel 的东西 | 它是什么 | 废弃? |
|---|---|---|
model.ChatModel(接口) |
核心包定义的接口,带 BindTools 方法 |
✅ 是,interface.go:73 标了 // Deprecated |
openai.NewChatModel / ark.NewChatModel(构造函数) |
provider 包的工厂函数 | ❌ 没废弃,第四节四家全在用 |
openai.ChatModel(struct 实现类型) |
provider 包造出来的具体对象类型 | ❌ 没废弃 |
本文(以及源码注释)说的「废弃」,只指第一行那个接口。 下面解释它为什么被废,以及为什么不影响你调 NewChatModel。
病根:BindTools 会原地改自己
被废弃的接口长这样(interface.go:80):
// Deprecated: Use [ToolCallingChatModel] instead.
type ChatModel interface {
BaseChatModel
BindTools(tools []*schema.ToolInfo) error // 病根在这
}
BindTools 直接修改对象本身。并发时是灾难:两个 goroutine 同时给同一个模型绑工具会互相覆盖——A 刚绑好搜索工具,B 一绑计算器,A 的就没了。注释把这点写得很直白(interface.go:75-79)。
解法:新接口用 WithTools,复印不改原件
type ToolCallingChatModel interface {
BaseChatModel
WithTools(tools []*schema.ToolInfo) (ToolCallingChatModel, error) // 返回新实例
}
WithTools 不改自己,而是复印一份新的给你,原件纹丝不动。多个 goroutine 各自复印各自的副本,互不干扰——并发安全。
为什么是「新开一个接口」而不是「把老的改了」?这是 Go 的硬约束:接口一旦发布,方法签名就不能改(改了所有老实现立刻编译崩),而
BindTools「原地改」的语义又没法安全修改(改了会让并发依赖它的老代码静默出错)。所以只能老接口标Deprecated保兼容、新接口引导迁移——这是 Go 社区的标准做法。
铁证:provider 那头一个字都没改
最能说明「实现没被废弃」的证据,在 openai/chatmodel.go 里:
var _ model.ToolCallingChatModel = (*ChatModel)(nil) // ✅ 满足【新】接口
var _ model.ChatModel = (*ChatModel)(nil) // ✅ 也满足【旧】接口
这两行叫编译期断言:让编译器确认 *ChatModel 这个 struct 同时满足新旧两个接口。也就是说——它是同一个东西,NewChatModel 照常返回它。你用新接口引用、旧接口引用都行,官方只是建议你今后用新的。
一句话比喻:不是「给老插座加孔」,而是「出了个防误插的新插座标准,老插座贴了张『不建议新装』的标签,但插上去照样能用」。所以放心调你的
openai.NewChatModel,它好得很;只是拿到对象后,绑工具时用WithTools、别用BindTools。
三、两种调法:Generate 和 Stream
接口就两个方法,但这两个方法的「脾气」完全不同。Eino 官方文档(doc.go)把它们的使用场景说得很清楚。
Generate:等它说完
reply, err := cm.Generate(ctx, messages)
// reply 就是完整的回答,直接用
模型在云端吭哧吭哧把整句想完,一次性返回。你的代码会卡在这一行,直到它说完(或超时)。
适合什么时候用:当你必须拿到完整结果才能继续的时候。比如:
- 让模型把一段话翻译成英文 → 你要的是完整的英文,半个英文没法用
- 让模型给一段文本分类(正面/负面)→ 你要的是最终标签
- 结构化抽取(从简历里提取姓名、电话、邮箱)→ 你要的是完整 JSON
Stream:边说边给
reader, err := cm.Stream(ctx, messages)
if err != nil { /* 处理错误 */ }
defer reader.Close() // ← 铁律一:用完必须关
for {
chunk, err := reader.Recv() // 取一块
if errors.Is(err, io.EOF) { // ← 流正常结束(不是错误!)
break
}
if err != nil { /* 真出错了 */ }
fmt.Print(chunk.Content) // 这一块的内容,立刻能用
}
模型每生成一小段(几个字、一个词),就先吐给你,你立刻能看到、能转发给前端。不用等它说完。
适合什么时候用:当你要让用户实时看到字往外蹦的时候。比如:
- 聊天界面(像 ChatGPT 那样一个字一个字出现)
- 长篇生成(写文章、写代码),干等几十秒太折磨人
三条「流」的铁律
用 Stream 时,E6、E7 已经反复强调过,这里再钉一次(doc.go:44):
reader用完必须Close()。不关,底层网络连接可能泄漏。io.EOF不是错误,是「读完了」。必须先判 EOF、再判其他错误,顺序不能反。这是小白最容易踩的坑——把 EOF 当异常去 panic。- 一个 reader 只能读一次。如果两个地方都要消费这条流(比如一边存库、一边推前端),得先
schema.Copy(reader)复制一份,再分头读。
怎么选?一句话:用户要等着用的(分类、抽取),用 Generate;用户要看着它生成的(聊天),用 Stream。生产里的聊天机器人,几乎都是 Stream。
四、换个模型,为什么只改一行
到这一步,问题来了:GPT、Claude、豆包、Ollama,调用方式各家都不一样(URL 不同、参数名不同、返回格式还总有微妙差异)。Eino 是怎么把它们「抹平」的?
答案:每家写一个适配器,都叫 NewChatModel,都返回同一个接口类型。
下面是四家适配器构造函数的真实签名(eino-ext/components/model/ 下,逐个确认过):
// openai/chatmodel.go:198
func NewChatModel(ctx context.Context, config *ChatModelConfig) (*ChatModel, error)
// claude/claude.go:62
func NewChatModel(ctx context.Context, config *Config) (*ChatModel, error)
// ark/chatmodel.go:194 (豆包/火山方舟)
func NewChatModel(_ context.Context, config *ChatModelConfig) (*ChatModel, error)
// ollama/chatmodel.go:77 (本地模型)
func NewChatModel(_ context.Context, config *ChatModelConfig) (*ChatModel, error)
四份签名长得几乎一样:传一个配置、拿回一个 *ChatModel。注意 ark 和 ollama 的第一个参数是 _——它们内部用不到 ctx,但为了和别家签名对齐,还是留着这个位置。这就是「接口统一、实现各异」。
配置长什么样
每家的 Config 字段不完全相同(各家的功能本来就有差别),但核心几个都一样:APIKey、Model、BaseURL。以 OpenAI 为例(openai/chatmodel.go):
type ChatModelConfig struct {
APIKey string // 密钥
Model string // 型号名,如 gpt-4o-mini
BaseURL string // 服务地址(可换成代理或兼容服务)
ByAzure bool // 是不是走 Azure OpenAI
Timeout time.Duration // 超时
// ...还有 AzureModelMapperFunc、APIVersion 等 Azure 专属字段
}
业务代码一行都不用改
因为业务代码只依赖接口,不依赖具体哪一家,所以换模型只动「造模型」那一段。Eino 官方示例 quickstart/chatwitheino/chatmodel/model.go 就是这么干的——根据环境变量 MODEL_TYPE 切换:
func newChatModel(ctx context.Context) (einomodel.ToolCallingChatModel, error) {
modelType := strings.ToLower(os.Getenv("MODEL_TYPE"))
if modelType == "ark" {
return ark.NewChatModel(ctx, &ark.ChatModelConfig{ // ← 豆包
APIKey: os.Getenv("ARK_API_KEY"),
Model: os.Getenv("ARK_MODEL"),
BaseURL: os.Getenv("ARK_BASE_URL"),
})
}
return openai.NewChatModel(ctx, &openai.ChatModelConfig{ // ← OpenAI(默认)
APIKey: os.Getenv("OPENAI_API_KEY"),
Model: os.Getenv("OPENAI_MODEL"),
BaseURL: os.Getenv("OPENAI_BASE_URL"),
})
}
注意返回类型 einomodel.ToolCallingChatModel——接口,不是某一家。后面所有用它的人,都不知道也不关心底下到底是 GPT 还是豆包。改一个环境变量,整套系统就从 OpenAI 切到 Ark 了,业务逻辑一个字没动。
这就是接口最大的好处:它把「选哪家模型」这件事,圈死在一个小角落里,不让它污染整个代码库。
顺便说,Ollama 是个特别的选手——它能让你在自己笔记本上、不花一分钱跑开源大模型(Llama、Qwen 之类)。开发调试、隐私敏感场景特别好用。接法和别家一模一样,换
ollama.NewChatModel+ 本地地址http://localhost:11434即可。(本地跑模型实战,留到后续 E42 详聊。)
五、Option 模式:调用时临时塞参数
到这儿有个现实问题:有些参数,我每次调用想用不一样的值。
比如温度(temperature,控制回答有多「放飞」):做严肃的代码生成想要低温(稳定、死板),做创意写作想要高温(活泼、发散)。但如果每次都得重新 NewChatModel 造一个模型,太蠢了。
Eino 的解法是 Go 里很经典的 Option 模式:模型造一次,参数在每次调用时临时塞。看接口签名那个 opts ...Option:
Generate(ctx context.Context, input []M, opts ...Option) (M, error)
// ^^^^^^^^^^^^
// 这就是「调用时塞参数」的入口
标准选项:WithXxx
Eino 内置了一堆现成的选项函数(components/model/option.go),名字都是 With 开头:
// 这次调用,用 0.2 的温度(稳定模式)
reply, _ := cm.Generate(ctx, messages,
model.WithTemperature(0.2),
model.WithMaxTokens(2000), // 最多生成 2000 token
model.WithModel("gpt-4o"), // 甚至能临时换个型号
)
每个 WithXxx 长这样,内部就是「拿一个值,存进配置结构体」:
func WithTemperature(temperature float32) Option {
return Option{
apply: func(opts *Options) {
opts.Temperature = &temperature
},
}
}
Option 这个小结构体里装的就是「一个改配置的动作」。Generate 内部会把这些动作挨个执行,把配置改好,再去真正调模型。
常用的标准选项一览:
| 选项 | 作用 |
|---|---|
WithTemperature(t) |
温度,越高越发散 |
WithMaxTokens(n) |
最多生成多少 token |
WithModel(name) |
临时换型号 |
WithTopP(p) |
另一种采样控制 |
WithStop(words) |
遇到这些词就停 |
WithTools(tools) |
这次允许调用哪些工具(见下一节) |
实现专属选项:每家自己加
标准选项是通用的(温度谁家都有)。但有些参数是某家独有的——比如 Ollama 有个 Seed(随机种子,设了就能复现结果),OpenAI 没有。
Eino 让每家适配器自己定义自己的专属选项,通过一个泛型函数 WrapImplSpecificOptFn 注册。Ollama 的真实代码(ollama/call_option.go,全文就这点):
type options struct {
Seed *int
}
func WithSeed(seed int) model.Option {
return model.WrapImplSpecificOptFn(func(o *options) {
o.Seed = &seed
})
}
于是调用方可以把标准选项和专属选项混着用:
reply, _ := ollamaModel.Generate(ctx, messages,
model.WithTemperature(0.7), // 标准选项(通用)
ollama.WithSeed(42), // Ollama 专属选项
)
两个 With 长得一模一样,调用方甚至感觉不到它们的区别。这是 Option 模式精巧的地方:通用和专属,在调用现场是同一种东西。
想自己写一个适配器?记住两条:调
model.GetCommonOptions(...)提取标准选项,调model.GetImplSpecificOptions(...)提取你自己的。Ollama 内部就是这么干的(ollama/chatmodel.go:259-260)。这俩函数官方文档(doc.go:51)明确要求「实现者必须调用」。
六、ToolCalling:让模型学会「调工具」
最后一块拼图,也是 ChatModel 名字里那个「Tool Calling」的由来。
光会聊天不够。一个能干活的 Agent,得会调工具——查天气、搜网页、算数学、读数据库。问题是:模型本身只会吐文字,它怎么「调」工具?
机制其实很朴素:
- 你告诉模型:「你有这些工具可用」(每个工具带名字、说明、参数格式)
- 模型回答时,可以选择吐出一个「工具调用请求」,而不是直接吐答案。比如它说「我想调
get_weather工具,参数是北京」 - 你的代码执行这个工具,把结果喂回模型
- 模型拿到结果,再组织出最终的人话回答
这个「告诉模型有哪些工具」的动作,叫绑工具。前面说过,绑工具有新旧两种写法,区别就在于安不安全。
新写法(推荐):WithTools
// base 是一个共享的、没绑工具的模型
base, _ := openai.NewChatModel(ctx, cfg)
// 给不同请求,派生出绑了不同工具的副本
withSearch, _ := base.WithTools([]*schema.ToolInfo{searchTool}) // 能搜网页的
withCalc, _ := base.WithTools([]*schema.ToolInfo{calcTool}) // 能算数的
WithTools 返回的是新对象,base 本身纹丝不动。多个请求、多个 goroutine 同时用它派生各自的副本,互不干扰——并发安全。
旧写法(别用):BindTools
cm, _ := openai.NewChatModel(ctx, cfg)
cm.BindTools([]*schema.ToolInfo{searchTool}) // 直接改 cm 自己
cm.BindTools([]*schema.ToolInfo{calcTool}) // 又改!
// 现在 cm 上绑的是 calcTool,searchTool 被覆盖了
BindTools 改的是 cm 这个对象本身。两个 goroutine 同时对一个 cm 调 BindTools,就会互相覆盖——A 刚绑好搜索工具,B 一绑计算器,A 的就没了。这就是前面说的「并发炸」,也是 ChatModel(旧接口)被废弃的根本原因。
怎么保证「真的实现了接口」
写适配器的人,怎么确认自己的 *ChatModel 真的满足 ToolCallingChatModel 接口?Eino 的各家适配器都用了一个 Go 的小技巧——编译期断言(openai/chatmodel.go:31):
var _ model.ToolCallingChatModel = (*ChatModel)(nil)
var _ model.ChatModel = (*ChatModel)(nil)
这一行不占内存、不执行,唯一的作用是:如果 *ChatModel 哪天漏实现了接口的某个方法,编译直接报错。等于让编译器帮你盯着接口契约。新写的适配器,照抄这两行即可。
小结
把 ChatModel 这块积木拆开看,就这五件事:
- 它是一个接口,核心就两个方法:
Generate(等整句)和Stream(边接边喝)。 - 名字有历史包袱:
model.ChatModel已废弃,今天该用ToolCallingChatModel(带工具、并发安全)或BaseChatModel(最基础)。 - 一套接口,多家 Provider:OpenAI / Claude / Ark / Ollama 各写一个
NewChatModel适配器,签名统一,业务代码换模型只改一行。 - Option 模式塞参数:调用时用
WithTemperature等临时改配置;各家还能用WrapImplSpecificOptFn加自己的专属选项,调用方无感。 - ToolCalling 让模型会调工具:用
WithTools(返回新实例,安全),别用BindTools(原地改,并发会炸)。
ChatModel 是 Eino 整座大厦的地基。后面所有的 Agent、ReAct 循环、工具调用、多智能体协作,最后都会落到「调一次 ChatModel」上。把它吃透,等于拿住了整个框架的命门。
源码索引(都可点开核对)
- 接口定义:
eino/components/model/interface.go - Option 模式:
eino/components/model/option.go - 官方组件说明:
eino/components/model/doc.go - Provider 适配器:
eino-ext/components/model/(openai / claude / ark / ollama / deepseek / gemini / qwen …) - 真实使用示例:
eino-examples/quickstart/chatwitheino/chatmodel/model.go
更多推荐


所有评论(0)