diff --git a/docs/consultant-review/AUDIT-TRACKER.md b/docs/consultant-review/AUDIT-TRACKER.md
index 53522ff..2b9007b 100644
--- a/docs/consultant-review/AUDIT-TRACKER.md
+++ b/docs/consultant-review/AUDIT-TRACKER.md
@@ -1,6 +1,6 @@
# 全站文章顾问审阅状态总控台 (Audit Tracker)
-> **最后同步时间**:2026-10-27 05:32:31
+> **最后同步时间**:2026-10-27 05:36:05
> **全站总文章数**:91 篇 | **已审批完成**:74 篇 (81.3%) | **待审阅**:17 篇
## 一、操作命令速查
@@ -70,17 +70,17 @@
| 56 | `devops` | `when-smart-says-healthy-but-your-raid-is-corrupting-data` | SMART 显示健康,RAID 却在悄悄损坏数据 | ✅ 已通过 | Batch-02 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 57 | `devops` | `why-chrome-forgets-its-tabs-in-a-container` | 为什么容器里的 Chrome 会忘记标签页 —— 以及我是怎么修好的 | ✅ 已通过 | Batch-02 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 58 | `devops` | `why-telegram-bot-notifications-die` | Telegram 机器人通知为什么发不出去:IPv6、DNS 和一个追查了一整天的 400 | ✅ 已通过 | Batch-02 | 2026-10-27 05:23 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
-| 59 | `devops` | `why-your-cdp-clicks-silently-fail` | 为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件 | ⏳ 待审阅 | Batch-02 | - | |
-| 60 | `devops` | `why-your-grafana-dashboard-shows-no-data` | Prometheus 明明有数据,Grafana 面板却全是 No data 的原因 | ⏳ 待审阅 | Batch-02 | - | |
-| 61 | `devops` | `your-disk-full-alert-is-lying` | 你的“磁盘将满”告警在说谎:别再按百分比告警 | ⏳ 待审阅 | Batch-02 | - | |
-| 62 | `engineering` | `adding-english-mode-to-a-chinese-only-web-app` | 浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式 | ⏳ 待审阅 | Batch-02 | - | |
-| 63 | `engineering` | `debugging-remotion-video-jitter` | 排查 Remotion 视频抽搐:为什么在背景图挂载音频低频 Scale 会毁了转场 | ⏳ 待审阅 | Batch-02 | - | |
-| 64 | `engineering` | `embedding-fonts-so-a-pdf-looks-the-same-everywhere` | 把字体嵌进 PDF,让它在任何机器上都长一样 | ⏳ 待审阅 | Batch-02 | - | |
-| 65 | `engineering` | `jpa-version-field-lost-update` | 那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁 | ⏳ 待审阅 | Batch-02 | - | |
-| 66 | `engineering` | `migrating-codeigniter-iis-to-openlitespeed` | 把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed | ⏳ 待审阅 | Batch-02 | - | |
-| 67 | `engineering` | `scraping-bot-walled-marketplace-warm-browser-session` | 用「热浏览器会话」爬取有反爬墙的电商网站 | ⏳ 待审阅 | Batch-02 | - | |
-| 68 | `engineering` | `two-ways-a-javascript-file-stops-working-and-looks-fine` | JavaScript 文件静默失效的两种方式:而页面看上去一切正常 | ⏳ 待审阅 | Batch-02 | - | |
-| 69 | `engineering` | `verifying-a-pdf-report-page-by-page` | 逐页验证一份 19 页的 PDF 报告 | ⏳ 待审阅 | Batch-02 | - | |
+| 59 | `devops` | `why-your-cdp-clicks-silently-fail` | 为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件 | ⏳ 待审阅 | Batch-01 | - | |
+| 60 | `devops` | `why-your-grafana-dashboard-shows-no-data` | Prometheus 明明有数据,Grafana 面板却全是 No data 的原因 | ⏳ 待审阅 | Batch-01 | - | |
+| 61 | `devops` | `your-disk-full-alert-is-lying` | 你的“磁盘将满”告警在说谎:别再按百分比告警 | ⏳ 待审阅 | Batch-01 | - | |
+| 62 | `engineering` | `adding-english-mode-to-a-chinese-only-web-app` | 浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式 | ⏳ 待审阅 | Batch-01 | - | |
+| 63 | `engineering` | `debugging-remotion-video-jitter` | 排查 Remotion 视频抽搐:为什么在背景图挂载音频低频 Scale 会毁了转场 | ⏳ 待审阅 | Batch-01 | - | |
+| 64 | `engineering` | `embedding-fonts-so-a-pdf-looks-the-same-everywhere` | 把字体嵌进 PDF,让它在任何机器上都长一样 | ⏳ 待审阅 | Batch-01 | - | |
+| 65 | `engineering` | `jpa-version-field-lost-update` | 那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁 | ⏳ 待审阅 | Batch-01 | - | |
+| 66 | `engineering` | `migrating-codeigniter-iis-to-openlitespeed` | 把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed | ⏳ 待审阅 | Batch-01 | - | |
+| 67 | `engineering` | `scraping-bot-walled-marketplace-warm-browser-session` | 用「热浏览器会话」爬取有反爬墙的电商网站 | ⏳ 待审阅 | Batch-01 | - | |
+| 68 | `engineering` | `two-ways-a-javascript-file-stops-working-and-looks-fine` | JavaScript 文件静默失效的两种方式:而页面看上去一切正常 | ⏳ 待审阅 | Batch-01 | - | |
+| 69 | `engineering` | `verifying-a-pdf-report-page-by-page` | 逐页验证一份 19 页的 PDF 报告 | ⏳ 待审阅 | Batch-01 | - | |
| 70 | `engineering` | `why-500000-lines-of-commit-churn-only-left-230000-in-production` | 30 天提交 51.8 万行,生产环境只留下 23.5 万行 | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 71 | `notes` | `adding-a-domain-to-someone-elses-mail-server` | 给别人家的邮件服务器加一个域名 | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 72 | `notes` | `hello-world` | 你好,世界 —— 关于这个博客 | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
@@ -97,9 +97,9 @@
| 83 | `notes` | `why-your-windows-mcp-calls-silently-fail` | 为什么你的 Windows 桌面 MCP 调用会静默失败:攻破 10 秒空闲门禁与避坑实录 | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 84 | `tutorials` | `calibrating-intel-quick-sync-hevc-quality` | 校准 Intel Quick Sync HEVC:28 与 30 之间的悬崖,和一个毫无作用的 preset | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 85 | `tutorials` | `does-hevc-actually-shrink-your-files` | 转成 HEVC 到底会不会让文件变小?我实测了 | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
-| 86 | `tutorials` | `generating-full-length-songs-with-yue2-comfyui` | 用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断 | ⏳ 待审阅 | Batch-04 | - | |
-| 87 | `tutorials` | `generating-subtitles-for-songs-without-timestamps` | 给没有时间戳的歌曲自动生成字幕 | ⏳ 待审阅 | Batch-05 | - | |
-| 88 | `tutorials` | `how-to-verify-a-hosting-provider-before-you-buy` | 购买主机前,如何核实一家服务商的真实情况 | ⏳ 待审阅 | Batch-05 | - | |
-| 89 | `web3` | `chainlink-vrf-v2-lottery-contract` | 一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约 | ⏳ 待审阅 | Batch-05 | - | |
-| 90 | `web3` | `fully-on-chain-svg-nfts` | 全链上 SVG NFT:把艺术作品放进合约里 | ⏳ 待审阅 | Batch-05 | - | |
-| 91 | `web3` | `why-my-on-chain-nft-art-changed-on-windows` | 为什么在 Windows 上克隆后,我的链上 NFT 图像变了 | ⏳ 待审阅 | Batch-05 | - | |
+| 86 | `tutorials` | `generating-full-length-songs-with-yue2-comfyui` | 用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断 | ⏳ 待审阅 | Batch-03 | - | |
+| 87 | `tutorials` | `generating-subtitles-for-songs-without-timestamps` | 给没有时间戳的歌曲自动生成字幕 | ⏳ 待审阅 | Batch-04 | - | |
+| 88 | `tutorials` | `how-to-verify-a-hosting-provider-before-you-buy` | 购买主机前,如何核实一家服务商的真实情况 | ⏳ 待审阅 | Batch-04 | - | |
+| 89 | `web3` | `chainlink-vrf-v2-lottery-contract` | 一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约 | ⏳ 待审阅 | Batch-04 | - | |
+| 90 | `web3` | `fully-on-chain-svg-nfts` | 全链上 SVG NFT:把艺术作品放进合约里 | ⏳ 待审阅 | Batch-04 | - | |
+| 91 | `web3` | `why-my-on-chain-nft-art-changed-on-windows` | 为什么在 Windows 上克隆后,我的链上 NFT 图像变了 | ⏳ 待审阅 | Batch-04 | - | |
diff --git a/docs/consultant-review/audit-tracker.json b/docs/consultant-review/audit-tracker.json
index 97774bd..b87a131 100644
--- a/docs/consultant-review/audit-tracker.json
+++ b/docs/consultant-review/audit-tracker.json
@@ -19,7 +19,7 @@
"category": "engineering",
"pub_date": "2025-06-11",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 11859,
@@ -123,7 +123,7 @@
"category": "web3",
"pub_date": "2024-12-10",
"status": "pending",
- "batch_id": "Batch-05",
+ "batch_id": "Batch-04",
"approved_at": null,
"notes": "",
"en_chars": 15801,
@@ -136,7 +136,7 @@
"category": "engineering",
"pub_date": "2025-11-26",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 9584,
@@ -175,7 +175,7 @@
"category": "engineering",
"pub_date": "2025-01-22",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 8033,
@@ -227,7 +227,7 @@
"category": "web3",
"pub_date": "2024-10-08",
"status": "pending",
- "batch_id": "Batch-05",
+ "batch_id": "Batch-04",
"approved_at": null,
"notes": "",
"en_chars": 14598,
@@ -240,7 +240,7 @@
"category": "tutorials",
"pub_date": "2026-10-26",
"status": "pending",
- "batch_id": "Batch-04",
+ "batch_id": "Batch-03",
"approved_at": null,
"notes": "",
"en_chars": 131667,
@@ -253,7 +253,7 @@
"category": "tutorials",
"pub_date": "2026-10-20",
"status": "pending",
- "batch_id": "Batch-05",
+ "batch_id": "Batch-04",
"approved_at": null,
"notes": "",
"en_chars": 46399,
@@ -370,7 +370,7 @@
"category": "tutorials",
"pub_date": "2025-11-26",
"status": "pending",
- "batch_id": "Batch-05",
+ "batch_id": "Batch-04",
"approved_at": null,
"notes": "",
"en_chars": 6743,
@@ -409,7 +409,7 @@
"category": "engineering",
"pub_date": "2026-08-19",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 12217,
@@ -435,7 +435,7 @@
"category": "engineering",
"pub_date": "2026-09-27",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 14943,
@@ -656,7 +656,7 @@
"category": "engineering",
"pub_date": "2026-09-13",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 11245,
@@ -890,7 +890,7 @@
"category": "engineering",
"pub_date": "2025-02-20",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 7750,
@@ -955,7 +955,7 @@
"category": "engineering",
"pub_date": "2026-09-28",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 12525,
@@ -1085,7 +1085,7 @@
"category": "web3",
"pub_date": "2026-08-19",
"status": "pending",
- "batch_id": "Batch-05",
+ "batch_id": "Batch-04",
"approved_at": null,
"notes": "",
"en_chars": 7725,
@@ -1111,7 +1111,7 @@
"category": "devops",
"pub_date": "2025-03-19",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 8010,
@@ -1124,7 +1124,7 @@
"category": "devops",
"pub_date": "2026-05-17",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 7347,
@@ -1163,7 +1163,7 @@
"category": "devops",
"pub_date": "2026-06-24",
"status": "pending",
- "batch_id": "Batch-02",
+ "batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"en_chars": 5930,
diff --git a/docs/consultant-review/batches/batch-01.md b/docs/consultant-review/batches/batch-01.md
index d76d8c5..1230356 100644
--- a/docs/consultant-review/batches/batch-01.md
+++ b/docs/consultant-review/batches/batch-01.md
@@ -1,10 +1,10 @@
# 博客文章顾问审阅资料包 — Batch-01
> **批次编号**:`Batch-01`
-> **包含文章数**:8 篇
-> **本批次总字符量**:174,959 字(严格限制 < 200,000 字)
+> **包含文章数**:11 篇
+> **本批次总字符量**:167,652 字(严格限制 < 200,000 字)
> **模式**:BILINGUAL
-> **生成时间**:2026-10-27 05:23:38
+> **生成时间**:2026-10-27 05:36:05
# 外部顾问审阅前置须知与工程背景规范
@@ -39,22 +39,25 @@
## 本批次文章目录索引
-1. [ai] **Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI** (`why-i-still-bought-a-local-gpu`) — ~16,735 字符
-2. [ai] **为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2** (`yue2-on-a-12gb-gpu-songs-that-lost-their-endings`) — ~53,369 字符
-3. [case-studies] **我如何用 FLUX.1 Kontext 打造 AI 家具合成演示** (`ai-furniture-compositing-with-flux-kontext`) — ~19,749 字符
-4. [case-studies] **从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操** (`from-audio-to-a-4-minute-ai-music-video`) — ~16,195 字符
-5. [case-studies] **我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的** (`how-i-built-an-automated-music-video-pipeline`) — ~14,196 字符
-6. [case-studies] **我是如何构建 DigiKedai Telegram AI 客服机器人的** (`how-i-built-the-digikedai-telegram-bot`) — ~15,990 字符
-7. [case-studies] **我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD** (`how-i-host-this-blog`) — ~10,053 字符
-8. [case-studies] **我用 Suno AI 做出自己的歌** (`how-i-made-my-own-songs-with-suno-ai`) — ~28,672 字符
+1. [devops] **为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件** (`why-your-cdp-clicks-silently-fail`) — ~12,514 字符
+2. [devops] **Prometheus 明明有数据,Grafana 面板却全是 No data 的原因** (`why-your-grafana-dashboard-shows-no-data`) — ~11,212 字符
+3. [devops] **你的“磁盘将满”告警在说谎:别再按百分比告警** (`your-disk-full-alert-is-lying`) — ~8,912 字符
+4. [engineering] **浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式** (`adding-english-mode-to-a-chinese-only-web-app`) — ~18,070 字符
+5. [engineering] **排查 Remotion 视频抽搐:为什么在背景图挂载音频低频 Scale 会毁了转场** (`debugging-remotion-video-jitter`) — ~15,111 字符
+6. [engineering] **把字体嵌进 PDF,让它在任何机器上都长一样** (`embedding-fonts-so-a-pdf-looks-the-same-everywhere`) — ~11,465 字符
+7. [engineering] **那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁** (`jpa-version-field-lost-update`) — ~19,575 字符
+8. [engineering] **把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed** (`migrating-codeigniter-iis-to-openlitespeed`) — ~23,361 字符
+9. [engineering] **用「热浏览器会话」爬取有反爬墙的电商网站** (`scraping-bot-walled-marketplace-warm-browser-session`) — ~16,928 字符
+10. [engineering] **JavaScript 文件静默失效的两种方式:而页面看上去一切正常** (`two-ways-a-javascript-file-stops-working-and-looks-fine`) — ~11,294 字符
+11. [engineering] **逐页验证一份 19 页的 PDF 报告** (`verifying-a-pdf-report-page-by-page`) — ~19,210 字符
---
-## 文章 01 / 08:Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI
-- **Slug**: `why-i-still-bought-a-local-gpu`
-- **英文标题**: Why I Still Bought a GPU to Run AI When Claude and GPT Are Obviously Stronger
-- **分类**: `ai` | **发布日期**: 2026-09-21
-- **线上地址**: https://blog.hoelee.com/posts/why-i-still-bought-a-local-gpu/ (EN) | https://blog.hoelee.com/posts/zh/why-i-still-bought-a-local-gpu/ (ZH)
+## 文章 01 / 11:为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件
+- **Slug**: `why-your-cdp-clicks-silently-fail`
+- **英文标题**: Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events
+- **分类**: `devops` | **发布日期**: 2025-03-19
+- **线上地址**: https://blog.hoelee.com/posts/why-your-cdp-clicks-silently-fail/ (EN) | https://blog.hoelee.com/posts/zh/why-your-cdp-clicks-silently-fail/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
@@ -68,451 +71,262 @@
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
-title: "Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI"
-description: "Claude Opus 和 GPT 确实比我能在本地跑的任何模型都强。但我还是买了 GPU——这篇讲成本、控制权与可用性,附实际的判断标准。"
-pubDate: 2026-09-21
-category: ai
-blueprint: ai-agent-automation
-blueprintLayer: L3-local-gpu-inference
-tags: [local-llm, gpu, llama-cpp, lm-studio, agents, self-hosting, cost]
-ogImage: /og/zh-why-i-still-bought-a-local-gpu.jpg
-banner: /banners/why-i-still-bought-a-local-gpu.jpg
+title: "为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件"
+description: "Chrome DevTools Protocol 点击报告成功却毫无反应的三个原因:平滑滚动导致的过期坐标、最小化窗口被丢弃的坐标输入、以及页面收到了点击但拒绝了它。"
+pubDate: 2025-03-19
+updatedDate: 2026-09-29
+category: devops
+tags: [cdp, chrome, react, browser-automation, debugging, python]
+ogImage: /og/zh-why-your-cdp-clicks-silently-fail.jpg
+banner: /banners/why-your-cdp-clicks-silently-fail.jpg
draft: false
-dynamicCta: true
---
-在我管理的自托管生产节点(Self-hosted production infrastructure)上,常年运行着全套 Docker 容器栈、NAS 存储、Cloudflare 隧道与自动化调度任务。但我照样在付顶级模型的钱。
+如果你用 Chrome DevTools Protocol 驱动网页应用,你一定见过这个失败:脚本报告 `clicked @591,361`,元素明明在,点击也确实发出去了 —— 然后什么都没发生。没有异常,没有控制台报错,页面毫无变化。
-所以每次别人知道我也在跑本地模型,问的都是同一句话:*Claude 和 GPT 就是
-比较强,本地模型又麻烦、又慢、效果还比较差,那你到底为什么要花钱买 GPU?*
+我在自动化某平台卖家后台时整整耗了一个晚上在这上面。找到的三个原因值得写下来,因为**它们对「报告点击成功」的那段代码全都是隐形的**。
-如果只比模型能力,我完全同意。今天要处理一个全新的问题、做复杂推理、规划
-产品架构、或者跑一个很长的 Agent 任务,我大部分时间还是先用 Claude、GPT。
-我部署本地模型,从来不是因为我觉得它已经打赢闭源模型。
+## 原因一:坐标过期,因为页面是平滑滚动的
-而是因为 AI 越来越好用之后,出现了另一个问题:我做的每一件事,都开始建立
-在一个我自己完全不能控制的服务上。
+最直观的点击辅助函数大概长这样:
----
+```python
+r = self.ev("""(()=>{const e=%s; e.scrollIntoView({block:'center'});
+ const b=e.getBoundingClientRect();
+ return JSON.stringify({x:b.left+b.width/2, y:b.top+b.height/2});})()""")
+self.click_xy(json.loads(r)["x"], json.loads(r)["y"])
+```
-## 真正的问题不是能力,是依赖
+滚动到可视区、读取元素矩形、点它的中心。逻辑没错 —— 除非页面有 `scroll-behavior: smooth`(现代 UI 组件库里非常常见)。那时 `scrollIntoView()` 启动的是**动画**,而同一个 tick 里的 `getBoundingClientRect()` 返回的是元素**即将离开**的位置。你的点击落在元素**原来**的地方,落在当时在那儿的别的东西上,而日志忠实地告诉你:它点击了你要求的坐标。
-今天模型可以用多少、方案怎么收费、什么地区能不能用、版本什么时候换掉,
-最后都不是使用者决定的。Anthropic 本来就有自己的地区限制,有些市场不能
-直接买 Claude 的商业服务;OpenAI 的方案、额度跟模型也一直在调。这些是商业
-服务,变动本来就可以预期。
+修法是两行 —— 关掉平滑滚动,然后重新读矩形:
-以前 ChatGPT 只是偶尔拿来问问题,今天不能用,我明天再问没什么差别。
+```python
+self.ev("""(()=>{document.documentElement.style.scrollBehavior='auto';
+ if(document.body) document.body.style.scrollBehavior='auto'; return 'ok';})()""")
+moved = self.ev("""(()=>{const e=%s; if(!e) return 'no';
+ e.scrollIntoView({block:'center', behavior:'instant'}); return 'ok';})()""" % sel)
+time.sleep(0.35) # 等布局稳定
+# 然后再读矩形(元素晚一帧才渲染的话,外面再套一层重试)
+```
-但当我开始拿 AI 写程式、跑 Agent、分析文件、处理客服、整理公司资料,甚至
-把它接进每天都在跑的工作流程之后,问题就变了。当 AI 从聊天工具变成工作里
-的基础设施,你要考虑的就不只是「哪一个模型比较强」,而是:
+两个细节很关键:`scrollIntoView` 上的 `behavior:'instant'` 会覆盖 CSS;而读取前那一小段等待,才是让坐标可信的原因。没有重试循环的话,这个辅助函数在晚一帧渲染的元素上仍会偶发失败 —— **偶发失败是最糟的一类,因为它会让你怀疑自己的判断。**
-> 如果我今天很依赖这个模型,但有一天它的使用条件改掉,我原本的工作还能不能继续?
+## 原因二:窗口最小化时,坐标点击被丢弃
-这就是为什么我觉得,闭源模型越强,本地模型的重要性会越来越高。因为闭源
-模型太好用,大家就会把越来越多事情交给它;依赖越深的同时,也会越被这些
-闭源模型控制。
+修好之后,我的点击辅助函数在一次性探针里能用,一放进真正的脚本就再也没成功过。同样的选择器、同样的坐标、同样的页面。差别最后落在可见性上:
-还有一件事很值得讲清楚。每一家大模型公司都在谈 AI 安全、模型风险,甚至
-限制某些内容、地区或使用方式,但同时每一家又都还在拼命把模型做得更强——
-因为模型能力本身就是竞争力,你不做,别人还是会做。
+```
+document.visibilityState → "hidden"
+document.hasFocus() → true
+```
-所以最后真正被控制的,往往不是 AI 要不要继续发展,而是谁能用、怎么用、
-用多少,以及模型公司愿意提供到什么程度。对一般人来说,这可能只是服务
-条款;对企业来说,这就是**供应商风险**。假设一间公司的核心流程,加上后面
-所有 Agent、工具、资料处理,全部绑在同一个供应商身上,那它其实不只是在买
-Token——它是在把一部分工作的主控权交出去。
+当目标标签页不是可见窗口的活动标签页时,CDP 合成的坐标级鼠标事件是不可靠的 —— 浏览器可能接受、可能限流、也可能直接不往任何地方送。注意 `hasFocus()` **在文档隐藏时依然返回 `true`**,所以它不是有效的判断依据;要看的是 `document.hidden` / `visibilityState`。
----
+两个修法,我都用:
-## 「可是本地模型就是比较差啊」——对,但这是错误的比较方式
+```python
+def activate(self): # 把标签页提到窗口最前
+ self.cdp("Page.bringToFront")
+ time.sleep(0.2)
-这是最诚实的反对意见,而且它是事实。拿一个能放进 24GB、32GB 消费级显卡的
-模型,直接跟 Claude Opus 全面比较,很多复杂任务就是会输。这没什么好辩的。
+def click_js(self, sel): # 在页面内部派发事件来点击
+ return self.ev("""(()=>{const e=%s; if(!e) return 'no';
+ ['mousedown','mouseup','click'].forEach(k=>
+ e.dispatchEvent(new MouseEvent(k,{bubbles:true,cancelable:true,view:window})));
+ return 'js-clicked';})()""" % sel)
+```
-但大家很容易用了错误的比较方式。真正该问的不是「Qwen 能不能打赢 Claude」,
-而是:
+`Page.bringToFront` 解决了常见情况。但真正从不出错的是 JS 派发的点击:它不依赖窗口是否可见,而且 React 挂在根节点上的事件监听器会收到它,和收到真实点击的方式完全一样 —— 因为事件冒泡到的是同一个根。一个用坐标点击五次才能打开一次的日期选择器,用这种方式**每次都开**。
-> **这个能力差距,到底有没有大到值得你永远用最贵的模型来做这件事?**
+需要知道的取舍:派发事件会跳过 hit-testing,所以它也会「点到」用户根本够不到的元素(被遮住、滚出屏幕、透明度为零)。在这里这是优点 —— 如果你用它去掩盖布局 bug,那就是坑 —— 所以凡是可视化的东西我都用坐标点击,而遇到自定义控件时才伸手去拿 `click_js`。
-因为这个交换并不对称。二三十 B 的模型确实落后顶级模型一截,但成本低一到
-两个数量级,或者直接跑在你已经买的硬体上。比例不同,决策就不同。
+## 原因三:页面收到了点击,然后拒绝了它
-### 我把模型能力看成分界线,不是排行榜
+这个最浪费时间,因为它的症状和前两个一模一样:点击发出去了,什么都没变。
-如果一个任务低于某个能力门槛,那就是做不了,当然用强模型。但一旦跨过
-那条线,后面真正影响我使用方式的,就不再是 benchmark 分数,而是成本、
-速度、能不能长时间运行,以及我能不能控制这套系统。
+区分它们只需要注入一个监听器:
-以我的实际分法:
+```js
+window.__ev = [];
+['mousedown','mouseup','click'].forEach(k =>
+ document.addEventListener(k, e => window.__ev.push(k + ':' + e.target.tagName), true));
+```
-| 任务 | 我用什么 |
-|---|---|
-| 问题还很模糊、连方向都不知道 | 顶级闭源模型——前面判断错了,后面做再多都没意义 |
-| 产品架构、资料格式、验收条件 | 顶级闭源模型 |
-| 照著已定规格建专案骨架、改档案、跑测试、看到错误再修 | 本地模型 |
-| 长时间 Agent、重试、批次工作、大量内容产出 | 本地模型 |
+- 有事件记录 → 输入链路是通的,问题在应用侧(原因三),或者在你读取结果的方式上(见下)。
+- 一条都没有 → 是原因一或原因二。
-关键是第二块。一旦方向确定、Agent 只是在执行规格,一个任务可能会呼叫模型
-二十次、五十次。这五十次真的每一次都需要市面上最贵的模型吗?通常不需要。
-Agent 已经不是在帮我决定产品要怎么做,它是在按照前面已经写好的规格执行。
-只要本地模型可以正确理解这些规格、知道工具怎么用,它就已经跨过可以工作的
-门槛。
+挂上监听器后我点了一次 `Confirm`,缓冲区满了 —— 说明点击没问题,是应用在说「不」。理由在一段我从没想过要看的文字里:
----
+> “Please create a new shop welcome voucher after the existing one is expired.”
-## Agent 会把小小的价差一直放大
+表单拒绝创建同类型的第二张券。没有字段级红字、头 500 毫秒里没有提示条、URL 也没变。**拒绝是真的,点击是无辜的。**
-用聊天,是问一次、回答一次。Claude 一次比本地模型贵很多,你也不会有特别
-感受。
+这一类 bug 还有两个工具:
-但 Agent 不是一次。一个任务可能呼叫模型二十次、五十次,失败之后还会重新
-跑。模型单次价格的差距,放进这种连续任务里就会被一直放大。
+```js
+document.elementFromPoint(x, y) // 那个坐标上到底是什么?
+document.querySelectorAll('button').length // 你的选择器到底命中了几个?
+```
-而真正做过这些流程的人都知道,最花资源的往往还不是最后正式执行,而是前面
-测试。你会改 Prompt、换工具、重新切资料、调整工作流程,同一个东西跑十次、
-二十次很正常。
+第二个抓到过一处很细的问题:门户页面在 DOM 里留了一个**隐藏的** `Confirm` 按钮(来自收起状态的日期面板),于是 `.pop()` 返回的是那个看不见的副本,点击自然落到了无人的地方。按可见性和尺寸一起过滤:
-每次都是 API,我自己就会很明显感觉到:又在烧钱。用自己那张卡,当然还是有
-电费跟硬体成本,但我今天让一个 Agent 跑五次还是五十次,不会每跑一次就多
-一笔帐单。这对我来说很重要,因为它会直接影响我敢不敢一直连续测试。很多
-真正稳定的 AI workflow,不是第一次写完就成功,而是让它失败很多次之后,
-才慢慢找到什么地方容易出问题。
+```python
+"[...document.querySelectorAll('button')].filter(b=>/^Confirm$/.test(b.innerText.trim())"
+" && b.offsetParent!==null && b.getBoundingClientRect().width>0).pop()"
+```
-本地算力其中一个很大的价值,就是你可以把这些大量试错搬回自己的固定成本
-里。这跟单纯比较「一百万 Token 多少钱」其实是两件事。
+## 第四种「失败」,只有回读才发现
-算一笔很实在的工程账:一个包含工具调用的典型 Agent 循环,跑完一次完整的代码测试或文件重构,通常要经历 30–50 轮迭代。由于每轮都需要把历史上下文和工具执行输出重新带入,平均单轮上下文在 15k–25k tokens 左右——单次 Agent 运行就要消耗 600k–1.2M tokens。如果你在调试一套新的复杂工作流,一天反复测试优化 20 次,就是 1,500 万到 2,400 万 tokens。按照顶级 API(如 Claude 3.5 Sonnet 的 $3/$15 每百万 token)计算,这一天的调试账单就在 $50 到 $150 美元;若是 Claude Opus,直接突破数百美元。
+还有一类「失败」其实不是失败:**写入成功了,而你的读取是旧的。** 我因为列表页没有立刻显示,就断定创建被静默拒绝,又点了一次;后来才发现那张券一直都存在 —— 列表只是延迟了大约十分钟。这个教训可以推广:一次写入之后,唯一可信的结论是在**等待足够时间之后**回读得到的,而「看不到变化」意味着**未知**,不是失败。
-而在我这台配有 12GB 显存的自托管 GPU 节点上(通过 LM Studio / llama.cpp 本地运行量化版 Qwen 2.5),整机满载功耗不过 170W 左右。一天跑 20 次还是跑 100 次,新增的边际成本只有几毛钱电费。这笔账的本质不是简单的 token 单价对比,而是决定了你在工程探索时,敢不敢给 Agent 足够的试错空间。
+## 检查清单
----
+1. 关掉平滑滚动;用 `behavior:'instant'` 滚动;等一会儿再读矩形。
+2. 点击前调用 `Page.bringToFront`;判断依据用 `document.hidden`,不是 `hasFocus()`。
+3. 自定义控件用 JS 派发 `mousedown`/`mouseup`/`click`,别点坐标。
+4. 先挂事件监听器,证明输入是否到达,再去怪应用。
+5. 数一数选择器命中几个 —— 组件库里隐藏的重复元素很常见。
+6. 写入之后先等待再回读;永远不要重复点击一个还没验证过的提交按钮。
-## 开放模型已经跨过可用的线,而这条线还在移动
+这些都不算冷门知识,但它们中的每一条,在一行 `clicked @591,361` 面前都是隐形的。如果你正在对着一个自己无法控制的后台做自动化:点击是整条链路里最不可靠的一环 —— 也是最容易被最后才怀疑的一环。
-三年前,要在消费级显卡上跑一个真的可以帮你工作的模型,限制很多。现在
-二三十 B 的模型经过量化之后,24GB、32GB 这种显卡已经可以处理不少工作;
-再往更大的记忆体走,语言、图片,甚至部分影片模型都开始可以自己部署。
-
-真正重要的不是「开源终于打赢闭源了」,而是原本只有闭源模型做得到的工作,
-正在一个一个掉进本地可以处理、而且可以被接受的范围。假设三年前十个工作
-只有两个能放本地,现在已经有四五个可以做——这个比例本身就已经改变了
-本地部署的价值。
-
-而且我买 GPU 的时候,不是在买 Qwen 这一颗模型。模型可以一直换,也会一直
-出现新的:如果之后出现一个更适合 Coding 的开放模型,我直接换权重;再过
-半年有更好的量化方式,同一张卡可能又可以装进原本跑不动的模型。我真正买
-下来的,是**一块自己可以控制的运算资源**,后面的模型可以一直替换。
-
-这就是本地跟闭源服务最不一样的地方。闭源模型能力通常比较强,但你用的是
-某家公司现在愿意提供给你的版本;本地硬体能力可能没那么高,但只要硬体
-撑得住,我可以自己决定今天跑什么模型、换什么版本、要跑多久。
-
----
-
-## 固定算力 vs. 每次租算力
-
-如果 AI 永远只是拿来问问题,我完全不觉得大家都需要买 GPU。但现在发展方向
-很明显不是这样:Agent 会长时间执行工作,图片跟影片模型会需要大量生成,
-公司也会把更多固定流程交给 AI。我们不是单纯使用模型的次数变多,而是模型
-开始变成一个持续工作的运算单位。
-
-这时候,「固定拥有一部分算力」跟「每一次执行都向外面租」,就会开始变成
-两种不同的成本结构。使用量少,租一定比较方便;使用量够大、够固定之后,
-自己有算力就能更好控制与估算成本。
-
-它还多了一个闭源服务给不了的东西:主控权。当机器是我的,我要跑内部资料、
-接自己的工具、长时间执行 Agent,都不需要每次先确认供应商的方案、额度或者
-限制有没有改。尤其公司真的开始把 AI 接进内部流程之后,这件事就不只是省
-Token——有些资料本来就不适合离开自己的环境,有些 Agent 权限也不可能直接
-全部交给外部服务。当模型跑在自己的环境里,资料、工具、权限跟执行方式可以
-一起设计。这才是「控制权」真正指的东西,不只是把权重下载下来而已。
-
----
-
-## 我现在实际怎么判断
-
-我不会再用「它比 Claude 笨,所以没有价值」这种方式判断一个本地模型。我会
-依序看四件事:
-
-1. **这颗模型能不能跨过我这个工作的门槛?**
-2. **这个任务会执行多少次?**
-3. **是不是需要大量试错,或长时间的 Agent?**
-4. **这些资料跟系统,到底要不要留在自己的环境?**
-
-这几个条件一叠起来,本地模型即使不是最强,还是可能比顶级闭源模型更适合
-那一段工作。而且随著开放模型越来越强,这个范围只会一直变大。
-
-所以我真正看好的,不是某一颗模型,也不是某一张 GPU,而是这个趋势:
-**可以在自己硬体上完成的 AI 工作正在变多。** 以前买一张消费级显示卡,
-可能只能拿来跑一些语言模型;现在开始可以接 Agent、图片、影片,后面模型
-跟推理框架继续优化,同样的硬体又可能得到新的用途。
-
-硬体当然会折旧,新卡也可能越来越贵,但至少模型不是绑死的。只要这台机器
-还有足够的运算能力,我就可以一直换新的开放模型。
-
----
-
-## 一句话版本
-
-我现在已经不会只看哪个模型比较聪明。我比较的是:**这件工作到底需要多强的
-模型、它要被执行多少次,以及我愿意把多少控制权留在外部服务手上。**
-
-需要顶级推理的地方,我照样花钱用最强的模型。但当工作开始变成大量、连续、
-固定,而且本地模型的能力已经跨过可使用门槛时,我就没有理由每一个步骤都
-继续用最贵的闭源模型。
-
-浓缩成一句:
-
-> 用顶级模型决定要做什么,用本地算力把它做上一千次。
-
-这就是我买 GPU 的原因。
+我平时就在做这类浏览器自动化与排障 —— 自托管服务、Docker 与 Traefik 栈,偶尔还有被 bot 墙挡住的市场页面。如果你有条工作流总因为「UI 有自己的想法」而断掉,跟我说说:[WhatsApp](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=Browser%20automation%20debugging) · [hoelee.com](https://hoelee.com)。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
-title: "Why I Still Bought a GPU to Run AI When Claude and GPT Are Obviously Stronger"
-description: "Claude Opus and GPT are stronger than anything I can run locally. Here is the cost, control, and uptime argument for owning a GPU anyway — with real numbers."
-pubDate: 2026-09-21
-category: ai
-blueprint: ai-agent-automation
-blueprintLayer: L3-local-gpu-inference
-tags: [local-llm, gpu, llama-cpp, lm-studio, agents, self-hosting, cost]
-ogImage: /og/why-i-still-bought-a-local-gpu.jpg
-banner: /banners/why-i-still-bought-a-local-gpu.jpg
+title: "Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events"
+description: "Three reasons a Chrome DevTools Protocol click reports success and does nothing: a stale rect from smooth scrolling, a minimised window that drops coordinate input, and a page that received the click but rejected it."
+pubDate: 2025-03-19
+updatedDate: 2026-09-29
+category: devops
+tags: [cdp, chrome, react, browser-automation, debugging, python]
+ogImage: /og/why-your-cdp-clicks-silently-fail.jpg
+banner: /banners/why-your-cdp-clicks-silently-fail.jpg
draft: false
-dynamicCta: true
---
-Across the self-hosted production infrastructure I manage — Docker stacks, NAS
-storage, tunnels, and automated cron pipelines — I still pay for frontier models.
+If you drive a web app with the Chrome DevTools Protocol, you have met this failure: your script reports `clicked @591,361`, the element is there, the click landed — and nothing happens. No exception, no console error, no change on the page.
-So when people find out I run local models too, the question is always the same:
-*Claude and GPT are stronger. Local models are a pain to set up, slower, and
-often just worse. So why did you spend money on a GPU?*
+I spent an evening on exactly this while automating a marketplace's seller admin panel, and the three causes I found are worth writing down, because they are all invisible to the code that reports the click.
-If you are only comparing raw model capability, I agree with you completely.
-When I hit a genuinely new problem, do complex reasoning, plan a product
-architecture, or kick off a long agent run, I still reach for Claude or GPT
-first. I did not deploy local models because I think they have beaten the
-closed frontier.
+## Cause 1: a stale rect, because the page scrolls smoothly
-I did it because of a different problem that only showed up *after* AI got good
-enough to depend on. Everything I built started sitting on a service I do not
-control.
+The obvious click helper looks like this:
----
+```python
+r = self.ev("""(()=>{const e=%s; e.scrollIntoView({block:'center'});
+ const b=e.getBoundingClientRect();
+ return JSON.stringify({x:b.left+b.width/2, y:b.top+b.height/2});})()""")
+self.click_xy(json.loads(r)["x"], json.loads(r)["y"])
+```
-## The problem is not capability — it is dependency
+Scroll into view, read the element's rectangle, click its centre. Correct — unless the page has `scroll-behavior: smooth` (very common in modern UI kits). Then `scrollIntoView()` starts an **animation**, and `getBoundingClientRect()` in the same tick returns the position the element is *about to leave*. Your click goes to where the element was, lands on whatever is there, and the log tells you it clicked the coordinates you asked for.
-How many tokens you get, what a plan costs, which regions can buy the commercial
-service, when the model version changes under you — none of that is the user's
-decision. Anthropic has regional restrictions of its own; some markets cannot
-buy Claude's commercial service directly. OpenAI's plans, quotas and model
-lineup keep shifting. These are commercial services, so of course they change.
+The fix is two lines — turn smooth scrolling off, then re-read the rect:
-That was fine when ChatGPT was something I opened occasionally to ask a
-question. If it was unavailable today, I would ask tomorrow and lose nothing.
+```python
+self.ev("""(()=>{document.documentElement.style.scrollBehavior='auto';
+ if(document.body) document.body.style.scrollBehavior='auto'; return 'ok';})()""")
+moved = self.ev("""(()=>{const e=%s; if(!e) return 'no';
+ e.scrollIntoView({block:'center', behavior:'instant'}); return 'ok';})()""" % sel)
+time.sleep(0.35) # let layout settle
+# then read the rect (and retry a couple of times if the element is not there yet)
+```
-It stopped being fine once I started using AI to write code, run agents,
-analyze documents, handle support, organize company data, and wire models into
-workflows that run every day. When AI moves from *chat tool* to *infrastructure*,
-the question changes. It is no longer "which model is smarter?" It becomes:
+Two details matter. `behavior:'instant'` on `scrollIntoView` overrides the CSS; and the small wait before reading is what makes the rect trustworthy. Without the retry loop the helper still fails intermittently on elements that render a frame late — intermittent failures are the worst kind, because they make you doubt your own diagnosis.
-> "If I depend on this model today, and its terms change tomorrow, does my work
-> still run?"
+## Cause 2: the window is minimised, and coordinate clicks are dropped
-That is why I think the stronger the closed models get, the more valuable a
-local model becomes. Precisely *because* they are so good, people hand them more
-and more work. And the deeper the dependency, the more of your operation someone
-else controls.
+After the fix, my click helper worked in a one-off probe and then never again inside the real script. Same selector, same coordinates, same page. The difference turned out to be visibility:
-There is a second-order version of this that rarely gets said plainly. Every big
-lab talks about AI safety, model risk, and restricting certain content, regions
-or use cases — while all of them keep racing to make models stronger, because
-capability *is* the competitive advantage. Nobody stops, because if you stop,
-someone else does not.
+```
+document.visibilityState → "hidden"
+document.hasFocus() → true
+```
-So what actually gets controlled is rarely whether AI keeps developing. It is
-who may use it, how, how much, and how far the vendor is willing to go. For an
-individual, that reads like a terms-of-service page. For a business, it is
-**supplier risk**. If your core process and every agent, tool and data pipeline
-around it are pinned to one vendor, you are not just buying tokens — you are
-handing over part of the steering wheel.
+CDP-synthesised mouse events at coordinates are unreliable when the target tab is not the active tab of a visible window — the browser may accept them, throttle them, or route them nowhere. Note that `hasFocus()` returns `true` even when the document is hidden, so it is not a useful check; `document.hidden`/`visibilityState` is.
----
+Two fixes, and I use both:
-## "But local models are worse" — yes, and that is the wrong comparison
+```python
+def activate(self): # bring the tab to the front of its window
+ self.cdp("Page.bringToFront")
+ time.sleep(0.2)
-This is the most honest objection, and it is true. Put a model that fits on a
-24GB or 32GB consumer card against Claude Opus and it loses on complex tasks. No
-argument.
+def click_js(self, sel): # click by dispatching events from inside the page
+ return self.ev("""(()=>{const e=%s; if(!e) return 'no';
+ ['mousedown','mouseup','click'].forEach(k=>
+ e.dispatchEvent(new MouseEvent(k,{bubbles:true,cancelable:true,view:window})));
+ return 'js-clicked';})()""" % sel)
+```
-But the comparison most people run is wrong. The question is not *can Qwen beat
-Claude*. It is:
+`Page.bringToFront` fixes the common case. But the JS-dispatched click is the one that never fails: it does not depend on the window being visible, and React's root-level event listener receives it exactly as it receives a real click, because the events bubble to the same root. A date-picker input that opened maybe one time in five with coordinate clicks opens **every** time this way.
-> **Is the capability gap large enough to justify using the most expensive model
-> for this task, forever?**
+The trade-off to know: dispatching events skips hit-testing, so it will also “click” an element a user could not reach (covered, scrolled off, zero-opacity). That is a feature here — and a trap if you use it to paper over a layout bug — so I keep coordinate clicking for anything visual and reach for `click_js` when the widget is a custom control.
-Because the trade is not symmetric. A 20–30B model is meaningfully behind the
-frontier — and costs one to two orders of magnitude less per token, or runs on
-hardware you already own. Different ratio, different decision.
+## Cause 3: the page received the click and rejected it
-### I treat capability as a threshold, not a ranking
+This is the one that wasted the most time, because the symptom is identical to causes 1 and 2: click fires, nothing changes.
-If a task is below some capability line, it cannot be done at all — use the
-strong model. But once a model crosses that line, the things that decide how I
-actually work stop being benchmark scores and become cost, speed, whether it can
-run for hours, and whether I control the system.
+Distinguishing them takes one injected listener:
-Concretely, here is how it splits for me:
+```js
+window.__ev = [];
+['mousedown','mouseup','click'].forEach(k =>
+ document.addEventListener(k, e => window.__ev.push(k + ':' + e.target.tagName), true));
+```
-| Task | What I reach for |
-|---|---|
-| Vague problem, no clear direction yet | Frontier model — a wrong early call invalidates everything downstream |
-| Product architecture, data model, acceptance criteria | Frontier model |
-| Scaffolding a project to a fixed spec, editing files, running tests, fixing failures | Local model |
-| Long-running agents, retries, batch jobs, bulk content | Local model |
+- Events recorded → the input path works. The problem is in the app (cause 3), or in how you read the result (see below).
+- Nothing recorded → causes 1 or 2.
-The important shift is the second block. Once direction is settled and the agent
-is *executing a spec*, a single task may call a model 20–50 times. Do all fifty
-calls need the most expensive model on the market? Usually not. The agent is not
-deciding what the product should be — it is following specifications that are
-already written. If a local model understands those specs and knows how to use
-the tools, it has crossed the working threshold.
+With the listener armed I clicked `Confirm` and the buffer filled up — so the click was fine, and the app was saying no. The reason was in text I had not thought to look at:
----
+> “Please create a new shop welcome voucher after the existing one is expired.”
-## Agents turn a small price difference into a large one
+The form refuses a second voucher of that type. No field-level red text, no toast in the first 500 ms, the URL unchanged. **The rejection was real and the click was innocent.**
-With chat, you ask once and get one answer. If Claude costs many times more than
-a local model, you barely notice.
+Two more tools for this class of bug:
-Agents are not one call. A task can invoke the model twenty times, fifty times;
-it fails and reruns. The per-token price gap gets multiplied by every call.
+```js
+document.elementFromPoint(x, y) // what is actually on top at that coordinate?
+document.querySelectorAll('button').length // how many match your selector?
+```
-And anyone who has actually built these pipelines knows the expensive part is
-not the final run — it is the testing before it. You rewrite the prompt, swap
-tooling, re-chunk the data, adjust the workflow, and run the same thing ten or
-twenty times. That is normal.
+The second one caught a subtle one: the portal leaves a **hidden** `Confirm` button in the DOM from the collapsed date-panel, so `.pop()` returned the invisible duplicate and the click went nowhere visible. Filter by visibility *and* size:
-On metered API calls, I feel it: *I am burning money right now.* On my own card,
-there is still electricity and hardware cost, but running an agent five times or
-fifty times does not add a line to an invoice. That difference changes my
-behavior — it decides whether I dare keep testing. Most stable AI workflows are
-not written correctly the first time. They get there after failing enough times
-that you find out where they break.
+```python
+"[...document.querySelectorAll('button')].filter(b=>/^Confirm$/.test(b.innerText.trim())"
+" && b.offsetParent!==null && b.getBoundingClientRect().width>0).pop()"
+```
-The value of local compute is that you move that trial-and-error into a fixed
-cost. That is a different question from "how much does a million tokens cost."
+## And the fourth failure I only found by re-reading
-Here is the math on a real engineering workload: a typical agent loop with tool-use iterating through tests and refactoring takes 30–50 turns. Because each turn carries previous tool outputs and conversation history, an average turn easily loads 15k–25k tokens of context — consuming 600k to 1.2M tokens in a single agent run. If you are tuning a new autonomous workflow and test it 20 times a day, you burn 15M to 24M tokens. On frontier APIs like Claude 3.5 Sonnet ($3/$15 per million tokens), that single day of debugging costs $50–$150; on Opus, it crosses several hundred dollars.
+There is a class of “failure” that is not one: **the write succeeded and your read is stale.** I concluded a create had been silently refused because the list page did not show it immediately, clicked again, and later discovered the voucher had existed the whole time — the list just lagged about ten minutes. The lesson generalises: after a write, the only trustworthy verdict is a read-back *after* a real waiting period, and “no visible change” means *unknown*, not *failed*.
-On my own self-hosted GPU node (a dedicated 12 GB VRAM card running quantized Qwen 2.5 via LM Studio / llama.cpp), the entire rig pulls under 200W at full load. Running 20 iterations or 100 iterations adds cents in electricity and $0 in marginal API bills. The real math is not just "cost per million tokens" — it is whether you can afford to let an agent fail enough times to become reliable.
+## The checklist
----
+1. Turn off smooth scrolling; scroll with `behavior:'instant'`; wait, then read the rect.
+2. Call `Page.bringToFront` before clicking; treat `document.hidden` as the signal, not `hasFocus()`.
+3. For custom widgets, dispatch `mousedown`/`mouseup`/`click` from JS rather than clicking coordinates.
+4. Arm an event listener to prove whether input arrived before you blame the app.
+5. Count your selectors — hidden duplicates are common in component libraries.
+6. After a write, wait and re-read; never re-click a submit you have not verified.
-## Open models crossed the useful line, and the line keeps moving
+None of this is exotic, but every item is invisible in a log line that says `clicked @591,361`. If you are building automation against an admin panel you do not control, the click is the least reliable part of the pipeline — and the one most likely to be blamed last.
-Three years ago, running a model on a consumer card that could genuinely do work
-for you was a research project. Today, 20–30B models — quantized — handle real
-work on 24GB and 32GB cards. Go up in VRAM and language, image, and now portions
-of video generation become self-hostable too.
-
-The important part is not "open source finally won." It is that work which used
-to be closed-model-only keeps falling inside the range that local hardware can
-do acceptably. If three years ago two of ten jobs could run locally, and today
-four or five can, that ratio alone has changed the value of owning the hardware.
-
-And when I buy a GPU, I am not buying a specific model. Models change. If a
-better coding model ships next month, I swap weights. If a better quantization
-scheme lands in six months, the same card suddenly fits a model it could not run
-before. What I actually bought is **compute I control**; the models on top of it
-are disposable.
-
-That is the structural difference. A closed model is stronger — and also exactly
-the version one company currently chooses to offer you, at terms it currently
-chooses. Local hardware is weaker — but as long as the card holds up, I decide
-which model runs today, which version I switch to, and how long it runs.
-
----
-
-## Fixed compute vs. renting compute
-
-If AI stays a question-and-answer tool, I do not think most people need a GPU at
-all. But that is clearly not the direction. Agents run for long stretches. Image
-and video models want bulk generation. Companies hand more fixed processes to
-AI. We are not just *using models more often* — models are becoming a continuously
-working compute unit.
-
-At that point, owning part of the capacity and renting every execution become
-**two different cost structures**. Low usage: renting is obviously more
-convenient. High, steady usage: owning lets you control and predict cost.
-
-It also buys something no API can sell you — control. When the machine is mine,
-I can point it at internal data, connect my own tools, and run agents for hours
-without checking whether a vendor's plan, quota or limits changed overnight.
-Once AI is inside real company processes, this stops being about saving tokens.
-Some data should not leave the environment it lives in. Some agent permissions
-should not be handed wholesale to an external service. If the model runs on my
-own hardware, data, tools, permissions and execution can be designed together.
-*That* is what I mean by control — not merely having the weights on disk.
-
----
-
-## What I actually evaluate now
-
-I no longer judge a local model by "it is dumber than Claude, therefore
-worthless." Four questions, in order:
-
-1. **Does this model clear my working threshold** for this specific job?
-2. **How many times will this task execute?**
-3. **Does it need heavy trial-and-error, or long agent runs?**
-4. **Does the data or system need to stay in my own environment?**
-
-When those stack up, a local model — even one that is not the strongest — can be
-the better tool for that slice of work. And as open models improve, that slice
-keeps getting bigger.
-
-So what I am bullish on is not one model or one GPU. It is the trend: **more AI
-work becomes doable on hardware you own.** A consumer card that used to run only
-language models now runs agents, image generation, video. Better models and
-better inference frameworks keep giving the same metal new jobs.
-
-Hardware depreciates, new cards get more expensive — that is real. But the model
-is not locked in. As long as the machine has enough compute, I can keep swapping
-in newer open models.
-
----
-
-## The short version
-
-I no longer compare "which model is smarter." I compare **how much capability
-this job needs, how many times it will run, and how much control I am willing to
-leave in someone else's hands.**
-
-Where I need frontier reasoning, I pay for the frontier. But when work becomes
-high-volume, continuous and predictable — and a local model has crossed the
-usable threshold — there is no reason to keep paying frontier prices for every
-single step.
-
-In one line:
-
-> Use the frontier model to decide what to build. Use local compute to build it
-> a thousand times.
-
-That is why I bought the GPU.
+I build and debug this kind of browser automation for a living — self-hosted services, Docker and Traefik stacks, and the odd scraped-but-bot-walled marketplace. If you have a workflow that keeps breaking because a UI has other ideas, tell me about it: [WhatsApp](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=Browser%20automation%20debugging) · [hoelee.com](https://hoelee.com).
````
---
-## 文章 02 / 08:为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2
-- **Slug**: `yue2-on-a-12gb-gpu-songs-that-lost-their-endings`
-- **英文标题**: Why My AI Songs Kept Losing Their Endings: Running YuE2 on a 12 GB GPU
-- **分类**: `ai` | **发布日期**: 2026-10-20
-- **线上地址**: https://blog.hoelee.com/posts/yue2-on-a-12gb-gpu-songs-that-lost-their-endings/ (EN) | https://blog.hoelee.com/posts/zh/yue2-on-a-12gb-gpu-songs-that-lost-their-endings/ (ZH)
+## 文章 02 / 11:Prometheus 明明有数据,Grafana 面板却全是 No data 的原因
+- **Slug**: `why-your-grafana-dashboard-shows-no-data`
+- **英文标题**: Why Your Grafana Dashboard Shows No Data (When Prometheus Is Fine)
+- **分类**: `devops` | **发布日期**: 2026-05-17
+- **线上地址**: https://blog.hoelee.com/posts/why-your-grafana-dashboard-shows-no-data/ (EN) | https://blog.hoelee.com/posts/zh/why-your-grafana-dashboard-shows-no-data/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
@@ -526,2406 +340,290 @@ That is why I bought the GPU.
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
-title: "为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2"
-description: "12 首中文歌时长全都恰好 240.000 秒,尾奏不见了。原因不在歌词 —— 是一道硬上限、一堵显存天花板,和一个不看 BPM 的规划器。"
-pubDate: 2026-10-20
-category: ai
-blueprint: ai-agent-automation
-blueprintLayer: L4-aigc-media-pipelines
-stream: creative-lab
-tags: ["yue2", "comfyui", "ai-music", "local-ai", "gpu", "vram", "mandarin"]
-ogImage: "/og/zh-yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
-banner: "/banners/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
+title: "Prometheus 明明有数据,Grafana 面板却全是 No data 的原因"
+description: "所有 target 都是 up、数据就在 Prometheus 里、面板表达式也没错——41 个面板却全部 No data。原因是 Grafana 变量过滤了自己,而我的验证方式恰好掩盖了它。"
+pubDate: 2026-05-17
+updatedDate: 2026-09-29
+category: devops
+blueprint: self-hosted-infrastructure
+blueprintLayer: L3-observability-self-healing
+tags: [grafana, prometheus, dashboards, monitoring, promql, observability]
+ogImage: /og/zh-why-your-grafana-dashboard-shows-no-data.jpg
+banner: /banners/why-your-grafana-dashboard-shows-no-data.jpg
draft: false
-dynamicCta: true
---
-YuE2 是一个 30 亿参数的开源音乐模型,能按风格提示词和歌词唱中文。我把它放在自己那台 12GB 显存的 RTX 3060 上用 ComfyUI 本地跑,一次排了 12 首原创歌 —— 结果第一批出来,**每一首的时长都恰好是 240.000 秒**。这个数字不是巧合,而且是个坏消息。
-## 每首都恰好 240.000 秒
+打开每天在看的面板,结果是空的。不是\"某一格坏了\",而是**从上到下每一格**都写着 *No data*。去查 Prometheus,一切健康。这篇讲我踩到的那个具体且不直观的原因,以及为什么我自己的验证会同时告诉我\"面板没问题\"。
-12 个文件,时长精确到毫秒完全一致:
+## 最省时间的排查顺序
-```
-01_A1_..._糖画小仙.mp3 duration=240.000000
-02_A2_..._溪边捉云记.mp3 duration=240.000000
-03_A3_..._寒露拾桂子.mp3 duration=240.000000
-...
-```
-
-我设的是 `max_duration=240`,因为参考指南说 12 段结构大约会落在三分半左右。结果它精确落在 240 秒 —— 这意味着模型从来没有"决定结束",它是把时间用光了。
-
-## 整数是一种自白
-
-YuE2 用 ABC 规划器来编排:读取歌词,给每一段分配小节数,生成一份符号化的曲谱草稿。音乐生成器照着这份草稿唱,**草稿结束它就结束**。
-
-所以一首真正唱完的歌,输出时长一定是**小于**你设的上限的 —— 它自己停下来了。
-
-判断方法只要一条命令:
+常见原因(数据源选错、时间范围不对、`rate()` 用在会重置的计数器上、target 没被抓取)都不适用。真正有效的检查顺序是:
```bash
-d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 take.mp3)
-python -c "print(abs(float('$d') - 240.0) <= 0.005)" # True = 被硬切
+# 1. target 真被抓取了吗?
+curl -s http://prometheus:9090/api/v1/targets | jq '.data.activeTargets[] | {job:.labels.job, health}'
+
+# 2. 指标现在存在吗?(完全绕过 Grafana)
+curl -s -G http://prometheus:9090/api/v1/query \
+ --data-urlencode 'query=node_uname_info{job="unraid-host"}' | jq '.data.result[0].metric'
+
+# 3. 面板表达式本身对吗?
+curl -s -G http://prometheus:9090/api/v1/query \
+ --data-urlencode 'query=count(node_cpu_seconds_total{mode="idle",job="unraid-host"})'
```
-如果时长和 `max_duration` 相差不到几毫秒,这次渲染就是被截断的。歌词需要的时间超过了上限允许的范围,切点那一句被拦腰砍断。
+三项都过了:7 个 target `up`、2.5 万条序列在采集、手动跑面板表达式也有数据。所以问题不在指标、不在抓取、也不在 PromQL——而在**它们之间的变量层**。
-## 确认尾奏真的丢了
+## 真正的原因:变量过滤了自己
-光看时长只能知道"被切了",不知道"丢了什么"。所以我把每首的尾巴切出来,用本地语音模型(SenseVoice,跑在 CPU 上,GPU 渲染时不用停)转写:
+面板上有一个主机选择器,两个变量串联:`$nodename`(哪台机器)和 `$node`(它的 `instance` 标签)。第一个的定义是:
+
+```promql
+# BROKEN — 变量用自身的值来过滤自己
+label_values(node_uname_info{job="unraid-host", nodename=~"$nodename"}, nodename)
+```
+
+首次加载时 `$nodename` 还没有值,选择器就变成 `nodename=~""`——**什么都匹配不到**。查询返回零行的变量没有任何选项,于是它保持空;而 `$node` 又是基于 `$nodename` 定义的:
+
+```promql
+label_values(node_uname_info{job="unraid-host", nodename="$nodename"}, instance)
+```
+
+于是 `$node` 也是空,所有以 `$node` 为过滤条件的面板自然匹配不到序列。Grafana 说 *No data* 并没有骗人:面板查询真的没有数据,因为给它限定范围的变量解析成了空。
+
+修法是去掉自引用,并给每个变量存一个明确的默认值:
+
+```promql
+# FIXED — 不自引用,每个变量只跳一层
+# $nodename
+label_values(node_uname_info{job="unraid-host"}, nodename)
+# $node
+label_values(node_uname_info{job="unraid-host", nodename="$nodename"}, instance)
+```
+
+## 为什么我的验证没抓到
+
+这才是值得抄的部分。我\"验证\"面板的方式是:**手动**把每个面板表达式里的变量替换成我知道正确的值(`$nodename=unRaid`、`$node=host.docker.internal:9100`),然后断言查询有数据点。13 项检查全部通过——面板依然是空的,因为 bug 在被替换的那个值**上游**:Grafana 自己对变量的解析是空的。
+
+**一个替换掉\"可疑值\"的验证,永远发现不了那个值解析失败。** 要抓它,得读 Grafana 真实存下来的值:
```bash
-ffmpeg -v error -y -i take.mp3 -ss 232 -t 20 -ar 16000 -ac 1 -c:a pcm_s16le t.wav
-curl -s -X POST http://127.0.0.1:20129/v1/audio/transcriptions \
- -F "file=@t.wav" -F "language=zh"
+curl -s -u admin:"$PW" http://grafana:4010/api/dashboards/uid/rYdddlPWk \
+ | jq '.dashboard.templating.list[] | {name, current: .current.value}'
```
-(对于连末遍副歌都没唱到的曲目,我将切片窗口向前扩展到了最后 30 秒即 `-ss 210`,以准确定位截断发生时的唱段。)结果没有歧义:
-
-| 歌 | 唱到哪 | 缺了什么 |
-|---|---|---|
-| 糖画小仙 | 末遍副歌最后一句 | **整段 `[Outro]`** |
-| 溪边捉云记 | 215 秒还停在 `[Bridge]` | Solo + 末遍副歌 + Outro |
-| 寒露拾桂子 | 215 秒还停在 `[Bridge]` | 末遍副歌 + Outro |
-| 纸鸢与旧木偶 | 末遍副歌第 5 句 | 副歌后半 + Outro |
-
-所以不是混音问题、也不是淡出 —— 是结构不完整。
-
-## 试过但没用的三件事,和半有用的一件
-
-### 1. 提高 BPM —— 完全无效
-
-直觉上:节奏更快、每秒塞进更多字、同样的窗口能装更多歌词。我在整批上验过:117 BPM 的国风组和 140 BPM 的 DJ 燃曲组,**吞吐几乎一样**。
-
-YuE2 的时长来自 ABC 规划器分配的小节数,不看你风格提示词里的 BPM 标签。BPM 只影响"感觉",买不到时间。别在它身上调时长。
-
-### 2. 同 seed 重跑 —— 静悄悄地什么都没做
-
-ComfyUI 是用 `--cache-classic` 启动的,所以重新提交一个逐字节相同的任务(同样的风格、同样的歌词、同样的 seed)会直接命中节点缓存。任务"完成"于 10 秒左右,还你一份一模一样的截断音频,文件名加个 `_00002`。
-
-看起来像一次又快又成功的重试。实际上是命中了一个已知有问题 take 的缓存。
-
-### 3. 换 seed —— 四次里能救三次
-
-规划器分配小节的方式依赖 seed,所以换个 seed 确实可能给出更短、能唱完的版本。这一招救回了我四首里的三首:
-
-| 歌 | 原始 | 新 seed | 结果 |
-|---|---|---|---|
-| 溪边捉云记 | 卡在 281 秒 | 20002 | 279.7 秒,完整 |
-| 外婆的绿搪瓷碗 | 卡在 300 秒 | 20007 | 251.9 秒,完整 |
-| 北溟有小鱼 | 卡在 300 秒 | 20005、30005 | 再次撞顶 |
-
-## 预算是"每秒几个字",而这个数还在变
-
-我试过把它做成算术:数字数、除以吞吐、得出时长。实测不配合。
-
-| 歌 | 汉字数 | 上限 | 实际 | 装下了吗 |
-|---|---|---|---|---|
-| 糖画小仙 | 556 | 272 秒 | 258.7 秒 | ✅ |
-| 纸鸢与旧木偶 | 572 | 300 秒 | 279.9 秒 | ✅ |
-| 溪边捉云记 | 578 | 281 秒 | **281.0 秒** | ❌ 被切 |
-| 小楼檐下听雨眠 | 580 | 300 秒 | 256.3 秒 | ✅ |
-| 画卷小锦鲤 | 634 | 300 秒 | 285.9 秒 | ✅ |
-| 三楼窗台的蒲公英 | 693 | 329 秒 | 247.4 秒 | ✅ |
-| 弄堂口的竹蜻蜓 | 692 | 300 秒 | 250.0 秒 | ✅ |
-
-把最后两行和第三行对比着看:**693 个字在 247 秒内唱完了,578 个字却撑爆了 281 秒。** 字数换算时长的公式在这里根本不存在 —— 实测吞吐落在每秒 2.0 到 2.8 个汉字之间,取决于规划器怎么切段落。
-
-所以字数只适合用来**预警预算**,不适合用来掐时间。
-
-## 真正卡住的瓶颈是显存
-
-一旦接受"给它足够空间、让它自己收尾",上限自然想设大一点。但在 12GB 卡上设不大。
-
-渲染过程中每 5 秒采一次 `nvidia-smi` 得到的峰值显存:
-
-| `max_duration` | 峰值显存 |
-|---|---|
-| 240 秒 | 10.3 GB |
-| 272 秒 | 11.65 GB |
-| 278 秒 | 11.07 GB |
-| 281 秒 | 12.06 GB |
-| 300 秒 | 12.03 GB |
-
-显存总量为 12,288 MiB(12 GB)。**300 秒就是实际天花板** —— 再往上,你离"渲染到第六分钟时因为一个浏览器标签页而 OOM 崩掉"只差一步。
-
-## 解法
-
-三处改动,都很便宜:
-
-1. **把上限设到 300 秒,让规划器自己收尾。** 不要追目标时长;给足余量,它会在音乐结束的地方结束。
-2. **被切的 take 归档,不要混进成品。** 时长等于上限的 take 移到 `未自然结尾\` 目录。成品目录里就只有完整的歌,而且重跑永远不会覆盖掉一个可用的 take。
-3. **换 seed 最多试三次。** 换 seed 有用,但如果一首歌的歌词真的超过显存能撑的时长,它会永远失败。三次不成就动歌词 —— 别烧一小时 GPU 假装下一个 seed 会中。
-
-## 那首始终装不下的歌
-
-12 首里有一首没做成。**北溟有小鱼,593 字,失败了五次:**
-
-| 尝试 | 参数 | 结果 |
-|---|---|---|
-| 1 | seed 10005,上限 287 秒 | 撞 287 秒 |
-| 2 | seed 10005,上限 300 秒 | 撞 300 秒 |
-| 3 | seed 20005,上限 300 秒 | 撞 300 秒 |
-| 4 | seed 30005,上限 300 秒 | 撞 300 秒 |
-| 5 | seed 40005,删掉两段纯器乐 | 撞 300 秒 |
-
-我甚至把 `[Interlude]` 和 `[Instrumental Solo]` 两个标记删掉 —— 从 12 段降到 10 段,一个唱词都没动 —— 它还是溢出。它的规划器要的时间超过了这张卡能给的上限。诚实的解法是砍掉大约 40 个字再重跑;我宁可告诉你这件事,也不想交一首结尾被咬掉一块的第十二首歌。
-
-## 在这之前踩到的一个坑
-
-值得单独警告:**YuE2 会把你歌词里括号内的中文制作备注唱出来。**
-
-如果你贴的歌词长这样(大多数歌词稿就是这么写的):
-
-```
-[Intro]
-(重鼓点起,急促琵琶轮指,唢呐破空,合成器铺底)
-嘿!哈!山海震!
-```
-
-那么那行括号内容就成了一句歌词。模型不知道那是舞台说明。提交前先清掉:
-
-```python
-clean = "\n".join(l for l in raw.split("\n")
- if not re.match(r"^\s*[((].*[))]\s*$", l))
-```
-
-配器和动态写进风格提示词。歌词框里只留段落标签和要唱的字。
-
-## 我会怎么改
-
-- **预警做在歌词阶段,不在渲染之后。** 先数字数。12GB 卡上超过约 600 字就要有心理准备,超过 550 字就该准备删点什么。
-- **永远不要把 `max_duration` 设成目标时长。** 设得比估算宽松,让它自己收尾。上限是安全护栏,不是旋钮。
-- **把"时长 == 上限"当成流水线里的硬失败**,而不是三天后觉得副歌怪怪的才发现。
-- **先换 seed,再动歌词** —— 免费,而且大多数时候有效。然后三次为止。
-
-## 十一首歌
-
-下面这些是自然收尾的成品,女童声、中文、跑在一张 12GB 显卡上,成本比一年的云端音乐额度还低。每个播放器都带同步歌词 —— 因为模型不输出时间戳,时间轴是用转写结果和歌词原文对齐反推出来的。
-
-
-
-### 1. 糖画小仙 — Sugar-Painting Fairy
-
-*Guofeng electronic* · 556 characters · 259 s
-
-
-
-
-
-
-
-````
-
-### 🇬🇧 英文版原文 (English Markdown Primary)
-````markdown
----
-title: "Why My AI Songs Kept Losing Their Endings: Running YuE2 on a 12 GB GPU"
-description: "Twelve Mandarin songs came out exactly 240.000 seconds long with the outros missing. The cause wasn't the lyrics — it was a hard cap, a VRAM ceiling, and a planner that ignores BPM."
-pubDate: 2026-10-20
-category: ai
-blueprint: ai-agent-automation
-blueprintLayer: L4-aigc-media-pipelines
-stream: creative-lab
-tags: ["yue2", "comfyui", "ai-music", "local-ai", "gpu", "vram", "mandarin"]
-ogImage: "/og/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
-banner: "/banners/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
-draft: false
-dynamicCta: true
----
-YuE2 is a 3B open-weights music model that sings Mandarin from a style prompt
-and a lyric sheet. I ran it locally on a 12 GB RTX 3060 through ComfyUI —
-twelve original songs in one serial queue — and the first batch came back with
-**every single file exactly 240.000 seconds long**. That number is not a
-coincidence, and it is not good news.
-
-## Every track was exactly 240.000 seconds
-
-Twelve files, twelve identical durations to the millisecond:
-
-```
-01_A1_..._糖画小仙.mp3 duration=240.000000
-02_A2_..._溪边捉云记.mp3 duration=240.000000
-03_A3_..._寒露拾桂子.mp3 duration=240.000000
-...
-```
-
-I had asked for `max_duration=240` because the style guide I was working from
-said a 12-section structure would land around three and a half minutes. It
-landed on 240 seconds exactly — which means the model never decided to stop.
-It ran out of room.
-
-## A round number is a confession
-
-YuE2 plans a song with an ABC planner: it reads the lyric sheet, decides how
-many bars each section gets, and emits a symbolic sketch. The music generator
-then sings that sketch and **ends when the sketch ends**. A finished song
-therefore comes out *shorter* than the cap you set — it stops on its own.
-
-Here is the tell, and it costs one shell command:
+更直接的检查对象是变量查询本身——跑 Grafana 会跑的那条:
```bash
-d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 take.mp3)
-python -c "print(abs(float('$d') - 240.0) <= 0.005)" # True = hard cut
+# $nodename 的 label_values() 到底返回什么?
+curl -s -G http://prometheus:9090/api/v1/query \
+ --data-urlencode 'query=count by (nodename) (node_uname_info{job="unraid-host"})' | jq '.data.result'
```
-If the duration equals `max_duration` to within a few milliseconds, the render
-was truncated. The lyric sheet wanted more time than the cap allowed, and the
-last thing being sung at the cutoff point got sliced off mid-phrase.
+那里是空,面板就不可能渲染出来,无论数据多健康。
-## Confirming the outros were actually missing
+## 第二个坑:`$__all` 不是 `.*`
-Duration alone tells you *that* it was cut, not *what* was lost. So I pulled
-the tail of each take and transcribed it with a local speech model (SenseVoice,
-CPU — it stays running while the GPU renders):
+做自动化面板检查时,值为\"All\"的变量,其 current 是哨兵字符串 `$__all`——不是正则。把 `name=~"$__all"` 原样展开会匹配不到任何东西,于是完全健康的容器面板\"全军覆没\"。展开前要把 `$__all` 解析成该变量的 `allValue`(通常是 `.*`),否则你追的是一个只存在于自己检查脚本里的 bug。
-```bash
-ffmpeg -v error -y -i take.mp3 -ss 232 -t 20 -ar 16000 -ac 1 -c:a pcm_s16le t.wav
-curl -s -X POST http://127.0.0.1:20129/v1/audio/transcriptions \
- -F "file=@t.wav" -F "language=zh"
-```
+## 不是每个空面板都是 bug
-(For tracks that had not even reached the final chorus, I widened the slice window back to the final 30 seconds with `-ss 210` to locate the active section when truncation hit.) The results were unambiguous:
+修完之后 25 个面板有 21 个出数。剩下 4 个是**设计上就不可能**有数据的,最好在面板说明里写清楚:
-| Song | Sung up to | What was missing |
-|---|---|---|
-| 糖画小仙 | final chorus, last line | the entire `[Outro]` |
-| 溪边捉云记 | `[Bridge]` at 215 s | solo + final chorus + outro |
-| 寒露拾桂子 | `[Bridge]` at 215 s | final chorus + outro |
-| 纸鸢与旧木偶 | final chorus, line 5 | rest of chorus + outro |
+- **PSI 面板**需要 `/proc/pressure`,而这台 NAS 的内核不暴露它——指标在这台机器上无法存在;同样的面板在会导出 `node_pressure_*` 的主机上正常显示。
+- **根文件系统面板**的表达式带 `fstype!="rootfs"`,而某台主机的 `/` 是**内存盘**(unRaid 就是),所以永远匹配不到。
-So: not a mixing artefact, not a fade-out. The songs were structurally
-incomplete.
+区分\"因为 bug 空\"和\"因为这台机器不可能有这个指标而空\",是真修好和瞎忙一下午的分界线。
-## Three fixes that did nothing, and one that half-worked
+## 我下次会怎么做
-### 1. Raising the BPM — no effect
-
-The obvious reasoning: faster tempo, more syllables per second, more lyrics
-fit in the same window. I tested this across the batch: the 117 BPM guofeng
-tracks and the 140 BPM DJ remixes had **effectively identical throughput**.
-
-YuE2's duration comes from the ABC planner's bar count, not from the BPM token
-in your style prompt. The BPM tag shapes the *feel*; it does not buy you room.
-Stop tuning it for duration.
-
-### 2. Re-running with the same seed — silently does nothing
-
-ComfyUI was started with `--cache-classic`, so resubmitting a byte-identical
-prompt — same style, same lyrics, same seed — hits the node cache. The job
-"completes" in about ten seconds and hands you back the same truncated audio
-with a `_00002` filename.
-
-It looks like a fast, successful retry. It is a cache hit on a known-bad take.
-
-### 3. Rolling new seeds — works about 3 times out of 4
-
-The planner's bar allocation depends on the seed, so a different seed really can
-produce a shorter, complete song from the same lyrics. This rescued three of my
-four clipped tracks:
-
-| Song | Original | New seed | Result |
-|---|---|---|---|
-| 溪边捉云记 | clipped at 281 s | 20002 | 279.7 s, complete |
-| 外婆的绿搪瓷碗 | clipped at 300 s | 20007 | 251.9 s, complete |
-| 北溟有小鱼 | clipped at 300 s | 20005, 30005 | clipped again |
-
-## The budget is characters per second — and it isn't constant
-
-I tried to turn this into arithmetic: count the Chinese characters, divide by a
-throughput figure, get the duration. The measurements refused to cooperate.
-
-| Song | Characters | Cap | Actual | Fits? |
-|---|---|---|---|---|
-| 糖画小仙 | 556 | 272 s | 258.7 s | ✅ |
-| 纸鸢与旧木偶 | 572 | 300 s | 279.9 s | ✅ |
-| 溪边捉云记 | 578 | 281 s | **281.0 s** | ❌ cut |
-| 小楼檐下听雨眠 | 580 | 300 s | 256.3 s | ✅ |
-| 画卷小锦鲤 | 634 | 300 s | 285.9 s | ✅ |
-| 三楼窗台的蒲公英 | 693 | 329 s | 247.4 s | ✅ |
-| 弄堂口的竹蜻蜓 | 692 | 300 s | 250.0 s | ✅ |
-
-Read the last two rows against the third. **693 characters finished in 247
-seconds, while 578 characters overflowed 281 seconds.** A lyrics-to-duration
-formula simply does not exist here — the observed throughput ranges from about
-2.0 to 2.8 Chinese characters per second, depending on how the planner chops the
-sections.
-
-So characters are useful for a *budget warning*, not for setting a timer.
-
-## The limit that actually binds: VRAM
-
-Once you accept "give it enough room and let it close naturally", the cap wants
-to be large. On a 12 GB card it cannot be.
-
-Measured peak VRAM while rendering, sampled every five seconds with
-`nvidia-smi` during the run:
-
-| `max_duration` | Peak VRAM |
-|---|---|
-| 240 s | 10.3 GB |
-| 272 s | 11.65 GB |
-| 278 s | 11.07 GB |
-| 281 s | 12.06 GB |
-| 300 s | 12.03 GB |
-
-The card has 12,288 MiB (12 GB) of VRAM. **300 seconds is the practical ceiling** — past that
-you are one desktop browser tab away from an out-of-memory abort in the middle
-of a six-minute render.
-
-## The fix
-
-Three changes, all cheap:
-
-1. **Set the cap to 300 s and let the planner close the song itself.** Do not
- chase a target length; give it headroom and it ends where the music ends.
-2. **Archive clipped takes instead of mixing them in.** A take whose duration
- equals the cap goes to `未自然结尾\` ("did not end naturally"). The output
- folder then contains only complete songs, and re-renders never destroy a
- usable take.
-3. **Cap the seed roulette at three attempts.** Different seeds help, but a song
- whose lyrics genuinely need more than the VRAM ceiling allows will fail
- forever. Three strikes and you trim the lyric sheet — don't burn an hour of
- GPU pretending the next seed will be the one.
-
-## The song that never fit
-
-One of the twelve never made it. **北溟有小鱼 — 593 characters — failed five
-times:**
-
-| Attempt | Parameters | Result |
-|---|---|---|
-| 1 | seed 10005, cap 287 s | hit 287 s |
-| 2 | seed 10005, cap 300 s | hit 300 s |
-| 3 | seed 20005, cap 300 s | hit 300 s |
-| 4 | seed 30005, cap 300 s | hit 300 s |
-| 5 | seed 40005, both instrumental sections removed | hit 300 s |
-
-I even deleted the `[Interlude]` and `[Instrumental Solo]` markers — 12 sections
-down to 10, not one sung word changed — and it still overflowed. Its planner
-wants more time than a 12 GB card can give. The honest fix is to cut about 40
-characters of lyric and re-render, and I would rather tell you that than ship a
-twelfth track with a bitten-off ending.
-
-## A trap I hit before any of this
-
-Worth its own warning: **YuE2 will sing the Chinese production notes in your
-lyric sheet.**
-
-If you paste lyrics that look like this — which is how most lyric sheets are
-written —
-
-```
-[Intro]
-(重鼓点起,急促琵琶轮指,唢呐破空,合成器铺底)
-嘿!哈!山海震!
-```
-
-…then that parenthetical line becomes a lyric. The model does not know it is a
-stage direction. Strip them before submitting:
-
-```python
-clean = "\n".join(l for l in raw.split("\n")
- if not re.match(r"^\s*[((].*[))]\s*$", l))
-```
-
-Arrangement and dynamics belong in the style prompt. The lyric box gets section
-tags and sung words, nothing else.
-
-## What I'd do differently
-
-- **Pre-flight the lyric sheet, not the render.** Count characters first. Over
- roughly 600 characters on a 12 GB card, expect trouble; over 550, expect to
- trim something.
-- **Never set `max_duration` to a target length.** Set it generously above the
- estimate and let the planner finish. The cap is a safety rail, not a dial.
-- **Treat "duration == cap" as a hard failure in the pipeline**, not something
- you notice three days later when a chorus sounds wrong.
-- **Re-run with a new seed before touching the lyrics** — it is free and works
- most of the time. Then stop at three.
-
-## Eleven tracks
-
-These are the ones that closed naturally, sung by a child voice, in Mandarin,
-on a 12 GB card that cost less than a year of cloud music credits. Each player
-below has synced lyrics — the timing was recovered by transcription and aligned
-against the written lyrics, since the model emits no timestamps.
-
-
-
-### 1. 糖画小仙 — Sugar-Painting Fairy
-
-*Guofeng electronic* · 556 characters · 259 s
-
-
-
-