基于 LLM Function Calling 的前端动态表单生成引擎:从 JSON Schema 映射到运行时组件树的端到端实现

34 阅读1分钟

基于 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 渲染组件,非要搞个中间层?

三个理由:

  1. 解耦。Schema 变了不用改渲染逻辑,渲染逻辑变了不用改解析逻辑
  2. 可干预。中间层可以被二次修改——比如运营想调整字段顺序、覆盖默认组件
  3. 可序列化。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 的约束(minLengthpatternminimum 等)需要翻译成前端校验规则:

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 输出。