Chat2Chart 架构分析:规则驱动的 ChatBI 解析器,而不是一个纯 LLM 应用

AI 摘要基于 chat2chart release 分支源码,分析其主题路由、NL2DSL 解析链路、多轮状态机与 DSL 回转能力。

这次看的 chat2chart,和很多人对“AI 数据分析”系统的第一直觉不太一样。

它不是那种“接一个大模型 API,把用户问题塞进去,再让模型吐 SQL 或图表建议”的典型 LLM 应用。恰恰相反,这套系统的主体是一个非常传统、非常工程化的解析服务:它有主题预加载、模型路由、切词、字段识别、任务抽取、多轮状态机、DSL 回转、拒识模型、Redis 二级缓存,以及一套相当明确的 Intent 数据结构。

如果一定要给它下定义,我会说:

chat2chart 更像一台 ChatBI 解析引擎,而不是一个套了模型外壳的问答服务。

本文基于本地 release 分支源码分析,当前代码版本为 4.4.0,提交为 d26eac9

1. 项目定位与核心约束

从目录和入口可以很快看出,这不是前端仓库,而是一个 Python 后端服务:

app.py
config/
metadata/
parsers/
utils/
database_utils/
model/
tests/

服务启动于 FastAPI,核心职责不是“生成图表”,而是把用户输入的自然语言或 DSL,解析成统一的 Intent 结构,供后续检索、SQL 生成、图表渲染或 BI 服务消费。

这套系统至少受以下约束驱动:

约束技术后果
一个会话可能对应多个主题 subject 和多个 model需要主题排序、模型选择和 session 管理
用户输入是自然语言,但落地执行必须可控需要规则化的 Intent 中间表示
BI 场景存在字段歧义、枚举值歧义、多轮补全需要消歧机制和对话状态机
线上解析服务必须低延迟、可预加载、可观测需要 Redis、预热、Prometheus、LRU 缓存
自然语言入口并不稳定,前端/上游也可能直接给 DSL需要 dsl2intent 反向通道

这几个约束共同决定了:这套系统不可能完全依赖“模型一次性端到端生成”。它必须拥有一套可解释、可分段、可缓存、可修复的解析流水线。

2. 总体架构:它是一套解析服务,不是一个模型代理

app.py 暴露的 API 已经把产品形态说得很清楚了。主要接口包括:

  • /api/v1/preload
  • /api/v1/load
  • /api/v1/create/session
  • /api/v1/cut
  • /api/v1/intent/recognition
  • /api/v1/intent
  • /api/v1/intents
  • /api/v1/dsl2intent
  • /api/v1/retrieval/data
  • /api/v1/rank/subject

仅看这些接口,就能判断系统被分成了四层职责:

  1. 主题/模型生命周期层:负责预加载、增量加载、删除和缓存管理
  2. 会话层:负责 session、上一轮状态、多轮继承
  3. 解析层:负责切词、意图识别、NL2DSL、DSL2Intent
  4. 运维层:负责健康检查、Prometheus 指标、日志和资源监控

这和普通 LLM 应用最本质的区别在于:它的复杂度主要来自工程编排,而不是提示词。

3. 启动阶段:先建缓存和状态,再谈解析

startup() 做的事情很有代表性:

  • 检查目录和日志路径
  • 初始化 table_parser_mappersist_table_parser_map
  • 初始化 session 与对话状态
  • 连接 Redis,并启动后台 redis stream watcher
  • 在单机模式下预加载本地 table_schemas
  • 初始化 ModelHub
  • 启动每日版本日志任务

这说明作者非常明确地把“模型/主题信息缓存”当成一等公民。

这里有两个缓存层:

  • persist_table_parser_map:偏持久化、预加载主题
  • table_parser_map:偏运行时、带 LRU 淘汰的动态主题

utils/helper_funcs.py 里的 LRUDictionary 也说明了这一点。它不是简单内存字典,而是带:

  • 最近使用时间刷新
  • 超限淘汰
  • 过期清理
  • 被淘汰 parser 主动 destroy

这是一种很标准的长服务思维。说明作者不是在写“研究代码”,而是在写能长期驻留的解析节点。

4. 数据预热:主题不是一次查询时临时拼出来的

preloadload 两个接口非常关键。它们说明 chat2chart 的工作方式不是“查询来了再现查 schema”,而是先把主题信息变成内存中的可解析对象。

