Skip to main content

Command Palette

Search for a command to run...

別只叫 Agent「不要說謊」:為 LangGraph 加上確定性 API 與 LLM-as-Judge 雙重防線

Updated
5 min readView as Markdown

我們的 AIOps agent 有個很具體的問題:它會捏造 trace ID。

當 on-call 工程師問「payment service 有沒有 error trace?」,agent 有時會信心滿滿地回答「是的,見 trace a1b2c3d4...」——但這串 ID 根本不存在 Tempo 裡。工程師點進去,404。壞的不只是使用者體驗,而是這讓整個 RCA 結論失去可信度。

另一個問題是 k8s 操作。我們的 agent 可以提議執行 rollout_undoscale。這些操作有真實影響:把 replica 縮到 0 可以讓服務整個掛掉。我們有 blast-radius gate、circuit breaker,但這些 gate 都是規則型的,覆蓋不到「action 本身語意上合不合理」的問題。

這兩個問題看似南轅北轍,但指向了同一個工程現實:光靠 Prompt 壓不死,我們必須在答案生成之後,做「事後驗證」(Post-Generation Verification)

驗證分兩條路:一種是確定性的事實查核(去資料庫對答案),另一種是語意上的邊界審查。而後者,正是 LLM-as-a-Judge 的核心戰場。


教科書解法:LangChain 的 RubricMiddleware

在講我們的實作前,先看 LangChain 官方近期怎麼抽象這個題目。這套 Middleware 雖然我們最終沒直接用,但它的設計提供了完美的參考指引: LangChain 的 RubricMiddleware 把這個 pattern 包成 middleware,接在 create_deep_agent() 上:

from langchain_deepagents import RubricMiddleware, create_deep_agent

middleware = RubricMiddleware(
    model="openai:gpt-4o-mini",   # grader 用的模型,可以比主 agent 便宜
    tools=[run_test_suite],        # grader 可以呼叫的工具(選用)
    max_iterations=3,              # 最多重跑幾次(上限 20)
    on_evaluation=log_verdict,     # 每次審查後的 callback(選用)
)
agent = create_deep_agent(..., middleware=[middleware])

# rubric 在呼叫時傳入,不用寫死在 agent 裡
result = await agent.ainvoke(
    {"messages": [{"role": "user", "content": "幫我寫一個 find_duplicates 函數"}]},
    rubric="""
    - 函數必須通過所有 unit test
    - 時間複雜度必須是 O(n)
    - 不能用任何第三方套件
    """,
    config={"configurable": {"thread_id": "thread-001"}},
)

rubric 是換行分隔的 checklist。沒有傳 rubric,middleware 就不會啟動。thread_id + checkpointer 讓 rubric 可以跨多次 invocation 持續,agent 可以在中斷後繼續跑。

每次 agent 產出答案,grader 就審一次,回傳五種 verdict 之一:

satisfied              → 全部通過,終止
needs_revision         → 注入 per-criterion feedback,agent 重跑
max_iterations_reached → 達到 max_iterations,強制終止
failed                 → rubric 本身無法評估(寫法有問題)
grader_error           → grader sub-agent 拋了 exception

needs_revision 是最重要的路徑:grader 不只說「不通過」,它會針對每個 criterion 給出具體的 gap(哪裡不對、缺什麼),然後把這份 feedback 注入回 agent 的 message history,讓 agent 下一輪有明確方向修正,而不是盲目重試。

on_evaluation callback 每次審查後都會觸發,拿到的是一個 RubricEvaluation dict:

def log_verdict(evaluation: RubricEvaluation):
    print(f"iteration={evaluation['iteration']}")
    print(f"result={evaluation['result']}")       # verdict 字串
    print(f"explanation={evaluation['explanation']}")
    for c in evaluation['criteria']:
        # 每個 criterion 的個別判斷
        print(f"  [{c['passed']}] {c['criterion']}: {c['gap']}")

