系列「企业级 AI Agent 实现拆解」E53 篇,Part 13 RAG 篇第二章。上一篇 用 100 行跑通了最简 RAG,其中读 PDF 那段是三层套娃,当时只说了句「各管一件事」。这篇把这三层拆开:为什么这么分、Option 怎么做到「统一签名还能塞私货」、callback 挂在哪一层,以及自己写一个 Parser 要写哪几段。
读完这篇你会知道
- 三个接口的边界:Loader 管从哪读、Parser 管什么格式、Transformer 管读完怎么加工
- 为什么
Parser不在document包里,而在document/parser子包Source只有一个字段,为什么还要包成 struct- Eino 的 Option 三件套:
WrapImplSpecificOptFn/GetImplSpecificOptions/GetCommonOptions- 组件怎么「自报」已经打过 callback,框架就不再代打
- 一个真实踩到的坑:
Conv*CallbackOutput会串台(有实测输出对照)- 完整可跑:自定义 CSVParser + ExtParser + Chain 编排,输出全是真机跑的
先回到那三行套娃
上一篇读 PDF 的代码长这样:
go
pdfParser, _ := pdf.NewPDFParser(ctx, &pdf.Config{})
extParser, _ := parser.NewExtParser(ctx, &parser.ExtParserConfig{
Parsers: map[string]parser.Parser{".pdf": pdfParser},
FallbackParser: parser.TextParser{},
})
loader, _ := file.NewFileLoader(ctx, &file.FileLoaderConfig{Parser: extParser})
三层,每层只管一件事:
| 层 | 类型 | 管什么 | 不管什么 |
|---|---|---|---|
FileLoader |
document.Loader |
打开文件、拿到字节流 | 字节里是 PDF 还是 Markdown |
ExtParser |
parser.Parser |
按扩展名选解析器 | 具体怎么解析 |
PDFParser |
parser.Parser |
认识 PDF 格式 | 文件从哪来 |
看着啰嗦。但换个场景就明白了:同一个 PDF,可能躺在本地磁盘、可能挂在某个 URL、可能存在 S3。
如果 Loader 和 Parser 不分家,你得写:本地读 PDF、URL 读 PDF、S3 读 PDF、本地读 Word、URL 读 Word......3 种来源 × 4 种格式 = 12 个实现。
分家之后,3 + 4 = 7 个实现,随便组合。
这不是理论推演,源码就是这么干的。file.FileLoader 和 url.Loader 是两个完全独立的包,但它们的 Load 方法最后一行几乎一模一样:
go
// eino-ext/components/document/loader/file/file_loader.go
docs, err = f.Parser.Parse(ctx, file,
append([]parser.Option{parser.WithURI(src.URI), parser.WithExtraMeta(meta)}, o.ParserOptions...)...)
// eino-ext/components/document/loader/url/url.go
docs, err = l.conf.Parser.Parse(ctx, readerCloser,
append([]parser.Option{parser.WithURI(src.URI)}, o.ParserOptions...)...)
一个的 reader 来自 os.Open,一个来自 http.Client.Do 的响应体。到了 Parse 这一步,它们已经没有区别了------都是 io.Reader。
Loader 的全部工作,就是「不管从哪,给我搞出一个 io.Reader」。
三个接口,一共三个方法
go
// components/document/interface.go
type Loader interface {
Load(ctx context.Context, src Source, opts ...LoaderOption) ([]*schema.Document, error)
}
type Transformer interface {
Transform(ctx context.Context, src []*schema.Document, opts ...TransformerOption) ([]*schema.Document, error)
}
// components/document/parser/interface.go
type Parser interface {
Parse(ctx context.Context, reader io.Reader, opts ...Option) ([]*schema.Document, error)
}
注意包的位置:Loader 和 Transformer 在 document 包,Parser 在 document/parser 子包。
这不是随手放的。去 compose 包里搜一下就知道了:
go
compose/graph.go:326: func (g *graph) AddLoaderNode(key string, node document.Loader, ...)
compose/graph.go:421: func (g *graph) AddDocumentTransformerNode(key string, node document.Transformer, ...)
没有 AddParserNode。
Loader 和 Transformer 是编排层的一等公民------可以当图节点,可以串进 Chain。Parser 不是,它是 Loader 肚子里的零件,永远由 Loader 调用,不单独出现在流程图上。
包的位置,就是这个地位差别的物理体现。
Source 只有一个字段,为什么还要包成 struct
go
type Source struct {
URI string
}
一个 string 而已,为什么不直接 Load(ctx, uri string, ...)?
答案在 compose 里。看 Loader 是怎么变成图节点的:
go
// compose/component_to_graph_node.go
func toLoaderNode(node document.Loader, opts ...GraphAddNodeOpt) (*graphNode, *graphAddNodeOpts) {
return toComponentNode(
node,
components.ComponentOfLoader,
node.Load, // ← 直接把方法当函数值传进去
nil, nil, nil,
opts...)
}
toComponentNode 的签名是 invoke Invoke[I, O, TOption],也就是 func(ctx, I, ...TOption) (O, error)。
node.Load 能直接塞进去,是因为它的形状恰好就是这个泛型函数 :I = Source,O = []*schema.Document。
于是在图里,Loader 节点的输入类型就是 Source:
go
chain := compose.NewChain[document.Source, []*schema.Document]().
AppendLoader(loader).
AppendDocumentTransformer(splitter)
如果当初写成 uri string,那图节点的输入类型就是 string------将来想加个 Source.Headers、Source.Auth,接口签名就得改,所有实现和所有已编译的图全得跟着动。
**包成 struct,是给未来留的扩展位。**一个字段的 struct 不是啰嗦,是留白。
Option 三件套:统一签名怎么塞私货
这是 Document 组件里最值得学的一段设计,而且整个 Eino 都在用同一套。
矛盾摆在这儿 :接口签名写死了 opts ...parser.Option。但 PDF 解析器想要一个「是否按页拆分」的开关,CSV 解析器想要「跳过表头」,Word 解析器想要别的。这些参数彼此毫无关系,怎么塞进同一个 Option 类型?
看 Option 的定义:
go
// components/document/parser/option.go
type Option struct {
apply func(opts *Options) // 框架认识的通用选项
implSpecificOptFn any // 各家私货,类型擦成 any
}
两个字段,对应两类选项:
第一类,通用选项,框架定义、所有 Parser 都认:
go
type Options struct {
URI string // 来源地址(ExtParser 靠它选解析器)
ExtraMeta map[string]any // 要合并进每篇 Document 的元数据
}
func WithURI(uri string) Option {
return Option{apply: func(opts *Options) { opts.URI = uri }}
}
第二类,实现专属选项,各家自己定义。以 PDF 为例:
go
// eino-ext/components/document/parser/pdf/option.go
type options struct { // 注意是小写,包外看不见
toPages *bool
}
func WithToPages(toPages bool) parser.Option {
return parser.WrapImplSpecificOptFn(func(opts *options) {
opts.toPages = &toPages
})
}
WrapImplSpecificOptFn 干的事简单到有点狡猾:
go
func WrapImplSpecificOptFn[T any](optFn func(*T)) Option {
return Option{
implSpecificOptFn: optFn, // 把 func(*options) 存成 any
}
}
把一个带类型的函数,塞进 any 里藏起来。
取回来的时候,靠类型断言:
go
func GetImplSpecificOptions[T any](base *T, opts ...Option) *T {
if base == nil {
base = new(T)
}
for i := range opts {
opt := opts[i]
if opt.implSpecificOptFn != nil {
s, ok := opt.implSpecificOptFn.(func(*T))
if ok { // ← 类型对不上就默默跳过
s(base)
}
}
}
return base
}
关键在那个 if ok。
**如果你把 PDF 的 WithToPages 传给 CSV 解析器会怎样?**不会崩,不会报错,会被静默忽略------因为 func(*pdf.options) 断言成 func(*csvOptions) 失败了。
这是个明确的设计取舍:宽松 > 严格 。代价是选项传错了没人提醒你,好处是多个 Parser 串在一条流水线上时,各拿各的,互不干扰。用 ExtParser 一次处理一堆混合格式的文件时,这个特性是刚需。
还有第三种 Option,只在 Loader 层有:
go
// components/document/option.go
func WithParserOptions(opts ...parser.Option) LoaderOption {
return LoaderOption{
apply: func(o *LoaderOptions) { o.ParserOptions = opts },
}
}
**从 Loader 往下透传给 Parser。**因为调用方手里只有 Loader,够不着里面的 Parser:
go
docs, _ := loader.Load(ctx, document.Source{URI: "./a.pdf"},
document.WithParserOptions(pdf.WithToPages(true)))
三件套记住这张表就够用:
| 你的身份 | 用哪个 |
|---|---|
| 组件使用者 | WithURI / WithExtraMeta / WithParserOptions |
| 组件实现者(定义选项) | WrapImplSpecificOptFn |
| 组件实现者(读取选项) | GetCommonOptions + GetImplSpecificOptions |
Callback 挂在哪一层
FileLoader.Load 的完整骨架长这样:
go
func (f *FileLoader) Load(ctx context.Context, src document.Source, opts ...document.LoaderOption) (docs []*schema.Document, err error) {
ctx = callbacks.EnsureRunInfo(ctx, f.GetType(), components.ComponentOfLoader)
ctx = callbacks.OnStart(ctx, &document.LoaderCallbackInput{Source: src})
defer func() {
if err != nil {
_ = callbacks.OnError(ctx, err)
}
}()
// ... 打开文件、调 Parser ...
_ = callbacks.OnEnd(ctx, &document.LoaderCallbackOutput{Source: src, Docs: docs})
return docs, nil
}
打点是组件自己写的,不是框架偷偷包的。
那框架怎么知道该不该再包一层?靠组件自报:
go
func (f *FileLoader) IsCallbacksEnabled() bool { return true }
这个方法来自 components.Checker 接口,注释说得很明白:
When IsCallbacksEnabled returns true, the framework skips its default OnStart/OnEnd wrapping and trusts the component to invoke callbacks itself at the correct points.
框架侧的判断就一行:
go
// compose/component_to_graph_node.go
run := runnableLambda(invoke, stream, collect, transform,
!meta.isComponentCallbackEnabled, // ← 组件自己打了,框架就不打
)
**为什么要让组件自己打?**因为框架只能在「方法调用前」和「方法返回后」两个位置插桩。流式组件需要在流的中途打点(每吐一个 token 报一次),框架包不到那个位置。Document 组件虽然不流式,但顺手统一了范式。
GetType() 也不是白写的------它决定了 DevOps 工具和 callback 里显示的名字(下面的实测输出里就是 FileLoader、RecursiveSplitter)。不实现 Typer 接口的话,框架会退化到反射拿类型名,那就是一串不好看的 *file.FileLoader。
坑:Conv*CallbackOutput 会串台
写 callback handler 时,官方给的姿势是用 ConvXxxCallbackInput/Output 做安全转换------类型不匹配返回 nil,你就能跳过不关心的组件。
我照着写了一版:
go
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if out := document.ConvLoaderCallbackOutput(output); out != nil {
fmt.Printf(" ◀ [%s/%s] 加载完成,产出 %d 篇\n", info.Component, info.Type, len(out.Docs))
}
if out := document.ConvTransformerCallbackOutput(output); out != nil {
fmt.Printf(" ◀ [%s/%s] 转换完成,产出 %d 片\n", info.Component, info.Type, len(out.Output))
}
return ctx
})
真跑出来是这样的(原样粘贴):
css
▶ [Chain/] 开始加载 ./testdata/leave.csv
▶ [Loader/FileLoader] 开始加载 ./testdata/leave.csv
◀ [Loader/FileLoader] 加载完成,产出 3 篇
▶ [DocumentTransformer/RecursiveSplitter] 开始转换,进来 3 篇
◀ [DocumentTransformer/RecursiveSplitter] 加载完成,产出 3 篇 ← ???
◀ [DocumentTransformer/RecursiveSplitter] 转换完成,产出 3 片
◀ [Chain/] 加载完成,产出 3 篇
◀ [Chain/] 转换完成,产出 3 片
Splitter 的 OnEnd 打了两行:既「加载完成」又「转换完成」。Chain 这个顶层节点还额外触发了一轮。
原因在两个 Conv 函数的兜底分支:
go
func ConvLoaderCallbackOutput(src callbacks.CallbackOutput) *LoaderCallbackOutput {
switch t := src.(type) {
case *LoaderCallbackOutput:
return t
case []*schema.Document: // ← 裸的 []*Document 也认
return &LoaderCallbackOutput{Docs: t}
default:
return nil
}
}
func ConvTransformerCallbackOutput(src callbacks.CallbackOutput) *TransformerCallbackOutput {
switch t := src.(type) {
case *TransformerCallbackOutput:
return t
case []*schema.Document: // ← 同一个类型,它也认
return &TransformerCallbackOutput{Output: t}
default:
return nil
}
}
Transformer 没有像 Loader 那样自己包装成 *TransformerCallbackOutput,它的 OnEnd 输出就是裸的 []*schema.Document------于是两个 Conv 函数都返回非 nil。
结论:不能靠 Conv* 返回非 nil 来判断组件类型。先看 info.Component,再转换。
go
switch info.Component {
case components.ComponentOfLoader:
if out := document.ConvLoaderCallbackOutput(output); out != nil { ... }
case components.ComponentOfTransformer:
if out := document.ConvTransformerCallbackOutput(output); out != nil { ... }
}
改完再跑,干净了:
css
▶ [FileLoader] 开始加载 ./testdata/leave.csv
◀ [FileLoader] 加载完成,产出 3 篇
▶ [RecursiveSplitter] 开始转换,进来 3 篇
◀ [RecursiveSplitter] 转换完成,产出 3 片
这个坑在做可观测性(trace、埋点、成本统计)的时候特别容易踩------不看 info.Component 就转换,指标会莫名其妙多出一倍。
实战:写一个 CSVParser
理论讲完,动手。目标:把一个请假记录 CSV 变成可检索的 Document,一行一篇。
① 定义自己的 options(小写,包外不可见)
go
type csvOptions struct {
skipHeader bool
maxRows int
}
② 用 WrapImplSpecificOptFn 包成框架类型
go
func WithSkipHeader(skip bool) parser.Option {
return parser.WrapImplSpecificOptFn(func(o *csvOptions) {
o.skipHeader = skip
})
}
func WithMaxRows(n int) parser.Option {
return parser.WrapImplSpecificOptFn(func(o *csvOptions) {
o.maxRows = n
})
}
③ 实现 Parse
go
type CSVParser struct {
skipHeader bool
maxRows int
}
var _ parser.Parser = (*CSVParser)(nil)
func (p *CSVParser) Parse(ctx context.Context, reader io.Reader,
opts ...parser.Option) ([]*schema.Document, error) {
// 通用选项:URI + ExtraMeta,由 Loader 传进来
common := parser.GetCommonOptions(&parser.Options{}, opts...)
// 专属选项:以构造时的字段作默认值,调用时可覆盖
o := parser.GetImplSpecificOptions(&csvOptions{
skipHeader: p.skipHeader,
maxRows: p.maxRows,
}, opts...)
rows, err := csv.NewReader(reader).ReadAll()
if err != nil {
return nil, fmt.Errorf("read csv: %w", err)
}
if len(rows) == 0 {
return nil, nil
}
header := rows[0]
body := rows
if o.skipHeader {
body = rows[1:]
}
if o.maxRows > 0 && len(body) > o.maxRows {
body = body[:o.maxRows]
}
docs := make([]*schema.Document, 0, len(body))
for i, row := range body {
// 一行 CSV → 一句自然语言,向量检索才有得算
var sb strings.Builder
for j, cell := range row {
if j < len(header) {
sb.WriteString(header[j] + ":" + cell + ";")
}
}
meta := map[string]any{"_row": i + 1}
for k, v := range common.ExtraMeta { // Loader 塞进来的文件名等信息,别丢
meta[k] = v
}
docs = append(docs, &schema.Document{
ID: fmt.Sprintf("%s#row%d", common.URI, i+1),
Content: sb.String(),
MetaData: meta,
})
}
return docs, nil
}
// 让 callback 和 DevOps 工具显示 "CSVParser"
func (p *CSVParser) GetType() string { return "CSVParser" }
三段之外,有两个细节容易漏,都跟接口约定有关:
ExtraMeta必须合并,不能覆盖。Loader塞进来的_file_name/_source是溯源信息,你自己的_row要往上加,不是顶掉。document.Transformer接口注释也是这么要求的:「preserve existing MetaData keys and merge rather than replace」。- 默认值放构造函数,覆盖值走 Option。
GetImplSpecificOptions的第一个参数就是给你传默认值的。
单独用 Parser
go
p := NewCSVParser(true, 0) // 构造时:跳表头、不限行数
docs, _ := p.Parse(ctx,
strings.NewReader("姓名,部门\n张三,研发\n李四,市场\n"),
parser.WithURI("inline.csv"),
WithMaxRows(1), // 调用时覆盖:只要 1 行
)
真实输出:
arduino
=== 1. Parser 单独用 ===
ID=inline.csv#row1 Content="姓名:张三;部门:研发;" Meta=map[_row:1]
WithMaxRows(1) 生效了,构造时的「不限行数」被调用时的参数覆盖。这就是 GetImplSpecificOptions(base, opts...) 那个 base 的意义。
注册进 ExtParser,交给 Loader
go
ext, _ := parser.NewExtParser(ctx, &parser.ExtParserConfig{
Parsers: map[string]parser.Parser{".csv": NewCSVParser(true, 0)},
FallbackParser: parser.TextParser{},
})
loader, _ := file.NewFileLoader(ctx, &file.FileLoaderConfig{Parser: ext})
docs, _ := loader.Load(ctx, document.Source{URI: "./testdata/leave.csv"})
真实输出:
ini
=== 2. ExtParser 按扩展名分发 ===
ID=./testdata/leave.csv#row1
Content="姓名:张三;部门:研发;请假天数:3;"
Meta=map[_extension:.csv _file_name:leave.csv _row:1 _source:./testdata/leave.csv]
ID=./testdata/leave.csv#row2
Content="姓名:李四;部门:市场;请假天数:1;"
Meta=map[_extension:.csv _file_name:leave.csv _row:2 _source:./testdata/leave.csv]
ID=./testdata/leave.csv#row3
Content="姓名:王五;部门:财务;请假天数:5;"
Meta=map[_extension:.csv _file_name:leave.csv _row:3 _source:./testdata/leave.csv]
看 MetaData:_row 是 CSVParser 自己加的,_extension / _file_name / _source 是 FileLoader 塞的,两边合并成功。这条链路通了,说明「Loader 管来源、Parser 管格式」的分工真的成立------我写 CSVParser 的时候完全没关心文件从哪来。
ExtParser 的分发逻辑本身只有几行:
go
func (p *ExtParser) Parse(ctx context.Context, reader io.Reader, opts ...Option) ([]*schema.Document, error) {
opt := GetCommonOptions(&Options{}, opts...)
ext := filepath.Ext(opt.URI) // ← 全靠这个 URI
parser, ok := p.parsers[ext]
if !ok {
parser = p.fallbackParser
}
// ...
}
**注意它依赖 opt.URI。**直接调 ExtParser.Parse 而忘了传 parser.WithURI(...),ext 就是空字符串,永远走 fallback(纯文本)。用 FileLoader 就没这问题------它会自动传。源码注释专门警告过这点。
串进 Chain:Loader 和 Transformer 是图节点
go
sp, _ := recursive.NewSplitter(ctx, &recursive.Config{
ChunkSize: 40,
OverlapSize: 0,
Separators: []string{";"},
LenFunc: func(s string) int { return len([]rune(s)) },
})
chain := compose.NewChain[document.Source, []*schema.Document]().
AppendLoader(loader).
AppendDocumentTransformer(sp)
runnable, _ := chain.Compile(ctx)
out, _ := runnable.Invoke(ctx,
document.Source{URI: "./testdata/leave.csv"},
compose.WithCallbacks(handler),
)
链的输入类型是 document.Source,输出是 []*schema.Document------正好对上前面说的「Source 是图节点的输入类型」。
真实输出:
css
=== 3. Chain 编排 + callback 打点 ===
▶ [FileLoader] 开始加载 ./testdata/leave.csv
◀ [FileLoader] 加载完成,产出 3 篇
▶ [RecursiveSplitter] 开始转换,进来 3 篇
◀ [RecursiveSplitter] 转换完成,产出 3 片
最终产出 3 片,第一片:"姓名:张三;部门:研发;请假天数:3;"
编排起来的好处不在这个 4 行的例子里,在于:同一条链,把 loader 换成 url.Loader,把 sp 换成 markdown 切片器,其余代码一个字不动。
Transformer 不只是切片器
很多人以为 Transformer = 切片器。看一眼 eino-ext 的目录:
css
transformer/
├── splitter/
│ ├── recursive/ 按分隔符递归切(上一篇用的)
│ ├── markdown/ 按 # 标题层级切,标题写进 MetaData
│ ├── semantic/ 按语义相似度切
│ └── html/ 按 HTML 标签切
└── reranker/
└── score/ 重排序
重排序也是 Transformer。
想想接口签名就明白了:
go
Transform(ctx, src []*schema.Document, ...) ([]*schema.Document, error)
进去一堆 Document,出来一堆 Document。切片是(1 篇进、N 片出),过滤是(N 进、M 出),重排序是(N 进、N 出但顺序变了)------形状完全一致,全都是 Transformer。
顺带看一眼 markdown 切片器,它做了 recursive 做不到的事:
go
config := &markdown.HeaderConfig{
Headers: map[string]string{"##": "headerNameOfLevel2"},
}
// 原文: "hell\n##Title 2\n hello world"
// 切完: Content: "##Title 2\n hello world"
// Metadata: {"headerNameOfLevel2": "Title 2"}
**把标题写进了 MetaData。**这样检索命中某一片时,你知道它出自哪一章------既能给用户显示来源,也能做「只在第三章里搜」这种过滤。
E68 会拿真实文档把这几种切片器摆一起对比,这里先记住:切片器不止一个,选错了检索效果差很远。
小结
- 分层:Loader 管从哪读(文件/URL/S3),Parser 管什么格式(PDF/Word/HTML/CSV),Transformer 管读完怎么加工(切片/重排/过滤)。3 种来源 × 4 种格式,只要 7 个实现
- 包位置即地位 :
Loader/Transformer在document包、能当图节点;Parser在子包、是 Loader 的内部零件,compose里没有AddParserNode Source包 struct 是给未来留扩展位,改字段不动接口签名- Option 三件套 :通用选项走
apply,私货塞进implSpecificOptFn any再靠类型断言取回;断言失败静默跳过,宽松换来了混合流水线的可用性 - callback 组件自己打 ,
IsCallbacksEnabled()告诉框架别重复包 Conv*CallbackOutput会串台 ,判断组件类型请用info.Component- 写自定义 Parser 就三段 :options 结构体 → Option 函数 → Parse 里
GetCommonOptions+GetImplSpecificOptions。别忘了合并ExtraMeta
下一篇 E68 换个视角,不拆源码,拆效果:同一份文档,固定长度切、按标题切、按语义切,检索结果到底差多少。
代码状态说明 :本文所有代码在
eino v0.9.13+eino-ext(2026-07-24 版本)下go vet通过并实际运行,三段输出(Parser 单独用 / ExtParser 分发 / Chain + callback)以及Conv*串台的对照输出,都是真机跑出来原样粘贴的,未做美化。