工程實作 24 min read ★ Featured

Arize Phoenix 接 LangGraph CDSS:3 個踩坑與醫師回饋閉環

Arize Phoenix 是開源、單容器自架的 LLM observability 工具(不像 Langfuse 還要外接 ClickHouse + Redis + S3)。我把它接進一個醫療 CDSS 的 LangGraph 8-step agent,目的不是看 latency,是要回答醫師那句「它為什麼開這個藥」。這篇記錄安裝三件套、三個讓我 debug 半天的踩坑(langchain 自動 instrument 散 trace、phoenix-client 預設讀錯 env、自家工具 OpenInference 抓不到),以及真正值錢的那段——把醫師回饋以 annotation 回掛到對應 trace,做成 human-in-the-loop 的評估閉環。附完整 code。

本文目錄 · 10
TL;DR
Arize Phoenix 是一套開源、OpenTelemetry-native 的 LLM observability 工具,自架只要一個 docker container(不像 Langfuse 還要外接 ClickHouse + Redis + S3)。我把它接進一個醫療 CDSS 的 LangGraph 8-step agent,目的不是看 latency,是要回答醫師那句「它為什麼開這個藥」。這篇記錄怎麼接、三個讓我 debug 半天的踩坑,以及真正值錢的那段——把醫師回饋以 annotation 回掛到對應 trace,做成 human-in-the-loop 的評估閉環。

📌 目錄

⚡ 我為什麼接 Phoenix

我在做一個臨床決策支援系統(CDSS)。簡單講就是醫師打開病人頁,按一下,後端一條 LangGraph 8-step pipeline 跑完,吐出一份處方建議:要不要加某個藥、劑量多少、要不要升階到次線療法。

問題是它是個黑箱。決策中間經過 8 個節點、每個節點可能呼叫 LLM、LLM 又可能呼叫好幾個工具(讀臨床 skill、查 few-shot 範例、算公式)。當醫師問我「它為什麼建議這個劑量」,我打開 log 看到的是一堆 httpx POST 200,根本還原不了當下的推理路徑。

我一開始接 observability 工具的時候,腦袋裡想的也只是「看看哪個 step 慢、quota 燒多少」。是後來做到「醫師回饋」那段,才發現真正的價值不在效能監控,而在能不能把一次決策完整攤開來看,還能把醫師的評分黏回那次決策上。這就是我選 Phoenix 的原因。

Arize Phoenix LLM observability 開源專案 GitHub 頁面

⭐ Arize Phoenix 是什麼

Arize Phoenix 是 Arize AI 開源的 AI observability 與 evaluation 平台,截至 2026 年 6 月最新版是 v17.2.0,GitHub 上 10,000+ stars,授權是 Elastic License 2.0。

它的技術底座是兩層:

  • OpenTelemetry(OTel):業界標準的分散式追蹤協定。意思是你的 LLM 應用送出的 trace 走的是通用格式,理論上任何 OTel-compatible 後端都能收。
  • OpenInference:Arize 自己定的一套語意慣例(span kind、屬性命名),補足 OTel 在「AI 應用」這個場景缺的東西——例如怎麼標記一個 span 是 LLMTOOLCHAIN 還是 AGENT
Phoenix 原生支援一票框架的自動 instrument:OpenAI Agents SDK、Claude Agent SDK、LangGraph、LlamaIndex、DSPy、CrewAI、Vercel AI SDK,以及 OpenAI / Anthropic / Google GenAI / Bedrock 等 provider。除了 tracing,它還包了 dataset 管理、evaluation(faithfulness、hallucination、toxicity 等內建 evaluator)、RAG 專屬指標、prompt playground。

對我來說最關鍵的一句話是:它可以用單一 docker container 自架,trace 資料留在本地 volume,不外傳第三方。醫療資料這條紅線下,這幾乎是硬需求。

⚖️ Phoenix vs Langfuse vs LangSmith

接之前我比過三個主流選項。結論先講:要全開源 + 好自架 + OTel 標準 → Phoenix;要框架無關 + 透明計價 → Langfuse;重度綁 LangChain 生態 → LangSmith

