大语言模型(Large Language Model,LLM)的回答通常是一段连续的文本流,前端把这些 token 拼起来,再按 Markdown 渲染成标题、列表、链接、代码块等内容。
但 Agent(智能体)时代的对话界面不再只有文字。用户问“推荐一款适合跑步的手表”,理想的回答可能是一组商品卡片;用户问“明天去上海的高铁”,回答里可能需要车次卡片;用户问“帮我订一张机票”,回答里甚至需要表单、按钮和支付入口。
这时问题就变成了:如何让模型输出的文本流里自然地出现可交互 UI(User Interface,用户界面)组件,并且保证数据准确、跨端一致、事件可控?
卡片式对话系统至少要解决三件事:
- 卡片怎么嵌入 Markdown 流:模型输出的是文本,卡片是组件,两者需要一套兼容流式解析的标记方式。
- 卡片数据从哪里来:价格、库存、航班、用户权益等实时数据不能让模型随便生成。
- 多团队怎么协作:如果每个业务方都发明一套格式,前端、客户端和后端会很快陷入协议碎片化。
整体结构可以抽象成这样:
flowchart LR
U[用户] --> A[Agent 编排层]
A --> M[LLM 文本生成]
A --> T[Tool 工具层]
M --> P[消息传输协议]
T --> P
P --> R[Markdown / UI 渲染器]
R --> C[卡片组件]
C --> E[事件通信协议]
E --> A
E --> API[业务 API]
其中,LLM 负责理解意图和组织回答,Tool 负责拿真实业务数据,协议层负责把文本、结构化 UI 和交互事件统一起来。
一、卡片如何嵌入 Markdown 流
Markdown 的能力边界很清楚:它能表达标题、段落、列表、链接、图片、代码块,但不能直接表达“这里要渲染一个商品卡片”。
所以需要在 Markdown 上加一层扩展语义。这个扩展不能破坏原有 Markdown 解析,也要适合 token 流式输出,否则前端会在解析过程中看到半截标记、半截 JSON(JavaScript Object Notation,JavaScript 对象表示法),体验非常差。
常见方案有三类:代码块扩展、占位符替换、自定义标签。
1. 代码块扩展:借用 Markdown 的 language 字段
Markdown 代码块本来就支持 language 标识,例如:
```javascript
console.log("hello");
```
渲染器通常会把 javascript 当作语法高亮语言。卡片式对话可以借用这个位置,把 language 当成组件类型。
例如模型输出:
为你推荐这款商品:
```ProductCard
{
"title": "iPhone 15 Pro Max",
"description": "钛金属设计,A17 Pro 芯片",
"itemPrice": 9999,
"imageUrl": "https://example.com/iphone.jpg",
"discount": "限时优惠"
}
```
支持分期免息,点击卡片可以查看详情。
前端解析 Markdown 时,如果发现代码块的 language 是 ProductCard,就不按普通代码渲染,而是把代码块内容解析成 JSON,传给对应组件。
React 中可以这样扩展 react-markdown:
import Markdown from "react-markdown";
import { ProductCard } from "./components/ProductCard";
import { FlightCard } from "./components/FlightCard";
const CARD_COMPONENTS = {
ProductCard,
FlightCard,
};
function fallbackCodeBlock(className: string | undefined, children: React.ReactNode) {
return (
<pre>
<code className={className}>{children}</code>
</pre>
);
}
export function ChatMessage({ markdown }: { markdown: string }) {
return (
<Markdown
components={{
code(props) {
const { children, className } = props;
const match = /language-(\w+)/.exec(className || "");
const componentName = match?.[1];
if (!componentName) {
return fallbackCodeBlock(className, children);
}
const CardComponent =
CARD_COMPONENTS[componentName as keyof typeof CARD_COMPONENTS];
if (!CardComponent) {
return fallbackCodeBlock(className, children);
}
try {
const cardProps = JSON.parse(String(children));
return <CardComponent {...cardProps} />;
} catch {
return fallbackCodeBlock(className, children);
}
},
}}
>
{markdown}
</Markdown>
);
}
代码块扩展适合生产环境,原因主要有三个。
| 特性 | 说明 |
|---|---|
| 兼容现有 Markdown 解析器 | 大多数 Markdown 解析器都支持代码块和 language 字段,不需要从零写 parser |
| JSON 不会被二次解析 | 代码块内部不会被 Markdown 当作列表、引用、链接处理,结构化数据更安全 |
| 适合流式输出 | 遇到开始标记后可以持续缓冲,等结束标记出现再解析 JSON |
流式渲染时,前端不应该在代码块尚未闭合时就解析 JSON。更合理的流程是:
flowchart LR
A[收到 ```ProductCard] --> B[进入代码块缓冲状态]
B --> C[持续接收 token]
C --> D{是否收到结束标记}
D -- 否 --> B
D -- 是 --> E[解析 JSON]
E --> F{解析成功}
F -- 是 --> G[渲染卡片]
F -- 否 --> H[降级为普通代码块或文本]
为了让 LLM 稳定输出这种结构,系统提示词里需要写清楚格式约束:
当需要展示结构化信息时,使用 Markdown 代码块描述卡片。
规则:
- 代码块 language 使用组件名,采用 PascalCase,例如 ProductCard、FlightCard
- 代码块内容必须是合法 JSON
- JSON 属性名使用 camelCase
- 字符串必须使用双引号
- 不要在 JSON 中写注释
示例:
```ProductCard
{
"title": "商品名称",
"itemPrice": 99,
"imageUrl": "https://example.com/img.jpg"
}
```
工程上还要做两层保护:
- 组件白名单:只允许渲染注册过的组件,不能让模型任意指定组件名。
- Schema 校验:每种卡片都有对应 JSON Schema,字段缺失或类型错误时降级展示。
2. 占位符替换:轻量,但流式体验容易出问题
占位符方案让模型只输出一个特殊标记,前端识别后替换为卡片。
例如:
推荐上午 10 点从杭州出发前往上海的高铁 [(TrainCard:G1234)],早班直达。
这种方式的好处是模型不需要生成完整 JSON,只要输出组件名和业务 ID 即可。真实数据可以由前端或服务端根据 G1234 再去查询。
但它在流式场景中有一个天然问题:模型是逐 token 输出的,当页面只收到 [(Train 时,前端无法判断这到底是普通文本,还是一个尚未完成的占位符。如果直接展示,用户会看到一串临时标记;如果一直等待,又会影响普通文本输出。
可以用缓冲状态机缓解这个问题:
flowchart LR
A[收到字符] --> B{是否遇到 '[('}
B -- 否 --> C[按普通文本输出]
B -- 是 --> D[进入占位符缓冲]
D --> E{是否遇到 ')]'}
E -- 是 --> F[生成组件占位节点]
E -- 否 --> G{是否超过缓冲上限}
G -- 否 --> D
G -- 是 --> H[回退为普通文本]
占位符适合这类场景:
- 卡片数据完全由服务端或前端查询;
- 模型只需要决定“这里要展示什么类型的卡片”;
- 卡片渲染前允许出现骨架屏。
不适合这类场景:
- 卡片需要携带复杂结构化数据;
- 希望模型输出和 UI 一次成型;
- 对流式展示稳定性要求很高。
3. 自定义标签:表达力强,但解析成本高
自定义标签使用类似 XML(Extensible Markup Language,可扩展标记语言)或 HTML(HyperText Markup Language,超文本标记语言)的结构描述 UI 或动作。
例如:
<artifact title="创建项目" id="artifact_1">
<action type="shell">npm install</action>
<action type="shell">npm run dev</action>
</artifact>
它的优势是表达能力强:天然支持属性、嵌套结构、多 Action 组合,也可以描述更复杂的交互语义。
但代价也很明确:
- 需要维护独立的流式 XML parser;
- 要处理 Markdown parser 和 XML parser 的边界;
- 标签没闭合、属性转义、嵌套错误都要兜底;
- 安全策略更复杂,不能把标签内容当作可执行代码。
自定义标签更适合这些情况:
- 模型提供方没有 Tool Calling(工具调用)能力,只能靠文本模拟结构化动作;
- 团队已有成熟 XML-like 解析基础设施;
- 需要跨多家模型统一描述复杂 UI 或任务动作。
4. 三种嵌入方式怎么选
| 方案 | 核心思路 | 优点 | 代价 | 适合场景 |
|---|---|---|---|---|
| 代码块扩展 | 使用代码块 language 表示组件类型,代码体放 JSON | 复用 Markdown 解析链路,流式友好,易排查 | 需要约束模型输出合法 JSON | 大多数卡片式对话场景 |
| 占位符替换 | 在文本中放特殊标记,前端替换为组件 | 改造轻,模型输出简单 | 流式时可能出现半截标记,数据不随标记携带 | 服务端异步补数据、骨架屏可接受 |
| 自定义标签 | 用 XML-like 标签描述组件和动作 | 表达力强,支持复杂嵌套 | 解析器成本高,边界处理复杂 | 多模型统一管控、复杂 Action 描述 |
如果没有特殊历史包袱,代码块扩展通常是最稳的默认选择。它没有引入新的语法体系,又能把组件类型和结构化数据都放进 Markdown 流里。
二、卡片数据从哪里来
卡片有两部分内容:
- UI 结构:这是商品卡、航班卡、用户卡,包含标题、图片、按钮等展示区域。
- 业务数据:商品价格、库存、航班余票、用户优惠、推荐理由等实时信息。
这两者必须解耦。LLM 可以判断“这里适合展示商品卡”,但不应该负责生成实时价格和库存。模型可能根据训练语料编出一个看似合理的价格,也可能生成不存在的商品链接,这些内容在真实业务里不可接受。
数据获取方案通常会经历三个阶段:模型直出、增量 Patch、Tool 驱动。
1. 模型直出:适合演示,不适合实时业务
最简单的方式是让 LLM 直接输出完整卡片:
```ProductCard
{
"title": "智能手环",
"price": 299,
"stock": 20,
"imageUrl": "https://example.com/band.jpg"
}
```
这种方式适合静态内容,例如百科卡片、功能介绍、固定流程说明。只要数据不要求实时准确,模型直出可以快速完成验证。
但它不适合这些数据:
| 数据类型 | 模型直出的风险 |
|---|---|
| 商品价格 | 价格会变化,模型可能生成过期价格 |
| 库存状态 | 库存实时变化,模型无法感知 |
| 优惠权益 | 不同用户权益不同,模型无法判断 |
| 商品链接 | 容易生成不存在或错误链接 |
| 推荐结果 | 真实推荐通常来自召回、排序、过滤链路 |
所以模型直出的边界很清楚:只能用于低风险、非实时、可容忍误差的内容。
2. 增量 Patch:模型先占位,服务端后补数据
更可靠的方式是让模型只输出卡片骨架,真实数据由服务端异步查询。
模型输出:
为你推荐这款商品:
```ProductCard
{
"id": "candidate_001"
}
```
这款商品适合日常运动使用。
前端看到 ProductCard 但字段不完整时,先渲染骨架屏。服务端同时根据 candidate_001 发起 RPC(Remote Procedure Call,远程过程调用)或业务 API(Application Programming Interface,应用程序编程接口)查询,拿到真实数据后,通过 Patch 更新前端已有消息。
时序如下:
sequenceDiagram
participant C as 前端
participant A as Agent 服务
participant M as LLM
participant B as 业务服务
C->>A: 用户问题
A->>M: 请求生成回答
M-->>A: 流式返回 Markdown + ProductCard 占位
A-->>C: 推送 full/append 消息
C-->>C: 渲染商品卡骨架屏
A->>B: 根据 id 查询真实商品数据
B-->>A: 返回标题、价格、图片、库存
A-->>C: 推送 patch 消息
C-->>C: 替换占位 JSON,渲染完整卡片
Patch 消息可以设计成这样:
[
{
"type": "full",
"data": {
"messageId": "msg_001",
"markdown": "为你推荐这款商品:\n```ProductCard\n{\n \"id\": \"candidate_001\"\n}\n```\n"
}
},
{
"type": "patch",
"messageId": "msg_001",
"patch": [
{
"op": "replace-substring",
"path": "/markdown",
"substring": "```ProductCard\n{\n \"id\": \"candidate_001\"\n}\n```",
"replacement": "```ProductCard\n{\n \"id\": \"12345\",\n \"title\": \"智能手环\",\n \"price\": 299,\n \"imageUrl\": \"https://example.com/product.jpg\",\n \"description\": \"支持健康监测和运动追踪\"\n}\n```"
}
]
}
]
这个方案解决了数据准确性问题,但会带来两个工程负担。
第一,体验上会出现跳变。 用户先看到骨架屏,等业务数据返回后才看到真实卡片。如果后端调用链较长,等待感会很明显。
第二,字符串替换不够稳。 replace-substring 依赖精确匹配占位片段。模型多输出一个空格、换行格式不同、字段顺序变化,都可能导致替换失败。
生产环境里可以做几层增强:
| 问题 | 处理方式 |
|---|---|
| 占位片段难匹配 | 给每个卡片生成稳定 cardId,Patch 直接按节点 ID 更新 |
| Patch 乱序 | 消息带 seq 序号,前端按序应用 |
| 数据返回慢 | 骨架屏加超时态,超过阈值显示“暂时无法获取” |
| Patch 失败 | 降级为文本或重新拉取完整消息 |
| 字段不可信 | Patch 后仍按卡片 Schema 校验 |
3. Tool 驱动:工具同时返回真实数据和 UI 描述
增量 Patch 的问题来自时序割裂:模型先返回卡片骨架,数据后补。更干净的方案是让 Tool 直接负责数据获取和 UI 描述。
用户问“推荐一款跑步手表”时,Agent 不让模型编商品数据,而是调用 searchProducts 工具。工具访问真实商品、推荐、价格、库存服务,然后返回结构化结果和 UI 描述。
sequenceDiagram
participant C as 前端
participant A as Agent
participant M as LLM
participant T as searchProducts Tool
participant S as 商品/推荐服务
C->>A: 推荐一款跑步手表
A->>M: 识别意图并规划工具调用
M-->>A: 调用 searchProducts
A->>T: 执行工具
T->>S: 查询商品、价格、库存、推荐理由
S-->>T: 返回真实业务数据
T-->>A: 返回数据 + UI 描述
A-->>C: 推送标准消息
C-->>C: 渲染商品卡片
工具返回值可以长这样:
{
"toolId": "searchProducts",
"content": "找到 3 款适合跑步的运动手表",
"ui": {
"type": "ProductList",
"version": "1.0",
"data": {
"items": [
{
"id": "12345",
"title": "运动智能手表 Pro",
"price": 699,
"imageUrl": "https://example.com/watch.jpg",
"reason": "支持 GPS、心率监测和长续航"
}
]
}
}
}
这样做有几个好处:
- 真实数据来自业务服务,模型不负责生成价格、库存等高风险字段;
- UI 描述和数据一起返回,没有先占位后补数据的体验断裂;
- 业务方维护自己的 Tool 和卡片 Schema,Agent 只负责意图理解和编排;
- 卡片内分页、筛选、收藏、加购等交互可以继续回调 Tool 或业务 API。
社区里有两类相关协议值得对比:MCP Apps 和 A2UI。
4. MCP Apps 与 A2UI 的区别
MCP(Model Context Protocol,模型上下文协议)用于让 Agent 安全连接外部工具、数据源和工作流。MCP Apps 可以理解为在 MCP 工具结果上增加 UI 展示能力。
A2UI(Agent to UI,Agent 到 UI 协议)则更关注声明式 UI 描述。它不绑定具体工具链,只定义“界面应该长什么样”。
| 协议 | 驱动方式 | 核心思想 | 粒度 | 适合场景 |
|---|---|---|---|---|
| MCP Apps | Tool 驱动 | Tool 返回结果时附带 UI 展示信息 | 工具级 | 工具和卡片关系固定,例如商品搜索对应商品列表 |
| A2UI | Agent 驱动 | Agent 输出标准 JSON UI,各端按 Schema 渲染 | 组件级 | 需要动态组合表单、列表、图表、布局 |
| 组合使用 | Tool + UI Schema | Tool 负责确定数据,A2UI 负责标准化 UI 描述 | 工具级 + 组件级 | 既要数据可靠,又要 UI 跨端通用 |
两者不是互斥关系。一个可落地的组合方式是:Tool 层采用 MCP Apps 的注册和调用机制,UI 层采用 A2UI 风格的 JSON Schema。这样既能保证数据来源确定,又能让 Web、iOS、Android 使用同一份 UI 描述。
三、用四层协议收敛多团队协作
当只有一个 Agent、一个前端页面、几种卡片时,任何方案都能跑起来。复杂度会在业务规模扩大后爆发:
- A 团队用代码块扩展,B 团队用占位符;
- C 团队消息格式叫
text,D 团队叫markdown; - Web 端点击按钮触发
addToCart,iOS 端叫cart.add; - 新增一张卡片时,多个端都要单独沟通字段含义。
协议的作用不是让系统变“高级”,而是给每一层输入输出建立稳定契约。卡片式对话可以拆成四层协议。
| 协议层 | 解决的问题 | 核心约定 |
|---|---|---|
| Markdown 标记协议 | 卡片在文本流中怎么写 | 使用哪种扩展语法、组件名如何命名、JSON 如何校验 |
| 消息传输协议 | 前后端之间传什么 | 流式消息、全量消息、增量 Patch、推荐追问等统一格式 |
| UI 渲染协议 | 卡片长什么样 | 用标准 JSON Schema 描述组件、布局和数据 |
| 事件通信协议 | 用户交互后怎么办 | 按统一 Action 格式处理跳转、API、Toast、Dialog、Tool 回调 |
四层协议之间的关系如下:
flowchart TB
A[LLM / Agent 输出] --> B[1. Markdown 标记协议]
B --> C[2. 消息传输协议]
C --> D[3. UI 渲染协议]
D --> E[Web / iOS / Android 渲染器]
E --> F[用户点击、输入、提交]
F --> G[4. 事件通信协议]
G --> H[业务 API / Tool / Agent]
H --> C
1. Markdown 标记协议:统一“怎么写卡片”
这一层要把卡片嵌入方式固定下来,例如统一采用代码块扩展:
```ProductCard
{
"id": "12345",
"title": "商品名称",
"price": 99
}
```
同时定义几类规范:
| 规范 | 示例 |
|---|---|
| 组件命名 | ProductCard、FlightCard、UserProfileCard |
| 字段命名 | 统一使用 camelCase |
| 数据格式 | 必须是合法 JSON |
| 组件注册 | 所有可渲染组件进入白名单 |
| 版本管理 | schemaVersion: "1.0" |
| 降级策略 | 未识别组件渲染为文本或兜底卡片 |
有了这层约束,LLM 的提示词、服务端校验、前端解析器、设计规范都能围绕同一种格式建设。
2. 消息传输协议:统一“怎么传”
对话系统通常是流式的。前端收到的不一定是一整段完整回答,而是一系列增量消息。消息传输协议要定义每个数据包的结构。
一个通用消息包可以这样设计:
{
"messageId": "msg_001",
"seq": 12,
"type": "append",
"payload": {
"markdown": "为你推荐这款商品:"
}
}
常见 type 可以包括:
| type | 含义 |
|---|---|
full | 返回完整消息,通常用于首包或重建状态 |
append | 向已有 Markdown 后追加文本 |
patch | 对已有消息做局部更新 |
replace | 替换整条消息 |
recommend/prompt | 返回追问建议 |
error | 返回错误状态 |
done | 表示当前回答结束 |
如果使用 SSE(Server-Sent Events,服务器发送事件),传输格式可以是:
data: {"messageId":"msg_001","seq":1,"type":"append","payload":{"markdown":"为你推荐:"}}
data: {"messageId":"msg_001","seq":2,"type":"append","payload":{"markdown":"\n```ProductCard\n"}}
data: {"messageId":"msg_001","seq":3,"type":"append","payload":{"markdown":"{\"id\":\"12345\"}\n```"}}
data: {"messageId":"msg_001","seq":4,"type":"done","payload":{}}
消息层至少要处理三个问题:
- 顺序:
seq用来避免乱序应用。 - 幂等:重复收到同一包时不能重复追加。
- 恢复:前端断线后可以按
messageId拉取完整状态。
3. UI 渲染协议:统一“怎么画”
Markdown 标记解决的是“卡片出现在文本哪里”,但卡片内部结构还需要一套可跨端执行的描述方式。
预设卡片阶段,可以给每类卡片定义固定 Schema:
{
"type": "ProductCard",
"schemaVersion": "1.0",
"data": {
"id": "12345",
"title": "运动智能手表 Pro",
"price": 699,
"imageUrl": "https://example.com/watch.jpg"
},
"actions": [
{
"type": "api",
"name": "addToCart",
"params": {
"itemId": "12345"
}
}
]
}
更进一步,可以走 A2UI 风格的通用组件描述:
{
"type": "card",
"children": [
{
"type": "image",
"props": {
"src": "https://example.com/watch.jpg",
"aspectRatio": "1:1"
}
},
{
"type": "text",
"props": {
"value": "运动智能手表 Pro",
"style": "title"
}
},
{
"type": "text",
"props": {
"value": "¥699",
"style": "price"
}
},
{
"type": "button",
"props": {
"text": "加入购物车"
},
"action": {
"type": "api",
"name": "addToCart",
"params": {
"itemId": "12345"
}
}
}
]
}
UI 渲染协议的演进通常分为两个阶段:
flowchart LR
A[预设卡片阶段] --> B[固定组件 + 固定 Schema]
B --> C[端侧渲染器稳定]
C --> D[Agent 生成阶段]
D --> E[通用组件 JSON Schema]
E --> F[Agent 动态组合布局、表单、列表、图表]
预设卡片更可控,适合早期落地;通用 JSON UI 更灵活,适合模型能力和安全校验成熟后的动态界面生成。两者可以共用同一套渲染器底座,只是开放程度不同。
4. 事件通信协议:统一“点了之后怎么办”
卡片不能只展示,还要能交互。用户点击“加入购物车”“筛选价格”“查看更多”“提交表单”后,系统需要知道事件发给谁、参数是什么、结果怎么反馈。
关键原则是:卡片 JSON 只声明动作,不包含可执行代码。
不要让模型生成 JavaScript 代码,也不要把任意脚本塞进卡片。更安全的做法是让 JSON 描述“能做什么”,具体执行由端侧事件处理器完成。
例如一个按钮:
{
"type": "button",
"text": "加入购物车",
"action": {
"type": "api",
"name": "addToCart",
"params": {
"itemId": "12345"
},
"feedback": {
"successText": "已加购",
"failureToast": "加购失败,请稍后重试"
}
}
}
交互流程如下:
sequenceDiagram
participant U as 用户
participant C as 卡片组件
participant E as 事件处理器
participant G as 网关 API
participant A as Agent
U->>C: 点击加入购物车
C->>E: 派发 action
E->>E: 校验 action 类型和参数
E->>G: 调用 addToCart
G-->>E: 返回结果
alt 成功
E-->>C: 更新按钮状态为已加购
else 失败
E-->>C: 展示错误提示
end
E-->>A: 可选:回传交互上下文
事件类型可以分层定义:
| 事件类型 | 用途 | 示例 |
|---|---|---|
navigate | 页面跳转 | 打开商品详情页 |
api | 调用业务接口 | 收藏、加购、提交表单 |
tool | 回调 Agent Tool | 重新筛选商品、查询下一页 |
toast | 轻提示 | 展示操作成功或失败 |
dialog | 弹窗 | 二次确认、权益说明 |
updateState | 局部状态更新 | 按钮置灰、列表刷新 |
事件通信协议需要特别关注安全边界:
- 只允许白名单 Action;
- 参数必须经过 Schema 校验;
- API 权限由端侧或网关控制;
- 敏感操作需要二次确认;
- 所有交互事件要带 traceId,方便排查链路问题。
四、落地时容易踩的坑
1. 不要相信模型生成的业务数据
模型可以生成 UI 结构建议,但价格、库存、权益、订单状态必须来自真实系统。涉及交易、履约、账户、权限的数据都应该走 Tool 或业务 API。
2. 流式解析要有中间态
卡片代码块没闭合时,不要急着解析 JSON。前端需要明确区分三种状态:
| 状态 | 行为 |
|---|---|
| 普通文本流 | 直接渲染 Markdown |
| 卡片缓冲中 | 暂存 token,不解析 JSON |
| 卡片完成 | 校验 Schema,渲染组件或降级 |
3. Schema 必须版本化
卡片字段一定会变化。没有版本号时,前端无法判断字段缺失是老版本还是错误数据。
推荐格式:
{
"type": "ProductCard",
"schemaVersion": "1.1",
"data": {}
}
旧版本继续兼容,新版本按能力逐步启用。
4. 跨端渲染不要依赖 Web 专属能力
如果目标包括 iOS 和 Android,UI Schema 不能绑定 CSS、DOM、浏览器事件等 Web 专属概念。更稳妥的方式是描述抽象组件和设计 token,例如 text.title、color.primary、spacing.medium。
5. 降级策略要从一开始设计
卡片渲染失败不应该导致整条消息不可读。常见降级方式包括:
- 卡片 JSON 无法解析:展示为普通文本;
- 组件未注册:展示兜底卡片;
- 数据超时:展示骨架屏和重试按钮;
- Action 不支持:隐藏按钮或置灰;
- Schema 版本过高:使用兼容字段渲染基础信息。
五、协议选型建议
不同阶段可以采用不同方案,不必一开始就追求完整的 Agentic UI。
| 阶段 | 推荐方案 | 原因 |
|---|---|---|
| Demo 验证 | 代码块扩展 + 模型直出 | 快速验证卡片展示效果 |
| 小规模业务 | 代码块扩展 + 服务端补数据 | 数据更可靠,改造成本可控 |
| 多业务接入 | 四层协议 + Schema 校验 | 避免各业务方格式分裂 |
| 复杂交互 | Tool 驱动 + 事件通信协议 | 数据、UI、交互闭环更清晰 |
| 跨端动态 UI | MCP Apps + A2UI 风格 Schema | Tool 保证确定性,Schema 保证跨端渲染一致 |
核心方向是:让模型负责意图理解和对话编排,让 Tool 负责真实数据,让协议负责跨端一致性和交互安全。
当 Markdown 标记、消息传输、UI 渲染、事件通信四层都稳定后,新业务接入就不再需要重新定义一套链路。业务方只要提供 Tool、Schema 和卡片组件,Agent 和前端都能按统一协议工作。
参考链接
- StackBlitz Bolt:https://github.com/stackblitz/bolt.new
- MCP Apps:https://modelcontextprotocol.io/extensions/apps/overview
- A2UI:https://a2ui.org/
- CopilotKit A2UI:https://docs.copilotkit.ai/built-in-agent/generative-ui/a2ui
- react-markdown:https://remarkjs.github.io/react-markdown/