第 9 課 · 控制預算(下):降級策略與單位經濟——每跑一次到底賺不賺
第 8 課,你替這台機器裝上了四樣東西:每個節點的預算閘(budget gate)、便宜/貴模型的模型分流(model routing)、避免重付同一次花費的快取(cache),還有防止某條線失控跑不停的 max-iteration(迭代天花板)。一句話收束:你現在量得到、也踩得住每個節點花的錢。
但「量得到、踩得住」還不是一門生意。還差兩個問題沒回答,而它們正是把「成本可控」變成「一門真生意」的最後兩塊:
- 當閘門真的落下——預算吃緊了,或某個模型/來源突然掛了——這台機器會怎樣?是把一張白畫面甩在付你月費的人臉上,還是優雅地退一步、照樣端出一份「差一點、但仍然有用」的週報?
- 拉到最高、把所有節點的花費加起來:一次執行、一個競品、一個客戶——到底賺不賺? 你收的月費,減掉每個月替他跑週報燒掉的 LLM 錢,還剩多少?
第一個問題的答案叫降級(fallback/degradation),第二個叫單位經濟(unit economics)。這一課就是這兩個詞的定義專章。
💡 先把邊界劃清楚,免得你等它教:
- 第 8 課的預算閘/模型分流/快取/max-iteration,本課只引用、不重定義——它們是這一課的地基,不是這一課的內容。
- 本課只算**「成本與毛利模型」,不接金流**。真正把用量變成向客戶收的錢(Stripe、用量計費(metered billing)、開發票)是第 11 課;把每一筆用量歸戶到某個租戶、資料隔離(多租戶(multi-tenant))是第 10 課。這一課先把「賺不賺」這個數字算清楚,收錢的水管留給後面。
- checkpointer/預算閘內部怎麼把狀態存下來、怎麼計量,那是姊妹系列 ai-pipeline-from-scratch 的題目。本課只教怎麼用現成原語出貨。一句話分工:手刻懂原理,框架去出貨。
這一課回答四個問題:
- 降級怎麼設計——預算吃緊、或某個模型/來源掛掉時,graph 怎麼優雅退到「便宜一點但仍可用」,而不是直接失敗?
- 怎麼把第 8 課的 per-node 花費彙總成「一次執行/一個競品/一個客戶」的成本?
- 算出來,這門生意到底賺不賺——毛利怎麼看,哪種客戶會讓你偷偷虧錢?
- 為什麼「可預測、可計費、有毛利」是把這東西從 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。 而且每一階都填了 tierUsed、degraded、note——降級這件事被寫進回傳值裡,不是偷偷發生的。 下一節你會看到,正是這三個欄位讓「降級可見」+「成本可算」同時成立。
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 那句話不是裝飾。回到第一節那家好廚房:換了菜要老實講。 我們把 note 和 tierUsed 一路帶到前端,讓分析師在 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都帶得上正確的競品/客戶標籤。這一課我們假設標籤已經在costLog裡(competitorId、account欄位),只教「加起來」這段純邏輯。真正要把用量可靠地歸到某個租戶頭上、還要跟別的客戶資料隔離,是第 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 ?? []) };
}
💡 真實版的
inputTokens/outputTokens不用自己數——Anthropic/OpenAI 的 API 回應裡都有一塊usage(input_tokens、output_tokens),照抄進CostEntry就是最準的計量。這裡寫死5000/400只是為了讓示範和後面的算術對得上。這也是為什麼成本要在「真的打完那一下」記——只有 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 課)。
- 降級要「可見」:
summarizeWithFallback把tierUsed/degraded/note寫進回傳值,一路帶到 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,把「用量」真的變成一張帳單和一張發票——那時你收到的第一筆錢,背後就是這一課算清的那個正號。