从 LangChain SQLAgent 天生缺陷到五把安全锁落地 — 牧场 AI 查询实战踩坑指南

先说结论 :LangChain 的 create_sql_agent 拿来跑 Demo 没问题,但直接丢生产环境等同于给 AI 开了数据库的无限制写权限。我们在牧场 AI 项目里踩了三大坑后,用 五把安全锁 把 LLM 的 SQL 生成能力关进了笼子里------表名只能从白名单取、WHERE 条件强制注入租户隔离、写操作关键字直接拦截。最后跑出来的效果是:零跨租户泄漏、零非法 SQL 执行、100% 拦截率


一、LangChain SQLAgent「开箱」的致命幻觉 🎭

一切要从「跑个 Demo 试试」说起。牧场 AI 助手需要支持自由 SQL 查询,比如「牧场A最近一周降产预警」「各牛舍饲喂统计」这类模板覆盖不到的复杂问题。

调研阶段,LangChain 的 create_sql_agent + SQLDatabaseToolkit 简直完美:

python 复制代码
# 刚上手时:三行代码就通了,感觉捡到宝了
from langchain_community.agent_toolkits.sql.toolkit import SQLDatabaseToolkit
from langchain.agents import create_agent

toolkit = SQLDatabaseToolkit(db=db, llm=llm)
agent = create_agent(model=llm, tools=toolkit.get_tools())
# → "产奶量最高的10头牛是......"  ✅ 太爽了!

但压测一轮后就笑不出来了。三个致命缺陷在真实数据面前暴露无遗:

缺陷1:LLM 会编造表名 🔮

用户问「产奶TOP10」,Agent 看了 DDL 后生成查询,但某次它 return 了一句:

sql 复制代码
SELECT cattle_number, SUM(milk) FROM cattle_production_summary_2024 ...

缺陷2:多租户隔离形同虚设 🔓

牧场系统是多租户架构,farm_id=86(牧场A)和 farm_id=88(牧场B)的数据存在同一张表里。LangChain SQLAgent 完全不知道「租户」是什么概念:

sql 复制代码
-- 用户 farm_id=86,但 Agent 可能生成这样的 SQL:
SELECT * FROM cattle WHERE milk_yield > 50
-- 没有 farm_id=86 过滤!等于查了全牧场的牛

LLM 生成 SQL 时不会自觉加上 WHERE farm_id=86------除非你在 Prompt 里明确要求。但就算 Prompt 里说了,LLM 也经常「忘」。

缺陷3:数据库安全裸奔 💣

这是最恐怖的。SQLAgent 的 sql_db_query 工具对 SQL 类型没有任何限制

python 复制代码
# LangChain SQLDatabase.run() --- 来者不拒
db.run("DROP TABLE cattle")        # 如果 LLM 抽风,这就是灾难
db.run("UPDATE farms SET ...")     # 静默修改数据

如果你只给 Agent 配了只读账户------那是你的最后防线。但如果账户权限配错了呢?如果 LLM 被 prompt injection 诱导执行危险操作呢?

核心矛盾:LangChain SQLAgent 是为「通用 SQL 助手」设计的,不是「多租户生产数据库安全查询层」。


二、决策定调:不走 LangGraph 弯路 🧭

发现问题后,先看了 LangGraph 的 checkpoint-sqlite 方案------但很快放弃:

  1. 状态管理过于复杂:LangGraph 引入了 checkpoint 机制,需要额外维护 SQLite 状态库。而项目已有的 brain 管线(7 层流水线)本身就有自己的状态管理逻辑,两者冲突。
  2. 集成成本高 :要改 brain 的 free_sql 层为 LangGraph Agent,牵一发动全身。
  3. 调试噩梦:LangGraph 的多轮 Agent 循环在 trace 层面非常不透明,排查「LLM 为什么选了这张表」几乎不可能。

最终方案

sql 复制代码
独立 aiohttp 服务
  → LangChain Agent 内核(create_agent + SecureSQLDatabase)
  → 自研安全层(五把锁,在 SQL 执行前逐层拦截)
  → brain 端只做调度 + 润色,不和 SQL 执行耦合
