LLM 辅助开发已经从实验性玩法变成了大量团队的日常工作方式。代码补全、解释报错、生成 SQL、补测试用例,这些任务在过去一年里几乎都被大语言模型接管了。随之而来的一个明显变化是:很多开发者写代码的速度确实快了,但遇到不常见的问题时,独立解决的能力却在下降。英文技术社区里把这种现象称为 programmatic stagnation,直译过来是“编程停滞”。
编程停滞不是“不会写代码”,而是一种渐进的能力退化:习惯了让模型帮你生成、修复、解释之后,你会越来越难判断模型给的是不是对的,也越来越难在模型失效的时候独立推进。下面从工程视角拆解这种现象的成因、表现,并给出可操作的对抗方法:一套更谨慎的 LLM 辅助开发工作流、一条排查链路,以及一份能放进团队规范的检查清单。
1. 编程停滞是什么:先从能力退化过程说起
1.1 三个典型表现,可以对照自己检查
第一,写不出第一步。打开一个需求,第一反应不是拆解步骤,而是打开对话框让 AI“帮我写一个 XXX”。当 AI 没有立刻给出答案时,自己反而不知道从哪里开始。这不是懒,而是大脑把“写代码”外包给了模型,自己的规划能力缺少练习,慢慢生锈。
第二,改不动已有代码。接手一段不是自己写的代码,或者发现自己三个月前写的代码,第一反应是“贴给 AI 让它解释”,而不是先自己读一遍。这种现象在 LLM 辅助开发团队里非常常见:模型解释完之后你觉得自己懂了,但真正让你修一个 bug 时,你仍然不知道改哪里,因为理解是模型的,不是你的。
第三,不放心。程序跑了,但不知道为什么跑。代码是 AI 生成的,依赖是 AI 推荐的,配置是 AI 补全的,出了问题你只能再贴给 AI。这种状态在线上事故里尤其危险,因为代码一旦上线,出了问题是需要人来负责定位和修复的。
这三个表现并不是同时出现的。多数人是从“不放心”开始,然后慢慢变成“改不动”,最后连“写不出第一步”都出现了。如果能早期识别,还有机会控制。
1.2 核心机制:反馈回路被切断了
正常的编程能力是怎么养成的?本质上靠的是“动作—结果”之间的反馈循环:
写代码 -> 编译失败或运行报错 -> 读错误信息 -> 定位问题 -> 修改 -> 再跑 -> 通过。
这个循环每走一次,你对语言、框架、运行机制的理解就加深一层。尤其是“读错误信息”这一步,它是程序员真正积累经验的地方。一个报错看多了,你会在看到前几个字段时就知道是依赖问题还是参数问题,这就是经验。
LLM 辅助开发改变的不是“写代码”这个动作,而是把反馈回路里最关键的“读错误信息、定位、修改”这一段压缩掉了。模型拿到报错,给出补丁,你粘贴,跑通,结束。整个过程里你只是搬运工。
短期看,错误被解决的速度快了;长期看,你的模式识别能力没有增长。下次遇到相同报错,你仍然要贴给模型。这就是停滞的形成机制:不是模型变弱了,而是你的反馈回路一直没打通。
1.3 停滞不等于不会编程,它是中间状态
编程停滞不是二进制判断,不是“会不会写代码”的区分。它更像一个连续的退化谱系:
- 阶段 0:没有 LLM 也能独立完成需求,遇到不会的问题知道怎么查。
- 阶段 1:依赖 LLM 写代码,但能审查、能修改、能判断模型输出是否符合需求。
- 阶段 2:依赖 LLM 写代码,审查浮于表面,只能看“能不能跑”,看不出“设计合不合理”。
- 阶段 3:完全依赖 LLM,遇到报错只能继续问,模型失效时彻底卡住。
很多工程师在阶段 1 和 2 之间徘徊。退到阶段 3 的人并不少,尤其在一些刚入门就使用 AI 编程的开发者中,可能根本没有经历过“没有 AI 怎么开发”的阶段,于是把“模型给代码”当成了开发本身。
理解这一点很重要:对抗编程停滞,不是让你放弃 LLM,而是想办法把反馈回路找回来,让自己至少稳定处在阶段 1。
2. LLM 生成代码为什么看似正确,却总在边界处出问题
2.1 三个典型错误来源
先看 LLM 生成代码时最常见的几类问题,按工程实践中的出现频率整理成下面的表:
| 错误类型 | 表现 | 典型例子 | 检测方式 |
|---|---|---|---|
| 幻觉 API | 调用了不存在的库、函数或方法签名 | 把不存在的requests.get_safe当标准接口 | 查看官方文档或 IDE 自动补全 |
| 版本错配 | 代码语法和依赖版本不匹配 | 按 Python 3.10 写,项目环境是 3.7 | 检查环境版本 |
| 上下文缺失 | 没结合项目已有结构,生成孤立代码 | 生成一个类,却不处理项目里的配置加载方式 | 阅读项目整体上下文 |
| 边界遗漏 | 只覆盖主路径,异常和边界没处理 | 缓存设置了过期时间但没实现判断逻辑 | 用边界测试验证 |
| 资源泄漏 | 打开了连接或文件但没有关闭 | 数据库连接用完不归还 | 检查 finally 或上下文管理器 |
这张表并不完整,但列出的是实际代码审查中最常见的问题。它们有一个共同点:都属于“局部看起来对”的错误。模型生成的单行代码可能完全正确,但放到整个项目里,可能因为版本、上下文、边界条件的组合而出错。
为什么会这样?因为 LLM 的核心能力是概率性补全,它在生成下一个 token 时并不持有完整的运行时上下文。它不知道你的环境里装了哪个版本的 numpy,不知道你的配置类是读取 YAML 还是 properties,不知道你的生产环境是否允许开放某些网络请求。它只是根据训练数据中的模式给出一个“最像样”的答案。
这里要牢记一个判断:LLM 输出的是“文本上像代码的内容”,不是“经过编译和运行验证的代码”。两者之间有距离,距离多远取决于需求复杂度和上下文缺失程度。
2.2 一个最小失败案例:缓存需求只做了一半
用一个非常常见的需求来说明“看似正确”的问题。需求描述是:“从数据库读取配置,并在内存中缓存 5 分钟。”
很多人会直接把这段需求贴给 LLM,得到类似这样的代码:
import time _config_cache = {} def get_config(key: str): if key not in _config_cache: _config_cache[key] = load_from_db(key) return _config_cache[key]这段代码能跑吗?能。第一次调用get_config("app_name"),会从数据库读取,然后存入字典,第二次调用直接返回缓存。
但它满足需求吗?不满足。问题很明显:缓存没有 5 分钟过期。只要进程不重启,缓存永远不会失效。数据库里配置改了,应用拿到的一直是旧值。
这不是 LLM 编错了语法,而是它没有对需求做边界拆解。模型看到“缓存”就生成“先查字典,没有就查库”,这是训练数据里最常见的模式。TTL(过期时间)是这个需求的关键约束,但在很多模型看来,它只是次要信息,甚至会被忽略。
更隐蔽的问题是:没有人审查。开发者拿到代码,跑了一遍,第一次读库,第二次从内存返回,看起来“功能正常”,于是提交。直到某天数据库配置改完,线上应用没生效,排查几小时,最后才发现缓存根本没有过期逻辑。
2.3 假正确比明显错误更危险
明显错误很容易被发现。比如语法错误,IDE 直接标红;比如运行报错,控制台直接输出异常。这类错误反而安全,因为它们触发你的反馈回路,逼着你读报错、去定位。
假正确则不同。它运行正常,结果也符合直觉,但逻辑与需求存在偏差。它不会触发报错,因此也不会进入你的审查注意力范围内。最常见的后果是:
第一,需求的关键分支被悄悄省略。就像上面的 TTL,模型只实现了缓存,没实现过期。
第二,边界条件被忽略。比如输入是空字符串时、并发访问时、数据库临时不可用时,模型生成的主路径代码不会处理这些情况。
第三,性能问题被隐藏。模型可能用一次性全表查询代替了带索引的条件查询,数据量小时看不出来,数据量大时直接拖垮数据库。
所以,“能运行”和“满足需求”之间有一道审查门槛,这道门槛不能交给 LLM 自己。这是对抗编程停滞最核心的一条原则。
核心判断:能运行不等于满足需求,编译通过不等于正确实现。LLM 生成的代码,要当作需要审查的候选代码,而不是可信的最终代码。
3. 用一个最小可运行案例,把“假正确”变成“真正确”
3.1 第一步:先列出边界,再写代码
在上面的缓存需求里,如果先做边界拆解,需求会变成:
- 从数据库读取配置字符串,按 key 存储。
- 缓存设置 TTL,5 分钟后过期。
- 多个线程同时调用时不能并发读数据库多次,需要加锁。
- 数据库不可用时要有明确的异常或日志,不能静默失败。
- 缓存对象要有上限或淘汰策略,防止 key 无限增长。
这个列表不是废话。它决定了代码怎么组织。TTL 决定了你要存储“写入时间”;加锁决定了你要用threading.Lock或RLock;异常处理决定了数据库查询要包在 try/except 里并记录日志。
这些边界条件不需要特别复杂,但它是“你看懂了需求”的证据。如果拿到需求第一件事就是让模型写代码,这个理解过程就会被跳过,直接进入实现,最终得到的就是只覆盖主路径的半成品。
3.2 第二步:给出一个正确实现
下面这段代码是一个满足上述边界条件的实现,用于说明思路。实际项目里可以根据自己的数据库访问方式、日志框架和部署环境调整:
import threading import time import logging logger = logging.getLogger(__name__) class ConfigCache: """带 TTL 和线程安全的配置缓存。""" def __init__(self, ttl_seconds: int = 300, max_size: int = 1024): self._ttl = ttl_seconds self._max_size = max_size self._store = {} self._lock = threading.Lock() def get(self, key: str): with self._lock: item = self._store.get(key) if item is None: return None value, expire_at = item if time.time() > expire_at: del self._store[key] return None return value def put(self, key: str, value): with self._lock: now = time.time() self._store[key] = (value, now + self._ttl) if len(self._store) > self._max_size: # 简单淘汰:删除最早写入的键 oldest = min(self._store, key=lambda k: self._store[k][1]) del self._store[oldest] def load_and_get(self, key: str, loader): value = self.get(key) if value is not None: return value try: value = loader(key) except Exception: logger.exception("load config failed, key=%s", key) raise self.put(key, value) return value这段代码的关键点:
get里先判断缓存是否存在,再判断是否过期,过期就删除并返回None。put里记录的是now + self._ttl,这样每次读取时只需要比较time.time()和expire_at。- 所有读写操作都放在
Lock中,避免多线程下重复加载或数据不一致。 - 数据库加载失败时
logger.exception会记录完整堆栈,方便定位。 - 当缓存数量超过
max_size时,按过期时间淘汰最早的一条,避免内存无限增长。
再看这段代码和 LLM 生成的第一步版本的区别:它多了一层expire_at的判断,多了一把锁,多了一条日志,多了一个淘汰策略。这些并不是性能优化,而是需求边界里明确要求的。
3.3 第三步:用验证清单确认代码真的满足需求
代码写完之后,不能只看“能不能跑”,要看“是否符合边界”。针对这个缓存类,至少要做这几项验证:
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 基础读写 | put 后 get | 返回写入值 |
| TTL 过期 | 设置 ttl=0.1 秒,sleep 0.2 秒后 get | 返回 None |
| 过期后重新加载 | 第一次 load 后等过期,再次 load | 重新调用 loader |
| 并发安全 | 多线程同时 load 同一个 key | 不抛异常,值一致 |
| 数据库异常 | loader 抛异常 | get 抛出异常并记录日志 |
| 容量限制 | max_size=1,写入两个 key | 第一个 key 被淘汰,内存不增长 |
这六项验证覆盖了前面列出的所有边界。做一遍之后,你对这段代码的理解会远超直接粘贴模型输出的程度。学习路径也应该这样:先看模型怎么写的,再自己补边界,最后用测试验证。这个过程其实就是重新接上反馈回路。
注意:不要只验证代码能启动。要验证过期、并发、异常、容量上限这些边界路径是否符合需求,否则下一次线上事故很可能就出在这些没被验证的分支里。
4. 防停滞工作流:把 LLM 从“答案生成器”改成“协作对象”
4.1 先设计边界,再生成代码
既然 LLM 的主要问题在处理不确定性边界,那就让它在更明确的约束下输出。一个可以落地的工作流是:拿到需求后,先写一段“需求边界说明”,再做实现。
下面是一个可以直接复用的提示词结构:
需求如下: [在这里粘贴需求] 请先不要写代码,只做三件事: 1. 列出这个需求的边界条件和异常场景。 2. 给出核心数据结构或接口设计。 3. 说明你会如何处理并发、失败重试和资源释放。 确认以上内容符合需求后,再给出实现代码。这看起来多了一步,但非常值得。原因有二:
第一,让模型先列出边界,会逼它把主路径之外的场景暴露出来。即使模型列得不全,你也能通过人工补充,避免直接进入实现时把边界完全忽略。
第二,这一步给你一个审查锚点。模型把边界列出来之后,你可以对照需求逐一确认,再让它生成代码。生成后,你只需要检查“实现是否覆盖了列出的边界”,判断难度显著降低。
实际项目中,边界说明可以短到三五行,也可以长到一页,取决于需求复杂度。关键是不要让实现代码先于边界理解出现。
4.2 把 LLM 当成“初级工程师提交的 PR”来审查
这是最重要的心态变化。模型生成的代码,就像一位初级工程师第一次写完后提交的 PR:可能主路径是对的,但边界处理、命名、异常路径、资源释放都需要人来审。
审查时按这个顺序:
- 先读需求,确认你理解了需求。
- 再读模型生成的代码,找出它处理了哪些分支、遗漏了哪些分支。
- 重点看异常路径和资源清理。try/except 是否吞掉了关键异常?文件或连接是否关闭?键不存在时是否处理?
- 看依赖和 API 是否真实存在。模棱两可的函数先查文档,不要假设它存在。
- 最后运行测试,不要只靠“编译通过”来判断。
这套审查流程并不需要懂很多架构知识,它只是把一个工程师的基本功迁移到 AI 输出上。每审查一次模型代码,你的“判断代码能力”就会长一分;每直接粘贴一次模型代码而不看,这个能力就会减值。
4.3 用测试用例约束生成结果
如果你已经在做单元测试,那就让测试先跑,再用测试定义“什么是正确”。下面这个例子用测试描述了 ConfigCache 的 TTL 行为:
import time def test_config_cache_expired(): cache = ConfigCache(ttl_seconds=0.1) cache.put("key", "value") time.sleep(0.2) assert cache.get("key") is None def test_config_cache_reload_after_expire(): calls = [] def loader(key): calls.append(key) return "db_value" cache = ConfigCache(ttl_seconds=0.1) assert cache.load_and_get("k", loader) == "db_value" time.sleep(0.2) assert cache.load_and_get("k", loader) == "db_value" assert len(calls) == 2这种做法的好处是,即使你还没看实现代码,测试已经把需求里的约束写死了。LLM 生成的代码如果没通过测试,说明实现和需求不一致。开发者再去阅读“为什么没通过”,就是一个完整的学习过程。
有团队可能觉得先写测试太麻烦。但以现在的工程实践来看,先写测试再用 LLM 实现,恰恰是防止生产事故和防止能力退化的双重保障。它同时解决了“这个功能对不对”和“你懂不懂这个功能”两个问题。
5. 排查链路:模型失效时,你自己怎么定位问题
5.1 排查顺序:先自己读,再决定是否求助模型
模型失效的时刻,就是编程停滞暴露的时刻。为了在这个时刻不卡住,日常就要养成固定的排查顺序:
- 看错误堆栈的第一条。它指向你自己写的文件还是依赖包内部?如果是前者,错误位置最接近真相。
- 确认输入。调用时传了什么参数,数据是什么类型,是否为 null、空字符串、超长数据?
- 确认配置。配置项是否拼写正确,是否被加载,环境变量是不是当前环境的值?
- 确认依赖版本。代码里使用的 API 是否在当前版本存在,换过版本吗?
- 用最小用例复现。删掉业务代码,只保留出错路径,看问题是否还能触发。
- 定位到根因后,再考虑要不要让 LLM 提供修复建议。
这套顺序的关键点是:模型只是最后一步的工具,而不是第一步的依赖。如果你每次报错都是直接把整个错误信息贴给模型,跳过自己阅读,就会越来越丧失定位能力。
排查的原则是:AI 可以帮你加快定位,但你不能把定位过程完全交给 AI。至少前四步要自己完成,否则你根本无法判断模型给出的修复建议是否真的命中根因。
5.2 报错排查时的常见坑
第一个坑是贴错误时不给上下文。只贴一句异常信息,没有指出是哪个函数调用、哪一行代码触发的,模型给出的答案大概率泛泛而谈。正确的做法是给出最小复现代码、异常堆栈、依赖版本和期望行为。
第二个坑是同时改了很多地方。排查时不要一次性改了配置、换了依赖版本、又改了一处代码,然后发现问题还在,这时你根本不知道哪一步影响结果。正确做法是只改一个变量,复现一次,记录一次。
第三个坑是接受模型给出的“看起来合理”的方案但不去验证。模型可能说“你试试升级到某个版本”,你升级了,问题仍然存在,却不知道问题本来就不是版本导致的。升级前要先有证据支持版本是根因。
5.3 一个可复用的排查清单
下面的清单可以直接放进团队文档:
| 排查步骤 | 操作 | 关键命令或验证方式 |
|---|---|---|
| 1. 读堆栈 | 找出第一行非框架异常 | 阅读文件名、行号、异常类型 |
| 2. 复现 | 用最小用例重现问题 | 构造最小输入 |
| 3. 确认输入 | 打印入参和类型 | logging 或 debugger |
| 4. 确认配置 | 检查配置加载路径和值 | 打印配置对象 |
| 5. 确认依赖 | 核对版本和 API 签名 | pip show、npm ls、mvn dependency:tree |
| 6. 检查资源 | 确认连接、文件、线程是否泄漏 | 查看进程句柄和连接数 |
| 7. 搜索已知问题 | 用异常关键字查 issue 或文档 | 不要只依赖模型 |
| 8. 分步修复 | 一次只修一个因素 | 修复后重新跑最小用例 |
这些步骤的核心不是让你变成一个“不用 AI 的人”,而是让你在 AI 给出结论之前,自己至少完成前四步。前三步做完之后,即使模型给错,你也能判断它哪里错了。
6. 在团队落地:让 LLM 辅助开发不演变成集体停滞
6.1 区分学习环境和生产环境
很多团队引入 AI 编码工具后,没有定义使用边界,于是学习环境里的随意习惯被直接带进了生产代码。这里给出一个简单的差异对照:
| 维度 | 学习环境 | 开发/测试环境 | 生产环境 |
|---|---|---|---|
| 验证方式 | 能跑通即可 | 单测、集成测试、审查 | 全链路测试、监控、回滚方案 |
| 依赖管理 | 临时安装 | 锁定版本 | 版本锁定、灰度发布 |
| 代码生成 | 可直接尝试 | 需要人工审查 | 严格审查、禁止直接发布 |
| 日志要求 |