Text2SQL 确定性管线 + ReAct 双路径:datapulse AI BI 工作台架构解析

作者:

🇬🇧 English

引言:AI BI 的痛点与双路径设计的起点

用 LLM 做 BI 分析,听起来很诱人,做起来全是坑。开发者最常遇到的三个问题几乎一模一样:

幻觉。 LLM 在数据库没有数据的时候,依然会自信地编出一个数字。

SQL 不可信。 即使生成了查询,也可能悄悄混入 INSERTUPDATEDROP 之类危险的语句,或者拼出多语句注入。

输出不可控。 模型可能把一段指令塞进 JSON 字段,自己就"听话执行"了。

datapulse 的核心设计张力,就落在 确定性 vs 灵活性 这一条轴上。取数问题追求确定——同一句话同一刻结果必然一样;分析探索问题追求灵活——允许多轮对话、允许尝试不同的查询。把这两条路混在一起做,往往会两头不讨好:确定性管线被 ReAct 的随意性拖慢,智能体又被繁琐的校验逻辑束缚。

所以 datapulse 选择把它们拆开,各走各的路,再用一个轻量的路由把它们粘合起来。

设计决策一:确定性 Text2SQL 管线 vs ReAct 智能体

我们最初的做法是统一的 ReAct 智能体:把所有问题都丢进去,让模型自己决定要不要调 run_sql 工具。结果很快暴露了两个问题。

一是速度。分析类问题本来只需要一次 SELECT,ReAct 模式要经过 思考 → 调用工具 → 观察结果 → 再思考 → 再调用 的完整循环,延迟高得难以接受。

二是可靠性。确定性取数任务被塞进探索式循环里,模型容易产生多余的"思考"步骤,甚至因为上下文过长而遗忘前面查过的数据,反复执行同一个查询。

于是我们拆出了两条路。

确定性管线 面向取数类问题:用户问"上个月销售额是多少",系统直接生成 SQL、执行、返回结果,没有多余对话。

ReAct 智能体 面向分析类问题:用户说"帮我看看这个数据集里有什么值得关注的趋势",模型可以多次调用工具、逐步探索。

这个决策的关键在于路由。router.ts 里只维护一张分析类关键词表——含有"为什么""原因""建议""分析""策略""优化""预测""解释"等 19 个开放探索词汇的问题才进 ReAct 智能体;其余一切取数/聚合类问题("上个月销售额是多少""上月各品类排名"等)默认走确定性管线

路由本身很简单,但它决定了一条查询要经历多长的链路。取数问题直接进确定性管线,链路短、代价低、结果可重现;探索问题进 ReAct 智能体,允许模型自主决定调用次数。

设计决策二:SELECT-only 双重守卫(正则 + AST)

把 SQL 生成交给 LLM,安全是第一位的。我们尝试过三种方案。

仅正则守卫。 最初我们只做了简单的正则检查:

if (!/^\s*select\b/i.test(sql)) throw new Error('Only SELECT statements are allowed')

看起来没问题,但很快被发现了:LLM 有时会输出注释开头的查询,比如 -- 请帮我查一下\ndelete from users;,正则从第一个非空白字符开始匹配,直接绕过了守卫。

方言差异决定了校验手段。 datapulse 同时支持三种数据源,却不存在一套通用的 SQL 校验器:SQLite 侧没有成熟的通用 AST 库,我们靠 better-sqlite3 的 prepare 预编译兜底——非法字段、列名拼错的语句会在预编译阶段直接抛错;PostgreSQL 和 MySQL 侧则交给 node-sql-parser 做真正的 AST 解析,从句法层面识别语句类型。校验方式跟着方言走,而不是强求一套统一方案。

双重守卫。 AST 之外,还有一层跨方言都生效的加固。

第一层是正则,但加了注释预处理:

const sql = stripLeadingComments(trimmed)
if (!/^\s*select\b/i.test(sql)) throw new Error('Only SELECT statements are allowed')

stripLeadingComments 函数会递归剥离 -- 行注释和 /* */ 块注释,确保类型检查落在真正的 SQL 语句上,而不是被注释绕过的空字符串。

第二层是多语句注入检测:

if (!/;\s*$/.test(sql) && sql.includes(';')) throw new Error('multiple statements are not allowed')

这条规则的核心逻辑是:合法的 SQL 查询至多允许末尾一个分号;如果分号出现在中间位置,就说明存在多语句注入的风险,直接拒绝。

两层守卫加起来,覆盖了"注释绕过"和"多语句注入"两个最典型的攻击面,同时保持了对三种方言的兼容性。

设计决策三:三方言实时 introspection 与差异化缓存策略

datapulse 支持 SQLite、PostgreSQL、MySQL 三种数据源。一开始我们想过一个"通用 schema 缓存"的方案——把所有表的列信息缓存下来,统一用一张表描述。但很快被现实打脸。

用户会导入 CSV,CSV 的列名和类型是动态推断的,不可能提前硬编码。用户的 PostgreSQL 和 MySQL 数据库结构也各不相同,schema 经常变化。用一套静态 schema 服务三种数据源,维护成本太高,且随时可能过期。

所以我们改为 实时 introspection:每次查询前,根据当前数据源类型动态获取 schema。但实时获取也有代价,于是我们按数据源特性做了差异化的缓存策略。

SQLite:mtime 缓存。 SQLite 是本地文件,schema 变化可以通过文件修改时间来检测。我们用 stat().mtimeMs 作为版本号,mtime 没变就用缓存,变了就重新拉取。这个方案几乎零开销,且对文件级变更足够敏感。

PostgreSQL / MySQL:TTL 缓存。 远程数据库不存在"文件修改时间"这个概念,我们改用 60 秒 TTL。缓存过期后自动失效,由下一次查询重新填充。60 秒是一个经验值:对用户来说几乎无感,对数据库来说也不会造成查询风暴。

三种方言,两套策略,各自的复杂度都很低,但组合在一起覆盖了所有常见场景。

设计决策四:阶段化自纠错——生成、校验、回答各独立重试

确定性管线不是"生成一条 SQL、执行、返回"这么简单。一个健壮的系统要允许失败,并在失败后自动恢复。

pipeline.ts 里,整个流程被拆成三个阶段,每个阶段都有独立的错误处理能力:

  • SQL 生成阶段。 LLM 可能输出无效 SQL。我们不会直接报错给用户,而是把错误信息反馈给模型,让它重新生成。这个过程最多重试若干次,直到生成一条能通过守卫的 SQL,或者确定无法修复为止。
  • SQL 执行阶段。 数据库可能返回语法错误或权限错误。同样,错误会被收集并反馈给生成阶段,尝试用修正后的提示词重新生成。
  • 回答生成阶段。 即使查询成功,LLM 也可能因为上下文过长或指令混淆而输出不可读的答案。finalize.ts 里有专门的逻辑来处理这种情况。

阶段化设计的核心收益是:一个阶段的失败不会拖累整个查询。 SQL 生成失败不会导致已经查到的数据丢失,回答生成失败也不会重新跑一次查询。每次失败都在最小范围内修复,系统整体保持稳定。

设计决策五:ECharts 看板自动生成——从 LLM 输出到独立 HTML

数据查到了,如何呈现?datapulse 的选择是:让 LLM 生成 ECharts 配置,然后渲染成独立的 HTML 文件。

generate.ts 里的 agent 循环设计很简洁:模型可以发起多次查询(多轮对话),把每次的结果汇总后,最终输出一个 JSON 配置。这个 JSON 描述了图表类型、数据映射、坐标轴标签等关键信息。

render.ts 负责把这些配置转成可独立运行的 HTML 页面。它的巧妙之处在于零依赖——通过 CDN 引入 ECharts,不需要构建工具,不需要包管理器,直接打开 HTML 文件就能看到图表。这对一个需要快速分享、快速验证的分析工具来说非常重要。

整个流程:LLM 输出 JSON 配置 → render.ts 内联渲染 → 独立 HTML 文件。每一步都是确定性的,没有额外的 LLM 调用,不存在"渲染环节幻觉"的可能。

CSV 导入的"不完美"设计

datapulse 支持用户上传 CSV 文件,系统会自动推断每一列的数据类型,然后写入 SQLite。这个功能有几个设计取舍值得记录。

