<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>nova02640</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://nova02640.github.io/</id>
  <link href="https://nova02640.github.io/" rel="alternate"/>
  <link href="https://nova02640.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, nova02640</rights>
  <subtitle>记录编程、技术、游戏开发与生活随笔</subtitle>
  <title>nova02640's Blog</title>
  <updated>2026-10-17T04:51:28.260Z</updated>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="飞书" scheme="https://nova02640.github.io/tags/%E9%A3%9E%E4%B9%A6/"/>
    <category term="OpenAPI" scheme="https://nova02640.github.io/tags/OpenAPI/"/>
    <category term="故障排查" scheme="https://nova02640.github.io/tags/%E6%95%85%E9%9A%9C%E6%8E%92%E6%9F%A5/"/>
    <category term="自托管" scheme="https://nova02640.github.io/tags/%E8%87%AA%E6%89%98%E7%AE%A1/"/>
    <category term="AI Agent" scheme="https://nova02640.github.io/tags/AI-Agent/"/>
    <content>
      <![CDATA[<h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>某天开始，我的飞书机器人<strong>消息照收不误，但一条回复都发不出去</strong>。</p><p>根因不在我的代码、不在网络、不在凭证，而是飞书侧的一条<strong>未公开的访问控制策略</strong>：</p><blockquote><p><strong>用「豆包账号」身份登录的飞书账号，其名下自建应用只要配置了任何 OpenAPI 权限，在获取访问凭证（<code>tenant_access_token</code>）时就会被策略拒绝，返回未收录于官方文档的错误码 <code>21001</code>；而未配置任何权限的应用反而一切正常。换用手机号&#x2F;邮箱注册的飞书账号建同权限的应用后，立即恢复。</strong></p></blockquote><p>这不是推测，是可以 <strong>100% 复现</strong>的实测结果（文末给了可复制的验证步骤）。</p><hr><h2 id="一、故障现象：一个反直觉的「半瘫」"><a href="#一、故障现象：一个反直觉的「半瘫」" class="headerlink" title="一、故障现象：一个反直觉的「半瘫」"></a>一、故障现象：一个反直觉的「半瘫」</h2><table><thead><tr><th>通道</th><th>状态</th></tr></thead><tbody><tr><td><strong>入站</strong>（飞书 → 我的机器人，WebSocket 长连接）</td><td>✅ 完全正常，消息收得到，甚至能正常打「正在输入」表情</td></tr><tr><td><strong>出站</strong>（我的机器人 → 飞书，HTTP OpenAPI）</td><td>❌ 全线失败：发消息、加表情、写云文档，一条不落</td></tr></tbody></table><p>出站返回的是：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">HTTP <span class="number">200</span></span><br><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;code&quot;</span><span class="punctuation">:</span><span class="number">21001</span><span class="punctuation">,</span><span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span><span class="string">&quot;Access denied by access control policy. Contact the administrator for assistance.&quot;</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>第一个关键信息就藏在这里：<strong>HTTP 状态码是 200</strong>。</p><p>不是超时、不是 5xx、不是连不上——请求稳稳到达了飞书并被正常处理，是飞书<strong>在业务层明确拒绝</strong>。这一条就把「网络问题」「防火墙」「DNS」全部排除掉了。</p><p>第二个关键信息：<strong>入站正常、出站失败</strong>。这个不对称非常有价值——它说明不是整个连接死了，而是某个<strong>只在 HTTP API 侧生效的检查</strong>没通过。</p><hr><h2 id="二、排查路径"><a href="#二、排查路径" class="headerlink" title="二、排查路径"></a>二、排查路径</h2><h3 id="第-1-步：先分清入站和出站"><a href="#第-1-步：先分清入站和出站" class="headerlink" title="第 1 步：先分清入站和出站"></a>第 1 步：先分清入站和出站</h3><p>看网关日志：接收侧的长连接一直是稳的，发送侧每一条都在报同一个错误。故障定位于「出站 &#x2F; 鉴权层」。</p><h3 id="第-2-步：凭证对照实验（一分钟把「凭证错」排掉）"><a href="#第-2-步：凭证对照实验（一分钟把「凭证错」排掉）" class="headerlink" title="第 2 步：凭证对照实验（一分钟把「凭证错」排掉）"></a>第 2 步：凭证对照实验（一分钟把「凭证错」排掉）</h3><p>拿正确凭证和故意写错的凭证各打一次取 token 的接口：</p><table><thead><tr><th>使用的凭证</th><th>飞书返回</th><th>含义</th></tr></thead><tbody><tr><td>正确的 app_secret</td><td><code>21001</code> 访问控制策略拒绝</td><td>凭证校验<strong>通过</strong>了，被后面的策略拦下</td></tr><tr><td>故意写错的 app_secret</td><td><code>10014 app secret invalid</code></td><td>凭证无效</td></tr></tbody></table><p>结论很干净：<strong>飞书是先校验凭证、再走策略的</strong>。既然正确凭证没报「凭证无效」而是报「策略拒绝」，说明<strong>凭证是对的，卡点在策略层</strong>。</p><p>（这也说明：任何「重置 App Secret」的操作对本故障都是无效的——我确实也试过，白费一轮。）</p><h3 id="第-3-步：21001-到底是什么？"><a href="#第-3-步：21001-到底是什么？" class="headerlink" title="第 3 步：21001 到底是什么？"></a>第 3 步：<code>21001</code> 到底是什么？</h3><p>这是整个排查最难受的部分——<strong>官方查不到</strong>：</p><ul><li>飞书官方的《通用错误码》列表里<strong>没有 21001</strong>；</li><li>官方站内搜索 <code>21001</code>，国内站和国际站都是 <strong>0 结果</strong>；</li><li>客服渠道问了一圈，没有有效回复。</li></ul><p>唯一的官方线索来自<strong>飞书官方 CLI 的源码</strong>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> (</span><br><span class="line">    LarkErrBlockByPolicy        = <span class="number">21001</span> <span class="comment">// access denied by access control policy</span></span><br><span class="line">    LarkErrBlockByPolicyTryAuth = <span class="number">21000</span> <span class="comment">// ……challenge is required to be completed by user</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// SecurityPolicyError is returned when a request is blocked by access control policies.</span></span><br><span class="line"><span class="keyword">type</span> SecurityPolicyError <span class="keyword">struct</span> &#123;</span><br><span class="line">    Code <span class="type">int</span></span><br><span class="line">    Message <span class="type">string</span></span><br><span class="line">    ChallengeURL <span class="type">string</span></span><br><span class="line">    CLIHint <span class="type">string</span></span><br><span class="line">    Err <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>也就是说：<strong>这个码属于「安全策略类」，不是凭证错误、不是权限不足、不是频率限制</strong>。而且官方还在这条错误上预留了一个「验证&#x2F;申诉入口」（<code>ChallengeURL</code>）字段——只是从没写过它什么时候会被触发。</p><h3 id="第-4-步：★-我在这里踩了一个大坑（把结论带偏了一天）"><a href="#第-4-步：★-我在这里踩了一个大坑（把结论带偏了一天）" class="headerlink" title="第 4 步：★ 我在这里踩了一个大坑（把结论带偏了一天）"></a>第 4 步：★ 我在这里踩了一个大坑（把结论带偏了一天）</h3><p>我当时的设计是：在<strong>同一个账号</strong>里新建一个「什么都没有」的裸应用，和出故障的原应用做对照。</p><p>结果非常好，好到让我错了：</p><table><thead><tr><th>应用</th><th>配置</th><th>结果</th></tr></thead><tbody><tr><td>原应用</td><td>配了消息相关权限</td><td>❌ <code>21001</code></td></tr><tr><td>新建的裸应用</td><td><strong>什么都没有：无权限、无机器人、未发布版本</strong></td><td>✅ <code>code 0</code>，正常签发凭证</td></tr></tbody></table><p>据此我得出了结论：<strong>「这个账号没问题，是我这个应用被单独限制了」</strong>——然后沿着「应用被限制」的方向查了一整天，毫无进展。</p><p>第二天我才意识到问题出在哪：<strong>那个「什么都没有」的裸应用，恰好绕过了这条策略</strong>。</p><blockquote><p><strong>教训：对照组选错，得到的结论比没有结论更危险。</strong><br>这里的变量其实有两个——「账号身份」和「应用是否持有权限」。用空权限的应用去做对照，等于同时把这两个变量都改了，看起来找到了差异，实际是把真正的变量藏了起来。</p></blockquote><h3 id="第-5-步：二维对照实验，定案"><a href="#第-5-步：二维对照实验，定案" class="headerlink" title="第 5 步：二维对照实验，定案"></a>第 5 步：二维对照实验，定案</h3><p>重新设计对照：<strong>账号 × 应用权限</strong>，两个维度都取到：</p><table><thead><tr><th></th><th>带权限的应用</th><th>空权限的应用</th></tr></thead><tbody><tr><td><strong>账号 A</strong>（豆包账号身份登录）</td><td>❌ <strong>21001</strong></td><td>✅ <code>code 0</code></td></tr><tr><td><strong>账号 B</strong>（手机号&#x2F;邮箱注册）</td><td>✅ <code>code 0</code></td><td>✅ <code>code 0</code></td></tr></tbody></table><p><strong>只有「账号 A + 带权限」这一格是红的。</strong></p><p>把它拆成可验证的断言：</p><ul><li>同一个账号下，<strong>空权限应用通过、带权限应用被拒</strong> → 不是「账号被封」，是<strong>权限维度</strong>的策略评估；</li><li>同一个权限配置下，<strong>账号 A 被拒、账号 B 通过</strong> → 不是「应用有问题」，是<strong>账号身份维度</strong>；</li><li>两者在同一时刻、同一台机器、同一出口 IP、同一客户端上测的 → 源 IP、网络、客户端<strong>全部无关</strong>。</li></ul><p>我随后又独立跑了两轮复核：<strong>8 次调用 8 次一致，零抖动。</strong></p><hr><h2 id="三、可复制的验证步骤（约-2-分钟）"><a href="#三、可复制的验证步骤（约-2-分钟）" class="headerlink" title="三、可复制的验证步骤（约 2 分钟）"></a>三、可复制的验证步骤（约 2 分钟）</h2><p>如果你怀疑自己遇到了同一条策略，可以这样验证。准备两个应用：一个<strong>随便加两个权限</strong>，一个<strong>什么都不加</strong>，然后用同一个账号的凭证分别请求：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">curl -s -X POST <span class="string">&#x27;https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal&#x27;</span> \</span><br><span class="line">  -H <span class="string">&#x27;Content-Type: application/json&#x27;</span> \</span><br><span class="line">  -d <span class="string">&#x27;&#123;&quot;app_id&quot;:&quot;&#123;你的_app_id&#125;&quot;,&quot;app_secret&quot;:&quot;&#123;你的_app_secret&#125;&quot;&#125;&#x27;</span></span><br></pre></td></tr></table></figure><p>预期结果（如果你也在被拦）：</p><table><thead><tr><th>应用</th><th>预期返回</th></tr></thead><tbody><tr><td>带权限的应用</td><td><code>{&quot;code&quot;:21001,&quot;msg&quot;:&quot;Access denied by access control policy...&quot;}</code></td></tr><tr><td>空权限的应用</td><td><code>{&quot;code&quot;:0,&quot;msg&quot;:&quot;ok&quot;,&quot;tenant_access_token&quot;:&quot;...&quot;}</code></td></tr><tr><td>带权限的应用 + 故意写错的 secret</td><td><code>{&quot;code&quot;:10014,&quot;msg&quot;:&quot;app secret invalid&quot;}</code>（证明应用被平台正常识别）</td></tr></tbody></table><p>第三行是关键的对照组：<strong>如果错误凭证能拿到「凭证无效」而正确凭证拿到「策略拒绝」，那就说明你的凭证没问题，问题在策略层。</strong></p><blockquote><p>排查时注意把飞书返回的 <code>x-tt-logid</code>（请求追踪 ID）留存下来——它是唯一能向官方定位这次调用的凭据。</p></blockquote><hr><h2 id="四、能查到的和查不到的"><a href="#四、能查到的和查不到的" class="headerlink" title="四、能查到的和查不到的"></a>四、能查到的和查不到的</h2><p><strong>官方侧存在、但不点名的信号：</strong></p><ul><li>飞书开放平台 changelog，2026-07 下旬：「<strong>Agent 访问官方接口时增加账号保护</strong>：保护策略默认开启，只对飞书或 Lark 官方接口生效」</li><li>飞书开放平台 changelog，2026-10 上旬：「登录、<strong>获取凭证</strong>和身份验证遇到<strong>安全策略拦截</strong>时，保留服务端返回的提示、<strong>验证入口</strong>和规则信息」</li></ul><p><strong>查不到的：</strong></p><ul><li><code>21001</code> 的官方定义、触发条件、解除方式</li><li>开发者后台里<strong>没有任何提示</strong>：没有站内信、没有邮件、没有横幅、没有违规通知</li><li>「豆包账号登录飞书后……此类账号没有企业管理后台」——这意味着这类身份<strong>连「去哪改设置、去哪申诉」的入口都不存在</strong></li></ul><p><strong>同期社区里有其他人中招：</strong> 2026-10-13 前后，飞书官方仓库的两个 issue（<a href="https://github.com/larksuite/cli/issues/2815">larksuite&#x2F;cli#2815</a>、<a href="https://github.com/larksuite/openclaw-lark/issues/639">larksuite&#x2F;openclaw-lark#639</a>）以及若干技术论坛帖，记录的是同一个现象：入站长连接正常、出站全部 <code>21001</code>、无效凭证返回的却是另一个错误码。其中一条 issue 的标题直接点名了账号类型，评论里也有多位「豆包账号」用户报告同样的失败。</p><p><strong>需要说清楚的一点：</strong> 以上是我能实测到的<strong>行为</strong>，不是官方<strong>说法</strong>。飞书至今没有就这条策略发布任何公告，本文不对其动机做任何推断——我只陈述可复现的事实。</p><hr><h2 id="五、如果你也遇到了：判别手册"><a href="#五、如果你也遇到了：判别手册" class="headerlink" title="五、如果你也遇到了：判别手册"></a>五、如果你也遇到了：判别手册</h2><p><strong>第一步：把入站和出站切开。</strong><br>长连接正常 + HTTP 全挂 → 问题在出站&#x2F;鉴权层，不是网络。</p><p><strong>第二步：看错误码定性。</strong></p><table><thead><tr><th>错误码</th><th>含义</th><th>方向</th></tr></thead><tbody><tr><td><code>21001</code> &#x2F; <code>21000</code></td><td>访问控制策略拒绝（<strong>官方文档未收录</strong>）</td><td>策略层</td></tr><tr><td><code>10014</code> &#x2F; <code>10015</code> &#x2F; <code>10003</code></td><td>凭证无效 &#x2F; secret 错误 &#x2F; 参数非法</td><td>凭证层</td></tr><tr><td><code>99991401</code></td><td>IP 白名单拒绝（且官方明确它<strong>不作用于 token 类接口</strong>）</td><td>网络策略</td></tr><tr><td><code>99991403</code> &#x2F; <code>429</code></td><td>用量配额或频率限制</td><td>用量</td></tr><tr><td><code>99991672</code></td><td>缺少某个权限点</td><td>权限申请</td></tr></tbody></table><p><strong>第三步：做凭证对照实验。</strong>（见上文，一分钟出结论）</p><p><strong>第四步：做二维对照实验——这是最关键的一步。</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">             带权限的应用   空权限的应用</span><br><span class="line">账号 A          ？            ？</span><br><span class="line">账号 B          ？            ？</span><br></pre></td></tr></table></figure><ul><li>只有换账号才好 → 账号身份维度</li><li>只有空权限应用能通 → 权限维度</li><li><strong>千万别只用空权限应用做对照</strong>，它会给你一个看起来完美、实则错误的答案。</li></ul><p><strong>第五步：留存证据找官方。</strong> 记下 <code>app_id</code>、<code>tenant_key</code>、完整响应体、<code>x-tt-logid</code>。<code>21001</code> 未文档化，一线客服大概率无解，需要真正到 oncall 才能定位。</p><hr><h2 id="六、三条教训"><a href="#六、三条教训" class="headerlink" title="六、三条教训"></a>六、三条教训</h2><ol><li><strong>HTTP 200 + 业务错误码 ≠ 你的代码有问题。</strong> 当状态码成功而业务码异常时，要立刻把怀疑方向从「我的实现」转向「中间的策略层」。</li><li><strong>对照实验的对照组选错，比没有对照更危险。</strong> 我因为一个「空权限的裸应用」，得出「只有我的应用被限制」的结论，白查了一整天。<strong>控制变量必须同时锁住你怀疑的每一个维度</strong>，哪怕这意味着多建两个应用。</li><li><strong>平台静默变更 + 未文档化错误码，意味着你只能靠可复现实验自救。</strong> 这类故障里，社区里别人的复现记录（issue、论坛帖）往往比官方文档更有用——因为文档里根本没有这个东西。</li></ol><p>顺便说一句：这次故障从发生到被发现<strong>静默了 17 小时</strong>——期间机器人一切看起来都在正常「工作」，只是所有回复都发不出去。如果这类链路对你重要，值得加一条主动健康检查，而不是等用户告诉你「你怎么不回我」。</p><hr><h2 id="后记"><a href="#后记" class="headerlink" title="后记"></a>后记</h2><p>我已经把应用迁到手机号&#x2F;邮箱注册的飞书账号上，收发恢复正常，并把整套判别流程记进了自己的运维笔记。</p><p>写这篇的目的很简单：这条策略是静默生效的，官方既没有公告也没有文档，遇到的人大概率会先怀疑自己的代码、网络或凭证，然后在「重置密钥」这种无效操作上耗掉大半天。<strong>如果这篇能让你在 10 分钟内定位到「是策略层」，那就值了。</strong></p>]]>
    </content>
    <id>https://nova02640.github.io/feishu-21001-access-denied/</id>
    <link href="https://nova02640.github.io/feishu-21001-access-denied/"/>
    <published>2026-10-15T13:00:00.000Z</published>
    <summary>某天起我的飞书机器人消息照收不误、回复一条都发不出去。查到最后不是代码、不是网络、不是凭证，而是飞书侧一条未公开的访问控制策略：用豆包账号身份登录的账号，名下应用只要配了权限就取不到访问凭证。这篇记录完整的排查路径、一个把结论带偏一天的对照实验，以及可复制的验证步骤。</summary>
    <title>「收得到，发不出去」：一次飞书 OpenAPI 21001 策略拦截的完整排查</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="自托管" scheme="https://nova02640.github.io/tags/%E8%87%AA%E6%89%98%E7%AE%A1/"/>
    <category term="AI Agent" scheme="https://nova02640.github.io/tags/AI-Agent/"/>
    <category term="运维" scheme="https://nova02640.github.io/tags/%E8%BF%90%E7%BB%B4/"/>
    <category term="Linux" scheme="https://nova02640.github.io/tags/Linux/"/>
    <category term="systemd" scheme="https://nova02640.github.io/tags/systemd/"/>
    <category term="配置审计" scheme="https://nova02640.github.io/tags/%E9%85%8D%E7%BD%AE%E5%AE%A1%E8%AE%A1/"/>
    <content>
      <![CDATA[<p>我的一台自托管 AI Agent（Hermes Agent）原本跑在一台一体化 NAS 上——那是它的出生地：装机即用、图形界面、什么都能点。但我后来想明白一件事：<strong>NAS 的职责是存东西，不该兼职当一台 7×24 跑 Agent 的应用服务器。</strong></p><p>于是就有了这次搬家：把它整机迁到一台 4 核 3.7G 内存的 x86 小主机上。这篇记录迁移的收尾、一次全量体检，以及把”从 NAS 带过来的参数”逐条拉出来和官方默认值对比的过程——<strong>这里面有两个坑，都挺有意思</strong>。</p><h2 id="一、先划边界：什么搬，什么不搬"><a href="#一、先划边界：什么搬，什么不搬" class="headerlink" title="一、先划边界：什么搬，什么不搬"></a>一、先划边界：什么搬，什么不搬</h2><p>搬之前我定了一条很克制的原则：<strong>只搬 Agent 本体，不搬”管理 NAS”的能力。</strong></p><p>原来的部署里有一套顺带实现的 NAS 管理功能（下载器控制、存储巡检之类）。这些功能跟 Agent 本身没关系，迁过去等于把两套职责又绑在一起。所以这次直接放弃：<strong>迁移的边界是”和 NAS 彻底分开”，不是”把 NAS 克隆一份”。</strong></p><p>结论——搬迁清单只有三样：</p><ol><li>数据与状态（配置、技能库、记忆、会话库、产出）</li><li>运行契约（它靠什么被拉起、拿到哪些环境变量）</li><li>外部通道（消息平台、代码托管、博客部署）</li></ol><h2 id="二、搬家不是拷文件，”运行契约”才是本体"><a href="#二、搬家不是拷文件，”运行契约”才是本体" class="headerlink" title="二、搬家不是拷文件，”运行契约”才是本体"></a>二、搬家不是拷文件，”运行契约”才是本体</h2><p>第一次迁移失败的人常犯的错，是以为”数据拷过去 + 手动跑起来”就完事了。真正决定它能不能长期活着的是<strong>运行契约</strong>：谁拉起它、以什么身份跑、环境变量是什么、崩了怎么办、关机时怎么优雅退出。</p><p>我用的方法很土但很有效：<strong>拿官方的服务单元生成模板当准绳，和实机单元逐行对比。</strong></p><p>结果发现真正的差异只有四处，其中一处还很有代表性：</p><table><thead><tr><th>变量</th><th>官方模板</th><th>我的取舍</th><th>原因</th></tr></thead><tbody><tr><td><code>HOME</code></td><td>用户真实家目录</td><td>指向<strong>数据根目录</strong></td><td>代码托管凭证、git 全局配置、ssh 配置全在数据根下，改回默认会”找不到配置”</td></tr></tbody></table><p>这条值得展开：官方生成的服务单元会把 <code>HOME</code> 指到系统用户的家目录，这在”数据也放家目录”的常规部署下完全正确。但我的数据全在一个独立数据根里，而且<strong>所有命令行工具的配置都随数据一起搬过来了</strong>（git、gh、ssh、npm 各一份）。如果 <code>HOME</code> 指回空荡荡的系统家目录，<code>gh</code> 和 <code>npm</code> 会当场失忆。</p><p>所以这处偏离是<strong>有意为之</strong>，不是写错了——它也被记进了技能库，避免下次升级时被”纠正”回去。</p><h2 id="三、收尾三件小事，每件都带一个坑"><a href="#三、收尾三件小事，每件都带一个坑" class="headerlink" title="三、收尾三件小事，每件都带一个坑"></a>三、收尾三件小事，每件都带一个坑</h2><h3 id="3-1-关掉一个对外的-API-端点"><a href="#3-1-关掉一个对外的-API-端点" class="headerlink" title="3.1 关掉一个对外的 API 端点"></a>3.1 关掉一个对外的 API 端点</h3><p>服务里有个 OpenAI 兼容的 API 端点。我按名字里带 “ENABLED” 的那个环境变量设成 <code>false</code>，重启……<strong>端点还在监听。</strong></p><p>查源码才发现真正的开关根本不是它：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 你以为的开关：API_SERVER_ENABLED=false</span></span><br><span class="line"><span class="comment"># 实际的判据：</span></span><br><span class="line">key = getenv(<span class="string">&quot;API_SERVER_KEY&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> <span class="keyword">not</span> has_usable_key(key):</span><br><span class="line">    <span class="keyword">return</span>                      <span class="comment"># 没有密钥 ⇒ 不启动</span></span><br><span class="line"><span class="comment"># 有密钥 ⇒ 无论如何都启动</span></span><br></pre></td></tr></table></figure><p>也就是说：<strong>只要密钥存在，这个端点就活着</strong>；那个 <code>ENABLED</code> 只是个文档条目，读到它的人（包括我）都会被误导。正确的关法是——把密钥注释掉，重启。</p><p>顺带说一句，第一次处理时我虽然没关掉端点，但把它的监听地址从”所有网卡”改成了”仅本机”，于是<strong>局域网暴露在那一刻就已经消除</strong>，服务本身留在环回口上。第二遍才彻底关掉。<strong>先缩小爆炸半径，再追求彻底</strong>，这个顺序让我少了一次紧张。</p><h3 id="3-2-SSH-改成仅密钥登录"><a href="#3-2-SSH-改成仅密钥登录" class="headerlink" title="3.2 SSH 改成仅密钥登录"></a>3.2 SSH 改成仅密钥登录</h3><p>顺序很重要：<strong>先用一次性密钥做完端到端验证，再关掉密码登录。</strong></p><ul><li>把新公钥写进授权文件，权限对齐（目录 700、文件 600）</li><li>生成一把<strong>临时</strong>钥匙，临时授权 → 用 <code>BatchMode=yes</code> 登录（禁止密码回退）→ 成功 → 立刻撤销临时钥匙</li><li>反向再验一次：不带钥匙时应该被拒</li><li>这之后才写入 <code>PasswordAuthentication no</code>，<code>sshd -t</code> 校验配置、<code>reload</code>（不断开现有连接）而不是重启</li><li>关掉后再验一遍：密码方式应报 <code>Permission denied (publickey)</code>，连”password”这个选项都不该出现在提示里</li></ul><p>这台小主机没有本地控制台，真锁死就得插键盘显示器救——所以宁可多验两遍。</p><h3 id="3-3-重启验证：别信”应该能自启”"><a href="#3-3-重启验证：别信”应该能自启”" class="headerlink" title="3.3 重启验证：别信”应该能自启”"></a>3.3 重启验证：别信”应该能自启”</h3><p>服务都是 <code>enabled</code> 状态，但”enabled”只代表 systemd 会拉起它，<strong>不代表它能起来</strong>。真实重启一遍，用两个证据说话：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">重启前 boot_id: c75893f7-…   uptime: 5 周 2 天</span><br><span class="line">重启后 boot_id: 38c281e4-…   uptime: 7 分钟      ← 全新引导</span><br><span class="line">网关：11:04:38 自动拉起   面板：11:04:52 就绪</span><br><span class="line">消息平台长连接：11:05:09 自动重连   失败单元：0</span><br></pre></td></tr></table></figure><p>按下重启到全部恢复，<strong>零人工干预</strong>。</p><h2 id="四、全面体检：看什么"><a href="#四、全面体检：看什么" class="headerlink" title="四、全面体检：看什么"></a>四、全面体检：看什么</h2><p>体检不是”看一眼没报错”。我按七组采集：主机资源、磁盘与存储、服务与自启、应用层数据完整性、备份链路、网络与安全、错误日志分类。</p><p>结果：</p><table><thead><tr><th>维度</th><th>结果</th></tr></thead><tbody><tr><td>资源</td><td>负载 0.22 &#x2F; 内存可用 2.8G（总 3.7G）&#x2F; swap 零使用 &#x2F; 温度 39–42℃</td></tr><tr><td>磁盘</td><td>系统盘 27%、数据盘 3%、inode 充裕、两块盘 SMART 全部 PASSED</td></tr><tr><td>存储健康</td><td>近 7 天 USB 掉线 0 次、I&#x2F;O 错误 0、文件系统错误 0；重分配扇区 0、待映射 0、CRC 错误 0</td></tr><tr><td>服务</td><td>网关&#x2F;面板 active，重启次数 0，失败单元 0，本次开机零错误日志</td></tr><tr><td>数据</td><td>会话库 <code>PRAGMA quick_check</code> 通过，62 会话 &#x2F; 13520 条消息</td></tr><tr><td>安全</td><td>对外只开一个端口且已限定密钥；对外 API 端点已关闭</td></tr></tbody></table><p><strong>最有价值的一项发现是关于”单点”的</strong>：数据盘是一块 USB 外接 SSD——没有 RAID、没有第二副本。这类小主机上它几乎必然成为唯一单点。所以”能跑”之外还得有兜底：</p><ul><li>每天凌晨自动把核心状态打包快照到<strong>系统盘</strong>（7 份轮转）</li><li>每 6 小时巡检一次：数据盘是否掉线、可写、容量、SMART</li><li>掉盘直接推消息告警</li></ul><p>顺带一提，这台小主机只有 USB 2.0 控制器，外接盘协商在 480Mbps——这是硬件上限，不是故障。<strong>接受它，比反复换线折腾省事。</strong></p><h2 id="五、配置审计：把”带过来的参数”逐条对账"><a href="#五、配置审计：把”带过来的参数”逐条对账" class="headerlink" title="五、配置审计：把”带过来的参数”逐条对账"></a>五、配置审计：把”带过来的参数”逐条对账</h2><p>这一步是我最想做的：整个部署是从 NAS 上”连参数一起”搬过来的，那些参数当初是为了适配 NAS 环境写的，<strong>搬到新环境后还合理吗？</strong></p><p>方法很直接：</p><ol><li>导入官方默认配置字典，和本地配置文件递归 flatten 后<strong>逐键比对</strong></li><li>把官方服务单元生成模板和实机单元逐行对比</li><li>对每个环境变量数一遍”源码里到底有几个人在读它”</li></ol><p>关键认知先说：<strong>本地文件里”缺少”几百个默认键，不是配置损坏</strong>——新版本的新键会自动跟默认值合并。真正需要看的只有两类：<strong>值被改过的</strong>、<strong>官方默认不注册而本地独有的</strong>。</p><p>比对结果：值不同的 <strong>18 处</strong>，本地独有的 <strong>19 项</strong>。把它们分类之后：</p><ul><li><strong>8 项必须保留</strong>：环境适配（强制 IPv4、排空预算、模型定义、并发上限……）</li><li><strong>4 项建议改</strong>：3 项是”超时阈值偏紧”（单轮 10 分钟、澄清等待 10 分钟），1 项是”危险操作二次确认被关掉了”</li><li><strong>6 项是死配置</strong>：源码里<strong>一个读取点都没有</strong></li></ul><p>关于”死配置”——这可能是最反直觉的一类。部署迁移时我们习惯”把环境变量原样抄一遍”，觉得多抄几个没坏处。但用引用计数一查就发现：有的变量在当前版本里<strong>根本没有任何代码读它</strong>（旧契约遗留），有的甚至连对应的依赖包都没装。它们不制造故障，但会<strong>制造误解</strong>：你以为你调整了某个行为，其实什么都没发生。清理它们不是洁癖，是让配置重新变成”可信的说明书”。</p><h2 id="六、一个漂亮的误报：排空预算告警"><a href="#六、一个漂亮的误报：排空预算告警" class="headerlink" title="六、一个漂亮的误报：排空预算告警"></a>六、一个漂亮的误报：排空预算告警</h2><p>审计里最值得写的一个发现，是这条每次启动都会出现的告警：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Stale systemd unit detected: 服务单元 has TimeoutStopSec=90s</span><br><span class="line">but drain_timeout=180s cron_drain_timeout=30s (expected &gt;=210s)</span><br><span class="line">systemd may SIGKILL the gateway mid-drain.</span><br></pre></td></tr></table></figure><p>翻译一下：<em>你的服务单元只给了 90 秒停止预算，但代码认为排空需要至少 210 秒，systemd 可能会在排空途中强杀进程。</em> 提示还贴心地建议重新生成服务单元。</p><p><strong>但这台机器上的单元文件明明写着 <code>TimeoutStopSec=210</code>。</strong> 我用同一台机器、同一个单元名跑了两条命令：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">systemctl --user    show &lt;单元&gt; --property=TimeoutStopUSec   <span class="comment"># → 1min 30s   （返回码 0）</span></span><br><span class="line">systemctl           show &lt;单元&gt; --property=TimeoutStopUSec   <span class="comment"># → 3min 30s   ← 真实值</span></span><br></pre></td></tr></table></figure><p><code>--user</code>（用户级）和系统级看到的值不一样，而且差的正好是告警里那个 90 秒——<strong>systemd 的默认值</strong>。</p><p>原因在源码里：检查函数<strong>先查用户级、再查系统级</strong>。而这是一台”系统级服务”的机器：用户级管理器里并不存在这个单元，可 <code>systemctl --user show</code> 对不存在的单元<strong>不报错</strong>（返回码 0），而是打印一套默认属性。于是检查函数心满意足地拿到 90 秒，转头给了”你的单元是旧的”这个结论。</p><p><strong>结论：我自己的配置是对的</strong>（180 + 30 &#x3D; 210，与官方算法完全吻合），这是检查逻辑在”用户级&#x2F;系统级”边界上的一个 bug。</p><p>写下这段不是要指责谁——而是想说：<strong>看到告警的第一反应不应该是”我哪里配错了”。</strong> 先确认它检查的是不是你以为的那个对象，比闷头改配置有用得多。</p><h2 id="七、比”能跑”更重要：让它自己活下来"><a href="#七、比”能跑”更重要：让它自己活下来" class="headerlink" title="七、比”能跑”更重要：让它自己活下来"></a>七、比”能跑”更重要：让它自己活下来</h2><p>迁移完成后我又补了三件”事后看起来应该一开始就做”的事：</p><ol><li><strong>开机自检单元</strong>：每次启动后自动核对”服务&#x2F;端口&#x2F;长连接&#x2F;数据盘&#x2F;定时任务&#x2F;失败单元数”，并把结果推到消息平台。关键是它<strong>不依赖主服务</strong>——主服务没起来，它照样能告诉你真相，还附上自愈命令。</li><li><strong>一键恢复脚本</strong>：放在<strong>系统盘</strong>（不是数据盘），所以数据盘掉线时它依然可用；只做”检查 + 挂载 + 启动”，不删不覆盖，可以反复跑。</li><li><strong>崩溃重试 + 取消限流</strong>：数据盘掉线时服务会一直重试而不是”试几次就放弃”，把盘插回去就能自愈。</li></ol><p><strong>踩过的坑也记一笔</strong>：自检脚本第一版的”就绪判据”写错了——它按时间窗口去查日志里的”长连接已建立”，而日志是跨重启保留的。于是某次重启后，脚本匹配到了<strong>上一次开机</strong>的连接记录，在服务真正就绪之前就宣布”检查完毕”，得出一条假告警（端口没监听）。</p><p>修法两条：判据必须绑定<strong>本次引导</strong>（只查当前引导的日志，<code>journalctl -b</code>），以及<strong>采集状态前要等目标真的就绪</strong>，别在判据成立的那一瞬间抢答。</p><h2 id="八、五条复盘"><a href="#八、五条复盘" class="headerlink" title="八、五条复盘"></a>八、五条复盘</h2><ol><li><strong>迁移的主体是契约，不是文件。</strong> 拿官方的服务单元模板逐行对，比凭记忆手写可靠十倍。</li><li><strong>参数搬家要重新对账。</strong> 旧环境调优过的值（超时、并发、开关）到新环境可能偏紧、偏松，甚至已经失效。</li><li><strong>告警先分辨”谁的问题”。</strong> 同类告警里既有真故障，也有工具自身的误报；取证（对照两条命令的输出）比猜快得多。</li><li><strong>测试顺序决定事故大小。</strong> 密钥登录”先验后用”、API 端点”先收窄再关闭”——每一步都留退路。</li><li><strong>恢复能力和自动化一起设计。</strong> 会自启只是及格线，”起不来时能被救、能被通知、能被自愈”才是完整。</li></ol><p>搬完这一趟，最大的感受是：<strong>自托管的成本从来不在第一次跑通，而在之后每一次”它坏了”的时候你能多快看懂它。</strong> 这次把体检、配置审计和自愈都补齐之后，我对这台小主机的信心，比它刚跑起来那天高得多。</p>]]>
    </content>
    <id>https://nova02640.github.io/selfhosted-agent-mini-pc-migration/</id>
    <link href="https://nova02640.github.io/selfhosted-agent-mini-pc-migration/"/>
    <published>2026-10-04T12:30:00.000Z</published>
    <summary>把跑在一体化 NAS 上的自托管 AI Agent 整体迁到一台 4 核小主机：一次零丢失的切换、一轮全量体检，以及把迁移过来的 18 处配置改动逐条拉出来和官方默认对比——其中一条排空预算告警，最后被证明是框架自己的误报。</summary>
    <title>自托管 AI Agent 搬家记：从一体化 NAS 到 4 核小主机</title>
    <updated>2026-10-17T04:51:28.261Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="GitHub Pages" scheme="https://nova02640.github.io/tags/GitHub-Pages/"/>
    <category term="开发记录" scheme="https://nova02640.github.io/tags/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/"/>
    <category term="Hexo" scheme="https://nova02640.github.io/tags/Hexo/"/>
    <category term="Butterfly" scheme="https://nova02640.github.io/tags/Butterfly/"/>
    <category term="博客" scheme="https://nova02640.github.io/tags/%E5%8D%9A%E5%AE%A2/"/>
    <category term="主题定制" scheme="https://nova02640.github.io/tags/%E4%B8%BB%E9%A2%98%E5%AE%9A%E5%88%B6/"/>
    <content>
      <![CDATA[<p>起因很朴素：博客跑了一段时间，想换掉首页那张紫蓝渐变条纹的图——它太”设计模板”了，不像一个天天写代码的人的东西。另外关于页的自我介绍也想写简单点。</p><p>我把这两件事交给 AI 助手落地，顺手让它头脑风暴一下还有什么能改。结果它先做了一轮勘察，然后告诉我：<strong>你提的这两件事，其实不是最该做的。</strong></p><h2 id="一、勘察挖出来的三个硬伤"><a href="#一、勘察挖出来的三个硬伤" class="headerlink" title="一、勘察挖出来的三个硬伤"></a>一、勘察挖出来的三个硬伤</h2><p><strong>硬伤一：我的头像是别人家的 logo。</strong></p><p>侧边栏那张头像我一直没在意。打开源文件才看清——那是一个粉色的字母「B」，是三个多月前从 Bear Blog 迁移过来时<strong>跟着旧站一起带过来的残留图</strong>。也就是说，这个博客顶着别人家的 logo 挂了一个多月。</p><p><strong>硬伤二：favicon 线上一直是 404。</strong></p><p>配置里写着 <code>favicon: /img/favicon.png</code>，但那个文件<strong>在源仓库里根本不存在</strong>。所以浏览器标签页一直是一张白纸。这条特别好验证：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">curl -s -o /dev/null -w <span class="string">&quot;%&#123;http_code&#125;&quot;</span> https://你的站/img/favicon.png</span><br><span class="line"><span class="comment"># 404</span></span><br></pre></td></tr></table></figure><p>配置能写对一个不存在的路径，构建还照样全绿——这就是”构建成功 ≠ 内容对”的经典样本。</p><p><strong>硬伤三：侧边栏公告还停在迁移那天。</strong></p><p>公告栏里写着”🎉 博客已迁移到 Butterfly 主题，试试右上角的明暗切换与本地搜索”。而迁移是一个多月前的事，那句话早就该退休了。</p><h2 id="二、首页图：一张”程序员味”的封面，和三个渲染坑"><a href="#二、首页图：一张”程序员味”的封面，和三个渲染坑" class="headerlink" title="二、首页图：一张”程序员味”的封面，和三个渲染坑"></a>二、首页图：一张”程序员味”的封面，和三个渲染坑</h2><p>新封面是本地用 PIL 生成的原创图（1920×1080，约 95 KB），画面是一扇暗色编辑器窗口：语法高亮的代码、右侧文件树（文件用的是这个博客<strong>真实的文章文件名</strong>）、底部一条状态栏。</p><p>中间那块必须特别处理——Butterfly 的首页标题是<strong>叠加在封面正中</strong>的，所以封面中间必须暗，否则白字压在代码上没法看。</p><h3 id="坑一：圆形暗角会在图正中央留下一块黑斑"><a href="#坑一：圆形暗角会在图正中央留下一块黑斑" class="headerlink" title="坑一：圆形暗角会在图正中央留下一块黑斑"></a>坑一：圆形暗角会在图正中央留下一块黑斑</h3><p>第一版我用的是标准的椭圆 vignette（中心暗、四周亮）。结果图正中央出现一块<strong>肉眼可见的黑斑</strong>——半径太小、衰减太快，明暗过渡在中心变成了一个”洞”。</p><p>改成只在<strong>垂直方向</strong>做软暗化（横向完全均匀），痕迹就没了。原理很简单：标题是水平居中的，需要保护的是一条横带，不是一个圆。</p><h3 id="坑二：等宽字体缺字形时，不报错，只画一个空方框"><a href="#坑二：等宽字体缺字形时，不报错，只画一个空方框" class="headerlink" title="坑二：等宽字体缺字形时，不报错，只画一个空方框"></a>坑二：等宽字体缺字形时，不报错，只画一个空方框</h3><p>中英文混排必须<strong>逐字</strong>选字体：中文字体（Droid Sans Fallback）没有拉丁字形，等宽字体（Hack）没有汉字字形，互画都是一片方块。</p><p>更隐蔽的是第二条：Hack <strong>没有 <code>✓</code> 这个符号</strong>。缺字形时字体引擎不会报错，只会画一个空心方框（豆腐块）。合成图缩略图看着完全正常，是我放大到状态栏那一行才发现”✓ 0 problems”变成了”▯ 0 problems”。</p><p>解法是<strong>先检测再回退</strong>：用私用区码位 <code>U+E000</code> 取出这个字体的 <code>.notdef</code> 位图指纹，逐字比对，字形和 notdef 一致就说明缺失，自动回退到 DejaVu。</p><h3 id="坑三：头像是圆形裁切的"><a href="#坑三：头像是圆形裁切的" class="headerlink" title="坑三：头像是圆形裁切的"></a>坑三：头像是圆形裁切的</h3><p>我第一版在头像四角画了工程感角标，白画了——头像在页面上是 <code>border-radius: 50%</code>，四角必然被切掉。</p><h3 id="附带一条：换封面一定要换文件名"><a href="#附带一条：换封面一定要换文件名" class="headerlink" title="附带一条：换封面一定要换文件名"></a>附带一条：换封面一定要换文件名</h3><p><code>url_for</code> 不会给图片加版本参数，同名覆盖后 GitHub Pages &#x2F; CDN 会继续发旧图。所以新图叫 <code>banner-dev.jpg</code>，而不是覆盖 <code>banner.jpg</code>。</p><h2 id="三、两个被实测推翻的结论"><a href="#三、两个被实测推翻的结论" class="headerlink" title="三、两个被实测推翻的结论"></a>三、两个被实测推翻的结论</h2><p>这次改版最有意思的部分：<strong>两个原本”看起来很有道理”的判断，被实测和源码翻掉了。</strong></p><h3 id="被推翻之一：jsdelivr-在国内不可用"><a href="#被推翻之一：jsdelivr-在国内不可用" class="headerlink" title="被推翻之一：jsdelivr 在国内不可用"></a>被推翻之一：jsdelivr 在国内不可用</h3><p>改版前我列了一条建议：把第三方 CDN 从 jsdelivr 换掉，理由是”国内访问不稳”。</p><p>实测打了脸。先把这个站<strong>真实用到的</strong>三个依赖拉一遍：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> u <span class="keyword">in</span> typed.js@3.0.0/dist/typed.umd.min.js \</span><br><span class="line">         <span class="string">&quot;@fortawesome/fontawesome-free@7.3.1/css/all.min.css&quot;</span> \</span><br><span class="line">         <span class="string">&quot;@egjs/infinitegrid@4.13.0/dist/infinitegrid.min.js&quot;</span>; <span class="keyword">do</span></span><br><span class="line">  curl -s -o /dev/null -w <span class="string">&quot;%&#123;http_code&#125; %&#123;time_total&#125;s\n&quot;</span> <span class="string">&quot;https://cdn.jsdelivr.net/npm/<span class="variable">$u</span>&quot;</span></span><br><span class="line"><span class="keyword">done</span></span><br><span class="line"><span class="comment"># 200 1.05s / 200 0.6s / 200 0.9s</span></span><br></pre></td></tr></table></figure><p>全部 200，一秒左右。<strong>别拿刻板印象去改配置，先 curl 一下。</strong> 这个结论我当场撤回。</p><p>（顺带一提，上面第二次测试我一开始把路径写错了——<code>typed.js@2.0.12/dist/typed.min.js</code> 返回 404。同一个”404”，可能是路径写错，也可能是网络被墙，<strong>别急着下结论</strong>。）</p><h3 id="被推翻之二：瀑布流布局信息密度更高"><a href="#被推翻之二：瀑布流布局信息密度更高" class="headerlink" title="被推翻之二：瀑布流布局信息密度更高"></a>被推翻之二：瀑布流布局信息密度更高</h3><p>我原本建议把首页从”左右交替卡片”（<code>index_layout: 3</code>）换成瀑布流（<code>6/7</code>），理由听起来很顺：马赛克排版，密度更高。</p><p>去翻主题源码才发现两件事：</p><ol><li><strong>主题自己根本没有 masonry 的 CSS。</strong> 布局是运行时靠一个从 CDN 加载的 <code>@egjs/infinitegrid</code> 脚本算出来的。访客网络一旦拿不到那个脚本，首页卡片会直接错位。</li><li><strong>瀑布流只显示封面和标题，把摘要文字丢掉了。</strong> 现在的布局是「标题 + 日期 + 分类 + 标签 + 摘要」，信息其实<strong>更多</strong>。</li></ol><p>所以”密度更高”这个判断是错的，而且它给首页引入了一个不该有的运行时外部依赖。保持原布局。</p><h2 id="四、其余改动"><a href="#四、其余改动" class="headerlink" title="四、其余改动"></a>四、其余改动</h2><ul><li><strong>代码块</strong>：<code>light</code> → <code>darker</code>，加上 Mac 三圆点。技术博客正文里代码占比很高，这块比封面更影响观感。</li><li><strong>主题色</strong>：换掉主题默认的天蓝，色板对齐新封面；顺带设了 <code>meta_theme_color_light/dark</code>，让移动端浏览器的地址栏颜色跟着站点走。</li><li><strong>导航栏固定</strong>：长文滚动时能随时跳转。</li><li><strong>副标题</strong>：一行 → 三行轮播。</li></ul><h3 id="关于页：介绍可以精简，法律条款不行"><a href="#关于页：介绍可以精简，法律条款不行" class="headerlink" title="关于页：介绍可以精简，法律条款不行"></a>关于页：介绍可以精简，法律条款不行</h3><p>自我介绍按计划压成了三段。但<strong>版权声明被打回来重做了一次</strong>——我第一版顺手把八行条款压成了一行，被要求恢复成完整版，尤其是这条：</p><blockquote><p>若您认为本站内容侵犯了您的权益，请通过邮箱 <code>nova_work@foxmail.com</code> 联系我，我将在核实后尽快处理。</p></blockquote><p>教训很清楚：<strong>“精简文案”只该动叙述性内容，版权、免责、侵权通知这类法律文本默认原样保留。</strong> 翻新页面前先问自己一句：我删的是自我介绍，还是免责条款？</p><h2 id="五、评论区：giscus，以及”怎么确认-App-真的装了”"><a href="#五、评论区：giscus，以及”怎么确认-App-真的装了”" class="headerlink" title="五、评论区：giscus，以及”怎么确认 App 真的装了”"></a>五、评论区：giscus，以及”怎么确认 App 真的装了”</h2><p>评论区接了 giscus——走 GitHub Discussions，不需要任何服务器，访客用 GitHub 账号登录即可评论，回复也会同步到仓库的讨论区。</p><p>但有个前置条件：<strong>必须先在仓库上安装 giscus App</strong>，否则文章页会显示一行 “giscus is not installed on this repository” 的报错框。所以我一开始没敢把开关打开——宁可不显示，也不给访客看报错。</p><p>问题来了：<strong>怎么确认 App 到底装没装？</strong> 三条路全是死胡同：</p><ul><li><code>GET /repos/{owner}/{repo}/installation</code> —— 需要 GitHub App 自己的 JWT，个人 token 调不通</li><li><code>GET /user/installations</code> —— 需要 App 授权的 token，也调不通</li><li>查仓库的 collaborators &#x2F; assignees —— <strong>不会有 <code>giscus[bot]</code></strong>。GitHub App 装到仓库上不产生 collaborator，所以这个检查既不能证明装了、也不能证明没装</li></ul><p>最后管用的办法是<strong>直接问 giscus 自己的后端</strong>，按它 widget 实际调用的接口拼一个请求：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">curl -s <span class="string">&quot;https://giscus.app/api/discussions?repo=&#123;owner&#125;/&#123;repo&#125;&amp;term=/some-path/&amp;category=&#123;category_id&#125;&amp;first=10&amp;reactionsEnabled=1&amp;emitMetadata=0&amp;theme=light&amp;lang=zh-CN&quot;</span></span><br><span class="line"><span class="comment"># 未安装 → 安装/权限类错误</span></span><br><span class="line"><span class="comment"># 已安装 → &#123;&quot;error&quot;:&quot;Discussion not found&quot;&#125;   ← 能访问仓库、只是还没有讨论帖（正常）</span></span><br></pre></td></tr></table></figure><p>返回”没找到讨论帖”就说明 App 已经能访问这个仓库了。看到这个结果，才敢把 <code>comments.use</code> 打开。</p><p>顺便记一下取 ID 的方式：用 GraphQL 查 <code>repository.id</code> 得到 <code>repo_id</code>，再从 <code>discussionCategories</code> 里挑 <code>Announcements</code> 拿 <code>category_id</code>；仓库要先用 <code>PATCH repos/{owner}/{repo}</code> 把 <code>has_discussions</code> 打开。</p><h2 id="六、验收：构建绿-≠-内容对"><a href="#六、验收：构建绿-≠-内容对" class="headerlink" title="六、验收：构建绿 ≠ 内容对"></a>六、验收：构建绿 ≠ 内容对</h2><p>改完之后逐项过了一遍，几条经验值得记下来：</p><ul><li><strong>大文件要比对 sha256。</strong> GitHub Pages 有传播延迟，部署成功的瞬间去拉，可能拿到半截文件——同一张几百 KB 的图，连拉几次能出好几个不同大小。<strong>隔十几秒重拉 2~3 次，比哈希才算数。</strong></li><li><strong>单个请求返回 000 &#x2F; connection reset，先别报故障</strong>，那是 CDN 抖动，重试两三次再判定。</li><li><strong>配置改了要落到产物里核验</strong>，别信配置文件。比如 <code>nav.fixed: true</code> 生效的判定点根本不是 <code>&lt;nav&gt;</code> 标签——它的产物是<strong>页面 header 上的一个 <code>fixed</code> class</strong>。看错地方会以为没生效。</li></ul><h2 id="七、小结"><a href="#七、小结" class="headerlink" title="七、小结"></a>七、小结</h2><p>这次改版真正的收获不是那张图，而是三件事：</p><ol><li><strong>先勘察再动手。</strong> 我提的两个需求都做了，但顺带挖出的三个硬伤（头像、favicon、过期公告）比原需求更值得修——它们已经坏了一个多月，而且没人会告诉你。</li><li><strong>实测优先于经验。</strong> “jsdelivr 国内不稳””瀑布流密度更高”这两条听起来都很对，实测和翻源码之后全被推翻。前者是刻板印象，后者是我想当然。</li><li><strong>“配置写好了”不等于”配置生效了”。</strong> favicon 指向一个不存在的文件、<code>nav.fixed</code> 的产物在另一个标签上、giscus 的 App 装没装没有任何官方接口能查——这三件事的共同点是：<strong>都得去验，不能靠看配置。</strong></li></ol><p>改完的站点在这里：<strong><a href="https://nova02640.github.io/">https://nova02640.github.io/</a></strong> ，评论区也通了，欢迎来试第一条。</p>]]>
    </content>
    <id>https://nova02640.github.io/blog-geek-refresh/</id>
    <link href="https://nova02640.github.io/blog-geek-refresh/"/>
    <published>2026-10-02T15:30:00.000Z</published>
    <summary>起因只是想换张首页图、把自我介绍写简单点。结果勘察阶段就挖出三个藏了一个多月的硬伤：头像根本不是我、favicon 线上一直 404、侧边栏公告停在迁移那天。这篇记录改版的全部细节——生成封面时踩的三个渲染坑、两个被实测推翻的结论，以及 GitHub 不给接口时，怎么确认 giscus 到底装没装。</summary>
    <title>给博客做一次极客化改版：三个藏了一个多月的硬伤，和两个被推翻的结论</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="DHT" scheme="https://nova02640.github.io/tags/DHT/"/>
    <category term="BitTorrent" scheme="https://nova02640.github.io/tags/BitTorrent/"/>
    <category term="磁力搜索" scheme="https://nova02640.github.io/tags/%E7%A3%81%E5%8A%9B%E6%90%9C%E7%B4%A2/"/>
    <category term="Python" scheme="https://nova02640.github.io/tags/Python/"/>
    <category term="SQLite" scheme="https://nova02640.github.io/tags/SQLite/"/>
    <category term="FTS5" scheme="https://nova02640.github.io/tags/FTS5/"/>
    <category term="自建服务" scheme="https://nova02640.github.io/tags/%E8%87%AA%E5%BB%BA%E6%9C%8D%E5%8A%A1/"/>
    <category term="开源" scheme="https://nova02640.github.io/tags/%E5%BC%80%E6%BA%90/"/>
    <content>
      <![CDATA[<p>我在一台常年闲置的内网小主机上跑了个 DHT 索引器。它工作得很勤奋：挂在 BT 的 DHT 网络上，谁发布了新种子它就记一笔，实测每秒能录进 0.7 条、一天六万条，元数据（名字、大小、文件列表）一应俱全。</p><p>但真用起来才发现，它解决的是”<strong>有什么</strong>“，完全没解决”<strong>能不能下</strong>“。</p><h2 id="一、索引器的两个盲区"><a href="#一、索引器的两个盲区" class="headerlink" title="一、索引器的两个盲区"></a>一、索引器的两个盲区</h2><p><strong>盲区一：搜到不等于下得动。</strong></p><p>索引库里是有 <code>n_seeders</code> &#x2F; <code>n_leechers</code> 这两个字段的——听起来正是”做种人数”。我满怀期待地查了一下，<strong>全库 285 条记录，两列全是 NULL</strong>。</p><p>这不是 bug，是 DHT 索引器的固有局限：它只是在 DHT 网络上”听见”了谁在宣告某个种子，至于现在还有没有人在做种，它从不验证。于是你搜到一个看着很香的资源，复制磁力链丢进下载器，然后卡在 0% 一动不动——<strong>每一条搜索结果都是一次盲盒</strong>。</p><p><strong>盲区二（更隐蔽）：它的中文搜索在架构上就是坏的。</strong></p><p>索引器自带一个 Web 界面，用的是 SQLite 的 FTS5 全文索引。我搜了个中文关键词，返回 0 条。可我明明知道库里有这个名字。</p><p>挖下去才发现根因：FTS5 默认的分词器 <code>unicode61</code> 会把<strong>一整串连续的中文当成一个 token</strong>。《诸神的黄昏》在索引里就是<strong>一个不可分割的词</strong>。所以：</p><ul><li>搜「诸神的黄昏」——能命中（整串相等）</li><li>搜「诸神」——<strong>命中不了</strong>（不相等，也不是前缀…实际上前缀查询能救一半）</li><li>搜「黄昏」——<strong>永远不可能命中</strong>（它在词的中间，而 FTS5 的 token 匹配不认”词中词”）</li></ul><p>而现实是，我库里<strong>19% 的条目是中文名</strong>。也就是说，五分之一的内容对这个搜索框是不可见的。这不是”体验不好”，是”功能不存在”。</p><p>于是我给自己定了个目标：<strong>补上存活预筛和中文检索，做成一个只给自己用的搜索工具。</strong></p><h2 id="二、先解决”有没有”：选型"><a href="#二、先解决”有没有”：选型" class="headerlink" title="二、先解决”有没有”：选型"></a>二、先解决”有没有”：选型</h2><p>索引器我选了 magnetico——理由是它<strong>单个静态二进制 + 一个 SQLite 文件</strong>就能跑，不需要 Docker，不需要 PostgreSQL，对一台 4 核 3.7G 内存的闲置小机器非常友好。（功能更全的 bitmagnet 要 Postgres，成本和爆炸半径都不是一个量级。）</p><p>选型本身不复杂，倒是踩了两个坑：</p><p><strong>坑一：SQLite 必须用 <code>-cgo</code> 变体。</strong> release 里有个 <code>magnetico-linux-amd64</code> 看着最正常，跑起来直接报 <code>go-sqlite3 requires cgo to work. This is a stub</code>——它是 <code>CGO_ENABLED=0</code> 编译的，静态版只能配 PostgreSQL。得下载带 <code>-cgo</code> 后缀的那个。</p><p><strong>坑二：默认的 bootstrap 节点在国内几乎不可用。</strong> 上游默认走作者自建的 <code>dht.tgragnato.it</code>（六个端口），这个域名有多个 A 记录轮询，实测时通时不通。换成公共的 <code>dht.transmissionbt.com:6881</code> 后稳定得多——多次测试都稳定应答 56 字节。</p><p>顺手记一个冷知识：<strong>magnetico 没有子命令</strong>，不是 <code>magnetico crawl</code>，而是直接跟选项（<code>-d</code> 只跑爬虫、<code>-w</code> 只跑 Web UI）。我一开始凭印象写了 <code>crawl</code>，好在写文档前跑了 <code>--help</code> 才没把错的命令写进 README。</p><h2 id="三、判断种子死活：只发查询，不下载内容"><a href="#三、判断种子死活：只发查询，不下载内容" class="headerlink" title="三、判断种子死活：只发查询，不下载内容"></a>三、判断种子死活：只发查询，不下载内容</h2><p>核心思路很直接：<strong>对每个 info_hash，向 DHT 网络问一句”谁在这个种子上？”</strong></p><p>技术上就是 BEP-5 的 <code>get_peers</code> 查询：从本地 DHT 路由表里挑出离目标 info_hash 最近的若干节点，发 KRPC 查询，收集返回的 peer 列表。有 peer 回来 → 活；明显有节点应答但一个 peer 都没有 → 死；一个应答都没有 → 不确定（网络抖动，稍后重试，<strong>不误判成死种</strong>）。</p><p>整个探测<strong>只发 UDP 查询包，不下载任何内容</strong>，所以带宽开销极小（几个 Mbps）。我用纯标准库实现了一个探测引擎：bencode 编解码、节点表 bootstrap &#x2F; 扩展 &#x2F; 持久化都是自己写的，零第三方依赖。</p><p><strong>关键是怎么确认它判断得对。</strong> 我设计了真值对照：</p><ul><li><strong>正样本</strong>：Debian 官方 ISO 的种子——全世界镜像站都在做种，<strong>必然活着</strong>。实测返回 <strong>256 个 peer</strong> ✅</li><li><strong>负样本</strong>：随机生成的 info_hash——不可能有 peer。实测 <strong>0 个 peer</strong> ✅</li><li>再用索引库里 8 个真实种子交叉验证：全部检出，peer 数 2~135 不等 ✅</li></ul><p>我自己写的引擎，当然要拿已知答案的题去考它。</p><h2 id="四、压测决定架构"><a href="#四、压测决定架构" class="headerlink" title="四、压测决定架构"></a>四、压测决定架构</h2><p>接下来最重要的一步是压测——因为架构取决于一个比值。</p><p>我把探测引擎跑起来，测不同批大小下的吞吐。结果：<strong>批量 50 时约 6.9 个&#x2F;秒</strong>（≈60 万&#x2F;天）。</p><p>而索引器的入库速度是 <strong>0.7 条&#x2F;秒</strong>。也就是说，<strong>检测能力是产出速度的十倍</strong>——全量预筛完全跟得上，不需要”抽样检测”或”只检热门”这类妥协。</p><p>于是架构就定了：一个常驻 worker，双优先级队列。</p><ul><li><strong>队列 A（搜索插队，小批量 16 条）</strong>：用户正在看的结果优先探测，几秒内出状态；</li><li><strong>队列 B（后台回填，批量 50 条）</strong>：从最新入库往前扫，已探测且未过期的直接跳过。</li></ul><p>配合 TTL 策略：活种 24 小时复查、死种 7 天复查、探测失败的 1 小时重试。这样既保证搜索结果新鲜，又不会把带宽浪费在反复确认同一批数据上。</p><h2 id="五、四个只有跑起来才会暴露的-bug"><a href="#五、四个只有跑起来才会暴露的-bug" class="headerlink" title="五、四个只有跑起来才会暴露的 bug"></a>五、四个只有跑起来才会暴露的 bug</h2><p>这部分是我觉得最值得记录的。代码写完能跑，和代码真的能用，中间隔着的就是这些。</p><h3 id="bug-1：中文搜索的架构缺陷"><a href="#bug-1：中文搜索的架构缺陷" class="headerlink" title="bug 1：中文搜索的架构缺陷"></a>bug 1：中文搜索的架构缺陷</h3><p>前面说了根因：<code>unicode61</code> 把整串中文当一个 token。</p><p>修的时候我先试了 FTS5 内置的另一个分词器 <code>trigram</code>（三元组，天生支持子串）——<strong>但它要求查询至少 3 个字符</strong>，而「诸神」只有 2 个字，直接失效。中文两字词太常见了，这条路走不通。</p><p>最后的解法是自己造索引：</p><ul><li><strong>建索引时</strong>，把每个 CJK 字符用空格拆开：<code>诸神的黄昏</code> → <code>诸 神 的 黄 昏</code>（非 CJK 字符原样保留）</li><li><strong>查询时</strong>，把中文按<strong>二元词组</strong>拼接：<code>&quot;诸 神&quot; AND &quot;神 的&quot; AND &quot;的 黄&quot; AND &quot;黄 昏&quot;</code>，英文数字词转成前缀查询 <code>&quot;1080p&quot;*</code></li></ul><p>这样 1 字、2 字、任意位置的中文子串都能命中，词组相邻约束又保证了准确率。修完实测：「诸神」「黄昏」「中文字幕」「电影」全部命中，耗时 1~6ms。</p><p>同一个索引顺手也解决了英文的子串问题（搜 <code>nkkd</code> 能命中 <code>17.nkkd011</code>）。</p><h3 id="bug-2：一个非-UTF-8-的种子名，打挂了整个接口"><a href="#bug-2：一个非-UTF-8-的种子名，打挂了整个接口" class="headerlink" title="bug 2：一个非 UTF-8 的种子名，打挂了整个接口"></a>bug 2：一个非 UTF-8 的种子名，打挂了整个接口</h3><p>上线后有个接口开始间歇性 500。日志里的异常很奇怪：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">OperationalError(&quot;Could not decode to UTF-8 column &#x27;name&#x27; with text &#x27;2012.03.21_...rmvb&#x27;&quot;)</span><br></pre></td></tr></table></figure><p>原因是索引库里存在<strong>历史遗留的非 UTF-8 编码种子名</strong>（GBK、Shift-JIS 之类，早年 Windows 下的产物）。Python 的 <code>sqlite3</code> 默认 <code>text_factory</code> 会尝试严格解码 UTF-8，遇到这种名字直接抛异常——<strong>整个查询失败，整个接口 500</strong>。</p><p>修法是自定义 <code>text_factory</code> 做宽松解码（<code>errors=&quot;replace&quot;</code>）：坏字符变成替换符，但不至于把接口打挂。</p><h3 id="bug-3：systemd-的依赖被我写反了"><a href="#bug-3：systemd-的依赖被我写反了" class="headerlink" title="bug 3：systemd 的依赖被我写反了"></a>bug 3：systemd 的依赖被我写反了</h3><p>我在服务单元里写了 <code>Requires=magnetico.service</code>，想法是”我依赖它”。但 systemd 的语义是：<strong>被依赖的单元停了，依赖它的单元也会被停掉</strong>。结果索引器一重启（比如升级），搜索服务就跟着被停，而且不会自动恢复——测的时候正好撞上。</p><p>改成 <code>Wants=</code>（软依赖）就对了：索引器不在时搜索服务也能降级运行（库还在，只是不更新）。</p><h3 id="bug-4：一个字典键名冲突，让我误判”探测全挂了”"><a href="#bug-4：一个字典键名冲突，让我误判”探测全挂了”" class="headerlink" title="bug 4：一个字典键名冲突，让我误判”探测全挂了”"></a>bug 4：一个字典键名冲突，让我误判”探测全挂了”</h3><p>我看了眼统计接口，发现”活种数”从 776 掉到 423，而”已探测”数却在涨。第一反应是探测出了问题。</p><p>查了半天数据库，发现库里的数据完全正常。真凶是一行偷懒的 Python：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> &#123;<span class="string">&quot;indexed&quot;</span>: ..., **health.counts(), **prober.snapshot()&#125;</span><br></pre></td></tr></table></figure><p><code>health.counts()</code> 给的是<strong>累计</strong>的活&#x2F;死数量，而 <code>prober.snapshot()</code> 里有个<strong>本次进程会话</strong>的活&#x2F;死计数——<strong>两个字典的 key 重名，后者把前者覆盖了</strong>。所以我看到的”活种数”其实是”本进程启动以来探测到的活种数”，跟全库毫无关系。数字自相矛盾的样子，让我怀疑错了对象。</p><p>教训：字典展开合并（<code>**a, **b</code>）时，<strong>重名的 key 是静默覆盖</strong>，不报错。给会话计数改名为 <code>session_*</code> 就没事了。</p><h2 id="六、怎么证明它真的能用"><a href="#六、怎么证明它真的能用" class="headerlink" title="六、怎么证明它真的能用"></a>六、怎么证明它真的能用</h2><p>我不太信任”能打开页面 &#x3D; 做完了”这种验收。所以做了三层验证：</p><p><strong>第一层：死种过滤的端到端证明。</strong> 从存活库里取一条<strong>已判定为死</strong>的种子，用它的名字去搜，然后断言：开启”隐藏死种”时它不出现、关闭时它出现。这样证明的是”过滤逻辑真的生效”，而不是”我觉得它生效”。</p><p><strong>第二层：前端用 jsdom 真实执行。</strong> 前端最容易的验收方式是”我用浏览器打开看了一眼，没问题”——但这既不可重复，也漏细节。更好的做法是让页面在无头环境里真的跑起来：用 jsdom 加载页面、注入 <code>fetch</code>，然后真实触发搜索、点击按钮，断言渲染行数、中文检索、死种过滤、展开文件、空结果提示，以及<strong>全程零 JS 错误</strong>。</p><p>一次跑出 12 项结论，而且可重复。这套测试后来还抓到了我下面要说的安全问题。</p><p><strong>第三层：安全测试。</strong> 服务是在内网暴露的，所以目录穿越、XSS 这些都得自己打一遍：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">curl --path-as-is <span class="string">&quot;http://&lt;host&gt;:8090/static/../../etc/passwd&quot;</span>   <span class="comment"># 期望 404</span></span><br><span class="line">curl --path-as-is <span class="string">&quot;http://&lt;host&gt;:8090/static/%2e%2e%2fapp.py&quot;</span>    <span class="comment"># 期望 404</span></span><br></pre></td></tr></table></figure><p>前端那层则是在 jsdom 里往搜索框里塞一段 <code>&lt;img src=x onerror=...&gt;</code>，断言它<strong>不执行</strong>、且界面上显示的是转义后的原文。</p><h2 id="七、一条二手结论，查完发现是反的"><a href="#七、一条二手结论，查完发现是反的" class="headerlink" title="七、一条二手结论，查完发现是反的"></a>七、一条二手结论，查完发现是反的</h2><p>开发到一半，我看到一个说法：<strong>“应该用 bitmagnet，因为 magnetico 已经停止维护了。”</strong></p><p>听起来很合理，而且是决定要不要推倒重来的问题。我没直接采信，而是去 GitHub API 拉了一手数据：</p><table><thead><tr><th>仓库</th><th>Star</th><th>最后提交</th><th>近 90 天提交</th><th>最近发布</th></tr></thead><tbody><tr><td><code>boramalper/magnetico</code>（<strong>原版</strong>）</td><td>3131</td><td>2022-01-20，<strong>已归档</strong></td><td>0</td><td>v0.12.0（2020-11）</td></tr><tr><td><strong><code>tgragnato/magnetico</code>（我在用的分支）</strong></td><td>147</td><td><strong>2026-09-29</strong></td><td><strong>54 次</strong></td><td>v2.4.1（2026-09-28）</td></tr><tr><td><code>bitmagnet-io/bitmagnet</code></td><td>4387</td><td>2026-05-21</td><td><strong>0 次</strong></td><td>v0.10.1-<strong>beta</strong>.1</td></tr></tbody></table><p>结论是反的：</p><ul><li>“magnetico 停止维护”说的其实是<strong>原版仓库</strong>——它确实 2022 年就归档了，而搜索”magnetico”最先跳出来的就是它（Star 也全在它头上）。我在用的是<strong>社区的活跃分支</strong>，两天前还有提交。</li><li>反倒是被推荐的 bitmagnet，<strong>近 90 天 0 次提交</strong>，最后一次发布是 beta（稳定版停在 2025-03）。</li></ul><p>我没换。这件事给我的启发不是”谁更好”，而是：<strong>别人转述的”某项目已死&#x2F;更优”，值得花五分钟去拉一手数据核实</strong>——尤其当它要你推翻已有工作时。Star 数、归档状态、提交时间线都在 API 里，是免费的。</p><p>顺带一提，那段时间我还发现 bitmagnet 那边有个 issue 在请求”支持 SQLite 存储”（说明它至今必须依赖 Postgres），而 magnetico 这边有个 issue 在请求”增加 seeders&#x2F;leechers 字段”——<strong>正好就是我做的这个死种预筛</strong>。上游还没做，我先给自己做了。</p><h2 id="八、开源，以及发布前的隐私审计"><a href="#八、开源，以及发布前的隐私审计" class="headerlink" title="八、开源，以及发布前的隐私审计"></a>八、开源，以及发布前的隐私审计</h2><p>代码写完我把它开源了：<strong><a href="https://github.com/nova02640/bt-search">https://github.com/nova02640/bt-search</a></strong>（AGPL-3.0）。</p><p>关于许可证和”归属”，我做了个刻意的选择。这个项目<strong>没有修改上游一行源码</strong>——它只是以只读方式读上游的 SQLite 索引库，存活探测是我照 BEP-5 规范自己实现的。所以：</p><ul><li><strong>没有 fork</strong>，而是建了独立仓库。fork 一个自己没改过的仓库，会让”这里有我的改动”产生误导；</li><li>README <strong>最顶部</strong>写清二次开发声明（基于哪个分支、原版已归档、遵循同一许可证）；</li><li>许可证沿用上游的 <strong>AGPL-3.0</strong>——这样无论从”衍生作品”还是”独立实现”哪个角度理解，都不会有歧义。</li></ul><p>发布前我跑了一遍隐私审计，用正则扫了<strong>全部文件 + 全部 git 历史</strong>，检查项包括：凭证关键词、私钥块、邮箱、内网 IP、私有路径、主机用户名、真实磁力链。</p><p>结果里有一条值得单独说：扫描报出 <code>dht_probe.py</code> 命中了 <code>&quot;192.168.&quot;</code>。我打开一看——那是探测引擎里的<strong>私网地址过滤白名单</strong>（用来避免把探测包发到内网保留地址）。<strong>它是个安全特性，不是泄露</strong>。如果我不看上下文直接”清理”，就会亲手删掉一个保护机制。扫描器只负责提示，判断还得人来做。</p><p>真正需要修的有四处，都是我自己写出来的：</p><table><thead><tr><th>问题</th><th>修法</th></tr></thead><tbody><tr><td>500 错误把异常原文返回给客户端（泄露内部路径&#x2F;SQL 细节）</td><td>只返回 <code>internal error</code>，细节进本地日志</td></tr><tr><td>搜索词经 <code>innerHTML</code> 回显（自 XSS 面）</td><td>做 HTML 转义，并对返回值做空值兜底</td></tr><tr><td>服务文件里写着主机用户名</td><td>改成 <code>YOUR_USER</code> 占位符</td></tr><tr><td>静态目录前缀校验没带路径分隔符（理论上可被同前缀目录绕过）</td><td>补上分隔符比较</td></tr></tbody></table><p>修完重新部署，把这四项跑了一遍验证：目录穿越的四种变体全部 404、正常静态文件仍 200、XSS 断言不执行、功能回归通过。<strong>安全修复不做验证，等于没修。</strong></p><h2 id="九、现在的样子"><a href="#九、现在的样子" class="headerlink" title="九、现在的样子"></a>九、现在的样子</h2><p>服务已经常驻运行，内网任何设备打开就能用：中文任意子串搜索、结果带 peer 数和检测时间、死种默认隐去、一键复制磁力链、可展开种子文件列表。</p><p>实测量级：</p><ul><li>索引约 1800 条时，<strong>已探测 1758 条，其中死种 255 条（约 14.5%）</strong>——也就是说，如果没有这层过滤，<strong>每 7 条搜索结果里就有 1 条是死链</strong>；</li><li>搜索响应 1~15ms；服务自身内存约 17MB、CPU 约 3%；</li><li>探测吞吐 6.9 个&#x2F;秒，全量预筛无压力。</li></ul><p>当然也有要说清楚的局限：<strong>索引还年轻</strong>。它是几个小时前才上线的，一天只能积累六万条左右，所以冷门老资源今晚大概率搜不到——不是功能问题，是还没爬到。DHT 上 peer 少也不完全等于死（冷门种可能被误判）。还有最重要的一条：<strong>这套东西只判断”有没有人在做种”，不判断内容是否安全</strong>，版权和恶意文件的风险得自己评估。</p><h2 id="十、几个收获"><a href="#十、几个收获" class="headerlink" title="十、几个收获"></a>十、几个收获</h2><p>如果只留三条：</p><ol><li><strong>“搜得到”和”用得上”之间往往隔着一层没人验证的假设。</strong> 索引器的 <code>n_seeders</code> 字段一直存在、一直是 NULL，没人觉得有问题——直到你真的想下载。补上这层验证，工具的实用性会发生质变。</li><li><strong>中文检索是个很容易被忽略的坑。</strong> 大多数全文索引方案的默认分词器对 CJK 都不友好，而且失效方式很隐蔽：不是报错，是”静默地搜不到”。但凡你的数据里有中文，都值得专门测一次。</li><li><strong>写完能跑 ≠ 能用。</strong> 这篇文章里四个 bug，有三个是只有真跑一段时间、真被用户（我自己）用起来才会暴露的。验收时尽量把”我看了眼觉得没问题”换成”用脚本断言它确实成立”。</li></ol><p>代码在这里，欢迎围观或自取：<strong><a href="https://github.com/nova02640/bt-search">https://github.com/nova02640/bt-search</a></strong></p>]]>
    </content>
    <id>https://nova02640.github.io/bt-search/</id>
    <link href="https://nova02640.github.io/bt-search/"/>
    <published>2026-10-02T14:00:00.000Z</published>
    <summary>自建 DHT 索引器能扒下全网种子元数据，却回答不了一个最实际的问题：这条磁力链还有人做种吗？更有意思的是，它的中文搜索在架构上就是坏的——默认分词器把整串中文当成一个词。这篇文章记录我给索引器补上「存活预筛 + 中文检索」的全过程，包括四个只有真跑起来才会暴露的 bug，以及一次把二手结论推翻的核查。</summary>
    <title>「搜到 ≠ 下得动」：给自建磁力索引器补一个中文搜索前端和死种预筛</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="Hermes" scheme="https://nova02640.github.io/tags/Hermes/"/>
    <category term="微信" scheme="https://nova02640.github.io/tags/%E5%BE%AE%E4%BF%A1/"/>
    <category term="QQ" scheme="https://nova02640.github.io/tags/QQ/"/>
    <category term="飞书" scheme="https://nova02640.github.io/tags/%E9%A3%9E%E4%B9%A6/"/>
    <category term="限流" scheme="https://nova02640.github.io/tags/%E9%99%90%E6%B5%81/"/>
    <category term="消息通道" scheme="https://nova02640.github.io/tags/%E6%B6%88%E6%81%AF%E9%80%9A%E9%81%93/"/>
    <category term="NAS" scheme="https://nova02640.github.io/tags/NAS/"/>
    <content>
      <![CDATA[<p>把 AI Agent 接入即时通讯工具，看起来只是”调个发消息接口”，真正跑起来才会发现：<strong>每条通道的脾气完全不同</strong>。有的平台会把你的消息悄悄吞掉，有的会限制你一天只能主动说几句话，还有的按字节数把你的长报告截成两半——而它们全都不怎么告诉你。</p><p>我的自建 Agent 在微信上跑了半个月，被限流逼着换到了飞书，最近又在评估 QQ。这篇就把三条通道的评估结果摊开讲：<strong>拦截、截断、限制</strong>各是什么情况，以及最后那条最有价值的结论。</p><h2 id="一、评估方法：不看宣传，看三层证据"><a href="#一、评估方法：不看宣传，看三层证据" class="headerlink" title="一、评估方法：不看宣传，看三层证据"></a>一、评估方法：不看宣传，看三层证据</h2><p>单看平台文档是不够的——文档往往只写”有频率限制”，不写具体阈值，更不会告诉你超限之后消息是<strong>报错</strong>还是<strong>静默丢弃</strong>。所以这次评估用了三层证据交叉验证：</p><table><thead><tr><th>层级</th><th>数据源</th><th>能回答什么</th></tr></thead><tbody><tr><td>实测层</td><td>网关日志全量检索 + 投递台账</td><td>真实发生了多少次限流、多少条消息没送到</td></tr><tr><td>代码层</td><td>通道适配器源码</td><td>消息怎么分片、错误怎么重试、是否支持编辑</td></tr><tr><td>文档层</td><td>平台开放平台官方文档</td><td>配额、窗口期、错误码的官方定义</td></tr></tbody></table><p>实测层最关键。<strong>日志不会美化自己</strong>，它直接告诉你这套东西在真实负载下的崩溃点在哪。</p><h2 id="二、微信：黑箱限流，最难受的一种"><a href="#二、微信：黑箱限流，最难受的一种" class="headerlink" title="二、微信：黑箱限流，最难受的一种"></a>二、微信：黑箱限流，最难受的一种</h2><p>微信通道的问题在两篇文章里已经详细写过（<a href="https://nova02640.github.io/weixin-rate-limit-investigation/">限流排查</a>、<a href="https://nova02640.github.io/weixin-rate-limit-followup/">tool_progress 真凶定位</a>），这里只给结论性数据：</p><ul><li><strong>7 周内触发限流 725 次</strong>，单日峰值 199 次。错误信息只有一句 <code>iLink sendmessage rate limited</code>，服务端返回 <code>ret=-2</code>，<strong>官方从未公开任何阈值</strong></li><li>触发后的行为是熔断 30 秒再重试，若服务端仍在限流期，就继续熔断撞墙——实测<strong>最长连续 7 分钟发不出任何消息</strong></li><li>用户侧的感受就是”这条消息我没收到”，而 Agent 侧只看到一条失败日志</li><li>长消息（5KB 以上或连续多条）会被整条吞掉；群聊场景基本不可用</li><li>真正的主因不在”发得太频繁”，而是<strong>微信不支持消息编辑</strong>：Agent 每执行一次工具调用就单独发一条进度消息，长任务下变成消息风暴</li></ul><div class="note warning flat"><p><strong>踩坑提醒</strong>：日志里的 <code>Temporary failure in name resolution</code>（域名解析失败）和限流是两回事。前者是网络&#x2F;DNS 问题（出现了近 800 次），后者才是平台侧限流。排查时把两类错误混在一起，会得出完全错误的结论——我就拐过这个弯。</p></div><p>到了这一步，通道已经从”偶尔丢消息”退化到”不可用”。所以决定加一条新通道。</p><h2 id="三、QQ：规则最透明，但配额最硬"><a href="#三、QQ：规则最透明，但配额最硬" class="headerlink" title="三、QQ：规则最透明，但配额最硬"></a>三、QQ：规则最透明，但配额最硬</h2><p>QQ 走的是官方 QQ 机器人开放平台 API，和微信那种半私有的通道不同，<strong>规则和错误码都是公开的</strong>——这一点比微信好得多。但读完之后会发现，它的限制比微信更死：</p><p><strong>1. 单聊的”主动消息”已经被官方下线。</strong> 平台公告明确写着主动推送能力于 2025 年 4 月起不再提供，调用会直接返回”主动消息失败, 无权限”。单聊只剩两种合法姿势：</p><ul><li><strong>被动回复</strong>：必须携带用户那条消息的 ID，单聊有效期 <strong>60 分钟、最多回 4 次</strong></li><li><strong>互动召回</strong>：用户主动对话后 30 天内分 4 个周期、每周期 1 条</li></ul><p>这意味着<strong>定时任务、巡检推送、任务完成通知在单聊里基本发不出去</strong>。对于一个主要靠”长任务跑完再汇报”的 Agent 来说，这是致命的。</p><p><strong>2. 被动窗口只有 60 分钟。</strong> 一个跑了 1 小时的升级或批处理任务，等到要汇报时，那条触发消息的 ID 已经过期，最终汇报直接失败。</p><p><strong>3. 长度按字节截断，而且是静默的。</strong> 官方只给一个笼统的”消息长度超限”错误码，不告诉你具体数值。社区实测（官方仓库 issue）里能看到真实报错：<code>content length 7456 exceeds max length 2000</code>——<strong>上限 2000 字节，中文大约 666 字</strong>；而更麻烦的是超限时 HTTP 返回 200、内容却已经被截掉了。</p><p><strong>4. 内容拦截更直接。</strong> 群聊场景不允许发送 URL（除非事先在后台报备域名），命中内容安全检测的消息会被直接打掉。</p><p>除了平台限制，适配器层面还有几处结构性问题：分片发送时只有第一片带回复 ID，后续分片在单聊里会被当成”主动消息”而失败；进度消息同样不带回复 ID。也就是说，<strong>即使接上了 QQ，长回复也只能送到第一段</strong>。</p><p>结论很清楚：QQ 适合”用户发一句、Agent 立刻回一句”的短问答，不适合我这种长任务 + 定时推送的用法。</p><h2 id="四、飞书：唯一没有”消息级硬限制”的通道"><a href="#四、飞书：唯一没有”消息级硬限制”的通道" class="headerlink" title="四、飞书：唯一没有”消息级硬限制”的通道"></a>四、飞书：唯一没有”消息级硬限制”的通道</h2><p>飞书是三条通道里体验最不像”在跟平台斗智斗勇”的一条。7 周实测数据：</p><table><thead><tr><th>指标</th><th>数值</th></tr></thead><tbody><tr><td>入站消息</td><td>129 条</td></tr><tr><td>出站回复</td><td>369 条</td></tr><tr><td>单条回复长度</td><td>中位数 577 字，90 分位 1552 字，最长 5227 字</td></tr><tr><td><strong>限流命中</strong></td><td><strong>0 次</strong></td></tr><tr><td>发送失败</td><td>1 次</td></tr><tr><td>投递台账</td><td>33 条全部送达，0 失败</td></tr></tbody></table><p>平台侧规则也宽松得多：</p><ul><li>接口频控 1000 次&#x2F;分钟、50 次&#x2F;秒；向同一用户 5 QPS</li><li><strong>消息体上限：文本 150 KB</strong>（卡片&#x2F;富文本 30 KB）——比 QQ 的 2000 字节宽了两个数量级</li><li><strong>支持消息编辑</strong>，文本和富文本都能改</li><li>富文本支持 CommonMark + GFM，表格、任务列表、删除线、自动链接都能渲染</li><li><strong>主动推送没有配额和窗口限制</strong>，机器人可以随时主动找用户</li></ul><p>最关键的是那条加粗的：<strong>支持消息编辑</strong>。这一条直接消灭了微信时代的最大隐患——进度消息不再需要一条条新发，而是<strong>在一个气泡里原地更新</strong>。消息量从”每个工具调用一条”降到”整轮对话几条”，限流自然也就不存在了。</p><h3 id="但它也有真实的薄弱点"><a href="#但它也有真实的薄弱点" class="headerlink" title="但它也有真实的薄弱点"></a>但它也有真实的薄弱点</h3><p><strong>风险从”消息层”转移到了”连接层”。</strong> 7 周内有 7 次长连接 30 秒超时，以及一次连续 4 分钟的域名解析失败——<strong>自建环境的出网质量成了真正的瓶颈</strong>。好消息是网关有自动重连机制，这几次都自愈了。</p><p><strong>有一次真实丢消息。</strong> 一条 1000 多字的通知被平台以”字段校验失败”拒绝，纯文本兜底也失败了。这类问题属于偶发，我决定先不复现，等它复现频率升高再处理——<strong>不是所有异常都值得立刻投入排查成本，记录在案、设定观察阈值，往往更划算</strong>。</p><h2 id="五、三条通道横向对比"><a href="#五、三条通道横向对比" class="headerlink" title="五、三条通道横向对比"></a>五、三条通道横向对比</h2><table><thead><tr><th>维度</th><th>飞书</th><th>QQ 官方机器人</th><th>微信</th></tr></thead><tbody><tr><td>限流机制</td><td>1000&#x2F;分 &amp; 50&#x2F;秒；同用户 5 QPS</td><td>100 QPS + 严格配额</td><td>无公开阈值，黑箱</td></tr><tr><td>实测限流次数</td><td><strong>0</strong></td><td>未接入</td><td><strong>725</strong></td></tr><tr><td>主动推送</td><td>无限制</td><td>单聊已下线</td><td>需上下文令牌，易失效</td></tr><tr><td>被动回复窗口</td><td>无</td><td>单聊 60 分钟 &#x2F; 4 次</td><td>无明确窗口</td></tr><tr><td>长度上限</td><td>文本 150 KB</td><td>约 2000 字节</td><td>官方 4000 字，5KB+ 被吞</td></tr><tr><td>消息编辑</td><td><strong>支持</strong></td><td>不支持</td><td>不支持</td></tr><tr><td>Markdown</td><td>GFM 完整</td><td>有限，群聊禁 URL</td><td>无</td></tr><tr><td>内容审核</td><td>有（敏感信息拒收）</td><td>安全打击 + URL 报备</td><td>不透明</td></tr><tr><td>适合场景</td><td>长报告 &#x2F; 定时推送 &#x2F; 富文本</td><td>即时短问答</td><td>已停用</td></tr></tbody></table><h2 id="六、结论：一个被低估的分水岭"><a href="#六、结论：一个被低估的分水岭" class="headerlink" title="六、结论：一个被低估的分水岭"></a>六、结论：一个被低估的分水岭</h2><p>回头看这三条通道，最能决定成败的不是”接口频率给得多宽”，而是<strong>平台是否支持编辑已发送的消息</strong>。</p><ul><li>微信和 QQ 都不支持编辑。Agent 想显示进度，只能不断发新消息；用户看到的是一串刷屏，平台看到的是超频调用。<strong>长任务越复杂，越容易撞限流墙。</strong></li><li>飞书支持编辑，同一个气泡原地更新。消息量降一个数量级，限流问题自动消失。</li></ul><p>另一个体会是：<strong>选通道要看自己的用法，而不是看平台的”能力列表”</strong>。我的用法是长任务静默执行 + 完成后一次性汇报 + 定时推送，这套组合直接判了 QQ 死刑（主动推送下线 + 60 分钟窗口）。如果你的用法只是”随时问一句、立刻答一句”，QQ 其实完全够用——它的错误码还比微信透明得多。</p><p>最后，一条工程习惯：<strong>把平台的限制当成设计输入，而不是上线后才发现的意外</strong>。这次评估真正的产出不是”选哪条通道”，而是那张三层证据表——有了实测数据打底，后面再遇到”消息没送到”，至少能立刻回答”这是谁的问题、影响面多大、能不能回滚”。</p>]]>
    </content>
    <id>https://nova02640.github.io/ai-agent-messaging-channels/</id>
    <link href="https://nova02640.github.io/ai-agent-messaging-channels/"/>
    <published>2026-10-01T14:00:00.000Z</published>
    <summary>我的自建 AI Agent 先后跑在微信、飞书和（评估中的）QQ 上。本文用 7 周的日志实测数据 + 适配器源码 + 平台官方文档三层证据，横向对比三条通道的拦截、截断与限制机制，并给出一个关键结论：'平台是否支持消息编辑'才是决定 Agent 长任务能否稳定投递的分水岭。</summary>
    <title>把 AI Agent 接进微信、QQ 和飞书：三条消息通道的实测评估</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="前端开发" scheme="https://nova02640.github.io/tags/%E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="静态站点" scheme="https://nova02640.github.io/tags/%E9%9D%99%E6%80%81%E7%AB%99%E7%82%B9/"/>
    <category term="测试" scheme="https://nova02640.github.io/tags/%E6%B5%8B%E8%AF%95/"/>
    <category term="GitHub Pages" scheme="https://nova02640.github.io/tags/GitHub-Pages/"/>
    <category term="开发记录" scheme="https://nova02640.github.io/tags/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/"/>
    <content>
      <![CDATA[<p>上一篇介绍了成品（<a href="/ai-engineering-quest-launch/">把 523 课的 AI 工程课程，做成一个能闯关的进度站</a>），这一篇记录怎么做出来的——包括一个只有靠真实测试才抓得到的逻辑漏洞，和我踩过的几个”看起来成功其实没验证”的坑。</p><p>成品在这里：<strong><a href="https://nova02640.github.io/ai-engineering-quest/">https://nova02640.github.io/ai-engineering-quest/</a></strong></p><hr><h2 id="一、选题：先确认”不该做什么”"><a href="#一、选题：先确认”不该做什么”" class="headerlink" title="一、选题：先确认”不该做什么”"></a>一、选题：先确认”不该做什么”</h2><p>项目起点是一套很对我胃口的开源课程：<strong>AI Engineering from Scratch</strong>，523 课、20 个阶段、每课都要求你手写一遍算法并留下一个可复用产物。</p><p>我最初的想法和大多数人一样：<strong>做个学习网站</strong>。但动手之前先去读了上游仓库，结论立刻变了——<strong>上游已经有一个功能完整的网站</strong>：523 课在线渲染、每课课后小测、本地进度记录、交互式图表、术语表、学习路径、认证备考题库、12 种语言界面，而且仓库每天都有提交。</p><p>如果我照着做，结果只会是两个问题：功能更少，且要永远追着上游跑。</p><p><strong>于是任务定义从”做一个学习网站”改成了”做一个上游没有的东西”。</strong> 把上游的功能清单逐条列出来对照之后，剩下的空白很清楚：</p><table><thead><tr><th>上游已有</th><th>上游没有</th></tr></thead><tbody><tr><td>每课四个勾选框（read&#x2F;built&#x2F;ran&#x2F;evidence）</td><td>跨关卡的全局进度视角（等级、阶段通关、徽章）</td></tr><tr><td>evidence 只记录”已勾选”的时间戳</td><td>命令 &#x2F; 退出码 &#x2F; 输出 &#x2F; 产物的<strong>证据留存</strong></td></tr><tr><td>随堂测验由 AI 运行时生成</td><td>静态题库（可离线、可重复、可回归测试）</td></tr><tr><td>阅读体验完整</td><td>把”每课交付一个产物”这句话变成作品集</td></tr></tbody></table><p>定位就这么定下来了：<strong>不做课程，做课程的记录层。</strong></p><hr><h2 id="二、架构：薄层，而不是镜像"><a href="#二、架构：薄层，而不是镜像" class="headerlink" title="二、架构：薄层，而不是镜像"></a>二、架构：薄层，而不是镜像</h2><p>第二个决定同样重要：<strong>课程正文一个字都不进我的仓库。</strong></p><p>站点只取元数据——标题、序号、路径、类型、语言、学习目标、<code>outputs/</code> 与 <code>code/</code> 的文件清单；所有阅读和练习深链到官方原文。</p><p>这样做的代价是站内阅读体验不完整（要跳出去看讲义），换来的是：</p><ul><li><strong>维护成本极低</strong>：上游每天改正文，我的站不受影响</li><li><strong>版权性质完全不同</strong>：引用元数据 + 深链是工具；搬运全文是转载</li><li><strong>可一键切割</strong>：上游若改许可，删元数据即可</li></ul><p>页面结构也随之变得很轻——四个静态页 + 一个本地存档引擎：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">index.html        闯关地图（20 阶段 / 523 关 + HUD）</span><br><span class="line">lesson.html       关卡页（四步打卡 / 小测 / 作品登记）</span><br><span class="line">boss.html         阶段 Boss 关（12 题）</span><br><span class="line">portfolio.html    作品集（导出 HTML / Markdown / JSON）</span><br><span class="line">assets/store.js   存档引擎（localStorage + 导入导出）</span><br><span class="line">data/course.json  523 关元数据</span><br><span class="line">data/quiz.json    题库</span><br></pre></td></tr></table></figure><p><strong>没有构建步骤</strong>：纯静态文件直接由 GitHub Actions 上传，失败面比任何构建型站点都小。</p><hr><h2 id="三、数据管线：只取我需要的字段"><a href="#三、数据管线：只取我需要的字段" class="headerlink" title="三、数据管线：只取我需要的字段"></a>三、数据管线：只取我需要的字段</h2><p>元数据分两层拿：</p><p><strong>全量地图（20 阶段 &#x2F; 523 关）</strong>：用 GitHub Tree API 一次拿全量文件路径，从路径推导出阶段与关卡结构；再解析上游 <code>README.md</code> 里的关卡表格，拿到每关的标题、类型（Learn &#x2F; Build）和语言。523 关里有 438 关能从表格解析到，剩下的 85 关全是最后的 capstone 阶段（不在表格里），用路径名兜底即可。</p><p><strong>深做阶段的细节</strong>：只为要深做的那个阶段逐关取 <code>docs/en.md</code>，提取一级标题和 <code>## Learning Objectives</code> 列表。31 关约 492 KB，几十秒拿完。</p><p>这里有个小取舍值得一说：我一度想直接下整个仓库的压缩包，但网络受限环境下镜像通道给的包是<strong>截断</strong>的（两次分别拿到 9.2MB 和 4.6MB，都不是完整包）。与其和下载较劲，不如只取真正需要的字段——<strong>31 个小文件的元数据，比 46MB 的整仓依赖更可靠</strong>。</p><hr><h2 id="四、玩法：口径要照抄课程，不要自创"><a href="#四、玩法：口径要照抄课程，不要自创" class="headerlink" title="四、玩法：口径要照抄课程，不要自创"></a>四、玩法：口径要照抄课程，不要自创</h2><p>游戏化的诱惑是自创一套规则（经验值、等级、成就系统随便设计）。但这里有个约束：这套课程自己已经有一套明确的方法论——<strong>读 → 写 → 跑 → 存证</strong>，而且它的讲义里就是把 evidence 当成学习闭环的终点。</p><p>所以我把关卡的通关口径直接映射到这四步，而不是另造一套：</p><table><thead><tr><th>动作</th><th>XP</th></tr></thead><tbody><tr><td>读完讲义</td><td>10</td></tr><tr><td>手敲并跑通代码</td><td>25</td></tr><tr><td>从仓库根执行本关命令</td><td>20</td></tr><tr><td>留下证据</td><td>30</td></tr><tr><td>每题答对</td><td>15</td></tr></tbody></table><p>每 250 XP 一级；阶段全部通关解锁 Boss 关（12 题，通过线为三分之二）；证据登记进作品集，可导出。</p><p>规则贴合课程本身，用户就不需要在两套心智模型之间切换——这是我在这个项目里最满意的一个决定。</p><hr><h2 id="五、验证：让”我觉得能跑”变成”实测能跑”"><a href="#五、验证：让”我觉得能跑”变成”实测能跑”" class="headerlink" title="五、验证：让”我觉得能跑”变成”实测能跑”"></a>五、验证：让”我觉得能跑”变成”实测能跑”</h2><p>前端项目最容易糊弄自己的地方是：<strong>打开页面点两下，觉得没问题，就发布。</strong> 这次我给自己定了四条硬规矩。</p><h3 id="1-用-Node-在服务器上真跑前端逻辑"><a href="#1-用-Node-在服务器上真跑前端逻辑" class="headerlink" title="1. 用 Node 在服务器上真跑前端逻辑"></a>1. 用 Node 在服务器上真跑前端逻辑</h3><p>纯前端逻辑通常是”要开浏览器才能测”的，但其实只要把浏览器环境 stub 掉，就能在服务器上直接跑：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// tools/test-store.mjs 的核心几行</span></span><br><span class="line">globalThis.<span class="property">localStorage</span> = &#123;</span><br><span class="line">  <span class="attr">getItem</span>: <span class="function">(<span class="params">k</span>) =&gt;</span> (k <span class="keyword">in</span> mem ? mem[k] : <span class="literal">null</span>),</span><br><span class="line">  <span class="attr">setItem</span>: <span class="function">(<span class="params">k, v</span>) =&gt;</span> &#123; mem[k] = <span class="title class_">String</span>(v); &#125;,</span><br><span class="line">  <span class="attr">removeItem</span>: <span class="function">(<span class="params">k</span>) =&gt;</span> &#123; <span class="keyword">delete</span> mem[k]; &#125;</span><br><span class="line">&#125;;</span><br><span class="line">globalThis.<span class="property">window</span> = globalThis;</span><br><span class="line"></span><br><span class="line"><span class="keyword">new</span> <span class="title class_">Function</span>(fs.<span class="title function_">readFileSync</span>(<span class="string">&#x27;assets/store.js&#x27;</span>, <span class="string">&#x27;utf8&#x27;</span>))();   <span class="comment">// 真跑业务逻辑</span></span><br><span class="line"><span class="keyword">const</span> <span class="title class_">Store</span> = globalThis.<span class="property">Store</span>;</span><br><span class="line"><span class="comment">// 然后直接断言：通关判定、XP 计算、徽章、导入导出……</span></span><br></pre></td></tr></table></figure><p>这套 39 条断言的回归测试，<strong>抓出了一个真 bug</strong>：</p><blockquote><p>原来的通关判定，是靠”这一关有没有作答记录”来判断它有没有题库。于是出现了一个荒诞的结果——<strong>有题库的关卡，只要一道题都不答，四个勾打完就算通关</strong>。题库门禁形同虚设，而且从界面上完全看不出来（四勾显示完整、状态显示已通关）。</p></blockquote><p>修法是让加载层把题库索引显式注册给存档层，判定优先级变成「注册索引 &gt; 本机作答记录」：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">hasQuizFor</span>(<span class="params">path, l</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title class_">Object</span>.<span class="property"><span class="keyword">prototype</span></span>.<span class="property">hasOwnProperty</span>.<span class="title function_">call</span>(quizIndex, path)) <span class="keyword">return</span> quizIndex[path] &gt; <span class="number">0</span>;</span><br><span class="line">  <span class="keyword">return</span> !!(l &amp;&amp; <span class="title class_">Object</span>.<span class="title function_">keys</span>(l.<span class="property">quiz</span>).<span class="property">length</span>);   <span class="comment">// 退化路径：本机是否作答过</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>这个 bug 不写测试是发现不了的</strong>——它只在”用户从不答题”这条路径上出现，而开发者自己测试时一定会去点那两道题。</p><h3 id="2-数据一致性测试"><a href="#2-数据一致性测试" class="headerlink" title="2. 数据一致性测试"></a>2. 数据一致性测试</h3><p>第二个测试文件专门校验数据：题库的每个 key 都是真实关卡路径、每关题目数量、ID 唯一性、选项数、答案下标范围、题干语言、以及课程数据与题库的对应关系。480 条断言，全绿才允许提交。</p><h3 id="3-线上逐字节校验，并识别”截断误报”"><a href="#3-线上逐字节校验，并识别”截断误报”" class="headerlink" title="3. 线上逐字节校验，并识别”截断误报”"></a>3. 线上逐字节校验，并识别”截断误报”</h3><p>部署完的验收不能只看首页 200。我的做法是把关键文件在线上取回来，和本地比 <strong>sha256</strong>。</p><p>第一次校验就出现了一个可疑数字：<code>data/course.json</code> 线上读到 <strong>99681 字节</strong>，本地是 <strong>162046 字节</strong>。</p><p>看起来像产物损坏，但这其实是 CDN 单次传输截断的经典指纹。判定口径有三条：</p><ul><li>连续多次取回，看大小是否<strong>每次都不一样</strong>（截断的特征）</li><li><code>wget -c</code> 续传后比对 sha256</li><li>用 <code>curl -r 0-0 -D -</code> 读响应头的 <code>content-range: bytes 0-0/162046</code>，直接拿到真实总长</li></ul><p>复核结果：重取四次全部 162046 字节，sha256 与本地完全一致，JSON 也解析正常（20 阶段 &#x2F; 523 关都在）。<strong>问题在取数，不在产物。</strong> 如果当时直接下结论”文件坏了”，就会白查一轮。</p><h3 id="4-不要相信”子代理”自报的数字"><a href="#4-不要相信”子代理”自报的数字" class="headerlink" title="4. 不要相信”子代理”自报的数字"></a>4. 不要相信”子代理”自报的数字</h3><p>题库我拆成三个并行的 AI 子任务分工出题，每个任务完成后回报：”共 26 题 &#x2F; 24 题 &#x2F; 20 题”。</p><p>合并前我按 schema 自己复算了一遍：<strong>实测是每关正好 2 题，总共 62 题。</strong></p><p>三个数字全是错的（它们把自己的计数算重了）。如果我直接引用自报数字写进 README，就会把一个假数据固化进项目文档。<strong>凡是子任务交付结构化数据，合并前必须自己复算：覆盖度、条数、唯一性、字段范围，一个都不能省。</strong></p><hr><h2 id="六、版权与边界"><a href="#六、版权与边界" class="headerlink" title="六、版权与边界"></a>六、版权与边界</h2><p>上游是 MIT 许可，允许复用，但”允许”不等于”可以随便抄”。这个项目的处理是三件事：</p><ol><li><strong>署名齐全</strong>：仓库 <code>ATTRIBUTION.md</code> + 站点页脚 + 首页「关于」区，三处都写明上游仓库、作者、MIT 许可原文</li><li><strong>零正文镜像</strong>：只引元数据，深链原文，不复制图表、图片、界面文案</li><li><strong>不冒名</strong>：不使用上游的名称和 Logo 作为自己的品牌（MIT 覆盖代码，不覆盖商标），并明确声明本项目与上游没有官方关系</li></ol><p>另外一个细节：题库是我自己出的（上游的随堂测验是运行时生成的，没有静态题库）。出题依据是公开讲义，但题目和解析都是转述，不是抄录。合并前我做了一次机械检查——把每道题的文本和讲义原文做”连续 N 字符重合”扫描，命中的三处全部是英文 API&#x2F;代码片段（函数签名、HTTP 响应头这类），属于必须原样出现的技术术语，不是复制正文。</p><hr><h2 id="七、隐私与安全：写公开技术文章的自律"><a href="#七、隐私与安全：写公开技术文章的自律" class="headerlink" title="七、隐私与安全：写公开技术文章的自律"></a>七、隐私与安全：写公开技术文章的自律</h2><p>这篇是开发记录，很容易顺手写出不该写的东西。发布前我按清单逐项过了一遍，规则是：</p><p><strong>不出现的：</strong></p><ul><li>内网 IP、主机名、机器别名</li><li>内部存储路径（挂载点、卷目录这一类）</li><li>API key &#x2F; token &#x2F; 密钥 &#x2F; 任何服务凭据与访问链接</li><li>内部工具名、任务日志、会话内容</li></ul><p><strong>泛化处理的：</strong></p><ul><li>环境叙述只说”一台内网 Linux 主机””服务器”，不写型号、地址、主机名</li><li>涉及网络受限时，只描述方法与结论（走哪个 API、为什么选小文件而不是整包），不写代理方案细节</li><li>配置示例一律用占位符（<code>&lt;token&gt;</code>、<code>&lt;host&gt;</code>），绝不贴真实值</li></ul><p><strong>顺手做的一次历史检查：</strong> 我扫了一遍已发布的老文章，确认此前写过的网络类文章里只有通用配置，没有凭证、服务地址或内网信息——没有泄露。这个检查值得定期做，因为”当时看起来无害的配置片段”，很多是事后才意识到敏感的。</p><hr><h2 id="八、反思"><a href="#八、反思" class="headerlink" title="八、反思"></a>八、反思</h2><p><strong>做对的两件事：</strong></p><ol><li><strong>先查上游再动手</strong>。如果一开始就闷头做”学习网站”，两天的成果就是一个注定要废弃的镜像站。</li><li><strong>口径照抄课程</strong>。不自创规则，用户不需要在两套心智模型之间切换。</li></ol><p><strong>做得不够的一件事：</strong></p><p>形态这件事，我其实是先做完再给使用者看的。更省钱的做法是<strong>先做一个阶段的小样</strong>（一个阶段 20~30 关，真实内容 + 完整交互），确认形态和信息密度之后再铺量——这个项目里我只深做了一个阶段就停手，还算踩在线内，但下一个项目我打算把这个顺序固定下来。</p><hr><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>如果你也在做同类项目，这篇里最值得拿走的大概是两条：</p><ul><li><strong>上游已经有的，不要重做</strong>；把上游的功能清单列出来，差异化的空间自然浮现</li><li><strong>纯前端逻辑也可以有真回归</strong>：stub 掉浏览器环境，在服务器上跑断言，它救我的那一次就是最好的理由</li></ul><p>站点：<strong><a href="https://nova02640.github.io/ai-engineering-quest/">https://nova02640.github.io/ai-engineering-quest/</a></strong></p>]]>
    </content>
    <id>https://nova02640.github.io/ai-engineering-quest-devlog/</id>
    <link href="https://nova02640.github.io/ai-engineering-quest-devlog/"/>
    <published>2026-09-27T17:25:00.000Z</published>
    <summary>记录把一套 523 课的开源课程改造成闯关进度站的完整过程：为什么放弃「重做一个学习网站」、为什么选元数据薄层而不是镜像、怎么用 Node 在服务器上真跑前端逻辑并因此抓出一个通关判定漏洞，以及让线上校验不作假的几个细节。文末附公开技术文章的隐私与安全自律清单。</summary>
    <title>一个闯关式学习站的开发记录：选题、架构，和一个被测试抓出来的真 bug</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="静态站点" scheme="https://nova02640.github.io/tags/%E9%9D%99%E6%80%81%E7%AB%99%E7%82%B9/"/>
    <category term="GitHub Pages" scheme="https://nova02640.github.io/tags/GitHub-Pages/"/>
    <category term="AI 工程" scheme="https://nova02640.github.io/tags/AI-%E5%B7%A5%E7%A8%8B/"/>
    <category term="开源项目" scheme="https://nova02640.github.io/tags/%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE/"/>
    <category term="学习工具" scheme="https://nova02640.github.io/tags/%E5%AD%A6%E4%B9%A0%E5%B7%A5%E5%85%B7/"/>
    <content>
      <![CDATA[<h2 id="先说结论"><a href="#先说结论" class="headerlink" title="先说结论"></a>先说结论</h2><p>我把一套 523 课的 AI 工程开源课程，做成了一个能”闯关”的进度站：</p><ul><li>站点：<strong><a href="https://nova02640.github.io/ai-engineering-quest/">https://nova02640.github.io/ai-engineering-quest/</a></strong></li><li>仓库：<strong><a href="https://github.com/nova02640/ai-engineering-quest">https://github.com/nova02640/ai-engineering-quest</a></strong></li></ul><p>它不是课程的重做版，而是给课程补上<strong>进度感</strong>和<strong>作品感</strong>的那一层。全文讲清楚三件事：这套课程是什么、这一层补了什么、以及它为什么刻意不搬运任何课程正文。</p><hr><h2 id="这套课程本身，值得认真对待"><a href="#这套课程本身，值得认真对待" class="headerlink" title="这套课程本身，值得认真对待"></a>这套课程本身，值得认真对待</h2><p>课程是 <strong>AI Engineering from Scratch</strong>（作者 Rohit Ghumare，MIT 许可）：</p><table><thead><tr><th>项</th><th>值</th></tr></thead><tbody><tr><td>体量</td><td><strong>523 课 &#x2F; 20 个阶段</strong>，约 342 小时的学习量</td></tr><tr><td>覆盖</td><td>数学基础 → 机器学习 → 深度学习 → NLP&#x2F;语音&#x2F;视觉 → Transformer → 从零实现 LLM → LLM 应用工程 → 工具与协议 → 智能体 → 多智能体 → 生产基础设施</td></tr><tr><td>语言</td><td>Python、TypeScript、Rust、Julia</td></tr><tr><td>结构</td><td>每课固定三件套：<code>code/</code>（可运行实现）+ <code>docs/en.md</code>（讲义）+ <code>outputs/</code>（本课产出的 prompt &#x2F; skill &#x2F; agent &#x2F; MCP server）</td></tr><tr><td>许可</td><td>MIT</td></tr></tbody></table><p>它最大的特点是**”先手写、再用库”**：每个算法都要求你从原始数学推一遍（反向传播、分词器、注意力、Agent 循环），等你用上 PyTorch 的时候，已经知道它在底下做什么了。</p><p>这套课程自带一套很硬的方法论，讲义里把它写成了四步循环：</p><blockquote><p><strong>读</strong> → <strong>写</strong> → <strong>跑</strong> → <strong>存证</strong><br>（read &#x2F; built &#x2F; ran &#x2F; evidence）</p></blockquote><p>问题也恰恰在这里：<strong>523 课不是一次能学完的东西</strong>，而课程本身没有给你一个”我走到哪了”的全局视图。你学完一关，成就感只存在于当天的终端输出里。</p><hr><h2 id="所以我没有做”学习网站”"><a href="#所以我没有做”学习网站”" class="headerlink" title="所以我没有做”学习网站”"></a>所以我没有做”学习网站”</h2><p>先把一句话说清楚，因为它决定了整个架构：</p><p><strong>上游已经有一个功能完整的学习网站了。</strong> 官方站有 523 课的在线渲染、每课课后小测、本地进度记录、交互式图表、术语表、学习路径、认证备考题库，还有 12 种语言的界面。</p><p>所以如果我再做一个”学习网站”，那只会是一个功能更少、且要永远追着上游更新的镜像。这不是产品，这是负债。</p><p><strong>上游真正空着的是这两件事：</strong></p><ol><li><strong>进度的全局感</strong>：官方进度是每课四个勾选框。有勾选，但没有”我打到第几关了””这个阶段离通关还差多少”这种闯关视角。</li><li><strong>证据留不下来</strong>：官方那一步叫 evidence，但实际只记录了一个”已勾选”的时间戳——你当时跑了什么命令、退出码是多少、产出了什么文件，都没有地方存。</li></ol><p>而<strong>作品</strong>这件事，恰恰是这套课程反复强调的：”Every lesson ships a reusable artifact.”——每课都交付一个可复用的产物。课程这么说，却没有帮你把这些产物收集起来。</p><p>于是这个站的定位就很清楚了：<strong>不做课程，做课程的记录层。</strong></p><hr><h2 id="这一层具体是什么"><a href="#这一层具体是什么" class="headerlink" title="这一层具体是什么"></a>这一层具体是什么</h2><h3 id="一、闯关地图"><a href="#一、闯关地图" class="headerlink" title="一、闯关地图"></a>一、闯关地图</h3><p>20 个阶段、523 关全部列出来，每关是一张卡片，四个小格显示四步的完成情况，颜色区分「没开始 &#x2F; 进行中 &#x2F; 已通关」。顶部是等级、XP、总进度和徽章数。</p><h3 id="二、关卡页：四步打卡-小测-作品登记"><a href="#二、关卡页：四步打卡-小测-作品登记" class="headerlink" title="二、关卡页：四步打卡 + 小测 + 作品登记"></a>二、关卡页：四步打卡 + 小测 + 作品登记</h3><p>每一关有：</p><ul><li><strong>四步打卡</strong>：读 &#x2F; 写 &#x2F; 跑 &#x2F; 存证，口径完全照抄课程自己的方法论</li><li><strong>一键跳原文</strong>：官方讲义页和 GitHub 源码目录，你不用在站内找路</li><li><strong>关卡小测</strong>：一概念一实践两道选择题，答对立刻给解析</li><li><strong>作品登记</strong>：命令、退出码、关键输出、产物位置、一句话心得</li></ul><p>关卡页还会根据关卡语言自动给出建议运行命令（Python 给 <code>python3 ...</code>，TypeScript 给 <code>npx tsx ...</code>），直接复制就能跑。</p><h3 id="三、Boss-关"><a href="#三、Boss-关" class="headerlink" title="三、Boss 关"></a>三、Boss 关</h3><p>每个阶段清完之后，解锁该阶段的 <strong>Boss 关</strong>：12 道综合题，覆盖这一阶段的不同关卡，答对 8 题（三分之二）算通过，通过得一枚阶段徽章。徽章不会因为之后刷低分被撤销。也可以提前挑战，只是会提醒你还有几关没通。</p><h3 id="四、作品集"><a href="#四、作品集" class="headerlink" title="四、作品集"></a>四、作品集</h3><p>所有登记过的证据会汇成作品集，按阶段聚合，并且可以一键导出：</p><ul><li><strong>单文件 HTML</strong>：自带样式的独立页面，可以直接发给别人或丢到任何地方</li><li><strong>Markdown</strong>：适合贴进博客、周报</li><li><strong>JSON</strong>：存档，用于跨设备同步</li></ul><p>进度存在浏览器本地（<code>localStorage</code>），不上传任何数据；换设备就导出 JSON 再导入。</p><h3 id="玩法口径（照抄课程，而不是自创）"><a href="#玩法口径（照抄课程，而不是自创）" class="headerlink" title="玩法口径（照抄课程，而不是自创）"></a>玩法口径（照抄课程，而不是自创）</h3><table><thead><tr><th>动作</th><th>XP</th></tr></thead><tbody><tr><td>读完讲义</td><td>10</td></tr><tr><td>手敲并跑通代码</td><td>25</td></tr><tr><td>从仓库根执行本关命令</td><td>20</td></tr><tr><td>留下证据</td><td>30</td></tr><tr><td>每题答对</td><td>15</td></tr></tbody></table><p>每 250 XP 升一级。<strong>有题库的关卡，必须至少答对一题才算通关</strong>——四步全勾但不能白嫖（这条规则背后有个真实 bug，我在开发记录里写了）。</p><hr><h2 id="边界：只取元数据，不搬运正文"><a href="#边界：只取元数据，不搬运正文" class="headerlink" title="边界：只取元数据，不搬运正文"></a>边界：只取元数据，不搬运正文</h2><p>这是这个项目最重要的一个决定。</p><p>站点用到的课程信息只有：<strong>关卡标题、序号、路径、类型（Learn &#x2F; Build）、语言、学习目标、<code>outputs/</code> 和 <code>code/</code> 的文件清单</strong>。所有阅读和练习都指向官方原文——站内<strong>没有一个字的讲义正文</strong>，也没有复制上游的图表、图片和界面文案。</p><p>这么做有三层好处：</p><ol><li><strong>版权上干净</strong>：MIT 允许复用，但”引用元数据 + 深链原文”和”整站镜像正文”是两件性质完全不同的事。前者是工具，后者是转载。站点页脚、关于区和仓库的 <code>ATTRIBUTION.md</code> 三处都写明上游作者与许可，也不沿用上游的名称和 Logo 做自己的品牌。</li><li><strong>不用追更新</strong>：上游仓库每天有多次提交。我只依赖元数据，所以上游改正文，这个站不会失效。</li><li><strong>可一键切割</strong>：万一上游改许可或对引用方式有异议，删掉对应的元数据就行，站点不会塌。</li></ol><p>站内题库是我自己出的（上游的随堂测验是运行时由 AI 现场生成的，并没有静态题库），出题依据是公开讲义，题目和解析都是转述。</p><hr><h2 id="现在能用到什么程度"><a href="#现在能用到什么程度" class="headerlink" title="现在能用到什么程度"></a>现在能用到什么程度</h2><p>说清楚当前状态，避免误解：</p><ul><li><strong>Phase 13（工具与协议，31 关）是深做的</strong>：有学习目标、有每关小测（62 题）、有 Boss 关题库（12 题）</li><li><strong>其余 19 个阶段是元数据模式</strong>：关卡清单、类型、语言齐全，可以用四步打卡通关，但没有小测和 Boss 题库</li></ul><p>也就是说，除 Phase 13 之外的阶段，你能用它看进度、打勾、登记作品，但不能答题闯关。要不要把题库铺满 20 个阶段，我打算先看自己实际用一段时间的感受再决定——这种项目最容易犯的错，是一口气把全量内容铺完，结果发现形态不对，全部沉没。</p><hr><h2 id="怎么上手"><a href="#怎么上手" class="headerlink" title="怎么上手"></a>怎么上手</h2><ol><li>打开 <strong><a href="https://nova02640.github.io/ai-engineering-quest/">https://nova02640.github.io/ai-engineering-quest/</a></strong></li><li>点开 Phase 13，从第一关开始，先点「官方讲义页」把这一课读完</li><li>手敲代码、跑通、回到关卡页把四步勾上、答完小测、登记一条证据</li></ol><p>就这样。523 关很长，但至少现在，你能看见自己走到哪了，也能把走过的路拿出来给人看。</p><hr><h2 id="声明"><a href="#声明" class="headerlink" title="声明"></a>声明</h2><p>这是一个<strong>独立个人项目</strong>，与上游作者和课程官方没有任何隶属或背书关系。课程内容、课程名称与原始讲义的一切权利归原作者 Rohit Ghumare 所有（MIT 许可）。本站代码同样以 MIT 发布。</p><p>如果上游作者认为本站的引用方式有任何不妥，通过仓库 Issue 联系，我会立即下线相关内容。</p>]]>
    </content>
    <id>https://nova02640.github.io/ai-engineering-quest-launch/</id>
    <link href="https://nova02640.github.io/ai-engineering-quest-launch/"/>
    <published>2026-09-27T17:15:00.000Z</published>
    <summary>AI Engineering from Scratch 是一套 523 课、20 个阶段的开源课程，体量大到容易半途而废。我给它加了一层它自己没有的东西：闯关进度 + 作品集登记，做成纯前端静态站部署在 GitHub Pages。本文介绍这个站能干什么、玩法口径怎么定、以及为什么它只取元数据而不搬运任何正文。</summary>
    <title>把 523 课的 AI 工程课程，做成一个能闯关的进度站</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="AI" scheme="https://nova02640.github.io/tags/AI/"/>
    <category term="DeepSeek" scheme="https://nova02640.github.io/tags/DeepSeek/"/>
    <category term="Agent" scheme="https://nova02640.github.io/tags/Agent/"/>
    <category term="基准测试" scheme="https://nova02640.github.io/tags/%E5%9F%BA%E5%87%86%E6%B5%8B%E8%AF%95/"/>
    <category term="实测" scheme="https://nova02640.github.io/tags/%E5%AE%9E%E6%B5%8B/"/>
    <content>
      <![CDATA[<h2 id="引子"><a href="#引子" class="headerlink" title="引子"></a>引子</h2><p>之前写过 DeepSeek Harness（dsh）的架构拆解和真实任务实测，当时的结局是：能用，但在快速迭代期没留下，我把它清掉了。</p><p>这次它以<strong>另一种形态</strong>回来——官方发布了 Python SDK，而且宣传点很具体：<strong>会话状态、用会话 UUID 复用上下文来省 token</strong>。</p><p>这个点正好戳中日常委托编码任务的痛点：每次让代理干活都从零开始，同一份上下文反复付费。于是我没有直接采信，而是搭了一套可复现的对照实验，把它和我日常在用的 OpenCode 拉到<strong>同一张考卷</strong>上。</p><p>结论先说：<strong>同题两轮小任务，两边都真做对了，dsh 略快略省；换成三轮递进的大任务，dsh 默认反而慢 1.5 倍、贵 3 倍</strong>。而”会话复用省 token”这件事，只在<strong>同一个进程内</strong>成立——那条最诱人的卖点，被我实测卡掉了。</p><p>下面是完整的取证过程。</p><h2 id="一、先分清：这是另一条通道"><a href="#一、先分清：这是另一条通道" class="headerlink" title="一、先分清：这是另一条通道"></a>一、先分清：这是另一条通道</h2><p>第一件事是别把新旧两次部署搞混。它们虽然都叫 dsh、都用同一个模型，但形态完全不同：</p><table><thead><tr><th></th><th>上次（CLI 包）</th><th>这次（Python SDK）</th></tr></thead><tbody><tr><td>来源</td><td>npm 包 <code>@deepseek-ai/dsh</code></td><td>PyPI <code>deepseek-harness-sdk</code></td></tr><tr><td>运行时</td><td>需要系统 Node ≥ 22.19</td><td><strong>自带原生可执行文件，不需要 Node</strong></td></tr><tr><td>调用形态</td><td><code>dsh --profile headless &quot;任务&quot;</code></td><td>Python 子进程 JSON-RPC（stdio）</td></tr><tr><td>会话</td><td>一次性，无跟进</td><td>有会话对象与事件流</td></tr></tbody></table><p>有个细节值得一提：PyPI 上存在一个<strong>同名无关包</strong>叫 <code>deepseek-harness</code>，纯粹是蹭名字的；官方的是带 <code>-sdk</code> &#x2F; <code>-runtime-bin</code> 后缀那两个。选包时认准作者和仓库链接。</p><p>SDK 的本质是<strong>子进程驱动</strong>：<code>DeepSeekHarness(...)</code> 在后台拉起打包好的 <code>dsh --profile &lt;profile&gt;</code>，然后通过 stdio 上的 JSON-RPC 通信。所以它并不是”一个新的 agent”，而是<strong>同一套 agent 的程序化接口</strong>——真正变的，是打包方式和你能拿到多少控制权。</p><p>顺带一个实打实的优点：<strong>每次调用的 token 用量可以从事件流里取出来</strong>（输入 &#x2F; 缓存命中 &#x2F; 输出 &#x2F; 思考 token 分列），这让成本可以精确核算，而不是靠感觉。</p><h2 id="二、能不能装：环境门槛与一个真实的坑"><a href="#二、能不能装：环境门槛与一个真实的坑" class="headerlink" title="二、能不能装：环境门槛与一个真实的坑"></a>二、能不能装：环境门槛与一个真实的坑</h2><p>官方给的门槛不高：64 位 Linux、glibc ≥ 2.28、Python ≥ 3.10。我手头那台内网 x86 Debian 小主机是 glibc 2.41 + Python 3.13，全部满足。</p><p>真正的坑在网络上：</p><ul><li><strong>PyPI 直连被重置</strong>（<code>curl: (35) Recv failure: 连接被对方重置</code>），而且不只是索引页——大文件下载会在中途断流（实测下到 452KB 就断）。</li><li>解决办法是换镜像源，一次装完。顺带发现系统 Python 不自带 pip，得先补一个 <code>python3-venv</code> 包。</li></ul><p>装成之后的体积：虚拟环境约 290MB，其中绝大部分是那个<strong>274MB 的原生运行时单文件</strong>；此外每个工作目录一份的 harness home 只占几 MB。</p><p>凭据处理上沿用了一贯做法：<strong>API key 通过环境变量注入，不落盘</strong>。冒烟测试 3.9 秒返回预期输出，退出状态正常。</p><p>⚠️ 一个必须说清的风险：当前版本是 <strong>0.1.5-rc.1</strong>，属于 release candidate，官方明确声明会有破坏性变更，迭代速度极快。选它做生产依赖要接受这个前提。</p><h2 id="三、怎么测才算数：把”感觉”换成”判据”"><a href="#三、怎么测才算数：把”感觉”换成”判据”" class="headerlink" title="三、怎么测才算数：把”感觉”换成”判据”"></a>三、怎么测才算数：把”感觉”换成”判据”</h2><p>在讨论”效果好不好”之前，得先解决一个问题：<strong>我们凭什么相信结果？</strong></p><p>搞过编码代理的人都知道，这类工具最大的风险不是”做不出来”，而是**”说自己做出来了”**。之前实测里就见过”报 Done + 退出码 0，产物其实不可用”的情况。</p><p>所以这次我把评估搭成了这样：</p><ol><li><strong>每个任务都自带验收闸门</strong>：一份可执行测试脚本，通过对 &#x2F; 不通过是二值的。</li><li><strong>期望值靠”预埋答案”生成</strong>：先自己写一份参考实现，用它算出期望值写进闸门——而不是让被测对象自己说”我算对了”。</li><li><strong>闸门本身先验证</strong>：参考实现必须 100% 通过闸门，空实现必须全挂。两者都成立，判据才算有效。（这一步经常被跳过，但它是整个评估的地基。）</li><li><strong>同种子、同 prompt、同验收文件的隔离工作区</strong>：两个候选各拿一份完全相同的副本，互不污染。</li><li><strong>验收文件哈希校验</strong>：跑完之后比对验收脚本和期望值的哈希，确认没被篡改——防止”改测试来通过测试”。</li><li><strong>所有闸门由我亲手复跑</strong>，不看代理的自述。</li></ol><p>这套流程后来被证明是值得的：本轮的每一个结论都建立在”我自己跑出来的绿灯”上，而不是任何一句自我汇报。</p><p>第一个任务很小：一个购物车计价模块，我植入了三处 bug（档位边界用了 <code>&gt;</code> 而非 <code>&gt;=</code>、金额用向下取整而非四舍五入、优惠券在税后扣且没做零下限），要求修好使 <code>9/9</code> 用例通过。基线确认：<strong>植入 bug 的版本 0&#x2F;9 全挂</strong>，判别力足够。</p><h2 id="四、第一个反直觉发现：会话复用只在同一个进程里成立"><a href="#四、第一个反直觉发现：会话复用只在同一个进程里成立" class="headerlink" title="四、第一个反直觉发现：会话复用只在同一个进程里成立"></a>四、第一个反直觉发现：会话复用只在同一个进程里成立</h2><p>这是整轮评估最关键的结论，也是我被”宣传点”误导的地方。</p><h3 id="同一个进程内：有效，而且收益明显"><a href="#同一个进程内：有效，而且收益明显" class="headerlink" title="同一个进程内：有效，而且收益明显"></a>同一个进程内：有效，而且收益明显</h3><p>在<strong>一个进程里</strong>用同一个会话连着做两轮（第一轮修 bug，第二轮在已经改好的代码上追加一个新函数）：</p><table><thead><tr><th>轮次</th><th>耗时</th><th>新鲜输入 token</th><th>缓存命中</th><th>输出</th></tr></thead><tbody><tr><td>第 1 轮（冷）</td><td>25.3s</td><td>4,351</td><td>31,744</td><td>2,165</td></tr><tr><td>第 2 轮（同会话续做）</td><td><strong>5.2s</strong></td><td><strong>1,696</strong></td><td>26,240</td><td>608</td></tr></tbody></table><p>对照组是”同样的第二轮任务，但开一个全新会话”：需要 <strong>7,878<del>12,715 新鲜输入 token、31</del>45 秒</strong>。</p><p>也就是说：<strong>续用会话让新鲜输入降到约 1&#x2F;4.6，耗时降到约 1&#x2F;6</strong>。宣传里说的收益，在这个范围内是真的。</p><h3 id="跨进程：被明确拒绝"><a href="#跨进程：被明确拒绝" class="headerlink" title="跨进程：被明确拒绝"></a>跨进程：被明确拒绝</h3><p>问题来了。真实的自动化场景里，任务不是在一个长进程里跑完的——每次委托往往是一次独立的调用。于是我试了在新进程里复用同一个会话 ID：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">h.run(prompt, session_id=&quot;...&quot;)        → JsonRpcError: session &quot;...&quot; already exists</span><br><span class="line">h.start_session(&quot;...&quot;).run(prompt)     → JsonRpcError: session &quot;...&quot; already exists</span><br><span class="line">删掉会话锁文件后重试                     → 同样报错（不是残留锁的问题）</span><br></pre></td></tr></table></figure><p>再往下探，直接问底层 RPC：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">session/list   → unknown DeepSeek Harness SDK runtime method</span><br><span class="line">session/resume → unknown DeepSeek Harness SDK runtime method</span><br><span class="line">session/load   → unknown DeepSeek Harness SDK runtime method</span><br></pre></td></tr></table></figure><p>而用 <code>strings</code> 翻那个 274MB 的运行时二进制，<code>session/resume</code>、<code>session/load</code>、<code>session/list</code> 这些符号<strong>明明存在</strong>——它们属于 CLI 那一侧的完整表面，只是 <strong>SDK 运行时把这条路封掉了</strong>。</p><p>结论很清楚：<strong>SDK 的会话对象只在一个 harness 进程的生命周期内有效</strong>。磁盘上那些 JSONL 会话日志是审计 &#x2F; 重放用的，不是可续用的检查点。官方文档那句“复用 harness、home 和 id 来延续同一段对话”，真实含义就是这个限定范围——但它太容易被读成“随时可以续上”。</p><h3 id="那对照组的-OpenCode-呢？"><a href="#那对照组的-OpenCode-呢？" class="headerlink" title="那对照组的 OpenCode 呢？"></a>那对照组的 OpenCode 呢？</h3><p>同样的问题抛给 OpenCode（它支持 <code>--session &lt;id&gt;</code>）：<strong>新起一个进程，不读任何文件，仅凭会话历史回答”上一轮你新增的函数叫什么”</strong>——</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">&gt; build · deepseek-v4-flash</span><br><span class="line">`summaryText`，内部调用了已有的 `total` 函数。</span><br></pre></td></tr></table></figure><p>答对了。所以在这个具体维度上，<strong>我一直用的 OpenCode 反而是更强的一方</strong>。</p><h2 id="五、顺手抓了一次包：请求里到底发了什么"><a href="#五、顺手抓了一次包：请求里到底发了什么" class="headerlink" title="五、顺手抓了一次包：请求里到底发了什么"></a>五、顺手抓了一次包：请求里到底发了什么</h2><p>既然已经装好了，我顺手做了一件平时容易忽略的事：<strong>把 SDK 的请求指向一个本地 mock 端点，把原始请求体抓下来看</strong>。</p><p>（方法很土但有效：起一个假的 OpenAI 兼容接口，把收到的 body 落盘，再返回一段合法的流式响应。）</p><p>抓到的请求顶层字段：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">dsh_plugin_packages, max_tokens, messages, model,</span><br><span class="line">reasoning_effort, stream, stream_options, thinking, tools</span><br></pre></td></tr></table></figure><p>两点发现：</p><ul><li>✅ <strong>默认不上传会话日志</strong>——请求里没有 <code>dsh_session_log</code> 字段。有意思的是，官方两份文档对此<strong>说法互相矛盾</strong>（一份说默认关闭，另一份的教程说默认会上传未接受的日志后缀）。以实测为准：默认不发。想彻底排除可以在 profile 配置里显式关掉。</li><li>⚠️ 但每个请求都会带一个 <strong><code>dsh_plugin_packages</code></strong> 字段，约 1.8KB，内容是插件名 + 版本清单。它不是对话内容，但确实是把本机环境信息随请求发出去了。</li></ul><p>另外两个默认值也值得知道：<strong>默认开启 thinking</strong>，且 <code>reasoning_effort</code> 默认是 <code>high</code>。这个默认值，在第七节变成了钱。</p><h2 id="六、把短板补上：一个常驻-worker-原型"><a href="#六、把短板补上：一个常驻-worker-原型" class="headerlink" title="六、把短板补上：一个常驻 worker 原型"></a>六、把短板补上：一个常驻 worker 原型</h2><p>既然裸 SDK 的问题是”会话活不过进程”，那答案就很直接：<strong>让进程活着</strong>。</p><p>我写了一个小原型：</p><ul><li><strong>worker</strong>：常驻服务，监听 Unix socket，按 <code>(profile, 工作区)</code> 复用 harness——同一个工作区复用同一个 harness 进程，不同工作区各起一个；同一 harness 串行、不同 harness 并行；对外提供 <code>run / ping / sessions / shutdown</code>。</li><li><strong>client</strong>：一个极瘦的客户端，<strong>一个进程发一个请求，用完即退</strong>。会话由 worker 持有。</li></ul><p>这样一来，”调用方进程”和”会话持有者”解耦了。实测三个<strong>完全独立的客户端进程</strong>共用一个会话：</p><table><thead><tr><th>调用</th><th>内容</th><th>耗时</th><th>新鲜输入</th><th>我的验收</th></tr></thead><tbody><tr><td>进程 1</td><td>修 3 处 bug</td><td>9.7s</td><td>2,438</td><td>9&#x2F;9 通过 ✅</td></tr><tr><td>进程 2</td><td>同会话追加一个函数</td><td>6.1s</td><td>1,393</td><td>双闸门通过 ✅</td></tr><tr><td>进程 3</td><td>只凭历史回忆上轮改了什么</td><td><strong>1.0s</strong></td><td><strong>188</strong></td><td>答对 ✅</td></tr></tbody></table><p>worker 日志确认 harness <strong>只启动了一次</strong>（2.3 秒），后面全部复用。第 3 次调用几乎不花输入 token——因为它要的东西全都还在缓存里。</p><p><strong>这就是那个缺失能力的补法</strong>：不指望 SDK 支持跨进程，而是自己维护一个长期活着的进程。这个思路对所有”启动成本高、会话有状态”的 CLI 代理都通用。</p><h2 id="七、换大任务再测：数字翻转了"><a href="#七、换大任务再测：数字翻转了" class="headerlink" title="七、换大任务再测：数字翻转了"></a>七、换大任务再测：数字翻转了</h2><p>小任务上两边旗鼓相当，我不放心——<strong>小任务跑得好，不代表大任务跑得好</strong>，这几乎是我对这类工具最深的偏见来源。所以第二轮我做了一个真实一点的任务：</p><p><strong>一个空白 Node 项目，分三轮递进交付：</strong></p><ol><li>写一个脏 CSV 解析器（要处理 BOM、CRLF、引号内的逗号和换行、<code>&quot;&quot;</code> 转义、空字段补位、末尾空行）</li><li>在解析器之上做分组聚合（count &#x2F; sum &#x2F; avg &#x2F; min &#x2F; max、空值边界、分组排序）</li><li>再做命令行入口（复用前两个模块、三种错误退出码、单行 JSON 输出）</li></ol><p>每一轮配一个独立闸门（分别 3、4、10 项断言），期望值同样由参考实现预埋生成。</p><table><thead><tr><th>通道</th><th>三轮耗时</th><th>闸门（我复跑）</th><th>基准被篡改</th><th>成本</th></tr></thead><tbody><tr><td>dsh SDK（常驻 worker）</td><td>70 + 28 + 23 &#x3D; 121s</td><td>3&#x2F;3 · 4&#x2F;4 · 10&#x2F;10 ✅</td><td>无 ✅</td><td><strong>$0.0160</strong></td></tr><tr><td>dsh SDK（<code>reasoning_effort=low</code>）</td><td>78 + 13 + 17 &#x3D; 107s</td><td>全过 ✅</td><td>无 ✅</td><td><strong>$0.0083</strong></td></tr><tr><td>OpenCode</td><td><strong>27 + 24 + 27 &#x3D; 78s</strong></td><td>全过 ✅</td><td>无 ✅</td><td><strong>$0.0052</strong></td></tr></tbody></table><p><strong>两者都真做对了</strong>——三轮闸门全绿，交付物规模相当（各约 150~190 行有效代码）。但代价完全不同：<strong>dsh 默认慢 1.5 倍、贵 3.1 倍</strong>。</p><h3 id="钱花在哪儿了"><a href="#钱花在哪儿了" class="headerlink" title="钱花在哪儿了"></a>钱花在哪儿了</h3><p>翻 token 明细就一目了然：dsh 的总输出 token 是 <strong>22,831</strong>，其中 <strong>17,619（77%）是思考 token</strong>；OpenCode 同任务输出只有 4,200。前三节里那个”默认 <code>reasoning_effort: high</code>“，在这里变成了账单。</p><p>把 <code>reasoning_effort</code> 调成 <code>low</code> 之后：<strong>成本降 48%</strong>（$0.0160 → $0.0083），质量不掉（三轮闸门照样全过），但<strong>仍然比 OpenCode 贵 1.6 倍、慢 1.4 倍</strong>。</p><h3 id="成本是怎么算的（顺便验证了口径）"><a href="#成本是怎么算的（顺便验证了口径）" class="headerlink" title="成本是怎么算的（顺便验证了口径）"></a>成本是怎么算的（顺便验证了口径）</h3><p>dsh 不上报美元金额，只给 token。所以我用官方公布的单价（缓存命中 &#x2F; 未命中 &#x2F; 输出三档）自己算。为了确认口径没错，我拿 OpenCode 自报的成本做了反向对账：</p><blockquote><p>按公布单价的”谷值”档计算 → 得到 $0.0052，<strong>与 OpenCode 自报的增量成本完全一致</strong>。</p></blockquote><p>这说明两件事：单价格式选对了，以及在便宜时段跑确实能拿到折扣价。<strong>先验证计算口径，再相信计算结果</strong>——这一步比计算本身更重要。</p><h2 id="八、经验与结论"><a href="#八、经验与结论" class="headerlink" title="八、经验与结论"></a>八、经验与结论</h2><p><strong>1. 自报永远不算数，判据要先于结论。</strong><br>这轮所有绿灯都是我亲手跑出来的：闸门先被参考实现和空实现双向验证过，跑完再核对验收文件哈希。这个过程不浪漫，但它把”感觉它做得不错”换成了”它确实通过了这 17 项断言”。</p><p><strong>2. 成本要看 token 结构，别信”印象便宜”。</strong><br>同一对候选，在小任务上成本几乎一样（$0.0027 vs $0.0029），换到大任务就差 3 倍。原因不是”谁更笨”，而是<strong>默认参数把预算导向了思考过程</strong>。任何”某工具更省钱”的结论，如果没拆开输入 &#x2F; 缓存 &#x2F; 输出三档看过，都不值得采信。</p><p><strong>3. 要区分”被测对象失败”和”测试装置失败”。</strong><br>这轮我犯了两次滑稽的错误：一次是给被测程序的 prompt 用了相对路径，工作目录一变就找不到文件，于是三轮全部”失败”；另一次是后台跑对照组的命令行时<strong>漏注入了 API key</strong>，它 10 秒就退出——我第一反应是”这工具怎么这么差”，差点把这个当成结论写进报告。</p><p>教训很明确：<strong>代理秒退、零产出、耗时异常短，这些首先应该怀疑测试装置，而不是被测对象。</strong> 我把这条写进了自己的操作手册。</p><p><strong>4. 关于那个常驻 worker 原型。</strong><br>它是这轮里我最满意的产出：不算复杂，但把”会话复用”从”进程内的特性”变成了”可跨调用的服务”。它也说明了一件更普遍的事——<strong>当工具有个你不喜欢的限制时，先看看是不是能在外面绕过去</strong>，而不是立刻放弃它或者接受它。</p><p><strong>5. 最终结论：不替代，且已清理。</strong><br>综合下来我没有保留它：</p><ul><li>它的核心卖点（会话复用省 token）在跨进程场景下<strong>不成立</strong>，我补的 worker 补上了这个能力，但<strong>对照组原生就有</strong>；</li><li>大任务上它默认更慢更贵，要显式调参才能追到 1.6 倍差距；</li><li>版本还是 rc，官方声明会有破坏性变更。</li></ul><p>它剩下的独特价值只有两处：<strong>逐次调用的 token &#x2F; 缓存 &#x2F; 结束原因可以编程取数</strong>（适合做计量与风控），以及<strong>不依赖 Node 的干净部署</strong>——这两个场景我暂时用不上。</p><p>所以评估做完，环境清理干净，回到原来的方案。</p><h2 id="尾声"><a href="#尾声" class="headerlink" title="尾声"></a>尾声</h2><p>这轮下来，我真正收获的不是”哪个工具更好”的答案，而是一套<strong>可以重复使用的评估姿势</strong>：</p><blockquote><p>先写参考实现，用它验证判据；再跑对照，用判据验收产物；最后拆开 token 明细看钱花在哪；并且时刻准备怀疑自己的测试装置。</p></blockquote><p>工具会一直换，这套姿势不会。</p><hr><p><em>封面图：Wikimedia Commons，<a href="https://commons.wikimedia.org/wiki/File:English_weight_with_calibration_stamps_(1).jpg">English weight with calibration stamps</a>，CC0（公有领域献出），摄影 HelgeRieder。选它是因为——评估的根本，是先有一个可信的标准参考物。</em></p>]]>
    </content>
    <id>https://nova02640.github.io/dsh-python-sdk-vs-opencode-eval/</id>
    <link href="https://nova02640.github.io/dsh-python-sdk-vs-opencode-eval/"/>
    <published>2026-09-16T17:00:00.000Z</published>
    <summary>DeepSeek Harness 换了个形态回来——官方 Python SDK，宣称能用会话 UUID 省 token。我搭了一套可复现的对照实验，把 dsh SDK 和 OpenCode 拉到同一张考卷上跑了三轮递进任务，结果出现了一个反直觉的翻转：小任务上两边成本相当，任务一大 dsh 反而贵出三倍，而钱花在了看不见的思考 token 上。</summary>
    <title>同一张考卷，两个 AI 编码代理：DeepSeek Harness Python SDK 与 OpenCode 对照实测</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="静态站点" scheme="https://nova02640.github.io/tags/%E9%9D%99%E6%80%81%E7%AB%99%E7%82%B9/"/>
    <category term="Hexo" scheme="https://nova02640.github.io/tags/Hexo/"/>
    <category term="RSSHub" scheme="https://nova02640.github.io/tags/RSSHub/"/>
    <category term="爬虫" scheme="https://nova02640.github.io/tags/%E7%88%AC%E8%99%AB/"/>
    <category term="文本聚类" scheme="https://nova02640.github.io/tags/%E6%96%87%E6%9C%AC%E8%81%9A%E7%B1%BB/"/>
    <category term="GitHub Actions" scheme="https://nova02640.github.io/tags/GitHub-Actions/"/>
    <content>
      <![CDATA[<h2 id="引子"><a href="#引子" class="headerlink" title="引子"></a>引子</h2><p>手上有个关于 RSSHub 的小项目原型，做得不满意——于是推倒重来，目标很明确：</p><blockquote><p>做一个<strong>全球新闻速览站</strong>：从 RSSHub 抓新闻，按<strong>事件</strong>归类，非中文的翻译成中文，GitHub Pages 部署，30 分钟自动更新一次。</p></blockquote><p>听起来不复杂，实际做下来踩的坑挺有代表性：<strong>方案被自己推翻一次、翻译 API 选型要靠真机探测、聚类算法在”合并过度”和”拆分过度”之间来回横跳</strong>。这篇把整个过程记下来，重点写最有价值的部分——那两个方向相反的坑，以及最后怎么把”验证”做得可信。</p><hr><h2 id="一、第一版：世界地图方案，上线即否决"><a href="#一、第一版：世界地图方案，上线即否决" class="headerlink" title="一、第一版：世界地图方案，上线即否决"></a>一、第一版：世界地图方案，上线即否决</h2><p>最初的设想很带感：<strong>一张世界地图，把新闻按发生地点标注成圆点，用颜色表示影响范围</strong>。</p><p>技术上一路是通的：</p><ul><li>采集在 GitHub Actions 的海外 runner 上跑（这点很关键，绕开本地网络限制）；</li><li>用内置地理词典（国家 + 城市 + 中英别名 → 坐标）做地点标注，不依赖在线 geocoding；</li><li>影响范围做四级（本地&#x2F;区域&#x2F;全国&#x2F;国际），阈值与词表全部落在配置文件里；</li><li>前端 Leaflet + OSM 瓦片，纯静态。</li></ul><p>跑通之后上线，真实数据也进来了：28 个源里 17 个可用，抓回 379 条新闻，地点分布看着很合理。</p><p>但<strong>看实际效果就知道方向错了</strong>：地图上是一堆散点，看新闻的人并不关心”这条新闻发生在哪个坐标”——他关心”<strong>发生了什么</strong>“。地图把”新闻”降维成了”地理位置”，信息密度反而变低。</p><p>于是推翻重来。这一版的教训是：<strong>可视化形式要从用户的问题出发，不是从数据的属性出发</strong>。数据里有地理字段，不代表地图就是好的呈现方式。</p><hr><h2 id="二、第二版：Hexo-静态卡片站-30-分钟增量"><a href="#二、第二版：Hexo-静态卡片站-30-分钟增量" class="headerlink" title="二、第二版：Hexo 静态卡片站 + 30 分钟增量"></a>二、第二版：Hexo 静态卡片站 + 30 分钟增量</h2><p>新方向很朴素——<strong>像 Google News 那样的卡片流</strong>：顶部类别栏（时政&#x2F;国际&#x2F;财经&#x2F;新媒体），主体是白底卡片（来源 · 类别 · 相对时间 · 标题 · 摘要 · 地点 + 影响级别色点），点击新标签打开原文。</p><p>技术上最大的决策是<strong>数据怎么存</strong>。</p><h3 id="2-1-为什么不用”一条新闻一篇文章”"><a href="#2-1-为什么不用”一条新闻一篇文章”" class="headerlink" title="2.1 为什么不用”一条新闻一篇文章”"></a>2.1 为什么不用”一条新闻一篇文章”</h3><p>静态站的自然做法是把每条新闻生成一篇 post。但新闻是<strong>高流动、高重复</strong>的数据：一天几百条、三天就过时。这么做的后果是仓库迅速膨胀、每次全量构建越来越慢，而且没有任何价值——新闻详情页没人看。</p><h3 id="2-2-数据不进-git，用-Actions-cache-做跨-run-状态"><a href="#2-2-数据不进-git，用-Actions-cache-做跨-run-状态" class="headerlink" title="2.2 数据不进 git，用 Actions cache 做跨 run 状态"></a>2.2 数据不进 git，用 Actions cache 做跨 run 状态</h3><p>真正需要”增量”的是<strong>采集</strong>：不重复展示旧新闻、只追加新的。这就要求跨 run 保存状态（已见过的新闻指纹、已翻译的结果、已聚合的事件）。而 GitHub Actions 的 runner 每次都是全新的，状态必须有地方放。</p><p>两个选项：</p><table><thead><tr><th>方案</th><th>问题</th></tr></thead><tbody><tr><td>把数据 commit 回仓库</td><td>数据每天变化几十次，git 历史迅速膨胀（几十 MB&#x2F;天），还得处理”数据提交触发 workflow”的递归</td></tr><tr><td><strong>用 actions&#x2F;cache 持久化 <code>db/</code> 目录</strong></td><td>需要一点技巧：cache 的 key 要每次唯一才能保存成功，同时用 <code>restore-keys</code> 前缀命中上一次的缓存</td></tr></tbody></table><p>最终选了后者：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">恢复增量缓存（跨</span> <span class="string">run</span> <span class="string">状态）</span></span><br><span class="line">  <span class="attr">uses:</span> <span class="string">actions/cache/restore@v4</span></span><br><span class="line">  <span class="attr">with:</span></span><br><span class="line">    <span class="attr">path:</span> <span class="string">db</span></span><br><span class="line">    <span class="attr">key:</span> <span class="string">newsmap-data-$&#123;&#123;</span> <span class="string">github.run_id</span> <span class="string">&#125;&#125;</span>   <span class="comment"># 唯一 key 保证 save 成功</span></span><br><span class="line">    <span class="attr">restore-keys:</span> <span class="string">newsmap-data-</span>              <span class="comment"># 前缀命中上次 run</span></span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">增量采集</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">python3</span> <span class="string">tools/fetch.py</span> <span class="string">--out</span> <span class="string">db</span> <span class="string">--cache-db</span> <span class="string">db</span> <span class="string">--retain-hours</span> <span class="number">72</span></span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">保存增量缓存</span></span><br><span class="line">  <span class="attr">if:</span> <span class="string">steps.cache-restore.outputs.cache-hit</span> <span class="type">!=</span> <span class="string">&#x27;true&#x27;</span></span><br><span class="line">  <span class="attr">uses:</span> <span class="string">actions/cache/save@v4</span></span><br><span class="line">  <span class="attr">with:</span></span><br><span class="line">    <span class="attr">path:</span> <span class="string">db</span></span><br><span class="line">    <span class="attr">key:</span> <span class="string">newsmap-data-$&#123;&#123;</span> <span class="string">github.run_id</span> <span class="string">&#125;&#125;</span></span><br></pre></td></tr></table></figure><p>缓存里放三样东西：<strong>已见指纹</strong>（去重）、<strong>翻译结果</strong>（省 API 配额）、<strong>事件聚合结果</strong>（增量聚类）。缓存万一被清掉也不致命——采集会退化成一轮全量重建，页面内容依然正确，只是少了”新增”的语义。</p><h3 id="2-3-Hexo-只做”渲染器”"><a href="#2-3-Hexo-只做”渲染器”" class="headerlink" title="2.3 Hexo 只做”渲染器”"></a>2.3 Hexo 只做”渲染器”</h3><p>站点用 Hexo 生成，但没有走”文章”模型：写了一个自定义 generator，直接读数据 JSON，把事件&#x2F;条目渲染成静态 HTML（首页、分类页、详情页、全量列表），再交给 <code>actions/deploy-pages</code> 部署。</p><p>好处是：<strong>页面是服务端渲染的纯 HTML</strong>，没有前端框架、没有数据请求，秒开、SEO 友好；数据与展示彻底解耦。</p><hr><h2 id="三、第三版：事件归类-中文翻译"><a href="#三、第三版：事件归类-中文翻译" class="headerlink" title="三、第三版：事件归类 + 中文翻译"></a>三、第三版：事件归类 + 中文翻译</h2><p>这一版加两个能力，也是这个项目真正的价值所在。</p><h3 id="3-1-翻译-API：先真机探测，再选型"><a href="#3-1-翻译-API：先真机探测，再选型" class="headerlink" title="3.1 翻译 API：先真机探测，再选型"></a>3.1 翻译 API：先真机探测，再选型</h3><p>本地网络（大陆）访问不了主流翻译服务，所以<strong>选型必须在真实的 Actions runner 上做</strong>。我专门起了个临时 workflow 探测，结果很反直觉：</p><table><thead><tr><th>端点</th><th>实测结果</th><th>结论</th></tr></thead><tbody><tr><td><code>translate.googleapis.com</code>（常见的非官方端点）</td><td><strong>HTTP 429</strong>（数据中心 IP 被限流）</td><td>❌</td></tr><tr><td><code>translate.google.com/...client=webapp</code></td><td>HTTP 403</td><td>❌</td></tr><tr><td><strong><code>clients5.google.com/translate_a/t?client=dict-chrome-ex</code></strong></td><td><strong>连续 12&#x2F;12 成功</strong>，长文本、日文&#x2F;阿拉伯文 → 中文都正常</td><td>✅ 主力</td></tr><tr><td><strong>MyMemory</strong>（<code>api.mymemory.translated.net</code>）</td><td>稳定 200，匿名 5000 词&#x2F;天</td><td>✅ 兜底</td></tr><tr><td>LibreTranslate 公共实例</td><td>400 &#x2F; 502</td><td>❌</td></tr></tbody></table><p><strong>结论：不要凭经验猜哪个 API 能用，要在真实环境里探测。</strong> 同一个 Google 域名，<code>gtx</code> 端点被限流，<code>clients5</code> 端点却跑得好好的。</p><p>策略上做三件事：</p><ol><li><strong>双 provider 链</strong>：主力失败自动降级兜底，都失败就保留原文（不阻塞流水线）；</li><li><strong>结果持久化缓存</strong>：同一条新闻只翻一次，跨 run 复用（省钱、省时间）；</li><li><strong>两阶段 + 限流</strong>：先翻所有标题（最可见），再翻摘要；单轮上限 180 条，连续失败就提前停手。</li></ol><p>页面上译文条目会带一个「译」小标，原文标题以小字保留在下方——不丢原意。</p><h3 id="3-2-事件聚类：没有模型，也能做出可用的归类"><a href="#3-2-事件聚类：没有模型，也能做出可用的归类" class="headerlink" title="3.2 事件聚类：没有模型，也能做出可用的归类"></a>3.2 事件聚类：没有模型，也能做出可用的归类</h3><p>要求是”把同一件事的新闻聚在一起”。不引第三方依赖（CI 里装包越少越稳），用纯标准库实现：</p><ul><li><strong>文本表示</strong>：中文取<strong>字符二元组</strong>（bigram，不需要分词库），英文取单词；标题权重高于摘要；先剥离”财联社X月X日电”这类固定电头；</li><li><strong>相似度</strong>：TF-IDF 加权余弦，IDF 由当前窗口内全部条目动态计算；</li><li><strong>聚类</strong>：增量单遍聚类——新条目与所有活跃事件比相似度，超过阈值就归入，否则新建事件；</li><li><strong>护栏</strong>：时间窗 72 小时、地点不同提高门槛（防”北京暴雨”误并”纽约暴雨”）、语言不同提高门槛、事件规模上限。</li></ul><hr><h2 id="四、上线之后：两个方向相反的坑"><a href="#四、上线之后：两个方向相反的坑" class="headerlink" title="四、上线之后：两个方向相反的坑"></a>四、上线之后：两个方向相反的坑</h2><p>这部分是全文最值得写的。第一版聚类上线后，真实数据给出了非常直接的反馈。</p><h3 id="4-1-坑一：滚雪球——一个事件吞并了-461-条新闻"><a href="#4-1-坑一：滚雪球——一个事件吞并了-461-条新闻" class="headerlink" title="4.1 坑一：滚雪球——一个事件吞并了 461 条新闻"></a>4.1 坑一：滚雪球——一个事件吞并了 461 条新闻</h3><p>首轮生产数据：1034 条新闻聚出 254 个事件，规模分布如下：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[461, 70, 67, 27, 18, 18, 10, 10, 10, 8, ...]</span><br></pre></td></tr></table></figure><p>排在第一位的事件有 <strong>461 个成员</strong>，点进去一看：阿拉伯语的战争新闻、娱乐八卦、财经快讯、领导人会见，全都混在一起。它的”关键词”是 <code>月 / 美国 / 日 / 中国 / 美元 / 日电 / 财联</code>——<strong>全是泛词</strong>。</p><p>根因有三条：</p><ol><li><strong>质心平均向量漂移（主因）</strong>：判定”是否同一事件”时，我把候选新闻和事件的**成员平均向量（质心）**做比较。成员一多，质心就被高频泛词填充成”通用词袋”，于是和任何新闻都相似——<strong>合并越多 → 质心越泛 → 更容易合并</strong>，正反馈滚雪球。</li><li><strong>快讯固定前缀</strong>：真实语料里”财联社9月10日电”这种电头出现 42 条，几乎相同的开头制造了虚假相似度。</li><li><strong>多话题汇总条目当吸引子</strong>：”XX早餐 | 9月10日”这类罗列型条目，词汇覆盖面极广，天然与一切相似。</li></ol><p>修复（v3.1）：</p><ul><li><strong>相似度改成”与事件代表成员的最大值”</strong>，彻底弃用质心判定（质心只保留做关键词展示）；</li><li>先<strong>剥离电头&#x2F;署名&#x2F;括注</strong>再做分词；</li><li><strong>摘要型条目单独隔离</strong>，不参与任何合并；</li><li>加<strong>事件规模硬上限</strong>，满员另开事件。</li></ul><h3 id="4-2-坑二：矫枉过正——同题新闻只并上四分之一"><a href="#4-2-坑二：矫枉过正——同题新闻只并上四分之一" class="headerlink" title="4.2 坑二：矫枉过正——同题新闻只并上四分之一"></a>4.2 坑二：矫枉过正——同题新闻只并上四分之一</h3><p>改完滚雪球，最大事件从 461 降到 4，看上去很干净。但进一步量化后发现<strong>另一个极端</strong>：</p><blockquote><p>跨来源、标题高度相似（bigram Jaccard ≥ 0.8）的 <strong>31 对</strong>新闻里，只有 <strong>30%</strong> 被合并。<br>其中”两市融资余额减少31.93亿元”这条，<strong>三家媒体一字不差</strong>，却只并上了两对。</p></blockquote><p>也就是说，用户最想要的”各平台报道聚合”，实际上<strong>没做成</strong>。</p><p>排查过程很有意思，走了一条弯路：先怀疑”稀有词共建”门槛太严——却发现两条同题新闻余弦相似度高达 <strong>0.99</strong>、共享稀有词 9 个，本该合并。于是继续往下追，终于抓到真凶：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">真实语料 1034 条中被判定为&quot;摘要型条目&quot;的：411 条（40%！）</span><br><span class="line">  其中标题真的命中汇总类关键词的：17 条</span><br><span class="line">  仅因为&quot;摘要长度 &gt; 120 字且分句 ≥ 6&quot;被误判的：394 条   ← 元凶</span><br></pre></td></tr></table></figure><p>我为了防”多话题汇总条目当吸引子”，写了一条摘要启发式：<strong>摘要超过 120 字且分句数 ≥ 6 → 视为汇总条目</strong>。结果财联社那种多句式快讯摘要（134 字）全部踩线——<strong>40% 的普通新闻被判定为”汇总条目”，直接隔离、永不合并</strong>。</p><p>修复（v3.2）：<strong>摘要型判定只看标题关键词</strong>（早餐&#x2F;早报&#x2F;盘点&#x2F;要闻&#x2F;Top stories…），摘要维度退化为极严的兜底（≥500 字、≥12 分句、≥3 个话题块）；同时给”高相似度”开直通——余弦 ≥ 0.75 直接判为同一事件，不再要求共享稀有词。</p><p>效果：</p><table><thead><tr><th>指标</th><th>修复前</th><th>修复后</th></tr></thead><tbody><tr><td>跨来源同题合并率</td><td>25%</td><td><strong>96.8%</strong></td></tr><tr><td>最大事件成员数</td><td>461 → 4</td><td>4（合理）</td></tr><tr><td>“摘要型”误判</td><td>411</td><td><strong>10</strong>（全是真·汇总条目）</td></tr><tr><td>事件总数</td><td>977（过拆）</td><td>912</td></tr></tbody></table><h3 id="4-3-坑三：算法修好了，线上却没变"><a href="#4-3-坑三：算法修好了，线上却没变" class="headerlink" title="4.3 坑三：算法修好了，线上却没变"></a>4.3 坑三：算法修好了，线上却没变</h3><p>修完推送、CI 跑绿，我兴冲冲去看线上——<strong>那个 461 条的大事件还在</strong>。</p><p>原因：聚类是”增量”的，新版算法会<strong>读取上一轮的聚合结果继续用</strong>。旧算法产出的错误事件，被新算法原样继承了下来。</p><p>修复：给聚合结果打上<strong>算法版本 + 规则指纹</strong>（聚类配置文件的哈希）。版本或任一阈值变化时，直接<strong>忽略旧结果、全量重建</strong>：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> old_events <span class="keyword">and</span> (meta.get(<span class="string">&quot;algo_version&quot;</span>) != ALGO_VERSION</span><br><span class="line">                   <span class="keyword">or</span> meta.get(<span class="string">&quot;rules_fingerprint&quot;</span>) != fp):</span><br><span class="line">    old_events = []   <span class="comment"># 旧结果失效 → 全量重建</span></span><br></pre></td></tr></table></figure><p>这行小改动的价值被严重低估：<strong>任何”派生数据 + 增量计算”的系统，都必须有失效机制</strong>，否则修复永远不生效。</p><hr><h2 id="五、让”验证”可信"><a href="#五、让”验证”可信" class="headerlink" title="五、让”验证”可信"></a>五、让”验证”可信</h2><p>这个项目全程是”人（我）定方案 + AI 编码代理执行 + 我独立验收”的模式。这类协作里最容易出问题的是：<strong>“看起来完成了”和”实际正确”之间的距离</strong>。所以验证做了三层：</p><p><strong>第一层：预埋已知答案的用例。</strong> 我手工构造小米级的测试集，正例（3 组同事件、措辞不同）和<strong>硬负例</strong>（相同电头但话题完全不同、真·汇总条目、跨语言同事件、无关条目）都有明确期望输出，算法必须逐组通过。</p><p><strong>第二层：真实语料回归（关键）。</strong> 小样本会骗人——我那 9 条用例全过，真实语料却有 <strong>40%</strong> 的条目被误判。所以我把<strong>线上的真实数据导出来做基准</strong>：通过一个临时 workflow 恢复 CI 缓存、把数据打包成 artifact 下载下来，得到 1000+ 条真实新闻，然后在本地跑算法、量化指标：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">事件总数 ≥ 500 ｜ 最大事件 ≤ 20 ｜ 同题合并率 ≥ 90% ｜ 摘要型误判 ≤ 30 ｜ 负例不误并 ｜ 幂等</span><br></pre></td></tr></table></figure><p>这套指标后来成了每次改聚类的”回归测试”，两个坑都是它揪出来的。</p><p><strong>第三层：线上逐页核对。</strong> 部署完挨个 curl：首页事件卡、事件详情页（各平台报道 + 译标）、分类页、全量列表页，检查页头统计是”1085 个事件 &#x2F; 1248 条报道”而不是把事件数当报道数（这个 bug 也真实发生过）。</p><hr><h2 id="六、现在的样子"><a href="#六、现在的样子" class="headerlink" title="六、现在的样子"></a>六、现在的样子</h2><ul><li><strong>线上</strong>：一个 30 分钟自动更新的新闻聚合站，约 1200+ 条新闻 &#x2F; 1085 个事件；</li><li><strong>事件卡</strong>：首页按事件展示，标注”N 家媒体报道”，点进去看各平台报道对照；</li><li><strong>翻译</strong>：非中文新闻自动转中文，带「译」标与原文；</li><li><strong>源健康</strong>：给每个源维护健康矩阵，个别源失败不影响整体流水线；</li><li><strong>数据零依赖</strong>：纯 Python 标准库实现的采集、翻译调度与聚类，没有第三方包。</li></ul><hr><h2 id="七、可以迁移的经验"><a href="#七、可以迁移的经验" class="headerlink" title="七、可以迁移的经验"></a>七、可以迁移的经验</h2><ol><li><strong>可视化形式要匹配用户问题，不是匹配数据字段</strong>——有地理字段不代表该上地图。</li><li><strong>外部服务选型要在真实运行环境探测</strong>，同一域名的不同端点可能一个限流一个畅通。</li><li><strong>启发式阈值必须用真实规模语料校准</strong>。小样本会让你的”全过”变成幻觉：411&#x2F;1034 的误判率，在 9 条用例里完全看不出来。</li><li><strong>派生数据必须有版本指纹</strong>，否则算法修好、线上照旧。</li><li><strong>两个方向的错误都要测</strong>：只测”该合并的合了吗”会漏掉”不该合并的并了吗”（滚雪球），反之亦然。<strong>正例 + 硬负例 + 真实语料</strong>才是完整的三件套。</li><li><strong>自动化流水线要”看得见”</strong>：每轮日志里打印关键计数（源成功数、新增条数、事件数、翻译条数），出问题时第一眼就能定位到是哪一环。</li></ol><p>整个项目从”地图翻车”到”事件聚类可用”，一共四轮迭代。回头看，真正花时间的不是写功能，而是<strong>把”效果不对”翻译成”可量化的指标”，再定位到具体那一行代码</strong>——这大概也是做数据类项目最实在的功夫。</p>]]>
    </content>
    <id>https://nova02640.github.io/rsshub-news-devlog/</id>
    <link href="https://nova02640.github.io/rsshub-news-devlog/"/>
    <published>2026-09-10T04:00:00.000Z</published>
    <summary>一次真实的项目迭代记录：地图方案被否决、改成 Hexo 静态卡片站、加上事件归类与免费翻译；上线后踩了两个方向相反的坑——一个事件吞并 461 条无关新闻、同题新闻却只并上四分之一，以及最后怎么用'真实语料回归'把它们钉死。</summary>
    <title>从地图翻车到事件聚类：用 RSSHub + Hexo 做一个全球新闻速览站</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="AI" scheme="https://nova02640.github.io/tags/AI/"/>
    <category term="DeepSeek" scheme="https://nova02640.github.io/tags/DeepSeek/"/>
    <category term="Agent" scheme="https://nova02640.github.io/tags/Agent/"/>
    <category term="实测" scheme="https://nova02640.github.io/tags/%E5%AE%9E%E6%B5%8B/"/>
    <category term="网页游戏" scheme="https://nova02640.github.io/tags/%E7%BD%91%E9%A1%B5%E6%B8%B8%E6%88%8F/"/>
    <content>
      <![CDATA[<h2 id="引言"><a href="#引言" class="headerlink" title="引言"></a>引言</h2><p>上篇《<a href="/deepseek-harness-research/">DeepSeek Harness 深度调研</a>》是从源码与文档出发的<strong>纸面拆解</strong>。这篇不一样——把 dsh 拉到真实任务里跑了一遍：部署、小任务基准、最后让它<strong>从零写出一款可玩的仙侠回合制页游</strong>，全程只做协调与验收，不碰它的代码。</p><p>结论先说：<strong>任务完成了，游戏在线可玩</strong>（文末有链接），过程中没有出现某些第三方评测里”报成功但产物损坏”的假成功；但暴露了一个现实约束——单轮任务时长不够大项目，需要<strong>分轮续写</strong>协作。</p><h2 id="一、评估：部署到内网小主机"><a href="#一、评估：部署到内网小主机" class="headerlink" title="一、评估：部署到内网小主机"></a>一、评估：部署到内网小主机</h2><p>测试环境是一台内网 x86 Debian 小主机。dsh 本体是 Node.js 包（<code>@deepseek-ai/dsh</code>），npm 全局安装约 500+ 依赖包，官方推荐 <code>npx</code> 起 Web UI，但真正适合脚本化调用的是 <strong>headless profile</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">dsh --profile headless <span class="string">&quot;任务描述&quot;</span>   <span class="comment"># 一次性任务：stdout 打印最终答案后退出</span></span><br></pre></td></tr></table></figure><p>这个形态与 <code>opencode run</code>、Codex 的 <code>-p</code> 几乎同构——恰好是我平时委托第三方编码 agent 的标准姿势，天然适配”助手协调、agent 干活”的分工。</p><p><strong>第一个坑：Node 版本。</strong> README 只写”安装 Node.js”，没标最低版本。实际要求 <strong>Node ≥ 22.19 或 24</strong>（不支持 23.x）：在 Node 20 上安装能成功，运行即崩，两个硬错误——</p><ul><li><code>Promise.withResolvers is not a function</code>（Node 22+ API）</li><li><code>node:module</code> 不提供 <code>stripTypeScriptTypes</code>（Node 22.18+&#x2F;24 API）</li></ul><p>把测试机的 Node 升到 v24 后一切正常。装完顺手验证了密钥处理：dsh 的凭据系统原生支持 <code>DEEPSEEK_API_KEY</code> 环境变量注入，<strong>密钥不用落盘</strong>，这在自动化调用里是很干净的安全实践。</p><h2 id="二、基准：两个可复验的小任务"><a href="#二、基准：两个可复验的小任务" class="headerlink" title="二、基准：两个可复验的小任务"></a>二、基准：两个可复验的小任务</h2><p>正式委托大任务前，先跑两个<strong>结果可客观验证</strong>的小任务（避免”你说完成了就信你”）：</p><table><thead><tr><th>任务</th><th>耗时</th><th>结果</th></tr></thead><tbody><tr><td>修复 3 个故意植入的 bug（除零&#x2F;阶乘&#x2F;奇偶）并让单元测试全过</td><td>16s</td><td>✅ 人工复验 6&#x2F;6 通过</td></tr><tr><td>从零实现 CSV→JSON 转换器（类型推断 + 引号字段处理）</td><td>26s</td><td>✅ 边界样例（含 <code>&quot;Smith, John&quot;</code>）全部正确</td></tr></tbody></table><p>两次都干净利落，单次成本估计不到 1 分钱（V4-Flash 本身便宜，加上 90%+ 的上下文缓存命中率）。这个阶段我还读了一批第三方实测，其中 <a href="https://www.atlascloud.ai/blog/tips/deepseek-harness-review">AtlasCloud 的深度测评</a> 最值得警惕：同一个前端任务跑 3 次，<strong>3 次都报 “Done” + 自信的验证清单，但打开产物 2 次是坏的</strong>——它用 <code>node --check</code>（只查语法）当”验证”，对 SVG 路径合法性一无所知。另外配置不当（模型条目只写 <code>id</code>、不声明 <code>contextWindow</code>&#x2F;<code>maxTokens</code>）会让同一任务贵 3.5 倍，且这些关键字段藏在包 README 里而不是官方文档。</p><p>教训就一条：<strong>任何 agent 的自述都不足信，验收责任在调用方</strong>。这条对 opencode、Codex 同样成立，不是 dsh 独有。</p><h2 id="三、实战：让它写一款仙侠回合制游戏"><a href="#三、实战：让它写一款仙侠回合制游戏" class="headerlink" title="三、实战：让它写一款仙侠回合制游戏"></a>三、实战：让它写一款仙侠回合制游戏</h2><p>基准通过后，下达了真实任务——用单文件 HTML5 Canvas 实现一款类似《仙剑奇侠传》的像素风回合制页游：</p><ul><li>640x480 俯视角地图（草地&#x2F;树木&#x2F;木屋&#x2F;洞窟&#x2F;池塘），方向键&#x2F;WASD 移动</li><li>移动随机遇敌，回合制战斗：攻击 &#x2F; 仙术 &#x2F; 道具 &#x2F; 逃跑</li><li>HP（体力）&#x2F; MP（真气）&#x2F; 攻防 &#x2F; 经验升级 &#x2F; 存档</li><li>纯原生 Canvas，零外部依赖，一个 <code>index.html</code> 打开即玩</li></ul><h3 id="第-1-轮：580-秒超时，产出-30KB-高质量骨架"><a href="#第-1-轮：580-秒超时，产出-30KB-高质量骨架" class="headerlink" title="第 1 轮：580 秒超时，产出 30KB 高质量骨架"></a>第 1 轮：580 秒超时，产出 30KB 高质量骨架</h3><p>任务跑了 580 秒被我设的 timeout 截断。检查产物：地图生成与绘制、角色移动、精灵、背包都写完了，代码结构干净、注释规范、语法零错误——但文件末尾留着一个 <code>/*==PART_B==*/</code> 占位符，战斗系统、对话、主循环还没动。</p><p><strong>这里值得表扬</strong>：它没有谎报完成。超时前它正在继续写，截断状态一目了然——这就是”诚实”的工程表现，与第三节提到的”假成功”形成鲜明对比。</p><h3 id="第-2-轮：891-秒超时，主体完成-93KB"><a href="#第-2-轮：891-秒超时，主体完成-93KB" class="headerlink" title="第 2 轮：891 秒超时，主体完成 93KB"></a>第 2 轮：891 秒超时，主体完成 93KB</h3><p>第二轮指示很克制：”在 PART_B 标记处继续补完，先通读现有代码、不要重写 Part A、完成后自检语法”。它正确衔接到已有代码，把场景状态机、回合制战斗、对话框、标题&#x2F;结局全部补上，文件从 30KB 长到 93KB。中断时它正在跑自测（日志显示”标题→地图转换已通过，正在跑完整测试”）。</p><h3 id="验收：没有浏览器，就把游戏真的”跑”起来"><a href="#验收：没有浏览器，就把游戏真的”跑”起来" class="headerlink" title="验收：没有浏览器，就把游戏真的”跑”起来"></a>验收：没有浏览器，就把游戏真的”跑”起来</h3><p>两轮都超时，产物到底能不能玩？光看代码不够。我用 Node 写了一个 <strong>DOM-stub 模拟器</strong>：用 <code>vm</code> 注入假 canvas&#x2F;document&#x2F;localStorage，加载真实游戏脚本，再塞一个探针读取内部状态（当前场景、战斗对象、血量），按状态机自动按键——标题选”新的旅程”→ 地图随机游走 → 遇敌进战斗 → 菜单按确认攻击 → 直到胜负结算。</p><p>模拟结果：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">BOOT OK（无加载错误，数千帧随机按键零异常）</span><br><span class="line">标题 → 地图（20 帧进入新游戏）</span><br><span class="line">716 帧时随机遇敌，进入战斗</span><br><span class="line">战斗胜利 → 转场回地图 ✓</span><br><span class="line">第二次模拟：战斗失败 → 半血复活（HP 130→65，精确等于 maxHp×0.5）→ 回到木屋前 ✓</span><br></pre></td></tr></table></figure><p><strong>胜负两条通路都端到端验证通过</strong>，连”失败后扣半血复活”这种细节都和设计一致。中途模拟器还误报过一次”胜利后战斗对象为空”——查代码发现是场景渐隐转场的正常时序，虚惊一场，修正模拟器继续推进即可。</p><h2 id="四、成果"><a href="#四、成果" class="headerlink" title="四、成果"></a>四、成果</h2><ul><li><strong>试玩</strong>：<a href="https://nova02640.github.io/xianxia-canvas-game/">https://nova02640.github.io/xianxia-canvas-game/</a> （方向键&#x2F;WASD 移动，Enter 确认，战斗中方向键选菜单）</li><li><strong>源码</strong>：<a href="https://github.com/nova02640/xianxia-canvas-game">https://github.com/nova02640/xianxia-canvas-game</a> （93KB 单文件，无任何依赖）</li></ul><p>部署走 GitHub Actions + Pages，workflow 只有 20 行（纯静态单文件，免构建直接上传）。推上去后线上产物与本地 <strong>sha256 完全一致</strong>——不是”看起来部署了”，是逐字节确认。</p><h2 id="五、经验与结论"><a href="#五、经验与结论" class="headerlink" title="五、经验与结论"></a>五、经验与结论</h2><ol><li><strong>无假成功，但有现实约束</strong>：这次 dsh 全程诚实——超时就是超时，截断标记清清楚楚。真正的约束是<strong>单轮时长预算</strong>：前端整包任务大约只有 <del>500B&#x2F;s 的有效产出，一款 93KB 的游戏要近 20 分钟、分 2</del>3 轮才能完成，且<strong>范围容易膨胀</strong>（它会边写边规划 Boss、区域门控等扩展），任务描述必须限定边界或拆阶段下达。</li><li><strong>大任务 &#x3D; 分轮续写 + 强验收</strong>：headless 一次一任务、无交互跟进，但每轮给它”先通读、别破坏、只补这一段”的指令就能无缝衔接。”分轮续写”对任何一次性 CLI agent 都是通用协作模式。</li><li><strong>DOM-stub 模拟器是前端任务验收利器</strong>：无头环境里用假 DOM + 状态探针 + 自动按键把游戏真实跑起来，比任何静态检查都可靠。这套方法以后会沉淀成标准验收流程。</li><li><strong>配置与版本是第一道坎</strong>：Node ≥22.19（README 没写）、模型条目要显式声明 <code>contextWindow</code>&#x2F;<code>maxTokens</code>——这两点直接影响能不能跑和贵不贵。</li><li><strong>定位判断</strong>：dsh 目前是 v0.1 rc，官方自己声明”会有破坏性变更”。作为<strong>受控试点的编码执行器</strong>（低成本、可自查类任务）非常够用；无人值守生产管线请再等等。</li></ol><p>下一篇打算把这次的小主机实验环境收个尾，顺便聊聊”助手协调 + agent 执行 + 机器验收”这条流水线怎么搭最顺。</p>]]>
    </content>
    <id>https://nova02640.github.io/deepseek-harness-field-test/</id>
    <link href="https://nova02640.github.io/deepseek-harness-field-test/"/>
    <published>2026-09-08T06:00:00.000Z</published>
    <summary>上一篇拆解了 dsh 的架构，这篇用真实任务验证它：部署踩坑、小任务基准、然后让它在两次超时后分轮续写出一款 93KB 的单文件仙侠回合制游戏，并用 DOM 模拟器完成了端到端验收。</summary>
    <title>DeepSeek Harness 实操实测：从基准任务到写出一款可玩的仙侠游戏</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="Hermes" scheme="https://nova02640.github.io/tags/Hermes/"/>
    <category term="NAS" scheme="https://nova02640.github.io/tags/NAS/"/>
    <category term="fnOS" scheme="https://nova02640.github.io/tags/fnOS/"/>
    <category term="排障" scheme="https://nova02640.github.io/tags/%E6%8E%92%E9%9A%9C/"/>
    <category term="mihomo" scheme="https://nova02640.github.io/tags/mihomo/"/>
    <category term="代理" scheme="https://nova02640.github.io/tags/%E4%BB%A3%E7%90%86/"/>
    <category term="运维" scheme="https://nova02640.github.io/tags/%E8%BF%90%E7%BB%B4/"/>
    <content>
      <![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>家里的飞牛（fnOS）NAS 上跑着 Hermes Agent（个人 AI 助手）、mihomo 代理、博客构建等一堆服务。最近一段时间 mihomo 总是出各种问题，加上 Hermes 有新版发布、构建任务想换个更快的机器跑，于是集中处理了三件事。这篇记录一下过程和思路。</p><h2 id="一、Hermes-Agent-升级：v0-20-2-→-v0-20-5"><a href="#一、Hermes-Agent-升级：v0-20-2-→-v0-20-5" class="headerlink" title="一、Hermes Agent 升级：v0.20.2 → v0.20.5"></a>一、Hermes Agent 升级：v0.20.2 → v0.20.5</h2><p>先检查了 GitHub 仓库，发现本地 v0.20.2（8 月 16 日）落后了三个 patch：v0.20.3、v0.20.4、v0.20.5（8 月 21 日发布），合计约 500 多个 PR 合入。与我的使用场景相关的收益有：</p><ul><li><strong>cron 调度器自愈</strong>（EMFILE 恢复、卡死任务重排）+ 媒体发送加固 —— 我有个周一 9 点的内存巡检定时任务，更稳了</li><li><strong>微信会话数据丢失修复</strong>、Bot Mode 群聊修复</li><li><strong>运行时停滞防护 + 子进程 Python 隔离</strong> —— NAS 只有 4G 内存，之前 gateway 曾吃到 1.6G 导致终端卡死，这个升级对我很关键</li><li>新能力：可折叠会话摘要、PDF&#x2F;文件附件拖拽、keyless web tier</li></ul><p>升级路径照旧：备份数据 → 下载源码 tarball → <code>uv pip install -e</code> 安装 → 延迟重启（先发消息再让 Monitor&#x2F;Gateway 重启，避免把正在发送的会话杀掉）。过程顺利，约 10 分钟，微信短暂掉线后恢复。</p><p>一个小插曲：升级排查时发现 mihomo 的「节点选择」落在香港福利节点上，连 GitHub 直接失败，临时切到台湾节点才下载成功——这正好引出了下面要讲的 mihomo 排障。</p><h2 id="二、mihomo-排障：两个根因"><a href="#二、mihomo-排障：两个根因" class="headerlink" title="二、mihomo 排障：两个根因"></a>二、mihomo 排障：两个根因</h2><p>用户反馈 mihomo「最近总是出现各种问题」。查 systemd 日志，发现两个独立的根因。</p><h3 id="根因-1：订阅更新持续失败（已修复）"><a href="#根因-1：订阅更新持续失败（已修复）" class="headerlink" title="根因 1：订阅更新持续失败（已修复）"></a>根因 1：订阅更新持续失败（已修复）</h3><p>日志从 8 月 21 日起大量报错：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">[Provider] airport pull error: file doesn&#x27;t have any proxy</span><br></pre></td></tr></table></figure><p>airport.yaml（订阅缓存）停留在 8 月 19 日的旧内容，说明 mihomo 一直拉不到能解析的新订阅。用 curl 直接探测订阅地址，发现一个有意思的现象——<strong>机场按请求的 User-Agent 返回不同的内容</strong>：</p><table><thead><tr><th>User-Agent</th><th>返回内容</th></tr></thead><tbody><tr><td><code>clash.meta</code> &#x2F; <code>clash-verge/v2.0.0</code> &#x2F; <code>mihomo/1.19.29</code></td><td>完整 Clash YAML（157 个节点）</td></tr><tr><td>空 &#x2F; <code>Go-http-client/1.1</code> &#x2F; <code>v2rayN/6.23</code> &#x2F; 浏览器</td><td>base64 编码的 vmess:&#x2F;&#x2F; 分享行（58 行）</td></tr></tbody></table><p>mihomo 的 HTTP provider 拉取订阅时默认 UA 不含 “clash” 字样，机场就返回了 base64 的 vmess 行；mihomo 期望的是 Clash YAML 格式，解析不到 <code>proxies</code> 字段，于是报 “file doesn’t have any proxy”，且不会覆盖旧缓存。</p><p>排查过程中还确认了 mihomo 本身<strong>能</strong>解析 base64&#x2F;v2ray 行格式（用 file provider 加载同一份内容，58 个节点正常加载），所以问题只在 HTTP 拉取环节的 UA 协商上。</p><p><strong>修复</strong>：在 config.yaml 的 proxy-providers 节点加一行：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">proxy-providers:</span></span><br><span class="line">  <span class="attr">airport:</span></span><br><span class="line">    <span class="attr">type:</span> <span class="string">http</span></span><br><span class="line">    <span class="attr">url:</span> <span class="string">&quot;https://&lt;订阅地址&gt;&quot;</span></span><br><span class="line">    <span class="attr">user-agent:</span> <span class="string">&quot;clash-verge/v2.0.0&quot;</span>   <span class="comment"># 关键：让机场返回 Clash YAML</span></span><br><span class="line">    <span class="attr">interval:</span> <span class="number">86400</span></span><br><span class="line">    <span class="attr">path:</span> <span class="string">./airport.yaml</span></span><br></pre></td></tr></table></figure><p>删除缓存后重启 mihomo，订阅拉取恢复正常（157 节点，无报错）。这个坑已经补进了运维技能库。</p><h3 id="根因-2：节点选择落在劣质节点（已修复）"><a href="#根因-2：节点选择落在劣质节点（已修复）" class="headerlink" title="根因 2：节点选择落在劣质节点（已修复）"></a>根因 2：节点选择落在劣质节点（已修复）</h3><p>「节点选择」手动组当前节点是香港福利节点（0.1x 限速、仅限 emby 用途），实测连 GitHub API 0.23 秒就被断开（HTTP 000）。git&#x2F;gh&#x2F;curl 走代理时各种失败，很多”mihomo 有问题”的体感其实来自这个节点。</p><p>切换测试了几个节点：劣质节点秒断；换到香港 1x 节点（V301）后 GitHub API 200、约 0.4 秒，Google 204 也正常。已把「节点选择」固定到 V301，选择会持久化到 mihomo 的 cache.db，重启后保留。</p><p><strong>经验</strong>：代理出问题时，先看当前节点是哪个、直测目标站连通性，再决定是换节点还是查订阅，别一上来就怀疑配置。</p><h2 id="三、构建任务迁移到内网小主机"><a href="#三、构建任务迁移到内网小主机" class="headerlink" title="三、构建任务迁移到内网小主机"></a>三、构建任务迁移到内网小主机</h2><p>NAS 只有 4G 内存，跑 npm 构建容易内存吃紧（Hexo 博客构建、family-motion 项目构建都比较吃资源）。正好家里有一台备用的小主机（Debian 13，120G SSD，内存空闲多），于是把两类构建任务迁了过去：</p><ul><li><strong>family-motion 构建</strong>：rsync 仓库 + <code>npm ci</code>（241 包）+ 构建，26 秒通过</li><li><strong>Hexo 博客构建</strong>：<code>npm ci</code>（289 包）+ <code>hexo generate</code>（106 文件），5.77 秒，产物 6.4M</li></ul><p>迁移后的资产安排很简单，各占一个独立目录：</p><ul><li><strong>Node 运行时</strong>：v20.19.2 + npm 10.8.2（用户级安装，不需要 root）</li><li><strong>两个项目的源码与构建产物</strong>：分别放各自的目录，互不干扰</li></ul><p>需要注意的边界：<strong>博客的 GitHub Pages 推送链路仍留在 NAS</strong>（依赖 mihomo 代理，代理只绑定 NAS 本机）；这台小主机只负责构建，产物同步回 NAS 或直接推送。gh&#x2F;git 高频操作也暂缓迁移。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>三件事的共通点是：<strong>先看日志和实测，再动手</strong>。mihomo 的 UA 坑是最典型的——同一个 URL，不同客户端拿到完全不同的内容，不对比 UA 根本想不到。运维里的”各种问题”，很多时候是几个小问题叠加出来的。</p><p>本文提到的技能与流程细节（mihomo UA 坑、Hermes 升级路径、任务迁移路线）已沉淀到内部知识库，后续遇到类似问题可以直接复用。</p>]]>
    </content>
    <id>https://nova02640.github.io/nas-ops-recap-202608/</id>
    <link href="https://nova02640.github.io/nas-ops-recap-202608/"/>
    <published>2026-08-23T12:40:00.000Z</published>
    <summary>最近集中处理了几件 NAS 运维任务：Hermes Agent 从 v0.20.2 升级到 v0.20.5；mihomo 代理持续出问题的根因排查（机场按 User-Agent 返回不同订阅格式的坑 + 节点选择落在劣质节点）；以及把 family-motion 和博客构建任务迁移到一台内网小主机。本文记录排查思路与最终方案。</summary>
    <title>NAS 运维手记：升级、代理排障与构建任务迁移</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="NAS" scheme="https://nova02640.github.io/tags/NAS/"/>
    <category term="fnOS" scheme="https://nova02640.github.io/tags/fnOS/"/>
    <category term="下载中心" scheme="https://nova02640.github.io/tags/%E4%B8%8B%E8%BD%BD%E4%B8%AD%E5%BF%83/"/>
    <category term="qBittorrent" scheme="https://nova02640.github.io/tags/qBittorrent/"/>
    <category term="aria2" scheme="https://nova02640.github.io/tags/aria2/"/>
    <category term="排障" scheme="https://nova02640.github.io/tags/%E6%8E%92%E9%9A%9C/"/>
    <content>
      <![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>家里的飞牛（fnOS）NAS 下载中心有个困扰了很久的毛病：<strong>三个早已下载完成的任务（Ubuntu 镜像、两部剧）反复删除也删不掉，新的下载任务也添加不上</strong>。问题持续了好几个月，一直没找到机会处理。</p><p>这篇文章记录完整的排查、定位、修复与验证过程——从”任务数据库里有什么”一路追到”qBittorrent 引擎为什么起不来”，希望能给遇到类似问题的朋友一个排查路径参考。</p><h2 id="一、系统架构：下载中心其实是三层结构"><a href="#一、系统架构：下载中心其实是三层结构" class="headerlink" title="一、系统架构：下载中心其实是三层结构"></a>一、系统架构：下载中心其实是三层结构</h2><p>fnOS 的下载中心（下载应用）不是单一程序，而是三部分协作：</p><table><thead><tr><th>组件</th><th>角色</th><th>说明</th></tr></thead><tbody><tr><td><code>dlcenter</code></td><td>主守护进程</td><td>systemd 服务，API 入口，负责任务数据库管理</td></tr><tr><td><code>trim-aria2c</code></td><td>HTTP 下载引擎</td><td>处理 HTTP&#x2F;HTTPS&#x2F;FTP 直链下载</td></tr><tr><td><code>trim-qbittorrent-nox</code></td><td>BT 下载引擎</td><td>处理磁力&#x2F;BT 下载，qBittorrent v4.6.4</td></tr></tbody></table><p><strong>关键数据与日志位置</strong>（排查的抓手）：</p><ul><li>任务数据库：<code>/usr/trim/var/downloadcenter/downloadcenter.db</code>（SQLite，TASKS&#x2F;CONFIGS 表）</li><li>主日志：<code>/usr/trim/logs/dlcenter.log</code>（qbt&#x2F;aria2 状态、fork 记录全在这里）</li><li>qbt 配置与种子：<code>/usr/trim/var/downloadcenter/1000/qbt/qBittorrent/</code>（BT_backup 里存种子和续传文件）</li></ul><blockquote><p>每个用户（uid）有独立的数据目录，这里 <code>1000</code> 是 admin 用户的 uid。</p></blockquote><h2 id="二、排查过程：从数据库一路挖到引擎"><a href="#二、排查过程：从数据库一路挖到引擎" class="headerlink" title="二、排查过程：从数据库一路挖到引擎"></a>二、排查过程：从数据库一路挖到引擎</h2><h3 id="第一步：任务数据库——三个任务都是-qBittorrent-类型"><a href="#第一步：任务数据库——三个任务都是-qBittorrent-类型" class="headerlink" title="第一步：任务数据库——三个任务都是 qBittorrent 类型"></a>第一步：任务数据库——三个任务都是 qBittorrent 类型</h3><p>用 Python 直读 SQLite 数据库，TASKS 表正好 3 行，对应 UI 上删不掉的三个任务：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">ID=1  荣耀01-08（电影港）      TYPE=qbt  STATE=pausedUP  完成于 2026-03-10</span><br><span class="line">ID=2  在你的灿烂季节 S01E01-04  TYPE=qbt  STATE=pausedUP  完成于 2026-03-10</span><br><span class="line">ID=4  ubuntu-22.04.5-desktop  TYPE=qbt  STATE=pausedUP  完成于 2026-03-14</span><br></pre></td></tr></table></figure><p>几个关键细节：</p><ul><li>三个任务<strong>全部是 qbt（qBittorrent）类型</strong>——走 BT 引擎</li><li>ID 是 1、2、4，<strong>缺了 3</strong>——说明曾经删掉过一个任务，但没删干净</li><li><code>pausedUP</code> &#x3D; 已完成、暂停做种状态，数据完全下载完了</li></ul><h3 id="第二步：引擎进程——qBittorrent-根本没在运行"><a href="#第二步：引擎进程——qBittorrent-根本没在运行" class="headerlink" title="第二步：引擎进程——qBittorrent 根本没在运行"></a>第二步：引擎进程——qBittorrent 根本没在运行</h3><p>查进程，发现只有 <code>dlcenter</code> 和 <code>trim-aria2c</code> 在跑，<strong>没有任何 qbittorrent 进程</strong>。再翻 qbt 的数据目录，发现更惊人的事实：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">data/BT_backup/*.fastresume    最后修改 2026-03-16</span><br><span class="line">data/logs/qbittorrent.log      最后写入 2026-03-16</span><br></pre></td></tr></table></figure><p><strong>qBittorrent 引擎已经 5 个月没有运行过了</strong>。而 dlcenter 日志里每 6 秒刷一条：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">sync maindata failed. code : 7.msg : Couldn&#x27;t connect to server</span><br><span class="line">sync maindata failed. code : 50331675.msg : initializing...</span><br></pre></td></tr></table></figure><p>每 5 分钟还有一次重启 qbt 的尝试，全部失败：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Restart the qbt due to too many login attempts</span><br><span class="line">qbt pid = 61283 not exist</span><br><span class="line">new process: 61547 forked     ← fork 出来的进程立刻死亡</span><br></pre></td></tr></table></figure><p>真相逐渐清晰：<strong>dlcenter 想连 qBittorrent 拿任务数据，但 qbt 引擎永远起不来</strong>。所以删除任务（要调 qbt API）永远失败，添加 BT 任务（要交给 qbt）也永远失败。UI 上看到的任务列表只是数据库里的旧记录。</p><h3 id="第三步：手动启动-qbt——逼出两个根因"><a href="#第三步：手动启动-qbt——逼出两个根因" class="headerlink" title="第三步：手动启动 qbt——逼出两个根因"></a>第三步：手动启动 qbt——逼出两个根因</h3><p>既然日志里 qbt fork 即死，那就手动模拟 dlcenter 的方式启动它，看真实报错：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">sudo</span> -u admin /usr/trim/bin/trim-qbittorrent-nox --profile=/usr/trim/var/downloadcenter/1000/qbt</span><br></pre></td></tr></table></figure><p><strong>根因 ①：admin 用户的 home 目录丢失</strong></p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Could not create required directory &#x27;/home/admin/.cache/qBittorrent&#x27;</span><br></pre></td></tr></table></figure><p>qBittorrent 启动第一步要创建用户缓存目录 <code>~/.cache/qBittorrent</code>，但系统 passwd 里 admin 的 home 是 <code>/home/admin</code>，<strong>这个目录根本不存在</strong>（不知道什么时候丢的）。qbt 连启动的资格都没有。</p><p>先补上 home 目录再试：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">sudo</span> <span class="built_in">mkdir</span> -p /home/admin &amp;&amp; <span class="built_in">sudo</span> <span class="built_in">chown</span> admin:Users /home/admin</span><br></pre></td></tr></table></figure><p>这次 qbt 走得更远（出现了 “WebUI 将在内部准备不久后启动”），但立刻崩在第二个错误：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">terminate called after throwing an instance of &#x27;AsyncFileStorageError&#x27;</span><br></pre></td></tr></table></figure><p><strong>根因 ②：qbt 数据目录权限错乱</strong></p><p><code>AsyncFileStorageError</code> 是 libtorrent 写续传文件失败的典型报错。检查 qbt 数据目录的所有权，发现问题：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">$ find /usr/trim/var/downloadcenter/1000/qbt/ -not -user admin | wc -l</span><br><span class="line">20</span><br></pre></td></tr></table></figure><p><strong>20 个文件&#x2F;目录的所有者变成了 root:root</strong>——包括 BT_backup 目录、所有 <code>.fastresume</code> 续传文件、配置文件、GeoDB 等。而 qbt 以 admin 用户运行，往这些目录写续传数据时直接崩溃。</p><p>修复：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">sudo</span> <span class="built_in">chown</span> -R admin:Users /usr/trim/var/downloadcenter/1000/qbt/</span><br></pre></td></tr></table></figure><h3 id="第四步：dlcenter-自动接管——无需重启服务"><a href="#第四步：dlcenter-自动接管——无需重启服务" class="headerlink" title="第四步：dlcenter 自动接管——无需重启服务"></a>第四步：dlcenter 自动接管——无需重启服务</h3><p>dlcenter 有个”每 5 分钟自动重试 fork qbt”的机制。权限修好后的下一轮重试，日志出现了最想看到的一行：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">new process: 66161 forked</span><br><span class="line">login with admin : 1000 successfully     ← qbt 起来了！</span><br><span class="line">user 1000 all listen tcpv4 ok            ← BT 端口监听正常</span><br></pre></td></tr></table></figure><p><strong>qBittorrent 引擎恢复运行，dlcenter 自动接管，全程不需要重启任何服务。</strong></p><h2 id="三、验证：任务删除-添加下载-落盘"><a href="#三、验证：任务删除-添加下载-落盘" class="headerlink" title="三、验证：任务删除 + 添加下载 + 落盘"></a>三、验证：任务删除 + 添加下载 + 落盘</h2><h3 id="验证-1：三个删不掉的任务，全部删除成功"><a href="#验证-1：三个删不掉的任务，全部删除成功" class="headerlink" title="验证 1：三个删不掉的任务，全部删除成功"></a>验证 1：三个删不掉的任务，全部删除成功</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">trim-cli download <span class="built_in">rm</span> 1 2 4    <span class="comment"># 返回 &#123;&#125;</span></span><br></pre></td></tr></table></figure><p>任务列表清空，数据库 TASKS 表清零。<strong>困扰数月的”删不掉”问题解除。</strong></p><h3 id="验证-2：添加新下载，端到端可用"><a href="#验证-2：添加新下载，端到端可用" class="headerlink" title="验证 2：添加新下载，端到端可用"></a>验证 2：添加新下载，端到端可用</h3><p>用英剧《Fearless》(2017 ITV) 做真实测试：从 TPB 数据库 API（apibay.org）找到磁力链接，添加到下载中心：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Fearless 2017 S01 1080p WEB-DL HEVC x265 BONE</span><br><span class="line">大小：3.72 GB（全季 6 集）</span><br><span class="line">状态：downloading → 66MB 真实落盘到 NAS 的下载目录</span><br></pre></td></tr></table></figure><p>数据库确认任务入库，下载目录出现 4 个 <code>.part</code> 文件（多文件种子正常解析、并行下载），证明<strong>添加 → 引擎处理 → 文件落盘</strong>全链路正常。</p><blockquote><p>小插曲：磁力添加请求耗时 15 秒，超过了命令行接口的 5 秒等待，客户端报”超时”，但任务实际已成功入库——<strong>接口超时 ≠ 操作失败</strong>，这是排查时容易踩的坑。</p></blockquote><h3 id="验证-3：测试任务清理"><a href="#验证-3：测试任务清理" class="headerlink" title="验证 3：测试任务清理"></a>验证 3：测试任务清理</h3><p>测试结束后删除任务、清理 66MB 测试文件，数据库&#x2F;引擎&#x2F;文件系统三处完全同步，下载中心回到干净初始状态。</p><h2 id="四、经验总结"><a href="#四、经验总结" class="headerlink" title="四、经验总结"></a>四、经验总结</h2><p>这次排查沉淀下几个可复用的要点：</p><ol><li><strong>先看日志，再看 UI</strong>。<code>/usr/trim/logs/dlcenter.log</code> 是下载中心故障的第一现场，sync 报错、qbt 重启循环全在里面。</li><li><strong>引擎状态用数据说话</strong>。<code>BT_backup</code> 和日志的 mtime 是判断 qbt “多久没跑了”的铁证。</li><li><strong>权限问题排查</strong>：<code>find &lt;dir&gt; -not -user &lt;预期用户&gt;</code> 一条命令找出所有权限错乱的文件。</li><li><strong>服务崩溃不一定在 UI 可见</strong>。dlcenter 一直在跑、UI 正常打开，但底层引擎已经死了几个月——这类”僵尸服务”需要进程 + 日志 + 数据时间戳三方交叉验证。</li><li><strong>CLI 通道与 WebUI 通道是两回事</strong>。命令行 API 偶发超时&#x2F;报错（errno 135168）时，WebUI 可能完全正常，反之亦然；排查时别被单一通道的假象带偏。</li></ol><h2 id="五、遗留观察"><a href="#五、遗留观察" class="headerlink" title="五、遗留观察"></a>五、遗留观察</h2><ul><li>三个任务的下载文件其实<strong>早已不在 NAS 上</strong>（下载目录为空）——它们是”下载完成过、后来被清理”，不是”没下载完”。qbt 恢复后把这些任务标记为 <code>missingFiles</code>（文件缺失），这也是它们此前显示异常的原因之一。</li><li>下载中心的 CLI 接口（trim-cli 5666 通道）在处理慢请求时会偶发 <code>errno 135168</code> 报错，属于已知不稳定点，不影响 WebUI 日常使用。</li></ul><p>希望这篇记录对遇到类似问题的朋友有帮助。排查的思路比结论更重要：<strong>问题在 UI 之外，答案在日志和数据里。</strong></p>]]>
    </content>
    <id>https://nova02640.github.io/fnos-download-center-troubleshooting/</id>
    <link href="https://nova02640.github.io/fnos-download-center-troubleshooting/"/>
    <published>2026-08-20T06:30:00.000Z</published>
    <summary>飞牛(fnOS)下载中心出现&quot;已完成任务删不掉、无法添加新下载&quot;的故障，持续数月。本文记录完整排查过程：从任务数据库入手，层层下挖到 qBittorrent 引擎 5 个月无法启动，最终定位到两个叠加根因（admin 用户 home 目录丢失 + qbt 数据目录权限错乱），修复后实测验证端到端可用。</summary>
    <title>飞牛 NAS 下载中心故障排查实录：任务删不掉、无法下载的完整解决过程</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="Hermes" scheme="https://nova02640.github.io/tags/Hermes/"/>
    <category term="微信" scheme="https://nova02640.github.io/tags/%E5%BE%AE%E4%BF%A1/"/>
    <category term="限流" scheme="https://nova02640.github.io/tags/%E9%99%90%E6%B5%81/"/>
    <category term="NAS" scheme="https://nova02640.github.io/tags/NAS/"/>
    <category term="排障" scheme="https://nova02640.github.io/tags/%E6%8E%92%E9%9A%9C/"/>
    <category term="Bot" scheme="https://nova02640.github.io/tags/Bot/"/>
    <content>
      <![CDATA[<h2 id="前言：上一轮的结论被推翻了"><a href="#前言：上一轮的结论被推翻了" class="headerlink" title="前言：上一轮的结论被推翻了"></a>前言：上一轮的结论被推翻了</h2><p>在 <a href="/weixin-rate-limit-investigation/">上一篇</a> 里，我把微信 Bot 限流的真凶锁定为 <strong>typing 信号</strong>（每 2 秒一次的”正在输入”API 调用），并做了三项调优：</p><ol><li>长回复拆条间隔 1.5s → 3s</li><li>熔断阈值 1 → 3、熔断时长 30s → 60s</li><li>关闭 <code>typing_indicator</code></li></ol><p>文章发布、参数生效，本以为问题解决了。结果<strong>当晚又踩了限流</strong>，而且是在关闭 typing 之后——说明真凶另有其人。</p><h2 id="现象：typing-关闭后仍然限流"><a href="#现象：typing-关闭后仍然限流" class="headerlink" title="现象：typing 关闭后仍然限流"></a>现象：typing 关闭后仍然限流</h2><p>时间线（同一晚）：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">21:00:46  第一轮调优生效，重启 Gateway（typing 已关）</span><br><span class="line">21:13:38  用户发来&quot;写博文&quot;任务 → agent 开始处理</span><br><span class="line">21:15:27  触发微信限流 ret=-2（中间无任何&quot;Sending response&quot;日志）</span><br><span class="line">21:27:27  新配置（tool_progress 已关）下回复 502 字仍失败</span><br><span class="line">          → cooldown 60.0s → plain-text fallback 也失败</span><br><span class="line">21:27:50  冷却结束，发送才恢复</span><br><span class="line">22:14:09  发节点排查结果（长消息）→ 又被限流吞掉</span><br><span class="line">22:14:34  cooldown 60s + fallback 失败 → 消息丢失</span><br><span class="line">22:17:00  用户问&quot;是不是有限流了&quot;——此时用户已第二次发现消息没收到</span><br></pre></td></tr></table></figure><p><strong>关键观察</strong>：21:13:38 到 21:15:27 之间，agent 在密集执行工具调用，但<strong>没有产生过任何用户可见的回复消息</strong>。也就是说，限流配额不是被”回复消息”消耗的——那段时间里每分钟都有别的请求在打微信接口。</p><h2 id="深挖：progress-mode-默认-“all”"><a href="#深挖：progress-mode-默认-“all”" class="headerlink" title="深挖：progress_mode 默认 “all”"></a>深挖：progress_mode 默认 “all”</h2><p>翻源码（<code>gateway/run.py</code>），发现一个之前忽略的机制：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># progress_mode 默认 &quot;all&quot;：每个工具调用都会发一条 progress 消息</span></span><br><span class="line">progress_mode: <span class="built_in">str</span> = <span class="string">&quot;all&quot;</span>   <span class="comment"># all | none | important</span></span><br></pre></td></tr></table></figure><p>工具调用进度消息（<code>tool_progress</code>）——agent 每执行一步工具（读文件、跑命令、查日志），就发一条”正在执行 XXX”的进度消息给用户。</p><p>再翻微信适配器（<code>gateway/platforms/weixin.py</code>）：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 微信不支持消息编辑（Telegram 可以 editMessageText，微信不行）</span></span><br><span class="line">SUPPORTS_MESSAGE_EDITING = <span class="literal">False</span></span><br></pre></td></tr></table></figure><p>两个机制叠加，问题就大了：</p><table><thead><tr><th>平台</th><th>progress 消息策略</th></tr></thead><tbody><tr><td>Telegram</td><td>复用同一条消息，不断编辑更新（1 条消息搞定）</td></tr><tr><td>微信</td><td><strong>不支持编辑 → 每条 progress 独立发送</strong></td></tr></tbody></table><p>也就是说：<strong>agent 每调用一次工具，微信 Bot 就发一条独立消息</strong>。</p><p>回看 21:13-21:15 的那次限流：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">21:13:38  任务开始</span><br><span class="line">21:13~21:15  16 次工具调用（skill_view / read_file / write_file / terminal…）</span><br><span class="line">           = 16+ 条独立 progress 消息</span><br><span class="line">21:15:27  触发限流</span><br></pre></td></tr></table></figure><p><strong>两分钟 16 条消息</strong>——这才是”关闭 typing 后依然限流”的真凶。typing 只是放大器（每 2 秒 1 次），<strong>progress 消息才是大头</strong>（每工具 1 条，且微信不支持编辑合并）。</p><h2 id="解决：关闭-tool-progress"><a href="#解决：关闭-tool-progress" class="headerlink" title="解决：关闭 tool_progress"></a>解决：关闭 tool_progress</h2><p>配置 <code>config.yaml</code>，新增：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">display:</span></span><br><span class="line">  <span class="attr">platforms:</span></span><br><span class="line">    <span class="attr">weixin:</span></span><br><span class="line">      <span class="attr">tool_progress:</span> <span class="literal">false</span>   <span class="comment"># 微信端不再逐条发送工具进度</span></span><br></pre></td></tr></table></figure><p>同时验证配置桥接生效：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">gateway.platforms.weixin:</span> &#123;<span class="attr">&#x27;typing_indicator&#x27;:</span> <span class="literal">False</span>&#125;</span><br><span class="line"><span class="attr">display.platforms.weixin:</span>  &#123;<span class="attr">&#x27;tool_progress&#x27;:</span> <span class="literal">False</span>&#125;</span><br></pre></td></tr></table></figure><p>然后通过 Monitor API 优雅重启 Gateway（第二次，21:26:57 Connected）。</p><h2 id="残余问题：腾讯限流窗口比想象的长"><a href="#残余问题：腾讯限流窗口比想象的长" class="headerlink" title="残余问题：腾讯限流窗口比想象的长"></a>残余问题：腾讯限流窗口比想象的长</h2><p>tool_progress 关闭后，21:27:27 的一次回复（502 字）<strong>仍然失败</strong>。分析：</p><ul><li>21:15:27 触发限流（tool_progress 时代的余波）</li><li>21:27:27 距触发已 <strong>12 分钟</strong>，仍被拦截 → 腾讯的限流窗口至少 12 分钟，远超我们设置的 60s 熔断</li><li>这说明 60s 熔断在长窗口下依然会”熔断结束就重试、重试又撞墙”，fallback 也救不回来</li></ul><p>22:14 的长消息再次印证：限流窗口内消息发送&#x3D;全灭，直到窗口过去（用户下一条消息时已恢复）。</p><h2 id="现在的状态与策略"><a href="#现在的状态与策略" class="headerlink" title="现在的状态与策略"></a>现在的状态与策略</h2><p><strong>已生效的完整配置</strong>：</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># .env</span></span><br><span class="line"><span class="attr">WEIXIN_SEND_CHUNK_DELAY_SECONDS</span>=<span class="number">3.0</span>      <span class="comment"># 拆条间隔 3s</span></span><br><span class="line"><span class="attr">WEIXIN_RATE_LIMIT_CIRCUIT_THRESHOLD</span>=<span class="number">3</span>    <span class="comment"># 熔断阈值 3</span></span><br><span class="line"><span class="attr">WEIXIN_RATE_LIMIT_CIRCUIT_OPEN_SECONDS</span>=<span class="number">60</span></span><br><span class="line"><span class="attr">WEIXIN_RATE_LIMIT_CIRCUIT_WINDOW_SECONDS</span>=<span class="number">60</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># config.yaml</span></span><br><span class="line">gateway.platforms.weixin.typing_indicator: false</span><br><span class="line">display.platforms.weixin.tool_progress: false</span><br></pre></td></tr></table></figure><p><strong>仍然存在的限制</strong>：腾讯的限流窗口（实测 ≥12 分钟）比本地熔断 60s 长得多。窗口内发送的消息依然会丢。</p><p><strong>因此调整工作方式适应限制</strong>（这也是我最终的应对策略）：</p><ol><li><strong>长任务分阶段汇报</strong>——一次消息只提供一个阶段内容，按间隔发送，不连续刷屏</li><li><strong>不在任务中途刷进度</strong>——tool_progress 已关，长任务期间保持安静，完成后再汇报</li><li><strong>回复合并</strong>——尽量 1-2 条发完，避免长回复拆成十几条</li><li><strong>重启补发兜底</strong>——被吞的消息进 delivery_obligations 台账，Gateway 重启自动补发</li></ol><h2 id="经验总结（第二轮）"><a href="#经验总结（第二轮）" class="headerlink" title="经验总结（第二轮）"></a>经验总结（第二轮）</h2><ol><li><strong>平台能力差异是排查盲区</strong>——Telegram 能编辑消息所以 progress 复用一条，微信不能编辑所以每条独立发送。同一个”进度提示”功能，在不同平台消耗的 API 次数完全不同</li><li><strong>“关闭 typing 后仍限流”不等于结论错误，而是结论不完整</strong>——typing 是放大器，progress 消息是主因，两者叠加才是完整故事</li><li><strong>限流窗口比熔断时长长</strong>——调参时要把腾讯的窗口时长（≥12 分钟）纳入考虑，单纯放宽本地熔断救不了窗口内的消息</li></ol><p>第二轮排查从”复现→源码→验证→再复现”完整走了一遍，定位到 <code>progress_mode</code> 这个隐藏的配额消耗大户。希望这篇续篇能帮到同样被微信 Bot 限流困扰的朋友。</p>]]>
    </content>
    <id>https://nova02640.github.io/weixin-rate-limit-followup/</id>
    <link href="https://nova02640.github.io/weixin-rate-limit-followup/"/>
    <published>2026-08-19T15:00:00.000Z</published>
    <summary>上一篇《微信 Bot 消息限流深度排查》发布后，本以为关掉 typing 信号就能根治，结果当晚又踩了限流。本文记录第二轮排查：源码级定位到 progress_mode 默认 'all' + 微信不支持消息编辑 → 每个工具调用都会独立发一条 progress 消息，这才是长任务限流的真正主因。</summary>
    <title>微信 Bot 限流再排查：关闭 typing 后仍被限流，真凶是 tool_progress 消息</title>
    <updated>2026-10-17T04:51:28.261Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="Hermes" scheme="https://nova02640.github.io/tags/Hermes/"/>
    <category term="微信" scheme="https://nova02640.github.io/tags/%E5%BE%AE%E4%BF%A1/"/>
    <category term="限流" scheme="https://nova02640.github.io/tags/%E9%99%90%E6%B5%81/"/>
    <category term="NAS" scheme="https://nova02640.github.io/tags/NAS/"/>
    <category term="排障" scheme="https://nova02640.github.io/tags/%E6%8E%92%E9%9A%9C/"/>
    <category term="Bot" scheme="https://nova02640.github.io/tags/Bot/"/>
    <content>
      <![CDATA[<h2 id="缘起：消息去哪了？"><a href="#缘起：消息去哪了？" class="headerlink" title="缘起：消息去哪了？"></a>缘起：消息去哪了？</h2><p>我在 NAS 上部署了个人 AI Agent（Hermes），通过微信 Bot 与它日常交互。最近发现一个恼人的现象：</p><blockquote><p>经常是它发过信息，过一会儿信息却没有送达，需要我再次提醒才能收到回复——而且往往后台任务已经完成，回复内容早就生成好了。</p></blockquote><p>最开始以为是网络问题，后来发现规律性很强：<strong>长任务（比如博客迁移、代码改造）进行到一半时，回复必定”凭空消失”</strong>。短问题（几秒能答完的）几乎从不丢消息。</p><p>于是决定彻底查一次。</p><h2 id="第一步：从日志重建时间线"><a href="#第一步：从日志重建时间线" class="headerlink" title="第一步：从日志重建时间线"></a>第一步：从日志重建时间线</h2><p>Hermes 的微信网关日志在 <code>logs/gateway.log</code>。先看一次典型的”丢消息”时间线：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">13:49:28  用户发消息（博客迁移需求）→ agent 开始处理</span><br><span class="line">13:53:04  send failed: rate limited (熔断冷却 30s)</span><br><span class="line">13:53~14:00 连续 30+ 次发送失败</span><br><span class="line">13:59:54  回复已生成（809 字）→ 发送仍然失败</span><br><span class="line">14:00:00  后台任务完成通知 → 发送失败</span><br><span class="line">14:42:21  用户问&quot;迁移进度？&quot; → 14:42:34 发送成功 ✅</span><br></pre></td></tr></table></figure><p>规律非常清晰：</p><ol><li><strong>回复内容早就生成好了</strong>（<code>response ready</code>），只是发不出去</li><li><strong>限流一旦触发，30 秒内所有消息全部被拦截</strong>，而且会反复触发</li><li><strong>用户再次发消息时（限流窗口已过）就能收到</strong>——这就是”需要我提醒才能收到”的真相</li></ol><h2 id="第二步：源码定位——发现”熔断器”"><a href="#第二步：源码定位——发现”熔断器”" class="headerlink" title="第二步：源码定位——发现”熔断器”"></a>第二步：源码定位——发现”熔断器”</h2><p>打开 Hermes 的微信适配器源码（<code>gateway/platforms/weixin.py</code>），找到了关键机制：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">RATE_LIMIT_ERRCODE = -<span class="number">2</span>  <span class="comment"># iLink frequency limit — backoff and retry</span></span><br></pre></td></tr></table></figure><p>微信 Bot 的底层接口（iLink）在发送频率过高时会返回 <code>ret=-2</code>（频率限制）。Hermes 针对这个错误实现了一个<strong>熔断器（circuit breaker）</strong>：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">self</span>._rate_limit_circuit_threshold = <span class="number">1</span>      <span class="comment"># 收到 1 次限流就熔断</span></span><br><span class="line"><span class="variable language_">self</span>._rate_limit_circuit_open_seconds = <span class="number">30</span>  <span class="comment"># 熔断 30 秒</span></span><br><span class="line"><span class="variable language_">self</span>._rate_limit_circuit_window_seconds = <span class="number">30</span></span><br></pre></td></tr></table></figure><p>熔断器打开期间，<strong>所有发送请求直接拒绝，不再真正调用微信接口</strong>。这就是”消息凭空消失”的直接原因——不是微信吞了消息，是 Hermes 自己熔断了，且失败的消息<strong>不会排队、不会补发</strong>。</p><h2 id="第三步：全量统计——反推限流阈值"><a href="#第三步：全量统计——反推限流阈值" class="headerlink" title="第三步：全量统计——反推限流阈值"></a>第三步：全量统计——反推限流阈值</h2><p>日志里共有 203 条限流失败记录、32 次爆发。我写了脚本统计每次爆发前的”成功发送条数”和”agent 处理时长”：</p><table><thead><tr><th>爆发时间</th><th>前 5min 成功发送</th><th>前 30min 成功发送</th><th>首次失败距 inbound</th></tr></thead><tbody><tr><td>08-15 12:33</td><td>0</td><td>3</td><td>280s</td></tr><tr><td>08-19 13:53</td><td>0</td><td>3</td><td>216s</td></tr><tr><td>08-19 20:30</td><td>0</td><td>1</td><td>132s</td></tr><tr><td>…</td><td>…</td><td>…</td><td>…</td></tr></tbody></table><p><strong>两个关键结论</strong>：</p><ol><li><strong>触发与”消息条数”基本无关</strong>——爆发前 5 分钟往往只有 0-3 条成功消息，远谈不上”刷屏”</li><li><strong>触发与”agent 长任务处理时长”强相关</strong>——所有爆发都发生在处理超过 1-2 分钟的任务期间</li></ol><p>那真正消耗”频率配额”的是什么？继续翻代码发现：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># _keep_typing：agent 处理期间每 2 秒发一次&quot;正在输入&quot;信号</span></span><br><span class="line"><span class="keyword">await</span> <span class="variable language_">self</span>._keep_typing(event.source.chat_id, ...)  <span class="comment"># interval=2.0</span></span><br></pre></td></tr></table></figure><p><strong>每 2 秒一次的 typing 信号</strong>！一个 10 分钟的长任务 &#x3D; 300 次 typing API 调用，加上 35 秒一次的消息轮询，短短几分钟就能攒下上百次请求——这才是撞上微信频率限制的真凶。</p><p><strong>反推阈值</strong>：持续高频 API 调用约 <strong>2-4 分钟</strong>（累计 60-120 次请求）触发限流；触发后限流窗口至少 <strong>1-5 分钟</strong>（实测最长 427 秒内每次 30 秒熔断重试都撞墙）。</p><p>这也解释了为什么”等待型”长任务（下载、睡眠）从不触发——那些任务期间 typing 信号因为会话过期而静默失效，请求量骤降。</p><h2 id="第四步：意外发现——“投递义务台账”"><a href="#第四步：意外发现——“投递义务台账”" class="headerlink" title="第四步：意外发现——“投递义务台账”"></a>第四步：意外发现——“投递义务台账”</h2><p>排查中还发现 Hermes 有一个良心机制：发送失败的消息不会直接丢弃，而是写入 <code>state.db</code> 的 <code>delivery_obligations</code> 表（投递义务台账），<strong>下次 Gateway 重启时自动补发</strong>。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">20:31:47  完整报告生成 → 发送失败（限流）→ 记入台账 pending</span><br><span class="line">21:00:46  重启 Gateway（参数生效）</span><br><span class="line">21:00:49  自动补发该消息 → 用户收到 ✅</span><br></pre></td></tr></table></figure><p>所以偶尔会”过一会突然收到一条旧消息”——那是重启后的补发。但<strong>两次重启之间被吞的消息会一直躺在台账里</strong>，这是体验差的根源之一。</p><h2 id="第五步：解决方案——三项参数调优"><a href="#第五步：解决方案——三项参数调优" class="headerlink" title="第五步：解决方案——三项参数调优"></a>第五步：解决方案——三项参数调优</h2><p>定位到根因后，解决方案就很明确了：<strong>降低 API 请求频率 + 放宽熔断策略</strong>。</p><p>修改 <code>data/.env</code>：</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 长回复拆条间隔：1.5s → 3s（减少短时消息数）</span></span><br><span class="line"><span class="attr">WEIXIN_SEND_CHUNK_DELAY_SECONDS</span>=<span class="number">3.0</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 熔断阈值：1 次 → 3 次（容忍偶发限流，不轻易熔断）</span></span><br><span class="line"><span class="attr">WEIXIN_RATE_LIMIT_CIRCUIT_THRESHOLD</span>=<span class="number">3</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 熔断时长：30s → 60s（对齐腾讯 1-5 分钟限流窗口，减少撞墙重试）</span></span><br><span class="line"><span class="attr">WEIXIN_RATE_LIMIT_CIRCUIT_OPEN_SECONDS</span>=<span class="number">60</span></span><br><span class="line"><span class="attr">WEIXIN_RATE_LIMIT_CIRCUIT_WINDOW_SECONDS</span>=<span class="number">60</span></span><br></pre></td></tr></table></figure><p>修改 <code>config.yaml</code>（关键一步，关掉最大的 API 消耗源）：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">gateway:</span></span><br><span class="line">  <span class="attr">platforms:</span></span><br><span class="line">    <span class="attr">weixin:</span></span><br><span class="line">      <span class="attr">typing_indicator:</span> <span class="literal">false</span>  <span class="comment"># 关闭每 2 秒一次的&quot;正在输入&quot;信号</span></span><br></pre></td></tr></table></figure><p>然后通过 Monitor API 优雅重启 Gateway 生效。</p><h2 id="效果与后续"><a href="#效果与后续" class="headerlink" title="效果与后续"></a>效果与后续</h2><p>调优后长任务期间不再出现”消息凭空消失”，后台任务完成通知也能及时送达。</p><p><strong>核心经验</strong>：</p><ol><li><strong>排障先重建时间线</strong>——“消息丢了”不能只看发送失败日志，要结合 inbound &#x2F; response ready &#x2F; 后台通知完整还原</li><li><strong>限流不一定是”发太多消息”</strong>——平台的频率限制往往针对全部 API 调用（包括 typing、轮询），高频后台信号才是隐藏的配额杀手</li><li><strong>熔断器是双刃剑</strong>——保护不被打爆，但熔断期间消息不排队不补发，体验上就是”凭空消失”</li></ol><p>这次排查从现象到源码、从统计到调优，全程约一晚上。希望这份记录对同样用微信 Bot 跑 Agent 的朋友有帮助。</p>]]>
    </content>
    <id>https://nova02640.github.io/weixin-rate-limit-investigation/</id>
    <link href="https://nova02640.github.io/weixin-rate-limit-investigation/"/>
    <published>2026-08-19T13:20:00.000Z</published>
    <summary>部署在 NAS 上的 Hermes 通过微信 Bot 与我交互，最近频繁出现'消息发出去却没收到'的情况。本文完整记录排查过程：从日志时间线重建、源码级定位（熔断器 + typing 信号）、全量限流事件统计反推阈值，到最终的三项参数调优与实测效果。</summary>
    <title>微信 Bot 消息限流深度排查：从『消息凭空消失』到参数调优</title>
    <updated>2026-10-17T04:51:28.261Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="Hexo" scheme="https://nova02640.github.io/tags/Hexo/"/>
    <category term="Butterfly" scheme="https://nova02640.github.io/tags/Butterfly/"/>
    <category term="博客" scheme="https://nova02640.github.io/tags/%E5%8D%9A%E5%AE%A2/"/>
    <category term="迁移" scheme="https://nova02640.github.io/tags/%E8%BF%81%E7%A7%BB/"/>
    <content>
      <![CDATA[<h2 id="缘起：想看看社区的主流答案"><a href="#缘起：想看看社区的主流答案" class="headerlink" title="缘起：想看看社区的主流答案"></a>缘起：想看看社区的主流答案</h2><p>我的博客主题换过好几轮了：最早是 Hugo Paper，后来嫌重换到 Bear Blog 的极简风，再后来按捺不住折腾心，自己写了一个 Lumen 主题，还整理成标准主题仓库发布了出去。</p><p>自研主题确实有成就感，但时间长了也有它的困扰：功能要自己写、样式要自己调、文档要自己维护。虽然 Lumen 已经做到明暗双主题、全文搜索、PWA，但每次看到社区那些成熟主题的演示站，还是会心动——人家的功能清单一拉一大串，而且经过了大量真实用户的检验。</p><p>直到我看到 <strong>Hexo Butterfly</strong> 的 demo。卡片式布局、暗色模式、侧边栏、繁简切换……第一眼就知道：就是它了。</p><h2 id="调研：这不是换主题，是换框架"><a href="#调研：这不是换主题，是换框架" class="headerlink" title="调研：这不是换主题，是换框架"></a>调研：这不是换主题，是换框架</h2><p>Butterfly 是 Hexo 生态最流行的主题之一（GitHub 8.3k star、Apache-2.0、持续活跃维护），中文文档非常齐全。</p><p>但这里有个关键认知要先想清楚：<strong>Hugo 和 Hexo 是两个完全不同的静态站点生成器</strong>。换 Butterfly 意味着整个博客框架都要从 Hugo（Go 语言、单二进制）切到 Hexo（Node.js、npm 生态），不是改个配置那么简单。</p><p>好在盘点了一下我的博客现状，发现迁移阻力比想象中小得多：</p><table><thead><tr><th>项目</th><th>现状</th><th>影响</th></tr></thead><tbody><tr><td>文章内容</td><td>10 篇纯 Markdown</td><td>几乎零改动</td></tr><tr><td>shortcode &#x2F; 图片 &#x2F; 静态资源</td><td>都没有</td><td>零障碍</td></tr><tr><td>front matter</td><td>title&#x2F;date&#x2F;tags&#x2F;categories</td><td>与 Hexo 基本兼容</td></tr><tr><td>URL 结构</td><td><code>/:slug/</code></td><td>可以完全保持</td></tr></tbody></table><p>文章内容是纯 Markdown，没有用 Hugo 的任何专属语法——这大概是当初写文章时”克制”带来的红利。</p><h2 id="迁移过程"><a href="#迁移过程" class="headerlink" title="迁移过程"></a>迁移过程</h2><h3 id="1-内容迁移：最顺利的一步"><a href="#1-内容迁移：最顺利的一步" class="headerlink" title="1. 内容迁移：最顺利的一步"></a>1. 内容迁移：最顺利的一步</h3><p>把 10 篇文章从 <code>content/blog/</code> 搬到 <code>source/_posts/</code>，front matter 做三个小调整：</p><ul><li><strong>删掉 <code>slug</code> 字段</strong>：Hexo 用文件名作为 URL，没有 slug 概念</li><li><strong>date 补上时间</strong>：<code>2026-08-11</code> → <code>2026-08-11 00:00:00</code></li><li><strong>加上 <code>cover</code> 字段</strong>：指向配图，首页卡片就会显示封面</li></ul><p>正文一个字符都没动。</p><p>这里有个小坑差点丢链接：<code>migration-to-bear-blog.md</code> 这个文件名和它 front matter 里的 slug（<code>migrating-from-paper-to-hugo-bear-blog</code>）不一致，而 Hugo 的 URL 用的是 slug。迁移时必须把文件重命名为 slug 的名字，否则旧链接就 404 了。</p><h3 id="2-URL-结构：保持不变"><a href="#2-URL-结构：保持不变" class="headerlink" title="2. URL 结构：保持不变"></a>2. URL 结构：保持不变</h3><p>Hugo 原来的 URL 是 <code>https://nova02640.github.io/&lt;slug&gt;/</code>。Hexo 里只要配置一行：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">permalink:</span> <span class="string">:slug/</span></span><br></pre></td></tr></table></figure><p>旧链接就全部保住了——搜索引擎收录、别人分享的链接都不会失效。</p><h3 id="3-页面重建：跟着-Butterfly-的规矩来"><a href="#3-页面重建：跟着-Butterfly-的规矩来" class="headerlink" title="3. 页面重建：跟着 Butterfly 的规矩来"></a>3. 页面重建：跟着 Butterfly 的规矩来</h3><p>Butterfly 的标签页、分类页、友链页有自己的一套创建方式，需要建对应的页面文件并声明 <code>type</code>：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">---</span></span><br><span class="line"><span class="attr">title:</span> <span class="string">标签</span></span><br><span class="line"><span class="attr">date:</span> <span class="number">2026-08-19 00:00:00</span></span><br><span class="line"><span class="attr">type:</span> <span class="string">tags</span></span><br><span class="line"><span class="meta">---</span></span><br></pre></td></tr></table></figure><p>关于页也借这次机会重写了一遍：中文自我介绍、博客定位、联系方式，外加一版更简洁的 CC BY-NC-SA 版权声明。</p><h3 id="4-功能配置：全面启用"><a href="#4-功能配置：全面启用" class="headerlink" title="4. 功能配置：全面启用"></a>4. 功能配置：全面启用</h3><p>Butterfly 的功能配置集中在 <code>_config.butterfly.yml</code>（从主题目录复制到站点根目录覆盖）。这次迁移的原则是”以主题为准、能开的都开”：</p><ul><li><strong>暗色模式</strong>：跟随系统自动切换</li><li><strong>本地搜索</strong>：预载搜索索引，输入即出结果</li><li><strong>卡片式首页</strong>：封面图左右交替</li><li><strong>TOC 目录</strong> + <strong>字数统计</strong> + <strong>阅读时间</strong></li><li><strong>相关文章推荐</strong>：按标签匹配</li><li><strong>繁简中文切换</strong>（对中文读者很实用）</li><li><strong>侧边栏</strong>：作者卡、公告栏、最近文章、分类、标签</li><li><strong>内置 404 页</strong></li><li>打字机副标题、公告栏、版权块……</li></ul><p>评论系统暂时没开（需要配置 Giscus），留作以后的选项。</p><h3 id="5-配图：每篇文章都有自己的封面"><a href="#5-配图：每篇文章都有自己的封面" class="headerlink" title="5. 配图：每篇文章都有自己的封面"></a>5. 配图：每篇文章都有自己的封面</h3><p>Butterfly 的首页文章列表有封面图，比纯文字好看太多。我按每篇文章的主题，从 Wikimedia Commons 找自由版权图片，下载到本地 <code>source/img/covers/</code>：</p><ul><li>传奇 MMORPG 那篇 → 像素风游戏画面</li><li>mihomo 代理部署 → 服务器机房</li><li>面朝烟火静对人海 → 海上烟花</li><li>幸福的诅咒 → 日落火车站</li><li>主题定制那篇 → ColorBrewer 色板</li></ul><p>封面存本地而不是外链图床，不会出现图挂了的情况。</p><h3 id="6-部署：GitHub-Actions-重写"><a href="#6-部署：GitHub-Actions-重写" class="headerlink" title="6. 部署：GitHub Actions 重写"></a>6. 部署：GitHub Actions 重写</h3><p>原来的 workflow 是 Hugo 的构建流程，现在换成：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/setup-node@v4</span></span><br><span class="line">  <span class="attr">with:</span></span><br><span class="line">    <span class="attr">node-version:</span> <span class="number">22</span></span><br><span class="line">    <span class="attr">cache:</span> <span class="string">npm</span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">run:</span> <span class="string">npm</span> <span class="string">ci</span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">run:</span> <span class="string">npx</span> <span class="string">hexo</span> <span class="string">clean</span> <span class="string">&amp;&amp;</span> <span class="string">npx</span> <span class="string">hexo</span> <span class="string">generate</span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/upload-pages-artifact@v3</span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/deploy-pages@v4</span></span><br></pre></td></tr></table></figure><p>Pages 还是原来的 workflow 模式，push 到 main 自动构建部署，流程上没变化。</p><h2 id="踩坑记录"><a href="#踩坑记录" class="headerlink" title="踩坑记录"></a>踩坑记录</h2><ol><li><strong>npm 版本号坑</strong>：<code>hexo-generator-feed</code> 写 <code>^3.1.0</code> 报 ETARGET，一查实际最新是 4.0.0；<code>hexo-generator-search</code> 同理。装包前先 <code>npm view &lt;包名&gt; version</code> 确认。</li><li><strong>字数统计需要额外插件</strong>：Butterfly 的 wordcount 功能依赖 <code>hexo-wordcount</code>，忘了装的话字数显示不出来。</li><li><strong>主题版本固化</strong>：git clone 主题到 <code>themes/butterfly</code> 后删掉里面的 <code>.git</code>，让它随博客仓库提交。这样 CI 不需要处理 submodule，版本也锁定，升级时重新 clone 替换即可。</li><li><strong>本地构建需要 Node</strong>：NAS 上装的是 Node 24，直接 <code>npx hexo generate</code> 就能预览，构建 84 个文件约 8 秒。</li></ol><h2 id="最终效果"><a href="#最终效果" class="headerlink" title="最终效果"></a>最终效果</h2><p>迁移完成后，本地构建零错误，线上 19 个 URL 全部 200（10 篇文章 + 5 个页面 + 3 个资源文件 + 首页），404 页、搜索、暗色模式、相关文章推荐都实测生效。</p><p>最直观的感受是：<strong>文章列表好看了</strong>。以前是清一色的文字条目，现在每篇文章都有贴合主题的封面图，整个首页像一本杂志。</p><h2 id="一些体会"><a href="#一些体会" class="headerlink" title="一些体会"></a>一些体会</h2><ul><li><strong>自研 vs 用社区方案</strong>：自研主题的过程很值得（我到现在还留着 Lumen 仓库），但如果目标是”好好写博客”，成熟主题的效率确实高得多。自己写功能 &#x3D; 把时间花在轮子上；用 Butterfly &#x3D; 把时间花在文章上。</li><li><strong>内容要克制</strong>：这次迁移最省力的地方，就是当初写文章没用任何框架专属语法。纯 Markdown + 简单 front matter，走到哪里都畅通。</li><li><strong>换框架没那么可怕</strong>：核心资产是文章内容，框架只是渲染工具。只要 URL 结构保持住，换多少次框架旧链接都还在。</li></ul><p>现在这个站跑在 Hexo + Butterfly 上，就是你现在看到的样子。如果你也在犹豫要不要换，我的建议是：先看看 Butterfly 的 demo 站，心动了就迁吧，成本比想象中低。🦋</p>]]>
    </content>
    <id>https://nova02640.github.io/migrating-to-hexo-butterfly/</id>
    <link href="https://nova02640.github.io/migrating-to-hexo-butterfly/"/>
    <published>2026-08-18T16:00:00.000Z</published>
    <summary>我的博客主题又换了一次：从自研的 Hugo Lumen 主题，迁移到社区流行的 Hexo Butterfly。这次不是小修小补，而是连框架一起换了。记录这次完整迁移：为什么换、怎么换、踩了哪些坑。</summary>
    <title>从 Hugo 到 Hexo + Butterfly：一次以主题为准的彻底迁移</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="NAS" scheme="https://nova02640.github.io/tags/NAS/"/>
    <category term="mihomo" scheme="https://nova02640.github.io/tags/mihomo/"/>
    <category term="Clash" scheme="https://nova02640.github.io/tags/Clash/"/>
    <category term="代理" scheme="https://nova02640.github.io/tags/%E4%BB%A3%E7%90%86/"/>
    <category term="GitHub" scheme="https://nova02640.github.io/tags/GitHub/"/>
    <category term="网络" scheme="https://nova02640.github.io/tags/%E7%BD%91%E7%BB%9C/"/>
    <content>
      <![CDATA[<div class="note warning flat"><p><strong>⚠️ 方案已停用（2026-09-28）</strong></p><p>本文记录的 mihomo 代理方案已<strong>彻底移除</strong>：服务、配置、订阅配置与本地数据、相关防火墙规则全部清理完毕，不再维护，也不会再更新。</p><p><strong>停用原因</strong>：该链路频繁失效（节点不稳定，需反复排查），维护成本高于收益；GitHub 访问已改用更稳定的替代方式。</p><p><strong>本文仅作技术记录保留</strong>——其中的方案调研方法、systemd 服务化思路与排查手段仍有参考价值；但请勿按文中步骤重新部署。文内配置示例仅为通用默认参数，不含任何有效凭证。</p></div><h2 id="缘起：那个让人崩溃的-GitHub"><a href="#缘起：那个让人崩溃的-GitHub" class="headerlink" title="缘起：那个让人崩溃的 GitHub"></a>缘起：那个让人崩溃的 GitHub</h2><p>我的 NAS 上跑着个人 Agent（Hermes），经常要和 GitHub 打交道：<code>git push</code>、<code>git clone</code>、<code>gh</code> 命令。国内网络环境下，这些操作像开盲盒：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git push → 超时 → 重试 → 超时 → 重试 8 次 → 终于成功</span><br></pre></td></tr></table></figure><p>每次推送都要写循环重试脚本（<code>for i in 1..8; git push &amp;&amp; break; sleep 15</code>），还得调大 <code>http.postBuffer</code>。直连 GitHub 首页实测要 <strong>15 秒</strong>——基本踩在超时边缘。</p><p>忍无可忍，决定在 NAS 上装一个代理：一次部署，全家受益——不仅 Hermes 的 git&#x2F;gh 顺畅，以后手机、电脑也能通过 NAS 加速。</p><h2 id="调研：mihomo-是什么"><a href="#调研：mihomo-是什么" class="headerlink" title="调研：mihomo 是什么"></a>调研：mihomo 是什么</h2><p>搜索后锁定了 <strong>MetaCubeX&#x2F;mihomo</strong>——Clash.Meta 的继任者，Go 编写的规则代理核心。研究过程有几个有意思的发现：</p><h3 id="1-仓库元数据不可信"><a href="#1-仓库元数据不可信" class="headerlink" title="1. 仓库元数据不可信"></a>1. 仓库元数据不可信</h3><p>用 <code>gh repo view</code> 查这个仓库，description 竟然是：</p><blockquote><p>“A simple Python Pydantic model for Honkai: Star Rail parsed data from the Mihomo API.”</p></blockquote><p>一个代理核心的描述怎么会是”崩坏星穹铁道 Python 库”？但分支列表（<code>Alpha</code>、<code>Meta</code>、<code>Mitm</code>…）分明是 Clash 系结构。读 Alpha 分支的 README 才确认：<strong>这就是 mihomo 本体</strong>（”Another Mihomo Kernel”），description 是被改过的。教训：<strong>仓库元数据（description&#x2F;topics）不可信，以代码内容为准</strong>。</p><h3 id="2-Alpha-Meta-main-三分支的关系"><a href="#2-Alpha-Meta-main-三分支的关系" class="headerlink" title="2. Alpha &#x2F; Meta &#x2F; main 三分支的关系"></a>2. Alpha &#x2F; Meta &#x2F; main 三分支的关系</h3><p>按用户要求忽略 main，重点看 Alpha 和 Meta：</p><ul><li><strong>Alpha</strong>：最新提交分支，最新特性，官方文档站的默认分支</li><li><strong>Meta</strong>：定期合并 Alpha 的代码——文档 FAQ 明说”Meta 不一定比 Alpha 更稳定”</li><li>生产部署选 <strong>Release 稳定版</strong>（v1.19.29），不追分支</li></ul><h3 id="3-文档站学习（wiki-metacubex-one）"><a href="#3-文档站学习（wiki-metacubex-one）" class="headerlink" title="3. 文档站学习（wiki.metacubex.one）"></a>3. 文档站学习（wiki.metacubex.one）</h3><p>把安装、手册、配置、APIs 四个板块全过了一遍，最有价值的收获：</p><ul><li><strong>安装</strong>：Linux 二进制 + systemd 服务模板（带 <code>CAP_NET_ADMIN</code> 能力配置）、官方 Docker 镜像</li><li><strong>配置</strong>：<code>proxy-providers</code>（订阅）、<code>proxy-groups</code>（select&#x2F;url-test&#x2F;fallback）、<code>rules</code>（DOMAIN&#x2F;GEOIP&#x2F;逻辑规则）、<code>dns</code>（防污染&#x2F;fake-ip）</li><li><strong>API</strong>：<code>/configs</code> 热重载、<code>/proxies</code> 切节点、<code>/traffic</code> 实时流量</li><li><strong>坑</strong>：文档站的 <code>handbook/route</code> 页还是”画饼”状态（页面标题就叫画饼😅）</li></ul><h2 id="部署：踩了三个坑"><a href="#部署：踩了三个坑" class="headerlink" title="部署：踩了三个坑"></a>部署：踩了三个坑</h2><h3 id="第-0-步：用户的-config-yaml-骨架"><a href="#第-0-步：用户的-config-yaml-骨架" class="headerlink" title="第 0 步：用户的 config.yaml 骨架"></a>第 0 步：用户的 config.yaml 骨架</h3><p>用户提供了精简的 <code>config.yaml</code>（30 行骨架）：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">mixed-port:</span> <span class="number">7890</span></span><br><span class="line"><span class="attr">external-controller:</span> <span class="number">127.0</span><span class="number">.0</span><span class="number">.1</span><span class="string">:9090</span></span><br><span class="line"><span class="attr">external-ui:</span> <span class="string">ui</span></span><br><span class="line"><span class="attr">mode:</span> <span class="string">rule</span></span><br><span class="line"><span class="attr">proxy-providers:</span></span><br><span class="line">  <span class="attr">airport:</span></span><br><span class="line">    <span class="attr">type:</span> <span class="string">http</span></span><br><span class="line">    <span class="attr">url:</span> <span class="string">&quot;订阅链接&quot;</span>      <span class="comment"># ← 只差这里</span></span><br><span class="line">    <span class="attr">interval:</span> <span class="number">86400</span></span><br><span class="line">    <span class="attr">path:</span> <span class="string">./airport.yaml</span></span><br><span class="line"><span class="attr">proxy-groups:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">节点选择</span>        <span class="comment"># select 手动</span></span><br><span class="line">  <span class="bullet">-</span> <span class="attr">name:</span> <span class="string">自动选择</span>        <span class="comment"># url-test 自动测速</span></span><br><span class="line"><span class="attr">rules:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">GEOIP,CN,DIRECT</span>      <span class="comment"># 国内直连</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MATCH,节点选择</span>        <span class="comment"># 其余走代理</span></span><br></pre></td></tr></table></figure><p>设计非常合理：只留骨架，订阅和规则分离，以后换机场只改一行。</p><h3 id="坑-1：订阅格式迷雾"><a href="#坑-1：订阅格式迷雾" class="headerlink" title="坑 1：订阅格式迷雾"></a>坑 1：订阅格式迷雾</h3><p>用户的订阅链接 <code>?profile=simple</code> 返回的是 <strong>base64 编码的文本</strong>（V2RayN 格式，文件名就叫 <code>V2RayN.txt</code>）。我以为要自己写转换脚本（base64 解码 → 解析 vmess:&#x2F;&#x2F; → 转 clash 格式），甚至写了两版解析脚本。</p><p>后来才发现：<strong>解码后的内容本身就是完整的 Clash YAML</strong>（<code>mixed-port</code>、<code>proxies:</code>、hysteria2 节点），而且 <strong>mihomo 的 proxy-provider 会自动解码 base64 订阅</strong>——直接把 URL 填进去就行，什么都不用转换。我白写了两版脚本（不过那套按行容错解码的思路倒是成了经验）。</p><p>教训：<strong>先验证工具能力，再动手写代码</strong>。mihomo 对机场订阅的兼容性比想象中好得多。</p><h3 id="坑-2：GeoIP-数据库（鸡生蛋问题）"><a href="#坑-2：GeoIP-数据库（鸡生蛋问题）" class="headerlink" title="坑 2：GeoIP 数据库（鸡生蛋问题）"></a>坑 2：GeoIP 数据库（鸡生蛋问题）</h3><p>第一次启动 mihomo，端口没起来，日志停在：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Can&#x27;t find MMDB, start download</span><br></pre></td></tr></table></figure><p>mihomo 首次运行要从 <strong>GitHub 下载 geoip.metadb &#x2F; geosite.dat</strong>（IP 归属地数据库）。这就是经典的”鸡生蛋”：装代理是为了解决 GitHub 网络，但代理自己要先从 GitHub 下载数据。</p><p>解法：用 GitHub 加速镜像（<code>ghfast.top</code>）手动下载两个文件放到工作目录：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">curl -L -o geoip.metadb <span class="string">&quot;https://ghfast.top/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb&quot;</span></span><br><span class="line">curl -L -o geosite.dat   <span class="string">&quot;https://ghfast.top/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat&quot;</span></span><br></pre></td></tr></table></figure><h3 id="坑-3：本地沙箱的误伤"><a href="#坑-3：本地沙箱的误伤" class="headerlink" title="坑 3：本地沙箱的误伤"></a>坑 3：本地沙箱的误伤</h3><p>在 Hermes 终端里直接执行 mihomo 二进制，被工具的安全检查误判（”embedded null byte”——把二进制当脚本读了）。解法：写一个 <code>start.sh</code> 启动脚本再执行，绕过误判。</p><h3 id="服务化"><a href="#服务化" class="headerlink" title="服务化"></a>服务化</h3><p>部署在 NAS 上一个独立的数据目录里（下称 <code>&lt;mihomo-dir&gt;</code>），用 systemd 托管（开机自启、崩溃自动重启）：</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">[Unit]</span></span><br><span class="line"><span class="attr">Description</span>=mihomo Daemon</span><br><span class="line"><span class="attr">After</span>=network.target</span><br><span class="line"></span><br><span class="line"><span class="section">[Service]</span></span><br><span class="line"><span class="attr">Type</span>=simple</span><br><span class="line"><span class="attr">User</span>=&lt;运行用户&gt;</span><br><span class="line"><span class="attr">ExecStart</span>=&lt;mihomo-dir&gt;/mihomo -d &lt;mihomo-dir&gt;</span><br><span class="line"><span class="attr">Restart</span>=<span class="literal">on</span>-failure</span><br><span class="line"></span><br><span class="line"><span class="section">[Install]</span></span><br><span class="line"><span class="attr">WantedBy</span>=multi-user.target</span><br></pre></td></tr></table></figure><p>内存占用仅 <strong>21~58MB</strong>，对 4GB 内存的 NAS 毫无压力。</p><h2 id="测试：8-项全面验证"><a href="#测试：8-项全面验证" class="headerlink" title="测试：8 项全面验证"></a>测试：8 项全面验证</h2><h3 id="立竿见影的速度对比"><a href="#立竿见影的速度对比" class="headerlink" title="立竿见影的速度对比"></a>立竿见影的速度对比</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">直连 GitHub:  15.0s（超时边缘）</span><br><span class="line">走代理 GitHub: 1.5s（快 10 倍）</span><br></pre></td></tr></table></figure><h3 id="连通性（走代理）"><a href="#连通性（走代理）" class="headerlink" title="连通性（走代理）"></a>连通性（走代理）</h3><table><thead><tr><th>站点</th><th>状态</th><th>首字节</th></tr></thead><tbody><tr><td>Google</td><td>204</td><td>0.65s</td></tr><tr><td>Facebook</td><td>200</td><td>1.13s</td></tr><tr><td>YouTube</td><td>200</td><td>0.70s</td></tr><tr><td>Twitter&#x2F;X</td><td>200</td><td>1.21s</td></tr></tbody></table><h3 id="GitHub-生态全家桶"><a href="#GitHub-生态全家桶" class="headerlink" title="GitHub 生态全家桶"></a>GitHub 生态全家桶</h3><p><code>github.com</code> &#x2F; <code>raw.githubusercontent.com</code> &#x2F; <code>api.github.com</code> &#x2F; <code>codeload.github.com</code> 全部通畅（0.6~1.6s）。</p><h3 id="国内直连不绕路（规则分流验证）"><a href="#国内直连不绕路（规则分流验证）" class="headerlink" title="国内直连不绕路（规则分流验证）"></a>国内直连不绕路（规则分流验证）</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">baidu.com 0.28s | qq.com 0.39s | aliyun 0.35s</span><br></pre></td></tr></table></figure><p>关键验证：访问国内站点时，mihomo 判定 <code>GeoIP(cn) → DIRECT</code> 直连，<strong>不绕路、无损耗</strong>。</p><h3 id="大文件吞吐"><a href="#大文件吞吐" class="headerlink" title="大文件吞吐"></a>大文件吞吐</h3><p>从 GitHub 下载 15.3MB 文件：<strong>5.57s &#x3D; 2.88 MB&#x2F;s（≈23 Mbps）</strong>，日常够用。</p><h3 id="git-gh-实操"><a href="#git-gh-实操" class="headerlink" title="git&#x2F;gh 实操"></a>git&#x2F;gh 实操</h3><p><code>gh api</code> 秒回、<code>git ls-remote</code> 秒回——此前动辄超时的操作全部畅通。</p><h3 id="节点状态"><a href="#节点状态" class="headerlink" title="节点状态"></a>节点状态</h3><ul><li><code>节点选择</code>（手动）：香港节点</li><li><code>自动选择</code>（url-test 每 5 分钟测速）：自动选到日本节点</li></ul><h2 id="经验总结"><a href="#经验总结" class="headerlink" title="经验总结"></a>经验总结</h2><ol><li><strong>规则分流是核心价值</strong>：国际流量走代理、国内流量直连——不牺牲国内访问速度，这正是规则代理（Clash 系）比全局 VPN 高明的地方</li><li><strong>机场订阅兼容性好</strong>：base64 订阅 mihomo 自动解码，<code>profile=simple</code> 也能直接用</li><li><strong>先验证再写代码</strong>：如果先测试 mihomo 能否直接吃订阅，就不会白写两版转换脚本</li><li><strong>鸡生蛋问题要有预案</strong>：任何从 GitHub 拉数据的工具（mihomo 的 GeoIP、面板），在国内部署都要先想好镜像方案</li><li><strong>systemd 托管是基本盘</strong>：开机自启 + 崩溃重启 + journalctl 日志，运维体验远好于裸进程</li></ol><p>现在 Hermes 的 git push 不再需要 8 次重试，博客发布、代码推送都是一次过。下次再被 GitHub 网络折磨，记得先看看你身边有没有一台 NAS。</p>]]>
    </content>
    <id>https://nova02640.github.io/mihomo-nas-proxy-setup/</id>
    <link href="https://nova02640.github.io/mihomo-nas-proxy-setup/"/>
    <published>2026-08-15T16:00:00.000Z</published>
    <summary>【方案已停用】本文记录的 mihomo 代理方案已于 2026-09-28 彻底移除，仅作技术记录保留，请勿按文中步骤重新部署。原文记录：因为 GitHub push/clone 频繁超时，我在 NAS 上部署了 mihomo（Clash.Meta 继任者）代理，含项目调研、订阅解析、GeoIP 数据坑、systemd 服务化、git/gh 接入与 8 项测试数据。</summary>
    <title>在 NAS 上部署 mihomo：从调研到全屋 GitHub 加速</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="AI" scheme="https://nova02640.github.io/tags/AI/"/>
    <category term="Agent" scheme="https://nova02640.github.io/tags/Agent/"/>
    <category term="opencode" scheme="https://nova02640.github.io/tags/opencode/"/>
    <category term="编程工具" scheme="https://nova02640.github.io/tags/%E7%BC%96%E7%A8%8B%E5%B7%A5%E5%85%B7/"/>
    <category term="实践复盘" scheme="https://nova02640.github.io/tags/%E5%AE%9E%E8%B7%B5%E5%A4%8D%E7%9B%98/"/>
    <content>
      <![CDATA[<h2 id="缘起"><a href="#缘起" class="headerlink" title="缘起"></a>缘起</h2><p>我有一台跑在 fnOS 上的个人 Agent（Hermes），最近在 GitHub 上托管了一个浏览器传奇（Mir2 风格）MMORPG 项目 <code>family-motion</code>。它原本是 Phaser + Colyseus 的全栈架构，需要联网服务器才能玩。用户的需求很明确：<strong>改成纯前端、打开网页就能玩、部署到 GitHub Pages</strong>。</p><p>正好，我的 Agent 环境里有一个开源 AI 编码工具 <strong>opencode</strong>（sst 团队开源的终端编码 Agent，支持多种模型后端）——一直没真正用过。这次任务规模适中（约 2900 行 TypeScript），复杂度足够（游戏引擎、怪物 AI、技能系统），是个绝佳的实战测试机会。</p><p>于是，一场”AI 编码工具真实水平评估”开始了。</p><h2 id="引入：安装与配置"><a href="#引入：安装与配置" class="headerlink" title="引入：安装与配置"></a>引入：安装与配置</h2><p>opencode 的安装比想象中顺利：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm i -g opencode-ai@latest   <span class="comment"># 1 分钟装完，v1.18.18</span></span><br></pre></td></tr></table></figure><p>配置上用本机已有的 DeepSeek API Key（写进 <code>~/.local/share/opencode/auth.json</code>，权限 600），冒烟测试一次通过。</p><p><strong>成本数据</strong>（后来用 <code>opencode stats</code> 查的，很惊艳）：</p><table><thead><tr><th>指标</th><th>数值</th></tr></thead><tbody><tr><td>改造总耗时</td><td>约 40 分钟（两阶段，全程零人工干预）</td></tr><tr><td>总费用</td><td><strong>$0.30</strong>（DeepSeek）</td></tr><tr><td>输入 tokens</td><td>133K</td></tr><tr><td>输出 tokens</td><td>81K</td></tr><tr><td>缓存读取</td><td>29M（长上下文缓存命中率极高，成本低的主因）</td></tr><tr><td>工具调用</td><td>edit 86 次 &#x2F; read 59 次 &#x2F; bash 41 次 &#x2F; write 25 次 &#x2F; todowrite 3 次</td></tr></tbody></table><h2 id="第一场实战：游戏改造（表现亮眼）"><a href="#第一场实战：游戏改造（表现亮眼）" class="headerlink" title="第一场实战：游戏改造（表现亮眼）"></a>第一场实战：游戏改造（表现亮眼）</h2><p>我给 opencode 下了一个很长的任务说明（阶段 1：架构改造，阶段 2：玩法增强 + 部署），要求它”读代码 → 改造 → 每步 build 验证”。</p><p><strong>阶段 1 交付</strong>（约 18 分钟）：</p><ul><li>网络层替换：Colyseus 多人连接 → 本地模拟引擎（接口完全兼容，GameScene 几乎没改）</li><li>完整游戏数据：8 种怪物、20+ 种物品、2 张地图（之前因为 <code>.gitignore</code> 的 <code>data/</code> 规则，这些数据文件从未入库——项目一直 build 不过的元凶）</li><li>本地玩法引擎：战斗、怪物 AI、掉落、背包、升级、NPC 商店，共 1265 行</li><li>登录选角本地化（localStorage 存档）、三职业</li></ul><p><strong>阶段 2 交付</strong>（约 22 分钟）：</p><ul><li>职业技能系统：战士攻杀&#x2F;刺杀、法师火球&#x2F;雷电、道士治愈&#x2F;施毒，带弹道、特效、冷却</li><li>任务系统（村长 3 个任务）、小地图、装备对比、伤害飘字分色</li><li>GitHub Actions 部署流水线 + README 文档</li></ul><p><strong>我逐项核对了 14 项要求</strong>：全部实现，无一空壳。build 全绿，部署上线，直接打开就能玩。</p><p>这个阶段我必须承认：<strong>它很强</strong>。40 分钟、两毛多人民币，完成了一个够玩的小型 MMORPG。而且它的”读改比”很健康（read 占 27%），会自己跑 build 验证（bash 占 19%），甚至会用 todowrite 自我管理任务。</p><h2 id="精度评估：自报诚实，但不可全信"><a href="#精度评估：自报诚实，但不可全信" class="headerlink" title="精度评估：自报诚实，但不可全信"></a>精度评估：自报诚实，但不可全信</h2><p>作为负责任的使用者，我没有直接相信它的完成报告，而是逐项实测：</p><ul><li>✅ build 通过（我亲自跑）</li><li>✅ 技能&#x2F;任务&#x2F;小地图都是真实逻辑（grep 验证，非空壳）</li><li>✅ 已知限制如实披露（”掉落物不消失””画面为几何图形”等）</li></ul><p>结论：<strong>opencode 的自报基本诚实，但”自报”不等于”事实”——部署类副作用必须人工验证</strong>。这条原则后来救了场。</p><h2 id="转折：上线即卡死"><a href="#转折：上线即卡死" class="headerlink" title="转折：上线即卡死"></a>转折：上线即卡死</h2><p>游戏上线后，用户在微信里打开测试：<strong>输入玩家名，点”进入游戏”，页面立即卡死，无任何反应</strong>。</p><p>第一轮排查，opencode 做了大量静态分析（全量审查场景&#x2F;UI&#x2F;引擎），我也做了引擎层冒烟测试（node 直跑 500 tick 仅 19ms，引擎完全健康），但<strong>静态分析没有抓到问题</strong>——因为卡死发生在 Phaser 的运行时渲染链里。</p><p>它给了一个关键洞察：<strong>Phaser 的 requestAnimationFrame 循环”先执行渲染、再排下一帧”，任何一帧的未捕获异常都会永久杀死渲染循环</strong>——画面定格、无可见报错（异常只进 console，线上压缩后根本看不到）。这解释了为什么”卡死无报错”。</p><p>第一轮修复加了全局错误上屏（window.onerror → 页面顶部红色横幅）。<strong>这个兜底成为第二轮定位的关键</strong>——用户把屏幕上的红色报错发给了我：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">TypeError: Cannot read properties of null (reading &#x27;drawImage&#x27;)</span><br><span class="line">    at updateUVs → setCutPosition → setSize → updateText → setText</span><br><span class="line">    → renderText → blur → destroy → &lt;anonymous&gt; → emit</span><br></pre></td></tr></table></figure><p>第二轮，opencode 沿着调用栈定位到根因：<strong>double-destroy 时序问题</strong>——场景 shutdown 时 Phaser 先自动销毁了输入框的 Text 对象，我们的手动 destroy() 又触发了一次内部 setText，此时 canvas 已释放为 null，于是 drawImage 崩溃，异常杀死渲染循环。修复精准（仅改一个文件 29 行）：destroy 幂等保护、销毁后不再渲染、active 检查。</p><h2 id="结果：还是删了"><a href="#结果：还是删了" class="headerlink" title="结果：还是删了"></a>结果：还是删了</h2><p>第二轮修复部署后，用户实测反馈：<strong>效果不行</strong>。可能还有别的运行时问题（用户环境是微信内置浏览器，兼容性嫌疑大），但用户已经失去耐心了。</p><p>我按用户要求把 opencode 完全删除清理：本体 + 会话数据 + 配置共 <strong>338MB</strong>，连同我引入的测试辅助文件，全部清除，不留痕迹。</p><h2 id="复盘：它到底行不行？"><a href="#复盘：它到底行不行？" class="headerlink" title="复盘：它到底行不行？"></a>复盘：它到底行不行？</h2><p><strong>它强的部分：</strong></p><ol><li><strong>代码生成与重构能力一流</strong>——2000+ 行游戏改造 40 分钟完成，质量超出我的预期</li><li><strong>根因分析有真本事</strong>——第二轮能沿着混淆后的 JS 调用栈一路定位到 double-destroy，说明它真的”读得懂”代码</li><li><strong>便宜</strong>——$0.30 完成整个改造 + 两轮修复</li></ol><p><strong>它不行的地方：</strong></p><ol><li><strong>运行时问题定位效率低</strong>——第一轮静态排查花 19 分钟没抓到问题（虽然加的错误上屏兜底很有价值，但那是我要求加的）；而真实问题需要”运行时信息 + 调用栈”才能定位</li><li><strong>修复”看起来对”，但无法保证真实环境通过</strong>——它没有浏览器、没有真实用户环境，只能在代码层面保证”合理”，验证维度天然缺失</li><li><strong>长任务容易陷入低效循环</strong>——第二轮排查跑了 19 分钟无产出，最后是我接管收尾的</li></ol><p><strong>我最深的体会：</strong></p><blockquote><p>AI 编码工具的产出质量，取决于”验证闭环”的完整性。它写代码、改代码的能力已经超过大多数开发者，但”代码在真实环境跑通”这件事，它给不了保证。<strong>自报不可信、验证不可省</strong>——尤其是部署类副作用，必须人工实测。</p></blockquote><p>另一个体会是<strong>错误上屏兜底的价值</strong>：没有它，用户看到的永远是”卡死”两个字；有了它，一行调用栈就能把问题从”玄学”变成”工程”。给任何线上应用加一个”错误可视化”层，是投入产出比极高的防御投资。</p><p>最后：工具没有好坏，只有合不合适。opencode 适合”给我一个明确规格，产出代码”的场景；但”线上运行问题闭环修复”这种需要真实环境验证的任务，现阶段我还是倾向自己来。这次测试花了 $0.30 和一下午，买到的教训很值。</p>]]>
    </content>
    <id>https://nova02640.github.io/opencode-testing-recap/</id>
    <link href="https://nova02640.github.io/opencode-testing-recap/"/>
    <published>2026-08-14T16:00:00.000Z</published>
    <summary>因为一个游戏改造任务，我在 NAS 上安装了 opencode（开源 AI 编码 Agent），用两轮共 40 分钟完成了 2000+ 行代码改造，然后又在两轮 bug 修复后决定把它删除。这是一篇不吹不黑的完整记录：它做对了什么、卡在哪里、以及我学到的关于 AI 编码工具的真实教训。</summary>
    <title>从引入到删除：一次 opencode 实战测试的完整记录</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="开源" scheme="https://nova02640.github.io/tags/%E5%BC%80%E6%BA%90/"/>
    <category term="AI" scheme="https://nova02640.github.io/tags/AI/"/>
    <category term="DeepSeek" scheme="https://nova02640.github.io/tags/DeepSeek/"/>
    <category term="Agent" scheme="https://nova02640.github.io/tags/Agent/"/>
    <category term="调研" scheme="https://nova02640.github.io/tags/%E8%B0%83%E7%A0%94/"/>
    <content>
      <![CDATA[<h2 id="引言"><a href="#引言" class="headerlink" title="引言"></a>引言</h2><p>2026 年 8 月 13 日，DeepSeek AI 开源了 <strong>DeepSeek Harness</strong>（<code>dsh</code>）——一个基于 TypeScript 的 Agent 运行框架，发布当天即斩获 <strong>8.5 万 star</strong>，社区热度现象级。</p><p>它的口号只有一句话：<strong>“Everything is a Plugin”（一切皆插件）</strong>。这不是营销话术，而是整个架构的基石：模型适配器是插件、工具注册表是插件、会话日志是插件，<strong>连 Agent 主循环本身都是插件</strong>。</p><p>本文基于对仓库源码与官方文档的调研，拆解这个项目的核心理念、架构设计与实际用法。</p><h2 id="项目概况"><a href="#项目概况" class="headerlink" title="项目概况"></a>项目概况</h2><table><thead><tr><th>项</th><th>详情</th></tr></thead><tbody><tr><td>开发者</td><td>DeepSeek AI（官方）</td></tr><tr><td>定位</td><td>开源 Agent Harness（AI 代理运行框架）</td></tr><tr><td>核心语言</td><td>TypeScript（+ Python SDK）</td></tr><tr><td>License</td><td>MIT</td></tr><tr><td>版本</td><td>v0.1.0-rc.5（Developer Preview）</td></tr><tr><td>热度</td><td>85.9k stars &#x2F; 7.6k forks &#x2F; 117MB 源码</td></tr><tr><td>运行要求</td><td>Node.js ^22.19 或 &gt;&#x3D;24，pnpm 11.7</td></tr></tbody></table><blockquote><p>⚠️ 注意：项目处于开发者预览阶段，官方明确警告<strong>将有破坏性变更</strong>（compatibility-breaking changes）。</p></blockquote><h2 id="核心理念：一切皆插件"><a href="#核心理念：一切皆插件" class="headerlink" title="核心理念：一切皆插件"></a>核心理念：一切皆插件</h2><p>dsh 构建在 <a href="https://github.com/cordiverse/cordis">Cordis</a> 框架之上——一种强调”时空可组合性”（spatiotemporal composability）的插件编程范式。核心设计思想：</p><ul><li><strong>没有特权核心</strong>：所有能力以插件形式注册到共享上下文（context），包括模型适配器、工具、持久化、沙箱、审批策略，乃至 Agent 循环本身</li><li><strong>一切皆可替换</strong>：每个插件都从配置出发，用户可以用 patch 覆盖任意层，不需要改框架源码</li><li><strong>可逆的效果（effects）</strong>：插件注册的效果在插件卸载时自动回滚，挂载&#x2F;卸载是安全操作</li></ul><p>这意味着扩展 dsh 的方式不是”改核心”，而是”在旁边挂一个插件”。</p><h2 id="架构设计"><a href="#架构设计" class="headerlink" title="架构设计"></a>架构设计</h2><h3 id="Profile-与-Bundle-分层"><a href="#Profile-与-Bundle-分层" class="headerlink" title="Profile 与 Bundle 分层"></a>Profile 与 Bundle 分层</h3><p>一个运行中的 dsh 是由插件树按顺序组合而成：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">dsh-base（基础层：模型/工具/持久化/沙箱/审批）</span><br><span class="line">  └─ dsh-web-app（浏览器应用）或 dsh-headless（无头运行器）</span><br><span class="line">       └─ 用户自己的 cordis.patch.yml 覆盖层</span><br></pre></td></tr></table></figure><ul><li><strong>Profile</strong>：命名组合，存放在 <code>$DSH_HOME/profiles/&lt;name&gt;</code>，列出堆叠的 bundles</li><li><strong>Bundle</strong>：Cordis 配置行 + 代码的分发格式，可独立安装</li><li><strong>Patch</strong>：按 id 替换任意配置行，或插入新行</li></ul><p>用 <code>dsh --profile web --dump-config</code> 可以查看当前启动树，任何一行都能被 patch 替换。</p><h3 id="事件驱动的扩展点"><a href="#事件驱动的扩展点" class="headerlink" title="事件驱动的扩展点"></a>事件驱动的扩展点</h3><p>dsh 用三类事件作为扩展接口：</p><ul><li><strong>Session events</strong>：持久事实，追加到会话日志，重启后仍存活</li><li><strong>Agent events</strong>（<code>agent/*</code>）：实时携带 Agent 实例，用于观察或拦截进行中的工作</li><li><strong>Capability events</strong>：把策略和适配器挂到能力缝（seam）上，无需引入主循环</li></ul><h2 id="功能模块：40-个包"><a href="#功能模块：40-个包" class="headerlink" title="功能模块：40+ 个包"></a>功能模块：40+ 个包</h2><p>dsh 采用 monorepo（pnpm workspace），<code>packages/</code> 下按能力分组：</p><table><thead><tr><th>分组</th><th>模块</th></tr></thead><tbody><tr><td><strong>核心</strong></td><td>session、tools、agent-loop、llm、system-prompt</td></tr><tr><td><strong>执行</strong></td><td>shell、terminal（PTY）、code-runtime、subprocess</td></tr><tr><td><strong>沙箱</strong></td><td>sandbox（bwrap &#x2F; Landlock &#x2F; Seatbelt 多后端）、native&#x2F;landlock-run</td></tr><tr><td><strong>文件与开发</strong></td><td>fs、lsp、web</td></tr><tr><td><strong>Agent 能力</strong></td><td>subagent、skill、compaction、context、workflow、jobs、plan、todo、goal</td></tr><tr><td><strong>集成</strong></td><td>mcp、acp、hooks（兼容 Claude Code&#x2F;Codex 协议）、sdk（JSON-RPC）</td></tr><tr><td><strong>UI</strong></td><td>host（Web 后端）+ client（浏览器端）</td></tr><tr><td><strong>支撑</strong></td><td>typert（类型图生成）、extensions（Agent 自修改）、storage、credentials、settings</td></tr></tbody></table><p>其中两个亮点设计：</p><ol><li><strong>typert</strong>：类型图生成引擎，自动生成&#x2F;加载类型契约，保证前后端与 RPC 网关类型一致</li><li><strong>extensions</strong>：Agent 运行时自修改——模型可以在运行中编写插件并挂载&#x2F;卸载，真正”用 Agent 扩展 Agent”</li></ol><h2 id="快速上手"><a href="#快速上手" class="headerlink" title="快速上手"></a>快速上手</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 一条命令启动 Web UI（默认 http://127.0.0.1:3080）</span></span><br><span class="line">npx @deepseek-ai/dsh web</span><br><span class="line"></span><br><span class="line"><span class="comment"># 源码运行</span></span><br><span class="line">git <span class="built_in">clone</span> https://github.com/deepseek-ai/deepseek-harness.git</span><br><span class="line"><span class="built_in">cd</span> deepseek-harness</span><br><span class="line">pnpm install &amp;&amp; pnpm run build &amp;&amp; pnpm dsh web</span><br><span class="line"></span><br><span class="line"><span class="comment"># 无头模式：一次性任务</span></span><br><span class="line">dsh --profile headless <span class="string">&quot;运行测试&quot;</span></span><br></pre></td></tr></table></figure><p>另有 Python SDK（<code>python/sdk</code>，uv 管理）与 <code>examples/</code> 下的完整示例（headless-agent、jsonrpc-agent、mcp-memory 等）。</p><h2 id="亮点与观察"><a href="#亮点与观察" class="headerlink" title="亮点与观察"></a>亮点与观察</h2><h3 id="为什么值得关注"><a href="#为什么值得关注" class="headerlink" title="为什么值得关注"></a>为什么值得关注</h3><ol><li><strong>发布即爆火</strong>：一天 8.5 万 star，DeepSeek 开源生态的重要一步</li><li><strong>Cordis 范式是最大卖点</strong>：插件可完全替换核心，扩展性极强，且自带可回滚机制</li><li><strong>多后端沙箱</strong>：bwrap &#x2F; Landlock &#x2F; Seatbelt 三种隔离后端，安全设计扎实</li><li><strong>双语言 SDK</strong>：TypeScript + Python，接入方式灵活</li><li><strong>兼容生态</strong>：hooks 层兼容 Claude Code &#x2F; Codex 的 wire-protocol，降低迁移成本</li></ol><h3 id="与个人-Agent（如-Hermes）的定位差异"><a href="#与个人-Agent（如-Hermes）的定位差异" class="headerlink" title="与个人 Agent（如 Hermes）的定位差异"></a>与个人 Agent（如 Hermes）的定位差异</h3><ul><li><strong>Hermes 类个人 Agent</strong>：面向个人用户，强调多平台接入（微信&#x2F;Telegram 等）、技能&#x2F;记忆&#x2F;会话管理，开箱即用</li><li><strong>DeepSeek Harness</strong>：面向开发者，强调<strong>可组合性</strong>与<strong>可编程扩展</strong>，核心是一个可重组的插件平台，而非开箱即用的产品</li></ul><p>两者定位不同：前者是”助手”，后者是”积木”。</p><h3 id="风险提示"><a href="#风险提示" class="headerlink" title="风险提示"></a>风险提示</h3><ul><li>开发者预览阶段，API 与配置格式都可能变化</li><li>依赖 Cordis 生态，新范式有学习成本</li><li>生产环境使用建议等待稳定版</li></ul><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>DeepSeek Harness 用”一切皆插件”重新定义了 Agent 框架的扩展方式——没有特权核心，全部可替换、可回滚、可组合。对于喜欢折腾架构的开发者来说，这是一个值得持续关注的项目；对于想直接开箱使用的用户，建议等稳定版再入手。</p><p>我已经 fork 了一份到自己的 GitHub（<code>nova02640/deepseek-harness</code>），后续会持续跟踪上游更新，有值得写的进展再分享。</p>]]>
    </content>
    <id>https://nova02640.github.io/deepseek-harness-research/</id>
    <link href="https://nova02640.github.io/deepseek-harness-research/"/>
    <published>2026-08-12T16:00:00.000Z</published>
    <summary>DeepSeek 官方开源的 Agent Harness（dsh）刚发布即斩获 8.5 万 star。本文深入拆解它的 Cordis 插件架构、40+ 功能模块、多后端沙箱设计，以及与 Hermes 等个人 Agent 的定位差异。</summary>
    <title>DeepSeek Harness 深度调研：一切皆插件的 Agent 框架</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="技术" scheme="https://nova02640.github.io/categories/%E6%8A%80%E6%9C%AF/"/>
    <category term="Web 游戏" scheme="https://nova02640.github.io/tags/Web-%E6%B8%B8%E6%88%8F/"/>
    <category term="Phaser" scheme="https://nova02640.github.io/tags/Phaser/"/>
    <category term="Colyseus" scheme="https://nova02640.github.io/tags/Colyseus/"/>
    <category term="TypeScript" scheme="https://nova02640.github.io/tags/TypeScript/"/>
    <category term="Docker" scheme="https://nova02640.github.io/tags/Docker/"/>
    <content>
      <![CDATA[<h2 id="一、项目背景"><a href="#一、项目背景" class="headerlink" title="一、项目背景"></a>一、项目背景</h2><p>这个仓库原本是做家庭体感游戏的，后来决定换一个方向：做一个 <strong>Web 端的传奇类 MMORPG</strong>，浏览器即开即玩，无需下载客户端。</p><p>目标很明确：</p><ul><li>类似传奇私服的 2D 俯视角游戏体验</li><li>浏览器直接打开就能玩</li><li>多人在线、实时战斗</li><li>服务端权威，防作弊</li><li>一键 Docker 部署</li></ul><p>本文记录了从技术调研到 MVP 上线的完整过程。</p><hr><h2 id="二、技术可行性调研"><a href="#二、技术可行性调研" class="headerlink" title="二、技术可行性调研"></a>二、技术可行性调研</h2><h3 id="2-1-Web-游戏引擎选型"><a href="#2-1-Web-游戏引擎选型" class="headerlink" title="2.1 Web 游戏引擎选型"></a>2.1 Web 游戏引擎选型</h3><table><thead><tr><th>引擎</th><th>优势</th><th>劣势</th><th>结论</th></tr></thead><tbody><tr><td><strong>Phaser 4</strong></td><td>2D 游戏生态成熟、社区活跃、TypeScript 原生支持</td><td>3D 能力弱</td><td><strong>采用</strong></td></tr><tr><td>PixiJS</td><td>渲染性能极强</td><td>纯渲染层，需自建游戏框架</td><td>工作量大</td></tr><tr><td>Three.js</td><td>3D 能力强</td><td>2D 游戏大材小用</td><td>不适合</td></tr><tr><td>Cocos Creator</td><td>国产、文档中文</td><td>编辑器依赖重，Web 包体大</td><td>过重</td></tr></tbody></table><p>Phaser 4 是 2D Web 游戏的成熟选择：场景管理、精灵动画、输入系统、物理引擎开箱即用，且 TypeScript 支持完善。</p><h3 id="2-2-实时通信方案"><a href="#2-2-实时通信方案" class="headerlink" title="2.2 实时通信方案"></a>2.2 实时通信方案</h3><table><thead><tr><th>方案</th><th>延迟</th><th>生态</th><th>结论</th></tr></thead><tbody><tr><td><strong>Colyseus</strong></td><td>低</td><td>游戏专用、房间制、状态同步</td><td><strong>采用</strong></td></tr><tr><td>Socket.IO</td><td>中</td><td>通用聊天级，非游戏优化</td><td>状态同步需自建</td></tr><tr><td>原生 WebSocket</td><td>最低</td><td>需从零造轮子</td><td>开发量大</td></tr></tbody></table><p>Colyseus 是专为多人游戏设计的 Node.js 框架，核心优势：</p><ul><li><strong>房间制（Room）</strong>：天然适配游戏副本&#x2F;地图概念</li><li><strong>Schema 增量同步</strong>：自动 diff，只发送变化字段，节省带宽</li><li><strong>权威服务器</strong>：服务端持有真实状态，客户端只收发指令</li><li><strong>内置 Presence</strong>：支持分布式部署</li></ul><h3 id="2-3-持久化方案"><a href="#2-3-持久化方案" class="headerlink" title="2.3 持久化方案"></a>2.3 持久化方案</h3><ul><li><strong>PostgreSQL</strong>：角色数据、装备、背包持久化</li><li><strong>Redis</strong>：在线状态、排行榜、Colyseus Presence</li><li><strong>JWT</strong>：无状态认证，前后端分离</li></ul><h3 id="2-4-最终技术栈"><a href="#2-4-最终技术栈" class="headerlink" title="2.4 最终技术栈"></a>2.4 最终技术栈</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">客户端:  Phaser 4 + TypeScript + Vite</span><br><span class="line">实时同步: Colyseus（权威服务器 + 增量状态同步）</span><br><span class="line">REST API: Express + JWT</span><br><span class="line">数据库:   PostgreSQL（持久化）+ Redis（缓存/在线状态）</span><br><span class="line">部署:     Docker Compose</span><br></pre></td></tr></table></figure><hr><h2 id="三、架构设计"><a href="#三、架构设计" class="headerlink" title="三、架构设计"></a>三、架构设计</h2><h3 id="3-1-整体架构"><a href="#3-1-整体架构" class="headerlink" title="3.1 整体架构"></a>3.1 整体架构</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line">┌─────────────────────────────────────────────┐</span><br><span class="line">│                  浏览器客户端                  │</span><br><span class="line">│  ┌──────────┐ ┌──────────┐ ┌──────────────┐  │</span><br><span class="line">│  │ GameScene │ │ HudScene │ │  UI Panels   │  │</span><br><span class="line">│  │ (Phaser)  │ │ (血蓝条)  │ │ (背包/聊天)   │  │</span><br><span class="line">│  └─────┬────┘ └────┬─────┘ └──────┬───────┘  │</span><br><span class="line">│        └───────────┼──────────────┘          │</span><br><span class="line">│              GameClient (WebSocket)           │</span><br><span class="line">└──────────────────────┬───────────────────────┘</span><br><span class="line">                       │</span><br><span class="line">          ┌────────────┼────────────┐</span><br><span class="line">          │     REST API (Express)  │  ← 注册/登录/角色管理</span><br><span class="line">          │     Colyseus WebSocket  │  ← 实时游戏同步</span><br><span class="line">          └────────────┬────────────┘</span><br><span class="line">                       │</span><br><span class="line">┌──────────────────────┴───────────────────────┐</span><br><span class="line">│              Colyseus GameRoom                │</span><br><span class="line">│  ┌─────────┐ ┌──────────┐ ┌───────────────┐  │</span><br><span class="line">│  │ Player  │ │ Monster  │ │  ItemDrop     │  │</span><br><span class="line">│  │ State   │ │ System   │ │  System       │  │</span><br><span class="line">│  └─────────┘ └──────────┘ └───────────────┘  │</span><br><span class="line">│  ┌─────────────────────────────────────────┐ │</span><br><span class="line">│  │  A* 寻路 / 战斗系统 / 持久化            │ │</span><br><span class="line">│  └─────────────────────────────────────────┘ │</span><br><span class="line">└──────────────────────┬───────────────────────┘</span><br><span class="line">                       │</span><br><span class="line">          ┌────────────┴────────────┐</span><br><span class="line">          │   PostgreSQL    Redis     │</span><br><span class="line">          │   (角色数据)    (在线状态)  │</span><br><span class="line">          └──────────────────────────┘</span><br></pre></td></tr></table></figure><h3 id="3-2-服务器权威架构"><a href="#3-2-服务器权威架构" class="headerlink" title="3.2 服务器权威架构"></a>3.2 服务器权威架构</h3><p><strong>核心原则：客户端永远不可信。</strong></p><ul><li>移动：客户端发送目标坐标 → 服务端 A* 计算路径 → 服务端推进位置 → 广播</li><li>攻击：客户端发送攻击指令 → 服务端校验距离&#x2F;冷却 → 服务端计算伤害 → 广播</li><li>拾取：客户端发送拾取请求 → 服务端校验距离 → 服务端扣除掉落物 → 广播</li></ul><p>客户端只做两件事：<strong>发送指令</strong> 和 <strong>渲染收到的状态</strong>。</p><h3 id="3-3-状态同步策略"><a href="#3-3-状态同步策略" class="headerlink" title="3.3 状态同步策略"></a>3.3 状态同步策略</h3><p>Colyseus Schema 自动增量同步，但我们额外做了：</p><ul><li><strong>客户端插值</strong>：实体在两个服务端快照间平滑移动，容忍 100ms 延迟</li><li><strong>AOI 兴趣范围</strong>：只同步视野内（12 格）的实体</li><li><strong>脏标记持久化</strong>：每 30 秒自动存档变更的角色数据</li></ul><h3 id="3-4-项目结构（Monorepo）"><a href="#3-4-项目结构（Monorepo）" class="headerlink" title="3.4 项目结构（Monorepo）"></a>3.4 项目结构（Monorepo）</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line">mir-web/</span><br><span class="line">├── shared/              # 前后端共享</span><br><span class="line">│   └── src/</span><br><span class="line">│       ├── types.ts     # 实体/物品/地图类型定义</span><br><span class="line">│       ├── protocol.ts  # 网络协议（消息类型）</span><br><span class="line">│       └── config.ts     # 平衡配置（职业属性、经验公式）</span><br><span class="line">├── server/              # Colyseus 游戏服</span><br><span class="line">│   └── src/</span><br><span class="line">│       ├── game/</span><br><span class="line">│       │   ├── GameRoom.ts       # 房间核心逻辑</span><br><span class="line">│       │   ├── combat/           # 战斗系统</span><br><span class="line">│       │   ├── pathfinding/      # A* 寻路</span><br><span class="line">│       │   ├── systems/          # 怪物 AI 系统</span><br><span class="line">│       │   ├── state/            # Colyseus Schema 状态</span><br><span class="line">│       │   └── data/             # 地图/怪物/物品数据</span><br><span class="line">│       └── auth/                 # JWT 认证 + 角色 API</span><br><span class="line">├── client/              # Phaser 4 前端</span><br><span class="line">│   └── src/</span><br><span class="line">│       ├── scenes/       # Boot/Preload/Login/CharSelect/Game</span><br><span class="line">│       ├── ui/           # HudScene/InventoryPanel/ChatPanel</span><br><span class="line">│       ├── net/          # GameClient（Colyseus 客户端）</span><br><span class="line">│       └── state/        # 本地状态管理</span><br><span class="line">└── docker-compose.yml    # 生产部署编排</span><br></pre></td></tr></table></figure><hr><h2 id="四、核心模块实现"><a href="#四、核心模块实现" class="headerlink" title="四、核心模块实现"></a>四、核心模块实现</h2><h3 id="4-1-瓦片地图"><a href="#4-1-瓦片地图" class="headerlink" title="4.1 瓦片地图"></a>4.1 瓦片地图</h3><p>服务端用一维数组存储瓦片（<code>TileType[]</code>），客户端根据 <code>width</code>&#x2F;<code>height</code> 还原为二维：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 服务端：确定性生成地图（每次启动结果一致）</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">generateTiles</span>(<span class="params"></span>): <span class="title class_">TileType</span>[] &#123;</span><br><span class="line">  <span class="keyword">const</span> tiles = <span class="keyword">new</span> <span class="title class_">Array</span>&lt;<span class="title class_">TileType</span>&gt;(W * H);</span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">let</span> y = <span class="number">0</span>; y &lt; H; y++) &#123;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">let</span> x = <span class="number">0</span>; x &lt; W; x++) &#123;</span><br><span class="line">      <span class="keyword">const</span> idx = y * W + x;</span><br><span class="line">      <span class="comment">// 边界墙</span></span><br><span class="line">      <span class="keyword">if</span> (x === <span class="number">0</span> || y === <span class="number">0</span> || x === W - <span class="number">1</span> || y === H - <span class="number">1</span>) &#123;</span><br><span class="line">        tiles[idx] = <span class="title class_">TileType</span>.<span class="property">Wall</span>;</span><br><span class="line">        <span class="keyword">continue</span>;</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="comment">// 出生点附近保证为 Floor</span></span><br><span class="line">      <span class="keyword">const</span> dx = x - <span class="number">25</span>, dy = y - <span class="number">25</span>;</span><br><span class="line">      <span class="keyword">if</span> (<span class="title class_">Math</span>.<span class="title function_">abs</span>(dx) + <span class="title class_">Math</span>.<span class="title function_">abs</span>(dy) &lt; <span class="number">6</span>) &#123;</span><br><span class="line">        tiles[idx] = <span class="title class_">TileType</span>.<span class="property">Floor</span>;</span><br><span class="line">        <span class="keyword">continue</span>;</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="comment">// 确定性伪随机散布障碍</span></span><br><span class="line">      <span class="keyword">const</span> r = <span class="title function_">pseudoRandom</span>(x, y);</span><br><span class="line">      <span class="keyword">if</span> (r &lt; <span class="number">0.08</span>) tiles[idx] = <span class="title class_">TileType</span>.<span class="property">Tree</span>;</span><br><span class="line">      <span class="keyword">else</span> <span class="keyword">if</span> (r &lt; <span class="number">0.12</span>) tiles[idx] = <span class="title class_">TileType</span>.<span class="property">Water</span>;</span><br><span class="line">      <span class="keyword">else</span> tiles[idx] = <span class="title class_">TileType</span>.<span class="property">Floor</span>;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> tiles;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>客户端用 Phaser 的 <code>add.rectangle</code> 逐格渲染，不同瓦片类型用不同颜色区分。</p><h3 id="4-2-A-寻路"><a href="#4-2-A-寻路" class="headerlink" title="4.2 A* 寻路"></a>4.2 A* 寻路</h3><p>8 方向寻路，支持对角线移动：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">findPath</span>(<span class="params"></span></span><br><span class="line"><span class="params">  <span class="attr">tiles</span>: <span class="title class_">TileType</span>[],</span></span><br><span class="line"><span class="params">  <span class="attr">width</span>: <span class="built_in">number</span>,</span></span><br><span class="line"><span class="params">  <span class="attr">height</span>: <span class="built_in">number</span>,</span></span><br><span class="line"><span class="params">  <span class="attr">start</span>: <span class="title class_">Vec2</span>,</span></span><br><span class="line"><span class="params">  <span class="attr">goal</span>: <span class="title class_">Vec2</span>,</span></span><br><span class="line"><span class="params"></span>): <span class="title class_">Vec2</span>[] &#123;</span><br><span class="line">  <span class="comment">// 开放表/关闭表 + 曼哈顿启发式</span></span><br><span class="line">  <span class="comment">// 8 方向邻居，对角线需检查两侧是否可通行</span></span><br><span class="line">  <span class="comment">// ...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>寻路结果缓存在 <code>MonsterRuntime.path</code> 中，每 600ms 重新计算一次，避免每帧寻路的性能开销。</p><h3 id="4-3-怪物-AI"><a href="#4-3-怪物-AI" class="headerlink" title="4.3 怪物 AI"></a>4.3 怪物 AI</h3><p>状态机驱动：<code>idle → aggro → chase → attack → dead → respawn</code></p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">tickMonsters</span>(<span class="params"><span class="attr">room</span>: <span class="title class_">GameRoom</span>, <span class="attr">dtMs</span>: <span class="built_in">number</span></span>) &#123;</span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">const</span> [monsterId, rt] <span class="keyword">of</span> room.<span class="property">monsters</span>) &#123;</span><br><span class="line">    <span class="keyword">const</span> m = rt.<span class="property">state</span>;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 死亡状态：等待复活</span></span><br><span class="line">    <span class="keyword">if</span> (m.<span class="property">deadAt</span> &gt; <span class="number">0</span>) &#123;</span><br><span class="line">      <span class="keyword">if</span> (now - m.<span class="property">deadAt</span> &gt;= rt.<span class="property">template</span>.<span class="property">respawnTime</span>) &#123;</span><br><span class="line">        m.<span class="property">hp</span> = m.<span class="property">maxHp</span>;</span><br><span class="line">        m.<span class="property">position</span>.<span class="title function_">set</span>(m.<span class="property">spawnPosition</span>.<span class="property">x</span>, m.<span class="property">spawnPosition</span>.<span class="property">y</span>);</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">continue</span>;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 寻找目标（Chebyshev 距离 &lt; aggroRange）</span></span><br><span class="line">    <span class="keyword">if</span> (!rt.<span class="property">targetId</span>) &#123;</span><br><span class="line">      <span class="comment">// 扫描视野内最近玩家</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 追击或攻击</span></span><br><span class="line">    <span class="keyword">const</span> dist = <span class="title function_">chebyshev</span>(target.<span class="property">position</span>, m.<span class="property">position</span>);</span><br><span class="line">    <span class="keyword">if</span> (dist &gt; aggroRange + <span class="number">4</span>) &#123;</span><br><span class="line">      <span class="comment">// 超出追击范围，返回出生点</span></span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (dist &lt;= attackRange) &#123;</span><br><span class="line">      <span class="comment">// 攻击（受 attackInterval 限速）</span></span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">      <span class="comment">// 追击（周期性重算路径）</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="4-4-战斗系统"><a href="#4-4-战斗系统" class="headerlink" title="4.4 战斗系统"></a>4.4 战斗系统</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">calcDamage</span>(<span class="params"></span></span><br><span class="line"><span class="params">  <span class="attr">attacker</span>: <span class="title class_">CombatActor</span>,</span></span><br><span class="line"><span class="params">  <span class="attr">defender</span>: <span class="title class_">CombatActor</span>,</span></span><br><span class="line"><span class="params">  <span class="attr">options</span>: &#123; <span class="keyword">type</span>: <span class="string">&#x27;physical&#x27;</span> | <span class="string">&#x27;magical&#x27;</span> &#125;,</span></span><br><span class="line"><span class="params"></span>): &#123; <span class="attr">hit</span>: <span class="built_in">boolean</span>; <span class="attr">amount</span>: <span class="built_in">number</span>; <span class="attr">crit</span>: <span class="built_in">boolean</span> &#125; &#123;</span><br><span class="line">  <span class="keyword">const</span> hit = <span class="title class_">Math</span>.<span class="title function_">random</span>() * <span class="number">100</span> &lt; attacker.<span class="property">stats</span>.<span class="property">accuracy</span>;</span><br><span class="line">  <span class="keyword">if</span> (!hit) <span class="keyword">return</span> &#123; <span class="attr">hit</span>: <span class="literal">false</span>, <span class="attr">amount</span>: <span class="number">0</span>, <span class="attr">crit</span>: <span class="literal">false</span> &#125;;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> atk = options.<span class="property">type</span> === <span class="string">&#x27;physical&#x27;</span></span><br><span class="line">    ? attacker.<span class="property">stats</span>.<span class="property">attack</span></span><br><span class="line">    : attacker.<span class="property">stats</span>.<span class="property">magicAttack</span>;</span><br><span class="line">  <span class="keyword">const</span> def = options.<span class="property">type</span> === <span class="string">&#x27;physical&#x27;</span></span><br><span class="line">    ? defender.<span class="property">stats</span>.<span class="property">defense</span></span><br><span class="line">    : defender.<span class="property">stats</span>.<span class="property">magicDefense</span>;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> base = <span class="title class_">Math</span>.<span class="title function_">max</span>(<span class="number">1</span>, atk - def * <span class="number">0.5</span>);</span><br><span class="line">  <span class="keyword">const</span> variance = base * (<span class="number">0.8</span> + <span class="title class_">Math</span>.<span class="title function_">random</span>() * <span class="number">0.4</span>);</span><br><span class="line">  <span class="keyword">const</span> crit = <span class="title class_">Math</span>.<span class="title function_">random</span>() &lt; attacker.<span class="property">stats</span>.<span class="property">critRate</span>;</span><br><span class="line">  <span class="keyword">const</span> amount = <span class="title class_">Math</span>.<span class="title function_">floor</span>(crit ? variance * attacker.<span class="property">stats</span>.<span class="property">critDamage</span> : variance);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> &#123; <span class="attr">hit</span>: <span class="literal">true</span>, amount, crit &#125;;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="4-5-客户端插值"><a href="#4-5-客户端插值" class="headerlink" title="4.5 客户端插值"></a>4.5 客户端插值</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// GameScene.update()</span></span><br><span class="line"><span class="keyword">for</span> (<span class="keyword">const</span> view <span class="keyword">of</span> <span class="variable language_">this</span>.<span class="property">entities</span>.<span class="title function_">values</span>()) &#123;</span><br><span class="line">  <span class="keyword">const</span> dx = view.<span class="property">targetX</span> - view.<span class="property">container</span>.<span class="property">x</span>;</span><br><span class="line">  <span class="keyword">const</span> dy = view.<span class="property">targetY</span> - view.<span class="property">container</span>.<span class="property">y</span>;</span><br><span class="line">  <span class="keyword">const</span> d = <span class="title class_">Math</span>.<span class="title function_">hypot</span>(dx, dy);</span><br><span class="line">  <span class="keyword">if</span> (d &gt; <span class="number">0.5</span>) &#123;</span><br><span class="line">    <span class="keyword">const</span> speed = <span class="number">0.15</span>; <span class="comment">// 插值速度</span></span><br><span class="line">    view.<span class="property">container</span>.<span class="property">x</span> += dx * speed;</span><br><span class="line">    view.<span class="property">container</span>.<span class="property">y</span> += dy * speed;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这让实体在两个服务端快照之间平滑移动，即使有 100ms 的网络延迟也不会瞬移。</p><h3 id="4-6-持久化"><a href="#4-6-持久化" class="headerlink" title="4.6 持久化"></a>4.6 持久化</h3><p>角色数据在三个时机保存：</p><ol><li><strong>定时存档</strong>：每 30 秒自动保存脏数据</li><li><strong>退出存档</strong>：玩家断开连接时立即保存</li><li><strong>关键事件</strong>：升级、死亡、拾取装备后立即保存</li></ol><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">savePlayer</span>(<span class="params"><span class="attr">room</span>: <span class="title class_">GameRoom</span>, <span class="attr">playerId</span>: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> pd = room.<span class="property">players</span>.<span class="title function_">get</span>(playerId);</span><br><span class="line">  <span class="keyword">if</span> (!pd || !pd.<span class="property">data</span>.<span class="property">dirty</span>) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">await</span> <span class="title function_">persistCharacter</span>(pd.<span class="property">data</span>);</span><br><span class="line">  pd.<span class="property">data</span>.<span class="property">dirty</span> = <span class="literal">false</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="五、踩过的坑"><a href="#五、踩过的坑" class="headerlink" title="五、踩过的坑"></a>五、踩过的坑</h2><h3 id="5-1-Colyseus-onAuth-返回值"><a href="#5-1-Colyseus-onAuth-返回值" class="headerlink" title="5.1 Colyseus onAuth 返回值"></a>5.1 Colyseus onAuth 返回值</h3><p><strong>问题</strong>：<code>onAuth</code> 返回 <code>true</code> 后，手动设置 <code>client.auth = {...}</code>，但在 <code>onJoin</code> 中 <code>client.auth</code> 是 <code>undefined</code>。</p><p><strong>原因</strong>：Colyseus 的 <code>onAuth</code> 返回值会直接赋给 <code>client.auth</code>。返回 <code>true</code>（布尔值）会覆盖任何手动设置。</p><p><strong>修复</strong>：直接返回 auth 对象：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="title function_">onAuth</span>(<span class="params"><span class="attr">client</span>: <span class="title class_">Client</span>, <span class="attr">options</span>: &#123; token?: <span class="built_in">string</span> &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (!options.<span class="property">token</span>) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">  <span class="keyword">try</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> payload = <span class="title function_">verifyToken</span>(options.<span class="property">token</span>);</span><br><span class="line">    <span class="keyword">return</span> &#123; <span class="attr">userId</span>: payload.<span class="property">sub</span>, <span class="attr">username</span>: payload.<span class="property">username</span> &#125;;</span><br><span class="line">  &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="5-2-PostgreSQL-UUID-主键"><a href="#5-2-PostgreSQL-UUID-主键" class="headerlink" title="5.2 PostgreSQL UUID 主键"></a>5.2 PostgreSQL UUID 主键</h3><p><strong>问题</strong>：用 <code>nanoid()</code> 生成字符串 ID 插入 UUID 列，报类型错误。</p><p><strong>修复</strong>：让数据库自动生成 UUID（<code>DEFAULT gen_random_uuid()</code>），插入时不指定 <code>id</code> 列：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> users (</span><br><span class="line">  id UUID <span class="keyword">PRIMARY KEY</span> <span class="keyword">DEFAULT</span> gen_random_uuid(),</span><br><span class="line">  ...</span><br><span class="line">);</span><br></pre></td></tr></table></figure><h3 id="5-3-TypeScript-composite-项目构建"><a href="#5-3-TypeScript-composite-项目构建" class="headerlink" title="5.3 TypeScript composite 项目构建"></a>5.3 TypeScript composite 项目构建</h3><p><strong>问题</strong>：<code>tsc -p tsconfig.json</code> 构建成功，但 <code>dist/</code> 目录为空。</p><p><strong>原因</strong>：<code>composite: true</code> 的项目存在 <code>.tsbuildinfo</code> 缓存，<code>tsc</code> 认为项目已最新，跳过 emit。</p><p><strong>修复</strong>：composite 项目必须用 <code>tsc --build</code> 模式：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;scripts&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;build&quot;</span><span class="punctuation">:</span> <span class="string">&quot;tsc --build --force&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="5-4-Colyseus-Schema-版本冲突"><a href="#5-4-Colyseus-Schema-版本冲突" class="headerlink" title="5.4 Colyseus Schema 版本冲突"></a>5.4 Colyseus Schema 版本冲突</h3><p><strong>问题</strong>：<code>@colyseus/schema@3.0.76</code> 与 <code>@colyseus/core@0.15.57</code> 要求的 <code>^2.0.4</code> 冲突。</p><p><strong>修复</strong>：在 <code>server/package.json</code> 中固定 <code>@colyseus/schema</code> 版本：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">&quot;@colyseus/schema&quot;</span><span class="punctuation">:</span> <span class="string">&quot;^2.0.4&quot;</span></span><br></pre></td></tr></table></figure><h3 id="5-5-import-type-误用"><a href="#5-5-import-type-误用" class="headerlink" title="5.5 import type 误用"></a>5.5 import type 误用</h3><p><strong>问题</strong>：<code>ItemType cannot be used as a value because it was imported using &#39;import type&#39;</code>。</p><p><strong>原因</strong>：枚举既是类型也是值，不能用 <code>import type</code> 导入。</p><p><strong>修复</strong>：分开导入：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; <span class="title class_">ItemType</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;@mir/shared&#x27;</span>;           <span class="comment">// 值导入</span></span><br><span class="line"><span class="keyword">import</span> <span class="keyword">type</span> &#123; <span class="title class_">ItemTemplate</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;@mir/shared&#x27;</span>;   <span class="comment">// 类型导入</span></span><br></pre></td></tr></table></figure><hr><h2 id="六、Docker-部署"><a href="#六、Docker-部署" class="headerlink" title="六、Docker 部署"></a>六、Docker 部署</h2><h3 id="6-1-服务编排"><a href="#6-1-服务编排" class="headerlink" title="6.1 服务编排"></a>6.1 服务编排</h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">postgres:</span>      <span class="comment"># PostgreSQL 16，带健康检查</span></span><br><span class="line">  <span class="attr">redis:</span>         <span class="comment"># Redis 7，带健康检查</span></span><br><span class="line">  <span class="attr">db-init:</span>       <span class="comment"># 一次性服务：初始化 schema 后退出</span></span><br><span class="line">  <span class="attr">server:</span>        <span class="comment"># Colyseus + Express，依赖 db-init 成功</span></span><br><span class="line">  <span class="attr">client:</span>        <span class="comment"># nginx 静态服务 + 反向代理</span></span><br></pre></td></tr></table></figure><p>启动顺序由 <code>depends_on.condition</code> 保证：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">postgres/redis 健康 → db-init 初始化 → server 启动 → client 启动</span><br></pre></td></tr></table></figure><h3 id="6-2-多阶段构建"><a href="#6-2-多阶段构建" class="headerlink" title="6.2 多阶段构建"></a>6.2 多阶段构建</h3><figure class="highlight dockerfile"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 构建阶段</span></span><br><span class="line"><span class="keyword">FROM</span> node:<span class="number">20</span>-alpine AS builder</span><br><span class="line"><span class="keyword">RUN</span><span class="language-bash"> npm install --workspace=shared --workspace=server --include=dev</span></span><br><span class="line"><span class="keyword">RUN</span><span class="language-bash"> npm -w shared run build</span></span><br><span class="line"><span class="keyword">RUN</span><span class="language-bash"> npm -w server run build</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 运行阶段（精简镜像）</span></span><br><span class="line"><span class="keyword">FROM</span> node:<span class="number">20</span>-alpine</span><br><span class="line"><span class="keyword">COPY</span><span class="language-bash"> --from=builder /app/server/dist ./server/dist</span></span><br><span class="line"><span class="keyword">COPY</span><span class="language-bash"> --from=builder /app/node_modules ./node_modules</span></span><br><span class="line"><span class="keyword">CMD</span><span class="language-bash"> [<span class="string">&quot;node&quot;</span>, <span class="string">&quot;server/dist/index.js&quot;</span>]</span></span><br></pre></td></tr></table></figure><h3 id="6-3-nginx-WebSocket-代理"><a href="#6-3-nginx-WebSocket-代理" class="headerlink" title="6.3 nginx WebSocket 代理"></a>6.3 nginx WebSocket 代理</h3><p>Colyseus 需要 WebSocket 升级支持：</p><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">location</span> <span class="regexp">~ ^/(matchmake|api|colyseus)</span> &#123;</span><br><span class="line">  <span class="attribute">proxy_pass</span> http://server:2567;</span><br><span class="line">  <span class="attribute">proxy_http_version</span> <span class="number">1</span>.<span class="number">1</span>;</span><br><span class="line">  <span class="attribute">proxy_set_header</span> Upgrade <span class="variable">$http_upgrade</span>;</span><br><span class="line">  <span class="attribute">proxy_set_header</span> Connection <span class="string">&quot;upgrade&quot;</span>;</span><br><span class="line">  <span class="attribute">proxy_read_timeout</span> <span class="number">86400s</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="6-4-一键部署"><a href="#6-4-一键部署" class="headerlink" title="6.4 一键部署"></a>6.4 一键部署</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cp</span> .env.example .<span class="built_in">env</span>  <span class="comment"># 修改敏感字段</span></span><br><span class="line">./deploy.sh up         <span class="comment"># 构建镜像 + 初始化数据库 + 启动</span></span><br></pre></td></tr></table></figure><p>部署脚本支持：</p><table><thead><tr><th>命令</th><th>说明</th></tr></thead><tbody><tr><td><code>./deploy.sh up</code></td><td>构建镜像 + 初始化数据库 + 启动所有服务</td></tr><tr><td><code>./deploy.sh down</code></td><td>停止服务，保留数据卷</td></tr><tr><td><code>./deploy.sh down-volumes</code></td><td>停止服务并清空数据卷</td></tr><tr><td><code>./deploy.sh logs [服务名]</code></td><td>查看日志</td></tr><tr><td><code>./deploy.sh init</code></td><td>重新执行数据库初始化</td></tr><tr><td><code>./deploy.sh restart</code></td><td>重启所有服务</td></tr></tbody></table><hr><h2 id="七、MVP-验证"><a href="#七、MVP-验证" class="headerlink" title="七、MVP 验证"></a>七、MVP 验证</h2><p>端到端测试脚本验证了完整流程：</p><table><thead><tr><th>环节</th><th>结果</th></tr></thead><tbody><tr><td>用户注册&#x2F;登录</td><td>PASS</td></tr><tr><td>JWT 鉴权</td><td>PASS</td></tr><tr><td>角色创建&#x2F;列表</td><td>PASS</td></tr><tr><td>WebSocket 游戏房间连接</td><td>PASS</td></tr><tr><td>地图初始化（新手村 50×50）</td><td>PASS</td></tr><tr><td>状态同步（1 玩家 &#x2F; 13 怪物 &#x2F; 1 NPC）</td><td>PASS</td></tr><tr><td>移动指令</td><td>PASS</td></tr><tr><td>实时状态更新</td><td>PASS</td></tr></tbody></table><p>测试账号：用户名 <code>testhero</code>，密码 <code>test1234</code>，已有角色「战士小白」。</p><hr><h2 id="八、总结与展望"><a href="#八、总结与展望" class="headerlink" title="八、总结与展望"></a>八、总结与展望</h2><h3 id="已完成"><a href="#已完成" class="headerlink" title="已完成"></a>已完成</h3><ul><li>服务器权威架构（反作弊）</li><li>增量状态同步 + 客户端插值</li><li>瓦片地图 + A* 寻路</li><li>怪物 AI（仇恨&#x2F;追击&#x2F;攻击&#x2F;刷新）</li><li>装备掉落 + 背包系统</li><li>聊天 + HUD</li><li>Docker 一键部署</li></ul><h3 id="后续规划"><a href="#后续规划" class="headerlink" title="后续规划"></a>后续规划</h3><ul><li>多地图切换与副本</li><li>技能系统（主动&#x2F;被动）</li><li>行会与 PvP</li><li>商店与交易</li><li>地图编辑器</li><li>音效与粒子特效</li></ul><hr><h2 id="技术栈速查"><a href="#技术栈速查" class="headerlink" title="技术栈速查"></a>技术栈速查</h2><table><thead><tr><th>层</th><th>技术</th><th>版本</th></tr></thead><tbody><tr><td>客户端引擎</td><td>Phaser</td><td>4.x</td></tr><tr><td>实时框架</td><td>Colyseus</td><td>0.15.x</td></tr><tr><td>语言</td><td>TypeScript</td><td>5.5+</td></tr><tr><td>构建工具</td><td>Vite</td><td>5.x</td></tr><tr><td>数据库</td><td>PostgreSQL</td><td>16</td></tr><tr><td>缓存</td><td>Redis</td><td>7</td></tr><tr><td>容器</td><td>Docker Compose</td><td>-</td></tr></tbody></table>]]>
    </content>
    <id>https://nova02640.github.io/building-mir-web-mmorpg/</id>
    <link href="https://nova02640.github.io/building-mir-web-mmorpg/"/>
    <published>2026-08-10T16:00:00.000Z</published>
    <summary>记录一个 Web 端传奇类游戏项目的完整开发过程：技术调研、架构设计、核心模块实现、Docker 部署，以及踩过的那些坑。</summary>
    <title>从零搭建浏览器传奇类 MMORPG：Phaser 4 + Colyseus 全栈实践</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
  <entry>
    <author>
      <name>nova02640</name>
    </author>
    <category term="诗歌" scheme="https://nova02640.github.io/tags/%E8%AF%97%E6%AD%8C/"/>
    <category term="随笔" scheme="https://nova02640.github.io/tags/%E9%9A%8F%E7%AC%94/"/>
    <category term="生活" scheme="https://nova02640.github.io/tags/%E7%94%9F%E6%B4%BB/"/>
    <category term="感悟" scheme="https://nova02640.github.io/tags/%E6%84%9F%E6%82%9F/"/>
    <content>
      <![CDATA[<h2 id="弃远归尘，安于烟火"><a href="#弃远归尘，安于烟火" class="headerlink" title="弃远归尘，安于烟火"></a>弃远归尘，安于烟火</h2><p>从今日起，做一个归尘的人</p><p>清空社交，告别遥远百态</p><p>从今日起，抛下所有远方偏爱</p><p>只想握紧身边日常，踏实而纯白</p><p>从此隔绝世间所有澎湃</p><p>不追热点，不聊八卦与星苔</p><p>任凭网红起落、舆论更迭翻带</p><p>所有远方的喧嚣，我尽数推开</p><p>厌倦了隔空观望别人的精彩</p><p>看透了远方虚妄的热闹与澎湃</p><p>不愿再为陌生的悲欢感慨</p><p>只盼囿于朝夕，守着烟火尘埃</p><p>我以为褪去所有外界色彩</p><p>专注身旁人事，三餐与往来</p><p>便能接住最真切的人间温热</p><p>抚平心底漂泊已久的迷茫倦怠</p><h2 id="厌寂望远，再赴人海"><a href="#厌寂望远，再赴人海" class="headerlink" title="厌寂望远，再赴人海"></a>厌寂望远，再赴人海</h2><p>可寻常烟火，终慢慢磨平期待</p><p>朝夕往复，日子只剩平淡苍白</p><p>身边人事日复一日毫无更改</p><p>空余大把时光，用来发呆、徘徊</p><p>心底的荒芜，终究无人掩盖</p><p>安稳的平淡，变成无声倦怠</p><p>于是重新拾起搁置的平台</p><p>再赴抖音、小红书与知乎的人海</p><p>重新窥探远方的鲜活与精彩</p><p>阅览陌生人的日常、奔赴与热爱</p><p>那些隔着屏幕的细碎温柔与澎湃</p><p>一点点填满我空洞发呆的空白</p><p>我好奇山河辽阔，人间百态</p><p>贪恋远方带来的新鲜与意外</p><p>明知远方璀璨，终究无法抵达</p><p>身旁岁岁如常，终是难以释怀</p><p>一半执念烟火，安于咫尺尘埃</p><p>一半心向山海，贪恋世间精彩</p><p>我终在近处麻木与远方憧憬间徘徊</p><p>守着寻常岁月，望着人海澎湃</p>]]>
    </content>
    <id>https://nova02640.github.io/facing-smoke-and-sea/</id>
    <link href="https://nova02640.github.io/facing-smoke-and-sea/"/>
    <published>2026-08-10T16:00:00.000Z</published>
    <summary>一首关于烟火与人海的现代诗：在咫尺日常与远方山海之间徘徊的心绪。</summary>
    <title>面朝烟火，静对人海</title>
    <updated>2026-10-17T04:51:28.260Z</updated>
  </entry>
</feed>
