“韧流不息”
利用文件系统实现 SSE 流式断点续传 - 暨 MolaGPT 开发日志 其(七)
作为 MolaGPT 的开发者,我在日常也会使用 MolaGPT,因为它相比中国大陆可用的 LLM Chat 服务有着无与伦比的优势。偶然一天,我发现弱网环境下的用户体验存在一个显著短板:当我在网络良好的时候询问 MolaGPT 任意问题,在 MolaGPT 生成回答的时候,网络突然出现抖动,这时候 SSE 流有很大概率出现中断情况且不可恢复。这是由我后端的 PHP 程序特性所决定的,一旦用户连接断开,PHP 进程会被结束,正在回答的对话过程会立即丢失,用户不得不刷新页面重新开始。
为了解决这个问题,我设计并实现了一套包含前后端协作的 SSE 断点续传机制。其核心目标是保证在网络波动甚至中断的情况下,会话能够自动恢复,且整个过程对用户透明。
利弊权衡
在设计缓存方案时,通常的首选是 Redis 这类内存数据库。但考虑到 MolaGPT 运行在一台资源受限的 VPS 上,部署 Redis 不仅增加了运维成本,其内存占用也是一个很大的负担,因为我平常在使用 IDE 远程开发的时候就已经让这台 VPS 叫苦不堪,再加上个 Redis 恐怕连最基本的 SLA 都保持不了了。此外,Redis 的安全性问题也是需要斟酌的点,尤其是在公网暴露的场景下,Redis 默认配置缺乏加密与访问控制,极易成为攻击入口。
基于实用主义和成本效益的考量,我决定采用基于文件系统的持久化方案。利用 PHP 原生的文件操作构建一个轻量级的流式缓存管理器。这种方案虽然原始,但在当前的并发规模下,它具备极高的可靠性且几乎不占用额外的内存资源。
架构设计
我的核心设计思路是将每一次流式响应视为一个可恢复的独立会话。这套机制由四个核心部分组成一个完整的闭环:

