流式消费:从 StreamReader 到 SSE 推送

系列「企业级 AI Agent 实现拆解」补充篇。流式管道:Pipe、StreamReader、背压控制 讲了流是怎么造出来 的------Pipe、channel、背压。这一篇讲流的另一头:怎么把它消费掉

一条 Eino 流,从 StreamReader.Recv() 一个个取 token,到最终变成浏览器屏幕上一个个蹦出来的字,中间这段路,就是这一篇的内容。顺带回答几个真做流式时绕不开的问题:参数是分片到的 ToolCall 怎么拼?浏览器中途断了,流还要不要读完?为什么要给流包一层适配器?

读完这篇你会知道:

  • 消费一条 Eino 流的「标准循环」长什么样(5 行骨架)
  • consumeEinoStream 逐行拆解:为什么是「边累积、边推」
  • 流式 ToolCall 怎么检测:参数被拆成碎片,怎么拼回完整 JSON
  • tokenStreamReader 适配器:为什么只取文字、扔掉 ToolCall
  • SSE 推送:浏览器断开了,流为什么还要继续读完

先回顾:流是一条「不断的水」

流式管道:Pipe、StreamReader、背压控制用过一个比喻:LLM 生成文字像水龙头,一个字一个字往外滴,不是一下子哗啦全出来。

复制代码
"你" → "好" → "!" → "我" → "是" → "AI" → ...

它讲的是水龙头和水管 ------Pipe 怎么造、channel 满了怎么「背压」让生产者慢下来。这一篇讲水杯:你拿什么接水、接了水往哪倒。

Eino 的流是 StreamReader[T]。消费它,核心就一个动作:

go 复制代码
chunk, err := sr.Recv()   // 取一块

调一次拿一块,再调再拿一块,直到拿到 io.EOF,表示流完了。

听起来很简单。但「消费」这件事,比「会调 Recv」复杂------因为你消费的时候,通常要同时干好几件事

  • 把 token 攒起来(流结束后要存进数据库,当这一轮的完整回复)
  • 每个 token 实时推给浏览器(用户要看到字一个个蹦出来,不能干等)
  • 中途报错了怎么办
  • 用户等不及关了页面,流还要不要继续读

DeepFlux 用一个 36 行的函数把这几件事全处理了,叫 consumeEinoStream。但在看它之前,先看最朴素的消费长什么样。


一、消费一条流的「标准循环」

这是 Eino 官方示例里的消费写法(quickstart/chat/stream.go:26):

go 复制代码
func reportStream(sr *schema.StreamReader[*schema.Message]) {
    defer sr.Close()                  // 1. 用完必须关

    i := 0
    for {                             // 2. 死循环
        message, err := sr.Recv()     // 3. 取一块
        if err == io.EOF {            // 4. 流正常结束
            return
        }
        if err != nil {               // 5. 真出错了
            log.Fatalf("recv failed: %v", err)
        }
        log.Printf("message[%d]: %+v\n", i, message)   // 处理这一块
        i++
    }
}

五个要素,缺一不可:

# 代码 为什么
1 defer sr.Close() 不关,写端可能永远阻塞,goroutine 泄漏(E6 讲过这条铁律)
2 for {} 流有多少块事先不知道,只能循环到结束
3 sr.Recv() 取下一块的唯一办法
4 err == io.EOF 这是「流正常结束」的信号,不是错误
5 err != nil(非 EOF) 这才是真出错,要处理

小白最容易踩的坑:把 io.EOF 当成错误去 panic。它不是。它就像读到文件末尾------是「读完了」的意思,是正常流程。所以必须先判 EOF,再判其他错误,顺序不能反。

这个循环会消费,但只会「打印」。生产环境不够用------你不能光打印不存库,也不能让用户盯着你的日志看。所以需要升级版。


二、consumeEinoStream:边收边发的完整逻辑

这是 DeepFlux 真实生产代码,完整 36 行(run_turn_stream.go:18):

go 复制代码
// consumeEinoStream · 消费 TokenReader → emit turn.delta SSE + 返回 final content
func (h *RunTurnHandler) consumeEinoStream(ctx context.Context, sid model.SessionID, tr port.TokenReader) (string, error) {
    var sb strings.Builder
    for {
        content, err := tr.Recv()
        if err != nil {
            if errors.Is(err, io.EOF) {
                break
            }
            return sb.String(), err
        }
        if content != "" {
            sb.WriteString(content)
            if h.stream != nil {
                h.stream.Emit(ctx, sid, port.StreamEvent{Type: "turn.delta", Payload: content})
            }
        }
    }
    return sb.String(), nil
}

