
字节笔记本
2026年10月24日 · 约 24 分钟读完
从阻塞到事件流:流式输出怎么做

本文是《从 0 写 Agent》系列第 6 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「流式输出:从阻塞到事件流」。
你用 ChatGPT 时,看到文字一个字一个字蹦出来,这不是模型"打字慢",是流式输出。模型边想边吐,前端边收边显示,你感觉它在"实时回应"。
这一章拆 agents-go 的流式实现,
RunStreamed怎么用 channel + goroutine 把 Agent 循环变成事件流,以及阻塞和流式到底差在哪。
引子:阻塞 vs 流式,差在哪
阻塞调用:Agent 跑完整个循环(可能 10 轮、调 5 个工具、花 30 秒),一次性返回最终结果。
用户提问 → [Agent 跑了 30 秒,期间什么都没有] → 最终答案
流式调用:Agent 循环里发生的一切,实时推给消费者。
用户提问 → [模型开始想...] → [决定调工具] → [工具结果回来了]
→ [模型继续想...] → [第一个字] → [第二个字] → ... → [最终答案]对用户体验,差距巨大。 阻塞模式下,用户盯着 spinner 干等 30 秒;流式模式下,用户实时看到 Agent 在干什么,知道它没死。
但流式的实现复杂得多,要处理"部分输出"、要处理"中途失败"、要处理"取消"。agents-go 怎么做的?

