用 Claude 的 Tool-Use 做了一个能自主查数据的客服分析 Agent。核心难点不是"让它会查",而是"让它安全地查、别烧钱、别乱编"。上线后最大的体感:给模型自由不难,难的是在给它自由的同时,用确定性系统把边界焊死。

一、为什么是 Agent,而不是更多报表接口

故事从一个真实的运营需求开始。运营想问的问题是开放式、组合式的:

"上周从小红书来的客户里,聊过但最后没成单的,流失原因 top3 是什么?"

这个问题拆开来有五个维度:时间(上周)、来源渠道(小红书)、行为条件(聊过)、结果筛选(没成单)、分析目标(流失原因排行)。走传统路线意味着:提需求 → 写 SQL → 配 BI 看板 → 运营拖拽筛选。一套流程下来,半天到一周过去了。而运营真正需要的,是从想法到答案的秒级闭环。

所以我把"自然语言 → 数据查询 → 结论"这一步交给模型编排,自己只负责提供可信的工具原语。最终暴露给模型的工具集只有 6 个:

工具

用途

设计意图

get_stats

固定口径统计(总量、成单率)

快、可控、结果确定

get_category_breakdown

分类排行(top N 分析)

预设聚合逻辑,避免模型手写复杂 GROUP BY

get_sessions

会话级明细查询

限定范围,不走自由 SQL

get_lost_reasons

流失原因分析

调用预置分析逻辑

batch_search

批量关键词检索

禁止在自由 SQL 中用 LIKE '%kw%'(全表扫描杀手)

query

受限的自由 SQL 查询​

仅用于真正开放的交叉分析

设计哲学很明确:固定口径走专用工具,开放式分析才放行自由 SQL。​ 前者是高速公路,后者是越野路段——不是不让走,而是只有必要时才放行。


二、难点一:让模型自由写 SQL,但绝不能越权

这是整个系统里最危险的地方。一旦模型写出 DROP TABLE 或 UPDATE users SET balance=999999,后果不堪设想。

很多人的第一反应是:"在 prompt 里写清楚不就行了?"

"你只能写 SELECT 语句,禁止任何增删改操作。"

这是最脆弱的方案。​ Prompt 是最外层、最容易被绕过的一层。模型可能因为上下文污染、对抗性输入、甚至自身的"创造性"(它觉得需要临时建表来辅助分析)而违反约束。靠 prompt 守门,相当于在金库门口贴一张"请勿入内"的告示。

我的方案是纵深防御(Defense in Depth),每一层挡不同的东西:

┌──────────────────────────────────────────────────────────┐
│  第 1 层:字符串白名单校验                                 │
│  · 必须以 SELECT 开头(startsWith,非正则匹配)             │
│  · 黑名单关键字一律拒绝:INSERT/UPDATE/DELETE/DROP/        │
│    ALTER/TRUNCATE/CREATE/EXEC/LOAD/INTO OUTFILE/DUMPFILE   │
│  目的:快速失败,挡掉明显的写操作                          │
├──────────────────────────────────────────────────────────┤
│  第 2 层:表白名单                                         │
│  · 查询必须命中 chat_messages / session_analysis          │
│  目的:把可访问范围收缩到分析所需的最小集合                │
├──────────────────────────────────────────────────────────┤
│  第 3 层:资源限制                                         │
│  · 强制 LIMIT 500(移除用户已有的 LIMIT 后统一追加)       │
│  · 查询超时保护(数据库层 statement timeout)              │
│  目的:防止全表扫描或大结果集拖垮数据库                    │
└──────────────────────────────────────────────────────────┘

对应到代码,核心就是几行朴素的校验:

if (!upper.startsWith("SELECT")) return error("仅支持 SELECT 查询");
if (upper.contains("INSERT") || upper.contains("DROP") || ...) {
    return error("包含禁止的写操作关键字");
}
// 必须命中白名单表,再强制拼上 LIMIT 500

但要诚实:字符串校验不是终点

