Ryuu 的个人博客

一个计算机初学者

很多人第一次听到 API 幂等,是从“防重复提交”开始的。

用户点了一次提交,页面没反应,又点了一次。前端给按钮加个 loading,提交后禁用三秒,看起来问题就解决了。再高级一点,加个 token,或者在 Redis 里 setnx 一下,同一个请求不要重复处理。

这些做法不是没用。

但如果你把 API 幂等理解成“防用户手快”,就会低估它真正要处理的场景。线上很多重复执行,并不是用户连续点了两下按钮,而是系统不知道上一件事到底发生到哪一步了。

请求发出去了,客户端超时了。

支付渠道扣款成功了,但响应丢了。

消息队列投递了一次,消费者处理到一半重启了。

Webhook 已经通知过你,但对方没收到你的 2xx,于是又发了一次。

后台补偿任务昨晚跑失败,今天人工补跑。

这些时候,问题不是“前端有没有挡住第二次点击”,而是:

当结果未知时,调用方一定会想办法再来一次。服务端要保证再来一次不会把业务改坏。

这才是 API 幂等真正解决的问题。

幂等不是按钮防抖

前端防连点解决的是用户体验层面的重复触发。

它可以减少无意义请求,也可以让页面状态更清楚。但它挡不住这些情况:

  • 用户刷新页面后重提
  • 客户端超时后自动重试
  • 网关、SDK 或任务框架重试
  • MQ 至少一次投递
  • 第三方 webhook 重复通知
  • 人工补偿和批量重放
  • 攻击者拿旧请求重新提交

前端按钮只活在浏览器当前页面里。服务端面对的是网络、队列、数据库、第三方系统、后台任务和人肉运维。

所以服务端幂等的第一步,是承认一个事实:

同一个业务意图可能会从多个入口、在不同时间、以不同姿势再次到达。

如果这个业务意图是“查一下订单列表”,重复到达通常没什么。如果它是“扣款”“发券”“创建订单”“发货”“记账”“领取奖励”,重复到达就可能产生真实损失。

按钮禁用只能减少“点了两下”。它不能回答“第一次到底成功了吗”。

幂等要回答的是这个问题。

HTTP 方法的幂等,和业务写操作的幂等,不是一回事

HTTP 规范里确实有幂等方法的概念。RFC 9110 把 PUTDELETE 和安全方法定义为幂等方法:同一个请求执行多次,对服务端的预期效果应当和执行一次相同。规范还提醒,客户端不应该自动重试非幂等请求,除非它知道这个请求语义本身就是幂等的。[1]

这很重要,但它只解决一层语义问题。

很多业务事故发生在 POST 上:

  • 创建订单
  • 创建支付单
  • 发起退款
  • 发放权益
  • 提交审批
  • 创建导入任务

从 HTTP 方法看,POST 通常不是天然幂等的。因为“创建一个新资源”执行两次,很可能就是两个资源。

但业务上我们经常希望某些 POST 可以安全重试。

比如“用这个客户端请求号创建一张订单”。如果客户端超时了,它应该可以带着同一个请求号再问一次:刚才那张订单到底创建出来没有?

这时候幂等不是 HTTP 方法送你的。

它是你在业务层设计出来的。

真正的敌人是“未知结果”

很多接口设计只考虑两种结果:

  • 成功
  • 失败

但分布式系统里最麻烦的是第三种:

  • 不知道

客户端发起支付请求。服务端调用三方支付。三方已经创建了支付对象,但你的服务端在写本地结果前重启了。

这个时候客户端看到的是超时。

它不知道该提示用户“失败了”,还是“成功了”,还是“稍后查看”。更现实的是,它会重试。SDK 会重试。业务方会重试。客服可能也会点一次“重新发起”。

如果服务端没有幂等,重试就可能变成第二次创建、第二次扣款、第二次发券。

所以幂等设计的核心不是“挡掉重复请求”,而是把未知结果变成可查询、可复用、可恢复的确定结果。

同一个业务意图再来一次时,服务端最好能回答:

  • 这件事第一次是否已经开始执行
  • 第一次使用的参数是什么
  • 第一次生成的业务对象是什么
  • 第一次最终成功、失败,还是仍在处理中
  • 这次重试应该返回旧结果、继续等待,还是拒绝

如果这些都没有记录,系统就只能靠猜。

猜,是幂等事故的开端。

幂等键绑定的是业务意图

最常见的做法,是让调用方带一个 idempotency key

Stripe 的官方 API 文档就是很典型的样板:调用方给 POST 请求带幂等键后,Stripe 会保存第一次请求的状态码和响应体,后续同一个 key 的请求会返回同一个结果;幂等键建议使用足够随机的值,比如 UUID v4;保留窗口至少 24 小时;同一个 key 后续请求的参数如果和第一次不一致,会被识别为误用。[2]

这个设计里有几个细节很值得注意。

第一,幂等键不是“这个字符串用过了就行”。

它要绑定一次业务意图。

比如用户点击“购买 A 商品 1 件”,客户端生成了一个请求号。这个请求号可以用于重试同一次购买。但它不能下一次又拿来购买 B 商品 3 件。

所以服务端只记录:

1
key = abc 已经用过

是不够的。

更稳的记录通常至少包括:

  • 幂等键
  • 规范化后的请求参数摘要
  • 首次请求时间
  • 当前处理状态
  • 关联的业务主键
  • 首次完成后的响应结果

这样第二次请求进来时,服务端才能判断:

  • 是同一业务意图的重试
  • 还是调用方复用了 key
  • 还是恶意或异常流量在碰撞

第二,幂等结果不是所有失败都应该永久固化。

Stripe 文档里还有一个边界:如果参数校验失败,或者请求在并发冲突前还没有真正开始执行,就不会保存幂等结果。[2:1]

这个边界很实用。

字段缺失、格式错误这种请求,本来就没有进入业务执行。调用方修正参数后可以重试。如果你把这种失败也永久绑定到 key 上,调用方反而很难恢复。

幂等真正要固化的,是“已经开始产生业务效果”的执行。

只靠幂等表还不够

很多人做到这里会觉得,建一张 idempotency_record 表就完事了。

还不够。

幂等表解决的是请求层的问题:同一个 key 再来时如何处理。

但业务层仍然要有自己的硬约束。

因为重复不一定都从同一个 key 来。

同一个支付回调可能有事件 ID。用户发起支付可能有客户端请求号。后台补偿可能按订单号跑。对账任务可能按渠道交易号修正。人工重放可能从管理后台触发。

如果这些入口最后都会影响同一张支付单、同一笔账、同一份权益,那么真正保护业务的,不能只有入口处的一张幂等表。

你还需要:

  • 业务唯一键
  • 数据库唯一约束
  • 条件更新
  • 状态机 guard
  • 事务边界
  • 版本号或乐观锁

比如支付单只允许从 PENDING 变成 PAID

重复的支付成功事件来了,可以忽略。

旧的支付失败事件晚到了,不能把 PAID 改回 FAILED

这不是幂等键一个字段能解决的。

这是状态机要解决的问题。

幂等解决“同一个触发重复来了怎么办”。

状态机解决“这次状态推进在业务上还允不允许”。

两者不是替代关系。

支付回调是最好的反例

支付回调很适合用来理解幂等,因为它天然会打破很多天真的假设。

天真的写法通常是:

  1. 回调进来
  2. 验签
  3. 查本地订单
  4. 如果未支付,就改成已支付
  5. 发货或发权益
  6. 返回成功

这段流程看起来很顺。

但线上现实通常不顺:

  • 回调可能比本地下单落库更早到
  • 同一事件可能重复投递
  • 事件可能乱序
  • 你的接口返回慢,对方会重试
  • 你处理成功了,但返回 2xx 之前进程挂了
  • 你本地状态已经是终态,但旧事件才刚到

Stripe 的 webhook 文档也明确提醒,事件不保证按生成顺序送达;在 live mode 下,如果 endpoint 没有成功响应,Stripe 会自动重试最长三天,并使用指数退避;文档还建议 webhook handler 尽快返回成功,把复杂逻辑延后异步处理。[3]

所以更稳的模型不是“回调来了就同步做完整个业务”,而是:

  1. 验签
  2. 原始事件落库
  3. 按事件唯一键去重
  4. 快速返回 2xx
  5. worker 异步消费事件
  6. 按业务主键和状态机更新订单
  7. 副作用通过 outbox、任务或事件继续推进
  8. 失败进入重试、死信、补偿或人工处理

这里有两层幂等。

第一层是事件级去重:同一个 webhook event 不要处理两次。

第二层是业务级幂等:即使两个不同事件都表达“这笔支付已经成功”,也不能重复记账、重复发货、重复发券。

很多支付事故就发生在只做了第二层的一点点:

1
2
if order.status == PAID:
return success

这只能挡住一部分重复支付成功回调。

它挡不住事件审计缺失,挡不住旧状态覆盖新状态,挡不住查不到订单的孤儿回调,挡不住发货逻辑被两个入口同时触发。

服务端要防的不是“这一行 if 有没有写”。

服务端要防的是所有入口都不能把核心不变量打破。

防重放、幂等、锁,不要混成一件事

幂等还经常和两个概念混在一起:防重放、锁。

它们有关,但不是同一个东西。

防重放关注的是:一份旧的合法请求,现在还能不能再次被接受。

比如 webhook payload 和签名被复制后重新提交。如果签名只证明“内容没被改过”,但没有 timestamp、nonce、event id、请求 ID 或时间窗口,那么旧请求可能仍然看起来合法。

所以高价值入口通常要回答三件事:

  1. 这个请求是谁发的,内容有没有被改
  2. 这是不是一条仍在有效窗口内的新请求
  3. 如果它重复到达,业务副作用是否只发生一次

第一件事靠鉴权、签名、验签。

第二件事靠 timestamp、nonce、event id、request id、时间窗口。

第三件事靠幂等键、唯一约束、状态机和事务。

幂等不能替代鉴权。

一个重复请求不重复生效,不代表它来源可信。

锁解决的是另一类问题:多个执行者如何互斥地修改资源。

比如两个 worker 同时抢一个任务,或者两个请求同时扣同一份库存。锁、乐观锁、唯一约束、条件更新都可能用上。

但锁也不是幂等。

锁能让同一时刻只有一个人进门,却不能告诉你这个人是不是昨天已经办过同一件事,也不能告诉你超时以后再次进门该返回什么结果。

把这些概念混在一起,最后很容易写出一种“看起来很安全”的代码:

1
2
3
4
前端禁用按钮
Redis setnx
查一下状态
没问题就执行

它能挡住一部分普通重复提交。

但它没有回答未知结果、参数复用、持久约束、状态回退、补偿重放这些更关键的问题。

先写不变量,再写接口

我现在更愿意从不变量开始设计这类接口。

不要先问:

  • 用不用 Redis
  • 幂等表怎么建
  • key 放 header 还是 body
  • 要不要分布式锁

先问:

  • 这个业务最不能坏的事实是什么
  • 哪些副作用绝不能重复
  • 哪些状态一旦到达就不能回退
  • 哪些检查必须和写入在同一个事务里完成
  • 哪些失败可以稍后补偿,哪些失败不能先脏后修

比如支付场景里,不变量可能是:

  • 同一支付意图只能生成一张有效支付单
  • 同一渠道交易只能记账一次
  • 已支付订单不能被旧失败事件回退
  • 发货或权益开通只能对同一业务事实生效一次

有了这些不变量,再决定机制:

  • 创建支付单用业务请求号唯一约束
  • 调三方支付带幂等键
  • 回调事件表按 provider event id 去重
  • 支付单状态用条件更新
  • ledger entry 用 source object + entry type 做唯一键
  • 发货通过领域事件触发,业务侧自己幂等
  • 失败事件进入 retry / dead letter / reconciliation

这时候幂等不再是一张“防重复提交表”。

它变成一组保护业务不变量的机制。

一个最小可用清单

如果一个写接口重试后可能造成钱、库存、权益、身份、权限或不可逆状态变化,我会至少检查这些问题:

  1. 调用方在什么失败下会重试?
  2. 谁生成幂等键?这个 key 表达哪个业务意图?
  3. 同一个 key 换参数时怎么办?
  4. 首次执行结果保存在哪里?处理中状态怎么返回?
  5. 幂等记录保留多久?过期后业务层是否仍有约束?
  6. 业务主键、外部单号、事件 ID 是否有唯一约束?
  7. 状态是否只能合法前进?终态收到旧事件怎么办?
  8. 状态推进和外部副作用是否拆开?
  9. 后台重试是否有次数上限、退避、死信和人工重放?
  10. 入口是否需要签名、timestamp、nonce 或时间窗口来防重放?

这里面没有哪一项单独等于“幂等”。

真正的幂等能力,来自它们组合在一起。

结尾

“防连点”是一个太小的入口。

它让人以为重复来自用户手快,以为只要少发一个请求,系统就安全了。

但服务端面对的重复,更多来自未知结果:不知道上一次有没有执行,不知道执行到哪一步,不知道响应是不是丢了,不知道对方会不会再投递,不知道补偿任务会不会重放。

API 幂等真正解决的,不是让第二次点击消失。

而是当第二次、第三次、隔天的一次、后台补偿的一次真的到来时,服务端仍然能说清:

这是同一件事。它已经发生过。结果在这里。业务不会再被改坏。


  1. RFC 9110《HTTP Semantics》在 Idempotent Methods 中定义了 HTTP 方法幂等语义,并说明客户端自动重试非幂等方法需要知道请求语义本身是幂等的。 ↩︎

  2. Stripe 官方文档 Idempotent requests 说明了幂等键、首次响应复用、参数一致性校验、建议 UUID v4、至少 24 小时清理窗口,以及未开始执行时不保存幂等结果等边界。 ↩︎ ↩︎

  3. Stripe 官方文档 Receive Stripe events in your webhook endpoint 说明了 webhook handler 应快速返回成功、失败会自动重试、事件可能重复且不保证顺序等处理边界。 ↩︎

用 AI 做长一点的编程任务时,很容易产生一种误会:只要上下文窗口够大,就可以放心把所有东西都塞进去。

需求说明、代码片段、测试日志、搜索结果、旧方案、会议结论、刚才的失败尝试,全都给它。模型不是能看很长吗?那它应该会记得更多,理解得更完整,最后做得更稳。

我现在越来越觉得,这个想法危险的地方在于:它把“能放下”误认为“能用好”。

长上下文确实有价值。它让模型能一次读更多材料,也让复杂任务少一些来回搬运。但长上下文不是长期记忆,更不是天然干净的工作台。很多时候,Agent 不是因为看不见信息而失败,而是因为看见了太多旧的、相似的、过期的、未经验证的信息,最后在一堆上下文里拿错了权重。

我把这种现象称为 Context Rot:上下文还在窗口里,但已经开始变质。

不是忘了,而是记住了太多不该记的东西

context window 解决的是容量问题:一次请求里最多能放多少可被模型回看的内容。Anthropic 的文档把它描述成模型生成文本时能够回看并引用的文本总量。[1]

但容量不是记忆质量。

你可以把一整张桌子铺满资料,但这不等于桌面更适合工作。桌上有当前需求,也有三轮之前废弃的方案;有刚跑出来的测试失败,也有已经被修复过的旧报错;有真正相关的文件,也有名字相似但语义不同的模块。它们都在“可见范围”里,却不应该拥有同样的地位。

这就是 Context Rot 的核心:问题不是模型完全忘了,而是上下文里的信号密度、时效性和证据身份开始下降。模型仍然能看到文字,但更难稳定判断哪些是当前事实、哪些是旧计划、哪些只是失败路径,哪些只是临时猜测。

所以它经常表现得很迷惑:明明前面已经改过方向,它后面又沿用旧方案;明明读过正确文件,它总结时却引用了相似但错误的上下文;明明用户补充了新约束,它实现时还是被早期目标牵着走。

它不是没有记忆。

它是记忆太脏。

长上下文本身也会退化

这不是纯经验感受。Lost in the Middle 研究观察到,模型处理长输入时,相关信息的位置会影响使用效果;信息放在开头或结尾通常比放在中间更容易被利用。[2]

Chroma 的 Context Rot 技术报告进一步讨论了输入变长以后模型表现的退化。他们关注的不只是简单的“针在草堆里”检索,而是更接近真实应用的情况:上下文里有干扰项、语义相似项、结构化输入和不同相似度的问题。报告的一个重要提醒是,长上下文能力不能只用“能不能找回一个明确字符串”来代表。[3]

真实的 Coding Agent 任务通常比这些实验更乱。

因为编程任务里的上下文不是一份干净文档,而是一条不断滚动的工作流。它会混入:

  • 代码搜索结果
  • 文件全文
  • 测试日志
  • 构建失败
  • 网页资料
  • 临时计划
  • 已经废弃的假设
  • 用户中途补充的新目标
  • Agent 自己写过但还没有验证的解释

这些材料都可能有用,但它们的有效期不一样。测试日志可能只在定位阶段有用;旧计划一旦被推翻,就不应该继续参与实现;搜索结果如果没有打开原文验证,只能算候选线索;模型自己的总结如果没有证据,只能算假设。

一旦这些身份被混在一起,上下文就开始腐烂。

Coding Agent 里最常见的五种腐烂源

第一种是旧计划残留。

长任务一开始可能走 A 方案,后来发现不合适,改成 B 方案。但 A 方案的理由、文件路径、设计草图和半成品解释还留在对话里。后面 Agent 写代码或总结时,又把 A 方案当成有效背景拿回来用。

第二种是失败路径残留。

排障时会尝试很多假设。假设本来应该被证据淘汰,但如果没有明确标记“这条路已排除”,它会变成上下文里的幽灵状态。后续推理可能继续围着它转。

第三种是工具结果堆积。

rg 输出、测试日志、网页正文、API 响应、stack trace 都很有价值,但它们多数是短期证据。定位完成后,大段原始输出应该被压缩成“命令、结果、关键证据、可重取路径”。如果一直留在 active context 里,它们会持续占用注意力预算。

Anthropic 的 context engineering 资料也把 compaction、tool-result clearing、external memory、sub-agent/context isolation 等放在一起讨论,本质上都是在治理 active context,而不是迷信窗口容量。[4]

