iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Claude AI

從 LLM 到 Agent:用 Claude 拆解現代 AI 工程的每一層系列 第 27 篇

用 LangGraph 跑 Claude 寫審代理:檢查點(checkpoint)、當機接回與 human-in-the-loop

  • 分享至 

  • xImage
  •  

第 26 篇的寫審實驗裡,「要不要再審一次」是協調者自己決定的。第 2 次跑的時候,它判斷再跑也一樣,就停下來問我 cursor 的參數叫什麼。這個判斷很合理,可是事先沒人知道它會這樣做。

這一篇把同一個寫審環寫成一張明確的圖。模型全程是 Claude,每一步都交給 Claude Code 的 claude -p 去跑,LangGraph 只負責把圖畫出來、存檔、停下來等人。先講結論:圖寫成程式之後,多出來三樣東西:一份每個節點都讀得到的狀態、每走一步就存一次的檢查點,以及一個可以停下來等人的地方。 付出去的是彈性:第 26 篇那種臨場決定,要你事先畫成一條邊。


同一個寫審環,這次邊寫在程式裡

節點有四個:寫、審、人工核准、存檔。

寫審環寫成 LangGraph 的圖。START 接到 write(Claude Sonnet 5.5,寫 changelog),write 接到 review(Claude Opus 5.5,回 PASS 或 FAIL)。review 之後分三條邊:PASS 接到 approve(interrupt(),等人核准),approve 在 resume=True 之後接到 save(寫出檔案),save 接到 END。FAIL 而且還沒到第 3 輪,紅色的邊繞回 write,把問題清單交回去改。FAIL 而且已經第 3 輪,虛線直接接到 END,不存檔。review 下方標註這次實跑在第一次 review 前故意當掉,新行程從檢查點接回這裡

關鍵是 review 後面那條條件邊:

class Verdict(BaseModel):
    verdict: Literal["PASS", "FAIL"]
    issues: list[str]  # 「規則編號:問題」

def route(state: State) -> Literal["approve", "write", "__end__"]:
    if state["verdicts"][-1] == "PASS":
        return "approve"
    return "write" if state["round"] < MAX_ROUNDS else END

g = StateGraph(State)
g.add_node("write", write)
g.add_node("review", review)
g.add_node("approve", approve)
g.add_node("save", save)
g.add_edge(START, "write")
g.add_edge("write", "review")
g.add_conditional_edges("review", route)
g.add_edge("approve", "save")
g.add_edge("save", END)
graph = g.compile(checkpointer=SqliteSaver(sqlite3.connect("checkpoints.sqlite")))

寫的節點呼叫 Claude Sonnet 5.5,審的節點呼叫 Claude Opus 5.5,兩個都走 Claude Code 的非互動模式,用本來的登入,不需要 API key。工具全關(--tools ""),審查加上 --json-schema,判決會放在輸出的 structured_output 欄位。這就是第 26 篇說的「審 → 寫,傳結構化的判決」,route 讀一個欄位就決定下一步。

第 26 篇是協調者模型讀完審查結果,自己決定下一步。這裡是 route 那幾行程式,同樣的輸入,永遠走同一條邊。第 26 篇發現每一輪的審查者都是新的,看不到自己上一輪說過什麼,這張圖沒有解決這件事。差別是上一輪的判決現在就在狀態裡,要不要交給審查者,是我寫不寫進提示詞的事。

把任務寫成狀態和轉移,學界有現成的說法。StateFlow(Wu et al., arXiv 2024)把 LLM 解題的過程看成一台狀態機,每個狀態代表流程目前走到哪,狀態之間的轉移可以由寫死的規則決定,也可以交給 LLM 決定。摘要報告在 InterCode SQL 和 ALFWorld 上,成功率比 ReAct 高 13% 和 28%,成本分別少 5 倍和 3 倍。用它的分法看,route 是規則決定的轉移,第 26 篇的協調者是 LLM 決定的轉移。


狀態:每一條邊傳的是同一份東西

LangGraph 官方把 State 定義成一份共用的資料結構,代表應用程式當下的快照。節點拿到目前的狀態,回傳要更新的欄位(2026-10-11 查):

class State(TypedDict):
    commits: str        # git log,含 hash
    rules: str          # 四條規則,只有 review 會放進提示詞
    changelog: str      # write 的產出
    feedback: list[str] # review 給的問題清單
    round: int
    verdicts: list[str]
    status: str         # approve 的結果

