基于 LLM Function Calling 的前端动态表单生成引擎:从 JSON Schema 映射到运行时组件树的端到端实现
一个真实的需求
上个月接了个活:给内部运营平台加一个"智能工单"功能。运营人员用自然语言描述需求,系统自动生成对应的表单让用户填写。
听起来很酷,对吧?
问题来了——LLM 返回的是 JSON Schema,而前端需要的是可交互的组件树。中间隔着一条鸿沟:类型映射、校验规则注入、条件渲染、组件动态加载……每一步都能让你怀疑人生。
这篇文章就聊这个:怎么把 LLM Function Calling 吐出来的 JSON Schema,变成一棵真正能跑的前端表单组件树。
Function Calling 到底给了你什么
先搞清楚起点。当你给 LLM 定义一个 function,它返回的 parameters 本质上就是一份 JSON Schema:
{
"name": "create_ticket",
"parameters": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "工单标题" },
"priority": { "type": "string", "enum": ["low", "medium", "high"] },
"deadline": { "type": "string", "format": "date" },
"attachments": {
"type": "array",
"items": { "type": "string", "format": "uri" }
}
},
"required": ["title", "priority"]
}
}
这份 Schema 告诉你数据长什么样,但不告诉你UI 长什么样。
string 应该渲染成 Input 还是 Textarea?enum 是下拉框还是 Radio?format: "date" 用哪个日期组件?
这就是核心问题:Schema 描述的是数据契约,不是 UI 契约。你需要一套映射引擎把前者翻译成后者。
映射引擎的本质:类型系统到组件系统的编译器
把 JSON Schema 变成组件树,本质上和编译器做的事情一样——词法分析(解析 Schema)、语法分析(构建 UI AST)、代码生成(渲染组件)。
只不过你的"源语言"是 JSON Schema,"目标语言"是 React/Vue 组件树。
第一层:基础类型映射
先解决最简单的问题——把 JSON Schema 的类型映射到组件:
// 类型映射注册表:JSON Schema type → 组件
const typeRegistry: Record<string, ComponentResolver> = {
string: (schema) => {
// format 优先级最高
if (schema.format === 'date') return DatePicker
if (schema.format === 'uri') return UrlInput
// enum 次之
if (schema.enum) return schema.enum.length > 4 ? Select : RadioGroup
// 长文本判断
if (schema.maxLength && schema.maxLength > 200) return Textarea
// 兜底
return Input
},
number: (schema) => {
if (schema.minimum !== undefined && schema.maximum !== undefined) return Slider
return InputNumber
},
boolean: () => Switch,
array: (schema) => {
// 数组套枚举 → 多选
if (schema.items?.enum) return CheckboxGroup
// 普通数组 → 动态列表
return DynamicList
},
object: (schema) => FieldGroup, // 递归处理
}
这层映射看似简单,但藏着一个决策:映射规则的优先级怎么定?
我们的优先级是:format > enum > 其他约束 > type 兜底。为什么?因为 format 是最具体的语义声明,而 type 是最泛化的。越具体的信息,越应该优先决定 UI 形态。
第二层:Schema → UI AST
拿到组件类型还不够,你需要一个中间表示层(IR),我们叫它 UI AST:
interface UINode {
id: string
component: string // 组件标识
field: string // 对应 Schema 的字段路径,如 "address.city"
props: Record<string, any> // 传给组件的 props
rules: ValidationRule[] // 校验规则
children?: UINode[] // 嵌套节点(object / array 场景)
visible?: ConditionExpr // 条件渲染表达式
}
为什么不直接从 Schema 渲染组件,非要搞个中间层?
三个理由:
- 解耦。Schema 变了不用改渲染逻辑,渲染逻辑变了不用改解析逻辑
- 可干预。中间层可以被二次修改——比如运营想调整字段顺序、覆盖默认组件
- 可序列化。UI AST 是纯数据,可以缓存、持久化、跨端复用
这个思路和 Vue 的虚拟 DOM 一模一样——不是直接操作真实 DOM,而是先生成一份描述,再统一渲染。
Schema 解析器:递归下降 + 特征提取
Schema 解析是整个引擎最核心的部分。JSON Schema 支持嵌套、引用($ref)、组合(allOf/oneOf)等复杂特性,你得递归处理:
function parseSchema(
schema: JSONSchema,
path: string = '',
required: string[] = []
): UINode[] {
const nodes: UINode[] = []
for (const [key, fieldSchema] of Object.entries(schema.properties || {})) {
const fieldPath = path ? `${path}.${key}` : key
const isRequired = required.includes(key)
// 递归处理嵌套 object
if (fieldSchema.type === 'object' && fieldSchema.properties) {
nodes.push({
id: generateId(),
component: 'FieldGroup',
field: fieldPath,
props: { label: fieldSchema.description || key },
rules: [],
// 关键:递归下降,把子字段也解析成 UINode
children: parseSchema(fieldSchema, fieldPath, fieldSchema.required || []),
})
continue
}
// 通过注册表解析组件类型
const resolver = typeRegistry[fieldSchema.type as string]
const component = resolver?.(fieldSchema) ?? Input
nodes.push({
id: generateId(),
component: component.name,
field: fieldPath,
props: extractProps(fieldSchema), // 从 Schema 约束中提取组件 props
rules: extractRules(fieldSchema, isRequired), // 约束 → 校验规则
children: undefined,
})
}
return nodes
}
约束到校验规则的翻译
JSON Schema 的约束(minLength、pattern、minimum 等)需要翻译成前端校验规则:
function extractRules(schema: JSONSchema, isRequired: boolean): ValidationRule[] {
const rules: ValidationRule[] = []
if (isRequired) {
rules.push({ type: 'required', message: `${schema.description || '此字段'}不能为空` })
}
// minLength / maxLength → 长度校验
if (schema.minLength) {
rules.push({ type: 'minLength', value: schema.minLength, message: `至少输入 ${schema.minLength} 个字符` })
}
// pattern → 正则校验(LLM 有时会生成正则,需要做安全校验)
if (schema.pattern) {
try {
new RegExp(schema.pattern) // 先验证正则合法性,LLM 给的不一定靠谱
rules.push({ type: 'pattern', value: schema.pattern, message: '格式不正确' })
} catch {
console.warn(`非法正则,已跳过: ${schema.pattern}`) // 防御性编程,别让 LLM 搞崩你
}
}
// enum → 枚举校验
if (schema.enum) {
rules.push({ type: 'enum', value: schema.enum, message: '请选择有效选项' })
}
return rules
}
注意那个 try/catch——永远不要相信 LLM 生成的正则表达式。它偶尔会给你一个语法错误的正则,甚至一个会导致 ReDoS 的正则。防御性编程不是多余的,是必须的。
运行时渲染:从 UI AST 到真实组件树
有了 UI AST,渲染就是一个递归 render 的过程。以 React 为例:
// 组件注册表:字符串标识 → 真实组件
const componentMap: Record<string, React.ComponentType<any>> = {
Input, InputNumber, Select, RadioGroup,
CheckboxGroup, DatePicker, Switch, Textarea,
Slider, UrlInput, DynamicList, FieldGroup,
}
function DynamicForm({ nodes, value, onChange }: DynamicFormProps) {
return (
<Form>
{nodes.map(node => (
<DynamicField key={node.id} node={node} value={value} onChange={onChange} />
))}
</Form>
)
}
function DynamicField({ node, value, onChange }: DynamicFieldProps) {
const Component = componentMap[node.component]
if (!Component) {
// 未注册的组件类型,降级为 Input,别直接崩
console.warn(`未知组件: ${node.component},降级为 Input`)
return <Input placeholder="(降级渲染)" />
}
// 嵌套节点递归渲染
if (node.children?.length) {
return (
<FieldGroup label={node.props.label}>
{node.children.map(child => (
<DynamicField key={child.id} node={child} value={value} onChange={onChange} />
))}
</FieldGroup>
)
}
return (
<FormItem
label={node.props.label}
rules={node.rules}
field={node.field}
>
<Component {...node.props} />
</FormItem>
)
}
整个链路跑通了:
自然语言 → LLM → JSON Schema → UI AST → 组件树 → 用户交互 → 提交数据
设计权衡:几个绕不开的选择
1. 组件映射写死还是可配置?
写死最简单,但业务方总会说"这个字段我想用富文本编辑器"。
我们的做法是默认映射 + Schema 扩展字段覆盖:
{
"type": "string",
"description": "商品详情",
"x-component": "RichTextEditor",
"x-component-props": { "height": 300 }
}
x- 前缀是 JSON Schema 的扩展约定,不会影响标准校验。这样 LLM 生成基础 Schema,业务方可以通过配置覆盖组件选择。
2. LLM 直接生成 UI AST 不行吗?
试过,放弃了。原因:
- LLM 对 UI 组件库的 API 记忆不准确,经常生成错误的 props
- UI AST 结构变了(比如换组件库),所有 prompt 都得重写
- JSON Schema 是标准协议,LLM 训练数据里大量存在,生成质量稳定得多
让 LLM 做它擅长的事(生成结构化数据),让前端引擎做它擅长的事(UI 渲染)。 这是最重要的架构决策。
3. Schema 校验要不要在前端做?
必须做。两个层面:
- 结构校验:LLM 返回的真的是合法 JSON Schema 吗?用
ajv跑一遍 - 安全校验:有没有危险的正则?字段数量是否合理(防止 LLM 幻觉生成 200 个字段)?
function validateSchema(schema: JSONSchema): { valid: boolean; errors: string[] } {
const errors: string[] = []
// 字段数量限制,LLM 偶尔会疯狂输出
const fieldCount = Object.keys(schema.properties || {}).length
if (fieldCount > 30) {
errors.push(`字段数量 ${fieldCount} 超过上限 30,疑似幻觉输出`)
}
// 嵌套深度检查
const maxDepth = getMaxDepth(schema)
if (maxDepth > 4) {
errors.push(`嵌套深度 ${maxDepth} 层,超过上限 4 层`)
}
return { valid: errors.length === 0, errors }
}
你不设防线,LLM 迟早给你一个惊喜。
条件渲染:表单的"活"逻辑
真实表单不是所有字段都永远可见的。比如选了"紧急"优先级,才出现"审批人"字段。
JSON Schema 原生支持 if/then/else,但这玩意的语法设计……写到这里我开始怀疑人生:
{
"if": { "properties": { "priority": { "const": "high" } } },
"then": { "properties": { "approver": { "type": "string" } } }
}
我们的处理方式是在解析阶段把这类条件提取出来,挂到 UI AST 的 visible 字段上:
// UI AST 中的条件表达式
{
field: "approver",
component: "Select",
visible: {
operator: "eq",
dependsOn: "priority", // 依赖哪个字段
value: "high" // 当值等于 "high" 时显示
}
}
渲染时用一个简单的条件判断:
function shouldShow(node: UINode, formValues: Record<string, any>): boolean {
if (!node.visible) return true // 没有条件,永远显示
const { dependsOn, operator, value } = node.visible
const current = get(formValues, dependsOn) // lodash.get 取嵌套值
switch (operator) {
case 'eq': return current === value
case 'in': return (value as any[]).includes(current)
case 'ne': return current !== value
default: return true
}
}
可扩展性:当需求开始膨胀
第一版做完,需求就开始野蛮生长了:
"能不能支持自定义组件?" —— 可以,往 componentMap 里注册就行。
"能不能支持布局控制?一行两列?" —— 在 UI AST 加 layout 字段,引入栅格系统。
"能不能多个 LLM 调用串联,一个表单的结果作为下一个表单的输入?" —— 这就不是表单引擎的事了,你需要一个工作流编排层。
架构上我们把引擎拆成了三层,每层可独立替换:
┌─────────────────────────────────────────┐
│ Schema Provider(LLM / 手写 / 远程) │ ← 数据来源可替换
├─────────────────────────────────────────┤
│ Schema → UI AST 编译器 │ ← 映射规则可扩展
├─────────────────────────────────────────┤
│ UI AST → Component Renderer │ ← 组件库可替换
└─────────────────────────────────────────┘
换组件库?只改 Renderer 层。换 LLM 提供商?只改 Provider 层。这不是过度设计,这是被需求变更教育后的防御姿态。
踩坑实录
坑 1:LLM 返回的 Schema 不一定合法。 type 拼错、enum 给了空数组、required 里写了不存在的字段——都遇到过。解析前必须做 normalize。
坑 2:数组类型的表单状态管理很痛。 用户动态添加/删除数组项时,field path 会变(items.0.name → 删掉第一个后变成 items.0.name 但指向了原来的第二项)。用 id 而不是 index 做 key,这是老生常谈但每次都有人踩。
坑 3:流式响应下的 Schema 解析。 如果 LLM 用流式返回 JSON Schema,你拿到的是不完整的 JSON。要么等流结束再解析,要么用增量 JSON 解析器(如 partial-json)。我们选了前者——复杂度不值得。
坑 4:description 字段的双重身份。 JSON Schema 的 description 本意是给开发者看的字段说明,但在表单场景下它变成了给用户看的 label。LLM 有时会在 description 里写"This field represents the..."这种开发者语言。解决方案:在 prompt 里明确告诉 LLM,description 要写中文、面向用户。
通用模型:这个问题的本质是什么
退一步看,这整个引擎做的事情可以抽象为一个通用模型:
声明式描述 → 中间表示 → 运行时实例化
这个模式到处都是:
- SQL Schema → ORM Model → 数据库表
- OpenAPI Spec → API Client → HTTP 请求
- Figma Design Token → Theme Config → UI 样式
- JSON Schema → UI AST → 表单组件
核心思想就一个:用数据描述意图,用引擎翻译成行为。当你下次遇到"需要从某种描述自动生成某种 UI"的需求时,先别急着写代码,先想想:中间表示层应该长什么样?映射规则的扩展点在哪里?哪些决策应该留给引擎,哪些应该留给使用者?
把这三个问题想清楚,架构就不会跑偏。
至于 LLM——它只是这条链路上一个新的数据源。它很强,但它不可靠。 你的引擎需要对它的输出做校验、降级、容错。就像你不会信任用户输入一样,也别信任 AI 输出。