AI Agent接入数据库:重试机制引发的数据灾难与安全防线
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 内部被拆成了四步查询 orders 表里需要归档的数据量确认数据存在把符合条件的订单 INSERT 到 archive_monthly 归档表DELETE 掉 orders 表里已经完成归档的旧数据调用数据同步工具把归档结果同步到统计库。前三步是典型的数据库增删改查第四步是我们内部基于 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 statusarchived... 返回 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 statusarchived第二次重试却变成了archived_at IS NULL。这个条件把所有没有归档标记的数据全部圈了进去其中包括大量正在使用中的业务实时订单。为什么会这样因为重试机制把“执行失败”当成了一种需要 Agent 去克服的障碍。LLM 的常规推理模式决定了它会想办法绕开上次失败点上次卡在 DELETE 太久那我就优化一下删除策略。但 Agent 没有能力判断“优化后的策略是否会带来更大破坏”它只知道应该完成任务。“给 AI Agent 加个失败重试”本来是为了应对模型请求失败、请稍后重试这类瞬时故障。但当重试的粒度从单次模型调用扩大到整个多步任务时隐患就完全不同了。我把重试机制放错了层级也让 Agent 的错误不再是孤立错误而是叠加放大的副本。2. 重试代码到底写在哪一层这是所有悲剧的根源2.1 错误示范把整个 Agent 任务当作普通接口来重试当时我写了一个很天真工具方法大概长这样def run_with_retry(task, retries3): 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.runAgent 都可能执行 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 的日志时我发现了最核心的证据第一次 DELETEDELETE FROM orders WHERE created_at 2024-07-01 AND status archived;第二次 DELETEDELETE 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, retries2): 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) ) ENGINEInnoDB;在 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 11对 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 11: 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 查询因为连接池获取超时失败可以重试因为重复查询没有副作用。这一层我加了连接池队列等待超时控制避免重试时无限排队。第三层是写操作。绝不自动重试。写操作一旦失败统一进入“需人工判断”的分支。要么是锁冲突要么是约束错误要么是影响行数超限这些都需要人来看而不是让机器再撞一次。如果一定要重试必须满足两个前提操作必须带上幂等 keySQL 语句必须是完全固定的字符串不允许 Agent 重新生成。比如 DELETE 失败后如果确认是瞬时锁冲突可以重放同一条 SQL。但如果 Agent 在重试时重新生成了删除 SQL哪怕只改了一个条件我都视为一次新操作必须重新走安全校验和审批。这个原则后来帮我挡了不少坑。再往后我给项目加了一个定期复盘清单每次 Agent 版本上线前都要过一遍写操作失败后代码是否会自动重试如果是改成不允许。同一任务重复执行两次数据结果是否一致不一致就说明存在非幂等操作。高危写操作是否有人工确认环节没有就补上。Agent 是否能看到自己的历史执行记录看不到的话加 trace_id 和审计表。有没有一条快速恢复路径备份策略和恢复演练至少要能保证 30 分钟内恢复核心表。这个清单看起来简单但每一条都有对应的真实事故案例。我不再迷信“失败重试”这个听起来很美好的机制。重试只能让系统在安全的前提下恢复执行它的价值是兜底不是万能药。最后分享一个我在实际项目中一直保留的习惯给 Agent 的数据库账号做最小权限拆分。查询任务用一个只读账号写任务用一个单独账号这台账号对应的库表严格限定在业务允许的范围里。权限越小Agent 犯错造成的爆炸半径就越小。哪怕是给自己的练手项目也值得从第一步就守住这条底线。这次事故之后我对“AI Agent 数据库操作”这类项目的态度从“追求能力强”变成了“先追求边界清楚”。能力可以慢慢加边界一旦被击穿代价往往是数据本身。后续如果再有人问我“给 AI Agent 加失败重试应该怎么做”我都会先反问一句你确定这个操作可以在失败后安全地再来一次吗如果回答不了这个问题就先把重试按钮拔掉。