Day 04 / 共 20 天 · 第 1 周 建立心智

components 组件接口

编排的"积木"。今天过一遍各组件接口:ChatModel / Tool / ChatTemplate / RAG 三件套 / 文档管线,各是什么、有哪些方法。贴真实接口代码。

📍 你在整门课的位置 · 第 1 周 建立心智(共 4 周 · 20 天)
D1 全景架构 D2 环境搭建 D3 schema 数据 D4 组件接口 D5 一次调用旅程
L01

组件层 = 能力接口

components/ 定义"有哪些能力接口"。回忆 Day 01——这里只有接口,实现在 eino-ext。组件类别常量在 components/types.go:66

🤔 痛点:换模型 / 换向量库就得重写业务代码 今天用 OpenAI,明天老板说换成公司自研模型;这个项目用 Elasticsearch 存向量,那个项目用 Milvus。如果你的业务代码里到处直接 new OpenAIClient()、直接调各家 SDK 的方法名,那每换一次底层,编排/Agent 全得跟着改——高层逻辑被底层实现绑架了。
💡 本质:先定"能力接口",业务只依赖接口,实现随便换 Eino 把每类能力抽象成一个 Go interfaceChatModel 只认"有 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/

"接口"是什么?(Go 小白) Go 的 interface 只规定"必须有哪些方法",不管怎么实现。比如 ChatModel 接口规定"必须有 Generate 和 Stream 方法"——OpenAI 的实现、Claude 的实现各写各的,但只要都满足这个接口,编排层就能一视同仁地用。面向接口编程让“换厂商”变成“换一个实现”,业务代码不动。
components/ 组件接口全景(本仓只放接口) 对话 ChatModel Tool ChatTemplate RAG 检索 / 文档 Embedder / Indexer / Retriever Loader / Transformer Parser eino-ext(各家实现) openai / claude / gemini… es / milvus / redis… html / pdf loader… 实现
左侧两组都是本仓 components/ 的接口;右侧 eino-ext 提供满足这些接口的具体实现。业务只认左侧。
L02

每个组件目录固定四件套

每个组件子目录都有相同的四个文件——记住这个规律,读任何组件都一样:

文件内容
interface.go★ 接口定义(你最该读的)
option.go运行时选项(如 WithTemperatureWithTopK
callback_extra.go回调携带的输入/输出结构(Day 16)
doc.go包级文档
option.go 里的"函数式选项":Go 常用模式——用 WithXxx() 函数传可选参数,如 model.WithTemperature(0.7)。好处是加新选项不破坏旧调用。你调用组件时可以传一串这样的 option。
L03

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 派生多个带不同工具集的变体。
为什么"返回新实例"比"原地改"好? 一个 base model 可能被多个 Agent 共享。如果 BindTools 原地改它,A 绑了工具集 X、B 又绑工具集 Y,就互相污染了(并发下更糟)。WithTools 返回一个新的、带这套工具的副本,原 base model 不变——这是“不可变优于可变”的又一体现(呼应 claude-code 教程里到处的不可变设计)。
📝 具体例子:同一个 ChatModel 接口,换实现只改一行 业务里你写的是接口类型: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)。
💥 没有 WithTools(只有原地改的 BindTools)会出什么事故? 设想你为省钱只 new一个 base model,共享给两个 Agent:客服 Agent 绑了「查订单」工具,翻译 Agent 绑了「查词典」工具。如果绑定是原地改(老的 BindTools):客服先绑上「查订单」,翻译又在同一个实例上绑「查词典」把它冲掉——结果客服 Agent 想查订单时,模型手里只剩「查词典」,答非所问;并发下两个 goroutine 同时改还可能直接 panic。
机制怎么解ToolCallingChatModel.WithTools() 返回一个新副本,各 Agent 拿到互不干扰的实例,共享的 base model 始终干净。一句话记住:共享要用"复制一份",别在原件上涂改。
L04

Tool:四层接口,逐级增强

components/tool/interface.go。工具接口分层——按“要不要执行、要不要流式、要不要多模态”逐级加:

📝 如果让你自己设计 Tool 接口,你可能会写(简化版)
// 朴素想法:一个接口,能拿描述、能执行,齐活
type Tool interface {
    Info() ToolInfo            // 我是什么
    Run(args string) string    // 执行
}
问题在哪?① 有些工具只想把描述给模型、根本不在本进程执行(比如交给外部系统),你却强制它实现 Run;② 有的工具输出很长想流式返回,string 一次性返回做不到;③ 出错了没法回传 error;④ 想返回图片/音频时 string 不够用。真实源码正是把这些拆成逐级增强的四层(下面):
读法:真实版比你的简化版多出来的每一层,都在解决简化版的一个硬伤——Info 单独拆出(解决①)、StreamableRun(解决②)、返回 error(解决③)、Enhanced 版(解决④)。
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 版。
InvokableRun 的入参出参为什么都是字符串? 因为模型发起工具调用时,给的参数是一段 JSON 字符串(如 {"city":"北京"}),工具跑完回给模型的也是文本。Day 02 的 InferTool 帮你把"JSON 字符串 ↔ Go struct"的编解码自动做了,所以你写业务时用的是结构体,接口层是字符串。
L05

