关联说明
本 issue 与 #733(优化流式响应异常处理:避免无条件 rollback,并开放 retry 行为配置)相关,但讨论的是不同层面的问题:
如果 #733 的建议被采纳,用户可以主动关闭自动重试,从而减少部分相关场景的触发概率。但即使关闭 retry,本 issue 关注的「取消后的 generation 生命周期管理」问题仍然独立存在。
问题描述
实际观察到的场景:
- 首轮流式回复输出到一半,内容突然消失,界面提示报错并进入自动重试。
- 用户在这个过程中点击了「停止」。
- 界面显示为「已停止」状态,但过了一段时间后仍然开始继续流式输出。
- 输出结束后,内容又整体消失。
- 部分情况下可以观察到额外的请求消耗。
核心诉求可以概括为:
用户点击停止后,当前 generation 应进入一个明确的终止状态;此后即使存在已经发出但延迟返回的数据,也不应再影响当前 UI 状态或写入历史记录。
代码层面的排查情况
查看 ClaudeProvider 中 cancelStreaming() 的实现:
override fun cancelStreaming() {
isManuallyCancelled = true
activeResponse?.let {
try {
it.close()
} catch (e: Exception) { ... }
}
activeResponse = null
activeCall?.let {
if (!it.isCanceled()) {
it.cancel()
}
}
activeCall = null
}
以及重试循环:
while (retryCount <= maxRetries) {
if (isManuallyCancelled) {
throw UserCancellationException(...)
}
val call = try {
...
client.newCall(request)
}
...
}
进一步核对后,可以确认:
- 重试循环在发起下一次请求之前会检查
isManuallyCancelled,因此最初关于「delay 结束后完全不检查取消状态,直接继续发起 retry」的推测并不成立,这一点需要更正。
- retry 流程与原始请求处于同一个
stream { ... } 协程结构内,但当前取消方式主要依赖 @Volatile 状态标志位和取消具体的 Call 对象,而不是通过结构化并发方式取消整个 generation 生命周期。
- 当前未观察到针对单次 generation / request 的独立生命周期标识或结果校验机制,用于判断某个返回的数据是否仍属于当前有效的生成过程。
因此,更准确的问题描述不是「停止一定无法阻止 retry」,而是:
当前实现缺少一个能够覆盖完整 generation 生命周期的终止与结果隔离机制。
如果某个请求在取消信号生效前已经进入网络层,或者某些异步结果在取消后才返回,目前没有看到一个独立的机制能够保证这些结果一定会被识别为「已失效 generation 的结果」并安全丢弃。
与其他客户端的对照
例如 RikkaHub 的停止实现:
suspend fun stopGeneration(conversationId: Uuid) {
val job = sessions[conversationId]?.getJob() ?: return
job.cancel()
runCatching { job.join() }
finishInterruptedPendingTools(conversationId)
}
这里取消的是整个任务生命周期,而不仅仅是某一次网络调用对象。
这种设计的核心区别在于:
- 取消请求中的网络操作;
- 取消等待中的 retry / suspend 状态;
- 确保取消完成后,不再允许该任务继续提交结果。
这类生命周期级取消机制,可以作为 Operit 当前实现的参考方向。
建议
这里的建议有明确主次:
1. 首要建议:让「停止」作用于整个 generation 生命周期
当前停止逻辑主要针对当前 Call 对象。
建议考虑让停止操作能够取消包裹整个生成流程的任务生命周期,而不是只取消当前网络请求。
这样可以同时覆盖:
- 正在进行中的请求;
- retry 等待阶段;
- 后续可能产生的继续处理逻辑。
具体实现方式可以由维护者根据当前架构决定,例如协程 Job 取消、任务 token、取消状态传播等。
2. 增加取消后的结果隔离机制作为兜底
即使请求取消无法在所有网络时序下做到绝对及时,也应该保证:
- 已经失效的 generation 返回的数据不会继续展示;
- 不会写入当前对话历史;
- 不会覆盖新的生成状态。
实现方式不限定,可以是 generation id、request token、状态校验等生命周期标识机制。
需要注意的是:
仅增加结果过滤只能解决「错误结果继续影响客户端状态」的问题,无法解决请求已经发出后的资源消耗。因此结果隔离应作为生命周期取消机制的补充,而不是替代。
已知的不确定性
- 当前无法确认观察到的「停止后仍继续输出」现象具体对应代码中的哪一个执行路径。
- 目前没有找到确定性的代码证据证明某一条固定路径必然导致该现象,因此本 issue 主要提出生命周期管理上的缺口和需要确认的保护机制。
- 已发出的网络请求是否能够被完全终止,与具体协议、连接方式以及上游服务行为有关,需要结合当前网络层实现评估。
修改范围
预计涉及:
- 停止操作与 generation 生命周期之间的状态管理。
- retry、stream、response 处理流程中的取消传播。
- 取消后的结果校验与历史写入保护。
具体实现方式由维护者根据现有架构评估。
环境
该问题在通过第三方反代 / 网关接入模型、上游响应延迟较高的情况下更容易观察到,因为「请求已经进入网络层但结果尚未返回」的时间窗口会被放大。
但问题本质并不依赖具体上游服务,而是:
用户的「停止」操作,是否能够可靠约束一个 generation 从开始到结束的完整生命周期。
关联说明
本 issue 与 #733(优化流式响应异常处理:避免无条件 rollback,并开放 retry 行为配置)相关,但讨论的是不同层面的问题:
如果 #733 的建议被采纳,用户可以主动关闭自动重试,从而减少部分相关场景的触发概率。但即使关闭 retry,本 issue 关注的「取消后的 generation 生命周期管理」问题仍然独立存在。
问题描述
实际观察到的场景:
核心诉求可以概括为:
代码层面的排查情况
查看
ClaudeProvider中cancelStreaming()的实现:以及重试循环:
进一步核对后,可以确认:
isManuallyCancelled,因此最初关于「delay 结束后完全不检查取消状态,直接继续发起 retry」的推测并不成立,这一点需要更正。stream { ... }协程结构内,但当前取消方式主要依赖@Volatile状态标志位和取消具体的Call对象,而不是通过结构化并发方式取消整个 generation 生命周期。因此,更准确的问题描述不是「停止一定无法阻止 retry」,而是:
当前实现缺少一个能够覆盖完整 generation 生命周期的终止与结果隔离机制。
如果某个请求在取消信号生效前已经进入网络层,或者某些异步结果在取消后才返回,目前没有看到一个独立的机制能够保证这些结果一定会被识别为「已失效 generation 的结果」并安全丢弃。
与其他客户端的对照
例如 RikkaHub 的停止实现:
这里取消的是整个任务生命周期,而不仅仅是某一次网络调用对象。
这种设计的核心区别在于:
这类生命周期级取消机制,可以作为 Operit 当前实现的参考方向。
建议
这里的建议有明确主次:
1. 首要建议:让「停止」作用于整个 generation 生命周期
当前停止逻辑主要针对当前
Call对象。建议考虑让停止操作能够取消包裹整个生成流程的任务生命周期,而不是只取消当前网络请求。
这样可以同时覆盖:
具体实现方式可以由维护者根据当前架构决定,例如协程 Job 取消、任务 token、取消状态传播等。
2. 增加取消后的结果隔离机制作为兜底
即使请求取消无法在所有网络时序下做到绝对及时,也应该保证:
实现方式不限定,可以是 generation id、request token、状态校验等生命周期标识机制。
需要注意的是:
仅增加结果过滤只能解决「错误结果继续影响客户端状态」的问题,无法解决请求已经发出后的资源消耗。因此结果隔离应作为生命周期取消机制的补充,而不是替代。
已知的不确定性
修改范围
预计涉及:
具体实现方式由维护者根据现有架构评估。
环境
该问题在通过第三方反代 / 网关接入模型、上游响应延迟较高的情况下更容易观察到,因为「请求已经进入网络层但结果尚未返回」的时间窗口会被放大。
但问题本质并不依赖具体上游服务,而是:
用户的「停止」操作,是否能够可靠约束一个 generation 从开始到结束的完整生命周期。