第 6 篇:处理数据统计与分析问题的 Agent

系列记录:《从零搭建企业级 LLM 应用》,这是第 6 篇
上一篇:记忆系统——长短期记忆与混合记忆
下一篇:API 调用 Agent——接入外部数据接口+llm


一、为什么需要数据分析 Agent

前几篇把知识库问答做透了——用户问制度条款、操作规范,RAG 检索文档 + LLM 生成回答,一条线走通。

但很快遇到一类 RAG 完全无力招架的问题:

“上个月考勤,迟到次数最多的是谁?”
“研发经费 3 月和 5 月的实际支出对比。”
“硬件采购总额是多少?”

这类问题有三个共同特征,每一个都在挑战 RAG 的底层假设:

RAG 的假设 数据分析问题的现实
数据在哪里 答案在文档中,检索即命中 答案在 Excel 文件中,要计算才能得出
怎么回答 拼接文档片段 + 生成描述 读取 → 排序 / 分组 / 求和 → 返回数字
答案性质 文本描述,允许部分不精确 数值结论,差一个数就是错的

但这三个特征指向同一个矛盾:LLM 擅长理解自然语言,却不擅长精确计算。你问它"一个月考勤,迟到次数最多的前三名是谁",它可能根据常见模式编一个答案,但你无法分辨这个"常见"里有多少是真实数据、有多少是它猜的。

业界面对这个矛盾,主流路线有两条:

路线一:Text-to-SQL / NL2SQL。适合数据库场景,LLM 把自然语言翻译成 SQL,在数据库里执行。LangChain 的 SQL Agent 就是这条线。

路线二:Code Interpreter / 沙箱执行。适合文件型数据,LLM 生成 Python 代码在隔离环境中执行。OpenAI 的 Code Interpreter、Anthropic 的 Artifacts、以及开源界的 Open Interpreter 都走这条路。

对于企业场景(员工上传 Excel、考勤表、采购单),显然是路线二。所以需要一个专门的 data_agent,它的职责不是检索文档,而是理解统计需求 → 生成 pandas 代码 → 安全执行 → 返回计算结果


二、架构设计逻辑

2.1 为什么是独立微服务而不是 in-process exec?

这是第一个关键设计决策。LLM 生成的代码,直接在当前进程里 exec() 是最简单的方案,但风险有两层:

  1. 安全风险:代码可能包含 os.system("rm -rf /") 或读取 .env 文件泄露密钥。虽然可以用规则引擎在前置阶段拦截,但防御纵深是必要的。
  2. 稳定性风险:一个错误的代码引发 OOM 或死循环,会直接拖垮整个 Agent 服务。

所以做了进程级隔离:code_executor 作为独立 FastAPI 微服务(端口 8001),只暴露两个端点 /execute/execute_batch,只接收代码字符串,返回 JSON 结果。和主 Agent 之间仅通过 HTTP 通信。

2.2 四工具的设计原则:能跳过就跳过

data_agent 配了四个工具,设计的核心原则是减少无效调用——不是每次都必须走完 list_files → inspect_file → execute 三步:

优先级:
  lookup_schema(命中 → 跳过 list_files + inspect_file)
      ↓ 未命中
  list_files → inspect_file → execute_data_query

lookup_schema 读取 data_schema.json,里面登记了所有已知数据类别的模板(skiprows、列名、文件路径)。考勤表、采购单这些稳定格式的文件,登记后可以直接开算,省两轮工具调用。

inspect_file 的设计也有讲究:不传 header=0,而是读原始前 10 行 header=None,让 LLM 看图判断表头在第几行。因为真实 Excel 文件经常第一行是标题、第二行空行、第三行才是列名。硬编码 skiprows 的代价是读错列,所有后续计算全错。

2.3 安全防线:三层隔离

执行 LLM 生成的代码,安全是第一位的。这里用了纵深防御:

第一层(前置)—— 规则引擎:代码在 HTTP 发出前,先用正则 + AST 扫描,拦截 os.systemevalsubprocess、文件写操作等 CRITICAL 级别的危险调用。注意这里只阻断真正危险的操作——import os 后用 os.path.basename() 是完全放行的。如果一刀切封掉 import os,正常功能全坏。

第二层(沙箱内)—— 受限命名空间:Worker 进程只暴露 pdjsonosDATA_PATH 四个变量。没有 requests、没有项目内部模块。代码看不到 Agent 的其他工具,看不到用户数据库。

第三层(进程级)—— 超时 + 隔离:代码在子进程中执行,崩溃不影响主服务。30 秒硬超时,防死循环。这是最后一道防线——前两层被绕过时,至少主服务不死。


三、真正踩到的坑:4 轮迭代 135 秒

试用了一下,发现效率较低,比如提问:“XX部门 5 月的研发经费主要用在哪些方面?总花费是多少?”

结果 data_agent 走了 4 轮 ReAct 迭代才拿到正确答案,总耗时 135 秒。从 LangSmith Trace 还原的完整时间线如下:

轮 ① —— 遗漏筛选条件(50.9s)
  → LLM 生成代码:按经费类别分组统计总额
  → 但忘了加"5 月"日期筛选
  → 结果返回全部数据的总和,数字不对
  → Agent 自己发现问题:"需要筛选 5 月数据"

轮 ② —— 列名错误(34.5s)
  → 加了日期筛选,但列名写成了"实际支出金额"
  → 实际列名是"实际支出 (元)",差两个字带个括号
  → 代码执行报错

轮 ③ —— 列名仍失败(53.6s)
  → 纠正了列名,但"实际支出 (元)"含空格+括号
  → pandas 语法处理不当,仍然报错

轮 ④ —— 终于成功(27.2s)
  → 改用字符串匹配策略筛选日期
  → 这次对了:人员人工费 31,200 + 知识产权费 21,800 + 耗材 55,300 = 108,300 元

拆解这 135 秒的构成:

类别 累计耗时 占比 说明
execute_data_query ×4 ~92s 68% 子进程冷启动 + pandas 加载占了大部分
LLM 代码生成 ×4 ~74s 55% 每次从头生成完整样板代码
Agent 推理 ~28s 21% 思考、决策、纠错

注:部分子 run 存在并行/重叠,累计值超过 135s

这个问题暴露出三个深层矛盾。

矛盾一:LLM 的"猜测病"

LLM 生成代码时有一个根深蒂固的倾向——它会"猜"列名。你告诉它要统计"实际支出",它就写出 df['实际支出']df['实际支出金额'],但真实列名是 实际支出 (元)。差一个括号、一个空格,代码就崩。

LLM 在概率意义上"觉得"列名是什么,和真实的 Excel 表头之间永远存在 gap。消除 gap 的唯一方法是让 LLM 抄作业,而不是猜答案

矛盾二:每次从头生成,而非增量修复

ReAct Agent 的默认行为是:工具返回失败结果 → Agent 重新思考 → 再次调用工具。每次调用工具,LLM 都会从头生成完整的代码——import pandas → read_excel → groupby → sum → print 全流程重写一遍,即使只需要改一个列名。

矛盾三:子进程冷启动的巨大开销

最初用的是最朴素的方式——subprocess.run(["python", "-c", code])

单次执行时间线:
  启动 Python 解释器         ~0.5s
  import pandas               ~1.5s   ← 重型库
  import json / os            ~0.01s
  实际执行代码                ~0.01s
  ──────────────────────────────────
  总计:~2s / 次

一次 2 秒看着不多,但当 Agent 要调 4-5 次工具,每次都在等 2 秒的冷启动,累积起来就是 8-10 秒的空等。


四、解决方案:六个设计决策

每一个决策都对应一个具体的、踩过坑的问题。

决策 1:代码骨架注入——让 LLM 填空,不写样板

自由生成代码的最大问题是 LLM 每次都在重复样板代码(import、文件读取、结果打印),而且经常出错——csv 用 read_excel、忘记 print()、输出格式不统一。

解决方案:给 LLM 预制骨架,只让它填数据处理逻辑:

骨架 = import 语句 + 文件读取(skiprows 已填) + "请在此处填写处理逻辑" + 结果序列化
LLM 的输出 = 骨架的不变部分 + 它填的 groupby/sum/filter 逻辑

决策 2:真实列名注入——抄作业不猜

inspect_filelookup_schema 之后,已经拿到了真实列名。把它们直接注进代码生成的 prompt:

【真实列名(必须严格使用)】
姓名, 迟到次数(次), 实际支出 (元), 支出日期

约束:含括号/空格的列名请用 df['列名'] 而非 df.列名

这本质上是把"信息不对称"的问题在 prompt 里解决了。LLM 不知道列名,则直接告诉它让它抄,不准猜。

