TL;DR:線性 graph 不夠用的時候,你需要兩個東西:Conditional Edge(跑完一個 node,看結果決定下一步去哪——過了往前、沒過退回重試、重試太多次直接退件)跟 Reducer(讓某個 state 欄位「累加」而不是「覆蓋」,最典型就是 retry 計數器)。這篇用add_conditional_edges+ router 函式 +Annotated[int, operator.add]把這兩件事講透,並解釋一個關鍵設計決策:MAX_RETRY 該設多少、為什麼很多時候答案是 0。這是「用真實專案學 LangGraph」系列 EP3。
EP2 教完線性流水線——一站接一站往前走。但真實流程不是直線:LLM 出的結果可能不合規,要退回重做;重試幾次還是不行,要直接放棄。這篇就把「會分流、會重試」的能力加上去。
📌 目錄
- 為什麼線性 graph 不夠用
- Conditional Edge:跑完看結果決定去哪
- Router 函式:回傳一個字串就好
- Reducer:讓欄位累加而不是覆蓋
- 完整範例:驗證 + 重試 + 退件
- 關鍵決策:MAX_RETRY 該設多少
- 我踩過的坑
- 常見問題 FAQ
- 延伸資源
為什麼線性 graph 不夠用
接著上一篇的血脂決策 pipeline。現在多一個現實需求:LLM 出的建議要過一道業務規則驗證。比如:
- 它說「升級用藥」,但理由欄位空白 → 不合格,退回重出
- 它推薦的藥不在允許清單裡 → 不合格
- 重試 N 次還是不合格 → 別卡死,直接退件回報
看到那個「分三條路」的 B 沒有?線性的 add_edge 做不到這個——它只能「A 跑完一定跑 B」。要讓「跑完 B 之後走哪條,由 B 的結果決定」,就要用 conditional edge。
⚡ Conditional Edge:跑完看結果決定去哪
add_conditional_edges 取代 add_edge,多帶兩個東西:一個 router 函式(看 state 決定回傳哪個標籤),跟一個 標籤到 node 的對照表:
g.add_conditional_edges(
"validate", # 跑完這個 node 後
validate_router, # 呼叫這個函式決定去哪
{
"ok": "adapt_response", # 回 "ok" → 去這個 node
"retry": "llm_decide", # 回 "retry" → 退回 LLM node 重做
"reject": END, # 回 "reject" → 結束
},
)
意思很直白:validate 這個 node 跑完,LangGraph 呼叫 validate_router(state),拿它的回傳值去對照表裡查下一站。回 "retry" 就跳回 llm_decide——這條邊往回連,就形成了重試迴圈。
🧠 Router 函式:回傳一個字串就好
Router 函式跟 node 不一樣:node 回傳 dict(改 state),router 回傳字串(決定路線)。Router 不改 state,只讀 state 然後做決定:
from typing import Literal
MAX_RETRY = 1
def validate_router(state) -> Literal["ok", "retry", "reject"]:
v = state.get("validation")
if v and v["passed"]:
return "ok" # 驗過了,往前
if state.get("retry_count", 0) >= MAX_RETRY:
return "reject" # 重試用完,退件
return "retry" # 還能試,退回重做
回傳型別標 Literal["ok", "retry", "reject"] 是好習慣——它讓你(跟你的 IDE)一眼看出這個 router 只會吐這三個值,而且必須跟 add_conditional_edges 對照表的 key 完全對上。少一個、拼錯一個,跑到那條路就炸。
⚙️ Reducer:讓欄位累加而不是覆蓋
現在問題來了:retry_count 怎麼加上去?
按 EP2 教的,node 回傳 dict 會「合併」進 state——但合併是覆蓋。如果驗證失敗的 node 回傳 {"retry_count": 1},那 state 裡的 retry_count 就被設成 1。再失敗一次,還是 1。永遠加不上去,重試迴圈跑不完。
解法是 reducer:告訴 LangGraph 這個欄位的更新方式不是「覆蓋」,而是「用某個函式合併」。對計數器來說,那個函式就是「加法」:
import operator
from typing import Annotated, TypedDict
class AnalyzeState(TypedDict, total=False):
validation: dict
retry_count: Annotated[int, operator.add] # 關鍵:標上 reducer
# ... 其他欄位
Annotated[int, operator.add] 的意思是:「這欄位是 int,每次 node 回傳新值時,用 operator.add 跟舊值合併」。所以驗證失敗的 node 回傳 {"retry_count": 1} 時:
- 第一次失敗:
0 + 1 = 1 - 第二次失敗:
1 + 1 = 2
operator.add)跟訊息列表(把新訊息 append 進舊列表,而不是整串換掉)。
📝 完整範例:驗證 + 重試 + 退件
把 conditional edge + reducer 拼進 EP2 的 graph:
import operator
from typing import Annotated, Literal, TypedDict, Any
from langgraph.graph import StateGraph, START, END
MAX_RETRY = 1
class AnalyzeState(TypedDict, total=False):
patient: dict[str, Any]
decision: str
validation: dict
retry_count: Annotated[int, operator.add]
advice: str
def llm_decide(state: AnalyzeState) -> dict:
# 假裝這裡呼叫 LLM。第一次故意給沒理由的爛結果,重試後給好的
attempt = state.get("retry_count", 0)
if attempt == 0:
return {"decision": "升級用藥", "validation": None} # 缺理由
return {"decision": "升級用藥(理由:LDL 未達標)"}
def validate(state: AnalyzeState) -> dict:
ok = "理由" in state.get("decision", "")
out = {"validation": {"passed": ok}}
if not ok:
out["retry_count"] = 1 # 失敗才 +1,靠 reducer 累加
return out
def validate_router(state) -> Literal["ok", "retry", "reject"]:
if state["validation"] and state["validation"]["passed"]:
return "ok"
if state.get("retry_count", 0) >= MAX_RETRY:
return "reject"
return "retry"
def adapt_response(state: AnalyzeState) -> dict:
return {"advice": state["decision"]}
g = StateGraph(AnalyzeState)
g.add_node("llm_decide", llm_decide)
g.add_node("validate", validate)
g.add_node("adapt_response", adapt_response)
g.add_edge(START, "llm_decide")
g.add_edge("llm_decide", "validate")
g.add_conditional_edges(
"validate",
validate_router,
{"ok": "adapt_response", "retry": "llm_decide", "reject": END},
)
g.add_edge("adapt_response", END)
app = g.compile()
result = app.invoke({"patient": {}})
print(result.get("advice", "(退件)"))
# → 升級用藥(理由:LDL 未達標)
跑這段:第一次 llm_decide 給沒理由的結果 → validate 不過、retry_count 變 1 → router 回 retry → 退回 llm_decide → 這次給有理由的 → validate 過 → 往 adapt_response → 印出最終建議。整個重試迴圈活起來了。
🎯 關鍵決策:MAX_RETRY 該設多少
這是設計層的決定,不是 code 問題,但它最影響系統行為。
直覺上「多重試幾次提高成功率」,但實務上很多嚴肅場景 MAX_RETRY 應該設 0——也就是「失敗一次就直接退件,不重試」。原因:
- 重試會放大延遲跟成本:每次重試都是一次完整 LLM 呼叫,retry 3 次 = 4 倍 latency + 4 倍 token 費用。
- 重試常常救不回來:如果 LLM 第一次就出系統性錯誤(prompt 設計問題、模型能力不足),重試只是換個說法錯一樣的。
- fail fast 比較誠實:尤其醫療、金融這種高風險場景,與其讓系統「重試到看起來像對」,不如失敗就老實退件給人處理。
MAX_RETRY = 0——驗證不過直接退件,噴 422 給上游。保留 "retry" 這條路只是讓 graph 結構完整,實際上不會被走到(因為 retry_count 一失敗就 ≥ 0)。
什麼時候才該 retry > 0?當失敗是隨機性的(網路抖動、偶發 JSON parse 失敗),重試真的可能成功。如果失敗是系統性的,retry 只是燒錢。先想清楚你的失敗是哪一種。
🔥 我踩過的坑
坑一:router 回傳的字串對不上對照表 key
def router(state) -> str:
return "OK" # ❌ 對照表 key 是小寫 "ok"
router 回傳的字串必須完全等於 add_conditional_edges 對照表的 key。大小寫、空白差一個就 KeyError。標 Literal[...] 回傳型別能讓這類錯誤在寫的時候就被 IDE 抓到。
坑二:忘記標 reducer,計數器永遠不累加
retry_count 沒標 Annotated[int, operator.add],每次回傳 {"retry_count": 1} 都是覆蓋成 1,永遠到不了 MAX_RETRY,重試迴圈變無限迴圈。LangGraph 預設有遞迴上限(recursion_limit)會幫你踩煞車丟錯,但那是兜底,不是你想要的行為。需要累加的欄位,第一時間就標 reducer。
坑三:retry 邊沒接回正確的 node
重試是「退回去重做」,那條 conditional edge 要連回該重做的起點。常見錯誤是連回 validate 自己(那就只是反覆驗同一個爛結果,不會變好),或連到不相干的 node。想清楚「重試到底要重跑哪一段」。
❓ 常見問題 FAQ
Q:conditional edge 跟 add_edge 可以混用嗎?
A:可以,而且通常會混。大部分的線用 add_edge 直連,只有真的需要分流的那幾個點用 add_conditional_edges。不要每條線都搞成 conditional,那會讓流程難讀。
Q:router 函式可以改 state 嗎?
A:不該。router 的職責是「讀 state、決定路線」,改 state 是 node 的事。如果你發現 router 裡想改東西,那段邏輯應該搬到前一個 node 去。職責分清楚,graph 才好維護。
Q:reducer 只能用 operator.add 嗎?
A:不是。reducer 是任何「拿舊值跟新值、回傳合併值」的函式。operator.add 適合數字相加跟 list 串接;你也可以自己寫一個函式做更複雜的合併(例如去重後合併、取最大值)。LangGraph 對訊息列表還有專用的 add_messages reducer。
Q:怎麼避免重試迴圈跑成無限迴圈?
A:兩層保險。第一層是你自己的 MAX_RETRY 邏輯(router 判斷 retry_count 超過就走 reject)。第二層是 LangGraph 的 recursion_limit(compile 後 invoke 時可設),跑超過步數上限會丟 GraphRecursionError。第一層是你該做的,第二層是 LangGraph 幫你兜底。
Q:MAX_RETRY = 0 那為什麼還要保留 retry 那條邊?
A:因為 add_conditional_edges 的對照表必須涵蓋 router 可能回傳的所有值。即使 retry 實際上不會被走到,對照表少了那個 key,萬一 router 真的回了它就 KeyError。保留是為了結構完整跟防禦。
🔗 延伸資源
- LangGraph 官方 repo(langchain-ai/langgraph) — conditional edges 跟 reducer 的 API
- LangGraph 1.0 GA 公告 — durable state 怎麼讓重試更可靠
- 上一篇:EP2 — LangGraph 核心 State / Node / Edge
- 下一篇:EP4 — 把 LLM 接進 node,怎麼讓它吐結構化輸出、配 few-shot、過業務驗證