第 12 課 · 上線硬化與收尾:LangGraph.js 沒有 HTTP server,你得自己包

第 12 課 · 上線硬化與收尾:LangGraph.js 沒有 HTTP server,你得自己包

十一課下來,你手上這台機器已經很像樣了。它會抓多來源(第 3 課)、平行盯 N 個競品又去重(第 4 課)、把中間結果串進 React control panel(第 5 課)、送出前停下來讓分析師審一眼(第 6 課)、記得上週只報 diff(第 7 課)、每個節點都有預算閘、便宜的活丟便宜模型、重複的不重付(第 8 課)、算得出每跑一次賺不賺(第 9 課)、狀態落在 Postgres 且每個租戶資料隔離(第 10 課)、用量還能變成 Stripe 帳單(第 11 課)。

你在 terminal 打 npx tsx run.ts,它跑得漂漂亮亮。於是你順手打了 npm start,期待看到一行 Server running on http://localhost:...——

什麼都沒有。 因為 LangGraph.js 從來沒答應要給你一台伺服器。它給你的,自始至終就是一張會跑的 graph

一個付你月費的陌生人不會 npx tsx。他要的是一個網址、一個能登入的頁面、一封準時到的信。從「在我機器上會跑」到「一個陌生人在網際網路上付費用它」,中間還差最後一哩——把這張 graph 包成一個能對外服務的東西。這一哩,就是這門課的最後一課。這也是第 1、2、5、6 課每次講到「LangGraph.js 沒有內建 HTTP server」時,一直答應你「第 12 課會處理」的那件事。今天兌現。

這一課回答四個問題:

  1. 為什麼 LangGraph.js 只是「一張會跑的 graph」、沒有內建 HTTP server? 要上線得自己用 Node 包一層,這層到底要處理哪些事——路由、CORS、金鑰保護、租戶鑑權
  2. 怎麼替這個 AI 產品加上觀測/tracing,讓每次執行的步驟、花費、失敗都看得見(呼應第 8–9 課的預算與單位經濟)?
  3. 部署與維運怎麼承接既有資源——直接回指 no-logic-trade 的 devops 子站當上線參照,不用在本系列重教一次 CI/CD 與雲端?
  4. 收尾:把四件事(全端+控制節點與狀態+控制預算+能收費)合起來看,這個產品接下來怎麼長大(更多來源、用 Store 做長期記憶、用 subgraph 拆更多競品類型)?

💡 這一課刻意只引用、不重定義。checkpointer、Store、diff、預算閘、多租戶、metered billing……前十一課都建立好了,這裡全部直接用。它是全系列的收尾,不是新原語的定義專章。


一、為什麼 LangGraph.js 沒有 HTTP server——而這其實是好事

先把這件事想通,你才不會覺得框架「缺了一塊」。

LangGraph.js 是一個引擎,不是一台車。.compile() 出來的 graph,本質上是一個函式——你餵它一份 input、給它一個 thread_id,它跑一趟、吐回結果(graph.invoke),或者一段段串流出中間結果(graph.stream)。就這樣。它不知道 HTTP、不知道 CORS、不知道誰在呼叫它、不在乎結果要送去瀏覽器還是寫進檔案。

這跟 Express、Fastify 那類東西根本是兩種生物:後者本身就是「伺服器」,listen(3000) 就在等連線了。LangGraph.js 沒有 listen——它是要被你放進一個伺服器裡、由那個伺服器在收到請求時去呼叫的一顆大腦。

flowchart LR subgraph BROWSER["瀏覽器:control panel(React)"] UI["情報儀表板 / 待審卡"] end subgraph NODE["你要自己寫的這一層 Node 服務(本課)"] MW["路由 · CORS · 金鑰 · 租戶鑑權"] GRAPH["graph(大腦)<br/>graph.stream / invoke"] MW --> GRAPH end PG[("Postgres:checkpointer + Store(第 10 課)")] LLM["LLM / Stripe(金鑰只存在這一層)"] UI -->|"HTTPS + Bearer token"| MW GRAPH <-->|"按 thread_id 撈回/存回"| PG GRAPH -->|"seam"| LLM

看這張圖:graph 待在正中間,外面裹著一層你自己寫的 Node 服務。 那層服務負責跟網際網路打交道(路由、鑑權、CORS、藏金鑰),graph 只負責「想事情」。這其實是一個很乾淨的關注點分離——框架不去猜你要用哪個 web 框架、要怎麼鑑權、要不要 CORS,把這些本來就該你決定的事留給你。

而且你早就一直這樣用它了:第 5 課那個 graph.stream 是包在一個 node:http 伺服器裡吐給前端的;第 6 課那兩個 GET /pendingPOST /review 端點,是把 graph.getStategraph.invoke(new Command(...)) 掛在 Express 上。「自己包一層」不是這一課才冒出來的新工作,是我們從第 5 課就在做、現在要把它從『本機能跑』硬化成『能上線收費』的那一層。

⚠️ 講清楚邊界,免得你以為「沒 server=只能全手工」:官方另有一個要付費的託管產品叫 LangGraph Platform,可以幫你把 graph 跑成一個帶 API 的服務,省掉自己包這層。它是獨立的一層、不是 @langchain/langgraph 內建的。我們選擇自己用 Node 包,有三個對「TS 母語的獨立開發者」很實在的理由:(1) 可攜——它就是一個普通 Node 服務,能部署到任何地方,不綁單一供應商;(2) 便宜——省掉一份託管費;(3) 承接你已有的技能——把 Node 服務送上線,你(或第 9 課那個 no-logic-trade 後端)早就會了。至於已經停止維護的 LangServe(第 1 課提過),別用了。

所以第一個問題的前半有答案了:LangGraph.js 給你一張 transport-agnostic 的 graph,把「怎麼對外服務」留給你決定。 接下來的整節,就是把「你決定」的那些事——路由、CORS、金鑰、租戶鑑權——一件件做對。


二、把 graph 包成服務:路由、CORS、金鑰、租戶鑑權

這一層 Node 服務,說穿了要做四件事。我們一件件來,並且守住全系列的紀律:能用純邏輯、能離線測的,就切出來(第 2 課的 seam 精神);真打 LLM/Stripe 的,藏在這層背後。