和官方示例比,它多了三件事。逐段拆:

① 准备一个攒内容的容器

go 复制代码
var sb strings.Builder

strings.Builder 是 Go 里拼字符串的高性能方式(比 += 快)。这里用来把所有 token 攒成完整回复,最后返回给调用方存库。

② EOF 和真错误分开处理

go 复制代码
content, err := tr.Recv()
if err != nil {
    if errors.Is(err, io.EOF) {
        break                    // 流正常结束,跳出循环
    }
    return sb.String(), err      // 真出错:把已经收到的部分也返回
}

注意出错那一行:return sb.String(), err。它没有直接 return "", err,而是把已经收到的那部分内容也带回去。

为什么?因为哪怕流中途崩了,前面已经吐出来的 token 是真实的、有价值的。比如 LLM 说到一半超时了,前 200 个字已经生成------这 200 个字值得存下来供排查,不该丢。

③ 边累积、边推 SSE(核心)

go 复制代码
if content != "" {
    sb.WriteString(content)                                  // 攒起来
    if h.stream != nil {
        h.stream.Emit(ctx, sid, port.StreamEvent{            // 同时推给浏览器
            Type:    "turn.delta",
            Payload: content,
        })
    }
}

每收到一个 token,同时做两件事:

复制代码
                  ┌──→ sb.WriteString(content)    攒全文(最后存库)
content 进来 ─────┤
                  └──→ stream.Emit(turn.delta)    推 SSE(用户立刻看到)

为什么要「双写」?想清楚这三点的取舍就懂了:

  • 等流结束才发 → 用户盯着空白屏幕干等好几秒,违背流式的初衷
  • 只发不攒 → 流结束后你手里啥也没有,没法存这一轮的完整回复
  • 边攒边发 → 用户实时看到字,流结束后你也有完整内容 ✅

这就是「边收边发」的全部秘密:一个 token,落两处。

数据从 Eino 流到浏览器的完整路径:

复制代码
Eino ReAct 图
   │ runnable.Stream() 返回
   ▼
schema.StreamReader[*schema.Message]      ← Eino 的流(一块 = 一个 Message)
   │ tokenStreamReader.Recv() 取出文字
   ▼
consumeEinoStream 的 for 循环
   │           │
   │   ┌───────┴────────┐
   ▼   ▼                ▼
sb.WriteString    stream.Emit(turn.delta)
   │                │
   │                ▼
   │          SSE 帧 → 浏览器(用户看到字蹦出来)
   ▼
return sb.String() → 存进 messages 表(完整回复)

上面那张图左边那条「攒全文」的线,最后落到 run_turn.go:206

go 复制代码
asstMsg := model.NewAssistantMessage(sess.ID(), sess.TurnCount(), finalContent, nil)

finalContent 就是 consumeEinoStream 返回的 sb.String()------一整轮对话攒下来的完整回复,存进数据库。


三、流式 ToolCall 检测:参数是分片到的

文字流好办,一个字一个字。但当 LLM 决定调用工具(比如查天气)时,它吐出来的不是文字,而是一段工具调用的 JSON:

json 复制代码
{"name": "get_weather", "arguments": {"city": "北京"}}

麻烦来了:流式模式下,这段 JSON 是被拆成好几片吐出来的,不是一次性给你:

复制代码
片1: {"name":"get_wea            ← 只有名字的开头
片2: ther","arguments":           ← 名字的尾巴 + 字段名
片3: {"city":"北京"}}             ← 参数的值

你收到的是一堆碎片。要回答两个问题:

  1. 哪些碎片属于同一个工具调用?(LLM 可能一次调多个工具)
  2. 怎么把散落的碎片拼回完整的 JSON?

Eino 官方的标准做法:用 Index 当钥匙

Eino 官方示例(a2ui/streamer.go)给了一个标准范式------每个工具调用碎片都带一个 Index 字段(「这是第几个工具调用」),用它当钥匙往 map 里塞:

