系列「企业级 AI Agent 实现拆解」补充篇。前面 E6E7 讲的是「流」——水流怎么造、怎么接。这一篇往源头走一步:水是哪来的

答案是:从一个叫 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.ChatModelstruct 实现类型 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


三、两种调法:GenerateStream

接口就两个方法,但这两个方法的「脾气」完全不同。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 时,E6E7 已经反复强调过,这里再钉一次(doc.go:44):

  1. reader 用完必须 Close()。不关,底层网络连接可能泄漏。
  2. io.EOF 不是错误,是「读完了」。必须先判 EOF、再判其他错误,顺序不能反。这是小白最容易踩的坑——把 EOF 当异常去 panic。
  3. 一个 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 字段不完全相同(各家的功能本来就有差别),但核心几个都一样APIKeyModelBaseURL。以 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,得会调工具——查天气、搜网页、算数学、读数据库。问题是:模型本身只会吐文字,它怎么「调」工具?

机制其实很朴素:

  1. 你告诉模型:「你有这些工具可用」(每个工具带名字、说明、参数格式)
  2. 模型回答时,可以选择吐出一个「工具调用请求」,而不是直接吐答案。比如它说「我想调 get_weather 工具,参数是 北京
  3. 你的代码执行这个工具,把结果喂回模型
  4. 模型拿到结果,再组织出最终的人话回答

这个「告诉模型有哪些工具」的动作,叫绑工具。前面说过,绑工具有新旧两种写法,区别就在于安不安全。

新写法(推荐):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 同时对一个 cmBindTools,就会互相覆盖——A 刚绑好搜索工具,B 一绑计算器,A 的就没了。这就是前面说的「并发炸」,也是 ChatModel(旧接口)被废弃的根本原因。

怎么保证「真的实现了接口」

写适配器的人,怎么确认自己的 *ChatModel 真的满足 ToolCallingChatModel 接口?Eino 的各家适配器都用了一个 Go 的小技巧——编译期断言openai/chatmodel.go:31):

var _ model.ToolCallingChatModel = (*ChatModel)(nil)
var _ model.ChatModel = (*ChatModel)(nil)

这一行不占内存、不执行,唯一的作用是:如果 *ChatModel 哪天漏实现了接口的某个方法,编译直接报错。等于让编译器帮你盯着接口契约。新写的适配器,照抄这两行即可。


小结

ChatModel 这块积木拆开看,就这五件事:

  1. 它是一个接口,核心就两个方法:Generate(等整句)和 Stream(边接边喝)。
  2. 名字有历史包袱model.ChatModel 已废弃,今天该用 ToolCallingChatModel(带工具、并发安全)或 BaseChatModel(最基础)。
  3. 一套接口,多家 Provider:OpenAI / Claude / Ark / Ollama 各写一个 NewChatModel 适配器,签名统一,业务代码换模型只改一行。
  4. Option 模式塞参数:调用时用 WithTemperature 等临时改配置;各家还能用 WrapImplSpecificOptFn 加自己的专属选项,调用方无感。
  5. ToolCalling 让模型会调工具:用 WithTools(返回新实例,安全),别用 BindTools(原地改,并发会炸)。

ChatModel 是 Eino 整座大厦的地基。后面所有的 Agent、ReAct 循环、工具调用、多智能体协作,最后都会落到「调一次 ChatModel」上。把它吃透,等于拿住了整个框架的命门。


源码索引(都可点开核对)

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