让大模型给你生成一套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版本、模型输出清洗结果。它既是审计日志,也是复现基线。
这套思路参考了编译器的做法:大模型是前端,负责把人话翻译成中间表示(IR);Schema校验是类型检查器;落盘的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设计:让模型输出可落盘的JSON
Schema只管校验,真正让模型产出合规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原生类型推导,校验器即类型定义 |
ajv | JSON 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.infer<typeof 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,避免在写入中途进程崩溃时留下半截JSON:
import { 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项目、配置文件还是文档结构,中间加一道结构化校验的闸门,体验会完全不一样。