第四种是相似干扰项。

最危险的上下文不一定是完全无关的东西,而是“看起来相关但不是这次答案”的东西。比如相似模块、相似错误、旧版本接口、同名概念、另一个分支里的实现。它们会给模型一个很自然的错误抓手。

第五种是规则膨胀。

把所有偏好、禁忌、工具说明、写作风格和边界条件都塞进 always-on prompt,看起来很保险,实际可能稀释真正关键的规则。AGENTS.md、skill、项目文档都应该是索引和高频约束的载体,而不是百科全书。

Context Rot 的症状

日常使用里,可以从几个现象判断上下文已经不干净了。

Agent 开始重复已经否定过的方案。

它不断调用更多工具,但判断质量没有提升。工具调用越来越像是在给旧假设找理由,而不是寻找能区分假设的证据。

它读了很多文件,却引用错了文件。尤其是当仓库里有多个相似模块、相似命名、旧实现和新实现并存时,这种情况很常见。

它在总结里混入早期探索结论。明明中途已经改了方向,最后交付说明还是把旧方案、旧目标或旧报错带进来。

它开始忽略新约束。用户刚刚说了“不改这个模块”“只做本地验证”“不要动生成文件”,后面实现时却又被前面的大目标牵走。

还有一个很有意思的信号:压缩、重开任务或把当前事实整理成短摘要之后,模型表现突然变好了。

这说明问题可能不是能力不够,而是工作现场太乱。

更大的窗口不能根治它

更大的上下文窗口能推迟一部分问题,但不能消除 Context Rot。

因为 Context Rot 的核心不是“空间不够”,而是“上下文身份混乱”。窗口变大以后,旧计划、失败日志、相似干扰项和未经验证的总结也能放得更多。它们不会因为窗口变大就自动变成低权重,也不会自动被标记为过期。

所以大窗口更像一间更大的工作室。空间变大以后,确实可以放更多材料;但如果没有整理规则,它也可以更快变成仓库。

工程上真正要管理的是几件事:

  • 当前任务到底是什么
  • 当前阶段需要什么证据
  • 哪些内容已经过期
  • 哪些结论有来源
  • 哪些工具结果可以重取
  • 哪些规则应该常驻
  • 哪些资料应该按需加载

OpenAI 的 Codex best practices 强调给 Agent 明确目标、上下文、约束和完成标准,也建议把重复出现的项目指导沉淀到 AGENTS.md,并让 Agent 通过测试、lint、review 等方式验证结果。[5] 这背后的逻辑也是一样的:好的 Agent 工作流不是让模型背更多,而是让它在每一步拿到更正确的工作现场。

怎么治理 Context Rot

第一,按阶段切上下文。

研究阶段可以宽一点,多读文件、多搜索、多列假设。计划阶段应该收窄,只留下关键事实、取舍、风险和验收方式。实现阶段更应该窄,只保留当前文件、邻近接口和确定的设计决策。验证阶段需要的是新鲜证据,而不是早期信心。

第二,压缩工具结果。

工具输出不要默认长期驻留。大段日志读完以后,应该变成:

1
2
3
4
5
命令:npm run generate
结果:失败
关键错误:某篇文章 frontmatter 解析失败
证据位置:完整输出可重新运行命令获得
下一步假设:检查该文章 YAML

这比把完整日志一直放在上下文里更有用。

第三,显式废弃旧假设。

不要只追加“现在改用 B 方案”。最好写清楚:

1
2
3
已废弃:A 方案,因为测试 X 证明它不能覆盖 Y 场景。
当前方案:B 方案。
仍待验证:B 方案是否影响旧接口。

这能帮模型把历史状态和当前状态分开。

第四,把稳定结论外存。

如果某个结论下次还会用,就不要只留在聊天记录里。项目规则进 AGENTS.md 或文档;通用知识进 wiki;验证方式进脚本或 CI;可复用流程进 skill;一次性移交进 handoff。对话应该是临时工作台,不应该承担知识库的职责。

第五,保留证据指针,而不是保留全文。

很多内容是可重取的:文件路径、行号、命令、URL、测试名、日志路径。对模型来说,当前阶段通常不需要所有原文,只需要知道证据在哪里、结论是什么、是否仍然有效。

第六,把外部内容当证据,不当指令。

网页、issue、邮件、PDF、搜索结果都可能进入上下文,但它们不能越权改变任务规则。它们可以提供 claim,可以提供线索,可以被总结;但是否采纳,要看来源、时间、权限和当前目标。

一个简单检查清单

当一个 Agent 任务变长时,我会问这几个问题:

当前目标还能不能一句话说清楚?

当前阶段是研究、计划、实现、验证,还是沉淀?

现在窗口里最占地方的内容,还是不是当前阶段需要的?

有没有已经被否定但还没有标记废弃的方案?

有没有一大段工具输出可以压缩成摘要和指针?

关键结论有没有来源?来源是文件、命令、测试、文档,还是模型自己的猜测?

新约束有没有覆盖旧目标?旧目标是否需要明确失效?

完成判断有没有新鲜证据?

如果这些问题回答不上来,继续往上下文里塞材料通常不会让 Agent 更聪明,只会让它更容易把垃圾加工成一套看起来完整的方案。

长上下文应该被使用,而不是被迷信

我不是反对长上下文。

相反,长上下文是 AI 编程真正好用的重要原因之一。没有它,很多跨文件理解、长文档阅读、复杂需求梳理都会变得很麻烦。

但长上下文应该被管理,而不是被崇拜。

它适合承载当前任务所需的高信号材料,不适合永久保存所有历史痕迹。它适合帮助模型理解工作现场,不适合替代文档、测试、脚本、wiki 和人工判断。它适合让 Agent 少猜一点,不适合让我们把证据边界、任务边界和责任边界都交给模型自己整理。

如果说 AI 上下文工程:把正确的信息放到正确的位置 讲的是“要把正确的信息放到正确的位置”,那么 Context Rot 这篇想补上的就是另一半:

错误的位置、过期的信息和未清理的历史,本身也会变成一种输入。

Agent 的输出不是只由模型能力决定,也由它所在的工作现场决定。上下文越长,越要认真整理现场。否则它不是长期记忆,而是一张越来越乱的桌子。

参考与延伸阅读


  1. Anthropic, Context windows。这里引用它对 context window 作为模型当前可回看文本范围的解释。 ↩︎

  2. Nelson F. Liu et al., Lost in the Middle: How Language Models Use Long Contexts。这篇研究支持“相关信息在窗口内不等于模型会稳定利用它”的判断。 ↩︎

  3. Kelly Hong, Anton Troynikov, Jeff Huber, Context Rot: How Increasing Input Tokens Impacts LLM Performance,Chroma Technical Report, 2025。这里引用它来支持“长输入、干扰项和任务形式会让模型表现不均匀退化”的判断。 ↩︎

  4. Anthropic, Effective context engineering for AI agents,以及 Anthropic Cookbook, Context engineering: memory, compaction, and tool clearing。这里引用它们对 active context、compaction、tool-result clearing、external memory 和上下文隔离的工程实践。 ↩︎

  5. OpenAI, Codex best practices。这里引用的是任务目标、项目上下文、AGENTS.md、验证命令和 review 对 Coding Agent 工作质量的影响。 ↩︎

这两年用 AI,最容易让人产生错觉的一件事,是它太会把答案写得像答案。

你问一个库的参数,它能列出名字、类型、默认值和示例。你问一篇论文,它能说出作者、年份、方法和结论。你问一个报错,它能给出原因、修复步骤和注意事项。很多时候这些确实有用。问题在于,只要它的语气足够完整,我们就很容易跳过一个最朴素的问题:

这些东西是它找到的,还是它生成的?

这两个动作完全不同。

生成,是模型根据上下文和语言模式补出一个最可能的回答。找到,是它从文件、日志、数据库、官方文档、论文、搜索结果或测试输出里拿到证据。前者可以很快,后者才接近事实。

所以这篇想接着 AI 上下文工程:把正确的信息放到正确的位置 往下谈一个更窄的问题:AI 为什么会一本正经地胡说,日常使用里又该怎样让它找到真实的东西,而不是生成一个像真的答案。

流畅不等于真实

AI 的危险不总是来自明显的错误。

明显的错误反而容易处理。代码跑不起来,链接打不开,数字离谱,概念混乱,人一眼就能发现。真正麻烦的是那些“刚好像对”的回答:结构完整,术语准确,语气稳定,还附带几个看起来很专业的解释。

比如它说某个 API 有一个参数。这个参数名很像这个库的命名风格,示例也很自然,甚至连异常情况都写出来了。读的人如果没有打开官方文档,就很容易把它当成事实。

这不是因为模型“故意骗你”。更常见的情况是,它在做自己最擅长的事:根据上下文补全一个合理的文本。

问题在于,合理文本不等于真实世界。

软件开发里尤其如此。一个字段应该在订单创建时确定,还是在支付成功时确定;一个缓存能不能写;一个数据库事务边界在哪里;一个 API 参数是否真的存在。这些都不是“语言上合理”就能决定的事实。它们要么来自业务规则,要么来自代码现状,要么来自文档、测试和运行结果。

AI 可以帮助我们解释这些事实,但不能凭语气让事实成立。

AI 为什么会自信地错

大语言模型的基础训练目标,通常不是判断一个命题是真还是假,而是在给定上下文后预测后续 token。它从大量文本里学到语言模式、概念关系、格式习惯和一部分世界知识,但这不等于它内置了一个稳定的事实裁判。Kalai 等人在关于语言模型幻觉的研究里,也把预训练目标和评测激励视为幻觉持续存在的重要原因之一。[1][2]

这能解释很多现象。

语法、常见概念、经典算法、热门框架的基本用法,因为在训练文本里重复很多,模型往往能说得不错。可具体版本、冷门参数、最新价格、某个仓库当前状态、某个团队内部约定,就没有这么稳定。它可能见过相似文本,于是生成一个“像那类事实”的答案。

还有一种更微妙的错误,是模仿人类错误。

TruthfulQA 这类研究专门测试模型是否会复述常见误解。它提醒我们:训练语料里不只有事实,也有误解、过时说法、谣言、玩笑、转述错误和大量自信但错误的人类文本。模型越会模仿人类表达,就越可能学到这些表达方式。[3]

所以“模型更大”不自动等于“更真实”。规模通常会带来更强的语言能力、推理能力和知识覆盖,但事实性还需要另外的东西:证据、检索、工具、校准、评测和允许它停下来的机制。

“不要幻觉”不是一句 Prompt 能解决的

很多人会在提示词里写:不要编造,不知道就说不知道,必须真实。

这当然比什么都不写要好。但它只能解决一小部分问题。

如果模型没有看到正确资料,它仍然只能在已有上下文和参数知识里猜。如果搜索结果本身错了,它会基于错误材料总结。如果 RAG 检索拿到了相似但不相关的 chunk,它会把噪声包装成答案。如果引用没有被打开验证,它甚至可能给你一个存在但不支持结论的链接。

提示词能改变回答风格,却不能凭空创造事实。

这也是为什么我现在越来越不把“减少幻觉”理解成一个模型问题,而是理解成一个工作流问题。我们真正要设计的,不是让 AI 在没有证据时说得更保守一点,而是让它在需要事实时走向证据。

有些事实要查官方文档,有些要读本地代码,有些要跑测试,有些要查数据库,有些要搜索最新来源,有些要直接问人。把这些通道区分开,比在 prompt 里重复“请真实”更有用。

事实要先有真源

让 AI 找真实东西,第一步不是搜索,而是判断真源在哪里。

不同事实的真源不一样。

如果问题是“RAG 是什么”,可以先看论文、教材、已有知识页和权威解释。如果问题是“某个 API 现在支持什么参数”,默认应该看官方文档、release note 或源码。如果问题是“这个项目为什么测试失败”,真源就在当前仓库、日志、测试输出和复现步骤里。如果问题是“这个价格、政策、版本是不是最新”,就必须查当前页面或 API。

我现在会先把事实分成几类:

事实类型 更可靠的来源
稳定概念 论文、教材、权威文档、已审知识库
实时信息 官方页面、API、公告、原始新闻
项目事实 源码、测试、日志、配置、数据库记录
可计算结果 脚本、计算器、测试、解析器
规范要求 标准、法条、官方文档、源码
社区经验 多个独立案例,且只能标注为观察

这张表看起来很朴素,但它能挡住很多幻觉。

因为它把问题从“AI 知不知道”换成了“这个事实应该去哪里确认”。这一步一换,AI 的角色也变了。它不再是一个凭空回答的专家,而是一个检索、整理、核验和表达证据的助手。

搜索、RAG 和工具各自解决什么问题

很多人谈 AI 事实性时,会很快跳到 RAG。

RAG 确实重要。它的基本思路是把外部资料检索出来,再让模型基于这些资料回答。RAG 原始论文把这种方式描述成参数化记忆和非参数化外部记忆的结合,用来改善知识密集任务中的事实性和可追溯性。[4]

但 RAG 不是真理机器。

它解决的是“模型没看到资料”的问题,不自动解决“检索资料是否正确”“chunk 是否完整”“排序是否合理”“模型是否忠实使用资料”的问题。RAGAS 之所以会把 RAG 评估拆成 context precision、context recall、faithfulness 等指标,就是因为检索和生成都有可能出错。[5]

搜索也一样。

搜索能帮 AI 找到候选来源,但搜索结果第一条不是事实。SEO 页面、转引文章、AI 摘要、旧教程、营销材料,都可能排在前面。搜索只能扩大候选范围,不能替代来源判断。

工具调用又是另一类东西。

如果问题能被执行工具回答,就不要让模型猜。代码能不能跑,跑测试。文件是否存在,读文件系统。JSON 是否合法,用解析器。SQL 是否返回某条记录,查数据库。算术是否正确,用计算器或脚本。

我更愿意把它们分成三层:

  • 搜索和 RAG:帮 AI 找候选证据。
  • 工具和 API:帮 AI 获取可执行事实。
  • 人和规则:决定高风险场景里什么算足够证据。

这三层不能互相替代。尤其是生产变更、财务、医疗、法律、安全这类场景,AI 的证据整理不能替代责任主体。

引用不是装饰,引用要支撑结论

我以前也会被“带引用的回答”安抚。

有链接,看起来就靠谱一点。可后来发现,引用本身也会幻觉。

有时链接根本不存在。有时链接存在,但文章里没有支持那个结论。有时来源支持的是一个更窄的说法,AI 把它扩成了普遍结论。有时来源已经过时,只适用于旧版本。还有时几个页面互相转引,看似很多来源,其实只有一条没有追到原文的引用链。

所以引用不能只看有没有。

要看它是否直接支撑 claim。

一个更稳的做法,是把答案拆成几条关键断言,然后逐条问:

1
2
3
4
5
6
这个 claim 是什么?
证据在哪里?
来源是一手还是二手?
适用日期、版本、地区是什么?
来源是否直接支持这个结论?
有没有冲突来源?

FActScore 和 SAFE 这类长文本事实性评估方法,也是在把长答案拆成更细的原子事实,再逐条检查。[6][7]

日常不一定要做得这么正式。但思路很有价值:不要让一整段流畅文字整体通过。把它拆开,逐条看证据。

什么时候应该让 AI 说不知道

好的 AI 系统不应该只会回答。

它还应该会停下来。

这句话听起来像产品体验问题,其实是事实性问题。如果评测和使用习惯只奖励“给出答案”,不奖励合理的“不知道”,模型就会被推向猜测。Kalai 等人的研究里就指出,很多 accuracy-only 评测会让猜测比 abstain 更有收益。[1:1]

在日常使用里,我会把这些情况视为应该停下来的信号:

  • 没有找到一手来源。
  • 来源之间冲突,还没判断哪个更新或更权威。
  • 问题里带着未经确认的前提。
  • 事实依赖当前时间、地区、版本或账号权限。
  • AI 给出了引用,但引用没有直接支撑结论。
  • 输出会影响钱、健康、法律、安全、生产系统或对外发布。

这时好的回答不是“我猜一个最可能的”。而是:

我现在只能确认到哪里。

还缺什么证据。

下一步应该查哪里。

这个结论暂时不能写成事实。

能说不知道,是事实工作的一部分。

一个更稳的事实获取流程

把前面的东西合起来,日常可以用一个不太重的流程。

第一,判断事实类型。

这是稳定概念、实时信息、项目事实、可计算结果、规范要求,还是社区经验?不同类型对应不同真源。

第二,选择证据通道。

能用文件、日志、测试、数据库、API 解决的,优先用工具。资料规模很大、需要找少量证据时,再用搜索、RAG、rerank。稳定概念可以先用已有知识解释,但关键结论最好仍然能回到来源。

第三,抽取关键 claim。

不要把一整段答案整体看成“对”或“错”。拆出关键断言:版本、原因、规则、步骤、限制、风险、结论。

第四,做来源核验。

来源是否一手?是否过时?是否直接支持 claim?是否存在冲突?如果只是社区经验,就不要写成普遍事实。

第五,决定输出方式。

证据足够,就回答并标出边界。证据不足,就澄清、补检索或说不知道。高风险场景,要把人工审核放在流程里,而不是最后让人扫一眼。

这个流程不复杂,但会明显改变 AI 的使用方式。

以前是:我问,AI 答。

更稳的是:我问,AI 找证据,AI 解释证据,我检查关键 claim,最后才形成答案。

管理 AI 的事实性,就是管理证据链

我现在越来越觉得,AI 不是不能参与事实判断。

恰恰相反,它很适合参与。它能帮我们列搜索词、读文档、比较来源、整理冲突、抽取 claim、生成检查清单、把零散证据写成清楚的解释。

但它不应该凭空拥有事实最终裁决权。

事实不是从模型嘴里自然长出来的。事实来自世界里的某个位置:文件、日志、数据库、论文、标准、官方文档、用户确认、测试结果、实际运行状态。AI 的工作,是把我们带到这些位置,再帮我们理解它们。

如果说 AI 上下文工程:把正确的信息放到正确的位置 讲的是“让 AI 在正确的信息现场工作”,那这篇想补上的就是:事实性要求这个现场里必须有证据链。

