AI 编程为什么总在同样的地方翻车:思维惯性、数据库设计、过度设计与日志追溯

  created  by  鱼鱼 {{tag}}
创建于 2026年09月30日 15:47:34 最后修改于 2026年09月30日 15:49:40

这两年,我们团队把 AI 编程工具(Copilot 类的补全、IDE 里的对话式助手、能自己读写文件并跑命令的 Agent)真正用进了日常开发。坦白说,收益是实打实的:样板代码、单元测试、脚本、接口联调、陌生框架的第一版实现,速度都明显快了。但用得越久,我们越发现一个现象:AI 写出来的代码,翻车的地方惊人地相似。不是随机的 bug,而是一套“可预测的坏味道”,换个项目、换个模型,过一段时间又会撞上同一堵墙。

我们把最常遇到的四类问题整理出来:

  1. 在对话里明明纠正过的错误,几轮之后又被模型“请”了回来(思维惯性);

  2. 数据库设计糟糕:超长的键、滥用 LONGTEXT、想用应用代码保证唯一性、表设计得过多或过少、缺少日志表与关联表;

  3. 在细节上过度设计:同一个字段既有 a_b 又有 aB,DTO、VO、Entity 层层复制,到处判空、到处兜底;

  4. 出问题时日志追溯困难:调用链又长又危险,偏偏没有可串起来的日志。

本文不是吐槽,也不想得出“AI 不能用”的结论。我们希望用比较专业的术语,把这些现象背后的机制讲清楚:它们大多不是“模型笨”,而是目标函数、上下文范围和反馈回路共同决定的必然结果。理解了机制,才能有针对性地设计协作方式。文中涉及具体模型行为的判断,都是我们的工程经验与对公开原理的推断,不同模型、不同版本差异很大,请当作“经验假设”而不是定论。

一、先说结论:这四类问题有共同的“根”

在分别展开之前,先给一个统一的视角。四类问题看上去风马牛不相及,一个是对话状态,一个是存储设计,一个是代码风格,一个是运维能力,但追根溯源,几乎都落在下面四个原因上。

1. 目标错位:让测试通过 ≠ 让系统长期可维护

大语言模型的基础训练目标是“预测下一个 token”,后续还会经过指令微调和基于人类反馈的强化学习(RLHF,或类似的偏好优化方法)。这些阶段优化的,本质上是“在给定上下文下,产出看起来正确、让人(或评分器)满意的回答”。而生产系统真正需要的是:十八个月后仍然容易修改、在真实流量下仍然稳定、出了问题能被定位。

前者是一个“局部、即时、可被一眼判断”的目标,后者是“全局、长期、需要运行时才能验证”的目标。当两者冲突时(比如多写一个兜底分支能让 demo 跑通,却让维护成本翻倍),模型没有理由选择后者,因为后者的代价不会体现在它眼前的上下文里。

2. 局部视野:模型只看得见上下文窗口里的东西

模型的“世界”就是当前上下文窗口:你贴的几个文件、它检索到的几个片段、之前的对话。它看不到生产库里有多少行数据、哪些接口是热点、哪个字段前端其实从来不读、下游有哪些服务依赖这个表。决定设计好坏的大量信息,恰恰不在窗口里。

3. 缺失反馈回路:没有运行时,就没有“疼”

人类工程师是被线上事故“教育”出来的:慢查询把库拖垮过一次,就再也不敢随手写 LONGTEXT;半夜排障找不到日志痛苦过一次,就会在每个边界打点。模型没有这样的“疼痛记忆”。在单次会话里,它的反馈大多来自编译器和测试,而编译器和测试恰恰不会检查“表结构合理不合理”“日志能不能串起来”。

4. 分布偏移:训练数据里的“典型代码”≠ 你的生产代码

公开的教程、示例、博客、开源的小项目,在训练语料里占了很大比重。这些代码的共同点是:规模小、场景单一、以“讲清楚一个概念”为目的。而企业系统的代码,是在数据量、并发、合规、历史包袱共同作用下长出来的。让模型“照着典型写”,得到的就是教程级别的设计,用在生产上自然水土不服。

这四点是后文所有分析的底色。下面逐个看具体现象。

二、思维惯性:改过的错误,几轮之后又回来了

现象

这是最让人抓狂的一类。典型场景是这样的:

  • 第 3 轮,你指出“这个项目的时间字段统一用 LocalDateTime,别再用 Date”,模型道歉并改好了;

  • 第 12 轮,让它加一个新功能,新代码里又出现了 new Date();

  • 或者:在另一个会话里,我们已经修过“不要在循环里查库”,新会话里它又写出同样的 N+1;

  • 又或者:人类手工改掉了某个 bug,让 Agent 继续做后续任务,它读到旧的上下文摘要后,把 bug“修复回去”。

我们把它称为“思维惯性”,下面拆开来看,它其实是多种机制叠加的结果。

1. 自回归解码与上下文自洽(self-consistency)

Transformer 类语言模型是自回归的:每生成一个 token,都以“此前全部上下文”为条件,P(下一个 token | 此前全部 token)。这意味着模型的输出天然倾向于与前文保持一致。当上下文里已经存在大量“错误写法”的痕迹(它自己先前生成的代码、你贴进来的旧代码、报错日志里带着的旧调用),模型会把这些当作“这个项目的风格与事实”来延续。一句“不要用 Date”只是上下文里的一小段文字,而十几处 Date 是大量的、结构化的“样例”。样例的说服力通常远大于指令。

2. 上下文锚定与上下文污染(in-context anchoring / context poisoning)

对话里出现过的错误答案,即使后来被否定,依然留在上下文里,成为一个强“锚点”。这在做上下文工程时被称为上下文污染:错误信息、失败的尝试、过期的假设,会持续影响后续生成。更麻烦的是,“被纠正的错误”在上下文里其实出现了至少两次:一次是错误本身,一次是你的纠正语句里对它的复述。模型并不会像人一样把它标记为“作废”,注意力机制只是看到“这个 token 序列与当前任务很相关”。

3. 注意力稀释与“中间迷失”(lost-in-the-middle)

上下文越长,注意力权重要在越多的 token 之间分配(softmax 归一化意味着总量固定),每条信息能分到的“份额”就变小,这通常称为注意力稀释。同时,学界观察到不少模型在长上下文里存在“lost in the middle”现象:位于开头和结尾的信息更容易被利用,位于中间的信息更容易被忽略。你在第 3 轮给出的纠正,几轮之后就恰好落在“中间”,而且被大量后续内容淹没。需要强调,这种效应的强弱与具体模型和上下文长度有关,新一代模型在长上下文上有明显改进,但不宜假设它已被彻底解决。

4. 近因偏差(recency bias)

与上一条相关:许多模型对靠近生成位置的内容更敏感。如果最近几轮里,你或工具输出里贴入了带有旧写法的代码(比如 git diff 的旧版本、报错堆栈),它们的“近因优势”会超过很早之前的一句口头纠正。

5. 训练先验与上下文指令的冲突

模型参数里编码了海量的“训练先验”:Java 里这样写日期最常见、这样写分页最常见。你的指令是“上下文内”的临时信息,两者冲突时,谁占优取决于指令的显著程度、模型的指令遵循能力、以及先验的强度。越是“网上最常见”的写法,先验越强,越容易在你不注意的时候回归。这也是为什么一些“违反主流习惯”的项目约定,特别容易被反复破坏。

6. 谄媚与固执并存(sycophancy vs stubbornness)

偏好训练容易带来谄媚:你一指出错误,模型立刻“您说得对,非常抱歉”,并给出修改,看上去很听话。但“表面认错”不等于“内部状态更新”,它只是生成了一段符合“被纠正时应有反应”的文本。而在另一些情况下,它又会固执:坚持自己的方案,用大段解释论证“其实我原来的写法也没问题”,因为上下文自洽压力让它倾向于维护已有输出。谄媚与固执看似矛盾,其实是同一机制的两面:都是在追求“看起来合理的下一段文本”,而不是在维护一个可验证的事实状态。

7. 无持久记忆:跨会话是完全无状态的(statelessness)

模型本身的权重在推理时不会改变。你在会话 A 里的所有纠正,对会话 B 来说完全不存在,除非有某种机制把它们再次写入上下文(规则文件、记忆功能、检索)。所谓“记忆功能”,本质上也是把文本注入上下文,而不是模型学会了。很多人误以为“我教过它了”,实际上教的只是那一段对话。

8. 压缩与摘要丢失纠正(summarization / compaction)

长任务里,Agent 工具通常会在上下文快满时把历史压缩成摘要。摘要由模型生成,倾向于保留“做了什么、结论是什么”,而容易丢掉“你曾经纠正过什么、为什么”这类细节性约束。更糟的是,摘要可能把“曾经出现过的错误方案”当成“已完成工作”记录下来。压缩之后,你的纠正等于被“有损压缩”掉了,惯性错误随即复发。

9. RLHF 习得的习惯

偏好训练会让模型形成一些稳定的“套路”:喜欢加注释、喜欢加异常处理、喜欢给出“更完整”的实现、喜欢在答案里加一大段解释。这些是分布层面的偏好,不是某段对话能轻易压制的。这也是为什么“不要写多余的注释”“不要加兜底”这类指令,需要反复强调才能维持。

用一张图理解“惯性”

会话早期                            会话后期
┌────────────────────┐             ┌──────────────────────────────┐
│ 用户: 别用 Date     │             │ ...大量新代码、日志、diff...   │
│ 模型: 好的,改用     │  ──────▶    │ 其中含有旧写法 Date (近因强)   │
│ LocalDateTime      │  注意力稀释  │ 早期纠正被稀释、位于“中间”     │
└────────────────────┘             │ → 新生成代码回归 Date         │
                                   └──────────────────────────────┘
        新会话 = 上下文清零 → 纠正完全不存在(无状态)

我们的缓解办法

