犀牛鸟 2026 研究笔记 estelledc.github.io

第十四章:Event/Streaming 架构与 AG-UI 协议

本章目标:理解 Agent 系统中 Event/Streaming 架构的核心设计,掌握 AG-UI 协议如何标准化 Agent-to-UI 通信,对比 tRPC-Agent-Go、LangGraph、CrewAI 的流式实现,学会在 Go 和 TypeScript/React 中构建可观测的事件流系统。


14.1 为什么 Agent 需要 Event/Streaming?

14.1.1 从”点餐等上菜”到”明厨亮灶”

先想一个日常场景:你去餐厅点了一份宫保鸡丁。传统模式是——你点完,服务员消失,厨房里发生什么你完全不知道。五分钟后菜端上来,或者十五分钟后服务员跑来说”鸡肉没了,换一个吧”。这段等待期你很焦虑:到底在做了吗?是不是忘了?出问题了吗?

现在很多餐厅搞”明厨亮灶”——你能透过玻璃看到厨师在切菜、颠勺、装盘。你知道它在进行中,大概到哪一步了,心里有底。

Agent 系统面临完全相同的问题。一个复杂任务(比如”帮我分析这份 50 页的 PDF 并生成报告”)可能需要 30 秒甚至几分钟。如果前端只是显示一个转圈的 loading,用户会想:它卡死了吗?在做什么?进度如何?

Event/Streaming 就是 Agent 系统的”明厨亮灶”。它让后端在处理过程中持续地、增量地把状态和中间结果推送给前端,而不是等一切做完再一次性返回。

14.1.2 Request-Response 模式的根本性缺陷

传统的 HTTP Request-Response 模式为 Agent 带来三个致命问题:

第一是延迟感知问题。LLM 生成一段 500 字的文本可能需要 5-10 秒。如果等全部生成完再返回,用户感知到的是 5 秒的”死寂”加一瞬间的文本涌入。而流式返回让用户在第 200ms 就看到第一个字,体验截然不同。

第二是超时问题。复杂 Agent 任务涉及多轮工具调用、多个子 Agent 协作,总耗时可能超过 HTTP 网关的超时限制(通常 30-60 秒)。流式连接(SSE/WebSocket)天然支持长时间通信。

第三是可观测性问题。Agent 内部在想什么?调用了什么工具?中间推理过程是什么?Request-Response 只给你最终答案,丢失了所有过程信息。而这些过程信息对 debug、优化、用户信任都极其重要。

14.1.3 Event-Driven 的核心思维转换

从 Request-Response 到 Event-Driven,核心思维转换是:

Request-Response 思维:
  客户端 → 请求 → 服务端处理(黑盒) → 响应 → 客户端

Event-Driven 思维:
  客户端 → 订阅 → 服务端持续发射事件流 → 客户端逐个处理

这个转换带来三个根本性变化:

(1)通信方向从”拉”变”推”。客户端不再反复轮询”好了没?”,而是服务端主动推送”我到这一步了”。

(2)数据单位从”完整响应”变”事件片段”。一个完整的 Agent 回答被拆成一系列细粒度事件:开始思考、第一个token、工具调用开始、工具返回结果、继续生成、结束。

(3)系统耦合从”同步阻塞”变”异步解耦”。前端不需要等后端全部做完才能行动,可以边收事件边更新 UI。


14.2 Agent 事件流的分层架构

14.2.1 三层事件模型

一个成熟的 Agent 事件流系统通常分三层:

┌─────────────────────────────────────────────┐
│  呈现层 (Presentation Events)               │
│  "显示给用户看的"                            │
│  文本增量、UI 状态、进度条                    │
├─────────────────────────────────────────────┤
│  语义层 (Semantic Events)                   │
│  "Agent 在做什么"                            │
│  工具调用、推理步骤、决策点                    │
├─────────────────────────────────────────────┤
│  传输层 (Transport Events)                  │
│  "字节怎么传的"                              │
│  SSE/WebSocket/gRPC 帧                      │
└─────────────────────────────────────────────┘

呈现层事件是给用户消费的——”第 3 个 token 来了,是’你’字”。语义层事件是给开发者和可观测系统消费的——”Agent 决定调用搜索工具”。传输层事件是网络基础设施关心的——”这是第 47 个 SSE data 帧”。

好的架构让这三层解耦:传输层可以从 SSE 换成 WebSocket 而不影响上层;语义层可以增加新事件类型而不需要改前端渲染逻辑。

14.2.2 事件的基本结构

不管哪个框架,Agent 事件都有类似的基本结构:

// 通用 Agent 事件结构
interface AgentEvent {
  // 标识
  id: string;           // 事件唯一 ID(用于去重和确认)
  type: string;         // 事件类型(决定怎么处理)
  timestamp: number;    // 发生时间(用于排序和延迟计算)

  // 上下文
  runId: string;        // 哪次运行产生的
  nodeId?: string;      // 哪个 Agent/Node 产生的
  parentId?: string;    // 父事件(用于构建事件树)

  // 负载
  payload: unknown;     // 类型特定的数据
}

这个结构看似简单,但每个字段都有明确用途。id 用于幂等性——网络断了重连,靠 id 去重。runId 用于关联——同一次用户请求产生的所有事件共享 runId。parentId 用于构建因果链——”这个工具结果事件是由那个工具调用事件触发的”。

14.2.3 典型事件类型清单

一个功能完整的 Agent 系统通常需要以下事件类型:

生命周期事件:run.started(开始处理)、run.completed(处理完成)、run.failed(处理失败)、run.cancelled(被取消)。

思考事件:thought.started(开始推理)、thought.delta(推理文本增量)、thought.completed(推理结束)。

消息事件:message.started(开始回答)、message.delta(文本增量)、message.completed(回答结束)。

工具事件:tool.call.started(发起工具调用)、tool.call.args_delta(参数流式产生)、tool.call.completed(调用完成)、tool.result(工具返回结果)。

状态事件:state.snapshot(完整状态快照)、state.delta(状态增量更新)。

元数据事件:metadata.usage(token 用量)、metadata.latency(延迟统计)。


14.3 传输协议对比:SSE vs WebSocket vs gRPC Streaming

14.3.1 三种传输方式的日常类比

想象你在看一场足球比赛的实况:

SSE(Server-Sent Events)就像收音机解说——单向的,解说员一直在说,你只能听。你不能通过收音机让解说员重复或者换个话题。但优点是:你只需要一个收音机(浏览器原生支持),信号还能自动重连。

WebSocket 就像打电话看比赛——双向的,你可以随时说”刚才那个进球再描述一遍”。但你得先拨号建立连接(握手开销),而且如果信号断了,你得自己重拨。

gRPC Streaming 就像专业转播车的通信系统——高效、结构化、带类型校验。但你需要专业设备(特殊客户端库),不是随便一个收音机就能收。

14.3.2 技术细节对比

