ByteNoteByteNote
从阻塞到事件流:流式输出怎么做
字

字节笔记本

2026年10月24日 · 约 24 分钟读完

从阻塞到事件流:流式输出怎么做

API中转
¥120

Claude Code 文档:Agent 循环的工作方式

本文是《从 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 循环里发生的一切,实时推给消费者。

text
用户提问 → [模型开始想...] → [决定调工具] → [工具结果回来了]
         → [模型继续想...] → [第一个字] → [第二个字] → ... → [最终答案]

对用户体验,差距巨大。 阻塞模式下,用户盯着 spinner 干等 30 秒;流式模式下,用户实时看到 Agent 在干什么,知道它没死。

但流式的实现复杂得多,要处理"部分输出"、要处理"中途失败"、要处理"取消"。agents-go 怎么做的?


Agent SDK:把 Claude Code 当库用

原理:生产者-消费者模型

流式的核心是一个经典的并发模式,生产者-消费者。

text
生产者(Agent 循环)              消费者(前端/调用方)
    │                                  │
    │  产出事件                         │
    ├──────────► channel ◄─────────────┤
    │           (缓冲队列)              │  消费事件
    │                                  │
  • 生产者:Agent 循环在后台 goroutine 里跑,每发生一件事(模型吐字、工具调用、handoff),往 channel 发一个事件
  • 消费者:调用方迭代 channel,逐个处理事件(比如推给 WebSocket)
  • channel:中间的缓冲队列,解耦生产和消费的速度

agents-go 的 RunStreamed 就是这个模型。


源码对照:StreamedResult 的结构

StreamedResult(agents/stream.go:45)是流式调用的句柄,消费者通过它拿事件:

go
// 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:

go
// 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 后调用,拿最终结果
}

使用模式,先迭代事件,迭代完拿最终结果:

go
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 启动

go
// 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

go
ch: make(chan streamMsg, 64)

为什么带缓冲? 如果是无缓冲 channel,生产者每次发事件都要等消费者收,生产快、消费慢时,生产者被阻塞,Agent 循环卡住。缓冲 64 让生产者能"先发一批",不被慢消费者拖累。

64 是经验值,足够吸收一次 turn 的事件爆发(一次模型调用可能产生几十个 token delta),又不至于占太多内存。


源码对照:emit 的取消感知

生产者往 channel 发事件,用 emit(stream.go:111):

go
// 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(打字效果)
RunItemStreamEventAgent 产出一个新 item(消息/工具调用/输出/handoff)UI 更新("调用了工具")
AgentUpdatedStreamEvent控制权切换到新 AgentUI 显示"现在 X 接手了"

其中 RunItemStreamEvent 有个 Name 字段(stream.go:24),表示具体是什么 item:

go
// 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)把这些原始事件透传给消费者:

go
// 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 并发(和模型调用同时跑,省时间):

go
// 阻塞版 run.go:299
go func() { guardResult := runInputGuardrails(...) }()  // 后台
resp := model.GetResponse(...)  // 同时调模型

流式版,输入 guardrail 同步先跑完:

go
// 流式版 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 泄漏。

正确姿势:

go
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:

go
// 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 展开)。这是处理"思考模型"流式的实战方案。


小结

读完这一章,你应该能回答:

  1. 阻塞和流式差在哪?,阻塞等完整结果,流式实时推事件
  2. 生产者-消费者模型怎么实现?,后台 goroutine 跑循环(生产者),channel 传事件,调用方迭代(消费者)
  3. channel 缓冲为什么是 64?,吸收一次 turn 的事件爆发,又不过度占内存
  4. emit 为什么用 select?,同时监听发送和取消,避免死锁
  5. 三种 StreamEvent 是什么?,原始事件(打字效果)、RunItem(UI 更新)、Agent 切换
  6. 为什么不统一成流式?,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 移植版)。全系列目录见总览。

相关文章

分享: