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")
三个约束值得写进代码规范:
toolCallId全程唯一,回填时按它匹配,不要按顺序配对;- 前端工具的执行要有超时,否则 Agent 会一直等
TOOL_CALL_RESULT; - 前端工具失败也要回填
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. 自测
- AG-UI、MCP、A2A 各自连接哪两端?为什么会同时出现?
- 为什么 SSE 场景下要显式关闭反向代理缓冲?不关的表现是什么?
TEXT_MESSAGE_CONTENT的delta为什么不能逐帧JSON.parse工具参数?STATE_SNAPSHOT与STATE_DELTA分别在什么时机使用?- 前端工具执行失败时,为什么应回
TOOL_CALL_RESULT而不是只发RUN_ERROR? - 断线重连后用户看到重复回答,最可能缺了哪两件事?