跳转至

在 Agent 里使用 Dosi

要让 AI Agent 回答数据问题,有两条路。一条是把数仓 schema 喂给它、让它自己写 SQL,也就是常说的 NL2SQL;另一条是调用一个已经知道指标含义的语义层。Dosi 内置 了 MCP 服务,走的是第二条:任何 MCP 客户端都能发现指标、编译成数仓 SQL 并执行。

本页与具体 Agent 无关,讲清楚 Agent 拿到了什么、为什么答得更准、以及怎么接得安 全。想看一步步的实操,按客户端挑一篇:Claude CodeCodexOpenCode

Agent 拿到了什么

十个工具,分三组,用的都是 CLIREST API 那套 MetricQuery 形状:

阶段 工具 Agent 由此得知
发现 list_metricslist_dimensionslist_datasetsdescribe_metriclist_connectionsget_capabilities 有哪些指标、各自什么含义、能按哪些字段拆分
预览 compile_sqlexplain_query 将要执行的 SQL 或逻辑计划,执行之前就能看到
执行 run_query JSON 行结果,并报出实际生效的行数上限
编写 validate_model 一次模型改动在服务端引擎模式下是否合法

词表小而封闭:指标有名字,group_by 字段写成 dataset.field(外加保留的 metric_time)。只要 Agent 待在这个词表里,就编不出一个不存在的列,也不会悄悄 选错表。

为什么比裸 schema 的 NL2SQL 更准

论点不是"模型写不好 SQL",而是:一个业务问题对应的正确 SQL,无法从列名推导出 来。这份知识必须有人先写下来一次——这正是语义模型的作用。由此有四点区别,都能 在自带的 orders 示例上看到。

口径由模型定,不靠猜

orders 模型里的 revenueSUM(orders.amount),覆盖全部订单,取消的也 算。只给表权限的 Agent 被问到"总营收",通常会补一句 WHERE status = 'completed' ——这个猜测很合理,但答案不一样:350,而不是 450。两条 SQL 都不算错,只有一 条符合公司对营收的定义。走指标层,所有 Agent、看板、notebook 拿到同一个数,而定 义写在 YAML 里可评审,不必每问一次就重新发明一遍。

不会跨表重复计数

total_margin 的定义是 SUM(orders.amount) - SUM(products.unit_cost):两个聚 合,分别落在两张表上。Dosi 的编译方式是先各自聚合,再合并:

WITH m0 AS (SELECT SUM(orders.amount)      AS orders_amount_sum       FROM main.orders   AS orders),
     m1 AS (SELECT SUM(products.unit_cost) AS products_unit_cost_sum  FROM main.products AS products)
SELECT m0.orders_amount_sum - m1.products_unit_cost_sum AS total_margin
FROM m0 CROSS JOIN m1

结果是 360。而手写时最自然的写法——先把 orders 连到 products,再 SUM(o.amount - p.unit_cost)——得到 270:连接让每个商品行按订单数重复,成本 那一侧被放大了。这就是不会静默重复计数那条保证在起 作用:扇出(fan-out)是连接图的性质,Agent 从 schema dump 里看不出来。

粒度和去重不会搞错

unique_customers 的定义是 COUNT(DISTINCT orders.customer_id)。按月拆分,示例 数据上是 1 / 2 / 1;换成普通的 COUNT(customer_id) 就变成 2 / 3 / 1。这个区别写 在指标定义里,Agent 不必重新发现,也就不会漏掉。

换数仓不用改问题

同一个请求会按目标方言编译成该数仓真正支持的写法:DuckDB 得到 DATE_TRUNC('MONTH', …),而 MySQL 没有 DATE_TRUNC,得到的是 STR_TO_DATE(DATE_FORMAT(…, '%Y-%m-01'), '%Y-%m-%d')。手写 SQL 的 Agent 得记住 每种数仓的日期写法;调 compile_sql 的只需要换一个 dialect

代价是什么

做这件事的理由是准确率。成本这一侧比通常的宣传更微妙,这里写实测结果,而不是写 着好听的说法。

