第 9 課 · 控制預算(下):降級策略與單位經濟——每跑一次到底賺不賺

第 9 課 · 控制預算(下):降級策略與單位經濟——每跑一次到底賺不賺

第 8 課,你替這台機器裝上了四樣東西:每個節點的預算閘(budget gate)、便宜/貴模型的模型分流(model routing)、避免重付同一次花費的快取(cache),還有防止某條線失控跑不停的 max-iteration(迭代天花板)。一句話收束:你現在量得到、也踩得住每個節點花的錢。

但「量得到、踩得住」還不是一門生意。還差兩個問題沒回答,而它們正是把「成本可控」變成「一門真生意」的最後兩塊:

  1. 當閘門真的落下——預算吃緊了,或某個模型/來源突然掛了——這台機器會怎樣?是把一張白畫面甩在付你月費的人臉上,還是優雅地退一步、照樣端出一份「差一點、但仍然有用」的週報?
  2. 拉到最高、把所有節點的花費加起來:一次執行、一個競品、一個客戶——到底賺不賺? 你收的月費,減掉每個月替他跑週報燒掉的 LLM 錢,還剩多少?

第一個問題的答案叫降級(fallback/degradation),第二個叫單位經濟(unit economics)。這一課就是這兩個詞的定義專章。

💡 先把邊界劃清楚,免得你等它教:

  • 第 8 課的預算閘/模型分流/快取/max-iteration,本課只引用、不重定義——它們是這一課的地基,不是這一課的內容。
  • 本課只算**「成本與毛利模型」,不接金流**。真正把用量變成向客戶收的錢(Stripe、用量計費(metered billing)、開發票)是第 11 課;把每一筆用量歸戶到某個租戶、資料隔離(多租戶(multi-tenant))是第 10 課。這一課先把「賺不賺」這個數字算清楚,收錢的水管留給後面。
  • checkpointer/預算閘內部怎麼把狀態存下來、怎麼計量,那是姊妹系列 ai-pipeline-from-scratch 的題目。本課只教怎麼用現成原語出貨。一句話分工:手刻懂原理,框架去出貨。

這一課回答四個問題:

  1. 降級怎麼設計——預算吃緊、或某個模型/來源掛掉時,graph 怎麼優雅退到「便宜一點但仍可用」,而不是直接失敗?
  2. 怎麼把第 8 課的 per-node 花費彙總成「一次執行/一個競品/一個客戶」的成本?
  3. 算出來,這門生意到底賺不賺——毛利怎麼看,哪種客戶會讓你偷偷虧錢?
  4. 為什麼「可預測、可計費、有毛利」是把這東西從 demo 變成產品的關鍵——單位經濟站不住,產品就不成立?

一、降級不是失敗:先把兩個詞分清楚

先講一個廚房的畫面,因為它把整件事講死了。

一家餐廳今晚的鮭魚用完了。壞的廚房:直接把整間店關了,門口貼「今日不營業」。好的廚房:服務生走過來說「不好意思,鮭魚剛好賣完,今天的主廚推薦換成鱸魚,一樣好吃」——你照樣吃到一頓飯,只是換了一道菜,而且他老實告訴你換了。

降級(fallback/degradation),就是那家好廚房:當預算吃緊、或某個模型/來源失效時,graph 優雅退到「便宜一點、但仍然可用」的做法,而不是直接失敗。 它的反面,是那家一缺料就拉下鐵門的壞廚房——一個例外往上炸、一張白畫面、一封「本週週報產生失敗」的道歉信。

為什麼「退一步」幾乎永遠勝過「直接失敗」?因為對付你月費的人來說,這兩件事的價值差了一個量級:

  • 一份降級的週報——只用了兩個來源(第三個掛了)、摘要是便宜模型寫的、甚至只有一行機械式的「Acme:$12 → $15」——仍然有價值。他照樣知道對手漲價了。
  • 一份失敗的週報——一個例外、一張白畫面——價值是零。更糟:它讓人懷疑「這產品到底穩不穩」,而懷疑會退訂。

所以降級的核心心法只有一句:

端出一盤「差一點但能吃」的菜,永遠好過關店。前提是——你得老實說你換了菜。

那句「老實說」不是客套,是這一課後面會反覆回來的紀律。第 3 課你已經見過它的雛形:某個來源逾時,我們沒有默默吞掉,而是往 fetchLog 記一筆 timeout,讓使用者在儀表板上看到「這塊我這週沒看到」。那就是降級的第一種——來源層的降級,第 3 課已經做完了。這一課補上另外兩種觸發器,它們都跟「錢」和「模型」直接相關:

觸發器 情境 這一課的降級做法
來源掛了(第 3 課已做) 某個 RSS/pricing 頁逾時 少一個來源照樣摘要,fetchLog 記一筆讓人看到
模型掛了(本課) 主力模型 429/529、逾時 退到便宜模型;再不行退到純邏輯 diff
預算吃緊(本課) 這次執行剩的錢不夠付主力模型 一開始就挑便宜模型,甚至完全不打 LLM

⚠️ 一句話先把模型分流(model routing)降級分清楚,這兩個很容易混:模型分流(第 8 課)是「進場時」按任務難易挑便宜或貴的模型;降級(本課)是「退場時」——你挑好的那個因為沒預算、或掛掉了,往下退一級但仍端得出東西。分流在入口挑,降級在下墜時接。 一個是計畫,一個是意外的安全網。