先把四件事一句話擺上桌,你就有地圖了:

這層要做的事 一句話 做錯了會怎樣
路由(routing) 把 HTTP 端點對到 graph 的操作(跑一次、撈待審、送決定) 前端沒有可呼叫的入口
租戶鑑權(tenant auth) 確認「你是誰」,並用它把 thread_id/Store 圈進這個租戶 租戶 A 讀到租戶 B 的競品記憶——產品直接完蛋
金鑰保護(key protection) LLM/Stripe 金鑰只活在這層,永不進瀏覽器 金鑰外洩,任何人拿你的錢去打模型
CORS 只讓你自己的前端網域能從瀏覽器打這個 API 要嘛全世界都能打,要嘛你自己的前端被擋

2.1 最危險的那件事先做:租戶隔離是一段純邏輯

四件事裡,做錯了最致命的是租戶鑑權,所以先做它,而且先做它最核心、最該被測試盯死的那一小塊。

回想第 7 課和第 10 課:我們追蹤的每一個競品是一條 threadthread_id 長得像 "acme-corp::acme-planner"——前半是租戶、後半是競品。checkpointer 就是拿這個 thread_id 當鑰匙去撈「上週的 Acme」。這裡藏著一個你一旦寫錯就會出大事的地方:

thread_id 裡的租戶部分,絕對不能相信客戶端傳來的值。 它必須由伺服器從『已鑑權的身分』推導出來。

為什麼?想像你的端點長這樣(這正是第 6 課為了教控制流、刻意簡化的寫法):POST /api/digest/:threadId/review。客戶端直接把 threadId 塞在網址裡。那麼一個惡意使用者只要把網址改成別人的租戶 id,就能撈到、甚至核准別人家的待審週報。第 6 課那樣寫是為了先講清楚 interrupt/resume上線版不能這樣

上線版的做法:客戶端只能指定**「哪個競品」(那是它自己租戶範圍內的選擇),「哪個租戶」永遠由伺服器從 token 推導**。把這條規則寫成一段純邏輯——不碰網路、不碰金鑰、可離線測到死:

// tenancy.ts —— 純邏輯:把「租戶 + 競品」對應到隔離的 thread_id 與 Store namespace
// 不碰網路、不碰 LLM、不需金鑰。這是整個產品最該被測試盯死的一段。

// 只允許安全字元,擋掉有人用 "../"、"::"、空白 之類的字元來越權或串味
const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;

function assertSafeId(kind: string, id: string): void {
  if (!SAFE_ID.test(id)) {
    throw new Error(`不合法的 ${kind} id:${JSON.stringify(id)}`);
  }
}

/** 這條競品 thread 的 id。租戶在前、競品在後——和第 7、10 課同一個格式。 */
export function threadIdFor(tenantId: string, competitorId: string): string {
  assertSafeId("tenant", tenantId);
  assertSafeId("competitor", competitorId);
  return `${tenantId}::${competitorId}`;
}

/** 這個租戶在 Store 裡的 namespace 前綴(第 7 課的偏好、門檻、追蹤名單都掛在這底下)。 */
export function storeNamespaceFor(tenantId: string, ...rest: string[]): string[] {
  assertSafeId("tenant", tenantId);
  rest.forEach((r) => assertSafeId("segment", r));
  return ["tenant", tenantId, ...rest];
}

這段短短的程式碼是整個租戶隔離的單一真相來源:後端要組 thread_id 用它、graph 節點要讀 Store 也用它(第 7 課那個 config.store?.get(["account", account, ...]) 現在改成 storeNamespaceFor(tenantId, ...))。因為它是純函式,它可以離線、無金鑰、毫秒級地被測到死——正是第 2 課那條 seam 給你的回報,用在最該保守的地方:

// tenancy.test.ts —— node --test,零金鑰、零網路
import { test } from "node:test";
import assert from "node:assert/strict";
import { threadIdFor, storeNamespaceFor } from "./tenancy";

test("正常情況:租戶 + 競品 → thread_id", () => {
  assert.equal(threadIdFor("acme-corp", "acme-planner"), "acme-corp::acme-planner");
});

test("Store namespace 永遠掛在租戶底下、彼此隔離", () => {
  assert.deepEqual(
    storeNamespaceFor("acme-corp", "preferences"),
    ["tenant", "acme-corp", "preferences"],
  );
});

test("擋掉想越權/串味的 id(含 :: 、../ 、空白)", () => {
  for (const evil of ["acme::bolt", "../secrets", "acme corp", "", "Acme"]) {
    assert.throws(() => threadIdFor(evil, "acme-planner"), /不合法/);
  }
});

⚠️ 這裡有一條絕對的規則和一個不絕對的邊界,別搞混:「租戶 id 由伺服器推導、絕不信客戶端」是絕對的(信了就等於把租戶隔離的鑰匙送給攻擊者)。但**「競品 id 可以由客戶端傳」是刻意放行的**——因為那是使用者在自己租戶範圍內選要看哪個競品,本來就該他決定。分野是:客戶端能選「我自己的哪一個」,但不能選「我是誰」。

2.2 鑑權:把 token 換成租戶,也是一道 seam

「怎麼從一個 HTTP 請求得到 tenantId」這件事,本身又是一道 seam:「這個 token 是誰」的查找可以抽換——離線測試用一張固定的 stub 表,上線接你的真實使用者資料庫或 JWT 驗證。graph 和路由完全不需要知道背後是哪一種。

// auth.ts —— seam:把 token 換成租戶身分
import type { Request, Response, NextFunction } from "express";

export interface TenantResolver {
  /** 認得這個 token 就回傳它屬於哪個租戶,不認得回 null */
  resolve(token: string): Promise<{ tenantId: string } | null>;
}

// stub(假的查找):離線、測試用。固定一張表,輸出穩定可斷言、不需要金鑰或資料庫。
export const stubResolver: TenantResolver = {
  async resolve(token) {
    const table: Record<string, string> = {
      "tok_acme": "acme-corp",
      "tok_globex": "globex-inc",
    };
    const tenantId = table[token];
    return tenantId ? { tenantId } : null;
  },
};

