让大模型给你生成一套TypeScript脚手架听起来是件特别爽的事——输入一句我要一个Node CLI工具tsup构建vitest测试带ESLint和Prettier回车几十个文件几分钟内全给你吐出来。但你真上手跑一次就会发现这条路上全是暗坑tsconfig里多了一个根本没用到的types: [node, jest]package.json里写着调用tsup的build脚本但依赖列表里压根没有tsup更常见的是模型把JSON注释、尾逗号、Markdown代码块直接塞进文件内容你往磁盘一写报错比生成速度还快。我折腾了几轮之后把方案收敛成一句话不让AI直接生成文件而是让它生成一份严格JSON描述校验通过后再落盘、渲染成真实项目。这套机制跑通以后脚手架从一次性生成变成了可复现、可审计、可diff的工程产物。这篇文章就把这套方案的完整思路、核心代码和踩坑记录拆开讲清楚适合正在用AIGC提效、但被不稳定输出折磨的TypeScript开发者。1. 方案思路为什么不能让AI直接生成文件1.1 直接让AI写文件的三个真实痛点第一个痛点是格式污染。大模型本质是Token续写器它对输出一段JSON的理解经常跑偏。我让模型输出一份tsconfig的JSON它给我返回了带说明文字、代码块包裹、甚至穿插自然语言的混合内容。你可以写个正则把代码块剥掉但剥完还得处理注释、处理没闭合的引号、处理全角字符。这些工作在单次生成里看似是小事一旦批量生成几十个文件清洗逻辑的复杂度就直接失控。第二个痛点是幻觉依赖。模型对这个项目需要哪些依赖的判断完全是概率性的。它可能觉得Vitest和Jest长得差不多顺手在devDependencies里同时塞了vitest和jest或者给一个纯Node项目安排了types/express。这种问题靠肉眼审查很难发现因为文件分散在不同目录你不可能逐个检查依赖矩阵。等项目跑起来报cannot find module再回头查生成的JSON早就被改得面目全非了。第三个痛点是不可追溯。直接生成的是一堆最终文件你只知道它长这样不知道它为什么长这样。同一个prompt在不同时间、不同模型版本下生成的结果千差万别每次diff都是噪音。哪天你发现生成的脚手架里有一个安全漏洞或者依赖版本问题想定位是哪次生成、哪条prompt引起的几乎不可能。1.2 严格JSON落盘到底在解决什么问题把AI输出转成严格JSON并落盘本质上是在AI的自由输出和文件系统的确定性要求之间加一道闸门。这道闸门做了三件事第一位置固定。不管模型吐出来的是代码块、是带前缀结尾的散文、还是半截JSON清洗器都把它收敛成一个纯JSON字符串再用JSON.parse做语法级确认。这一步消灭了格式污染保证落盘的一定是合法JSON。第二结构受控。JSON.parse只验证语法不验证业务含义。所以还需要一层Schema校验对字段类型、必填项、枚举值、路径格式做严格约束。这层校验把幻觉依赖和非法结构拦截在写盘之前。第三状态留痕。严格JSON代表生成决策的完整快照独立于最终渲染出的文件。脚手架文件可以随时改但这份JSON记录了当次生成的所有输入参数、Schema版本、模型输出清洗结果。它既是审计日志也是复现基线。这套思路参考了编译器的做法大模型是前端负责把人话翻译成中间表示IRSchema校验是类型检查器落盘的JSON是IR模板渲染是后端负责把IR转成目标平台的产物。加了这层IR之后AI的不稳定性被隔离在文件系统之外。1.3 整体工作流一次生成的全过程整套流程可以概括为七步项目参数入参项目名、运行时版本、包管理器、目标模块、CLI还是库等。组装Prompt把项目参数和预置的JSON Schema指令拼接成提示词。模型生成大模型输出一份描述项目结构的JSON。原始输出清洗提取纯JSON片段处理代码块包裹、BOM、注释残留。双重校验先用JSON.parse做语法校验再用Zod做结构校验。严格落盘把校验通过的JSON原子写入project.blueprint.json归档到历史目录。模板渲染按JSON描述的路径和模板ID渲染出真实项目文件。这套流程把生成和落盘分离所以任何一步出错都能单独排查。下面逐段拆开讲。2. 核心设计JSON Schema与Prompt约束2.1 脚手架元模型把项目结构翻译成JSON Schema先明确一个概念让AI生成的不是项目文件本身而是对项目的结构化描述。我把这份描述叫脚手架元模型它用JSON Schema定义一个TypeScript脚手架应该长成什么样。下面这份Schema是我实际在用的简化版它规定了顶层必须有哪几个字段、每个模块必须包含哪些文件、每个文件必须满足什么路径格式{ $schema: https://json-schema.org/draft/2020-12/schema, type: object, required: [projectName, runtime, packageManager, modules], properties: { projectName: { type: string, pattern: ^[a-z][a-z0-9-]*$ }, runtime: { enum: [node18, node20, bun] }, packageManager: { enum: [npm, pnpm, yarn] }, modules: { type: array, items: { type: object, required: [id, files], properties: { id: { type: string, enum: [core, cli, test, lint, e2e] }, files: { type: array, items: { type: object, required: [path, templateId], properties: { path: { type: string, pattern: ^[A-Za-z0-9_./-]$ }, templateId: { type: string } } } } } } } } }几个设计点值得说明。projectName用了pattern限制只能是小写字母、数字和连字符这是npm包名的基本规则也是目录名的安全规则能在早期拦截大部分路径穿越和非法命名。modules里的id字段用了enum枚举出核心模块、CLI模块、测试模块、Lint模块和E2E模块模块粒度必须在生成之前定死否则AI给你造出个中台模块你会很难收场。templateId是模板渲染器的索引键它不直接存文件内容而是存这份文件用哪个模板渲染。注意Schema里的templateId和path都不涉及具体文件内容。文件内容交给模板渲染器JSON只负责描述结构和选择模板。这样设计的好处是你可以随意切换模板实现而不影响已经落盘的描述JSON。2.2 Prompt设计让模型输出可落盘的JSONSchema只管校验真正让模型产出合规JSON靠的是Prompt。Prompt设计的目标是让模型把注意力放在填字段而不是自由发挥上。我的做法是给模型一个极简的角色设定、输入参数、输出Schema摘要和一个少样本示例。下面是一段实际用过的Prompt骨架你是TypeScript脚手架设计专家。你只输出JSON不输出任何解释、注释或Markdown。 根据以下输入参数设计一份脚手架描述JSON - 项目名: my-tool - 运行时: node20 - 包管理器: pnpm - 需要模块: core, cli, test, lint 输出必须满足 1. 顶层包含 projectName、runtime、packageManager、modules 四个字段。 2. modules 是一个数组每个元素包含 id 和 files。 3. path 必须使用正斜杠不允许反斜杠和空格。 4. 不要生成 package.json 包含尚未声明的依赖模块。 示例 { projectName: my-tool, runtime: node20, packageManager: pnpm, modules: [ { id: core, files: [ { path: src/index.ts, templateId: plain-ts } ] } ] }这份Prompt的关键词是只输出JSON不允许必须满足。模型对负面约束的理解通常比正面要求更精确所以我把不要输出解释不允许反斜杠这类禁令放在最显眼的位置。少样本示例的价值在于让模型模仿结构我的经验是示例越贴近目标输出模型越不会跑偏。还有一个小细节范例里我只给了一组简化示例没有把整个项目结构全秀出来。如果示例太完整模型会倾向于抄答案从而丢失对输入参数的变化响应如果示例太少模型又容易遗漏必填字段。一两个代表性模块是最好的平衡点。2.3 为什么说IR比最终代码更值得信赖我反复强调生成JSON描述而不是直接生成代码这个选择值得一个专节讲透。当AI直接生成代码时错误是藏在代码语义里的。它可能在Vite配置里用了Node的path模块但没引入类型声明可能写了process.cwd()但忘了兼容Windows这些错误在文件系统视角看都是合法的只有运行时才会暴露。你要审查的是几十个文件里的深层语义这是人工成本极高的事。当AI生成JSON描述时错误被压缩到结构性问题上。文件路径格式错了、模块枚举出了范围、必填字段缺了——这些问题全部可以被程序化校验不需要人肉看代码。你审查的对象从整个项目的每个字符缩减为一份JSON的结构工作量不是一个量级。更关键的是稳定性。前面说了AI直接生成文件时每次输出都不同diff全是噪音而落盘的JSON是稳定产物同一次生成的JSON可以反复渲染渲染结果完全一致。这意味着你可以对同一份脚手架快照做可重复的构建也可以在CI里对两份JSON做语义化diff直接看到项目结构的演变过程。3. 实操过程从AI输出到严格落盘3.1 工具选型zod、ajv与handlebars的分工先交代栈。生成端接入哪个大模型API取决于你的团队环境有的用Claude有的用GPT有的走内部网关这些不影响核心逻辑。真正影响方案的是校验和渲染工具。我在核心路径上选了四样东西工具职责为什么是它node:fs文件读写、原子写入Node原生能力不引入额外依赖zod运行时结构校验TypeScript原生类型推导校验器即类型定义ajvJSON Schema标准校验支持2020-12草案生态成熟handlebars模板渲染语法宽容、条件判断简单、非技术人员也能改模板Zod和ajv在我这里同时存在各有分工。ajv负责做标准合规检查因为Schema是JSON格式可以独立成文件、被多个工具消费也可以由团队里不写TS的同事评审。Zod负责在代码里做运行时窄化它校验通过后TypeScript类型就自然收窄到了具体结构后续渲染代码不需要再做类型断言。dependencies方面尽量精简脚手架自身这也是吃自己的狗粮。3.2 原始输出清洗从Markdown代码块到纯JSON模型输出的原始字符串最典型的形态是这样的好的下面是生成的脚手架描述 json { projectName: my-tool, runtime: node20, ... }希望对你有帮助清洗器的目标就是把这个东西剥成一个能被JSON.parse接受的字面量。我的实现分四步走 typescript export function extractStrictJson(raw: string): string { // 1. 去掉 json ... 代码块 const fenced raw.match(/(?:json)?[\r\n]*([\s\S]*?)/); if (fenced) { raw fenced[1]; } // 2. 去掉 BOM raw raw.replace(/^\uFEFF/, ); // 3. 去掉自然语言前缀和后缀取第一个 { 到最后一个 } const start raw.indexOf({); const end raw.lastIndexOf(}); if (start ! -1 end ! -1 end start) { raw raw.slice(start, end 1); } // 4. 暴力清理常见污染字符全角引号、零宽空格 raw raw .replace(/[\u200B-\u200D\uFEFF]/g, ) .replace(/[\u201C\u201D]/g, \) .replace(/[\u2018\u2019]/g, ); return raw.trim(); }第一步去代码块是常规操作第二步处理BOM是真踩过的坑——有一次Windows用户的机器上生成的JSON字符串最前面藏了一个不可见字符JSON.parse直接抛错。第三步的取第一个{到最后一个}是兜底策略因为模型偶尔还是会在JSON前后夹带自然语言。第四步的零宽空格清理属于玄学防御有些模型会输出肉眼不可见的格式字符防不胜防。注意第三步的截取逻辑必须加end start的判断否则内容里没有JSON时会把整个字符串截成空串。清洗器宁可返回原始片段也不能返回被截坏的半截JSON因为后续Zod校验会给出明确错误而半截JSON的错误信息往往让人摸不着头脑。3.3 双重校验语法严格与结构严格清洗通过后先过JSON.parse这一步不过直接报落盘失败非法JSON语法返回。过了语法关再过结构关。结构校验我用Zod写了与Schema对应的运行时定义import { z } from zod; const FileNodeSchema z.object({ path: z.string().regex(/^[A-Za-z0-9_./-]$/), templateId: z.string().min(1), }); const ModuleSchema z.object({ id: z.enum([core, cli, test, lint, e2e]), files: z.array(FileNodeSchema).min(1), }); export const BlueprintSchema z.object({ projectName: z.string().regex(/^[a-z][a-z0-9-]*$/), runtime: z.enum([node18, node20, bun]), packageManager: z.enum([npm, pnpm, yarn]), modules: z.array(ModuleSchema).min(1), }); export type Blueprint z.infertypeof BlueprintSchema;这里有一个容易被忽略但极其有用的细节z.infer从Schema自动推导出的Blueprint类型让落盘后的JSON在代码里是一等公民。校验函数可以写成export function parseAndValidate(input: unknown): Blueprint { const result BlueprintSchema.safeParse(input); if (!result.success) { const issues result.error.issues.map(i ${i.path.join(.)}: ${i.message}); throw new Error(Blueprint校验失败:\n${issues.join(\n)}); } return result.data; }错误信息里我故意把path和message拼成一行一行输出。因为真实场景里模型输出的JSON可能同时缺字段、枚举越界、路径字符非法一次性把所有问题列出来比逐条修要有用得多。你把这些issue直接贴回Prompt里让模型自己改效率也比人肉改JSON高。3.4 原子落盘与历史归档校验通过后进入落盘环节。落盘我坚持两个要求原子写入和历史归档。原子写入的经典做法是写临时文件再rename避免在写入中途进程崩溃时留下半截JSONimport { writeFileSync, renameSync, mkdirSync } from node:fs; import { dirname } from node:path; export function atomicWriteJson(filePath: string, data: unknown): void { const tmpPath ${filePath}.${process.pid}.tmp; const json JSON.stringify(data, null, 2) \n; mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(tmpPath, json, utf-8); renameSync(tmpPath, filePath); }注意临时文件名的设计${filePath}.${process.pid}.tmp带上进程ID避免多个进程同时写同一目标时互相覆盖。JSON.stringify(data, null, 2)加空格的缩进是故意保留的因为它直接落盘后可以通过git diff看到字段级别的变化压缩成一行的话任何diff体验都是灾难。历史归档的做法是把每次生成的Blueprint复制到.blueprint/history/目录文件名带时间戳和输入哈希.blueprint/ latest.json history/ 2024-06-18T10-30-00Z_a3f2c.json 2024-06-18T11-45-22Z_b2e91.jsonlatest.json始终指向本次生成结果history里按时间排每次快照文件名的哈希是输入参数项目名、运行时、包管理器、模块列表的SHA-256前五位。这样你查历史时一眼就能看出同样的输入在哪个时间段生成过几次。这套归档结构是整个方案里最便宜但最有价值的部分。3.5 模板渲染从JSON描述到真实项目落盘JSON只是设计图真正生成项目还得靠渲染。渲染器从Blueprint读取每个模块的每个文件节点按templateId找到对应模板把上下文变量填进去后写到目标路径import Handlebars from handlebars; import { writeFileSync, mkdirSync } from node:fs; import { dirname, join } from node:path; import type { Blueprint } from ./schema; export function renderBlueprint(blueprint: Blueprint, baseDir: string): string[] { const written: string[] []; for (const mod of blueprint.modules) { for (const file of mod.files) { const template loadTemplate(file.templateId); const ctx { projectName: blueprint.projectName, runtime: blueprint.runtime, moduleId: mod.id }; const content template(ctx); const targetPath join(baseDir, file.path); mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, content, utf-8); written.push(targetPath); } } return written; }模板是.hbs后缀的纯文本文件内容和常规脚手架文件几乎一样只是把项目名、版本号、模块名这些变量替换成Handlebars占位符。比如package.json模板{ name: {{projectName}}, version: 0.1.0, type: module, scripts: { build: tsup src/index.ts --format esm,cjs --dts, test: vitest run }, devDependencies: { tsup: ^8.0.0, typescript: ^5.4.0, vitest: ^1.6.0 } }模板里只出现确定要装的依赖不依赖AI的临场发挥。这样就把依赖合不合理的判定从模型手里拿回来放到模板作者的掌控中。渲染前可以做一次模板覆盖检查如果渲染出来的package.json里出现了模板没有声明的依赖说明模板ID映射出了问题应该阻断构建而不是让它偷偷溜过去。4. 常见问题与排查技巧实录4.1 JSON解析失败排行榜实战跑下来模型输出解析失败的原因高度集中在下面几类错误表现根因处理办法Unexpected token /JSON里混进了//注释或/* */块注释Prompt里明确禁止注释清洗阶段删除注释正则Unexpected token ,数组或对象末尾多出一个逗号清洗阶段去掉尾逗号或改用宽容解析器兜底Bad control character字符串里混入了换行符、制表符在模板层转义校验层拒绝非转义控制符Expected property name键名混入了全角引号、或漏了引号清洗阶段替换全角引号重新生成文件路径带\模型输出了Windows风格路径校验阶段强约束正斜杠Prompt明示数组嵌套过深模型把整个项目所有配置放到一个模块里校验files长度上限Prompt强调按模块拆分这里面尾逗号出现频率最高几乎每次都会碰到。我的清洗函数里没有写死删除尾逗号的逻辑而是在Prompt里强调同时校验允许失败后自动带上下文重试一次——把Zod报错列表原样贴回给模型让它修正后重新输出完整JSON。这个校验失败回灌的循环比让开发人员手工改JSON有效得多。4.2 模型幻觉字段的兜底策略幻觉是AIGC的固有属性完全消除不现实但可以在设计上把影响面压到最小。我现在依赖三层兜底。第一层是白名单约束。Schema里凡是能枚举的字段全部用枚举依赖名、运行时、模块ID都不允许自由文本。这样做让模型的选择空间从无限变成预定义集合幻觉自然被压缩到集合内。第二层是回退值策略。对非关键字段如果模型输出的值不在枚举里而且没有明显语义冲突用Schema里的default值覆盖而不是直接失败。比如runtime字段如果给出个node22但不在支持列表里回退到node20比中断整次生成更符合实际需求。第三层是黑名单依赖检查。渲染前扫描所有templateId和package.json里出现的包名如果在已知幻觉依赖黑名单里比如express出现在非web项目、jest与vitest同时出现直接报阻断错误。这三层兜底并不能消灭幻觉但能把幻觉从悄悄写进文件变成显式报错或按预设值纠正。工程里不怕出bug怕的是bug藏得无声无息。4.3 用diff回溯一次失败的生成最后说一个我每天都在用的排查手段把落盘的Blueprint纳入版本管理。一旦project.blueprint.json进了Git每次生成都是一次可追溯的提交。真实案例我遇到过一次生成的脚手架在CI里莫名其妙构建失败我打开Git历史发现当次Blueprint的diff只有一行——runtime从node20变成了node18而本地恰好没有Node 18。如果我当时没有落盘JSON这个锅八成会甩给AI抽风但我拿着diff去对比Prompt输入发现是上一个同事顺手改了输入参数没同步说明。有了这份diff排查时间从半小时缩短到两分钟。更进一步我还会把清洗前的原始模型输出也顺手存一份.blueprint/raw/。这样不仅能看到最终被校验通过的结构还能看到模型最初输出的样子。这个组合拳对分析模型行为、优化Prompt有直接的指导意义。5. 进阶技巧从单次生成到可持续演化5.1 用interface继承组织多级元模型前面提到ts-json-schema-generator能把TypeScript interface直接转成JSON Schema。这个工具的价值在于你可以用interface继承来组织多级元模型。定义一份BaseScaffold接口描述所有TypeScript项目的公共结构projectName、runtime、packageManager再让NodeCliScaffold继承它并增加CLI专属字段让LibScaffold继承它并增加库模式专属字段。export interface BaseScaffold { projectName: string; runtime: node18 | node20; packageManager: npm | pnpm | yarn; } export interface NodeCliScaffold extends BaseScaffold { binName: string; modules: [core, cli, test, lint]; }Zod的z.infer和interface的继承能力天然互补。这样你维护的是TypeScript里一套有层级的类型定义Schema和校验器都由它派生不会出现类型改了但Schema忘了同步的经典事故。这也是为什么我在整套方案里选择TypeScript作为实现语言——它自带的类型系统就是最好的元模型载体。5.2 把落盘结果接进CI流水线落盘机制稳定之后我把它从本地脚本升级成了CI流水线的一环。每个PR里只要输入参数文件或者模板有改动流水线自动执行生成、校验、落盘然后把git diff贴在PR评论区。评审者不需要运行任何东西直接看Blueprint的diff就能判断改动对项目结构的影响。这个流程还天然支持生成一致性测试在CI里跑两次同样的输入参数对比两份严格JSON是否完全一致。如果不一致说明模板或Prompt里存在非确定性因素CI就会标红。这一道检查把AIGC不可重复这个最大的工程隐患变成了可量化、可回归的测试项。5.3 从固定模板到实验矩阵最后分享一个我正在尝试的扩展方向用相同输入参数跑多个模型版本把生成的JSON分别落盘到experiments/模型名-版本号/目录然后用脚本对比字段级差异。这套实验矩阵能回答一些很有意思的问题哪个模型对必须满足枚举约束的遵循度最高、哪个模型容易在JSON里插注释、升级模型版本后脚手架文件名会不会变。这些结论不需要人工统计写个几十行脚本从历史归档里就能算出来。当你的AIGC脚手架方案运行三个月后回头看看这些数据你会对该不该升级模型哪个环节需要更严格的兜底有比拍脑袋准确得多的判断。踩过几次坑之后我现在做脚手架生成的习惯是凡是让AI产出最终文件的操作一律先让它产出JSON清洗、校验、归档再渲染。这一步多花的几分钟换来的是一年后你依然能说清楚这个项目的结构当初是怎么长出来的。如果你也在做AIGC生成代码的尝试这套严格JSON落盘的思路可以直接抄过去无论你是生成TypeScript项目、配置文件还是文档结构中间加一道结构化校验的闸门体验会完全不一样。