流式管道:Pipe、StreamReader、背压控制

系列「企业级 AI Agent 实现拆解」补充篇。E2 讲 Schema 时提到过 PipeStreamReader,但没有展开。这一篇补上------Eino 的流式管道是怎么工作的、背压(backpressure)怎么防止内存爆炸、5 个工具函数怎么组合出你想要的流处理逻辑

读完这篇你会知道:

  • Pipe 的 channel 实现:容量 = 背压阈值
  • StreamReader 的 5 种内部类型
  • Send 阻塞时发生了什么(背压的物理意义)
  • Copy:一个流扇出给多个消费者
  • StreamReaderWithConvert:流中间加转换/过滤
  • MergeStreamReaders:多个流合成一个
  • 生产环境的注意事项(必须 Close、goroutine 泄漏排查)

先搞懂"为什么需要流"

LLM 生成文字不是一瞬间的。它是一个字一个字往外蹦:

复制代码
"你" → "好" → "!" → "我" → "是" → "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 会阻塞------直到消费者读走一个。

复制代码
写入速度: 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)
}

两条铁律

  1. 写端必须 Close()------消费者靠收到 io.EOF 判断流结束
  2. 读端必须 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.Selectstream.go:542)。

复制代码
流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助手
}

数据流:

复制代码
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 自动关 不推荐关键路径用

记住三句话

  1. 背压 = channel 满了 Send 就阻塞------生产者自动降速,不用你手动控制
  2. 每个流用完必须 Close()------两端都要关,否则 goroutine 泄漏
  3. 流是一次性的------一个 StreamReader 只能被一个消费者读,多消费者用 Copy

本文涉及的源码版本:eino main 分支。文中代码已简化以突出核心逻辑,完整实现请参考 schema/stream.go

相关推荐
云烟成雨TD2 小时前
Agent Scope Java 2.x 系列【4】模型层
java·人工智能·agent
云烟成雨TD2 小时前
Agent Scope Java 2.x 系列【5】智能体抽象层
java·人工智能·agent
hoaxxcj3 小时前
AI编程2026:Copilot桌面应用发布,我们正在经历一场不可逆的范式转移
copilot·agent·ai编程·github copilot·编程工具
摸鱼同学4 小时前
17-Codex 高级工作流:Subagent、Worktree、多模型路由
ai·agent·codex
XLYcmy4 小时前
一个基于 Python 的轻量级 LLM(大语言模型)API 客户端程序:从API交互到LLM应用架构
服务器·python·ai·llm·prompt·agent·token
云烟成雨TD5 小时前
Agent Scope Java 2.x 系列【1】核心架构
java·人工智能·agent
AustinXu5 小时前
谁在驾驭 AI-Native 的组织?一份实战报告
人工智能·agent·敏捷开发
阿泽·黑核5 小时前
使用 C 语言结构体设计模块化按键检测
嵌入式·agent·模块化设计
菜鸟小九5 小时前
hello agent(智能体经典范式、框架开发实践)
python·langchain·agent