这一层由 utils/app_funcs.py 里的 get_subject_info()get_model_infos() 完成,具体包括:

  • 解析模型 schema
  • 构建属性自动机、模糊匹配字典、别名映射
  • 构建 table / field / transferName 双向映射
  • 初始化 GrammarBasedParser
  • 注入 slang、自定义参数等业务词汇

最后,一个 subject 会被扩展成:

  • subject_name
  • model_infos
  • 每个 model 对应的 parser
  • 字段别名、枚举值、日期维、模糊匹配信息

这意味着解析器实例不是轻量对象,而是“预编译后的主题语义环境”。

这也是为什么它必须有 preload/load,而不是每次请求都临时构造。

5. 主题路由和模型选择:一个 ChatBI 系统真正麻烦的地方

很多“自然语言分析”系统只假设一个数据集,所以用户问什么都在同一个 schema 里找答案。但 chat2chart 明显不是这种简化场景。

它显式支持:

  • 一个 session 绑定多个 subject
  • 一个 subject 下存在多个 model
  • 需要根据 query 命中情况排序主题和模型

SubjectTool.rank_model() 是这条链路里最有业务味道的部分。它不是让模型“理解 query 属于哪个主题”,而是基于切词和命中结果做一个打分排序:

  • 命中的字段/枚举/表名长度
  • 消歧信息长度
  • 模糊匹配长度
  • 上一轮 subject / model 的继承优先级
  • 主题历史访问次数

这是非常典型的企业规则系统设计:用可解释的打分规则替代黑盒分类器。

优点很明确:

  • 出错时可以分析原因
  • 可以针对具体业务 case 微调
  • 不依赖额外训练一个大规模主题分类模型

缺点也很明确:

  • 随着规则越来越多,会变得难维护
  • 对边界 query 的泛化能力弱于更强的表示模型

但对于 BI 主题路由这种任务,这个取舍很合理。因为线上真正可怕的不是“分类精度少 1 个百分点”,而是“为什么今天这个 query 跑到另一张表去了却没人知道原因”。

6. NL2DSL 主链路:一个经典的分阶段语义解析器

parsers/nl2dsl/__init__.py 定义的 NL4DV 是整个自然语言入口的内核。它把解析过程拆成多个 Genie:

  • QueryGenie
  • DataGenie
  • DateGenie
  • AttributeGenie
  • TaskGenie
  • VisGenie

这套命名看起来很“学术项目”,但工程上其实很受用,因为它明确分离了几种不同责任。

6.1 QueryGenie:先把输入规整化

QueryGenie 负责的不是理解业务,而是让 query 进入一个更适合规则分析的形态,例如:

  • 中文数字转阿拉伯数字
  • 百分比转小数
  • “上半年/下半年”“半个月”之类的时间表达预处理
  • 一些泛化词形归一

这一步听起来不高级,但极其重要。因为规则系统最怕输入不规范,预处理做不好,后面的 attribute/task/date 都会变脆。

6.2 AttributeGenie:先找字段,再谈任务

NL4DV.analyze_query() 里,字段识别先于任务识别:

  1. extract_table_name
  2. extract_attributes_from_dict
  3. 去掉 metric / dimension / enum / date_range,再合并成 task 字符串

这是一个非常正确的顺序。因为 BI 查询的任务理解高度依赖 schema。用户说“销售额前十”,只有先知道“销售额”是什么字段,后面的“前十”才知道该排什么。

6.3 TaskGenie:规则系统的真正核心

TaskGenie 负责抽出:

  • filter
  • count
  • expression
  • metric task
  • rank
  • vs / compare
  • conditional filter
  • totals
  • details

然后再把这些任务组织成 task_map / expr_tasks / count_tasks / metric_tasks

从设计上看,它不是直接生成最终 intent,而是生成“任务中间态”。这使得系统拥有两层缓冲:

  • 第一层:query 到 task
  • 第二层:task 到 intent

这就是规则系统能长期维护的关键。因为你可以在 task 层修补问题,而不用每次都重写最终输出结构。

7. GrammarBasedParser:把中间任务收束为统一 Intent

parsers/grammar_parser.py 里的 GrammarBasedParser.dict2intent() 是真正把语义结果收束成结构化输出的地方。

