跳到主要内容

当用户取消对话后,下游 API 却仍在继续写入

· 阅读需 11 分钟
Tian Pan
Software Engineer

用户点击停止。浏览器关闭了 SSE 连接。你的 AI SDK 触发了 onAbort。Agent 运行时检测到信号,停止向模型请求更多 token,并终止其循环。从你的代码库内部来看,这次取消显得非常利索。你所能看到的每个子系统都在执行正确的操作。

与此同时,两秒钟前,模型发出了一个工具调用(tool call)。运行时分发了它。工具的 execute 函数打开了一个连接到第三方 API 的 TCP 连接并发送了 payload。该 HTTP 请求仍在传输中,第三方的服务器仍在处理它,而第三方完全无法得知它所服务的对话已不存在。写入操作成功提交。用户的心智模型认为他们通过点击停止避开了该操作。下游系统的数据库则记录了完全不同的结果。

这种故障模式存在于进程内取消(in-process cancellation)与远程取消(remote cancellation)之间的鸿沟中。大多数工程师对 AbortController 的理解就像 Go 的 context.Context 一样——认为它是一个单一的令牌,可以扇出到调用图中的每一个 goroutine,并在每一个阻塞操作中同时触发取消通道。这种推理在你的进程内部是正确的。但在跨网络通信时,它基本上是错误的。一个穿越了两个 HTTPS 跳点、一个 L7 负载均衡器和一个供应商队列的取消操作,已经不再是取消操作了。它只是一份“奢望”。

AbortSignal 停止的是你的代码,而非依赖的代码

AbortController 是一个 Web 平台原语,旨在中断在运行时内部执行的阻塞工作。当你将其接入 fetch 时,你是在要求你的运行时关闭 TCP 套接字并拒绝该 Promise。这就是它的作用。它确实非常有用:提供商侧的 GPU 会在几百毫秒内察觉到套接字关闭并停止生成 token,这就是为什么流式 LLM 取消在推理本身上效果很好的原因。

但是,一旦你的工具 execute 函数向第三方(非 LLM 提供商)发送 HTTP 请求时——无论是 Stripe、Mailgun、Salesforce 还是你自己的内部服务,任何执行副作用的操作——取消协议就改变了。关闭一个写入端点的连接会产生以下三种结果之一,具体取决于服务器的实现,而不是你的:

  • 服务器在处理程序达到提交点之前检测到套接字已关闭并中止。这是你潜意识里假设的情况。
  • 服务器在提交之后检测到套接字已关闭,尝试刷新响应,刷新失败,并记录一条“客户端已断开(client gone)”的日志。而写入操作已经完成。
  • 服务器根本没有检测到套接字关闭,因为请求已进入异步处理队列。下游的 worker 在十秒钟后从队列中取出消息,并针对一个早已关闭的对话执行操作。

三种不同的结果,只有一种符合用户的预期。运行时无法区分它们,因为信号止步于套接字。第三方没有可以监听的类似 ctx.Done() 的等效机制。

除非你在设计中加入,否则取消令牌不会跨越进程边界

Go 开发者在第一次跨服务边界使用 context.Context 时,往往会深刻体会到这个教训。在进程内部,取消父级 context 会立即关闭每个派生 context 的 Done() 通道,并且每个 select 该通道的 goroutine 都会在微秒内返回。但在跨越服务边界时,context 值消失了——HTTP 或 gRPC 的标准信封中没有哪个字段承载“此请求已被上游客户端取消”的信息。

你可以进行近似处理。你可以传播一个截止时间(deadline)请求头,让下游服务在每个操作时检查。你可以在原始 POST 之后发出一个带外的 DELETE /jobs/{id}。你可以在原始请求中包含一个取消令牌,服务器在每个提交点之前对其进行轮询。所有这些都是显式的协议,你必须在连接的两端进行设计、文档记录和强制执行。

LLM 工具调用框架并没有附带这些协议。工具内部的 fetch 与普通的“发后即忘(fire-and-forget)”式 HTTP 调用无异。AI SDK 的 abortSignal 完全存在于该 fetch 的客户端。当 SDK 将信号传递给工具的 execute 函数时,运行时知道取消了,但目标端并不知道它正在做的工作已被放弃。更糟糕的是,运行时的中止操作可能在请求体已经传输完毕后才触发,导致服务器处于一种正在处理请求,而发起者已经挂断的状态。

比对话寿命更长的异步工作

当工具的下游 API 是异步的时,会出现这种故障最严重的变体。工具的 execute 函数实际上并没有执行副作用——它只是将其排入队列。它调用类似于 POST /workflows/run 的接口,并收到带有运行 ID 的 202 Accepted。从运行时的角度来看,工具返回成功。从第三方的角度来看,一个工作流现在已排期执行,可能是在几分钟后,也可能是在另一台机器上。

如果用户在工具返回的那一刻中止,运行时会干净利落地取消。对话关闭。用户会话结束。而第三方的 worker 队列对此一无所知。它按自己的计划领取任务,并针对用户认为已经避开的状态运行。副作用会在用户关闭标签页几分钟后提交。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates