Phoenix Analytics SQL:面向 Agent 的只读 SQL 分析接口设计与实现
2026/9/24 23:41:26 网站建设 项目流程
  • 可观测性
  • AI 评测
  • LLMOps
  • AI 应用
  • 人工智能

【免费下载链接】phoenix

AI Observability & Evaluation

项目地址:https://gitcode.com/gh_mirrors/phoenix13/phoenix
点击查看免费下载

导读

Phoenix 通过 GraphQL 与 REST API 对外暴露数据,但这些 API 只能回答固定形态的问题。当 Agent 提出"本周哪个模型 p95 延迟最差"、"哪些提示词触发了最多重试"这类分析性问题时,固定的 API 无从回答。本指南深入解析 Phoenix 仓库中 Analytics SQL 这一 MCP 分析面(design 文档见 internal_docs/specs/mcp-analytics-sql.md,实现位于 src/phoenix/server/mcp/sql/),说明它如何在不让 Agent 越权读取表、不让语句永不终止、不让返回数据撑爆上下文窗口的前提下,允许 Agent 用 SQL 表达任意分析问题并直接获得答案。读完本文,你将掌握:该面从解析、准入、重写到执行背书的完整六阶段管线;能力边界(行数、字节、截止时间、并发)的设计意图;两个 MCP 工具describeSqlSchemaexecuteSql的用法与返回结构;以及它如何在 SQLite 与 PostgreSQL 两个后端之间裁决"分歧是否算缺陷"。


一、问题:固定 API 之外的即席分析需求

Phoenix 以遥测(telemetry)、数据集(datasets)与实验(experiments)为核心数据。既有 API 均回答固定的问题集。一个 Agent 若被问到某个没有人预先设想过的分析性问题——例如"这个项目本周哪个模型 p95 延迟最差"、"哪些提示词产生的重试最多"——它没有现成的查询入口。它只能自己翻页拉取 spans 并自行聚合,这需要大量往返与上下文开销;或者干脆放弃。

数据库本身可以直接回答这些问题。所缺的只是让 Agent 安全地发问的途径:既不能让它读到不该读的表,也不能让它运行永不终止的语句,更不能让它返回超出上下文窗口容量的数据。

二、目标与非目标

目标

  • 让 Agent 以 SQL 表达分析性问题并直接获得答案;
  • 对任何单条语句的读取量、成本与返回量设界;
  • 把 schema 描述得足够好,使模型第一次就能写出正确的 SQL;
  • 让 schema 描述足够廉价,schema 发现不会主导调用方的 token 预算。

非目标(spec 原文):

  • 不是机密性边界(见威胁模型章节);
  • 不支持写操作、DDL 或事务控制;
  • 不支持跨数据库或联邦查询;
  • 不做引擎之外的查询优化;
  • 不缓存结果;
  • 不提供存储或命名查询。

三、正确性判据:谁编写了行为

该面上几乎所有正确性争议都以同一种形式出现:调用方得到一个意外结果,或两个后端对同一语句给出不同答案,需要判定这是否为缺陷。一个提问即可定论:产生该结果的行为是谁编写的。该面自己编写的行为归该面负责修复;引擎编写的行为归引擎负责,调用方有权依赖其文档。

该面编写的行为引擎编写的行为
语句的含义我们改变了含义,就由我们修复引擎不同,差异就透传
可用的能力概念是我们的,两个后端必须一致能力不同,允许能跑的一侧,在另一侧给出可执行的拼写并拒绝

具体化为四条规则:

  1. 答案必须回答被提出的问题。语句可以被自由重写——展开星号、替换派生列、注入 limit——但绝不能使结果偏离调用方 SQL 的本意。案例:SQLGlot 把调用方的json_extract渲染成->(返回 JSON 文本而非值),导致MAX按字典序比较。这是该面管线改变了含义,由该面修复。
  2. 该面发明的概念只能有一个含义。latency_msgraphql_node_id在别处不存在,没有任何规范定义它们,包外也无法校验。案例:latency_ms在两个后端计算出不同数值,这是该面的责任——两个实现必须彼此一致。
  3. 引擎行为透传。案例:->>在 SQLite 返回类型化值、在 PostgreSQL 返回文本,因此对数值 JSON 路径求max,一端得到130000,另一端得到'9'。两个引擎都按规范行事,该面既不做调和也不拒绝;但结果信封会在未加 CAST 的排序敏感提取处给出警告(见 rewrite.py 中_note_uncast_json_ordering,其按方言区分的_JSON_TEXT_ORDERING_NOTES注释给出了两端的具体示例)。
  4. 能力可以不同,拒绝必须可执行。案例:percentile_cont(...) WITHIN GROUP在 SQLite 没有对应语法。这是引擎的能力缺口,因此该构造在 PostgreSQL 上被准入、在 SQLite 上被拒绝,且拒绝消息点名给出percentile(x, p)作为替代拼写。

