流式传输引擎:Eino StreamReader 源码拆解(第61篇-E47)

系列「企业级 AI Agent 实现拆解」E47 篇,Part 10 生产工程篇第五章。上一篇 讲了 RAG 流水线。这篇往下看底层:LLM 的流式输出在 Eino 内部是怎么流动的------StreamReader 如何实现 fan-out、fan-in、类型转换,以及 Graph 层如何包装它。

读完这篇你会知道

  • Pipe[T] 怎么创建一对 StreamReader/StreamWriter
  • StreamReader 的 5 种内部类型是什么,各自解决什么问题
  • Copy(n) fan-out 的懒读链表实现:cpStreamElement + sync.Once
  • MergeStreamReaders fan-in 和 MergeNamedStreamReaders 的区别
  • StreamReaderWithConvert:类型转换 + ErrNoValue 过滤 + WithOnEOF
  • SetAutomaticClose:GC finalizer 兜底,但不是 Close 的替代
  • Graph 层的 streamReaderPacker 是什么
  • 写流式代码的 4 条实用规则

为什么流式传输在 Agent 里是个难题

LLM 流式输出(SSE/Server-Sent Events)是用户体验的关键------用户看到第一个 Token 的时间比完整回答更重要。

但在 Agent 内部,一个流式输出往往需要同时被多个消费者读取

  • 主流程继续传给下游节点
  • Callback 系统同时统计 Token
  • 客户端 SSE 输出同时推给浏览器

这就是流式 fan-out(一个流分叉给多个读者)的需求。反过来,MergeStreamReaders 解决 fan-in------多个并行节点的流要合并给下游。

Go 的 channel 天然读一次即消费,不支持多读。Eino 在 schema/stream.go 里实现了一套完整的流式抽象来解决这些问题。


5 种内部类型

StreamReader[T] 是个接口外壳,内部有 5 种具体实现:

go 复制代码
// schema/stream.go 里的 5 种 reader 类型

type stream[T any]        // 基础类型:底层是 channel
type arrayReader[T any]   // 数组转流:把 []T 包成流(非流式组件兼容用)
type multiStream[T any]   // fan-in:合并多个 StreamReader
type withConvert[T, O any]// 类型转换:T → O,可过滤
type cpStreamReader[T any] // Copy 产生的子 reader(fan-out)

外部代码只看到 *StreamReader[T],具体类型由工厂函数决定。


PipeT:创建一对读写端

go 复制代码
// 创建一个容量为 cap 的流
reader, writer := schema.Pipe[string](32)

// 写端(通常在 goroutine 里)
go func() {
    writer.Send("hello", nil)  // 发送数据
    writer.Send("world", nil)
    writer.Close()             // 关闭写端
}()

// 读端
for {
    chunk, err := reader.Recv()
    if err == io.EOF { break }
    if err != nil { /* 处理错误 */ break }
    fmt.Print(chunk)
}
reader.Close() // 读完必须关闭

Pipe[T] 内部创建的 stream[T] 结构:

go 复制代码
type stream[T any] struct {
    items  chan streamItem[T]  // 带缓冲的数据通道,容量 = cap 参数
    closed chan struct{}       // 关闭信号
}

type streamItem[T any] struct {
    val T
    err error  // 数据和错误复用同一个 chan
}

Send(val, err) 就是往 items chan 里放 streamItemRecv() 是取出来。Close() 关闭 closed chan 并消费掉 items chan 里剩余的数据(防止写端 goroutine 阻塞)。

关键约定:StreamReader 是 read-once 的 ------和 Go channel 一样,Recv 一次数据就消费掉了,不能回放。如果需要多个消费者,必须用 Copy


Copy(n):fan-out 的懒读实现

go 复制代码
// Copy 把一个 reader 分裂成 n 个独立的 reader
readers := reader.Copy(3) // readers[0], readers[1], readers[2]
// 原 reader 调用 Copy 后不可再用!

Copy 内部用懒读链表实现,不是直接复制 channel:

go 复制代码
// 每个 item 是一个链表节点
type cpStreamElement[T any] struct {
    val  T
    err  error
    next *cpStreamElement[T]  // 指向下一个节点
    once sync.Once            // 保证只从原 stream 读一次
}

每个子 reader(cpStreamReader)持有一个指向当前位置的链表节点指针。多个子 reader 共享同一个链表,但各自维护自己的"当前节点"。

当子 reader 调用 Recv() 时:

  1. 查看当前节点是否已有数据(once 已执行)
  2. 没有 → 用 sync.Once 从原 stream 读一次,写入节点,并设置 next 指针
  3. 已有 → 直接读取,移动到 next

