第 6 課 · 發送前的人工關卡——`interrupt()` 讓 graph 停下來等一個人

第 6 課 · 發送前的人工關卡——interrupt() 讓 graph 停下來等一個人

週一凌晨三點,沒人開著頁面,你的 graph 準時醒來,抓料、去重、摘要,起草出這週的競品情報週報。草稿裡有一則紅色警報:

🔴 Acme Planner 把「Pro 方案」從 $12/座 漲到 $79/座(+558%)。

只有一個問題:Acme 沒有漲到 $79。 模型把定價頁上一個「年繳一次付 $79」的字樣,讀成了每座單價。草稿六點自動寄出,你的客戶——一位 PM——九點看到,中午前就擬好了「我們也跟著漲」的計畫。他調整定價的依據,是你的產品憑空捏造的一個數字。

等他發現搞錯了,這不會是一張 bug report,而是一次退訂,外加一則「這 AI 工具會亂編數字,別用」的推文。

請注意,問題不是「模型不夠好」。模型永遠會偶爾出錯,這是你消滅不了的物理現實。問題是:一份會被人拿去做定價決策的內容,如果中間沒有任何一個真人看過就送出去,它就是一個你收不了錢的負債。

這一課要補上的,就是那道關卡:在任何東西送出去之前,讓一個真人分析師看一眼——核准、改一改、或直接退回。 LangGraph.js 有一個為這件事量身打造的原語,叫 interrupt()。這一課我們就把它學會、接進第 5 課那個 control panel,並且說清楚:為什麼這道人工關卡不是可有可無的裝飾,而是這門生意能不能成立的一環。

💡 這一課是 interrupt() / resume / human-in-the-loop(HITL) 的定義專章。前面第 5 課已經把 streaming 和 React control panel 建好了,這一課的審核卡就接在那個控制面上——控制面本身怎麼做,第 5 課的事,這裡只用不重講。

這一課回答三個問題:(1)怎麼用 interrupt() 讓 graph 在「發送前」停住、把待審內容交給真人,再用 resume 帶著人的決定繼續跑?(2)這道 HITL 審核卡怎麼接進控制面,讓分析師看到待發週報、改一改、按核准或退回?(3)為什麼「分析師人工策展把關」是這個產品可信賴、可收費的一環?


一、為什麼發送前一定要有一道人

先講「為什麼」,因為這決定了你願不願意花力氣把它做對。

我們的產品是決策級的。第 1 課就釘死過:週報裡每一則變化,一個 PM 看了會調路線圖、一個行銷看了會改文案、一個創辦人看了會動定位。這是它值錢的原因——但也正是它危險的地方。跟「花錢的決定」綁在一起的內容,錯一次的代價,遠大於對十次的功勞。

一個一直說對、偶爾自信地說錯的 AI,在很多場景裡沒關係(拼錯一個字、摘要少一句)。但在「客戶會照著它改定價」的場景裡,偶爾自信地說錯 = 偶爾自信地害客戶做錯決策。這種產品要能收費,你賣的其實不是「AI 摘要」,而是信任——「送到你信箱的每一封週報,都可以放心拿去開會」。

而信任,是靠一道人工關卡買來的。這道關卡給你一個可以印在銷售頁上、對手(那些只會把模型輸出直接群發的 raw-AI 工具)給不出的承諾:

每一封送出的週報,都經過一位真人分析師核可。

回指:這就是 ai-you-should-know 第 5 章講的那道「檢查點」

你不用重新想這是什麼模式。ai-you-should-know 第 5 章 講過:把工作拆成一步一步,好處之一就是能在關鍵處插入一道檢查點讓人審一眼;也講過大多數穩定的產品骨子裡是 workflow,只在少數幾個要害插一道人審。我們的競品情報週報正是如此——抓取 → 去重 → 摘要 → 比對這一長串都自動跑,只在「送出去」這一個要害前面,插一道真人關卡

這一課不重講那個模式(那是 ai-you-should-know 第 5 章的事),只做「概念之後的實作」:把那道關卡用 LangGraph.js 真的做出來。 它的正式名字叫 human-in-the-loop(HITL)——流程跑到一半,把控制權交回給一個真人,等他拍板,再繼續。

早期,這道關卡還是你的金礦

