[控场AI]
· 6 分钟阅读· 3,392 字

构建报表Agent:结构化分析工具还是LLM生成SQL?

构建报表Agent:结构化分析工具还是LLM生成SQL?

面向运营的报表Agent,架构难点不在于能否生成查询,而在于如何保证数字正确

本文围绕一位开发者搭建企业内部报表Agent时遇到的架构困境,深入分析了两种核心方案的取舍:结构化分析工具(Pandas/DuckDB执行预定义操作)可控性高,但随业务增长会演变为维护私有查询DSL的负担;LLM直接生成SQL表达能力更强,但会产生"语法正确、数字错误"的隐蔽风险,如因错误JOIN导致的重复计数。文章进一步指出,大数据集和业务元数据应从对话历史中解耦,通过ID和独立状态存储(如LangGraph持久化state)管理,防止上下文截断丢失关键定义。针对正确性问题,文章建议建设行数守恒检查、关联唯一性验证、结果合理性预览和预定义业务指标等防护层。两种方案并非互斥,SQL路线是更值得长期投入的方向,但必须配套完善的正确性保障机制。

在企业内部搭建一个面向运营人员的报表Agent,听起来像是把自然语言问题直接翻译成图表的魔法。但真正动手实现时,架构选择上的取舍会直接决定这套系统能否输出正确的数字——而不仅仅是能跑通的查询。

一位开发者在 Reddit 上分享了他的技术栈与困惑:使用 Python、LangGraph、FastAPI 加上 React/ECharts,数据源全部来自公司的 HTTP API。用户提问后,Agent 需要抓取数据、分析并返回图表。典型场景是:从不同 API 拉取考勤记录和学生名单,按 person_id 关联,筛选迟到记录,再按班级统计迟到次数。这个看似简单的需求,背后隐藏着 AI Agent 数据分析架构的核心难题。

当前实现的痛点:数据塞满对话历史

开发者目前的做法是把功能拆成一组细粒度工具:取数、分组、聚合(count、max 等)、绘图各自独立。工具之间通过 dataset ID 传递数据集,而不是让模型复述原始记录——这是个正确的方向,因为让 LLM 逐条搬运数据既浪费 token 又极易出错。

问题出在数据的存放位置。虽然完整数据集保存在 tool-message 的 artifacts 中,但消息内容里仍然返回了过多数据。随着对话轮次增加,上下文窗口会被大量原始记录挤占,最终触发历史截断,而截断又会导致模型丢失关键的 schema 和业务定义信息。

开发者希望的理想状态是:大数据集始终留在对话历史之外,只给模型 ID、schema、业务描述和小规模的结果预览。这个思路本身没问题,真正需要决策的是如何让模型表达它想要的分析操作。

LangGraph 的持久化 state 机制在这里是关键的架构基础设施。LangGraph 允许开发者将 Agent 的运行状态拆分为"消息历史"和"自定义 state 字段"两部分,二者可以独立管理截断和持久化策略。消息历史随对话增长会触发截断,但自定义 state 字段可以配置为始终保留,不参与截断逻辑。将 dataset ID 映射表、schema 摘要、业务指标定义存入自定义 state 字段,意味着即便对话进行了数十轮,这些元数据仍然完整可用,不会因为历史窗口收缩而丢失。这与把元数据也写入普通消息的做法有本质区别——后者一旦超出上下文窗口就永久不可见,而前者通过工具调用随时可以重新读取。设计 LangGraph state schema 时,明确区分"对话内容"与"会话元数据",是保障长对话稳定性的重要工程决策。

两条路线的权衡

开发者提出了两种替代当前细粒度工具的方案,各有明显的风险。

方案一:结构化分析工具

模型提供 dataset ID、预定义的关联关系、过滤条件、分组字段和聚合操作,后端用 Pandas 或 DuckDB 验证并执行。这种方式的好处是操作被严格约束在预定义的语义内,模型不可能生成无法解析的请求。

但开发者敏锐地指出了它的隐患:你会逐渐发明自己的查询语言。随着业务需求增长,你需要支持更复杂的过滤、嵌套聚合、多表关联,每一次扩展都是在给这套私有 DSL 添砖加瓦。最终你可能重新实现了一个残缺版的 SQL,还得自己维护它的解析器、验证器和文档。

方案二:LLM 生成 SQL

把 API 响应转成 DuckDB 中的表,模型拿到 schema 和业务元数据后直接生成 SQL,提交验证执行,结果存储后按 ID 传给绘图工具。SQL 的表达能力足够强,也是经过数十年验证的成熟查询语言,模型对 SQL 的训练数据也非常充分。