┌────────────┬──────────────────────────────────────────────────┐
│ 维度       │ SSE           │ WebSocket      │ gRPC Stream    │
├────────────┼───────────────┼────────────────┼────────────────┤
│ 方向       │ 服务端→客户端  │ 双向           │ 双向/单向均可  │
│ 协议       │ HTTP/1.1      │ ws:// 自有协议  │ HTTP/2         │
│ 浏览器支持  │ 原生          │ 原生           │ 需要 grpc-web  │
│ 自动重连   │ 内置          │ 需自己实现      │ 需自己实现     │
│ 消息格式   │ 纯文本/UTF-8  │ 文本或二进制    │ Protobuf二进制 │
│ 代理穿透   │ 好(标准HTTP) │ 中(需升级)   │ 差(需特殊配置)│
│ 序列化开销  │ 高(JSON文本) │ 中             │ 低(二进制)   │
│ 适用场景   │ 通知/推送/流式 │ 聊天/协作/游戏 │ 微服务间通信   │
└────────────┴───────────────┴────────────────┴────────────────┘

14.3.3 为什么 Agent 系统多数选择 SSE?

三个核心原因:

(1)Agent 通信天然是单向主导的。用户发一条消息后,绝大多数时间是 Agent 在向客户端推送。用户很少在 Agent 回答过程中需要发送新信息(取消操作除外,而取消可以用独立的 HTTP 请求实现)。

(2)SSE 对基础设施最友好。它就是普通的 HTTP 响应(只不过 Content-Type 是 text/event-stream,连接不关闭)。CDN、反向代理、负载均衡器都能正常工作。WebSocket 需要升级协议,很多企业网关不支持或需要特殊配置。

(3)SSE 自带重连和 Last-Event-ID。浏览器的 EventSource API 在连接断开时会自动重连,并带上 Last-Event-ID 请求头,让服务端可以从断点续传。这对 Agent 这种长时间推送场景非常重要。

但 SSE 也有明确的短板:不支持二进制数据(需要 Base64 编码)、在 HTTP/1.1 下浏览器有 6 连接限制(HTTP/2 解决了这个问题)、不支持自定义请求头(认证通常走 URL 参数或 cookie)。

14.3.4 SSE 的协议格式

SSE 的线上格式极其简单:

data: {"type":"message.delta","payload":{"content":"你"}}\n\n
data: {"type":"message.delta","payload":{"content":"好"}}\n\n
data: {"type":"message.delta","payload":{"content":"!"}}\n\n
data: {"type":"message.completed","payload":{}}\n\n

每个事件以 data: 开头,以两个换行符 \n\n 结尾。可选字段包括:

id: evt_001\n
event: agent.message\n
data: {"content":"你好"}\n
retry: 3000\n
\n

id 用于断点续传,event 用于客户端分发到不同处理器,retry 告诉客户端重连间隔。


14.4 tRPC-Agent-Go 的事件通道架构

14.4.1 设计哲学:Go Channel 就是事件管道

tRPC-Agent-Go 的流式架构有一个核心设计洞察:Go 的 channel 天然就是一个事件管道。channel 的语义——有序、阻塞、可关闭——与事件流的需求完美匹配。

日常类比:想象一个寿司传送带(回转寿司)。厨师(Agent 逻辑)把做好的寿司(事件)一个个放到传送带(channel)上,传送带把寿司送到顾客(前端)面前。传送带保证顺序(先做的先到)、容量有限(channel buffer)、厨师可以决定”今天的寿司做完了”然后关闭传送带(close channel)。

14.4.2 核心数据结构

tRPC-Agent-Go 定义了清晰的事件类型层次:

package streaming

import "time"

// Event 是事件流中的基本单位
type Event struct {
    ID        string      `json:"id"`
    Type      EventType   `json:"type"`
    Timestamp time.Time   `json:"timestamp"`
    RunID     string      `json:"run_id"`
    NodeID    string      `json:"node_id,omitempty"`
    Payload   interface{} `json:"payload"`
}

// EventType 枚举所有事件类型
type EventType string

const (
    EventRunStarted       EventType = "run.started"
    EventRunCompleted     EventType = "run.completed"
    EventRunFailed        EventType = "run.failed"
    EventMessageDelta     EventType = "message.delta"
    EventMessageCompleted EventType = "message.completed"
    EventToolCallStarted  EventType = "tool_call.started"
    EventToolCallCompleted EventType = "tool_call.completed"
    EventToolResult       EventType = "tool.result"
    EventStateSnapshot    EventType = "state.snapshot"
    EventStateDelta       EventType = "state.delta"
    EventThoughtDelta     EventType = "thought.delta"
)

// EventChannel 是事件的传输管道
type EventChannel struct {
    ch     chan Event
    done   chan struct{}
    closed bool
}

// NewEventChannel 创建带缓冲的事件通道
func NewEventChannel(bufferSize int) *EventChannel {
    return &EventChannel{
        ch:   make(chan Event, bufferSize),
        done: make(chan struct{}),
    }
}

// Emit 发送一个事件到通道
func (ec *EventChannel) Emit(evt Event) error {
    select {
    case ec.ch <- evt:
        return nil
    case <-ec.done:
        return ErrChannelClosed
    }
}

// Subscribe 返回只读事件流
func (ec *EventChannel) Subscribe() <-chan Event {
    return ec.ch
}

// Close 关闭事件通道,通知所有订阅者
func (ec *EventChannel) Close() {
    if !ec.closed {
        ec.closed = true
        close(ec.done)
        close(ec.ch)
    }
}

14.4.3 从 Agent 执行到事件发射

Agent 的执行循环中,每个关键步骤都会发射对应事件:

package agent

import (
    "context"
    "fmt"
    "time"

    "github.com/google/uuid"
)

// Run 执行 Agent 并通过 EventChannel 流式输出
func (a *Agent) Run(ctx context.Context, input string, ec *EventChannel) error {
    runID := uuid.New().String()

    // 1. 发射运行开始事件
    ec.Emit(Event{
        ID:        uuid.New().String(),
        Type:      EventRunStarted,
        Timestamp: time.Now(),
        RunID:     runID,
        Payload:   map[string]string{"input": input},
    })

    // 2. 调用 LLM(流式)
    llmStream, err := a.llm.StreamChat(ctx, a.buildMessages(input))
    if err != nil {
        ec.Emit(Event{
            ID:      uuid.New().String(),
            Type:    EventRunFailed,
            RunID:   runID,
            Payload: map[string]string{"error": err.Error()},
        })
        return err
    }

    // 3. 逐 token 转发 LLM 输出为事件
    var fullContent string
    for chunk := range llmStream {
        if chunk.Content != "" {
            fullContent += chunk.Content
            ec.Emit(Event{
                ID:      uuid.New().String(),
                Type:    EventMessageDelta,
                RunID:   runID,
                Payload: map[string]string{"delta": chunk.Content},
            })
        }

        // 4. 检测工具调用
        if chunk.ToolCall != nil {
            ec.Emit(Event{
                ID:      uuid.New().String(),
                Type:    EventToolCallStarted,
                RunID:   runID,
                Payload: chunk.ToolCall,
            })

            // 执行工具
            result, toolErr := a.executeTool(ctx, chunk.ToolCall)

            ec.Emit(Event{
                ID:      uuid.New().String(),
                Type:    EventToolResult,
                RunID:   runID,
                Payload: map[string]interface{}{
                    "tool":   chunk.ToolCall.Name,
                    "result": result,
                    "error":  toolErr,
                },
            })
        }
    }

    // 5. 发射完成事件
    ec.Emit(Event{
        ID:      uuid.New().String(),
        Type:    EventMessageCompleted,
        RunID:   runID,
        Payload: map[string]string{"content": fullContent},
    })

    ec.Emit(Event{
        ID:      uuid.New().String(),
        Type:    EventRunCompleted,
        RunID:   runID,
        Payload: map[string]interface{}{"tokens_used": llmStream.Usage()},
    })

    return nil
}