除了「買信任」,人工關卡在產品早期還有一個附帶好處:分析師每次改了什麼、退了什麼,都是在免費告訴你「模型在哪裡最弱」。 他老是把某類來源的摘要改掉,你就知道那類來源該加規則;他從不動的那類,你以後就能放心讓它自動過。這是你之後才可能做「自動核准低風險、只人審高風險」的資料來源——先有人看著,才知道哪些以後可以不看。

⚠️ 別把這條規則推到極端:不是「所有東西永遠都要人審」。 人工關卡有它的成本——它是一個人的瓶頸:會增加延遲(凌晨三點跑完,可能要等分析師九點上班才送出),而且不隨用量線性擴張(客戶從 10 個變 1000 個,你不可能請 100 倍的分析師逐字看)。所以這道關卡要設計得又快又窄

  • :一個畫面、一顆按鈕、只看 diff(變化),不看 40 頁全文。
  • :之後可以只對 🔴 高風險變化強制人審,🟡 低風險的自動放行(要防某條線失控跑不停,是第 8 課的 max-iteration(迭代天花板) 的事)。

這一課先把「一道乾淨、快速的人工關卡」做出來、做對。怎麼把它變窄、變便宜,是後面控制預算那幾課的事。

💡 順帶一提:這道關卡撐得住,一半要歸功於 diff(只報變化)。因為我們只報變化(第 7 課的殺手鐧),分析師每週要審的不是三個競品的全部資訊,而是幾則變化——關卡小,人才看得動。diff 讓 HITL 便宜,HITL 讓 diff 可信,兩者是一對。


二、interrupt() 的心智模型:在節點中間按下暫停

現在講「是什麼」。

interrupt() 是一個你在節點(node)裡面呼叫的函式。你可以把它想成節點內部的一顆暫停鍵

  • 當某個節點執行到 interrupt(payload) 這一行,整張 graph 就地凍結——這個節點沒跑完、後面的節點一個都不會跑。
  • 它把你給的 payload(例如「這是待審草稿」)交出來给呼叫端(也就是你的後端 API,再轉給前端控制面)。
  • 然後它。等一個真人做決定。
  • 直到你用 resume 帶著那個決定回來,graph 才從剛才凍結的地方恢復執行——這時 interrupt() 會把人的決定當作回傳值吐出來,節點接著往下跑。

一個你熟悉的類比:CI/CD 的部署核准關卡。 pipeline 一路 build、test 跑到「等待核准部署上線」就停住,站在那裡不動;直到有人按下 approve,它才繼續 deploy。interrupt() 就是 graph 版本的那顆「等待核准」——只是它還能把一份待審內容一起交出來給審核的人看。

暫停,意味著它必須把「停在哪」記下來

這裡有一個關鍵,決定了 interrupt() 底層需要什麼。

想想凌晨三點那個場景:graph 跑到 review 節點停住、把草稿交出來——然後呢?發起這次執行的那個 HTTP 請求,早就該回應、結束了。 沒道理讓一個 Node 請求掛在那裡空等六個小時,等分析師九點上班。所以「暫停」的真實含意是:

graph 要能把「我停在 review 節點、手上這份草稿」這個現場存下來,讓幾個小時後另一個 HTTP 請求(分析師按下核准時發的)能把現場撈回來、從斷點繼續。

負責「把現場存下來、之後撈回來」的那個原語,就是 checkpointer(跨執行狀態)。這也是為什麼——

⚠️ 要用 interrupt(),compile 這張 graph 時就一定要給它一個 checkpointer;沒有 checkpointer,interrupt() 會直接報錯。 因為沒有地方存現場,它根本沒辦法「暫停後恢復」。

本課我們用 LangGraph.js 最簡單的記憶體版 checkpointer——MemorySaver——把「暫停 → 等人 → 恢復」這條控制流帶過去就好

💡 checkpointer 這一課只用、不定義。 它的正式定義、跨執行的完整語意(怎麼靠它「記得上週」、給出 diff)是第 7 課的主題;把它從記憶體換成真的資料庫(Postgres)、做到多租戶隔離,是第 10 課。這一課你只要接受一件事:interrupt() 的暫停能力,是 checkpointer 借給它的。 至於 checkpointer 自己怎麼運作,第 7 課見。(想更底層地理解「checkpoint/state 機器怎麼自己手刻」,那是姊妹系列 ai-pipeline-from-scratch 的事——手刻懂原理,框架去出貨。

把整條時間線畫出來,你會看得更清楚——注意 checkpointer 出現在哪,以及它跨越了兩個不同的請求

sequenceDiagram participant FE as 控制面 React participant GR as 後端 graph participant CP as checkpointer participant AN as 分析師 FE->>GR: 觸發本週 digest(invoke) GR->>GR: draft 節點起草週報 GR->>GR: review 節點呼叫 interrupt() GR->>CP: 存下「停在 review」+這份草稿 GR-->>FE: 回傳 __interrupt__(待審草稿) Note over GR,CP: graph 凍結,這個請求可以結束了 AN->>FE: 幾小時後,看到待審卡、改一改 AN->>FE: 按下「核准並送出」 FE->>GR: resume(Command,resume=決定) GR->>CP: 撈回「停在 review」的現場 GR->>GR: interrupt() 這次回傳分析師的決定 GR->>GR: send 節點送出週報 GR-->>FE: 完成(sent=true)

resume 怎麼找到「幾小時前停在哪」?靠 thread(一條追蹤線)thread_id。第一次 invoke 和後來 resume 的那次 invoke,必須用同一個 thread_id——checkpointer 就是拿它當鑰匙,去撈對應的那個現場。(我們追蹤的每一個競品是一條 thread,這條線第 7 課會講透,這裡先用著。)


三、最小可跑:一個會停下來等人的 graph

先用一個最小、完全離線、不需要任何 API 金鑰的例子,把「暫停 → 交出 → 恢復」這條控制流跑通。整段可以直接貼進一個裝好 @langchain/langgraph 的 TS 專案執行:

// hitl-min.ts
import {
  StateGraph,
  Annotation,
  START,
  END,
  interrupt,
  Command,
  MemorySaver,
} from "@langchain/langgraph";

// 一個最小的 state:手上的草稿,和人做的決定
const State = Annotation.Root({
  draft: Annotation<string>({ reducer: (_prev, next) => next, default: () => "" }),
  decision: Annotation<string>({ reducer: (_prev, next) => next, default: () => "" }),
});

// 這個節點就是那道關卡:把草稿交出去、停住、等人的決定。
function reviewNode(state: typeof State.State) {
  const decision = interrupt({ draft: state.draft }) as string; // ← 暫停鍵
  return { decision }; // resume 之後才會執行到這行
}

const graph = new StateGraph(State)
  .addNode("review", reviewNode)
  .addEdge(START, "review")
  .addEdge("review", END)
  .compile({ checkpointer: new MemorySaver() }); // ← 沒有 checkpointer,interrupt 會報錯

async function main() {
  // 同一個 thread_id,貫穿「暫停」和「恢復」兩次呼叫
  const config = { configurable: { thread_id: "digest-acme-w27" } };

  // 1) 跑一次 —— 撞到 interrupt,帶著待審 payload 回來,graph 就地凍結
  const paused = await graph.invoke({ draft: "Acme 把 Pro 從 $12 漲到 $15" }, config);
  console.log("交出來的待審內容:", paused["__interrupt__"]);
  // → [ { value: { draft: 'Acme 把 Pro 從 $12 漲到 $15' }, ... } ]
  console.log("decision 還是空的:", JSON.stringify(paused.decision)); // → ""

  // 2) 人看完了,做決定。用 Command 帶著 resume 值回來,從斷點恢復。
  const done = await graph.invoke(new Command({ resume: "approve" }), config);
  console.log("恢復後 decision:", done.decision); // → "approve"
}

main();

跑起來,你會看到三件事,正好對應上一節的心智模型:

  1. 第一次 invoke 沒有跑完就回來了:回傳值裡多了一個 __interrupt__ 陣列,裡面 value 就是你丟給 interrupt() 的 payload。decision 還是預設空字串——因為 return { decision } 那行根本還沒執行到
  2. graph 現在凍在 review 節點,狀態存在 MemorySaver 裡,鑰匙是 thread_id
  3. 第二次 invoke 傳的不是新輸入,而是一個 Command({ resume: "approve" }):它告訴 graph「用同一個 thread、帶著這個決定回去」。這一次,interrupt() 回傳 "approve"reviewNode 才把 return { decision } 跑完。

💡 注意第二次呼叫傳的是 new Command({ resume: ... })不是再傳一次 { draft: ... }。這是新手最容易搞錯的地方:resume 不是「重新開始一次」,而是「回到上次停住的那個斷點,把人的答案交給還在等的 interrupt()」。thread_id 一樣,它才知道要恢復哪一次。

💡 你可能在別的地方看過另一種寫法:compile 時用 interruptBefore: ["someNode"] 做「靜態」中斷。那是比較舊、比較死的做法(只能停在某節點前、不能順手交出一份 payload)。我們用的是動態的 interrupt() 函式——它更靈活,而且能把「待審什麼」一起交出去,正是 HITL 要的。


四、把它變成真的審核卡:起草 → 停 → 核准/改/退回

最小例子只有一個節點、一個字串決定。真實的審核卡要能表達分析師的三種動作,而且要接在「起草週報」之後、「送出」之前。我們照著全系列的工程紀律,把純邏輯和 LLM 呼叫在 seam(LLM 邊界)上切開:起草要打 LLM,其餘(停、路由、送出)都是純控制流,不需要金鑰、能用 stub 離線測。

4.1 state 與決定的形狀

// digest-state.ts
import { Annotation } from "@langchain/langgraph";

export interface Change {
  competitor: string;
  kind: "pricing" | "feature" | "positioning" | "hiring";
  severity: "red" | "yellow";
  summary: string;
  source: string;
}

// 分析師能做的三種決定
export type ReviewAction = "approve" | "approve_with_edits" | "reject";

export interface ReviewDecision {
  action: ReviewAction;
  editedDraft?: string; // action = approve_with_edits 時,改過的全文
  note?: string;        // 退回原因,或給下游的備註
}

// 交給前端待審卡的內容
export interface ReviewPayload {
  draft: string;
  changes: Change[];
}

export const DigestState = Annotation.Root({
  competitor: Annotation<string>({ reducer: (_p, n) => n, default: () => "" }),
  changes: Annotation<Change[]>({ reducer: (_p, n) => n, default: () => [] }),
  draft: Annotation<string>({ reducer: (_p, n) => n, default: () => "" }),
  decision: Annotation<ReviewDecision | null>({ reducer: (_p, n) => n, default: () => null }),
  sent: Annotation<boolean>({ reducer: (_p, n) => n, default: () => false }),
});

💡 這裡 reducer: (_p, n) => n 就是「後寫覆蓋前值」(last write wins)——最單純的合併規則。state 的 reducer 語意是第 2 課建立的,這裡只是用它,不重講。ReviewDecisionReviewPayload 這兩個型別待會前後端會共用同一份——這正是第 1 課說的「全 TypeScript 讓 graph 吐出的 state 型別能直接流到前端」的紅利。

4.2 seam:LLM 起草節點,離線用 stub 頂替

// summarizer.ts
import type { Change } from "./digest-state";

// seam:起草這件事的介面。graph 只依賴這個介面,不直接依賴任何 LLM SDK。
export interface Summarizer {
  draftDigest(changes: Change[]): Promise<string>;
}

// stub(假模型):測試/離線用,不需要金鑰,輸出穩定可斷言。
export const stubSummarizer: Summarizer = {
  async draftDigest(changes) {
    const body = changes
      .map((c, i) => `${i + 1}. [${c.severity}] ${c.summary}(來源:${c.source})`)
      .join("\n");
    return `競品情報週報(草稿)\n本週偵測到 ${changes.length} 則變化:\n${body}`;
  },
};

// 真打 LLM 的版本(示範,需要金鑰)。真實產品跑的是這個。
import type { BaseChatModel } from "@langchain/core/language_models/chat_models";

export function llmSummarizer(model: BaseChatModel): Summarizer {
  return {
    async draftDigest(changes) {
      const res = await model.invoke([
        { role: "system", content: "你是競品情報分析師,把下列變化寫成一封精簡、決策導向的週報草稿。只寫變化,不要客套。" },
        { role: "user", content: JSON.stringify(changes, null, 2) },
      ]);
      return String(res.content);
    },
  };
}

4.3 graph:起草 → 審核(interrupt)→ 條件邊分岔 → 送出/不送

// digest-graph.ts
import {
  StateGraph, START, END, interrupt, Command, MemorySaver,
} from "@langchain/langgraph";
import { DigestState, type ReviewDecision, type ReviewPayload } from "./digest-state";
import type { Summarizer } from "./summarizer";

export function buildDigestGraph(
  summarizer: Summarizer,
  // 預設用記憶體版 checkpointer;第 10 課會傳一個接 Postgres 的進來換掉它
  checkpointer = new MemorySaver(),
) {
  // LLM 節點:起草。這是唯一會花錢的一步。
  async function draftNode(state: typeof DigestState.State) {
    const draft = await summarizer.draftDigest(state.changes);
    return { draft };
  }

  // 關卡節點:只做一件事——把待審內容交出去、停住、拿回決定。
  // ⚠️ 這個節點刻意「只有 interrupt + return」,理由在第六節會講。
  function reviewNode(state: typeof DigestState.State) {
    const payload: ReviewPayload = { draft: state.draft, changes: state.changes };
    const decision = interrupt(payload) as ReviewDecision;
    return { decision };
  }

  // 純邏輯:看分析師的決定,決定往哪走。
  function routeAfterReview(state: typeof DigestState.State): "send" | "discarded" {
    return state.decision?.action === "reject" ? "discarded" : "send";
  }

  // 送出節點:核准就送。若是「改後核准」,送的是改過的版本。
  function sendNode(state: typeof DigestState.State) {
    const finalText =
      state.decision?.action === "approve_with_edits"
        ? state.decision.editedDraft ?? state.draft
        : state.draft;
    // 真實產品這裡會寄信/推播;示範就標記已送。
    return { draft: finalText, sent: true };
  }

  // 退回節點:本週不送,留下備註。
  function discardedNode(state: typeof DigestState.State) {
    return { sent: false };
  }

  return new StateGraph(DigestState)
    .addNode("draft", draftNode)
    .addNode("review", reviewNode)
    .addNode("send", sendNode)
    .addNode("discarded", discardedNode)
    .addEdge(START, "draft")
    .addEdge("draft", "review")
    // 條件邊(conditional edge):依 routeAfterReview 的回傳,去 send 或 discarded
    .addConditionalEdges("review", routeAfterReview, {
      send: "send",
      discarded: "discarded",
    })
    .addEdge("send", END)
    .addEdge("discarded", END)
    .compile({ checkpointer });
}

整張 graph 的控制流長這樣——注意 review 節點怎麼把草稿「交出去」給控制面外的分析師,又怎麼靠 resume 把決定「帶回來」:

flowchart TB S((START)) --> D["draft 節點:LLM 起草週報"] D --> R["review 節點:interrupt() 停住"] R -. "交出待審草稿" .-> H["分析師在 control panel"] H -. "resume:帶回決定" .-> R R --> Q{"decision.action?"} Q -->|"approve / approve_with_edits"| SEND["send 節點:送出週報"] Q -->|"reject"| DISC["discarded 節點:本週不送"] SEND --> E((END)) DISC --> E

4.4 離線測:三種決定各驗一次

因為關卡、路由、送出都是純控制流,draft 又躲在 stub 後面,整條流程可以完全離線、不花一毛錢地單元測試。這就是 seam 切乾淨的回報:

// digest-graph.test.ts   (vitest 風格,離線,不需要金鑰)
import { describe, it, expect } from "vitest";
import { Command } from "@langchain/langgraph";
import { buildDigestGraph } from "./digest-graph";
import { stubSummarizer } from "./summarizer";
import type { Change } from "./digest-state";

const changes: Change[] = [
  { competitor: "Acme", kind: "pricing", severity: "red",
    summary: "Pro 從 $12 漲到 $15", source: "定價頁" },
];

// 小工具:跑到 interrupt,拿回待審 payload
async function runToReview(threadId: string) {
  const g = buildDigestGraph(stubSummarizer);
  const cfg = { configurable: { thread_id: threadId } };
  const paused = await g.invoke({ changes }, cfg);
  return { g, cfg, paused };
}

describe("發送前的人工關卡", () => {
  it("停在 review,把草稿交出來,還沒送", async () => {
    const { paused } = await runToReview("t1");
    const payload = paused["__interrupt__"]?.[0]?.value;
    expect(payload.draft).toContain("漲到 $15");
    expect(paused.sent).toBe(false); // 關鍵:停住時,一定還沒送
  });

  it("核准 → 送出", async () => {
    const { g, cfg } = await runToReview("t2");
    const done = await g.invoke(new Command({ resume: { action: "approve" } }), cfg);
    expect(done.sent).toBe(true);
  });

  it("退回 → 不送", async () => {
    const { g, cfg } = await runToReview("t3");
    const done = await g.invoke(
      new Command({ resume: { action: "reject", note: "來源存疑" } }), cfg);
    expect(done.sent).toBe(false);
  });

  it("改後核准 → 送出的是改過的版本", async () => {
    const { g, cfg } = await runToReview("t4");
    const done = await g.invoke(
      new Command({ resume: { action: "approve_with_edits", editedDraft: "改過的週報" } }), cfg);
    expect(done.sent).toBe(true);
    expect(done.draft).toBe("改過的週報");
  });
});

四個測試,把「停 → 三種決定」的每一條路都釘死了,而且跑一次零成本、零金鑰、毫秒級。等你哪天改了路由邏輯,這幾個測試會立刻告訴你有沒有踩到「明明退回了卻還是送出去」這種會賠掉信任的 bug。


五、接進控制面:分析師怎麼看到、改、按核准

graph 會停、會恢復了,剩下的是把這道關卡接進第 5 課那個 React control panel。這需要兩端:後端多開一個「接收決定」的入口,前端多一張「待審卡」。

回顧一個第 1 課就講明的事實:LangGraph.js 本身沒有內建 HTTP server。 所以我們是把這張 graph 包在自己的 Node 服務裡對外開端點(完整的上線硬化在第 12 課)。第 5 課那個 streaming 端點已經在把 graph 進度串進控制面了——差別只在:這次 graph 不是一路跑到底,而是跑到 review 停住。串流的最後一個事件不是「完成」,而是 __interrupt__ 控制面收到它,就把畫面從「跑到哪了」切換成「這張待審卡」。(streaming 怎麼串,是第 5 課的事,這裡只引用。)

5.1 後端:一個「取待審」端點、一個「送決定」端點

// server-review.ts   (加在第 5 課那個 server 上)
import express from "express";
import { Command } from "@langchain/langgraph";
import { digestGraph } from "./digest-graph-instance";
import type { ReviewDecision } from "./digest-state";

export const reviewApi = express.Router();
reviewApi.use(express.json());

// 分析師九點上班,打開控制面:這條 thread 有沒有東西在等我審?
reviewApi.get("/digest/:threadId/pending", async (req, res) => {
  const config = { configurable: { thread_id: req.params.threadId } };
  const snap = await digestGraph.getState(config); // 從 checkpointer 撈現場
  const pending = snap.tasks?.[0]?.interrupts?.[0]?.value ?? null;
  res.json({ awaitingReview: pending !== null, review: pending });
});

// 分析師按了核准/改後核准/退回:帶著決定 resume。
reviewApi.post("/digest/:threadId/review", async (req, res) => {
  const config = { configurable: { thread_id: req.params.threadId } };
  const decision = req.body as ReviewDecision; // { action, editedDraft?, note? }
  const result = await digestGraph.invoke(new Command({ resume: decision }), config);
  res.json({ sent: result.sent, draft: result.draft });
});

GET /pendinggraph.getState(config) 去 checkpointer 把「停在 review 的現場」撈出來——這正是第二節那條「跨越兩個請求」的關鍵:凌晨三點停住的那次執行,和九點打開控制面的這個請求,是靠 checkpointer 接起來的。 分析師甚至可以換一台電腦、隔一天再來審,只要 thread_id 對得上,待審卡就還在。

POST /review 就是把上一節測試裡的 graph.invoke(new Command({ resume })) 搬到 HTTP 上——分析師的決定從瀏覽器 POST 進來,變成 resume 值,graph 從斷點恢復、走 send 或 discarded、把結果回給前端。

⚠️ 注意 digestGraph 是一個模組層級、只建一次的單例digest-graph-instance.tsexport const digestGraph = buildDigestGraph(llmSummarizer(model)))。為什麼要單例?因為 MemorySaver 把現場存在這個 instance 的記憶體裡——若每個請求都 buildDigestGraph() 一個新的,resume 那次就撈不到 run 那次存的現場,關卡直接失效。