維度Arize PhoenixLangfuseLangSmith
開源授權✓ Elastic 2.0✓ 100% 開源✗ 閉源
自架難度✓ 單 container△ 要外接 ClickHouse+Redis+S3✗ 自架限 Enterprise
OTel native✓ OpenInference✓ 走 OTel△ 偏自家 SDK
Prompt Playground / LLM-as-judge✓ 免費△ 部分鎖付費
框架深度LlamaIndex / Agents SDK 佳任意框架但較淺LangChain/LangGraph 最深
eval 嚴謹度✓ ML observability 底子深
計價依 span 數 / 資料量透明 volume-based
我的場景是 LangGraph + 醫療資料 + 要本地自架,所以 Phoenix 的「單容器自架 + eval 嚴謹 + 全開源」這三項直接勝出。Langfuse 自架要拉三個外部服務這點,對我這種「一台 dev 機 docker compose 全包」的單機部署太重。

🔗 怎麼接:安裝三件套

接 Phoenix 到一個既有的 FastAPI + LangGraph 服務,本質上只有三件事:起 Phoenix 容器、裝 instrument 套件、設一個環境變數開關

1. Prereq

依賴最低版本驗證
Docker24+docker --version
Python3.11+python --version
既有 LLM 服務走 OpenAI 相容 API

2. 起 Phoenix 容器

我把 Phoenix 做成一個獨立、可單獨啟停的 compose,跟主服務解耦:

name: clinical-phoenix

services:
phoenix:
image: arizephoenix/phoenix:latest
container_name: clinical-phoenix
ports:
- "6006:6006" # UI + OTLP/HTTP
- "4317:4317" # OTLP/gRPC
environment:
PHOENIX_WORKING_DIR: /mnt/data
volumes:
- phoenix_data:/mnt/data
restart: unless-stopped

volumes:
phoenix_data:

6006 同時是 Web UI 和 OTLP/HTTP 接收埠,4317 是 OTLP/gRPC,trace 落在本地 phoenix_data volume。

3. 一鍵安裝 + 啟用

# 裝 instrument 套件(加進 requirements.txt)
pip install \
  "arize-phoenix-otel>=0.6.0" \
  "openinference-instrumentation-openai>=0.1.0" \
  "arize-phoenix-client>=1.0.0"

# 起 Phoenix UI
docker compose -f deploy/phoenix/docker-compose.phoenix.yml up -d

# 設一個 env 開關(沒設就完全 no-op)
export PHOENIX_COLLECTOR_ENDPOINT="http://host.docker.internal:6006"

4. 驗證

http://localhost:6006 看到 Phoenix UI、左側 project 列表出現你的服務名 → 接上了。打一次請求後,trace 樹會即時出現在 UI。這才算 round-trip 成功,不是看 log 寫了 tracing enabled 就算。

這裡我做了一個刻意的設計:整套追蹤是 opt-in 的PHOENIX_COLLECTOR_ENDPOINT 沒設值,setup_tracing() 直接 return,所有 span 的 context manager 都降級成 no-op,連追蹤套件沒裝都不會讓服務啟動失敗——因為每個 import 都包在 try/except 裡。正式環境不想開追蹤?不設那個 env 就好,零成本、零風險。

def setup_tracing() -> None:
    """未設 PHOENIX_COLLECTOR_ENDPOINT 則 no-op。"""
    endpoint = os.environ.get("PHOENIX_COLLECTOR_ENDPOINT")
    if not endpoint:
        return
    try:
        from phoenix.otel import register
        tracer_provider = register(
            project_name="clinical-ai-service",
            auto_instrument=False,   # 關鍵:不開 langchain 自動 instrument,原因見踩坑段
            batch=True,
        )
        from openinference.instrumentation.openai import OpenAIInstrumentor
        OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
    except Exception as e:
        # 追蹤失敗絕不可拖垮服務
        logger.warning("Phoenix tracing setup failed, continuing: %s", e)