stream_events(..., version="v3") 的話,可以從 event stream 裡抓到 rubric_evaluation_startrubric_evaluation_end 兩個自訂 event,適合需要即時顯示審查進度的場景。

grader 帶工具是這個設計最關鍵的地方。文件裡的例子是讓 grader 拿到一個 run_test_suite 工具,實際執行測試套件,而不是靠 LLM 自己猜程式碼對不對:

@tool
def run_test_suite(code: str) -> str:
    """執行測試,回傳 pass/fail 結果"""
    # 實際跑 pytest 或 unittest
    ...

middleware = RubricMiddleware(
    model="openai:gpt-4o-mini",
    tools=[run_test_suite],  # grader 會主動呼叫這個來驗證
)

這把 grader 從「LLM 憑感覺打分」變成「帶工具的 sub-agent 驗證」。差別很大:前者受 LLM 幻覺影響,後者的結果是確定性的。

flowchart TD
    U([使用者提問]) --> A[Agent 執行工具、生成答案]
    A --> G{Grader 審查}
    G -->|呼叫工具驗證| T[測試/API/DB 查詢]
    T --> G
    G -->|satisfied| R([回傳答案])
    G -->|needs_revision\nper-criterion feedback| FB[feedback 注入 history]
    FB --> A
    G -->|max_iterations_reached| R
    G -->|grader_error| R

    style G fill:#f5a623,color:#000
    style FB fill:#d0e8ff,color:#000
    style T fill:#e8f5e9,color:#000

這個設計的吸引力在於把「品質標準」從 prompt 裡抽出來,變成一個獨立可測試的元件。rubric 可以是字串,可以有工具,可以隨場景換,也可以在 runtime 動態決定要審什麼。


我們沒用 Middleware,但用了同樣的思路

我們的 agent 是 LangGraph + Gemini,不是 LangChain deep agent,沒辦法直接插 RubricMiddleware。但核心思路一樣:在 agent 產出之後,用獨立的 LLM 或程式驗證,不通過就打回去重試

我們把這個邏輯包在 rubric.py 裡,實作了兩個 guard。

Guard 1:Trace ID 驗證

思路很直接:用 regex 把答案裡所有 32-hex 的字串找出來,逐一去 Tempo HTTP API 確認存不存在。

_TRACE_ID_RE = re.compile(r"\b([0-9a-f]{32})\b", re.IGNORECASE)

async def _tempo_trace_exists(trace_id: str) -> bool:
    url = f"{settings.tempo_url}/api/traces/{trace_id}"
    async with httpx.AsyncClient(timeout=3.0) as client:
        resp = await client.get(url)
    if resp.status_code == 404:
        return False
    return bool(resp.json().get("batches", []))

async def verify_trace_ids(answer: str) -> tuple[bool, str]:
    ids = {m.group(1).lower() for m in _TRACE_ID_RE.finditer(answer)}
    missing = [tid for tid in ids if not await _tempo_trace_exists(tid)]
    if not missing:
        return True, ""
    return False, (
        f"The trace IDs {missing} you cited do not exist in Tempo. "
        "Call `query_tempo_traces` again to find real traces."
    )

這個 guard 本身不用 LLM,直接查資料庫,3 秒 timeout,網路出錯就 pass-through(不能讓 infra 問題阻擋正常回答)。

「為什麼不直接在 prompt 裡叫 agent 不要捏造?」我們試過了。Prompt 有效果,但失敗率不是零。當工具回傳空結果,agent 有時會「補全」一個看起來合理的 trace ID。這種行為靠 prompt 壓不死,要靠事後驗證。

Guard 2:K8s Write 預飛審查

這個 guard 才真正用到 LLM-as-judge。我們需要判斷「這個 action 在當前 context 下合不合理」,這是語意層面的問題,規則寫不完。

class _K8sRubricVerdict(BaseModel):
    safe_to_proceed: bool
    reason: str