这样,最慢的子 reader 决定原 stream 的读取速度,最快的 reader 可以缓存在链表节点里等着,不会丢数据。

go 复制代码
// cpStreamReader.Recv() 的核心逻辑
func (c *cpStreamReader[T]) Recv() (T, error) {
    cur := c.cur  // 当前链表节点

    // sync.Once 保证只从原 stream 读一次
    cur.once.Do(func() {
        cur.val, cur.err = c.parent.src.Recv()  // 从原 stream 读
        cur.next = &cpStreamElement[T]{}          // 为下一个 item 创建节点
    })

    c.cur = cur.next  // 移动到下一个节点
    return cur.val, cur.err
}

Copy 后的子 reader 独立 Close()。当所有 子 reader 都关闭了,parentStreamReader 才会关闭原 stream------如果有子 reader 没关闭,原 stream 就一直泄漏。


MergeStreamReaders:fan-in

go 复制代码
// 把多个 reader 合并成一个
merged := schema.MergeStreamReaders([]*StreamReader[string]{r1, r2, r3})

// merged.Recv() 非确定性地从 r1/r2/r3 中读取,谁有数据先返回谁
for {
    chunk, err := merged.Recv()
    if err == io.EOF { break }
    fmt.Println(chunk)
}

内部实现:启动 N 个 goroutine,每个负责从一个 reader 读数据,全部往同一个 output chan 里写。merged.Recv() 就是从 output chan 取。

顺序不保证------多个 LLM 并行调用的输出顺序是 race 决定的,用于"拿到就处理"的场景。

MergeNamedStreamReaders:知道数据来自哪里

go 复制代码
type NamedStreamReader[T any] struct {
    Name   string
    Reader *StreamReader[T]
}

merged := schema.MergeNamedStreamReaders([]NamedStreamReader[string]{
    {Name: "model-a", Reader: r1},
    {Name: "model-b", Reader: r2},
})

// 每当某个 reader 读完,会发出一个 SourceEOF 错误
chunk, err := merged.Recv()
if errors.Is(err, schema.SourceEOF) {
    // 某个 reader 结束了,但 merged 还没结束
    // err.(*SourceEOFError).Source 是哪个 Name
}

SourceEOF 让你知道 r1 先结束了,而 r2 还在继续------适合需要知道"每个子流各自结束"的场景,比如并行工具调用的结果流聚合。


StreamReaderWithConvert:类型转换 + 过滤

go 复制代码
// 把 *StreamReader[string] 转成 *StreamReader[int](取字符串长度)
intReader := schema.StreamReaderWithConvert(
    stringReader,
    func(s string) (int, error) {
        if s == "" {
            return 0, schema.ErrNoValue  // 过滤掉空字符串
        }
        return len(s), nil
    },
)

ErrNoValue 是过滤哨兵------转换函数返回它时,Recv() 不返回这个值,自动跳过继续读下一个。适合从流里过滤无关的 Token 或格式化标记。

其他可选参数:

go 复制代码
schema.StreamReaderWithConvert(reader, fn,
    schema.WithOnEOF(func() { /* 流结束时执行一次 */ }),
    schema.WithErrWrapper(func(err error) error {
        return fmt.Errorf("convert stage: %w", err)
    }),
)

WithOnEOF 用于在流结束时做清理(比如关闭资源),WithErrWrapper 包装错误增加上下文,不破坏原始错误链。


ErrNoValue / ErrRecvAfterClosed / SourceEOF

Eino 流式系统的三个哨兵错误:

错误 含义 谁产生
io.EOF 流正常结束 stream[T].Recv(),写端 Close 后
schema.ErrNoValue 过滤信号,跳过这个 item 转换函数返回
schema.ErrRecvAfterClosed 对已关闭的 reader 继续调用 Recv 内部 panic 保护,通常是 bug
schema.SourceEOF 某个子流结束(named merge 用) MergeNamedStreamReaders

errors.Is 检查,不要直接 == io.EOF------因为这些错误可能被 WithErrWrapper 包装过。


SetAutomaticClose:GC finalizer 兜底

go 复制代码
schema.SetAutomaticClose(reader)

这行代码给 reader 注册了一个 runtime.SetFinalizer------当 reader 被 GC 回收时,自动调用 Close()

但这不是 Close 的替代

  1. GC 时机不确定,finalizer 可能延迟很久才触发
  2. 底层 goroutine(如 MergeStreamReaders 启动的那些)在这段时间里一直跑着,消耗资源
  3. 有些 reader 类型的 Close 需要通知上游,finalizer 时序无法保证

SetAutomaticClose兜底安全网 ,防止某些代码路径忘记 Close 导致永久泄漏。它不等于你可以不写 defer reader.Close()

