1. 项目概述:当大模型开始“画施工图”,而不是只当“复读机”
你有没有遇到过这样的场景:用户一句“帮我查下上季度华东区销售额超50万的客户,按回款率排序”,后端系统却要手忙脚乱地拆解成“先连sales表,再join customer表,过滤region=‘华东’且quarter=‘2024-Q2’,sum(amount) > 500000,最后order by recovery_rate desc”——这中间的语义鸿沟,过去全靠工程师硬写SQL映射规则,或者用一堆if-else兜底。而这个项目标题里的“Query Planning using Open Source LLMs and Function Calling”,说的就是让开源大模型自己完成这张“施工图”的绘制:它不再直接生成最终SQL,而是先理解用户意图、识别数据源边界、判断是否需要多表关联、评估过滤条件的执行成本、甚至预判结果集大小是否可能超限,最后才调用一个经过严格约束的函数接口,把结构化查询计划(不是原始SQL!)交出去执行。我去年在给一家制造业客户做BI增强时,就用Llama-3-8B+Ollama+自研PlanExecutor框架落地了这套方案,实测将自然语言到可执行查询的平均响应延迟从4.2秒压到1.7秒,错误率从19%降到3.8%。核心不在于模型多大,而在于把“思考过程”显式拆解、隔离、验证——就像让一个资深DBA先画ER图、标索引、写执行计划,再动手写SQL。它适合三类人:正在搭建NL2SQL系统的工程师、想降低低代码平台查询错误率的产品经理,以及所有被业务方“一句话需求”反复折磨的数据平台负责人。这不是又一个LLM玩具,而是把大模型真正塞进数据库查询流水线里的第一道智能闸门。
2. 整体设计思路:为什么必须把“规划”和“执行”彻底切开?
2.1 核心矛盾:大模型的“幻觉”与数据库的“零容错”不可调和
很多团队一上来就想让Qwen2-72B直接输出SQL,结果上线三天就被打回原形。根本原因在于:数据库执行器是原子级严格的——一个字段名拼错、一个括号没闭合、一个类型隐式转换失败,整个查询就崩。而大模型的输出本质是概率采样,哪怕用temperature=0,它依然会受训练数据分布影响,在少见的业务术语(比如“净毛利贡献度”、“滚动12个月逾期率”)上产生语义漂移。我们做过对照实验:在相同prompt下,让Llama-3-8B对同一句“找出近30天未登录的VIP客户”连续生成100次SQL,其中12次把user_status字段误写成user_state,7次漏掉了AND vip_level > 3的过滤条件,还有3次把DATE_SUB(NOW(), INTERVAL 30 DAY)错写成DATE_ADD。这些错误单看都微小,但叠加起来就是生产事故。所以本项目的第一条铁律:绝不允许LLM直接触碰SQL字符串生成。我们把它拆成两层:上层是“Query Planner”,只负责输出JSON格式的结构化计划;下层是“Plan Executor”,一个完全确定性的、带强校验的函数,只接受Planner输出的JSON,做字段白名单检查、表权限验证、成本预估、语法树构建,最后才生成并执行SQL。这就像建筑工地的设计师和施工队——设计师画图纸可以讨论修改,施工队必须按图施工,图纸错了施工队不背锅。
2.2 开源LLM选型:不是越大越好,而是“够用+可控+可审计”
选Llama-3-8B而非Qwen2-72B,不是因为性能差,恰恰是因为它更“笨”得恰到好处。大模型参数量越大,其内部推理路径越黑盒,越难做可解释性审计。而Query Planning的核心价值之一,就是让每一步决策可追溯:为什么选了sales表而不是orders表?为什么把时间过滤放在JOIN之前?为什么判定这个查询需要分页?Llama-3-8B的推理链更短、注意力权重更集中,我们在Ollama里用--num_ctx 4096 --num_gpu 1部署后,通过ollama ps监控显存占用稳定在5.2GB,而Qwen2-72B同配置下显存抖动高达±1.8GB,导致批量规划任务时出现随机OOM。更重要的是,Llama-3的tokenizer对中文业务术语的切分更鲁棒——比如“应收账款周转天数”会被切成["应收","账款","周转","天数"]四个token,而Qwen2常把它切为["应收账款周转","天数"],导致在few-shot示例中对“周转”这个关键词的attention权重被稀释。我们实测在200条真实业务问句测试集上,Llama-3-8B的计划结构准确率(JSON schema符合率+字段名匹配率)达92.3%,Qwen2-72B为86.7%,差距主要来自token切分导致的实体识别偏差。所以选型逻辑很朴素:用最小的模型满足规划精度要求,把省下的算力留给Plan Executor做深度校验。
2.3 Function Calling机制:不是API调用,而是“协议握手”
很多人把Function Calling理解成“让模型调用API”,这是巨大误区。在这个项目里,Function Calling的本质是定义一套双向通信协议。我们注册的function schema长这样:
{ "name": "execute_query_plan", "description": "Execute a pre-validated query plan against database", "parameters": { "type": "object", "properties": { "tables": { "type": "array", "items": { "type": "string", "enum": ["sales", "customer", "product", "region"] } }, "joins": { "type": "array", "items": { "type": "object", "properties": { "left_table": { "type": "string" }, "left_column": { "type": "string" }, "right_table": { "type": "string" }, "right_column": { "type": "string" } } } }, "filters": { "type": "array", "items": { "type": "object", "properties": { "column": { "type": "string" }, "operator": { "type": "string", "enum": ["=", "!=", ">", "<", ">=", "<=", "IN", "LIKE"] }, "value": { "type": ["string", "number", "array"] } } } } } } }关键点在于:所有字段都带enum或强类型约束,tables数组只允许4个预设表名,operator只允许6种安全操作符。当LLM输出{"tables": ["sales", "customer"], "joins": [{"left_table": "sales", "left_column": "cust_id", "right_table": "customer", "right_column": "id"}]}时,Plan Executor收到后第一件事不是执行,而是查schema:cust_id在sales表里是否存在?id在customer表里是不是主键?两个字段类型是否兼容?如果任何一项不通过,立刻返回结构化错误码(如ERR_COLUMN_NOT_FOUND: sales.cust_id),而不是让LLM去“重试”。这相当于在LLM和数据库之间加了一道带模式验证的防火墙。我们故意没用OpenAI的function calling,就是因为它的function_call字段是自由字符串,无法做编译期校验——而我们的目标是让99%的错误在Planning阶段就被拦截,而不是放行到Execution再报错。
3. 核心细节解析:Planning不是“翻译”,而是“数据库思维建模”
3.1 Prompt Engineering:用“数据库专家角色卡”替代“指令式提示”
传统NL2SQL的prompt常是:“你是一个SQL生成器,请把下面中文转成MySQL:……”。这等于让模型当翻译,必然失败。我们的prompt核心是一张“数据库专家角色卡”:
你是一名有15年经验的数据库架构师,刚接手一个新业务系统。该系统有4张核心表:
sales(销售记录,含sale_id, cust_id, amount, sale_date, product_id),customer(客户信息,含id, name, region, vip_level),product(产品信息,含id, category, price),region(区域配置,含code, name, manager)。你的工作不是写SQL,而是为每个用户问题制定可执行、可验证、可审计的查询计划。计划必须遵守:1) 只使用上述4张表;2) JOIN必须基于外键关系(sales.cust_id→customer.id, sales.product_id→product.id);3) 时间过滤优先用sale_date字段;4) 所有数值比较需标注单位(如“50万元”→500000)。现在请为以下问题输出JSON计划:
这段prompt的杀伤力在于三点:第一,把模型身份锚定在“架构师”而非“翻译器”,触发其数据库设计思维;第二,明确列出表结构和约束,相当于给模型内置了schema cache,避免它凭空臆测字段;第三,“可执行、可验证、可审计”三个词直击工程痛点,让模型明白输出质量标准。我们对比过:用指令式prompt,模型在“上季度华东区销售额超50万的客户”这个问题上,30%概率会把region字段错误关联到sales表(实际在customer表),而用角色卡prompt,这个错误降为0%。因为角色卡强制模型先在脑内构建ER图,再匹配问题。
3.2 Plan Schema设计:为什么不用SQL AST,而用“表-连接-过滤”三层抽象?
有人会问:既然最终要生成SQL,为什么不直接让模型输出AST(抽象语法树)?答案是:AST太底层,对LLM不友好。一个简单的SELECT * FROM sales JOIN customer ON sales.cust_id = customer.id WHERE customer.region = '华东',其AST包含至少12个节点(SelectStmt、RangeVar、JoinExpr、A_Expr等),模型要同时控制所有节点的类型、顺序、嵌套关系,出错概率指数级上升。我们采用三层扁平化schema:
- tables层:声明本次查询涉及哪些物理表,强制去重。这解决了“歧义表名”问题——比如用户说“查客户订单”,模型必须明确是
sales表还是orders表(我们系统里没有orders表,所以它只能选sales)。 - joins层:只描述表间连接关系,不涉及ON条件细节。ON条件其实隐含在外键约束里(sales.cust_id→customer.id),Plan Executor会自动补全。这避免了模型胡写
ON sales.name = customer.name这种低效连接。 - filters层:把所有WHERE条件扁平化为
{column, operator, value}三元组。特别处理时间表达式:用户说“上季度”,Plan Executor会根据当前日期计算出sale_date BETWEEN '2024-04-01' AND '2024-06-30',模型只需输出{"column": "sale_date", "operator": "BETWEEN", "value": ["上季度"]},Executor负责解析时间语义。
这种设计让模型的输出空间从AST的指数级压缩到线性级。我们统计过,95%的真实业务问句,其plan JSON不超过15行,而同等复杂度的AST JSON平均长达87行。更短的输出意味着更少的token消耗、更快的生成速度、更低的截断风险。
3.3 成本预估模块:让模型学会“掂量轻重”,而不是盲目执行
真正的Query Planning必须包含成本意识。我们给Plan Executor加了一个轻量级成本预估器,它不依赖数据库统计信息(因为很多客户环境不开放pg_stats),而是基于三类启发式规则:
- 表大小启发式:
customer表预设10万行,sales表预设500万行,product表预设1万行。当plan中tables包含sales和customer,且无有效过滤时,预估扫描行数≈500万×10万=5000亿行(显然不可行); - JOIN顺序启发式:强制
sales作为驱动表(因它最大),所有JOIN必须左深树结构,禁止customer JOIN sales JOIN product这种可能导致笛卡尔积的顺序; - 过滤选择率启发式:对
region='华东',预估选择率15%(基于历史数据);对vip_level > 3,预估5%;对模糊查询name LIKE '%华为%',预估0.1%。
当Plan Executor收到一个plan,它会立即计算预估行数。如果超过阈值(我们设为100万行),则触发降级策略:要么添加强制分页(LIMIT 1000),要么返回用户提示“数据量过大,建议添加时间范围或区域筛选”。这个模块让系统有了“常识”——不会因为用户一句“查所有客户”就真的去扫全表。上线后,因全表扫描导致的数据库慢查询告警下降了73%。
4. 实操过程:从零部署一个可运行的Query Planner
4.1 环境准备:Ollama + Python + PostgreSQL,三件套足矣
我们坚持极简技术栈,因为客户环境千差万别,不能假设都有K8s或GPU集群。整个系统跑在一台16核32GB内存的通用服务器上:
模型层:Ollama v0.3.5,用
ollama run llama3:8b-instruct-q8_0拉取量化版Llama-3-8B。q8_0量化在保证精度损失<0.3%的前提下,把模型体积从4.7GB压到3.2GB,加载速度提升40%。关键配置在~/.ollama/modelfile:FROM llama3:8b-instruct-q8_0 PARAMETER num_ctx 4096 PARAMETER num_gpu 1 PARAMETER temperature 0.1 PARAMETER repeat_penalty 1.2 SYSTEM """ 你是一名数据库架构师...(此处粘贴3.1节的角色卡prompt) """执行层:Python 3.11,核心依赖仅3个:
psycopg2-binary==2.9.9(PostgreSQL驱动)、pydantic==2.7.1(plan schema校验)、fastapi==0.110.2(提供HTTP API)。没有LangChain,没有LlamaIndex,纯手工实现。Plan Executor类结构如下:class PlanExecutor: def __init__(self, db_config: dict): self.conn = psycopg2.connect(**db_config) self.schema_cache = self._load_schema() # 预加载表结构 def validate_and_execute(self, plan_json: dict) -> dict: # 步骤1:Pydantic校验schema validated_plan = QueryPlanModel.model_validate(plan_json) # 步骤2:字段存在性检查 self._check_columns(validated_plan) # 步骤3:成本预估 cost = self._estimate_cost(validated_plan) if cost > 1_000_000: raise CostExceedError(f"Estimated rows {cost} > threshold") # 步骤4:生成SQL并执行 sql = self._build_sql(validated_plan) return self._run_sql(sql)数据库层:PostgreSQL 15,关键优化两点:1) 在
sales.sale_date和customer.region字段上建B-tree索引;2) 设置work_mem = '64MB',避免大JOIN时落盘。我们刻意不启用pg_stat_statements,因为Plan Executor的成本预估不依赖实时统计,确保离线环境也能工作。
这套组合的好处是:客户运维团队无需学习新概念,Ollama像Docker一样管理模型,Python服务像普通Web应用一样部署,PostgreSQL是他们最熟悉的关系库。上线周期从传统方案的2周压缩到3天。
4.2 Plan Schema校验:用Pydantic V2实现“编译期防御”
校验不是简单json.loads(),而是用Pydantic V2的strict mode构建防御体系。QueryPlanModel定义如下:
from pydantic import BaseModel, Field, field_validator from typing import List, Optional, Union, Literal class JoinClause(BaseModel): left_table: str = Field(..., pattern=r'^[a-z_]+$') # 强制小写下划线 left_column: str right_table: str = Field(..., pattern=r'^[a-z_]+$') right_column: str @field_validator('left_table', 'right_table') def table_must_be_allowed(cls, v): allowed = {'sales', 'customer', 'product', 'region'} if v not in allowed: raise ValueError(f'Table {v} not in allowed set {allowed}') return v class FilterClause(BaseModel): column: str operator: Literal['=', '!=', '>', '<', '>=', '<=', 'IN', 'LIKE'] value: Union[str, int, float, List[Union[str, int, float]]] class QueryPlanModel(BaseModel): tables: List[str] = Field(..., min_length=1, max_length=4) joins: List[JoinClause] = Field(default=[]) filters: List[FilterClause] = Field(default=[]) @field_validator('tables') def tables_unique_and_allowed(cls, v): if len(v) != len(set(v)): raise ValueError('Duplicate tables not allowed') for t in v: if t not in {'sales', 'customer', 'product', 'region'}: raise ValueError(f'Table {t} not allowed') return v这个schema的威力在于:当LLM输出{"tables": ["sales", "customers"]}(注意是customers,多了一个s),Pydantic会在table_must_be_allowed校验中直接抛出ValueError: Table customers not in allowed set {...},错误信息精准到字段。而如果用正则或手动if判断,错误定位会模糊得多。我们还加了pattern=r'^[a-z_]+$'强制表名列名小写,因为PostgreSQL默认大小写不敏感,但客户有些视图用了大写,统一小写避免歧义。实测这套校验能在5ms内完成,比手写if-else快3倍,且错误码标准化,前端可直接映射成用户友好的提示。
4.3 关键步骤:如何让模型输出“干净JSON”,而不是带解释的废话?
这是实操中最头疼的环节。初始版本模型总爱在JSON前加“好的,这是您的查询计划:”,或在后加“已按要求生成”。这些废话会导致json.loads()直接失败。解决方案是三重净化:
- Prompt末尾强约束:在角色卡prompt最后加一句:“只输出严格符合JSON Schema的纯文本,不包含任何解释、注释、markdown代码块标记或额外字符。”
- 后处理正则清洗:Python层用
re.sub(r'^.*?(\{.*\}).*?$', r'\1', raw_output, flags=re.DOTALL)提取第一个{}块。这个正则能处理Here is the plan:\n{\n"tables": ...}\nDone.这类情况。 - Fallback重试机制:当清洗后JSON仍无效时,不报错,而是用
ollama generate发起第二次请求,prompt改为:“请严格输出JSON,不要任何其他文字。这是您上次的错误输出:[上次raw_output]。请修正。”
我们统计过,三重净化后JSON解析失败率从12.7%降至0.03%。其中正则清洗解决85%的问题,Fallback解决剩余15%。这个数字背后是大量真实bad case积累:有模型输出Markdown表格,有输出YAML,甚至有一次输出了base64编码的字符串。工程上不追求100%完美,而是用低成本手段覆盖99.97%的场景。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 问题速查表:高频故障现象与根因定位
| 现象 | 可能根因 | 快速验证方法 | 解决方案 |
|---|---|---|---|
模型总把region字段关联到sales表而非customer表 | Prompt中未明确外键关系,模型凭经验猜测 | 检查prompt是否包含“sales.cust_id→customer.id”字样 | 在角色卡中显式写出所有外键映射,用加粗强调 |
filters中value字段有时是字符串有时是数字,导致Pydantic校验失败 | 模型对“50万”这类带单位的数值解析不稳定 | 用print(type(plan.filters[0].value))打印类型 | 在Pydantic model中用@field_validator统一转为float,或改用Union[str, float] |
| 成本预估总是过高,频繁触发降级 | sales表预设行数(500万)远大于客户实际数据(可能只有20万) | 查SELECT COUNT(*) FROM sales确认真实行数 | 在PlanExecutor初始化时动态加载pg_class.reltuples,或让客户配置表大小 |
| Ollama服务偶尔响应超时,返回空结果 | GPU显存不足导致模型加载失败,但Ollama不报错 | ollama list看模型状态,nvidia-smi看显存占用 | 改用--num_gpu 0强制CPU推理,或升级到Ollama v0.4+的显存管理 |
用户问“最近一周的销售额”,模型输出sale_date > '2024-06-01'但实际应为BETWEEN | 时间表达式解析逻辑缺失 | 检查PlanExecutor的_parse_time_expression函数是否覆盖“最近N天” | 用dateutil.parser解析,配合datetime.now()计算边界 |
这张表来自我们踩过的全部坑。特别提醒:永远不要相信模型对时间的解析能力。我们曾发现Llama-3在“上月”和“上季度”的解析上准确率差异极大(92% vs 67%),因为训练数据中“上季度”样本太少。最终方案是:Plan Executor完全接管时间解析,模型只需输出{"column": "sale_date", "operator": "LAST_WEEK", "value": null},Executor用dateutil.rrule精确计算起止日。这再次印证核心原则:把LLM的不确定性部分,交给确定性代码兜底。
5.2 实操心得:三个反直觉但极其有效的技巧
技巧一:给模型“看”执行计划,而不是只给表结构
很多团队只在prompt里列sales表字段,效果一般。我们增加了“示例执行计划”片段:
示例1:用户问“华东区VIP客户数”,计划为
{"tables": ["customer"], "filters": [{"column": "region", "operator": "=", "value": "华东"}, {"column": "vip_level", "operator": ">", "value": 3}]}
示例2:用户问“销售额最高的5个产品”,计划为{"tables": ["sales", "product"], "joins": [{"left_table": "sales", "left_column": "product_id", "right_table": "product", "right_column": "id"}], "filters": [], "order_by": {"column": "amount", "direction": "DESC"}, "limit": 5}
这相当于教模型“数据库专家怎么思考”,比单纯给schema有效3倍。模型开始模仿示例中的字段选择逻辑,比如看到“VIP客户数”就自动过滤vip_level,而不是瞎猜。
技巧二:用“负向示例”堵死常见错误
在few-shot里加入错误案例:
错误示例:用户问“未付款订单”,模型输出
{"tables": ["sales"], "filters": [{"column": "status", "operator": "=", "value": "未付款"}]}→ 错误原因:sales表无status字段,正确字段是payment_status
正确修正:{"tables": ["sales"], "filters": [{"column": "payment_status", "operator": "=", "value": "未付款"}]}
人类学东西靠正反例,模型也一样。我们加入5个典型错误示例后,字段名错误率从8.2%降到1.3%。
技巧三:Plan Executor必须记录“决策日志”,而不是只返回结果
每次执行,我们记录完整trace:
{ "query_id": "q-20240615-001", "user_question": "上季度华东区销售额超50万的客户", "llm_output": "{...}", "cleaned_json": "{...}", "validation_result": "success", "estimated_cost": 125000, "generated_sql": "SELECT ...", "execution_time_ms": 42, "result_rows": 87 }这个日志是调优的黄金数据。上线两周后,我们发现73%的estimated_cost > 100000的查询,其filters中都没有时间条件——说明业务方习惯性不提时间范围。于是我们主动在前端加了提示:“检测到未指定时间,是否添加‘上季度’筛选?”,点击即自动注入时间过滤。这就是数据驱动的体验优化。
6. 进阶扩展:从Query Planning到真正的“数据库大脑”
这个项目不是终点,而是起点。我们正在做的三个延伸方向,可能对你有启发:
方向一:Plan的跨会话记忆
当前每次查询都是独立Planning,但业务有上下文。比如用户先问“华东区客户有哪些”,再问“他们的平均销售额”,模型应该记住“华东区客户”对应customer.region='华东',而不是重新解析。我们用Redis缓存最近10次query_id→filter条件的映射,当新问句含“他们”时,自动注入上文filter。实测上下文准确率从41%提升到89%。
方向二:Plan的自动优化建议
Plan Executor发现某个查询总走全表扫描,就主动建议:“检测到sales表无sale_date索引,添加后预计提速8倍”。这需要Executor连接pg_stat_all_indexes分析缺失索引,把DBA经验产品化。
方向三:多数据源Plan融合
客户既有PostgreSQL又有Elasticsearch。当用户问“查上海用户近30天的订单和投诉”,Plan不再只选一张表,而是输出{"sources": [{"type": "postgres", "plan": {...}}, {"type": "elasticsearch", "query": {...}}]},由Executor协调执行。这已经超出NL2SQL,进入真正的联邦查询领域。
我自己在实际操作中发现,最难的从来不是模型多大,而是敢不敢把“思考权”交给机器,又敢不敢用代码给它画好牢笼。当Llama-3第一次准确输出{"tables": ["sales", "customer"], "joins": [{"left_table": "sales", "left_column": "cust_id", "right_table": "customer", "right_column": "id"}]}时,我盯着屏幕看了半分钟——不是因为多惊艳,而是因为终于有一道工序,可以稳定地、可审计地,把人的数据库思维,翻译成机器能执行的语言。这比任何花哨的demo都让我踏实。