从0到1搭建企业智能体平台:ChatBI工作流与NL2SQL落地实践 上周有个业务负责人跑过来问我“北区上个月的退货率怎么比双十一还高是不是数据出问题了”我说等等我查一下。结果这一查就是一下午——他说的“退货率”在订单系统里对应的是“订单退货率”在商品报表里又叫“商品退货率”两个口径差了快一倍。这种“查个数”的日常几乎每个企业内部每天都在发生也是我们决定从0到1搭建智能体平台、落地ChatBI工作流的最直接原因。这篇文章会完整复盘我们做的一个内部代号为 openJiuwen意思就是“有问题直接问”的企业智能体平台。它第一个落地的核心场景是把“业务人员用自然语言问数”这件事做成一条可解释、可控制、可审计的自动化链路。文章会讲清楚三件事为什么先选ChatBI切入、平台整体架构怎么设计、NL2SQL和指标语义层到底怎么实现以及我们在真实业务里踩过的三个大坑。适合有数据基础、正在考虑自建企业知识问答或BI能力的团队参考尤其适合那些已经试过“接一个大模型API就想搞定问数”但发现远没那么简单的同学。1. 为什么先从ChatBI切入企业中“查个数”远比想象中难1.1 传统BI最大的问题不是慢而是没有人“翻译”需求很多公司其实不缺报表缺的是把“脑子里的业务问题”翻译成“能查的指标”的人。传统BI看板固定了指标、维度、粒度业务方一旦问一个不在看板上的新口径就得提需求、排期、等数据开发写SQL一来一回就是好几天。我们在搭建openJiuwen之前专门统计过一次内部问数的工单大概有40%的查询需求都属于“临时性、探索性”的根本不适合做成固定报表。这些临时查询如果都走人工数据团队就会被淹没而业务方还会觉得“你们怎么这么慢”。所以问题的本质是企业里缺一个能秒级响应的“查询翻译层”ChatBI恰好能承担这个角色。1.2 ChatBI的本质把自然语言变成可审计的查询动作很多人觉得ChatBI就是“套壳聊天机器人”问一句答一句能吐个数字和图表就完事了。实际做过之后才发现ChatBI的核心不是对话而是把一句自然语言转换成一个“查询动作”——这个动作要能被理解、被校验、被限制、被追溯。说得直白一点用户问“北区上个月的退货率是多少”系统内部生成的应该是一段结构化的查询语句最终是SQL并且这段SQL必须满足几个硬性条件只能查用户权限范围内的数据、只能查语义层里定义过的指标、必须有可解释的口径说明。这样一来任何一个结果都能回答“这个数字是怎么算出来的”出了问题也能回查“谁在什么时候用哪个口径查了什么”。我们当时把平台命名为openJiuwen其实就是想强调这个定位面向企业内部的智能体不是闲聊助手而是“开放式地问、严谨地算”。1.3 很多ChatBI做失败的原因多半死在语义层见过不少团队做ChatBI翻车的案例最初都以为“大模型API一接Prompt一写就能上线”。结果一跑真实数据就露馅同一个“销售额”在不同部门有含税和不含税两种口径用户说“退货率”系统分不清是订单级还是商品级模型偶尔还会编造出不存在的字段名一本正经地给出错误数字。这些问题表面上看是“模型不够聪明”实际上都是企业里的指标口径没有标准化、权限边界没有落到查询层、模型生成结果没有校验机制。所以我们从第一天起就定了一个原则模型只负责“理解与生成”企业内部的“定义与约束”必须由平台层来兜底。这也是openJiuwen整个架构设计的主线。2. openJiuwen的定位与ChatBI工作流的整体拆解2.1 openJiuwen作为智能体平台的四个核心模块openJiuwen不是一个单纯的NL2SQL服务它定位为企业智能体平台也就是说除了ChatBI后续还可以接知识问答、报表分析、异常预警这类智能体应用。平台层抽象了四个核心模块模块职责在ChatBI场景中的作用Agent运行时管理多轮对话、会话状态、上下文压缩处理“那上周呢”这类指代问题工具注册中心把外部能力封装成标准工具供Agent调用注册“查指标数据”“查表结构”“生成图表”等工具指标语义层定义指标、维度、同义词、口径规则与字段映射把“退货率”映射到真实查询字段是准确性的根基工作流引擎编排多步骤流程支持超时、重试、人工干预把“提问—出数—解释”编排为可观测的链路这四个模块各司其职核心原则是大模型只负责理解意图和生成SQL草稿规则、权限、校验全部由平台控制。这样即使模型版本升级或者模型输出变化底层的口径与权限约束也不会被绕过。2.2 一条查询请求在平台里经历的完整链路我们最终落地的ChatBI工作流走的是下面这条链路每个环节都有明确产出用户输入问题Agent运行时先做意图识别判断是“查数”“看图”还是“问口径”。若为查数进入语义解析把自然语言里的业务词与指标语义层做匹配找出候选指标、候选维度和时间范围。如果有歧义比如多个指标都叫“退货率”走追问确认或按部门默认口径自动消歧。明确指标与维度后生成对应的SQL草稿然后注入数据权限过滤条件和行级权限控制。对SQL做只读校验、白名单校验、结果集限制通过后提交查询引擎执行。拿到结果后由Agent组织自然语言解释同时生成图表配置回传对话界面。这条链路看起来不复杂但每一步都需要设计“兜底”。例如意图识别错了后面全盘皆输语义映射错了SQL再漂亮结果也是错的。所以我们在平台里把每一步都做成可观测的工作流节点日志里能直接看到“模型认为用户指的是哪个指标、SQL在哪一步被拦截”而不是一个大黑盒直接吐一个结果。2.3 为什么每条链路环节都要有“人可干预”的口子模型一定会犯错所以链路里必须留人工干预的缝隙。我们设计工作流时在每个节点之间都暴露了干预接口。比如语义映射阶段如果置信度低于某个阈值就先返回候选列表让用户确认SQL生成之后如果查询成本预估过高就给用户提示“这个查询会扫描近一年的明细预计耗时30秒是否继续”。这么做还有一个额外好处业务方和数据团队逐步沉淀问答日志人工纠正过的口径会反哺指标语义层的同义词表和规则库系统越用越准而不是永远靠调Prompt来改善。3. 工程骨架搭建选型、目录结构与工作流引擎3.1 技术选型里的三个取舍从0到1搭平台最忌讳一上来就追求“业界最佳”我们当时定了三个实际约束团队熟悉Python、业务数据主要在OLAP引擎里、需要快速支持异步任务和Web界面。最终选型Web框架选了FastAPI这类轻量异步框架核心原因是ChatBI请求会涉及大模型调用和数据库查询耗时几秒到几十秒不等必须用异步IO来处理长连接不能阻塞线程。数据存储业务明细在OLAP引擎里跑聚合查询平台自身的配置、指标字典、问答日志放在MySQL这类关系库中两者分工明确。工作流编排没有引入大型流程引擎而是自己写了一个轻量的DAG执行器后面会详细解释原因。选型的核心权衡是自己能维护的复杂度才是合理的复杂度。大型工作流引擎功能强大但团队要为此付出不小的学习与运维成本而我们的场景本质上是“一条主链路上的少数分支”轻量自研反而更容易把控。3.2 项目管理与目录结构openJiuwen项目从仓库第一天起就按“模块化”组织避免把后端、工作流、语义配置混在一个大目录里。最终的工程结构大致如下openjiuwen/ ├── app/ │ ├── agent/ # Agent运行时、会话管理 │ ├── tools/ # 工具注册中心数据查询工具、图表工具等 │ ├── semantic/ # 指标语义层字典加载、同义词匹配 │ ├── workflow/ # 工作流引擎节点基类、DAG执行器 │ ├── nlsql/ # NL2SQLPrompt构造、SQL生成与校验 │ └── api/ # Web层对话接口、任务状态接口、管理接口 ├── config/ │ ├── metrics/ # YAML格式的指标定义 │ ├── synonyms/ # 同义词和业务限定词表 │ └── prompts/ # 各环节的系统提示词与few-shot示例 ├── tests/ └── scripts/ # 数据同步、语义层编译等运维脚本重点说一下config目录。我们把指标配置、同义词、Prompt都外部化了数据团队可以不用改代码、直接改YAML来维护口径。这是平台能持续运营的关键语义资产应该像代码一样有版本、有评审、有上线流程。3.3 节点抽象没有用现成编排引擎而是自己写了轻量工作流之所以自研轻量工作流是因为我们需要的节点类型非常固定LLM调用节点、SQL查询节点、条件分支节点、人工确认节点。与其引入一套通用编排系统不如直接定义统一的节点接口让每个节点只管“按输入产输出”。核心抽象大致是这样class WorkflowNode: 所有节点统一继承的基类。 name: 节点名称用于日志追踪 timeout: 单节点超时时间 retries: 失败重试次数 def __init__(self, name, timeout30, retries0): self.name name self.timeout timeout self.retries retries async def execute(self, ctx): 子类必须实现的核心方法。 ctx 是整个工作流共享的上下文对象 前序节点的输出都会挂载到上面。 raise NotImplementedError实际执行时工作流引擎会按照定义好的拓扑顺序依次调用节点并处理超时与重试。比如“语义解析节点”超时或失败直接走“兜底话术节点”而不是让用户干等一个失败接口。这套轻量框架我们大概用了几百行代码就实现了却足够支撑ChatBI主链路而且排查问题非常直观每个节点的输入、输出、耗时都有结构化日志。4. 核心实现指标语义层、NL2SQL与安全执行4.1 指标字典和同义词表模型之前先给业务建模我们内部有个说法ChatBI项目的第一阶段工作不是写代码而是“给业务数据立规矩”。没有指标语义层就直接让大模型写SQL等于让一个不了解企业语言的实习生去写核心报表出错只是时间问题。openJiuwen的指标定义用YAML管理每个指标至少包含业务名称、口径说明、同义词、聚合方式、物理表、过滤条件。举个简化的例子metric: key: order_refund_rate biz_name: 订单退货率 description: 统计周期内发生退货的订单数占支付订单数的比例退货订单按订单维度去重 synonyms: [退货率, 订单退货, refund rate] agg_type: ratio table: dws_order_daily base_metric: order_refund_count denominator: order_paid_count where: is_valid 1同义词表则独立维护专门处理“业务口语”到“指标key”的映射比如“退单率”“退货比例”都会被归一到order_refund_rate。这里有一个很关键的设计同义词映射不做模糊匹配而是做候选排序。模型先给出候选指标及置信度平台再决定是直接使用还是追问。4.2 Prompt与few-shot设计把SQL规范写进系统提示词有了语义层Prompt的设计就变得有的放矢了。我们不是让模型“自由发挥”写SQL而是把指标定义、字段清单、SQL书写规范、few-shot示例全部注入上下文让模型做“受限生成”。系统提示词的核心骨架大致如下你是企业数据分析助手。请根据以下指标定义和数据库结构 生成符合要求的只读SQL查询。 规则 1. 只能使用语义层中定义的指标字段禁止编造字段名。 2. 必须使用标准聚合函数禁止 SELECT *。 3. 必须加上平台要求的权限过滤条件。 4. 如果用户问题存在指标歧义返回需要确认的候选列表不要擅自选择。 指标定义 {metrics_schema} 数据库表结构 {db_schema} 最近5个正确示例 {few_shot_examples} 用户问题{user_question}关键点是db_schema并不是整库全量schema而是根据用户问题先做“召回”后的相关表结构控制Token量同时减少幻觉。few-shot示例我们是从真实历史数据里挑的每一个都经过人工审核因为示例SQL一旦有口径错误模型会学歪。4.3 SQL生成后的三道校验只读、白名单与结果集限制模型生成SQL之后绝对不能直接拿去执行。我们实现了一个SQL校验服务统一做三道检查任何一道不过都拒绝执行只读校验解析SQL语句类型只允许SELECT对INSERT、UPDATE、DELETE、DDL直接拦截。这一步我们用了一个轻量的SQL解析库而不是正则匹配因为正则很容易被复杂子查询绕过。表白名单校验SQL里出现的所有表名必须都在该用户的已授权表集合中。这个检查既能防越权也顺带拦截了模型幻觉出的“不存在表”。结果集限制强制拼上LIMIT并限制最大返回行数防止一次查询拉出几百万行把服务打爆。校验模块的大致逻辑是这样的def validate_sql(sql, user_permissions, max_rows10000): stmts parse_sql(sql) for stmt in stmts: if stmt.get_type() ! SELECT: raise SecurityViolation(只允许SELECT查询) for table in stmt.get_tables(): if table not in user_permissions.allowed_tables: raise SecurityViolation(f无权访问表 {table}) if not has_limit(stmt): sql append_limit(sql, max_rows) return sql我们在实践里坚持“宁可查不出来也不要查错”的原则。被拦截的SQL会连同原因一起回到Agent层让它重新修正生成逻辑而不是直接把报错抛给用户。4.4 行级权限注入数据权限必须落在查询里很多系统把权限控制做在应用层比如查完结果再过滤这在BI场景里是致命的——一次聚合查询扫描了所有区域虽然返回时过滤了但中间过程仍然暴露了全量数据。openJiuwen的做法是行级权限必须拼进SQL里在数据库层面完成数据隔离。实现方式并不复杂用户登录后Agent上下文里带有机构层级编码。生成SQL时强制拼入一层WHERE条件def inject_row_permission(sql, user_ctx): 根据用户的组织范围自动注入行级权限条件。 例如用户只能访问北区数据则注入: region_code IN (NORTH, NORTH_EAST) region_list user_ctx.get_authorized_regions() if not region_list: raise PermissionDenied(用户未配置任何机构数据范围) condition region_code IN ({}).format( ,.join(f{r} for r in region_list) ) return inject_where(sql, condition)同时列级安全靠指标语义层控制用户能查哪些指标取决于他在语义层的可见范围。口径和字段对用户透明但物理表字段不直接暴露这样“列权限”也间接落到了指标粒度上。5. 实测中踩过的三个大坑及解决过程5.1 坑一口语化的指标歧义模型自己“猜”错了方向上线第一个月我们收到一条高频反馈业务方问“退货率”系统给出的数字跟财务报表对不上。排查日志发现模型在两个候选指标之间做了一个隐式选择默认选了order_refund_rate订单退货率但业务部门当前讨论的其实是商品退货率两者分母完全不同。问题不在模型而在于我们的语义层没有暴露“口径差异”也没有设计歧义确认机制。后来我们做了两处改动同义词表里不再只映射到一个指标而是支持“一词多指”返回候选列表。语义解析节点增加歧义判定当候选指标之间的排序分差小于阈值时触发追问话术。追问话术我们也做了模板化“您说的‘退货率’可能有两种口径A是订单退货率按订单去重B是商品退货率按商品明细计算。请问您指的是哪一个”实测下来这个改动让指标命中准确率从78%提升到了94%而且用户并不觉得多一次确认烦人反而觉得系统“懂业务”。5.2 坑二模型幻觉出根本不存在的字段名另一个高频问题是模型一本正经地生成SQL里面却带了一个表中根本不存在的字段比如把“退货率”直接写成return_rate而真实字段名是order_refund_ratio。这个坑的排查过程很典型第一次发现错误时我们以为是生产环境表结构变更导致后来翻查模型日志才发现是模型在没有任何依据的情况下“创造”了字段名。解决方案分三层在Prompt里强制注入字段级别的schema而不是只给表名。把“查询表结构”本身做成一个工具模型拿不准字段时就先调用工具获取真实字段。保留SQL执行前的“语义层解析校验”——把模型生成的字段名与指标定义里的物理字段做比对不匹配就拒绝。这里反馈最明显的是模型从“编造字段”变成“先查字段再写SQL”之后SQL执行前报错率下降了七成。5.3 坑三多轮对话中的指代消解上下文不是越长越好ChatBI和一次性问数最大的区别在于多轮对话。用户经常会追问“那北区呢”“那上周呢”“去掉赠品再看呢”——一旦指代消解失败整个对话就断掉了。我们最初的做法是把最近20轮对话全部塞进上下文让模型自己理解。结果反而更差了上下文信息太多模型经常把前几轮里已经放弃的指标又捞回来还会把“上周”错误地理解成“上月”。排查后我们发现问题出在“过多无关上下文稀释了关键意图”。改进策略是引入会话状态压缩每一轮成功解析后把关键要素提取出来持久化到会话上下文里包括当前指标key、当前维度、当前时间范围、当前附加过滤条件。下一轮提问时Agent只需要“增量理解”class SessionState(BaseModel): metric_key: str | None dimensions: list[str] time_range: tuple[str, str] | None filters: list[str] pending_confirm: str | None # 待确认的口径选项然后Prompt从“完整历史”改成“当前会话关键状态 用户最新问题”。这个改动让多轮对话的指代消解成功率提升了很多同时token消耗和响应时延也降下来了属于成本和效果双赢。6. 部署落地与成本控制让ChatBI真正“敢上线”6.1 异步化与缓存查询接口不能同步等待模型ChatBI请求的耗时有很大波动简单问题可能两三秒复杂查询加模型推理可能要二十秒以上。如果用HTTP同步请求前端连接很容易超时体验很差。我们最终把“提交问题”和“获取结果”拆成两步提交后返回一个任务ID前端通过WebSocket或轮询接收状态更新。任务队列里跑的是同一条工作流但每一步都上报状态。比如“正在理解问题”“正在生成SQL”“正在执行查询预计xx秒”“已生成回答”。用户能实时看到进度等待就不再焦虑。我们同时给查询结果做了缓存如果相同用户、相同指标、相同时间范围、相同过滤条件在短时间重复查询直接命中缓存几乎秒回。6.2 模型成本与响应速度的平衡策略大模型API调用成本是企业落地ChatBI时早晚要面对的问题尤其是业务高频使用后账单会涨得很快。我们做了三件事来控制成本问题分级路由先由一个轻量分类器判断问题复杂程度简单问题比如查单个指标、时间范围固定走小模型复杂分析问题才调用大模型。实测下来70%左右的问题可以走小模型成本降低一半以上。few-shot缓存与高频问答模板把最高频的数百个问题做成固定模板命中模板就直接查数完全不走模型。查询结果语义复用同一个指标同一时间范围不同用户查询时共享底层查询结果只在展示层做权限过滤大幅减少对数据库和大模型的双重压力。这套策略上线后单次对话的平均成本压到了原来的三分之一而且响应速度更快业务方接受度明显提高。6.3 上线前的检查清单最后放一份我们整理的上线检查清单每一条都来自真实事故教训建议任何准备做ChatBI的团队都对照自查检查项要求目的指标口径评审每个指标必须有业务方确认的口径说明防止上线后“对不上数”数据权限验证不同角色登录后SQL必须自动拼权限条件防止数据越权SQL防误操作SELECT白名单、只读校验、LIMIT强制防止生成灾难SQL超时与熔断单节点超时、整体任务超时、可熔断防止数据库被打爆对话日志审计记录用户提问、解析结果、最终SQL与结果方便溯源与迭代歧义兜底候选指标分差小的时候必须追问防止模型“猜错口径”成本监控按用户、按指标维度记录token与查询成本防止成本失控从0到1做完openJiuwen的ChatBI工作流我个人最大的体会是ChatBI项目真正的分水岭不在模型选得多强而在于企业愿不愿意投入时间把指标语义层梳理干净、把权限边界落到查询执行层、把每次模型输出都当作“需要校验的草稿”而不是“最终答案”。模型负责聪明平台负责可靠这两件事各归其位ChatBI才真正敢上线让业务方天天用。