理解了机制,对策其实很直接:别指望模型“记住”,而是让约束在每一次生成时都“出现在上下文里”,并且尽量由机器而不是人来执行。

  1. 把纠正沉淀为项目规则文件:AGENTS.md、CLAUDE.md、.cursor/rules 之类(不同工具名称不同)。每次人工纠正过一个重复出现的错误,就往里加一条。这是把“会话内的纠正”转化为“每个会话都会自动注入的先验”。

  2. 短会话,任务切小:一个会话只做一件事,做完就开新会话。上下文越短,稀释与污染越少。不要在一个已经充满失败尝试的会话里继续“修修补补”,直接新开,并把正确的结论带过去。

  3. 关键约束重新注入(re-injection):长任务中,在每个阶段的提示词里重申最重要的三五条约束,利用近因效应;不要只在开头说一次。

  4. 约束要具体、可判定:不写“注意代码质量”,而写“所有时间字段使用 java.time.LocalDateTime,禁止 java.util.Date”。越具体,越容易被遵循,也越容易被机器检查。

  5. 让测试与静态检查成为“可执行的记忆”:能写成 ArchUnit 规则、Checkstyle 规则、lint、单元测试的约束,就不要只写在文档里。模型改坏了,CI 会红,Agent 能拿到确定性的反馈,不依赖谁记不记得。

  6. 摘要后要核对:Agent 压缩上下文后,检查关键约束是否还在;必要时手动把它们重新贴进去。

  7. 审查 diff,而不是审查“看起来对的结果”:人类手动修改过的地方,用 diff 盯着,防止后续被 Agent“顺手改回去”。提交小而频繁的 commit,让人工修改成为不可被覆盖的基线。

  8. 把决策写进代码附近:在容易被“修正”的地方加一行说明其原因(例如“这里故意不用 X,因为 Y”),既帮助后来的人,也给模型提供了局部的、近距离的上下文。

一个规则文件的最小示例:

# 项目约定(Agent 必读)

## 硬性约束
- 时间类型统一使用 java.time.LocalDateTime / Instant,禁止 java.util.Date。
- 数据库访问只能通过 Mapper,禁止在循环内查询(避免 N+1)。
- 新增表必须有主键、create_time、update_time;字符串列必须显式给出合理长度。

## 曾经犯过的错(不要复发)
- 不要把 DTO 字段同时写成 snake_case 和 camelCase 两份。
- 不要吞异常:catch 后必须记录日志(带业务ID)或向上抛出。

## 交付前自检
- 运行 ./  gradlew check,并列出本次修改中新增的依赖与配置项。

三、数据库的糟糕设计:能跑,但不能“活”

现象

我们见过的 AI 生成的建表语句,常见问题大致有这些:

  • 键过长:主键或唯一键用 VARCHAR(255)、甚至 VARCHAR(512),或者用几个长字符串列组成联合唯一索引;

  • 滥用 TEXT / LONGTEXT:状态、类型、姓名、 URL、甚至枚举值都用 LONGTEXT,或者把整个 JSON 塞在一个大字段里,再把 ID 列表用逗号拼在一个字段中;

  • 唯一性靠应用代码:先 SELECT 判断是否存在,再 INSERT,而不建唯一索引;或者反过来,用错误的唯一约束(比如在软删除表上给 email 加唯一索引,导致删除后无法重新注册);

  • 表的数量与宽度失衡:要么把所有东西塞进一张几十列的“大宽表”,要么把很小的概念拆成十几张表,查询要 JOIN 一圈;

  • 缺少日志/审计表与关联表:多对多关系用逗号分隔字符串表达,状态流转没有流水表,事后无法追溯是谁在什么时候改了什么。

为什么会这样:训练数据与目标的双重偏斜

1. 训练语料偏向教程与 CRUD Demo。 网上大量的建表示例都是“讲解 SQL 语法”用的:name VARCHAR(255)、content TEXT,简洁、通用、没有任何量级假设。模型学到的是“建表长这样”,而不是“建表要先想清楚数据量和访问模式”。

2. “让代码跑起来”的局部最优。 对模型来说,LONGTEXT 是一种“永远不会因长度报错”的安全选择;VARCHAR(255) 是“看起来合理”的默认值。它们在功能上不会引发即时错误,所以在“跑通”这一目标下是局部最优,代价(索引膨胀、内存表转磁盘临时表、页分裂)只在数据增长后才显现。

3. 缺乏工作负载、基数与数据量知识。 好的 schema 设计取决于:这张表未来有多少行?读多写多?哪些查询是热点?字段的取值基数是多少?这些信息几乎从不出现在提示词里,模型只能按“平均情况”猜。

4. 看不到生产的查询模式。 索引是为查询设计的。没有慢查询日志、没有 EXPLAIN、没有真实的 SQL 分布,模型无法知道该建联合索引 (user_id, status, create_time) 还是别的。它常做的是“每个查询条件各建一个索引”或者“完全不建”。

关键技术点

索引键长度与 InnoDB 页结构。 InnoDB 的数据以 B+Tree 组织,默认页大小是 16KB。索引键越长,一个页能放的条目越少,树越高、越“胖”,缓存命中率越低。索引键长度也有上限:在使用 DYNAMIC 行格式(MySQL 5.7 之后的默认)时,单个索引键最大 3072 字节;在较老的 COMPACT/REDUNDANT 格式下只有 767 字节(具体请以你使用的版本与配置为准)。utf8mb4 每个字符最多占 4 字节,所以 VARCHAR(255) 作为索引列,最坏需要 1020 字节;联合起来很容易逼近上限,或者带来不必要的浪费。

聚簇索引与二级索引。 InnoDB 的主键就是聚簇索引,所有二级索引的叶子节点都会存一份主键值。如果主键是一个 VARCHAR(64) 的字符串(尤其是随机的 UUID),那么每一个二级索引都会因此变大;随机的主键插入位置还会导致页分裂与碎片。这就是“键过长”问题的放大效应。自增的 BIGINT 或有序的分布式 ID(如 雪花算法一类)通常是更稳妥的选择。

行大小与 TEXT/BLOB。 MySQL 对一行(不含 BLOB/TEXT 溢出部分)有 65535 字节的总限制,InnoDB 还要求一行在页内的部分大约不超过半页。TEXT/BLOB 类型在 DYNAMIC 行格式下通常只在行内保留一个指针,内容存在溢出页里,每次读取都可能额外的随机 I/O;不能直接完整索引(需要前缀长度);在排序、分组的临时表中,含 TEXT/BLOB 的查询往往更容易落到磁盘临时表。SELECT * 遇到大字段,代价更高。大字段不是不能用,而是应该与热点数据分表存放,并且明确它的确需要。

覆盖索引。 如果一个查询所需的列全部包含在某个二级索引里,就无需回表,性能提升明显。而设计糟糕的宽表、大字段,让 覆盖索引几乎不可能。

唯一性与并发:竞态条件 vs 唯一约束。 “先查后插”在并发下必然出现竞态:两个请求同时查到“不存在”,然后都插入成功。应用层加锁(synchronized、本地锁)在多实例部署下同样无效,分布式锁又引入了新的复杂度。能用数据库唯一索引保证的,就应该用唯一索引保证,应用层只负责捕获冲突(duplicate key)并转成业务语义。

软删除的陷阱。 用 is_deleted 标记软删除后,唯一索引就会与“已删除的历史数据”冲突。常见的做法是把删除标记纳入唯一键,例如 (email, deleted_at),其中未删除的记录 deleted_at 使用固定默认值(因为 MySQL 的唯一索引允许出现多个 NULL,所以不能用 NULL 表示“未删除”)。软删除还会让所有查询都得带上 is_deleted = 0,稍有遗漏就是数据泄漏;何时清理历史数据也是需要提前设计的问题。

范式与反范式的取舍。 范式化减少冗余、保证一致;反范式化用冗余换查询性能。合理的做法是默认从第三范式起步,只在有明确的查询性能证据时,才有目的地做冗余。AI 的两种极端(大宽表 or 过度拆分)都是因为缺少“证据”这个输入。

迁移成本。 表结构一旦上线,修改代价随数据量快速上升:大表加索引、改列类型、拆表都需要在线变更工具、灰度与回滚方案,还要协调所有读写方。这正是“schema 是最贵的决策之一”的原因,也是我们坚持schema 必须由人评审的理由。

坏的 vs 好的:一个订单表的例子(MySQL 8.x)

先看一个典型的“AI 第一版”:

-- ❌ 坏例子:能跑,但每一行都是隐患
CREATE TABLE t_order (
    id            VARCHAR(255) PRIMARY KEY,       -- 长字符串主键,所有二级索引都被拖胖
    order_no      VARCHAR(255),                   -- 没有唯一索引,靠应用先查后插
    user_name     LONGTEXT,                       -- 姓名用 LONGTEXT
    status        VARCHAR(255),                   -- 状态用长字符串
    product_ids   LONGTEXT,                       -- '1001,1002,1003' 逗号拼接,无法 JOIN 与约束
    extra         LONGTEXT,                       -- 整个 JSON 塞进来,什么都往里放
    is_deleted    TINYINT DEFAULT 0,
    created       VARCHAR(255)                    -- 时间用字符串
);
-- 没有状态流转记录,没有外键关联的明细表

再看我们评审之后期望的形态:

-- ✅ 订单主表:窄、稳、热数据
CREATE TABLE `order_main` (
    `id`           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键,自增或有序分布式ID',
    `order_no`     CHAR(20)        NOT NULL COMMENT '业务单号,定长',
    `user_id`      BIGINT UNSIGNED NOT NULL COMMENT '用户ID,不存冗余姓名',
    `status`       TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 9已取消',
    `total_amount` DECIMAL(12,2)   NOT NULL COMMENT '金额,禁止用浮点',
    `deleted_at`   BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除时间戳,0表示未删除',
    `created_at`   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updated_at`   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_order_no` (`order_no`, `deleted_at`),     -- 唯一性交给数据库
    KEY `idx_user_status_created` (`user_id`, `status`, `created_at`)  -- 服务于“我的订单列表”
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

-- ✅ 关联(明细)表:多对多/一对多用行表达,而不是逗号字符串
CREATE TABLE `order_item` (
    `id`         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `order_id`   BIGINT UNSIGNED NOT NULL,
    `sku_id`     BIGINT UNSIGNED NOT NULL,
    `quantity`   INT UNSIGNED    NOT NULL,
    `unit_price` DECIMAL(12,2)   NOT NULL COMMENT '下单时价格快照',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_order_sku` (`order_id`, `sku_id`),        -- 同一订单同一商品只有一行
    KEY `idx_sku` (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细';

-- ✅ 状态流转/审计表:只追加,不修改
CREATE TABLE `order_status_log` (
    `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `order_id`    BIGINT UNSIGNED NOT NULL,
    `from_status` TINYINT UNSIGNED NOT NULL,
    `to_status`   TINYINT UNSIGNED NOT NULL,
    `operator_id` BIGINT UNSIGNED NOT NULL COMMENT '操作人,系统操作用0',
    `trace_id`    CHAR(32)        NOT NULL DEFAULT '' COMMENT '与日志系统关联',
    `reason`      VARCHAR(255)    NOT NULL DEFAULT '',
    `created_at`  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (`id`),
    KEY `idx_order_created` (`order_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态流转日志';

-- ✅ 低频大字段单独存放,避免拖累主表
CREATE TABLE `order_ext` (
    `order_id` BIGINT UNSIGNED NOT NULL,
    `remark`   TEXT NULL,
    `snapshot` JSON NULL COMMENT '收货信息快照等低频读取的数据',
    PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单扩展';

应用层配合唯一索引,只需要把冲突转成业务语义:

try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    // 唯一约束是并发下的最终裁判;这里只做幂等处理,不再“先查后插”
    log.warn("duplicate order, orderNo={}", order.getOrderNo());
    return orderMapper.selectByOrderNo(order.getOrderNo());
}

需要说明:上面只是示意。外键是否使用、订单号长度、金额精度、分库分表策略,都取决于你自己的业务与团队规范,不要把它当作模板照搬。我们想说明的是评审的角度。

数据库设计评审清单

  • ☐ 每张表都有主键;主键短、有序、不含业务含义(或有充分理由)

  • ☐ 每个索引列的长度合理,联合索引的顺序与真实查询匹配,没有“每列一个索引”

  • ☐ 没有为了“省事”而使用 TEXT/LONGTEXT;大字段是否拆到扩展表

  • ☐ 状态、类型等枚举使用小整型或短定长字段,含义写在注释或字典里

  • ☐ 业务唯一性由唯一索引保证;应用层处理 duplicate key,而不是先查后插

  • ☐ 软删除与唯一索引的关系已想清楚

  • ☐ 一对多、多对多使用明细/关联表,没有逗号拼接的 ID 列表

  • ☐ 关键业务对象有状态流转/审计表,含操作人与 trace_id

  • ☐ 金额、时间的类型正确(DECIMAL、DATETIME 或整型时间戳,不用字符串、浮点)

  • ☐ 预估了 1 年、3 年后的数据量,以及归档/清理方案

  • ☐ 变更如何上线(在线 DDL、回滚)已有方案

四、过度设计:在细节里“多此一举”

现象

和“设计不足”相反,AI 还有一种同样普遍的毛病:在小处写得过多。我们反复看到:

  • 一个实体里同时存在 user_name 和 userName,其中只有一个是真正被数据库和接口使用的;

  • Entity、DTO、VO、BO、Query、Param 一套复制,字段完全一样,只是换了类名,中间还有一堆 BeanUtils.copyProperties 或手写的转换器;

  • 每个方法开头都是 if (x == null) { return null; },哪怕这个参数在调用链上根本不可能为空;

  • 出现莫名其妙的“fallback”分支:主路径失败后静默走另一条路径,掩盖了真实错误;

  • 引入从没被读取的配置开关 enableNewLogic、compatMode,以及为“可能存在的旧版本”写的兼容 shim。

一个 Java 例子:两套命名并存

// ❌ 过度设计:同一份数据两套字段,以及一个“兜底”的 getter
public class UserDTO {
    private String user_name;   // 数据库/JSON 下划线风格
    private String userName;    // Java 驼峰风格
    // ... 两套 getter/setter

    public String getDisplayName() {
        if (userName != null && !userName.isEmpty()) {
            return userName;
        }
        if (user_name != null && !user_name.isEmpty()) {
            return user_name;
        }
        return "";   // 兜底:调用方永远不知道数据其实缺失了
    }
}

// Mapper 里两边都赋值,“以防万一”
dto.setUserName(entity.getUserName());
dto.setUser_name(entity.getUserName());

后来的维护者面对这段代码,会陷入一堆无法回答的问题:哪个字段才是真的?前端读的是哪个?改名的时候要不要两个都改?空字符串是合法值还是缺失?

// ✅ 只保留一个真实字段;外部协议命名用注解明确声明,转换由框架统一负责
public class UserDTO {
    @JsonProperty("user_name")   // 对外 JSON 使用下划线,Java 内部统一驼峰
    private String userName;
    // getter/setter 由 Lombok 或 record 生成
}

如果确实有“旧客户端仍在发 userName”这样的兼容需求,正确的做法是显式、有期限:

public record UserDTO(
    @JsonProperty("user_name")
    @JsonAlias("userName")   // TODO(2026-12-31): 旧客户端全部升级后删除
    String userName
) {}

为什么会这样

1. 规避歧义的对冲行为(hedging)。 当需求不明确时,模型倾向于“都支持一下”,这样无论你实际想要哪种,都不算错。人类会去问“到底是哪个”,而模型的默认路径是生成一个“覆盖所有可能性”的答案。

2. 为“看起来完整”而优化。 评审者(人或评分模型)往往觉得:有判空、有兜底、有兼容、有分层的代码“更专业、更健壮”。偏好训练会强化这种印象,于是“看起来周全”被奖励,“克制”很难被奖励,因为克制的收益(未来维护成本更低)在当次回答里无法体现。

3. 没有对未来维护成本的所有权。 多写的每一行代码,都是人类维护者未来要阅读、理解、测试、修改的成本;而模型不承担这个成本,也没有“两年后我还得维护它”的激励。成本外部化,是过度设计的经济学根源。

4. 对冗长的隐性奖励。 许多训练与评测场景里,更长、更详细的回答更容易获得好评。这种“长度偏好”会迁移到代码里,表现为多余的抽象、注释和分支。

5. 缺少全局视图,不知道什么是“死代码”。 模型不知道这个字段前端到底读没读、这个配置是不是线上有人在用,所以它不敢删,也不敢少写。没有调用关系与运行数据,就无从判断“不需要”。

6. 缺少运行时反馈。 兜底分支从来不会在测试里报错,恰恰相反,它让测试更容易通过。没有人告诉模型“这个分支线上从来没走过”。

7. 违反 YAGNI / KISS。 “你不会需要它”(You Aren't Gonna Need It)与“保持简单”是老原则,模型并不缺这些知识,但在“不确定就多写点”的倾向下,它们经常败给对冲行为。

对后来维护者的危害

  • 认知负荷(cognitive load)升高:阅读者要同时记住两套命名、四层对象、若干开关的含义;

  • 霰弹式修改(shotgun surgery):加一个字段要改 Entity、DTO、VO、Mapper、转换器、文档六个地方,漏一处就是 bug;

  • 静默失败:兜底分支把本该暴露的错误吞掉,问题被推迟到更难排查的位置;

  • 测试面积膨胀:每个分支理论上都需要覆盖,否则就是未测试的代码。

缓解办法

  1. 写进规则的命名约定:明确“数据库列 snake_case,Java 属性 camelCase,JSON 对外协议 snake_case(或 camelCase)”,且只能通过映射框架转换,禁止手写两份字段。

  2. 用工具兜底:Checkstyle / PMD / SpotBugs / Error Prone、SonarQube 的重复代码与未使用代码规则,IDE 的“unused declaration”检查,以及 ArchUnit 约束分层(比如“不允许出现 xxxVO”)。

  3. 死代码检测:静态分析加上运行时数据,例如覆盖率报告、Feature Flag 的使用统计、访问日志;对于一个月内没有被走过的分支,认真考虑删除。

  4. 提示词里明确“克制”:例如“不要添加未被需求提到的字段、配置项与兼容逻辑;参数的非空性由调用方与校验注解保证,不要重复判空;如果需要 fallback,先问我。”

  5. 代码审查的提问模板:“这个字段谁在读?”“这个分支什么情况下会触发?”“如果删掉它,哪个测试会失败?”回答不上来的,就删。

  6. 删除优先(delete-first)的心态:把 AI 给出的每一个 PR 当成“候选集”,审查的任务是做减法。我们甚至会专门让 AI 做一轮“只删不加”的重构,并要求它对每处删除给出依据。

  7. 让约束在边界,不在内部:在系统边界(Controller 入参、外部接口返回)做一次严格校验,内部代码信任已校验的数据,而不是处处防御。

五、日志追溯难:出事的时候,找不到线索

现象

这类问题往往在线上出事时才暴露:

  • 调用链很长(网关 → 订单 → 库存 → 支付 → MQ → 通知),偏偏没有一个能串起全链路的 trace id / correlation id;

  • 日志里只有 "处理失败",没有订单号、用户 ID 等关键业务标识;

  • catch (Exception e) {},或者只 e.printStackTrace(),或者只记录 e.getMessage() 而丢掉堆栈与原因链;

  • 日志级别用错:正常流程用 ERROR,真正的故障用 INFO;或者为了“调试”到处 System.out.println;

  • 日志是随手拼接的字符串,无法被检索、聚合;

  • 在异步任务、MQ 消费、跨服务调用处,没有入口/出口/分支日志,线程切换后上下文(如 MDC)丢失。

为什么会这样

1. 局部视图生成。 模型每次只在一个方法、一个类里工作,写出来的日志也只对这个方法“有意义”。日志的价值在于跨越边界的串联,而边界恰恰是局部视图看不到的东西。

2. 训练样本里缺少可观测性思维。 教程和示例代码里,日志要么没有,要么就是一句 log.info("start")。真正的生产日志规范(字段约定、采样、脱敏、链路传播)存在于公司内部文档与运维经验里,公开语料中较少。

3. 快乐路径偏差(happy-path bias)。 训练样本多是“正常情况”的演示,异常分支往往被省略或写得潦草。于是模型最擅长写“成功怎样”,对“失败时需要留下什么线索”并不敏感。

4. 把日志当作噪音。 在“简洁、干净”的偏好下,日志常被视为“影响可读性的杂项”,尤其在被要求“精简代码”时,它们是第一批被删掉的东西。

5. 不了解运行时拓扑。 模型不知道你的系统有几个服务、用的是 Kafka 还是 RocketMQ、有没有网关注入 trace id、日志被谁采集。没有拓扑,就不知道哪里是边界,也就不知道该在哪里打点。

6. 缺失 MDC 等基础设施的意识。 线程池、CompletableFuture、响应式调用、MQ 消费,都会导致线程局部变量(ThreadLocal,MDC 的底层实现)无法自动传递。这是需要“知道系统怎么运行”才会想到的问题。

一个 Java 例子:入口注入 trace id,跨线程与 MQ 传播

// 1) Web 入口:接收或生成 traceId,放进 MDC,请求结束清理
@Component
public class TraceFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                                 .filter(s -> !s.isBlank())
                                 .orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));
        MDC.put("traceId", traceId);
        resp.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(req, resp);
        } finally {
            MDC.clear();   // 线程会被复用,必须清理,否则会串号
        }
    }
}
// 2) 关键业务节点:结构化字段 + 业务标识 + 保留异常原因链
public void pay(String orderNo, long userId) {
    log.info("pay.start orderNo={} userId={}", orderNo, userId);
    try {
        PayResult r = payClient.pay(orderNo);
        log.info("pay.done orderNo={} channelTxnId={} cost={}ms", orderNo, r.txnId(), r.costMs());
    } catch (PayTimeoutException e) {
        // 可预期的业务异常:warn,并明确后续动作(重试/补偿)
        log.warn("pay.timeout orderNo={} willRetry=true", orderNo, e);
        throw e;
    } catch (Exception e) {
        // 不可预期:error,带堆栈;不吞、不改写成“处理失败”
        log.error("pay.error orderNo={} userId={}", orderNo, userId, e);
        throw new BizException("PAY_FAILED", e);
    }
}
// 3) 线程池 / MQ:跨线程需要显式传递上下文
Map<String, String> ctx = MDC.getCopyOfContextMap();
executor.submit(() -> {
    if (ctx != null) MDC.setContextMap(ctx);
    try {
        doWork();
    } finally {
        MDC.clear();
    }
});

// MQ 发送:把 traceId 放进消息头;消费者取出再放入 MDC
Message<OrderEvent> msg = MessageBuilder.withPayload(event)
        .setHeader("traceId", MDC.get("traceId"))
        .setHeader("idempotencyKey", event.getOrderNo() + ":" + event.getType())
        .build();

如果采用 OpenTelemetry,思路是一样的,只是传播与关联交给标准来做:

// OpenTelemetry 风格:在关键节点创建 Span,并附上业务属性
Span span = tracer.spanBuilder("order.pay").startSpan();
try (Scope scope = span.makeCurrent()) {
    span.setAttribute("order.no", orderNo);
    span.setAttribute("user.id", userId);
    doPay();
} catch (Exception e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR);
    throw e;
} finally {
    span.end();
}
// 使用 OpenTelemetry 的 Java Agent 与日志框架集成时,通常可以自动把 trace_id / span_id
// 写入 MDC,使日志与链路互相跳转(具体字段名与配置以你使用的版本文档为准)。

日志输出建议使用 JSON 等结构化格式,例如:

{"ts":"2026-09-30T15:20:11.532+08:00","level":"ERROR","service":"order-service",
 "traceId":"4bf92f3577b34da6a3ce929d0e0e4736","event":"pay.error",
 "orderNo":"O2026093000001","userId":10086,"msg":"channel timeout","exception":"..."}

可追溯性检查清单

  • ☐ 边界必打点:HTTP/ RPC 入口与出口、MQ 生产与消费、定时任务、异步线程的入口与出口,都有日志

  • ☐ trace id / correlation id 全链路传播:网关生成或透传,服务间通过 Header / 消息头传递,线程切换时显式携带

  • ☐ 关键业务标识入日志:订单号、用户 ID、租户 ID、外部流水号,而不是“处理失败”

  • ☐ 幂等键与重试次数:消息消费、回调、补偿任务,记录 idempotency key 与 retry 次数,便于判断是否重复

  • ☐ 分支可见:重要的分支决策(走了哪个渠道、命中了哪条规则、为什么降级)留一条日志

  • ☐ 异常不吞:要么向上抛,要么记录日志(带堆栈、带原因链、带业务标识),二者至少居其一,避免同一异常层层重复打印

  • ☐ 级别恰当:ERROR 代表需要人介入;WARN 代表可恢复的异常;INFO 记录关键业务事件;DEBUG 用于排查,默认关闭

  • ☐ 结构化字段:统一字段名(traceId、orderNo、event、costMs),便于检索与聚合

  • ☐ 采样与限流:高频路径的日志需要采样或限流,避免日志风暴,但错误日志不应被采样丢弃

  • ☐ 敏感信息脱敏:手机号、证件号、密钥、令牌不得明文进入日志

  • ☐ 指标与链路配合:日志之外配合指标(耗时、错误率)与分布式追踪,不要把所有责任压在日志上

六、四个问题放在一起看:对照表

问题 典型症状 技术成因 缓解手段
思维惯性 已纠正的错误反复出现;跨会话重蹈覆辙;压缩后约束丢失 自回归上下文自洽、上下文污染、注意力稀释与中间迷失、近因偏差、训练先验冲突、无状态、摘要有损 规则文件沉淀、短会话、关键约束重注入、用测试与 lint 作为可执行记忆、审查 diff
数据库设计差 超长键、TEXT 滥用、先查后插、逗号拼接 ID、缺审计表 教程与 CRUD 数据偏斜、“能跑”的局部最优、缺乏数据量与查询模式信息、看不到生产负载 人工评审 schema、提供数据量与查询样例、EXPLAIN 验证、唯一索引兜底、评审清单
过度设计 双套命名、层层 DTO、到处判空与兜底、无用开关 规避歧义的对冲、追求“看起来完整”、成本外部化、长度偏好、缺少全局调用关系 命名规则、lint 与 ArchUnit、死代码检测、克制型提示词、删除优先的审查
日志追溯难 无 trace id、无业务标识、吞异常、级别混乱 局部视图、训练样本缺乏可观测性、快乐路径偏差、不了解拓扑、忽视 MDC 传播 在提示词中明确可观测性要求、统一日志规范与工具类、边界打点、结构化日志与链路追踪

七、共同的根因,再说一遍

回头看这张表,四个问题的“成因”列虽然各不相同,但它们背后是同一组结构性原因,值得再强调一次:

  1. 目标错位(objective mismatch):模型被优化成“给出当前看起来最好的答案”,而工程质量是“未来能不能活得好”。演示能跑、测试能过,是它能感知的全部;十八个月后的维护成本,它感知不到。

  2. 局部 vs 全局上下文:窗口内的信息决定了输出。跨文件、跨服务、跨时间的信息(数据量、调用关系、运行拓扑、历史决策),如果不主动喂给它,就等于不存在。

  3. 缺失反馈回路:编译与单测提供的是“语法与局部逻辑”的反馈,而 schema 合理性、代码简洁度、日志可用性、线上性能,没有任何自动反馈信号,只能靠人补上。

  4. 分布偏移(distribution shift):训练分布里的“典型代码”偏向教程与小项目,生产环境属于长尾。越是“需要公司特有知识”的地方,模型越容易给出通用但不合适的答案。

推论很清楚:AI 编程的短板,几乎都在“需要全局信息、长期视角和运行时反馈”的地方。 这也恰恰是人类工程师最不该让渡的部分。

八、怎么和 AI 编程工具协作:我们的实践

以下是我们目前在团队里执行的做法,并不高深,胜在稳定。

1. 规格先行(spec-first)

不要直接说“帮我做一个订单模块”。先用几段话写清楚:目标、非目标、数据量预估、关键查询、边界条件、失败场景、需要的日志与监控。可以让 AI 先产出设计草案,由人评审并定稿后再让它写代码。设计阶段的一次修改,比实现阶段便宜得多。

2. 规则文件与项目上下文

把架构约定、命名规范、常见错误、禁止事项写成规则文件,并持续维护。每次踩坑,就多一条规则。这是把个人经验“资产化”的最便宜方式,也是对抗无状态的主要手段。

3. 小步快跑,频繁提交

每次只让它完成一个小而可验证的改动,跑通、审查、提交,再进入下一步。小步有两个好处:上下文短,惯性与污染小;人工修改可以通过提交固化为基线。

4. 测试与检查先行

先有测试(哪怕由 AI 写、由人审),再有实现。把约束尽可能变成机器可执行的:单元测试、集成测试、lint、类型检查、ArchUnit、schema 迁移检查。AI 擅长“让红灯变绿”,那就先把红灯设计好。

5. 数据库 schema 与迁移必须由人评审

无论 AI 给出的 DDL 看起来多么合理,都要经过人的检查:数据量、索引、唯一性、字段类型、审计与关联表、迁移方案。我们会把 DDL 与预期的 SQL、数据量一起交给 AI 做“评审”,但最终签字的是人。

6. 在提示词里明确可观测性要求

“关键流程需要日志”这句话必须由你来说。可以固定一段提示词模板:

实现时请遵守:
1. 所有入口、出口、MQ 生产与消费处记录日志,包含 traceId 与业务主键(orderNo/userId)
2. 禁止吞异常;catch 后必须记录日志(带堆栈)或抛出
3. 异步/线程池调用需要显式传递 MDC
4. 日志使用统一的事件名与键值对格式,不要拼接长句
5. 不要添加需求之外的字段、配置项与兼容逻辑;需要兜底请先说明理由

7. 审查清单

我们的代码审查里,对 AI 生成的代码额外加一份清单:

  • ☐ 有没有新增未被使用的字段、配置、方法?

  • ☐ 有没有把已经确认过的约定又改回去?(对照规则文件与 diff)

  • ☐ DDL 是否经过评审?有没有超长键、大字段、缺失的唯一约束?

  • ☐ 异常是否被吞?日志是否包含 trace id 与业务标识?

  • ☐ 每个兜底分支是否有明确的存在理由?

  • ☐ 是否引入了不必要的新依赖?

8. 把 AI 当作“很快、很勤奋、没有经验的同事”

这是我们最喜欢的比喻。它写得快、读得多、不会累,但它没见过你们的线上事故,也不知道下游有谁。给这样的同事布置任务,你会:说明背景、给出约束、审查结果、把踩过的坑写进文档。对 AI 也是一样。

九、常见问题(FAQ)

Q1:这些问题会随着模型变强而消失吗?部分会缓解,比如长上下文能力、指令遵循能力、对代码库的检索与理解都在进步。但本文列出的多数问题,根源是信息不在上下文里(数据量、拓扑、历史决策)与反馈信号缺失,这不是单靠模型变大就能解决的。所以我们的判断是:模型会变好,但“人提供全局信息、设置反馈回路”的工作不会消失,只是形式会变。这是我们的经验推断,并非定论。

Q2:规则文件写得越多越好吗?不是。规则文件本身也占用上下文,写得太长同样会被稀释。建议保持简短、具体、可判定,并定期清理过时的条目;能用 lint 与测试强制的,就不要只靠文字。

Q3:既然要人评审 schema,那 AI 帮我建表还有意义吗?有。AI 很适合产出第一版草稿、补充注释、生成迁移脚本与测试数据。关键是提供足够的输入(数据量、查询、约束),并且把它的输出视为“待评审的草案”,而不是最终方案。

Q4:日志这么重要,为什么不在框架层统一解决?应该在框架层解决一部分:统一的 Filter/拦截器、线程池装饰器、MQ 拦截器、OpenTelemetry Agent,都可以自动完成 trace id 的注入与传播。业务标识与关键分支日志则仍需要业务代码来写。最好的方式是基础设施自动化,加上提示词与审查兜住业务打点。

Q5:过度设计和防御性编程的界线在哪里?看“边界”。在系统边界(外部输入、第三方响应)做严格校验与防御是必要的;在内部已经校验过的数据流上重复判空、加兜底,就是过度。另一个判断标准是:这个防御分支,能不能用一个明确的场景与测试来说明它存在的理由?说不出来就是多余的。

Q6:思维惯性能通过“换个模型”避免吗?不同模型的表现确实有差异,但机制层面的原因(上下文自洽、无状态、注意力分配)在类似架构上普遍存在。换模型可能减轻,很难根除。所以我们更看重流程层面的对策,而不是押注某个模型。

Q7:小团队没精力做这么多治理,最低限度做什么?我们建议三件事:一个简短的规则文件,加上每次纠正后更新;schema 变更必须人工评审;关键链路统一的 trace id 与业务标识日志。这三项成本很低,收益最大。

十、小结

  • AI 编程的翻车不是随机的,而是可预测的模式:思维惯性、数据库设计糟糕、细节过度设计、日志难追溯。

  • 背后共同的原因是:目标错位、局部视野、缺失反馈回路、分布偏移。模型擅长“让眼前的东西跑起来”,不擅长“为未来的维护者与运维者负责”。

  • 对策的核心是把全局信息、长期约束和运行时反馈主动补给它:规则文件、短会话、规格先行、测试与 lint 作为可执行的记忆、schema 人工评审、克制型提示词、可观测性要求写进提示词。

  • 把 AI 当成“快而勤奋、但缺乏经验的同事”:它放大你的产出,也会放大你没有说清楚的部分。人不该退出的,恰恰是需要全局判断与长期责任的那些环节。

如果你只想从这篇文章里带走一件事,那就是:别指望它自己“懂”,把你踩过的坑变成规则、测试和检查清单,让每一次生成都带着这些约束。

评论区
评论
{{comment.creator}}
{{comment.createTime}} {{comment.index}}楼
评论

AI 编程为什么总在同样的地方翻车:思维惯性、数据库设计、过度设计与日志追溯

AI 编程为什么总在同样的地方翻车:思维惯性、数据库设计、过度设计与日志追溯

这两年,我们团队把 AI 编程工具(Copilot 类的补全、IDE 里的对话式助手、能自己读写文件并跑命令的 Agent)真正用进了日常开发。坦白说,收益是实打实的:样板代码、单元测试、脚本、接口联调、陌生框架的第一版实现,速度都明显快了。但用得越久,我们越发现一个现象:AI 写出来的代码,翻车的地方惊人地相似。不是随机的 bug,而是一套“可预测的坏味道”,换个项目、换个模型,过一段时间又会撞上同一堵墙。

我们把最常遇到的四类问题整理出来:

  1. 在对话里明明纠正过的错误,几轮之后又被模型“请”了回来(思维惯性);

  2. 数据库设计糟糕:超长的键、滥用 LONGTEXT、想用应用代码保证唯一性、表设计得过多或过少、缺少日志表与关联表;

  3. 在细节上过度设计:同一个字段既有 a_b 又有 aB,DTO、VO、Entity 层层复制,到处判空、到处兜底;

  4. 出问题时日志追溯困难:调用链又长又危险,偏偏没有可串起来的日志。

本文不是吐槽,也不想得出“AI 不能用”的结论。我们希望用比较专业的术语,把这些现象背后的机制讲清楚:它们大多不是“模型笨”,而是目标函数、上下文范围和反馈回路共同决定的必然结果。理解了机制,才能有针对性地设计协作方式。文中涉及具体模型行为的判断,都是我们的工程经验与对公开原理的推断,不同模型、不同版本差异很大,请当作“经验假设”而不是定论。

一、先说结论:这四类问题有共同的“根”

在分别展开之前,先给一个统一的视角。四类问题看上去风马牛不相及,一个是对话状态,一个是存储设计,一个是代码风格,一个是运维能力,但追根溯源,几乎都落在下面四个原因上。

1. 目标错位:让测试通过 ≠ 让系统长期可维护

大语言模型的基础训练目标是“预测下一个 token”,后续还会经过指令微调和基于人类反馈的强化学习(RLHF,或类似的偏好优化方法)。这些阶段优化的,本质上是“在给定上下文下,产出看起来正确、让人(或评分器)满意的回答”。而生产系统真正需要的是:十八个月后仍然容易修改、在真实流量下仍然稳定、出了问题能被定位。

前者是一个“局部、即时、可被一眼判断”的目标,后者是“全局、长期、需要运行时才能验证”的目标。当两者冲突时(比如多写一个兜底分支能让 demo 跑通,却让维护成本翻倍),模型没有理由选择后者,因为后者的代价不会体现在它眼前的上下文里。

2. 局部视野:模型只看得见上下文窗口里的东西

模型的“世界”就是当前上下文窗口:你贴的几个文件、它检索到的几个片段、之前的对话。它看不到生产库里有多少行数据、哪些接口是热点、哪个字段前端其实从来不读、下游有哪些服务依赖这个表。决定设计好坏的大量信息,恰恰不在窗口里。

3. 缺失反馈回路:没有运行时,就没有“疼”

人类工程师是被线上事故“教育”出来的:慢查询把库拖垮过一次,就再也不敢随手写 LONGTEXT;半夜排障找不到日志痛苦过一次,就会在每个边界打点。模型没有这样的“疼痛记忆”。在单次会话里,它的反馈大多来自编译器和测试,而编译器和测试恰恰不会检查“表结构合理不合理”“日志能不能串起来”。

4. 分布偏移:训练数据里的“典型代码”≠ 你的生产代码

公开的教程、示例、博客、开源的小项目,在训练语料里占了很大比重。这些代码的共同点是:规模小、场景单一、以“讲清楚一个概念”为目的。而企业系统的代码,是在数据量、并发、合规、历史包袱共同作用下长出来的。让模型“照着典型写”,得到的就是教程级别的设计,用在生产上自然水土不服。

这四点是后文所有分析的底色。下面逐个看具体现象。

二、思维惯性:改过的错误,几轮之后又回来了

现象

这是最让人抓狂的一类。典型场景是这样的:

我们把它称为“思维惯性”,下面拆开来看,它其实是多种机制叠加的结果。

1. 自回归解码与上下文自洽(self-consistency)

Transformer 类语言模型是自回归的:每生成一个 token,都以“此前全部上下文”为条件,P(下一个 token | 此前全部 token)。这意味着模型的输出天然倾向于与前文保持一致。当上下文里已经存在大量“错误写法”的痕迹(它自己先前生成的代码、你贴进来的旧代码、报错日志里带着的旧调用),模型会把这些当作“这个项目的风格与事实”来延续。一句“不要用 Date”只是上下文里的一小段文字,而十几处 Date 是大量的、结构化的“样例”。样例的说服力通常远大于指令。

2. 上下文锚定与上下文污染(in-context anchoring / context poisoning)

对话里出现过的错误答案,即使后来被否定,依然留在上下文里,成为一个强“锚点”。这在做上下文工程时被称为上下文污染:错误信息、失败的尝试、过期的假设,会持续影响后续生成。更麻烦的是,“被纠正的错误”在上下文里其实出现了至少两次:一次是错误本身,一次是你的纠正语句里对它的复述。模型并不会像人一样把它标记为“作废”,注意力机制只是看到“这个 token 序列与当前任务很相关”。

3. 注意力稀释与“中间迷失”(lost-in-the-middle)

上下文越长,注意力权重要在越多的 token 之间分配(softmax 归一化意味着总量固定),每条信息能分到的“份额”就变小,这通常称为注意力稀释。同时,学界观察到不少模型在长上下文里存在“lost in the middle”现象:位于开头和结尾的信息更容易被利用,位于中间的信息更容易被忽略。你在第 3 轮给出的纠正,几轮之后就恰好落在“中间”,而且被大量后续内容淹没。需要强调,这种效应的强弱与具体模型和上下文长度有关,新一代模型在长上下文上有明显改进,但不宜假设它已被彻底解决。

4. 近因偏差(recency bias)

与上一条相关:许多模型对靠近生成位置的内容更敏感。如果最近几轮里,你或工具输出里贴入了带有旧写法的代码(比如 git diff 的旧版本、报错堆栈),它们的“近因优势”会超过很早之前的一句口头纠正。

5. 训练先验与上下文指令的冲突

模型参数里编码了海量的“训练先验”:Java 里这样写日期最常见、这样写分页最常见。你的指令是“上下文内”的临时信息,两者冲突时,谁占优取决于指令的显著程度、模型的指令遵循能力、以及先验的强度。越是“网上最常见”的写法,先验越强,越容易在你不注意的时候回归。这也是为什么一些“违反主流习惯”的项目约定,特别容易被反复破坏。

6. 谄媚与固执并存(sycophancy vs stubbornness)

偏好训练容易带来谄媚:你一指出错误,模型立刻“您说得对,非常抱歉”,并给出修改,看上去很听话。但“表面认错”不等于“内部状态更新”,它只是生成了一段符合“被纠正时应有反应”的文本。而在另一些情况下,它又会固执:坚持自己的方案,用大段解释论证“其实我原来的写法也没问题”,因为上下文自洽压力让它倾向于维护已有输出。谄媚与固执看似矛盾,其实是同一机制的两面:都是在追求“看起来合理的下一段文本”,而不是在维护一个可验证的事实状态。

7. 无持久记忆:跨会话是完全无状态的(statelessness)

模型本身的权重在推理时不会改变。你在会话 A 里的所有纠正,对会话 B 来说完全不存在,除非有某种机制把它们再次写入上下文(规则文件、记忆功能、检索)。所谓“记忆功能”,本质上也是把文本注入上下文,而不是模型学会了。很多人误以为“我教过它了”,实际上教的只是那一段对话。

8. 压缩与摘要丢失纠正(summarization / compaction)

长任务里,Agent 工具通常会在上下文快满时把历史压缩成摘要。摘要由模型生成,倾向于保留“做了什么、结论是什么”,而容易丢掉“你曾经纠正过什么、为什么”这类细节性约束。更糟的是,摘要可能把“曾经出现过的错误方案”当成“已完成工作”记录下来。压缩之后,你的纠正等于被“有损压缩”掉了,惯性错误随即复发。

9. RLHF 习得的习惯

偏好训练会让模型形成一些稳定的“套路”:喜欢加注释、喜欢加异常处理、喜欢给出“更完整”的实现、喜欢在答案里加一大段解释。这些是分布层面的偏好,不是某段对话能轻易压制的。这也是为什么“不要写多余的注释”“不要加兜底”这类指令,需要反复强调才能维持。

用一张图理解“惯性”

会话早期                            会话后期
┌────────────────────┐             ┌──────────────────────────────┐
│ 用户: 别用 Date     │             │ ...大量新代码、日志、diff...   │
│ 模型: 好的,改用     │  ──────▶    │ 其中含有旧写法 Date (近因强)   │
│ LocalDateTime      │  注意力稀释  │ 早期纠正被稀释、位于“中间”     │
└────────────────────┘             │ → 新生成代码回归 Date         │
                                   └──────────────────────────────┘
        新会话 = 上下文清零 → 纠正完全不存在(无状态)

我们的缓解办法

理解了机制,对策其实很直接:别指望模型“记住”,而是让约束在每一次生成时都“出现在上下文里”,并且尽量由机器而不是人来执行。

  1. 把纠正沉淀为项目规则文件:AGENTS.md、CLAUDE.md、.cursor/rules 之类(不同工具名称不同)。每次人工纠正过一个重复出现的错误,就往里加一条。这是把“会话内的纠正”转化为“每个会话都会自动注入的先验”。

  2. 短会话,任务切小:一个会话只做一件事,做完就开新会话。上下文越短,稀释与污染越少。不要在一个已经充满失败尝试的会话里继续“修修补补”,直接新开,并把正确的结论带过去。

  3. 关键约束重新注入(re-injection):长任务中,在每个阶段的提示词里重申最重要的三五条约束,利用近因效应;不要只在开头说一次。

  4. 约束要具体、可判定:不写“注意代码质量”,而写“所有时间字段使用 java.time.LocalDateTime,禁止 java.util.Date”。越具体,越容易被遵循,也越容易被机器检查。

  5. 让测试与静态检查成为“可执行的记忆”:能写成 ArchUnit 规则、Checkstyle 规则、lint、单元测试的约束,就不要只写在文档里。模型改坏了,CI 会红,Agent 能拿到确定性的反馈,不依赖谁记不记得。

  6. 摘要后要核对:Agent 压缩上下文后,检查关键约束是否还在;必要时手动把它们重新贴进去。

  7. 审查 diff,而不是审查“看起来对的结果”:人类手动修改过的地方,用 diff 盯着,防止后续被 Agent“顺手改回去”。提交小而频繁的 commit,让人工修改成为不可被覆盖的基线。

  8. 把决策写进代码附近:在容易被“修正”的地方加一行说明其原因(例如“这里故意不用 X,因为 Y”),既帮助后来的人,也给模型提供了局部的、近距离的上下文。

一个规则文件的最小示例:

# 项目约定(Agent 必读)

## 硬性约束
- 时间类型统一使用 java.time.LocalDateTime / Instant,禁止 java.util.Date。
- 数据库访问只能通过 Mapper,禁止在循环内查询(避免 N+1)。
- 新增表必须有主键、create_time、update_time;字符串列必须显式给出合理长度。

## 曾经犯过的错(不要复发)
- 不要把 DTO 字段同时写成 snake_case 和 camelCase 两份。
- 不要吞异常:catch 后必须记录日志(带业务ID)或向上抛出。

## 交付前自检
- 运行 ./  gradlew check,并列出本次修改中新增的依赖与配置项。

三、数据库的糟糕设计:能跑,但不能“活”

现象

我们见过的 AI 生成的建表语句,常见问题大致有这些:

为什么会这样:训练数据与目标的双重偏斜

1. 训练语料偏向教程与 CRUD Demo。 网上大量的建表示例都是“讲解 SQL 语法”用的:name VARCHAR(255)、content TEXT,简洁、通用、没有任何量级假设。模型学到的是“建表长这样”,而不是“建表要先想清楚数据量和访问模式”。

2. “让代码跑起来”的局部最优。 对模型来说,LONGTEXT 是一种“永远不会因长度报错”的安全选择;VARCHAR(255) 是“看起来合理”的默认值。它们在功能上不会引发即时错误,所以在“跑通”这一目标下是局部最优,代价(索引膨胀、内存表转磁盘临时表、页分裂)只在数据增长后才显现。

3. 缺乏工作负载、基数与数据量知识。 好的 schema 设计取决于:这张表未来有多少行?读多写多?哪些查询是热点?字段的取值基数是多少?这些信息几乎从不出现在提示词里,模型只能按“平均情况”猜。

4. 看不到生产的查询模式。 索引是为查询设计的。没有慢查询日志、没有 EXPLAIN、没有真实的 SQL 分布,模型无法知道该建联合索引 (user_id, status, create_time) 还是别的。它常做的是“每个查询条件各建一个索引”或者“完全不建”。

关键技术点

索引键长度与 InnoDB 页结构。 InnoDB 的数据以 B+Tree 组织,默认页大小是 16KB。索引键越长,一个页能放的条目越少,树越高、越“胖”,缓存命中率越低。索引键长度也有上限:在使用 DYNAMIC 行格式(MySQL 5.7 之后的默认)时,单个索引键最大 3072 字节;在较老的 COMPACT/REDUNDANT 格式下只有 767 字节(具体请以你使用的版本与配置为准)。utf8mb4 每个字符最多占 4 字节,所以 VARCHAR(255) 作为索引列,最坏需要 1020 字节;联合起来很容易逼近上限,或者带来不必要的浪费。

聚簇索引与二级索引。 InnoDB 的主键就是聚簇索引,所有二级索引的叶子节点都会存一份主键值。如果主键是一个 VARCHAR(64) 的字符串(尤其是随机的 UUID),那么每一个二级索引都会因此变大;随机的主键插入位置还会导致页分裂与碎片。这就是“键过长”问题的放大效应。自增的 BIGINT 或有序的分布式 ID(如 雪花算法一类)通常是更稳妥的选择。

行大小与 TEXT/BLOB。 MySQL 对一行(不含 BLOB/TEXT 溢出部分)有 65535 字节的总限制,InnoDB 还要求一行在页内的部分大约不超过半页。TEXT/BLOB 类型在 DYNAMIC 行格式下通常只在行内保留一个指针,内容存在溢出页里,每次读取都可能额外的随机 I/O;不能直接完整索引(需要前缀长度);在排序、分组的临时表中,含 TEXT/BLOB 的查询往往更容易落到磁盘临时表。SELECT * 遇到大字段,代价更高。大字段不是不能用,而是应该与热点数据分表存放,并且明确它的确需要。

覆盖索引。 如果一个查询所需的列全部包含在某个二级索引里,就无需回表,性能提升明显。而设计糟糕的宽表、大字段,让 覆盖索引几乎不可能。

唯一性与并发:竞态条件 vs 唯一约束。 “先查后插”在并发下必然出现竞态:两个请求同时查到“不存在”,然后都插入成功。应用层加锁(synchronized、本地锁)在多实例部署下同样无效,分布式锁又引入了新的复杂度。能用数据库唯一索引保证的,就应该用唯一索引保证,应用层只负责捕获冲突(duplicate key)并转成业务语义。

软删除的陷阱。 用 is_deleted 标记软删除后,唯一索引就会与“已删除的历史数据”冲突。常见的做法是把删除标记纳入唯一键,例如 (email, deleted_at),其中未删除的记录 deleted_at 使用固定默认值(因为 MySQL 的唯一索引允许出现多个 NULL,所以不能用 NULL 表示“未删除”)。软删除还会让所有查询都得带上 is_deleted = 0,稍有遗漏就是数据泄漏;何时清理历史数据也是需要提前设计的问题。

范式与反范式的取舍。 范式化减少冗余、保证一致;反范式化用冗余换查询性能。合理的做法是默认从第三范式起步,只在有明确的查询性能证据时,才有目的地做冗余。AI 的两种极端(大宽表 or 过度拆分)都是因为缺少“证据”这个输入。

迁移成本。 表结构一旦上线,修改代价随数据量快速上升:大表加索引、改列类型、拆表都需要在线变更工具、灰度与回滚方案,还要协调所有读写方。这正是“schema 是最贵的决策之一”的原因,也是我们坚持schema 必须由人评审的理由。

坏的 vs 好的:一个订单表的例子(MySQL 8.x)

先看一个典型的“AI 第一版”:

-- ❌ 坏例子:能跑,但每一行都是隐患
CREATE TABLE t_order (
    id            VARCHAR(255) PRIMARY KEY,       -- 长字符串主键,所有二级索引都被拖胖
    order_no      VARCHAR(255),                   -- 没有唯一索引,靠应用先查后插
    user_name     LONGTEXT,                       -- 姓名用 LONGTEXT
    status        VARCHAR(255),                   -- 状态用长字符串
    product_ids   LONGTEXT,                       -- '1001,1002,1003' 逗号拼接,无法 JOIN 与约束
    extra         LONGTEXT,                       -- 整个 JSON 塞进来,什么都往里放
    is_deleted    TINYINT DEFAULT 0,
    created       VARCHAR(255)                    -- 时间用字符串
);
-- 没有状态流转记录,没有外键关联的明细表

再看我们评审之后期望的形态:

-- ✅ 订单主表:窄、稳、热数据
CREATE TABLE `order_main` (
    `id`           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键,自增或有序分布式ID',
    `order_no`     CHAR(20)        NOT NULL COMMENT '业务单号,定长',
    `user_id`      BIGINT UNSIGNED NOT NULL COMMENT '用户ID,不存冗余姓名',
    `status`       TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 9已取消',
    `total_amount` DECIMAL(12,2)   NOT NULL COMMENT '金额,禁止用浮点',
    `deleted_at`   BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除时间戳,0表示未删除',
    `created_at`   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updated_at`   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_order_no` (`order_no`, `deleted_at`),     -- 唯一性交给数据库
    KEY `idx_user_status_created` (`user_id`, `status`, `created_at`)  -- 服务于“我的订单列表”
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

-- ✅ 关联(明细)表:多对多/一对多用行表达,而不是逗号字符串
CREATE TABLE `order_item` (
    `id`         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `order_id`   BIGINT UNSIGNED NOT NULL,
    `sku_id`     BIGINT UNSIGNED NOT NULL,
    `quantity`   INT UNSIGNED    NOT NULL,
    `unit_price` DECIMAL(12,2)   NOT NULL COMMENT '下单时价格快照',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_order_sku` (`order_id`, `sku_id`),        -- 同一订单同一商品只有一行
    KEY `idx_sku` (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细';

-- ✅ 状态流转/审计表:只追加,不修改
CREATE TABLE `order_status_log` (
    `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `order_id`    BIGINT UNSIGNED NOT NULL,
    `from_status` TINYINT UNSIGNED NOT NULL,
    `to_status`   TINYINT UNSIGNED NOT NULL,
    `operator_id` BIGINT UNSIGNED NOT NULL COMMENT '操作人,系统操作用0',
    `trace_id`    CHAR(32)        NOT NULL DEFAULT '' COMMENT '与日志系统关联',
    `reason`      VARCHAR(255)    NOT NULL DEFAULT '',
    `created_at`  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (`id`),
    KEY `idx_order_created` (`order_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态流转日志';

-- ✅ 低频大字段单独存放,避免拖累主表
CREATE TABLE `order_ext` (
    `order_id` BIGINT UNSIGNED NOT NULL,
    `remark`   TEXT NULL,
    `snapshot` JSON NULL COMMENT '收货信息快照等低频读取的数据',
    PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单扩展';

应用层配合唯一索引,只需要把冲突转成业务语义:

try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    // 唯一约束是并发下的最终裁判;这里只做幂等处理,不再“先查后插”
    log.warn("duplicate order, orderNo={}", order.getOrderNo());
    return orderMapper.selectByOrderNo(order.getOrderNo());
}

需要说明:上面只是示意。外键是否使用、订单号长度、金额精度、分库分表策略,都取决于你自己的业务与团队规范,不要把它当作模板照搬。我们想说明的是评审的角度。

数据库设计评审清单

四、过度设计:在细节里“多此一举”

现象

和“设计不足”相反,AI 还有一种同样普遍的毛病:在小处写得过多。我们反复看到:

一个 Java 例子:两套命名并存

// ❌ 过度设计:同一份数据两套字段,以及一个“兜底”的 getter
public class UserDTO {
    private String user_name;   // 数据库/JSON 下划线风格
    private String userName;    // Java 驼峰风格
    // ... 两套 getter/setter

    public String getDisplayName() {
        if (userName != null && !userName.isEmpty()) {
            return userName;
        }
        if (user_name != null && !user_name.isEmpty()) {
            return user_name;
        }
        return "";   // 兜底:调用方永远不知道数据其实缺失了
    }
}

// Mapper 里两边都赋值,“以防万一”
dto.setUserName(entity.getUserName());
dto.setUser_name(entity.getUserName());

后来的维护者面对这段代码,会陷入一堆无法回答的问题:哪个字段才是真的?前端读的是哪个?改名的时候要不要两个都改?空字符串是合法值还是缺失?

// ✅ 只保留一个真实字段;外部协议命名用注解明确声明,转换由框架统一负责
public class UserDTO {
    @JsonProperty("user_name")   // 对外 JSON 使用下划线,Java 内部统一驼峰
    private String userName;
    // getter/setter 由 Lombok 或 record 生成
}

如果确实有“旧客户端仍在发 userName”这样的兼容需求,正确的做法是显式、有期限:

public record UserDTO(
    @JsonProperty("user_name")
    @JsonAlias("userName")   // TODO(2026-12-31): 旧客户端全部升级后删除
    String userName
) {}

为什么会这样

1. 规避歧义的对冲行为(hedging)。 当需求不明确时,模型倾向于“都支持一下”,这样无论你实际想要哪种,都不算错。人类会去问“到底是哪个”,而模型的默认路径是生成一个“覆盖所有可能性”的答案。

2. 为“看起来完整”而优化。 评审者(人或评分模型)往往觉得:有判空、有兜底、有兼容、有分层的代码“更专业、更健壮”。偏好训练会强化这种印象,于是“看起来周全”被奖励,“克制”很难被奖励,因为克制的收益(未来维护成本更低)在当次回答里无法体现。

3. 没有对未来维护成本的所有权。 多写的每一行代码,都是人类维护者未来要阅读、理解、测试、修改的成本;而模型不承担这个成本,也没有“两年后我还得维护它”的激励。成本外部化,是过度设计的经济学根源。

4. 对冗长的隐性奖励。 许多训练与评测场景里,更长、更详细的回答更容易获得好评。这种“长度偏好”会迁移到代码里,表现为多余的抽象、注释和分支。

5. 缺少全局视图,不知道什么是“死代码”。 模型不知道这个字段前端到底读没读、这个配置是不是线上有人在用,所以它不敢删,也不敢少写。没有调用关系与运行数据,就无从判断“不需要”。

6. 缺少运行时反馈。 兜底分支从来不会在测试里报错,恰恰相反,它让测试更容易通过。没有人告诉模型“这个分支线上从来没走过”。

7. 违反 YAGNI / KISS。 “你不会需要它”(You Aren't Gonna Need It)与“保持简单”是老原则,模型并不缺这些知识,但在“不确定就多写点”的倾向下,它们经常败给对冲行为。

对后来维护者的危害

缓解办法

  1. 写进规则的命名约定:明确“数据库列 snake_case,Java 属性 camelCase,JSON 对外协议 snake_case(或 camelCase)”,且只能通过映射框架转换,禁止手写两份字段。

  2. 用工具兜底:Checkstyle / PMD / SpotBugs / Error Prone、SonarQube 的重复代码与未使用代码规则,IDE 的“unused declaration”检查,以及 ArchUnit 约束分层(比如“不允许出现 xxxVO”)。

  3. 死代码检测:静态分析加上运行时数据,例如覆盖率报告、Feature Flag 的使用统计、访问日志;对于一个月内没有被走过的分支,认真考虑删除。

  4. 提示词里明确“克制”:例如“不要添加未被需求提到的字段、配置项与兼容逻辑;参数的非空性由调用方与校验注解保证,不要重复判空;如果需要 fallback,先问我。”

  5. 代码审查的提问模板:“这个字段谁在读?”“这个分支什么情况下会触发?”“如果删掉它,哪个测试会失败?”回答不上来的,就删。

  6. 删除优先(delete-first)的心态:把 AI 给出的每一个 PR 当成“候选集”,审查的任务是做减法。我们甚至会专门让 AI 做一轮“只删不加”的重构,并要求它对每处删除给出依据。

  7. 让约束在边界,不在内部:在系统边界(Controller 入参、外部接口返回)做一次严格校验,内部代码信任已校验的数据,而不是处处防御。

五、日志追溯难:出事的时候,找不到线索

现象

这类问题往往在线上出事时才暴露:

为什么会这样

1. 局部视图生成。 模型每次只在一个方法、一个类里工作,写出来的日志也只对这个方法“有意义”。日志的价值在于跨越边界的串联,而边界恰恰是局部视图看不到的东西。

2. 训练样本里缺少可观测性思维。 教程和示例代码里,日志要么没有,要么就是一句 log.info("start")。真正的生产日志规范(字段约定、采样、脱敏、链路传播)存在于公司内部文档与运维经验里,公开语料中较少。

3. 快乐路径偏差(happy-path bias)。 训练样本多是“正常情况”的演示,异常分支往往被省略或写得潦草。于是模型最擅长写“成功怎样”,对“失败时需要留下什么线索”并不敏感。

4. 把日志当作噪音。 在“简洁、干净”的偏好下,日志常被视为“影响可读性的杂项”,尤其在被要求“精简代码”时,它们是第一批被删掉的东西。

5. 不了解运行时拓扑。 模型不知道你的系统有几个服务、用的是 Kafka 还是 RocketMQ、有没有网关注入 trace id、日志被谁采集。没有拓扑,就不知道哪里是边界,也就不知道该在哪里打点。

6. 缺失 MDC 等基础设施的意识。 线程池、CompletableFuture、响应式调用、MQ 消费,都会导致线程局部变量(ThreadLocal,MDC 的底层实现)无法自动传递。这是需要“知道系统怎么运行”才会想到的问题。

一个 Java 例子:入口注入 trace id,跨线程与 MQ 传播

// 1) Web 入口:接收或生成 traceId,放进 MDC,请求结束清理
@Component
public class TraceFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                                 .filter(s -> !s.isBlank())
                                 .orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));
        MDC.put("traceId", traceId);
        resp.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(req, resp);
        } finally {
            MDC.clear();   // 线程会被复用,必须清理,否则会串号
        }
    }
}
// 2) 关键业务节点:结构化字段 + 业务标识 + 保留异常原因链
public void pay(String orderNo, long userId) {
    log.info("pay.start orderNo={} userId={}", orderNo, userId);
    try {
        PayResult r = payClient.pay(orderNo);
        log.info("pay.done orderNo={} channelTxnId={} cost={}ms", orderNo, r.txnId(), r.costMs());
    } catch (PayTimeoutException e) {
        // 可预期的业务异常:warn,并明确后续动作(重试/补偿)
        log.warn("pay.timeout orderNo={} willRetry=true", orderNo, e);
        throw e;
    } catch (Exception e) {
        // 不可预期:error,带堆栈;不吞、不改写成“处理失败”
        log.error("pay.error orderNo={} userId={}", orderNo, userId, e);
        throw new BizException("PAY_FAILED", e);
    }
}
// 3) 线程池 / MQ:跨线程需要显式传递上下文
Map<String, String> ctx = MDC.getCopyOfContextMap();
executor.submit(() -> {
    if (ctx != null) MDC.setContextMap(ctx);
    try {
        doWork();
    } finally {
        MDC.clear();
    }
});

// MQ 发送:把 traceId 放进消息头;消费者取出再放入 MDC
Message<OrderEvent> msg = MessageBuilder.withPayload(event)
        .setHeader("traceId", MDC.get("traceId"))
        .setHeader("idempotencyKey", event.getOrderNo() + ":" + event.getType())
        .build();

如果采用 OpenTelemetry,思路是一样的,只是传播与关联交给标准来做:

// OpenTelemetry 风格:在关键节点创建 Span,并附上业务属性
Span span = tracer.spanBuilder("order.pay").startSpan();
try (Scope scope = span.makeCurrent()) {
    span.setAttribute("order.no", orderNo);
    span.setAttribute("user.id", userId);
    doPay();
} catch (Exception e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR);
    throw e;
} finally {
    span.end();
}
// 使用 OpenTelemetry 的 Java Agent 与日志框架集成时,通常可以自动把 trace_id / span_id
// 写入 MDC,使日志与链路互相跳转(具体字段名与配置以你使用的版本文档为准)。

