第 8 課 · 控制預算(上):把「跑一次多少錢」壓成可預測、可控
第 7 課結尾,你手上那台機器已經很像個產品了:它記得上週、只報 diff,扛下了「記憶+比對」這兩件累活。但我在那一課最後埋了一個沒回答的問題:
每記一次、每比一次,到底花了多少錢?
現在來面對它。假設你上線三個月,某天一位可能的投資人問你一句最基本的話:「你們跑一次多少錢?」 你打開供應商的帳單後台——只看得到一個「這個月總共 $840」的數字。跑一次多少錢?分不出來。哪個客戶最燒錢?不知道。你只能支支吾吾地說「呃……平均下來大概……」。
更糟的還在後面。你翻明細,發現有一位追 40 個競品的重度使用者,某週一個競品的 changelog 抓回來是一坨畸形 HTML,你那個「起草 → 自我批評 → 重寫」的迴圈嫌棄了自己 20 次、每一圈都是一次貴模型呼叫……那個月,這個付你 $149 的客戶,悄悄燒掉了你 $190 的 token。
這就是第 1 課那句警告活生生的樣子:
單位經濟(unit economics)是負的 = 賣越多虧越多。這不是生意,是慈善。
這一課,我們把「一次執行的成本」從一個月底才知道的謎,變成一個開跑前就估得出、跑的過程中壓得住、超過就跳閘的數字。用四道閘:
- 預算閘(budget gate)——每個節點花錢前先估、累計、超過上限就擋下或降級。
- 模型分流(model routing)——便宜的活丟便宜模型,只有決策級的才動用貴模型。
- 快取(cache)——同一份計算別重付第二次。
- max-iteration(迭代天花板)——擋掉那種會鬼打牆、燒錢燒到天荒地老的失控迴圈。
💡 先把本課的邊界釘死,免得你期待落空:這一課只管**「把每次執行的成本壓到可預測、可控」這一半**。把這些成本記錄變成計費指標、算毛利、設計一整條系統化的降級(fallback)階梯、回答「每跑一次到底賺不賺」——那是單位經濟的另一半,全部留給第 9 課。這一課你只要先能拍胸脯說出「這次執行最多花 X,實際花了 Y」。
一、先算一筆帳:一次執行的錢,到底花在哪
在寫任何一道閘之前,先看清楚錢是怎麼漏的。回想我們這條 graph 的骨架(第 2~7 課一路蓋起來的):
flowchart LR
F["fetch:抓多來源(免費)"] --> DE["dedup:同一批去重(免費)"]
DE --> CO["condense:把每則濃縮成一句(付費 LLM)"]
CO --> DF["diff:比上週、只留變化(免費)"]
DF --> DR["draft:起草決策級週報(付費 LLM)"]
DR --> OUT["送出 → control panel"]
盯著這張圖,第一個關鍵觀察就跳出來了:只有跨過 seam 的那幾個節點會花錢。
- 抓取(fetch) 是第 3 課的活,呼叫的是你自己寫的工具函式,不花 LLM 的錢。
- 去重(dedup,第 4 課) 和 diff(第 7 課) 都是純邏輯,一毛不花。
- 只有
condense、draft這種「真的打 LLM」的節點——也就是坐在 seam(LLM 邊界) 另一側的那些——才會產生費用。
所以「一次執行的成本」,攤開來就是一條很樸素的算式:
一次執行的成本 = 把這次所有 LLM 呼叫的(輸入 token × 輸入單價 + 輸出 token × 輸出單價)加總起來。
沒有魔法。錢就花在 token 上,而 token 只在 seam 後面燒。這件事有個很爽的推論,也是這一課的工程主軸:
💡 算錢是純算術,花錢才在 seam。 「這次會花多少」「累計花了多少」「超過上限沒」——這些全是不需要金鑰、可以離線算的純邏輯。真正會連網、會計費、結果不確定的,只有 seam 後面那一下
model.complete(...)。所以我們可以把整套預算控制的大腦,做成一坨離線就能測到死的純函式,而把「真的花錢」關在 seam 後面當示範。這正是第 2 課那條 seam 在這一課的兌現。
給你一個能扛整章的比喻:一次執行,就是一趟計程車。 你想要的是——上車前估得出大概多少錢(估算)、跳表一路跳你看得見(記帳)、而且設了一條「跳到這個數就必須靠邊停」的硬上限(預算閘+迭代天花板)。至於模型分流,是「短程別叫豪華車隊」;快取,是「同一段路上週剛跑過、結果一樣,這趟直接沿用、別重新跳一次表」。
四道閘各擋一種漏法,先讓你有張全景:
| 閘 | 擋掉哪一種漏錢 | 一句話 |
|---|---|---|
| 預算閘(budget gate) | 單次執行成本失控、無上限 | 花之前先估、超上限就擋下或降級 |
| 模型分流(model routing) | 拿貴模型做便宜的活 | 便宜的活丟便宜模型 |
| 快取(cache) | 同一份計算重付好幾次 | 算過的別重算重付 |
| max-iteration(迭代天花板) | 迴圈鬼打牆、燒到天荒地老 | 繞 N 圈到頂就收工 |
專案沿用第 2 課的設定(ESM、TypeScript、tsx、@langchain/langgraph)。這一課的程式碼會放在一個 budget/ 資料夾裡,一塊一塊長出來:
budget/
├── pricing.ts # 純邏輯:計價、粗估 token
├── ledger.ts # 純邏輯:記帳、累計、對上限
├── routing.ts # 純邏輯:模型分流
├── cache.ts # 純邏輯:算 cache key + 儲存介面
├── seam.ts # MeteredModel 介面 + stub 假模型 + 真模型(示範)
├── gate.ts # decideGate(純邏輯)+ runWithGate(接 seam)
└── *.test.ts # 全部離線、零金鑰
二、把「錢」變成純邏輯:計價、估算、記帳
先打地基:一套完全不需要金鑰的計價與記帳邏輯。這一節寫完,你就能離線算出「任何一串 LLM 呼叫值多少錢」。
2.1 計價表與估算(pricing.ts)
// budget/pricing.ts —— 純邏輯:計價與估算,不碰網路、不需要金鑰
// 兩檔模型:便宜的 / 貴的。示範用兩個抽象檔位,對應到真實模型見 seam.ts
export type ModelId = "haiku" | "sonnet";
// 每「百萬 token」的美元單價(示範價;以你供應商當下的實際定價為準)
export interface ModelPrice {
inputPerM: number; // 輸入 $/1M tokens
outputPerM: number; // 輸出 $/1M tokens
}
export const PRICE_TABLE: Record<ModelId, ModelPrice> = {
haiku: { inputPerM: 0.80, outputPerM: 4.00 }, // 便宜檔
sonnet: { inputPerM: 3.00, outputPerM: 15.00 }, // 貴檔(約 3.75 倍)
};
// 一次呼叫實際用掉的 token 數(真實版由 API 回來的 usage 填)
export interface Usage {
inputTokens: number;
outputTokens: number;
}
// 純算術:一次呼叫值多少錢(美元)
export function costOf(model: ModelId, usage: Usage): number {
const p = PRICE_TABLE[model];
return (usage.inputTokens * p.inputPerM + usage.outputTokens * p.outputPerM) / 1_000_000;
}
// 粗估 token:花錢「之前」先估。這是「估」不是「量」——真實數字要等 API 回 usage。
// 英文約 4 字元/token、中文更接近 1.5~2 字元/token;這裡取一個偏高的粗略係數,
// 寧可把預算抓緊一點,也不要估太低害閘門形同虛設。
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 3);
}
三個地方值得停一下:
costOf就是那條算式的程式碼:輸入 token 乘輸入單價、輸出 token 乘輸出單價、除以一百萬。整章所有「值多少錢」的問題,最後都收斂到這一個純函式。estimateTokens是「估」,不是「量」。它在花錢之前給你一個粗略上界,好讓閘門能在呼叫之前做決定。⚠️ 它必然不準——真實 token 數要等 API 回來的usage才知道。所以我們的紀律是:估算用來「擋」,實際 usage 用來「記帳」。 這兩個數字幹兩件不同的事,等一下你會看到它們各自的用途。- 價格表清楚標了「示範價」。真實單價供應商會調、會依模型不同,你上線前務必換成當下的實際定價——但算法一個字都不用改,這正是把價格抽成一張表的好處。
2.2 記帳:累計與對上限(ledger.ts)
有了「一次呼叫值多少錢」,接著要能把一次執行裡的每一筆花費記下來、累計、對上限。
// budget/ledger.ts —— 純邏輯:記帳、累計、對上限
import { costOf, type ModelId, type Usage } from "./pricing";
// 一筆花費:哪個節點、用哪個模型、花了多少 token、換算多少錢
export interface CostEntry {
node: string;
model: ModelId;
usage: Usage;
usd: number;
cached?: boolean; // 這筆是不是快取命中(省下的錢)——第五節會用到
}
// 把「一次呼叫」記成一筆帳(usd 由 costOf 算,不手動填)
export function entryFor(node: string, model: ModelId, usage: Usage): CostEntry {
return { node, model, usage, usd: costOf(model, usage) };
}
// 累計:這串花費總共多少錢
export function totalUsd(entries: CostEntry[]): number {
return entries.reduce((sum, e) => sum + e.usd, 0);
}
// 對上限:在 capUsd 這條天花板下,還剩多少可花
export function remainingUsd(capUsd: number, entries: CostEntry[]): number {
return capUsd - totalUsd(entries);
}
這三個函式加起來,就是一本可離線算的帳本。它不知道什麼是 LLM、什麼是網路——它只認 CostEntry 的陣列,做加減法。
2.3 純邏輯就該離線測到死
因為 pricing 和 ledger 全是純函式,測試不需要金鑰、不需要網路、毫秒級跑完——用 Node 內建的測試器就好(沿用第 3、7 課的 node:test):
// budget/ledger.test.ts —— node --test,零金鑰
import { test } from "node:test";
import assert from "node:assert/strict";
import { costOf } from "./pricing";
import { entryFor, totalUsd, remainingUsd, type CostEntry } from "./ledger";
test("costOf:便宜檔與貴檔的算術,且貴檔約 3.75 倍", () => {
const usage = { inputTokens: 1000, outputTokens: 1000 };
// haiku = (1000*0.8 + 1000*4)/1e6 = 4800/1e6 = 0.0048
assert.equal(costOf("haiku", usage), 0.0048);
// sonnet = (1000*3 + 1000*15)/1e6 = 18000/1e6 = 0.018
assert.equal(costOf("sonnet", usage), 0.018);
assert.ok(Math.abs(costOf("sonnet", usage) / costOf("haiku", usage) - 3.75) < 1e-9);
});
test("記帳:累計與剩餘額度", () => {
const entries: CostEntry[] = [
entryFor("condense", "haiku", { inputTokens: 500, outputTokens: 60 }),
entryFor("draft", "sonnet", { inputTokens: 1500, outputTokens: 400 }),
];
// condense = (500*0.8 + 60*4)/1e6 = 640/1e6 = 0.00064
// draft = (1500*3 + 400*15)/1e6 = 10500/1e6 = 0.0105
assert.equal(totalUsd(entries), 0.00064 + 0.0105);
assert.equal(remainingUsd(0.05, entries).toFixed(5), (0.05 - 0.01114).toFixed(5));
});
node --import tsx --test budget/ledger.test.ts
停下來看清楚這個測試證明了什麼:你產品最關鍵的一塊商業邏輯——「這次執行值多少錢、還剩多少額度」——是全系統裡最好測、最穩、跑起來一毛錢不用花的一塊。這就是把算錢和花錢在 seam 切開的第一份紅利,跟第 7 課「diff 是純邏輯所以離線測到死」是同一個念頭。
💡 你可能注意到
draft那筆花費($0.0105)是condense($0.00064)的十幾倍。這不是巧合——它一半來自「輸出比較長」,另一半來自「用了貴模型」。後者正是下一節(第四節)模型分流要處理的事:不是每個節點都配得上 sonnet。先記住這個數字,等等回來對照。
三、per-node 預算閘(budget gate):估、累計、超過就擋下或降級
地基打好了。現在裝第一道閘。
**預算閘(budget gate)**要做的事,用一句話說就是:在某個節點真的花錢之前,先估這次要花多少、加上目前累計、看看撞不撞上這次執行的上限——撞上了,就擋下或降級。
3.1 閘的「決策」是純邏輯
先把 seam 定義出來——一次「計量的」LLM 呼叫,它除了回文字,還要回這次用掉多少 token(好讓我們記帳):
// budget/seam.ts —— seam(LLM 邊界):一次計量的呼叫
import type { ModelId, Usage } from "./pricing";
// graph 只認得這個介面,不認得背後是 stub 還是真模型(沿用第 2 課的 seam 精神)
export interface MeteredModel {
complete(req: {
model: ModelId;
prompt: string;
maxOutputTokens: number;
}): Promise<{ text: string; usage: Usage }>;
}
接著是這一節的主角:decideGate。注意它是純函式——它只做估算、加減、比大小,完全不呼叫任何模型、不需要金鑰:
// budget/gate.ts —— 閘的「決策」是純邏輯;真正的呼叫在 seam 後面
import { estimateTokens, type ModelId } from "./pricing";
import { entryFor, remainingUsd, type CostEntry } from "./ledger";
export interface GateInput {
node: string;
preferred: ModelId; // 這個節點本來想用的模型
cheaper?: ModelId; // 預算吃緊時可降級到的便宜模型(可選)
prompt: string;
maxOutputTokens: number;
capUsd: number; // 這次執行的預算上限
spentSoFar: CostEntry[]; // 目前累計花費
}
export type GateDecision =
| { action: "proceed"; model: ModelId; estUsd: number } // 照原計畫用 preferred
| { action: "downgrade"; model: ModelId; estUsd: number } // 改用便宜模型
| { action: "block"; estUsd: number }; // 連便宜的都超 → 擋下
// 純邏輯:花錢之前先估、對上限,決定 proceed / downgrade / block。
export function decideGate(input: GateInput): GateDecision {
const remaining = remainingUsd(input.capUsd, input.spentSoFar);
// 估算時,輸出用「最壞情況」= maxOutputTokens:預算要撐得住最貴的可能
const estWith = (m: ModelId): number =>
entryFor(input.node, m, {
inputTokens: estimateTokens(input.prompt),
outputTokens: input.maxOutputTokens,
}).usd;
const estPreferred = estWith(input.preferred);
if (estPreferred <= remaining) {
return { action: "proceed", model: input.preferred, estUsd: estPreferred };
}
// 想用的太貴 → 試試降級到便宜模型
if (input.cheaper) {
const estCheap = estWith(input.cheaper);
if (estCheap <= remaining) {
return { action: "downgrade", model: input.cheaper, estUsd: estCheap };
}
}
// 連便宜的都塞不下 → 擋下
return { action: "block", estUsd: estPreferred };
}
這顆純邏輯把 mustAnswer 的第一題整個答完了——「估算並累計每個節點的花費、超過上限就擋下或降級」逐字對得上程式碼:estWith 是估算、remaining 是累計後的剩餘額度、三個 action 就是擋下(block)或降級(downgrade)。
一個關鍵設計決定,值得你點頭記下:
⚠️ 估算時,輸出 token 一律用最壞情況(
maxOutputTokens)。 為什麼不用「平均輸出」?因為呼叫之前你根本不知道模型會吐多長。用最壞情況估,換來的是一個硬承諾:閘門放行的每一筆,都保證「就算模型把maxOutputTokens吐好吐滿,也不會超過上限」。代價是有時會過度保守——本來付得起的呼叫被降級或擋下。這是個刻意的取捨:本課的賣點是「可預測的上界」,寧可偶爾保守,不要偶爾爆表。哪天你更在乎吞吐量,可以把估算換成 p50 輸出,但那條上限就從「硬保證」變回「軟目標」了。
3.2 決策(純邏輯)+ 呼叫(seam)接起來
decideGate 只做決定,不花錢。真正跨過 seam、並且記下「實際」花費的,是這個 runtime 包裝:
// budget/gate.ts(續)
import type { MeteredModel } from "./seam";
export type GateResult =
| { ok: true; text: string; entry: CostEntry }
| { ok: false; reason: "blocked"; estUsd: number };
export async function runWithGate(model: MeteredModel, input: GateInput): Promise<GateResult> {
const decision = decideGate(input); // ← 純邏輯先算,一毛不花
if (decision.action === "block") {
return { ok: false, reason: "blocked", estUsd: decision.estUsd };
}
// seam:唯一真的花錢的一下
const res = await model.complete({
model: decision.model,
prompt: input.prompt,
maxOutputTokens: input.maxOutputTokens,
});
// 記「實際」花費(用 API 回來的 usage,不是估的)
// ── 估用來「擋」,實際用來「記帳」,兩個數字各司其職 ──
const entry = entryFor(input.node, decision.model, res.usage);
return { ok: true, text: res.text, entry };
}
看清楚估算與實際的分工:decideGate 用估算擋門(花之前);runWithGate 用 API 回來的實際 usage 記帳(花之後)。這是預算控制的核心節奏——估在前,記在後。
3.3 讓花費累計進 state,並在節點裡用它
累計花在哪?答案是 graph 的 state(第 2 課的 state,這裡只用不重定義)。加一個 spend channel,用「附加」reducer,讓每個節點把自己的花費接進這次執行的總帳:
// budget/state.ts —— 只展示跟預算有關的 channel(其餘沿用第 3、7 課)
import { Annotation } from "@langchain/langgraph";
import type { CostEntry } from "./ledger";
export const BudgetState = Annotation.Root({
// …competitor / rawItems / changes 等,沿用前面課的定義…
capUsd: Annotation<number>({ reducer: (_p, n) => n, default: () => 0.05 }),
spend: Annotation<CostEntry[]>({
reducer: (a, b) => a.concat(b), // 附加:每個節點記一筆,累計成總帳
default: () => [],
}),
});
export type BudgetStateT = typeof BudgetState.State;
💡 用「附加」reducer 而不是「覆蓋」,是刻意的——我們要的是每一筆花費的明細(哪個節點、哪個模型、多少錢),不是只留一個總數。這份明細,第 9 課會拿去算毛利、第 11 課會拿去餵 Stripe 做用量計費(metered billing)。跟第 3 課的
rawItems、第 5 課的fetchLog一樣——現在多存的結構化明細,後面每一課都撿得起來用。
現在一個真實的 LLM 節點長這樣。注意被擋下(block)時,它怎麼優雅退場而不是崩掉:
// budget/nodes.ts
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { runWithGate } from "./gate";
import type { MeteredModel } from "./seam";
import type { BudgetStateT } from "./state";
export function makeDraftNode(model: MeteredModel) {
return async function draftNode(state: BudgetStateT): Promise<Partial<BudgetStateT>> {
const prompt = `把下列變化寫成一封決策級競品情報週報:\n${JSON.stringify(state.changes)}`;
const res = await runWithGate(model, {
node: "draft",
preferred: "sonnet", // 決策級:本來想用貴模型
cheaper: "haiku", // 但預算吃緊時允許降級
prompt,
maxOutputTokens: 600,
capUsd: state.capUsd,
spentSoFar: state.spend,
});
if (!res.ok) {
// 被擋下:退到「不花 LLM」的退路——標記待審、交真人核可,別硬送一份沒把握的東西
return { draft: "(本週預算已用盡,未產生新草稿,已轉真人審核)" };
}
return { draft: res.text, spend: [res.entry] }; // ← 這一筆花費被附加進總帳
};
}
3.4 用 stub 離線測「擋下 / 降級 / 放行」
因為 decideGate 是純邏輯、MeteredModel 是注入的,整套閘門邏輯離線就能測到死。先測純決策:
// budget/gate.test.ts —— node --test,零金鑰
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideGate } from "./gate";
import type { CostEntry } from "./ledger";
// 固定 100 個估算輸入 token(length 300 / 3),maxOutput 400
// est sonnet = (100*3 + 400*15)/1e6 = 6300/1e6 = 0.0063
// est haiku = (100*0.8 + 400*4)/1e6 = 1680/1e6 = 0.00168
const base = {
node: "draft",
preferred: "sonnet" as const,
cheaper: "haiku" as const,
prompt: "x".repeat(300),
maxOutputTokens: 400,
};
const spent = (usd: number): CostEntry[] =>
[{ node: "prev", model: "sonnet", usage: { inputTokens: 0, outputTokens: 0 }, usd }];
test("額度充足 → proceed,用貴模型", () => {
const d = decideGate({ ...base, capUsd: 0.05, spentSoFar: [] });
assert.equal(d.action, "proceed");
assert.equal(d.action === "proceed" && d.model, "sonnet");
});
test("貴的塞不下、便宜的還行 → downgrade", () => {
// 剩 0.05 - 0.047 = 0.003:sonnet(0.0063) 超、haiku(0.00168) 過 → 降級
const d = decideGate({ ...base, capUsd: 0.05, spentSoFar: spent(0.047) });
assert.equal(d.action, "downgrade");
assert.equal(d.action === "downgrade" && d.model, "haiku");
});
test("連便宜的都塞不下 → block", () => {
// 剩 0.05 - 0.049 = 0.001:連 haiku(0.00168) 都超 → 擋下
const d = decideGate({ ...base, capUsd: 0.05, spentSoFar: spent(0.049) });
assert.equal(d.action, "block");
});
再測「接上 seam」的整條路——用一個 stub 假模型,回固定 usage,證明放行會記帳、擋下會優雅回傳:
// budget/gate.integration.test.ts —— 離線,用 stub
import { test } from "node:test";
import assert from "node:assert/strict";
import { runWithGate } from "./gate";
import type { MeteredModel } from "./seam";
// stub 假模型:不打網路、回固定 usage,方便斷言
const stub: MeteredModel = {
async complete(req) {
return { text: `【stub】${req.model}`, usage: { inputTokens: 120, outputTokens: 80 } };
},
};
test("放行 → 走 seam、用實際 usage 記一筆帳", async () => {
const res = await runWithGate(stub, {
node: "draft", preferred: "sonnet", prompt: "hi", maxOutputTokens: 400,
capUsd: 0.05, spentSoFar: [],
});
assert.ok(res.ok);
// 記的是「實際」usage(120/80),不是估算的 maxOutputTokens
assert.equal(res.ok && res.entry.usage.outputTokens, 80);
});
test("擋下 → 不走 seam、回 blocked", async () => {
const res = await runWithGate(stub, {
node: "draft", preferred: "sonnet", prompt: "x".repeat(300), maxOutputTokens: 400,
capUsd: 0.001, spentSoFar: [], // 額度小到連 haiku 都塞不下
});
assert.equal(res.ok, false);
});
node --import tsx --test budget/gate.test.ts budget/gate.integration.test.ts
整套預算閘的行為,離線、零金鑰、毫秒級全測到了。 上線時把 stub 換成真模型(下一節給),閘門邏輯一個字都不用動。
⚠️ 一個誠實的邊界:這道閘是「每次執行的軟上限」,不是分散式硬鎖。 回想第 4 課——多個競品是平行跑的。若兩個節點在同一個 super-step 同時讀
state.spend,它們看到的是同一個快照,於是可能雙雙以為「還有預算」而各自花下去,真實總額只在它們合流後才揭曉。所以本課的閘保證的是「開跑前估得出上界、每一步都在對帳」,對絕大多數情況(尤其把貴的draft放在平行分支合流之後)綽綽有餘。要在大規模平行下做到「一分不差的硬上限」,得把貴呼叫串行化、或加一個協調者統一發額度——那是更進階的取捨,不在本課範圍。知道規則,也知道它的邊界。
四、模型分流(model routing):便宜的活丟便宜模型
上一節你看到 draft 那筆花費是 condense 的十幾倍,一半原因是「用了 sonnet」。這一節就處理它:不是每個 LLM 呼叫都配得上貴模型。
4.1 依「任務種類」分流
核心觀念很直白:把 LLM 要做的活,按難度與利害分檔。在我們的競品情報週報裡:
- 便宜的活:把一則 changelog 濃縮成一句話、把一則變化分級成 red/yellow、正規化一段文字。這些又多又雜、但都不難,
haiku綽綽有餘。 - 貴的活:起草那封會被送進 PM 信箱、被拿去開會、被拿去做定價決策的週報總結。這是決策級的,回想第 6 課那個把 $12 讀成 $79 的教訓——這一步賣的是信任,不能省。
// budget/routing.ts —— 純邏輯:依「任務種類」分流
import type { ModelId } from "./pricing";
// 任務種類——由固定步驟決定,不是讓 LLM 自己挑(回指第 3 課)
export type Task =
| "condense" // 把一則來源濃縮成一句 —— 便宜
| "classify" // 把一則變化分級 red/yellow —— 便宜
| "digest_draft"; // 起草決策級週報 —— 貴
export function routeModel(task: Task): ModelId {
switch (task) {
case "condense":
case "classify":
return "haiku"; // 便宜、夠用
case "digest_draft":
return "sonnet"; // 決策級:這封信會被拿去開會,別省
}
}
⚠️ 分流是「你」按固定任務種類決定的,不是讓 LLM 臨場挑模型。 這條線第 3 課劃過:workflow 裡「要做什麼」是固定步驟決定的,不是 agent 式的臨場探索。讓模型自己決定「這題該用貴的還是便宜的」,等於為了省錢先多燒一輪 token 去問,還把一個可測的純函式變成不可測的模型輸出。能寫死的,別花錢讓模型猜——這裡照樣成立。(回指 ai-you-should-know 第 5 章的 workflow vs agent。)
4.2 省在哪?把帳算給你看
「便宜模型省錢」不能只是一句口號,算給你看。假設處理一個競品要跑這些呼叫:
// budget/routing.ts(續)—— 純邏輯:估一次執行、比較「全用貴的」vs「分流」
import { costOf, type ModelId } from "./pricing";
export interface PlannedCall {
task: Task;
inputTokens: number;
outputTokens: number;
}
export function estimateRunCost(calls: PlannedCall[], pick: (t: Task) => ModelId): number {
return calls.reduce(
(sum, c) => sum + costOf(pick(c.task), { inputTokens: c.inputTokens, outputTokens: c.outputTokens }),
0,
);
}
// 一個競品的典型呼叫組合:8 通便宜的雜活 + 1 通貴的決策級起草
export const TYPICAL_RUN: PlannedCall[] = [
...Array.from({ length: 8 }, (): PlannedCall => ({ task: "condense", inputTokens: 500, outputTokens: 60 })),
{ task: "digest_draft", inputTokens: 1500, outputTokens: 400 },
];
跑個小 demo 比一比:
// budget/routing-demo.ts —— tsx budget/routing-demo.ts(零金鑰)
import { estimateRunCost, routeModel, TYPICAL_RUN } from "./routing";
const allSonnet = estimateRunCost(TYPICAL_RUN, () => "sonnet"); // 全用貴的
const routed = estimateRunCost(TYPICAL_RUN, routeModel); // 分流
console.log("全用 sonnet:$" + allSonnet.toFixed(5));
console.log("分流後 :$" + routed.toFixed(5));
console.log("省下 :" + ((1 - routed / allSonnet) * 100).toFixed(0) + "%");
// 全用 sonnet:$0.02970 (8*0.0024 + 0.0105)
// 分流後 :$0.01562 (8*0.00064 + 0.0105)
// 省下 :47%
一個競品就省了將近一半。而且便宜的活越多,這個差距越大:那 8 通雜活如果變成 30 通(追更多來源、更細的分級),全用貴模型的帳單會直線上升,分流卻幾乎只多花便宜檔的錢。這就是「省在哪」的答案——把貴模型的預算,集中花在那唯一一通真正需要它的 digest_draft 上。
順手測一下這個「省」是真的:
// budget/routing.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { estimateRunCost, routeModel, TYPICAL_RUN } from "./routing";
test("分流比全用貴模型便宜,且決策級那通仍走貴模型", () => {
const allSonnet = estimateRunCost(TYPICAL_RUN, () => "sonnet");
const routed = estimateRunCost(TYPICAL_RUN, routeModel);
assert.ok(routed < allSonnet * 0.6, "分流應省下四成以上");
assert.equal(routeModel("digest_draft"), "sonnet"); // 決策級不准被省掉
});
4.3 分流與預算閘怎麼組合
這兩道閘不是二選一,它們回答兩個不同的問題:
- 模型分流問:「這個活,配得上貴模型嗎?」——按利害選對的模型。
- 預算閘問:「現在,還付得起這個模型嗎?」——按剩餘額度決定要不要再降。
所以它們天生疊在一起:routeModel(task) 給出這個節點的 preferred,而預算閘的 cheaper 是它的降級目標。分流先挑好該用的模型,閘門只在錢真的不夠時才進一步往下降。
flowchart TB
T["節點要做某個 task"] --> R["routeModel(task):這活配得上貴模型嗎?"]
R --> G["decideGate:現在還付得起嗎?"]
G -->|"付得起"| P["proceed:用選好的模型"]
G -->|"付不起、但便宜的行"| D["downgrade:退到便宜模型"]
G -->|"連便宜的都不行"| B["block:擋下 → 退到不花 LLM 的退路"]
看出來了嗎——「降級到便宜模型」其實就是模型分流的機制被預算閘借去用了。 這也是 mustAnswer 第一題那個「降級」的具體長相之一。
⚠️ 別為了省幾分錢,把決策級的活分流去便宜模型。 這是最容易犯的錯:看到
digest_draft最貴,手一癢把它也丟給 haiku。但那封信一旦出現一個 $79 的幻覺數字(第 6 課),賠掉的是一個 $149/月的訂閱,遠不是你省下的那 $0.008。分流的判準是「利害」,不是「單純挑最便宜」——便宜是為了把預算集中花在該花的地方,不是無腦省。一整條「錢真的吃緊時該怎麼有尊嚴地退場」的系統化降級策略,是第 9 課的事。
五、快取(cache):算過的別重付第二次
第三道閘,處理另一種漏法:同一份計算,付了第二次錢。
5.1 為什麼會重付,怎麼不重付
想想這些場景,全是白花的錢:
- 某競品的定價頁這週跟上週一模一樣,你卻又叫 LLM 把它抽取、濃縮了一遍。
- 兩個競品的某則 changelog 內容恰好相同(同一則產業新聞),你濃縮了兩次。
- 一次執行失敗重試,把已經成功濃縮過的東西又跑一遍。
解法就一句話:同樣的輸入 → 同樣的輸出 → 只算一次、存起來、下次直接拿。 這就是快取(cache)。
而快取的鑰匙——cache key——又是一段純邏輯:拿「模型 + prompt」算一個**內容定址(content-addressed)**的雜湊。輸入變了,key 就變;輸入沒變,key 就一樣、直接命中。
// budget/cache.ts —— 純邏輯算 key;儲存介面可抽換
import { createHash } from "node:crypto";
import type { ModelId, Usage } from "./pricing";
// 內容定址:同樣的 (模型, prompt) → 同樣的 key
export function cacheKey(model: ModelId, prompt: string): string {
return createHash("sha256").update(`${model}\n${prompt}`).digest("hex").slice(0, 32);
}
// 快取儲存的介面——測試塞記憶體 Map,上線塞第 7 課的 Store
export interface CacheStore {
get(key: string): Promise<{ text: string; usage: Usage } | null>;
set(key: string, value: { text: string; usage: Usage }): Promise<void>;
}
包裝一個「先查快取、沒中才花錢」的呼叫:
// budget/cache.ts(續)
import type { MeteredModel } from "./seam";
import { entryFor, type CostEntry } from "./ledger";
export async function runCached(
model: MeteredModel,
store: CacheStore,
req: { node: string; model: ModelId; prompt: string; maxOutputTokens: number },
): Promise<{ text: string; entry: CostEntry; cached: boolean }> {
const key = cacheKey(req.model, req.prompt);
const hit = await store.get(key);
if (hit) {
// 命中:完全不打 LLM。記一筆「$0 且 cached」,讓帳目誠實反映「這次沒花錢」
const entry: CostEntry = {
node: req.node, model: req.model,
usage: { inputTokens: 0, outputTokens: 0 }, usd: 0, cached: true,
};
return { text: hit.text, entry, cached: true };
}
// 沒中:走 seam 花錢,並把結果存起來給下次
const res = await model.complete(req);
await store.set(key, { text: res.text, usage: res.usage });
return { text: res.text, entry: entryFor(req.node, req.model, res.usage), cached: false };
}
💡 命中時為什麼還記一筆
$0的帳? 因為你要看得見快取幫你省了多少。帳本裡一排cached: true, usd: 0,就是白紙黑字的省錢證據——第 9 課算單位經濟時,「快取命中率」會是你最愛盯的一個數字。省了錢卻不記錄,等於省得不明不白。
5.2 快取存哪:用第 7 課的 Store,不重造
CacheStore 只是個介面。測試時塞一個記憶體 Map;上線時塞什麼?
回想第 7 課的分工判準:屬於某一個競品 → checkpointer;屬於整個帳號/全域 → Store。 一個內容定址的快取(key 是內容雜湊、跟哪個競品無關、誰算出來大家都能重用)顯然屬於後者——它是跨 thread、可重用的共用知識,正是 Store(跨 thread 記憶) 的地盤。接上去只是一層薄薄的 adapter:
// budget/cache-store.ts —— 把第 7 課的 Store 包成 CacheStore(只用不重定義)
import type { InMemoryStore } from "@langchain/langgraph";
import type { CacheStore } from "./cache";
import type { Usage } from "./pricing";
const NS = ["cache", "llm"]; // Store 的命名空間(第 7 課教過)
export function storeAsCache(store: InMemoryStore): CacheStore {
return {
async get(key) {
const item = await store.get(NS, key);
return (item?.value as { text: string; usage: Usage }) ?? null;
},
async set(key, value) {
await store.put(NS, key, value);
},
};
}
💡 Store 的完整定義、
namespace語意、跟 checkpointer 的分工,全是第 7 課的內容,這裡只是拿來用。至於「這份快取怎麼真正跨程序、跨部署持久化、按租戶隔離」——接 Postgres、多租戶(multi-tenant)是第 10 課的事。本課的記憶體 Store 先讓你看到快取的形狀。
5.3 快取最危險的坑:該變的沒變
快取用起來爽,但它有一個經典的、會咬人的坑,一定要講在明處:
⚠️ 快取失效(cache invalidation)——「該變的東西你卻拿了舊的」,是快取最危險的 bug。 內容定址幫你擋掉一半:輸入一變、雜湊就變、自動 miss。但如果你的 prompt 裡藏了會過期的東西,就會出事,而且是兩個反方向的出事:
- key 太「敏感」→ 永遠 miss:如果你把整段原始 HTML(含每次都不同的抓取時間戳)塞進 prompt 去算 key,那每週 key 都不一樣,快取永遠命中不了,等於沒裝。
- key 太「遲鈍」→ 拿到過期的:如果快取的內容其實跟時間有關(例如「本週」的定義),卻沒把時間放進 key,你就會拿到上週的舊答案。
兩邊的解法是同一個紀律:只把「真正該影響輸出的內容」放進 key。這跟第 7 課 diff 忽略
fetchedAt是同一個念頭的一體兩面——diff 忽略沒意義的時間戳,好讓「其實沒變」被正確判成沒變;快取也要忽略它,好讓「其實一樣的內容」算出一樣的 key、命中快取。所以你 key 算的應該是正規化後的有意義內容(價格、功能、主標),不是那坨帶時間戳的原始 bytes。真的有時效性的東西,就在 key 裡明確加一個日期或版本、或給一個 TTL。
這帶出一個很漂亮的呼應:diff 和快取是一對表親,都在問「跟上次一樣嗎?」——只是diff 把「變了」報出來(產品價值),快取把「沒變」的重算跳過去(省成本)。同一份「這週定價頁跟上週一模一樣」的事實:快取據此跳過那通抽取呼叫、不重付;diff 據此正確地閉嘴、不報假變化。兩者一省錢、一保準,配合得天衣無縫。
5.4 離線測快取
// budget/cache.test.ts —— 記憶體 Map 當快取,stub 當模型,零金鑰
import { test } from "node:test";
import assert from "node:assert/strict";
import { runCached, type CacheStore } from "./cache";
import type { MeteredModel } from "./seam";
import type { Usage } from "./pricing";
function memCache(): CacheStore {
const m = new Map<string, { text: string; usage: Usage }>();
return {
async get(k) { return m.get(k) ?? null; },
async set(k, v) { m.set(k, v); },
};
}
test("第二次同樣輸入 → 命中快取、不打 LLM、記 $0", async () => {
let calls = 0;
const model: MeteredModel = {
async complete() { calls++; return { text: "濃縮結果", usage: { inputTokens: 500, outputTokens: 60 } }; },
};
const cache = memCache();
const req = { node: "condense", model: "haiku" as const, prompt: "Acme 上線 SSO", maxOutputTokens: 80 };
const first = await runCached(model, cache, req);
const second = await runCached(model, cache, req);
assert.equal(calls, 1); // 真模型只被打了一次
assert.equal(first.cached, false);
assert.equal(second.cached, true);
assert.equal(second.entry.usd, 0); // 命中那次記 $0
assert.equal(second.text, "濃縮結果");
});
node --import tsx --test budget/cache.test.ts
calls === 1 這行斷言就是整節的重點:同樣的輸入來第二次,真模型一次都沒被打——省下的錢,測試替你證明了。
六、max-iteration(迭代天花板):擋掉鬼打牆的失控迴圈
最後一道閘,處理開場那個 $190 帳單的元凶:會繞不出來的迴圈。
6.1 迴圈為什麼危險
前面三道閘管的是「單次呼叫」的成本。但有一種漏法它們都擋不住:一個會自己繞回去的迴圈。
還記得第 6 課那個伏筆嗎?當時談到「退回(reject)能不能讓 graph 重新起草一版」,我說:
「你一旦讓 graph 能『繞回去重跑』,就得防它繞不出來……那道『最多繞幾次』的護欄,就是 max-iteration(迭代天花板),是第 8 課的事。」
就是現在。任何一條繞回上游的邊——「起草 → 自我批評 → 不夠好就回去重寫」、「分析師退回 → 重新起草」——都藏著同一個風險:如果那個「夠不夠好」的判斷永遠說「不夠好」,這個迴圈就會一圈一圈轉下去,每一圈都是一次付費 LLM 呼叫。 那台機器就從印鈔機變成碎鈔機:凌晨三點那次執行永遠送不出去,帳單卻一路往上飆。
max-iteration 就是給這種迴圈一條硬上限:最多繞 N 圈,到頂就收工——不管它自認為好了沒。
6.2 用計數器 + 條件邊實作
實作樸素到你會鬆一口氣:在 state 放一個計數器,用一條**條件邊(conditional edge,第 6 課用過)**在「繞回去」之前檢查它。
// budget/refine.ts —— 一個會「起草 → 批評 → 也許重寫」的迴圈,但有天花板
import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import type { MeteredModel } from "./seam";
import { runWithGate } from "./gate";
import type { CostEntry } from "./ledger";
const MAX_ITER = 3; // 天花板:最多重寫 3 次
const RefineState = Annotation.Root({
changes: Annotation<string>({ reducer: (_p, n) => n, default: () => "" }),
draft: Annotation<string>({ reducer: (_p, n) => n, default: () => "" }),
goodEnough: Annotation<boolean>({ reducer: (_p, n) => n, default: () => false }),
iteration: Annotation<number>({ reducer: (_p, n) => n, default: () => 0 }),
capUsd: Annotation<number>({ reducer: (_p, n) => n, default: () => 0.05 }),
spend: Annotation<CostEntry[]>({ reducer: (a, b) => a.concat(b), default: () => [] }),
});
type RefineStateT = typeof RefineState.State;
export function buildRefineGraph(model: MeteredModel) {
// 起草/重寫:每繞一圈 iteration + 1
async function draft(state: RefineStateT): Promise<Partial<RefineStateT>> {
const res = await runWithGate(model, {
node: "draft", preferred: "sonnet", cheaper: "haiku",
prompt: `第 ${state.iteration + 1} 版草稿,根據:${state.changes}`,
maxOutputTokens: 600, capUsd: state.capUsd, spentSoFar: state.spend,
});
if (!res.ok) return { goodEnough: true, iteration: state.iteration + 1 }; // 預算擋下 → 別再繞
return { draft: res.text, spend: [res.entry], iteration: state.iteration + 1 };
}
// 自我批評:判斷夠不夠好(示範用長度當代理指標;真實版是另一通便宜 LLM)
function critique(state: RefineStateT): Partial<RefineStateT> {
return { goodEnough: state.draft.length >= 20 };
}
// 純邏輯路由:夠好了、或撞到天花板 → 收工;否則繞回去重寫
function route(state: RefineStateT): "refine" | "done" {
if (state.goodEnough) return "done";
if (state.iteration >= MAX_ITER) return "done"; // ← 天花板:到頂就停,不管好了沒
return "refine";
}
return new StateGraph(RefineState)
.addNode("draft", draft)
.addNode("critique", critique)
.addEdge(START, "draft")
.addEdge("draft", "critique")
.addConditionalEdges("critique", route, { refine: "draft", done: END })
.compile();
}
控制流長這樣——注意 critique 後面那條同時看「夠好沒」和「繞幾圈了」的條件邊:
flowchart TB
S(["START"]) --> D["draft:起草/重寫(iteration + 1,付費)"]
D --> C["critique:夠好了嗎?"]
C --> Q{"route:goodEnough 或 iteration ≥ MAX?"}
Q -->|"還不夠 且 沒到頂"| D
Q -->|"夠好 或 撞到天花板"| E(["END"])
那條 iteration >= MAX_ITER 就是整節的靈魂:就算模型永遠嫌自己不夠好,這個迴圈也最多轉 3 圈就強制收工。 最壞情況的成本,從「無限」變成「3 次 draft 呼叫」——一個你開跑前就算得出來的上界。
6.3 到頂時要「降級」,不是「崩潰」
撞到天花板之後怎麼辦,有個重要的取捨:
⚠️ 到頂時,要拿「目前最好的一版」優雅收工,別把成果全丟掉、更別讓它崩。 上面
route回done時,state 裡還躺著最後一版draft——我們讓它帶著這版繼續走(真實產品會把它轉去第 6 課那道 HITL 真人審核關卡,讓分析師拍板)。這叫降級(degradation):繞不出漂亮結果,就退到「一個堪用的結果 + 一面待審旗標」,而不是空手而歸。一整條系統化的降級階梯(sonnet → haiku → 快取 → 上週那版 → 純規則),是第 9 課的主題;這裡先讓你看到最基本的一格。
6.4 框架的最後防線:recursionLimit
LangGraph.js 自己也有一道防迴圈的網,你該知道它、但別只靠它:
💡 compile 出來的 graph 在
invoke時可以給{ recursionLimit: 25 }(預設 25)——這是框架層級的總步數上限,一旦 graph 走的 super-step 超過它,會直接丟出GraphRecursionError。它是一張粗魯的最後防線:擋得住「無限迴圈把伺服器跑死」,但它的行為是當場崩掉,不是優雅降級,而且它數的是「總步數」,不是「你這個迴圈繞了幾次」。所以正確的分工是:用你自己的iteration計數器 + 條件邊做「優雅、貼合語意」的天花板(到頂就拿最好那版收工);把recursionLimit當成「萬一我計數器寫錯了」的終極保險絲。 一句老話:框架給你保險絲,產品級的護欄要你自己搭——手刻懂原理、框架去出貨,這裡是兩者各司其職的好例子。(想知道「步數上限、狀態機怎麼自己實作」,那是姊妹系列 ai-pipeline-from-scratch 的題目。)
離線測天花板,一樣零金鑰——關鍵是塞一個**永遠說「不夠好」**的情境,證明它到頂會停、而且花費有上界:
// budget/refine.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildRefineGraph } from "./refine";
import type { MeteredModel } from "./seam";
// stub:永遠只吐一個字(長度 < 20 → critique 永遠判「不夠好」→ 逼迴圈想繞下去)
const stubbornModel: MeteredModel = {
async complete() { return { text: "短", usage: { inputTokens: 100, outputTokens: 5 } }; },
};
test("模型永遠不滿意,也最多繞 3 圈就停,且花費有上界", async () => {
const graph = buildRefineGraph(stubbornModel);
const final = await graph.invoke({ changes: "Acme 漲價", capUsd: 1 });
assert.equal(final.iteration, 3); // 撞到 MAX_ITER 就收工
assert.equal(final.spend.length, 3); // 只花了 3 次 draft 的錢,沒有失控
});
node --import tsx --test budget/refine.test.ts
iteration === 3 和 spend.length === 3——這兩行斷言就是你對抗開場那個 $190 帳單的護身符:無論模型多固執,最壞情況就是 3 次呼叫,離線可證。
七、四道閘裝進 graph:開跑前,你就估得出上界
把四道閘擺回競品情報週報的 graph,看它們各守一段:
flowchart LR
F["fetch(免費,ch3)"] --> DE["dedup(免費,ch4)"]
DE --> CO["condense:haiku(分流)+ 快取(省重付)"]
CO --> DF["diff(免費,ch7)"]
DF --> RF["draft 迴圈:sonnet(分流)+ 預算閘 + 迭代天花板"]
RF --> OUT["送出 → control panel(ch5)"]
LEDGER[("state.spend:每筆花費的總帳")] -.->|"每個付費節點記一筆"| CO
LEDGER -.-> RF
- 模型分流在每個付費節點挑對模型(
condense走便宜、draft走貴)。 - 快取讓
condense對「跟上週一樣」的來源不重付。 - 預算閘在
draft前估算、對上限,撐不住就降級或擋下。 - 迭代天花板保證
draft的重寫迴圈最多繞 N 圈。 - 而
state.spend這本總帳,把每一筆花費都收攏起來。
7.1 收尾的紅利:一份誠實的帳單
因為每一筆花費都進了 state.spend,你可以在執行結束時印出一份逐節點、逐模型的帳單——又是一段純邏輯:
// budget/bill.ts —— 純邏輯:把總帳收攏成一份可讀帳單
import { totalUsd, type CostEntry } from "./ledger";
export function summarizeBill(spend: CostEntry[]): {
totalUsd: number;
cachedHits: number;
byNode: Record<string, number>;
} {
const byNode: Record<string, number> = {};
let cachedHits = 0;
for (const e of spend) {
byNode[e.node] = (byNode[e.node] ?? 0) + e.usd;
if (e.cached) cachedHits++;
}
return { totalUsd: totalUsd(spend), cachedHits, byNode };
}
於是那個投資人問「跑一次多少錢?」時,你不再支支吾吾——你打開 state.spend,回答「這次 $0.0156,其中 draft 佔 $0.0105,condense 有 3 次快取命中省下的」。「跑一次多少錢」從月底的謎,變成每次執行都附帶的一行明細。
💡 這份帳單也能串進 control panel(第 5 課):讓使用者/你自己在儀表板上直接看到「這次執行花了多少、哪個節點最貴」。它跟第 5 課那句「使用者關掉分頁就
AbortController中止、別為沒人要看的結果白燒錢」是同一條省錢的線——一個在花之前擋、一個在花到一半喊停。
7.2 回到「純邏輯」:mustAnswer 第四題的總答
停下來看清楚整章的四道閘,會花錢的部分有多少。答案是:幾乎沒有。
- 計價(
costOf)、估算(estimateTokens)、記帳(ledger)、閘門決策(decideGate)、分流(routeModel)、算 cache key(cacheKey)、迴圈天花板(那個route判斷)、收帳單(summarizeBill)——全是純函式,零金鑰、離線、毫秒級可測。 - 真正需要金鑰、會連網、會計費的,只有 seam 後面那一下
model.complete(...)。
這就是 mustAnswer 第四題的答案,也是為什麼要這樣切:把「預算的大腦」做成純邏輯,你就能對整套成本控制寫出一整套離線測試,在每次 git push 都跑、擋掉「改壞了預算閘卻上線」這種會直接賠錢的 bug。 花錢的那一下留在 seam 後面當示範——想看真的計費,把 stub 換成真模型即可:
// budget/seam.ts(續)—— 真模型示範:需要金鑰、會計費
import Anthropic from "@anthropic-ai/sdk";
import type { MeteredModel } from "./seam";
import type { ModelId } from "./pricing";
const REAL: Record<ModelId, string> = {
haiku: "claude-3-5-haiku-latest",
sonnet: "claude-3-5-sonnet-latest",
};
export function makeAnthropicModel(): MeteredModel {
const client = new Anthropic(); // 自動讀 ANTHROPIC_API_KEY,別把金鑰寫死
return {
async complete(req) {
const res = await client.messages.create({
model: REAL[req.model],
max_tokens: req.maxOutputTokens,
messages: [{ role: "user", content: req.prompt }],
});
const first = res.content[0];
return {
text: first?.type === "text" ? first.text : "",
// ← 真實計費的關鍵:用 API 回來的實際 usage 記帳,不是估的
usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens },
};
},
};
}
注意 res.usage.input_tokens / output_tokens——真模型會告訴你這次「實際」燒了多少 token,這正是我們記帳要的實際數字。估算負責在花之前擋門,實際 usage 負責在花之後對帳,一前一後,閉環。
7.3 「可預測」到什麼程度?誠實劃界
最後把「可預測」這個承諾講精確,免得你以為它比實際更強:
⚠️ 這一課給你的是**「有保證的上界」+「夠準的估計」**,不是「精確到分」的預測。
- 上界是硬的:預算閘的
capUsd+ max-iteration 的MAX_ITER,聯手保證「這次執行最多花 X」——這個 X 你開跑前就算得出來,而且保證不會被突破(就算突破了,還有recursionLimit兜底)。- 精確值是軟的:實際花多少,取決於模型這次吐了多長、快取命中了幾次,這些要跑完才知道。
但對做生意來說,「有保證的上界」正是你要的那半——它讓你能安心地把月費定在上界之上,確保單位經濟不會是負的。至於「上界之下、平均花多少、毛利多少、還能怎麼壓」——那是第 9 課單位經濟的事。這一課,你已經把「失控的可能」關掉了。
這一課建立了什麼
把四道閘收攏成你可以帶走的心智模型:
- 一次執行的成本 = 所有 LLM 呼叫的(輸入 token × 單價 + 輸出 token × 單價)加總。 只有跨過 seam 的節點花錢;fetch/dedup/diff 都免費。推論:算錢是純算術(離線可測),花錢才在 seam(要金鑰)。
- 預算閘(budget gate):
decideGate是純邏輯——估算(輸出用最壞情況maxOutputTokens)、對累計後的剩餘額度,決定 proceed/downgrade/block。runWithGate接上 seam,並用 API 回來的實際 usage 記帳。口訣:估在前(用來擋)、記在後(用來對帳)。 累計花費放進state.spend(附加 reducer)。邊界:它是每次執行的軟上限,大規模平行下不是分散式硬鎖。 - 模型分流(model routing):
routeModel(task)按任務利害(不是讓 LLM 挑)把便宜的活丟便宜模型、決策級的才用貴模型;一個典型競品的執行就省了近一半,便宜的活越多省越多。它跟預算閘組合:分流問「配得上貴模型嗎」、閘問「還付得起嗎」,「降級」就是閘把分流機制借去用。邊界:決策級的別為省幾分錢分流去便宜模型——賠掉的是訂閱。 - 快取(cache):
cacheKey是內容定址的純邏輯雜湊;runCached先查再花、命中就記$0。儲存用第 7 課的 Store(內容定址=跨 thread 可重用=Store 的地盤),只用不重造。邊界:快取失效是最危險的坑——只把「真正該影響輸出的內容」放進 key(跟 diff 忽略fetchedAt是一體兩面),有時效的東西加日期或 TTL。diff 報「變了」、快取跳過「沒變」,是一對表親。 - max-iteration(迭代天花板):任何繞回上游的迴圈都要一個計數器 + 條件邊的硬上限,接住第 6 課那個「退回重寫」的環。到頂要優雅降級(拿最好那版收工/轉 HITL),不是崩。框架的
recursionLimit是粗魯的最後防線,你的iteration計數器才是產品級的優雅天花板。 - 「可預測」的精確含義:
capUsd+MAX_ITER給你一個開跑前算得出、保證不被突破的上界;精確值要跑完才知道。上界這半,就足以保證單位經濟不會失控。
你現在能拍胸脯說出「這次執行最多花 X、實際花了 Y、哪個節點最貴、快取省了幾次」——這就是把「跑一次多少錢」從謎變成明細。
但「成本可控」還不等於「賺錢」。下一課(第 9 課)我們接著把 state.spend 這本帳,變成真正的生意算術:設計一整條降級(fallback)策略(錢真的吃緊時怎麼有尊嚴地退場)、把成本算進單位經濟(unit economics)、回答那個終極問題——每跑一次,扣掉成本,到底賺不賺? 第 1 課那句話,第 9 課來兌現另一半:能不能收費,一半取決於能不能控制成本。