做数据平台这几年,我统计过团队需求构成里最磨人的一类:不是复杂的建模,不是埋点设计,而是"帮我查个数"这种看似简单、实际循环往复的请求。业务同学不会SQL,数据分析师每天被工单打断,一个三分钟能跑完的查询,从提需求到拿到数据经常要花半天。后来我们决定做一个基于LangChain的SQL Agent,让业务同学用自然语言直接问数据,把重复取数这件事逐步自动化。这篇文章把我从方案设计、代码落地到生产环境踩坑的完整过程讲清楚,适合正在做AI数据助手、Text2SQL产品或者数据平台自动化的读者参考。
1. 为什么要做企业级SQL助手:传统"提数"流程的三大死穴
1.1 需求排期与沟通损耗:真正的时间浪费在"对齐口径"
传统的取数流程是这样的:业务同学在工单系统里提一个需求,写"我要看昨天的订单情况",数据分析师收到之后开始猜——"昨天"是自然日还是业务日?"订单情况"是订单量、GMV还是毛利率?要不要排除测试订单?退款算不算?来回确认两三轮,半天就没了。等口径终于对齐,真正执行SELECT的时间可能不到五秒。
我们统计过某业务线一个季度的提数工单,平均流转时长是5.2小时,其中数据分析师实际写SQL的时间平均只有18分钟。剩下的时间全部消耗在需求澄清、排队等待、上下文切换上。更麻烦的是,这种请求占了总工单量的六成以上,量级大到让分析师根本没精力去做有价值的专题分析。
1.2 SQL Agent能做什么、不能做什么
先泼一盆冷水:SQL Agent不是"万能取数机器人"。它能解决的是查询结构清晰、口径有文档沉淀、表结构稳定的需求。对于口径还在争论中、需要跨十几张表做探索性分析的场景,或者数据质量本身有问题的表,Agent再聪明也救不了。
我把合适和不合适的场景整理成了一张表,这张表后来也成了我们内部判断需求能不能上的标准:
| 场景 | 适不适合上SQL Agent | 原因 |
|---|---|---|
| 日常看板口径复核 | 非常适合 | 表和指标固定,查询结构模板化 |
| 临时性多条件筛选 | 适合 | 模型擅长组合WHERE条件、时间范围等 |
| 跨部门复杂指标口径探索 | 不建议 | 语境信息不足,幻觉率会明显升高 |
| 数据异常根因分析 | 不建议 | 需要大量业务上下文和归因逻辑 |
| 写操作、删改操作 | 坚决不碰 | 风险不可控,任何场景都不应该开放 |
SQL Agent的核心价值是把分析师从"重复回答同一个口径问题"里解放出来。口径定义清楚之后,交给Agent,它每次都用同样的标准去理解、生成和执行,反而比人更稳定。人的耐心会波动,Agent不会。
2. LangChain SQL Agent的运行机制:从自然语言到安全SQL的四次跳转
2.1 ReAct循环:模型、工具与数据库之间的三角对话
LangChain SQL Agent本质上是一个ReAct模式的Agent。ReAct是"推理+行动"的组合:模型先看到用户问题,决定要不要调用工具,然后根据工具返回的观察结果继续推理,直到它认为可以给出最终答案。整个循环由三股信息驱动:思考(Thought)、动作(Action)、观察(Observation)。
对不熟悉的人,我习惯用实习生来类比:你请了一个实习生,他会先看需求,然后查表结构,再写SQL跑一下,看到结果发现没有数据,就改成按日期分区再查一次,直到结果合理才回来汇报。SQL Agent也是这样工作的,只是"查表结构""写SQL""执行SQL"分别被封装成了工具调用。
举个我们生产环境里的真实例子。用户问"华东区上周每天的订单量是多少",Agent的第一步不是直接写SQL,而是先看订单表有哪些字段、时间字段叫什么、地区字段存的是省还是城市。确认完之后写SQL,执行,发现返回的行数比预期少,模型看到结果集只有四天数据,自动推断出可能是时区问题,补上一个时区转换再跑一次。这种"执行后发现问题并回头修正"的能力,是它和单次Text2SQL接口最大的区别。
2.2 表结构检索与Schema注入:提示词里的"数据库地图"
SQL Agent要生成能跑的SQL,前提是知道数据库里有哪些表、表里有哪些字段、字段含义是什么。LangChain的SQL Toolkit提供两个层次的信息注入:第一层是通过SQLDatabase对象用include_tables参数从库里挑出参与查询的几张核心表;第二层是把每张表的建表语句拼进System Prompt,配合sample_rows_in_table_info把样例数据也塞进去。
不要小看这个设计。表结构信息不是越多越好,而是"边界清晰、样例可理解"最好。我们最初做技术验证的时候,把整个库三百多张表的DDL全塞进去,结果模型被无关表干扰,明明用户问的是订单,它非要join一张用户行为埋点表,生成一个逻辑错误但语法完全正确的SQL。后面在第6章会详细讲怎么裁剪Schema。
2.3 执行反馈与自纠错:Agent比Text2SQL稳定的核心原因
能不能把SQL执行结果反馈给模型继续推理,是Agent方案和普通Text2SQL方案的分水岭。执行遇到语法错误时,数据库报错信息会返回给模型,模型根据报错修正SQL再执行;执行结果为空时,模型会回头看自己的WHERE条件是否合理,尝试调整时间范围或筛选逻辑。
这个自纠错能力不是无上限的。我们生产环境把max_iterations设置为5,也就是模型最多思考+行动五轮。实测下来,绝大多数语法错误在前两轮就能修正完毕,超过五轮还在打转的基本是逻辑问题,让它继续跑只会浪费Token、浪费时间,还可能在错误的方向上越跑越远。
2.4 多轮对话的上下文控制:不要无脑追加历史
用户不会只问一句话。我们的产品里,用户经常追问"那上个月呢""按华东区域再拆分一下""换成按周统计"。SQL Agent把人话里的指代转换成新的SQL,依赖的是对话上下文。LangChain提供了memory参数,可以直接把历史消息灌给模型。
但企业级场景里,我更推荐在应用层主动维护一个"压缩后的上下文",而不是让Agent的对话历史无限膨胀。每一轮对话的历史都会追加进Prompt,历史越长Token开销越大,而且模型容易被几轮之前的错误SQL带偏。我们的做法是:把多轮会话压缩成"用户最新问题+当前筛选条件+最近一次成功SQL"三段式上下文。效果比无脑追加全部历史好得多,响应也更快。
3. 完整实现方案:技术选型、系统架构与连接层设计
3.1 技术选型与版本基线:为什么是LangChain加PostgreSQL
团队技术栈以Python为主,落地时LangChain版本固定在0.2.x,LLM用的是gpt-4o,数据库以PostgreSQL为主。这个选型逻辑其实没有太多花哨的理由:LangChain社区对SQL Toolkit的支持最成熟,create_sql_agent这个封装把Agent编排、工具注册、执行回环都处理好了;PostgreSQL配合SQLAlchemy的适配非常稳定,只读事务、statement_timeout这些安全特性都支持得很好;gpt-4o的SQL生成能力在通用模型里属于第一梯队,不需要微调就能覆盖大多数常规查询。
这里要强调一句:LLM选型要看"稳定执行率",光看排行榜分数没用。我们试过几个模型,有的在公开benchmark上分数不低,但一放到生产环境面对真实的字段注释、枚举值、脏数据就经常跑偏。最后老老实实回到gpt-4o,不是因为它是所有任务里最强的,而是在SQL生成这个任务上表现最稳。
3.2 请求链路与模块划分:SQL执行必须单独隔离
整体的请求链路是这样的:业务前端把自然语言问题发到应用服务,应用服务先做权限校验和会话管理,然后调用SQL Agent。Agent内部先读取表结构信息,再让LLM生成SQL,SQL经过静态审查通过后才会真正执行。执行结果返回给LLM做最终解释,最后把自然语言答案和数据表格一起返回给前端。
这条链路里最关键的设计是:SQL执行被单独封装成一个工具,工具内部挂了只读校验、超时控制、行数限制和脱敏逻辑。所有安全检查都放在工具层,而不是让LLM自己判断能不能执行。这个决策来自一个朴素的原则:模型是"建议者",工具才是"执行者",安全边界必须落在执行者身上,不依赖模型的自律。
3.3 连接池与只读事务:最后一道防线写在数据库层
我们用SQLAlchemy创建引擎,连接池和生产配置如下:
from sqlalchemy import create_engine engine = create_engine( "postgresql+psycopg2://user:password@host:5432/mydb", pool_size=10, max_overflow=5, pool_pre_ping=True, pool_recycle=3600, connect_args={"options": "-c default_transaction_read_only=on"}, )两个关键点解释一下:pool_pre_ping=True让连接池在每次拿连接前先探活,避免数据库重启后拿到一堆失效连接,这在生产环境是必选项;default_transaction_read_only=on在数据库会话级别强制只读,这是最后一道防线,哪怕LLM抽风生成了UPDATE语句,也会在数据库层被直接拒绝。
从架构上,只读控制至少要有三层:数据库账号权限、会话级只读、Agent工具层的SQL审查。缺任何一层都不安全,因为每一层都可能被绕过——账号权限可能配错,会话级只读可能被连接复用绕过,工具层审查可能被新型SQL写法绕过。三层叠加,才叫真正的只读。
4. 核心代码走读:Agent编排、SQL执行器与Prompt工程
4.1 只读Agent的构建与关键参数
LangChain的SQL Toolkit把大部分脏活封装好了,构建一个只读Agent的核心代码如下:
from langchain_community.utilities import SQLDatabase from langchain_community.agent_toolkits import create_sql_agent from langchain_openai import ChatOpenAI db = SQLDatabase.from_uri( engine.url, include_tables=["orders", "users", "products", "payments"], sample_rows_in_table_info=3, max_token_limit_for_table_info=3000, ) llm = ChatOpenAI( model="gpt-4o", temperature=0, ) agent_executor = create_sql_agent( llm=llm, db=db, agent_type="openai-tools", verbose=False, max_iterations=5, max_execution_time=30, )几个容易被忽略但很重要的参数:
temperature=0:SQL生成必须确定性优先,温度稍微调高一点模型就开始"发挥",同一个问题每次生成的SQL不一样,这对生产环境是灾难。sample_rows_in_table_info=3:每张表塞三行样例数据进Prompt,让模型理解字段的真实格式(比如日期是时间戳还是字符串、金额单位是分还是元),但样例行数不宜多,否则纯粹烧Token。max_execution_time=30:这是Agent总的执行时间上限,防止模型陷入无限推理循环。配合单条SQL的statement_timeout,才能保证一个失控请求不会拖垮数据库。
4.2 把安全检查挂载进SQL执行器
create_sql_agent默认的SQL执行逻辑是直接调用的,生产环境必须重写成独立的"SQL执行器"工具。这个工具内部按顺序做四件事:静态审查、参数绑定、执行、结果裁剪。改造后的核心逻辑大致是这样:
def execute_safe_sql(sql: str, engine) -> dict: review_result = review_sql(sql) # 第5章会详细讲 if not review_result["pass"]: return {"error": f"SQL被安全策略拦截: {review_result['reason']}"} with engine.connect() as conn: conn.execute(text("SET LOCAL statement_timeout = 10000")) result = conn.execute(text(sql + " LIMIT 200")) columns = result.keys() rows = [dict(zip(columns, row)) for row in result.fetchall()] return {"columns": columns, "rows": rows}这里有两个容易被忽视的设计。
第一,statement_timeout设置为10秒,单条查询超过10秒直接终止。很多慢SQL问题不是语法问题,而是没有限定时间,一个笛卡尔积就能把生产库拖垮。10秒是我们统计了线上查询耗时分布后定的值,90%的合法查询在3秒内完成,10秒已经给了很大余量。
第二,LIMIT 200强制限制返回行数。用户问"所有订单明细"时,模型生成的SQL如果不加LIMIT,可能一次返回几十万行。限制返回行数不仅保护了网络传输,也保护了模型处理结果时的Token开销。
4.3 企业级Prompt模板里的三条军规
用内置Prompt跑Demo没问题,但企业场景必须自定义System Prompt。我们给Prompt注入了几类额外信息:口径定义、SQL书写规范、禁区声明。这里截取一段我们实际在用的模板:
你是一个数据查询助手。请严格遵循以下规范: 1. 日期字段统一使用 created_at,默认按北京时间(UTC+8)解释。 2. "新客"定义为首次支付成功时间在筛选窗口内的用户。 3. 金额字段单位是分,输出前请除以100换算为元。 4. 禁止生成 INSERT、UPDATE、DELETE、DROP 等非查询语句。 5. 如果用户问题涉及口径不明确的指标,先向用户确认,不要自行假设。 6. 所有SQL必须包含明确的表名和字段名,禁止 SELECT *。口径定义这块,是SQL Agent好不好用的分水岭。同一个"GMV",有人按支付时间算,有人按订单创建时间算,模型只要有一次理解错了,业务对这个工具的信任就崩塌。我们的做法是把口径文档以结构化文本的形式合入Prompt,并且每次口径变更都走一次回归评测(第6章会讲评测集怎么建设)。
5. 企业级SQL助手的安全闸门:权限隔离、熔断、审查与审计
5.1 行级权限强制过滤:让每个用户只看自己的数据
很多团队做SQL Agent只做了表权限,没有做行级权限。但实际问题往往是:不同业务线的用户都能访问同一张订单表,但只应该看到自己业务线的那部分数据。
我们的做法非常朴素:在SQL执行前,根据当前用户所属的业务域,强制往SQL的WHERE条件末尾追加一个AND business_line = 'xxx'。这个后缀来自用户Token里的权限声明,用户自己无法篡改。实现代码大概是:
forced_filter = f"AND {tenant_column} = '{tenant_id}'" sql = add_forced_filter(sql, forced_filter)这里有个细节要注意:强制过滤的字段名必须提前声明,并且要和Agent使用的表名完全一致,避免用户SELECT的别名和表名冲突导致SQL执行报错。另外,这个追加动作发生在SQL执行工具内部,而不是在Prompt里让模型"自觉"加条件,因为模型的自律在安全地形里永远不可靠。
5.2 超时熔断与并发限流:三个数字守护数据库
SQL Agent在企业落地,最大的风险不是模型出错,而是模型生成一个没有WHERE条件的全表扫描,或者多张大表互相JOIN的怪物查询。我们在三个层面做了熔断:
- 单条SQL执行时间:statement_timeout=10秒,执行超时直接终止;
- 单次Agent总耗时:max_execution_time=30秒,包含多轮ReAct循环;
- 应用层并发限流:每个账号同时最多3个查询,超出排队等待。
这三个数字不是拍脑袋定的。我们统计过生产环境的查询耗时分布,90%的合法查询在3秒内完成,10秒已经给了很大余量。再长就不是"查数"了,是"跑批",跑批应该走离线调度平台,而不是在线Agent。
5.3 SQL静态审查与危险语句拦截:第一道闸门必须放在应用层
静态审查的核心思路是区分"模型生成的SQL"和"真正能执行的SQL",中间加一道闸门。我们用了两层:第一层是正则黑名单,匹配明显的危险关键字、注释混淆、堆叠查询;第二层是词法分析,把SQL解析成AST后检查类型,确保是单一的SELECT语句,且不包含SELECT *。
import re DANGEROUS_PATTERNS = [ r"\b(insert|update|delete|drop|alter|truncate|create|grant)\b", r";\s*\w+", r"\/\*.*?\*\/", ] def review_sql(sql: str) -> dict: lowered = sql.lower() for pattern in DANGEROUS_PATTERNS: if re.search(pattern, lowered): return {"pass": False, "reason": f"命中危险模式: {pattern}"} if lowered.count("select *") > 0: return {"pass": False, "reason": "禁止全表字段查询"} return {"pass": True}这个方案的优势是轻量、响应快、不阻塞请求。缺点也很明显:无法识别语义层面的恶意操作,比如一条SELECT里带了UPDATE子查询。所以它定位为"第一道闸门",真正的底线仍然是数据库账号权限和会话级只读。三层配合,才敢把执行能力开放给Agent。
5.4 敏感字段脱敏与审计日志:数据平台专业度的试金石
看一个数据平台做得专不专业,就看它的脱敏和审计做得细不细。我们在SQL执行器里做了字段级脱敏:如果查询目标表包含手机号、邮箱、身份证这类字段,默认返回脱敏版本,只有白名单里的管理角色能看原始值。这个能力一开始没有,是业务同学在试用时发现"我能看到别人的手机号"才紧急补上的。
同时,所有查询请求、生成的SQL、执行结果摘要、用户反馈,全部写入审计日志。日志里至少包含:用户ID、请求时间、原始问题、生成的SQL、是否被拦截、执行耗时、返回行数。这份审计日志既是合规需要,也是后续评测集的天然数据来源——我们所有的回归测试数据都是从审计日志里捞的。
6. 性能优化与踩坑实录:从Token爆炸到SQL幻觉
6.1 Schema过载引发的Token爆炸:信息不是越多越好
第一次联调时,我们天真地把全库DDL都注入了Prompt,结果一次请求光表结构就要吃掉一两万个Token。不仅慢、贵,而且模型被无关表干扰,生成SQL的准确率反而下降。
解决办法是三层裁剪:
- SQLDatabase构造时用
include_tables只暴露业务高频的20张表; max_token_limit_for_table_info限定表结构部分的总Token上限;- 给表名和字段名补业务注释,让模型少用"猜"的。
实测从300张表裁到20张核心表之后,Prompt总Token数降了约三分之二,SQL执行准确率反而上升了。这说明一个道理:对模型来说,信息是"够用且相关"最好,而不是"越多越好"。无关信息只会增加干扰。
6.2 幻觉SQL的识别与兜底:语法正确但逻辑错误的查询
模型会一本正经地生成一个查不出任何数据的SQL,这属于典型的幻觉问题。比如业务问"各渠道的转化漏斗",如果表里根本没有完整的漏斗事件链路,模型就会自动join几张看似相关的表,生成一个逻辑错误但语法完全正确的SQL。
这种问题比语法错误难处理得多,因为数据库不会报错,结果就是返回空数据或者明显异常的数据。我们的兜底策略有三条:
- 结果行数为0且查询条件没有明显错误时,提示用户检查口径或反馈"数据为空";
- 对返回结果做字段级校验,识别日期乱码、金额为负、比率为负数等明显异常;
- 关键指标设置"预期范围",超出范围立刻标记。
这三条策略不需要模型参与,纯规则校验,速度快、可解释性强。它们不能完全消除幻觉,但能把幻觉SQL的结果拦截在"给用户看到"之前。
6.3 并发场景下的连接池耗尽:Demo正常、上线崩掉的经典案例
技术验证阶段一切正常,一上生产并发一高就发现数据库连接被打满。排查下来原因有两个:一是连接池的max_overflow配得太宽松,二是Agent在max_iterations内可能执行多次SQL,一次用户请求实际占用了多个连接,等于把并发放大了一倍不止。
调整方案是:把连接池收缩到pool_size=10, max_overflow=5,并且让Agent的SQL执行统一走同一个Engine连接,而不是每次重新创建连接。更重要的是在应用层统计SQL Agent的单次请求数据库占用时间,把它计入限流判断。只有这样才能避免并发一高就把生产库拖垮。
6.4 评测集:唯一可信的迭代标尺
SQL Agent不是上线就完事。没有评测集,你根本不知道换一个Prompt模板、换一个模型版本,效果是变好了还是变坏了。这是个特别容易被忽视的坑——凭感觉调Prompt,调完感觉"好像好点了",实际上可能是随机误差。
我们从审计日志里抽了500条真实问题,人工标注成三类:正确执行、执行但结果错误、完全错误。每类再配上标准SQL作为对照。之后所有Prompt调整都必须跑这500条回归,用SQL执行准确率作为唯一通行证。准确率没提高或者下降了,哪怕局部例子看起来更漂亮,也一律不发布。
这套评测集的价值会随时间增长而增长。模型版本升级、业务口径调整、新表上线,都先跑一遍评测集,再决定要不要放量。我们每次对业务答复"口径已更新",前提一定是评测集里对应问题的回答没有漂移。
说实话,SQL Agent这类项目,最难的从来不是把Demo跑通,而是把"偶尔好用"变成"持续可信"。我从这个项目里最大的收获是:Agent的能力边界要提前划清楚,安全防线要层层叠叠而不是寄托于模型自觉,评测集要上线第一天就建,后续所有优化才有依据。如果你的团队也在做类似的AI数据助手,希望这篇基于实践的文章能帮你少走几步弯路。