四、威胁模型与能力边界

这是塑造其余设计的最关键决定,且最初在代码中被表述错误。该面对任何能触达 MCP 挂载点的调用方开放,可读取遥测、数据集与实验。Phoenix 本已允许任何已认证用户通过 GraphQL 读取所有这些:仅四个查询携带IsAdmin——usersuser_api_keysoauth2_grantssystem_api_keys——而这些表均不在本面白名单内;projects及其可达的所有表则根本不带权限类。

因此,此前的 ADMIN/SYSTEM 检查比旁边的 API 更严格:在 SQL 中拒绝的,同一调用方却可在 GraphQL 中取到。该检查已被移除(源码见 tools.py 中register_analytics_sql_tools的文档字符串)。

约束该面的是能力(capability),而非身份(identity)

约束
语句形态单条只读语句:SELECTUNIONINTERSECTEXCEPT
行数默认 500,最大 5000
字节每行 256 KiB,每响应 4 MiB
截止时间30 秒(PostgreSQL 用statement_timeout,SQLite 用 progress handler)
并发PostgreSQL 4 并发、SQLite 1 并发;队列深度 8

以上常量在 execute.py 中有精确对应:DEFAULT_ROW_LIMIT = 500MAX_ROW_LIMIT = 5_000BYTE_LIMIT = 262_144MAX_RESPONSE_BYTES = 4 * 1024 * 1024MAX_SQL_BYTES = 2 * 1024(单次调用 SQL 文本上限 2 KiB,超长在未执行前即被拒绝)、PG_STATEMENT_TIMEOUT_MS = 30_000SQLITE_TIMEOUT_SECONDS = 30,并发表为SQL_CONCURRENCY = {"postgresql": 4, "sqlite": 1},队列深度由StatementAdmissionController._queue_size = 8承载。

该设计要防御的对手是被误导或被劫持的模型,而非试图获取本无法获取之数据的恶意用户。上述每项控制都在限制爆炸半径;没有一项在限制调用方通过其他途径同样能拿到的信息。两个随之而来的推论都是有意为之:

  • 表就是数据边界。白名单表内的每个物理列都可查询;被排除在面外的数据,通过不将其表列入白名单而排除。
  • 争用是残余风险。SQLite 执行宽度为 1,一条慢查询会将其余所有分析请求串行化直至其截止时间。许可证(permit)在解析之前获取,因此准入与重写也要排队等待。行数限制、字节上限与截止时间约束的是单条查询,不约束争用。

设计中未处理的一个独立问题:spans.attributesspans.events包含被追踪应用写入的文本(通常即该应用的终端用户写入的文本),结果被返回给同一个 MCP 服务器上持有破坏性工具的模型,而该面没有把返回的行标记为不可信内容。这是已记录在开放问题中的缺口。

五、架构:六阶段管线

一条语句依次经过六个阶段:一个解析、两个策略闸门、一个变换、一个文本生成,最后一个交给引擎自带的背封(backstop)。

caller SQL │ ├─ 1. parse SQLGlot,单条语句;SELECT 或集合操作 ├─ 2. admission 对解析树做白名单校验 ├─ 3. rewrite 星号展开、派生列、时间戳算术与字面量、 │ JSON 规范化、schema 限定、limit 注入 ├─ 4. post-rewrite 关系与 schema 限定复检 ├─ 5. render 把树重新生成为 SQL 文本 │ └─ 6. execution SQLite:authorizer 回调 PostgreSQL:EXPLAIN 计划闸门

调用方的文本只在阶段 1 被读取一次,此后永不再读。之后的每个阶段都工作在树上。数据库实际运行的语句在阶段 5 由该树生成,意味着数据库永远不会看到调用方输入的原样文本。阶段 5 是无条件的:不存在任何路径让未修改的语句以文本形式透传,因为 limit 注入与 schema 限定作用于每条语句。

一条语句的端到端旅程

这是真实 trace 而非示意图。调用方提交:

SELECT latency_ms FROM spans WHERE name = 'chat'

latency_ms是虚拟列,被替换为按方言的表达式;整个替换以括号包裹,因此绑定位置与普通列完全一致:

SELECT (EXTRACT(EPOCH FROM (end_time - start_time)) * 1000) AS latency_ms FROM spans WHERE name = 'chat'

随后 schema 限定把spans解析到连接对应的 schema,limit 注入追加row_limit + 1使截断可被检测而非被假设。以下是 PostgreSQL 实际收到的字符串:

SELECT (EXTRACT(EPOCH FROM (end_time - start_time)) * 1000) AS latency_ms FROM public.spans WHERE name = 'chat' LIMIT 501