// Express 中介層:驗 token → 把 tenantId 放進 res.locals,供後面的路由使用。
export function authenticate(resolver: TenantResolver) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const token = (req.header("authorization") ?? "").replace(/^Bearer\s+/i, "");
    if (!token) return res.status(401).json({ error: "缺少 Bearer token" });

    const who = await resolver.resolve(token);
    if (!who) return res.status(401).json({ error: "token 無效" });

    res.locals.tenantId = who.tenantId; // ← 後面所有路由只信這個,不信網址/body 裡的租戶
    next();
  };
}

💡 上線時 stubResolver 換成真貨——查你第 10 課那個 Postgres 裡的 API key 表,或驗一個 JWT。因為它是一道 seam,路由和 graph 一行都不用改。這跟第 2 課「換模型不動 graph」是同一招,只是這次換的是「怎麼認人」。

2.3 金鑰保護:這層是金鑰唯一的家

金鑰保護幾乎不需要新程式碼,需要的是一條紀律

LLM 金鑰、Stripe secret key,只活在這層 Node 服務的環境變數裡,永遠不進瀏覽器。

還記得第 2 課的 new Anthropic() 是自動從 process.env.ANTHROPIC_API_KEY 讀的、金鑰不寫死在程式碼裡嗎?那條紀律在這裡收成:graph 的 seam 右側(真模型、真 Stripe 呼叫)跑在後端這層,金鑰對瀏覽器完全不可見。 前端拿到的只有 graph 吐出的結果(週報、進度事件),從頭到尾看不到一把金鑰。

這正好也是第 1 課那條主線的另一面。fe-admin-ai-chat 的後端是「薄 proxy,只藏金鑰、轉發格式」,因為它的大腦在前端;我們的後端這層更厚——它藏金鑰,但它裡面還住著整顆大腦(graph)。 金鑰不進前端這件事兩邊一樣,差別是我們藏在後面的東西多得多。

// keys.ts —— 金鑰只從環境變數讀,缺了就早早爆掉(fail fast),別讓它半路才炸
export function requireEnv(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`缺少必要的環境變數:${name}(金鑰只放環境變數,別寫進程式碼)`);
  return v;
}

⚠️ 一個很好抓、但很多人漏的洩漏路徑:別把後端錯誤原封不動吐給前端。 某些 SDK 的錯誤訊息會把請求標頭(含金鑰片段)帶進 message。第 5 課我們把錯誤包成 { type: "error", message } 串回前端——上線版記得那個 message過濾成使用者看得懂的字(「這張卡分析失敗,稍後重試」),完整堆疊只寫進你自己的日誌。

2.4 CORS、路由、優雅關閉:把整台服務組起來

最後把路由、收緊的 CORS、健康檢查、優雅關閉,連同上面那些零件,組成一台真的能上線的服務。程式碼有點長,但每一段都對著上面講過的一件事——我在註解裡標了它是四件事裡的哪一件。

// server.ts —— 把第 2–11 課那張 graph 包成一台能上線的服務
import express, { type Request, type Response } from "express";
import { Command } from "@langchain/langgraph";
import { buildDigestGraph } from "./digest";          // ← 第 2–7 課那張 graph,原封不動搬來用
import { getCheckpointer, getStore } from "./state";  // ← 第 10 課:Postgres 版 checkpointer / Store
import { anthropicMetered, newTrace, traced, logRun } from "./observability"; // 第三節:真模型 + tracing
import { authenticate, stubResolver } from "./auth";
import { threadIdFor } from "./tenancy";
import type { WireEvent } from "./shared/events";     // ← 第 5 課那份前後端共用的事件型別

const app = express();
app.use(express.json());

// ── CORS:從第 5 課的 "*" 收緊成白名單 ──────────────────────────
// 只有你自己的前端網域,才准從瀏覽器打這個 API。
const ALLOWED = new Set(
  (process.env.ALLOWED_ORIGINS ?? "http://localhost:5173").split(","),
);
app.use((req: Request, res: Response, next) => {
  const origin = req.header("origin");
  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin"); // 快取分流:不同 origin 拿到不同的 CORS 回應
    res.setHeader("Access-Control-Allow-Headers", "authorization, content-type");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
  }
  if (req.method === "OPTIONS") {
    return res.sendStatus(origin && ALLOWED.has(origin) ? 204 : 403);
  }
  next();
});

// ── 健康檢查:不需鑑權,給 load balancer / 部署平台探活用 ──────────
app.get("/healthz", (_req, res) => res.json({ ok: true }));

// ── 以下所有 /api 端點都要先過鑑權(token → tenantId)──────────────
app.use("/api", authenticate(stubResolver)); // 上線把 stubResolver 換成真貨

// 上線用的依賴:金鑰只在這層。沒金鑰時 anthropicMetered 內部會退回 stub,可離線起服務。
const realModel = anthropicMetered(process.env.ANTHROPIC_API_KEY);
const checkpointer = await getCheckpointer(); // Postgres(第 10 課)
const store = await getStore();

// ── 路由 1:跑一次本週分析,串流進 control panel(第 5 課的 streaming)──
app.post("/api/digest/:competitorId/run", async (req: Request, res: Response) => {
  const tenantId = res.locals.tenantId as string;        // ← 只信鑑權推導出來的租戶
  const competitorId = req.params.competitorId;          // ← 競品可由客戶端指定(自己範圍內)
  const threadId = threadIdFor(tenantId, competitorId);  // ← 純邏輯,租戶隔離就靠這一行

  // 這次執行專屬的 tracing sink:把每個 LLM 節點的花費/延遲/成敗記下來(第三節)
  const trace = newTrace(tenantId, competitorId);
  const model = traced("draft", realModel, trace.record); // 在 seam 上包一層,graph 不用改

  // checkpointer/Store 都在 Postgres(第 10 課),graph instance 不再存狀態,
  // 所以可以放心每個請求建一張、注入這次專屬的 tracing model。(週報是週級節奏,編譯開銷可忽略)
  const graph = buildDigestGraph({ summarizer: model }, { checkpointer, store });

  res.status(200);
  res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("X-Accel-Buffering", "no"); // 叫反向代理別緩衝,否則串流會被卡成一大包(第 5 課)
  res.flushHeaders?.();

  const write = (e: WireEvent) => res.write(JSON.stringify(e) + "\n");
  const ac = new AbortController();
  req.on("close", () => ac.abort()); // 使用者關頁面就中止,別白燒 LLM 的錢(第 5 課、呼應第 8 課)

  try {
    const stream = await graph.stream(
      { competitor: competitorId },
      {
        configurable: { thread_id: threadId, tenantId }, // ← tenantId 傳進去,節點讀 Store 用它圈 namespace
        streamMode: ["updates", "custom"],
        signal: ac.signal,
      },
    );
    for await (const [mode, chunk] of stream) {
      if (mode === "custom") write(chunk as WireEvent);
      else {
        const [node, update] = Object.entries(chunk as Record<string, unknown>)[0];
        write({ type: "state_update", node, update });
      }
    }
    write({ type: "done" });
  } catch (err) {
    write({ type: "error", message: "本週分析失敗,稍後重試" }); // 對外講人話,細節進日誌
  } finally {
    logRun(trace); // ← 不管成敗,把這次執行的一行結構化 trace 落到日誌(第三節)
    res.end();
  }
});

