系列「企业级 AI Agent 实现拆解」补充篇。E2 讲 Schema 时提到过
Pipe和StreamReader,但没有展开。这一篇补上------Eino 的流式管道是怎么工作的、背压(backpressure)怎么防止内存爆炸、5 个工具函数怎么组合出你想要的流处理逻辑。
读完这篇你会知道:
- Pipe 的 channel 实现:容量 = 背压阈值
- StreamReader 的 5 种内部类型
- Send 阻塞时发生了什么(背压的物理意义)
- Copy:一个流扇出给多个消费者
- StreamReaderWithConvert:流中间加转换/过滤
- MergeStreamReaders:多个流合成一个
- 生产环境的注意事项(必须 Close、goroutine 泄漏排查)
先搞懂"为什么需要流"
LLM 生成文字不是一瞬间的。它是一个字一个字往外蹦:
erlang
"你" → "好" → "!" → "我" → "是" → "AI" → ...
如果等全部生成完再返回,用户要盯着空白屏幕等好几秒。流式输出(streaming)让每个字生成后立刻发给用户,体验好得多。
但流的工程问题不少:
- 生产者太快 → 内存撑爆
- 消费者太慢 → 数据堆积
- 中途报错 → 怎么通知下游
- 一个流要给两个消费者 → 怎么分叉
Eino 用 Pipe + StreamReader 解决了这些问题。
Pipe:带容量的通道
Pipe 是流的起点。它返回一对 (StreamReader, StreamWriter)------一个读端、一个写端(stream.go:99):
go
sr, sw := schema.Pipe[string](3) // 容量 = 3
容量就是背压阈值。底层实现是一个带缓冲的 channel:
go
// stream.go:389
func newStream[T any](cap int) *stream[T] {
return &stream[T]{
items: make(chan streamItem[T], cap), // ← 就是一个 channel
closed: make(chan struct{}),
}
}
capacity = 3 意味着 channel 里最多缓存 3 个元素。写第 4 个时,Send 会阻塞------直到消费者读走一个。
ini
写入速度: 1000 个/秒
消费速度: 100 个/秒
容量 = 3
channel: [item1][item2][item3] ← 满了
Send(item4) → 阻塞!等消费者取走一个
这就是背压(backpressure)------消费者处理不过来时,压力反向传导给生产者,让它慢下来。容量越小,背压越强,内存越安全;容量越大,吞吐越高,但内存占用越多。
基本用法:
go
sr, sw := schema.Pipe[string](10)
// 写端(生产者)
go func() {
defer sw.Close() // ← 必须关闭,否则消费者永远等不到 EOF
for i := 0; i < 100; i++ {
closed := sw.Send(fmt.Sprintf("chunk_%d", i), nil)
if closed {
return // 消费者已关闭,提前退出
}
}
}()
// 读端(消费者)
defer sr.Close() // ← 必须关闭,否则生产者的 Send 可能永远阻塞
for {
chunk, err := sr.Recv()
if errors.Is(err, io.EOF) {
break // 写端关闭,流结束
}
if err != nil {
panic(err)
}
fmt.Println(chunk)
}
两条铁律:
- 写端必须
Close()------消费者靠收到io.EOF判断流结束 - 读端必须
Close()------通知写端"我不要了",防止写端的 Send 永远阻塞
StreamReader 的 5 种内部类型
StreamReader 不是一个具体的实现,而是一个联合类型 (stream.go:168):
go
type StreamReader[T any] struct {
typ readerType // 决定走哪个分支
st *stream[T] // 类型 1: Pipe 创建的 channel 流
ar *arrayReader[T] // 类型 2: 从数组创建(无 channel,纯索引)
msr *multiStreamReader[T] // 类型 3: 多流合并
srw *streamReaderWithConvert[T] // 类型 4: 带转换的流
csr *childStreamReader[T] // 类型 5: Copy 扇出的子流
}
Recv() 内部根据 typ 分发到不同的实现(stream.go:195):
go
func (sr *StreamReader[T]) Recv() (T, error) {
switch sr.typ {
case readerTypeStream: return sr.st.recv() // channel 接收
case readerTypeArray: return sr.ar.recv() // 数组索引 +1
case readerTypeMultiStream: return sr.msr.recv() // reflect.Select 多路复用
case readerTypeWithConvert: return sr.srw.recv() // 读一个 → 转换 → 返回
case readerTypeChild: return sr.csr.recv() // 从父流分发
}
}
类型 2:StreamReaderFromArray
最简单的流------把数组包成流,没有 channel、没有 goroutine:
go
sr := schema.StreamReaderFromArray([]string{"Hello", " ", "World"})
defer sr.Close()
for {
chunk, err := sr.Recv()
if errors.Is(err, io.EOF) { break }
fmt.Print(chunk) // 输出: Hello World
}
底层就是一个索引计数器,每次 Recv 索引 +1,到末尾返回 io.EOF。零开销。
Send 阻塞:背压的物理意义
Send 在 channel 满时会阻塞(stream.go:410):
go
func (s *stream[T]) send(chunk T, err error) (closed bool) {
// 先检查读端是否已关闭
select {
case <-s.closed:
return true // 读端关了,别发了
default:
}
item := streamItem[T]{chunk, err}
select {
case <-s.closed:
return true // 等待期间读端关了
case s.items <- item: // ← channel 满时阻塞在这里
return false
}
}
如果读端调了 Close(),s.closed channel 被关闭,Send 立即返回 true。这意味着:
- 消费者关闭 → 生产者被唤醒,不会永远卡住
- 消费者太慢 → 生产者自动降速,内存不会无限增长
这就是 Go channel 天然提供的背压机制。Eino 没有发明新东西,而是把 channel 的语义封装成了 StreamReader/StreamWriter 接口。
Copy:一个流扇出给多个消费者
有时候一个流需要给两个地方用------比如一个给回调处理器记录日志,一个给下游节点继续处理。
Copy 把一个流变成 N 个独立的流,每个都能读到完全相同的数据(stream.go:261):
go
sr, sw := schema.Pipe[string](5)
copies := sr.Copy(2) // 原 sr 失效,得到 2 个独立副本
callbackSr := copies[0]
downstreamSr := copies[1]
// 每个副本独立消费
go func() {
defer callbackSr.Close()
for { chunk, err := callbackSr.Recv(); /* 记录日志 */ }
}()
go func() {
defer downstreamSr.Close()
for { chunk, err := downstreamSr.Recv(); /* 继续处理 */ }
}()
关键 :调用 Copy 后,原始的 sr 变成不可用------只能用返回的副本。
内部实现:为每个副本创建一个新的子 channel,写端每发一个数据,同时写入所有子 channel。
StreamReaderWithConvert:流中间加转换/过滤
StreamReaderWithConvert 是流处理中最常用的工具------在流的中间插入一个转换函数(stream.go:691):
go
intStream := schema.StreamReaderFromArray([]int{0, 1, 2, 3})
strStream := schema.StreamReaderWithConvert(intStream,
func(i int) (string, error) {
if i == 0 {
return "", schema.ErrNoValue // ← 过滤掉 0
}
return fmt.Sprintf("val_%d", i), nil
})
defer strStream.Close()
// Recv 得到: "val_1", "val_2", "val_3"
两个特殊能力:
1. 过滤(ErrNoValue) :返回 ErrNoValue 时,这个元素被静默丢弃,自动读下一个。
2. 错误包装(WithErrWrapper):上游的错误在传递给消费者之前可以被包装或转换:
go
strStream := schema.StreamReaderWithConvert(intStream, convertFn,
schema.WithErrWrapper(func(err error) error {
if errors.Is(err, someInternalErr) {
return fmt.Errorf("处理失败: %w", err) // 包装内部错误
}
return err
}))
OnEOF 钩子:流正常结束时执行一次:
go
strStream := schema.StreamReaderWithConvert(intStream,
func(i int) ([]string, error) {
return []string{fmt.Sprintf("val_%d", i)}, nil
},
schema.WithOnEOF(func() ([]string, error) {
return []string{"最终汇总"}, nil // 流结束前额外输出一个元素
}))
MergeStreamReaders:多个流合成一个
把多个独立的流合成一个------哪个先来数据就先读哪个(stream.go:912):
go
sr1, sw1 := schema.Pipe[string](3)
sr2, sw2 := schema.Pipe[string](3)
merged := schema.MergeStreamReaders([]*schema.StreamReader[string]{sr1, sr2})
defer merged.Close()
// 哪个流先有数据,先读哪个
// 两个流都 EOF 后,merged 也 EOF
底层用 reflect.Select 实现多路复用------同时监听多个 channel,哪个可读就读哪个。当流数量 ≤ 64 时用 Go 的 select 语句,超过 64 用 reflect.Select(stream.go:542)。
arduino
流1: "A" ──→ "B" ──→ EOF
流2: ──── "X" ──→ "Y" ──→ EOF
合并后: "A" → "X" → "B" → "Y" → EOF
(顺序取决于哪个流先到)
还有带名字的版本 MergeNamedStreamReaders------每个源流有名字,某个源 EOF 时返回 SourceEOF{sourceName} 而不是直接结束,让你知道是哪个源流结束了。
完整示例:LLM 流式输出的处理管道
把前面学的串起来------模拟一个 LLM 流式输出的处理管道:
go
package main
import (
"errors"
"fmt"
"io"
"strings"
"github.com/cloudwego/eino/schema"
)
func main() {
// 1. 模拟 LLM 的流式输出
llmSr, llmSw := schema.Pipe[string](5)
go func() {
defer llmSw.Close()
words := []string{"你", "好", "!", "我", "是", "AI", "助手"}
for _, w := range words {
llmSw.Send(w, nil)
}
}()
// 2. 转换:给每个 chunk 加序号
numbered := schema.StreamReaderWithConvert(llmSr,
func(s string) (string, error) {
if s == "!" {
return "", schema.ErrNoValue // 过滤掉感叹号
}
return s, nil
})
defer numbered.Close()
// 3. 消费
var result strings.Builder
for {
chunk, err := numbered.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
panic(err)
}
fmt.Print(chunk) // 逐字打印
result.WriteString(chunk)
}
fmt.Printf("\n完整输出: %s\n", result.String())
// 输出: 你好我是AI助手
}
数据流:
lua
Pipe[string](5) StreamReaderWithConvert Recv loop
"你" ──────────────────────────→ "你" ──────────────→ fmt.Print
"好" ──────────────────────────→ "好" ──────────────→ fmt.Print
"!" ──────────────────────────→ ErrNoValue (过滤) ─→ 自动跳过
"我" ──────────────────────────→ "我" ──────────────→ fmt.Print
"是" ──────────────────────────→ "是" ──────────────→ fmt.Print
"AI" ─────────────────────────→ "AI" ──────────────→ fmt.Print
"助手" ───────────────────────→ "助手" ─────────────→ fmt.Print
sw.Close() ───────────────────→ io.EOF ────────────→ break
自动关闭:SetAutomaticClose
如果你不想手动调 Close(),可以设置自动关闭:
go
sr, _ := schema.Pipe[string](5)
sr.SetAutomaticClose() // GC 回收 sr 时自动 Close()
底层用 runtime.SetFinalizer 注册了一个 GC 回调(stream.go:279)。适合短生命周期的流,不推荐用于关键路径------GC 时机不可控。
生产环境注意事项
1. 必须关闭,否则 goroutine 泄漏
go
// ❌ 错误:只 Close 写端不 Close 读端
sw.Close()
// 如果 Send 还在阻塞,它会被 s.closed 唤醒
// 但如果 Recv 端从不关闭,某些 Merge 场景下 goroutine 可能泄漏
// ✅ 正确:两端都关
defer sw.Close() // 写端
defer sr.Close() // 读端
2. 容量选择
| 场景 | 建议容量 | 理由 |
|---|---|---|
| LLM 流式输出 | 1-5 | 每个 chunk 很小,不需要大缓冲 |
| 文档处理 | 10-20 | 批处理,适当缓冲提高吞吐 |
| 高并发合并 | 5-10 | 太大会占内存,太小会频繁阻塞 |
3. StreamReader 是一次性的
一个 StreamReader 只能被一个 goroutine 消费。如果两个 goroutine 同时调 Recv,数据会错乱。需要多消费者用 Copy。
4. ErrNoValue 只在 StreamReaderWithConvert 里用
不要在别的地方用 ErrNoValue------它是一个特殊的哨兵值,只在 convert 函数的返回值中有意义。
小结
| 工具 | 干什么 | 一句话 |
|---|---|---|
Pipe[T](cap) |
创建读写对 | channel 封装,cap = 背压阈值 |
StreamReaderFromArray |
数组变流 | 零开销,纯索引遍历 |
Copy(n) |
一变多 | 扇出 N 个独立副本 |
StreamReaderWithConvert |
流中间加转换 | 转换 + 过滤 + 错误包装 |
MergeStreamReaders |
多变一 | 多路复用,谁先到读谁 |
SetAutomaticClose |
GC 自动关 | 不推荐关键路径用 |
记住三句话:
- 背压 = channel 满了 Send 就阻塞------生产者自动降速,不用你手动控制
- 每个流用完必须
Close()------两端都要关,否则 goroutine 泄漏 - 流是一次性的------一个 StreamReader 只能被一个消费者读,多消费者用
Copy
本文涉及的源码版本:eino main 分支。文中代码已简化以突出核心逻辑,完整实现请参考 schema/stream.go。