这条语句在五个阶段中几乎无事可做:没有*可展开;没有 node id;没有时间戳字面量比较;没有时间戳相减;JSON 规范化仅限 SQLite。信封(envelope)如实报告实际触发的三个重写。最终执行的字符串与调用方提交的不同——它从树打印而来,而非从调用方文本编辑而来。该设计依赖的每条性质——limit 存在、只出现白名单关系——都是树的性质,且仅因为执行语句由该树打印而对执行语句成立。

六、阶段 2:准入(Admission)

准入作用于解析树而非文本。四个维度是白名单——节点类、按方言的函数类与函数名、关系、CAST 目标类型。基表列是第五个正向目录:对给定方言,每个引用必须命名打包 DDL 资产中的物理列或适用的虚拟覆盖层。未知名称在未执行前即被拒绝,并就近给出物理与虚拟名称作为更正建议(parse.py 中用difflib.get_close_matches实现)。

查询局部关系则不同。CTE、子查询、输出别名或表值函数自行定义列,这些名称无法对照基表资产校验。既包含此类源、又存在未限定引用的作用域中,若该源可能投影出该名称则允许;限定基表引用仍然失败关闭(fail closed)。

标识符匹配遵循目标引擎:SQLite 标识符大小写不敏感;PostgreSQL 将未加引号的标识符折叠为小写并保留带引号的拼写,因此 DDL 加载器保留了哪些物理列曾加引号。NATURAL JOIN被拒绝,因为其隐式键会随物理 schema 演进而变化;整行与复合字段引用也被拒绝,以让策略继续在显式列上推理。

七、阶段 3:重写(Rewrite)——九个按序执行的 pass

九个 pass 的顺序是承重的,而非偶然(rewrite.py 的rewrite()入口依次调度它们):

  1. 星号展开——*变成有序的物理 DDL 列,后接适用的虚拟覆盖层。放在第一位是因为它产出latency_msgraphql_node_id供下两个 pass 解析;若颠倒顺序,会把这些列原样送进引擎。
  2. latency_ms——替换为按方言的表达式。SQLite 上基于time_sub(time 扩展的纳秒级减法),PostgreSQL 上为EXTRACT(EPOCH FROM (end_time - start_time)) * 1000
  3. graphql_node_id——在谓词中解码、在投影中构造,类型通过限定符逐引用解析。相等、IN/NOT IN= ANY/= ALLIS [NOT] DISTINCT FROM变成主键上的整数比较;LIKEBETWEEN不是 node id 语义,保留投影形式。
  4. 时间戳相减(仅 SQLite)——end_time - start_time通过unixepoch重写,避免引擎做文本算术。
  5. 时间戳字面量——与时间戳列比较的字面量按后端正确比较的布局重新输出。PostgreSQL 自解析字面量,因此实际仅 SQLite 生效;两端对裸日期都会在notes中记录"按 UTC 读取"。此外,无偏移的时间字面量在准入即被拒(前置规则要求写2026-07-01T14:30:00Z这样的形式)。
  6. JSON 规范化(仅 SQLite)——访问器改写为部署中表达式索引所用的拼写(json_extract+ 全引号路径)。SQLite 按解析后的表达式匹配索引,改写使调用方写$.a.b也能命中 SQLAlchemy 编译出的json_extract全引号拼写;->返回 JSON 文本、->>json_extract返回值,因此绝不把->改写成函数形式以免改变比较语义。
  7. PostgreSQL 动态 JSON 提取——WORKAROUND sqlglot<=30.15.0。SQLGlot 在键非常量路径时把jsonb -> expr渲染为json_extract_path,而 PostgreSQL 的json_extract_path接受json而非jsonb。这些节点被改写为jsonb_extract_path/jsonb_extract_path_text;字面量键(attributes -> 'llm')仍渲染为->。当 sqlglot 固定版本越过 30.16.0 时可移除该 pass(不输出外部链接;仓库中检索WORKAROUND sqlglot<=30.15.0即可定位所有相关站点)。
  8. Schema 限定——白名单关系限定为解析出的 PostgreSQL schema。
  9. Limit 注入——追加row_limit + 1,使截断可检测而非被假设。

此外,rewrite 末尾会运行 JSON 文本排序检查(_note_uncast_json_ordering):当MINMAXORDER BY及排序类比较作用于未 CAST 的 JSON 提取时,按方言给出警告注释——PostgreSQL 上#>>返回 text 导致'1017066'排在'149740'之后,SQLite 上则取决于文档中值的类型。SUM/AVG会强制类型转换,故被有意排除在警告之外。

八、阶段 4 与阶段 6:复检与引擎背封