go 复制代码
// 简化自 a2ui/streamer.go:219
for _, tc := range msgops.ToolCalls(chunk) {
    idx := tc.Index
    seenTCIdx[idx] = true              // 记住出现过这个序号

    if tc.Name != "" {                 // 这片带名字,就记下名字
        nameByIdx[idx] = tc.Name
    }
    if tc.ID != "" {                   // 这片带 ID,就记下 ID
        idByIdx[idx] = tc.ID
    }
    if tc.Args != "" {                 // 这片带参数片段,就追加到 Builder
        if argsByIdx[idx] == nil {
            argsByIdx[idx] = &strings.Builder{}
        }
        argsByIdx[idx].WriteString(tc.Args)   // ← 关键:参数是分片的,要拼接
    }
}

一张图说清楚累积过程(假设 LLM 要同时调两个工具):

复制代码
到达的碎片               nameByIdx      idByIdx      argsByIdx(Builder)
─────────────────────────────────────────────────────────────────────
{Index:0, Name:"get_weather"}    [0]=get_weather
{Index:0, Args:'{"city":"北'}                            [0] = '{"city":"北'
{Index:1, Name:"search_web"}     [1]=search_web
{Index:0, Args:'京"}'}                                    [0] = '{"city":"北京"}'  ← 拼上了!
{Index:1, Args:'{"q":"news"}'}                            [1] = '{"q":"news"}'

流结束 → 遍历 map,得到两个完整工具调用:
  [0] get_weather({"city":"北京"})
  [1] search_web({"q":"news"})

核心就一句:同一个 Index 的碎片,名字记一次、ID 记一次、参数往 Builder 里追加。流结束,map 里就是完整的工具调用列表。

这是「姿势 A」:从流里解析。要自己管 map、管 Builder、管拼接顺序。通用、灵活,但代码量不小。

DeepFlux 的选择:不解析流,用 callback 钩子

DeepFlux 没用姿势 A。它消费流时故意只取文字、把 ToolCall 整个扔掉 ------下一节细说。工具调用靠 Eino 的 callback 钩子在节点边界感知,不用管分片拼接。

这是「姿势 B」。两种姿势的分工:

姿势 A(解析流) 姿势 B(callback 钩子)
怎么知道调了工具 自己从流的碎片里拼 Eino 内部拼好了,在节点开始/结束时通知你
谁负责拼参数 你(map + Builder) Eino ReAct 图内部
代码量
灵活度 高(能拿到中间过程) 低(只拿到开始/结束两个时刻)
适合 需要精细控制工具调用过程的场景 用 ReAct 图、只想知道「调了啥、结果啥」的场景

DeepFlux 用了 Eino 的 ReAct 图(工具调用是图里的节点),所以选姿势 B 更自然------下面看它具体怎么做。


四、tokenStreamReader 适配器:为什么只取文字

先看这个适配器的完整代码(factory.go:178):

go 复制代码
// tokenStreamReader · wraps schema.StreamReader[*schema.Message] 实现 port.TokenReader
type tokenStreamReader struct {
    sr *schema.StreamReader[*schema.Message]
}

func (r *tokenStreamReader) Recv() (string, error) {
    msg, err := r.sr.Recv()
    if err != nil {
        return "", err
    }
    if msg != nil {
        return msg.Content, nil      // ← 只取文字,ToolCalls 被丢弃
    }
    return "", nil
}

func (r *tokenStreamReader) Close() { r.sr.Close() }

它做的事极其简单:把 Eino 的 StreamReader[*schema.Message] 包一层,对外只暴露 Recv() (string, error)------每次只返回 msg.Content(文字部分),msg.ToolCalls 直接无视

这就是上一节说的「姿势 B」落地:token 流保持纯粹,只管文字。工具调用不在这里感知。

那工具调用的 SSE 帧是谁发的?AgentCallback

Eino 内部决定调工具时,会触发 callback 的 OnStart 钩子。DeepFlux 在那里检测到「这是个工具节点」,就推一个 turn.tool_call 帧(callback.go:40):

go 复制代码
b.OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
    switch info.Component {
    case components.ComponentOfChatModel:
        // 调 LLM 前:跑 BeforeModelCall hook(脱敏、审核)
        // ...
    case components.ComponentOfTool:
        // 工具调用前:发 turn.tool_call 帧
        ci := tool.ConvCallbackInput(input)
        tc := model.ToolCall{Name: info.Name, Arguments: ci.ArgumentsInJSON}
        if streamer != nil {
            streamer.Emit(ctx, sid, port.StreamEvent{
                Type:    "turn.tool_call",
                Payload: map[string]any{"name": info.Name, "arguments": ci.ArgumentsInJSON},
            })
        }
    }
    return ctx
})