也正因為存在記憶體裡,MemorySaver 有個一定要講明的天花板:server 一重啟,所有「待審中」的現場就沒了。 這在正式產品當然不能接受——把 checkpointer 換成接 Postgres、跨程序都撈得到現場,是第 10 課的事。本課用 MemorySaver 是為了先把控制流講清楚,別把它帶去 production。

5.2 前端:一張待審卡,接進控制面

這張卡就住在第 5 課的控制面裡。當串流吐出 __interrupt__(或 GET /pending 回報有待審),就渲染它:

// ReviewCard.tsx   (住在第 5 課的 control panel 裡)
import { useState } from "react";
import type { ReviewDecision, ReviewPayload } from "./digest-state"; // ← 前後端共用同一份型別

export function ReviewCard({
  threadId,
  review,
  onResolved,
}: {
  threadId: string;
  review: ReviewPayload;
  onResolved: (sent: boolean) => void;
}) {
  const [draft, setDraft] = useState(review.draft);
  const [busy, setBusy] = useState(false);
  const edited = draft !== review.draft; // 分析師動過草稿沒?

  async function decide(decision: ReviewDecision) {
    setBusy(true);
    const res = await fetch(`/api/digest/${threadId}/review`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(decision),
    });
    const { sent } = await res.json();
    setBusy(false);
    onResolved(sent); // 收掉這張卡,回到控制面主畫面
  }

  return (
    <div className="review-card">
      <h3>待審週報 · 發送前確認</h3>

      {/* 給分析師的脈絡:這週偵測到哪些變化 */}
      <ul className="changes">
        {review.changes.map((c, i) => (
          <li key={i} data-severity={c.severity}>
            {c.severity === "red" ? "🔴" : "🟡"} {c.summary}
            <small>(來源:{c.source})</small>
          </li>
        ))}
      </ul>

      {/* 草稿本身,可直接改 */}
      <textarea value={draft} rows={16} onChange={(e) => setDraft(e.target.value)} />

      <div className="actions">
        <button
          disabled={busy}
          onClick={() =>
            decide(
              edited
                ? { action: "approve_with_edits", editedDraft: draft }
                : { action: "approve" },
            )
          }
        >
          {edited ? "改後核准並送出" : "核准並送出"}
        </button>
        <button
          disabled={busy}
          onClick={() => decide({ action: "reject", note: "退回,本週不送" })}
        >
          退回
        </button>
      </div>
    </div>
  );
}

