ARTICLE DETAIL

资讯详情

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

用TDD重塑LLM编程:先写测试,再让AI写代码

用TDD重塑LLM编程:先写测试,再让AI写代码 最近在重构一个数据清洗模块的时候我又一次被LLM辅助编程的“表面顺利”给骗了——让模型直接生成日期解析函数肉眼检查觉得没什么问题扔进pytest一跑8个用例挂了5个。当时我的第一反应是“再让它改一版就好了”结果连续修了三轮每次都是补上一个漏洞又冒出个新问题。这让我开始认真反思LLM代码生成的问题到底出在模型能力上还是出在我的使用方式上后来我尝试把测试驱动开发的思路引入工作流先定清楚“正确”的标准再让模型去满足这套标准效果天差地别。这篇文章就把这个转变过程中完整的思考和实践记录下来希望能给正在用LLM写代码、又对生成质量心里没底的朋友一些参考。1. 又一次“肉眼可用”的生成代码被测试打回了原形1.1 一个典型的数据清洗需求和AI给的“标准答案”先说那次让我彻底改变的案例。我需要一个函数输入是各种乱七八糟的日期字符串比如“2024/3/15”“15-03-2024”“March 15, 2024”“2024.3.15 14:30:00”要求统一输出成ISO 8601格式的datetime对象解析不了的返回None。我把这个需求原封不动丢给LLM它非常流畅地给出了一段看起来相当靠谱的代码先尝试一串strptime格式列表匹配不到就返回None还贴心地处理了大小写和空格。我扫了一眼觉得思路没问题直接放进项目里。等到写单元测试的时候傻眼了2024-02-30不存在的日期被解析成功了15/03/2024和03/15/2024在同一个格式列表里被同时匹配结果全看哪个格式先命中行为完全不确定March 15, 2024前面加一个全角空格就解析失败2024-3-5被解析成了2024年3月5日但需求里明确要求月份和日期必须补零。每一个问题单独看都是小问题但合在一起这段代码在真实生产环境里根本不能用。关键是它看起来非常专业、非常完整。1.2 一次、两次、三次为什么“再给它一次机会”治标不治本发现问题后我的第一反应是典型的“直接生成式”思路把报错信息贴回去让模型“修一下”。第一轮它修好了2024-02-30的问题增加了日期合法性校验但第二轮它为了处理全角空格把所有空格都strip()掉了结果把带时区字符串里的空格也删了2024-03-15 14:30:0008:00直接解析失败第三轮它把格式列表顺序调整了15/03/2024固定走%d/%m/%Y结果03/15/2024这种美式格式又全部返回None。三轮下来代码越来越长逻辑越来越绕bug却像打地鼠一样此起彼伏。这个时候我意识到问题不在模型不努力而在于每次修改都缺乏一个客观的“成功标准”。模型在猜测我对的需求我也在用肉眼猜测模型的输出是否正确两边都在盲人摸象。真正的转折点是我把这次任务完全改成了“先写测试、再让LLM实现”的顺序——测试先定义好了什么是“正确”模型只需要在这个约束空间里求解效果立刻不一样了。后面我会详细展开这个方法。2. 直接生成式编程的三个致命伤为什么“能跑”和“正确”之间隔着鸿沟经历过那次翻车之后我花了不少时间思考一个问题为什么LLM生成的代码看起来那么合理却经不起推敲后来我把原因归结为三个层面这三个问题不是模型迭代就能解决的而是“直接生成”这种使用方式自带的缺陷。2.1 概率性输出没有“需求的锚”LLM生成代码的本质是逐token预测“在给定上下文下下一个token最可能是什么”。它训练目标是“生成像人写的代码的文本”不是“生成满足需求的代码”。这就像你让一个代码能力很强、但完全没看过需求文档的实习生直接上手写模块——他写出来的代码在风格上、结构上都很专业但功能上可能完全跑偏。传统编程里“需求”是通过函数名、注释、类型签名、接口定义这些硬性约束传递的但在自然语言直接生成代码的场景里这些约束被压缩成一段描述模型需要在海量的“常见写法”里找一个和描述最像的。一旦需求里存在模型没见过、或者训练数据里很少见的组合逻辑它就会用“最常见的情况”去脑补。直接生成模式下你的提示词并没有为模型提供一个可验证的、具体的成功标准所以它只能尽可能输出“看起来正确”的代码。2.2 幻觉与默认假设模型“脑补”了需求第二个致命伤是默认假设。模型在生成代码时会带出大量隐含的、你没有说过的假设。比如日期解析函数里它默认了“如果年份是两位数字20xx和19xx都合理”但你的需求可能只接受20xx默认了“输入不会是空字符串”结果空字符串直接抛异常而不是返回None默认了“时区只可能是UTC”导致08:00被静默忽略。这些默认假设单看都情有可原因为它们是训练数据里的“统计主流”。问题在于代码和自然语言不同自然语言有歧义可以追问代码一旦把错误假设写死就会静默地产生错误结果而且肉眼审查很难发现——除非你非常清楚每个分支的预期行为。这也是为什么“Code Review看起来没问题”这么有迷惑性。2.3 上下文窗口的局部视野修改一处崩掉一片直接生成模式下还有一个特别容易被忽视的问题模型对长代码库的理解是碎片化的。当你让它修改A函数时它不知道B函数对A的返回值做了什么假设当你让它加一个新功能时它不知道已有模块里已经有个类似函数导致重复实现、行为冲突。我经历过一次最典型的场景一个金额处理模块原来所有金额都是Decimal类型我让LLM加一个“支持折扣后四舍五入”的功能它直接返回了float类型。单看这个改动没毛病但下游所有依赖Decimal做精确计算的报表模块全崩了。这类问题在直接生成模式下几乎无法避免因为模型根本没有“全局一致性”的概念它只有当前上下文窗口里的局部视野。把这三个问题放一起看结论就很清晰了直接生成式编程把“需求理解”“方案设计”“正确性验证”这三件事全都压在了模型一次性的概率输出上。这对简单逻辑比如写个冒泡排序没问题但对任何有真实业务约束的模块翻车是迟早的事。3. 测试驱动开发为什么恰好补上了LLM最缺的那块短板既然问题在于“缺乏可验证的成功标准”那解法就顺理成章了——把测试驱动开发TDD的思路引入LLM编程工作流。TDD本来是人类开发者为了提升代码质量发明的实践但我越用越发现它和LLM简直是天作之合。3.1 TDD的本质是把“需求”翻译成“可执行契约”传统TDD的核心循环是先写一个失败的测试再写刚好能让测试通过的代码然后重构。这个循环里最关键的动作是把模糊的需求“支持各种日期格式”转成精确的断言parse_date(2024/3/15) datetime(2024, 3, 15)。一旦这些断言写下来需求就不再是一段有歧义的自然语言而是一份可执行的契约。对LLM来说这份契约就是它最需要的东西。给它一份完整的测试用例它就不再需要猜测“成功是什么样”只需要写代码满足这些断言。很多实验和从业者的反馈都证明给LLM提供明确的测试用例生成代码的准确率会有非常显著的提升。原因是测试用例极大地缩小了模型的搜索空间它不用从所有可能的函数行为里猜一个最像的只需要在测试用例约束的范围内找一个实现。3.2 当LLM遇到TDD从“猜需求”变成“解习题”我做了一个简单的对比实验同一个需求还是那个日期解析函数用两种方式让同一个模型实现第一种直接给一句话需求“写一个日期解析函数支持常见格式解析不了返回None”。第二种给同样的需求加上8个测试用例作为“验收标准”。结果是第二种方式下模型第一版的通过率从大约30%提升到了80%以上。而且更关键的是第二种方式下如果还有测试没通过模型可以根据失败的断言精确修改而不是靠猜。这个过程很像让学生做一道有标准答案的习题——有标准答案学生更容易校准自己的思路没有标准答案学生只能凭感觉写写完了也不知道对不对。3.3 谁负责写测试一个关键的“人员分工”问题用TDD驱动LLM第一个要回答的问题是测试谁来写最懒的做法是让LLM自己写测试、自己写实现。但我强烈不建议这样做尤其是对于业务逻辑复杂的场景。原因也很简单同一份需求理解偏差会同时污染测试和实现。如果模型对需求的理解是错的它写出来的测试就是“错误的测试”再用这个测试去驱动实现等于给错误上了双保险——测试全绿代码逻辑全错而且你还会被绿色测试欺骗以为一切正常。我目前的实践是由我人类负责写测试或者让LLM生成测试草稿后我再逐条审查并补充边界条件。核心原则是测试必须来源于对需求的人工拆解而不是来自模型的“自说自话”。这一点是整个工作流的基石后面我还会再展开。4. 把TDD注入LLM工作流我验证过的四步循环理论说完了下面是一套我在真实项目里反复使用、验证有效的工作流。它和传统TDD很像但针对LLM的特性做了优化。4.1 第一步把需求拆成可验证的用户故事与测试用例这一步是整个流程里最花时间、也最体现功力的部分。拿到需求后我不会急着让LLM写代码而是先在纸上或者编辑器里把需求拆成一条条可验证的行为正常输入应该返回什么边界输入空值、极值、特殊字符应该返回什么非法输入是抛异常还是返回默认值格式冲突时优先级怎么定每一条都对应一个或几个测试用例。这个过程本身就在逼着我澄清需求——很多需求在写测试用例时才会暴露出含糊的地方。比如“解析不了返回None”这个看似简单的需求拆解时你才会发现“2024-02-30”算解析不了吗“2024/2/30”算吗“2024-13-01”呢没有测试用例这些歧义会一路躲过review直到线上出问题。4.2 第二步用测试用例反向编写提示词测试用例写完之后把它们作为“验收标准”写进提示词。我的提示词模板大致长这样你是本项目的资深开发。请根据以下需求实现一个Python函数。 需求说明 这里写函数的功能、输入输出、以及业务背景 验收标准必须全部满足 1. parse_date(2024/3/15) 应返回 datetime(2024, 3, 15) 2. parse_date(15-03-2024) 应返回 datetime(2024, 3, 15) 3. parse_date(March 15, 2024) 应返回 datetime(2024, 3, 15) 4. parse_date(2024-02-30) 应返回 None 5. parse_date(2024-13-01) 应返回 None 6. parse_date() 应返回 None 7. parse_date(2024.3.15 14:30:00) 应返回 datetime(2024, 3, 15, 14, 30) 8. parse_date(None) 应抛出 TypeError 约束 - 返回类型必须与验收标准一致 - 代码必须包含类型注解 - 不要修改其他文件 - 如果某个验收标准无法满足请在回复中明确说明注意几个要点测试用例一定要写“期望值”而不是只写“应该能解析”约束要明确且可执行避免模糊表述如果模型认为需求本身有问题允许它指出而不是强行实现。这样给模型的任务就从“猜需求”变成了“满足一组明确约束的编程题”。4.3 第三步跑测试、看失败、把失败信息喂回去拿到模型生成的代码后不要用肉眼review来代替测试——直接跑。如果测试有失败把失败信息、期望值和实际值一起作为反馈喂给模型让它修改。这里有一个实操细节不要直接把整个错误堆栈原封不动贴给模型。大多数情况下模型不需要500行堆栈它需要的是精简后的信息。我一般会把失败信息整理成测试用例 test_parse_date_with_invalid_date 失败。 期望parse_date(2024-02-30) 返回 None 实际返回了 datetime(2024, 2, 30) 请检查日期合法性校验逻辑。这样做的原因是大段堆栈里往往混着大量和本次失败无关的信息模型容易“迷路”。精简后的失败信息让模型直接定位到问题本质。当然如果模型对项目上下文不熟适当贴上相关代码片段也有帮助但核心是“失败断言 期望行为”必须清晰。4.4 第四步用“测试覆盖率”和“变异测试”来把关测试全绿之后事情还没完。我会再做两件把关的事第一件是检查覆盖率。不需要追求100%但核心逻辑分支必须覆盖到。比如日期解析里的“非法日期返回None”分支如果没测到那测试全绿也没有意义。第二件是更硬核的变异测试。简单说就是故意把代码里的某个逻辑“变异”一下比如把改成、把and改成or然后看测试能不能抓住这个变异。如果测试还是全绿说明测试有盲区。这个操作人工做起来很费劲但可以让LLM帮忙生成“变异版本”你只需要跑测试看结果。我在一些核心模块上用过确实能发现不少测试盲区。完成这一步之后LLM辅助编程的产出才算真正“可交付”。4.5 一件重要的小事版本控制里先提交测试最后分享一个工作流层面的小建议把“先写测试”落到版本控制里。具体做法是第一步提交一个只包含失败测试的commit红色提交。第二步让LLM实现功能测试通过后再提交一个commit绿色提交。这样你的git历史里天然留下了一条“需求→测试→实现”的轨迹。谁提交的、什么时候加的测试、对应实现是哪一版全都清清楚楚。以后代码出问题回溯起来效率高很多。而且红色提交的存在也保证了“测试先行”不是嘴上说说——它能被团队其他成员看到、审查。5. 一个完整案例我用LLMTDD重写了一个订单金额计算模块前面的方法论讲得再多不如一个完整的实操案例来得直观。下面分享一个我最近在电商项目里真实做过的改造。5.1 需求背景折扣叠加的规则远比想象复杂需求一句话计算订单最终支付金额支持会员折扣、满减、优惠券、限时活动四种优惠这四种优惠可以叠加。听起来很简单但细节一展开就很复杂会员折扣先算还是满减先算答案是会员折扣先算满减基于折扣后的金额优惠券能不能和满减同时用部分能部分不能取决于优惠券类型限时活动折扣和会员折扣叠加时是相乘还是取最大最终金额是否允许为负数不允许最低为0精度怎么处理用Decimal保留两位小数四舍五入我第一次直接让LLM写这个函数它给了我一个三四十行的实现逻辑看着井井有条但用真实订单数据一测四种优惠叠加时完全乱套。后来我彻底重写改用TDD流程。5.2 先写出来的12个测试用例长什么样这次我先坐下来把需求拆成了12个测试用例。挑几个有代表性的# test_order_amount.py def test_only_member_discount(): # 原价100会员折扣8折 assert calculate_payable(price100, member_rate0.8, full_reduction0, coupon0, activity_rate1.0) Decimal(80.00) def test_member_discount_before_full_reduction(): # 原价100会员8折 满80减20 → 先80再减20 60 assert calculate_payable(price100, member_rate0.8, full_reduction20, threshold80, coupon0, activity_rate1.0) Decimal(60.00) def test_activity_rate_and_member_rate_are_multiplied(): # 原价100会员8折 限时活动9折 → 100 * 0.8 * 0.9 72 assert calculate_payable(price100, member_rate0.8, full_reduction0, coupon0, activity_rate0.9) Decimal(72.00) def test_total_amount_never_negative(): # 原价50满减80优惠券20 → 最低0不能为负 assert calculate_payable(price50, member_rate1.0, full_reduction80, threshold10, coupon20, activity_rate1.0) Decimal(0.00) def test_precision_rounds_to_two_decimals(): assert calculate_payable(price10.01, member_rate0.8, full_reduction0, coupon3.33, activity_rate1.0) Decimal(4.68)这12个用例覆盖了单一优惠、组合优惠、优惠叠加顺序、精度、负数约束、并发场景同一个订单重复调用不改变结果等。写完后我提交了一个“红色提交”测试全部失败——因为函数根本还没实现。5.3 LLM实现过程中的3次“红绿”迭代接下来我把这12个测试用例和需求说明一起丢给LLM要求它实现calculate_payable函数。第一次生成的代码通过了7个用例失败的5个集中在叠加顺序上——它把优惠券抵扣放在了满减之前导致优惠基准错误。我把失败的5个测试信息精简后贴给模型要求它“以测试通过为准调整计算顺序”。第二轮通过11个还剩1个精度测试失败模型用了float中间计算导致浮点误差累积。第三轮我明确提醒“所有中间计算必须使用Decimal禁止使用float”终于12个测试全部通过。整个迭代过程大约花了20分钟其中大部分时间是我在看测试结果和整理反馈真正“思考”的时间其实很少。5.4 效果对比直接生成 vs TDD我把前后两种方式的数据做了一个简单对比虽然不是严格意义的实验但数据来自亲测参考价值还是有的对比项直接生成含多轮修复TDD驱动生成总轮次5轮3轮Token消耗约7200约5800测试通过率最终7/1212/12人工审查耗时约35分钟约15分钟上线后暴露的bug1个负数边界0个后续维护成本高逻辑混乱低测试即文档最让我意外的不是最终通过率而是人工审查时间的下降——因为有了明确的测试用例review代码时不再需要逐行推演业务逻辑只需要对照“测试是否覆盖了需求”“实现是否简洁”两个维度看效率高了一倍不止。6. 工具链配合IDE插件、Agent、MCP与本地模型如何进入这条工作流四步循环跑通之后下一步自然是把工具链升级起来让流程更自动化。这一节聊聊我在IDE插件、Agent、MCP、RAG和本地模型方面的一些实际体验。6.1 IDE里的AI辅助编程插件从“补全工具”到“测试驱动搭档”现在主流的IDE基本都有AI助手PyCharm系的有自带的AI AssistantVS Code/Cursor里有Copilot、Continue等。很多人把这些插件当成“自动补全加强版”其实在TDD工作流里它们更适合干两件事第一件是在写测试阶段辅助生成测试用例草稿。你把需求和函数签名贴给它让它生成一组测试用例然后你逐条审查、补充边界情况。这比我手写快很多而且能启发你想到一些容易漏掉的场景。第二件是在红绿循环里充当“结对编程搭档”。测试失败后直接把失败信息丢给插件内置的对话让它生成修改建议比切到网页版快得多。我用过几个插件后发现它们对单文件的修改比较靠谱但涉及跨文件修改时提示词里必须显式说明项目结构否则很容易改出编译错误。6.2 引入Agent与MCP自动跑测试、自动修改、自动反馈再往上一层就是Agent化。所谓LLM Agent核心是让模型能自己调用工具、观察结果、决定下一步。在编程场景里最常见的Agent循环是读写文件、执行测试命令、读取测试输出、修改代码、再跑测试直到测试通过或达到最大轮数。我把这套循环跑通的方式是结合MCPModel Context Protocol把测试运行器暴露成工具。这样一来Agent可以直接调用run_tests工具拿到测试结果作为上下文再决定如何修改代码。实测下来对于“边界条件处理”这类局部问题Agent的自主修复成功率还是挺高的但“需求理解完全错误”导致的系统性失败Agent往往会在错误方向上反复打转这时候必须有人类介入重新描述需求或补充测试。使用Agent化工作流时我建议设定两个硬性限制最大迭代轮数我一般设5轮超过后强制人工介入单次任务的最大token预算避免模型长时间自我循环。6.3 RAG与个人知识库让LLM更理解你的项目规范测试用例解决了“这个函数要做什么”的问题但“项目里有哪些规范、同类代码历史上有哪些坑”这些问题测试用例管不了。这时候可以引入RAG检索增强生成——把项目文档、代码规范、历史bug记录、技术决策文档等切块向量化在生成代码前检索相关内容作为额外上下文。我自己搭了一个简单版本用Obsidian加llm wiki的插件维护项目的知识库里面有各模块的设计文档、常见的坑、代码风格约定。每次给LLM写提示词前先从知识库里检索出和当前任务最相关的3到5条内容附加到上下文中。效果最明显的场景是新人写代码——LLM生成的代码风格和项目老代码明显一致了不会出现“一个模块里两种日期处理方案”这种问题。这里有个容易被卡住的细节RAG依赖文本向量API不少平台需要单独配置API Key。我在本地部署的时候遇到过“文本向量API未配置”导致检索功能完全失效的情况排查后才发现是环境变量漏设了。如果你搭RAG发现检索结果为空先检查这一步。6.4 本地模型与精度问题fp16、fp32、bf16对代码生成的实际影响除了云端模型我还尝试过用本地模型跑这套工作流。本地部署的优势是数据不出内网、成本可控代价是模型能力通常比云端大模型弱一些。在本地跑模型时一个绕不开的话题是精度fp16、fp32、bf16该怎么选简单说fp32是单精度浮点占用显存最大但数值最稳fp16是半精度占用显存减半但数值范围小、容易溢出bf16是“脑浮点16”它牺牲了尾数精度但保留了和fp32一样的指数范围在深度学习场景里通常比fp16更稳。我的经验是对代码生成任务来说fp16和bf16的精度差异几乎可以忽略。原因在于代码生成本质是离散token的预测不是连续数值计算模型输出的概率分布对这种小幅精度波动不敏感。真正影响代码生成质量的是上下文长度和模型参数量——上下文窗口太小模型根本记不住你的测试用例和需求。所以我建议如果显存紧张优先保上下文长度其次才是考虑用fp16还是bf16为了省显存把上下文截到很小才是本末倒置。我自己用Ollama和LM Studio跑本地模型7B量级的模型在代码生成上勉强可用但复杂业务逻辑还是云端模型更稳。7. 我在项目里反复踩过的几个坑给后来的你最后这部分聊聊我在实践这套工作流时踩过的坑。有些坑是通过反复尝试才绕过去的写出来希望能帮你省点时间。7.1 让LLM只写测试不写实现看起来很美落地很难前面说测试不能完全交给LLM来写但实际落地时有个折中方案让LLM生成测试草稿再由人来审核补充。我一开始比较激进想让LLM全自动生成测试和实现结果吃了大亏——它写的测试覆盖的全是“顺风局”正常输入、正常边界但真正关键的业务规则比如优惠叠加顺序、非法输入优先级它一个都没测到。原因很简单业务规则经常不在训练数据里模型不知道你项目里有哪些隐藏约束。所以我的建议是LLM生成的测试只能当草稿你必须逐条问自己——“这个用例如果删掉会不会有bug漏过”如果会就得补。这个审查步骤不能省。7.2 “测试全绿”不等于“需求全对”这是一个特别容易产生虚假安全感的地方。测试全绿只能证明“代码满足了你写的那些断言”不能证明“代码满足了你脑子里真正的需求”。我经历过一次最惨痛的教训我按照自己理解的需求写了一组测试LLM也完美实现了测试全绿但上线后业务方说需求理解错了——我测的是一套错误的规则测试越完整反而越强化了错误行为。要降低这个风险最好的办法是让业务方或需求提出方参与测试用例评审。哪怕只是把12个测试用例拿给对方看一眼问一句“这些行为符合你的预期吗”都能避免很多大方向上的错误。7.3 别让AI无限“自我修复”预算与质量的最佳截止点Agent化循环里有一个需要留心的点修复到后面容易出现“边际收益递减”。第1轮修复可能解决5个bug第2轮解决3个第3轮解决1个第4轮开始可能就是为了修复“修复代码时引入的新问题”了。如果到第5轮还有测试失败大概率不是小修小补能解决的而是需求理解、接口设计这些大方向出了问题。这时候最好的选择是停止修复回到第一步重新拆解需求或者换一个更强的模型或者人工介入。7.4 长期维护测试是给人类的文档也是给AI的“锚”这套工作流带来的好处不止在开发期长期维护阶段价值更大。当我三个月后再回到一个旧模块看到那12个测试用例立刻就能回忆起当初所有业务规则和边界处理不用再去代码里逐行推理。而对于后续的AI辅助修改这些既有测试天然成了“回归保护的锚”——我修改代码后跑一遍全套测试如果之前的行为被破坏了测试马上就能发现。这也是我为什么坚持用“测试即文档”的思路来组织LLM辅助编程测试用例不只是质量保障手段更是一种结构化的、可执行的需求表达方式。对人是这样对AI更是这样。从直接生成到测试驱动这个转变本质上不是换一个工具而是换一种思考方式——在接受AI的输出之前先明确什么是“正确”。我现在的习惯是拿到任何新需求第一件事已经不是打开对话窗口让AI写代码而是打开编辑器先列测试用例。如果你也在为LLM生成代码的质量头疼不妨试试把顺序反过来先写测试再让AI去实现——你会回来感谢这个习惯的。
返回列表