ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AIGC生成TypeScript脚手架:严格JSON校验与落盘引擎实战

2026/10/4 15:16:16 拓冰建站 浏览量
AIGC生成TypeScript脚手架:严格JSON校验与落盘引擎实战 花了半个周末把用AIGC生成TypeScript脚手架这件事完整落地了核心就卡在一个点上AI输出不能直接当工程产物必须经过一道严格JSON校验再落盘。这套方案做完之后最大的感受是AIGC在脚手架这个场景里确实能大幅度缩短项目初始化流程但前提是你得先给它套好缰绳。今天把这套思路、代码和踩过的坑一次性说清楚给正在做类似事情的朋友一个能直接抄作业的参考。这套方案适合谁来用如果你手头有AI接口的调用能力又希望告别每次新建项目都手动拷贝配置、手工改package.json的重复劳动那这篇文章正好帮你把AI生成项目骨架变成一条自动流水线。我会先从为什么选JSON当中间层讲起再拆数据模型和校验环节最后把落盘引擎和问题排查完整展开。1. 为什么非要用JSON当中间层1.1 AIGC直接生成文件和直接生成JSON有什么不同最早我试过让AI直接输出一个完整的项目目录比如让它给我生成一个带vitest和eslint的TypeScript库项目。结果可想而知AI给出的往往是一堆散装的文件片段有的路径对不上有的依赖缺失甚至同一个配置在package.json里出现两次。最麻烦的是你没法对这份输出做任何结构化检查只能肉眼一个个看。后来换了个思路不让AI直接生成文件而是让它生成一份描述项目的JSON再由本地代码负责把JSON解析成真实文件。这个转变很关键。JSON是结构化数据天然可以被schema校验、被diff对比、被重新生成而自由文本没有这些能力。换句话说JSON在这里承担的是中间表示层的角色AI只负责产出内容不负责动磁盘最终的文件落盘由一个确定性的引擎完成。1.2 严格到底严在哪里严格JSON落盘不是修辞是字面要求。我所谓的严格包含三层第一是格式严格AI输出必须是一个合法的JSON对象不能带markdown代码块标记不能夹带解释文字第二是结构严格JSON里的每个字段都要符合预定schema比如文件路径必须符合安全约束内容字段必须是字符串第三是落盘严格不是简单地读到就写而是要做路径校验、原子写入、幂等控制防止一次生成把已有项目搞得乱七八糟。这三层缺一不可。很多人做AIGC脚手架只做到第一层觉得能parse出来就行结果AI生成一个形如../../etc/passwd的路径就傻眼了。把严格前置到设计阶段后面出问题的概率会低很多。2. 脚手架数据模型设计2.1 文件树与文件内容的JSON表达先看我实际使用的JSON结构。整个脚手架定义就是一个对象包含项目名和一个文件数组{ name: my-ts-lib, version: 0.1.0, files: [ { path: package.json, content: {\n \name\: \my-ts-lib\,\n \version\: \0.1.0\\n}\n }, { path: tsconfig.json, content: { \compilerOptions\: { \strict\: true } }\n }, { path: src/index.ts, content: export const version 0.1.0;\n } ] }文件数组是核心每个元素至少包含path和content两个字段。path代表文件在项目里的相对路径content是文件正文。有人会问为什么不直接用树形结构表示目录我试过嵌套树AI在深层嵌套时很容易把父子关系搞错而扁平数组加路径的方式更不容易产生歧义也方便后续排序和去重。2.2 配套的TypeScript类型定义JSON是运行时的数据格式但工程里必须有一份静态类型来约束它。我用了zod因为zod既能定义类型又能直接当运行时校验器用省掉手写两份逻辑的麻烦import { z } from zod; export const ScaffoldFileSchema z.object({ path: z.string() .min(1) .regex(/^[a-zA-Z0-9_./-]$/, 路径包含非法字符), content: z.string(), encoding: z.enum([utf8, base64]).optional(), overwrite: z.boolean().optional(), }); export const ScaffoldSchema z.object({ name: z.string().regex(/^[a-z0-9-]$/, 项目名必须为kebab-case), version: z.string().regex(/^\d\.\d\.\d$/).optional(), files: z.array(ScaffoldFileSchema).min(1), }); export type Scaffold z.infertypeof ScaffoldSchema;这段类型定义实际上就是整条流水线的核心契约。AI生成的内容要先过这个schema过不了直接判失败不会进入落盘环节。我在这里对path做了严格限制只允许字母、数字、下划线、点、斜杠和连字符这一下就把路径穿越和绝对路径等问题挡在外面了。2.3 关于schema校验的三道关卡一份AI生成的JSON要经过三道关才能落盘。第一关是AI侧的提示词约束我要求模型只输出纯JSON第二关是代码侧用zod做完整校验第三关是落盘时的路径和覆盖检查。三道关看起来重复但实际上每一道都在防不同种类的错误。第一关防的是格式污染比如AI把JSON包在代码块里虽然视觉友好但对程序就是毒药第二关防的是语义错误比如字段拼错、类型不对第三关防的是环境冲突比如目标目录已经存在同名文件或者路径解析出来根本不在工作区内。三道关缺一不可我见过不少简化到只剩一道关的方案最后都在某个环节翻车了。3. AI生成环节的提示词工程3.1 提示词模板设计JSON中间层定了之后AI生成并不是把需求丢进去这么简单。我维护了一套固定的提示词模板每次生成都复用核心逻辑是明确角色、明确输出格式、给出few-shot样例、给出硬性约束。你是一个 TypeScript 项目脚手架生成器。 请根据以下需求生成项目定义输出为严格的 JSON。 硬性要求 1. 只输出 JSON不要输出任何解释、注释或 markdown 代码块标记。 2. 项目名使用 kebab-case。 3. 文件路径只能包含字母、数字、下划线、点、斜杠和连字符。 4. 所有文件内容必须是字符串文件内容需要完整不要用省略号或注释代替。 5. 必须包含 package.json 和 tsconfig.json。 参考输出格式 {name:demo,files:[{path:package.json,content:{...}}]} 本次需求 {demand}这里最容易被忽略的是文件内容必须完整这一条。AI在生成长文件时倾向于偷懒写到一半给你来个// ... rest of the code。如果不在提示词里禁止落盘出来的项目根本跑不起来。我甚至在硬性约束里加了省略号属于失败输出这句话实测对减少偷懒效果明显。3.2 结构化输出与退避策略即便提示词写得再详细AI偶尔还是会输出带代码块标记的JSON或者干脆输出一段解释文字。所以我设计了完整的解析流程先把响应里的代码块剥掉再尝试JSON.parse失败则进入重试逻辑。async function generateScaffold(prompt: string, retries 3): PromiseScaffold { for (let attempt 0; attempt retries; attempt) { const raw await callLLM(prompt); const cleaned stripMarkdownCodeFence(raw); try { const parsed ScaffoldSchema.parse(JSON.parse(cleaned)); return parsed; } catch (e) { console.warn(第${attempt 1}次解析失败:, e); } } throw new Error(AI生成脚手架失败已达最大重试次数); }重试时我会把上一次的错误信息附加进提示词让AI知道自己哪里错了。这个错误反馈回路非常有用很多时候第二次生成就能修正问题。另外在调用大模型时把temperature调低一点比如0到0.3之间能明显减少JSON结构漂移的概率。temperature太高AI就像喝多了一样开始自由发挥。4. 落盘引擎的完整实现4.1 目录创建与路径安全JSON校验通过之后真正的落盘逻辑才开始。我把它做成了一个独立模块核心原则是所有路径都要经过解析和校验禁止直接使用AI给的原始路径字符串。import { mkdir, writeFile, rename, access } from fs/promises; import * as path from path; import { Scaffold } from ./schema; function safeResolve(baseDir: string, filePath: string): string { const target path.resolve(baseDir, filePath); if (!target.startsWith(baseDir)) { throw new Error(非法路径: ${filePath}); } return target; }这段代码做的事很简单先把工作目录和目标文件路径做一次绝对路径解析再检查解析结果是否还在工作目录范围内。path.resolve会把../和绝对路径都展开startsWith检查能拦截掉试图跳出工作目录的路径。这个检查必须放在schema校验之外再做一次因为即使单个字段通过了字符白名单拼接起来也可能产生a/../../b这种组合攻击。4.2 原子写入与幂等控制落盘引擎还有一个隐藏要求中途失败不能留下半个项目。我采用了临时文件rename的原子写入策略。先在同目录下写一个带随机后缀的临时文件写完再通过rename替换目标文件。rename在同一个文件系统内是原子操作要么成功要么失败不会出现写了半个文件的情况。async function writeFileAtomic(targetPath: string, content: string) { const dir path.dirname(targetPath); await mkdir(dir, { recursive: true }); const tmp ${targetPath}.${process.pid}.${Date.now()}.tmp; await writeFile(tmp, content, utf8); await rename(tmp, targetPath); }幂等控制同样重要。如果目标目录已经存在某个文件直接覆盖是危险的。我给每个文件加了一个overwrite标记默认false。落盘前先检查文件是否存在如果存在且overwrite不为true就记录冲突并跳过。全部文件处理完之后再统一报告冲突列表而不是边写边报错这样用户可以一次性决定哪些文件要强制覆盖。4.3 文件内容模板的占位符处理最后一个落盘细节是占位符处理。AI生成的package.json内容里经常包含${PROJECT_NAME}这种占位符用来表示这里是项目名称。如果原样写入磁盘项目根本没法用。我在落盘引擎里加了一个简单的模板渲染步骤支持两种占位符一种是{{name}}这种显式变量另一种是AI偶尔会用但语义明确的${name}。function renderTemplate(content: string, vars: Recordstring, string): string { return content.replace(/\{\{(\w)\}\}|\$\{(\w)\}/g, (_, v1, v2) { const key v1 || v2; return vars[key] ?? (v1 ? {{${v1}}} : \${${v2}}); }); }这个正则会把已知变量替换成实际值未知变量保持原样避免误替换。实测中保留未定义变量原样这个设计救了我好几次因为AI有时候会在content里写真正的模板字符串比如配置项里的${VAR}那些不应该被替换。占位符处理放在写入之前渲染后的内容才进入原子写入。5. 常见问题与排查技巧实录5.1 高频问题速查表实际跑下来问题集中在下面几类直接整理成速查表方便对照症状根因处理方式JSON.parse报错提示Unexpected tokenAI输出带了markdown代码块或解释文字先剥离代码块标记再parse可能还需截取第一个{到最后一个}之间的内容校验报错路径包含非法字符AI用了反斜杠或中文字符在提示词里强化路径白名单说明必要时把非法字符自动替换成正斜杠生成的文件缺内容AI用省略号代替了长文件正文提示词里明令禁止省略同时在schema层检查content是否包含...目录已存在且文件冲突重复生成同一项目落盘前收集冲突清单按overwrite标记决定覆盖策略写入后项目跑不起来package.json里依赖版本互相冲突让AI生成一个最小依赖集合运行npm install后做冒烟验证5.2 JSON类型不匹配的现场诊断最典型的类型问题出现在version字段。我定义的version是\d\.\d\.\d格式但AI经常生成version: 1.0这种省略补零的版本号。zod的报错信息虽然会说明字段路径但不会告诉你该怎么改。我的做法是在重试提示词里附上原始报错信息让AI自行修正实测绝大多数情况第二次就能给出符合格式的版本号。还有一个隐蔽问题AI在content字段里生成的内容有时根本不是字符串而是嵌套对象。这是因为模型在输出JSON时会自带美化倾向把package.json的内容当作对象而不转成字符串。解决办法是在提示词和schema两侧同时约束提示词明确要求文件内容转成JSON字符串用转义符处理schema侧的z.string()则会直接拦截非字符串。5.3 覆盖已有目录时的保护策略最后说一下覆盖保护。生成脚手架最危险的操作就是覆盖已有代码。我最终采用的策略是三态覆盖规则默认不覆盖显式标记的覆盖以及备份后覆盖。备份后覆盖适合整个目录重建的场景——先把旧目录改名加时间戳再写入新的脚手架这样即使新项目有问题旧版本还能找回来。async function backupDir(dir: string): Promisestring | null { try { await access(dir); } catch { return null; } const backup ${dir}.bak.${Date.now()}; await rename(dir, backup); return backup; }注意备份用的是rename而不是拷贝因为项目目录动辄上百兆拷贝太慢。rename在同一个分区是瞬间完成的。这个备份函数在整个落盘流程开始时调用返回的备份路径会打印在控制台方便用户后悔时手动恢复。最后分享一点实操体会这套方案我从头到尾落地之后最大的心得是AIGC在工程化场景里不是取代编码而是取代需求到原型的翻译过程。AI的价值在于它能把我要一个库项目翻译成一份包含package.json、tsconfig和各种源码的文件清单而工程化的严格JSON校验和落盘引擎才是这套方案能安全落地的基础。如果你也想做类似的东西我建议先把JSON schema和落盘引擎写好再接入AI顺序千万别反。后面我还在琢磨把这份中间的JSON定义输出到CI里这样每次改需求重新生成的时候Git能直接展示文件级diff——那才是这个方案的完全体形态。