数据截至 (上游 commit 2301e2dc16a8)
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:691、Trace:754、Span:813、ProjectSession:731、SpanAnnotation: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 还原出来的那个 |
events | span 事件列表(如 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:1046)用 Python 的 ast 模块把表达式解析成语法树,校验只含允许的结构,再翻译成 SQLAlchemy 表达式,最后才 eval 那棵"已消毒"的树——而且 eval 的命名空间被锁死。
看 __post_init__(filter.py:1086-1129)的流水线:
# 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:3164,函数 docstring 自陈"primarily the structural validation, e.g. function calls are not allowed")。它 ast.walk 遍历整棵树,只对一小撮被显式允许的构造 continue:布尔/比较运算(BoolOp/Compare)、not、metadata/attributes 上的合法下标取值、以及对 eval 标注的引用(filter.py:3187-3267);任何其它节点——函数调用、import、对危险对象的属性访问——都落不到任何 continue 分支,会被抛 SyntaxError。这是入口处的第一道结构闸。
注意一个易混点: 模块里另有一个白名单元组
_VALID_PROJECTION_NODE_TYPES(filter.py:1369-1377,含Expression / Attribute / Subscript / Name / Constant / List / Tuple / Load)。它不是 SpanFilter 的闸,而是投影/查询 DSL 的节点校验器_validate_projection_expression(filter.py:1381)在用——那条路只允许name、output.value、attributes['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:2189-2195)。
闸二:锁死的执行环境。 即便要 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.py、average_experiment_run_latency.py、span_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.py | Span、Trace、Project、ProjectSession、SpanAnnotation |
| hybrid 列 | src/phoenix/db/models.py | Span.latency_ms、Span.input_value |
| 过滤 DSL | src/phoenix/trace/dsl/filter.py | SpanFilter、_validate_expression、_FilterTranslator、visit_generic、visit_Name |
| 投影/查询 DSL | src/phoenix/trace/dsl/query.py / filter.py | SpanQuery、_validate_projection_expression、_VALID_PROJECTION_NODE_TYPES |
| dataloaders | src/phoenix/server/api/dataloaders/ | annotation_summaries、span_descendants 等 |
| GraphQL 装配 | src/phoenix/server/api/app.py / app.py | create_graphql_router |
| 缓存失效 | src/phoenix/server/dml_event_handler.py | _SpanDmlEventHandler._clear |