二、競品情報週報的「降級階梯」

抽象講完,來把我們這個產品具體該怎麼降級畫成一道階梯。從「最好」一路退到「還能用」,最後才是「大聲失敗」:

flowchart TB A["一次摘要請求"] --> B{"預算還夠付主力模型?"} B -->|夠| C["premium:主力模型(最佳品質)"] B -->|只夠便宜的| D["cheap:便宜模型(品質略降)"] B -->|一毛都不剩| E["diff-only:純邏輯 diff(免 LLM、仍可用)"] C -->|主力掛了| D D -->|便宜的也掛了| E C --> F["送出週報(降級也照送、且標明降級)"] D --> F E --> F

這道階梯有四階,最後一階最值得你盯著看:

  • 第 0 階 · premium:主力模型、全部來源。正常狀態,最好的品質。

  • 第 1 階 · cheap:退到便宜模型。摘要的文筆、洞察會弱一點,但「Acme 漲價了、上線了 AI 週報」這種事實照樣抓得出來。觸發原因可能是預算吃緊(分流的延伸),也可能是主力模型當下 529 overloaded。

  • 第 2 階 · 少來源:某個來源掛了,就用剩下的來源摘要(第 3 課已經做完,這裡只是接上)。

  • 第 3 階 · diff-only(純邏輯 diff)這是整道階梯的靈魂。當預算歸零、或所有模型都打不通時,你還有最後一張牌——第 7 課那個 computeDiff純邏輯,它不打 LLM、不需要金鑰、不花一毛錢。就算 LLM 全掛,你依然能送出:

    Acme Planner:Pro 方案 $12 → $15(+25%);新增功能「AI 週報」。
    (本週 LLM 額度已用罄,這是未經潤飾的機械式比對結果。)
    

    它沒有漂亮的文筆、沒有「疑似要進企業市場」這種需要模型腦補的洞察——但它報出了真變化,而這正是產品最值錢的那件事(第 1 課、第 7 課)。因為 diff 是純邏輯,這個產品有一個「$0 地板」,地板之上仍然有用。 一個一缺 LLM 就整份掛掉的產品沒有這塊地板;你有,這是第 7 課把 diff 切成純邏輯換來的紅利。

💡 為什麼 diff-only 這張牌永遠在手上?因為第 7 課的 lastSnapshot(上週狀態)是靠 checkpointer 持久化的、早就存好了;而 computeDiff 是純函式。「上週的資料」+「純邏輯比對」都不需要當下花任何 LLM 的錢——所以哪怕這次執行一毛預算都不剩,這一階照樣走得動。降級能有一個「仍然有用」的地板,是第 7 課那兩個決定(純邏輯 diff+持久化狀態)在這一課的兌現。

邊界:不是所有東西都該降級

⚠️ 降級不是「把所有錯誤都吞掉、假裝沒事」。有些失敗該大聲炸出來,不該偷偷降級——判準跟第 3 課那條一模一樣:

這個失敗,會不會讓產出變成一份「看起來正常、其實是錯的」週報?

  • 少一個來源 → 產出仍然誠實(少看一塊、且標明了)→ 降級
  • 主力模型 529 → 退便宜模型,產出仍然誠實 → 降級
  • state 裡根本沒有 competitor、或資料庫連不上 → 這是整張 graph 的前置條件壞了,硬要跑只會產出一份「基於空資料、看起來正常」的假週報 → 該大聲失敗,讓上層的重試/告警接手(那是第 10、12 課的事)。

降級是「有損但誠實」,不是「有損又騙人」。能誠實地少給一點,就降級;只能給出騙人的東西,就寧可炸。


三、動手:把降級寫成 graph 走得動的路徑

心法有了,來寫程式。跟全系列一樣,我們把純邏輯和 LLM 呼叫在 seam 切開——降級的「決策」是純邏輯(可離線測),真正打模型的那一下才是 seam。

3.1 純邏輯:決定這次該用哪一階

先寫「按預算挑階」這段。它是純函式:給它「還剩多少預算」和「各階估價」,回你該走哪一階。不打網路、不打 LLM、可離線測到死。

// degrade.ts —— 純邏輯:決定這次摘要走哪一階
export type Tier = "premium" | "cheap" | "diff-only";

// 給定「這次執行剩下的預算」與「各階的估計花費」,決定該用哪一階。
// 純函式:同樣輸入永遠同樣輸出,離線可測。
export function chooseTier(
  budgetLeftUsd: number,
  est: { premiumUsd: number; cheapUsd: number },
): Tier {
  if (budgetLeftUsd >= est.premiumUsd) return "premium"; // 錢夠 → 用最好的
  if (budgetLeftUsd >= est.cheapUsd) return "cheap";      // 只夠便宜的 → 降一階
  return "diff-only";                                     // 一毛都不剩 → 退到純邏輯 diff
}

💡 budgetLeftUsd(這次執行還剩多少預算)是第 8 課預算閘在管的數字——閘門一路扣、扣到剩多少,這裡就拿多少來挑階。我們不重講預算閘怎麼記帳(第 8 課的事),只在它給的數字上做「該退哪一階」的決定。

3.2 seam:一個「會自己降級」的摘要器

