<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>EmptyWu</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://murmurpaper.heitang.info/</id>
  <link href="https://murmurpaper.heitang.info/" rel="alternate"/>
  <link href="https://murmurpaper.heitang.info/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, EmptyWu</rights>
  <title>MurmurPaper</title>
  <updated>2026-08-08T18:25:45.446Z</updated>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="RAG 實戰筆記" scheme="https://murmurpaper.heitang.info/categories/RAG-%E5%AF%A6%E6%88%B0%E7%AD%86%E8%A8%98/"/>
    <category term="RAG" scheme="https://murmurpaper.heitang.info/tags/RAG/"/>
    <category term="Qdrant" scheme="https://murmurpaper.heitang.info/tags/Qdrant/"/>
    <category term="權限控制" scheme="https://murmurpaper.heitang.info/tags/%E6%AC%8A%E9%99%90%E6%8E%A7%E5%88%B6/"/>
    <category term="RBAC" scheme="https://murmurpaper.heitang.info/tags/RBAC/"/>
    <category term="資安" scheme="https://murmurpaper.heitang.info/tags/%E8%B3%87%E5%AE%89/"/>
    <content>
      <![CDATA[<p>「RAG 實戰筆記」第四篇。企業要導入文件問答，第一個問題永遠不是準不準，而是：<strong>機密文件會不會被不該看的人問出來？</strong> 這篇拆解我的平台如何回答這個問題——核心原則只有一句話：權限在檢索層強制執行，絕不交給 LLM 把關。從 RBAC 模型、切塊 payload 設計、Qdrant filter 的組法，到幾個「不做就會被繞過」的細節設計。（前情：<a href="/2026/08/09/rag-fundamentals/">RAG 基礎</a>、<a href="/2026/08/09/hybrid-retrieval-deep-dive/">混合檢索拆解</a>）</p><span id="more"></span><h2 id="一、為什麼不能讓-LLM-把關權限"><a href="#一、為什麼不能讓-LLM-把關權限" class="headerlink" title="一、為什麼不能讓 LLM 把關權限"></a>一、為什麼不能讓 LLM 把關權限</h2><p>直覺的做法：把權限規則寫進 prompt——「你不可以透露機密文件的內容」。這條路是死路，原因有三個層次：</p><ol><li><strong>LLM 是可以被說服的。</strong> Prompt injection、角色扮演、「我是管理員」——模型的服從性本身就是攻擊面。把權限交給一個「會被聊天內容影響的元件」把關，等於把金庫鑰匙掛在會跟陌生人聊天的警衛身上。</li><li><strong>進了 prompt 就等於外洩。</strong> 就算模型忍住不直接複述，機密內容已經影響了它的回答——摘要、暗示、選字都可能洩漏。真正的邊界必須在內容進入 prompt <strong>之前</strong>。</li><li><strong>連「存在」本身都是資訊。</strong>「我找到了相關文件但不能告訴你」——這句話已經洩漏了機密文件的存在與主題方向。</li></ol><p>所以我的平台有四條鐵則，第一條就是：<strong>權限在檢索層強制執行</strong>。使用者無權看的切塊，根本不會出現在檢索結果，不會進 rerank，更不會進 prompt——對整條下游管線來說，那些文件<strong>不存在</strong>。</p><h2 id="二、RBAC-模型：角色決定看得到什麼"><a href="#二、RBAC-模型：角色決定看得到什麼" class="headerlink" title="二、RBAC 模型：角色決定看得到什麼"></a>二、RBAC 模型：角色決定看得到什麼</h2><p>權限模型參考 RuoYi 的 RBAC 設計：</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">    │              └─* 資料權限（看得到哪些文件）</span><br><span class="line">  工號/部門</span><br></pre></td></tr></table></figure><p>資料權限拆成<strong>四個維度</strong>，前三個管「範圍」、第四個管「密級」，彼此正交：</p><table><thead><tr><th>維度</th><th>意義</th></tr></thead><tbody><tr><td><code>public</code></td><td>全公司公開文件</td></tr><tr><td><code>own_dept</code></td><td>自己部門的文件</td></tr><tr><td><code>cross_dept</code></td><td>其他部門的文件</td></tr><tr><td><code>confidential</code></td><td>機密文件（與範圍正交）</td></tr></tbody></table><p>「正交」是關鍵設計：一份文件可以同時是「人資部 + 機密」，要看到它必須<strong>同時</strong>具備部門範圍權限與機密權限。預設的角色矩陣：</p><table><thead><tr><th>角色</th><th align="center">public</th><th align="center">own_dept</th><th align="center">cross_dept</th><th align="center">confidential</th></tr></thead><tbody><tr><td>一般員工</td><td align="center">✅</td><td align="center">✅</td><td align="center">✕</td><td align="center">✕</td></tr><tr><td>部門主管</td><td align="center">✅</td><td align="center">✅</td><td align="center">✕</td><td align="center">✅</td></tr><tr><td>系統管理員</td><td align="center">✅</td><td align="center">✅</td><td align="center">✅</td><td align="center">✅</td></tr><tr><td>稽核</td><td align="center">✅</td><td align="center">✕</td><td align="center">✕</td><td align="center">✕</td></tr></tbody></table><p>而且這張表<strong>不是寫死的</strong>——它存在資料庫裡，管理員可以在介面上編輯。這帶出一條隱形的設計紀律：程式碼裡永遠不出現 <code>if role == &quot;admin&quot;</code> 這種硬編碼判斷，一律查權限表。否則日後新增角色時，散落各處的角色判斷就是一顆顆地雷。</p><h2 id="三、資料端：權限住在每一個切塊上"><a href="#三、資料端：權限住在每一個切塊上" class="headerlink" title="三、資料端：權限住在每一個切塊上"></a>三、資料端：權限住在每一個切塊上</h2><p>檢索層要能過濾，前提是<strong>每個切塊自帶權限標籤</strong>。文件切塊寫入 Qdrant 時，payload 除了原文和來源，還帶兩個權限欄位：</p><figure class="highlight jsonc"><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="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;text&quot;</span><span class="punctuation">:</span> <span class="string">&quot;切塊原文…&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;source&quot;</span><span class="punctuation">:</span> <span class="string">&quot;人事管理辦法.pdf&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;chunk_index&quot;</span><span class="punctuation">:</span> <span class="number">12</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;scope&quot;</span><span class="punctuation">:</span> <span class="string">&quot;HR&quot;</span><span class="punctuation">,</span>                      <span class="comment">// &quot;public&quot; 或部門代號</span></span><br><span class="line">  <span class="attr">&quot;confidentiality&quot;</span><span class="punctuation">:</span> <span class="string">&quot;unclassified&quot;</span>   <span class="comment">// unclassified | confidential</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>一個看似小事、實則重要的決定：<strong><code>scope</code> 存部門代號（<code>HR</code>、<code>IT</code>），不存中文名稱</strong>。中文名稱只是 <code>departments</code> 表裡的顯示標籤——部門改名時只改標籤，幾十萬個切塊的 payload 一個都不用動。</p><p>那如果真的要改代號呢？這裡藏著一個多資料庫架構的經典陷阱：權限表在 SQLite、文件記錄在 MongoDB、但 <code>scope</code> 複製在<strong>每一個</strong> Qdrant 切塊的 payload 裡。只改前兩者的話，檢索層還在用舊代號比對——結果是那個部門的員工突然<strong>什麼都查不到</strong>。所幸方向是安全的（fail-closed：資料消失而不是外洩），但功能就是壞了。所以系統提供了 <code>rename_scope</code>：用 Qdrant 的 <code>set_payload</code> 批次把所有 <code>scope == 舊代號</code> 的切塊改成新代號，並回傳受影響的切塊數供比對。<strong>冗餘欄位帶來檢索效率，也帶來同步義務</strong>——這是把權限放進檢索層的成本，值得，但要記帳。</p><h2 id="四、查詢端-從-JWT-到-Qdrant-Filter"><a href="#四、查詢端-從-JWT-到-Qdrant-Filter" class="headerlink" title="四、查詢端:從 JWT 到 Qdrant Filter"></a>四、查詢端:從 JWT 到 Qdrant Filter</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></pre></td><td class="code"><pre><span class="line">前端 ──JWT──▶ C# 閘道：驗證身分 → 查角色的 data_scopes → 展開成 perm_filter</span><br><span class="line">                  │</span><br><span class="line">                  ▼  &#123;&quot;scopes&quot;: [&quot;public&quot;,&quot;own_dept&quot;], &quot;depts&quot;: [&quot;HR&quot;],</span><br><span class="line">                      &quot;max_confidentiality&quot;: &quot;unclassified&quot;&#125;</span><br><span class="line">              Python Agent：build_permission_filter() → Qdrant Filter</span><br><span class="line">                  │</span><br><span class="line">                  ▼</span><br><span class="line">              Qdrant：filter 套在檢索的 prefetch 層，無權切塊不進候選</span><br></pre></td></tr></table></figure><p><strong>身分由閘道從 JWT 注入</strong>——這是鐵則第二條。使用者說自己是誰不算數，LLM 認為使用者是誰更不算數；權限條件是後端查表展開的，前端傳什麼都不採信。</p><p>Agent 端把權限條件翻譯成 Qdrant Filter 的核心邏輯（節錄實際程式碼）：</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><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 class="keyword">def</span> <span class="title function_">build_permission_filter</span>(<span class="params">perm_filter: <span class="built_in">dict</span> | <span class="literal">None</span></span>) -&gt; models.Filter | <span class="literal">None</span>:</span><br><span class="line">    <span class="keyword">if</span> perm_filter <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">None</span>          <span class="comment"># 僅限內部評測；正式請求一律由閘道帶入</span></span><br><span class="line"></span><br><span class="line">    scopes = perm_filter.get(<span class="string">&quot;scopes&quot;</span>) <span class="keyword">or</span> []</span><br><span class="line">    depts = perm_filter.get(<span class="string">&quot;depts&quot;</span>) <span class="keyword">or</span> []</span><br><span class="line"></span><br><span class="line">    allowed_scopes = []</span><br><span class="line">    <span class="keyword">if</span> <span class="string">&quot;public&quot;</span> <span class="keyword">in</span> scopes:</span><br><span class="line">        allowed_scopes.append(<span class="string">&quot;public&quot;</span>)</span><br><span class="line"></span><br><span class="line">    conds = []</span><br><span class="line">    <span class="keyword">if</span> <span class="string">&quot;cross_dept&quot;</span> <span class="keyword">in</span> scopes:</span><br><span class="line">        <span class="keyword">pass</span>                 <span class="comment"># 不限部門 → 不加 scope 條件</span></span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        <span class="keyword">if</span> <span class="string">&quot;own_dept&quot;</span> <span class="keyword">in</span> scopes:</span><br><span class="line">            allowed_scopes.extend(depts)</span><br><span class="line">        <span class="keyword">if</span> <span class="keyword">not</span> allowed_scopes:</span><br><span class="line">            <span class="comment"># 沒有任何可見範圍 → 回傳「必然無結果」的 filter</span></span><br><span class="line">            <span class="keyword">return</span> models.Filter(must=[models.FieldCondition(</span><br><span class="line">                key=<span class="string">&quot;scope&quot;</span>, <span class="keyword">match</span>=models.MatchValue(value=<span class="string">&quot;__none__&quot;</span>))])</span><br><span class="line">        conds.append(models.FieldCondition(</span><br><span class="line">            key=<span class="string">&quot;scope&quot;</span>, <span class="keyword">match</span>=models.MatchAny(<span class="built_in">any</span>=allowed_scopes)))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> perm_filter.get(<span class="string">&quot;max_confidentiality&quot;</span>) != <span class="string">&quot;confidential&quot;</span>:</span><br><span class="line">        conds.append(models.FieldCondition(</span><br><span class="line">            key=<span class="string">&quot;confidentiality&quot;</span>,</span><br><span class="line">            <span class="keyword">match</span>=models.MatchValue(value=<span class="string">&quot;unclassified&quot;</span>)))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> models.Filter(must=conds) <span class="keyword">if</span> conds <span class="keyword">else</span> <span class="literal">None</span></span><br></pre></td></tr></table></figure><p>最值得停下來看的是中間那段<strong>刻意的彆扭</strong>：當使用者沒有任何可見範圍時，函式不回傳 <code>None</code>，而是回傳一個匹配 <code>scope == &quot;__none__&quot;</code> 的 filter——一個<strong>保證撈不到任何東西</strong>的條件。因為在這個介面裡 <code>None</code> 的意思是「不過濾」；如果「無權限」被表示成 <code>None</code>，一個上游的疏忽就會讓無權限使用者看到<strong>全部</strong>文件。這就是 fail-closed 設計：預設值永遠朝安全的方向倒。</p><p>最後一哩：這個 filter 套在混合檢索的 <strong>prefetch 層</strong>（dense 與 sparse 兩路都套）。這意味著權限過濾發生在<strong>召回之前</strong>，而不是撈回來再刪——差別很實際：</p><ul><li>召回名額不被無權文件浪費（top-20 全是你看得到的）</li><li>rerank 不用處理註定被丟掉的候選</li><li>不存在「先看到再遮住」的時間窗</li></ul><h2 id="五、應用層的防繞過設計"><a href="#五、應用層的防繞過設計" class="headerlink" title="五、應用層的防繞過設計"></a>五、應用層的防繞過設計</h2><p>檢索層是地基，但權限系統的完整性取決於<strong>每一條繞過路徑都被堵上</strong>。幾個實例：</p><p><strong>上傳權與定範圍權是兩個權限。</strong> <code>docs.upload</code>（能上傳）和 <code>docs.scope</code>（能決定文件給誰看）刻意拆開。部門主管有前者沒後者——他上傳的文件一律鎖定為自己部門，用戶端指定什麼 <code>scope</code> 都直接覆寫（並落一筆 <code>SCOPE_OVERRIDDEN</code> 稽核記錄）。同時，<strong>事後修改 scope 也被封死</strong>（回 403）——只擋上傳的話，「先上傳成部門文件、再編輯成 public」就繞過去了。但改機密等級仍被允許：把自己部門的文件標成機密只會更嚴，不構成風險。每條規則都對應一條被想像過的繞過路徑。</p><p><strong>稽核角色能查軌跡、不能藉軌跡讀內容。</strong> 稽核能看所有人的問答紀錄（誰問了什麼、引用了哪些文件），但紀錄裡的引用只存<strong>檔名與切塊編號</strong>，不存全文——稽核權限不會變成讀機密文件的後門。</p><p><strong>機密文件連生成階段都被隔離。</strong> 檢索結果裡如果含機密切塊，Router 會把生成路由到<strong>地端 LLM</strong>（文件內容不出內網）；地端模型不可用時<strong>直接拒絕服務，而非降級改用雲端</strong>——「不可用」的正確反應是失敗，不是妥協。</p><p><strong>測試測的是繞過，不是正常流程。</strong> 權限測試專門驗證攻擊路徑：指定 public 上傳會不會被覆寫？先上傳再編輯會不會被擋？正常流程會過只證明功能存在，繞過測試才證明邊界存在。</p><h2 id="小結：權限系統的三層縱深"><a href="#小結：權限系統的三層縱深" class="headerlink" title="小結：權限系統的三層縱深"></a>小結：權限系統的三層縱深</h2><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">資料層   每個切塊自帶 scope + confidentiality（同步義務：rename_scope）</span><br><span class="line">檢索層   JWT → 權限表展開 → Qdrant filter 套在召回之前（fail-closed）</span><br><span class="line">應用層   權限拆分、防繞過規則、稽核不越權、機密走地端</span><br></pre></td></tr></table></figure><p>貫穿三層的是同一個思維：<strong>不信任下游</strong>。不信任 LLM 會守規矩、不信任前端傳來的參數、不信任「正常使用者不會那樣操作」。權限系統的品質不在正常路徑多順暢，而在每一條歪路是不是都走不通。</p><p>RAG 系列四篇到此完成一個段落：<a href="/2026/08/09/rag-fundamentals/">基礎邏輯</a>、<a href="/2026/08/09/vectordb-vs-mongodb/">向量庫與 MongoDB</a>、<a href="/2026/08/09/hybrid-retrieval-deep-dive/">混合檢索</a>、權限實作。後續主題（語意快取、結構化查詢、評測方法論）等專案推進再寫。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/09/retrieval-permission/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/09/retrieval-permission/"/>
    <published>2026-08-09T11:30:00.000Z</published>
    <summary>
      <![CDATA[<p>「RAG 實戰筆記」第四篇。企業要導入文件問答，第一個問題永遠不是準不準，而是：<strong>機密文件會不會被不該看的人問出來？</strong> 這篇拆解我的平台如何回答這個問題——核心原則只有一句話：權限在檢索層強制執行，絕不交給 LLM 把關。從 RBAC 模型、切塊 payload 設計、Qdrant filter 的組法，到幾個「不做就會被繞過」的細節設計。（前情：<a href="/2026/08/09/rag-fundamentals/">RAG 基礎</a>、<a href="/2026/08/09/hybrid-retrieval-deep-dive/">混合檢索拆解</a>）</p>]]>
    </summary>
    <title>檢索層權限實作與應用：讓沒權限的文件連被檢索到的機會都沒有</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="RAG 實戰筆記" scheme="https://murmurpaper.heitang.info/categories/RAG-%E5%AF%A6%E6%88%B0%E7%AD%86%E8%A8%98/"/>
    <category term="RAG" scheme="https://murmurpaper.heitang.info/tags/RAG/"/>
    <category term="混合檢索" scheme="https://murmurpaper.heitang.info/tags/%E6%B7%B7%E5%90%88%E6%AA%A2%E7%B4%A2/"/>
    <category term="Qdrant" scheme="https://murmurpaper.heitang.info/tags/Qdrant/"/>
    <category term="Reranker" scheme="https://murmurpaper.heitang.info/tags/Reranker/"/>
    <category term="bge-m3" scheme="https://murmurpaper.heitang.info/tags/bge-m3/"/>
    <content>
      <![CDATA[<p>「RAG 實戰筆記」第三篇。<a href="/2026/08/09/rag-fundamentals/">第一篇</a>把混合檢索一句話帶過：「dense 撈 20、sparse 撈 20、RRF 融合、rerank 精排」。這篇把這句話完全展開——為什麼單一檢索必然有盲區、RRF 為什麼用名次而不用分數、雙塔與交叉編碼在架構上的根本差異，以及每一步在 Qdrant 上的實際程式碼。</p><span id="more"></span><h2 id="一、為什麼需要「混合」：兩種檢索的互補盲區"><a href="#一、為什麼需要「混合」：兩種檢索的互補盲區" class="headerlink" title="一、為什麼需要「混合」：兩種檢索的互補盲區"></a>一、為什麼需要「混合」：兩種檢索的互補盲區</h2><h3 id="Dense（稠密向量）：懂語意、瞎於字面"><a href="#Dense（稠密向量）：懂語意、瞎於字面" class="headerlink" title="Dense（稠密向量）：懂語意、瞎於字面"></a>Dense（稠密向量）：懂語意、瞎於字面</h3><p>Embedding 模型（我用 bge-m3）把整段文字壓成一個 1024 維向量，語意相近的文字在向量空間中距離相近。它的強項是<strong>同義與改寫</strong>：</p><blockquote><p>查詢「員工請假要跑什麼流程」能命中寫著「休假申請辦法」的切塊——字面幾乎沒有重疊，語意卻是同一件事。</p></blockquote><p>但它有個致命盲區：<strong>精確字串</strong>。「115年偵字第7615號」這種案號，對 dense 向量來說只是一串沒有語意結構的符號，壓進 1024 維後跟其他案號幾乎難以區分。查案號、料號、法條編號、產品型號——dense 檢索會給你「語意上都是案號」的一堆錯誤結果。</p><h3 id="Sparse（稀疏向量）：認字精準、不懂改寫"><a href="#Sparse（稀疏向量）：認字精準、不懂改寫" class="headerlink" title="Sparse（稀疏向量）：認字精準、不懂改寫"></a>Sparse（稀疏向量）：認字精準、不懂改寫</h3><p>bge-m3 的特別之處是同一次推理<strong>順便</strong>產出 sparse 向量——一張「token → 權重」的詞彙表，本質上是神經網路加權版的關鍵字索引（可以理解為學習出來的 BM25）。它的強項正好補上 dense 的盲區：「7615」就是「7615」，一字不差才有分。</p><p>但反過來，使用者查「休假」而文件寫「請假」，sparse 就完全接不上——它不懂同義詞。</p><table><thead><tr><th></th><th>Dense</th><th>Sparse</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="二、RRF：為什麼融合名次，而不是分數"><a href="#二、RRF：為什麼融合名次，而不是分數" class="headerlink" title="二、RRF：為什麼融合名次，而不是分數"></a>二、RRF：為什麼融合名次，而不是分數</h2><p>兩路檢索各自回傳一份帶分數的排名，怎麼合併？直覺是把分數加起來——<strong>這是錯的</strong>。dense 的分數是餘弦相似度（0~1 之間、分佈密集），sparse 的分數是詞彙權重內積（範圍完全不同）。兩種量綱不同的分數相加，等於拿體重加身高。</p><p><strong>RRF（Reciprocal Rank Fusion）的解法：丟掉分數，只看名次。</strong></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">RRF_score(文件d) = Σ 每一路檢索 1 / (k + rank_d)</span><br></pre></td></tr></table></figure><p><code>rank_d</code> 是文件在該路檢索的名次，<code>k</code> 是平滑常數（通常 60）。一個切塊如果在 dense 排第 2、sparse 排第 5，它的 RRF 分數就是 <code>1/62 + 1/65</code>。只在其中一路出現的，就只拿那一路的分。</p><p>這個設計的精妙之處：</p><ul><li><strong>免疫於量綱問題</strong>——名次永遠可比，不管各路的分數怎麼算</li><li><strong>兩路都認可的結果自然上浮</strong>——雙料入榜的切塊分數幾乎翻倍</li><li><strong><code>k</code> 壓制了「第一名獨大」</strong>——<code>1/61</code> 和 <code>1/62</code> 差距很小，避免任何一路的榜首直接輾壓全場，讓融合真的是「合議」而不是「一言堂」</li></ul><h2 id="三、Qdrant-實作：一次請求做完兩路檢索與融合"><a href="#三、Qdrant-實作：一次請求做完兩路檢索與融合" class="headerlink" title="三、Qdrant 實作：一次請求做完兩路檢索與融合"></a>三、Qdrant 實作：一次請求做完兩路檢索與融合</h2><p>理論講完，看真實程式碼。Qdrant 的 <code>query_points</code> + <code>prefetch</code> 讓整個混合檢索<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><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></pre></td><td class="code"><pre><span class="line">result = client.query_points(</span><br><span class="line">    collection_name=settings.collection_name,</span><br><span class="line">    prefetch=[</span><br><span class="line">        models.Prefetch(               <span class="comment"># 第一路：dense 語意檢索</span></span><br><span class="line">            query=q[<span class="string">&quot;dense&quot;</span>],</span><br><span class="line">            using=<span class="string">&quot;dense&quot;</span>,</span><br><span class="line">            limit=settings.dense_top_k,      <span class="comment"># 各撈 20</span></span><br><span class="line">        ),</span><br><span class="line">        models.Prefetch(               <span class="comment"># 第二路：sparse 關鍵字檢索</span></span><br><span class="line">            query=models.SparseVector(</span><br><span class="line">                indices=q[<span class="string">&quot;sparse_indices&quot;</span>],</span><br><span class="line">                values=q[<span class="string">&quot;sparse_values&quot;</span>],</span><br><span class="line">            ),</span><br><span class="line">            using=<span class="string">&quot;sparse&quot;</span>,</span><br><span class="line">            limit=settings.sparse_top_k,</span><br><span class="line">        ),</span><br><span class="line">    ],</span><br><span class="line">    query=models.FusionQuery(fusion=models.Fusion.RRF),   <span class="comment"># 資料庫端 RRF 融合</span></span><br><span class="line">    limit=limit,                       <span class="comment"># 開 rerank 時廣撈 30 筆候選</span></span><br><span class="line">    with_payload=<span class="literal">True</span>,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>幾個實作細節：</p><ul><li><strong>collection 建立時就要宣告雙向量欄位</strong>：<code>vectors_config</code> 放 dense（COSINE 距離）、<code>sparse_vectors_config</code> 放 sparse。一個切塊一個 point，同時攜帶兩種向量。</li><li><strong>查詢端與索引端用同一顆 bge-m3</strong>——<code>embed_query</code> 同樣一次產出 dense + sparse，一次模型推理餵飽兩路檢索。</li><li>撈的數量由設定檔控制（<code>dense_top_k</code> &#x2F; <code>sparse_top_k</code> 各 20），改參數不動程式碼，方便跑評測調參。</li></ul><h2 id="四、Reranker：雙塔的極限，交叉編碼來補"><a href="#四、Reranker：雙塔的極限，交叉編碼來補" class="headerlink" title="四、Reranker：雙塔的極限，交叉編碼來補"></a>四、Reranker：雙塔的極限，交叉編碼來補</h2><p>混合檢索解決了「兩種盲區」，但還有一個更深層的架構限制。</p><h3 id="雙塔（Bi-encoder）為什麼天生粗糙"><a href="#雙塔（Bi-encoder）為什麼天生粗糙" class="headerlink" title="雙塔（Bi-encoder）為什麼天生粗糙"></a>雙塔（Bi-encoder）為什麼天生粗糙</h3><p>Dense 檢索是「雙塔」架構：問題和切塊<strong>各自獨立</strong>編碼成向量，事後只比距離。切塊在建索引時被壓縮成 1024 個數字——那時它根本不知道未來會被什麼問題查詢。一段 700 字的內容硬塞進固定維度，細節必然丟失；問題與切塊之間詞與詞的精細對應關係（誰修飾誰、否定詞在哪），距離計算完全看不見。</p><p>換來的是速度：所有切塊向量預先算好，查詢時只算一次問題向量，再做 ANN 近鄰搜尋——百萬級資料毫秒回應。</p><h3 id="交叉編碼（Cross-encoder）：慢工出細活"><a href="#交叉編碼（Cross-encoder）：慢工出細活" class="headerlink" title="交叉編碼（Cross-encoder）：慢工出細活"></a>交叉編碼（Cross-encoder）：慢工出細活</h3><p>Reranker（我用 bge-reranker-v2-m3）走完全不同的路：把**「問題＋切塊」串接成一段輸入**，整段送進模型，讓注意力機制直接看見兩者之間每個詞的互動，輸出一個相關性分數。</p><p>代價是<strong>每個候選都要跑一次完整的模型推理</strong>——不能預先計算（輸入包含查詢時才知道的問題），沒有索引可以加速。30 個候選就是 30 次推理。</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">百萬切塊 ──混合檢索（雙塔·快·粗）──▶ 30 個候選 ──Reranker（交叉編碼·慢·準）──▶ top-15 進 prompt</span><br></pre></td></tr></table></figure><p>粗篩的職責是<strong>召回</strong>：寧可多撈，別漏掉正確答案——所以廣撈 30。精排的職責是<strong>排序</strong>：把真正相關的排上來、把「語意相似但答非所問」的踢下去。慢的模型只處理 30 筆，成本可控。</p><p>實作上有兩個值得記的細節：</p><ul><li><strong>用開關控制</strong>（<code>rerank_enabled</code>），方便 A&#x2F;B 對照 rerank 到底有沒有幫助——回到第一篇說的：每個改動都要能被黃金問答集量測。</li><li><strong>裝置選擇的實戰陷阱</strong>：bge-m3 embedding 在 Apple MPS 上會卡死（所以 embedding 預設避開 MPS），但同系列的 reranker 在 MPS 上不僅正常、還比 CPU 快約一倍（30 個候選 24 秒 → 12.3 秒，分數完全相同）。同一家的模型、同一台機器、完全相反的結論——<strong>加速後端的相容性只能實測，不能推論</strong>。</li></ul><h2 id="五、整條管線的最終形貌"><a href="#五、整條管線的最終形貌" class="headerlink" title="五、整條管線的最終形貌"></a>五、整條管線的最終形貌</h2><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">問題</span><br><span class="line"> └─ bge-m3 編碼（一次推理，dense + sparse 雙輸出）</span><br><span class="line">     ├─ dense 檢索 top-20 ─┐</span><br><span class="line">     └─ sparse 檢索 top-20 ─┤</span><br><span class="line">                            ├─ RRF 融合（名次合議）→ 候選 30</span><br><span class="line">                            └─ bge-reranker-v2-m3 交叉編碼精排 → top-15</span><br><span class="line">                                └─ 進 prompt，強制標來源生成</span><br></pre></td></tr></table></figure><p>每一層都在補上一層的不足：sparse 補 dense 的字面盲區，RRF 公平合議兩路結果，reranker 補雙塔架構的精度極限。**沒有一個環節是銀彈，疊起來才是。**第一篇提過的實測結果——檢索命中率 100%——就是這條管線加上表格感知切塊共同達成的。</p><p>下一篇處理企業場景最硬的需求：<a href="/2026/08/09/retrieval-permission/">權限怎麼在檢索層強制執行——讓沒權限的文件連被檢索到的機會都沒有</a>。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/09/hybrid-retrieval-deep-dive/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/09/hybrid-retrieval-deep-dive/"/>
    <published>2026-08-09T11:00:00.000Z</published>
    <summary>
      <![CDATA[<p>「RAG 實戰筆記」第三篇。<a href="/2026/08/09/rag-fundamentals/">第一篇</a>把混合檢索一句話帶過：「dense 撈 20、sparse 撈 20、RRF 融合、rerank 精排」。這篇把這句話完全展開——為什麼單一檢索必然有盲區、RRF 為什麼用名次而不用分數、雙塔與交叉編碼在架構上的根本差異，以及每一步在 Qdrant 上的實際程式碼。</p>]]>
    </summary>
    <title>混合檢索深入拆解：dense、sparse、RRF 與 Reranker 的理論與實作</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="RAG 實戰筆記" scheme="https://murmurpaper.heitang.info/categories/RAG-%E5%AF%A6%E6%88%B0%E7%AD%86%E8%A8%98/"/>
    <category term="RAG" scheme="https://murmurpaper.heitang.info/tags/RAG/"/>
    <category term="Qdrant" scheme="https://murmurpaper.heitang.info/tags/Qdrant/"/>
    <category term="向量資料庫" scheme="https://murmurpaper.heitang.info/tags/%E5%90%91%E9%87%8F%E8%B3%87%E6%96%99%E5%BA%AB/"/>
    <category term="MongoDB" scheme="https://murmurpaper.heitang.info/tags/MongoDB/"/>
    <category term="資料庫" scheme="https://murmurpaper.heitang.info/tags/%E8%B3%87%E6%96%99%E5%BA%AB/"/>
    <content>
      <![CDATA[<p>「都是資料庫，為什麼 RAG 要特地用向量資料庫？MongoDB 不行嗎？」——這是我被問過、也曾自問過的問題。最好的回答方式不是抽象比較，而是看一個真實的架構：我的企業文件問答平台（<a href="/2026/08/09/rag-fundamentals/">上一篇</a>介紹過）<strong>同時</strong>用了 Qdrant 和 MongoDB，兩者各司其職、誰也取代不了誰。這篇從「它們根本在回答不同的問題」講起，最後整理成何時該用哪個的判斷準則。</p><span id="more"></span><h2 id="一、核心差異：兩種完全不同的「查詢」"><a href="#一、核心差異：兩種完全不同的「查詢」" class="headerlink" title="一、核心差異：兩種完全不同的「查詢」"></a>一、核心差異：兩種完全不同的「查詢」</h2><p>先把最本質的差異放在最前面。</p><p><strong>MongoDB 回答的問題是：「找出符合條件的資料。」</strong></p><figure class="highlight javascript"><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">// 找出「業務部」所有「已完成」的任務</span></span><br><span class="line">db.<span class="property">tasks</span>.<span class="title function_">find</span>(&#123; <span class="attr">department</span>: <span class="string">&quot;業務部&quot;</span>, <span class="attr">status</span>: <span class="string">&quot;done&quot;</span> &#125;)</span><br></pre></td></tr></table></figure><p>這是<strong>精確匹配</strong>的世界：條件成立就回傳，不成立就不回傳，結果非黑即白。底層靠 B-tree 索引，本質上是排好序的目錄，查詢是「翻到那一頁」。</p><p><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 找出跟「員工請假要跑什麼流程」語意最接近的 20 個文件切塊</span></span><br><span class="line">client.search(collection, query_vector=embed(<span class="string">&quot;員工請假要跑什麼流程&quot;</span>), limit=<span class="number">20</span>)</span><br></pre></td></tr></table></figure><p>這是<strong>相似度</strong>的世界：沒有「符合／不符合」，只有「多接近」。每筆資料是一個高維向量（我的專案用 1024 維），查詢是在這個空間裡找最近鄰。結果永遠是一個按距離排序的清單，第 1 名和第 20 名的差別只是遠近。</p><p>一句話總結：<strong>MongoDB 做的是「查找」（lookup），向量資料庫做的是「召回」（recall）</strong>。前者要的是準確命中，後者要的是語意涵蓋。</p><h2 id="二、為什麼一般資料庫做不好向量搜尋？"><a href="#二、為什麼一般資料庫做不好向量搜尋？" class="headerlink" title="二、為什麼一般資料庫做不好向量搜尋？"></a>二、為什麼一般資料庫做不好向量搜尋？</h2><p>「那我把向量存進 MongoDB 的欄位，查的時候自己算距離不就好了？」——可以，但會撞上兩堵牆。</p><p><strong>第一堵牆：算距離不能用傳統索引。</strong> B-tree 的前提是資料能排序（數字有大小、字串有字典序），但 1024 維向量之間只有「距離」沒有「順序」，B-tree 完全使不上力。不用索引的話就是暴力掃描：每次查詢跟<strong>全部</strong>向量算一次餘弦相似度——一萬筆還行，百萬筆每查一次都是災難。</p><p><strong>第二堵牆：近似最近鄰（ANN）是門專門的學問。</strong> 向量資料庫的核心武器是 HNSW 這類 ANN 索引——用「多層跳躍圖」讓搜尋不用走訪全部節點就能找到近鄰，以極小的精度損失換取數量級的速度提升。這套索引的建構、維護、調參（精度與速度的權衡）是向量資料庫的看家本領。</p><p>此外還有 RAG 實戰很依賴的周邊能力，以我用的 Qdrant 為例：</p><ul><li><strong>一個 collection 同時放 dense + sparse 兩種向量</strong>——上一篇講的混合檢索，原生支援</li><li><strong>payload 過濾與向量搜尋同時做</strong>——「在『非機密』的切塊裡找最相似的」一次完成，這是我的權限系統的地基（下面細講）</li><li>量化壓縮、分片、向量專用的記憶體管理</li></ul><p>公平地說：<strong>傳統資料庫陣營正在追</strong>。MongoDB Atlas 有 Vector Search、PostgreSQL 有 pgvector，小規模場景已經夠用，「順手用現有的資料庫加向量欄位」是完全合理的起點。但截至目前，混合檢索、過濾效率、向量規模化這些硬仗，專用向量資料庫仍是更穩的選擇——尤其自架（不用雲服務）的時候，MongoDB 社群版並沒有向量搜尋能力。</p><h2 id="三、真實架構：我的專案裡兩者怎麼分工"><a href="#三、真實架構：我的專案裡兩者怎麼分工" class="headerlink" title="三、真實架構：我的專案裡兩者怎麼分工"></a>三、真實架構：我的專案裡兩者怎麼分工</h2><p>講理論不如看實戰。我的文件問答平台資料層長這樣：</p><table><thead><tr><th>資料庫</th><th>存什麼</th><th>角色</th></tr></thead><tbody><tr><td><strong>Qdrant</strong></td><td>文件切塊的 dense&#x2F;sparse 向量 + payload（原文、來源、切塊序號、機密等級）</td><td><strong>檢索引擎</strong>：回答「哪些切塊跟問題最相關」</td></tr><tr><td><strong>MongoDB</strong></td><td>文件原始記錄、上傳任務狀態、對話歷史、使用者收藏、稽核紀錄</td><td><strong>業務資料的家</strong>：回答「這份文件是誰上傳的」「這場對話說了什麼」</td></tr><tr><td>SQLite</td><td>帳號、角色、部門、權限</td><td>關聯式的權限模型（RBAC）</td></tr><tr><td>Redis</td><td>任務佇列、快取</td><td>背景工作的傳令兵</td></tr></tbody></table><p>注意分工的邏輯：</p><p><strong>Qdrant 是「可重建的索引」，MongoDB 是「事實的來源」。</strong> 向量庫裡的東西全部是從原始文件推導出來的——我的 <code>ingest.py</code> 甚至設計成每次重跑就清空重建整個 collection，方便「改切塊參數 → 重算 → 評測」的調校迴圈。敢這樣做正是因為向量庫裡<strong>沒有任何不可再生的資料</strong>。反過來，MongoDB 裡的對話歷史、稽核紀錄是事實本身，掉了就是掉了。這個「來源 vs 索引」的區分，是多資料庫架構最重要的心智模型。</p><p><strong>各自發揮所長，而不是互相替代。</strong> 使用者問問題 → Qdrant 負責從幾萬個切塊裡召回最相關的 30 個；答案生成後 → 對話寫進 MongoDB；管理員要查「上週誰上傳了什麼文件」→ 純 MongoDB 條件查詢，向量庫完全不參與。硬要單邊包辦的話：MongoDB 做語意檢索做不動（自架版根本沒有），Qdrant 存對話歷史則是拿檢索引擎當業務資料庫用——payload 是給過濾用的，不是給你 CRUD 的。</p><p><strong>權限在檢索層強制執行。</strong> 這是兩者合作最精彩的地方：每個切塊寫入 Qdrant 時都帶著 <code>confidentiality</code>（機密等級）payload；使用者查詢時，後端把他的權限範圍組成 Qdrant filter，<strong>向量搜尋只在他有權看的切塊中進行</strong>。沒權限的文件不是「檢索到了再過濾掉」，而是從一開始就不在搜尋範圍裡——不會漏答案位置、不會浪費召回名額、更不會在錯誤訊息裡洩漏機密文件的存在。這種「相似度搜尋 + 結構化過濾」一次完成的能力，正是向量資料庫相對於「自己算距離」的決定性優勢。</p><h2 id="四、對照表：一次看懂"><a href="#四、對照表：一次看懂" class="headerlink" title="四、對照表：一次看懂"></a>四、對照表：一次看懂</h2><table><thead><tr><th>面向</th><th>MongoDB（文件資料庫）</th><th>向量資料庫（Qdrant 等）</th></tr></thead><tbody><tr><td>回答的問題</td><td>符合條件的資料</td><td>語意最接近的資料</td></tr><tr><td>查詢結果</td><td>精確集合（符合&#x2F;不符合）</td><td>排序清單（按距離遠近）</td></tr><tr><td>核心索引</td><td>B-tree</td><td>HNSW 等 ANN 索引</td></tr><tr><td>資料單位</td><td>JSON 文件</td><td>向量 + payload</td></tr><tr><td>擅長</td><td>CRUD、聚合統計、業務邏輯</td><td>相似度搜尋、混合檢索、帶過濾的召回</td></tr><tr><td>不擅長</td><td>語意搜尋（自架版沒有）</td><td>交易、複雜聚合、當業務主資料庫</td></tr><tr><td>資料性質</td><td>事實來源（不可再生）</td><td>推導索引（可隨時重建）</td></tr><tr><td>在 RAG 中的角色</td><td>存文件記錄、對話、稽核</td><td>檢索引擎本體</td></tr></tbody></table><h2 id="五、判斷準則：你需要哪一個？"><a href="#五、判斷準則：你需要哪一個？" class="headerlink" title="五、判斷準則：你需要哪一個？"></a>五、判斷準則：你需要哪一個？</h2><ul><li><strong>只需要條件查詢、統計、業務資料</strong> → MongoDB（或任何你熟的資料庫），跟向量無關。</li><li><strong>要做語意搜尋，資料量小（幾萬筆內）、已在用 MongoDB Atlas 或 PostgreSQL</strong> → 先用 Atlas Vector Search &#x2F; pgvector，別為了技術潮流多養一個服務。</li><li><strong>RAG 是核心功能，需要混合檢索、metadata 過濾、自架部署，或資料量會成長</strong> → 專用向量資料庫。我選 Qdrant 的理由：docker compose 一行起服務、原生 dense+sparse 雙向量、filter 表達力強（權限系統直接建在上面）。</li><li><strong>做正經的系統</strong> → 大概率跟我一樣<strong>兩個都要</strong>：向量庫當檢索引擎，一般資料庫當事實來源。這不是架構的妥協，而是各就各位。</li></ul><h2 id="小結"><a href="#小結" class="headerlink" title="小結"></a>小結</h2><p>「向量資料庫 vs MongoDB」其實是個假對立——它們一個管「像不像」、一個管「是不是」，在 RAG 系統裡是上下游的合作關係。真正要做的決定是：<strong>你的語意檢索需求，重到值得為它養一個專用引擎嗎？</strong> 我的答案是值得——混合檢索與檢索層權限過濾這兩件事，就足以讓 Qdrant 在架構裡站穩位置。</p><p>系列下一篇的主題還在構思，可能是混合檢索與 Rerank 的深入拆解，或是檢索層權限設計的完整實作。敬請期待。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/09/vectordb-vs-mongodb/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/09/vectordb-vs-mongodb/"/>
    <published>2026-08-09T10:30:00.000Z</published>
    <summary>
      <![CDATA[<p>「都是資料庫，為什麼 RAG 要特地用向量資料庫？MongoDB 不行嗎？」——這是我被問過、也曾自問過的問題。最好的回答方式不是抽象比較，而是看一個真實的架構：我的企業文件問答平台（<a href="/2026/08/09/rag-fundamentals/">上一篇</a>介紹過）<strong>同時</strong>用了 Qdrant 和 MongoDB，兩者各司其職、誰也取代不了誰。這篇從「它們根本在回答不同的問題」講起，最後整理成何時該用哪個的判斷準則。</p>]]>
    </summary>
    <title>向量資料庫與 MongoDB 差在哪？從一個同時用 Qdrant 和 MongoDB 的專案說起</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="RAG 實戰筆記" scheme="https://murmurpaper.heitang.info/categories/RAG-%E5%AF%A6%E6%88%B0%E7%AD%86%E8%A8%98/"/>
    <category term="教學" scheme="https://murmurpaper.heitang.info/tags/%E6%95%99%E5%AD%B8/"/>
    <category term="RAG" scheme="https://murmurpaper.heitang.info/tags/RAG/"/>
    <category term="Qdrant" scheme="https://murmurpaper.heitang.info/tags/Qdrant/"/>
    <category term="LLM" scheme="https://murmurpaper.heitang.info/tags/LLM/"/>
    <category term="向量檢索" scheme="https://murmurpaper.heitang.info/tags/%E5%90%91%E9%87%8F%E6%AA%A2%E7%B4%A2/"/>
    <content>
      <![CDATA[<p>這是「RAG 實戰筆記」系列的第一篇。我正在打造一個企業文件問答平台（黃金問答集實測檢索命中率 100%、答案正確率 89%），這個系列會把過程中搞懂的觀念和踩過的坑整理成文。第一篇先把地基打好：RAG 到底在解決什麼問題、完整管線長什麼樣、以及每個環節的實戰細節。</p><span id="more"></span><h2 id="一、RAG-在解決什麼問題？"><a href="#一、RAG-在解決什麼問題？" class="headerlink" title="一、RAG 在解決什麼問題？"></a>一、RAG 在解決什麼問題？</h2><p>LLM 很聰明，但有三個先天限制：</p><ol><li><strong>知識有截止日</strong>——模型訓練完成後發生的事它不知道。</li><li><strong>不知道你的私有資料</strong>——公司的規章、合約、會議記錄，模型從沒看過。</li><li><strong>會一本正經地胡說</strong>——被問到不知道的事，它傾向生成「看起來合理」的答案，而不是承認不知道。</li></ol><p>RAG（Retrieval-Augmented Generation，檢索增強生成）的解法直觀得近乎樸素：<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">傳統 LLM：  問題 ──────────────────▶ 模型憑訓練記憶回答</span><br><span class="line">RAG：      問題 ──▶ 檢索相關文件片段 ──▶ 模型根據片段回答（並標明出處）</span><br></pre></td></tr></table></figure><p>這帶來三個直接的好處：答案有依據（可以強制標來源，錯了能追溯）、資料可以隨時更新（加文件就好，不用重訓模型）、私有資料不用送去訓練（我的專案裡 embedding 完全在本機跑，文件內容不出機器）。</p><h2 id="二、完整管線：兩條路徑"><a href="#二、完整管線：兩條路徑" class="headerlink" title="二、完整管線：兩條路徑"></a>二、完整管線：兩條路徑</h2><p>RAG 系統有兩條獨立的資料流——<strong>建索引</strong>（把文件變成可檢索的形式，離線做）和<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><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">【建索引 · Ingestion】</span><br><span class="line">文件（PDF/Word/Excel/PPT/掃描檔）</span><br><span class="line">  → 載入（含 OCR 判斷）</span><br><span class="line">  → 切塊（chunking）</span><br><span class="line">  → 向量化（embedding：dense + sparse）</span><br><span class="line">  → 寫入向量資料庫（附 metadata）</span><br><span class="line"></span><br><span class="line">【查詢 · Query】</span><br><span class="line">問題 →（多輪對話時先改寫）</span><br><span class="line">  → 向量化（與建索引同一顆模型！）</span><br><span class="line">  → 混合檢索（dense 20 + sparse 20 → RRF 融合 → 廣撈 30）</span><br><span class="line">  → Rerank 重排 → 取 top-15</span><br><span class="line">  → 丟給 LLM 生成（強制標來源）</span><br></pre></td></tr></table></figure><p>以下逐段拆解，每一段都附上我實際的做法與理由。</p><h2 id="三、載入：文件世界比你想的髒"><a href="#三、載入：文件世界比你想的髒" class="headerlink" title="三、載入：文件世界比你想的髒"></a>三、載入：文件世界比你想的髒</h2><p>教學文章的 RAG 都從乾淨的純文字開始，現實裡你面對的是：有文字層的 PDF、<strong>沒有</strong>文字層的掃描 PDF、Word 裡的表格、Excel 的多工作表、PPT 的文字框。每種都要有對應的處理：</p><table><thead><tr><th>類型</th><th>處理方式</th></tr></thead><tbody><tr><td>PDF（有文字層）</td><td>直接抽文字（pypdf）</td></tr><tr><td>PDF（掃描檔）</td><td>轉圖 → OCR（tesseract，中文繁體+英文）</td></tr><tr><td>Word</td><td>段落 + 表格</td></tr><tr><td>Excel</td><td>逐工作表逐列串接</td></tr><tr><td>PPT</td><td>逐投影片文字框</td></tr></tbody></table><p>一個實用的小設計：<strong>怎麼判斷 PDF 是不是掃描檔？</strong> 我的做法是「平均每頁字元數 &lt; 20 就當掃描檔走 OCR」。閾值很土，但有效——有文字層的 PDF 每頁隨便都幾百字，掃描檔抽出來幾乎是空的。</p><h2 id="四、切塊：RAG-品質的第一個決勝點"><a href="#四、切塊：RAG-品質的第一個決勝點" class="headerlink" title="四、切塊：RAG 品質的第一個決勝點"></a>四、切塊：RAG 品質的第一個決勝點</h2><p>文件不能整份塞給模型（塞不下，也不精準），要切成小塊。切塊策略直接決定檢索品質，這是我整個專案裡<strong>投資報酬率最高</strong>的一個環節。</p><p><strong>起手式：固定長度滑動視窗。</strong> 每塊 700 字、相鄰塊重疊 120 字，以字元數計（對中文最直覺），切點盡量落在自然斷點（換行 &gt; 句末標點 &gt; 逗號），避免硬切在詞中間。</p><p>為什麼從最笨的方法開始？<strong>先笨後巧</strong>——第一階段要驗證的是「檢索＋切塊能不能回答問題」這個大方向，固定切塊最穩、最好除錯；結構感知切塊是優化題，過早引入只會多一個出錯變數。</p><p><strong>進化：表格列感知切塊。</strong> 固定切塊遇到表格會出災難——表格被橫向硬切，切塊開頭是上一筆資料的尾巴，「不能安全駕駛」被切成「不能安全駕&#x2F;駛」，表頭和內容分家，切塊語意完全破碎。解法是先把版面文字還原成「一筆一列」的紀錄，再以固定筆數（我用 12 筆）完整列組成一個切塊，<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">臺灣桃園地方檢察署公告（民國115年03月10日）偵查案件一覽</span><br><span class="line">股別 | 年度 | 字別 | 案號 | 案由 | 移送機關 | 被告姓名 | 偵結要旨</span><br><span class="line">律 | 115年偵字第7615號 | 家庭暴力防治 | 楊梅分局 | 黃○育 | 起訴</span><br><span class="line">…（共 12 筆完整案件）</span><br></pre></td></tr></table></figure><p>偵測不到已知表格樣式時自動退回固定長度切塊，一般文件不受影響。<strong>實測效果：答案正確率 50% → 72%（+22 個百分點）、檢索命中率 93% → 100%。</strong> 一個切塊策略的改進，比換模型、調 prompt 都有效——這是我想強調的重點：<strong>RAG 的瓶頸常常在資料工程，不在模型。</strong></p><h2 id="五、Embedding：把語意變成座標"><a href="#五、Embedding：把語意變成座標" class="headerlink" title="五、Embedding：把語意變成座標"></a>五、Embedding：把語意變成座標</h2><p>Embedding 模型把文字轉成高維向量，讓「語意相近」變成「距離相近」——這是整個檢索的數學基礎。我用 <strong>bge-m3</strong>（本機執行，文件不出機器），它一次產生兩種向量：</p><ul><li><strong>dense（稠密向量）</strong>：1024 維的語意向量。「請假規定」和「休假辦法」字面不同但語意近，dense 向量抓得到。</li><li><strong>sparse（稀疏向量）</strong>：詞彙級的權重表。專有名詞、案號、代碼這種「一字不差才算命中」的查詢，sparse 才可靠——dense 對「115年偵字第7615號」這種字串是很遲鈍的。</li></ul><h3 id="⚠️-單向門：embedding-模型一旦定案就很難換"><a href="#⚠️-單向門：embedding-模型一旦定案就很難換" class="headerlink" title="⚠️ 單向門：embedding 模型一旦定案就很難換"></a>⚠️ 單向門：embedding 模型一旦定案就很難換</h3><p><strong>查詢時的向量必須跟建索引時用同一顆模型產生</strong>，否則兩邊的向量空間對不上，檢索結果全錯。這意味著換 embedding 模型 &#x3D; 整個向量庫重算。所以選型時要想清楚，而且開發與正式環境用同一顆模型——品質一致、零重算。這是 RAG 架構裡少數「決定了就回不太了頭」的選擇。</p><h2 id="六、檢索：混合搜尋-重排"><a href="#六、檢索：混合搜尋-重排" class="headerlink" title="六、檢索：混合搜尋 + 重排"></a>六、檢索：混合搜尋 + 重排</h2><p>單靠 dense 或單靠 sparse 都有盲區，所以用<strong>混合檢索</strong>：</p><ol><li>dense 搜 20 筆 + sparse 搜 20 筆</li><li>用 <strong>RRF</strong>（Reciprocal Rank Fusion）融合兩份排名——不比較兩邊的分數（量綱不同沒法比），只看名次</li><li>廣撈 30 筆候選</li><li>交給 <strong>reranker</strong>（bge-reranker-v2-m3）精排——它把「問題＋候選切塊」成對送進模型打分，比純向量距離準得多，但慢，所以只用在最後一哩</li><li>取 top-15 進生成</li></ol><p>這個「粗篩廣撈 → 精排收斂」的兩段式，是檢索系統的經典架構：粗篩要快、召回要廣（寧可多撈）；精排要準（把真正相關的排上來）。</p><h2 id="七、生成：把模型鎖在證據裡"><a href="#七、生成：把模型鎖在證據裡" class="headerlink" title="七、生成：把模型鎖在證據裡"></a>七、生成：把模型鎖在證據裡</h2><p>檢索到的切塊連同問題一起組成 prompt 送給 LLM（我用 Claude），關鍵的設計有兩個：</p><ul><li><strong>強制標來源</strong>：要求模型每個論點都標註出處（<code>[1]</code>、<code>[2]</code> 對應到具體文件與切塊）。這不只為了可信度——它實質上改變了模型的行為，讓它傾向「只說找得到依據的話」。</li><li><strong>多輪改寫</strong>：對話中的追問常常是「那第二種呢？」這種缺乏上下文的句子，直接拿去檢索必死。所以檢索前先用 LLM 把問題改寫成獨立完整的查詢（query rewrite），這一步用 LangGraph 把「改寫 → 檢索 → 生成」串成明確的流程圖。</li></ul><h2 id="八、沒有評測，一切都是感覺"><a href="#八、沒有評測，一切都是感覺" class="headerlink" title="八、沒有評測，一切都是感覺"></a>八、沒有評測，一切都是感覺</h2><p>最後、也是最容易被跳過的一環：<strong>黃金問答集</strong>。準備一組「問題 + 標準答案 + 答案所在文件」的測試集，每次改動（換切塊策略、調檢索參數、改 prompt）都跑一遍，量兩個數字：</p><ul><li><strong>檢索命中率</strong>：正確答案所在的切塊，有沒有進到 top-k？</li><li><strong>答案正確率</strong>：模型的回答對不對？</li></ul><p>沒有這個，你只能憑感覺判斷「好像有變好」；有了它，前面提到的「表格切塊 +22pt」才有辦法被驗證。<strong>RAG 是資料工程，資料工程要量測。</strong></p><h2 id="小結：一張圖記住-RAG"><a href="#小結：一張圖記住-RAG" class="headerlink" title="小結：一張圖記住 RAG"></a>小結：一張圖記住 RAG</h2><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">     品質好壞的責任分佈（個人體感）</span><br><span class="line">切塊策略 ████████████ 40%</span><br><span class="line">檢索設計 ████████░░░░ 30%（混合檢索、rerank）</span><br><span class="line">Embedding 選型 ████░░░░ 15%</span><br><span class="line">Prompt／生成 ███░░░░░░ 15%</span><br></pre></td></tr></table></figure><p>下一篇會深入資料層的一個常見疑問：<a href="/2026/08/09/vectordb-vs-mongodb/">向量資料庫和 MongoDB 到底差在哪？為什麼我的專案兩個都用？</a></p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/09/rag-fundamentals/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/09/rag-fundamentals/"/>
    <published>2026-08-09T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p>這是「RAG 實戰筆記」系列的第一篇。我正在打造一個企業文件問答平台（黃金問答集實測檢索命中率 100%、答案正確率 89%），這個系列會把過程中搞懂的觀念和踩過的坑整理成文。第一篇先把地基打好：RAG 到底在解決什麼問題、完整管線長什麼樣、以及每個環節的實戰細節。</p>]]>
    </summary>
    <title>RAG 基礎邏輯與實戰教學：從文件到答案的完整管線</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="專案筆記" scheme="https://murmurpaper.heitang.info/categories/%E5%B0%88%E6%A1%88%E7%AD%86%E8%A8%98/"/>
    <category term="教學" scheme="https://murmurpaper.heitang.info/tags/%E6%95%99%E5%AD%B8/"/>
    <category term="Claude Code" scheme="https://murmurpaper.heitang.info/tags/Claude-Code/"/>
    <category term="LINE" scheme="https://murmurpaper.heitang.info/tags/LINE/"/>
    <category term="Cloudflare Tunnel" scheme="https://murmurpaper.heitang.info/tags/Cloudflare-Tunnel/"/>
    <category term="macOS" scheme="https://murmurpaper.heitang.info/tags/macOS/"/>
    <content>
      <![CDATA[<p>系列最後一篇，把 <a href="https://github.com/murmur-wu/claude_linebot">claude_linebot</a> 從零部署起來：申請 LINE channel、設定 <code>.env</code>、用 Cloudflare Tunnel 把本機 webhook 掛上公網（quick tunnel 與 named tunnel 兩種），最後註冊成 macOS LaunchAgent 開機自動跑。（前情提要：<a href="/2026/08/09/linebot-motivation/">動機</a>、<a href="/2026/08/09/linebot-architecture/">架構</a>）</p><span id="more"></span><h2 id="前置需求"><a href="#前置需求" class="headerlink" title="前置需求"></a>前置需求</h2><ul><li>macOS（防睡眠用 <code>caffeinate</code>；Windows 也能跑，自動走 Win32 分支）</li><li>Python 3.10+</li><li>已安裝、登入完成的 <a href="https://docs.claude.com/en/docs/claude-code/setup"><code>claude</code> CLI</a></li><li>LINE Messaging API channel（下面申請）</li><li><code>cloudflared</code>（<code>brew install cloudflared</code>）</li></ul><p>LINE 是 webhook 模式——LINE 的伺服器要能主動連到你的電腦，所以需要一個公開 HTTPS 網址。Cloudflare Tunnel 用一條 outbound 連線解決這件事：不開防火牆 port、不需要固定 IP。</p><h2 id="一、申請-LINE-channel"><a href="#一、申請-LINE-channel" class="headerlink" title="一、申請 LINE channel"></a>一、申請 LINE channel</h2><ol><li>到 <a href="https://developers.line.biz/console/">LINE Developers Console</a> 建立 Provider（沒有的話）</li><li>建立 <strong>Messaging API channel</strong></li><li>記下兩樣東西：<strong>Channel secret</strong>（Basic settings 分頁）和 <strong>Channel access token</strong>（Messaging API 分頁，按 Issue）</li></ol><h2 id="二、設定並啟動-bot"><a href="#二、設定並啟動-bot" class="headerlink" title="二、設定並啟動 bot"></a>二、設定並啟動 bot</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></pre></td><td class="code"><pre><span class="line">git <span class="built_in">clone</span> https://github.com/murmur-wu/claude_linebot.git ~/claude/lineBot</span><br><span class="line"><span class="built_in">cd</span> ~/claude/lineBot</span><br><span class="line"><span class="built_in">cp</span> .env.example .<span class="built_in">env</span></span><br></pre></td></tr></table></figure><p><code>.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></pre></td><td class="code"><pre><span class="line"><span class="attr">LINE_CHANNEL_SECRET</span>=...</span><br><span class="line"><span class="attr">LINE_CHANNEL_ACCESS_TOKEN</span>=...</span><br><span class="line">ALLOWED_USER_IDS=          <span class="comment"># 先留空，等下用 bot 的回覆拿自己的 id</span></span><br><span class="line"><span class="attr">PROJECTS</span>=blog:/Users/you/projects/blog<span class="comment">;work:/Users/you/projects/work</span></span><br><span class="line"><span class="attr">DEFAULT_PROJECT</span>=blog</span><br></pre></td></tr></table></figure><p>啟動（首次會自動建 <code>.venv</code>、裝套件）：</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">./start.sh</span><br></pre></td></tr></table></figure><blockquote><p>專案路徑建議放 <code>~/claude/</code> 這類一般目錄。放在 <code>~/Desktop</code>、<code>~/Documents</code>、<code>~/Downloads</code> 的話，之後服務化會撞上 macOS 的保護目錄限制（見最後一節）。</p></blockquote><h2 id="三、Cloudflare-Tunnel"><a href="#三、Cloudflare-Tunnel" class="headerlink" title="三、Cloudflare Tunnel"></a>三、Cloudflare Tunnel</h2><h3 id="先用-quick-tunnel-打通流程"><a href="#先用-quick-tunnel-打通流程" class="headerlink" title="先用 quick tunnel 打通流程"></a>先用 quick tunnel 打通流程</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">./tunnel.sh          <span class="comment"># 會印出 https://xxxx.trycloudflare.com</span></span><br></pre></td></tr></table></figure><p>quick tunnel 不用註冊、不用網域，適合驗證整條流程。<strong>缺點是網址每次重啟都會變</strong>，變了就要回 Console 更新 Webhook URL——所以打通後建議直接升級 named tunnel。</p><h3 id="設定-Webhook-URL"><a href="#設定-Webhook-URL" class="headerlink" title="設定 Webhook URL"></a>設定 Webhook URL</h3><p>LINE Developers Console → 你的 channel → <strong>Messaging API</strong> 分頁：</p><ol><li><strong>Webhook URL</strong> 填 <code>https://xxxx.trycloudflare.com/callback</code></li><li><strong>Use webhook</strong> 打開</li><li><strong>Auto-reply messages</strong> 關掉（不然官方罐頭回覆會跟 bot 打架）</li><li>按 <strong>Verify</strong> → 應該回 Success</li></ol><h3 id="拿白名單-id"><a href="#拿白名單-id" class="headerlink" title="拿白名單 id"></a>拿白名單 id</h3><p>用手機加 bot 好友、傳任意訊息，bot 會直接回你的 <code>userId</code>（一串 <code>U</code> 開頭 33 字）。填進 <code>.env</code> 的 <code>ALLOWED_USER_IDS</code>，<code>./restart.sh</code> 重啟。這是 fail-closed 設計：名單空著時 bot 拒絕所有人，不存在「忘了設定所以全開」的狀態。</p><h3 id="升級-named-tunnel（固定網址）"><a href="#升級-named-tunnel（固定網址）" class="headerlink" title="升級 named tunnel（固定網址）"></a>升級 named tunnel（固定網址）</h3><p>前提：你有一個網域託管在 Cloudflare（免費方案即可）。</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">cloudflared tunnel login          <span class="comment"># 瀏覽器授權，選你的 zone</span></span><br><span class="line">cloudflared tunnel create linebot</span><br></pre></td></tr></table></figure><p>建立 <code>~/.cloudflared/config.yml</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><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">tunnel:</span> <span class="string">linebot</span></span><br><span class="line"><span class="attr">credentials-file:</span> <span class="string">/Users/&lt;你&gt;/.cloudflared/&lt;tunnel-uuid&gt;.json</span></span><br><span class="line"></span><br><span class="line"><span class="attr">ingress:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="attr">hostname:</span> <span class="string">linebot.example.com</span></span><br><span class="line">    <span class="attr">service:</span> <span class="string">http://127.0.0.1:8000</span></span><br><span class="line">  <span class="bullet">-</span> <span class="attr">service:</span> <span class="string">http_status:404</span></span><br></pre></td></tr></table></figure><p>把 DNS 指到 tunnel、再啟動：</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">cloudflared tunnel route dns --overwrite-dns linebot linebot.example.com</span><br><span class="line">./tunnel.sh          <span class="comment"># 偵測到 config.yml 就自動跑 named tunnel</span></span><br></pre></td></tr></table></figure><p>回 Console 把 Webhook URL 改成 <code>https://linebot.example.com/callback</code>，Verify 一次。從此網址固定，重啟也不用再碰 Console。</p><p>安全上的細節：bot 的 <code>HOST</code> 預設綁 <code>127.0.0.1</code>，只有 tunnel 進得來，不對區網開放；webhook 已內建 <code>X-Line-Signature</code> HMAC 驗簽，驗不過直接 400。</p><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></pre></td><td class="code"><pre><span class="line">./restart.sh              <span class="comment"># 改完 .env 重啟（自動判斷 launchd / nohup 模式）</span></span><br><span class="line"><span class="built_in">tail</span> -f state/bot.log     <span class="comment"># 看 log</span></span><br></pre></td></tr></table></figure><p>重啟不會斷對話——session_id 存在 <code>state/sessions.json</code>。改 <code>.env</code> 不用重啟 tunnel（除非改了 <code>PORT</code>）。</p><h2 id="五、開機自動啟動（LaunchAgent）"><a href="#五、開機自動啟動（LaunchAgent）" class="headerlink" title="五、開機自動啟動（LaunchAgent）"></a>五、開機自動啟動（LaunchAgent）</h2><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">./services/install-service.sh     <span class="comment"># 註冊 bot 與 tunnel 兩個 LaunchAgent</span></span><br></pre></td></tr></table></figure><p>用的是使用者層的 <strong>LaunchAgent</strong> 而不是系統層的 LaunchDaemon——原因跟 Windows 版不能用 LocalSystem 一樣：<code>claude</code> CLI 的認證在 <code>~/.claude/</code>，以 root 跑找不到。代價是「登入後」才啟動；要真正開機即跑，到系統設定開自動登入。</p><h3 id="macOS-保護目錄的坑"><a href="#macOS-保護目錄的坑" class="headerlink" title="macOS 保護目錄的坑"></a>macOS 保護目錄的坑</h3><p>如果專案放在 <code>~/Desktop</code>、<code>~/Documents</code>、<code>~/Downloads</code>（TCC 保護目錄），launchd 會遇到一連串限制：不能 <code>chdir</code> 進去、不能直接 exec 裡面的執行檔、不能開啟由 Terminal 建立的既有 log 檔——而且失敗時<strong>只給 <code>exit code 78 (EX_CONFIG)</code>，零錯誤輸出</strong>，極難排查。</p><p>最陰險的是第三項：你先用 <code>./start.sh</code> 手動跑過，<code>state/bot.log</code> 的存取權歸屬 Terminal；改成服務後 launchd 開不了那個檔案，服務永遠起不來。</p><p><code>install-service.sh</code> 已經把能繞的都繞了（不設 <code>WorkingDirectory</code>、透過 <code>/bin/sh -c &#39;exec …&#39;</code> 啟動、自動把舊 log 改名），但「launchd 啟動的程序<strong>讀取</strong>保護目錄檔案」這關必須手動授權：系統設定 → 隱私權與安全性 → 完全磁碟取用權 → 加入 <code>&lt;專案&gt;/.venv/bin/python</code>。</p><p>不想開這麼大的權限？<strong>把專案移出保護目錄</strong>（例如 <code>~/claude/lineBot</code>）就完全不需要任何授權——這也是本文開頭建議 clone 到 <code>~/claude/</code> 的原因。</p><h2 id="常見坑速查"><a href="#常見坑速查" class="headerlink" title="常見坑速查"></a>常見坑速查</h2><table><thead><tr><th>症狀</th><th>原因</th></tr></thead><tbody><tr><td>Console 按 Verify 回 504</td><td>tunnel 沒跑，或 ingress 沒指到 bot。先 <code>curl 127.0.0.1:8000/healthz</code> 確認本機活著</td></tr><tr><td>自己的測試腳本收到 403</td><td>Cloudflare 擋部分 User-Agent（如 <code>Python-urllib</code>），換個 UA 就好，LINE 本身不受影響</td></tr><tr><td>userId 填了還是被拒</td><td>LINE userId 固定 33 字，手抄常漏字——直接從 <code>state/bot.log</code> 的 rejection log 複製</td></tr><tr><td>群組裡 bot 完全沒反應</td><td>依序查：<code>ALLOW_GROUPS=true</code> 且重啟過、訊息帶 <code>claude</code> 前綴、發話者在白名單</td></tr><tr><td>改 <code>.env</code> 沒生效</td><td>bot 不會自動重讀設定，要 <code>./restart.sh</code></td></tr><tr><td>LaunchAgent exit 78 且無 log</td><td>保護目錄問題，見上一節</td></tr></tbody></table><h2 id="系列回顧"><a href="#系列回顧" class="headerlink" title="系列回顧"></a>系列回顧</h2><ul><li><a href="/2026/08/09/linebot-motivation/">第一篇：動機</a>——為什麼是 LINE，三大平台難題</li><li><a href="/2026/08/09/linebot-architecture/">第二篇：架構</a>——webhook 管線、回覆經濟學、兩段式附件</li><li><strong>本篇：部署</strong>——LINE channel、Cloudflare Tunnel、LaunchAgent</li></ul><p>到這裡，家裡的 Mac 就成了一台隨身可指揮的開發機：LINE 傳個訊息，Claude Code 改 code、跑指令、commit，結果回到手機上。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/09/linebot-deploy/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/09/linebot-deploy/"/>
    <published>2026-08-09T02:20:00.000Z</published>
    <summary>
      <![CDATA[<p>系列最後一篇，把 <a href="https://github.com/murmur-wu/claude_linebot">claude_linebot</a> 從零部署起來：申請 LINE channel、設定 <code>.env</code>、用 Cloudflare Tunnel 把本機 webhook 掛上公網（quick tunnel 與 named tunnel 兩種），最後註冊成 macOS LaunchAgent 開機自動跑。（前情提要：<a href="/2026/08/09/linebot-motivation/">動機</a>、<a href="/2026/08/09/linebot-architecture/">架構</a>）</p>]]>
    </summary>
    <title>claude_linebot 部署教學：LINE channel、Cloudflare Tunnel 與開機自動啟動</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="專案筆記" scheme="https://murmurpaper.heitang.info/categories/%E5%B0%88%E6%A1%88%E7%AD%86%E8%A8%98/"/>
    <category term="Claude Code" scheme="https://murmurpaper.heitang.info/tags/Claude-Code/"/>
    <category term="LINE" scheme="https://murmurpaper.heitang.info/tags/LINE/"/>
    <category term="Python" scheme="https://murmurpaper.heitang.info/tags/Python/"/>
    <category term="asyncio" scheme="https://murmurpaper.heitang.info/tags/asyncio/"/>
    <category term="架構設計" scheme="https://murmurpaper.heitang.info/tags/%E6%9E%B6%E6%A7%8B%E8%A8%AD%E8%A8%88/"/>
    <content>
      <![CDATA[<p><a href="/2026/08/09/linebot-motivation/">上一篇</a>講了 <a href="https://github.com/murmur-wu/claude_linebot">claude_linebot</a> 的動機和 LINE 的三大平台難題；這篇拆開實作：一則 LINE 訊息的完整旅程、reply&#x2F;push 的成本決策、以及「圖片沒有 caption」逼出來的兩段式附件設計。</p><span id="more"></span><h2 id="檔案結構"><a href="#檔案結構" class="headerlink" title="檔案結構"></a>檔案結構</h2><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">lineBot/</span><br><span class="line">├── bot.py              ← aiohttp webhook server + 指令 + 訊息流程</span><br><span class="line">├── line_client.py      ← LINE API 包裝：reply/push 路由、分段、打字動畫</span><br><span class="line">├── claude_runner.py    ← async wrapper 包 `claude --print`（與 remotetools 幾乎相同）</span><br><span class="line">├── attachment_store.py ← 圖片/檔案落地 + 待處理清單</span><br><span class="line">├── session_store.py    ← session_id 持久化</span><br><span class="line">├── usage_tracker.py    ← RPM + 每日上限 + 成本累計</span><br><span class="line">├── keep_awake.py       ← macOS caffeinate / Windows SetThreadExecutionState</span><br><span class="line">└── state/              ← sessions.json / usage.json / bot.log / uploads/</span><br></pre></td></tr></table></figure><h2 id="一則訊息的十道關卡"><a href="#一則訊息的十道關卡" class="headerlink" title="一則訊息的十道關卡"></a>一則訊息的十道關卡</h2><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">webhook POST ─▶ ① 驗 X-Line-Signature ─▶ ② webhookEventId 去重 ─▶ ③ 立刻回 200</span><br><span class="line">            ─▶ ④ 解析 source 取兩種 key ─▶ ⑤ 白名單 ─▶ ⑥ lock（佔用即拒絕）</span><br><span class="line">            ─▶ ⑦ 用量原子預留 ─▶ ⑧ spawn claude subprocess</span><br><span class="line">            ─▶ ⑨ 每 55 秒續 loading animation ─▶ ⑩ 存 session、依 reply/push 規則送出</span><br></pre></td></tr></table></figure><p>幾個關卡值得展開：</p><p><strong>① 驗簽</strong>：對 request body 算 HMAC-SHA256（key 是 channel secret）、base64 後與 <code>X-Line-Signature</code> header 比對，不過直接 400。這是 webhook 模式的必要防線——你的 endpoint 在公網上，任何人都打得到。順帶一提，這也讓離線測試變得容易：自己簽一個假 webhook POST 到 <code>127.0.0.1:8000/callback</code>，不需要真的 LINE 就能走完整條管線。</p><p><strong>③ 立刻回 200</strong>：LINE 要求 webhook 快速回應，逾時會重送（所以才需要②的去重）。實際處理丟進背景 asyncio task，webhook handler 永遠秒回。</p><p><strong>⑥ lock 佔用即拒絕</strong>：這是跟 remotetools 的一個刻意分歧。Telegram&#x2F;Discord 版的 per-對話鎖是<strong>排隊</strong>——前一個請求跑完，下一個接著跑。LINE 版改成<strong>直接拒絕</strong>：排隊期間 reply token 早就過期了，等排到的時候只能用付費 push 回覆，等於白花額度。與其默默扣錢，不如立刻告訴你「上一個還在跑」。</p><h2 id="三組-key，各管各的"><a href="#三組-key，各管各的" class="headerlink" title="三組 key，各管各的"></a>三組 key，各管各的</h2><p>跟 Discord 版同一套拆法：</p><table><thead><tr><th>用途</th><th>key</th><th>理由</th></tr></thead><tbody><tr><td>白名單</td><td><code>source.userId</code></td><td>「誰能驅動 bot」跟在哪聊無關</td></tr><tr><td>用量上限</td><td><code>source.userId</code></td><td>配額保護是 per-person</td></tr><tr><td>session &#x2F; lock &#x2F; <code>/more</code> 暫存</td><td><code>session_key</code></td><td>每段對話獨立脈絡</td></tr></tbody></table><p><code>session_key</code> 的規則：1:1 用 <code>userId</code>、群組用 <code>groupId</code>、聊天室用 <code>roomId</code>。所以你私聊問到一半的東西，不會污染群組裡的對話。</p><p>群組還有一個順序陷阱：訊息要<strong>先</strong>判斷「是不是對 bot 說的」（有沒有 <code>claude</code> 前綴），<strong>再</strong>做白名單檢查。順序反過來的話，群組成員正常聊天會被 bot 回「未授權」洗版——這是真實發生過才寫進文件的教訓。</p><h2 id="回覆經濟學：ReplyContext"><a href="#回覆經濟學：ReplyContext" class="headerlink" title="回覆經濟學：ReplyContext"></a>回覆經濟學：ReplyContext</h2><p>LINE 的計費模型：reply 免費但 token 一次性、約一分鐘失效；push 扣官方帳號的免費月額度。Claude 跑任務動輒數分鐘，所以「怎麼送回覆」需要一套決策邏輯，集中在 <code>line_client.py</code> 的 <code>ReplyContext</code>：</p><ul><li>50 秒內（<code>REPLY_TOKEN_BUDGET_SECONDS</code>）跑完 → 用 <strong>reply</strong>，免費</li><li>超過 → 用 <strong>push</strong>，扣一則額度，log 會誠實印出 <code>used a PUSH message</code></li><li>reply 失敗（token 被判定失效）→ 自動 fallback 到 push，代價跟直接 push 一樣，所以 budget 可以放心用滿</li><li>執行中的「還在跑」提示用 <strong>loading animation</strong>——獨立 API，不算訊息、不扣額度（但只有 1:1 有效，群組裡 Claude 跑的時候畫面完全沒動靜）</li><li>回覆太長只送前 5 則（<code>MAX_MESSAGE_CHUNKS</code>），其餘存在記憶體等 <code>/more</code>——<code>/more</code> 是新訊息、帶新 reply token，<strong>免費</strong></li></ul><p>整套邏輯的優先序就一句話：<strong>能免費就免費</strong>。專案的 CLAUDE.md 特別警告未來的維護者（包括 AI）：不要為了程式簡潔一律改用 push。</p><h2 id="兩段式附件：平台限制逼出來的好設計"><a href="#兩段式附件：平台限制逼出來的好設計" class="headerlink" title="兩段式附件：平台限制逼出來的好設計"></a>兩段式附件：平台限制逼出來的好設計</h2><p>LINE 的圖片訊息<strong>沒有 caption</strong>——圖片和說明文字是兩則獨立的 webhook 事件。收到圖就立刻跑 Claude 沒有意義（還不知道你要問什麼），所以：</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">你：[一張截圖]            ← bot 安靜收下，存進 state/uploads/，排進 pending</span><br><span class="line">你：這個錯誤是什麼意思？   ← 文字 prompt 帶著圖的絕對路徑一起送進 Claude</span><br></pre></td></tr></table></figure><p>可以連傳五張再一次提問，全部一起送。這裡藏著一個併發細節：每個 webhook 事件各自一個 task，五張圖是<strong>併發下載</strong>的；如果你手速快、圖剛送出就送問題，文字可能比下載先完成——所以 prompt 在取用附件前會等在途下載歸零（上限 8 秒）。拿掉這個等待，bug 只會在慢網路下出現，最難查的那種。</p><h3 id="追問計時器與-reply-token-的精算"><a href="#追問計時器與-reply-token-的精算" class="headerlink" title="追問計時器與 reply token 的精算"></a>追問計時器與 reply token 的精算</h3><p>收到圖時 bot <strong>刻意不回「已收到」</strong>。原因還是 token 經濟學：reply token 一次性,拿去 ack 就沒了，之後想追問只能走付費 push。做法是把 token 留在一個計時器裡：</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">傳圖 ──30s──▶ 追問「請問你想問什麼？」──30s──▶ 靜默丟棄 pending</span><br><span class="line">        │                    │</span><br><span class="line">        └── 中途傳文字就取消計時器，圖照樣一起送 ──┘</span><br></pre></td></tr></table></figure><p>追問本身就兼任 ack，而且用的是留下來的免費 token。追問後仍無下文就靜默丟棄（不發通知——那時 token 已用掉，通知會是一則付費 push）。磁碟上的檔案另由背景任務清理（預設保留 3 天，每 6 小時掃一次）。</p><h2 id="一個真實的-bug：64KB-的-readline-上限"><a href="#一個真實的-bug：64KB-的-readline-上限" class="headerlink" title="一個真實的 bug：64KB 的 readline 上限"></a>一個真實的 bug：64KB 的 readline 上限</h2><p><code>claude_runner.py</code> 本來跟 remotetools 逐字元相同，但圖片支援逼出了一個分叉：Claude 讀圖時，<code>stream-json</code> 會把整張圖的 base64 塞進<strong>同一行</strong> JSON，遠超 asyncio <code>readline</code> 預設的 64KB 上限——<code>LimitOverrunError</code> 拋出、整個請求靜默崩潰，使用者只看到「沒反應」。修法是 <code>create_subprocess_exec</code> 加上 <code>limit=64MB</code>，並把該例外收成明確的錯誤訊息。remotetools 沒有圖片支援所以永遠碰不到這個 bug——同一份程式碼，新的使用情境挖出了潛伏的地雷。</p><h2 id="其他移植差異速覽"><a href="#其他移植差異速覽" class="headerlink" title="其他移植差異速覽"></a>其他移植差異速覽</h2><table><thead><tr><th>主題</th><th>Telegram &#x2F; Discord 版</th><th>LINE 版</th></tr></thead><tbody><tr><td>傳輸</td><td>long polling</td><td>aiohttp webhook server</td></tr><tr><td>進度</td><td>每 2 秒編輯訊息</td><td>loading animation（每 55 秒續一次）</td></tr><tr><td>併發</td><td>排隊等 lock</td><td>佔用即拒絕</td></tr><tr><td>長訊息</td><td>全部切片送出</td><td>送 5 則，其餘等 <code>/more</code></td></tr><tr><td>防睡眠</td><td>Win32 API</td><td>macOS <code>caffeinate</code>（保留 Windows 分支）</td></tr></tbody></table><p>下一篇實戰：<a href="/2026/08/09/linebot-deploy/">從 LINE Developers Console 到 Cloudflare Tunnel，把整套系統部署起來</a>。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/09/linebot-architecture/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/09/linebot-architecture/"/>
    <published>2026-08-09T02:10:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="/2026/08/09/linebot-motivation/">上一篇</a>講了 <a href="https://github.com/murmur-wu/claude_linebot">claude_linebot</a> 的動機和 LINE 的三大平台難題；這篇拆開實作：一則 LINE 訊息的完整旅程、reply&#x2F;push 的成本決策、以及「圖片沒有 caption」逼出來的兩段式附件設計。</p>]]>
    </summary>
    <title>claude_linebot 架構解析：webhook 管線、回覆經濟學與兩段式附件</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="專案筆記" scheme="https://murmurpaper.heitang.info/categories/%E5%B0%88%E6%A1%88%E7%AD%86%E8%A8%98/"/>
    <category term="Claude Code" scheme="https://murmurpaper.heitang.info/tags/Claude-Code/"/>
    <category term="LINE" scheme="https://murmurpaper.heitang.info/tags/LINE/"/>
    <category term="Python" scheme="https://murmurpaper.heitang.info/tags/Python/"/>
    <category term="專案介紹" scheme="https://murmurpaper.heitang.info/tags/%E5%B0%88%E6%A1%88%E4%BB%8B%E7%B4%B9/"/>
    <content>
      <![CDATA[<p>做完 <a href="/2026/08/08/remotetools-intro/">remotetools</a>（Telegram &#x2F; Discord 版的手機遠端 Claude Code）之後，下一個自然的問題是：能不能接進 <strong>LINE</strong>？<a href="https://github.com/murmur-wu/claude_linebot">claude_linebot</a> 就是答案。這篇講為什麼要做、以及 LINE 這個平台帶來的三個根本性難題如何反過來塑造了整個設計。</p><p>系列文：本篇（動機）→ <a href="/2026/08/09/linebot-architecture/">架構與實作</a> → <a href="/2026/08/09/linebot-deploy/">部署與 Cloudflare 設定</a>。</p><span id="more"></span><h2 id="動機：訊息軟體裝在哪，工具就該在哪"><a href="#動機：訊息軟體裝在哪，工具就該在哪" class="headerlink" title="動機：訊息軟體裝在哪，工具就該在哪"></a>動機：訊息軟體裝在哪，工具就該在哪</h2><p>remotetools 已經證明「傳訊息 → 家裡電腦的 Claude Code 動起來」這個模式可行。但 Telegram 和 Discord 在台灣終究是「為了某個目的才裝」的 app——真正天天開著、通知永遠會看到的是 LINE。工具的價值跟「拿出來用的摩擦力」成反比：如果指揮家裡電腦這件事能發生在最常用的聊天軟體裡，它才會真的被用。</p><p>另一個動機是技術上的：LINE 的平台限制跟 Telegram&#x2F;Discord 完全不同，移植過程等於強迫重新檢視原本設計裡哪些是本質、哪些只是平台恰好允許。</p><h2 id="LINE-帶來的三個根本難題"><a href="#LINE-帶來的三個根本難題" class="headerlink" title="LINE 帶來的三個根本難題"></a>LINE 帶來的三個根本難題</h2><table><thead><tr><th></th><th>Telegram &#x2F; Discord</th><th>LINE</th></tr></thead><tbody><tr><td>連線方式</td><td>long polling，躲在 NAT 後面就能跑</td><td><strong>webhook</strong>——必須有公開 HTTPS URL</td></tr><tr><td>進度顯示</td><td>每 2 秒編輯同一則訊息做 streaming</td><td>訊息<strong>發出後不能編輯</strong></td></tr><tr><td>回覆成本</td><td>免費、無上限</td><td>reply 免費但一次性，<strong>push 吃官方帳號月額度</strong></td></tr></tbody></table><h3 id="難題一：webhook-表示你的電腦要露出在網路上"><a href="#難題一：webhook-表示你的電腦要露出在網路上" class="headerlink" title="難題一：webhook 表示你的電腦要露出在網路上"></a>難題一：webhook 表示你的電腦要露出在網路上</h3><p>Polling 是 bot 主動去問「有沒有新訊息」，家裡電腦躲在 NAT 後面完全沒問題。LINE 只支援 webhook——LINE 的伺服器要能主動打到你，代表你需要一個公開的 HTTPS endpoint。這個專案用 <strong>Cloudflare Tunnel</strong> 解決：不開防火牆 port、不需要固定 IP，一條 outbound 連線把本機的 8000 port 安全地掛到公網上。這也是為什麼第三篇部署教學有一半篇幅在講 Cloudflare。</p><h3 id="難題二：不能編輯訊息，streaming-UI-整個作廢"><a href="#難題二：不能編輯訊息，streaming-UI-整個作廢" class="headerlink" title="難題二：不能編輯訊息，streaming UI 整個作廢"></a>難題二：不能編輯訊息，streaming UI 整個作廢</h3><p>Telegram&#x2F;Discord 版最舒服的體驗是看著 bot 每兩秒更新「Claude 正在做什麼」。LINE 的訊息送出就是送出，不能改。替代方案是 LINE 的 <strong>loading animation</strong>（打字中動畫）——資訊量少得多，但至少讓你知道它還活著。這是平台限制下的誠實妥協。</p><h3 id="難題三：回覆是要錢的"><a href="#難題三：回覆是要錢的" class="headerlink" title="難題三：回覆是要錢的"></a>難題三：回覆是要錢的</h3><p>LINE 官方帳號免費方案每月只有固定則數的 push 訊息額度，但 reply（用一次性的 reply token 回覆）免費無上限。麻煩在於 reply token 約一分鐘就失效，而 Claude 跑一個任務動輒好幾分鐘。於是「怎麼回覆」變成一個<strong>經濟學問題</strong>：50 秒內跑完就用免費 reply，超過就認命扣一則 push；長回覆只送前五段，剩下的等你用 <code>/more</code> 拿（<code>/more</code> 是新訊息、帶新 token，所以免費）。這套決策邏輯（<code>ReplyContext</code>）是整個專案最精巧也最容易改壞的部分，下一篇細講。</p><h2 id="設計原則：跟參考專案刻意保持一致"><a href="#設計原則：跟參考專案刻意保持一致" class="headerlink" title="設計原則：跟參考專案刻意保持一致"></a>設計原則：跟參考專案刻意保持一致</h2><p>claude_linebot 不是從零開始，而是 remotetools 的<strong>有紀律的移植</strong>：指令名稱、state 檔格式、session&#x2F;usage 語意刻意跟 Telegram&#x2F;Discord 版完全一致。好處很實際——</p><ul><li>兩邊可以互相對照，bugfix 可以同步（<code>claude_runner.py</code> 兩邊幾乎逐字元相同）</li><li>使用者換平台不用重新學指令</li><li>「哪些不同」變得非常清楚：所有差異都是 LINE 平台特性逼出來的，不是隨手重新發明</li></ul><h2 id="它能做什麼"><a href="#它能做什麼" class="headerlink" title="它能做什麼"></a>它能做什麼</h2><ul><li>傳訊息就是 prompt，<code>--resume</code> 維持連續對話；1:1、每個群組、每個聊天室各自獨立的 session</li><li><strong>傳圖片和檔案</strong>：先貼圖再提問，Claude 會連圖一起看（這是 Telegram&#x2F;Discord 版沒有的功能，因為 LINE 圖片沒有 caption，反而逼出了更通用的兩段式設計）</li><li><code>/project</code> 切換要操作的 repo、<code>/model</code> 切換模型、<code>/cancel</code> 中止、<code>/retry</code> 重跑</li><li>群組模式：訊息以 <code>claude</code> 開頭才觸發，其他人正常聊天完全不受打擾</li><li>fail-closed 白名單、每日&#x2F;每分鐘用量上限、webhook 簽章驗證——安全模型與 remotetools 相同</li></ul><p>下一篇進入內部：<a href="/2026/08/09/linebot-architecture/">一則 LINE 訊息如何走完驗簽、鎖、subprocess 到回覆路由的完整旅程</a>。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/09/linebot-motivation/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/09/linebot-motivation/"/>
    <published>2026-08-09T02:00:00.000Z</published>
    <summary>
      <![CDATA[<p>做完 <a href="/2026/08/08/remotetools-intro/">remotetools</a>（Telegram &#x2F; Discord 版的手機遠端 Claude Code）之後，下一個自然的問題是：能不能接進 <strong>LINE</strong>？<a href="https://github.com/murmur-wu/claude_linebot">claude_linebot</a> 就是答案。這篇講為什麼要做、以及 LINE 這個平台帶來的三個根本性難題如何反過來塑造了整個設計。</p>
<p>系列文：本篇（動機）→ <a href="/2026/08/09/linebot-architecture/">架構與實作</a> → <a href="/2026/08/09/linebot-deploy/">部署與 Cloudflare 設定</a>。</p>]]>
    </summary>
    <title>為什麼做 claude_linebot：把 Claude Code 接進 LINE 的動機與取捨</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="專案筆記" scheme="https://murmurpaper.heitang.info/categories/%E5%B0%88%E6%A1%88%E7%AD%86%E8%A8%98/"/>
    <category term="教學" scheme="https://murmurpaper.heitang.info/tags/%E6%95%99%E5%AD%B8/"/>
    <category term="Claude Code" scheme="https://murmurpaper.heitang.info/tags/Claude-Code/"/>
    <category term="Telegram" scheme="https://murmurpaper.heitang.info/tags/Telegram/"/>
    <category term="Discord" scheme="https://murmurpaper.heitang.info/tags/Discord/"/>
    <category term="Windows" scheme="https://murmurpaper.heitang.info/tags/Windows/"/>
    <category term="NSSM" scheme="https://murmurpaper.heitang.info/tags/NSSM/"/>
    <content>
      <![CDATA[<p>系列最後一篇，實戰部署 <a href="https://github.com/murmur-wu/claude_RemoteForWindows">remotetools</a>：申請 bot、填設定、跑起來、鎖白名單，最後用 NSSM 包成 Windows 服務讓它開機自動執行。（前情提要：<a href="/2026/08/08/remotetools-intro/">專案介紹</a>、<a href="/2026/08/08/remotetools-internals/">底層邏輯</a>）</p><span id="more"></span><h2 id="前置需求"><a href="#前置需求" class="headerlink" title="前置需求"></a>前置需求</h2><ul><li>Windows（其他平台也能跑，但防睡眠的 <code>keep_awake.py</code> 只在 Windows 生效）</li><li>Python 3.10+</li><li>已安裝、PATH 找得到的 <a href="https://docs.claude.com/en/docs/claude-code/setup"><code>claude</code> CLI</a>，且登入完成</li><li>Telegram bot token <strong>或</strong> Discord bot token（下面說怎麼拿）</li></ul><p>先把 repo 抓下來：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git clone https://github.com/murmur<span class="literal">-wu</span>/claude_RemoteForWindows.git C:\claude\remotetools</span><br></pre></td></tr></table></figure><p>也可以從 GitHub Releases 下載打包好的 zip——每次 release 會附 <code>-telegram</code>、<code>-discord</code>、<code>-services</code> 三種部署包，不想裝 git 的話直接解壓即可。</p><h2 id="路線一：Telegram"><a href="#路線一：Telegram" class="headerlink" title="路線一：Telegram"></a>路線一：Telegram</h2><h3 id="1-申請-bot-token"><a href="#1-申請-bot-token" class="headerlink" title="1. 申請 bot token"></a>1. 申請 bot token</h3><p>在 Telegram 找 <a href="https://t.me/BotFather">@BotFather</a>，送出 <code>/newbot</code>，照指示取名，拿到一串 token。</p><h3 id="2-設定-env"><a href="#2-設定-env" class="headerlink" title="2. 設定 .env"></a>2. 設定 .env</h3><figure class="highlight powershell"><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="built_in">cd</span> C:\claude\remotetools\telegram</span><br><span class="line"><span class="built_in">copy</span> .env.example .env</span><br><span class="line">notepad .env</span><br></pre></td></tr></table></figure><p>至少填這幾項：</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></pre></td><td class="code"><pre><span class="line"><span class="attr">TELEGRAM_BOT_TOKEN</span>=<span class="number">123456</span>:ABC-DEF...</span><br><span class="line">ALLOWED_CHAT_IDS=          <span class="comment"># 先留空，等下用 log 拿自己的 id</span></span><br><span class="line"><span class="attr">PROJECTS</span>=blog:C:\claude\projects\murmurpaper<span class="comment">;work:C:\Web\MyProject</span></span><br><span class="line"><span class="attr">DEFAULT_PROJECT</span>=blog</span><br></pre></td></tr></table></figure><p><code>PROJECTS</code> 就是 bot 可以操作的 working directory 清單，格式是 <code>名稱:路徑</code>、分號分隔。</p><h3 id="3-啟動-鎖白名單"><a href="#3-啟動-鎖白名單" class="headerlink" title="3. 啟動 + 鎖白名單"></a>3. 啟動 + 鎖白名單</h3><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">.\start.ps1</span><br></pre></td></tr></table></figure><p>第一次跑會自動建 <code>.venv</code> 並安裝套件。啟動後對 bot 傳任意訊息，console 會印：</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">rejected message from chat_id=987654321 (not whitelisted)</span><br></pre></td></tr></table></figure><p>這就是白名單 fail-closed 的設計：名單空著時拒絕所有人。把那個數字填進 <code>.env</code> 的 <code>ALLOWED_CHAT_IDS</code>，重啟，完成。</p><h2 id="路線二：Discord"><a href="#路線二：Discord" class="headerlink" title="路線二：Discord"></a>路線二：Discord</h2><p>Discord 流程類似，但多兩個必做設定，漏掉的話 bot「連得上但完全沒反應」：</p><ol><li><a href="https://discord.com/developers/applications">Developer Portal</a> 建立 Application → Bot → 拿 token</li><li><strong>Bot → Privileged Gateway Intents → 打開 MESSAGE CONTENT INTENT</strong>——不開的話 <code>message.content</code> 永遠是空字串，bot 看不到任何訊息內容</li><li>OAuth2 → URL Generator：Scopes 勾 <code>bot</code>，Permissions 勾 <code>Send Messages</code> + <code>Read Message History</code>，用產生的 URL 把 bot 邀進你的伺服器（或直接 DM 它）</li></ol><figure class="highlight powershell"><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="built_in">cd</span> C:\claude\remotetools\discord</span><br><span class="line"><span class="built_in">copy</span> .env.example .env</span><br><span class="line"><span class="comment"># 填 DISCORD_BOT_TOKEN、PROJECTS、DEFAULT_PROJECT</span></span><br><span class="line">.\start.ps1</span><br></pre></td></tr></table></figure><p>白名單用的是 user id（<code>ALLOWED_USER_IDS</code>）：Discord 設定 → Advanced → 開 Developer Mode → 對自己頭像右鍵 Copy User ID；或跟 Telegram 一樣先留空、看 rejection log。</p><h2 id="安全檢查清單"><a href="#安全檢查清單" class="headerlink" title="安全檢查清單"></a>安全檢查清單</h2><p>上線前過一遍：</p><ul><li><input disabled="" type="checkbox"> <code>ALLOWED_CHAT_IDS</code> &#x2F; <code>ALLOWED_USER_IDS</code> 已填，只有自己的 id</li><li><input disabled="" type="checkbox"> <code>PERMISSION_MODE</code> 理解過取捨：<code>bypassPermissions</code>（推薦，全放行、信任押在白名單）或 <code>auto</code>（受 <code>~/.claude/settings.json</code> 的 allow&#x2F;deny 表管制）。<code>default</code> 和 <code>acceptEdits</code> 會讓 bot 卡死等一個沒人能回答的權限詢問</li><li><input disabled="" type="checkbox"> <code>DAILY_MESSAGE_LIMIT</code>（預設 100）和 <code>RATE_LIMIT_PER_MINUTE</code>（預設 6）沒有關掉——它們保護的是你自己的 Claude Max 配額</li><li><input disabled="" type="checkbox"> <code>.env</code>（含 token）和 <code>state/sessions.json</code> 不會進版控（repo 已 gitignore，備份時記得加密）</li></ul><h2 id="服務化：開機自動跑"><a href="#服務化：開機自動跑" class="headerlink" title="服務化：開機自動跑"></a>服務化：開機自動跑</h2><p><code>start.ps1</code> 開著 PowerShell 視窗跑當然可以，但關掉視窗 bot 就死了。<code>services/</code> 資料夾提供用 <a href="https://nssm.cc/">NSSM</a> 包成 Windows 服務的腳本。</p><h3 id="安裝步驟"><a href="#安裝步驟" class="headerlink" title="安裝步驟"></a>安裝步驟</h3><ol><li>從 <a href="https://nssm.cc/download">nssm.cc&#x2F;download</a> 下載 win64 zip，把 <code>nssm.exe</code> 丟進 <code>services\</code></li><li>確認兩個 bot 都各自跑過一次 <code>start.ps1</code>（<code>.venv</code> 和 <code>.env</code> 要先就位）</li><li><strong>以系統管理員身分</strong>開 PowerShell：</li></ol><figure class="highlight powershell"><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="built_in">cd</span> C:\claude\remotetools\services</span><br><span class="line">.\<span class="built_in">install-service</span>.ps1                 <span class="comment"># 兩個都裝</span></span><br><span class="line">.\<span class="built_in">install-service</span>.ps1 <span class="literal">-Bot</span> telegram   <span class="comment"># 或只裝其一</span></span><br></pre></td></tr></table></figure><p>安裝時會跳出 <code>Get-Credential</code> 視窗——<strong>必須輸入你登入 Windows 的帳號密碼</strong>，不能用預設的 LocalSystem。原因：<code>claude</code> CLI 的認證存在 <code>%USERPROFILE%\.claude\</code>，SYSTEM 帳號的 profile 裡根本沒有這份認證，服務起得來但 claude 一律失敗。</p><h3 id="裝完之後"><a href="#裝完之後" class="headerlink" title="裝完之後"></a>裝完之後</h3><p>註冊出兩個服務：<code>ClaudeRemoteTelegram</code>、<code>ClaudeRemoteDiscord</code>，特性：</p><ul><li>開機自動啟動</li><li>crash 後 5 秒自動重啟</li><li>stdout&#x2F;stderr 寫到 <code>services\logs\&lt;bot&gt;.{out,err}.log</code>，10MB 自動 rotate</li></ul><p>日常操作：</p><figure class="highlight powershell"><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="built_in">Get-Service</span> ClaudeRemote*                                  <span class="comment"># 看狀態</span></span><br><span class="line"><span class="built_in">Restart-Service</span> ClaudeRemoteTelegram                       <span class="comment"># 改 .env 後要重啟才生效</span></span><br><span class="line"><span class="built_in">Get-Content</span> services\logs\telegram.out.log <span class="literal">-Tail</span> <span class="number">50</span> <span class="literal">-Wait</span>  <span class="comment"># 追 log</span></span><br><span class="line">.\<span class="built_in">uninstall-service</span>.ps1                                    <span class="comment"># 移除服務</span></span><br></pre></td></tr></table></figure><h2 id="常見坑"><a href="#常見坑" class="headerlink" title="常見坑"></a>常見坑</h2><table><thead><tr><th>症狀</th><th>原因</th></tr></thead><tbody><tr><td>Discord bot 在線但不回應</td><td>MESSAGE CONTENT INTENT 沒開，或在 guild channel 沒 @mention 它</td></tr><tr><td>每次任務都卡住不動</td><td><code>PERMISSION_MODE</code> 用了 <code>default</code>&#x2F;<code>acceptEdits</code>，claude 在等沒人能回答的權限詢問</td></tr><tr><td>服務啟動但 claude 一直失敗</td><td>服務裝成 LocalSystem 了，重裝並輸入自己的 Windows 帳密</td></tr><tr><td>改了 <code>.env</code> 沒生效</td><td>服務模式要 <code>Restart-Service</code></td></tr><tr><td>bot 回「已達今日上限」</td><td><code>DAILY_MESSAGE_LIMIT</code> 觸發——這是配額保護正常運作，隔天自動重置</td></tr></tbody></table><p>到這裡整套系統就完成了：手機傳訊息 → 家裡電腦的 Claude Code 動起來 → 結果回到手機，電腦睡不著、bot 死了自己爬起來。系列三篇到此結束，回顧：<a href="/2026/08/08/remotetools-intro/">專案介紹</a>、<a href="/2026/08/08/remotetools-internals/">底層邏輯</a>。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/08/remotetools-deploy/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/08/remotetools-deploy/"/>
    <published>2026-08-08T15:59:00.000Z</published>
    <summary>
      <![CDATA[<p>系列最後一篇，實戰部署 <a href="https://github.com/murmur-wu/claude_RemoteForWindows">remotetools</a>：申請 bot、填設定、跑起來、鎖白名單，最後用 NSSM 包成 Windows 服務讓它開機自動執行。（前情提要：<a href="/2026/08/08/remotetools-intro/">專案介紹</a>、<a href="/2026/08/08/remotetools-internals/">底層邏輯</a>）</p>]]>
    </summary>
    <title>remotetools 部署教學：從零架起手機遠端 Claude Code，含 Windows 服務化</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="專案筆記" scheme="https://murmurpaper.heitang.info/categories/%E5%B0%88%E6%A1%88%E7%AD%86%E8%A8%98/"/>
    <category term="Claude Code" scheme="https://murmurpaper.heitang.info/tags/Claude-Code/"/>
    <category term="Python" scheme="https://murmurpaper.heitang.info/tags/Python/"/>
    <category term="asyncio" scheme="https://murmurpaper.heitang.info/tags/asyncio/"/>
    <category term="架構設計" scheme="https://murmurpaper.heitang.info/tags/%E6%9E%B6%E6%A7%8B%E8%A8%AD%E8%A8%88/"/>
    <content>
      <![CDATA[<p><a href="/2026/08/08/remotetools-intro/">上一篇</a>介紹了 <a href="https://github.com/murmur-wu/claude_RemoteForWindows">remotetools</a> 是什麼；這篇拆開來看它的內部：一則訊息從手機到 Claude 再回到手機，中間經過哪些關卡，以及幾個關鍵設計決策背後的理由。</p><span id="more"></span><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">訊息 ─▶ ① 白名單檢查 ─▶ ② 用量原子預留 ─▶ ③ per-對話 asyncio.Lock</span><br><span class="line">      ─▶ ④ spawn claude subprocess（stream-json）─▶ ⑤ 節流推送進度</span><br><span class="line">      ─▶ ⑥ 持久化 session_id ─▶ ⑦ 切片回傳</span><br></pre></td></tr></table></figure><p>逐關來看。</p><h3 id="①-白名單：fail-closed"><a href="#①-白名單：fail-closed" class="headerlink" title="① 白名單：fail-closed"></a>① 白名單：fail-closed</h3><p>Telegram 用 <code>chat_id</code>、Discord 用 <code>author.id</code> 比對白名單。名單<strong>留空時拒絕所有人</strong>——這是刻意的 fail-closed 設計：新手第一次跑起來，bot 會把陌生 id 印在 console log（<code>rejected message from chat_id=XXXXX</code>），你把自己的 id 填進 <code>.env</code> 重啟才開始服務。永遠不會有「忘了設白名單所以全網開放」的窗口。</p><h3 id="②-用量檢查：check-and-reserve"><a href="#②-用量檢查：check-and-reserve" class="headerlink" title="② 用量檢查：check-and-reserve"></a>② 用量檢查：check-and-reserve</h3><p><code>usage_tracker.check_and_reserve</code> <strong>原子地</strong>檢查並預留額度（RPM + 每日上限），而不是「先檢查、後扣款」兩步走——兩個請求同時通過檢查再各自扣款會超賣。這層存在的理由不是防垃圾訊息，是防<strong>自己</strong>：自動化呼叫 claude 比人手快得多，一個失控迴圈能在 30 分鐘燒光 Claude Max 的五小時配額。</p><h3 id="③-per-對話鎖：防-–resume-race"><a href="#③-per-對話鎖：防-–resume-race" class="headerlink" title="③ per-對話鎖：防 –resume race"></a>③ per-對話鎖：防 –resume race</h3><p>這是整個系統最關鍵的一道防線。每段對話持有一把獨立的 <code>asyncio.Lock</code>，同一對話的請求強制序列化。原因：session 的延續靠 <code>claude --resume &lt;session_id&gt;</code>，如果同一對話兩個請求並發執行，兩個 subprocess 會拿同一個舊 session_id 去 resume，跑完各自產生新的 session_id，後寫的把先寫的蓋掉——對話脈絡從此分岔錯亂。鎖是 per-對話而非全域的，所以不同對話仍然可以並行。</p><h3 id="④-subprocess：包在-asyncio-裡的-claude-CLI"><a href="#④-subprocess：包在-asyncio-裡的-claude-CLI" class="headerlink" title="④ subprocess：包在 asyncio 裡的 claude CLI"></a>④ subprocess：包在 asyncio 裡的 claude CLI</h3><p><code>claude_runner.py</code> 用 project 的 working directory spawn：</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">claude --print --verbose --output-format stream-json [--resume &lt;sid&gt;] [--model &lt;name&gt;] &lt;prompt&gt;</span><br></pre></td></tr></table></figure><p><code>--output-format stream-json</code> 讓 claude 逐行輸出 JSONL 事件流。runner 逐行 parse，累積兩樣東西：<code>assistant</code> 事件裡的文字，和 tool call 事件（渲染成 <code>🔧 Bash \</code>git status`<code>這樣的單行摘要）。最後的</code>result&#96; 事件帶出 session_id、成本、耗時、turn 數。</p><h3 id="⑤-streaming-UI：節流的-edit"><a href="#⑤-streaming-UI：節流的-edit" class="headerlink" title="⑤ streaming UI：節流的 edit"></a>⑤ streaming UI：節流的 edit</h3><p>跑任務可能耗時數分鐘，bot 不能讓用戶盯著已讀不回。做法是先送一則 placeholder 訊息，然後 runner 透過 <code>on_update</code> callback、以 <strong>2 秒節流</strong>推送進度快照，bot 把 callback 實作成「edit 那則 placeholder」。快照內容是最近 12 筆 tool 呼叫加上 Claude 當前累積文字的末尾 1200 字。跑完後 placeholder 被最終答覆覆蓋。</p><p>節流是必要的：Telegram 和 Discord 對訊息編輯都有 rate limit，每個事件都 edit 一次會被平台掐死。</p><h3 id="⑥⑦-持久化與切片"><a href="#⑥⑦-持久化與切片" class="headerlink" title="⑥⑦ 持久化與切片"></a>⑥⑦ 持久化與切片</h3><p><code>result</code> 事件的新 session_id 寫進 <code>state/sessions.json</code>（用 tmp file + rename 確保原子性），原始 prompt 也同步存下來給 <code>/retry</code> 用。最後把輸出按平台上限切片（Telegram 4000 字、Discord 1900 字）送出。</p><h2 id="Discord-的三組-key"><a href="#Discord-的三組-key" class="headerlink" title="Discord 的三組 key"></a>Discord 的三組 key</h2><p>Telegram 一個 <code>chat_id</code> 打天下；Discord 的世界複雜得多（DM、guild channel、thread），所以拆成三組 key，各管各的：</p><table><thead><tr><th>用途</th><th>key</th><th>理由</th></tr></thead><tbody><tr><td>白名單</td><td><code>author.id</code></td><td>「誰能驅動 bot」跟在哪個頻道無關</td></tr><tr><td>用量上限</td><td><code>author.id</code></td><td>配額保護是 per-person</td></tr><tr><td>session &#x2F; lock &#x2F; cancel</td><td><code>session_key</code></td><td>每個 thread 是獨立對話</td></tr></tbody></table><p><code>session_key</code> 的決定規則：DM 用 <code>author.id</code>，thread 用 <code>channel.id</code>，一般 guild channel 則沒有 session——bot 會先用 <code>message.create_thread()</code> 開一條 thread，讓每個話題天然隔離。觸發規則也不同：DM 永遠回應，bot 自己開的 thread 永遠回應，其他地方要 @mention 它才理你。</p><h2 id="權限模式：為什麼只有兩種能用"><a href="#權限模式：為什麼只有兩種能用" class="headerlink" title="權限模式：為什麼只有兩種能用"></a>權限模式：為什麼只有兩種能用</h2><p>Claude Code 平常遇到敏感操作會停下來問「可以執行嗎？」——但 bot 沒有把 stdin 接給 claude，手機端也沒人能按 Always Allow。所以：</p><table><thead><tr><th>模式</th><th>結果</th></tr></thead><tbody><tr><td><code>default</code> &#x2F; <code>acceptEdits</code></td><td>卡死等一個永遠不會來的回答</td></tr><tr><td><code>auto</code></td><td>依 <code>~/.claude/settings.json</code> 的 allow&#x2F;deny 表決定，沒授權的操作直接失敗。適合 allow 表已備齊的情境</td></tr><tr><td><code>bypassPermissions</code></td><td>全放行，連 deny 規則都跳過。手機遠端的推薦模式</td></tr></tbody></table><p>選 <code>bypassPermissions</code> 的代價很明確：失去 settings.json 這層 safety net，信任邊界完全推到白名單上。這是「個人裝置 + 白名單鎖死」場景下的合理取捨，但必須是<strong>知情的</strong>取捨。</p><h2 id="兩份-byte-identical-的程式碼，為什麼不抽共用？"><a href="#兩份-byte-identical-的程式碼，為什麼不抽共用？" class="headerlink" title="兩份 byte-identical 的程式碼，為什麼不抽共用？"></a>兩份 byte-identical 的程式碼，為什麼不抽共用？</h2><p><code>claude_runner.py</code>、<code>session_store.py</code>、<code>usage_tracker.py</code>、<code>keep_awake.py</code> 這四個模組在 <code>telegram/</code> 和 <code>discord/</code> 是完全相同的複製，卻刻意不抽成 <code>core/</code> package。README 給的理由：</p><ul><li>抽出來會讓部署從「進資料夾跑 <code>start.ps1</code>」變成「裝兩層東西」</li><li>第三個平台出現之前，你只能<strong>猜</strong>共用介面該長怎樣——過早抽象猜錯的成本，比「修 bug 兩邊各改一次」高</li><li>等真的有第三個平台，才知道介面的正確形狀</li></ul><p>這違反教科書的 DRY 直覺，但對一個兩平台、千行等級的專案是務實的選擇。專案的 CLAUDE.md 甚至明文警告未來的 AI agent：「修共用模組時兩邊都要改，否則會 drift」。</p><h2 id="一些小而美的細節"><a href="#一些小而美的細節" class="headerlink" title="一些小而美的細節"></a>一些小而美的細節</h2><ul><li><strong><code>keep_awake.py</code></strong>：呼叫 Win32 <code>SetThreadExecutionState</code>，bot 在跑時 Windows 不會睡著（螢幕還是會關）。效果是 per-process 的——bot 死了就自動退回正常睡眠行為，不會留下副作用。</li><li><strong><code>/cancel</code> 的語意</strong>：直接 <code>kill()</code> 正在跑的 subprocess，並把該對話標進 cancelled 集合，結果送回來也直接丟棄。</li><li><strong><code>/reset</code> 不清模型偏好</strong>：reset 只把 session_id 清成 None，<code>last_prompt</code> 和 <code>model</code> 保留——「重置對話」不等於「忘記我喜歡用哪個模型」。</li><li><strong>切模型不重置 session</strong>：<code>claude --resume</code> 跨模型沿用對話脈絡，所以 <code>/model sonnet</code> 之後對話還是接得上。</li><li><strong>state 的向前相容</strong>：<code>sessions.json</code> 載入時用欄位白名單過濾，舊資料缺欄位用 dataclass 預設值補，未知欄位直接丟掉——升級不會被舊 state 檔絆倒。</li></ul><p>下一篇是實戰：<a href="/2026/08/08/remotetools-deploy/">從零把這套系統部署起來</a>，包含把 bot 包成 Windows 服務開機自動跑。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/08/remotetools-internals/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/08/remotetools-internals/"/>
    <published>2026-08-08T15:50:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="/2026/08/08/remotetools-intro/">上一篇</a>介紹了 <a href="https://github.com/murmur-wu/claude_RemoteForWindows">remotetools</a> 是什麼；這篇拆開來看它的內部：一則訊息從手機到 Claude 再回到手機，中間經過哪些關卡，以及幾個關鍵設計決策背後的理由。</p>]]>
    </summary>
    <title>remotetools 底層邏輯：一則手機訊息如何變成 Claude Code 的一次執行</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="專案筆記" scheme="https://murmurpaper.heitang.info/categories/%E5%B0%88%E6%A1%88%E7%AD%86%E8%A8%98/"/>
    <category term="Claude Code" scheme="https://murmurpaper.heitang.info/tags/Claude-Code/"/>
    <category term="Python" scheme="https://murmurpaper.heitang.info/tags/Python/"/>
    <category term="專案介紹" scheme="https://murmurpaper.heitang.info/tags/%E5%B0%88%E6%A1%88%E4%BB%8B%E7%B4%B9/"/>
    <category term="Telegram" scheme="https://murmurpaper.heitang.info/tags/Telegram/"/>
    <category term="Discord" scheme="https://murmurpaper.heitang.info/tags/Discord/"/>
    <content>
      <![CDATA[<p>人在外面，突然想到「啊，那個專案的 README 要加一段」——掏出手機，傳個訊息給 bot，家裡電腦上的 Claude Code 就自動改好、commit 完。這就是 <a href="https://github.com/murmur-wu/claude_RemoteForWindows">remotetools</a> 在做的事。</p><p>這是系列文的第一篇，先講這個專案是什麼、能做什麼；之後兩篇分別深入<a href="/2026/08/08/remotetools-internals/">底層邏輯</a>和<a href="/2026/08/08/remotetools-deploy/">部署教學</a>。</p><span id="more"></span><h2 id="它解決什麼問題"><a href="#它解決什麼問題" class="headerlink" title="它解決什麼問題"></a>它解決什麼問題</h2><p><a href="https://docs.claude.com/en/docs/claude-code">Claude Code</a> 是跑在終端機裡的 AI coding agent，能讀寫檔案、跑指令、操作 git。但它只活在你的電腦上——人不在電腦前就用不了。</p><p>remotetools 把這個能力延伸到手機上：跑一個常駐的 Telegram 或 Discord bot，把你傳的訊息丟進本機的 <code>claude</code> CLI，再把回應切片傳回手機。</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">你 (📱)  ──訊息──▶  Telegram / Discord  ──polling──▶  bot.py  ──subprocess──▶  claude --print</span><br><span class="line">                                                         ▲                          │</span><br><span class="line">                                                         └──── stdout JSON ─────────┘</span><br><span class="line">                                                         │</span><br><span class="line">                                                         ▼</span><br><span class="line">                                                   家裡的 repo（自動 edit / git / build）</span><br></pre></td></tr></table></figure><p>重點特性：</p><ul><li><strong>對話有記憶</strong>：每段對話對應一個持續存活的 Claude session，用 <code>--resume</code> 接續脈絡，不是每句話都從零開始。</li><li><strong>多專案切換</strong>：<code>.env</code> 裡定義多個 project（就是 working directory），用指令隨時切換 bot 要操作哪個 repo。</li><li><strong>即時進度</strong>：Claude 跑任務的過程中，bot 會每兩秒更新一則訊息，顯示它正在呼叫哪些工具（讀檔、跑指令…），不是傻等到跑完。</li><li><strong>配額保護</strong>：內建每分鐘與每日訊息上限，防止失控迴圈半小時燒光 Claude Max 的五小時配額。</li></ul><h2 id="支援平台"><a href="#支援平台" class="headerlink" title="支援平台"></a>支援平台</h2><table><thead><tr><th>平台</th><th>SDK</th><th>訊息上限</th><th>指令前綴</th></tr></thead><tbody><tr><td>Telegram</td><td><code>python-telegram-bot</code> 21.9</td><td>4000 字</td><td><code>/</code></td></tr><tr><td>Discord</td><td><code>discord.py</code> 2.4.0</td><td>1900 字</td><td><code>!</code></td></tr></tbody></table><p>兩個平台是<strong>各自獨立的 Python app</strong>，各有自己的 <code>.venv</code> 和 <code>.env</code>，可以只跑一個或同時跑。有趣的設計決策是：兩邊有四個 byte-identical 的共用模組，但刻意<strong>不</strong>抽成共用 package——這個取捨在下一篇會細講。</p><h2 id="指令一覽"><a href="#指令一覽" class="headerlink" title="指令一覽"></a>指令一覽</h2><table><thead><tr><th>Telegram</th><th>Discord</th><th>說明</th></tr></thead><tbody><tr><td><code>/status</code></td><td><code>!status</code></td><td>session id、當前專案、今日用量、成本</td></tr><tr><td><code>/projects</code></td><td><code>!projects</code></td><td>列出所有定義的專案</td></tr><tr><td><code>/project &lt;name&gt;</code></td><td><code>!project &lt;name&gt;</code></td><td>切換 working directory（清空 session）</td></tr><tr><td><code>/cancel</code></td><td><code>!cancel</code></td><td>殺掉正在執行的 claude 請求</td></tr><tr><td><code>/reset</code></td><td><code>!reset</code></td><td>下一則訊息開新對話</td></tr><tr><td><code>/retry</code></td><td><code>!retry</code></td><td>同 prompt 開全新 session 重跑</td></tr><tr><td><code>/model [name]</code></td><td><code>!model</code></td><td>查看或切換模型（opus &#x2F; sonnet &#x2F; haiku）</td></tr></tbody></table><p>不帶前綴的純訊息就是直接送給 Claude 的 prompt。</p><h2 id="安全模型：一句話總結"><a href="#安全模型：一句話總結" class="headerlink" title="安全模型：一句話總結"></a>安全模型：一句話總結</h2><p>這個 bot 等於把「在你電腦上執行任意動作的能力」開放到網路上，所以安全設計是 fail-closed 的：</p><ol><li><strong>白名單是唯一的信任邊界</strong>——<code>ALLOWED_CHAT_IDS</code> &#x2F; <code>ALLOWED_USER_IDS</code> 留空時 bot 拒絕所有人，永遠不要為了方便測試關掉它。</li><li><strong>權限模式只有兩種能用</strong>——bot 沒有 stdin，Claude 問「可以執行嗎？」沒人能回答，所以互動式權限模式會直接卡死。推薦 <code>bypassPermissions</code>（全放行，信任完全押在白名單上）。</li><li><strong>用量上限不是防別人，是防自己</strong>——自動化呼叫比人手快得多，預設每日 100 則、每分鐘 6 則。</li></ol><h2 id="系列文章"><a href="#系列文章" class="headerlink" title="系列文章"></a>系列文章</h2><ul><li><strong>本篇</strong>：專案是什麼、能做什麼</li><li><a href="/2026/08/08/remotetools-internals/">第二篇：底層邏輯</a>——訊息生命週期、session 管理、streaming、並發防護</li><li><a href="/2026/08/08/remotetools-deploy/">第三篇：部署教學</a>——從零架起來，含 Windows 服務化</li></ul>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/08/remotetools-intro/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/08/remotetools-intro/"/>
    <published>2026-08-08T15:40:00.000Z</published>
    <summary>
      <![CDATA[<p>人在外面，突然想到「啊，那個專案的 README 要加一段」——掏出手機，傳個訊息給 bot，家裡電腦上的 Claude Code 就自動改好、commit 完。這就是 <a href="https://github.com/murmur-wu/claude_RemoteForWindows">remotetools</a> 在做的事。</p>
<p>這是系列文的第一篇，先講這個專案是什麼、能做什麼；之後兩篇分別深入<a href="/2026/08/08/remotetools-internals/">底層邏輯</a>和<a href="/2026/08/08/remotetools-deploy/">部署教學</a>。</p>]]>
    </summary>
    <title>「remotetools」：用手機遠端指揮家裡的 Claude Code</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="架站筆記" scheme="https://murmurpaper.heitang.info/categories/%E6%9E%B6%E7%AB%99%E7%AD%86%E8%A8%98/"/>
    <category term="Hexo" scheme="https://murmurpaper.heitang.info/tags/Hexo/"/>
    <category term="GitHub" scheme="https://murmurpaper.heitang.info/tags/GitHub/"/>
    <category term="Cloudflare Pages" scheme="https://murmurpaper.heitang.info/tags/Cloudflare-Pages/"/>
    <category term="教學" scheme="https://murmurpaper.heitang.info/tags/%E6%95%99%E5%AD%B8/"/>
    <content>
      <![CDATA[<p>這個部落格就是用 <a href="https://hexo.io/">Hexo</a> 產生靜態網頁、原始碼放在 GitHub、再由 Cloudflare Pages 自動建置與發布的。整套流程完全免費，而且之後發文只需要 <code>git push</code>，剩下的交給自動部署。這篇記錄從零開始的完整步驟。</p><span id="more"></span><h2 id="整體架構"><a href="#整體架構" class="headerlink" title="整體架構"></a>整體架構</h2><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">本機寫 Markdown ──git push──▶ GitHub Repo ──自動觸發──▶ Cloudflare Pages</span><br><span class="line">                                                        （執行 hexo generate，</span><br><span class="line">                                                          發布 public/ 到全球 CDN）</span><br></pre></td></tr></table></figure><ul><li><strong>Hexo</strong>：把 Markdown 文章轉成靜態 HTML 的框架。</li><li><strong>GitHub</strong>：存放部落格原始碼（文章、設定、佈景主題）。</li><li><strong>Cloudflare Pages</strong>：偵測到 GitHub 有新 commit 就自動執行建置指令，把產出的靜態檔部署到 CDN。</li></ul><h2 id="一、環境準備"><a href="#一、環境準備" class="headerlink" title="一、環境準備"></a>一、環境準備</h2><p>只需要兩樣東西：</p><ol><li><a href="https://nodejs.org/">Node.js</a>（建議 LTS 版本）</li><li><a href="https://git-scm.com/">Git</a></li></ol><p>安裝完用這兩個指令確認：</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">node -v</span><br><span class="line">git --version</span><br></pre></td></tr></table></figure><h2 id="二、建立-Hexo-網站"><a href="#二、建立-Hexo-網站" class="headerlink" title="二、建立 Hexo 網站"></a>二、建立 Hexo 網站</h2><p>不需要全域安裝 hexo-cli，用 <code>npx</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">npx hexo-cli init murmurpaper</span><br><span class="line"><span class="built_in">cd</span> murmurpaper</span><br></pre></td></tr></table></figure><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></pre></td><td class="code"><pre><span class="line">├── _config.yml       # 網站設定（站名、語言、網址…）</span><br><span class="line">├── package.json      # 相依套件，內建 build 指令</span><br><span class="line">├── scaffolds/        # 新文章的範本</span><br><span class="line">├── source/</span><br><span class="line">│   └── _posts/       # 文章都放這裡（Markdown）</span><br><span class="line">└── themes/           # 佈景主題</span><br></pre></td></tr></table></figure><p>打開 <code>_config.yml</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">title:</span> <span class="string">MurmurPaper</span></span><br><span class="line"><span class="attr">author:</span> <span class="string">EmptyWu</span></span><br><span class="line"><span class="attr">language:</span> <span class="string">zh-TW</span></span><br><span class="line"><span class="attr">timezone:</span> <span class="string">&#x27;Asia/Taipei&#x27;</span></span><br></pre></td></tr></table></figure><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">npx hexo server</span><br></pre></td></tr></table></figure><p>瀏覽器開 <code>http://localhost:4000</code> 就能看到網站。</p><h3 id="常用指令"><a href="#常用指令" class="headerlink" title="常用指令"></a>常用指令</h3><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></pre></td><td class="code"><pre><span class="line">npx hexo new <span class="string">&quot;文章標題&quot;</span>   <span class="comment"># 新增文章到 source/_posts/</span></span><br><span class="line">npx hexo server           <span class="comment"># 本地預覽</span></span><br><span class="line">npx hexo generate         <span class="comment"># 產生靜態檔到 public/</span></span><br><span class="line">npx hexo clean            <span class="comment"># 清掉快取與 public/</span></span><br></pre></td></tr></table></figure><h2 id="三、推上-GitHub"><a href="#三、推上-GitHub" class="headerlink" title="三、推上 GitHub"></a>三、推上 GitHub</h2><p>到 GitHub 建立一個新的 repository（公開或私人都可以，Cloudflare Pages 兩種都支援），然後把專案推上去：</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></pre></td><td class="code"><pre><span class="line">git init</span><br><span class="line">git add .</span><br><span class="line">git commit -m <span class="string">&quot;Initial Hexo site&quot;</span></span><br><span class="line">git remote add origin https://github.com/&lt;你的帳號&gt;/&lt;repo名稱&gt;.git</span><br><span class="line">git push -u origin main</span><br></pre></td></tr></table></figure><p>Hexo 初始化時附的 <code>.gitignore</code> 已經排除了 <code>node_modules/</code> 和 <code>public/</code>——這兩個都不需要進版控：套件由 Cloudflare 建置時自行安裝，靜態檔由 Cloudflare 建置時自行產生。</p><h3 id="鎖定-Node-版本（建議）"><a href="#鎖定-Node-版本（建議）" class="headerlink" title="鎖定 Node 版本（建議）"></a>鎖定 Node 版本（建議）</h3><p>在專案根目錄加一個 <code>.node-version</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">22</span><br></pre></td></tr></table></figure><p>Cloudflare Pages 建置時會讀這個檔案，確保雲端和本機用相近的 Node 版本，避免「本機沒問題、雲端建置失敗」的狀況。</p><h2 id="四、設定-Cloudflare-Pages-自動部署"><a href="#四、設定-Cloudflare-Pages-自動部署" class="headerlink" title="四、設定 Cloudflare Pages 自動部署"></a>四、設定 Cloudflare Pages 自動部署</h2><p>這一步只需要做一次，之後每次 push 都會自動部署。</p><ol><li><p>登入 <a href="https://dash.cloudflare.com/">Cloudflare Dashboard</a>（沒有帳號先免費註冊一個）。</p></li><li><p>左側選單進入 <strong>Workers &amp; Pages</strong>，點 <strong>Create</strong> → 切到 <strong>Pages</strong> 分頁 → <strong>Connect to Git</strong>。</p></li><li><p>授權 Cloudflare 存取你的 GitHub 帳號，選擇部落格的 repository。</p></li><li><p>建置設定填入：</p><table><thead><tr><th>欄位</th><th>值</th></tr></thead><tbody><tr><td>Framework preset</td><td><code>Hexo</code></td></tr><tr><td>Build command</td><td><code>npm run build</code></td></tr><tr><td>Build output directory</td><td><code>public</code></td></tr></tbody></table><p>選了 Hexo preset 的話這些值會自動帶入，確認一下即可。</p></li><li><p>按 <strong>Save and Deploy</strong>，等一兩分鐘建置完成，就會拿到一個 <code>https://&lt;專案名&gt;.pages.dev</code> 的網址。</p></li></ol><p>部署成功後，回到 <code>_config.yml</code> 把 <code>url</code> 改成正式網址再 push 一次，讓 sitemap 和永久連結使用正確的網域：</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">url:</span> <span class="string">https://murmurpaper.pages.dev</span></span><br></pre></td></tr></table></figure><h2 id="五、之後的發文流程"><a href="#五、之後的發文流程" class="headerlink" title="五、之後的發文流程"></a>五、之後的發文流程</h2><p>設定完成後，發一篇新文章只有三步：</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><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">npx hexo new <span class="string">&quot;我的新文章&quot;</span></span><br><span class="line"><span class="comment"># 編輯 source/_posts/我的新文章.md</span></span><br><span class="line"></span><br><span class="line">npx hexo server        <span class="comment"># （可選）本地預覽確認</span></span><br><span class="line"></span><br><span class="line">git add .</span><br><span class="line">git commit -m <span class="string">&quot;新增文章：我的新文章&quot;</span></span><br><span class="line">git push</span><br></pre></td></tr></table></figure><p>push 之後 Cloudflare Pages 會自動偵測到新 commit、跑 <code>npm run build</code>、把產出發布到 CDN，通常一兩分鐘內就上線了。可以在 Cloudflare Dashboard 的專案頁面看到每次部署的狀態和紀錄。</p><h2 id="小結"><a href="#小結" class="headerlink" title="小結"></a>小結</h2><table><thead><tr><th>項目</th><th>花費</th></tr></thead><tbody><tr><td>Hexo</td><td>免費（開源）</td></tr><tr><td>GitHub</td><td>免費</td></tr><tr><td>Cloudflare Pages</td><td>免費（每月 500 次建置額度，個人部落格綽綽有餘）</td></tr></tbody></table><p>整套流程架好之後，寫作的心智負擔就只剩下「寫 Markdown、push」。之後有空再來研究換佈景主題和綁自訂網域。</p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2026/08/08/hexo-github-cloudflare-pages/</id>
    <link href="https://murmurpaper.heitang.info/2026/08/08/hexo-github-cloudflare-pages/"/>
    <published>2026-08-08T15:30:00.000Z</published>
    <summary>
      <![CDATA[<p>這個部落格就是用 <a href="https://hexo.io/">Hexo</a> 產生靜態網頁、原始碼放在 GitHub、再由 Cloudflare Pages 自動建置與發布的。整套流程完全免費，而且之後發文只需要 <code>git push</code>，剩下的交給自動部署。這篇記錄從零開始的完整步驟。</p>]]>
    </summary>
    <title>用 Hexo + GitHub + Cloudflare Pages 免費架部落格</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
  <entry>
    <author>
      <name>EmptyWu</name>
    </author>
    <category term="學習" scheme="https://murmurpaper.heitang.info/categories/%E5%AD%B8%E7%BF%92/"/>
    <category term="Vue" scheme="https://murmurpaper.heitang.info/tags/Vue/"/>
    <category term="六角學院" scheme="https://murmurpaper.heitang.info/tags/%E5%85%AD%E8%A7%92%E5%AD%B8%E9%99%A2/"/>
    <category term="Vue直播班" scheme="https://murmurpaper.heitang.info/tags/Vue%E7%9B%B4%E6%92%AD%E7%8F%AD/"/>
    <category term="學習心得" scheme="https://murmurpaper.heitang.info/tags/%E5%AD%B8%E7%BF%92%E5%BF%83%E5%BE%97/"/>
    <content>
      <![CDATA[<p><img src="https://i.imgur.com/yH6qz6W.png"></p><h2 id="當初為什麼報名？"><a href="#當初為什麼報名？" class="headerlink" title="當初為什麼報名？"></a>當初為什麼報名？</h2><p>報名的時候，我正處在一個很多自學者都熟悉的狀態：前端框架看了不少，每一個都停留在「大概知道在幹嘛」的一知半解。教學文章讀起來都懂，自己動手就卡住——這種懂與不懂之間的模糊地帶，靠自己很難突破，因為你不知道自己缺的到底是哪一塊。</p><p>剛好看到六角學院開課，想著與其繼續東看西看，不如找個有進度壓力的環境把自己丟進去，就一股腦報名了。當時還不知道，這是「罪惡的開始」。</p><span id="more"></span><h2 id="八週的學習曲線：從輕鬆到追不上"><a href="#八週的學習曲線：從輕鬆到追不上" class="headerlink" title="八週的學習曲線：從輕鬆到追不上"></a>八週的學習曲線：從輕鬆到追不上</h2><p>前兩週還算游刃有餘，作業一兩天就能搞定，甚至有點懷疑課程強度是不是也就這樣。轉折發生在中後期：大量的影音要追、大量的加碼內容、大量的功課，一週接一週堆上來。我從「提前交作業」變成「追進度」，最後是「努力不脫隊」。</p><p>現在回頭看，這個強度設計其實是有道理的。一知半解的癥結就在於缺乏足夠密度的練習——概念在腦中是散的，只有反覆在作業裡把同一套東西用過很多遍，它們才會連成網。課程就是用不斷堆疊的實作，硬是把熟悉度壓進你的肌肉記憶裡。這真的有別於外面看影片自學的課程。</p><p>週末老師還會犧牲自己的時間，開直播帶著大家一起寫作業。這件事對心態的影響比想像中大：看著老師都投入成這樣，不交作業會有種對不起自己的感覺。</p><p><img src="https://i.imgur.com/bA4fIYo.png"></p><h2 id="最大的收穫：不是學會-Vue，是改變了寫法"><a href="#最大的收穫：不是學會-Vue，是改變了寫法" class="headerlink" title="最大的收穫：不是學會 Vue，是改變了寫法"></a>最大的收穫：不是學會 Vue，是改變了寫法</h2><p><img src="https://i.imgur.com/vTlfS23.jpg"></p><p>如果只是「會用 Vue」，那八週有點太貴。真正的收穫是它改變了我以往寫 JavaScript 的習慣：</p><ul><li><strong>樣板字面值（template literals）與解構賦值</strong>——以前寫字串拼接和一層層取值，現在回不去了</li><li><strong>理解 Vue 渲染 HTML 的方式</strong>——不再是「反正資料改了畫面就會動」的黑箱，而是知道它為什麼會動</li><li><strong>宣告式地操作畫面資料</strong>——從「用 jQuery 思維去改 DOM」轉成「改資料，讓畫面跟著資料走」</li></ul><p>第三點是最根本的轉變。框架本身會過時，但「畫面是資料的投影」這個思維方式，是之後接觸任何前端框架都通用的底層觀念。</p><h2 id="直播班的形式，比內容更值錢"><a href="#直播班的形式，比內容更值錢" class="headerlink" title="直播班的形式，比內容更值錢"></a>直播班的形式，比內容更值錢</h2><p>自學最缺的不是教材——網路上教材多的是——是<strong>回饋迴路</strong>。直播班有三個活動剛好補上這塊：</p><ol><li><strong>作業討論</strong>：看別人怎麼解同一題，才發現很多自己根本沒注意到的地方。自己寫只能驗證「能不能動」，看別人的寫法才知道「還能怎麼寫」。</li><li><strong>分組團隊活動</strong>：互相催作業、互相勉勵。進度壓力由一群人分攤，比一個人硬撐容易走得遠。</li><li><strong>每日作業</strong>：份量不大，但累積下來補齊了大量基本小知識——正是那些「太基礎所以沒人特別教，但不會就是不會」的東西。</li></ol><p><img src="https://i.imgur.com/2zC183o.png"></p><h2 id="如果時光倒流，我會注意什麼？"><a href="#如果時光倒流，我會注意什麼？" class="headerlink" title="如果時光倒流，我會注意什麼？"></a>如果時光倒流，我會注意什麼？</h2><p><strong>第一週就把最終作品的主題想好。</strong></p><p>課程的節奏是從第一週的基礎一路堆到最後一週的完整作品。如果一開始就定好主題,每週的作業都可以往同一個方向累積，八週剛好長成一個完整的東西；反之，後期一邊追新進度一邊補前面的方向債，就會陷入想追也追不上的窘境。累。😅</p><p>這其實不只適用於這門課——任何有時限的學習專案，「先想清楚終點長什麼樣」都比「走到哪算哪」省力得多。</p><h2 id="給想入坑的新同學"><a href="#給想入坑的新同學" class="headerlink" title="給想入坑的新同學"></a>給想入坑的新同學</h2><p>先說醜話：不交作業，會有助教無情催繳。每次看到催繳訊息都會感受到害怕。</p><p><img src="https://i.imgur.com/YKJmYNb.png"></p><p>所以報名前真的要想清楚自己要的是什麼。這不是報了名放著、有空再看的線上課——開始之後就是一份接一份的作業等著你，老師還會努力加碼再加碼。主線直播、錄影、課前預習、加碼內容加起來超過 50 小時，八週上完像是上了一學年的課。</p><p>但如果你跟當年的我一樣，卡在一知半解、需要外力逼自己跨過那道坎——那這個「罪惡的開始」，值得。</p><p><img src="https://i.imgur.com/1aisE81.png"></p>]]>
    </content>
    <id>https://murmurpaper.heitang.info/2022/03/07/vue-bootcamp-2022-review/</id>
    <link href="https://murmurpaper.heitang.info/2022/03/07/vue-bootcamp-2022-review/"/>
    <published>2022-03-07T04:00:00.000Z</published>
    <summary>
      <![CDATA[<p><img src="https://i.imgur.com/yH6qz6W.png"></p>
<h2 id="當初為什麼報名？"><a href="#當初為什麼報名？" class="headerlink" title="當初為什麼報名？"></a>當初為什麼報名？</h2><p>報名的時候，我正處在一個很多自學者都熟悉的狀態：前端框架看了不少，每一個都停留在「大概知道在幹嘛」的一知半解。教學文章讀起來都懂，自己動手就卡住——這種懂與不懂之間的模糊地帶，靠自己很難突破，因為你不知道自己缺的到底是哪一塊。</p>
<p>剛好看到六角學院開課，想著與其繼續東看西看，不如找個有進度壓力的環境把自己丟進去，就一股腦報名了。當時還不知道，這是「罪惡的開始」。</p>]]>
    </summary>
    <title>Vue 直播班 2022 春季班心得：八週被作業追著跑，換來一次寫法的重構</title>
    <updated>2026-08-08T18:25:45.446Z</updated>
  </entry>
</feed>
