TL;DR:前三篇 node 都是純算的假資料,這篇把真正的 LLM 接進 node。三個關鍵:結構化輸出(用 Pydantic schema 強制 LLM 吐固定格式 JSON,不要 parse 自由文字)、few-shot(在 prompt 裡塞幾個範例,把輸出品質拉穩)、業務驗證(LLM 吐的東西不能照單全收,過一道規則檢查)。這篇示範怎麼在血脂決策 node 裡把這三件事兜起來,並講一個最容易出事的地方:LLM 的結構對了不代表內容對。系列 EP4。
EP3 教完分流跟重試,但那個 llm_decide 是假的——它沒真的呼叫模型。這篇把它換成真的,並把「怎麼讓 LLM 的輸出可控、可驗」講清楚。
📌 目錄
- 為什麼不能直接 parse LLM 的自由文字
- 結構化輸出:用 schema 綁死格式
- Few-shot:把範例塞進 prompt 拉穩品質
- 把 LLM 包成一個 node
- 業務驗證:結構對 ≠ 內容對
- 我踩過的坑
- 常見問題 FAQ
- 延伸資源
為什麼不能直接 parse LLM 的自由文字
最天真的做法:丟一段 prompt,LLM 回一坨自由文字,你自己用正則 / 字串切割去撈你要的欄位。
這做法在 demo 能跑,上 production 一定爆。因為 LLM 的自由文字格式每次都可能不一樣:
- 這次回「建議:升級用藥」,下次回「我的建議是升級用藥喔!」
- 你要的數字它有時加單位、有時不加
- 它偶爾在前面加一段「好的,讓我來分析…」的廢話
- 換個 model、換個版本,格式整個變
🧠 結構化輸出:用 schema 綁死格式
先用 Pydantic 定義你要的輸出長什麼樣:
from pydantic import BaseModel, Field
from typing import Literal
class LipidDecision(BaseModel):
"""血脂處置建議的結構化輸出"""
action: Literal["維持現狀", "調整生活型態", "升級用藥"] = Field(
description="處置方向,只能是這三個之一"
)
reason: str = Field(description="這個處置的臨床理由,必填,不可空白")
target_ldl: int = Field(description="建議的 LDL 目標值 (mg/dL)")
重點在三個地方:
Literal[...]把 action 鎖死成枚舉——LLM 不能亂創一個第四種處置。Field(description=...)是給 LLM 看的——這些描述會變成 schema 的一部分送給模型,等於是 inline 的指令。- 型別就是契約——
target_ldl: int保證你拿到的是整數,不是"100 mg/dL"字串。
with_structured_output 把 schema 綁到 model 上:
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4o-mini") # 換成你的 provider
structured = model.with_structured_output(LipidDecision)
result = structured.invoke("LDL 160、有家族史的中年男性,給處置建議")
print(result.action) # → "升級用藥"(保證是三個枚舉之一)
print(result.reason) # → 臨床理由字串
print(type(result)) # → <class 'LipidDecision'>,不是 str
with_structured_output(LipidDecision) 之後,invoke 回的直接是 LipidDecision 物件,不是文字。底層它用 model 的 function calling / JSON mode 去保證格式,parse 失敗框架會幫你處理。你再也不用寫正則撈欄位。
📝 Few-shot:把範例塞進 prompt 拉穩品質
結構對了,但內容品質還是飄。同樣的 case,LLM 給的理由有時很到位、有時很空泛。拉穩品質最有效的手段是 few-shot——在 prompt 裡塞幾個「輸入 → 理想輸出」的範例,讓模型照著學:
from langchain_core.prompts import ChatPromptTemplate
SYSTEM = """你是血脂處置決策助手。根據病人指標給出結構化建議。
理由必須引用具體數值,不可空泛。參考以下範例的詳細程度:"""
EXAMPLES = """
範例 1:
輸入:LDL 100、無危險因子、首次檢查
輸出:action=維持現狀, reason=LDL 100 已達一般族群目標 (<130),無危險因子,建議維持並一年後複檢
範例 2:
輸入:LDL 160、糖尿病史、已用 statin
輸出:action=升級用藥, reason=糖尿病屬高風險族群目標 <70,現值 160 嚴重未達標,建議在現有 statin 上加 ezetimibe
"""
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM + EXAMPLES),
("human", "{patient_summary}"),
])
chain = prompt | model.with_structured_output(LipidDecision)
兩個範例就能讓「理由要引用具體數值」這個要求從「模型有時記得」變成「模型穩定做到」。few-shot 的範例數量不是越多越好——通常 2-4 個有代表性的就夠,太多會吃掉 context 又拉高成本。挑邊界 case 當範例(剛好達標 / 嚴重未達標)比挑普通 case 有用。
⚙️ 把 LLM 包成一個 node
現在把上面這條 chain 塞進 LangGraph 的 node。記得 EP2 教的:node 收 state、回傳只含改動欄位的 dict:
def llm_decide(state: AnalyzeState) -> dict:
patient = state["patient"]
summary = f"LDL {patient['ldl']}、危險因子 {patient['risk_factors']}"
decision: LipidDecision = chain.invoke({"patient_summary": summary})
# 把 Pydantic 物件轉成 state 要存的形狀
return {
"decision": decision.action,
"reason": decision.reason,
"target_ldl": decision.target_ldl,
}
這個 node 就能直接接進 EP3 那張帶 conditional edge 的 graph——llm_decide 跑完進 validate,驗過往前、不過退回重試。LLM 在這裡只是 graph 裡的一個 node,跟其他純算的 node 平起平坐。這就是 LangGraph 的價值:把 AI 步驟跟確定性步驟用同一套 state / edge 串在一起。
🎯 業務驗證:結構對 ≠ 內容對
這是這篇最重要的一段,也是最多人栽的地方。
with_structured_output 保證的是結構合法:action 一定是三個枚舉之一、target_ldl 一定是整數、reason 一定有字串。但它完全不保證內容正確:
- action 是合法枚舉,但對這個病人是錯的處置
- reason 有字串,但寫的是「建議升級用藥」配上「維持現狀」的 action(自相矛盾)
- target_ldl 是整數 50,但臨床上不合理
validate node),用你自己寫死的規則去檢查 LLM 的輸出在業務上合不合理:
def validate(state: AnalyzeState) -> dict:
action = state["decision"]
target = state["target_ldl"]
reason = state.get("reason", "")
checks = [
bool(reason.strip()), # 理由不可空白
70 <= target <= 190, # 目標值要在合理範圍
not (action == "維持現狀" and "升級" in reason), # 不可自相矛盾
]
passed = all(checks)
out = {"validation": {"passed": passed}}
if not passed:
out["retry_count"] = 1
return out
這道驗證是你系統的最後一道防線。LLM 再聰明都可能出系統性錯誤,業務規則是確定性的、可審計的、不會幻覺。永遠不要讓 LLM 的輸出未經驗證就流到下游——尤其醫療、金融這種高風險場景。結構化輸出解決「格式可控」,業務驗證解決「內容可信」,兩個都要。
🔥 我踩過的坑
坑一:以為結構化輸出就不用驗了
最常見的誤會。with_structured_output 只管格式,不管對錯。我一開始也以為「都用 Pydantic 綁死了還驗什麼」,結果 LLM 給出結構完美但臨床上矛盾的建議——action 跟 reason 對不上。結構化輸出跟業務驗證是兩件事,缺一不可。
坑二:few-shot 範例挑錯,反而帶歪
few-shot 是雙面刃。如果你的範例本身品質不一致,或都挑簡單 case,模型會學到「原來不用很仔細」。範例要挑你最在意的邊界情況,而且每個範例的輸出都要是你心目中的滿分答案。範例就是你對模型的隱性規格。
坑三:把 schema 描述寫得太簡略
Field(description=...) 不是註解,是會送給 LLM 的指令。寫 Field(description="理由") 跟寫 Field(description="臨床理由,必填,需引用具體 LDL 數值,不可空泛"),模型表現差很多。description 越具體,輸出越穩。把它當成 prompt 的一部分來寫,不要當成程式註解。
❓ 常見問題 FAQ
Q:with_structured_output 每個 model 都支援嗎?
A:主流的(OpenAI、Anthropic、Google 等)都支援,但底層機制不同(function calling vs JSON mode)。LangChain 幫你抽象掉差異,但冷門或本地小模型可能支援度差、parse 失敗率高。換 model 時務必實測結構化輸出的穩定度。
Q:結構化輸出失敗會怎樣?
A:如果 LLM 吐的東西無法 parse 成你的 schema,LangChain 預設會丟錯。你可以在 node 裡 try/except 接住,當成驗證失敗走 EP3 的 retry 流程。不要讓 parse 錯誤直接炸掉整張 graph。
Q:few-shot 跟 fine-tune 差在哪,我該用哪個?
A:few-shot 是在 prompt 裡塞範例,零成本、即改即生效,適合範例少、需求常變的情況——大部分業務場景用 few-shot 就夠。fine-tune 是真的去訓練模型,成本高、週期長,只有在 few-shot 怎麼調都救不動、且量大到划算時才考慮。先用 few-shot,撞牆再說。
Q:業務驗證規則寫在 node 裡,會不會跟 LLM 邏輯混在一起難維護?
A:建議拆開。LLM node 只負責「呼叫模型拿結構化輸出」,validate node 只負責「用規則檢查」。EP3 就是這樣分的——兩個獨立 node、中間用 conditional edge 連。職責分清楚,驗證規則改起來不會動到 LLM 那段。
Q:可以讓 LLM 自己驗證自己的輸出嗎(LLM-as-judge)?
A:可以當補充,不能當唯一防線。LLM 驗 LLM 一樣會幻覺,兩個都錯的機率不是零。高風險場景的最後一道驗證一定要是確定性規則(你寫死的 if-else),LLM-as-judge 適合用在規則難明確表達的軟性品質檢查(語氣、完整度)。
🔗 延伸資源
- LangChain 官方 repo(langchain-ai/langchain) — structured output 與 chat model API
- LangGraph 官方 repo(langchain-ai/langgraph) — 把 LLM node 接進 graph
- LangChain 結構化輸出官方文件 —
with_structured_output完整用法 - 上一篇:EP3 — Conditional Edge + Reducer:分流與重試
- 下一篇:EP5 — 把六個 node 串成完整 pipeline,加上 human-in-the-loop 人工審核關卡