决策 3:进程池预热——150 倍性能跃升

这是改动最小但效果最猛的一个。核心思路:不要让 pandas 每次重新加载,让它在后台常驻

# 启动时创建进程池,每个 Worker 预加载 pandas
pool = multiprocessing.Pool(
    processes=4,
    initializer=worker_init    # import pandas 只跑一次
)

# 请求到达时:exec(code, namespace) → 9ms

改造前后的对比:

subprocess 冷启动:~2.0s / 次
进程池预热后   :~0.01s / 次

提升:约 150 倍

这本质上是把"每次都要做"的事提前做好——和数据库连接池、线程池是同一个思想。业界 OpenAI Code Interpreter 也是类似的思路,背后维护一个持久化的 Jupyter kernel,而不是每次创建。

决策 4:工具内部增量修复——改一行不改三十行

默认的 ReAct 行为是:失败 → Agent 重新生成全部代码。这很慢。

execute_data_query 内部加了增量修复环路:

生成代码 → 执行 → 失败?
   → 把错误信息 + 原代码发给 LLM:"只修复出错的行,输出修复后的完整代码"
   → 再次执行
   → 仍失败?再修复一次(最多 2 次)
   → 还失败?返回错误让 Agent 处理

修复一次 LLM 调用 vs 重新生成 + 重新执行,差距是 3-5 倍。

决策 5:代码缓存——同样的活不干两遍

(file_path, skiprows, query) 三个要素一样 → 代码逻辑一定一样。直接复用:

_code_cache: dict[tuple, str] = {}
cache_key = (file_path, skiprows, query)
if cache_key in _code_cache:
    code = _code_cache[cache_key]    # 复用
else:
    code = llm.invoke(code_prompt)
    _code_cache[cache_key] = code    # 缓存

多轮对话中问"那第二名呢"、“那平均值呢”——同一个文件同一个 skiprows,只是 query 不同。有时用户会重复问同一个问题,缓存直接命中。


五、主流方案对比

常见的业界解决"LLM 操作真实数据"这个问题,大致有三条路线:

方案 代表 思路 优势 劣势
Code Interpreter OpenAI, Anthropic LLM 写代码 → 沙箱执行 → 返回结果 灵活,支持任意计算逻辑 代码质量依赖 prompt 工程
Text-to-SQL LangChain SQL Agent, Vanna.ai LLM 翻译自然语言 → SQL → 数据库执行 高效,数据库原生计算能力 只能处理结构化数据库
Function Calling + 预定义计算 传统方案 预定义统计函数,LLM 选函数 + 填参数 稳定,无代码生成风险 不灵活,新统计需求要写新函数

我们这个场景选了路线一,因为 Excel 文件不是数据库,格式多变,预定义函数覆盖不了所有统计需求。

但路线一最大的挑战——代码正确性——我们也付出了不少工程代价。骨架注入、列名注入、增量修复这三个设计决策,本质上是在用结构化约束降低 LLM 的自由度。这和当前业界一个趋势一致:不靠更好的 prompt,靠更少的决策空间


六、从 135 秒到 30 秒

回到那个"研发经费统计"的问题。经过所有优化后:

阶段 优化前 优化后 关键改动
文件探索 18.6s 1.2s lookup_schema 直接命中模板
代码生成 74.0s 12.0s 骨架注入 + 缓存命中
代码执行 92.2s 0.04s 进程池预热(4 次 → 4×10ms)
总计 135s ~30s 降幅 78%

而且一个更重要的变化:不再需要 4 轮迭代。真实列名注入消除了列名错误,骨架注入消除了样板代码错误。第一次调用就成功,Agent 不需要反复纠错。


七、一个更深层的体会

做完 data_agent 之后,我有一个和直觉相反的体会:让 LLM 生成代码,比让它直接回答数字问题更可靠

直觉上,"让 LLM 直接回答"似乎更简单。但 LLM 回答数字问题时本质上在做什么?它在概率建模——"迟到次数最多"这个场景下,它见过的训练数据里通常答案是什么,这就很容易与真实计算值之间存在出入。

但把它写成代码真的跑一遍,结果就是确定的、可复现的、可验证的。
也就是说,在处理数据统计类问题时,设计LLM生成代码的prompt,走沙箱代码执行,比直接回答数字问题,更可靠。


下一篇预告:API 调用 Agent——如何让 LLM 调用外部系统接口,打通安全告警、组织架构等实时数据源。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