语义层定义了收入、客户、地区和业务时间这些可以被依赖的对象,但它还没有回答一个更现实的问题: 当 Agent 真正开始分析时,怎样确保它使用这些定义完成计算,而不是读完定义之后,仍然凭自己的理解 重新写一套查询?
这道边界很重要。语义层如果只出现在上下文里,它仍然是一份参考资料。Agent 可能正确复述“收入”的 业务定义,却在执行时换了一列金额、漏掉一个状态,或者沿一条未声明的关系做连接。查询可以成功, 答案也可能看起来合理,但语义契约并没有真正参与计算。
Marivo 在语义层之上设计 Analysis DSL,就是为了让业务定义从“被 Agent 看见”变成“每个可检查的分析 动作的实际输入”。它约束的是动作是否合法、怎样执行、产出什么;至于为什么先比较、接下来检查 哪个维度、何时停止以及怎样解释结果,仍然由 Agent 决定。
语义层之后,还有一道执行边界
先看一个很容易发生的偏差。Agent 从语义层读到:sales.revenue 代表已完成订单的净收入,默认使用
完成时间,可以按销售地区拆分。接着它根据这段说明自行生成查询,从订单表选择一个金额列,补上状态
过滤,再按地区聚合。
这段查询可能与定义完全一致,也可能只有九成一致。问题在于,系统无法仅凭最终结果证明:这次计算
确实使用了当前的 sales.revenue 定义,包括默认时间、排除范围、单位、能否跨时间和地区直接相加,
以及允许使用的关联关系。语义层被阅读了,却没有成为执行前提。
Marivo 因此区分两件事:
- 参考语义定义:Agent 读取文字,然后自行把含义翻译成底层计算;
- 以语义对象为输入:Agent 把当前目录中类别明确的业务对象交给 Analysis DSL,由运行时解析并执行定义。
第二种方式里,Agent 不能只传一个看起来正确的名字。observe 接受当前语义目录中的 metric 条目或
稳定 ref,也就是这个业务对象的唯一引用;维度同样从对应目录中取得。已经过期、来自其他目录、类别
不符的对象和裸字符串都会在执行前被拒绝。运行时随后沿语义定义解析计算、时间轴、实体与关系,而
不是让 Agent 再实现一次。
import marivo.analysis as mv
session = mv.session.get_or_create( name="revenue-investigation", question="为什么第三季度收入低于去年同期?",)
revenue = session.catalog.metrics.get("sales.revenue")region = session.catalog.dimensions.get("sales.orders.region")
current = session.observe( revenue, time_scope=mv.time_scope(start="2026-07-01", end="2026-10-01"), grain=mv.grain("month"), dimensions=[region], analysis_purpose="确认第三季度各地区收入",)这并不意味着 Marivo 能阻止 Agent 在系统之外运行任意代码。真正能保证的是一条更清楚、也更可验证 的边界:只有从语义对象出发,经过 Marivo 检查和执行得到的结果,才会留在同一条可追踪的分析链里。 如果 Agent 为了特殊问题导出数据做自定义处理,就意味着明确离开了这条链;自定义结果不能作为 Marivo 的 frame 重新接回去,也不能冒充由同一套业务定义产生的事实。
约束的价值不在于封住所有可能路径,而在于让“仍然使用已确认的业务定义”和“已经离开这条分析链” 变得清楚。企业要基于 Agent 的结果做运营、治理或决策时,这种边界比“Agent 应该遵守定义”的提示 更可靠。
为什么 Analysis DSL 能降低认知成本
让 Agent 直接生成底层查询,表面上少了一层接口,实际上把许多责任同时塞进了一次生成:
- 从业务问题找到正确的 metric;
- 把 metric 展开成字段、过滤和聚合;
- 选择时间轴并处理开始、结束日期的边界;
- 判断这个指标能否按某个维度拆分、数据连接会不会放大数值;
- 判断两个结果是否采用相同的时间粒度和业务口径,能不能公平比较;
- 为下一步重新识别结果列、范围与计算来历。
这些步骤里,真正属于 Agent 推理的只有一部分。其余大多是已经写在语义层里的明确规则,或者可以 由运行时直接检查的条件。如果每一步分析都要求 Agent 把这些事实重新翻译到底层实现,语义层 只是帮助生成查询,而不是减少分析本身的复杂度。
Marivo 的 Analysis DSL 直接沿用语义层的业务语言:语义层提供稳定的业务“名词”,DSL 提供作用于 这些名词和分析结果的“动词”。
语义对象:revenue · region · completed_at分析动作:observe · compare · attribute · forecast分析结果:MetricFrame · DeltaFrame · AttributionFrame · ForecastFrameAgent 想观察收入,就把 revenue 交给 observe;想量化两个范围之间的变化,就比较两个
MetricFrame;想检查变化在地区上的贡献,就把 DeltaFrame 与 region 交给 attribute。它不必
反复降到表、列和连接,再努力把结果恢复成“收入变化”这样的业务概念。
这种设计降低了三类认知成本。
第一是翻译成本。Agent 表达“观察什么、在哪个范围、按什么粒度和维度”,而不是重写 metric 的 实现。第二是记忆负担。每个动作返回一种含义明确、生成后不再被改写的结果,下一轮可以直接读取其 范围、输出结构、来自哪些输入和动作,以及质量信息,不必从一段自由代码中重建上下文。第三是 纠错成本。输入类别、结果形式或使用条件不符合要求时,Marivo 会明确指出问题和修复方向,Agent 面对的是一个局部、明确的问题,而不是从一条数据库错误里猜测是哪层含义出了偏差。
但 DSL 不应该为了降低成本,把分析变成一套固定流程。compare 之后可以做归因、候选发现、质量检查,
也可以直接停止;当前结果还能接哪些动作,由结果自身的说明给出,哪个动作对当前问题有意义,仍由
Agent 判断。可执行不等于值得执行,候选也不等于结论。