python 复制代码
# server.py --- 极简入口
async def sql_generate(request):
    question = body.get("question")
    # 锁5: asyncio.wait_for 外层兜底超时
    result = await asyncio.wait_for(
        loop.run_in_executor(None, generate_sql, question, farm_id),
        timeout=AGENT_TIMEOUT + 10,
    )
    return web.json_response(result)

三、五把锁详解 🔐

先看执行优先级和整体架构。每一条 SQL 从生成到执行,经过的检查链是:

scss 复制代码
LLM 生成 SQL
  → [锁3] 静态校验(语法 + 关键字黑名单)  ← 最快,先拦截
  → [锁1] 表/字段白名单验证                ← 确保只访问授权表
  → [锁2] 租户 WHERE 强制注入              ← 最后的 SQL 改写
  → SecureSQLDatabase.run() 执行           ← 只读账户兜底
python 复制代码
# security_locks.py --- apply_all_locks() 的执行顺序
def apply_all_locks(sql: str) -> Tuple[str, str]:
    # 锁3: 静态校验(语法 + 黑名单关键字)
    ok, err = validate_sql_static(sql)
    if not ok:
        return sql, f"[锁3-静态校验] {err}"

    # 锁1: 表白名单
    ok, err = validate_table_whitelist(sql)
    if not ok:
        return sql, f"[锁1-表白名单] {err}"

    # 锁2: 租户 WHERE(最后改写 SQL)
    sql, modified = enforce_tenant_where(sql)
    return sql, ""

⚠️ 优先级设计原则 :先检查(锁3→锁1),后改写(锁2)。检查失败直接拦截不生成日志噪音;改写成功才放行执行。最底层的保障是只读数据库账户 ------就算前四把锁全崩了,SQL 用户 readonly_user 也写不了任何数据。


锁1:表/字段白名单 --- 38 张表精确授权 📋

核心思路:不在白名单里的表,LLM 连提都不能提

python 复制代码
# config.py --- 白名单定义(基于真实 DDL 验证后的 38 张表)
CORE_TABLES = {
    # milk_platform(主库)
    "cattle":              "farm_id",
    "farms":               None,       # 牧场参考表,按 id 过滤
    "milking_results":     "farm_id",
    "milk_yield_lower":    "farm_id",
    # ... 共 31 张主库表

    # farm_feed(跨库,远程 MySQL)
    "farm_feed.feeding_operation_detail":  "farm_id",
    "farm_feed.feeding_result_summary":    "farm_id",
    "farm_feed.farm":                      "id",

    # farm_weather(跨库,远程 MySQL)
    "farm_weather.weather_current":   None,
    "farm_weather.weather_forecast":  None,
    # ... 共 38 张表
}

白名单不是手写的------它是 DDL 同步(锁4)自动填充 的。启动时通过 SHOW CREATE TABLE 拉取真实 DDL,_parse_columns_from_ddl() 解析列名,TABLE_WHITELIST 字典自动构建:

python 复制代码
# ddl_sync.py --- 白名单自动构建
def load_all_ddl() -> Dict[str, str]:
    new_whitelist = {}
    for db_key in DB_CONFIGS:
        ddl_map = _load_ddl_for_db(db_key)
        for tbl, ddl in ddl_map.items():
            entry = core_tables_bare.get(tbl)
            if entry is None:
                continue  # CORE_TABLES 里没定义的,直接跳过
            new_whitelist[tbl] = _parse_columns_from_ddl(ddl)

    # 原子更新
    TABLE_WHITELIST.clear()
    TABLE_WHITELIST.update(new_whitelist)

验证时,从 SQL 中提取参与的表名,逐一检查:

python 复制代码
# security_locks.py --- 白名单验证
def validate_table_whitelist(sql: str) -> Tuple[bool, str]:
    tables_in_sql = _extract_table_names_from_sql(sql)
    for tbl in tables_in_sql:
        if tbl not in TABLE_WHITELIST:
            return False, f"表不在白名单中: {tbl}"
    return True, ""

