用 Claude Code 提问¶
本页把 Dosi 注册成 Claude Code 的 MCP 服务,然 后用自然语言提问,看着 Agent 通过指标作答,而不是自己猜 SQL。所有数字都小到 可以手算核对。
大约 15 分钟,会消耗少量模型额度。
前置条件
已经安装好 Dosi,有 dosi-server 可执行文件;
claude --version 输出 2.1 或更新;duckdb CLI 在 PATH 上。
$DOSI_EXAMPLES 指自带示例模型所在的位置——用安装脚本的话是
~/.local/share/dosi/examples。
用的是自带的 orders 模型:三张表、五个指标、六行数据,和
第一个指标查询那篇是同一个。
第 1 步 —— 灌一个数据库¶
MCP 服务要在真实数据库上执行,先准备一个:
别跳过这步
下一步不带 --db 的话,服务端的本地 DuckDB 是内存库且没有灌数据:
compile_sql 能用,run_query 会报表不存在——初次运行最常见的困惑。
第 2 步 —— 启动 MCP 服务¶
$ dosi-server --model $DOSI_EXAMPLES/orders/model.yaml --db orders.duckdb
INFO dosi_server::bootstrap: model compiled model=.../orders/model.yaml mode="datus" datasets=3 metrics=5
INFO dosi_server: listening on http://127.0.0.1:8081
MCP 挂在 POST /mcp 上——顶层路径,不在 /v1 之下。先不带 Agent 自检一下:
$ curl -s -X POST localhost:8081/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
10
要用编译好的二进制,不要用 cargo run:在 MCP 客户端下,一次编译看起来就像卡
死。
第 3 步 —— 注册到 Claude Code¶
$ claude mcp add --transport http dosi http://127.0.0.1:8081/mcp
Added HTTP MCP server dosi with URL: http://127.0.0.1:8081/mcp to local config
确认客户端真的连得上——claude mcp list 会对每个服务做健康检查:
$ claude mcp list
dosi: http://127.0.0.1:8081/mcp (HTTP) - ✔ Connected
$ claude mcp get dosi
dosi:
Scope: Local config (private to you in this project)
Status: ✔ Connected
Type: http
URL: http://127.0.0.1:8081/mcp
服务需要 token 时(--auth-token,环境变量 DOSI_SERVER_TOKEN),注册时一并传
入——每个请求都会校验:
claude mcp add --transport http dosi http://127.0.0.1:8081/mcp \
--header "Authorization: Bearer $DOSI_SERVER_TOKEN"
第 4 步 —— 问第一个问题¶
交互式的话直接把问题打出来即可。这里用 headless 形式,输出可复现:
$ claude -p "What is revenue by order status?" \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__list_dimensions,mcp__dosi__compile_sql,mcp__dosi__run_query"
Revenue by order status (`revenue` = SUM of `orders.amount`, from the server's
local DuckDB):
| status | revenue |
|-----------|--------:|
| completed | 350.00 |
| cancelled | 100.00 |
Total 450.00 across 2 statuses. Note this includes cancelled orders — if you
want completed-only, I can re-run with a `status = 'completed'` filter.
对着种子数据核一遍:completed 是 100 + 50 + 80 + 120 = 350,cancelled 是 30 + 70 = 100。对得上——注意 Agent 还说明了它用的口径,因为口径来自模型, 不是它自己猜的。
--allowedTools 列出的就是 Dosi 的工具面,命名为 mcp__<server>__<tool>。明确
写出来,意味着 Agent 没有 shell、也无法手写 SQL:它答出来的任何东西,都是走你的
指标算的。
第 5 步 —— 读懂轨迹¶
那一个回答背后是四次工具调用。整套机制就在这里:
1. list_metrics —— 能问什么(只保留关键字段):
[
{"name": "revenue", "kind": "aggregate", "datasets": ["orders"],
"description": "Total order amount"},
{"name": "order_count", "kind": "aggregate", "datasets": ["orders"],
"description": "Number of orders"},
{"name": "unique_customers", "kind": "aggregate", "datasets": ["orders"],
"description": "Distinct purchasing customers"},
{"name": "avg_order_value", "kind": "ratio", "datasets": ["orders"],
"description": "Revenue per order (ratio)"},
{"name": "total_margin", "kind": "expression", "datasets": ["orders", "products"],
"description": "Revenue minus cost (expression over two aggregates)"}
]
2. list_dimensions —— 能按什么拆。字段写成 dataset.field,时间维度带标记
(十二行里取三行):
[{"name": "orders.status", "is_time": false, "time_granularity": null},
{"name": "orders.order_date", "is_time": true, "time_granularity": null},
{"name": "customers.region", "is_time": false, "time_granularity": null}]
3. compile_sql —— 执行之前,先看它将要跑的 SQL:
{"dialect": "duckdb",
"sql": "SELECT orders.status AS status, SUM(orders.amount) AS revenue FROM main.orders AS orders GROUP BY orders.status"}
4. run_query —— 拿到行:
{"dialect": "duckdb",
"sql": "SELECT orders.status AS status, SUM(orders.amount) AS revenue FROM main.orders AS orders GROUP BY orders.status LIMIT 500",
"columns": ["status", "revenue"],
"rows": [{"status": "completed", "revenue": 350.0},
{"status": "cancelled", "revenue": 100.0}],
"row_count": 2, "row_limit_applied": 500}
Agent 只说了一个指标名和一个维度。它没写 SUM、没选表、没挑连接方式、也没编过
滤条件。注意 row_limit_applied:结果要进 LLM 上下文,所以 run_query 默认最多
返回 500 行(上限 5000),并且总会报出实际生效的上限——Agent 据此能分辨截断页和
完整结果。
第 6 步 —— 一个跨两张表的指标¶
问一下总毛利:
$ claude -p "What is our total gross margin?" \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__run_query"
**Total margin: 360.0** (on revenue of 450.0 — an 80% margin ratio).
[…]
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
450 − (20 + 10 + 60) = 360,与定义完全一致。同样的想法手写成连接——
orders JOIN products 之后 SUM(o.amount - p.unit_cost)——得到 270:连接让
每个商品行按订单数重复,成本侧从 60 被放大到 180。引擎的计划要挡的就是这种漂
移:无论指标定义成什么,加一个维度、加第二张表,都不该悄悄改变它的值。
引擎保证的是定义,不是业务逻辑
Dosi 保证你在任何数仓上都能可复现地拿到这个模型的 total_margin。至于这
个定义在业务上对不对,那是建模者的判断——而本示例的定义是刻意简化的(它把整
个商品目录的单位成本相加,不按销量加权,因此并不是 COGS)。Claude 常常会主
动指出这一点,而这正说明问题:SQL 是可见的,所以定义可评审,不会埋在某个人
的临时查询里。参见
语义层解决不了什么。
第 7 步 —— 看它自我纠正¶
错误是刻意做成结构化的。问一个不存在的指标:
$ claude -p "Show me revenu by month" \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__list_dimensions,mcp__dosi__run_query"
**Revenue by month** (metric `revenue` = `SUM(orders.amount)`, grouped on
`orders.order_date` at month grain): 2024-01 150.00, 2024-02 230.00,
2024-03 70.00. Total 450.00 across 3 months.
1 月 = 100 + 50 = 150,2 月 = 80 + 30 + 120 = 230,3 月 = 70——拼错了 也答对了。多数时候 Agent 根本碰不到这个错误:它会先列指标,从那里读到正确拼写。 真猜错的时候,工具调用会失败得很响亮、也很有用:
{"error": {"code": "unknown_metric", "message": "unknown metric \"revenu\"",
"metrics": ["revenu"],
"candidates": ["revenue", "order_count", "unique_customers",
"avg_order_value", "total_margin"]}}
candidates 就是整个纠正机制:Agent 不会盲目重试,也不会退回去自己编 SQL,而是
挑一个确实存在的名字。要改的不是名字而是查询形状时,错误里给的是散文式指引:
{"error": {"code": "grain_on_non_time_dimension",
"message": "orders.status is not a time dimension; a grain cannot be applied",
"suggested_retry": "drop the :grain suffix or mark the field with dimension.is_time: true"}}
第 8 步 —— 把配置提交给团队¶
自己手动注册没问题;把 .mcp.json 提交进仓库,则是让所有 clone 的人都拿到同一套
工具。放在项目根目录:
{
"mcpServers": {
"dosi": {
"command": "dosi-server",
"args": ["--model", "examples/orders/model.yaml",
"--db", "orders.duckdb", "--mcp-stdio"]
}
}
}
--mcp-stdio 让 Claude Code 自己拉起服务——不占端口、没有长驻进程、日志走
stderr。相对路径按项目根目录解析,所以把示例模型复制到旁边(或者用绝对路径),并
先按第 1 步灌好 orders.duckdb。项目级的服务首次使用需要批准:
$ claude mcp list
dosi: dosi-server --model examples/orders/model.yaml --db orders.duckdb --mcp-stdio - ⏸ Pending approval (run `claude` to approve)
起一次 claude 批准掉,之后两种传输方式的表现完全一致。
第 9 步 —— 让 Claude 优先走指标¶
对一个同时有 Bash 的 Agent,光有工具拦不住它手写 SQL,提示词规则可以。把这
段加进项目的 CLAUDE.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.
2. Preview: `compile_sql` before executing, and show me the SQL.
3. Execute: `run_query`.
Never invent a metric name — read the error's `candidates` and retry. Never
re-derive a metric by hand. If no metric covers the question, say so.
完整版连同每条规则的理由,见 在 Agent 里使用 Dosi。
第 10 步 —— 自己量一量¶
--output-format json 会把回答包在一个信封里,里面带着这次回答的成本:
$ claude -p "What is revenue by order status?" \
--mcp-config .mcp.json \
--allowedTools "mcp__dosi__list_metrics,mcp__dosi__run_query" \
--max-turns 8 --output-format json | jq '{num_turns, duration_ms, total_cost_usd}'
{
"num_turns": 5,
"duration_ms": 12206,
"total_cost_usd": 0.1271
}
(你的数字会不一样——模型、机器、措辞都会影响。)
这就是跟裸 schema NL2SQL 做 A/B 的原材料:同一批问题、同一个数据库,一臂给 MCP
工具,一臂只给 SQL shell,然后比正确率、轮次和 token。我们自己的那一版——连同入
库可评审的基线提示词——见基准测试。有个预期要先摆正:在这个
fixture 上,走指标的一臂赢在正确率,输在上下文体积,因为十个工具的定义比
三张表的 schema dump 还大,见代价是什么。
--mcp-config(替代已注册的服务)、--max-turns、--output-format stream-json
是把这类 harness 脚本化时会用到的参数。
你学到了什么¶
- 用 HTTP 和 stdio 两种方式注册了 Dosi,并用
claude mcp list做了健康检查。 - 用自然语言提问,拿到由受治理的指标定义算出来的答案,并逐个对着种子数据核过。
- 读懂了轨迹:发现 → 预览 → 执行,全程没有手写 SQL。
- 在
total_margin上看到了差别(360,不是 270),也看到 Agent 一轮就从错名 字里纠正回来。 - 用
--allowedTools收紧了权限,并知道了只读部署的另一种做法 (--disable-execute)。
下一步¶
-
传输方式、鉴权、只读部署、完整的提示词 snippet,以及排错清单。
-
把示例 DuckDB 文件换成 Postgres、Snowflake、StarRocks 等。
-
十个工具、各自的入参形状、协议,以及错误契约。