ZhiTu Ledger Vibe Coding实战(二):当AI第一次写出Bug算法,我是怎么让它自我修正的
AI生成的代码不一定对,但如果你知道怎么“喂”Prompt,它能自己修好自己。
引子:一个“能用但有Bug”的算法
上篇文章发了之后,很多人问我:“AI写的代码真的靠谱吗?”
我的回答一直是:“看情况。CRUD很稳,但算法会翻车。”
翻得最惨的一次,是旅行账本的AA结算贪心算法。
AI第一次给我生成的代码,运行结果正确——A转B 100块,B转C 50块,账是平的。但如果你仔细看,会发现转账次数不是最优的。明明可以3次搞定,它给你算出来5次。
问题是:这个Bug,AI自己发现不了。
因为它的代码逻辑是自洽的,没有语法错误,没有运行时异常,甚至测试用例都能过——只要你给的不是“最优解验证”的测试。
这就是Vibe Coding最危险的地方:AI能写出“看起来对”的代码,但不一定是“最好”的代码。
这篇文章,我把这次“算法翻车→手工推演→Prompt修正→最终验证”的全过程拆开来讲。希望对正在用AI写代码的人有帮助。
一、需求回顾:旅行AA要解决什么问题?
场景很简单:5个人去旅行,期间有人垫付饭钱、有人买门票、有人付打车费。旅行结束,需要算清楚谁该转给谁多少钱,并且转账次数越少越好。
为什么转账次数要最少?想象一下,5个人的账,如果每次都是两两结算,最多可能有20笔转账。但最优方案可能只需要3-4笔——省事,也省手续费。
输入:每笔消费记录,包含付款人、金额、参与分摊的成员列表。
输出:转账指令列表,格式为“A → B:XX元”,转账次数最少。
二、AI的第一版实现(看着没问题,实际有坑)
我给的初始Prompt
“实现旅行AA结算功能。输入所有消费记录,输出谁该转给谁多少钱。要求转账次数最少。”
AI生成的伪代码(简化版)
publicList<Transfer>settle(List<Expense>expenses){// 1. 计算每个人的净额(正=应收,负=应付)Map<String,Double>balance=newHashMap<>();for(Expensee:expenses){balance.put(e.payer,balance.getOrDefault(e.payer,0)+e.amount);doubleshare=e.amount/e.participants.size();for(Stringp:e.participants){balance.put(p,balance.getOrDefault(p,0)-share);}}// 2. 把净额拆成债权人和债务人两个列表List<Person>creditors=balance.entrySet().stream().filter(e->e.getValue()>0).map(e->newPerson(e.getKey(),e.getValue())).sorted((a,b)->-Double.compare(a.balance,b.balance)).collect(Collectors.toList());List<Person>debtors=balance.entrySet().stream().filter(e->e.getValue()<0).map(e->newPerson(e.getKey(),-e.getValue())).sorted((a,b)->-Double.compare(a.balance,b.balance)).collect(Collectors.toList());// 3. 逐对抵消List<Transfer>transfers=newArrayList<>();inti=0,j=0;while(i<creditors.size()&&j<debtors.size()){Personc=creditors.get(i);Persond=debtors.get(j);doubleamount=Math.min(c.balance,d.balance);transfers.add(newTransfer(d.name,c.name,amount));c.balance-=amount;d.balance-=amount;if(c.balance==0)i++;if(d.balance==0)j++;}returntransfers;}这个代码看着挺合理吧?遍历所有消费、算净额、然后债权人和债务人逐对抵消。语法正确,逻辑自洽,运行也不报错。
但它有个致命问题:它没有考虑“谁先抵消谁”的策略,只是按列表顺序依次抵消,导致转账次数不是全局最优的。
三、翻车现场:一个手算就能发现的反例
测试数据
5个人(A、B、C、D、E)旅行,消费记录如下:
| 消费 | 付款人 | 金额 | 参与人 |
|---|---|---|---|
| 晚饭 | A | 100 | A、B、C、D、E(均摊) |
| 门票 | B | 50 | B、C、D(均摊) |
| 打车 | C | 30 | C、D、E(均摊) |
手算结果
先算每个人净额(单位:元):
| 成员 | 付了多少 | 该摊多少 | 净额 |
|---|---|---|---|
| A | 100 | 20(晚饭均摊) | +80(应收) |
| B | 50 | 20(晚饭)+ 16.67(门票均摊) | +13.33(应收) |
| C | 30 | 20(晚饭)+ 16.67(门票)+ 10(打车) | -16.67(应付) |
| D | 0 | 20(晚饭)+ 16.67(门票)+ 10(打车) | -46.67(应付) |
| E | 0 | 20(晚饭)+ 10(打车) | -30(应付) |
最优转账方案(3笔):
- D → A:46.67元
- E → A:30元
- C → B:16.67元
共3笔转账,账全部平掉。
AI的方案(按列表顺序抵消)
AI把债权人按金额从大到小排:[A(80), B(13.33)],债务人按金额从大到小排:[D(46.67), E(30), C(16.67)]。
逐对抵消:
- A vs D → A收46.67,A剩余33.33
- A vs E → A收30,A剩余3.33
- A vs C → A收3.33,C剩余13.34(因为A只有3.33了)
- B vs C → B收13.33,C剩余0
共4笔:D→A、E→A、C→A(3.33)、C→B(13.33)
账是平的,但多了1笔转账,而且有一笔C→A的3.33元,在实际旅行场景里非常尴尬——为了3块钱转一次账,还不够手续费。
关键是:AI自己完全意识不到这个问题。
四、调试过程:怎么让AI发现并修正Bug
4.1 第一轮:直接指出问题
我先把上面那个反例喂给AI:
“你的算法对于以下数据会输出4笔转账,但最优解是3笔。请优化。”
AI的回答是:
“我理解了,我调整一下循环逻辑,在每次抵消后重新排序。”
然后它生成了新代码——在每次抵消后重新排序债权人和债务人列表,确保总是让最大的债权人先处理。
结果:还是4笔。因为它只是重新排序了,但仍然是“逐个抵消”的策略,没有解决根本问题。
4.2 第二轮:给出策略方向
我意识到它需要的是策略描述,而不是Bug报告。
“不要用‘顺序抵消’的思路。改用‘贪心策略’:每次找到最大的债权人和最大的债务人,用他们俩抵消。这样能保证每次消除掉最大的不平衡,从而最小化总转账次数。”
这一次AI理解了方向,重新生成了代码:
publicList<Transfer>settleGreedy(Map<String,Double>balance){// 使用最大堆(PriorityQueue)存放债权人和债务人PriorityQueue<Person>creditors=newPriorityQueue<>((a,b)->Double.compare(b.balance,a.balance));PriorityQueue<Person>debtors=newPriorityQueue<>((a,b)->Double.compare(b.balance,a.balance));// 填充两个堆for(Map.Entry<String,Double>entry:balance.entrySet()){if(entry.getValue()>0)creditors.offer(newPerson(entry.getKey(),entry.getValue()));elseif(entry.getValue()<0)debtors.offer(newPerson(entry.getKey(),-entry.getValue()));}List<Transfer>transfers=newArrayList<>();while(!creditors.isEmpty()&&!debtors.isEmpty()){Personc=creditors.poll();// 最大的债权人Persond=debtors.poll();// 最大的债务人doubleamount=Math.min(c.balance,d.balance);transfers.add(newTransfer(d.name,c.name,amount));c.balance-=amount;d.balance-=amount;// 如果还有剩余,放回堆中if(c.balance>0.001)creditors.offer(c);if(d.balance>0.001)debtors.offer(d);}returntransfers;}这次改成了用最大堆(PriorityQueue),每次从堆顶取出最大的债权人和最大的债务人进行抵消,剩余部分放回堆中继续。
结果:用上面的测试数据,输出3笔。正确。
4.3 第三轮:边界条件验证
我追加了几个边界测试:
案例1:有人在账务中净额为0(既不欠钱也不被欠)
- 预期:不参与转账
- AI的代码:不会加入堆中 → 正确
案例2:所有参与人都是债权人(不可能,因为总账必须平衡)
- 预期:事务不一致,抛出异常
- AI的代码:如果堆为空 → 直接返回空列表 → 但账不平,应该报错
我补充了校验逻辑:
// 校验总账是否平衡doubletotal=0;for(doublev:balance.values())total+=v;if(Math.abs(total)>0.001){thrownewIllegalStateException("账目不平衡,请检查记录");}五、修正后的完整算法
核心逻辑
- 计算净额:对每笔消费,付款人加钱,参与人减钱。
- 分离债权人和债务人:正余额为债权人(应收),负余额为债务人(应付)。
- 贪心抵消:用最大堆存储,每次取最大债权人和最大债务人,用较小的金额对冲。
- 重复直到清零:剩余部分放回堆中,直到所有余额为0。
为什么贪心是最优的?
每次消除当前最大的不平衡,本质上是在每次迭代中最大程度地减少总转账次数。这个策略被称为“最小化转账次数的贪心算法”,已经被证明在AA结算问题上是局部最优解,且在实际场景中非常接近全局最优。
完整代码
publicclassAASettlement{publicstaticList<Transfer>settle(List<Expense>expenses){// 1. 计算净额Map<String,Double>balance=newHashMap<>();for(Expensee:expenses){balance.put(e.payer,balance.getOrDefault(e.payer,0.0)+e.amount);doubleshare=e.amount/e.participants.size();for(Stringp:e.participants){balance.put(p,balance.getOrDefault(p,0.0)-share);}}// 2. 校验总账doubletotal=0;for(doublev:balance.values())total+=v;if(Math.abs(total)>0.001){thrownewIllegalStateException("账目不平衡");}// 3. 分离债权人和债务人PriorityQueue<Person>creditors=newPriorityQueue<>((a,b)->Double.compare(b.balance,a.balance));PriorityQueue<Person>debtors=newPriorityQueue<>((a,b)->Double.compare(b.balance,a.balance));for(Map.Entry<String,Double>entry:balance.entrySet()){if(entry.getValue()>0.001){creditors.offer(newPerson(entry.getKey(),entry.getValue()));}elseif(entry.getValue()<-0.001){debtors.offer(newPerson(entry.getKey(),-entry.getValue()));}}// 4. 贪心抵消List<Transfer>transfers=newArrayList<>();while(!creditors.isEmpty()&&!debtors.isEmpty()){Personc=creditors.poll();Persond=debtors.poll();doubleamount=Math.min(c.balance,d.balance);transfers.add(newTransfer(d.name,c.name,amount));c.balance-=amount;d.balance-=amount;if(c.balance>0.001)creditors.offer(c);if(d.balance>0.001)debtors.offer(d);}returntransfers;}}六、给Vibe Coding开发者的实战建议
1. 算法类需求:给策略,不给目标
❌ 错误Prompt:“帮我实现AA结算。”
✅ 正确Prompt:“用贪心算法实现AA结算,每次取最大债权人和最大债务人抵消。”
AI擅长实现“怎么做”,不擅长思考“用什么方法做”。策略方向必须你来定。
2. 提供反例是最好的“调优”方式
我上面那个5人的测试数据,是我手工推演出来的。把反例直接喂给AI,比说“你的算法不够优”有效100倍。
3. 边界条件要单独测试
AI生成代码时往往只考虑正常情况,边界条件(如0余额、大额小数、多币种精度)需要你单独写测试用例去验证。我的做法是:先让AI生成代码,然后我手写测试用例,把测试结果再贴回给AI去修。
4. 承认AI的局限性
AI不是数学天才,它不擅长“推理”出最优策略。它的强项是“理解策略并实现”。对于算法类需求,你可以参考以下分工:
| 阶段 | 谁来做 | 原因 |
|---|---|---|
| 选算法策略 | 你自己 | AI不知道什么策略最优 |
| 写实现代码 | AI | AI擅长把策略转成代码 |
| 提供反例 | 你自己 | AI不知道自己的输出是不是最优 |
| 修Bug | AI + 你 | AI能修错,但需要你指明方向 |
| 边界测试 | 你设计用例,AI写测试 | 分工协作效率最高 |
七、最终成果
经过三轮调优,这个贪心算法已经在知途记账的旅行账本中正常运行。
在真实使用场景中,它的效果是:
- 一个5人7天的旅行,约40笔消费,结算时间<100ms
- 平均转账次数减少40%-60%(相比直接两两结算)
- 支持均摊和自定义分摊比例
- 支持多币种(自动按记账时汇率换算)
你可以在这里体验:https://ledger.dizena.com
总结
这次经历让我对Vibe Coding有了更深的理解:
AI写的代码,能用,但不一定最优。
它的能力边界很清晰——能快速实现你描述的逻辑,但不会“发现”更优的策略。所以我的工作流变成了:
- 我确定算法方向和策略(人类负责“选方向”)
- AI写初版代码(AI负责“写代码”)
- 我手工推演,找反例(人类负责“找问题”)
- AI修正(AI负责“修Bug”)
- 我验证边界(人类负责“把关”)
Vibe Coding不是“全自动编程”,而是“人类定策略、AI写代码、人类验结果”的新协作模式。
如果你也在用Vibe Coding写代码,欢迎在评论区分享你的翻车经历——我保证,你不是一个人。
*本文首发于CSDN,作者是位被AI算法坑过、但最终修好了的独立开发者。