AI编程总翻车?用结构化Spec和测试用例彻底解决代码生成偏差 📅 发布时间:2026/9/9 15:41:51 👁 浏览次数: 你有没有遇到过这种情况需求文档写了两千字边界条件、返回值、异常处理全都有喂给 AI 之后跑出来的代码还是和预期差一大截。我最近也踩了同样的坑。做一个版本范围解析的小模块时spec 写得自认为滴水不漏结果 AI 连续三次都在同一个判断逻辑上翻车。一开始我以为是模型不行后来把问题拆开看才发现真正的问题不在 spec 写没写全而在于我们理解“完整”的方式和 AI 处理信息的方式根本不是一回事。这篇文章我打算把自己的翻车过程、原因拆解和现在在用的改进方法完整梳理一遍。无论你是刚接触 AI coding还是已经在用 AI Agent 写业务代码都应该能从中找到可复用的判断标准。下面我不讲“prompt 技巧”只讲 spec 到底应该怎么组织才能让 AI 真正做对。1. 为什么 spec 完整不等于 AI 能做对1.1 自然语言是“高语境”的代码是“低语境”的平时我们写需求文档默认读者是一个有经验的工程师。你写“如果输入不合法就返回错误”对方能自动理解成可能要考虑 null、空字符串、超长字符串、类型错误甚至还要考虑要不要抛异常。但 AI 不一样它确实读过大量代码可它对你那段自然语言的解读是基于概率分布而不是基于你脑子里的上下文。举个例子。你在 spec 里写“版本号不超过三段时忽略多余部分”这在你看来很清楚意思是“如果传了 1.2.3.4就解析成 1.2.3忽略 .4”。但 AI 可能会理解成“遇到多余部分就返回错误”也可能理解成“多余部分直接丢弃但仍然解析成功”。两种理解都能从自然语言里找到依据可它们是完全不同的行为。自然语言描述的是意图代码需要的是确定性的转换规则。这就是第一层错位。我并不是说 spec 写得细没有用。但如果你没有意识到“自然语言本质是高语境语言”那你写出来的“完整”就只是人类视角的完整AI 不一定能还原出你脑补的那些默认前提。1.2 完整的是“业务规则”不完整的是“计算规则”很多人写 spec 会把业务规则写得很细什么状态下要返回什么状态码、哪个操作要记录日志、权限不足时要返回 403。这些当然重要但它们属于“业务层完整性”。真正让 AI 作出错误决策的往往是“计算层完整性”缺失。所谓计算层指的是输入输出的精确形态、类型转换规则、边界阈值、比较顺序、异常对象的结构。比如你写“解析版本范围字符串支持 2.7 这种带等号前缀的写法”但你没写“2.7 和 2.7 是否等价”“带空格时要不要先 trim”“如果等号重复出现怎么处理”。AI 遇到这些未定义区域时不会停下来问你它会选择一个它见过的常见模式然后继续生成。这个模式恰好和你的预期不一致就出现了“看起来完整但还是做不对”的诡异现象。我后来复盘发现自己写 spec 时习惯性把“应该怎么做”写得很多但把“对于每一个可能的输入输出应该是什么”写得很少。前者是给人类看的后者才是给 AI 或者测试用例看的。1.3 AI 不会主动追问你没定义的地方人类开发者在拿到一份 spec 后遇到不明白的地方会问这个空字符串算不算合法错误返回是 null 还是抛异常多个规则冲突时哪个优先但 AI 在生成代码时通常没有这个习惯尤其是你把它当成“单次生成工具”用的时候。你给它一份完整 spec它只会按自己的理解直接生成一版代码。如果你运气好它生成的代码碰巧覆盖了你的预期运气不好它会在某个边界条件上自由发挥。而且自由发挥的部分往往不是凭空乱写而是从训练数据里拿了一个“看起来很像”的模式。这在代码审查时特别难发现因为它不是语法错误而是语义偏差。所以问题的关键不是“spec 不够完整”而是“spec 里没有把 AI 可能自由发挥的空间全部堵死”。人类能容忍含糊AI 不能。你需要主动用一个更机械、更容易验证的方式来描述行为。2. 一次实际翻车spec 很完整AI 为什么做错2.1 我当时写的 spec 长什么样为了不抽象我拿最近的实际例子来说。我需要一个解析版本范围字符串的函数输入是字符串输出是一个结构化对象。当时我写的 spec 大概是这样的支持精确版本1.2.3返回{ type: exact, version: [1, 2, 3] }支持范围版本1.2.3返回{ type: range, operator: , version: [1, 2, 3] }支持latest返回{ type: latest }输入为空字符串时返回错误输入包含非数字字符时返回错误版本号不超过三段时多余部分忽略错误统一返回{ error: INVALID_VERSION_SPEC }这份 spec 在我眼里已经够完整了。它有输入输出、有边界条件、有错误处理方式。然后我把这个 spec 直接贴给 AI让它生成 JavaScript 实现。2.2 AI 返回的代码差在哪里AI 第一次生成的代码长这样简化版function parseVersionSpec(input) { if (!input) return { error: INVALID_VERSION_SPEC }; const trimmed input.trim(); if (trimmed latest) return { type: latest }; const match trimmed.match(/^(||||)?\s*v?([\d.])$/i); if (!match) return { error: INVALID_VERSION_SPEC }; const op match[1] || exact; const version match[2].split(.).map(Number); return { type: range, operator: op, version: version.slice(0, 3) }; }初看好像没问题正则能匹配2.7版本号也做了截断。但我实际一测发现问题集中在几个地方。第一它把1.2.3也返回成了{ type: range, operator: exact }而不是 spec 里要求的{ type: exact }。第二当输入是1.2.3.4时它用slice(0, 3)处理结果是[1, 2, 3]这个符合要求。可当输入是1.2.3-beta.1时正则里的[\d.]匹配不上字母整个返回错误。这也不能说错因为 spec 写了“包含非数字字符时返回错误”但没有明确版本号里的预发布后缀算不算非数字字符。第三如果输入是1.2.3 尾部有空格它先 trim 了所以没问题。但如果输入是 1.2.3正则里的\s*能处理可它没想过要保留原始输入以便错误定位。你说它做得不对吗按你没写清楚的标准它其实做出了一个自洽的选择。问题是你心里的“完整”和它执行的“完整”之间出现了三条以上可以有不同的解释路径。2.3 逐条问题对应回 spec发现我漏了什么我后来把这些问题重新对应回我的 spec发现每一个看起来像 AI 犯错的地方其实都能追溯到我漏掉了明确约束AI 的“错误”行为我 spec 里没写清楚的地方精确版本返回type: range没有明确1.2.3必须用type: exact只是例子这样写了预发布后缀直接报错没有定义1.2.3-beta.1是合法还是非法2.7被解析成带的范围没有明确2.7和2.7是否等价多余版本段被静默忽略没有说明是忽略还是返回错误虽然我主观上要忽略错误对象没有字段说明只写了返回{ error: INVALID_VERSION_SPEC }没有定义是否还要带input字段这份表格让我意识到所谓“完整 spec”如果不站在机器验证的角度去审视就永远会有漏洞。给人类审查他能脑补给 AI 执行它只能猜。3. 拆开看AI 做不对的核心机制3.1 上下文窗口不是无限内存先说一个很现实的问题。Spec 再完整如果超过模型上下文窗口的有效范围信息一定会折损。现在主流模型的上下文窗口动辄几十万 token看起来很大但有效利用率和物理上限是两回事。我做过一个实验把一份 2 万字的规范文档直接塞给 AI要求它按照文档实现某个模块。结果发现它对文档开头的要求执行得相对准确对中间部分的约束开始选择性地遗忘对最后几页的内容几乎完全忽略。这不是模型“不听话”而是注意力机制在长输入上天然有衰减。尤其在生成长代码时模型要同时维持正在生成的代码、之前生成的结构和最初的业务约束过长的 spec 反而会稀释关键信息。所以把“写得很完整”的 spec 直接丢给 AI未必比写一份精炼的、带测试用例的 spec 更有效。“完整”不该体现在字数上而该体现在约束密度上。3.2 AI 是逐 token 生成的不是全局编译的很多人对 AI 编程工具有一个错误预期认为它能像编译器一样理解整个项目然后输出全局最优方案。实际上模型在生成代码时是逐个 token 预测的。每一个 token 的选择只依赖于它之前的上下文不存在“先生成一个完整抽象语法树再检查逻辑是否自洽”的步骤。这就导致了一个很常见的问题AI 写函数开头的时候对变量名的用法可能是 A 风格写到函数结尾为了处理某个边界条件可能不小心用了一个语义不同的写法。单看局部都没问题合在一起就是双重判空、重复赋值、分支逻辑互相覆盖。这个机制决定了你给 AI 的 spec 越“大而全”它越难在生成过程中维持全局一致性。相反把任务拆成小粒度每个函数只处理一个明确行为AI 的准确率会高很多。3.3 没有可执行的测试用例AI 就缺少纠错依据你可以把 AI 当成一个“特别擅长写代码但不太会自我检查”的开发者。如果你给它 spec 之后不提供任何测试预期它只能按自己的理解写完就结束。它不会主动写测试除非你明确要求。我后来做了一个对比实验同一份 spec第一轮只给文字描述第二轮在文字描述后面附上 5 组输入输出测试用例。结果是第二轮生成的代码在第一次就通过了我准备的全部用例第一轮生成的代码则需要修 3 个 bug。原因很简单测试用例让 AI 有机会在生成时就“对照答案”它能根据输入输出反推你的真实意图而不是猜一个可能的方向。更重要的是测试用例本身就是一种更精确的约束语言。你写“版本号超过三段时忽略多余部分”AI 可能在slice(0,3)和filter之间犹豫但你写了parse(1.2.3.4)应该返回[1,2,3]它就只剩一条路了。3.4 运行时环境差异spec 根本描述不了还有一个经常被人忽视的问题。AI 生成的代码不仅要满足业务逻辑还要能跑在你的目标环境里。可 spec 里很少会写“这个函数的临时文件应该写到当前工作目录不能跨设备 rename”。你也不会在需求文档里写“文件系统挂载点和容器内路径不同”。我遇到过最典型的问题是 AI 生成的脚本在本地测试机上运行正常到了 CI 环境就报exdev: cross-device link not permitted。原因是脚本里用了rename操作源文件和目标文件分别落在两个不同的挂载点上跨文件系统重命名不被允许。AI 生成代码时根本不知道你的部署环境长什么样它只会按常规方式用rename。这类问题不是靠把 spec 写得更长能解决的而是要在 spec 里显式加入环境约束或者让 AI 在生成后跑一遍真实的测试命令靠环境反馈来修正自己。所以我要强调一点spec 之外必须有闭环。要么提供可运行测试要么提供一个让 AI 能获取错误信息的执行环境。否则它的“做对”只能是静态意义上的对而不是运行意义上的对。4. 把 spec 改成 AI 更好执行的样子4.1 用结构化表格替代大段自然语言如果一份 spec 里超过三分之一是连续的自然语言段落那它对 AI 的友好度就已经打折了。我现在的习惯是把关键行为全部整理成表格。表格的好处是每一行都是一个独立的输入输出对模型在训练数据中见过太多类似格式它更擅长从这种结构里提取约束。还是以版本解析为例。我会直接这么写输入期望输出1.2.3{ type: exact, version: [1, 2, 3] }2.7{ type: exact, version: [2, 7] }1.2.3{ type: range, operator: , version: [1, 2, 3] }latest{ type: latest }{ error: INVALID_VERSION_SPEC }1.2.3.4{ type: exact, version: [1, 2, 3] }abc{ error: INVALID_VERSION_SPEC }1.2.3-beta.1{ error: INVALID_VERSION_SPEC }表格写完之后你还需要补几条“为什么”说明但说明应该放在表格后面而不是代替表格。模型生成代码时会优先参考这些输入输出对它不需要理解你的业务背景只需要把映射关系做对。4.2 每条规则都配上“正例/反例”只写规则还不够我建议每条规则至少配一个正例和一个反例。反例尤其重要因为 AI 容易在“不应该做什么”上放飞自我。比如你写“支持范围版本”就要同时给出“1.0.0 合法”和“1.0.0 不能误判为范围”的反例。你写“空字符串返回错误”就要额外说明 这种全空白字符串算不算空。你写“多余版本段忽略”反例就是“如果输入是 1.2.3.4.5.6不能返回错误”。每一条反例都在帮 AI 划清楚它自由发挥的边界。这个方法也特别适合配合测试驱动开发。你可以把每个正例/反例直接转成测试用例让 AI 先写测试函数再写实现。测试本身就是 spec 的机器可执行版本。4.3 用接口签名和类型定义替代“意会”如果你是写强类型语言给 AI 提供接口签名和类型定义比写一百句自然语言都管用。因为类型约束能把“输入不合法”这类模糊需求变成编译器能检查的东西。比如你写type VersionSpecResult | { type: exact; version: number[] } | { type: range; operator: | | | ; version: number[] } | { type: latest } | { error: INVALID_VERSION_SPEC }; function parseVersionSpec(input: string): VersionSpecResult;AI 看到这个签名就知道输出空间被收窄到四种情况不会随便创造第五种状态。它也知道error是一个联合类型成员而不是一个带字符串细节的字段。类型定义和示例组合在一起比自然语言的“返回错误对象”要精确得多。我自己在写 AI 生成代码的 prompt 时已经默认把“类型签名 输入输出表 少量边界说明”三件套作为标准开头。自然语言只用来解释业务场景不再用来描述具体逻辑。4.4 关键算法路径可以直接给伪代码如果你的功能里有一段比较复杂的算法逻辑不要指望 AI 能从你的业务描述里反推出你想要的特殊策略。这时候直接在 spec 里给一小段伪代码或核心逻辑骨架是最省事的方式。比如版本比较规则里如果超过三段就忽略那你可以写一个伪代码split input by . if length 3: slice first 3 convert each part to number if any part is NaN: return error这段伪代码看似很简单但它能避免 AI 自己去发明“把多余段相加”或者“保留最大段”之类的奇怪策略。AI 在执行明确的算法指令时往往比执行自然语言理解更可靠。因为它要做的不是语义推断而是把伪代码翻译成正式代码。这个方式也适合应对“判断条件很多且优先级重要”的场景。比如你需要多个运算符同时支持可以明确告诉它“比较顺序必须按照 、、、、、无前缀 来匹配。”这比让 AI 自己设计正则更安全。4.5 分阶段生成先复述再写码如果你在用 AI Agent 或支持多轮对话的工具我强烈建议改成两阶段先让 AI 用自己的话复述一遍 spec重点复述输入输出、边界条件和处理优先级等你确认它理解正确再让它生成代码。这个做法能提前暴露信息差。因为复述时AI 会把它即将采用的判断标准说出来你就可以发现“哦它以为空字符串和 null 是同一个东西”或者“它以为最新版本要写成 latest 而不是 latest-version”。发现问题后你只需要补充一句“输入永远是 string 类型不会传 null”后续生成质量会显著提升。我试过在同一个任务里跳过复述阶段直接生成前两轮都有偏差加上复述阶段后第一轮生成的代码就符合预期。这个额外成本非常小值得养成习惯。5. 常见问题排查与独门经验5.1 高频问题速查表下面这个表是我在实践里总结出来的每一条都对应“AI 做不对”的典型症状和解决方案。现象可能原因建议解法AI 总在边界条件上出错spec 缺少反例和边界输入补充输入输出表给空值、超长、特殊字符用例AI 改了 A 功能但弄坏了 B 功能没有给它回归测试命令让 AI 在修改前先运行全量测试修改后再次运行AI 忽略错误处理错误对象的结构没定义提供类型定义或返回结构的示例AI 生成的代码风格不一致缺少代码风格和现有示例粘贴一段现有模块代码要求按同样风格生成AI 在长 spec 下表现越来越差上下文过长导致注意力丢失拆任务一次只实现一个函数或一个模块AI 生成的代码在本地能跑CI 上报环境错误缺少环境约束描述在 spec 中写明文件系统、权限、依赖版本环境限制AI 总是返回一种奇怪的自定义格式输出格式没有被充分约束用类型定义加三五个范例限制输出空间这张表的重点是不要把所有问题都归因于“模型笨”。大部分情况下是你提供的约束和验证方式不够闭环。5.2 把“完整”拆成三层业务规则、接口契约、测试用例我现在写 spec 时会主动把内容分成三层而不是一股脑写成一段长文档。业务规则层是给人和 AI 共同看的说明这个功能要解决什么问题使用场景是什么。接口契约层是最硬的约束包括函数签名、类型、返回值结构、错误对象。测试用例层是最具体的给出一组可以运行的输入输出示例最好直接对应单测。三层各司其职。业务规则帮助 AI 理解背景接口契约限制它的实现边界测试用例提供最终验收标准。如果你在写 spec 时发现某一层缺失那最后 AI 出问题的概率就会明显上升。这个分类也特别适合放到 AI Agent 的 prompt 里让 Agent 知道自己每一步该依据哪层信息去做决策。5.3 让 AI 先写测试再写实现这可能是我今年收获最大的一个习惯。以前我总是先让 AI 写实现然后再补测试。后来发现如果我先让它根据 spec 写出测试用例再让它写实现去通过这些测试代码准确率会高出非常多。原因很简单测试用例是机器可验证的“完整 spec”。AI 写测试时它会主动考虑输入输出、异常分支和边界条件一旦测试写好它写实现时就有了一个明确的优化目标。写完测试后你还可以先让 AI 把所有测试跑一遍确认测试本身没有自相矛盾再开始写实现。如果你用的是带命令行执行能力的 Agent这个流程可以完全自动化Agent 先生成测试运行失败再写代码运行直到通过。这时候你对 spec 的要求反而没有以前那么高因为测试用例本身会不断纠正 Agent 的错误理解。但需要注意测试用例也不能太少。至少覆盖正常输入、边界输入、非法输入和特殊值。最好是表驱动测试把输入输出对集中放在一个数据结构里这样 AI 在分析时能一目了然。5.4 把巨型 spec 拆成小任务我理解很多人希望“给一份完整 spec让 AI 一次写出整个模块”听起来很爽实际效果并不好。模型在生成长代码时早期决策会严重影响后期代码一旦中间出现矛盾后面全跟着歪。我现在的方法是把模块拆成多个只有单一职责的小函数每个小函数对应一小段 spec。比如版本解析这个模块我会拆成“预处理字符串”“解析主版本号”“解析操作符”“组装结果对象”四个小任务。每个任务单独让 AI 实现再写一个协调函数把它们串起来。这样做的好处有三个。第一单次生成的上下文短模型注意力更集中错误率降低。第二每个小任务的输入输出更容易用表格定义测试用例更精准。第三如果某个函数写错了你只需要重新生成那一个函数不用整段重来。拆分的粒度可以参考一个原则如果一个函数的代码超过 50 行或者职责描述需要用一个以上的“并且”就说明拆得还不够细。5.5 版本化 spec 和测试用例同步管理最后我想提醒一点spec 不是一次性文档它需要和代码、测试同步维护。AI 生成代码后最好把原始的 spec 和测试用例存到仓库里比如放在specs/目录以后每次新需求都对应更新。我遇到过一个场景第一次让 AI 生成的代码通过了当时的测试但过了一周我又加了一个新需求要求支持~1.2.3波浪号范围。如果我直接跟 AI 说“在这个函数上加上对 ~ 的支持”它大概率会破坏原有逻辑。但如果我把原来的 spec 和测试一起提供给 AI再在这个基础上追加需求AI 就能看到新旧行为之间的约束不容易把旧功能弄坏。顺便说一个很多人不知道的小技巧在给 AI 的 spec 里加上版本号注释。比如spec v1.3然后列出本次变更点和参考测试。这样 AI 在生成时会更倾向于“在已有基础上增量修改”而不是“重新生成一个看起来差不多的版本”。对于维护中的项目这个习惯能省不少事。最后再说一点我自己的体会踩过几次坑之后我现在的态度是不再纠结“spec 到底要写多完整”而是思考“我给的 spec 能不能被 AI 转化成可验证的测试”。只要输出是可验证的完整不完整就有客观标准如果输出不可验证再长的 spec 也只是自我感觉良好。另一个体会是AI coding 这个事本质上是把需求翻译成约束的艺术。Spec 是约束测试用例是约束类型定义也是约束。谁提供的约束更接近机器可执行语言谁就能收获更稳定的生成结果。所谓“spec 写得很完整但 AI 做不对”大多数时候不是 AI 太笨而是我们的完整还停留在人类语言的层面没有翻译成 AI 真正依赖的结构化信息。想明白这一点很多看似玄学的问题其实都有解。