
【實戰】Day 25:Mode B:用 LangGraph 打造三角辯論 Agent
Mode A 單一 Agent 會說「我建議…」;Mode B 的三個 Agent 會先吵一架再告訴你答案。本篇實作 LangGraph StateGraph:業務、生管、財務三角辯論,財務否決條件路由,總經理最終仲裁。
WRITTEN BY

- Name
- Harry Chang
💡 數據為虛擬數據,並為了方便演示,毛利率改由品牌決定,而非庫存。
Day 24,我們建好了 Mode A:一個 Pydantic AI Agent,拿到問題就直接給答案。
今天,我們要讓三個 Agent 先吵一架——業務、生管、財務各說各話——然後再由總經理做最後裁決。這就是 Mode B:LangGraph 三角辯論。
- 專案目錄(Day 25 更新後)
- Mode A vs Mode B:差在哪裡?
- 為什麼選 LangGraph 而不是 CrewAI?
- LangGraph 核心概念
- 架構圖
- 請求時序圖
- 實作:成本模型(觸發財務否決的關鍵)
- 實作:三個專家 Agent
- 實作:四個節點函數
- 實作:組裝 LangGraph
- 實作:FastAPI 端點
- 安裝與啟動
- 測試:財務否決場景
- Mode A vs Mode B 實際差異對比
- 小結
- Day 18–25 進度表
專案目錄(Day 25 更新後)
ERP-AI-Agent/backend/
├── app/
│ ├── agents/
│ │ ├── order_agent.py # Mode A(Day 24)
│ │ └── debate_graph.py # Mode B(Day 25,新增)← NEW
│ ├── routers/
│ │ ├── orders.py
│ │ ├── agent.py # /agent/query
│ │ └── debate.py # /agent/debate ← NEW
│ ├── tools/
│ │ └── order_tools.py # 新增 BRAND_COST_FACTOR ← UPDATED
│ ├── context/
│ │ └── user_context.py
│ └── main.py # 加入 debate router ← UPDATED
├── data/
│ └── *.json
└── requirements.txt # 加入 langgraph ← UPDATED
Mode A vs Mode B:差在哪裡?
| 維度 | Mode A(Pydantic AI) | Mode B(LangGraph) |
|---|---|---|
| 決策者 | 單一 Agent | 3 專家 + 總經理仲裁 |
| 觀點 | 統一視角 | 三種角色對立 |
| 條件路由 | 不支援 | 財務否決 → 升級路徑 |
| 透明度 | 黑盒輸出 | 每個觀點都可見 |
| 速度 | 快(1 次 LLM 呼叫) | 慢(4 次 LLM 呼叫) |
| 適用場景 | 日常查詢、快速決策 | 大單審批、有爭議的決策 |
何時用 Mode B?
- 訂單金額超過門檻
- 跨部門利益有衝突(業務要衝業績、生管要控產能、財務要守紅線)
- 需要留下決策紀錄(三方意見可存稿)
為什麼選 LangGraph 而不是 CrewAI?
Day 5 介紹過 CrewAI,它的 Hierarchical 模式也有「主管分配任務」的概念。但對於有明確條件邏輯的流程,LangGraph 更合適:
CrewAI Hierarchical:
Manager → (黑盒分配) → Workers → Manager 彙整
條件分支?放在 prompt 裡,不保證執行
LangGraph StateGraph:
節點 A → 節點 B → 條件路由函數 → 節點 C 或節點 D
條件分支是程式碼,不是 prompt
我們的財務否決邏輯:毛利率 < 15% → 觸發否決 → 總經理必須正面回應。
這種「if margin < 15% then escalate」必須是確定性程式碼,不能交給 LLM 自己判斷要不要走。
LangGraph 核心概念
StateGraph:共享記憶體
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
class DebateState(TypedDict):
question: str
sales_view: str # 業務觀點(節點 A 寫入)
production_view: str # 生管觀點(節點 B 寫入)
finance_view: str # 財務觀點(節點 C 寫入)
finance_veto: bool # 否決旗標(節點 C 寫入,路由函數讀取)
final_decision: str # 總裁決(節點 D 寫入)
DebateState 是所有節點共享的「會議記錄本」:每個節點讀取它、寫入自己的部分、下個節點繼承所有欄位。
節點:純 async 函數
async def sales_node(state: DebateState) -> dict:
result = await sales_agent.run(state["question"])
return {"sales_view": result.data} # 只回傳要更新的欄位
節點回傳 dict,LangGraph 把它 merge 進 State。不需要回傳完整 State。
條件路由:程式碼決定走哪條邊
def route_after_finance(state: DebateState) -> Literal["veto_path", "normal_path"]:
return "veto_path" if state.get("finance_veto") else "normal_path"
builder.add_conditional_edges(
"finance", # 來源節點
route_after_finance, # 路由函數
{"veto_path": "gm", "normal_path": "gm"}, # 兩條路都到 gm,但狀態不同
)
架構圖
系統全貌:前端到總裁決
LangGraph 條件路由
兩條路最終都進入同一個 gm 節點,但節點內部會讀取 finance_veto 旗標,調整 GM 的提示語。
請求時序圖
架構圖說明「有哪些參與者」,時序圖說明「一次辯論請求如何走過這些節點」。以下以 Puma 財務否決場景為例:
| 步驟 | 發生什麼事 | 對應技術 |
|---|---|---|
| ① 前後端橋樑 | 前端發送問題,跨越 Node.js / Python 環境邊界 | FastAPI CORS + fetch |
| ② 三角辯論 | LangGraph 依序呼叫三個專家節點,各自將分析寫入 DebateState | StateGraph.ainvoke + 節點 partial dict |
| ③ 財務審查 | 財務節點同步計算毛利率(程式碼),設定 finance_veto 旗標後條件路由 | BRAND_COST_FACTOR + add_conditional_edges |
| ④ 總裁決 + 回傳 | 總經理讀取否決旗標調整提示語,裁決結果序列化為 JSON 回傳前端 | gm_node → DebateResponse |
實作:成本模型(觸發財務否決的關鍵)
order_tools.py 加入各品牌成本係數:
# 各品牌成本係數(模擬不同代工廠的實際成本結構)
BRAND_COST_FACTOR = {
"nike": 0.35, # 毛利 65%
"adidas": 0.38, # 毛利 62%
"new balance": 0.40, # 毛利 60%
"puma": 0.88, # 毛利 12% → 低於 15% 紅線 🚨
"asics": 0.36, # 毛利 64%
"under armour": 0.42, # 毛利 58%
}
Puma 的成本係數 0.88 代表代工成本佔售價 88%,毛利只剩 12%,低於公司紅線。這個設定讓財務否決可以被可靠地觸發,用於演示和測試。
實作:三個專家 Agent
三個 Agent 各有獨立的 system_prompt,代表不同職能角色的思考框架:
# app/agents/debate_graph.py
from pydantic_ai import Agent
from langgraph.graph import StateGraph, START, END
MODEL = os.getenv("LLM_MODEL", "openai:gpt-4o-mini")
sales_agent = Agent(
MODEL,
system_prompt=(
"你是鞋墊廠的業務主任,使用繁體中文回答。\n"
"分析重點:訂單達標率、客戶關係維護、交期承諾、市場機會。\n"
"回答格式:3-5 點建議,每點不超過 50 字。\n"
"結尾加上「業務觀點結論:」一句話。"
),
)
production_agent = Agent(
MODEL,
system_prompt=(
"你是鞋墊廠的生產管理主任,使用繁體中文回答。\n"
"分析重點:廠區稼動率、產能瓶頸、交期可行性、備料風險。\n"
"回答格式:3-5 點評估,每點不超過 50 字。\n"
"結尾加上「生管觀點結論:」一句話。"
),
)
finance_agent = Agent(
MODEL,
system_prompt=(
"你是鞋墊廠的財務主任,使用繁體中文回答。\n"
"分析重點:毛利率健全度、現金流、付款條件風險。\n"
"公司毛利率紅線:15%。低於紅線必須明確反對並說明原因。\n"
"如有財務否決,最後一行必須以「財務否決:」開頭說明原因。"
),
)
gm_agent = Agent(
MODEL,
system_prompt=(
"你是鞋墊廠的總經理,使用繁體中文回答。\n"
"決策優先序:財務健全 > 產能可行 > 業務機會。\n"
"回答格式:\n"
"【最終決策】:接受 / 拒絕 / 條件接受\n"
"【決策理由】:2-3 句話\n"
"【行動指示】:具體的下一步(2-3 條)"
),
)
實作:四個節點函數
業務節點(讀取訂單摘要)
async def sales_node(state: DebateState) -> dict:
summary = get_orders_summary()
context = (
f"問題:{state['question']}\n"
f"目前整體訂單摘要:各品牌營收 {summary['by_brand']},"
f"訂單狀態分佈 {summary['by_status']}"
)
result = await sales_agent.run(context)
return {"sales_view": result.data}
生管節點(讀取三廠產能)
async def production_node(state: DebateState) -> dict:
factories_info = [get_factory_capacity(fc) for fc in ["TW-01", "VN-02", "ID-03"]]
cap_summary = [
f"{f['factory_name']}:稼動率 {f['utilization_pct']}%,"
f"可接單 {f['available_qty']} 雙,交期 {f['lead_time_days']} 天"
for f in factories_info if f.get("found")
]
context = (
f"問題:{state['question']}\n"
f"目前廠區狀況:{' | '.join(cap_summary)}"
)
result = await production_agent.run(context)
return {"production_view": result.data}
財務節點(計算全品牌毛利率 + 設定否決旗標)
async def finance_node(state: DebateState) -> dict:
margin_lines = []
veto_brands = []
for brand in ["Nike", "Adidas", "New Balance", "Puma", "Asics", "Under Armour"]:
m = calculate_gross_margin(brand)
if m.get("found"):
margin_lines.append(f"{brand}: {m['gross_margin_pct']}%")
if m["below_redline"]:
veto_brands.append(f"{brand}({m['gross_margin_pct']}%)")
veto_triggered = len(veto_brands) > 0
veto_reason = f"以下品牌毛利率低於 15% 紅線:{', '.join(veto_brands)}" if veto_triggered else ""
context = (
f"問題:{state['question']}\n"
f"各品牌毛利率:{', '.join(margin_lines)}\n"
+ (f"⚠️ 警告:{veto_reason}" if veto_triggered else "所有品牌毛利率均符合紅線要求")
)
result = await finance_agent.run(context)
return {
"finance_view": result.data,
"finance_veto": veto_triggered, # ← 這個旗標會被路由函數讀取
"veto_reason": veto_reason,
}
關鍵點: finance_node 不只是呼叫 LLM,它還同步執行 Python 工具函數計算毛利率,把確定性資料(數字)和語言模型輸出(文字分析)分開。
總經理節點(讀取否決旗標,調整提示語)
async def gm_node(state: DebateState) -> dict:
veto_notice = (
f"\n⚠️ 財務否決已觸發:{state.get('veto_reason', '')}\n"
"請特別考量是否接受財務否決,或要求財務提出改善條件後重新評估。"
if state.get("finance_veto")
else ""
)
synthesis = (
f"問題:{state['question']}\n\n"
f"業務觀點:\n{state['sales_view']}\n\n"
f"生管觀點:\n{state['production_view']}\n\n"
f"財務觀點:\n{state['finance_view']}"
f"{veto_notice}"
)
result = await gm_agent.run(synthesis)
return {"final_decision": result.data}
實作:組裝 LangGraph
def build_debate_graph():
builder = StateGraph(DebateState)
# 註冊節點
builder.add_node("sales", sales_node)
builder.add_node("production", production_node)
builder.add_node("finance", finance_node)
builder.add_node("gm", gm_node)
# 線性邊:sales → production → finance
builder.add_edge(START, "sales")
builder.add_edge("sales", "production")
builder.add_edge("production", "finance")
# 條件邊:finance → (veto_path / normal_path) → gm
builder.add_conditional_edges(
"finance",
route_after_finance, # 路由函數
{"veto_path": "gm", "normal_path": "gm"}, # 兩條路都到 gm
)
builder.add_edge("gm", END)
return builder.compile()
debate_graph = build_debate_graph()
builder.compile() 回傳一個可執行的 graph 物件,支援 await graph.ainvoke(state) 非同步呼叫。
實作:FastAPI 端點
# app/routers/debate.py
from fastapi import APIRouter
from pydantic import BaseModel
from app.agents.debate_graph import debate_graph, DebateState
router = APIRouter(prefix="/api/v1", tags=["debate"])
class DebateRequest(BaseModel):
message: str
user_id: str = "gm_harry"
class DebateResponse(BaseModel):
sales_view: str
production_view: str
finance_view: str
finance_veto: bool
veto_reason: str
final_decision: str
user_role: str
user_name: str
@router.post("/agent/debate", response_model=DebateResponse)
async def agent_debate(request: DebateRequest):
profile = USER_PROFILES.get(request.user_id, DEFAULT_PROFILE)
initial_state: DebateState = {
"question": request.message,
"user_role": profile["role"],
"user_name": profile["name"],
"sales_view": "",
"production_view": "",
"finance_view": "",
"finance_veto": False,
"veto_reason": "",
"final_decision": "",
}
result = await debate_graph.ainvoke(initial_state)
return DebateResponse(**{k: result[k] for k in DebateResponse.model_fields}, ...)
在 main.py 加一行:
from app.routers import orders, agent, debate # ← 新增 debate
app.include_router(debate.router)
安裝與啟動
安裝新依賴:
# 在 venv 環境下
pip install langgraph>=0.2.0
或重新安裝全部:
pip install -r requirements.txt
啟動伺服器(與 Day 24 相同):
cd C:\Users\Harry\Desktop\30-Days\ERP-AI-Agent\backend
.\.venv\Scripts\Activate.ps1
uvicorn app.main:app --reload --port 8000
測試:財務否決場景
提問關於 Puma(成本係數 0.88,毛利 12%):
curl -X POST http://localhost:8000/api/v1/agent/debate \
-H "Content-Type: application/json" \
-d '{"message": "我們是否應該增加 Puma 的訂單量?", "user_id": "gm_harry"}'
預期回應結構(簡化):
{
"sales_view": "1. Puma 去年完成率 100%,客戶關係穩定...\n業務觀點結論:建議積極爭取 Puma 擴單。",
"production_view": "1. VN-02 目前稼動率 91%,已接近上限...\n生管觀點結論:短期擴單有產能風險。",
"finance_view": "1. Puma 訂單毛利率 12%,低於公司紅線 15%...\n財務否決:Puma 毛利率不符最低門檻,建議重新議價後再決定。",
"finance_veto": true,
"veto_reason": "以下品牌毛利率低於 15% 紅線:Puma(12%)",
"final_decision": "【最終決策】:條件接受\n【決策理由】:業務端客戶關係良好,但財務紅線不可逾越...\n【行動指示】:\n1. 要求業務在兩週內與 Puma 重新議價,目標毛利率 18%\n2. ...",
"finance_veto": true,
"user_role": "general_manager",
"user_name": "Harry"
}

