🏠

卡片式对话的协议方案探索和思考

从 Markdown 嵌入到四层统一协议,聊聊智能助手卡片交互的工程实践

引言:三个核心问题

当我们第一次在智能助手中看到一张可交互的商品卡片时,觉得这不过是"在聊天框里塞了个组件"。但真正实践下来才发现,这件事远比想象中复杂——它不是一个前端渲染问题,而是一个贯穿 Agent 设计、模型输出、数据流转、协议设计、跨端一致性的系统工程。

这篇文章不讲概念,讲探索过程中踩过的坑和沉淀的方案。文章将围绕三个问题展开:

  1. 卡片如何嵌入对话流?——模型输出的是 Markdown 文本流,卡片怎么"混"进去?
  2. 卡片数据从何而来?——模型不知道实时价格,数据该由谁提供、怎么填充?
  3. 多团队协作怎么不乱?——当 N 个业务方都要接卡片时,如何用协议约束混乱?

卡片如何嵌入对话流

问题的本质

大模型的输出本质上是一个 token 流,经过拼接后形成 Markdown 文本。Markdown 本身是一种排版标记语言,它能表达标题、列表、代码块、图片,但无法表达"这里应该渲染一个商品卡片组件"这件事。

所以核心问题是:如何在不破坏 Markdown 流式解析的前提下,嵌入自定义 UI 组件的语义信息?

调研和实践了三种方案,各有适用场景。

方案一:代码块扩展(生产环境采用)

这是最终在生产环境中采用的方案,原理非常直觉——Markdown 的代码块本身就有一个 language 字段(用于语法高亮),我们把这个字段"借用"为组件类型标识。

模型输出的 Markdown 长这样:

为你推荐以下商品:
```ProductCard{  "title": "iPhone 15 Pro Max",  "description": "全新钛金属设计,A17 Pro 芯片",  "itemPrice": 9999,  "imageUrl": "https://example.com/iphone.jpg",  "discount": "限时优惠 -10%"}```
以上商品支持分期免息,点击卡片可查看详情。

前端 Markdown 渲染器拿到这段文本后,在解析代码块时检查 language 字段:如果是 javascriptpython 这种已知语言就做语法高亮;如果是 ProductCard 这种自定义标识,就把代码体当作 JSON 解析,传给对应的 React 组件渲染。

react-markdown 为例,扩展逻辑如下:

import Markdown from 'react-markdown';
import { ProductCard } from './components/ProductCard';

// 组件注册表:language 名 → React 组件的映射
const CARD_COMPONENTS = {
  ProductCard: ProductCard,
  UserProfile: UserProfile,
  FlightCard: FlightCard,
};

function ChatMessage({ markdown }) {
  return (
    <Markdown
      children={markdown}
      components={{
        code(props) {
          const { children, className, ...rest } = props;
          // className 格式为 "language-xxx",提取 xxx 部分
          const match = /language-(\w+)/.exec(className || '');
          const langName = match ? match[1] : null;

          // 命中组件注册表 → 渲染业务卡片
          if (langName && CARD_COMPONENTS[langName]) {
            const CardComponent = CARD_COMPONENTS[langName];
            const cardProps = JSON.parse(String(children));
            return <CardComponent {...cardProps} />;
          }

          // 未命中 → 走默认的代码高亮渲染
          return (
            <code {...rest} className={className}>
              {children}
            </code>
          );
        },
      }}
    />
  );
}

为什么选代码块而不是其他 Markdown 元素?

代码块有两个天然优势:

流式渲染的兼容性

代码块有明确的开始标记(```)和结束标记(```),流式解析器在遇到开始标记时就能知道"接下来是一个代码块",在结束标记到来前持续缓冲内容。这意味着前端可以在代码块完整输出后再做 JSON 解析和组件渲染,不会出现半截 JSON 导致的 parse error。

模型输出约束

大模型不会天然知道要输出 ProductCard 格式的代码块。通过 System Prompt 给模型一份"卡片生成规范",实际调试中发现,给模型 2-3 个 Few-Shot 示例就能稳定输出正确格式。如果对格式一致性要求极高,还可以在服务端加一层正则校验——代码块输出完毕后检查 JSON 是否合法,不合法就降级为纯文本展示。

方案二:占位符替换

占位符替换是一个更轻量的思路——模型在 Markdown 中输出一个特殊标记(比如 __placeholder__ProductCard),前端识别到后替换为对应组件。

推荐上午 10 点从杭州出发前往上海的高铁__placeholder__ProductCard,早班直达。