类型推断是启发式的。 系统默认用全部行来推断每一列的类型:按能否解析为整数/浮点判定 INTEGER/REAL,否则归为 TEXT(也支持配置只抽样前若干行)。启发式意味着极少数混合类型的边界情况可能被归为更宽松的类型,但常见 CSV 场景结果都是正确的。我们不打算为此引入一个完整的类型推断引擎,成本远大于收益。

写入是单事务的。 整个 CSV 文件在一个事务里完成,要么全部写入成功,要么全部回滚。这是一个简单的取舍:放弃了逐行处理的内存效率,换取了数据一致性保证。对于几十 MB 级别的 CSV,这个方案完全够用。

这些设计都不是最优雅的,但都是"足够好"的工程决策——在正确的时间和正确的复杂度上解决问题。

总结:架构设计的取舍与启示

回顾 datapulse 的架构,有三条原则是可以迁移到其他项目的:

第一,确定性管线优先于灵活智能体。 如果一个任务可以用确定性步骤解决,就不要让它进入 ReAct 循环。确定性管线在速度、成本、可预测性上都有明显优势,智能体应该作为补充而不是默认路径。

第二,双重守卫是 LLM 写 SQL 的底线。 正则预处理注释 + 多语句注入检测,这两层加起来以极低的成本覆盖了最常见的安全风险。AST 解析更精确,但工程复杂度也更高,在支持多方言的场景下反而是负担。

第三,缓存策略要按数据源特性差异化。 没有一种缓存策略适用于所有场景。SQLite 用 mtime、远程数据库用 TTL,各自的开销和正确性都很清晰。强求统一策略,往往会为了兼容性付出不必要的代价。

架构设计没有银弹,只有在具体约束下的最优选择。datapulse 的双路径设计、双重守卫、差异化缓存,每一条都是针对具体问题的答案,而不是可以生搬硬套的模板。


源码导航

  • src/agent/sqlTool.ts — SELECT-only 双重守卫与只读查询工具实现
  • src/agent/text2sql/pipeline.ts — 确定性管线与阶段化自纠错
  • src/agent/text2sql/router.ts — 取数/探索双路径路由
  • src/agent/text2sql/sqlite.ts — SQLite introspection 与 mtime 缓存
  • src/agent/text2sql/postgres.ts — PostgreSQL introspection 与 TTL 缓存
  • src/agent/text2sql/mysql.ts — MySQL introspection 与 TTL 缓存
  • src/agent/text2sql/generator.ts — SQL 生成阶段
  • src/agent/text2sql/finalize.ts — 回答生成与护栏
  • src/bi/generate.ts — ECharts 看板 agent 循环
  • src/bi/render.ts — ECharts 配置到 HTML 渲染
  • src/import/csvImport.ts — CSV 导入与类型推断

项目仓库:github.com/erishen/datapulse

datapulse 为什么要分确定性和 ReAct 两条路?

统一走 ReAct 会导致取数任务延迟高、结果不稳定;拆成两条路后,取数走确定性管线(快且可重试),分析走 ReAct 智能体(灵活可探索),各自发挥优势。

SELECT-only 守卫为什么需要两层而不是只用正则?

单纯的正则会被注释开头的 SQL 绕过,而注释剥离后的正则加上多语句注入检测,两层叠加覆盖了两个最常见的攻击面,且不依赖特定数据库的 AST 解析器。

SQLite 和 PostgreSQL/MySQL 的缓存策略为什么不同?

SQLite 是本地文件,可以用 mtime 检测 schema 变化,几乎零开销;远程数据库没有文件修改时间这个概念,只能用 TTL 方案,60 秒是经验上兼顾实时性与数据库压力的值。

pipeline.ts 的三阶段自纠错有什么好处?

SQL 生成失败、执行失败、回答失败各自独立重试,一个阶段的错误不会导致已查询到的数据丢失,也不需要重跑整个流程。

finalize.ts 的 unwrapAnswerFence 函数解决了什么问题?

模型有时会把整个回答包在 markdown 代码围栏里,这个函数会检测并剥离单层的围栏,防止围栏符号泄露到 UI 中。

CSV 导入的类型推断有什么局限?

默认用全部行做启发式推断,混合类型的极端边界可能被归为更宽松的类型;但引入完整类型推断引擎的成本远高于收益,常见 CSV 场景结果正确。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

首页 简历 商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号