它的风险更隐蔽也更致命:语法完全正确、执行成功,但因为关联错误或指标理解偏差而产出错误的数字。比如一对多关联导致的重复计数,或者把"迟到次数"错误理解成"迟到的不同人数"。这类错误不会报错,会安静地生成一张看起来很专业、实则误导决策的图表。

DuckDB 在这个架构中的角色值得单独说明。它是一个嵌入式的列式分析数据库,可以直接在 Python 进程内运行,无需独立部署服务。其核心优势在于能够将 Pandas DataFrame、JSON 甚至 Parquet 文件直接注册为虚拟表,让 API 返回的数据可以立即被 SQL 查询——这使得"把 API 响应转成表再用 SQL 分析"的流程变得低摩擦。DuckDB 对标准 SQL 的支持相当完整,包括窗口函数、CTE(公共表表达式)等复杂分析语法,而这些正是报表场景中常见的需求。与把数据推入 PostgreSQL 等传统数据库相比,DuckDB 省去了网络传输和连接管理的开销,适合处理中等规模的临时分析数据集。不过,它的嵌入式特性也意味着多进程并发写入需要额外设计,如果 Agent 并发请求量较高,需要评估是否需要引入独立的查询引擎。

核心挑战:正确性远比能跑通更难

两个方案对比下来,真正的分歧点不在于选 Pandas 还是 SQL,而在于如何保证分析结果的正确性。这也是社区讨论中最值得深挖的部分。

对于 schema 和业务定义如何暴露而不撑爆上下文窗口,一个可行思路是分层供给:默认只给模型精简的表结构和字段业务含义摘要,当模型明确需要某张表的细节时,再通过专门的"查询 schema"工具按需拉取完整定义。这样既保留了信息可达性,又避免了每轮对话都携带全量元数据。

历史被截断后如何恢复这些信息,则需要把关键元数据从对话历史中解耦。schema、业务定义、dataset ID 映射应该存放在独立的状态存储(如 LangGraph 的持久化 state)中,而不是依赖对话消息。这样即便消息历史被裁剪,Agent 仍能通过工具重新获取上下文。

如何捕捉错误的关联与重复计数

这是整套系统最难、也最容易被忽视的环节。语法检查完全无法捕捉这类语义错误,需要额外的防护层:

  • 行数守恒检查:关联前后记录数的变化往往能暴露意外的笛卡尔积或一对多膨胀。如果关联后行数远超预期,应触发警告。
  • 关联唯一性验证:在执行 join 前,检查关联键在"一"侧表中是否真正唯一,避免因重复键造成的重复计数。
  • 结果合理性预览:给模型返回小规模结果预览,让它有机会对数字量级做常识判断,或在返回给用户前做一次自检。
  • 预定义指标而非临时计算:对于"迟到次数"这类关键业务指标,与其让模型每次即兴生成聚合逻辑,不如在元数据层固化其精确定义,减少歧义空间。

笛卡尔积(Cartesian Product)是关联错误中最典型的一类,理解它有助于设计防护规则。当两张表按某个键做 JOIN 时,如果该键在任意一侧存在重复值,结果行数会成倍膨胀——例如考勤表中同一 person_id 有 3 条记录,而学生名单中该 person_id 也因数据问题出现了 2 次,关联后该学生的记录会变成 6 条,导致迟到次数被重复计算。这种膨胀不会触发任何 SQL 报错,结果看起来完全正常。"行数守恒检查"的本质就是对这一现象的数值验证:如果关联后的行数超过关联前"多"侧表的行数,通常意味着"一"侧存在重复键。实际实现时,可以在执行 JOIN 之前先用 SELECT COUNT(*) vs COUNT(DISTINCT key) 的差异来检测重复键,并将检测结果作为提示注入给模型,让其决定是继续执行、调整 SQL 还是向用户请求澄清。

实践建议

从这场讨论中可以提炼出一个务实的方向:两种方案并非非此即彼。SQL 方案的表达能力更强、生态更成熟,是更值得投入的长期路线,但必须配套建设正确性防护层。结构化工具方案则适合在业务查询模式相对固定、追求高确定性的场景中作为兜底。

无论选哪条路,把大数据集留在对话历史之外、通过 ID 和 schema 与模型交互、将业务定义沉淀为可复用的元数据,这些原则都是共通的。真正的工程难点从来不是让 Agent 跑出一张图,而是让运营人员敢于相信这张图上的数字。

本文基于 Reddit 开发者社区的技术讨论整理,属于单一来源的经验分享与架构探讨,具体方案仍需结合自身业务验证。

分享:

相关推荐