找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5267

积分

1

好友

724

主题
发表于 3 小时前 | 查看: 5| 回复: 0

在智能助手的对话流中实现可交互的卡片,远不止“在聊天框里塞个组件”那么简单。这是一项贯穿 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` 字段:如果是 `javascript`、`python` 等已知语言就进行语法高亮;如果是 `ProductCard` 这类自定义标识,则将代码体内容作为 JSON 解析,并传递给对应的 React 组件进行渲染。

以 `react-markdown` 为例,扩展逻辑如下:
```javascript
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>
          );
        },
      }}
    />
  );
}

实践细节:

  1. 为何选择代码块?
    代码块有两个天然优势:一是所有 Markdown 解析器都支持提取 language 字段,无需编写自定义解析器;二是代码块内部内容不会被 Markdown 解析器二次处理(不会把 { 当成列表、把 " 当成引号),JSON 数据可以安全传递。

  2. 流式渲染的兼容性
    代码块有明确的开始标记(```)和结束标记(```),流式解析器在遇到开始标记时就知道“接下来是一个代码块”,在结束标记到来前持续缓冲内容。这意味着前端可以在代码块完整输出后再进行 JSON 解析和组件渲染,避免了因半截 JSON 导致的解析错误。

  3. 模型输出约束
    大模型不会天然知道要输出 ProductCard 格式的代码块。我们通过在 System Prompt 中提供“卡片生成规范”来引导模型:

    # 卡片生成规范
    当需要展示结构化信息(商品、航班、用户资料等)时,使用 Markdown 代码块格式:
    ## 语法
    - 语言标识使用组件名(PascalCase),如 `ProductCard`、`FlightCard`
    - 代码体使用标准 JSON,属性名 camelCase
    - 确保 JSON 格式合法,属性名使用双引号
    ## 示例
    ```ProductCard
    {
      "title": "商品名称",
      "itemPrice": 99,
      "imageUrl": "https://example.com/img.jpg"
    }

    实际调试发现,给模型提供 2-3 个 Few-Shot 示例就能稳定输出正确格式。若对格式一致性要求极高,可在服务端增加一层正则校验——在代码块输出完毕后检查 JSON 是否合法,不合法则降级为纯文本展示。

方案二:占位符替换

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

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

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

我们尝试过一种优化:将占位符设计为类似超链接的语法 [(ProductCard)],利用 Markdown 解析器的现有机制进行扩展。
markdown-it 中可将其实现为一个 inline rule 插件:

function componentPlugin(md) {
  md.inline.ruler.before('link', 'component', (state, silent) => {
    const start = state.pos;
    const max = state.posMax;
    // 检查起始标记 [(
    if (state.src.charCodeAt(start) !== 0x5B /* [ */ ||
        state.src.charCodeAt(start + 1) !== 0x28 /* ( */) {
      return false;
    }
    // 向前扫描,寻找结束标记 )]
    let pos = start + 2;
    while (pos < max - 1) {
      if (state.src.charCodeAt(pos) === 0x29 /* ) */ &&
          state.src.charCodeAt(pos + 1) === 0x5D /* ] */) {
        break;
      }
      pos++;
    }
    if (pos >= max - 1) return false;
    const componentName = state.src.slice(start + 2, pos);
    if (!silent) {
      const token = state.push('component', '', 0);
      token.content = componentName;
    }
    state.pos = pos + 2;
    return true;
  });
  md.renderer.rules.component = (tokens, idx) => {
    const name = tokens[idx].content;
    return `<div class="custom-component" data-component="${name}"></div>`;
  };
}

占位符方案的优势是改造成本低(无需模型生成完整 JSON),适合卡片数据完全由服务端异步填充的场景。但劣势明显:占位符本身不携带数据,卡片要么需要事后补数据(增加一次请求),要么需要约定全局的组件-数据映射关系。

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

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

这个方案的灵感来自 bolt.new,它在模型输出中引入类 HTML 的自定义标签来描述 Action:

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

这种方式需要在 Markdown 解析器之外单独编写一个 XML 解析器,从文本流中提取 <boltArtifact> 标签及其属性和子元素。

自定义标签的核心优势在于表达力最强——XML 天然支持嵌套结构、属性和事件绑定(如 on-click="run"),可以描述比 JSON 更复杂的 UI 语义。它与模型无关,任何能生成合法 XML 的模型都可接入,这对于需要同时对接多家模型(Qwen、Claude、GPT)的场景尤其有价值。

但其代价也很明显:需要维护一个独立的流式 XML 解析器,且必须处理好与 Markdown 解析器的协作关系(哪些部分走 Markdown 渲染,哪些部分走 XML 解析)。此方案的背景是部分模型厂商未提供 Tool Call API,需通过 Prompt 工程模拟工具调用。如果你的模型已支持 Function Calling,此方案的必要性就降低了。

三种方案的选择建议

总结一下:

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

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

但这仅解决了“卡片如何写进 Markdown”的问题。紧接着的问题是——卡片里的 JSON 数据从何而来?模型自己编的商品价格能用吗?库存状态、用户偏好这些实时信息怎么办?

卡片数据从何而来

数据与 UI 必须解耦

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

原因很现实:大模型的预训练数据不可能涵盖所有商品,即便涵盖,价格和库存也在实时变化,更不用说千人千面的个性化推荐了。若让模型直接生成完整的商品数据,只会得到一个“看起来像回事但完全不能用”的 JSON。

我们将此归纳为三个阶段的方案演进。

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

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

这在 Demo 阶段确实能跑通,但上线后问题立刻暴露:

  • 模型编造了不存在的商品链接(胡编乱造)。
  • 价格与实际售价不一致(训练数据过时)。
  • 同一商品对不同用户展示相同价格(无法个性化)。

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

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

既然模型不能直接生成可靠数据,那就换个思路:让模型只生成卡片的“骨架”(一个带 ID 的占位 JSON),服务端异步获取真实数据后,通过 Patch 机制更新到前端。

模型输出的内容如下(注意代码块里只有 id,没有真实数据):

为你推荐以下商品:
```ProductCard
{
  "id": "xxx"
}

这些商品不仅实用,而且价格便宜。

然后前端和服务端各司其职:
*   **前端**:解析到 `ProductCard` 代码块后,发现数据不完整,渲染一个骨架屏占位。
*   **服务端**:在识别到模型输出中包含 `ProductCard` 代码块时,立即异步发起 RPC 请求获取商品数据。数据返回后,通过我们在标准 JSON Patch 之上扩展的 `replace-substring` 操作符,将占位 JSON 替换为完整数据。

传输消息格式如下:
```json
[
  {
    "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": "仅实用,而且价格便宜。"
      }
    ]
  }
]

整个交互时序如下图所示:

卡片增量Patch更新的交互时序图

此方案解决了数据准确性问题,但体验上有一个明显缺陷:用户会先看到骨架屏,等数据回来后才看到真实卡片,存在可感知的“跳变”。如果 RPC 请求慢(例如跨地域调用),延迟会更明显。

此外还有一个工程复杂度问题:replace-substring 需要在服务端精确匹配 Markdown 文本中的占位片段再做替换,若模型输出的格式有微小偏差(如多一个空格、换行不一致),替换就会失败。这在生产中需要做鲁棒性处理。

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

方案二的核心矛盾在于“模型先返回,数据后补”,数据和 UI 在时序上是割裂的。有没有可能让它们一步到位?答案是:把数据获取和 UI 描述都交给 Tool 来完成。 Agent 识别到用户意图后调用一个工具(例如 search_products),工具直接返回结构化数据 + UI 描述,前端按约定渲染。模型不再需要“编”数据,它只负责意图理解和对话编排。

社区中有两个代表性协议在探索此路:MCP AppsA2UI。它们都致力于解决“Agent 如何驱动前端渲染 UI”的问题,但设计哲学截然不同。

MCP Apps:工具驱动 UI
MCP Apps 是 Anthropic 在 MCP(Model Context Protocol)基础上的扩展。其核心理念是:Tool 返回的不只是纯数据,还附带一份 UI 描述。
具体来说:

  • 每个 MCP Tool 在注册时就声明“我的返回结果应该用什么 UI 来展示”。
  • Agent 调用 Tool 后,前端根据 tool_id 匹配预注册的渲染器,自动渲染卡片。
  • 卡片内的交互事件可以回调到 Agent,形成闭环。
    MCP Apps 的设计隐喻是:UI 是 Tool 执行结果的附属产物。工具先把活干了(查商品、查航班),然后顺便告诉前端“结果长这样”,用什么 UI 组件来渲染。这意味着 UI 与 Tool 是强绑定的——一个 search_products 工具对应一种 ProductList 卡片。

A2UI:Agent 驱动 UI
A2UI(Agent to UI)是 Google 提出的通用声明式 UI 协议。其设计哲学与 MCP Apps 完全不同:不绑定任何工具链,纯粹定义“界面应该长什么样”。
A2UI 定义了一套 JSON Schema,涵盖布局、表单、列表、图表等常见 UI 组件。Agent 只需输出符合此 Schema 的 JSON,各端(Web / iOS / Android)各自实现渲染器。Action 事件通过标准事件总线回传给 Agent。
与 MCP Apps 相比,A2UI 更关注渲染层本身的标准化。它不关心数据是从 Tool 来的还是模型直接生成的——它只管“拿到一份 JSON,画出对应的 UI”。

两者的本质区别
理解这两个协议的关键在于看它们的驱动方式

  • MCP Apps:工具驱动。UI 是 Tool 的附属,粒度是 Tool 级别(一个工具对应一种卡片)。适合卡片类型确定、交互模式固定的场景。
  • A2UI:Agent 驱动。UI 由 Agent 自主组合,粒度是组件级别(可以任意拼装布局、表单、列表)。适合需要动态生成 UI、或从预设卡片向 Agent 自主生成过渡的场景。

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

Tool 驱动的整体流程
无论选择哪种协议,Tool 驱动 UI 的核心时序是一致的,我们在实践中采用 Tool 返回数据 + UI 描述的方式:

Tool驱动UI的交互时序图

Tool 驱动方案的优势非常明显:

  • 数据与 UI 在 Tool 层一步到位,没有“先占位后补数据”的体验断裂。
  • 业务方自主维护 Tool 和对应的卡片,Agent 只做编排,职责清晰。
  • 天然支持复杂交互——卡片内分页、筛选、表单提交都可以通过 Tool 回调实现。

三种方案的演进逻辑

回顾这三种方案,它们并非简单的“好坏之分”,而是一条渐进式的架构演进路径
模型直出 → 最快出活,适合 Demo 和验证阶段。
增量 Patch → 数据可靠了,但时序和工程复杂度上升。
Tool 驱动 → 架构最干净,但需要 MCP 工具链和协议基础设施。
核心方向是:把数据生产的责任从模型转移到工具链,让模型专注于意图理解和对话编排。

至此,我们讨论了卡片如何嵌入 Markdown 流,以及卡片数据从何而来。但当我们要把这套方案推广到实际业务时,还需考虑新的问题:A 团队用代码块扩展,B 团队用占位符;C 团队的消息格式和 D 团队不兼容;Web 端和 iOS 端对同一种卡片的事件约定完全不同。每接入一个新业务方,前端就要写一套新的解析和渲染逻辑。这引出了一个更根本的问题:如何用协议来收敛混乱?

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

为什么需要协议

“协议”一词听起来很重,但它要解决的问题非常朴素:让不同团队、不同端的开发者面向同一套规范工作,减少重复建设和沟通成本。
没有协议时,每个 Agent 团队都在各自定义 Markdown 扩展格式、消息传输结构、卡片事件约定。前端为每种 Agent 写一套解析器,iOS 和 Android 各自实现一遍,修改一个字段需同步通知 N 个团队。这种“自由”的代价是系统迅速碎片化。

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

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

四层协议的整体运作关系如下图所示:

四层统一协议的组件交互架构图

下面我们逐层展开。

第一层:Markdown 标记协议

回到第一部分的问题:我们列举了代码块扩展、占位符替换、自定义标签三种方案。如果不做约束,不同 Agent 团队各选各的,前端就要为每种格式维护一套解析器。
Markdown 标记协议就是做这个约束:统一选定一种扩展方式,定义支持的组件类型列表,规范 JSON 属性的命名和格式。
有了这层协议后:

  • 所有 Agent 共用一份 System Prompt 中的“卡片生成规范”,只需调试一种输出格式。
  • 前端只维护一套 Markdown 解析器,新增卡片类型只需注册组件,无需修改解析逻辑。
  • 设计师可以提前预知每种标记渲染出来的样子,统一调整视觉规范。

第二层:消息传输协议

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

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

type 标识动作类型:text 是模型返回的 Markdown 消息,recommend/prompt 是追问推荐。第二部分讲到的增量 Patch 更新,其中的 type=fulltype=patch 也属于消息传输协议的一部分——正是有了统一的数据包结构,前端才能用同一套逻辑处理全量消息和增量更新。
统一消息格式之后,最大的收益是前端组件的可复用性。无论接入哪个 Agent,消息解析逻辑都是同一套代码。新业务方接入时,不需要前端配合“定制消息格式”,只要按协议传输就行。

第三层:UI 渲染协议

这一层定义的是:一张卡片的结构化描述应该长什么样?
核心理念来自第二部分介绍的 A2UI 协议——Agent 输出一份标准 JSON,各端(Web / iOS / Android)各自实现渲染器。这种“描述与渲染分离”的设计,使得同一份 JSON 可以在三端呈现一致的 UI,而无需 Agent 关心前端的技术实现。

结合现状和对模型能力发展的期望,LUI(Language User Interface)的演进可以分为两个阶段:

  • 第一阶段:预设卡片。 模型能力尚不稳定,通过预定义的卡片模板 + JSON 数据来保证产品对生成内容的把控力。每种卡片有固定的 Schema,Agent 只需要填数据。
  • 第二阶段:Agent 生成。 随着模型能力提升,由 Agent 自主组合 UI 组件,实现千人千面的动态卡片。Agent 不再受限于预设模板,而是根据上下文自由拼装布局、表单、列表。

LUI演进的两个阶段:预设卡片与Agent生成

这两个阶段的关键在于平滑过渡。A2UI 协议因为采用了框架无关的 JSON Schema,预设卡片和 Agent 动态生成可以共享同一套渲染器——切换时前端零改动。因此,将 A2UI 作为 UI 渲染协议虽然现阶段有挑战,但从长远看会是一个不错的选择。

从固定模板到完全动态的统一JSON渲染

第四层:事件通信协议

前三层解决了“卡片怎么写、怎么传、怎么画”,但还缺一环:用户和卡片交互之后,发生什么?
我们的设计原则是:卡片 JSON 只描述“是什么”和“能做什么”,不包含“怎么做”。执行逻辑由端侧事件处理器统一承担。
这样做有两个好处:一是 Agent 生成的 JSON 不包含可执行代码,降低了安全风险(想象一下如果 Agent 可以往卡片里注入任意 JS 代码的后果);二是事件派发逻辑由端侧统一实现,各业务方无需重复建设。

举一个具体例子,走一遍完整流程:

  1. 渲染阶段:Agent 返回一个商品卡片的 JSON,其中按钮声明了一个 Action:
    {
      "type": "button",
      "text": "加入购物车",
      "action": {
        "type": "api",
        "action": "addToCart",
        "params": { "itemId": "12345" }
      }
    }
  2. 交互阶段:用户点击按钮,事件处理器接收到 Action,识别 type=api,发起网关请求调用加购接口。
  3. 反馈阶段:请求返回后,事件处理器根据结果更新卡片状态——成功则按钮变灰显示“已加购”,失败则弹出 Toast 提示错误原因。

整个过程中,卡片 JSON 是“纯声明”的——它声明“我有一个按钮,点了之后应该调用 addToCart”,但具体如何调用、调用后如何更新 UI,全部由端侧事件处理器负责。

Action Listener与Action Emitter的事件通信流程

A2UI 本身已定义了 Action 规范,在此基础上需要做的工作是:梳理业务中实际需要的事件类型(UI 事件:Toast、Dialog、页面跳转;API 事件:数据请求、收藏、加购),定义统一的事件格式和消费方式,让各业务方无需自己发明事件协议。如果是 H5,则可采用 uniapi 等形式。

总结

卡片式交互远非“在聊天框里塞组件”那么简单。它重新定义了 Agent 时代的前后端协作方式:

  • 模型从“文本生成器”变为“界面规划师”——需要理解何时展示卡片、展示何种卡片。
  • 后端从“数据提供者”变为“协议协调者”——需要在 Tool 层打通数据获取与 UI 描述。
  • 前端从“页面渲染器”变为“协议执行引擎”——面向标准 JSON Schema 渲染,而非为每个业务方定制。

四层协议体系(Markdown 标记 → 消息传输 → UI 渲染 → 事件通信)的价值,不在于技术有多先进,而在于为复杂系统架构建立了确定性——当每一层的输入输出都有明确约定,问题排查、能力复用、新业务接入的效率就会指数级提升。

最后,值得关注的是社区中正在涌现的 Agentic 协议生态:

  • AG-UI(Agent-用户交互协议):基于事件的标准,连接 Agent 与面向用户的应用。
  • MCP(Anthropic):Agent 安全连接外部工具、工作流和数据源的开放标准。
  • A2A(Google):分布式 Agent 系统中 Agent 之间的协调协议。
  • A2UI(Google)/ MCP Apps(Anthropic)/ Open-JSON-UI(OpenAI):声明式 UI 规范,定义 Agent 响应的视觉组织方式。
  • Web MCP:让网页本身作为 MCP Server,为 Agent 提供工具访问。

卡片式对话只是 Agentic 交互的起点。当协议足够成熟、模型能力持续提升,对话界面将真正成为用户与数字世界交互的统一入口。我们也希望与更多同行交流,在 云栈社区 分享更多关于协议设计、渲染器实现、Prompt 工程与 MCP 工具封装的实践经验。

参考资料




上一篇:Claude Opus 4.7版本升级:技术细节解读与开发者工具的新可靠助手
下一篇:网盘合规风暴来袭,海外剧资源链接失效的应对策略
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-18 22:39 , Processed in 1.221666 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表