_K8S_RUBRIC_SYSTEM = """你是 Kubernetes 操作的安全審查員。

BLOCK(safe_to_proceed=false)的條件:
- deployment name 有萬用字元或異常通用("all"、"*")
- replicas = 0(服務整個掛掉)
- rollout_undo 但 context 顯示問題不是 bad deploy(例如 DB 過載、infra 故障)
- scale 倍數超過當前的 10 倍

其他情況 ALLOW。blast-radius 和 circuit breaker 已在前面跑過,只擋語意上明顯有問題的操作。"""

async def check_k8s_write(action: str, args: dict, context: str = "") -> tuple[bool, str]:
    try:
        llm = _k8s_rubric_llm()  # structured output → _K8sRubricVerdict
        verdict = await llm.ainvoke([
            SystemMessage(content=_K8S_RUBRIC_SYSTEM),
            HumanMessage(content=f"Action: {action}\nArgs: {args}\nContext: {context}")
        ])
        return verdict.safe_to_proceed, verdict.reason
    except Exception as e:
        return True, f"rubric check skipped ({e})"

Structured output(Pydantic model)確保 grader 一定回傳 bool 而非讓我們自己 parse 文字。例外情況一律放行——grader 出問題不能成為阻擋正當操作的理由。


接線到 LangGraph

兩個 guard 接的位置不一樣,這很重要。

Trace ID guard 在答案生成後接,兩條路都有:

# headless path(alert webhook)—— run_headless() 裡
trace_ok, retry_prompt = await verify_trace_ids(answer)
if not trace_ok:
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": retry_prompt}],
         "tool_calls_used": 0,
         "budget": max(2, settings.webhook_tool_call_budget // 2)},
        config=config,
    )

# chat path —— astream_events 結束後
trace_ok, _ = await verify_trace_ids("".join(answer_parts))
if not trace_ok:
    correction = "\n\n> **[注意]** 部分 trace ID 無法驗證,請重新查詢。"
    answer_parts.append(correction)
    yield {"type": "token", "text": correction}

headless path 給 agent 補一個 retry turn(budget 砍半避免無限追加費用);chat path 則直接在答案末尾附上警告(因為使用者在等,重跑太慢)。

K8s write guardexecution.py 的 gate 序列裡插進 gate 3b:

flowchart LR
    A[ActionRequest approved] --> G1[Gate 1\n前置條件重驗]
    G1 -->|fail| ABORT1([ABORTED])
    G1 -->|pass| G2[Gate 2\nBlast-Radius]
    G2 -->|over policy| ABORT2([ABORTED])
    G2 -->|pass| G3[Gate 3\nCircuit Breaker]
    G3 -->|open| ABORT3([ABORTED])
    G3 -->|pass| G3B[Gate 3b\nRubric LLM]
    G3B -->|blocked| ABORT4([ABORTED])
    G3B -->|pass| G4[Gate 4\n實際執行]
    G4 --> DONE([SUCCEEDED / FAILED])

    style G3B fill:#f5a623,color:#000
    style ABORT4 fill:#ffcccc,color:#000
# execution.py —— gate 3b
rubric_ok, rubric_reason = await check_k8s_write(req.action, req.args, context)
if not rubric_ok and settings.actions_enabled:  # kill switch 關著時不擋
    ar_store_transition(req.request_id, Status.EXECUTING, Status.ABORTED,
                        outcome=f"rubric blocked: {rubric_reason}")
    return {"status": "aborted", "outcome": f"rubric blocked: {rubric_reason}"}

settings.actions_enabled 是全局 kill switch。當它是 False(預設),整個 execute 路徑都不會真正跑,所以 gate 3b 也沒必要阻擋——這讓既有測試(期待走到 refused)不受影響。


測試策略

16 個 unit test,分三層:

底層:HTTP 行為

respx mock Tempo API,測試四種情境:200 有 batches、200 空 batches、404、網路錯誤(均應回傳 True 避免誤擋)。

中層:guard 邏輯