🎨 一次請求的 trace 樹長怎樣

接好以後,一次 /analyze 請求在 Phoenix 上就是一棵 trace,從進來到出處方完整攤開:

/analyze (root span · AGENT)

step1 patient_loader

step2 precompute

step3 skill_router

step4 few_shot_picker

step5 llm_decide · CHAIN

LLM turn 1 · 自動抓

list_skills · TOOL

load_skill · TOOL

query_few_shots · TOOL

LLM turn 2

step6 validate

step8 persist

每一層都看得到輸入輸出:哪個節點吃了什麼 state、step5 的 LLM 第幾輪呼叫了哪個工具、工具回了什麼。醫師問「它為什麼開這個劑量」,我現在可以直接點開那棵 trace,看到 step5 第二輪呼叫了 query_few_shots、抄到了某筆 positive 範例的劑量字串——推理路徑一目了然。

📝 三個踩坑

接的過程不是一路順。這三個都讓我 debug 了一陣子,寫下來給後面接的人省時間。

踩坑一:langchain 自動 instrument 會讓 trace 散成好幾棵

Phoenix 的賣點之一是「auto_instrument 自動抓 langchain」。我一開始當然是直接開,結果 UI 上的 trace 散成一堆孤兒 span——LLM 呼叫、工具呼叫各自成一棵,根本兜不回那次請求的節點底下。

根因:langchain/langgraph 的 instrumentor 用 callback 機制建節點 span,但它不會把節點 span 設成 OTel 的「當前 span」。於是底下的 openai span、工具 span 一查當前 context,找不到父節點,就各自飄成獨立 trace。

解法是關掉 langchain 自動 instrument(auto_instrument=False),改用我自己包的 traced_node() 手動串。關鍵在 start_as_current_span——它會把節點 span 設成當前 span,底下的 LLM 和工具才能正確巢狀:

def traced_node(name: str, fn):
    """把 langgraph 節點包一層 OTel current-span。"""
    tracer = trace.get_tracer("clinical-ai-service.graph")

async def _async_wrapped(state):
with tracer.start_as_current_span(name) as span:
span.set_attribute(_KIND_KEY, _CHAIN)
result = await fn(state)
_set_io(span, state, result) # 記節點進出 state
return result
return _async_wrapped

在 graph 定義處,每個節點包一層就好:

g.add_node("step5_llm_decide", traced_node("step5_llm_decide", llm_decide))

教訓:自動 instrument 能抓到「有沒有呼叫」,但抓不到「階層」。要漂亮的巢狀 trace,節點層得自己用 start_as_current_span 串。

踩坑二:phoenix-client 預設讀的 env 跟 phoenix-otel 不一樣

這個是真的隱藏雷,靜默到不會報錯。

我要把醫師回饋以 annotation 寫回 trace,用的是 phoenix.client.Client。問題是:register()(送 trace 那端)讀的是 PHOENIX_COLLECTOR_ENDPOINT,但 Client()(送 annotation 那端)預設讀的是另一個 env PHOENIX_BASE_URL

我的容器只設了前者。於是 Client() 無參數初始化時,fallback 到預設的 http://localhost:6006——也就是容器自己,不是 Phoenix server。結果 annotation 送出去石沉大海,UI 上完全看不到,也沒有任何錯誤。我盯著「trace 看得到、annotation 看不到」這個現象查了好一陣子。

解法很簡單,但你得先知道有這個坑:顯式把 endpoint 當 base_url 傳進去

@lru_cache(maxsize=1)
def _phoenix_client():
    endpoint = os.environ.get("PHOENIX_COLLECTOR_ENDPOINT")
    if not endpoint:
        return None
    from phoenix.client import Client
    # 必須顯式傳 base_url,否則 Client() 會 fallback 到 localhost:6006(容器自己)
    return Client(base_url=endpoint)

教訓:同一個產品的「送資料 SDK」跟「讀/寫 API client」吃的環境變數可能不一樣。設定靜默送錯位、又不報錯時,先懷疑「它到底連到哪台」。

