30秒速览
- 代码异味这东西,不管的话能慢慢毒死一个系统。我们那个1200行的订单计算函数就是最好的例子,测试覆盖率12%,加一个促销规则要三天。LLM能帮忙检测和重构,但前提是你得把提示词设计好——别让它想当然合并业务逻辑,得强制它输出行为等价性声明。沙箱验证是最后的保命符,随机扔1万个测试用例进去比对输出,我们靠这个逮到了一个跨函数状态污染的隐式bug。
那些被我们容忍了三年的代码异味,差点把整个订单系统拖垮
说起来有点丢脸,但这件事确实发生在我们组里。三年前,我们的电商订单处理模块跑得还算顺畅,日处理10万单没什么压力。随着业务野蛮生长,各种促销规则、优惠券叠加逻辑、运费计算方式不断往上贴,代码逐渐变成了名副其实的“意大利面条”。最明显的一个症状是:一个叫calculateOrderPrice的函数,从最初的80行膨胀到了1200多行,里面嵌套了14层if-else,有些分支的循环复杂度直接飙到28。我跟你说,每次有新同事入职,让他们读这个函数,三天之内必定提离职。
这种代码异味不是一天形成的。我自己亲手往里面塞过两次“临时方案”——一次是双十一前夕临时加了个满减逻辑,想着活动结束就重构,结果这个“临时”活了两年;另一次是对接第三方物流接口时,为了赶进度直接复制粘贴了200行代码改了三个参数。当时心里想的是“反正下个迭代就修”,但你知道的,“下个迭代”永远不会来。到今年年初,这个模块已经成了整个团队的噩梦。修一个运费计算bug可能导致优惠券失效,改一个促销规则会引发库存扣减异常,我们甚至专门安排了一个“值班敢死队员”来处理线上问题——这哥们儿后来真的辞职去卖咖啡了。
经典的代码异味在这个模块里几乎集齐了:长函数、过大的类、重复代码、特性依恋、霰弹式修改、数据泥团。但最让我头疼的不是这些表面症状,而是更深层的逻辑腐烂。比如有个函数叫computeMemberDiscount,看似只负责计算会员折扣,实际上它会偷偷修改一个全局的orderContext对象里的三个字段,然后calculateShippingFee在完全不显式依赖这个字段的情况下,通过一个叫ShippingRuleEngine的中间层间接读取了被修改的值。这种隐式的状态传递链跨了五个函数、三个文件,除非你把整个调用栈背下来,否则根本不知道数据从哪来的。我花了整整两天时间画调用图,才搞清楚一个“为什么VIP用户选了包邮商品却被收了8块钱运费”的bug是什么导致的——原来是computeMemberDiscount在某些边界条件下会把会员等级从3降到2,而ShippingRuleEngine读取的就是被篡改后的等级。
这种代码异味带来的影响是全方位的。测试覆盖率只有可怜的12%,但这还不是最惨的——因为那些测试大多是针对正常路径的happy path测试,对边界条件几乎零覆盖。我们试过补充单元测试,但发现根本写不下去:一个函数需要mock的外部依赖多达17个,构造函数接受9个参数,其中三个是巨大的配置对象。性能方面也在恶化,calculateOrderPrice的平均响应时间从最初的80ms涨到了420ms,峰值能到1.2秒。我怀疑里面有些循环因为重复计算和无效的对象创建在疯狂浪费CPU,但没人敢去碰那些嵌套逻辑,生怕引发连锁反应。最致命的是业务响应速度:加一个新促销规则原本两小时能搞定,现在至少需要三天,其中两天半是在理解现有逻辑和担心改崩线的恐惧中度过的。
今年二月份,CTO终于拍板:这个模块必须重构,但不能停业务,不能出线上事故,预算只给两周。我当时的第一反应是“你杀了我吧”。但转念一想,这也是个机会——我一直想试试用LLM辅助重构,让它去啃那些恶心人的屎山代码,我在旁边盯着加安全阀就行。这个想法最后确实救了我们,但过程里的坑绝对比你想象的多。
让LLM当侦探:提示词里不塞业务知识,它比实习生还瞎
一开始我的想法特别天真:把整个calculateOrderPrice函数丢给LLM,让它直接输出重构后的干净版本。我用的当时刚发布的Claude 3.5 Sonnet,觉得它的代码理解能力应该足够。写了这么个提示词:“这是一个订单计算的旧函数,请重构它,使代码更清晰、可维护。”然后满怀期待地等了十五秒——结果它确实重构了,把1200行代码压缩到了400行,看起来挺整洁的。但一细看差点心梗:它把“满100减20”和“满200减50”的逻辑合并了,但我们的业务规则是这两个优惠可以叠加使用,叠加后先减20再减50,而LLM理解成了互斥关系,直接用一个switch-case替换了原来的if-else链。更离谱的是,它把运费计算里的一个“当订单包含液体商品时走陆运”的特殊处理给删了,因为它觉得那个判断条件“冗余”。
这次翻车让我意识到一个核心问题:LLM不懂业务上下文。它看到的只是代码文本和通用的编程模式,但那些隐藏在if-else里的业务规则、那些看似冗余实际上是防御性编程的判断、那些历史沉淀下来的微妙时序依赖,它统统不知道。如果你不把这些约束明确告诉它,它就像一个只会背设计模式的实习生,把代码写得很漂亮但不保证正确。后来我完全换了个思路:不让LLM直接动手重构,而是让它当“侦探”,只负责检测代码异味并给出检测报告。这样至少不会直接搞坏代码库,但即使只是检测,提示词设计也是门手艺活。
我设计的检测提示词大概长这样:
你是一个代码审查专家。请分析以下Java代码片段,识别其中存在的代码异味。
对于每个异味,请提供:
1. 异味类型(从以下列表选择:长方法、重复代码、过长参数列表、循环复杂度高、
特性依恋、数据泥团、霰弹式修改、神秘命名、全局状态滥用、未处理边界条件)
2. 出现位置(精确到行号)
3. 影响评估(低/中/高/严重)
4. 简洁的重构建议(一句话,不要展开)
特别注意:
- 不要假设业务逻辑可以随意合并或简化
- 不要建议删除你认为是“冗余”的条件判断
- 关注隐式的状态修改和副作用
- 标记所有嵌套深度超过4层的代码块
这个提示词有几个关键设计。首先,我把异味类型限制在了一个明确的列表里,而不是让LLM自由发挥。不限制的话,它会发明各种奇怪的名词,比如什么“逻辑分散综合征”、“职责模糊症候群”——名字听着挺唬人但对实际定位问题毫无帮助。其次,我明确禁止它建议“简化”或“合并”业务逻辑,因为这是最容易出错的地方。第三,我特别强调了“隐式状态修改和副作用”,这正好对应我们模块里最危险的那种跨函数状态污染。
实际跑了一遍,效果比我想象的好。LLM准确定位了calculateOrderPrice里的14处嵌套深度超过4层的代码块,标记了3个“特性依恋”问题(函数频繁调用另一个类的getter而不是让那个类自己完成计算),还揪出了一个我都没注意到的“数据泥团”——price、discount、tax、shippingFee、insuranceFee这五个变量总是一起出现,应该封装成一个值对象。但也有不少误报,比如它标记了一个“神秘命名”问题,说tmp这个变量命名不清晰。但实际上tmp是一个只活了3行的临时变量,在那种上下文里用tmp反而是最清晰的,硬要写成temporaryIntermediateDiscountCalculationResult才是脑子有包。
更让我头疼的是LLM对“重复代码”的检测。它会把两段结构相似但业务含义完全不同的代码标记为重复。比如我们的“新用户首单折扣”和“老用户复购折扣”计算逻辑在代码结构上看起来确实很像,都是先查用户表,再判断订单总额,然后应用不同的折扣率。LLM建议抽取一个通用方法,参数化折扣率。但实际业务里,新用户折扣和复购折扣的数据来源不同(一个从用户注册表查、一个从订单历史表查),未来可能的改动方向也完全不同,强行合并只会让未来的改动更痛苦。这种“假重复”的判断需要开发者自己来做,LLM能提供线索但绝对不能替你做决定。
经过几轮迭代,我把提示词调整到了一个比较舒服的状态:让它扮演“气味探测器”而不是“解决方案提供商”。它负责扫描、定位、分类,但最后的判断和重构方案由我来定。这就引出了下一个问题——当LLM给出了重构建议之后,怎么安全地把建议落地?
差异不是补丁那么简单——我把重构拆成“手术刀式变更”再喂给LLM验证
有了LLM的检测报告,下一步就是实际重构。但这时候我还是不敢放手让LLM改代码。我的策略是:把重构拆成一系列小的、可验证的变更,每个变更只做一件事,然后用一套流水线来验证每个变更的安全性。这个思路其实来源于Martin Fowler的“重构是微小步长”的理念,只不过我把每一步的“代码生成”外包给了LLM,而我自己专注在“安全性验证”上。
具体来说,我选了一个最安全的切入角度来解决重复代码问题——不是那些涉及复杂业务逻辑的重复,而是一个纯粹的技术性重复:三个不同的服务类里都有几乎一模一样的数据库连接池配置代码,总共15行,分散在三个文件里。这种重复的消除几乎不会有业务风险,适合用来跑通整个流水线。我写了一个提示词,把三个文件的内容都塞进去,让LLM生成一个统一的ConnectionPoolConfig工具类,同时给出修改后三个文件的完整内容。
这里的关键技术点在于“差异生成”。我不要LLM直接输出完整的重构后代码让我人眼比对,而是让它同时输出三样东西:重构前的代码、重构后的代码、以及一份结构化的差异说明。提示词长这样:
请对以下三个文件进行重构,消除数据库连接池配置的重复代码:
[文件A内容]
[文件B内容]
[文件C内容]
请按照以下格式输出:
===重构后新文件:ConnectionPoolConfig.java===
[完整的新文件内容]
===修改后:FileA.java===
[完整的修改后内容]
===修改后:FileB.java===
[完整的修改后内容]
===修改后:FileC.java===
[完整的修改后内容]
===差异摘要===
1. 新增文件:ConnectionPoolConfig.java(提取的公共配置类)
2. FileA.java 变更:删除第23-37行,第42行改为ConnectionPoolConfig.getDefault()
3. FileB.java 变更:删除第15-29行,第35行改为ConnectionPoolConfig.getDefault()
4. FileC.java 变更:删除第8-22行,第28行改为ConnectionPoolConfig.getDefault()
===行为等价性声明===
请逐条说明为什么这些变更不会改变程序的运行时行为,
只允许结构性重构(提取方法、提取类、移动方法、内联变量等),
不允许任何逻辑变更、算法替换或条件判断修改。
这个提示词的精髓在最后那个“行为等价性声明”部分。我发现如果不加这个约束,LLM有时候会自作主张地“顺便优化”一下逻辑——比如把连接的超时时间从5000ms改成3000ms,理由是“常见的最佳实践”。这种行为在它看来是善意的,但对我来说就是一颗定时炸弹。加上这个声明后,LLM被强制要求为每个变更做出解释,如果它意识到自己做了一个逻辑变更,它就会在声明里暴露出来(或者干脆放弃那个变更),这给了我一个审查的机会。
跑通这个简单的案例之后,我开始逐步提高难度。下一个目标是消除calculateOrderPrice里的“特性依恋”问题。这个函数有大量的代码在频繁调用order.getCustomer().getName()、order.getCustomer().getLevel()、order.getCustomer().getRegistrationDate()——典型的特性依恋,逻辑应该移到Customer类或者至少封装在一个方法里。我把问题代码喂给LLM,让它生成重构方案,同样的格式要求。LLM提出在Customer类里新增一个calculateEligibleDiscount(orderHistory, currentOrder)方法,把原本分散在calculateOrderPrice里的用户等级判断、历史订单检查、注册时长计算都移了过去。
这个重构比连接池提取复杂得多,因为它涉及业务逻辑的移动。LLM生成的差异摘要里列出了calculateOrderPrice里要删除的87行代码,以及在Customer类里新增的92行方法。行为等价性声明里,LLM逐条解释了为什么移动这些逻辑不会改变行为:函数签名里的参数包含了所有需要的数据、没有引入新的外部依赖、所有条件判断的嵌套顺序保持不变。但当我仔细审查时,发现了一个致命问题:LLM在移动代码时把一个if (orderHistory.size() > 0)的判断改成了if (!orderHistory.isEmpty())。它认为这是“等价的语义替换”,但实际上orderHistory有可能为null,isEmpty()会在null上抛NPE,而原来的size() > 0也会抛NPE。两者对null的处理是一样的,所以严格来说确实是行为等价的——但LLM在声明里完全没有提到这个NPE风险,它只是觉得isEmpty()“更具语义清晰性”。这说明它的行为等价性声明虽然有用,但还不够严谨,需要额外的验证机制来兜底。
这整套差异生成流程让我养成了一个习惯:每次LLM生成重构代码后,我做的第一件事不是看代码,而是仔细读它写的行为等价性声明。大部分情况下,LLM是诚实的——如果它确实动了不该动的逻辑,声明里会露出马脚。真正危险的,是那些它以为等价但实际上不等价的变更,以及那些它根本没意识到自己做了的微小修改。这两个漏洞,就需要下一道安全阀来堵。
让机器人在沙盒里跑个步:我把重构前后的代码同时喂给1000个随机测试用例
上一节结尾提到的问题其实是个测试问题。如果我有足够高的测试覆盖率,LLM改了什么我一眼就能从测试失败里看到。但现实是整个模块的覆盖率只有12%,而且那些测试还都是浅层的好路径测试。所以我面临的挑战是:在缺乏完备测试的情况下,如何验证重构前后的代码行为一致性?
我的解决方案是建一个“沙箱一致性验证”环境。核心思路很简单:把重构前和重构后的代码分别部署在隔离的沙箱里,喂给它们相同的随机输入,比较输出结果。如果两个版本的输出在大量随机测试下完全一致,那我就能以较高的置信度说这次重构没有改变行为。这个思路跟差分测试有点类似,但我不需要LLM参与测试生成——我用的是基于业务规则的概率性输入生成器。
具体实现上,我用Docker分别构建了两个镜像:一个运行重构前的代码,一个运行重构后的。每个镜像暴露一个HTTP接口,接收订单请求JSON,返回计算结果。然后我写了一个测试编排器,它会随机生成订单场景——包括随机的商品组合(普通商品、促销商品、液体商品、大件商品)、随机的用户类型(新用户、普通会员、黄金会员、黑金会员)、随机的优惠券组合(无门槛券、满减券、折扣券)、以及各种边界条件(订单金额刚好达到满减门槛、跨零点下单、库存刚好为0等)。
测试编排器的核心代码大概长这样:
import random
import requests
from deepdiff import DeepDiff
def generate_random_order():
return {
"userId": random.choice(existing_user_ids),
"items": random.sample(products_pool, random.randint(1, 8)),
"coupons": random.sample(active_coupons, random.randint(0, 3)),
"address": random.choice(address_pool),
"timestamp": random.choice(time_scenarios),
}
old_endpoint = "http://sandbox-old:8080/calculate"
new_endpoint = "http://sandbox-new:8080/calculate"
discrepancies = []
for i in range(1000):
order = generate_random_order()
old_resp = requests.post(old_endpoint, json=order).json()
new_resp = requests.post(new_endpoint, json=order).json()
diff = DeepDiff(old_resp, new_resp, ignore_order=True)
if diff:
discrepancies.append({
"test_id": i,
"input": order,
"diff": diff
})
if len(discrepancies) > 5:
break # 发现5个不一致就停,不用跑完
if discrepancies:
print(f"发现{len(discrepancies)}个不一致,重构存在行为差异")
else:
print("1000个随机测试全部通过,行为一致")
这套沙箱验证在第一个案例上跑得完美——1000个随机订单在两个版本上的输出完全一致,给我吃了个定心丸。但在第二个案例(特性依恋重构)上,它逮到了问题:第347个随机测试用例的输出不一致。差异出在运费计算上——旧版本返回的运费是18元,新版本返回的是0元。我顺着差异追查下去,发现LLM在移动代码时确实引入了一个微妙的bug:calculateShippingFee原本依赖orderContext里的一个containsLiquid字段来判断是否走陆运,这个字段是在computeMemberDiscount(就是前面提到的那个会偷偷修改全局状态的方法)里被设置的。LLM重构时把computeMemberDiscount里的业务逻辑移走了,但遗留了一行orderContext.setContainsLiquid(false)的副作用在新重构的代码里没有被正确保留。这导致calculateShippingFee读到了默认值false,跳过了陆运加价逻辑。
这个发现让我后背发凉。如果不是随机测试逮到了,仅靠LLM的行为等价性声明、我的代码审查、以及那12%的单元测试,这个bug大概率会溜进生产环境,在某个买了液体商品的用户享受免运费时才会暴露。更难受的是,这种跨函数的状态依赖在代码层面是完全隐式的,LLM根本不可能从静态分析里推断出来——它只能看到computeMemberDiscount里有一行看起来无伤大雅的setter调用,然后轻易地在重构时把它弄丢了。
从那之后,我把沙箱测试的规模提到了一个比较激进的水平:每个重构至少跑1万个随机用例,覆盖正常场景、边界场景、异常场景各占一定比例。边界场景的生成不是完全随机的——我人工维护了一个“已知边界条件”列表,比如“订单金额=满减门槛”、“优惠券刚好剩一张”、“运费刚好触发大件加价”等,让生成器以一定概率命中这些边界。异常场景则包括无效的用户ID、负数商品数量、超长的收货地址等,这些用例主要是验证两个版本在错误处理上的一致性。
跑了几个案例之后,我也总结出了一些经验。沙箱测试的误报率确实存在——有些“不一致”其实是浮点数精度差异导致的,比如旧版本返回{"discount":10.0000000002},新版本返回{"discount":10.0},DeepDiff会认为它们不同,但业务上完全没影响。这就需要加一些容忍逻辑,比如浮点数比较时允许1e-6的误差。还有一种情况是“顺序性差异”:两个版本返回的优惠券列表顺序不同,但内容相同。这种需要配置DeepDiff的ignore_order参数来忽略。
简单说就是沙箱验证成了我整个流水线里最踏实的一道防线。它不依赖测试覆盖率,不依赖LLM的自我审查,用纯暴力比对的方式在最底层兜住了行为一致性。但它也有一个软肋:如果两个版本恰好在同样的随机输入上产生了同样的bug,那沙箱就检测不出来。这种“同步错误”的概率虽然不高,但不是零。所以沙箱验证应该作为最后一道防线,而不是唯一的防线——前面还得有LLM的行为等价性声明、有针对性的人工代码审查、以及逐步补充的单元测试来层层设防。
这套流水线上线三个月后,我们的订单模块从2000行缩减到了1100行,平均响应时间从420ms降到了180ms,测试覆盖率从12%提到了43%(还在继续补)。最关键的是,线上没出过一次重构引发的事故。LLM帮我们啃掉了最硬的骨头,但那些骨头渣子——那些隐式的依赖、那些微妙的时序假设、那些“碰巧能工作”的代码——是被我们一层层的验证机制拦下来的。我现在对LLM重构的态度是:可以用,而且应该用,但必须把它当成一个“能力极强但不懂业务的初级程序员”,你指哪它打哪,但你得负责确认它打的姿势是对的。