留意這張卡怎麼把「三種決定」收成「兩顆按鈕」:分析師沒動草稿,核准就是乾淨的 approve;動過了,同一顆按鈕自動變成 approve_with_edits、把改過的全文一起送回去。退回則帶一則備註。這三種 ReviewDecision,就是上一節 graph 條件邊分岔的那三條路——前端點的按鈕,直接對應後端 graph 走的邊。 型別 ReviewDecision / ReviewPayload 前後端 import 的是同一份檔案,這是第 1 課那個「全 TypeScript 單一語言貫穿」在替你擋掉「前端送 approved 後端等 approve」這類低級不一致。

到這裡,第一、二個問題就都答完了:分析師在控制面上看到待發週報(GET /pending 撈回的草稿+變化清單)、改一改(textarea)、按核准或退回(POST 回 resume 端點),graph 就帶著他的決定繼續跑完。 這道 HITL 關卡,正式接上了第 5 課的控制面——它不是一個要人打字問問題的 chat 泡泡,而是一張「看一眼、按一下」的策展卡。


六、一個你一定會踩的坑:interrupt() 之後,節點會重跑

這是 LangGraph.js 的 HITL 最容易咬人一口的地方,講清楚能幫你省掉一個很難查的 bug。

回想 resume 做的事:它讓 graph 從剛才停住的節點恢復。但「恢復」的實作方式,不是從 interrupt() 那一行的正中間繼續——而是把整個 review 節點函式從頭再執行一次,只不過這一次跑到 interrupt() 時,它直接回傳 resume 值,而不是拋出中斷。

