流式消费:从 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}}。文中代码已简化以突出核心逻辑,完整实现请参考对应文件。

相关推荐
API开发平台2 小时前
API智能开发与治理平台v5.0发布
低代码·ai编程
洛星核3 小时前
CrewAI 安装、使用方法详细全解
人工智能·github·人机交互·ai编程·agi·智能体
w3296362713 小时前
八、OpenCode 高阶玩法:CLI 自动化、CI/CD 集成与远程协作
运维·ci/cd·自动化·ai编程·开发工具·opencode
伍肆聊AI3 小时前
一篇讲清 CLAUDE.md,让 Claude Code AI 编程稳定高效不踩坑
ai编程
copyer_xyf3 小时前
Agent Tool 调用
后端·python·agent
copyer_xyf3 小时前
Agent 结构化输出
后端·python·agent
玉鸯3 小时前
理解 Agent 的运行时心脏--从零写一个 Agent Loop
agent
HIT_Weston3 小时前
115、【Agent】【OpenCode】项目配置(SemVer)
人工智能·agent·opencode