第 2 課 · 你的第一張 graph:用 StateGraph 把「單一競品 → 摘要」跑起來
第 1 課是地圖,這一課我們把手弄髒。
上一課結尾我答應你:這一課要用 StateGraph 蓋出 graph 的第一版骨架,讓「輸入一個競品 → 產出摘要」這條線真的在你機器上跑起來。而且我要先給你一個小驚喜——這張 graph 的第一次執行,不需要 API 金鑰、不花一毛錢、離線就能跑。這不是省事的偷吃步,而是我們整個系列的工程紀律第一次現身:把會花錢、會連網、結果不確定的 LLM 呼叫,關進一道叫 seam 的門後面。
💡 這一課是本系列的定義專章。graph(圖)/節點(node)/邊(edge)/狀態(state)/StateGraph/seam/stub 這幾個詞,全部在這一課講清楚、給第一段程式碼。後面每一課都只會引用它們、不再重新定義——所以這一課值得你慢慢讀、跟著把程式碼跑一遍。
先把今天的守備範圍釘死,免得你期待落空:
- 今天只做「單一競品、單一 LLM 摘要節點」。 輸入一個競品的原始情報,經過一個 LLM 節點,吐出一段摘要,結束。
- 今天不抓真實來源(RSS/changelog/定價頁那些)——那是第 3 課讓節點動手抓料的事。今天的「原始情報」我們先用手貼一段假的塞進去。
- 今天不做多競品平行、不做去重(dedup)——那是第 4 課。
- 今天不接 React、不做 streaming(串流)、不接 checkpointer——串進 control panel 是第 5 課、跨執行記得上週是第 7 課。今天就是一支在 Node 上跑一次、印出結果的程式。
一次學一件事。今天這件事是:把 graph 的五個零件認識清楚,然後親手組出第一張、能跑的 graph。
一、先建立心智模型:一張 graph 是什麼
在寫任何程式碼之前,先給你一個能扛整個系列的比喻。
一張 graph,就是一條工廠產線。
想像一條輸送帶產線:一件半成品從產線頭進去,經過一個又一個工作站,每一站對它做一點加工,最後從產線尾出來變成成品。把這個畫面對到 LangGraph.js 的詞彙,五個零件就各就各位了:
| 產線上的東西 | LangGraph.js 的詞 | 它是什麼 |
|---|---|---|
| 整條產線 | graph(圖) | 你的「大腦」——由節點用邊串成的一整張處理流程 |
| 一個工作站 | 節點(node) | 一段具體工作:清理資料、呼叫 LLM 摘要、算 diff…… |
| 站與站之間的輸送帶 | 邊(edge) | 決定「這一站做完,換哪一站」的順序 |
| 跟著半成品一起走的「共享工單」 | 狀態(state) | 一份大家共用的資料物件,每一站都讀它、也往上面寫 |
| 你用來畫這條產線的藍圖 | StateGraph | 建 graph 的類別:你用它宣告有哪些節點、怎麼連 |
flowchart LR
subgraph G["一張 graph = 一條產線"]
direction LR
A["工作站 A · node"] -->|edge| B["工作站 B · node"]
end
S["共享工單 = state(每一站都讀它、也往上面寫)"] -.-> A
S -.-> B
這個比喻裡最關鍵、也最容易被新手忽略的,是那份 共享工單(state)。它不是某一站私有的變數,而是整條產線共用的一份資料:prepare 站把清理好的文字寫在工單上,summarize 站就從同一張工單上把它讀出來。節點之間不直接對話,它們透過 state 這張共享工單交接。 記住這句話,你就抓到 LangGraph.js 的骨架了。
💡 這裡刻意不碰的兩件事,先講在明處:
- 「這張 graph 到底算 workflow 還是 agent、五大設計模式各是什麼」——那是 ch5(ai-you-should-know 第 5 章) 講透的事,本系列一律回指、不重講。你只要記得結論:我們的競品情報週報骨子裡是個 workflow(路線我們自己定好:清理 → 摘要 → ……),所以這一課我們只管把 graph 的零件認識清楚。
- 「LangGraph.js 內部到底怎麼把節點一個個叫起來、怎麼把各站寫的東西合併進 state」——那是姊妹系列 ai-pipeline-from-scratch(手刻編排引擎)的主題。本系列的立場一句話:手刻懂原理,框架去出貨。 這一課我們只用現成的 StateGraph,不自己刻它的內部。
心智模型有了,開始蓋。先建一個空專案,最後檔案會長這樣(我們一個一個寫):
competitor-digest/
├── package.json
├── tsconfig.json
└── src/
├── state.ts # 共享 state 的定義
├── prepare.ts # 純邏輯節點(免金鑰、可離線測)
├── summarizer.ts # seam:LLM 邊界(介面 + stub + 真模型)
├── graph.ts # 用 StateGraph 把節點組成一張 graph
├── run.ts # 在 Node 上跑一次
└── graph.test.ts # 用 stub 離線單元測試
先把兩個設定檔放好。package.json:
{
"name": "competitor-digest",
"private": true,
"type": "module",
"scripts": {
"graph": "tsx src/run.ts",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.30.1",
"@langchain/core": "^0.3.0",
"@langchain/langgraph": "^0.2.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
然後 npm install。我們用 tsx 直接跑 TypeScript(不用先 build)、用 vitest 跑測試——這兩個工具你寫 React 時大概都碰過,這裡不展開。
⚠️ 版本號以你
npm install當下的官方最新為準;上面的 caret 範圍是能跑的基線。另外你會注意到package.json裡沒有任何 web server(Express、Fastory 都沒有)——這不是漏了。上一課講過:LangGraph.js 本身沒有內建 HTTP server,它給你的是「一張 graph」,不是一個網站。把這張 graph 包成能對外服務的東西,是第 12 課的事。今天我們就在 Node 上跑一次、印到終端機,這樣剛剛好。
二、定義共享的 state(那張工單長什麼樣)
產線要先決定「工單上有哪幾格」。對我們這條「單一競品 → 摘要」的產線,工單需要四格:
competitor:這次要盯哪個競品(輸入)。raw:這個競品的原始情報文字(輸入。今天先手貼,第 3 課才真的去抓)。prepared:清理過、準備餵給 LLM 的文字(由prepare節點填)。summary:LLM 產出的摘要(由summarize節點填,最後的成品)。
在 LangGraph.js 裡,我們用 Annotation.Root 把這張工單宣告出來。建 src/state.ts:
import { Annotation } from "@langchain/langgraph";
// 這是整條產線共用的「工單」:每一格就是 state 的一個欄位(channel)
export const DigestState = Annotation.Root({
competitor: Annotation<string>, // 輸入:要盯的競品名稱
raw: Annotation<string>, // 輸入:原始情報(今天手貼,第 3 課才去抓)
prepared: Annotation<string>, // prepare 節點寫入:清理過的文字
summary: Annotation<string>, // summarize 節點寫入:最終摘要
});
// 給 TypeScript 用的型別:一份完整 state 物件的形狀
export type DigestStateType = typeof DigestState.State;
三個地方值得停下來看:
Annotation.Root({...})就是在宣告「工單有哪幾格」。 每一格叫一個 channel,用Annotation<型別>描述它裝什麼型別的資料。Annotation<string>沒有加括號,這是 TypeScript 的「instantiation expression」寫法,你照抄即可。typeof DigestState.State幫你把整張工單的 TypeScript 型別自動生出來。 這正是第 1 課說的「全 TypeScript 的槓桿」:state 的形狀是真的型別,節點讀錯欄位、寫錯型別,編輯器當場紅給你看——不用你手寫一份型別去對。- 每一格預設的合併規則是「後寫的蓋掉先寫的」(last-write-wins)。 這是關鍵,下面單獨講。
reducer:當一格會被「寫很多次」
節點做完事,會回傳一個「只包含它想更新的欄位」的物件(partial update),LangGraph.js 再把這個更新併進共享 state。問題來了:要怎麼併?
- 預設(像上面四格都沒特別指定):直接蓋掉。
summarize回傳{ summary: "..." },就把 state 裡的summary換成新值。對「這一格只有一個節點會寫」的欄位,這樣剛剛好。 - 但有些格子會被很多節點各寫一筆、而且你想要累積起來——例如未來要收集「這週抓到的所有來源」,每個抓料節點都往
sources丟幾筆,你要的是接起來,不是後一個把前一個蓋掉。這時你就要給這一格一個 reducer:一條「新值怎麼併進舊值」的規則。
寫法是把 Annotation<T> 改成呼叫它、傳入 { reducer, default }(這段是示意,不用加進我們的 state.ts):
import { Annotation } from "@langchain/langgraph";
const ExampleState = Annotation.Root({
// 沒 reducer:後寫的蓋掉先寫的(last-write-wins)
title: Annotation<string>,
// 有 reducer:把每個節點回傳的陣列「接在後面」,而不是蓋掉
sources: Annotation<string[]>({
reducer: (existing, incoming) => existing.concat(incoming),
default: () => [], // 一開始是空陣列,才有東西可以 concat
}),
});
一句話記住這個區別:沒給 reducer=蓋掉;給了 reducer=由你決定新舊怎麼合。
⚠️ 別把 reducer 想得太玄,也別急著到處加。它只在「同一格會被多次寫入、而你不想要蓋掉」時才需要。我們今天這條產線是「單一競品、每格只有一個節點寫一次」,所以四格都用預設的蓋掉規則就夠了——
sources那種累積式 reducer 的真正用武之地,是第 4 課多競品平行、多個節點同時往同一格寫的時候。那時如果你用預設(不給 reducer)而讓兩個節點在同一步同時寫同一格,LangGraph.js 會直接丟InvalidUpdateError擋你——因為它不知道該聽誰的。今天碰不到這個雷,但先讓你知道它在哪。
💡 一個容易混的邊界先劃清楚:這裡的 state 指的是**「一次執行之內、各節點共享的那份工單」——執行結束,它就沒了。至於「怎麼讓這張工單跨越一次又一次的執行、下週還記得上週寫過什麼**」,那是另一顆螺絲 checkpointer 的活,第 7 課才登場。所以請照術語表叫它 state(狀態),別叫它「上下文」「記憶體」或「快取」——那些是別的東西、別的課。
三、切開 seam:把 LLM 呼叫關進一道門後面
現在來到這一課的靈魂。
我們的 summarize 節點要呼叫 LLM。你直覺的寫法大概是:在節點裡直接 import Anthropic 的 SDK、直接 client.messages.create(...)。能跑,但這樣寫會讓你之後很痛。 為什麼?
想像你想跑一下這張 graph、確認「節點有沒有把 raw 讀對、有沒有把結果寫進 summary」——這件事跟「LLM 摘要得好不好」完全無關,它是純粹的「接線對不對」。可是如果 LLM 呼叫寫死在節點裡,你光是想跑這一下,就得:有 API 金鑰、要連得上網、要付一次錢、還要忍受 LLM 每次回的東西都不一樣(同樣輸入,真模型這次回三句、下次回兩句,你的測試永遠時好時壞)。
解法是在「純邏輯」和「真正打 LLM 的那一下」之間,切一道接縫。這道接縫,術語叫 seam(LLM 邊界)。
seam=純邏輯與 LLM 呼叫之間的接縫。 節點只認得接縫這一側的「介面」,不認得另一側到底是真模型還是假模型。
具體做法:定義一個介面 SummarizerModel——它只承諾一件事「給我競品名和一段文字,還你一段摘要」。節點只依賴這個介面。接縫的另一側,我們準備兩個實作:
- stub(假模型):不打網路、不花錢、回一段固定的假摘要。拿來離線跑、拿來寫測試。
- 真模型:真的呼叫 Anthropic,會花錢、會計費。上線時用它。
flowchart LR
N["summarize 節點<br/>只呼叫 model.summarize"] --> SEAM{{"seam:SummarizerModel 介面"}}
SEAM -.-> STUB["stub 假模型<br/>測試/離線用 · 不花錢免金鑰"]
SEAM -.-> REAL["Anthropic 真模型<br/>正式執行用 · 要金鑰會計費"]
建 src/summarizer.ts:
import Anthropic from "@anthropic-ai/sdk";
// ── 這就是 seam:節點只認得這個介面,不認得背後是真是假 ──
export interface SummarizerModel {
summarize(competitor: string, material: string): Promise<string>;
}
// ── stub 假模型:不打網路、不花錢,還會記下「被呼叫時收到什麼」,方便測試斷言 ──
export interface StubModel extends SummarizerModel {
calls: Array<{ competitor: string; material: string }>;
}
export function makeStubModel(reply?: string): StubModel {
const calls: StubModel["calls"] = [];
return {
calls,
async summarize(competitor, material) {
calls.push({ competitor, material }); // 記錄這次呼叫,測試時可以檢查
return (
reply ??
`【stub 摘要】${competitor}:${material.slice(0, 40)}…(這是假模型,不花錢、不需金鑰)`
);
},
};
}
// ── 真模型:seam 另一側的正式實作,會真的打 Anthropic ──
const MODEL = "claude-3-5-haiku-latest"; // 便宜夠用;貴/便宜模型分流是第 8 課的事
export function makeAnthropicModel(): SummarizerModel {
const client = new Anthropic(); // 自動讀環境變數 ANTHROPIC_API_KEY,別把金鑰寫死在程式裡
return {
async summarize(competitor, material) {
const res = await client.messages.create({
model: MODEL,
max_tokens: 400,
messages: [
{
role: "user",
content:
`你是競品情報分析師。根據以下關於「${competitor}」的原始情報,` +
`用繁體中文寫出最多 3 條、每條一句的重點摘要,只寫「值得決策者注意的變化」,不要客套:\n\n` +
material,
},
],
});
// Anthropic 回傳的是一個 content block 陣列;取第一個 text 區塊
const first = res.content[0];
return first?.type === "text" ? first.text : "";
},
};
}
注意幾件事:
- 節點依賴的是
SummarizerModel這個介面,不是任何具體實作。 這就是接縫的意義——接縫左側(節點、接線)完全不知道右側插的是 stub 還是真模型。 - prompt(那段「你是競品情報分析師……」)住在真模型實作裡,也就是 seam 的右側。 所以「接線對不對」的測試根本不碰 prompt——你調 prompt 的字句,不會弄壞接線測試。這正是切開的好處。
- 金鑰用
new Anthropic()自動從環境變數讀,不寫死在程式碼裡。 這條紀律你在 ch5、在 fe-admin-ai-chat 都見過,這裡照舊。
seam 幫你換來三件實打實的好處:
- 離線就能跑、就能測:不用金鑰、不花錢、不看網路臉色。開發迴圈快得多。
- 測試有確定性:真模型同樣輸入每次回不一樣,測試會時好時壞(flaky);stub 回固定值,「接線與純邏輯」的測試才穩。
- 換模型不用動 graph:今天用 Anthropic,明天想比價換一家、或想貴/便宜模型分流(第 8 課的 model routing),只換 seam 右側那塊,graph、節點、state 一行都不用改。
⚠️ seam 不是「把每個函式都抽象成介面」的教條——過度抽象反而難讀。它只在「會打 LLM、會花錢、結果不確定」的那一刀切下去就好。純邏輯之間(例如等一下的
prepare)不需要 seam,因為它們本來就不花錢、本來就確定、本來就好測。接縫切在該切的地方,不是切越多越好。
四、兩種節點,和用 StateGraph 把它們組成一張 graph
seam 講完,順帶就把「節點分兩種」講清楚了:
- 純邏輯節點:不打 LLM,只做確定性的計算(清理文字、算 diff、去重、算預算……)。不需金鑰、天生好測。
- LLM 節點:要打模型。我們把那一下呼叫關在 seam 後面,於是它也能用 stub 離線測。
我們這條產線一種各一個:prepare(純邏輯)和 summarize(LLM)。
先寫純邏輯那塊。建 src/prepare.ts——它就是一個普通的、確定性的函式,跟 LangGraph.js 一點關係都沒有:
// 純邏輯:把原始情報清理成適合餵給 LLM 的樣子。
// 不打網路、不需金鑰、同樣輸入永遠同樣輸出 —— 天生好測。
export function prepareRaw(raw: string, maxChars = 4000): string {
const collapsed = raw.replace(/\s+/g, " ").trim(); // 把連續空白/換行壓成單一空白
return collapsed.length > maxChars ? collapsed.slice(0, maxChars) : collapsed;
}
prepareRaw 做兩件小事:把亂七八糟的空白壓乾淨、把過長的文字截到上限。第二件事順手擋掉「有人塞一篇十萬字進來、害你 LLM 帳單爆掉」——這是預算控制的第一個影子(正式的預算閘是第 8 課,這裡只是隨手一擋)。重點是:它是純函式,等一下我們不用任何金鑰就能直接測它。
現在用 StateGraph 把兩個節點組成一張 graph。建 src/graph.ts:
import { StateGraph, START, END } from "@langchain/langgraph";
import { DigestState, type DigestStateType } from "./state";
import { prepareRaw } from "./prepare";
import type { SummarizerModel } from "./summarizer";
// buildGraph 收一個 model(seam 的右側),回傳一張編譯好、可執行的 graph。
// 把 model 當參數傳進來(而不是在裡面寫死),正是 seam 的用法:
// 正式跑就傳真模型、測試就傳 stub。
export function buildGraph(model: SummarizerModel) {
// 純邏輯節點:讀 state.raw,把清理結果寫進 state.prepared
const prepare = (state: DigestStateType) => {
return { prepared: prepareRaw(state.raw) };
};
// LLM 節點:讀 state.prepared,透過 seam 呼叫模型,把摘要寫進 state.summary
const summarize = async (state: DigestStateType) => {
const summary = await model.summarize(state.competitor, state.prepared);
return { summary };
};
return new StateGraph(DigestState)
.addNode("prepare", prepare) // 掛上兩個工作站
.addNode("summarize", summarize)
.addEdge(START, "prepare") // 產線頭 → prepare
.addEdge("prepare", "summarize") // prepare → summarize
.addEdge("summarize", END) // summarize → 產線尾
.compile(); // 收工,產出一張可執行的 graph
}
這短短一段,就是 LangGraph.js 建圖的全部核心動作,逐行認識一下:
- 節點就是一個函式:收一份
state(那張共享工單),回傳一個「只含它想更新的欄位」的物件。prepare讀state.raw、回{ prepared };summarize讀state.prepared、回{ summary }。它們從沒直接對話——summarize讀得到prepared,是因為prepare先把它寫上了工單。 這就是「節點透過 state 交接」的實際樣子。 new StateGraph(DigestState):拿第二節那張工單當藍圖,開一張新 graph。.addNode("名字", 函式):把節點掛上去,給它一個名字。.addEdge(來源, 目標):拉輸送帶、定順序。START和END是 LangGraph.js 給的兩個特殊端點——產線的頭和尾。addEdge(START, "prepare")是說「開跑就先做 prepare」;addEdge("summarize", END)是說「summarize 做完就收工」。.compile():把這張藍圖編譯成一個真正能執行的物件(有.invoke()可以呼叫)。沒 compile 的是藍圖,compile 過的才是能開動的產線。
我們的 graph 長這樣,清清爽爽一條線:
flowchart LR
ST(["START"]) --> P["prepare<br/>純邏輯節點(免金鑰)"]
P --> SU["summarize<br/>LLM 節點(走 seam)"]
SU --> EN(["END"])
釘一下「最小」的邊界:整條線上,產出摘要的 LLM 節點只有一個——就是 summarize。真要摳到字面上的最小,把 prepare 拿掉、讓 summarize 直接讀 state.raw,就是「輸入一個競品 → 一個 LLM 節點 → 結束」的極簡版。我們刻意留著 prepare 這個純邏輯節點,是為了下一節那個「切開 seam」的測試——有一塊不打 LLM 的純邏輯在圖裡,你才看得出「純邏輯節點」和「LLM 節點」擺在一起是什麼樣,也才有東西可以離線測。
💡 有沒有注意到
buildGraph(model)把model當參數收進來?這一步看似不起眼,卻是整張 graph 可測、可換模型的關鍵。graph 本身不決定用哪個模型——呼叫它的人決定。正式執行的人傳真模型進去,寫測試的人傳 stub 進去。同一張 graph,兩種人生。這就是把 seam 用起來的樣子。
五、在 Node 上跑一次
藍圖組好了,來讓它動。建 src/run.ts:
import { buildGraph } from "./graph";
import { makeStubModel, makeAnthropicModel } from "./summarizer";
// 有金鑰就用真模型,沒有就退回 stub —— 讓你零設定先跑起來看看
const hasKey = !!process.env.ANTHROPIC_API_KEY;
const model = hasKey ? makeAnthropicModel() : makeStubModel();
console.log(hasKey ? "→ 使用真模型(Anthropic)" : "→ 沒偵測到金鑰,使用 stub 假模型(離線)");
const graph = buildGraph(model);
// 餵一個競品進去。raw 今天手貼;第 3 課才讓節點自己去抓。
const result = await graph.invoke({
competitor: "Acme Planner",
raw: `Acme Planner 官網定價頁:Pro 方案 $15 /座/月(上週還是 $12)。
changelog(2026-07-08):上線「AI 週報」功能。
官方部落格:連兩週在談 SOC 2。`,
});
console.log("\n===== 週報摘要 =====\n" + result.summary);
graph.invoke({...}) 就是把一份初始 state 丟進產線頭、讓它一路跑到產線尾、回傳最後那張完整工單。我們只給了 competitor 和 raw 兩格,prepared 和 summary 會在途中被兩個節點填上。跑完,result 是最終的完整 state,result.summary 就是我們要的成品。
先離線跑(不需金鑰、不花錢):
npm install
npm run graph
你會看到類似這樣——證明整條產線通了:prepare 把換行和多餘空白清乾淨了(看得到摘要裡的文字是壓平的),summarize(此刻是 stub)把結果寫進了 summary:
→ 沒偵測到金鑰,使用 stub 假模型(離線)
===== 週報摘要 =====
【stub 摘要】Acme Planner:Acme Planner 官網定價頁:Pro 方案 $15 /座/月(上週還是…(這是假模型,不花錢、不需金鑰)
想看真模型? 設好金鑰再跑一次——graph 一行都不用改,只是 seam 右側從 stub 換成了 Anthropic:
export ANTHROPIC_API_KEY=sk-ant-... # 換成你自己的金鑰
npm run graph
這次 result.summary 會是模型真的產出的三條重點摘要。同一張 graph、同一組節點、同一份 state——換掉的只有 seam 後面那塊。 這就是第三節那三個好處裡最爽的一個,你剛剛親手驗證了它。
💡 你可能發現
run.ts頂層直接用了await(沒有包在async function裡)。這叫 top-level await,在 ESM(我們package.json設了"type": "module")裡是合法的,tsx也支援。這也是為什麼上面沒有main().catch(...)那一圈殼——保持最小。
六、寫測試:為什麼 stub 讓這一切離線可測
現在來兌現這一課開頭那個承諾:因為 LLM 被關在 seam 後面,我們可以不需金鑰、離線、穩定地測這張 graph。
我們寫兩層測試,由簡到繁:
- 純邏輯直接測:
prepareRaw是純函式,連 graph 都不用碰,最好測。 - 整張 graph 用 stub 測:把 stub 傳進
buildGraph,跑一次invoke,斷言「節點有沒有讀對、寫對」、「seam 有沒有收到清理過的文字」。全程不需金鑰。
建 src/graph.test.ts:
import { describe, it, expect } from "vitest";
import { prepareRaw } from "./prepare";
import { buildGraph } from "./graph";
import { makeStubModel } from "./summarizer";
describe("prepareRaw(純邏輯,免金鑰)", () => {
it("把連續空白/換行壓成單一空白並去頭尾", () => {
expect(prepareRaw(" Acme 漲價\n\n到 $15 ")).toBe("Acme 漲價 到 $15");
});
it("超過上限就截斷", () => {
const long = "x".repeat(5000);
expect(prepareRaw(long, 4000)).toHaveLength(4000);
});
});
describe("整張 graph(用 stub 假模型,離線可跑)", () => {
it("節點讀得到 raw、寫得出 summary,且把清理過的文字餵給模型", async () => {
const model = makeStubModel("STUB_SUMMARY"); // 固定回值 → 測試有確定性
const graph = buildGraph(model); // 把 stub 從 seam 插進去
const result = await graph.invoke({
competitor: "Acme Planner",
raw: " Acme Planner 把 Pro 從 $12 漲到 $15 ",
});
// 1) summarize 節點把模型的輸出寫進了 state.summary
expect(result.summary).toBe("STUB_SUMMARY");
// 2) 模型(透過 seam)收到的,是 prepare 節點「清理過」的文字,不是原始 raw
expect(model.calls).toHaveLength(1);
expect(model.calls[0].competitor).toBe("Acme Planner");
expect(model.calls[0].material).toBe("Acme Planner 把 Pro 從 $12 漲到 $15");
});
});
跑它:
npm test
✓ src/graph.test.ts (3)
✓ prepareRaw(純邏輯,免金鑰) (2)
✓ 整張 graph(用 stub 假模型,離線可跑) (1)
Test Files 1 passed (1)
Tests 3 passed (3)
停下來體會一下第二個測試證明了什麼——它一次驗證了這一課的三根支柱:
- state 有被正確地讀寫:
result.summary === "STUB_SUMMARY"證明summarize節點確實把模型輸出寫回了工單那一格。 - 節點之間確實透過 state 交接:
model.calls[0].material是清理後的文字($12 漲到的雙空白被壓成單一空白),證明prepare先寫了prepared、summarize再讀了它——兩個節點靠共享工單接上了棒。 - seam 真的把 LLM 換掉了:整個測試沒有金鑰、沒有連網、沒有花錢,卻把「接線與純邏輯」測得死死的。這就是把 LLM 呼叫關進 seam 的回報。
⚠️ 講清楚 stub 測不負責什麼,免得你誤會:它不檢查「摘要寫得好不好」——那是模型品質,得用別的方法評(真模型、人看、或做評測,那是後話)。stub 測負責的是**「這台機器的接線、資料流、純邏輯有沒有錯」**。這兩件事要分開測:接線的錯,stub 幫你擋在離線、免費、每次都跑的地方;品質的評,才值得動用真模型那條又慢又花錢的路。 把最常跑的那類測試留在最便宜的地方,是能長期出貨的團隊都在做的事。
這一課建立了什麼
你現在手上有一張真的會跑的 graph,也把本系列最核心的幾個詞一次認全了。收攏成一張你可以帶走的心智圖:
- graph(圖)=一條產線;節點(node)=工作站;邊(edge)=站間的輸送帶(定順序);狀態(state)=隨工件走、每站都讀寫的共享工單;StateGraph=你用來畫這條產線的藍圖類別。 節點之間不直接對話,一律透過 state 交接。
- 用
Annotation.Root宣告 state:每格是一個 channel,用Annotation<型別>描述。預設合併規則是「後寫的蓋掉先寫的」;當一格會被多次寫入又想累積,才給它 reducer((舊, 新) => ...)+default。跨執行記住上週是 checkpointer(第 7 課) 的事,別跟 state 混為一談。 - 節點=「收 state、回傳 partial 更新」的函式;用
.addNode掛節點、.addEdge(START/END/...)定順序、.compile()變成可執行的 graph;.invoke(初始 state)在 Node 上跑一次、回傳最終 state。 - seam(LLM 邊界)=純邏輯與 LLM 呼叫之間的接縫:定一個
SummarizerModel介面,把 model 當參數傳進buildGraph。正式跑傳真模型、測試傳 stub(假模型)。好處是離線可跑、測試有確定性、換模型不動 graph。切在該切的地方就好,別過度抽象。 - 節點分兩種:純邏輯節點(
prepare)天生免金鑰好測;LLM 節點(summarize)把呼叫藏在 seam 後也能用 stub 離線測。這一課的可跑成品是competitor-digest:npm run graph(沒金鑰先跑 stub、設了金鑰跑真模型)、npm test(vitest,3 passed,全程離線)。
這幾個詞——graph/node/edge/state/StateGraph/seam/stub——是本系列的地基,後面每一課都直接踩在上面、不再重講。
下一課我們拆掉一個今天偷懶的地方:那段手貼的 raw。第 3 課,我們讓節點自己動手去抓料——RSS、changelog、定價頁——把真實世界的原料寫回 state。你會發現,今天切好的 seam 和 state 這兩塊地基,正好穩穩接住那些新節點。