agent操作中的幂等性设计方案

发布时间:2026/10/5 9:41:32
agent操作中的幂等性设计方案 幂等性是指同一个操作无论执行一次还是多次最终产生的业务结果是一致的。对于涉及 AI Agent 这种长链路、高成本的场景如果前端抖动重试或网关超时重发导致系统重复触发了 Agent 生成、重复扣费或覆盖了正在生成的任务那将是灾难性的。在我们的系统中一个“论文写作任务”包含研究、提纲、章节生成等多个阶段。我们需要解决三个具体的并发问题重复触发用户手抖点了两次“生成章节”或者网络超时导致请求重试。我们不允许为同一个请求创建两个TaskRun任务运行记录。状态冲突同一个任务不能同时有两个 Agent 在跑比如一个在写引言一个在写实验否则状态机容易乱。结果重复Agent 跑完后回调保存结果时不能因为回调重试而生成两份章节版本。第一道防线幂等键与请求摘要首先我们在应用层定义了幂等键。 在这个项目中我们的幂等键组合是taskId任务IDaction动作如 GENERATE_SECTIONrequestId请求唯一标识。但这还不够。如果攻击者拿着一个旧的requestId但篡改了请求内容比如把“写引言”改成“写实验”发过来系统如果只认 ID就会错误地返回旧结果。因此我们引入了请求摘要。// 计算本次请求的语义摘要 String requestHash WritingHashes.sha256(String.join(\n, taskId.toString(), outline.id().toString(), section.sectionKey(), instruction, contextBuilder.budgetFingerprint(), citation-schema1));我们在处理请求时会计算当前请求内容的 Hash 值。如果数据库里已经存在这个requestId我们会比对 HashHash 一致说明是真正的重复请求直接返回已有结果。Hash 不一致说明是幂等键冲突ID 重用了但内容变了抛出IDEMPOTENCY_CONFLICT异常拒绝执行。第二道防线JVM 锁与事务内的“先查后插”有了幂等键最直观的做法是“先查数据库有没有没有就插”。但在高并发下两个请求可能同时查不到然后双双插入成功。为了解决这个问题我们在代码入口处加了taskId 级别的 JVM 锁Object lock taskLocks.computeIfAbsent(taskId, ignored - new Object()); synchronized (lock) { return transactions.required(() - { // 1. 查幂等记录 TaskRun existing runRepository.findByTaskIdActionAndRequestId(...); if (existing ! null) { requireMatchingHash(existing, requestHash); // 校验摘要 return SectionAcceptance.idempotent(existing); } // 2. 检查是否有正在运行的任务 if (runRepository.existsRunningByTaskId(taskId)) { throw new WritingConflictException(TASK_RUN_CONFLICT, ...); } ​ // 3. 创建并启动新任务 TaskRun run TaskRun.create(...); run.start(); runRepository.save(run); return SectionAcceptance.started(run, ...); }); }锁的粒度我们锁的是taskId而不是全局锁。这意味着不同的论文任务可以并行处理互不干扰保证了吞吐量。顺序很重要我们是先获取锁再在事务里查库。这保证了在单机环境下同一时刻只有一个线程能执行“检查创建”的逻辑彻底杜绝了并发竞争。第三道防线数据库唯一约束终极兜底JVM 锁只能解决单实例的问题。如果我们的服务部署了多个实例集群模式JVM 锁就失效了。这时候数据库约束就是最后一道防线。我们设计了两张“王牌”索引1. 幂等唯一约束CONSTRAINT uq_writing_run_action_request UNIQUE (task_id, action, request_id)这就是幂等键在数据库层面的物理落实。它保证了对于同一个任务、同一个动作、同一个请求 ID数据库里绝对只能有一条记录。即使两个实例同时越过应用层检查数据库也会拦截住第二个插入请求。2. 运行状态互斥约束CREATE UNIQUE INDEX uq_writing_task_running_run ON writing_task_runs(task_id) WHERE status RUNNING;这是一个部分唯一索引Partial Unique Index。它非常巧妙它只限制statusRUNNING的记录唯一。当一个任务正在运行RUNNING时第二个请求想插入 RUNNING 状态会被数据库拒绝。当任务结束变成 SUCCESS/FAIL后这个限制就解除了允许开启新的任务。 这就完美解决了“同一任务同一时间只能有一个 Agent 在跑”的问题而不需要额外的状态机锁。第四道防线冲突后的“优雅回读”虽然数据库约束保证了数据不脏但如果直接抛出DataIntegrityViolationException唯一键冲突异常给前端体验并不好。在文档组装接口中我们实现了更优雅的处理捕获冲突回读胜者。try { // 尝试创建 DocumentVersion result transactions.required(() - commit(...)); return response(result); } catch (DataIntegrityViolationException conflict) { // 冲突了没关系看看是谁赢了 DocumentVersion recovered transactions.required(() - recoverUniqueConflict(taskId, requestId, requestHash, ...) ); if (recovered ! null) { return response(recovered); // 返回已有的结果 } throw conflict; }这种“先尝试插入失败则回读”的模式在多实例并发下能提供类似分布式锁的体验但实现成本更低。注目前我们的研究、提纲和章节生成接口暂时只做到了抛出异常后续可以统一升级为这种回读模式。补充防止结果重复生成除了防止“任务重复创建”我们还要防止“结果重复生成”。 Agent 运行结束后会回调保存章节版本。为了防止回调被重复消费我们在章节版本表设计了generated_by_run_id UUID NOT NULL UNIQUE这保证了一个TaskRun只能对应一个章节版本。即使回调逻辑因为网络原因被触发了两次第二次写入也会因为唯一键冲突而失败从而保护了业务数据的纯净。