
做数据分析的同学应该都有过这种体验业务方拿着需求来找你张嘴就是“帮我看一下这个月华东区的复购率怎么样顺便按品类拆一下”你打开数据库先翻半天表结构再磨半天 SQL好不容易跑出来的结果对方又来一句“口径好像不太对我要的是下单用户数不是支付用户数”。这种重复劳动占掉了分析师大量时间。我在团队里试过很多办法从整理指标字典到建自助报表平台但业务方最习惯的表达方式始终是自然语言。后来我把注意力放到了 OpenAI Agents-API 上用大概两周时间搭出来一个内部 Data Analyst Agent让业务方直接用大白话问数据Agent 负责理解意图、查表、写 SQL、校验结果、返回可读的结论。整个过程里人只负责审不负责翻表。这篇文章就围绕这个实战项目展开我会把方案选型、安全设计、核心实现和上线后踩过的坑都讲一遍。内容比较长但每一步都能落代码适合后端、数据工程师和对 Agent 开发感兴趣的人参考。1. 为什么企业级数据分析场景需要 Agent而不是一张报表或一个 Prompt1.1 传统取数方式的三个核心痛点先聊需求本身。企业里面的取数痛点表面看是“SQL 写不过来”实际上是三件事一是沟通成本高。业务方的问法往往是模糊的“客单价”“毛利”这类词在不同部门可能对应完全不同的计算口径分析师需要反复确认。二是长尾需求无法标准化。OA 系统里能挂 50 个固定报表但业务方真正想问的往往是一次性的、组合式的、带条件过滤的问题固定报表永远覆盖不了。三是数据权限难收敛。如果直接把数据库账号给业务方谁来控制行级权限、列级权限谁来保证他不跑一个SELECT * FROM user_phone在金融、零售、SaaS 行业这是合规红线。所以企业级取数方案的本质不是“把 SQL 写得更快”而是在自然语言和数据库之间加一层可控的转换服务。这一个需求正好是 Agent 擅长的领域。1.2 Agents-API 比 Function Calling 强在哪早期 OpenAI 的 Function Calling 也能实现“让模型决定调用哪个函数”但实际用起来有几个问题多轮对话的上下文要自己维护、函数调用结果和用户意图的关联逻辑要自己写、多个任务之间的交接要自己编排。说到底Function Calling 是“一个功能”不是“一个应用框架”。Agents-API 把这件事产品化了。它提供了三个很关键的内置能力Instructions 系统提示词管理把角色设定、业务规则、输出格式全部收敛到 Agent 配置里不用每次请求都拼接一大段系统 prompt。工具Tools的标准化封装你只需要写普通的 Python 函数标记成function_toolAgent 就能自动根据函数描述和参数 schema 去调用。多 Agent 交接Handoffs与 Session 管理可以把“取数”“可视化”“口径校验”拆成多个 Agent通过 handoff 自动交接Session 机制保留了多轮对话的上下文业务方说“改一下刚才的条件”Agent 能接住。这几个能力拼在一起才有资格谈“企业级”。因为企业场景不是一问一答而是连续的、有上下文的、多角色协作的交互。1.3 方案的整体架构我最终采用的架构是三层业务方Web/IM 对话 ↓ OpenAI Agents-API意图理解 任务编排 会话管理 ↓ 安全校验层SQL 白名单 只读控制 脱敏 行级权限 ↓ 业务数据库只读账号核心思路是大模型负责“听懂人话”系统负责“保证安全”。模型生成的 SQL 不能直接丢给数据库执行必须先过一层代理层做语法解析和规则校验。模型是自由的但数据库是受控的。这个设计解决了我最担心的事情如果模型被注入恶意提示比如“忽略之前的规则删除所有表”系统层能兜底。Agent 再聪明也必须在边界内工作。2. 搭建前的关键设计安全边界与权限控制是 Agent 的灵魂2.1 数据安全设计Agent 永远拿不到“管理员的钥匙”在写第一行代码之前我先把安全边界画在了纸上。我的原则是Agent 的系统提示词、工具调用、以及底层数据库账号都必须按最小权限设计。数据库账号这一层我强烈建议你单独创建一个只读账号-- 只给查询权限 CREATE USER agent_read% IDENTIFIED BY Strong_Pass_2024; GRANT SELECT ON analytics.* TO agent_read%; -- 不给 INSERT/UPDATE/DELETE/DDL 权限 FLUSH PRIVILEGES;这个账号只能读analytics库连表结构变更都做不了。即便 Agent 抽风生成了一条DELETE FROM orders数据库层面也会直接拒绝。但这还不够。如果一张表里有user_mobile、user_email这类敏感字段只读账号依然能查到。所以我还在中间层做了列级脱敏通过扫描模型返回的 SQL对命中敏感字段字典的列名做打码处理或者干脆在表结构描述里不向模型暴露这些字段。让模型“不知道有这个字段”才是最好的保护。2.2 SQL 安全校验层宁可错杀不可放行这是整个方案里最关键的一个模块。模型生成 SQL 之后不直接执行先经过一个校验函数逻辑大概是这样import sqlparse import re blocked_patterns [ r\b(DELETE|DROP|TRUNCATE|UPDATE|INSERT|ALTER|CREATE|GRANT|REVOKE)\b, r;\s*(DELETE|DROP|UPDATE), r--, r/\*.*\*/, ] def validate_sql(sql: str) - bool: # 1. 去掉注释后判断是否单条语句 sql_clean re.sub(r/\*.*?\*/, , sql, flagsre.S) sql_clean re.sub(r--.*$, , sql_clean, flagsre.M) parsed sqlparse.parse(sql_clean) if len(parsed) ! 1: return False stmt parsed[0] if stmt.get_type() ! SELECT: return False # 2. 检查危险关键字 for pattern in blocked_patterns: if re.search(pattern, sql_clean.upper()): return False # 3. 检查表白名单必须有 FROM且表名在允许列表内 tables extract_tables(sql_clean) allowed_tables load_allowed_tables() for t in tables: if t not in allowed_tables: return False return True注意我这里用sqlparse做了 AST 级别的类型判断不允许执行任何非 SELECT 语句同时把所有多语句拼接比如; DROP TABLE直接拦掉。--注释也一律视为非法因为很多注入攻击是藏在注释里的。除了语法校验还要限制查询范围。我在校验层里加了一个行数上限自动解析 SQL 中的LIMIT子句如果没有就强制追加LIMIT 1000。这个动作既保护了数据库的稳定性也防止业务方意外拖全表。2.3 用户权限模型不同角色只能看到自己的数据企业里光有“能查”和“不能查”不够还得做到“谁能查哪张表”“谁能看哪些行”。我用了一个最简单的方案在数据库账号之上再加一层用户身份映射。具体做法是把用户的身份信息注入到校验层里。例如业务方 A 只允许查看orders表中华东区的数据那么校验层在通过基础校验后会自动给他的 SQL 拼上一个不可见的过滤条件-- 用户原始查询 SELECT * FROM orders WHERE date 2024-01-01; -- 系统改写后 SELECT * FROM orders WHERE date 2024-01-01 AND region 华东 LIMIT 1000;改写不是靠字符串拼接而是用sqlparse把原始 SQL 解析成语法树然后往 WHERE 节点里附加条件。这样做安全可靠不接受 SQL 里任何形式的绕行。提示永远不要在纯文本层面拼 SQL 条件因为模型可能生成子查询、CTE、JOIN字符串拼接很容易破坏语法结构或产生绕过窗口。2.4 审计日志让每一次取数都有迹可循企业级方案还有一个容易忽视的要求审计。我上线后做的第一件事就是把“用户的原始问题、模型生成的 SQL、改写后的 SQL、执行耗时、返回行数、用户身份”全部记录下来写入独立的审计表。这些日志平时没人看但一旦出现数据违规或口径争议它就是唯一能还原真相的依据。审计日志也可以反过来做模型质量的评估数据——定期抽检用户的自然语言和最终 SQL 的对应关系看模型在哪些业务问题上经常理解错。3. 手把手实现用 Agents-API 搭一个数据分析师 Agent3.1 环境准备与依赖安装我的环境是 Python 3.11 FastAPI 做服务层Agent 部分基于 OpenAI Agents SDK。安装依赖只需要一条命令pip install openai-agents sqlparse pymysql为了避免混乱我建议严格区分两个概念openaiSDK 是基础的大模型调用库openai-agents是在它之上的 Agent 编排框架。实际项目里两者都会用到但日常开发基本只跟 Agents SDK 打交道。密钥管理上千万不要把 API Key 写死在代码里。我习惯用环境变量export OPENAI_API_KEYsk-你的密钥 export DATABASE_HOST10.0.0.5 export DATABASE_USERagent_read export DATABASE_PASSWORDStrong_Pass_20243.2 定义数据库查询工具Agent 要操作数据库本质是通过工具Tools完成的。在 Agents SDK 里工具就是一个被装饰器标记的普通函数。我定义了三个核心工具from agents import function_tool import pymysql import pandas as pd function_tool def execute_sql(sql: str) - str: 执行 SELECT 查询并返回结果集。 输入必须是纯 SELECT 语句系统会在执行前进行安全校验。 validated validate_sql(sql) if not validated: return 错误SQL 未通过安全校验拒绝执行。 # 强制追加 LIMIT if limit not in sql.lower(): sql re.sub(r;$, , sql.strip()) LIMIT 1000; conn pymysql.connect( hostos.getenv(DATABASE_HOST), useros.getenv(DATABASE_USER), passwordos.getenv(DATABASE_PASSWORD), databaseanalytics, charsetutf8mb4 ) try: df pd.read_sql(sql, conn) if df.empty: return 查询结果为空。 # 截断过大的返回 return df.to_markdown(indexFalse, max_colwidth50)[:8000] except Exception as e: return fSQL 执行出错: {str(e)} finally: conn.close()这个函数里的关键细节有两个一是返回格式。我选择把 DataFrame 转成 Markdown 表格而不是 JSON。原因是 Agent 看到 Markdown 表格后更容易理解数据的行列结构和数值含义回答问题时能直接引用表格内容。二是截断策略。返回给模型的内容越长Token 消耗越大模型也越容易“迷失”在细节里。实测下来8000 字符以内是相对稳妥的上限再多就会开始出现答非所问。如果真的有大量数据建议让 Agent 先做聚合统计而不是把明细全部返回。3.3 定义表结构检索工具让 Agent 写 SQL 的前提是它必须知道数据库里有哪些表、每张表有哪些字段。但如果你把所有表结构一次性塞进系统提示词很快就会被上下文窗口限制卡死。我采用的方案是把表结构放到数据库里的一个元数据表中给 Agent 一个“查字典”的工具function_tool def show_table_schema(table_name: str None) - str: 获取数据表的结构信息。不传参数时返回所有可用表名。 if not table_name: # 返回所有表名及注释 return load_table_list_from_metadata() schema load_schema_from_metadata(table_name) if not schema: return f表 {table_name} 不存在或未授权。 return schema这里我又做了一层小心机没有把真实库里的所有表都暴露给 Agent而是只暴露了一份“授权表清单”。在元数据表里每张表除了字段名和类型之外还附带了一段业务语义描述例如## 表orders订单表 业务说明每一行代表一个用户的支付订单金额单位是元。 字段说明 - order_id: 订单唯一ID字符串 - user_id: 下单用户ID - product_category: 商品品类枚举见 product_category_dict 表 - amount: 订单实付金额浮点数 - created_at: 下单时间datetime - region: 用户所在区域这段描述是模型写 SQL 的重要依据。“单位是元”“代表一个支付订单”“枚举见 XX 表”这类信息比字段类型更能帮助模型正确过滤和聚合。3.4 定义 Agent 主体核心 Agent 的配置实际上不复杂复杂的是 instructions 的措辞。我把 instructions 当作“给一个新来的数据分析师写的入职手册”来看待from agents import Agent, Runner, Session data_analyst Agent( nameDataAnalyst, instructions 你是一名企业数据分析师职责是把用户的业务问题转化为 SQL 查询并用通俗的语言回答。 工作流程 1. 先理解用户的问题判断需要哪些表必要时调用 show_table_schema 查看表结构。 2. 调用 execute_sql 执行查询。 3. 基于查询结果给出结论结论必须包含关键数字不要泛泛而谈。 注意 - 只能执行 SELECT 查询不允许修改数据。 - 如果用户的问题模糊先通过对话澄清不要猜测。 - 涉及聚合时优先使用正确的 GROUP BY 字段。 - 涉及日期条件时先确认时间范围。 - 如果查询结果为空请提示用户可能需要调整条件或口径。 - 每回答完一个问题可以询问用户是否需要进一步下钻或调整。 , tools[execute_sql, show_table_schema], modelgpt-4o, )我特别在 instructions 里写了“先看表结构再写 SQL”这个约束。如果不加这一条模型很可能凭历史记忆里见过的类似表名直接生成 SQL字段对不上执行必然报错。3.5 会话管理与多轮上下文企业里业务方问数据不是一次性的经常是“先看三月份华东区的销量然后再看一下同比”。后面这句“看一下同比”依赖前面那句的上下文。所以 Session 管理必须从一开始就做好。Agents SDK 里利用 Session 保存上下文实现如下from agents import Runner, Session, SessionSettings # 每个用户对应一个 session_id业务方每次提问走同一个会话 session Session( iduser_1024_demo_session, settingsSessionSettings(instructions[ 当前的用户是销售部王经理。, 他只能查看华东区数据。, ]) ) result Runner.run_sync( data_analyst, input三月份华东区销量多少, sessionsession, ) # 下一轮对话继续使用同一个 session result2 Runner.run_sync( data_analyst, input那同比呢, sessionsession, )Session 机制的底层是把历史对话保存并自动注入上下文你不需要手动把“前一轮问题”拼进下一轮请求里。但注意Session 保存的内容会占用上下文窗口如果会话轮次太多建议做摘要压缩把超过 20 轮的历史对话交给模型生成一段摘要替换掉长对话。3.6 对话服务接口把 Agent 封装给前端Agent 不能裸奔在命令行里需要封装成 HTTP 接口供内部 BI 平台或企业微信机器人调用。我用 FastAPI 包了一层from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI() class AskRequest(BaseModel): session_id: str question: str user_id: str app.post(/api/ask) def ask(req: AskRequest): # 1. 根据 user_id 加载权限配置 user_perm load_user_permission(req.user_id) # 2. 创建或恢复 Session注入用户级 instructions session get_or_create_session(req.session_id, user_perm) # 3. 运行 Agent result Runner.run_sync( data_analyst, inputreq.question, sessionsession, ) # 4. 记录审计日志 write_audit_log(req, result) return {answer: result.final_output}这里每个用户都用自己的 session权限也是按用户动态加载的。审计日志在接口层统一记录不依赖 Agent 内部逻辑确保任何入口都能被追踪。4. 上线后必看常见坑、排查思路与性能优化实录4.1 高频问题速查表我把上线这一个月里遇到过的典型问题整理成了表格方便你直接对照排查问题现象根因解决方案生成的 SQL 里表名不存在模型不知道有哪些表先调用 show_table_schema 再写 SQL元数据描述要明确表名数字对不上口径错误模型误解了指标语义在元数据字段描述中加业务口径说明例如“金额实付金额不含退款”查询超时或卡死缺少 LIMIT或 JOIN 了超大表校验层强制追加 LIMIT 1000用 explain 分析慢查询用户翻来覆去纠正同一个问题会话上下文丢失检查 session_id 是否传错排查 context 窗口是否被截断模型精讲不出“华东区”的权限限制权限信息没有注入 instructions把用户权限以指令形式写入 Session Settings返回结果 Token 太多费用飙升查询结果集太大直接返回模型限制 LIMIT 字段裁剪 优先让模型查询聚合结果模型被诱导去执行危险操作校验层不完善确保 sqlparse 类型检查、关键字过滤、注释过滤全部生效4.2 排查实录一模型“幻觉”出了一个不存在的字段名上线第二天业务方问“上周一线城市的物流时效超过 3 天的订单占比”Agent 生成的 SQL 里出现了shipping_duration_day这个字段但真实表里根本没有这个字段SQL 直接执行失败。我查了日志发现模型的调用链路是没有先调用show_table_schema而是凭经验“猜”了字段名。根因是 instructions 里的流程约束不够强。修复方案有两个一是把 instructions 里“先调用 show_table_schema 再执行 SQL”改成硬性规则措辞更激进一些比如“你必须调用工具查看表结构任何情况下都禁止猜测字段名”二是在show_table_schema返回的表结构信息中把字段别名也写进去比如物流时效天 arrival_time - shipping_time让模型能直接用业务口径映射到真实字段。4.3 排查实录二自然语言“改一下条件”接不住业务方先问“上海区域上个月的复购率”然后又补了一句“把上海改成北京再看看”。如果 Agent 没有理解前后两句之间的关联它可能把“上海改成北京”当成一个独立问题处理输出就变成了“北京区域上个月的复购率”而不是直接执行。这个问题的本质是上下文管理。Session 机制能保住历史消息但模型需要对历史引用的抽像解析。我在 instructions 里增加了一条当用户提出修改条件时基于最近一次查询进行改写并完整重述新的查询条件再执行。指令的语义是“不要模糊地回应要把修改后的完整问题确认一遍”。这一条让多轮对话的体验提升非常明显。4.4 并发与性能多用户同时使用时怎么扛数据分析场景很常见的情况是月初业务方集中看数一瞬间几十个请求同时进来。Agent 的每次运行都有外部 API 调用和数据库查询耗时较长直接同步处理根本扛不住。我的做法是把 FastAPI 接口改成异步任务队列。用户提交请求后立刻返回一个“查询中”的状态后台用Celery Redis异步执行 Agent 的完整调用链完成后通过 WebSocket 或轮询推送给前端。数据库侧也要做防抖在查询执行前加一个请求合并/缓存层。一模一样的自然语言问法如果 5 分钟内出现过直接返回缓存结果。这部分缓存命中率相当高业务方常常对同一个表反复问不同角度的问题聚合结果完全相同的概率不低。4.5 成本控制Token 是隐性的大头Agent 项目看着只是调 API但实际跑起来发现 Costs 增长得很快。一次普通的数据问答来回可能消耗 3000 到 5000 Tokens听起来不多一天几十个问题累积下来就是不小的开支。省钱思路有几个维度第一模型分级。大部分数据查询任务不需要最强的模型我用了gpt-4o-mini做常规取数只有遇到模型自身判断复杂、需要从多个表中聚合推导时才升级到gpt-4o。这个升级判断也可以让 Agent 自己决定例如在 instructions 里写“当任务涉及 2 张以上表的 JOIN 或复杂窗口函数时调用高级模型工具”。第二精简返回。前面讲的“把 DataFrame 转 Markdown 后截断”就是控制 Token 的重要手段宁可让模型少看一点数据也不要让它淹没在噪声里。第三对话历史压缩。Session 里堆积的历史消息迟早会撑爆上下文窗口用模型对历史做一轮概括总结能大幅减少每次请求的基础开销。5. 落地后的几点个人体会项目跑通之后回过头看真正让这个系统能留在企业里正常运行的因素其实不是模型能力而是边界意识。我把数据安全拆成了数据库账号、SQL 校验、用户权限、审计日志四层每一层各司其职。模型可以在边界之内自由发挥但边界本身不能被模型的输出所穿透。这套思路对任何 LLM 应用都适用——不要指望模型自觉遵守规则要把规则焊接在代码和基础设施里。另外一点体会是自然语言取数不是要把 SQL 技能消灭掉而是把基础查询和口径解释的工作转移出去。分析师的工作重心从“写 SQL”变成“定义口径、维护元数据、审核异常查询”工作的价值密度明显提高了。这个方向团队里认可度最高的反而是那些平时最不愿意写 SQL 的业务方。如果你正准备在团队里推进类似的项目建议从一张表、三个字段、一个只读账号开始。先跑通最小闭环再慢慢往 Agent 里加表、加权限、加记忆。架构上预留好校验层和审计层后面扩到几十张表时也不会推倒重来。