日志输出建议使用 JSON 等结构化格式,例如:

{"ts":"2026-09-30T15:20:11.532+08:00","level":"ERROR","service":"order-service",
 "traceId":"4bf92f3577b34da6a3ce929d0e0e4736","event":"pay.error",
 "orderNo":"O2026093000001","userId":10086,"msg":"channel timeout","exception":"..."}

可追溯性检查清单

六、四个问题放在一起看:对照表

问题 典型症状 技术成因 缓解手段
思维惯性 已纠正的错误反复出现;跨会话重蹈覆辙;压缩后约束丢失 自回归上下文自洽、上下文污染、注意力稀释与中间迷失、近因偏差、训练先验冲突、无状态、摘要有损 规则文件沉淀、短会话、关键约束重注入、用测试与 lint 作为可执行记忆、审查 diff
数据库设计差 超长键、TEXT 滥用、先查后插、逗号拼接 ID、缺审计表 教程与 CRUD 数据偏斜、“能跑”的局部最优、缺乏数据量与查询模式信息、看不到生产负载 人工评审 schema、提供数据量与查询样例、EXPLAIN 验证、唯一索引兜底、评审清单
过度设计 双套命名、层层 DTO、到处判空与兜底、无用开关 规避歧义的对冲、追求“看起来完整”、成本外部化、长度偏好、缺少全局调用关系 命名规则、lint 与 ArchUnit、死代码检测、克制型提示词、删除优先的审查
日志追溯难 无 trace id、无业务标识、吞异常、级别混乱 局部视图、训练样本缺乏可观测性、快乐路径偏差、不了解拓扑、忽视 MDC 传播 在提示词中明确可观测性要求、统一日志规范与工具类、边界打点、结构化日志与链路追踪

