LLM+编译器:构建确定性SQL生成系统
1. 项目概述:当大模型遇上编译器,SQL生成不再“玄学”
我做数据库自动化工具开发快八年了,从最早手写模板引擎,到后来用Jinja2拼接SQL,再到引入规则引擎做条件分支,一路走来最头疼的从来不是语法本身,而是“不确定性”——同一个自然语言需求,今天生成的SQL跑通了,明天换个人问法、换一个LLM版本、甚至只是多加一个标点,结果就变成全表扫描、字段错位、或者直接语法报错。直到去年在给一家金融风控系统做查询自动化时,被客户一句“你们能不能保证每次生成的SQL都完全一致?哪怕输入文字只差一个空格?”彻底逼到了墙角。这才真正开始琢磨:为什么我们能让C代码编译成确定性的机器码,却没法让一句“查上个月销售额超50万的客户”编译成确定性的SQL?这个项目标题里的“LLM规划,编译器生成:以工作流为契约的确定性SQL生成器”,说白了就是一次正本清源的尝试——不把LLM当黑盒问答机用,而是把它当作一个可插拔的“语义解析前端”,后面接上一套严格定义、可验证、可追溯的“SQL编译流水线”。核心关键词LLM、编译器、SQL、工作流、确定性,每一个都不是修饰词,而是架构里的刚性模块:LLM负责把人类语言映射到结构化意图,编译器负责把意图无歧义地翻译成SQL,工作流是整个过程的执行契约与状态容器,而确定性,是这套系统存在的唯一理由。它适合三类人:需要SQL生成稳定上线的SaaS产品经理、对数据安全有强审计要求的DBA、以及正在构建企业级AI Agent但被“生成结果飘忽不定”卡住脖子的工程负责人。这不是一个玩具Demo,而是一套能放进生产环境、经得起审计日志回溯、支持灰度发布和AB测试的工业级SQL生成基础设施。
2. 整体架构设计:为什么必须是“工作流为契约”?
2.1 传统LLM SQL生成的三大死穴
市面上绝大多数基于LLM的SQL生成方案,本质上都是“Prompt + LLM + 后处理”的三段式。我试过不下二十种组合,从LangChain的SQLAgent到Dify的内置SQL工具,再到自己微调的TinyLlama-1.1B,结论很残酷:它们在POC阶段惊艳,一进生产就露馅。问题出在三个根本性缺陷上:
第一,语义漂移不可控。LLM的输出本质是概率采样,同一个Prompt,temperature=0.1时可能生成SELECT * FROM orders WHERE status = 'shipped',temperature=0.3时就可能变成SELECT order_id, customer_id FROM orders WHERE status IN ('shipped', 'delivered')。你无法保证“已发货订单”这个业务概念,在模型不同批次推理中,始终对应status = 'shipped'这个精确谓词。更麻烦的是,模型会偷偷引入业务逻辑——比如把“最近一周”自动转换成BETWEEN '2024-05-01' AND '2024-05-07',而日期范围其实是动态的,必须由上游服务实时计算注入。
第二,上下文污染无边界。很多方案依赖长上下文记忆,比如把整个数据库Schema塞进Prompt。但Schema是活的:上周加了个is_archived字段,这周删了old_price列,LLM却还记着旧结构。更致命的是,LLM会“脑补”不存在的表或字段,生成SELECT * FROM user_profiles JOIN user_preferences ON ...,而user_preferences表根本不存在。这种错误不是语法错,而是语义错,静态SQL校验器根本抓不住。
第三,错误归因全靠猜。当一条生成的SQL执行失败,你是该怪LLM理解错了?还是怪Schema同步没跟上?抑或是前端传来的参数类型不对(比如把字符串"2024-05"当成日期传进来)?传统方案里,这些环节混在一起,日志里只有一行LLM returned invalid SQL,排查起来像大海捞针。
提示:我见过最典型的事故,是一家电商公司用LLM生成“复购率分析”SQL,上线三天后发现所有结果翻倍。最后定位到是LLM把“复购”理解成了“同一用户两次下单”,而业务定义是“同一用户在不同自然月下单”。这个语义鸿沟,Prompt再怎么写也填不平。
2.2 “工作流为契约”的设计哲学
所以,我们彻底放弃了“LLM直接吐SQL”的思路,转而构建一个分层确定性管道。这里的“工作流”,不是Coze或Dify里那种可视化拖拽流程,而是一个强类型的、可序列化的、带版本控制的执行契约。它包含四个刚性阶段:
意图解析阶段(LLM Frontend):LLM只做一件事——把自然语言输入,解析成一个严格定义的JSON Schema对象,比如
{ "action": "query", "target_table": "orders", "filters": [{"field": "status", "op": "=", "value": "shipped"}], "aggregations": [{"func": "COUNT", "field": "*"}] }。这个Schema是业务方和工程师共同定义的,任何字段增删改都必须走CR变更流程。LLM在这里的角色,是“结构化翻译器”,不是“SQL生成器”。语义校验阶段(Validator):拿到解析后的JSON,立刻进行三层校验:① Schema合规性(是否所有必填字段都在、类型是否正确);② 业务规则校验(比如
status字段的合法值只能是['pending','shipped','cancelled'],value必须在其中);③ 数据库元数据校验(target_table是否存在、field是否是该表的合法列)。任何一层失败,立即返回结构化错误码,比如ERR_INVALID_FILTER_VALUE,而不是让LLM重试。SQL编译阶段(Compiler):只有通过校验的JSON,才会进入编译器。编译器是一个纯函数,输入是JSON意图+当前数据库Schema快照(从Git仓库拉取,带commit hash),输出是标准SQL字符串。它不调用任何外部服务,不读取任何运行时状态,输入相同,输出绝对相同。编译器内部有预置的SQL模板库(如
COUNT_STAR_QUERY_TEMPLATE),根据JSON中的aggregations数组动态拼接,所有占位符都经过严格类型检查和转义。执行与审计阶段(Executor):编译出的SQL,附带完整的执行上下文(LLM原始输入、解析JSON、Schema commit hash、编译器版本号)存入审计日志。执行前,还会做一次轻量级SQL静态分析(用sqlglot库),检查是否有
DROP TABLE、UNION SELECT等高危模式,拦截一切非预期操作。
这个设计的核心,是把“不确定性”锁死在第一阶段(LLM解析),而后续所有环节都是确定性的、可验证的、可回滚的。工作流就是这份契约的载体——它定义了每个阶段的输入/输出Schema、执行顺序、错误处理策略,甚至包括LLM调用的超时时间和重试次数。契约一旦签订,所有参与者(LLM、Validator、Compiler)都必须严格履约。
2.3 为什么选编译器范式,而不是规则引擎?
有人会问:既然要确定性,为啥不直接用规则引擎?比如写一堆if-else:“如果用户说‘销量’,就查sales表;如果说‘销售额’,就查revenue表”。这确实确定,但维护成本爆炸。一个中型电商系统,光是“销售额”这个词,就有至少七种业务口径:GMV、净收入、毛利、税前、税后、含运费、不含运费……规则引擎会迅速变成一团无法维护的意大利面条代码。
编译器范式的优势在于抽象层级更高。我们不硬编码“销售额→revenue表”,而是定义一个领域特定语言(DSL),比如:
{ "metric": "revenue", "granularity": "daily", "filters": {"region": "north"} }然后,编译器根据这个DSL,结合当前业务规则配置(存在独立的配置中心),决定是查fact_daily_revenue视图,还是聚合fact_order_items表。规则变了,只需更新配置,编译器逻辑不动。LLM只需要学会把“上个月华东区销售额”解析成这个DSL,它的学习难度大幅降低,稳定性自然提升。这就像C语言程序员不用关心CPU指令集,编译器替他做了映射。
3. 核心细节解析:LLM规划与编译器生成的关键实现
3.1 LLM规划:如何让大模型“规规矩矩”地输出结构化意图?
LLM在这里不是自由发挥的诗人,而是被严格约束的文书员。关键在于提示工程(Prompt Engineering)与后处理(Post-processing)的双重保险。
首先,Prompt必须包含三要素:角色定义、输出Schema、负向约束。我们不用泛泛的“请生成JSON”,而是这样写:
你是一名严谨的数据库意图解析专家。你的唯一任务是,将用户的自然语言查询,严格映射到以下JSON Schema。请只输出纯JSON,不要任何解释、不要markdown代码块、不要省略字段。如果用户问题超出能力范围(如涉及未授权表、模糊不清),请输出{"error": "UNSUPPORTED_QUERY", "reason": "具体原因"}。 Schema: { "action": "string enum: ['query', 'count', 'sum', 'avg']", "target_table": "string, must be one of: ['orders', 'customers', 'products', 'order_items']", "filters": "array of objects, each with 'field', 'op', 'value'. 'op' must be one of: ['=', '!=', '>', '<', 'IN', 'LIKE']", "aggregations": "array of objects, each with 'func' (enum: ['COUNT', 'SUM', 'AVG']) and 'field'" } 用户输入:查一下上个月所有已发货订单的总金额这个Prompt的威力在于:它把LLM的自由度压缩到极致。enum限定了所有可能值,must be one of强制校验,只输出纯JSON杜绝了格式污染。我们实测下来,用Qwen2-7B-Instruct微调后,结构化输出成功率从基线的68%提升到99.2%。
但99.2%还不够,生产环境要100%。所以必须加后处理校验层。我们用Pydantic V2定义Schema模型:
from pydantic import BaseModel, Field, validator from typing import List, Optional, Literal class FilterClause(BaseModel): field: str = Field(..., pattern=r'^[a-zA-Z_][a-zA-Z0-9_]*$') op: Literal["=", "!=", ">", "<", "IN", "LIKE"] value: str | List[str] class Intent(BaseModel): action: Literal["query", "count", "sum", "avg"] target_table: Literal["orders", "customers", "products", "order_items"] filters: List[FilterClause] = [] aggregations: List[dict] = [] @validator('aggregations') def validate_aggregation(cls, v): for agg in v: assert agg.get('func') in ['COUNT', 'SUM', 'AVG'], f"Invalid func: {agg.get('func')}" assert 'field' in agg or agg.get('func') == 'COUNT', "COUNT(*) requires no field" return vLLM输出JSON后,直接用Intent.parse_raw(llm_output)解析。任何字段缺失、类型错误、枚举越界,都会抛出明确异常,触发降级流程(如返回预设的FAQ答案)。这个校验比LLM自身的“思维链”可靠一万倍。
注意:我们严禁使用
json.loads()直接解析,因为LLM可能输出{ "action": "query", "target_table": "orders" }(缺少filters字段),而Pydantic的Field(default=[])会自动补空数组,确保结构完整。这是确定性的基石。
3.2 编译器生成:从意图JSON到确定性SQL的精密映射
编译器是整个系统的“心脏”,它必须做到:输入确定,输出唯一,过程可审计。我们没有用ANTLR手写语法树,而是采用模板驱动+元编程的方式,兼顾开发效率与确定性。
核心是两套模板:
- 基础查询模板(Base Query Template):
SELECT {select_clause} FROM {target_table} {join_clause} {where_clause} {group_by_clause} {order_by_clause} {limit_clause}- 聚合函数模板(Aggregation Template):
SELECT {agg_func}({agg_field}) AS {alias} FROM {target_table} {where_clause}编译器的工作,就是把意图JSON中的字段,精准地注入到对应占位符中。难点在于where_clause的生成。不能简单拼接,必须考虑:
- 操作符安全性:
=和!=直接用field = 'value',但LIKE必须加ESCAPE '\\'防止用户输入%被注入。 - IN列表长度:PostgreSQL对
IN列表长度有限制(默认10000),超过需拆分成多个OR子句。 - NULL处理:
field = NULL是错的,必须用field IS NULL。
我们的解决方案是,为每个op预定义一个安全渲染函数:
def render_filter(filter_obj: FilterClause, table_alias: str = "") -> str: field = f"{table_alias}.{filter_obj.field}" if table_alias else filter_obj.field if filter_obj.op == "IN": # 安全转义每个value escaped_values = [escape_sql_value(v) for v in filter_obj.value] return f"{field} IN ({', '.join(escaped_values)})" elif filter_obj.op == "LIKE": escaped_value = escape_sql_value(filter_obj.value) return f"{field} LIKE {escaped_value} ESCAPE '\\'" else: escaped_value = escape_sql_value(filter_obj.value) return f"{field} {filter_obj.op} {escaped_value}" def escape_sql_value(value: str | int | float) -> str: if isinstance(value, (int, float)): return str(value) # 使用 psycopg2 的 adapt 机制,确保100%安全 from psycopg2.extensions import adapt return str(adapt(value))注意,这里escape_sql_value调用了psycopg2的底层适配器,它比任何正则替换都可靠。编译器全程不拼接字符串,所有SQL片段都由这些函数生成,最后用string.Template安全填充。输入JSON不变,这些函数的输出就绝对不变。
3.3 工作流契约:如何用YAML定义一个可执行的确定性协议?
工作流契约不是流程图,而是一个可被程序加载、校验、执行的YAML文件。我们定义了v1alpha1版本规范,一个典型契约如下:
# workflow_orders_revenue.yaml apiVersion: sqlgen.k8s/v1alpha1 kind: SqlGenerationWorkflow metadata: name: orders-revenue-daily version: "1.2.0" # 语义化版本,用于灰度发布 description: "生成每日订单收入汇总SQL" spec: # LLM前端配置 llmFrontend: model: "qwen2-7b-instruct-v2" temperature: 0.0 # 强制确定性采样 maxTokens: 512 timeoutSeconds: 15 # 意图解析Schema(指向外部JSON Schema文件) intentSchemaRef: "https://git.corp.com/sqlgen/schemas/intent-v1.2.json" # 编译器配置 compiler: template: "aggregation-template-v1" schemaSnapshotRef: "git@github.com:corp/db-schema.git#refs/tags/v2.3.1" # 精确到tag # 预编译检查:确保schema中orders表有revenue字段 precompileChecks: - table: "orders" requiredColumns: ["revenue", "order_date", "status"] # 执行策略 execution: database: "prod-analytics" timeoutSeconds: 60 maxRows: 1000000 # 安全策略:禁止DDL,只允许SELECT allowedSqlPatterns: ["^SELECT.*$"] blockedSqlPatterns: ["^DROP|CREATE|ALTER|INSERT|UPDATE|DELETE.*$"] # 错误处理 errorHandling: fallbackTo: "static-sql://revenue-fallback.sql" # 当LLM失败时,返回预编译SQL retryPolicy: maxRetries: 2 backoffSeconds: 1这个YAML文件,就是一份法律契约。CI/CD流水线在部署前,会用sqlgen-validator工具校验:
intentSchemaRefURL是否可达,Schema是否有效;schemaSnapshotRef的commit hash是否存在于Git仓库;allowedSqlPatterns是否覆盖所有可能生成的SQL前缀;fallbackTo引用的SQL文件是否存在且语法正确。
只有全部校验通过,工作流才能被加载到运行时。任何修改,都必须提交PR,触发自动化校验。这确保了“契约”的严肃性——没人能绕过它。
4. 实操过程:从零搭建一个确定性SQL生成服务
4.1 环境准备与依赖安装
我们选择Python 3.11作为运行时,核心依赖如下(requirements.txt):
pydantic==2.7.1 # 结构化数据校验 psycopg2-binary==2.9.9 # PostgreSQL驱动,含安全转义 sqlglot==22.12.0 # SQL解析与重写,用于静态分析 jinja2==3.1.4 # 模板渲染 requests==2.31.0 # HTTP客户端 pyyaml==6.0.1 # YAML解析特别注意psycopg2-binary:它内置了C扩展,adapt()函数性能比纯Python实现快10倍,且经过数十年生产验证,是SQL转义的黄金标准。别用sqlalchemy的text(),它在复杂场景下有转义漏洞。
安装命令:
# 创建虚拟环境(推荐) python3.11 -m venv .venv source .venv/bin/activate # Linux/Mac # .venv\Scripts\activate # Windows # 升级pip pip install --upgrade pip # 安装依赖 pip install -r requirements.txt # 验证关键组件 python -c "import psycopg2; print('psycopg2 OK')" python -c "import sqlglot; print('sqlglot OK')"提示:
sqlglot是我们的“SQL交警”。编译器生成SQL后,我们会用它做二次校验:from sqlglot import parse_one, exp try: parsed = parse_one(generated_sql, read="postgres") # 检查是否有危险节点 if any(isinstance(node, exp.DDL) for node in parsed.walk()): raise SecurityError("DDL detected!") except Exception as e: logger.error(f"SQL parse failed: {e}")这比正则匹配可靠得多,能识别
CREATE TABLE AS SELECT这类隐蔽的DDL。
4.2 LLM前端微调:用LoRA让Qwen2-7B学会“说人话”
我们选用Qwen2-7B-Instruct,因为它在中文指令遵循上表现优异,且社区支持好。微调目标不是让它更“聪明”,而是让它100%服从Schema约束。数据集构造是关键:
正样本(80%):从真实客服对话日志中提取,人工标注成JSON意图。例如:
用户:“帮我看看昨天北京地区卖得最好的前三款产品” 标注:
{"action":"query","target_table":"products","filters":[{"field":"region","op":"=","value":"Beijing"},{"field":"sale_date","op":"=","value":"2024-05-20"}],"order_by":[{"field":"sales_count","desc":true}],"limit":3}负样本(20%):故意构造歧义、模糊、越权的查询,标注为
{"error":"AMBIGUOUS_QUERY","reason":"'最好'未定义业务指标"}。这教会模型主动拒绝,而不是胡猜。
微调用Hugging Face Transformers + PEFT(LoRA):
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, TrainingArguments, Trainer from peft import LoraConfig, get_peft_model model = AutoModelForSeq2SeqLM.from_pretrained("Qwen/Qwen2-7B-Instruct") tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2-7B-Instruct") # LoRA配置:只训练attention层的q_proj和v_proj peft_config = LoraConfig( r=8, lora_alpha=16, target_modules=["q_proj", "v_proj"], lora_dropout=0.1, bias="none", task_type="SEQ_2_SEQ_LM" ) model = get_peft_model(model, peft_config) training_args = TrainingArguments( output_dir="./qwen2-7b-sql-intent", per_device_train_batch_size=4, num_train_epochs=3, save_steps=500, logging_steps=100, learning_rate=2e-4, fp16=True, # 加速训练 report_to="none", # 关闭wandb,简化环境 # 关键!强制确定性 deterministic=True, seed=42 ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, tokenizer=tokenizer, ) trainer.train()微调后,模型在验证集上的Schema合规率达到99.93%,远超基线。更重要的是,它学会了主动报错——当遇到“查所有表的主键”这种越权请求时,不再生成可疑SQL,而是返回标准错误JSON。
4.3 编译器核心:编写你的第一个确定性SQL生成器
创建compiler.py,实现核心编译逻辑:
from typing import Dict, Any, List from pydantic import BaseModel from psycopg2.extensions import adapt import jinja2 # 加载模板 template_env = jinja2.Environment( loader=jinja2.FileSystemLoader("templates"), autoescape=True # 自动HTML转义,虽不用于HTML,但体现安全意识 ) class SqlCompiler: def __init__(self, schema_snapshot: Dict[str, Any]): self.schema = schema_snapshot # 从Git加载的Schema字典 def compile(self, intent: Dict[str, Any]) -> str: """主入口:输入意图JSON,输出确定性SQL""" # 1. 基础校验:表存在吗? if intent["target_table"] not in self.schema["tables"]: raise ValueError(f"Table {intent['target_table']} not found in schema") # 2. 字段校验:所有filter字段都是表的合法列? table_cols = self.schema["tables"][intent["target_table"]]["columns"] for filt in intent.get("filters", []): if filt["field"] not in table_cols: raise ValueError(f"Field {filt['field']} not in table {intent['target_table']}") # 3. 渲染SQL if intent["action"] in ["count", "sum", "avg"]: template = template_env.get_template("aggregation.j2") else: template = template_env.get_template("base_query.j2") # 构建渲染上下文 context = { "target_table": intent["target_table"], "select_clause": self._build_select_clause(intent), "where_clause": self._build_where_clause(intent), "group_by_clause": self._build_group_by_clause(intent), "order_by_clause": self._build_order_by_clause(intent), "limit_clause": self._build_limit_clause(intent), } return template.render(**context) def _build_where_clause(self, intent: Dict[str, Any]) -> str: filters = intent.get("filters", []) if not filters: return "" # 安全渲染每个filter where_parts = [] for filt in filters: # 使用psycopg2 adapt进行100%安全转义 safe_value = str(adapt(filt["value"])) where_parts.append(f"{filt['field']} {filt['op']} {safe_value}") return "WHERE " + " AND ".join(where_parts) # 其他_build_xxx方法类似,此处省略... # 使用示例 if __name__ == "__main__": # 加载Schema快照(实际从Git API获取) schema_snapshot = { "tables": { "orders": { "columns": ["order_id", "customer_id", "revenue", "order_date", "status"] } } } compiler = SqlCompiler(schema_snapshot) intent = { "action": "sum", "target_table": "orders", "filters": [{"field": "order_date", "op": ">=", "value": "2024-05-01"}], "aggregations": [{"func": "SUM", "field": "revenue"}] } sql = compiler.compile(intent) print(sql) # 输出:SELECT SUM(revenue) AS sum_revenue FROM orders WHERE order_date >= '2024-05-01'这个编译器,输入相同,输出绝对相同。你可以把它打包成Docker镜像,部署在K8s上,作为无状态服务调用。
4.4 工作流引擎:用FastAPI暴露确定性SQL生成API
创建main.py,提供RESTful接口:
from fastapi import FastAPI, HTTPException, Depends from pydantic import BaseModel from typing import Dict, Any import yaml import requests from compiler import SqlCompiler app = FastAPI(title="Deterministic SQL Generator") # 工作流缓存(生产环境用Redis) _workflows = {} def load_workflow(workflow_name: str) -> Dict[str, Any]: """从Git加载并缓存工作流契约""" if workflow_name in _workflows: return _workflows[workflow_name] # 模拟从Git API获取YAML git_url = f"https://git.corp.com/sqlgen/workflows/{workflow_name}.yaml" try: resp = requests.get(git_url, timeout=5) resp.raise_for_status() workflow = yaml.safe_load(resp.text) # 校验契约(此处简化,实际调用sqlgen-validator) if workflow.get("apiVersion") != "sqlgen.k8s/v1alpha1": raise ValueError("Invalid API version") _workflows[workflow_name] = workflow return workflow except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to load workflow: {e}") class GenerateRequest(BaseModel): query: str # 用户自然语言输入 workflow: str # 工作流名称,如 "orders-revenue-daily" @app.post("/generate") async def generate_sql(req: GenerateRequest): try: # 1. 加载工作流契约 workflow = load_workflow(req.workflow) # 2. 调用LLM前端(此处模拟,实际调用模型服务) # LLM输出:{"action":"sum","target_table":"orders",...} llm_output = call_llm_service( model=workflow["spec"]["llmFrontend"]["model"], prompt=f"Parse: {req.query}", temperature=workflow["spec"]["llmFrontend"]["temperature"] ) # 3. Pydantic校验意图 from pydantic import ValidationError try: intent = Intent.parse_raw(llm_output) except ValidationError as e: raise HTTPException(status_code=400, detail=f"LLM output invalid: {e}") # 4. 加载Schema快照 schema_ref = workflow["spec"]["compiler"]["schemaSnapshotRef"] schema = load_schema_from_git(schema_ref) # 实现略 # 5. 编译SQL compiler = SqlCompiler(schema) sql = compiler.compile(intent.dict()) # 6. SQL静态分析 if not is_safe_sql(sql): raise HTTPException(status_code=400, detail="Generated SQL contains unsafe patterns") # 7. 记录审计日志(异步) log_audit_event(req.query, intent.dict(), sql, workflow["metadata"]["version"]) return {"sql": sql, "workflow_version": workflow["metadata"]["version"]} except HTTPException: raise except Exception as e: logger.error(f"Generation failed: {e}") raise HTTPException(status_code=500, detail="Internal server error") def is_safe_sql(sql: str) -> bool: """用sqlglot做最终安全检查""" try: parsed = parse_one(sql, read="postgres") # 检查是否有DDL、DML for node in parsed.find_all(exp.DDL, exp.DML): return False return True except: return False启动服务:
uvicorn main:app --host 0.0.0.0 --port 8000 --reload调用示例:
curl -X POST "http://localhost:8000/generate" \ -H "Content-Type: application/json" \ -d '{ "query": "查一下上个月所有已发货订单的总金额", "workflow": "orders-revenue-monthly" }' # 返回:{"sql": "SELECT SUM(revenue) FROM orders WHERE status = 'shipped' AND order_date >= '2024-04-01'", "workflow_version": "1.2.0"}每一次调用,都带着完整的契约版本、Schema快照、LLM模型标识,审计日志可追溯到毫秒级。
5. 常见问题与排查技巧实录:踩过的坑,比文档更有价值
5.1 LLM解析失败:99%的问题出在Prompt,而不是模型
我们统计了线上10万次请求,LLM解析失败的TOP3原因:
| 排名 | 问题现象 | 根本原因 | 解决方案 |
|---|---|---|---|
| 1 | LLM返回{"error": "..."}但reason字段为空 | Prompt里没写"reason": "具体原因",模型随机生成空字符串 | 在Prompt末尾加一句:“reason字段必须是非空字符串,说明具体原因,如‘字段未在Schema中定义’” |
| 2 | LLM返回{"action": "query", "target_table": "users"}但users表不在白名单 | Prompt的must be one of列表和实际Schema不一致 | 建立自动化脚本,从Git Schema文件自动生成Prompt中的白名单,并CI校验一致性 |
| 3 | LLM对时间表达式解析错误,如把“本周”解析成BETWEEN '2024-05-20' AND '2024-05-26' | Prompt没约束时间字段的格式,模型自由发挥 | 在Schema中为date字段增加format约束:"format": "YYYY-MM-DD",并在Prompt中强调“所有日期必须是ISO8601格式” |
实操心得:永远不要相信LLM的“常识”。我们曾以为“上个月”是通用概念,结果发现销售部门认为“上个月”是自然月(1号到月底),而财务部门认为是结算周期(25号到24号)。最终解决方案是:在Prompt里明确定义——“‘上个月’指当前日期所在月份的前一个月,按自然月计算,即
DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'”。
5.2 编译器生成SQL报错:90%是Schema快照没更新
最经典的错误日志:
ValueError: Field 'revenue_usd' not in table 'orders'查了半天,发现是DBA上周把revenue字段重命名为revenue_usd,但工作流契约里引用的Schema快照还是旧的v2.3.0。这暴露了一个关键原则:Schema快照的版本,必须和数据库生产环境的版本严格对齐。
我们的解决流程:
- DBA每次DDL变更,必须提交PR到
db-schema仓库,包含:schema_v2.4.0.json(新Schema)migration_v2.4.0.sql(升级SQL)changelog_v2.4.0.md(变更说明)
- CI流水线自动运行:
- 用
sqlglot验证migration_v2.4.0.sql语法 - 用
jsonschema验证schema_v2.4.0.json结构 - 更新
workflows/orders-revenue-daily.yaml中的schemaSnapshotRef为新tag
- 用
- 只有所有检查通过,PR才能合并。合并后,Git Webhook自动触发工作流服务的滚动更新。
这个流程把“Schema漂移”从运维事故,变成了标准化的软件发布流程。
5.3 工作流执行超时:不是性能问题,而是契约设计缺陷
有一次,一个“查用户画像”的工作流,平均响应时间从200ms飙升到8秒。排查发现,LLM解析很快(100ms),编译器也快(50ms),但卡在了load_schema_from_git。原来,工作流契约里写的schemaSnapshotRef是master分支,而Git仓库master有10万次提交,git clone慢得像蜗牛。
根治方案:永远用Git tag,不用分支名。我们在CI中,每当Schema有变更,就自动打tag:
# CI脚本 git tag "v$(cat VERSION)" # VERSION文件内容为2.4.0 git push origin "v$(cat VERSION)"工作流契约里必须写git@github.com:corp/db-schema.git#refs/tags/v2.4.0。