有两项确实变便宜了:

  • 编译发生在 Rust 里,不烧 token。规划连接图、渲染方言 SQL 大约一毫秒,在模 型之外完成——实测且确定。
  • Agent 不再探查数据。裸 SQL 的会话里,SELECT DISTINCT statusLIMIT 5 试探、"这列是不是编码"之类的确认要花掉不少轮次。走指标层没什么可探的;名字写 错时错误里直接带 candidates,一轮改对,而不是进入一段调试循环。

但有一项不便宜,做预算前值得知道:在 schema 上,MCP 这条路吃掉的模型上 下文更多,不是更少。十个工具的定义(含完整的指标查询形状)是每次调用都要付 的固定成本,在示例这种三张表的模型上,它比整份 schema dump 还大。我们自己的实测 里,语义层这一臂在该 fixture 上轮次更多、上下文更大。

这笔账会随数仓变大而反转:工具表面积恒定,schema dump 随列数增长,探查轮次随编 码列增长。所以"更省"是你自己 schema 规模的函数,需要实测—— 我们用的 harness 可以直接喂你自己的问题。

两臂的准确率与成本实测,连同入库可评审的基线提示词,见 基准测试

它解决不了什么

把边界说清楚,因为这些才决定它适不适合你:

  • 从措辞映射到指标名,仍然是 Agent 的活list_metrics 暴露的是指标名和描 述,还没有投影 OSI 模型里的 ai_context 同义词,所以一个说法只要在名字和 描述里都没出现过,就仍然可能对不上。
  • 模型没覆盖的问题,用这些工具答不出来。这是刻意的——拒答好过一个自信的错 数——但也意味着模型的覆盖面就是你的覆盖面。探索性的查询,还是留给裸 SQL。
  • 定义错了就到处都错。定义集中,爆炸半径也集中:把 model.yaml 当作要评审 的代码,并在 CI 里跑 dosi validate

接线

选传输方式

