上个月被某后台系统折腾得没脾气一沓 PDF 合同里的客户姓名、证件号、开户行得对着屏幕一条条粘到表单里几十个字段对下来眼睛都花了。为了把这类重复劳动抛给大模型我动手做了一个用 Web Component 封装的人工智能表单填充 SDK。加载到页面上就是一个自定义元素扔一段原始文本进去它能自动提取表单结构、调大模型识别语义再回填到对应输入框。今天这篇文章把选型逻辑、实现原理、核心代码、踩坑记录完整写出来前端同学可以直接照着搭一套自己的方案。先说清楚它解决什么问题凡是从一段非结构化文本里提取字段值填入另一个结构化表单的场景比如合同导入、邮件建单、Excel 信息归档都能用。组件做成自定义元素ai-form-fill不依赖 Vue、React也不挑构建工具原生 HTML 页面和 MVVM 项目都能挂上。整个过程涉及 Web Component 三个标准、表单 Schema 提取、LLM 结构化输出、Shadow DOM 边界处理这些知识点下面按做事的顺序拆开讲。1. 手写一个通用 SDK 之前方案对比与选型理由动手写代码前我先把自己逼着回答了一个问题这个需求到底应该用什么形态交付因为交付形态决定了后续的维护成本也决定了用户怎么用它。我先梳理了这个功能在工程上的本质再做方案取舍。1.1 表单填充这个场景到底在解决什么事表单填充的本质是把外部非结构化信息映射成页面结构化控件值。原来靠人肉搬运后来有人写正则、接 OCR但这些方案泛化能力太差换一种文本格式就崩。大模型擅长语义理解——你告诉它客户姓名对应哪个字段它读到张三就知道填进姓名框哪怕原文里写的是客户张三也认得出。所以核心要解决三件事第一能够描述表单结构第二能够让大模型理解一段原文并输出结构化结果第三能够把结果按规则回填到页面并且让页面里已有的框架响应这次变化。这三件事分开看都不难难的是组合。表单结构千变万化页面可能跑在旧系统里也可能跑在 Vue 3 项目里。组件必须同时适应这些场景才有资格叫通用 SDK。1.2 三种常规方案都不如一个自定义元素顺手我在选型时排了三个常规方案各有各的难受。第一种是浏览器插件。能读页面 DOM也能自动填但分发和版本管理太痛苦还要面对不同浏览器的权限模型。自家后台页面用插件纯属给自己找事。第二种是页面内普通 JS 插件。一坨全局函数挂到 window 上调用方得先引入脚本再手动调用初始化函数。表单和插件之间靠 DOM 选择器耦合集成得越多命名空间冲突的概率越高项目一复杂就难维护。第三种是框架组件。Vue 项目里写一个 vue 组件、React 项目里再写一个两边逻辑重复维护换一个技术栈就得重写一遍。要覆盖后台项目的各种技术栈组合只能多份代码轮着补。这三种都做过对比之后我最终选了 Web Component。它本身就是浏览器标准一套代码放进任意框架页面都能用调用方只需要认识一个自定义标签不需要关心组件内部实现。原生、跨框架、开箱即用这三个特性刚好击中这个需求最痛的部位。1.3 Web Component 的适用边界不是玩具也不是万能这里要给个客观定位Web Component 适合做增强型组件不适合做复杂数据流绑定的重组件。表单填充恰恰是最典型的增强场景——组件不掌管业务数据它只是拿到一份文本填一次值状态简单命令式 API 最自然。它由三个标准组合而成Custom Elements 用于定义和使用自定义标签Shadow DOM 做样式和 DOM 隔离HTML Template 定义内部结构。打个比方普通组件像一个现成的家用电器插上电就能用Web Component 像一个独立供电的模块接到哪套电路上都只管自己那摊活儿。兼容性在 2026 年已经不是问题桌面端现代浏览器全部支持 Custom Elements v1。如果你的使用方都是内部系统放心上。2. 表单 Schema 提取与 AI 识别链路数据怎么看懂表单要让大模型填表单第一步不是调用模型而是先让组件自己看懂页面上有哪些字段。我把这一步叫做 Schema 提取——拿到表单的完整字段清单包括每个控件的名字、标签、类型、选项。这是整条识别链路的数据基础。2.1 遍历表单收集字段元数据一个后台表单里通常混着 input、textarea、select还有各种隐藏控件。提取时先过滤掉 submit、button、reset 这类不承载数据的按钮也跳过 type 为 hidden 的控件——那些往往是页面内部的辅助值不该让模型猜。关键信息里最重要的不是 name而是 label。很多人写表单时不爱填 name或者 name 是一串缩写比如 custN。模型看到 custN 根本不知道对应什么但看到 客户姓名 就明白了。所以我提取 label 的优先级是label[for]或包裹型 label 的文本其次是aria-label再退到 placeholder最后才是 name 兜底。这一步决定了后面 Prompt 的质量值得多花点心思。function extractFormSchema(formElement) { const controls [...formElement.querySelectorAll(input, textarea, select)]; const schema []; for (const field of controls) { if (field.dataset.aiSkip ! undefined) continue; // 开发方主动标记跳过 if ([submit, button, reset, hidden].includes(field.type)) continue; const labelText field.labels?.[0]?.textContent?.trim() || field.getAttribute(aria-label) || field.placeholder?.trim() || field.name; const item { name: field.name || field.id, label: labelText, type: field.type || field.tagName.toLowerCase(), required: field.required || false }; if (field.tagName SELECT) { item.options [...field.options] .filter(opt opt.value) .map(opt ({ value: opt.value, text: opt.text.trim() })); } if (field.type radio) { // 同一 name 的 radio 属于一个组放到一起 item.options [...formElement.querySelectorAll(input[typeradio][name${field.name}])] .map(radio ({ value: radio.value, text: radio.labels?.[0]?.textContent?.trim() })); } schema.push(item); } return schema; }我把提取结果调试打印出来字段、label、类型一目了然。加过滤条件是为了避免验证码、密码这类敏感字段被送进模型。2.2 把表单结构转成 Prompt 并约束输出 JSONSchema 提取完之后下一步是构造 Prompt。我习惯把 Prompt 分成四个部分系统角色、表单结构、待识别原文、输出约束。系统角色一句话就够比如你是一个表单字段提取助手只输出 JSON。表单结构就是上面的 schema 序列化成 JSON 字符串原文放在最后。输出约束是重中之重。一定要让模型返回严格 JSON并且字段 key 直接使用 schema 里的 name。为什么不返回自由文本因为自由文本没法稳定解析。让模型走结构化输出模式效果会稳定很多。如果接的大模型服务支持强制 JSON 模式直接开启不然就在 Prompt 里反复强调不得输出多余解释。function buildPrompt(schema, sourceText) { const prompt [ 你是表单字段提取助手。下面是一个表单字段清单和一段原始文本。, 请从原始文本中提取每个字段对应的值。, 要求, 1. 只返回 JSON 对象key 必须是字段清单中的 name, 2. 无法确定的值用 null, 3. 不要编造原文中不存在的信息, 4. 数组和布尔值请转换为字符串。, 字段清单, JSON.stringify(schema, null, 2), 原始文本, sourceText.slice(0, 8000) ].join(\n); return prompt; }文本长度控制也在这里做。原文超过 8000 字符我会截断因为后台表单的字段数量有限给模型的上下文里原文太长反而稀释关键信息。字段多的时候我会把 schema 里的一些冗余说明去掉只保留 name 和 label。再强调一次隐私边界密码、验证码、身份证号这种敏感字段默认不进 schema。开发方如果确实需要模型处理某个特殊字段得显式加一个>class AIFormFill extends HTMLElement { static get observedAttributes() { return [selector, api, model]; } constructor() { super(); this.attachShadow({ mode: open }); this.schema []; this._controller null; } connectedCallback() { this.render(); this.bindEvents(); // 如果宿主表单还没渲染完成延迟再挂载 this.connectForm(); } }组件内部模板就三块一个智能填充按钮、一个显示命中率的状态条、一个结果面板容器。按钮触发fillFromText()状态条反馈当前是识别中还是已完成结果面板展示逐字段匹配情况。最开始版本只做按钮点击不做自动监听能减少很多状态判断。3.2 对外属性和方法的 API 设计公共 API 我压到最小的量能少暴露就少暴露。属性有三个通过观察器响应变化属性类型作用selectorstring要填充的表单选择器如 #mainFormapistring大模型接口地址组件内部统一走 fetch POSTmodelstring模型标识透传给接口方法暴露四个getSchema()给调用方调试用fillFromText(text)是核心入口从文本到填充一步完成applyResult(json)支持调用方自行完成 AI 调用后直接把结果交给组件fillFromFile(file)是文本入口的文件版内部用 FileReader 读文本再走上面流程。事件方面我用了自定义事件都从组件本身派发aifill:start开始识别aifill:success识别并回填完成aifill:error调用或解析失败aifill:warning部分字段未匹配为什么不用回调函数而要发事件因为使用方可能是 Vue、React 或者原生 JS事件机制是各环境都通用的不需要为了框架适配做额外桥接。这在跨框架设计里是一条重要原则能用标准能力沟通就不要引入框架私有能力。3.3 核心填充引擎字段匹配、赋值和事件触发填充引擎是组件的心脏。applyResult(json)拿到大模型返回的 JSON 后逐字段做三件事从结果里取到值、按照控件类型赋值、触发标准事件让上层框架感知。赋值的坑比想象中多。textarea 和 text 输入框直接设置value就行select 需要按 value 匹配option匹配不到就尝试按 option 的文本匹配checkbox 得根据 true/false 切换checkedradio 则是找到对应 value 的控件再点亮。function setFieldValue(fieldElement, value) { const type fieldElement.type || fieldElement.tagName.toLowerCase(); const norm String(value ?? ).trim(); if (type checkbox) { const truthy [true, 1, yes, on].includes(norm.toLowerCase()); fieldElement.checked truthy; } else if (type radio) { const radio document.querySelector( input[typeradio][name${fieldElement.name}][value${norm}] ); if (radio) radio.checked true; } else if (fieldElement.tagName SELECT) { const matchByValue [...fieldElement.options].find(opt opt.value norm); const matchByText [...fieldElement.options].find(opt opt.text.trim() norm); if (matchByValue) fieldElement.value matchByValue.value; else if (matchByText) fieldElement.value matchByText.value; } else { fieldElement.value norm; } // 触发框架可识别的事件 fieldElement.dispatchEvent(new Event(input, { bubbles: true })); fieldElement.dispatchEvent(new Event(change, { bubbles: true })); }最后那两行dispatchEvent很重要。Vue 的 v-model、React 的受控组件底层都监听 input 或 change。只用value赋值不会触发视图更新改完页面还是显示空的这是无数集成者最容易踩的坑。事件派发要使用bubbles: true这样才能穿透到表单层级的监听器。识别完成之后组件把一个摘要对象传给aifill:success事件的 detail包含成功字段数、失败字段数、耗时。调用方想要弹提示或埋点直接监听事件拿数据。4. Shadow DOM 带来的三处反直觉事件、样式、表单关联Shadow DOM 的隔离能力强但三处行为特别反直觉事件往外冒泡时会被重新定向组件内部的输入控件不参与外部表单提交样式完全隔离后使用方没法定制。这三处的处理方式直接决定组件好不好用。4.1 事件重定向与 composedPath 的实际含义Shadow DOM 里冒泡的事件有重定向机制。比如组件内部按钮被点击事件冒泡到 document 时event.target不再是按钮而是整个自定义元素。这是浏览器故意为之为了不让外部看到 shadow 内部的 DOM 细节。影响是什么如果使用方在 document 上做事件委托想判断用户是不是点了填充按钮通过event.target只能拿到ai-form-fill拿不到具体内部元素。这时候要用composedPath()拿到真实路径document.addEventListener(click, (e) { const path e.composedPath(); if (path[0]?.tagName BUTTON path[0]?.textContent.includes(智能填充)) { console.log(点击了组件内的填充按钮); } });这个 API 返回事件传播的真实元素路径不会因为重定向被隐藏。写组件 SDK 的时候我自己内部逻辑不依赖外部事件纯粹在组件内部绑定使用方才需要关心这个问题。但文档里必须写清楚否则用户排查点击事件时会被重定向搞糊涂。4.2 表单提交边界不要在组件里放业务输入控件这是 Shadow DOM 最深的坑。组件 shadow root 里放的input从标准规则来说不归属于任何外部form。也就是说即使在form内部放一个包含input nameemail的自定义组件点提交按钮时浏览器也不会把那个 input 的值提交上去因为它被 shadow 边界隔开了。解决方案很明确表单填充组件本身的 shadow 里不放任何业务输入控件。它只是一个控制器按钮、状态条、结果面板都在 shadow 里被增强的真实表单控件一直在 light DOM 里不影响原有提交逻辑。如果你确实要做自定义表单控件的组件那就是另一套玩法了——用formAssociated属性关联宿主表单实现get value()接口。这个属性兼容性已经不错但复杂度高对表单填充组件不是必选项。我当前版本直接绕开回归组件职责本身。4.3 用 CSS 变量和 ::part 把样式控制权还给使用方Shadow DOM 把组件样式和页面样式彻底隔开。好处是页面里的全局样式污染不到组件坏处是使用方想改按钮颜色直接写.btn { color: red }无效。我的做法是双通道开放定制第一层是 CSS 自定义变量组件内部所有关键颜色、圆角、间距都引用变量使用方覆盖变量即可第二层是把关键内部结构暴露为::part()。按钮暴露成partfill-button状态条暴露成partstatus-bar使用方可以用ai-form-fill::part(fill-button) { background: #123456 }精确定制。:host { --aifill-primary: #2563eb; --aifill-radius: 6px; --aifill-text-color: #fff; } .fill-button { background: var(--aifill-primary); border-radius: var(--aifill-radius); color: var(--aifill-text-color); }对外提供两个变量加一个 part基本覆盖九成定制诉求。极少数特殊定制我就建议直接改源码重新构建了——定制到那种程度说明组件边界不适合你的场景。5. AI 输出不可靠时的兜底模糊匹配、类型校验与人工确认大模型输出再稳定也不能保证每次 key 都和 schema 完全一致。我的经验是永远不要信任模型的 key永远要信任你提取的 schema。把信任边界放在自己这边可靠性才能兜住。5.1 字段归一化与模糊匹配模型返回的 JSON 里key 经常是 label 而不是 name比如 schema 里的 name 是 custNlabel 是 客户姓名模型可能直接把 客户姓名 当 key 用了。我不直接放弃做一层归一化匹配。归一化就是把字符串里的空格、下划线、中划线、括号全部去掉转为小写得到规范化后的 key。匹配优先级从高到低是原始 name 精确匹配、归一化后的 name 匹配、label 归一化匹配、placeholder 归一化匹配。再加一层简单包含匹配做兜底。function normalizeKey(key) { return String(key).toLowerCase().replace(/[\s\-_():,。.]/g, ); } function findFieldByResultKey(resultKey, schema) { const target normalizeKey(resultKey); let match null; for (const field of schema) { const cands [field.name, field.label, field.placeholder].filter(Boolean); for (const cand of cands) { const norm normalizeKey(cand); if (norm target) return field; if (norm.includes(target) || target.includes(norm)) match field; } } return match; }包含匹配容易误伤比如 姓名 和 名字 就不该算同一个字段。所以包含匹配的结果我只用做低置信度建议最终进人工确认面板展示不直接落值。5.2 按字段类型做赋值前的校验字段匹配完并不是直接塞值。每个字段带着类型信息塞之前必须做一次校验否则会把 abc 填进 number 字段、把乱码填进邮箱。校验规则按类型差异化处理number 用parseFloat之后必须是有限数字email 走一个最简正则date 要能被new Date()正确识别select 的值必须在 options 的 value 或 text 列表里。不合法时该字段标记为校验失败进入人工确认列表而不是假装填充成功。这块代码不复杂但价值极高。实际测试中大概有 10% 到 20% 的字段值会因为类型不匹配被拦住省掉后续表单提交时的一堆服务端报错。5.3 填充快照与人工确认面板自动填充不能一锤子定生死。我的做法是先保存一份填前快照组件在填充前把每个控件当时的 value、checked、selected 记录到数组里。一旦用户点撤销填充直接按快照恢复。填充完成后的结果面板每一行展示字段 label、原文名称、模型给出的值、匹配状态。状态分三种已填充、待确认、未识别。待确认的是类型校验失败的未识别的是连字段匹配都没做到的。用户可以在面板里手动改值然后统一点确认再触发一次 change 事件。这个交互设计解决一个核心痛点AI 生成结果在部分场景下只适合建议不适合直接提交。给用户一个中间确认层才能让组件真正出现在生产表单里。整个确认面板的数据结构也对外暴露方便团队对接自己的审批流程。6. 打包、发布与跨框架接入怎么让组件真正通用代码写完只完成一半。一个 SDK 要被不同技术栈的人使用打包方式、发布结构、接入差异必须提前想好。这一节讲我反复试错后定下的最终形态。6.1 构建产物与 npm 包结构构建工具我选了 esbuild零配置、速度快、产物干净。产物分两个版本ESM 版给现代构建工具用IIFE 版给原生页面通过 script 标签直接用。两个版本共享同一份源码入口文件不写框架相关代码保持框架中立。包结构保持简单ai-form-fill/ ├── dist/ │ ├── ai-form-fill.esm.js │ └── ai-form-fill.iife.js ├── src/ │ ├── index.js │ ├── schema.js │ ├── engine.js │ └── panel.js ├── types/ │ └── index.d.ts └── package.jsonpackage.json 里module指向 ESMmain指向 IIFEexports字段做细化映射让打包工具优先吃module。TypeScript 类型声明单独放 types 目录不耦合到主包里。6.2 原生页面、MVVM 框架里的接入差异原生页面最简单引入 IIFE 脚本直接写ai-form-fill selector#appForm api/api/ai/fill modeldefault/ai-form-fill完事。Vue 和 React 里接入要各做一个提示。Vue 3 对原生自定义元素的支持比老版本好很多模板里直接用自定义标签即可只要确保这个元素已经注册到浏览器。如果项目配置里对未知元素做了严格的组件解析需要配置compilerOptions.isCustomElement把ai-form-fill标记为原生元素。React 稍微要注意两点。第一React 的 JSX 类型定义不认识自定义元素需要在JSX.IntrinsicElements里声明ai-form-fill的属性和 ref 类型。第二方法调用不能直接在 JSX 里传函数要用 ref 拿到组件实例再调componentRef.current.fillFromText(text)。declare module react { namespace JSX { interface IntrinsicElements { ai-form-fill: React.DetailedHTMLPropsReact.HTMLAttributesHTMLElement, HTMLElement { selector?: string; api?: string; model?: string; }; } } }很多自定义元素在 React 里不能工作的传言其实九成是类型声明缺失导致的。属性传参时自定义元素作为 DOM 元素的属性只能传字符串如果需要传对象老老实实用 DOM API 设置属性或者调用实例方法。6.3 给 SDK 加日志和演示模式SDK 不能是个黑盒。用户遇到问题时没有日志就很难排查。我在组件上加了一个debug属性开启后会把每次 Prompt 内容、模型返回原文、schema JSON、字段匹配结果打到 console。开发环境直接开生产环境关掉平时自己排查问题也靠它。同时内置了一套演示模式当检测到api为空时组件不发起网络请求而是用内置的几组示例文本配合一个简易规则识别器跑通全流程。这个模式对组件调试、UI 验收、给产品讲演示都很有用。很多时候产品想看效果又不想真的配大模型 API演示模式直接兜底。7. 实际使用中的坑位清单与工程化建议最后一部分把我在真实环境里遇到的坑按排查链路列出来。有些问题光看文档想不到真到页面上跑一跑才发现。7.1 动态渲染导致 schema 失效用 MutationObserver 兜底第一次接 Vue 项目时页面加载完组件就立刻去querySelector(#mainForm)结果表单是v-if渲染出来的组件执行时 DOM 里根本还没有这个表单。我排查了半小时最后确认不是选择器写错而是渲染时序问题。解法分两层。第一层connectedCallback里尝试查找表单找不到就把查找函数包在setTimeout里重试连查三次。大多数表格页 500 毫秒内会出现。第二层也是更靠谱的组件挂载后实例化一个MutationObserver监听容器区域的 childList 变化一旦出现对应表单元素就绑定 schema。这样即使页面初始化很慢也不会错过。还有一个更简单的工程约定fillFromText被调用时永远重新提取一次 schema。反正提取成本低以实际填充时刻的表单状态为准永远不缓存旧 schema。这套组合拳下来时序问题基本绝迹。7.2 跳过 iframe、隐藏控件还要防住敏感字段后台表单里经常混着富文本编辑器很多实现是基于iframe的比如旧的富文本编辑器。表单遍历时querySelectorAll不会穿透 iframe所以组件倒不至于崩溃但如果页面里恰好有那种内嵌整个编辑器的表单控件提取会缺一块。我的处理是给这类区域加>