components 组件接口
编排的"积木"。今天过一遍各组件接口:ChatModel / Tool / ChatTemplate / RAG 三件套 / 文档管线,各是什么、有哪些方法。贴真实接口代码。
组件层 = 能力接口
components/ 定义"有哪些能力接口"。回忆 Day 01——这里只有接口,实现在 eino-ext。组件类别常量在 components/types.go:66:
new OpenAIClient()、直接调各家 SDK 的方法名,那每换一次底层,编排/Agent 全得跟着改——高层逻辑被底层实现绑架了。interface(ChatModel 只认"有 Generate/Stream"、Retriever 只认"有 Retrieve")。业务代码和编排引擎只跟接口打交道;具体是 OpenAI 还是 Claude、ES 还是 Milvus,都是 eino-ext 里满足同一接口的不同实现。换实现 = 换一行构造代码,上层零改动——这就是“面向接口编程 + 依赖倒置”。生活类比:接口就像墙上的插座标准(220V 两孔)——电风扇、充电器、台灯(各家实现)只要插头符合标准就能用;你换个电器,墙、电线一点不用动。
ChatModel 就是那个"插座标准",OpenAI/Claude 是不同的"电器"。ChatModel
调大模型(对话生成)· model/
Tool
工具(让模型能调外部能力)· tool/
ChatTemplate
提示词模板(变量→消息)· prompt/
Retriever / Embedder / Indexer
RAG 检索三件套 · retriever/ embedding/ indexer/
Loader / Transformer / Parser
文档管线 · document/
interface 只规定"必须有哪些方法",不管怎么实现。比如 ChatModel 接口规定"必须有 Generate 和 Stream 方法"——OpenAI 的实现、Claude 的实现各写各的,但只要都满足这个接口,编排层就能一视同仁地用。面向接口编程让“换厂商”变成“换一个实现”,业务代码不动。每个组件目录固定四件套
每个组件子目录都有相同的四个文件——记住这个规律,读任何组件都一样:
| 文件 | 内容 |
|---|---|
interface.go | ★ 接口定义(你最该读的) |
option.go | 运行时选项(如 WithTemperature、WithTopK) |
callback_extra.go | 回调携带的输入/输出结构(Day 16) |
doc.go | 包级文档 |
WithXxx() 函数传可选参数,如 model.WithTemperature(0.7)。好处是加新选项不破坏旧调用。你调用组件时可以传一串这样的 option。ChatModel:调大模型(真实接口)
components/model/interface.go。泛型基座 BaseModel[M](:36)就两个方法——正好对应 Day 03 的"非流"和"流":
type BaseModel[M messageType] interface {
Generate(ctx, input []M, opts ...Option) (M, error) // 阻塞,返回完整响应
Stream(ctx, input []M, opts ...Option) (*schema.StreamReader[M], error) // 返回流
}
type BaseChatModel = BaseModel[*schema.Message] // :71 最常用的别名
工具绑定有两个版本(重要区别):
ChatModel(:80,已废弃):BindTools()原地修改实例——并发不安全。ToolCallingChatModel(:99,推荐):WithTools()返回新实例——可安全共享一个 base model 派生多个带不同工具集的变体。
BindTools 原地改它,A 绑了工具集 X、B 又绑工具集 Y,就互相污染了(并发下更糟)。WithTools 返回一个新的、带这套工具的副本,原 base model 不变——这是“不可变优于可变”的又一体现(呼应 claude-code 教程里到处的不可变设计)。var cm model.BaseChatModel。用 OpenAI:
cm, _ = openai.NewChatModel(ctx, &openai.ChatModelConfig{Model:"gpt-4o", APIKey:key})改用 Claude:
cm, _ = claude.NewChatModel(ctx, &claude.Config{Model:"claude-...", APIKey:key})后面所有
cm.Generate(ctx, msgs) / cm.Stream(ctx, msgs) 的调用一个字都不用改——因为它们只依赖接口方法。两个 NewChatModel 都在 eino-ext,本仓只有 BaseChatModel 这个接口(components/model/interface.go:36)。new 了一个 base model,共享给两个 Agent:客服 Agent 绑了「查订单」工具,翻译 Agent 绑了「查词典」工具。如果绑定是原地改(老的 BindTools):客服先绑上「查订单」,翻译又在同一个实例上绑「查词典」把它冲掉——结果客服 Agent 想查订单时,模型手里只剩「查词典」,答非所问;并发下两个 goroutine 同时改还可能直接 panic。机制怎么解:
ToolCallingChatModel.WithTools() 返回一个新副本,各 Agent 拿到互不干扰的实例,共享的 base model 始终干净。一句话记住:共享要用"复制一份",别在原件上涂改。Tool:四层接口,逐级增强
components/tool/interface.go。工具接口分层——按“要不要执行、要不要流式、要不要多模态”逐级加:
// 朴素想法:一个接口,能拿描述、能执行,齐活
type Tool interface {
Info() ToolInfo // 我是什么
Run(args string) string // 执行
}
问题在哪?① 有些工具只想把描述给模型、根本不在本进程执行(比如交给外部系统),你却强制它实现 Run;② 有的工具输出很长想流式返回,string 一次性返回做不到;③ 出错了没法回传 error;④ 想返回图片/音频时 string 不够用。真实源码正是把这些拆成逐级增强的四层(下面):type BaseTool interface { // :32 只告诉模型"我是什么"
Info(ctx) (*schema.ToolInfo, error) // 返回工具描述(Day 03 的 ToolInfo)
}
type InvokableTool interface { // :42 加"同步执行"
BaseTool
InvokableRun(ctx, argumentsInJSON string, opts) (string, error) // 入参JSON字符串→出参字符串
}
type StreamableTool interface { // :53 加"流式执行"
BaseTool
StreamableRun(...) (*schema.StreamReader[string], error)
}
// 还有 EnhancedInvokableTool / EnhancedStreamableTool(:67/:76):多模态版,出参可含图片/音视频
Info(把描述交给模型);要真能执行就加 InvokableRun(最常用);要流式加 StreamableRun;要返回图片音频加 Enhanced 版。{"city":"北京"}),工具跑完回给模型的也是文本。Day 02 的 InferTool 帮你把"JSON 字符串 ↔ Go struct"的编解码自动做了,所以你写业务时用的是结构体,接口层是字符串。ChatTemplate:提示词模板
components/prompt/interface.go:43:
type ChatTemplate interface {
Format(ctx, vs map[string]any, opts) ([]*schema.Message, error) // 变量 map → 消息列表
}
它把"模板 + 变量"渲染成消息列表(复用 Day 03 讲的 Message.Format + 三种模板语法)。默认实现 DefaultChatTemplate 配 FromMessages + MessagesPlaceholder 构造。在编排里通常排在 ChatModel 之前——先拼提示词,再喂给模型。
[System: 你是{role}] [历史消息占位] [User: {question}]。运行时传 vs = {role: "翻译官", question: "hello 中文?"},Format 后得到填好的消息列表 → 喂给 ChatModel。这就是"动态提示词"。RAG 检索三件套
RAG(检索增强生成)= 调模型前先从知识库检索相关资料塞进提示词。Eino 用三个组件配合:
| 组件 | 接口方法(真实) | 干什么 |
|---|---|---|
Embedder | EmbedStrings(ctx, texts) ([][]float64, error)(embedding/interface.go:37) | 一批文本→一批向量 |
Indexer | Store(ctx, docs) (ids, error)(indexer/interface.go:38) | 文档写入存储("存") |
Retriever | Retrieve(ctx, query) ([]*Document, error)(retriever/interface.go:48) | 按 query 返回相关文档("查") |
Document 就是 Day 03 讲的那个类型(带 Score 相关度分)。文档管线:Loader / Transformer / Parser
RAG 建库前的“数据准备”阶段(components/document/):
Loader(interface.go:43):Load(ctx, src Source) ([]*Document, error)——从文件/URL 读原始内容成 Document。Transformer(interface.go:53):Transform(ctx, src []*Document) ([]*Document, error)——切分/过滤/合并/重排(约定保留并合并已有 MetaData)。Parser(parser/interface.go:34):Parse(ctx, reader) ([]*Document, error)——把原始字节按格式解析成 Document(通常配在 Loader 上,不直接调)。
为什么全是接口(再点透)
你翻遍 components/ 也找不到"真能调 OpenAI 的代码"——因为实现全在 eino-ext。核心框架里每个组件目录只有接口 + 选项 + 回调结构。
internal/mock 生成的 mock),不联网。这跟 gov-agents 的 Protocol、claude-code 的 CoreTool 是同一种"面向接口 + 依赖倒置"的思想——三个框架不约而同。还有两个横切接口(types.go):Typer.GetType()(给组件起可读类型名,可视化调试用)、Checker.IsCallbacksEnabled()(组件声明"我自己管回调,框架别自动包",Day 16 用)。
👶 小白:一个"接口"什么真事都不干,光有方法签名、没有实现,它到底有啥用?我直接写个能调 OpenAI 的类不就完了?
👨🏫 老师:接口的价值恰恰在于"不干活"。接着 L01 的插座标准类比——插座本身不发电,但它规定了"孔位长这样",于是电风扇、台灯谁都能插。ChatModel 接口不调任何模型,只规定"必须有 Generate/Stream",于是编排引擎只认这套方法,OpenAI、Claude 谁满足谁就能被塞进去。
👨🏫 你直接写死一个 OpenAI 类当然也能跑,但哪天要换 Claude,凡是 new OpenAIClient() 和调它专属方法的地方全得改一遍;而依赖接口的写法(L03 那个例子)只换一行构造代码,上层 cm.Generate(...) 一个字不动。所以本仓 components/ 只放接口、实现全丢给 eino-ext——接口就是"以后能随便换零件"的那份保险。
今日小结 + 动手
🧠 今天你应该能回答
- 组件层是什么?为什么全是接口、实现在哪?
- ChatModel 的两个核心方法?WithTools 为什么比 BindTools 好?
- Tool 四层接口逐级加了什么?InvokableRun 为什么入参出参是字符串?
- RAG 三件套怎么配合?为什么建库/查询要同一个 Embedder?
- 文档管线 Loader/Transformer/Parser 各干什么?
✋ 动手:对着真实接口读一遍
# 1. ChatModel 接口(L03)
sed -n '36,110p' components/model/interface.go
# 2. Tool 四层接口(L04)
sed -n '32,80p' components/tool/interface.go
# 3. RAG 三件套(L06)
sed -n '37,50p' components/embedding/interface.go
sed -n '38,50p' components/indexer/interface.go
sed -n '48,52p' components/retriever/interface.go
# 4. 组件类别常量 + 横切接口(L01/L08)
sed -n '29,90p' components/types.go