阶段 4:post-rewrite 检查

验证重写后的树只引用白名单关系,且若带 schema 限定符,该 schema 正是表所在 schema。它严格弱于准入,且无法做到与准入相等:重写有意发出准入会拒绝的 SQL——如匿名函数encodeconvert_to。实际保证的性质是"没有出现新关系",而非"仍然可准入"。这是一个已知弱点,已记录在开放问题中。源码中对应_assert_rewrites_preserved_policy:它以"拒绝而非断言"的方式工作,使失败通过错误信封离开,而非作为未处理异常逃逸。

阶段 6:引擎背封

SQLite 的 authorizer 回调与 PostgreSQL 的EXPLAIN计划闸门看到的是渲染之后的语句,这与准入看到的不是同一件事:json_extract(x, path)已作为->运算符发出,调用方从未写过的函数以 SQL 名出现。

二者并不等价。SQLite authorizer(execute.py 中_sqlite_authorizer)在任何位置拒绝任何非白名单函数,且拒绝记录具体被拒的标识符——因为 SQLite 对驱动报告的只是一条无法区分的通用 operational error,authorizer 是唯一知道哪个标识符被拒的地方。它还拒绝一切改变状态的 action(SQLITE_INSERTSQLITE_UPDATESQLITE_DELETESQLITE_ATTACH、DDL 全家等),拒绝读取数据库目录表(sqlite_master等),并利用第五个参数via区分直接基表读取与经由视图/触发器的读取——视图即使与白名单表同名也一律拒绝,因为回调看不到视图的定义。计划闸门(verify_postgres_plan)则检查关系与集合返回节点,并从ProjectSet的表达式文本中读取标量函数名——这无法区分函数与关键字(一个EXTRACT(epoch FROM ...)曾因内部括号被误报为名为from的函数,见_NOT_A_CALL列表的注释),也根本看不到普通标量调用。因此,准入函数策略上的漏洞在 SQLite 有第二层兜底,在 PostgreSQL 则没有。

九、设计决策详解

决策:能力按后端划分,答案分歧不算缺陷

函数策略是带声明差异的并集,而非交集。每个后端获得其能做的——SQLite 的percentilejuliandayjson_each,PostgreSQL 的 ordered-set 聚合与 JSONB 面——叠加在 35 个可移植节点类之上。把面裁剪到两引擎之较小者,会为对称性而删除两端的真实能力。

JSON 面按数据所在位置而非可移植性来定尺寸:该部署存储的几乎所有东西都在spans.attributes,读取文档就是对其大部分提问方式,两端都获得各自 JSON 词汇表中"纯且受文档边界约束"的部分。PostgreSQL 是键存在(??|?&)、包含(@><@)、路径测试(@?jsonb_path_existsjsonb_path_match)、路径查询、键枚举以及尺寸/渲染/构造函数;SQLite 是 json1 对应物:json_array_lengthjson_validjson_prettyjsonjson_quotejson_arrayjson_object与两个分组聚合。