这个方案看起来简单,但在流式场景下有一个棘手的体验问题:模型是逐 token 输出的,当只输出到 __place 的时候,前端无法判断这是一个占位符还是普通文本。用户会先看到一串乱码般的占位符文字,然后突然跳变为卡片。

💡 实践建议:如果选择占位符方案,务必配合前端骨架屏。用户看到的应该是"加载中的卡片轮廓",而不是一闪而过的占位符文本。

方案三:自定义标签(XML-LIKE)

灵感来自 bolt.new。bolt.new 在模型输出中引入了类 HTML 的自定义标签来描述 Action:

<boltArtifact title="Some title" id="artifact_1">
  <boltAction type="shell">npm install</boltAction>
</boltArtifact>

自定义标签的核心优势在于表达力最强——XML 天然支持嵌套结构、属性、事件绑定(如 on-click="run"),可以描述比 JSON 更复杂的 UI 语义。而且它与模型无关,任何能生成合法 XML 的模型都可以接入。

代价也很明显:需要维护一个独立的流式 XML parser,且必须处理好与 Markdown 解析器的协作关系。

三种方案的选择建议

方案 特点 适用场景
代码块扩展 最稳健,复用现有解析链路、零额外依赖 适合大多数场景
占位符替换 最轻量 适合卡片数据完全由服务端提供的场景
自定义标签 表达力最强 适合需要多模型统一管控、或已有 XML 基础设施的团队

在生产环境中选择了代码块扩展,原因很朴素:改造成本最低、模型约束最简单、出问题时最容易排查


卡片数据从何而来

数据与 UI 必须解耦

这是一个关键的架构决策。卡片的视觉结构("这是一个商品卡,有标题、价格、图片")和卡片的业务数据("iPhone 15 Pro Max, ¥9999, 库存 32")必须分开处理。

原因很现实:大模型的预训练数据不可能涵盖所有商品,即使涵盖了,价格库存也在实时变化。如果让模型直接生成完整的商品数据,你会得到一个"看起来像回事但完全不能用"的 JSON。

方案一:模型直出——能用,但不可靠

最简单的方式:在 System Prompt 中告诉模型"需要展示商品时,生成一个包含完整数据的代码块",模型直接把标题、价格、图片 URL 都填进 JSON 里。

这在 Demo 阶段确实能跑通。但一上线就暴露了问题:

⚠️ 结论:模型直出只适合静态内容(如百科摘要、功能说明),不适合任何涉及实时业务数据的场景。

方案二:增量 PATCH 更新——先占位,后补数据

让模型只生成卡片的"骨架"(一个带 ID 的占位 JSON),服务端异步获取真实数据后通过 Patch 机制更新到前端。

模型输出占位 JSON → 前端渲染骨架屏 → 服务端异步获取真实数据 → 通过 Patch 更新前端

传输消息格式如下:

[
  {
    "type": "full",
    "data": {
      "markdown": "为你推荐以下商品:\n```ProductCard\n{\n  id:xxx\n}\n```\n这些商品不"
    }
  },
  {
    "type": "patch",
    "patch": [
      {
        "op": "replace-substring",
        "path": "/markdown",
        "substring": "```ProductCard\n{\n  id:xxx\n}\n```",
        "replacement": "```ProductCard\n{\n  \"id\": \"12345\",\n  \"title\": \"智能手环\",\n  \"price\": 299,\n  \"image\": \"https://example.com/product.jpg\",\n  \"description\": \"健康监测,运动追踪\"\n}\n```"
      }
    ]
  },
  {
    "type": "patch",
    "patch": [
      {
        "op": "add",
        "path": "/markdown",
        "value": "仅实用,而且价格便宜。"
      }
    ]
  }
]

这个方案解决了数据准确性问题,但体验上有一个明显缺陷:用户会先看到骨架屏,等数据回来后才看到真实卡片,有一个可感知的"跳变"。另外还有工程复杂度问题:replace-substring 需要在服务端精确匹配 Markdown 文本中的占位片段再做替换,如果模型输出的格式有微小偏差,替换就会失败。

方案三:Tool 驱动——让工具同时生产数据和 UI

方案二的核心矛盾在于"模型先返回,数据后补",数据和 UI 在时序上是割裂的。有没有可能让它们一步到位?

答案是:把数据获取和 UI 描述都交给 Tool 来做。Agent 识别到用户意图后调用一个工具(比如 search_products),工具直接返回结构化数据 + UI 描述,前端按约定渲染。模型不再需要"编"数据,它只负责意图理解和对话编排。