提問關於 Nike(毛利 65%,正常路徑):
curl -X POST http://localhost:8000/api/v1/agent/debate \
-H "Content-Type: application/json" \
-d '{"message": "Nike Q3 訂單量能否如期交貨?", "user_id": "sales_alice"}'
此時 finance_veto: false,GM 走正常路徑,不會看到否決提示。

Mode A vs Mode B 實際差異對比
同一個問題:「我們是否應該增加 Puma 的訂單量?」
Mode A 回應(Pydantic AI 單一 Agent):
Puma 目前有 2 張訂單,完成率 100%,但 🚨 毛利率低於 15% 紅線(12%)。 建議在議價提升毛利率前暫緩擴單。
Mode B 回應(LangGraph 三角辯論):
業務觀點:Puma 是穩定客戶,建議爭取擴單…
生管觀點:VN-02 接近滿載,短期擴單有風險…
財務觀點:毛利 12% 低於紅線,財務否決…
總經理裁決:條件接受。要求兩週內完成議價,目標毛利 18%,否則拒絕。
Mode B 的輸出更長、更慢(~4x LLM 呼叫),但:
- 每個部門的立場清晰可見
- 否決是程式邏輯驅動,不是 LLM 猜測
- 最終決策有完整推理鏈
小結
| 今天完成 | 技術點 |
|---|---|
| LangGraph StateGraph 設計 | TypedDict state,節點回傳 partial dict |
| 三個 Pydantic AI Agent | 各自獨立 system_prompt,呼叫不同工具 |
| 條件路由 | add_conditional_edges + 路由函數 |
| 財務否決機制 | 程式碼計算 + 旗標傳遞,不依賴 LLM 判斷 |
| FastAPI 整合 | await debate_graph.ainvoke(state) |
Day 18–25 進度表
| Day | 主題 | 狀態 |
|---|---|---|
| 18 | Oracle → PostgreSQL ETL + 匿名化 | ✅ |
| 19 | FastAPI REST API 橋接層 | ✅ |
| 20 | 系統規格書(雙模式 + 五層架構) | ✅ |
| 21 | Tool Layer(@tool + docstring 即 prompt) | ✅ |
| 22 | User Context Layer(角色注入) | ✅ |
| 23 | 前端 next-shadcn-dashboard-starter | ✅ |
| 24 | Mode A 實作:Pydantic AI 單 Agent | ✅ |
| 25 | Mode B 實作:LangGraph 三角辯論 | ✅ |
| 26 | Streaming(FastAPI SSE → Vercel AI SDK) | 🔜 |
| 27 | Dashboard 視覺化(Shadcn Charts) | 🔜 |
| 28 | HITL + 完整整合測試 | 🔜 |
| 29 | Docker Compose 一鍵啟動 | 🔜 |
| 30 | 最終總結 | 🔜 |
下一篇,我們要解決前後端資料串流的問題:FastAPI 的 StreamingResponse 如何對接 Next.js 的 Vercel AI SDK useChat,讓 AI 的文字一個字一個字出現在前端——而不是等 4 次 LLM 呼叫全部完成才顯示。