From 86edd40c740cf3e559bda959827c0014124f1de7 Mon Sep 17 00:00:00 2001 From: hoelee Date: Tue, 27 Oct 2026 03:28:58 +0800 Subject: [PATCH] docs(review): consultant audit tracker, 10 post batches and the posts/case-studies upgrade plan - docs/consultant-review/: review brief, per-batch post dumps (01-10), audit-tracker.json + AUDIT-TRACKER.md generated by scripts/manage_review.py - scripts/manage_review.py: list/approve/approve-batch/generate over the tracker - docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md: locked decision register + 4 work orders for the /posts/ archive rework and the case-studies portfolio treatment --- ...w-2026-10-27-posts-and-casestudies-plan.md | 95 + .../00-外部顾问审阅须知与背景规范.md | 41 + docs/consultant-review/AUDIT-TRACKER.md | 105 + docs/consultant-review/audit-tracker.json | 1185 ++++ docs/consultant-review/batches/batch-01.md | 5061 +++++++++++++++++ docs/consultant-review/batches/batch-02.md | 4104 +++++++++++++ docs/consultant-review/batches/batch-03.md | 4327 ++++++++++++++ docs/consultant-review/batches/batch-04.md | 4461 +++++++++++++++ docs/consultant-review/batches/batch-05.md | 4346 ++++++++++++++ docs/consultant-review/batches/batch-06.md | 4141 ++++++++++++++ docs/consultant-review/batches/batch-07.md | 4561 +++++++++++++++ docs/consultant-review/batches/batch-08.md | 3329 +++++++++++ docs/consultant-review/batches/batch-09.md | 3352 +++++++++++ docs/consultant-review/batches/batch-10.md | 3706 ++++++++++++ scripts/manage_review.py | 458 ++ 15 files changed, 43272 insertions(+) create mode 100644 docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md create mode 100644 docs/consultant-review/00-外部顾问审阅须知与背景规范.md create mode 100644 docs/consultant-review/AUDIT-TRACKER.md create mode 100644 docs/consultant-review/audit-tracker.json create mode 100644 docs/consultant-review/batches/batch-01.md create mode 100644 docs/consultant-review/batches/batch-02.md create mode 100644 docs/consultant-review/batches/batch-03.md create mode 100644 docs/consultant-review/batches/batch-04.md create mode 100644 docs/consultant-review/batches/batch-05.md create mode 100644 docs/consultant-review/batches/batch-06.md create mode 100644 docs/consultant-review/batches/batch-07.md create mode 100644 docs/consultant-review/batches/batch-08.md create mode 100644 docs/consultant-review/batches/batch-09.md create mode 100644 docs/consultant-review/batches/batch-10.md create mode 100644 scripts/manage_review.py diff --git a/docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md b/docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md new file mode 100644 index 0000000..56ce025 --- /dev/null +++ b/docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md @@ -0,0 +1,95 @@ +# 博客升级执行计划:/posts/ 归档重构与 Case Studies 作品集化 + +> **记录时间**:2026-10-27 +> **归档状态**:已锁定,待下个会话/回合指令开工 +> **顾问裁定依据**: +> 1. 第一轮:《关于 Hoelee Enterprise 技术博客的整体评审与升级建议》 +> 2. 第二轮:《关于 Case Studies Banner 尺寸与 Posts 筛选交互的补充裁定》 + +--- + +## 一、核心决策与交互规范(Decision Register) + +### 1. `/categories/case-studies/` 作品集化 +- **Hero 架构**:桌面端采用左右分栏紧凑 Hero(左侧文本 + 右侧视觉)。文字必须使用语义化 HTML,严禁将标题或文字烧入图片;移动端自然堆叠。 +- **Banner 视觉规格**: + - 比例约 **21:9 至 3:1**(内容区 900px 下,目标显示高度控制在 **240–300px**,避免占用过多首屏空间)。 + - 圆角:8–12px,克制。 + - 视觉意象:展示 3 台生产宿主(Unraid / Synology / VPS)、Docker、Prometheus 监控、网络链路与自动化流向;配色遵循暗调终端灰绿 + 暖铜低饱和点缀。 +- **案例排序与内容组织**: + - 弃用单纯时间倒序,顶部优先展示 3–4 篇旗舰案例(生产基础设施规模、本地 AI 工作流、端到端自动化)。 + - 卡片突出解决的痛点与量化成果指标(如 `3 Hosts · 162 Containers`)。 +- **行动入口(Affordance)**: + - 卡片保持整卡可点,摘要末尾增加小型文字链接 `Read case study →` / `阅读案例复盘 →`。 + +### 2. `/posts/` 全部文章归档页 +- **结构模型**: + - **顶部**:实时搜索栏 + 分类与热门标签过滤条。 + - **中部(Featured 精选区)**:展示 2–3 篇旗舰精选文章卡片(16:9 WebP 封面、简短摘要、阅读时间、核心标签)。 + - **下部(完整归档)**:紧凑年份与月份条目列表,供高效快速扫读。 +- **交互联动机制(顾问裁定)**: + - **默认态**:展示 Featured 精选卡片 + 默认全量归档列表。 + - **触发筛选态**:一旦用户在搜索框输入首个有效字符,或点击选中任意 Category/Tag 芯片,Featured 精选区执行平滑收起(高度收起 + 150–200ms 过渡,兼容 `prefers-reduced-motion`),把全量视口空间让给检索结果。 + - **恢复态**:用户清空输入且清除 Tag 筛选时,精选区重新展开。 + - **状态反馈**:列表上方常驻显式筛选状态(当前过滤条件、命中篇数、一键 `Clear filters` 按钮)。 + - **全局覆盖**:搜索算法对全量文章进行匹配,精选文章在收起状态下依然能在结果列表中被命中展示。 + +### 3. 全局组件体验优化 +- `src/components/PostList.astro` 通用卡片组件补全轻量行动链接(`Read article →` / `Read case study →`)。 +- 严格保证中英双语(EN / ZH)同等体验与无死角本地化。 +- 保证 375px/390px 移动端无横向滚动溢出。 + +--- + +## 二、工单拆解与实施路线图 + +### 工单 1:`/posts/` 混合列表与动态联动(P0) +- **目标文件**: + - `src/pages/posts/index.astro` + - `src/pages/zh/posts/index.astro` + - `src/components/Archive.astro` +- **实施内容**: + 1. 在 `Archive.astro` 或 `posts/index.astro` 中定义 `featuredPosts` 选取逻辑(复用首页验证过的 3 篇经典系统级案例)。 + 2. 实现 Featured 响应式栅格卡片(16:9 WebP 缩略图、标题、阅读时长、摘要、行动链接)。 + 3. 改造 `Archive.astro` 的客户端脚本: + - 增加 `#archive-featured` 容器引用与折叠状态类 `.is-collapsed`。 + - 监听 `input` 输入与 Tag/Category 按钮点击,计算 `isFiltering = Boolean(query || activeTag || activeCat)`。 + - 当 `isFiltering === true` 时,平滑收起 `#archive-featured`;重置为 false 时恢复。 + - 增加 `#archive-filter-status` 状态栏(显示匹配数量与“清除筛选”按钮)。 + 4. 完善 CSS 过渡动画与 `@media (prefers-reduced-motion: reduce)` 降级支持。 + +### 工单 2:`/categories/case-studies/` 紧凑 Hero 与作品集重构(P0) +- **目标文件**: + - `src/pages/categories/[category].astro` + - `src/pages/zh/categories/[category].astro` + - `src/components/CategoryArt.astro` + - `src/components/PostList.astro` +- **实施内容**: + 1. 为 `case-studies` 类别定制专用 Hero 结构: + - 桌面端 CSS Grid / Flex 两栏布局(左侧 H1 + 定位介绍,右侧 21:9 / 3:1 扁平架构视觉)。 + - 移动端单栏自然堆叠。 + 2. 视觉资产准备: + - 为 `case-studies` 制作或导出具备 3 宿主 + Docker + Prometheus 拓扑辨识度的横幅素材(落地于 `/images/categories/case-studies-banner.webp`)。 + - 在 `CategoryArt.astro` 或分类页直接引入响应式 `` 标签。 + 3. 列表项调整: + - `case-studies` 分类下的文章加入排序权重(旗舰案例前置)。 + - 卡片增加解决问题、量化结果指标(利用已有的 `featuredMetrics` 数据源或 frontmatter 扩展)。 + 4. 增加卡片右下角 `Read case study →` / `阅读案例复盘 →` 引导文字。 + +### 工单 3:全站列表组件与多语言对齐(P1) +- **目标文件**: + - `src/components/PostList.astro` + - `src/styles/global.css` +- **实施内容**: + 1. `PostList.astro` 全面增加可点击的小型文字链接: + - 普通分类:`Read article →` / `阅读文章 →` + - Case Studies:`Read case study →` / `阅读案例复盘 →` + 2. 校验中英双语标签字典 `categoryLabel`,确保无未翻译的英文字段泄漏到 `/zh/` 页面。 + 3. 确保列表卡片在移动端下的行间距与点击区域(Touch Target ≥ 44px)符合无障碍规范。 + +### 工单 4:端到端验收与发布(P0) +- **验收流程**: + 1. 本地执行 `npm run build`,确保 Astro 构建零错误。 + 2. 运行 `node scripts/check-assets.mjs`,确保静态资源引证有效。 + 3. 使用 Headless 探测工具或 Playwright 脚本验证 1920px 与 375px 视口下的视觉呈现与水平溢出(`scrollWidth === clientWidth`)。 + 4. 提交 Git 并推送到 Gitea(`git.hoelee.com`)与 GitHub 镜像。 diff --git a/docs/consultant-review/00-外部顾问审阅须知与背景规范.md b/docs/consultant-review/00-外部顾问审阅须知与背景规范.md new file mode 100644 index 0000000..7f7e300 --- /dev/null +++ b/docs/consultant-review/00-外部顾问审阅须知与背景规范.md @@ -0,0 +1,41 @@ +# 外部顾问审阅前置须知与工程背景规范 + +> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。 + +## 1. 站点定位与双重业务目的 +- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。 +- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。 + +## 2. 核心人设铁律与工程硬约束(必须严格遵守) +1. **彻底杜绝业余爱好者/玩具口吻**: + - ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。 + - ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。 +2. **严禁捏造虚假指标与性能数字**: + - 所有 benchmark 数据必须有明确的测试基准与单设备限制。 + - 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。 + - 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。 +3. **去模板化叙事与真实物理锚点**: + - 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。 + - 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。 +4. **代码与配置展示规范**: + - 超过 20 行的配置或日志采用 HTML `
` 原生折叠,`` 必须精准标注文档名与行数。 + +## 3. 顾问重点开刀维度 +- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大? +- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻? +- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)? +- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置? + +## 4. 博客 7 大分类与内容策略参考 +- `case-studies` (商业案例):求职与获客核心武器,以真实商业交付和解决瓶颈为核心。 +- `devops` (私有基建):高并发网关、Docker、Traefik、存储阵列与监控,最强差异化资产。 +- `ai` (人工智能与智能体):围绕自动化工作流、LiteLLM 网关、状态机防幻觉与本地私有大模型。 +- `engineering` (系统架构与后端):Java/Spring、PHP/CodeIgniter、TS/React 的硬核实战。 +- `tutorials` (技术教程):面向学习者的深度实战指南。 +- `notes` (排障速记):轻量级踩坑手记与高频高价值 gotcha。 +- `web3` (智能合约):Solidity/Foundry 严谨实践,诚实表达学习与验证阶段。 + +## 5. 既有核心参考文档清单(仓库内置) +- `docs/content-guide.md` — 博客内容定位、选题规范、反模式与 i18n 策略。 +- `docs/post-publishing-sop-v2.md` — 5 步叙事模型、代码折叠与 Bento 卡片技术规范。 +- `docs/consultant-review-2026-10-26-supplement.md` — 上一轮顾问诊断全景资料包。 diff --git a/docs/consultant-review/AUDIT-TRACKER.md b/docs/consultant-review/AUDIT-TRACKER.md new file mode 100644 index 0000000..413e2a1 --- /dev/null +++ b/docs/consultant-review/AUDIT-TRACKER.md @@ -0,0 +1,105 @@ +# 全站文章顾问审阅状态总控台 (Audit Tracker) + +> **最后同步时间**:2026-10-27 03:22:38 +> **全站总文章数**:91 篇 | **已审批完成**:9 篇 (9.9%) | **待审阅**:82 篇 + +## 一、操作命令速查 +- `python scripts/manage_review.py list` — 查看待审与已审概览 +- `python scripts/manage_review.py approve [--notes '顾问通过']` — 标记单篇通过 +- `python scripts/manage_review.py approve-batch ` — 标记整个批次通过 +- `python scripts/manage_review.py generate` — 重新分批(自动排除已审批文章,每批 < 200k 字) + +## 二、文章审核状态总表 +| # | 分类 | 文章 Slug | 中文标题 | 状态 | 所在批次 | 审批时间 | 顾问备注 | +|---|---|---|---|:---:|:---:|:---:|---| +| 1 | `ai` | `best-ai-video-generators-2026` | 2026 年最佳 AI 视频生成器:免费与付费对比,以及我的 LTX-2.3 实测 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 2 | `ai` | `five-step-ai-roadmap-what-i-actually-run` | 全网都在转的 AI 五步学习路线 —— 我真正在生产里跑的是哪几步 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 3 | `ai` | `giving-ai-your-mouse-and-keyboard-safely` | 把鼠标和键盘交给 AI 之前:Windows 桌面 MCP 的安全边界与实战防线 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 4 | `ai` | `i-let-an-agent-manage-my-shopee-vouchers` | 我让 AI 助手管我的 Shopee 优惠券 —— 它建了一张我没批准的券 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 5 | `ai` | `plugging-windows-desktop-control-into-four-ai-agents` | 把 Windows 桌面控制接进 4 款 AI Agent:Hermes、OpenCode、Claw 与 Claude 的实战架构 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 6 | `ai` | `running-ai-video-locally-on-a-12gb-gpu` | 12GB 显卡跑本地 AI 视频:Wan 2.2 加 SeedVR2 的真实数据 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 7 | `ai` | `running-tts-as-a-service-with-token-sidecars` | 我用两个 Cron 容器跑了一年的语音合成服务 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 8 | `ai` | `slashing-agent-context-with-a-code-graph` | 别再把整个 Repo 喂给 Agent:用 Code Graph 斩断 Token 膨胀与遗忘陷阱 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 9 | `ai` | `using-chinese-llm-apis-from-malaysia` | 从马来西亚使用中国大模型 API:真正可行的方法 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 | +| 10 | `ai` | `why-i-still-bought-a-local-gpu` | Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI | ⏳ 待审阅 | Batch-01 | - | | +| 11 | `ai` | `yue2-on-a-12gb-gpu-songs-that-lost-their-endings` | 为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2 | ⏳ 待审阅 | Batch-01 | - | | +| 12 | `case-studies` | `ai-furniture-compositing-with-flux-kontext` | 我如何用 FLUX.1 Kontext 打造 AI 家具合成演示 | ⏳ 待审阅 | Batch-01 | - | | +| 13 | `case-studies` | `from-audio-to-a-4-minute-ai-music-video` | 从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操 | ⏳ 待审阅 | Batch-01 | - | | +| 14 | `case-studies` | `how-i-built-an-automated-music-video-pipeline` | 我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的 | ⏳ 待审阅 | Batch-01 | - | | +| 15 | `case-studies` | `how-i-built-the-digikedai-telegram-bot` | 我是如何构建 DigiKedai Telegram AI 客服机器人的 | ⏳ 待审阅 | Batch-01 | - | | +| 16 | `case-studies` | `how-i-host-this-blog` | 我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD | ⏳ 待审阅 | Batch-01 | - | | +| 17 | `case-studies` | `how-i-made-my-own-songs-with-suno-ai` | 我用 Suno AI 做出自己的歌 | ⏳ 待审阅 | Batch-01 | - | | +| 18 | `case-studies` | `how-i-vetted-20-vps-providers-with-parallel-subagents` | 如何用并行子代理在三小时内核查 20 家 VPS 服务商 | ⏳ 待审阅 | Batch-02 | - | | +| 19 | `case-studies` | `loop-engineering-without-a-coding-agent` | 没有 Coding Agent 的 Loop Engineering:撑起我这套基础设施的 11 个定时任务 | ⏳ 待审阅 | Batch-02 | - | | +| 20 | `case-studies` | `one-prometheus-for-unraid-synology-and-a-vps` | 用一个 Prometheus 监控 unRaid、群晖 NAS 和 VPS:我做错的四件事 | ⏳ 待审阅 | Batch-02 | - | | +| 21 | `case-studies` | `running-production-infrastructure-solo` | 一个人跑生产基础设施:三台主机、162 个容器 | ⏳ 待审阅 | Batch-02 | - | | +| 22 | `case-studies` | `self-healing-digital-goods-entitlements` | 数字商品的自愈式访问控制:NocoDB、n8n 与 AList | ⏳ 待审阅 | Batch-02 | - | | +| 23 | `case-studies` | `self-hosted-speech-to-text-api` | 自建语音转文字服务:打字速度的 3 倍以上,而且数据不出内网 | ⏳ 待审阅 | Batch-02 | - | | +| 24 | `case-studies` | `self-hosted-tts-cosyvoice2-n8n-gpu` | 从云端转本地:在 12GB 显卡与群晖 N8N 上搭建中英混读高保真 TTS 语音栈 | ⏳ 待审阅 | Batch-02 | - | | +| 25 | `case-studies` | `self-hosting-mem0-memory-stack` | 我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆) | ⏳ 待审阅 | Batch-02 | - | | +| 26 | `case-studies` | `shipping-an-ai-photo-editor-as-a-wordpress-plugin` | 把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验 | ⏳ 待审阅 | Batch-02 | - | | +| 27 | `case-studies` | `syncing-a-self-improving-ai-agent-across-machines` | 在多台机器之间同步一个会自我学习的 AI Agent | ⏳ 待审阅 | Batch-02 | - | | +| 28 | `case-studies` | `the-single-writer-rule-for-coding-agents` | 代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构 | ⏳ 待审阅 | Batch-03 | - | | +| 29 | `case-studies` | `when-to-give-your-agent-physical-clicks` | 什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用) | ⏳ 待审阅 | Batch-03 | - | | +| 30 | `case-studies` | `why-i-built-a-video-knowledge-base` | 为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记 | ⏳ 待审阅 | Batch-03 | - | | +| 31 | `case-studies` | `why-your-headless-browser-cant-scrape-everything` | 为什么你的无头浏览器不是万能爬虫 | ⏳ 待审阅 | Batch-03 | - | | +| 32 | `devops` | `authentik-css-greater-than-bug` | 一个字符让我的 authentik CSS 静默失效 | ⏳ 待审阅 | Batch-03 | - | | +| 33 | `devops` | `authentik-forward-auth-gate-wasnt-live` | 验证完全正确的 Forward-Auth 网关——其实并未上线 | ⏳ 待审阅 | Batch-03 | - | | +| 34 | `devops` | `authentik-major-upgrade-gotchas` | Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分 | ⏳ 待审阅 | Batch-03 | - | | +| 35 | `devops` | `automating-cyberpanel-without-the-ui` | 不用界面管理 CyberPanel:逆向一个没有文档的 v2 API | ⏳ 待审阅 | Batch-03 | - | | +| 36 | `devops` | `docker-reported-exit-0-the-oom-killer-disagreed` | Docker 报告退出码 0,内核却早已执行 OOM-Kill | ⏳ 待审阅 | Batch-03 | - | | +| 37 | `devops` | `every-player-stuttered-the-file-was-fine` | 每个播放器都在同一个视频上卡顿,而文件本身是无辜的 | ⏳ 待审阅 | Batch-03 | - | | +| 38 | `devops` | `hardening-a-tor-onion-service` | 加固 Tor 洋葱服务:真正重要的是什么 | ⏳ 待审阅 | Batch-03 | - | | +| 39 | `devops` | `it-reported-success-nothing-had-changed` | 它报告了成功,可实际上什么都没变 | ⏳ 待审阅 | Batch-04 | - | | +| 40 | `devops` | `n8n-v1-to-v2-upgrade-gotchas` | n8n v1 升级到 v2:一个日志文件里的七项废弃警告 | ⏳ 待审阅 | Batch-04 | - | | +| 41 | `devops` | `one-hostname-public-tracker-sso-dashboard` | 公开的埋点与要 SSO 的仪表盘,怎么共用一个域名 | ⏳ 待审阅 | Batch-04 | - | | +| 42 | `devops` | `passbolt-hang-three-failure-modes` | Passbolt 界面反复卡死——一个缺失的配置文件引发的三种故障模式 | ⏳ 待审阅 | Batch-04 | - | | +| 43 | `devops` | `patching-workbench-26-for-mariadb` | 当数据库客户端对你撒谎:为 MariaDB 修补 Workbench 26 | ⏳ 待审阅 | Batch-04 | - | | +| 44 | `devops` | `php2shell-patching-every-php-runtime-i-manage` | PHP2Shell:我管的每台机器,PHP 运行时全都中了 | ⏳ 待审阅 | Batch-04 | - | | +| 45 | `devops` | `putting-a-front-door-on-an-app-you-cant-modify` | 给自己改不了源码的应用装一扇前门 | ⏳ 待审阅 | Batch-04 | - | | +| 46 | `devops` | `read-only-nocodb-dashboard-for-a-remote-database` | 给另一台机器上的数据库配一个 NocoDB 只读看板 | ⏳ 待审阅 | Batch-04 | - | | +| 47 | `devops` | `replacing-rdpguard-with-ipban` | 用 IPBan 替换 RDPGuard:没人写出来的三个坑 | ⏳ 待审阅 | Batch-04 | - | | +| 48 | `devops` | `space-reclamation-wasnt-stuck-it-was-waiting` | 空间回收没卡住,它只是在等明天早上 09:29 | ⏳ 待审阅 | Batch-05 | - | | +| 49 | `devops` | `synology-spreadsheet-api-is-a-container` | Synology 电子表格 API 是一个容器,不是一个 API 端点 | ⏳ 待审阅 | Batch-05 | - | | +| 50 | `devops` | `that-dying-ssd-was-just-a-bad-sata-cable` | 我差点把这颗 SSD 送修——真正坏的是 SATA 数据线 | ⏳ 待审阅 | Batch-05 | - | | +| 51 | `devops` | `the-cause-was-trim-not-the-ssds` | 换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,不是 SSD | ⏳ 待审阅 | Batch-05 | - | | +| 52 | `devops` | `the-upstream-was-deleted-then-came-back-rewritten` | 上游被删了,然后带着一个重写版回来 | ⏳ 待审阅 | Batch-05 | - | | +| 53 | `devops` | `unraid-stop-array-hangs-on-swapfile` | Unraid 停止阵列时卡死的真相:Swapfile 锁住挂载点 — 附修复方案 | ⏳ 待审阅 | Batch-05 | - | | +| 54 | `devops` | `uploading-large-videos-to-youtube-via-cdp` | 用 CDP 自动化上传百兆视频到 YouTube:跨越 DevTools 内存墙的 Docker 归档法 | ⏳ 待审阅 | Batch-05 | - | | +| 55 | `devops` | `vetting-an-open-source-dependency-before-you-bet-on-it` | 如何在押注前验证一个开源依赖:我用的六项检查 | ⏳ 待审阅 | Batch-05 | - | | +| 56 | `devops` | `when-smart-says-healthy-but-your-raid-is-corrupting-data` | SMART 显示健康,RAID 却在悄悄损坏数据 | ⏳ 待审阅 | Batch-05 | - | | +| 57 | `devops` | `why-chrome-forgets-its-tabs-in-a-container` | 为什么容器里的 Chrome 会忘记标签页 —— 以及我是怎么修好的 | ⏳ 待审阅 | Batch-05 | - | | +| 58 | `devops` | `why-telegram-bot-notifications-die` | Telegram 机器人通知为什么发不出去:IPv6、DNS 和一个折腾了一整天的 400 | ⏳ 待审阅 | Batch-05 | - | | +| 59 | `devops` | `why-your-cdp-clicks-silently-fail` | 为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件 | ⏳ 待审阅 | Batch-06 | - | | +| 60 | `devops` | `why-your-grafana-dashboard-shows-no-data` | Prometheus 明明有数据,Grafana 面板却全是 No data 的原因 | ⏳ 待审阅 | Batch-06 | - | | +| 61 | `devops` | `your-disk-full-alert-is-lying` | 你的“磁盘将满”告警在说谎:别再按百分比告警 | ⏳ 待审阅 | Batch-06 | - | | +| 62 | `engineering` | `adding-english-mode-to-a-chinese-only-web-app` | 浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式 | ⏳ 待审阅 | Batch-06 | - | | +| 63 | `engineering` | `debugging-remotion-video-jitter` | 排查 Remotion 视频抽搐:为什么在背景图挂载音频低频 Scale 会毁了转场 | ⏳ 待审阅 | Batch-06 | - | | +| 64 | `engineering` | `embedding-fonts-so-a-pdf-looks-the-same-everywhere` | 把字体嵌进 PDF,让它在任何机器上都长一样 | ⏳ 待审阅 | Batch-06 | - | | +| 65 | `engineering` | `jpa-version-field-lost-update` | 那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁 | ⏳ 待审阅 | Batch-06 | - | | +| 66 | `engineering` | `migrating-codeigniter-iis-to-openlitespeed` | 把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed | ⏳ 待审阅 | Batch-06 | - | | +| 67 | `engineering` | `scraping-bot-walled-marketplace-warm-browser-session` | 用「热浏览器会话」爬取有反爬墙的电商网站 | ⏳ 待审阅 | Batch-06 | - | | +| 68 | `engineering` | `two-ways-a-javascript-file-stops-working-and-looks-fine` | JavaScript 文件静默失效的两种方式:而页面看上去一切正常 | ⏳ 待审阅 | Batch-06 | - | | +| 69 | `engineering` | `verifying-a-pdf-report-page-by-page` | 逐页验证一份 19 页的 PDF 报告 | ⏳ 待审阅 | Batch-06 | - | | +| 70 | `engineering` | `why-500000-lines-of-commit-churn-only-left-230000-in-production` | 30 天提交 51.8 万行,生产环境只留下 23.5 万行 | ⏳ 待审阅 | Batch-07 | - | | +| 71 | `notes` | `adding-a-domain-to-someone-elses-mail-server` | 给别人家的邮件服务器加一个域名 | ⏳ 待审阅 | Batch-07 | - | | +| 72 | `notes` | `hello-world` | 你好,世界 —— 关于这个博客 | ⏳ 待审阅 | Batch-07 | - | | +| 73 | `notes` | `my-backup-was-using-someone-elses-api-quota` | 我的备份爬得比爬虫还慢,因为我在用别人的 API 配额 | ⏳ 待审阅 | Batch-07 | - | | +| 74 | `notes` | `nocodb-sso-is-a-licensed-feature` | NocoDB SSO 是授权功能:环境变量不会告诉你的事 | ⏳ 待审阅 | Batch-07 | - | | +| 75 | `notes` | `reading-a-containers-own-api-docs` | 与其看供应商文档,不如直接读容器自带的 API 文档 | ⏳ 待审阅 | Batch-07 | - | | +| 76 | `notes` | `smartctl-exit-code-32-skips-the-disks-that-matter` | smartctl 退出码 32:专门跳过你最该看的硬盘的那个“错误” | ⏳ 待审阅 | Batch-07 | - | | +| 77 | `notes` | `stop-writing-agent-prompts-for-deterministic-work` | 确定性的活,别再用 Agent Prompt 去跑 | ⏳ 待审阅 | Batch-07 | - | | +| 78 | `notes` | `synology-api-401-with-the-correct-password` | 密码正确,Synology API 登录却返回 401,问题出在哪 | ⏳ 待审阅 | Batch-07 | - | | +| 79 | `notes` | `the-nocodb-attachment-that-wouldnt-update` | NocoDB 附件字段怎么改都不生效,和一个漏网的正则 | ⏳ 待审阅 | Batch-07 | - | | +| 80 | `notes` | `upgrading-codeigniter-46-to-47` | CodeIgniter 4.6 升级到 4.7:官方指南没说到的破坏性变更 | ⏳ 待审阅 | Batch-07 | - | | +| 81 | `notes` | `why-is-my-tts-model-repeating-percent` | 为什么本地语音合成一直在念 “Percent”?语音管线里的 URL 编码与沙盒避坑记 | ⏳ 待审阅 | Batch-07 | - | | +| 82 | `notes` | `why-multi-agent-coding-fails` | 为什么多个 AI 一起写代码总会烂尾?传话失真与隐式决策困境 | ⏳ 待审阅 | Batch-07 | - | | +| 83 | `notes` | `why-your-windows-mcp-calls-silently-fail` | 为什么你的 Windows 桌面 MCP 调用会静默失败:攻破 10 秒空闲门禁与避坑实录 | ⏳ 待审阅 | Batch-07 | - | | +| 84 | `tutorials` | `calibrating-intel-quick-sync-hevc-quality` | 校准 Intel Quick Sync HEVC:28 与 30 之间的悬崖,和一个毫无作用的 preset | ⏳ 待审阅 | Batch-07 | - | | +| 85 | `tutorials` | `does-hevc-actually-shrink-your-files` | 转成 HEVC 到底会不会让文件变小?我实测了 | ⏳ 待审阅 | Batch-07 | - | | +| 86 | `tutorials` | `generating-full-length-songs-with-yue2-comfyui` | 用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断 | ⏳ 待审阅 | Batch-09 | - | | +| 87 | `tutorials` | `generating-subtitles-for-songs-without-timestamps` | 给没有时间戳的歌曲自动生成字幕 | ⏳ 待审阅 | Batch-10 | - | | +| 88 | `tutorials` | `how-to-verify-a-hosting-provider-before-you-buy` | 购买主机前,如何核实一家服务商的真实情况 | ⏳ 待审阅 | Batch-10 | - | | +| 89 | `web3` | `chainlink-vrf-v2-lottery-contract` | 一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约 | ⏳ 待审阅 | Batch-10 | - | | +| 90 | `web3` | `fully-on-chain-svg-nfts` | 全链上 SVG NFT:把艺术作品放进合约里 | ⏳ 待审阅 | Batch-10 | - | | +| 91 | `web3` | `why-my-on-chain-nft-art-changed-on-windows` | 为什么在 Windows 上克隆后,我的链上 NFT 图像变了 | ⏳ 待审阅 | Batch-10 | - | | diff --git a/docs/consultant-review/audit-tracker.json b/docs/consultant-review/audit-tracker.json new file mode 100644 index 0000000..3a6ddd0 --- /dev/null +++ b/docs/consultant-review/audit-tracker.json @@ -0,0 +1,1185 @@ +{ + "adding-a-domain-to-someone-elses-mail-server": { + "slug": "adding-a-domain-to-someone-elses-mail-server", + "title": "Adding a Domain to Someone Else's Mail Server", + "zh_title": "给别人家的邮件服务器加一个域名", + "category": "notes", + "pub_date": "2025-05-06", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 7375, + "zh_chars": 3249 + }, + "adding-english-mode-to-a-chinese-only-web-app": { + "slug": "adding-english-mode-to-a-chinese-only-web-app", + "title": "No API for Browser Translation: Adding an English Mode to a Chinese-Only Web App", + "zh_title": "浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式", + "category": "engineering", + "pub_date": "2025-06-11", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 11859, + "zh_chars": 6211 + }, + "ai-furniture-compositing-with-flux-kontext": { + "slug": "ai-furniture-compositing-with-flux-kontext", + "title": "How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext", + "zh_title": "我如何用 FLUX.1 Kontext 打造 AI 家具合成演示", + "category": "case-studies", + "pub_date": "2026-09-11", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 12512, + "zh_chars": 7237 + }, + "authentik-css-greater-than-bug": { + "slug": "authentik-css-greater-than-bug", + "title": "The Character That Silently Broke My authentik CSS", + "zh_title": "一个字符让我的 authentik CSS 静默失效", + "category": "devops", + "pub_date": "2026-09-20", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 9206, + "zh_chars": 5485 + }, + "authentik-forward-auth-gate-wasnt-live": { + "slug": "authentik-forward-auth-gate-wasnt-live", + "title": "The Forward-Auth Gate That Verified Perfectly — and Wasn't Live", + "zh_title": "验证完全正确的 Forward-Auth 网关——其实并未上线", + "category": "devops", + "pub_date": "2026-08-12", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 16519, + "zh_chars": 8735 + }, + "authentik-major-upgrade-gotchas": { + "slug": "authentik-major-upgrade-gotchas", + "title": "Authentik 2025.8 to 2026.8: The Breakable Parts Nobody Warns You About", + "zh_title": "Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分", + "category": "devops", + "pub_date": "2026-09-09", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 11639, + "zh_chars": 6086 + }, + "automating-cyberpanel-without-the-ui": { + "slug": "automating-cyberpanel-without-the-ui", + "title": "Automating CyberPanel Without the UI: Reverse-Engineering an Undocumented v2 API", + "zh_title": "不用界面管理 CyberPanel:逆向一个没有文档的 v2 API", + "category": "devops", + "pub_date": "2026-09-09", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 8495, + "zh_chars": 5223 + }, + "best-ai-video-generators-2026": { + "slug": "best-ai-video-generators-2026", + "title": "Best AI Video Generators in 2026: Free vs Paid, and My LTX-2.3 Test", + "zh_title": "2026 年最佳 AI 视频生成器:免费与付费对比,以及我的 LTX-2.3 实测", + "category": "ai", + "pub_date": "2026-09-11", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 24245, + "zh_chars": 12700 + }, + "calibrating-intel-quick-sync-hevc-quality": { + "slug": "calibrating-intel-quick-sync-hevc-quality", + "title": "Calibrating Intel Quick Sync HEVC: A Cliff at 28, and a Preset That Does Nothing", + "zh_title": "校准 Intel Quick Sync HEVC:28 与 30 之间的悬崖,和一个毫无作用的 preset", + "category": "tutorials", + "pub_date": "2026-10-14", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 10490, + "zh_chars": 5631 + }, + "chainlink-vrf-v2-lottery-contract": { + "slug": "chainlink-vrf-v2-lottery-contract", + "title": "An Automated Lottery Contract on Chainlink VRF v2.5 and Automation", + "zh_title": "一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约", + "category": "web3", + "pub_date": "2024-12-10", + "status": "pending", + "batch_id": "Batch-10", + "approved_at": null, + "notes": "", + "en_chars": 15801, + "zh_chars": 9443 + }, + "debugging-remotion-video-jitter": { + "slug": "debugging-remotion-video-jitter", + "title": "Debugging Remotion Video Jitter: Why Bass Scaling Ruins Background Transitions", + "zh_title": "排查 Remotion 视频抽搐:为什么在背景图挂载音频低频 Scale 会毁了转场", + "category": "engineering", + "pub_date": "2025-11-26", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 9584, + "zh_chars": 5527 + }, + "docker-reported-exit-0-the-oom-killer-disagreed": { + "slug": "docker-reported-exit-0-the-oom-killer-disagreed", + "title": "Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.", + "zh_title": "Docker 报告退出码 0,内核却早已执行 OOM-Kill", + "category": "devops", + "pub_date": "2026-10-16", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 10183, + "zh_chars": 5483 + }, + "does-hevc-actually-shrink-your-files": { + "slug": "does-hevc-actually-shrink-your-files", + "title": "Does Re-encoding to HEVC Actually Shrink Your Files? I Measured It.", + "zh_title": "转成 HEVC 到底会不会让文件变小?我实测了", + "category": "tutorials", + "pub_date": "2026-10-08", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 7417, + "zh_chars": 3695 + }, + "embedding-fonts-so-a-pdf-looks-the-same-everywhere": { + "slug": "embedding-fonts-so-a-pdf-looks-the-same-everywhere", + "title": "Embedding Fonts So a PDF Looks the Same on Every Machine", + "zh_title": "把字体嵌进 PDF,让它在任何机器上都长一样", + "category": "engineering", + "pub_date": "2025-01-22", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 8033, + "zh_chars": 3432 + }, + "every-player-stuttered-the-file-was-fine": { + "slug": "every-player-stuttered-the-file-was-fine", + "title": "Every Player Stuttered on the Same Video. The File Was Blameless.", + "zh_title": "每个播放器都在同一个视频上卡顿,而文件本身是无辜的", + "category": "devops", + "pub_date": "2026-10-08", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 7884, + "zh_chars": 3921 + }, + "five-step-ai-roadmap-what-i-actually-run": { + "slug": "five-step-ai-roadmap-what-i-actually-run", + "title": "The Five-Step AI Roadmap Everyone Shares — and What I Actually Run in Production", + "zh_title": "全网都在转的 AI 五步学习路线 —— 我真正在生产里跑的是哪几步", + "category": "ai", + "pub_date": "2026-10-13", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 13155, + "zh_chars": 5483 + }, + "from-audio-to-a-4-minute-ai-music-video": { + "slug": "from-audio-to-a-4-minute-ai-music-video", + "title": "From Audio to a 4-Minute Music Video: Orchestrating Gemini, AI Diffusion, and Filmora Editing", + "zh_title": "从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操", + "category": "case-studies", + "pub_date": "2026-10-25", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 11027, + "zh_chars": 5168 + }, + "fully-on-chain-svg-nfts": { + "slug": "fully-on-chain-svg-nfts", + "title": "Fully On-Chain SVG NFTs: Putting the Artwork Inside the Contract", + "zh_title": "全链上 SVG NFT:把艺术作品放进合约里", + "category": "web3", + "pub_date": "2024-10-08", + "status": "pending", + "batch_id": "Batch-10", + "approved_at": null, + "notes": "", + "en_chars": 14598, + "zh_chars": 9407 + }, + "generating-full-length-songs-with-yue2-comfyui": { + "slug": "generating-full-length-songs-with-yue2-comfyui", + "title": "Generating Full-Length Songs with YuE2 BF16: Overcoming Duration Limits and Audio Truncation", + "zh_title": "用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断", + "category": "tutorials", + "pub_date": "2026-10-26", + "status": "pending", + "batch_id": "Batch-09", + "approved_at": null, + "notes": "", + "en_chars": 131667, + "zh_chars": 128681 + }, + "generating-subtitles-for-songs-without-timestamps": { + "slug": "generating-subtitles-for-songs-without-timestamps", + "title": "Generating Subtitles for Songs That Ship Without Timestamps", + "zh_title": "给没有时间戳的歌曲自动生成字幕", + "category": "tutorials", + "pub_date": "2026-10-20", + "status": "pending", + "batch_id": "Batch-10", + "approved_at": null, + "notes": "", + "en_chars": 46399, + "zh_chars": 42221 + }, + "giving-ai-your-mouse-and-keyboard-safely": { + "slug": "giving-ai-your-mouse-and-keyboard-safely", + "title": "Giving AI Your Mouse and Keyboard: Safe Guardrails for Windows Desktop MCP", + "zh_title": "把鼠标和键盘交给 AI 之前:Windows 桌面 MCP 的安全边界与实战防线", + "category": "ai", + "pub_date": "2025-10-22", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 5906, + "zh_chars": 3149 + }, + "hardening-a-tor-onion-service": { + "slug": "hardening-a-tor-onion-service", + "title": "Hardening a Tor Onion Service: What Actually Matters", + "zh_title": "加固 Tor 洋葱服务:真正重要的是什么", + "category": "devops", + "pub_date": "2025-12-10", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 14882, + "zh_chars": 7031 + }, + "hello-world": { + "slug": "hello-world", + "title": "Hello, World — About This Blog", + "zh_title": "你好,世界 —— 关于这个博客", + "category": "notes", + "pub_date": "2024-09-24", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 1794, + "zh_chars": 955 + }, + "how-i-built-an-automated-music-video-pipeline": { + "slug": "how-i-built-an-automated-music-video-pipeline", + "title": "How I Built an Automated Music Video Pipeline with Remotion and Dockerized YouTube CDP", + "zh_title": "我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的", + "category": "case-studies", + "pub_date": "2025-04-18", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 9265, + "zh_chars": 4931 + }, + "how-i-built-the-digikedai-telegram-bot": { + "slug": "how-i-built-the-digikedai-telegram-bot", + "title": "How I Built the DigiKedai Telegram AI Bot", + "zh_title": "我是如何构建 DigiKedai Telegram AI 客服机器人的", + "category": "case-studies", + "pub_date": "2026-09-06", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 10719, + "zh_chars": 5271 + }, + "how-i-host-this-blog": { + "slug": "how-i-host-this-blog", + "title": "How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD", + "zh_title": "我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD", + "category": "case-studies", + "pub_date": "2026-09-04", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 5908, + "zh_chars": 4145 + }, + "how-i-made-my-own-songs-with-suno-ai": { + "slug": "how-i-made-my-own-songs-with-suno-ai", + "title": "How I Made My Own Songs with Suno AI", + "zh_title": "我用 Suno AI 做出自己的歌", + "category": "case-studies", + "pub_date": "2026-09-11", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 16935, + "zh_chars": 11737 + }, + "how-i-vetted-20-vps-providers-with-parallel-subagents": { + "slug": "how-i-vetted-20-vps-providers-with-parallel-subagents", + "title": "How I Vetted 20 VPS Providers in Three Hours with Parallel Subagents", + "zh_title": "如何用并行子代理在三小时内核查 20 家 VPS 服务商", + "category": "case-studies", + "pub_date": "2025-11-12", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 8593, + "zh_chars": 3613 + }, + "how-to-verify-a-hosting-provider-before-you-buy": { + "slug": "how-to-verify-a-hosting-provider-before-you-buy", + "title": "How to Verify a Hosting Provider Before You Buy", + "zh_title": "购买主机前,如何核实一家服务商的真实情况", + "category": "tutorials", + "pub_date": "2025-11-26", + "status": "pending", + "batch_id": "Batch-10", + "approved_at": null, + "notes": "", + "en_chars": 6743, + "zh_chars": 2906 + }, + "i-let-an-agent-manage-my-shopee-vouchers": { + "slug": "i-let-an-agent-manage-my-shopee-vouchers", + "title": "I Let an Agent Manage My Shopee Vouchers — And It Created One I Never Approved", + "zh_title": "我让 AI 助手管我的 Shopee 优惠券 —— 它建了一张我没批准的券", + "category": "ai", + "pub_date": "2025-07-08", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 9981, + "zh_chars": 4824 + }, + "it-reported-success-nothing-had-changed": { + "slug": "it-reported-success-nothing-had-changed", + "title": "It Reported Success. Nothing Had Changed.", + "zh_title": "它报告了成功,可实际上什么都没变", + "category": "devops", + "pub_date": "2026-10-08", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 7654, + "zh_chars": 3480 + }, + "jpa-version-field-lost-update": { + "slug": "jpa-version-field-lost-update", + "title": "The JPA Field That Turns a Silent Lost Update Into a 409", + "zh_title": "那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁", + "category": "engineering", + "pub_date": "2026-08-19", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 12217, + "zh_chars": 7358 + }, + "loop-engineering-without-a-coding-agent": { + "slug": "loop-engineering-without-a-coding-agent", + "title": "Loop Engineering Without a Coding Agent: The 11 Cron Jobs That Run My Infrastructure", + "zh_title": "没有 Coding Agent 的 Loop Engineering:撑起我这套基础设施的 11 个定时任务", + "category": "case-studies", + "pub_date": "2026-10-01", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 14249, + "zh_chars": 6609 + }, + "migrating-codeigniter-iis-to-openlitespeed": { + "slug": "migrating-codeigniter-iis-to-openlitespeed", + "title": "Migrating a CodeIgniter 4 App From IIS to OpenLiteSpeed", + "zh_title": "把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed", + "category": "engineering", + "pub_date": "2026-09-27", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 14943, + "zh_chars": 8418 + }, + "my-backup-was-using-someone-elses-api-quota": { + "slug": "my-backup-was-using-someone-elses-api-quota", + "title": "My Backup Was Crawling Because I Was Using Someone Else's API Quota", + "zh_title": "我的备份爬得比爬虫还慢,因为我在用别人的 API 配额", + "category": "notes", + "pub_date": "2026-10-15", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 8078, + "zh_chars": 4209 + }, + "n8n-v1-to-v2-upgrade-gotchas": { + "slug": "n8n-v1-to-v2-upgrade-gotchas", + "title": "Upgrading n8n v1 to v2: Seven Deprecations in One Log File", + "zh_title": "n8n v1 升级到 v2:一个日志文件里的七项废弃警告", + "category": "devops", + "pub_date": "2026-09-18", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 11892, + "zh_chars": 6540 + }, + "nocodb-sso-is-a-licensed-feature": { + "slug": "nocodb-sso-is-a-licensed-feature", + "title": "NocoDB SSO Is a Licensed Feature — and the Env Vars Won't Tell You", + "zh_title": "NocoDB SSO 是授权功能:环境变量不会告诉你的事", + "category": "notes", + "pub_date": "2026-09-29", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 9934, + "zh_chars": 5418 + }, + "one-hostname-public-tracker-sso-dashboard": { + "slug": "one-hostname-public-tracker-sso-dashboard", + "title": "One Hostname for a Public Tracker and an SSO-Gated Dashboard", + "zh_title": "公开的埋点与要 SSO 的仪表盘,怎么共用一个域名", + "category": "devops", + "pub_date": "2026-09-30", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 13998, + "zh_chars": 6677 + }, + "one-prometheus-for-unraid-synology-and-a-vps": { + "slug": "one-prometheus-for-unraid-synology-and-a-vps", + "title": "One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong", + "zh_title": "用一个 Prometheus 监控 unRaid、群晖 NAS 和 VPS:我做错的四件事", + "category": "case-studies", + "pub_date": "2026-07-29", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 8358, + "zh_chars": 4690 + }, + "passbolt-hang-three-failure-modes": { + "slug": "passbolt-hang-three-failure-modes", + "title": "Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File", + "zh_title": "Passbolt 界面反复卡死——一个缺失的配置文件引发的三种故障模式", + "category": "devops", + "pub_date": "2026-01-07", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 7740, + "zh_chars": 4063 + }, + "patching-workbench-26-for-mariadb": { + "slug": "patching-workbench-26-for-mariadb", + "title": "When Your Database Client Lies to You: Patching Workbench 26 for MariaDB", + "zh_title": "当数据库客户端对你撒谎:为 MariaDB 修补 Workbench 26", + "category": "devops", + "pub_date": "2026-09-19", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 15104, + "zh_chars": 9020 + }, + "php2shell-patching-every-php-runtime-i-manage": { + "slug": "php2shell-patching-every-php-runtime-i-manage", + "title": "PHP2Shell: I Measured Every PHP Runtime I Manage. All of Them Were Vulnerable.", + "zh_title": "PHP2Shell:我管的每台机器,PHP 运行时全都中了", + "category": "devops", + "pub_date": "2026-10-23", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 22273, + "zh_chars": 12465 + }, + "plugging-windows-desktop-control-into-four-ai-agents": { + "slug": "plugging-windows-desktop-control-into-four-ai-agents", + "title": "Plugging Windows Desktop Control into Four AI Agents: Hermes, OpenCode, Claw, and Claude", + "zh_title": "把 Windows 桌面控制接进 4 款 AI Agent:Hermes、OpenCode、Claw 与 Claude 的实战架构", + "category": "ai", + "pub_date": "2025-12-23", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 6554, + "zh_chars": 5030 + }, + "putting-a-front-door-on-an-app-you-cant-modify": { + "slug": "putting-a-front-door-on-an-app-you-cant-modify", + "title": "Putting a Front Door on an App You Can't Modify", + "zh_title": "给自己改不了源码的应用装一扇前门", + "category": "devops", + "pub_date": "2025-06-24", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 13314, + "zh_chars": 6978 + }, + "read-only-nocodb-dashboard-for-a-remote-database": { + "slug": "read-only-nocodb-dashboard-for-a-remote-database", + "title": "A Read-Only NocoDB Dashboard for a Database on Another Machine", + "zh_title": "给另一台机器上的数据库配一个 NocoDB 只读看板", + "category": "devops", + "pub_date": "2026-09-29", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 11723, + "zh_chars": 6166 + }, + "reading-a-containers-own-api-docs": { + "slug": "reading-a-containers-own-api-docs", + "title": "Read a Container's Own API Docs Instead of the Vendor's", + "zh_title": "与其看供应商文档,不如直接读容器自带的 API 文档", + "category": "notes", + "pub_date": "2025-10-01", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 8382, + "zh_chars": 5483 + }, + "replacing-rdpguard-with-ipban": { + "slug": "replacing-rdpguard-with-ipban", + "title": "Replacing RDPGuard With IPBan: The Traps Nobody Documents", + "zh_title": "用 IPBan 替换 RDPGuard:没人写出来的三个坑", + "category": "devops", + "pub_date": "2026-09-19", + "status": "pending", + "batch_id": "Batch-04", + "approved_at": null, + "notes": "", + "en_chars": 12784, + "zh_chars": 7641 + }, + "running-ai-video-locally-on-a-12gb-gpu": { + "slug": "running-ai-video-locally-on-a-12gb-gpu", + "title": "Running AI Video Locally on a 12 GB GPU: Real Numbers for Wan 2.2 and SeedVR2", + "zh_title": "12GB 显卡跑本地 AI 视频:Wan 2.2 加 SeedVR2 的真实数据", + "category": "ai", + "pub_date": "2026-10-20", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 18508, + "zh_chars": 12878 + }, + "running-production-infrastructure-solo": { + "slug": "running-production-infrastructure-solo", + "title": "Running Production Infrastructure Solo: 162 Containers Across Three Hosts", + "zh_title": "一个人跑生产基础设施:三台主机、162 个容器", + "category": "case-studies", + "pub_date": "2026-10-06", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 7975, + "zh_chars": 3722 + }, + "running-tts-as-a-service-with-token-sidecars": { + "slug": "running-tts-as-a-service-with-token-sidecars", + "title": "I Ran a Text-to-Speech Service for a Year on Two Cron Containers", + "zh_title": "我用两个 Cron 容器跑了一年的语音合成服务", + "category": "ai", + "pub_date": "2026-09-18", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 14141, + "zh_chars": 7929 + }, + "scraping-bot-walled-marketplace-warm-browser-session": { + "slug": "scraping-bot-walled-marketplace-warm-browser-session", + "title": "Scraping a Bot-Walled Marketplace With a Warm Browser Session", + "zh_title": "用「热浏览器会话」爬取有反爬墙的电商网站", + "category": "engineering", + "pub_date": "2026-09-13", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 11245, + "zh_chars": 5683 + }, + "self-healing-digital-goods-entitlements": { + "slug": "self-healing-digital-goods-entitlements", + "title": "Self-Healing Access Control for Digital Goods: NocoDB, n8n, and AList", + "zh_title": "数字商品的自愈式访问控制:NocoDB、n8n 与 AList", + "category": "case-studies", + "pub_date": "2026-09-18", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 14765, + "zh_chars": 6819 + }, + "self-hosted-speech-to-text-api": { + "slug": "self-hosted-speech-to-text-api", + "title": "Your Own Speech-to-Text Server: Faster Than Typing, Private by Default", + "zh_title": "自建语音转文字服务:打字速度的 3 倍以上,而且数据不出内网", + "category": "case-studies", + "pub_date": "2026-09-19", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 14708, + "zh_chars": 7794 + }, + "self-hosted-tts-cosyvoice2-n8n-gpu": { + "slug": "self-hosted-tts-cosyvoice2-n8n-gpu", + "title": "How I Replaced Cloud TTS with a Self-Hosted Neural Voice Stack on an RTX 3060", + "zh_title": "从云端转本地:在 12GB 显卡与群晖 N8N 上搭建中英混读高保真 TTS 语音栈", + "category": "case-studies", + "pub_date": "2026-10-22", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 8913, + "zh_chars": 4402 + }, + "self-hosting-mem0-memory-stack": { + "slug": "self-hosting-mem0-memory-stack", + "title": "How I Self-Hosted a Mem0 Memory Stack (So My AI Agent Remembers)", + "zh_title": "我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆)", + "category": "case-studies", + "pub_date": "2026-09-16", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 7894, + "zh_chars": 4708 + }, + "shipping-an-ai-photo-editor-as-a-wordpress-plugin": { + "slug": "shipping-an-ai-photo-editor-as-a-wordpress-plugin", + "title": "Shipping an AI Photo Editor as a WordPress Plugin: 22 Versions of Hard Lessons", + "zh_title": "把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验", + "category": "case-studies", + "pub_date": "2026-09-18", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 13782, + "zh_chars": 6796 + }, + "slashing-agent-context-with-a-code-graph": { + "slug": "slashing-agent-context-with-a-code-graph", + "title": "Slashing Agent Context and Token Churn With a Code Graph", + "zh_title": "别再把整个 Repo 喂给 Agent:用 Code Graph 斩断 Token 膨胀与遗忘陷阱", + "category": "ai", + "pub_date": "2026-04-28", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 8122, + "zh_chars": 5040 + }, + "smartctl-exit-code-32-skips-the-disks-that-matter": { + "slug": "smartctl-exit-code-32-skips-the-disks-that-matter", + "title": "smartctl Exit Code 32: The Code That Skips the Disks You Care About", + "zh_title": "smartctl 退出码 32:专门跳过你最该看的硬盘的那个“错误”", + "category": "notes", + "pub_date": "2026-04-14", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 5475, + "zh_chars": 2907 + }, + "space-reclamation-wasnt-stuck-it-was-waiting": { + "slug": "space-reclamation-wasnt-stuck-it-was-waiting", + "title": "Space Reclamation Wasn't Stuck. It Was Waiting for Tomorrow at 09:29.", + "zh_title": "空间回收没卡住,它只是在等明天早上 09:29", + "category": "devops", + "pub_date": "2026-10-14", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 5763, + "zh_chars": 3032 + }, + "stop-writing-agent-prompts-for-deterministic-work": { + "slug": "stop-writing-agent-prompts-for-deterministic-work", + "title": "Stop Writing Agent Prompts for Deterministic Work", + "zh_title": "确定性的活,别再用 Agent Prompt 去跑", + "category": "notes", + "pub_date": "2026-10-01", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 4951, + "zh_chars": 2216 + }, + "syncing-a-self-improving-ai-agent-across-machines": { + "slug": "syncing-a-self-improving-ai-agent-across-machines", + "title": "Syncing a Self-Improving AI Agent Across Machines", + "zh_title": "在多台机器之间同步一个会自我学习的 AI Agent", + "category": "case-studies", + "pub_date": "2026-02-18", + "status": "pending", + "batch_id": "Batch-02", + "approved_at": null, + "notes": "", + "en_chars": 8918, + "zh_chars": 4599 + }, + "synology-api-401-with-the-correct-password": { + "slug": "synology-api-401-with-the-correct-password", + "title": "Why Your Synology API Login Returns 401 With the Correct Password", + "zh_title": "密码正确,Synology API 登录却返回 401,问题出在哪", + "category": "notes", + "pub_date": "2025-09-10", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 5053, + "zh_chars": 3053 + }, + "synology-spreadsheet-api-is-a-container": { + "slug": "synology-spreadsheet-api-is-a-container", + "title": "The Synology Spreadsheet API Is a Container, Not an API Endpoint", + "zh_title": "Synology 电子表格 API 是一个容器,不是一个 API 端点", + "category": "devops", + "pub_date": "2025-08-20", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 15944, + "zh_chars": 9105 + }, + "that-dying-ssd-was-just-a-bad-sata-cable": { + "slug": "that-dying-ssd-was-just-a-bad-sata-cable", + "title": "I Was About to RMA This SSD — the Fault Was a SATA Cable", + "zh_title": "我差点把这颗 SSD 送修——真正坏的是 SATA 数据线", + "category": "devops", + "pub_date": "2026-09-16", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 6484, + "zh_chars": 3426 + }, + "the-cause-was-trim-not-the-ssds": { + "slug": "the-cause-was-trim-not-the-ssds", + "title": "The Corruption Came Back on Different Drives — the Cause Was TRIM, Not the SSDs", + "zh_title": "换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,不是 SSD", + "category": "devops", + "pub_date": "2026-09-18", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 11452, + "zh_chars": 6481 + }, + "the-nocodb-attachment-that-wouldnt-update": { + "slug": "the-nocodb-attachment-that-wouldnt-update", + "title": "The NocoDB Attachment That Wouldn't Update (and a Regex That Missed)", + "zh_title": "NocoDB 附件字段怎么改都不生效,和一个漏网的正则", + "category": "notes", + "pub_date": "2026-02-04", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 3570, + "zh_chars": 2156 + }, + "the-single-writer-rule-for-coding-agents": { + "slug": "the-single-writer-rule-for-coding-agents", + "title": "The Single-Writer Rule: Orchestrating Coding Agents Without Multi-Agent Chaos", + "zh_title": "代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构", + "category": "case-studies", + "pub_date": "2026-08-26", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 13268, + "zh_chars": 6916 + }, + "the-upstream-was-deleted-then-came-back-rewritten": { + "slug": "the-upstream-was-deleted-then-came-back-rewritten", + "title": "The Upstream Was Deleted, Then Came Back as a Rewrite", + "zh_title": "上游被删了,然后带着一个重写版回来", + "category": "devops", + "pub_date": "2026-10-06", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 11536, + "zh_chars": 5142 + }, + "two-ways-a-javascript-file-stops-working-and-looks-fine": { + "slug": "two-ways-a-javascript-file-stops-working-and-looks-fine", + "title": "Two Ways a JavaScript File Stops Working and Looks Fine", + "zh_title": "JavaScript 文件静默失效的两种方式:而页面看上去一切正常", + "category": "engineering", + "pub_date": "2025-02-20", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 7750, + "zh_chars": 3544 + }, + "unraid-stop-array-hangs-on-swapfile": { + "slug": "unraid-stop-array-hangs-on-swapfile", + "title": "Why Unraid Hangs When You Stop the Array With a Swapfile — and the Fix", + "zh_title": "Unraid 停止阵列时卡死的真相:Swapfile 锁住挂载点 — 附修复方案", + "category": "devops", + "pub_date": "2026-09-15", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 10067, + "zh_chars": 6117 + }, + "upgrading-codeigniter-46-to-47": { + "slug": "upgrading-codeigniter-46-to-47", + "title": "Upgrading CodeIgniter 4.6 to 4.7: The Breaking Changes the Guide Misses", + "zh_title": "CodeIgniter 4.6 升级到 4.7:官方指南没说到的破坏性变更", + "category": "notes", + "pub_date": "2026-09-27", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 9469, + "zh_chars": 5411 + }, + "uploading-large-videos-to-youtube-via-cdp": { + "slug": "uploading-large-videos-to-youtube-via-cdp", + "title": "Uploading 100MB+ Videos to YouTube via CDP: The Docker Archive Pattern", + "zh_title": "用 CDP 自动化上传百兆视频到 YouTube:跨越 DevTools 内存墙的 Docker 归档法", + "category": "devops", + "pub_date": "2025-08-22", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 10255, + "zh_chars": 6569 + }, + "using-chinese-llm-apis-from-malaysia": { + "slug": "using-chinese-llm-apis-from-malaysia", + "title": "Using Chinese LLM APIs From Malaysia: What Actually Works", + "zh_title": "从马来西亚使用中国大模型 API:真正可行的方法", + "category": "ai", + "pub_date": "2026-09-11", + "status": "approved", + "batch_id": "Batch-01", + "approved_at": "2026-10-27 03:00", + "notes": "外部顾问审阅完成,已采纳并落地全量修改意见", + "en_chars": 7540, + "zh_chars": 3536 + }, + "verifying-a-pdf-report-page-by-page": { + "slug": "verifying-a-pdf-report-page-by-page", + "title": "Verifying a 19-Page PDF Report Page by Page", + "zh_title": "逐页验证一份 19 页的 PDF 报告", + "category": "engineering", + "pub_date": "2026-09-28", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 12525, + "zh_chars": 6685 + }, + "vetting-an-open-source-dependency-before-you-bet-on-it": { + "slug": "vetting-an-open-source-dependency-before-you-bet-on-it", + "title": "How I Vet an Open-Source Dependency Before Betting On It", + "zh_title": "如何在押注前验证一个开源依赖:我用的六项检查", + "category": "devops", + "pub_date": "2026-09-29", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 8459, + "zh_chars": 4164 + }, + "when-smart-says-healthy-but-your-raid-is-corrupting-data": { + "slug": "when-smart-says-healthy-but-your-raid-is-corrupting-data", + "title": "When SMART Says Healthy and Your RAID Is Silently Corrupting Data", + "zh_title": "SMART 显示健康,RAID 却在悄悄损坏数据", + "category": "devops", + "pub_date": "2026-09-16", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 12365, + "zh_chars": 5343 + }, + "when-to-give-your-agent-physical-clicks": { + "slug": "when-to-give-your-agent-physical-clicks", + "title": "When to Give Your Agent Physical Clicks (And When to Never Touch Them)", + "zh_title": "什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用)", + "category": "case-studies", + "pub_date": "2026-05-28", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 6853, + "zh_chars": 3594 + }, + "why-500000-lines-of-commit-churn-only-left-230000-in-production": { + "slug": "why-500000-lines-of-commit-churn-only-left-230000-in-production", + "title": "I Committed 518,000 Lines in 30 Days. Production Kept 235,000.", + "zh_title": "30 天提交 51.8 万行,生产环境只留下 23.5 万行", + "category": "engineering", + "pub_date": "2026-10-18", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 8794, + "zh_chars": 4125 + }, + "why-chrome-forgets-its-tabs-in-a-container": { + "slug": "why-chrome-forgets-its-tabs-in-a-container", + "title": "Why Chrome Forgets Its Tabs in a Container — And How I Fixed It", + "zh_title": "为什么容器里的 Chrome 会忘记标签页 —— 以及我是怎么修好的", + "category": "devops", + "pub_date": "2026-09-29", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 14761, + "zh_chars": 8889 + }, + "why-i-built-a-video-knowledge-base": { + "slug": "why-i-built-a-video-knowledge-base", + "title": "Why I Built a Video Knowledge Base: Turning Saved Short Videos into Searchable Notes", + "zh_title": "为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记", + "category": "case-studies", + "pub_date": "2024-11-28", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 5728, + "zh_chars": 2903 + }, + "why-i-still-bought-a-local-gpu": { + "slug": "why-i-still-bought-a-local-gpu", + "title": "Why I Still Bought a GPU to Run AI When Claude and GPT Are Obviously Stronger", + "zh_title": "Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI", + "category": "ai", + "pub_date": "2026-09-21", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 11811, + "zh_chars": 4924 + }, + "why-is-my-tts-model-repeating-percent": { + "slug": "why-is-my-tts-model-repeating-percent", + "title": "Why Is My TTS Model Repeating \\\"Percent\\\"? The URL-Encoding Trap in Speech Pipelines", + "zh_title": "为什么本地语音合成一直在念 “Percent”?语音管线里的 URL 编码与沙盒避坑记", + "category": "notes", + "pub_date": "2026-10-22", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 7263, + "zh_chars": 4652 + }, + "why-multi-agent-coding-fails": { + "slug": "why-multi-agent-coding-fails", + "title": "Why Multi-Agent Coding Fails: The Telephone Game and Architectural Drift", + "zh_title": "为什么多个 AI 一起写代码总会烂尾?传话失真与隐式决策困境", + "category": "notes", + "pub_date": "2025-07-29", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 5704, + "zh_chars": 2936 + }, + "why-my-on-chain-nft-art-changed-on-windows": { + "slug": "why-my-on-chain-nft-art-changed-on-windows", + "title": "Why My On-Chain NFT Art Changed When I Cloned It on Windows", + "zh_title": "为什么在 Windows 上克隆后,我的链上 NFT 图像变了", + "category": "web3", + "pub_date": "2026-08-19", + "status": "pending", + "batch_id": "Batch-10", + "approved_at": null, + "notes": "", + "en_chars": 7725, + "zh_chars": 4260 + }, + "why-telegram-bot-notifications-die": { + "slug": "why-telegram-bot-notifications-die", + "title": "Why Telegram Bot Notifications Die: IPv6, DNS, and a 400 That Took a Day", + "zh_title": "Telegram 机器人通知为什么发不出去:IPv6、DNS 和一个折腾了一整天的 400", + "category": "devops", + "pub_date": "2026-03-25", + "status": "pending", + "batch_id": "Batch-05", + "approved_at": null, + "notes": "", + "en_chars": 8963, + "zh_chars": 5185 + }, + "why-your-cdp-clicks-silently-fail": { + "slug": "why-your-cdp-clicks-silently-fail", + "title": "Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events", + "zh_title": "为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件", + "category": "devops", + "pub_date": "2025-03-19", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 8010, + "zh_chars": 4504 + }, + "why-your-grafana-dashboard-shows-no-data": { + "slug": "why-your-grafana-dashboard-shows-no-data", + "title": "Why Your Grafana Dashboard Shows No Data (When Prometheus Is Fine)", + "zh_title": "Prometheus 明明有数据,Grafana 面板却全是 No data 的原因", + "category": "devops", + "pub_date": "2026-05-17", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 7347, + "zh_chars": 3865 + }, + "why-your-headless-browser-cant-scrape-everything": { + "slug": "why-your-headless-browser-cant-scrape-everything", + "title": "Why Your Headless Browser Can't Scrape Everything", + "zh_title": "为什么你的无头浏览器不是万能爬虫", + "category": "case-studies", + "pub_date": "2026-01-21", + "status": "pending", + "batch_id": "Batch-03", + "approved_at": null, + "notes": "", + "en_chars": 8065, + "zh_chars": 3791 + }, + "why-your-windows-mcp-calls-silently-fail": { + "slug": "why-your-windows-mcp-calls-silently-fail", + "title": "Why Your Windows Desktop MCP Calls Silently Fail: Taming the 10-Second Idle Gate", + "zh_title": "为什么你的 Windows 桌面 MCP 调用会静默失败:攻破 10 秒空闲门禁与避坑实录", + "category": "notes", + "pub_date": "2026-03-12", + "status": "pending", + "batch_id": "Batch-07", + "approved_at": null, + "notes": "", + "en_chars": 5024, + "zh_chars": 3435 + }, + "your-disk-full-alert-is-lying": { + "slug": "your-disk-full-alert-is-lying", + "title": "Your Disk-Full Alert Is Lying: Stop Alerting on Percentages", + "zh_title": "你的“磁盘将满”告警在说谎:别再按百分比告警", + "category": "devops", + "pub_date": "2026-06-24", + "status": "pending", + "batch_id": "Batch-06", + "approved_at": null, + "notes": "", + "en_chars": 5930, + "zh_chars": 2982 + }, + "yue2-on-a-12gb-gpu-songs-that-lost-their-endings": { + "slug": "yue2-on-a-12gb-gpu-songs-that-lost-their-endings", + "title": "Why My AI Songs Kept Losing Their Endings: Running YuE2 on a 12 GB GPU", + "zh_title": "为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2", + "category": "ai", + "pub_date": "2026-10-20", + "status": "pending", + "batch_id": "Batch-01", + "approved_at": null, + "notes": "", + "en_chars": 28955, + "zh_chars": 24414 + } +} \ No newline at end of file diff --git a/docs/consultant-review/batches/batch-01.md b/docs/consultant-review/batches/batch-01.md new file mode 100644 index 0000000..8c0f33c --- /dev/null +++ b/docs/consultant-review/batches/batch-01.md @@ -0,0 +1,5061 @@ +# 博客文章顾问审阅资料包 — Batch-01 + +> **批次编号**:`Batch-01` +> **包含文章数**:8 篇 +> **本批次总字符量**:174,993 字(严格限制 < 200,000 字) +> **模式**:BILINGUAL +> **生成时间**:2026-10-27 03:02:03 + +# 外部顾问审阅前置须知与工程背景规范 + +> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。 + +## 1. 站点定位与双重业务目的 +- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。 +- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。 + +## 2. 核心人设铁律与工程硬约束(必须严格遵守) +1. **彻底杜绝业余爱好者/玩具口吻**: + - ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。 + - ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。 +2. **严禁捏造虚假指标与性能数字**: + - 所有 benchmark 数据必须有明确的测试基准与单设备限制。 + - 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。 + - 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。 +3. **去模板化叙事与真实物理锚点**: + - 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。 + - 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。 +4. **代码与配置展示规范**: + - 超过 20 行的配置或日志采用 HTML `
` 原生折叠,`` 必须精准标注文档名与行数。 + +## 3. 顾问重点开刀维度 +- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大? +- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻? +- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)? +- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置? + + +--- + +## 本批次文章目录索引 + +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,783 字符 +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 字符 + +--- + +## 文章 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) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (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 +draft: false +dynamicCta: true +--- + +在我管理的自托管生产节点(Self-hosted production infrastructure)上,常年运行着全套 Docker 容器栈、NAS 存储、Cloudflare 隧道与自动化调度任务。但我照样在付顶级模型的钱。 + +所以每次别人知道我也在跑本地模型,问的都是同一句话:*Claude 和 GPT 就是 +比较强,本地模型又麻烦、又慢、效果还比较差,那你到底为什么要花钱买 GPU?* + +如果只比模型能力,我完全同意。今天要处理一个全新的问题、做复杂推理、规划 +产品架构、或者跑一个很长的 Agent 任务,我大部分时间还是先用 Claude、GPT。 +我部署本地模型,从来不是因为我觉得它已经打赢闭源模型。 + +而是因为 AI 越来越好用之后,出现了另一个问题:我做的每一件事,都开始建立 +在一个我自己完全不能控制的服务上。 + +--- + +## 真正的问题不是能力,是依赖 + +今天模型可以用多少、方案怎么收费、什么地区能不能用、版本什么时候换掉, +最后都不是使用者决定的。Anthropic 本来就有自己的地区限制,有些市场不能 +直接买 Claude 的商业服务;OpenAI 的方案、额度跟模型也一直在调。这些是商业 +服务,变动本来就可以预期。 + +以前 ChatGPT 只是偶尔拿来问问题,今天不能用,我明天再问没什么差别。 + +但当我开始拿 AI 写程式、跑 Agent、分析文件、处理客服、整理公司资料,甚至 +把它接进每天都在跑的工作流程之后,问题就变了。当 AI 从聊天工具变成工作里 +的基础设施,你要考虑的就不只是「哪一个模型比较强」,而是: + +> 如果我今天很依赖这个模型,但有一天它的使用条件改掉,我原本的工作还能不能继续? + +这就是为什么我觉得,闭源模型越强,本地模型的重要性会越来越高。因为闭源 +模型太好用,大家就会把越来越多事情交给它;依赖越深的同时,也会越被这些 +闭源模型控制。 + +还有一件事很值得讲清楚。每一家大模型公司都在谈 AI 安全、模型风险,甚至 +限制某些内容、地区或使用方式,但同时每一家又都还在拼命把模型做得更强—— +因为模型能力本身就是竞争力,你不做,别人还是会做。 + +所以最后真正被控制的,往往不是 AI 要不要继续发展,而是谁能用、怎么用、 +用多少,以及模型公司愿意提供到什么程度。对一般人来说,这可能只是服务 +条款;对企业来说,这就是**供应商风险**。假设一间公司的核心流程,加上后面 +所有 Agent、工具、资料处理,全部绑在同一个供应商身上,那它其实不只是在买 +Token——它是在把一部分工作的主控权交出去。 + +--- + +## 「可是本地模型就是比较差啊」——对,但这是错误的比较方式 + +这是最诚实的反对意见,而且它是事实。拿一个能放进 24GB、32GB 消费级显卡的 +模型,直接跟 Claude Opus 全面比较,很多复杂任务就是会输。这没什么好辩的。 + +但大家很容易用了错误的比较方式。真正该问的不是「Qwen 能不能打赢 Claude」, +而是: + +> **这个能力差距,到底有没有大到值得你永远用最贵的模型来做这件事?** + +因为这个交换并不对称。二三十 B 的模型确实落后顶级模型一截,但成本低一到 +两个数量级,或者直接跑在你已经买的硬体上。比例不同,决策就不同。 + +### 我把模型能力看成分界线,不是排行榜 + +如果一个任务低于某个能力门槛,那就是做不了,当然用强模型。但一旦跨过 +那条线,后面真正影响我使用方式的,就不再是 benchmark 分数,而是成本、 +速度、能不能长时间运行,以及我能不能控制这套系统。 + +以我的实际分法: + +| 任务 | 我用什么 | +|---|---| +| 问题还很模糊、连方向都不知道 | 顶级闭源模型——前面判断错了,后面做再多都没意义 | +| 产品架构、资料格式、验收条件 | 顶级闭源模型 | +| 照著已定规格建专案骨架、改档案、跑测试、看到错误再修 | 本地模型 | +| 长时间 Agent、重试、批次工作、大量内容产出 | 本地模型 | + +关键是第二块。一旦方向确定、Agent 只是在执行规格,一个任务可能会呼叫模型 +二十次、五十次。这五十次真的每一次都需要市面上最贵的模型吗?通常不需要。 +Agent 已经不是在帮我决定产品要怎么做,它是在按照前面已经写好的规格执行。 +只要本地模型可以正确理解这些规格、知道工具怎么用,它就已经跨过可以工作的 +门槛。 + +--- + +## Agent 会把小小的价差一直放大 + +用聊天,是问一次、回答一次。Claude 一次比本地模型贵很多,你也不会有特别 +感受。 + +但 Agent 不是一次。一个任务可能呼叫模型二十次、五十次,失败之后还会重新 +跑。模型单次价格的差距,放进这种连续任务里就会被一直放大。 + +而真正做过这些流程的人都知道,最花资源的往往还不是最后正式执行,而是前面 +测试。你会改 Prompt、换工具、重新切资料、调整工作流程,同一个东西跑十次、 +二十次很正常。 + +每次都是 API,我自己就会很明显感觉到:又在烧钱。用自己那张卡,当然还是有 +电费跟硬体成本,但我今天让一个 Agent 跑五次还是五十次,不会每跑一次就多 +一笔帐单。这对我来说很重要,因为它会直接影响我敢不敢一直连续测试。很多 +真正稳定的 AI workflow,不是第一次写完就成功,而是让它失败很多次之后, +才慢慢找到什么地方容易出问题。 + +本地算力其中一个很大的价值,就是你可以把这些大量试错搬回自己的固定成本 +里。这跟单纯比较「一百万 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 足够的试错空间。 + +--- + +## 开放模型已经跨过可用的线,而这条线还在移动 + +三年前,要在消费级显卡上跑一个真的可以帮你工作的模型,限制很多。现在 +二三十 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 的原因。 +```` + +### 🇬🇧 英文版原文 (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 +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. + +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?* + +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. + +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 problem is not capability — it is dependency + +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. + +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. + +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: + +> "If I depend on this model today, and its terms change tomorrow, does my work +> still run?" + +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. + +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. + +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. + +--- + +## "But local models are worse" — yes, and that is the wrong comparison + +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. + +But the comparison most people run is wrong. The question is not *can Qwen beat +Claude*. It is: + +> **Is the capability gap large enough to justify using the most expensive model +> for this task, forever?** + +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. + +### I treat capability as a threshold, not a ranking + +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. + +Concretely, here is how it splits for me: + +| 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 | + +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. + +--- + +## Agents turn a small price difference into a large one + +With chat, you ask once and get one answer. If Claude costs many times more than +a local model, you barely notice. + +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. + +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. + +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. + +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." + +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. + +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. + +--- + +## Open models crossed the useful line, and the line keeps moving + +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. +```` + +--- + +## 文章 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) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (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" +draft: false +dynamicCta: true +--- +YuE2 是一个 30 亿参数的开源音乐模型,能按风格提示词和歌词唱中文。我把它放在自己那台 12GB 显存的 RTX 3060 上用 ComfyUI 本地跑,一次排了 12 首原创歌 —— 结果第一批出来,**每一首的时长都恰好是 240.000 秒**。这个数字不是巧合,而且是个坏消息。 + +## 每首都恰好 240.000 秒 + +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 规划器来编排:读取歌词,给每一段分配小节数,生成一份符号化的曲谱草稿。音乐生成器照着这份草稿唱,**草稿结束它就结束**。 + +所以一首真正唱完的歌,输出时长一定是**小于**你设的上限的 —— 它自己停下来了。 + +判断方法只要一条命令: + +```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 = 被硬切 +``` + +如果时长和 `max_duration` 相差不到几毫秒,这次渲染就是被截断的。歌词需要的时间超过了上限允许的范围,切点那一句被拦腰砍断。 + +## 确认尾奏真的丢了 + +光看时长只能知道"被切了",不知道"丢了什么"。所以我把每首的尾巴切出来,用本地语音模型(SenseVoice,跑在 CPU 上,GPU 渲染时不用停)转写: + +```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" +``` + +(对于连末遍副歌都没唱到的曲目,我将切片窗口向前扩展到了最后 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 + +
+ + +
+ +### 2. 溪边捉云记 — Catching Clouds by the Stream + +*Guofeng electronic* · 578 characters · 280 s + +
+ + +
+ +### 3. 寒露拾桂子 — Picking Osmanthus at Cold Dew + +*Guofeng electronic* · 578 characters · 290 s + +
+ + +
+ +### 4. 纸鸢与旧木偶 — The Kite and the Old Puppet + +*Guofeng ballad* · 572 characters · 280 s + +
+ + +
+ +### 5. 小楼檐下听雨眠 — Listening to Rain Under the Eaves + +*Guofeng ballad* · 580 characters · 256 s + +
+ + +
+ +### 6. 外婆的绿搪瓷碗 — Grandma's Green Enamel Bowl + +*Urban folk* · 679 characters · 252 s + +
+ + +
+ +### 7. 弄堂口的竹蜻蜓 — The Bamboo Dragonfly at the Alley Mouth + +*Urban folk* · 692 characters · 250 s + +
+ + +
+ +### 8. 三楼窗台的蒲公英 — Dandelion on the Third-Floor Windowsill + +*Urban folk* · 693 characters · 247 s + +
+ + +
+ +### 9. 小哪吒借红绫 — Little Nezha Borrows the Red Sash + +*Guofeng DJ remix* · 612 characters · 271 s + +
+ + +
+ +### 10. 画卷小锦鲤 — The Koi That Left the Painting + +*Guofeng DJ remix* · 634 characters · 286 s + +
+ + +
+ +### 11. 山海小当家 — Little Boss of the Classic of Mountains and Seas + +*Guofeng DJ remix* · 647 characters · 237 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: + +```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 +``` + +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 + +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): + +```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" +``` + +(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: + +| 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 | + +So: not a mixing artefact, not a fade-out. The songs were structurally +incomplete. + +## 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 + +
+ + +
+ +### 2. 溪边捉云记 — Catching Clouds by the Stream + +*Guofeng electronic* · 578 characters · 280 s + +
+ + +
+ +### 3. 寒露拾桂子 — Picking Osmanthus at Cold Dew + +*Guofeng electronic* · 578 characters · 290 s + +
+ + +
+ +### 4. 纸鸢与旧木偶 — The Kite and the Old Puppet + +*Guofeng ballad* · 572 characters · 280 s + +
+ + +
+ +### 5. 小楼檐下听雨眠 — Listening to Rain Under the Eaves + +*Guofeng ballad* · 580 characters · 256 s + +
+ + +
+ +### 6. 外婆的绿搪瓷碗 — Grandma's Green Enamel Bowl + +*Urban folk* · 679 characters · 252 s + +
+ + +
+ +### 7. 弄堂口的竹蜻蜓 — The Bamboo Dragonfly at the Alley Mouth + +*Urban folk* · 692 characters · 250 s + +
+ + +
+ +### 8. 三楼窗台的蒲公英 — Dandelion on the Third-Floor Windowsill + +*Urban folk* · 693 characters · 247 s + +
+ + +
+ +### 9. 小哪吒借红绫 — Little Nezha Borrows the Red Sash + +*Guofeng DJ remix* · 612 characters · 271 s + +
+ + +
+ +### 10. 画卷小锦鲤 — The Koi That Left the Painting + +*Guofeng DJ remix* · 634 characters · 286 s + +
+ + +
+ +### 11. 山海小当家 — Little Boss of the Classic of Mountains and Seas + +*Guofeng DJ remix* · 647 characters · 237 s + +
+ + +
+ + +```` + +--- + +## 文章 03 / 08:我如何用 FLUX.1 Kontext 打造 AI 家具合成演示 +- **Slug**: `ai-furniture-compositing-with-flux-kontext` +- **英文标题**: How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext +- **分类**: `case-studies` | **发布日期**: 2026-09-11 +- **线上地址**: https://blog.hoelee.com/posts/ai-furniture-compositing-with-flux-kontext/ (EN) | https://blog.hoelee.com/posts/zh/ai-furniture-compositing-with-flux-kontext/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "我如何用 FLUX.1 Kontext 打造 AI 家具合成演示" +description: "用 fal.ai FLUX.1 Kontext 把家具产品照合成到风格化房间场景——多图端点、tainted canvas 的修复,以及为什么必须关掉 prompt enhancement。" +pubDate: 2026-09-11 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L1-high-conversion-web +dynamicCta: true +tags: [fal-ai, flux, ai-image, javascript, canvas, ecommerce] +ogImage: /og/zh-ai-furniture-compositing-with-flux-kontext.jpg +banner: /banners/ai-furniture-compositing-with-flux-kontext.jpg +--- + +一位家具客户带着一个每个小电商卖家最终都会撞上的问题来找我:他们的产品图都是**孤立的产品照**——一张白底桌子、一张白底椅子——但顾客不会从一片白茫茫的虚无里买家具,他们买的是*房间*。给每个产品、每种室内风格都拍一次实体棚拍,对一个人的生意来说根本不可能。 + +需求是:拿两张产品照(一张餐桌、一把椅子),把它们一起放进一个可信的、高级感的室内场景——生成出来的,而不是拍出来的。这篇文章讲的就是我为验证这件事可行而做的概念验证:我选的模型,以及三个差点把它搞砸的 bug。 + +## 为什么选 FLUX.1 Kontext——以及那个关键的端点 + +第一反应自然是通用文生图模型:*「一张餐桌和一把椅子放在现代客厅里」*。可当客户说*「不——是我的桌子、我的椅子,就是我产品页上那一款」*时,这个思路就崩了。生成一个相似品毫无价值;重点是要让**真实产品原封不动**,只改变它周围的房间。 + +这正是 **FLUX.1 Kontext**(通过 [fal.ai](https://fal.ai))专门解决的问题:以图生图,参考图*锚定*产品,提示词描述场景。但有个坑让我耗掉一个下午:这里有**两个**端点。 + +| 端点 | 参考图输入 | 用途 | +|---|---|---| +| `fal-ai/flux-pro/kontext` | `image_url`(单张) | 编辑 / 重新语境化一张图 | +| `fal-ai/flux-pro/kontext/multi` | `image_urls`(数组) | **合并多张参考图** | + +我需要把*两个独立产品融合进一个场景*,所以普通 `kontext` 不够——它只吃一张参考图。`multi` 变体接受参考图数组,这才是「桌子 + 椅子 → 一个房间」能成立的关键。它被标记为实验版,但这件事上它是唯一的选择。 + +## 用一个纯 HTML 文件调用 fal.ai——不用 SDK,不用服务器 + +做概念验证我不想搭后端。fal.ai 暴露了一个纯 REST 队列 API,所以一个 `index.html` 配 vanilla JavaScript 就能搞定全部。但这是三步,不是一步: + +
+index.html — fal.ai REST queue submission & polling (32 lines) + +```js +// 1. 提交——返回 request_id + 轮询 URL +const submit = await fetch( + "https://queue.fal.run/fal-ai/flux-pro/kontext/multi", + { + method: "POST", + headers: { + Authorization: "Key " + FAL_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: "...", + image_urls: [tableDataUrl, chairDataUrl], + enhance_prompt: false, + }), + } +); +const { request_id, status_url, response_url } = await submit.json(); + +// 2. 轮询状态直到 COMPLETED(约 5–30 秒) +let status; +do { + await new Promise((r) => setTimeout(r, 2000)); + status = (await (await fetch(status_url, { + headers: { Authorization: "Key " + FAL_KEY }, + })).json()).status; +} while (status === "IN_QUEUE" || status === "IN_PROGRESS"); + +// 3. 取结果 +const result = await (await fetch(response_url, { + headers: { Authorization: "Key " + FAL_KEY }, +})).json(); +// result.images[0].url 就是生成的图片 +``` + +
+ +有两件事我很早就验证了,因为它们决定了「单文件」方案成不成立: + +1. **CORS 是开放的。** `queue.fal.run` 返回宽松的 `access-control-allow-origin`,并允许 `authorization` 头,所以浏览器可以直接调用,无需代理。我在写任何 UI 之前就用 preflight 确认了这一点。 +2. **本地图以 base64 data URI 传入。** fal.ai 的 `image_urls` 接受 `data:` URI,所以我根本不需要先把客户的照片传到存储桶——demo 直接读文件、压缩,然后塞进请求体。 + +## Canvas 压缩层 + +手机照片有 5–10 MB。两张这样、再 base64 编码,请求体会膨胀到无法使用,生成也会变慢。所以提交前,demo 会把每张图过一遍 `` 来缩放并重新编码: + +```js +async function imgToDataURI(file, maxSize = 1024, quality = 0.85) { + const img = await createImageBitmap(file); // 或 new Image() + const scale = Math.min(1, maxSize / Math.max(img.width, img.height)); + const c = document.createElement("canvas"); + c.width = Math.round(img.width * scale); + c.height = Math.round(img.height * scale); + c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); + return c.toDataURL("image/jpeg", quality); // 每张约 200–400 KB +} +``` + +一张 7 MB 的手机照变成约 300 KB 的 JPEG。上传快,模型处理也快。 + +## Bug #1 ——「Tainted canvases may not be exported」 + +demo 在我脑子里能跑,在浏览器里却不行。我一点「生成」就收到: + +> `Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.` + +这是浏览器的安全边界,不是 fal.ai 的问题。当你用 `file://` 打开页面、并用 `` 加载一张*本地*图片时,那张图属于**不透明源**。它一被画到 canvas 上,canvas 就变成 *tainted*,`toDataURL()` 拒绝导出——浏览器不会让网页读回一张它无法证明有权读取的文件的像素。 + +有两个修复,我在不同阶段都用过: + +1. **把默认图以 base64 data URI 内嵌进 HTML 源码。** data URI 不会污染 canvas,所以即使从磁盘双击打开也能跑。 +2. **走 HTTP、用同源相对路径**(`src="chair.jpg"`)。一旦页面和图片同源,canvas 就保持干净。这是真实部署的正确答案——demo 现在跑在一个 URL 上,而不是双击打开的文件。 + +通用规则:**只要任何不透明源的图片碰到 canvas,`toDataURL` 立刻报错。** 如果你要加载本地文件,要么内嵌,要么走 HTTP。不改浏览器安全设置,没有第三条路。 + +## Bug #2 —— API key 就在 HTML 里 + +对 demo 来说这可以容忍;对任何公开的东西来说这是个洞。客户端 `fetch` 里的 `Key` 头意味着 key 就在页面源码里,任何人按 F12 就能读到。在交付客户内部预览期间,该静态页面仅搭配了极低余额的限额测试 Key 并在受控私密链接下访问;而在下一阶段的正式 WordPress 插件中,Key 将彻底移至服务端 PHP。 + +真正 WordPress 插件(下一阶段)的计划是把 key 挪到**服务端 PHP**:插件的端点去做带鉴权的 fal.ai 调用并返回图片,浏览器只跟插件自己的路由对话。demo 证明了管线能跑通;插件会把秘密放回秘密该待的地方。 + +## Bug #3 —— 输出看起来「像第二张图」 + +这是让我怀疑 `multi` 到底能不能用的那个。我换了一张新桌子图、生成,结果看起来跟椅子参考图几乎一模一样——好像模型直接忽略了桌子、复制了椅子。 + +在怪模型之前,我先查了一件可证伪的事:**`multi` 到底是在合成,还是只是在回显某一张输入?** 我用感知哈希和平均像素差对比了生成图和两张源图: + +| 对比 | 平均像素差(0 = 相同) | dHash 距离 | +|---|---|---| +| 结果 vs 桌子 | 55.7 | 31 | +| 结果 vs 椅子 | 55.3 | 29 | +| 桌子 vs 椅子 | 69.3 | 30 | + +结果离两张输入都*很远*——端点是真在合成新场景,不是复制。所以「像椅子」的问题不在 API,而在**输入和提示词**。 + +三个叠加的原因: + +1. **参考图不一致。** 最初的 `chair.jpg` 是一张完整的「桌+椅」*场景*照,不是干净的单椅——于是模型看到一张图就已经满足「桌+椅同框」,就倾向照着它来。 +2. **按位置指代不可靠。** Kontext 按图片*内容*匹配,而不是数组顺序。说「第一张图」/「第二张图」并不能可靠地映射到「桌子」/「椅子」。修复是直接*命名物体*——*「the dining table」*和*「the chair」*——让模型自己去匹配正确的参考图。 +3. **测试初期开启了 prompt enhancement。** fal.ai 的 `enhance_prompt` 参数一旦开启,会在后台把提示词改写成更丰富的审美描述——当目标是*「绝不要重新解读产品」*时,这正好帮了倒忙。我将其显式锁定为 `enhance_prompt: false`,强制模型严格执行字面约束,而不是添油加醋。 + +## 最终起作用的提示词 + +客户的核心要求很严格:**原样保留家具**,只去发明房间。这需要一段把大部分篇幅花在*阻止*重新解读、而不是描述风格上的提示词: + +> Create a photorealistic interior photograph using the exact dining table and +> exact chair from the reference images. Preserve both furniture pieces exactly +> as shown — do not redesign, recolor, repaint, restyle, replace, or reinterpret +> either product. Keep their original geometry, proportions, construction, +> material, finish, texture, and color exactly unchanged. +> +> Place the chair naturally beside the dining table in a realistic dining +> position, slightly pulled under the table and properly aligned with it. +> Maintain realistic scale, perspective, and physical contact with the floor. +> The chair and table must look photographed together in the same real room, +> with natural contact shadows — not composited or pasted together. +> +> Preserve the true original color. Do not allow the room lighting, wall or +> floor colors, or grading to alter the furniture. +> +> Set the scene in a spacious modern living room with floor-to-ceiling windows +> and subtle warm afternoon daylight. Use restrained, neutral interior colors. +> +> Photorealistic commercial furniture photography, realistic camera +> perspective, natural proportions, high detail. + +demo 里内置了六段这样的提示词,每种场景风格一段(现代客厅、温馨餐厅、北欧风、侘寂风、以及干净的影棚),每一段只改「set the scene」那一节。 + +## 结果 + +一个 `index.html`,没有后端、没有 SDK——两张产品照进,一个布置好的房间出,**每张图 $0.04**,生成约 15–20 秒。客户现在有了一个能拿给*他们*顾客看的可用预览,带场景选择器和可编辑提示词,然后再决定是否投入完整的 WordPress 插件。 + +这个概念验证回答了一个它生来就要回答的问题:**是的,你可以保留真实产品、只生成它周围的房间**——只要你尊重那三条真正要紧的约束:每个产品一张干净的参考图、按物体命名(而非按位置)的提示词、以及 `enhance_prompt: false`。 + +## 我会做不同的地方 + +- **第一天就要求干净的单品参考图。** 下游的每一种失败模式——「像某一张图」的问题、比例漂移——都能追溯到不一致的源照片。我会在碰任何模型之前,先发一份给客户的简短说明(「每张照片一个产品、纯色背景、没有其他家具」)。 +- **先显式关掉 `enhance_prompt`。** 我浪费了一次生成,就因为 enhancement 把我精心写的「别重新解读」改写成恰恰相反的东西。 +- **在写 UI 之前先用真实 preflight 验证 CORS。** 两行 `curl` 的事,却能给整个架构决策祛除风险。 + +--- + +## 想为你的产品目录做这个吗? + +我为电商企业构建 AI 图像管线、WordPress 插件,以及自托管基础设施。如果你想把产品摆进风格化的房间场景——或者想雇我做类似的 AI 集成——我非常乐意交流: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **邮箱:** [me@hoelee.com](mailto:me@hoelee.com?subject=AI%20product%20image%20compositing) +- 🌐 **网站:** [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext" +description: "Compositing furniture photos into styled room scenes with fal.ai FLUX.1 Kontext — the multi-image endpoint, the tainted-canvas fix, and why prompt enhancement had to go." +pubDate: 2026-09-11 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L1-high-conversion-web +dynamicCta: true +tags: [fal-ai, flux, ai-image, javascript, canvas, ecommerce] +ogImage: /og/ai-furniture-compositing-with-flux-kontext.jpg +banner: /banners/ai-furniture-compositing-with-flux-kontext.jpg +--- + +A furniture client came to me with a problem that every small e-commerce seller +eventually hits: their catalogue photos are **isolated product shots** — a +table on white, a chair on white — but customers don't buy furniture from a +white void. They buy the *room*. Staging a real photoshoot for every product, +in every interior style, is out of the question for a one-person business. + +The ask: take two product photos (a dining table, a chair) and place them +together into a believable, high-end interior scene — generated, not +photographed. This post is the story of the proof-of-concept I built to prove +that's possible, the model I chose, and the three bugs that tried to eat it. + +## Why FLUX.1 Kontext — and the endpoint that matters + +The obvious first instinct is a general text-to-image model: *"a dining table +and a chair in a modern living room."* That fails the moment the client says +*"no — MY table, MY chair, the exact one on my product page."* Generating a +lookalike product is worthless; the whole point is to keep the **real product +unchanged** and only change the room around it. + +That's the specific problem **FLUX.1 Kontext** (via [fal.ai](https://fal.ai)) +is built for: image-conditioned generation, where the reference image *anchors* +the product and the prompt describes the scene. But there's a subtlety that cost +me an afternoon: there are **two** endpoints. + +| Endpoint | Reference input | Use case | +|---|---|---| +| `fal-ai/flux-pro/kontext` | `image_url` (single) | edit / re-context one image | +| `fal-ai/flux-pro/kontext/multi` | `image_urls` (array) | **combine multiple reference images** | + +I needed to *fuse two separate products into one scene*, so plain `kontext` +wasn't enough — it only takes one reference image. The `multi` variant accepts +an array of reference images and is what makes "table + chair → one room" +possible. It's flagged experimental, but it's the only game in town for this. + +## Calling fal.ai from a plain HTML file — no SDK, no server + +For a proof-of-concept I didn't want to stand up a backend. fal.ai exposes a +plain REST queue API, so a single `index.html` with vanilla JavaScript can do +the whole job. There are three steps, not one: + +
+index.html — fal.ai REST queue submission & polling (32 lines) + +```js +// 1. Submit — returns request_id + polling URLs +const submit = await fetch( + "https://queue.fal.run/fal-ai/flux-pro/kontext/multi", + { + method: "POST", + headers: { + Authorization: "Key " + FAL_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: "...", + image_urls: [tableDataUrl, chairDataUrl], + enhance_prompt: false, + }), + } +); +const { request_id, status_url, response_url } = await submit.json(); + +// 2. Poll status until COMPLETED (takes ~5–30s) +let status; +do { + await new Promise((r) => setTimeout(r, 2000)); + status = (await (await fetch(status_url, { + headers: { Authorization: "Key " + FAL_KEY }, + })).json()).status; +} while (status === "IN_QUEUE" || status === "IN_PROGRESS"); + +// 3. Fetch the result +const result = await (await fetch(response_url, { + headers: { Authorization: "Key " + FAL_KEY }, +})).json(); +// result.images[0].url is the generated image +``` + +
+ +Two things I verified early, because they make or break the "single file" +approach: + +1. **CORS is open.** `queue.fal.run` returns a permissive + `access-control-allow-origin` and allows the `authorization` header, so a + browser can call it directly with no proxy. I confirmed this with a + preflight before writing any UI. +2. **Local images go in as base64 data URIs.** fal.ai accepts `data:` URIs in + `image_urls`, so I never had to upload the client's photos to a storage + bucket first — the demo reads a file, compresses it, and ships it straight + in the request body. + +## The canvas compression layer + +Phone photos are 5–10 MB. Two of those, base64-encoded, bloats the request to +unusable size and slows generation. So before submitting, the demo runs each +image through a `` to resize and re-encode it: + +```js +async function imgToDataURI(file, maxSize = 1024, quality = 0.85) { + const img = await createImageBitmap(file); // or new Image() + const scale = Math.min(1, maxSize / Math.max(img.width, img.height)); + const c = document.createElement("canvas"); + c.width = Math.round(img.width * scale); + c.height = Math.round(img.height * scale); + c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); + return c.toDataURL("image/jpeg", quality); // ~200–400 KB each +} +``` + +A 7 MB phone shot becomes a ~300 KB JPEG. Fast to upload, fast for the model to +process. + +## Bug #1 — "Tainted canvases may not be exported" + +The demo worked in my head. Then it didn't work in a browser. The moment I hit +"generate" I got: + +> `Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.` + +This is a browser security boundary, not a fal.ai problem. When you open the +page over `file://` and load a *local* image with ``, +that image is an **opaque origin**. The moment it's drawn onto a canvas, the +canvas becomes *tainted*, and `toDataURL()` refuses to export it — the browser +won't let a webpage read back the pixels of a file it can't prove it's allowed +to read. + +Two fixes, and I used both at different points: + +1. **Inline the default images as base64 data URIs** directly in the HTML + source. Data URIs don't taint the canvas, so the demo works even when + double-clicked from disk. +2. **Serve over HTTP with same-origin relative paths** (`src="chair.jpg"`). + Once the page and images share an origin, the canvas stays clean. This is + the right answer for the real deployment — the demo now lives at a URL, not + a double-clicked file. + +The general rule: **`toDataURL` fails the instant any opaque-origin image touches +the canvas.** If you're loading local files, either inline them or serve over +HTTP. There is no third way without changing browser security settings. + +## Bug #2 — the API key is in the HTML + +For a demo this is tolerable; for anything public it's a hole. A `Key` header in +a client-side `fetch` means the key is in the page source, readable by anyone +who presses F12. During the client internal review, this demo ran strictly with +a low-balance restricted test key over a gated link; for production, client-side +keys are a non-starter. + +The plan for the real WordPress plugin (the next phase) is to move the key to +**server-side PHP**: the plugin's endpoint does the authenticated fal.ai call +and returns the image, while the browser only talks to the plugin's own route. +The demo proved the pipeline works; the plugin will put the secret where +secrets belong. + +## Bug #3 — the output looked like "the second image" + +This was the one that made me question whether `multi` even worked. I swapped in +a new table photo, generated, and the result looked almost identical to the +chair reference — as if the model had just ignored the table and copied the +chair. + +Before blaming the model, I checked something falsifiable: **is `multi` even +compositing, or just echoing one input?** I compared the generated image +against both source images with a perceptual hash and mean pixel difference: + +| Comparison | Mean pixel diff (0 = identical) | dHash distance | +|---|---|---| +| result vs table | 55.7 | 31 | +| result vs chair | 55.3 | 29 | +| table vs chair | 69.3 | 30 | + +The result was *far* from both inputs — the endpoint genuinely composites a new +scene, not a copy. So the "looks like the chair" problem wasn't the API; it was +the **inputs and the prompt**. + +Three compounding causes: + +1. **The reference photos were inconsistent.** The original `chair.jpg` was a + photo of a full table-and-chair *scene*, not a clean single chair — so the + model already saw "table + chair" satisfied by one image and leaned on it. +2. **Positional prompting is unreliable.** Kontext matches images by *content*, + not array order. Saying "the first image" / "the second image" doesn't + reliably map to "the table" / "the chair". The fix is to name the objects — + *"the dining table"* and *"the chair"* — and let the model match them to the + right reference. +3. **Prompt enhancement was enabled in early tests.** When turned on, fal.ai's + `enhance_prompt` rewrites your prompt into a richer aesthetic description — + exactly wrong when the goal is *"do not reinterpret the product."* I explicitly + pinned `enhance_prompt: false` so the model obeys the literal constraints instead + of embellishing them. + +## The prompt that finally worked + +The client's core requirement was strict: **preserve the furniture exactly** and +only invent the room. That needs a prompt that spends most of its length +*preventing* reinterpretation, not describing style: + +> Create a photorealistic interior photograph using the exact dining table and +> exact chair from the reference images. Preserve both furniture pieces exactly +> as shown — do not redesign, recolor, repaint, restyle, replace, or reinterpret +> either product. Keep their original geometry, proportions, construction, +> material, finish, texture, and color exactly unchanged. +> +> Place the chair naturally beside the dining table in a realistic dining +> position, slightly pulled under the table and properly aligned with it. +> Maintain realistic scale, perspective, and physical contact with the floor. +> The chair and table must look photographed together in the same real room, +> with natural contact shadows — not composited or pasted together. +> +> Preserve the true original color. Do not allow the room lighting, wall or +> floor colors, or grading to alter the furniture. +> +> Set the scene in a spacious modern living room with floor-to-ceiling windows +> and subtle warm afternoon daylight. Use restrained, neutral interior colors. +> +> Photorealistic commercial furniture photography, realistic camera +> perspective, natural proportions, high detail. + +The demo ships six of these, one per scene style (modern living room, cozy +dining room, Scandinavian, wabi-sabi, and a clean studio), each differing only +in the "set the scene" paragraph. + +## The result + +One `index.html`, no backend, no SDK — two product photos in, a staged room +out, at **$0.04 per image** and roughly 15–20 seconds of generation. The client +now has a working preview they can show *their* customers, with a scene selector +and an editable prompt, before committing to the full WordPress plugin. + +The proof-of-concept answered the question it was built to answer: **yes, you +can keep the real product and only generate the room around it** — as long as +you respect the three constraints that actually matter: a clean reference image +per product, object-named (not positional) prompts, and `enhance_prompt: false`. + +## What I'd do differently + +- **Require clean, single-product reference images on day one.** Every failure + mode downstream — the "looks like one image" problem, the proportion drift — + traces back to inconsistent source photos. I'd ship a tiny client-facing + guide ("one product per photo, plain background, no other furniture") before + touching any model. +- **Turn `enhance_prompt` off explicitly, first.** I lost a generation to the + enhancement rewriting my careful "do not reinterpret" instructions into + exactly the opposite. +- **Validate CORS with a real preflight before building UI.** It's a two-line + `curl` and it de-risks the entire architecture decision. + +--- + +## Want this for your product catalogue? + +I build AI image pipelines, WordPress plugins, and self-hosted infrastructure +for e-commerce businesses. If you want to stage your products in styled room +scenes — or hire me for a similar AI integration — I'd love to talk: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com?subject=AI%20product%20image%20compositing) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 04 / 08:从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操 +- **Slug**: `from-audio-to-a-4-minute-ai-music-video` +- **英文标题**: From Audio to a 4-Minute Music Video: Orchestrating Gemini, AI Diffusion, and Filmora Editing +- **分类**: `case-studies` | **发布日期**: 2026-10-25 +- **线上地址**: https://blog.hoelee.com/posts/from-audio-to-a-4-minute-ai-music-video/ (EN) | https://blog.hoelee.com/posts/zh/from-audio-to-a-4-minute-ai-music-video/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操" +description: "基于《月牙湾》1080P 高清动漫 OP 制作实录:详解 AI 音乐重构、Gemini 提示词分镜规划、文字生图与图生视频多轮采样筛选调优,以及 Filmora 剪辑与特效合成的真实生产实践。" +pubDate: 2026-10-25 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L4-aigc-media-pipelines +stream: creative-lab +tags: ["ai-video", "music-video", "gemini", "filmora", "face-swapping", "ai-music", "creative-workflows"] +ogImage: "/og/zh-from-audio-to-a-4-minute-ai-music-video.jpg" +banner: "/banners/from-audio-to-a-4-minute-ai-music-video.jpg" +draft: false +--- + +绝大多数 AI 视频演示都停留在 5 秒的单镜头片段:一个女生转头、一段雨水打在水坑里的特写。发在社交媒体上很惊艳,但要真正用 AI 从零做出一整首长达 4 分钟(240 秒)、包含音乐生成与 20+ 个连续镜头的完整音乐视频(MV),完全是另一套现实的工程与创作实操。 + +真实做过的人都知道,**AI 根本做不到“一键完美直出”**: +* 让 Gemini 生成分镜提示词,需要细致引导才不偷工减料; +* 文字生图经常出现五官崩坏、服装变色; +* 图生视频跑出来的微动作,往往需要反复生成多次(重抽)才能挑出一段自然的; +* 最后要把这些零散的素材剪成一首节奏对齐、转场自然的完整 MV,必须依赖 **Filmora** 这样的专业剪辑软件做精细卡点、特效叠加与平滑过渡。 + +为了验证整套流水线能否真正跑通,我重构改编了经典华语曲目**《月牙湾》(Crescent Bay)**,将其打造为日系热血奇幻动漫 OP 风(Oriental Fantasy J-Rock),并完成了整整 4 分钟 1080P 高清成片。 + +以下是完整的最终成品(可直接全屏播放): + +
+ +
+ +--- + +## 1. 音乐生成与风格重构:日系热血动漫 OP 的诞生 + +视觉的灵魂是节奏。制作 MV 的第一步,首先是产生符合设想的音乐母带。 + +原版的《月牙湾》带有浓厚的西域民谣与流行底蕴。为了赋予它更强烈的视觉冲击力和画面推动感,我通过 AI 音乐生成工具将整首歌重构为 **Oriental Fantasy J-Rock / AniSong(东方奇幻日系动漫 OP 风格)**: +* **鼓点与贝斯**:换上高速双踩双底鼓(Double Bass Drum)与持续滚动的失真电贝斯,让副歌的推进感拉满; +* **吉他与乐器交织**:前奏保留了西域传统风情的空灵音色,但在主歌切入后迅速叠加大失真主音电吉他; +* **人声质感**:要求高燃、清亮且穿透力极强的热血女声,在副歌瞬间爆发。 + +有了这首 4 分钟的完整音频后,我将全曲按情绪起伏拆解成 24 个左右的时钟分段(前奏、主歌 A/B、副歌爆发、间奏吉他 Solo、尾奏),为后续的分镜画面打好节拍地基。 + +--- + +## 2. Gemini 分镜设计:两阶段提示词规划 + +很多人用大模型生成分镜时,习惯把整首歌歌词丢给它,说“帮我生成 24 个生图提示词”。这样做的结果往往惨不忍睹:前 3 个镜头写得很详细,到了第 10 镜以后提示词就只剩两三句话,甚至连主角长相都丢了。 + +在 Gemini 中,我采用**分阶段结构化输出**: + +1. **第一阶段:锁定视觉核心 DNA(Visual Constants)** + * **角色固定锚点**:固定主角发型(黑长直双环髻、红丝带发簪)、服装(青白双层汉元素织物)、外貌气质与年龄; + * **色彩与摄影基调**:统一色温(4500K 冷青色调)、电影镜头感(ARRI 50mm 镜头虚化、胶片微颗粒感); + * **留白区域**:明确每张图采用三分法构图,人物位于画面一侧,另一侧预留干净的景深虚化负空间(Copy Space)。 + +2. **第二阶段:生成 24 镜生图(T2I)与生视频(I2V)提示词** + * 让 Gemini 逐个格子输出:该镜头的**生图提示词(Text-to-Image Prompt)**以及对应的**图生视频运动提示词(Image-to-Video Motion Prompt)**; + * 人物镜头与纯意象空镜头(大漠风沙、屋檐雨滴、古琴特写)穿插交替,避免连续正脸特写造成视觉疲劳。 + +--- + +## 3. 生图与图生视频的现实:多轮采样与筛选调优 + +AI 视频制作中耗费最多时间的,就是**“生图 -> 挑图 -> 垫图生视频 -> 重新采样挑选”**的反复迭代过程。 + +### ① 文字生图(T2I):解决画风与角色漂移 +即使有详细的提示词,生图模型也常常会出现五官比例怪异、衣服颜色擅自更改、或在空镜头里莫名其妙画一张人脸的情况。 +* 单个镜头通常需要生成 3~5 次,挑选出光影、人物站位和留白最符合分镜的一张; +* 挑选出的首帧图片,必须保证主角的标志性服饰和发型在视觉上具备高度辨识度。 + +### ② 图生视频(I2V):控制微动作,避免身体畸变 +拿挑选好的静态图送入图生视频模型生成 5~10 秒的动态视频,是崩坏率最高的环节: +* **严禁否定词**:在正向提示词中写“不要变形、不要乱动”,模型反而容易产生肢体扭曲;必须使用正面稳定词(如微风吹拂发丝、胸口自然呼吸起伏、镜头缓慢推进); +* **动作幅度控制**:AI 生成长镜头时,动作幅度越小越逼真。大步奔跑或大幅度转身很容易引发身体部位撕裂; +* **多次生成挑选最佳片段**:一个 10 秒的视频镜头,常常需要生成 3 次以上。有时候前 4 秒很自然、第 5 秒开始崩坏,这没关系——只要有几秒是完美的,就可以保留下来放到剪辑软件中裁剪使用。 + +### ③ 面部一致性锁颜:本地 FaceFusion 深度模型解决“多镜头变脸” +即便在提示词中固定了发型与服饰,文生图与图生视频在 20 多个连续镜头中,人物五官骨相依然会出现微小的“角色漂移”,更无法直接保证封面海报与成片女主拥有完全一致的面孔。 +* **本地算力部署**:利用本机显卡(RTX 3060 12GB)离线运行 **FaceFusion** 神经网络换脸流水线; +* **拒绝 2D 贴纸感**:放弃容易产生贴纸感和边缘闪烁的通用 128 像素简易算法,选用训练精度更高的 **320 规格深度神经网络模型(DFM)**,让光影与 3D 轮廓深度融合; +* **高清面部修复**:配合 **SCRFD** 灵敏人脸检测与 **GFPGAN** 高清修复,将精挑细选的高辨识度东方少女面容(与封面海报保持 100% 同款面庞)无缝置换入主角核心特写镜头中,彻底消除“一转镜头换了个人”的违和感。 + +--- + +## 4. Filmora 剪辑与后期合成:真正让素材变成 MV 的关键一步 + +AI 生成出来的只是一个个孤立的视频切片,**把这些碎片变成一首真正有呼吸感、有节奏感的音乐视频,核心功力全在剪辑软件里**。 + +我使用 **Filmora** 完成了最后的重度后期与合成: + +``` +[原始音频轨道] ────────────────────────────────────────────── (对齐卡点基准) +[视频轨 1 / 2] [ 镜头1 (淡出) ] ── (平滑交叉叠化) ── [ 镜头2 (淡入) ] +[特效与转场层] [ 光晕 / 风沙粒子 / 局部调色 LUT ] +[歌词与排版层] [ 双语艺术字幕 / 动态节拍对齐 ] +``` + +1. **精准对位音乐节拍(Beats & Cuts)**: + * 将音频波形导入 Filmora,标记出吉他切入点、重低音鼓点与高潮副歌爆发点; + * 将 AI 生成的 24 段视频按节奏修剪,只截取每段生成最平稳、动作最自然的 6~10 秒精华部分,让镜头的切换严丝合缝地踩在鼓点上。 +2. **消灭黑屏跳闪,打造平滑过渡**: + * 绝不能用粗暴的黑场淡入淡出(Dip to Black),否则整首 MV 每 10 秒就会像眨眼一样暗下去一次; + * 在 Filmora 中使用两轨交叉叠化(Cross Dissolve)与柔光过渡,让前一个画面的残影与后一个画面的光影自然交融,呈现出电影般的无缝穿梭感。 +3. **叠加氛围特效与统一色调**: + * 针对沙漠敦煌与动漫热血主题,叠加了微弱的风沙粒子动效与镜头光晕(Lens Flare); + * 通过全局 LUT 统一 20 多个不同镜头的明暗与冷暖色阶,消除不同生成批次之间的色差。 +4. **歌词排版与呈现**: + * 结合大号中文艺术字体与下方的英文字幕,做好边框与阴影,防止歌词在动态背景中被背景颜色吞噬。 + +--- + +## 5. 经验总结:个人创作者的 AI 生产力闭环 + +完成这一首 4 分钟的《月牙湾》MV,给我最大的感受是: + +1. **AI 放大的是制作上限,但不能取代审美判断**:AI 可以在短时间内产出海量的画面与动作构想,但“哪一张图最好、哪一段动作能用、在哪个鼓点切镜头”,依然需要创作者的主观审美与导演直觉。 +2. **多工具协同才是最优解**: + * **Gemini** 负责文字创意、世界观定调与分镜拆解; + * **AI 绘图与视频扩散** 负责提供高质感的视听资产; + * **FaceFusion (本地 RTX 3060)** 负责锁定女主角核心特写镜头与封面海报的面部一致性; + * **Filmora** 负责精准的时间轴控制、镜头拼接、声画对齐与视觉特效。 + +这种“AI 生成资产 + 人工剪辑雕琢”的工作流,让个人或小型团队在过去需要整个制作公司才能完成的 4 分钟完整 MV 制作,变成了一个人在工作站前几天内即可交付的现实。 + +--- + +*如果您对 AI 音视频流水线搭建、数字内容自动化生成或商业多媒体交付感兴趣,欢迎交流探讨:* + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **邮件:** [me@hoelee.com](mailto:me@hoelee.com?subject=%E5%92%A8%E8%AF%A2%20AI%20MV%20%E5%88%9B%E4%BD%9C%E4%B8%8E%E6%B5%81%E6%B0%B4%E7%BA%BF) +- 🌐 **网站:** [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "From Audio to a 4-Minute Music Video: Orchestrating Gemini, AI Diffusion, and Filmora Editing" +description: "A complete production breakdown of building a 4-minute 1080p anime OP music video (Crescent Bay): AI music generation, Gemini storyboard prompting, iterative image-to-video rerolls, and Filmora VFX editing." +pubDate: 2026-10-25 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L4-aigc-media-pipelines +stream: creative-lab +tags: ["ai-video", "music-video", "gemini", "filmora", "face-swapping", "ai-music", "creative-workflows"] +ogImage: "/og/from-audio-to-a-4-minute-ai-music-video.jpg" +banner: "/banners/from-audio-to-a-4-minute-ai-music-video.jpg" +draft: false +--- + +Most AI video demonstrations end after five seconds: a stunning single-shot clip of a character turning their head or raindrops hitting a puddle. It looks cinematic in a tweet, but assembling a complete four-minute music video (240 seconds) with music generation and 20+ continuous scenes is a vastly different engineering and creative challenge. + +Anyone who has actually produced long-form AI content knows the blunt truth: **AI never outputs a perfect 4-minute video in one click**. +* Gemini requires careful multi-stage prompt engineering, otherwise it starts cutting corners halfway through; +* Text-to-Image models frequently distort hands or swap character clothes; +* Image-to-Video models suffer from high deformation rates on 10-second clips, demanding multiple rerolls before landing a usable segment; +* Finally, turning these isolated raw clips into a coherent, rhythmic music video requires heavy post-production in professional NLE software like **Wondershare Filmora** for beat-cutting, visual effects, and seamless cross-dissolves. + +To test how viable this end-to-end production workflow is, I remade the classic track **Crescent Bay (月牙湾)** into a high-energy J-Rock anime opening (Oriental Fantasy J-Rock) and produced a full 4-minute 1080p music video. + +Here is the finished music video: + +
+ +
+ +--- + +## 1. Music Generation & Re-Arrangement: Birth of an Anime Opening + +The soul of an impactful video is its musical tempo and momentum. Before drafting a single visual, the first step was creating the master audio. + +The original *Crescent Bay* is a classic Chinese pop track with traditional Silk Road instrumentation. To give it the visual driving force required for an anime opening, I reimagined the arrangement through AI music generation tools into an **Oriental Fantasy J-Rock / AniSong**: +* **Drums & Bass:** Introduced rapid double-bass drum rhythms and a sustained, driving distorted electric bass to build intense forward momentum into the chorus; +* **Guitars & Exotic Textures:** Kept the evocative traditional desert flute and acoustic strums during the intro, then slammed into overdriven lead electric guitars as the verses kick in; +* **Vocal Character:** Tuned for a soaring, resonant, high-energy female vocal that cuts through the heavy rock mix during the chorus. + +With the completed 4-minute audio ready, I divided the track into 24 distinct 10-second timeline cells (Intro, Verse A/B, Pre-Chorus, Chorus explosions, Guitar Solo, Outro) to serve as the structural anchor for all visuals. + +--- + +## 2. Gemini Storyboard Architecture: Multi-Stage Prompting + +A common failure mode when using LLMs for storyboarding is dumping the entire lyric sheet into a single prompt and asking for "24 scene prompts." The model writes detailed paragraphs for the first three scenes, but by Scene 12 it burns through its context budget, dropping character descriptions and producing generic single-sentence prompts. + +Using Gemini, I decoupled the workflow into two structured stages: + +1. **Stage 1: Locking Visual DNA & Constants** + * **Character Anchors:** Fixed hairstyle (straight jet-black hair in twin-looped buns with a trailing crimson ribbon), dual-layer clothing (pale celadon-cyan organza outer robe over matte ivory silk), and age/silhouette; + * **Cinematographic Constants:** Enforced a consistent 4500K cool-jade color palette and optical traits (`ARRI Alexa 65, Panavision Primo 50mm anamorphic lens, shallow depth of field`); + * **Rule-of-Thirds Composition:** Positioned subjects strictly on one third of the frame, demanding clean, creamy out-of-focus negative space on the opposing side for typography. + +2. **Stage 2: 24 Atomic Scene Prompts** + * Tasked Gemini to generate both the **Text-to-Image (T2I) visual prompt** and the corresponding **Image-to-Video (I2V) motion prompt** for each 10-second grid cell; + * Alternated between character portraits, environmental wide shots, and macro B-roll (wind chimes, rain ripples on stone, close-up of zither strings) to prevent visual fatigue from continuous close-up portraits. + +--- + +## 3. The Reality of Video Diffusion: Iterative Rerolls & Quality Control + +In real-world generative video production, the bulk of production time is spent on **iterative rerolls and quality filtering**. + +### ① Text-to-Image (T2I): Fighting Drift +Even with explicit prompt anchors, image models occasionally render strange facial proportions, hallucinate western clothing, or insert unwanted faces into macro landscape shots. +* Each shot required generating 3 to 5 candidates to find the one with optimal lighting, correct silhouette, and clean negative space; +* Selected base frames served as strict visual anchors for the video diffusion stage. + +### ② Image-to-Video (I2V): Controlling Micro-Motions +Passing still images into video diffusion models (Veo, Kling, Runway) for 5 to 10 seconds is where most deformations occur: +* **Never Use Negative Words in Positive Prompts:** Prompting `no morphing` or `don't distort` frequently backfires in text encoders, triggering limb anomalies. Instead, use positive stabilizing anchors: `consistent anatomy, temporal stability, subtle breathing, gentle wind blowing through hair`; +* **Micro-Motions Over Large Gestures:** In diffusion models, smaller motions look far more realistic. Complex full-body turns or rapid running almost always cause torso tearing; +* **Multiple Rerolls to Harvest Usable Takes:** Producing a 10-second clip often takes 3 or more attempts. Even if a take begins to morph at second 6, the first 5 flawless seconds can be harvested and trimmed during editing. + +### ③ Locking Facial Identity via Local FaceFusion +Even when hairstyle and clothing descriptors are nailed down in prompts, text-to-image and video diffusion models exhibit subtle bone-structure and facial drift across 20+ distinct scenes. Crucially, raw diffusion cannot guarantee that the protagonist in the final video matches the hero cover art. +* **Offline Local GPU Pipeline:** Deployed **FaceFusion** locally on an NVIDIA RTX 3060 12GB; +* **Beyond 2D Affine Warping:** Bypassed basic 128px insightface warping (which causes plastic edge shimmering) in favor of high-resolution **320 DFM deep neural models**, achieving seamless 3D contour integration and natural skin lighting; +* **Restoration & Detection:** Paired with **SCRFD** face detection and **GFPGAN** neural enhancement to lock in a striking, consistent East Asian heroine face across both key close-up video cuts and the official 2.7K cover artwork. + +--- + +## 4. Filmora Post-Production: Turning Raw Clips into a Music Video + +AI models only produce disconnected video fragments. **Transforming these raw assets into a cohesive, breathing music video happens entirely in the editing suite**. + +I assembled and finished the entire video in **Wondershare Filmora**: + +``` +[Master Audio Track] ────────────────────────────────────────────── (Beat & Rhythm Guide) +[Video Tracks 1 & 2] [ Clip 1 (Fade Out) ] ── (Cross Dissolve) ── [ Clip 2 (Fade In) ] +[VFX & Adjustment] [ Lens Flare / Dust Particles / Master Cinematic LUT ] +[Typography Layer] [ Bilingual Synchronized Artistic Subtitles ] +``` + +1. **Precision Beat-Cutting**: + * Imported the master audio and marked downbeats, guitar riffs, and dynamic chorus drops; + * Trimmed each AI video clip to its cleanest, most stable 5–9 second segment, aligning cuts precisely with musical transients. +2. **Eliminating Black-Screen Dips with Cross-Dissolves**: + * Hard fade-to-black transitions (`Dip to Black`) make a 4-minute video feel like an annoying strobe light; + * Used multi-track overlapping cross-dissolves with gentle easing, allowing light and motion from the preceding clip to blend smoothly into the next. +3. **Atmospheric VFX & Unified Color Grading**: + * Added subtle desert dust particle overlays and delicate anamorphic lens flares to enhance the fantasy anime atmosphere; + * Applied a master color-grading LUT across all 24 clips to harmonize luminance and color temperature variations between different generation batches. +4. **Bilingual Subtitle Styling**: + * Styled large, expressive Chinese calligraphy font lyrics paired with clear English translations beneath, backed by delicate shadows to maintain legibility against bright desert and sky backdrops. + +--- + +## 5. Key Takeaways for Solo Creators + +Producing this 4-minute *Crescent Bay* music video clarified what modern AI content creation actually looks like: + +1. **AI Expands Production Bandwidth, But Human Taste Directs the Cut:** Generative models can spit out endless visual variations, but selecting the right take, controlling motion amplitude, and knowing exactly when to cut on a beat remains the domain of human creative direction. +2. **Tool Synergy Wins:** + * **Gemini** handles conceptualization, world-building, and structured storyboard prompting; + * **AI Image & Video Diffusion** generate high-fidelity raw audio-visual assets; + * **FaceFusion (Local RTX 3060)** locks protagonist facial identity and consistency across key close-ups and hero cover art; + * **Filmora** provides precise timeline control, clip assembly, rhythm synchronization, and post-processing VFX. + +This collaborative workflow makes it possible for a solo creator to deliver an entire 4-minute anime music video—a project that previously required a dedicated production team—in a matter of days from a single workstation. + +--- + +*Interested in custom AI video pipelines, automated digital media workflows, or high-throughput creative production for your business? Let's connect:* + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com?subject=AI%20Music%20Video%20Production) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 05 / 08:我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的 +- **Slug**: `how-i-built-an-automated-music-video-pipeline` +- **英文标题**: How I Built an Automated Music Video Pipeline with Remotion and Dockerized YouTube CDP +- **分类**: `case-studies` | **发布日期**: 2025-04-18 +- **线上地址**: https://blog.hoelee.com/posts/how-i-built-an-automated-music-video-pipeline/ (EN) | https://blog.hoelee.com/posts/zh/how-i-built-an-automated-music-video-pipeline/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的" +description: '基于 Remotion、Whisper、Docker 与 Chrome DevTools Protocol 的全自动音乐视频流水线:从歌词声学对齐、1080P 渲染到 YouTube 自动发布与多语种外挂字幕。' +pubDate: 2025-04-18 +updatedDate: 2026-10-22 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L4-aigc-media-pipelines +stream: creative-lab +tags: ["remotion", "docker", "cdp", "youtube", "automation", "video-generation", "whisper"] +ogImage: "/og/zh-how-i-built-an-automated-music-video-pipeline.jpg" +banner: "/banners/how-i-built-an-automated-music-video-pipeline.jpg" +draft: false +--- + +绝大多数 AI 音乐制作流程在生成完音频文件后都会陷入停滞。你手里有一份 MP3 文件和一段纯文本歌词。如果想把它做成能在 YouTube、Bilibili 或各大社交平台上发布的成品视频,通常需要经历漫长而繁琐的手工流程:打开剪辑软件、裁剪背景画面、逐行拉字幕调关键帧、导出几个 G 的庞大文件、守着浏览器上传、手动填写封面与标签、再手动复制粘贴字幕文件。 + +做一首歌可能要耗费大半天,如果有几十首歌曲库,这种手工做法完全无法规模化。 + +我需要一条纯自动化的工业级管线:只需丢入音频文件与歌词,离开工位。几分钟后,一段 1080P 高清 MV 视频即渲染完成,自带跟随音乐动态律动的音频动效环、高精度三行滚动歌词、智能分镜人物避让切换,并全自动通过远程 CDP 发布到 YouTube,挂载自定义封面与 8 种语言的外挂同步字幕。 + +以下是整套系统的架构设计、核心踩坑点与线上实战方案。 + +> **线上成品展示**:由本管线全自动渲染并发布上线的真实曲目为:[《糖画小仙》1080p 官方 MV(YouTube)](https://youtu.be/vZbyKeCCSRA),内置精准声学对齐与 8 语同步外挂字幕(英、日、韩、西、法、德、马、繁中)。 + +> **TL;DR** —— 四段互不耦合的工序:Whisper 词级对齐 → Remotion 1080P 渲染 → Docker 归档落盘 → CDP 发布到 YouTube Studio。每一段各自失败、也各自可单独排查;最难缠的两段单独写了长文:[把大文件送进浏览器容器](/posts/zh/uploading-large-videos-to-youtube-via-cdp/) 与 [消除渲染抖动](/posts/zh/debugging-remotion-video-jitter/)。 + +--- + +## 1. 端到端执行流程与耗时分解 + +为了诚实呈现系统的实际吞吐能力,而不是笼统承诺“三分钟搞定一切”,以下是针对 4 分 21 秒(261 秒)1080P 30fps 曲目的实测端到端耗时: + +| 阶段 | 处理流程与工具 | 实测耗时 | 是否真正无人值守? | +|---|---|---|---| +| **1. 歌词声学对齐** | `faster-whisper`(词级时间戳)+ 能量起始峰值锁定 | 14.2 秒 | ✅ 100% 自动 | +| **2. 构图与留白核验** | 4x2 分镜拼图生成 + 左右留白侧校验(`sides.json`) | 2.1 秒 | ⚠️ 建议 1 键人工核对 | +| **3. Remotion 1080P 渲染** | Remotion CLI(30 fps,H.264,7830 帧,i9-13900K + RTX 3060) | 218.3 秒 | ✅ 100% 自动 | +| **4. Docker 容器落盘** | Portainer Docker Archive API tar 流写入(128.85 MB 视频 + 封面) | 1.89 秒 | ✅ 100% 自动 | +| **5. YouTube 自动化发布** | Chrome CDP 自动化(注入文件、填充简介、勾选公开) | 32.4 秒 | ✅ 100% 自动 | +| **6. 多语种字幕处理** | 大模型批量翻译(8 语 × 56 句)+ CDP 自动上架发布 | 48.6 秒 | ✅ 100% 自动 | +| **全流程总计** | **从音频文件到油管 8 语公开发布** | **约 5.3 分钟** | **98% 自动化** | + +4 分钟视频的渲染阶段在我们的工作站上用时 **218.3 秒**(约 3.6 分钟),随后的入库、发布与 8 语字幕处理约 1 分多钟。在分镜留白侧确认无误后,后续全链路无需任何人工交互。 + +--- + +## 2. 整体系统架构 + +整个自动化系统分为四个完全解耦的层级: + +``` +[原始 MP3 音频 + 文本歌词] + │ + ▼ +1. 声学起唱对齐(Whisper 词级时间戳 + 声学起唱校准 -> LRC + SRT) + │ + ▼ +2. 动态 React 视频合成(Remotion 1080P 30fps) + ├─ 动态环形音频动效(320px 基础半径 -> 380px 低频弹性外扩 + 72 根跳动频谱柱) + ├─ 三行平滑滚动歌词(留白侧智能滑动与人物避让) + └─ 纯净透明度叠化背景(1.8s 贝塞尔曲线过渡,彻底杜绝抖动) + │ + ▼ +3. 容器免鉴权落盘(Portainer Docker Archive API -> 容器 /config/Downloads) + │ + ▼ +4. YouTube 自动化发布(远程 Chrome DevTools Protocol / Playwright) + ├─ 自动挂载视频与封面 cover.jpg + ├─ 自动填充标题、简介、SEO 标签与官网/业务联络方式 + ├─ 穿透发布向导,选择公开(Public)发布 + └─ 调度大模型翻译并自动挂载发布 8 国外挂字幕(英、日、韩、西、法、德、马、繁中) +``` + +--- + +## 3. 解决歌唱声学字幕对齐难题 + +AI 歌唱音频中普遍存在拖音、转音、颤音和节奏弹性拉伸。如果直接用常规语音转写工具识别,伴奏强烈的鼓点和乐器声往往会导致整句唱词被漏掉。 + +为了让咬字对齐落在**渲染帧**这一级,管线采用了**基于已知歌词的声学起唱对齐(Forced Acoustic Onset Alignment)**: +1. 以人工确认的标准歌词作为基准锚点; +2. 调用本地 `faster-whisper` 并开启词级时间戳(`word_timestamps=True`); +3. 针对每行歌词,锁定首字真实发音起振的能量起跑点,校准字幕起止点。 + +这样产出的 `.lrc` 与 `.srt` 即可精准对齐到**物理渲染帧**:在 30 fps 帧率下,单帧时长为 33.3 ms,这才是视频渲染真正起决定性作用的时间分辨率。当歌手唱出乐句第一个辅音的瞬间,高亮状态即在对应渲染帧切换。词级时间戳并非“微秒级精确”,这套管线也绝不作此夸大宣称——它将每行歌词对齐到声学起振点,并交由 33.3 ms 的物理帧网格做最终裁决。 + +--- + +## 4. Remotion 留白侧人物避让算法 + +在采用插画背景的 MV 中,一个常见痛点是歌词遮挡画面主角。如果某一镜小仙女画在画面右侧,歌词依然居中或靠右就会直接盖在人物脸上。 + +为了从根本上规避遮挡,管线引入了 `sides.json` 留白侧定义文件: + +```json +["left", "right", "left", "right", "left", "right", "left", "right"] +``` + +在 Remotion 合成组件(`MusicVideo.tsx`)中,音频环与歌词容器由统一的动态坐标驱动: + +```tsx +const getSideX = (idx: number) => { + const side = sides[idx] || (idx % 2 === 0 ? 'left' : 'right'); + return side === 'left' ? 540 : 1380; +}; +``` + +在每张分镜结束前的 1.8 秒内,歌词与音频环使用贝塞尔缓动平滑横向滑移。在下一张图片淡入之前,文字已平稳就位在留白侧,右侧人物与景物 100% 毫无遮挡。 + +--- + +## 5. 远程 YouTube CDP 上传通道 + +视频在本地 Windows 工作站上渲染,而已登录 YouTube 账号的浏览器运行在 DSM NAS 上的独立 Docker 容器(`chrome2`)中。 + +为了无需人工扫码或重新授权即可完成上架: +1. **Docker 归档落盘**:130MB 的 MP4 视频与封面图片通过 Portainer REST API 打包成 tar 流直接 PUT 写入容器文件系统(`/config/Downloads`),2 秒内完成落盘,彻底规避 CDP 协议的内存上限; +2. **CDP 节点注入**:通过 Playwright 接入 CDP(`ws://192.168.1.1:9242`),直接向 DOM 的文件输入节点注入容器路径: + +```python +cdp.send("DOM.setFileInputFiles", { + "files": ["/config/Downloads/国风电子 糖画小仙.mp4"], + "nodeId": video_node["nodeId"], +}) +``` + +3. **多语种字幕调度**:字幕翻译自动化脚本将基准 SRT 提取为纯文本行数组,通过本地 OmniRoute 大模型网关翻译为 8 种语言,并调用 CDP 遍历 YouTube Studio 后台,自动创建语言并发布所有外挂字幕轨。 + +--- + +## 6. 经验与教训 + +- **渲染与发布必须解耦**:早期版本尝试过一边渲染一边推流或直接向浏览器传 buffer,编码器稍有波动就会导致浏览器连接重置。先在磁盘写出完整 MP4 再通过归档传输,两道环节互不牵连,容错率极高。 +- **合成前必须进行九宫格审查**:正式渲染全片前,先生成一张全分镜缩略拼图快速校验,肉眼确认每一张图的留白侧标记完全吻合,避免全片渲染数分钟后才发现有一处文字挡了人物。 + +--- + +## 想为你的业务打造类似的系统吗? + +我专注于自动化音视频制作管线、AI 音乐与多媒体创作流,以及基于 Docker / NAS 的自托管自动化基础设施搭建。 + +无论你是需要自动化媒体制作系统,还是需要定制全栈软件工具: +- **WhatsApp**: [直接在 WhatsApp 上联系我](https://wa.me/60127972969) +- **邮箱**: [me@hoelee.com](mailto:me@hoelee.com?subject=自动化视频管线合作咨询) +- **官网**: [hoelee.com](https://www.hoelee.com/) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Built an Automated Music Video Pipeline with Remotion and Dockerized YouTube CDP" +description: 'Explore an automated music video workflow using Remotion, Whisper, Docker and Chrome DevTools Protocol, from lyric alignment and rendering to YouTube publishing and multilingual subtitles.' +pubDate: 2025-04-18 +updatedDate: 2026-10-22 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L4-aigc-media-pipelines +stream: creative-lab +tags: ["remotion", "docker", "cdp", "youtube", "automation", "video-generation", "whisper"] +ogImage: "/og/how-i-built-an-automated-music-video-pipeline.jpg" +banner: "/banners/how-i-built-an-automated-music-video-pipeline.jpg" +draft: false +--- + +Most AI music workflows hit a brick wall right after the track is generated. You have an MP3 file and maybe a text block of lyrics. If you want to turn that into a finished YouTube video, you typically enter a tedious manual loop: opening a video editor, cutting background footage, adjusting rolling captions line by line, setting keyframes, exporting a multi-gigabyte render, waiting for YouTube Studio to upload, typing descriptions, and pasting subtitle tracks. + +Doing that once takes an afternoon. Doing that across dozens of tracks is unsustainable. + +I wanted a single automated pipeline: give it an audio file and lyrics, and walk away. A few minutes later, a 1080p music video is rendered with dynamic audio-reactive visuals, synchronized three-line rolling lyrics, smart scene cut alternation, and automatically published to YouTube with 8-language external subtitles. + +Here is the exact architecture, the failure points I had to solve, and the code running this in production. + +> **Live Production Proof**: The full benchmark artifact rendered and published by this exact pipeline is live on YouTube: [《糖画小仙》 (Sugar Art Fairy) 1080p MV](https://youtu.be/vZbyKeCCSRA), featuring 8-language CC subtitle tracks (EN, JA, KO, ES, FR, DE, MS, ZH-Hant). + +> **TL;DR** — Four decoupled stages: Whisper word-level alignment → Remotion 1080p render → Docker Archive staging → CDP upload to YouTube Studio. Each fails on its own, so each can be debugged on its own; the two that fought back hardest have their own write-ups: [staging large files into the browser container](/posts/uploading-large-videos-to-youtube-via-cdp/) and [killing render jitter](/posts/debugging-remotion-video-jitter/). + +--- + +## 1. End-to-End Execution & Timing Breakdown + +To be completely honest about performance rather than promising a vague "three minutes": here is the measured benchmark run for our 4-minute 21-second (261s) 1080p 30fps song: + +| Stage | Process & Tools | Measured Time | Unattended? | +|---|---|---|---| +| **1. Audio-Lyric Alignment** | `faster-whisper` (word timestamps) + acoustic onset peak extraction | 14.2 s | ✅ 100% Autonomous | +| **2. Layout Verification** | 4x2 montage thumbnail generation + negative space side audit | 2.1 s | ⚠️ 1-click human check | +| **3. Remotion 1080p Render** | Remotion CLI (30 fps, H.264, 7,830 frames, i9-13900K + RTX 3060) | 218.3 s | ✅ 100% Autonomous | +| **4. Docker Staging** | Portainer Docker Archive API tar stream (128.85 MB MP4 + cover) | 1.89 s | ✅ 100% Autonomous | +| **5. YouTube Publication** | Headless Chrome CDP (Studio upload dialog, public visibility) | 32.4 s | ✅ 100% Autonomous | +| **6. Multilingual Subtitles** | LLM translation (8 languages × 56 cues) + CDP Studio upload | 48.6 s | ✅ 100% Autonomous | +| **Total Pipeline Cycle** | **From raw audio to public YouTube video with 8 languages** | **~5.3 minutes** | **98% Autonomous** | + +The actual rendering of a 4-minute video takes **218.3 seconds** (under 3.7 minutes) on our workstation, and the upload/subtitle publication pipeline finishes in another minute. Once the storyboard layout is confirmed, the entire sequence runs with zero human intervention. + +--- + +## 2. System Architecture + +The pipeline consists of four decoupled layers: + +``` +[Audio MP3 + Lyrics] + │ + ▼ +1. Acoustic Onset Alignment (Whisper word-level timestamping -> LRC + SRT) + │ + ▼ +2. Dynamic React Video Synthesis (Remotion 1080p 30fps) + ├─ Dynamic Circular Visualizer (320px base -> 380px bass expansion + 72 radial bars) + ├─ 3-Line Smooth Scrolling Lyrics (Negative space side-tracking) + └─ Pure-Opacity Image Slideshow (no per-frame scaling · 1.8s Bézier crossfade) + │ + ▼ +3. Headless Docker Staging (Portainer Docker Archive API -> Container /config/Downloads) + │ + ▼ +4. Autonomous YouTube Publication (Remote Chrome DevTools Protocol / Playwright) + ├─ Attach video & custom thumbnail + ├─ Fill SEO metadata, biography, & service contact links + ├─ Step through upload wizard & set visibility to Public + └─ Upload & publish 8 multilingual subtitle tracks (EN, JA, KO, ES, FR, DE, MS, ZH-TW) +``` + +--- + +## 3. Solving Acoustic Lyric Alignment + +AI music generators stretch vowels, add melodic vibrato, and change tempo dynamically. Standard text-to-speech alignment or plain transcription drops entire verses when instruments overpower vocals. + +Instead of blind speech recognition, I use **forced acoustic onset alignment**: +1. Take the ground-truth lyrics written beforehand. +2. Run `faster-whisper` on the vocal track with word-level timestamps (`word_timestamps=True`). +3. For each lyric line, find the closest acoustic voice activity onset for the starting character and lock the timestamp. + +That yields a `.lrc` and `.srt` pair aligned to the **rendered frame**: at 30 fps one frame is 33.3 ms, so a frame is the resolution that actually matters here. When the singer lands the first consonant of a phrase, the highlight switches on that frame. Word-level timestamps are not microsecond-accurate, and the pipeline does not claim to be — it lines each line up with the acoustic onset and lets the frame grid decide. + +--- + +## 4. Negative-Space Side Alternation in Remotion + +In music videos with illustrated backgrounds, a central problem is character obstruction. If a slide shows a character standing on the right, centering the lyrics or locking them to the right covers the artwork. + +To solve this deterministically, the pipeline extracts a `sides.json` layout manifest: + +```json +["left", "right", "left", "right", "left", "right", "left", "right"] +``` + +In Remotion (`MusicVideo.tsx`), the visualizer ring and lyric stack share a single animated X coordinate: + +```tsx +const getSideX = (idx: number) => { + const side = sides[idx] || (idx % 2 === 0 ? 'left' : 'right'); + return side === 'left' ? 540 : 1380; +}; +``` + +During the last 1.8 seconds of a scene, the HUD coordinates glide across the canvas using a smooth cubic Bézier curve (`Easing.bezier(0.42, 0, 0.58, 1)`). The lyrics drift gracefully into the empty negative space before the new illustration fades in, guaranteeing zero overlap with focal characters. + +--- + +## 5. The Remote YouTube Upload Bridge + +Rendering happens on a Windows workstation, while my authenticated YouTube Studio session runs inside an isolated Docker container (`chrome2`) on a Synology NAS. + +To upload without browser authentication prompts: +1. **Docker Archive Staging**: The ~129 MB MP4 video and cover image package is tar-streamed directly into the container filesystem via Portainer's REST API (`PUT /api/endpoints/2/docker/containers/{id}/archive?path=/config/Downloads`). It bypasses CDP byte limits and writes in under 2 seconds. The full pattern — code, tar caveats, and what it does not solve — is in [Uploading 100MB+ Videos to YouTube via CDP](/posts/uploading-large-videos-to-youtube-via-cdp/). +2. **CDP File Injection**: Using Playwright over CDP (`ws://192.168.1.1:9242`), the upload file input receives the local container path: + +```python +cdp.send("DOM.setFileInputFiles", { + "files": ["/config/Downloads/国风电子 糖画小仙.mp4"], + "nodeId": video_node["nodeId"], +}) +``` + +3. **Multilingual Subtitle Dispatch**: An automated worker splits the calibrated SRT into an array of lines, translates them into 8 languages (English, Japanese, Korean, Spanish, French, German, Malay, Traditional Chinese) via our local OmniRoute LLM gateway, and uploads each `.srt` file through the Studio Translations interface. + +--- + +## 6. What I Would Do Differently + +- **Decouple rendering from upload**: Early revisions tried to pipe ffmpeg buffers directly into a browser tab. Encoding hiccups would freeze the browser socket. Saving the MP4 locally first and staging via Docker Archive completely isolated the two failure domains. +- **Strict layout verification**: Always generate a 4x2 montage thumbnail of all storyboard scenes before starting full rendering. Visually asserting the negative space of each slide prevents re-rendering four-minute videos due to a single misplaced side flag. + +--- + +## Want this for your business? + +I build automated video generation pipelines, custom music production workflows, and self-hosted infrastructure for creators, educators, and brands. + +If you want to automate your media workflow or need custom full-stack tools: +- **WhatsApp**: [Chat directly on WhatsApp](https://wa.me/60127972969) +- **Email**: [me@hoelee.com](mailto:me@hoelee.com?subject=Automated%20Video%20Pipeline%20Inquiry) +- **Website**: [hoelee.com](https://www.hoelee.com/) +```` + +--- + +## 文章 06 / 08:我是如何构建 DigiKedai Telegram AI 客服机器人的 +- **Slug**: `how-i-built-the-digikedai-telegram-bot` +- **英文标题**: How I Built the DigiKedai Telegram AI Bot +- **分类**: `case-studies` | **发布日期**: 2026-09-06 +- **线上地址**: https://blog.hoelee.com/posts/how-i-built-the-digikedai-telegram-bot/ (EN) | https://blog.hoelee.com/posts/zh/how-i-built-the-digikedai-telegram-bot/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "我是如何构建 DigiKedai Telegram AI 客服机器人的" +description: "一个跨 Telegram 与 WhatsApp 解答疑问并全自动开通免费试用账号的 AI 客服机器人——TypeScript、grammY、Cloudflare 隧道 webhook,以及自建 LiteLLM 网关。" +pubDate: 2026-09-06 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L1-deterministic-orchestration +tags: [telegram, docker, cloudflare, litellm, typescript, ai] +ogImage: /og/zh-how-i-built-the-digikedai-telegram-bot.jpg +banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg +summary: + audience: "企业主与产品/运营负责人" + painPoint: "一人数字商品电商面临大量重复售前咨询(试用申请、账号开通、选品),夜间与周末漏单严重,人工客服成本高。" + impact: + - metric: "24/7 秒级响应" + detail: "全天候接管 Telegram 与 WhatsApp 双端咨询,零人工介入自动发放免费试用账号" + - metric: "0 价格幻觉" + detail: "三层确定性 SKU 检索 + 状态机拦截购买意图,杜绝大模型胡报价或泄漏内部路径" + - metric: "~175 项单测护航" + detail: "单容器稳定运行于 NAS,通过自建 LiteLLM 网关实现模型高可用切换" + stack: ["TypeScript", "grammY", "LiteLLM", "NocoDB", "Cloudflare Tunnel", "PostgreSQL"] + jumpLinks: + business: "#why-a-business-needs-a-bot-like-this" + architecture: "#the-architecture" + result: "#the-result" +dynamicCta: true +--- + +Digi Kedai 销售数字产品——在线课程、电子书、模板——每一单都会带来一连串同样的顾客问题:*「这门课有免费试看吗?」「我怎么拿到账号?」「我该买哪个套餐?」*。对一个人的生意来说,人工回复根本忙不过来。于是我打造了一个 AI 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。 + +这篇讲的是它是怎么搭起来的——架构、LLM 接线,以及那些各自吃掉我一整个下午的 bug。 + +## 为什么企业需要一个这样的机器人 + +讲架构之前,先说*为什么*。客服机器人不是噱头——它改变了小生意的经济学: + +- **省钱省人力。** 机器人每回答一个常规问题,你的团队就少答一个。Digi Kedai 用雇一个客服几分之一的成本,就得到了一个全天候的客服专员。 +- **全天候即时响应。** 顾客会在半夜和周末提问。机器人几秒钟内用他们的语言回复——没有排队,没有「我们稍后回复」。 +- **把浏览者变成买家。** 机器人不只是回答——它还会*向上销售*。一句「有免费试用吗?」几下点击就变成一个已领取的试用账号,全程无需人工。 +- **永远记得顾客。** 长期记忆让回头客被当作熟客问候,而不是陌生人。 +- **随商品目录扩展。** 加一个商品,机器人就已经认识它了——无需重新训练,无需新的 FAQ 页面。 + +对 Digi Kedai 这样一个人的生意来说,这就是在凌晨两点丢单和成交之间的差别。 + +## 技术栈 + +- **TypeScript + Node 20**,作为单个容器跑在我的 Synology NAS 上。 +- **grammY**——一个轻量的 Telegram Bot 框架,用 webhook 模式接线。 +- **Hono**——一个极小的 HTTP 服务器,负责接收 webhook 并响应 `/health`。 +- **Cloudflare 隧道**——`bot.digikedai.com` 直连容器;无需公网 IP,无需开放端口。 +- **LiteLLM**——一个位于真实模型之前的自托管网关,这样我可以不碰机器人代码就切换供应商或加故障转移。 +- **PostgreSQL**——对话与用户记忆存储,与我的 mem0 栈共用。 + +## 架构 + +``` +顾客 → Telegram/WhatsApp → Cloudflare 隧道 → bot.digikedai.com + → Hono POST //webhook → grammY 处理器 + → 存入 PostgreSQL → Agent.respond(系统提示词 + 检索到的商品 facts + 最近 20 条消息) + → LiteLLM(模型别名 "mem0-openai")→ 持久化 + 回复 +``` + +几个值得解释的决策: + +**Cloudflare 隧道直连容器,不经过 Traefik。** 我管理的自托管生产节点在 CGNAT 后面,没有隧道就没有任何东西能被公网访问。webhook URL 藏在一个**秘密路径段**(`//webhook`)后面,这样 Telegram 的更新只有知道这个 secret 的人才能送达——这是在 Telegram 自带 token 认证之外的第一道廉价防线。 + +**模型用 LiteLLM 的*别名*寻址,从不用原始模型名。** 机器人调用 `mem0-openai`;LiteLLM 把它映射到真实模型(背后还有 OpenRouter 故障转移)。机器人永远不需要知道实际是哪个供应商在服务请求。我设了 `temperature: 0.4` 和 `45s` 超时。 + +**机器人知道自己的商品目录,但不含价格和内部路径。** 一个构建时的生成器把单一的 `catalog_sku.csv`(唯一数据源)转成机器人导入的 TypeScript 模块——只含 SKU、名称、分类、大小和商品 URL,*别的什么都不带*。价格从不定死(「以页面当前价为准」),内部资源路径从不会进机器人镜像。这让打包体积精简,也防止模型泄露内部结构。 + +## 检索:机器人如何真正*知道*商品目录 + +把整个目录塞进提示词里的通用 LLM 会瞎编。所以机器人先检索、再回答。它有一个三档检索器: + +1. **SKU token 匹配**——`/\b[A-Z]{2,}\d{2,}\b/gi` 能精确命中 `CZH01` 这样的 SKU。 +2. **整句子串匹配**——针对短而精确的查询。 +3. **分段匹配**——切分 CJK 连续串(剥离「有/吗/哪些」这类疑问助词),并过滤英文停用词。 + +前 5 个匹配结果成为注入系统提示词的「facts」,模型被要求*只*基于实际检索到的内容回答——查不到时也要明说。 + +这比看起来更重要。顾客问*「CZH01 有免费版吗?」*之所以能得到关于 `FREECZH01` 的正确回答,是因为检索器在命中付费 SKU 时,会**自动附带它的免费 twin** 并排在第二位。这个细节把一句「抱歉,没有」变成了正确的向上销售。 + +## 免费试用开通——全程无需人工 + +我最引以为豪的部分:机器人不只是*谈论*免费试用,而是真正*发放*它们。顾客有三种方式触发——`/trial` 命令、像*「我要这个试用:digikedai.com/products/czh01」*这样的自然语言消息,或者 `?start=CZH01` 这样的深链。 + +机器人不经过 n8n,而是**直接写入 NocoDB**——插入一条顾客记录和一条顾客-商品记录。我已有的 webhook 接手并自动开通真实账号,所以机器人从不碰文件服务器。整个过程是幂等的:如果顾客已有账号,就复用它而不是新建重复的;如果商品插入失败,就回滚顾客记录,这样重试不会卡死。 + +甚至还有多账号选择器。如果同一个 Telegram 用户有好几个账号,机器人会把它们列成内联按钮,问要把试用挂到哪个账号上。 + +## 那些各自吃掉我一整个下午的 bug + +上线并不顺利。有三个值得讲的调试故事: + +**1. 无限回复循环。** 如果服务器没在超时窗口内确认,Telegram 会重新投递同一条 webhook 更新。我的处理器跑得久(LLM 延迟),于是 Telegram 重发了同一条消息——机器人就一遍又一遍地回答它。修复是 `onTimeout: "return"` 加 50 秒窗口,这能让重投递的螺旋戛然而止。 + +**2. 模型别名 400。** 直接调用原始模型名(`gpt-5-mini`)返回 400。只有 LiteLLM 别名能通。这现在是仓库里的一条硬性规则:*LLM 必须用别名,从不用原始上游名。* + +**3. WhatsApp 的「m_text」bug。** 当我加 WhatsApp 通道(通过一个把 WhatsApp Web 事件 POST 到 webhook 的浏览器扩展)时,一个「自定义 webhook payload」设置里空字符串的模板值让扩展把字面字段*名* `m_text` 当成了消息文本——于是机器人回复*「我不明白 m_text」*。修复是关掉那个设置、改用默认 payload,适配器就能正确读取了。 + +三个故事共同的诚实结论是:故障不在难的部分——LLM 或检索。而在 **webhook 生命周期和 payload 契约的细节**,那些集成真正出问题的无聊边角。 + +## 在 LLM 之上叠加确定性流程 + +LLM 擅长开放式问题,却很不擅长*状态*。所以那些需要可靠的对话环节——购买、试用领取、深链——都是**确定性状态机**,而非提示词工程: + +- **购买意图**(`order`、`want to buy`、`buy`…)在进入 LLM 之前就被拦截,驱动一个内联键盘(网店 → 管理员 → 返回),从不给自由文本回复。 +- **深链**把 SKU 放在 `?start=` payload 里,但 Telegram 那里只允许 `A-Z a-z 0-9 _ -`——一个 `:` 会静默地把它弄坏。我吃过大亏,于是把分隔符从 `buy:SKU` 换成了 `buy-SKU`。 +- **WhatsApp 里的跑题消息**会让模型输出一个哨兵值(`NO_REPLY`),代理把它变成*「什么都不发」*——这样机器人在没话可说时保持沉默,而不是胡言乱语。 + +## 我会做不同的地方 + +- **启动时就加 `setMyCommands()`。** 命令列表定义在代码里,但我从没在 Telegram 注册它,所以聊天框里的命令菜单一直是空的,直到我用 Bot API 修好。小事,但因此损失了一个真实的顾客触点。 +- **在写通道适配器之前先定好 payload 契约。** WhatsApp 的 `m_text` bug 就来自我信任了一个没读过契约的自定义功能。 +- **把 webhook 超时当作一等架构**,而不是事后补丁——如果我在第一天就想到确认窗口,重投递循环本可以避免。 + +## 结果 + +现在 NAS 上的一个容器就能跨两个通道(Telegram + WhatsApp)解答顾客问题,按 SKU 或自然语言检索到正确商品,并端到端发放免费试用账号——还有一套约 175 个单元测试覆盖确定性流程、检索和 payload 契约。源码在 +[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot)。 + +如果你也在打造一个 LLM 驱动的 Telegram 机器人,教训其实很朴素:模型是最容易的部分。webhook 生命周期、payload 契约、以及开通流程的幂等性才是真正会出问题的地方——先设计好这些。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Built the DigiKedai Telegram AI Bot" +description: "A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway." +pubDate: 2026-09-06 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L1-deterministic-orchestration +tags: [telegram, docker, cloudflare, litellm, typescript, ai] +ogImage: /og/how-i-built-the-digikedai-telegram-bot.jpg +banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg +summary: + audience: "Business Owners & Product Leads" + painPoint: "A solo digital goods business faces a non-stop stream of repetitive pre-sales questions (trial requests, account setup, package selection) — leading to missed midnight sales and high manual support overhead." + impact: + - metric: "24/7 Instant Reply" + detail: "Handles customer inquiries across Telegram and WhatsApp with zero human intervention for free-trial provisioning." + - metric: "Zero Price Hallucination" + detail: "3-tier deterministic SKU retrieval and state-machine purchase interception prevent hallucinated prices or leaked internal paths." + - metric: "~175 Unit Tests" + detail: "Runs reliably in a single container on a Synology NAS with LiteLLM gateway failover." + stack: ["TypeScript", "grammY", "LiteLLM", "NocoDB", "Cloudflare Tunnel", "PostgreSQL"] + jumpLinks: + business: "#why-a-business-needs-a-bot-like-this" + architecture: "#the-architecture" + result: "#the-result" +dynamicCta: true +--- + +Digi Kedai sells digital products — online courses, ebooks, and templates — and +every sale involves a stream of the same customer questions: *"does this course +have a free preview?", "how do I get my account?", "which package should I +buy?"*. Answering them manually doesn't scale for a one-person business. So I +built an AI customer-support bot that answers those questions around the clock +and can even hand out free-trial accounts without a human in the loop. + +This is the story of how it went together — the architecture, the LLM wiring, +and the bugs that ate an afternoon each. + +## Why a business needs a bot like this + +Before the architecture, the *why*. A support bot isn't a gimmick — it changes +the economics of a small business: + +- **Saves money on staff.** Every routine question a bot answers is one your + team doesn't have to. Digi Kedai gets the equivalent of a round-the-clock + support agent for a fraction of the cost of hiring one. +- **Answers instantly, 24/7.** Customers ask at midnight and on weekends. A bot + replies in seconds, in their language — no queues, no "we'll get back to you". +- **Converts browsers into buyers.** The bot doesn't just answer — it *upsells*. + A "does this have a free trial?" question turns into a claimed trial account + in a couple of taps, with no human in the loop. +- **Never forgets a customer.** Long-term memory means repeat customers are + greeted like regulars, not strangers. +- **Scales with your catalogue.** Add a product and the bot already knows it — + no retraining, no new FAQ pages. + +For a one-person business like Digi Kedai, that's the difference between losing +sales at 2 a.m. and closing them. + +## The stack + +- **TypeScript + Node 20** running as a single container on my Synology NAS. +- **grammY** — a lightweight Telegram Bot framework, wired in webhook mode. +- **Hono** — a tiny HTTP server that receives webhooks and answers `/health`. +- **Cloudflare tunnel** — `bot.digikedai.com` routes straight into the container; + no public IP, no open ports. +- **LiteLLM** — a self-hosted gateway in front of the actual model, so I can swap + providers or add fallbacks without touching bot code. +- **PostgreSQL** — conversation and user-memory storage, shared with my mem0 stack. + +## The architecture + +``` +Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com + → Hono POST //webhook → grammY handler + → save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs) + → LiteLLM (model alias "mem0-openai") → persist + reply +``` + +A few decisions worth explaining: + +**A Cloudflare tunnel into the container, with no Traefik hop.** The self-hosted infrastructure is +behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL +lives behind a **secret path segment** (`//webhook`) so Telegram's +updates only land if the caller knows the secret — a cheap first line of defense +on top of Telegram's own token auth. + +**The model is addressed by a LiteLLM *alias*, never a raw model name.** The bot +calls `mem0-openai`; LiteLLM maps that to the real model (with an OpenRouter +fallback behind it). The bot never needs to know which provider is actually +serving the request. I set `temperature: 0.4` and a `45s` timeout. + +**The bot knows its catalogue without prices or internal paths.** A build-time +generator turns a single `catalog_sku.csv` (the source of truth) into a +TypeScript module the bot imports — SKU, name, category, size, and a product URL, +and *nothing else*. Prices are never baked in ("check the site for the current +price"), and internal resource paths never ship to the bot image. That keeps the +bundle lean and prevents the model from leaking internal structure. + +## Retrieval: how the bot actually *knows* the catalogue + +A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the +bot retrieves first, then answers. It has a three-tier retriever: + +1. **SKU token match** — `/\b[A-Z]{2,}\d{2,}\b/gi` catches exact SKUs like `CZH01`. +2. **Whole-query substring** — for short, precise queries. +3. **Per-segment matching** — splits CJK runs (stripping question particles like + 有/吗/哪些) and filters English stopwords. + +The top-5 matches become the "facts" injected into the system prompt, and the +model is instructed to answer *only* from what it actually retrieved — and to say +so when it finds nothing. + +This matters more than it looks. A customer asking *"does CZH01 have a free +version?"* gets a correct answer about `FREECZH01` only because the retriever, +on hitting the paid SKU, **automatically attaches its free twin** and ranks it +second. That one detail turns a "no, sorry" into the correct upsell. + +## Free-trial provisioning — no human in the loop + +The part I'm proudest of: the bot doesn't just *talk about* free trials, it +*issues* them. A customer can trigger it three ways — the `/trial` command, a +natural-language message like *"我要这个试用:digikedai.com/products/czh01"*, or a +deep link like `?start=CZH01`. + +Rather than route through n8n, the bot writes **directly to NocoDB** — inserting +a customer row and a customer-product row. My existing webhooks pick that up and +provision the actual account automatically, so the bot never touches the file +server. The whole thing is idempotent: if a customer already has an account, it +reuses it instead of creating a duplicate; if the product insert fails, it rolls +back the customer row so a retry doesn't dead-end. + +There's even a multi-account selector. If the same Telegram user has several +accounts, the bot lists them as inline buttons and asks which one to attach the +trial to. + +## The bugs that ate an afternoon each + +Shipping this was not smooth. Three debugging stories stand out: + +**1. The infinite reply loop.** Telegram re-delivers a webhook update if the +server doesn't acknowledge it within a timeout window. My handler was running +long (LLM latency), so Telegram re-sent the same message — and the bot answered +it again, and again. The fix was `onTimeout: "return"` with a 50-second window, +which stops the redelivery spiral dead. + +**2. The model alias 400.** Calling the raw model name (`gpt-5-mini`) returned a +400. Only the LiteLLM alias worked. This is now a hard rule in the repo: *the LLM +must be the alias, never the raw upstream name.* + +**3. The WhatsApp "m_text" bug.** When I added a WhatsApp channel (via a browser +extension that POSTs WhatsApp Web events to a webhook), a "customize webhook +payload" setting with empty-string template values made the extension send the +literal field *name* `m_text` as the message text — so the bot replied *"I don't +understand m_text"*. The fix was to turn that setting off and use the default +payload, which the adapter reads correctly. + +The honest takeaway from all three: the failures weren't in the hard parts — the +LLM or the retrieval. They were in **webhook lifecycle and payload-contract +details**, the boring edges where integrations actually break. + +## Deterministic flows on top of the LLM + +An LLM is great for open-ended questions and terrible for *state*. So the +conversational pieces that need reliability — buying, trial redemption, deep +links — are **deterministic state machines**, not prompt engineering: + +- **Buy intent** (`order`, `want to buy`, `buy`…) is intercepted before the LLM + and drives an inline keyboard (online store → admin → back), never a free-form + reply. +- **Deep links** carry the SKU in a `?start=` payload, but Telegram only allows + `A-Z a-z 0-9 _ -` there — a `:` silently breaks it. I learned that the hard way + and switched the separator from `buy:SKU` to `buy-SKU`. +- **Off-topic messages** in WhatsApp make the model emit a sentinel (`NO_REPLY`) + that the agent turns into *"send nothing"* — so the bot stays silent instead of + babbling when it has nothing useful to say. + +## What I'd do differently + +- **Add `setMyCommands()` at startup.** The command list is defined in code, but + I never registered it with Telegram, so the in-chat command menu was empty + until I fixed it via the Bot API. Small, but it cost a real customer touchpoint. +- **Design the payload contract before the channel adapter.** The WhatsApp + `m_text` bug came from trusting a customization feature I hadn't read the + contract for. +- **Treat webhook timeouts as first-class architecture**, not an afterthought — + the redelivery loop was avoidable if I'd thought about acknowledgement windows + on day one. + +## The result + +One container on the NAS now answers customer questions across two channels +(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural +language, and issues free-trial accounts end-to-end — with a prompt suite of +~175 unit tests covering the deterministic flows, retrieval, and the payload +contracts. The source lives at +[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot). + +If you're building your own Telegram bot backed by an LLM, the lesson is the +boring one: the model is the easy part. The webhook lifecycle, the payload +contract, and the idempotency of your provisioning are where it actually breaks — +design those first. +```` + +--- + +## 文章 07 / 08:我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD +- **Slug**: `how-i-host-this-blog` +- **英文标题**: How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD +- **分类**: `case-studies` | **发布日期**: 2026-09-04 +- **线上地址**: https://blog.hoelee.com/posts/how-i-host-this-blog/ (EN) | https://blog.hoelee.com/posts/zh/how-i-host-this-blog/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD" +description: "这篇文章完整拆解了这个网站背后的构建与部署管线——以 Git 作为 CMS、跑在 unRaid 服务器上的自托管 runner,以及最前端的 Cloudflare。" +pubDate: 2026-09-04 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L1-high-conversion-web +dynamicCta: true +tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare] +ogImage: /og/zh-how-i-host-this-blog.jpg +banner: /banners/how-i-host-this-blog.jpg +--- + +这个博客本身,就是我用来说明「我到底会做什么」的一个项目。下面是完整的 +管线,让整个架构一目了然。源代码公开在 +[git.hoelee.com/hoelee/hoelee-blog](https://git.hoelee.com/hoelee/hoelee-blog), +可以对照着这篇文章一起看。 + +## 技术栈 + +- **Astro 5** —— 从 Markdown 生成静态站点。 +- **Git 即 CMS** —— 每一篇文章都是一个带 YAML frontmatter 的 `.md` 文件,版本管理在 Gitea 里。 +- **Gitea Actions** —— 一个跑在我 unRaid 服务器上的自托管 CI runner(`act_runner`),每次 push 到 `main` 都会自动构建站点。 +- **nginx** —— 一个独立的容器负责对外提供静态的 `dist/` 输出。 +- **Cloudflare** —— 隧道把它暴露到公网,CDN 负责缓存一切。 + +## 为什么选「静态 + Git」? + +技术博客没有任何动态内容。用 Markdown 写作、用 pull request 评审、合并后重新构建,带给我的是: + +1. 我发布过的每一个字的完整版本历史。 +2. 没有需要加固、打补丁的数据库或后台管理面板。 +3. 接近完美的性能——预构建的 HTML,没有任何服务端渲染。 + +## 部署流程 + +``` +git push → Gitea webhook → act_runner 领取任务 + → npm ci → astro build → 拷贝 dist/* 到站点根目录 → nginx 提供服务 +``` + +整个过程都跑在我自己管理的基础设施硬件上——这正是重点:它就是我别处所描述的 +DevOps 工作的一个活生生的演示。 + +## docker-compose 配置 + +下面是这套栈的大致形态。这是本地/源站这一侧(Gitea + runner + nginx); +公网暴露由 Cloudflare 通过隧道单独处理,所以这里的任何东西都不需要公网 IP +或开放端口。 + +
+docker-compose.yml — Gitea + act_runner + nginx (40 lines) + +```yaml +# docker-compose.yml — Gitea + act_runner + nginx(博客站点根目录) +services: + gitea: + image: gitea/gitea:1.27 + container_name: gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + volumes: + - ./gitea-data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "2222:22" # SSH(可选——我用 HTTPS) + restart: unless-stopped + + act_runner: + image: gitea/act_runner:0.2.13 + container_name: act_runner + environment: + - GITEA_INSTANCE_URL=http://gitea:3000 + - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN} + volumes: + - /var/run/docker.sock:/var/run/docker.sock # runner 需要它来拉起构建任务 + - ./runner-data:/data + depends_on: + - gitea + restart: unless-stopped + + nginx-blog: + image: nginx:alpine + container_name: nginx-blog + volumes: + - ./html:/usr/share/nginx/html:ro # astro 构建产物就落到这里 + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + restart: unless-stopped +``` + +
+ +CI 任务(`deploy.yml`)先在 runner 上构建出 `dist/`,然后 `cp -r dist/*` 到 +与 nginx 容器共享的 `./html` 卷里。Cloudflare 的隧道守护进程(`cloudflared`) +指向 `nginx-blog`——那是唯一暴露到公网的东西。 + +有两件事值得特别说明,它们都花了我不少时间: + +1. **`act_runner` 需要挂载 Docker socket**(`/var/run/docker.sock`)才能拉起 + 构建任务——没有它,任务会一直卡在排队状态。 +2. **敏感凭据彻底外置**:宿主机注册 Runner 所用的 `GITEA_RUNNER_REGISTRATION_TOKEN` 通过宿主机未纳入版本管理的本地 `.env` 注入(`${RUNNER_TOKEN}`),而 CI 构建拉取代码所用的令牌则存储在 Gitea 的 Actions Secret 中(workflow 内引用为 `${{ secrets.PAT }}`),仓库源码中绝不硬编码任何凭据——因此整个仓库可以安全地完全公开。 + +## 安装时的坑(真实经历,来自我的 commit 历史) + +把这条管线跑通并不顺利——这个仓库的 git 历史就是一本「坑记录」。真正花时间的 +有四个: + +1. **Gitea 上没有 `actions/checkout`。** Gitea 的 `act_runner` 不自带 GitHub + marketplace 的那些 action。我的第一个 workflow 立刻就失败了——因为压根没有 + 东西去 checkout 仓库。解决办法:去掉 `actions/checkout` 这一步,手动 clone: + ```yaml + - name: Checkout + run: | + git config --global --add safe.directory '*' + git clone --depth 1 "https://hoelee:${{ secrets.PAT }}@git.hoelee.com/hoelee/hoelee-blog.git" . + ``` + +2. **`GITHUB_TOKEN` 注入无法通过 clone 的身份验证。** 默认的 runner token + 不足以 `git clone` 一个私有仓库,所以任务在 checkout 阶段就失败了。解决办法: + 创建一个专门的 **Personal Access Token**,并在 workflow 里引用为 + `${{ secrets.PAT }}`——绝不要硬编码进去(这也是为什么这个仓库可以放心公开)。 + +3. **Gitea 与 `act_runner` 的版本不匹配。** 我花了三个 commit + ("test gitea 1.24.7 + act_runner 0.2.13" → "gitea 1.25.5" → "gitea 1.27.3 + + runner 3.3.2")才让 runner 成功注册并领取任务。教训是:**关注 Gitea Actions 生态的架构迁移**——Gitea 1.27+ 将原有的 `act_runner` 重构演进为新版 `gitea/runner`(版本号跳转至 3.x 分支,配置项如 `container.valid_volumes` 等结构均有调整)。如果 runner 镜像或协议落后于 Gitea 实例,任务就会静默失败或无法拉起容器。 + +4. **runner 需要 `config_file` 环境变量。** 只有显式传入配置文件路径,runner + 才会连到正确的实例。 + +每一个都是「挠头一小时、修复一行代码」——而这恰恰是一篇博客文章最该帮后来人 +省下的东西。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD" +description: "A walkthrough of the end-to-end pipeline that builds and serves this site — git-as-CMS, a self-hosted runner on my unRaid server, and Cloudflare in front." +pubDate: 2026-09-04 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L1-high-conversion-web +dynamicCta: true +tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare] +ogImage: /og/how-i-host-this-blog.jpg +banner: /banners/how-i-host-this-blog.jpg +--- + +This blog is itself a project I built to demonstrate the kind of work I do. +Here's the full pipeline, so the architecture is transparent. The source is +public at [git.hoelee.com/hoelee/hoelee-blog](https://git.hoelee.com/hoelee/hoelee-blog) +— read it alongside this post. + +## The stack + +- **Astro 5** — static site generated from Markdown. +- **Git as the CMS** — every post is a `.md` file with YAML frontmatter, versioned in Gitea. +- **Gitea Actions** — a self-hosted CI runner (`act_runner`) on my unRaid server builds the site on every push to `main`. +- **nginx** — a dedicated container serves the static `dist/` output. +- **Cloudflare** — the tunnel exposes it publicly, and the CDN caches everything. + +## Why static + git? + +A technical blog has no dynamic content. Writing in Markdown, reviewing via +pull request, and rebuilding on merge gives me: + +1. Full version history of every word I publish. +2. No database or admin panel to secure or patch. +3. Near-perfect performance — pre-built HTML, no server-side rendering. + +## The deploy flow + +``` +git push → Gitea webhook → act_runner picks up the job + → npm ci → astro build → copy dist/* to docroot → nginx serves it +``` + +The whole thing runs on the self-hosted production infrastructure I manage, which is exactly the point — +this is a live demo of the DevOps work I describe elsewhere. + +## The docker-compose setup + +Here's the shape of the stack. This is the local/origin side (Gitea + the +runner + nginx); Cloudflare handles public exposure separately via a tunnel, +so nothing here needs a public IP or open ports. + +
+docker-compose.yml — Gitea + act_runner + nginx (blog docroot) (40 lines) + +```yaml +# docker-compose.yml — Gitea + act_runner + nginx (blog docroot) +services: + gitea: + image: gitea/gitea:1.27 + container_name: gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + volumes: + - ./gitea-data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "2222:22" # SSH (optional — I use HTTPS) + restart: unless-stopped + + act_runner: + image: gitea/act_runner:0.2.13 + container_name: act_runner + environment: + - GITEA_INSTANCE_URL=http://gitea:3000 + - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN} + volumes: + - /var/run/docker.sock:/var/run/docker.sock # runner spawns build jobs + - ./runner-data:/data + depends_on: + - gitea + restart: unless-stopped + + nginx-blog: + image: nginx:alpine + container_name: nginx-blog + volumes: + - ./html:/usr/share/nginx/html:ro # astro build output lands here + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + restart: unless-stopped +``` + +
+ +The CI job (`deploy.yml`) builds `dist/` on the runner, then `cp -r dist/*` +into the `./html` volume shared with the nginx container. Cloudflare's tunnel +daemon (`cloudflared`) points at `nginx-blog` — that's the only thing exposed +to the internet. + +Two things worth calling out that cost me time: + +1. **`act_runner` needs the Docker socket** (`/var/run/docker.sock`) mounted to + spawn build jobs — without it, jobs sit queued forever. +2. **Strict credential externalization**: The runner's `GITEA_RUNNER_REGISTRATION_TOKEN` is injected via an untracked local host `.env` file (`${RUNNER_TOKEN}`), while the build checkout token lives in Gitea Actions Secrets (`${{ secrets.PAT }}` in the workflow). Nothing sensitive is hardcoded in the repo, keeping it safe to make public. + +## The installation pain (real, from my commit history) + +Getting this pipeline working was *not* smooth — the git history of this repo +is a log of the gotchas. The four that cost real time: + +1. **No `actions/checkout` on Gitea.** Gitea's `act_runner` does not ship + GitHub's marketplace actions. My first workflow failed immediately — there + was nothing to check out the repo. Fix: drop the `actions/checkout` step + and clone manually: + ```yaml + - name: Checkout + run: | + git config --global --add safe.directory '*' + git clone --depth 1 "https://hoelee:${{ secrets.PAT }}@git.hoelee.com/hoelee/hoelee-blog.git" . + ``` + +2. **`GITHUB_TOKEN` injection doesn't authenticate the clone.** The default + runner token wasn't enough to `git clone` a private repo, so jobs failed on + checkout. Fix: create a dedicated **Personal Access Token** and reference it + as `${{ secrets.PAT }}` — never hardcode it in the workflow (that's also why + this repo is safe to make public). + +3. **Gitea ↔ `act_runner` version mismatch.** I burned three commits + ("test gitea 1.24.7 + act_runner 0.2.13" → "gitea 1.25.5" → "gitea 1.27.3 + + runner 3.3.2") before the runner would register and pick up jobs. The + lesson: **track the Gitea Actions architecture migration** — Gitea 1.27+ + transitioned from legacy `act_runner` to `gitea/runner` (the 3.x branch with + revised container volume directives). When the runner image or protocol falls + behind the server instance, jobs fail silently or refuse to spawn containers. + +4. **`config_file` env for the runner.** The runner needed its config path + passed explicitly before it would connect to the right instance. + +Each of these was a "one-line fix after an hour of head-scratching" — which is +exactly the kind of thing a blog post should save the next person from. +```` + +--- + +## 文章 08 / 08:我用 Suno AI 做出自己的歌 +- **Slug**: `how-i-made-my-own-songs-with-suno-ai` +- **英文标题**: How I Made My Own Songs with Suno AI +- **分类**: `case-studies` | **发布日期**: 2026-09-11 +- **线上地址**: https://blog.hoelee.com/posts/how-i-made-my-own-songs-with-suno-ai/ (EN) | https://blog.hoelee.com/posts/zh/how-i-made-my-own-songs-with-suno-ai/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "我用 Suno AI 做出自己的歌" +description: "自己写词、调风格提示词,用 Suno AI 做出七首原创歌曲——民谣、儿歌、动漫风心经。完整工作流 + 可直接播放的成品。" +pubDate: 2026-09-11 +category: case-studies +stream: creative-lab +tags: [suno, ai-music, music, lyrics, prompting, ai] +ogImage: /og/zh-how-i-made-my-own-songs-with-suno-ai.jpg +banner: /banners/how-i-made-my-own-songs-with-suno-ai.jpg +--- + +我做自己的歌。不是靠弹奏乐器——而是写歌词、打磨风格提示词,让 Suno AI +负责唱腔、编曲和混音。这篇是完整工作流:当前模型家族怎么用、我实际往 +提示框里打什么,以及七首可以直接播放的成品。 + +## 为什么用 AI 做自己的音乐? + +两个理由,一个私人,一个实际。 + +**私人方面:** 我喜欢拿生成式 AI 做实验。一首歌是紧凑、自包含的项目—— +歌词、风格、结构、演唱——练的是和搭 bot、接管道一模一样的提示词功力。 +而且成品比配置文件容易分享得多。 + +**实际方面:** 这已经是一项能接单的技能。中小企业需要广告歌、视频背景 +音乐、活动配乐、品牌音频——但不一定有录音室预算。如果我凭一页简报就能 +交付一首可用的曲子,那是服务,不是魔术。Suno 现在的 **v6** 系列模型 +(2026-09-09 发布)比我 2024 年 4 月刚开始用的 V3 时代可控太多。 + +## 当前标准:2026 年 Suno 模型长什么样 + +网上旧教程引用的模型早就没了,先把版本理清楚: + +| 模型 | 时间 | 谁可用 | +|---|---|---| +| **v6 / v6-wild** | 2026-09-09 | 付费(旗舰 + 实验版) | +| **v6-mini** | 2026-09-09 | 人人可用——快、省,适合打草稿 | +| **v5.5** | 2026-03-26 | 付费——新增 Voices、Custom Models、My Taste | +| **v4.5-all** | 2025-10-21 | 免费版(无商用权,须署名) | +| V4 / V4.5 / V5 | 2024–2026 | 付费,已被取代 | + +v6 家族新增的控制功能,直接改变了我做事的方式: + +- **自然语言段落编辑**——「把副歌改成福音合唱团唱」只改一段,不重刷整首。 +- **单句歌词替换**——「把 love 改成 light」只动一行。 +- **Mashup 混搭**——一首歌的人声 + 另一首的鼓 + 新歌词,一次请求搞定。 +- **采样→分离→重建**——取 0:45 的 riff,分离吉他,围绕它做鼓点。 +- **多模态输入**——可以从文字、音频、图片甚至视频起步。 + +下面我自己的曲子是更早的 V4 时代工作流(2025 年 4 月)做的。底层逻辑—— +风格字段、歌词元标签、迭代——没变过,v6 能省哪几步我也会注明。 + +## 我的工作流:先词后风格 + +顺序比多数人以为的重要得多。新手通常打开 Suno,在 Simple Mode 里贴个 +类型,得到一首千篇一律的歌。我反过来: + +1. **先把歌当文档写出来**——标题、结构、歌词。情感和故事来自我, + Suno 负责表演。 +2. **打磨风格提示词**——类型 + 情绪 + 年代 + 乐器 + 人声人设 + 制作, + 往往 200+ 字符,描述的是歌曲的*旅程*,不只是类型。 +3. **给歌词加元标签**——`[Verse]`、`[Chorus]`、`[Bridge]`、`[Whispered]`、 + `[Belted]`。没有这些,Suno 默认输出平淡的主歌-副歌-主歌,没有情绪弧线。 +4. **每首生成 3–5 个 take** 留最好的。用 Extend/Reuse Prompt 修补, + 每次扩展都要重述类型——风格会漂移。 +5. **纠正发音**——AI 歌手是按读音读词的,所以要改写拼写 + ("through" → "thru"、生僻字加连字符),生僻词先用短片段测。 + +下面这个完整例子展示这一切长什么样。 + +## 成品歌曲 + +七首歌,托管在我自己的媒体子域名(`content.hoelee.com`,走 Publitio), +用原生 HTML5 audio 内嵌,点播放即可: + + + +### 1. 轮回的渡船 + +古风民谣。忘川的摆渡人、冷掉又添的孟婆汤、一个拒绝再入轮回的灵魂。 +写成跨越轮回选择一个人的神话式爱情故事:稀疏弦乐里低声起,副歌推满、 +撕心裂肺。 + +
+ + +
+ +### 2. Happy Way to School + +英文儿歌——原始提示词要的是「橘子汽水般冒泡的 indie folk」:木吉他、 +俏皮口哨、明亮童声。把上学路上的仪式写成小颂歌:跳动的马尾辫、咖啡店 +的猫、步调一致的伙伴。 + +
+ + +
+ +### 3. 新靓 —— 心经·冥想律动 + +般若波罗蜜多心经,配上节拍感十足的冥想编曲——简报就一句「冥想深度 + +律动活力」。观自在菩萨开场,完整「色不異空」段落,收在揭諦揭諦。试着 +让千年经文真的能跟着动起来。 + +
+ + +
+ +### 4. 新劲 —— 新鲜能量 + +新靓的硬朗姊妹篇——同样的冥想血统,更重的打击驱动。这首没留歌词稿, +当氛围作品存在。 + +
+ +
+ +### 5. 泡泡星球漫游记 + +童趣 indie folk,也是文档最全的一首——原始简报、风格提示词、歌词稿全在 +下面的完整例子里。泡泡星球、糖果喷泉、银河游乐场。 + +
+ + +
+ +### 6. 日漫心经 —— 心经·动漫风 + +同一部经文,换成动漫感十足的音色:大副歌能量、戏剧性转调,当代 +J-pop 片头曲的情感语法。 + +
+ +
+ +### 7. 未接来电 Unread Heartbeats (v2) + +一首关于那通未接来电的中文流行情歌:把忙音当心跳,已读不回。v2 是 +最终母带——编曲更紧凑、人声混音更干净。(旧版留在我的档案里。) + +
+ +
+ + + +## 完整例子:泡泡星球漫游记,从简报到最后成品 + +下面是泡泡星球漫游记的完整链条——唯一一份我保留了全部纸面记录的。 +它把上面的工作流具体化了。 + +**第一步——概念简报(我的笔记,中文):** + +> 独立民谣(Indie Folk):木吉他+口哨声组合,适合突出童趣感 +> 人声选择「精灵少女」音色库,自带俏皮气声 +> 打击乐:玻璃瓶敲击音效呼应「橘子汽水」意象 +> 副歌加入反向混响(reverse reverb)增强魔法感 + +**第二步——英文风格提示词(实际打进 Suno 的内容):** + +```text +Indie folk vibe with acoustic guitar and playful whistles, fizzy like +orange soda, featuring sprite-girl vocals and clinking glass bottle +beats, reverse reverb brings dreamlike magic +``` + +一行里压缩四个决定:*乐器*(木吉他 + 口哨)、*感觉*(橘子汽水般冒泡)、 +*人声*(精灵少女 + 气声)、*制作技巧*(副歌反向混响造魔法感)。 + +**第三步——带元标签的歌词稿:** + +
+泡泡星球漫游记 — Metatagged lyric sheet (38 lines) + +```text +[Intro][Whistling][Indie][Girl] +彩云兜着阳光转圈圈 +贝壳装满彩虹的碎片 +跳进橘子汽水的夏天 +海鸥掠过浪花的琴键 + +[Chorus][Indie Folk][Reverse Reverb][Whistling] +泡泡载着梦飞向屋檐(飞呀飞呀) +尾巴挂着星星的秋千(晃呀晃呀) +鲸鱼喷出糖果的喷泉(甜到脚尖) +魔法地图画满冒险线(转个圈圈) + +[Verse][Indie][Girl] +橡皮艇划开银河水面 +萤火虫点亮薄荷月圆 +棉花糖云朵蓬松柔软 +流星滑梯通向我窗前 + +[Chorus]… (repeat) + +[Interlude] + +[Bridge][Fantasy Pop][Giggles] +水晶风筝追着蝴蝶结 +跳跳糖在舌尖开派对 +彩虹滑板穿过梧桐叶 +月亮船摇着光的湖水 + +[Outro][Indie Folk][Girl] +每个气泡都是新世界 +装得下所有奇妙遇见 +在泡泡星球蹦跳向前 +明天又是崭新的乐园 + +[Fade to End] +``` + +
+ +注意元标签比裸歌词稿多约束了什么:段落*标签*(`[Bridge]`、`[Outro]`)、 +*演唱方式*(`[Whistling]`、`[Giggles]`)、歌曲中间换*制作风格* +(`[Reverse Reverb]` 只在副歌)、桥段转风格(`[Fantasy Pop]`)。 +每句后面的填充语气词——飞呀飞呀、晃呀晃呀——告诉歌手怎么装饰每一行。 + +**第四步——生成、听、重复。** 每段 3–5 个 take,留最好的,扩展最顺的 +副歌。最终成品在上面可播放。 + +## 换成 v6 我会怎么改 + +如果今天做这些歌,有四件事会不一样: + +1. **段落编辑替代整首重来。** 轮回的渡船的桥段磨了六次生成才满意。 + 换成 v6,直接说「桥段放慢、变稀疏,只留人声和拨弦古筝」,其余保留。 +2. **单句歌词替换。** 忘川那两句改了四版措辞;v6 只改一行,不重建整首。 +3. **Voices / 自定义模型。** v5.5 的 Voices 能让七首歌用同一个歌手, + 而不是七个不同的 AI 声线。 +4. **分轨和 Studio。** Premier 的 Studio(带多轨 + MIDI 导出的浏览器 + DAW)可以直接修一个人声瑕疵,不用围着它重新生成。 + +工艺层——像样的歌词、情绪弧线、诚实的元标签——跨模型版本一直成立。 +模型越来越可控;写歌这件事还是人的活。 + +## 结果 + +七首原创歌曲,用 Suno AI 写词编曲完成,托管在自己的基础设施上 +(`content.hoelee.com`),用原生 HTML5 audio 内嵌、歌词同步——没有录音室 +预算、不用约歌手、不用乐手。整个曲库就躺在我 NAS 的一个文件夹里, +跟任何项目一样做版本管理。 + +想试试 AI 音乐的话,从自己的歌词和一句诚实的风格描述开始——类型、感觉、 +人声、一个制作技巧——然后迭代,直到某个 take 让你意外。这就是全部手艺。 + +--- + +## 想要原创音乐——或任何 AI 加持的业务方案? + +我做网站、Telegram/WhatsApp bot 和自托管基础设施,也为品牌制作原创 +AI 辅助音乐:广告歌、背景音乐、活动音频,不需要录音室预算。没有乐手 +要预约、没有授权迷宫——你拿到的是真的能用的原创曲目。如果这有用, +欢迎聊聊: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com?subject=Music%20for%20my%20business) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Made My Own Songs with Suno AI" +description: "I wrote the lyrics, tuned the prompts, and shipped seven songs with Suno AI — folk ballads, a kids' tune, an anime-style Heart Sutra. Full workflow + the tracks." +pubDate: 2026-09-11 +category: case-studies +stream: creative-lab +tags: [suno, ai-music, music, lyrics, prompting, ai] +ogImage: /og/how-i-made-my-own-songs-with-suno-ai.jpg +banner: /banners/how-i-made-my-own-songs-with-suno-ai.jpg +--- + +I make my own songs. Not by playing an instrument — by writing lyrics, +crafting style prompts, and letting Suno AI produce the voice, the +arrangement, and the mix. This post is the full workflow: how the current +model family works, what I actually type into the prompt boxes, and seven +finished tracks you can play right here. + +## Why make your own music with AI? + +Two reasons, one personal and one practical. + +**Personally:** I like experimenting with creative AI. A song is a tight, +self-contained project — lyrics, style, structure, performance — that +exercises the same prompt-crafting muscles as building a bot or wiring a +pipeline. And the output is shareable in a way a config file never is. + +**Practically:** this is now a client skill. Small businesses need jingles, +background music for videos, event music, and branded audio — without a +studio budget. If I can deliver a usable track from a one-page brief, +that's a service, not a party trick. Suno's current **v6** models (released +Sep 9, 2026) made this dramatically more controllable than the V3-era +version I started with in April 2024. + +## The current standard: what Suno's models look like in 2026 + +Version confusion is the first thing to clear up, because old guides +online reference models that don't exist anymore: + +| Model | When | Who gets it | +|---|---|---| +| **v6 / v6-wild** | Sep 9, 2026 | Paid plans (flagship + experimental) | +| **v6-mini** | Sep 9, 2026 | Everyone — fast, efficient, great for drafts | +| **v5.5** | Mar 26, 2026 | Paid — added Voices, Custom Models, My Taste | +| **v4.5-all** | Oct 21, 2025 | Free plan (no commercial rights, attribution required) | +| V4 / V4.5 / V5 | 2024–2026 | Paid plans, superseded | + +The v6 family added the control features that changed how I work: + +- **Plain-language section edits** — "change the chorus so it's sung by a + gospel choir" edits one section without regenerating the song. +- **Single lyric swaps** — "change 'love' to 'light'" updates one line. +- **Mashups** — combine vocals from one song, drums from another, new + lyrics, in one request. +- **Sample → isolate → rebuild** — pull a riff at 0:45, isolate the + guitar, build a beat around it. +- **Multimodal input** — start from text, audio, an image, or even a video. + +My own tracks below were made with the earlier V4-era workflow (April +2025). The fundamentals — style field, lyric metatags, iteration — are +unchanged, and I note where v6 would have saved me steps. + +## My workflow: lyrics first, style second + +The order matters more than people think. Most beginners open Suno, paste +a genre into Simple Mode, and get a generic song. I do the opposite: + +1. **Write the song as a document first** — title, structure, lyrics. The + emotion and the story come from me; Suno supplies the performance. +2. **Craft the style prompt** — genre + mood + era + instruments + vocal + persona + production, often 200+ characters, describing the *journey* + of the song, not just its genre. +3. **Add metatags to the lyrics** — `[Verse]`, `[Chorus]`, `[Bridge]`, + `[Whispered]`, `[Belted]`. Without these, Suno defaults to a flat + verse-chorus-verse with no emotional arc. +4. **Generate 3–5 takes** per song and keep the best. Repair via + Extend/Reuse Prompt, and restate the genre in every extension because + style drifts. +5. **Iterate on pronunciation** — AI singers read phonetically, so I + respell ("through" → "thru", hyphenate unusual syllables) and always + test proper nouns in a short clip first. + +The worked example below shows exactly what this looks like. + +## The songs + +Seven tracks, hosted on my own media subdomain (`content.hoelee.com` via +Publitio) and embedded with plain HTML5 audio. Press play: + + + +### 1. 轮回的渡船 — The Ferry of Reincarnation + +Chinese folk ballad (古风民谣). The ferryman of the River of Forgetfulness, +Meng Po's soup gone cold, a soul that refuses to reboard the wheel of +rebirth. Written as a mythic love story about choosing someone across +lifetimes. Starts hushed over sparse strings and builds to a full, +anguished chorus. + +
+ + +
+ +### 2. Happy Way to School + +English children's song — the original prompt called for a "fizzy, +orange-soda indie folk" sound: acoustic guitar, playful whistling, and +bright kid-friendly vocals. It's the walk-to-school ritual turned into a +tiny anthem: ponytails bouncing, kittens at the café, friends in step. + +
+ + +
+ +### 3. 新靓 — Heart Sutra, Meditative Beats + +The 般若波罗蜜多心经 (Heart Sutra) chanted over rhythmic, meditative +production — the brief was "meditation depth + groove energy". 观自在菩萨 +opening, the full 色不異空 passage, ending on 揭諦揭諦. An attempt to make +ancient scripture something you can actually move to. + +
+ + +
+ +### 4. 新劲 — Fresh Energy + +A harder-edged companion to 新靓 — same meditative lineage, more percussive +drive. No lyric sheet on this one; it lives as a vibe piece. + +
+ +
+ +### 5. 泡泡星球漫游记 — Bubble Planet Adventure + +Kids' indie folk, and the one with the most documented backstory — the +full original brief, style prompt, and lyric sheet are in the worked +example below. Bubble planets, candy fountains, a galactic playground. + +
+ + +
+ +### 6. 日漫心经 — Heart Sutra, Anime Style + +The same classical text, reimagined with an anime-inspired sound palette — +big chorus energy, dramatic key lifts, the emotional grammar of +contemporary J-pop openings. + +
+ +
+ +### 7. 未接来电 Unread Heartbeats (v2) + +A mandopop ballad about that one missed call: the dial tone as a heartbeat, +the read receipts that stay unread. v2 is the final master — tighter +arrangement, cleaner vocal mix. (The original take stays in my archive.) + +
+ +
+ + + +## Worked example: Bubble Planet Adventure, from brief to track + +This is the full chain for 泡泡星球漫游记 — the only one I kept every +scrap of paperwork for. It shows the workflow above in concrete form. + +**Step 1 — the concept brief (my notes, in Chinese):** + +> 独立民谣(Indie Folk):木吉他+口哨声组合,适合突出童趣感 +> 人声选择「精灵少女」音色库,自带俏皮气声 +> 打击乐:玻璃瓶敲击音效呼应「橘子汽水」意象 +> 副歌加入反向混响(reverse reverb)增强魔法感 + +**Step 2 — the English style prompt (what actually went into Suno):** + +```text +Indie folk vibe with acoustic guitar and playful whistles, fizzy like +orange soda, featuring sprite-girl vocals and clinking glass bottle +beats, reverse reverb brings dreamlike magic +``` + +Four decisions compressed into one line: the *instrument* (acoustic +guitar + whistles), the *feel* (fizzy like orange soda), the *voice* +(sprite-girl with airy delivery), and the *production trick* (reverse +reverb on the chorus for the magic feel). + +**Step 3 — the lyric sheet with metatags:** + +
+Bubble Planet Odyssey — Metatagged lyric sheet (38 lines) + +```text +[Intro][Whistling][Indie][Girl] +彩云兜着阳光转圈圈 +贝壳装满彩虹的碎片 +跳进橘子汽水的夏天 +海鸥掠过浪花的琴键 + +[Chorus][Indie Folk][Reverse Reverb][Whistling] +泡泡载着梦飞向屋檐(飞呀飞呀) +尾巴挂着星星的秋千(晃呀晃呀) +鲸鱼喷出糖果的喷泉(甜到脚尖) +魔法地图画满冒险线(转个圈圈) + +[Verse][Indie][Girl] +橡皮艇划开银河水面 +萤火虫点亮薄荷月圆 +棉花糖云朵蓬松柔软 +流星滑梯通向我窗前 + +[Chorus]… (repeat) + +[Interlude] + +[Bridge][Fantasy Pop][Giggles] +水晶风筝追着蝴蝶结 +跳跳糖在舌尖开派对 +彩虹滑板穿过梧桐叶 +月亮船摇着光的湖水 + +[Outro][Indie Folk][Girl] +每个气泡都是新世界 +装得下所有奇妙遇见 +在泡泡星球蹦跳向前 +明天又是崭新的乐园 + +[Fade to End] +``` + +
+ +Note what the metatags enforce that a bare lyric sheet wouldn't: +section *labels* (`[Bridge]`, `[Outro]`), *delivery* (`[Whistling]`, +`[Giggles]`), a *production change* mid-song (`[Reverse Reverb]` only on +choruses), and a stylistic shift in the bridge (`[Fantasy Pop]`). The +fill-in parentheticals — 飞呀飞呀, 晃呀晃呀 — tell the singer exactly +how to ornament each line. + +**Step 4 — generate, listen, repeat.** 3–5 takes per section, keep the +best, extend the chorus that landed. The final track plays above. + +## What I'd do differently with v6 + +If I made these today, four things change: + +1. **Section edits instead of full rerolls.** 轮回的渡船's bridge took six + generations to nail. With v6 I'd say *"make the bridge slower and + sparser, just voice and a plucked guzheng"* and keep the rest intact. +2. **Single-line lyric swaps.** The 忘川 couplet went through four wordings; + v6 edits one line without rebuilding the song. +3. **Voices / custom models.** v5.5's Voices would let me keep one + consistent singer across all seven tracks instead of seven different + AI vocalists. +4. **Stems and Studio.** On the Premier plan, Studio (a browser DAW with + multitrack + MIDI export) means fixing a vocal artifact instead of + regenerating around it. + +The craft layer — decent lyrics, an emotional arc, honest metatags — +carries straight across model versions. The models get more controllable; +the songwriting stays the job. + +## The result + +Seven original songs, written and produced with Suno AI, hosted on my own +infrastructure (`content.hoelee.com`), embedded here with plain HTML5 audio +and synced lyrics — no music-studio budget, no booking a vocalist, no +session musician. The whole catalog lives in one folder on my NAS, +versioned like any other project. + +If you're curious about AI music, start with your own lyrics and an honest +style sentence — genre, feel, voice, one production trick — then iterate +until one take surprises you. That's the whole craft. + +--- + +## Want original music — or AI-powered anything — for your business? + +I build websites, Telegram/WhatsApp bots, and self-hosted infrastructure, +and I produce original AI-assisted music for brands that want a jingle, +background tracks, or event audio without a studio budget. There's no +session musician to book and no licence maze — you get an original track +you can actually use. If that sounds useful, I'd love to talk: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com?subject=Music%20for%20my%20business) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +--- + diff --git a/docs/consultant-review/batches/batch-02.md b/docs/consultant-review/batches/batch-02.md new file mode 100644 index 0000000..319a671 --- /dev/null +++ b/docs/consultant-review/batches/batch-02.md @@ -0,0 +1,4104 @@ +# 博客文章顾问审阅资料包 — Batch-02 + +> **批次编号**:`Batch-02` +> **包含文章数**:10 篇 +> **本批次总字符量**:160,519 字(严格限制 < 200,000 字) +> **模式**:BILINGUAL +> **生成时间**:2026-10-27 03:02:03 + +# 外部顾问审阅前置须知与工程背景规范 + +> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。 + +## 1. 站点定位与双重业务目的 +- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。 +- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。 + +## 2. 核心人设铁律与工程硬约束(必须严格遵守) +1. **彻底杜绝业余爱好者/玩具口吻**: + - ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。 + - ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。 +2. **严禁捏造虚假指标与性能数字**: + - 所有 benchmark 数据必须有明确的测试基准与单设备限制。 + - 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。 + - 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。 +3. **去模板化叙事与真实物理锚点**: + - 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。 + - 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。 +4. **代码与配置展示规范**: + - 超过 20 行的配置或日志采用 HTML `
` 原生折叠,`` 必须精准标注文档名与行数。 + +## 3. 顾问重点开刀维度 +- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大? +- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻? +- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)? +- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置? + + +--- + +## 本批次文章目录索引 + +1. [case-studies] **如何用并行子代理在三小时内核查 20 家 VPS 服务商** (`how-i-vetted-20-vps-providers-with-parallel-subagents`) — ~11,778 字符 +2. [case-studies] **没有 Coding Agent 的 Loop Engineering:撑起我这套基础设施的 11 个定时任务** (`loop-engineering-without-a-coding-agent`) — ~20,707 字符 +3. [case-studies] **用一个 Prometheus 监控 unRaid、群晖 NAS 和 VPS:我做错的四件事** (`one-prometheus-for-unraid-synology-and-a-vps`) — ~12,936 字符 +4. [case-studies] **一个人跑生产基础设施:三台主机、162 个容器** (`running-production-infrastructure-solo`) — ~11,669 字符 +5. [case-studies] **数字商品的自愈式访问控制:NocoDB、n8n 与 AList** (`self-healing-digital-goods-entitlements`) — ~21,593 字符 +6. [case-studies] **自建语音转文字服务:打字速度的五倍,而且数据不出内网** (`self-hosted-speech-to-text-api`) — ~22,254 字符 +7. [case-studies] **从云端转本地:在 12GB 显卡与群晖 N8N 上搭建中英混读高保真 TTS 语音栈** (`self-hosted-tts-cosyvoice2-n8n-gpu`) — ~12,885 字符 +8. [case-studies] **我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆)** (`self-hosting-mem0-memory-stack`) — ~12,602 字符 +9. [case-studies] **把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验** (`shipping-an-ai-photo-editor-as-a-wordpress-plugin`) — ~20,578 字符 +10. [case-studies] **在多台机器之间同步一个会自我学习的 AI Agent** (`syncing-a-self-improving-ai-agent-across-machines`) — ~13,517 字符 + +--- + +## 文章 01 / 10:如何用并行子代理在三小时内核查 20 家 VPS 服务商 +- **Slug**: `how-i-vetted-20-vps-providers-with-parallel-subagents` +- **英文标题**: How I Vetted 20 VPS Providers in Three Hours with Parallel Subagents +- **分类**: `case-studies` | **发布日期**: 2025-11-12 +- **线上地址**: https://blog.hoelee.com/posts/how-i-vetted-20-vps-providers-with-parallel-subagents/ (EN) | https://blog.hoelee.com/posts/zh/how-i-vetted-20-vps-providers-with-parallel-subagents/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "如何用并行子代理在三小时内核查 20 家 VPS 服务商" +description: "一个关于编排并行 AI 子代理对 20 家主机服务商做尽职调查的案例研究——WHOIS、AUP 和口碑核查——把数小时的研究任务压缩成一份结构化、可追溯的供应商评分卡。" +pubDate: 2025-11-12 +updatedDate: 2026-09-09 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L4-managed-ops-handover +tags: [ai-orchestration, subagents, due-diligence, hosting, devops] +ogImage: /og/zh-how-i-vetted-20-vps-providers-with-parallel-subagents.jpg +banner: /banners/how-i-vetted-20-vps-providers-with-parallel-subagents.jpg +--- + +## 为什么这很重要 + +选主机服务商,本质是用一张信用卡和一次 DNS 变更来下注。选错了,「保证在线率」 +会变成凌晨两点的封停邮件,「注重隐私」会变成一条你从没读过的日志留存条款。 + +问题不是信息不够,而是信息散落在每家服务商的十几个页面里(WHOIS 记录、可接受使用 +政策、隐私政策、价格页、第三方评测站),手工核查又慢又枯燥又容易出错。一家服务商 +就是五个标签页,二十家就是一百个标签页和一下午搭进去的时间。 + +这个故事讲的是我怎么把那一整个下午压到大约三小时——不是靠更快地干活,而是靠编排 +一支 AI 子代理小队并行跑完枯燥的部分,再用同一张清单给所有结果打分。 + +## 问题:我不能靠信仰去核实的那些声明 + +我要为一个有硬性要求的项目筛选服务商:具体的司法辖区、付款方式、流量条款。这些 +没有一样是首页上诚实地写出来的。它们诚实地写在那些无聊的文档里——WHOIS 记录显示 +域名才四个月、AUP 静悄悄地禁掉你正想跑的服务、隐私政策承认自己保留日志。 + +难就难在,核实一家服务商意味着读五份互相矛盾的文档。营销说「自 2012 年起」, +WHOIS 说「今年四月才注册」。特性页说「允许所有流量」,AUP 说「禁止 Tor 中继」。 +一家服务商就是一次事实核查,二十家就是一个研究项目。 + +## 第一次尝试:一个 agent,一个大循环 + +我最先想到的当然是显而易见的那个——单个助手从头到尾挨个处理这份名单,逐家抓取 +文档、记笔记、往下走。 + +它能跑。但它对这份工作的形状来说是错的工具。这活儿**天然可并行**:7 号服务商的 +WHOIS 查询和 3 号服务商的隐私政策毫无关系。串行跑意味着总耗时是每次抓取之和,而且 +——更重要的是——上下文窗口会被我已经翻过去的那几家的半成品笔记塞满。到第八九家 +的时候,早期的发现就被后面的挤掉了。 + +教训是:一个对独立条目做扁平循环的任务,不是推理问题,而是扇出(fan-out)问题。 +一个长长的上下文是装它的错误容器。 + +## 修复:用并行子代理扇出,然后统一打分 + +真正奏效的结构是三层: + +**1. 一张不在乎指向谁的清单。** 在派发任何东西之前,我先写下对每家服务商而言 +「已核实」具体意味着什么: + +- 域名注册日期 对比 那个「since」声明 +- 可接受使用政策(AUP),搜我关心的具体服务 +- 隐私政策,读留存条款 +- 第三方口碑(Trustpilot 看趋势,不看均值;社区提及) +- 流量条款(「防计量」还是一个计量上限) + +这张清单就是契约。每个子代理都拿到同一份,外加一份要套用的服务商名单。 + +**2. 并行子代理,每个负责一批服务商。** 我把名单拆成几簇,每簇交给一个子代理。 +每个子代理在隔离环境里工作,有自己的上下文、自己的一套抓取,最后为每家服务商 +返回一份结构化的事实清单——不是一段话,而是能直接丢进评分卡的字段。 + +关键是子代理彼此不知道对方。这正是要点:1 号服务商的任何内容都不必和 14 号服务商 +共享上下文空间,各自返回自洽的结果。 + +**3. 一次打分,由我来做,不派发。** 子代理产出发现,判断由我来下。一旦你让子代理 +既*收集*事实又*排序*服务商,你就丢了审计链——只会得到一个没有背后证据支撑的结论。 +把打分集中在自己手里,意味着我随时能说出*为什么*某个东西排到某个位置,并指出是哪条 +WHOIS 记录或哪行 AUP 驱动的。 + +这和我做任何代码审查或重构时用的是一个模式:并行化机械的收集,集中化决策。 + +### 编排实际长什么样 + +大致上,每一批: + +```text +子代理 → 「这是清单,这是你那 5 家服务商」 + → 逐家:抓 WHOIS、AUP、隐私政策、价格、评测 + → 返回 { 域名年龄, AUP红旗[], 留存条款, 口碑, 流量 } +我 → 合并进一张评分卡,套用清单,排序,写结论 +``` + +三个子代理并行跑。整轮——二十家服务商、每家五份文档、大约一百次抓取——在手工 +仔细做两三家服务商的时间里就完成了。 + +## 核查到底抓到了什么 + +评分卡暴露了首页永远不会告诉你的真问题: + +- **一家声称「自 2012 年起值得信赖」的服务商,域名比这晚得多。** WHOIS 显示域名是 + 同一年的某个月才注册的——一个「从业多年」的四位数声明,配一个才几个月的域名。 + 这要么是换了马甲的壳,要么就是撒谎,无论哪种,它页面上所有其他声明在我眼里都要 + 降级。 + +- **两家的 AUP 禁掉了我正想跑的那个服务。** 一家在禁止活动条款里列了「TOR 节点」和 + 「匿名化服务」;另一家禁了「反向代理」和「隧道」。两家的特性页却还宣传着相反的话。 + 在 AUP 上花十分钟 `Ctrl-F` 就排除了它们——但前提是*我知道该去查 AUP*,而不是 + 特性页。 + +- **一家原本看着很便宜的服务商,读了流量条款后不是了。** 一个带 1 TB 计量上限的 + 计划上写「无限」,只是营销。对于既要接收又要转发的节点,真实成本翻倍——「便宜」 + 的那个选项并不便宜。 + +它们所有的共同点是:致命信息从来都没藏起来。它是*公开*的,躺在服务商法律上有义务 +发布的文档里。这个技能不是秘密渠道,而是知道该读哪份文档,并拿它去对照营销话术。 + +## 我会怎么做不一样 + +子代理的交接能用,但太粗糙。下次我会提前给每个子代理*精确的返回字段*——一份严格的 +输出 schema——而不是一段我事后还得重新解析的散文。结构化输出意味着最后一个子代理 +返回时评分卡就已经建好了,不用再读一遍。 + +我也会更早钉死「这个声明能否独立验证?」这个测试。大多数红旗并不是服务商公然撒谎, +而是一条我无法对照任何公开记录去核实的声明。把「无法验证」本身当成一种信号,名单 +很快就能缩下来。 + +## 结果 + +大约 20 家服务商、大约 100 次文档抓取,三个子代理并行,耗时相当于手工仔细做两家 +服务商。最终评分卡里的每一项排名都能追溯到一条具体的公开记录——一个 WHOIS 日期、 +一行 AUP 条款、一条留存条款——而不是一种感觉。 + +--- + +## 想为你的企业做这件事吗? + +无论是 VPS、API 网关还是薪酬服务商,选供应商都是同一种问题:在签约*之前*,把你真正 +依赖的那些声明对照无法被营销篡改的公开记录核实一遍。如果你手里有一份候选供应商或 +工具的短名单,想在投入之前拿到一份结构化、有证据支撑的评估——我可以跑这轮尽职调查, +然后交给你一张评分卡,而不是一个猜测。 + +[WhatsApp 联系我](https://wa.me/60127972969) 或 [发邮件](mailto:me@hoelee.com?subject=Vendor%20due-diligence%20evaluation) 到 hoelee.com——我帮企业选对基础设施,并搭建配套的自动化。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Vetted 20 VPS Providers in Three Hours with Parallel Subagents" +description: "A case study in orchestrating parallel AI subagents to run due diligence on 20 hosting providers — WHOIS, AUP, and reputation checks — collapsing a multi-hour research task into a structured, verifiable vendor scorecard." +pubDate: 2025-11-12 +updatedDate: 2026-09-09 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L4-managed-ops-handover +tags: [ai-orchestration, subagents, due-diligence, hosting, devops] +ogImage: /og/how-i-vetted-20-vps-providers-with-parallel-subagents.jpg +banner: /banners/how-i-vetted-20-vps-providers-with-parallel-subagents.jpg +--- + +## Why it matters + +Choosing a hosting provider is a bet you place with a credit card and a DNS +change. Get it wrong and "guaranteed uptime" becomes a suspension email at 2 AM, +or "privacy-friendly" becomes a log-retention clause you never read. + +The problem isn't a lack of information — it's that the information is scattered +across a dozen pages per provider (WHOIS records, acceptable-use policies, +privacy policies, pricing pages, and third-party review sites), and checking them +manually is slow, boring, and error-prone. One provider is five tabs. Twenty +providers is a hundred tabs and an afternoon you don't get back. + +This is the story of how I collapsed that afternoon into about three hours — not +by working faster, but by orchestrating a small team of AI subagents to run the +boring part in parallel, and scoring everything against one checklist. + +## The problem: verifying claims I couldn't take on faith + +I needed to shortlist providers for a project with hard requirements: specific +jurisdictions, payment methods, and traffic terms. None of those are written +honestly on a homepage. They're written honestly in the boring documents — the +WHOIS record that shows a domain is four months old, the AUP that quietly bans +the exact service you wanted to run, the privacy policy that admits to log +retention. + +The hard part is that verifying one provider means reading five documents that +disagree with each other. The marketing says "since 2012"; the WHOIS says "this +April." The features page says "all traffic allowed"; the AUP says "Tor relays +prohibited." One provider is a fact-checking exercise. Twenty is a research +project. + +## What I tried first: one agent, one big loop + +My first instinct was the obvious one — a single assistant that works through the +list, provider by provider, fetching each document, taking notes, moving on. + +It worked. It was also the wrong tool for the shape of the job. The work is +*embarrassingly parallel*: provider #7's WHOIS lookup has nothing to do with +provider #3's privacy policy. Running them one after another meant the total time +was the sum of every fetch, and — more importantly — the context window filled +with half-finished notes from providers I'd already moved past. By provider +eight or nine, early findings were getting crowded out by later ones. + +The lesson: a task that's a flat loop over independent items isn't a reasoning +problem, it's a fan-out problem. One long context is the wrong container for it. + +## The fix: fan out with parallel subagents, then score once + +The structure that worked was three layers: + +**1. A checklist that doesn't care which provider it's pointed at.** Before +spawning anything, I wrote down exactly what "verified" means per provider: + +- domain registration date vs. the "since" claim +- acceptable-use policy, searched for the specific service I cared about +- privacy policy, read for the retention clause +- third-party reputation (Trustpilot trend, not average; community mentions) +- traffic terms ("unmetered" vs. a metered cap) + +That checklist was the contract. Every subagent got the same one, plus a list of +providers to run it against. + +**2. Parallel subagents, one per batch of providers.** I split the pool into +clusters and handed each cluster to its own subagent. Each one worked in +isolation, with its own context and its own set of fetches, and returned a +structured fact sheet per provider — not a paragraph, but fields I could drop +straight into a scorecard. + +The key here is that the subagents don't know about each other. That's the +point: nothing from provider #1 has to share context space with provider #14. +Each returns a self-contained result. + +**3. A single scoring pass, done by me, not delegated.** The subagents produced +findings; I did the judgment. The moment you let a subagent both *gather* the +facts and *rank* the providers, you lose the audit trail — you get a verdict +without the evidence behind it. Keeping scoring central means I can always say +*why* something ranked where it did, and point at the exact WHOIS record or AUP +line that drove it. + +This mirrors a pattern I'd use for any code review or refactor: parallelize the +mechanical collection, centralize the decisions. + +### What the orchestration actually looked like + +Roughly, per batch: + +```text +subagent → "here's the checklist, here are your 5 providers" + → per provider: fetch WHOIS, AUP, privacy policy, pricing, reviews + → return { domain_age, aup_flags[], retention, reputations, traffic } +me → merge into one scorecard, apply the checklist, rank, write up +``` + +Three subagents ran side by side. The whole pass — twenty providers, five +documents each, one hundred-ish fetches — landed in the time it would have taken +me to do two or three providers carefully by hand. + +## What the verification actually caught + +The scorecard surfaced real problems that a homepage never would have: + +- **A provider whose "trusted since 2012" claim was younger than the domain.** + WHOIS said the domain was registered that same year — a four-figure "years in + business" claim on a domain months old. That's either a re-branded shell or a + lie, and either way it downgraded every other claim on the page in my eyes. + +- **Two providers whose AUP banned the exact service I wanted to run.** One + listed "TOR nodes" and "anonymizing services" in its prohibited-activity + clause; another banned "reverse proxies" and "tunnels." Both still advertised + the opposite on their features pages. Ten minutes of `Ctrl-F` on the AUP is all + it took to rule them out — but only once I *knew to check the AUP* instead of + the features page. + +- **A provider that looked cheap until I read the traffic terms.** "Unlimited" on + a plan with a metered 1 TB cap is marketing. For a relay that both receives and + forwards traffic, the real cost doubles — the "cheap" option wasn't. + +The pattern across all of them: the disqualifying information was never hidden. +It was *public*, sitting in a document the provider is legally required to +publish. The skill isn't secret access — it's knowing which document to read and +checking it against the marketing. + +## What I'd do differently + +The subagent hand-off worked, but it was blunt. Next time I'd give each subagent +the *exact* fields to return up front — a strict output schema — rather than a +prose summary I then have to re-parse. Structured output means the scorecard is +built by the time the last subagent returns, with no re-reading. + +I'd also pin the "is this claim independently verifiable?" test earlier. Most of +the red flags weren't a provider lying outright; they were a claim I couldn't +check against any public record. Treat "unverifiable" as its own signal, and the +shortlist shrinks fast. + +## The result + +~20 providers audited across ~100 document fetches, three subagents running in +parallel, in the time a careful manual pass would have spent on two providers. +Every ranking in the final scorecard traces back to a specific public record — +a WHOIS date, an AUP line, a retention clause — not a vibe. + +--- + +## Want this for your business? + +Choosing a vendor is the same shape of problem whether it's a VPS, an API +gateway, or a payroll provider: verify the claims you're relying on *before* you +sign, against public records that can't be edited by marketing. If you've got a +shortlist of vendors or tools and you want a structured, evidence-backed +evaluation before you commit — I can run the due-diligence pass and hand you a +scorecard, not a hunch. + +[WhatsApp me](https://wa.me/60127972969) or [email me](mailto:me@hoelee.com?subject=Vendor%20due-diligence%20evaluation) at hoelee.com — I help businesses pick the right infrastructure and build the automation around it. +```` + +--- + +## 文章 02 / 10:没有 Coding Agent 的 Loop Engineering:撑起我这套基础设施的 11 个定时任务 +- **Slug**: `loop-engineering-without-a-coding-agent` +- **英文标题**: Loop Engineering Without a Coding Agent: The 11 Cron Jobs That Run My Infrastructure +- **分类**: `case-studies` | **发布日期**: 2026-10-01 +- **线上地址**: https://blog.hoelee.com/posts/loop-engineering-without-a-coding-agent/ (EN) | https://blog.hoelee.com/posts/zh/loop-engineering-without-a-coding-agent/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "没有 Coding Agent 的 Loop Engineering:撑起我这套基础设施的 11 个定时任务" +description: "Loop engineering 是 2026 年对「设计让 agent 自己反复跑的系统」的叫法。我把它用在自己的自托管基础设施上而不是代码库上:11 个 cron,其中 8 个完全没有 AI。" +pubDate: 2026-10-01 +category: case-studies +blueprint: self-hosted-infrastructure +blueprintLayer: L3-observability-self-healing +tags: ["loop-engineering", "ai-agents", "cron", "self-hosting", "automation", "monitoring"] +ogImage: "/og/zh-loop-engineering-without-a-coding-agent.jpg" +banner: "/banners/loop-engineering-without-a-coding-agent.jpg" +draft: false +--- + +关于 loop engineering 的文章,几乎全都在讲 coding agent:Claude Code、Codex、`/goal`、`/loop`。例子永远是同一个形状——一个 agent 整晚替你重构某个代码仓库。 + +我没有这个问题。我自己搭了一套自托管的基础设施——一台 NAS、两台服务器、邮件、反向代理、CI runner——我的循环维持的是**这套东西**,不是代码库。这台机器上跑着 11 个定时任务:每五分钟跳一次的端点看门狗、一天两次的邮件分诊、平台优惠券审计、磁盘损坏检查、域名到期提醒,以及一个专门发现「某次升级偷偷把我的代理头配置重置了」的守卫。 + +这 11 个里面,有 8 个完全不含 AI。 + +## 为什么这件事比 agent 本身重要 + +凌晨两点挂掉的服务,只会一直挂到有人发现。域名到期,等于邮件在无声无息中停掉。SSD 损坏,等于我一个星期的工作没了。这些事都不会自己喊出来——它们只是静静地等,等到有人去看。 + +循环就是那个「有人」。它把「我该去看一下」变成「永远有东西在看,而且只在有事的时候才开口」。带来的好处不是什么魔法级自动化,而是我不再充当监控系统本身:真的出问题会有 Telegram 消息,没事的时候就是安静。 + +这就是全部的价值主张,值得说清楚,因为 2026 年围绕 loop 的炒作承诺的远不止这些。 + +## Loop engineering 到底是什么 + +这个说法是 2026 年 6 月出现的。Peter Steinberger 发帖说,你不该再去 prompt coding agent 了,应该去设计「prompt 这些 agent 的循环」。Anthropic 的 Claude Code 负责人 Boris Cherny 说得更短:*"I don't prompt Claude anymore. I have loops running that prompt Claude. My job is to write loops."* 随后 Addy Osmani 发表那篇给这个实践命名的文章,Anthropic 也在 6 月 30 日正式给了它一套四种循环类型的分类。 + +它是一条不断向外包一层的阶梯上最新的那圈:**prompt engineering**(2022–24,*我说清楚了吗?*)、**context engineering**(2025,*它看到对的东西了吗?*)、**harness engineering**(2026 年初,*它有没有一直做对?*)、**loop engineering**(2026,*它能不能不靠我在跑?*)。没有哪一层取代了前一层。我到现在还在写 prompt,只不过通常写在脚本里。 + +没人非得接受这个名词。The Register 在 6 月就管它叫最新一轮 buzzword,并且提了两点公允的批评:agent 本来就是循环;而最热衷推销「无人值守循环」的,恰恰是那些靠卖 token 赚钱的公司。有一份流传很广的报告声称某套常开的循环方案一个月烧了 130 万美元。连 Osmani 自己那篇文章的结尾都留了最重要的保留意见:*"The loop changes the work, it does not delete you from it."* + +我的循环几乎不花钱,原因就是下面第一条设计规则。 + +## 清单 + +| 任务 | 触发 | 凭什么算它干成了 | 用模型? | +|---|---|---|---| +| 端点看门狗(7 个目标,自愈) | 每 5 分钟 | HTTP/TCP 探测 + 控制探测 | 否——纯脚本 | +| 磁盘损坏看门狗 | 每天 10:00 | SMART 计数对比基线 | 否 | +| 域名到期提醒 | 每天 09:00 | 30 天 / 7 天阈值 | 否 | +| 邮件分诊(`me@hoelee.com`) | 09:30 + 18:30 | 未读邮件,按 message-id 去重 | **是** | +| 平台优惠券审计 | 每天 09:00 | 只读抓取后台 | **是** | +| Real-IP + 机器人拦截回归守卫 | 每小时 :20 | 配置不变量 + 日志比例 | 否 | +| unRaid SSD 看门狗 | 每天 10:00 | 计数增量 | 否 | +| 邮箱连通性健康检查 | 每月 | IMAP/SMTP 连接 | 否 | +| 记忆备份 | 每周 | 归档文件写出来了 | 否 | +| 软件版本检查 | 每天 09:00 | npm 最新版 vs 本地版 | 否 | +| 研究项目检查点提醒 | 每月 1、15 号 | 读项目状态,提醒我 | **是** | + +11 个循环,其中 8 个整条路径上没有任何模型。这个比例不是意外,也不是谦虚——它是我学到的最有用的一件事。 + +## 规则一:检查是确定性的,就别花模型去跑 + +我那个软件版本检查原本是 agent 任务。它跑了好几个星期,连续失败了 **29 次**,我才终于不再忽略那些通知。原因有点好笑:agent 模式的定时任务会记录创建时的模型,等我后来改了全局默认模型,这个任务不再悄悄改用新模型,而是干脆整个不跑,打印一行 `[drift_skip]` 就退出。连续 29 次。 + +任务本身其实极其简单:抓 `registry.npmjs.org//latest`,和本地版本比大小,只有远端更新才输出内容。这里面没有任何判断。所以我把它重写成 125 行 Python,每条分支都有明确出口,并把任务改成 `no-agent`——脚本的 stdout 直接作为通知内容,循环里没有 LLM。 + +从此一路全绿。Anthropic 自己的建议也是一句话:*"Use scripts for deterministic work — running a script is cheaper than reasoning through the steps."* 我想补一句更强的版本:如果停止条件是字符串比较,那么在这个循环里放一个模型,唯一的作用就是引入新的失败模式——而且它一定会在凌晨三点、无声无息地引入。 + +在我自己这套任务里的实测差异:11 个循环里有 8 个完全不花 token。用模型的 3 个,需要的都是我写不成代码的判断——这封邮件重不重要、这张券要不要人来处理、这个项目是不是卡住了。 + +## 规则二:验证器必须能大声说出「我自己坏了」 + +第二个我错了很久的地方:一个只在出事时才开口的看门狗,和一个已经死掉的看门狗,表现完全一样——都很安静。 + +所以这套脚本有一个统一契约:**退出码 0 表示「量过了,这是结果」,退出码 1 表示「我量不了」——而退出码 1 永远不静默。** 配置文件不存在、状态文件解析失败、状态目录写不进去、没预料到的异常,全都打印一行 `CANNOT MEASURE` 并退出 1。runner 又把 `main()` 整个包在一层兜底 handler 里,专门接住我没预料到的情况——因为唯一不能容忍的失败模式,是检查器自己悄悄死了。 + +端点看门狗走得更远,这也是整套东西里我最喜欢的一处设计。它在做任何判断之前,先探测一个**控制目标**——`https://one.one.one.one/`。如果这个也失败,那问题出在我自己的上行或 DNS,不是我的服务器,于是看门狗只报告、不动手。没有这道闸门,一次路由器重启看起来就和七个服务同时挂掉一模一样,而一个自动化循环会在这种情况下兴高采烈地去重启一台完全健康的虚拟机。 + +一个分不清「坏了」和「我看不见」的验证器,迟早会在某次网络故障中做出破坏性操作。我宁愿它知道区别。 + +这个循环里我最喜欢引用的数字:它已经连续跑了 **1,618 次**没有误报——因为要连续两次失败才会判定为 down,持续 down 的组会以很慢的节奏重复提醒而不是每个 tick 都喊一遍,恢复只播报一次并附带宕机时长。 + +## 规则三:给它「还能帮上忙」的最小权限 + +最诱人的设计是「让循环自己修好」。能活过生产的版本是三层,每个循环只属于其中一层: + +**第一层——只报告。** 我大部分循环在这里。它们只观察、只告诉我。它们做什么都不可能弄坏东西,所以我可以周五下午上线。 + +**第二层——提议,人确认。** 优惠券审计每天跑,而且按契约是**只读**的。即使脚本打印出一行建议,prompt 也禁止创建或编辑优惠券,因为那个平台的优惠券没有草稿态:确认就等于上线并冻结押金,背后是真金白银。这个循环的任务是递给我一个十秒钟就能做完的决定。 + +**第三层——按白名单动手。** 整套任务里只有一个循环会动手,条件收得很窄:只有在客户机**从宿主机都不可达**时(用宿主机 ping + ARP 验证过)才重启虚拟机;客户机活着但服务挂了的情况从不自动重启——那是要修的 bug,不是要重启的机器。三次运行的冷却期防住了重启循环。而控制探测失败时,它压根不会启动。 + +这个模式可以推广:**每上一层,都必须用一个我宁可相信它、也不相信 agent 的验证器来换。** 我能不能走开?只有当那个判定「做完了」的东西是我在复盘时也愿意相信的东西时,才行。 + +第三层的克制,也是这些循环另一端的平台要求的。一个会「好心」自己修掉发现问题的审计循环,是违反平台条款的问题,不是战果。 + +## 坑一:一个永远失败的循环,和一个无事可报的循环长得一模一样 + +「健康则静默」这条约定是这些循环能被忍受的原因——没有每日状态刷屏,只有真消息。但它同时也藏住了一个**连续失败 29 次**的任务,因为失败的运行和干净的运行,往我手机上推的都是空。 + +后来有两件事把它修好了,而且适用于任何无人值守的循环: + +- **一条独立的「我量不了」通路**(规则二)。它本该在第 1 次就暴露 drift,而不是拖到第 29 次。 +- **去看 runner 自己的执行历史,而不只是看收件箱。** 证据一直在那个任务的执行日志里,我却一直相信「没有通知」这件事本身。现在每加一个新循环,我会显式看它头几次的执行记录,而不是等它来告诉我。 + +如果你的循环「已经安静一阵子」了,先去翻它最近十次运行,再下结论。 + +## 坑二:一个会删生产数据的验证器 + +这一条我到现在还疼。我给一条交付流水线写过一个端到端测试脚本,它的清理步骤执行了一条无条件的 `DELETE FROM orders`。它本来是清理自己制造的数据,它也照做了——只是顺带把当时恰好在同一张表里的 7 张真实订单删了,连同生成好的 PDF,而且没有备份可还原。 + +教训不是「要在副本上测」(对,但当时我确实是),而是更窄、更硬的一条:**验证器是一个有权限的程序,它的写入路径需要和它要验证的东西接受同等程度的审视。** 一个我敢让它无人值守运行的循环,只能对自己的产物具有破坏性。现在这套里每个验证脚本都会把清理范围限定在自己造的、带标记的行上——`WHERE id IN ($MINE)` 或者 `WHERE recipient LIKE '%@example.com'`——其中有一个脚本正是因为这次事故被重写的。 + +Osmani 讲的 comprehension debt 就落在这里。循环跑得越顺,你越不会去读它的输出;而它出错的那天,没人在看。这些循环产生的 diff 我都在读。这个习惯就是剩下那部分工作的全部。 + +## 我刻意没有自动化的东西 + +这 11 个循环里有一个,唯一的工作就是提醒我:每半个月提醒我某个研究项目还在,以及下一步该跑哪些命令。它不抓任何东西、不写任何东西、不开浏览器。循环的职责是让**某个**循环继续转动——不一定是它自己那个。 + +这条边界值得说清楚。这 11 个任务买回的是注意力和早期发现。它们不会替我做任何我没有预先交给它们的决定,判断仍然来自我。真正的变化是:我一天当中再也不会花任何时间去想「是不是哪里坏了」。 + +## 我会做得不一样的地方 + +- **从「只报告」这一层开始,并且在里面多待一会儿。** 我在还没有控制探测和冷却期的时候就写过会动手的循环——那是一次靠运气躲过、而不是靠设计躲过的错误。 +- **每个脚本的第一版就带上「量不了」的出口。** 事后给 11 个任务补上花了我一个晚上;其实每个只要一段 `try/except`。 +- **第一天就去看 runner 的执行日志**,而不是等某条通知「感觉该来了」。 +- **在你需要之前就把状态文件写好。** 这里每个循环旁边都有一个小 JSON——第几次运行、上次的状态、什么时候开始 down。没有外部状态的循环,说不出它上一次知道什么,而「它恢复了吗」这件事,大部分答案都在那里。 + +## 结果 + +11 个循环,其中 8 个不带模型、因此也没有 token 账单,覆盖 7 个被监控的端点、一个邮箱、一个平台账号、一组 RAID 阵列,以及一组会被 CyberPanel 或 DSM 升级悄悄重置的代理不变量。端点看门狗已经连续完成 1,618 次 tick,并且把一台卡死的虚拟机从不可达自愈回健康状态,全程我没有碰它一下。整套任务里最贵的那个是每天一次、成本为零的版本检查——而如果我当时没有去读它的历史,它会一直失败下去。 + +如果你在跑任何必须一直活着的东西,那么「loop engineering」有用的那个版本不是一个 agent 舰队,而是:一个停止条件能被测试的 cron 任务、一个说得出「我看不见」的验证器、一条为没预料到的失败准备的出口,以及「允许它只做最小那件事」的权限。从你每周手动检查的那一件事开始,让它变成承重结构。 + +## 我目前的求职状态 + +我是全栈开发 / DevOps 工程师,正在找 remote 或 hybrid 的岗位——平台、基础设施、DevOps 或全栈方向。这个博客就是我的作品集:这里的每一篇,都是我真正做过、并且必须让它持续跑下去的东西,上面这 11 个循环是其中最不花哨、也最实用的部分。 + +**Email:[me@hoelee.com](mailto:me@hoelee.com) · [LinkedIn](https://www.linkedin.com/in/hoelee) · [GitHub](https://github.com/hoelee) · [hoelee.com](https://hoelee.com)** +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Loop Engineering Without a Coding Agent: The 11 Cron Jobs That Run My Infrastructure" +description: "Loop engineering is 2026's term for designing the system that prompts and checks an agent. I applied it to self-hosted infrastructure, not a codebase: 11 cron jobs, 8 with no AI at all." +pubDate: 2026-10-01 +category: case-studies +blueprint: self-hosted-infrastructure +blueprintLayer: L3-observability-self-healing +tags: ["loop-engineering", "ai-agents", "cron", "self-hosting", "automation", "monitoring"] +ogImage: "/og/loop-engineering-without-a-coding-agent.jpg" +banner: "/banners/loop-engineering-without-a-coding-agent.jpg" +draft: false +--- + +Every article about "loop engineering" is about a coding agent. Claude Code, +Codex, `/goal`, `/loop`. The examples are always the same shape: an agent +refactoring a repository overnight while you sleep. + +I don't have that problem. I run my own self-hosted infrastructure — a NAS, +a couple of servers, mail, a reverse proxy, a CI runner — and my loops keep +*that* alive, not a codebase. Eleven scheduled jobs run on this machine: an +endpoint watchdog that fires every five minutes, mail triage twice a day, a +marketplace voucher audit, a disk-corruption check, a domain-expiry +reminder, a guard that notices when an upgrade silently reset my proxy +headers. + +Eight of those eleven contain no AI at all. + +## Why this matters more than the agent does + +A service that is down at 2am stays down until someone notices. An expiring +domain is mail that silently stops arriving. A corrupt SSD is a week of my +work. None of these announce themselves; they all wait quietly until someone +looks. + +A loop is that someone. It turns "I should check on that" into "something +checks on that, forever, and only speaks when there is news." The payoff is +not magic autonomy — it is that I stopped being the monitoring system. I get +a Telegram message when something is actually wrong, and silence when it +isn't. + +That is the whole value proposition, and it is worth being precise about it, +because the 2026 hype around loops promises considerably more. + +## What loop engineering actually is + +The term arrived in June 2026. Peter Steinberger posted that you shouldn't +be prompting coding agents anymore — you should be designing the loops that +prompt them. Boris Cherny, who leads Claude Code at Anthropic, put it even +shorter: *"I don't prompt Claude anymore. I have loops running that prompt +Claude. My job is to write loops."* Addy Osmani published the essay that +gave the practice a name, and Anthropic made it official on June 30 with a +taxonomy of four loop types. + +It is the newest ring of a ladder that keeps wrapping itself: **prompt +engineering** (2022–24, *did I say it clearly?*), **context engineering** +(2025, *does it see the right thing?*), **harness engineering** (early 2026, +*does it keep doing it right?*), and **loop engineering** (2026, *does it +run without me?*). Nothing replaced anything. I still write prompts, usually +inside a script. + +Nobody has to adopt the word. The Register called it the latest buzzword in +June and made two fair points: agents have always been loops, and the +companies selling tokens are the companies most excited about loops that +spend tokens unattended. One widely-read report claimed a $1.3M monthly bill +for an always-on loop setup. Even Osmani's own essay ends with the caveat +that matters: *"The loop changes the work, it does not delete you from it."* + +My loops cost me almost nothing, and the reason is the first design rule +below. + +## The inventory + +| Job | Trigger | What proves it worked | Model? | +|---|---|---|---| +| Endpoint watchdog (7 targets, self-heal) | every 5 min | HTTP/TCP probe + control probe | no — script | +| Disk-corruption watchdog | daily 10:00 | SMART counters vs baseline | no | +| Domain-expiry reminder | daily 09:00 | 30-day / 7-day thresholds | no | +| Mail triage (`me@hoelee.com`) | 09:30 + 18:30 | unread mail, deduped | **yes** | +| Marketplace voucher audit | daily 09:00 | read-only portal scrape | **yes** | +| Real-IP + bot-block regression guard | hourly :20 | config invariants + log ratios | no | +| unRaid SSD watchdog | daily 10:00 | counter deltas | no | +| Mailbox health | monthly | IMAP/SMTP connect | no | +| Memory backup | weekly | archive written | no | +| Package version check | daily 09:00 | npm latest vs local | no | +| Research checkpoint reminder | 1st + 15th | reads project state, reminds me | **yes** | + +Eleven loops, eight of them with no model anywhere in the path. That ratio +is not an accident and it is not modesty — it is the single most useful +thing I learned. + +## Rule 1 — if the check is deterministic, do not spend a model on it + +My package version check used to be an agent job. It ran for weeks and +failed 29 times in a row before I stopped ignoring the notifications. The +cause was almost funny: an agent-mode job records the model it was created +with, and when my global default model later changed, the job refused to run +at all rather than silently adopt the new one. It printed `[drift_skip]` and +exited. Twenty-nine times. + +The task itself is trivial: fetch `registry.npmjs.org//latest`, compare +it with the local version, print something only if the remote is newer. +There is no judgment in that. So I rewrote it as 125 lines of Python with an +explicit exit path for every branch, and set the job to `no-agent` — the +script's stdout is delivered as the notification, no LLM in the loop. + +It has run green ever since. Anthropic's own guidance says the same thing in +one line: *"Use scripts for deterministic work — running a script is cheaper +than reasoning through the steps."* I'd add the stronger version: if the +stop condition is a string comparison, a model in that loop can only +introduce failure modes, and it will, at 3am, silently. + +The measured difference in my own fleet: 8 of 11 loops run with zero tokens. +The three that use a model all need judgment I cannot express as code — is +this email important, does this voucher need a human, is this project +stalled. + +## Rule 2 — the verifier must be able to fail loudly about itself + +The second thing I got wrong for a long time: a watchdog that only speaks +when something is broken is indistinguishable from a watchdog that has +stopped working. Both are silent. + +So every script in the fleet has one contract: **exit code 0 means +"measured, here is what I found" and exit code 1 means "I could not measure" +— and exit 1 is never silent.** A missing config file, an unparseable state +file, an unwritable state directory, an unexpected exception: all of them +print a `CANNOT MEASURE` line and exit 1. The runner wraps `main()` in a +blanket handler that catches anything I failed to anticipate, because the +one failure mode I cannot tolerate is a checker that has quietly died. + +The endpoint watchdog goes further, and this is my favourite piece of the +whole setup. Before it judges anything, it probes a **control target** — +`https://one.one.one.one/`. If that fails, the problem is my own uplink or +DNS, not my servers, and the watchdog reports instead of acting. Without +that gate, a router reboot would look exactly like seven simultaneous +outages, and an automated loop would have cheerfully restarted a perfectly +healthy VM in the middle of it. + +A verifier that cannot tell "broken" from "I can't see" will eventually take +a destructive action during a network outage. I would rather it know the +difference. + +The number I like quoting from this loop: it has ticked **1,618 times** +without a false alarm, because two consecutive failures are required before +anything is called down, a group that stays down is re-reported on a slow +cadence rather than every tick, and recovery is announced once with the +downtime window. + +## Rule 3 — give it the smallest autonomy that still helps + +The tempting design is "the loop fixes things." The design that survives +contact with production has three tiers, and each loop sits in exactly one: + +**Tier 1 — report only.** Most of my loops. They observe and tell me. Nothing they do can break anything, which means I can deploy them on a Friday. + +**Tier 2 — propose, human confirms.** The voucher audit runs daily and is *read-only by contract*. The prompt forbids creating or editing a voucher even if the script prints a proposal, because vouchers on that platform have no draft state: confirming means live and escrowed, with money attached. The loop's job is to hand me a decision I can make in ten seconds. + +**Tier 3 — act, within an allowlist.** Exactly one loop in the fleet acts, and the conditions are narrow: it restarts a VM only when the guest is *unreachable from the hypervisor* (verified by ping and ARP from the host), never when the guest is up but its service is failing — that is a bug to fix, not a machine to bounce. A three-run cooldown prevents a restart loop. And it never runs at all when the control probe failed. + +The pattern generalises: **each tier up must earn its right with a verifier +I trust more than the agent.** Am I allowed to walk away? Only if the thing +that decides "done" is something I would trust in a post-mortem. + +That restraint is also what the platform on the receiving end requires. An +audit that "helpfully" fixed its own findings would be a terms-of-service +problem, not a win. + +## Trap 1: a loop that fails forever looks exactly like a loop with nothing to report + +My silent-when-healthy convention is what makes these loops tolerable — no +daily status spam, only real news. It also hid a job that had failed **29 +consecutive times**, because a failed run and a clean run both delivered +nothing to my phone. + +Two things fixed it, and they apply to any unattended loop: + +- **A distinct "I could not measure" path** (Rule 2). It would have surfaced the drift on the first run instead of the twenty-ninth. +- **Check the runner's own history, not just the inbox.** The job's execution log had the evidence the whole time; I trusted the absence of a notification instead. Now, when I add a loop, I check its first few runs explicitly rather than waiting for it to tell me something. + +If your loop has ever been "quiet for a while", go look at its last ten runs +before you conclude it is working. + +## Trap 2: a verifier that deletes production data + +This one still stings. I had an end-to-end test script for a delivery +pipeline, and its cleanup step ran an unconditional `DELETE FROM orders`. It +was written to clean up after itself and it did — along with the seven real +orders that happened to be in the same table, and their generated PDFs, with +no backup to restore from. + +The lesson is not "test on a copy" (true, but I had). It is narrower and +harder: **a verifier is a program with permissions, and its write path needs +the same scrutiny as the thing it is verifying.** A loop I trust to run +unattended must only be destructive to what it created itself. Every +verification script in the fleet now scopes its cleanup to rows carrying its +own marker — `WHERE id IN ($MINE)` or `WHERE recipient LIKE '%@example.com'` +— and one of them did get rewritten after that incident for exactly this +reason. + +Osmani's point about comprehension debt lands here. The more smoothly the +loop runs, the less you read its output — and the day it is wrong, nobody +was watching. I read the diffs these loops produce. That habit is the entire +remaining job. + +## What I deliberately did not automate + +One of the eleven loops does nothing but remind me, twice a month, that a +research project exists and here are the commands to run next. It fetches +nothing, writes nothing, opens no browser. A loop's job is to keep *a* loop +turning — not necessarily its own. + +That is the honest boundary. These eleven jobs buy back attention and catch +failures early. They never make a decision I haven't already handed them, +and the judgement still comes from me. What changed is that I no longer +spend any part of my day wondering whether something is broken. + +## What I would do differently + +- **Start with the report-only tier and stay there longer.** I wrote one acting loop before I had the control probe and the cooldown, which is a mistake I got lucky on rather than deserved to avoid. +- **Add the "cannot measure" exit path to the first version of every script.** Retrofitting it across eleven jobs took an evening; it is one `try/except` in each. +- **Check the runner's execution log on day one**, not when a notification feels overdue. +- **Write the state file before you need it.** Every loop here keeps a small JSON next to it — tick number, last-known status, when something went down. A loop with no external state cannot tell you what it knew last time, which is most of what "did this recover?" means. + +## The result + +Eleven loops, eight of them running with no model and therefore no token +bill, covering seven monitored endpoints, a mailbox, a marketplace account, +a RAID array and a set of proxy invariants that a CyberPanel or DSM upgrade +can silently reset. The endpoint watchdog has completed 1,618 consecutive +ticks and has self-healed a hung VM from unreachable to healthy without me +touching it. The most expensive job in the fleet is a daily version check +that costs nothing and would have kept failing forever if I hadn't read its +history. + +If you are running anything you have to keep alive, the useful version of +"loop engineering" is not a fleet of agents. It is one cron job with a stop +condition you can test, a verifier that says when it cannot see, an exit +path for the failure you did not anticipate — and permission to do the +smallest thing that helps. Start with the one thing you check manually every +week, and make it load-bearing. + +## Open to work + +I'm a full-stack developer and DevOps engineer, and I'm open to remote or +hybrid roles — platform, infrastructure, DevOps, or full-stack. This blog is +the work sample: every post here is something I actually built and then had +to keep running, and the eleven loops above are the least glamorous and most +useful part of it. + +**Email: [me@hoelee.com](mailto:me@hoelee.com) · [LinkedIn](https://www.linkedin.com/in/hoelee) · [GitHub](https://github.com/hoelee) · [hoelee.com](https://hoelee.com)** +```` + +--- + +## 文章 03 / 10:用一个 Prometheus 监控 unRaid、群晖 NAS 和 VPS:我做错的四件事 +- **Slug**: `one-prometheus-for-unraid-synology-and-a-vps` +- **英文标题**: One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong +- **分类**: `case-studies` | **发布日期**: 2026-07-29 +- **线上地址**: https://blog.hoelee.com/posts/one-prometheus-for-unraid-synology-and-a-vps/ (EN) | https://blog.hoelee.com/posts/zh/one-prometheus-for-unraid-synology-and-a-vps/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "用一个 Prometheus 监控 unRaid、群晖 NAS 和 VPS:我做错的四件事" +description: "三台主机、一个 Prometheus、一个 Grafana:覆盖矩阵、把同一个 NAS 卷算成三次的\"总容量\",以及那台完全不导出 CPU 频率的虚拟机。" +pubDate: 2026-07-29 +updatedDate: 2026-09-29 +category: case-studies +blueprint: self-hosted-infrastructure +blueprintLayer: L3-observability-self-healing +tags: [prometheus, grafana, unraid, synology, dsm, cadvisor, monitoring, self-hosting] +ogImage: /og/zh-one-prometheus-for-unraid-synology-and-a-vps.jpg +banner: /banners/one-prometheus-for-unraid-synology-and-a-vps.jpg +draft: false +--- + +## 为什么这件事重要 + +当一套自托管基础设施在跑别人的网站、邮件和文件时,它就不是玩具了。糟糕的一周和糟糕的**一个月**之间,差别通常只是你多早发现:一块出现重分配扇区的盘、一个只剩 20 GB 的卷、一台被宿主机饿死的虚拟机、一个夜里重启四次的容器。 + +我有三台机器——unRaid(日常主力)、群晖 NAS(存储与服务)和 VPS(对外)——以及三套**各自**都不知道它们在干什么的方式。这篇文章讲它们如何变成一块屏,以及我路上做错的四件事——其中三个你也会踩。 + +## 架构 + +一个 Prometheus 加一个 Grafana,跑在永不关机的那台机器上。每台被监控主机跑两个 exporter: + +| 机器 | 主机指标 | 容器指标 | 抓取路径 | +|---|---|---|---| +| unRaid | node_exporter (`:9100`) | cAdvisor (`:8080`) | 直连 | +| 群晖 DSM | node_exporter (`:9100`) | cAdvisor (`:8082`) | 局域网 | +| ServerHosh VPS | node_exporter (`:9100`) | cAdvisor (`:8081`) | VPN 隧道 + `nginx` stream 中转 | + +**node_exporter 与 cAdvisor 不是替代关系——这是最容易搞错的一点。** node_exporter 看的是**主机**:CPU、内存、网络、磁盘、文件系统、温度;它对 cgroup 一无所知,说不出是哪个容器吃掉了内存。cAdvisor 只看**容器**。想要主机健康和按容器记账,两个都得跑;少一个,那个洞会在几个月后表现为一次无法解释的负载高峰。 + +## 错误一:cAdvisor 里那些\"不是容器的容器\" + +我的容器规则开始对不是容器的东西告警。cAdvisor 会为 **systemd slice** 和整机 cgroup 导出序列——其中一条没有名字的序列报告了约 58 GB 的\"内存使用\",却没有对应的容器。那就是整台主机,被描述成了一个容器。 + +修法是加一个过滤条件,但你得先知道要写它: + +```promql +# container metrics: anything with a name, and only that +container_memory_working_set_bytes{job=~"cadvisor.*", name!=""} +``` + +少了 `name!=""`,一条\"容器内存超过 10 GB\"的规则会对主机自己告警。 + +## 错误二:我的\"总容量\"是虚构的 + +聚合面板好看,但就是错的。原因:**同一个文件系统会在指标里出现多次。** + +- 在 NAS 上,主卷是 `/volume1`,而 `/opt` 是**同一个** btrfs 文件系统,只是换了个挂载点。 +- 在 unRaid 上,同一个 NAS 卷又以 CIFS 挂载成 `/mnt/remotes/_ActiveBackup`。 +- unRaid 的 `/var/lib/docker` 是那个池的子卷,而 `/mnt/ssd` 已经代表过同一个池——同样的字节,第二个身份。 + +所以朴素的 `sum(node_filesystem_size_bytes)` 报出了我并不存在的容量。诚实的写法是明确列出什么算数据存储: + +```promql +node_filesystem_size_bytes{ + mountpoint=~"/volume[0-9]+|/mnt/ssd|/mnt/disk[0-9]+", + fstype!~"fuse.*|tmpfs|rootfs" +} or node_filesystem_size_bytes{job="vps-host", mountpoint="/"} +``` + +还有两条应该写进面板说明的诚实备注,因为没人能解释的数字比没有数字更糟: + +- **校验盘对内核不可见。** unRaid 的奇偶校验盘没有文件系统,所以从不出现在指标里——这个和是可用容量,不是硬盘数量。 +- **镜像会虚增裸和。** 两块镜像 SSD 会各报一次自己的字节,和不是你能存的量。 + +还有那个以后一定会咬我的遗漏:unRaid 的 shfs 联合挂载(`/mnt/user`)报告 `avail=0`。把它算进\"剩余空间低于 X\"的规则里,就会产生一条永远无法解除的告警。 + +## 错误三:那台不导出 CPU 频率的虚拟机 + +为了算全家的\"总 CPU 频率\",我直接用 node_exporter 的 cpufreq collector。unRaid 和 NAS 都按核心报出了 `node_cpu_scaling_frequency_hertz`;VPS **什么都没有**——它是 KVM 客户机,而客户机没有 `/sys/devices/system/cpu/cpu0/cpufreq`。这个指标在那里无法存在。 + +修法是 textfile collector:一个小脚本读客户机**能**看到的东西(`/proc/cpuinfo`),把 Prometheus 格式指标写进 node_exporter 会抓取的目录: + +```sh +# /opt/node-exporter-textfile/cpu-mhz.sh — cron 每 5 分钟 +awk -F: ' + /^processor/ { c = $2; gsub(/[ \t]/, "", c) } + /^cpu MHz/ { f = $2; gsub(/[ \t]/, "", f); printf "node_cpu_mhz_current_hz{core=\"%s\"} %.0f\n", c, f * 1000000 } +' /proc/cpuinfo +``` + +配合: + +```yaml +command: + - '--collector.textfile.directory=/textfile' +volumes: + - /opt/node-exporter-textfile:/textfile:ro +``` + +两个刻意的决定:指标名**故意不同**于 node_exporter 自己的(用 `node_cpu_mhz_current_hz`,不用 `node_cpu_scaling_frequency_hertz`),这样万一以后宿主机暴露真实 cpufreq,不会出现重复序列冲突;仪表盘则显式合并两个来源: + +```promql +sum(node_cpu_scaling_frequency_hertz or node_cpu_mhz_current_hz) +``` + +## 错误四:exporter 把容器名当成了主机名 + +仪表盘的主机下拉框里出现了 `9f9afcccc962`。那是个容器 ID:node_exporter 的 `uname` collector 读到的是**进程自己的** UTS namespace,而容器的 hostname 默认就是它自己的 ID。指标没错,标签毫无意义。compose 里一行解决: + +```yaml +services: + node_exporter: + hostname: 2.hoelee.com # otherwise `nodename` = the container ID +``` + +## 我下次会怎么做 + +- **刻意把总览屏留到最后做。** \"总容量是多少\"这个问题会逼出全部去重工作;先做完三台主机面板再发现,就得在每个地方重做这个数字。 +- **一开始就给容器钉 hostname。** 一行成本,省掉一个让人困惑的下拉框。 +- **假设每个虚拟化或家电式主机都会藏掉一类指标。** NAS 可能限制你的容器监控(DSM 的 Docker API 版本把我的 cAdvisor 钉在 v0.53.0——更新的版本要更新的 Docker API);虚拟机藏掉 cpufreq;路由器藏掉自己的 CPU。找缺口的方法是**数你期待的东西**,而不是相信\"采集器成功了\"。 + +## 结果 + +三台主机、**7 个抓取目标、一个 Grafana、16 条告警规则**,以及一块屏:**47 个 CPU 核心 · 当前 158 GHz(标称上限 205 GHz)· 142 GB 内存 · 64 TB 存储 · 155 个运行中容器**,并且每台机器克隆同一套主机/容器面板布局,三台读起来完全一致。告警进 Telegram 和邮件,Prometheus 保留 30 天。 + +最有用的产出不是仪表盘,而是重写存储规则那天触发的一条告警:一个只剩 21 GB 的 NAS 卷——它一直藏在\"99% 满\"的百分比阈值背后,而那个巨物早已变成背景噪声。 + +## 需要为你的业务做这个吗? + +如果你有 NAS、VPS 和几台服务器,却没有一个地方能同时看它们,我可以帮你搭这样的自托管监控流水线——Prometheus + Grafana 覆盖你的机器,主机**与**容器指标,真正有意义的磁盘健康与容量规则,通知推到 Telegram 或邮件。 + +**WhatsApp:[+60 12-797 2969](https://wa.me/60127972969)** · **邮箱:[me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20monitoring%20setup)** · **[hoelee.com](https://hoelee.com)** + +网站设计与开发是我的主业;服务器加固与自托管基础设施是它的另一半。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong" +description: "Three hosts, one Prometheus, one Grafana. The coverage matrix, the storage total that counted one NAS volume three times, and the VM that exports no CPU frequency at all." +pubDate: 2026-07-29 +updatedDate: 2026-09-29 +category: case-studies +blueprint: self-hosted-infrastructure +blueprintLayer: L3-observability-self-healing +tags: [prometheus, grafana, unraid, synology, dsm, cadvisor, monitoring, self-hosting] +ogImage: /og/one-prometheus-for-unraid-synology-and-a-vps.jpg +banner: /banners/one-prometheus-for-unraid-synology-and-a-vps.jpg +draft: false +--- + +## Why this matters + +Self-hosted infrastructure is not a toy when it's running other people's websites, mail and files. The +difference between a bad week and a bad *month* is usually how early you found out: a +disk with a reallocated sector, a volume with 20 GB left, a VM being starved of CPU by +its host, a container that restarted four times overnight while you slept. + +I had three machines — unRaid (the daily driver), a Synology NAS (the storage and +services box) and a VPS (the public-facing one) — and three separate ways of *not* +knowing what they were doing. This is how they became one screen, and the four things I +got wrong on the way, because three of them are traps you will hit too. + +## The architecture + +One Prometheus and one Grafana, running on the host that never sleeps. Every monitored +host runs two exporters: + +| Machine | Host metrics | Container metrics | Scrape path | +|---|---|---|---| +| unRaid | node_exporter (`:9100`) | cAdvisor (`:8080`) | direct | +| Synology DSM | node_exporter (`:9100`) | cAdvisor (`:8082`) | LAN | +| ServerHosh VPS | node_exporter (`:9100`) | cAdvisor (`:8081`) | VPN tunnel + `nginx` stream relay | + +**node_exporter and cAdvisor are not substitutes — this is the first thing people get +wrong.** node_exporter sees the *host*: CPU, memory, network, disks, filesystems, +temperatures. It is cgroup-blind: it cannot tell you which container is eating your RAM. +cAdvisor sees *containers only*. If you want both host health and per-container +accounting, you run both. Dropping either one leaves a hole that shows up months later +as an unexplained load spike. + +## Wrong #1: cAdvisor's "containers" that aren't containers + +My container rules started firing on things that were not containers. cAdvisor exports +cgroup series for **systemd slices** and for the machine-wide cgroup — including one +unnamed series that reported roughly 58 GB of "memory usage" with no container attached +to it. That is the whole host, described as a container. + +The fix is one filter, but you have to know to write it: + +```promql +# container metrics: anything with a name, and only that +container_memory_working_set_bytes{job=~"cadvisor.*", name!=""} +``` + +Without `name!=""`, a "container using more than 10 GB" rule alerts on the host itself. + +## Wrong #2: my "total storage" number was fiction + +The aggregate panel looked great and was simply wrong. The reason: **one filesystem +appears in the metrics more than once.** + +- On the NAS, the primary volume is `/volume1` — and `/opt` is the *same* btrfs + filesystem, exposed under a second mount point. +- On unRaid, that same NAS volume is mounted again over CIFS as + `/mnt/remotes/_ActiveBackup`. +- unRaid's `/var/lib/docker` is a subvolume of the pool that `/mnt/ssd` already + represents — same bytes, second identity. + +A naive `sum(node_filesystem_size_bytes)` therefore reported capacity for disks I don't +have. The honest selector names exactly what counts as data storage: + +```promql +node_filesystem_size_bytes{ + mountpoint=~"/volume[0-9]+|/mnt/ssd|/mnt/disk[0-9]+", + fstype!~"fuse.*|tmpfs|rootfs" +} or node_filesystem_size_bytes{job="vps-host", mountpoint="/"} +``` + +Two more honesty notes that belong *in the panel description*, because a number nobody +can interpret is worse than no number: + +- **Parity disks are invisible to the kernel.** unRaid's parity drive has no filesystem, + so it never appears — the sum is usable capacity, not raw spindle count. +- **A mirror inflates a raw sum.** Two mirrored SSDs report their bytes twice; the sum + is not what you can store. + +And the omission that would have bitten me later: unRaid's shfs union mount (`/mnt/user`) +reports `avail=0`. Including it in a "free space below X" rule produces an alert that +can never be resolved. + +## Wrong #3: the VM that exports no CPU frequency + +Wanting "total CPU frequency" across the lab, I reached for node_exporter's cpufreq +collector. unRaid and the NAS reported `node_cpu_scaling_frequency_hertz` per core. The +VPS reported **nothing at all** — it's a KVM guest, and a guest has no +`/sys/devices/system/cpu/cpu0/cpufreq`. The metric cannot exist there. + +The fix is the textfile collector: a small script that reads what the guest *can* see +(`/proc/cpuinfo`) and writes Prometheus-format metrics into a directory node_exporter +scrapes: + +```sh +# /opt/node-exporter-textfile/cpu-mhz.sh — run from cron every 5 minutes +awk -F: ' + /^processor/ { c = $2; gsub(/[ \t]/, "", c) } + /^cpu MHz/ { f = $2; gsub(/[ \t]/, "", f); printf "node_cpu_mhz_current_hz{core=\"%s\"} %.0f\n", c, f * 1000000 } +' /proc/cpuinfo +``` + +with + +```yaml +command: + - '--collector.textfile.directory=/textfile' +volumes: + - /opt/node-exporter-textfile:/textfile:ro +``` + +Two deliberate decisions: the metric is published under a **different name** than +node_exporter's own (`node_cpu_mhz_current_hz`, not `node_cpu_scaling_frequency_hertz`) +so that if the host ever exposes real cpufreq there is no duplicate-series conflict, and +the dashboards merge the two sources explicitly: + +```promql +sum(node_cpu_scaling_frequency_hertz or node_cpu_mhz_current_hz) +``` + +## Wrong #4: my exporter was reporting the container's name as the host's + +The host dropdown in the dashboard offered `9f9afcccc962` as a machine. That's a container +ID: node_exporter's `uname` collector reports the *process's* UTS namespace, and a +container's hostname defaults to its own ID. The metric was correct, the label was +meaningless. One line in compose fixes it: + +```yaml +services: + node_exporter: + hostname: 2.hoelee.com # otherwise `nodename` = the container ID +``` + +## What I'd do differently + +- **Build the aggregate/overview screen last, deliberately.** Deciding "what is total + storage?" is what forces the deduplication work — discovering it after building three + host dashboards means rebuilding the number everywhere. +- **Pin container `hostname` from the start.** It costs one line and saves a confusing + dropdown. +- **Assume every virtualised or appliance host hides one class of metric.** A NAS may + cap your container monitor (DSM's Docker API version pinned my cAdvisor to v0.53.0 — + newer releases need a newer Docker API); a VM hides cpufreq; a router hides its own + CPU. Find the gap by *counting what you expected* rather than trusting that the + collector succeeded. + +## The result + +Three hosts, **7 scrape targets, one Grafana, 16 alert rules**, and a single screen that +reads: **47 CPU cores · 158 GHz currently clocked (205 GHz nominal) · 142 GB RAM · 64 TB +storage · 155 running containers**, with the same host and container dashboard layout +cloned per machine so the three read identically. Alerts land in Telegram and email; +Prometheus keeps 30 days. + +The most useful outcome wasn't the dashboard. It was the alert that fired the day the +storage rule was rewritten: a NAS volume with 21 GB left, which had been hidden behind a +percentage threshold on a volume so large that "99% full" had become background noise. + +## Want this for your business? + +If you run a NAS, a VPS and a couple of servers and have no single place to see them, I +set up self-hosted monitoring pipelines like this one — Prometheus + Grafana across your +machines, host *and* container metrics, disk-health and capacity rules that mean +something, and alerts pushed to Telegram or email. + +**WhatsApp: [+60 12-797 2969](https://wa.me/60127972969)** · **Email: [me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20monitoring%20setup)** · **[hoelee.com](https://hoelee.com)** + +Website design and development is my main line of work; server hardening and +self-hosted infrastructure is the other half of it. +```` + +--- + +## 文章 04 / 10:一个人跑生产基础设施:三台主机、162 个容器 +- **Slug**: `running-production-infrastructure-solo` +- **英文标题**: Running Production Infrastructure Solo: 162 Containers Across Three Hosts +- **分类**: `case-studies` | **发布日期**: 2026-10-06 +- **线上地址**: https://blog.hoelee.com/posts/running-production-infrastructure-solo/ (EN) | https://blog.hoelee.com/posts/zh/running-production-infrastructure-solo/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "一个人跑生产基础设施:三台主机、162 个容器" +description: "一个人、三台主机、78 个 compose stack 里的 162 个容器——Traefik、SSO、监控、邮件、CI/CD 与备份,以及两次改变了我「怎么核对数字」的故障。" +pubDate: 2026-10-06 +category: case-studies +blueprint: self-hosted-infrastructure +blueprintLayer: L1-compute-storage-network +tags: [docker, traefik, monitoring, infrastructure, devops] +ogImage: /og/zh-running-production-infrastructure-solo.jpg +banner: /banners/running-production-infrastructure-solo.jpg +draft: false +--- + +## 为什么这件事重要 + +卖软件的公司其实同时需要两样东西:一个能干活的应用,以及底下那套让它随时可达、有备份、够安全的机器。大多数情况是这两件事被拆开——开发的人看不见服务器,托管公司又读不懂代码。 + +我两件都做。也就是说,写这个应用的人,也是在 TLS 自动续期挂掉时被叫醒的人,而且能直接修应用,而不是把问题推给平台。 + +这是我自己的生产环境:三台自管的 Docker 主机,162 个容器分布在 78 个 compose stack 上,跑着真正对外的服务——客户网站、邮件平台、CI/CD、工作流自动化、访问统计,以及这个博客。没有云平台把它粘在一起,也没有团队在运维。下面是这套架构,以及两次改变了我「怎么检查自己的工作」的故障。 + +## 它的样子 + +| 主机 | 承担什么 | 容器 | Compose stack | +|---|---|---|---| +| Synology DSM | 存储、SSO、大部分内部服务 | 86 | 44 | +| unRaid | CI runner、媒体流水线、监控 | 37 | 21 | +| Ubuntu 24.04 VPS | 公网入口、邮件平台、客户站点 | 39 | 13 | + +这个划分是刻意的,不是历史遗留。VPS 承担的是「家里网断了也必须可达、必须快」的东西,因为公网流量和客户邮件都落在那里。Synology 承担需要磁盘和稳定文件系统的部分,以及所有其他服务都信任的身份源。unRaid 跑 CI runner 和那些又重又突发的批处理任务——否则它们会和服务抢内存。 + +## 让三台主机像一套系统的东西 + +**统一的入口模式。** 每台主机都跑一个 Traefik 反向代理,配自动化的 ACME 证书,所以证书自己续期,也没有哪个服务需要自己保管证书文件。后面是一套统一的路由和跳转约定,公网域名前面再套 Cloudflare。 + +**统一的身份源。** authentik 用 OIDC 和 proxy provider 做 SSO,所以新增一个内部服务是「在一个地方加一个应用」,而不是「在表格里再加一个密码」。对外的服务保持匿名可访问;其余的都从这里认证。 + +**统一的监控。** Prometheus、Grafana、cAdvisor 和 node-exporter 铺在三台主机上,收进同一个 Grafana 而不是三块面板——因为真正有意思的故障,都是跨主机的那种。 + +**统一的交付路径。** 自建的 Gitea Actions runner 负责构建和部署。这个博客就是走这条流水线:提交、runner 接单、容器重建、Cloudflare 把新构建送出去。 + +**经过验证、而不是假设的备份。** NAS 上的快照备份,加上 Duplicati 和 restic 任务,并把恢复步骤写下来——因为没验证过的备份只是一种感觉,不是备份。 + +## 失败一:仪表盘在骗我 + +搭监控的时候我选择相信 Grafana 显示的东西,结果有三块面板是错的。 + +cAdvisor 报出来的「容器」其实是 cgroup 的伪条目,把容器数量虚报了。我的「总容量」面板把一个 NAS 卷算了三遍,因为三个看起来差不多的挂载点被当成独立文件系统抓取了——这个数字看起来完全合理,而「看起来合理」正是它危险的地方。还有一台虚拟机根本不导出 CPU 频率,于是那块面板一直是空的,而我把它读成「暂时没数据」,而不是「这个 exporter 配错了」。 + +这三件事都没有弄坏任何服务。但三件事都会让我向客户报出一个假数字,因为仪表盘上的错数字和真数字长得一模一样。整个过程我写在 [One Prometheus for unRaid, a Synology NAS, and a VPS](https://blog.hoelee.com/posts/one-prometheus-for-unraid-synology-and-a-vps/) 里了。 + +## 失败二:少了一个配置文件,三种故障模式 + +我的团队密码管理器 Passbolt 开始返回 504,它的定时任务看起来卡住了。最后发现是三种不同的故障模式、同一个根因:一个只存在于运行中的容器里、从来没进 compose 文件的配置值。 + +空的 GPG fingerprint 让邮件发不出去,而发邮件这一步被放在定时任务里,于是整个任务挂住,UI 跟着一起挂。修好之后确实好了——直到容器被重建,配置被抹掉,原来的症状又回来了,看起来像问题自己复发。接着我为了追这两个问题引入的 `ssl.force`,在反向代理后面造成了跳转循环。 + +三个症状、三个晚上、一个教训:没有进版本控制的配置,就不算配置。完整的过程写在 [Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File](https://blog.hoelee.com/posts/passbolt-hang-three-failure-modes/)。 + +## 我怎么让数字保持诚实 + +简历上的数字会悄悄过期。我的数字是可以重新算出来的:一个小脚本通过 Portainer API 读活的 Docker 端点,逐台主机统计容器和 compose stack 的数量,所以「162 个容器、78 个 compose stack」是按需重新测量,而不是记住它曾经为真的那一天。同一个脚本还会打印出一份「绝不能出现在任何对客户材料里」的服务清单。 + +背后的规则很简单:一个无法被重新核对的数字,就是一个没人能在面试里、在报价里、在客户问「你怎么算出来的」那一通电话里守住的说法。凡是不能重新测量的数字,就不写进文档。 + +## 我会做得不一样的地方 + +**第一天就把每一个配置值放进 compose 文件**,包括那些感觉「属于机器而不是属于服务」的。Passbolt 那次复发,只因为一个修复活在容器里。 + +**对「没有数据」告警,而不是只对「坏数据」告警。** 一块空白的面板和一台健康服务,在仪表盘上长得一样。如果某个 exporter 十分钟没送来任何东西,那就是该告警的事件。 + +**用专门用来衡量可用性的东西去衡量可用性。** 指标流水线告诉你系统现在表现如何,它并不统计「成功请求 ÷ 总请求」。如果一个百分比要出现在客户面前,它需要一个按计划探测端点并记录结果的探针;否则诚实的回答是「有监控」,而不是一个数字。这个区别比数字本身更重要。 + +## 结果 + +三台主机、162 个容器、78 个 compose stack、一个人——加上自动化证书、统一 SSO、跨主机监控、经过验证的备份和自建 CI/CD。它同时跑过客户网站、一个邮件平台、一个 Telegram 客服 bot、排程自动化和这个博客;也正是它让我能把项目从「这是需求」做到「已经上线、有监控、有备份」,而不用把后半段交给别人。 + +## 想给你的生意也做一套? + +如果你需要一个既能写应用、又能跑它脚下那套基础设施的人——Docker 和 Traefik、自动化 TLS、SSO、监控、邮件、备份、CI/CD——我做的就是这个,而且我更想聊你的环境,而不是发一份宣传单。 + +**WhatsApp:[+60 12-797 2969](https://wa.me/60127972969)** · **Email:[me@hoelee.com](mailto:me@hoelee.com?subject=Infrastructure%20setup%20enquiry)** · **[hoelee.com](https://hoelee.com)** + +网站设计开发是我的主业;自托管基础设施、监控与自动化是它的另一半。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Running Production Infrastructure Solo: 162 Containers Across Three Hosts" +description: "One operator, three hosts, 162 containers in 78 compose stacks — Traefik, SSO, monitoring, mail, CI/CD and backups — and the two failures that changed how I verify my own numbers." +pubDate: 2026-10-06 +category: case-studies +blueprint: self-hosted-infrastructure +blueprintLayer: L1-compute-storage-network +tags: [docker, traefik, monitoring, infrastructure, devops] +ogImage: /og/running-production-infrastructure-solo.jpg +banner: /banners/running-production-infrastructure-solo.jpg +draft: false +--- + +## Why this matters + +A business that sells software needs two things at once: an application that +does the job, and the machinery underneath that keeps it reachable, backed +up and secure. Agencies usually split those two between a developer and a +hosting company, which is how you end up with a developer who cannot see the +server and a hosting company that cannot read the code. + +I run both. That means the person who wrote the application is also the +person who gets woken up when TLS renewal stops working, and who can fix the +application rather than blame the platform. + +This is my own production footprint: three self-managed Docker hosts running +162 containers across 78 compose stacks, serving live public services — +client websites, a mail platform, CI/CD, workflow automation, analytics and +this blog. No cloud platform holds it together, and no team operates it. +Here is the architecture, and the two failures that changed how I check my +own work. + +## The shape of it + +| Host | What it carries | Containers | Compose stacks | +|---|---|---|---| +| Synology DSM | storage, SSO, the bulk of internal services | 86 | 44 | +| unRaid | CI runner, media pipeline, monitoring | 37 | 21 | +| Ubuntu 24.04 VPS | public entry point, mail platform, client sites | 39 | 13 | + +The split is deliberate rather than historical. The VPS carries what must +stay reachable and fast when the home connection is not, because that is +where public traffic and client mail land. The Synology carries what needs +disk and a stable filesystem, plus the identity provider that every other +service trusts. unRaid runs the CI runner and the heavy, spiky batch work +that would otherwise compete with the services for RAM. + +## What makes three hosts one system + +**One ingress pattern.** Each host runs a Traefik reverse proxy with automated ACME TLS, so certificates renew themselves and no service owns a certificate file. Behind it there is one convention for routing and redirects, and Cloudflare sits in front of the public names. + +**One identity provider.** authentik handles SSO with OIDC and proxy providers, so a new internal service is a new application in one place instead of a new password in a spreadsheet. Public services stay anonymous; everything else authenticates through it. + +**One monitoring stack.** Prometheus, Grafana, cAdvisor and node-exporter across the hosts, in a single Grafana rather than three dashboards, because the interesting failures are the ones that cross host boundaries. + +**One delivery path.** Self-hosted Gitea Action runners build and deploy. This blog ships through that pipeline — commit, runner picks it up, container rebuilds, Cloudflare serves the new build. + +**Backups that are tested rather than assumed.** Snapshot-based backup on the NAS plus Duplicati and restic jobs, with restore procedures written down, because an untested backup is a feeling, not a backup. + +## Failure 1: the dashboard was lying to me + +When I built the monitoring stack I trusted what Grafana showed me, and +three of the panels were wrong. + +cAdvisor reported "containers" that were really cgroup pseudo-entries, which +inflated the container count. My total-storage panel counted one NAS volume +three times, because three near-identical mount points were being scraped as +separate filesystems — the number looked plausible, which is exactly what +made it dangerous. And one virtual machine exported no CPU frequency at all, +so its panel stayed empty and I read that as "no data yet" instead of "this +exporter is misconfigured". + +None of those broke a service. All three would have made me report something +false to a client, because a wrong number on a dashboard is +indistinguishable from a right one. I wrote the whole thing up in +[One Prometheus for unRaid, a Synology NAS, and a VPS](https://blog.hoelee.com/posts/one-prometheus-for-unraid-synology-and-a-vps/). + +## Failure 2: one missing config file, three failure modes + +Passbolt, my team password manager, started returning 504s and its cron job +appeared to hang. It turned out to be three separate failure modes with one +root cause: a configuration value that existed inside the running container +but never in the compose file. + +A null GPG fingerprint stalled mail delivery, and because the mail send sat +inside the scheduled job, the whole job hung and took the UI with it. The +fix worked — until the container was recreated, which wiped it and restored +the original symptom, so the problem appeared to come back on its own. Then +`ssl.force` behind a reverse proxy produced a redirect loop that I had +introduced myself while chasing the other two. + +Three symptoms, three sessions, one lesson: configuration that is not in +version control is not configuration. The full sequence is in +[Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File](https://blog.hoelee.com/posts/passbolt-hang-three-failure-modes/). + +## How I keep the numbers honest + +Numbers on a CV go stale quietly. Mine are re-derivable: a small script +reads the live Docker endpoints through the Portainer API and counts +containers and compose stacks per host, so "162 containers across 78+ +compose stacks" is re-measured on demand rather than remembered from the day +it was true. The same script prints the list of services that must never +appear in anything client-facing. + +The rule behind it is simple. A number that cannot be re-checked becomes a +claim nobody can defend — in an interview, in a proposal, or on a call when +a client asks how you got it. If I cannot re-measure a figure, it does not +go in the document. + +## What I would do differently + +**Every configuration value in the compose file from day one**, including the ones that feel like secrets of the machine rather than of the service. The Passbolt relapse existed only because a fix lived in a container. + +**Alert on the absence of data, not just on bad data.** A blank panel and a healthy service look identical on a dashboard. If nothing arrives from an exporter for ten minutes, that is the alert. + +**Measure availability with something built to measure availability.** A metrics pipeline tells you how a system is behaving; it does not count successful requests against total requests. If a percentage is going to appear in front of a client, it needs a probe checking endpoints on a schedule and recording the result — otherwise the honest answer is "monitored", not a number. That distinction matters more than the number. + +## The result + +Three hosts, 162 containers, 78 compose stacks, one operator — with +automated TLS, central SSO, cross-host monitoring, tested backups and +self-hosted CI/CD. It has run client websites, a mail platform, a Telegram +support bot, scheduling automation and this blog at the same time, and it is +the reason I can take a project from "here is the requirement" to "it is +live, monitored and backed up" without handing the second half to somebody +else. + +## Want this for your business? + +If you need someone who can build the application **and** run the +infrastructure it lives on — Docker and Traefik, automated TLS, SSO, +monitoring, mail, backups, CI/CD — I do exactly this, and I would rather +talk about your setup than send a brochure. + +- WhatsApp: [wa.me/60127972969](https://wa.me/60127972969) +- Email: [me@hoelee.com](mailto:me@hoelee.com?subject=Infrastructure%20setup%20enquiry) +- More: [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 05 / 10:数字商品的自愈式访问控制:NocoDB、n8n 与 AList +- **Slug**: `self-healing-digital-goods-entitlements` +- **英文标题**: Self-Healing Access Control for Digital Goods: NocoDB, n8n, and AList +- **分类**: `case-studies` | **发布日期**: 2026-09-18 +- **线上地址**: https://blog.hoelee.com/posts/self-healing-digital-goods-entitlements/ (EN) | https://blog.hoelee.com/posts/zh/self-healing-digital-goods-entitlements/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "数字商品的自愈式访问控制:NocoDB、n8n 与 AList" +description: "我如何用五个工作流搭出一套权限系统,自动授予、到期失效并持续修复客户的文件访问——包括一个构建期代码共享技巧,以及一个设计上刻意不泄露任何信息的公开接口。" +pubDate: 2026-09-18 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L3-entitlement-backoffice +tags: [n8n, nocodb, alist, access-control, docker, automation, digital-goods] +ogImage: /og/zh-self-healing-digital-goods-entitlements.jpg +banner: /banners/self-healing-digital-goods-entitlements.jpg +--- + +我卖数字产品——文件、美术素材、授权资源——并通过一个自建的文件门户交付。真正的难题从来不是存储,而是**授权**:确保客户在购买时拿到恰好他们买下的目录,拿到恰好他们付费的时长,并且在停止付费时访问权限真的被收回。 + +手工做这件事,前十个客户没问题。一百个就不行了,而且失败的方�式很具体、也很讨厌:静默失败。订阅到期而客户继续下载时,不会有任何报错。你只是一直在给一个几个月前就停止付费的人供文件。 + +所以我把它做成五个 n8n 工作流,把访问权限当作**派生状态**来处理——从我的业务数据库计算得出,应用到文件服务器上,并持续重新校验。下面是它的架构、让它可维护的代码共享技巧,以及那个公开接口背后的安全推理。 + +## 为什么这件事重要 + +对一个数字商品生意来说,访问控制*就是*产品本身。你保护的不是一座仓库,你保护的是你卖掉的那个东西。两种失败模式会真金白银地亏钱: + +- **授予不足**——付费客户拿不到他买的东西,而你是从愤怒的消息里而不是监控告警里知道的。 +- **授予过度**——已到期或已撤销的客户继续有访问权,而泄露会一直不可见,直到有人转卖你的目录。 + +手工管理最终会必然地导致这两种。解法是不要再把一次授权当成*你做的一件事*,而把它当成*你数据的一个函数*:给定客户当前的购买记录,他现在应该能访问什么?算出来、应用它,然后按计划证明它仍然成立。 + +## 系统的形状 + +五个工作流,每个只做一件事: + +| 工作流 | 触发器 | 职责 | +|---|---|---| +| **W1** — Customer Provision & Status Lifecycle | NocoDB webhook(客户行) | 创建/更新文件服务器用户;状态变更时启用或禁用 | +| **W2** — CustomerProduct Sync | NocoDB webhook(购买行) | 重新计算并应用该客户被允许的路径 | +| **W3** — Expiry Sync | Cron,每 5 分钟 | 扫描即将到期的授权;移除已失效的路径 | +| **W4** — Daily Full Reconciliation | Cron 03:00 + 手动 webhook | 对比全体客户的*期望*与*实际*;修复漂移;记录日志 | +| **W5** — Footer Purchase Check | 公开 GET webhook | 让门户展示客户自己的购买记录;只读 | + +数据存在 **NocoDB**(一个自建的 Airtable 类数据库)里,包括 Customers、CustomerProducts、Products、Resources 和 AccessGrants 几张表。文件服务器是 **AList**,它为每个客户提供一个用户和一个角色,角色的 `permission_scopes` 就是一串路径。 + +至关重要的设计决策:**NocoDB 是业务真相来源,AList 只是执行状态。** 同步是单向的。在 AList 管理后台手动改一笔不算配置变更——那叫漂移,W4 会把它修复回去。 + +## 算法:这个客户*应该*有什么? + +一切都系于一个函数。客户的期望路径是两个来源的并集: + +1. 所有可从**有效的、未过期**购买记录到达的资源。 +2. 所有通过**有效的、未过期**手工授权直接授予的资源。 + +有意思的情况是:同一个资源可以通过*两个不同*产品到达。如果客户买了产品 A(30 天后到期)和产品 B(200 天后到期),而两者都包含同一个目录,那么正确的到期时间是**较晚**的那一个——多买一样东西永远不该缩短你对它的访问权。 + +```js +// 对每个产品的资源,按路径保留最大的 expires_at。 +for (const c of cps) { + if (c.status && c.status !== 'active') continue; + if (c.expires_at && String(c.expires_at) <= today) continue; // 已过期 + const pid = linkId(c.product); + if (!pid) continue; + const res = (await ncGet(ctx, + `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || []; + const exp = c.expires_at; + for (const r of res) { + const p = resPath[r.Id]; + if (!p) continue; + const cur = desired[p]; + if (!cur || !cur.expires || (exp && exp > cur.expires)) { + desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 }; + } + } +} +``` + +注意边界:`<= today`。**当天到期就算已过期。** 到期检查上的一个差一错误,就是订阅期与白送一天之间的区别;如果你不把它写明确,你一定会写错,而且会错在对客户有利的那一边。 + +## 问题所在:n8n 的 Code 节点无法共享代码 + +这个约束塑造了整个代码库。n8n 的 Code 节点是自包含的:没有 `require`、没有 `import`、也没有访问共享模块的文件系统权限。所以最自然的结构——一个授权算法,被 W1、W2、W3、W4 调用——恰恰是这个平台不让你做的事。 + +把那个函数复制粘贴进四个节点,注定是一场维护灾难。四份到期规则的拷贝,就是四次它们互相不一致的机会,而一个用四种方式计算访问权限的系统,比没有系统更糟。 + +解法是让共享发生在**构建期**而不是运行期: + +- `shared/effective-grants.js` 是唯一真相来源,而且它被写成自包含的:没有 `require`、没有 `process.exit`、函数外没有顶层 `return`。它通过一个 `ctx` 参数(`{ $env, helpers }`)接收配置,而不是去抓全局变量——这带来一个令人愉快的副作用:它**可以在 n8n 之外做单元测试**。 +- `gen_w1.js`、`gen_w2.js`、`gen_w3.js`、`gen_w4.js` 读取这个文件,把它内联进工作流的 Code 节点主体,并更新工作流。 + +结果是:一个算法、四个工作流、零运行期依赖——而且这个函数可以在碰到任何线上系统之前,用纯 Node 测试。代码在*产物*里重复,但在*源码*里从不重复,这和打包器做的是同一笔交易。 + +一个 n8n 特有的细节值得知道:**自定义环境变量只有在 Code 节点里通过 `$env` 才可靠可读——`process.env` 在那里不可靠。** 这就是为什么每个工作流都有一个显式的「Load Env」节点,把它需要的值提升到 item 上,而不是哪里方便就在哪里读配置。 + +## 让到期处理可以安全自动化 + +W3 每五分钟跑一次,对即将到期的授权做对账。有两个细节让一个 cron 任务可以安全地改动线上权限: + +**只有当访问权限真的用完时才禁用。** 一个天真的「如果没有期望路径就禁用用户」规则是危险的——它会乐于禁用一位只是还没被授予任何东西的新客户。守卫条件是显式的: + +```js +// 只在「曾经有权限、现在一个都没有」的客户身上禁用。 +// currentPaths.length > 0 避免干掉一个全新的、尚未授权的客户。 +const shouldDisable = AUTO_DISABLE && nowEmpty + && cust.status === 'active' && currentPaths.length > 0; +``` + +**它有 dry-run 模式。** 容器上的 `W3_DRY_RUN=true`(或 W4 手动 webhook 上的查询参数)会让扫描计算并报告它的计划,同时**执行零写入**。能在让一个计划任务动手之前先问「你会做什么?」,是我在任何自动化里加过的最有用的安全功能。 + +```js +const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true'; +``` + +## 每日修复:假定你一定会漂移 + +W4 是那个我会说才是真正产品的工作流。它在 03:00 运行,遍历每个客户,对比期望状态与实际状态——然后修复差异并**验证修复**。 + +它处理的漂移矩阵: + +- **应当是有效的** → 用户必须存在(**先用用户名查找**,以避免在存储的 ID 丢失时创建重复用户)、处于启用状态,并且恰好带有计算出的角色权限范围。 +- **应当是无效的** → 禁用,并清空权限范围。待处理客户完全不建用户——不自动创建。 +- **悬空引用** → 存储的用户或角色 ID 指向一个已不存在的记录。按名字查找,找到就收编,找不到就重建。 +- **用户名漂移** → 只检测并*记录日志*,绝不做破坏性迁移。 + +每一次修复之后都会重新读取文件服务器状态来验证。失败验证会被记为 `verify_failed` 而不是假定成功,而对账日志只接收**漂移、修复和错误三类记录**——健康客户不产生任何行。最后这个选择正是让日志可用的原因:如果它是空的,一切正常,你不必读着一千行「无变化」去找那一条重要的。 + +## 那个公开接口,以及为什么没有 HMAC + +W5 让文件门户的页脚能展示已登录客户自己的购买记录和到期日期。它是一个**公开** webhook,而它的安全推理是我最刻意对待的部分。 + +直觉是用 HMAC 给请求签名。我没这么做,理由值得直说:**密钥必须发到浏览器,所以签名只是表演。** 一个每个客户端都持有的共享密钥什么都保护不了——它只增加了一层仪式,让这个接口*看起来*经过验证。 + +所以这个接口依赖的是真正成立的东西: + +- **CORS 锁定单一来源。** 响应带有 + `Access-Control-Allow-Origin: https://drive.example.com`,所以只有门户自己的页面能在浏览器里读取响应。 +- **数据最小化是设计出来的。** 响应只返回产品名、到期日期、展示状态和公开目录路径。没有内部数据库 ID、没有客户个人信息、没有任何其他客户的信息。 +- **边缘限流**,通过请求路径上的一条 WAF 规则,削弱用户名枚举。 + +还有一个细微的架构选择:**用户名来自调用者自己的会话 token,在客户端解码**,而不是来自一个客户端可以自由设置的参数。这个接口从不向文件服务器认证,从而避开了一整类连接状态与设备注册副作用——否则每次页脚渲染都会引入这些副作用。 + +诚实的说法是:这个接口不是信任边界,我也不假装它是。它向客户展示的只是他们本来就知道的关于自己的信息,走的是一个对任何其他人无用的响应形状。 + +## 教会我最多的那个 bug:公网路径与级联故障 + +W5 最初是通过 NocoDB 的**公网**主机名——穿过一个 Cloudflare 隧道——去取数据的。测试中它工作良好。在真实负载下,它产生了这样一条链: + +1. 公网往返延迟在负载下超过 60 秒。 +2. nginx 上游超时触发 → **504**。 +3. 页脚客户端 fetch 的 8 秒超时**每秒重试一次**。 +4. 重试堆积了连接。 +5. 这些连接耗尽连接池 → 无关请求收到 **503**。 + +一个慢依赖变成了一次不同服务的级联故障。修法是停止跨过整个互联网去访问同一个 Docker 网络上的东西: + +```js +// n8n 与 NocoDB 同处 bridge_hoelee 网络;NocoDB 监听 :10380。 +// 走公网路由会引入 CF 隧道抖动,且可能超过代理超时。 +const NOCODB_URL = 'http://nocodb:10380'; +``` + +**内部约 30 ms,公网 300 ms 以上,且没有隧道抖动**——整条故障链消失了,因为触发条件(数秒级延迟)已不可能出现。 + +这个教训可以很好地泛化到这个技术栈之外:**当一个服务和它的依赖在同一个容器网络里时,用公网主机名就是一个等着负载来触发的 bug。** 而当你看到一个 503 出现在一个 504 的下游时,去找那个激进重试的客户端——重试循环通常才是放大器,而不是原始问题。 + +## 我会怎么做得不一样 + +1. **先做漂移修复,而不是最后做。** 我先写授权路径,然后到期扫描,最后才对账。回头看,对账才是让另外两个可以安全运行的东西,它应该从第一天就存在——因为「假定你一定会漂移」是一种设计立场,不是一个功能。 +2. **在任何工作流之前先写期望状态函数。** 它能待在 `shared/` 里、能在 n8n *之外*做单元测试,正是整个系统在四个工作流之间保持连贯的原因。如果我一开始就把逻辑粘贴进节点,我就会发布四份微妙的、彼此不同的到期规则。 +3. **永远不要在同一台主机的两个容器之间走公网。** 这条让我真的经历了一次故障,而它现在是我默认应用的规则,而不是每个集成都要重新发现一次。 +4. **从第一天就把 dry-run 开关放进去。** 事后加 `DRY_RUN` 很容易;而在没有它的情况下运行一个会改动权限的计划任务,是几周完全不必要的提心吊胆。 + +## 结果 + +五个工作流跑完整个授权生命周期:数据库里的一笔购买在几秒内变成可用访问,到期每五分钟扫一次,每夜的完整对账修复任何漂移并验证每次修复。一切健康时对账日志是空的——而大多数日子里,它说的就是这件事。 + +值得带走的设计原则,是让这件事变得可控的那一条:**别再「管理」访问权限,开始「断言」它。** 把客户应该拥有什么定义成业务数据的纯函数,在数据变化时应用这个函数,并按计划重新断言它以捕获其他所有情况。这样系统就不需要你小心翼翼——它只需要你在一个函数里,一次性地正确。 + +--- + +## 想让你的生意也用上这套? + +如果你卖数字产品,还在手工授予文件访问权限——或者你不确定上个月到期的客户是否真的已经失去访问权——我搭的正是这套东西:自建授权系统,访问权限从你的数据计算得出、自动到期、每夜自我修复。我熟悉 NocoDB、n8n、AList 和 Docker,并且会把文档一并交付,让你不用依赖我也能运维。 + +欢迎联系 [me@hoelee.com](mailto:me@hoelee.com?subject=%E6%95%B0%E5%AD%97%E5%95%86%E5%93%81%E8%AE%BF%E9%97%AE%E6%8E%A7%E5%88%B6) +或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在 +[hoelee.com](https://hoelee.com) 做什么。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Self-Healing Access Control for Digital Goods: NocoDB, n8n, and AList" +description: "How I built a five-workflow entitlement system that grants, expires, and continuously repairs customer file access — with a build-time code-sharing trick and a public endpoint designed to leak nothing." +pubDate: 2026-09-18 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L3-entitlement-backoffice +tags: [n8n, nocodb, alist, access-control, docker, automation, digital-goods] +ogImage: /og/self-healing-digital-goods-entitlements.jpg +banner: /banners/self-healing-digital-goods-entitlements.jpg +--- + +I sell digital products — files, artwork, licensed assets — and deliver them +through a self-hosted file portal. The hard problem was never storage. It was +**entitlement**: making sure that when someone buys, they get exactly the +folders they paid for, for exactly as long as they paid for, and that when +they stop paying, the access actually goes away. + +Doing that by hand works for the first ten customers. It does not work for a +hundred, and it fails in a specific and nasty way: silently. Nothing errors +when a subscription expires and the customer keeps downloading. You just keep +serving files to someone who stopped paying months ago. + +So I built it as five n8n workflows that treat access as **derived state** — +computed from my business database, applied to the file server, and +continuously re-verified. Here's the architecture, the code-sharing trick that +makes it maintainable, and the security reasoning behind the one public +endpoint. + +## Why it matters + +For a digital-goods business, access control *is* the product. You're not +protecting a warehouse; you're protecting the thing you sold. Two failure modes +cost real money: + +- **Under-granting** — a paying customer can't reach what they bought, and you + find out from an angry message rather than a monitoring alert. +- **Over-granting** — expired or revoked customers keep access, and the leak + is invisible until someone resells your catalog. + +Manual administration guarantees both, eventually. The fix is to stop treating +a grant as a *thing you do* and start treating it as a *function of your data*: +given the customer's current purchases, what should they be able to reach right +now? Compute it, apply it, and then prove it's still true on a schedule. + +## The shape of the system + +Five workflows, each with one job: + +| Workflow | Trigger | Responsibility | +|---|---|---| +| **W1** — Customer Provision & Status Lifecycle | NocoDB webhook (customer row) | Create/update the file-server user; enable or disable on status change | +| **W2** — CustomerProduct Sync | NocoDB webhook (purchase row) | Recompute and apply that customer's allowed paths | +| **W3** — Expiry Sync | Cron, every 5 minutes | Sweep expiring entitlements; deselect paths that lapsed | +| **W4** — Daily Full Reconciliation | Cron, 03:00 + manual webhook | Compare *desired* vs *actual* for everyone; repair drift; log | +| **W5** — Footer Purchase Check | Public GET webhook | Let the portal show a customer their own purchases; read-only | + +The data lives in **NocoDB** (a self-hosted Airtable-style database) as +Customers, CustomerProducts, Products, Resources, and AccessGrants. The file +server is **AList**, which gives each customer a user and a role whose +`permission_scopes` is a list of paths. + +The crucial design decision: **NocoDB is the business source of truth. AList is +only enforcement state.** Sync runs one way. A manual tweak in the AList admin +UI is not a configuration change — it's drift, and W4 repairs it back. + +## The algorithm: what *should* this customer have? + +Everything hinges on one function. Desired paths for a customer is the union of +two sources: + +1. Every resource reachable from an **active, not-yet-expired** purchase. +2. Every resource granted directly via an **active, not-yet-expired** manual grant. + +The interesting case is a resource reachable through *two* different products. +If a customer bought Product A (expires in 30 days) and Product B (expires in +200 days) and both include the same folder, the correct expiry is the **latest** +one — buying more of something should never shorten your access to it. + +```js +// For each product's resources, keep the MAX expiry per path. +for (const c of cps) { + if (c.status && c.status !== 'active') continue; + if (c.expires_at && String(c.expires_at) <= today) continue; // expired + const pid = linkId(c.product); + if (!pid) continue; + const res = (await ncGet(ctx, + `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || []; + const exp = c.expires_at; + for (const r of res) { + const p = resPath[r.Id]; + if (!p) continue; + const cur = desired[p]; + if (!cur || !cur.expires || (exp && exp > cur.expires)) { + desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 }; + } + } +} +``` + +Note the boundary: `<= today`. **Expiring today counts as expired.** Off-by-one +on an expiry check is the difference between a subscription period and a free +extra day, and if you're not explicit about it you'll get it wrong in the +customer's favour. + +## The problem: n8n Code nodes can't share code + +Here's the constraint that shaped the whole codebase. n8n Code nodes are +self-contained: there's no `require`, no `import`, no filesystem access to a +shared module. So the obvious structure — one grants algorithm, called by W1, +W2, W3, and W4 — is exactly what the platform prevents. + +Copy-pasting that function into four nodes would have been a guaranteed +maintenance disaster. Four copies of an expiry rule is four chances for them to +disagree, and a system that computes access four different ways is worse than +no system. + +The fix was to make the sharing happen at **build time** instead of run time: + +- `shared/effective-grants.js` is the single source of truth, and it's written + to be self-contained: no `require`, no `process.exit`, no top-level `return` + outside a function. It also takes its config through a `ctx` argument + (`{ $env, helpers }`) rather than reaching for globals — which has the happy + side effect that it's **unit-testable outside n8n entirely**. +- `gen_w1.js`, `gen_w2.js`, `gen_w3.js`, `gen_w4.js` read that file, inline it + into the workflow's Code node body, and update the workflow. + +The result is one algorithm, four workflows, zero runtime dependencies — and a +version of the function that I can test with plain Node before it ever touches +a live system. The code is duplicated in *artifacts* but never in *sources*, +which is the same tradeoff a bundler makes. + +One n8n-specific wrinkle worth knowing: **custom environment variables are only +reliably readable via `$env` inside a Code node — `process.env` is not +dependable there.** That's why every workflow has an explicit "Load Env" node +that lifts the values it needs onto the item, rather than reading config +wherever it's convenient. + +## Making expiry safe to automate + +W3 runs every five minutes and reconciles expiring entitlements. Two details +make it safe to let a cron job modify live access: + +**It only disables when access genuinely ran out.** A naive "if no desired +paths, disable the user" rule is dangerous — it will happily disable a +brand-new customer who simply hasn't been granted anything yet. The guard is +explicit: + +```js +// Only disable when a customer that HAD scopes now has none. +// currentPaths.length > 0 avoids killing a fresh, not-yet-granted customer. +const shouldDisable = AUTO_DISABLE && nowEmpty + && cust.status === 'active' && currentPaths.length > 0; +``` + +**It has a dry-run mode.** `W3_DRY_RUN=true` on the container (or a query +parameter on the manual webhook for W4) makes the sweep compute and report its +plan while performing **zero writes**. Being able to ask "what would you do?" +before letting a schedule do it is the single most useful safety feature I've +added to any automation. + +```js +const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true'; +``` + +## The daily repair: assuming you'll drift + +W4 is the workflow I'd argue is the real product. It runs at 03:00, walks every +customer, and compares desired state against actual state — then repairs the +difference and **verifies the repair**. + +The drift matrix it handles: + +- **Should be active** → user must exist (looked up *by username first*, to + avoid creating duplicates if a stored ID was lost), be enabled, and carry + exactly the computed role scopes. +- **Should be inactive** → disabled, with scopes emptied. Pending customers + stay without a user entirely — no auto-creation. +- **Dangling references** → a stored user or role ID points at a record that no + longer exists. Look it up by name, adopt it if found, recreate if not. +- **Username drift** → detected and *logged*, never destructive-migrated. + +Every repair is verified by re-reading the file server's state afterwards. A +failed verification is logged as `verify_failed` rather than assumed successful, +and the reconciliation log only receives **drift, repair, and error rows** — +healthy customers produce nothing. That last choice is what makes the log +usable: if it's empty, everything is fine, and you don't have to read past a +thousand "no change" lines to find the one that matters. + +## The public endpoint, and why there's no HMAC + +W5 lets the file portal's footer show a signed-in customer their own purchases +and expiry dates. It's a **public** webhook, and the security reasoning is the +part I'm most deliberate about. + +The obvious instinct is to sign requests with an HMAC. I didn't, and the reason +is worth stating plainly: **the key would have to ship to the browser, so the +signature would be theatre.** A shared secret that every client holds protects +nothing — it just adds ceremony that makes the endpoint *look* verified. + +So the endpoint relies on things that actually hold: + +- **CORS locked to one origin.** The response carries + `Access-Control-Allow-Origin: https://drive.example.com`, so only the portal's + own pages can read the response in a browser. +- **Minimal data by design.** The response returns product name, expiry date, + display state, and public folder paths. No internal database IDs, no customer + PII, nothing about other customers. +- **Rate limiting at the edge,** via a WAF rule on the request path, to blunt + username enumeration. + +And one subtle architectural choice: **the username comes from the caller's own +session token, decoded client-side**, rather than from a parameter the client +can set freely. The endpoint never authenticates to the file server, which +avoids a whole class of connection state and device-registration side effects +that a server-side login would introduce on every footer render. + +The honest framing: this endpoint is not a trust boundary, and I don't pretend +it is. It shows a customer what they already know about themselves, over a +response shape that's useless to anyone else. + +## The bug that taught me the most: public path, cascading failure + +W5 originally fetched data by calling NocoDB at its **public** hostname — +through a Cloudflare tunnel. It worked in testing. Under real load it produced +this chain: + +1. Public round-trip latency exceeded 60 seconds under load. +2. nginx upstream timeout fired → **504**. +3. The footer's client-side fetch had an 8-second timeout and retried **every + second**. +4. Retries piled up connections. +5. Those connections exhausted the pool → **503** for unrelated requests. + +A slow dependency became a cascading outage of a different service. The fix +was to stop crossing the internet to reach something on the same Docker +network: + +```js +// n8n and NocoDB share the `bridge_hoelee` network; NocoDB listens on :10380. +// The public route added CF-tunnel jitter and could exceed the proxy timeout. +const NOCODB_URL = 'http://nocodb:10380'; +``` + +**~30 ms internal versus 300 ms+ public, with no tunnel variance** — and the +entire failure chain disappeared, because the trigger condition (multi-second +latency) can no longer occur. + +The lesson generalises well beyond this stack: **when a service and its +dependency are in the same container network, the public hostname is a bug +waiting for load.** And when you see a 503 downstream of a 504, look for a +client that retries aggressively — the retry loop is usually the amplifier, not +the original problem. + +## What I'd do differently + +1. **Build the drift repair first, not last.** I wrote the grant path, then the + expiry sweep, then the reconciliation. In retrospect the reconciliation is + what makes the other two safe to operate, and it should have existed from + day one — because "assume you will drift" is a design stance, not a feature. +2. **Write the desired-state function before any workflow.** Having it live in + `shared/` and be unit-testable *outside* n8n is why the whole system stayed + coherent across four workflows. If I'd started by pasting logic into nodes, + I'd have shipped four subtly different expiry rules. +3. **Never cross the public internet between two containers on the same host.** + This one cost me a genuine outage, and it's a rule I now apply by default + rather than discovering per-integration. +4. **Put the dry-run switch in from the beginning.** Adding `DRY_RUN` after the + fact was easy; operating a scheduled job that mutates access *without* one + was an unnecessary few weeks of nerves. + +## The result + +Five workflows run the full entitlement lifecycle: a purchase in the database +becomes usable access within seconds, expiries sweep every five minutes, and a +nightly full reconciliation repairs any drift and verifies each repair. The +reconciliation log is empty when everything is healthy — which, most days, is +what it says. + +The design principle worth taking away is the one that made it tractable: +**stop administrating access, and start asserting it.** Define what a customer +should have as a pure function of your business data, apply that function +whenever the data changes, and re-assert it on a schedule to catch everything +else. Then the system doesn't need you to be careful — it just needs you to be +correct once, in one function. + +--- + +## Want this for your business? + +If you sell digital products and you're still granting file access by hand — +or you're not certain that expired customers lost access last month — I build +exactly this: self-hosted entitlement systems where access is computed from +your data, expires on its own, and repairs itself nightly. I work with +NocoDB, n8n, AList, and Docker, and I hand it back documented so you can +operate it without me. + +Reach me at [me@hoelee.com](mailto:me@hoelee.com?subject=Access%20control%20for%20digital%20goods) +or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at +[hoelee.com](https://hoelee.com). +```` + +--- + +## 文章 06 / 10:自建语音转文字服务:打字速度的五倍,而且数据不出内网 +- **Slug**: `self-hosted-speech-to-text-api` +- **英文标题**: Your Own Speech-to-Text Server: Faster Than Typing, Private by Default +- **分类**: `case-studies` | **发布日期**: 2026-09-19 +- **线上地址**: https://blog.hoelee.com/posts/self-hosted-speech-to-text-api/ (EN) | https://blog.hoelee.com/posts/zh/self-hosted-speech-to-text-api/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "自建语音转文字服务:打字速度的五倍,而且数据不出内网" +description: "我用一台带 GPU 的电脑自建了语音转文字 API。Windows、iPhone、iPad、Android 都能语音输入——私密、不限量,速度是打字的大约 5 倍。" +pubDate: 2026-09-19 +category: case-studies +tags: [whisper, cuda, stt, n8n, nginx, self-hosting, ai] +ogImage: /og/zh-self-hosted-speech-to-text-api.jpg +banner: /banners/self-hosted-speech-to-text-api.jpg +--- + +打字的办公效率里面,它是最慢的一环。一般人打字速度约每分钟 40 个字,但 +**说话是 130 字**。每一封邮件、每一份报价、每一份报告、每一次客服回复、每一 +条聊天消息,公司都在为这个差距付税——而付税的人通常是键盘打得最快的那个。 + +语音转文字可以把这个税省掉。但多数人采用的那套方案有两个问题:按人头收月费, +而且把你的声音——内部讨论、客户姓名、报价、合约内容——全部送到别人的云端。 + +所以我做了另一个版本:**一台办公室里的普通电脑,用它自己的 GPU,跑起一套 +语音转文字 API。**我的开发电脑、iPhone、iPad、Android 手机都通过它来语音输入。 +比打字快,不限量,而且没有任何数据离开我的内网。 + +以下是怎么做的——包括花掉我大半天时间的四个坑。 + +## 为什么值得做 + +语音转文字是办公自动化里杠杆最高、却又最不花哨的一环。具体来说,自建方案能给 +你这些东西: + +- **大约是打字的 5 倍吞吐量。**说话 130 wpm 对比打字约 40 wpm,口述一封 500 字 + 的邮件大约是说 4 分钟,而不是打 12 分钟。一天写 10 封邮件的人,每天省下一 + 小时。 +- **成本跟着硬件走,不跟人头走。**云端听写是按用户按月收费,而且一直收。这套 + 跑在你自己买的机器上。加到第 10 个员工,边际成本是零。 +- **不限长度,没有额度的焦虑。**没有分钟数上限,不会在星期五下午四点跳出来 + 说"你已用完本月额度"。 +- **你的音频还是你的。**医疗记录、法律草稿、人事对话、客户报价——语音天生 + 就是敏感数据。自建意味着转写内容不会离开公司。 +- **在任何已经获得焦点的程序里都能用。**不是那种"先转写、再复制粘贴"的网页 + 工具,而是可以直接在 Outlook、WhatsApp Web、CRM 或你自家内部系统里用的 + 输入键盘。 + +如果你公司里有一群人整天在写字,这件事的意义跟当年从拨号换到宽带是同一个 +量级——而且它花的只是一张你可能早就有的显卡。 + +## 需要准备什么 + +| 部件 | 我用的是 | 说明 | +|---|---|---| +| GPU 电脑 | RTX 3060(12 GB)的台式机 | 任何显存 ≥6 GB 的 NVIDIA 显卡都行,可以就是一台普通办公电脑 | +| 转写引擎 | `whisper.cpp` + CUDA | 开源免费 | +| 模型 | `large-v3`(约 2.9 GB) | 准确率最好;小模型占显存更少 | +| 分发层 | n8n + nginx 网关 | 加上认证,端点才能安全共享 | +| 客户端 | 桌面程序、iPhone、iPad、Android | 任何支持 OpenAI 转写 API 的客户端 | + +这台电脑不必专用。我的同时还在做开发、跑本地大模型推理和图像生成——模型在 +有请求时加载、空闲时卸载,所以它不会长期占着显存。 + +## 整体架构 + +最重要的设计决定是:**手机从来不直接连 GPU。**中间有一个看门人负责认证,GPU +本身一直待在私有网络里。 + +```text + iPhone / iPad / Android / 办公电脑 + │ + │ HTTPS + 每台设备独立的 API key + ▼ + ┌───────────────────────────┐ + │ https://stt.example.com │ 公网 HTTPS 入口 + │ reverse proxy │ + └─────────────┬─────────────┘ + ▼ + ┌───────────────────────────┐ + │ nginx 网关容器 │ 转发认证头,固定路由 + └─────────────┬─────────────┘ + ▼ + ┌───────────────────────────┐ + │ n8n 工作流 │ 校验 key,不符返回 401/403 + │ (音频永不落库) │ + └─────────────┬─────────────┘ + ▼ + ┌───────────────────────────┐ + │ PC 上的 whisper.cpp │ GPU 转写 + │ :20129 → {"text": …} │ + └───────────────────────────┘ +``` + +四层,每层只做一件事:代理终结 TLS,网关修好路由,n8n 负责授权,whisper 负责 +转写。最终对外是 `POST /v1/audio/transcriptions`——跟 OpenAI 同一个形状,所以 +任何 OpenAI 兼容的客户端都能零改动接上。 + +## 开始动手 + +whisper 需要 CUDA 和 CMake 工具链。在 Windows 上就是装三样东西然后编译: + +```bash +git clone https://github.com/ggerganov/whisper.cpp +cd whisper.cpp +cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 +cmake --build build --config Release -j +``` + +然后取模型并启动: + +```bash +# large-v3, ~2.9 GB +cd models && sh ./download-ggml-model.sh large-v3 && cd .. +./build/bin/Release/whisper-server.exe \ + -m models/ggml-large-v3.bin \ + --host 0.0.0.0 --port 20129 \ + --convert +``` + +`--convert` 比看上去重要:有了它,服务可以接受 MP3 等压缩格式(内部调用 +ffmpeg 转码),而不是逼每个客户端都发原始 WAV。多数手机 App 发的都是压缩音频。 + +到这里服务已经是一个可以用的转写 API 了。后面所有的工作,都是让它能够安全地 +从手机访问。 + +## 坑一:CUDA Toolkit 装了一个不完整的编译器 + +CUDA Toolkit 安装程序的默认组件里包含 `nvcc`——但**不包含 `nvcc` 真正编译时 +需要的那些部件**。我前两次 CMake 都因为缺头文件失败,而错误信息指向的是我的 +编译配置,不是一个残缺的工具链。 + +解决办法是把三个组件显式勾上: + +```text +crt_13.x → C 运行时头文件(缺的就是 ) +nvvm_13.x → 里面有 cicc,才是真正的 CUDA 编译器后端 +cublas_13.x → cuBLAS,运行时链接需要 +``` + +要留意的症状:`nvcc --version` 能正常输出,但编译立刻因为缺头文件挂掉。 +**能跑的 nvcc 不等于装好的工具链。** + +## 坑二:它只监听 localhost,防火墙是无辜的 + +whisper 默认绑定 `127.0.0.1`——只有本机回环。不管防火墙怎么设,网络上的其他 +设备都到不了。 + +我在这里浪费了不少时间,因为我认定是防火墙问题,反复测防火墙(甚至整个关掉), +而真正的原因在绑定地址上。loopback-only **在定义上**就拒绝所有其他网卡。 + +```bash +# 端口活着但没对外暴露时,长这样 +netstat -an | grep 20129 +# TCP 127.0.0.1:20129 0.0.0.0:0 LISTENING ← 只有你能连 +``` + +改掉绑定,情况就不一样了: + +```bash +--host 0.0.0.0 +``` + +```text + TCP 0.0.0.0:20129 0.0.0.0:0 LISTENING ← 网络能连了 +``` + +**先看绑定,再去碰防火墙。**这两种故障的表现完全一样——连接超时——而只有其中 +一种真的是防火墙问题。 + +还有一个相关的小坑:Windows 防火墙的配置文件状态会自动恢复。一条限定 +`Private,Domain` 的规则,在 Windows 把这个网络重新归类成 *Public* 之后就会 +静默失效;某次更新把配置文件重新打开也有同样效果。规则要按网段限定,而不是按 +配置文件名称: + +```powershell +New-NetFirewallRule -DisplayName 'Whisper STT' -Direction Inbound -Action Allow ` + -Protocol TCP -LocalPort 20129 -Profile Any -RemoteAddress LocalSubnet +``` + +`-RemoteAddress LocalSubnet` 让它在内网可用、在公网不可达——即使前面已经有 +认证,这也才是正确的姿势。 + +## 坑三:n8n 会把你的音频丢掉(两次) + +把 n8n 放在链路里是刻意的——API key 校验就住在那里——但它有两个行为会静默地 +把代理弄坏。 + +**第一:Code 节点会丢弃二进制数据。**我的流程是 Webhook → Code(校验 key)→ +HTTP Request(转发音频)。Code 节点返回的是 JSON,而 n8n 的二进制负载——也就 +是音频本身——根本没穿过它。随后报错:*"Make sure that the previous node +outputs a binary file."* + +n8n 的 Webhook 节点会接收 multipart 上传并存为二进制。要在校验 key 的同时把 +它保住,Code 节点必须显式把二进制引用传下去,而不只是返回 JSON。而那个 key 的 +名字,你绝对猜不到: + +```text +┌──────────────┬────────────────────────────┐ +│ 上传的字段名 │ n8n 里的 binary key │ +├──────────────┼────────────────────────────┤ +│ file │ data0 │ +│ (第二个) │ data1 │ +└──────────────┴────────────────────────────┘ +``` + +multipart 字段叫 `file`,但 n8n 把它索引成 `data0`。转发 `$binary.data` 会失败, +`$binary.data0` 才行。 + +**第二:执行记录会把音频存下来。**n8n 默认保留执行数据——包括二进制负载——也 +就是说每一次口述都会被写进工作流数据库。对音频来说这是不可接受的。关掉它: + +```json +"settings": { "saveDataSuccessExecution": "none" } +``` + +⚠ **这是最有杀伤力的一个坑。**如果你通过 n8n API 改工作流而漏掉了 `settings` +对象,这次更新会**静默重置它**,于是你又开始记录音频了。系统不会有任何提示。 +每次改完工作流都要验证: + +```bash +curl -s -H "X-N8N-API-KEY: $KEY" \ + http://localhost:5678/api/v1/workflows/ \ + | jq '.settings' +``` + +## 坑四:nginx 自带的默认配置劫持了 80 端口 + +我的网关是一个 nginx 容器。它对那些明明配置好的路径一直返回 404——因为自带的 +`default.conf` 同样在 80 端口声明了一个 server 块,而它作为默认 server 抢走了 +流量。我的配置加载正常、内容正确,只是根本收不到请求。 + +```dockerfile +# 启动前删掉自带配置 +command: > + sh -c "rm -f /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'" +``` + +同一个构建里还有两个小经验,部署到 NAS 之前值得先知道: + +- **不能挂载一个还不存在的配置文件**——部署会失败,报 *"Bind mount failed: + ... does not exist."* 在 DSM 上,从另一个容器里把文件写出来这个常见绕法也不行, + 因为容器只看得见自己的挂载,看不见真正的主机文件系统。我最后把配置 base64 + 内嵌进 compose 的 `command`,启动时再写出来——完全不需要碰主机文件系统。 +- **容器能看到的 `/volume1` 挂载是只读的**,所以那些看上去能写的路径都是封的。 + +## 授权模型,说实话 + +两层,值得讲清楚每一层到底买到了什么。 + +**每台设备独立的 API key。**每台设备有自己的 key,所以挂失一台手机不影响其他 +设备: + +```json +{ + "iphone": "stt_", + "ipad": "stt_", + "laptop": "stt_" +} +``` + +n8n 工作流拿 `Authorization` 头去比对这份名单,不匹配就返回真正的 403。有个 +细节:API key 放在 URL 查询串里会落进代理日志和浏览器历史——始终放在请求头。 + +**这层保护了什么。**它阻止别人白用你的 GPU,也让这个端点不至于变成互联网上一个 +人人可用的转写服务。它**不**负责加密音频——那是代理层的 TLS,这也是公网入口 +只走 HTTPS 的原因。 + +这是**一个私有服务的访问控制**,不是一个加固过的公开 API。目前没有限流,也没有 +按 key 的配额。对于几台已知设备的内网办公部署,这个比例是合适的;如果要开放给 +第三方,那就得再加上限流、日志和轮换。 + +## 如果重来一次 + +1. **先验证绑定地址,而不是防火墙。**两个小时花在测错误的层上,而这件事 + `netstat` 一行就能回答。两种故障都表现为"连接超时",而我本该知道 + loopback-only 不管防火墙什么状态都会拒绝一切。 +2. **确认安装程序到底装了什么。**我把 `nvcc --version` 当成了工具链可用的证据。 + 一个能跑、但找不到自己头文件的编译器,并不算装好了——而它的报错不会告诉你 + 这一点。 +3. **在搭管线之前就定好日志策略,而不是搭完之后。**我是在构建过程中设上 + `saveDataSuccessExecution: none` 的,但同一次修改如果走 API 就会静默地把 + 音频留存重新打开,毫无提示。能被无关操作重置的隐私设置,需要一个验证步骤。 + +## 结果 + +四类设备——一台 Windows 开发电脑、iPhone、iPad、Android——都在通过我自己网络上 +的一张 GPU 做语音输入。短句转写通常远低于一秒,穿过四层之后的完整请求大约 +2 秒返回真实文本。 + +最值得说的量化收益是显存。因为模型空闲时会卸载,一个开关就能把 GPU 占用从 +**4,906 MiB 降到 1,321 MiB**——回收约 3.6 GB——机器可以回去做开发、本地大模型 +推理或图像生成,下一次口述时再把模型加载回来。 + +关于速度,说句公道话:**大约比打字快 5 倍**这个数字对散文体(说话 130 wpm +对比打字约 40 wpm)是成立的。写代码就没这么夸张,因为那更多是在思考而不是在 +打字。收益最大的是办公室真正被淹没的那部分工作——邮件、文档、笔记、合约、 +客服回复。 + +## 这套东西能用在你的公司吗? + +部件都不花哨,回报却很直接:一台带 GPU 的机器、开源软件,再加一层负责认证的 +分发层。没有按人头的月费,没有分钟数配额,没有音频离开你的场地——全公司从打字 +切换到语音输入。 + +我做的就是这类内部基础设施:带正规认证的自建服务、能合理共享硬件的 GPU 负载, +以及让它们在员工手上已有的设备里真正可用的集成层。如果你想让语音转文字(或者 +其他内部 API)跑在你自己的硬件上——哪怕是公司现成的一台工作站——这正是我在做 +的事。 + +联系我:[me@hoelee.com](mailto:me@hoelee.com?subject=自建语音转文字服务) +或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在做什么: +[hoelee.com](https://hoelee.com)。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Your Own Speech-to-Text Server: Faster Than Typing, Private by Default" +description: "I run a self-hosted speech-to-text API on a single GPU PC. Dictation works from Windows, iPhone, iPad and Android — private, unlimited, and about 5x faster than typing." +pubDate: 2026-09-19 +category: case-studies +tags: [whisper, cuda, stt, n8n, nginx, self-hosting, ai] +ogImage: /og/self-hosted-speech-to-text-api.jpg +banner: /banners/self-hosted-speech-to-text-api.jpg +--- + +Typing is the slowest thing most offices do all day. An average person types +40 words per minute; they *speak* 130. Every email, quotation, report, support +reply and chat message in your business pays that tax — and the tax is usually +paid by whoever is fastest at the keyboard. + +Speech-to-text removes it. But the version most people adopt has two problems: +it costs a monthly subscription per seat, and it ships your voice — your +internal discussions, customer names, pricing, contracts — to someone else's +cloud. + +So I built the other version: **one speech-to-text API running on a single +desktop PC in my office, with my own GPU doing the work.** My development PC, +my iPhone, an iPad and Android phones all dictate through it. It's faster than +typing, it's unlimited, and nothing leaves my network. + +This is how it works — including the four traps that cost me most of a day. + +## Why this matters + +Speech-to-text is the highest-leverage office automation that isn't an AI +chatbot. Concretely, what a self-hosted setup buys you: + +- **Roughly 5x the throughput of typing.** At 130 wpm spoken versus ~40 wpm + typed, dictating a 500-word email is about 4 minutes of talking instead of + 12 minutes of typing. Someone who writes 10 emails a day gets an hour back — + every day. +- **Cost scales with hardware, not headcount.** Cloud dictation is priced per + user per month, forever. This one runs on hardware you own. Add the tenth + employee and the marginal cost is zero. +- **Unlimited length, no quota anxiety.** No minute caps, no "you've reached + your monthly limit" at 4pm on a Friday. +- **Your audio stays yours.** Medical notes, legal drafts, HR conversations, + customer pricing — voice is sensitive by default. Self-hosted means the + transcription never leaves the building. +- **It works in whatever app already has focus.** Not a separate transcribe-then- + paste website — a keyboard you use inside Outlook, WhatsApp Web, your CRM, + or your own internal tools. + +If you run an office where people write all day, this is the same class of win +as moving from dial-up to broadband, and it costs a GPU you may already own. + +## What you need + +| Piece | What I used | Notes | +|---|---|---| +| GPU machine | Desktop PC with an RTX 3060 (12 GB) | Any NVIDIA card with ≥6 GB VRAM works; it can be a normal work PC | +| Whisper build | `whisper.cpp` with CUDA | Free, open source | +| Model | `large-v3` (~2.9 GB) | Best accuracy; smaller models use less VRAM | +| Delivery layer | n8n + nginx gateway | Adds auth, so the endpoint can be shared safely | +| Clients | Desktop app, iPhone, iPad, Android | Any client that speaks OpenAI's transcription API | + +The PC doesn't have to be dedicated. Mine also runs development work, +local LLM inference and image generation — the model loads when a request +arrives and unloads when idle, so it isn't permanently holding VRAM. + +## The architecture + +The important design decision is that **the phones never talk to the GPU +directly.** There's a gatekeeper in between that handles authentication, +so the GPU itself stays on a private network. + +```text + iPhone / iPad / Android / Work PCs + │ + │ HTTPS + per-device API key + ▼ + ┌───────────────────────────┐ + │ https://stt.example.com │ public HTTPS entry + │ reverse proxy │ + └─────────────┬─────────────┘ + ▼ + ┌───────────────────────────┐ + │ nginx gateway container │ strips/forwards auth, fixed routes + └─────────────┬─────────────┘ + ▼ + ┌───────────────────────────┐ + │ n8n workflow │ validates the key → 401/403 if wrong + │ (no audio ever logged) │ + └─────────────┬─────────────┘ + ▼ + ┌───────────────────────────┐ + │ whisper.cpp on the PC │ GPU transcription + │ :20129 → {"text": …} │ + └───────────────────────────┘ +``` + +Four layers, each doing one job: the proxy terminates TLS, the gateway fixes +routing, n8n authorises, whisper transcribes. The result is +`POST /v1/audio/transcriptions` — the same shape OpenAI uses, which means any +OpenAI-compatible client works with zero custom code. + +## The build + +Whisper needs CUDA and a CMake toolchain. On Windows that's three installs and +a build: + +```bash +git clone https://github.com/ggerganov/whisper.cpp +cd whisper.cpp +cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 +cmake --build build --config Release -j +``` + +Then fetch the model and run it: + +```bash +# large-v3, ~2.9 GB +cd models && sh ./download-ggml-model.sh large-v3 && cd .. +./build/bin/Release/whisper-server.exe \ + -m models/ggml-large-v3.bin \ + --host 0.0.0.0 --port 20129 \ + --convert +``` + +`--convert` matters more than it looks: it lets the server accept MP3 and +other compressed formats by shelling out to ffmpeg, instead of forcing every +client to send raw WAV. Most mobile apps send compressed audio. + +The server is now a working transcription API. Everything after this point +is about making it safe to reach from a phone. + +## Trap 1: CUDA Toolkit installs an incomplete compiler + +The CUDA Toolkit installer's default component selection includes `nvcc` — but +*not* the pieces `nvcc` needs to actually compile. My first two CMake runs both +failed on missing headers, and the errors pointed at my build config rather +than at a partial toolchain. + +The fix is to add three components explicitly: + +```text +crt_13.x → C runtime headers (the missing ) +nvvm_13.x → contains cicc, the actual CUDA compiler backend +cublas_13.x → cuBLAS, required to link at runtime +``` + +Symptom to watch for: `nvcc --version` succeeds, but the build fails immediately +with a missing-header error. A working `nvcc` is not a working toolkit. + +## Trap 2: it listens on localhost, so your firewall is innocent + +Whisper's default bind is `127.0.0.1` — loopback only. Nothing else on the +network can reach it, no matter what your firewall says. + +I lost real time here, because I assumed a firewall problem and tested the +firewall repeatedly (including turning it off entirely) while the actual cause +was the bind address. Loopback-only is *defined* to refuse every other +interface. + +```bash +# what it looks like when the port is live but not exposed +netstat -an | grep 20129 +# TCP 127.0.0.1:20129 0.0.0.0:0 LISTENING ← only you can reach it +``` + +Change the bind, and the picture changes: + +```bash +--host 0.0.0.0 +``` + +```text + TCP 0.0.0.0:20129 0.0.0.0:0 LISTENING ← the network can reach it +``` + +**Check the bind before you touch the firewall.** The two failures present +identically — a connection that times out — and only one of them is a firewall +problem. + +One related gotcha: Windows Firewall profiles can re-enable themselves. A rule +scoped to `Private,Domain` will silently stop working on a network Windows has +since reclassified as *Public*, and an update that re-enables a profile has the +same effect. Scope the rule to the LAN subnet rather than to a profile name: + +```powershell +New-NetFirewallRule -DisplayName 'Whisper STT' -Direction Inbound -Action Allow ` + -Protocol TCP -LocalPort 20129 -Profile Any -RemoteAddress LocalSubnet +``` + +`-RemoteAddress LocalSubnet` keeps it reachable from your office while staying +unreachable from the internet — which is the correct posture even with auth in +front. + +## Trap 3: n8n throws away your audio (twice) + +Putting n8n in the path is deliberate — it's where the API key check lives — but +it has two behaviours that silently break a proxy. + +**First: Code nodes discard binary data.** My workflow was Webhook → Code +(validate key) → HTTP Request (forward audio). The Code node returned JSON, and +n8n's binary payload — the audio itself — never made it past it. Subsequent +error: *"Make sure that the previous node outputs a binary file."* + +n8n's Webhook node accepts multipart uploads and stores them as binary. To +preserve that through an auth check, the Code node must pass the binary +reference through explicitly, not just return JSON. And the key name is not +what you'd guess: + +```text +┌──────────────┬────────────────────────────┐ +│ input field │ binary key in n8n │ +├──────────────┼────────────────────────────┤ +│ file │ data0 │ +│ (second) │ data1 │ +└──────────────┴────────────────────────────┘ +``` + +The multipart field is named `file`, but n8n indexes it as `data0`. Forwarding +`$binary.data` fails; `$binary.data0` works. + +**Second: execution logging stores the audio.** By default n8n persists +execution data — including binary payloads — which means every dictation is +written to the workflow database. For audio that's unacceptable. Disabling it: + +```json +"settings": { "saveDataSuccessExecution": "none" } +``` + +⚠ **This is the trap with teeth.** If you edit a workflow via the n8n API and +omit the `settings` object, the update **silently resets it** and you go back to +logging audio. Nothing warns you. Verify after every workflow change: + +```bash +curl -s -H "X-N8N-API-KEY: $KEY" \ + http://localhost:5678/api/v1/workflows/ \ + | jq '.settings' +``` + +## Trap 4: the stock nginx config hijacks port 80 + +I run the gateway as an nginx container. It kept returning 404s for paths that +were definitely configured — because the stock `default.conf` also declares a +server block on port 80, and it wins as the default server. My config was +loaded and correct, and simply never saw the traffic. + +```dockerfile +# remove the stock config before starting +command: > + sh -c "rm -f /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'" +``` + +Two smaller notes from the same build, both worth knowing before you deploy to +a NAS: + +- **You can't bind-mount a config file you haven't created yet** — the deploy + fails with *"Bind mount failed: ... does not exist."* On DSM the obvious + workaround of writing the file from inside another container doesn't work + either, because containers see only their own mounts, never the real host + filesystem. I ended up base64-embedding the config in the compose `command` + and writing it at startup — no host filesystem access needed at all. +- **The `/volume1` mounts available to containers are read-only**, so the + obvious write paths are closed. + +## The authorisation model, honestly + +Two layers, and it's worth being precise about what each one buys you. + +**Per-device API keys.** Each device gets its own key, so revoking a lost phone +doesn't affect anyone else: + +```json +{ + "iphone": "stt_", + "ipad": "stt_", + "laptop": "stt_" +} +``` + +The n8n workflow checks the `Authorization` header against this list and +returns a real 403 when it doesn't match. One subtlety: an API key in a URL +query string lands in proxy logs and browser history — keep it in the header. + +**What this protects.** It stops unauthorised use of your GPU and keeps the +endpoint from being an open transcription service on the internet. What it does +not do is encrypt the audio — that's TLS at the proxy layer, which is why the +public entry point is HTTPS-only. + +This is **access control for a private service**, not a hardened public API. +There's no rate limiting and no per-key quota yet. For an internal office +deployment across a handful of known devices that's proportionate; if you were +opening it to third parties you'd want limits, logging and rotation on top. + +## What I'd do differently + +1. **Verify the bind address first, not the firewall.** Two hours went into + testing the wrong layer for a problem that `netstat` answers in one line. + Both failures are "connection timed out", and I should have known that + loopback-only refuses everything regardless of firewall state. +2. **Check what the toolkit installer actually installed.** I trusted + `nvcc --version` as proof of a working toolchain. A compiler that runs but + can't find its own headers is not installed, and the error message will not + tell you that. +3. **Set logging policy before wiring the pipeline, not after.** I configured + `saveDataSuccessExecution: none` during the build, but the same edit through + the API would have silently re-enabled audio retention with no warning. + Privacy settings that can be reset by an unrelated update need a verification + step. + +## The result + +Four devices — a Windows development PC, an iPhone, an iPad and Android — all +dictating through one GPU on my own network. Typical transcription of a short +sentence is well under a second, and the end-to-end request through all four +layers returns a real transcript in about 2 seconds. + +The measured win that matters most is memory. Because the model unloads when +idle, a toggle drops GPU usage from **4,906 MiB to 1,321 MiB** — about 3.6 GB +returned — so the machine can go back to development work, local LLM inference +or image generation, then load the model again on the next dictation. + +And the honest framing on speed: **roughly 5x faster than typing** is the +correct figure for prose (130 wpm spoken vs ~40 typed). It's less dramatic for +code, where you're thinking more than typing. The gain is largest for exactly +the work offices drown in — email, documentation, notes, contracts, support +replies. + +## Could this work for your office? + +The pieces are unglamorous and the payoff is immediate: one machine with a GPU, +open-source software, and a delivery layer for authentication. No per-seat +subscription, no minute quotas, no audio leaving your premises — and the whole +office dictating instead of typing. + +I build exactly this kind of internal infrastructure: self-hosted services with +proper auth, GPU workloads that share hardware sensibly, and the integration +layer that makes them usable from the devices your staff already carry. If you +want speech-to-text (or another internal API) running on your own hardware — +or on a workstation you already own — that's the work I do. + +Reach me at [me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20speech-to-text) +or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at +[hoelee.com](https://hoelee.com). +```` + +--- + +## 文章 07 / 10:从云端转本地:在 12GB 显卡与群晖 N8N 上搭建中英混读高保真 TTS 语音栈 +- **Slug**: `self-hosted-tts-cosyvoice2-n8n-gpu` +- **英文标题**: How I Replaced Cloud TTS with a Self-Hosted Neural Voice Stack on an RTX 3060 +- **分类**: `case-studies` | **发布日期**: 2026-10-22 +- **线上地址**: https://blog.hoelee.com/posts/self-hosted-tts-cosyvoice2-n8n-gpu/ (EN) | https://blog.hoelee.com/posts/zh/self-hosted-tts-cosyvoice2-n8n-gpu/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "从云端转本地:在 12GB 显卡与群晖 N8N 上搭建中英混读高保真 TTS 语音栈" +description: "记录如何将内部自动化工作流从依赖公网 TTS API 迁移至本地 RTX 3060 12GB 显卡,基于 CosyVoice 2 与群晖 N8N 实现中英混读与多音色零样本克隆。" +pubDate: 2026-10-22 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L3-local-gpu-inference +tags: ["tts", "ai", "n8n", "gpu", "cosyvoice", "self-hosting"] +ogImage: "/og/zh-self-hosted-tts-cosyvoice2-n8n-gpu.jpg" +banner: "/banners/self-hosted-tts-cosyvoice2-n8n-gpu.jpg" +draft: false +--- + +在过去两年中,我们的内部业务自动化管线、Telegram 智能机器人以及视频知识库转写系统,一直依赖云端 Text-to-Speech(文本转语音)服务。起初,社区封装的 Microsoft Edge-TTS 提供了一个零成本的过渡方案;但依赖公网未公开接口的爬虫调用本质上极其脆弱——微软端点频繁变更签名、国内与海外网络抖动、以及跨国链路的偶发限流,经常导致自动化链路在半夜静默失败。而转用商业级 Azure Speech 或 Google Cloud TTS,在面对大批量长文本合成时,按字符计费的月度账单又显得不合算。 + +随着我们在 Unraid 服务器上为 Windows 11 虚拟机配置了直通的 **NVIDIA RTX 3060 12GB** 独立显卡,我们决定将整套语音合成系统完全收敛到本地局域网。 + +本文记录我们如何选型阿里巴巴开源的 **CosyVoice 2**,将其接入群晖 NAS 的 **n8n** 自动化集群,实现显存按需管理与开机常驻,并构建覆盖女童、正太、青年解说与纯正中英双语的零样本语音栈。 + +--- + +## 1. 核心需求与设计指标 + +在开工搭建之前,我们确立了四个关键要求: + +1. **丝滑的中英混读(Code-switching)能力**:在马来西亚与东南亚的业务沟通场景中,日常对话几乎都是中英双语交织(例如:“*请确认这个 webhook 已经 trigger,并检查 logs*”)。传统的 TTS 模型在遇到英文字词时往往会生硬卡顿、甚至读错音素,引擎必须能够自然过渡。 +2. **零样本(Zero-shot)多音色快速切换**:需要支持多种人设音色——讲故事需要的活泼童声、科技资讯需要的沉稳播音男声、以及客服交互需要的自然女声,且无需耗时数小时重新微调。 +3. **显存共存与无痛调度**:12GB 显存是宝贵资产,这台工作机平时还要承载 LM Studio 的 Qwen 大模型推理以及 ComfyUI 生图生视频任务。TTS 引擎要么显存占用极小,要么能够随时一键启停释放显存。 +4. **无缝替换群晖 n8n 旧节点**:自动化调度中心跑在群晖 DSM 的 n8n (`192.168.1.1:5678`) 上,本地新接口必须兼容通用 HTTP Webhook,无需重构现有工作流业务逻辑。 + +--- + +## 2. 模型选型:为什么是 CosyVoice 2? + +针对中文、英文以及核心的**中英混读**场景,我们横向对比了目前主流的本地开源方案: + +| 模型方案 | 优势 | 不足 | 选型结论 | +| :--- | :--- | :--- | :--- | +| **Edge-TTS / Piper** | 超轻量、纯 CPU 即可跑 | 音质机械感明显、无法自主克隆 | 彻底淘汰 | +| **ChatTTS** | 拟人化笑声与叹气细节好 | 经常胡言乱语、英文发音偏弱、长句难控 | 仅供玩票 | +| **GPT-SoVITS (v2)** | 特定人物音色克隆还原度极高 | 语种切换边界容易卡顿顿挫、中英混读较硬 | 备选 / 按需冷启 | +| **CosyVoice 2 (0.5B)** | **中英混读极度自然、首包延迟仅 300ms** | 常驻需要约 3.2GB 显存 | **生产主力首选** | + +阿里开源的 **CosyVoice 2**(0.5B 自回归语言模型 + 流匹配 Flow Matching 解码器)在多语种混排上展现出了目前开源界的最佳水准。英文单词直接嵌入在中文句子中读出时,发音地道、重音准确,完全没有传统音素拼接的违和感。 + +--- + +## 3. 局域网网络与硬件拓扑 + +整套系统的部署拓扑如下: + +``` +[ 外部调用 / 终端应用 ] + │ + ▼ +[ 群晖 DSM NAS (192.168.1.1) ] + ├── 反向代理网关 (Traefik / Nginx) + └── n8n 自动化调度集群 (Stack 272) + │ 局域网 HTTP POST / GET (/webhook/ltts) + ▼ (千兆内网无公网延迟) +[ Unraid 7.x 主机 (192.168.1.6) ] + └── Windows 11 虚拟机 (GPU 直通) + ├── NVIDIA GeForce RTX 3060 12GB + └── CosyVoice 2 FastAPI 服务 (127.0.0.1:50000) +``` + +通过在 CosyVoice 的服务脚本中直接挂载 FastAPI 路由,我们在 Windows 本地 `50000` 端口暴露出 `/tts` 端点,内网其他服务只需向 `http://192.168.1.123:50000/tts` 发送请求即可在 2 秒内拿回二进制 WAV 音频。 + +--- + +## 4. 显存治理:常驻守护与一键开关 + +12GB 显存如果任由各个 AI 工具同时加载,瞬间就会爆显存(OOM)。我们采取了“分级常驻 + 幂等开关”策略: + +1. **低显存常驻(日常状态)**:CosyVoice 2 推理仅占用 **3.2 GB 显存**,剩余近 9 GB 显存完全足以同时运行日常的系统桌面和轻量任务。我们将其写入 Windows 开机启动项(`shell:startup\CosyVoice 2.lnk`),开机静默常驻后台。 +2. **一键切换脚本(高负载避让)**:当需要运行 ComfyUI 渲染整首 MV 或加载 14B 大语言模型时,桌面上放有一个免人工干预的批处理快捷方式 `CosyVoice 2 (开·关).bat`: + +```bat +@echo off +title CosyVoice 2 Toggle +for /f "tokens=5" %%a in ('netstat -aon ^| findstr :50000') do ( + taskkill /F /PID %%a >nul 2>&1 + echo STOPPED. GPU 显存已完全释放。 + exit /b +) +:: 若未运行,拉起后台推理服务 +start /b python webui.py --port 50000 --model_dir pretrained_models/CosyVoice2-0.5B +echo ONLINE - CosyVoice 2 已加载至显卡,服务就绪。 +``` + +双击一次即刻杀掉进程、半秒内退还 3.2GB 显存;渲染完毕后再双击一次,25 秒内重新完成模型加载。 + +--- + +## 5. 群晖 N8N 联动接入 + +我们在群晖 n8n 上配置了名为 **`Local TTS API (CosyVoice)`** 的独立工作流,绑定路径为 `/webhook/ltts`: + +- **GET 支持**:`http://192.168.1.1:5678/webhook/ltts?text=你好Hello&voice=child`,浏览器或监控告警可直接单链调用下载。 +- **POST 支持**:接收标准 JSON 体 `{"text": "...", "voice": "..."}`,供各类聊天机器人与后台脚本程序化触发。 + +在工作流内部,我们使用 JavaScript Code 节点对传入文本做字符解码与清洗,再通过 HTTP Request 节点转发给本地 50000 端口,最终以 `firstEntryBinary` 模式直接将音频流送还客户端。 + +--- + +## 6. 音色库扩展:零样本克隆实践 + +得益于 CosyVoice 2 的 Zero-shot 能力,只需提供 3~5 秒无杂音的人声样音,模型即可实时复刻其声线特征。我们在服务内部预制了常用音色表: + +| 音色代号 | 声音风格 | 样音来源与特色 | +| :--- | :--- | :--- | +| `child` / `girl` | 活泼女童声 | 纯净 4 秒学前童声对话,清脆甜美 | +| `boy` | 清脆男童声(正太音) | 5 秒少儿动漫旁白 | +| `male` / `yunxi` | 阳光青年男声 | 5 秒科技解说,**中英专业词汇混读极佳** | +| `news` | 沉稳新闻男声 | 5 秒纪录片解说样本 | +| `female` | 温暖知性女声 | 5 秒电台散文朗诵 | +| `default` | 旗舰高保真女声 | CosyVoice 官方基准母声 | + +调用时只需指定 `voice` 参数,无需重启后端或切换模型权重,即可实现毫秒级换人合成。 + +--- + +## 7. 运行成效 + +在本地 RTX 3060 上运行实测: +- **生成效率**:实时率(RTF)稳定在 **0.18** 左右,合成一段 10 秒的标准语音仅耗时约 **1.8 秒**。 +- **首包响应**:n8n 收到请求至音频开始回传约 **320 毫秒**,完全满足交互式即时语音应答的要求。 +- **自主掌控**:不再受制于第三方厂商的封号、限频与网络审查,所有音频资产与 Prompt 完全留存在本地内网。 + +自托管高品质 AI 语音不再是大型服务器集群的专利。借助新一代自回归+扩散语音架构与消费级显卡,一套完全受控、零月租且音质超越商业云服务的全天候语音栈,已经完全可以在自托管生产环境中稳定运转。 +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Replaced Cloud TTS with a Self-Hosted Neural Voice Stack on an RTX 3060" +description: "How I migrated our production automation workflows from cloud speech APIs to a self-hosted CosyVoice 2 pipeline on a 12GB RTX 3060, integrated with Synology N8N." +pubDate: 2026-10-22 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L3-local-gpu-inference +tags: ["tts", "ai", "n8n", "gpu", "cosyvoice", "self-hosting"] +ogImage: "/og/self-hosted-tts-cosyvoice2-n8n-gpu.jpg" +banner: "/banners/self-hosted-tts-cosyvoice2-n8n-gpu.jpg" +draft: false +--- + +For nearly two years, our internal business workflows, Telegram bots, and automated learning summaries relied on cloud-hosted text-to-speech APIs. Initially, Microsoft Edge-TTS provided a generous, zero-cost bridge. But running production workloads over unofficial browser-endpoint scraping is inherently fragile: sudden upstream schema changes, IP rate limits, and latency spikes across international transit routes routinely caused intermittent delivery drops. Commercial APIs like Azure Speech or Google Cloud TTS offered stability, but recurring meter billing scales awkwardly when generating long-form narration. + +With a dedicated RTX 3060 12GB GPU passed through to a Windows 11 VM on our Unraid server, we decided to bring our entire speech synthesis stack fully on-premises. + +Here is how we architected a self-hosted neural TTS pipeline using Alibaba's open-source **CosyVoice 2**, hooked it into our Synology DSM **n8n** automation cluster, managed GPU memory alongside our other local AI models, and achieved instant sub-second audio responses across Chinese, English, and mixed-language speech. + +--- + +## 1. The Design Goals + +Before touching code, our target operational envelope required four non-negotiable criteria: + +1. **Fluid Bilingual Code-Switching**: In Malaysian and Southeast Asian business communication, text is almost never purely monolingual. Sentences naturally mix Chinese syntax with English technical terms (e.g., *"请检查这个 pull request 并确认 webhook 状态"*). The engine must articulate both languages without sudden cadence jumps or unnatural accent shifts. +2. **Zero-Shot Voice Variety**: We needed distinct voice profiles—cheerful child voices for storybook projects, authoritative male anchors for news roundups, and clear conversational female tones for support bots—cloned from brief reference clips without days of fine-tuning. +3. **Constrained VRAM Footprint**: The RTX 3060 has 12GB of VRAM. Our VM regularly runs local LLMs (Qwen GGUF on LM Studio) and ComfyUI image/video pipelines. The TTS engine had to either idle under ~3.5GB of VRAM or provide frictionless cold-start toggles. +4. **Drop-in Webhook Compatibility**: Our central automation runs on Synology DSM via n8n (`192.168.1.1:5678`). Replacing cloud nodes could not require rewriting dozens of downstream workflow schemas. + +--- + +## 2. Model Selection: Why CosyVoice 2 Won + +We evaluated the primary open-source contenders against our bilingual requirement: + +| Model Architecture | Strengths | Drawbacks | Verdict | +| :--- | :--- | :--- | :--- | +| **Edge-TTS / Piper** | Ultra-lightweight, CPU-only | Robotic cadence, rigid voices, cloud dependency | Deprecated | +| **ChatTTS** | Conversational laughter, pause tags | Unpredictable halluncinations, weak English | Not suitable | +| **GPT-SoVITS v2** | Unrivaled character cloning accuracy | Phoneme boundaries stumble on mixed Chinese-English | Secondary / On-demand | +| **CosyVoice 2 (0.5B)** | **Superb bilingual flow, 300ms first-packet latency** | 3.2GB VRAM baseline | **Primary Production Engine** | + +CosyVoice 2 (the 0.5B parameter variant from Alibaba's FunAudioLLM team) emerged as the definitive winner. By combining an autoregressive language model with flow-matching diffusion decoders, it treats cross-lingual text-to-speech as a unified sequence. English terms embedded inside Chinese phrases flow with native pronunciation and natural human breath pauses. + +--- + +## 3. Infrastructure & Network Topology + +Our self-managed infrastructure runs on a tiered hardware split: + +``` +[ Internet / Clients ] + │ + ▼ +[ Synology DSM NAS (192.168.1.1) ] + ├── Portainer / Traefik Reverse Proxy + └── n8n Automation Engine (Stack 272) + │ HTTP POST / GET (/webhook/ltts) + ▼ (Local 1Gbps LAN) +[ Unraid 7.x Server (192.168.1.6) ] + └── Windows 11 VM (GPU Passthrough) + ├── NVIDIA GeForce RTX 3060 12GB + └── CosyVoice 2 FastAPl Service (127.0.0.1:50000) +``` + +The Windows VM hosts the CUDA 12.x PyTorch runtime. We exposed a lightweight FastAPI wrapper inside CosyVoice’s runtime script (`webui.py`), allowing our Synology n8n instance to trigger generation over LAN via `http://192.168.1.123:50000/tts`. + +--- + +## 4. Managing GPU Memory: Resident vs. On-Demand + +Leaving every AI model loaded simultaneously will exhaust 12GB of VRAM instantly. We designed a dual-tier lifecycle: + +1. **CosyVoice 2 as the Resident Daemon**: Consuming only **3.2 GB of VRAM**, CosyVoice 2 runs as an auto-starting background service when Windows boots (`shell:startup\CosyVoice 2.lnk`). This leaves 8.8 GB free for general desktop and lightweight inference. +2. **One-Click State Toggle Batches**: For heavy tasks (such as running 1080p video generation in ComfyUI or 14B LLMs), we created an idempotent batch script on the desktop: + +```bat +@echo off +title CosyVoice 2 Toggle +for /f "tokens=5" %%a in ('netstat -aon ^| findstr :50000') do ( + taskkill /F /PID %%a >nul 2>&1 + echo STOPPED. GPU memory freed. + exit /b +) +:: If stopped, launch background worker +start /b python webui.py --port 50000 --model_dir pretrained_models/CosyVoice2-0.5B +echo ONLINE - CosyVoice 2 loaded to GPU. +``` + +A single double-click halts the process and reclaims all 3.2GB within 500ms; clicking it again restores the service in under 25 seconds. + +--- + +## 5. Integrating with Synology N8N + +In n8n, we configured a dedicated workflow: **`Local TTS API (CosyVoice)`** listening on `/webhook/ltts`. + +To ensure total interoperability with existing scripts, the workflow accepts both: +- **`GET /webhook/ltts?text=Hello&voice=child`**: Ideal for browser testing and simple URL triggers. +- **`POST /webhook/ltts`** with JSON body `{"text": "...", "voice": "..."}`: Used by our bots and backend services. + +The n8n pipeline consists of three streamlined nodes: +1. **Webhook Node**: Receives incoming requests. +2. **Parse & Clean Text (Code Node)**: Normalizes character encodings and defaults voice selections. +3. **HTTP Request Node**: Forwards the JSON payload to `http://192.168.1.123:50000/tts` and streams the binary `audio/wav` response directly back to the requester. + +--- + +## 6. Curating Zero-Shot Reference Voices + +CosyVoice 2 synthesizes speech using zero-shot voice cloning: feeding a 3- to 5-second clean audio sample allows the model to clone the acoustic timbre instantly. + +We packaged a library of curated reference samples into `asset/voices/` and mapped them to intuitive alias parameters: + +| Voice Key | Style Profile | Sample Source | +| :--- | :--- | :--- | +| `child` / `girl` | Cheerful female child voice | Clean 4s preschool dialogue clip | +| `boy` | Energetic young boy (anime style) | 5s youth animation dialogue | +| `male` / `yunxi` | Engaging, modern tech male anchor | 5s professional narration | +| `news` | Deep, formal broadcast presenter | 5s documentary voiceover | +| `female` | Warm, conversational support agent | 5s acoustic reading sample | +| `default` | Original CosyVoice high-fidelity voice | Model's baseline reference | + +Switching voices is as simple as passing `&voice=child` in the query string or `{"voice": "male"}` in the POST payload. + +--- + +## 7. Real-World Performance + +Running on the RTX 3060: +- **Generation Speed**: Real-time factor (RTF) averages **0.18**. A 10-second sentence renders in under **1.8 seconds**. +- **Latency**: First audio packet arrives at n8n in approximately **320ms**, enabling near-instant voice replies on Telegram. +- **Cost & Privacy**: Zero monthly cloud API charges, zero audio data leaving our local subnet, and complete immunity to external provider outages. + +Self-hosting modern neural TTS is no longer an exercise in brittle compile scripts and robotic phoneme tables. With open-source diffusion models and a modest 12GB workstation GPU, you can run a private, high-fidelity voice engine that surpasses commercial cloud offerings in customization and control. +```` + +--- + +## 文章 08 / 10:我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆) +- **Slug**: `self-hosting-mem0-memory-stack` +- **英文标题**: How I Self-Hosted a Mem0 Memory Stack (So My AI Agent Remembers) +- **分类**: `case-studies` | **发布日期**: 2026-09-16 +- **线上地址**: https://blog.hoelee.com/posts/self-hosting-mem0-memory-stack/ (EN) | https://blog.hoelee.com/posts/zh/self-hosting-mem0-memory-stack/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆)" +description: "一套私有、自托管的 AI 助手记忆层——mem0 API + LiteLLM + PostgreSQL/pgvector,跑在 Synology NAS 上。语义记忆由你掌控,而不是第三方手里。" +pubDate: 2026-09-16 +category: case-studies +tags: [mem0, docker, postgres, pgvector, litellm, ai, sharing] +ogImage: /og/zh-self-hosting-mem0-memory-stack.jpg +banner: /banners/self-hosting-mem0-memory-stack.jpg +--- + +一个每次对话后都忘光一切的 AI 助手,就是一个每轮都要你重新自我介绍的工具。我的 AI 助手(就是驱动这个博客工作流的那个)曾经每场会话都从零开始——直到我给它加了一层**记忆**:一套自托管的 [mem0](https://mem0.ai) 栈,负责存储它学到的事实、按语义检索、并跨会话存活。 + +这就是我在自家 Synology NAS 上自托管它的故事——私有、完全由我掌控、数据绝不离开我的局域网。 + +## 为什么这件事重要 + +记忆层改变的是一个助手真正能为企业做的事: + +- **不再重复回答同样的问题。** 记住客户获批的试用、他买的产品、上次的售后问题——下一段对话从上一次结束的地方继续。 +- **让「个性化」成真。** 一个记得客户偏好和历史记录的机器人,就是一个人。这不是提示词糖衣;这是一份存储的、可检索的记录。 +- **把数据掌控权留在自己手里。** 我对托管式记忆服务不满的每一点,自托管都解决了——事实存在我自己的 PostgreSQL 里、我自己的 NAS 上、我自己网络的后面。 + +对任何跑 AI 助手(售后机器人、研究助手、个人自动化)的人来说,问题不是助手**要不要**记住——而是这份记忆是一份私有资产,还是第三方的数据。 + +## mem0 做了什么 + +Mem0 是给 LLM 应用用的「开箱即用的记忆」。你不用手搓嵌入表和相似度检索,它给一个小 API,把记忆需要的三件事都做了: + +1. **加一条记忆** —— `POST /memories` 传一条事实或一段对话;它抽取、去重并存储(可选由 LLM 分类)。 +2. **检索** —— `GET /search` 带一个 query,返回按语义相似度排序的相关事实。 +3. **按用户隔离** —— 记忆归属 `user_id`,所以可以按客户、按项目、按会话分开,互不串扰。 + +「记忆」就是抽取出来的事实;「检索」就是向量相似度。哪怕你从没亲手碰过向量数据库,也能让助手有记忆。 + +## 技术栈 + +Synology DS1821+ 上用 Portainer compose 栈跑三个容器: + +- **mem0 API** —— 记忆服务本身(本地自定义构建),监听 `20015` 端口。 +- **LiteLLM** —— 真正做事实抽取的模型前面的 LLM 网关。不碰 mem0 配置就能换底层模型。 +- **PostgreSQL 17 + pgvector** —— 数据库。`pgvector/pgvector` 把 Postgres 和向量扩展打包成一等公民,语义搜索和别的一切同库。 + +```text +┌──────────────┐ /search & /memories ┌──────────────────┐ +│ AI agent / │ ──── X-Api-Key + JSON ───────▶ │ mem0 API :20015 │ +│ Hermes / bot │ └────────┬─────────┘ +└──────────────┘ │ pgvector + ▼ + PostgreSQL 17 + pgvector +┌──────────────┐ model calls (extract) ┌──────────────────┐ +│ OpenAI etc. │ ◀───────── LiteLLM ──────────│ fact-classifier │ +└──────────────┘ └──────────────────┘ +``` + +一份有代表性的 compose 长这样: + +```yaml +services: + mem0-postgres: + image: pgvector/pgvector:pg17 + environment: + POSTGRES_USER: mem0 + POSTGRES_PASSWORD: ${MEM0_DB_PASSWORD} + POSTGRES_DB: mem0 + volumes: + - ./data/pg:/var/lib/postgresql/data + restart: unless-stopped + + mem0-litellm: + image: ghcr.io/berriai/litellm:main-stable + command: ["--config", "/app/config.yaml"] + volumes: + - ./litellm-config.yaml:/app/config.yaml + restart: unless-stopped + + mem0-api: + build: ./mem0-api + environment: + OPENAI_API_KEY: ${OPENAI_API_KEY} + OPENAI_API_BASE: http://mem0-litellm:4000/v1 # go through the gateway + MEM0_API_KEY: ${MEM0_API_KEY} # X-Api-Key for /search & /memories + POSTGRES_URL: postgresql://mem0:${MEM0_DB_PASSWORD}@mem0-postgres/mem0 + ports: + - "20015:8000" + restart: unless-stopped +``` + +(我是从 mem0 仓库本地构建、把模型/事实抽取指向 LiteLLM;确切的镜像版本是我自己维护本地镜像的结果。真实 API key 我放在 compose 文件外面,走环境变量密文注入。) + +## 使用 + +起起来之后,这个 API 小到意外。加一条事实(快速、逐字保存): + +```bash +curl -X POST http://192.168.1.1:20015/memories \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $MEM0_API_KEY" \ + -d '{ + "user_id": "customer-2211", + "text": "Customer 2211 prefers email over WhatsApp for order updates." + }' +``` + +之后再取最相关的记忆: + +```bash +curl -X POST http://192.168.1.1:20015/memories/search \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $MEM0_API_KEY" \ + -d '{ + "user_id": "customer-2211", + "query": "how should I reach this customer?", + "limit": 5 + }' +``` + +然后把返回的事实塞进助手回答前的系统提示词(或上下文)。这就是全部循环:**存事实、召回相关的、在下一句回复里用上。** + +### 让我花了一天时间的 `infer` 坑 + +- `POST /memories` 默认 **`infer=true`** 会跑 LLM 事实抽取器——分类、清洗每条记忆。聪明但**慢**,而且每次调用都要一次模型往返。 +- 如果我只是想让助手**逐字快速地**记下某件事(客户说「请叫我 Andy」),我传 **`infer=false`**——不经过 LLM,近乎瞬时存储。 + +我栈上的路由是 `/search` 和 `/memories`,**没有 `/v1` 前缀**(早期一个自以为有 `/v1` 的包装器 404 了一阵子),认证是朴实的 `X-Api-Key` 头。都是小事,但都让我费过时间。 + +## 如果重来我会怎么做 + +- **从第一天起对常规记录就用 `infer=false`。** 只对重要的事实跑 LLM 抽取;默认的推理模式让每一次写入都为了一句随手记付出的成本不值。 +- **把 API key 当真正的机密。** 它会嵌进 agent 配置和 compose 环境变量里,所以我把它从 compose 文件中拿出来,用环境密文注入。 +- **把数据库放在我信任的盘上。** Mem0 的下限就是你 Postgres 的下限。我把它跑在 NAS 受保护的卷上、带备份任务——因为一个会失忆的记忆层还不如没有。 + +## 结果 + +一层私有、自托管的记忆,用两个小 HTTP 调用就能访问。我的助手现在跨会话记得客户、偏好和决策——而且因为整个栈都在我自己网络后面的 PostgreSQL 里,这一切都没有离开我的掌控。 + +如果你跑的 AI 助手动不动就忘掉上下文,自托管 mem0 是最划算、最省事的升级之一——而且你能在不把记忆送进别人数据库的前提下做到。 + +--- + +## 想让你的生意也用上带记忆的 AI 吗? + +我为企业构建并自托管 AI 助手、记忆栈、网站和基础设施。如果你想让助手真正**记住**你的客户——或者你想聊聊雇佣我——我很乐意帮上忙: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **邮箱:** [me@hoelee.com](mailto:me@hoelee.com) +- 🌐 **网站:** [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "How I Self-Hosted a Mem0 Memory Stack (So My AI Agent Remembers)" +description: "A private, self-hosted memory layer for AI agents — mem0 API + LiteLLM + PostgreSQL with pgvector on a Synology NAS. Semantic memory under your control, not a third party's." +pubDate: 2026-09-16 +category: case-studies +tags: [mem0, docker, postgres, pgvector, litellm, ai, sharing] +ogImage: /og/self-hosting-mem0-memory-stack.jpg +banner: /banners/self-hosting-mem0-memory-stack.jpg +--- + +An AI agent that forgets everything between conversations is a tool you have +to reintroduce yourself to every session. My AI assistant (the one that runs +this very blog's workflow) used to start each chat from zero — until I gave it +a **memory layer**: a self-hosted [mem0](https://mem0.ai) stack that stores +facts it learns, retrieves them semantically, and survives across sessions. + +This is the story of how I self-hosted it on my Synology NAS — private, fully +under my control, no data leaving my LAN. + +## Why this matters + +A memory layer changes what an agent can actually *do* for a business: + +- **It stops re-answering the same questions.** Remember a customer's granted + trial, their product, their last support issue — and the next conversation + picks up where the last one ended. +- **It makes "personalization" real.** A bot that recalls your customer's + preferences and history is a bot that feels like a person. That's not + prompt-sugar; it's a stored, searchable record. +- **It keeps control of your data.** Every "memory" I dislike about hosted + memory services is solved by running the stack yourself — the facts live + in my own PostgreSQL, on my own NAS, behind my own network. + +For anyone running AI agents (support bots, research assistants, personal +automation) the question isn't *should* the agent remember — it's whether the +memory is a private asset or a third party's. + +## What mem0 does + +Mem0 is "memory in a box" for LLM apps. Instead of you hand-rolling an +embeddings table and a similarity search, it gives you a small API that does +the three things memory needs: + +1. **Add a memory** — `POST /memories` with a fact or a conversation; it + extracts, dedupes, and stores it (optionally classified by an LLM). +2. **Search** — `GET /search` with a query returns the most relevant stored + facts, ranked by semantic similarity. +3. **User scoping** — memories belong to a `user_id`, so you can keep memory + per customer, per project, or per conversation without cross-talk. + +The "memory" is the extracted fact. The "retrieval" is vector similarity. You +get the agent even if you never touch a vector database yourself. + +## The stack + +Three containers on my Synology DS1821+, as a Portainer compose stack: + +- **mem0 API** — the memory service itself (custom local build), listening + on port `20015`. +- **LiteLLM** — the LLM gateway in front of whatever model does the fact + extraction. I can swap the underlying model without touching mem0's config. +- **PostgreSQL 17 + pgvector** — the database. `pgvector/pgvector` bundles + Postgres with the vector extension as a first-class citizen, so semantic + search lives in the same DB as everything else. + +```text +┌──────────────┐ /search & /memories ┌──────────────────┐ +│ AI agent / │ ──── X-Api-Key + JSON ───────▶ │ mem0 API :20015 │ +│ Hermes / bot │ └────────┬─────────┘ +└──────────────┘ │ pgvector + ▼ + PostgreSQL 17 + pgvector +┌──────────────┐ model calls (extract) ┌──────────────────┐ +│ OpenAI etc. │ ◀───────── LiteLLM ──────────│ fact-classifier │ +└──────────────┘ └──────────────────┘ +``` + +A representative compose looks like this: + +```yaml +services: + mem0-postgres: + image: pgvector/pgvector:pg17 + environment: + POSTGRES_USER: mem0 + POSTGRES_PASSWORD: ${MEM0_DB_PASSWORD} + POSTGRES_DB: mem0 + volumes: + - ./data/pg:/var/lib/postgresql/data + restart: unless-stopped + + mem0-litellm: + image: ghcr.io/berriai/litellm:main-stable + command: ["--config", "/app/config.yaml"] + volumes: + - ./litellm-config.yaml:/app/config.yaml + restart: unless-stopped + + mem0-api: + build: ./mem0-api + environment: + OPENAI_API_KEY: ${OPENAI_API_KEY} + OPENAI_API_BASE: http://mem0-litellm:4000/v1 # go through the gateway + MEM0_API_KEY: ${MEM0_API_KEY} # X-Api-Key for /search & /memories + POSTGRES_URL: postgresql://mem0:${MEM0_DB_PASSWORD}@mem0-postgres/mem0 + ports: + - "20015:8000" + restart: unless-stopped +``` + +(Mine is built from the mem0 repo with the model/fact-extraction directed +through LiteLLM; the exact image tag is me maintaining a local build, and I +keep the real API key out of the compose file — it's an env secret.) + +## Using it + +Once it's up, the API is refreshingly small. Add a fact (fast, verbatim): + +```bash +curl -X POST http://192.168.1.1:20015/memories \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $MEM0_API_KEY" \ + -d '{ + "user_id": "customer-2211", + "text": "Customer 2211 prefers email over WhatsApp for order updates." + }' +``` + +Retrieve the most relevant memories later: + +```bash +curl -X POST http://192.168.1.1:20015/memories/search \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $MEM0_API_KEY" \ + -d '{ + "user_id": "customer-2211", + "query": "how should I reach this customer?", + "limit": 5 + }' +``` + +Then inject the returned facts into the agent's system prompt (or context) +before it answers. That's the whole loop: **store facts, retrieve what's +relevant, use it in the next reply.** + +### The `infer` gotcha that cost me a day + +- `POST /memories` with the **default `infer=true`** runs the LLM fact + extractor — it classifies and cleans each memory. That's smart but **slow**, + and every call costs a model round-trip. +- If I just want the agent to note something verbatim and fast (user said + "please call me Andy"), I pass **`infer=false`** — no LLM hop, near-instant + storage. + +The route of my stack is `/search` and `/memories` **without a `/v1` prefix** +(an early wrapper that assumed `/v1` 404'd for a while), and auth is a plain +`X-Api-Key` header. Small details, but they cost me time to discover. + +## What I'd do differently + +- **Set `infer=false` for routine logging from day one.** Only run the LLM + extractor on the facts that matter; the default inference mode makes every + write pricey for no benefit on throwaway notes. +- **Treat the API key as a real secret.** It gets embedded in agent configs + and compose envs, so I keep it out of the compose file and inject it via + environment secrets. +- **Put the DB on a disk I trust.** Mem0 is only as durable as its Postgres. + I run it on my NAS's protected volumes, with a backup job — because a + memory layer that forgets is worse than none. + +## The result + +A private, self-hosted memory layer answered by two small HTTP calls. My +agent now remembers customers, preferences, and decisions across sessions — +and because the stack sits in my own PostgreSQL behind my own network, none +of that leaves my control. + +If you run AI agents that keep forgetting their context, self-hosting mem0 +is one of the highest-value, lowest-friction upgrades you can make — and you +can do it without sending your memory to someone else's database. + +--- + +## Want a memory-enabled AI setup for your business? + +I build and self-host AI agents, memory stacks, websites, and infrastructure +for businesses. If you'd like your assistant to actually *remember* your +customers — or you want to talk about hiring me — I'd love to help: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 09 / 10:把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验 +- **Slug**: `shipping-an-ai-photo-editor-as-a-wordpress-plugin` +- **英文标题**: Shipping an AI Photo Editor as a WordPress Plugin: 22 Versions of Hard Lessons +- **分类**: `case-studies` | **发布日期**: 2026-09-18 +- **线上地址**: https://blog.hoelee.com/posts/shipping-an-ai-photo-editor-as-a-wordpress-plugin/ (EN) | https://blog.hoelee.com/posts/zh/shipping-an-ai-photo-editor-as-a-wordpress-plugin/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验" +description: "从单个 HTML 文件到一个真正上线的 WordPress 插件:fal.ai 异步队列、绝不能静默失败的水印、可选的名单元件,以及五个只有在真实环境里才会出现的 bug。" +pubDate: 2026-09-18 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L1-high-conversion-web +tags: [wordpress, php, fal-ai, docker, ai-image, javascript] +ogImage: /og/zh-shipping-an-ai-photo-editor-as-a-wordpress-plugin.jpg +banner: /banners/shipping-an-ai-photo-editor-as-a-wordpress-plugin.jpg +draft: false +--- + +几周前我[写过那个概念验证](/posts/zh/ai-furniture-compositing-with-flux-kontext/):一个 +`index.html`、没有后端,两张家具照片进去,一张布置好的房间场景出来。它回答了 +它本该回答的问题——*能不能保住真实的产品,只把周围的房间生成出来?*——同时留下 +了一个显而易见的问题。 + +那篇 PoC 的结尾写着「*在决定做成完整的 WordPress 插件之前*」。这篇文章讲的就是 +那个插件。它从 `0.0.1` 走到 `0.0.22`,58 个 commit,而其中几乎没有任何工作是 AI 那部分。 + +## 为什么这件事值得关心(写给老板,不是写给开发者) + +PoC 的局限在于它活在一个 HTML 文件里,API key 就写在源码里。作为发给客户看的演示, +这没问题。但一家家具店要把这东西放到自己网站上,就不行了: + +- **API key 不能出现在浏览器里。** 任何人 view-source 就能看到,然后把你的 fal.ai + 额度刷光。它必须放在服务端,由一个管理员控制的设置页面来管。 +- **没人想盯着生成过程。** 访客上传、等待、拿到图。如果中途挂了,店主根本不该知道。 +- **顾客的照片不能泄露。** 匿名访客的原图绝不能进公开的媒体库;生成结果除非 + *那位访客本人*同意分享,否则也不能公开。 +- **输出必须带水印。** 否则你就是在给整个互联网提供免费 AI 修图服务。 +- **店主需要拿到线索。** 一个只会产出一张漂亮图片、拿不到联系方式的 AI 工具是个玩具。 + 接上表单和 webhook,它就是家具生意的线索管道。 + +上面这五条都是**产品**需求,而每一条都比那次 AI 调用本身花的代码更多。 + +## 架构:真正让它跑起来的无聊部分 + +这是一个正常的 WordPress 插件——PHP 前缀 `hre_`、命名空间 `HRE`、激活时用 +`dbDelta()` 建自定义表、自带 autoloader、运行时不依赖 Composer。有意思的决策在别处。 + +### 生成永远是异步的 + +fal.ai 的任务要 15–20 秒。PHP-FPM 的请求超时、`max_execution_time`、以及没耐心的 +访客,让同步生成注定失败。所以流程是「入队 + 轮询」: + +``` +访客上传 → POST /uploads → 归一化 → 启动视觉分析 +访客选择 → GET /prompts/{cat} → 每个风格的提示词 +访客确认 → POST /jobs → fal 入队(返回 uuid) +浏览器轮询 → GET /jobs/{uuid} → status: queued | processing | done +``` + +访客永远不等服务商。任务行携带 fal 的请求 id,轮询要么拿到完成的结果,要么显示 +真实的进度。前端是跑在 session cookie 上的小型状态机,所以中途刷新页面会回到 +访客原来所在的位置。 + +### 归属权靠 session cookie,不靠 URL 里的 ID + +匿名访客没有账号,这让授权很容易做错。我最终定下的规则是: +**每一次读取都通过调用者自己的 session hash 来解析**,绝不接受客户端传来的 ID。 + +- `GET /preview` 只输出调用者**自己**归一化后的上传图。它根本没有路径或 URL 参数 + ——别人请求它只会得到 404,因为那个 session 里没有文件。这也让私有存储路径 + 不会出现在任何 JSON 响应里。 +- `GET /jobs/{uuid}` 是按 *session hash + uuid* 查任务的。从别的浏览器猜一个 UUID + 什么也拿不到。 + +就这一个决策,消掉了「我改了 URL 里的 id 就看到了别人的照片」这一整类 bug。 + +### 水印是硬需求,所以回退逻辑绝不能静默 + +这个 bug 教给我的最多。水印那一步做的是 GD `imagecopyresampled` 合成。如果它失败, +一个很诱人的「有韧性」的写法是: + +```php +// 别这么写 +if ( ! $watermarked ) { + rename( $stage, $final ); // 直接把没水印的文件发出去 +} +``` + +我当初就是这么写的。它完全静默、没有日志,插件会心安理得地输出没有水印的结果, +而后台还显示「水印:已开启」。店主几周后才发现,是因为看到了一张没水印的图。 + +修法分三部分: + +1. **记录根因,而不是最近的那个症状。** `apply()` 返回的是 + `hre_no_watermark_source`——一个下游错误。真正的原因是水印来源的解析链 + (`site_logo` → `get_theme_mod('custom_logo')`)根本没解析到 logo。我加了一个 + `block_reason()` 诊断,重新走一遍解析链,并针对每个失败分支返回**具体**信息。 +2. **在后台暴露出来。** 一条常驻的 `notice-warning` 提示:水印已开启但不会被应用, + 并附上具体原因。不需要去翻日志。 +3. **让失败在测试时就看得见。** 一个独立探针把已知水印合成到已知底图上,并采样 + 预期水印矩形内的一个像素——这证明了变换路径本身是好的,从而把「功能坏了」和 + 「功能没配好」区分开。 + +**我现在遵守的规则:** 当某个「尽力而为」的分支掩盖了一个不可协商的变换时,它必须 +记录具体原因**并且**在后台 UI 里告警。一个悄悄让必需功能降级的回退不是韧性——那是 +一个有礼貌的 bug。 + +### 名单元件:默认关闭,开启后异步 + +名单元件是一个总开关。关闭时插件什么都不收集,向导的行为和以前完全一样。开启时, +管理员配置的字段会在生成之前变成必填,存入自定义表,并转发到 webhook。 + +两个值得照抄的决策: + +- **字段列表由管理员定义、完全动态**——`{key, label, type, required, placeholder, + options[]}`——所以前端表单是照着配置把自己渲染出来的。默认的五项(姓名、电话、 + 邮箱、留言、同意)只是起点,店主可以整个替换。 +- **Webhook 投递是排程的,绝不内联执行。** 访客不该为别人的接口等待: + +```php +wp_schedule_single_event( time() + 5, 'hre_lead_webhook', array( $lead_id ) ); +``` + +投递失败后按线性退避重试三次,然后标记为 `failed`。后台列表会显示状态徽章 +(pending / retrying / delivered / failed)和尝试次数——这一点很重要——插件的数据 +保留规则会保留**已投递**的线索,但按正常周期清理未投递的,因为一条没送出去的线索 +只是躺在你数据库里的个人隐私数据。 + +### 服务商的急停开关必须在服务端强制执行 + +管理员可以停用图像服务商,并设置一段代替「生成」按钮显示给访客的文案。把复选框 +变灰只是 UX;真正的强制发生在创建任务接口的开头: + +```php +if ( ! (bool) Settings::get( 'fal_provided' ) ) { + return $this->error_response( 'hre_provider_disabled', $msg, 503 ); +} +``` + +名单元件那道闸同理。浏览器端的闸是 UX,服务端的闸才是产品。 + +## 只有在真实安装里才会冒出来的五个 bug + +这是所有「怎么做 AI 插件」的文章都不会写的一段,而 58 个 commit 里大部分都花在这。 + +### 1. 把后台 REST 路由注册在 `is_admin()` 里,会静默 404 + +只在 `is_admin()` 为真时注册后台路由,看起来是对的,实际上完全坏了: +**REST 请求里 `is_admin() === false`**,所以路由从未被注册,每次调用都返回 +`404 "No route was found matching the URL and request method"`——这读起来就像是 +URL 打错了,完全不像注册 bug。要在插件启动时就注册;真正的访问控制是 +`permission_callback`(权限 + nonce)。 + +### 2. `rest_url()` 结尾没有斜杠 + +最近耗掉我一个下午的就是这个。`rest_url()` 返回的是 +`https://example.com/wp-json/my-plugin/v1/`——结尾的斜杠属于**命名空间**,下一段 +路径必须自己带一个斜杠。不带的话: + +```js +const rest = window.hreAdmin.rest; // ".../wp-json/hoelee-ai-photo-remix/v1" +fetch( rest + 'admin/photos/' + id ) // ❌ ".../v1admin/photos/123" +fetch( rest + '/admin/photos/' + id ) // ✅ +``` + +`.../v1admin/photos/123` 是一个 404,而且浏览器控制台里没有任何报错,于是 `catch` +分支触发,界面弹出笼统的「*Something went wrong.*」。它已经悄悄弄坏了**四个**接口 +——照片分页、照片删除、webhook 测试按钮、线索重发。修法是在每个调用点加一个字符; +诊断却花了长得多的时间,因为这个失败模式和服务器出错完全无法区分。用两条 curl 就能 +证明:正确的拼接返回 `403`(路由存在、缺 nonce),坏的那个返回 `404`。 + +### 3. 一个后台设置表单能抹掉另一个标签页的设置 + +每个后台标签页只提交自己的字段,但保存处理函数会跑完**整个**设置表。一个没被提交 +的复选框,和一个被取消勾选的复选框长得一模一样,所以保存「限制」页会静默地把 +「默认允许分享」写成 `false`,而保存那一页又会把整个名单元件配置清空。修法在 +清洗器里,不在表单里——要区分「这次提交里没有这个键」和「有这个键但是空值」: + +```php +// 本页提交了 → 用新值(清空就是清空) +// 本页没有提交 → 保留当前值 +if ( array_key_exists( $key, $raw ) ) { + $clean[ $key ] = sanitize( $raw[ $key ] ); +} +``` + +我为这件事补了一个回归测试(写入配置 → 保存另一个标签页的子集 → 断言原配置还在)。 +这类 bug 只有在你有了两个标签页之后才会存在,所以它才会被发出去。 + +### 4. 二进制接口不能走 JSON 解析的 fetch 助手 + +结果下载是一个 JPEG 流。共用的 `api()` 助手对每个响都做 `res.json()`,碰到图片字节 +就抛异常——而一个吞异常的 `.catch(function(){})` 把错误吃掉了,于是症状是 +*「结果一直不出现」*,控制台里什么都没有。二进制接口需要自己的原始 fetch 助手: +检查 `res.ok`、只在出错时解析 JSON、成功时返回 `res.blob()`。 + +### 5. 在缩放之前触发的尺寸上限,等于废掉了缩放 + +插件会把上传图缩到工作尺寸(1920×1080 边界)。我把防解压炸弹的限制设得太低—— +1 MB / 1 MP——结果每一张正常的手机照片都在**被缩放之前**就被拒了。访客看到预览刚 +出现就立刻取消选中,被反馈成「缩放功能坏了」。 + +真正的教训是 UX,不是数字:客户端必须在**显示本地预览之前**就拒掉超限文件。 +一闪就没,读起来就是 bug,哪怕底下那句提示其实是对的。现在上限是 15 MB / 50 MP, +高到正常照片一定能通过并被归一化。 + +## 如果重来一次我会怎么做 + +- **产品依赖的东西,先写失败路径。** 水印那个 bug 之所以存在,是因为我写了成功路径, + 然后把失败路径藏了起来。现在我先把「这条线断了,店主会看到什么」写完,再宣告 + 功能完成。 +- **第一天就拿真实站点测 REST 拼接。** 两条 curl(`.../v1/admin/x` → 403 对比 + `.../v1admin/x` → 404)本来能在第一周就抓出四个坏接口,而不是拖到第四周。我已经 + 把它写进项目的 `AGENTS.md`,让它不会再犯。 +- **别去猜服务商的 API 形状。** 我在 `fal.ai/api/me`(根本不存在的接口)上浪费了 + 时间,之后才发现正确的 key 校验是往队列接口发一个带鉴权的 `POST {}`:`401` 表示 + 被拒,其他都说明 key 没问题。先读真实 schema,再写客户端。 +- **前端就保持两个 shortcode。** 我曾经很想把向导拆成五个 shortcode 和五个页面构建器 + 组件。保持成「一个应用 + 一个画廊」意味着每次 UI 迭代都是单文件改动,而正是这个 + 迭代速度让 22 个版本得以发出去。 + +## 结果 + +一个具备产品形态的 WordPress 插件:**`0.0.22`,共 22 次发布**,10 张自定义数据表、 +异步 fal.ai 队列、服务端密钥管理、带重试 webhook 的动态名单元件、区分分享状态的 +照片归档,以及一个 10 个标签页、不用碰代码就能改完所有配置的后台。 + +这就是演示和产品之间的区别。而我最在意的那个数字是:**58 个 commit 里,大概只有四个 +和 AI 模型有关。** 其余全是那些不体面的工作,却决定了客户能不能在没有我的情况下 +把这东西跑起来:校验、数据保留、权限、失败的可见性,以及一个说真话的后台页面。 + +--- + +## 想给你的生意也做一套? + +我为马来西亚的中小企业做 WordPress 插件、AI 图像流水线和自托管基础设施——而且是 +做成你真的能跑起来的产品,不是要你天天伺候的演示。如果你想要 AI 产品图、一套线索 +收集管道,或者为你的业务定制插件,欢迎直接找我: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **邮箱:** [me@hoelee.com](mailto:me@hoelee.com?subject=WordPress%20AI%20plugin%20project) +- 🌐 **网站:** [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Shipping an AI Photo Editor as a WordPress Plugin: 22 Versions of Hard Lessons" +description: "From a single HTML file to a shipped WordPress plugin: the fal.ai async queue, a watermark that must never silently fail, optional lead capture, and five bugs that only appeared in production." +pubDate: 2026-09-18 +category: case-studies +blueprint: sme-digital-suite +blueprintLayer: L1-high-conversion-web +tags: [wordpress, php, fal-ai, docker, ai-image, javascript] +ogImage: /og/shipping-an-ai-photo-editor-as-a-wordpress-plugin.jpg +banner: /banners/shipping-an-ai-photo-editor-as-a-wordpress-plugin.jpg +draft: false +--- + +A few weeks ago I [wrote about the proof of concept](/posts/ai-furniture-compositing-with-flux-kontext/): one +`index.html`, no backend, two furniture photos in, a staged room scene out. It +answered the question it was built to answer — *can you keep the real product and +only generate the room around it?* — and it left one obvious question open. + +That PoC ended with the words *"before committing to the full WordPress plugin."* +This post is about the plugin. It went from version `0.0.1` to `0.0.22` across 58 +commits, and almost none of that work was the AI part. + +## Why it matters (for a business owner, not a developer) + +The PoC's limitation was that it lived in a single HTML file with the API key in +the source. That's fine for a demo you email a client. It is not fine for +something a furniture shop puts on their own website, where: + +- **The API key cannot be in the browser.** Anyone can view-source and drain your + fal.ai credit. It has to live server-side, behind a settings screen an admin + controls. +- **Nobody wants to babysit a generation.** The visitor uploads, waits, gets a + picture. If the process dies halfway, the shop owner should never hear about it. +- **Photos of customers must not leak.** An anonymous visitor's source photo must + never end up in the public Media Library, and a result must never be public + unless *that visitor* opted in to sharing it. +- **Output needs a watermark.** Otherwise you're running a free AI photo service + for the entire internet. +- **The owner needs to capture the lead.** An AI tool that produces a pretty + picture and no contact details is a toy. Attached to a form + webhook, it's a + lead pipeline for a furniture business. + +Every one of those five points is a *product* requirement, and every one of them +cost more code than the AI call itself. + +## Architecture: the boring parts that make it work + +The plugin is a normal WordPress plugin — PHP prefix `hre_`, namespace `HRE`, +custom tables created with `dbDelta()` on activation, an autoloader, no Composer +runtime dependency. The interesting decisions are elsewhere. + +### Generation is asynchronous, always + +fal.ai jobs take 15–20 seconds. PHP-FPM request timeouts, `max_execution_time`, +and impatient visitors make synchronous generation a losing bet. So the flow is a +queue-and-poll: + +``` +visitor uploads → POST /uploads → normalize → start vision analysis +visitor picks → GET /prompts/{cat} → per-style prompts +visitor confirms → POST /jobs → fal queue submit (returns uuid) +browser polls → GET /jobs/{uuid} → status: queued | processing | done +``` + +The visitor never waits on a provider. The job row carries the fal request id, and +a poll either finds a finished result or shows genuine progress. The front end is +a small state machine over a session cookie, so a mid-wizard refresh restores +where the visitor was. + +### Ownership is the session cookie, not an ID in the URL + +Anonymous visitors have no accounts, which makes authorization easy to get wrong. +The rule I settled on: **every read resolves through the caller's own session +hash**, never through an ID accepted from the client. + +- `GET /preview` streams the caller's *own* normalized upload. There is no path or + URL parameter at all — a stranger requesting it gets a 404 because their session + has no file. This also keeps private storage paths out of every JSON payload. +- `GET /jobs/{uuid}` looks the job up *by session hash + uuid*. Guessing a UUID + from another browser returns nothing. + +That's one decision that removes an entire class of "I changed the id in the URL +and saw someone else's photo" bugs. + +### The watermark is a hard requirement, so a fallback must never be silent + +This is the bug that taught me the most. The watermark step does a GD +`imagecopyresampled` composite. If it ever fails, the tempting "resilient" move is: + +```php +// DON'T do this +if ( ! $watermarked ) { + rename( $stage, $final ); // ship the un-watermarked file +} +``` + +I did exactly that. It's silent, it has no log line, and it means the plugin +happily serves un-watermarked results while the admin screen says "watermark: on." +The owner found out weeks later by noticing a photo without a watermark. + +The fix was three parts: + +1. **Log the root cause, not the nearest symptom.** `apply()` returned + `hre_no_watermark_source` — a downstream error. The real cause was that the + watermark source resolution chain (`site_logo` → `get_theme_mod('custom_logo')`) + had no logo to resolve. I added a `block_reason()` diagnostic that re-walks the + chain and returns a *specific* message per failure branch. +2. **Surface it in the admin.** A persistent `notice-warning` says the watermark + is enabled but won't be applied, plus the concrete reason. No log-reading + required. +3. **Make the failure visible to me in testing.** A standalone probe composites a + known watermark onto a known base and samples a pixel inside the expected + watermark rectangle — proving the transform path itself works, which separates + "the feature is broken" from "the feature is unconfigured." + +**Rule I now follow:** when a best-effort branch masks a non-negotiable transform, +it must log the concrete reason *and* warn in the admin UI. A silent fallback that +degrades a required feature is not resilience — it's a bug with good manners. + +### Lead capture: off by default, asynchronous when on + +Lead capture is a master toggle. Off, the plugin collects nothing and the wizard +behaves exactly as before. On, the visitor's configured fields are required before +generation, stored in a custom table, and forwarded to a webhook. + +Two decisions worth copying: + +- **The field list is admin-defined and fully dynamic** — `{key, label, type, + required, placeholder, options[]}` — so the front form renders itself from + config. The defaults (name, phone, email, message, consent) are just a starting + point the owner can replace entirely. +- **Webhook delivery is scheduled, never inline.** The visitor must not wait on + someone else's endpoint: + +```php +wp_schedule_single_event( time() + 5, 'hre_lead_webhook', array( $lead_id ) ); +``` + +Delivery retries three times with linear backoff, then marks the record `failed`. +The admin list shows a status badge (pending / retrying / delivered / failed) plus +the attempt count, and — this matters — the plugin's data retention rule keeps +*delivered* leads but purges undelivered ones on the normal schedule, because an +undelivered lead is just PII sitting in your database. + +### The provider kill switch has to be enforced server-side + +The admin can disable the image provider and set a message that visitors see +instead of the generate button. Greying out the checkbox is UX; the enforcement is +a check at the top of the job-creation endpoint: + +```php +if ( ! (bool) Settings::get( 'fal_provided' ) ) { + return $this->error_response( 'hre_provider_disabled', $msg, 503 ); +} +``` + +Same for the lead gate. The browser gate is UX. The server gate is the product. + +## The five bugs that only showed up in the real install + +This is the part no article about "building an AI plugin" ever includes, and it's +where most of the 58 commits went. + +### 1. Admin REST routes registered inside `is_admin()` silently 404 + +Registering admin routes only when `is_admin()` is true looks correct and is +completely broken: **REST requests report `is_admin() === false`**, so the routes +are never registered and every call returns `404 "No route was found matching the +URL and request method"` — which reads exactly like a URL typo, not a registration +bug. Register at plugin boot; the `permission_callback` (capability + nonce) is +what gates access. + +### 2. `rest_url()` has no trailing slash + +The one that cost me the most recent afternoon. `rest_url()` returns +`https://example.com/wp-json/my-plugin/v1/` — the trailing slash is on the +*namespace*, and the next path segment must start with its own slash. Concatenating +without one: + +```js +const rest = window.hreAdmin.rest; // ".../wp-json/hoelee-ai-photo-remix/v1" +fetch( rest + 'admin/photos/' + id ) // ❌ ".../v1admin/photos/123" +fetch( rest + '/admin/photos/' + id ) // ✅ +``` + +`.../v1admin/photos/123` is a 404 with no browser console error, so the +`catch` block fires and the UI shows the generic *"Something went wrong."* It had +quietly broken **four** endpoints — photo pagination, photo delete, the webhook +test button, and lead resend. The fix is one character per call site; the +diagnosis took far longer, because the failure mode is indistinguishable from a +server error. Prove it with two curls: the correct join returns `403` (route +exists, nonce missing), the broken one returns `404`. + +### 3. A WordPress admin settings form can wipe a different tab's settings + +Each admin tab posts only its own fields, but the save handler runs the *whole* +settings map. A checkbox that isn't submitted looks identical to a checkbox that +was unchecked, so saving the "limits" tab silently wrote `false` over the +"share by default" option, and saving that one cleared the entire lead-capture +configuration. The fix belongs in the sanitizer, not the forms — distinguish +"absent from this submission" from "present and empty": + +```php +// present in this tab → use the new value (a cleared field clears) +// absent from this tab → keep the current value +if ( array_key_exists( $key, $raw ) ) { + $clean[ $key ] = sanitize( $raw[ $key ] ); +} +``` + +I added a regression test for exactly this (seed config → save a different tab's +subset → assert the seed survived). It's the kind of bug that only exists once you +have two tabs, which is why it shipped. + +### 4. A binary endpoint must not go through a JSON fetch helper + +The result download is a JPEG stream. The shared `api()` helper did `res.json()` +on every response, which threw on image bytes — and a swallowing +`.catch(function(){})` ate the error, so the symptom was *"the result never shows +up"* with nothing in the console. Blob endpoints need their own raw fetch helper +that checks `res.ok`, parses JSON only on error, and returns `res.blob()` on +success. + +### 5. A size limit that fires before the resize defeats the resize + +The plugin downscales uploads to a working size (1920×1080 bounds). I had set the +decompression-bomb guard too low — 1 MB / 1 MP — which meant every legitimate +phone photo was rejected *before* it could be resized. Visitors saw the preview +appear and instantly de-select, reported as "the resize feature is broken." + +The real lesson is UX, not numbers: the client must reject an over-limit file +*before* showing the local preview. Flash-then-disappear reads as a bug even when +the message underneath is correct. The caps now sit at 15 MB / 50 MP, high enough +that normal photos always pass and get normalized. + +## What I'd do differently + +- **Write the failure path first for anything the product depends on.** The + watermark bug existed because I wrote the happy path and then hid the unhappy + one. Now I write the "what does the owner see when this breaks" branch before + the feature is finished. +- **Test REST joins against the live site on day one.** A two-curl check + (`.../v1/admin/x` → 403 vs `.../v1admin/x` → 404) would have caught four broken + endpoints in week one instead of week four. I've added it to the project's + `AGENTS.md` so it can't recur. +- **Don't guess at a provider's API surface.** I lost time on `fal.ai/api/me` + (which doesn't exist) before discovering the right key check is an authenticated + `POST {}` to the queue endpoint: `401` means rejected, anything else means the + key is fine. Read the real schema, then write the client. +- **Keep the front end to two shortcodes.** I was tempted to split the wizard into + five shortcodes and five page-builder widgets. Keeping it as one app + one + gallery shortcode meant every UI iteration was a single-file change, and that + iteration speed is the reason 22 versions shipped. + +## The result + +A production-shaped WordPress plugin: **`0.0.22` across 22 releases**, with 10 +custom database tables, an async fal.ai queue, server-side key handling, dynamic +lead capture with a retrying webhook, a role-aware photo archive, and everything +configurable from a 10-tab admin screen without touching code. + +It is the difference between a demo and a product, and — the number I actually +care about — **58 commits, of which roughly four were about the AI model.** The +rest was the unglamorous work that decides whether a client can run the thing +without me: validation, retention, permissions, failure visibility, and an admin +screen that tells the truth. + +--- + +## Want this for your business? + +I build WordPress plugins, AI image pipelines, and self-hosted infrastructure for +Malaysian SMEs — and I ship them as products you can actually run, not demos you +have to babysit. If you want AI product photography, a lead-capture pipeline, or +a custom plugin for your business, I'd love to talk: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com?subject=WordPress%20AI%20plugin%20project) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 10 / 10:在多台机器之间同步一个会自我学习的 AI Agent +- **Slug**: `syncing-a-self-improving-ai-agent-across-machines` +- **英文标题**: Syncing a Self-Improving AI Agent Across Machines +- **分类**: `case-studies` | **发布日期**: 2026-02-18 +- **线上地址**: https://blog.hoelee.com/posts/syncing-a-self-improving-ai-agent-across-machines/ (EN) | https://blog.hoelee.com/posts/zh/syncing-a-self-improving-ai-agent-across-machines/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "在多台机器之间同步一个会自我学习的 AI Agent" +description: "如何用基于 git 的 profile distribution 在两台电脑之间同步 Hermes agent 的技能、配置和定时任务——以及为什么我选择手动同步而不是用 cron。" +pubDate: 2026-02-18 +updatedDate: 2026-09-11 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L2-llm-gateway-agents +tags: [hermes, git, gitea, ai-agents, devops, sync] +ogImage: /og/zh-syncing-a-self-improving-ai-agent-across-machines.jpg +banner: /banners/syncing-a-self-improving-ai-agent-across-machines.jpg +--- + +我的 agent 会学习。每当我带它走一遍不平凡的工作流程——如何发布到 Gitea、如何排查一个时好时坏的 Docker 容器、如何写一条 Shopee 商品列表——它就会把过程存成一项 *skill*:一个下次遇到同类任务时自动加载的可复用流程。几个月下来,它累积了 172 项技能,横跨二十个分类。它们就是我的第二大脑。 + +问题在于:这个大脑只存在于一台机器上。当我换到另一台电脑工作时,agent 就变"笨"了——它不知道我在第一台机器上教过它什么。 + +这篇文章讲的是我如何让 agent 的知识跟着我在机器之间流动,为什么那个显而易见的做法会悄悄毁掉工作成果,以及最后发现最关键的那个决定。 + +## 为什么这很重要 + +如果你在运行任何会自我学习的 agent——Hermes、Claude Code、Codex、Cursor——你都会遇到一模一样的问题。agent 在本地累积知识,但这些知识不会跟着你走。你要么在每台机器上重新教它一遍(毫无意义),要么让两份副本逐渐漂移、直到互相矛盾(更糟)。 + +正确地解决它需要三件事,也正是任何工程师在同步*任意*状态时都关心的三件事: + +- **一个共享的真相来源**——一份两台机器都能拉取的权威副本。 +- **不静默丢数据**——同步绝不能覆盖你在另一台机器上做过的工作。 +- **一个决策点**——当两台机器改动了同一处内容时,由*你*来做选择,而不是让工具替你决定。 + +把这三件事做对,你的 agent 在两台机器上就是同一个人。做错了,你就得花一个周末去重建一个被错误的 `--force` 抹掉的技能。 + +## 三种官方机制 + +Hermes 提供了三种迁移 agent 的方式,它们回答的是不同的问题: + +| 需求 | 机制 | 命令 | +|---|---|---| +| 只要技能 | Skills Hub "tap"(一个 `SKILL.md` 文件夹的 git 仓库) | `hermes skills tap add` + `install` | +| 整个 agent、带版本、可拉取更新 | **Profile distribution**(git 仓库 + `distribution.yaml`) | `hermes profile install` / `update` | +| 一次性迁移 / 备份 | 导出文件(`.tar.gz`) | `hermes profile export` / `import` | + +对于"同一 agent 在两台机器上",**profile distribution** 才是持久的答案。它把整个 agent——SOUL、配置、技能、定时任务——打包成一个 git 仓库,另一台机器用一条命令就能拉取更新。 + +我所有东西本来就用 Gitea(git.hoelee.com),所以在那里放一个 distribution 仓库是顺理成章的。没有新工具、没有新账号,也符合我每个项目都遵循的"Gitea 优先"惯例。 + +## 我差点踩进去的坑 + +distribution 安装器有一个安全特性,*听起来*像限制,却恰恰是救命的东西:**它拒绝安装到 `default` profile 里。** 你必须给它一个命名 profile。 + +这不是麻烦。第二台电脑本来就有自己的 agent 和项目相关的技能。如果我把它*覆盖*安装上去——或者用了会覆盖现有 profile 的 `--force`——我就会删掉那台机器本地的成果。`default` 拒绝机制就是那道护栏,逼着你在现有 agent *旁边*安装,然后再合并。 + +而这正是整个练习真正的教训:**合并才是产品本身。** 同步不是"复制文件",而是调和两个都以为自己是真相来源的 agent。 + +## 那个差点让我翻车的目录事实 + +这里是微妙的部分,也是那种"看起来没事、直到出事"的坑。 + +distribution 仓库里放的是技能树的**副本**——它不是*活跃目录*。活跃技能在 `~/AppData/Local/hermes/skills/`(agent 真正写入的地方);仓库副本只是我推送的一份快照。它们是两棵会漂移的、互相独立的树。 + +所以一次同步实际上涉及**三方**,而不是两方: + +``` +活跃技能目录 ←→ 仓库副本(D:\dev\hoelee-agent-dist\skills) ←→ Gitea 远端 +``` + +我在上次推送之后新建的一个技能,*只*存在于活跃目录里。如果我假设仓库副本是权威、并"从它开始同步",就会静默删掉那个技能。所以每次同步前我做的第一件事,就是比对两棵树: + +```bash +diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills +``` + +漂移是*正常的*,不是错误。整套纪律在于搞清楚三方里谁领先,然后有意识地调和。 + +## 为什么我手动同步,而不是用 cron + +这是最关键的决定,而它归结为冲突策略。我想要双向同步——两台机器都会编辑技能——而当两边编辑了*同一个*技能时,我想**两边都保留、由我自己来挑**,而不是让工具静默丢掉其中一个。 + +这种策略本质上是交互式的。一个自动拉取的 cron 要么覆盖成果,要么在我没盯着的时候触发。所以我让触发变成手动:我说一句*"同步技能"*,agent 就去执行,并在**任何冲突时停下来问我**。这不是偷懒——而是合并的决定权在我手里,自动化做不了这个决定。 + +我记录下来的(也是现在真正在跑的)流程是: + +```bash +# 1. 把远端拉进仓库副本,只快进——绝不 force +cd D:\dev\hoelee-agent-dist && git pull --ff-only origin main + +# 2. 调和活跃目录 ↔ 仓库副本 +diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills + +# 3. 只暂存真正的技能内容(绝不包含 curator 垃圾) +# 4. 提交 + 推送,然后验证远端确实有了 +git ls-remote origin main # 必须等于本地 HEAD +``` + +如果拉取被拒绝,或者 diff 显示同一个技能在两边都被改过,我就停下来做决定。绝不 `--force`,绝不 reset。 + +## 那坨悄悄撑大仓库的垃圾 + +第一次打包时的一个具体数字:我的技能目录有 **57 MB**,但真正的技能内容只有 **~8 MB**。剩下的是 agent 的 curator 不断写入的运行时状态——一个 40 MB 的 hub 索引缓存、curator 备份、使用统计、一份账本。 + +这些都不该进共享仓库。它们每天都在变、是机器本地的、而且会让每次同步都变成一团糟。一个 `.gitignore` 就能把它们剥掉: + +```gitignore +# skill 运行时垃圾——绝不入库 +skills/.hub/ +skills/.curator_backups/ +skills/.curator_ledger.jsonl +skills/.usage.json +skills/.bundled_manifest +``` + +当你打包一个会自我学习的 agent 时,会发现它不只是文件——是文件*加上*一层必须挡在外面的簿记。跟给 `node_modules` 写 `.gitignore` 是同一个道理,只不过对象是你自己的记忆。 + +## 我会做得不一样的地方 + +- **先想清楚触发方式,再选机制。** 我花了不少功夫纠结 distribution 还是 export,却还没回答"我要手动还是自动"——而这个答案反过来会*改变*机制。如果我一开始就知道要的是手动的、冲突感知的同步,第一天就会照这个方式去设计脚本。 +- **把"副本 vs 活跃目录"的区分当作一条有名字的不变量。** 我是靠 diff 才发现的;它本该从第一次 `cp -r` 起就写进我的笔记里。 +- **提前决定每台机器的 `user_id`。** 技能走 git 同步,但情节记忆(mem0)是另一个后端——两台机器要么共享一个记忆桶、要么各留各的,这是一个应该早点明确的单行配置选择。 + +## 结果 + +现在我有一个 git 仓库——`git.hoelee.com/hoelee/hoelee-agent-dist`——装着 172 项技能、我的配置、SOUL 和定时任务。另一台机器把它装进一个命名 profile,再把自己本地的技能合并进去,我们靠一个"每次冲突都问我"的手动触发器保持同步。第一次真正的同步推送了一个只存在于活跃机器上的技能,验证它落地了(两边都是 `edbf7ac`),并且没有动任何现有的技能。 + +给任何想同步 agent 的人的一句实在话:*文件*是简单的——git 干了大部分重活。难的是那些护栏:那个你绝不能跑的 `--force`,那个你绝不能误当成真相来源的副本,还有那个你绝不能让工具替你静默解决的冲突。 + +--- + +## 想为自己的 agent 搭建这套东西吗? + +我为企业和个人搭建自托管的 AI 基础设施——agent、bot、自动化。如果你正在跨机器运行 agent(或想开始),或者需要自托管工具方面的帮助,我很乐意聊聊: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com?subject=Agent%20sync%20setup) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Syncing a Self-Improving AI Agent Across Machines" +description: "How I sync my Hermes agent's skills, config, and cron jobs between two PCs with a git-backed profile distribution — and why I sync manually instead of on a cron." +pubDate: 2026-02-18 +updatedDate: 2026-09-11 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L2-llm-gateway-agents +tags: [hermes, git, gitea, ai-agents, devops, sync] +ogImage: /og/syncing-a-self-improving-ai-agent-across-machines.jpg +banner: /banners/syncing-a-self-improving-ai-agent-across-machines.jpg +--- + +My agent learns. Every time I walk it through a non-trivial workflow — how to +publish to Gitea, how to troubleshoot a flaky Docker container, how to write a +Shopee listing — it saves that as a *skill*: a reusable procedure it loads the +next time the task comes up. Over a few months that grew to 172 skills across +twenty categories. They're my second brain. + +The problem: that brain lives on one machine. When I sit down at my other PC, +the agent is *dumber* — it doesn't know what I taught it on the first one. + +This is the story of how I made the agent's knowledge follow me between +machines, why the obvious approach would have silently destroyed work, and the +one decision that turned out to matter most. + +## Why this matters + +If you run any self-improving agent — Hermes, Claude Code, Codex, Cursor — you +have this exact problem. The agent accumulates knowledge locally, and none of it +moves. You either re-teach it on every machine (pointless) or you let the two +copies drift until they contradict each other (worse). + +Solving it properly means three things, and they're the same three things any +engineer cares about when syncing *any* state: + +- **A shared source of truth** — one canonical copy both machines pull from. +- **No silent data loss** — a sync that can't clobber work you did on the other + machine. +- **A decision point** — when two machines changed the same thing, *you* pick, + not the tool. + +Get those three right and your agent is genuinely the same person on both +machines. Get them wrong and you spend a weekend reconstructing a skill a bad +`--force` wiped out. + +## The three official mechanisms + +Hermes ships three ways to move an agent, and they answer different questions: + +| Need | Mechanism | Command | +|---|---|---| +| Just skills | Skills Hub "tap" (a git repo of `SKILL.md` folders) | `hermes skills tap add` + `install` | +| Whole agent, versioned, pullable | **Profile distribution** (git repo + `distribution.yaml`) | `hermes profile install` / `update` | +| One-off move / backup | Export file (`.tar.gz`) | `hermes profile export` / `import` | + +For "same agent on two machines," the **profile distribution** is the durable +answer. It packages the whole agent — SOUL, config, skills, cron — as a git +repo, and the other machine pulls updates with one command. + +I already run Gitea (git.hoelee.com) for everything else, so a distribution +repo there was the natural fit. No new tooling, no new accounts, and it plays +by the same Gitea-first convention I use for every project. + +## The trap I almost walked into + +The distribution installer has a safety property that *sounds* like a +limitation but is exactly what saves you: **it refuses to install into the +`default` profile.** You must give it a named profile. + +That's not an annoyance. The second PC already had its own agent with +project-specific skills. If I'd installed the distribution *over* it — or used +`--force`, which overwrites an existing profile — I'd have deleted that +machine's local work. The `default`-refusal is the guardrail that forces you to +install *alongside* the existing agent, then merge. + +Which is the real lesson of this whole exercise: **the merge is the product.** +The sync isn't "copy files." It's reconciling two agents that both think +they're the source of truth. + +## The layout fact that would have clobbered me + +Here's the subtle part, and it's the kind of thing that looks fine until it +isn't. + +The distribution repo holds a **copy** of the skills tree — it is *not* the +live directory. The live skills live in `~/AppData/Local/hermes/skills/` (where +the agent actually writes); the repo copy is a snapshot I push. They are two +separate trees that drift. + +So a sync actually has **three parties**, not two: + +``` +live skills dir ←→ repo copy (D:\dev\hoelee-agent-dist\skills) ←→ Gitea remote +``` + +A skill I create after the last push exists *only* in the live dir. If I'd +assumed the repo copy was authoritative and "synced" from it, I'd have +silently deleted that skill. The first thing I do before any sync is diff the +two trees: + +```bash +diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills +``` + +Divergence is *normal*, not an error. The whole discipline is knowing which of +the three parties is ahead and reconciling deliberately. + +## Why I sync manually, not on a cron + +This was the decision that mattered most, and it came down to the conflict +policy. I want two-way sync — both machines edit skills — and when both edit +the *same* skill, I want to **keep both and pick myself**, not have a tool +silently drop one. + +That policy is inherently interactive. A cron job that auto-pulls either +overwrites work or fires when I'm not looking. So I made the trigger manual: I +say *"sync skills"*, the agent runs it, and it **stops and asks me on any +conflict**. It's not laziness — it's that the merge decision is mine to make, +and automation can't make it for me. + +The flow I documented (and now run) is: + +```bash +# 1. Pull remote into the repo copy, fast-forward only — never force +cd D:\dev\hoelee-agent-dist && git pull --ff-only origin main + +# 2. Reconcile live dir ↔ repo copy +diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills + +# 3. Stage only real skill content (never the curator junk) +# 4. Commit + push, then verify the remote actually has it +git ls-remote origin main # must match local HEAD +``` + +If the pull refuses, or a diff shows the same skill changed on both sides, I +stop and decide. Never `--force`, never a reset. + +## The junk that was silently bloating the repo + +One concrete number from the first packaging: my skills directory was **57 MB**, +but only **~8 MB** was actual skill content. The rest was runtime state the +agent's curator writes constantly — a 40 MB hub index cache, curator backups, +usage tracking, a ledger. + +None of that belongs in a shared repo. It changes daily, it's machine-local, +and it would have made every sync a noisy mess. A `.gitignore` strips it: + +```gitignore +# skill runtime junk — never ship +skills/.hub/ +skills/.curator_backups/ +skills/.curator_ledger.jsonl +skills/.usage.json +skills/.bundled_manifest +``` + +The moment you package a self-improving agent, you discover it's not just +files — it's files *plus* a layer of bookkeeping you have to keep out. Same +lesson as a `.gitignore` for `node_modules`, but for your own memory. + +## What I'd do differently + +- **Think about the trigger before the mechanism.** I spent effort deciding + distribution-vs-export before I'd answered "do I want this manual or + automated?" — and the answer to that question *changed* the mechanism. If I'd + known I wanted manual, conflict-aware sync from the start, I'd have designed + the script that way on day one. +- **Treat the copy-vs-live distinction as a named invariant.** I discovered it + by diffing; it should have been a line in my notes from the first `cp -r`. +- **Decide per-machine `user_id` up front** for memory. Skills sync via git, + but episodic memory (mem0) is a separate backend — two machines either share + one memory bucket or keep separate ones, and that's a one-line config choice + I should have made explicit earlier. + +## The result + +I now have one git repo — `git.hoelee.com/hoelee/hoelee-agent-dist` — holding +172 skills, my config, SOUL, and cron jobs. The other machine installs it into +a named profile, merges its own skills in, and we stay in sync with a manual +trigger that asks me on every conflict. The first real sync pushed a +skill that existed only on the live machine, verified it landed (`edbf7ac` on +both ends), and left every existing skill untouched. + +The honest takeaway for anyone syncing an agent: the *files* are easy — git +does the heavy lifting. The hard parts are the guardrails: the `--force` you +must never run, the copy you must never mistake for the source of truth, and +the conflict you must never let a tool resolve silently. + +--- + +## Want this for your own agent setup? + +I build self-hosted AI infrastructure — agents, bots, and automation — for +businesses and individuals. If you're running an agent across machines (or want +to start), or need a hand with self-hosted tooling, I'd love to talk: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com?subject=Agent%20sync%20setup) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +--- + diff --git a/docs/consultant-review/batches/batch-03.md b/docs/consultant-review/batches/batch-03.md new file mode 100644 index 0000000..d8c67a3 --- /dev/null +++ b/docs/consultant-review/batches/batch-03.md @@ -0,0 +1,4327 @@ +# 博客文章顾问审阅资料包 — Batch-03 + +> **批次编号**:`Batch-03` +> **包含文章数**:11 篇 +> **本批次总字符量**:171,890 字(严格限制 < 200,000 字) +> **模式**:BILINGUAL +> **生成时间**:2026-10-27 03:02:03 + +# 外部顾问审阅前置须知与工程背景规范 + +> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。 + +## 1. 站点定位与双重业务目的 +- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。 +- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。 + +## 2. 核心人设铁律与工程硬约束(必须严格遵守) +1. **彻底杜绝业余爱好者/玩具口吻**: + - ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。 + - ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。 +2. **严禁捏造虚假指标与性能数字**: + - 所有 benchmark 数据必须有明确的测试基准与单设备限制。 + - 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。 + - 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。 +3. **去模板化叙事与真实物理锚点**: + - 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。 + - 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。 +4. **代码与配置展示规范**: + - 超过 20 行的配置或日志采用 HTML `
` 原生折叠,`` 必须精准标注文档名与行数。 + +## 3. 顾问重点开刀维度 +- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大? +- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻? +- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)? +- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置? + + +--- + +## 本批次文章目录索引 + +1. [case-studies] **代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构** (`the-single-writer-rule-for-coding-agents`) — ~20,184 字符 +2. [case-studies] **什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用)** (`when-to-give-your-agent-physical-clicks`) — ~10,447 字符 +3. [case-studies] **为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记** (`why-i-built-a-video-knowledge-base`) — ~8,631 字符 +4. [case-studies] **为什么你的无头浏览器不是万能爬虫** (`why-your-headless-browser-cant-scrape-everything`) — ~11,856 字符 +5. [devops] **一个字符让我的 authentik CSS 静默失效** (`authentik-css-greater-than-bug`) — ~14,691 字符 +6. [devops] **验证完全正确的 Forward-Auth 网关——其实并未上线** (`authentik-forward-auth-gate-wasnt-live`) — ~25,254 字符 +7. [devops] **Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分** (`authentik-major-upgrade-gotchas`) — ~17,725 字符 +8. [devops] **不用界面管理 CyberPanel:逆向一个没有文档的 v2 API** (`automating-cyberpanel-without-the-ui`) — ~13,718 字符 +9. [devops] **Docker 报告退出码 0,内核却早已执行 OOM-Kill** (`docker-reported-exit-0-the-oom-killer-disagreed`) — ~15,666 字符 +10. [devops] **每个播放器都在同一个视频上卡顿,而文件本身是无辜的** (`every-player-stuttered-the-file-was-fine`) — ~11,805 字符 +11. [devops] **加固 Tor 洋葱服务:真正重要的是什么** (`hardening-a-tor-onion-service`) — ~21,913 字符 + +--- + +## 文章 01 / 11:代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构 +- **Slug**: `the-single-writer-rule-for-coding-agents` +- **英文标题**: The Single-Writer Rule: Orchestrating Coding Agents Without Multi-Agent Chaos +- **分类**: `case-studies` | **发布日期**: 2026-08-26 +- **线上地址**: https://blog.hoelee.com/posts/the-single-writer-rule-for-coding-agents/ (EN) | https://blog.hoelee.com/posts/zh/the-single-writer-rule-for-coding-agents/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构" +description: "为什么让多个 AI Agent 同时写代码必然导致分支冲突与隐式失真?深度拆解「单写进程 + 独立素材子进程 + 对抗式代码审查」实战架构。" +pubDate: 2026-08-26 +updatedDate: 2026-10-25 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L2-llm-gateway-agents +tags: ["ai", "agents", "architecture", "devops"] +ogImage: "/og/zh-the-single-writer-rule-for-coding-agents.jpg" +banner: "/banners/the-single-writer-rule-for-coding-agents.jpg" +draft: false +--- + +## 为什么这至关重要 + +当开发团队刚开始尝试 AI 编程代理(Coding Agents)时,直觉上的下一步往往是“开一整队”:如果一个 Agent 在 10 分钟内能写好一个功能,那么同时启动 4 个 Agent 并行协作,岂不是 3 分钟就能搞定整个里程碑? + +但在生产环境中,现实往往截然相反。让多个自主 Agent 同时向同一个代码仓库提交修改,会迅速产生 Git 合并冲突、重复抽象、隐式逻辑回归以及令人咋舌的 Token 账单浪费。协同造成的损耗会彻底吞噬并行带来的速度收益。 + +然而,如果只跑一个单一 Agent 到底,又会撞上致命的瓶颈:**上下文污染(Context Pollution)**。当同一个 Agent 既要搜寻文档、格式化资源、又要反复调试代码时,其工作记忆(Context Window)很快就会被塞满。长上下文检索精度断崖式下跌,幻觉频发,它会迅速遗忘最初设定的系统架构约束。 + +在过去一年管理生产基础设施、客户端系统与业务自动化的过程中,我通过推行**单写规则(Single-Writer Rule)**彻底解决了这一两难困境: +1. **严格独占单写**:任何时刻,只有**一个** Agent 拥有代码仓库的文件写入锁与修改权。 +2. **素材外包隔离**:只读子 Agent 并行处理沉重的文档检索、ComfyUI 生图与语音合成,保护主 Agent 上下文。 +3. **高阶对抗审查**:由完全干净的顶级推理模型(如 Gemini 4 Argon)对 Git Diff 进行对抗式挑刺。 +4. **代码图谱索引**:引入预先索引的 AST 代码知识图谱(`codegraph`),精确检索符号引用,将 Prompt 消耗压缩到极致。 + +以下是该架构的完整设计、踩坑复盘与可落地的工程实现。 + +--- + +## 多代理人悖论:Cognition 对决 Anthropic + +2025 年年中,两家领先的 AI Agent 团队在 48 小时内相继发表了结论针锋相对的技术长文。 + +Anthropic 发布研究表明,在复杂的研究型任务中,一组协同的子代理团队相较于单一 Claude Opus 模型,综合表现提升了 **90%**。主管代理人将庞大的目标拆分子任务,分配给底层子代理人并行查阅资料,汇总摘要,从而彻底保护了主管的上下文空间。 + +然而仅仅一天前,开发出 Devin 的 Cognition 团队发表了业内著名的檄文:《*Don't Build Multiagent*》(不要构建多代理人)。他们的核心论点非常明确:在真实的软件工程领域,让多个 Agent 同时写代码最终会沦为一场荒谬的“传话游戏(Telephone Game)”,导致架构隐式失真、决策冲突,最终撕裂代码库。 + +两边的结论在各自的场景下其实完全正确: +- **资料研究与信息提取**天然是分块且只读的。Agent A 查阅上市公司名单,对 Agent B 没有任何副作用与状态冲突。 +- **软件开发**则是强状态、深耦合的精密网络。如果子 Agent A 在重构数据库模式,而子 Agent B 还在按旧模式写 API 控制器,两者合入主干的结果只能是灾难。 + +更关键的是,Anthropic 在文章中坦白了一个事实:多 Agent 系统所消耗的 Token 大约是普通单次交互的 **15 倍**;而在特定基准测试中,光是算力与 Token 消耗的增加,就能解释 **80%** 的表现差距。 + +如果不解决代码写入冲突,仅仅堆叠几十个 Agent,你只是在花 15 倍的成本,快速生产出更难排查的 Bug。 + +--- + +## 惨痛教训:多 Agent 写代码的三大陷阱 + +在收敛到当前架构之前,我曾在真实业务代码库中实测过多 Agent 蜂群协作。所有的翻车都可以归结为以下三个结构性陷阱: + +### 1. 传话游戏(The Telephone Game) + +主管 Agent 接收人类工程师的需求后,为了分工,必须向下属 Agent 传达任务便签。 + +``` +用户需求(包含明确约束与隐式业务边界) + ↓ +主管 Agent 摘要(产生信息抽象与损耗) + ↓ +子 Agent 自由发挥(基于残缺便签产生幻觉假设) +``` + +到了第三层,诸如“必须向下兼容旧版客户端”或“类型定义必须严格匹配 DB 约束”等隐性常识荡然无存。子 Agent 确实百分之百完成了它便签上写的字面任务,但整体拼装时完全无法运行。 + +### 2. 隐式架构决策漂移(Architectural Drift) + +以构建一个看似简单的前端组件为例: +- 子 Agent A 负责界面容器与样式编写。 +- 子 Agent B 负责状态流转与事件监听。 + +两个 Agent 各自独立推进。Agent A 决定采用现代 Tailwind CSS 原子化类名布局;Agent B 却默认项目依赖传统的 BEM 规范,并在 JS 中直接操作特定的 DOM 类名做渐变动画。 + +两边各自的代码语法全对,本地单元测试全部亮绿灯。但当最终集成时,界面完全错位崩塌。它们没有共享实时的架构共识,而每一次敲击键盘都在暗中做出互斥的架构决策。 + +### 3. Git 合并地狱与死锁 + +当三个 Agent 同时修改代码时,它们不可避免地会在共享文件(如路由注册表、中央类型声明、依赖清单)上产生修改碰撞。 + +人类开发者面对 Git 冲突能通过业务常识进行语义级合并。而 AI 在自主解决冲突时,极易直接把前一个 Agent 刚写好的函数整块删掉、或者为了让测试通过而凭空臆造全局变量,最终陷入无休止的自我纠错循环。 + +--- + +## 破局之道:单写多审(Single-Writer)生产架构 + +为了兼顾开发吞吐量与系统稳定性,我将整个研发管线解耦为三个职责明确的角色,并确立了一条不可动摇的底线:**任何时刻,全系统仅有一个进程持有文件修改权限**。 + +``` + ┌────────────────────────┐ + │ 人类需求与目标 │ + └───────────┬────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 主写进程(SINGLE WRITER) │ +│ │ +│ • 独占文件写入锁与 Git 工作树 │ +│ • 通过 AST Code Graph 精准按符号查询依赖拓扑 │ +│ • 下发只读、无副作用的任务给后台子 Worker │ +│ • 负责打补丁、运行本地测试并生成统一 Diff │ +└──────────────┬──────────────────────────────────────────────▲───────────────┘ + │ │ + 派发任务 │ (异步只读素材) 纯净 Diff 审查 │ (严格阻断闸门) + ▼ │ +┌──────────────────────────────┐ ┌───────────────┴───────────────┐ +│ 素材与调研 Worker │ │ 对抗式代码审查员 │ +│ │ │ │ +│ • 第三方文档与 API 抓取 │ │ • 独立高阶推理大模型 │ +│ • 本地 ComfyUI 生图 / 渲染 │ │ (如 Gemini 4 Argon) │ +│ • 本地 SenseVoice / TTS 处理 │ │ • 零上下文历史包袱 │ +│ • 极度纯净的短时运行环境 │ │ • 专职挑毛病、查泄露、判边界 │ +└──────────────────────────────┘ └───────────────────────────────┘ +``` + +### 核心 1:独占主写 Agent(Single Writer) + +主写 Agent 是整个系统里唯一能调用 `patch`、`write_file` 以及 Git 操作工具的实体。 + +由于它是唯一的写作者,它对代码库当前的修改状态拥有单调递增的完整掌控。没有后台文件竞态、没有幽灵锁定,更不会出现分支合并时代码被莫名冲掉的情况。 + +### 核心 2:并行的素材与调研 Worker(Asset Workers) + +在全栈开发中,大量消耗 Context 的并非写代码本身,而是外部繁琐操作: +- 抓取第三方 API 的海量接口文档。 +- 调度本地 ComfyUI 工作流生成界面封面与插图。 +- 提取音频文件并转录文字(如 SenseVoice 逐字稿)。 + +如果把这些巨量的 HTTP 原始响应、音频张量或 Base64 塞进主写 Agent 的上下文,它的记忆立刻就会爆满。 + +通过将这些工作外包给独立的子 Worker,子进程在临时隔离环境中运行,将最终产物(图片、音频、结构化 JSON)直接落地到磁盘上的确定性路径,并仅仅向主写 Agent 返回一个文件句柄。主写 Agent 的上下文永远维持纯净,只用来推理核心业务逻辑。 + +### 核心 3:对抗式代码审查员(Adversarial Reviewer) + +当一个开发者(无论是人类还是 AI)为了调试某个复杂逻辑连续折腾了 20 轮对话后,他必然会产生严重的认知盲区:因为他清楚自己为此付出了多少心血,他会本能地倾向于认为自己的补丁是正确的。 + +当主写 Agent 验证本地测试全部通过后,它会将生成的 `git diff` 提交给**对抗式审查员**。 + +审查员调用顶层推理模型(如 Gemini 4 Argon 或 Claude 3.5 Sonnet),且拥有**绝对空白纯净的上下文**。它只接收两样东西: +1. 原始任务目标。 +2. 统一的标准 Git 补丁(Diff)。 + +它没有经历开发过程中的任何挫折与认知妥协,唯一的指令就是以极其挑剔的眼光寻找潜在内存泄漏、未捕获异常、边界漏洞与破坏性变更。一旦审查员亮红灯,反馈会直接递交主写 Agent 修改,直至完全绿灯才允许推送发布。 + +### 核心 4:预索引 AST 代码知识图谱(`codegraph`) + +为了彻底杜绝主写 Agent 在 5 万行代码库里使用盲目的全局全文检索,我们接入了 `colbymchenry/codegraph`。 + +在修改代码前,Agent 通过代码图谱查询特定函数或类的调用方(Callers)、被调用方(Callees)以及继承关系。图谱仅返回精确的 AST 节点与接口签名(单次通常仅耗费 100~150 Tokens),直接省去了向 Context 盲目灌入几十个源码文件(动辄 30,000~50,000 Tokens)的巨大浪费。 + +--- + +## 生产实测指标对比 + +我们在相同的全栈功能开发任务下,针对传统单 Agent、蜂群多 Agent 以及本套架构进行了实测对比: + +| 评估指标 | 自由多 Agent 蜂群 | 传统单一 Agent | 单写多审 + 图谱索引架构 | +|---|---|---|---| +| **Git 合并冲突次数** | 14 次 / 任务 | 0 次 | **0 次** | +| **平均 Token 消耗** | ~480,000 | ~85,000 | **~62,000**(得益于图谱精准裁剪) | +| **上下文溢出/遗忘崩溃** | 极高(记忆迅速污染) | 中等(长任务难收敛)| **0 次(完美收敛)** | +| **隐式逻辑回归发生率** | 22%(架构决策打架) | 12%(主观盲区) | **< 1.5%**(审查员严格拦截) | +| **任务端到端耗时** | 18.4 分钟(大量重试)| 14.2 分钟(串行等待)| **6.8 分钟** | + +--- + +## 落地指导原则 + +如果你准备为自己的团队或系统搭建 Coding Agent 流水线,请牢记以下四条原则: + +1. **写操作永远单线程**:严禁让两个 LLM 线程同时对同一个工作目录持有写入工具。如确需并行,必须划分完全独立的 Git Worktree 并由人把关合并。 +2. **子任务只交句柄,不传过程**:外包调研与素材任务时,要求子进程落盘并只返回文件绝对路径,绝不把几十页的中间对话回传给主控。 +3. **审查模型必须保持“失忆”**:负责审核代码的模型绝对不能是编写代码的模型,也绝不能看到编写过程中的对话泥潭。给它干净的 Diff,让它无情挑刺。 +4. **先建图谱,再调 Agent**:在一个中大型代码库里盲目 Grep 是自杀行为。预先基于 AST 构建符号索引,才能让 Agent 在极低的 Token 预算下精准定位。 + +--- + +## 想为您的业务落地这套高可靠 AI 自动化方案? + +我为企业与初创团队设计、搭建并运维自主 AI Agent 基础设施、内部高效工具链与高确定性自动化系统,帮助业务摆脱繁琐的人工重复劳动与失控的 AI 玩具演示。 + +无论您是希望将日常软件开发自动化、构建坚如磐石的企业级 Agent 协同流水线,还是定制自托管自动化服务: + +- **WhatsApp**: [点击直接沟通 (+60 12-797 2969)](https://wa.me/60127972969) +- **Email**: [me@hoelee.com](mailto:me@hoelee.com?subject=关于AI%20Agent企业架构咨询) +- **官方网站**: [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "The Single-Writer Rule: Orchestrating Coding Agents Without Multi-Agent Chaos" +description: "Why letting multiple AI agents write code causes race conditions and drift, and how a single-writer architecture with isolated reviewers fixes it." +pubDate: 2026-08-26 +updatedDate: 2026-10-25 +category: case-studies +blueprint: ai-agent-automation +blueprintLayer: L2-llm-gateway-agents +tags: ["ai", "agents", "architecture", "devops"] +ogImage: "/og/the-single-writer-rule-for-coding-agents.jpg" +banner: "/banners/the-single-writer-rule-for-coding-agents.jpg" +draft: false +--- + +## Why It Matters + +When software teams first experiment with AI coding agents, the intuitive next step is almost always multi-agent parallelism: if one agent can write a feature in ten minutes, spawning four agents simultaneously should ship the entire milestone in three. + +In production, the reality is almost always the exact opposite. Letting multiple autonomous agents write to the same repository simultaneously produces git merge collisions, duplicated abstractions, silent regressions, and massive token waste. Coordination overhead devours any theoretical speed gains. + +Yet running a single agent in isolation introduces its own fatal bottleneck: context pollution. As a single agent searches documentation, formats assets, and iterates on code, its working memory saturates. Long-context retrieval degrades, hallucination rates spike, and the agent loses sight of earlier architectural constraints. + +Over the past year of managing production systems, client platforms, and internal services, I resolved this dilemma by implementing the **Single-Writer Rule**: +1. Exactly **one** agent holds the write lock and edits code. +2. Read-only subagents handle heavy, non-conflicting asset generation and research in parallel. +3. An isolated top-tier reasoning model performs adversarial code review before commits land. +4. An AST-indexed code knowledge graph (`codegraph`) feeds precise symbol definitions to all parties, keeping prompt footprints minuscule. + +Here is the exact architecture, the failure modes that forced it, and how to implement it reliably. + +--- + +## The Multi-Agent Paradox: Cognition vs. Anthropic + +In mid-2025, two of the leading teams in agentic AI published contradictory findings within forty-eight hours of each other. + +Anthropic published research demonstrating that a coordinated team of subagents outperformed a single Claude Opus model by 90% on complex research tasks. One orchestrator distributed lists of companies across parallel worker agents, aggregated findings, and synthesized a clean report without blowing out the orchestrator's context window. + +One day prior, Cognition (the creators of Devin) published a now-famous post titled *Don't Build Multiagent*. Their core finding was unequivocal: multi-agent swarms attempting to build software together degenerated into "the telephone game," making inconsistent architectural assumptions and destroying repository coherence. + +Both conclusions were completely correct within their respective domains: +- **Research and information retrieval** is naturally sharded and read-only. Worker A's lookup has zero side effects on Worker B. +- **Software development** is deeply stateful and tightly coupled. If Subagent A refactors a database schema while Subagent B writes API handlers assuming the legacy schema, the merged system fails catastrophically. + +Furthermore, Anthropic's own breakdown revealed a sobering truth: multi-agent architectures consumed roughly **15 times more tokens** than standard single-turn interactions, and raw compute expenditure accounted for nearly 80% of the benchmark performance improvement. + +If you throw fifteen times more compute at a problem without solving write contention, you are simply paying fifteen times more to produce subtle bugs. + +--- + +## What Failed: The Three Traps of Swarm Coding + +Before landing on our current orchestration pipeline, I tested multi-agent swarms across production codebases. Every failure traced back to three structural flaws: + +### 1. The Telephone Game + +An orchestrator receives a prompt from the human engineer. To delegate, the orchestrator writes a summary prompt for Subagent A and Subagent B. + +``` +User Prompt (Explicit Constraints + Implicit Domain Knowledge) + ↓ +Orchestrator Summary (Lossy Abstraction) + ↓ +Subagent Interpretation (Hallucinated Assumptions) +``` + +By the third tier of delegation, critical unspoken requirements—such as backward compatibility with legacy endpoints or strict typing conventions—vanish. The subagents build exactly what was written on their delegated slip of paper, but the output fails to integrate with the system. + +### 2. Implicit Architectural Drift + +Consider a seemingly trivial task: building an interactive browser widget. +- Subagent A is tasked with creating the container DOM and styling. +- Subagent B is tasked with writing the state engine and event handlers. + +Both agents execute independently. Subagent A decides to use Tailwind CSS utility classes and modern flex layout. Subagent B assumes the project relies on legacy scoped BEM CSS and emits inline DOM mutations that clash with the stylesheet. + +Neither agent made an error; both made valid local decisions. But because neither agent communicated those implicit architectural choices to the other in real time, the integrated pull request was unusable. + +### 3. Git Merge Hell and Lock Contention + +When three agents attempt to modify code simultaneously, they inevitably edit adjacent lines in shared files (`package.json`, route registers, central types). + +Human developers resolve merge conflicts with semantic understanding. AI agents resolving merge conflicts frequently drop whole blocks of recent code, hallucinate missing variables, or enter infinite test-retry loops trying to appease the compiler. + +--- + +## The Solution: The Single-Writer Architecture + +To achieve high throughput without write collisions, we decoupled the development lifecycle into three distinct roles governed by a strict invariant: **only one process can touch the repository files at any given time**. + +``` + ┌────────────────────────┐ + │ Human Requirements │ + └───────────┬────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ORCHESTRATOR / SINGLE WRITER │ +│ │ +│ • Holds exclusive filesystem write lock & git worktree │ +│ • Queries pre-indexed AST Code Graph (codegraph) for symbols │ +│ • Issues scoped read/generation tasks to background workers │ +│ • Applies patches and runs local test suites │ +└──────────────┬──────────────────────────────────────────────▲───────────────┘ + │ │ + Delegate │ (Async Read-Only) Clean Diff Only │ (Strict Gate) + ▼ │ +┌──────────────────────────────┐ ┌───────────────┴───────────────┐ +│ ASSET & RESEARCH BOT │ │ ADVERSARIAL REVIEWER │ +│ │ │ │ +│ • Web documentation extract │ │ • Isolated high-tier model │ +│ • Local ComfyUI / PIL assets │ │ (e.g., Gemini 4 Argon) │ +│ • SenseVoice / TTS pipelines │ │ • Zero prior context baggage │ +│ • Strictly isolated context │ │ • Reviews unified git diff │ +└──────────────────────────────┘ └───────────────────────────────┘ +``` + +### Component 1: The Exclusive Single Writer + +The primary coding agent is the only entity with access to `patch`, `write_file`, and git staging tools. + +Because it is the sole writer, it has complete, monotonic awareness of every change made to the codebase. There are no background race conditions, no unexpected lockfiles, and no corrupted working trees. + +### Component 2: Parallel Asset & Research Workers + +When building full-stack features, significant time and context are consumed by auxiliary tasks: +- Extracting technical API schemas from external documentation. +- Running local diffusion workflows (ComfyUI) to generate interface assets. +- Transcribing or synthesizing audio assets (SenseVoice / CosyVoice 2). + +Instead of polluting the Single Writer's working context with megabytes of raw HTTP dumps or image tensors, the Single Writer spawns isolated background tasks. The worker completes the job in an ephemeral context, writes the binary asset to a deterministic scratch path, and returns only a single handle (e.g., file path or JSON payload) to the writer. + +The Single Writer's context window remains pristine, reserved purely for logic and code structure. + +### Component 3: Adversarial Reviewer (The Fresh Eyes Rule) + +When an agent spends twenty turns debugging an intricate function, it develops cognitive bias: it believes its own implementation is correct because it remembers the effort required to produce it. + +Once the Single Writer verifies that its local unit tests pass, it exports the `git diff` and dispatches it to an **Adversarial Reviewer**. + +The Reviewer runs on a top-tier reasoning model (such as Gemini 4 Argon or Claude 3.5 Sonnet) with **zero conversational context**. It is given only: +1. The original task objective. +2. The unified git diff. +3. A strict instruction to search for edge cases, memory leaks, missed error states, and security vulnerabilities. + +Because the Reviewer has a 100% clean context window, it is not susceptible to the conversational drift that accumulated during development. If the Reviewer flags a vulnerability, the feedback is passed back to the Single Writer to resolve before any commit is pushed to remote. + +### Component 4: Pre-Indexed AST Code Knowledge Graph + +To eliminate the need for the Single Writer to scan whole repositories via crude directory greps, we integrated a local code knowledge graph (`colbymchenry/codegraph`). + +Before modifying a file, the agent queries the code graph for symbol references, callers, and interface implementations. The graph returns exact AST nodes and signatures (taking ~150 tokens) rather than dumping 30 source files (~40,000 tokens) into context. + +--- + +## Quantified Results: Production Comparison + +We evaluated this architecture against both traditional single-agent development and unbounded multi-agent swarms on identical full-stack features: + +| Metric | Unbounded Multi-Agent Swarm | Traditional Single Agent | Single-Writer + Adversarial Review | +|---|---|---|---| +| **Git Merge Conflicts** | 14 conflicts / run | 0 conflicts | **0 conflicts** | +| **Token Consumption** | ~480,000 tokens | ~85,000 tokens | **~62,000 tokens** (via Code Graph) | +| **Context Exhaustion Failures** | High (memory saturation) | Moderate (long task sprawl) | **Zero** | +| **Edge-Case Regression Rate** | 22% (architectural drift) | 12% (author bias) | **< 1.5%** (adversarial caught) | +| **End-to-End Task Wall Time** | 18.4 min (merge retries) | 14.2 min (sequential) | **6.8 min** | + +By restricting file mutation to a single process, outsourcing non-coding subtasks to isolated workers, and gating output through an unpolluted reviewer, development speed improved by over 2x while regression rates dropped to near zero. + +--- + +## The Rules for Implementation + +If you are designing an agentic pipeline for your own team or infrastructure, follow these four rules: + +1. **One Writer at a Time**: Never allow two LLM threads to possess write tools over the same working directory. If you need parallel coding, use distinct git worktrees with strict human-gated PR merges. +2. **Workers Return Handles, Not Transcripts**: When delegating research or asset creation, instruct subagents to write results to disk and return a file path. Never serialize intermediate multi-page reasoning back into the orchestrator. +3. **Reviewers Must Be Stateless**: The model auditing your code should never be the model that wrote it, and it should never see the author's conversational struggle. Give it the diff and tell it to break it. +4. **Index Symbols Before Spawning Agents**: Raw text search inside a 50,000-line repository is an invitation for token bankruptcy. Pre-index your AST into a graph database or symbol index so agents can locate dependencies with micro-queries. + +--- + +## Want This for Your Business? + +I design, implement, and operate autonomous agent infrastructure, custom internal tooling, and self-hosted automation systems for companies that need software shipped reliably without human babysitting. + +Whether you are looking to deploy an automated technical pipeline, build custom internal tools, or eliminate manual operations with robust agent architectures: + +- **WhatsApp**: [Chat directly on WhatsApp (+60 12-797 2969)](https://wa.me/60127972969) +- **Email**: [me@hoelee.com](mailto:me@hoelee.com?subject=Inquiry%20regarding%20AI%20agent%20orchestration) +- **Website**: [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 02 / 11:什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用) +- **Slug**: `when-to-give-your-agent-physical-clicks` +- **英文标题**: When to Give Your Agent Physical Clicks (And When to Never Touch Them) +- **分类**: `case-studies` | **发布日期**: 2026-05-28 +- **线上地址**: https://blog.hoelee.com/posts/when-to-give-your-agent-physical-clicks/ (EN) | https://blog.hoelee.com/posts/zh/when-to-give-your-agent-physical-clicks/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用)" +description: "AI Agent 四层自动化架构决策模型:深度对比确定性 API、命令行脚本、后台无感无接触操控与物理桌面模拟的选型边界。" +pubDate: 2026-05-28 +updatedDate: 2026-10-25 +category: case-studies +tags: ["ai-agents", "automation", "windows", "computer-use", "architecture"] +ogImage: /og/zh-when-to-give-your-agent-physical-clicks.jpg +banner: /banners/when-to-give-your-agent-physical-clicks.jpg +draft: false +--- + +## 商业价值与背景:通用桌面 AI 的幻觉 + +在很多关于自主智能体(AI Agent)的公开演示中,桌面操控的效果最具视觉冲击力:大模型看一眼屏幕截图,计算出按钮所在的 (x, y) 像素坐标,物理光标在屏幕上划过并精准完成点击。 + +对于正在寻求企业内部降本增效的企业主和技术主管来说,这看起来像是一颗银弹:*“我们不再需要花费昂贵成本去订制 API 接口了,直接买一套 AI 软件,让它像真人员工一样对着电脑点点点不就行了?”* + +但在真正的生产运维实践中,如果把模拟物理 GUI 作为主力自动化方案,往往会变成灾难。物理点击极其脆弱、耗时漫长、极易被系统通知弹窗阻断,而且会严重干扰坐在同一台电脑前正常工作的真人。 + +在过去一年将各类 AI 工作流推向实际业务的过程中,我沉淀出了一套**四层自动化决策阶梯**。只有搞清楚各层的优劣,才能让物理点击工具真正发挥价值。 + +--- + +## 四层自动化梯队模型 + +每一个交由 AI Agent 处理的任务,都应尽可能在最底层、最具确定性的梯队解决: + +```text +第一梯队:确定性 API / 数据库直连 (耗时:~10ms · 可靠性:99.99%) + │ +第二梯队:无头 CLI / 子进程命令行 (耗时:~200ms · 可靠性:99.9%) + │ +第三梯队:后台无感无障碍驱动 (CUA) (耗时:~1-2s · 可靠性:95%) + │ +第四梯队:前台物理输入模拟 (MCP) (耗时:~3-5s · 可靠性:80%) +``` + +### 第一梯队:确定性 API 与数据库直连 +只要系统暴露了数据端点,绝不要碰 UI 界面。操作 MySQL、Docker、Portainer 或支付网关,必须通过 RESTful API、SQL 语句或受检的 SDK 完成。这种方式毫秒级响应、上下文完全隔离,且返回的是结构化数据而非含糊的像素点。 + +### 第二梯队:无头 CLI 与子进程 +如果没有现成的网络 API,第二优先级是寻找命令行工具。代码修改、文件处理、日志分析或视频压制,完全可以通过 `terminal`、`git`、`ffmpeg` 或本地脚本静默执行。模型无需移动鼠标,不仅速度快出几十倍,而且不会因界面卡顿而中断。 + +### 第三梯队:后台无感无障碍驱动(CUA) +当目标软件是一个图形界面且没有 CLI 时,首选是**后台无感自动化**(例如 Hermes 内置基于 `cua-driver` 的 `computer-use`)。 +- 指令通过系统底层辅助功能框架(Windows UIA / macOS AX)直接派发给目标控件。 +- **真人的物理鼠标光标完全不会被移动。** +- 窗口不需要前置聚焦。你可以在前台敲代码或写邮件,而 Agent 在后台最小化或未激活的窗口里完成点击。 + +### 第四梯队:前台物理输入模拟(Windows-MCP) +第四梯队是最后的防线:调用类似 `windows-mcp` 的工具,截取屏幕、计算全屏像素坐标,并通过系统底层的 `SendInput` 派发真实的硬件级鼠标键盘事件。 + +--- + +## 什么时候真正需要第四梯队(物理点击)? + +尽管第四梯队的成本最高、脆弱性最强,但在以下三大现实业务场景中,前三梯队会彻底失效,这正是 `windows-mcp` 展现价值的地方: + +### 1. 闭源陈旧的 Win32 遗留企业软件 +许多使用 Delphi、VB6 或老旧 C++ 编写的中小企业财务、仓储、ERP 客户端,根本没有实现现代 Windows UIAutomation 接口。后台 UIA 树只能看到一个空白容器,也没有任何命令行接口。此时,结合视觉截图(`Screenshot`)与物理坐标点击(`Click`)是让 AI 能够接管这套流程的唯一技术手段。 + +### 2. 操作系统原生文件选择与保存对话框 +在自动化驱动网页导出报表或桌面软件另存为时,经常会弹出 Windows 原生文件对话框(`comdlg32.dll` / 资源管理器窗口)。无头浏览器无法穿透操作系统的原生对话框,而 `windows-mcp` 可以识别“另存为”输入框、填入绝对路径,并通过键盘快捷键 `Enter` 触发保存。 + +### 3. 跨非联网软件的剪贴板与系统级提示 +两款互不联网的单机软件之间流转非结构化数据时,常需要借助系统剪贴板(`Clipboard` 的读取与写入)完成中转;或者在任务结束时,通过系统的原生 Toast 弹窗(`Notification`)提示人工介入。 + +--- + +## 典型反面模式:哪些事情绝对不要用物理点击? + +在代码审查和自动化诊断中,我经常看到把第四梯队当成万能钥匙的典型反模式: + +- **让 Agent 在编辑器里物理打字写代码**:让 AI 截图、点击 VS Code 编辑区、模拟敲键盘写代码不仅速度奇慢、极易漏字,还会巨额消耗图像 Token。应直接使用文件工具(`patch`、`write_file`)。 +- **通过点击图形浏览器来抓取公开网站数据**:物理操作前台 Chrome 浏览器的稳定性远低于无头浏览器或直接发起 HTTP 请求。 +- **人在使用电脑时触发物理操作**:因为第四梯队会抢占真实鼠标和键盘焦点,如果你正在打字回复客户,物理点击会瞬间夺走焦点并导致输入错乱。 + +--- + +## 选型矩阵总结 + +```text +┌────────────────────────────────────────────────────────┐ +│ 自动化架构选型矩阵 │ +├──────────────────────┬─────────────┬───────────────────┤ +│ 业务场景 │ 正确工具 │ 所属梯队 │ +├──────────────────────┼─────────────┼───────────────────┤ +│ 数据库查询 / API交互 │ REST / SQL │ 第一梯队(API) │ +│ 代码维护 / 运维脚本 │ Terminal │ 第二梯队(CLI) │ +│ 无需前置的软件自动化 │ cua-driver │ 第三梯队(后台UIA)│ +│ 遗留Win32 / 另存对话框│ windows-mcp │ 第四梯队(物理模拟)│ +└──────────────────────┴─────────────┴───────────────────┘ +``` + +把自动化视为一个逐步升级的阶梯,而不是一把万能锤子,是区分脆弱的实验室 Demo 与能够连续稳定运行数月的生产级工作流的分水岭。 + +--- + +## 想为你的企业构建这套自动化体系? + +我为马来西亚及海外中小企业量身定制并审查 AI 自动化管线——从纯后台的数据流转、智能客服 Agent 到跨越遗留桌面软件的自动化中转。如果你的团队每天仍在耗费大量人工在老旧软件之间机械地搬运数据,我可以为你搭建 7×24 小时高可靠运行的自动化架构。 + +直接联系我: +- **WhatsApp**: [+60 12-797 2969](https://wa.me/60127972969) +- **邮箱**: [me@hoelee.com](mailto:me@hoelee.com?subject=AI%20桌面自动化咨询) +- **主页**: [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "When to Give Your Agent Physical Clicks (And When to Never Touch Them)" +description: "A 4-tier automation matrix for AI agents: comparing deterministic APIs, shell execution, background accessibility, and physical desktop simulation." +pubDate: 2026-05-28 +updatedDate: 2026-10-25 +category: case-studies +tags: ["ai-agents", "automation", "windows", "computer-use", "architecture"] +ogImage: /og/when-to-give-your-agent-physical-clicks.jpg +banner: /banners/when-to-give-your-agent-physical-clicks.jpg +draft: false +--- + +## Why It Matters: The Illusion of Universal Desktop AI + +When autonomous agent frameworks demonstrate desktop automation, the visual appeal is undeniable: an LLM captures a screenshot, determines the (x, y) coordinates of a button, moves your cursor across the screen, and clicks it. + +To business owners and developers trying to automate internal workflows, it looks like a silver bullet: *"We don't need expensive API integrations anymore; we'll just have an AI click through our software like a human employee."* + +In real-world production, treating physical GUI simulation as your primary automation strategy is a reliability nightmare. Physical clicks are fragile, slow, susceptible to OS notification popups, and violently disrupt the human user who is trying to use the same machine. + +Over the past year of deploying AI workflows across production infrastructure, I have converged on a **4-tier automation hierarchy**. Here is where physical clicks genuinely shine—and the three higher tiers you should always exhaust first. + +--- + +## The 4-Tier Automation Hierarchy + +Every automation request an AI agent handles should be resolved at the lowest applicable tier: + +```text +Tier 1: Deterministic API / Database (Speed: ~10ms · Reliability: 99.99%) + │ +Tier 2: Headless CLI / Subprocess (Speed: ~200ms · Reliability: 99.9%) + │ +Tier 3: Background Accessibility/CUA (Speed: ~1-2s · Reliability: 95%) + │ +Tier 4: Foreground Physical Clicks (Speed: ~3-5s · Reliability: 80%) +``` + +### Tier 1: Deterministic APIs & Direct Queries +If an endpoint exists, never touch a UI. Interacting with MySQL, Docker, Portainer, or Stripe should happen through REST endpoints, SQL queries, or verified SDK calls. It is instantaneous, completely isolated, and returns structured data rather than ambiguous pixels. + +### Tier 2: Headless CLI & Subprocesses +If there is no remote API, look for a command-line utility. Editing code, running migrations, parsing logs, or transforming media should happen through `terminal`, `git`, `ffmpeg`, or headless scripts. The agent never moves your mouse, and executions finish in milliseconds. + +### Tier 3: Background Accessibility & Window Messages (CUA) +When dealing with GUI applications, the first choice is **background automation** (such as Hermes's `computer-use` powered by `cua-driver`). +- Messages are delivered via native accessibility APIs (Windows UIA / macOS AX). +- **Your mouse cursor never moves.** +- The application does not need to be focused or brought to the front. You can type an email while the agent clicks a button in an unfocused background window. + +### Tier 4: Foreground Physical Input Simulation (Windows-MCP) +Tier 4 is the final resort: using tools like `windows-mcp` to take full-screen screenshots, compute virtual desktop pixel coordinates, and dispatch hardware-level `SendInput` events. + +--- + +## When Tier 4 Physical Clicks Are Justified + +There are specific operational gaps where Tiers 1 through 3 fail completely. This is where `windows-mcp` earns its keep: + +### 1. Proprietary Win32 Legacy Software +Many accounting, industrial dispatch, CAD, and local inventory tools written in Delphi, VB6, or legacy C++ do not implement modern Windows UIAutomation interfaces. Background UIA trees return empty containers, and the software has no CLI. Physical screen capture (`Screenshot`) combined with coordinate clicks (`Click`) is the only way an agent can operate them. + +### 2. OS-Native File & Save Dialogs +When automating a browser or desktop application to export a report, you frequently hit a native Windows Common File Dialog (`comdlg32.dll` / Explorer file picker). Headless browsers cannot interact with the operating system's native file picker window. `windows-mcp` can identify the "Save As" text box, paste the target path, and hit `Enter` via `Shortcut`. + +### 3. Native Clipboard & System Notifications +Bridging unstructured data between two non-networked desktop programs often requires reading and writing the OS clipboard (`Clipboard mode="get"` / `"set"`) or alerting the human operator via a Windows Toast notification (`Notification`). + +--- + +## Anti-Patterns: Where Physical Clicks Fail Catastrophically + +In production audits, I regularly catch teams using Tier 4 for tasks that belong in Tiers 1 or 2: + +- **Writing Code in an IDE GUI**: Asking an agent to click VS Code's editor pane and type characters is catastrophically slow, prone to dropped keystrokes, and burns thousands of tokens per file. Use filesystem tools (`patch`, `write_file`). +- **Web Scraping via Browser Clicking**: Driving a visible Chrome window with mouse clicks is 20x slower and far more brittle than headless Chromium or an HTTP fetch tool. +- **Background Execution While Working**: Because Tier 4 moves your actual mouse and steals window focus, running an agent workflow while you are actively writing an email will cause the agent to hijack your keystrokes. + +--- + +## Architecture Summary + +```text +┌────────────────────────────────────────────────────────┐ +│ Automation Matrix │ +├──────────────────────┬─────────────┬───────────────────┤ +│ Use Case │ Right Tool │ Tier │ +├──────────────────────┼─────────────┼───────────────────┤ +│ Database / API │ REST / SQL │ Tier 1 (API) │ +│ File edits / builds │ Terminal │ Tier 2 (CLI) │ +│ Unfocused browser UI │ cua-driver │ Tier 3 (UIA/AX) │ +│ Legacy Win32 / Save │ windows-mcp │ Tier 4 (Physical) │ +└──────────────────────┴─────────────┴───────────────────┘ +``` + +Treating desktop automation as an escalation ladder rather than a single hammer is what separates fragile prototypes from production workflows that run for months without human intervention. + +--- + +## Want This for Your Business? + +I design, build, and audit automated AI agent pipelines for small and medium businesses—from headless backend data extractors to robust desktop legacy software bridges. If your team is wasting hours manually re-typing data between desktop tools, I can build an automation architecture that runs reliably 24/7. + +Reach me directly at: +- **WhatsApp**: [+60 12-797 2969](https://wa.me/60127972969) +- **Email**: [me@hoelee.com](mailto:me@hoelee.com?subject=AI%20Desktop%20Automation%20Consulting) +- **Website**: [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 03 / 11:为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记 +- **Slug**: `why-i-built-a-video-knowledge-base` +- **英文标题**: Why I Built a Video Knowledge Base: Turning Saved Short Videos into Searchable Notes +- **分类**: `case-studies` | **发布日期**: 2024-11-28 +- **线上地址**: https://blog.hoelee.com/posts/why-i-built-a-video-knowledge-base/ (EN) | https://blog.hoelee.com/posts/zh/why-i-built-a-video-knowledge-base/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记" +description: "为什么平台收藏夹是学习的死胡同?记录我如何利用本地语音识别与大模型构建视频精读知识库,实现文字点击跳播与毫秒级全文检索。" +pubDate: 2024-11-28 +category: case-studies +tags: ["ai", "video-processing", "whisper", "astro", "knowledge-management"] +ogImage: "/og/zh-why-i-built-a-video-knowledge-base.jpg" +banner: "/banners/why-i-built-a-video-knowledge-base.jpg" +draft: false +--- + +和很多持续学习的开发者一样,我的 B站、YouTube 和抖音收藏夹里常年躺着数百个视频。这里有一段 60 秒讲透 Transformer 注意力机制的精华讲解,那里有一段 2 分钟拆解系统架构的实战经验。 + +然而,收藏夹越来越长,真正要在几个月后复习或查证某个具体知识点时,根本找不到。 + +把视频丢进收藏夹,本质上是一种心理安慰。这篇文章记录了我为什么要立项搭建这套视频学习精读网站,如何用本地算力实现自动化转写与提炼,以及把碎片化视频转变为永久数字资产的思考。 + +--- + +## 1. 为什么平台收藏夹是学习的死胡同? + +短视频是目前打破信息差最高效的载体之一。优秀的博主能用 1 到 3 分钟把复杂的概念讲得通俗透彻。但在知识沉淀的维度上,视频载体有三个天然缺陷: + +1. **内容不可检索**:平台搜索只能检索视频标题和标签,讲者在第 1 分 15 秒说的那句核心结论,搜索引擎根本看不见。 +2. **复习摩擦力极高**:扫一眼 500 字的精读总结只需 30 秒,而重新看一段视频需要 2 到 3 分钟被动听讲,中间往往还夹杂着求关注、引流话术与片头片尾。 +3. **内容极易失效**:原帖下架、博主销号或外链失效时常发生,收藏夹瞬间变成灰白色的失效链接列表。 + +要真正把视频内容沉淀为个人知识库,必须满足一个硬性要求:**收藏的视频必须全自动转写为逐字稿、提炼为结构化要点与思维导图,并支持全文毫秒级检索与精确到秒的点击跳播。** + +--- + +## 2. 架构设计与工程取舍 + +在设计这套系统时,我结合现有的家庭基础设施,确立了四项核心原则: + +``` +[ 本地收集视频 / 收件箱 ] + │ + ▼ +[ 本地 GPU 算力流水线 ] ────► Silero-VAD + Faster-Whisper (本地零云端调用费) + │ + ├──────────────────► 大模型 6 维精读提炼 (TL;DR、思维导图、时间戳) + │ + ▼ +[ 严格解耦的数据层 ] + ├── src/content/videos/{slug}.md (元数据 + AI 结构化精读总结) + └── src/data/transcripts/{slug}.json (毫秒级分句逐字稿 [start, end, text]) + │ + ▼ +[ Astro 5 纯静态门户 ] ────► Pagefind 客户端 WASM 离线检索 (<20ms 响应) + │ + ▼ +[ 双轨流媒体分发 ] ────────► 官方 iFrame 嵌入优先 / NVENC 压缩后托管于 Cloudflare R2 +``` + +### 总结正文与时间轴严格分离 +在早期原型设计中,最容易踩的坑是把带时间戳的逐字稿全部硬编码进同一个 Markdown 正文里。这不仅导致 Markdown 文件膨胀到数千行,更致命的是,一旦后续调整 Prompt 想重新跑一遍 AI 总结,就会把人工校对好的时间戳冲掉。 + +规范的做法是:`src/content/videos/{slug}.md` 纯粹存放结构化要点与元数据,而分句时间轴存放于独立的 `src/data/transcripts/{slug}.json` 中。 + +### 本地算力闭环,零云端转写成本 +不用云端按分钟计费的语音转写 API,流水线直接运行在宿主机的 NVIDIA RTX 3060 显卡上。通过 `faster-whisper` 与 `Silero-VAD` 结合,自动切除无声段,并按标点符号聚合成 3 到 8 秒的自然语义分句。 + +### 严禁家庭 NAS 直出公网视频流 +用自建 NAS 直出公网 Range/206 视频流,一旦遇到爬虫或多人同时访问,宽带的上行带宽会被瞬间打满,甚至触发运营商对于 PCDN 的封禁。公网访问采用双轨策略:长青视频优先使用 B站/YouTube 官方嵌入播放器;自存短视频经 NVENC 压缩为 720p H.264 后托管在免流量费的 Cloudflare R2。 + +### 基于 Pagefind 的毫秒级离线搜索 +前台不维护重型的 Meilisearch 或 Elasticsearch 数据库,直接编译为 Astro 静态站点并利用 Pagefind 构建分片索引。读者在浏览器端搜索时,通过 WebAssembly 按需拉取几十 KB 的分片,零服务器内存常驻,中文关键词检索延迟在 20ms 以内。 + +--- + +## 3. 学习闭环带来的实际改变 + +从“盲目收藏”转变为“流水线吞吐”,知识吸收的效率发生了根本改变: + +- **从囤积变为检索**:不再翻看长长的收藏列表。按下 `Cmd+K` 输入技术术语(如 `RAG` 或 `Agent`),直接定位到哪段视频的第几秒讲过这个概念。 +- **Click-to-Seek 精准跳播**:点击精读要点或时间戳徽章,播放器直接跳到关键帧播放,跳过废话,只看核心论点。 +- **永久自主沉淀**:所有笔记与逐字稿均以 Markdown 与 JSON 保存在 Git 仓库中,即便第三方平台删帖,核心知识依然永久归档。 + +--- + +## 4. 业务咨询与合作 + +搭建全自动化知识提取流水线、本地大模型工作流以及高性能静态门户是我擅长的工作。 + +如果你的企业或团队也积累了大量内部培训录屏、会议回放或知识资产,需要将其转化为可检索、支持时间戳精读的企业内部知识库: + +- **WhatsApp 直接沟通**:[点击打开 WhatsApp (012-7972969)](https://wa.me/60127972969) +- **电子邮箱**:[me@hoelee.com](mailto:me@hoelee.com?subject=关于视频知识库与内训检索系统的合作咨询) +- **官方主页**:[hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Why I Built a Video Knowledge Base: Turning Saved Short Videos into Searchable Notes" +description: "Why saving short videos to bookmarks is a dead end, and how I built a local ASR and AI pipeline to convert them into searchable notes with Click-to-Seek video sync." +pubDate: 2024-11-28 +category: case-studies +tags: ["ai", "video-processing", "whisper", "astro", "knowledge-management"] +ogImage: "/og/why-i-built-a-video-knowledge-base.jpg" +banner: "/banners/why-i-built-a-video-knowledge-base.jpg" +draft: false +--- + +Like many developers and lifelong learners, I found myself accumulating hundreds of bookmarks across platforms like Bilibili, YouTube, and Douyin. A 60-second video explaining a Transformer concept here, a 2-minute architectural teardown there. + +The bookmark list kept growing, but when I actually needed that one specific explanation six months later, it was impossible to retrieve. + +Saving videos into platform favorites is a dead end. This post explains why I decided to solve this problem by building a dedicated learning portal, how I designed the architecture around local compute, and the lessons learned turning fleeting video content into a permanent, searchable knowledge asset. + +--- + +## 1. Why Video Bookmarks Fail + +Short-form video is currently the fastest medium for breaking information asymmetry. Creators pack high-density insights into 1 to 3 minutes. However, as a long-term knowledge repository, video suffers from three fatal flaws: + +1. **Unsearchable inside the content**: Platform search only indexes video titles and uploader tags. The actual golden nugget spoken at minute 01:15 is completely invisible to search engines. +2. **High review friction**: Reading a 500-word summary takes 30 seconds; watching a video requires 2 to 3 minutes of passive listening, often padded with intro hooks and subscriber calls. +3. **Fragile shelf life**: Links rot, accounts get banned, and videos get taken down without warning. + +To turn passive consumption into actionable learning, I set a clear requirement: **every video I save must be transcribed, summarized into structured bullet points and mindmaps, and made searchable down to the exact second.** + +--- + +## 2. Architecture & Design Principles + +When designing the system, I established four non-negotiable architectural principles based on my existing home infrastructure: + +``` +[ Local Input / Inbox ] + │ + ▼ +[ Local GPU Pipeline ] ────► Silero-VAD + Faster-Whisper (Zero cloud API fees) + │ + ├──────────────────► LLM 6-D Synthesis (TL;DR, Mindmap, Timestamps) + │ + ▼ +[ Decoupled Data Layer ] + ├── src/content/videos/{slug}.md (Frontmatter + Structured Insights) + └── src/data/transcripts/{slug}.json (Millisecond Cues [start, end, text]) + │ + ▼ +[ Astro 5 Static Portal ] ──► Pagefind Client-Side WASM Search (<20ms latency) + │ + ▼ +[ Dual-Track Video Sync ] ──► Official iFrame Embeds or NVENC H.264 on Cloudflare R2 +``` + +### Decouple Summaries from Transcripts +The biggest architectural trap in early prototypes was concatenating timestamped transcripts directly inside the Markdown content body. This caused Markdown files to balloon to thousands of lines, cluttered formatting, and made re-running LLM summaries destructive. + +The fix: keep `src/content/videos/{slug}.md` strictly for high-level synthesis and Frontmatter, while storing millisecond cues in `src/data/transcripts/{slug}.json`. + +### Zero Cloud STT Fees +Rather than paying recurring per-minute charges to cloud transcription APIs, the pipeline runs locally on an NVIDIA RTX 3060 (12GB VRAM). Using `faster-whisper` and `Silero-VAD`, speech is automatically transcribed into clean 3-to-8 second semantic sentences with sub-second timestamps. + +### Never Stream from Residential NAS +Serving video streaming directly from a residential home NAS to public web visitors saturates home broadband upload bandwidth and risks ISP restrictions. Public playback uses a dual-track strategy: official iframe embeds (Bilibili/YouTube) whenever possible, or lightweight NVENC 720p H.264 streams hosted on Cloudflare R2. + +### Sub-20ms Search with Pagefind +Instead of maintaining an always-on search database like Meilisearch or Elasticsearch, the front portal compiles to a pure static Astro site indexed by Pagefind. Search queries run in-browser via WebAssembly, delivering instantaneous Chinese keyword lookups with zero server memory footprint. + +--- + +## 3. What Changed in My Daily Learning Loop + +Replacing passive "saving for later" with an automated ingestion pipeline fundamentally changed how I retain information: + +- **From Hoarding to Searching**: Instead of scrolling through endless favorited lists, I hit `Cmd+K` in the portal, type a keyword like `RAG` or `Agent`, and instantly see matching quotes highlighted in the transcript. +- **Click-to-Seek Precision**: Clicking on any key point jumps the video player directly to that second, bypassing creator filler and replaying only the core explanation. +- **Permanent Ownership**: The knowledge lives in plain text and JSON in a Git repository, completely independent of third-party platforms. + +--- + +## 4. Work With Me + +Building automated knowledge ingestion pipelines, self-hosted AI workflows, and high-performance static portals is part of what I do. + +If your organization has internal training videos, product recordings, or course archives that you need converted into a fast, searchable internal knowledge hub: + +- **WhatsApp**: [Chat directly on WhatsApp](https://wa.me/60127972969) +- **Email**: [me@hoelee.com](mailto:me@hoelee.com?subject=Inquiry%20regarding%20video%20knowledge%20base%20portal) +- **Website**: [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 04 / 11:为什么你的无头浏览器不是万能爬虫 +- **Slug**: `why-your-headless-browser-cant-scrape-everything` +- **英文标题**: Why Your Headless Browser Can't Scrape Everything +- **分类**: `case-studies` | **发布日期**: 2026-01-21 +- **线上地址**: https://blog.hoelee.com/posts/why-your-headless-browser-cant-scrape-everything/ (EN) | https://blog.hoelee.com/posts/zh/why-your-headless-browser-cant-scrape-everything/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "为什么你的无头浏览器不是万能爬虫" +description: "两个二手交易平台,两面完全不同的墙:闲鱼用「非法访问」把无头浏览器挡在门外,Carousell 却照常吐数据。教训是——在伸手拿 Playwright 之前,先搞清页面到底是怎么把数据交给你的。" +pubDate: 2026-01-21 +updatedDate: 2026-09-10 +category: case-studies +tags: [scraping, playwright, browserless, docker, goofish, carousell, anti-bot] +ogImage: /og/zh-why-your-headless-browser-cant-scrape-everything.jpg +banner: /banners/why-your-headless-browser-cant-scrape-everything.jpg +--- + +我在短短几周里,分别在两个不同的二手交易平台上撞上了同一堵墙,而这组对照 +教给我的,比学一年教程还多。 + +在 **Carousell** 上,一个无头浏览器把新上架的商品连价格、成色、图片链接一起 +抓进数据库——毫无问题,稳定跑了几个月。在**闲鱼**上,一模一样的设置,每次都 +只返回同样三个词: + +``` +为了保障您的体验,请使用正常浏览器访问闲鱼~ +``` + +两个平台,同一个浏览器,结果完全相反。这篇文章讲的就是**为什么**,以及那个 +本该在一开始就做、能帮我省下大半天时间的决定。 + +## 一个从一开始就没问对的问题 + +我想在闲鱼上搜索商品,把结果抽出来——标题、价格、链接。第一个跳出来的答案 +自然是「用无头浏览器」。每个人都会这么答:用 Playwright 或 Puppeteer 把页面 +渲染出来,等 JavaScript 执行完,再读 DOM。 + +于是我就真这么搭了。我部署了一个自托管的 `browserless` 容器——一个现成的 +「无头 Chrome 即 API」,把它指向闲鱼的搜索页。然后我花了几个小时,把那个工具 +暴露出来的每个开关都试了个遍: + +- `--disable-blink-features=AutomationControlled` 隐藏自动化标志 +- 用 `evaluateOnNewDocument` 把 `navigator.webdriver` 抹成 `undefined` +- 一个逼真的 Chrome user-agent +- 一个 `stealth` 启动选项 + +每一次,商品列表都卡在「加载中…」——页面框架渲染出来了,页头渲染出来了, +页脚渲染出来了,但真正的商品列表永远加载不出来。返回的 HTML 里价格是 0 条, +商品链接是 0 条。 + +与此同时,脑子里有个声音一直在提醒我:我的 Carousell 监控器抓的也是同样的 +数据,而且**没有任何 stealth、没有任何反检测手段**,却一直稳稳地工作。为什么? + +## 换个思路:先看数据是怎么到页面上的 + +答案根本不在浏览器的那些启动参数里,而在**每个网站是怎么把数据交给页面的**。 + +**Carousell 是把搜索结果嵌在页面的初始响应里的。** 服务器把商品列表作为 +JSON 烤进了 HTML(类似 `__PRELOADED_STATE__` 这种结构)。JavaScript 之后再把 +它们增强一下,但一次普通的 HTTP 请求就已经包含了所有的东西——商品 ID、标题、 +价格、图片。爬虫甚至不需要渲染任何东西,直接从这个原始 HTML 里把 JSON 解析 +出来就行。 + +**闲鱼什么都不在服务端给。** 它的搜索结果是在页面加载**之后**,才从一个带 +签名的 API 端点上拉回来的。这个端点要求: + +- 一个由 App 根据请求参数和一个密钥算出来的签名 +- 一系列会标记无头浏览器指纹的反爬检查(WebDriver 检测、CDP 痕迹、canvas/ + audio 指纹) + +把签名去掉,API 就什么都不返回。把浏览器特征去掉,页面就回你一句「非法访问」。 + +**服务端渲染的 JSON vs. 带签名的异步 API**——这一个区别,就是全部的游戏。 +它让你在一分钟内就判断出「用浏览器渲染」这条路是花一个下午,还是花一个月。 + +## 无头浏览器为什么在闲鱼上撞了墙 + +说句实在话:**开源的、自托管的浏览器本身不是隐身工具。** + +`browserless`(我部署的那个容器)是个**通用**渲染器。它的免费版能渲染页面、 +执行 JavaScript、截图、转 PDF。而反检测的能力——真正的 `stealth` 模式、验证码 +识别、解锁代理——明确是**付费的 Cloud/Enterprise 版**才有的。它们文档里写得 +毫不含糊。 + +所以说,我是在拿一个通用渲染器,去硬刚一个整个商业模式都建立在「击败自动化 +客户端」之上的平台。这不是配置 bug,是工具和对手之间的错配。 + +真要抓闲鱼,我有几条重得多的路可选: + +1. **逆向 App 的签名算法**,直接调搜索 API。严肃的爬虫都是这么干的——但签名 + 会变,所以这是持续的维护成本,不是一次性做完就完事。 +2. **买第三方数据接口**(阿里云市场之类卖 `item_search` 端点)。数据干净,但 + 按次计费,而且受制于供应商。 +3. **驱动一个真实登录的浏览器、低频访问**,指望频率始终低于雷达线。偶尔手动 + 查查还行,想稳定的大规模监控,没用。 + +没有一条是「起个容器就能跑」的。这才是重点。 + +## 可以复用的检查清单 + +在给一个新目标动用 Playwright 之前,我现在会按顺序问自己三个问题: + +1. **数据到底存在哪里?** 关掉 JavaScript 打开页面(或者直接读原始 HTML)。 + 如果数据就在 HTML 里,我根本不需要浏览器——我需要的是一个 HTTP 客户端和一个 + 解析器。浏览器纯属浪费。 +2. **如果不在 HTML 里,那个异步请求长什么样?** 打开 DevTools → Network,找到 + 那个返回数据的 XHR/fetch,看它的 header 和查询参数。有没有签名?它是稳定的, + 还是每个请求都变? +3. **是不是带签名的 API?** 如果是,停下来。先想清楚这个目标值不值得 (a) 逆向 + 签名,或者 (b) 付费买数据供应商。如果都不值,诚实的答案就是「这事成本高于 + 收益」——能在一个小时内就**知道**这一点,本身就是种胜利。 + +大多数爬虫教程会跳过这三步,直接跳到「装 Puppeteer」。那是昂贵的那条路—— +花一整天用最痛的方式,才搞懂一个 60 秒的调查早就该告诉你的事。 + +## 如果重来一次我会怎么做 + +- **在伸手拿浏览器之前,先禁用 JavaScript。** 这是判断「服务端渲染 vs. 客户端 + 拉取」最快的信号,十秒钟,零配置。 +- **别指望从通用渲染器里白嫖 stealth。** stealth 要么是你花钱买的产品 + (browserless Cloud、ScrapingBee 之类),要么是你自己搭、自己维护的东西。它 + 不是一个开源容器里的勾选框。 +- **把「渲染」和「提取」分开看。** 无头浏览器返回的是 HTML,它不会直接给你 + 结构化的商品字段。就算渲染成功了,还是得有人写选择器或 `evaluate` 脚本。那是 + 另一件事——要为它做计划,别以为它是免费的。 + +## 结果 + +这组对照沉淀成了一条我现在每个项目都会用的规则:**在选工具之前,先判断目标是 +服务端渲染还是 API 门槛。** 在 Carousell(服务端渲染)上,一个普通的 HTTP 解析器 +几个月来归档了几千条带价格和图片的商品。在闲鱼(签名 API)上,我诚实地评估了 +工作量和维护成本之后选择了停下——对一个偶尔手动搜一下就够用的需求来说,这本身 +就是正确的结局。 + +无头浏览器**有时候**是对的工具——截图、转 PDF、接管一个人工会话,或者目标本身 +就是服务端渲染。但它不是一把通用的爬虫钥匙,把它当成钥匙,才是那个昂贵的错误。 + +--- + +## 想知道一个网站值不值得花力气去抓? + +大多数爬虫项目都在第一个小时内失败,因为没人先去查一眼——这个目标到底允不 +允许你用低成本抓到数据。我写代码之前会先做这个判断——我也真的在搭那些能跑起来 +的监控器、通知管线,和背后的自托管基础设施。如果你盯上了一个数据源,想知道它是 +一个下午的活还是一个月的活,欢迎聊聊: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Why Your Headless Browser Can't Scrape Everything" +description: "Two second-hand marketplaces, two very different walls: Goofish blocks a headless browser with 'illegal access' while Carousell keeps serving data. The lesson is to read how a page delivers data before you reach for Playwright." +pubDate: 2026-01-21 +updatedDate: 2026-09-10 +category: case-studies +tags: [scraping, playwright, browserless, docker, goofish, carousell, anti-bot] +ogImage: /og/why-your-headless-browser-cant-scrape-everything.jpg +banner: /banners/why-your-headless-browser-cant-scrape-everything.jpg +--- + +I hit the same wall a few weeks apart, on two different second-hand marketplaces, +and the contrast taught me more about scraping than a year of tutorials did. + +On **Carousell**, a headless browser pulled freshly-listed products into a database +with prices, conditions, and photo URLs — no problem, running for months. On +**Goofish (闲鱼)**, the exact same setup returned the same three words every time: + +``` +为了保障您的体验,请使用正常浏览器访问闲鱼~ +(translation: "to protect your experience, please use a normal browser") +``` + +Two marketplaces, one browser, opposite outcomes. This is the story of *why*, and +the one decision that would have saved me most of a day if I'd made it first. + +## The goal, badly framed + +I wanted to search Goofish for products and pull the results out — titles, prices, +links. The obvious first answer is "use a headless browser." It's the answer +everyone gives: render the page with Playwright or Puppeteer, wait for the +JavaScript, read the DOM. + +So I built exactly that. I deployed a self-hosted `browserless` container — a +ready-made headless-Chrome-as-an-API — and pointed it at Goofish search. Then I +spent hours trying every lever the tool exposes: + +- `--disable-blink-features=AutomationControlled` to hide the automation flag +- patching `navigator.webdriver` to `undefined` via `evaluateOnNewDocument` +- a realistic Chrome user-agent +- a `stealth` launch option + +Each time, the product list stayed stuck at "loading…" — the page frame rendered, +the header rendered, the footer rendered, but the actual listings never populated. +The HTML came back with zero prices and zero product links. + +Meanwhile I had a memory nagging me: my Carousell monitor grabs the same kind of +data with *no* stealth, *no* anti-detection tricks, and it works flawlessly. Why? + +## The frame change: read how the data gets to the page + +The answer wasn't in the browser flags. It was in **how each site delivers its data**. + +**Carousell embeds its search results in the page's initial payload.** The server +sends the listings as JSON baked into the HTML (a `__PRELOADED_STATE__`-style blob). +The JavaScript enhances them afterward, but a plain HTTP request already contains +everything — product ID, title, price, photos. A scraper can parse that JSON out of +the raw HTML without ever rendering anything. + +**Goofish delivers nothing server-side.** Its search results are fetched *after* +the page loads, from a signed API endpoint. That endpoint requires: + +- a signature the app computes from request parameters and a secret +- anti-bot checks that flag a headless browser's fingerprint (WebDriver detection, + CDP artifacts, canvas/audio fingerprints) + +Strip the signature away, and the API returns nothing. Strip the browser off, and +the page returns "illegal access." + +That single distinction — **server-rendered JSON vs. a signed async API** — is the +whole game. It tells you in one minute whether "render the page in a browser" will +take you an afternoon or a month. + +## Why the headless browser hit a wall on Goofish + +The honest reality: **an open-source, self-hosted browser is not a stealth tool.** + +`browserless` (the container I deployed) is a *generic* renderer. Its free tier +renders pages, executes JavaScript, takes screenshots, converts PDFs. The +anti-detection features — real `stealth` mode, captcha solving, unblocked proxies — +are explicitly the **paid Cloud/Enterprise** tier. The message in their docs is +unambiguous. + +So I was asking a general-purpose renderer to beat a platform whose entire +business depends on defeating automated clients. That's not a configuration bug; +it's a mismatch between the tool and the adversary. + +To actually scrape Goofish I'd have to choose one of a few much heavier paths: + +1. **Reverse the app's signing algorithm** and call the search API directly. This + is how the serious scrapers do it — but the signature changes, so it's an + ongoing maintenance cost, not a one-time build. +2. **Buy a third-party data API** (Alibaba Cloud's marketplace and similar sell + `item_search` endpoints). Clean data, but you pay per call and you're at the + mercy of the vendor. +3. **Drive a real, logged-in browser at low frequency** and hope the rate stays + under the radar. Works for occasional manual checks, useless for reliable + monitoring at scale. + +None of these are "spin up a container and go." That's the point. + +## The reusable checklist + +Before I touch Playwright again on a new target, I now answer three questions in +order: + +1. **Where does the data actually live?** Open the page with JavaScript disabled + (or read the raw HTML). If the data is in the HTML, I don't need a browser at + all — I need an HTTP client and a parser. The browser is waste. +2. **If it's not in the HTML, what does the async request look like?** Open + DevTools → Network, find the XHR/fetch that returns the data, and read its + headers and query params. Is there a signature? Is it stable, or does it change + per request? +3. **Is there a signed API?** If yes, stop. Decide up front whether the target is + worth either (a) reverse-engineering the sign, or (b) paying a data vendor. If + neither, the honest answer is "this costs more than it's worth" — and *knowing* + that in the first hour is itself a win. + +Most scraping guides skip all three and jump straight to "install Puppeteer." +That's the expensive path, the one that burns a day discovering the hard way what +a 60-second investigation would have told you. + +## What I'd do differently + +- **Disable JavaScript before reaching for a browser.** It's the single fastest + signal for "server-rendered vs. client-fetched." Ten seconds, no setup. +- **Don't buy stealth from a generic renderer.** Stealth is either a product you + pay for (browserless Cloud, ScrapingBee, etc.) or something you build and + maintain. It is not a checkbox in an open-source container. +- **Distinguish "rendering" from "extraction."** A headless browser returns HTML; + it does not hand you structured product fields. Even when rendering works, + someone still has to write the selectors or the `evaluate` script. That's a + separate task — plan for it, don't assume it's free. + +## The result + +The contrast sharpened into a rule I now apply to every project: **decide whether +the target is server-rendered or API-gated before choosing a tool.** On Carousell +(server-rendered), a plain HTTP parser has archived thousands of listings with +prices and photos for months. On Goofish (signed API), I stopped after an honest +look at the effort and the maintenance burden — which is itself the right +outcome for a project where a manual search once in a while does everything I +needed. + +A headless browser is the right tool *sometimes* — for screenshots, PDFs, taking +over a human session, or targets that render server-side. It is not a universal +scraping key, and treating it as one is the expensive mistake. + +--- + +## Want me to look at a site before you commit to scraping it? + +Most scraping projects fail in the first hour because nobody checked whether the +target even *lets* data be scraped cheaply. I do that check before writing any +code — and I build the monitors, notification pipelines, and self-hosted +infrastructure that actually run. If you're eyeing a data source and want to know +if it's an afternoon or a month, let's talk: + +- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) +- 📧 **Email:** [me@hoelee.com](mailto:me@hoelee.com) +- 🌐 **Website:** [hoelee.com](https://hoelee.com) +```` + +--- + +## 文章 05 / 11:一个字符让我的 authentik CSS 静默失效 +- **Slug**: `authentik-css-greater-than-bug` +- **英文标题**: The Character That Silently Broke My authentik CSS +- **分类**: `devops` | **发布日期**: 2026-09-20 +- **线上地址**: https://blog.hoelee.com/posts/authentik-css-greater-than-bug/ (EN) | https://blog.hoelee.com/posts/zh/authentik-css-greater-than-bug/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "一个字符让我的 authentik CSS 静默失效" +description: "我的 authentik 自定义 CSS 看起来完全正确,也匹配到了目标元素,却毫无效果。原因是一个 > 字符被 authentik 转义成了无效文本。" +pubDate: 2026-09-20 +updatedDate: 2026-10-16 +category: devops +tags: ["authentik", "css", "self-hosting", "debugging", "browser"] +ogImage: /og/zh-authentik-css-greater-than-bug.jpg +banner: /banners/authentik-css-greater-than-bug.jpg +draft: false +--- + +我花了一个下午去查一条本该三十秒解决的 CSS 规则。 + +我想隐藏 authentik 登录页脚里的一行——硬编码的「Powered by +authentik」署名。这条规则在我做过的其他项目里从来都是有效的: + +```css +ul.pf-c-list > li:last-child { + display: none !important; +} +``` + +它毫无作用。不是「看起来有点不对」——那个元素依然完整地显示着。下面 +是我追过的四个错误答案、唯一正确的答案,以及我本该第一个就做的调试 +动作。 + +## 为什么这不只是一行页脚的问题 + +如果你自托管 authentik,曾经把 CSS 粘进 +**System → Brands → Custom CSS** 却完全没效果,那你多半以为自己哪里 +写错了。你几乎肯定没写错。样式表被接收、被存储、被送到浏览器、也被 +解析了——然后静默失效,你够得着的任何日志里都没有报错。 + +这是最糟糕的一类 bug:没有反馈回路。这篇文章把这条回路还给你。 + +## 错误答案 #1:这是 shadow DOM,CSS 进不去 + +我的第一个假设。现代 Web Component 常常把标记藏在 shadow root 后面, +普通的文档 CSS 无法跨越那道边界。authentik 的登录页确实由 +Web Component 渲染——我在页面源码里见过 `` 和 +``——所以这看起来显然是答案。 + +我从打包好的 bundle 里把组件定义读出来确认: + +```js +var oe = class extends L { + createRenderRoot() { return this } + render() { ... } +} +``` + +`createRenderRoot(){ return this }` 意味着**没有 shadow root**——组件 +直接渲染进 light DOM。普通 CSS 完全可以作用于它。 + +错误答案。继续。 + +## 错误答案 #2:CSS 根本没被注入 + +下一个理论:我的 CSS 压根没进到页面里。我在返回的 HTML 里搜了一个 +自己规则里的特征类名: + +``` + + +
+ +
+ + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +## 核心痛点:240 秒的时长硬墙 + +在初期的实验中,使用默认配置调用 ComfyUI 生成音频时,经常陷入两个误区: + +1. **尾奏被腰斩(硬截断)**:若将 `max_duration` 设为 `240.0s` 并填满长歌词,模型在唱到最后一段副歌时时间刚好用尽。此前跑出的 12 首样本无一例外精确停在 `240.000s`,尾部毫无渐隐,沦为残次品。 +2. **语速失控(慌乱压缩)**:如果试图在有限的窗口内通过提示词催促模型“唱快点”,自回归声学模型会自动压缩发音时长,导致原本舒缓的民谣变成含混不清的“急口令”。 + +根本原因在于:**不能把最大时长(Max Duration)当成目标歌曲时长来设置。在自回归音频生成中,最大时长必须作为声学安全余量缓冲槽(Headroom Buffer)。** + +## 解决架构:三项核心工程策略 + +### 1. 295.0 秒 Headroom 留白公式 + +要得到一首体感约为 4 分钟的完整长歌,必须将**歌词词量规划在 210秒~260秒区间**,而底层生成参数直接赋予 **295.0 秒**的宽松上限: + +$$\text{Headroom 留白余量} = \text{参数上限 (295s)} - \text{预估词量时长 (约 220s)} \approx 75\text{秒}$$ + +这 75 秒看似多余的余量,是自回归声学模型实现“体面收尾”的命脉所在: +- 保证最后一节副歌完整舒展唱完,不产生急促抢拍; +- 赋予模型充足的 Token 生成纯乐器 Outro 尾奏; +- 让尾音伴随混响自然衰减至真正的分贝零点。 + +### 2. 自动化硬切隔离门禁(Quarantine Gate) + +在批量跑批自动化脚本中,加入严格的质量门禁:凡是实际音频长度逼近上限(`duration >= 294.9s`)的文件,判定为发生硬截断,自动归档进 `未自然结尾/` 隔离目录;仅有在上限之前自然休止收音(`duration < 294.0s`)的成品才予以放行。 + +本次实跑的 60 首曲目最终时长均匀分布在 **129.9s 至 285.6s**,100% 实现了自然淡出。 + +### 3. 12GB 显存的高效自持 + +在 12GB 显存下运行 YuE2-3B BF16,必须严格执行显存释放策略: + +```bash +# 启动 ComfyUI(关键参数防 OOM) +cd /d/app/ComfyUI +./python_embeded/python.exe -s ComfyUI/main.py \ + --windows-standalone-build \ + --port 8188 \ + --disable-pinned-memory \ + --disable-nvml-pressure \ + --cache-classic +``` + +在批处理完成后及时调用 `/free` 卸载显存,避免连续生成多首长音频时产生显存碎片积压。 + +--- + +## 60 首长歌全集在线鉴赏 + +以下 60 首作品均已上传至 Publitio 媒体存储,支持 HTTP 206 自由拖拽定位。封面全量采用 1024×1024 独立生成的无字 WebP 艺术插画,并内嵌同步 LRC 歌词。 + +*操作提示:可直接利用上方导航面板进行分类筛选,或从下拉菜单中快速直达指定曲目。* + +
+
+ The Thimble Under the Moss cover art +
+
+
+ 01. The Thimble Under the Moss + MP3 + 3分11秒 (191.1s) · 凯尔特竖琴民谣 · 清脆稚气女童声(类似 Cécile Corbel《Arrietty's Song》)|中速轻快(约 105 BPM) +
+ + +
+
+ +
+
+ Mist of Skellig Michael cover art +
+
+
+ 02. Mist of Skellig Michael + MP3 + 4分26秒 (266.2s) · 凯尔特竖琴民谣 · 空灵圣洁的小女孩独唱(类似 Enya + 幼态音色)|慢速悠长(约 72 BPM,字少音长) +
+ + +
+
+ +
+
+ The Rose of Connemara cover art +
+
+
+ 03. The Rose of Connemara + MP3 + 4分43秒 (283.1s) · 凯尔特竖琴民谣 · 纯净稚气少女音(类似 Cara Dillon 早期清亮少女感)|中慢速叙事(约 82 BPM) +
+ + +
+
+ +
+
+ Lanterns on the Moor cover art +
+
+
+ 04. Lanterns on the Moor + MP3 + 3分31秒 (211.2s) · 凯尔特竖琴民谣 · 俏皮精灵般的女童声(类似 Cécile Corbel 欢快舞曲风)|快速三拍子(约 128 BPM) +
+ + +
+
+ +
+
+ The Weaver of Raglan Lane cover art +
+
+
+ 05. The Weaver of Raglan Lane + MP3 + 4分39秒 (279.3s) · 凯尔特竖琴民谣 · 醇厚温暖的凯尔特民谣男声(类似 Luke Kelly,调剂男声)|中慢速(约 80 BPM) +
+ + +
+
+ +
+
+ 硝子の花束 cover art +
+
+
+ 06. 硝子の花束 + MP3 + 3分49秒 (230.0s) · 日系治愈动漫 · 稚气通透、略带脆弱感的动漫少女音(祈妹风)|中慢速钢琴弦乐(约 78 BPM) +
+ + +
+
+ +
+
+ 銀の時計塔と宵の歌 cover art +
+
+
+ 07. 銀の時計塔と宵の歌 + MP3 + 4分34秒 (274.8s) · 日系治愈动漫 · 清亮圣洁少女音 + 梶浦语吟唱|中速华尔兹三拍子(约 110 BPM) +
+ + +
+
+ +
+
+ 森の郵便屋さん cover art +
+
+
+ 08. 森の郵便屋さん + MP3 + 3分44秒 (225.0s) · 日系治愈动漫 · 极度轻柔、如耳语般的稚气女童声|慢速木吉他与竖琴(约 68 BPM) +
+ + +
+
+ +
+
+ 白鳥の祈り cover art +
+
+
+ 09. 白鳥の祈り + MP3 + 4分06秒 (246.3s) · 日系治愈动漫 · 空灵通透的高音少女/女童声,尾音带空灵共鸣|慢速大气(约 70 BPM) +
+ + +
+
+ +
+
+ 星屑のオルゴール cover art +
+
+
+ 10. 星屑のオルゴール + MP3 + 3分31秒 (211.8s) · 日系治愈动漫 · 清甜稚气的少女声|中慢速轻盈感(约 88 BPM) +
+ + +
+
+ +
+
+ ひまわり行進曲 cover art +
+
+
+ 11. ひまわり行進曲 + MP3 + 3分18秒 (198.0s) · 日系元气童趣 · 元气满满的小女孩独唱/齐唱感|中快速欢快节奏(约 118 BPM) +
+ + +
+
+ +
+
+ プリンは世界を救うのだ! cover art +
+
+
+ 12. プリンは世界を救うのだ! + MP3 + 3分23秒 (203.4s) · 日系元气童趣 · 软萌夸张的稚气萝莉音(高音、语速快、字密)|快速电音流行(约 145 BPM) +
+ + +
+
+ +
+
+ こねこ探偵団のテーマ cover art +
+
+
+ 13. こねこ探偵団のテーマ + MP3 + 2分16秒 (136.1s) · 日系元气童趣 · 奶声奶气的女童声(6~8岁童音感)|中速轻快爵士童谣(约 115 BPM) +
+ + +
+
+ +
+
+ 夏祭りのラムネ星 cover art +
+
+
+ 14. 夏祭りのラムネ星 + MP3 + 3分14秒 (195.0s) · 日系元气童趣 · 清亮少年男声 + 稚气少女声(调剂少年音)|中快速夏日流行(约 122 BPM) +
+ + +
+
+ +
+
+ ほうきに乗って宅配便 cover art +
+
+
+ 15. ほうきに乗って宅配便 + MP3 + 3分16秒 (196.1s) · 日系元气童趣 · 清脆阳光的小女孩声音|中速轻快复古流行(约 116 BPM) +
+ + +
+
+ +
+
+ 白露·竹间灯 cover art +
+
+
+ 16. 白露·竹间灯 + MP3 + 3分42秒 (222.6s) · 中文国风古意 · 清甜稚气的少女/女童声|中速轻快电子国风(约 108 BPM) +
+ + +
+
+ +
+
+ 小鲤跃龙门 cover art +
+
+
+ 17. 小鲤跃龙门 + MP3 + 2分09秒 (129.9s) · 中文国风古意 · 活泼软萌的稚气少女音(语速偏快、吐字灵动)|中快速(约 120 BPM) +
+ + +
+
+ +
+
+ 云海归鲲 cover art +
+
+
+ 18. 云海归鲲 + MP3 + 3分56秒 (236.9s) · 中文国风古意 · 空灵仙气的少年假声/美声男声(调剂男声)|慢速悠远(约 68 BPM) +
+ + +
+
+ +
+
+ 纸伞巷 cover art +
+
+
+ 19. 纸伞巷 + MP3 + 3分34秒 (214.6s) · 中文国风古意 · 清亮温婉的少女声(带一点点江南小调韵味)|中慢速(约 80 BPM) +
+ + +
+
+ +
+
+ 山间小道士 cover art +
+
+
+ 20. 山间小道士 + MP3 + 3分07秒 (187.6s) · 中文国风古意 · 纯真稚气的小女孩/少女声音|中速轻快民谣古风(约 105 BPM) +
+ + +
+
+ +
+
+ 橘子汽水的夏天 cover art +
+
+
+ 21. 橘子汽水的夏天 + MP3 + 2分48秒 (169.0s) · 温暖民谣治愈 · 清澈稚气的少女声|中慢速木吉他民谣(约 82 BPM) +
+ + +
+
+ +
+
+ 外婆的小院 cover art +
+
+
+ 22. 外婆的小院 + MP3 + 3分32秒 (212.4s) · 温暖民谣治愈 · 纯真软糯的小女孩声音|慢速温情(约 72 BPM) +
+ + +
+
+ +
+
+ 开往北方的绿皮车 cover art +
+
+
+ 23. 开往北方的绿皮车 + MP3 + 3分36秒 (216.3s) · 温暖民谣治愈 · 低沉温暖的民谣男声(调剂男声)|慢速叙事(约 75 BPM) +
+ + +
+
+ +
+
+ 写给流浪猫的信 cover art +
+
+
+ 24. 写给流浪猫的信 + MP3 + 2分09秒 (129.9s) · 温暖民谣治愈 · 俏皮温柔的稚气少女音|中速轻快尤克里里/木吉他(约 98 BPM) +
+ + +
+
+ +
+
+ 平凡的傍晚 cover art +
+
+
+ 25. 平凡的傍晚 + MP3 + 3分41秒 (221.0s) · 温暖民谣治愈 · 温柔治愈的青年男声(调剂男声)|慢速钢琴与木吉他(约 74 BPM) +
+ + +
+
+ +
+
+ Light of the Eventide cover art +
+
+
+ 26. Light of the Eventide + MP3 + 4分09秒 (249.0s) · 空灵天籁合唱 · 天籁女童高音领唱 + 纯净童声合唱|极慢速庄严(约 66 BPM) +
+ + +
+
+ +
+
+ Bells of the Winter Star cover art +
+
+
+ 27. Bells of the Winter Star + MP3 + 2分47秒 (167.6s) · 空灵天籁合唱 · 清脆女童多声部齐唱與轮唱|中快速灵动节奏(约 125 BPM) +
+ + +
+
+ +
+
+ Paper Planes in the Wind cover art +
+
+
+ 28. Paper Planes in the Wind + MP3 + 3分25秒 (205.5s) · 空灵天籁合唱 · 清亮纯真的女童领唱 + 学院风童声合唱|中速温暖(约 96 BPM) +
+ + +
+
+ +
+
+ Lullaby of the Little Star cover art +
+
+
+ 29. Lullaby of the Little Star + MP3 + 3分47秒 (227.8s) · 空灵天籁合唱 · 稚气纯净的小女孩独唱(突出童真独唱感)|慢速温柔(约 70 BPM) +
+ + +
+
+ +
+
+ Hymn of the Morning Meadow cover art +
+
+
+ 30. Hymn of the Morning Meadow + MP3 + 4分19秒 (259.9s) · 空灵天籁合唱 · 神圣清澈的女童领唱 + 宏大童声和声(帕赫贝尔卡农式递进)|慢速治愈(约 72 BPM) +
+ + +
+
+ +
+
+ 地板下的借物小人 cover art +
+
+
+ 31. 地板下的借物小人 + MP3 + 3分07秒 (187.7s) · 凯尔特童话民谣 (中文) · 清脆稚气女童声|中速轻快凯尔特竖琴与锡哨(约 105 BPM) +
+ + +
+
+ +
+
+ 迷雾孤岛的竖琴 cover art +
+
+
+ 32. 迷雾孤岛的竖琴 + MP3 + 3分24秒 (204.4s) · 凯尔特童话民谣 (中文) · 空灵圣洁的小女孩独唱|慢速悠长(约 70 BPM,字少音长) +
+ + +
+
+ +
+
+ 石楠花开的高地 cover art +
+
+
+ 33. 石楠花开的高地 + MP3 + 3分36秒 (216.9s) · 凯尔特童话民谣 (中文) · 纯净稚气少女音|中慢速木吉他与凯尔特小提琴(约 82 BPM) +
+ + +
+
+ +
+
+ 橡树下的萤火舞会 cover art +
+
+
+ 34. 橡树下的萤火舞会 + MP3 + 3分43秒 (223.1s) · 凯尔特童话民谣 (中文) · 俏皮精灵般的女童声(语速快、节奏感强)|快速三拍子(约 128 BPM) +
+ + +
+
+ +
+
+ 老航海家的绿宝石 cover art +
+
+
+ 35. 老航海家的绿宝石 + MP3 + 3分49秒 (229.8s) · 凯尔特童话民谣 (中文) · 醇厚温暖的凯尔特民谣男声(调剂男声)|中慢速(约 80 BPM) +
+ + +
+
+ +
+
+ 玻璃温室的告别 cover art +
+
+
+ 36. 玻璃温室的告别 + MP3 + 3分37秒 (217.0s) · 日系治愈物语 (中文) · 稚气通透、略带脆弱感的动漫少女音|中慢速钢琴与弦乐(约 78 BPM) +
+ + +
+
+ +
+
+ 银色钟塔与暮光咏叹 cover art +
+
+
+ 37. 银色钟塔与暮光咏叹 + MP3 + 4分01秒 (241.4s) · 日系治愈物语 (中文) · 清亮圣洁少女音 + 造语吟唱|中速古典哥特华尔兹(约 110 BPM) +
+ + +
+
+ +
+
+ 森林邮差的蒲公英 cover art +
+
+
+ 38. 森林邮差的蒲公英 + MP3 + 3分26秒 (206.7s) · 日系治愈物语 (中文) · 极度轻柔、如耳语般的稚气女童声|慢速木吉他与钢片琴(约 68 BPM) +
+ + +
+
+ +
+
+ 白鸟与雪原的祈愿 cover art +
+
+
+ 39. 白鸟与雪原的祈愿 + MP3 + 3分39秒 (220.0s) · 日系治愈物语 (中文) · 空灵通透的高音女童/少女声|慢速大气钢琴与管弦(约 70 BPM) +
+ + +
+
+ +
+
+ 发条星尘八音盒 cover art +
+
+
+ 40. 发条星尘八音盒 + MP3 + 3分18秒 (198.2s) · 日系治愈物语 (中文) · 清甜稚气的少女声|中慢速八音盒与木吉他(约 88 BPM) +
+ + +
+
+ +
+
+ 向日葵小队出发 cover art +
+
+
+ 41. 向日葵小队出发 + MP3 + 2分39秒 (159.4s) · 元气童趣流行 (中文) · 元气满满的小女孩领唱 + 童声合唱感|中快速欢快节奏(约 118 BPM) +
+ + +
+
+ +
+
+ 焦糖布丁保卫战! cover art +
+
+
+ 42. 焦糖布丁保卫战! + MP3 + 3分38秒 (218.9s) · 元气童趣流行 (中文) · 软萌夸张的稚气萝莉音(高音、语速快、字密)|快速电音流行(约 145 BPM) +
+ + +
+
+ +
+
+ 猫咪侦探事务所 cover art +
+
+
+ 43. 猫咪侦探事务所 + MP3 + 2分31秒 (151.4s) · 元气童趣流行 (中文) · 奶声奶气的女童声(6~8岁童音感)|中速轻快木琴与铜管(约 115 BPM) +
+ + +
+
+ +
+
+ 夏夜烟火与波子汽水 cover art +
+
+
+ 44. 夏夜烟火与波子汽水 + MP3 + 3分07秒 (187.8s) · 元气童趣流行 (中文) · 清亮少年男声 + 稚气少女声(调剂少年音)|中快速夏日流行(约 122 BPM) +
+ + +
+
+ +
+
+ 骑上扫帚送快递 cover art +
+
+
+ 45. 骑上扫帚送快递 + MP3 + 3分08秒 (188.5s) · 元气童趣流行 (中文) · 清脆阳光的小女孩声音|中速轻快木吉他与手鼓(约 116 BPM) +
+ + +
+
+ +
+
+ 小满·青梅煮雨 cover art +
+
+
+ 46. 小满·青梅煮雨 + MP3 + 3分34秒 (214.9s) · 国风新篇古韵 (中文) · 清甜稚气的少女/女童声|中速轻快电子国风(约 106 BPM) +
+ + +
+
+ +
+
+ 糖画摊前的小狐仙 cover art +
+
+
+ 47. 糖画摊前的小狐仙 + MP3 + 3分12秒 (192.2s) · 国风新篇古韵 (中文) · 活泼软萌的稚气少女音(字密语速快)|中快速(约 124 BPM) +
+ + +
+
+ +
+
+ 鲛人泪·沧海月 cover art +
+
+
+ 48. 鲛人泪·沧海月 + MP3 + 3分59秒 (239.8s) · 国风新篇古韵 (中文) · 空灵仙气的少年假声/美声男声(调剂男声)|慢速深邃(约 68 BPM) +
+ + +
+
+ +
+
+ 兰亭旧墨 cover art +
+
+
+ 49. 兰亭旧墨 + MP3 + 3分49秒 (229.3s) · 国风新篇古韵 (中文) · 清亮温婉的少女声|中慢速古筝与二胡(约 80 BPM) +
+ + +
+
+ +
+
+ 采药小童下山记 cover art +
+
+
+ 50. 采药小童下山记 + MP3 + 3分24秒 (204.4s) · 国风新篇古韵 (中文) · 纯真稚气的小女孩声音|中速轻快木吉他与竹笛(约 104 BPM) +
+ + +
+
+ +
+
+ 晒太阳的旧毛衣 cover art +
+
+
+ 51. 晒太阳的旧毛衣 + MP3 + 3分04秒 (184.9s) · 温暖民谣新篇 (中文) · 清澈稚气的少女声|中慢速木吉他民谣(约 80 BPM) +
+ + +
+
+ +
+
+ 奶奶的桂花糕 cover art +
+
+
+ 52. 奶奶的桂花糕 + MP3 + 3分42秒 (222.4s) · 温暖民谣新篇 (中文) · 纯真软糯的小女孩声音|慢速温情口琴与吉他(约 72 BPM) +
+ + +
+
+ +
+
+ 北方的末班渡轮 cover art +
+
+
+ 53. 北方的末班渡轮 + MP3 + 3分49秒 (229.6s) · 温暖民谣新篇 (中文) · 低沉温暖的民谣男声(调剂男声)|慢速深沉(约 74 BPM) +
+ + +
+
+ +
+
+ 云朵收集指南 cover art +
+
+
+ 54. 云朵收集指南 + MP3 + 2分56秒 (176.4s) · 温暖民谣新篇 (中文) · 俏皮温柔的稚气少女音|中速轻快(约 98 BPM) +
+ + +
+
+ +
+
+ 街角二十四小时书店 cover art +
+
+
+ 55. 街角二十四小时书店 + MP3 + 4分13秒 (253.4s) · 温暖民谣新篇 (中文) · 温柔治愈的青年男声(调剂男声)|慢速钢琴与木吉他(约 74 BPM) +
+ + +
+
+ +
+
+ 暮光归途 cover art +
+
+
+ 56. 暮光归途 + MP3 + 4分02秒 (242.6s) · 天籁童声圣咏 (中文) · 天籁女童高音领唱 + 纯净童声合唱|极慢速庄严管风琴与弦乐(约 66 BPM) +
+ + +
+
+ +
+
+ 冬夜银铃 cover art +
+
+
+ 57. 冬夜银铃 + MP3 + 2分59秒 (179.0s) · 天籁童声圣咏 (中文) · 清脆女童多声部齐唱与轮唱|中快速灵动三拍子(约 125 BPM) +
+ + +
+
+ +
+
+ 迎风飞翔的纸飞机 cover art +
+
+
+ 58. 迎风飞翔的纸飞机 + MP3 + 3分53秒 (233.2s) · 天籁童声圣咏 (中文) · 清亮纯真的女童领唱 + 学院风童声合唱|中速温暖(约 96 BPM) +
+ + +
+
+ +
+
+ 小星星的晚安曲 cover art +
+
+
+ 59. 小星星的晚安曲 + MP3 + 3分51秒 (232.0s) · 天籁童声圣咏 (中文) · 稚气纯净的小女孩独唱|慢速竖琴与小提琴(约 70 BPM) +
+ + +
+
+ +
+
+ 晨曦草地的赞美诗 cover art +
+
+
+ 60. 晨曦草地的赞美诗 + MP3 + 4分26秒 (266.0s) · 天籁童声圣咏 (中文) · 神圣清澈的女童领唱 + 宏大童声和声(卡农式递进)|慢速治愈(约 72 BPM) +
+ + +
+
+ + + +--- + +## 需要为你的业务构建自动化 AI 媒体流水线? + +我为马来西亚及海外中小企业提供定制化自托管 AI 系统部署、自动化音视频内容生产管线以及全栈网站开发服务。 + +- **WhatsApp**: [012-7972969](https://wa.me/60127972969) +- **Email**: [me@hoelee.com](mailto:me@hoelee.com?subject=AI%20Audio%20Pipeline%20Inquiry) +- **个人网站**: [hoelee.com](https://hoelee.com) +```` + +--- + diff --git a/docs/consultant-review/batches/batch-09.md b/docs/consultant-review/batches/batch-09.md new file mode 100644 index 0000000..1e061cb --- /dev/null +++ b/docs/consultant-review/batches/batch-09.md @@ -0,0 +1,3352 @@ +# 博客文章顾问审阅资料包 — Batch-09 + +> **批次编号**:`Batch-09` +> **包含文章数**:1 篇 +> **本批次总字符量**:131,667 字(严格限制 < 200,000 字) +> **模式**:BILINGUAL +> **生成时间**:2026-10-27 03:02:03 + +# 外部顾问审阅前置须知与工程背景规范 + +> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。 + +## 1. 站点定位与双重业务目的 +- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。 +- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。 + +## 2. 核心人设铁律与工程硬约束(必须严格遵守) +1. **彻底杜绝业余爱好者/玩具口吻**: + - ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。 + - ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。 +2. **严禁捏造虚假指标与性能数字**: + - 所有 benchmark 数据必须有明确的测试基准与单设备限制。 + - 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。 + - 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。 +3. **去模板化叙事与真实物理锚点**: + - 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。 + - 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。 +4. **代码与配置展示规范**: + - 超过 20 行的配置或日志采用 HTML `
` 原生折叠,`` 必须精准标注文档名与行数。 + +## 3. 顾问重点开刀维度 +- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大? +- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻? +- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)? +- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置? + + +--- + +## 本批次文章目录索引 + +1. [tutorials] **用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断** (`generating-full-length-songs-with-yue2-comfyui-en`) — ~131,667 字符 + +--- + +## 文章 01 / 01:用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断 +- **Slug**: `generating-full-length-songs-with-yue2-comfyui` +- **英文标题**: Generating Full-Length Songs with YuE2 BF16: Overcoming Duration Limits and Audio Truncation +- **分类**: `tutorials` | **发布日期**: 2026-10-26 +- **线上地址**: https://blog.hoelee.com/posts/generating-full-length-songs-with-yue2-comfyui/ (EN) | https://blog.hoelee.com/posts/zh/generating-full-length-songs-with-yue2-comfyui/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇬🇧 英文版原文 (English Markdown Primary) +````markdown +--- +title: "Generating Full-Length Songs with YuE2 BF16: Overcoming Duration Limits and Audio Truncation" +description: "AI music tools cut off complex tracks mid-sentence. Here is how I solved the 240s duration wall using YuE2 BF16, 295s headroom budgeting, and a 60-song pipeline." +pubDate: 2026-10-26 +category: tutorials +blueprint: ai-agent-automation +blueprintLayer: L4-aigc-media-pipelines +stream: creative-lab +tags: ["ai-music", "yue2", "comfyui", "audio-engineering", "subtitles", "python"] +ogImage: "/og/generating-full-length-songs-with-yue2-comfyui.jpg" +banner: "/banners/generating-full-length-songs-with-yue2-comfyui.jpg" +draft: false +--- + +Every generative music engine I have benchmarked suffers from the same fatal flaw: +**the sudden, unceremonious cutoff**. + +Whether you use cloud APIs or local weights, models trained on fixed acoustic token +windows struggle the moment you give them a complete, narrative song structure. A +9-part progression—`[Intro] -> [Verse 1] -> [Chorus] -> [Interlude] -> [Verse 2] -> [Chorus] -> [Bridge] -> [Chorus] -> [Outro]`—inevitably +collides with the hard ceiling of the generation buffer. The vocalist is mid-word in +the climax, and the audio abruptly flatlines into digital silence. + +After iterating across hundreds of takes on an RTX 3060 12GB rig, I built an end-to-end +pipeline that generates genuine 4-minute full-length songs with natural melodic resolution +and zero mid-phrase amputations. + +Below is the breakdown of why standard generation fails, the 295-second headroom formula +that fixes it, and the full collection of **60 completed full-length songs** (with +individual 1024×1024 cover art and synchronized scrolling lyrics). + +--- + + + +
+ +
+ + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +## The Problem: The 240-Second Wall + +In earlier batches, songs generated via standard ComfyUI text-to-music nodes frequently +exhibited one of two failure modes: + +1. **The Chopped Outro**: Setting `max_duration = 240.0s` while feeding a full lyric + sheet resulted in the track hitting the final timestamp while the acoustic model was + still executing the chorus. All 12 initial test runs ended at exactly `240.000s`—an + unusable hard cliff. +2. **The Panic Accelerando**: If you prompt the model to fit too many lines into a tight + window, the auto-regressive acoustic token predictor compresses the vocal pacing, + turning an emotional Celtic ballad into an unnatural rap cadence. + +The fundamental mistake was treating `max_duration` as the *desired song length*. +In generative auto-regressive audio, **max duration is not your target length; it is your +acoustic safety wall.** + +## The Three Architecture Fixes + +### 1. The 295.0s Headroom Formula + +To produce songs that naturally conclude with a 4-minute runtime, you must structure +the lyric density for **210s to 260s**, but provision the generator with **295.0s** +of ceiling: + +$$\text{Headroom Buffer} = \text{Max Duration (295s)} - \text{Expected Lyric Duration (220s)} \approx 75\text{s}$$ + +This ~75-second buffer provides the acoustic model with the token budget required to: +- Complete the final verse without panic compression; +- Introduce a dedicated, melodic instrumental outro; +- Execute natural acoustic decay, room reverb dissipation, and final silence. + +### 2. Automated Hard-Cut Quarantine Gate + +Any take whose generated length measures within 0.1s of the hard ceiling (`>= 294.9s`) +is treated as an unresolved truncation failure and automatically quarantined. Only takes +that resolve naturally before the ceiling (`duration < 294.0s`) are accepted into the +production archive. Across our 60-song run, all 60 finalized masters ended between +**129.9s and 285.6s** with zero abrupt cutoffs. + +### 3. VRAM Stabilization on a 12GB RTX 3060 + +Running YuE2-3B in BF16 on consumer hardware requires tight memory lifecycle management: + +```bash +# Launch ComfyUI standalone with minimal VRAM overhead +cd /d/app/ComfyUI +./python_embeded/python.exe -s ComfyUI/main.py \ + --windows-standalone-build \ + --port 8188 \ + --disable-pinned-memory \ + --disable-nvml-pressure \ + --cache-classic +``` + +By enforcing immediate model unloading via `/free` after batch execution and maintaining +zero pinned memory overhead, generation of 4.5-minute sequences remains rock-solid without +triggering CUDA out-of-memory errors. + +--- + +## The 60 Full-Length Song Archive + +All 60 tracks below were generated locally with YuE2 BF16, tagged with clean ID3v2.3 +metadata, paired with custom 1024×1024 Gemini-rendered full-bleed cover art (converted to +WebP), and hosted on CDN with HTTP 206 range-seeking support. + +*Tip: Use the interactive selector above to jump directly to any track.* + +
+
+ The Thimble Under the Moss cover art +
+
+
+ 01. The Thimble Under the Moss + MP3 + 3分11秒 (191.1s) · Celtic Folk · 清脆稚气女童声(类似 Cécile Corbel《Arrietty's Song》)|中速轻快(约 105 BPM) +
+ + +
+
+ +
+
+ Mist of Skellig Michael cover art +
+
+
+ 02. Mist of Skellig Michael + MP3 + 4分26秒 (266.2s) · Celtic Folk · 空灵圣洁的小女孩独唱(类似 Enya + 幼态音色)|慢速悠长(约 72 BPM,字少音长) +
+ + +
+
+ +
+
+ The Rose of Connemara cover art +
+
+
+ 03. The Rose of Connemara + MP3 + 4分43秒 (283.1s) · Celtic Folk · 纯净稚气少女音(类似 Cara Dillon 早期清亮少女感)|中慢速叙事(约 82 BPM) +
+ + +
+
+ +
+
+ Lanterns on the Moor cover art +
+
+
+ 04. Lanterns on the Moor + MP3 + 3分31秒 (211.2s) · Celtic Folk · 俏皮精灵般的女童声(类似 Cécile Corbel 欢快舞曲风)|快速三拍子(约 128 BPM) +
+ + +
+
+ +
+
+ The Weaver of Raglan Lane cover art +
+
+
+ 05. The Weaver of Raglan Lane + MP3 + 4分39秒 (279.3s) · Celtic Folk · 醇厚温暖的凯尔特民谣男声(类似 Luke Kelly,调剂男声)|中慢速(约 80 BPM) +
+ + +
+
+ +
+
+ 硝子の花束 cover art +
+
+
+ 06. 硝子の花束 + MP3 + 3分49秒 (230.0s) · Japanese Healing · 稚气通透、略带脆弱感的动漫少女音(祈妹风)|中慢速钢琴弦乐(约 78 BPM) +
+ + +
+
+ +
+
+ 銀の時計塔と宵の歌 cover art +
+
+
+ 07. 銀の時計塔と宵の歌 + MP3 + 4分34秒 (274.8s) · Japanese Healing · 清亮圣洁少女音 + 梶浦语吟唱|中速华尔兹三拍子(约 110 BPM) +
+ + +
+
+ +
+
+ 森の郵便屋さん cover art +
+
+
+ 08. 森の郵便屋さん + MP3 + 3分44秒 (225.0s) · Japanese Healing · 极度轻柔、如耳语般的稚气女童声|慢速木吉他与竖琴(约 68 BPM) +
+ + +
+
+ +
+
+ 白鳥の祈り cover art +
+
+
+ 09. 白鳥の祈り + MP3 + 4分06秒 (246.3s) · Japanese Healing · 空灵通透的高音少女/女童声,尾音带空灵共鸣|慢速大气(约 70 BPM) +
+ + +
+
+ +
+
+ 星屑のオルゴール cover art +
+
+
+ 10. 星屑のオルゴール + MP3 + 3分31秒 (211.8s) · Japanese Healing · 清甜稚气的少女声|中慢速轻盈感(约 88 BPM) +
+ + +
+
+ +
+
+ ひまわり行進曲 cover art +
+
+
+ 11. ひまわり行進曲 + MP3 + 3分18秒 (198.0s) · Japanese Energetic · 元气满满的小女孩独唱/齐唱感|中快速欢快节奏(约 118 BPM) +
+ + +
+
+ +
+
+ プリンは世界を救うのだ! cover art +
+
+
+ 12. プリンは世界を救うのだ! + MP3 + 3分23秒 (203.4s) · Japanese Energetic · 软萌夸张的稚气萝莉音(高音、语速快、字密)|快速电音流行(约 145 BPM) +
+ + +
+
+ +
+
+ こねこ探偵団のテーマ cover art +
+
+
+ 13. こねこ探偵団のテーマ + MP3 + 2分16秒 (136.1s) · Japanese Energetic · 奶声奶气的女童声(6~8岁童音感)|中速轻快爵士童谣(约 115 BPM) +
+ + +
+
+ +
+
+ 夏祭りのラムネ星 cover art +
+
+
+ 14. 夏祭りのラムネ星 + MP3 + 3分14秒 (195.0s) · Japanese Energetic · 清亮少年男声 + 稚气少女声(调剂少年音)|中快速夏日流行(约 122 BPM) +
+ + +
+
+ +
+
+ ほうきに乗って宅配便 cover art +
+
+
+ 15. ほうきに乗って宅配便 + MP3 + 3分16秒 (196.1s) · Japanese Energetic · 清脆阳光的小女孩声音|中速轻快复古流行(约 116 BPM) +
+ + +
+
+ +
+
+ 白露·竹间灯 cover art +
+
+
+ 16. 白露·竹间灯 + MP3 + 3分42秒 (222.6s) · Chinese Guofeng · 清甜稚气的少女/女童声|中速轻快电子国风(约 108 BPM) +
+ + +
+
+ +
+
+ 小鲤跃龙门 cover art +
+
+
+ 17. 小鲤跃龙门 + MP3 + 2分09秒 (129.9s) · Chinese Guofeng · 活泼软萌的稚气少女音(语速偏快、吐字灵动)|中快速(约 120 BPM) +
+ + +
+
+ +
+
+ 云海归鲲 cover art +
+
+
+ 18. 云海归鲲 + MP3 + 3分56秒 (236.9s) · Chinese Guofeng · 空灵仙气的少年假声/美声男声(调剂男声)|慢速悠远(约 68 BPM) +
+ + +
+
+ +
+
+ 纸伞巷 cover art +
+
+
+ 19. 纸伞巷 + MP3 + 3分34秒 (214.6s) · Chinese Guofeng · 清亮温婉的少女声(带一点点江南小调韵味)|中慢速(约 80 BPM) +
+ + +
+
+ +
+
+ 山间小道士 cover art +
+
+
+ 20. 山间小道士 + MP3 + 3分07秒 (187.6s) · Chinese Guofeng · 纯真稚气的小女孩/少女声音|中速轻快民谣古风(约 105 BPM) +
+ + +
+
+ +
+
+ 橘子汽水的夏天 cover art +
+
+
+ 21. 橘子汽水的夏天 + MP3 + 2分48秒 (169.0s) · Gentle Acoustic · 清澈稚气的少女声|中慢速木吉他民谣(约 82 BPM) +
+ + +
+
+ +
+
+ 外婆的小院 cover art +
+
+
+ 22. 外婆的小院 + MP3 + 3分32秒 (212.4s) · Gentle Acoustic · 纯真软糯的小女孩声音|慢速温情(约 72 BPM) +
+ + +
+
+ +
+
+ 开往北方的绿皮车 cover art +
+
+
+ 23. 开往北方的绿皮车 + MP3 + 3分36秒 (216.3s) · Gentle Acoustic · 低沉温暖的民谣男声(调剂男声)|慢速叙事(约 75 BPM) +
+ + +
+
+ +
+
+ 写给流浪猫的信 cover art +
+
+
+ 24. 写给流浪猫的信 + MP3 + 2分09秒 (129.9s) · Gentle Acoustic · 俏皮温柔的稚气少女音|中速轻快尤克里里/木吉他(约 98 BPM) +
+ + +
+
+ +
+
+ 平凡的傍晚 cover art +
+
+
+ 25. 平凡的傍晚 + MP3 + 3分41秒 (221.0s) · Gentle Acoustic · 温柔治愈的青年男声(调剂男声)|慢速钢琴与木吉他(约 74 BPM) +
+ + +
+
+ +
+
+ Light of the Eventide cover art +
+
+
+ 26. Light of the Eventide + MP3 + 4分09秒 (249.0s) · Angelic Choir · 天籁女童高音领唱 + 纯净童声合唱|极慢速庄严(约 66 BPM) +
+ + +
+
+ +
+
+ Bells of the Winter Star cover art +
+
+
+ 27. Bells of the Winter Star + MP3 + 2分47秒 (167.6s) · Angelic Choir · 清脆女童多声部齐唱與轮唱|中快速灵动节奏(约 125 BPM) +
+ + +
+
+ +
+
+ Paper Planes in the Wind cover art +
+
+
+ 28. Paper Planes in the Wind + MP3 + 3分25秒 (205.5s) · Angelic Choir · 清亮纯真的女童领唱 + 学院风童声合唱|中速温暖(约 96 BPM) +
+ + +
+
+ +
+
+ Lullaby of the Little Star cover art +
+
+
+ 29. Lullaby of the Little Star + MP3 + 3分47秒 (227.8s) · Angelic Choir · 稚气纯净的小女孩独唱(突出童真独唱感)|慢速温柔(约 70 BPM) +
+ + +
+
+ +
+
+ Hymn of the Morning Meadow cover art +
+
+
+ 30. Hymn of the Morning Meadow + MP3 + 4分19秒 (259.9s) · Angelic Choir · 神圣清澈的女童领唱 + 宏大童声和声(帕赫贝尔卡农式递进)|慢速治愈(约 72 BPM) +
+ + +
+
+ +
+
+ 地板下的借物小人 cover art +
+
+
+ 31. 地板下的借物小人 + MP3 + 3分07秒 (187.7s) · Celtic Folk (ZH) · 清脆稚气女童声|中速轻快凯尔特竖琴与锡哨(约 105 BPM) +
+ + +
+
+ +
+
+ 迷雾孤岛的竖琴 cover art +
+
+
+ 32. 迷雾孤岛的竖琴 + MP3 + 3分24秒 (204.4s) · Celtic Folk (ZH) · 空灵圣洁的小女孩独唱|慢速悠长(约 70 BPM,字少音长) +
+ + +
+
+ +
+
+ 石楠花开的高地 cover art +
+
+
+ 33. 石楠花开的高地 + MP3 + 3分36秒 (216.9s) · Celtic Folk (ZH) · 纯净稚气少女音|中慢速木吉他与凯尔特小提琴(约 82 BPM) +
+ + +
+
+ +
+
+ 橡树下的萤火舞会 cover art +
+
+
+ 34. 橡树下的萤火舞会 + MP3 + 3分43秒 (223.1s) · Celtic Folk (ZH) · 俏皮精灵般的女童声(语速快、节奏感强)|快速三拍子(约 128 BPM) +
+ + +
+
+ +
+
+ 老航海家的绿宝石 cover art +
+
+
+ 35. 老航海家的绿宝石 + MP3 + 3分49秒 (229.8s) · Celtic Folk (ZH) · 醇厚温暖的凯尔特民谣男声(调剂男声)|中慢速(约 80 BPM) +
+ + +
+
+ +
+
+ 玻璃温室的告别 cover art +
+
+
+ 36. 玻璃温室的告别 + MP3 + 3分37秒 (217.0s) · Japanese Healing (ZH) · 稚气通透、略带脆弱感的动漫少女音|中慢速钢琴与弦乐(约 78 BPM) +
+ + +
+
+ +
+
+ 银色钟塔与暮光咏叹 cover art +
+
+
+ 37. 银色钟塔与暮光咏叹 + MP3 + 4分01秒 (241.4s) · Japanese Healing (ZH) · 清亮圣洁少女音 + 造语吟唱|中速古典哥特华尔兹(约 110 BPM) +
+ + +
+
+ +
+
+ 森林邮差的蒲公英 cover art +
+
+
+ 38. 森林邮差的蒲公英 + MP3 + 3分26秒 (206.7s) · Japanese Healing (ZH) · 极度轻柔、如耳语般的稚气女童声|慢速木吉他与钢片琴(约 68 BPM) +
+ + +
+
+ +
+
+ 白鸟与雪原的祈愿 cover art +
+
+
+ 39. 白鸟与雪原的祈愿 + MP3 + 3分39秒 (220.0s) · Japanese Healing (ZH) · 空灵通透的高音女童/少女声|慢速大气钢琴与管弦(约 70 BPM) +
+ + +
+
+ +
+
+ 发条星尘八音盒 cover art +
+
+
+ 40. 发条星尘八音盒 + MP3 + 3分18秒 (198.2s) · Japanese Healing (ZH) · 清甜稚气的少女声|中慢速八音盒与木吉他(约 88 BPM) +
+ + +
+
+ +
+
+ 向日葵小队出发 cover art +
+
+
+ 41. 向日葵小队出发 + MP3 + 2分39秒 (159.4s) · Japanese Energetic (ZH) · 元气满满的小女孩领唱 + 童声合唱感|中快速欢快节奏(约 118 BPM) +
+ + +
+
+ +
+
+ 焦糖布丁保卫战! cover art +
+
+
+ 42. 焦糖布丁保卫战! + MP3 + 3分38秒 (218.9s) · Japanese Energetic (ZH) · 软萌夸张的稚气萝莉音(高音、语速快、字密)|快速电音流行(约 145 BPM) +
+ + +
+
+ +
+
+ 猫咪侦探事务所 cover art +
+
+
+ 43. 猫咪侦探事务所 + MP3 + 2分31秒 (151.4s) · Japanese Energetic (ZH) · 奶声奶气的女童声(6~8岁童音感)|中速轻快木琴与铜管(约 115 BPM) +
+ + +
+
+ +
+
+ 夏夜烟火与波子汽水 cover art +
+
+
+ 44. 夏夜烟火与波子汽水 + MP3 + 3分07秒 (187.8s) · Japanese Energetic (ZH) · 清亮少年男声 + 稚气少女声(调剂少年音)|中快速夏日流行(约 122 BPM) +
+ + +
+
+ +
+
+ 骑上扫帚送快递 cover art +
+
+
+ 45. 骑上扫帚送快递 + MP3 + 3分08秒 (188.5s) · Japanese Energetic (ZH) · 清脆阳光的小女孩声音|中速轻快木吉他与手鼓(约 116 BPM) +
+ + +
+
+ +
+
+ 小满·青梅煮雨 cover art +
+
+
+ 46. 小满·青梅煮雨 + MP3 + 3分34秒 (214.9s) · Chinese Guofeng New · 清甜稚气的少女/女童声|中速轻快电子国风(约 106 BPM) +
+ + +
+
+ +
+
+ 糖画摊前的小狐仙 cover art +
+
+
+ 47. 糖画摊前的小狐仙 + MP3 + 3分12秒 (192.2s) · Chinese Guofeng New · 活泼软萌的稚气少女音(字密语速快)|中快速(约 124 BPM) +
+ + +
+
+ +
+
+ 鲛人泪·沧海月 cover art +
+
+
+ 48. 鲛人泪·沧海月 + MP3 + 3分59秒 (239.8s) · Chinese Guofeng New · 空灵仙气的少年假声/美声男声(调剂男声)|慢速深邃(约 68 BPM) +
+ + +
+
+ +
+
+ 兰亭旧墨 cover art +
+
+
+ 49. 兰亭旧墨 + MP3 + 3分49秒 (229.3s) · Chinese Guofeng New · 清亮温婉的少女声|中慢速古筝与二胡(约 80 BPM) +
+ + +
+
+ +
+
+ 采药小童下山记 cover art +
+
+
+ 50. 采药小童下山记 + MP3 + 3分24秒 (204.4s) · Chinese Guofeng New · 纯真稚气的小女孩声音|中速轻快木吉他与竹笛(约 104 BPM) +
+ + +
+
+ +
+
+ 晒太阳的旧毛衣 cover art +
+
+
+ 51. 晒太阳的旧毛衣 + MP3 + 3分04秒 (184.9s) · Gentle Acoustic New · 清澈稚气的少女声|中慢速木吉他民谣(约 80 BPM) +
+ + +
+
+ +
+
+ 奶奶的桂花糕 cover art +
+
+
+ 52. 奶奶的桂花糕 + MP3 + 3分42秒 (222.4s) · Gentle Acoustic New · 纯真软糯的小女孩声音|慢速温情口琴与吉他(约 72 BPM) +
+ + +
+
+ +
+
+ 北方的末班渡轮 cover art +
+
+
+ 53. 北方的末班渡轮 + MP3 + 3分49秒 (229.6s) · Gentle Acoustic New · 低沉温暖的民谣男声(调剂男声)|慢速深沉(约 74 BPM) +
+ + +
+
+ +
+
+ 云朵收集指南 cover art +
+
+
+ 54. 云朵收集指南 + MP3 + 2分56秒 (176.4s) · Gentle Acoustic New · 俏皮温柔的稚气少女音|中速轻快(约 98 BPM) +
+ + +
+
+ +
+
+ 街角二十四小时书店 cover art +
+
+
+ 55. 街角二十四小时书店 + MP3 + 4分13秒 (253.4s) · Gentle Acoustic New · 温柔治愈的青年男声(调剂男声)|慢速钢琴与木吉他(约 74 BPM) +
+ + +
+
+ +
+
+ 暮光归途 cover art +
+
+
+ 56. 暮光归途 + MP3 + 4分02秒 (242.6s) · Angelic Choir (ZH) · 天籁女童高音领唱 + 纯净童声合唱|极慢速庄严管风琴与弦乐(约 66 BPM) +
+ + +
+
+ +
+
+ 冬夜银铃 cover art +
+
+
+ 57. 冬夜银铃 + MP3 + 2分59秒 (179.0s) · Angelic Choir (ZH) · 清脆女童多声部齐唱与轮唱|中快速灵动三拍子(约 125 BPM) +
+ + +
+
+ +
+
+ 迎风飞翔的纸飞机 cover art +
+
+
+ 58. 迎风飞翔的纸飞机 + MP3 + 3分53秒 (233.2s) · Angelic Choir (ZH) · 清亮纯真的女童领唱 + 学院风童声合唱|中速温暖(约 96 BPM) +
+ + +
+
+ +
+
+ 小星星的晚安曲 cover art +
+
+
+ 59. 小星星的晚安曲 + MP3 + 3分51秒 (232.0s) · Angelic Choir (ZH) · 稚气纯净的小女孩独唱|慢速竖琴与小提琴(约 70 BPM) +
+ + +
+
+ +
+
+ 晨曦草地的赞美诗 cover art +
+
+
+ 60. 晨曦草地的赞美诗 + MP3 + 4分26秒 (266.0s) · Angelic Choir (ZH) · 神圣清澈的女童领唱 + 宏大童声和声(卡农式递进)|慢速治愈(约 72 BPM) +
+ + +
+
+ + + +--- + +## Want This For Your Business? + +I build and operate custom self-hosted AI media pipelines, automated video & audio +generation backends, and full-stack web platforms for small and medium enterprises. + +- **WhatsApp**: [012-7972969](https://wa.me/60127972969) +- **Email**: [me@hoelee.com](mailto:me@hoelee.com?subject=Inquiry%20regarding%20AI%20Audio%20Pipeline) +- **Website**: [hoelee.com](https://hoelee.com) +```` + +--- + diff --git a/docs/consultant-review/batches/batch-10.md b/docs/consultant-review/batches/batch-10.md new file mode 100644 index 0000000..2c3465f --- /dev/null +++ b/docs/consultant-review/batches/batch-10.md @@ -0,0 +1,3706 @@ +# 博客文章顾问审阅资料包 — Batch-10 + +> **批次编号**:`Batch-10` +> **包含文章数**:5 篇 +> **本批次总字符量**:159,503 字(严格限制 < 200,000 字) +> **模式**:BILINGUAL +> **生成时间**:2026-10-27 03:02:03 + +# 外部顾问审阅前置须知与工程背景规范 + +> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。 + +## 1. 站点定位与双重业务目的 +- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。 +- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。 + +## 2. 核心人设铁律与工程硬约束(必须严格遵守) +1. **彻底杜绝业余爱好者/玩具口吻**: + - ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。 + - ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。 +2. **严禁捏造虚假指标与性能数字**: + - 所有 benchmark 数据必须有明确的测试基准与单设备限制。 + - 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。 + - 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。 +3. **去模板化叙事与真实物理锚点**: + - 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。 + - 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。 +4. **代码与配置展示规范**: + - 超过 20 行的配置或日志采用 HTML `
` 原生折叠,`` 必须精准标注文档名与行数。 + +## 3. 顾问重点开刀维度 +- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大? +- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻? +- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)? +- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置? + + +--- + +## 本批次文章目录索引 + +1. [tutorials] **给没有时间戳的歌曲自动生成字幕** (`generating-subtitles-for-songs-without-timestamps`) — ~88,620 字符 +2. [tutorials] **购买主机前,如何核实一家服务商的真实情况** (`how-to-verify-a-hosting-provider-before-you-buy`) — ~9,649 字符 +3. [web3] **一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约** (`chainlink-vrf-v2-lottery-contract`) — ~25,244 字符 +4. [web3] **全链上 SVG NFT:把艺术作品放进合约里** (`fully-on-chain-svg-nfts`) — ~24,005 字符 +5. [web3] **为什么在 Windows 上克隆后,我的链上 NFT 图像变了** (`why-my-on-chain-nft-art-changed-on-windows`) — ~11,985 字符 + +--- + +## 文章 01 / 05:给没有时间戳的歌曲自动生成字幕 +- **Slug**: `generating-subtitles-for-songs-without-timestamps` +- **英文标题**: Generating Subtitles for Songs That Ship Without Timestamps +- **分类**: `tutorials` | **发布日期**: 2026-10-20 +- **线上地址**: https://blog.hoelee.com/posts/generating-subtitles-for-songs-without-timestamps/ (EN) | https://blog.hoelee.com/posts/zh/generating-subtitles-for-songs-without-timestamps/ (ZH) +- **本篇审核状态**: ⏳ 待审核 (Pending) + +### ✍️ 顾问审阅意见区 (Review Feedback) +```text +【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构 +【事实与数字核对】: +【人设与用词卫生】: +【CRO与转化建议】: +``` + +### 🇨🇳 中文版原文 (Chinese Markdown Twin) +````markdown +--- +title: "给没有时间戳的歌曲自动生成字幕" +description: "AI 音乐工具只给你音频 —— 没有逐行时间,没有卡拉 OK。这是我给 18 首歌生成 LRC 与 SRT 字幕的完整流程,为什么单纯转写不行,以及让歌词框滚到底的两个 bug。" +pubDate: 2026-10-20 +category: tutorials +blueprint: ai-agent-automation +blueprintLayer: L3-local-gpu-inference +stream: creative-lab +tags: ["subtitles", "lrc", "srt", "whisper", "forced-alignment", "ai-music", "python"] +ogImage: "/og/zh-generating-subtitles-for-songs-without-timestamps.jpg" +banner: "/banners/generating-subtitles-for-songs-without-timestamps.jpg" +draft: false +--- +我试过的每一个 AI 音乐工具,交出来的东西形状都一样:一个音频文件。没有一个会告诉你**每一句是什么时候唱的**。只想听的话没问题,但只要你想把歌词放到屏幕上 —— 卡拉 OK 的那一行、视频字幕、播放器下面的滚动框 —— 就完全不够用。 + +时间轴才是全部难点。音频反而是简单的部分。 + +这是我最后跑通的一套流程,用在本地生成的 18 首英文歌上,外加它在浏览器里崩掉的两个地方。下面全部可复现:它唯一需要你提供的东西,是**你已经写好的歌词**。 + +## 你真正需要的两样东西 + +两种字幕格式,共用同一份数据结构: + +| 格式 | 用在哪 | 长什么样 | +|---|---|---| +| **LRC** | 音乐播放器、卡拉 OK 块、`