規則只有 review 會放進提示詞。第 26 篇第一版的寫手自己去資料夾把 RULES.md 讀了出來,這裡的寫手沒有工具,它看得到的只有 write 送進去的那段字。

第 1 篇說過,messages 這個結構會跟著走完七層,每加一層,要嘛是在陣列裡多塞東西,要嘛是在外面多包一層流程。到第七層是後者。前面幾層的對話越疊越長,這裡每個節點送出去的,都是用 State 當場組出來的一則新請求。誰寫過什麼、審到第幾輪,搬到了請求外面,放在 State 和檢查點裡。


檢查點:每走一步就存一次

編譯時傳一個 checkpointer,LangGraph 會在每一步之後存一份檢查點(checkpoint),裡面有當下的狀態值、下一步要跑的節點、所屬的執行緒(thread_id)。這裡的檢查點存的是圖的狀態,跟第 15 篇提到 Claude Code 用 /rewind 倒回去的那種檢查點是兩回事。官方的說法是,某一步失敗了,可以從最後一個成功的步驟重新開始(2026-10-11 查)。

我在 review 開頭放了一個開關,第一次審查前故意丟一個例外,然後開一個全新的行程,用同一個 thread_id 呼叫 graph.invoke(None, config)。實際跑一次(2026-10-11,例子,不是證據):

  1. 寫手寫完第一版,第一次審查前當掉。get_state 顯示 next=('review',)、round=1。
  2. 新行程直接從 review 開始,沒有重寫第一版。第一輪 FAIL:分類用了 Features、Bug Fixes、Documentation、Chores,破壞性變更的標題寫成英文的 Breaking Changes,前面還加了一個警告符號,hash 用了半形括號。
  3. 第二版 PASS,圖停在 approve。
  4. 我回 True,跑完 save,寫出 changelog.md。

整趟四次模型呼叫,寫手兩次、審查兩次,當機沒有造成任何一次重複。0.08 美元,約 26 秒。get_state_history 從頭到尾 8 份檢查點,next 依序是 __start__、write、review、write、review、approve、save、空。

這次的審查讓只有 commit 原句的破壞性變更通過了,第 26 篇第 2 次判同一句違反規則 2。換一次呼叫,判決就可能不一樣,這張圖沒有改變這件事。另外,token-inspectour 攔到的四則請求都在 2.7K 到 3.6K 詞元之間,工具全關也一樣,因為 Claude Code 自己的系統提示會一起送出去。改成直接打 API,只要換掉呼叫模型的兩個函式。

第 13 篇說記憶不在 Claude 那裡,存在哪是你的事。檢查點就是那句話在圖上的版本。存的時機官方給了三種:exit 跑完才存,async 邊跑邊存,sync 每一步存完才往下走,越後面越穩也越慢。


中斷:在圖上留一個等人的位置

審查通過之後,approve 呼叫 interrupt(),圖就停在這裡:

def approve(state: State):
    ok = interrupt({"question": "審查通過,要存檔嗎?", "changelog": state["changelog"]})
    return {"status": "approved" if ok else "rejected"}

interrupt() 會用檢查點把狀態存起來,等外面的輸入。人看完之後,用 graph.invoke(Command(resume=True), config) 接回去,True 就是 interrupt() 的回傳值,所以人可以隔一天再回來按。接回的時候,被中斷的那個節點會從頭重跑(2026-10-11 查)。如果你在 interrupt() 之前寄了一封信,接回時就會再寄一次。這是分散式系統的老問題。Elnozahy 等人(ACM Computing Surveys, 2002)整理回溯復原(rollback-recovery)時,把系統外面那些收不回來的輸入輸出叫做「外部世界」,外部世界不能指望它跟著回溯,所以只靠檢查點的復原,不適合常跟外部世界互動的程式。寄信、扣款、呼叫外部 API 都是外部世界,放在會重跑的節點裡,就要寫成重跑也不會出事。

第 26 篇的協調者是自己決定停下來問人的,這裡停在哪、問什麼,是我畫在圖上的。什麼時候該問人,人機互動領域早就討論過。Horvitz(CHI 1999)的混合主導(mixed-initiative)介面原則裡,有一條是系統對使用者的意圖不確定時,用對話把關鍵的不確定釐清,同時要考慮白白打擾使用者的成本。第 26 篇的協調者是碰到規則打架、缺了資料才停下來問,比較接近這條。這張圖則是每次審查通過都問,不管有沒有不確定。這也跟第 20 篇的權限系統是兩回事:權限擋的是一次工具呼叫,interrupt() 停的是流程裡的一步。