对应的 OnEnd 钩子,在工具执行完时发 turn.tool_result 帧(callback.go:71)。所以一整轮对话,浏览器收到的 SSE 帧序列大致是:

复制代码
data: turn.delta  "你好"          ← LLM 生成文字(来自 consumeEinoStream)
data: turn.delta  ",让我查一下"   ← LLM 生成文字
data: turn.tool_call  get_weather  ← 要调工具了(来自 callback OnStart)
data: turn.tool_result  晴, 25℃   ← 工具结果(来自 callback OnEnd)
data: turn.delta  "北京今天晴"     ← LLM 继续生成文字
data: done                         ← 本轮结束

分工一目了然:

复制代码
token 流(consumeEinoStream)  ──→  turn.delta 帧(文字 token)
callback 钩子(OnStart/OnEnd) ──→  turn.tool_call / turn.tool_result 帧(工具)

两条路并行,各管各的。tokenStreamReader 根本不用操心 ToolCall 怎么分片拼接------那活儿 Eino ReAct 图内部干完了,到 callback 这一层时,ci.ArgumentsInJSON 已经是拼好的完整 JSON

为什么非要包这一层适配器?

看起来 tokenStreamReader 只是「取个 Content」,像是多此一举。但它解决一个硬约束------DDD 的 D2 规则:application 层不能依赖 infrastructure

application 层定义的消费接口(ports.go:138):

go 复制代码
// TokenReader · application 层读取 LLM 流式 token(框架无关)
// Recv 返回 io.EOF 表示流结束,其他 error 为执行错误
type TokenReader interface {
    Recv() (string, error)
    Close()
}

注意注释里的「框架无关」。application/command/run_turn_stream.go 里的 consumeEinoStream 只认这个接口------它根本不知道 背后是 Eino 的 StreamReader[*schema.Message]

复制代码
application 层(consumeEinoStream)
        │ 只依赖 port.TokenReader(Recv/Close)
        │  ── 不知道 Eino 存在 ──
        ▼
infrastructure 层(tokenStreamReader)实现 TokenReader
        │ 桥接 Eino schema.StreamReader[*schema.Message]
        ▼
Eino 框架

好处很实在:哪天把 Eino 换成别的框架(比如直接用 OpenAI SDK),consumeEinoStream 一行不用改------只要新写一个适配器实现 TokenReader 就行。业务逻辑和框架细节,被这个接口彻底隔开。

这就是「适配器」三个字的分量:不是过度设计,是 DDD 4 层架构里 application 和 infrastructure 之间的绝缘层


五、SSE 推送:浏览器断开了,流还要读完吗?

最后一个问题,也是最实际的一个。

SSE(Server-Sent Events)是服务器往浏览器单向推消息的协议。用户看到字一个个蹦出来,靠的就是它。但有个尴尬场景:用户等不及,中途关了页面

这时候 Eino 那边的流还在咕嘟咕嘟吐 token。你怎么办?两个选择:

  • A. 立刻停 --- 省 token 钱,但这一轮的记录可能残缺
  • B. 继续读完 --- 多花点 token,但能保证存库内容完整

Eino 官方示例选了 B,叫 writerBroken 模式a2ui/streamer.go:99):

go 复制代码
// writerBroken 在 SSE 写失败时置 true(比如用户中途关了页面)。
// 置 true 后停止往 UI 写,但继续消费事件流,
// 保证 intermediates 完整累积,用于 session 持久化。
writerBroken := false

for {
    chunk, recvErr := mo.MessageStream.Recv()
    // ...
    accContent.WriteString(text)
    if writerBroken {
        continue          // ← 不再往浏览器推,但循环没停,还在收
    }
    // ...
    if err := emitDataUpdate(w, ...); err != nil {
        writerBroken = true   // 推失败了,标记一下,以后只收不发
    }
}

逻辑就一句话:推不动了就不推,但收的脚步不停

为什么这么设计?因为「用户关页面」是高频事件,但你不能因为用户走了,就把 agent 正在做的事记成一坨残缺数据。把流完整读完,中间产生的工具调用、部分文字,都能正确存进会话历史------下次用户重新打开,看到的是完整的上一轮,而不是断在半截的乱码。

复制代码
正常情况:    收 token ──→ 攒 + 推浏览器 ──→ 浏览器显示
浏览器断开:  收 token ──→ 攒(不推了)  ──→ 浏览器没显示
                                       └─→ 但存库内容是完整的 ✅

