子代理的容错与降级:同一个 429,主循环重试到成功,子代理 1ms 就死了
主代理并行派 6 个子代理做代码审计。网关返回 429,其中 2 个子代理在 1ms 内失败。 主循环遇到同一个 429 会退避重试到成功——同一个模型、同一个网关、同一份配置。
第一反应是"子代理漏了重试,补上"。我们就是这么做的,而且当天就上线了。 那个方向是错的,它引入的第二份实现自己又长出六个缺口,其中一个是新的并发 bug。
这篇讲三件事:那次改错的方向为什么错、收敛之后连续五次撞上同一个形态 (能力写好了、测试全绿、生产从未执行),以及一个我到现在也没能证明它有用的改造。
结论先放这里
- 1ms 失败不是"漏了重试",是"韧性层不可被共享"。 重试/退避/降级全在
ModelFallback内部,而它带实例字段hasFallenBack,全进程单实例 → 6 路并发共用一个降级位。接上去会坏,所以当初绕过了它。 - 补第二份实现的代价已经量出来:那份 170 行的补丁自己长出 6 个缺口 (含一个误删并行子代理活快照的新 bug),且它的单元测试 11 个场景全是单子代理。
- 收敛后 13 处直连 → 1 个漏斗,现在 9 个文件 10 处调用同一入口 (2026-08-09 复核)。9 条旧缺口里 6 条的正解是删代码,不是写代码。
- "能力已实现 ≠ 能力已生效"在这一轮复发 5 次,其中 2 次是改造当天新写的代码。 三处旧的:连接阶段整个 catch 块不可达(异步生成器不同步抛错)、
disableKeepAlive置位后零消费者、非流式降级只有测试在驱动。 - 一处并发放大治理(S2)接线正确却零收益:事件从 0 变 6,总请求数一模一样—— 惊群。"接线了"和"有收益"是两个判据。
- 最诚实的那部分:本地窗口 43 个会话里只有 1 次子代理启动、0 条 retry 事件。 改造有没有降低真实失败率,我现在测不出来。见能力边界。
- 顺带查出一个文档里没有、B4 改造引入的新缺口:子代理的快照被拆成两份, 一个正常输出的子代理流会被判为「不在进展」。见一条改造引入的新缺口。
事故:1ms 是个很有信息量的数字
轨迹 20260730-183103-5e334145,events.jsonl:543-546 配 warn.log:61-63:
| 时刻 | 事件 |
|---|---|
10:35:24.586 | [LLM:OPENAI] API 错误: 429 limit_burst_rate |
10:35:24.587 | [AGENT_LOOP] LLM 错误: ... 429 |
10:35:24.588 | SubagentStop status="error" |
429 到子代理终止,间隔 1ms,中间零重试事件。 1ms 排除了"重试过但都失败"—— 一次退避最少也是秒级。它只可能是"根本没有重试这段代码"。
grep "retry\|backoff\|attempt" src/agent/agentic-loop.ts 在当时零命中,证实了这一点。
还有一个细节决定了修法:429 不是抛异常回来的,而是以流内 error 事件回来 (stream-processor.ts 把它转成 stopReason:"error"),然后被直接 return 成失败。 只给 catch 分支补重试会完全漏掉真实的限流场景——这是后面踩的第一个坑。
第一次修法:补一套重试,当天上线
结构上看,事情很清楚:
主循环: queryLoop → ModelFallback → provider.sendMessageStream
└─ 重试 / 退避 / 降级 / 可用性预筛 / 遥测
子代理: runAgentLoop ──────────→ provider.sendMessageStream
└─ 什么都没有于是我们在 agentic-loop.ts 里加了一个重试循环(约 170 行)、把退避计算抽成共享模块 retry-backoff.ts、补了 11 个回归场景的测试。bun test 7524 全绿,构建自检通过。
这个修法当天就暴露了三个坑,而且三个都不是靠读自己的代码发现的,是靠对比主路径。
坑 1:拍平结构化错误,让重试对一类真实限流完全不触发
初版把流内 error 拍平成 new Error(message),然后用 classifyError 按文本猜分类。 但 openai.ts:1687-1693 的注释早就写明:"OpenAI 族 error 对象常带 type/code 但 message 无关键词"。同一个错误,两种判法分岔:
输入: {message:"OpenAI 流内错误: Service is busy right now",
type:"rate_limit_error"}
classifyStreamError(主路径)→ rate_limit ← 该重试
classifyError(初版子代理) → 普通 Error ← 不重试实测 11 组输入中 10 组分岔(仅 401 一致)。
最该记的不是这个 bug,是初版测试为什么绿:测试用的 OpenAI 报文文本里恰好带 429 字样,classifyError 靠文本命中了。测试选样掩盖了缺陷。
坑 2:抄了主路径的一行,在并发下变成新 bug
主循环 query/loop.ts 在重试前调 clearStreamSnapshot,注释写明"防止看门狗读到上次 失败的脏 lastContentProgressAt 立即误杀"。初版漏了这行,补上——然后引入了一个新缺口。
原因是两个事实相乘:
agentStreamIndex = 10000 + turns—— 只含轮次,不含子代理身份- 快照 key 是
${loopId}:${index},而loopId来自 ambient 变量,不是 per-agent
6 个并行子代理各自第 1 轮 → index 全是 10001。实测:
6 个子代理各 emit 后,活跃快照数 = 1 ← 全部共用同一个 key
其中 1 个重试调 clearStreamSnapshot 后 = 0
← 其余 5 路还在跑的快照被一并删掉其余 5 个子代理的看门狗读不到自己的快照,lastContentProgressAt 归零重建, stall 检测失真。「照着主路径抄一行」在并发语境下并不安全——主循环只有一路, 它那行代码从来不需要考虑身份。
坑 3:失败 attempt 的 token 被静默丢弃
服务端对已产出的 token 照常计费。只累加成功那次,重试 N 次后就少计 N-1 份, 表现为"网关账单 > 本地轨迹"。这是成本采集口径那条线上的老问题 换了个入口复发。
所以第一次修法错在哪
那份文档的第一段自己写明了正确判据:
逐个把能力"抄"进
agentic-loop.ts会产生两份平行实现,必然漂移…… 正确做法是抽共享模块,或让子代理走ModelFallback。
但它的正文有六条缺口走的恰恰是它自己警告的那条路,第六章"若只修 3 项"还把这种 挑食固化成了交付建议。判据和方案不同向——不是个别条目错,是脊梁错。
这里有个可迁移的判断:当一份修复方案的开头写着"不要 A,要 B",而正文在做 A, 以开头那句为准。 开头那句通常是想清楚之后写的,正文是赶工时写的。
真根因:韧性层带实例状态,因而不可被并发共享
"子代理绕过了 ModelFallback"这个归因是对的,但它没解释为什么会绕过。
先看接口:executeWithFallback(provider, params, signal) 返回 AsyncGenerator<StreamEvent>,与 provider.sendMessageStream(params, signal)签名与返回形状完全一致,本来就是 drop-in 可替换的。接不上不是技术障碍。
真障碍是:接上会坏。
| 事实 | 并发后果 |
|---|---|
hasFallenBack 是实例字段,而 app.ts 是全进程单实例 | 6 路共用一个降级位:A 降级过一次置位后,B/C/D 想降级会被静默拒绝 |
engine.ts 每次调用前先 fallback.reset() | 并行调用互相 reset 打架。reset() 这个方法的存在本身就是状态共享的证据 |
querySource 是实例配置 | 单实例无法同时正确服务主循环与子代理,而 529 前后台闸门恰好依赖它 |
对照参考实现(claude-code withRetry.ts:185-188):client / consecutive529Errors / persistentAttempt 全部声明在 generator 函数体内部。它是函数,不是类,零实例状态。 这正是它的 N 个并行子代理能安全共用同一份韧性实现的唯一原因。
于是判据变得很短:
韧性属于 provider 调用的下一层,不属于 loop 层;该层必须无实例状态, 由所有调用方共用同一份实现,per-call 差异靠传参表达。
把 hasFallenBack 从实例字段搬进 per-call 的 RetryContext,加一个可选的 PerCallOptions 第四参(querySource / switchMode / agentId / deadlineAt…), 未传时全部回落实例配置——主循环零改动,行为完全不变。
搬完之后顺带消灭了一个被误判为"永久差异"的问题:旧文档因为 fallbackSwitchMode 生产默认是 "ask"(需要 TUI 弹窗),把"子代理不能降级"记成了合理设计差异。 改成 per-call 之后,这只是一个参数——主循环传 ask,子代理传 auto。 差异消失,而不是被绕过。
这是这篇最想留下的一条:"这是合理的设计差异"有时是错误架构的伪装。 判断方法是问一句「如果那个状态不在实例上,这个差异还存在吗」。
范围也错了:不是两条路径,是 13 处
旧文档把问题框成"主循环 vs 子代理"。实测 grep -rn "\.sendMessageStream(" src/ | grep -v "^src/llm/" 是一个漏斗 vs 13 处直连:
| 分级 | 处数 | 都有谁 |
|---|---|---|
| A · agentic 主循环性质 | 4 | 子代理主流、子代理总结流、fork 子代理、无头主循环 |
| B · 关键旁路 | 5 | 自动压缩、部分压缩、上下文折叠、目标评估、hook agent |
| C · 轻量旁路 | 3 | 团队记忆召回、两个安全分类器 |
| 死代码 | 1 | 非流式降级(见下文) |
其中三处的性质值得单说:子代理的总结流连第一次修法的重试都没有;fork 子代理完全裸奔; 无头模式主循环也在直连——而无头场景恰恰最需要重试,因为没有人在旁边可以手动重试。 旧文档一处都没提。
按清单式修法,未列入的直连点将来会各自复现同一套缺口。 这是"抽共享" 比"逐条补"更对的最直接理由:清单会漏,收口不会。
两个分类器刻意不并入
bash-classifier.ts / tool-classifier.ts 是安全判定路径,语义要求与韧性相反: 它们必须快速失败,不能重试等待——重试期间用户被卡住,或更糟,超时后 fail-open 放过一个危险命令。
这里我们和参考实现立场相反,需要明确裁决而不是默认跟随:它把 auto_mode / bash_classifier 放进前台重试白名单,注释写明 "must complete for auto-mode correctness"——它是让分类器重试的。它直连一家,重试成本可预期; 我们多 provider 且默认走网关,分类器重试会在限流级联时放大,而那正是本次事故的形态。
裁决:分类器保持不进漏斗。但审计时修正了一处我自己的表述——我原本把"快速失败" 当优点写。准确的说法是:快速失败之所以安全,不是因为它快,而是因为失败后落到 fail-closed 的兜底链(硬编码层 + needsConfirmation + 竞速超时 deny)。 如果哪天有人"优化"掉兜底链里的任一环,快速失败会立刻变成快速放过。
收敛的结果:6 条缺口的正解是删代码
收口到一个入口 resilient-stream.ts(171 行),调用方只需声明"我是谁" (querySource)和"我能不能弹窗"(switchMode),韧性能力由漏斗统一提供。
2026-08-09 复核,grep -rn "streamWithResilience(" src/:9 个文件、10 处调用 (子代理主流与总结流各一处)。剩余直连只有 4 处:两个分类器(刻意)、 非流式降级(已成为漏斗内部实现)、一处后文要说的漂移。
删掉那 170 行第一次修法之后,旧清单里 9 条缺口的去向是这样的:
| 旧缺口 | 正解 |
|---|---|
| availability 只写不读 / 模型降级缺失 / 遥测缺失 / 空响应不重试 | 删代码——漏斗内本来就有 |
settings.json 的 network.* 对子代理无效 | 删代码——漏斗的参数由 app 层注入,settings 自然生效 |
| 快照误删并行子代理 / AbortSignal 累积 | 删代码——它们是第一次修法自己引入的 |
| 权限检查缺失 / 重试门槛 / 预算腰斩 / 错误文案 | 仍需单独做,与漏斗正交 |
9 条里 6 条靠删代码解决。 这是判断"架构对齐"与"逐条打补丁"哪个更对最直接的证据—— 如果一份修复清单里多数条目的正解是删掉之前写的东西,那不是清单执行得不好, 是清单本身建立在错误的层级上。
一个必须显式确认、不能让它悄悄发生的语义切换
收敛把子代理的重试上界从 maxTimeoutRetries(10) 换成了漏斗的 maxRetriesPerCall(12)。这个数字变化不影响正确性,但它必须被写出来—— 静默改掉一个默认值,日后排查"为什么重试了 12 次"时没有任何线索可查。
顺带说一个相关的诚实问题:maxStreamRetries 那个 "10" 本来就是幻觉。 按 base 5s / cap 120s 的退避累计:
| 重试次 | 累计耗时 |
|---|---|
| 6 | 275s |
| 7 | 395s ← 已超 300s 预算 |
而各 agent 的超时是 180/240/300/360s。实际最多只能跑 5–6 次, 且最后一次退避常常等不完就被外层 abort。这不是 bug(有界是好事), 但"10"这个数字会让人以为有 10 次机会。修法是按外层剩余预算动态钳制, 判据取"退避睡完还来得及发一次请求吗"而不是"还有没有剩余时间"—— 后者会退化成"睡到被砍"。实测预算 20s 时:修前睡满 11s 退避后被外层 abort, 修后 5.7s 提前收手,并给出 剩余预算 14342ms 不足以「退避 11007ms + 一次请求」。
"能力已实现 ≠ 能力已生效",这一轮复发五次
这是本次最值得带走的一条。五处都有完整实现、有注释、部分有测试,唯独没有生产消费方。
三处旧的
① 连接阶段的整个 catch 块不可达。 这一处最严重,而且主循环同样中招:
fallback.ts: stream = primaryProvider.sendMessageStream(params, ...)
↑ 没有 await
anthropic.ts: async *sendMessageStream( ← 异步生成器异步生成器被调用时不执行函数体,只返回迭代器对象。实测探针:
bun -e '
async function* g(){ throw new Error("boom"); yield 1; }
let sync=false, it=null;
try { it = g(); } catch { sync=true; }
console.log("sync throw:", sync, "| iterator:", it!==null);'
# 实测输出: sync throw: false | iterator: true那一行永不抛错,于是整个 catch 块在生产路径不可达。被架空的能力包括 401 retry-once 闸门、ECONNRESET 禁 keep-alive、连接阶段 529 计数与退避重试。
后果比"没做"更糟:401 改由流式阶段的 classifyError 处理 → 判 TerminalError(auth_failed) → 直接把模型拉黑。凭据过期时拉黑模型是完全错误的归因——模型是好的。
这条还改变了叙事:我原本写"主循环有全套韧性,子代理什么都没有"。 修正后是——主循环的"全套"里,连接阶段那一半也是死的。
② disableKeepAlive 置位后零消费者。 grep 实测:fallback.ts 之外零命中。 那 7 处命中全是类型声明、赋值和自我读取,没有任何 fetch 层消费它。 于是双重失效:即便修好①让代码可达,置位也只是改了个内存布尔值,socket 池照旧复用死连接。 必须同批修——单修①会让人误以为 ECONNRESET 处理生效了。
③ 非流式降级只有测试在驱动。 streamWithFallback 实现完整, grep 生产零消费,只有测试文件命中 8 处。我们已经写好了那套降级,但从未接线。
两处是改造当天新写的
这两处让这条纪律从"审计老代码时要注意"变成"写新能力的当下就会犯":
④ 非流式降级首版接错了位置。 只接在"重试耗尽"之后,而空响应 (StreamValidationError 不是 RetryableError)走的是 fail-fast 分支,永远到不了。 实测非流式请求零调用。原因很朴素:两条退出路径长得不像,只顺着"重试耗尽"这条读代码。
⑤ 共享 429 冷却首版只写不读。 读点只在调用入口,但 6 路并发几乎同时起跑, 全部在任何人撞限流之前就通过了入口检查(那时冷却表还是空的)。真正的放大发生在 重试循环里,而重试路径从不复查冷却。实测 shared_cooldown_wait 事件 0 条。
两次都是靠"断言副作用真的发生了"(provider 被调了几次 / 遥测事件几条)抓出来的。 如果断言只写"函数返回值对不对",两次都会绿着交付。
接线正确 ≠ 有收益
上面第⑤条补上读点之后,事件从 0 变成 6 条——接线对了。 但实测总请求数和被拒数一模一样(16/10 → 16/10)。
原因是惊群:把 6 路对齐到同一个截止时刻 = 一起睡、一起醒、一起再撞。 补了确定性错峰(按 agentId 哈希分槽)之后才有收益:
| 场景 · 语义 | 成功 | 总请求 / 被拒 | 耗时 |
|---|---|---|---|
| 窗口放行 2 · 零协调 | 6/6 | 16.0 / 10.0 | 3.3s |
| 窗口放行 2 · 共享冷却 | 6/6 | 13.0 / 7.0 | 4.3s |
| 窗口放行 1 · 零协调 | 2/6 | 21.0 / 19.0 | 3.2s |
| 窗口放行 1 · 共享冷却 | 3/6 | 20.0 / 17.0 | 8.0s |
口径:模拟配额型限流网关(滑动窗口,被拒也算网关负载),6 路并发共享同一 availability,各跑 3 轮取平均。
"对齐"只提供『等多久』,『别一起醒』必须另外做。 还有一个更小的坑: 冷却时长取自退避估计,快配置下可能只有 1ms,写进去瞬间就过期、别人读不到—— 加了 500ms 下限才有意义。
这个 trade-off 要照实记:用延迟换限流级联下的成功率。严苛级联下成功率 2/6 → 3/6(收益记在"稳"),代价是耗时 3.2s → 8.0s(记在"快"上)。 无限流时冷却表恒空,零影响。按项目的四个方向,这一项是明确为"省"和"稳" 牺牲"快",不是免费的。
测了组件,没测装配
同一个模式在这一轮出现两次:
| 案例 | 单元层 | 集成层 | 后果 |
|---|---|---|---|
| 第一次修法的重试 | 11 个场景全绿 | 全是单子代理,零并行 | 并发副作用靠事后独立审计才发现 |
| 安全分类器 | 15 处 fail-closed 断言 | 全在分类器内部,零调用方 | --yes × 分类器不可用的 fail-open 差异从未被发现 |
第二行那个差异具体是这样:同一条命令,分类器可用时被 --yes 拦下, 分类器不可用时被 --yes 放过——因为回退到硬编码兜底后没有了危险标记。 触发条件窄(三个条件同时成立),但代码注释明确承诺过"仍然会阻止危险命令", 而用户无从知晓这个承诺打了折。
测试数量不等于场景覆盖。 7524 个测试全绿,也不代表装配正确。 凡新增"跨组件契约"(并发共享、失败后的兜底链、参数透传),必须有一条端到端断言。
现在这几批改造的门槛断言是 102 条(bun test tests/llm/resilience-b*-gates.test.ts tests/agent/subagent-rate-limit-retry.test.ts tests/agent/resilience-b5-gates.test.ts → 102 pass / 0 fail,2026-08-09 实测),其中集成层的那几条是直接 Promise.all 跑 6 个 runAgentLoop,断言并发峰值 > 1、每路各自重试到成功。
一条改造引入的新缺口
按标准的要求,写"当前状态"之前要回源码核验,不能照抄文档。这一遍核验查出一个 所有文档都没记、且是并发隔离改造自己引入的缺口。
隔离的做法是给快照 key 加一段身份:${loopId}:${agentId}:${index},无 agentId 时保持旧格式逐字节不变(因为主循环和 provider 侧都是无身份调用,两侧必须拼出同一把 key)。
问题是:只有一部分函数接了 agentId 参数。
| 函数 | 签名含 agentId |
|---|---|
emitStreamPhase / emitTimeoutFired / getStreamSnapshot / clearStreamSnapshot | ✔ |
updateStreamStats / emitStreamStall | ✘ |
而 updateStreamStats 恰恰是唯一推进 chunksReceived 与 lastContentProgressAt 的那个函数,调用方在 openai.ts 的 SSE 解析循环里(无身份,index 取 ambient turnIndex)。
实测探针(2026-08-09):
子代理快照 chunksReceived: 0
lastContentProgressAt 是否被推进: false再看下游怎么用它。collector.ts 判断"这条流是慢还是死"的判据是 chunksReceived > 0 && 最近有内容进展。原样复算:
chunksReceived: 0 | still_progressing: false
→ 一个正在正常输出的子代理流,会被判为『不在进展』方向是安全的(误报"疑似 hang",不会误判成健康),但它让"慢 vs 死"的降级判据 在子代理上完全失效——而这个判据存在的意义就是别把慢响应报成 hang。
更麻烦的是第二个后果。同一条子代理流会产生两份快照:
index=10001 agentId=sub-1 chunks=0
index=3 agentId=(判为主循环) chunks=128那份 provider 侧写的快照没有 agentId,于是被 collector 认成主循环那份, 心跳里报出的 chunks=128 实际是子代理的流量。这正是那份文档自己写过的话—— "错误归因比无归因更坏"——只是它当时说的是另外两个函数,漏了这两个。
这条我只做了核验,没有修(不属于本次写作范围)。根因值得记: makeSnapshotKey 的注释把"无 agentId 时保持旧格式"论证得很充分, 但那个论证只覆盖了"两侧必须拼出同一把 key",没有回答 "provider 侧永远拿不到 agentId,那子代理的两侧怎么对上"。 一个可选参数加到 6 个函数里的 4 个,剩下 2 个不会报错,只会静静地写到另一把 key 上。
当前的能力边界
① 改造有没有降低真实失败率,我测不出来。 这是最大的一条。 本地窗口(~/.sid-code/trajectories/sessions,43 个有 events.jsonl 的会话, 2026-07-10 至 2026-08-09,2026-08-09 实测):
| 指标 | 值 |
|---|---|
RetryTelemetry 事件总数 | 651 |
其中 type=retry | 0 |
其中 stream_completed | 651 |
子代理启动次数(SubagentStart) | 1 |
零条 retry 事件不等于"重试机制没生效",它更可能意味着这段时间没撞上限流—— 窗口内唯一那条看着像 429 的日志,核对后是时间戳 09:48:28.429Z。 但这两个数放在一起说明了一件事:这个窗口里既没有子代理并发,也没有限流, 所以它无法证实也无法否证任何韧性改动。
管道本身是活的:有 17 条带 agentId 的事件落到了轨迹里, 证明子代理→全局观察者→events.jsonl 这条腿通着(这条腿本身就是第⑤类死能力的修复—— 子代理的漏斗实例每次调用新建,拿不到主循环那个 per-instance 回调)。 能证实的是"仪器接通了",不能证实"能力有收益"。
绕法:想验证得自己造限流。共享冷却那张表就是这么来的——模拟网关而非真实 A/B。
② 共享冷却的实测是模拟网关,不是真实网关。 真实收益取决于网关的限流算法 (令牌桶 / 滑动窗口 / 并发数)与 Retry-After 是否可信,量级可能不同。 错峰参数(6 槽 × 300ms)和"一次尝试至少值多少毫秒"(5s)都是方向正确的粗参数, 不是精调值——调优需要真实轨迹数据。
③ 快照身份只覆盖了 4/6 个函数,见上一节。方向安全(误报而非漏报), 绕法是排查子代理 hang 时不要采信 still_progressing 与心跳里的 chunks, 直接看 StreamPhase 事件序列。
④ 多 provider 的凭据刷新只有一个通用钩子。 参考实现覆盖四类凭据 (OAuth / OAuth-revoked / Bedrock / Vertex)各自重建 client;我们现在是一个 onAuthRefresh 钩子由 app 层按 provider 注入。这条对我们比对它更重要—— 我们每接一家 provider 就多一套凭据过期语义。钩子在,但每家的实现是否都接了, 需要逐家核验,我没有全部验过。
⑤ 非流式降级未覆盖 fallback 模型的流。 不是漏改:fallback 已经是"换模型" 后的兜底,再叠一层降级会让失败路径过深、归因更难。这条是刻意不做。
⑥ 又一处直连漂移。 复核时发现 src/tool/web-fetch-extract.ts 在直连 (2026-08-07 引入,晚于收敛改造),任何文档都没记它。它是 best-effort 旁路 (失败有降级路径),性质接近"轻量旁路",但它证明了收口没有门禁就会漂移—— 那份方案里写着 F1「韧性能力只有一个入口」,判据是一条 grep 命令, 而那条 grep 从未变成测试。手写的 grep 判据和手写的注释一样,拦不住下一个人。
可迁移的几条
脱离 sid-code 也成立的部分:
- "补上缺失的能力"和"让能力可被共享"是两种修法,先判断是哪一种。 判据很短:如果 A 有 B 没有,先问"B 接上 A 的实现会怎样"。答案是"会坏"时, 真问题在那个"会坏"里,不在"没有"里。
- 带实例状态的组件不能被并发共享,而
reset()方法的存在就是状态共享的自白。 无状态的实现不需要 reset。 - "这是合理的设计差异"可能是错误架构的伪装。 问"如果那个状态不在实例上, 差异还存在吗"。
- 异步生成器被调用时不执行函数体,所以
stream = gen()外面的 catch 块 是死代码。这个坑与语言特性绑定,跟 agent 无关,任何用生成器包装 IO 的地方都会撞。 - 新写能力时就要断言副作用真的发生了,不能只断言返回值。 本轮 5 次"写好了没生效",2 次是当天新写的代码。
- 接线正确 ≠ 有收益。 并发协调类的改动尤其如此——共享一个截止时刻会造出惊群, 把收益整个吃掉,而事件计数看着完全正常。
- 收口类的纪律必须变成门禁,不能留在注释里。 这一轮有两个独立证据: 一处注释明确警告"两条路径都要接,只接一条会成为隐形差异",结果它自己漏传了参数; 一条 grep 判据写进了验收标准却从未变成测试,四个月后漂出第 14 处直连。
相关
- 子代理隔离:五次修复都在补同一个洞 —— 同一批架构改造的另一半: 隔离修的是"状态该不该共享",这篇修的是"能力该不该共享",两者是同一个问题的正反面
- JIT 上下文:让规则在正确的时刻进入上下文 —— 子代理的上下文预算 与规则注入怎么和主循环共享同一套 JIT 机制,附实测基线
- Prompt Cache:一个读错的字段名,把 95% 的命中率记成 2.2% —— 同一类"判据方法对、认错了对象"的度量事故,以及重试为什么会污染成本口径
- settings.json 字段参考 ——
network.*下重试次数与退避的可配字段, 收敛之后这些值才真正对子代理生效