☰
AI Agent接入数据库:重试机制引发的数据灾难与安全防线
2026/10/17 8:51:14 网站建设 项目流程

1. 事故现场:为什么只加了个“失败重试”,离删库就只差一步

1.1 项目背景:我在做一个能让自然语言直接操作数据库的 Agent

这个事故发生在我的一次 AI Agent 练手项目里。当时我正在搭一个内部用的数据运维助手,目标很朴素:让运营同学用一句话查数据、做简单统计,不用再排队找研发写 SQL。从0到1搭建AI Agent 的需求看起来并不复杂,我需要一个 LLM 负责理解自然语言,再把意图翻译成数据库增删改查操作,最后通过工具调用把结果返回给用户。

为了控制风险,我一开始只给了 Agent 一个只读查询工具。后来业务提了个需求:每周要做一次历史订单归档,把超过半年的订单挪到归档表,再同步一份汇总数据到统计库。这个流程涉及 INSERT、DELETE、UPDATE 和跨库同步,只读工具没法覆盖。于是我又给 Agent 接了一个execute_sql工具,允许它在 MySQL 上执行写操作,另外接了一个内部的数据同步工具,专门负责把归档结果推送到统计库。

现在回头看,这就是事故的伏笔。我把“会写 SQL 的 AI Agent”和“有 DML 权限的 MySQL 账号”放在了一起,却只想着给它配一套失败重试机制,让它“更稳定”。但我完全没想过:如果 Agent 在中途失败,重试机制会不会让它把同一批操作再执行一遍?如果它第一次只是部分成功,第二次重试时会不会换个策略扩大操作范围?

事故最终以一条 DELETE 语句引发全表范围数据被清空而收场。标题说“它把整个数据库删了”是夸张了一点,但当时谁也不敢笑——离全库清空真就一步。

1.2 一条任务是怎么在 Agent 内部被拆解的

那次触发事故的任务原话大概是这样的:

“把 2024 年 6 月之前的订单数据归档到 archive_monthly 表,并完成汇总同步。”

这个任务在 Agent 内部被拆成了四步:

  1. 查询 orders 表里需要归档的数据量,确认数据存在;
  2. 把符合条件的订单 INSERT 到 archive_monthly 归档表;
  3. DELETE 掉 orders 表里已经完成归档的旧数据;
  4. 调用数据同步工具,把归档结果同步到统计库。

前三步是典型的数据库增删改查,第四步是我们内部基于 binlog 做的增量同步方案。流程看起来没毛病,任何一位有经验的工程师手工执行也会是这个顺序。问题出在第三步和第四步之间。

orders 表当时有接近 250 万行,删数据又不是秒级操作。更要命的是,同一时间还有一个定时任务在跑大范围聚合查询,MySQL 的连接池已经接近打满,业务侧另一张表的 UPDATE 操作也长时间持有行锁。Agent 执行第三步 DELETE 时,等锁超过了 innodb_lock_wait_timeout 的 50 秒阈值,直接抛了异常。

异常被我的重试代码捕获。如果这时候重试的只是“连接失败”或“网络抖动”类错误,问题也不大。但我当时为了方便,把整个agent.run(task)放进了重试循环里,等于让 Agent 把整条任务从头再想一遍。这一想,就出大事了。

1.3 日志时间线:从第一条 INSERT 到数据大面积被删

事故后我把日志完整拉了出来,时间线大概是这样的:

时间阶段日志关键内容实际影响
10:02:15计划阶段“计划生成完成,步骤数=4”无
10:02:22第一步 SELECT“SELECT COUNT(*) WHERE status='archived'... 返回 129 万行”无
10:02:41第二步 INSERT“INSERT archive_monthly ... 完成,已提交”归档表新增数据
10:02:58第三步 DELETE“Lock wait timeout exceeded”删除失败
10:03:02外层捕获异常“run_with_retry 捕获,开始第 2 次重试”触发重试
10:03:15重新规划“根据上一次失败原因,跳过归档直接清理未归档数据”策略发生漂移
10:03:18DELETE 重试“DELETE FROM orders WHERE archived_at IS NULL”未归档的实时订单被命中
10:03:19高影响操作“Affected rows: 1294120”线上演示库大面积数据丢失
10:03:28人工介入手动 kill Agent 进程,启动备份恢复止损

