上游调用失败取证(Upstream Error Forensics)
核心原则
用户看到的错误文案,通常不是根因。 它可能是:
- 上游原文透传(最迷惑:中文文案看着像我们写的,其实代码里根本没有)
- 我们代码在某处 throw new Error(e.message) 丢掉了机器可读的错误码
- 一个语义完全不同的分支复用(提交阶段的错误走了轮询阶段的文案)
- 配置/凭据问题的伪装(缺 Key、余额不足被包装成「内容审核不通过」)
所以第一动作不是读代码,是给错误文案「定户籍」。
八步取证流程
① 定项目/定位失败记录 → ② 聚合统计失败模式 → ③ 给错误文案定户籍
④ 查有无资损(先做这个!)→ ⑤ 精确复现 → ⑥ 抓上游原始响应(决定性)
⑦ 变量隔离定位触发条件 → ⑧ 才读代码定位缺陷
① 定位失败记录(看数据,不看 UI)
UI 常常不显示历史失败。直接查持久化层:
- 平台有任务表(如
gen_tasks)就查它,按status聚合:
sql SELECT status, provider, type, COUNT(*) FROM <task_table> GROUP BY 1,2,3; - 记下
error字段原文、时间戳、refunded标记。 - ⚠️ 多台机器是独立库,先确认失败发生在哪台。只看一台会得出片面结论。
- ⚠️ 很多环境没装对应 CLI(
sqlite3等)→ 用运行时自带的驱动直读(better-sqlite3/pg)。
脚本必须放在项目目录内,放/tmp会MODULE_NOT_FOUND。
② 聚合统计失败模式
单条看不出规律。按 status × provider × type × 时间窗口 分组,找:
- 是否集中在某个 provider / 某个模型
- 是否集中在某个时段(→ 可能是版本切换、配额耗尽、上游故障)
- 是否有成功的对照组(同 provider 别的 type 成功过吗)
③ 给错误文案定户籍(最关键的一步)
# 全仓搜这句文案
grep -rn "<错误文案的关键片段>" --include=*.js --include=*.py --include=*.go . | grep -v node_modules
# 再搜 git 历史(排除「旧版本代码写死」的可能)
git log --all --oneline -S"<错误文案的关键片段>"
# 若代码里有多个语言版本,都搜
grep -rni "<可能的英文源>" --include=*.js .
判定表:
| 搜索结果 | 结论 | 下一步 |
|---|---|---|
| 代码里有 | 我们自己的文案 | 定位那个 throw/分支,看条件是否合理 |
| 代码里没有,但上游响应里有 | 上游原文透传 | 走 ⑥ 抓原始响应,看它丢掉了什么 |
| 代码里有同名错误但分支不对 | 分支复用/语义混淆 | 对比两个分支的判定条件 |
> 这一 step 的产出是把搜索范围砍掉一半。 代码里搜不到 = 别再读代码了,去看上游。
④ 查有无资损(必须在给结论之前做)
用户报「失败」时,第一反应容易是「是不是白扣钱了」。先把这个查清楚,否则会把普通失败误报成事故。
-- 资金流水表(按你的平台实际表名)
SELECT * FROM wallet_events WHERE user_id = ? AND created_at >= ?;
SELECT * FROM billing_logs WHERE user_id = ? AND created_at >= ?;
如果流水为空,还要进一步确认「是不是这个角色本来就不扣费」:
// dry-run 一次计费,看返回值
const r = await billing.charge(db, UID, '<type>', '<provider>', {model}, units);
// {ok:true, cost:0, free:true, reason:"admin"} ← 管理员/免费额度/会员 = 本来就不扣
// {ok:true, cost:20, ...} ← 应该扣;此时查流水为空才是异常
> ⚠️ 角色免费(admin / 会员 / 免费额度)会让「重试 N 次」看起来像「白扣 N 次」,实际零损失。
> 不做这一步就会误报资损。
失败时是否有退款逻辑:查任务的 refunded 字段 + 代码里的失败分支是否调了 refund。
> 注意设计差异:cost > 0 才退是正常设计(免费用户无可退),不是 bug。
⑤ 精确复现
走主入口,不要手动拼参数(手动传 key/模型会掩盖真实解析路径的问题):
// ✅ 让内部自己解析 provider/key/model
const r = await generateVideo({ provider: 'xxx', prompt, duration: 3 });
// ❌ 手动传 apiKey/baseURL/model —— 复现的就不是线上那条路
复现成功(拿到同样文案)后,记下完整调用参数,进入 ⑥。
⑥ 抓上游原始响应(决定性证据)
绕过适配层的错误归一化,直连上游,把 HTTP 状态码和 body 原样打出来:
const r = await fetch(upstreamUrl, { method:'POST', headers, body: JSON.stringify(body) });
const txt = await r.text(); // 用 text() 不用 json(),防非 JSON 响应
console.log('UPSTREAM HTTP', r.status);
console.log(txt.slice(0, 900)); // 原样打印,别先解析
要重点看三件事:
- HTTP 状态码。看到 4xx/5xx 不奇怪;看到 200 但业务失败才是大头。
- body 里的业务状态字段(
status/state/code/success)。 - 机器可读的错误码(
error.code/reason/err_code)—— 这是能拿去查文档、做分支映射的东西。
典型陷阱:
HTTP 200
{"id":"", "status":"failed",
"error":{"code":"OperationDenied.TextIllegalDetected",
"message":"文本可能包含敏感信息,请重试。"}}
这里是内容审核拦截,但:
- HTTP 是 200 → 任何 if (!res.ok) throw 的写法都拦不住
- id 是空串 → 后续「取 taskId」的逻辑会拿到空值,报出完全无关的错误(「未返回任务ID」)
- 真正有用的 error.code 被 message 盖住 → 用户只能看到无法操作的中文
⑦ 变量隔离定位触发条件
拿到上游错误码后,不要急着下结论说「就是 X 触发的」。做隔离实验:
// 同一 key、同一模型,只改一个变量,逐个提交,对比 status
const tests = [
['原输入', fullInput],
['去掉 A 段', inputWithoutA],
['去掉 B 段', inputWithoutB],
['纯中性输入', neutralInput],
['单片段×N', ...fragments], // 每个片段单独提交
];
for (const [name, input] of tests) {
const r = await fetch(...);
console.log(name, 'status=' + (j.status||'?'), j.error?.code || 'OK');
}
结论的两种形态(处理方式完全不同):
| 实验结果 | 结论 | 修法 |
|---|---|---|
| 某片段单独提交就失败 | 固定词/模式命中 | 可以做替换表 / 黑名单 |
| 片段全过、只有原始整段失败 | 整段语义触发 | 替换表无效,只能改写后重试 |
> 这个区分很重要:如果是语义级审核,写「敏感词替换表」是白费力气。
⚠️ 变量隔离实验有「假象」风险 —— 必须重复取证(2026-09-15 实战)
真实翻车案例:第一轮变量隔离显示「六种片段单独提交全部 queued、只有原始整段被判违规」,
于是判定「整段语义触发审核」。但第二轮真机复测发现:连「一只猫」「纯中性」甚至「暴力血腥」
prompt 也全部 queued,随后查任务状态 5/7 返回 {code:"429", message:"limit"} ——
真实根因是上游 429 限流,第一轮看到的「片段全过」只是恰好避开限流窗口的假象。
三条血泪教训:
- 提交成功(queued)≠ 成功。异步任务的失败可能发生在轮询阶段,必须查
query结果,
不能只看submit返回的状态。 error.code形态不可预设。可能上一分钟是腾讯云风格OperationDenied.TextIllegalDetected,
下一分钟是纯数字"429"、message是裸英文"limit"。映射表要同时覆盖 code 名与数字码。- 同一现象在不同时间点可能给出不同根因。限流窗口会让失败被「伪装」成审核/鉴权/超时等任意形态。
→ 下结论前至少重复取证一次,且以轮询后的终态为准。
⑦bis 查「提交即成功、轮询后失败」的终态
若 submit 返回 queued,别停,接着查 query 直到终态,把 status 与 error.code 都记下来:
// submit 返回 {id, status:'queued'} 后,轮询查终态
const q = await fetch(base + '/api/video/query', {method:'POST', body:{model, id}});
// {"status":"failed","error":{"code":"429","message":"limit"}} ← 真相在这里
⑧ 才读代码定位缺陷
现在目标明确了:去代码里找「上游那个关键信息在哪一步被丢掉」。
常查的三处:
1. 成功判定条件 —— 是否只看 HTTP 状态,漏了业务 status 字段?
js
// ❌ 漏判业务失败
if (!res.ok || (j && j.error)) { /* 但 200+status:failed 时 j.error 可能为空 */ }
// ✅ 两者都判
const bizOk = !j.status || ['queued','processing','success'].includes(String(j.status).toLowerCase());
if (!res.ok || !bizOk || (j && j.error)) { ... }
2. 错误归一化是否透出机器码 —— 只取 message 就会丢掉 code。
js
// ❌ 丢掉 code
throw new Error(j.error && j.error.message);
// ✅ 保留 code,按 code 分类映射可操作提示
const d = describeError(j, res.status); // → {code, message, text}
throw new Error('提交失败: ' + d.text);
3. 空值兜底路径是否报无关错误 —— id 为空时,别报「未返回任务ID」,
那会让排查者以为是响应格式问题。
⑧bis 判「通道是否真的可用」必须下钻到 key —— 并警惕「调用方忽略 meta」的静默降级
背景(2026-09-15 实战翻车):探针打印 resolveEffectiveProvider('image') → jimeng ✓ 真实通道,
据此判定「能出真图」,于是整轮验证跑完 12 张图 —— 结果全是 SVG 占位、整步只花 754ms、零告警。
根源:问错了问题。 resolveEffectiveProvider(kind) 回答的是「该用谁」(provider 名),
不回答「那个人手里有没有钥匙(key 是否可用)」。这是两个互不替代的函数:
| 函数 | 回答的问题 | 不能代替它回答的 |
|---|---|---|
resolveEffectiveProvider(kind, name, cfg) |
该用哪个 provider | 该 provider 的 key 是否可用 |
resolveSubApi(provider, 't2i'\|'refimg', {}) |
该 provider 有没有配 key | key 是否格式正确 / 未过期 / 额度未耗尽 |
四级判据(从弱到强,至少用到第 ③ 级):
- ❌
resolveEffectiveProvider返回的 provider 名 —— 只说明「该找谁」,不说明「找得到」。 - ⚠️
resolveSubApi(provider, sub, {})的apiKey.length > 0—— 说明「有钥匙」,
不说明钥匙能开门。本次实测key=27字符,真实错误却是The API key format is incorrect
→ 长度 > 0 的 key 照样可能是废的。 - ✅ 单次调用的耗时量级 —— 真出图 30–48s;假动作(回退 simulate)< 1s。
量级差 1~2 个数量级,是最难被伪装的判据。 本次 754ms 就是那根救命稻草,一眼假。 - ✅ 产出物本身的 scheme —— 真产物是外部 URL(
https://…cos…);
simulate回退产出的是data:image/svg+xml;base64,…内联占位。
→ 判据:url.startsWith('data:')= 回退,不是真产物。
第二层坑:错误被写进 meta,而调用方从不读 meta。
resolveEffectiveProvider('image') → 'jimeng' // 答:该用谁
→ resolveSubApi('jimeng','t2i') → key.length=27 // 答:有钥匙
→ callJimengImage 调用失败(key 格式错)
→ generateImage 的 try/catch → simulateFallback() → SVG 占位
→ 真原因写进 meta.realError
→ ★ executeStep(4) 从不读 meta ★ → 错误被彻底吞掉
generateImage 的设计是「降级但保留真因」(把错误塞进 meta.realError 再返回 ok:true)——
降级本身没错,错在调用方不读 meta,于是「降级」退化成「静默失败」。
> 判据:降级可以,但不能让人看不见。 与 §② 的「提交成功 ≠ 成功」是同一家族盲区 ——
> 都表现为「上层以为成功」。取证时必须读 meta / warnings,别只看 provider 与 ok。
检查清单:
- [ ] 产物函数回退时,是否把回退原因带进返回值(meta.realError / warnings[])?
- [ ] 调用方是否读了这个字段?grep -n "realError" <调用方文件> ——
若只出现在产物函数内部 = 无人读,该字段等于不存在。
- [ ] 回退是否记进落库元数据(如 s.consistency.genWarnings),还是只活在内存里?
- [ ] 回退时是否发告警/日志?本次是「三处都看不见」的纯静默。
⑨ 若根因是「限流」—— 重试治理设计与参数校准
定位到限流(429 / RequestLimitExceeded / JobNumExceed)后,别急着上重试。
重试的参数选错,比不重试更糟。下面这套是 2026-09-15 用四轮真机对照实验换来的。
A. 先量三个数,再动代码
| 要量的东西 | 怎么量 | 实测例子 |
|---|---|---|
| 上游上限 | N=1 基线(必做)→ 逐步加 N,找到开始失败的 N | N=1 稳过;N=3 起有任务被清 → 上限 ≈2 |
| 上游限流窗口 | 单发成功后,隔多久再发才会再撞 429;也可直接看它报错文案 | 文案写「请间隔 1-2 分钟后重试」→ 分钟级 |
| 任务单次耗时 | 看成功样本的 updated_at - started_at |
≈144s |
> ⚠️ 没做 N=1 基线就下结论 = 大概率误判。N=1 能稳过说明「能力/凭据/额度都在」,
> 问题只在并发维度 —— 这与你以为的「内容被拦 / Key 失效 / 余额不足」修法完全相反。
B. 退避总量必须与「上游限流窗口」同量级
这是最容易写错、且错了完全看不出来的一处。
退避公式 min(cap, base × 3^(n-1)),出厂 base=3s、cap=60s
→ 4 次等待 = 3 + 9 + 27 + 60 = 99s
| 退避累计 | 尝试次数 | vs 上游窗口(分钟级) | 实战结果 |
|---|---|---|---|
| 12s | 3 | 远不够 | 样本不足(N=1) |
| 99s | 5 | 全程落在同一个窗口内 | 3 轮 8 个任务 → 救回 0 |
| 90s | 3 | 同上 | 1 个任务 → 救回 0 |
| 210s | 5 | 仍未跨过 | 2 个任务 → 救回 0 |
| 240s | 5 | 跨过(n=1) | 1 个任务 → 救回 1 |
(后三组是 2026-09-15 补测:base=30s 配 3 次 / 5 次。240s 那次只有 1 个样本,不足以当阈值。)
为什么 99s 一定失败:限流桶是「按窗口回血」的。99s 内连打 5 次,是在同一个已经耗尽的桶里反复取;
更糟的是每次重投自己也是一次提交,会把窗口继续往前推 —— 越急着重投,窗口越难冷却。
> ⚠️ 只看 base 会把总量算错。 累计退避 ≈ Σ(n=1..次数−1) min(cap, base·3ⁿ⁻¹) ——
> 段数 = 尝试次数 − 1,而 base 一旦被 cap 裁平就失去作用。
> 实测:base 从 3s 提到 30s(×10),累计只从 12s 提到 90s(×7.5);
> 而次数从 3 提到 5,累计从 90s 直接翻到 210s。段数才是主控变量。
> 所以「调大 base」与「调大次数」必须成对改,只改一个 = 半修。
> ⚠️ 多臂实验必须错开时间跑,否则结论不干净。 同一账户上背靠背连跑,
> 累计消耗会成为混淆变量(把后一臂的成功率一起压低),
> 于是你分不清是「参数不够」还是「额度已被前面的实验抽干」。
> 要落到"某阈值够/不够"的结论,必须留冷却间隔 + 只改一个变量。
五条可操作结论:
1. base 别设成秒级。对齐上游文案给的量级(它说「1-2 分钟」,就按 30-60s 起步)。
2. 注意 cap:单次等待有硬上限时,base 再大也拉不长总时长。要更长的总退避只能加 n(尝试次数)。
3. base 与次数成对调,且记住累计是「段数」的函数、不是「base」的函数(见上)。
4. 调大退避要一起算代价:并发闸若包住整个重试序列(见 C),退避期那个槽位是被占住的 →
退避变长 = 撞限流的任务多占闸更久。低并发时这个代价体现不出来,高并发时会拖慢整条队列。
5. 退避拉长后必须复查任务 TTL:最坏总耗时 ≈ 次数 × 单次超时 + 累计退避。
实测配方 5 × 300s + 210s ≈ 28.5min 撞上 30min 的 TTL,只剩 90s 余量;
一旦超了会表现成「任务凭空消失」(行被 prune,而 UPDATE ... WHERE id=? 匹配 0 行 → 静默 no-op)。
C. 并发闸必须包住「整个重试序列」,并做跨实例收敛
// ✅ 闸在外层:包住「尝试 + 退避等待」全程
withSlot(() => retried(opts))
// ❌ 闸只包单次尝试:退避期间槽位被别的请求拿走 → 在飞数瞬时超限
withSlot(() => once(opts)) // 外面套重试循环
为什么:上游超限的惩罚往往不是「拒绝新提交」,而是把已在跑的任务一起清掉(连坐)。
宁可多等几十秒,也不能瞬时超限。
跨实例:进程级闸的实际在飞数 = 闸值 × 实例数。
nginx 上游有几个实例就乘几 —— 必须把这个乘积跟上游上限对齐。
最省事的收敛方式是先减实例(零代码),正规方案再用共享存储做全局计数
(已有 Redis 就用 INCR/DECR;别用 DB advisory lock —— 视频任务动辄几分钟,
会长期占住连接)。
D. 怎么证明「重投真的救回来了」
唯一口径:status='done' 且 retry_count > 1。
- 失败路径通常天然有
attempts(异常对象上带);成功路径极易漏 ——
它只返回一个 URL 字符串,次数在返回值里就丢了。
→ 检查重试函数有没有onAttempts之类的回调钩子,有就接上;没有就加一个。
⚠️ 钩子写好了却没接调用方,是极常见的半成品:有 JSDoc、有调用点、全项目无人传参。 - 语义要能区分:非重试链路记
0(=「没有这条链路」),重试链路首次即成记1。别统一填 1。 - 没有这个指标,「退避重投救回了多少任务」永远无人知道 —— 而它正是判断这套治理值不值的唯一依据。
E. 复现实验的装置与坑
| 坑 | 做法 |
|---|---|
| 别在生产实例上实验 | 另起一个临时实例(换端口,不进负载池)→ 与线上流量完全隔离,跑完即杀 |
| 闸与提交数必须解耦 | 绑死成 N=N 时,提交完就一直满负荷 → 重投撞上仍在跑的兄弟 → 再次超限 → 必败。解耦后才能让重投落在兄弟任务结束之后 |
| 临时实例的身份 | 服务以 root 跑、数据目录属主是别的用户 → 临时实例必须也以 root 起,否则落库 readonly database,结论全是内存里的假值。脚本里加身份校验 + 日志 grep readonly/EACCES 自检 |
| 额度消耗 | 实验要真出片,会烧上游额度。用免费角色(admin)只免平台侧计费,上游额度照烧 |
| 启动脚本要幂等 | 若所在环境有「单条命令执行两次」之类的特性,后台启动脚本必须加标记文件守卫,否则白烧一倍额度 |
| 「先读后删」拆成两条命令 | 把「cat 存档」和「rm 存档」写在同一条命令里,第一遍执行就把存档删了,第二遍 cat 拿到空文件 → 丢档 |
| 断言方向 | 新写的判定要能区分「一次就成」和「被救回」。只断言「成功任务有 retry_count」是弱断言(首次即成也能过),要额外断言存在 retry_count > 1 的样本 |
| 证「配置真生效」要看行为,不看文件 | 「文件里有这行」不等于「进程在用这个值」。最硬的证据是断言日志里实际打印的等待秒数 == 按当前配置算出的期望值。本次正是靠这条才摸清注入通道:EnvironmentFile=-/etc/manju/env 只在 systemd 启动时读一次 → 改完 env 必须重启,否则文件对了、进程还是旧值 |
| 临时实例要全局互斥锁,不能只有单臂守卫 | 环境若有「单条命令执行两次」特性,两臂会同时在跑:后起的实例 rm -f $LOG $PIDFILE 会删掉先起者的日志与 PID 文件,整轮证据作废。要用 mkdir(原子)做全局锁,而不是给每臂各加一个标记文件 |
| 测「生产配置」时别给被测变量传默认值 | 脚本里 VAR=${E2E_VAR:-3000} 这种写法会把生产值屏蔽掉 —— 你以为在测生产配置,实际测的是代码默认值。要么根本不设该变量(让它走配置文件),要么显式读出配置文件的值并打印出来自证 |
读进程 env 的重定向必须放进 sudo |
sudo tr '\0' '\n' < /proc/PID/environ 读不到 —— < 是由调用方(无特权)shell 执行的。必须写成 sudo bash -c "tr '\0' '\n' < /proc/PID/environ" |
F. 上游限流报错文案的写法
别把上游原文透传(如「请间隔 1-2 分钟后重试,或到控制台申请提额」——
对创作者毫无指导意义,还泄漏了厂商信息)。映射成领域语言,并保留机器码用于聚合统计:
429 → 「当前排队任务较多,系统正在自动重试;若持续失败可稍后再试」
G. ⚠️ 先判「并发级」还是「账户级」限流 —— 这决定重试到底有没有用
这是本节最该先做的一次判断,判错了后面全是白工。
| 形态 | 特征 | 重试有用吗 |
|---|---|---|
| 并发级(在飞数超限) | 兄弟任务跑完、场上只剩自己时,重投能过 | ✅ 有用:退避 + 并发闸即可 |
| 账户级(配额 / 速率上限) | 兄弟任务早已结束、只剩自己时重投仍 429 | ❌ 无解:见下 |
实战判定(2026-09-15):ARM B 里兄弟任务 +163s 就已结束,该任务 +210s 的重投仍然 429
—— 与「并发恢复后就能过」直接矛盾 → 账户级。
为什么「每任务各自退避」对账户级限流天生无效:
N 个任务各自退避 → 每个任务的重投【都是一次新提交】
→ 它们的重投时刻虽错开,却不断落在同一个账户桶上
→ 桶永远回不到满 → 谁都别想成功
而且越加重试越糟(重投本身把窗口继续往前推)。这与 §B「99s 内连打 5 次是在同一个已耗尽的桶里反复取」
是同一个机理,只是量级从"单个任务的 5 次"放大到"N 个任务 × 5 次"。
正确修法:账户级冷却闸(circuit breaker)
// 模块级:一旦观测到 429,全局暂停【提交】一段时间
let cooldownUntil = 0;
const COOLDOWN_MS = Number(process.env.PROVIDER_COOLDOWN_MS) || 120000; // 对齐上游"1-2 分钟"
async function once(opts) {
const wait = cooldownUntil - Date.now();
if (wait > 0) await sleep(wait); // 冷却期内【不提交】,而不是"提交了再退避"
try {
return await submitAndPoll(opts);
} catch (e) {
if (String((e && e.code) || '').includes('429')) cooldownUntil = Date.now() + COOLDOWN_MS;
throw e;
}
}
三条硬要求:
1. 要在「提交之前」等,不是「失败之后」等。 退避是"撞了再退",冷却是"知道疼了就不凑上去"。
2. 冷却状态必须跨实例共享(Redis SET key NX PX)。进程内冷却 × N 实例 = 各暂停各的,
等于没暂停 —— 与「进程级并发闸 × N 实例」是同一个坑(见 §C)。
3. 冷却不解决配额耗尽:账户真没额度要靠充值/提额,冷却只管速率型限流。
→ 因此错误文案必须把两者分开:速率型 → "系统正在自动重试";配额耗尽 → "额度不足,请充值"。
落地时才暴露的 7 条硬要求(2026-09-15 实施记录,上面那段草图是不够的)
- 别自己 new 一个 Redis 客户端 —— 复用项目已有的"共享态抽象"。
若项目里已有state-store之类(KV + TTL + Redis/本地双后端),直接用它。
理由:它通常已经内置了降级(连不上 Redis 就回落进程内),而这正是冷却闸需要的 fail-open;
另外也避免多开一条 Redis 连接。 - 等待必须放在「并发闸之内」。
放到闸外 → 多个任务各自睡 120s 然后同时醒来一起提交,闸退化成一次性仪式。
放闸内则天然串行:后来的任务排队等前面那个把冷却睡过去。 - ⚠️ 防活锁:已处于冷却中时「不续期」。
否则一个持续报 429 的任务(轮询每 10s 报一次)会把窗口无限往后推 → 后来者永远等不到,
比限流本身更糟。做法:trigger前先读一次,remaining > 0就直接返回,不重写。 - 只对「限流类」错误触发,不要用
retryable顶替。
限流类 ⊂ 可重试类;5xx/网络抖动同样"可重试",但那是上游故障而非账户额度问题 ——
为它全场停手 2 分钟救不了它,只会白拖慢。需要单独写一个isLimitError(e)。 - fail-open 是硬要求,不是可选项。 所有读取路径包 try/catch,异常一律按"无冷却"处理;
记录损坏(until不是数字)也要安全归零。
最坏退化成"单实例内冷却",与改动前同级 —— 绝不能让冷却机制本身变成新的故障源。 - ⚠️ 加了冷却会拉长最坏耗时,必须回头重算「任务 TTL」。
最坏 =尝试次数 × (冷却时长 + 单次超时) + 累计退避。
实测:5×(120s+300s)+210s = 38.5min,直接超过原来的 30min TTL →
超 TTL 后任务行被 prune 删掉、后续 UPDATE 匹配 0 行 → 静默 no-op → 前端「任务凭空消失」。
别用"砍单次超时"来腾余量 —— 限流时上游只会更慢,砍超时会误杀本该成功的任务,方向反了。 - 怎么证明"跨实例"真的成立:写完不可信,要两个独立进程验 ——
进程A 写入冷却 → 退出 → 新进程 B 能读到剩余时间 → 才算共享。
单测若跑在本地后端(不调init()),永远证明不了这一层。
⚠️ 在生产上跑这个探针时,把冷却时长临时压到 2s(用 env 覆盖):
探针会往生产 Redis 写真实键,用默认 120s 的话,恰好在窗口内提交的真实用户任务会被白等 2 分钟。
> 配置化建议:冷却时长做成 env(..._COOLDOWN_MS),并支持显式设 0 一键关闭 ——
> 紧急回退时不必改代码、不必发版。
⑨bis、把「账户级冷却」写成原子条件写时的两个陷阱(2026-09-15 实战)
冷却/排队类状态通常长这样:{ until: now + ms },窗口内所有实例都不许提交。
把它从「读 → 判断 → 写」改成原子条件写(SET k v PX ttl NX)时,有两个必踩的坑。
陷阱 1:缓冲 TTL 会制造一段「键还在、但语义已过期」的死区
为了让业务窗口的边界不抖动,记录的物理存活时间通常比业务窗口长一点(例如 ms + 5000)。
于是时间轴上多出一段:
|---- 业务窗口 until(真在冷却)----|-- 5s 缓冲:until 已过,但键还在 --|
在缓冲期里 setNX 会失败(键存在)。如果失败后一律当成「还在冷却中」:
setNX 失败 → 读剩余时间 = 0(窗口已过)
→ 返回 0,既不开窗也不给出有效等待
→ 冷却在接下来 5 秒内**静默失效**(新来的 429 打不开窗口,日志毫无异常)
修法:setNX 失败后必须区分两种情形。
const won = await kv.setNX(KEY, rec, ttl);
if (won) return ms; // 我是赢家,开窗
const rest = await cooldownRemainingMs();
if (rest > 0) return rest; // (a) 真在冷却中 → 不续期(防活锁),只回报剩余
await kv.set(KEY, rec, ttl); // (b) 死区 → 退回无条件覆盖写,重新开窗
return ms;
- (a) 不续期是必须的:否则持续 429 会把窗口无限后推 → 活锁,比限流更糟。
- (b) 重开窗才让冷却在缓冲期内仍然有效。
> ⚠️ 这个回归是把"覆盖写"改成"条件写"这个动作本身引入的,不是原有问题。
> 改原子原语时,必须重新推演时间轴上的所有区段 —— 尤其"键还在但语义已过期"的缓冲区。
> 单测里专门加一条「窗口已过但键仍在 → 必须能重新开窗」把它钉住(本例为 C17 + 变异 M13)。
陷阱 2:原子性有分级,别给错了保证
| 手段 | 保证 | 不保证 |
|---|---|---|
SET k v PX ttl NX |
原子条件写:「两个都成功」不可能 | 「同一时刻只有一个赢家」—— 串行两次,后者照样失败 |
Lua EVAL |
「检查 + 写入」复合操作绝对原子 | 分布式锁租约/续期的正确性 |
所以:先想清楚需要哪一个性质。 如果调用方不做控制流决策(抢不到就退让、去走退避),
「原子条件写」就够了 —— 不必为此上 Lua,脚本分发/版本管理/NOSCRIPT 回退都是新增失效面。
并且:判定赢家必须用 setNX 的布尔返回值,不能用派生出来的业务值(如剩余毫秒数)——
同毫秒内两个调用者的返回值数值相等,a === b 根本区分不开
「两次独立写入同一个值」和「一次写入被读到两次」。这条曾让一条正确的断言当场误报失败。
配套:冷却阈值的可观测性
账户级配额是不可控外部变量,同一配置在不同时间点的"桶剩余量"不同 ——
所以 "调参 → 多轮复测 → 比成功率"这套实验做不了,结果不可比、归因不了。
不要假装做了这个实验。改为导出观测接口(如 cooldownStats() 返回
{enabled, cooldownMs, backend}),靠生产真实触发数据逐步收敛;
并让冷却时长可配 + 支持显式设 0 一键关闭,紧急回退不必改代码、不必发版。
另需注意 backend 字段本身会说谎:共享态模块的 mode 初值通常是 local,
只有显式 init() 且连接成功后才翻成 redis。
探针若不调 init(),就会在确实开着 Redis 的机器上打印 local → 让人误判"跨实例保护没启用"。
→ 探针必须自己 init() 并等连接就绪;且必须用异步等待(同步阻塞会冻住事件循环,
连接永远无法推进 —— 详见 mutation-test-verify 的探针相关章节)。
修法清单(按优先级)
| 优先级 | 修法 | 理由 |
|---|---|---|
| P0 | 错误归一化保留机器可读 code,按 code 映射可操作中文提示 | 让用户知道「该充值 / 该换 Key / 该改文案」,而不是「请重试」 |
| P0 | 成功判定同时看 HTTP 状态和业务 status | 200+failed 会被静默当成功,或报出无关错误 |
| P0 | 产物函数回退(simulate)时必须把真因带进返回值,且调用方必须读 | 否则用户拿到「看起来成功」的占位产物,错误被彻底吞掉(见 ⑧bis) |
| P1 | error.code 落库到任务的 error 字段 |
后续可聚合统计,不用再捞日志 |
| P1 | 空 taskId 时优先判业务 status,而不是报「未返回ID」 |
消除语义混淆 |
| P2 | 可重试错误(429 / 5xx / 超时)做退避重投,且退避量级必须对齐上游限流窗口 | 见第 ⑨ 节;量级不对的重投等于白打(99s vs 240s 实测:0 救回 vs 1 救回) |
| P2 | 真·审核类失败才做降级改写重试(如剥离台词只留画面描述) | ⚠️ 仅在 ⑥⑦ 确证是审核后才做 —— 限流会伪装成审核,此时改写无效 |
| P2 | 成功与失败两条路径都要落 retry_count,并定义 done 且 retry_count>1 为「被救回」口径 |
否则治理效果永远不可观测(见 ⑨-D) |
| P2 | 上游原文不要直接暴露给终端用户,映射成领域语言 | 「文本可能包含敏感信息」对创作者毫无指导意义 |
| P1 | 先判「并发级 vs 账户级」限流(判据:只剩自己时重投能否过) | 判错方向整轮白做 —— 账户级限流加重试只会更糟(见 ⑨-G) |
| P1 | 若属账户级:做跨实例共享的 429 冷却闸(SET key NX PX),且在提交之前等 |
每任务各自退避对账户级无效;进程内冷却 × N 实例 = 没冷却(见 ⑨-G) |
| P2 | 调大退避后复查任务 TTL:次数 × 单次超时 + 累计退避 不得逼近 TTL |
超了会表现成「任务凭空消失」(行被 prune 后 UPDATE 静默 no-op) |
反模式(别做)
- ❌ 直接读代码找 bug —— 先定错误文案的户籍,能省一整轮。
- ❌ 跳过资损检查就下结论 —— 会把免费用户的普通失败误报成事故。
- ❌ 只看一台机器的数据 —— 多机部署时失败可能只在一边。
- ❌ 凭错误文案字面含义推断根因 —— 「内容审核」可能是余额不足,「参数不合法」可能是鉴权失败。
- ❌ 用「provider 名」判断通道可用 ——
resolveEffectiveProvider只说「该用谁」,
不说「找得到」。实测据此打印「✓ 真实通道 jimeng」,而 12 张图全成 SVG 占位(见 ⑧bis)。 - ❌ 只看
ok:true不看meta—— 产物函数把真因写进meta.realError后回退 simulate,
调用方不读 → 静默降级,用户与日志都看不见(见 ⑧bis)。 - ❌ 只看 key 长度判 key 有效 ——
key.length > 0只说明「有配」,
实测key=27字符却报The API key format is incorrect。耗时量级才是硬判据(见 ⑧bis)。 - ❌ 拿「重试次数」当成本依据 —— 先查
cost/free/ 角色类型。 - ❌ 对语义级审核做敏感词替换表 —— 做不了,先做变量隔离确认是哪种。
- ❌ 退避基数取秒级 —— 上游限流窗口是分钟级时,99s 的累计等待全程落在同一窗口内,
必然全败,且每次重投还会把窗口继续往前推。先量窗口,再定 base(见 ⑨-B)。 - ❌ 只调
base不调次数 —— 累计是「段数(=次数−1)」的函数,base一旦被cap裁平就失效;
只改一个 = 半修(见 ⑨-B)。 - ❌ 并发闸只包单次尝试 —— 退避期间槽位被抢走 → 在飞数瞬时超限 → 触发上游连坐清任务,
比多等几十秒严重得多(见 ⑨-C)。 - ❌ 在唯一的生产实例上做限流复现实验 —— 放宽闸会伤到真实用户;另起临时实例做。
- ❌ 对账户级限流加重试次数 —— 每个重投都是一次新提交,N 个任务互相把桶抽干,
越重试越糟。要么做账户级冷却,要么老实在 UI 上让用户稍后再试(见 ⑨-G)。 - ❌ 多臂实验背靠背跑在同一账户上 —— 累计消耗会污染后一臂,得到「参数不够」的假结论(见 ⑨-B)。
- ❌ 改完配置只看文件不看行为 —— 必须断言日志里实际的等待秒数 == 配置期望值(见 ⑨-E)。
- ❌ 加了冷却/更长的重试却不回头重算任务 TTL —— 最坏耗时 =
次数×(冷却+单次超时)+累计退避,
很容易悄悄越过 TTL,然后表现为「任务凭空消失」(prune 删行 + UPDATE 匹配 0 行 = 静默 no-op)。
也别用"砍单次超时"来腾余量:限流时上游更慢,方向反了(见 ⑨-G 落地要求 6)。 - ❌ 冷却闸用
retryable判定触发 —— 5xx 也是 retryable,为它全场停手只是白拖慢;
必须单独判「限流类」(见 ⑨-G 落地要求 4)。 - ❌ 冷却闸自己 new Redis 客户端、自己写降级逻辑 —— 复用它所在项目已有的共享态抽象
(通常已含 fail-open 与本地回落),否则等于给故障面多加一个点(见 ⑨-G 落地要求 1)。
交付物模板
排查完应输出:
## 现象
<失败记录聚合:时间范围 / 次数 / provider / 统一错误文案>
## 上游真实响应
<HTTP 状态码 + 原始 body>
## 根因
1. 上游:<行为描述,含错误码>
2. 代码:<文件:行号 + 缺陷描述>
3. 影响面:<有无资损 / 影响哪些用户>
## 证据
<变量隔离实验结果的表格>
## 建议修法
<按 P0/P1/P2 列出,附最小改动位置>
附:本方法论来源
2026-09-15 排查「漫剧平台第 1 集第 5 镜连续 8 次出片失败」:
- 用户看到的「文本可能包含敏感信息,请重试」= 上游原文,代码库(含 git 历史)中从未出现
- 真实上游响应:HTTP 200 + status:"failed" + error.code:"OperationDenied.TextIllegalDetected"
- 资损:0(admin 角色 cost:0, free:true)
- 代码缺陷:callViduVideo() 成功判定只看 HTTP + j.error,且错误归一化丢掉 code
- 变量隔离(第一轮):六种片段单独提交全部 queued → 当时判定「整段语义触发,非固定词命中」
⚠️ 上述「整段语义触发」的结论已于同日被推翻,保留在此作为反面教材:
- 第二轮复测:连「一只猫」「纯中性」甚至「暴力血腥」prompt 也全部 queued,
随后查终态 5/7 返回 {code:"429", message:"limit"} → 真根因是上游 429 限流,
第一轮看到的「片段全过」只是恰好避开限流窗口的假象。
- 第三轮并发探针:N=1 稳定出片(144s → 有真实 MP4);N=4 时 1 个提交被
RequestLimitExceeded.JobNumExceed 拒、3 个查询返回 429;
事后复查 5 个任务 id 全部 FailedOperation.JobNotExist(含从未报过 429 的)
→ 上游超限会把已在跑的任务一起清掉(连坐)。
- 第四轮(重试参数校准):把退避基数从 3s 顶到 60s(累计 99s → 240s)后,
首次拿到 status='done' 且 retry_count=5 的样本 → 重投真的能救回任务,前提是退避量级对。
- 第五/六轮(30s 基数上线生产后复测,base=30s 分别配 3 次 / 5 次):
两臂各 3 个任务,救回 0 个(90s → 0/3,210s → 0/3)。
- 不能据此断言「30s 无效」:两臂背靠背跑在同一账户上,且当天已有 4 轮消耗
→ 累计额度是混淆变量,结论不干净(已写进 ⑨-B 的警告)。
- 更关键的一条反证:ARM B 里兄弟任务 +163s 已结束、场上只剩自己,
该任务 +210s 的重投仍然 429 → 这与「并发级限流」直接矛盾,
说明上游卡的是账户级配额/速率,不是单纯并发数。
- 顺带在 ⑨-E 沉淀了一条通用手法:断言日志里实际打印的退避秒数 == 配置期望值,
用它证明"配置真进了进程"(文件对了 ≠ 进程在用);并由此确认
EnvironmentFile= 只在 systemd 启动时读一次 → 改 env 必须重启。
这条案例的真正价值(两层):
- 取证层面 —— 同一个现象在六轮里先后给出「内容审核 → 429 限流 → 并发连坐 → 账户级配额」
四种解释。每一轮都有看似完整的证据链,但前两轮都是错的。
→ 唯一的解法是第 ⑦ 节那条:定论前必须重复取证,并以轮询后的终态为准。 - 修法层面 —— 先判定限流是「并发级」还是「账户级」,再决定要不要加重试。
这次若不是多跑了一臂、并注意到"只剩自己时仍 429",
就会掉进「继续调大退避参数」的坑 —— 而那个方向永远调不好(见 ⑨-G)。
详见第 ⑨ 节(限流类重试的参数校准)。
同一平台的第二案(2026-09-15 晚):第 4 步「美术 + 定妆图」静默降级
排查对象不是"失败"而是"看起来成功但其实没出真图"——属于反面盲区:
- 探针据
resolveEffectiveProvider('image') → jimeng打印「✓ 真实通道」,
于是整轮验证跑完 12 张图,全部落库「成功」。 - 但 12 张全是
data:image/svg+xml占位(simulate 回退),整步仅 754ms(真出图需 30–48s)。 - 真因:
resolveSubApi('jimeng','t2i')拿到的 key 长度 27,
而实际调用报The API key format is incorrect→ 有 key ≠ key 有效。 - 错误被
generateImage写进meta.realError,而executeStep(4)从不读meta
→ 日志、UI、返回值三处全看不见 → 静默降级(与generateVideo的静默回退同族)。
沉淀的两条(已写进 ⑧bis):
- 判「通道可用」必须下钻到 key + 用量级交叉验证:
provider 名(弱)→apiKey.length>0(弱)→ 单次耗时量级(强:真 30–48s vs 假 <1s)
→ 产物 scheme(强:data:即回退)。 - 调用方忽略
meta/warnings是静默降级的温床:
降级本身没错,错在没人读那份"为什么降级"。取证必须读meta,别只看provider与ok。
> 附带的取证装置经验:本轮的验证脚本把 process.stdout.write 改写成同时写自建日志,
> 后来后台任务被上层误报 failed、TaskOutput 甚至查不到该任务时,
> 自建日志成了唯一取证路径 —— 结论:长跑脚本不要依赖外部管道/tee 收日志。
补遗 A:修复「静默降级」的三条实现纪律(2026-09-16 实战)
同一平台把上述第 4 步降级修完之后沉淀的 —— 取证得出结论只是第一步,修法本身也有坑。
A-1 判据必须做成可离线单测的纯函数
不要在两处循环里各写一遍 if (meta.fellBack || ...) —— 那样无法单测,也无法变异测试。
抽 pickDegrade(result, wantProvider) → {fellBack, provider, realError, reason} + summarizeDegrade(warns),
埋点处只做 if (deg.fellBack) warns.push(...)。判定逻辑与埋点分离,坑就被关进一个可测的小函数里。
A-2 ★ 多条件「或」判定:每个条件都要有一个只满足它的样本
本文档最值得记住的一条测试教训。 降级有两个独立来源:
meta.fellBack—— 真实调用发起过但失败(真因在meta.realError)got==='simulate' && want!=='simulate'—— 连 key 都没有,压根没发起真实调用
代码是 const fellBack = !!meta.fellBack; const toSimulate = got==='simulate' && want && want!=='simulate';
最终 if (fellBack || toSimulate)。
首轮变异测试(把 fellBack 强制改成 false)逃逸了 ——
因为测试样本 got='simulate' 同时满足两条分支,|| 右边兜住了,测试照样绿。
⇒ 这条断言根本没在测 meta.fellBack,而它看起来是全绿的。
修法:补一个 meta.fellBack=true 但 provider='vidu'(即 toSimulate=false)的样本 ——
只有第一条分支能识别它,改坏第一条就必红。再加一个同 provider 无 fellBack 的对照(防"恒判降级"的假阳性)。
> 通用规则:凡是 A || B 型判定,测试矩阵里必须有「只满足 A」「只满足 B」「都不满足」三类样本。
> 缺少「只满足 A」这一类 = A 分支完全没有测试覆盖,而报告会显示"全绿"。
> 变异测试是唯一能自动暴露这件事的手段 —— 所以「单测全绿」不能替代变异测试。
A-3 修完之后,用真实通道再跑一次证明「不误报」
只测降级路径是不够的 —— 一个「永远报降级」的实现也能让降级用例全绿。
必须补一次真实成功的跑,断言不产生告警。本轮的做法:dev 上用进程内重定向换到可用通道,
真出 12 张 PNG(2.2–3.0MB),断言 genWarnings 为空。
附带的高价值发现:这次真实跑真的抓到 1 张降级(上游超时 180s),
而修复前它会被静默吞掉 —— 用户看到「美术 13 张」以为全部成功。
⇒ 「修复价值的最强证据」往往来自修复后跑真实流量,而不是构造用例。
建议:修完静默降级类缺陷后,故意跑一次真实链路并完整收集告警,拿去当交付证据。
补遗 B:取证/部署链路上的两个「静默失败」新坑(2026-09-16)
B-1 ssh -n 会把投递内容吃成 0 字节,且不报错
ssh -n host "cat > /tmp/x.js" < /tmp/x.js # ❌ 远端 0 字节,md5 = d41d8cd9…(空文件指纹)
ssh host "cat > /tmp/x.js" < /tmp/x.js # ✅ 去掉 -n
-n 把远端 stdin 顶成 /dev/null,< src 重定向因此失效,且退出码为 0。
- 规则:凡是要往远端灌数据(
cat > file、bash -s、tar xz),一律不加-n;
-n只在「不希望远端消费 stdin」时才对。 - 这条与旧版「内层 ssh 须
-n」不矛盾 —— 后者只在「脚本本身从 stdin 喂进去」的架构下成立。 - ⚠️ 唯一能抓住它的手段:转发后双边比对字节数 + md5。
ls -l对 0 字节也返 0,等于没校验;
必须wc -c。"命令成功"从来不是"数据到了"的证据。
B-2 变异测试必须单飞,否则会伪造出"未捕获"
本机工具链存在「单条命令被执行两次(串行跑两遍)」的特性。
若变异脚本用目录锁做单飞,两遍之间锁会被释放 → 第二遍照样进 →
两个实例争抢同一个源文件 → 替换被套用两次 → 语法错误 →
被误读成「该变异未被捕获」(实际是脚本自己把自己搞坏了)。
- 有状态操作一律用「完成标记 + 幂等判断」(「已是目标值就跳过」),别指望锁。
- 替换串不要包含锚点本身(否则二次套用会继续匹配)。
B-3 改代码前必须核实基线是"线上真身"
一次差点酿成事故的核对:手上有三个同名文件、md5 全不同 ——
线上真身(71294B)、某次导出的基线(67010B)、本地副本(76292B)。
本地副本里已经删掉了线上正在跑的某个功能实现,若直接推本地版 = 把线上功能回退掉。
- 规矩:动手前
base64 -w0从线上拉一份真身(base64 防字节损坏),md5sum逐字节校验,
在真身上打补丁,再把结果覆盖本地副本(diff -q确认逐字节一致)。 - 「文档/记忆里记的版本」与「线上真身」经常不一致(本项目生产 git 早已分叉)。
不要相信任何二手版本号,只相信字节。
补遗 C:重复调用 = 重复扣费(2026-09-16 实战,直接烧钱)
C-1 症状与定位
运维脚本执行一次,生产流水却出现两条相同的扣费:
| id | delta | reason | 时间 |
|---|---|---|---|
| 38 | −3 | charge:llm |
21:26:59 |
| 39 | −3 | charge:llm |
21:27:25 |
间隔 26 秒 —— 是「同一条命令被串行执行两遍」造成的。但根因不在调用方,
读完产品代码才看清:
async function runOneStep(projectId, userId, stepId, opts) {
// ① 只校验「前置依赖是否完成」
const missing = def.deps.filter(d => { const rec = states.find(x => x.step === d); return !rec || !rec.done; });
if (missing.length) return { ok:false, error:'请先完成第 ... 步' };
// ② 直接置 running 并扣费 —— 全程没有「本步是否已完成」这一问
await workflow.markStep(projectId, stepId, 'running');
const pc = await precharge(userId, stepId, project);
「依赖检查」不是「幂等检查」。 依赖满足只说明「可以开始」,不说明「还没做过」。
runPipeline 的 for 循环里是同一个模式。
C-2 取证要点:账本才是判据,返回值不是
调用方拿到的返回值是内存对象,「跑成功」和「扣了几次钱」是两件事。
唯一硬证据是流水表(本项目 wallet_events:user_id / delta / reason / balance_after / created_at)。
- 查法:
SELECT * FROM wallet_events ORDER BY id DESC LIMIT 10;
—— 两条同 reason、同 amount、balance_after递减两次 = 铁证。 - ⚠️ 别用「返回值里的
cost」判断总花费 —— 那是单次的。 - 记账类核对必须在给结论之前做(与 ④ 节「查有无资损」同源)。
C-3 影响面判断:别只修调用方
发现重复扣费后,必须把所有入口列出来(本项目三条):
| 路径 | 门禁现状 |
|---|---|
运维脚本(runOneStep) |
❌ 无 |
一键全流程(runPipeline) |
❌ 循环内同样无 |
前端按钮(executeStep + 独立 charge) |
⚠️ 仅前端兜:按钮 disabled 重入锁、数据层幂等判断 |
只要有一层是"前端兜",就等于没有兜:绕过前端(并发请求 / 直接调接口 / 脚本 / 请求重放)
即可复现。而前端兜这条路本项目已经出过事故 ——
代码里留着上一轮修复的注释:
// 旧实现在 cb 之前就 btn.disabled=false,导致异步生图期间按钮可重复点击 → 重复计费。
⇒ 教训:在调用方补防护,是「每新增一个调用方就复现一次」的隐患结构。
本项目该 bug 已复现四次。幂等门禁必须加在被调用的服务端函数里,
在 precharge() 之前,并提供 opts.force 作为显式重跑出口(重跑是合法需求,但必须主动)。
C-4 自检问句(写完任何「执行 + 计费」的服务端函数后过一遍)
- 这个函数被重复调用会怎样?(不只是并发,还包括串行重复、用户连点、脚本跑两遍)
- 我有前置依赖检查,但我有本步已完成检查吗?这两者是不同的东西。
- 我的防护写在服务端还是调用方?——写在调用方的,下一个调用方就会忘。
- 用户要怎么主动重跑?没有出口的话,你的门禁会变成新 bug。
补遗 D:资损类缺陷的修复位置 —— 「调用方纪律」vs「服务端强制」(2026-09-16 实证)
背景:一个「重复执行 → 重复扣费」的缺陷。查证时发现,
同类 bug 在本项目已经出现过四次,前三次的修法都是「在调用方补一层防护」:
| 次序 | 修在哪 | 结果 |
|---|---|---|
| 第 1 次 | 前端按钮 disabled 重入锁(异步回调完成前不解锁) | 单点修复 |
| 第 2 次 | 某预处理函数的「产物非空则跳过」判断 | 单点修复 |
| 第 3 次 | 运维脚本层的「完成标记」文件 | 单点修复 |
| 第 4 次 | —— 换了新的调用方,缺陷原样复现 | 生产 wallet_events 出现两条 charge:llm −3 |
关键判据:如果一个缺陷修了 N 次还复现,那它每次被修的位置就都是错的。
根因不在调用方,在被调用的那个函数内部。
症状识别
- 同一类故障在不同调用路径上反复出现(前端 / 脚本 / 定时任务各来一次)
- 每次修复都能复现「修完就好、加个新入口又坏」
- 代码里散落着多份「防护」,但被调用函数本身没有任何校验
正确做法:门禁下沉到被调用函数
调用方A ─┐
调用方B ─┼─→ [被调用函数:内部校验,一处生效]
调用方C ─┤
未来新方 ─┘
具体到「已完成的操作不要重复执行」,应当在扣费动作之前加:
// 在 precharge()/扣费调用之前 —— 位置是关键
const cur = states.find(x => x.step === stepId);
if (cur && cur.done && !opts.force) {
return { ok: true, skipped: true, cost: 0,
note: '该步骤已完成,已跳过(避免重复扣费)。如需重跑请显式指定 force。' };
}
await markStep(projectId, stepId, 'running');
const pc = await precharge(...); // ← 门禁必须在它之前
四条设计取舍(照抄即可)
| 决策 | 选择 | 理由 |
|---|---|---|
| 放哪 | 服务端/被调用方 | 调用方纪律 = 每新增一个入口就要记得加一次,已经漏了四次 |
| 返回什么 | skipped + ok:true,不报错 |
重复调用不是错误,是「这件事已经做完了」。报错会让调用方以为失败 → 触发重试/告警,把一次正常去重变成一次假故障 |
| 读状态失败 | fail-open(放行) | 宁可多扣一次(用户可见),也不因一次存储抖动把整条流程卡死 |
| 确实要重跑 | opts.force 显式出口 |
重跑是合法需求(改了输入要重算),但必须是主动声明的,不能是「手滑点了两次」 |
已知边界必须在注释里写明
「跑过但状态机没标 done」的项目会被判为『未完成』而放行。
这是刻意的保守选择:宁可多扣(用户可见、可退),也不误拦
(功能直接不可用,且用户看不懂为什么)。写进注释,避免后人以为是疏漏。
影响面清单(改前务必列全)
修 root cause 前,把所有调用路径列出来 —— 这既是影响面评估,
也是验证覆盖清单:
| 路径 | 代码位置 | 改前状态 |
|---|---|---|
| 前端单步按钮 | server.js 路由 → pipeline.executeStep + billing.charge |
服务端零防护,仅靠前端兜 |
| 「一键全流程」 | runPipeline 的 for 循环 |
循环内无本步门禁 |
| 运维脚本 | 直调 runOneStep |
无门禁 |
| 未来新增 | —— | 门禁下沉后自动覆盖 |
> ⚠️ 前端兜防连点是个坏模式:它让服务端看起来有防护,
> 实际只是把资损风险推迟到「下一个人写新调用方」的那天。
验证要点
- 正向:已完成的步骤 → 返回
skipped:true/cost === 0 - 反向对照(必须做):未完成的步骤 → 不得被误拦(否则门禁写错成恒真,
正向照样全绿而业务全废) - 硬证据:查钱包流水表,确认目标时间窗内新增扣费 = 0
(这是唯一能证明「真的没扣钱」的证据,返回值只能证明「代码里 return 了」)
补遗 E:判「某个上游参数是否真的生效」—— 请求体拦截 + 字节级 + 异值对照组(2026-09-18 实证)
场景:代码里稳定传了某个参数(如 seed),但怀疑上游根本没理会。
为什么不能用感知哈希下结论:本项目已实测 dHash 对「是不是同一张图」根本不敏感(同角色跨镜、跨角色、同角色全都挤在 0.47~0.55)。
拿 dHash 得出"同 seed 与异 seed 无差异"→ 证据强度不够,不能据此删代码。
三步取证(缺一不可)
① 拦请求体 —— 先证明「我们真的发出去了」
有些参数我们这边就丢了(解构传参漏字段,函数入口静默丢弃,不报错不留痕),根本到不了上游。
不改生产代码的做法:monkey-patch global.fetch 把 body 抓下来。
const origFetch = global.fetch;
const captured = [];
global.fetch = async function (url, opts) {
if (opts && opts.body) captured.push({ url: String(url), body: String(opts.body).slice(0, 800) });
return origFetch.apply(this, arguments);
};
// 断言:/"seed"\s*:\s*\d+/.test(body),且值与传入一致
⚠️ 若模块把 fetch 提前存成常量,这里抓不到 → 必须显式报「未捕获到请求」,不能假装成功。
② 比对字节而不是感知哈希 —— 同值跑两次
同值 → md5 相同 ⇒ 上游对该输入是确定的(可以依赖)
同值 → md5 不同 ⇒ 至少"像素层面不确定"
本轮实测:同 prompt 同 seed 两次,md5 完全不同,连文件大小都不同(2363570 vs 2347694)。
③ 加异值对照组 —— 没有它,②的"不同"无法定性
只跑两次只能得到"一样 / 不一样"。不一样时无法区分:
- (a) 上游完全忽略该参数 → 两两差异程度相当
- (b) 上游确实用了,但输出有随机编码抖动 → 同值之间更接近
所以序列是 [S1, S1, S2],三者互比。多花一次调用,但结论从"存疑"变成"定性"。
第四条:看「返回地址是否复用」
三次返回同一个 URL → 上游有缓存。
这是产品级副作用:用户想重跑一镜却永远拿到同一张图,比"参数无效"更值得警惕。
本轮:3/3 各不相同 → 无缓存 ✅
结论的写法(别让后人误读)
本轮最终结论:vidu / tokenhub 出图链路上 seed 是无效参数(零收益、零害处)。
但 callComfyUIImage 走本地 KSampler,seed 真的生效 → 因此保留该参数,
只在文档里删掉"稳定 seed 提升一致性"的预期 —— 而不是顺手删代码。
可推广的完整链条:
本地发出去了吗(拦请求体)→ 上游响应变了吗(字节级)→ 变化可归因吗(异值对照)→ 有没有副作用(地址复用)。
四条走完才叫"取证";只走第一条叫"我以为"。