1. 问题场景与核心痛点拆解
1.1 一个真实到让人头疼的线上事故
先说一个我亲身经历的场景。某天凌晨两点,监控告警群突然炸了——工单系统里同一笔业务在十分钟内创建了十七条重复工单。值班同事一边骂娘一边手动关单,业务方那边电话已经打过来了,问是不是系统崩了。
排查下来原因并不复杂:Agent 调用工单创建接口时,网络抖动导致请求超时,Agent 的重试机制自动触发,又发了一次创建请求。但问题在于,第一次请求其实已经到达服务端并且成功创建了工单,只是响应回来的路上丢了。于是 Agent 认为失败,重试,服务端又老老实实创建了一条新的。如此反复,十七条工单就这么来的。
这个问题的本质,是分布式系统里经典的"超时≠失败"困境。超时只是说"我没在预期时间内拿到结果",它可能是请求没发出去、可能是服务端处理慢、也可能是响应丢了。而 Agent 作为调用方,在超时后如果无脑重试,就会把"可能已经成功"的操作变成"确定重复执行"。
工单创建这类操作有个共同特征:它是非幂等的写操作。你调一次创建一条,调两次创建两条,服务端不会帮你自动去重。这跟查询接口完全不一样,查询你调一百次结果都一样,但创建不行。所以标题里说的"避免重复创建工单",核心要解决的就是在超时这种模糊状态下,如何保证最终只创建一条工单。
1.2 为什么这个问题在 Agent 场景下格外突出
传统后端服务之间的调用,超时重试这套逻辑大家已经玩得很熟了,有成熟的框架和中间件兜底。但 Agent 场景有几个特殊性,让这个问题变得更棘手。
第一,Agent 的决策链路更长。一个 Agent 完成任务可能要经过"理解意图→规划步骤→调用工具→观察结果→决定下一步"多个环节。工具调用超时后,Agent 不一定只是简单重试,它可能重新规划整个任务,换一个工具、换一组参数再来一遍。这就导致重复创建的路径不止一条,排查起来更麻烦。
第二,Agent 的工具调用往往是自然语言驱动的。它不像写死的代码那样有明确的 if-else,而是模型根据上下文"决定"要不要重试。同一个超时事件,模型这次决定重试,下次可能决定放弃,行为不稳定。这种不确定性让传统的重试控制手段部分失效。
第三,Agent 经常是异步、长时运行的任务。一个任务可能跑几分钟甚至几十分钟,中间工具调用超时后,Agent 可能先去做别的事,过一会儿再回来重试。这种延迟重试让"去重窗口"变得很难界定。
我见过不少团队在 Agent 上线初期都踩过这个坑,因为大家习惯性地把 Agent 当成普通服务来对待,忽略了它决策链路的复杂性和不确定性。下面我就把这个问题拆开,从设计思路到落地实现,一步步讲清楚怎么解决。
2. 整体设计思路与方案选型
2.1 核心原则:把"去重"的责任放在正确的位置
解决重复创建,最忌讳的就是"哪里出问题就在哪里打补丁"。我见过有团队直接在 Agent 的重试逻辑里加个判断"如果上次调用超过X秒就不重试了",这种方案治标不治本,换个场景就失效。
正确的思路是分层治理。我一般把整个链路分成三层来看:
- Agent 调用层:负责决定要不要重试、怎么重试
- 传输层:负责请求的可靠传递
- 服务端层:负责最终的业务去重
这三层里,服务端层的幂等保证是底线,无论前面怎么乱,服务端必须能识别并拒绝重复请求。Agent 调用层是优化,尽量减少不必要的重试。传输层是辅助,能提高成功率但不能依赖它。
为什么这么分?因为 Agent 的行为是不确定的,你没法保证它永远不重复调用。所以最终的去重必须落在服务端,这是唯一可靠的地方。但光靠服务端也不够,因为重复请求会浪费资源、增加服务端压力,所以 Agent 层也要尽量收敛。
2.2 方案选型:幂等键 + 状态机 + 重试策略
具体到技术方案,我推荐组合拳:幂等键(Idempotency Key)+ 服务端状态机 + Agent 侧智能重试。
幂等键是核心。Agent 在发起创建请求前,先生成一个全局唯一的键(比如 UUID),这个键代表"这一次创建意图"。无论这个请求被发送多少次,服务端只要看到相同的幂等键,就只处理一次,后续的重复请求直接返回第一次的结果。
这里有个关键点:幂等键必须在 Agent 发起第一次请求之前就生成好,并且在重试时保持不变。很多团队踩的坑就是每次重试都重新生成键,那幂等就完全失效了。幂等键的生成时机和生命周期管理,是这套方案能不能work的关键。
服务端状态机用来处理"请求已到达但还在处理中"的情况。当 Agent 超时重试时,服务端可能第一次请求还在处理,这时候不能简单返回"重复",而要返回"处理中",让 Agent 知道该等一等而不是再发一次。
Agent 侧智能重试则是从源头减少重复。核心思路是:超时后不要立即重试,而是先查询状态。如果服务端提供了"根据幂等键查询创建结果"的接口,Agent 超时后应该先查一下,确认没成功再重试。
2.3 为什么不用分布式锁或唯一索引
有读者可能会问,直接用数据库唯一索引或者分布式锁不行吗?行,但各有局限。
唯一索引的问题在于,它只能保证"最终不重复",但重复请求还是会打到数据库,触发唯一键冲突异常。在高并发场景下,大量冲突异常会影响数据库性能,而且异常处理逻辑会让代码变复杂。更重要的是,唯一索引没法处理"请求还在处理中"的中间状态。
分布式锁的问题是粒度和性能。如果锁的粒度太粗,会影响并发;太细又容易漏。而且锁本身也有超时问题,锁超时后释放,重复请求照样进来。锁更适合保护临界区,不太适合做跨请求的幂等保证。
所以我的建议是:幂等键做业务层去重,唯一索引做兜底。两层配合,既保证正确性又兼顾性能。
3. 核心细节解析与实操要点
3.1 幂等键的设计:不只是生成一个UUID那么简单
幂等键看着简单,但设计不好会埋一堆坑。我总结几个关键点。
第一,键的生成必须由调用方(Agent)负责,不能由服务端生成。因为服务端生成的话,每次请求都会拿到新键,就没法去重了。Agent 要在"决定创建工单"这个意图产生的瞬间就生成键,并把它和这次创建意图绑定。
第二,键的粒度要对应业务语义。一个幂等键应该代表"一次业务创建意图"。比如用户提交了一个报修申请,Agent 要为此创建一个工单,那这个幂等键就对应这次报修申请。如果 Agent 因为超时重试,用的还是同一个键;但如果用户又提交了一个新的报修,那就要用新键。这个边界要划清楚,否则要么去重过度(把不同请求当成同一个),要么去重不足。
第三,键要足够唯一且可追溯。我一般用"业务前缀 + 时间戳 + 随机数"的组合,比如ticket-create-20240520143000-a3f8b2c1。加业务前缀是为了排查时一眼能看出用途,时间戳方便定位,随机数保证唯一性。纯 UUID 也行,但可读性差一些。
第四,键要能传递到服务端并被持久化。Agent 通过请求头(比如X-Idempotency-Key)或者请求体字段把键传给服务端,服务端要把它存下来,并且和创建出来的工单关联。这样后续查询、对账都能用上。
注意:幂等键的有效期要设合理。太短,延迟重试就失效了;太长,存储成本高。一般建议至少覆盖 Agent 任务的最长执行时间,比如 24 小时。超过有效期后,相同的键可以被视为新请求。
3.2 服务端幂等处理的完整流程
服务端收到创建请求后,处理逻辑要分几种情况,我用一个表格来梳理清楚。
| 场景 | 幂等键状态 | 服务端处理 | 返回给 Agent |
|---|---|---|---|
| 首次请求 | 不存在 | 创建工单,记录键与工单ID的映射,状态置为"处理中" | 处理中或成功 |
| 重复请求,首次仍在处理 | 存在,状态"处理中" | 不创建,直接返回当前状态 | 处理中 |
| 重复请求,首次已成功 | 存在,状态"成功" | 不创建,返回已创建的工单ID | 成功 + 工单ID |
| 重复请求,首次已失败 | 存在,状态"失败" | 可选择允许重试或返回失败 | 失败或重新处理 |
这个流程的关键在于状态要持久化,并且状态转换要原子。我一般用一张独立的幂等记录表来存,字段包括:幂等键、业务类型、关联业务ID、状态、创建时间、更新时间、过期时间。状态转换用数据库的行锁或者乐观锁保证原子性。
具体实现上,收到请求后先尝试插入幂等记录(利用唯一索引),插入成功说明是首次请求,继续走创建逻辑;插入失败说明键已存在,读取记录判断状态。这里有个细节:插入幂等记录和创建工单最好在同一个事务里,或者至少保证幂等记录先落库,避免工单创建了但幂等记录没写进去的情况。
如果创建工单是个耗时操作(比如要调用外部系统),那"处理中"状态就很重要。Agent 超时重试时看到"处理中",就知道不该再发新请求,而是等待或者轮询查询结果。
3.3 Agent 侧的重试策略:从"无脑重试"到"先查后试"
Agent 侧是减少重复的第一道防线。核心思路是:超时后不要立即重试,先确认状态。
我推荐的重试流程是这样的:
- Agent 调用创建接口,带上幂等键,设置合理超时(比如 5 秒)
- 如果超时,不立即重试,而是调用"根据幂等键查询结果"的接口
- 查询接口返回"成功",则直接使用返回的工单ID,任务继续
- 查询接口返回"处理中",则等待一段时间(比如 2 秒)后再查,最多查 N 次
- 查询接口返回"不存在",说明请求确实没到达服务端,可以安全重试创建
- 查询接口也超时或失败,则进入退避重试,且重试时仍用同一个幂等键
这个流程把"重试"变成了"查询+条件重试",大大降低了重复创建的概率。因为大部分超时其实是"请求成功了但响应丢了",这种情况下查询会返回成功,Agent 就不会再发创建请求。
退避策略也很重要。如果确实需要重试,不要固定间隔,而要用指数退避,比如 1秒、2秒、4秒、8秒,加上随机抖动。这样能避免多个 Agent 同时重试造成服务端压力峰值。重试次数要有上限,比如 3 到 5 次,超过就上报人工处理,不要无限重试。
实操心得:查询接口本身也要幂等且轻量。它只读不写,根据幂等键查状态,不应该有副作用。查询接口的超时要设得比创建接口短,避免 Agent 卡在查询上。
3.4 超时时间的设置:一个容易被忽视的关键参数
超时时间设多少,直接影响重复创建的概率。设太短,正常请求也被判超时,重试增多;设太长,Agent 卡住影响任务进度。
我的经验是分层设置:Agent 调用创建接口的超时,要大于服务端 P99 处理时间,但小于 Agent 任务的整体超时。比如服务端创建工单 P99 是 800ms,那 Agent 侧超时可以设 3 到 5 秒,留足余量应对网络抖动。
同时,服务端内部也要有超时控制。如果创建工单涉及多个下游调用,每个下游都要设超时,避免一个慢下游拖垮整个请求。服务端整体处理时间要可控,否则 Agent 侧超时了但服务端还在跑,就会出现"处理中"状态长时间不结束。
这里有个计算思路:假设服务端处理分三步,每步 P99 分别是 200ms、300ms、400ms,那整体 P99 大约 900ms,加上网络往返 200ms,Agent 侧超时设 3 秒是比较稳妥的。这个数字不是拍脑袋,要根据实际监控数据来定。
4. 实操过程与核心环节实现
4.1 服务端幂等接口的代码实现
下面用 Python 写一个简化的服务端幂等处理示例,展示核心逻辑。实际生产环境还要考虑事务、锁、异常处理等,这里聚焦幂等本身。
import uuid import time from datetime import datetime, timedelta class IdempotencyService: def __init__(self, db): self.db = db def create_ticket(self, idempotency_key, ticket_data): # 第一步:尝试插入幂等记录,利用唯一索引保证原子性 record = { "key": idempotency_key, "biz_type": "ticket_create", "status": "processing", "created_at": datetime.now(), "expires_at": datetime.now() + timedelta(hours=24) } inserted = self.db.insert_if_not_exists("idempotency_records", record) if not inserted: # 键已存在,读取现有记录 existing = self.db.query_one( "SELECT * FROM idempotency_records WHERE key = %s", (idempotency_key,) ) if existing["status"] == "success": return {"status": "success", "ticket_id": existing["biz_id"]} elif existing["status"] == "processing": return {"status": "processing", "message": "请求处理中,请稍后查询"} else: # 失败状态,根据业务决定是否允许重试 return {"status": "failed", "message": existing.get("error_msg")} # 首次请求,执行创建逻辑 try: ticket_id = self._do_create_ticket(ticket_data) self.db.update( "UPDATE idempotency_records SET status='success', biz_id=%s WHERE key=%s", (ticket_id, idempotency_key) ) return {"status": "success", "ticket_id": ticket_id} except Exception as e: self.db.update( "UPDATE idempotency_records SET status='failed', error_msg=%s WHERE key=%s", (str(e), idempotency_key) ) raise def query_by_key(self, idempotency_key): record = self.db.query_one( "SELECT * FROM idempotency_records WHERE key = %s", (idempotency_key,) ) if not record: return {"status": "not_found"} return { "status": record["status"], "ticket_id": record.get("biz_id") }这段代码的核心是insert_if_not_exists,它依赖数据库的唯一索引来实现原子插入。如果插入成功,说明是首次请求;如果失败,说明键已存在,走查询分支。这样即使两个请求同时到达,也只有一个能插入成功,另一个会读到已存在的记录。
_do_create_ticket是实际的创建逻辑,它和幂等记录的更新最好在同一个事务里,保证要么都成功要么都失败。如果创建成功但更新幂等记录失败,下次重试会看到"处理中"状态,这时候需要有个补偿机制,比如定时任务扫描超时的"处理中"记录,去实际查询工单是否创建成功。
4.2 Agent 侧的重试封装
Agent 侧我一般封装一个统一的工具调用器,把幂等和重试逻辑收进去,业务代码不用关心这些细节。
import time import uuid import requests class IdempotentToolCaller: def __init__(self, base_url, max_retries=3, base_timeout=5): self.base_url = base_url self.max_retries = max_retries self.base_timeout = base_timeout def create_ticket(self, ticket_data): # 生成幂等键,整个重试过程复用 idempotency_key = f"ticket-create-{int(time.time())}-{uuid.uuid4().hex[:8]}" headers = {"X-Idempotency-Key": idempotency_key} for attempt in range(self.max_retries): try: resp = requests.post( f"{self.base_url}/tickets", json=ticket_data, headers=headers, timeout=self.base_timeout ) if resp.status_code == 200: result = resp.json() if result["status"] == "success": return result["ticket_id"] elif result["status"] == "processing": # 服务端还在处理,等待后查询 time.sleep(2) continue except requests.Timeout: # 超时后先查询,确认是否已创建 ticket_id = self._query_ticket(idempotency_key) if ticket_id: return ticket_id # 确认未创建,进入退避重试 backoff = (2 ** attempt) + (time.time() % 1) time.sleep(backoff) except requests.RequestException: backoff = (2 ** attempt) + (time.time() % 1) time.sleep(backoff) raise Exception(f"创建工单失败,幂等键: {idempotency_key}") def _query_ticket(self, idempotency_key): try: resp = requests.get( f"{self.base_url}/tickets/query", params={"idempotency_key": idempotency_key}, timeout=2 ) if resp.status_code == 200: result = resp.json() if result["status"] == "success": return result["ticket_id"] except requests.RequestException: pass return None这个封装有几个要点。幂等键在循环外生成,整个重试过程复用同一个键,这是去重的前提。超时后先查询,只有确认未创建才重试。退避带随机抖动,避免多个 Agent 同步重试。查询接口超时更短,避免卡在查询上。
实际用的时候,这个调用器可以注册成 Agent 的一个工具,Agent 只管调用create_ticket,内部的幂等和重试逻辑对 Agent 透明。这样即使 Agent 的决策逻辑变化,去重保证依然有效。
4.3 端到端的完整时序
把服务端和 Agent 侧串起来,一次完整的创建流程是这样的:
- Agent 决定创建工单,生成幂等键 K
- Agent 调用创建接口,带 K,超时 5 秒
- 服务端收到请求,插入幂等记录(K,processing),开始创建
- 假设服务端处理耗时 6 秒,Agent 在 5 秒时超时
- Agent 不重试创建,而是调用查询接口,带 K
- 服务端查询到 K 状态为 processing,返回"处理中"
- Agent 等待 2 秒后再次查询
- 此时服务端已创建完成,状态更新为 success,返回工单 ID
- Agent 拿到工单 ID,任务继续
这个流程里,虽然 Agent 超时了,但通过查询拿到了最终结果,没有产生重复工单。即使查询也失败,Agent 重试创建时用的还是 K,服务端看到 K 已存在且成功,会直接返回已有工单 ID,依然不会重复。
提示:查询接口的返回要包含足够信息,至少要有状态和工单 ID。如果状态是"处理中",最好能返回预计完成时间或建议的查询间隔,方便 Agent 决策。
5. 常见问题与排查技巧实录
5.1 幂等键失效的几种典型情况
实际运维中,幂等键失效是重复创建的主要原因。我整理了几种常见情况和排查方法。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 同一业务创建多条工单 | 每次重试生成新键 | 检查 Agent 代码,看键生成位置是否在重试循环内 | 把键生成移到循环外 |
| 延迟重试产生重复 | 键过期时间太短 | 查看幂等记录过期时间与重试间隔 | 延长过期时间至覆盖任务周期 |
| 跨进程重复 | 键未持久化,进程重启丢失 | 检查键是否只存在内存 | 键要随任务持久化 |
| 键冲突导致误去重 | 键生成规则碰撞 | 检查键的随机性 | 用 UUID 或加足够随机位 |
第一种情况最常见。很多团队写重试逻辑时,习惯在每次请求前生成参数,结果幂等键也跟着每次重新生成,去重完全失效。这个坑我踩过,排查了半天才发现键生成位置不对。
第二种情况是延迟重试。Agent 任务可能跑很久,如果幂等记录 1 小时就过期了,而 Agent 在 2 小时后重试,那服务端就认不出这是重复请求了。所以过期时间要覆盖任务的最长生命周期。
5.2 "处理中"状态卡死的处理
服务端返回"处理中"后,如果创建逻辑因为异常没更新状态,这条记录就会永远卡在"处理中",Agent 一直查不到结果,最后超时失败,但工单可能已经创建了。
解决这个问题需要补偿机制。我一般加一个定时任务,扫描超过一定时间(比如 5 分钟)还处于"处理中"的幂等记录,去实际查询对应的工单是否创建成功。如果成功,更新状态为 success;如果失败,更新为 failed 并记录原因。
这个补偿任务的关键是幂等记录里要存足够的信息去反查。比如存业务参数或者业务唯一标识,这样才能去工单系统里查。如果只存了个键,反查不了,那就只能人工介入了。
还有一种情况是创建逻辑本身有 bug,每次都失败但状态没更新。这种要靠监控告警,对"处理中"记录的数量和时长设阈值,超了就报警。
5.3 高并发下的性能问题
幂等记录表在高并发下可能成为瓶颈。每次创建都要插入和更新,如果 QPS 很高,这张表会很大,查询也慢。
优化思路有几个。第一,幂等记录可以分表,按时间或者业务类型分,减少单表压力。第二,过期记录要及时清理,用定时任务删除过期数据,别让表无限增长。第三,查询接口可以加缓存,热点键的状态缓存在 Redis 里,减少数据库查询。
但要注意,缓存不能替代数据库的唯一约束。缓存只是加速查询,真正的去重保证还是靠数据库的唯一索引。缓存和数据库之间要有一致性处理,比如更新数据库后删除缓存,或者用较短的缓存过期时间。
5.4 Agent 决策不稳定导致的重复
前面说的都是技术层面的去重,但 Agent 场景还有个特殊问题:模型可能自己决定重新创建。比如 Agent 超时后,模型重新规划任务,觉得应该再创建一个工单,于是生成了新的幂等键。
这种情况技术手段防不住,因为从系统角度看,这就是两次不同的创建意图。解决办法是在Agent 的提示词和工具设计上下功夫。比如在工具描述里明确告诉模型:"创建工单是幂等操作,超时后应使用相同幂等键重试,不要创建新工单。"或者在 Agent 的上下文里维护一个"已发起的创建意图"列表,模型规划时参考这个列表,避免重复发起。
我试过在工具返回里带上幂等键,让模型知道这次创建用的哪个键,后续重试要复用。这个做法有一定效果,但依赖模型的遵循程度。更稳妥的还是服务端兜底,即使模型生成了新键,如果业务上能识别出是同一笔业务(比如用业务单号做二次去重),也能拦住。
6. 监控、对账与长期治理
6.1 必须监控的几个关键指标
去重机制上线后,不能就不管了,要有监控盯着。我一般关注这几个指标:
- 重复创建率:同一业务创建多条工单的比例,这是最直接的指标,理想情况应该是 0
- 幂等命中率:重复请求被幂等拦截的比例,这个指标能反映重试的频繁程度
- "处理中"记录数:处于处理中状态的幂等记录数量,持续增长说明有卡死
- 查询接口调用量:Agent 超时后查询的频率,太高说明创建接口超时设置不合理
- 幂等记录表大小:反映存储压力,增长过快说明清理不及时
这些指标要配告警。比如重复创建率超过 0.1% 就报警,"处理中"记录超过 100 条且持续 10 分钟就报警。告警要能定位到具体的幂等键和业务,方便快速排查。
6.2 对账机制:最后的防线
再完善的实时去重也可能有漏网之鱼,所以要有对账兜底。我一般做两层对账。
第一层是幂等记录与工单的对账。定期扫描幂等记录,检查状态为 success 的记录是否都有对应的工单,状态为 processing 的是否超时。发现不一致就修复或告警。
第二层是业务维度的对账。从业务侧统计应该创建的工单数,和实际创建的工单数对比。比如报修申请有 100 笔,工单系统应该有 100 条工单,如果多了,就说明有重复。这层对账能发现幂等键设计不合理导致的漏网。
对账频率不用太高,每天一次或者每小时一次都行。关键是要有,而且要有人看结果。我见过团队做了对账但没人看告警,结果重复工单积累了一周才发现。
6.3 长期治理:从个案到机制
重复创建工单这个问题,解决一次不难,难的是长期不出问题。我的经验是要把它变成机制,而不是靠人盯。
第一,把幂等作为工具调用的标准能力。所有写操作的工具,都要支持幂等键,这是规范。新工具上线要检查是否满足这个要求。
第二,把去重逻辑封装成公共库。不要让每个业务团队自己实现,容易实现不一致。公共库统一处理幂等键生成、重试、查询、退避,业务方只管调用。
第三,定期做故障演练。模拟超时、网络抖动、服务端慢等场景,验证去重机制是否有效。演练能发现很多平时发现不了的问题。
第四,把重复创建纳入代码评审检查项。新写的工具调用代码,评审时要看有没有幂等处理,超时重试逻辑对不对。这个习惯能拦住大部分问题。
我个人在实际操作中的体会是,Agent 工具调用的可靠性问题,本质上和传统分布式系统的问题是一脉相承的,只是 Agent 的不确定性放大了这些问题的概率。所以解决思路也是相通的:在不确定的调用方和确定的业务之间,加一层可靠的幂等保证。这层保证做扎实了,Agent 怎么重试都不怕。
最后再分享一个小技巧:如果你的工单系统支持,可以在创建接口里加一个"业务唯一标识"字段,比如报修单号。服务端对这个字段也做唯一约束,这样即使幂等键因为某种原因失效了,业务唯一标识还能兜底去重。两层保险,基本就万无一失了。