七、共同的根因,再说一遍

回头看这张表,四个问题的“成因”列虽然各不相同,但它们背后是同一组结构性原因,值得再强调一次:

  1. 目标错位(objective mismatch):模型被优化成“给出当前看起来最好的答案”,而工程质量是“未来能不能活得好”。演示能跑、测试能过,是它能感知的全部;十八个月后的维护成本,它感知不到。

  2. 局部 vs 全局上下文:窗口内的信息决定了输出。跨文件、跨服务、跨时间的信息(数据量、调用关系、运行拓扑、历史决策),如果不主动喂给它,就等于不存在。

  3. 缺失反馈回路:编译与单测提供的是“语法与局部逻辑”的反馈,而 schema 合理性、代码简洁度、日志可用性、线上性能,没有任何自动反馈信号,只能靠人补上。

  4. 分布偏移(distribution shift):训练分布里的“典型代码”偏向教程与小项目,生产环境属于长尾。越是“需要公司特有知识”的地方,模型越容易给出通用但不合适的答案。

推论很清楚:AI 编程的短板,几乎都在“需要全局信息、长期视角和运行时反馈”的地方。 这也恰恰是人类工程师最不该让渡的部分。

八、怎么和 AI 编程工具协作:我们的实践

以下是我们目前在团队里执行的做法,并不高深,胜在稳定。