// ── 路由 2、3:待審與決定(第 6 課的 HITL),端點只吃 competitorId,不吃 threadId ──
app.get("/api/digest/:competitorId/pending", async (req: Request, res: Response) => {
  const threadId = threadIdFor(res.locals.tenantId, req.params.competitorId);
  // getState 只從 checkpointer 撈現場、不會真的跑節點,所以這裡的 model 不會被呼叫;
  // 用一個丟棄式 sink 包一下,只是為了型別對齊(graph 要的是 Summarizer)。
  const graph = buildDigestGraph(
    { summarizer: traced("draft", realModel, () => {}) },
    { checkpointer, store },
  );
  const snap = await graph.getState({ configurable: { thread_id: threadId } });
  const pending = snap.tasks?.[0]?.interrupts?.[0]?.value ?? null;
  res.json({ awaitingReview: pending !== null, review: pending });
});

app.post("/api/digest/:competitorId/review", async (req: Request, res: Response) => {
  const tenantId = res.locals.tenantId as string;
  const threadId = threadIdFor(tenantId, req.params.competitorId);
  const trace = newTrace(tenantId, req.params.competitorId);
  const graph = buildDigestGraph(
    { summarizer: traced("draft", realModel, trace.record) },
    { checkpointer, store },
  );
  try {
    const result = await graph.invoke(
      new Command({ resume: req.body }), // 分析師的 ReviewDecision(第 6 課)
      { configurable: { thread_id: threadId, tenantId } },
    );
    res.json({ sent: result.sent });
  } finally {
    logRun(trace);
  }
});

// ── 優雅關閉:部署平台要重啟時會送 SIGTERM,先停收新連線、把手上的跑完再退 ──
const server = app.listen(8787, () => console.log("digest API on http://localhost:8787"));
process.on("SIGTERM", () => {
  console.log("收到 SIGTERM,停止接受新連線,等手上的執行收尾…");
  server.close(() => process.exit(0)); // 進行中的串流會自己跑完;沒跑完的下次靠 checkpointer 續(第 7 課)
});

這台服務值得你盯著看的,不是「它能 listen」,而是四件事在哪幾行落地

  • 路由:三個 /api 端點(跑一次/撈待審/送決定),把 HTTP 動作對到 graph.streamgetStateinvoke(Command)——全是你前幾課已經會的 graph 操作,這裡只是把它們掛上網址。
  • 租戶鑑權app.use("/api", authenticate(...)) 讓每個 /api 請求先換到 tenantId;每條路由都用 threadIdFor(res.locals.tenantId, ...)thread_id——沒有任何一行相信網址或 body 裡的租戶
  • 金鑰保護realModel 拿的是環境變數裡的金鑰,跑在後端;前端只收到 NDJSON 事件,一把金鑰都摸不到。
  • CORSALLOWED 白名單把第 5 課那個開發用的 "*" 收緊;OPTIONS 預檢不在白名單直接 403。

⚠️ CORS 的邊界別誤會:CORS 只管「瀏覽器」發起的跨網域請求。你那個週一凌晨三點的排程觸發(下一節、第四節會提)是 server 對 server 打的,根本不經過 CORS——CORS 攔不住它,也不該攔它。所以 CORS 是「保護你的前端不被別的網站冒名從瀏覽器呼叫」,不是萬能的存取控制。真正擋住壞人的是上面那道鑑權,不是 CORS。兩者分工別搞混。

到這裡,第一個問題整個答完了:LangGraph.js 給你一張 transport-agnostic 的 graph,你用一層 Node 服務把它包起來,這層負責路由、CORS 白名單、金鑰只留後端、以及最要命的租戶鑑權(租戶由 token 推導、絕不信客戶端)。 而其中最該保守的租戶隔離,被我們切成了一段可離線測到死的純邏輯。


三、觀測 / tracing:讓步驟、花費、失敗都看得見

服務上線了,新問題馬上來:週一凌晨三點,它自己在跑,沒人盯著。 那一跑到底發生了什麼?跑了哪幾個節點?花了多少錢?哪個競品的摘要失敗了?如果你答不出來,你就是閉著眼睛在開一台會花你錢的機器。

這一節就是替它裝上儀表板。但先把一個很容易混的分層講清楚,否則你會把力氣花錯地方。

3.1 兩種觀測,別混為一談

  • 基礎設施層的指標(每秒幾個請求、延遲、錯誤率、CPU/記憶體)——這是通用的,任何 Node 服務都一樣。它不是這一課要教的,它是 devops 子站第 9 章(Prometheus + Grafana)的事,第四節會回指。它回答的是「服務活著嗎?慢不慢?
  • 執行層的 tracing(這一次跑了哪些節點、每個節點吃了多少 token、花了多少錢、哪個 LLM 呼叫失敗了、model routing 選了貴的還是便宜的、預算閘有沒有擋下)——這是AI 產品特有的,Prometheus 那套通用指標答不出來。它回答的是「這一次執行,到底幹了什麼、花了多少?

