【實戰】Day 25:Mode B:用 LangGraph 打造三角辯論 Agent

【實戰】Day 25:Mode B:用 LangGraph 打造三角辯論 Agent

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


💡 數據為虛擬數據,並為了方便演示,毛利率改由品牌決定,而非庫存。

Day 24,我們建好了 Mode A:一個 Pydantic AI Agent,拿到問題就直接給答案。

今天,我們要讓三個 Agent 先吵一架——業務、生管、財務各說各話——然後再由總經理做最後裁決。這就是 Mode B:LangGraph 三角辯論


專案目錄(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_FACTORUPDATED
│   ├── 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)
決策者單一 Agent3 專家 + 總經理仲裁
觀點統一視角三種角色對立
條件路由不支援財務否決 → 升級路徑
透明度黑盒輸出每個觀點都可見
速度快(1 次 LLM 呼叫)慢(4 次 LLM 呼叫)
適用場景日常查詢、快速決策大單審批、有爭議的決策

何時用 Mode B?

  • 訂單金額超過門檻
  • 跨部門利益有衝突(業務要衝業績、生管要控產能、財務要守紅線)
  • 需要留下決策紀錄(三方意見可存稿)

為什麼選 LangGraph 而不是 CrewAI?

Day 5 介紹過 CrewAI,它的 Hierarchical 模式也有「主管分配任務」的概念。但對於有明確條件邏輯的流程,LangGraph 更合適:

CrewAI Hierarchical:
  Manager  (黑盒分配)WorkersManager 彙整
  條件分支?放在 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,但狀態不同
)

架構圖

系統全貌:前端到總裁決

Loading Diagram...

LangGraph 條件路由

Loading Diagram...

兩條路最終都進入同一個 gm 節點,但節點內部會讀取 finance_veto 旗標,調整 GM 的提示語。


請求時序圖

架構圖說明「有哪些參與者」,時序圖說明「一次辯論請求如何走過這些節點」。以下以 Puma 財務否決場景為例:

Loading Diagram...
步驟發生什麼事對應技術
① 前後端橋樑前端發送問題,跨越 Node.js / Python 環境邊界FastAPI CORS + fetch
② 三角辯論LangGraph 依序呼叫三個專家節點,各自將分析寫入 DebateStateStateGraph.ainvoke + 節點 partial dict
③ 財務審查財務節點同步計算毛利率(程式碼),設定 finance_veto 旗標後條件路由BRAND_COST_FACTOR + add_conditional_edges
④ 總裁決 + 回傳總經理讀取否決旗標調整提示語,裁決結果序列化為 JSON 回傳前端gm_nodeDebateResponse

實作:成本模型(觸發財務否決的關鍵)

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 呼叫),但:

  1. 每個部門的立場清晰可見
  2. 否決是程式邏輯驅動,不是 LLM 猜測
  3. 最終決策有完整推理鏈

小結

今天完成技術點
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主題狀態
18Oracle → PostgreSQL ETL + 匿名化
19FastAPI REST API 橋接層
20系統規格書(雙模式 + 五層架構)
21Tool Layer(@tool + docstring 即 prompt)
22User Context Layer(角色注入)
23前端 next-shadcn-dashboard-starter
24Mode A 實作:Pydantic AI 單 Agent
25Mode B 實作:LangGraph 三角辯論
26Streaming(FastAPI SSE → Vercel AI SDK)🔜
27Dashboard 視覺化(Shadcn Charts)🔜
28HITL + 完整整合測試🔜
29Docker Compose 一鍵啟動🔜
30最終總結🔜

下一篇,我們要解決前後端資料串流的問題:FastAPI 的 StreamingResponse 如何對接 Next.js 的 Vercel AI SDK useChat,讓 AI 的文字一個字一個字出現在前端——而不是等 4 次 LLM 呼叫全部完成才顯示。