Analysis DSL 约束的究竟是什么
一套面向 Agent 的 DSL,如果只是给常用查询换几个短名字,价值很有限。它需要把容易影响业务含义、 又适合由程序直接检查的部分写进每个动作的规则。
在 Marivo 中,一次分析动作至少会受到以下几层约束:
- 用的是哪个业务对象:metric、dimension、Event、StateModel 必须是当前目录中类别正确的对象; 分析结果必须属于当前 session,不能把相似名字或其他调查中的 frame 悄悄混进来。
- 这次看什么范围,结果长什么样:时间范围、粒度、分组维度和筛选共同决定结果是单个数、时间 序列、分组数据还是同时包含时间与分组的数据;后续动作只能接收自己明确支持的结果形式。
- 两份数据的含义是否一致:时间轴、单位、计算方式、包含哪些数据,以及两边使用的业务定义都必须 满足当前动作的要求。两个结果都是数值,不代表它们可以比较;有一列地区,也不代表变化可以沿地区 安全归因。
- 哪些关键选择必须说清楚:两个时间段怎样对应、按哪些维度拆解、怎样抽样以及预测多远,都只能 使用明确列出的选项。这些选择会留在调用中,而不是让运行时暗中猜测。
- 成功之后能承诺什么:每个核心操作只产生一种含义明确的结果。成功就意味着结果符合这项操作的 要求;不满足时就明确失败,而不是返回一份“尽量像”正确答案的数据。
这些约束都针对可执行动作,而不是 Agent 的思考过程。Marivo 不规定问题必须先同比还是先环比,不替 Agent 选择最重要的地区,不把相关性升级成原因,也不会自动生成业务结论。它负责让相同输入遵循相同 规则完成计算,并守住每项操作的边界;Agent 负责提出假设、选择下一步、综合多份结果并承担解释。
Analysis DSL 具体提供哪些能力
Marivo 没有按照数据库操作来组织 DSL,而是按照分析问题与结果类型组织能力。
从观察某个指标开始
session.observe(...) 按明确的范围、粒度和维度计算 metric,并把结果保存为 MetricFrame,是指标
分析的入口。事件路径从 session.events.match(...) 进入,生命周期问题从
session.lifecycle.replay(...) 进入。三者都要求类别明确的语义对象,不从字段名临时猜测业务过程。
最常见的用法,是观察一个 metric 在一段时间内的变化:
revenue_by_region = session.observe( revenue, time_scope=mv.time_scope(start="2026-07-01", end="2026-10-01"), grain=mv.grain("month"), dimensions=[region], analysis_purpose="查看第三季度各地区收入",)revenue_by_region.show()这里的 revenue 和 region 都来自语义层。Agent 只表达这次要看的范围与拆分方式,不需要重新实现
收入怎样计算。
量化变化,并检查变化由什么组成
session.compare(...) 接收两个含义一致的 MetricFrame,产生 DeltaFrame。它会明确两个时间段怎样
一一对应,并先检查它们能不能公平比较,而不是简单地把两列数相减。session.attribute(...) 再按
明确的业务维度计算贡献,并要求各部分能够与总变化核对。归因给出的是数值上的贡献,不是因果解释;
这条边界会保留给 Agent。
baseline = session.observe( revenue, time_scope=mv.time_scope(start="2025-07-01", end="2025-10-01"), grain=mv.grain("month"), dimensions=[region], analysis_purpose="建立去年同期基线",)
delta = session.compare( current, baseline, alignment=mv.window_bucket(), analysis_purpose="量化第三季度收入同比变化",)attribution = session.attribute( delta, axes=[region], analysis_purpose="检查地区对收入变化的贡献",)delta.show()attribution.show()compare 先回答“变化了多少”,attribute 再回答“哪些地区贡献了这次变化”。第二个结果仍然只是
数值贡献,不能直接写成“这个地区导致了收入下降”。
发现值得继续调查的候选
session.discover.* 不是一个含糊的“自动洞察”接口,而是一组目标明确的候选发现动作:时间序列中的
异常点、区间变化、可能的影响维度、值得查看的分组或时间段,以及同一时点下明显偏离其他对象的结果。
它们返回 CandidateSet,
其中的分数只用于当前集合内排序。候选是一条线索,不是已经确认的异常,更不是业务原因。
例如,可以从已经按地区计算的收入中,找出最值得继续查看的地区:
candidates = session.discover.interesting_slices( current, search_space=[region], limit=5, analysis_purpose="寻找值得继续调查的地区",)candidates.show()这个调用只缩小调查范围。最终选择哪个候选继续分析,以及它是否具有业务意义,仍由 Agent 判断。
做统计判断、预测和质量检查
correlate 计算两个 MetricFrame 的统计关联,hypothesis_test 按明确方法做统计检验,forecast 从历史
frame 产生未来区间,assess_quality 对受支持的结果运行固定质量检查。这些动作分别返回
AssociationResult、HypothesisTestResult、ForecastFrame 和 QualityReport,不会被压成一种
通用“分析结果”。每种结果都保留自己的含义:关联不是因果,预测不是观察值,统计显著也不等于
业务重要。这样可以避免 Agent 在解释时把一个结果说成它没有证明的事情。
下面把几种动作放在一起看。order_count_frame 与 current 使用相同的时间范围、粒度和地区维度:
order_count = session.catalog.metrics.get("sales.order_count")order_count_frame = session.observe( order_count, time_scope=mv.time_scope(start="2026-07-01", end="2026-10-01"), grain=mv.grain("month"), dimensions=[region],)
association = session.correlate(current, order_count_frame)test = session.hypothesis_test(current, baseline)projection = session.forecast(current, horizon=3, model="drift")quality = session.assess_quality(current)
association.show()test.show()projection.show()quality.show()四个结果回答的是不同问题:收入与订单量是否一起变化、当前与基线的差异是否通过统计检验、未来三个 周期可能怎样变化,以及当前数据是否存在已知质量问题。它们不能互相替代。
处理事件路径与生命周期
对于漏斗、到达耗时、状态分布、状态变化、停留时长和不允许出现的状态变化,Marivo 提供 events.* 与
lifecycle.* 专项动作。它们使用语义层中定义的 Event、模式和 StateModel,并产出含义明确的
EventFrame 或 LifecycleFrame。业务过程因此不必先被压扁成几个临时指标,再丢失事件顺序和状态含义。
假设 checkout_pattern 已经由语义层中的“创建购物车”和“支付成功”两个 Event 组成,一次漏斗分析
可以这样开始:
journeys = session.events.match( pattern=checkout_pattern, cohort_window=mv.time_scope( start="2026-07-01T00:00:00Z", end="2026-07-08T00:00:00Z", ), completion_through="2026-07-15T00:00:00Z", matching=mv.first_per_subject(),)funnel = session.events.funnel(journeys)funnel.show()生命周期分析使用相同思路,只是输入变成已经定义好的 StateModel:
order_lifecycle = session.catalog.state_models.get( "commerce.order_lifecycle")history = session.lifecycle.replay( order_lifecycle, window=mv.time_scope( start="2026-07-01T00:00:00Z", end="2026-08-01T00:00:00Z", ), seed=mv.from_inception(),)state_counts = session.lifecycle.distribution( history, at=("2026-07-31T00:00:00Z",),)state_counts.show()第一段保留用户经过各步骤的顺序,第二段保留订单在不同状态之间的变化。Agent 不需要先把过程压成 几列临时数字,之后再猜每个数字代表什么。
这些能力不是越多越好。一个操作只有在能够说清输入是什么、输出是什么、失败时怎样说明,以及会留下 什么证据时,才值得成为核心 DSL。仅仅节省几行代码,或者把一套常见步骤藏进一个大函数,并不足以 形成稳定的分析动作。
约束需要边界,也需要逃生通道
Analysis DSL 不可能预先覆盖所有分析方法,也不应该把 Agent 困在一个封闭系统里。真实调查会遇到尚未 定义的业务对象、特殊的统计方法,以及只在某个行业中使用的计算。完全禁止这些需求,只会促使 Agent 绕开系统;更诚实的做法是保留明确的逃生通道,并让离开类型化分析的时刻清楚可见。
Marivo 提供两条方向不同的终止路径:md.raw_sql(...) 从数据源侧处理语义定义尚未覆盖的临时诊断,
frame.to_pandas() 则从已经成立的类型化结果出发,进入更广泛的 Python 生态。它们都提供必要的自由,
但产出都不能重新接入 Analysis DSL,冒充由同一套约束产生的 frame。
raw_sql:定义缺失时的有限诊断
如果 Agent 找不到问题需要的 metric、dimension、关系、Event 或 StateModel,类型化分析应该停在缺口
处,而不是悄悄换一列数据。项目允许临时诊断时,可以使用 md.raw_sql(...) 执行一条只读 SQL,并在
reason 中写明缺少什么、这次查询为了什么,以及使用了哪些临时假设。
import marivo.datasource as mdimport marivo.semantic as ms
diagnostic = md.raw_sql( ms.ref.datasource("warehouse"), """ SELECT sales_region, SUM(net_amount) AS provisional_revenue FROM orders WHERE completed_at >= DATE '2026-07-01' AND completed_at < DATE '2026-10-01' AND status = 'completed' GROUP BY sales_region """, reason=( "sales.revenue 与 sales.orders.region 尚未定义;" "临时估算第三季度地区收入,并假设 net_amount 是净收入" ), limit=100,)diagnostic.show()raw_sql 会拒绝空的 reason、多条语句和写操作,并限制返回的行数与执行时间。这里的 limit 只限制
返回结果,不代表底层查询一定便宜,因此 SQL 本身仍应尽量小。它返回的是终止式 RawSqlResult,没有
metric 身份、类型化后续动作和重新进入 Analysis DSL 的资格。由临时假设得到的结果也必须明确标为
临时结果,不能因为查询成功就被当成正式业务定义。
更重要的是,Agent 使用 raw_sql 不只是一次技术选择,也是一个产品信号:当前语义层不足以回答这个
问题。 如果同一个缺口反复出现,正确的后续不是把 SQL 收藏成常用模板,而是补上缺失的业务对象、
关系或时间定义,让以后相同的问题回到类型化主路径。
to_pandas:从可靠输入进入 Python 生态
frame.to_pandas() 位于另一端。它要求 Agent 先通过 observe、compare 或其他类型化动作得到 frame,
因此 metric、范围、粒度和维度已经明确。随后它返回一份独立的 pandas DataFrame,让 Agent 使用
项目环境中可用的 pandas、SciPy、statsmodels、scikit-learn 或行业库,完成 Analysis DSL 尚未提供的
统计、建模与可视化。
from scipy.stats import median_abs_deviation
rows = current.to_pandas()
# metric、时间范围和地区已经由 current 固定;# 这里使用 Python 生态计算 DSL 尚未提供的稳健离散程度。robust_spread = ( rows.groupby("region")["revenue"] .apply(median_abs_deviation) .sort_values(ascending=False))这条通道保留了 Python 最有价值的开放性。Marivo 不需要把每一种统计方法都包装成核心操作,Agent 也
不必为了使用一个专业库而放弃前面已经确认的业务含义。但边界仍然存在:robust_spread 是自定义计算
结果,不是 Marivo frame,不能再交给 compare 或 attribute。Agent 应把转换和后续计算放在同一份
可重跑脚本中,并单独说明所用方法、假设和限制。
调用 to_pandas() 本身不等于语义层有错。一次性的专业方法可能只是超出了 DSL 的覆盖范围。但它仍然
值得被视为信号:如果 Agent 导出数据是为了重新计算收入、补业务筛选或自行连接实体,说明语义定义
不完整;如果很多调查都重复同一种自定义统计,则说明 Analysis DSL 可能缺少一个值得类型化的动作。