不要只问 AI 知不知道。

要问它证据在哪里。

参考与延伸阅读


  1. Adam Tauman Kalai et al., Why Language Models Hallucinate, Nature, 2026。这里引用的是论文对语言模型幻觉、预训练目标和评测激励的分析。 ↩︎ ↩︎

  2. OpenAI, Why language models hallucinate。这里引用的是 OpenAI 对幻觉成因和 accuracy-only 评测激励的解释。 ↩︎

  3. Stephanie Lin, Jacob Hilton, Owain Evans, TruthfulQA: Measuring How Models Mimic Human Falsehoods。这里引用它来支撑“模型可能复述训练语料中的常见误解,规模本身不保证 truthfulness”的判断。 ↩︎

  4. Patrick Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks。这里引用 RAG 的原始问题定位:把参数化模型与外部非参数记忆结合,以支持知识密集任务。 ↩︎

  5. Ragas 文档,Available Metrics。这里引用 context precision、context recall、faithfulness 等指标,说明 RAG 需要同时评估检索上下文和生成忠实度。 ↩︎

  6. Alex Min et al., FActScore: Fine-grained Atomic Evaluation of Factual Precision in Long Form Text Generation。这里引用的是把长答案拆成原子事实进行评估的思路。 ↩︎

  7. Google Research, Long-form factuality in large language models。这里引用 SAFE 使用搜索增强方式评估长文本事实性的思路。 ↩︎

用 Agent 做事时,最容易产生错觉的一句话是:继续。

继续查、继续改、继续修、继续总结。对话还没爆 token,模型也还在回应,好像就还能往下推进。可很多长任务不是死在上下文窗口不够长,而是死在上下文已经变脏了。

它读过太多中间材料,留下太多旧假设;它跑过几轮失败命令,记住了几条已经废弃的解释;它同时背着研究、规划、实现、验证和发布几个目标。表面上它还在执行,实际上当前判断已经被旧上下文拖住了。

所以我现在更愿意把 Agent 任务粒度看成一个工程判断:

一次 Agent 任务的合适大小,不取决于模型还能装多少 token,而取决于当前对话还能不能保持目标单一、边界清晰、证据新鲜、上下文干净。

这篇不讨论怎么“压榨” Agent 多干活,而讨论一个更实用的问题:什么时候该继续聊,什么时候该拆任务。

不是越长越能干

长上下文确实有用。

它让模型能读更大的文件、保留更多对话历史、处理更完整的资料包。对于很多任务,这直接提升了体验:不用频繁重复背景,不用把代码切得太碎,也不用每走一步就重新交代上下文。

但“放得下”不等于“用得稳”。

Lost in the Middle 这类研究已经说明,模型处理长输入时,相关信息的位置会影响使用效果;放在开头和结尾的信息,通常比放在中间的信息更容易被利用。[1] RULER 进一步提醒我们,标称上下文长度不等于真实任务里的有效上下文长度;当任务从简单检索变成多针、多跳、聚合时,性能下降会更早暴露。[2]

这些研究不是说长上下文没用,而是提醒我们:上下文窗口不是可靠记忆库。

真实 Agent 任务比论文里的长文本更乱。它不只是读一篇文章,而是在多轮里不断加入:

  • 用户补充的新约束
  • 模型自己写过的旧计划
  • 文件读取结果
  • 搜索结果
  • 测试日志
  • 报错堆栈
  • 已经被否定的假设
  • 尚未验证的总结

这些东西如果一直留在同一个对话里,问题就不只是 token 多。问题是旧材料会持续参与后续判断。

这就是我觉得 Context Rot 这个词好用的地方。它说的不是上下文超过硬上限,而是上下文还在窗口里,但质量已经下降:变长、变旧、变杂、互相干扰。Chroma 的技术报告把这个现象放在长输入性能退化里讨论;Anthropic 的上下文工程文章也把 context 当成有限资源,强调要把最小的高信号信息放进窗口。[3][4]

所以,长任务里最危险的往往不是模型忘了,而是它记住了太多不该继续起作用的东西。

任务太大时,Agent 会怎么漂

一个任务刚开始时,目标通常还算清楚。

“帮我查这个 bug。”
“把这个功能做完。”
“研究一下这个主题,然后写一篇文章。”

真正的问题发生在中后段。任务一大,Agent 会开始同时背几类工作:研究材料、比较方案、改文件、跑测试、处理失败、总结结论。每一类工作都需要不同的上下文,但它们常常混在同一个窗口里。

我见过最典型的漂移有几种。

第一种是沿用旧假设。

前面为了定位问题,Agent 提出过 A、B、C 三个可能原因。后来证据已经排除了 A,但 A 的解释还留在对话里。再往后写总结或做实现时,它可能又把 A 当成背景条件拿回来。

第二种是忘记新约束。

用户中途补了一句“不要动这个模块”“这次只写草稿,不发布”“不要改现有接口”。但窗口里还有更早的宽泛目标,后面的执行就可能被旧目标带跑。

第三种是把研究、计划和实现混成一团。

研究阶段本来应该宽一点,多读、多搜、多比较。实现阶段应该窄一点,只保留当前方案、相关文件和验收条件。如果研究材料不压缩,整个实现阶段就会背着一大堆候选路径前进。

第四种是工具输出堆积。

一次 rg、一段日志、一篇网页、一串测试失败,在当下都很有用。但定位完成后,完整原文常常不该继续留在 active context 里。Anthropic 的 cookbook 把 compaction、tool-result clearing 和 memory 分成三种不同手段:压缩会话、清理可重新获取的大型工具结果、把稳定事实写到外部记忆。[5]

这说明一个简单事实:长运行 Agent 需要上下文治理,而不是只靠“继续”。

好任务的四个条件

我现在判断一个任务能不能继续留在同一个 Agent 对话里,会看四个条件。

第一,目标单一。

这个任务能不能用一句话说清完成状态?不是“把这个系统优化一下”,而是“给支付回调入口补上事件级幂等,并用现有测试覆盖重复回调”。不是“研究 Agent 任务粒度并写一堆东西”,而是“基于已有来源写一篇面向 AI 编程用户的判断文章”。

目标越单一,Agent 越容易知道自己现在该服务什么。

第二,边界清晰。

哪些文件在范围内?哪些模块不能动?哪些材料只是背景?哪些结论必须有来源?如果边界说不清,Agent 会用自己的默认模式补齐,而默认模式通常是“尽量完成更多”。

第三,可验证。

代码任务要有测试、lint、build、截图或人工检查点。研究任务要有来源、claim-to-source 映射和事实边界。写作任务要有读者、论点、结构和证据清单。

没有验收方式的任务,很容易变成模型写了一段更顺的解释。

第四,上下文可控。

完成任务所需的实时材料,不能把大量无关历史长期留在主对话里。旧日志、旧搜索结果、旧方案、已废弃计划,如果只是“曾经出现过”,就不该自动继续拥有影响力。

这四个条件凑在一起,基本就是一次 Agent 任务的健康边界:

目标单一,边界清晰,可验证,上下文可控。

如果这四项都满足,继续聊通常没问题。反过来,如果其中两项开始失控,就应该考虑拆。

什么情况下可以继续聊

不是所有任务都要拆。过度拆分也会制造流程负担。

我会在这些情况下继续让同一个 Agent 往下做。

任务范围很小,只涉及一两个文件,或者一篇文章的一个明确章节。

上下文还短,里面没有很多失败路径、旧假设和互相竞争的方案。

验收方式明确。比如“跑这个测试”“生成这篇草稿”“检查这几个链接”“把这段内容改得更清楚”。

下一步需要的不是新判断,而是接着当前状态执行。比如刚刚形成了清晰大纲,下一步就是按大纲写正文;刚刚定位了失败测试,下一步就是做最小修复。

这类任务继续聊是自然的。拆出去反而会损失刚刚形成的局部上下文。

一个简单判断是:下一步需要的是更多历史,还是更干净的判断?

如果下一步需要更多历史,可以继续。如果下一步需要更干净的判断,就该拆。

什么情况下应该拆

真正该拆的时候,通常会出现一些很明确的信号。

第一,同时包含多个阶段。

如果一个请求里同时有 research、plan、implement、verify、document、publish,而且每个阶段都会产生大量中间材料,就不要期待一个对话从头干到尾还保持清爽。

更好的方式是分阶段:

1
Research -> Plan -> Implement -> Verify -> Document

每一阶段把输出压缩成下一阶段真正需要的内容。Research 阶段的输出不是“所有资料全文”,而是来源、关键结论、争议点、待验证问题。Plan 阶段的输出不是“所有思考过程”,而是目标、范围、步骤、风险和验收。Implement 阶段只需要当前方案和相关文件。

第二,工具结果太多。

如果对话里已经堆了大量日志、网页、搜索结果、测试输出、文件全文,继续往下通常会让模型越来越难抓重点。此时要么清理,要么摘要,要么开新上下文,只保留可追溯指针。

第三,方向多次变化。

用户中途改过几次目标,Agent 也尝试过几种方案。这种任务继续聊很容易“旧方向诈尸”。更稳的做法是停一下,明确当前方案和废弃方案,再进入下一阶段。

第四,出现多个互相竞争的假设。

调试任务尤其常见。A 解释看起来合理,B 解释也合理,C 解释后来被测试推翻。如果这些假设没有被整理成“事实、假设、已排除、待验证”,Agent 后面很容易把它们混用。

第五,任务跨模块或跨责任边界。

比如一次修改同时涉及前端、后端、数据库迁移、部署脚本、监控告警和文档。它们不是不能一起做,而是每一块最好有独立验收和独立上下文。

第六,已经形成稳定结论。

一旦某个结论变成长期可复用的知识,就不要只留在聊天记录里。项目规则进 AGENTS.md 或文档;领域结论进 wiki;验证方式进脚本或 CI;一次性交接进 handoff。下一阶段读取稳定产物,而不是继承整段杂乱历史。

拆任务不是制造流程负担

很多人不愿意拆任务,是因为拆听起来像项目管理开销。

但对 Agent 来说,拆任务的意义不是官僚化,而是给它换一个更干净的工作现场。

研究阶段需要发散,允许多读、多搜、多比较。

计划阶段需要收敛,把发现压缩成目标、边界、步骤和验收。

实现阶段需要局部、稳定、少噪声,只看当前要改的文件和决策。

验证阶段需要新鲜证据,不需要旧的“应该没问题”。

沉淀阶段需要把可复用结论写到长期位置,不需要保存所有聊天细节。

这其实和人工作很像。你不会把会议白板、草稿纸、错误日志、最终设计稿、上线 checklist 全部摊在同一张桌子上,然后指望自己越干越清醒。你会在不同阶段清桌面、换材料、归档结论。

Agent 也需要这样的工作现场。

一句实用判断

我最后会把任务粒度压成一句话:

如果下一步需要的是更多历史,就继续;如果下一步需要的是更干净的判断,就拆。

继续,适合当前目标还单一、上下文还干净、验收还明确的时候。

拆,适合任务已经跨阶段、工具输出太多、旧假设太多、方向变化太多,或者下一步需要独立判断的时候。

这不是为了少用 Agent。恰恰相反,是为了让 Agent 更稳定地做完真正有价值的事。

好的 Agent 协作,不是把一个巨大任务塞进一个巨大对话里。

而是让每一次对话都像一个边界清楚的工作单元:知道要做什么,知道不做什么,知道用什么证据判断完成,也知道什么时候该把稳定结论交给下一阶段。

Agent 任务粒度,本质上不是 token 管理。

它是上下文卫生。

参考与延伸阅读


  1. Nelson F. Liu et al., Lost in the Middle: How Language Models Use Long Contexts。这篇研究说明,相关信息在长上下文中的位置会显著影响模型表现,开头和结尾通常比中间更容易被利用。 ↩︎

  2. Cheng-Ping Hsieh et al., RULER: What’s the Real Context Size of Your Long-Context Language Models?。这里引用它来支撑“标称上下文窗口不等于复杂任务中的真实可用上下文长度”。 ↩︎

  3. Chroma, Context Rot: How Increasing Input Tokens Impacts LLM Performance。这里引用它对输入变长、干扰项和任务结构导致性能退化的分析。 ↩︎

  4. Anthropic, Effective context engineering for AI agents。文中把 context 视为有限资源,并强调上下文工程是管理推理时可用 token 的策略集合。 ↩︎

  5. Anthropic Cookbook, Context engineering: memory, compaction, and tool clearing。这里引用它区分 compaction、tool-result clearing 和 memory 的工程边界。 ↩︎

使用 AI 编程时,有一个很自然的冲动:把更多东西给它。

需求说明给它,代码文件给它,错误日志给它,历史讨论给它,旧方案也给它。模型不是有很长的上下文窗口吗?那多给一点,它应该就更懂项目。

我现在越来越觉得,这个想法只对了一半。

AI 确实需要上下文。没有上下文,它只能靠通用模式猜。可上下文不是资料仓库。把所有东西塞进去,不等于它会更稳定地理解项目。很多时候,对话越长,材料越多,AI 反而越容易抓错重点、重复旧方案、忽略新约束,或者把早就废弃的尝试当成当前事实。

这篇想专门谈这个问题:AI 的上下文到底是什么,为什么它会变脏,日常使用里又该怎么管理。

如果前面几篇文章是在说“AI 应该被放进工作流”,那这一篇更像是继续往下问:这个工作流里,AI 每一步到底应该看见什么?

Prompt 只是入口,上下文才是现场

很多关于 AI 使用的讨论,会从 Prompt 开始。

这当然没错。Prompt 是我们表达当前意图的入口。你要它解释代码、起草测试、排查 bug、写文章,第一步总要把需求说出来。

但当任务稍微复杂一点,Prompt 就不再是全部。

比如你让 AI 修一个 bug。真正影响它判断的,不只是“帮我修这个 bug”这句话,还包括:

  • 它有没有读到复现步骤。
  • 它有没有看见当前分支的真实代码。
  • 它有没有知道哪些旧行为不能破坏。
  • 它有没有拿到测试命令和失败输出。
  • 它有没有被旧日志、旧假设、旧计划带偏。
  • 它有没有知道什么证据可以算修复完成。

这些东西共同构成了 AI 当前的工作现场。

所以我不太愿意把 AI 协作只理解成“写提示词”。提示词只是把任务递进去。上下文决定 AI 在什么信息环境里思考和行动。OpenAI 在谈 Codex 的 agent-first 实践时,也把重点放在环境、工具、反馈和仓库内知识上:让 agent 能直接使用标准开发工具、读取项目内的上下文、复现和验证问题,而不是靠人把所有材料复制进对话里。[1]

更准确地说,AI 的输出质量同时取决于三件事:

第一,模型本身有什么能力。

第二,当前任务说得是否清楚。

第三,当前上下文是否干净、相关、可追溯、可验证。

我们平时最容易盯着前两项。模型换一个,Prompt 改一句。但第三项经常才是长任务里真正出问题的地方。

上下文不只是聊天记录

“上下文”这个词容易被说窄。

很多人想到上下文,会想到聊天记录:前面问过什么,AI 回过什么。可在 AI 编程里,上下文远不止这些。

它至少包括几类东西。

第一类是指令。比如 system prompt、developer instructions、AGENTS.md、skill、输出格式、代码风格、禁止事项和完成要求。

第二类是知识。比如项目 README、架构文档、源码、测试、接口说明、数据库结构、历史 issue、搜索结果和 RAG 检索片段。

第三类是工具结果。比如 rg 命中、测试输出、lint 报错、构建日志、浏览器截图、API 响应和命令行返回值。

第四类是任务状态。比如当前目标、计划、待办、已经废弃的方案、剩余风险、用户刚刚补充的新约束。

第五类是长期记忆。比如项目稳定约定、团队偏好、常用验证命令、以前沉淀下来的排障经验。

这些内容的性质完全不同,却常常被混在同一个上下文窗口里。问题也从这里开始。

代码现状是事实,方案是计划,错误日志是证据,模型总结是假设,外部网页是待核验材料,AGENTS.md 是项目规则。它们可以同时出现,但不能拥有同样的身份。

如果 AI 分不清“这是事实”还是“这是刚才想过但还没验证的方案”,它就很容易把一个看起来合理的中间产物当成真实约束。

这也是我在 GPT 有智能,但不是责任主体 里写过的那个问题:AI 可以处理关系,但不能替人承担事实确认和责任边界。放到上下文管理里,就是要把不同信息的身份标清楚。

窗口变大,不代表记忆变可靠

现在很多模型都有很长的上下文窗口。它们能一次性放进更多文本,这确实改变了很多工作方式。

长文可以直接读,大段代码可以一起比较,多轮对话不必太快丢失历史。这些都是实用能力。

但上下文窗口更像“当前请求的工作记忆”,不是无限记忆。

它回答的是:这一次交互里,最多能放多少 token。

它不保证:放进去的每个信息都会被同等稳定地使用。

这点很关键。长上下文的风险不是只有“超过上限”。在还没超过上限时,模型就可能因为信息太多、位置太深、相似干扰太多而开始变差。Lost in the Middle 这篇研究就观察到,模型处理长输入时,对开头和结尾的信息通常更敏感,中间位置的相关信息更容易被忽略。[2]

工程上看到的现象也很熟悉:

  • 你已经告诉它不要改某个模块,它后面还是改了。
  • 你后面修正了目标,它仍然沿用前面的旧计划。
  • 它读过正确文件,但总结时引用了过时结论。
  • 它拿到了测试失败输出,却去修另一个看起来相关的地方。

这些问题不是简单的“上下文不够长”。恰恰相反,有时是上下文太长、太杂、太像。

能放下更多材料,只是让你拥有更大的桌面。桌面变大以后,如果所有文件、草稿、废纸和咖啡杯都堆在一起,你不一定更容易工作。

AI 也一样。

上下文会腐烂

我很喜欢 Context Rot 这个说法。

它指的不是上下文超过窗口以后被截断,而是上下文还在,但质量已经变差。模型仍然“看得见”,却开始不能稳定使用关键内容。