14.4.4 HTTP Handler:Channel 到 SSE 的桥接

把 Go channel 转换为 SSE 输出的关键代码:

package handler

import (
    "encoding/json"
    "fmt"
    "net/http"
)

// StreamHandler 将 EventChannel 桥接到 HTTP SSE
func StreamHandler(w http.ResponseWriter, r *http.Request) {
    // 设置 SSE 响应头
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming not supported", http.StatusInternalServerError)
        return
    }

    // 创建事件通道
    ec := NewEventChannel(64)
    defer ec.Close()

    // 后台启动 Agent 执行
    go func() {
        input := r.URL.Query().Get("input")
        agent := GetAgent(r.Context())
        if err := agent.Run(r.Context(), input, ec); err != nil {
            // Agent 执行失败,错误已通过事件通道发送
        }
        ec.Close() // Agent 完成,关闭通道
    }()

    // 主循环:从 channel 读事件,写 SSE
    for evt := range ec.Subscribe() {
        data, _ := json.Marshal(evt)
        fmt.Fprintf(w, "id: %s\n", evt.ID)
        fmt.Fprintf(w, "event: %s\n", evt.Type)
        fmt.Fprintf(w, "data: %s\n\n", data)
        flusher.Flush() // 立即推送,不等缓冲区满
    }
}

这里有个关键细节:flusher.Flush()。HTTP 服务器默认会缓冲响应数据以提高吞吐量。但对 SSE 来说,我们需要每个事件立即发送,所以每写一个事件就 Flush 一次。另外 X-Accel-Buffering: no 是告诉 Nginx 不要代理层缓冲。

14.4.5 多 Agent 场景的事件合并

当多个子 Agent 并行工作时,需要把多个 channel 的事件合并到一个输出流中:

package streaming

import "sync"

// FanIn 将多个 EventChannel 合并为一个
// 类似"多车道合并为一车道"
func FanIn(channels ...*EventChannel) *EventChannel {
    merged := NewEventChannel(128)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Add(1)
        go func(c *EventChannel) {
            defer wg.Done()
            for evt := range c.Subscribe() {
                merged.Emit(evt)
            }
        }(ch)
    }

    // 所有子 channel 关闭后,关闭合并 channel
    go func() {
        wg.Wait()
        merged.Close()
    }()

    return merged
}

这个模式在团队协作(Team)Agent 中非常常见:多个专家 Agent 并行处理不同子任务,它们各自发射事件到自己的 channel,最后通过 FanIn 合并成一个流发送给前端。


14.5 LangGraph 的 Streaming 模式

14.5.1 LangGraph 的四种流式模式

LangGraph 提供了四种递进的流式模式,每种适用不同场景:

values 模式:每个节点执行完后,发送完整的状态快照。像拍照片——每一步拍一张全景照,你能看到每一步后的”世界全貌”。

# values 模式 - 每步发送完整状态
async for state_snapshot in graph.astream(inputs, stream_mode="values"):
    print(f"当前状态: {state_snapshot}")
    # 输出: {'messages': [...全部消息...], 'tool_results': [...]}

updates 模式:每个节点执行完后,只发送该节点对状态的修改(增量)。像 Git diff——只告诉你哪里变了,不重复发没变的部分。

# updates 模式 - 只发增量
async for node_name, state_delta in graph.astream(inputs, stream_mode="updates"):
    print(f"节点 {node_name} 修改了: {state_delta}")
    # 输出: ("agent", {'messages': [新增的那条消息]})

messages 模式:专门针对 LLM 的 token 级流式输出。只流式传输 LLM 生成的消息 token,不关心其他状态变化。这是做”打字机效果”的模式。

# messages 模式 - token 级流式
async for msg_chunk, metadata in graph.astream(inputs, stream_mode="messages"):
    if msg_chunk.content:
        print(msg_chunk.content, end="", flush=True)
    # 逐字输出: 你 好 , 我 来 帮 你 ...

events 模式:最细粒度,暴露 LangGraph 内部的所有事件(包括 LangChain 回调事件)。像打开了引擎盖看每个零件在转。适合 debug 和可观测性。

# events 模式 - 全部内部事件
async for event in graph.astream_events(inputs, version="v2"):
    print(f"[{event['event']}] {event['name']}: {event['data']}")
    # 输出:
    # [on_chain_start] RunnableSequence: {...}
    # [on_chat_model_stream] ChatOpenAI: {'chunk': ...}
    # [on_tool_start] search: {'query': '...'}
    # [on_tool_end] search: {'output': '...'}

14.5.2 流式模式选择的决策树

需要 token 级打字机效果?
├── 是 → messages 模式
└── 否 → 需要看到中间步骤?
         ├── 是 → 需要完整状态还是只看变化?
         │       ├── 完整状态 → values 模式
         │       └── 只看变化 → updates 模式
         └── 否(只要最终结果)→ 不需要流式,用 invoke

实际项目中经常组合使用:用 messages 模式驱动前端打字机效果,同时用 events 模式做后端日志和 tracing。

14.5.3 LangGraph 事件的内部实现

LangGraph 的流式实现基于 Python 的 AsyncGenerator

from typing import AsyncGenerator, Any
from langchain_core.runnables import RunnableConfig

class Pregel:
    """LangGraph 的核心执行引擎"""

    async def astream(
        self,
        inputs: dict,
        config: RunnableConfig = None,
        stream_mode: str = "values",
    ) -> AsyncGenerator[Any, None]:
        """流式执行图,返回异步生成器"""

        # 初始化状态
        state = self._initialize_state(inputs)

        # 获取执行计划(拓扑排序后的节点序列)
        plan = self._get_execution_plan(state)

        for step in plan:
            node = self.nodes[step]

            # 执行节点
            result = await node.ainvoke(state, config)

            # 更新状态
            state = self._apply_update(state, step, result)

            # 根据 stream_mode 决定输出什么
            if stream_mode == "values":
                yield state.copy()  # 完整快照
            elif stream_mode == "updates":
                yield (step, result)  # 增量

关键设计:astream 返回的是 AsyncGenerator。Python 的 async for 语法让消费者可以逐个处理事件,天然具备背压(backpressure)能力——如果消费者处理慢了,生产者会自动暂停。

14.5.4 与 tRPC-Agent-Go 的对比

两者的核心差异在于语言模型带来的不同设计选择:

Go 用 channel:显式的、有类型的、有容量的管道。需要手动管理 goroutine 生命周期和 channel 关闭。优势是并发模型清晰、性能高。

Python 用 AsyncGenerator:隐式的、基于 yield 的协程。消费者驱动(拉模型)而非生产者驱动(推模型)。优势是代码简洁、天然支持背压。

Go Channel (推模型):
  生产者 → [buffer] → 消费者
  生产者主动 push,buffer 满了会阻塞生产者

Python AsyncGenerator (拉模型):
  生产者 ← 消费者请求下一个
  消费者主动 pull,生产者在 yield 处暂停等待

14.6 CrewAI 的输出模式

14.6.1 CrewAI 的简化流式模型

CrewAI 的设计哲学是”开箱即用”,所以它的流式模型比 LangGraph 简单得多。CrewAI 提供两种输出控制:

verbose 模式:打印 Agent 的思考过程到控制台。严格来说这不是”流式”——它只是把中间步骤 print 出来,不是通过事件通道传输的。

from crewai import Crew, Agent, Task

# verbose=True 开启过程输出
crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    verbose=True,  # 打印中间步骤到 stdout
)

result = crew.kickoff()
# [Agent: Researcher] 正在思考...
# [Agent: Researcher] 使用工具: web_search("AI agent frameworks 2024")
# [Agent: Researcher] 搜索结果: ...
# [Tool Result] ...
# [Agent: Writer] 开始写作...

output_log_file 模式:把所有中间步骤写入文件,而不是 stdout。

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    output_log_file="crew_execution.log",
)

14.6.2 CrewAI 的 step_callback

CrewAI 提供了 step_callback 机制,这是它最接近”事件流”的特性:

from crewai import Crew

def on_step(step_output):
    """每个 Agent 步骤完成后回调"""
    print(f"Agent: {step_output.agent}")
    print(f"Action: {step_output.action}")
    print(f"Output: {step_output.output}")
    # 这里可以把 step 转发到 SSE/WebSocket

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    step_callback=on_step,
)

step_callback 的粒度是”一个完整步骤”——一次 LLM 调用 + 一次工具调用算一个步骤。它不提供 token 级流式,不提供工具调用参数的流式。与 LangGraph 的 events 模式相比,粒度粗很多。

14.6.3 CrewAI 的局限性与变通

CrewAI 原生不支持真正的 SSE 流式输出。如果你的产品需要”打字机效果”,有两种变通方案:

方案一:Hook 底层 LLM 的流式回调。CrewAI 使用 LangChain 的 LLM 接口,可以通过自定义 CallbackHandler 拦截 token 流。

from langchain_core.callbacks import BaseCallbackHandler

class StreamingCallbackHandler(BaseCallbackHandler):
    def __init__(self, sse_connection):
        self.sse = sse_connection

    def on_llm_new_token(self, token: str, **kwargs):
        """每个新 token 生成时触发"""
        self.sse.send_event("message.delta", {"content": token})

    def on_tool_start(self, tool_name: str, tool_input: str, **kwargs):
        self.sse.send_event("tool.started", {
            "name": tool_name,
            "input": tool_input,
        })

    def on_tool_end(self, output: str, **kwargs):
        self.sse.send_event("tool.completed", {"output": output})

方案二:不做 token 级流式,只做步骤级流式——通过 step_callback 把每个完成的步骤推送给前端。很多企业应用其实不需要”逐字显示”,显示”正在搜索…“→”搜索完成”→”正在写报告…“这样的步骤进度就够了。

14.6.4 三框架流式能力对比

┌────────────────┬─────────────────┬─────────────────┬────────────────┐
│ 能力           │ tRPC-Agent-Go   │ LangGraph       │ CrewAI         │
├────────────────┼─────────────────┼─────────────────┼────────────────┤
│ Token 级流式   │ 支持(channel)   │ 支持(messages)  │ 需要 hook LLM │
│ 步骤级流式     │ 支持            │ 支持(updates)   │ 支持(callback) │
│ 状态流式       │ 支持(自定义)    │ 支持(values)    │ 不支持         │
│ 事件类型系统   │ 自定义枚举      │ LangChain 标准  │ 无正式定义     │
│ 多 Agent 合并  │ FanIn 模式      │ 图结构天然支持  │ 顺序执行为主   │
│ 背压控制       │ Channel buffer  │ AsyncGenerator  │ 无             │
│ 传输协议       │ SSE/gRPC       │ SSE (LangServe) │ 无内置传输     │
└────────────────┴─────────────────┴─────────────────┴────────────────┘

14.7 AG-UI 协议:标准化 Agent-to-UI 通信

14.7.1 为什么需要 AG-UI 协议?

想象一个场景:你的团队在开发一个 AI 产品。后端用 LangGraph 写 Agent,前端要显示流式回答。第一版做完了,效果不错。

三个月后,技术选型变了——后端要换成 tRPC-Agent-Go(因为性能需求)。前端工程师崩溃了:两个框架的事件格式完全不同,事件类型不同,字段名不同,流式粒度不同。前端代码要大改。

再过半年,产品要加一个”代码执行”功能,接入了另一个 Agent 服务。又一套新的事件格式。前端再改一遍。

AG-UI 协议就是要解决这个问题:定义一个标准的、框架无关的 Agent-to-UI 事件协议。不管后端用什么 Agent 框架,只要输出符合 AG-UI 规范的事件流,前端就能通用处理。

这就像 HTTP 之于 Web——不管你用 Java、Go、Python 写后端,HTTP 响应的格式是一样的,浏览器不需要关心后端语言。

14.7.2 AG-UI 的核心事件类型

AG-UI 协议定义了以下标准事件类型(基于 CopilotKit 的 AG-UI 实现):

// AG-UI 协议事件类型定义
enum EventType {
  // 生命周期
  RUN_STARTED = "RUN_STARTED",
  RUN_FINISHED = "RUN_FINISHED",
  RUN_ERROR = "RUN_ERROR",

  // 文本消息
  TEXT_MESSAGE_START = "TEXT_MESSAGE_START",
  TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT",
  TEXT_MESSAGE_END = "TEXT_MESSAGE_END",

  // 工具调用
  TOOL_CALL_START = "TOOL_CALL_START",
  TOOL_CALL_ARGS = "TOOL_CALL_ARGS",
  TOOL_CALL_END = "TOOL_CALL_END",

  // 状态管理(AG-UI 的独特贡献)
  STATE_SNAPSHOT = "STATE_SNAPSHOT",
  STATE_DELTA = "STATE_DELTA",

  // 元信息
  STEP_STARTED = "STEP_STARTED",
  STEP_FINISHED = "STEP_FINISHED",
  RAW = "RAW",  // 透传框架特有事件
}

14.7.3 AG-UI 事件的标准格式

每个 AG-UI 事件都有统一的信封格式:

interface BaseEvent {
  type: EventType;
  timestamp: number;       // Unix 毫秒时间戳
  rawEvent?: unknown;      // 原始框架事件(可选,用于 debug)
}

// 文本消息开始
interface TextMessageStartEvent extends BaseEvent {
  type: EventType.TEXT_MESSAGE_START;
  messageId: string;       // 消息 ID
  role: "assistant";       // 角色
}

// 文本内容增量
interface TextMessageContentEvent extends BaseEvent {
  type: EventType.TEXT_MESSAGE_CONTENT;
  messageId: string;
  delta: string;           // 增量文本
}

// 工具调用开始
interface ToolCallStartEvent extends BaseEvent {
  type: EventType.TOOL_CALL_START;
  toolCallId: string;
  toolCallName: string;    // 工具名
}

// 工具调用参数(流式)
interface ToolCallArgsEvent extends BaseEvent {
  type: EventType.TOOL_CALL_ARGS;
  toolCallId: string;
  delta: string;           // JSON 参数片段
}

// 状态快照
interface StateSnapshotEvent extends BaseEvent {
  type: EventType.STATE_SNAPSHOT;
  snapshot: Record<string, unknown>;  // 完整状态
}

// 状态增量(JSON Patch 格式)
interface StateDeltaEvent extends BaseEvent {
  type: EventType.STATE_DELTA;
  delta: JsonPatch[];      // RFC 6902 JSON Patch
}

14.7.4 AG-UI 的独特贡献:前端状态同步

AG-UI 最有价值的设计不是事件类型定义(这其实各框架都类似),而是它对 “Agent 状态 ↔ UI 状态” 同步的标准化处理。

传统做法是前端自己维护状态:收到 message.delta 就拼接文本,收到 tool.started 就显示工具名。每个前端项目都要自己写这套状态管理逻辑。

AG-UI 的做法是让 Agent 端直接告诉前端”当前状态是什么”(STATE_SNAPSHOT)或”状态怎么变了”(STATE_DELTA)。前端只需要把状态映射到 UI,不需要自己从事件流中推导状态。

// AG-UI 的状态同步模式
// Agent 发送状态:
emit({
  type: "STATE_SNAPSHOT",
  snapshot: {
    currentStep: "searching",
    progress: 0.3,
    searchResults: ["结果1", "结果2"],
    isThinking: true,
  }
});

// 后续只发增量:
emit({
  type: "STATE_DELTA",
  delta: [
    { op: "replace", path: "/progress", value: 0.6 },
    { op: "add", path: "/searchResults/-", value: "结果3" },
  ]
});

前端只需要:

const [agentState, setAgentState] = useState({});

// 收到 SNAPSHOT 就整体替换
// 收到 DELTA 就 apply JSON Patch
function handleEvent(event: BaseEvent) {
  if (event.type === "STATE_SNAPSHOT") {
    setAgentState(event.snapshot);
  } else if (event.type === "STATE_DELTA") {
    setAgentState(prev => applyPatch(prev, event.delta));
  }
}

这比让前端从一堆细粒度事件中自己拼凑状态简单得多。

14.7.5 AG-UI 适配器模式

AG-UI 通过”适配器”连接不同的 Agent 框架:

┌───────────┐    ┌──────────────┐    ┌─────────────┐
│ LangGraph │───→│ AG-UI 适配器  │───→│  AG-UI 事件  │───→ 前端
└───────────┘    │ (框架特定)    │    │  (标准格式)  │
                 └──────────────┘    └─────────────┘

┌───────────────┐    ┌──────────────┐    ┌─────────────┐
│tRPC-Agent-Go  │───→│ AG-UI 适配器  │───→│  AG-UI 事件  │───→ 同一个前端
└───────────────┘    │ (框架特定)    │    │  (标准格式)  │
                     └──────────────┘    └─────────────┘

一个 LangGraph 适配器的简化实现:

from ag_ui import EventType, emit_event

class LangGraphAGUIAdapter:
    """将 LangGraph 事件转换为 AG-UI 标准格式"""

    async def stream(self, graph, inputs):
        run_id = str(uuid.uuid4())

        emit_event(EventType.RUN_STARTED, timestamp=now())

        async for event in graph.astream_events(inputs, version="v2"):
            # 映射 LangGraph 事件到 AG-UI 事件
            if event["event"] == "on_chat_model_stream":
                chunk = event["data"]["chunk"]
                if chunk.content:
                    emit_event(
                        EventType.TEXT_MESSAGE_CONTENT,
                        messageId=run_id,
                        delta=chunk.content,
                    )
            elif event["event"] == "on_tool_start":
                emit_event(
                    EventType.TOOL_CALL_START,
                    toolCallId=event["run_id"],
                    toolCallName=event["name"],
                )
            elif event["event"] == "on_tool_end":
                emit_event(
                    EventType.TOOL_CALL_END,
                    toolCallId=event["run_id"],
                )

        emit_event(EventType.RUN_FINISHED, timestamp=now())

14.8 React 前端消费 Agent 事件流

14.8.1 基础 SSE 客户端

用浏览器原生 EventSource API 连接 Agent SSE:

// 最简单的 SSE 连接
const source = new EventSource("/api/agent/stream?input=hello");

source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log("收到事件:", data);
};

source.onerror = (error) => {
  console.error("SSE 连接错误:", error);
  source.close();
};

EventSource 有两个严重限制:只支持 GET 请求(不能发 POST body)、不能设置自定义请求头(认证困难)。所以实际项目中通常用 fetch + ReadableStream

// 基于 fetch 的 SSE 客户端(支持 POST 和自定义头)
async function* streamAgent(input: string): AsyncGenerator<AgentEvent> {
  const response = await fetch("/api/agent/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${getToken()}`,
    },
    body: JSON.stringify({ input }),
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    // 按 SSE 协议解析:双换行符分隔事件
    const events = buffer.split("\n\n");
    buffer = events.pop()!; // 最后一段可能不完整,留到下次

    for (const eventStr of events) {
      if (!eventStr.trim()) continue;

      // 解析 SSE 格式
      const lines = eventStr.split("\n");
      let data = "";
      let eventType = "";

      for (const line of lines) {
        if (line.startsWith("data: ")) {
          data += line.slice(6);
        } else if (line.startsWith("event: ")) {
          eventType = line.slice(7);
        }
      }

      if (data) {
        yield JSON.parse(data) as AgentEvent;
      }
    }
  }
}

14.8.2 React Hook 封装

将事件流封装为 React Hook,方便组件使用:

import { useState, useCallback, useRef } from "react";

interface UseAgentStreamOptions {
  onEvent?: (event: AgentEvent) => void;
  onError?: (error: Error) => void;
}

interface AgentStreamState {
  isStreaming: boolean;
  messages: Message[];
  currentText: string;
  toolCalls: ToolCall[];
  error: Error | null;
}

function useAgentStream(options: UseAgentStreamOptions = {}) {
  const [state, setState] = useState<AgentStreamState>({
    isStreaming: false,
    messages: [],
    currentText: "",
    toolCalls: [],
    error: null,
  });
  const abortRef = useRef<AbortController | null>(null);

  const send = useCallback(async (input: string) => {
    // 取消上一次流
    abortRef.current?.abort();
    abortRef.current = new AbortController();

    setState(prev => ({
      ...prev,
      isStreaming: true,
      currentText: "",
      error: null,
    }));

    try {
      for await (const event of streamAgent(input)) {
        // 检查是否被取消
        if (abortRef.current?.signal.aborted) break;

        options.onEvent?.(event);

        setState(prev => {
          switch (event.type) {
            case "TEXT_MESSAGE_CONTENT":
              return {
                ...prev,
                currentText: prev.currentText + event.delta,
              };

            case "TEXT_MESSAGE_END":
              return {
                ...prev,
                messages: [...prev.messages, {
                  role: "assistant",
                  content: prev.currentText,
                }],
                currentText: "",
              };

            case "TOOL_CALL_START":
              return {
                ...prev,
                toolCalls: [...prev.toolCalls, {
                  id: event.toolCallId,
                  name: event.toolCallName,
                  status: "running",
                }],
              };

            case "TOOL_CALL_END":
              return {
                ...prev,
                toolCalls: prev.toolCalls.map(tc =>
                  tc.id === event.toolCallId
                    ? { ...tc, status: "completed" }
                    : tc
                ),
              };

            case "STATE_SNAPSHOT":
              return { ...prev, ...event.snapshot };

            default:
              return prev;
          }
        });
      }
    } catch (error) {
      if (error instanceof Error && error.name !== "AbortError") {
        setState(prev => ({ ...prev, error }));
        options.onError?.(error);
      }
    } finally {
      setState(prev => ({ ...prev, isStreaming: false }));
    }
  }, [options]);

  const cancel = useCallback(() => {
    abortRef.current?.abort();
    setState(prev => ({ ...prev, isStreaming: false }));
  }, []);

  return { ...state, send, cancel };
}

14.8.3 完整的聊天组件

使用上面的 Hook 构建实际的聊天 UI:

import React, { useState } from "react";

function AgentChat() {
  const [input, setInput] = useState("");
  const {
    messages,
    currentText,
    toolCalls,
    isStreaming,
    error,
    send,
    cancel,
  } = useAgentStream();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;
    send(input);
    setInput("");
  };

  return (
    <div className="chat-container">
      {/* 消息列表 */}
      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i} className={`message ${msg.role}`}>
            {msg.content}
          </div>
        ))}

        {/* 正在生成的消息(打字机效果) */}
        {currentText && (
          <div className="message assistant streaming">
            {currentText}
            <span className="cursor">|</span>
          </div>
        )}

        {/* 工具调用状态 */}
        {toolCalls.filter(tc => tc.status === "running").map(tc => (
          <div key={tc.id} className="tool-call">
            正在调用工具: {tc.name}...
          </div>
        ))}
      </div>

      {/* 输入区域 */}
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="输入消息..."
          disabled={isStreaming}
        />
        {isStreaming ? (
          <button type="button" onClick={cancel}>停止</button>
        ) : (
          <button type="submit">发送</button>
        )}
      </form>

      {error && <div className="error">{error.message}</div>}
    </div>
  );
}

14.8.4 高级模式:乐观更新与事件回放

在复杂应用中,前端通常需要两个高级模式:

乐观更新:用户发送消息时,不等服务端确认就立即显示在 UI 上,收到 RUN_STARTED 再确认。如果收到 RUN_ERROR 就回滚。

// 乐观更新
const send = async (input: string) => {
  // 立即显示用户消息(乐观)
  const optimisticMsg = { role: "user", content: input, pending: true };
  setState(prev => ({
    ...prev,
    messages: [...prev.messages, optimisticMsg],
  }));

  try {
    for await (const event of streamAgent(input)) {
      if (event.type === "RUN_STARTED") {
        // 服务端确认,移除 pending 标记
        setState(prev => ({
          ...prev,
          messages: prev.messages.map(m =>
            m === optimisticMsg ? { ...m, pending: false } : m
          ),
        }));
      }
      // ... 其他事件处理
    }
  } catch {
    // 回滚乐观更新
    setState(prev => ({
      ...prev,
      messages: prev.messages.filter(m => m !== optimisticMsg),
    }));
  }
};

事件回放:把所有收到的事件存储到数组中,需要时可以”回放”整个过程。这对 debug、单元测试、演示录屏都很有用。

// 事件回放
const eventLog = useRef<AgentEvent[]>([]);

// 收到事件时存储
options.onEvent = (event) => {
  eventLog.current.push(event);
};

// 回放函数
async function replay(events: AgentEvent[], speed = 1) {
  for (let i = 0; i < events.length; i++) {
    const event = events[i];
    const nextEvent = events[i + 1];
    const delay = nextEvent
      ? (nextEvent.timestamp - event.timestamp) / speed
      : 0;

    handleEvent(event); // 应用事件
    await sleep(delay); // 模拟时间间隔
  }
}

14.9 从事件流构建可观测性

14.9.1 可观测性不是”加日志”

很多初学者把可观测性等同于”多打点 log”。这是错误的。

日常类比:你去医院体检。”加日志”相当于医生随手在病历上写了几句——”看起来还行”“血压量了”。可观测性是一套完整的体检系统——血常规、肝功能、心电图、CT,每项有标准值、有对比基线、有异常报警。

Agent 的可观测性需要回答四个问题:发生了什么(事件日志)?花了多长时间(延迟追踪)?资源消耗多少(指标度量)?出了问题怎么定位(分布式追踪)?

14.9.2 利用事件流构建 Tracing

事件流天然包含构建 Trace 所需的全部信息:

package observability

import (
    "context"
    "time"

    "go.opentelemetry.io/otel/trace"
)

// EventTracer 从事件流构建 OpenTelemetry Trace
type EventTracer struct {
    tracer   trace.Tracer
    spans    map[string]trace.Span  // eventID -> span
    rootSpan trace.Span
}

func NewEventTracer(tracer trace.Tracer) *EventTracer {
    return &EventTracer{
        tracer: tracer,
        spans:  make(map[string]trace.Span),
    }
}

// ProcessEvent 处理每个事件,自动构建 Span 树
func (et *EventTracer) ProcessEvent(ctx context.Context, evt Event) {
    switch evt.Type {
    case EventRunStarted:
        // 创建根 Span
        ctx, span := et.tracer.Start(ctx, "agent.run",
            trace.WithAttributes(
                attribute.String("run_id", evt.RunID),
            ),
        )
        et.rootSpan = span
        et.spans[evt.ID] = span

    case EventToolCallStarted:
        // 在根 Span 下创建子 Span
        payload := evt.Payload.(map[string]string)
        _, span := et.tracer.Start(ctx, "tool."+payload["name"],
            trace.WithAttributes(
                attribute.String("tool.name", payload["name"]),
            ),
        )
        et.spans[evt.ID] = span

    case EventToolResult:
        // 结束工具 Span
        if span, ok := et.spans[evt.ID]; ok {
            span.End()
        }

    case EventRunCompleted:
        // 结束根 Span,附加统计信息
        payload := evt.Payload.(map[string]interface{})
        et.rootSpan.SetAttributes(
            attribute.Int("tokens.total", payload["tokens_used"].(int)),
        )
        et.rootSpan.End()

    case EventRunFailed:
        // 记录错误并结束
        payload := evt.Payload.(map[string]string)
        et.rootSpan.RecordError(fmt.Errorf(payload["error"]))
        et.rootSpan.SetStatus(codes.Error, payload["error"])
        et.rootSpan.End()
    }
}

14.9.3 从事件流提取指标

package metrics

import (
    "time"

    "go.opentelemetry.io/otel/metric"
)

// EventMetricsCollector 从事件流中提取关键指标
type EventMetricsCollector struct {
    // 计数器
    runCounter     metric.Int64Counter
    toolCallCounter metric.Int64Counter
    errorCounter   metric.Int64Counter

    // 直方图(延迟分布)
    runDuration     metric.Float64Histogram
    toolDuration    metric.Float64Histogram
    firstTokenDelay metric.Float64Histogram

    // 临时状态
    runStartTimes  map[string]time.Time
    toolStartTimes map[string]time.Time
}

func (mc *EventMetricsCollector) ProcessEvent(evt Event) {
    switch evt.Type {
    case EventRunStarted:
        mc.runCounter.Add(context.Background(), 1)
        mc.runStartTimes[evt.RunID] = evt.Timestamp

    case EventMessageDelta:
        // 计算 First Token 延迟
        if startTime, ok := mc.runStartTimes[evt.RunID]; ok {
            delay := evt.Timestamp.Sub(startTime).Seconds()
            mc.firstTokenDelay.Record(context.Background(), delay)
            delete(mc.runStartTimes, evt.RunID) // 只记第一个 token
        }

    case EventToolCallStarted:
        mc.toolCallCounter.Add(context.Background(), 1)
        mc.toolStartTimes[evt.ID] = evt.Timestamp

    case EventToolResult:
        if startTime, ok := mc.toolStartTimes[evt.ID]; ok {
            duration := evt.Timestamp.Sub(startTime).Seconds()
            mc.toolDuration.Record(context.Background(), duration)
        }

    case EventRunCompleted:
        if startTime, ok := mc.runStartTimes[evt.RunID]; ok {
            duration := evt.Timestamp.Sub(startTime).Seconds()
            mc.runDuration.Record(context.Background(), duration)
        }

    case EventRunFailed:
        mc.errorCounter.Add(context.Background(), 1)
    }
}

14.9.4 关键可观测性指标

对 Agent 系统来说,最重要的指标是:

First Token Latency(首 token 延迟):从用户发送消息到前端收到第一个字的时间。这是用户感知延迟的核心指标。目标通常 < 500ms。

Time to Complete(完成时间):整个 Agent 运行从开始到结束的总时间。包含 LLM 推理时间 + 工具调用时间。

Token Usage per Run(每次运行 token 用量):直接关联成本。输入 token + 输出 token + 工具描述 token。

Tool Call Success Rate(工具调用成功率):工具调用失败通常意味着 Agent 需要重试,浪费 token 和时间。

Streaming Gap(流式间隙):相邻两个事件之间的时间间隔。如果某处间隙特别大,说明那个步骤是瓶颈。

// 计算 Streaming Gap 分布
type GapAnalyzer struct {
    lastEventTime time.Time
    gaps          []time.Duration
}

func (ga *GapAnalyzer) ProcessEvent(evt Event) {
    if !ga.lastEventTime.IsZero() {
        gap := evt.Timestamp.Sub(ga.lastEventTime)
        ga.gaps = append(ga.gaps, gap)

        // 告警:如果某个 gap > 5s,可能有问题
        if gap > 5*time.Second {
            log.Warn("Large streaming gap detected",
                "gap", gap,
                "after_event", evt.Type,
                "run_id", evt.RunID,
            )
        }
    }
    ga.lastEventTime = evt.Timestamp
}

14.9.5 事件流与 Debug 回放

把事件流完整存储下来,就是最好的 debug 工具——你可以完整回放任何一次 Agent 执行的全过程:

package debug

import (
    "encoding/json"
    "os"
)

// EventRecorder 录制事件流,用于事后回放
type EventRecorder struct {
    file *os.File
}

func NewEventRecorder(path string) (*EventRecorder, error) {
    f, err := os.Create(path)
    if err != nil {
        return nil, err
    }
    return &EventRecorder{file: f}, nil
}

// Record 逐行写入事件(JSONL 格式)
func (er *EventRecorder) Record(evt Event) error {
    data, err := json.Marshal(evt)
    if err != nil {
        return err
    }
    _, err = er.file.Write(append(data, '\n'))
    return err
}

// Replay 从文件回放事件流
func Replay(path string, handler func(Event)) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        var evt Event
        if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
            continue
        }
        handler(evt)
    }
    return scanner.Err()
}

14.10 生产环境的工程挑战

14.10.1 背压(Backpressure)控制

当事件产生速度超过消费速度时怎么办?比如 LLM 产生 token 的速度超过了网络带宽能传输的速度。

Go channel 的方案:buffer 满了,写入方阻塞。这意味着 LLM 会被”堵住”——但这通常不是问题,因为 LLM 本身速度有限,很少会真的堵住。

// buffer 大小的选择
// 太小(如 1):频繁阻塞,降低吞吐
// 太大(如 10000):内存浪费,延迟感知不准
// 经验值:64-256 对大多数 Agent 场景够用
ec := NewEventChannel(128)

如果确实需要处理背压,可以加入丢弃策略:

// 带丢弃策略的事件发送
func (ec *EventChannel) EmitWithDrop(evt Event) bool {
    select {
    case ec.ch <- evt:
        return true // 成功发送
    default:
        // buffer 满了,丢弃这个事件(但记录日志)
        log.Warn("Event dropped due to backpressure",
            "type", evt.Type, "run_id", evt.RunID)
        return false
    }
}

但注意:丢弃事件需要非常谨慎。丢弃一个 message.delta 意味着前端显示的文本会缺字。通常只有”元数据类”事件(如 usage 统计)可以安全丢弃。

14.10.2 连接断开与恢复

SSE 连接可能因为网络波动断开。恢复策略:

// 服务端:支持从指定位置续传
func StreamHandler(w http.ResponseWriter, r *http.Request) {
    // 检查客户端是否在续传
    lastEventID := r.Header.Get("Last-Event-ID")

    // 如果有 lastEventID,从事件日志中找到断点
    if lastEventID != "" {
        // 从存储中获取该 ID 之后的所有事件
        missedEvents := eventStore.GetAfter(lastEventID)
        for _, evt := range missedEvents {
            writeSSEEvent(w, evt)
        }
        flusher.Flush()
    }

    // 继续正常流式输出后续事件
    // ...
}

