<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Api on Tarragon</title><link>https://tarrragon.github.io/blog/tags/api/</link><description>Recent content in Api on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/api/index.xml" rel="self" type="application/rss+xml"/><item><title>HTTP contract test 設計</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/http-contract-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/http-contract-test/</guid><description>&lt;p>HTTP REST API 的協議複雜度比 WebSocket 低 — request body 是 JSON、response body 是 JSON、status code 有標準語意。但 mock HTTP client（回傳固定 JSON）和真實 API 之間仍然存在差異：error response 的格式、header 的必要性、認證 token 的有效期、rate limit 行為。&lt;/p>
&lt;h2 id="http-protocol-test-的驗證對象">HTTP protocol test 的驗證對象&lt;/h2>
&lt;h3 id="request-格式">Request 格式&lt;/h3>
&lt;p>Client 端發送的 request 是否符合 API 規格。Content-Type header、JSON body 的欄位名稱和型別、query parameter 的格式 — 這些在 mock client 中通常不被驗證（mock 接受任何 request），但真實 API 可能因為格式不符而拒絕。&lt;/p>
&lt;h3 id="response-解析">Response 解析&lt;/h3>
&lt;p>Client 端能否正確解析真實 API 的 response。Mock response 通常是開發者手寫的 JSON，可能和真實 API 的 response 有微妙差異 — 欄位名稱大小寫、數值型別（integer vs float）、null vs 缺失欄位、巢狀結構。&lt;/p>
&lt;h3 id="error-response-處理">Error response 處理&lt;/h3>
&lt;p>真實 API 的 error response 格式可能和 success response 不同。Mock client 通常只模擬 success case，偶爾模擬簡化的 error case。真實 API 的 400/401/403/404/500 各自可能有不同的 error body 結構。&lt;/p>
&lt;h3 id="認證流程">認證流程&lt;/h3>
&lt;p>API 的認證流程（API key、OAuth token、session cookie）在 mock 中通常被跳過。真實 API 的認證包括 token 取得、token 過期、refresh flow — 每一步都可能失敗。&lt;/p>
&lt;h2 id="test-結構">Test 結構&lt;/h2>
&lt;p>HTTP protocol test 的結構和 WebSocket protocol test 類似 — 對真實 API 發送真實 request、驗證真實 response。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">test(&amp;#39;POST /api/resource creates resource&amp;#39;):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> response = await httpClient.post(
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &amp;#39;http://localhost:8080/api/resource&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> body: jsonEncode({&amp;#39;name&amp;#39;: &amp;#39;test&amp;#39;, &amp;#39;type&amp;#39;: &amp;#39;A&amp;#39;}),
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> headers: {&amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;, &amp;#39;Authorization&amp;#39;: &amp;#39;Bearer ...&amp;#39;},
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> )
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> expect(response.statusCode, 201)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> body = jsonDecode(response.body)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> expect(body[&amp;#39;id&amp;#39;], isNotNull)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> expect(body[&amp;#39;name&amp;#39;], &amp;#39;test&amp;#39;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">test(&amp;#39;POST /api/resource with invalid body returns 400&amp;#39;):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> response = await httpClient.post(
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &amp;#39;http://localhost:8080/api/resource&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> body: jsonEncode({&amp;#39;invalid_field&amp;#39;: &amp;#39;value&amp;#39;}),
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> headers: {&amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;, &amp;#39;Authorization&amp;#39;: &amp;#39;Bearer ...&amp;#39;},
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> )
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> expect(response.statusCode, 400)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> body = jsonDecode(response.body)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> expect(body[&amp;#39;error&amp;#39;], isNotNull) // 驗證 error body 結構&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="consumer-driven-contract-test">Consumer-driven contract test&lt;/h2>
&lt;p>當 client 和 server 由不同團隊開發時，consumer-driven contract test 是 protocol integration test 的延伸。Client 團隊定義「我期望的 request/response 格式」（contract），server 團隊驗證 server 實作是否符合 contract。&lt;/p></description><content:encoded><![CDATA[<p>HTTP REST API 的協議複雜度比 WebSocket 低 — request body 是 JSON、response body 是 JSON、status code 有標準語意。但 mock HTTP client（回傳固定 JSON）和真實 API 之間仍然存在差異：error response 的格式、header 的必要性、認證 token 的有效期、rate limit 行為。</p>
<h2 id="http-protocol-test-的驗證對象">HTTP protocol test 的驗證對象</h2>
<h3 id="request-格式">Request 格式</h3>
<p>Client 端發送的 request 是否符合 API 規格。Content-Type header、JSON body 的欄位名稱和型別、query parameter 的格式 — 這些在 mock client 中通常不被驗證（mock 接受任何 request），但真實 API 可能因為格式不符而拒絕。</p>
<h3 id="response-解析">Response 解析</h3>
<p>Client 端能否正確解析真實 API 的 response。Mock response 通常是開發者手寫的 JSON，可能和真實 API 的 response 有微妙差異 — 欄位名稱大小寫、數值型別（integer vs float）、null vs 缺失欄位、巢狀結構。</p>
<h3 id="error-response-處理">Error response 處理</h3>
<p>真實 API 的 error response 格式可能和 success response 不同。Mock client 通常只模擬 success case，偶爾模擬簡化的 error case。真實 API 的 400/401/403/404/500 各自可能有不同的 error body 結構。</p>
<h3 id="認證流程">認證流程</h3>
<p>API 的認證流程（API key、OAuth token、session cookie）在 mock 中通常被跳過。真實 API 的認證包括 token 取得、token 過期、refresh flow — 每一步都可能失敗。</p>
<h2 id="test-結構">Test 結構</h2>
<p>HTTP protocol test 的結構和 WebSocket protocol test 類似 — 對真實 API 發送真實 request、驗證真實 response。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">test(&#39;POST /api/resource creates resource&#39;):
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  response = await httpClient.post(
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    &#39;http://localhost:8080/api/resource&#39;,
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    body: jsonEncode({&#39;name&#39;: &#39;test&#39;, &#39;type&#39;: &#39;A&#39;}),
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    headers: {&#39;Content-Type&#39;: &#39;application/json&#39;, &#39;Authorization&#39;: &#39;Bearer ...&#39;},
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  )
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  expect(response.statusCode, 201)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  body = jsonDecode(response.body)
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  expect(body[&#39;id&#39;], isNotNull)
</span></span><span class="line"><span class="ln">10</span><span class="cl">  expect(body[&#39;name&#39;], &#39;test&#39;)
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">test(&#39;POST /api/resource with invalid body returns 400&#39;):
</span></span><span class="line"><span class="ln">13</span><span class="cl">  response = await httpClient.post(
</span></span><span class="line"><span class="ln">14</span><span class="cl">    &#39;http://localhost:8080/api/resource&#39;,
</span></span><span class="line"><span class="ln">15</span><span class="cl">    body: jsonEncode({&#39;invalid_field&#39;: &#39;value&#39;}),
</span></span><span class="line"><span class="ln">16</span><span class="cl">    headers: {&#39;Content-Type&#39;: &#39;application/json&#39;, &#39;Authorization&#39;: &#39;Bearer ...&#39;},
</span></span><span class="line"><span class="ln">17</span><span class="cl">  )
</span></span><span class="line"><span class="ln">18</span><span class="cl">  expect(response.statusCode, 400)
</span></span><span class="line"><span class="ln">19</span><span class="cl">  body = jsonDecode(response.body)
</span></span><span class="line"><span class="ln">20</span><span class="cl">  expect(body[&#39;error&#39;], isNotNull)  // 驗證 error body 結構</span></span></code></pre></div><h2 id="consumer-driven-contract-test">Consumer-driven contract test</h2>
<p>當 client 和 server 由不同團隊開發時，consumer-driven contract test 是 protocol integration test 的延伸。Client 團隊定義「我期望的 request/response 格式」（contract），server 團隊驗證 server 實作是否符合 contract。</p>
<p>Consumer-driven contract test 的工具（Pact、Spring Cloud Contract）自動化了 contract 的定義、驗證和版本管理。適合 API 有多個 consumer 且需要獨立部署的場景。</p>
<p>自用工具或 client/server 同一人開發的場景不需要 contract test 工具 — 直接對真實 server 跑 protocol integration test 更簡單。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>CI 中如何管理 test 用的 server → <a href="/blog/testing/03-protocol-integration-test/service-fixture-management/" data-link-title="CI 中的服務 fixture 管理" data-link-desc="在 CI 中啟動和停止真實服務的 test harness 設計 — Process.start / Docker / testcontainers 三種方案的適用場景">CI 中的服務 fixture 管理</a></li>
<li>WebSocket 的 protocol test → <a href="/blog/testing/03-protocol-integration-test/websocket-protocol-test/" data-link-title="WebSocket 協議測試實作" data-link-desc="對真實 ttyd 驗證 frame type 和 auth handshake — 從 T.C1 和 T.C2 的教訓推導出的 protocol integration test 設計">WebSocket 協議測試實作</a></li>
<li>什麼時候用 contract test 替代 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/cost-judgment/" data-link-title="成本判斷表" data-link-desc="什麼時候值得寫 protocol integration test、什麼時候用 contract test 或實機測試替代 — 根據服務啟動成本和協議複雜度判斷">成本判斷表</a></li>
<li>Backend 的 contract testing 實務 → <a href="/blog/backend/06-reliability/contract-testing/" data-link-title="6.10 Contract Testing 與 Schema 演進" data-link-desc="把跨服務 / API / event schema 的隱性期待變成可驗證契約，控制演進相容性">Backend 可靠性 contract testing</a></li>
</ul>
]]></content:encoded></item><item><title>查詢 API 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/query-api/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/query-api/</guid><description>&lt;p>查詢是監控資料的消費介面。Collector 提供兩種查詢方式：CLI 直接操作 JSONL 檔案（grep + jq），和 HTTP 查詢 endpoint。兩種方式服務不同的消費者 — CLI 給開發者即時探索，HTTP endpoint 給自動化工具和非 CLI 使用者。&lt;/p>
&lt;h2 id="cli-查詢grep--jq">CLI 查詢：grep + jq&lt;/h2>
&lt;p>JSONL 格式的最大優勢是原生支援 Unix 文字處理工具。不需要額外的查詢語言、不需要客戶端工具、不需要連線到 database。&lt;/p>
&lt;h3 id="常見查詢模式">常見查詢模式&lt;/h3>
&lt;p>按事件類型過濾：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">grep &lt;span class="s1">&amp;#39;&amp;#34;type&amp;#34;:&amp;#34;error&amp;#34;&amp;#39;&lt;/span> events-2026-06-19.jsonl &lt;span class="p">|&lt;/span> jq .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>按 namespace 過濾：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">grep &lt;span class="s1">&amp;#39;&amp;#34;name&amp;#34;:&amp;#34;terminal.connect&amp;#39;&lt;/span> events-2026-06-19.jsonl &lt;span class="p">|&lt;/span> jq .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>按時間範圍過濾（跨檔案）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">cat events-2026-06-1&lt;span class="o">{&lt;/span>8,9&lt;span class="o">}&lt;/span>.jsonl &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;select(.ts &amp;gt;= &amp;#34;2026-06-18T18:00:00&amp;#34;)&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>統計每種事件的數量：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">jq -r &lt;span class="s1">&amp;#39;.name&amp;#39;&lt;/span> events-2026-06-19.jsonl &lt;span class="p">|&lt;/span> sort &lt;span class="p">|&lt;/span> uniq -c &lt;span class="p">|&lt;/span> sort -rn&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="grep-友好的-jsonl-設計">grep 友好的 JSONL 設計&lt;/h3>
&lt;p>JSONL 的每行 JSON 結構影響 grep 的查詢效率和準確性。&lt;/p>
&lt;p>&lt;strong>把常用過濾欄位放在 JSON 的前面&lt;/strong>。grep 是字串匹配，把 &lt;code>type&lt;/code> 和 &lt;code>name&lt;/code> 放在行首讓 grep pattern 更簡單、誤匹配更少。&lt;/p>
&lt;p>&lt;strong>避免 JSON 值中包含雙引號&lt;/strong>。事件名稱和型別用簡單字串（不含特殊字元），讓 grep 的 pattern 不需要處理 escape。&lt;/p>
&lt;p>&lt;strong>每行 JSON 不換行&lt;/strong>。JSONL 的定義就是每行一個 JSON，但格式化工具可能自動加換行。寫入時用 &lt;code>json.Marshal&lt;/code>（Go）或 &lt;code>JSON.stringify&lt;/code>（JS）確保單行輸出。&lt;/p>
&lt;h2 id="http-查詢-endpoint">HTTP 查詢 endpoint&lt;/h2>
&lt;p>HTTP 查詢 endpoint 讓非 CLI 使用者（dashboard、自動化腳本、其他服務）能查詢事件資料。&lt;/p>
&lt;h3 id="endpoint-設計">Endpoint 設計&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">GET /v1/events?type=error&amp;amp;name=terminal.connect.*&amp;amp;from=2026-06-18T00:00:00Z&amp;amp;to=2026-06-19T00:00:00Z&amp;amp;limit=100&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>查詢參數：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>參數&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;th>預設值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>type&lt;/td>
 &lt;td>事件類型（event/error/metric/lifecycle）&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>name&lt;/td>
 &lt;td>事件名稱（支援 &lt;code>*&lt;/code> 萬用字元）&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>from&lt;/td>
 &lt;td>起始時間（ISO 8601）&lt;/td>
 &lt;td>24 小時前&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>to&lt;/td>
 &lt;td>結束時間（ISO 8601）&lt;/td>
 &lt;td>現在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>limit&lt;/td>
 &lt;td>回傳筆數上限&lt;/td>
 &lt;td>100&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>offset&lt;/td>
 &lt;td>分頁偏移&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="回應格式">回應格式&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;events&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;v&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-06-19T08:42:00Z&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;sdk&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;python&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;platform&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;macos&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;app&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;claude-hooks&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;hook.failure&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;data&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;hook&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;branch-status-reminder&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;step&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;validation&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KeyError: &amp;#39;status&amp;#39;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;stack&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Traceback...&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KeyError&amp;#34;&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;context&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;session_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sess-abc-123&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;total&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;limit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;offset&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>events&lt;/code> 陣列按 &lt;code>timestamp&lt;/code> 降序排列。&lt;code>total&lt;/code> 是符合篩選條件的全量筆數（不受 limit 截斷），讓呼叫端計算分頁（&lt;code>total_pages = ceil(total / limit)&lt;/code>）。分頁用 offset-based（&lt;code>offset=100&lt;/code> 取第二頁），適合資料量在十萬筆以下的場景。資料量大到 offset 效能不足時，改用 cursor-based（&lt;code>after=&amp;lt;last_event_id&amp;gt;&lt;/code>），但 cursor-based 是 PostgreSQL 層的演進，SQLite 層用 offset 足夠。&lt;/p>
&lt;h3 id="實作策略">實作策略&lt;/h3>
&lt;p>HTTP 查詢 endpoint 的底層實作可以直接讀取 JSONL 檔案 — 根據 from/to 確定要讀哪些日期的檔案，逐行 parse 並過濾。這個實作在資料量小（單日萬筆以下）時足夠快。&lt;/p>
&lt;p>當查詢效能成為問題時，在 JSONL 之上加一層索引（按 type/name 建立反向索引），或演進到 SQLite 儲存（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>）。&lt;/p></description><content:encoded><![CDATA[<p>查詢是監控資料的消費介面。Collector 提供兩種查詢方式：CLI 直接操作 JSONL 檔案（grep + jq），和 HTTP 查詢 endpoint。兩種方式服務不同的消費者 — CLI 給開發者即時探索，HTTP endpoint 給自動化工具和非 CLI 使用者。</p>
<h2 id="cli-查詢grep--jq">CLI 查詢：grep + jq</h2>
<p>JSONL 格式的最大優勢是原生支援 Unix 文字處理工具。不需要額外的查詢語言、不需要客戶端工具、不需要連線到 database。</p>
<h3 id="常見查詢模式">常見查詢模式</h3>
<p>按事件類型過濾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">grep <span class="s1">&#39;&#34;type&#34;:&#34;error&#34;&#39;</span> events-2026-06-19.jsonl <span class="p">|</span> jq .</span></span></code></pre></div><p>按 namespace 過濾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">grep <span class="s1">&#39;&#34;name&#34;:&#34;terminal.connect&#39;</span> events-2026-06-19.jsonl <span class="p">|</span> jq .</span></span></code></pre></div><p>按時間範圍過濾（跨檔案）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat events-2026-06-1<span class="o">{</span>8,9<span class="o">}</span>.jsonl <span class="p">|</span> jq <span class="s1">&#39;select(.ts &gt;= &#34;2026-06-18T18:00:00&#34;)&#39;</span></span></span></code></pre></div><p>統計每種事件的數量：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">jq -r <span class="s1">&#39;.name&#39;</span> events-2026-06-19.jsonl <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn</span></span></code></pre></div><h3 id="grep-友好的-jsonl-設計">grep 友好的 JSONL 設計</h3>
<p>JSONL 的每行 JSON 結構影響 grep 的查詢效率和準確性。</p>
<p><strong>把常用過濾欄位放在 JSON 的前面</strong>。grep 是字串匹配，把 <code>type</code> 和 <code>name</code> 放在行首讓 grep pattern 更簡單、誤匹配更少。</p>
<p><strong>避免 JSON 值中包含雙引號</strong>。事件名稱和型別用簡單字串（不含特殊字元），讓 grep 的 pattern 不需要處理 escape。</p>
<p><strong>每行 JSON 不換行</strong>。JSONL 的定義就是每行一個 JSON，但格式化工具可能自動加換行。寫入時用 <code>json.Marshal</code>（Go）或 <code>JSON.stringify</code>（JS）確保單行輸出。</p>
<h2 id="http-查詢-endpoint">HTTP 查詢 endpoint</h2>
<p>HTTP 查詢 endpoint 讓非 CLI 使用者（dashboard、自動化腳本、其他服務）能查詢事件資料。</p>
<h3 id="endpoint-設計">Endpoint 設計</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GET /v1/events?type=error&amp;name=terminal.connect.*&amp;from=2026-06-18T00:00:00Z&amp;to=2026-06-19T00:00:00Z&amp;limit=100</span></span></code></pre></div><p>查詢參數：</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>說明</th>
          <th>預設值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>type</td>
          <td>事件類型（event/error/metric/lifecycle）</td>
          <td>全部</td>
      </tr>
      <tr>
          <td>name</td>
          <td>事件名稱（支援 <code>*</code> 萬用字元）</td>
          <td>全部</td>
      </tr>
      <tr>
          <td>from</td>
          <td>起始時間（ISO 8601）</td>
          <td>24 小時前</td>
      </tr>
      <tr>
          <td>to</td>
          <td>結束時間（ISO 8601）</td>
          <td>現在</td>
      </tr>
      <tr>
          <td>limit</td>
          <td>回傳筆數上限</td>
          <td>100</td>
      </tr>
      <tr>
          <td>offset</td>
          <td>分頁偏移</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<h3 id="回應格式">回應格式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;events&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="nt">&#34;v&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T08:42:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;python&#34;</span><span class="p">,</span> <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;macos&#34;</span><span class="p">,</span> <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;claude-hooks&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hook.failure&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="nt">&#34;level&#34;</span><span class="p">:</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;hook&#34;</span><span class="p">:</span> <span class="s2">&#34;branch-status-reminder&#34;</span><span class="p">,</span> <span class="nt">&#34;step&#34;</span><span class="p">:</span> <span class="s2">&#34;validation&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;KeyError: &#39;status&#39;&#34;</span><span class="p">,</span> <span class="nt">&#34;stack&#34;</span><span class="p">:</span> <span class="s2">&#34;Traceback...&#34;</span><span class="p">,</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;KeyError&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;session_id&#34;</span><span class="p">:</span> <span class="s2">&#34;sess-abc-123&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nt">&#34;total&#34;</span><span class="p">:</span> <span class="mi">42</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="nt">&#34;limit&#34;</span><span class="p">:</span> <span class="mi">100</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="nt">&#34;offset&#34;</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>events</code> 陣列按 <code>timestamp</code> 降序排列。<code>total</code> 是符合篩選條件的全量筆數（不受 limit 截斷），讓呼叫端計算分頁（<code>total_pages = ceil(total / limit)</code>）。分頁用 offset-based（<code>offset=100</code> 取第二頁），適合資料量在十萬筆以下的場景。資料量大到 offset 效能不足時，改用 cursor-based（<code>after=&lt;last_event_id&gt;</code>），但 cursor-based 是 PostgreSQL 層的演進，SQLite 層用 offset 足夠。</p>
<h3 id="實作策略">實作策略</h3>
<p>HTTP 查詢 endpoint 的底層實作可以直接讀取 JSONL 檔案 — 根據 from/to 確定要讀哪些日期的檔案，逐行 parse 並過濾。這個實作在資料量小（單日萬筆以下）時足夠快。</p>
<p>當查詢效能成為問題時，在 JSONL 之上加一層索引（按 type/name 建立反向索引），或演進到 SQLite 儲存（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。</p>
<h2 id="聚合查詢">聚合查詢</h2>
<p>逐筆查詢回答「發生了什麼」，聚合查詢回答「發生了多少」。Error 調查的第一步是定位最頻繁的 error — 「哪些 error 最多」需要按 name 分群計數的聚合結果，逐筆列表在這個階段資訊量太大。</p>
<h3 id="endpoint-設計-1">Endpoint 設計</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">GET /v1/events/summary?type=error&amp;from=2026-06-18T00:00:00Z&amp;to=2026-06-19T00:00:00Z&amp;group_by=name</span></span></code></pre></div><p>回傳按 name 分群的統計：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;groups&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hook.failure&#34;</span><span class="p">,</span> <span class="nt">&#34;count&#34;</span><span class="p">:</span> <span class="mi">15</span><span class="p">,</span> <span class="nt">&#34;last_seen&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T08:42:00Z&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;terminal.connect.failed&#34;</span><span class="p">,</span> <span class="nt">&#34;count&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="nt">&#34;last_seen&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T07:10:00Z&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;total&#34;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nt">&#34;from&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-18T00:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="nt">&#34;to&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-06-19T00:00:00Z&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>查詢參數和逐筆查詢共用（type、name、from、to），額外的 <code>group_by</code> 指定分群欄位（name 或 type）。</p>
<h3 id="sql-實作">SQL 實作</h3>
<p>SQLite backend 下直接用 GROUP BY：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="k">count</span><span class="p">,</span><span class="w"> </span><span class="k">MAX</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">last_seen</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;error&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="o">?</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">name</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">count</span><span class="w"> </span><span class="k">DESC</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">100</span></span></span></code></pre></div><p>有 type + timestamp 複合索引時，這個查詢在 10 萬筆資料內的效能和逐筆查詢相當 — GROUP BY 在索引掃描後做，不需要全表掃描。</p>
<h3 id="和逐筆查詢的定位差異">和逐筆查詢的定位差異</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>逐筆查詢 <code>/v1/events</code></th>
          <th>聚合查詢 <code>/v1/events/summary</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>回答</td>
          <td>發生了什麼（事件列表）</td>
          <td>發生了多少（統計摘要）</td>
      </tr>
      <tr>
          <td>用途</td>
          <td>看單筆 error 的 stack trace</td>
          <td>找出最頻繁的 error</td>
      </tr>
      <tr>
          <td>回傳</td>
          <td>事件陣列（含完整 JSON）</td>
          <td>分群摘要（name + count + last_seen）</td>
      </tr>
      <tr>
          <td>資料量</td>
          <td>大（完整事件 body）</td>
          <td>小（只有統計值）</td>
      </tr>
      <tr>
          <td>典型工作流</td>
          <td>聚合查詢找到問題 name → 逐筆查詢看細節</td>
          <td>首先使用</td>
      </tr>
  </tbody>
</table>
<p>兩者是互補的工作流 — 聚合查詢定位問題方向，逐筆查詢深入細節。Dashboard 的 Error 列表頁面直接消費聚合查詢的結果。</p>
<h2 id="cli-vs-http-的定位">CLI vs HTTP 的定位</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>CLI (grep + jq)</th>
          <th>HTTP endpoint</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者</td>
          <td>開發者</td>
          <td>自動化工具、dashboard</td>
      </tr>
      <tr>
          <td>適合</td>
          <td>即時探索、ad-hoc 查詢</td>
          <td>結構化查詢、程式化存取</td>
      </tr>
      <tr>
          <td>優勢</td>
          <td>零安裝、可組合</td>
          <td>遠端存取、標準化</td>
      </tr>
      <tr>
          <td>限制</td>
          <td>需要 SSH 存取 server</td>
          <td>需要 collector 啟動</td>
      </tr>
  </tbody>
</table>
<p>兩種介面共存 — CLI 用於開發者日常 debug，HTTP endpoint 用於自動化和遠端存取。兩者底層讀取同一份 JSONL 檔案，結果一致。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>JSONL 儲存的設計 → <a href="/blog/monitoring/04-collector/jsonl-storage/" data-link-title="JSONL 匯出與備份格式" data-link-desc="JSONL 作為匯出和備份格式的設計 — 人類可讀、grep 友好、SQLite 損壞時的重建來源">JSONL 儲存設計</a></li>
<li>Rule engine 的自動化處理 → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a></li>
<li>Collector 的完整架構 → <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">Collector 架構</a></li>
</ul>
]]></content:encoded></item><item><title>0.3 OpenAI 相容 API</title><link>https://tarrragon.github.io/blog/llm/00-foundations/openai-compatible-api/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/00-foundations/openai-compatible-api/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容 API&lt;/a> 是本地 LLM 生態能夠快速繁榮的關鍵基礎建設。OpenAI 在 2023 年定義的 &lt;code>POST /v1/chat/completions&lt;/code> 介面成為事實標準後，後來幾乎所有本地推論伺服器（Ollama、LM Studio、llama.cpp、vLLM、oMLX）都實作同一份 API 規格；介面層工具只要支援這個規格，就能「不改一行程式」切換本地與雲端。&lt;/p>
&lt;p>這個相容性決定了你的選擇空間。理解它的意義後，看到任何工具寫「支援 OpenAI 相容 API」時，你會知道這句話真正承諾的是什麼、不承諾的是什麼。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後，你應該能：&lt;/p>
&lt;ol>
&lt;li>看懂 &lt;code>apiBase: http://localhost:11434/v1&lt;/code> 這類設定背後在做什麼。&lt;/li>
&lt;li>判斷一個介面層工具是否支援本地 LLM。&lt;/li>
&lt;li>知道「&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容&lt;/a>」承諾的範圍與邊界。&lt;/li>
&lt;li>用 curl 直接打本地 LLM 的 API 驗證它在跑。&lt;/li>
&lt;/ol>
&lt;h2 id="api-形狀的核心chat-completions">API 形狀的核心：chat completions&lt;/h2>
&lt;p>OpenAI 在 2023 年定義的 chat completions API 核心是這個請求格式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">curl http://api.openai.com/v1/chat/completions &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$OPENAI_API_KEY&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s1"> &amp;#34;model&amp;#34;: &amp;#34;gpt-5&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s1"> &amp;#34;messages&amp;#34;: [
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s1"> {&amp;#34;role&amp;#34;: &amp;#34;system&amp;#34;, &amp;#34;content&amp;#34;: &amp;#34;You are a helpful assistant.&amp;#34;},
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s1"> {&amp;#34;role&amp;#34;: &amp;#34;user&amp;#34;, &amp;#34;content&amp;#34;: &amp;#34;寫一個 Python function 計算費氏數列&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s1"> ],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s1"> &amp;#34;stream&amp;#34;: true
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>回應是一連串 server-sent events（SSE、伺服器把回應切成小封包陸續推給 client、而不是等整段算完才一次回）、每個 event 包含一個 token chunk。&lt;/p>
&lt;p>本地推論伺服器實作同樣的 endpoint 形狀，只是 host 換成 localhost、API key 不檢查或檢查 dummy 值：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">curl http://localhost:11434/v1/chat/completions &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s1"> &amp;#34;model&amp;#34;: &amp;#34;gemma4:31b-coding-mtp-bf16&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s1"> &amp;#34;messages&amp;#34;: [
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s1"> {&amp;#34;role&amp;#34;: &amp;#34;system&amp;#34;, &amp;#34;content&amp;#34;: &amp;#34;You are a helpful assistant.&amp;#34;},
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s1"> {&amp;#34;role&amp;#34;: &amp;#34;user&amp;#34;, &amp;#34;content&amp;#34;: &amp;#34;寫一個 Python function 計算費氏數列&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s1"> ],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s1"> &amp;#34;stream&amp;#34;: true
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>差別只有三點：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>host&lt;/strong>：從 &lt;code>api.openai.com&lt;/code> 換成 &lt;code>localhost:11434&lt;/code>。&lt;/li>
&lt;li>&lt;strong>model&lt;/strong>：從 &lt;code>gpt-5&lt;/code> 換成 &lt;code>gemma4:31b-coding-mtp-bf16&lt;/code>。&lt;/li>
&lt;li>&lt;strong>Authorization&lt;/strong>：本地通常不檢查 API key，或接受任意值。&lt;/li>
&lt;/ol>
&lt;p>請求與回應的 JSON schema 完全一樣。這就是「OpenAI 相容」的字面意義。&lt;/p>
&lt;h2 id="為什麼這個相容性這麼重要">為什麼這個相容性這麼重要&lt;/h2>
&lt;p>如果沒有 OpenAI 相容 API，每個介面層工具要支援新的伺服器就得寫專屬整合：Continue.dev 要為 Ollama 寫一份、為 LM Studio 寫一份、為 llama.cpp 寫一份、為雲端 OpenAI 寫一份、為 Anthropic 寫一份。每多一個工具就 N×M 的整合成本。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容 API</a> 是本地 LLM 生態能夠快速繁榮的關鍵基礎建設。OpenAI 在 2023 年定義的 <code>POST /v1/chat/completions</code> 介面成為事實標準後，後來幾乎所有本地推論伺服器（Ollama、LM Studio、llama.cpp、vLLM、oMLX）都實作同一份 API 規格；介面層工具只要支援這個規格，就能「不改一行程式」切換本地與雲端。</p>
<p>這個相容性決定了你的選擇空間。理解它的意義後，看到任何工具寫「支援 OpenAI 相容 API」時，你會知道這句話真正承諾的是什麼、不承諾的是什麼。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後，你應該能：</p>
<ol>
<li>看懂 <code>apiBase: http://localhost:11434/v1</code> 這類設定背後在做什麼。</li>
<li>判斷一個介面層工具是否支援本地 LLM。</li>
<li>知道「<a href="/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容</a>」承諾的範圍與邊界。</li>
<li>用 curl 直接打本地 LLM 的 API 驗證它在跑。</li>
</ol>
<h2 id="api-形狀的核心chat-completions">API 形狀的核心：chat completions</h2>
<p>OpenAI 在 2023 年定義的 chat completions API 核心是這個請求格式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">curl http://api.openai.com/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$OPENAI_API_KEY</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">    &#34;model&#34;: &#34;gpt-5&#34;,
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">    &#34;messages&#34;: [
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">      {&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: &#34;You are a helpful assistant.&#34;},
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">      {&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;寫一個 Python function 計算費氏數列&#34;}
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">    ],
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">    &#34;stream&#34;: true
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s1">  }&#39;</span></span></span></code></pre></div><p>回應是一連串 server-sent events（SSE、伺服器把回應切成小封包陸續推給 client、而不是等整段算完才一次回）、每個 event 包含一個 token chunk。</p>
<p>本地推論伺服器實作同樣的 endpoint 形狀，只是 host 換成 localhost、API key 不檢查或檢查 dummy 值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">curl http://localhost:11434/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">    &#34;model&#34;: &#34;gemma4:31b-coding-mtp-bf16&#34;,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">    &#34;messages&#34;: [
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">      {&#34;role&#34;: &#34;system&#34;, &#34;content&#34;: &#34;You are a helpful assistant.&#34;},
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">      {&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;寫一個 Python function 計算費氏數列&#34;}
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">    ],
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">    &#34;stream&#34;: true
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  }&#39;</span></span></span></code></pre></div><p>差別只有三點：</p>
<ol>
<li><strong>host</strong>：從 <code>api.openai.com</code> 換成 <code>localhost:11434</code>。</li>
<li><strong>model</strong>：從 <code>gpt-5</code> 換成 <code>gemma4:31b-coding-mtp-bf16</code>。</li>
<li><strong>Authorization</strong>：本地通常不檢查 API key，或接受任意值。</li>
</ol>
<p>請求與回應的 JSON schema 完全一樣。這就是「OpenAI 相容」的字面意義。</p>
<h2 id="為什麼這個相容性這麼重要">為什麼這個相容性這麼重要</h2>
<p>如果沒有 OpenAI 相容 API，每個介面層工具要支援新的伺服器就得寫專屬整合：Continue.dev 要為 Ollama 寫一份、為 LM Studio 寫一份、為 llama.cpp 寫一份、為雲端 OpenAI 寫一份、為 Anthropic 寫一份。每多一個工具就 N×M 的整合成本。</p>
<p>OpenAI 相容把這個成本拆成「<a href="/blog/llm/00-foundations/three-layer-architecture/" data-link-title="0.2 介面 / 伺服器 / 模型三層架構" data-link-desc="把任何本地 LLM 工具放回正確的層級，用三層心智模型看懂工具關係">介面層</a>支援標準 API 一次 + <a href="/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">伺服器層</a>實作標準 API 一次」、整合工作從 N×M 降到 N+M。後果是新伺服器（如 2024 年才出現的 oMLX）只要實作這份 API、馬上能被既有的所有介面層用上。</p>
<p>這也是為什麼幾乎所有 IDE plugin、CLI 工具、Web UI 都選擇 OpenAI 相容做 first-class citizen。Anthropic 自己的 API 形狀（messages、不同 streaming 格式）反而成為次要選項，介面層工具通常要為 Anthropic 寫額外的 adapter。</p>
<h2 id="接本地-llm-的最小設定">接本地 LLM 的最小設定</h2>
<p>實際使用上，把任一個介面層工具切到本地 LLM 通常只要改三個欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>雲端 OpenAI 預設</th>
          <th>切到本地 Ollama 後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>API base</td>
          <td><code>https://api.openai.com/v1</code></td>
          <td><code>http://localhost:11434/v1</code></td>
      </tr>
      <tr>
          <td>API key</td>
          <td><code>sk-xxxxxxx</code></td>
          <td>任意字串，常用 <code>ollama</code> 或 <code>not-needed</code></td>
      </tr>
      <tr>
          <td>Model name</td>
          <td><code>gpt-5</code>、<code>gpt-4o</code></td>
          <td>Ollama 本地的 model tag，如 <code>gemma4:31b</code></td>
      </tr>
  </tbody>
</table>
<p>三個欄位的延伸判讀：API base 改成 <a href="/blog/llm/knowledge-cards/port-and-localhost/" data-link-title="Port 與 Localhost" data-link-desc="TCP port 與 listen address 如何決定 API server 的對外暴露範圍"><code>localhost:11434</code></a> 表示請求送到本機 11434 port、不走網路；API key 本地通常不檢查、但介面層工具可能仍要求填一個值才能初始化；Model name 要去伺服器看當前已下載的 <a href="/blog/llm/knowledge-cards/model-tag/" data-link-title="Model Tag" data-link-desc="Ollama 等推論伺服器用來定位特定模型版本的命名規則">model tag</a>、Ollama 用 <code>ollama list</code> 查、LM Studio 在 Discover 分頁查。</p>
<p>接近真實的例子是 Continue.dev 的 <code>config.json</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;models&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="s2">&#34;Gemma 4 31B (local)&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;provider&#34;</span><span class="p">:</span> <span class="s2">&#34;ollama&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;gemma4:31b-coding-mtp-bf16&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nt">&#34;apiBase&#34;</span><span class="p">:</span> <span class="s2">&#34;http://localhost:11434&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Continue.dev 內部會把 <code>provider: ollama</code> 翻成 OpenAI 相容請求送到 <code>apiBase</code>。如果你想用通用 OpenAI provider：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;models&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="s2">&#34;Local LLM (via OpenAI-compatible)&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;provider&#34;</span><span class="p">:</span> <span class="s2">&#34;openai&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;gemma4:31b-coding-mtp-bf16&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nt">&#34;apiBase&#34;</span><span class="p">:</span> <span class="s2">&#34;http://localhost:11434/v1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="nt">&#34;apiKey&#34;</span><span class="p">:</span> <span class="s2">&#34;not-needed&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩種寫法都會工作。<code>provider: ollama</code> 多一些 Ollama 特有功能（如 model auto-pull），<code>provider: openai</code> 比較通用、可以接任何 OpenAI 相容伺服器。</p>
<h2 id="openai-相容承諾什麼不承諾什麼">「OpenAI 相容」承諾什麼、不承諾什麼</h2>
<p>相容承諾的是 <strong>API 形狀</strong> —— request schema、response schema、streaming 格式、錯誤碼大致一致。不承諾的是：</p>
<ol>
<li><strong>模型能力</strong>：本地 Gemma 4 31B 跟雲端 GPT-5 都能用同一套 API 呼叫，但回答品質天差地遠。</li>
<li><strong>效能特性</strong>：本地的 TTFT、生字速度跟雲端完全不同，介面層感覺不到差別不代表速度一樣。</li>
<li><strong>進階參數</strong>：OpenAI 自己的新功能（function calling 進階模式、structured output 強制 JSON 輸出、reasoning effort 控制推理深度等）不一定被本地伺服器完整支援。寫 code 場景常見問題是設定了 <code>tools</code> 參數但本地模型不會主動呼叫。模組四會展開這些進階特性、見 <a href="/blog/llm/04-applications/tool-use-principles/" data-link-title="4.3 Tool use 原理：LLM 跟外部世界互動" data-link-desc="Structured output 是 LLM 跨入工程系統的橋、function calling 取捨、為什麼本地小模型 tool use 表現崩潰">4.3 Tool use 原理</a>。</li>
<li><strong>模型清單</strong>：呼叫 <code>GET /v1/models</code> 回的清單、本地是你已下載的模型、雲端是 OpenAI 提供的模型；介面層要把兩邊清單視為各自獨立的資料。</li>
</ol>
<p>接近真實的意外事件：</p>
<ul>
<li>設定 <code>response_format: { type: &quot;json_object&quot; }</code> 強制 JSON 輸出，本地某些舊模型不認，會直接回普通文字。</li>
<li>設定 <code>tool_choice: &quot;required&quot;</code> 強制使用工具，本地許多模型不支援，行為退化成普通對話。</li>
<li>設定 <code>seed</code> 想拿確定性輸出，本地伺服器多半實作了，但雲端 OpenAI 並不保證每個 model 都尊重。</li>
</ul>
<p>陷阱是把「相容」當成「等價」。在依賴進階參數的場景下、寫程式時值得先假設本地伺服器可能不支援最新功能、預先準備降級處理（例如先試 <code>tool_choice: &quot;required&quot;</code>、伺服器忽略時 fallback 到 prompt-based 工具呼叫）。</p>
<h2 id="用-curl-驗證本地-llm-在跑">用 curl 驗證本地 LLM 在跑</h2>
<p>啟動 Ollama 並 pull 一個模型後，最快確認它在跑的方式是直接 curl：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl http://localhost:11434/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    &#34;model&#34;: &#34;gemma4:e4b&#34;,
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    &#34;messages&#34;: [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Say hi in three languages.&#34;}],
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">    &#34;stream&#34;: false
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s1">  }&#39;</span></span></span></code></pre></div><p>如果回的是 JSON 包含 <code>choices[0].message.content</code>，伺服器層正常。介面層連不上的時候，先用這個 curl 確認問題是介面層、伺服器層，還是模型本身。</p>
<p>需要驗證 streaming：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl http://localhost:11434/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    &#34;model&#34;: &#34;gemma4:e4b&#34;,
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    &#34;messages&#34;: [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Count from 1 to 5.&#34;}],
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">    &#34;stream&#34;: true
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s1">  }&#39;</span></span></span></code></pre></div><p>正常應該看到一連串 <code>data: {...}</code> 行，每行是一個 token chunk。</p>
<h2 id="多伺服器並存同時跑-ollama-與-lm-studio">多伺服器並存：同時跑 Ollama 與 LM Studio</h2>
<p>OpenAI 相容讓你可以同時在同一台 Mac 上跑多個伺服器，只要 port 不撞。常見配置：</p>
<table>
  <thead>
      <tr>
          <th>伺服器</th>
          <th>預設 port</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Ollama</td>
          <td>11434</td>
          <td>日常寫 code 主力</td>
      </tr>
      <tr>
          <td>LM Studio</td>
          <td>1234</td>
          <td>探索新模型、不影響主 server</td>
      </tr>
      <tr>
          <td>llama.cpp</td>
          <td>8080</td>
          <td>進階測試、特殊量化</td>
      </tr>
      <tr>
          <td>oMLX</td>
          <td>8000</td>
          <td>長 context coding agent 場景</td>
      </tr>
  </tbody>
</table>
<p>Port 衝突的徵兆是啟動伺服器時報 <code>address already in use</code>。用 <code>lsof -i :&lt;port&gt;</code> 找佔用方、確認是舊版伺服器就 <code>pkill -f</code> 終止、或改用其他 port 啟動。詳細的 port 與 listen address 判讀見 <a href="/blog/llm/knowledge-cards/port-and-localhost/" data-link-title="Port 與 Localhost" data-link-desc="TCP port 與 listen address 如何決定 API server 的對外暴露範圍">Port 與 Localhost</a> 卡片。</p>
<p>Continue.dev 的 <code>config.json</code> 可以同時列多個 model、每個 model 指向不同伺服器、UI 上下拉切換。這個能力讓「主力模型穩定跑、實驗模型隔離測試」變得直接。</p>
<h2 id="不是-openai-相容的本地工具">不是 OpenAI 相容的本地工具</h2>
<p>少數本地工具不走 OpenAI 相容，要特別注意：</p>
<ol>
<li><strong><a href="/blog/llm/knowledge-cards/mlx/" data-link-title="MLX" data-link-desc="Apple 釋出的 Apple Silicon 數值運算 framework：類似 PyTorch / JAX 的 Mac 對應物">MLX</a> 原生 Python API</strong>：Apple 的 MLX framework 本身是 Python library、不是 HTTP server。需要自己 wrap 或用 <code>mlx_lm.server</code>（次要產品、功能不全）。完整的 MLX / MTP / oMLX 區別見 <a href="/blog/llm/00-foundations/mlx-mtp-omlx/" data-link-title="0.4 MLX / MTP / oMLX 的區別" data-link-desc="三個常被混為一談的術語：framework、加速技巧、特化 server，疊加而非互斥">0.4 章節</a>。</li>
<li><strong>早期 llama.cpp</strong>：在 OpenAI 相容前就存在，原生 API 形狀不同；新版加上 <code>/v1/chat/completions</code> 後跟主流相容。</li>
<li><strong>某些研究專案</strong>：直接 wrap PyTorch / Transformers，沒有 HTTP 層，要當 library 用。</li>
</ol>
<p>遇到這類工具時、值得先評估「該不該為它寫 adapter」。判讀訊號：模型唯一性（這個工具是否提供其他伺服器拿不到的模型？）vs 整合成本（寫 adapter 與長期維護的時間投入）。模型唯一性高時值得投資、模型可在主流伺服器找到替代時、選 OpenAI 相容的主流伺服器（Ollama、LM Studio）能省下大量整合成本。</p>
<h2 id="下一章">下一章</h2>
<p>下一章：<a href="/blog/llm/00-foundations/mlx-mtp-omlx/" data-link-title="0.4 MLX / MTP / oMLX 的區別" data-link-desc="三個常被混為一談的術語：framework、加速技巧、特化 server，疊加而非互斥">0.4 MLX / MTP / oMLX</a>，澄清三個常被混為一談的術語，避開網路上最常見的本地 LLM 認知陷阱。</p>
]]></content:encoded></item><item><title>API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning</title><link>https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/</guid><description>&lt;h2 id="api-認證為什麼要分層">API 認證為什麼要分層&lt;/h2>
&lt;p>&lt;strong>API 認證的核心是「身分維度的分離」&lt;/strong> — 一個 request 同時牽涉「人」「呼叫的系統」「另一個系統有沒有對應身分」三個獨立問題，每個問題的 secret 機制不同、洩漏後果不同、撤銷方式不同。混用一個機制回答全部問題，等於用同一把鑰匙開家、車、保險箱。&lt;/p>
&lt;p>看似一個 API request，其實同時要回答：&lt;/p>
&lt;ul>
&lt;li>發起這個 request 的「&lt;strong>人&lt;/strong>」是誰？（identity）&lt;/li>
&lt;li>把這個 request 傳過來的「&lt;strong>系統&lt;/strong>」是誰？（caller）&lt;/li>
&lt;li>這個人在「&lt;strong>另一個系統&lt;/strong>」有沒有對應身分？（cross-system mapping）&lt;/li>
&lt;/ul>
&lt;p>每個問題都需要不同的 secret 機制來回答。設計時先拆身分維度，再選 token、shared secret、mTLS 或 provisioning workflow，才有辦法讓洩漏範圍、撤銷粒度與排障路由各自清楚。&lt;/p>
&lt;p>這篇整理兩層信任邊界（Layer 1 使用者、Layer 2 系統）跟一個跨系統 workflow（Layer 3 Provisioning），以及它們各自對應的 secret 機制。&lt;strong>每層的實作細節都另有獨立文章深入&lt;/strong>、本文聚焦「為什麼要分」「各層解什麼問題」的心智模型。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>前提假設&lt;/strong>：以下所有機制都假設 transport 走 HTTPS / TLS。Token 與 secret 需要在加密通道內傳輸，否則中間人可直接取得 credential。HTTPS 是所有層共同依賴的 transport 前提。&lt;/p>
&lt;p>&lt;strong>本文 token 範圍&lt;/strong>：本文討論「opaque token」（隨機字串、server 端 lookup），不涵蓋 JWT（self-contained token、簽章驗證）。兩者安全模型不同，比較見 Layer 1 段落。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="layer-1使用者層bearer-token">Layer 1：使用者層（Bearer Token）&lt;/h2>
&lt;p>&lt;strong>使用者層負責把 request 綁到已登入的人類或帳號主體&lt;/strong>。它回答的問題是：「這個 request 是哪個使用者發的？」&lt;/p>
&lt;p>&lt;strong>Bearer Token 是 capability credential（持有即授權）、不是 identity credential（身分證明）&lt;/strong>。差別在於：身分證遺失可以掛失補辦、別人撿到也無法直接領錢；Bearer Token 一旦被取得、攻擊者就能即時用該使用者身分發 request、沒有第二道關卡。這個本質決定了 token 的儲存、傳輸、撤銷機制都必須以「持有即危險」為前提設計。&lt;/p>
&lt;p>「Bearer Token」是 RFC 6750 定義的 HTTP authentication scheme（&lt;code>Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code>）、屬於通用概念 — GitHub PAT、Stripe API Key、OAuth access token、Laravel Sanctum 的 PAT、JWT 都是 Bearer Token 的不同實作。&lt;/p>
&lt;h3 id="opaque-token-vs-jwt兩種根本不同的設計">Opaque Token vs JWT：兩種根本不同的設計&lt;/h3>
&lt;p>「Bearer Token」是上位概念、實作上有兩條主線、安全模型完全不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>Opaque Token（如 Sanctum）&lt;/th>
 &lt;th>JWT&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Token 本身&lt;/td>
 &lt;td>隨機字串、無內含資訊&lt;/td>
 &lt;td>簽章 payload、內嵌使用者 claim&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>驗證方式&lt;/td>
 &lt;td>server 查 DB lookup&lt;/td>
 &lt;td>驗簽章、不需 DB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>載入使用者&lt;/td>
 &lt;td>從 DB row 撈&lt;/td>
 &lt;td>直接讀 claim&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>撤銷&lt;/td>
 &lt;td>刪 DB row、立即生效&lt;/td>
 &lt;td>困難、需 blacklist 或短 TTL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>洩漏暴露範圍&lt;/td>
 &lt;td>該 row 立即停用&lt;/td>
 &lt;td>直到 expire 都有效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨服務驗證&lt;/td>
 &lt;td>需要共用 DB 或驗證 endpoint&lt;/td>
 &lt;td>共享公鑰即可、stateless&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者各有適合情境：opaque token 撤銷快、適合「使用者主動登出 / 帳號被盜要立即停權」；JWT 不需 DB lookup、適合「跨多個 microservice、想避免每次都查中央 DB」。下面 Layer 1 的內容&lt;strong>只聚焦 opaque token&lt;/strong> — JWT 的設計細節（簽章演算法選擇、&lt;code>alg: none&lt;/code> 攻擊、key rotation）是獨立議題、不在本篇範圍。&lt;/p>
&lt;h3 id="opaque-token-的格式設計">Opaque Token 的格式設計&lt;/h3>
&lt;p>Opaque token 是隨機字串、但實際 format 在不同產品有兩條主流分流：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;th>解的問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>&lt;code>{PK}|{secret}&lt;/code>&lt;/strong>&lt;/td>
 &lt;td>&lt;code>1|abc123def456...&lt;/code>（Laravel Sanctum）&lt;/td>
 &lt;td>用 PK 收斂 DB 搜尋、把 timing 安全留給應用層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>&lt;code>{prefix}_{secret}&lt;/code>&lt;/strong>&lt;/td>
 &lt;td>&lt;code>ghp_xxx&lt;/code>（GitHub）、&lt;code>sk_live_xxx&lt;/code>（Stripe）&lt;/td>
 &lt;td>用語意 prefix 支援自動洩漏掃描跟 token type 辨識&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩種設計&lt;strong>沒有絕對優劣&lt;/strong>、取決於 token 的傳播範圍：純內部使用、Sanctum 設計簡潔且足夠；對外開放、容易散落公開 repo、prefix 設計能讓 GitHub Secret Scanning / Stripe webhook 等工具自動偵測洩漏。&lt;/p></description><content:encoded><![CDATA[<h2 id="api-認證為什麼要分層">API 認證為什麼要分層</h2>
<p><strong>API 認證的核心是「身分維度的分離」</strong> — 一個 request 同時牽涉「人」「呼叫的系統」「另一個系統有沒有對應身分」三個獨立問題，每個問題的 secret 機制不同、洩漏後果不同、撤銷方式不同。混用一個機制回答全部問題，等於用同一把鑰匙開家、車、保險箱。</p>
<p>看似一個 API request，其實同時要回答：</p>
<ul>
<li>發起這個 request 的「<strong>人</strong>」是誰？（identity）</li>
<li>把這個 request 傳過來的「<strong>系統</strong>」是誰？（caller）</li>
<li>這個人在「<strong>另一個系統</strong>」有沒有對應身分？（cross-system mapping）</li>
</ul>
<p>每個問題都需要不同的 secret 機制來回答。設計時先拆身分維度，再選 token、shared secret、mTLS 或 provisioning workflow，才有辦法讓洩漏範圍、撤銷粒度與排障路由各自清楚。</p>
<p>這篇整理兩層信任邊界（Layer 1 使用者、Layer 2 系統）跟一個跨系統 workflow（Layer 3 Provisioning），以及它們各自對應的 secret 機制。<strong>每層的實作細節都另有獨立文章深入</strong>、本文聚焦「為什麼要分」「各層解什麼問題」的心智模型。</p>
<blockquote>
<p><strong>前提假設</strong>：以下所有機制都假設 transport 走 HTTPS / TLS。Token 與 secret 需要在加密通道內傳輸，否則中間人可直接取得 credential。HTTPS 是所有層共同依賴的 transport 前提。</p>
<p><strong>本文 token 範圍</strong>：本文討論「opaque token」（隨機字串、server 端 lookup），不涵蓋 JWT（self-contained token、簽章驗證）。兩者安全模型不同，比較見 Layer 1 段落。</p></blockquote>
<hr>
<h2 id="layer-1使用者層bearer-token">Layer 1：使用者層（Bearer Token）</h2>
<p><strong>使用者層負責把 request 綁到已登入的人類或帳號主體</strong>。它回答的問題是：「這個 request 是哪個使用者發的？」</p>
<p><strong>Bearer Token 是 capability credential（持有即授權）、不是 identity credential（身分證明）</strong>。差別在於：身分證遺失可以掛失補辦、別人撿到也無法直接領錢；Bearer Token 一旦被取得、攻擊者就能即時用該使用者身分發 request、沒有第二道關卡。這個本質決定了 token 的儲存、傳輸、撤銷機制都必須以「持有即危險」為前提設計。</p>
<p>「Bearer Token」是 RFC 6750 定義的 HTTP authentication scheme（<code>Authorization: Bearer &lt;token&gt;</code>）、屬於通用概念 — GitHub PAT、Stripe API Key、OAuth access token、Laravel Sanctum 的 PAT、JWT 都是 Bearer Token 的不同實作。</p>
<h3 id="opaque-token-vs-jwt兩種根本不同的設計">Opaque Token vs JWT：兩種根本不同的設計</h3>
<p>「Bearer Token」是上位概念、實作上有兩條主線、安全模型完全不同：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>Opaque Token（如 Sanctum）</th>
          <th>JWT</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Token 本身</td>
          <td>隨機字串、無內含資訊</td>
          <td>簽章 payload、內嵌使用者 claim</td>
      </tr>
      <tr>
          <td>驗證方式</td>
          <td>server 查 DB lookup</td>
          <td>驗簽章、不需 DB</td>
      </tr>
      <tr>
          <td>載入使用者</td>
          <td>從 DB row 撈</td>
          <td>直接讀 claim</td>
      </tr>
      <tr>
          <td>撤銷</td>
          <td>刪 DB row、立即生效</td>
          <td>困難、需 blacklist 或短 TTL</td>
      </tr>
      <tr>
          <td>洩漏暴露範圍</td>
          <td>該 row 立即停用</td>
          <td>直到 expire 都有效</td>
      </tr>
      <tr>
          <td>跨服務驗證</td>
          <td>需要共用 DB 或驗證 endpoint</td>
          <td>共享公鑰即可、stateless</td>
      </tr>
  </tbody>
</table>
<p>兩者各有適合情境：opaque token 撤銷快、適合「使用者主動登出 / 帳號被盜要立即停權」；JWT 不需 DB lookup、適合「跨多個 microservice、想避免每次都查中央 DB」。下面 Layer 1 的內容<strong>只聚焦 opaque token</strong> — JWT 的設計細節（簽章演算法選擇、<code>alg: none</code> 攻擊、key rotation）是獨立議題、不在本篇範圍。</p>
<h3 id="opaque-token-的格式設計">Opaque Token 的格式設計</h3>
<p>Opaque token 是隨機字串、但實際 format 在不同產品有兩條主流分流：</p>
<table>
  <thead>
      <tr>
          <th>設計</th>
          <th>範例</th>
          <th>解的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong><code>{PK}|{secret}</code></strong></td>
          <td><code>1|abc123def456...</code>（Laravel Sanctum）</td>
          <td>用 PK 收斂 DB 搜尋、把 timing 安全留給應用層</td>
      </tr>
      <tr>
          <td><strong><code>{prefix}_{secret}</code></strong></td>
          <td><code>ghp_xxx</code>（GitHub）、<code>sk_live_xxx</code>（Stripe）</td>
          <td>用語意 prefix 支援自動洩漏掃描跟 token type 辨識</td>
      </tr>
  </tbody>
</table>
<p>兩種設計<strong>沒有絕對優劣</strong>、取決於 token 的傳播範圍：純內部使用、Sanctum 設計簡潔且足夠；對外開放、容易散落公開 repo、prefix 設計能讓 GitHub Secret Scanning / Stripe webhook 等工具自動偵測洩漏。</p>
<p>Sanctum 的 <code>{PK}|{secret}</code> 設計常被誤解為「業界標準」 — 其實是 Laravel 生態的特定選擇。具體機制、跟 GitHub / Stripe 設計的比較、各語言實作範例見 <a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>。</p>
<h3 id="token-在-db-的儲存原則簡述">Token 在 DB 的儲存原則（簡述）</h3>
<p>無論用哪種 format、有三條跨設計通用的儲存原則：</p>
<ol>
<li><strong>DB 只存 hash、不存原文</strong> — token 是高熵隨機字串、SHA-256 即可、不需 bcrypt</li>
<li><strong>比對必須是 constant-time</strong> — 用各語言提供的 <code>hash_equals</code> / <code>compare_digest</code> / <code>ConstantTimeCompare</code>、不用 <code>==</code></li>
<li><strong>Lookup 用穩定字段、機密比對放應用層</strong> — DB 引擎不保證 constant-time 比對、把機密比對搬離 DB</li>
</ol>
<p>這三條的詳細推導、各語言 constant-time 函式對照、非 Laravel 環境的實作範例見 <a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>。</p>
<h3 id="token-的生命週期">Token 的生命週期</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">   Login                  Use                  Expire/Revoke
</span></span><span class="line"><span class="ln">2</span><span class="cl">─────────  ───────────────────────────  ─────────────────
</span></span><span class="line"><span class="ln">3</span><span class="cl">issued → DB 存 hash  →  Bearer 驗證    →   row deleted
</span></span><span class="line"><span class="ln">4</span><span class="cl">                            ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">                       set request.user</span></span></code></pre></div><ul>
<li><strong><code>expires_at</code></strong>（例如 7 天、30 天）— 限制洩漏 token 的暴露窗</li>
<li><strong><code>abilities</code> / <code>scopes</code></strong> — 限縮權限粒度（「只能讀」「只能存取某 resource」），降低單一 token 洩漏的破壞範圍</li>
<li><strong>登出即刪 row</strong> — opaque token 的撤銷成本低，這是它相對 JWT 的關鍵優勢</li>
<li><strong>rate limit / brute force 防護</strong> — token 是隨機字串、攻擊者可暴力試。應用層要對「token 驗證失敗」加 rate limit、避免被掃出有效 token</li>
<li><strong>長期 access 用 refresh token pattern</strong> — access token 短 TTL（小時級）、refresh token 長 TTL（月級）。Access token 洩漏只影響短窗、refresh token 撤銷後新的 access token 也無法發放</li>
</ul>
<h3 id="信任邊界">信任邊界</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[ 使用者 ] ─────────▶ [ API server ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">              token        ↑
</span></span><span class="line"><span class="ln">3</span><span class="cl">                           知道「你是誰」
</span></span><span class="line"><span class="ln">4</span><span class="cl">                           但不會自動跨到其他系統</span></span></code></pre></div><p>Bearer Token 是 capability credential — 任何持有它的 client 都能以該使用者身分發 request。這也是為什麼 token 一旦離開原本的 API server，就會引發下一層問題：B 系統收到 A 系統的 token、根本不知道該怎麼驗證、也不該驗證。</p>
<hr>
<h2 id="layer-2系統層system-to-system-credential">Layer 2：系統層（System-to-system credential）</h2>
<p><strong>系統層負責驗證呼叫方服務本身的身分</strong>。它回答的問題是：「這個 request 是哪個系統發的？」</p>
<p>當系統 A 需要呼叫系統 B 的 API 時，Layer 1 的使用者 token 只代表「使用者」的身分。系統 B 仍需要獨立驗證「這個 request 來自合法的合作系統 A」，這個判斷要由系統層 credential 承擔。</p>
<h3 id="為什麼分得這麼清楚">為什麼分得這麼清楚</h3>
<p>想像系統 B 收到一個請求：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">B 收到請求「給我會員 X 的資料」
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">B 自問：這請求來自...
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ├─ 我的合作夥伴系統 A？  → 可進入授權判斷
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ├─ 未註冊的外部 caller？ → 回 401 / 403
</span></span><span class="line"><span class="ln">6</span><span class="cl">   └─ 偽裝成 A 的 caller？  → 回 401 / 403 並記錄告警</span></span></code></pre></div><p>純粹靠 Layer 1 的使用者 token 只能證明「這位 user 的身分」，無法證明「系統 A 的身分」。這個分工讓帳號被盜與合作系統被冒用分別走不同監控與撤銷流程。</p>
<h3 id="shared-secret與api-key的關係">「Shared Secret」與「API Key」的關係</h3>
<p>兩者常被混用、實際上是同一個機制（一邊發、一邊存的對稱字串）的不同部署方式：</p>
<table>
  <thead>
      <tr>
          <th>區分點</th>
          <th>Shared Secret</th>
          <th>API Key</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Caller identity</td>
          <td>兩邊都用同一把、沒有 caller 區分</td>
          <td>每個 client 一把、server 有 key → identity 對照表</td>
      </tr>
      <tr>
          <td>撤銷粒度</td>
          <td>換一邊、全部斷</td>
          <td>撤一把 key、只影響該 client</td>
      </tr>
      <tr>
          <td>典型部署</td>
          <td>內部固定夥伴系統</td>
          <td>對外開放 API、多 tenant</td>
      </tr>
  </tbody>
</table>
<p>下面討論的「Shared Secret」泛指這個 pattern；要做 per-client identity 與 revoke 時、改成 API Key 結構即可。</p>
<h3 id="常見方案的取捨">常見方案的取捨</h3>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>機制</th>
          <th>撤銷粒度</th>
          <th>適合情境</th>
          <th>主要代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Shared Secret</strong></td>
          <td>兩邊放同一把字串</td>
          <td>全部 caller</td>
          <td>內部單一夥伴、低變更頻率</td>
          <td>多 client 時撤銷會牽動所有人</td>
      </tr>
      <tr>
          <td><strong>API Key</strong></td>
          <td>每個 client 一把、server 有對照表</td>
          <td>per-client</td>
          <td>對外開放、多 tenant</td>
          <td>server 需維護 key → identity mapping</td>
      </tr>
      <tr>
          <td><strong>HMAC 簽章</strong></td>
          <td>client 用 secret 簽 request body</td>
          <td>per-key</td>
          <td>secret 不想經過網路、需防 replay / 改寫</td>
          <td>兩邊都要實作簽章邏輯、debug 較難</td>
      </tr>
      <tr>
          <td><strong>mTLS</strong></td>
          <td>雙向 TLS 憑證</td>
          <td>撤憑證</td>
          <td>金融、醫療、零信任網路</td>
          <td>憑證生命週期管理複雜、CA / CRL 基礎建設成本</td>
      </tr>
      <tr>
          <td><strong>OAuth Client Credentials</strong></td>
          <td>client_id + secret 換短期 access token</td>
          <td>撤 long-lived secret、短 token 自然 expire</td>
          <td>跨組織、權限粒度需要、需配合 scope</td>
          <td>多一層 token endpoint、實作成本較高</td>
      </tr>
  </tbody>
</table>
<p>選擇預設值的判斷：純內部固定夥伴可從 Shared Secret 起步；對外或多 client 直接上 API Key；公網跨組織 + 需要短期撤銷上 OAuth Client Credentials；合規或高威脅環境用 mTLS。</p>
<p>mTLS 的 CA 階層、憑證生命週期、撤銷機制、nginx / service mesh 整合見 <a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a>。</p>
<h3 id="shared-secret-的隱形成本">Shared Secret 的隱形成本</h3>
<p>Shared Secret 部署簡單、但維運上有幾個固定痛點：</p>
<ul>
<li><strong>無法 per-caller 撤銷</strong> — 一旦洩漏，所有用這把 secret 的 client 都得換</li>
<li><strong>輪替需要兩邊同步</strong> — 任何一邊忘了更新就斷線、需要「雙密過渡期」讓兩邊有時間切換。具體實作見 <a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a></li>
<li><strong>常被放進 query param</strong> — 為了簡便、會留在 nginx access log、CDN log、瀏覽器 history 裡。應放在 request header（例如 <code>X-System-Secret: xxx</code>）或走 HMAC / OAuth</li>
</ul>
<h3 id="信任邊界-1">信任邊界</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[ 系統 A ] ═════════▶ [ 系統 B ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">       shared secret
</span></span><span class="line"><span class="ln">3</span><span class="cl">       (server-to-server, server-only credential)</span></span></code></pre></div><p><strong>Layer 2 secret 的安全邊界是 server-side runtime</strong>。一旦進入瀏覽器或行動 app，攻擊者就能透過反編譯、JS source map、devtools network panel 等管道取得；取得後即可假冒系統 A 呼叫系統 B。Mobile app 的反編譯工具（jadx、Hopper、Ghidra 等）讓這個攻擊成本極低，obfuscation 只能增加時間成本。</p>
<p>如果 client 端需要呼叫 B，安全路由是讓 client 先呼叫 A，由 A 在 server 端用 Layer 2 secret 呼叫 B（A 當 proxy / BFF）；另一條路是用 OAuth 把 short-lived token 發給 client，long-lived secret 留在 server。</p>
<hr>
<h2 id="layer-3跨系統-provisioning身分對應-workflow不是新的信任邊界">Layer 3：跨系統 Provisioning（身分對應 workflow、不是新的信任邊界）</h2>
<p><strong>回答的問題</strong>：「系統 A 的使用者 X、在系統 B 對應到哪個身分？」</p>
<p><strong>Layer 3 跟 Layer 1 / 2 在概念上不對等</strong> — Layer 1 / 2 是「驗證某個身分」的信任邊界、各自需要獨立的 secret 機制；Layer 3 不引入新的 secret、是「<strong>讓兩個系統的使用者身分對應上</strong>」的 workflow。它建立在 Layer 1（A 已驗證使用者）跟 Layer 2（A 已被授權呼叫 B）之上、不取代任何一層。</p>
<p>之所以仍放進「層」的編號系統、是因為實際 API 串接時、開發者會把它跟前兩層一起遇到、必須在同一個心智模型裡處理。但設計時要清楚意識到：<strong>Layer 3 的失敗模式是「身分對不上」、不是「身分被偽造」</strong>、跟 Layer 1 / 2 的安全失敗模式不同。</p>
<h3 id="為什麼需要-provisioning">為什麼需要 provisioning</h3>
<p>當 A 跟 B 是兩個獨立 service 時，「<strong>A 的使用者 X</strong>」跟「<strong>B 的使用者 X</strong>」未必是同一筆資料。可能：</p>
<ul>
<li>B 從來沒見過 X 這個人</li>
<li>B 有自己對 X 的 record、但跟 A 不同 schema</li>
<li>B 看過 X、但兩邊的 user_id 還沒對應上</li>
</ul>
<p>需要一個機制把兩邊綁定 — 這個動作叫 <strong>provisioning</strong>。</p>
<h3 id="eager-vs-lazy-兩種策略">Eager vs Lazy 兩種策略</h3>
<p>Provisioning 策略的判斷核心是「何時承擔跨系統建檔成本」。Eager 把成本前移到註冊流程，Lazy 把成本延後到第一次使用；兩者差異不只是效能，而是資料膨脹、首用體驗與文件契約的取捨。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">EAGER (註冊時就跨系統建檔)
</span></span><span class="line"><span class="ln">2</span><span class="cl">────────────────────────────
</span></span><span class="line"><span class="ln">3</span><span class="cl">使用者註冊系統 A
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">   A 新增會員 row
</span></span><span class="line"><span class="ln">6</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">7</span><span class="cl">   A ──同步呼叫──▶ B.createUser()  ← 即使他可能永遠不用 B
</span></span><span class="line"><span class="ln">8</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">9</span><span class="cl">   兩邊都有資料、可以立刻呼叫 B 的 API</span></span></code></pre></div><p>Eager 適合大多數使用者都會用到 B 功能、且首用延遲成本高的服務。主要風險是 B 會累積大量低活躍 user，schema migration、備份與隱私刪除流程都會被放大。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">LAZY (第一次需要時才建)
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">────────────────────────────
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">使用者註冊系統 A
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   A 新增會員 row              ← 只有 A 這邊
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   ...日後可能很久才用到 B...
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">使用者第一次需要 B 的功能
</span></span><span class="line"><span class="ln">10</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">11</span><span class="cl">   呼叫 A 的「provision」endpoint
</span></span><span class="line"><span class="ln">12</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">13</span><span class="cl">   A ──呼叫──▶ B.findOrCreateUser()  ← 這時候才建
</span></span><span class="line"><span class="ln">14</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">15</span><span class="cl">   之後就跟 eager 一樣</span></span></code></pre></div><p>Lazy 適合只有一部分使用者會用到 B 功能、且第一次使用可以接受一次 provisioning 延遲的服務。主要風險是「第一次使用」這個時機需要被寫進文件、SDK 或錯誤碼，否則接手者會把 B 的 404 誤判成 request 格式或權限問題。</p>
<h3 id="lazy-的隱性-api-依賴順序">Lazy 的「隱性 API 依賴順序」</h3>
<p>Lazy provisioning 的最大成本是<strong>隱性依賴順序造成的認知負擔</strong>：</p>
<ul>
<li>文件若沒有寫清楚「呼叫 B 前先呼叫 A 的 provision endpoint」，接手者會在「B 回 404 找不到 user」的訊號上花大量時間排查</li>
<li>用 SDK 包裝可以把 provision 自動處理、對外只暴露單一 API</li>
<li>不用 SDK 時，文件需要在快速上手與錯誤碼段落顯眼註明這個依賴順序</li>
</ul>
<p>折衷做法：B 的 API 在第一次發現 user 不存在時、<strong>主動回一個 <code>PROVISIONING_REQUIRED</code> 錯誤碼</strong>、client 看到就知道要去呼叫 A 的 provision endpoint。比起靜默 500 或單純 404 更能引導 client 走到正確流程。</p>
<h3 id="信任邊界示意">信任邊界示意</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[ 使用者 ] ──Layer 1──▶ [ 系統 A ] ══Layer 2══▶ [ 系統 B ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                            │  Layer 3 workflow：
</span></span><span class="line"><span class="ln">3</span><span class="cl">                            └─ 觸發後在 B 建立對應身分</span></span></code></pre></div><p>Layer 3 不引入新的 secret、是「<strong>建立兩邊身分關聯</strong>」的 lifecycle 動作。它依賴 Layer 1（確認使用者身分）跟 Layer 2（A 被授權對 B 發指令）。沒有 Layer 1 / 2 的話、provisioning 自己無法獨立成立。</p>
<hr>
<h2 id="三層怎麼組合">三層怎麼組合</h2>
<p>把三層擺在一起的典型 request 流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">        ┌─────────────┐                       ┌──────────────┐
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        │  使用者      │                       │   系統 A     │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        │  (Browser/  │ ──── Layer 1 ──────▶ │              │
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        │   App)      │      Bearer token     │              │
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        └─────────────┘                       └──────┬───────┘
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                                                     │
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                                            Layer 3  │ Provision
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                                                     │ (第一次)
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                                                     ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">                                              ┌──────────────┐
</span></span><span class="line"><span class="ln">11</span><span class="cl">                                              │   系統 B     │
</span></span><span class="line"><span class="ln">12</span><span class="cl">                                              └──────────────┘
</span></span><span class="line"><span class="ln">13</span><span class="cl">                                                     ▲
</span></span><span class="line"><span class="ln">14</span><span class="cl">                                                     │
</span></span><span class="line"><span class="ln">15</span><span class="cl">                                            Layer 2  │ Shared secret
</span></span><span class="line"><span class="ln">16</span><span class="cl">                                                     │ (server-to-server)</span></span></code></pre></div><p>每一條線都是一層信任邊界，各自需要不同 secret 機制保護。</p>
<hr>
<h2 id="設計時最常見的三個失效模式">設計時最常見的三個失效模式</h2>
<h3 id="失效模式一讓使用者-token-也能驗-layer-2">失效模式一：讓使用者 token 也能驗 Layer 2</h3>
<p><strong>責任分工</strong>：「使用者身分」跟「呼叫系統身分」是兩個獨立維度、各自需要獨立 credential。系統 B 對「來自 A」的信任應綁定在系統層 credential，而不是任何單一使用者帳號上。</p>
<p><strong>常見誤用</strong>：B 接受「只要 request 帶有任一合法使用者 token 就放行」。</p>
<p><strong>風險判讀</strong>：這會把系統信任降階為使用者信任。任一帳號被盜（釣魚、密碼洩漏、token 外流）時，攻擊者就能用該使用者身分對 B 發 request，執行 B 開放給 A 的系統操作。</p>
<p><strong>操作路由</strong>：使用者層用 Layer 1 token，系統層用 Layer 2 credential，兩層都通過才放行。</p>
<h3 id="失效模式二把-layer-2-secret-放進-client">失效模式二：把 Layer 2 secret 放進 client</h3>
<p><strong>責任分工</strong>：Layer 2 secret 是「server 代表系統 A 對外的證明」，應留在 server 端的受信任執行環境。</p>
<p><strong>常見誤用</strong>：把 shared secret 寫進前端 JS、行動 app 編譯時、甚至 git public repo。</p>
<p><strong>風險判讀</strong>：client 環境（瀏覽器、mobile app）不在受控範圍。JS source 可在 devtools 直接看，mobile binary 可被反編譯出字串。Obfuscation 提高的是時間成本，沒有改變 secret 已散佈到不受信任環境的事實。</p>
<p><strong>操作路由</strong>：client 需要 B 的功能時，走「client → A → B」，由 A 在 server 端用 Layer 2 secret 呼叫 B；或用 OAuth 把 short-lived token 發給 client，long-lived secret 留在 server。</p>
<h3 id="失效模式三layer-3-依賴順序沒文件化">失效模式三：Layer 3 依賴順序沒文件化</h3>
<p><strong>責任分工</strong>：跨系統依賴順序是 API 契約的一部分，屬 publisher 的責任，需要在文件、SDK 或錯誤訊號中顯式表達。</p>
<p><strong>常見誤用</strong>：「呼叫 B 之前要先呼叫 A 的某個 endpoint」這個前置條件只存在於原始設計者的記憶中、文件沒寫、SDK 沒包、B 失敗時也只回 generic error。</p>
<p><strong>風險判讀</strong>：接手者看到「呼叫 B 失敗」時，會優先檢查 B 的文件、request 格式與 network 層。若真正根因是尚未呼叫 A 的 provision endpoint，偵錯路徑會被導到錯誤層級。</p>
<p><strong>操作路由</strong>（任選其一、優先序由上而下）：</p>
<ol>
<li>SDK 包裝、自動處理 provision、對外只暴露單一 API</li>
<li>B 主動回 <code>PROVISIONING_REQUIRED</code> error code、引導 client 補上前置呼叫</li>
<li>文件在「快速上手」段顯眼處註明依賴順序</li>
</ol>
<hr>
<h2 id="何時可以簡化三層">何時可以簡化三層</h2>
<p>三層框架的設計重點是「跨系統身分與 credential 分工」。當某一層回答的問題在架構裡不存在，設計可以縮小到實際存在的身分問題。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>簡化方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單體 application（沒有跨系統呼叫）</td>
          <td>只需 Layer 1。沒有 system-to-system 互動、Layer 2 / 3 不存在</td>
      </tr>
      <tr>
          <td>內網微服務、共用 identity provider</td>
          <td>Layer 1 透過 service mesh 或共用 token 傳遞、Layer 2 可用 service mesh 內建 mTLS 取代手動 secret 管理</td>
      </tr>
      <tr>
          <td>後端 cron / batch job 之間互呼</td>
          <td>只需 Layer 2（system-to-system credential）、沒有使用者觸發、Layer 1 不適用</td>
      </tr>
      <tr>
          <td>兩個系統共用同一份 user DB</td>
          <td>可省略 Layer 3（身分天然對應），但 Layer 1 / 2 仍各自獨立</td>
      </tr>
  </tbody>
</table>
<p>簡化的判準是「<strong>該層回答的問題是否真實存在於這個架構</strong>」。單體 application 沒有跨系統呼叫時，Layer 2 的 caller 驗證可以省略；兩個系統共用同一份 user DB 時，Layer 3 的身分對應 workflow 可以省略。</p>
<p>簡化不等於降低基礎安全前提。HTTPS / TLS 與 token 儲存原則（hash + constant-time）是任何 Layer 1 的最低要求，跟「層」的數量無關。</p>
<hr>
<h2 id="收尾">收尾</h2>
<p>兩層信任邊界 + 一個身分對應 workflow：</p>
<ul>
<li><strong>Layer 1（使用者）</strong>：解決「你是誰」 — 用 Bearer Token、注意 capability credential 的暴露成本</li>
<li><strong>Layer 2（系統）</strong>：解決「哪個系統呼叫的」 — 用 Shared Secret / API Key / OAuth / mTLS、secret 不離 server</li>
<li><strong>Layer 3（Provisioning workflow）</strong>：解決「兩邊身分怎麼對上」 — 不是新的 secret、是 lifecycle 動作</li>
</ul>
<p>設計後端 API 時，先把這三個問題分開，secret 機制的選擇會變清楚。若排障訊號是「這個 token 在那邊不能用」，下一步是先判斷它卡在使用者層、系統層，還是 provisioning workflow。</p>
<h3 id="各層的深入文章">各層的深入文章</h3>
<p>本文聚焦「為什麼要分層」的心智模型、各層的具體實作細節都另有獨立文章：</p>
<ul>
<li><strong>Layer 1（使用者）</strong> → <a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>：<code>{PK}|{secret}</code> format 為什麼這樣設計、DB 儲存三原則、各語言 constant-time 函式對照、跟 GitHub / Stripe 的設計比較</li>
<li><strong>Layer 2（系統）→ Shared Secret 維運</strong> → <a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a>：雙密過渡期、自動化 rotation 工具（AWS Secrets Manager / Vault / GCP）、緊急 vs 定期流程、多 client 同步難題</li>
<li><strong>Layer 2（系統）→ mTLS 部署</strong> → <a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a>：CA 階層、憑證生命週期、撤銷機制（CRL / OCSP / short-lived）、nginx / Envoy / service mesh 整合</li>
</ul>
<h3 id="沒展開的延伸議題">沒展開的延伸議題</h3>
<p>JWT 的簽章演算法選擇、<code>alg: none</code> 攻擊、token rotation 的具體實作、零信任網路下的 service-to-service 認證、OAuth flow 的完整 lifecycle、SSO（SAML / OIDC）跟本文三層的對應關係。每個都值得獨立成篇、本文聚焦在「先把層數想清楚」這個前置問題。</p>
]]></content:encoded></item></channel></rss>