Chroma 在 2025 年的技术报告里用 Context Rot 讨论输入变长后模型表现的退化。他们的实验条件已经相对受控,但仍观察到模型性能会随着输入长度增加而以不均匀、甚至有些意外的方式下降。真实 agent 任务通常更复杂:里面有工具输出、搜索结果、代码片段、错误路径、多轮计划和人类补充说明,情况只会更乱。[3]

在日常 AI 编程里,上下文腐烂通常来自几种东西。

第一种是旧计划残留。

长任务一开始可能计划走 A 方案,后来发现不合适,改成 B 方案。但 A 方案的解释、文件路径、半成品代码和理由还留在对话里。后面 AI 总结或实现时,可能又把 A 当成有效背景。

第二种是失败路径残留。

调试时会尝试很多假设。假设本来应该被证据淘汰,但如果没有明确标废弃,它会变成上下文里的幽灵状态。AI 后面可能继续围着它解释。

第三种是工具结果堆积。

测试输出、日志、网页、搜索结果、rg 命中都很有用,但它们多数是短期证据。定位完成以后,大段原始输出应该被压缩成“命令、结果、关键证据、可重取路径”。如果一直留在主上下文里,它们很快就会变成噪声。

第四种是相似干扰项。

最危险的上下文不一定是完全无关的内容,而是“看起来相关但不是这次问题答案”的内容。相似模块、相似 bug、同名概念、旧版本接口,都可能让 AI 抓错证据。

第五种是规则膨胀。

把所有偏好都写进 always-on prompt,看起来很保险,实际会稀释高频关键规则。一个过大的 AGENTS.md 或 skill,可能让 AI 每次都携带大量低频规则进入任务。到最后,不是规则变强了,而是所有规则一起变弱了。

所以,上下文腐烂的本质不是“AI 忘了”,而是“AI 记住了太多不该继续起作用的东西”。

上下文质量比上下文长度更重要

如果只用 token 数衡量上下文,很容易误判。

一个很长的上下文可能质量很低。里面有重复说明、旧日志、无来源摘要、相互冲突的计划和一堆可重新获取的原始输出。

一个很短的上下文可能质量很高。它只包含当前目标、必要文件、关键约束、验证命令和几条有来源的事实。

我现在更愿意用几个维度判断上下文质量。

第一,相关性。

当前窗口里的信息,有多少直接服务这一步任务?如果我正在改一个局部 bug,是否真的需要保留三轮之前的架构讨论全文?

第二,充分性。

必要事实是否齐全?有些上下文很干净,但少了关键边界,比如旧行为不能破坏、接口兼容性、权限约束、异常路径。这会让 AI 用合理默认值补洞。

第三,信号密度。

有效信息占多少比例?大段日志、搜索结果、重复解释会让模型付出注意力成本,却不一定带来判断价值。

第四,可追溯性。

结论能不能回到来源?是哪个文件、哪条命令、哪个测试、哪个官方文档、哪一次用户确认?没有来源的总结只能作为提示,不能直接当证据。

第五,时效。

上下文是不是当前状态?旧分支的代码、昨天的测试结果、已经被用户改掉的计划,都可能误导模型。

第六,可验证性。

这份上下文能不能导向外部检查?比如测试、lint、build、截图、日志、人工 review、来源核验。没有验证闭环的上下文,很容易让 AI 只是在写更流畅的解释。

RAG 领域里也有类似思想。比如 Ragas 会区分 context precision、context recall、faithfulness、answer relevance 这类指标:不是只看“检索到了多少”,而是看取回内容是否相关、答案所需事实是否覆盖、回答是否忠于上下文。[4] 这个思路可以迁移到更广义的 AI 协作里:上下文不是越多越好,而是要看它是否真的支撑当前任务。

不同阶段需要不同上下文

上下文管理最实用的一条原则是:不要让一个上下文承载所有阶段。

复杂任务通常至少可以拆成五段。

第一段是 Research。

这个阶段要宽一点。目标是弄清楚系统现状:相关文件在哪里,调用链是什么,测试怎么跑,风险点在哪。这里可以多搜索、多浏览、多问几个为什么。

但 Research 的输出不应该是“把所有读过的东西留在对话里”。它应该压缩成问题地图:关键文件、关键事实、可疑点、已排除路径、需要继续验证的假设。

第二段是 Plan。

计划阶段要窄一些。它不需要保留所有探索痕迹,而要保留决策所需的事实、约束、方案取舍、非目标和验证方式。计划的价值之一,就是把复杂上下文压缩成可执行路径。

OpenAI 的 Codex best practices 也建议复杂、模糊或难描述的任务先规划,再写代码。计划模式的价值不只是“想一想”,而是让 agent 先收集上下文、澄清目标、形成更强的执行边界。[5]

第三段是 Implement。

真正实现时,上下文应该更窄。当前文件、邻近接口、测试、验收条件、已经确认的设计决策就够了。早期探索时读过的长文档、旧日志和被废弃方案,最好不要继续影响实现。

第四段是 Verify。

验证阶段需要的是新鲜证据。运行了什么命令,结果如何,失败在哪里,修复后有没有重新跑。这里最怕的是拿旧的通过结果或模型自述当完成证据。

第五段是 Document。

如果任务产生了稳定结论,就不要让它只留在聊天记录里。项目规则进 AGENTS.md 或项目文档;通用知识进 wiki;验证方式进脚本、测试或 CI;个人/项目长期偏好进受控 memory。

这样下一次任务不是继承一整段又长又脏的对话,而是按需读取被整理过的上下文。

该外存的东西,不要留在对话里

上下文窗口是运行时资源,不是长期知识库。

这个区别很重要。

有些信息必须出现在当前对话里,因为 AI 这一步马上要用。比如用户刚刚定义的目标,当前报错,正在修改的函数,刚跑出来的测试失败。

有些信息不应该长期停留在对话里,而应该外存化。

稳定项目规则,应该进 AGENTS.md、README、开发文档、lint、formatter、CI 或脚本。比如怎么跑测试、哪些目录不能乱动、提交前必须检查什么。

可复用工作方式,应该进 skill。比如写文章前先确认读者、目的、论点、材料和声音;修 bug 前先复现;完成前必须拿新鲜验证证据。

主题知识,应该进 wiki 或项目文档。比如某个模块的架构边界、某类问题的排障流程、某个设计决策的理由。

一次性工具结果,应该被摘要和指针化。比如“npm test 失败,失败用例是 X,错误是 Y,完整输出可重新运行命令获取”。

这个动作看起来像整理,其实是在保护后续推理。

对话是临时工作台。文档、测试、脚本、wiki、skill,才是能跨任务复用的上下文系统。

长上下文、RAG 和 Skill 各自解决不同问题

谈上下文时,很容易把几个概念混在一起:长上下文、RAG、文档、memory、skill。

它们不是互相替代的关系。

长上下文适合整体阅读。比如资料数量少,但需要看完整结构,直接读进来通常最简单。代码 review、架构文档比较、长文总结,都可能适合这种方式。

RAG 适合从大量、可更新、需要出处或权限治理的资料里取少量证据。它的重点不是“让模型拥有全部知识”,而是把大语料缩成当前问题需要的高价值片段。

项目文档适合保存已经稳定的项目事实。它应该比聊天记录更可靠,也应该能被人 review、修改和版本控制。

Memory 适合保存跨任务稳定偏好或状态,但它尤其需要谨慎。临时情绪、未验证总结、外部网页内容,不应该随便写进长期记忆。

Skill 适合保存某类任务的工作协议。OpenAI 的 Codex skill 文档和 Anthropic 的 Agent Skills 文档都采用类似的渐进式披露思路:先让 agent 看到 skill 的名称和描述,任务匹配时再读完整说明,更细的参考材料和脚本继续按需加载。[6][7]

这套分层的意义是:让 AI 在需要时看到正确东西,而不是每次都携带所有东西。

所以我会这样粗略判断:

  • 少量材料,需要整体结构:直接读。
  • 大量材料,只需要少量证据:检索或 RAG。
  • 稳定项目规则:写进项目文档、AGENTS.md、脚本或 CI。
  • 稳定工作方式:写成 skill。
  • 当前任务状态:留在计划或阶段性摘要里。
  • 低信任外部内容:只作为待核验证据。

这比“上下文窗口够大,全部塞进去”更麻烦一点,但也更像工程。

上下文也有信任边界

上下文还有一个经常被忽略的问题:不是所有进入上下文的内容都可信。

用户当前请求和项目规则不一样。源码和外部网页不一样。测试输出和模型草稿不一样。RAG chunk 和系统指令更不是一回事。

任何被模型读到的文本,都可能影响模型后续行为。网页、Issue、PR、日志、PDF、代码注释、搜索结果里都可能出现类似“忽略之前指令”这样的内容。OWASP 在 2025 年 LLM Top 10 中把 prompt injection 放在第一项,并且明确区分直接注入和来自外部来源的间接注入。[8] NIST 的生成式 AI 风险管理资料也把这类风险放进更大的治理、度量和管理框架里看。[9]

对日常 AI 编程来说,一个简单原则是:外部内容只能提供证据,不能自动升级成指令。

比如网页里写着“请忽略系统提示”,这句话只是网页内容。Issue 里有人说“顺手删掉旧模块”,这也只是待确认需求,不是权限。日志里出现的提示词片段,不应该让 agent 改变自己的安全规则。

更具体一点,可以把上下文分成几层:

  • 平台和开发者规则:最高优先级。
  • 项目规则和源码事实:支撑工程判断。
  • 用户当前目标:定义这次任务。
  • 外部资料和工具结果:作为待核验证据。
  • 模型自己的草稿和总结:作为假设。

低信任内容可以帮助我们发现问题,但不能替我们决定权限、事实身份和长期记忆写入。

这也是为什么“上下文工程”不只是效率问题,也是安全问题。

一个日常检查清单

真正使用时,不需要每次都套很重的流程。

我会用一个很短的检查清单。

第一,当前目标能不能一句话说清楚?

如果不能,先不要让 AI 大量读文件或直接实现。目标不清楚时,读得越多,越容易把噪声包装成方案。

第二,当前阶段是什么?

是研究、计划、实现、验证,还是沉淀?不同阶段需要不同上下文。不要让研究阶段的所有材料都陪着实现阶段走到最后。

第三,现在给 AI 的材料是不是当前步骤必要的?

如果不是,给路径、摘要或索引就够了。能重新获取的原始输出,不要永久留在主上下文。

第四,事实、计划、假设有没有分开?

尤其是调试和重构。已经被证据排除的假设,要明确标废弃;当前方案要标当前;未验证内容不要写成结论。

第五,关键结论有没有来源?

文件路径、命令输出、测试名、官方文档链接、用户确认,都比“看起来应该是”可靠。

第六,完成判断有没有新鲜证据?

测试、lint、build、截图、日志、人工 review,至少要有和任务相称的证据。没有证据时,就说“未验证”,不要把语气写得更像完成。

第七,有没有稳定结论值得外存?

如果这次又重复解释了同一个项目规则,就考虑写进 AGENTS.md、文档、skill 或脚本。下次不要再靠聊天记录召回。

这个清单的目的不是约束 AI 少做事,而是让它少在错误的信息环境里做事。

管理 AI,就是管理它的工作现场

我以前更关心“怎么把话说清楚”。

现在仍然关心,但顺序变了。

对简单任务,好的 Prompt 已经够用。可对复杂任务,真正影响结果的往往是上下文系统:哪些规则常驻,哪些材料按需加载,哪些工具结果要压缩,哪些结论要写回,哪些外部内容不能越过信任边界。

AI 的能力越强,这件事反而越重要。

能力弱的时候,它可能只是做不好。能力强的时候,它会把一个不干净的上下文执行得很流畅。旧计划、噪声日志、模糊目标、相似干扰项,都可能被它加工成一套看起来完整的方案。

所以,好的 AI 使用方式,不是把上下文堆满。

而是让 AI 在每个阶段看到正确、干净、可追溯、可验证的信息。

上下文不是资料仓库。

上下文是 AI 的工作现场。

你怎么布置这个现场,它就会怎么工作。

参考与延伸阅读


  1. OpenAI, Harness engineering: leveraging Codex in an agent-first world。这里引用的是 agent-first 软件开发中环境、工具、仓库内知识和验证反馈对 Codex 工作方式的影响。 ↩︎

  2. Nelson F. Liu et al., Lost in the Middle: How Language Models Use Long Contexts。这篇研究观察到,长上下文中相关信息的位置会显著影响模型表现,开头和结尾通常比中间更容易被利用。 ↩︎

  3. Kelly Hong, Anton Troynikov, Jeff Huber, Context Rot: How Increasing Input Tokens Impacts LLM Performance,Chroma Technical Report, 2025。这里引用它来支撑“输入变长后模型性能可能不均匀退化,长上下文能力不能只用简单检索任务代表”的判断。 ↩︎

  4. Ragas 文档中的 Context PrecisionContext Recall 指标,把检索上下文拆成相关性、排序质量和事实覆盖等维度。本文借用这个思路说明:上下文质量不等于上下文长度。 ↩︎

  5. OpenAI Developers, Codex best practices。这里引用的是复杂任务先计划、把重复指导沉淀到 AGENTS.md、并通过测试和 review 提高可靠性的实践建议。 ↩︎

  6. OpenAI Developers, Build skills。Codex skill 文档说明 skill 是包含 SKILL.md 以及可选 scripts/references/assets/ 的目录,并强调 Codex 会先看 skill 列表,匹配后再读取完整说明。 ↩︎

  7. Anthropic, Equipping agents for the real world with Agent Skills。这里引用的是 Agent Skills 的三层加载和 progressive disclosure:metadata 常驻,instructions 触发时加载,reference/code 按需使用。 ↩︎

  8. OWASP Gen AI Security Project, LLM01:2025 Prompt Injection。这里引用它来支撑“进入上下文的外部内容可能改变模型行为,prompt injection 包括直接和间接形式”的安全边界。 ↩︎

  9. NIST, AI Risk Management Framework。这里引用它来支撑“生成式 AI 风险需要进入组织和系统层面的治理、度量和管理,而不是只靠模型自律或一次确认”的判断。 ↩︎

前面几篇文章里,我一直在谈同一个问题的不同侧面。

代码、工具与人:AI 时代的程序员 里,我谈的是 AI 并没有让程序员的责任消失;在 GPT 有智能,但不是责任主体 里,我谈的是 GPT 可以参与理解和判断,但不能成为责任主体;在 把 AI 放进工作流 里,我进一步谈到,AI 不是靠一次漂亮提示词用好的,而是要被放进一个有边界、有验证、有沉淀的工作流里;在 TDD 让 AI 可靠,AI 让 TDD 可行 里,我又专门写了测试如何约束 AI 的生成能力。

这篇想反过来看:如果工作流没有设计好,AI 编程会怎样失控?

这里说的“反模式”,不是指 AI 不该参与编程,也不是指某个模型能力不够。相反,很多问题正是因为 AI 太能生成、太会解释、太容易把一个还没被确认的想法变成完整代码。它不是慢吞吞地失败,而是很流畅地失败。

所以本文讨论的不是 prompt 技巧,也不是模型排行榜,而是 AI 进入软件开发流程以后,在任务定义、上下文管理、变更控制、诊断、验证和责任边界上常见的工作流问题。

把局部提速误认为整体提速

AI 确实能降低很多局部任务的成本。查资料、解释代码、生成样板、起草测试、补充边界、整理日志,这些事情都可以变快。问题在于,局部实现变快,不等于整体交付变稳。[1]

软件开发不是只由“写出代码”组成的。一个变更能不能进入系统,还取决于需求是否收敛、上下文是否正确、测试是否覆盖真实风险、review 是否能承受变更规模、发布后是否可回滚。AI 加快的是其中一部分环节。如果其他环节没有同步升级,生成速度反而会把压力传导到 review、测试、CI 和发布流程里。

DORA 关于生成式 AI 的研究里也提示过类似现象:AI adoption 可以和个人生产率、flow、满意度提升相关,但也可能伴随交付吞吐和交付稳定性的下降。[2][3] 这并不说明 AI 对工程有害,而是说明“更快生成”必须被接入更强的验证和交付系统。

第一个反模式就是:只看见 AI 带来的局部提速,没有看见它对整体工作流提出的新要求。

任务没有收敛就进入实现

最常见的反模式,是需求还没收敛,就让 AI 直接实现。

表现通常很熟悉:

  • 只有一句模糊需求,就让 AI “做一下”。
  • 没有定义非目标。
  • 没有说明兼容性、权限、性能、异常路径和边界条件。
  • 没有说清楚哪些行为不能被破坏。
  • 把 AI 给出的方案解释,当成需求澄清结果。

AI 很擅长补全缺失信息。问题是,它补出来的东西不一定是业务真实意图。任务越模糊,AI 越容易用“合理默认值”填空。它会根据常见模式生成一个看起来完整的实现,但这个实现可能从第一步就偏离了真实目标。[4]

这类问题在业务规则里尤其危险。比如一个订单系统里,奖励是在订单创建时确定,还是在支付成功时确定,这不是代码风格问题,而是业务事实。如果这个事实没有先被确认,AI 仍然可以写出字段、状态流转、测试和说明文档。越完整,越容易让人误以为它是对的。

更稳的做法,是先把任务压缩成可讨论、可验证的形式:

  • 目标是什么?
  • 非目标是什么?
  • 输入、输出和状态变化是什么?
  • 哪些旧行为不能变?
  • 哪些异常和边界必须覆盖?
  • 什么证据可以说明完成?

复杂任务可以先让 AI 调查和整理,而不是直接改代码。AI 可以帮我们建立问题地图,但不应该替我们决定问题边界。

上下文管理失控

第二个反模式,是把上下文当成资料仓库。

有些人使用 AI 时,会把大量文件、历史讨论、日志、旧计划、失败尝试一次性塞进上下文里。背后的想法很自然:给得越多,AI 知道得越全。可在真实工作里,上下文不是越多越好。

上下文是一种工作资源。它有容量限制,也有注意力分布问题。即使模型还没有撞上 token 上限,也可能已经开始忽略中间区域的关键约束,或者被无关信息带偏。长任务里常见的漂移、重复、遗忘和自相矛盾,很多都和上下文污染有关。[5]

