ARTICLE DETAIL

资讯详情

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

用LLM自动生成单元测试:从提示词工程到LoRA微调实战

用LLM自动生成单元测试:从提示词工程到LoRA微调实战 1. 这件事的起点为什么我非要用LLM来写单元测试先说一个每天都在发生的真实场景。你接手的那个老服务核心方法是别人三年前写的一没有注释二没有单测三还是业界罕见的超长方法。你不敢改怕改坏线上又不想补测因为补起来的工作量比你重构还大。这时候如果能有一个工具把方法喂进去自动给你生成一套覆盖主分支、异常分支、边界条件的单元测试用例你会不会用反正我是会用的而且我已经这么干了很久。这整件事的核心链路其实非常简单一共三站第一站是提示词设计目的是让大语言模型在没有额外训练的情况下仅凭几条精心设计的指令就输出合格的单测第二站是套上测试执行器和覆盖率工具验证模型生成的东西到底能不能跑、覆盖多少第三站是当提示词做到极限也喂不动了再上LoRA做轻量微调让模型真正理解你们项目的测试规范。这个路线是我实测走通过的不是停留在PPT阶段。技术选型上我的建议是除非你是纯粹想体验一下API的省事否则早期阶段就锁定本地部署的大语言模型原因后面专门说。模型我用的Qwen系列一方面它在中英文代码理解上确实能打另一方面它的底座开源可以顺路做LoRA微调不会被厂商绑定卡脖子。整套实现下来你需要的核心材料就四样一个大模型推理服务或API、一个提示词模板层、一个测试执行与统计工具链、一份用于微调的测试用例数据集。这篇文章就是把我从提示词设计一路走到LoRA微调的全过程拆开讲。适合谁看适合那种已经会写代码、用过ChatGPT或本地模型但还不清楚怎么把LLM的能力稳定地压到单测生成流水线里的工程同学。你不需要懂太深的机器学习原理但需要一点Python基础以及愿意折腾环境的耐心。2. 用提示工程逼出模型的代码能力先别急着微调把提示词玩明白再说很多人一上来就想微调觉得自己数据集也攒了、显卡也租了结果是拿大炮打蚊子。微调是很重的手段成本高不说搞不好还会把模型原本的代码能力练歪。所以我的第一个建议是先把提示词做到模型能产出的上限再决定要不要动权重。2.1 提示词的分层结构角色、任务、约束、范例一个都不能少我自己写过非常多版的单测提示词最后沉淀出来的结构是四层角色层告诉模型你是一个精通X语言和X单元测试框架的资深测试工程师。这一层表面看是心理暗示实际上非常关键。模型在角色约束下输出的表达风格、覆盖意识、边界判断都会有实质变化尤其对7B级别的小模型来说角色提示等于是帮它把输出分布往专家方向压缩了一大截。任务层必须用祈使句精确描述你要什么根据以下函数签名和函数体生成单元测试用例。要求覆盖正常分支、异常分支、边界条件。约束层列硬性规则比如只输出JUnit测试代码不要解释不要修改被测函数的签名被测函数依赖的外部服务全部用Mockito mock掉生成的用例必须通过编译。约束要尽量用否定句肯定句混合模型对禁令的服从度通常比对尽量这种软要求高一截。范例层给一到两个few-shot示例Include被测代码的输入输出、通常的测试风格、断言的写法。这是整个提示词里信息密度最高的部分一张好的范例顶过十句抽象描述。这里有个值得注意的细节范例不是为了展示标准答案而是为了统一模型的输出格式和风格。同一个项目里模型如果两次给出风格完全不同的测试一次用junit4的RunWith一次用junit5的Nested你说这是人写的都没有说服力。所以把你们的既有测试风格写进范例里这一点微调都替代不了。2.2 被测代码上下文注入函数体太长时怎么切实践里遇到最多的一个问题是被测函数动不动几百行全部塞进上下文里模型的注意力会涣散输出质量断崖式下跌。但截掉函数体只给签名模型又只会写Happy Path覆盖不到内部分支。我试过几种切法最后稳定下来的是分而治之。先把函数体里的if/else、switch、异常抛出点全部抽出来得到一张分支清单然后把函数签名、分支清单、以及分支对应的关键代码片段一起塞给模型。等于说我不让模型看整棵代码树而是给它一张标注好的地图。这样上下文塞得进去模型的覆盖效果也接近满分。等到这套提示词产出稳定了再把它做成一套模板管线。我的管线长这样输入被测函数源码静态分析出函数签名、依赖列表、分支点列表按模板组装提示词调本地模型推理解析模型输出提取代码块把测试代码落盘到测试目录跑mvn test jacoco覆盖率返回结果这一套其实就是后续微调时要用的推理侧完整链路。你提前把它做扎实微调完成后只需要换模型权重文件其他一概不动。2.3 模型倾向性调节温度、top_p和采样参数对用例多样性有多敏感你可能会觉得生成单测这种偏严谨的任务模型输出越稳定越好那温度直接拉到0不就行了。但我跑下来发现温度固定在0时模型倾向于输出语料中出现频率最高的那几种测试形态比如只覆盖正常路径、只测单个断言、大量重复等待超时。这会导致一个很隐蔽的问题覆盖率看起来还行但同一批用例里冗余度高得吓人。我的做法是温度设0.2~0.4之间做一点随机性注入然后把模型生成三轮用覆盖率报告和变异测试结果做一次择优合并。不要试图把温度调太高否则模型会开始编造根本不存在的API比如生成一个毫无根据的Mockito.when(foo.doSomething()).thenReturn(...)可被测代码里根本没有这方法。0.2到0.4这个区间是我反复试下来性价比最高的区间。2.4 提示词版本管理的必要性既然提示词的改动对模型输出影响这么大那它就有资格跟代码一样进入版本管理。我维护了一个prompts.yaml每个提示模板都有版本号、改动说明、实测覆盖率对比。改模板之前必须先在十条黄金测试样本上跑一遍旧版本和新版本都过了才能换。这块建议直接做成脚本挂CI里。每次prompts文件变动自动触发一次benchmark输出覆盖率变化。别问我为什么这么较真你只要经历过一次改了一个字让模型输出全部跑偏的事故就知道提示词管理的价值在哪里了。3. 本地部署模型作为底座为什么是Qwen以及推理服务怎么搭提示词好归好但如果你全程调用云端API你会发现两个问题第一是代码会出网大部分公司这关就过不了第二是后续微调时云端API只能黑盒调用微调完的权重落不到自己手里。所以从第一天起我就把底座锁定在本地部署的开源模型上。3.1 选Qwen的理由代码能力、中文语境、微调生态这个领域能选的模型其实不少。Llama系列的代码能力不差但中文指令理解有时候会有点水土不服CodeLlama专门为代码场景优化过但聊天能力和通用性弱一些提示词稍微复杂一点就崩。Qwen系列对我来说是综合平衡最好的基座模型在代码语料上的训练量足够对中文注释和中文需求的理解又天然占优而且它的指令微调版本Qwen2.5-Coder系列等在代码生成任务上跟同级别的Llama系比也不会吃亏。实操上我选的是Qwen2.5-Coder-7B-Instruct量化成4bit塞进一张24G显卡完全跑得动。如果你只有16G显存那就用Qwen2.5-Coder-7B的GPTQ int4版本如果显存宽裕14B的Instruct版效果更好尤其是对长函数体、多分支场景的把握明显强一个量级。3.2 本地推理服务的两种搭法llama.cpp和vLLM我在不同的阶段用过两条路线各有各的适用场景。llama.cpp占内存极小CPU也能凑合跑适合单机调试和快速验证。它的API是一个OpenAI兼容的server默认端口8080curl一发就能拿到响应。缺点是并发能力弱生成速度吃不满GPU。我在业务代码研究阶段、或者说只生成少量用例的时候就一直开着它。vLLM吞吐量确实牛高并发场景下能把GPU利用率顶上去但显存要求也高而且跟Windows环境基本绝缘需要Linux服务器。当我要批量生成上千个函数的单测跑一轮全量评估时就得上vLLM。两条路线的共同点是它们都提供OpenAI兼容的/v1/chat/completions接口。这意味着你的提示词层、微调之后的部署都不需要换接口只换base_url和model_name就行。这个设计带来的省心事后面你会真切体会到。3.3 部署过程中最容易卡住的三个点第一模型的上下文长度配额。Qwen2.5系列默认支持很长上下文但如果你用llama.cpp需要在启动时显式设置--ctx-size否则只给你默认的512或者2048塞一份长函数代码进去就超出限制了。第二量化格式和推理框架的匹配问题。同一个GGUF文件在老版本llama.cpp上可能直接报错不识别。建议装新一点的版本或者干脆用官方文档里的命令拉最新的release。第三是输出长度限制生成测试用例时往往输出几百行代码默认的max_tokens根本不够我一般直接给到2048以上必要时开到4096。如果你用vLLM还需要额外注意一个参数--max-model-len。这个值设太小长上下文请求会被直接拒掉设太大显存占用量又会翻番。我建议按实际提示词的平均长度加两到三倍的余量来设。3.4 一个实用的小动作给本地服务套一层统一代理一旦本地模型服务和后续的LoRA推理要接入多个流程你会发现各流程里service_url散落各处到处都要改。我的做法是在本地加一个极薄的反向代理层统一暴露/v1/chat/completions后端真实模型然后走转发。后续做微调版本切换只改代理的配置业务代码一行不动。这一层代理配合上面说的OpenAI兼容接口基本等于给整个项目做了一个模型无关的缓冲。无论后面模型从7B换成14B还是从base模型切到LoRA融合版本提示词层和测试执行层都无感知。4. 从提示生成到评估闭环覆盖率、编译成功率、无效用例率光会生成代码不叫落地能证明生成的代码真实可用才算数。我踩了一个大坑之后才知道初版模型生成了500个用例编译通过率高达95%但mvn test一跑红色瀑布哗啦一片。为什么因为大量编译通过的用例运行时直接在setup阶段就抛异常全是一堆无效测试。从那一刻起我就把评估指标改成了比代码生成质量更靠近工程现实的三大指标编译通过率、测试执行通过率、代码覆盖率外加一个统计无效用例率的辅助指标。4.1 编译通过率和执行通过率你能骗过编译器但骗不过junit编译通过率是入门指标它衡量的是模型输出能不能过编译器这一关。执行通过率更进一步要求用例在测试运行时不能出现初始化失败、mock失败、超时这类假失败。所谓假失败就是你的测试代码本身有问题而不是被测试逻辑有问题。这两种情况要分开统计否则你没法判断究竟是模型生成的测试不好还是被测代码真的有bug。我的统计方式是执行通过率 通过用例数 / 总用例数但把异常分门别类比如NullPointerException、MockitoException、超时、AssertionError等一一打标。第三步做用例去重 缺陷聚类时这些标签能帮你快速定位模型在哪些类型上系统性犯错。4.2 覆盖率的正确打开姿势别只看行覆盖Jacoco的报告里有行覆盖、分支覆盖、方法覆盖、指令覆盖很多人只盯着行覆盖被那个虚高的百分比自我感动。单测生成任务里分支覆盖才是更难的指标。行覆盖高但分支覆盖低说明模型一直在重复测同一条路径根本没碰到else或者异常捕获逻辑。我有一次跑出来的数据就是行覆盖82%分支覆盖只有46%。排查之后发现模型生成的用例全部集中在正常返回路径上几乎所有包含if (xxx null) throw ...的方法异常分支的用例一个都没有。我的评估脚本后来就改成行覆盖和分支覆盖分开输出生成任务的目标函数改成两覆盖都要高于基线否则本轮生成视为失败。4.3 无效用例率被大多数人忽略的隐形杀手无效用例是什么是能编译、能执行、但它什么也没验证的用例。典型的例子包括测试方法体只有mock调用没有任何断言。跑是能跑但等于没测。断言写得模棱两可比如只断言返回值不为null。这种用例在覆盖率报告里还占名额但你要是指着它抓回归bug基本指望不上。打着测试旗号的空跑根本没有调用被测方法。统计无效用例率的口径我用了静态特征动态特征结合的方式静态检查是否有assert语句、是否调用了被测方法动态检查如果删除该用例后覆盖率不变则视为疑似无效用例。把无效用例率压到5%以下你的生成链路才有资格谈稳定。4.4 让评估闭环成为一条可回归的流水线最后我在本地CI上搭了一条流水线每条提示词改动、每次模型权重的替换自动触发一轮全量评估。数据集是预先选好的50个覆盖不同复杂度等级的函数从纯工具函数到带外部依赖的服务方法都有。评估结果写成一个JSON历史版本存档。这样谁改了什么覆盖率升了还是降了一查便知。没有这条流水线之前我好几次凭感觉优化提示词改完反而把分支覆盖拉低了却因为只看单条生成结果而完全没察觉。有了自动评估之后这类劣化会被立刻发现。5. 提示词喂不动了才是LoRA微调出场的时候你可能会问提示词已经做到这样了为什么还要微调因为我在实践中遇到了一个提示词怎么调都跨不过去的瓶颈模型对项目私有风格的适配能力有限。你的项目有自己的一套返回体结构ResultT有自己的异常类型BizException有自己的mock规范禁止mock静态方法。提示词里写得清清楚楚模型偶尔能照着做但大多数时候还是回到它训练语料里的主流写法。这个问题的本质是通用模型知道怎么写单元测试但不认识你们项目的开发规范。LoRA微调干的活就是在不更新全部权重的前提下用一批你们项目自己的数据给模型补一门短课让它学会你们项目内部的代码风格和测试习惯。5.1 训练数据一份高质量单测数据集要满足什么条件微调效果的上限由数据决定这句话我说得一点不夸张。我第一批数据是从开源项目里抓来的通用单测微调完模型写出来的用例依然偏通用风格项目特色没有任何显著体现。真正让模型产生质变的是用你们自己项目的真实代码和真实测试对。一份合格的数据集长这样样本数不需要多800到2000条之间足够。LoRA本身是轻量微调数据量太大容易过拟合太小又学不到模式。配对结构instruction input_code output_test。instruction是固定的提示模板input_code是被测方法源码output_test是项目里实际爱用的那种测试写法。多样性同样的业务逻辑最好用不同的代码形式出现比如同一个接口方法有同步实现、有异步实现、有带重试的实现这样子模型才能学到抽象规律而不是死记模板。项目规范自然融入数据里大量出现你们项目的Result、自定义异常、mock风格、断言风格。样例本身就会教给模型。数据清洗的时候特别注意三件事把测试里引用到的不存在的依赖去掉把超长、超复杂的测试样本排除掉把含有敏感业务信息的测试做个脱敏。别小看第三点本地微调数据一般都要进训练脚本如果里面有线上账号密码的硬编码鬼知道会被模型学成什么。5.2 数据格式我不造轮子直接套LLaMA-Factory的格式微调工具链我用的LLaMA-Factory因为它的数据格式通用、支持Qwen全系、训练脚本写得很顺手而且对LoRA这类PEFT方法支持得很成熟。它要求的数据结构是conversations式的或者alpine格式。具体我用的是alpine格式每个样本有instruction、input、output三段。这段稍微强调一下格式细节input字段放被测源码output字段放期望生成的测试代码。为了保持指令和推理阶段一致训练时的instruction文本必须跟你线上推理时用的prompt完全一致。如果不一致模型学到的输入输出映射在线上会全部错位。这一点看似废话但在实际的微调项目里翻车率极高。5.3 训练工程参数LoRA rank、alpha、learning rate怎么定LoRA的核心思想是给原始权重矩阵加两个低秩矩阵做增量训练时只更新这两个小矩阵。所以你要关心的参数就那几个lora_rank低秩矩阵的维度。一般取8到64。任务越复杂rank越大但太大也会引入更多可训练参数增加过拟合风险。单测生成这个任务我取16到32之间比较稳。lora_alpha缩放系数控制LoRA分支对最终输出的影响强度。常用取值是rank的两倍比如rank16alpha32。learning_rateLoRA微调的学习率一般比全参数微调要大常用1e-4到5e-4。我用2e-4起步跑few轮后看一眼loss曲线如果训练loss还在降但验证集上生成质量已经在变差就降学习率重跑。num_epochs我建议从2到3个epoch起步。批次数据量小的话太多轮次会过拟合到数据集里的噪声。训练日志里重点盯两处一是训练loss曲线是否平滑下降二是验证集上的生成样例是否逐步贴近目标风格。验证方式就是拿微调后的模型在几条没进过训练集的函数上生成一次测试人工看一眼像不像项目里的人写的。5.4 把LoRA权重部署回推理服务不合并、不烧权重我强烈建议你别把LoRA权重直接合并进底座模型保存成一个大文件。合并没有错但每次想调LoRA的强度、或者换一套LoRA权重时都要重新加载整个模型太笨重。LLaMA-Factory训练完会保存adapter配置和权重部署时只需要让推理框架加载底座模型再动态挂上adapter即可。如果在vLLM里做可以把adapter配置放到模型目录启动时用--lora-modules指定如果用的是llama.cpp社区最近也支持了LoRA加载启动时带--lora参数就行。这样你可以同时跑裸模型和LoRA版本两个服务一个当baseline一个当test让评估脚本比一比哪个覆盖率高。6. 实际效果与踩坑记录从数据里挑几个有代表性的例子光说方法没有例子总觉得不落地。我从自己的实测记录里挑了三组有代表性的情况都是提示词阶段和LoRA微调阶段对比很直观的样本。6.1 典型场景A带复杂前置校验的业务方法这是一个用户注册方法前置校验包括用户名格式、密码强度、邮箱合法性、验证码时效每一个校验失败都抛不同的BizException(code, msg)。提示词阶段的模型能生成正常注册路径的用例但对每个校验分支的覆盖就时好时坏经常漏掉验证码过期这个分支。LoRA微调之后模型生成的用例把校验分支全部覆盖了而且异常断言的写法跟项目里老测试完全一致assertBizExceptionWithCode(result, 1203)。这一条的改变逻辑其实不难理解项目里的大量既有测试都是这种业务异常分支的写法模型在微调样本里反复看到了校验失败必须断言错误码这个模式自然就把这个习惯学会了。6.2 典型场景B第三方服务依赖的mocking这个场景是外部HTTP调用型方法的测试。提示词阶段的模型频繁出两种问题不mock外部HTTP服务直接new真实客户端或者mock了但没配置when(...).thenReturn(...)返回了个null导致被测方法里NPE。LoRA微调之后模型学会了先用接口抽象层mock再对成功、超时、5xx分别配置不同响应。这个改变直接让这个场景下的执行通过率从71%提到93%。6.3 典型场景C异步方法的测试异步代码的测试对模型来说是天然盲区。prompt阶段的模型经常拿同步思维写异步测试直接断言还没返回的结果。我试过在提示词里加注意CompletableFuture异步等待效果一般。LoRA数据集里塞了几十条不同风格的项目异步测试样本之后模型学会了CompletableFuture.join()的正确位置以及超时时间的设置这个进步是提示词怎么压都压不出来的。6.4 把翻车现场也放出来数据泄露、过拟合、格式错乱不能光报喜。我微调翻过几次车最典型的第一次数据没做清洗有一批测试里面调用了私有方法模型学了一堆getDeclaredMethod setAccessible(true)的黑魔法生成出一堆反射测试编译通过率高得惊人但覆盖率却低得吓人。后来拿掉这类样本重新训练才恢复正常。第二轮翻车是过拟合。epoch跑了6轮训练loss从1.1降到0.2我一看降得漂亮直接把这个版本上线结果在没见过的函数上生成质量还不如baseline模型。特征很明显模型只会重复数据集的写法遇到新代码结构时完全失去泛化能力。最后把所有训练轮次降到3又在评估流水线上跑通发现生成质量才真正稳住了。7. 严格自查后的落地清单和几个长期看到的效果如果现在让你自己动手复现这条路我建议按这个顺序走先不管微调的事把提示词模板写出来跑一个最小可行链路让模型真能生成出可编译、可执行、有覆盖率的测试用例然后在评估流水线上把baseline跑稳当你确认提示词已经榨出80%的能力之后再攒数据集上LoRALoRA训练完不要急着并权重单独开一个服务用同样是评估流水线跟baseline对比不能说效果没变好就把微调版本换上去。这一套流程跑完我这边长期挂在流水线上的效果是存量老代码的单测覆盖率平均提升了约30个百分点新代码的用例生成已经常态化地进到了开发流程里。别指望它替代人也别在它出错的时候甩锅给它把它当成一个永远有精力把边边角角分支都测一遍的实习生定位就对了。最后再分享一个小技巧微调完的模型并不是终点你还可以把提示词里成功的范例继续优化替换成微调后生成的优质新样本让底座模型提示词LoRA三者互相迭代。我的经验是这个迭代周期大概一个月做一轮每一轮都能见到稳定的质量爬坡。
返回列表