跳到主要内容

Phoenix 数据模型与读路径

本章讲什么: span 落盘后长什么样(表与关系),以及读侧两个最值得学的机制——安全的过滤 DSL(让用户写过滤表达式但不执行任意代码)和 dataloaders(GraphQL 批量查询防 N+1)。

3.1 数据模型:一棵树加两条旁支

核心实体是一棵层级树,加上会话和标注两条旁支。先看关系图(谁含谁、谁指向谁):

Project (一个被监控的应用 / 命名空间)
│ 1

▼ N
Trace (一次端到端请求;有 start/end_time)
│ 1 │
│ └──(可选)指向 1 个 ──► ProjectSession (一次多轮会话)
▼ N
Span (请求里的一步:LLM / 检索 / 工具 / agent…)

├─ parent_id ──► 另一个 Span (自引用,构成调用树)

└─ 1:N ──► SpanAnnotation (对这步的打分/标签:人或 eval 产生)

其它标注:TraceAnnotation、DocumentAnnotation、ProjectSessionAnnotation

模型定义都在 src/phoenix/db/models.py:Project:691Trace:754Span:813ProjectSession:731SpanAnnotation:1137

Span 表是核心。 看它的列(models.py:813-837)就懂了 Phoenix 关心什么:

含义
span_id / parent_id自身 id、父 span(构成调用树)
span_kind这步是什么:LLM / RETRIEVER / TOOL / AGENT / CHAIN…
start_time / end_time用来算延迟(end_time 不建索引,start_time 建)
attributes整坨嵌套 JSON——ch.01 里 unflatten 还原出来的那个
eventsspan 事件列表(如 exception)
cumulative_*ch.01 算的子树累计:错误数、prompt/completion token
llm_token_count_prompt/completion本 span 自身的 token 数

巧妙之处——hybrid_property 让 Python 和 SQL 共用一个定义。 latency_ms 既能在 Python 对象上算(end - start),又能作为 SQL 表达式参与查询/排序:

# src/phoenix/db/models.py:839-846
@hybrid_property
def latency_ms(self) -> float: # Python 侧:对象属性
return round((self.end_time - self.start_time).total_seconds() * 1000, 1)

@latency_ms.inplace.expression
@classmethod
def _latency_ms_expression(cls) -> ColumnElement[float]: # SQL 侧:转成 SQL 函数
return LatencyMs(cls.start_time, cls.end_time)

同理 input_value(models.py:848-855)从 JSON attributes 里取值——既能在内存取,也能下推成 SQL JSON 取值。这避免了"Python 一套、SQL 一套"的重复逻辑。

3.2 读侧机制一:span filter DSL —— 让用户写表达式但别 eval 任意代码

要解决的小问题: UI 上用户想按条件筛 span,比如:

span_kind == 'LLM' and llm.token_count.prompt > 100

你得把这段字符串变成 SQL 的 WHERE。最偷懒的做法是 Python eval() ——但那等于把任意代码执行权交给输入,灾难性的安全洞。

思路: Phoenix 的 SpanFilter(src/phoenix/trace/dsl/filter.py:147)用 Python 的 ast 模块把表达式解析成语法树,校验只含允许的结构,再翻译成 SQLAlchemy 表达式,最后才 eval 那棵"已消毒"的树——而且 eval 的命名空间被锁死。

__post_init__(filter.py:158-180)的流水线:

# src/phoenix/trace/dsl/filter.py:161-173 (节选)
root = ast.parse(source, mode="eval") # 1. 解析成 AST
_validate_expression(root, valid_eval_names=...) # 2. 校验:只允许白名单结构
source, aliased_annotation_relations = _apply_eval_aliasing(source)
root = ast.parse(source, mode="eval")
translated = _FilterTranslator(...).visit(root) # 3. 翻译成 SQLAlchemy 表达式 AST
ast.fix_missing_locations(translated)
compiled = compile(translated, filename="", mode="eval") # 4. 编译

两道安全闸:

闸一:结构校验器 _validate_expression 流水线第 2 步调的就是它(filter.py:623,函数 docstring 自陈"primarily the structural validation, e.g. function calls are not allowed")。它 ast.walk 遍历整棵树,只对一小撮被显式允许的构造 continue:布尔/比较运算(BoolOp/Compare)、notmetadata/attributes 上的合法下标取值、以及对 eval 标注的引用(filter.py:640-690);任何其它节点——函数调用、import、对危险对象的属性访问——都落不到任何 continue 分支,会被抛 SyntaxError。这是入口处的第一道结构闸。