1. 规格先行(spec-first)

不要直接说“帮我做一个订单模块”。先用几段话写清楚:目标、非目标、数据量预估、关键查询、边界条件、失败场景、需要的日志与监控。可以让 AI 先产出设计草案,由人评审并定稿后再让它写代码。设计阶段的一次修改,比实现阶段便宜得多。

2. 规则文件与项目上下文

把架构约定、命名规范、常见错误、禁止事项写成规则文件,并持续维护。每次踩坑,就多一条规则。这是把个人经验“资产化”的最便宜方式,也是对抗无状态的主要手段。

3. 小步快跑,频繁提交

每次只让它完成一个小而可验证的改动,跑通、审查、提交,再进入下一步。小步有两个好处:上下文短,惯性与污染小;人工修改可以通过提交固化为基线。

4. 测试与检查先行

先有测试(哪怕由 AI 写、由人审),再有实现。把约束尽可能变成机器可执行的:单元测试、集成测试、lint、类型检查、ArchUnit、schema 迁移检查。AI 擅长“让红灯变绿”,那就先把红灯设计好。

5. 数据库 schema 与迁移必须由人评审

无论 AI 给出的 DDL 看起来多么合理,都要经过人的检查:数据量、索引、唯一性、字段类型、审计与关联表、迁移方案。我们会把 DDL 与预期的 SQL、数据量一起交给 AI 做“评审”,但最终签字的是人。