锁2:租户 WHERE 强制拼接 --- farm_id 隐形注入 🏷️

多租户隔离的核心:LLM 永远不需要知道租户字段的存在 。系统自动为每条 SQL 注入 farm_id=86 条件。

python 复制代码
# security_locks.py --- 租户强制注入
def enforce_tenant_where(sql: str) -> Tuple[str, bool]:
    parsed = sqlparse.parse(sql)
    table = _find_table_in_from(parsed)  # 从 FROM 子句提取主表名

    if not table:
        return sql, False

    # 查 TENANT_MAP:表名 → (farm_id列名, farm_id值)
    farm_info = TENANT_MAP.get(table_simple)
    if farm_info:
        farm_col, farm_val = farm_info
        return _append_tenant_where(sql, farm_col, farm_val), True

    return sql, False

注入逻辑:找到 SQL 中 GROUP BY/ORDER BY/LIMIT 等关键字的位置,在它们之前插入 WHERE 条件:

python 复制代码
def _append_tenant_where(sql: str, farm_col: str, farm_val: int) -> str:
    # 找到最早的结束关键字位置(GROUP BY / ORDER BY / LIMIT 等)
    insert_pos = len(sql)
    for kw in [r'\bGROUP\s+BY\b', r'\bORDER\s+BY\b', r'\bLIMIT\b', ...]:
        m = re.search(kw, sql, re.IGNORECASE)
        if m and m.start() < insert_pos:
            insert_pos = m.start()

    if re.search(r'\bWHERE\b', sql, re.IGNORECASE):
        # 已有 WHERE → AND 追加
        return sql[:insert_pos] + f" AND {farm_col} = {farm_val}\n" + sql[insert_pos:]
    else:
        # 无 WHERE → 新建 WHERE 子句
        return sql[:insert_pos] + f" WHERE {farm_col} = {farm_val}\n" + sql[insert_pos:]

租户映射(TENANT_MAP)示例

表名 farm_id 列 注入值
cattle farm_id 86
milking_results farm_id 86
farm_feed.farm id 86
farms None 不注入(参考表)

🔧 生产改进建议 :当前版本使用正则定位 WHERE 插入点,适合大多数单表查询。对于多表 JOIN + 子查询的复杂 SQL,建议换用 sqlglot AST 解析 精确定位每张表的注入点,并对违规常量(如 WHERE farm_id=88)做硬拦截。


锁3:SQL 静态校验 --- 词边界关键字黑名单 🚫

锁3 是两条防线的组合:语法检查 + 禁止关键字匹配。

python 复制代码
# security_locks.py --- 禁止关键字(13 种)
FORBIDDEN_KEYWORDS = [
    "DROP", "INSERT", "UPDATE", "DELETE", "TRUNCATE",
    "ALTER", "CREATE", "GRANT", "REVOKE", "REPLACE",
    "LOAD", "INTO OUTFILE", "INTO DUMPFILE",
]

关键技巧是用 \b 词边界匹配,防止误杀:

python 复制代码
def _check_forbidden_keywords(sql_upper: str) -> Tuple[bool, str]:
    for kw in FORBIDDEN_KEYWORDS:
        # \b 词边界:UPDATE 能匹配但 UPDATE_TIME 不会误杀
        if re.search(r'\b' + re.escape(kw) + r'\b', sql_upper):
            return False, f"禁止的SQL操作: {kw}"
    return True, ""

语法检查用 sqlparse 做基本验证------不够深度,但足够挡掉「空 SQL」「语法混乱」两类常见问题:

python 复制代码
def _check_sql_syntax(sql: str) -> Tuple[bool, str]:
    parsed = sqlparse.parse(sql)
    if not parsed:
        return False, "SQL 为空或无法解析"
    first = parsed[0]
    if not first.tokens or len(first.tokens) == 0:
        return False, "SQL 语句无效"
    return True, ""

🔧 诚实交代sqlparse 是格式化库,不是 AST 解析器。它无法区分 SELECT * FROM cattle WHERE 1=1; DROP TABLE cattle 这种注入攻击。生产环境强烈建议升级到 sqlglot 做 AST 级验证------它能精确解析出 SQL 中的每条语句并逐条审计。