它最终构造的对象包括:

  • Table
  • DateRange
  • TimeUnit
  • Dimension
  • Metric
  • DimensionFilter
  • CalculationFilter
  • Expression
  • Calculation
  • Limit
  • VisHint
  • PotentialSemanticMention

也就是说,系统的最终落点不是 SQL,不是图表配置,而是一个领域中间表示 Intent

这一步非常关键,因为它决定了这个系统可以支持多种下游:

  • 从 query 直接解析 intent
  • 从 DSL 反解 intent
  • 在 intent 上做多轮补全
  • 在 intent 上做 retrieval / SQL / 图表建议

这就是为什么我说它更像“解析引擎”而不是“问答应用”。

7.1 Intent 建模比规则本身更重要

parsers/intent.py 里的类定义说明作者已经非常明确地把业务概念对象化了:

  • 聚合函数 AggFuncEnum
  • 时间粒度 TimeUnitEnum
  • 计算类型 CalcTypeEnum
  • 维度过滤 DimFilterOptEnum

这使得系统后续的日志、调试、测试和多轮处理都有了稳定的语义边界。

换句话说,真正可维护的不是规则,而是规则最终汇聚到的统一抽象

8. 多轮能力:不是聊天历史拼接,而是显式状态机

DialogStatusDialogManagement 是这套系统另一个很值得肯定的点。

很多所谓多轮问数系统,其实只是把上一轮 query 拼到下一轮提示词里。这种做法看起来快,但一旦进入:

  • 改时间范围
  • 改维度
  • 改指标
  • 加过滤
  • 子查询
  • 比较/同比/环比

就会很难控。

chat2chart 的做法更硬核:它维护显式的对话状态追踪信息 dst,并且:

  • 每个 session 只保留最近 3 轮
  • 设置时间窗口
  • Redis 与内存都能存
  • policy() / post_policy() 根据多轮意图显式修改 intent

dialog_management.py 可以看到它识别和处理的多轮类型并不少:

  • intent_AddMetric
  • intent_Vis
  • intent_Rank
  • intent_Compare
  • intent_ChangeDate
  • intent_ChangeDim
  • intent_ChangeMet
  • intent_ChangeDimFilter
  • intent_ChangeExpr
  • intent_Subquery

这说明它的多轮能力不是“附加功能”,而是整个产品语义的一部分。

9. 拒识模型:模型只在该出手的时候出手

这套系统里确实有模型,但位置很克制。

ModelHub 目前加载的是一个 intent_rejection 模型,本质上是一个文本分类器,用于判断 query 是否应该直接拒识。它的使用位置也很谨慎:

  • 只有在 query 没命中任何 DIMENSION / METRIC / ENUM / TABLE_NAME / DATE_DIMENSION / SLANG
  • 且没有命中黑白名单规则时
  • 才会调用拒识模型

阈值也很明确:label == "reject"score > 0.97

这就是一种很成熟的“模型补洞”思路:

  • 主流程靠规则,确保稳定可控
  • 规则边界兜不住的地方,用小模型做风险拦截

这种设计比“用 LLM 干全部事情”更像真实生产环境,因为它把模型放在收益最高、风险最可控的位置。

10. DSL2Intent:系统不是单向的,这很重要

dsl2intent 接口说明了一件事:这套系统的中间表示设计得足够稳定,因此可以支持反向映射。

它支持:

  • 普通查询 DSL
  • 归因分析 DSL
  • 中英文/繁体的 parser 变体

DSL2IntentParser 的职责就是把结构化 DSL 重新投影回统一的 Intent 类族上。

这件事的意义远大于“多了一个接口”。

它意味着:

  1. 前端/上游模型可以先生成 DSL,再让服务端校正为 intent
  2. 自然语言与 DSL 两条入口可以汇合到同一后处理和多轮体系
  3. 系统可以逐步从纯规则解析过渡到“LLM 生成 DSL + 规则收敛”的混合架构

而 README 的 TODO 里其实已经点明了这一演进方向:未来要把 rule-based parser 和 llm-based parser 结合起来。

也就是说,这套代码虽然现在不是一个 LLM-first 系统,但它给未来的混合架构留了接口。

11. 工程优点:它非常诚实

看完这套仓库,我最认可的一点不是某个模块有多炫,而是它对问题边界非常诚实。

