Agent 的流式输出与“停止”按钮:用户点了停止之后,到底该发生什么

  created  by  鱼鱼 {{tag}}
创建于 2026年10月10日 14:52:44 最后修改于 2026年10月14日 18:34:51

给内部做的一个小 Agent 加“停止”按钮,我原本以为是半天的活:前端加个按钮,点了以后断开流式连接,完事。

上线第二天就有同事反馈:他点了停止,文件却还是被改了。我去翻日志,发现事情是这样的:他点停止的时候,模型刚刚生成完一个写文件的工具调用;前端确实停止了渲染,连接也断了,但后端的 Agent 循环根本不知道用户已经喊停,照样把工具执行完了,甚至还又调了一次模型,生成了一段没人看的总结。

那之后我才意识到,“停止”这件事远比一个按钮复杂。这篇就聊聊我后来是怎么重新设计的,主要讲三件事:取消信号怎么传到底、中断落在不同阶段该怎么处理、以及中断之后对话历史怎么写。

停止,其实有三层

我后来把“停止”拆成了三层,每一层都要单独处理:

第一层是界面。 用户点击后,前端立刻停止渲染新的内容,给出明确反馈,比如“已停止”。这一层最容易做,也是我最开始唯一做了的那层。

第二层是模型的流式请求。 关闭和模型服务之间的连接,让它别再继续生成。这里有个我不太确定的点:连接断开后,服务端是否立即停止生成、已经生成的部分怎么计费,不同服务商的行为可能不一样,我没有逐家验证过,建议以各家文档为准。我的做法是不依赖这个假设,自己的系统里一定要主动关掉连接,并且不再处理之后到达的任何数据。

第三层是 Agent 循环本身。 这是我最开始漏掉的一层,也是最重要的。取消信号必须传到循环里,让它知道:不要再执行新的工具,不要再发起新的模型调用,正在执行的工具也要尽量收到通知。

停止的三层:界面层、模型流层与 Agent 循环层,取消令牌贯穿

图:停止的三层:界面层、模型流层与 Agent 循环层,取消令牌贯穿(原创示意图)

在实现上,我给每一次 Agent 运行都分配一个取消令牌,从接收请求的那一层一路往下传:模型调用、工具执行、子任务,全都拿着同一个令牌。用户点停止,前端调一个接口,把这个令牌标记为已取消。

用 Python 的 asyncio 写,大概是这样一个结构(示意代码,省略了很多细节):

async def run_agent(task, cancel: asyncio.Event):
   while not cancel.is_set():
       msg = await stream_model(history, cancel)   # 内部检查 cancel 并关闭连接
       if cancel.is_set():
           record_interrupted(msg)                 # 保存已生成的部分
           break
       for call in msg.complete_tool_calls():      # 只执行参数完整的调用
           if cancel.is_set():
               record_skipped(call)
               continue
           result = await run_tool(call, cancel)
           history.append(result)
       if not msg.tool_calls:
           break

这里有个容易被忽略的坑:取消信号被吞掉。 在 Python 里,任务被取消时会抛出 CancelledError,从 3.8 开始它继承自 BaseException,普通的 except Exception 不会捕获它;但如果有人写了裸的 except: 或者 except BaseException,又没有重新抛出,取消就被悄悄吞掉了。Java 里也类似:捕获 InterruptedException 之后既不重新抛出,也不调用 Thread.currentThread().interrupt() 恢复中断标志,上层就再也不知道这个线程被中断过。我那次排查,最后就是在一个工具的重试逻辑里找到了这样一段代码。

中断落在哪个阶段,处理方式完全不同

这是我觉得最需要想清楚的部分。用户点停止的那一刻,Agent 可能处在好几种不同的状态里,每一种的处理方式都不一样。

模型正在生成文字。 这是最简单的情况。把已经生成的部分保存下来,标记为“被中断”,前端显示“已停止,以下是部分回复”。用户可能只想看到前半段,没必要整段丢掉。

