AI 能生成界面,但生不出确定性:动态 UI 的边界与落地取舍
能生成的放界面,不能生成的放引擎
text
[读者] 前端与平台架构师,正在评估 AI 生成页面
[痛点] 生成很快,但权限、状态与交易没人敢签字
[现在读] 需求已经压到页面上,再靠人肉写表单顶不住
[读完] 能划出可变边界,并落一个确定性的最小 V1
text
[旧方案] 每张报表手写页面 + 直连业务写接口
|
v
[新需求] 页面按需生成,配置交付从天级压到小时级
|
v
[冲突] 生成物是概率性的,业务规则必须是确定性的
|
v
[后果] 页面越灵活,权限与交易越不可审计
我是老李,在深圳带一个企业数据平台的前端与中台小组。这套系统管着三百多张报表和配置台页面,客户是结算、预算、运营三类业务方。
每张页面背后都是真业务:结算口径、审批状态、预算冻结。以前需求两周一批,现在业务方的期望是今天提、明天看到。于是团队开始试一条更激进的路线------让模型直接吐页面描述。
生成式界面到底把成本降在哪一层?
如果页面描述是模型写的,谁为它的越权和重复提交负责?
先从哪类场景切,才不至于上线当天就回滚?
01、故事
季度结算前的第三个礼拜,结算组负责人在群里发了一张截图:某张「资金冻结配置页」上,一个本不该出现的「确认划扣」按钮被点了下去。
这个按钮不是我们写的。它是上一轮「动态页面」试验里,由模型根据一段业务描述生成的。
我们当时正在做的事,任务是:两周内把 20 张结算配置页从手写 React 改成「描述生成 + 动态渲染」,同时不能动权限和审批的任何一条规则。
当时的方案很朴素:页面代码在前端仓库,每张报表一个组件;写操作直接打后端 REST,权限靠前端隐藏按钮,后端只做参数校验。这套方案撑了三年,靠的是需求少、人熟、节奏慢。
变量出现在今年:业务方开始频繁调整口径,一张配置页一个月改四五次。手写页面的成本不是「写一次」,而是「改十次」。于是我们把页面描述交给模型,前端只保留一个通用渲染器。
第一次跑通很快,一下午就生成了 8 张页面。问题也来得很快:生成器不知道我们的权限模型,也不知道「冻结」和「划扣」之间隔着一条状态机。它只知道:业务描述里写了「确认划扣」,那就该有一个按钮。
页面是生成了,但生成出来的不是页面,是一个没有边界的入口。
需求催人急
手写难为继
页面可生成
规则不可移
02、问题
旧方案失效的地方很具体:不是页面写不出来,而是页面里的「可写动作」没人管。
业务影响是直接的:一次误点让冻结金额记错,结算窗口被拖了两天,最后靠人工冲正。
技术表现更值得记录:校验散落在三个地方------前端隐藏按钮、后端参数校验、数据库约束;权限判断依赖前端;写接口没有幂等键;状态迁移没有前置条件;页面描述本身没有任何 schema 约束,模型给什么就渲染什么。
所以这一轮改造的完成标准必须是可验证的,而不是「感觉更灵活了」:
- 页面描述必须先通过一份固定 schema 校验才能下发,未通过的比例可统计;
- 所有写动作必须命中服务端注册表,未注册一律拒绝;
- 任意写动作必须携带幂等键,同键重放不产生第二次副作用;
- 状态迁移必须由前置条件判定,非法迁移返回明确错误码;
- 每一次动作都能在审计日志里还原「谁、在哪个页面、用什么参数、改了什么状态」。
这五条里,前两条解决「不该出现的按钮」,后三条解决「按钮被点两下」。
旧路已到头
新路待验证
标准先落纸
再谈快与省
03、原理
这一节只讲本篇真正用得上的原理:把系统拆成「描述」和「语义」两层。
描述层是 UI schema:页面长什么样、有哪些字段、按钮叫什么。它的特征是------可生成、可丢弃、可重放、允许不确定。改错了重新生成一份就行。
语义层是动作定义、权限、状态迁移、幂等、事务。它的特征是------必须唯一、必须确定、必须可审计。同一件事只能有一个真源。
反直觉判断:动态生成的范围越大,确定性执行引擎的边界就要收得越窄。
直觉上,既然页面都能生成了,顺手把动作也生成出来似乎更省事。但动作一旦被生成,系统就失去了唯一真源:同一个操作在 A 页面叫 submit、在 B 页面叫 confirm,权限对不上、幂等对不上、审计也对不上。生成能力越强的团队,越容易踩这个坑,因为「能生成」的诱惑太大了。
第二个反直觉判断:生成界面的收益不在「少写页面」,而在把页面从代码降级为数据。
代码要评审、要发版、要回滚;数据可以校验、可以拒绝、可以按租户下发。只有页面变成数据,配置交付的节奏才会真正变化。而数据必须配一份 schema,否则它只是「另一份更难懂的代码」。
为什么低风险场景先行的成本更低?报表、配置台、数据分析这三类场景的共同点是读多写少、副作用可逆、失败可重试,它们的语义面很薄。反过来,交易、扣款、结算这类场景语义面极厚,任何一处不确定都要用对账去补。
关于外部模型能力:本篇参考的模型页面在写作时无法访问核验,因此凡涉及该来源的具体能力描述,均需按当前官方文档核验,本文不引用其结论。
描述可重放
语义须唯一
越自由生成
越收紧执行
04、架构
text
[输入] AI 生成的 UI Schema + 用户动作请求
|
v
[模块] Schema 校验层 / 组件白名单 / 动作注册表
|
v
[数据/状态] 规范化 IR + 权限策略 + 状态机 + 幂等表
|
v
[处理] 渲染 → 动作分发 → 参数校验 → 事务执行
|
v
[输出] 界面 / 状态变更 / 审计日志
边界:校验层是唯一入口,前端渲染器不做业务兜底;动作注册表只存在于服务端;状态变更只能由状态机产生,页面不许直接改状态。
收益:页面交付从「发版」变成「下发」;权限、幂等、审计三件事集中到一处,改动有单点;违规描述在下发前就被拒绝,前端不需要写防御式代码。
代价:schema 的表达力被主动限制,复杂交互只能退回手写;每新增一个组件要改白名单并评审;渲染器需要长期维护,且它本身成了关键路径。
适用条件:读多写少、副作用可逆、业务规则相对稳定、业务方愿意接受「先配置后定制」的交付方式。任何一条不满足,收益都会被风险吃掉。
输入皆有界
中间设关卡
状态归一处
输出可追溯
05、实战一次
这一节落一个最小但完整的 V1:一份能校验的页面描述,加一个确定性的动作入口。
环境与依赖:Node.js 20 LTS、TypeScript 5.x、Express 4.x、Ajv 8.x,具体版本需按各自官方文档核验当前发布。
初始化:
bash
mkdir dynamic-ui-v1 && cd dynamic-ui-v1
npm init -y
npm i express ajv
npm i -D typescript tsx @types/express @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist --strict
三个文件:src/actions.ts 放动作注册表,src/ui-schema.ts 放描述校验,src/server.ts 放 HTTP 入口。
核心实现一,UI 描述校验与组件白名单:
ts
import Ajv from 'ajv';
const ajv = new Ajv({ allErrors: true, strict: false });
export const COMPONENT_WHITELIST = ['table', 'form', 'text', 'number', 'select', 'button'] as const;
const nodeSchema = {
type: 'object',
required: ['component'],
properties: {
component: { type: 'string', enum: [...COMPONENT_WHITELIST] },
children: { type: 'array', items: { type: 'object' } },
props: { type: 'object' },
},
additionalProperties: false,
};
const pageSchema = {
type: 'object',
required: ['pageId', 'root'],
properties: {
pageId: { type: 'string', pattern: '^[a-z0-9._-]{1,64}$' },
title: { type: 'string', maxLength: 64 },
root: nodeSchema,
},
additionalProperties: false,
};
const validatePage = ajv.compile(pageSchema);
export function validateUiSchema(doc: unknown) {
if (!validatePage(doc)) {
throw new Error('UI_SCHEMA_REJECTED: ' + ajv.errorsText(validatePage.errors));
}
return doc as { pageId: string; title?: string; root: Record<string, unknown> };
}
注意这里的 children 只做了浅校验,递归校验是 V1 的已知缺口,第六章会因为这个缺口出一次故障。
核心实现二,动作注册表:
ts
export type ActionCtx = {
user: string;
roles: string[];
idempotencyKey: string;
};
export type ActionDef<P> = {
name: string;
roles: string[];
params: object;
run: (params: P, ctx: ActionCtx) => Promise<unknown> | unknown;
};
const registry = new Map<string, ActionDef<any>>();
export function defineAction<P>(def: ActionDef<P>) {
if (registry.has(def.name)) throw new Error('DUPLICATE_ACTION: ' + def.name);
registry.set(def.name, def);
}
export function getAction(name: string) {
return registry.get(name);
}
核心实现三,唯一的写入口:
ts
import express from 'express';
import Ajv from 'ajv';
import { randomUUID } from 'node:crypto';
import { defineAction, getAction, type ActionCtx } from './actions.js';
import { validateUiSchema } from './ui-schema.js';
const ajv = new Ajv({ allErrors: true, strict: false });
const app = express();
app.use(express.json({ limit: '256kb' }));
// V1 用内存表;生产必须换成带唯一索引的持久表
const idempotency = new Map<string, unknown>();
const orders = new Map<string, { state: string; amount: number }>([
['O-1', { state: 'draft', amount: 1200 }],
]);
defineAction<{ orderId: string; memo?: string }>({
name: 'order.submit',
roles: ['finance'],
params: {
type: 'object',
required: ['orderId'],
properties: {
orderId: { type: 'string', minLength: 1 },
memo: { type: 'string', maxLength: 128 },
},
additionalProperties: false,
},
run: (params) => {
const order = orders.get(params.orderId);
if (!order) throw new Error('ORDER_NOT_FOUND');
if (order.state !== 'draft') throw new Error('ILLEGAL_STATE_TRANSITION');
order.state = 'submitted';
return { orderId: params.orderId, state: order.state };
},
});
// 页面描述下发前必须先过校验
app.post('/api/pages', (req, res) => {
try {
const page = validateUiSchema(req.body);
return res.json({ code: 'OK', pageId: page.pageId });
} catch (e) {
return res.status(422).json({ code: 'UI_SCHEMA_REJECTED', message: (e as Error).message });
}
});
// 唯一写入口:动作必须注册,参数必须校验,权限必须在服务端判
app.post('/api/action/:name', async (req, res) => {
const action = getAction(req.params.name);
if (!action) return res.status(404).json({ code: 'ACTION_NOT_REGISTERED' });
const ctx: ActionCtx = {
user: req.header('x-user') ?? 'anonymous',
roles: (req.header('x-roles') ?? '').split(',').filter(Boolean),
idempotencyKey: req.header('x-idempotency-key') ?? randomUUID(),
};
if (!action.roles.some((r) => ctx.roles.includes(r))) {
return res.status(403).json({ code: 'FORBIDDEN' });
}
const validate = ajv.compile(action.params);
if (!validate(req.body)) {
return res.status(400).json({ code: 'INVALID_PARAMS', errors: validate.errors });
}
const cacheKey = `${ctx.user}:${action.name}:${ctx.idempotencyKey}`;
if (idempotency.has(cacheKey)) {
return res.json({ code: 'OK', replayed: true, data: idempotency.get(cacheKey) });
}
try {
const data = await action.run(req.body, ctx);
idempotency.set(cacheKey, data);
return res.json({ code: 'OK', replayed: false, data });
} catch (e) {
return res.status(409).json({ code: (e as Error).message });
}
});
app.listen(3000, () => console.log('listening on :3000'));
启动与第一条验证:
bash
npx tsx src/server.ts
# 示例输出
# listening on :3000
curl -s -X POST localhost:3000/api/action/order.submit \
-H 'content-type: application/json' \
-H 'x-user: u1' -H 'x-roles: finance' -H 'x-idempotency-key: k-1' \
-d '{"orderId":"O-1"}'
# 示例输出
# {"code":"OK","replayed":false,"data":{"orderId":"O-1","state":"submitted"}}
同一个幂等键再发一次,模拟按钮被点两下或网络重放:
bash
curl -s -X POST localhost:3000/api/action/order.submit \
-H 'content-type: application/json' \
-H 'x-user: u1' -H 'x-roles: finance' -H 'x-idempotency-key: k-1' \
-d '{"orderId":"O-1"}'
# 示例输出
# {"code":"OK","replayed":true,"data":{"orderId":"O-1","state":"submitted"}}
越权与未注册动作:
bash
curl -s -X POST localhost:3000/api/action/order.submit \
-H 'content-type: application/json' -d '{}'
# 示例输出
# {"code":"FORBIDDEN"}
curl -s -X POST localhost:3000/api/action/order.delete \
-H 'content-type: application/json' -d '{}'
# 示例输出
# {"code":"ACTION_NOT_REGISTERED"}
未在当前环境实测,以上为预期结果。
V1 到此跑通了三件事:页面描述能被拒绝,动作能被白名单拦住,重复提交不会产生第二个副作用。同时留下三个明确缺口------描述里的 children 没有递归校验、幂等表在内存里、渲染器还没有对未知组件的处理策略。
先跑一条线
再谈千张页
最小可验证
胜过全蓝图
06、排查
上线第三天,两条故障链同时来了。
诊断链一:页面白屏。现象是 8 张生成页面里有 2 张打开即白屏,控制台只有一行 Unknown component: raw-html。怀疑是渲染器的动态 import 路径写错,或者打包漏了组件。检查方式是先把落库的页面描述原样打出来看一眼。证据是:描述里 root.children[0].component 的值是 raw-html,且 props 里带着一整段 HTML 字符串。根因有两个:渲染器对白名单外的组件没有拒绝策略,走的是「查不到就渲染成空」的分支;入库前的校验只校验了最外层,没有递归校验 children。修复方向是把校验层放到入库前并且递归,渲染器对未知组件渲染为可审计占位而不是静默为空。
错误尝试: 我们一开始的做法是在渲染器里包一层 try/catch,出错就渲染兜底页。这是错的,因为校验被放到了渲染期------错误发生时页面已经进过一次不确定的渲染,而且错误只有前端日志看得见,服务端完全不知道有人下发了一份非法描述。校验的位置比校验的强度更重要。
诊断链二:同一张单据被提交两次。现象是结算组反馈,同一张 draft 单据出现了两条 submitted 记录,间隔 12 毫秒。怀疑是数据库事务没生效,或者后端并发写没有加锁。检查方式是捞审计日志,按 orderId 聚合,对比两条记录的入参与请求头。证据是:两条记录入参完全一致,且都没有 x-idempotency-key 请求头------服务端在缺省时用 randomUUID() 生成,等于每次请求都是新键。根因有两个:幂等键由服务端生成而不是调用方给定,等价于没有幂等;动作内部只判断了「单据存在」,没判断状态迁移是否合法,所以第二次提交时状态已经是 submitted 也照样通过。修复方向是幂等键必须由页面在渲染时生成并随动作提交,服务端只负责存储与判重;动作内部必须显式校验状态前置条件。
两条链的共同根因其实是同一个:我们把「正确性」交给了运行时兜底,而不是交给入口处的约束。
现象莫轻信
证据定根因
兜底非校验
白名单是墙
07、优化
V2 完全基于第六章的证据,一共四处修改。
修改一,描述校验变成递归,并且前置到入库。根因是校验只到第一层、且落在渲染期。修改方式是让 validateUiSchema 递归遍历 root 与 children,逐层校验 component 是否在白名单内、props 是否属于该组件允许的字段。这样做的原因是非法描述在入库前就被拒绝,服务端留下记录,前端不再需要防御式代码。新行为是:POST /api/pages 对含未注册组件的描述返回 422,错误信息里带路径。验证方式是两条用例------children 里塞 raw-html 期望 422,合法描述期望 200 并能拿到 pageId。
修改二,幂等键由页面生成、服务端持久化。根因是服务端生成幂等键等于没有幂等。修改方式是页面在渲染按钮时生成一个 UUID,随请求头一起提交;服务端把幂等结果写进 (user, action, key) 唯一索引的表里。这样做的原因是重复提交的唯一识别点应该在调用方,服务端只做存储与判重。新行为是并发两次同键请求,只有一次进入业务逻辑。验证方式是并发发两次同键请求,检查动作审计表里只有一条状态变更记录。
修改三,状态迁移集中到状态机。根因是状态判断散落在每个动作内部,容易漏写。修改方式是抽出一张声明式迁移表,例如 order.submit 只允许从 draft 到 submitted,执行引擎在调用 run 之前统一校验。新行为是非法迁移返回 ILLEGAL_STATE_TRANSITION,且不进入业务逻辑。验证方式是对已 submitted 的单据再次提交,期望 409 加该错误码。
修改四,渲染降级为可审计占位。根因是未知组件静默为空导致白屏不可诊断。修改方式是渲染器遇到白名单外组件,渲染一块带组件名的错误占位,并上报一条前端事件。新行为是即便非法描述漏网到前端,也能一眼看出是哪个组件、来自哪个页面。
四处修改都不追求复杂,只追求同一件事:错误在入口处被拦住,在日志里被看见。
根因既已明
改在入口处
幂等加状态
一次即一次
08、演进
text
[同一输入] 用户点击「提交」按钮
|
+--[V1] 直连业务写接口,前端隐藏按钮做权限
| 幂等键服务端随机生成 / 无状态前置条件
| 代价:重复提交、非法迁移、白屏不可诊断
|
+--[V2] 动作注册表 → 权限 → 参数校验 → 状态机 → 幂等 → 事务
| 代价:多一层校验与注册维护成本,描述表达力受限
|
[Trade-off]
得到:可校验、可判重、可审计的确定性写入口
失去:页面描述的自由度;每加一个动作要改服务端
适用边界:读多写少、副作用可逆、业务规则相对稳定的场景
逐项对比。正确性上,V1 依赖前端自觉和并发运气,V2 由状态机与幂等表保证。稳定性上,V1 白屏只有前端日志,V2 未知组件有占位与上报。复杂度上,V1 代码更少,但心智负担分散在每个人手里;V2 集中了一个校验层和一个注册表,需要有人长期维护。成本上,V1 省下的是开发时间,付出的是线上对账;V2 付出的是约束,省下的是事故。适用范围上,V2 适合配置台、报表、数据分析;强交互的编辑器类页面会把 schema 撑爆,不如老实手写。遗留问题有三个:描述的多租户下发、schema 的版本兼容、以及生成侧的反馈闭环。
同入不同路
取舍各分明
得在快与省
失在自由度
09、洞见
9.1 界面可以生成,业务语义不能生成
反直觉判断:AI 让「写页面的成本」下降,但真正决定这次改造成败的,是那些它根本不该碰的部分。
页面是描述,描述错了可以重生成;权限和状态是语义,语义错了要靠对账和后置修正。所以架构的第一刀不是切「哪些页面接进来」,而是切「哪些东西永远不生成」。
9.2 校验的位置比校验的强度更重要
反直觉判断:一个放在渲染期的强校验,不如一个放在入库期的弱校验。
第六章里两次故障的根因是同一个------我们用运行时兜底替代了入口处的约束。try/catch 看起来更鲁棒,实际上把错误的可见性和可追溯性一起吞掉了。校验层必须是唯一入口,且必须在写路径之前。
9.3 生成能力越强,越要有一个确定性执行引擎
反直觉判断:模型越强,服务端的约束应该越硬,而不是越松。
因为生成能力的提升会让调用面迅速扩大:今天生成页面,明天生成表单,后天生成工作流。如果底层没有一个幂等、可审计、状态唯一的执行引擎,上面生成得越快,下面崩得越快。确定性引擎不是创新的阻碍,而是创新的安全垫。
9.4 低风险场景先行是控制爆炸半径
报表、配置台、数据分析三类场景的共同特征是读多写少、可重试、失败可回滚。先用它们验证三件事:生成的描述能不能被稳定校验、动作注册表够不够用、业务方能不能接受「先配置后定制」。这三件事验证完,再去碰交易。
生成非交付
边界即架构
低险先行试
高险缓一步
10、系统落地
原来有什么:手写页面加直连写接口,权限靠前端隐藏按钮,页面改动必须发版。
本篇新增什么:一个 UI Schema 校验层,含组件白名单与递归校验,在入库前拒绝非法描述;一个服务端动作注册表,动作名唯一、角色绑定、参数 schema 齐全;一个由状态机加幂等表组成的确定性执行引擎;一个对未知组件的可审计降级策略。
现在能做什么:页面描述可以下发、可以拒绝、可以灰度;写动作有唯一入口,重复提交不产生第二次副作用;每一次状态变更都能在审计里还原到参数级。
还缺什么:描述的版本兼容与多租户下发;生成侧还没有接上校验层的反馈闭环,模型仍会生成未注册组件;渲染器的组件覆盖率与性能没有量化;面向不可逆写场景的语义面校验尚未验证。
下一步如何演进:把校验层拒绝的原因回灌给生成侧,让模型在生成时就知道哪些组件不存在;把动作注册表升级为按租户可配置;在低风险场景跑够周期之后,再评估是否放一两个可逆写场景进来。
关于外部资料:本篇参考的模型能力页面在写作时无法访问核验,凡涉及该来源的具体能力表述均需按当前官方文档核验,本文不引用其结论作为论据。
原有手写页
今添约束层
能跑能审计
待补版本账
11、小结
text
Q1 → 生成式界面降的是「页面描述」的成本,不是业务语义的成本
Q2 → 越权与重复提交由服务端动作注册表 + 状态机 + 幂等表负责,不由模型负责
Q3 → 从报表、配置台、数据分析切,读多写少、副作用可逆、失败可重试
状态 → 一套可校验、可白名单、可幂等的动态 UI V1,并带有明确的已知缺口清单
三问皆有答
状态可交付
边界已划清
引擎定乾坤
12、作业
12.1 理解题:为什么幂等键必须由调用方生成,而不是服务端?
参考答案:幂等的前提是「同一个操作」能被同一个标识识别。服务端在缺省时生成随机键,每次请求都是新键,等价于没有幂等。调用方在渲染动作时生成键,同一个按钮实例的重复点击与网络重试共享同一个键,服务端才有判重的可能。
12.2 实战题:给第五章的 validateUiSchema 加上递归校验,并给出两条验证用例。
参考答案:递归遍历 root 与 children,对每个节点套用同一份 nodeSchema,同时把 props 的允许字段按组件类型做白名单。用例一:children 中塞入 component: 'raw-html',期望抛出 UI_SCHEMA_REJECTED 且错误里带节点路径;用例二:合法描述,期望返回 200 并能拿到 pageId。
12.3 排障题:某张生成页面点击按钮后返回 200,但数据没变。你会按什么顺序排查?
参考答案:先看审计日志里有没有这次动作记录。没有记录,说明请求没打到动作路由,检查按钮绑定的动作名是否注册;有记录但 replayed 为 true,说明幂等键被复用,检查键是在渲染期生成还是在点击期生成;有记录且 replayed 为 false 但状态没变,检查状态机前置条件是否命中了未生效分支,以及事务是否真的提交。
12.4 架构判断题:业务方要求「把资金划扣页面也接进动态生成」,你是否同意?说明理由和前置条件。
参考答案:不同意直接接入。划扣属于不可逆写、语义面厚、失败要靠人工对账,与本篇的适用边界冲突。若必须做,前置条件是动作注册表覆盖全部划扣动作并经过评审、状态机与幂等表落库并通过并发用例、审计可还原到参数级、存在灰度与回滚路径,并且先在可逆场景跑够周期。
学会划边界
练会写校验
排障看证据
判断守确定
13、思考
回到最初那个画面:一个不该存在的按钮被点了下去。它提醒的不是「模型不可靠」,而是我们把两件不同的事混在了一起------生成界面是描述问题,权限与交易是语义问题。
描述可以交给模型,因为它可重放、可丢弃、可重生成;语义必须留给确定性执行引擎,因为它要唯一、要幂等、要被审计。这两层的边界画在哪儿,决定了这次改造是提效还是事故。
所以我的判断是:先问「这个动作错了会怎样」,再决定「这个页面能不能生成」。可逆的、读多的、失败能重试的,先上;不可逆的、写多的、要对账的,等引擎稳了再说。
二阶思维不是想得更远,而是先问一层「那接下来会发生什么」。
生成是手段
确定是底线
先小后大走
慢即是快行