用户关闭对话后才完成的异步工具调用
智能体(agent)会话模型出现故障的最明显标志,就是当工具结果无处可去时。智能体发起了一个耗时较长的调用——例如渲染、资源配置任务或多步查询。用户盯着加载图标看了几秒钟,觉得终究还是不需要,于是关闭标签页并离开了。40 秒后工具运行结束。它的回调(callback)携带着一个不再指向任何内容的 conversation_id 命中你的网关。网关面临两个同样糟糕的选择:默默丢弃该结果,或者将其缝合到接管该 ID 的下一个会话中。
大多数团队发现这种失败模式的方式都如出一辙:一张服务工单,用户在里面反馈看到了一个他们没问过的问题的答案,且挂载在一个他们并未开启的对话中。或者是下游系统对同一笔费用进行了两次扣款,因为网关“热心地”针对下一个活动会话重试了交付。或者——最常见的情况——表面上什么也看不出来,只是完成率指标(completion metrics)在缓慢下滑,而没人能将其与任何具体原因联系起来,因为这些失败不会触发警报;它们只会触发“空无”。
这与“发后即忘”(fire-and-forget)的失败模式不同,在那种情况下,规划器(planner)将任务 ID 视为最终答案并在不进行轮询的情况下继续运行。那个问题存在于单个智能体循环内部。而本文讨论的问题存在于智能体循环与你其余的基础设施之间:工具注定会完成,结果注定会送达,而你的会话边界在那之前就已经崩塌了。
你设计的会话是同步的;但工具不是
大多数聊天 UI 都是围绕同步的“请求-响应”模式构建的。用户发送消息,模型回答,轮次结束。对话状态存在于内存或短效缓存中;长效状态则在对话结束或一段简短的空闲期后迁移到数据库。整个流水线都假设从用户输入到最终输出的时间是有界的,并且用户在该时间范围内始终保持连接到会话。
工具在无人察觉的情况下打破了这个假设,因为第一波工具——搜索、计算器、词典查询、简单的 API 读取——速度极快,足以适应隐含的“用户还在”的时间预算。接着第二波工具到来了:渲染、转录、资源配置、代码执行、智能体间分发,以及任何调用外部系统且尾延迟以分钟而非秒计的操作。流水线并没有为了匹配这些需求而改变形态。同步会话仍然维持着开启的轮次,规划器仍然期望结果能内联(inline)返回,而唯一能让模型保持“诚实”的约束就是用户一直保持连接。
因此,当用户断开连接时——关闭标签页、导航离开、关闭应用、遇到网络波动、手机锁屏——智能体循环会一直处于挂起状态,等待一个它已无法路由的结果。有些客户端会在服务器端保留该轮次直至 TTL 到期,然后让它默默消亡。另一些则会在断开连接时中止智能体运行,让工具执行在被派发的队列中沦为“孤儿”。无论哪种方式:工作仍在继续,而会话却已不复存在。
这就是差距所在。一个实际持续时间超过会话寿命的工具,注定会将其结果落在请求它的会话之外。纠结这种情况何时发生是选错了问题,为这种情况发生时该怎么办进行设计,才是唯一的问题。
当结果延迟送达时会发生的场景(三选一)
当携带过时 conversation_id 的工具回调到达时,你的路由层会采取以下三种行动之一,而你应该在某些不可抗力逼你做出选择之前,先弄清楚你的系统采取的是哪一种。
丢弃结果。 网关查找对话,发现已过期,记录一条警告,并向工具服务返回 200 响应以使其不再重试。工具运行了。副作用(side effects)已经产生——扣费成功、邮件已发、行已删除、文档已创建。但没有任何机制告知用户,也没有任何机制告知下一个会话。工作已经在现实世界中完成,但模型对此毫无记忆。下一次用户开启对话询问“那件事办成了吗?”时,智能体必须从现实世界的状态中推导答案,而不是从其自身的历史记录中推导。大多数智能体并非为此构建,它们会毫不犹豫地给出一个或肯定或否定的答案。
路由到下一个会话。 网关查找对话,发现已过期 ,然后“热心地”将结果嫁接到该用户接下来开启的任何对话中。下一个会话继承了一个在其历史记录中没有匹配工具调用的工具响应。面对一个悬空的工具结果消息,模型要么忽略它(最好情况),要么幻觉出一个合理的工具调用(一般情况),或者将延迟的结果视为新鲜的用户消息并采取行动(最坏情况——继承副作用,即下一个对话的智能体会根据上一个对话留下的输出来执行额外的工作)。
路由到完全不同的用户。 这是会让值班人员(on-call)从睡梦中惊醒的情况。conversation_id 被重复使用了,或者用户身份与对话挂钩而非与认证令牌挂钩,或者负载均衡器的键碰撞导致两个会话混淆,又或者是 GC 运行后,一个新生成的 ID 恰好与过期的 ID 碰撞。工具结果出现在了别人的聊天框里。发生一次是侥幸逃过一劫,发生两次就是事故复盘。
第一种失败模式是隐形的,直到你将完成率与断开连接率进行关联分析。第二种也是隐形的,直到用户发现答案与他们的问题不匹配。而第三种,则会自动记录在事故频道中。
幂等性原本就很复杂;现在它变成了两个幂等性问题
生产环境中的重试场景通常假设是 系统 进行重试,而不是 会话 进行重试。像 Temporal、Restate 或 LangGraph 的检查点层(checkpointing layer)这样的持久执行引擎,通过记录每一步的日志并使用幂 等键进行重放,来防止 worker 崩溃和工具的不稳定性,从而确保已完成的工作不会重复执行。这之所以有效,是因为工作流运行(workflow run)是身份的单位,而幂等键是由工作流运行 ID 与步骤结合生成的。
异步工具回调(async-tool-callback)是另一个正交的问题。这不再是系统重试同一个工作流;而是 用户 放弃了当前工作流,并在旧工作流完成之前启动了一个新工作流。由于新的工作流运行具有不同的 ID,工具服务无法知道新运行与旧运行其实是同一个用户想要同样的结果,因此幂等键无法起到去重的作用。
两个失败场景,都戴着“重试”的帽子:
- 引擎级重试:worker 崩溃,工作流恢复,同一步骤需要从日志重放或通过幂等性重新执行。由持久执行引擎解决。已被广泛理解。
- 用户级重试:对话过期,用户重新开始,前一次运行的工具结果现在成了一个寻找归属的残留产物(artifact)。持久执行引擎 无法 解决此问题。通常没有任何方案能解决。
如果你的工具服务构建得很好,它会有一个源自运行 ID 和步骤的幂等键。如果引擎重试,该键可以保护你免受重复执行的影响。但它无法保护你免受用户启动新对话并重新发出相同逻辑请求的影响——对于工具服务来说,那是两个不同的键和两次不同的调用,两者都会执行。第二次调用可能会在第一次调用即将成功时生效。第一次调用也可能在第二次调用已经确定结果后才完成。用户看到一个答案;现实世界却看到了两个副作用。
最干净的修复方法是从对 意图(intent)保持稳定的事物——用户、工具和输入——中派生出幂等键,而不是从对话运行中派生。这要求工具层知道是哪个用户在调用(大多数工具层都知道),并接受同一个用户在某个时间窗口内使用相同的参数调用同一个工具是同一个逻辑请求。选择这个窗口是一个设计抉择。选得太窄会导致重复执行;选得太宽则会阻止用户合法地重新执行相同的工作。
工具需要的是可逆性层级,而不仅仅是幂等键
幂等性告诉你两次执行调用是否安全。可逆性(Reversibility)告诉你,一旦会话断开连接,执行该调用是否依然安全。
读取操作显然是安全的。结果无处可去的读取可以直接丢弃——其副作用为零。写入分为两个层级:可逆写入(副作用在结果没有消费者时可以撤销,例如保存的草稿、可以拆除的幂等配置步骤)和单向写入(无论谁在听,副作用都会持久存在,例如发送的邮件、发布的消息、扣费、删除的行)。
Agent 的规划器(planner)天生没有这种区分的概念;函数调用规范(function-calling schema)也没有对其命名。运行层必须具备这种能力。在调度工具之前,网关需要知道如果工具完成时会话已经消失会发生什么:
- 可取消 / 可逆:当会话过期时,向工具服务发出“连接断开时取消”信号;忽略延迟的结果。
- 幂等且持久:将结果保存在一个稳定的“用户与意图”键下;将其交付给下一个匹配的会话;在用户下一次对话的第一轮向其展示结转的结果。
- 单向且不可逆:不要在可能在工具完成前崩溃的会话边界上调度;需要一个独立的确认界面(通知、电子邮件、专门的任务列表),以便结果有一个不依赖于会话保持开启的归宿。
第三层是大多数团队跳过的层级,因为同步聊天 UI 没有它的位置。聊天是唯一的界面。增加任务列表、通知频道或“你的渲染已完成”界面,意味着将长时运行的工具视为状态单位,而不是将对话视为状态单位。这是持久执行社区两年来一直在推动的架构转型,而大多数 Agent 前端仍未实现这一点。
会话寿命应该是 Agent 能调度的最慢工具的函数
在大多数技术栈中,默认的对话 TTL 是由产品直觉设定的——聊天几分钟,助手几小时,项目类工作几天;而工具超时是由运维直觉设定的——只要能让慢速工具在负载下不再报错就行。这两个数字几乎从未被放在一起考虑。所有有趣的失败案例都存在于它们之间的缝隙中。
一个有用的不变量:会话状态的寿命必须超过该会话中 Agent 可能调度的任何工具在最坏情况下的完成时间。 否则,你将面临一个必然的孤儿率,其比例等于超过会话 TTL 的工具运行比例。除非你专门统计“交付给已过期对话的工具结果”,否则这个孤儿率在现有仪表盘中是不可见的——而大多数团队并不会这样做。
这个不变量说起来容易做起来难。24 小时的会话 TTL 在数据库行数方面很便宜,但在上下文 变得过时方面却很昂贵。允许 Agent 调度需要一天才能完成的工具,会迫使会话状态层的寿命远远超过用户的注意力。诚实的做法是拆分状态模型:用于聊天体验的短效内存对话状态,用于 Agent 循环及其未完成工具调用的长效持久运行状态,以及在结果落地时协调两者的交付层。
一旦两个状态层分离,“用户关闭对话时会发生什么”就变成了一个简单的路由决策,而不是一个数据丢失事件。Agent 运行会针对持久状态继续进行。工具结果会根据运行的稳定 ID 落地。当用户回来时——无论是同一个会话还是新会话——运行时会询问 Agent 循环是否有待处理的完成结果需要呈现,答案要么是“是的,这是你昨天开始的渲染”,要么是“不,你关心的所有事情都在你离开期间处理完了”。
你最后才会察觉的失效模式
这个问题的复盘分析版本几乎总是围绕“跨会话泄露”案例展开,因为这种案例才会被上报。随着时间的推移,耗费成本更高的是“静默丢弃”的情况——工具运行完成,副作用已在现实中产生,但用户从未被告知。你支付了工具费用,承担了副作用成本,却得到了零转化归因,因为用户从未看到答案。
捕获这一情况的检测手段并不华丽:对于发出的每一个工具调用,记录其完成时间以及完成时是否挂载了会话。计算比例。如果“完成时脱离”占总长时调用的比例超过几个百分点,那么你面临的是架构问题,而不是告警问题。解决方法不是设置更响亮的警报,而是上文所述的状态分离模型和交付层。
你发起的异步工具调用终将完成。有趣的问题在于,到那时你的系统是否有地方可以存放结果。
- https://developers.googleblog.com/build-long-running-ai-agents-that-pause-resume-and-never-lose-context-with-adk/
- https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-long-run.html
- https://workos.com/blog/mcp-async-tasks-ai-agent-workflows
- https://www.inngest.com/blog/durable-execution-key-to-harnessing-ai-agents
- https://temporal.io/blog/idempotency-and-durable-execution
- https://www.restate.dev/what-is-durable-execution
- https://docs.langchain.com/oss/python/langgraph/durable-execution
- https://elevenlabs.io/docs/agents-platform/workflows/post-call-webhooks
