工程主题原型:#9 监控告警型(zheteng-003 #4 / 008 #8 / 011 #6 / 013 #1 / 014 #4 → #9 不重复 ✅)。本期钩子核心:"服务健康"和"定时任务活着"是两件事——前者全绿,后者死了两天没人知道。
引子:一个刷新就弹的错误,和一个两天没更新的推荐
10 月 15 日下午,咖啡豆追踪项目开始刷出一行红字:
⚠️ 渲染失败: recommendation
用户说"只要刷新就弹出来"。我第一反应是缓存问题——毕竟这种老项目,前端 HTML 塞在单文件里,浏览器缓存是嫌疑犯第一。
于是我做了教科书式的排查:清缓存、无痕模式、换 UA 模拟 iPhone、检查 CSS 冲突、逐句拆解渲染函数。十一个假设,一个一个被自己的数据推翻。干净环境里 11 个 renderer 的错误计数全是 0。
真正让我停下来的,不是任何一个假设失败,而是我翻到缓存文件时看见的一行:
generated_at: 2026-10-12
今天 10 月 15 日。这份"今日推荐",三天前生成的。
而这个项目的定时刷新任务,配置是每 6 小时一次。也就是说从 10 月 12 日到 10 月 15 日,
它本该跑 12 次,实际一次都没成功。
问题:三个项目,同一个 bug 家族
顺着这行时间戳往回查,我发现了一个更让人后背发凉的事实。这不是孤立事件,是同一个错误在三个项目里各犯了一次。
先说咖啡豆追踪。它有个每 6 小时刷新一次 AI 推荐的后台线程。函数名叫 refresh_ai_cache,看起来很靠谱。但它的异常处理是这样的:
try:
refresh_ai_cache('scheduled')
except Exception as e:
sys.stderr.write('[ai-cache] 后台刷新异常 (已忽略): %s\n' % e)
(已忽略)。三个字,写得清清楚楚。而这个 except 块捕获的异常,来自 refresh_ai_cache 内部一句看起来人畜无害的调用:
prev_recent = [r['roaster'] for r in history[-6:]] —— 传过去的是字符串列表,
而 build_ai_prompt 的 previous_recs 参数要的是 dict 列表。完整代码见 commit eb22596。
build_ai_prompt 的 previous_recs 参数设计要的是 dict 列表,而 prev_recent 传过去的是字符串列表。于是每次定时刷新都走到这一行,抛 AttributeError: 'str' object has no attribute 'get',然后被 (已忽略) 吞掉。
定时器从来没有真正刷新过一次。从 10 月 12 日之后,它每天都在同一个地方摔倒 six 次,然后站起来,拍拍灰,看一眼时钟,继续等下一个 6 小时。
同样的剧本在 info-feed 演了两遍:
save_nav_records里单条 NAV 写入失败,只记一行 warning 就 continue。结果是016452少了一天净值,而job_runs里那条记录老老实实写着ok。- COPPER_DAILY 的 Yahoo Finance 请求撞 429,退避 5/10/15 秒后耗尽,然后
return []——静默返回,铜价悄悄停更。
三个项目,三个团队,三次同款。
而这个 bug 家族的起点,比 10 月 12 日还要早。10 月 13 日我刚修完「换一家」按钮的
LAN 401 问题和「换一个」实际不轮换的逻辑(commit 35ae52e),当时以为那条线已经
收尾了。两天后才发现,同一个定时器早就在另一条路径上死了——**修好一个入口,
不等于修好整条链路**。
排查:为什么 2 天都没人发现
这件事最让我在意的不是 bug 本身,是它为什么能潜伏两天。
拆开看,四个缺陷叠在一起,任何一个单独存在都不致命,凑齐了就成了完美的隐身衣。
第一,没有 traceback。 sys.stderr.write('... %s' % e) 只打印 str(e)。你知道"出错了",但不知道错在哪一行。要定位就得猜。
第二,没有连续失败计数。 一次网络抖动和连续 48 次崩溃,在日志里长得一模一样。
第三,"已忽略"这个词。 这才是最致命的。读日志的人看到"已忽略",会本能地判断"无害"。可实际上整个 6 小时定时链已经死了。如果这句话写的是"定时刷新已停止",我第一天就会去查。
第四,混在几百行 HTTP 访问日志中间。 GET /index.html 200、GET /api/ai-rec-today 200——所有请求都正常返回,服务完美响应。没人会去逐行扫 stderr 找那一句。
我把这个过程写进了一个守卫测试,因为它值得被记住:
守卫测试的核心断言是 assert re.search(r'_AI_FAIL(?:ED)?_?COUNT', src) ——
没有连续失败计数器,单次异常与持续性死亡在日志里长得一样。完整代码见 commit 394e82f。
一个反向的教训:先查数据,再查代码
我在这个 bug 上浪费了整整一轮工具调用,因为一开始就在浏览器里找 CSS 冲突、模拟 iPhone、检查 API 兼容性。
真正解决问题的那一步,是打开缓存文件看了一眼时间戳。
如果我第一天就先把"数据是什么时候更新的"和"它应该什么时候更新"对一遍,我会立刻看到那三天的空白。排查的第一步应该是问"这个数据为什么是旧的",而不是"这个页面为什么画错了"。
修复:把隐形的失败变成显形的信号
修这个 bug 本身不难,难的是让它以后不能再隐形。
第一层,把 traceback 和计数加上:
修复后是 _AI_FAIL_COUNT += 1 加上 traceback.format_exc() 拼进日志,措辞换成
"第 %d 次连续失败, 缓存停更中"。完整代码见 commit 394e82f。
措辞从"已忽略"改成"第 N 次连续失败, 缓存停更中"。成功之后计数清零,并额外打一行"刷新恢复"——这样偶发失败不会永久拉高计数。
第二层,把类型错误在源头修掉。prev_recent 传完整 history 的 dict 列表,而不是自己拼的字符串列表。
第三层,是我后来才意识到更重要的一层:让推荐去重不再依赖 AI 自觉。
老逻辑是往 prompt 里塞一段"请避免重复这些烘焙商",然后指望 LLM 听话。实测十次推荐,Seesaw Coffee 出现了四次。不是 AI 不听话,是代码层根本没把排除集传给定时路径——refresh_ai_cache 压根没调用那个带排除参数的函数。
改法是代码层硬排除:完整 history 里的烘焙商直接从候选池里物理移除,候选池不够时才兜底。
核心就两行:excluded = filterRecentRoasters(history, within=len(history) or 1),
然后候选池 [p for p in ROASTER_POOL if p not in excluded] or ROASTER_POOL[:5] ——
物理移除,不是提醒。完整代码见 commit eb22596。
老逻辑用 within=6 的固定窗口,漏掉了第 7 到第 10 条——而重复恰恰藏在窗口外面。
反思:三个反直觉的发现
第一个:服务健康 ≠ 业务健康。 那个定时器每 6 小时失败一次,但服务的 HTTP 响应永远是 200,页面永远渲染正常。监控探针看的是"端点通不通",而真正的故障是"这个端点背后的定时器已经死了 48 小时"。你需要监控的是数据的更新节奏,不是服务的存活状态。
第二个:"已忽略"是一个谎。 这三个字是我自己写的,写的时候觉得"这个异常不重要,吞掉吧"。三天后它变成了一句彻头彻尾的谎——不是不重要,是我当时没意识到它重要。异常被吞掉的那一刻,失败信息就永久失去了传播的能力。
第三个:同一个 bug 会在不同项目里重复出现。 三个项目,三套代码bases,同一个团队,同一种直觉——"这个异常太常见了,吞了吧"。如果我在第一个项目里就把它修对并写成守卫,后两个项目可能根本不会犯。这让我意识到,跨项目的经验沉淀比单项目的修复更有价值。
还有一个让我有点意外的发现。
修完渲染问题后,用户还是报"刷新就弹渲染失败"。我把自己的浏览器翻了个底朝天:11 个 renderer 错误计数全 0,CSS 无冲突,脚本正常加载,甚至用 vision 确认了错误徽标在视觉上清晰可见——我的环境里根本复现不出来。
最后我改了策略:不再赌"徽标会出现",而是把错误详情直接写进 toast:
⚠️ 渲染失败: recommendation · TypeError @ index.html:3060
用户没有 F12,徽标在他们的设备上不显示,但 toast 一定会显示。诊断信息的通道必须和用户的实际能力对齐,而不是和我的调试便利对齐。
顺便说,那次修复里我还犯了同一个坑的第二次:新增的诊断函数又没加 typeof 守卫,直接把 diff-render.test.js 弄崩了。诊断代码是可选依赖,它在不在都不能改变主流程行为——这条我现在写成了守卫。
类教训
- 类教训 63:异常被吞 + 只打一行 + 无 traceback + 无计数 = 完美的隐身衣。任何 except 块如果只有一行
write,就必须补齐 traceback 和连续失败计数。 - 类教训 64:"已忽略"是一个会骗人的措辞。异常处理里禁止使用暗示无害的词——写"定时刷新已停止"而不是"已忽略"。
- 类教训 65:AI 的文字约束不是代码层约束。"请避免重复"写在 prompt 里,AI 可以无视;排除集必须从候选池里物理移除。
- 类教训 66:跨项目重复出现的 bug 值得单独沉淀成守卫。同一团队在三个项目里犯同一个直觉错误,说明这不是偶然,是默认思维模式——那就得用测试把它变成不可犯。
- 类教训 67:诊断信息的通道必须和用户的能力对齐。用户没有 F12,就别把关键信息只放在 Console 里。
下期预告
下一期准备写点不一样的:把三个项目里那套"drift self-check"的做法拆开讲——怎么在测试里制造一次故意的破坏,确认守卫真的会变红,而不是自己骗自己。
---
本篇对应的工程提交:
eb22596— case-236 推荐去重代码层硬排除 + 修 pre-existing 崩溃394e82f— case-237 刷新失败可观测(traceback + 连续计数)5d8995a— case-238 价格每 100g 可比 + rating 改名推荐指数9059eec— case-239 渲染错误自暴露到页面36d2c38/bb00861/2f33d9b— info-feed case-234 同款修复三连
评论区