同一件事,在 Claude 那邊怎麼做

狀態、檢查點、等人插手,是圖工程本身的需求,Claude 自己的工具也各有一套做法(2026-10-11 查):

需求 LangGraph Claude Agent SDK/Claude Code Managed Agents
存什麼 你定義的 State,每一步一份檢查點 工作階段(session):整段對話,存成 ~/.claude/projects/ 底下的 .jsonl,不含檔案變更 每個代理的執行緒會留著,可以追問
怎麼接回 同一個 thread_id,invoke(None, config) resume 傳 session ID,或 continue 接最近一個 對同一條執行緒再送一則訊息
人從哪裡插手 任何節點裡都能 interrupt() 工具呼叫要權限時走 canUseTool 回呼 子代理要權限時,事件轉到主執行緒
下一步誰決定 你寫的邊 模型,在迴圈裡 協調者模型

Claude 那邊存的是對話,接回之後模型看到完整的歷史,自己接著想。LangGraph 存的是你定義的欄位,接回之後從下一個節點開始跑。

人工核准的部分我也跑了一次。用 Agent SDK 叫 Claude 把同樣那五則 commit 整理成 changelog.md,寫檔要經過回呼:

async def can_use_tool(tool_name, input_data, context):
    if tool_name == "Write" and first_write():  # 第一次寫檔
        return PermissionResultDeny(
            message="先不要存。這份是要給客戶看的,chore 和 docs 那兩條拿掉,其他照舊,改好再存。")
    return PermissionResultAllow(updated_input=input_data)

Claude 第一次要寫的檔案有五節,最後兩節是「文件(Documentation)」和「維護(Chores)」。回呼擋下來,那段話交給 Claude,它第二次要寫的內容只少了那兩節,這次放行(2026-10-11,Claude Opus 5.5,0.11 美元)。讀檔和唯讀的 git 指令會在更前面自動放行,不經過回呼。Python 版照官方文件還要用串流送提示詞、掛一個空的 PreToolUse hook。這裡回答的是一段程式,換成真的人,就是回呼裡等他按同意或打一段話。

兩種插手的差別:回呼是 Claude 來問,單位是一次工具呼叫,人的回答變成 Claude 看到的一段話。interrupt() 是圖停在你指定的節點,人的回答變成 State 裡的一個值,下一步由你寫的邊決定。


這麼做的代價

第一,彈性換成了可控。 第 26 篇的協調者看出規則打架,自己停下來問人。這張圖只會照 route 走到第三輪結束。想要它停下來問,得先把「什麼情況要問」寫成一條邊。

第二,存檔要時間和空間。 sync 模式下每一步都要等存完,SQLite 檔案會一直長,舊的執行緒要自己清。

第三,接回時會重跑。 被中斷或沒跑完的節點會從頭來過,有副作用的節點要寫成重跑也不會出事。


這一篇多了什麼,又多付了什麼

多了什麼能力:會用 LangGraph 把寫審環寫成明確的圖,用共用的 State 傳東西、用條件邊決定下一步、用檢查點在當機後接回、用 interrupt() 留一個等人的位置,並且分得清它跟 Claude Agent SDK 的工作階段、canUseTool、Managed Agents 的執行緒各存什麼、在哪裡讓人插手。

多付了什麼代價:沒畫在圖上的情況它不會應變、每一步存檔的時間與空間,以及接回時重跑的那一步。


下一篇

拆成好幾個代理、接成一張圖之後,第 4 篇那 10 題會變好嗎?帳單又多了多少?


延伸閱讀

官方文件

論文


上一篇
圖工程(graph engineering):五種接法,以及寫的和審的為什麼要分開.
下一篇
多代理的代價:分數沒動、帳單多六成,以及 MAST 整理的 14 種壞法
系列文
從 LLM 到 Agent:用 Claude 拆解現代 AI 工程的每一層 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
helenanova
iT邦新手 5 級 ‧ 2026-10-11 23:24:11

把審查結果的 verdict 用 Literal 限死、再用條件邊接 retry,這比讓協調者臨場判斷好驗證。我會多測一個情況:審查節點一直回 FAIL 時,有沒有最大重試次數,還有從檢查點接回時 state 裡的 issues 會不會重複累加。可以故意讓 review 固定回 FAIL,跑到中途殺掉行程再接回,看節點次數和 issues 長度是否符合預期。

我要留言

立即登入留言