我很清楚这套方案的软肋——字符串/关键词检测总有被绕过的可能:大小写混淆、注释注入、编码绕过、用 UNION 拼接恶意子查询……只要在字符串层面博弈,攻防就没有尽头。而且"必须命中白名单表"是子串判断,它能保证查询引用了白名单表,却不能百分百保证它没同时碰别的表。

所以真正可靠的底座不该是"我检查它写了什么",而应该是"就算它写了坏的,数据库也没权限执行"——也就是用独立的只读账号 / 只读连接去跑这条 SQL,把 INSERT/UPDATE/DELETE/DROP 挡在数据库权限层。

这一层目前还没上,是我明确列出的下一步加固项。​ 当前线上兜底靠的是应用层这三道校验 + 只读的业务场景(这个 Agent 只做分析、不写库)。把它如实写出来,是因为:知道正确答案在哪、并把它排进计划,比假装已经做到了更重要。

这也是我想传递给读者的态度:工程成熟度的标志不是"没有漏洞",而是"清楚地知道自己还有哪些漏洞没补上,并且有计划去补"。


三、难点二:别让它烧钱空转

上线第一天我就发现一个现象:模型特别爱"反复交叉验证"。

它的行为模式是这样的:

  1. 查一遍数据,得到一个初步结论;

  2. 觉得不够确信,换个角度再查一遍验证;

  3. 又想到一个可能的遗漏,再查第三遍;

  4. 还想确认一下边界条件,再查第四遍……

每一轮都消耗 token、增加延迟,但结论的准确度并没有显著提升。它像一个完美主义者在反复检查自己的作业,而运营只想快点知道答案。

更严重的是,自由 SQL 的每次调用都可能触及较大的数据量。如果不加约束,单次对话的成本可能膨胀 5~10 倍。

解法:prompt 里的预算闸门

我在 system prompt 里加了三条硬性约束:

1. 关键词检索必须走 batch_search 工具,禁止在自由 SQL 中使用 LIKE '%关键词%'。
   后者会触发全表扫描,既慢又贵。

2. 跨表分析只能用 query 工具调用一次。拿到结果后直接回答,
   不要追加"验证性"查询。

3. 如果一次查询不足以回答全部问题,先给出基于现有结果的部分结论,
   说明还需要哪些信息,而不是反复尝试不同角度的重查。

这不是限制模型的能力,而是在把它的自由度约束在"够用"的范围内。​ 就像一个优秀的顾问,不是让他无限深入地研究一个问题,而是在给定的时间和预算内给出"足够好的答案"。约束不是枷锁,是让系统变得可预测和可控制的必要条件。


四、难点三:500 行结果会撑爆上下文

自由 SQL 可能返回几百行结果。如果原样塞回模型的上下文,会出现两个问题:

  1. Token 爆炸:500 行 × 每行几十个字符 = 上万 token,一次查询就吃掉大量预算;

  2. 截断风险:如果总上下文接近上限,模型可能在输出中途被截断,导致"有头无尾";

  3. 心算陷阱:模型拿到大量原始数据后,容易试图在上下文中手动做聚合、排序、统计——而这恰恰是它最容易出错的地方。

解法:摘要式回传

query 工具的返回格式是刻意设计过的摘要:

{
  "ok": true,
  "rows": 347,
  "columns": ["date", "buyer", "platform", "lost_reason"],
  "samples": [
    {"date": "2026-05-14", "buyer": "u_82391", "platform": "小红书", "lost_reason": "价格偏高"},
    {"date": "2026-05-15", "buyer": "u_47201", "platform": "小红书", "lost_reason": "对比竞品"}
  ],
  "truncated": true
}

模型拿到的是:行数 + 列结构 + 最多 3 条样本(其中长文本字段还会截到 60 字)。它据此判断趋势和量级,如果需要细节,就主动收窄条件重查。

背后的原则是:模型应该做"理解和解释",而不是"存储和计算"。


五、难点四:流式输出与幻觉治理

流式输出:SSE 比 WebSocket 更轻

推理过程和最终结果用 Server-Sent Events (SSE)​ 流式推给前端。

