
目录
Hello,大家好,我是 1M55!👋
上一篇聊流式输出时,我们把一条模型回答拆成了几段:模型逐步返回正文,Python 后端读取并转发,浏览器再把新内容追加到页面。
既然回答可以一边生成一边显示,用户自然也可能在生成到一半时点“停止”。
表面上看,这个功能很简单。点一下按钮,文字不再增加,把状态改成“已停止”就行。
但我越往后想,越觉得这里有个问题:
页面停了,正在读取模型响应的后端也停了吗?
如果只是前端不再显示,而后端仍然读取剩下的答案,那“停止生成”对于用户是有效的,对于后端资源却可能没有达到预期。
这篇就从一次尚未完成的流式请求出发,看看停止信号到底要经过哪些地方。
0. 先看一个容易误判的“停止”
假设模型正在回答一个较长的问题。浏览器已经显示了前两段:
第一段:这个问题可以从三个方面来看……
第二段:首先,我们需要区分……
此时用户点击停止。页面不再出现第三段。
我们能确认什么?
只能确认:页面没有继续显示内容。
如果前端只是设置一个开关,收到后续数据也不再渲染,那么浏览器请求可能仍然保持连接;后端仍在转发;模型也仍在生成。
所以实现停止按钮时,第一个要区分的就是:
停止显示
和
取消正在进行的请求
前者是页面行为。后者才有机会把断连信号传给后端。
1. 浏览器取消了请求,为什么后端不一定马上停?
对于通过 fetch 发起的流式请求,可以使用 AbortController:
const controller = new AbortController();
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
signal: controller.signal,
});
// 点击“停止生成”
controller.abort();
abort() 可以中止 fetch 请求和响应体的读取。前端正在等待下一段数据时,通常会进入取消异常处理,页面据此显示“已停止”。MDN:AbortController.abort()
可是这里有两条不同的 HTTP 连接:
连接 A:浏览器 ──→ Python 后端
连接 B:Python 后端 ──→ 模型 API
controller.abort() 取消的是浏览器发起的连接 A。浏览器并不知道后端如何调用模型,也不能直接关闭连接 B。
接下来要看后端:它能否发现连接 A 已经断开,并结束正在处理这个请求的任务?
底层 ASGI 协议定义了 http.disconnect 事件;向已经关闭的连接继续发送数据,也可能出现发送异常。这些都是服务端得知客户端离开的途径。ASGI HTTP 规范
不过,具体由谁监听断连、生成器如何结束,与使用的 ASGI 服务器和 Starlette 版本有关。Starlette 的版本记录中,就有关于 StreamingResponse 断连异常行为的变更。因此写实际项目时,需要以所用版本的行为为准。Starlette 更新记录
到这里,我们可以得到一个更准确的说法:
浏览器取消请求,是后端停止处理的起点;后端还需要收到断连信号,并让当前流式任务退出。
2. 后端任务退出后,上游模型连接怎么关?
这是整件事最容易漏掉的一步。
后端通常一边读取模型 API 的响应,一边向浏览器 yield 新正文。如果浏览器已经走了,这段读取就不应该继续占着连接。
一个比较清楚的写法,是让上游 HTTPX 响应和下游生成器处在同一个生命周期里:
async def stream_to_browser(prompt: str):
try:
async with httpx.AsyncClient(timeout=90) as client:
async with client.stream(
"POST",
API_URL,
headers=api_headers(),
json={
"model": MODEL,
"messages": [
{"role": "user", "content": prompt}
],
"stream": True,
},
) as upstream:
upstream.raise_for_status()
async for line in upstream.aiter_lines():
text = extract_text(line)
if text:
yield make_sse_event(text)
finally:
log("本次流式响应已退出")
为什么把上游请求放在这里?
因为这段代码在同一个生成器里完成了两件事:
从模型读取一段
→ 向浏览器交出一段
当下游响应结束、生成器退出时,代码会离开 HTTPX 的 async with client.stream(...) 上下文,关闭本地的上游响应。HTTPX 文档明确说明了上下文模式的关闭行为;如果采用手动流式请求模式,则需要开发者自行保证调用 response.aclose()。HTTPX 异步流式文档
这里还涉及 Python 的取消语义。
asyncio 不会像结束操作系统进程那样,强行在任意一行代码上终止协程。取消会在任务后续有机会处理取消的位置触发 CancelledError。因此,异步读取上游数据时,任务通常有机会响应取消;如果中间插入很长时间的同步阻塞操作,就可能让取消变慢。Python asyncio 任务取消
如果业务确实需要捕获取消异常,应在清理后继续抛出:
except asyncio.CancelledError:
log("请求被取消")
raise
finally 负责清理,`raise` 保留取消语义。把异常吞掉后继续执行,反而可能让“停止生成”变成一次看不见的后台生成。
还有一种常见情况:后端另外用 asyncio.create_task() 启动了一个独立任务来读取模型。这个任务已经脱离当前响应生成器,仅仅结束下游响应,不能据此断定它也会结束。需要明确管理这个任务的取消和回收。
所以,“客户端断开后后端会不会停”,最终要回到项目的代码结构:上游读取任务究竟跟谁一起结束?
3. 上游连接关了,模型就一定停止了吗?
到这里,应用侧已经可以做不少事情:
浏览器取消读取
→ 后端结束当前响应
→ 生成器退出
→ HTTPX 关闭上游响应
但最后还有一层边界:模型服务商。
HTTPX 关闭上游响应,能够说明我们的应用不再维持这条上游连接。它不能单独证明服务商内部的生成任务已经在同一时刻停止,更不能直接推出费用一定在此刻终止。
DeepSeek Chat Completions 文档列出了 finish_reason="aborted",表示生成被中断。DeepSeek Chat Completions 文档
这个字段可以帮助理解服务商返回的结束状态。但主动断开连接时,客户端也可能读不到流尾事件。没有读到 aborted,并不能反过来证明服务商仍在生成。
所以我觉得产品界面和工程记录需要使用不同的表述:
| 说法 | 需要的证据 |
|---|---|
| 页面已停止显示 | 前端停止追加正文 |
| 请求已取消 | 浏览器取消请求,后端观察到断连 |
| 上游连接已关闭 | HTTPX 响应完成清理 |
| 模型生成已中断 | 服务商提供明确的中止状态 |
| 计费已停止 | 服务商提供相应的计费确认 |
日常聊天界面显示“已停止”没有问题,用户需要的是明确反馈。但在排查资源和费用问题时,不能把这个 UI 状态当成所有层都已停止的证据。
4. 实际开发中,我会怎样设计这个按钮?
如果要在自己的 AI 后端里加“停止生成”,我会先保证三件事。
第一,前端真的取消当前 fetch,并正确处理取消状态,而不是只停止渲染。
第二,上游 HTTP 请求与当前流式响应共享清楚的生命周期。客户端断开后,生成器能退出;生成器退出后,上游响应能关闭。若创建了独立后台任务,也要单独管理它。
第三,记录几个关键节点:客户端何时断开、响应生成器何时退出、上游响应何时关闭。这样遇到“用户点停止后仍在消耗资源”,至少能知道取消停在了哪一层。
我暂时不会在界面上承诺“点击停止就不会再产生费用”。在服务商没有给出明确保证和可核对证据前,这句话超出了应用自己能确认的范围。
最后
最开始看“停止生成”,我以为只是给流式请求加一个取消按钮。
把链路拆开之后,才发现真正要管理的是两个连接、一个正在执行的生成任务,以及各自的资源生命周期。
浏览器的 abort() 让用户停止等待。后端收到断连后,要结束当前响应并关闭读取模型的上游连接。至于模型服务端是否立即停止计算,还需要服务商提供进一步的确认。
所以判断停止功能是否做完整,不能只看页面有没有停。更应该追问:后端的任务退出了吗?上游连接释放了吗?哪些结果是我们实际观察到的?
我是 1M55,我们下篇见!
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2501_94225300/article/details/169746922