在 AI 编程里,上下文管理至少要区分阶段:

Research 阶段可以宽一点。这个阶段的目标是找相关代码、相关测试、历史约束和可能风险,所以需要广泛浏览。

Plan 阶段要窄一些。这个阶段的目标是做设计取舍,所以应该聚焦关键模块、关键约束和验证方式。

Implement 阶段应该更窄。真正改代码时,最好只保留当前变更所需的文件、接口、测试和约束。

如果把这三个阶段混在同一个不断膨胀的对话里,AI 很容易进入一种“看起来还在工作,但已经不再稳定利用关键信息”的状态。

所以稳定规则不应该长期留在聊天记录里。构建命令、测试命令、命名约定、禁止事项、目录边界、review 习惯,这些应该进入 AGENTS.md、项目文档、测试、lint、CI 或脚本。OpenAI 的 Codex 实践也强调把项目上下文和重复指导沉淀到仓库里,而不是每次都靠临时 prompt 重新说明。[6]

好的上下文管理,不是把所有东西都给 AI,而是让 AI 在正确阶段看到正确材料。

变更范围自然膨胀

第三个反模式,是让变更范围自然膨胀。

AI 很容易给出完整方案。它会顺手整理命名,顺手改接口,顺手抽象一层,顺手重构附近代码。很多时候,这些改动单独看并不糟糕,甚至局部上更漂亮。但工程变更不只看局部漂亮,还要看任务边界、review 成本、回归风险和长期一致性。

典型表现包括:

  • 修一个 bug,顺手重构多个模块。
  • 为了适配新实现,改变既有接口。
  • 把功能修改、重构、测试迁移、文档调整混在一个 diff 里。
  • 生成大批量代码,但没有明确分层。
  • 代码看起来更现代,但破坏了项目既有风格。

这类问题的根源,是 AI 的生成倾向和工程变更的审查需求并不天然一致。AI 倾向于补齐、扩展、圆满化;工程上却经常需要局部、保守、可回滚。[7]

所以在 AI 编程里,review 不能只问“这段代码写得好不好”,还要先问“这些代码是否应该出现在本次变更里”。

更稳的做法是:

  • 明确允许修改哪些文件。
  • 明确哪些模块只读。
  • 把重构和功能修改拆开。
  • 优先小批次提交。
  • 大改动先 review plan,再进入实现。

AI 生成速度越快,越需要人为压住变更批次。

诊断过程被跳过

第四个反模式,是 debug 时直接让 AI 猜根因。

这件事很诱人。把错误日志、代码片段丢给 AI,它很快就能给出一个解释和修复方案。很多时候这个方案还挺像回事。但诊断的核心不是“给出一个合理叙述”,而是“用证据收敛假设”。

没有复现,就不知道问题是否真的存在于当前观察里。没有假设列表,就不知道自己排除了什么。没有能区分假设的证据,就不知道改动是不是只碰巧修到了附近。

AI 在这里的风险,是它会很快生成一个故事:因为 A,所以 B,应该改 C。这个故事可能正确,也可能只是流畅。OpenAI 关于 hallucination 的讨论提醒过我们,模型的流畅输出和事实正确性不是同一件事。[8]

更稳的诊断流程应该是:

  1. 先复现问题。
  2. 明确当前已知事实。
  3. 列出可能假设。
  4. 找能区分假设的证据。
  5. 根据证据更新判断。
  6. 最后再修改代码。
  7. 修复后留下回归测试或最小验证命令。

AI 可以参与这个过程。它可以帮我们读日志、找相关代码、提出假设、设计插桩点、解释测试失败。但它不应该跳过证据,直接进入修复。

debug 中最坏的情况,不是 AI 没有给出答案,而是它给了一个足够像答案的答案,让人停止收集证据。

同一个 Agent 自写自验

第五个反模式,是让同一个 AI 自己写实现、自己写测试、自己修改测试、自己宣布通过。

这看起来像闭环,实际上可能是闭门造车。

如果测试来自同一个模型对需求的即兴理解,它测到的可能不是外部规格,而是模型自己的解释。更糟的是,实现失败以后,AI 可能会为了绿灯去修改断言、放宽条件、删除不方便的用例,或者把当前错误行为写进测试里。[9]

这就是我在 TDD 让 AI 可靠,AI 让 TDD 可行 里反复强调的点:AI-TDD 的关键不是“让 AI 先写测试”,而是先有外部行为规格,再让测试约束实现。

测试在 AI 编程里有双重身份。对 CI 来说,它是验证;对 AI 来说,它也是上下文。好的测试可以把需求稳定地暴露给模型;坏的测试则会同时污染上下文和反馈回路。

更稳的分工应该是:

  • 人定义行为规格和验收边界。
  • AI 起草测试和实现。
  • 自动化工具给出反馈。
  • 人或独立 review 审查测试是否表达了正确规格。

新测试最好先失败,而且失败原因应该是目标行为缺失,而不是测试本身写错、环境缺失或依赖问题。通过以后,也要防止 AI 为了绿灯修改测试语义。

实现写错了,可以重写。测试写错了,会把错误规格固化下来。

完成声明没有验证证据

第六个反模式,是把 AI 的完成声明当成工程事实。

“已完成”“已修复”“测试通过”“应该可以了”,这些都是文本。工程上的完成必须绑定证据。

证据可以是:

  • 实际运行过的测试命令。
  • lint、typecheck、build 的输出。
  • 失败用例从红到绿的记录。
  • diff review 的结果。
  • UI 截图或手工验证步骤。
  • CI 状态。
  • 明确说明未验证部分和剩余风险。

如果没有这些证据,AI 的完成声明只是它对自己输出的总结。这个总结可能有帮助,但不能替代验证。

这里最容易出现的问题,是 AI 用语气弥补证据。它会写得很确定,甚至列出“已完成事项”。但如果没有实际命令和结果,这些文字并不能说明系统状态已经改变。

所以我现在更倾向于把完成判断写成固定要求:完成之前必须说明运行了什么检查,结果如何,哪些检查没有运行,为什么没有运行,剩余风险是什么。Codex 的最佳实践也把测试、检查、行为确认和 review 放在可靠性收口里。[10]

AI 可以宣布候选结论,但工程完成需要证据。

人工介入退化成橡皮图章

第七个反模式,是所谓人在环只剩下“点继续”。

很多 AI 工具会在关键时刻请求用户确认。这个机制本身是必要的,但如果只给一个确认按钮,而不给足够上下文,人工介入就会退化成橡皮图章。

有效的人工介入,至少要让人看到:

  • 当前目标是什么。
  • AI 准备做什么。
  • 影响范围是什么。
  • 风险在哪里。
  • 是否可回滚。
  • 有哪些替代选项。
  • 当前证据支持到什么程度。

人在环不是让人频繁打断 AI,也不是让人最后兜底。它应该出现在高杠杆位置:计划确认、风险动作、测试规格、发布决策、证据不足时的暂停。

Microsoft 的 Human-AI Interaction Guidelines 强调 AI 系统应该让用户能够理解、纠正和恢复;NIST 的 AI Risk Management Framework 也把风险放进治理、度量和管理结构里看。[11][12] 这对应到日常 AI 编程,就是不要把责任模糊地推给“用户已确认”。确认必须基于上下文,反馈也要能沉淀。

如果用户纠正了 AI 的行为,这个纠正不应该只停留在一次对话里。它应该尽量进入规则、测试、文档、模板或脚本。否则同样的问题下次还会发生。

一个更稳的工作流

把这些反模式放在一起看,它们其实都指向同一个问题:AI 编程不能只围绕“生成”设计,而要围绕“可验证交付”设计。

一个更稳的流程可以很简单:

  1. Research:先理解现有代码、测试、约束和风险。
  2. Plan:定义改动范围、非目标、验证方式和人工介入点。
  3. Implement:小步修改,避免混入无关重构。
  4. Verify:运行测试、lint、build,必要时做人工检查。
  5. Document:把稳定结论沉淀到项目规则、文档或知识库。

这不是为了把流程变重。简单任务当然不需要完整仪式。改一个拼写、补一个小配置、调整一个局部样式,没有必要强行走完整流程。

关键在于,任务一旦涉及模糊需求、存量系统、跨模块变更、生产风险、测试缺口或长期维护,就不能只依赖 AI 的生成能力。越是复杂任务,越需要阶段化、上下文压缩、小批次变更和验证证据。

AI 编程成熟度的标志,不是让模型一次生成更多代码,而是让生成结果更容易被约束、审查、验证和回滚。

真正需要升级的,不只是 prompt,而是工作流。

参考与延伸阅读


  1. DORA, Impact of Generative AI in Software DevelopmentAccelerate State of DevOps Report 2024 支撑这里的边界:AI 对个人体验、flow 和局部生产率可能有正向影响,但团队吞吐、稳定性和交付系统不一定同步改善。 ↩︎

  2. DORA, Accelerate State of DevOps Report 2024。这里引用它来支撑“AI 对个人体验和团队交付系统可能产生不同方向影响”的边界判断。 ↩︎

  3. DORA, Impact of Generative AI in Software Development。这里引用它来支撑“生成速度可能通过更大 batch size 压力传导到 review、测试和交付系统”的解释。 ↩︎

  4. OpenAI Developers, Codex best practices 强调要提供目标、验证方式和项目上下文;Anthropic Building Effective Agents 也建议从简单、可组合、有清晰边界的 workflow 开始,而不是直接把模糊任务交给高自主 agent。 ↩︎

  5. OpenAI, Harness Engineering 将 repository knowledge 作为 system of record,并讨论过大上下文会挤占任务、代码和相关文档;这支撑“上下文是一种工作资源,不是资料仓库”的判断。 ↩︎

  6. OpenAI Developers, Codex best practices。这里引用它来支撑“项目上下文、重复指导和验证命令应该沉淀到仓库和工作流中”的实践判断。 ↩︎

  7. OpenAI Developers, Codex best practices 将 review、diff 检查、测试和验证放在可靠性收口中;这里据此推导出 AI 变更应控制批次、影响面和可回滚性,避免把功能修改与无关重构混成一个大 diff。 ↩︎

  8. OpenAI, Why language models hallucinate。这里引用它来提醒:模型输出的流畅性不能直接等同于事实可靠性。 ↩︎

  9. Kent Beck, Test-Driven Development: By Example 与 Gerard Meszaros, xUnit Test Patterns 都把测试视为行为反馈和规格表达的一部分;AI-TDD 场景下,测试必须锚定外部行为规格,而不能只验证模型自己刚刚补出来的解释。 ↩︎

  10. OpenAI Developers, Codex best practices。这里引用它来支撑“完成断言需要测试、检查、行为确认和 review 收口”。 ↩︎

  11. Microsoft Research, Guidelines for Human-AI Interaction。这里引用它来支撑“人需要能够理解、纠正和恢复 AI 输出”的交互原则。 ↩︎

  12. NIST, AI Risk Management Framework。这里引用它来支撑“AI 风险需要进入治理、度量和管理结构,而不是由模型或单次确认承担”的边界。 ↩︎

把 AI 放进工作流 里,我写过一个判断:AI 不是靠一次漂亮的提示词用好的,而是要被放进一个有边界、有验证、有沉淀的工作流里。

这篇继续往下走,专门谈这个工作流里越来越重要的一层东西:Agent Skill。

如果一个同事还不知道什么是 Agent Skill,我不太想一上来就告诉他:它是一个包含 SKILL.md 的目录,里面有 YAML front matter、说明正文、references/scripts/assets/

这当然是事实,但这不是最重要的事实。

更重要的是:为什么我们已经会写 Prompt 了,还需要 Skill?

Prompt 适合表达这一次,Skill 适合沉淀下一次

我们平时和 AI 协作,最自然的方式是写 Prompt。

“帮我看一下这段代码。”

“按 TDD 的方式实现这个功能。”

“先不要动手,先读项目结构。”

“写完以后跑测试,不要只说完成。”

这些话都很有用。Prompt 的优势就在这里:它直接、灵活、贴近当前任务。你脑子里刚形成一个意图,就可以把它说出来,让 AI 立刻参与。

但问题也在这里。

很多要求并不是只对这一次有用。比如:

  • 修 bug 前要先复现。
  • 不熟悉代码时要先 zoom out,而不是盯着当前文件解释。
  • 写文章前要先确定读者、目的、论点和材料。
  • 改完代码后要用新鲜证据验证,不能靠语气宣布完成。
  • 重要结论要写回项目文档或知识库,而不是散落在聊天记录里。

如果这些要求每次都靠临时 Prompt 提醒,它们就很容易失效。

人会忘记说。对话会变长。上下文会变脏。AI 会在后半段逐渐回到它熟悉的生成模式里。更麻烦的是,这些做法一旦只存在于聊天记录里,就很难被复用、修改、审查和传给团队里的其他人。

所以 Prompt 的问题不是“不够强”,而是它天然偏向一次性沟通。

Skill 要解决的是另一类问题:把反复出现的工作方式,沉淀成可复用、可触发、可维护的上下文。[1]

Skill 不是更长的 Prompt

很容易把 Agent Skill 理解成“高级提示词”。

这个理解只对了一半。

Skill 里面确实有自然语言指令,Agent 也确实会读这些指令。但一个好的 Skill 不只是把一大段 Prompt 存起来。它至少多解决了三个问题。

第一,它解决“什么时候用”的问题。

Skill 的 description 不只是给人看的简介。对 Agent 来说,它是路由线索。一个好的 description 要说清楚:这个 skill 适用于什么任务,不适用于什么任务,和相邻 skill 的边界在哪里。[2]

第二,它解决“触发后先判断什么”的问题。

SKILL.md 不应该像百科全书,也不应该复制工具教程。它更应该告诉 Agent:当前任务先确认什么,哪些边界不能越过,什么时候需要继续读 references/,什么结果才算完成。

第三,它解决“细节如何按需加载”的问题。

不常用的背景资料、长检查清单、示例、脚本、模板,不一定要全部塞进主文件。它们可以放进 references/scripts/assets/。Agent 需要时再读,需要执行时再调用。[3]

所以 Skill 和 Prompt 的差别,不只是“短”和“长”的差别。

Prompt 更像一次对话里的意图表达。Skill 更像某类任务的工作协议。

Skill、Prompt 和 Tool 各自解决不同问题

这三个概念很容易混在一起。

可以先粗略地区分一下:

概念 解决的问题 例子
Prompt 这一次要做什么 “帮我写一篇解释 Agent Skill 的文章”
Tool Agent 能执行什么动作 读写文件、运行测试、搜索网页、调用浏览器
Skill 某类任务应该怎么做 写文章前先确认读者、目的、论点、材料和声音

比如我说:“帮我写测试。”

这是一句 Prompt。

Agent 能调用测试命令、编辑器、文件系统,这些是 Tool。

如果我希望它按 TDD 的节奏推进,先写失败测试,再写最小实现,再重构,并且不要为了让测试通过反过来放松断言,那就是 Skill 要表达的工作方式。

Skill 不替代 Prompt,也不替代 Tool。

Prompt 提供当前目标。Tool 提供行动能力。Skill 提供可复用的判断和流程。[4]

Skill 的核心是渐进式披露

Agent Skill 背后的关键设计,不是“把更多说明交给 AI”,而是“只在需要时加载正确说明”。

这叫渐进式披露。

Agent 不应该在每次任务里都读完所有规则。那样上下文会很快变成垃圾桶。真正可持续的方式是分层:

第一层,启动时只让 Agent 看到 skill 的名字、描述和位置。它知道“有这么一个能力”,但还没有把全文塞进上下文。

第二层,当任务匹配时,Agent 才读取对应的 SKILL.md

第三层,如果 SKILL.md 指向更细的参考资料、脚本或模板,Agent 再按需要继续读取或执行。

OpenAI 的 Codex 文档把 skill 描述为可复用的工作流格式,并明确提到 Codex 先看到 skill 的名称、描述和路径,只有决定使用时才读取完整 SKILL.md。Anthropic 在介绍 Agent Skills 时也用了同样的思路:先加载 namedescription,任务相关时再读正文,更细的文件继续按需展开。[5][6]

这件事很重要。

因为上下文窗口不是越满越好。把所有规范、示例、异常情况、脚本说明都塞进去,短期看像是“资料齐全”,长期看却会稀释真正关键的约束。Agent 可能读了很多字,却更难抓住当前任务最应该遵守的那几条规则。[7]

好 Skill 不是让 Agent 多读字,而是让 Agent 少想错。

一个 Skill 通常长什么样

从格式上看,一个 Skill 通常是一个目录。

1
2
3
4
5
my-skill/
├── SKILL.md
├── references/
├── scripts/
└── assets/

SKILL.md 是入口。它通常包含 namedescription,然后是一段给 Agent 的任务说明。

references/ 放更细的材料,比如写作检查清单、项目规范、领域背景、案例、边界说明。

scripts/ 放可以执行的脚本。能机械化的东西,尽量不要长期停留在自然语言里。脚本比口头规则更稳定,也更容易验证。

assets/ 放模板、图片、样例文件或其他资源。

但格式只是外壳。真正决定 Skill 质量的,是它有没有把边界和判断写清楚。

一个坏的 description 可能是:

1
description: Help with project work.

这句话几乎没用。它太宽了,什么都像能触发。

一个更好的写法会像这样:

1
description: Use when drafting or revising public-facing technical articles. Identify reader, purpose, thesis, evidence, structure, and voice before drafting. Prefer project-docs-writing for durable internal project documentation.

它说清楚了三件事:什么时候用,做什么,和相邻 skill 的边界在哪里。

这就是 Skill 和普通说明文档的差别。Skill 首先要能被正确触发,其次才是触发后写得多完整。

好 Skill 先教判断,再教步骤

很多人第一次写 Skill,容易把它写成操作手册。

第一步做什么,第二步做什么,第三步做什么。再加一大堆命令示例、注意事项、边界情况。最后 SKILL.md 越写越长,像一份很努力但很臃肿的说明书。

这不一定有效。