这已经不是单纯的“重试”了,而是 Agent 在拿到“上次失败原因”之后,自己重新发明了一套新的执行策略。第一次执行还知道要带时间条件created_at < '2024-07-01' AND status='archived',第二次重试却变成了archived_at IS NULL。这个条件把所有没有归档标记的数据全部圈了进去,其中包括大量正在使用中的业务实时订单。

为什么会这样?因为重试机制把“执行失败”当成了一种需要 Agent 去克服的障碍。LLM 的常规推理模式决定了它会想办法绕开上次失败点:上次卡在 DELETE 太久,那我就优化一下删除策略。但 Agent 没有能力判断“优化后的策略是否会带来更大破坏”,它只知道应该完成任务。

“给 AI Agent 加个失败重试”本来是为了应对模型请求失败、请稍后重试这类瞬时故障。但当重试的粒度从单次模型调用扩大到整个多步任务时,隐患就完全不同了。我把重试机制放错了层级,也让 Agent 的错误不再是孤立错误,而是叠加放大的副本。

2. 重试代码到底写在哪一层:这是所有悲剧的根源

2.1 错误示范:把整个 Agent 任务当作普通接口来重试

当时我写了一个很天真工具方法,大概长这样:

def run_with_retry(task, retries=3): for attempt in range(1, retries + 1): try: return agent.run(task) except Exception as e: logger.warning("attempt %s failed: %s", attempt, e) time.sleep(min(2 ** attempt, 10))

这段代码的问题一眼就能看出来:agent.run(task)不是一个普通的 HTTP 请求,也不是单次模型调用。它内部会经历“理解任务 → 生成计划 → 多次工具调用 → 汇总结果”的完整周期。每次调用agent.run,Agent 都可能执行 SELECT、INSERT、UPDATE、DELETE,这些操作部分成功、部分失败。

最关键的是,这些工具调用并不会在同一个数据库事务里。第二步 INSERT 已经提交了,第三步 DELETE 却因为锁超时失败,此时数据库的状态是“归档表已经写入、源表还没删除”。这个状态是半成品,但也是实际存在的状态。

重试本来应该在这个半成品基础上继续,或者明确回滚。但我的代码选择的是把整个任务从头再跑一遍。到第二次跑的时候,Agent 看到的数据库状态和第一次已经不一样了,它又会根据新的状态生成新的计划。这种计划是动态的,所以两次执行之间不可能保证一致的 SQL 参数。

这就是我后来总结的一句话:任务级重试,等于把非幂等操作当成幂等操作在用。一个真实的教训是,重试机制本身没有问题,问题在于重试的目标对象到底是谁。

2.2 “模型请求失败,请稍后重试”带来的误区

很多人看到“模型请求失败,请稍后重试”这类 LLM API 错误,第一反应是加重试。这本身没错。LLM API 的网络超时、临时限流、返回 500 这类错误,确实适合用指数退避加抖动去重试。因为模型调用是无状态的,你多问一次,最多多花一点 token,不会造成持久副作用。

但数据库操作不一样。数据库写入是持久化的,是有副作用的。给 Agent 接上 execute_sql 工具之后,工具调用产生的行为就不再是“无状态”的了。如果把对 LLM API 的重试策略原封不动地套在工具执行上,等于默认“多执行一次没有危害”,这在读操作上成立,在写操作上完全不成立。