換句話說:

在會 interrupt() 的那個節點裡,寫在 interrupt() 「之前」的程式碼,會執行兩次(第一次跑到中斷、第二次 resume 時重跑)。寫在 interrupt() 「之後」的,只跑一次(resume 之後)。

如果你在 interrupt() 之前塞了會產生副作用的程式碼,它就會觸發兩次

// ❌ 錯:通知會發兩次
function reviewNode(state: typeof DigestState.State) {
  sendSlackPing("有一份週報等你審");            // 第一次跑一次,resume 時又跑一次 → 分析師被 ping 兩下
  const decision = interrupt({ draft: state.draft }) as ReviewDecision;
  return { decision };
}

有兩個層次要分清楚,別誤會成「所有東西都會重跑」:

  • 已經跑完的上游節點,不會重跑。 draft 節點在 review 之前、已經完成,它的結果早就進了 checkpointer。resume 時只有被中斷的 review 節點重跑,那個花錢的 LLM 起草只跑一次——這其實是好消息(重跑不會重付 LLM 的錢)。
  • 會重跑的,只是「被中斷的那個節點裡、interrupt() 之前的那幾行」。

所以正解就是我們在 4.3 一開始就做的:reviewNode 乾淨到只有 interrupt()return,任何副作用都別放在它 interrupt() 之前。 需要「通知分析師來審」這種副作用,就獨立成一個上游節點(它自己不 interrupt(),所以只跑一次):