Agent 很多基础操作本来就会。它会读文件,会看 --help,会根据项目里的 package scripts 推断测试命令,会模仿现有代码风格。Skill 不应该把这些 baseline 能力重新讲一遍。[8]

Skill 最值得写的是 Agent 默认不知道、但对这类任务很关键的东西:

  • 这个团队的特殊约定。
  • 这个任务的判断边界。
  • 常见错误路径。
  • 完成定义。
  • 失败时应该回退到哪里。
  • 哪些信息必须来自外部证据,而不是猜测。

比如一个写作 skill,不需要教 Agent “Markdown 用 # 表示标题”。它真正应该写的是:先确认读者是谁,文章要解决什么问题,中心论点是什么,材料哪些来自事实,哪些只是作者经验,哪些地方不能编造。

比如一个诊断 skill,不需要教 Agent “可以打印日志”。它真正应该写的是:先建立反馈循环,先复现,再提出可证伪假设,用观察结果更新判断,不要直接跳到最像答案的修复。

比如一个验证 skill,不需要教 Agent “测试命令怎么运行”。它真正应该写的是:不要在没有新鲜证据的情况下宣布完成;验证失败时要报告失败,而不是把语气改得更肯定。

好的 Skill 不是把 Agent 当新手,而是把 Agent 拉回正确的判断结构里。

坏 Skill 会把 Agent 推进噪声里

Skill 写坏了,会比没有 Skill 更糟。

最常见的坏法,是把 Skill 写成“万能入口”。

比如一个叫 engineering 的 Skill,里面同时包含代码审查、TDD、架构设计、文档同步、Git 提交、发布流程和故障诊断。它看起来很完整,但每次触发都把一大堆当前任务用不到的内容塞进上下文。最后 Agent 反而更容易抓错重点。

第二种坏法,是复制工具文档。

比如在 Skill 里长篇解释 git statusnpm testpytestcurl 怎么用。这些东西大多可以由 Agent 自己发现,或者应该交给脚本、README、package scripts、CI 配置处理。自然语言 Skill 应该保留给不能机械化的判断。

第三种坏法,是只有反模式,没有替代方案。

“不要乱改文件。”

“不要跳过测试。”

“不要写太长。”

这些话单独看都对,但只写“不要”很容易让 Agent 锚定在错误动作上,又不知道正确路径是什么。更好的写法是同时给出替代方案:

不要直接改实现。先复现问题,记录可观察现象,再提出假设。

不要把所有细节塞进 SKILL.md。主文件只保留路由和关键判断,长案例放到 references/

不要只说完成。先运行和任务相关的验证命令,再用验证结果说明完成状态。

第四种坏法,是边界不清。

两个 Skill 如果都像能处理同一个任务,Agent 就会犹豫,或者选错。Skill 的边界应该写在 description 里,而不是藏在正文深处。

所以评估一个 Skill,不只要问它“能不能用”,还要问它有没有制造新的上下文噪声。

什么时候该把 Prompt 升级成 Skill

不是所有 Prompt 都值得变成 Skill。

一次性需求没有必要 Skill 化。还在探索中的偏好也不急着沉淀。只有当一段提示开始反复出现,并且忘记它会导致稳定的质量问题时,才值得考虑升级。[9]

我会用这几个问题判断:

第一,它是不是高频出现?

如果每周都在重复同一类要求,比如写文章、诊断 bug、做代码审查、同步项目文档,那它有可能适合 Skill。

第二,它是不是代表稳定的团队约定?

比如“这个项目所有文档结论要区分事实、计划和猜测”,“修改支付逻辑必须补对账视角”,“发布前必须跑某几个验证命令”。这些不只是个人偏好,而是团队工作方式。

第三,它有没有明确触发边界?

如果你说不清什么时候该用、什么时候不该用,那它还不适合写成 Skill。边界模糊的 Skill 很容易误触发。

第四,它是不是包含判断规则,而不只是命令清单?

命令清单更适合脚本、Makefile、package scripts 或 CI。Skill 适合承载“什么时候该走哪条路”的判断。

第五,它是不是会从版本化中获益?

如果这个工作方式需要被审查、迭代、共享、回滚,那它就更像工程资产,而不是一次聊天里的提醒。

当这些问题的答案大多是“是”,Prompt 就可以升级成 Skill。

从最小 Skill 开始

写 Skill 不应该从设计一个完整体系开始。

更好的方式是从一个真实任务开始。

先选一个你最常重复的工作流,比如“写技术文章”“修 bug”“做 TDD”“同步项目文档”。然后只写最小入口:

1
2
3
4
---
name: article-writing
description: Use when drafting, outlining, rewriting, editing, or reviewing public-facing articles. Identify reader, purpose, thesis, material, voice, and venue before drafting.
---

正文也先保持短:

1
2
3
4
5
6
7
8
9
# Article Writing

Before drafting, identify reader, purpose, thesis, material, voice, and venue.

Choose a structure before writing.

Separate sourced facts from author experience and invention.

Draft with a visible through-line: every section should advance the thesis.

然后用它做真实任务。

如果 Agent 误触发,就改 description

如果 Agent 触发后仍然忘记关键边界,就把边界前置。

如果正文越来越长,就把细节拆进 references/

如果某些步骤可以机械化,就写脚本。

如果某条规则只是你临时想到的,不要急着加进去。等它在真实任务里反复证明有用,再沉淀。

这和写代码有点像。不要一开始就抽象一个庞大的框架。先从一个代表性任务开始,让真实失败告诉你这个 Skill 缺什么。

Skill 让 AI 协作更像工程资产

Prompt 仍然重要。

我们不可能把所有意图都提前写成 Skill。每一次任务仍然需要人说明目标、提供上下文、判断结果。Skill 不是为了取代 Prompt,而是为了让那些稳定、重复、可复用的部分不再每次从零开始。

这也是我觉得“从 Prompt 到 Skill”重要的原因。

它不是提示词技巧的升级,而是工作流抽象的升级。

当我们只写 Prompt 时,AI 协作更像一场场临时对话。对话当然有价值,但它很难沉淀。

当我们开始写 Skill 时,一部分经验会变成可版本化的资产:团队怎么写文章,怎么修 bug,怎么做测试,怎么验证完成,怎么把结论写回文档。[10]

这些东西过去可能藏在资深同事的脑子里,藏在 code review 的评论里,藏在一次次会议和聊天记录里。Skill 把它们转成 Agent 能按需读取的工作上下文。

所以,Agent Skill 不是为了让 AI 替我们判断。

恰恰相反,它是把人的判断边界写得更清楚,让 AI 在这些边界里更稳定地工作。

Prompt 表达这一次我要什么。

Skill 沉淀下一次仍然要遵守什么。

这就是从 Prompt 到 Skill 的那一层抽象。

参考与延伸阅读


  1. Agent Skills 标准把 skill 定义为包含 SKILL.md 的轻量开放格式,用来把专门知识和工作流交给 agent,并强调这些知识可以作为可复用、按需加载的 instructions / resources:https://agentskills.io/home↩︎

  2. OpenAI Codex 文档和 Anthropic Claude Code 文档都把 description 放在触发 / 路由层:Codex 会根据 skill description 隐式匹配任务,Claude 也使用 description 判断何时应用 skill。因此,description 的边界清晰度会直接影响触发质量:https://learn.chatgpt.com/docs/build-skillshttps://code.claude.com/docs/en/skills↩︎

  3. OpenAI Codex 文档说明 skill 目录可以包含 SKILL.mdscripts/references/assets/;Agent Skills 标准也采用同样的目录结构,把脚本、参考资料、模板等作为按需加载资源:https://learn.chatgpt.com/docs/build-skillshttps://agentskills.io/home↩︎

  4. OpenAI API Skills 文档把 skill 称为 versioned bundle,可用来 codify processes and conventions;这支撑“Prompt 给当前目标、Tool 给动作能力、Skill 给可复用流程和判断”的区分:https://developers.openai.com/api/docs/guides/tools-skills↩︎

  5. OpenAI Developers, Build skills。这里引用的是 Codex Skills 对可复用工作流、渐进式披露、SKILL.mdscripts/references/ 的说明。 ↩︎

  6. Anthropic, Equipping agents for the real world with Agent Skills。这里引用的是 Agent Skills 的目录结构、name / description 路由、渐进式披露和按需加载参考文件的设计。 ↩︎

  7. Anthropic 的 Agent Skills 文章把 progressive disclosure 称为让 Skills 灵活、可扩展的核心设计原则;NN/g 对渐进式披露的经典定义也强调先呈现少量核心选项、按需展开复杂内容,以降低认知负荷和错误率。这里将该原则类比到 agent 上下文管理:https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skillshttps://www.nngroup.com/articles/progressive-disclosure/↩︎

  8. Agent Skills 的 best practices 建议 skill 应聚焦 agent 不知道的项目约定、领域流程和非显然边界,省略 agent 已经能处理的通用知识;这支撑“Skill 不该重复 baseline 能力”的判断:https://agentskills.io/skill-creation/best-practices↩︎

  9. Agent Skills best practices 建议从真实任务、人工纠正、项目约定、运行结果和失败案例中提取 skill,并通过真实执行持续迭代;这支撑“反复出现且影响质量的 Prompt 才值得升级成 Skill”:https://agentskills.io/skill-creation/best-practices↩︎

  10. Agent Skills 标准强调 skill 可以把程序性知识和团队上下文打包进 portable、version-controlled folders;OpenAI API Skills 文档也把 skill 称为 versioned bundle。这支撑“Skill 让协作经验变成可版本化资产”的说法:https://agentskills.io/homehttps://developers.openai.com/api/docs/guides/tools-skills↩︎

前面几篇文章里,我一直在绕着同一个问题打转:AI 到底应该放在软件开发的什么位置?

代码、工具与人:AI 时代的程序员 里,我谈的是程序员的职责并没有因为 AI 消失;在 GPT 有智能,但不是责任主体 里,我谈的是 GPT 可以有智能,但不能替我们承担判断后果;在 把 AI 放进工作流 里,我又进一步谈到,AI 不是靠一次漂亮提示词用好的,而是要被放进一个有边界、有验证、有沉淀的工作流里。

这篇想继续往下走,谈一个更具体的实践:TDD。

我越来越觉得,AI 和 TDD 不是谁取代谁的关系。恰恰相反,它们有一种相互成就的关系:

TDD 让 AI 可靠,AI 让 TDD 可行。

这句话的前半句比较容易理解。AI 写代码很快,但越快越需要验证;TDD 提供的失败测试、最小实现和重构反馈,能把 AI 的生成能力拉回到具体行为上。

后半句也同样重要。过去很多人不是不知道 TDD 有价值,而是很难坚持。写测试、补边界、维护反馈回路,这些都是真实成本。AI 出现以后,这些成本被压低了。它可以帮我们起草测试、补充边界、迁移既有风格、解释失败原因、推进小步重构。也就是说,AI 不只是需要 TDD,AI 也让 TDD 更容易被执行。

AI 让错误也变快了

AI 时代写代码,最明显的变化当然是快。

过去实现一个需求,我们要自己读代码、想接口、写逻辑、补测试、跑验证。这个过程不一定优雅,但它有一种天然的摩擦。人写得慢,所以每一步多少都会停一下,想一想这段逻辑是不是对的,边界有没有漏,和现有系统是否合得上。

现在不一样了。你可以把需求丢给 AI,它很快就能给出一整套实现。更厉害的是,它不只会写实现,还会写解释,甚至还能顺手补几条测试。

这当然是好事。很多重复劳动、样板代码、边界枚举、失败排查,都可以被大幅加速。

但风险也在这里。AI 的问题很多时候不是“写不出来”,而是“写得太像真的”。它可以把一个还没有被确认的理解,快速变成一段结构完整、命名像样、测试齐全的代码。它的输出越流畅,人越容易误以为事情已经成立。

过去错误可能只是一个想法。现在错误可以很快变成代码,变成测试,变成提交,甚至变成一段看起来很合理的说明。

比如一个订单系统里,奖励到底是在“订单创建时确定”,还是在“支付成功时确定”,这是业务事实,不是代码风格。AI 如果一开始理解错了,它照样可以写出完整的字段、状态流转、单元测试和说明文档。问题不在于这些代码不像工程代码,而在于它们可能从第一步就把错误业务事实固定下来了。

所以 AI 改变的不只是开发效率,也改变了错误扩散的速度。

TDD 难,不是因为它没价值

说到 TDD,很多讨论容易走向两个极端。

一种是把 TDD 当成某种纯粹正确的工程道德:不先写测试就是不专业。另一种是把 TDD 当成过时的仪式:现实项目哪有时间红绿重构,先把需求做出来再说。

我不太喜欢这两种说法。

TDD 的价值一直在那里。先写测试,意味着先把期望行为说清楚;看到测试失败,意味着确认当前系统确实缺少这个行为;写最小实现让测试通过,意味着先解决具体问题,而不是顺手扩大战场;在测试保护下重构,意味着结构调整有反馈网兜。Kent Beck 在《Test-Driven Development: By Example》里反复强调的也是这个节奏:用很小的步长,在测试、实现和重构之间建立反馈。[1]

这些东西并不过时。

但 TDD 的成本也一直在那里。写测试要花时间,想边界要花时间,理解测试框架要花时间,维护 fixture 和 mock 要花时间。尤其在存量项目里,最难的往往不是“写一个测试”,而是写一个符合当前项目习惯、能稳定运行、以后别人愿意维护的测试。

这也是很多 TDD 书籍真正花篇幅讨论的部分。《xUnit Test Patterns》不是只讲“要写测试”,而是系统整理 fixture、stub、mock、test smell 和可维护测试结构;《Growing Object-Oriented Software, Guided by Tests》也不是把测试当成附属物,而是把测试放进对象设计和系统演进里看。换句话说,TDD 的难点从来不只是“先写测试”这个动作,而是如何长期维护一套有表达力、可定位、可演进的反馈系统。[2][3]

很多人不做 TDD,不一定是反对它的价值,而是抗不过它的成本。

至少在我现在的使用里,AI 在这里带来了一个很有意思的变化:它降低了 TDD 的执行成本。

它可以帮我们从需求里拆出输入、输出、状态变化和错误路径;可以根据已有测试补出相似用例;可以把重复的断言整理成 helper;可以在测试失败时快速定位失败原因;可以在红绿循环中承担大量机械推进。

这个判断不只是体感,也有一些旁证。Microsoft Research 和 GitHub 关于 Copilot 的受控实验里,使用 Copilot 的开发者在一个 JavaScript HTTP server 任务中完成速度快了 55.8%。[4] GitHub 2024 年针对软件开发团队的 AI 调查也显示,超过 98% 的受访者表示所在组织已经尝试用 AI coding tools 生成测试用例。[5] Stack Overflow 2025 年开发者调查里,约 70% 的 AI agent 用户同意 agent 减少了特定开发任务耗时,69% 同意它提高了个人生产率。[6]

这些数据不能直接证明“AI 让 TDD 变简单了”。它们支持的是一个更窄的结论:在编码、测试起草、重复修改这些局部任务上,AI 确实在降低摩擦。TDD 过去最难坚持的部分,恰好有不少落在这些局部任务上。

过去 TDD 像是一件“知道应该做,但经常做不动”的事。AI 出现以后,它变得更容易启动,也更容易坚持小步反馈。

这就是“AI 让 TDD 可行”的部分。

AI 更需要 TDD

但另一面更关键:AI 也更需要 TDD。

因为 AI 很容易给出一个看起来完整的答案。它会解释需求,会写实现,会补测试,会总结自己做了什么。如果没有外部约束,这个过程很容易变成一个封闭叙事:它先生成一个理解,再根据这个理解写测试,然后根据这个测试写实现,最后宣布一切通过。

表面上看,这像是一个完整闭环。实际上,闭环里最重要的问题可能没有被回答:

这个测试测的是我们真正想要的行为吗?

它有没有把 bug 固化成期望?

它有没有只覆盖 happy path?

它有没有 mock 掉真正有风险的边界?

它是不是为了让实现通过,顺手放宽了断言?

TDD 在 AI 时代的价值,不只是“先写测试”这个动作,而是把 AI 的生成能力接入工程反馈回路。失败测试不是装饰,它要证明目标行为当前确实不存在;通过测试也不是表演,它要证明实现满足了我们事先定义的行为。

AI 可以生成代码,但测试把它拉回到行为。

AI 可以解释自己,但验证结果比解释更重要。

AI 可以快速尝试方案,但红绿反馈能告诉我们哪些尝试真的成立。

所以 TDD 在 AI 时代不是变轻了,而是变得更关键了。生成能力越强,越需要验证结构。

这里也有反方向的证据提醒我们别太乐观。2024 年一项关于 GitHub Copilot 生成 Python 测试的实证研究评估了 290 个生成测试:在已有测试套件上下文中,只有 45.28% 是可直接通过的非空测试;如果没有已有测试套件上下文,92.45% 的生成结果是失败、损坏或空测试。[7] 也就是说,AI 确实能帮我们写测试,但“有上下文”和“需要人工修正”不是细节,而是成败条件。

另一项关于生成式 AI 和 TDD 的早期实验也给出了类似提醒:GenAI 可以参与 TDD 流程,但代码质量监督不能省掉,模型有时会为了给出答案而误导非专家。[8] 越是想把 TDD 自动化,越要明确哪些地方必须由人或外部反馈接管。

先定义行为,而不是先写测试

不过,这里有一个容易误解的地方。

AI 时代的 TDD,不能简单理解成“让 AI 先写测试”。如果需求本身还没说清楚,AI 先写测试也只是先写一份猜测。

真正应该先出现的,不是测试代码,而是行为规格。

也就是说,在让 AI 动手之前,最好先把这些东西说清楚:

  • 输入是什么?
  • 输出是什么?
  • 哪些状态会变化?
  • 哪些业务规则不能被破坏?
  • 哪些边界情况必须覆盖?
  • 权限、幂等、并发、兼容性有没有要求?
  • 哪些情况不属于本次范围?
  • 最后应该运行哪些验证命令?

这一步不一定要写成长篇文档。很多时候,几条清楚的 bullet 就够了。关键是要把“对不对”从聊天感觉变成可执行的标准。