客户端侧的重连逻辑:

// 带指数退避的重连
class ResilientSSEClient {
  private retryCount = 0;
  private maxRetries = 5;
  private lastEventId = "";

  async connect(url: string) {
    while (this.retryCount < this.maxRetries) {
      try {
        const response = await fetch(url, {
          headers: {
            "Last-Event-ID": this.lastEventId,
          },
        });

        this.retryCount = 0; // 连接成功,重置计数

        for await (const event of parseSSE(response)) {
          this.lastEventId = event.id;
          this.handleEvent(event);
        }
      } catch (error) {
        this.retryCount++;
        const delay = Math.min(1000 * 2 ** this.retryCount, 30000);
        await sleep(delay);
      }
    }
  }
}

14.10.3 多租户事件隔离

在 SaaS 场景中,多个用户同时使用 Agent 服务。事件流必须严格隔离:

// 多租户事件路由
type EventRouter struct {
    channels map[string]*EventChannel // tenantID -> channel
    mu       sync.RWMutex
}

func (er *EventRouter) GetChannel(tenantID string) *EventChannel {
    er.mu.RLock()
    ch, ok := er.channels[tenantID]
    er.mu.RUnlock()

    if !ok {
        er.mu.Lock()
        ch = NewEventChannel(128)
        er.channels[tenantID] = ch
        er.mu.Unlock()
    }
    return ch
}

// 确保事件只发给正确的租户
func (er *EventRouter) Route(evt Event, tenantID string) {
    ch := er.GetChannel(tenantID)
    ch.Emit(evt)
}

14.10.4 事件序列化性能

在高吞吐场景下,JSON 序列化可能成为瓶颈。优化策略:

// 策略1:预分配 buffer,避免反复 GC
var bufPool = sync.Pool{
    New: func() interface{} {
        return bytes.NewBuffer(make([]byte, 0, 512))
    },
}

func marshalEvent(evt Event) []byte {
    buf := bufPool.Get().(*bytes.Buffer)
    defer bufPool.Put(buf)
    buf.Reset()

    // 使用 json.NewEncoder 避免额外内存分配
    enc := json.NewEncoder(buf)
    enc.Encode(evt)
    return buf.Bytes()
}

// 策略2:对高频事件使用手动序列化
func marshalMessageDelta(delta string) []byte {
    // 跳过 reflect,直接拼 JSON
    return []byte(`{"type":"message.delta","payload":{"delta":"` +
        jsonEscape(delta) + `"}}`)
}

14.10.5 优雅关闭

Agent 执行被取消时(用户点了”停止”按钮),需要优雅地关闭事件流:

func (a *Agent) Run(ctx context.Context, input string, ec *EventChannel) error {
    runID := uuid.New().String()
    ec.Emit(Event{Type: EventRunStarted, RunID: runID})

    // 监听 context 取消
    go func() {
        <-ctx.Done()
        // 发送取消事件,然后关闭
        ec.Emit(Event{
            Type:    EventRunFailed,
            RunID:   runID,
            Payload: map[string]string{"error": "cancelled by user"},
        })
        ec.Close()
    }()

    // ... 正常执行逻辑
    // 所有 LLM 调用和工具调用都传入 ctx
    // 当 ctx 被 cancel 时,它们会自行中断
}

前端触发取消:

// 用户点击"停止"
function handleCancel() {
  // 方案1:通过 AbortController 断开 SSE 连接
  abortController.abort();

  // 方案2:发送独立的 cancel 请求
  fetch(`/api/agent/cancel/${runId}`, { method: "POST" });
}

14.11 设计取舍总结

14.11.1 事件粒度的权衡

粒度太细(每个 token 一个事件):网络开销大、事件存储量大、前端需要高频 setState。但用户体验好(打字机效果流畅)。

粒度太粗(每个步骤一个事件):网络开销小、存储少、前端轻松。但用户体验差(长时间无反馈)。

实际项目中的折中方案:LLM 输出用 token 级粒度(用户体验优先),工具调用用步骤级粒度(不需要逐字显示工具参数),状态更新用 snapshot + delta(减少计算量)。

14.11.2 推模型 vs 拉模型

推模型(Go channel / WebSocket):服务端主动发,客户端被动收。优势是实时性好。风险是客户端处理不过来会丢数据。

拉模型(AsyncGenerator / HTTP polling):客户端主动要,服务端被动给。优势是天然背压控制。风险是延迟取决于拉取频率。

SSE 实质上是推模型套在 HTTP 上——服务端主动推,但基于 HTTP 所以兼容性好。这也是为什么大多数 Agent 系统选择 SSE 作为对外协议,内部用 channel/generator 管理。

14.11.3 有状态 vs 无状态

有状态连接(WebSocket):服务端记住每个客户端的状态。优势是效率高(不需要每次发 context)。风险是水平扩展困难(用户会”粘”在某台服务器上)。

无状态连接(SSE over HTTP):每次连接携带完整上下文(或从存储恢复)。优势是水平扩展容易。风险是重连成本高。

Agent 系统的实际做法是”伪无状态”:SSE 连接本身是长连接(有状态),但 Agent 的执行状态存储在 Redis/数据库中。如果连接断了重连到另一台服务器,可以从存储中恢复状态继续发送事件。


14.12 思考题

  1. 背压设计:假设你的 Agent 调用的某个外部 API(如搜索引擎)突然变慢,从 200ms 变成 5s。这会如何影响事件流?下游(前端用户)会感知到什么?你会如何设计”超时 + 部分结果”的事件通知机制?

  2. 事件去重与幂等:网络不稳定时,客户端可能收到重复事件(重连后服务端重发了一些事件)。如果收到两次 TEXT_MESSAGE_CONTENT { delta: "你" },前端会显示”你你”。请设计一个方案,让前端能正确处理重复事件而不影响显示。提示:考虑 event.id 和客户端侧的已处理事件缓存。

  3. 多模态事件流:如果 Agent 不仅生成文本,还生成图片、音频、代码执行结果,事件类型系统需要如何扩展?SSE 只支持文本传输,二进制内容(图片字节)怎么办?请设计一个方案。

  4. 事件流测试策略:你要为一个 Agent 的流式输出写集成测试。如何验证事件的顺序正确(比如 RUN_STARTED 一定在 MESSAGE_DELTA 之前)?如何测试断线重连的正确性?请写出你的测试策略大纲。

  5. AG-UI 协议的局限性:AG-UI 协议假设 Agent 的状态可以用 JSON 表示,用 JSON Patch 做增量更新。但如果 Agent 的状态包含循环引用、大型二进制 blob、或者需要有序但频繁变化的列表(如实时日志),JSON Patch 会遇到什么问题?你会如何改进状态同步机制?


参考资料


上一章:第十三章 Plugin 与 Tool 系统设计深度对比

下一章:第十五章 Agent 的 Prompt Engineering 实践

返回目录:Agent 框架深度导读系列