第十四章: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 思考题
-
背压设计:假设你的 Agent 调用的某个外部 API(如搜索引擎)突然变慢,从 200ms 变成 5s。这会如何影响事件流?下游(前端用户)会感知到什么?你会如何设计”超时 + 部分结果”的事件通知机制?
-
事件去重与幂等:网络不稳定时,客户端可能收到重复事件(重连后服务端重发了一些事件)。如果收到两次
TEXT_MESSAGE_CONTENT { delta: "你" },前端会显示”你你”。请设计一个方案,让前端能正确处理重复事件而不影响显示。提示:考虑event.id和客户端侧的已处理事件缓存。 -
多模态事件流:如果 Agent 不仅生成文本,还生成图片、音频、代码执行结果,事件类型系统需要如何扩展?SSE 只支持文本传输,二进制内容(图片字节)怎么办?请设计一个方案。
-
事件流测试策略:你要为一个 Agent 的流式输出写集成测试。如何验证事件的顺序正确(比如
RUN_STARTED一定在MESSAGE_DELTA之前)?如何测试断线重连的正确性?请写出你的测试策略大纲。 -
AG-UI 协议的局限性:AG-UI 协议假设 Agent 的状态可以用 JSON 表示,用 JSON Patch 做增量更新。但如果 Agent 的状态包含循环引用、大型二进制 blob、或者需要有序但频繁变化的列表(如实时日志),JSON Patch 会遇到什么问题?你会如何改进状态同步机制?
参考资料
- AG-UI Protocol Specification: https://docs.ag-ui.com
- CopilotKit AG-UI Implementation: https://github.com/CopilotKit/CopilotKit
- LangGraph Streaming Documentation: https://langchain-ai.github.io/langgraph/how-tos/streaming-tokens/
- SSE Specification (WHATWG): https://html.spec.whatwg.org/multipage/server-sent-events.html
- tRPC-Agent-Go Event Architecture (内部文档)
- Go Concurrency Patterns: https://go.dev/blog/pipelines
- RFC 6902 - JSON Patch: https://tools.ietf.org/html/rfc6902
上一章:第十三章 Plugin 与 Tool 系统设计深度对比
下一章:第十五章 Agent 的 Prompt Engineering 实践
返回目录:Agent 框架深度导读系列