在 Agent 里使用 Dosi¶
要让 AI Agent 回答数据问题,有两条路。一条是把数仓 schema 喂给它、让它自己写 SQL,也就是常说的 NL2SQL;另一条是调用一个已经知道指标含义的语义层。Dosi 内置 了 MCP 服务,走的是第二条:任何 MCP 客户端都能发现指标、编译成数仓 SQL 并执行。
本页与具体 Agent 无关,讲清楚 Agent 拿到了什么、为什么答得更准、以及怎么接得安 全。想看一步步的实操,按客户端挑一篇:Claude Code、 Codex 或 OpenCode。
Agent 拿到了什么¶
十个工具,分三组,用的都是 CLI 和 REST API 那套
MetricQuery 形状:
| 阶段 | 工具 | Agent 由此得知 |
|---|---|---|
| 发现 | list_metrics、list_dimensions、list_datasets、describe_metric、list_connections、get_capabilities |
有哪些指标、各自什么含义、能按哪些字段拆分 |
| 预览 | compile_sql、explain_query |
将要执行的 SQL 或逻辑计划,执行之前就能看到 |
| 执行 | run_query |
JSON 行结果,并报出实际生效的行数上限 |
| 编写 | validate_model |
一次模型改动在服务端引擎模式下是否合法 |
词表小而封闭:指标有名字,group_by 字段写成 dataset.field(外加保留的
metric_time)。只要 Agent 待在这个词表里,就编不出一个不存在的列,也不会悄悄
选错表。
为什么比裸 schema 的 NL2SQL 更准¶
论点不是"模型写不好 SQL",而是:一个业务问题对应的正确 SQL,无法从列名推导出
来。这份知识必须有人先写下来一次——这正是语义模型的作用。由此有四点区别,都能
在自带的 orders 示例上看到。
口径由模型定,不靠猜¶
orders 模型里的 revenue 是 SUM(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 status、LIMIT 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 下来就得到同一套工具:
{
"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-execute。run_query变成工具级的forbidden错 误,发现和编译仍然可用。共享的只读部署就该是这个形状:Agent 做什么都碰不到数 仓。 - 客户端 —— 工具白名单。评审场景只放行
compile_sql和explain_query、 不放行run_query;分析场景全放开。多数客户端把 MCP 工具命名为mcp__<server>__<tool>,所以"只用 Dosi"的 Agent 就是放行mcp__dosi__*再禁 掉 shell。
任何 HTTP 部署都建议加 --auth-token(环境变量 DOSI_SERVER_TOKEN)。它每个请
求都校验——没有"认证一次的会话"——这正是无状态、可负载均衡的部署所需要的。
让 Agent 优先走指标¶
光有工具,拦不住一个同时还有 shell 权限的 Agent 自己去写 SQL。下面这几条规则可
以。把它放进 CLAUDE.md、AGENTS.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 500,row_limit 上限 5000 |
在查询里做聚合;读 row_limit_applied |
| Agent 编造指标名 | 没有配提示词规则 | 加上前面那段 snippet |
下一步¶
-
完整实操:注册、提问、读轨迹,每个数字都能手算核对。
-
每个工具、入参形状、协议,以及错误契约。
-
从示例 DuckDB 文件换到 Postgres、Snowflake、StarRocks 等。