模型正在生成工具调用的参数。 在流式模式下,主流接口的工具调用参数是一段一段增量发过来的。如果中断发生在这里,你手上拿到的是一段不完整的 JSON。我的规则很简单:参数不完整的工具调用,一律丢弃,绝不尝试“修补”后执行。 我曾经想过用宽松的 JSON 解析把它“救回来”,后来觉得太危险了——一个被截断的路径参数,可能刚好变成了另一个合法的路径。

工具正在执行。 这是最难的一种。要看工具本身的性质:

  • 只读的工具,比如搜索、读文件,直接放弃结果就好;

  • 可以安全取消的工具,比如一个长时间的查询,把取消令牌传进去,让它自己尽快退出;

  • 不可中断的写操作,比如已经发出去的支付请求、正在写入的文件,我的选择是让它执行完,然后如实记录结果,而不是在中途硬生生打断,留下一个不确定的状态。

工具已经执行完,模型还没看到结果。 这时候结果必须写进历史。因为副作用已经发生了,如果你把它丢掉,下次对话时模型会以为这件事没做过,很可能再做一遍。

中断落在不同阶段时的处理方式对照表

图:中断落在不同阶段时的处理方式对照表(原创示意图)

中断之后,对话历史怎么写

这是第二个让我栽跟头的地方。

就我用过的几家接口而言,消息格式都要求:模型发出的每一个工具调用,后面都要有对应的工具结果。如果中断发生在工具调用之后、结果写回之前,历史里就会留下一个“有调用、没结果”的消息。下一次用户发新消息时,请求可能直接报错,或者模型被这个悬空的调用搞糊涂。

我的处理办法是:中断时,给每一个没有结果的工具调用,补一条明确的合成结果。比如:

{"ok": false, "error": {"code": "INTERRUPTED_BY_USER",
 "message": "用户中断,此工具未执行"}}

如果工具其实已经执行完了(前面说的最后一种情况),就写入真实结果,并加上一句“该结果在用户中断前已产生”。这样下一轮模型看到的历史是完整、诚实的,它知道哪些事做了,哪些事没做。

另外,我会在历史里加一条简短的系统备注,告诉模型“上一轮被用户中断”。这样用户接着说“继续”的时候,模型知道该从哪里接上;用户说“算了,换个思路”的时候,它也不会执着于上一轮没做完的计划。

体验上的几个小细节

技术问题解决之后,我又调了几个体验上的细节,同事反馈都还不错:

  • 停止按钮要有即时反馈。 哪怕后端还在等一个不可中断的工具执行完,前端也要立刻显示“正在停止……”,并且在真正停下后告诉用户:哪些操作已经完成,哪些被跳过了。

  • 提供“继续”而不是只有“重新开始”。 很多时候用户只是想打断一下,补充一句信息,然后让它接着干。

  • 超时和用户中断走同一条路径。 我后来把超时也实现成“系统自动按下停止按钮”,这样两种情况的处理逻辑完全一致,少维护一套代码。

中断这件事,我是怎么测的

还有一个教训:中断相关的 bug,靠手动点按钮很难测出来,因为你很难恰好在“工具参数生成到一半”的那一刻按下停止。

我后来在测试里做了一个小工具:用一个假的模型和几个假的工具,按脚本输出预先写好的内容,然后在指定的阶段自动触发取消。比如“在第二个工具调用的参数生成到一半时取消”“在写文件工具执行期间取消”。每个用例跑完,检查三件事:有没有不该执行的工具被执行了,对话历史是不是每个调用都有结果,下一轮请求能不能正常发出去。

这套测试写起来不算麻烦,但它帮我找到了好几个手动测试永远碰不到的问题,包括前面说的那个被吞掉的取消信号。

最后