锁4:DDL 定时同步 --- SHOW CREATE TABLE 每 30 分钟刷新 🔄

表结构不是静态的。牧场业务会新增字段(比如挤奶设备升级后加了 milking_duration_ms),如果用硬编码的 DDL,Agent 永远看不到新字段。

解决方案:启动时全量拉取 + 后台定时刷新

python 复制代码
# ddl_sync.py --- 全量加载
def load_all_ddl() -> Dict[str, str]:
    all_ddl = {}
    for db_key in DB_CONFIGS:           # 4 个数据库
        ddl_map = _load_ddl_for_db(db_key)
        for tbl, ddl in ddl_map.items():
            all_ddl[tbl] = ddl
            cols = _parse_columns_from_ddl(ddl)
            new_whitelist[tbl] = cols
            # 构建租户映射
            if farm_col:
                new_tenant_map[tbl] = (farm_col, FARM_ID)

    # 原子更新全局状态
    TABLE_WHITELIST.clear(); TABLE_WHITELIST.update(new_whitelist)
    TABLE_SCHEMAS.clear(); TABLE_SCHEMAS.update(all_ddl)
    TENANT_MAP.clear(); TENANT_MAP.update(new_tenant_map)

后台协程每 30 分钟自动刷新:

python 复制代码
async def ddl_sync_loop():
    while True:
        await asyncio.sleep(DDL_SYNC_INTERVAL)  # 默认 1800s
        old_count = len(TABLE_WHITELIST)
        new_ddl = await loop.run_in_executor(None, load_all_ddl)

        if old_count != len(TABLE_WHITELIST):
            logger.warning("DDL_TABLE_COUNT_CHANGED old=%d new=%d", old_count, ...)

        # 检测 DDL 变化
        for tbl, old_ddl in TABLE_SCHEMAS.items():
            if tbl in new_ddl and new_ddl[tbl] != old_ddl:
                logger.warning("DDL_CHANGED table=%s", tbl)

farm 表的特殊处理farm 表用 id 列做租户隔离(而非 farm_id),_build_tenant_map() 对此做了特殊判断:

python 复制代码
def _build_tenant_map(db_name, table_name, columns, farm_id):
    if "farm_id" in columns:
        return ("farm_id", farm_id)
    # farm 表特殊处理:用 id 列
    if table_name == "farm" and "id" in columns:
        return ("id", farm_id)

服务启动时的降级策略:DDL 加载失败不阻塞服务启动 ,通过 /health 端点的 ddl_ready 字段暴露状态:

python 复制代码
# server.py --- 启动时 DDL 加载,失败降级不崩溃
async def on_startup(app):
    try:
        ddl = await loop.run_in_executor(None, load_all_ddl)
        if ddl:
            _ddl_ready = True
        else:
            logger.error("DDL_INIT_EMPTY: no tables loaded, service degraded")
    except Exception as e:
        logger.error("DDL_INIT_CRASH: %s", str(e)[:200])

锁5:降级链路 --- 五层超时 + 崩溃优雅兜底 🛡️

这是最容易被忽略的一把锁------安全不只是拦截恶意请求,还包括系统自己挂了的时候不丢数据、不雪崩

python 复制代码
# agent_engine.py --- generate_sql() 的 try/except 全局兜底
def generate_sql(question: str, farm_id: int) -> Dict[str, Any]:
    result = {"sql": "", "data": [], "summary": "", "ok": False, "error": ""}
    try:
        agent = _create_agent()
        response = agent.invoke({"messages": [...]})
        # ... SQL 提取 + 数据查询 + 摘要生成
        result["ok"] = True
    except Exception as e:
        if "timeout" in str(e).lower() or "timed out" in str(e).lower():
            result["error"] = f"SQL生成超时(>{AGENT_TIMEOUT}s)"
        else:
            result["error"] = f"SQL生成失败: {str(e)[:300]}"
    return result  # 永远不抛异常

完整的五层降级链

