第 10 課 · 持久化與多租戶——讓記憶撐過重啟,讓租戶彼此看不見
你的產品有了第二個付費客戶。恭喜——也正是從這一刻起,兩個在你筆電上「完全沒問題」的東西,變成了兩顆隨時會引爆的雷。
第一顆雷:星期五下午,你部署了一個小修正。
就一行文案的改動,你很有把握。git push、服務重啟、三十秒後新版上線。你去泡了杯咖啡。
星期一早上,客服信箱炸了。每一個客戶的週報都在說同一句怪話:
「本週首次追蹤 Acme Planner,建立基準。」
你上週才替他們養出來的、那份「記得 Acme 上週賣 $12」的記憶——在星期五那次重啟裡,蒸發得一乾二淨。 每一個競品都變回「第一次見到」,diff 全滅。你花了整個第 7 課建起來的護城河,被你自己一次 git push 填平了。
第二顆雷:客戶 A 打開他的 control panel,看到了客戶 B 在追哪些競品。
在別的產品裡這已經夠糟,但在這個產品裡格外致命——因為你的客戶,很可能彼此就是競爭對手。你把「globex-inc 正在盯 acme-corp 的定價」這條情報,親手漏給了 acme-corp。這不是一張 bug ticket,是一封律師函,外加所有客戶連夜退訂。
請注意,這兩顆雷都不是「模型不夠好」。第 7 課的 checkpointer 語意、第 6 課的 interrupt() / resume,邏輯全對。問題出在後端存哪、怎麼分:
- 記憶存在記憶體(
MemorySaver)或一個本機檔案(SqliteSaver)裡——撐不過重啟、撐不過部署,更別說撐過「你的服務其實同時跑在好幾台機器上」。 - 而且它根本沒按客戶分開——第 7 課的
thread_id只讓你「先看到分層的形狀」,還沒有一道真正攔住「A 讀到 B」的閘門。
這一課就是把這兩個洞一次補上。補完,你手上的東西才第一次配得上「可以對外收費」這句話。
這一課我們回答三個問題:
- 怎麼把第 7 課那個示範用的 checkpointer,換成真正的 Postgres 持久化,讓 state 跨行程重啟、跨部署、跨機器都還在?
- 多租戶(multi-tenant)怎麼做——用 thread routing 把「哪個客戶的哪個競品追蹤線」對應到正確的 thread,並且確保租戶之間資料完全隔離,A 絕不可能看到 B?
- 為什麼「每個付費客戶的狀態各自持久、互不干擾」,是這門生意能對外收費、能規模化的前提?
💡 先把邊界釘死,免得你等它教:
- 這一課只做「換 Postgres 後端 + thread routing + 租戶隔離」。
- 把每個租戶的用量換成錢、開發票 → 第 11 課(用量計費,metered billing)。
- 把這張 graph 包成一個真能對外服務的 HTTP server(CORS、金鑰、觀測硬化)→ 第 12 課。這一課的路由只寫成一個「用例函式」,不是完整伺服器。
- checkpointer 內部怎麼把 state 寫進資料庫、怎麼做 migration 的原理 → 姊妹系列 ai-pipeline-from-scratch。這一課照舊只教「用 LangGraph.js 現成原語出貨」——手刻懂原理,框架去出貨。
- 老規矩:路由/隔離這段純邏輯用 stub/假 DB 離線測到死;真連 Postgres 的部分當示範。
一、換上 Postgres:讓記憶撐過重啟、撐過部署
先回收第 7 課留下的那句承諾。當時我們說:checkpointer 是一個可抽換的介面,換後端只換一行。 MemorySaver 拿來看語意、跑測試(程序一重啟就全沒),SqliteSaver 撐得過本機重啟。而「上線接 Postgres」被明明白白地標成「第 10 課的事」。現在就是那一課。
為什麼是 Postgres,而不是繼續用 SQLite
SQLite 已經能撐過「同一台機器上、同一個檔案、一個程序重啟」了,為什麼還要換?
因為你上線後的架構,幾乎一定是「多行程」的。把一個真能收費的產品攤開來看,至少有兩種行程在同時動:
- web 服務:接使用者從 control panel 來的請求(「立即重跑一次 Acme」「打開待審卡」)。而且為了不當機(HA),它通常不只一個 instance——負載平衡器後面掛著兩、三台。
- 排程器:第 1 課就講過,這產品是排程觸發的——週一凌晨三點沒人盯著也要自己跑。那個 cron 排程器是另一個獨立行程,跟 web 服務不是同一個。
這幾個行程,全都得讀寫同一份「上週的記憶」。web instance A 上使用者按了「立即重跑」,排程器凌晨也要跑同一條 thread——它們必須看到彼此寫的東西。
這正是 SQLite 撐不住的地方,也是把界線講清楚的地方:
⚠️ SQLite 是單一檔案。同一台機器上勉強能多行程共用(靠檔案鎖),但一並發就開始「database is locked」;而它跨機器完全不行——檔案在本機磁碟上,另一台機器根本碰不到。你的 web instance B 和排程器只要不在同一台機器,就各看各的記憶,等於沒有共享。
Postgres 是一個跑在網路上的資料庫服務:多個行程、多台機器可以同時連上來、並發讀寫同一份資料。它就是那個「大家都連得到的單一真相來源」。
flowchart TB
subgraph APP["你的上線架構(幾乎一定是多行程)"]
W1["web instance A<br/>使用者按「立即重跑」"]
W2["web instance B<br/>另一台,做 HA"]
SCH["排程器行程<br/>每週一 03:00 自己跑"]
end
PG[("Postgres<br/>checkpointer + Store 的表")]
W1 -->|"invoke / getState"| PG
W2 -->|"invoke / getState"| PG
SCH -->|"invoke"| PG
💡 準確地說,重點不是「非 Postgres 不可」,而是「跨行程、跨機器共享的資料庫」。MySQL、雲端託管的 Postgres、任何你團隊熟的關聯式資料庫都行。這一課用 Postgres,是因為 LangGraph.js 官方就有一個現成的
PostgresSaver,你不用自己接。SQLite /MemorySaver也沒有被淘汰——它們依舊是你跑測試、寫離線 demo 最省事的選擇,只是別拿去上線。
換 checkpointer:真的只換一行
先裝套件(Postgres 版的 checkpointer 底層用 pg):
npm i @langchain/langgraph-checkpoint-postgres pg
第 7 課那張 graph(buildGraph(checkpointer))一個字都不用改。我們只是把「建 checkpointer」這件事換成 Postgres 版,並且——這很重要——一個行程只建一次(理由跟第 6 課的 graph 單例一樣:連線池很貴,不能每個請求開一個):
// db.ts —— 一個行程只建一次的 Postgres checkpointer(單例)
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
let saver: PostgresSaver | null = null;
export function getCheckpointer(): PostgresSaver {
if (saver) return saver;
const conn = process.env.DATABASE_URL;
if (!conn) throw new Error("請先設定 DATABASE_URL");
// fromConnString 內部會建一個連線池;整個行程共用這一個 saver
saver = PostgresSaver.fromConnString(conn);
return saver;
}
Postgres 需要先有「放 checkpoint 的那幾張表」。PostgresSaver 幫你把建表包成一個 setup()——你把它當成一次性的 migration 來跑,不是每次啟動都跑:
// setup-db.ts —— 部署時跑一次,建好 checkpointer 需要的表(像一次 migration)
import { getCheckpointer } from "./db";
async function main() {
await getCheckpointer().setup(); // 建表;已存在就跳過,重複跑是安全的
console.log("checkpointer 的表建好了");
process.exit(0);
}
main();
⚠️
setup()要當成部署步驟跑一次,別塞進每個 web instance 的開機流程。三台 instance 同時開機、同時setup()去建同一批表,會撞在一起。把它放進你的部署 pipeline(npm run migrate那類步驟)跑一次就好——這正是 no-logic-trade/devops 講的「migration 是部署的一環,不是應用啟動的一環」。
證明它真的跨行程還在
換 Postgres 到底買到了什麼?買到了「state 不在任何單一行程的記憶體裡,而在資料庫裡,誰連上都拿得到」。這句話值得用兩個各自獨立的行程證給你看——這是 MemorySaver 打死做不到的事。
行程 A:跑一次 graph,把某條 thread 的 state 寫進 Postgres,然後整個退出:
// writer-process.ts —— 行程 A:跑一次 graph 寫入 state,然後退出
import { getCheckpointer } from "./db";
import { buildGraph } from "./graph"; // ← 第 7 課那張 graph,原封不動
async function main() {
const graph = buildGraph(getCheckpointer()); // 唯一的差別:checkpointer 是 Postgres 版
const threadId = "acme-corp::acme-planner";
await graph.invoke(
{ competitor: "Acme Planner" },
{ configurable: { thread_id: threadId, asOfWeek: 27 } },
);
console.log(`行程 A:已把 ${threadId} 的 state 寫進 Postgres,我要退出了`);
process.exit(0); // 行程結束、記憶體清空——但 state 不在記憶體,在 Postgres
}
main();
行程 B:一個全新的行程,它從沒跑過 graph,只是連上同一個 Postgres,把上面那條 thread 的 state 讀回來:
// reader-process.ts —— 行程 B:全新行程,只讀回 state,證明記憶不在行程 A 的記憶體裡
import { getCheckpointer } from "./db";
import { buildGraph } from "./graph";
async function main() {
const graph = buildGraph(getCheckpointer());
const threadId = "acme-corp::acme-planner";
// getState 就是第 7 課那個「掀開 checkpointer 看它存了什麼」的方法
const snap = await graph.getState({ configurable: { thread_id: threadId } });
console.log("行程 B:我沒跑過任何 graph,卻從 Postgres 讀回了這條 thread 的 lastSnapshot:");
console.log(snap.values.lastSnapshot);
// → { price: 15, features: [...], headline: "團隊協作", fetchedAt: "2026-07-06..." }
process.exit(0);
}
main();
跑起來(示範用 Docker 起一個 Postgres):
# 起一個本機 Postgres
docker run -d --name digest-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:16
export DATABASE_URL="postgresql://postgres:dev@localhost:5432/postgres"
npx tsx setup-db.ts # 建表(一次)
npx tsx writer-process.ts # 行程 A:寫入後「退出」
npx tsx reader-process.ts # 行程 B:另一個全新行程,卻讀得到!
停下來品這件事:行程 B 從沒執行過 graph,行程 A 也早已退出、記憶體早被作業系統回收——但 lastSnapshot 還在。因為它從一開始就不在任何行程的記憶體裡,它在 Postgres。把行程 B 想成「另一台機器上的 web instance B」或「凌晨三點才醒來的排程器」,畫面就對了:只要連的是同一個 Postgres,上週的記憶就一直在那裡等著被撈回。 換成 MemorySaver,行程 A 一 process.exit,記憶就跟著陪葬了。
💡 第 6 課那道 HITL 關卡,到這裡才真正站得住。 第 6 課我們用
interrupt()把 graph 停在待審卡、等分析師隔天上班再 resume,當時用MemorySaver帶過,並老實說「server 一重啟,所有待審中的現場就沒了」。現在把 checkpointer 換成 Postgres,那個「凌晨三點停住的現場」就寫進了資料庫——分析師換一台電腦、隔一天、就算你中間部署過新版,只要thread_id對得上,待審卡都還在。第 6 課埋的伏筆,在這一行PostgresSaver上兌現。
⚠️ 一個上線才會咬你的邊界:Postgres 有連線數上限。每個
PostgresSaver都握著一個連線池,instance 一多,連線就可能爆。真要上量時,前面要擺一個連線池代理(如 PgBouncer)——那屬於上線硬化,回指 no-logic-trade/devops,這一課先把單例做對就好。
到這裡,第一個問題答完了:把 checkpointer 從 MemorySaver / SqliteSaver 換成 PostgresSaver.fromConnString(...)(一個行程只建一次、setup() 當 migration 跑一次),graph 其餘一行不改,state 就從「某個行程的記憶體」搬到了「大家共用的 Postgres」——跨行程、跨部署、跨機器都還在。
二、thread routing:把「哪個客戶的哪個競品」路由到正確的 thread
記憶持久了,但它現在是一鍋大雜燴——所有客戶的 thread 混在同一個 Postgres 裡。多租戶(multi-tenant)要處理的就是:怎麼確保每個付費客戶只碰得到自己的資料,A 永遠看不到 B。
先接回第 7 課。那課我們用的 thread_id 是 "acme-corp::acme-planner"——帳號::競品 的形狀。但第 7 課也講得很白:那只是「先讓你看到分層的形狀,還不是完整的租戶隔離方案」。這一節就把它補成完整方案。
核心觀念:thread_id 是一把鑰匙,隔離 ≡ 這把鑰匙怎麼構造
回想第 7 課 checkpointer 的工作方式:它拿 thread_id 當鑰匙,去撈對應那條 thread 的 state。把這件事反過來看,一個讓人背脊發涼的事實就浮出來了:
誰能構造出正確的
thread_id,誰就能讀到那條 thread 的 state。
也就是說,在這個架構裡,「租戶隔離」這件大事,本質上等於一件小事:thread_id 是怎麼被構造出來的。 構造規則對了,隔離就成立;構造規則有一個缺口,隔離就破。
而 thread routing 就是負責構造這把鑰匙的那段邏輯——它把「哪個客戶的哪個競品追蹤線」這個業務問題,翻譯成「正確的 thread_id」這個技術答案。注意它是一段純邏輯:不碰 LLM、不碰網路、坐在 seam 上,可以離線測到死。
頭號殺手:讓前端直接傳 thread_id
先看錯的做法,因為它太自然、太多人會踩。你可能想這樣開端點:
// ❌ 災難:thread_id 由前端說了算
// POST /api/digest/rerun?thread_id=acme-corp::acme-planner
app.post("/api/digest/rerun", async (req, res) => {
const threadId = req.query.thread_id as string; // ← 致命
await graph.invoke(input, { configurable: { thread_id: threadId } });
});
看起來能跑。但 acme-corp 的使用者只要把網址裡的 acme-corp 改成 globex-inc,重送一次——他就讀到、甚至覆寫了 globex-inc 的競品情報。 這個經典漏洞有個名字叫 IDOR(Insecure Direct Object Reference,直接把物件識別碼交給使用者、又不檢查他有沒有權限碰)。在競品情報這產品裡,它不是小 bug,是「把 A 的情報送給 A 的對手」的公司級事故。
問題的根源一句話:你讓「租戶身分」這個最該可信的東西,來自「使用者說他是誰」(請求參數),而不是「他被驗證成誰」(登入)。
正解:租戶部分永遠來自認證,後端自己構造
規則反過來就對了:
thread_id裡的「租戶」那一段,永遠由後端從「已認證的身分」構造,絕不接受前端傳來的租戶部分。
前端只需要說「我要看我追的 Acme」;後端從 session / JWT 拿到「你是 acme-corp」,自己把 thread_id 拼出來。租戶身分來自「你被驗證成誰」,不是來自「你在網址裡打了誰」。
把這段路由邏輯寫成純函式 + 一個可注入的「租戶目錄」介面(真實版查資料庫,測試版塞假 DB):
// routing.ts —— 純邏輯 + 可注入的目錄介面。整個檔案不碰 LLM、不碰網路。
// 「這條競品追蹤線屬不屬於這個租戶」的查詢,抽成介面:真實版查 Postgres/Store,測試版用假 DB。
export interface TenantDirectory {
ownsCompetitor(tenantId: string, competitorId: string): Promise<boolean>;
}
/**
* 從「可信的租戶 id」+「競品 id」構造 thread_id。
* ⚠️ tenantId 一定來自認證(你被驗證成誰);competitorId 來自請求(你想看誰)。
* 租戶固定放最前面,thread_id 天生就帶著租戶前綴。
*/
export function threadIdFor(tenantId: string, competitorId: string): string {
return `${tenantId}::${competitorId}`;
}
export type RouteResult =
| { ok: true; threadId: string }
| { ok: false; reason: "not_found" };
/**
* thread routing 的閘門:先驗歸屬,再構造 thread_id。
* 只有「這個競品確實屬於這個租戶」,才給得到 thread_id。
*/
export async function resolveThread(
dir: TenantDirectory,
tenantId: string, // 可信:來自認證
competitorId: string, // 待驗:來自請求
): Promise<RouteResult> {
const owns = await dir.ownsCompetitor(tenantId, competitorId);
if (!owns) {
return { ok: false, reason: "not_found" }; // ⚠️ 回 not_found,不是 forbidden——見下方
}
return { ok: true, threadId: threadIdFor(tenantId, competitorId) };
}
這裡藏了兩個容易被忽略、但很要命的設計決定,各花三秒:
⚠️ 為什麼越權要回
not_found(404),不是forbidden(403)? 因為 403「你無權存取這個競品」等於向攻擊者確認了「這個競品存在、只是不屬於你」——這本身就是情報洩漏(你證實了 globex-inc 在追某個競品)。跨租戶邊界一律回 404「查無此物」,不確認存在性。邊界講清楚:這條規則是針對「跨租戶」的;在同一租戶內部的權限不足(例如唯讀成員想改設定),用 403 反而更清楚——別把存在與否洩漏過租戶的牆,是這條規則的真正目的。
💡 注意真正擋住攻擊的是「歸屬檢查」,不是「字串清洗」。 就算攻擊者把
competitorId塞成"../../globex-inc::rival"這種怪字串,ownsCompetitor拿它去比對「這個租戶追蹤名單裡有沒有這一筆」,比對是精確相等——怪字串根本不在名單裡,直接not_found。安全來自「只有名單裡的 id 才過關」,不是來自你有沒有記得去 sanitize 那個字串。這讓你少擔一整類「我是不是漏濾了某個字元」的心。
離線測:證明 A 路由不到 B 的 thread
因為 resolveThread 是純邏輯 + 注入的目錄,整個租戶隔離的核心可以用假 DB、零金鑰、不需要真 Postgres測到死。這正是全系列 seam 紀律的回報——產品裡最不能出錯的那段(隔離),剛好是最好測的那段。
// routing.test.ts —— 假 DB,離線、零金鑰、不碰真 Postgres
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveThread, type TenantDirectory } from "./routing";
// 假 DB:一張寫死的「誰在追誰」表
class FakeDirectory implements TenantDirectory {
constructor(private owns: Record<string, Set<string>>) {}
async ownsCompetitor(tenantId: string, competitorId: string): Promise<boolean> {
return this.owns[tenantId]?.has(competitorId) ?? false;
}
}
const dir = new FakeDirectory({
"acme-corp": new Set(["rival-x", "rival-y"]),
"globex-inc": new Set(["rival-x"]), // 注意:兩家都在追 rival-x
});
test("拿自己追的競品 → 路由到帶自己前綴的 thread", async () => {
const r = await resolveThread(dir, "acme-corp", "rival-x");
assert.deepEqual(r, { ok: true, threadId: "acme-corp::rival-x" });
});
test("兩家追同一個競品,thread_id 卻完全不同 —— 天然隔離", async () => {
const a = await resolveThread(dir, "acme-corp", "rival-x");
const b = await resolveThread(dir, "globex-inc", "rival-x");
assert.ok(a.ok && b.ok);
assert.notEqual(a.threadId, b.threadId); // acme-corp::rival-x ≠ globex-inc::rival-x
});
test("越權:拿別的租戶才有的競品 → not_found,絕不吐 thread_id", async () => {
// globex-inc 並沒有追 rival-y(那是 acme-corp 的)
const r = await resolveThread(dir, "globex-inc", "rival-y");
assert.deepEqual(r, { ok: false, reason: "not_found" });
});
第二個測試是整套隔離最漂亮的證明:acme-corp 和 globex-inc 都在追同一個競品 rival-x,但它們的 thread_id 一個是 acme-corp::rival-x、一個是 globex-inc::rival-x——同一個競品、兩條完全不同的 thread、兩份互不相見的上週記憶。 第三個測試則把「越權」釘死:拿別人租戶才有的競品,永遠只換得到 not_found。這兩條紅線一旦哪天被改壞,這幾個毫秒級的測試會立刻在 CI 裡尖叫。
到這裡,第二個問題答了一半:thread routing 就是 resolveThread 這道純邏輯閘門——它拿可信的租戶身分去驗證競品歸屬,只有歸屬成立才構造出 帳號::競品 的 thread_id。租戶部分絕不來自前端,所以 A 構造不出 B 的鑰匙。
flowchart TB
A["已認證租戶身分<br/>tenantId(可信,來自 session/JWT)"]
C["前端請求想看的<br/>competitorId(需驗證歸屬)"]
A --> G{"resolveThread<br/>純邏輯閘門:這競品屬於這租戶嗎?"}
C --> G
G -->|"是"| T["thread_id = tenantId::competitorId"]
G -->|"否"| N["404 查無此物<br/>(不確認存在性)"]
T --> CP[("checkpointer<br/>用 thread_id 撈這條 thread 的 state")]
三、隔離是兩層:checkpointer 與 Store 用同一把租戶鑰匙切
上一節的 thread_id 隔離了 checkpointer——每條 thread 各自的抽屜。但第 7 課還有一個搭檔:Store(跨 thread 記憶),放的是「這個帳號對所有競品共用的東西」——使用者偏好、標紅門檻、這個帳號在追的競品名單。
Store 也必須按租戶切。否則你把隔離做了一半:競品的上週價格分開了,但「globex-inc 在追哪些競品、偏好什麼」卻漏給了 acme-corp——同樣是災難。
好消息是,Store 的隔離用的是同一把鑰匙。回想第 7 課的 Store namespace:
// 第 7 課的 Store namespace,第二段就是租戶:
const ns = ["account", authedTenantId, "preferences"];
// ^^^^^^^^^^^^^^^ 和 thread_id 一樣,這一段永遠來自認證
於是整個隔離就是兩層、一把鑰匙——那把鑰匙就是「可信的 tenantId」:
| 層 | 原語 | 隔離鑰匙 | 放什麼 |
|---|---|---|---|
| 單一競品的跨週狀態 | checkpointer | thread_id = tenant::competitor |
上週 snapshot |
| 帳號層的跨 thread 記憶 | Store | namespace = ["account", tenant, ...] |
偏好、門檻、競品名單 |
Postgres 版的 Store 跟 checkpointer 是平行的——同一個套件也提供 PostgresStore,用法跟 PostgresSaver 一模一樣(fromConnString + setup()),掛上 graph 的方式也和第 7 課相同(.compile({ checkpointer, store })):
// store.ts —— Postgres 版 Store,用法與 checkpointer 平行
import { PostgresStore } from "@langchain/langgraph-checkpoint-postgres";
let store: PostgresStore | null = null;
export function getStore(): PostgresStore {
if (store) return store;
store = PostgresStore.fromConnString(process.env.DATABASE_URL!);
return store;
}
// 部署時一併 await getStore().setup(),跟 checkpointer 的 setup 一起跑
把兩個租戶擺進同一個 Postgres 看,隔離的形狀就一目了然了——特別留意:兩家都在追 rival-x,但它們的 thread 和 Store 命名空間完全不交疊:
flowchart TB
PG[("單一 Postgres · 邏輯隔離")]
subgraph TENA["租戶 acme-corp 能碰到的"]
A1["thread:acme-corp::rival-x"]
A2["thread:acme-corp::rival-y"]
AS["Store:account/acme-corp/*"]
end
subgraph TENB["租戶 globex-inc 能碰到的"]
B1["thread:globex-inc::rival-x"]
BS["Store:account/globex-inc/*"]
end
PG --- TENA
PG --- TENB
⚠️ 這是「邏輯隔離」,不是「物理隔離」,把差別講清楚免得你日後被稽核問倒。 我們做的是:同一個 Postgres、同一批表,靠
tenantId這把 key 把資料分開。這對絕大多數 SaaS 完全夠用,也是業界主流。另一種是物理隔離——每個租戶一個獨立資料庫 / schema,成本高、維運重,通常是某個大企業客戶的合約(資料落地、合規)明文要求時才做。給規則也給邊界:先做邏輯隔離,等真有一紙合約逼你物理隔離再說——別為一個還沒出現的需求先付重稅。
一條非守不可的紀律:所有 thread_id 都走同一道閘門
邏輯隔離有個天生的軟肋:它把「隔離」全押在「thread_id / namespace 怎麼構造」這一小段純邏輯上。好處是這段可以測到死(上一節做了);代價是——只要有任何一個端點繞過這道閘門、直接拿前端傳的 thread_id 去跑,整道牆就破一個洞。 第二節那個 IDOR 反例,就是「繞過閘門」的具體長相。
所以紀律只有一條,但要焊死:
凡是租戶身分來自外部請求的路徑,都必須經過同一個
resolveThread閘門去換thread_id;不給任何端點「自己從請求裡撿thread_id」的機會。
這條規則也有它的邊界,講清楚才不會綁死你自己:排程器是例外。 排程器不吃前端輸入,它遍歷的是你自己 DB 裡「哪個租戶追哪些競品」的名單——租戶身分本來就可信,所以它可以直接 threadIdFor(tenantId, competitorId),不必再驗歸屬(下一節就會這樣寫)。規則的精神是「不信任外部請求給的租戶身分」,不是「所有地方都得跑一次歸屬檢查」。
四、接起來:一個「按租戶路由」的入口 + 掃全體租戶的排程器
現在把 Postgres 持久化 + thread routing + 雙層隔離收攏成一個真正跑得動的入口。
使用者觸發:一個按租戶路由的用例函式
先強調邊界:這不是完整的 HTTP server(CORS、金鑰、優雅關閉那些硬化是第 12 課)。這裡只寫「一個請求進來、路由到對的 thread、跑一次」的用例函式,把認證後拿到的租戶身分當作已經可信傳進來:
// use-case.ts —— 「按租戶跑一次 digest」。認證與 HTTP 外殼是第 12 課,這裡只做路由 + 執行。
import { buildGraphWithStore } from "./graph"; // 第 7 課那個「同時吃 checkpointer 與 Store」的版本
import { getCheckpointer } from "./db"; // Postgres checkpointer 單例
import { getStore } from "./store"; // Postgres Store 單例
import { resolveThread, type TenantDirectory } from "./routing";
// 一個行程只建一次的 graph 單例(理由同第 6 課:現場存在 checkpointer,不能每請求重建)
const graph = buildGraphWithStore(getCheckpointer(), getStore());
export class NotFoundError extends Error {}
/**
* @param authedTenantId 來自你的認證中介層(session/JWT 解出來的),可信。
* @param competitorId 來自請求 body/query,需驗證歸屬。
*/
export async function runDigestForTenant(
dir: TenantDirectory,
authedTenantId: string,
competitorId: string,
) {
const route = await resolveThread(dir, authedTenantId, competitorId);
if (!route.ok) throw new NotFoundError(); // 越權 → 查無此物(不洩漏存在性)
return graph.invoke(
{ competitor: competitorId },
{
configurable: {
thread_id: route.threadId, // ← 隔離 checkpointer 的鑰匙(tenant::competitor)
account: authedTenantId, // ← 隔離 Store 的鑰匙(第 7 課 diffNodeWithPrefs 用它讀帳號偏好)
},
},
);
}
盯著那個 configurable 看三秒——這一整課的重點全濃縮在這裡:
thread_id來自route.threadId,而route.threadId是用authedTenantId構造的 → checkpointer 這一層按租戶隔離。account直接就是authedTenantId→ 第 7 課那個diffNodeWithPrefs會拿它去 Store 撈這個帳號的偏好,Store 這一層也按租戶隔離。- 兩把鑰匙都來自
authedTenantId,沒有一個字來自前端能左右的部分。 前端只能決定「看哪個競品」,決定不了「以誰的身分看」。
💡 這裡順手回收第 7 課的
buildGraphWithStore(checkpointer, store):graph 本身、diffNodeWithPrefs節點、computeDiffWithRules那顆純邏輯——全都一行沒改。這一課從頭到尾沒動 graph 的內部,只做了兩件事:把後端換成 Postgres、在 graph 前面加一道路由閘門。這就是第 7 課那句「換更硬的後端只是換一行」的完整兌現。
排程觸發:掃過每個租戶 × 他追的每個競品
第 1 課定調過,這產品的主戲是排程觸發、沒人盯著也要跑。排程器是「另一個行程」,它不吃前端輸入,遍歷你 DB 裡的追蹤名單,替每一條 租戶 × 競品 各跑一次——每次都給它自己那把隔離鑰匙:
// scheduler.ts —— 每週一凌晨 03:00 由 cron 觸發的獨立行程
import { getCheckpointer } from "./db";
import { getStore } from "./store";
import { buildGraphWithStore } from "./graph";
import { threadIdFor } from "./routing";
const graph = buildGraphWithStore(getCheckpointer(), getStore());
// 真實版從 DB 讀出「每個租戶追哪些競品」;這裡示意形狀
interface TenantPlan { tenantId: string; competitors: string[]; }
export async function runWeeklyForAll(plans: TenantPlan[]) {
for (const { tenantId, competitors } of plans) {
for (const competitorId of competitors) {
// 排程是後端自己觸發、名單來自你自己的 DB、租戶可信,
// 所以直接構造 thread_id,不必再驗歸屬(第三節那條紀律的合法例外)
const threadId = threadIdFor(tenantId, competitorId);
await graph.invoke(
{ competitor: competitorId },
{ configurable: { thread_id: threadId, account: tenantId } },
);
}
}
}
這段看似平凡的雙層迴圈,藏著規模化的整個祕密。假設你有 100 個租戶、每人平均追 5 個競品:
- 那就是 500 條 thread,全部躺在同一個 Postgres、由同一張 graph、同一份程式碼服務。
- 你沒有為第 2 個或第 200 個客戶多開任何一套部署——只是多了幾條
thread_id。 - 而且這個排程器(一個行程)和使用者用的 web 服務(另外幾個行程)共用同一個 Postgres——這正是第一節說「非跨機器共享的資料庫不可」的原因:排程器凌晨寫進去的記憶,web instance 白天要讀得到。
到這裡,第二個問題完整答完了:thread routing(resolveThread / threadIdFor)把「哪個客戶的哪個競品」對應到 帳號::競品 的 thread_id;checkpointer 與 Store 兩層都用同一把「認證來的租戶鑰匙」切開;使用者入口過閘門驗歸屬、排程器走可信名單——租戶之間的資料,在鑰匙這一層就完全不交疊。
五、為什麼「各自持久、互不干擾」是收費與規模化的前提
最後拉高視角,回答第三個問題,也把這一課接回第 1 課那四根柱子——它補的是柱子 4(能收費)的地基。 你會發現,這一課做的兩件事(持久 + 隔離),恰好各自對應「能不能收費」的兩個前提。
前提一:能收費,因為你賣的護城河不會被自己每週摧毀一次。
第 7 課論證過,客戶付月費買的是那道護城河——替他一週一週累積下來的競品記憶。但如果這份記憶存在記憶體裡,那麼你每一次部署、每一次當機、每一次擴容重啟,都在親手清空客戶花錢買的東西。沒有人會為「每週一被你的 git push 洗掉一次記憶」的產品付費。把 checkpointer 換成 Postgres,這份記憶才真的一直在——上週、上上週、20 週前的歷史,deploy 不動它、重啟不動它、換機器也不動它。護城河得先立得住,才談得上收過河費。
前提二:能規模化,因為加一個客戶只是加幾條 thread,不是加一套部署。
第四節那個雙層迴圈就是答案:多租戶邏輯隔離意味著第 2 個和第 200 個客戶,共用同一張 graph、同一個 Postgres、同一份程式碼,差別只是 thread_id 前綴不同。這是 SaaS「邊際成本趨近於零」的技術長相——你的成本隨客戶數幾乎不變(多花的只是那幾條 thread 的 LLM 錢,那是第 8~9 課控制的單位經濟)。
對照反例會更痛:假設你沒做多租戶,每來一個客戶就手動開一套 MemorySaver 的 instance、各自一份設定——你到第 10 個客戶就被維運拖垮,到第 100 個就破產了。「一套服務、多條 thread」和「一個客戶、一套服務」之間,隔的正是這一課。
前提三:隔離,是這個產品敢把「對外收費」四個字說出口的信任門檻。
競品情報這門生意,隔離的賭注格外高——你的客戶名單裡,很可能就躺著彼此的競爭對手。 一次跨租戶洩漏,不是「不小心給錯了資料」,是「把 A 的商業情報,送到了 A 最不想給的那個人手上」。這種事發生一次,賠掉的不是一個客戶,是所有客戶對你的信任,外加法務問題。隔離做對了,你才敢在銷售頁上寫「你的競品情報只有你看得到」——而這句話,正是把一個免費工具變成一門可收費生意的分水嶺。
把三件事收攏:這一課補的,是柱子 4(能收費)的地基。 下一課的用量計費(metered billing)之所以能成立,前提就是這一課——你不可能對一個「大家共用、隨時失憶」的東西按用量開帳單。
⚠️ 誠實把邊界講清楚:應用層的邏輯隔離不是銀彈。 它把「隔離」集中在「
thread_id/ namespace 怎麼構造」這一小段純邏輯上——好處是可測(這課測了),代價是這段一旦有個端點繞過去,牆就破。所以它是第一道、也最便宜的一道防線,不是唯一一道。真正的生產環境還會在資料庫層再補一層(連線權限最小化、row-level security 等),那屬於上線硬化,回指 no-logic-trade/devops。這一課的重點是:把應用層這把鑰匙構造對——這是你花力氣最少、擋掉事故最多的一道。
這一課建立了什麼
把這一課收攏成你可以帶走的心智模型:
- Postgres checkpointer = 把第 7 課的
MemorySaver/SqliteSaver換成PostgresSaver.fromConnString(...),graph 其餘一行不改。上線幾乎一定是多行程的(多個 web instance + 獨立排程器),它們得共用一份記憶——這是 SQLite(單檔、跨機器不行)撐不住、非「跨行程跨機器共享的資料庫」不可的原因。紀律:checkpointer 一個行程只建一次(單例)、setup()當 migration 跑一次。它讓 state 跨行程、跨部署、跨機器都還在——也讓第 6 課那道 HITL 待審現場真正撐得過重啟。 - thread routing = 把「哪個客戶的哪個競品」翻譯成正確
thread_id的那段純邏輯(resolveThread/threadIdFor)。核心心法:thread_id是鑰匙,隔離 ≡ 這把鑰匙怎麼構造。 鐵律:租戶那一段永遠來自認證、由後端構造,絕不接受前端傳的thread_id(否則就是 IDOR,改個字就讀到別人)。越權回 404 不回 403(不洩漏存在性);擋住攻擊的是歸屬檢查(精確比對名單)而非字串清洗。 - 雙層隔離、一把鑰匙 = checkpointer(
thread_id = tenant::competitor)與 Store(namespace = ["account", tenant, ...])都用同一把「可信租戶鑰匙」切開。我們做的是邏輯隔離(同一 Postgres、靠 key 分開),主流且夠用;物理隔離留給有合規合約逼你時。守則:所有租戶身分來自外部請求的路徑,都走同一個resolveThread閘門(排程器走可信名單,是合法例外)。 - 接起來 = 使用者入口
runDigestForTenant(過閘門、thread_id與account都來自authedTenantId)+ 排程器runWeeklyForAll(掃全體租戶 × 競品)。100 租戶 × 5 競品 = 500 條 thread,同一張 graph、同一個 Postgres——這就是規模化的長相。 - 為什麼這是收費與規模化的前提:持久(護城河不被自己每週清空)+ 隔離(加客戶只是加 thread、且客戶敢信任你)= 柱子 4(能收費)的地基。邊界:邏輯隔離是第一道也最便宜的防線,不是銀彈,生產環境還要配 DB 層防線(回指 devops)。
你現在手上這台機器,已經能替每一個付費客戶,各自持久、互不干擾地記得上週了。萬事俱備——只差最後一角:每個客戶這個月到底跑了幾次、用了多少,該收他多少錢? 好消息是,這一課已經替下一課鋪好路了:因為每一次執行的 thread_id 裡就寫著「這是誰」,用量天生可歸屬。下一課(第 11 課)我們就把這份可歸屬的用量,用 Stripe 變成一張真正的帳單——用量計費(metered billing),補上「能對外收費」的最後一塊。