現在把 LLM 那一下包進來。沿用第 2 課的 SummarizerModel 介面(seam),我們寫一個協調函式 summarizeWithFallback:它按預算挑階、真的去打模型、打掛了就再退一階,而且——最重要的——把「這次到底走了哪一階、有沒有降級」如實回報,好讓儀表板顯示、好讓成本帳算得清。

// degrade.ts(續)
// 沿用第 2 課的 seam:節點只認得這個介面,不認得背後是真模型還是 stub。
// 多加一個 name,純粹是為了記帳時知道「這一階實際跑了什麼」。
export interface SummarizerModel {
  readonly name: string;
  summarize(competitor: string, material: string): Promise<string>;
}

// 一次摘要的「結局」:不只有文字,還帶著「怎麼得到它的」——這是降級可見的關鍵
export interface SummaryOutcome {
  text: string;      // 摘要(或降級後的純邏輯 diff 文字)
  tierUsed: Tier;    // 實際走了哪一階
  degraded: boolean; // 有沒有降級(premium 以外都算)
  note: string;      // 給人看的一句話,會被送進 control panel
}

// 把第 7 課 computeDiff 的結果排版成一段文字 —— 純邏輯、免 LLM,這就是「$0 地板」。
// 只依賴 { summary } 這個最小形狀,不綁死第 7 課的 Change 型別。
export function renderDiffText(competitor: string, changes: Array<{ summary: string }>): string {
  if (changes.length === 0) return `${competitor}:本週無變化。`;
  return (
    `${competitor}:\n` +
    changes.map((c) => `• ${c.summary}`).join("\n") +
    `\n(本週 LLM 額度用罄,這是未經潤飾的機械式比對結果。)`
  );
}

export async function summarizeWithFallback(args: {
  competitor: string;
  material: string;
  diffText: string;               // 預先算好的純邏輯 diff($0 地板),見 renderDiffText
  budgetLeftUsd: number;
  est: { premiumUsd: number; cheapUsd: number };
  premium: SummarizerModel;
  cheap: SummarizerModel;
}): Promise<SummaryOutcome> {
  const intended = chooseTier(args.budgetLeftUsd, args.est);

  // 預算連便宜的都付不起 → 根本不打 LLM,直接用純邏輯 diff
  if (intended === "diff-only") {
    return { text: args.diffText, tierUsed: "diff-only", degraded: true,
      note: "預算已用罄,本則改用純邏輯 diff(未動用 LLM)" };
  }

  // 預算夠付主力 → 先試主力;掛了才退一階
  if (intended === "premium") {
    try {
      const text = await args.premium.summarize(args.competitor, args.material);
      return { text, tierUsed: "premium", degraded: false, note: "" };
    } catch {
      // 主力模型掛了(429/529/逾時)→ 落到下面的 cheap 分支
    }
  }

  // 到這裡:預算只夠便宜的,或主力剛剛掛了。試便宜模型;再掛就退到 diff-only。
  try {
    const text = await args.cheap.summarize(args.competitor, args.material);
    return { text, tierUsed: "cheap", degraded: true,
      note: intended === "premium"
        ? "主力模型失敗,本則改用便宜模型"
        : "預算吃緊,本則改用便宜模型" };
  } catch {
    return { text: args.diffText, tierUsed: "diff-only", degraded: true,
      note: "兩個模型都失敗,本則改用純邏輯 diff(未動用 LLM)" };
  }
}

盯著這個函式看,它把整道降級階梯壓成了三個可能的下墜點:沒錢付主力就從 cheap 起跑、主力掛了退到 cheap、cheap 也掛了退到 diff-only。 而且每一階都填了 tierUseddegradednote——降級這件事被寫進回傳值裡,不是偷偷發生的。 下一節你會看到,正是這三個欄位讓「降級可見」+「成本可算」同時成立。

3.3 用 stub 離線測所有降級路徑

因為模型是注入的(seam),我們可以用 stub 假模型在離線、零金鑰、毫秒級的情況下,把每一條降級路徑都測到。這裡的 stub 多一個小本事:記下自己被呼叫了幾次,好讓我們斷言「該不打的那一階,真的沒去打模型」(省錢的證據)。

// degrade.test.ts —— node --test,零金鑰、離線
import { test } from "node:test";
import assert from "node:assert/strict";
import { summarizeWithFallback, type SummarizerModel } from "./degrade.js";

// 一個會記錄呼叫次數的 stub;throwErr 給定時就模擬「模型掛了」
function spyModel(name: string, reply: string, throwErr?: Error) {
  const state = { calls: 0 };
  const model: SummarizerModel = {
    name,
    async summarize() {
      state.calls++;
      if (throwErr) throw throwErr;
      return reply;
    },
  };
  return { model, state };
}

const est = { premiumUsd: 0.021, cheapUsd: 0.0056 };
const diffText = "Acme:Pro $12 → $15(+25%)"; // $0 地板:預先算好的純邏輯 diff
const base = { competitor: "Acme", material: "…本週原始情報…", diffText, est };

test("預算充足、主力正常 → premium,不降級", async () => {
  const premium = spyModel("premium", "PREMIUM_SUMMARY");
  const cheap = spyModel("cheap", "CHEAP_SUMMARY");
  const out = await summarizeWithFallback({
    ...base, budgetLeftUsd: 1.0, premium: premium.model, cheap: cheap.model,
  });
  assert.equal(out.tierUsed, "premium");
  assert.equal(out.degraded, false);
  assert.equal(out.text, "PREMIUM_SUMMARY");
  assert.equal(cheap.state.calls, 0); // 沒必要動用便宜模型
});

