2130 字
约 7 分钟
17
S21-X19 · AG-UI:Agent 与前端之间的事件流协议

Agent 跑在服务端,界面跑在浏览器里,"打字机式"的流式输出只是表面。真正难的是四件事:文本怎么增量到屏幕、工具调用怎么让前端参与、状态怎么双向同步、中断怎么恢复。AG-UI 就是把这四件事定义成一组事件类型的协议,让任何 Agent 框架和任何前端框架能对上话。它和 MCP(给 Agent 接工具)、A2A(Agent 之间协作)是三个不同层面的东西,经常被混为一谈。


1. 三个协议的分工

协议 连接的两端 解决的问题 典型消息
MCP Agent ↔ 工具/数据源 工具发现与调用 tools/list、tools/call
A2A Agent ↔ Agent 任务委派、能力协商 tasks/send、message
AG-UI Agent ↔ 用户界面 事件流、状态同步、人类介入 TEXT_MESSAGE_CONTENT、STATE_DELTA

AG-UI 的关键取舍:它不规定 Agent 内部怎么思考,只规定"发生了什么"怎么播出去。因此同一个前端可以接 LangGraph、CrewAI、自研 Agent,只要对方实现了事件发射。


2. 传输:一条下行流 + 一条上行通道

传输 下行 上行 适用
SSE + POST text/event-stream 长连接 独立 POST 提交用户输入 最常用,穿透代理友好
WebSocket 双向帧 同连接 需要低延迟双向、多人协同
轮询 定时拉 events 端点 POST 受限环境兜底

选择顺序建议 SSE 优先:文本事件是单向的,SSE 天然匹配,且经过 Nginx/Cloudflare 时的兼容性最好。WebSocket 只在"前端工具需要即时回传、且状态更新频繁"时才值得上。

# SSE 客户端视角:连接、消费事件、终止
curl -N -X POST http://localhost:3001/agent \
  -H 'Content-Type: application/json' \
  -H 'Accept: text/event-stream' \
  -d '{"threadId":"t-1","runId":"r-1","messages":[{"id":"m1","role":"user","content":"查一下订单 1001"}]}'

-N 关掉缓冲,否则 curl 会把整个流攒到结束才打印,看起来像"没有流式"。


3. 事件族:生命周期、文本、工具、状态

族 事件 作用 关键字段
生命周期 RUN_STARTED / RUN_FINISHED / RUN_ERROR 标记一次运行的边界 threadId、runId
步骤 STEP_STARTED / STEP_FINISHED 暴露中间阶段,前端可显示进度 stepName
文本 TEXT_MESSAGE_START / _CONTENT / _END 增量文本 messageId、delta
工具 TOOL_CALL_START / _ARGS / _END / _RESULT 工具调用与结果回填 toolCallId、toolCallName
状态 STATE_SNAPSHOT / STATE_DELTA 全量/增量同步共享状态 snapshot、delta(JSON Patch)
消息 MESSAGES_SNAPSHOT 一次性替换整条消息历史 messages
扩展 CUSTOM / RAW 框架私有事件、透传原始数据 name、value

文本三件套的顺序不可乱:START 带 messageId,CONTENT 只带 delta,END 收尾。前端按 messageId 累积,因此同一个 session 里 messageId 必须唯一;复用会让两段回答串在一起,这是最常见的显示错乱原因。

event: TEXT_MESSAGE_START
data: {"type":"TEXT_MESSAGE_START","messageId":"m-2","role":"assistant"}

event: TEXT_MESSAGE_CONTENT
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m-2","delta":"订单 1001 "}

event: TOOL_CALL_START
data: {"type":"TOOL_CALL_START","toolCallId":"c-1","toolCallName":"queryOrder"}

event: TOOL_CALL_ARGS
data: {"type":"TOOL_CALL_ARGS","toolCallId":"c-1","delta":"{\"id\":1001}"}

TOOL_CALL_ARGS 也是增量拼装:参数 JSON 可能分多帧到达,前端必须累积后再解析,逐帧 JSON.parse 一定抛异常。


4. 共享状态:快照与增量补丁

STATE_SNAPSHOT 送全量对象,STATE_DELTA 送 RFC 6902 JSON Patch 数组。两者的分工是:连接建立或状态重置时用快照,之后每次变化用增量。

{"type":"STATE_DELTA","delta":[
  {"op":"replace","path":"/filters/status","value":"paid"},
  {"op":"add","path":"/cart/items/-","value":{"sku":"A-1","qty":1}}
]}
操作 语义 常见错误
add 新增键或数组元素(/- 表示追加) 对已存在键 add 会覆盖,语义易混
replace 替换已存在的路径 路径不存在则整条 patch 失败
remove 删除 删不存在的路径同样失败
test 断言当前值 用于乐观并发校验,少用但有用

