upstream-error-forensics

SKILL运维和基础设施
运维和基础设施 未标注权限 许可证不明确
安装这个技能

排查「第三方 AI/API 调用失败」类报障的取证方法论 —— 从用户可见的错误文案反推真实根因,区分「自己的 bug / 上游行为 / 配置问题 / 资损」;并覆盖「限流/429 类失败的重试治理」:退避参数校准、并发闸设计、连坐风险、「被重投救回」的指标口径与复现实验装置;以及修完降级后的实现纪律(纯函数化判据、多条件「或」的测试矩阵、真实通道反证)与**「重复调用 = 重复扣费」类计费缺陷的取证与修法**(账本才是判据、幂等门禁必须放服务端)。当用户报告「某个功能一直失败」「生成不出片」「报错但看不懂」「重试也没用」,需要判断失败是否造成资金损失,或要设计/调优重试与并发限流,或要**修复静默降级并证明修好了**,或怀疑**重复扣费/多扣钱**时使用。触发词:排查失败、为什么失败、一直重试失败、报错看不懂、出片失败、生成失败、上游报错、错误文案、资损、限流、429、退避、重试参数、并发闸、连坐、重试没救回来、静默降级、假成功、显示成功但没出图、回退成占位图、通道不可用、有 key 却出不了图、变异测试逃逸、断言冗余、测试全绿但没测到、投递 0 字节、重复扣费、多扣钱、扣了两次、重复调用、连点两次、幂等门禁。

一句话选型结论

按资料字段整理

upstream-error-forensics是运维和基础设施类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类运维和基础设施 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_6af9f15b/upstream-error-forensics
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

运维和基础设施
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_6af9f15b仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验未标注上游最后核验时间 · 数据更新 1791622043
正文体量59.6 KBSKILL.md 原文大小,越大说明说明书写得越细

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
查服务与日志

按现象定位服务状态与日志线索。

写部署与巡检脚本

把重复的部署、巡检写成脚本。

故障排查

按步骤排查网络、依赖与配置。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在运维和基础设施场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
upstream-error-forensics当前 未标注权限 未标注 未标注 未标注 有
Azure 部署 调用外部服务 GitHub 仓库 664.2 万 Codex、Claude、Cursor 有
Azure diagnostics 问题管理 调用外部服务 GitHub 仓库 657.6 万 Codex、Claude、Cursor 有
Azure aigateway 部署 调用外部服务 GitHub 仓库 655.3 万 Codex、Claude、Cursor 有
Azure AI 搜索 调用外部服务 GitHub 仓库 651.4 万 Codex、Claude、Cursor 有
Azure Cloud migrate 部署 调用外部服务 GitHub 仓库 650.7 万 Codex、Claude、Cursor 有

SKILL.md 全文

59.6 KB
模型实际读到的说明书原文,来自技能仓库

上游调用失败取证(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));           // 原样打印,别先解析