test("預算夠、但主力掛了 → 自動退到 cheap,仍產出摘要", async () => {
  const premium = spyModel("premium", "", new Error("529 overloaded"));
  const cheap = spyModel("cheap", "CHEAP_SUMMARY");
  const out = await summarizeWithFallback({
    ...base, budgetLeftUsd: 1.0, premium: premium.model, cheap: cheap.model,
  });
  assert.equal(out.tierUsed, "cheap");
  assert.equal(out.degraded, true);
  assert.equal(out.text, "CHEAP_SUMMARY");
  assert.match(out.note, /主力模型失敗/);
});

test("預算只夠便宜的 → 直接走 cheap,連碰都不碰主力(省錢)", async () => {
  const premium = spyModel("premium", "PREMIUM_SUMMARY");
  const cheap = spyModel("cheap", "CHEAP_SUMMARY");
  const out = await summarizeWithFallback({
    ...base, budgetLeftUsd: 0.01, premium: premium.model, cheap: cheap.model,
  }); // 0.01 ≥ cheap 0.0056,但 < premium 0.021
  assert.equal(out.tierUsed, "cheap");
  assert.equal(premium.state.calls, 0); // 關鍵:明知付不起,就別浪費一次呼叫去試
});

test("預算歸零 → diff-only,完全不打 LLM,仍送得出東西", async () => {
  const premium = spyModel("premium", "PREMIUM_SUMMARY");
  const cheap = spyModel("cheap", "CHEAP_SUMMARY");
  const out = await summarizeWithFallback({
    ...base, budgetLeftUsd: 0, premium: premium.model, cheap: cheap.model,
  });
  assert.equal(out.tierUsed, "diff-only");
  assert.equal(out.text, diffText);       // 用的是純邏輯 diff
  assert.equal(premium.state.calls, 0);   // 兩個模型
  assert.equal(cheap.state.calls, 0);     // 一次都沒打 → $0
});
node --import tsx --test degrade.test.ts

四條路徑——正常、模型掛、預算吃緊、預算歸零——全部離線、毫秒級、可重複地驗證了。最後一個測試尤其重要:它證明了「$0 地板」不是說說的——預算歸零時,兩個模型的 calls 都是 0,卻照樣回傳了一份有內容的 text。你的產品在完全不花錢的情況下,仍然送得出真變化。

3.4 降級必須「可見」——接回 control panel

summaryOutcome.note 那句話不是裝飾。回到第一節那家好廚房:換了菜要老實講。 我們把 notetierUsed 一路帶到前端,讓分析師在 control panel(第 5 課那塊看板)上一眼看到「這則是降級來的」:

┌─────────────────────────────┐
│ Acme Planner          ✅完成 │
│ 找到 3 則來源                │
│ 「Acme 把 Pro 從 $12 漲到…  │
│ ⚠️ 便宜模型(主力逾時)      │  ← tierUsed=cheap 的一枚小徽章
└─────────────────────────────┘

這一枚小徽章,接的正是第 6 課那道 human-in-the-loop(HITL) 審核卡:分析師看到「這則是降級來的」,就知道該多看一眼——降級的摘要,正是最需要人把關的那種。降級與人審是搭檔:機器誠實地說「我這則沒盡全力」,人就知道要補上那一眼。 偷偷降級的產品剝奪了這一眼,那才是真正危險的地方。


四、把 per-node 花費彙總成生意看得懂的數字

降級講完,鏡頭拉到最高。第 8 課你替每台機器(每個 LLM 節點)裝了電表——每跑一次,它就記下這一下花了多少。這一節我們請一個會計進來,把所有電表的讀數加起來,並按「一次執行/一個競品/一個客戶」分帳。

4.1 一筆 per-node 花費長什麼樣

第 8 課的預算閘要能「擋下超支」,前提就是它量得到每一次呼叫花多少。我們把量到的每一筆,寫成一筆記錄——就像第 3 課的 fetchLog 記每個來源的成績單一樣,這裡的 costLog 記每次 LLM 呼叫的花費:

// cost.ts —— 成本模型(全是純邏輯,離線可測、不需金鑰)

// 一次 LLM 呼叫的花費記錄。這是第 8 課預算閘「順手量到」的東西寫成一筆;
// 欄位你可依第 8 課的實作微調,本課的重點是「怎麼把一堆這個加起來」。
export interface CostEntry {
  node: string;          // 哪個節點("summarize")
  account: string;       // 哪個付費客戶(歸戶用;真正的租戶隔離是第 10 課)
  competitorId: string;  // 哪個競品
  tier: "premium" | "cheap" | "diff-only";
  inputTokens: number;
  outputTokens: number;
  usd: number;           // 這一次呼叫的花費(diff-only = 0,因為沒打 LLM)
  degraded: boolean;     // 這次有沒有降級(接第三節的 SummaryOutcome)
}

注意 degraded 這一欄——它把第三節的降級和這一節的成本縫在一起了:降級省下的錢、以及「省錢的代價是品質降了幾則」,會同時出現在成本帳上。等一下你就看到這條線的價值。

先寫「token 換算成美元」這段純邏輯。價格用一張表,方便換供應商、換模型:

// cost.ts(續)
// 各階模型的價格:美元 / 每百萬(1M)token。⚠️ 數字以你查到的當下官方報價為準,這裡是示範基線。
export const PRICES = {
  premium: { inPerM: 3.0, outPerM: 15.0 }, // 主力(例如 Sonnet 級)
  cheap:   { inPerM: 0.8, outPerM: 4.0 },  // 便宜(例如 Haiku 級)
} as const;

function round6(n: number): number {
  return Math.round(n * 1e6) / 1e6; // 到百萬分之一美元,順手擋掉浮點誤差在報表上露臉
}

// 純函式:一次呼叫花多少錢
export function costOfCall(
  tier: "premium" | "cheap",
  inputTokens: number,
  outputTokens: number,
): number {
  const p = PRICES[tier];
  return round6((inputTokens / 1e6) * p.inPerM + (outputTokens / 1e6) * p.outPerM);
}

4.2 彙總:把一堆記錄加成一張帳

現在是這一節的主角——把一整份 costLog 加起來,同時按競品、按階分帳。純函式,離線可測。

// cost.ts(續)
export interface CostRollup {
  totalUsd: number;                      // 這批記錄一共花多少
  calls: number;                         // 一共呼叫幾次
  degradedCalls: number;                 // 其中幾次是降級的
  byCompetitor: Record<string, number>;  // 每個競品各花多少
  byTier: Record<string, number>;        // 每一階各花多少(哪一階在燒錢)
}

export function aggregateCost(entries: CostEntry[]): CostRollup {
  const byCompetitor: Record<string, number> = {};
  const byTier: Record<string, number> = {};
  let totalUsd = 0;
  let degradedCalls = 0;

  for (const e of entries) {
    totalUsd += e.usd;
    if (e.degraded) degradedCalls++;
    byCompetitor[e.competitorId] = (byCompetitor[e.competitorId] ?? 0) + e.usd;
    byTier[e.tier] = (byTier[e.tier] ?? 0) + e.usd;
  }

  // 統一四捨五入,讓報表數字乾淨
  for (const k in byCompetitor) byCompetitor[k] = round6(byCompetitor[k]);
  for (const k in byTier) byTier[k] = round6(byTier[k]);

  return { totalUsd: round6(totalUsd), calls: entries.length, degradedCalls, byCompetitor, byTier };
}

這張帳可以按三種粒度去看,而每一種粒度回答一個不同的生意問題:

flowchart LR A["每個 LLM 節點一筆花費<br/>(第 8 課預算閘順手量的)"] --> B["aggregateCost 彙總"] B --> C["一次執行花多少<br/>→ 這次有沒有異常暴衝"] B --> D["一個競品花多少<br/>→ 誰特別貴、要不要單獨降級"] B --> E["一個客戶整月花多少<br/>→ 賺不賺的最終單位"] E --> F["單位經濟:月成本 vs 月費 = 毛利"]
  • 一次執行:這次跑燒了多少?有沒有某個節點異常暴衝(例如某條線失控多跑了幾百次——這正是第 8 課 max-iteration 在擋的)?
  • 一個競品:哪個競品特別燒錢?(某家的 changelog 巨大、pricing 頁得動用貴模型去讀。)看得到,你才知道要不要對它單獨降級
  • 一個客戶這才是「賺不賺」的最終單位。 一個客戶整月替他跑了幾次、盯了幾個競品,加起來多少錢——拿去比他付的月費,就是下一節的單位經濟。

⚠️ 邊界:byCompetitor、按 account 分帳,前提是每一筆 CostEntry帶得上正確的競品/客戶標籤。這一課我們假設標籤已經在 costLogcompetitorIdaccount 欄位),只教「加起來」這段純邏輯。真正要把用量可靠地歸到某個租戶頭上、還要跟別的客戶資料隔離,是第 10 課多租戶的事——那時 thread_id 和 Store namespace 怎麼按租戶切,會補完這塊。本課先把加法算對。

4.3 用 stub 離線測彙總

彙總是純邏輯,測試不需要金鑰、不需要真跑 graph——捏一份假的 costLog 餵進去,斷言加出來的帳對不對就好:

// cost.test.ts —— node --test,零金鑰
import { test } from "node:test";
import assert from "node:assert/strict";
import { aggregateCost, costOfCall, type CostEntry } from "./cost.js";

// 小工具:捏一筆花費記錄(usd 用 costOfCall 真的算,帳才誠實)
function entry(
  competitorId: string,
  tier: CostEntry["tier"],
  inTok: number,
  outTok: number,
  degraded = false,
): CostEntry {
  return {
    node: "summarize", account: "acme-corp", competitorId, tier,
    inputTokens: inTok, outputTokens: outTok,
    usd: tier === "diff-only" ? 0 : costOfCall(tier, inTok, outTok),
    degraded,
  };
}

test("彙總:加總、按競品/階分帳、數降級次數", () => {
  const costLog: CostEntry[] = [
    entry("acme", "premium", 5000, 400),            // $0.021
    entry("boltboard", "cheap", 5000, 400, true),   // $0.0056,降級
    entry("cascade", "premium", 5000, 400),         // $0.021
  ];

  const r = aggregateCost(costLog);
  assert.equal(r.totalUsd, 0.0476);       // 0.021 + 0.0056 + 0.021
  assert.equal(r.calls, 3);
  assert.equal(r.degradedCalls, 1);       // 只有 boltboard 那筆降級
  assert.equal(r.byCompetitor["acme"], 0.021);
  assert.equal(r.byTier["cheap"], 0.0056); // 便宜階一共花了這麼多
});