原理:生产者-消费者模型
流式的核心是一个经典的并发模式,生产者-消费者。
生产者(Agent 循环) 消费者(前端/调用方)
│ │
│ 产出事件 │
├──────────► channel ◄─────────────┤
│ (缓冲队列) │ 消费事件
│ │- 生产者:Agent 循环在后台 goroutine 里跑,每发生一件事(模型吐字、工具调用、handoff),往 channel 发一个事件
- 消费者:调用方迭代 channel,逐个处理事件(比如推给 WebSocket)
- channel:中间的缓冲队列,解耦生产和消费的速度
agents-go 的 RunStreamed 就是这个模型。
源码对照:StreamedResult 的结构
StreamedResult(agents/stream.go:45)是流式调用的句柄,消费者通过它拿事件:
// agents/stream.go:45
type StreamedResult struct {
ch chan streamMsg // 事件 channel
mu sync.Mutex
final *RunResult // 循环结束后的最终结果
err error
}
type streamMsg struct {
event StreamEvent // 事件(可能为 nil)
err error // 终止错误(可能为 nil)
}两个核心字段:
ch,带 64 缓冲的 channel,事件管道final/err,循环跑完后存最终结果,用 mutex 保护(因为生产者写、消费者读)
消费者有两个 API:
// agents/stream.go:61
func (s *StreamedResult) Events() iter.Seq2[StreamEvent, error] {
return func(yield func(StreamEvent, error) bool) {
for msg := range s.ch { // 迭代 channel
if msg.err != nil {
yield(nil, msg.err) // 终止错误
return
}
if !yield(msg.event, nil) {
return // 消费者提前 break
}
}
}
}
// agents/stream.go:77
func (s *StreamedResult) FinalResult() (*RunResult, error) {
// 迭代完 channel 后调用,拿最终结果
}使用模式,先迭代事件,迭代完拿最终结果:
sr := agents.RunStreamed(ctx, agent, "帮我查天气", opts)
// 1. 消费事件流
for event, err := range sr.Events() {
if err != nil { panic(err) }
switch e := event.(type) {
case *agents.RunItemStreamEvent:
if e.Name == "message_output_created" {
fmt.Print(e.Item.(*agents.MessageOutputItem).Text())
}
}
}
// 2. 拿最终结果
res, _ := sr.FinalResult()这就是 Go 1.23 的 range-over-func 语法,for event, err := range sr.Events() 看起来像在迭代一个集合,实际上是在消费一个事件流。优雅。
源码对照:RunStreamed 的 goroutine 启动
// agents/stream.go:94
func RunStreamed(ctx context.Context, agent *Agent, input any, opts RunOptions) *StreamedResult {
sr := &StreamedResult{ch: make(chan streamMsg, 64)}
go func() { // 起一个后台 goroutine 跑循环
defer close(sr.ch) // 循环结束,关闭 channel(消费者迭代会自动结束)
res, err := runStreamedLoop(ctx, agent, input, opts, sr)
sr.setFinal(res, err) // 存最终结果
if err != nil {
sr.ch <- streamMsg{err: err} // 发终止错误
}
}()
return sr // 立即返回,不阻塞
}三个关键点:
1. RunStreamed 立即返回
注意它不阻塞,起 goroutine 后立即返回 sr。调用方拿到 sr 后,可以一边消费事件、一边让后台 goroutine 继续跑循环。这是异步编程的标准姿势,不要让调用方干等。
2. defer close(sr.ch)
goroutine 退出时关闭 channel。这是 Go 的惯用法,生产者关闭 channel,消费者迭代自动结束。 如果不 close,消费者的 for range 会永远阻塞。
3. channel 缓冲 64
ch: make(chan streamMsg, 64)为什么带缓冲? 如果是无缓冲 channel,生产者每次发事件都要等消费者收,生产快、消费慢时,生产者被阻塞,Agent 循环卡住。缓冲 64 让生产者能"先发一批",不被慢消费者拖累。
64 是经验值,足够吸收一次 turn 的事件爆发(一次模型调用可能产生几十个 token delta),又不至于占太多内存。
源码对照:emit 的取消感知
生产者往 channel 发事件,用 emit(stream.go:111):
// agents/stream.go:111
func (s *StreamedResult) emit(ctx context.Context, event StreamEvent) {
select {
case s.ch <- streamMsg{event: event}: // 正常发送
case <-ctx.Done(): // context 取消了,丢弃事件
}
}select 是关键,它同时监听两个 channel:发送给 s.ch,或者 ctx.Done()(取消信号)。
如果消费者已经走人(取消 context),生产者不会卡在"往一个没人收的 channel 发送"上,ctx.Done() 先到,事件被丢弃,生产者继续(在下一轮 turn 检查 ctx.Err() 时发现取消,退出循环)。
这是 Go 并发的优雅之处,用 select 处理"正常路径 + 取消路径",没有死锁风险。
三种 StreamEvent
agents-go 定义了三种事件(stream.go:9-38),都是封闭接口:
| 事件类型 | 含义 | 用途 |
|---|---|---|
RawResponsesStreamEvent | 模型的原始流式事件,透传 | 拿到逐字 token(打字效果) |
RunItemStreamEvent | Agent 产出一个新 item(消息/工具调用/输出/handoff) | UI 更新("调用了工具") |
AgentUpdatedStreamEvent | 控制权切换到新 Agent | UI 显示"现在 X 接手了" |
其中 RunItemStreamEvent 有个 Name 字段(stream.go:24),表示具体是什么 item:
// agents/stream.go:118
switch item.(type) {
case *MessageOutputItem: return "message_output_created"
case *ToolCallItem: return "tool_called"
case *ToolCallOutputItem: return "tool_output"
case *HandoffCallItem: return "handoff_requested"
case *HandoffOutputItem: return "handoff_occured"
case *ReasoningItem: return "reasoning_item_created"
}消费者根据 Name 决定怎么渲染,"tool_called" 显示个 spinner,"tool_output" 显示结果,"message_output_created" 显示文字。
流式文本的来源:response.output_text.delta
打字效果的关键,在 RawResponsesStreamEvent 里。OpenAI 的流式 API 会发一系列事件,其中 response.output_text.delta 是"又吐了一个字"。
agents-go 的流式实现(agents/stream_run.go:301 的 streamOneModelCall)把这些原始事件透传给消费者:
// agents/stream_run.go:301(骨架)
func streamOneModelCall(...) iter.Seq2[*TResponseStreamEvent, error] {
return func(yield func(*TResponseStreamEvent, error) bool) {
for event, err := range model.StreamResponse(ctx, req) {
if err != nil { yield(nil, err); return }
// 透传原始事件(消费者能拿到 response.output_text.delta)
if !yield(event, nil) { return }
// 收集 response.completed 事件,组装最终响应
if event.Type == "response.completed" {
finalResponse = assembleFrom(event)
}
}
// 没有 completed 事件 → 报错(不伪造空响应)
}
}注意一个鲁棒性设计,如果流式响应里没有 response.completed 事件(异常情况),agents-go 报错而不是伪造一个空响应(stream_run.go:322)。因为"伪造成功"比"明确失败"更危险,用户以为 Agent 跑完了,其实什么都没有。
设计权衡:阻塞和流式为什么不能统一
第 2 章我们讲过,Model 接口有两个方法(GetResponse 阻塞、StreamResponse 流式)。有人会问,为什么不统一成流式?阻塞不就是"读完整个流"吗?
agents-go 在流式版 runStreamedLoop 和阻塞版 loop 之间,有几个真实的差异:
1. guardrail 的并发模型不同。
阻塞版,输入 guardrail 在后台 goroutine 并发(和模型调用同时跑,省时间):
// 阻塞版 run.go:299
go func() { guardResult := runInputGuardrails(...) }() // 后台
resp := model.GetResponse(...) // 同时调模型流式版,输入 guardrail 同步先跑完:
// 流式版 stream_run.go:140
guardResult := runInputGuardrails(...) // 先跑完
streamOneModelCall(...) // 再开始流式注释明说"for simplicity"(为了简单)。流式版如果也后台并发 guardrail,要在流已经开始后再决定是否取消,逻辑复杂。简化处理是合理的工程取舍。
2. HITL resume 的处理不同。
阻塞版有 pendingResponse 分支(HITL 恢复时不重调模型)。流式版没有这个分支,流式版目前不支持 HITL resume 复用上次的 response。
3. 事件推送。
这是本质差异,流式版在产出 item 时 sr.emit(...) 推事件,阻塞版不推。这意味着两个版本的代码路径不同(一个有 emit,一个没有)。
所以阻塞和流式是两套实现,共享大部分逻辑(executeToolsAndSideEffects、processModelResponse、分支决策),但在模型调用、guardrail、事件推送三处分道扬镳。 强行统一会让其中一套变复杂,不如分开。
消费者的责任:不迭代要取消
StreamedResult 有个文档警告(stream.go:42):
If the consumer stops iterating early, it must cancel the run's context — otherwise the producing goroutine stays blocked on the event channel.
翻译,如果你消费到一半不想消费了,必须取消 context。否则生产者 goroutine 会卡在"往一个没人收的 channel 发送"上(虽然有 64 缓冲,但满了就卡),造成 goroutine 泄漏。
正确姿势:
ctx, cancel := context.WithCancel(parentCtx)
defer cancel() // 确保最终取消
sr := agents.RunStreamed(ctx, agent, input, opts)
for event, err := range sr.Events() {
if someCondition {
break // 提前退出
}
}
// cancel() 会通过 defer 触发,生产者感知 ctx.Done() 后退出这是 Go 流式编程的纪律,生产者和消费者的生命周期要绑定。 context 是这个绑定的纽带。
实战对照:mini-claude-code 的流式实现
mini-claude-code 不用 channel + goroutine(它单线程消费),而是用回调函数实现流式,更简单,更适合终端 REPL:
// mini-claude-code/model.go 的 ChatStream
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text() // 逐行读 SSE
if !strings.HasPrefix(line, "data: ") { continue }
payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" { break }
var chunk struct{ Choices []... }
json.Unmarshal([]byte(payload), &chunk)
delta := chunk.Choices[0].Delta
if delta.ReasoningContent != "" { // 思考过程(glm-5.2)
onEvent(StreamEvent{ReasonDelta: delta.ReasoningContent})
}
if delta.Content != "" { // 正文
onEvent(StreamEvent{TextDelta: delta.Content})
}
// 工具调用按 index 累积参数分片...
}回调 vs channel,channel(agents-go 的做法)适合"多消费者异步处理",回调适合"单消费者即时打印"。对终端 REPL,回调更直接:每收到一个 delta,onEvent 立即 fmt.Print,打字效果就有了。
glm-5.2 的思考过程流式,reasoning_content 会先于 content 到达。mini-claude-code 把它存进 reasonBuf,思考结束后显示一行摘要(折叠模式)或全文(/think 展开)。这是处理"思考模型"流式的实战方案。
小结
读完这一章,你应该能回答:
- 阻塞和流式差在哪?,阻塞等完整结果,流式实时推事件
- 生产者-消费者模型怎么实现?,后台 goroutine 跑循环(生产者),channel 传事件,调用方迭代(消费者)
- channel 缓冲为什么是 64?,吸收一次 turn 的事件爆发,又不过度占内存
- emit 为什么用 select?,同时监听发送和取消,避免死锁
- 三种 StreamEvent 是什么?,原始事件(打字效果)、RunItem(UI 更新)、Agent 切换
- 为什么不统一成流式?,guardrail 并发模型、HITL resume、事件推送三处真实差异
流式是 Agent 从"后端工具"变成"用户伙伴"的关键。 没有流式,用户盯着 spinner 干等;有了流式,用户实时看到 Agent 在想什么、做什么。这是 ChatGPT 体验的核心。
下一篇
第 7 章,Part I 的小结,你现在已经有一个能跑的 Agent 内核了。我们回顾 Part I 学到的五个能力,并预告 Part II 要补什么,Agent 还没有记忆、不能暂停、没有护栏。这些都是接下来的内容。
本文是《从 0 写 Agent》系列第 6 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