Mock _tempo_trace_exists,不跑真實 HTTP。測試重點:

  • 重複 ID 只查一次(dedup)

  • 混合情境(一真一假)整體判為 fail

  • LLM exception → pass-through

上層:gate 條件

不啟動完整 execution pipeline,直接驗布林邏輯:

assert not (not rubric_ok and ex.settings.actions_enabled)
# rubric 說 False,actions_enabled=False → gate 不觸發

這讓測試不依賴 k8s cluster 或真實 LLM 呼叫,跑起來快。


Tradeoff 與我們沒做的事

Token 成本

每次答案生成後多一次 LLM 呼叫(k8s guard),或一次 HTTP 查詢(trace guard)。我們的 headless path 本來就有 confidence loop(低信心時重跑),rubric retry 是在這之外再疊一層。最壞情況:一次 alert 觸發 → 3 個 confidence loop × 1 rubric retry = 6 次 agent run。

這是可接受的,因為 k8s 操作本來就該謹慎,而 trace ID 查詢只是 HTTP GET,成本可忽略。

Chat path 的限制

Chat path 的 trace guard 只警告,不重跑。因為使用者在即時等待,重跑可能需要 30-60 秒,體驗比直接顯示警告更差。這個取捨不是技術問題,是 UX 決策。

Grader 本身的幻覺

K8s write guard 用 LLM 來 judge,但 LLM 本身也可能判錯。我們的設計是「寧可漏判,不要誤擋」:exception 一律放行,block 條件設得相對明確(不模糊)。真正的安全線是 blast-radius policy 和 circuit breaker(純規則型),rubric 是額外的語意層,不是唯一防線。

RubricMiddleware vs 自己接

如果你用的是 LangChain deep agent,RubricMiddleware 封裝得很完整,不用自己處理 retry loop 和 verdict 狀態。我們選擇自己接是因為架構是 LangGraph,而且我們需要對不同路徑(headless vs chat)有不同行為。Middleware 的抽象在我們的場景下反而是限制。


結語:從 Prompt Engineering 走向 System Engineering

實作完這兩層 Guard 後最大的體會是:構建可靠的 AI Agent,重心早已不在於「寫出一個讓它絕不犯錯的完美 Prompt」,而是承認它一定會犯錯,並圍繞著它建立一套低成本的糾錯與制衡系統。

確定性的事實交給 API,模糊的語意交給另一個 LLM,剩下的,交給架構設計。這大概就是 Agent 進入 Production 前,工程師最該做的事。

程式碼在 rubric.py

More from this blog

Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

稟告主公:此乃司馬懿進呈之兵書,詳解如何以 OpenTelemetry 陣法,令臥龍神算之一舉一動盡在掌握,知糧草消耗、察兵器效能、辨戰報異常,使主公運籌帷幄於大帳之中。 為何需要斥候情報? 司馬懿稟告主公: 臥龍神算(Claude Code)乃當世利器,然若無斥候回報,主公便如蒙眼行軍——兵器耗損幾何、糧草消費幾許、哪路斥候出了差錯,一概不知。臣以為,此乃兵家大忌。 無情報之弊,有四: 軍

Feb 19, 202610 min read237
Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

工程師的 Claude Code 實戰指南:從零開始到高效開發

工程師的 Claude Code 實戰指南:從零開始到高效開發 本文整合 Anthropic 官方 Best Practices 與社群實戰 Tips,帶你由淺入深掌握 Claude Code。 什麼是 Claude Code?為什麼值得學? 如果你還在用「複製程式碼貼到 ChatGPT,再複製答案貼回去」的工作流程,Claude Code 會讓你大開眼界。 Claude Code 是 Anthropic 推出的命令列工具,它直接活在你的 terminal 裡,能夠讀懂你的整個 codeb...

Feb 18, 20265 min read105
工程師的 Claude Code 實戰指南:從零開始到高效開發
M

MicroFIRE

73 posts