test("diff-only 記 0 元 —— $0 地板在帳上就是 0", () => {
  const r = aggregateCost([entry("acme", "diff-only", 0, 0, true)]);
  assert.equal(r.totalUsd, 0);
  assert.equal(r.degradedCalls, 1); // 免費,但仍算一次「降級」,帳上看得到
});
node --import tsx --test cost.test.ts

第二個測試把降級和成本的那條縫,釘在了帳面上:diff-only 這一階在成本表上就是實打實的 $0,同時 degradedCalls 記了一筆。於是你的成本報表天生就能回答一個經營問題:「這個月我靠降級省了多少錢、代價是幾則週報品質打了折?」——省錢和降級不再是兩本帳。

4.4 這兩塊怎麼接回 graph

純邏輯都測好了,接回 graph 就很直白。摘要節點summarizeWithFallback,跑完把一筆 CostEntry 附加進 state.costLog(用第 3 課那個「附加」reducer);graph 尾端加一個純邏輯的「會計節點」,跑 aggregateCost 把帳算出來寫進 state:

// nodes.ts(示意:接線骨架,seam 兩側沿用前面的 degrade.ts / cost.ts)
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { summarizeWithFallback, renderDiffText, type SummarizerModel } from "./degrade.js";
import { aggregateCost, costOfCall, type CostEntry } from "./cost.js";

// 摘要節點:走降級邏輯,並記一筆花費
export function makeSummarizeNode(deps: { premium: SummarizerModel; cheap: SummarizerModel }) {
  return async function summarize(state: any, config: LangGraphRunnableConfig) {
    // budgetLeftUsd 由第 8 課的預算閘給(這裡不重刻預算閘)
    const budgetLeftUsd = config.configurable?.budgetLeftUsd ?? 1.0;
    const est = { premiumUsd: 0.021, cheapUsd: 0.0056 };
    const diffText = renderDiffText(state.competitor.name, state.changes ?? []); // 第 7 課的 diff

    const out = await summarizeWithFallback({
      competitor: state.competitor.name, material: state.prepared, diffText,
      budgetLeftUsd, est, premium: deps.premium, cheap: deps.cheap,
    });

    // 把「這次花了多少」寫成一筆 CostEntry(token 數真實版由 SDK 回傳的 usage 給)
    const entry: CostEntry = {
      node: "summarize", account: state.competitor.account, competitorId: state.competitor.id,
      tier: out.tierUsed,
      inputTokens: out.tierUsed === "diff-only" ? 0 : 5000,
      outputTokens: out.tierUsed === "diff-only" ? 0 : 400,
      usd: out.tierUsed === "diff-only" ? 0 : costOfCall(out.tierUsed, 5000, 400),
      degraded: out.degraded,
    };
    return { summary: out.text, summaryNote: out.note, costLog: [entry] }; // costLog 用附加 reducer
  };
}

// 會計節點:純邏輯,把整份 costLog 彙總成一張帳寫回 state
export function accountingNode(state: any) {
  return { runCost: aggregateCost(state.costLog ?? []) };
}

💡 真實版的 inputTokensoutputTokens 不用自己數——Anthropic/OpenAI 的 API 回應裡都有一塊 usageinput_tokensoutput_tokens),照抄進 CostEntry 就是最準的計量。這裡寫死 5000400 只是為了讓示範和後面的算術對得上。這也是為什麼成本要在「真的打完那一下」記——只有 API 回來的 usage 才是真帳,估的不算。


五、算給你看:這個客戶到底賺不賺

帳會加了,來回答終極問題。先給**單位經濟(unit economics)**一個定義:

單位經濟(unit economics)=每跑一次(或每服務一個客戶一段時間)值多少錢、定價之後還有沒有毛利。 它是「這門生意本身賺不賺」最小的那顆算珠——一顆算珠是負的,整串算盤再大也是虧的。

把我們的數字擺上桌。假設(每一條都寫在明處,方便你換成自己的):

假設
每個競品每次執行的 LLM 呼叫 1 次摘要(premium)
每次呼叫 token 輸入 5,000 / 輸出 400
premium 一次呼叫 costOfCall("premium", 5000, 400)$0.021
執行頻率 每週一次 ≈ 每月 4.33 次
月費 $149

算一般客戶(盯 5 個競品):

每月成本 = 5 競品 × $0.021 × 4.33 次 ≈ $0.45 / 月
毛利     = $149 − $0.45 = $148.55(毛利率 99.7%)

再算一個動力用戶(盯 40 個競品):

每月成本 = 40 競品 × $0.021 × 4.33 次 ≈ $3.64 / 月
毛利     = $149 − $3.64 = $145.36(毛利率 97.6%)

先別急著開香檳,但也先誠實承認:這門生意的基本盤,健康得有點誇張。 就算把競品數量翻到 40,成本還是零頭。這不是運氣,是設計出來的——而且每一塊都是前面幾課的功勞:

  • 抓取是固定 workflow(第 3 課),不讓 LLM 臨場亂挑工具亂燒 token;
  • 摘要吃得起便宜模型(第 8 課的模型分流),品質夠用就不付貴的;
  • 最值錢的 diff 是純邏輯(第 7 課),一毛不花;
  • 沒變的競品靠快取+第 7 課的 diff「沒變就別重付」(第 8 課的 cache)直接跳過,不重新摘要。