要重点看三件事:

  1. HTTP 状态码。看到 4xx/5xx 不奇怪;看到 200 但业务失败才是大头。
  2. body 里的业务状态字段(status / state / code / success)。
  3. 机器可读的错误码(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 限流,第一轮看到的「片段全过」只是恰好避开限流窗口的假象。

三条血泪教训:

  1. 提交成功(queued)≠ 成功。异步任务的失败可能发生在轮询阶段,必须查 query 结果,
    不能只看 submit 返回的状态。
  2. error.code 形态不可预设。可能上一分钟是腾讯云风格 OperationDenied.TextIllegalDetected,
    下一分钟是纯数字 "429"、message 是裸英文 "limit"。映射表要同时覆盖 code 名与数字码。
  3. 同一现象在不同时间点可能给出不同根因。限流窗口会让失败被「伪装」成审核/鉴权/超时等任意形态。
    → 下结论前至少重复取证一次,且以轮询后的终态为准。

⑦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 是否格式正确 / 未过期 / 额度未耗尽

四级判据(从弱到强,至少用到第 ③ 级):

  1. ❌ resolveEffectiveProvider 返回的 provider 名 —— 只说明「该找谁」,不说明「找得到」。
  2. ⚠️ resolveSubApi(provider, sub, {}) 的 apiKey.length > 0 —— 说明「有钥匙」,
    不说明钥匙能开门。本次实测 key=27 字符,真实错误却是 The API key format is incorrect
    → 长度 > 0 的 key 照样可能是废的。
  3. ✅ 单次调用的耗时量级 —— 真出图 30–48s;假动作(回退 simulate)< 1s。
    量级差 1~2 个数量级,是最难被伪装的判据。 本次 754ms 就是那根救命稻草,一眼假。
  4. ✅ 产出物本身的 scheme —— 真产物是外部 URL(https://…cos…);
    simulate 回退产出的是 data:image/svg+xml;base64,… 内联占位。
    → 判据:url.startsWith(&#x27;data:&#x27;) = 回退,不是真产物。

第二层坑:错误被写进 meta,而调用方从不读 meta。

resolveEffectiveProvider(&#x27;image&#x27;) → &#x27;jimeng&#x27;        // 答:该用谁
  → resolveSubApi(&#x27;jimeng&#x27;,&#x27;t2i&#x27;)  → 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 &quot;realError&quot; &lt;调用方文件&gt; ——
若只出现在产物函数内部 = 无人读,该字段等于不存在。
- [ ] 回退是否记进落库元数据(如 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(() =&gt; retried(opts))
// ❌ 闸只包单次尝试:退避期间槽位被别的请求拿走 → 在飞数瞬时超限
withSlot(() =&gt; once(opts))  // 外面套重试循环

为什么:上游超限的惩罚往往不是「拒绝新提交」,而是把已在跑的任务一起清掉(连坐)。
宁可多等几十秒,也不能瞬时超限。

跨实例:进程级闸的实际在飞数 = 闸值 × 实例数。
nginx 上游有几个实例就乘几 —— 必须把这个乘积跟上游上限对齐。
最省事的收敛方式是先减实例(零代码),正规方案再用共享存储做全局计数
(已有 Redis 就用 INCR/DECR;别用 DB advisory lock —— 视频任务动辄几分钟,
会长期占住连接)。

D. 怎么证明「重投真的救回来了」

唯一口径:status=&#x27;done&#x27; 且 retry_count &gt; 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 &gt; 1 的样本
证「配置真生效」要看行为,不看文件 「文件里有这行」不等于「进程在用这个值」。最硬的证据是断言日志里实际打印的等待秒数 == 按当前配置算出的期望值。本次正是靠这条才摸清注入通道:EnvironmentFile=-/etc/manju/env 只在 systemd 启动时读一次 → 改完 env 必须重启,否则文件对了、进程还是旧值
临时实例要全局互斥锁,不能只有单臂守卫 环境若有「单条命令执行两次」特性,两臂会同时在跑:后起的实例 rm -f $LOG $PIDFILE 会删掉先起者的日志与 PID 文件,整轮证据作废。要用 mkdir(原子)做全局锁,而不是给每臂各加一个标记文件
测「生产配置」时别给被测变量传默认值 脚本里 VAR=${E2E_VAR:-3000} 这种写法会把生产值屏蔽掉 —— 你以为在测生产配置,实际测的是代码默认值。要么根本不设该变量(让它走配置文件),要么显式读出配置文件的值并打印出来自证
读进程 env 的重定向必须放进 sudo sudo tr &#x27;\0&#x27; &#x27;\n&#x27; &lt; /proc/PID/environ 读不到 —— &lt; 是由调用方(无特权)shell 执行的。必须写成 sudo bash -c &quot;tr &#x27;\0&#x27; &#x27;\n&#x27; &lt; /proc/PID/environ&quot;

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; // 对齐上游&quot;1-2 分钟&quot;

async function once(opts) {
  const wait = cooldownUntil - Date.now();
  if (wait &gt; 0) await sleep(wait);            // 冷却期内【不提交】,而不是&quot;提交了再退避&quot;
  try {
    return await submitAndPoll(opts);
  } catch (e) {
    if (String((e &amp;&amp; e.code) || &#x27;&#x27;).includes(&#x27;429&#x27;)) cooldownUntil = Date.now() + COOLDOWN_MS;
    throw e;
  }
}

三条硬要求:
1. 要在「提交之前」等,不是「失败之后」等。 退避是"撞了再退",冷却是"知道疼了就不凑上去"。
2. 冷却状态必须跨实例共享(Redis SET key NX PX)。进程内冷却 × N 实例 = 各暂停各的,
等于没暂停 —— 与「进程级并发闸 × N 实例」是同一个坑(见 §C)。
3. 冷却不解决配额耗尽:账户真没额度要靠充值/提额,冷却只管速率型限流。
→ 因此错误文案必须把两者分开:速率型 → "系统正在自动重试";配额耗尽 → "额度不足,请充值"。

落地时才暴露的 7 条硬要求(2026-09-15 实施记录,上面那段草图是不够的)

  1. 别自己 new 一个 Redis 客户端 —— 复用项目已有的"共享态抽象"。
    若项目里已有 state-store 之类(KV + TTL + Redis/本地双后端),直接用它。
    理由:它通常已经内置了降级(连不上 Redis 就回落进程内),而这正是冷却闸需要的 fail-open;
    另外也避免多开一条 Redis 连接。
  2. 等待必须放在「并发闸之内」。
    放到闸外 → 多个任务各自睡 120s 然后同时醒来一起提交,闸退化成一次性仪式。
    放闸内则天然串行:后来的任务排队等前面那个把冷却睡过去。
  3. ⚠️ 防活锁:已处于冷却中时「不续期」。
    否则一个持续报 429 的任务(轮询每 10s 报一次)会把窗口无限往后推 → 后来者永远等不到,
    比限流本身更糟。做法:trigger 前先读一次,remaining &gt; 0 就直接返回,不重写。
  4. 只对「限流类」错误触发,不要用 retryable 顶替。
    限流类 ⊂ 可重试类;5xx/网络抖动同样"可重试",但那是上游故障而非账户额度问题 ——
    为它全场停手 2 分钟救不了它,只会白拖慢。需要单独写一个 isLimitError(e)。
  5. fail-open 是硬要求,不是可选项。 所有读取路径包 try/catch,异常一律按"无冷却"处理;
    记录损坏(until 不是数字)也要安全归零。
    最坏退化成"单实例内冷却",与改动前同级 —— 绝不能让冷却机制本身变成新的故障源。
  6. ⚠️ 加了冷却会拉长最坏耗时,必须回头重算「任务 TTL」。
    最坏 = 尝试次数 × (冷却时长 + 单次超时) + 累计退避。
    实测:5×(120s+300s)+210s = 38.5min,直接超过原来的 30min TTL →
    超 TTL 后任务行被 prune 删掉、后续 UPDATE 匹配 0 行 → 静默 no-op → 前端「任务凭空消失」。
    别用"砍单次超时"来腾余量 —— 限流时上游只会更慢,砍超时会误杀本该成功的任务,方向反了。
  7. 怎么证明"跨实例"真的成立:写完不可信,要两个独立进程验 ——
    进程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 &gt; 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&gt;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 &gt; 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)。

交付物模板

排查完应输出:

## 现象
&lt;失败记录聚合:时间范围 / 次数 / provider / 统一错误文案&gt;

## 上游真实响应
&lt;HTTP 状态码 + 原始 body&gt;

## 根因
1. 上游:&lt;行为描述,含错误码&gt;
2. 代码:&lt;文件:行号 + 缺陷描述&gt;
3. 影响面:&lt;有无资损 / 影响哪些用户&gt;

## 证据
&lt;变量隔离实验结果的表格&gt;

## 建议修法
&lt;按 P0/P1/P2 列出,附最小改动位置&gt;

附:本方法论来源

2026-09-15 排查「漫剧平台第 1 集第 5 镜连续 8 次出片失败」:
- 用户看到的「文本可能包含敏感信息,请重试」= 上游原文,代码库(含 git 历史)中从未出现
- 真实上游响应:HTTP 200 + status:&quot;failed&quot; + error.code:&quot;OperationDenied.TextIllegalDetected&quot;
- 资损:0(admin 角色 cost:0, free:true)
- 代码缺陷:callViduVideo() 成功判定只看 HTTP + j.error,且错误归一化丢掉 code
- 变量隔离(第一轮):六种片段单独提交全部 queued → 当时判定「整段语义触发,非固定词命中」

⚠️ 上述「整段语义触发」的结论已于同日被推翻,保留在此作为反面教材:
- 第二轮复测:连「一只猫」「纯中性」甚至「暴力血腥」prompt 也全部 queued,
随后查终态 5/7 返回 {code:&quot;429&quot;, message:&quot;limit&quot;} → 真根因是上游 429 限流,
第一轮看到的「片段全过」只是恰好避开限流窗口的假象。
- 第三轮并发探针:N=1 稳定出片(144s → 有真实 MP4);N=4 时 1 个提交被
RequestLimitExceeded.JobNumExceed 拒、3 个查询返回 429;
事后复查 5 个任务 id 全部 FailedOperation.JobNotExist(含从未报过 429 的)
→ 上游超限会把已在跑的任务一起清掉(连坐)。
- 第四轮(重试参数校准):把退避基数从 3s 顶到 60s(累计 99s → 240s)后,
首次拿到 status=&#x27;done&#x27; 且 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 必须重启。

这条案例的真正价值(两层):

  1. 取证层面 —— 同一个现象在六轮里先后给出「内容审核 → 429 限流 → 并发连坐 → 账户级配额」
    四种解释。每一轮都有看似完整的证据链,但前两轮都是错的。
    → 唯一的解法是第 ⑦ 节那条:定论前必须重复取证,并以轮询后的终态为准。
  2. 修法层面 —— 先判定限流是「并发级」还是「账户级」,再决定要不要加重试。
    这次若不是多跑了一臂、并注意到"只剩自己时仍 429",
    就会掉进「继续调大退避参数」的坑 —— 而那个方向永远调不好(见 ⑨-G)。

详见第 ⑨ 节(限流类重试的参数校准)。

同一平台的第二案(2026-09-15 晚):第 4 步「美术 + 定妆图」静默降级

排查对象不是"失败"而是"看起来成功但其实没出真图"——属于反面盲区:

  • 探针据 resolveEffectiveProvider(&#x27;image&#x27;) → jimeng 打印「✓ 真实通道」,
    于是整轮验证跑完 12 张图,全部落库「成功」。
  • 但 12 张全是 data:image/svg+xml 占位(simulate 回退),整步仅 754ms(真出图需 30–48s)。
  • 真因:resolveSubApi(&#x27;jimeng&#x27;,&#x27;t2i&#x27;) 拿到的 key 长度 27,
    而实际调用报 The API key format is incorrect → 有 key ≠ key 有效。
  • 错误被 generateImage 写进 meta.realError,而 executeStep(4) 从不读 meta
    → 日志、UI、返回值三处全看不见 → 静默降级(与 generateVideo 的静默回退同族)。

沉淀的两条(已写进 ⑧bis):

  1. 判「通道可用」必须下钻到 key + 用量级交叉验证:
    provider 名(弱)→ apiKey.length&gt;0(弱)→ 单次耗时量级(强:真 30–48s vs 假 <1s)
    → 产物 scheme(强:data: 即回退)。
  2. 调用方忽略 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 ★ 多条件「或」判定:每个条件都要有一个只满足它的样本

本文档最值得记住的一条测试教训。 降级有两个独立来源:

  1. meta.fellBack —— 真实调用发起过但失败(真因在 meta.realError)
  2. got===&#x27;simulate&#x27; &amp;&amp; want!==&#x27;simulate&#x27; —— 连 key 都没有,压根没发起真实调用

代码是 const fellBack = !!meta.fellBack; const toSimulate = got===&#x27;simulate&#x27; &amp;&amp; want &amp;&amp; want!==&#x27;simulate&#x27;;
最终 if (fellBack || toSimulate)。

首轮变异测试(把 fellBack 强制改成 false)逃逸了 ——
因为测试样本 got=&#x27;simulate&#x27; 同时满足两条分支,|| 右边兜住了,测试照样绿。
⇒ 这条断言根本没在测 meta.fellBack,而它看起来是全绿的。

修法:补一个 meta.fellBack=true 但 provider=&#x27;vidu&#x27;(即 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 &quot;cat &gt; /tmp/x.js&quot; &lt; /tmp/x.js   # ❌ 远端 0 字节,md5 = d41d8cd9…(空文件指纹)
ssh    host &quot;cat &gt; /tmp/x.js&quot; &lt; /tmp/x.js   # ✅ 去掉 -n

-n 把远端 stdin 顶成 /dev/null,&lt; src 重定向因此失效,且退出码为 0。

  • 规则:凡是要往远端灌数据(cat &gt; 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 =&gt; { const rec = states.find(x =&gt; x.step === d); return !rec || !rec.done; });
  if (missing.length) return { ok:false, error:&#x27;请先完成第 ... 步&#x27; };

  // ② 直接置 running 并扣费 —— 全程没有「本步是否已完成」这一问
  await workflow.markStep(projectId, stepId, &#x27;running&#x27;);
  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 自检问句(写完任何「执行 + 计费」的服务端函数后过一遍)

  1. 这个函数被重复调用会怎样?(不只是并发,还包括串行重复、用户连点、脚本跑两遍)
  2. 我有前置依赖检查,但我有本步已完成检查吗?这两者是不同的东西。
  3. 我的防护写在服务端还是调用方?——写在调用方的,下一个调用方就会忘。
  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 =&gt; x.step === stepId);
if (cur &amp;&amp; cur.done &amp;&amp; !opts.force) {
  return { ok: true, skipped: true, cost: 0,
           note: &#x27;该步骤已完成,已跳过(避免重复扣费)。如需重跑请显式指定 force。&#x27; };
}
await markStep(projectId, stepId, &#x27;running&#x27;);
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 &amp;&amp; opts.body) captured.push({ url: String(url), body: String(opts.body).slice(0, 800) });
  return origFetch.apply(this, arguments);
};
// 断言:/&quot;seed&quot;\s*:\s*\d+/.test(body),且值与传入一致

⚠️ 若模块把 fetch 提前存成常量,这里抓不到 → 必须显式报「未捕获到请求」,不能假装成功。

② 比对字节而不是感知哈希 —— 同值跑两次

同值 → md5 相同   ⇒ 上游对该输入是确定的(可以依赖)
同值 → md5 不同   ⇒ 至少&quot;像素层面不确定&quot;

本轮实测:同 prompt 同 seed 两次,md5 完全不同,连文件大小都不同(2363570 vs 2347694)。

③ 加异值对照组 —— 没有它,②的"不同"无法定性

只跑两次只能得到"一样 / 不一样"。不一样时无法区分:
- (a) 上游完全忽略该参数 → 两两差异程度相当
- (b) 上游确实用了,但输出有随机编码抖动 → 同值之间更接近

所以序列是 [S1, S1, S2],三者互比。多花一次调用,但结论从"存疑"变成"定性"。

第四条:看「返回地址是否复用」

三次返回同一个 URL → 上游有缓存。
这是产品级副作用:用户想重跑一镜却永远拿到同一张图,比"参数无效"更值得警惕。
本轮:3/3 各不相同 → 无缓存 ✅

结论的写法(别让后人误读)

本轮最终结论:vidu / tokenhub 出图链路上 seed 是无效参数(零收益、零害处)。
但 callComfyUIImage 走本地 KSampler,seed 真的生效 → 因此保留该参数,
只在文档里删掉"稳定 seed 提升一致性"的预期 —— 而不是顺手删代码。

可推广的完整链条:
本地发出去了吗(拦请求体)→ 上游响应变了吗(字节级)→ 变化可归因吗(异值对照)→ 有没有副作用(地址复用)。
四条走完才叫"取证";只走第一条叫"我以为"。

常见问题

5 问
答案由本站依据条目字段整理
upstream-error-forensics怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_6af9f15b/upstream-error-forensics。安装方式为未标注。装完刷新技能列表或重开客户端。

upstream-error-forensics支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

upstream-error-forensics权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

upstream-error-forensics收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

upstream-error-forensics用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 未标注。