ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI生成不可控?用JSON Schema约束让AIGC产出可用TypeScript脚手架

AI生成不可控?用JSON Schema约束让AIGC产出可用TypeScript脚手架 几个月前我想偷个懒直接把需求丢给大模型让它一口气吐出一个Node TypeScript的CLI项目脚手架。它确实给了二十几个文件目录结构像模像样结果npm install一跑依赖版本互相打架tsconfig里noEmit和outDir的配置自相矛盾我特意要的类型声明文件压根没生成。那时候我意识到问题不在模型能力而在交付方式——让AI直接写散乱文件等于把项目的正确性押注在它的自由发挥上。后来我把整个流程改成AI只负责输出一份严格符合JSON Schema的配置清单程序再根据这份JSON生成脚手架文件。这版方案跑通之后稳定性远超预期。这篇文章就把这套“严格JSON落盘”的流程完整拆开讲一遍覆盖数据模型、提示词设计、校验循环和生成器实现。适合正在用AIGC做工程提效、想让AI产出真正可控代码的开发者阅读。1. 为什么我不再让AI“直接生成项目文件”1.1 第一次尝试的翻车现场我最初的做法很简单在对话里描述需求“生成一个TypeScript CLI项目用commander处理命令带测试、ESLint、Prettier配置”。模型回答得头头是道输出了一堆文件。表面看每个文件都是合法的但合在一起就是没法用。问题出在文件之间的隐式契约上。package.json里声明了type: moduletsconfig.json里却用了CommonJS的module配置依赖里装的commander版本需要Node 16以上而.nvmrc写的是14源码里用了import.meta.urltypes/node却没装最新版。这些都是分散在多个文件里、彼此关联的决定模型在没有统一约束的情况下很容易生成彼此矛盾的组合。这件事让我明白AI直接生成代码文件的本质是让模型在一堆相互制约的条件下做全局优化而它并不擅长保持这种跨文件的约束一致性。1.2 直接生成代码的四个痛点我把后续几次失败的经验归纳了一下直接生成方式有四个绕不开的问题不可校验代码文件的正确性只能靠编译器事后检查错误信息往往又长又晦涩修起来费劲。不可审计你拿到二十个文件根本没法快速确认哪一个是错的得逐个打开看。不可增量更新改一个需求想让模型改三个文件它可能把另外五个无关文件也改坏了。不可程序化消费文件内容是纯文本想写规则去检查依赖版本、目录结构、配置合法性都得自己解析成本很高。1.3 “AI决策 程序执行”的架构转变我的解决办法是砍掉模型的“执行权”只留“决策权”。AI负责根据需求产出结构化的JSON配置清单里面写清楚项目要哪些依赖、tsconfig怎么配、目录下要放哪些文件、每个文件内容是什么。这些JSON需要经过严格的校验校验通过后落盘保存。然后由一段完全确定性的Node脚本去读取JSON再生成真实的项目文件。打个不恰当的比方AI是规划设计院只出图纸执行脚本是施工队严格按图施工。施工队不会自由发挥规划院也不能直接上手砌墙。这套架构把AI的“发挥空间”压缩到了数据层面而数据的正确性可以通过Schema校验、类型检查和业务规则层层把关。2. 脚手架数据模型JSON里到底装些什么2.1 先拆解一个TypeScript脚手架需要哪些要素设计JSON结构之前我先列了一个标准TypeScript脚手架必须包含的要素项目元信息项目名、描述、入口文件、模块类型ESM还是CommonJS。依赖清单运行时依赖和开发依赖必须带语义化版本范围。脚本命令dev、build、typecheck、test等。编译配置target、module、moduleResolution、strict、outDir等。目录与文件清单每个文件的路径和内容。可选特性开关要不要ESLint、Prettier、Vitest、ESM等。其中“文件清单”是最核心的部分。为了让AI生成的文件内容可控我把文件内容也放进JSON里让JSON成为唯一的交付载体。2.2 交付JSON的完整结构下面是我实际在用的JSON结构简化了一点但保留了核心字段{ meta: { schemaVersion: 1.0, generatedAt: 2025-06-20T10:30:00.000Z, model: gpt-4o-mini, promptFingerprint: ts-cli-scaffold-v3 }, project: { name: ts-cli-demo, description: A minimal TypeScript CLI project, entry: src/index.ts, type: module }, dependencies: { commander: ^12.0.0 }, devDependencies: { typescript: ^5.4.0, types/node: ^20.11.0, tsx: ^4.7.0 }, scripts: { dev: tsx src/index.ts, build: tsc, typecheck: tsc --noEmit }, tsconfig: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, outDir: dist, rootDir: src, skipLibCheck: true }, files: [ { path: src/index.ts, content: console.log(hello from ts-cli); }, { path: src/types/index.d.ts, content: export interface CliCommand { name: string; description: string; } }, { path: .gitignore, content: node_modules/\ndist/\n*.log\n } ], features: { eslint: true, prettier: true, vitest: false } }我把meta字段放在最前面记录schema版本、生成时间和模型信息。这样做有两个好处一是方便追溯这份JSON是怎么来的二是后续升级schema时可以根据版本号决定是兼容还是拒绝。2.3 为什么文件内容也要放进JSON有人可能会问文件内容直接让AI按目录结构一个个生成不就行了吗为什么还要塞进JSON我当时的考虑是统一交付格式可以极大降低消费端的解析复杂度。如果AI同时产出“目录结构文本文件内容依赖清单配置项”格式五花八门消费端得写一堆解析逻辑处理各种边界情况。把所有内容都收敛进一个JSON对象后我只需要一个JSON解析器剩下的所有信息都用Schema校验整个过程异常干净。另外把文件内容放进JSON还有一个意外的好处方便在生成前做内容级检查。比如我可以禁止content里出现process.env.SECRET这种硬编码敏感信息的模式也可以限制单个文件内容长度上限防止AI抽风生成一个超大文件把磁盘写爆。2.4 数据模型里的隐性约束规则JSON结构只是骨架真正让AI输出“可用”的是隐藏在结构之外的约束规则。我在校验层里加了这些硬性要求路径白名单files数组里的path只能以src/、test/或config/开头且不允许出现..跳级防止路径穿越。语义化版本所有依赖版本必须是合法的semver范围比如^1.2.3不允许latest、*这种不可复现的写法。内容长度上限单文件内容最多50000字符超出直接判失败。关键命令必填typecheck脚本必须存在因为它是生成后验证的入口。entry与files联动project.entry声明的路径必须存在于files数组中否则视为自相矛盾。这些规则不写在提示词里而是写在校验脚本里。AI不一定每次都遵守提示词但校验脚本是铁面无私的。3. 提示词设计让大模型只吐JSON不吐废话3.1 提示词里的三条硬性输出要求提示词是这套流程的起点。我的经验是输出约束必须写在最前面而且要写得非常绝对否则模型很容易自作主张。我一般会放这三条1. 只输出一个JSON对象不要使用markdown代码块或任何包裹格式。 2. 不要输出任何解释、前缀、后缀、问候语。 3. 如果某个字段无法确定请采用常见保守配置不要自行发明。第一条是为了方便解析。大模型经常会把JSON包在json代码块里甚至前后夹带一段说明文字。直接禁止是最省事的。第二条是为了防止模型输出“好的根据您的需求我生成了以下配置……”这类废话。这些文字会让JSON解析失败而且完全没用。第三条最微妙。模型在面对模糊需求时倾向于自己“合理推测”但它的推测不一定符合真实工具链。明确要求采用保守配置能把模型的创造性压制到最低。3.2 把JSON Schema直接写进提示词提示词里光说“要符合Schema”没用模型不知道自己该符合什么。我干脆把JSON Schema的简化版直接写进提示词里让模型照着字段名和类型生成。Schema起到两个作用一是告诉模型有哪些字段、类型是什么二是通过枚举值限制它的选择范围。比如tsconfig.target只允许ES2020、ES2022、ESNext三个值模型再想生成ES2017就会被挡住。字段约束 - project.type: 只允许 module 或 commonjs - project.entry: 必须以 src/ 开头 - tsconfig.target: 只允许 ES2020 | ES2022 | ESNext - tsconfig.module: 只允许 NodeNext | ESNext | CommonJS - tsconfig.strict: 必须是布尔值 - dependencies 与 devDependencies: 键值对值必须符合语义化版本范围例如 ^1.2.3 - files 数组: 每个元素包含 path 和 content 两个字段path 必须以 src/ 或 test/ 或 config/ 开头写提示词的时候要注意提示词里的Schema和校验脚本里的Schema必须完全一致。我踩过坑提示词里写了eslint: true校验脚本里却要求boolean模型输出字符串true直接校验失败。3.3 few-shot示例再给模型一个“标准答案”光有约束还不够很多模型在压力下会把字段名改得五花八门。我建议在提示词末尾给一小段完整示例让模型照着样子输出。这个示例不必太长覆盖最核心的字段即可。例子我会写一个微型的JSON比如files里只放一个src/index.tsdependencies里只放一个commander。模型看到这种“标准答案”之后输出格式基本不会跑偏。注意示例必须和真实需求场景接近别拿一个前端组件库的JSON当示例去生成CLI项目。3.4 工程化封装的生成-解析-重试骨架提示词写好后我把它封装成一个Node脚本流程大致如下async function generateScaffoldConfig(requirement: string): PromiseScaffoldConfig { const messages [ { role: system, content: SYSTEM_PROMPT }, { role: user, content: requirement } ]; for (let attempt 1; attempt 3; attempt) { const response await callLLM(messages, { temperature: 0.2 }); const parsed extractJSON(response); const result validateScaffold(parsed); if (result.success) { return parsed; } // 把校验错误回喂给模型让它修复 messages.push({ role: assistant, content: response }); messages.push({ role: user, content: 上面的输出未通过校验错误如下\n${result.errors.join(\n)}\n请重新输出修正后的完整JSON。 }); } throw new Error(连续三次生成失败请人工介入); }温度参数我固定在0到0.2之间太高会让模型发挥过头太低则显得死板但对于配置生成这个场景死板反而是好事。重试次数限制在3次超过就放弃避免模型陷入“改一个错又添一个错”的循环也避免烧太多token。4. 严格校验与自修复循环落盘之前的最后防线4.1 第一层格式与类型校验拿到AI输出的JSON字符串后第一步是用JSON.parse解析。如果连解析都失败说明输出格式不对直接进入重试。解析成功之后我用Zod做类型校验。Zod的schema和提示词里的约束一一对应。import { z } from zod; const FileItemSchema z.object({ path: z.string().regex(/^(src|test|config)\/[a-zA-Z0-9_\/.-]$/), content: z.string().min(1).max(50000) }); const ScaffoldSchema z.object({ meta: z.object({ schemaVersion: z.string().regex(/^\d\.\d$/), generatedAt: z.string().datetime(), model: z.string().min(1) }), project: z.object({ name: z.string().regex(/^[a-z0-9-]$/), description: z.string().max(200), entry: z.string().startsWith(src/), type: z.enum([module, commonjs]) }), dependencies: z.record(z.string(), z.string()), devDependencies: z.record(z.string(), z.string()).optional(), scripts: z.record(z.string(), z.string()).optional(), tsconfig: z.object({ target: z.enum([ES2020, ES2022, ESNext]), module: z.enum([NodeNext, ESNext, CommonJS]), strict: z.boolean(), outDir: z.string().default(dist), skipLibCheck: z.boolean().default(true) }), files: z.array(FileItemSchema).min(1), features: z.object({ eslint: z.boolean().default(true), prettier: z.boolean().default(true), vitest: z.boolean().default(false) }).optional() }); type ScaffoldConfig z.infertypeof ScaffoldSchema;用Zod的好处是校验和类型推导一步到位。z.infer能直接从schema推导出TS类型消费端拿到手的就是强类型数据不用再手动定义一遍接口。4.2 第二层业务规则校验类型检查过了不代表JSON就能用。我还会跑一遍业务规则校验检查那些Schema表达不了、但实际工程必须满足的约束依赖版本是否存在。模型写出的版本号可能是编造的尤其是冷门包。我在校验脚本里维护了一个常用包版本白名单凡是不在白名单里的版本就通过npm view pkg version临时查询查询失败直接判失败。包名与用途匹配。比如设备类型声明文件要跟对应包配套。模型可能给dependencies里塞了types/react但项目根本不是React项目。这类问题要靠“包名与项目feature联动”的规则去查。entry文件确实存在。上面提过project.entry声明的路径必须在files数组里能找到。找不到就说明AI漏了最关键的文件。tsconfig字段与项目类型一致。如果project.type是module那么tsconfig.module不允许是CommonJS否则编译出来的代码和Node的模块解析方式对不上。这类规则我会一条条列在配置里方便日后扩充。4.3 自修复循环把校验错误回喂给模型这是整个流程里最实用的一环。AI第一次生成往往不能完全通过校验但只要错误信息足够具体第二次基本就能修好。举个例子模型生成的dependencies里写了commander: ^11我的语义化版本校验要求必须是主.次.修订三段式错误信息就会是字段 dependencies[commander] 的值 ^11 不是合法的语义化版本请改为 ^11.1.0 或 ^12.0.0把这条错误原样追加到对话里让模型重新输出完整JSON。实测下来绝大多数错误在第二轮就能修好第三轮是保险。关键点在于错误信息必须精确到字段路径和正确的业务预期而不是简单说“格式不对”。模糊的错误提示只会换来模糊的修复。4.4 严格落盘只有合格JSON才被写入正式文件“落盘”这个词听上去简单但我在工程上把它严格分成了两步校验失败的中间产物写入.draft目录文件名带时间戳方便排查AI到底反复错在哪。只有通过全部校验的JSON才写入正式文件scaffold.config.json这个文件是后续生成器唯一承认的输入。这样做的好处是生成器永远只面对一份经过验证的数据不会被脏数据半路坑一下。同时draft文件也保留了AI的“原始答案”做溯源分析时很有价值。正式落盘时我还会在meta里补充真实生成时间、校验器版本号等信息防止schema升级后旧配置被悄悄消费。5. 消费端生成器从JSON到可运行脚手架的最后一公里5.1 生成器主流程JSON一旦落盘生成器的工作就变得非常简单——它不再需要任何智能只需要忠实地把JSON翻译成文件。主流程如下读取scaffold.config.json用Zod再次校验防止有人手动改坏。根据files数组逐条创建目录、写入文件。根据project、dependencies、devDependencies、scripts字段生成package.json。根据tsconfig字段生成tsconfig.json。根据features字段决定是否生成eslint.config.js、.prettierrc.json等附加配置。在项目根目录写入scaffold.lock.json记录生成器版本、输入JSON的hash、生成时间。可选地执行npm install然后跑npm run typecheck验证。我这里给出生成package.json的核心逻辑其他文件生成大同小异function renderPackageJson(config: ScaffoldConfig): string { return JSON.stringify({ name: config.project.name, version: 0.1.0, description: config.project.description, type: config.project.type, main: config.project.entry, scripts: config.scripts ?? { build: tsc, typecheck: tsc --noEmit }, dependencies: config.dependencies, devDependencies: config.devDependencies }, null, 2); }5.2 路径安全与幂等性这两件事必须做对生成器看似简单但有两个细节不做对就会出事故。路径安全。files数组里的path虽然在校验层已经过滤过但消费端必须再做一次白名单判断绝不能直接用path.join(process.cwd(), item.path)这种写法。万一哪天校验层被绕过一个../../../../etc/cron.d的路径就能让你后悔莫及。我采用的是“先规范化再验证前缀”的套路const resolved path.resolve(process.cwd(), item.path); const allowedRoot path.resolve(process.cwd(), .); if (!resolved.startsWith(allowedRoot)) { throw new Error(非法路径: ${item.path}); }幂等性。同一个配置反复执行结果应该一致。我默认如果目标文件已存在就拒绝覆盖并提示加--force参数加了--force之后先备份原文件到.backup目录再覆盖。这样既能保证重复执行不产生意外也能在出错时快速回滚。我还把生成的整个目录自动git init并做一次初始提交。这样AI生成的每一个文件改动都留痕后续手工修改也可以直接走git diff查看。JSON格式的配置文件在git里做merge conflict时也比散乱代码文件好处理得多——至少人类能一眼看出冲突的是哪个字段。5.3 编译器当裁判生成完成后自动验证文件全部生成只是完成了第一步真正判断脚手架“能不能用”要把编译器拉出来当裁判。我在生成器里内置了两个验证步骤npm install验证依赖确实能装上、版本之间没有硬冲突。npm run typecheck跑tsc --noEmit验证所有TS文件类型正确。如果typecheck失败生成器会把错误信息收集起来回写到draft目录并提示你把错误喂回给AI生成新版本。这就形成了一个闭环AI生成JSON → 程序生成文件 → 编译器反馈错误 → 修正JSON。闭环的价值在于AI的任何幻觉都会在编译器这里被抓住而不是等你运行到第三天才发现类型对不上。5.4 端到端效果与实战示例最后说一下实际跑通的例子。我给模型的需求是“生成一个使用commander的TypeScript CLI项目附带类型声明目录”。生成器最终输出如下$ ai-scaffold init --requirement ts cli with commander ✓ 已生成 scaffold.config.json通过schema校验 ✓ 已创建 12 个文件 - src/index.ts - src/types/index.d.ts - test/example.test.ts - tsconfig.json - package.json - .gitignore - .prettierrc.json ✓ npm install 完成 ✓ tsc --noEmit 通过无类型错误 项目已就绪ts-cli-demo这个结果看起来平淡但对我这种被AI直接生成文件坑过好几次的人来说已经是巨大进步。因为每个环节都有校验、有日志、有回滚路径我可以放心地把“生成脚手架”这种重复劳动交给流程去跑自己只去review最终落盘的JSON和编译结果。6. 实战踩坑记录与我对这套流程的几点反思6.1 大模型输出JSON的六种失败模式跑了不少case之后我总结了大模型输出JSON时最常见的六种失败模式处理方式都写在表里失败模式典型表现处理策略markdown代码块包裹输出json开头和结尾解析前先剥离前后缀和代码块标记解释性文字混入“好的以下是生成的JSON{...}”提取第一个{到最后一个}之间的内容JSON截断输出到一半被token上限截断检测到不完整结构后直接重试不浪费修复轮次字段缺失忘了生成files数组用Schema的min(1)约束拦截进入重试类型漂移dependencies里的版本写成数字类型校验拦截错误信息回喂自创字段生成一个不在schema里的options字段校验时用stripUnknown丢弃并警告第3种最闹心。截断后的JSON往往带一半字符串一半结构JSON.parse必然失败。我一开始还会尝试补全后来发现补全的性价比很低直接重试反而快。6.2 依赖版本幻觉最让人头疼的问题所有坑里依赖版本幻觉是唯一让我觉得“光靠AI救不了AI”的。模型非常擅长写^12.1.0这种看起来像模像样的版本号但那个版本可能压根不存在或者在2025年的Node版本下已经被deprecated。我在校验层做了白名单缓解但白名单也只能覆盖热门包。冷门包的版本验证只能靠npm view去查每次都走网络请求又太慢。我的折中方案是维护一份热门包版本白名单校验时命中白名单直接放行。白名单外的包用npm view pkg version --json查询结果缓存24小时。连续查询超过20个冷门包就直接判失败防止滥用。6.3 这个流程的适用范围与边界“AI出JSON、程序消费JSON”这个模式我目前主要用在三类场景脚手架生成初始化项目、生成模块骨架本期主题。配置文件生成让AI根据环境要求生成CI配置、部署配置JSON结构天然适配。批量文件重命名与重组AI输出“旧路径→新路径”的映射JSON再由脚本执行移动。它的边界也很明显凡是需要强语义判断的输出不适合硬塞进JSON。比如让AI写业务逻辑实现JSON化只会增加表达难度不如直接生成代码文件再靠代码评审把关。另外这套流程对提示词和校验器的维护成本不低。schema改一个字段提示词、Zod、业务规则三处都要同步更新。我建议把schema的版本号放到meta里一旦检测到旧版本允许自动迁移而不是直接拒绝这样后端升级时前端老流水线不至于立刻断掉。6.4 最后分享一点个人心得折腾这套流程花了我大概两周时间其中有四天都在跟各种校验失败较劲。但回头看真正让流程稳定跑起来的不是某个提示词技巧而是那个“把AI当规划师、让自己当施工队”的架构决定。AI的发挥空间一旦被数据结构限制住它的输出质量会稳定很多。如果让我给后来者一个最直接的建议不要指望AI输出的第一版JSON是好的把“校验-回喂-重试”的三段式骨架先搭起来后面所有优化都只是往校验层里加规则。规则越具体模型修得越准流程就越接近“一次生成、直接落盘、拿来即用”的理想状态。
返回列表