聚合进单文档的能力两端都准入:PostgreSQL 的jsonb_agg/jsonb_object_agg与 SQLite 的json_group_array/json_group_object,四个都会放大(N 行塌缩进一个随 N 增长的单格),按group_concat已确立的条款准入——每格字节上限拒绝超大结果,截止时间约束工作量。产生修改副本也被准入(jsonb_setjsonb_insert#-与 SQLite 的json_setjson_insertjson_replacejson_removejson_patch),因为它们只返回受输入约束的新文档,且在大成员跨过每格字节上限前移除它正是该面想要的用途。

剩余的才是引擎表达能力的真实差异。SQLite 没有键存在/包含运算符、没有 SQL/JSON 路径函数,因此这些问题在 SQLite 上用json_extract(...) IS NOT NULLjson_each来问。该拒绝被记录在语料中——按规则,未声明的非对称与缺口不可区分。手维护集合中的缺口在有人写出它遗漏的语句前是不可见的,且随之而来的拒绝点名的是解析器类而非调用方写的东西:?jsonb_contains(并非 PostgreSQL 对它的函数名),@?j_s_o_n_b_path_exists(一个任何地方都不存在的名字)。sql_names()对运算符没有函数拼写,因此回退用 snake_case 化类名。这两半——静默缺口与不可执行的报错——都属于开放问题 1。

可接受的分歧由"谁编写了行为"裁决。该面发明的概念要承担全部一致责任(没人能拿规范校验它;拒绝可恢复,悄悄不同的数字不可恢复);引擎语义不属于该面调和的范围,强行调和只会更糟——让->>两端一致要么覆盖数据库的文档化行为,要么降级已做正确事情的端。执行信封在排序敏感操作使用未 CAST 的 JSON 提取时给出警告;存在活动表达式索引时,full 详细级别的 schema 还发布精确的索引拼写。因此非对称是一种决策,并被记录为决策:admission_corpus.py以每条语句按方言各带一份、附结果与原因的方式记录。未声明的非对称按定义是缺陷。

决策:白名单解析树,而非语句文本

文本级过滤会被注释、空白、大小写、unicode 与嵌套构造打败。先解析意味着策略与引擎读取同一构件,因为引擎运行的语句正是从策略检查过的树打印的。代价是对 SQLGlot 解析忠实性的依赖,而这个依赖比表面更重:不忠实的解析不会使策略与引擎失步(二者都源于树而保持一致),而是使二者都与调用方失步——调用方的文本在阶段 1 就被丢弃、永不再查。下游无法察觉,原因有二:每个下游检查读的是同一棵错误之树,管线中不存在第二个意见;且在错误树上往返是稳定的——解析、渲染、再解析得到原样,自洽性检查必然通过。由此衍生两条实践:准入拒绝它不认识而非忽略的节点类;admission_corpus.py记录每一个曾漏过的构造。

决策:重写语句,而非拒绝需要重写的语句

调用方要latency_ms是在问 schema 已广告的问题。拒绝并解释正确表达式要花一次往返,还假设模型能写对;替换只需一次交换。代价是执行语句可能变成调用方没问的东西。两个缓解措施针对各 pass:替换表达式整体加括号,绑定位置与列一致;liveness 套件对种子行而非空表执行每个被允许的构造。但两者都够不到含义变化的另一来源——解析本身:一棵已歪曲调用方语句的树会被忠实地重写成一棵同样歪曲的语句,且上述两个缓解都会报告成功。

决策:schema 是 DDL,而非 JSON

describeSqlSchemabrief级别返回纯注释的表目录;在detailedfull级别选择请求方言的打包CREATE TABLE语句并追加--策展注释。PostgreSQL 的public.限定从CREATE TABLEREFERENCES语法中移除(调用方 SQL 必须使用未限定表名),物理语句其余部分原样保留。三个理由:

  • 省 token(已实测):比等价 JSON 目录少约三分之一 token——JSON 为每列重复name/type/nullable键。数字随渲染器获得约束与列注释而变化,当前数值由ddl.py承载,spec 文档刻意不复述,因为同一测量的两份拷贝会漂移。
  • 结构性:JSON 类型是抽象,DDL 不是。方言资产直接报告start_time在 SQLite 是TIMESTAMP、在 PostgreSQL 是TIMESTAMP WITH TIME ZONE——写比较或 CAST 的调用方必须知道这个。
  • 原生性:它就是调用方写回的形态。

物理 DDL 无法提供的策展——区域、grain(行粒度)、虚拟列声明、时间列标签、提升列指引与语义列注释——以注释形式随行。虚拟列是注释而非物理CREATE TABLE的成员,重写使它们在查询中行为如列。原始外键被保留,因此被选表可能描述指向分析白名单外表的引用:该目标是可用的物理上下文而非查询许可——前置声明中写明全局白名单定义可查询表,准入会拒绝该目标。返回前渲染文本要过解析,因为看起来像 DDL 的文本仍可能无效,而调用方无法分辨(对他们是散文)。

决策:schema 是文本,结果是 dict

两个工具返回不同形态,因为消费方式不同。describeSqlSchema返回散文——没人解析它,JSON 包装不增加读者使用的结构,反而转义一个几乎全是换行的文档的每个换行(detailed级别约 174 token、约 7%);output_schema=None还抑制结构化镜像——对散文而言那是文本块的逐字重复。executeSql返回 dict,结果集以数据而非待解析文本到达。

关于重复:MCP 的CallToolResult携带必需的content列表与可选的structuredContent,二者都发出是惯例——文本块供所有客户端可读,结构化视图供理解它的客户端使用。这不是浪费,而且在该面实际驱动的路径上(见消费模型)完全不花成本。

决策:结果信封只携带会变化的内容

凡不可能取第二个值的字段都被移除。拆分前,一行结果 696 字节中 53 字节是行、401 字节不可能不同:一个硬编码报告所有区域可用的availability图、字面量read_only: true、字节上限、以及每次调用逐字重复的consistency注释。这些是面的属性,因此改由describeSqlSchema携带——每次调用该工具一次,而调用方调用它的频率远低于运行查询。结果信封结构见 output.py:columnsrowsrow_countrow_count_is_partial(截断的权威答案,因为多取了一行)、applied(生效方言、钳制后 limit、触发的重写、必要时含实际执行 SQL)、backend_validatednotes与 PostgreSQL 独有的estimated_rows(规划器对未截断行数的估计,绝不回答截断问题)。

决策:白名单表的每个物理列都可查询

白名单策展的是而非列。表一旦准入,其方言 DDL 资产中的每个物理列都被广告、被准入接受、被纳入SELECT *,适用虚拟覆盖层追加其后。显示属性与指向非白名单表的外键因此作为值可见,尽管被引用表本身仍不可查询。这使 schema 教学、准入与星号展开共用同一目录。未知基表列失败关闭,而迁移新增的物理列在重新生成资产落地时被有意加入面。真正在面之外的东西必须住在未白名单的表中。

决策:不注入默认时间窗口

早期版本在调用方未给窗口时注入尾随七天窗口。它无法约束坚决的调用方(破解它只需一个参数),对其他人则回答了未问的问题还报告成功。约二十五次冷启动 Agent 运行中每个调用方都注意到并绕开了它,因此它保护不了任何人还让每个人都付出一次往返。行数与字节上限约束答案;截止时间约束工作量。

决策:读路径避开写入方,两条不同路线

Phoenix 增加了专用 SQLite 读引擎(mode=ro、队列池),使读不再排队在写入方单一StaticPool连接之后。实测NullPool490 条/秒对比队列池 3,879 条/秒,这正是选池而非每次读建连接的原因。该面在 PostgreSQL 执行路径与目录读取(索引反射、引擎版本、schema 解析)中通过db.read()使用它。

SQLite 的executeSql刻意不用它:每次语句自行打开sqlean.connect(...?mode=ro),因为约束查询的 authorizer 回调与 progress handler 是每连接态的,不能活得比语句更久——在池化连接上下一个调用方会继承它们,或在查询中途被剥离;mode=ro也在打开时固定,事后无法强加。放弃池并无损失,因为 SQLite 执行宽度是 1——无论如何一次只跑一条语句。读己之写(read-your-writes)在池化路径上不保证,DbSessionFactory.read上已声明——此前对 PostgreSQL 副本本就不保证。

决策:物理 schema 来自打包 DDL,策展来自类型化 Python

src/phoenix/db/ddl/ 下的生成式 PostgreSQL 与 SQLite schema 资产随 Phoenix 打包,是物理CREATE TABLE文本与有序列名的运行时来源。加载器按确定性-- Table: <name>段落索引,要求恰好一个匹配的CREATE TABLE,不经 SQLGlot 往返提取列,保留带引号标识符语义,返回不可变按方言目录。加载分析白名单时,若白名单表缺失或虚拟列与物理列大小写不敏感地碰撞,则加载失败。

生成器仍在 scripts/ddl/ 下。make schema-ddl重新生成两份规范资产、校验加载器可消费它们、比较两方言的表/有序列/显式索引/命名约束漂移;CI 运行该目标并要求干净的 Git diff,因此改变物理 schema 的迁移必须更新已检入资产。

索引不同:full详细级别从pg_get_indexdefsqlite_master.sql实时读取,因为表达式索引定义是部署事实,调用方必须逐字复现其拼写;briefdetailed不读实时目录。不可变 manifest.py 模块只提供策略与策展:表/区域白名单、grain、时间列标签、虚拟列声明、提升列指引与语义列注释;graphql_node_id的适用性还由代码中 GraphQL 类型映射派生(见 allowlist.py 的TABLE_GRAPHQL_TYPES)。原始物理外键即使指向非白名单表也保留在 DDL 中,准入仍执行全局表白名单。

PostgreSQL schema 针对连接解析而非假设:环境变量设置时用之,否则用未限定projects引用解析出的 schema,而非current_schema()——它报告CREATE会落在哪里,而非表所在哪里,两者在迁移后search_path头部新增条目时即分叉。执行与 full 详细级别的索引反射用同一解析,因此为表发布的索引属于查询所读的关系。

当前三个区域与表的划分(manifest.py):

  • telemetryprojectstracesstart_time为时间列,虚拟列latency_ms)、spans(grain"一条 OpenTelemetry span",含parent_id/span_id/trace_rowid的列注释与llm_token_count_*提升列指引)、span_annotationsspan_costsspan_cost_detailsgenerative_modelsproject_sessions
  • datasetsdatasetsdataset_versionsdataset_examplesdataset_example_revisions(grain"数据集示例的一条不可变修订");
  • experimentsexperimentsexperiments_dataset_examplesexperiment_runsstart_time时间列,虚拟列latency_ms)、experiment_run_annotations