ChatTemplate:提示词模板

components/prompt/interface.go:43

type ChatTemplate interface {
    Format(ctx, vs map[string]any, opts) ([]*schema.Message, error)  // 变量 map → 消息列表
}

它把"模板 + 变量"渲染成消息列表(复用 Day 03 讲的 Message.Format + 三种模板语法)。默认实现 DefaultChatTemplateFromMessages + MessagesPlaceholder 构造。在编排里通常排在 ChatModel 之前——先拼提示词,再喂给模型。

典型用法 模板:[System: 你是{role}] [历史消息占位] [User: {question}]。运行时传 vs = {role: "翻译官", question: "hello 中文?"},Format 后得到填好的消息列表 → 喂给 ChatModel。这就是"动态提示词"。
L06

RAG 检索三件套

RAG(检索增强生成)= 调模型前先从知识库检索相关资料塞进提示词。Eino 用三个组件配合:

💡 生活类比:RAG 三件套 = 图书馆的三个岗位 Embedder(编目员):把每本书读一遍,转成一串"主题坐标"(向量),意思相近的书坐标也相近。Indexer(上架员):把书连同坐标摆进书架(向量库)。Retriever(找书员):你报一个需求,他先把需求也转成同样的坐标,再去书架上找坐标最接近的几本递给你。一句话记住:先编码、再上架、后按坐标找——RAG 就是给知识开了个图书馆。
Embedder文本→向量 Indexer向量存进库 Retriever按 query 检索
组件接口方法(真实)干什么
EmbedderEmbedStrings(ctx, texts) ([][]float64, error)(embedding/interface.go:37)一批文本→一批向量
IndexerStore(ctx, docs) (ids, error)(indexer/interface.go:38)文档写入存储("存")
RetrieverRetrieve(ctx, query) ([]*Document, error)(retriever/interface.go:48)按 query 返回相关文档("查")
三件套怎么配合?建库:Embedder 把文档转成向量 → Indexer 把"文档+向量"存进向量数据库。② 查询:Retriever 拿到你的 query → 用 同一个 Embedder 把 query 转向量 → 在库里找最相似的文档返回。关键:建库和查询必须用同一个 Embedder,否则向量空间不一致、相似度失效。返回的 Document 就是 Day 03 讲的那个类型(带 Score 相关度分)。
L07

文档管线:Loader / Transformer / Parser

RAG 建库前的“数据准备”阶段(components/document/):

💡 生活类比(接着图书馆世界观):文档管线 = 新书入库前的加工车间 一批新书(原始文件)到货,先 Loader(收货员)把箱子拆开把书搬进来;Parser(辨识员)把每本书翻开辨认内容(PDF/HTML 各有各的读法);Transformer(裁切员)把厚书拆成一章章便于检索的小册子。加工完,才轮到上一节图书馆的编目→上架。
  • Loaderinterface.go:43):Load(ctx, src Source) ([]*Document, error)——从文件/URL 读原始内容成 Document。
  • Transformerinterface.go:53):Transform(ctx, src []*Document) ([]*Document, error)——切分/过滤/合并/重排(约定保留并合并已有 MetaData)。
  • Parserparser/interface.go:34):Parse(ctx, reader) ([]*Document, error)——把原始字节按格式解析成 Document(通常配在 Loader 上,不直接调)。
RAG 全流程串起来 准备:Loader 读 PDF → Parser 解析成文本 → Transformer 切成小块 → Embedder 转向量 → Indexer 存库。问答:用户问 → Retriever 检索相关块 → 塞进 ChatTemplate → ChatModel 基于资料回答。这一整条就是用 Eino 组件搭 RAG 应用的骨架(Day 15 之后的 flow/retriever 有现成封装)。
L08

为什么全是接口(再点透)

你翻遍 components/ 也找不到"真能调 OpenAI 的代码"——因为实现全在 eino-ext。核心框架里每个组件目录只有接口 + 选项 + 回调结构。

这样分的三个好处:① 核心零第三方依赖——不用被迫依赖 OpenAI SDK、各种数据库驱动,又轻又不冲突。② 按需引入——你用哪家就装哪家的 eino-ext 实现。③ 可测试/可替换——测试时可以用一个假的 ChatModel 实现(内部有 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——接口就是"以后能随便换零件"的那份保险。

L09

今日小结 + 动手

🧠 今天你应该能回答

  • 组件层是什么?为什么全是接口、实现在哪?
  • 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
明天预告 · Day 05:第 1 周收官。把 schema(数据)+ components(组件)串成"一次调用的完整旅程",为第 2 周的编排引擎(把这些积木连起来跑)铺路。
← Day 03 schema Day 05 · 一次调用的完整旅程 →