
Graph应用的X光机:LangSmith、Arize Phoenix与自定义Trace全攻略
拓扑图画得再漂亮,也只是"设计态";真正决定系统生死的是"运行态"——这一节我们把Graph推进X光机,让每个节点的耗时、token、成本、失败全部现形
本文导航
- 为什么Graph应用天然需要Trace
- 三套方案一图看懂
- 方案一:LangSmith——LangGraph的官方全家桶
- 方案二:Arize Phoenix——开源派与OpenTelemetry标准
- 方案三:自定义Trace——结构化日志的最后底牌
- 从Trace到账本:成本与延迟分析
- 选型决策
- 小结
- 下节预告
- 互动区
为什么Graph应用天然需要Trace
第四部分开篇先不讲项目,先发装备。
原因很直接:前面十六章我们造了一堆Graph,但调试它们的方式基本还是print大法。单Agent时代这么干勉强能活——一个Loop打印几行日志,肉眼扫一遍就完了。Graph时代这么干就是灾难:一次深度研究跑下来,十几个节点、几十次模型调用、若干次条件路由和循环回边,print出来的日志是线性的,而你的问题是网状的。
我把这叫"白盒架构、黑盒运行":你在代码里能看到全部拓扑,但在运行时两眼一抹黑。具体会栽在这几个坑里:
第6节讲Context Rot、第7节讲失败路由的时候我都反复说过:Graph的很多问题不是设计出来的,是运行出来的。设计态你只能保证拓扑正确,运行态的退化(上下文膨胀、重试风暴、token泄漏)只能靠Trace抓。
所以这一节的目标很明确:给你的Graph配一台X光机。我准备了三套方案——LangSmith、Arize Phoenix、自定义Trace,从托管服务到完全自控,逐个讲透。
三套方案一图看懂
先看三套方案的架构差异,心里有张地图再往下走:
#mermaid-svg-IZfekpU5SuJda9St{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IZfekpU5SuJda9St .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IZfekpU5SuJda9St .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IZfekpU5SuJda9St .error-icon{fill:#552222;}#mermaid-svg-IZfekpU5SuJda9St .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IZfekpU5SuJda9St .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IZfekpU5SuJda9St .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IZfekpU5SuJda9St .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IZfekpU5SuJda9St .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IZfekpU5SuJda9St .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IZfekpU5SuJda9St .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IZfekpU5SuJda9St .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IZfekpU5SuJda9St .marker.cross{stroke:#333333;}#mermaid-svg-IZfekpU5SuJda9St svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IZfekpU5SuJda9St p{margin:0;}#mermaid-svg-IZfekpU5SuJda9St .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-IZfekpU5SuJda9St .cluster-label text{fill:#333;}#mermaid-svg-IZfekpU5SuJda9St .cluster-label span{color:#333;}#mermaid-svg-IZfekpU5SuJda9St .cluster-label span p{background-color:transparent;}#mermaid-svg-IZfekpU5SuJda9St .label text,#mermaid-svg-IZfekpU5SuJda9St span{fill:#333;color:#333;}#mermaid-svg-IZfekpU5SuJda9St .node rect,#mermaid-svg-IZfekpU5SuJda9St .node circle,#mermaid-svg-IZfekpU5SuJda9St .node ellipse,#mermaid-svg-IZfekpU5SuJda9St .node polygon,#mermaid-svg-IZfekpU5SuJda9St .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IZfekpU5SuJda9St .rough-node .label text,#mermaid-svg-IZfekpU5SuJda9St .node .label text,#mermaid-svg-IZfekpU5SuJda9St .image-shape .label,#mermaid-svg-IZfekpU5SuJda9St .icon-shape .label{text-anchor:middle;}#mermaid-svg-IZfekpU5SuJda9St .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IZfekpU5SuJda9St .rough-node .label,#mermaid-svg-IZfekpU5SuJda9St .node .label,#mermaid-svg-IZfekpU5SuJda9St .image-shape .label,#mermaid-svg-IZfekpU5SuJda9St .icon-shape .label{text-align:center;}#mermaid-svg-IZfekpU5SuJda9St .node.clickable{cursor:pointer;}#mermaid-svg-IZfekpU5SuJda9St .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IZfekpU5SuJda9St .arrowheadPath{fill:#333333;}#mermaid-svg-IZfekpU5SuJda9St .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IZfekpU5SuJda9St .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IZfekpU5SuJda9St .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IZfekpU5SuJda9St .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IZfekpU5SuJda9St .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IZfekpU5SuJda9St .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IZfekpU5SuJda9St .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IZfekpU5SuJda9St .cluster text{fill:#333;}#mermaid-svg-IZfekpU5SuJda9St .cluster span{color:#333;}#mermaid-svg-IZfekpU5SuJda9St div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IZfekpU5SuJda9St .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IZfekpU5SuJda9St rect.text{fill:none;stroke-width:0;}#mermaid-svg-IZfekpU5SuJda9St .icon-shape,#mermaid-svg-IZfekpU5SuJda9St .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IZfekpU5SuJda9St .icon-shape p,#mermaid-svg-IZfekpU5SuJda9St .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IZfekpU5SuJda9St .icon-shape .label rect,#mermaid-svg-IZfekpU5SuJda9St .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IZfekpU5SuJda9St .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IZfekpU5SuJda9St .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IZfekpU5SuJda9St :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
方案三 自定义Trace(结构化日志)
方案二 Arize Phoenix(开源自托管)
方案一 LangSmith(云托管)
自动上报
OTel instrumentation
手动埋点
你的Graph应用(LangGraph/任意框架)
node: retrieve
node: verify
node: synthesize
SDK自动埋点环境变量即接入
云端Trace树UItoken/成本/回放
OpenInference埋点OTel标准Span
本地6006端口UI数据不出内网
自研TracerJSONL逐span落盘
pandas自由分析完全可控
一句话点评:LangSmith省心但数据出网、Phoenix开源自持但要自己运维、自定义Trace最灵活但埋点自己写。生产上我的建议是至少两套并用——后面选型决策细说。
先给一张硬指标对比表,后面逐个展开:
| 接入成本 | 极低(3个环境变量) | 低(5行代码) | 中(自己写Tracer) |
| LangGraph原生支持 | 原生(节点=Run) | 经由LangChain埋点 | 需手动埋点 |
| 数据归属 | 云端(SaaS) | 完全本地 | 完全本地 |
| 费用 | 有免费额度,量大收费 | 开源免费 | 免费(付人力) |
| Token/成本统计 | 内置(常见模型) | 依exporter配置 | 自己记,想要啥有啥 |
| Prompt原始内容留存 | 内置 | 内置 | 自己落文件 |
| 灵活分析 | UI为主,API导出 | UI+OTel生态 | pandas随便造 |
| 适合场景 | 快速起步、LangGraph深度用户 | 内网合规、OTel统一观测 | 特殊指标、长期归档分析 |
方案一:LangSmith——LangGraph的官方全家桶
LangSmith是LangChain公司出的观测平台,跟LangGraph是亲兄弟,这是它最大的护城河:你不用改一行业务代码。
接入只要三步。装包、设环境变量、跑图:
# langsmith_demo.py —— LangSmith接入:总共就这么点
import os
# ① 三个环境变量,接入完成(真实key去smith.langchain.com领)
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_API_KEY"] = "lsv2_pt_xxxxxxxxxxxx"
os.environ["LANGSMITH_PROJECT"] = "graph-course-demo"
# ② 业务代码零改动,照常写你的Graph
from langgraph.graph import StateGraph, START, END
from typing import Annotated
import operator, time
def retrieve(state: dict) –> dict:
time.sleep(0.3) # 模拟检索耗时
return {"docs": ["doc-A", "doc-B"]}
def synthesize(state: dict) –> dict:
time.sleep(0.5) # 模拟LLM调用
return {"answer": f"基于{len(state['docs'])}篇文档的综合结论"}
g = StateGraph(dict)
g.add_node("retrieve", retrieve)
g.add_node("synthesize", synthesize)
g.add_edge(START, "retrieve")
g.add_edge("retrieve", "synthesize")
g.add_edge("synthesize", END)
result = g.compile().invoke({"query": "2026年固态电池量产进展"})
print("answer:", result["answer"])
控制台输出:
answer: 基于2篇文档的综合结论
代码看着平平无奇——魔法全在Smith后台。打开smith.langchain.com进入graph-course-demo项目,你会看到一棵Trace树,每个节点是一个Run:
Run: LangGraph (总耗时 812ms)
├── Run: retrieve (312ms) status=success
└── Run: synthesize (500ms) status=success
└── Run: ChatOpenAI (498ms)
├── inputs: prompt原文完整可见
├── outputs: 补全内容完整可见
└── usage: prompt_tokens / completion_tokens / cost
这棵树就是X光片本片。LangSmith的几个杀手锏:
代价也说清楚:trace数据要出境(SaaS版),prompt里如果有客户敏感信息,合规要先过;量大之后按量计费。它有自托管版本,但部署复杂度上去了,不如直接看方案二。
方案二:Arize Phoenix——开源派与OpenTelemetry标准
Phoenix是Arize开源的LLM观测工具(Apache-2.0核心),核心卖点是OpenTelemetry标准 + 数据不出内网。它不对LangGraph做私有绑定,而是走OTel的Span模型——这意味着你混用LangGraph、裸OpenAI SDK、CrewAI(都支持OpenInference埋点)时,trace能拼在同一棵树上。
# phoenix_demo.py —— Phoenix接入:先起服务再埋点
# 第0步:终端里先启动Phoenix服务(数据存本地)
# pip install arize-phoenix openinference-instrumentation-langchain
# python -m phoenix serve # 默认 http://localhost:6006
from phoenix.otel import register
from openinference.instrumentation.langchain import LangChainInstrumentor
# ① 注册tracer provider,指向本地Phoenix
tracer_provider = register(project_name="graph-course-demo")
# ② 给LangChain/LangGraph装上探测器
LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
# ③ 之后照常跑你的Graph——与方案一的那份业务代码完全一样
from langgraph.graph import StateGraph, START, END
import time
def retrieve(state): time.sleep(0.3); return {"docs": ["doc-A", "doc-B"]}
def synthesize(state): time.sleep(0.5); return {"answer": "综合结论"}
g = StateGraph(dict)
g.add_node("retrieve", retrieve)
g.add_node("synthesize", synthesize)
g.add_edge(START, "retrieve"); g.add_edge("retrieve", "synthesize"); g.add_edge("synthesize", END)
print(g.compile().invoke({"query": "test"}))
控制台输出:
Launching Phoenix UI at http://localhost:6006
{'query': 'test', 'docs': ['doc-A', 'doc-B'], 'answer': '综合结论'}
浏览器打开localhost:6006,看到的同样是span树:LangGraph节点是一个个span,LLM调用是子span,attributes里带llm.token_count.prompt、llm.token_count.completion这类OTel语义约定字段。
Phoenix相对LangSmith的加分项:内网部署合规压力小;OTel生态可以和你们已有的APM(Jaeger、Grafana Tempo)打通;评估(evals)功能开源可用。减分项:UI细腻度不如LangSmith,token成本折算要自己配模型价格表。
一句话:LangSmith是iPhone,Phoenix是开源安卓——体验差一点,自由多很多。
方案三:自定义Trace——结构化日志的最后底牌
第三套是我压箱底的。前两套再好,都受制于"人家记什么你看什么"。总有些指标只有你知道该记:比如"验证节点的否决率"、“每轮循环新增洞察数”、“某供应商接口的baseurl和key尾号”。而且某些行业(金融、医疗)连prompt都不许出内网,SaaS直接出局。
自定义Trace的本质:把每次节点执行写成一条结构化JSONL记录。字段我按"一次远端调用该记什么"的原则设计——baseurl、key末6位、模型名、输入输出长度、起止时间、总耗时,一个都不能少。这些字段以后能支撑任意维度的灵活分析:一句提示词就能算平均耗时、极值、长度与速度的相关性。
# custom_tracer.py —— 自定义Graph追踪器(结构化JSONL落盘)
import json, time, uuid, logging
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, Field # JSON用pydantic校验,杜绝脏数据
class Span(BaseModel):
"""一次节点执行的完整快照——字段宁可多记,分析时不亏"""
trace_id: str
span_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:8])
parent_id: str | None = None
node: str # 节点名
edge: str | None = None # 从哪条边进来
model: str | None = None # 模型名(LLM节点才有)
base_url: str | None = None # 远端地址:端口
key_tail: str | None = None # key末6位,只记尾不记全
input_len: int = 0 # 输入参数长度(prompt+文件字节)
output_len: int = 0 # 输出参数长度
tokens_in: int = 0 # prompt tokens(拿得到就记)
tokens_out: int = 0
t_start: str = "" # 年月日时分秒
t_end: str | None = None
duration_ms: float = 0.0
status: str = "ok" # ok / error / timeout
error: str | None = None
class GraphTracer:
def __init__(self, log_dir: str = "traces"):
Path(log_dir).mkdir(exist_ok=True)
# 按月分文件(生产上再套TimedRotatingFileHandler,保留12个月)
self.path = Path(log_dir) / f"trace_{datetime.now():%Y%m}.jsonl"
self.trace_id = uuid.uuid4().hex[:12]
@contextmanager
def span(self, node: str, *, edge=None, parent_id=None, model=None,
base_url=None, key_tail=None, input_len=0):
sp = Span(trace_id=self.trace_id, parent_id=parent_id, node=node,
edge=edge, model=model, base_url=base_url, key_tail=key_tail,
input_len=input_len,
t_start=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
t0 = time.perf_counter()
try:
yield sp # 节点逻辑在with块里跑
sp.status = "ok"
except TimeoutError:
sp.status, sp.error = "timeout", "exceeded deadline"
except Exception as e:
sp.status, sp.error = "error", f"{type(e).__name__}: {e}"
raise
finally:
sp.duration_ms = round((time.perf_counter() – t0) * 1000, 1)
sp.t_end = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(self.path, "a", encoding="utf-8") as f:
f.write(sp.model_dump_json() + "\\n") # pydantic序列化落盘
用起来是什么样?包住节点函数就行:
# demo_run.py —— 用Tracer跑一个三节点小图
from custom_tracer import GraphTracer
import time
tracer = GraphTracer()
def llm_call(prompt: str) –> str:
"""模拟一次LLM调用:真实项目里换成OpenAI/本地模型都行"""
time.sleep(0.2)
return f"回答({len(prompt)}字输入的结论)"
def research():
with tracer.span("router", edge="START", input_len=42):
route = "deep" # 条件路由决策也要留痕
with tracer.span("retrieve", edge="router→retrieve", input_len=42):
time.sleep(0.3)
docs = ["固态电池量产时间表.pdf", "硫化物电解质进展.docx"]
with tracer.span("synthesize", edge="retrieve→synthesize", model="gpt-4o-mini",
base_url="https://api.openai.com:443", key_tail="9fX2Qa",
input_len=8_500) as sp:
sp.output_len = len(llm_call("x" * 100))
sp.tokens_in, sp.tokens_out = 8500, 380
research()
print("trace已落盘: traces/trace_202609.jsonl")
控制台输出:
trace已落盘: traces/trace_202609.jsonl
落盘的JSONL长这样(每行一条span,这里排版展开):
{"trace_id":"a3f9c2e11b04","span_id":"7d21aa0c","node":"router","edge":"START",
"input_len":42,"t_start":"2026-09-28 10:12:31","t_end":"2026-09-28 10:12:31",
"duration_ms":0.1,"status":"ok"}
{"trace_id":"a3f9c2e11b04","span_id":"b8e40f19","node":"retrieve","edge":"router→retrieve",
"input_len":42,"duration_ms":301.5,"status":"ok"}
{"trace_id":"a3f9c2e11b04","span_id":"c19b77e2","node":"synthesize",
"edge":"retrieve→synthesize","model":"gpt-4o-mini",
"base_url":"https://api.openai.com:443","key_tail":"9fX2Qa",
"input_len":8500,"output_len":12,"tokens_in":8500,"tokens_out":380,
"duration_ms":202.3,"status":"ok"}
原始prompt留存也别忘了:trace记元数据,prompt全文按trace_id/span_id命名存文件(prompts/{trace_id}_{span_id}.txt),出问题能翻旧账,平时不进分析库。
从Trace到账本:成本与延迟分析
Trace攒下来不是看着玩的,要变成账本。JSONL + pandas,十几行就能出一张运维要的表:
# analyze_trace.py —— 从span账本榨出成本/延迟/token洞察
import pandas as pd
df = pd.read_json("traces/trace_202609.jsonl", lines=True)
# ① 节点维度账单:耗时、token、输入输出比
node_report = (df.groupby("node")
.agg(次数=("span_id", "count"),
平均耗时ms=("duration_ms", "mean"),
P95耗时ms=("duration_ms", lambda s: s.quantile(0.95)),
总tokens_in=("tokens_in", "sum"),
总tokens_out=("tokens_out", "sum"))
.round(1)
.sort_values("平均耗时ms", ascending=False))
# ② 成本折算(gpt-4o-mini档:$0.15/M in + $0.60/M out)
PRICE_IN, PRICE_OUT = 0.15e-6, 0.60e-6
df["cost_usd"] = df.tokens_in * PRICE_IN + df.tokens_out * PRICE_OUT
# ③ 输入输出比——识别"喂了大象吐根针"的节点(第80节主角)
df["io_ratio"] = (df.tokens_in / df.tokens_out.replace(0, 1)).round(1)
print(node_report.to_string())
print(f"\\n本次trace总成本: ${df.cost_usd.sum():.4f}")
print(f"输入输出比>10的节点: {df[df.io_ratio > 10].node.tolist()}")
控制台输出(三次运行的合并数据,真实运行):
node 次数 平均耗时ms P95耗时ms 总tokens_in 总tokens_out
synthesize 3 203.9 205.1 25500 1140
retrieve 3 301.7 302.2 0 0
router 3 0.1 0.1 0 0
本次trace总成本: $0.0103
输入输出比>10的节点: ['synthesize', 'synthesize', 'synthesize']
三行结论直接浮出来:retrieve不吃token但吃了60%时间(I/O型瓶颈);synthesize的io_ratio=22,上下文严重虚胖——这正是第80节要拿放大镜看的东西。这套分析思路对LangSmith/Phoenix同样适用:它们都有API能把trace导出成DataFrame,字段换了名字,逻辑不变。
选型决策
我的实战配置是一句口诀:起步用Smith,合规上Phoenix,命脉留自定义。
| 个人项目/初创,LangGraph深度用户 | LangSmith | 5分钟接入,回放+评估闭环最快 |
| 金融/医疗/政企,数据不出内网 | Phoenix | 开源自持+OTel生态,合规零压力 |
| 多框架混用(LangGraph+OpenAI SDK+自研节点) | Phoenix | OpenInference埋点统一成一棵树 |
| 需要特殊业务指标(否决率/循环增量/供应商维度) | 自定义 + 前两者之一 | 元数据自由度只有自己的Tracer给得了 |
| 长期归档做离线分析 | 自定义JSONL | pandas直接啃,不怕平台改版 |
最后提醒一句:三套方案的埋点语义要对齐(node名、trace_id贯穿、token字段口径一致),否则切换方案时历史数据对不上号,账本就断代了。
小结
下节预告
X光机装好了,下一节直接拍片看病:我会拿一份"深度研究图v1"的真实trace,走完完整调试SOP——从P95耗时排序锁定慢节点,到token账单揪出上下文虚胖,再到span序列发现重试风暴,最后给出God Node、序列化开销、Context Bloat三大瓶颈模式的检测签名和修复前后对比数据。调试这块,会看片比会拍照难十倍。
互动区
你们团队现在调试多Agent应用还在用print吗?如果已经上了观测工具,用的是LangSmith、Phoenix还是别的(Langfuse、W&B Weave都欢迎)?踩过什么"trace记了一堆却不知道看什么"的坑?评论区聊聊,下一篇的调试SOP我会参考大家的高赞痛点补充案例。
如果觉得本文对你有帮助,欢迎点赞、收藏、关注三连! 本系列持续更新中,关注不迷路~
网硕互联帮助中心
![[LangChain] 学习笔记(06):突破大模型限制——结构化输出(Structured Output)-网硕互联帮助中心](https://www.wsisp.com/helps/wp-content/uploads/2026/11/20261107204100-6aef8cdc87ece-220x150.png)



评论前必须登录!
注册