正确模式始终是:

go 复制代码
reader := schema.Pipe[string](32)
defer reader.Close()  // 保证 Close,哪怕中间 panic

compose 层的 streamReaderPacker

Graph 层(compose/stream_reader.go)在 StreamReader 外面套了一层 streamReaderPacker[T]

go 复制代码
// compose/stream_reader.go
type streamReaderPacker[T any] struct {
    s *schema.StreamReader[T]
}

它的主要作用是实现 compose.StreamReader[T] 接口(Graph 内部用的),把 schema.StreamReader 适配成 Graph 节点能直接处理的形式。

Graph 的流式执行路径:

  1. 节点 A 产出 *schema.StreamReader[Output]
  2. streamReaderPacker 包装它
  3. 传给下游节点 B 作为流式输入
  4. 节点 B 通过 Recv() 逐 Token 消费

对于需要 fan-out 的情况(节点输出同时去往多个下游 + Callback),Graph 内部会在节点结束后自动调用 Copy(n) 分发。


4 条实用规则

1. Recv 只能调用一次 每次 Recv 消费一个 item,不可回放。如果需要重播,在第一次消费前把数据存下来。

2. 总是要 Close 无论正常读完还是中途 break,都要调用 reader.Close()。最安全的写法:

go 复制代码
defer reader.Close()
for {
    chunk, err := reader.Recv()
    if err != nil { break }  // io.EOF 也在这里退出
    // ...
}

3. 需要 fan-out?先 Copy,再 Recv

go 复制代码
// 错误:先 Recv 消费了,Copy 就拿不到这个 item 了
chunk, _ := reader.Recv()
copies := reader.Copy(2)  // copies 会丢失第一个 item

// 正确:先 Copy,再各自 Recv
copies := reader.Copy(2)
go consume(copies[0])  // callback handler
consume(copies[1])     // main path

4. 流式 Callback 必须异步消费 (E45 讲过,这里再强调)OnEndWithStreamOutput 里收到的是 Copy 的副本,必须在 goroutine 里异步消费并调用 Close

go 复制代码
OnEndWithStreamOutput: func(ctx, info, output) context.Context {
    go func() {
        defer output.Close()  // 忘记这行 = goroutine 泄漏
        for {
            chunk, err := output.Recv()
            if err != nil { break }
        }
    }()
    return ctx
},

小结

Eino StreamReader 的设计核心是一次性读取 + 显式所有权

机制 作用
Pipe[T] / stream[T] 基础读写对,channel-backed,容量可配
Copy(n) / cpStreamElement fan-out:懒读链表,不复制 channel,sync.Once 保证原子读
MergeStreamReaders fan-in:非确定性合并,多 goroutine 竞速
MergeNamedStreamReaders 带来源标识的 fan-in,SourceEOF 知道子流各自结束
StreamReaderWithConvert 类型转换 + ErrNoValue 过滤 + OnEOF 钩子
SetAutomaticClose GC finalizer 兜底,不替代显式 Close
streamReaderPacker compose 层适配器,让 Graph 节点无缝衔接流式输出

理解了这套机制,就能理解 Eino Graph 里的流式数据为什么能在节点间、Callback 之间无缝传递,而不丢数据、不发生 goroutine 泄漏。


代码来源:eino/schema/stream.go · eino/compose/stream_reader.go

相关推荐
寒水馨3 小时前
Windows下载、安装ollama-v0.32.1(附安装包OllamaSetup.exe)
windows·llm·大语言模型·llama·本地部署·ollama·模型运行
John_ToDebug3 小时前
Git Stash 完全指南:临时保存工作区的艺术
人工智能·git·agent
卡卡罗特AI3 小时前
OpenAI模型,自主入侵顶级AI公司系统!后背发凉!
aigc·openai
老梁agent3 小时前
告别硬编码 System Prompt:Prompt 六层编译引擎设计与实现
agent
iThinkAi3 小时前
别被那些“必装清单”骗了,我替你们踩完了 Codex Skills 的所有坑
aigc
决战灬4 小时前
langgraph之interrupt(事例篇)
人工智能·python·agent
张申傲4 小时前
拆解 harness9(9):Observability 可观测性
人工智能·aigc·agent·deepseek·harness
李剑一4 小时前
瑞幸也AI上了?我用AI命令行帮我点了一杯咖啡,但是我花了不止一杯咖啡钱
aigc·openai·ai编程
武子康4 小时前
Copilot Code Review 从固定 Reviewer 演进为可编程 Runtime,仓库控制面、Setup 供应链、Runner 资源和 MCP 工具同时被纳入审查决策
人工智能·github·aigc