第 8 課 · 控制預算(上):把「跑一次多少錢」壓成可預測、可控

第 8 課 · 控制預算(上):把「跑一次多少錢」壓成可預測、可控

第 7 課結尾,你手上那台機器已經很像個產品了:它記得上週、只報 diff,扛下了「記憶+比對」這兩件累活。但我在那一課最後埋了一個沒回答的問題:

每記一次、每比一次,到底花了多少錢?

現在來面對它。假設你上線三個月,某天一位可能的投資人問你一句最基本的話:「你們跑一次多少錢?」 你打開供應商的帳單後台——只看得到一個「這個月總共 $840」的數字。跑一次多少錢?分不出來。哪個客戶最燒錢?不知道。你只能支支吾吾地說「呃……平均下來大概……」。

更糟的還在後面。你翻明細,發現有一位追 40 個競品的重度使用者,某週一個競品的 changelog 抓回來是一坨畸形 HTML,你那個「起草 → 自我批評 → 重寫」的迴圈嫌棄了自己 20 次、每一圈都是一次貴模型呼叫……那個月,這個付你 $149 的客戶,悄悄燒掉了你 $190 的 token。

這就是第 1 課那句警告活生生的樣子:

單位經濟(unit economics)是負的 = 賣越多虧越多。這不是生意,是慈善。

這一課,我們把「一次執行的成本」從一個月底才知道的謎,變成一個開跑前就估得出、跑的過程中壓得住、超過就跳閘的數字。用四道閘:

  1. 預算閘(budget gate)——每個節點花錢前先估、累計、超過上限就擋下或降級。
  2. 模型分流(model routing)——便宜的活丟便宜模型,只有決策級的才動用貴模型。
  3. 快取(cache)——同一份計算別重付第二次。
  4. 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 課) 都是純邏輯,一毛不花。
  • 只有 condensedraft 這種「真的打 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 純邏輯就該離線測到死

因為 pricingledger 全是純函式,測試不需要金鑰、不需要網路、毫秒級跑完——用 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 到頂時要「降級」,不是「崩潰」

撞到天花板之後怎麼辦,有個重要的取捨:

⚠️ 到頂時,要拿「目前最好的一版」優雅收工,別把成果全丟掉、更別讓它崩。 上面 routedone 時,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 === 3spend.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/blockrunWithGate 接上 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 計數器才是產品級的優雅天花板。
  • 「可預測」的精確含義capUsdMAX_ITER 給你一個開跑前算得出、保證不被突破的上界;精確值要跑完才知道。上界這半,就足以保證單位經濟不會失控。

你現在能拍胸脯說出「這次執行最多花 X、實際花了 Y、哪個節點最貴、快取省了幾次」——這就是把「跑一次多少錢」從謎變成明細。

但「成本可控」還不等於「賺錢」。下一課(第 9 課)我們接著把 state.spend 這本帳,變成真正的生意算術:設計一整條降級(fallback)策略(錢真的吃緊時怎麼有尊嚴地退場)、把成本算進單位經濟(unit economics)、回答那個終極問題——每跑一次,扣掉成本,到底賺不賺? 第 1 課那句話,第 9 課來兌現另一半:能不能收費,一半取決於能不能控制成本。