Streamable HTTP(POST /mcp stdio(--mcp-stdio
谁来启动 你自己,长驻进程 客户端拉起的子进程
适合 共享服务、多客户端、CI、容器 单个本地客户端,零配置
鉴权 --auth-token,每个请求都校验 进程边界
日志 照常 stdout/stderr 强制走 stderr,stdout 只有协议

两者提供完全相同的工具集,也都要求 --model。启动是 fail-fast 的:模型不合法就 在绑定端口或 stdio 开口之前退出。

指向数据

$ duckdb orders.duckdb < $DOSI_EXAMPLES/orders/seed.sql
$ dosi-server --model $DOSI_EXAMPLES/orders/model.yaml --db orders.duckdb

--db 实际上不是可选项

不带它时,服务端的本地 DuckDB 是内存库且没有灌数据compile_sql 正 常,run_query 报表不存在。这是初次运行最常见的意外。

要接真实数仓,传 --connections(一份 datasources: YAML 或 Datus 的 agent.yml),再让 Agent 从 list_connections 里挑一个配置,见 连接数仓

注册服务

多数客户端要么接受一条启动命令,要么接受一个 URL。通常最好把项目级配置文件提交 进仓库——同事 clone 下来就得到同一套工具:

.mcp.json
{
  "mcpServers": {
    "dosi": {
      "command": "dosi-server",
      "args": ["--model", "examples/orders/model.yaml",
               "--db", "orders.duckdb", "--mcp-stdio"]
    }
  }
}

同一件事的 HTTP 写法:

{
  "mcpServers": {
    "dosi": {
      "type": "http",
      "url": "http://127.0.0.1:8081/mcp",
      "headers": { "Authorization": "Bearer ${DOSI_SERVER_TOKEN}" }
    }
  }
}

相对路径按项目根目录解析,所以把示例模型复制进自己的仓库(或者 --model 直接写 绝对路径),并先把 orders.duckdb 灌好。若 dosi-server 不在客户端的 PATH 上,command 要写绝对路径;也绝不要指向 cargo run,MCP 客户端分不清编译和卡 死。

决定 Agent 能做什么

两个独立开关,可以叠加:

  • 服务端 —— --disable-executerun_query 变成工具级的 forbidden 错 误,发现和编译仍然可用。共享的只读部署就该是这个形状:Agent 做什么都碰不到数 仓。
  • 客户端 —— 工具白名单。评审场景只放行 compile_sqlexplain_query、 不放行 run_query;分析场景全放开。多数客户端把 MCP 工具命名为 mcp__<server>__<tool>,所以"只用 Dosi"的 Agent 就是放行 mcp__dosi__* 再禁 掉 shell。

任何 HTTP 部署都建议加 --auth-token(环境变量 DOSI_SERVER_TOKEN)。它每个请 求都校验——没有"认证一次的会话"——这正是无状态、可负载均衡的部署所需要的。

让 Agent 优先走指标

光有工具,拦不住一个同时还有 shell 权限的 Agent 自己去写 SQL。下面这几条规则可 以。把它放进 CLAUDE.mdAGENTS.md,或你的客户端对应的系统提示词里:

## Data questions

Answer data questions through the `dosi` MCP tools, never by writing SQL
against raw tables.

1. Discover: `list_metrics`, then `list_dimensions` for the breakdown fields
   (`dataset.field`, plus the reserved `metric_time`). Use `describe_metric`
   when a metric's meaning matters.
2. Preview: `compile_sql` (or `explain_query`) before executing, and show me
   the SQL you are about to run.
3. Execute: `run_query`. Omit `connection` for the server's local database, or
   pick one from `list_connections`.

Rules:
- Never invent a metric or dimension name. If a name is rejected, read the
  error's `candidates` / `suggested_retry` and retry with a valid one.
- Never re-derive a metric by hand — no ad-hoc `SUM`/`COUNT DISTINCT`, no
  hand-written joins or filters. The metric definition is the governed answer,
  and a hand-rolled equivalent will drift.
- If no metric covers the question, say so instead of approximating.
- Report the number the tool returned, plus the metric name and any filter
  applied, so the answer is auditable.
- `run_query` caps rows (`row_limit_applied`). Never present a capped page as
  a complete result — aggregate in the query instead.

这段文字与基准测试 harness 用的系统提示词是同一个文件 (tests/nl2sql/prompts/arm_b_mcp.md), 所以文档写的行为和实测的行为不会各走各的。

客户端

客户端 状态 说明
Claude Code 已支持,有完整实操 claude mcp add.mcp.json、headless 的 claude -p
Codex 已支持,有完整实操 codex mcp add、用户级 ~/.codex/config.toml、headless 的 codex exec。要先配好 default_tools_approval_mode = "approve" 和一条 AGENTS.md 规则,工具调用才跑得通
OpenCode 已支持,有完整实操 可提交的 opencode.json{env:VAR} token 替换、headless 的 opencode run
MCP Inspector 调试用 npx @modelcontextprotocol/inspector <binary> … --mcp-stdio
datus-agent 已支持 另有一个走同一引擎的原生 Python 适配器
其他 MCP 客户端 预期可用 任何支持 MCP 工具调用的客户端

Dosi 只暴露工具——没有 resources,也没有 prompts——所以客户端只要支持 MCP 工 具调用就够了。

排错

现象 原因 处理
run_query 报表不存在 内存库且没灌数据 灌一个文件库并传 --db
客户端显示服务连接失败 路径写成了 /v1/mcp、服务没起、或 cargo run 还在编译 MCP 在顶层的 POST /mcp;用编译好的二进制
400 且响应体为空 Host 头(DNS 重绑定防护) 代理或手搓请求时不要剥掉 Host
每次调用都 401 设了 --auth-token,客户端没带 bearer 头 补上;鉴权是每请求的,不是每会话
config 错误:Conflicting lock is held DuckDB 一个文件只允许一个写入者,已被别的进程占用 每个服务用各自的库文件,或先停掉那个进程
run_query 返回 forbidden --disable-execute 改用 compile_sql,或起一个不带该参数的服务
busy / timeout 执行信号量占满 / 触发执行超时 调大 --max-concurrent-executions--execute-timeout-secs,或把查询写轻
validate_model 在大模型上失败 /mcp 请求体上限 1 MiB 大模型改用 dosi validate 校验
结果看起来被截断 默认 LIMIT 500row_limit 上限 5000 在查询里做聚合;读 row_limit_applied
Agent 编造指标名 没有配提示词规则 加上前面那段 snippet

下一步

  • Claude Code


    完整实操:注册、提问、读轨迹,每个数字都能手算核对。

  • MCP 参考


    每个工具、入参形状、协议,以及错误契约。

  • 连接数仓


    从示例 DuckDB 文件换到 Postgres、Snowflake、StarRocks 等。