前端必须用支持 JSON Patch 的库应用补丁,并保证补丁按到达顺序应用。并发两条补丁时,SSE 的单连接顺序是唯一的顺序保证;一旦改成多连接或并发请求,状态就会漂移。


5. 前后端工具的往返

工具分两类:后端工具(Agent 自己调)与前端工具(需要界面能力,如读剪贴板、打开弹窗、取当前选中项)。AG-UI 用同一条事件通道表达两者,差别在"谁来执行"。

Agent → 前端: TOOL_CALL_START(toolCallName="openPanel", toolCallId="c-7")
Agent → 前端: TOOL_CALL_ARGS(delta="{...}")
Agent → 前端: TOOL_CALL_END
前端 → Agent: TOOL_CALL_RESULT(toolCallId="c-7", content="opened")

三个约束值得写进代码规范:

  1. toolCallId 全程唯一,回填时按它匹配,不要按顺序配对;
  2. 前端工具的执行要有超时,否则 Agent 会一直等 TOOL_CALL_RESULT;
  3. 前端工具失败也要回填 TOOL_CALL_RESULT(含错误信息),不要只发 RUN_ERROR——前者让 Agent 能改参数重试,后者直接终止运行。

6. 服务端最小实现

下面这段按 Node + Express 写,展示"事件怎么发"这件事本身,不依赖任何 Agent 框架。

import express from 'express';

const app = express();
app.use(express.json());

app.post('/agent', async (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
    'X-Accel-Buffering': 'no',        // 关掉 nginx 缓冲,否则不是流式
  });

  const send = (type, payload) =>
    res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`);

  const { threadId, runId } = req.body;
  send('RUN_STARTED', { threadId, runId });

  send('TEXT_MESSAGE_START', { messageId: 'm-1', role: 'assistant' });
  for (const chunk of ['订单 ', '1001 ', '状态为已支付']) {
    send('TEXT_MESSAGE_CONTENT', { messageId: 'm-1', delta: chunk });
    await new Promise(r => setTimeout(r, 50));
  }
  send('TEXT_MESSAGE_END', { messageId: 'm-1' });
  send('RUN_FINISHED', { threadId, runId });
  res.end();
});

app.listen(3001);

X-Accel-Buffering: no 这一行几乎是必需的:反向代理默认会缓冲响应体,事件全被攒在代理里,前端只看到"一次性出现整段文本"。


7. 排错:六种典型现象

现象 根因 处理
文本一次性整段出现 代理缓冲 / 客户端缓冲 加 X-Accel-Buffering: no,curl -N 验证
断线后界面卡住不动 未发 RUN_ERROR/RUN_FINISHED 服务端异常分支也要发终止事件
两次回答混在一起 messageId 复用 每次运行生成新的 messageId
状态跳变、过滤条件错乱 STATE_DELTA 乱序或丢帧 单连接 + 顺序应用,必要时改用快照重发
工具调用一直等待 前端未回 TOOL_CALL_RESULT 前端加超时与错误回填
重连后重复回答 未带 Last-Event-ID/断点信息 服务端按 runId 支持幂等重放或续传

最后一条涉及 SSE 的天然能力:浏览器重连会带 Last-Event-ID。服务端若实现了事件缓冲,可以从该 id 续传;没实现的话,重连会从头再来,用户看到两遍相同内容。


8. 边界:AG-UI 不解决什么

需求 该交给 原因
工具的安全边界与权限 MCP 服务端 + 授权层 AG-UI 只是传输,不做鉴权模型
Agent 之间的任务编排 A2A 或自研编排层 跨 Agent 的能力协商不属于协议范围
长任务的持久化与恢复 业务侧任务表 + 幂等 事件流本身不落盘
富文本/多模态渲染细节 前端组件库 协议只送文本与结构化 payload
消息级加密 传输层 TLS 协议不定义密钥交换

一条实践建议:把 AG-UI 事件当日志对待。每次运行把事件序列落一份,出问题时能重放出用户当时看到的一切。这比查应用日志快得多,也是协议设计里"事件即事实"的直接收益。


9. 自测

  1. AG-UI、MCP、A2A 各自连接哪两端?为什么会同时出现?
  2. 为什么 SSE 场景下要显式关闭反向代理缓冲?不关的表现是什么?
  3. TEXT_MESSAGE_CONTENT 的 delta 为什么不能逐帧 JSON.parse 工具参数?
  4. STATE_SNAPSHOT 与 STATE_DELTA 分别在什么时机使用?
  5. 前端工具执行失败时,为什么应回 TOOL_CALL_RESULT 而不是只发 RUN_ERROR?
  6. 断线重连后用户看到重复回答,最可能缺了哪两件事?
S21-X19 · AG-UI:Agent 与前端之间的事件流协议
https://www.clxhxhhr.top/posts/6030/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。