11.1 不假装 LLM 能解决一切

它没有为了“AI 感”把所有事情都交给模型,而是保留了:

  • schema 预编译
  • 规则解析
  • 会话状态机
  • 主题/模型排序
  • 显式 intent 结构

这是很成熟的工程判断。

11.2 不把 ChatBI 简化成“问句转 SQL”

真正的复杂度其实在:

  • 主题选择
  • 字段/枚举歧义
  • 多轮补全
  • 时间比较和派生计算
  • 前端/后端之间的中间表达

chat2chart 基本都正面处理了,没有回避。

11.3 服务形态足够完整

它已经具备生产服务应有的基础设施:

  • FastAPI 生命周期管理
  • Redis 缓存与 stream watcher
  • Prometheus 指标
  • API key 校验
  • 内存/OOM 保护
  • 主题预热与淘汰

这比很多“研究代码产品化”项目成熟得多。

12. 它的问题也很明显

这套架构并不轻,也不完美。

12.1 app.py 过重

主入口几乎承担了:

  • 生命周期
  • 缓存初始化
  • session 读写
  • 切词路由
  • intent 主流程
  • dsl2intent 主流程
  • logging / metrics / error handling

这会让 app.py 持续膨胀,后续演进风险不小。严格来说,这里已经出现了“协调器过重”的问题。

12.2 规则系统天然会膨胀

无论是 TaskGenie 还是 DialogManagement,本质上都在累积大量 case。这样的系统前期迭代很快,但中后期的维护成本会显著上升:

  • 新需求容易破坏老规则
  • 同类规则分散在多个模块
  • 很难做全局一致性验证

这不是代码风格问题,而是规则系统本身的宿命。

12.3 测试体系不够强

README 里提到可以 pytest tests,但从目录看,当前测试更像:

  • 本地测试脚本
  • 自动化 query 文档
  • 一些 pkl 测试数据

它不像现代后端那样有成体系的单元测试、集成测试和回归样例库。这对于这种 heavily rule-based 系统来说,其实是个风险点。

因为规则系统最怕“改对一个 case,打坏五个旧 case”。

13. 如果继续演进,我会怎么做

如果让我在这套基础上继续推进,我会优先做四件事。

13.1 先把解析主链路拆出服务编排层

app.py 里的大流程抽到明确的 service 层,例如:

  • subject routing service
  • query parsing service
  • dialog management service
  • dsl conversion service

这样至少能让入口从“巨型控制器”收缩成真正的 API adapter。

13.2 把测试重心放到 query regression 上

对这种项目,最重要的不是单元测试覆盖率,而是 query 回归集。应该把:

  • query
  • cut tokens
  • intent
  • session 前态
  • 预期 status

做成稳定的回归资产。

13.3 让小模型和规则的边界更显式

目前拒识模型接得比较克制,这是优点。后续如果要加入 LLM parser,也应继续坚持这条原则:

  • 模型负责补洞、建议和候选生成
  • 规则/结构化层负责最终收敛和校验

而不是直接让模型越过 intent 层输出不可控结果。

13.4 强化 Intent 作为平台协议的地位

现在 intent 已经是系统的核心中间表示。后续不管接 SQL 生成、图表选择、检索增强还是 LLM,都应该继续围绕 intent 做扩展,而不是让每条链路各自发明一套新协议。

14. 总结

chat2chart 最值得肯定的地方,不是它用了多少 AI 技术,而是它很清楚 ChatBI 真正困难的部分到底在哪里。

真正难的从来不是“把一句话交给模型”,而是:

  • 在多主题多模型下选对语义上下文
  • 把 query 拆成可靠的结构化任务
  • 在多轮场景里维护状态连续性
  • 用统一 intent 表达后续所有动作
  • 让整个服务可预热、可缓存、可观测、可排错

从这个角度看,chat2chart 是一套相当典型、也相当现实的企业级 ChatBI 解析器。

它没有追逐最炫的技术叙事,而是把“可控”和“可落地”放在了前面。也正因为如此,它看起来不那么像一个时髦的 AI 产品,反而更像一套真正能接业务、能背 SLA 的后端系统。

这也是我看完之后最强烈的判断:

chat2chart 的核心竞争力不是大模型能力,而是把自然语言问数这件事拆成了一条可工程化维护的流水线。