先说明一下这个“openJiuwen”是我给项目起的内部代号实际就是一套基于大模型能力做的企业级数据问答与智能分析平台。整个平台的核心业务场景是ChatBIChat Business Intelligence对话式商业智能也就是让业务人员用大白话问数据系统自动生成SQL、查数、出图不用再排队等数仓团队提数。这篇文章是复盘我们团队从零搭建这套平台的完整过程涉及架构设计、语义层建模、Agent工作流编排、环境部署和踩坑记录。内容偏工程实践适合正在做ChatBI、智能体平台或者数仓工具链的开发者参考。整个项目的推进节奏大概是这样先花两周梳理业务场景和数据现状再用一周搭出最小可行版本后续用三周迭代语义层和Agent编排逻辑最后花时间做权限、性能和稳定性加固。下面直接进入正题。1. 整体设计与平台架构拆解1.1 ChatBI没做之前我以为最难的是写SQL2025年之前我们团队的数据服务模式和其他公司没什么两样业务方在群里问“昨日各渠道GMV怎么变”数据开发同学查数、写SQL、做报表再丢回群里。整个链路看起来没什么问题但实际体验非常割裂业务方等结果要一两个小时甚至一下午数据团队每天被大量重复且琐碎的取数请求淹没真正该做的数据模型优化反而没时间做。后来我们调研了市面上各种ChatBI方案发现关键难点根本不在“让大模型写SQL”这一件事上而是整个链路业务问题到数据口径的映射是有歧义的比如“GMV”到底是支付口径还是下单口径“活跃用户”是按设备去重还是按账号去重用户的问法天马行空同一个指标可能有十几种说法企业数据表动辄几百上千张不筛选上下文直接丢给模型效果一定很差数据权限怎么控不同角色能不能看不同范围的数据生成的SQL错了怎么办谁来兜底。所以在动手写代码之前我先把整个系统的设计目标定了下来不追求一个“什么都懂”的通用大模型问答而是做一个“懂这家公司数据资产”的企业内部智能体。系统一定要能解释每个指标的口径SQL一定要能被审查和被兜底整个流程一定要可观测。1.2 openJiuwen的架构分层openJiuwen的总体架构分了五层。最底层是数据源层主要对接数仓的Hive表和业务库的MySQL/PostgreSQL数据通过同步任务统一汇聚到查询引擎里。查询引擎早期直接用Trino或者Doris偶尔也会走CK具体看表的规模和查询特征。再往上就是平台自己实现的核心层包括元数据服务、语义层、上下文管理、Agent编排和安全网关。元数据服务负责同步表结构、字段注释、分区信息以及表之间的关联关系语义层则是把数仓里晦涩的英文表名校成业务人员能理解的中文指标和维度同时定义指标口径和默认聚合方式上下文管理会缓存多轮对话里的筛选条件和指标状态避免用户在第二句说“改成按月看”时模型不知道要修改什么。Agent编排是这套系统的大脑它接收用户的问题之后依次做意图识别、语义解析、SQL生成、结果复核和执行查询。安全网关在最后一道关口做拦截包括权限校验、SQL黑名单、行级权限注入和查询超时熔断。展示层则是给用户输出最终结果包括表格、图表、口径解释和生成SQL的展示。1.3 为什么一定要有“语义层”而不是直接把表结构丢给模型这是我和团队争论最多的地方。一开始有人提出既然大模型这么强干脆后台把所有表的DDL全部同步给它让它自己选表、自己写SQL不是更省事吗试了一次就后悔了。几百张表的字段全塞进上下文模型根本分不清哪个是订单表、哪个是退款表经常选错表不说Token消耗还特别大。更麻烦的是没有预先定义指标口径模型就会凭自己的理解乱“猜”今天写SUM(amount)明天写COUNT(DISTINCT user_id)同一个指标查出来的数每次都不一样。后来我们才意识到ChatBI系统真正要做的不是教模型写SQL而是把公司数据资产的业务语义显式地告诉模型。语义层就是这个“翻译官”把表名映射成“订单事实表”把字段映射成“支付金额”“下单时间”“用户ID”把口径规则比如“退款订单剔除”“仅看支付成功状态”直接写进配置。模型拿到手的是一张“业务地图”而不是一堆冰冷的字段名。也就是说平台的开发工作量分布大概是语义层配置和元数据治理占近一半工作量Agent编排占三成数据接入和展示层占两成。前期重一点后期才会越来越轻松。2. 数据接入与语义层建模ChatBI的“地基工程”2.1 元数据同步与表结构标准化开工第一步不是部署Agent而是先把元数据服务搭好。一个直接暴力的方式写一个同步调度每天凌晨把数仓元数据表名、字段、类型、注释、分区信息拉取到平台的MySQL里同时记录字段的血缘和表之间的主外键关系。但元数据拉下来只是一半。我们还需要做表结构的标准化否则脏数据会让模型在语义层里迷失方向。我们定了几条硬性规范事实表必须有明确的主键或唯一键并在元数据里标记所有的时间字段统一命名成dt并标注“分区字段”值格式统一为yyyy-MM-dd或yyyy-MM-dd HH:mm:ss金额字段统一标注单位元/万元、精度保留2位小数以及是否含税维度字段每个枚举值必须有注释比如status1代表“已支付”status2代表“已退款”。在这期间不要嫌麻烦我们直接写了一个元数据质量巡检脚本每天扫描那些没有注释、类型不匹配的字段然后推给数仓团队修复。实践下来这一步对后期ChatBI的效果提升是最显著的比调Prompt有用得多。2.2 语义层配置让模型看懂业务语言语义层的设计是整个平台的核心。我的做法是引入一个语义数据集Semantic Dataset的概念用DSL描述一个“业务主题域”。比如“订单分析域”的配置大概是dataset: order_analysis display_name: 订单分析 tables: - table: dws_order_detail alias: order_fact display_name: 订单事实表 measures: - field: order_amount display_name: 支付金额 agg: sum default_format: currency description: 用户支付成功的订单金额剔除退款订单 - field: order_count display_name: 订单量 agg: count_distinct description: 支付的订单数按订单ID去重 dimensions: - field: dt display_name: 日期 granularity: day - field: channel display_name: 渠道 dict_mapping: 1: 直营店 2: 加盟店 3: 电商平台 - field: user_level display_name: 会员等级 dict_mapping: 1: 普通用户 2: 银卡 3: 金卡 joins: - type: left from: order_fact to: dim_user on: order_fact.user_id dim_user.user_id这段配置的作用很直接它告诉大模型“当用户提到支付金额时你应该从order_amount字段取数聚合方式是SUM并且要排除退款”。没有这一步模型生成的SQL大概率会在字段选择上出错。另外语义层里还支持配置“默认过滤条件”和“衍生指标”。比如“支付金额”默认带status in (paid)的过滤逻辑“客单价”是SUM(order_amount) / COUNT(DISTINCT order_id)的组合表达。这些规则配置好之后模型在生成SQL时会自动携带这些逻辑而不是靠模型临场推理。2.3 同义词扩展应对业务方的“花式问法”业务人员问数据不会按照你的字段命名来。比如“本月新客有多少”和“流失用户召回情况怎么样”看似两句话但都涉及“用户”这个实体和“时间”这个维度。为了提升意图识别的准确率我们建了一张同义词表用户说法标准语义实体成交、销售、卖了多少钱order_amount、pay_amount新客、首次购买用户用户维度的首单标记店铺、门店、线下channel in (1, 2)这个月、本月、近30天时间相对偏移同义词表不是一次性配完的而是通过运营反馈持续补充。前期从业务方的历史取数工单里整理出高频词表上线后每天看用户的实际问句遇到识别失败的再回填到词表里。这个机制跑起来以后系统的意图识别准确率稳定提升了近二十个百分点。2.4 数据权限隔离行级权限的实现ChatBI一旦开放给全公司数据权限就必须做好。我们的方案很朴素每个用户在平台里有对应的角色和数据域角色的范围映射到SQL的固定条件。比如某大区总监只能看自己大区的数据那么在语义层对应的维表上就给他加一行权限规则WHERE region_id IN (华东,华南)。权限规则固定注入到每次生成的SQL中不允许出现在大模型的自由生成环节里避免模型自行绕过。权限这块还有一个细节要在Agent编排阶段就把权限条件注入而不是等到SQL生成完再改写。因为大模型在写SQL时如果看到表里有region_id字段但没收到约束条件很容易写出不带过滤的语句最后靠后置改写去补条件会非常痛苦。3. Agent工作流编排从用户问句到结果输出的完整链路3.1 流程框架意图识别 → 语义解析 → SQL生成 → 复核 → 执行openJiuwen的Agent核心是一个可编排的LangGraph流程。每个节点都是一个独立的函数意图识别节点判断用户是在问数、看趋势、比较维度还是闲聊实体链接节点把用户问句里的业务词匹配到语义层的指标、维度多轮改写节点结合历史对话把当前问句补全成完整形式SQL生成节点基于语义层配置和已链接实体生成可执行的SQL复核节点用规则和模型双重检查SQL解决字段幻觉、权限遗漏、聚合口径问题执行与格式化节点查询引擎跑SQL结果格式化输出并决定图表类型。这里有个容易踩的坑不要把SQL生成前的所有逻辑都塞到一个巨大的Prompt里。最稳定的做法是每个节点做一件事用结构化输出传递信息。意图识别输出JSON标签实体链接输出实体列表SQL生成只负责在给定实体列表上做代码生成。这样不仅好调试也方便单独优化某个节点。3.2 意图识别与路由策略意图识别我们尝试过几种方案效果最稳的还是“基于有限选项的分类规则修正”。先把问题分到预设的几个意图类型metric_query指标查询、trend_analysis趋势分析、comparison对比分析、drill_down下钻、chart_request图表生成、others闲聊。对于metric_query和trend_analysis后续的动作不同查询指标用单行SQL趋势分析需要带上时间分组和日期补全逻辑。识别准确率的关键在于Prompt里先给定义再给示例最后要求输出JSON格式里的键名就用intent和confidence。实际补充一个细节意图识别不能只依赖一次判断。比如业务方说“帮我看一下这个月华东区的销售情况”这句话既包含区域维度华东区也包含时间维度这个月同时指向指标销售情况。我的做法是把意图识别和实体链接做成交替执行先粗分类再抽实体再根据实体类型修正意图。这样能显著减少误判比如“A和B谁高”这种比较类问题如果先抽到了两个指标基本就可以从metric_query改写为comparison。3.3 多轮对话上下文管理多轮对话是ChatBI最容易翻车的地方。用户第一句问“本月销售额”第二句说“按渠道看看”如果系统没有上下文关联第二句就会被当成独立问题去匹配“渠道”这个维度导致结果不符合预期。我们的方案是把上下文管理拆成两部分一个是“状态记忆”一个是“轨迹摘要”。状态记忆保存最近几轮的目标指标、维度、时间范围、过滤条件用结构化JSON存储轨迹摘要是用大模型对前文做一次长文本概括比如“用户已选择本月作为时间范围正在按渠道下钻销售额”。在每一轮开始时系统会先判断当前问题是否依赖历史。如果发现问题里没有明确的时间范围或者缺少主语就用摘要补齐上下文再走实体链接。这个机制上线后连续追问的准确率提升了非常多用户从“3月销量多少”到“那华南呢”再到“同比呢”都能接得住。多轮对话还有一个容易忽略的点当用户主动指代调整时比如“换到4月”不是简单替换上下文里的时间字段而是要修改当前指标查询的完整条件。如果状态记忆只存值不存语义模型拿到“4月”这个实体后不知道怎么替换之前的“3月”就会生成两个时间条件互相冲突。3.4 SQL生成节点的Prompt设计思路SQL生成的Prompt我把它结构化成了四个部分系统指令、语义层定义、对话历史、当前问题。其中语义层定义是动态拼进去的不是固定模板。系统指令里明确几条铁律只允许使用提供的表和字段禁止对维度字段使用SUM必须使用语义层定义的口径生成的SQL必须是完整并且可执行的。语义层定义不是把所有表都塞进去而是先做一层“选表筛选”。根据实体链接命中的指标和维度挑出相关的表和关联关系再拼成Prompt。这样做的好处是输入长度可控、上下文噪音低、Token消耗更低。从实测来看筛选字段后的SQL准确率远高于全量上下文。在Prompt里我还会给大模型一个“生成前思考”的步骤让它先把用户的问题拆成指标、维度、时间条件和过滤条件再编写SQL。这个思考过程不一定输出给用户但是内部会让SQL的准确性更高。3.5 复核节点SQL不等于能跑也不能等于跑得对模型生成的SQL即使语法正确也可能会用错字段、算错口径、漏掉权限条件。所以复核节点必须做三重验证。规则校验检查SQL里出现的表和字段是否都在元数据和语义层白名单内检查是否有SELECT *检查聚合函数与字段类型是否匹配对字符串字段做SUM就是错误检查是否包含权限过滤条件。模型验证把生成的SQL连同语义层定义一起送回大模型让它判断SQL是否忠实于用户的问题。这一层能挡住不少字段“幻觉”问题。但这里要注意别让同一个模型又生成又验证已经有自纠正模板风险可以在推理参数上做区分。试运行验证在测试表上执行SQL如果执行报错自动将错误信息反馈给模型让它重新生成。很多隐蔽的分区语法问题、字段类型问题靠表面检查发现不了非要跑一遍才知道。这个机制实际上是“SQL生成→执行报错→错误反馈→再生成”的循环上限重启次数一般设为2次多了容易进入死循环。3.6 结果展示与口径解释展示不只是画一张图。我们的展示层会同时输出三个东西查询结果表格或图表、生成SQL原文、指标口径解释。口径解释是固定从语义层生成的比如“支付金额口径为剔除退款后的支付订单金额”不是模型临时编的。图表选择也有规则时间趋势用折线图维度对比用柱状图占比分析用饼图。如果不指定就会根据结果集的字段类型自动匹配。4. 环境搭建与端到端实现4.1 基础环境准备部署形态我们选了Docker Compose一台8核16G的机器就能跑完整套最小环境。整体组件包括MySQL元数据、PostgreSQL业务数据示例、Redis上下文缓存、Trino查询引擎、向量库可选用于知识库检索、openJiuwen后端服务和Web前端。目录结构大致如下openjiuwen/ ├── docker-compose.yml ├── config/ │ ├── semantic/ # 语义层配置 │ ├── metadata/ # 元数据同步配置 │ └── agent/ # Agent编排配置 ├── services/ │ ├── api-server/ # 后端服务 │ └── worker/ # 异步任务 └── frontend/用一条命令拉起所有服务docker compose up -d启动后后端服务会暴露8000端口前端跑在3000端口。首次启动需要跑一个初始化脚本用来同步元数据、导入语义配置和创建内置账号。4.2 数据初始化示例为了演示我们准备了“电商零售数仓”的模拟数据一张订单事实表dws_order_detail和两张维表dim_user、dim_channel。初始化SQL大约几百行核心就是构造一个月级粒度的订单流水。数据初始化后在平台的“数据源管理”页面添加新的数据源连接填入Trino的坐标即可。之后元数据同步服务会自动读取库表信息并以每天一次的频率刷新。4.3 配置语义层打开config/semantic/order_analysis.yaml按前面的配置填入订单分析的指标、维度和表关联。保存后调用Admin API触发语义加载curl -X POST http://localhost:8000/api/semantic/reload这一步会把YAML编译成内部语义结构并缓存起来。调试的时候多看一眼日志如果加载报错基本都是字段名和元数据不一致导致的。4.4 Agent工作流服务配置Agent编排的服务配置如下{ model: { provider: one-api, name: qwen2.5-72b-instruct, temperature: 0.1, max_tokens: 2048 }, sql_engine: trino, semantic_config: order_analysis, max_correct_rounds: 2, enable_permission: true, enable_multi_turn: true }这里的参数有个经验生成SQL的大模型温度要低尽量接近确定的输出但做意图识别或口语化的解释时温度可以微高一点。另外max_correct_rounds不要设太大2次试运行已经能覆盖绝大多数报错场景再大只会增加延迟。4.5 端到端流程验证配置完成之后打开平台页面输入“本月各渠道的支付金额”系统返回结果时的完整过程为意图识别节点判定为trend_analysis因为问题里带“本月”和“各渠道”实体链接命中“支付金额”映射到order_amount命中“渠道”映射到channel命中“本月”转换为具体时间区间SQL生成节点拿到语义配置输出类似这样的查询SELECT channel, SUM(order_amount) AS total_amount FROM dws_order_detail WHERE dt DATE 2025-06-01 AND dt DATE 2025-06-30 AND status paid GROUP BY channel ORDER BY total_amount DESC复核节点检查字段合法性、权限条件和聚合口径再试运行结果返回后前端根据结果类型自动渲染柱状图下方展示SQL和口径说明。到这里一条ChatBI核心链路就算真正跑通了。4.6 与现有系统对接企业里不可能只用一个平台。我们必须把openJiuwen的能力输出给其他系统。实现方式有两种提供API给内部系统调用或者通过Webhook回调。API方式适合在内部的报表平台、协同软件里集成问答能力Webhook方式适合异步触发数据分析任务。权限对接方面通过统一登录服务接入组织架构。用户登录后拿到角色和区域信息平台自动映射到行级权限条件。这一块需要花时间联调但却是企业落地不可省略的环节。5. 常见问题与排查技巧实录5.1 模型生成的SQL里出现“幻觉字段”项目上线初期我们遇到最多的问题是模型写SQL时用了不存在的字段名比如把order_amount写成order_amt甚至把两个表里都不存在的user_name编出来。排查思路很明确第一步查看元数据表里是否有该字段第二步检查语义层配置里的字段映射字段名和元数据是否一致第三步检查Prompt里是否明确限制了候选字段列表。如果三步都查完还是有问题大概率是上下文里塞了太多无关内容导致指令漂移。解决方法是做更严格的选表筛选和字段白名单。5.2 同一指标前几轮正确后来开始报错这种问题通常不是模型变笨了而是上下文状态出了问题。多轮对话里如果上一轮已经把时间限定在“3月”下一轮用户说“全年趋势”系统如果没有正确更新状态就会生成WHERE dt BETWEEN 2025-03-01 AND 2025-03-31和GROUP BY month同时出现的矛盾SQL。排查方案是开启轨迹摘要日志逐轮看上下文的更新记录。我们的状态管理不直接把原始问题塞给模型而是维护一个“用户意图状态机”每一轮根据新问题更新状态机字段这样就能避免上下文漂移。5.3 权限条件注入失败权限条件必须由安全网关注入而不能透传给模型自由发挥。最初我们在SQL生成节点后就结束了结果发现部分用户能看到非授权区域的数据排查发现是权限规则没正确附加到生成的SQL上。后来我们把权限规则设计成一个独立的“规则注入层”在模型生成SQL之后、试运行之前执行。规则注入层用SQL解析器把WHERE条件拼接进AST而不是直接改字符串这样能避免因为子查询、嵌套查询导致的条件遗漏。如果担心AST改SQL过于复杂另一个稳妥方案是给每个数据域建视图例如view_order_auth模型只能看到当前用户有权限的视图。这样就从根本上杜绝了权限绕过比后置改写更安全。5.4 查询性能慢页面响应超过十秒性能问题分两端一端是大模型生成SQL的耗时另一端是查询引擎的执行耗时。模型端的优化手段包括用更小的模型做实体链接把SQL生成的结果做流式输出优先展示文本查询端主要靠Trino/Doris的缓存机制和结果集大小限制。我们加了几个实用功能查询超时熔断默认5秒防止慢查询拖垮引擎同步接口限制最多返回500行超出则自动分页或提示用户缩小范围频繁查询同一指标的结果会被缓存15分钟减少重复计算。5.5 常用问题速查表现象可能原因解决动作模型选了错误的事实表语义层表映射缺失在语义配置中加入更精确的表名和业务描述SQL报“列名不存在”元数据与数据源不同步执行元数据强制刷新指标值比预期大很多聚合函数粒度过大检查是否需要COUNT(DISTINCT)或添加去重条件时间过滤不生效时间字段没有映射到标准dt字段统一字段命名和格式图表类型不对结果集字段类型识别错误在语义配置中标注维度类型用户追问时丢失上下文状态记忆没有及时更新检查多轮上下文管理逻辑API响应超时模型Token过长或查询量大缩短上下文、启用缓存、增大熔断位点最后再分享一点心得整个平台从立项到上线最核心的结论其实只有一条不要让大模型自由发挥去理解你的数据而是要把数据资产本身的结构、口径、权限变成显式的配置和约束让模型在框架内做事。语义层是我们投入最重、回报最明显的一个模块凡是觉得ChatBI不靠谱的团队八成是跳过了这一步直接让模型写SQL。我个人在实际操作中还有一个体会不要追求一次把Agent编排做得太复杂。先把意图识别、实体链接、SQL生成、复核执行这四个节点跑通再加上多轮上下文和权限注入已经能覆盖大部分常见场景。之后再加知识库问答、报表订阅、异常归因这些能力每一步叠加起来平台就会越来越完整也越来越像“智能体”而不是一个玩具Demo。如果你们公司也正在做类似的数据问答项目希望这篇实战记录能帮你少踩坑也欢迎一起交流架构和实现细节。