Files
cs-note/Eino/quick_start/chapter_10_a2ui_protocol.md
T
2026-05-24 11:42:38 +08:00

367 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [eino, ai-development, go, quickstart, a2ui, sse]
create time: 2026-04-29 16:00
---
# 第十章:A2UI 协议(流式 UI 组件)(最终章)
## 概述
本章作为 ChatWithEino Quickstart 的最终章,引入 **A2UI 协议**——把 Agent 的事件流以 JSONL/SSE 的形式推送到前端,渲染为可增量更新的 UI 组件树。你将掌握 Agent 到 Web 的端到端集成方案,理解为什么 AI 应用需要从纯文本走向结构化、可交互的 UI 呈现。
> [!tip] 一句话理解 A2UI
>
> **A2UI = Agent 输出 × UI 组件映射**。它定义了"Agent 做了什么"如何变成"用户看到了什么":文本 → Text 组件、工具调用 → Chip 卡片、进度更新 → 实时更新……一切通过声明式的组件树实现。
---
## 代码位置
- 入口代码:[main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/main.go)
- Agent 构建:[agent.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/agent.go)
- 服务端路由:[server/server.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/server/server.go)
- A2UI 子集实现:[a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)
- A2UI 事件流转换:[a2ui/streamer.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/streamer.go)
- 前端页面:[static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark)。
## 运行
在 `examples/quickstart/chatwitheino` 目录下执行:
```bash
go run .
```
输出示例:
```
starting server on http://localhost:8080
```
启动后浏览器访问 `http://localhost:8080` 即可看到完整的 A2UI 交互界面。
### (可选)启用 Skills 能力
最终 Web 版使用的 Agent 构建逻辑与第九章对齐:当 `EINO_EXT_SKILLS_DIR` 指向一个合法 skills 目录时,会自动注册 `skill` 中间件,模型就能按需调用 `skill` 工具加载文档。
```bash
go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean
EINO_EXT_SKILLS_DIR="$(pwd)/skills/eino-ext" go run .
```
## A2UI 的定位与边界
> [!important] A2UI 不属于 Eino 框架本身
A2UI 是一个**业务层的 UI 协议/渲染方案**,不是 Eino 的核心 Component。本章把它集成进前面章节逐步构建出来的 Agent,是为了提供一个端到端、可落地的完整示例:从模型调用、工具调用、工作流编排,到最终把结果以更友好的 UI 方式呈现出来。
**真实业务场景中,你完全可以根据产品形态选择不同的 UI 形式:**
| 场景 | UI 形式 | 说明 |
|------|---------|------|
| Web / App | 自定义组件、表格、卡片、图表 | 最典型的 B/S 架构应用 |
| IM / 办公套件 | 消息卡片、交互式表单 | 飞书、钉钉等平台的富消息 |
| 命令行 | 纯文本或 TUI | Console 版 Agent 的原生形式 |
Eino 关注「可组合的智能执行与编排能力」,而「如何呈现给用户」属于业务层可以自由扩展的一环。
## 从纯文本到结构化的 UI:为什么需要 A2UI
> [!question] 思考一下
>
> 如果你要为一个 AI 聊天产品设计更丰富的交互体验,纯文本回复会遇到哪些瓶颈?
**纯文本输出的局限:**
- ❌ 无法展示结构化数据(表格、列表、卡片等)
- ❌ 无法实时更新(进度条、状态变化等)
- ❌ 无法嵌入交互元素(按钮、表单、链接等)
- ❌ 无法支持多媒体(图片、视频、音频等)
**A2UI 的定位:**
- ✅ **协议映射**:Agent 输出 → UI 组件的声明式映射关系
- ✅ **流式渲染**:组件实时更新,无需等待完整响应
- ✅ **增量更新**:基于 dataKey 的数据绑定,文本流可逐 token 更新
**简单类比:**
- **纯文本输出** = "终端命令行"(只能显示文本)
- **A2UI** = "Web 应用"(可以显示任何 UI 组件)
## A2UI v0.8 子集(本示例的边界)
本 quickstart 并没有实现一个"完整的 A2UI 标准库",而是实现了一个 **A2UI v0.8 的子集**:目标是把 Agent 的事件流,以稳定、可增量渲染的 UI 组件树方式推给浏览器。
当前实现的 A2UI 消息类型与组件类型,以 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go) 为准。
### A2UI 消息类型:信封结构
每一行 SSE(`data: {...}`)承载一个 A2UI Message,Message 是一个"信封结构",每次只会出现一个字段:
> [!note] 关键代码片段
>
> 注意:这是简化后的代码片段,不能直接运行,完整代码请参考 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)。
```go
type Message struct {
BeginRendering *BeginRenderingMsg
SurfaceUpdate *SurfaceUpdateMsg
DataModelUpdate *DataModelUpdateMsg
DeleteSurface *DeleteSurfaceMsg
InterruptRequest *InterruptRequestMsg
}
```
| 消息类型 | 作用 | 触发时机 |
|----------|------|----------|
| `BeginRendering` | 告诉前端"开始渲染一个 surface",指定根节点 ID | 新会话开始时 |
| `SurfaceUpdate` | 新增/更新一批组件(组件是树,用 id 互相引用) | 创建/修改 UI 结构时 |
| `DataModelUpdate` | 更新 data bindings(用于流式文本增量渲染) | assistant 生成文本时 |
| `InterruptRequest` | 通知前端展示批准/拒绝入口 | Agent 需要人类审批时 |
| `DeleteSurface` | 删除某个 surface | 清理/重置会话时 |
### A2UI 组件类型
本示例 UI 组件只实现了 4 种(见 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)):
```mermaid
classDiagram
class ComponentTree {
<<abstract>>
+id string
+children []string
}
class TextComponent {
+text string
+dataKey string
+usageHint string
}
class ColumnLayout {
+spacing float64
+align ItemsAlign
}
class RowLayout {
+spacing float64
+align ItemsAlign
}
class CardContainer {
+title string
+border bool
}
ComponentTree <|-- TextComponent
ComponentTree <|-- ColumnLayout
ComponentTree <|-- RowLayout
ComponentTree <|-- CardContainer
note for TextComponent "支持 dataKey\n流式绑定"
note for ColumnLayout "垂直布局容器"
note for RowLayout "水平布局容器"
note for CardContainer "内容容器\n无布局功能"
```
各组件职责:
| 组件 | 用途 | 特性 |
|------|------|------|
| `Text` | 文本渲染 | 支持 `usageHint`(caption/body/title),当存在 `dataKey` 时文本来自 `DataModelUpdate` |
| `Column` | 垂直布局 | children 是组件 ID 列表 |
| `Row` | 水平布局 | children 是组件 ID 列表 |
| `Card` | 卡片容器 | children 是组件 ID 列表,仅做视觉分组 |
> [!warning] Card ≠ 布局容器
>
> `Card` 不提供任何布局控制(不排布子元素的位置),它只是一个有视觉边界的容器。如需布局请用 `Column` / `Row`。
## A2UI 的实现链路
最终 Web 版的核心链路是三阶段管道:
1. **后端运行 Agent**:得到 `*adk.AsyncIterator[*adk.AgentEvent]`
2. **事件 → A2UI JSONL/SSE 流**:转换为 A2UI 消息推送给浏览器(见 [a2ui/streamer.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/streamer.go))
3. **前端解析并渲染**:读取 SSE 流并渲染组件树(见 [static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html))
### 服务端路由(高层)
与 A2UI 相关的关键接口(见 [server/server.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/server/server.go)):
| 方法 | 路径 | 响应 | 说明 |
|------|------|------|------|
| GET | `/` | HTML | 返回前端页面 |
| POST | `/sessions/:id/chat` | SSE 流 | Agent 运行结果实时渲染 |
| GET | `/sessions/:id/render` | JSONL | 回放历史消息 |
| POST | `/sessions/:id/approve` | SSE 流 | interrupt 批准后继续执行 |
### 事件流转换
服务端把 `Runner.Run(...)` 的事件流交给 `a2ui.StreamToWriter(...)`,后者负责:
```mermaid
flowchart TD
A["AgentEvent 输入"] --> B{事件类型}
B -->|"user"| C["渲染 User 气泡"]
B -->|"assistant"| D["创建 DataModelUpdate\n流式追加文本"]
B -->|"tool call"| E["渲染 ToolCall Chip 卡片"]
B -->|"tool result"| F["渲染 ToolResult Chip 卡片"]
B -->|"interrupt"| G["发送 InterruptRequest\n暂停等待人类审批"]
C --> H["SSE JSONL 输出"]
D --> H
E --> H
F --> H
G --> H
style D fill:#fff3e0
style G fill:#fce4ec
```
核心处理逻辑:
- **User 输出**:渲染为用户消息气泡
- **Assistant 流式 token**:创建 `DataModelUpdate`,通过 `dataKey` 绑定到一个 `Text` 组件上,实现"边生成边渲染"
- **Tool Call / Tool Result**:渲染为独立的 chip 卡片
- **Interrupt**:发送 `InterruptRequest`,暂停等待人类批准后 resume
### 前端集成:Fetch + SSE(不是 WebSocket)
前端通过 `fetch('/sessions/:id/chat')` 发起请求,然后从 `res.body` 读取流式字节,按行切分并解析 `data: {...}` 的 JSON:
> [!note] 前端代码片段
>
> 注意:这是简化后的代码片段,不能直接运行,完整代码请参考 [static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html)。
```javascript
const res = await fetch(`/sessions/${id}/chat`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message}),
});
const reader = res.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});
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
const jsonStr = trimmed.slice(5).trimStart();
processA2UIMessage(JSON.parse(jsonStr));
}
}
}
```
> [!tip] 为什么用 SSE 而不是 WebSocket?
>
> - **SSE**(Server-Sent Events)是单向的、HTTP 兼容、天然支持断线重连语义
> - 对于"服务器推送 UI 事件,客户端只需消费"的场景,SSE 比 WebSocket 更轻量
> - 如果未来需要双向交互(如键盘快捷键、实时光标同步),才考虑升级到 WebSocket
## A2UI 流式渲染流程
```mermaid
sequenceDiagram
participant U as 用户
participant FE as 前端浏览器
participant BE as 后端 Server
participant AG as Agent
participant LM as LLM
U->>FE: 输入消息
FE->>BE: POST /sessions/:id/chat
BE->>AG: Runner.Run()
AG->>LM: 发送请求
LM-->>AG: token 流式返回
loop 每个 AgentEvent
AG-->>BE: AgentEvent
alt assistant token
BE->>FE: DataModelUpdate (dataKey 绑定)
else tool call
BE->>FE: SurfaceUpdate (Chip 卡片)
end
FE-->>U: UI 增量更新
end
AG-->>AG: 可能需要 Interrupt
AG->>BE: InterruptRequest
BE->>FE: InterruptRequest
FE-->>U: 展示审批按钮
U->>FE: 点击批准
FE->>BE: POST /sessions/:id/approve
BE->>AG: Resume 继续执行
```
## 从 Quickstart 到生产落地
> [!tip] 可扩展的设计思路
>
> A2UI 的协议层和 Agent 层是解耦的。你可以只做其中的任意一部分。
| 方向 | 替换方案 | 适用场景 |
|------|---------|----------|
| 不同 UI 形态 | React/Vue 组件、移动端原生组件、TUI | 产品定位差异 |
| 传输协议升级 | gRPC Streaming / GraphQL Subscription | 需要更强的双向交互 |
| 渲染引擎切换 | 服务端 SSR → 客户端动态渲染 | CDN 加速需求 |
| 多模态扩展 | 嵌入图片、图表、代码高亮 | 数据类/分析型 Agent |
## 本章小结
| 核心概念 | 说明 | 关键点 |
|----------|------|--------|
| **A2UI 协议** | Agent 到 UI 的映射协议 | 声明式组件树 + 数据绑定 |
| **消息信封** | BeginRendering / SurfaceUpdate / DataModelUpdate | 每种消息对应不同生命周期 |
| **组件系统** | Text / Column / Card / Row | 4 种基础组件覆盖常见 UI 场景 |
| **SSE 流** | AgentEvent → A2UI JSONL → 前端渲染 | 单向推送、增量更新 |
| **中断协作** | InterruptRequest + approve 机制 | 人机协同的关键路径 |
> [!success] 学习成果
>
> 完成本章后,你应该能够:
> - 理解 A2UI 协议的设计思路和适用场景
> - 掌握 Agent 事件流到前端 UI 的完整转换链路
> - 使用 SSE 实现增量渲染的前端集成方案
> - 知道如何将这个骨架扩展到不同的产品形态中
## 下一章预告
这是 Quickstart 系列的终章。后续如果你想深入:
- [[Eino/quick_start/chapter_09_skill_console]] — 回顾第九章的 Skill 知识注入能力
- [[Eino/README]] — 探索 Eino 框架的系统性学习路径
## 扩展思考
### 其他组件类型(可选实现方向)
| 组件 | 说明 | 实现难度 |
|------|------|----------|
| **图表组件** | 折线图、柱状图、饼图 | 中高(需接入图表库) |
| **地图组件** | 地理信息可视化 | 中(依赖地图 SDK) |
| **时间线组件** | 事件顺序排列展示 | 低(已有 Column 即可) |
| **树形组件** | 层级数据结构展示 | 中(递归渲染逻辑) |
| **标签页组件** | 多面板 Tab 切换 | 低(State 管理即可) |
### 高级交互能力
| 能力 | 说明 | 技术要点 |
|------|------|----------|
| **组件交互** | 点击、拖拽、输入反馈 | 前端事件 → API 调用 |
| **条件渲染** | 根据数据决定组件显隐 | 服务端根据状态发送不同 SurfaceUpdate |
| **组件动画** | 平滑过渡效果 | CSS transition / animation |
| **响应式布局** | 自适应屏幕尺寸 | 媒体查询 + 弹性布局 |
## 关联笔记
- [[Eino/quick_start/_index]] — Quickstart 系列统一入口
- [[Eino/quick_start/chapter_08_graph_tool]] — Graph Tool 复杂工作流编排
- [[Eino/quick_start/chapter_09_skill_console]] — Skill 知识与指令注入
- [[Eino/quick_start/chapter_07_interrupt_resume]] — Interrupt 与 Resume 机制