让 Agent 返回页面能直接渲染的结果:从一句话到商品卡片

用户在商城里输入:“预算 500 元,推荐两款通勤双肩包。”

聊天助手很容易回一段推荐文案。但放到真实页面里,前端还需要商品名称、价格、卡片列表和异常提示。如果答案里只有一段 Markdown,页面就得猜哪一行是商品、哪一个数字是价格,后续的点击、对比和状态更新也很难维护。

这一篇从一个具体交互开始:让 Agent 查询商品,再把结果交给固定的 Vue 组件展示。 推荐理由可以由模型组织,商品身份和价格必须来自查询结果。

本文是可复现的离线工程演示:商品为虚构数据,模型响应使用固定夹具,工具在浏览器本地执行。没有调用真实模型,没有连接真实商城,也没有下单功能。验证覆盖的是工具与页面之间的数据边界。

机器人把查询结果送入校验关卡,合格结果成为商品卡片,异常结果被拦下。

先约定页面真正需要什么

商品卡片可以先收敛到四个字段:id、name、priceCents、reason。金额统一用整数分保存,显示时才转成元,避免把“299 元”“¥299”“29900”混成一种数据。

其中,前三个字段属于商品事实;reason 是模型给出的推荐解释。把它们分开,接口就容易设计了:工具返回商品事实,模型最终只返回 productId 和 reason,应用程序再按 ID 合并。

json
{
  "type": "product_cards",
  "items": [
    { "productId": "bag-01", "reason": "重量轻,适合日常通勤。" },
    { "productId": "bag-02", "reason": "防泼水面料,适合雨天通勤。" }
  ]
}

这份最终结果不接受模型提供的名称或价格。即使模型试图附加一个 priceCents: 1,卡片校验也会失败。这样可以把“表达是否流畅”和“商品事实是否可信”拆成两个可检查的问题。

还要加一条业务约束:模型只能引用本次工具返回的商品。 商品 ID 在全站存在,并不代表它符合这次的预算和筛选条件。仅查询全局商品表会把这个限制绕过去。

把一次交互拆成四步

演示页面展示完整轨迹,方便对照数据从哪里来:

  1. 模拟模型返回 search_products 调用,参数是通勤双肩包和预算 50000 分。
  2. 应用程序校验参数,执行只读工具,并校验工具返回的商品数据。
  3. 模拟模型读取商品结果,生成上面的最终 JSON。
  4. 前端解析结果、检查商品 ID 和字段,再交给固定卡片组件。

这里的 function_call 和 function_call_output 用来说明调用与结果的关联,演示保留同一个 call_id。它们都是本地夹具,不能把轨迹面板里的 JSON 当成真实模型服务的运行记录。

为了让读者集中观察数据边界,查询语句和预算被固定在代码里。这个版本没有自然语言理解能力,也没有多轮规划;接入真实模型时,需要替换模拟响应,再增加拒答、超时、调用次数上限等处理。

先检查工具数据,再生成卡片

假设工具返回:

json
[
  { "id": "bag-01", "name": "轻量通勤双肩包", "priceCents": 29900 },
  { "id": "bag-02", "name": "防泼水通勤双肩包", "priceCents": 42900 }
]

“这是我们的工具”也不等于数据永远正确。接口迁移、字段重命名或上游异常,都可能让 priceCents 消失。因此示例在工具结果进入后续步骤前,检查数组长度、ID、名称、价格及重复 ID;价格必须是非负的安全整数。

如果一件商品缺价格,本例停止整批展示并提示错误,不用零元兜底,也不让模型补齐。整批停止是一种明确的简化策略。如果业务允许部分结果,接口需要额外说明哪些商品失败,以及为什么缺失,不能静默丢掉。

价格通过校验,只代表它来自这次演示查询且格式有效。真实商城仍然需要服务端的价格、库存与结算校验,浏览器里的数据不能作为交易依据。

从 unknown 开始接收模型结果

下面这行写法没有提供运行时保护:

typescript
const result = JSON.parse(raw) as ProductCards;

TypeScript 的类型断言会在编译后移除,不能替你检查外部 JSON。这个边界应从 unknown 开始,逐步确认它的结构,再生成页面内部使用的 Card[]。TypeScript 类型断言说明、类型收窄说明

完整校验函数保存在示例的 src/core.ts 中,关键合并逻辑如下:

typescript
const product = products.find(
  candidate => candidate.id === item.productId
);
if (!product) {
  throw new Error(
    `模型引用了本次工具结果中不存在的商品:${item.productId}`
  );
}
return { ...product, reason: item.reason };

这段逻辑运行前,函数还会限制原始 JSON 长度、卡片数量和推荐理由长度,拒绝重复商品,以及卡片上除 productId、reason 之外的字段。JSON 能解析只是第一关,字段正确也不代表商品引用有效。

示例最多接受三张卡片、每条理由最多 180 个字符。这样的限制让异常结果更容易处理,也能避免一段超长输出把移动端页面撑得难以使用。实际项目应根据业务确定上限,并在服务端同步执行。

固定组件渲染,文本只作为文本

卡片布局由前端代码定义。推荐理由通过 Vue 文本节点展示,商品价格从校验后的整数分格式化。示例使用 Vue 渲染函数;换成模板,核心写法也是 {{ card.reason }}。

不要把模型输出拼成 Vue 模板,也不要直接放进 v-html。Vue 会转义普通文本节点,但主动解释 HTML 会改变这个安全边界;动态 URL 也需要单独校验,不能因为它出现在 JSON 中就直接信任。Vue 安全说明

演示专门准备了包含 <img src=x onerror="alert(1)"> 的推荐理由。预期是页面原样显示这些字符,不生成图片元素、不执行事件。这只能验证当前文本渲染路径,不能推导出整个 Agent 已具备完备的注入防御。

页面同样需要清楚的状态:初始态等待用户运行;执行时禁用重复提交;成功时展示卡片;失败时清空旧结果,显示原因和恢复入口。旧卡片如果一直留着,用户很容易把上一次成功结果误认为本次查询的结果。

把失败场景做成可以点击的开关

只看两张正常卡片,很难知道工程边界是否生效。演示页提供以下场景,可以直接切换并观察轨迹:

场景 预期行为
正常结果 展示 299 元、429 元两张商品卡片
未知商品 ID 拒绝展示,不补造商品
工具缺少价格 停止流程,不显示零元卡片
非法 JSON 显示结果格式错误,保留恢复入口
推荐理由包含 HTML 按普通文本显示,不创建 HTML 元素
工具首次失败 显示错误;用户重试后重新执行只读查询

“重试”在这里有严格的适用范围:工具只有查询,不修改数据。不要把这个重试按钮原样复制到退款、提交订单等操作上。真实写操作超时后,应先核对状态并配合服务端幂等机制,不能仅凭页面报错判断没有执行。

运行示例与核对结果

打开离线交互演示 · 下载完整源码与预构建页面

下载解压后,直接打开 index.html 即可操作;Vue 已包含在本地构建产物中,不需要密钥或 CDN。也可以在解压目录启动一个静态服务:

Bash
python3 -m http.server 8000

然后访问 http://localhost:8000。若要修改 TypeScript 源码并重新构建,请按包内 README 安装依赖,运行类型检查、测试和构建命令。

本次类型检查通过,7 个核心离线测试通过;浏览器实际操作了六个场景,工具失败后重试恢复,HTML 文本没有生成图片元素或触发弹窗。1440 像素桌面与 390 像素手机视口均未出现横向溢出,演示页没有发出外部网络请求。具体结果保存在源码包的验证说明中。这些检查不包含真实模型调用、真实商品服务或生产业务收益。

接到业务里,下一步改哪里

先在服务端接入模型和商品服务,保留“工具事实 → 模型选择与解释 → 校验 → 组件渲染”的数据路径。服务端负责工具允许列表、参数校验、账号权限、调用上限和结果约束;前端继续负责结果校验、状态展示与恢复交互。

推荐理由仍然可能包含缺乏依据的描述。这里的“轻量”“防泼水”来自固定演示文案,没有实现属性证据校验。接入真实模型后,可以把可引用属性一起放进工具结果,并要求理由关联这些属性;需要合规承诺的描述还应使用受控字段或人工审核。

完成这一步,前端就有了一条可维护的接入路径:工具提供商品事实,模型给出选择与解释,应用程序检查两者的关联,组件负责稳定展示。下次再加对比表或追问选项时,可以继续定义新的结果类型和固定组件。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2022-2023 alan_mf
  • Visitors: | Views:

请我喝杯咖啡吧~

支付宝
微信