
第 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()是最简单的方案但风险有两层安全风险代码可能包含os.system(rm -rf /)或读取.env文件泄露密钥。虽然可以用规则引擎在前置阶段拦截但防御纵深是必要的。稳定性风险一个错误的代码引发 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_querylookup_schema读取data_schema.json里面登记了所有已知数据类别的模板skiprows、列名、文件路径。考勤表、采购单这些稳定格式的文件登记后可以直接开算省两轮工具调用。inspect_file的设计也有讲究不传header0而是读原始前 10 行headerNone让 LLM 看图判断表头在第几行。因为真实 Excel 文件经常第一行是标题、第二行空行、第三行才是列名。硬编码 skiprows 的代价是读错列所有后续计算全错。2.3 安全防线三层隔离执行 LLM 生成的代码安全是第一位的。这里用了纵深防御第一层前置—— 规则引擎代码在 HTTP 发出前先用正则 AST 扫描拦截os.system、eval、subprocess、文件写操作等 CRITICAL 级别的危险调用。注意这里只阻断真正危险的操作——import os后用os.path.basename()是完全放行的。如果一刀切封掉import os正常功能全坏。第二层沙箱内—— 受限命名空间Worker 进程只暴露pd、json、os、DATA_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~92s68%子进程冷启动 pandas 加载占了大部分LLM 代码生成 ×4~74s55%每次从头生成完整样板代码Agent 推理~28s21%思考、决策、纠错注部分子 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_file或lookup_schema之后已经拿到了真实列名。把它们直接注进代码生成的 prompt【真实列名必须严格使用】 姓名, 迟到次数次, 实际支出 (元), 支出日期 约束含括号/空格的列名请用 df[列名] 而非 df.列名这本质上是把信息不对称的问题在 prompt 里解决了。LLM 不知道列名则直接告诉它让它抄不准猜。决策 3进程池预热——150 倍性能跃升这是改动最小但效果最猛的一个。核心思路不要让 pandas 每次重新加载让它在后台常驻。# 启动时创建进程池每个 Worker 预加载 pandaspoolmultiprocessing.Pool(processes4,initializerworker_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)ifcache_keyin_code_cache:code_code_cache[cache_key]# 复用else:codellm.invoke(code_prompt)_code_cache[cache_key]code# 缓存多轮对话中问那第二名呢、“那平均值呢”——同一个文件同一个 skiprows只是 query 不同。有时用户会重复问同一个问题缓存直接命中。五、主流方案对比常见的业界解决LLM 操作真实数据这个问题大致有三条路线方案代表思路优势劣势Code InterpreterOpenAI, AnthropicLLM 写代码 → 沙箱执行 → 返回结果灵活支持任意计算逻辑代码质量依赖 prompt 工程Text-to-SQLLangChain SQL Agent, Vanna.aiLLM 翻译自然语言 → SQL → 数据库执行高效数据库原生计算能力只能处理结构化数据库Function Calling 预定义计算传统方案预定义统计函数LLM 选函数 填参数稳定无代码生成风险不灵活新统计需求要写新函数我们这个场景选了路线一因为 Excel 文件不是数据库格式多变预定义函数覆盖不了所有统计需求。但路线一最大的挑战——代码正确性——我们也付出了不少工程代价。骨架注入、列名注入、增量修复这三个设计决策本质上是在用结构化约束降低 LLM 的自由度。这和当前业界一个趋势一致不靠更好的 prompt靠更少的决策空间。六、从 135 秒到 30 秒回到那个研发经费统计的问题。经过所有优化后阶段优化前优化后关键改动文件探索18.6s1.2slookup_schema 直接命中模板代码生成74.0s12.0s骨架注入 缓存命中代码执行92.2s0.04s进程池预热4 次 → 4×10ms总计135s~30s降幅 78%而且一个更重要的变化不再需要 4 轮迭代。真实列名注入消除了列名错误骨架注入消除了样板代码错误。第一次调用就成功Agent 不需要反复纠错。七、一个更深层的体会做完 data_agent 之后我有一个和直觉相反的体会让 LLM 生成代码比让它直接回答数字问题更可靠。直觉上让 LLM 直接回答似乎更简单。但 LLM 回答数字问题时本质上在做什么它在概率建模——迟到次数最多这个场景下它见过的训练数据里通常答案是什么这就很容易与真实计算值之间存在出入。但把它写成代码真的跑一遍结果就是确定的、可复现的、可验证的。也就是说在处理数据统计类问题时设计LLM生成代码的prompt走沙箱代码执行比直接回答数字问题更可靠。下一篇预告API 调用 Agent——如何让 LLM 调用外部系统接口打通安全告警、组织架构等实时数据源。