逃生通道的价值,不只是让少数问题可以继续完成。它还让系统知道约束在哪里失效、下一步应该补什么。 偶发的专业计算可以留在 Python 生态;反复出现的业务含义应回到语义层,反复出现的通用分析动作则应 进入 DSL。这样,开放性不会削弱约束,反而会成为约束持续改进的来源。
Analysis DSL 应该产出什么
DSL 最不应该产出的是一段看似完整的最终结论。结论需要综合业务背景、多个分析步骤和程序无法直接 判断的限制;如果运行时替 Agent 写完,事实与判断会再次混在一起。
Marivo 的核心操作产出含义明确、生成后不再被改写的分析结果。每份结果至少保留:
- 它是哪一种分析结果,以及数据是单个数、时间序列、分组数据还是同时包含时间与分组;
- 使用了哪些语义对象、范围、粒度和分析参数;
- 由哪个动作和哪些前序结果得到;
- 输出列、数据质量和是否已经完整保存;
- 它还能接哪些后续动作,以及有哪些使用限制。
每种操作都有明确的结果,这让一次成功执行成为可依赖的承诺。compare 成功就得到可以检查的
DeltaFrame;两份数据不能公平比较时就停止,而不是返回一张结构相近的表。Agent 可以在不同轮次
恢复同一结果,人也能查看当时究竟比较了什么。
但这些含义明确的分析结果还不是企业可以直接依赖的最终证据。一次调查往往有多份 frame,最终陈述 则会说“收入下降主要来自北区”或“这个变化与渠道结构有关”。系统还需要回答:这句话由哪几个结果 支持?引用的是哪一项事实?贡献有没有被说成原因?有限摘要省略了什么?后来的人能否从结论回到原始 结果核查?
这正是下一篇要讨论的 Evidence Engine。Analysis DSL 负责把每一步计算变成含义和范围都清楚的 分析结果;Evidence Engine 要进一步把结果中的事实变成可定位、可追溯、不会被悄悄扩大含义的证据。 前者约束“这一步做了什么”,后者回答“最终这句话凭什么成立”。
Marivo 的源码与最新进展可以在 GitHub 查看;如果你想先 看看这些动作怎样进入一次真实调查,可以从让 Agent 完成第一次分析 开始,也可以回看上一篇语义层:把业务含义变成 Agent 可以依赖的代码契约。