回头看,我最开始犯的错,是把“停止”当成了一个界面功能。其实它是一个贯穿整个系统的协议:信号要能传到底,每个阶段要有明确的处理规则,历史要诚实地记录发生了什么。

如果你也在做 Agent,我的建议是尽早把中断当成一等公民来设计,而不是等到出了事再回头补。毕竟对用户来说,能放心地按下“停止”,才敢放心地按下“开始”。

评论区
评论
{{comment.creator}}
{{comment.createTime}} {{comment.index}}楼
评论

Agent 的流式输出与“停止”按钮:用户点了停止之后,到底该发生什么

Agent 的流式输出与“停止”按钮:用户点了停止之后,到底该发生什么

给内部做的一个小 Agent 加“停止”按钮,我原本以为是半天的活:前端加个按钮,点了以后断开流式连接,完事。

上线第二天就有同事反馈:他点了停止,文件却还是被改了。我去翻日志,发现事情是这样的:他点停止的时候,模型刚刚生成完一个写文件的工具调用;前端确实停止了渲染,连接也断了,但后端的 Agent 循环根本不知道用户已经喊停,照样把工具执行完了,甚至还又调了一次模型,生成了一段没人看的总结。

那之后我才意识到,“停止”这件事远比一个按钮复杂。这篇就聊聊我后来是怎么重新设计的,主要讲三件事:取消信号怎么传到底、中断落在不同阶段该怎么处理、以及中断之后对话历史怎么写。

停止,其实有三层

我后来把“停止”拆成了三层,每一层都要单独处理:

第一层是界面。 用户点击后,前端立刻停止渲染新的内容,给出明确反馈,比如“已停止”。这一层最容易做,也是我最开始唯一做了的那层。

第二层是模型的流式请求。 关闭和模型服务之间的连接,让它别再继续生成。这里有个我不太确定的点:连接断开后,服务端是否立即停止生成、已经生成的部分怎么计费,不同服务商的行为可能不一样,我没有逐家验证过,建议以各家文档为准。我的做法是不依赖这个假设,自己的系统里一定要主动关掉连接,并且不再处理之后到达的任何数据。

第三层是 Agent 循环本身。 这是我最开始漏掉的一层,也是最重要的。取消信号必须传到循环里,让它知道:不要再执行新的工具,不要再发起新的模型调用,正在执行的工具也要尽量收到通知。

停止的三层:界面层、模型流层与 Agent 循环层,取消令牌贯穿

图:停止的三层:界面层、模型流层与 Agent 循环层,取消令牌贯穿(原创示意图)

在实现上,我给每一次 Agent 运行都分配一个取消令牌,从接收请求的那一层一路往下传:模型调用、工具执行、子任务,全都拿着同一个令牌。用户点停止,前端调一个接口,把这个令牌标记为已取消。

用 Python 的 asyncio 写,大概是这样一个结构(示意代码,省略了很多细节):

async def run_agent(task, cancel: asyncio.Event):
   while not cancel.is_set():
       msg = await stream_model(history, cancel)   # 内部检查 cancel 并关闭连接
       if cancel.is_set():
           record_interrupted(msg)                 # 保存已生成的部分
           break
       for call in msg.complete_tool_calls():      # 只执行参数完整的调用
           if cancel.is_set():
               record_skipped(call)
               continue
           result = await run_tool(call, cancel)
           history.append(result)
       if not msg.tool_calls:
           break

这里有个容易被忽略的坑:取消信号被吞掉。 在 Python 里,任务被取消时会抛出 CancelledError,从 3.8 开始它继承自 BaseException,普通的 except Exception 不会捕获它;但如果有人写了裸的 except: 或者 except BaseException,又没有重新抛出,取消就被悄悄吞掉了。Java 里也类似:捕获 InterruptedException 之后既不重新抛出,也不调用 Thread.currentThread().interrupt() 恢复中断标志,上层就再也不知道这个线程被中断过。我那次排查,最后就是在一个工具的重试逻辑里找到了这样一段代码。