6. 在提示词里明确可观测性要求

“关键流程需要日志”这句话必须由你来说。可以固定一段提示词模板:

实现时请遵守:
1. 所有入口、出口、MQ 生产与消费处记录日志,包含 traceId 与业务主键(orderNo/userId)
2. 禁止吞异常;catch 后必须记录日志(带堆栈)或抛出
3. 异步/线程池调用需要显式传递 MDC
4. 日志使用统一的事件名与键值对格式,不要拼接长句
5. 不要添加需求之外的字段、配置项与兼容逻辑;需要兜底请先说明理由

7. 审查清单

我们的代码审查里,对 AI 生成的代码额外加一份清单:

8. 把 AI 当作“很快、很勤奋、没有经验的同事”

这是我们最喜欢的比喻。它写得快、读得多、不会累,但它没见过你们的线上事故,也不知道下游有谁。给这样的同事布置任务,你会:说明背景、给出约束、审查结果、把踩过的坑写进文档。对 AI 也是一样。

九、常见问题(FAQ)

Q1:这些问题会随着模型变强而消失吗?部分会缓解,比如长上下文能力、指令遵循能力、对代码库的检索与理解都在进步。但本文列出的多数问题,根源是信息不在上下文里(数据量、拓扑、历史决策)与反馈信号缺失,这不是单靠模型变大就能解决的。所以我们的判断是:模型会变好,但“人提供全局信息、设置反馈回路”的工作不会消失,只是形式会变。这是我们的经验推断,并非定论。