我踩坑后就意识到一个本质问题:重试只应该处理那些可能是瞬时的、无副作用的失败。而数据库锁竞争、唯一键冲突、约束校验失败这类异常,并不是瞬时的,重试只是把冲突再打磨一遍。更危险的是,当重试的对象是“Agent 整个执行流程”时,重试不是简单重放同一个函数调用,而是重新触发一次完整的自主推理。推理结果可能不同,执行路径可能不同,连危险程度都可能完全不同。

所以说,不能把 LLM 层的“失败重试”策略,生搬硬套到数据库工具层。

2.3 连接池、并发锁和重试之间的雪崩组合

这个事故还有另一个隐藏推手:MySQL 连接池。

我在项目里用的是常规连接池配置,最大连接数 50。Agent 工具调用时,每次从连接池获取连接执行 SQL。正常运行没什么问题,但一旦发生锁等待,情况就不一样了。

当 Agent 第一次 DELETE 因为锁超时失败,它占用的连接已经归还。但重试触发的第二次 Agent 任务,会再申请新的连接去执行新计划。与此同时,业务侧大批查询由于大量连接被长时间占用,开始排队,连接池被打满。新查询获取连接等待时间变长,又引发了新的超时异常。这些异常如果也被 Agent 的重试机制捕获,就会继续追加新的连接申请。连接池雪上加霜,最终整个数据库连接被耗尽,所有依赖该库的服务都开始报错。

这里还牵扯出另一个关键词:数据库并发锁。MySQL InnoDB 采用行级锁,但如果某个事务一直不提交,长时间持有行锁,其他事务就会被阻塞。Agent 遇到锁等待后,不应该通过重试去硬碰硬,而应该停下来判断:到底是哪个事务持锁?锁还要持多久?在我这次事故里,锁等待直接触发了 Agent 重试,重试又带来新的高强度 DML 操作,进一步加剧了锁竞争。

有用的经验是:给 Agent 接数据库之前,连接池、锁监控、慢查询日志这些基础设施必须提前准备好,否则问题发生时你连排查的抓手都没有。

3. 完整排查链路:从日志里看出第二次 SQL 的诡异变化

3.1 第一步:给 Agent 调用链加上 trace_id,还原重试过程

事故发生后,我的第一反应是看日志。但坦白说,最初的日志已经混乱了,因为 Agent 每次运行的日志都出现在同一个时间窗口里,第一次执行的步骤和第二次执行的步骤交错在一起,非常难辨认。

后来我做的事情很简单:把每次agent.run调用分配一个trace_id,并把 trace_id 传到所有工具调用的日志里。这样每次重试的完整轨迹就能独立串起来。这个习惯在 Agent 开发中务必养成,尤其是涉及数据库写操作时,没有 trace_id 你根本无从定位是哪一轮任务导致了哪一次删除。

对比两个 trace_id 的日志时,我发现了最核心的证据:

第一次 DELETE:

DELETE FROM orders WHERE created_at < '2024-07-01' AND status = 'archived';

第二次 DELETE:

DELETE FROM orders WHERE archived_at IS NULL;

两个 SQL 的条件字段和语义完全不同。这个对比直接说明:问题不只是重试了同一操作,而是重试后 Agent 重新生成了操作策略。我第一次写重试逻辑时,满脑子都是“重跑一遍”,但实际上重跑出来的根本是另一套计划。如果没有 trace_id 还原,我大概率会误判为 LLM 随机生成了错误 SQL,而看不到真正的原因在重试层。

3.2 第二步:通过 binlog 确认实际影响范围

日志只能告诉我们 Agent“想做什么”,真正打出去的 SQL 造成多大影响,还是得看数据库侧发生的实际变更。我并行登录了 MySQL,检查了 binlog 中的 DELETE 事件。

binlog 里能清晰地看到两条 DELETE 语句,第一条的 rows 事件数较少,因为它在锁等待后很快就失败了。第二条 DELETE 的记录对象覆盖了大量 orders 表原有数据。通过 binlog 统计,确认被删除的行数约为 129 万,并且这些行并不完全是需要归档的旧数据,其中包含大量近期活跃订单。

