Document 组件源码:Loader / Transformer / Parser 为什么分成三个接口(第67篇-E53)

系列「企业级 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.FileLoaderurl.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)
}

注意包的位置:LoaderTransformerdocument 包,Parserdocument/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 = SourceO = []*schema.Document

于是在图里,Loader 节点的输入类型就是 Source

go 复制代码
chain := compose.NewChain[document.Source, []*schema.Document]().
    AppendLoader(loader).
    AppendDocumentTransformer(splitter)

如果当初写成 uri string,那图节点的输入类型就是 string------将来想加个 Source.HeadersSource.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 里显示的名字(下面的实测输出里就是 FileLoaderRecursiveSplitter)。不实现 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" }

三段之外,有两个细节容易漏,都跟接口约定有关:

  1. ExtraMeta 必须合并,不能覆盖。 Loader 塞进来的 _file_name / _source 是溯源信息,你自己的 _row 要往上加,不是顶掉。document.Transformer 接口注释也是这么要求的:「preserve existing MetaData keys and merge rather than replace」。
  2. 默认值放构造函数,覆盖值走 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 / _sourceFileLoader 塞的,两边合并成功。这条链路通了,说明「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 / Transformerdocument 包、能当图节点;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* 串台的对照输出,都是真机跑出来原样粘贴的,未做美化。

相关推荐
喝咖啡的女孩1 小时前
🌱 手把手做一只「会改代码」的 VS Code AI 小助手
agent
武子康1 小时前
从随机动作块到真实闭环:Diffusion 与 Flow 策略的执行账本
人工智能·stable diffusion·agent
唐老板1 小时前
Prompt 已死,Loop 永生
ai编程
网易云信1 小时前
销售为什么是企业 AI 落地的"最佳突破口"?
人工智能·后端·agent
散修-小胖子2 小时前
Agent原理 & 实现(快速上手)
ai·agent
ClouGence2 小时前
Kimi 暂停新订阅后,如何用上 Kimi K3?
aigc·ai编程
BreezeJiang2 小时前
做 RAG 时,最先该跑通的不是大模型,而是“资料找回”
agent
AI编程实验室2 小时前
用 npm + Three.js 做一颗西瓜:把夏天的清凉感放进浏览器
前端·后端·ai编程
老梁agent2 小时前
从单一向量到多路召回:RAG 混合检索的工程实践
agent