Q2:规则文件写得越多越好吗?不是。规则文件本身也占用上下文,写得太长同样会被稀释。建议保持简短、具体、可判定,并定期清理过时的条目;能用 lint 与测试强制的,就不要只靠文字。

Q3:既然要人评审 schema,那 AI 帮我建表还有意义吗?有。AI 很适合产出第一版草稿、补充注释、生成迁移脚本与测试数据。关键是提供足够的输入(数据量、查询、约束),并且把它的输出视为“待评审的草案”,而不是最终方案。

Q4:日志这么重要,为什么不在框架层统一解决?应该在框架层解决一部分:统一的 Filter/拦截器、线程池装饰器、MQ 拦截器、OpenTelemetry Agent,都可以自动完成 trace id 的注入与传播。业务标识与关键分支日志则仍需要业务代码来写。最好的方式是基础设施自动化,加上提示词与审查兜住业务打点。

Q5:过度设计和防御性编程的界线在哪里?看“边界”。在系统边界(外部输入、第三方响应)做严格校验与防御是必要的;在内部已经校验过的数据流上重复判空、加兜底,就是过度。另一个判断标准是:这个防御分支,能不能用一个明确的场景与测试来说明它存在的理由?说不出来就是多余的。

Q6:思维惯性能通过“换个模型”避免吗?不同模型的表现确实有差异,但机制层面的原因(上下文自洽、无状态、注意力分配)在类似架构上普遍存在。换模型可能减轻,很难根除。所以我们更看重流程层面的对策,而不是押注某个模型。

Q7:小团队没精力做这么多治理,最低限度做什么?我们建议三件事:一个简短的规则文件,加上每次纠正后更新;schema 变更必须人工评审;关键链路统一的 trace id 与业务标识日志。这三项成本很低,收益最大。

十、小结

如果你只想从这篇文章里带走一件事,那就是:别指望它自己“懂”,把你踩过的坑变成规则、测试和检查清单,让每一次生成都带着这些约束。


AI 编程为什么总在同样的地方翻车:思维惯性、数据库设计、过度设计与日志追溯2026-09-30鱼鱼

{{commentTitle}}

评论   ctrl+Enter 发送评论