策展字段保持手写。time_column是渲染为注释的教学标签,不注入或改变查询窗口;grain、提升列指引与列注释同样只影响 schema 教学。

十、消费模型:token 核算

该面的 token 核算取决于客户端如何调用它,而默认不是显而易见的那条。在MCP 代码模式(默认)下,模型不接收工具结果:它写 Python 调用call_tool(...),后者返回反序列化的 dict,只有该代码返回的内容进入模型上下文。中间结果在沙箱内被过滤与聚合。五次编排的executeSql调用实测:沙箱内取回的信封共 11,522 字节,到达模型上下文的约 200 字节。因此content/structuredContent的重复在这里不可见——代码两者都不看,只看 dict。每次调用的信封大小远不如"每个结果都被上浮"时重要——这正是executeSql返回结构化数据而非文本的原因,也是值得裁剪信封的原因,因为确实上浮结果的调用方要为每个字段付费。对于直接渲染每个工具结果的客户端,两半都被计费,这正是describeSqlSchemaoutput_schema=None针对的情形。

十一、测试策略

该面占主导地位的缺陷类别是"多个策略层之间夹着一个变换器,各自单独验证、从不相互对验"。因此套件测试的是目录、策略、重写与引擎背封之间的一致性,而非只隔离测试每个组件。测试位于 tests/unit/server/mcp/sql/:

  • 准入语料admission_corpus.py)——每个曾漏过的构造一条记录,连同其现在必须产生的结果。削弱白名单最廉价的方式是在测试仍通过时把它加宽。
  • Liveness——每个被允许的构造对种子行执行并必须返回它们。对空表执行只能验证解析器与 authorizer 一致,无法验证构造是否真的算得出东西。
  • 节点覆盖——钉住可达的非Func表达式类,因为多个绕过来自"表里一套实际另一套"的类。
  • DDL 资产——加载器测试覆盖标记分隔段落、精确CREATE TABLE提取、有序与带引号列、不可变性、以及排除后续索引语句。make schema-ddl校验两份生成资产、比较其逻辑形态,CI 拒绝任何重新生成 diff。
  • 文档 vs 执行器——对每张表比较SELECT *展开与该方言资产的有序物理列加适用虚拟覆盖层;代表性物理列(含显示与外键字段)经准入提交,未知名称必须带实用建议被拒。
  • 渲染 DDL 可解析——每个详细级别与方言在返回前被解析。测试另钉住 detailed 输出以所选物理资产开头、虚拟覆盖层保持注释、原始外键可描述非白名单目标。

