生成式 UI 的风险通常不在「模型会不会画界面」,而在「模型被当成了前端框架」。把 LLM 输出收成带版本号的数据协议,用 JSON Schema 白名单卡住组件名、属性和嵌套,React 只做递归映射与未知类型降级,这层渲染才能测试、才能回滚。模型负责填协议,前后端共同解释协议,而不是共同祈祷提示词生效。
从拼 Markdown 到发明组件
生成式界面常见的演进路径很短:先让模型输出 Markdown,再放开一小段 HTML,最后发现卡片、筛选、步骤条塞不进文档格式,于是提示词里开始出现「请输出 <ProductCard price={99} />」。
这条路会稳定地露出三类问题。
第一是注入。Markdown 转 HTML、以及把模型输出写进 innerHTML / dangerouslySetInnerHTML,都会把 XSS 从理论变成日常。React 对文本子节点默认转义,这是保护;一旦为了「更像页面」把转义关掉,协议就退化成浏览器解析器。
第二是布局抖动。没有组件契约时,模型今天给三列,明天给一段散文加一个表;流式输出还会把半截标签画到屏幕上。前端无法预留结构,也无法区分「还在生成」和「已经是一棵合法树」。
第三是发明。模型会写出不存在的组件名、记错 prop、把事件编成字符串、把树嵌套到十几层。提示词里写「只能用这些组件」只能降低概率,不能当运行时保证。白名单必须发生在校验器里,而不是发生在对模型的礼貌请求里。
所以不要把模型当成前端框架。它适合填写一份事先定义好的文档;渲染、事件、样式、无障碍,仍然是你仓库里的代码的责任。
把 LLM 输出当成有版本的数据协议
可回滚的生成式 UI,核心不是更长的提示词,而是一份可以校验、可以存盘、可以拒绝的协议:
- 文档带
version,渲染器按版本选实现,不猜测。 - 节点
type只有白名单。 - 每个
type的props有类型、必填、枚举和长度上限。 - 子节点复用同一套节点定义;深度和节点总数用一次遍历限制。
- 未知字段直接拒绝。默默丢掉多余字段再渲染,等于允许模型「差不多就行」。
动作不要交给模型随便写。按钮的 action 应是枚举(例如 submit / reset / noop),真正的提交、跳转、请求落在运行时。允许模型输出 URL、内联事件或任意函数名,协议会再次滑回 HTML。
JSON Schema 是这份协议的运行时形态,给后端结构化解析和前端渲染器共用。需要诚实说一句:JSON Schema 能约束形状、枚举和数组长度,但对「整棵树最深几层」没有一等能力。深度和节点上限放在校验后的遍历里,这是协议的一部分,不是事后补丁。
校验失败时不要「修一修再渲染」。回滚点应是上一份合法文档,或服务端准备好的静态骨架。半份 JSON 比错误 HTML 更危险,因为它看起来像成功。
Schema 白名单:名字、属性、再补深度
下面这份 Schema 对应协议 1.0。additionalProperties: false 是否决发明字段的关键。oneOf 按 type 分支;不要在 Schema 里写「组件可以任意扩展」——白名单的价值就是拒绝。
Ajv 8 对 compile、errors、const、enum、$ref 的支持是稳定的,下面模块可以直接跑(先安装 ajv)。
import Ajv from "ajv";
const uiSchema = {
$id: "https://example.local/ui-protocol-v1.json",
type: "object",
additionalProperties: false,
required: ["version", "root"],
properties: {
version: { const: "1.0" },
root: { $ref: "#/$defs/node" },
},
$defs: {
node: {
oneOf: [
{ $ref: "#/$defs/Text" },
{ $ref: "#/$defs/Button" },
{ $ref: "#/$defs/Stack" },
],
},
Text: {
type: "object",
additionalProperties: false,
required: ["type", "props"],
properties: {
type: { const: "Text" },
props: {
type: "object",
additionalProperties: false,
required: ["content"],
properties: {
content: { type: "string", minLength: 1, maxLength: 500 },
tone: { enum: ["body", "muted"] },
},
},
},
},
Button: {
type: "object",
additionalProperties: false,
required: ["type", "props"],
properties: {
type: { const: "Button" },
props: {
type: "object",
additionalProperties: false,
required: ["label", "action"],
properties: {
label: { type: "string", minLength: 1, maxLength: 40 },
action: { enum: ["submit", "reset", "noop"] },
},
},
},
},
Stack: {
type: "object",
additionalProperties: false,
required: ["type", "props", "children"],
properties: {
type: { const: "Stack" },
props: {
type: "object",
additionalProperties: false,
required: ["direction"],
properties: {
direction: { enum: ["vertical", "horizontal"] },
gap: { type: "integer", minimum: 0, maximum: 48 },
},
},
children: {
type: "array",
minItems: 1,
maxItems: 16,
items: { $ref: "#/$defs/node" },
},
},
},
},
};
const ajv = new Ajv({ allErrors: true, strict: true });
const validateDoc = ajv.compile(uiSchema);
function walkLimits(node, depth, acc) {
acc.nodes += 1;
if (depth > 6 || acc.nodes > 80) return false;
if (node.type === "Stack") {
return node.children.every((child) => walkLimits(child, depth + 1, acc));
}
return true;
}
export function parseUIDocument(input) {
if (!validateDoc(input)) {
return { ok: false, errors: validateDoc.errors };
}
if (!walkLimits(input.root, 1, { nodes: 0 })) {
return { ok: false, errors: [{ message: "nesting or node limit exceeded" }] };
}
return { ok: true, doc: input };
}
const sample = {
version: "1.0",
root: {
type: "Stack",
props: { direction: "vertical", gap: 12 },
children: [
{ type: "Text", props: { content: "确认提交这份表单?", tone: "body" } },
{ type: "Button", props: { label: "提交", action: "submit" } },
],
},
};
console.log(parseUIDocument(sample));
后端应把同一份 Schema 当作结构化输出的解析边界:JSON mode 或工具调用的结果先走 parseUIDocument,失败则重试或降级,不要把原始字符串交给前端「再试试看」。前后端对不齐的时候,发明组件会从模型侧漏到浏览器侧。
在 React 里递归渲染,未知类型必须降级
渲染器是一张 type -> 组件 映射,加上递归。不要 eval,不要拼接 JSX 字符串。未知 type 必须降级:既不要整页白屏,也不要把原始对象展示给用户。
import { useMemo, type ReactNode } from "react";
type UINode =
| { type: "Text"; props: { content: string; tone?: "body" | "muted" } }
| {
type: "Button";
props: { label: string; action: "submit" | "reset" | "noop" };
}
| {
type: "Stack";
props: { direction: "vertical" | "horizontal"; gap?: number };
children: UINode[];
};
type UIDocument = { version: "1.0"; root: UINode };
function Fallback() {
return (
<div role="note" style={{ color: "#666", fontSize: 13 }}>
该内容暂不可用
</div>
);
}
const registry = {
Text(node: Extract<UINode, { type: "Text" }>) {
return (
<p
style={{
margin: 0,
color: node.props.tone === "muted" ? "#666" : "inherit",
}}
>
{node.props.content}
</p>
);
},
Button(node: Extract<UINode, { type: "Button" }>) {
const htmlType = node.props.action === "noop" ? "button" : node.props.action;
return <button type={htmlType}>{node.props.label}</button>;
},
Stack(node: Extract<UINode, { type: "Stack" }>) {
return (
<div
style={{
display: "flex",
flexDirection: node.props.direction === "horizontal" ? "row" : "column",
gap: node.props.gap ?? 8,
}}
>
{node.children.map((child, index) => (
<NodeView key={index} node={child} />
))}
</div>
);
},
};
function NodeView({ node }: { node: UINode }) {
const impl = registry[node.type] as ((n: UINode) => ReactNode) | undefined;
if (!impl) return <Fallback />;
return impl(node);
}
export function GenerativeUI({ doc }: { doc: UIDocument }) {
const tree = useMemo(() => <NodeView node={doc.root} />, [doc]);
if (doc.version !== "1.0") return <Fallback />;
return tree;
}
实现上保持克制:文本走 React 子节点,不走 dangerouslySetInnerHTML;Button.action 只映射到真实的 type 或你自己的命令表;key 用稳定 id 更好,模型没给 id 时用索引只适合不可重排的静态树。映射表放在模块顶层,测试可以对着同一份 JSON 做快照,不必启动模型。
流式场景不要把不完整 JSON 丢进这个组件。先在缓冲里拼到可以 JSON.parse、并且 parseUIDocument 通过,再切换视图。若必须边生成边展示,只流式更新 Text.content 这类纯文本叶子,而不是流式输出树结构——半棵树不是渐进增强,是布局抖动的来源。
演进、测试,以及和后端的边界
协议上线后一定会变。新组件、新 prop、弃用旧 type,都不要靠「模型已经知道了」。
可操作的策略很普通:version 是文档级字段,1.0 渲染器拒绝 2.0 的根,而不是猜测;加可选字段时保持旧文档合法;加新 type 时旧渲染器走 Fallback,页面其余部分仍可用;破坏性变更升版本。服务端同时存原始模型输出和校验后的文档,渲染失败时回滚到上一版合法文档,而不是回滚提示词碰运气。
测试对象是协议,不是某一句提示词。合法文档必须渲染出确定的文本和角色;给未知 type、超深嵌套、多余字段、错误 version 各准备一条夹具,断言得到降级而不是抛异常。生成式 UI 的回归,测的是渲染层对非法输入的态度。
白名单不会消灭产品问题。模型仍可能用合法组件堆出难用的信息架构。它解决的是「能不能安全地画出来、能不能撤回」,不是「好不好看」。先把渲染层做小、做可测试,再增加组件,比一上来开放 HTML 便宜得多。
总结
把模型从「写界面的人」降成「填表的人」,生成式 UI 才站得住。JSON Schema 白名单约束组件名与属性,遍历补上深度和节点上限;React 用映射表递归渲染,未知类型降级;version 让协议可演进、文档可回滚。和后端对齐的是同一份 Schema,而不是同一段提示词。渲染层一旦可测试,组件清单才值得变长。
评论