- 可观测性
- AI 评测
- LLMOps
- AI 应用
- 人工智能
【免费下载链接】phoenix
AI Observability & Evaluation
导读
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 工具describeSqlSchema与executeSql的用法与返回结构;以及它如何在 SQLite 与 PostgreSQL 两个后端之间裁决"分歧是否算缺陷"。
一、问题:固定 API 之外的即席分析需求
Phoenix 以遥测(telemetry)、数据集(datasets)与实验(experiments)为核心数据。既有 API 均回答固定的问题集。一个 Agent 若被问到某个没有人预先设想过的分析性问题——例如"这个项目本周哪个模型 p95 延迟最差"、"哪些提示词产生的重试最多"——它没有现成的查询入口。它只能自己翻页拉取 spans 并自行聚合,这需要大量往返与上下文开销;或者干脆放弃。
数据库本身可以直接回答这些问题。所缺的只是让 Agent 安全地发问的途径:既不能让它读到不该读的表,也不能让它运行永不终止的语句,更不能让它返回超出上下文窗口容量的数据。
二、目标与非目标
目标:
- 让 Agent 以 SQL 表达分析性问题并直接获得答案;
- 对任何单条语句的读取量、成本与返回量设界;
- 把 schema 描述得足够好,使模型第一次就能写出正确的 SQL;
- 让 schema 描述足够廉价,schema 发现不会主导调用方的 token 预算。
非目标(spec 原文):
- 不是机密性边界(见威胁模型章节);
- 不支持写操作、DDL 或事务控制;
- 不支持跨数据库或联邦查询;
- 不做引擎之外的查询优化;
- 不缓存结果;
- 不提供存储或命名查询。
三、正确性判据:谁编写了行为
该面上几乎所有正确性争议都以同一种形式出现:调用方得到一个意外结果,或两个后端对同一语句给出不同答案,需要判定这是否为缺陷。一个提问即可定论:产生该结果的行为是谁编写的。该面自己编写的行为归该面负责修复;引擎编写的行为归引擎负责,调用方有权依赖其文档。
| 该面编写的行为 | 引擎编写的行为 | |
|---|---|---|
| 语句的含义 | 我们改变了含义,就由我们修复 | 引擎不同,差异就透传 |
| 可用的能力 | 概念是我们的,两个后端必须一致 | 能力不同,允许能跑的一侧,在另一侧给出可执行的拼写并拒绝 |
具体化为四条规则:
- 答案必须回答被提出的问题。语句可以被自由重写——展开星号、替换派生列、注入 limit——但绝不能使结果偏离调用方 SQL 的本意。案例:SQLGlot 把调用方的
json_extract渲染成->(返回 JSON 文本而非值),导致MAX按字典序比较。这是该面管线改变了含义,由该面修复。 - 该面发明的概念只能有一个含义。
latency_ms与graphql_node_id在别处不存在,没有任何规范定义它们,包外也无法校验。案例:latency_ms在两个后端计算出不同数值,这是该面的责任——两个实现必须彼此一致。 - 引擎行为透传。案例:
->>在 SQLite 返回类型化值、在 PostgreSQL 返回文本,因此对数值 JSON 路径求max,一端得到130000,另一端得到'9'。两个引擎都按规范行事,该面既不做调和也不拒绝;但结果信封会在未加 CAST 的排序敏感提取处给出警告(见 rewrite.py 中_note_uncast_json_ordering,其按方言区分的_JSON_TEXT_ORDERING_NOTES注释给出了两端的具体示例)。 - 能力可以不同,拒绝必须可执行。案例:
percentile_cont(...) WITHIN GROUP在 SQLite 没有对应语法。这是引擎的能力缺口,因此该构造在 PostgreSQL 上被准入、在 SQLite 上被拒绝,且拒绝消息点名给出percentile(x, p)作为替代拼写。
四、威胁模型与能力边界
这是塑造其余设计的最关键决定,且最初在代码中被表述错误。该面对任何能触达 MCP 挂载点的调用方开放,可读取遥测、数据集与实验。Phoenix 本已允许任何已认证用户通过 GraphQL 读取所有这些:仅四个查询携带IsAdmin——users、user_api_keys、oauth2_grants、system_api_keys——而这些表均不在本面白名单内;projects及其可达的所有表则根本不带权限类。
因此,此前的 ADMIN/SYSTEM 检查比旁边的 API 更严格:在 SQL 中拒绝的,同一调用方却可在 GraphQL 中取到。该检查已被移除(源码见 tools.py 中register_analytics_sql_tools的文档字符串)。
约束该面的是能力(capability),而非身份(identity):
| 约束 | 值 |
|---|---|
| 语句形态 | 单条只读语句:SELECT、UNION、INTERSECT、EXCEPT |
| 行数 | 默认 500,最大 5000 |
| 字节 | 每行 256 KiB,每响应 4 MiB |
| 截止时间 | 30 秒(PostgreSQL 用statement_timeout,SQLite 用 progress handler) |
| 并发 | PostgreSQL 4 并发、SQLite 1 并发;队列深度 8 |
以上常量在 execute.py 中有精确对应:DEFAULT_ROW_LIMIT = 500、MAX_ROW_LIMIT = 5_000、BYTE_LIMIT = 262_144、MAX_RESPONSE_BYTES = 4 * 1024 * 1024、MAX_SQL_BYTES = 2 * 1024(单次调用 SQL 文本上限 2 KiB,超长在未执行前即被拒绝)、PG_STATEMENT_TIMEOUT_MS = 30_000、SQLITE_TIMEOUT_SECONDS = 30,并发表为SQL_CONCURRENCY = {"postgresql": 4, "sqlite": 1},队列深度由StatementAdmissionController._queue_size = 8承载。
该设计要防御的对手是被误导或被劫持的模型,而非试图获取本无法获取之数据的恶意用户。上述每项控制都在限制爆炸半径;没有一项在限制调用方通过其他途径同样能拿到的信息。两个随之而来的推论都是有意为之:
- 表就是数据边界。白名单表内的每个物理列都可查询;被排除在面外的数据,通过不将其表列入白名单而排除。
- 争用是残余风险。SQLite 执行宽度为 1,一条慢查询会将其余所有分析请求串行化直至其截止时间。许可证(permit)在解析之前获取,因此准入与重写也要排队等待。行数限制、字节上限与截止时间约束的是单条查询,不约束争用。
设计中未处理的一个独立问题:spans.attributes与spans.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()入口依次调度它们):
- 星号展开——
*变成有序的物理 DDL 列,后接适用的虚拟覆盖层。放在第一位是因为它产出latency_ms与graphql_node_id供下两个 pass 解析;若颠倒顺序,会把这些列原样送进引擎。 latency_ms——替换为按方言的表达式。SQLite 上基于time_sub(time 扩展的纳秒级减法),PostgreSQL 上为EXTRACT(EPOCH FROM (end_time - start_time)) * 1000。graphql_node_id——在谓词中解码、在投影中构造,类型通过限定符逐引用解析。相等、IN/NOT IN、= ANY/= ALL、IS [NOT] DISTINCT FROM变成主键上的整数比较;LIKE、BETWEEN不是 node id 语义,保留投影形式。- 时间戳相减(仅 SQLite)——
end_time - start_time通过unixepoch重写,避免引擎做文本算术。 - 时间戳字面量——与时间戳列比较的字面量按后端正确比较的布局重新输出。PostgreSQL 自解析字面量,因此实际仅 SQLite 生效;两端对裸日期都会在
notes中记录"按 UTC 读取"。此外,无偏移的时间字面量在准入即被拒(前置规则要求写2026-07-01T14:30:00Z这样的形式)。 - JSON 规范化(仅 SQLite)——访问器改写为部署中表达式索引所用的拼写(
json_extract+ 全引号路径)。SQLite 按解析后的表达式匹配索引,改写使调用方写$.a.b也能命中 SQLAlchemy 编译出的json_extract全引号拼写;->返回 JSON 文本、->>与json_extract返回值,因此绝不把->改写成函数形式以免改变比较语义。 - 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即可定位所有相关站点)。 - Schema 限定——白名单关系限定为解析出的 PostgreSQL schema。
- Limit 注入——追加
row_limit + 1,使截断可检测而非被假设。
此外,rewrite 末尾会运行 JSON 文本排序检查(_note_uncast_json_ordering):当MIN、MAX、ORDER BY及排序类比较作用于未 CAST 的 JSON 提取时,按方言给出警告注释——PostgreSQL 上#>>返回 text 导致'1017066'排在'149740'之后,SQLite 上则取决于文档中值的类型。SUM/AVG会强制类型转换,故被有意排除在警告之外。
八、阶段 4 与阶段 6:复检与引擎背封
阶段 4:post-rewrite 检查
验证重写后的树只引用白名单关系,且若带 schema 限定符,该 schema 正是表所在 schema。它严格弱于准入,且无法做到与准入相等:重写有意发出准入会拒绝的 SQL——如匿名函数encode、convert_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_INSERT、SQLITE_UPDATE、SQLITE_DELETE、SQLITE_ATTACH、DDL 全家等),拒绝读取数据库目录表(sqlite_master等),并利用第五个参数via区分直接基表读取与经由视图/触发器的读取——视图即使与白名单表同名也一律拒绝,因为回调看不到视图的定义。计划闸门(verify_postgres_plan)则检查关系与集合返回节点,并从ProjectSet的表达式文本中读取标量函数名——这无法区分函数与关键字(一个EXTRACT(epoch FROM ...)曾因内部括号被误报为名为from的函数,见_NOT_A_CALL列表的注释),也根本看不到普通标量调用。因此,准入函数策略上的漏洞在 SQLite 有第二层兜底,在 PostgreSQL 则没有。
九、设计决策详解
决策:能力按后端划分,答案分歧不算缺陷
函数策略是带声明差异的并集,而非交集。每个后端获得其能做的——SQLite 的percentile、julianday、json_each,PostgreSQL 的 ordered-set 聚合与 JSONB 面——叠加在 35 个可移植节点类之上。把面裁剪到两引擎之较小者,会为对称性而删除两端的真实能力。
JSON 面按数据所在位置而非可移植性来定尺寸:该部署存储的几乎所有东西都在spans.attributes,读取文档就是对其大部分提问方式,两端都获得各自 JSON 词汇表中"纯且受文档边界约束"的部分。PostgreSQL 是键存在(?、?|、?&)、包含(@>、<@)、路径测试(@?、jsonb_path_exists、jsonb_path_match)、路径查询、键枚举以及尺寸/渲染/构造函数;SQLite 是 json1 对应物:json_array_length、json_valid、json_pretty、json、json_quote、json_array、json_object与两个分组聚合。
聚合进单文档的能力两端都准入:PostgreSQL 的jsonb_agg/jsonb_object_agg与 SQLite 的json_group_array/json_group_object,四个都会放大(N 行塌缩进一个随 N 增长的单格),按group_concat已确立的条款准入——每格字节上限拒绝超大结果,截止时间约束工作量。产生修改副本也被准入(jsonb_set、jsonb_insert、#-与 SQLite 的json_set、json_insert、json_replace、json_remove、json_patch),因为它们只返回受输入约束的新文档,且在大成员跨过每格字节上限前移除它正是该面想要的用途。
剩余的才是引擎表达能力的真实差异。SQLite 没有键存在/包含运算符、没有 SQL/JSON 路径函数,因此这些问题在 SQLite 上用json_extract(...) IS NOT NULL与json_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
describeSqlSchema在brief级别返回纯注释的表目录;在detailed与full级别选择请求方言的打包CREATE TABLE语句并追加--策展注释。PostgreSQL 的public.限定从CREATE TABLE与REFERENCES语法中移除(调用方 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:columns、rows、row_count、row_count_is_partial(截断的权威答案,因为多取了一行)、applied(生效方言、钳制后 limit、触发的重写、必要时含实际执行 SQL)、backend_validated、notes与 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_indexdef或sqlite_master.sql实时读取,因为表达式索引定义是部署事实,调用方必须逐字复现其拼写;brief与detailed不读实时目录。不可变 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):
- telemetry:
projects、traces(start_time为时间列,虚拟列latency_ms)、spans(grain"一条 OpenTelemetry span",含parent_id/span_id/trace_rowid的列注释与llm_token_count_*提升列指引)、span_annotations、span_costs、span_cost_details、generative_models、project_sessions; - datasets:
datasets、dataset_versions、dataset_examples、dataset_example_revisions(grain"数据集示例的一条不可变修订"); - experiments:
experiments、experiments_dataset_examples、experiment_runs(start_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返回结构化数据而非文本的原因,也是值得裁剪信封的原因,因为确实上浮结果的调用方要为每个字段付费。对于直接渲染每个工具结果的客户端,两半都被计费,这正是describeSqlSchema上output_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,而非待填写的表单。
describeSqlSchema的full详细级别是值得一做的调用,因为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.rewrites为latency_ms、schema_qualification、limit_injection——虚拟列被替换为按方言表达式、spans针对连接解析、row_limit + 1被追加使截断可检测。
十二、开放问题
按它们会改变设计多少排序:
一个策略、十处枚举、四套词汇。"允许哪些计算运行"写在十处,分别表达为 SQLGlot 类、调用方拼写、SQLite authorizer 名称与 PostgreSQL 计划标识符。没有一集合由另一集合派生;一致性靠测试维持,四处分歧记录在代码注释中。生成式能力表会让缺失的单元在导入时失败。
结果未被标记为不可信内容,尽管它们携带受攻击者影响的文本进入持有破坏性工具的模型。
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) -> da -> b + 1(a -> b) + 1a -> (b + 1)缺陷在解析中,下游检查无法恢复含义,且往返不暴露它——解析、渲染、再解析返回同一棵错误之树。第二、四行渲染回调用方逐字文本,PostgreSQL 按其自身优先级重解析,含义靠偶然而非设计存活;第一、三行把错误分组渲染进
CAST或JSON_EXTRACT_PATH调用,任何引擎都无法重新解释。括号击败全部四例:带括号的操作数落在Paren节点下、作为整体结合,a #>> ('{a,b}'::text[])、a -> ('a'[1])、a -> (b.c) -> d、a -> (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——调用方会直接写它。计划闸门与 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
相关推荐
Granite-20B-Code-Base-8K vs 其他代码模型:谁才是开发者真正的生产力工具
Granite 20B Code Base 8K vs 其他代码模型:谁才是开发者真正的生产力工具 Granite 20B Code Base 8K 是一款专为
Lightdash 的 Effective dbt SQL 实战指南:面向 Agent 与开发者的模型 SQL 语义规范
Lightdash 的 Effective dbt SQL 实战指南:面向 Agent 与开发者的模型 SQL 语义规范 本文基于 Lightdash 仓库 s
后端前端数据分析数据可视化人工智能AI AgentNocoBase SQL 表(SQL Collection):用 SQL 查询构建只读报表数据表的完整实践
NocoBase SQL 表(SQL Collection):用 SQL 查询构建只读报表数据表的完整实践 本文基于 NocoBase 官方文档《SQL 表》与
低代码后端前端人工智能AI 应用工作流自动化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考