仍未自动化的部分:把每个广告列、外键与CHECK字面量都提交给执行器。full 详细级别索引是实时部署数据,因此测试改为钉住发布其表达式所用的解析器 workaround。读者不应假设 CI 会抓住每个未来的文档 vs 引擎失配。另有专门的 test_percentile_parity.py、test_plan_gate.py、test_sqlite_authorizer.py 与 test_liveness.py 覆盖各背封与跨后端一致性。

亲眼运行

以下两段可直接整段粘贴进 MCP Inspector。该面以代码模式驱动,因此一次调用是返回工具反序列化 dict 的 Python,而非待填写的表单。

describeSqlSchemafull详细级别是值得一做的调用,因为full是唯一读取实时目录的级别。其表 DDL 仍来自打包方言资产,实时读取提供部署的索引:

return await call_tool( "describeSqlSchema", { "tables": ["spans"], "detail": "full", }, )

它返回带策展注释的spansDDL、编写 JSON 运算符的前置规则,以及渲染为CREATE INDEX的部署表达式索引。索引段落是值得细读的部分:表达式索引只有在查询逐字复现其表达式时才可用,因此这些拼写是要求而非提示,没见过它们的调用方猜不出多个等价形式中哪个被索引了。

executeSql随后可以问一个固定 API 无法回答的问题,因为其维度无法预知——按模型的尾延迟,其中模型名是 JSON 路径而非列:

return await call_tool( "executeSql", { "sql": """ SELECT attributes #>> '{llm,model_name}' AS model, count(*) AS calls, round(percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms)::numeric, 1) AS p95_ms FROM spans WHERE attributes ? 'llm' GROUP BY model HAVING count(*) > 5 ORDER BY p95_ms DESC """ }, )

该语句中有四处值得注意,每处都是本文记录过的一个决策:

  • attributes #>> '{llm,model_name}'describeSqlSchema为索引路径发布的拼写,无需 CAST(路径字面量无需转型);
  • attributes ? 'llm'是键存在测试,PostgreSQL 准入、SQLite 缺失,是声明的非对称而非缺口;
  • percentile_cont(...) WITHIN GROUP在此准入、SQLite 拒绝并给出命名percentile(x, p)的消息;
  • latency_ms根本不是列。

最后一点在答案中可见:信封报告applied.rewriteslatency_msschema_qualificationlimit_injection——虚拟列被替换为按方言表达式、spans针对连接解析、row_limit + 1被追加使截断可检测。

十二、开放问题