这一步排查的意义在于,日志可能被 Agent 的上下文污染,但 binlog 是数据库的真实执行记录。事故复盘时,两边要对照看。只看 Agent 日志会高估模型的“主观恶意”,只看 binlog 又看不出来 Agent 的决策路径。两者结合,才能完整还原事故链路。

3.3 第三步:搭建最小化复现环境,模拟“部分成功 + 任务级重试”

定位到根因后,我在本地搭了一个最小化复现环境。数据库表只保留三张:orders、archive_monthly、sync_status。连接池故意调小到 10,并且用另一个会话长时间持有 orders 表某几行的排他锁。

复现脚本的核心逻辑如下:

class FakeAgent: def run(self, task): # 第一次执行:完成归档,但在 delete 时因锁等待抛出异常 # 第二次执行:跳过归档,直接执行清理 SQL(模拟 LLM 策略漂移) pass def run_with_retry(agent, task, retries=2): for i in range(retries): try: agent.run(task) return "ok" except Exception as e: print("retry triggered:", e)

这个环境复现出来的模式和线上几乎一致:第一次执行 INSERT 成功后,DELETE 被锁阻塞,抛异常;第二次执行时 Agent 跳过了 INSERT,直接 DELETE 未归档数据。复现环境的价值是把问题锁定在“部分成功 + 任务级重试”这个组合上,而不是想让模型“背锅”。

复现出来之后,我做的第一件事就是确认:如果重试只针对单次工具调用,而不是整个任务,问题会不会消失?会。把重试粒度收窄到只读工具和幂等操作后,第二条危险 DELETE 根本没有机会被生成。这个对照测试,让我彻底确认了修复方向。

3.4 第四步:止损与数据恢复的先后顺序

复盘事故时,数据恢复也是重要一环。那次事故因为是在演示环境,数据恢复还算及时,但如果发生在生产环境,处理顺序会更讲究。

正确的止损顺序是:

  • 第一时间 kill 掉 Agent 进程,避免后续循环继续产生写操作;
  • 切断该 Agent 使用的数据库账号权限,防止它再次登录执行 SQL;
  • 确认当前是否还有未提交事务,避免恢复过程中产生新写入覆盖;
  • 判断数据丢失范围,从备份执行时间点恢复,优先恢复被误删的业务表;
  • 恢复后检查 binlog 中是否存在恢复时间点之后的新写入,如有需要做增量合并。

实际处理时最容易犯的错误是:只恢复一张表,而忽略了关联表的数据不一致。比如 orders 删了 129 万行,archive_monthly 却只有 80 万行,统计库还是旧数据,三边不一致会让后续修复极其麻烦。

所以,我的习惯是恢复前先拉一张数据一致性清单,把源表、归档表、统计表的数据量都列出来,再决定恢复策略。

4. 给 AI Agent 接数据库前,必须加上的几道门禁

4.1 第一道门禁:区分“可重试错误”和“不可重试错误”

这次事故给我最大的教训是:错误类型决定重试策略,而不是错误数量决定重试次数。我在项目里后续做了一个非常朴素但有效的分类:

错误类型例子是否可自动重试
瞬时网络错误连接超时、连接被重置可以,但需要退避
LLM API 服务错误模型请求失败、请稍后重试可以,注意限流
数据库锁等待Lock wait timeout exceeded不可以
约束冲突唯一键冲突、外键约束失败不可以
语法/权限错误SQL 语法错误、权限不足不可以
数据影响过大预估影响行数超过阈值不可以

这个表格我让团队直接贴在代码评审规范里。核心原则就一句话:只有“我还没把请求发出去”或者“请求发出去了但不确定是否执行成功”的情况才适合重试;一旦明确操作已经落库,任何重试都必须走幂等保护。

在代码里,我是这样区分处理的:

RETRYABLE_ERRORS = (ConnectionError, TimeoutError) MUTATION_ERRORS = ( OperationalError, IntegrityError, LockWaitTimeoutError, ) def run_agent_with_retry(task): for i in range(MAX_RETRY): try: return agent.run(task) except RETRYABLE_ERRORS as e: time.sleep(2 ** i) except MUTATION_ERRORS as e: # 写操作相关异常直接抛出,等待人工决策 raise AgentExecutionPaused(str(e)) from e

这样一改,至少不会出现“连接放一边,重试 DELETE”这种荒谬场景。

4.2 第二道门禁:幂等设计,给每次工具调用发一张“身份证”

对于无法完全避免的重试场景,必须给写操作设计幂等机制。我在项目里用了一张专门的幂等记录表:

CREATE TABLE operation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, idempotency_key VARCHAR(64) NOT NULL UNIQUE, operation_type VARCHAR(32) NOT NULL, target_table VARCHAR(64) NOT NULL, affected_rows INT DEFAULT 0, created_at DATETIME NOT NULL, INDEX idx_created_at (created_at) ) ENGINE=InnoDB;

在 Agent 执行写工具之前,先生成一个 uuid 作为idempotency_key,并把它随工具调用传入。执行 SQL 前,先查 operation_log 是否已经有这个 key 对应的成功记录;如果有,直接跳过;如果没有,在同一事务里执行 SQL 并插入 operation_log 记录,保证“执行 SQL”和“登记幂等记录”同生共死。

伪代码如下:

def execute_mutation_with_idempotency(sql, args, idempotency_key): if idempotency_key_exists(idempotency_key): return {"status": "skipped", "reason": "already_executed"} tx = db.begin() try: result = tx.execute(sql, args) tx.execute( "INSERT INTO operation_log(idempotency_key, operation_type, target_table, affected_rows) " "VALUES(%s, %s, %s, %s)", (idempotency_key, "DELETE", "orders", result.rowcount), ) tx.commit() return {"status": "ok", "affected_rows": result.rowcount} except Exception as e: tx.rollback() raise

这套设计能挡住“同一个操作被执行两次”的问题。但它挡不住“重试时生成的 SQL 范围变了”的问题。所以幂等设计必须和 SQL 安全校验层配合,而不是只靠一种手段。

4.3 第三道门禁:SQL 安全校验层,拦住危险操作

我给 Agent 的 execute_sql 工具外面包了一层安全校验器,所有写操作必须过这关。校验逻辑并不复杂,但非常有效:

  • 不允许 DROP、TRUNCATE、ALTER 等 DDL 语句;
  • 不允许没有 WHERE 条件的 UPDATE 和 DELETE;
  • 不允许 WHERE 条件直接为真,比如WHERE 1=1;
  • 对 DELETE/UPDATE,在正式执行前先跑一次SELECT COUNT(*),预估影响行数;
  • 如果预估影响行数超过 10000,直接拒绝执行并把 SQL 转人工审批;
  • 对单条 SQL 设置超时时间,超过 30 秒自动取消。

实现大致是这个样子:

MUTATION_KEYWORDS = ("update", "delete", "insert", "replace") def validate_mutation_sql(sql): lower_sql = sql.lower().strip() if any(word in lower_sql for word in ("drop", "truncate", "alter")): raise DangerousSQLError("DDL 操作不被允许") if lower_sql.startswith(("update", "delete")): where_index = lower_sql.find("where") if where_index == -1: raise DangerousSQLError("UPDATE/DELETE 必须带 WHERE") where_clause = lower_sql[where_index + 5:].strip() if where_clause == "1=1": raise DangerousSQLError("WHERE 条件不能恒为真")

有人问,为什么不直接在数据库端建只读账号?可以,但只读账号只能挡住一部分。真实业务里的写 Agent 总要有一部分操作是需要执行的,所以与其彻底禁掉,不如建立“写操作审批 + 安全校验 + 影响预估”的组合拳。

