系列「企业级 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.OnceMergeStreamReadersfan-in 和MergeNamedStreamReaders的区别StreamReaderWithConvert:类型转换 +ErrNoValue过滤 +WithOnEOFSetAutomaticClose: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 里放 streamItem。Recv() 是取出来。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() 时:
- 查看当前节点是否已有数据(
once已执行) - 没有 → 用
sync.Once从原 stream 读一次,写入节点,并设置next指针 - 已有 → 直接读取,移动到
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 的替代:
- GC 时机不确定,finalizer 可能延迟很久才触发
- 底层 goroutine(如 MergeStreamReaders 启动的那些)在这段时间里一直跑着,消耗资源
- 有些 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 的流式执行路径:
- 节点 A 产出
*schema.StreamReader[Output] streamReaderPacker包装它- 传给下游节点 B 作为流式输入
- 节点 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 泄漏。