踩坑三:自家工具不是 langchain tool,OpenInference 抓不到

OpenInference 的自動 instrument 抓得到 openai 的 LLM 呼叫,但我 step5 那 6 個工具(讀 clinical skill、查 few-shot、算公式、RAG 文獻檢索)不是 langchain tool,是我自己分派的函式。自動 instrument 看不到它們,trace 樹上那層就是空的。

解法是在工具分派的地方手動包一層 OpenInference TOOL span,把 args 和結果都記進去:

@contextmanager
def tool_span(name, args):
    tracer = trace.get_tracer("clinical-ai-service.tools")
    with tracer.start_as_current_span(name) as span:
        span.set_attribute(_KIND_KEY, OpenInferenceSpanKindValues.TOOL.value)
        span.set_attribute(SpanAttributes.TOOL_NAME, name)
        span.set_attribute(SpanAttributes.INPUT_VALUE,
                           json.dumps(args or {}, ensure_ascii=False, default=str))
        yield span

教訓:自動 instrument 只覆蓋它認得的框架原語。自己手刻的工具分派、自訂的 agent loop,這些 span 得自己補。Phoenix 給了 OpenInferenceSpanKindValues 這套語意,補的時候照著標 TOOL / CHAIN / AGENT,UI 上才會分類正確。

💰 真正的 ROI:醫師回饋回掛 trace

前面都還只是「把黑箱攤開」。Phoenix 真正讓我覺得值得的,是這段閉環