MCP Apps vs A2UI:两种设计哲学

社区中有两个代表性协议在探索这条路:

特性 MCP Apps(Anthropic) A2UI(Google)
驱动方式 工具驱动:UI 是 Tool 执行结果的附属产物 Agent 驱动:纯粹定义"界面应该长什么样"
粒度 Tool 级别(一个工具对应一种卡片) 组件级别(可任意拼装布局、表单、列表)
适用场景 卡片类型确定、交互模式固定 需要动态生成 UI 或向 Agent 自主生成过渡
绑定关系 UI 与 Tool 强绑定 框架无关,不绑定工具链

两者在实践中并非互斥。一种可行的组合方式是:在 MCP Tool 层使用 MCP Apps 的绑定机制来管理 Tool 与 UI 的映射关系,同时用 A2UI 的 JSON Schema 作为 UI 描述的标准格式——这样既有 Tool 层的确定性,又有 UI 层的通用性。

用户输入意图 → Agent 识别意图调用 Tool → Tool 返回结构化数据 + UI 描述 → 前端根据协议渲染卡片

Tool 驱动的优势非常明显:数据与 UI 在 Tool 层一步到位,没有"先占位后补数据"的体验断裂;业务方自主维护 Tool 和对应的卡片,Agent 只做编排;天然支持复杂交互——卡片内分页、筛选、表单提交都可以通过 Tool 回调实现。

三种方案的演进逻辑

方案 特点 适用阶段
模型直出 最快出活 Demo 和验证阶段
增量 Patch 数据可靠,但时序复杂度高 过渡阶段
Tool 驱动 架构最干净 生产环境
✅ 核心方向:把数据生产的责任从模型转移到工具链,让模型专注于意图理解和对话编排。

多团队协作怎么不乱——四层统一协议

为什么需要协议

"协议"这个词听起来很重,但它要解决的问题非常朴素:让不同团队、不同端的开发者面向同一套规范工作,减少重复建设和沟通成本。

没有协议的时候,每个 Agent 团队都在各自定义 Markdown 扩展格式、消息传输结构、卡片事件约定。前端为每种 Agent 写一套解析器,iOS 和 Android 各自实现一遍,改一个字段要同步通知 N 个团队。这种"自由"的代价是系统迅速碎片化。

我们把协议分成四层,每一层解决一个明确的问题:

协议层 解决什么问题 一句话描述
1. Markdown 标记协议 卡片在文本流中怎么写 约定使用哪种 Markdown 扩展方式、支持的组件类型
2. 消息传输协议 前后端之间传什么格式 定义流式响应的数据包结构(全量/增量/推荐)
3. UI 渲染协议 卡片长什么样 标准 JSON Schema,Web/iOS/Android 共用一份描述
4. 事件通信协议 用户点了卡片之后怎么办 定义卡片可触发的 Action 及其响应方式
第一层(Markdown 标记)→ 第二层(消息传输)→ 第三层(UI 渲染)→ 第四层(事件通信)

第一层:Markdown 标记协议

回到第一部分的问题:列举了代码块扩展、占位符替换、自定义标签三种方案。如果不做约束,不同 Agent 团队各选各的,前端就要为每种格式维护一套解析器。

Markdown 标记协议就是做这个约束:统一选定一种扩展方式,定义支持的组件类型列表,规范 JSON 属性的命名和格式

有了这层协议后:所有 Agent 共用一份 System Prompt 中的"卡片生成规范";前端只维护一套 Markdown 解析器,新增卡片类型只需注册组件;设计师可以提前预知每种标记渲染出来的样子。

第二层:消息传输协议

模型输出经过 Agent 编排后,需要通过一个统一的消息格式传输给前端。约定每次传输的是一个"动作组":

data: [
  {"type": "text", "content": "第一条消息"},
  {"type": "recommend/prompt", "content": ["你可能喜欢1", "你可能喜欢2"]}
]

统一消息格式之后,最大的收益是前端组件的可复用性。无论接入哪个 Agent,消息解析逻辑都是同一套代码。新业务方接入时,不需要前端配合"定制消息格式",只要按协议传输就行。

第三层:UI 渲染协议

这一层定义的是:一张卡片的结构化描述应该长什么样?核心理念来自 A2UI 协议——Agent 输出一份标准 JSON,各端(Web / iOS / Android)各自实现渲染器

LUI(Language User Interface)的演进可以分为两个阶段:

A2UI 协议因为采用了框架无关的 JSON Schema,预设卡片和 Agent 动态生成可以共享同一套渲染器——切换时前端零改动。

第四层:事件通信协议

前三层解决了"卡片怎么写、怎么传、怎么画",但还缺一环:用户和卡片交互之后,发生什么?

我们的设计原则是:卡片 JSON 只描述"是什么"和"能做什么",不包含"怎么做"。执行逻辑由端侧事件处理器统一承担。

这样做有两个好处:一是 Agent 生成的 JSON 不包含可执行代码,降低安全风险;二是事件派发逻辑端侧统一实现,各业务方无需重复建设。

举个完整的流程例子:

  1. 渲染阶段:Agent 返回一个商品卡片的 JSON,其中按钮声明了一个 Action
  2. 交互阶段:用户点击按钮,事件处理器接收到 Action,识别 type=api,发起网关请求调用加购接口
  3. 反馈阶段:请求返回后,事件处理器根据结果更新卡片状态

总结与个人思考

卡片式交互不是"在聊天框里塞组件"这么简单。它重新定义了 Agent 时代的前后端协作方式:

四层协议体系(Markdown 标记 → 消息传输 → UI 渲染 → 事件通信)的价值,不在于技术有多先进,而在于为复杂系统建立了确定性。

一些个人思考

1. 协议设计是"约束自由"

文中提到"没有协议的时候,每个 Agent 团队都在各自定义格式",这让我想到自己在做超级工厂平台时的经历。当多个团队各自为政时,最痛苦的不是技术选型,而是沟通成本指数级上升——一个字段改名的需求要同步 N 个团队,每个团队理解还不一样。

四层协议的本质是在做"约束自由"。听起来矛盾,但工程实践中,适度的约定恰恰是效率的源泉。就像 React 生态中的 convention over configuration 思想——约定好了目录结构和数据流方向后,团队成员可以把精力放在业务逻辑上而不是纠结架构决策。

2. 代码块扩展方案的"借力打力"很妙

三种嵌入方案中,我最欣赏的是代码块扩展方案的设计思路。不发明新语法,而是借用 Markdown 已有的 language 字段来承载自定义语义——这是一种典型的"渐进增强"(Progressive Enhancement)思维。

这种思路在我们日常开发中其实很常见:React 的 JSX 本质上就是借用了 JavaScript 的函数调用语法来描述 UI;CSS-in-JS 借用了 JS 的模板字符串能力来写样式;甚至 Tailwind 的 utility-first 也是借用了 class 名这个已有机制来做原子化样式。好的抽象往往是"旧瓶装新酒",而非凭空创造一套全新体系。

3. Tool 驱动是必然趋势,但落地有门槛

文章将 Tool 驱动定位为生产环境的最终方案,我完全认同。但在实际落地中,Tool 驱动面临几个现实挑战:

我认为一个务实的路径是:MVP 阶段用代码块扩展快速验证想法,验证通过后再投入资源建设 Tool 体系。不要一上来就追求完美的架构,先跑通再优化。

4. A2UI 和 MCP Apps 不是非此即彼

文章中对比了 MCP Apps(工具驱动)和 A2UI(Agent 驱动)两种范式,并提出了组合使用的可能。我的观察是,这两种范式实际上对应着不同的产品阶段:

这很像前端框架从 jQuery 到 Vue/React 的演进——jQuery 是命令式的(告诉 DOM 怎么做),Vue/React 是声明式的(描述界面长什么样)。声明式表达力更强,但学习曲线也更高。选择哪种取决于团队能力和业务场景。

5. 对我们自己的启示

作为在 AI Agent 领域工作的开发者,这篇文章给我的最大启示是:Agent 时代的工程问题不再是纯技术问题,而是协议与协作问题。以前我们关注的是"怎么写好一个组件",现在需要关注的是"怎么定义好一套让所有组件都能正确工作的协议"。

这要求我们具备更宏观的系统思维——不只是写出能跑的代码,而是思考代码在整个系统中扮演的角色、与其他模块的接口契约、以及这套体系能否支撑未来的扩展。这也是我在腾讯做超级工厂平台时一直在思考的问题。

✅ 总而言之,卡片式对话是 Agentic UI 的冰山一角。水面之下,协议设计、数据流转、跨端一致性这些"脏活累活"才是决定系统能否长期健康运转的关键。

参考资料

本文基于大淘宝技术公众号文章《卡片式对话的协议方案探索和思考》总结整理