最近使用 Codex + GPT5.6 sol xhigh遇到了一个典型 bug,记录一下,业务已脱敏 + 转成其他业务说明,这里仅举例说明问题,供参考,核心意思不变。
事故:一行改动让旧策略静默变质
一次真实的火车票往返计价事故,一句话就能概括教训:
共享数据不等于共享语义,复用输入不等于复用策略假设。
系统收到一次往返查询,会拿到去程、返程两份报价:去程报价对应去程日期、车次、座席,返程报价同理对应返程。系统里有两个计价策略,都实现同一个 PricingStrategy 接口(策略模式):标准往返策略让去程、返程各自用自己的车次、报价、校验上下文组合出价格;去程基准优惠策略(新需求)以去程票面价为基准,先从去程报价建立专用索引,再结合优惠参数计算。
为了支持新策略,Agent 改了公共加载逻辑。原本它分别读两个方向的报价:
ini
List<FareQuote> outboundQuotes = input.getOutbound().getQuotes();
List<FareQuote> inboundQuotes = input.getInbound().getQuotes();
Agent 把它改成了返程也读去程:
ini
List<FareQuote> outboundQuotes = input.getOutbound().getQuotes();
List<FareQuote> inboundQuotes = input.getOutbound().getQuotes(); // 返程读成了去程
改动有"合理"的解释:"新策略需要去程报价作基准,公共流程统一用去程,少写一套逻辑。"但这段加载逻辑不只服务新策略,标准策略也用它。于是标准策略的实际行为变成"去程车次 + 去程报价、返程车次 + 去程报价",返程报价消失了。更糟的是,一旦返程换成去程,返程链路里的其他参数------校验上下文、日期、发车时间、航段索引------都可能被顺手带错。返程的过滤条件、库存判断、时间限制全在用去程信息,结果对象照常创建,却已经不是真实的"去程 + 返程"组合。
这个 bug 最阴险的地方在于它不会立刻暴露:系统不崩溃,仍然返回价格,只是价格的含义错了。新策略的测试只验证了"能用去程报价",当然通过;旧策略没人测,测试数据也没有区分度。如果测试数据是"去程二等座 100 元、返程二等座 100 元",返程错读去程,金额也碰巧一样。有区分度的数据应该刻意不对称------去程报价 ID 101、价格 100,返程报价 ID 202、价格 230------并同时断言:标准策略去程用 101、返程用 202;去程基准策略的索引只含 101。只有这样才能证明"新旧策略互不污染"同时成立。
根因:共享输入不等于共享语义
事后容易说"Agent 修改了公共对象,所以影响了其他策略"。更准确的说法是:原始输入可以共享,公共代码不应该替某个策略重新解释输入。多个策略读同一个只读的 RoundTripInput 完全合理------标准策略分别读 outbound 和 inbound,新策略读 outbound 生成自己的基准价索引。真正危险的是把新策略的特殊假设写进公共预处理器:"所有策略都把 inbound 当成 outbound"。这不是数据复用,是语义污染。
策略模式在这里没有起保护作用,很多人看到两个 PricingStrategy 就以为已经互相隔离了。但策略模式只隔离算法入口,不隔离算法依赖的公共预处理,Bug 落在 SharedPreprocessor 里,两个策略都会中招。策略模式解决的是"选择哪个算法",不解决"算法之间是否共享了错误的上下文"。
修复:隔离派生结果
正确方案不需要复制整份往返输入,也不需要在标准策略里塞条件分支。给新策略增加一个专用的派生对象 OutboundBenchmarkIndex,流程变成:共享只读的 RoundTripInput 之下,标准策略继续各自读 outbound / inbound,新策略把 outbound 报价喂给自己的索引再计算。公共输入只构建一次,每个策略拥有自己的派生结果和解释方式。记住一条规则:只读输入可以共享,派生结果必须私有。系统里已有类似的专用索引就直接复用,不要为了"看起来更隔离"而复制一整套底层输入模型------新对象应该解决真实的生命周期或语义差异,而不是增加结构数量。
使用 Coding Agent 的注意点
这类事故,Agent 有典型的犯错路径。首先,需求通常只描述新增行为:"新策略使用去程票面价"写得很明确,而"标准策略必须继续使用返程报价"往往没写进需求,只藏在旧代码的数据流里。Agent 围绕新增测试调整代码,就容易牺牲这个隐式契约。其次,复用看起来比新增派生对象更简单:直接复用已有的去程报价,改动少、短期也更干净,但应该复用的是原始数据,不是其他策略的解释方式。再次,非空结果会制造成功假象------编译成功、测试通过、返回金额正常,补丁看起来可靠,而业务语义错误不会抛异常,只会在特定的价格、日期、座席组合下出现。最后,名称和注释未必可靠,往返代码里 first、second、leg1、leg2 不一定稳定对应去程和返程,判断对象归属要沿着可执行的数据流确认:这个车次来自哪个字段?这个日期属于哪个航段?这个校验上下文和哪个报价一起用?这个结果最终写入哪个策略的输出?代码的实际调用关系比注释和变量名可靠。
对使用 Agent 的人来说,有几条务实的做法:
-
需求里写"禁止项"而不只是"新增项"。Agent 字面执行 prompt,没写的约束不是被忽略,而是根本不知道。例如:
diff需求:新增去程基准优惠策略,以去程票面价为基准计算。 新增项: - 新增 BenchmarkStrategy,用去程报价建立专用索引 禁止项: - 标准往返策略必须继续读返程报价,行为不得改变 - 公共加载器、预处理器、RoundTripInput 不得修改 只允许新增:BenchmarkStrategy 及其测试文件 -
明确指定触碰边界。告诉它只允许新增或修改哪些类,公共 Context、加载器、预处理器一律不许动。Agent 对"相关代码"的理解比人宽泛,倾向改最近的公共代码来省事。
-
尽早使用 code review 功能,结合项目自定义与优化 review 注意点。diff 审查盯共享路径,不是新增文件。新增策略类少看几眼问题不大,公共组件或路径改一行必须逐字审------改动行数不是风险度量,改的是谁才是。
-
先让 Agent 写"旧行为不变"的测试。先补一条两个策略同时跑、数据不对称的回归测试,再写新功能,改坏时立刻红。
-
验收时对照 prompt 问它动了哪些公共代码、为什么。对不上的改动单独看,Agent 常会悄悄"顺手优化"。
-
留意新旧策略同时开启时,中间结果是否互不覆盖。共享的派生数据一样会互相污染,即上文规则:只读输入可以共享,派生结果必须私有。
-
注意一个高频危险信号:"为了支持新策略,把公共流程统一改成使用某一侧的数据。"这句话通常意味着局部规则正在越过策略边界。
代码侧:把约束变成强制
靠 Agent 自觉不可靠,最好让这类 bug 在编译期或测试期就暴露。第一层是类型编码语义:根因是 outbound 和 inbound 都用 List<FareQuote>,换错方向编译照样过。如果两侧是不同的语义类型,比如 List<OutboundQuote> 和 List<InboundQuote>,哪怕只是轻量包装类,"把去程塞给返程"就直接变成编译错误。第二层是让共享输入只读、派生结果私有:RoundTripInput 构建后不可变,新策略的数据视图只在自己类内部生成、不外泄到公共层,Agent 想改公共预处理器时类型上无处下手。第三层是契约测试锁死旧策略------一条测试同时跑两个策略、用不对称数据,断言各自引用各自的报价:
scss
@Test
public void strategiesUseOwnLegData() {
Result std = standardStrategy.calculate(input); // 返程报价 ID = 202
Result bench = benchmarkStrategy.calculate(input); // 索引只含去程 ID = 101
assertStdUsesInbound202(std);
assertBenchmarkIndexOnly101(bench);
}
这条测试要在加新功能之前就存在,进 CI、挡合并。第四层是功能开关:新策略走 QConfig 开关,开关关掉时行为必须与改动前逐字节一致,开关就成了"Agent 改坏公共逻辑"的兜底闸门。第五层是把依赖规则写成代码,用 ArchUnit 之类声明"新策略类不得依赖公共预处理器",违反即 CI 失败------规则变成测试,Agent 就不会"觉得可以"。这五层分工不同:类型和只读约束在编译期拦截,契约测试和 ArchUnit 在测试/CI 期亮红灯,功能开关是运行时的兜底闸门------把语义污染从静默的运行时 bug,变成编译错误、测试红灯或可一键回退的开关。
总结
这次修复本身不复杂:恢复标准策略对去程和返程报价的正确映射,新策略继续使用自己的基准价索引。
但这次事故真正值得记住的,是怎么用 Coding Agent。LLM 执行力强,也会犯低级错误------把 inbound 读成 outbound 就是典型一例。这类错误最危险的是静默成功:编译过、测试过、金额正常,语义却已错。LLM 自己意识不到:它字面执行 prompt,约束不在 prompt 里等于不存在,加上天然无状态、上下文有限,看不到完整调用链,评估公共代码影响面时更容易漏掉隐式依赖。
所以别完全信任 Agent,信任要靠验证。人的工作不是替代它写代码,而是补上它缺的三样东西:
- 把约束写出来。禁止项、触碰边界、"旧行为不变",全明写进需求。Agent 不知道的约束等于不存在。
- 人为代码审查。code review 和 diff 审查优先看公共代码,新增文件可以放心,公共路径改一行要深入分析。
- 把兜底建起来。契约测试、类型编码、功能开关,让低级错误在编译期、测试期或开关处暴露。