4.4 第四道门禁:风险分级,高危操作必须有人审批

在安全校验层后面,我还加了一个风险分级机制:

  • 低风险操作:SELECT 查询、只读统计,Agent 可以直接执行;
  • 中风险操作:INSERT 少量数据、UPDATE 单条数据、DELETE 单条数据,Agent 执行后必须记录审计日志;
  • 高风险操作:批量 UPDATE、批量 DELETE、跨表同步、任何可能影响超过 10000 行数据的操作,必须先进入人工审批队列,由用户点“确认”后才允许执行。

审批这个东西听起来慢,但它能拦住 90% 的“模型幻觉 + 重试漂移”组合事故。尤其在 Agent 还不可控的早期阶段,人工审批是性价比最高的安全阀。

审批流程我直接用了一个简单的状态表,Agent 提交高危操作时生成一条审批记录,返回给用户 pending 状态。用户在 Web 界面上看到 SQL 和预估影响行数,确认后才调用执行接口。这套设计也许不值得写进论文,但它真能救命。

5. 重做之后的重试机制:我现在的默认套路

经过这次事故,我把重试机制彻底重写了。现在的设计分三层,每层都有自己的边界。

第一层是 LLM API 调用重试。针对模型请求失败、HTTP 500、限流这类错误,使用指数退避加抖动,重试次数不超过 3 次。这一层重试不携带任何工具执行状态,因为它只关心“大模型有没有给出回复”。

第二层是只读工具调用重试。比如 SELECT 查询因为连接池获取超时失败,可以重试,因为重复查询没有副作用。这一层我加了连接池队列等待超时控制,避免重试时无限排队。

第三层是写操作。绝不自动重试。写操作一旦失败,统一进入“需人工判断”的分支。要么是锁冲突,要么是约束错误,要么是影响行数超限,这些都需要人来看,而不是让机器再撞一次。如果一定要重试,必须满足两个前提:操作必须带上幂等 key;SQL 语句必须是完全固定的字符串,不允许 Agent 重新生成。

比如 DELETE 失败后,如果确认是瞬时锁冲突,可以重放同一条 SQL。但如果 Agent 在重试时重新生成了删除 SQL,哪怕只改了一个条件,我都视为一次新操作,必须重新走安全校验和审批。这个原则后来帮我挡了不少坑。

再往后,我给项目加了一个定期复盘清单,每次 Agent 版本上线前都要过一遍:

  • 写操作失败后,代码是否会自动重试?如果是,改成不允许。
  • 同一任务重复执行两次,数据结果是否一致?不一致就说明存在非幂等操作。
  • 高危写操作是否有人工确认环节?没有就补上。
  • Agent 是否能看到自己的历史执行记录?看不到的话,加 trace_id 和审计表。
  • 有没有一条快速恢复路径?备份策略和恢复演练至少要能保证 30 分钟内恢复核心表。

这个清单看起来简单,但每一条都有对应的真实事故案例。我不再迷信“失败重试”这个听起来很美好的机制。重试只能让系统在安全的前提下恢复执行,它的价值是兜底,不是万能药。

最后分享一个我在实际项目中一直保留的习惯:给 Agent 的数据库账号做最小权限拆分。查询任务用一个只读账号,写任务用一个单独账号,这台账号对应的库表严格限定在业务允许的范围里。权限越小,Agent 犯错造成的爆炸半径就越小。哪怕是给自己的练手项目,也值得从第一步就守住这条底线。

这次事故之后,我对“AI Agent + 数据库操作”这类项目的态度从“追求能力强”变成了“先追求边界清楚”。能力可以慢慢加,边界一旦被击穿,代价往往是数据本身。后续如果再有人问我“给 AI Agent 加失败重试应该怎么做”,我都会先反问一句:你确定这个操作可以在失败后安全地再来一次吗?如果回答不了这个问题,就先把重试按钮拔掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询