用 DeepSeek 给 DuckDB 配一个自然语言转 SQL 代理:config.toml 骨架与验证步骤
1. 为什么要在本地 DuckDB 上折腾自然语言转 SQLDuckDB 这两年在本地分析场景里出镜率很高单文件、零服务、直接对 Parquet/CSV 跑 SQL做数据探索特别顺手。但真到日常用的时候痛点也很明显——你脑子里想的是「上个月每个渠道的复购率是多少」手上却要把它翻译成SELECT channel, COUNT(DISTINCT CASE WHEN ...) ... GROUP BY ...。表名记不住、字段拼错、窗口函数写一半卡住这些都在消耗注意力。自然语言转 SQLText-to-SQL就是来解决这个翻译环节的。它的定位不是替代你写 SQL而是把「意图 → 可执行查询」这段重复劳动自动化让你把精力放在验证结果对不对上。适合谁适合已经有一份本地 DuckDB 数据、想用自然语言快速取数、又不想把数据传到云端的分析师和工程师。我这次的做法是用 DeepSeek 作为推理后端搭一个面向 DuckDB 的 Text-to-SQL 代理。核心思路参考了 agentic-duckdb-analyst 那套「不信任第一个答案」的验证阶梯——先执行、再检查结果合理性、必要时做意图对齐和自洽性抽样最后要么给出查询要么诚实地说「我无法验证」。本文给出一份可复制的config.toml骨架、代理启动命令以及用示例问题验证转换是否正确的具体动作帮你把最小可用链路跑通。需要说明的是本文聚焦的是「本地 DuckDB DeepSeek 推理」这条链路不涉及任何网络访问工具所有请求都走标准 HTTPS API。2. 前置准备TaoToken 接入与 DeepSeek 模型选择代理要调用 DeepSeek得先有一个能用的 API 入口。我用的是 TaoToken 作为统一接入层它把模型调用收敛成一个 OpenAI 兼容的接口配置里只需要填 base_url 和 key切换模型时改一个字段就行不用动代理代码。先到控制台创建 API Key然后确认你要用的模型名。DeepSeek 系列在推理和代码生成上表现稳定Text-to-SQL 这种「理解意图 生成结构化文本」的任务正好对口。如果你后面想对比不同模型的效果只要在配置里换model字段即可代理逻辑不用改。几个入口按用途分一下创建和管理密钥https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接入文档接口格式、参数说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite想先在网页里试模型对话、验证提示词效果https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite长期跑编码/Agent 任务考虑套餐https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteAPI 基地址统一用https://taotoken.net/api这个不加 UTM 参数直接写进配置。拿到 key 之后先别急着写代理用一条 curl 确认链路通curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: deepseek-chat, messages: [{role: user, content: 回复 ok}], temperature: 0 }返回里有choices[0].message.content就说明 key 和网络都正常。这一步很关键因为后面代理报错时你要能区分是「模型调用失败」还是「SQL 生成逻辑有问题」。3. 可复制的 config.toml 骨架代理的配置我全部收在一个config.toml里包括模型、数据库路径、验证阶梯的开关和成本参数。这样做的原因是验证策略应该是显式的一个简单问题只花一次调用昂贵的自洽性抽样只在必要时触发而不是每次都全量跑。# config.toml —— DuckDB Text-to-SQL 代理配置骨架 [llm] # TaoToken 统一接入OpenAI 兼容格式 base_url https://taotoken.net/api/v1 api_key_env TAOTOKEN_API_KEY # 从环境变量读别硬编码 model deepseek-chat temperature 0.0 # 生成 SQL 时用 0保证可复现 max_tokens 1024 timeout_s 60 [llm.pricing] # 每百万 token 的美元价格按你实际费率填默认 0 表示不计算成本 price_in_per_mtok 0.0 price_out_per_mtok 0.0 [database] path ./data/analytics.duckdb # 本地 DuckDB 文件 read_only true # 代理只读防止误写 max_rows_preview 50 # 执行后预览行数 [schema] # 模式检索先按词法匹配表名/列名匹配不到再回退全量模式 retrieval lexical include_sample_values true max_tables_in_prompt 20 [verify] # 验证阶梯从便宜到昂贵按需升级 level0_execution_guard true # 解析/列名/类型错误错误文本回喂自修正 level1_result_sanity true # 空结果 / 全 NULL / 退化结果检查 level2_intent_alignment on_anomaly # 层级1异常时把SQL反译回英文比对意图 level3_consistency on_unstable # 路径不稳时抽样N次按结果集聚类 consistency_samples 3 consistency_threshold 0.66 # 一致性低于此值视为不稳 max_repair_attempts 2 # 自修正最多重试次数 [output] trace_path ./traces/run.jsonl # 结构化追踪便于复盘 return_sql_on_decline true # 放弃时也返回它尝试过的SQL几个字段值得单独说。read_only true是硬性建议代理生成的 SQL 你没法逐条审查只读连接能挡住DROP/UPDATE这类意外。temperature 0.0用于主生成但层级 3 的自洽性抽样需要真实的temperature 0才能拿到独立样本所以代理内部会在抽样时临时覆盖这个值。level2_intent_alignment和level3_consistency用字符串枚举而不是布尔是为了表达「按条件触发」这个语义——默认只在层级 1 发现异常时才升级避免每个问题都烧三次调用。把 key 放进环境变量export TAOTOKEN_API_KEYsk-你的key4. 代理启动与最小链路跑通配置就绪后代理的启动分两步先确认 DuckDB 能连上、模式能读出来再启动问答循环。先准备一个测试库。如果你手头没有现成的用 DuckDB 快速造一张表# seed.py import duckdb con duckdb.connect(./data/analytics.duckdb) con.execute( CREATE OR REPLACE TABLE orders AS SELECT i AS order_id, (i % 5) 1 AS channel_id, (i % 100) 1 AS customer_id, ROUND(random() * 500, 2) AS amount, DATE 2024-01-01 INTERVAL (i % 180) DAY AS order_date FROM range(1, 1001) AS t(i) ) con.close() print(seeded)跑一下python seed.py库和表就有了。接着启动代理# 交互式问答 python -m analyst --config config.toml ask # 单次提问直接看生成的 SQL 和执行结果 python -m analyst --config config.toml ask 每个渠道的订单总金额是多少代理内部的处理顺序是这样的读模式 → 词法检索相关表 → 拼提示词 → 调 DeepSeek 生成 SQL → 层级 0 执行保护 → 层级 1 结果合理性 → 按需升级 → 返回。你可以在traces/run.jsonl里看到每一步的耗时和中间产物排查问题时非常有用。如果你想把代理嵌到自己的脚本里核心调用大概长这样from analyst.agent import Agent from analyst.config import Settings settings Settings.from_toml(config.toml) agent Agent(settings) result agent.ask(每个渠道的订单总金额是多少) print(SQL:, result.sql) print(行数:, len(result.rows)) print(是否放弃:, result.gave_up) print(置信度:, result.confidence)result.gave_up是这套设计里我最看重的字段。当所有验证层级都耗尽、代理仍然无法确认答案时它会返回gave_upTrue而不是硬编一个看起来合理的 SQL。一个经过校准的「我无法验证这一点」比一个自信的错误数字有用得多。5. 验证自然语言转 SQL 是否正确跑通不等于正确。Text-to-SQL 最容易骗人的地方是SQL 语法没错、能执行、返回了数字但回答的根本不是你问的问题。所以验证要分两层——先看结果集对不对再看意图有没有对齐。第一层结果集等价性。不要用 SQL 字符串比对两个写法完全不同的查询可能同样正确。正确做法是比结果集把代理返回的行和人工写的标准答案行做多重集合比较浮点数给一点容差顺序无关。比如问「每个渠道的订单总金额」你自己写一条参考 SQLSELECT channel_id, ROUND(SUM(amount), 2) AS total FROM orders GROUP BY channel_id ORDER BY channel_id;然后和代理的输出逐行对。如果数值一致、分组一致这条就算过。第二层意图对齐。有些错误结果集看起来正常但语义偏了。典型例子是「前五名客户」——按消费额排按订单数排按余额排这属于有歧义的问题正确行为是认可任何一种站得住的解读而不是因为措辞扣分。代理的层级 2 会把生成的 SQL 反译回自然语言再和你的原始意图比对发现偏差就触发修正。第三层自洽性。对同一个问题用temperature 0抽 3 个独立样本按结果集等价性聚类。如果三个样本聚成两类以上说明这条路径不稳代理会标记低置信度。这一步成本最高所以默认只在层级 1 发现异常时才触发。验证时我建议准备一个小型标准答案集覆盖三类问题可回答的有唯一正确结果、不可回答的模式里根本没有这个列或概念正确行为是拒绝、有歧义的多种合理解读。跑完看三个指标执行准确率、拒绝召回率不可回答问题里拒绝了多少、拒绝精确率所有拒绝里有多少是恰当的。拒绝精确率低说明代理在可回答的问题上也退缩了这比答错还影响体验。一个具体的验证动作拿「客户 1 的电子邮件地址」去问。你的 orders 表里根本没有 email 列正确行为是gave_upTrue并说明「模式中没有该字段」而不是编一个SELECT email FROM customers。如果它编了说明层级 0 的列名校验没生效回去检查level0_execution_guard和模式描述是否完整。6. 本篇常见错误排查报错401 Unauthorized或invalid api key。先确认TAOTOKEN_API_KEY真的导出了echo $TAOTOKEN_API_KEY能看到值。再确认base_url结尾是/v1TaoToken 的接口是 OpenAI 兼容格式路径写错会直接 404 或 401。如果 key 是在控制台刚建的注意别把前后空格复制进去。报错Catalog Error: Table with name xxx does not exist。这是层级 0 抓到的典型错误说明模型生成的表名和实际模式对不上。检查config.toml里database.path指向的库是不是你 seed 的那个以及schema.retrieval是否把相关表检索进了提示词。如果表很多、词法检索没匹配上可以临时把max_tables_in_prompt调大或者把retrieval换成更宽松的策略。SQL 能执行但结果是空的。这通常触发层级 1。先别急着怪代理空结果可能是合法的——比如筛选条件确实没匹配到数据。代理会把它标记为异常并可能升级到层级 2多花一次调用但不会给出错误答案。如果你确定空结果是正常的可以在配置里放宽层级 1 的触发条件。代理频繁gave_up。看traces/run.jsonl里是哪一层在拒绝。如果是层级 3 一致性太低可能是temperature抽样时没真正生效或者问题本身歧义太大。如果是层级 0 反复修不好多半是模式描述太简略模型不知道列的含义可以在模式里补上列注释。成本比预期高。检查level2_intent_alignment和level3_consistency的触发条件。默认是on_anomaly和on_unstable如果你改成了always每个问题都会跑满四层调用次数翻好几倍。另外price_in_per_mtok/price_out_per_mtok默认是 0不填的话成本数据全是 0你会误以为没花钱。中文问题生成的 SQL 字段名对不上。这是模式检索弱导致的。词法检索对「余额」和balance这种中英不对应的情况会失手回退到全量模式后模型容易猜错列。短期办法是在模式描述里给关键列加中文别名长期可以换成基于嵌入的检索接口是预留好的替换score_table即可其他逻辑不用动。7. 把链路接进你的工作流最小链路跑通之后下一步是让它真正省时间。我的做法是把代理包成一个命令行工具日常取数直接问生成的 SQL 顺手存进一个queries/目录攒多了就是自己的查询库。遇到代理放弃的问题正好是模式描述需要补注释的地方——它拒绝得越准你对数据的理解反而越清晰。如果你要长期跑编码或 Agent 类任务可以看看 Coding Plan 套餐把模型调用成本压下来https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite需要新建或轮换密钥时控制台在这里https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接口参数和返回格式的细节以接入文档为准https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite想先在网页里对比不同模型对同一句自然语言的转 SQL 效果用模型对话最快https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite最后留一个我踩过的坑别一上来就追求高准确率先把「拒绝」这条路走通。一个敢说「我无法验证」的代理比一个每次都自信给数的代理在真实取数场景里可靠得多。