yaml 复制代码
Level 1: MySQL 执行超时 → max_execution_time=30s (SQL层)
Level 2: Agent 调用超时 → asyncio.wait_for(AGENT_TIMEOUT+10) (服务层)
Level 3: HTTP 请求超时 → aiohttp.ClientTimeout(total=60) (调用层)
Level 4: brain 端异常 → free_sql.py 返回 None (brain层)
Level 5: 兜底 → sql_line(SQL模板)(最终兜底)

每一层失败都向上传递 None,最终在 brain 的 sql_line(SQL 模板管线)接住------用户看到的是模板化的降级回答,而不是报错。

python 复制代码
# free_sql.py --- brain 端调用 sql-agent,所有异常返回 None→sql_line
async def handle_free_sql(user_input, wecom, rid, t0, is_stream, request):
    try:
        async with session.post(SQL_AGENT_URL, json=body, timeout=...) as resp:
            if resp.status != 200:
                return None  # → sql_line
            data = await resp.json()
            if not data.get("ok"):
                return None  # → sql_line

        polished = await _polish_free_sql(summary, wecom, rid, user_input)
        if polished is not None:
            return web.json_response({"code": 0, "data": polished})
        return None  # 润色失败 → sql_line

    except asyncio.TimeoutError:
        return None  # 超时 → sql_line
    except aiohttp.ClientConnectorError:
        return None  # 服务不可达 → sql_line
    except Exception:
        return None  # 兜底 → sql_line

🔧 生产改进建议 :当前降级链是「失败→sql_line」的二元兜底。建议升级为三层 fallback:LLM 自动修复 (捕获 SQL 语法错误后让 LLM 自我修正一次)→ 知识库检索 (从 Qdrant 中匹配相似问题的缓存结果)→ 人工工单(极端情况创建待处理工单,而非直接返回「暂无数据」)。


四、完整请求链路 🗺️

sql 复制代码
用户:「牧场A本周产奶量最高的10头牛」

brain classify(QM 快匹配 0.85 阈值 → 无模板命中)
  → free_sql.py
    → HTTP POST /v1/sql/generate  {"question": "...", "farm_id": 86}
      → server.py → asyncio.wait_for(60s+10s)
        → agent_engine.generate_sql()
          → Top-K 表选择(关键词匹配,取最相关的 10 张表)
          → LangChain create_agent(AGENT_PREFIX + DDL)
            → sql_db_list_tables → sql_db_schema(获取表结构)
            → sql_db_query(生成并执行 SQL)
              → SecureSQLDatabase.run()
                → 非 SELECT → 直接拒绝 ❌
                → apply_all_locks()
                  → [锁3] 静态校验 → 关键字黑名单 ❌/✅
                  → [锁1] 表白名单验证 ❌/✅
                  → [锁2] 租户 WHERE 注入 → AND farm_id=86
                → SET max_execution_time=30000; SELECT ...
          → 提取 SQL(tool_calls → markdown 代码块 → 兜底)
          → 重执行 SQL → 结构化数据 [{"cattle_number": "BF001", ...}]
          → LLM 生成摘要 ≤200字
        ← {sql, data: [...], summary: "...", ok: true}
    → _polish_free_sql(安全过滤 + 格式优化)
      → 牧场AI助手 Prompt:「禁止编造、数据原样引用」
  → 返回用户

失败链路:
Agent 超时/崩溃 → {ok: false} → free_sql 返回 None
  → brain main.py 降级 → sql_line(SQL模板)兜底

五、踩坑实录 🕳️

坑1:LangChain tool_calls 不暴露 query 参数

LangChain create_agent 调用后,SQL 实际执行的语句藏在 tool_calls 消息里,API 不直接返回。

解决:写了 _extract_sql_from_messages() 做三级抽取:

python 复制代码
# agent_engine.py --- 三级 SQL 提取策略
def _extract_sql_from_messages(messages: list) -> Optional[str]:
    # 1️⃣ 优先:从 tool_calls 的 query 参数拿(最可靠)
    for msg in reversed(messages):
        if hasattr(msg, "tool_calls") and msg.tool_calls:
            for tc in msg.tool_calls:
                if name in ("sql_db_query", "sql_db_query_checker"):
                    query = args.get("query", "")
                    if query.strip().upper().startswith("SELECT"):
                        return query.strip()

    # 2️⃣ 降级:从 AI 回复的 markdown ```sql 代码块提取
    for msg in reversed(messages):
        sql_blocks = re.findall(r'```sql\s*(.*?)```', content, re.DOTALL)
        if sql_blocks:
            for block in reversed(sql_blocks):
                cleaned = _clean_sql(block)  # 去注释、取第一条 SELECT
                if cleaned:
                    return cleaned

    # 3️⃣ 兜底:从文本中找以 SELECT 开头的行
    for line in reversed(content.split('\n')):
        if stripped.upper().startswith('SELECT '):
            return stripped
    return None

坑2:38 张表的 DDL 全塞给 LLM ------ Token 爆炸

38 张表的完整 DDL 一次性发给 LLM,光 Schema 就占了大几千 Token,不仅慢还容易超出上下文窗口。

解决:Top-K 表选择------先用关键词匹配算相关性分数,每次只送最相关的 10 张表:

python 复制代码
# agent_engine.py --- 关键词匹配打分
TABLE_DESCRIPTIONS = {
    "cattle":              "牛只档案 牛号 牛耳号 品种 胎次 日龄 泌乳天数 牛只信息",
    "milking_results":     "挤奶结果 挤奶 产奶 挤奶产量 奶量",
    "milk_yield_count":    "产奶统计 产奶 产量 产奶量统计 日产奶",
    "milk_yield_lower":    "降产预警 降产 产奶下降 产奶预警 低产 产量下降",
    # ...
}

def _score_tables(question: str, top_k: int = 10) -> list:
    scores = {}
    for tbl, desc in TABLE_DESCRIPTIONS.items():
        score = 0
        for keyword in desc.split():
            if keyword in question:
                score += 1    # 问题命中的关键词越多,分数越高
        scores[tbl] = score
    # 取 Top-K,分数为 0 的表也补到 K 张
    ranked = sorted(scores.items(), key=lambda x: (-x[1], x[0]))
    return [tbl for tbl, _ in ranked if ...][:top_k]

坑3:SecureSQLDatabase 重执行 SQL------LangChain 的 tool 输出不可靠

Agent 执行完 SQL 后,结果藏在 tool 返回消息里,LangChain 不暴露标准化接口来获取数据行。哪怕拿到了 Agent 的文本输出,markdown 表格的解析也非常脆弱------字段里一个竖线就能让 _parse_markdown_table() 崩掉。

解决:不依赖 Agent 输出,直接重执行

python 复制代码
# agent_engine.py --- 拿到 SQL 后自己跑一遍取结构化数据
def _execute_sql_for_data(sql: str) -> list:
    cleaned_sql = _clean_sql(sql)
    with engine.connect() as conn:
        result = conn.execute(sa_text(cleaned_sql))
        if result.returns_rows:
            columns = list(result.keys())
            rows = [dict(zip(columns, row)) for row in result.fetchall()]
            return clean_rows  # datetime/Decimal → JSON 安全类型
    return []

坑4:systemd 安全加固的坑

sql-agent.service 配了 ProtectSystem=strict,结果是绝大部分系统目录只读。但 ReadOnlyPaths 配了项目目录后仍报 Permission Denied,因为 SQL agent 需要写日志到 /tmp

ini 复制代码
# sql-agent.service --- 正确的安全加固
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/tmp                                    # ← 关键!
ReadOnlyPaths=/opt/sql-agent \
              /etc/sql-agent/shared.env

六、效果验证 ✅

测试套件 test_queries.py 覆盖三条核心查询:

测试用例 验证点 预期
牧场A产奶TOP10 表名合法性、租户条件、结构化数据 data 非空 + 表名在白名单 + 摘要完整
最近一周降产预警 同上 + 不跨牧场 farm_id=86 隔离生效
各牛舍饲喂统计 同上 + 跨库表 farm_feed 表正常访问
python 复制代码
# test_queries.py --- 验证逻辑
KNOWN_TABLES = {
    "cattle", "farms", "milking_results", "milk_yield_count",
    "feeding_operation_detail", "feeding_result_summary",
    # ... 全部 38 张合法表
}