这是「流式消费」里很容易被忽略的工程细节:消费和生产是解耦的。消费者(浏览器)挂了,不代表你要把生产者(LLM 流)也掐了。该读完读完,该存全存全。


六、串起来:从一行请求到屏幕上的字

把这篇所有零件装回 DeepFlux 的真实链路,一整轮对话的数据流是这样的(run_turn.go:188):

go 复制代码
// 1. 启动流式执行,拿到一个 TokenReader(背后是 Eino 的流)
sess.SetPhase(model.PhaseThinking)
tr, interrupt, err := h.runnableFac.StreamTurn(ctx, cfg, sess.TenantID(), runInput, h.hooks, h.stream, sess.ID())
if interrupt != nil {
    return h.handleEinoInterrupt(ctx, sess, interrupt)   // HITL 中断,走另一条路
}
defer tr.Close()

// 2. 消费流:边攒全文、边推 turn.delta SSE 帧
finalContent, err := h.consumeEinoStream(ctx, sess.ID(), tr)

// 3. 用攒好的完整内容,落库成这一轮的 assistant 消息
asstMsg := model.NewAssistantMessage(sess.ID(), sess.TurnCount(), finalContent, nil)

中间这条链上,每个角色各司其职:

角色 文件 干什么
StreamTurn factory.go:70 调 Eino Runnable,返回 tokenStreamReader(或中断信号)
tokenStreamReader factory.go:178 适配器:Eino 流 → port.TokenReader,只取文字
consumeEinoStream run_turn_stream.go:18 消费循环:边攒 sb、边 Emit(turn.delta)
AgentCallback callback.go:27 工具节点边界:发 turn.tool_call / turn.tool_result
Streamer ports.go:77 SSE 出口:把 StreamEvent 推给浏览器
writerBroken 模式 (官方范式) 浏览器断了仍读完,保证存库完整

Eino 框架负责「流怎么造、工具怎么调」,DeepFlux 负责「流怎么消费、怎么变成 SSE 推给前端」。tokenStreamReader 是两者之间那座桥。


小结

知识点 一句话
标准消费循环 defer Close + for { Recv } + 先判 EOF 再判错误
consumeEinoStream sb.WriteString 攒全文、边 Emit(turn.delta) 推浏览器,一个 token 落两处
流式 ToolCall 检测 参数是分片到的;姿势 A 用 map[Index]*Builder 拼,姿势 B 用 callback 钩子在节点边界拿
tokenStreamReader 适配器:Eino 流 → port.TokenReader,只取文字,让 application 层不依赖框架
SSE 断连处理 writerBroken 模式------推不动就不推,但流要读完,保证存库完整

记住三句话

  1. 消费一条流就是「关、循环、取、判 EOF、判错误」------五步缺一不可,EOF 不是错误,是「读完了」。
  2. 流式消费的核心是「双写」------一手攒全文存库,一手推 SSE 给用户,两手都要硬。
  3. ToolCall 检测有两条路 ------要么自己从流碎片拼(map + Builder),要么让框架拼好、你在 callback 钩子里收。用 ReAct 图就选后者,省心。

本文涉及的源码:Eino 框架 eino main 分支 + eino-examples;DeepFlux 适配代码 server/internal/agent/{application/command/run_turn_stream.go, infrastructure/einoadapter/{factory.go,callback.go}}。文中代码已简化以突出核心逻辑,完整实现请参考对应文件。

相关推荐
aovenus16 分钟前
Skill / Agent / Workflow 使用场景指南及对比
agent·workflow·skill
JouYY2 小时前
聊一下知识答疑Agent的“层次聚类”流程
架构·llm·agent
L3S2 小时前
Agent为什么会死循环?
人工智能·agent
云烟成雨TD2 小时前
LangFlow 1.x 系列【3】入门案例
人工智能·python·agent
阿洛学长2 小时前
Cursor下载安装使用教程(最新详细图文)
人工智能·gpt·深度学习·ai·ai编程
墨流藏于库3 小时前
Electron 应用 macOS 自动更新的正确姿势 —— 没有 Apple Developer Program 也能用
agent
新知图书3 小时前
智能体基础架构
人工智能·agent·ai agent·智能体·langgraph
longxibo3 小时前
《DeepSeek 源码分析及企业应用实践》--前言
人工智能·aigc·ai编程