其主要分为四大块逻辑,前端生成 ID 后,后端进行同步写入。如用户侧出现网络抖动,前端逻辑可根据出现错误的 offset 来快速获取最新的 SSE 并构造 SSE 事件重新产生接续恢复。
- 前端控制层: 负责生成唯一会话 ID,监控流状态,并在连接中断时发起带有偏移量的恢复请求。
- 后端转发层: 充当中间件,在向前端输出数据的同时,同步将数据写入磁盘缓存。
- 持久化层(Cache): 为每个会话维护两个文件,.data 用于存储原始数据流,.json 用于存储元数据和状态。
- 恢复逻辑: 基于偏移量读取缓存,并通过轮询文件增长实现增量数据的补全。
双路处理与同步写入
后端的 StreamCacheManager 类是整个机制的基石。在正常流式对话流程中,当后端从大模型接收到 SSE 数据块时,系统执行一个关键的双路处理操作。
function emit_and_cache($content, $session_id, $cache_manager) {
//构造标准 SSE 格式数据
$sse_data = [
'id' => uniqid('msg_'),
'object' => 'chat.completion.chunk',
'created' => time(),
'choices' => [[
'delta' => ['content' => $content],
'index' => 0,
'finish_reason' => null
]]
];
// 格式化为 SSE 协议格式
$chunk = "data: " . json_encode($sse_data, JSON_UNESCAPED_UNICODE) . "\n\n";
// 1: 实时发送给前端
echo $chunk;
flush();
// 2: 同步写入缓存文件
if ($session_id && $cache_manager) {
$cache_manager->appendChunk($session_id, $chunk);
}
}
这个双路处理确保了原子性:系统在通过 echo 将数据块实时转发给前端的同时,会同步调用 appendChunk 将其追加到服务器的本地缓存文件中。这一步是后续所有恢复操作的数据基础。
前端检测与指数退避
在前端,我利用 Fetch API 的 ReadableStream 来处理数据流。这使我们能够精确捕获网络中断抛出的错误。
streamRecoveryManager 对象负责管理当前的会话状态。当监测到流中断时,它不会盲目地立即重连,而是进入一个等待周期,这能有效防止在网络抖动剧烈时,因频繁重试而对服务器造成不必要的冲击。
const streamRecoveryManager = {
currentSessionId: null,
receivedMessageCount: 0, //记录 Offset
retryCount: 0,
isRecovering: false,
};
//处理流中断
async function handleStreamInterruption(error) {
if (isStopped || !streamRecoveryManager.enabled) return;
streamRecoveryManager.isRecovering = true;
streamRecoveryManager.retryCount++;
await sleep(3000);
if (!isStopped) {
try {
await resumeStream();
} catch (resumeError) {
//恢复请求如果也失败了,递归重试
handleStreamInterruption(resumeError);
}
}
}
//发送恢复请求
async function resumeStream() {
const resumeData = {
action: 'resume',
session_id: streamRecoveryManager.currentSessionId,
offset: streamRecoveryManager.receivedMessageCount //发送位置
};
const response = await fetch(streamRecoveryManager.currentApiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(resumeData)
});
//如果后端返回 410,说明会话已被用户主动停止
if (response.status === 410) {
console.log('Ended');
streamRecoveryManager.clearState();
return;
}
//恢复成功
if (response.ok) {
processResponse(response, true);
}
}
等待结束后,前端会构建一个包含 action: 'resume'、session_id 以及当前已接收消息数量 offset 的请求发送给后端。
增量回填与轮询追更
当后端接收到恢复请求,系统会进入恢复模式。逻辑并非重新请求大模型,而是对缓存文件进行追更(此处“追更”有坑,下文会讲):
- 状态验证: 首先检查缓存层中该会话的状态。
- 历史回填: 若会话有效,系统根据
offset从缓存文件中读取客户端缺失的那部分数据,并一次性发送给前端。 - 轮询新数据: 发送完历史数据后,恢复脚本进入一个循环,持续轮询缓存文件是否有新数据写入,并增量发送给前端。
坑 1:并发读写下的缓存文件锁死
在早期的实现版本中,我忽视了一个关键的边界情况,当用户侧断开连接时,服务端的原始 PHP 进程并未立刻终止,它仍在接收大模型的数据并持续写入文件(持有写锁)。此时,前端发起的 resume 请求试图读取同一个文件(申请读锁),这种模型生成中的并发冲突导致了很多时候回复机制根本不起作用。
经过分析,这是由于我一开始未对文件锁的在边界条件下的处理进行优化,导致 resume 请求被系统阻塞,前端表现为一直加载,直到原始进程完全结束写入(即模型回复完毕)后,恢复请求才能拿到数据。这完全违背了“实时恢复”的初衷。
为了解决这个问题,我优化了后端的读写策略:将写入操作的锁定时间压缩至仅在写入瞬间锁定,并允许恢复进程尽可能以非阻塞,再不济就共享锁的方式读取数据。这确保了即使大模型仍在生成,前端也能实时“追更”到最新的内容。
坑 2:用户主动停止
这是个顺手发现和解决的问题,我在前端有一个停止按钮,这个按钮的作用就是在用户单击时,停止前端流式输出的同时中断 Fetch 请求,然后不关闭后端的线程。但是在恢复机制加入后,当用户主动点击停止按钮时,前端中止请求会被错误识别为网络异常,从而触发自动恢复机制。
为了解决这个问题,我引入了明确的停止状态流转:
- 前端: 用户点击停止时,除了中止 Fetch 请求,还会向
/api/auth/stop_stream.php发送一个异步信号。 - 后端: 接收到停止信号后,将缓存层中的会话标记为
stopped。 - 校验: 任何恢复请求在执行前,都会先检查该状态。如果发现会话已标记为停止,后端直接返回 HTTP 410 Gone 状态码,前端接收后将彻底终止重试逻辑。

通过这套基于文件系统的断点续传机制,MolaGPT 在较低的资源消耗下实现了较为可靠的回答可靠性。由此可见,系统的韧性并非总源于昂贵的资源,更在于因地制宜的架构设计。这套基于文件系统的方案,正是在局限中构建可靠性的一次成功实践。
在日常的开发和运维中,只要架构设计得当,依然可以交付鲁棒性高的用户体验。

发表回复