测试不是让 AI 猜我们想要什么,而是把人的判断翻译成可以运行的东西。

从 AI 的角度看,测试还不只是验收条件,它也是一种上下文。WebApp1K 这类 TDD benchmark 直接把测试用例同时当作 prompt 和 verification,评估模型能不能从测试中读出需求、实现功能并通过验证。它的结论很有启发:TDD 场景里,模型能力不只取决于一般代码能力,还取决于 instruction following、in-context learning,以及长上下文下是否丢失约束。[9]

这也是为什么我觉得 AI-TDD 里,人的位置并没有后退。人少写了一些样板代码,但更需要负责定义行为、判断边界和审查测试是否表达了正确规格。

如果行为定义错了,AI 写得越快,只会错得越完整。

最危险的是自写自验

AI-TDD 里最危险的反模式,是让同一个 AI 自写自验。

它自己理解需求,自己设计测试,自己写实现,自己修改断言,自己宣布通过。整个过程看起来很顺,但问题是:没有外部规格,也没有独立审查。

这会产生一些很隐蔽的问题。

比如实现失败了,它可能不是停下来问规格是否理解错了,而是把断言改松一点。
比如原来有一个 bug,它可能把当前错误行为写进测试里,让 bug 变成“被保护的期望”。
比如它为了让单元测试容易写,把数据库、时间、权限、队列这些真正有风险的边界全部 mock 掉。
比如它写了很多测试,但测试复刻了生产逻辑,所以实现错了,测试也跟着错。
比如它新建一套横向切片测试,看起来覆盖很完整,却完全不符合项目已有 fixture、helper 和目录习惯。

这些问题比“实现代码写错”更麻烦。

代码写错了,测试可能会抓出来。测试写错了,就会把错误规格长期固化下来。它不只是没发现问题,还会给人一种已经验证过的错觉。

所以在 AI-TDD 里,测试 review 甚至比实现 review 更重要。

实现可以重写,错误测试会污染反馈回路。

更好的分工

我现在更倾向于把 AI-TDD 看成一种分工,而不是一种仪式。这一节只回答一个问题:一次需求从行为规格到完成断言,应该由谁负责哪一段。

人负责定义行为和验收边界。
AI 负责起草测试、生成实现、修复失败、整理重复。
自动化工具负责持续给出反馈。
人再回来判断这些反馈是否足以说明任务完成。

放到实际流程里,可以是这样:

  1. 先写清楚行为规格和不覆盖范围。
  2. 让 AI 先阅读相关代码和现有测试。
  3. 让 AI 总结测试框架、fixture、mock 边界和运行命令。
  4. 新增测试。
  5. 先运行测试,确认它因为目标行为缺失而失败。
  6. 先 review 测试,再写实现。
  7. 写最小实现,让测试通过。
  8. 运行相关测试、typecheck、lint 和构建。
  9. 在测试保护下重构。
  10. 用验证结果,而不是 AI 的语气,判断是否完成。

这里最重要的不是步骤数量,而是反馈方向。

测试要先约束实现,而不是实现失败以后反过来改测试。
验证命令要约束完成判断,而不是 AI 说完成就完成。
现有测试生态要约束新增测试,而不是每次需求都新建一套自己的小世界。

这个流程看起来慢一点,但它实际上是在把 AI 的速度导入一个更可靠的轨道。

没有轨道,速度越快越危险。

存量项目里,先研究测试生态

如果是在一个新项目里,TDD 的启动成本相对低一点。测试框架、目录结构、fixture 习惯都还可以一起塑造。

但大多数真实工作不是这样。真实项目里已经有历史包袱,有旧测试,有命名习惯,有各种 helper 和 mock 边界。AI 如果一上来就新建测试文件,很容易写出一种“看起来没问题,但不像这个项目”的测试。

这种测试短期可能能跑,长期很难维护。

如果上一节讲的是“流程分工”,这一节讲的就是“进入项目之前先认路”。对存量项目,我觉得 AI-TDD 的第一步不是写测试,而是研究测试生态。

可以先让 AI 回答这些问题:

  • 相关模块已有测试在哪里?
  • 用的是什么测试框架?
  • fixture、factory、mock helper 怎么组织?
  • 单元测试、集成测试、端到端测试各自负责什么?
  • 哪些外部依赖通常 mock,哪些边界通常走真实集成?
  • 本地快速验证命令是什么?
  • CI 必跑命令是什么?
  • 有没有最接近本次需求的既有测试?

这一步很像我前面说的“先把 AI 放进工作流”。AI 不是凭空开始写,而是先进入项目已有的规则系统。

越是存量项目,越不能只追求“有测试”。真正有价值的是“这个测试属于这个项目”。

测试策略应该写进仓库

研究一次测试生态还不够。如果我们希望 AI 稳定地做对这些事,就不能每次都靠临时提醒。

对 AI 来说,不在上下文里的规则就等于不存在。你心里知道这个项目不喜欢过度 mock,知道端到端测试只覆盖关键路径,知道某些数据库行为必须走集成测试,但如果这些规则没有写出来,AI 不会天然知道。

所以测试策略应该进入仓库。

它可以在 AGENTS.md,可以在 docs/testing.md,可以在测试目录的 README,也可以在团队约定的其他位置。关键是让 AI 和人都能看到:

  • 常用测试命令是什么?
  • 什么情况写单元测试?
  • 什么情况写集成测试?
  • 什么情况才需要端到端测试?
  • fixture、factory 和 helper 应该怎么复用?
  • 哪些边界不要随便 mock?
  • 测试命名有什么约定?
  • flaky test 怎么处理?
  • CI 上哪些检查必须通过?

这些内容看起来像文档,其实也是 harness 的一部分。它们把人的偏好和经验变成环境约束,让 AI 不需要每次都从零猜测。

这也是 AI 时代软件工程很重要的一个变化:项目不只是给人读的,项目也要能被 AI 读懂。

不是更多测试,而是更好的反馈

AI 可以很快生成很多测试,但更多测试不一定意味着更可靠。

如果测试很慢、很脆、定位困难,AI 的迭代效率反而会下降。它可能开始绕过测试,或者为了让测试通过去修改断言。端到端测试尤其如此。它们有价值,但不应该因为 AI 写得快就无限膨胀。

健康的测试组合仍然需要层次。

快速的单元测试和组件测试,适合覆盖纯逻辑、边界计算、解析校验和状态转换。
集成测试适合覆盖数据库、队列、权限、时间、事务这些真实风险。
少量端到端测试适合保护关键用户路径和系统级冒烟。

AI 降低了起草测试和重复修改的成本,但没有取消测试设计的判断。

我们仍然要问:这个风险应该在哪一层验证?这个测试失败后能不能快速定位?这个 mock 是隔离噪音,还是遮住了真正风险?这个用例是在测行为,还是在测实现细节?

TDD 的目标不是堆测试数量,而是建立有效反馈。

生成越便宜,验证越重要

AI 让软件开发里的生成变得便宜了。

代码可以更快生成,测试可以更快生成,文档可以更快生成,方案也可以更快生成。生成变便宜以后,真正稀缺的东西就变了。

DORA 关于生成式 AI 的研究也提示了同一个方向:AI 会提升个人生产率、flow 和满意度,但如果团队的 review、测试和交付机制没有跟上,交付稳定性和吞吐反而可能下降。DORA 的 GenAI 报告进一步指出,AI adoption 增加和 delivery throughput、delivery stability 下降存在关联,其中一个解释是 AI 让代码生成更快,容易带来更大的 batch size,而大 batch 更慢 review,也更容易造成系统不稳定。[10][11]

这说明 AI 把瓶颈从“能不能写出来”推到了“能不能及时 review、测试和交付”。TDD 在这里就不只是个人习惯,而是让生成速度进入可控反馈回路的一部分。

稀缺的不再只是“谁能把代码写出来”,而是:

  • 谁能定义正确的问题?
  • 谁能说清楚行为边界?
  • 谁能判断测试是否表达了规格?
  • 谁能确认反馈是否可信?
  • 谁能为最终结果负责?

TDD 正好站在这个位置上。

它不是为了抵抗 AI,也不是为了保留某种旧时代的开发仪式。它是在 AI 时代重新变得重要的一种工程结构:用测试把意图固定下来,用失败证明问题存在,用通过证明行为成立,用重构保护长期演进。

所以我不觉得 AI 会让 TDD 过时。

更准确地说,AI 和 TDD 在补足彼此。

AI 让我们更容易抵达绿灯,但 TDD 提醒我们先确认红灯是真的。AI 让生成变快,TDD 让验证跟上。AI 降低了 TDD 的执行成本,TDD 降低了 AI 的工程风险。

这就是我现在更愿意相信的开发方式:

不是把 AI 当成一个可以直接交付答案的人,也不是把 TDD 当成一个必须机械执行的仪式,而是让 AI 的生成能力和 TDD 的验证结构形成闭环。

生成能力越强,越需要验证结构。验证结构越清楚,AI 的生成能力才越值得放心使用。

参考与延伸阅读


  1. Kent Beck, Test-Driven Development: By Example。本文借用的是其中“用小步反馈推动设计”的 TDD 基本结构,而不是把书中的全部实践等同于 AI-TDD。 ↩︎

  2. Gerard Meszaros, xUnit Test Patterns: Refactoring Test Code。这里引用它来支撑“测试可维护性本身是一门工程实践”,尤其是 fixture、test double 和 test smell。 ↩︎

  3. Steve Freeman, Nat Pryce, Growing Object-Oriented Software, Guided by Tests。这里引用它来支撑“测试可以参与系统设计,而不只是事后验收”。 ↩︎

  4. Eirini Kalliamvakou, The Impact of AI on Developer Productivity: Evidence from GitHub Copilot,Microsoft Research。55.8% 是特定实验任务中的结果,不应外推为所有开发任务的通用提升。 ↩︎

  5. GitHub Blog, Survey: The AI wave continues to grow on software development teams。这里用来说明团队已经在尝试用 AI 生成测试用例,而不是证明生成测试天然可靠。 ↩︎

  6. Stack Overflow, 2025 Developer Survey。这里引用的是开发者对 AI agent 节省局部任务时间和提高个人生产率的自报告数据。 ↩︎

  7. Khalid El Haji, Carolin Brandt, Andy Zaidman, Using GitHub Copilot for Test Generation in Python: An Empirical Study。这里引用它来支撑“AI 生成测试高度依赖已有测试上下文,且需要人工修正”。 ↩︎

  8. Davide Falessi et al., Generative AI for Test Driven Development: Preliminary Results。这是一项早期实验,适合作为“GenAI 可以参与 TDD,但质量监督不能省”的边界证据。 ↩︎

  9. Yi Cui, Tests as Prompt: A Test-Driven-Development Benchmark for LLM Code Generation。这里引用它来支撑“测试同时可以作为 prompt 和 verification”的视角。 ↩︎

  10. DORA, Accelerate State of DevOps Report 2024。这里引用它来支撑“AI 提升个人层面体验和生产率,但对交付稳定性和吞吐有意外 tradeoff”的系统视角。 ↩︎

  11. DORA, Impact of Generative AI in Software Development。这里引用它来支撑“生成速度可能通过更大 batch size 压力传导到 review、测试和交付系统”的解释。 ↩︎

有些 Bug 很反直觉。

比如一个任务进度,明明已经在数据库里变成了完成状态,退出游戏再重新登录以后,又变回未完成。

更诡异的是,更新时间是新的,进度却是旧的。

这类问题最麻烦的地方在于:单看某一条链路,它都是对的;把几条链路放在一起,它就错了。

现象

这次问题的表现大概是这样:

  1. 玩家在游戏内做了一次兑换。
  2. 数据库里的任务记录一度变成了完成状态:progress=1status=CAN_CLAIM
  3. 玩家退出游戏后重新登录。
  4. 同一条任务又变回了未完成:progress=0status=INIT

如果只看兑换流程,会觉得它没有问题。兑换完成后,平台服务通知任务服务更新进度,数据库也确实写成功了。

真正的问题发生在退出游戏的时候。

三个模块各自都很合理

这个任务系统被拆在三个模块里:

模块 职责
平台服务 平台事件入口,比如支付、兑换这类不直接发生在游戏循环里的操作
任务服务 任务系统核心,负责 DB 读写、进度计算、任务刷新
游戏服务 游戏内实时任务进度,负责内存缓存、客户端推送、退出时 flush

它们的职责单独看都很自然:

  • 平台事件发生在平台服务,所以兑换后由平台服务通知任务服务。
  • 任务数据落在任务服务,所以最终由任务服务写数据库。
  • 游戏中需要实时显示任务进度,所以游戏服务会在玩家进游戏时加载任务进度到内存。

问题就出在最后一点:游戏服务里有一份任务进度缓存。

两条写路径

这个系统里,任务进度至少有两条更新路径。[1]

第一条是游戏内事件。

例如射击、开宝箱、赚金币这种行为,本来就发生在游戏服里:

1
2
3
4
5
游戏内事件
-> 更新游戏服务内存缓存
-> 推送客户端任务进度变化
-> 退出或阶段性 flush
-> 任务服务写 DB

这条路径是自洽的。内存先更新,DB 后更新,客户端也能收到推送。

第二条是平台事件。

兑换不是游戏服自己完成的,而是平台服务处理的:

1
2
3
4
平台服务完成兑换
-> 通知任务服务更新进度
-> 任务服务按条件更新任务
-> 写 DB

这条路径也自洽。兑换成功后,任务服务把 DB 里的进度加上去。

但它少了一件事:没有通知游戏服务里的任务缓存。

脏缓存是怎么覆盖正确数据的

把时间线连起来,就能看到问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
玩家进入游戏
-> 游戏服务从任务服务加载任务进度
-> 缓存在游戏服务内存中
-> 此时 finishNum=0, status=INIT

玩家在游戏内兑换
-> 平台服务完成兑换
-> 任务服务写 DB
-> DB 变为 progress=1, status=CAN_CLAIM
-> 游戏服务缓存仍然是 finishNum=0, status=INIT

玩家退出游戏
-> 游戏服务 flush 玩家任务缓存
-> 把内存里的旧进度 flush 到任务服务
-> 任务服务用旧 progress/status 覆盖 DB

所以,数据库不是没有更新成功。

它是先被正确更新了,然后又被旧缓存覆盖了。

这就是这个问题最容易误判的地方:如果只查兑换那一刻的数据库,会看到正确结果;如果查退出后的数据库,会看到错误结果。中间缺的不是一次写入,而是一次来自旧状态的覆盖写入。这个形态和并发控制里常说的 lost update 很接近:不是没有写入,而是后来的旧快照把已经成立的新事实覆盖掉了。[2]

为什么更新时间是对的

这个现象也很迷惑:upd_time 看起来是对的,但 progressstatus 是错的。

原因在 SQL 写法里。

任务更新时,progressupd_timestatus 是一起 SET 的:

1
2
3
4
5
6
update task_progress
set progress = ?,
upd_time = ?,
status = ?
where role_id = ?
and task_id = ?

退出 flush 那条路径在写之前会先查一次 DB。此时 DB 里已经有新的 upd_time,但内存缓存里还是旧的进度和状态。

于是它写回去的是:

  • progress: 来自旧缓存,变回 0
  • status: 来自旧缓存,变回 INIT
  • upd_time: 来自刚查出来的 DB 记录,看起来还是新的

这就造成了一个很有迷惑性的现场:时间像是更新过,数据却像是回滚了。

它不是事务回滚,而是旧状态覆盖。[3]

根因不是缓存,而是缓存的所有权不清楚

看到这里,很容易得出一个简单结论:缓存有问题。

但更准确地说,是这份缓存的所有权和写入边界不够清楚。

游戏服务里的任务缓存适合处理游戏内事件,因为这些事件天然发生在游戏服务,并且需要实时推送客户端。

但兑换任务不是游戏内事件。它的事实来源在平台服务,最终状态在任务服务,游戏服务只是曾经加载过一份旧快照。

如果一份缓存满足下面两个条件,它就很危险:

  1. 它不是事实来源。
  2. 它可以在退出、定时任务、补偿流程里反向覆盖事实来源。

这时候缓存就不只是缓存了,它变成了另一个写入者。[4]Microsoft 的 Cache-Aside pattern 文档也明确提醒:cache-aside 本身并不保证缓存和数据源一致,外部进程更新数据源时,缓存要等重新加载或失效后才会反映变化。[5]

而多个写入者最怕的,就是每个人都以为自己手上的状态是最新的。

修复为什么不是加一次同步

直觉上的修复是:兑换后通知游戏服务,把任务缓存也更新掉。

这当然是一种方案,但它会引入新的问题:

  • 要给游戏服务增加更新任务进度的 RPC 能力。
  • 要考虑玩家是否在线、在哪个游戏服、通知失败怎么办。
  • 要保证 DB 更新和游戏服务缓存更新之间的失败补偿。
  • 以后所有平台任务都要想清楚要不要同步游戏服务缓存。

这不是不能做,而是成本更高。AWS 对 cache-aside 和 write-through 的区分可以解释这里的取舍:如果选择 write-through,应用就要在更新主数据库后同步维护缓存;如果选择 cache-aside,就要承认缓存是按需加载的副本,并明确它的失效和回源机制。[6]

这次采用的是更小的修复:不要把兑换任务加载到游戏服务缓存里。[7]

原因很简单:兑换任务不需要游戏内实时追踪。

玩家做兑换时,事实发生在平台服务;任务完成状态也由任务服务写 DB。它不需要由游戏服内存来参与生命周期。

所以把兑换任务加入一个“非游戏内追踪任务”的排除列表:

1
2
3
4
5
6
7
private static final int EXCHANGE_TASK_ID = 12;

public static final Set<Integer> NON_GAMEPLAY_TASK_IDS = set(
BIND_PHONE_TASK_ID,
DAILY_RECHARGE_TASK_ID,
EXCHANGE_TASK_ID
);

这样玩家进入游戏时,这类任务不会进入游戏服务的内存缓存:

1
2
3
4
不加载到游戏服务
-> 不产生游戏服务缓存
-> 退出时不 flush
-> 不会用旧缓存覆盖 DB