// ✅ 對:通知獨立成一個節點,它不 interrupt,所以只跑一次
function notifyNode(state: typeof DigestState.State) {
  sendSlackPing("有一份週報等你審");
  return {};
}
function reviewNode(state: typeof DigestState.State) {
  const decision = interrupt({ draft: state.draft, changes: state.changes }) as ReviewDecision;
  return { decision };
}
// 邊:draft → notify → review → ...

一句話記住這條紀律:interrupt() 關進一個只負責「停」的小節點,別讓它跟副作用同居。 副作用(發通知、寄信、扣錢、寫外部系統)要嘛放在它上游的獨立節點、要嘛放在 resume 之後的下游節點——就是別放在會中斷的節點裡、interrupt() 的前面

💡 有人會問:退回(reject)能不能不要直接丟掉,而是退回去讓 graph 重新起草一版?當然可以——把 discarded 換成一條連回 draft 的邊、把分析師的 note 餵回去當提示就行。但你一旦讓 graph 能「繞回去重跑」,就得防它繞不出來(分析師一直退、模型一直改)。那道「最多繞幾次」的護欄,就是 max-iteration(迭代天花板),是第 8 課的事。這一課先讓退回乾淨地結束,不開這個環。


這一課建立了什麼

  • interrupt():在節點裡呼叫的暫停原語。執行到它,graph 就地凍結、把你給的 payload 交給呼叫端、然後等人。它的暫停能力是 checkpointer 借給它的——compile 時沒給 checkpointer,interrupt() 會報錯。本課用記憶體版 MemorySaver 帶過;checkpointer 的正式定義在第 7 課、接 Postgres/多租戶在第 10 課
  • resume:用 graph.invoke(new Command({ resume: 決定 }), config)帶著同一個 thread_id 回到斷點。interrupt() 這時把「決定」當回傳值吐出,節點繼續跑完。resume 傳的是 Command,不是重新輸入。
  • human-in-the-loop(HITL):流程跑到要害(發送前)把控制權交回真人的模式。它是 ai-you-should-know 第 5 章那道「在關鍵處插檢查點」的實作,本課不重講模式、只做實作。
  • 接進控制面:後端多兩個端點——GET /pending(用 getState 從 checkpointer 撈待審現場)、POST /review(把分析師決定變成 resume);前端一張 ReviewCard(顯示草稿+變化、可改、核准/退回),接在第 5 課的 control panel 與 streaming 上。三種 ReviewDecision 對應 graph 的三條條件邊;型別前後端共用(第 1 課的紅利)。它是策展卡,不是 chat 泡泡
  • 為什麼這道關卡值錢:決策級內容錯一次的代價遠大於對十次;HITL 把「一個偶爾出錯的 AI」變成「一個陌生人敢付費的產品」,賣的是信任。邊界:它是人的瓶頸,要設計得又快又窄(只看 diff、之後可只人審高風險),不是「所有東西永遠都要人審」。
  • 一定會踩的坑:被中斷的節點在 resume 時會從頭重跑interrupt() 之前的程式碼會跑兩次(已完成的上游節點與 LLM 起草不會重跑)。紀律:讓會 interrupt() 的節點只負責「停」,副作用放到別的節點。

下一課是這門課的殺手鐧⭐:我們終於要正式定義 checkpointer / Store / thread,讓 graph 真的記得上週——把這一課「暫停用得上的 checkpointer」升級成「跨週記憶用得上的 checkpointer」,然後兌現整個產品最值錢的那一招:只報 diff。