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
仅看这些接口,就能判断系统被分成了四层职责:
- 主题/模型生命周期层:负责预加载、增量加载、删除和缓存管理
- 会话层:负责 session、上一轮状态、多轮继承
- 解析层:负责切词、意图识别、NL2DSL、DSL2Intent
- 运维层:负责健康检查、Prometheus 指标、日志和资源监控
这和普通 LLM 应用最本质的区别在于:它的复杂度主要来自工程编排,而不是提示词。
3. 启动阶段:先建缓存和状态,再谈解析
startup() 做的事情很有代表性:
- 检查目录和日志路径
- 初始化
table_parser_map和persist_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. 数据预热:主题不是一次查询时临时拼出来的
preload 和 load 两个接口非常关键。它们说明 chat2chart 的工作方式不是“查询来了再现查 schema”,而是先把主题信息变成内存中的可解析对象。
这一层由 utils/app_funcs.py 里的 get_subject_info() 和 get_model_infos() 完成,具体包括:
- 解析模型 schema
- 构建属性自动机、模糊匹配字典、别名映射
- 构建 table / field / transferName 双向映射
- 初始化
GrammarBasedParser - 注入 slang、自定义参数等业务词汇
最后,一个 subject 会被扩展成:
subject_namemodel_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:
QueryGenieDataGenieDateGenieAttributeGenieTaskGenieVisGenie
这套命名看起来很“学术项目”,但工程上其实很受用,因为它明确分离了几种不同责任。
6.1 QueryGenie:先把输入规整化
QueryGenie 负责的不是理解业务,而是让 query 进入一个更适合规则分析的形态,例如:
- 中文数字转阿拉伯数字
- 百分比转小数
- “上半年/下半年”“半个月”之类的时间表达预处理
- 一些泛化词形归一
这一步听起来不高级,但极其重要。因为规则系统最怕输入不规范,预处理做不好,后面的 attribute/task/date 都会变脆。
6.2 AttributeGenie:先找字段,再谈任务
在 NL4DV.analyze_query() 里,字段识别先于任务识别:
extract_table_nameextract_attributes_from_dict- 去掉 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() 是真正把语义结果收束成结构化输出的地方。
它最终构造的对象包括:
TableDateRangeTimeUnitDimensionMetricDimensionFilterCalculationFilterExpressionCalculationLimitVisHintPotentialSemanticMention
也就是说,系统的最终落点不是 SQL,不是图表配置,而是一个领域中间表示 Intent。
这一步非常关键,因为它决定了这个系统可以支持多种下游:
- 从 query 直接解析 intent
- 从 DSL 反解 intent
- 在 intent 上做多轮补全
- 在 intent 上做 retrieval / SQL / 图表建议
这就是为什么我说它更像“解析引擎”而不是“问答应用”。
7.1 Intent 建模比规则本身更重要
parsers/intent.py 里的类定义说明作者已经非常明确地把业务概念对象化了:
- 聚合函数
AggFuncEnum - 时间粒度
TimeUnitEnum - 计算类型
CalcTypeEnum - 维度过滤
DimFilterOptEnum
这使得系统后续的日志、调试、测试和多轮处理都有了稳定的语义边界。
换句话说,真正可维护的不是规则,而是规则最终汇聚到的统一抽象。
8. 多轮能力:不是聊天历史拼接,而是显式状态机
DialogStatus 和 DialogManagement 是这套系统另一个很值得肯定的点。
很多所谓多轮问数系统,其实只是把上一轮 query 拼到下一轮提示词里。这种做法看起来快,但一旦进入:
- 改时间范围
- 改维度
- 改指标
- 加过滤
- 子查询
- 比较/同比/环比
就会很难控。
chat2chart 的做法更硬核:它维护显式的对话状态追踪信息 dst,并且:
- 每个 session 只保留最近 3 轮
- 设置时间窗口
- Redis 与内存都能存
policy()/post_policy()根据多轮意图显式修改 intent
从 dialog_management.py 可以看到它识别和处理的多轮类型并不少:
intent_AddMetricintent_Visintent_Rankintent_Compareintent_ChangeDateintent_ChangeDimintent_ChangeMetintent_ChangeDimFilterintent_ChangeExprintent_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 类族上。
这件事的意义远大于“多了一个接口”。
它意味着:
- 前端/上游模型可以先生成 DSL,再让服务端校正为 intent
- 自然语言与 DSL 两条入口可以汇合到同一后处理和多轮体系
- 系统可以逐步从纯规则解析过渡到“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 的核心竞争力不是大模型能力,而是把自然语言问数这件事拆成了一条可工程化维护的流水线。