为什么不用 WebSocket?因为这个场景是单向、服务端持续推的:

  • 模型在思考 → 推"正在分析…"

  • 调用工具 → 推"正在查询数据库…"

  • 拿到结果 → 推"正在生成结论…"

  • 最终输出 → 推正文

没有双向高频通信的需求,不需要 WebSocket 的复杂性。SSE 基于 HTTP,实现简单,重连机制天然,调试友好。对于 Agent 这种"服务端主导、客户端被动接收"的场景,SSE 是正解。

幻觉治理:从架构上而不是从道德上

关于幻觉,我的态度很明确:Agent 一定会算错数字。

指望模型"诚实地说我不知道"是不现实的——它的训练目标就是"生成看起来合理的下一个词"。与其祈祷它别错,不如从架构上让错误的影响最小化。

核心策略是计算下沉:

应该下沉到确定性系统

留给模型

计数(COUNT(*))

趋势判断

求和、平均、百分比

原因解读

排序(ORDER BY)

归纳总结

分组聚合(GROUP BY)

建议生成

时间范围过滤

自然语言转述

精确的数字计算,全部下沉到 SQL 里去做。​ 模型只负责"编排"(决定查什么)和"解释"(告诉用户这意味着什么)。

另一个小但重要的细节:max_tokens 截断时,区分"有没有 tool_use"分别处理。如果模型正在输出工具调用但被截断了,不要让它空转浪费一轮——直接终止并提示用户简化问题。


六、总结:给自由,同时焊死边界

做这个 Agent ,最大的体会可以用一句话概括:

给模型自由,同时用确定性系统兜住边界。

这四个维度的分工,是我反复踩坑后沉淀下来的:

维度

谁来保证

手段

会不会越权

应用层 + 数据库层

关键词/表白名单校验(当前)+ 只读连接(下一步兜底)

会不会烧钱

编排层

prompt 预算闸门 + 工具调用次数限制

会不会撑爆上下文

工具层

结果摘要式回传

会不会算错

执行层

计算下沉到 SQL,模型只编排


七、一个更通用的模式:Agent 的"三明治架构"

把上面的经验抽象一下,你会得到一个适用于很多数据分析 Agent 的通用架构模式:

┌───────────────────────────────────────────────────────────┐
│  上层:自然语言接口(用户输入)                              │
│  自由度高,但不可信                                        │
├───────────────────────────────────────────────────────────┤
│  中间层:Agent 编排(模型 + 工具定义)                       │
│  · 决定调什么工具、把用户意图翻译成工具参数                 │
│  · 把工具结果翻译成自然语言                                │
│  · 受预算闸门约束                                          │
├───────────────────────────────────────────────────────────┤
│  下层:确定性系统(数据库 / 规则引擎)                       │
│  · SQL 执行 + 精确计算(聚合、排序、统计)                  │
│  · 权限与范围控制(表白名单、LIMIT、超时)                  │
│  可信,但不灵活                                            │
└───────────────────────────────────────────────────────────┘

模型活在中间层,上面承接用户的任意表达,下面受制于确定性系统的硬约束。

它不是在"自由地探索数据",而是在一个精心设计的游乐场里"按规则玩耍"。围栏内的自由度已经足够解决 95% 的问题,而围栏本身确保了它不会伤到自己或别人。


八、最后一句话

让 AI 负责编排、理解、生成,把精确和真值交给确定性系统。

这句话说起来简单,真正做到需要你在每一个维度上问自己一个问题:"如果这个判断错了,兜底在哪里?"

有兜底的,放手让模型做;没有兜底的,老老实实写成代码。而对于那些你明确知道"应该有兜底但目前还没有"的地方(比如只读数据库连接),诚实地把它列出来、排进计划,比假装已经做到了更重要。

在 Agent 落地的世界里,克制不是能力的天花板,而是工程成熟的标志。如果你也在做数据分析 Agent,或者对"如何安全地让模型写 SQL"有更多想法,欢迎交流。


如果你觉得这篇文章对你有启发,也欢迎分享你在 Agent 安全设计上的经验和踩坑。

更多推荐