CSV 变 SQL:本地模型写查询,SQLite 验算
把 CSV 读进内存 SQLite,让本地模型只写一条 SELECT,再由数据库执行并打印结果。模型负责翻译问题,SQLite 负责给答案,数字不靠模型编。
本页目录
30 秒速查
- CSV 放
input/sales.csv,第一行是表头- 问:
node resources/csv-to-sql/query.mjs input/sales.csv "每个城市的总销售额"- 模型只写 SQL,SQLite 负责执行
- 先看打印的 SQL,再信下面的数字
- 3B 起步;0.5B 经常漏 GROUP BY
这篇教程解决什么问题
手里有一张销售表,想问“哪个城市卖得最好”。把 CSV 粘给聊天模型,它可能给你一段看起来对、实际加错行的数字。
更稳的分工是:模型只负责把问题翻译成 SQL,SQLite 负责算。算错了能查 SQL,算对了能重复跑。
这篇用 Node 自带的 SQLite,不装数据库服务,CSV 读完就进内存。
装本地模型
还是用 Ollama,拉一个 3B 模型:
ollama pull qwen2.5:3b
服务默认在 11434。不想装 Ollama,用 llama.cpp 的 server 也行,脚本走 OpenAI 兼容接口。
建三个文件
在 resources/csv-to-sql/ 下放三个文件:
csv-to-sql/
├── core.mjs
├── model.mjs
└── query.mjs
core.mjs 管 CSV 解析、建库、提示词、SQL 校验;model.mjs 发请求;query.mjs 是命令入口。
写核心模块
保存为 resources/csv-to-sql/core.mjs:
import { DatabaseSync } from "node:sqlite";
export function parseCsv(text) {
const input = text.replace(/^\uFEFF/, "");
const rows = [];
let row = [];
let field = "";
let quoted = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (quoted) {
if (char === '"' && input[index + 1] === '"') {
field += '"';
index += 1;
} else if (char === '"') {
quoted = false;
} else {
field += char;
}
} else if (char === '"') {
quoted = true;
} else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
} else if (char !== "\r") {
field += char;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
export function loadCsv(text) {
const [header, ...body] = parseCsv(text);
if (!header?.length) throw new Error("CSV 没有表头。");
const columns = header.map((name, index) => name.trim() || `column_${index + 1}`);
const rows = body
.filter((line) => line.some((value) => value.trim() !== ""))
.map((line) =>
Object.fromEntries(columns.map((column, index) => [column, line[index] ?? ""])),
);
return { columns, rows };
}
export function createDb({ columns, rows }) {
const db = new DatabaseSync(":memory:");
const defs = columns.map((column) => `"${column.replace(/"/g, '""')}" TEXT`).join(", ");
db.exec(`CREATE TABLE data (${defs})`);
const marks = columns.map(() => "?").join(", ");
const insert = db.prepare(`INSERT INTO data VALUES (${marks})`);
for (const row of rows) insert.run(...columns.map((column) => row[column]));
return db;
}
export function buildPrompt({ question, columns, rows }) {
const sample = rows
.slice(0, 3)
.map((row) => columns.map((column) => String(row[column])).join(" | "))
.join("\n");
return [
"你是数据分析师。根据 SQLite 表结构写一条只读查询。",
"表名:data",
`字段:${columns.join(", ")}`,
"样例数据(用 | 分隔,顺序同上):",
sample,
"",
`问题:${question}`,
"只输出一条 SQLite 查询语句,不要 JSON,不要解释,不要 Markdown 代码围栏。",
"要求:只允许 SELECT 或 WITH;不要 PRAGMA、ATTACH、INSERT、UPDATE、DELETE。",
"字段名用双引号;数字列要 SUM/AVG/ORDER BY 时,用 CAST(\"字段\" AS REAL)。",
"问题问“每个/合计/最高”时,先 GROUP BY 再聚合,不要直接返回明细行。",
].join("\n");
}
export function validateSql(text) {
const cleaned = text.replace(/`{3}(?:sql)?/gi, "").replace(/`{3}/g, "").trim();
const start = cleaned.search(/\b(select|with)\b/i);
if (start < 0) throw new Error("模型没有返回 SELECT 或 WITH。");
const sql = cleaned.slice(start).trim().replace(/;+\s*$/, "");
if (/;/.test(sql)) throw new Error("只允许一条语句。");
if (/\b(insert|update|delete|drop|alter|attach|pragma|vacuum)\b/i.test(sql)) {
throw new Error("查询里出现了写操作关键字。");
}
return sql;
}
export function runSql(db, sql, limit = 20) {
const statement = db.prepare(sql);
const columns = statement.columns().map((column) => column.name);
const rows = statement.all();
return { columns, rows: rows.slice(0, limit), total: rows.length };
}
几个决定:
- 所有列先建成 TEXT:CSV 不保证类型,先当文本存,SQL 里用
CAST转数字,比猜类型安全; - 表名固定
data:提示词里写死,模型不用猜; - 只允许 SELECT/WITH:模型偶尔会写
PRAGMA或DELETE,在进数据库前就拦掉; - 结果 LIMIT 20:查询对了也不把整张表打到终端。
写模型调用
保存为 resources/csv-to-sql/model.mjs:
const SYSTEM_PROMPT = "你是数据分析师。只输出 SQL,不要解释。";
export async function callModel({ baseUrl, model, prompt, temperature = 0.2 } = {}) {
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model,
temperature,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: prompt },
],
}),
});
if (!response.ok) {
throw new Error(`模型接口返回 ${response.status}:${await response.text()}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("模型返回里没有 content。");
return content;
}
系统提示和正文提示都要求只输出 SQL,模型就不会在前后加解释。
写命令入口
保存为 resources/csv-to-sql/query.mjs:
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { parseArgs } from "node:util";
import {
buildPrompt,
createDb,
loadCsv,
runSql,
validateSql,
} from "./core.mjs";
import { callModel } from "./model.mjs";
function renderTable(columns, rows) {
const widths = columns.map((column) =>
Math.max(
column.length,
...rows.map((row) => String(row[column] ?? "").length),
),
);
const line = (values) =>
values.map((value, index) => String(value).padEnd(widths[index])).join(" ");
console.log(line(columns));
console.log(widths.map((width) => "-".repeat(width)).join(" "));
for (const row of rows) console.log(line(columns.map((column) => row[column] ?? "")));
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const { values, positionals } = parseArgs({
options: {
"base-url": { type: "string", default: "http://127.0.0.1:11434/v1" },
model: { type: "string", default: "qwen2.5:3b" },
limit: { type: "string", default: "20" },
sql: { type: "string" },
},
allowPositionals: true,
});
const [csvPath, question] = positionals;
if (!csvPath || (!question && !values.sql)) {
console.error('用法:node resources/csv-to-sql/query.mjs input/sales.csv "销售额最高的三个城市"');
process.exitCode = 1;
} else {
try {
const table = loadCsv(await readFile(csvPath, "utf8"));
const db = createDb(table);
let sql = values.sql;
if (!sql) {
const started = Date.now();
const raw = await callModel({
baseUrl: values["base-url"],
model: values.model,
prompt: buildPrompt({ question, ...table }),
});
sql = validateSql(raw);
console.log(`模型用时:${((Date.now() - started) / 1000).toFixed(1)} 秒`);
} else {
sql = validateSql(sql);
}
console.log(`SQL:${sql}\n`);
const result = runSql(db, sql, Number(values.limit));
renderTable(result.columns, result.rows);
console.log(`\n共 ${result.total} 行,显示 ${result.rows.length} 行。`);
} catch (error) {
console.error(
/fetch failed/i.test(error.message)
? "连不上本地模型。先启动 Ollama,或用 --base-url 指向 llama.cpp server。"
: error.message,
);
process.exitCode = 1;
}
}
}
--sql 是手动通道:模型写歪了,可以直接把改好的 SQL 传进去,不重新请求模型。
准备 CSV
新建 input/sales.csv:
城市,月份,销售额,订单数
上海,1,128000,860
北京,1,115000,790
深圳,1,98000,640
上海,2,136000,910
北京,2,121000,820
深圳,2,104000,700
杭州,1,76000,510
杭州,2,83000,560

跑一次
node resources/csv-to-sql/query.mjs input/sales.csv "每个城市的总销售额,按总销售额从高到低"
真实输出(3B 模型,约 6 秒):

模型用时 6.0 秒。SQL 与查询结果如下:
SQL:SELECT "城市", SUM(CAST("销售额" AS REAL)) AS 总销售额 FROM data GROUP BY "城市" ORDER BY 总销售额 DESC
城市 总销售额
-- ------
上海 264000
北京 236000
深圳 202000
杭州 159000
共 4 行,显示 4 行。上海 128000 + 136000 = 264000,北京 115000 + 121000 = 236000,能和 CSV 对上。

看懂这条 SQL
SUM(CAST("销售额" AS REAL))
销售额 这一列在 SQLite 里是 TEXT,直接 SUM 会按文本处理或得到 0。CAST(... AS REAL) 先转成数字,再求和。
GROUP BY "城市" 把同一个城市的行并成一组;没有它,模型会返回每一行明细,看起来像结果,实际没汇总。
先看 SQL 有没有这两样,再信下面的数字。
常见坑
1. 模型返回明细行,没有 GROUP BY
问“每个城市”时最容易出现。提示词已经要求先聚合,但 3B 仍会漏。看到 SQL 里没有 GROUP BY,加一句“按城市汇总”重跑,或直接 --sql 手写。
2. 数字列被当成文本
CSV 读进来全是 TEXT。ORDER BY "销售额" 会按字符串排,9 会排在 10 后面。所有求和、排序都先 CAST。
3. 字段名是中文或带空格
SQL 里用双引号包住,例如 "销售额"、"订单数"。不加引号,SQLite 会把中文当标识符,某些查询直接报错。
4. 数字里带千分位逗号
CAST("128,000" AS REAL) 只会得到 128,逗号后面的被截掉。CSV 里的金额要么不带千分位,要么先洗掉逗号再入库。
5. 模型在 SQL 前后加解释
validateSql 会去掉代码围栏,从第一个 SELECT 或 WITH 截到结尾。中间夹了解释仍然会报错,重跑一次通常就好。
下一步读什么
CSV 本身的清洗和编码,先看表格乱码怎么解决;每周复盘可以用周报脚本。
这条路线收在开发者 AI 实战;想先搭本地模型,看本地跑大模型前先算硬件。
常见问题
为什么不让模型直接算?
模型算数会错,尤其是多行汇总。让它写 SQL,SQLite 执行,结果可复现,也能把 SQL 留下来复查。
CSV 很大能跑吗?
这个脚本把 CSV 读进内存,适合几万行以内。再大改用 SQLite 文件或 DuckDB,脚本里的 SQL 仍然能用。
模型写错 SQL 怎么办?
先看打印出来的 SQL。常见错法是没 GROUP BY、把数字当文本、字段名没加引号。改提示词或直接加 --sql 手动跑。
能连 MySQL 或 PostgreSQL 吗?
思路一样,把 createDb 换成对应驱动即可。本文只用 Node 自带的 SQLite,不装额外服务。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。