这个修复的关键不是“少写一段同步代码”,而是重新划清了任务的归属。

游戏内任务交给游戏服务缓存追踪。

平台任务不要进入游戏服务缓存。

什么时候应该让缓存参与写入

这次问题给我的提醒是:不要只问“要不要缓存”,还要问“缓存有没有资格写回”。

如果缓存只是读优化,它失效或过期最多影响性能。[8]

如果缓存会写回数据库,它就进入了 缓存一致性 的核心路径。此时至少要回答几个问题:

  1. 缓存里的数据是谁更新的?
  2. 还有没有其他路径会更新同一份 DB 数据?
  3. 缓存写回时,是覆盖写、增量写,还是带版本判断的写?
  4. 如果 DB 已经被其他路径更新,缓存写回应该跳过、合并,还是报错?
  5. 哪些业务根本不应该进入这份缓存?

这几个问题里,最后一个经常被忽略。乐观并发控制给出的思路是:保存前比较“我当初读到的版本”和“数据库当前版本”,如果已经变化,就不要静默覆盖,而是进入冲突处理。EF Core 的 concurrency token 和 Fowler 的 Optimistic Offline Lock 都是在解决这类“旧副本写回”问题。[9]

很多时候,最稳的修复不是让所有状态同步到所有地方,而是减少不该拥有状态的地方。

小结

这个 Bug 表面上是“任务进度倒退”,本质上是两条写路径竞争同一行数据:

1
2
3
平台服务路径:写入正确进度
游戏服务路径:退出时 flush 旧缓存
最终结果:旧缓存覆盖新 DB

它提醒我一件事:

缓存不是只要命中率高就算设计完成。只要缓存能写回,它就是一个数据作者。[10]

一个系统里可以有多个读者,但最好不要有太多自以为拥有事实的作者。

参考与延伸阅读


  1. Fowler 的 Optimistic Offline Lock 把问题表述为:多个业务事务可能跨越多个系统事务,不能只依赖单个数据库事务管理器来避免覆盖写;EF Core 的 optimistic concurrency 也通过保存时比较原始 concurrency token 来检测同一行是否已被别人修改。这支撑了“同一任务进度存在多条写路径时,必须把它当成多写入者问题”的判断:https://martinfowler.com/eaaCatalog/optimisticOfflineLock.htmlhttps://learn.microsoft.com/en-us/ef/core/saving/concurrency↩︎

  2. Martin Fowler, Optimistic Offline Lock。这里借用的是“检测并阻止并发业务事务互相覆盖”的思路,不是说这个事故一定发生在 ORM 或长事务场景。 ↩︎

  3. EF Core concurrency token 的更新语句会把 token 放入 WHERE 条件,若行已被并发修改则影响 0 行并触发并发冲突;这说明旧副本写回应被识别为冲突,而不是无条件覆盖事实源:https://learn.microsoft.com/en-us/ef/core/saving/concurrency↩︎

  4. AWS caching patterns 把 write-through 描述为应用在主数据库更新后同步更新缓存的模式;一旦缓存被纳入写路径,应用就必须承担维护缓存与事实源一致性的责任。这支持把“可写缓存”视为数据作者,而不是普通读优化:https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.html↩︎

  5. Microsoft Azure Architecture Center, Cache-Aside pattern。这里引用它来支撑“cache-aside 不自动保证缓存和数据源一致,外部写路径会让缓存变旧”的判断。 ↩︎

  6. AWS, Caching patterns - Database Caching Strategies Using Redis。这里引用它来区分 cache-aside / lazy loading 和 write-through:前者按需填充,后者要求应用在主库更新后主动维护缓存。 ↩︎

  7. 从 cache-aside / write-through 的取舍看,若选择同步所有副本,就要把缓存维护纳入写路径;从 optimistic concurrency 的角度看,减少非权威写入者可以降低旧副本覆盖新事实的机会。因此,对不需要实时追踪的兑换任务,排除出游戏服务可写缓存是“减少写入者”的修复,而不是单纯少做一次同步:https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.htmlhttps://martinfowler.com/eaaCatalog/optimisticOfflineLock.html↩︎

  8. Microsoft Cache-Aside pattern 把缓存定位为按需加载的副本,并提醒缓存可能与数据源不一致;这种风险在只读缓存中主要表现为读到旧值,而当缓存能写回事实源时,风险会升级为旧值覆盖新事实:https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside↩︎

  9. Microsoft Learn, Handling Concurrency Conflicts - EF Core;Martin Fowler, Optimistic Offline Lock。这里引用它们来支撑“旧快照写回应带版本判断或冲突处理,而不是无条件覆盖”的设计原则。 ↩︎

  10. “缓存能写回就是数据作者”是对 AWS write-through、Microsoft cache-aside 边界和 Fowler optimistic lock 思想的综合判断:写路径中的缓存必须有明确 owner、写入条件和冲突处理,否则就可能成为覆盖事实源的第二作者:https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.htmlhttps://learn.microsoft.com/en-us/azure/architecture/patterns/cache-asidehttps://martinfowler.com/eaaCatalog/optimisticOfflineLock.html↩︎

前面两篇文章分别谈了程序员的职责和 GPT 的能力边界:代码、工具与人:AI 时代的程序员,以及 GPT 有智能,但不是责任主体

这一篇继续往下走,谈一个更具体的问题:日常工作里,我到底应该怎样使用 AI?

我现在越来越觉得,AI 不是靠一次提示词用好的。

提示词当然重要。但真正决定效果的,往往不是某一句话写得多漂亮,而是 AI 被放进了什么样的工作方式里:什么时候先澄清,什么时候先读上下文,什么时候可以动手,什么时候必须验证,什么时候应该把结论写回文档。OpenAI 在谈 Codex 的 agent-first 实践时,把工程师的角色描述为设计环境、指定意图和建立反馈循环;Anthropic 在讨论 agentic systems 时,也区分了预定义路径的 workflow 和由模型动态决定步骤的 agent。[1][2]

也就是说,问题不是“怎么让 AI 回答得更像一个聪明人”,而是“怎么让 AI 参与一个可靠的工作流”。

不要只和 AI 聊天

很多时候,我们会反复对 AI 说同样的东西:先理解上下文,不要急着改;修 bug 前先复现;写完以后要验证;不要随便宣布完成;重要结论要沉淀到文档里。

如果每次都靠临时提醒,这件事很容易失效。因为对话会变长,上下文会变脏,人的注意力也会松。AI 可能这次听进去了,下次又回到它更熟悉的生成模式里:先给一个看起来合理的答案。

所以我把这些重复要求整理成了 skill:https://cnb.cool/ryuu64/skills。

skill 对我来说,不是“更长的提示词”,而是一种协作协议。它把那些反复出现的工程判断写成可复用流程,让 AI 知道在什么场景下该采用什么工作方式。这个判断也和 Codex、Claude Code 的官方建议相近:稳定的项目偏好应该进入 AGENTS.md、skill 或项目文档,而不是每次都靠临时 prompt 重复。[3][4]

先决定当前阶段

AI 很擅长生成内容,但它不一定天然知道什么时候该停下来理解上下文,什么时候该先验证假设,什么时候该把结论写回文档。

所以我更倾向于先判断当前需求处在哪个阶段。对复杂、模糊或多步骤任务,先 plan 再动手,比直接进入实现更稳;对简单任务,则没有必要把流程做重。[5]

当我还没有想清楚方案时,grill-then-docs 对我很重要。它先用 grill 的方式把一个决策点问透:有哪些选项,各自的代价是什么,推荐哪一个,为什么。等结论稳定以后,再把这些结论写回项目文档。这样 AI 不只是陪我聊了一轮方案,而是把这轮讨论变成项目之后还能复用的上下文。

比如看不懂一段代码时,我不希望 AI 直接解释当前文件,而是先使用 zoom-out 拉高一层,弄清楚它在系统里的位置:上游是谁、下游是谁、核心概念是什么、下一步应该读哪里。Claude Code 的常见工作流里也建议先从 broad questions 开始,再逐步深入具体组件和执行路径。[6] 这样后面的修改才不会只盯着局部。

修 bug 时,我会更希望它进入 diagnose 的流程:先建立反馈循环,再复现问题,然后提出可证伪的假设,用日志、测试或调试器去验证。很多 bug 真正难的地方不在改动本身,而在确认自己没有修错方向。Anthropic 的 coding agent 讨论也指出,代码任务之所以适合 agent,很大一部分原因在于代码可以通过自动化测试验证,agent 可以用测试结果迭代。[7]

如果是在实现一个明确的新行为,我会倾向于用 tdd。先列业务规则、边界条件和验收范围,再写失败测试,再写最小实现。AI 可以很快生成代码,但测试会把它拉回到具体行为上,不让它只写出一段“看起来像那么回事”的实现。

任务做完以后,我还会要求 verification-before-completion。AI 不能只说“已经完成”,而要先运行对应的验证命令,读输出,再根据结果判断能不能做完成断言。这个 skill 对我很重要,因为 AI 很容易在语气上显得很确定,但工程上真正需要的是证据。Codex 的最佳实践也把测试、lint、type check、行为确认和 diff review 放在可靠性收口里。[8]

如果过程中形成了稳定结论,比如某个模块的约束、某种排障方法、某个架构决策,我会再用 project-docs-syncproject-docs-writing 把它同步回项目文档。否则这次对话结束以后,经验又散掉了,下次还要重新解释。OpenAI 在 Harness Engineering 里把 repository knowledge 作为 system of record,并把短入口文件、结构化文档和渐进式披露放在一起使用;这其实是在解决同一个问题:让 AI 能从项目内部重新找到稳定上下文。[9]



flowchart TD
    A[遇到需求] --> B{当前最需要澄清什么}
    B -->|方案还不清楚| K[grill-then-docs<br/>先问透决策,再写回文档]
    B -->|不熟悉代码| C[zoom-out<br/>先建立上下文地图]
    B -->|排查 bug| D[diagnose<br/>复现、假设、验证、收敛根因]
    B -->|实现新行为| E[tdd<br/>测试先行,按行为推进]
    K --> F
    C --> F[修改或继续分析]
    D --> F
    E --> F
    F --> G[verification-before-completion<br/>用新鲜证据确认结果]
    G --> H{形成稳定结论?}
    H -->|是| I[project-docs-sync / project-docs-writing<br/>写回项目文档]
    H -->|否| J[结束任务]
    I --> J

这些流程看起来琐碎,但它们决定了 AI 是在帮你推进工程,还是只是在生成一堆看起来合理的文本。

处理 AI 的失控

把 AI 放进工作流,并不是因为 AI 不会做事。恰恰相反,是因为它太容易流畅地做事,甚至在方向不对的时候也显得很有把握。

上下文污染

我使用 AI 的过程中遇到过不少问题。最常见的一类,是上下文变得太长、太杂以后,AI 开始不理解业务。它会抓住对话里某个局部信息,然后沿着一个不对的方向继续推理,甚至越解释越偏。

这种时候,继续在原来的对话里纠正,不一定是最好的办法。因为问题有时不在某一句提示词,而在整个上下文已经被污染了。OpenAI 对“大 AGENTS.md”的反思很有代表性:上下文是稀缺资源,太多指导反而会挤掉任务、代码和真正相关的文档。[10] 我会选择新开一个对话,或者先清理上下文,再把真正重要的背景、约束和当前目标重新说清楚。如果项目里已经有文档,就让 AI 先看文档,而不是继续依赖一段越来越长的聊天记录。

调试时跳过证据

另一类问题出现在 debug 时。AI 很容易想直接根据代码猜原因,然后给出一个修复方案。这个过程看起来很快,但风险很高,因为它可能只是在修一个“看起来像问题”的地方。我遇到过 AI 死活不肯先插桩、不肯根据日志复现,而是反复尝试直接改代码。

后来我用 diagnose 去约束这个过程,并且反复调整它的规则:先建立反馈循环,先复现,再提出假设;需要日志就插有明确目的的日志;每次观察结果以后更新假设,而不是直接跳到修复。这样做会慢一点,但它能把 AI 从“猜一个答案”拉回到“拿证据收敛问题”。这也是人在环(Human-in-the-Loop)真正有价值的地方:人不只是最后点确认,而是在高不确定、高风险或缺证据的位置把流程停住。[11]

混淆计划和事实

还有一类更隐蔽的问题:AI 看起来像是知道自己在干什么,但实际上它可能只是选择了一个“看起来合理”的位置。

比如我的 grill-then-docs,它的目标是先把方案问清楚,再把稳定结论沉淀下来。可在一开始,AI 经常会把 grill 之后“接下来要做什么”写进 ./docs/features./docs/architecture。从表面看,这很合理:方案讨论和功能、架构都有关。但实际上这是错误的,因为 grill 后续要做的事情只是计划,不是已经成立的功能事实,也不是架构事实。

它应该先进入 ./docs/plan,等实现完成、结论稳定以后,才有资格进入 features 或 architecture。

这个错误很有代表性。AI 能根据文字相似性把内容放到看起来相关的地方,但“语义相关”不等于“事实身份正确”。一个计划即使和架构有关,也仍然只是计划;一个候选方案即使描述得很完整,也还不是系统事实。Microsoft 的 Human-AI Interaction Guidelines 也提醒我们,AI 系统需要让用户能理解、纠正和恢复,而不是把一个看似合理的输出直接当作事实。[12]

这背后更完整的能力边界,我在 GPT 有智能,但不是责任主体 里单独写过。放到工作流里看,解决办法不是指望 AI 自己突然理解项目知识治理,而是把规则明确写进去:grill 之后形成的后续计划,应该写到 /docs/plan,不能直接写进 /docs/features/docs/architecture

这不是一个简单的目录偏好,而是在告诉 AI:什么东西还只是计划,什么东西才可以被当作项目事实。

把偏好变成约束

使用 AI 写代码时,很多问题不是 AI 不会,而是它不知道你在意什么。

你在意命名是否贴合领域,注释是否解释意图而不是复述代码,提交信息是否清楚,文档是否跟着代码一起更新,这些偏好如果每次都临时说明,就很容易遗漏。

skill 的价值在于把这些偏好变成稳定约束。它不是让 AI 替我做判断,而是让 AI 在我设定的边界里工作。更准确地说,skill 管的是“在某类任务里如何行动”,而不是“把人的责任外包出去”。

这也是为什么我不太愿意把这件事叫作“写提示词”。提示词更像一次性的表达,工作流则会问另一组问题:

  • 这个需求现在处在哪个阶段?
  • AI 应该先读什么上下文?
  • 什么情况下可以动手?
  • 什么结果可以算完成?
  • 需要什么证据才能宣布完成?
  • 哪些结论应该写回文档?

这些问题比一句漂亮提示词更重要。

让 AI 更像协作者

skill 是一种协作协议。它让 AI 知道在什么场景下该采用什么工作方式,也让我不用每次都从零开始解释自己的工程习惯。

这也对应了前面两篇文章的结论:AI 可以加速表达、生成和试探,但程序员仍然要负责判断、边界和验证。把 AI 放进工作流,就是把这些责任拆成更清晰的流程,让 AI 更稳定地参与其中。

好的 AI 使用方式,不是把人从流程里拿掉,而是让人更清楚地站在该负责的位置上。

参考与延伸阅读

  • OpenAI, Harness engineering:适合理解为什么 AI coding 的关键不只是提示词,而是环境、工具、反馈和知识系统。
  • OpenAI Developers, Codex best practices:适合理解 AGENTS.md、plan-first、测试验证和 review 如何进入日常工作流。
  • Anthropic, Building Effective AI Agents:适合理解 workflow、agent、自主性、测试反馈和 guardrails 的边界。
  • Anthropic Docs, Claude Code common workflows:适合把“看代码、修 bug、写测试、写文档”拆成可执行工作流。
  • Anthropic Docs, Extend Claude with skills:适合理解 skill 为什么应该聚焦入口和按需加载,而不是变成大而全说明书。
  • Microsoft Research, Guidelines for Human-AI Interaction:适合理解人在 AI 系统中的控制、纠错和恢复位置。

  1. OpenAI, Harness engineering: leveraging Codex in an agent-first world。这里引用它来支撑“AI 使用效果取决于环境、意图、工具和反馈循环,而不只是 prompt”的判断。 ↩︎

  2. Anthropic, Building Effective AI Agents。Anthropic 区分 workflow 与 agent,并提醒只在复杂度确实需要时提升自主性。 ↩︎

  3. OpenAI Developers, Codex best practices。这里引用的是“把重复指导沉淀到 AGENTS.md、skill、配置和自动化工作流”的实践建议。 ↩︎

  4. Anthropic Docs, Extend Claude with skills。Claude Code 的 skill 文档强调 SKILL.md 保持聚焦,细节放入按需加载的 supporting files。 ↩︎

  5. OpenAI Developers, Codex best practices: Plan first for difficult tasks。这里引用它来支撑复杂任务先规划、再执行的工作流。 ↩︎

  6. Anthropic Docs, Claude Code common workflows。其中“理解代码库”和“查找相关代码”的建议都强调从广到窄地建立上下文。 ↩︎

  7. Anthropic, Building Effective AI Agents。这里引用的是 coding agents 适合用测试反馈迭代的判断。 ↩︎

  8. OpenAI Developers, Codex best practices: Improve reliability with testing and review。这里引用它来支撑“完成断言必须绑定测试、检查、行为确认和 review”。 ↩︎

  9. OpenAI, Harness engineering。这里引用的是“repository knowledge as the system of record”和渐进式披露的实践。 ↩︎

  10. OpenAI, Harness engineering。这里引用的是“大型指令文件会挤占任务、代码和相关文档”的上下文管理经验。 ↩︎

  11. 参见 Microsoft Research, Guidelines for Human-AI Interaction;以及 NIST, AI Risk Management Framework。这里引用它们来支撑“人工介入点需要上下文、控制、恢复和责任边界”的说法。 ↩︎

  12. Microsoft Research, Guidelines for Human-AI Interaction。这份 CHI 2019 论文提出并验证了 18 条 Human-AI Interaction 设计指南,适合支撑“AI 输出需要可理解、可纠正、可恢复”的边界。 ↩︎

0%