用户已确认,Agent 为何还会重复执行
文章分析 Android AI Agent 中用户确认到动作派发之间的竞态窗口:跨会话操作、超时重试以及自动确认都可能导致同一动作重复执行。作者以 StareBrain 为例讨论如何处理迟到确认,避免重复派发或错误拦截。
AI 深度解读
AI Agent 先征求同意,再执行操作,看起来很稳妥。但用户点下确认,到请求真正发出去,中间仍有一段时间差:后台任务、超时重试、另一台设备,都可能抢先执行同一个动作。确认按钮随后触发的,就可能是第二次预约。
StareBrain 团队讨论的正是这类「迟到确认竞态」。他们的方案是:确认入口先检查状态,结果不明的旧请求先核对,执行端再用幂等键兜底。这套思路适合多入口的 AI 工具调用,但文中的代码还不能直接当作完整的并发安全实现。
用户同意了,为什么还不能直接执行
作者列出了三种重复执行的来源:
- 用户在一个设备或会话里确认时,后台任务已经触发了同一个动作。
- 第一次请求超时后启动重试,但超时设得太短,原请求其实仍在执行。
- 低风险操作满足自动确认条件,恰好又收到了人工确认。
它们都有一个共同点:用户授权执行,不等于这个动作现在仍然需要执行。
授权回答的是「允不允许」,执行状态回答的是「是否已经做过」。如果确认回调只检查前一个问题,就会把一个有效的授权,变成一次多余的操作。
作者首先检查 delivery:已经是 CONFIRMED 或 FAILED,就返回 AlreadyCompleted,不再发送请求。这里的名字容易误导,AlreadyCompleted 也包含失败,它表达的是旧流程已结束,不保证业务成功。
更棘手的是 ActionStatus.DENIED_UNRESOLVED。按照原文定义,它表示请求已经发出,但结果未知。服务商可能已经完成操作,只是回执没有回来。此时重新发送,很可能制造第二次副作用。
下面这段处理器把这种状态单独拦住,要求先核对旧请求:
fun handleConfirmation(actionId: String): ConfirmationOutcome {
val record = actionRepository.find(actionId)
?: return ConfirmationOutcome.Blocked(actionId, "action record not found")
return when {
record.delivery == DeliveryState.CONFIRMED ->
ConfirmationOutcome.AlreadyCompleted(actionId, DeliveryState.CONFIRMED)
record.delivery == DeliveryState.FAILED ->
ConfirmationOutcome.AlreadyCompleted(actionId, DeliveryState.FAILED)
record.actionStatus == ActionStatus.DENIED_UNRESOLVED -> {
// Do NOT dispatch. Surface for reconciliation first.
ConfirmationOutcome.Blocked(
actionId,
"prior dispatch unresolved — reconcile before retry"
)
}
record.delivery == DeliveryState.PENDING ->
dispatcher.dispatch(record).let {
ConfirmationOutcome.Dispatched(actionId)
}
else -> ConfirmationOutcome.Blocked(actionId, "unexpected state")
}
}
要看清 DENIED_UNRESOLVED 分支的位置:它排在 PENDING 之前。即使投递状态仍显示待处理,只要上一次执行结果没有查清,也不能直接补发。
这里的核对(reconciliation),就是确认旧请求到底有没有在服务商侧生效。原文提出了这一步,但没有给出查询、回调或人工核查的具体实现。
确认入口检查一次,执行端为什么还要兜底
确认处理器只是入口之一。后台任务和重试任务,也可能直接调用执行器,所以作者又在 ActionDispatcher 里检查一次状态,并给服务商请求加上幂等键。
幂等键可以理解成一次操作的固定凭证:同一个凭证重复提交时,需要接收方识别并复用已有结果,避免再次产生副作用。
下面这段代码展示了执行端的两道检查,以及不同异常如何落库:
class ActionDispatcher(
private val repository: ActionRepository,
private val provider: ActionProvider
) {
fun dispatch(record: ActionRecord): DispatchResult {
// Idempotency key scoped to action + attempt number
val idempotencyKey = "${record.actionId}:${record.attemptNumber}"
// Re-check delivery state inside dispatch — the terminal check
// in ConfirmationHandler is a fast path, not the only guard
if (record.delivery != DeliveryState.PENDING) {
return DispatchResult.Skipped(record.actionId, "non-pending state at dispatch")
}
return try {
val result = provider.execute(record, idempotencyKey)
repository.markDelivered(record.actionId, result.providerObjectId)
DispatchResult.Succeeded(record.actionId)
} catch (e: TimeoutException) {
repository.markUnresolved(record.actionId)
DispatchResult.Unresolved(record.actionId)
} catch (e: Exception) {
repository.markFailed(record.actionId)
DispatchResult.Failed(record.actionId, e.message)
}
}
}
sealed class DispatchResult {
data class Succeeded(val actionId: String) : DispatchResult()
data class Failed(val actionId: String, val reason: String?) : DispatchResult()
data class Unresolved(val actionId: String) : DispatchResult()
data class Skipped(val actionId: String, val reason: String) : DispatchResult()
}
关键是 "${record.actionId}:${record.attemptNumber}":同一次尝试的重发沿用同一个键,新尝试才生成新键。TimeoutException 则通过 markUnresolved 进入待核对状态,不能直接按确定失败处理。
落到界面上,作者希望用户能区分三种结果:正常确认后执行;动作早已完成,确认被接收并提示已有结果;旧请求结果未知,暂时阻止补发并说明原因。
第三种提示不太讨喜,却保留了用户判断所需的信息。比起悄悄重试,明确告诉用户「上次是否成功还没查清」,更符合实际状态。
我的判断
这篇文章最有价值的地方,是把「失败」和「不知道是否成功」拆开。网络超时只说明调用方没有及时拿到结果,不能据此判断服务商没有执行。相比超时就重试,这种状态划分更适合会产生预约等副作用的操作。
但作者声称这套组合能关闭重复执行窗口,代码展示的证据还不够。
首先,dispatch 检查的是传入的 record,没有展示重新读取或原子占用动作的过程。两个入口可能同时读到 PENDING,随后一起调用服务商。普通的「先检查,再执行」仍然有竞态;落地时还需要明确数据库条件更新、锁或其他并发控制如何配合。
其次,生成幂等键本身没有去重能力。provider.execute 背后的服务商必须支持对应语义,键的作用范围和有效期也要满足需求。原文没有交代这些条件,不能据此认定任意外部 API 都能避免重复副作用。
尝试编号同样需要规则。如果超时后直接增加 attemptNumber,新键就绕过了旧键的去重。因此,什么时候允许创建新尝试,必须和核对结果绑定。
示例还有一个返回值问题:handleConfirmation 调用执行器后,无论拿到 Failed、Unresolved 还是 Skipped,都会包装成 Dispatched。如果界面把它当成成功信号,用户仍然会看到与实际状态不符的反馈。执行结果需要继续向上传递。
作者没有提供重复率、并发压测或延迟对照数据。「不会给正常流程增加明显延迟」只能视为经验判断。这里值得借鉴的是状态设计,性能和并发保证仍需具体实现来证明。
能拿来做什么
- 给前端确认按钮接入真实的服务端动作状态。按钮禁用可以减少重复点击,后台任务、跨会话确认和重试则需要共用同一个
actionId。 - 在 Node 或 AI 工具执行服务中,单独保存「请求已发出、结果未知」的状态。遇到超时先进入核对流程,查清旧请求后再决定是否创建新尝试。
- 接入有副作用的外部 API 前,确认服务商的幂等机制,并明确
attemptNumber的递增条件。同一次尝试重发时保持原键。 - 将
Succeeded、Failed、Unresolved、Skipped映射成准确的接口响应和界面提示,再检查两个入口同时执行、服务商成功但回执超时这两类关键场景。