危險全在尾巴:三種讓客戶偷偷變負的方式

基本盤健康,不代表你安全。單位經濟翻負,幾乎從來不是發生在happy path,而是發生在你沒設防的尾巴事件上。 三個真實的例子,每一個都對得上前面某一課的護欄:

① 失控的迴圈(沒有 max-iteration)。 某個 bug 讓摘要節點鬼打牆,一個競品一次執行就把 LLM 打了 800 次:

800 次 × $0.021 ≈ 單一競品、單次執行 $16.8

一個客戶的一次執行,就燒掉他四分之一個月的月費——跨幾個客戶同時發作,一個下午就把整月毛利掀了。這正是第 8 課 max-iteration(迭代天花板)在守的東西:它把「最壞情況」封了頂。 沒有天花板,一次執行理論上能花掉無限,那你的成本就是不可預測的——下一節會講,不可預測的成本=沒法定價。

② 重付沒變的東西(沒有快取)。 一個競品這週什麼都沒動,你卻每週照樣把它重新摘要一遍。乘上幾百個客戶、幾十週,你付的是一大筆「內容一字沒變」的重複帳單。第 8 課的快取+第 7 課的 diff『沒變就別重付』,省的正是這個。

③ 上錯方案的鯨魚。 假設你手滑開了個 $19 的入門方案,某個客戶在上面盯了 300 個競品:

每月成本 = 300 × $0.021 × 4.33 ≈ $27.3 / 月
毛利     = $19 − $27.3 = −$8.3(毛利率 −44%)

這個客戶你每個月倒貼他 $8.3——他用得越勤,你虧得越多。 你不會「感覺」到這件事,因為它藏在總帳裡;能揪出它的,只有按客戶分帳的單位經濟(4.2 節那張 byCompetitor/按 account 加起來的帳)。它就是那支儀器,告訴你方案的競品上限該畫在哪一條線

把「賺不賺」寫成一個函式

最後,把毛利判斷也寫成純邏輯,讓它可測、可監控:

// cost.ts(續)—— 單位經濟:月成本 vs 月費 → 毛利
export interface UnitEconomics {
  monthlyPriceUsd: number;
  monthlyCostUsd: number;
  grossMarginUsd: number;
  grossMarginPct: number; // 0..1
  profitable: boolean;
}

export function unitEconomics(monthlyPriceUsd: number, monthlyCostUsd: number): UnitEconomics {
  const grossMarginUsd = Math.round((monthlyPriceUsd - monthlyCostUsd) * 1e6) / 1e6;
  const grossMarginPct =
    monthlyPriceUsd === 0 ? 0 : Math.round((grossMarginUsd / monthlyPriceUsd) * 1000) / 1000;
  return {
    monthlyPriceUsd,
    monthlyCostUsd,
    grossMarginUsd,
    grossMarginPct,
    profitable: grossMarginUsd > 0,
  };
}
// cost.test.ts(續)
import { unitEconomics } from "./cost.js";

test("一般客戶:毛利厚,profitable", () => {
  const ue = unitEconomics(149, 3.64);
  assert.equal(ue.profitable, true);
  assert.ok(ue.grossMarginPct > 0.9); // 97.6%
});

test("上錯方案的鯨魚:成本比售價貴 → 標成不賺,讓你揪得出來", () => {
  const ue = unitEconomics(19, 27.3);
  assert.equal(ue.profitable, false);
  assert.ok(ue.grossMarginUsd < 0); // −$8.3
});

⚠️ 別把「毛利為正」讀成「生意穩了」。這裡的成本只算了 serving 這一層(LLM token)——真實的帳還有 CAC(獲客成本)、churn(流失)、客服、其他 infra,那些是毛利之外的事,這門課不展開。但反過來這條鐵律成立且致命:如果光是『每跑一次的 LLM 成本』本身就已經高過你的售價,後面再怎麼省、再會賣,都救不回來。 單位經濟為正是必要條件,不是充分條件——它不保證你成功,但它為負保證你失敗。


六、為什麼「可預測、可計費、有毛利」是 demo → 產品的關鍵

拉到最高,回答第 1 課埋的那顆種子。第 1 課的第三根柱子講「控制預算」,還撂了一句狠話:單位經濟是負的,賣越多虧越多,這不是生意,是慈善。 這一課,我們終於有能力把那句話拆成三個字,逐字兌現——可預測、可計費、有毛利,缺一個,這東西就從「產品」掉回「demo」。

可預測——你只能對「你算得出的成本」定價。一個 $149 的月費,背後是一個賭注:「這個客戶一個月花我的錢,穩穩低於 $149」。這個賭注敢下,靠的是成本有天花板:第 8 課的預算閘、max-iteration 把單次執行的最壞情況封了頂,這一課的降級讓「錢不夠時退到便宜甚至免費」而不是無上限地燒。你是按最壞情況定價的,不是按平均——而降級與閘門,就是把「最壞情況」變成一個你算得出的有限數字的東西。沒有天花板,一次執行理論上能花掉無限,那你報任何價都可能虧。