一句話分清楚:Prometheus 告訴你「服務慢了」,tracing 告訴你「這一次跑為什麼花了 $0.42,平常只要 $0.08」。 後者才是這一節的主角,也正是呼應第 8–9 課(預算與單位經濟)的地方——第 8 課算得出「一個節點值多少錢」、第 9 課算得出「一次跑賺不賺」,tracing 就是把那些數字沿著每一次真實執行記錄下來、讓它可見。

3.2 seam 是你唯一的收費站——在那裡收一次,全都看得見

這裡有一個因為前十一課的紀律而白賺到的大禮。

因為我們所有會花錢的 LLM 呼叫,都穿過同一道 seam(那個 Summarizer / model 介面),你不需要在每個節點裡東插一段計時、西插一段計費。你只要在 seam 上包一層,就一次收齊所有節點的花費、延遲、成敗。 seam 是收費站,車都得經過,你在站上記帳就好。

先把「一次執行的帳」長什麼樣定下來,順便把 seam 的回傳擴一格帶上 usage(token 數)——這是記帳、算單位經濟、開帳單全都要的原料:

// observability.ts —— tracing:把每次執行的步驟、花費、失敗記下來
import { randomUUID } from "node:crypto";
import type { Summarizer } from "./digest";       // 第 6 課那道 seam:draftDigest(changes)
import type { Change } from "./digest-state";      // 第 6 課的變化型別

// 上線版的模型回傳,比第 6 課的 Summarizer 多帶一格 usage(token 數)——這是所有帳的原料。
// 形狀跟 Summarizer 一樣吃 changes,只是回傳從「一段字」擴成「字 + model + usage」。
export interface Metered {
  text: string;
  model: string;
  usage: { inputTokens: number; outputTokens: number };
}
export interface MeteredModel {
  draftDigest(changes: Change[]): Promise<Metered>;
}

// 一個節點跑完留下的一筆 span;一次執行由多筆 span 組成一份 trace
export interface Span {
  node: string;
  model: string;
  inputTokens: number;
  outputTokens: number;
  costUSD: number;
  ms: number;
  ok: boolean;
  error?: string;
}
export interface RunTrace {
  runId: string;
  tenantId: string;
  competitorId: string;
  spans: Span[];
  record: (s: Span) => void; // 給 traced() 往裡塞 span 的水龍頭
}

export function newTrace(tenantId: string, competitorId: string): RunTrace {
  const spans: Span[] = [];
  return {
    runId: randomUUID(),
    tenantId,
    competitorId,
    spans,
    record: (s) => spans.push(s),
  };
}

算錢是一段純邏輯——token 數乘價目表。價目表是第 8 課的 budget gate 已經在用的同一張,這裡不重列、只示意(實際數字以官方定價為準):

// observability.ts(續)—— 算錢:純邏輯,回指第 8 課的價目表,不重教
const PRICE: Record<string, { in: number; out: number }> = {
  // 單位:USD / 每百萬 token(示意值,以官方定價為準)
  "claude-3-5-haiku-latest": { in: 0.8, out: 4.0 },
  "claude-3-5-sonnet-latest": { in: 3.0, out: 15.0 },
};

export function priceUSD(model: string, inTok: number, outTok: number): number {
  const p = PRICE[model] ?? { in: 0, out: 0 };
  return (inTok * p.in + outTok * p.out) / 1_000_000;
}

現在是這一節的主角:traced()。它在 seam 上包一層,graph、節點、state 一行都不用改,就把花費、延遲、成敗全記下來:

// observability.ts(續)—— 在 seam 上包一層 tracing。這就是「收費站記帳」。
export function traced(
  node: string,
  inner: MeteredModel,
  record: (s: Span) => void,
): Summarizer {
  return {
    async draftDigest(changes) {
      const t0 = Date.now();
      try {
        const out = await inner.draftDigest(changes); // 車經過收費站,我們在這裡記一筆帳
        record({
          node,
          model: out.model,
          inputTokens: out.usage.inputTokens,
          outputTokens: out.usage.outputTokens,
          costUSD: priceUSD(out.model, out.usage.inputTokens, out.usage.outputTokens),
          ms: Date.now() - t0,
          ok: true,
        });
        return out.text;
      } catch (e) {
        // ⚠️ 失敗一樣要記一筆——凌晨三點靜默失敗、沒有任何記錄,是最貴的那種 bug
        record({
          node, model: "?", inputTokens: 0, outputTokens: 0, costUSD: 0,
          ms: Date.now() - t0, ok: false,
          error: e instanceof Error ? e.message : String(e),
        });
        throw e;
      }
    },
  };
}

因為 traced() 包的是 seam、算錢的是純函式,這整套 tracing 可以離線、無金鑰地測——用一個回固定 usage 的 stub,斷言「span 有記下來、錢算對了」:

// observability.test.ts —— node --test,零金鑰、零網路
import { test } from "node:test";
import assert from "node:assert/strict";
import { newTrace, traced, priceUSD, type MeteredModel } from "./observability";

const stubModel: MeteredModel = {
  async draftDigest() {
    return { text: "假摘要", model: "claude-3-5-haiku-latest",
             usage: { inputTokens: 1000, outputTokens: 200 } };
  },
};

test("成功執行:span 記下 token、算對錢", async () => {
  const trace = newTrace("acme-corp", "acme-planner");
  const model = traced("draft", stubModel, trace.record);
  await model.draftDigest([]);

  assert.equal(trace.spans.length, 1);
  const s = trace.spans[0];
  assert.equal(s.ok, true);
  assert.equal(s.inputTokens, 1000);
  // (1000 * 0.8 + 200 * 4) / 1e6 = 0.0016
  assert.equal(s.costUSD, priceUSD("claude-3-5-haiku-latest", 1000, 200));
  assert.equal(s.costUSD, 0.0016);
});

test("失敗執行:也要記一筆 ok:false,不能靜默", async () => {
  const boom: MeteredModel = { async draftDigest() { throw new Error("429 rate limit"); } };
  const trace = newTrace("acme-corp", "acme-planner");
  const model = traced("draft", boom, trace.record);
  await assert.rejects(() => model.draftDigest([]));

  assert.equal(trace.spans.length, 1);
  assert.equal(trace.spans[0].ok, false);
  assert.match(trace.spans[0].error!, /429/);
});

