Agent 重跑一次就多写一份数据?缓存键与副作用步骤怎么排查
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
**多数人把重跑翻车归给模型不稳定,其实八成是你从来没给流程划出重跑边界——哪些步骤重做只是浪费时间,哪些步骤重做会真的改变外部世界,这条线没画,重跑就一定出事。**你看到的症状是”跑第二遍多提交了一次""通知发了两条""明明没改代码却又全量跑了一遍”,根因往往集中在两个地方:缓存键定错了,以及有副作用的步骤没做幂等。
这篇和站内三条相邻的内容分工不同:重试之后同一件事做了两遍 站在被调用方,讲幂等键本身怎么设计;长时运行 Agent 的断点续跑 讲的是检查点存什么、崩了从哪一步接上;Agent 执行失败后怎么恢复 讲失败之后该不该重试。本篇只管这三者中间那一块——重跑真的发生时,逐个步骤判断该不该重做,以及怎么把这个判断落成一条缓存键和一条幂等键。
一、先把步骤分成三类,别急着改代码
排查的第一步不是看日志,是把这条流程的步骤在纸上列出来,逐个打标签。只有三类:
**A 类:纯计算。**输入确定则输出确定。解析文件、拼装上下文、生成 diff 草稿、做静态检查都属于这类。这类步骤重做只有时间成本,是缓存的主战场。
**B 类:外部读取。**结果由外部世界决定,会随时间变化。拉依赖、查接口、读数据库、跑一次测试都算。这类可以缓存,但缓存必须带过期语义,而且要能一键失效。
C 类:有副作用。执行后外部世界被改变了,且不会自己变回来。写文件、git 提交、推分支、发 HTTP 写请求、建云资源、发消息、发邮件、扣一次用量。这类永远不该靠缓存跳过,只能靠幂等保证”多执行几次等于执行一次”。
工程上最常见的错误是把 B 和 C 混在一个步骤里。比如一个”同步”步骤既查了远端状态又顺手写了本地文件,你想缓存它,结果一缓存就把写文件也跳过了;你想重跑它,结果一重跑就把文件写重了。拆开,是所有后续动作的前提。
二、现象到成因的判别表
先按现象定位,再动手。下面这些是重跑翻车里反复出现的几类,按”看到什么”排:
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 代码没动,重跑却全量重算 | 缓存键混入了每次都变的量(时间戳、随机值、绝对路径、临时目录名) | 连续两次跑,把计算出的键打印出来对比,看哪一段字节不同 | 从键里剔除易变量,只保留真正影响输出的输入 |
| 改了代码,重跑仍拿旧结果 | 键漏了关键输入(文件内容、依赖锁文件、模型标识、逻辑版本) | 手动改一个字符再跑,键不变即确诊 | 把漏掉的输入补进键,并加一个可手动递增的逻辑版本号 |
| 重跑后多了一条重复记录/多发一条通知 | C 类步骤无幂等键,重跑即重做 | 查目标表或消息记录,看是否存在业务字段相同、只有主键或时间不同的两条 | 给这一步定唯一业务键,写入侧加唯一约束或 upsert |
| 中途崩溃后重跑,前半段又执行一遍 | 状态只在整体成功后才落盘,中间进度没记 | 人为在中段杀掉进程,再重跑观察日志 | 每完成一个 C 类步骤就单独落一次状态,落盘在副作用之后 |
| 重跑越来越慢,缓存目录暴涨 | 键粒度太细或含随机量,缓存只写不命中 | 统计缓存目录条目数与命中次数的比值 | 收敛键粒度,加容量/时间上限的清理策略 |
| 上游失败被缓存,之后一直失败 | 把异常结果也当结果缓存了 | 清掉缓存单跑一次即成功,确诊 | 失败不入缓存;确需缓存失败要单独短过期并标记 |
| 超时后重试,结果被执行了两次 | 把”不确定”当成”失败”处理 | 查服务端记录,看首次请求是否其实成功 | 超时后先查询目标状态再决定是否重发,配合幂等键 |
| 两个人/两个会话同时重跑,互相覆盖 | 没有互斥,同一目标被并发写 | 看写入时间戳是否高度接近 | 对目标资源加锁或加租约,重跑串行化 |
三、缓存键怎么定:把”会改变输出的一切”塞进去,别多也别少
缓存键的判断标准只有一句话:**两次计算,只要键相同就必须允许输出相同。**据此正反两面各列一份清单。
必须进键的东西:
- 输入的内容哈希,不是路径也不是修改时间。文件被 checkout 出来时间会变、内容没变,用 mtime 当键会让你天天全量重算。
- 依赖与运行环境的指纹:锁文件哈希、语言主版本、操作系统与架构。跨机器共享缓存时这一条最容易漏。
- 你调用的模型或工具的标识。别名指向的实际版本变了,输出就会变,这条坑在 别用模型别名 里单独讲过。
- 一个你自己维护的逻辑版本号。当你改了这一步的实现却没改任何输入时,靠手动加一来整体失效,比挨个清缓存可靠得多。
绝不能进键的东西:当前时间、未固定的随机数、进程号、临时目录路径、机器名、请求 ID。任何一个混进去,命中率立刻归零,表现就是”什么都没改却重跑了全部”。
一个通用的键计算写法,语言无关,思路照搬即可:
import hashlib, json, pathlib
def cache_key(paths, model_id, lock_file, logic_version):
h = hashlib.sha256()
h.update(json.dumps({
"model": model_id,
"logic": logic_version,
}, sort_keys=True).encode("utf-8"))
for p in sorted(paths):
h.update(p.encode("utf-8"))
h.update(pathlib.Path(p).read_bytes())
h.update(pathlib.Path(lock_file).read_bytes())
return h.hexdigest()
注意两个细节:路径列表要排序再哈希,否则同一批文件因为遍历顺序不同就产生不同键;路径本身也要参与哈希,否则改名不改内容会被误判为命中。
B 类步骤的缓存还要额外加过期语义。做法上分两档:能拿到上游版本标识(比如接口返回的 ETag 或数据集版本号)就用标识做键,拿不到就退化成时间窗口过期,并且必须留一个显式跳过缓存的开关,让人能在怀疑缓存脏了的时候一把绕过。构建层面类似的失效问题,代码改了却不生效 里有更细的展开。
四、副作用步骤怎么保幂等
C 类步骤不能靠”跳过”,只能靠”重做等于没做”。四种落地手法,按代价从低到高:
**一,给操作定唯一业务键,让存储层兜底。**键要从业务事实推导,比如”任务 ID + 步骤名”或”输入内容哈希”。写库时加唯一索引配 upsert,重复写第二次自然变成更新或空操作。反例是拿 UUID 或时间戳当键——每次重跑都生成新值,等于没有幂等。
**二,写文件用临时文件加原子改名。**直接向目标路径写,崩在半途就留下一个半成品,下次重跑读到它还以为是完整结果。正确姿势是写到同目录下的临时文件,把数据刷到磁盘之后再改名覆盖目标:同一文件系统内的改名是原子的,读方要么读到完整的旧版本,要么读到完整的新版本,不会读到半截。两个前提别忘:临时文件必须和目标在同一分区,跨分区的”改名”会退化成复制加删除,原子性就没了;Windows 上直接改名到已存在的路径会报错,要用允许覆盖的那个替换接口(Python 里是 os.replace,它在各平台上都覆盖,os.rename 在 Windows 上遇到同名文件会抛异常)。
三,写请求带幂等标识,且先查后写。很多写接口支持在请求头带一个由调用方生成、重试时保持不变的幂等标识,服务端据此去重(具体头名与支持范围各家不同,以官方最新说明为准)。真正要命的是超时:连接超时或读超时意味着结果未知,不等于失败。正确处理是先按业务键查询目标状态,确认没生效再重发。
# 超时后不要盲目重发,先查目标状态
curl -sS --max-time 10 "$API_BASE/orders/$BIZ_KEY" -H "Authorization: Bearer $TOKEN"
超时与中断的完整判断链条,接口超时、连接被重置 里说得更细,这里只强调它对幂等设计的影响。
**四,git 操作前先判空、判存在。**Agent 最爱重复制造的副作用就是提交。加两道检查:提交前确认工作区确实有变更,分支操作前确认目标分支不存在。
git diff --quiet && git diff --cached --quiet && echo "无变更,跳过提交" && exit 0
git rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null && echo "分支已存在" && exit 0
这里有个坑要点破:git diff 只看已跟踪文件,Agent 新建出来还没 add 的文件它是看不见的。如果你的流程是”先 git add -A 再提交”,判空就得换成看暂存与未跟踪的整体状态,例如用 git status --porcelain 的输出是否为空来判断,否则会出现”明明生成了新文件却被判成无变更”的反向漏做。同理,分支那一行只挡住了同名分支重复创建,如果重跑时你希望的是”复用已有分支继续提交”,那就不该 exit,而应切过去——跳过还是复用,取决于这一步的语义,别照抄。
还有一条容易忽略的原则:**状态落盘要在副作用之后。**先记”已完成”再执行,崩在中间就永久漏做;先执行再记,崩在中间最多重做一次——而重做一次正是幂等在兜的事。至于回滚之后本地状态与远端不一致该怎么收,代码回滚了故障还在 是配套的一篇。
五、什么情况下别再折腾
排查也要有止损点,下面几条命中任意一条,就停手换策略:
**同一位置连续失败三次,停止重跑。**三次同样位置同样报错,说明它不是偶发抖动,继续重跑只是在烧时间和额度。转去看这一步的输入是否本身就不合法。
**副作用已经外泄,立刻停,先补偿再谈重跑。**数据已经写进生产库、消息已经发到群里、资源已经在云上建出来了,这时候再重跑是在放大伤害。先把补偿动作做完(删除、撤回、标记作废),确认外部状态干净了再考虑下一轮。
**状态不可推断时,别修状态,回到干净起点。**你如果无法判断上一轮到底做到了哪一步,手工推理状态的出错概率远高于全量重来。回滚点选在最近一个可信的快照或提交上,从那里全量跑,代价通常比修补小。
**缓存命中率长期上不去且单轮成本可接受,直接关掉缓存。**缓存是优化,不是正确性依赖。为了修命中率把键改得越来越复杂,反而更容易引入”改了代码却拿旧结果”的隐性错误。
**换条路的信号:链条太长。**一条十几步的自动流程,任何一步出问题都要整条重跑,边际成本极高。这时候更值得做的是把它拆成两三段、段间留人工确认点,而不是继续给单一长链条打补丁。定时触发的流程尤其如此,漏跑与重跑叠在一起的复合故障可参考 定时任务有时不跑有时跑两遍。
六、避坑清单
**用文件修改时间当缓存键。**为什么会踩:mtime 看起来”就是变更信号”,本地开发时也确实好用。但 CI 里每次 clone 出来的文件 mtime 都是新的,缓存永远不命中;反过来某些同步工具会保留旧 mtime,内容变了键却没变。怎么避:一律用内容哈希,mtime 最多做一层本地快速预筛。
**把整轮 Agent 执行当成一个缓存单元。**为什么会踩:粒度粗写起来省事。但一轮里只要有任何一个输入变了,整轮就全废,而且里面往往夹着 C 类步骤,一缓存就跳过了本该执行的写操作。怎么避:缓存只包 A 类步骤,粒度定在”一个纯函数式的子任务”。
**把失败结果也写进缓存。**为什么会踩:缓存层通常统一包在调用外面,不区分返回内容。结果是一次网络抖动导致的失败被固化,之后每次都读到它。怎么避:只缓存成功结果;如果确实要缓存失败以避免重复打爆上游,单独标记并给一个明显更短的过期。
**幂等键里混了会变的字段。**为什么会踩:生成键时顺手把当前时间或随机 ID 拼了进去,本地测试只跑一次看不出问题。怎么避:键的每一个组成部分都要能回答”重跑时它还一样吗”,答不出就不许进键。
**把超时当失败直接重发。**为什么会踩:客户端拿到的就是一个错误对象,最自然的处理就是重试。但超时的语义是结果未知,服务端可能已经执行成功。怎么避:写操作的超时走”先查询后决策”分支,禁止直连重发。
**清缓存当万能药。**为什么会踩:清完确实好了,于是形成肌肉记忆。但每次清缓存都在掩盖键定义的缺陷,问题会以更隐蔽的形式复发。怎么避:每次清缓存都记一笔,同一原因出现第二次就去改键,不改就一直复发。
**两个会话同时重跑同一目标。**为什么会踩:并行提速很诱人,重跑又常常是手忙脚乱时干的事,很容易一个人在终端跑、另一个流程在后台也跑。怎么避:对目标资源加文件锁或租约,检测到已有实例在跑就直接退出而不是排队等待。
**测试数据借道重跑流进了正式环境。**为什么会踩:重跑时为了快,把参数替成了样例值,忘了改回来。怎么避:写入侧对环境标识做硬校验,非生产标记的数据禁止进生产表,参考 示例数据混进真实路径。
收束:重跑前的五条自检
重跑边界这件事,本质是逼你把一条模糊的自动流程写成有明确输入输出、有明确副作用清单的工程件。它做好了,重跑就从一件提心吊胆的事变成常规操作。
下次按下重跑之前,过一遍这五条:
- 这条流程里哪些步骤有副作用?能一口气数出来吗?
- 每个有副作用的步骤,它的唯一业务键是什么?重跑时这个键会变吗?
- 缓存键里有没有混进时间、随机值、绝对路径?
- 上一轮崩在哪一步,我是从状态文件读出来的,还是靠猜的?
- 万一这次重跑又出错,我的回滚点在哪里,能不能一条命令回去?
五条里有任何一条答不上来,先别重跑——先把它答出来。