注意一个易混点: 模块里另有一个白名单元组 _VALID_PROJECTION_NODE_TYPES(filter.py:250-258,含 Expression / Attribute / Subscript / Name / Constant / List / Tuple / Load)。它不是 SpanFilter 的闸,而是投影/查询 DSL 的节点校验器 _validate_projection_expression(filter.py:262)在用——那条路只允许 nameoutput.valueattributes['key'] 这类纯路径取值。两者别混。

第二道把守在翻译阶段:翻译器 _FilterTranslator 走访已校验过的树,对任何它没有专门 visit_* 方法的节点一律走 visit_generic 抛错,这是兜底的拒绝路径:

# src/phoenix/trace/dsl/filter.py:487-488
def visit_generic(self, node: ast.AST) -> typing.Any:
raise SyntaxError(f"invalid expression: {ast.unparse(node)}")

翻译器把裸名字(如 span_kind)重写成对 ORM 列的引用(visit_Name,filter.py:501-506),把 a.b.c 路径重写成对 JSON attributes 的取值(visit_Attribute,filter.py:493-499)。

闸二:锁死的执行环境。 即便要 eval 编译后的代码,命名空间里没有 __builtins__,只有几个白名单 SQLAlchemy 构造:

# src/phoenix/trace/dsl/filter.py:188-202 (__call__ 里)
eval(
self.compiled,
{
"__builtins__": {}, # 清空内建——没有 open/eval/import
**_NAMES, # 允许的列名映射
"not_": sqlalchemy.not_, "and_": sqlalchemy.and_, "or_": sqlalchemy.or_,
"cast": sqlalchemy.cast, "Float": sqlalchemy.Float, "String": sqlalchemy.String,
"TextContains": models.TextContains,
},
)

结果是一个 SQLAlchemy 的 ColumnElement,直接拼进 select.where(...)用户能写过滤逻辑,但永远跑不了任意 Python——这就是"先 AST 消毒,再翻译,再受限 eval"模式的价值。

直觉: 不是"信任输入并执行",而是"把输入当成只能用我给的积木拼的乐高",拼不出来的形状一律拒收。

3.3 读侧机制二:dataloaders —— 干掉 GraphQL 的 N+1

要解决的小问题: GraphQL 查询天然会触发 N+1。比如"查 100 个 span,每个 span 要它的标注汇总"——朴素实现会先查 1 次 span,再为每个 span 查 1 次标注,共 101 次数据库往返。

思路:DataLoader 模式:在一个事件循环 tick 内,把所有"按 id 取 X"的请求攒成一批,用一条 WHERE id IN (...) 查询解决,再把结果分发回各调用点。

Phoenix 为几乎每种聚合都写了一个 loader,见 src/phoenix/server/api/dataloaders/ 目录(annotation_summaries.pyaverage_experiment_run_latency.pyspan_descendants 等数十个)。它们在 GraphQL Context 里被装配进请求(create_graphql_router,app.py:694)。

和写路径的联动: 这些 loader 是带缓存的。ch.01 提到的 SpanInsertEvent 最终会触发 DmlEventHandler清掉相关 project 的 loader 缓存(dml_event_handler.py_SpanDmlEventHandler._clear,:113-126),保证新写入的 span 不会被旧缓存挡住。这就是"写事件 → 失效读缓存"闭环的另一半。

3.4 边界与坑

  • attributes 是大 JSON 列。 灵活(任意嵌套),但对它的深层字段做过滤/排序依赖数据库的 JSON 函数,复杂查询可能不走索引。
  • 过滤 DSL 的能力被刻意限制。 你只能用白名单里的运算和列;这是安全换表达力的主动取舍。valid_eval_names 还会进一步限定能引用哪些 eval/annotation 名(filter.py:149)。
  • dataloader 缓存与一致性。 缓存失效靠 DML 事件;若某条写路径没发对应事件,读侧可能短暂看到陈旧聚合(本章未逐一核对所有事件覆盖)。

3.5 代码地图

主题文件符号
ORM 模型src/phoenix/db/models.pySpanTraceProjectProjectSessionSpanAnnotation
hybrid 列src/phoenix/db/models.pySpan.latency_msSpan.input_value
过滤 DSLsrc/phoenix/trace/dsl/filter.pySpanFilter_validate_expression_FilterTranslatorvisit_genericvisit_Name
投影/查询 DSLsrc/phoenix/trace/dsl/query.py / filter.pySpanQuery_validate_projection_expression_VALID_PROJECTION_NODE_TYPES
dataloaderssrc/phoenix/server/api/dataloaders/annotation_summariesspan_descendants
GraphQL 装配src/phoenix/server/api/app.py / app.pycreate_graphql_router
缓存失效src/phoenix/server/dml_event_handler.py_SpanDmlEventHandler._clear