seam 右側那塊真貨(示範,需金鑰)就長這樣——它跟第 6 課的 llmSummarizer 幾乎一樣,只是把 Anthropic 回應裡自帶的 usage 一起帶出來(那就是我們記帳、計費要的 token 數)。沒金鑰時退回一個假的,讓整台服務能離線起:

// observability.ts(續)—— 真貨(示範,需要 ANTHROPIC_API_KEY);沒金鑰就退回 stub
import Anthropic from "@anthropic-ai/sdk";

export function anthropicMetered(apiKey?: string): MeteredModel {
  if (!apiKey) {
    // 離線 stub:不打網路、不花錢,讓服務與 tracing 這條路都能離線起
    return {
      async draftDigest() {
        return { text: "【stub 週報草稿】", model: "stub", usage: { inputTokens: 0, outputTokens: 0 } };
      },
    };
  }
  const client = new Anthropic({ apiKey });
  const model = "claude-3-5-haiku-latest"; // 便宜夠用;貴/便宜分流是第 8 課的 model routing
  return {
    async draftDigest(changes) {
      const res = await client.messages.create({
        model,
        max_tokens: 400,
        messages: [{
          role: "user",
          content: "你是競品情報分析師,把下列變化寫成精簡、決策導向的週報草稿,只寫變化:\n\n" +
            JSON.stringify(changes, null, 2),
        }],
      });
      const text = res.content[0]?.type === "text" ? res.content[0].text : "";
      // ← Anthropic 回應自帶 usage,這就是 tracing/計費的原料
      return { text, model, usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens } };
    },
  };
}

3.3 一次執行 → 一行日誌 → 三個去處

server.tsfinally 裡那句 logRun(trace),就是把這份 trace 收尾成一行結構化日誌

// observability.ts(續)—— 把一次執行收成一行結構化日誌
export function logRun(t: RunTrace): void {
  const totalCostUSD = t.spans.reduce((a, s) => a + s.costUSD, 0);
  const ok = t.spans.every((s) => s.ok);
  console.log(JSON.stringify({
    at: new Date().toISOString(),
    runId: t.runId,
    tenant: t.tenantId,
    competitor: t.competitorId,
    ok,
    totalCostUSD: Number(totalCostUSD.toFixed(6)),
    nodes: t.spans.map((s) => ({ node: s.node, model: s.model, costUSD: s.costUSD, ms: s.ms, ok: s.ok })),
  }));
}

跑一次,你的日誌就多一行像這樣的東西:

{"at":"2026-07-06T03:00:12Z","runId":"9f2c…","tenant":"acme-corp","competitor":"acme-planner",
 "ok":true,"totalCostUSD":0.0016,"nodes":[{"node":"draft","model":"claude-3-5-haiku-latest","costUSD":0.0016,"ms":842,"ok":true}]}

這一行看起來平平無奇,但它同時餵給三個去處,而且它們用的是同一份數字——這正是全系列紀律收束的一刻:

  1. 看得見(本節):這行 JSON 送進你的日誌系統(回指 devops 子站第 9 章的日誌/監控),凌晨三點那一跑從此不再是黑箱。你能查「acme-corp 上週那次為什麼失敗」「哪個租戶最燒錢」。
  2. 單位經濟(第 9 課)totalCostUSD 就是第 9 課「一次跑值多少錢」的那個數字,只是現在它來自真實執行,不是估算。把它按租戶加總除以月費,第 9 課那張單位經濟表就活了起來。
  3. 用量計費(第 11 課)spans 裡的 outputTokensinputTokens,就是第 11 課餵給 Stripe metered billing 的用量。同一份 usage,一邊拿去算你賺不賺、一邊拿去給客戶開帳單。

💡 順手就能長出的兩個維運能力:(1) 花費回歸警報——把每次 totalCostUSD 跟這條 thread 的中位數比,超過 N 倍就告警(可能是某個競品頁面爆量、或 model routing 誤判把貴模型叫上場,呼應第 8 課的 budget gate)。(2) 失敗可見——ok:false 的執行自動進一個「待查」清單,而不是靜靜地讓某個客戶這週收不到週報卻沒人知道。

3.4 邊界:要更深的逐步追蹤,有現成的託管方案

traced() 這套自己記帳的好處是可攜、零依賴、而且直接餵你自己的計費與單位經濟。但它記的是「每個 LLM 節點花了多少」這種粗顆粒。如果你想要逐步、逐 prompt 的深度追蹤(每個 LLM 呼叫的完整輸入輸出、巢狀的 subgraph 展開、可視化的時間軸),LangGraph.js 的同門觀測產品 LangSmith(同一個團隊出的託管平台)現成可用:設兩個環境變數(LANGSMITH_TRACING=trueLANGSMITH_API_KEY=...)大致就會自動把 trace 收上去,幾乎不用改碼。

⚠️ 它的邊界要講清楚:LangSmith 是要付費的第三方 SaaS,而且會把你的執行資料送到外部——對某些在意資料落地的付費客戶,這是要先問清楚的一件事。所以兩條路不是二選一:自己 traced() 記帳負責「花費可見、直接接你的計費」,這條你一定要有;LangSmith 負責「出事時逐步 debug」,是很值得加的一層便利。用哪個,看你此刻更需要「省錢可攜」還是「深度可視」。

第二個問題答完了:觀測分兩層——通用基礎設施指標回指 devops,AI 執行層的 tracing 靠在 seam 上包一層 traced(),一次收齊每個節點的花費、延遲、成敗,收成一行結構化日誌,同一份數字同時餵給『看得見』『單位經濟(第 9 課)』『用量計費(第 11 課)』。


四、部署與維運:承接 devops 子站,本系列不重教

好消息,而且是這門課能收尾的關鍵好消息:到這一步,你的產品在部署眼裡,就是一個普通的 Node 服務 + 一個 Postgres + 一份 React 靜態網站。 沒有任何一塊是「AI 產品才有的詭異部署需求」。

而「把一個 Node 服務容器化、上 CI、推 registry、部署上雲、加監控」——這件事已經有一整套教學了,就是 no-logic-trade 的 devops 子站。它教的是 Go 後端,但那條交付線(改 code → 自動測試 → build image → 推 registry → 部署上雲 → 監控)跟語言無關,你把「Go 二進位」換成「Node 服務」,整條照用。所以本系列不重教一次 CI/CD 與雲端,直接回指:

這產品上線需要 就是 devops 子站的… 你只要照做
把 Node 服務容器化、精簡 image 第 2 章(優化 Image) Node 的 Dockerfile 跟 Go 的結構一樣:多階段 build、只留 runtime
push 自動跑測試與 build 第 3 章(CI / GitHub Actions) 見下方💡——這裡有個 seam 的紅利
build image 推到 registry 第 4 章(CD / GHCR) 一模一樣
用程式碼佈建雲端資源 第 5 章(Terraform IaC) 多佈一個持久的 Postgres(見下方⚠️)
真的跑上網際網路 第 6–8 章(Cloud Run / K8s / GKE) Node 服務就是一個容器,照部
基礎設施監控 第 9 章(Prometheus + Grafana) 通用指標看這裡;AI 執行層 tracing 用第三節那套補上
完整 pipeline 全貌 第 10 章 收尾參照

💡 CI 這一步藏著全系列紀律的回報。 因為我們把 diff(第 7 課)、去重(第 4 課)、預算計算(第 8 課)、租戶隔離(本課 tenancy.ts)、tracing 算錢(本課 observability.ts)全都切在 seam 的純邏輯那一側,你的 CI 可以不帶任何金鑰、不連網、毫秒級地把這些測試全跑一遍(node --test / vitest)。「接線與純邏輯對不對」擋在最便宜、每次 push 都跑的地方;「LLM 品質好不好」那條又慢又花錢的評測才動用真金鑰。這就是第 2 課切 seam,一路省到 CI 的回報。

但你不能只會「照抄 devops」——這產品跟一個無狀態 Go API 有四個真實差別,是 devops 子站不會替你講、你得自己補的。把它們講在明處,正是本系列在部署上唯一需要「加值」的地方:

  • ⚠️ 它是有狀態的——但狀態不在程序裡。 第 10 課把 checkpointer/Store 從 MemorySaver 搬進 Postgres,這一步不只是「持久化」,它是讓部署模型成立的關鍵:因為狀態出了程序、進了資料庫,你的 Node 服務本身變回無狀態,就能像 Cloud Run/K8s 想要的那樣多開幾個一模一樣的複本、擺在負載平衡後面——只要所有複本都指向同一個 Postgres。反過來說,你要是還用 MemorySaver,就永遠只能跑一個程序,一擴就記憶錯亂。所以佈建時那個 Postgres 一定要是持久的(不是用完即刪的 demo 資料庫),這是這產品跟股票 API 最大的部署差異。
  • ⚠️ 排程觸發,不是使用者點按。 第 1 課就定調:週報是週一凌晨三點自動跑。在部署平台上,那是一個 Cloud Scheduler(或 K8s CronJob),每週定時去打你的 POST /api/digest/:competitorId/run。這是 server 對 server 的呼叫,不經過 CORS(第二節那道邊界),但它一樣要帶服務層的鑑權(一把只有排程器有的內部 token)。
  • ⚠️ 長串流會撞到 serverless 的請求逾時。 Cloud Run 那類平台對單一請求有逾時上限;一次盯十個競品的 digest 串流可能跑很久,撞上限就被硬切。逃生門很自然,而且你已經有零件了:觸發後立刻回應「開始了」,讓 graph 在背景跑,前端改成輪詢 graph.getState(第 7 課)讀進度——因為進度本來就存在 checkpointer 裡,客戶端斷線重連照樣讀得到。小規模先用長串流沒問題,撞到逾時再拆成這種「觸發 + 輪詢」,別提早過度工程。
  • ⚠️ 金鑰走平台的 secret manager,不是烤進 image。 第二節說金鑰只放環境變數;上線時那些環境變數要來自部署平台的 secret 機制(devops 子站佈雲時處理的東西),別把金鑰 build 進 Docker image——image 會被推到 registry,等於把金鑰寄存在那裡。

第三個問題答完了:你的產品部署起來就是個 Node 服務 + Postgres + 靜態前端,整條 CI/CD 與上雲直接承接 devops 子站、本系列不重教;你只需要額外補上四件這產品特有的事——持久 Postgres 讓複本無狀態、排程觸發、長串流的逾時逃生門、金鑰走 secret manager。


五、收尾:四件事合起來,然後它怎麼長大

十二課走到這裡,把鏡頭拉到最遠,看全貌。

第 1 課說過:一個能收費的產品,是四件事一起做,缺一角就只是 demo。當時那四件事只是四個標題。現在,每一件都有了真實的原語、真實的程式碼、真實的測試撐著——那條「demo 到產品」又深又髒的溝,你已經走過來了:

四件事(第 1 課) 由哪幾課建立 靠什麼原語
1. 全端(前端=control panel) 第 5 課 streaming+第 6 課審核卡+本課這層 Node 服務 graph.streaminterrupt()/resume、reduce 進看板
2. 控制節點與狀態 第 2 課 StateGraph+第 7 課記憶+第 10 課落地 StateGraph/node/edge/state、checkpointer/Store/thread/diff
3. 控制預算 第 8 課+第 9 課 budget gatemodel routing快取max-iteration降級單位經濟
4. 能收費 第 10 課隔離+第 11 課收錢 多租戶metered billing

把這張表跟本課的收尾對起來看,你會發現這一課其實是把四件事焊到網際網路上的那道焊縫:它讓 control panel 真的有個 API 可打(1)、讓 checkpointer/Store 隔著租戶被正確地圈起來(2+4)、讓每次執行的花費順著 seam 被記下來餵回單位經濟與帳單(3+4)。四根柱子在這一課合流,變成一台陌生人能刷卡的機器。

它接下來怎麼長大

出貨不是終點,是起點。好消息是:因為地基是一張 graph,長大這件事有現成的三個方向,而且都不用打掉重練。

flowchart TB CORE["現在的 graph<br/>fetch → dedup → diff → draft → review → send"] subgraph GROW["三個長大的方向"] G1["更多來源<br/>沿 seam 加 fetcher(第 3、4 課)"] G2["用 Store 做長期記憶<br/>偏好 → 學到的規則 → 跨週趨勢(第 7 課)"] G3["用 subgraph 拆競品類型<br/>SaaS / 開源 / 電商 各一條產線(第 4 課)"] end CORE --> G1 CORE --> G2 CORE --> G3