流程是這樣的:

  • /analyze 回 response 時,順手帶上這次 trace 的 phoenix_span_idphoenix_trace_id

  • 前端(clinical-api)把 span_id 存進資料庫,跟那筆 AI 摘要綁在一起

  • 醫師日後看到這份建議,覺得劑量開太高,在 UI 打 2 星 + 選一個訂正分類 dose_too_high

  • clinical-api 帶著當初存的 span_id 打回 AI 服務的 /feedback/annotate

  • 服務端把這個評分以 HUMAN annotation 掛回那棵 trace
  • def log_doctor_annotation(span_id, *, rating, label, comment, doctor_id):
        client = _phoenix_client()
        if client is None:
            return False
        # score 直接用原始星等 1~5,不正規化成 0~1,臨床端篩低分直接 < 3
        client.spans.add_span_annotation(
            span_id=span_id,
            annotation_name="doctor_feedback",
            annotator_kind="HUMAN",
            label=f"{rating}★ / {label}",     # 例:2★ / dose_too_high
            score=float(rating),
            explanation=comment or None,
            metadata={"doctor_id": doctor_id, "rating": rating},
            sync=True,
        )
        return True

    這段做完,我在 Phoenix UI 上可以直接撈出所有醫師打 ≤2 星的決策,一鍵點開那次完整的推理 trace:它當時讀了哪些 skill、抄了哪筆 few-shot、LLM 怎麼一步步收斂到那個劑量。這就是 LLM 產品該有的 human-in-the-loop evaluation 基建——不是測 latency,是把「真人專家的判斷」黏回「模型的決策過程」上,變成可以回看、可以累積、可以拿去做下一輪改進的資料。

    我在 annotation 上刻意留了兩個設計:score 用原始 1~5 星不正規化(臨床端直覺,篩低分直接 < 3),以及整段是 best-effort 不碰主流程——Phoenix 沒開、送失敗一律回 False,因為醫師回饋本身已經落地在前端資料庫,標註失敗絕不能反向害得回饋送不出去。追蹤永遠是附加品,不能傷害主流程,這條原則貫穿整個 323 行的 tracing 模組。

    值得提醒的一個合規點:我的 traced_node 會把節點的進出 state 序列化進 span,裡面包含病人資料(病摘、檢驗值、ICD10)。如果你的場景也有 PII,務必確認 Phoenix 部署在受控環境、存取有上鎖——這也是我堅持本地自架、trace 不外傳的原因。

    ❓ 常見問題

    Q1:Arize Phoenix 是免費的嗎?
    開源版(self-host)是免費的,Elastic License 2.0,功能包含 tracing、evaluation、prompt playground、LLM-as-judge。Arize 另有商業版 AX(雲端託管、依 span 數計價)。自架版對絕大多數團隊夠用。

    Q2:Phoenix 跟 Langfuse 我該選哪個?
    要框架無關、要透明 volume-based 計價 → Langfuse。要全開源 + 單容器好自架 + eval 嚴謹(尤其受監管 / 高準確度要求的場景)→ Phoenix。我做醫療 CDSS 選 Phoenix,主因是單容器自架 + 資料不外傳。

    Q3:一定要用 LangChain / LangGraph 才能接嗎?
    不用。Phoenix 走 OpenTelemetry + OpenInference,框架無關。我這次甚至刻意關掉 langchain 自動 instrument、改手動串節點(見踩坑一)。只要你的 LLM 呼叫走 OpenAI 相容 API,OpenAIInstrumentor 就抓得到。

    Q4:接了會不會拖慢正式環境 / 增加風險?
    我的做法是 opt-in:沒設 PHOENIX_COLLECTOR_ENDPOINT 就完全 no-op,連追蹤套件沒裝都不會讓服務啟動失敗(每個 import 都包 try/except)。正式環境不想開就不設那個 env,零成本。

    Q5:trace 資料會外傳嗎?
    self-host 版本 trace 全部落在你本地的 docker volume(phoenix_data),不外傳第三方。處理敏感資料時這是關鍵——但要注意 span 裡若記了 PII,得自己管好 Phoenix 的存取權限。

    🔗 延伸資源

    author
    陳彥彤

    AI 工程師 · AI 顧問。Java 後端 8 年、AI 工程師 2 年。AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。

    support

    覺得文章有用可以到 GitHub 給個 star,或是透過信箱聊聊 AI 內訓、AI 導入顧問或前後端 / 雲端培訓。

    related

    相關文章

    [AI 工具] · 12min
    我原本用 Hermes Agent CLI,後來為什麼換成 LangGraph(遷移覆盤)
    系列番外。我這套醫療決策 pipeline 最早不是用 LangGraph,是用 Hermes Agent CLI(NousResearch 2026 年初開源的自主 agent 框架)。Hermes 很強,但強在通用對話助理、會自我學習、接 20+ 平台;而我要的是確定性步驟為主、流程可畫成圖、每步可審計、出錯能精準退件的決策管線。方向不對再強也是錯的工具。這篇覆盤這個選型決策:Hermes 哪裡好、為什麼對我的場景不對、換到 LangGraph 解決了什麼。「用真實專案學 LangGraph」系列 EP6(完結)。
    [AI 教學] · 13min
    LangGraph 完整 Pipeline + Human-in-the-loop:8 步決策管線全貌
    前四篇的零件——state、node、edge、conditional edge、reducer、LLM 結構化輸出——這篇全部組起來,串成一條完整的醫療 AI 決策 pipeline:載入資料 → 補衍生指標 → 載入規則 → 組 few-shot → LLM 出建議 → 業務驗證 → 適配輸出 → 持久化,中間帶分流重試。然後加上 LangGraph 的殺手級功能 human-in-the-loop:用 interrupt() 在高風險決策點暫停整張 graph,等人點頭才繼續,靠 durable state 讓這件事變簡單。「用真實專案學 LangGraph」系列 EP5。
    [AI 教學] · 16min
    LangGraph Conditional Edge + Reducer:讓 graph 會分流、會重試
    線性 graph 不夠用時你需要兩個東西:Conditional Edge(跑完一個 node 看結果決定下一步去哪——過了往前、沒過退回重試、重試太多次直接退件)跟 Reducer(讓某個 state 欄位累加而不是覆蓋,最典型就是 retry 計數器)。這篇用 add_conditional_edges + router 函式 + Annotated[int, operator.add] 把這兩件事講透,並解釋一個關鍵設計決策:MAX_RETRY 該設多少、為什麼很多嚴肅場景答案是 0。「用真實專案學 LangGraph」系列 EP3。