系列「企业级 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":"北京"}} ← 参数的值
你收到的是一堆碎片。要回答两个问题:
- 哪些碎片属于同一个工具调用?(LLM 可能一次调多个工具)
- 怎么把散落的碎片拼回完整的 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 模式------推不动就不推,但流要读完,保证存库完整 |
记住三句话:
- 消费一条流就是「关、循环、取、判 EOF、判错误」------五步缺一不可,EOF 不是错误,是「读完了」。
- 流式消费的核心是「双写」------一手攒全文存库,一手推 SSE 给用户,两手都要硬。
- 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}}。文中代码已简化以突出核心逻辑,完整实现请参考对应文件。