方向一:更多來源。 今天盯定價頁、changelog、部落格、招聘頁;明天想加評價網站、App Store 評論、社群聲量——每一種新來源就是一個新的 Fetcher(第 3 課的工具節點)。因為抓取藏在 seam 後面,加來源不會動到 diff:diff 那段純邏輯(第 7 課)比的是結構化的 Snapshot,它不在乎那些欄位是從哪抓來的。來源多了平行度就上去,第 4 課的 fan-out 早準備好了。

方向二:用 Store 做長期記憶。 今天 Store 裡放的還只是偏好、標紅門檻、追蹤名單(第 7 課)。它能長成這個產品真正的護城河:往裡面存學到的正規化規則(「Acme Planner Pro」=「Pro 方案」)、這個租戶到底在乎什麼(他老是把某類變化靜音,你就學會別再報)、甚至跨週的趨勢(「Acme 這一季已經漲第二次了」——這是有 20 週記憶才說得出口的洞察)。第 7 課說過,記憶會複利:跑越久,Store 越厚,週報越有別人抄不走的價值。

方向三:用 subgraph 拆更多競品類型。 今天所有競品都走同一條產線。但一個 SaaS 競品(看定價/changelog)、一個開源競品(看 GitHub releases/星數/issue 熱度)、一個電商競品(看評價/庫存/促銷)——它們該抓的、該比的根本不一樣。這正是 subgraph(子圖) 的用武之地(第 4 課):把每一種競品類型的「抓取→摘要」封裝成一個可複用的子圖,再用一條條件邊(conditional edge)(第 6 課)依競品類型把它路由到對的子圖。主 graph 保持乾淨,每種類型各自演化,互不干擾。

flowchart LR IN["一個競品"] --> ROUTE{"競品類型?<br/>條件邊"} ROUTE -->|"SaaS"| SG1["subgraph:定價 / changelog"] ROUTE -->|"開源"| SG2["subgraph:GitHub releases / issues"] ROUTE -->|"電商"| SG3["subgraph:評價 / 庫存 / 促銷"] SG1 --> DIFF["共用的 diff → review → send"] SG2 --> DIFF SG3 --> DIFF

💡 三個方向有一個共同點,值得你記一輩子:它們都是「加」,不是「改」。 加 fetcher、加 Store 的用途、加 subgraph——底下的 graph/state/checkpointer/seam 這套地基不動。一個能讓你靠疊加而不是重寫來長大的架構,才撐得起一個要活很多年的產品。這就是第 1 課選「大腦在後端一張 graph」而不是塞進前端 ReAct loop 的長線回報。


這一課建立了什麼

這是全系列的最後一課,收攏成你可以帶走的東西:

  • LangGraph.js 沒有內建 HTTP server,是刻意的關注點分離:它給你一張 transport-agnostic 的 graph(invokestream),把「怎麼對外服務」留給你。上線=自己用一層 Node 服務把 graph 包起來(官方託管方案 LangGraph Platform 存在、但要付費且獨立;LangServe 已棄用)。
  • 這層 Node 服務要做四件事路由(HTTP 端點對到 graph 操作)、租戶鑑權tenantId 由 token 推導、絕不信客戶端,靠純邏輯 threadIdFor/storeNamespaceForthread_id/Store namespace 圈進租戶——客戶端只能選「我自己的哪個競品」、不能選「我是誰」)、金鑰保護(LLM/Stripe 金鑰只活在後端環境變數、永不進瀏覽器)、CORS(白名單收緊,且記得它只管瀏覽器、擋不住也不該擋 server 對 server 的排程觸發)。
  • 觀測分兩層:通用基礎設施指標回指 devops 子站第 9 章(Prometheus/Grafana);AI 執行層的 tracing 靠在 seam 上包一層 traced()——一次收齊每個節點的花費/延遲/成敗(失敗也要記,別讓凌晨三點靜默失敗),收成一行結構化日誌。同一份 usage 數字餵三個去處:看得見、單位經濟(第 9 課)、metered billing(第 11 課)。 要更深的逐步追蹤可加 LangSmith(付費、資料出外部,看需求)。
  • 部署直接承接 no-logic-trade 的 devops 子站(容器化→CI→GHCR→Terraform→Cloud Run/K8s→監控),本系列不重教;只需補四件產品特有的事:持久 Postgres 讓 Node 複本無狀態可水平擴(第 10 課的深層回報)排程觸發(Cloud Scheduler/CronJob)長串流撞 serverless 逾時就改「觸發+輪詢 getState」金鑰走 secret manager 別烤進 image
  • 四件事在這一課合流:全端+控制節點與狀態+控制預算+能收費,四根柱子焊到網際網路上,變成一台陌生人能刷卡的機器。它靠疊加長大:更多來源(沿 seam 加 fetcher)、Store 做長期記憶(偏好→學到的規則→跨週趨勢)、subgraph 拆競品類型(SaaS/開源/電商 各一條產線,用條件邊路由)——全都是「加」,不是「改」。

十二課,我們從第 1 課那句話出發:

demo 回答的是「模型能不能做到這件酷事一次」;產品回答的是「一個陌生人願不願意為它每個月付錢」。

現在你手上這台機器,能回答後一句了。它記得上週、只報 diff、每步可控可驗、每跑一次的錢算得清、每個租戶隔離、用量能開帳單、還準時在凌晨三點自己醒來幹活——你(後端 graph)扛下了記憶、比對、把關、控本、計費這一整排累活,所以你收得起月費。誰扛累活,誰收月費,這條線從第 1 課畫到這裡,兌現了。

而你之所以能在十二課內走完這一哩,是因為我們一路都遵守那句分工:手刻懂原理,框架去出貨。 checkpointer 怎麼把 state 存下來、state 機器怎麼轉、retry 怎麼接——那些原理值得你有天親手刻一遍(姊妹系列 ai-pipeline-from-scratch 等你),但今天,我們用 LangGraph.js 身經百戰的現成原語,把一個真能收費的產品送出了門。

地圖看完了,路也走完了。接下來,去把你自己的那個 graph 上線收錢吧。