中断落在哪个阶段,处理方式完全不同

这是我觉得最需要想清楚的部分。用户点停止的那一刻,Agent 可能处在好几种不同的状态里,每一种的处理方式都不一样。

模型正在生成文字。 这是最简单的情况。把已经生成的部分保存下来,标记为“被中断”,前端显示“已停止,以下是部分回复”。用户可能只想看到前半段,没必要整段丢掉。

模型正在生成工具调用的参数。 在流式模式下,主流接口的工具调用参数是一段一段增量发过来的。如果中断发生在这里,你手上拿到的是一段不完整的 JSON。我的规则很简单:参数不完整的工具调用,一律丢弃,绝不尝试“修补”后执行。 我曾经想过用宽松的 JSON 解析把它“救回来”,后来觉得太危险了——一个被截断的路径参数,可能刚好变成了另一个合法的路径。

工具正在执行。 这是最难的一种。要看工具本身的性质:

工具已经执行完,模型还没看到结果。 这时候结果必须写进历史。因为副作用已经发生了,如果你把它丢掉,下次对话时模型会以为这件事没做过,很可能再做一遍。

中断落在不同阶段时的处理方式对照表

图:中断落在不同阶段时的处理方式对照表(原创示意图)

中断之后,对话历史怎么写

这是第二个让我栽跟头的地方。

就我用过的几家接口而言,消息格式都要求:模型发出的每一个工具调用,后面都要有对应的工具结果。如果中断发生在工具调用之后、结果写回之前,历史里就会留下一个“有调用、没结果”的消息。下一次用户发新消息时,请求可能直接报错,或者模型被这个悬空的调用搞糊涂。

我的处理办法是:中断时,给每一个没有结果的工具调用,补一条明确的合成结果。比如:

{"ok": false, "error": {"code": "INTERRUPTED_BY_USER",
 "message": "用户中断,此工具未执行"}}

如果工具其实已经执行完了(前面说的最后一种情况),就写入真实结果,并加上一句“该结果在用户中断前已产生”。这样下一轮模型看到的历史是完整、诚实的,它知道哪些事做了,哪些事没做。

另外,我会在历史里加一条简短的系统备注,告诉模型“上一轮被用户中断”。这样用户接着说“继续”的时候,模型知道该从哪里接上;用户说“算了,换个思路”的时候,它也不会执着于上一轮没做完的计划。

体验上的几个小细节

技术问题解决之后,我又调了几个体验上的细节,同事反馈都还不错:

中断这件事,我是怎么测的

还有一个教训:中断相关的 bug,靠手动点按钮很难测出来,因为你很难恰好在“工具参数生成到一半”的那一刻按下停止。

我后来在测试里做了一个小工具:用一个假的模型和几个假的工具,按脚本输出预先写好的内容,然后在指定的阶段自动触发取消。比如“在第二个工具调用的参数生成到一半时取消”“在写文件工具执行期间取消”。每个用例跑完,检查三件事:有没有不该执行的工具被执行了,对话历史是不是每个调用都有结果,下一轮请求能不能正常发出去。

这套测试写起来不算麻烦,但它帮我找到了好几个手动测试永远碰不到的问题,包括前面说的那个被吞掉的取消信号。

最后

回头看,我最开始犯的错,是把“停止”当成了一个界面功能。其实它是一个贯穿整个系统的协议:信号要能传到底,每个阶段要有明确的处理规则,历史要诚实地记录发生了什么。

如果你也在做 Agent,我的建议是尽早把中断当成一等公民来设计,而不是等到出了事再回头补。毕竟对用户来说,能放心地按下“停止”,才敢放心地按下“开始”。


Agent 的流式输出与“停止”按钮:用户点了停止之后,到底该发生什么2026-10-14鱼鱼

{{commentTitle}}

评论   ctrl+Enter 发送评论