按它们会改变设计多少排序:

  1. 一个策略、十处枚举、四套词汇。"允许哪些计算运行"写在十处,分别表达为 SQLGlot 类、调用方拼写、SQLite authorizer 名称与 PostgreSQL 计划标识符。没有一集合由另一集合派生;一致性靠测试维持,四处分歧记录在代码注释中。生成式能力表会让缺失的单元在导入时失败。

  2. 结果未被标记为不可信内容,尽管它们携带受攻击者影响的文本进入持有破坏性工具的模型。

  3. SQLGlot 把 PostgreSQL JSON 运算符解析为访问器而非二元运算符。->->>#>#>>?是 SQLGlotCOLUMN_OPERATORS的条目,以最紧优先级结合,右操作数解析不一致;PostgreSQL 把它们放在"任意其他运算符"层级,低于算术、与||同级。四组分群出错:

    输入SQLGlot 构建PostgreSQL 含义
    a #>> b::text[]CAST(a #>> b AS TEXT[])a #>> CAST(b AS TEXT[])
    a -> b[1](a -> b)[1]a -> b[1]
    a -> b.c -> da -> (b.c -> d)(a -> b.c) -> d
    a -> b + 1(a -> b) + 1a -> (b + 1)

    缺陷在解析中,下游检查无法恢复含义,且往返不暴露它——解析、渲染、再解析返回同一棵错误之树。第二、四行渲染回调用方逐字文本,PostgreSQL 按其自身优先级重解析,含义靠偶然而非设计存活;第一、三行把错误分组渲染进CASTJSON_EXTRACT_PATH调用,任何引擎都无法重新解释。括号击败全部四例:带括号的操作数落在Paren节点下、作为整体结合,a #>> ('{a,b}'::text[])a -> ('a'[1])a -> (b.c) -> da -> (1 + 1)都按 PostgreSQL 的读法解析与渲染,四例均对 PostgreSQL 17 验证过。

    三个缓解措施已就位:catalog.py从该面发布的索引拼写中丢弃冗余 CAST(pg_get_indexdef对 JSON 路径表达式索引发出'{a,b}'::text[],剥离形式到达同一索引,用EXPLAIN验证);schema 前置声明开篇即写明括号规则,让调用方在写 SQL 前而非被拒后学会它;准入直接拒绝第一行——因为该树也是故意的CAST(a #>> b AS text[])所产生的,两种读法解析后无法区分,静默任选其一都会回答未问的问题,故拒绝消息点名两个无歧义拼写。剩余未覆盖的是不遵守前置声明的前两行之外的调用方:第二、四行仍渲染为调用方原文,PostgreSQL 恢复含义;第三行不会——a -> b.c -> d渲染为JSON_EXTRACT_PATH调用且结合性已错向固定,返回错误值且无任何报告。在 sqlglot 固定为 30.14.0/30.15.0 期间重关联第三行曾被调研并否决:其形态与第一行同样歧义,只能靠未文档化的解析器内部实现区分,基于此的含义改变型重写是坏交易。该问题已上报上游并在 sqlglot 30.16.0 修复——JSON 运算符移到 Postgres 二元运算符优先级层;该版本上每行以及col -> k都以中缀往返。Phoenix 当前固定sqlglot==30.15.0,缓解措施保留;检索WORKAROUND sqlglot<=30.15.0定位站点,固定版本越过 30.16.0 后移除。准入对a #>> b::text[]的拒绝届时可移除,因为修正后的解析器能区分它存在的两种读法。白名单上的jsonb_extract_path不是 workaround——调用方会直接写它。

  4. 计划闸门与 SQLite authorizer 不是等价背封(见阶段 6)。

十三、相关文档

  • 设计规格:internal_docs/specs/mcp-analytics-sql.md
  • 实现目录:src/phoenix/server/mcp/sql/(入口 tools.py,核心执行 execute.py,重写 rewrite.py,准入 parse.py,函数策略 allowlist.py,策展 manifest.py,schema 渲染 ddl.py,结果信封 output.py)
  • 打包 DDL 资产:src/phoenix/db/ddl/postgresql_schema.sql、src/phoenix/db/ddl/sqlite_schema.sql,加载器 src/phoenix/db/ddl/loader.py
  • 测试:tests/unit/server/mcp/sql/
  • 关联设计:只读副本路由
  • 可观测性
  • AI 评测
  • LLMOps
  • AI 应用
  • 人工智能

【免费下载链接】phoenix

AI Observability & Evaluation

项目地址:https://gitcode.com/gh_mirrors/phoenix13/phoenix
点击查看免费下载

相关推荐

上一篇:nerfstudio LocalWriter 终端日志指南:配置、输出格式与自定义统计项
下一篇:Arduino ESP32完整开发指南:从零开始构建物联网应用

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询