可計費——成本要能「歸到某個客戶頭上」,你才收得了他的錢。這一課的 byCompetitor、按 account 彙總,就是這件事的前置:先知道每個客戶花了你多少,才談得上向他收多少。 但請記住邊界——這一課只把「成本模型」建好,真正把用量歸到每個租戶、資料隔離是第 10 課,真正把它變成 Stripe 帳單和發票是第 11 課。可計費的地基在這裡打,收錢的水管在後面接。

有毛利——price − cost 穩定為正、且規模化後不塌。負的單位經濟就是第 1 課那句「賣越多虧越多」:每多一個客戶,你多倒貼一點;成長不再是好消息,而是加速失血。一門生意和一個你自掏腰包養著的免費工具,分界線就在這個數字的正負號上。

三者是一條鏈,環環相扣:

flowchart LR A["可預測<br/>成本有天花板"] --> B["敢定價<br/>按最壞情況報月費"] B --> C["可計費<br/>用量歸得了戶"] C --> D["有毛利<br/>price − cost 穩定為正"] D --> E["是產品<br/>賣越多賺越多"] X["缺任一環"] --> Y["是 demo<br/>你自掏腰包養著"]
  • 不可預測 → 不敢定價(不知道會被燒多少),或定了價睡不著覺。
  • 不可計費 → 收不到錢(或全體均攤、成本你自己吸)。
  • 沒毛利 → 賣越多虧越多。

三個裡缺任何一個,這東西就永遠停在「一個很酷、但你自己付錢養著的 demo」——這正是第 1 課說的、絕大多數 AI demo 死掉的地方。能對外收費的產品,和跑得起來的 demo,差的就是這一課這個站得住的單位經濟。

⚠️ 最後一個誠實的邊界,免得你矯枉過正:這不代表 day-one 就要把成本壓到極致、把每一分毛利摳出來。第 1 課講過,四根柱子先求「存在」、再求好。這一課真正的重點不是「毛利要多高」,而是這個數字你「算得出、而且為正」——一個從沒算過「每跑一次多少錢」的產品,是在盲飛;哪天某個客戶開始倒貼,你會是最後一個知道的人。先把儀表裝上、先讓數字為正,剩下的優化,是有了正號之後才值得做的事。


這一課建立了什麼

把這一課收攏成你可以帶走的心智模型:

  • 降級(fallback/degradation)=預算吃緊、或模型/來源失效時,graph 優雅退到「便宜一點但仍可用」,而不是直接失敗。心法:端出「差一點但能吃」永遠好過關店,前提是老實說你降級了。 它和第 8 課的模型分流分工是:分流在入口按難易挑模型,降級在下墜時接住你。
  • 競品情報週報的降級階梯:premium →(沒錢/模型掛)→ cheap →(再掛)→ diff-only。最後這階是靈魂——因為第 7 課的 diff 是純邏輯、上週狀態靠 checkpointer 早存好,產品有一個**「$0 地板」,地板之上仍然報得出真變化**。但邊界要守:能誠實地少給就降級,只能給騙人的東西就寧可大聲失敗(判準同第 3 課)。
  • 降級要「可見」summarizeWithFallbacktierUseddegradednote 寫進回傳值,一路帶到 control panel(第 5 課)和 HITL 審核卡(第 6 課)——降級與人審是搭檔,機器誠實說「這則沒盡全力」,人就知道要多看一眼。
  • 成本彙總=純邏輯costOfCall(token → 美元)、aggregateCost(把 costLog 加成一張帳)、unitEconomics(月成本 vs 月費 → 毛利),全部離線、零金鑰、用 stub/假 costLog 可測。三種粒度各答一個問題:一次執行(有沒有暴衝)、一個競品(誰特別貴)、一個客戶(賺不賺的最終單位)。
  • 單位經濟(unit economics)=每服務一個客戶一段時間值多少錢、定價後有沒有毛利。本產品基本盤健康得誇張(99% 級毛利),是設計出來的(固定 workflow+便宜模型+純邏輯 diff+快取)。但危險在尾巴:失控迴圈(靠 max-iteration 封頂)、重付沒變的(靠快取)、上錯方案的鯨魚(靠按客戶分帳揪出)——每一個都對應一道前面建過的護欄。
  • 為什麼這是 demo → 產品的關鍵可預測(成本有天花板才敢定價)+可計費(歸得了戶才收得到錢)+有毛利(price − cost 穩定為正)。三者缺一,這東西就是「你自掏腰包養的 demo」。單位經濟為正是必要非充分——它不保證成功,但為負保證失敗。

你現在手上這台機器,不只跑得動、記得上週、控得住每個節點的花費——它還在錢吃緊時優雅退場、而且算得出自己到底賺不賺。成本這一半的故事講完了。

但帳目前只算在「一個客戶」的假設上,那個 account 標籤還只是貼上去的。下一課(第 10 課),我們把 checkpointer 接上 Postgres、把 thread_id 和 Store 按租戶切開,讓「每個付費客戶資料徹底隔離、用量可靠歸戶」真正成立——這一課算出來的每客戶成本,到那時才有一個站得住的租戶邊界撐著它。再往後(第 11 課),我們就把這套成本模型接上 Stripe,把「用量」真的變成一張帳單和一張發票——那時你收到的第一筆錢,背後就是這一課算清的那個正號。