# 检查1: 表名合法性
tables_in_sql = set(re.findall(r'FROM\s+`?(\w+)`?', sql, re.IGNORECASE))
fake_tables = tables_in_sql - KNOWN_TABLES
if fake_tables:
    checks["fails"].append(f"编造表名: {fake_tables}")

# 检查2: 租户条件
if "farm_id" in sql.lower() or "id = 86" in sql.lower():
    checks["passes"].append("含租户条件")

七、量化指标 📊

指标 目标 现状
表名拦截率 100% ✅ 100%(白名单外表名零放行)
写操作拦截率 100% ✅ 100%(13 种关键字全覆盖 + 非SELECT直接拒绝)
跨租户数据泄漏 0 次 ✅ 零泄漏(farm_id 强制注入 + TENANT_MAP 全覆盖)
服务可用性 99.9% ✅ DDL 加载失败不阻塞启动
单次查询超时 ≤60s ✅ Agent 60s + SQL 30s 双层兜底

八、避坑速查表 📝

场景 ❌ 踩坑做法 ✅ 正确方案
表名校验 信任 LLM 输出,不校验 白名单精确授权,不在清单的表直接拦截
租户隔离 Prompt 里写「请加 farm_id」 代码层强制注入 WHERE 条件,LLM 无感知
SQL 类型限制 依赖数据库账户权限 应用层关键字黑名单 + 非SELECT拒绝 + 只读账户三重保障
危险关键字检测 直接用 kw in sql 字符串匹配 \b 词边界正则,防止误杀列名
DDL 同步 硬编码表结构 SHOW CREATE TABLE 定时刷新,变化自动检测
服务降级 崩溃→重启→再崩溃 全局 try/except + 多层超时 + 优雅返回错误
SQL 提取 依赖 Agent 的 markdown 表格 tool_calls 直接取 + 重执行获取结构化数据
systemd 部署 不加限制或限制过紧 NoNewPrivileges + ProtectSystem=strict + 精确 ReadWritePaths

九、写在最后 💡

这套「五把锁」方案从 LangChain SQLAgent 的原生缺陷出发,每一把锁解决的都是在生产环境真实踩过的坑。核心设计思想就一条:

不要让 LLM 决定它能做什么,而是用代码层强制约束它只能做什么。

LLM 是不可靠的------它会编造表名、忽略 Prompt 指令、在边界情况出人意料的行为。安全不能靠「Prompt 里说好了」,得靠代码层的三重防线:静态校验 → 白名单授权 → SQL 改写 → 只读账户兜底

相关推荐
Kel1 小时前
GQA 与 KV 缓存(Grouped-Query Attention & KV Cache)
人工智能
DogDaoDao1 小时前
OpenBrowser 深度解析:让 AI 真正「用上」浏览器的自主代理框架
人工智能·程序员·大模型·github·web·ai工具·openbrowser
_Jimmy_1 小时前
Tool Calling 与 Function Calling 区别
人工智能·python·langchain
ITmaster07312 小时前
告别 IDE?Android CLI 来了,开发进入 AI Agent 时代
android·ide·人工智能
陈明勇2 小时前
一篇文章,多种表达:我用 Seed Evolving 生成知识卡片
人工智能
墨舟的AI笔记2 小时前
ECS 中的确定性随机与回放:让帧同步在 DOTS 上成立
人工智能
图特摩斯科技2 小时前
本体智能应用案例实践分享:汽车零部件库存优化与召回应急保障
人工智能·汽车·palantir·ontology·ontoflow·ontoos
专业工业电源打工人2 小时前
F0505S-2WR3 适配优选 钡特电源 DF2-05S05LS|2W 隔离 DC-DC 模块电源5V转5V硬件选型参数规格解析
大数据·网络·人工智能
a1117762 小时前
三色软糖坠落玻璃池 THreeJS kimi
前端·人工智能·threejs