一、背景
2022年10月,一股AI绘画浪潮由太平洋的另一边汹涌而来,经领国的Noval AI而盛,再由B站、抖音席卷中国互联网。
对于从小就梦想绘画能够所思即所得的人来说,这种技术无异于魔法。恰好当时刚刚冒出了独立开发一个产品的想法但苦于没有方向,这阵东风让我毫不犹豫地决定:开发一款AI绘画应用。
二、前期调研
2.1 信息获取
那时AI绘画发展方兴未艾,各大搜索引擎上相关内容还很有限,前沿信息主要来源于B站和QQ群,传播模式主要为大佬摸索→发布教程→众人CV。
那段时间,B站铺天盖地都是AI绘画(当然和大数据推荐也有关系),各种部署、模型、调参、操作教程直接把B站整成一个AI绘画学习APP,把人直接看到AI绘画 PTSD。当时也潜入了各大QQ群中,从中“窃取”了不少优秀的模型和prompt文档。
2.2 市场分析
那时候有一个头部应用在一个月内实现了用户破百万,我至今依然记得加入他们的一个新建AI绘画群时看到的疯狂景象。几千人的群,每个人都在分享自己生成的AI图片,聊天记录像滚动的日志一样令人眼花缭乱。我从中看到了这片新兴市场的火爆——即便是个人,也大有可为。
那时AI绘画的网页和APP已有不少,但小程序只有10+个,我几乎挨个都看了一遍。使用过程中发现这些应用界面大多比较混乱,信息繁杂,一大堆的模型让刚进去的用户不知从何下手,更别提还有满屏幕的弹窗和贴片广告,以及强制登录,还有高到吓人的会员费。
在产出图形式上,他们主要集中于风景和真人,还有真人照片转二次元。风景的成图乏善可陈,真人大多都是恐怖片,最受欢迎的是照片转二次元(后来抖音也做了这块功能),但出图效果和我在QQ群里看到的还是有差距。我认为真实图片的AI绘画还有一段路要走,而二次元对各种瑕疵和缺陷的包容度是最高的,有时候三条腿多手指两个头反而会让人觉得很好笑。
2.3 产品定位
我当即决定主打二次元绘画方向,面向十几二十岁的年轻人,他们是对AI绘画这类新事物接受度最高的人群。考虑到这是一片小蓝海,大家都在抢跑,而我是独立开发,前期功能要尽量精简,优先追求完成而不是完美。AI绘画对于大多数人来说还很新,界面操作一定要尽量傻瓜,前期为吸引用户,保持免费无广告。至于应用的前端形式,APP成本太高,网页流量太少,小程序是支持以个人为主体的,上手难度也不高,目前竞争也不激烈,我很自然地就选择了小程序。
2.4 技术选型
还有一个最关键的模块——后端系统。当时主要有Novel AI和stable diffusion两大后端系统,前面说到这股AI绘画风潮就是自NovelAi而起。很多开发者很自然地选了这个系统来部署,优点是操作简单,无需调参就能得到质量很高的成图,但它是一个商业系统,并不开源,操作简单也意味着它的自定义程度很低。而stable diffusion是一个正在被持续维护的开源系统,每天都有好多人提issue,也有源源不断的pr和fix。Novel AI本身也是基于stable diffusion进行二次开发的,这也意味着Novel AI的模型可以直接拿来给stable diffusion用,只需调整stable diffusion的参数,就能得到近似甚至超越Novel AI的图片效果。于是我最终选择了stable diffusion。
三、产品功能
目前这款AI绘画小程序的大部分预想功能都已经实现了,并且已经停止运营。下面我将它展开铺平,逐个介绍各个页面功能。
3.1 首页
首页是一个简单的瀑布流,用于展示一些比较优秀的作品,这些图片都被存放在对象存储服务器中。顶部是可以实时更新的公告,主要用于展示版本更新信息和一些临时通知事项。
3.2 功能页-功能面板
“召唤”页只有文生图和图生图两大功能,用户进入界面后,直接点击“开始召唤”即可生成图片,或者也可以从上到下依次自定义:
- 提示词 —— 决定生成什么样的图片
- 图片尺寸 —— 这里只给了比例而不给实际尺寸,也是出于简化的目的
- 单次生成图片数量 —— 为减轻服务器压力,最多支持5张
- 基础大模型 —— 决定图片风格的关键选项
- lora —— 微调模型,最多支持叠加3个,可以依次选择不同百分比
- 上传图片 —— 如果想要图生图,就需要在此上传图片
点击商店图标可以购买用于批量生成图片的积分(单图免费),也可以查看自己的历史订单。历史订单中会标注每一个订单的状态,包含未激活、已关闭、未使用、使用中、已用完五个状态,扣除积分时,会按照 蓝药水→旧订单中的红药水→新订单中的红药水 这样的先后顺序,目的是保障付费用户权益——如果新订单中的红药水尚未使用,则支持全额退款。
商店图标下方是一个广告链接,点击后会触发微信小程序的激励广告,看完后可奖励积分,每日有观看次数上限。
最底部的高级设置用于满足高阶用户需求,为他们提供更多维度的自定义玩法。
3.3 功能页-图片生成流程
受限于现有的AI技术和硬件水平,AI绘画应用与普通应用最大的不同在于,它的核心功能请求是无法得到即时反馈的,或者说,它是长阻塞的。用户从点按“开始召唤”按钮,到得到最终图片,其间往往需要等待几十秒甚至几分钟的时间(有些资源富足的小程序只需要几秒),中间还需要经历词和图的两道审核,所以把每个阶段的结果及时响应给用户就变得非常关键。
考虑到单图和多图展示空间的差异性,会区分两种场景展示不同尺寸的图片。
3.4 功能页-MidJourney[1]
与“召唤”页面相比,“MJ”页面的交互体验会更好,进入页面时,系统会自动拉取用户当前生图任务的进度,例如排队进度(无上限)、图片生成进度(百分比)等,还会默认展示最近生成的一张图片。用户即便点击重新进入小程序,或者从移动端小程序切换到PC端,也能同步看到任务进度。MJ原生支持的命令参数和图生图模式,本小程序也都完整支持。
3.5 功能页-高清放大
“进化”页的功能比较简单,就是将图片进行原地高清放大,产出图相较原图会变高清,同时尺寸也会变大。不过为了防止GPU和带宽资源过载,限制了图片的最大尺寸和大小,同时会在小程序侧对上传图片进行预压缩,在服务端也会对产出图进行一定程度的压缩。
3.6 我的
“我的”页面主要用于为用户提供一个反馈入口,提供加客服企微和直接唤起小程序原生客服对话框两种方式。充电页面用于提供支付赞助功能。
四、系统架构
4.1 服务分布
图4-1 以三个不同文生图请求的路径为例,展示了本小程序后端服务的部署和交互方式。
我最终选择部署的后端系统是‣(以下简称SD),它其实具备了完整的WebUI,直觉上看拿来就能用,无需另外的架构设计。但我的需求是做一个完整的AI绘画小程序,涉及到翻译、鉴权、审核、支付等业务功能,而SD能提供的就只有生图api,这样其余功能都需要通过另外一个系统实现。
所以我最终的设计是,让每个SD服务都配合一个SpringBoot服务来使用,前者只负责跑图,业务功能由后者实现。但这种分工对前端是透明的,从业务层面看,它们是一个整体,实际它们也是被部署在同一台机器上的。从可用性角度来说,不同服务应当部署在不同机器上,但由于GPU服务器耗能巨大,一般云厂商都会为这类服务器分配较高的CPU和内存资源,而SD本身主要消耗的是显存,剩余的资源刚好留给SpringBoot服务,这样就免去了另外租用应用云服务器的成本。至于为什么一台服务器中会同时存在多个版本的SpringBoot服务,可见下文版本发布。
SD服务本身又该如何部署呢?用户想要尽可能多的大模型,但一个SD服务同一时刻只允许加载一个大模型,且不支持毫秒级切换模型的操作[2]。这意味着每个SD服务只能绑定一个大模型,用到几个大模型,就需要部署几个SD服务。
本小程序最高峰时一共用到了6个大模型,那就需要部署6个SD服务。这需要几台GPU服务器,每台部署几个服务呢?如果一台一个的话,图片的生成速度自然是最快的,但是由于SD本身自带了一个串行锁,同一时间只允许执行一个请求,所以这么做服务器的资源是无法得到充分利用的,在请求量较高时,反而可能造成串行阻塞。同时这么做也意味着,用到多少个模型就需要几台GPU服务器,这高昂的成本也不是多少开发者能够承受的。但如果单台服务器部署过多服务的话,请求数增多时,又容易造成显存溢出。
从请求量、生图速度、GPU服务器成本、大模型需求数量等因素综合考虑,我最终决定租用两台GPU服务器,每台部署三个模型(SD服务),同时将使用频次较高的热点模型均匀分配到两台机器上以均衡负载。两台服务器在一定程度上也能起到提高可用性的目的,结合预期收入来看,成本风险也在个人的承受范围内。
剩下的一些Nginx、frp、Redis、MySQL等中间件数据库服务都被部署在了同一台应用云服务器上(没错,原因同样是为了节约成本)。在这里Nginx的主要功能不在于负载均衡,而是将请求基于URL路径转发到对应版本的SpringBoot服务上,具体见下文版本发布;需要frp的原因是,GPU服务器是从AutoDL租赁的,他们对对外端口的限制较为严格,为了同时将多个服务暴露出去,只能借助内网穿透。
4.2 SD生图请求流程详解
下图展示了一个完整的图生图请求交互流程,包括其中涉及到的各个服务组件。
一个完整的流程包括以下几个关键步骤:
- 校验 —— 包括敏感词审核、请求频率校验、积分校验
- 翻译
- 获取入参图片
- 调用SD生图API
- 产出图审核
两个显得冗余的操作是翻译和获取入参图片。翻译需要请求Redis的原因是,各大厂商提供翻译API价格都是很昂贵的,而用户的操作习惯是,同样的词会连续跑几次甚至几十次,这对于API资源来说是巨大的浪费。一开始我还为翻译API付过费,接入缓存之后就再也没超出过免费额度。
而获取入参图片的操作就更显诡异,为什么不直接从对象存储服务器中获取呢?其实还是为了节约成本(没错,省钱这个理念是贯穿了整个开发周期的 🥲)。国内对象存储服务收费都很高,国外的服务在国内访问又有难以忍受的延迟,考虑到入参图片只需要临时存储,所以最终采用了将其直接存放在服务器本地硬盘上,再通过接口暴露出去的方案(当然做了一些访问路径的限制)。不同服务器可以通过接口相互访问,内网不消耗流量,且速度客观,实际实施起来是可行的。
4.3 MidJourney生图流程详解
4.3.1 反向代理
MJ是一个海外服务,只能通过discord访问。但既然discord能访问,我们自然也可以通过它暴露给discord的接口拿到鉴权信息,直接访问。这里我使用了国内的一个MJ代理开源项目‣,也就是图4-3中右下方的mj-proxy,它支持MJ原生的imagine和垫图等各种操作,还实现了排队进度和任务进度,为我们省去了大量与MJ官方接口交互的逻辑。由于国内无法直接访问MJ,MJ返回的图片URL也无法打开,因此需要将mj-proxy部署在一台国内能够访问、同时又能够访问MJ的VPS上,并在其上部署nginx,对最终成图的URL域名进行反向代理。
部署完成后,剩下的工作似乎就只剩下通过后端服务直接调用mj-proxy提供的API了,但图4-3纷乱的箭头告诉我们事情并没有那么简单。
4.3.2 排队
核心问题在于排队进度。由于事先调研了其它小程序MJ的使用量,可以预料到这个功能上线后会有大量用户使用,当请求量较高导致阻塞时,一定要提供一个良好的进度反馈体验。为此,我单独实现了一个排队机制。
前面说mj-proxy已经提供了排队进度功能,为什么不能直接用?原因有三:
1、MJ原生排队机制问题
MJ原生也是有排队机制的,就标准账号而言,可以允许有三个并发任务,但队列中最多只允许存放10个任务,再多就直接丢弃了(具体可以参考它们的订阅计划Midjourney Subscription Plans)。在这一点上,mj-proxy并没有做任何改进(当时版本)。这就与需求2和3不匹配了。
2、失败重试问题
由于服务宕机、网络异常或程序设计缺陷等问题,正常请求失败的问题总是存在的,而mj-proxy并不提供失败重试的接口。
3、共享账号问题
出于成本考虑,我使用的是淘宝上购买的MJ 6人共享账号,共享也就意味着,你并不知道某一时刻真正在MJ执行的请求有多少个,从MJ官方和mj-proxy中都没有找到此类查询接口。那就只能以服务本身发出的请求为度量,队列前3个请求的状态都认为是执行中,后面新增的请求都会进入排队状态。这种限制实际也是为了保障共享账号其它购买者的体验,我不能蛮不讲理地将队列一直占满,其它用户的手速一定是比不过程序的调用速度的。当然这也是为了规避被账号卖家拉黑的风险。
4、多账号问题
由于SD生图的压力一直很大,MJ自然就承担起了为SD分流的角色。因此只有一个账号是不够的,预估的时候我认为至少需要两个。当时mj-proxy的账号池功能还在TODO List里面,这样要实现多账号,就需要部署两个mj-proxy服务,每个服务都对应一个账号。这时候直接使用mj-proxy的排队功能的话,就会出现排队进度“跳变”的情况。因为两个mj-proxy是作为一个分布式集群被均衡访问的,用户可能首先访问的是其中一个服务,刷新过后又访问到了另外一个服务,而两个服务的排队进度并不相同。或者还可能会出现用户A先访问了服务A,用户B访问了服务B,但用户B的排队进度反而在用户A之前的情况。
基于上述问题,排队功能不能倚靠第三方,需要自行实现。
在设计阶段,我对排队功能的需求是:
- 实时展示某用户请求排队进度
- 排队人数无上限
- 已发起的请求不会丢失
- 失败请求会被自动重试
- 及时删除无效请求
首先对队列进行选型,将需求转换为对队列的要求:
- 根据需求一,需要通过对象属性获取对象位置
- 根据需求一,需要翻转队列中指定位置请求的状态
- 根据需求三,需要将数据持久化
- 根据需求五,需要删除指定对象
对以上需求进行归纳,会发现增删改查四个方面都涉及到了。虽然我需要的是“队列”,但其实数据库才是这个场景的完美适配者,只是阻塞和超时机制需要手动另外实现。考虑到性能因素,最后采用的是Redis和MySQL相结合的方式,Redis用来处理当前活跃请求,MySQL存储历史请求,更多起到归档作用。
在实际操作时,需要根据id和状态来定位请求,用Zset+Hash的形式可以达到最高性能,但增加了业务复杂度。用单纯的list也能够满足以上所有需求,因此最终选择了RList来作为队列类型。
4.3.3 任务定时健康检查
考虑到SpringBoot服务和mj-proxy服务异常重启、网络异常、程序bug可能导致的任务状态切换异常,设置了一个单点定时线程来对队列中的任务进行定期检查,纠正任务的异常状态,以及及时唤醒假死任务、删除死亡任务,保证整个队列健康运转。要知道一个账号就只有3个并发,一个假死任务可能会很长时间就占去一个并发名额,所以定时的健康检查是至关重要的。
4.3.3 连接方式
为了实时查看排队和生图进度,前端需要和后端保持频繁的请求交互。实现这种需求的常见方式有三种:
- 长连接
- 轮询
- WebSocket
微信小程序有它的特殊性,在切至后台后,所有HTTP请求都会被强制中断,所以长连接在小程序是无法使用的。轮询是一个可行的操作,本小程序的SD生图页面采用的就是这种方式,缺点是比较耗性能。WebSocket相对而言是最优解,所以在开发MJ模块的时候,就采用了这种方式。WebSocket不仅允许客户端向服务端发送请求,还能让服务端主动将请求推送到客户端,而且过程中都是复用一个连接,大大降低了性能损耗,非常适合MJ绘图这类需要频繁推送进度信息的业务场景。
4.3.4 多会话进度同步
我们知道,MJ生图是一个漫长的过程,用户很少会一直盯着界面,坚定而又耐心地等待着图片生成完成。更多时候,他们会切到后台、重新进入小程序,或者既打开移动端又打开PC端小程序,甚至在两端之间来回切换。我们要做的是,确保用户无论如何操作,排队进度和图片生成进度都能够准确而又及时地推送到他的面前。
一种简单直接的方式是使用分布式session,但4.3.3说到我们使用的是WebSocket连接方式,它的设计理念就是点对点。WebSocketSession是无法序列化的,意味着它不能被存入Redis,只能放在本机内存中。
用户每发起一次WebSocket连接,都会在内存中创建一个新的WebSocketSession,当同时访问多端时,他的session就可能会散落在不同机器上。而mj-proxy发起回调时,只会命中其中一台机器,无法实现全局session同步推送。
怎么解决?这里的实现方案是使用Redis广播。所有机器都订阅同一个topic,无论哪台机器被回调命中,只要被命中的机器将消息广播出去,所有机器就都能接收到最新的回调信息,再通过回调消息中的userId关联出所有活跃session(WebSocketSession的HttpHeaders中存放了JWTToken,从中可以获取到userId),就能实现全局同步推送。需要注意的是,session需要和userId提前绑定。在建立WebSocket连接时,客户端在HttpHeaders中存入JWTToken,服务端从中获取到userId即可将二者进行关联。
4.4 登录及身份校验
背靠微信生态,小程序的身份校验可以很简单。无需额外的个人信息,利用小程序提供的原生登录方式小程序登录 / 小程序登录 (qq.com)就可以获取到用户的唯一key,也就是openId。在本小程序前期运营阶段,出于保护用户隐私的考虑,没有让任何用户填写过邮箱、手机号等个人信息,整个用户体系就是基于openId。推出支付功能后,为了避免产生一些金钱上的纠纷,另外也为了在用户产生意外损失时方便联系追回,还是将用户体系转到了手机号上。但openId还是需要保留,和手机号是多对多的关系。相较于openId,手机号也具备更强的通用性,方便后续开发拓展到网页、APP等不同平台时同步用户信息。
手机号的获取也很简单,同样基于平台提供的原生登录方式用户信息 / 手机号 / 手机号快速验证 (qq.com)就能获取,且交互方式方便快捷(现在应该开始向开发者按次数收费了,之前是免费的)。
获取到openId后服务端会将其加密并转为JWTToken返回给客户端,同时返回的还有Token过期时间以及用户身份。客户端将这些信息都存入storage,在Token过期前3天向服务端请求申请更新。用户身份包含游客、会员和白名单用户。会员和游客的区别就在于提供过手机号,游客在使用需要消耗积分的功能时会受限制,在提供手机号后即成为会员,此后不会被再次要求提供手机号。
后续请求的请求头都需要携带Token,实现身份校验。
4.5 支付
小程序要想实现支付功能,接入官方的微信支付是正途,API文档见小程序调起支付 - 小程序支付 | 微信支付商户文档中心 (qq.com)
官方提供了一些SDK,以java为例使用 Java SDK 快速开始 - SDK&开发工具 | 微信支付商户文档中心 (qq.com),需要预先获取证书和密钥,按照官方文档操作即可。
支付是通过异步回调的方式完成的,但由于对微信支付的回调状态机不是很了解,官方文档对这块的描述也不够详尽,只有注意事项提示。
为了防止订单状态错乱,做了以下保护措施:
- 在回调方法中添加分布式锁(考虑到付费用户的体量,不存在性能问题)
- 不允许已进入结束态的订单被再次更新。
- 使用定时任务定时扫描未关闭的订单,将其自动关闭。
五、迭代发布
5.1 发布流程
5.1.1 客户端
5.1.2 服务端
服务端的SpringBoot服务都是通过jar包的方式手动部署的,每个版本都有对应的启停脚本,区别仅在于路径和端口号。只需手动执行启动脚本,即可完成新版服务上线。
#! /bin/bash
# springboot启动脚本
P_NAME=tna-service-2.4.9-param-RELEASE.jar
PATH_PREFIX=/root/autodl-tmp/ai/springboot
LOG_FILE_NAME=start_$(date +%Y-%m-%d).log
count=`jps |grep $P_NAME |grep -v "grep" |wc -l`
if [ $count -gt 0 ]; then
echo $P_NAME "正在运行中"
else
echo $P_NAME "未运行,开始启动..."
nohup java -jar -Dserver.port=7003 \
-Dloader.path=$PATH_PREFIX/lib \
-Dspring.config.location=$PATH_PREFIX/resources/application.yaml \
$PATH_PREFIX/$P_NAME \
>> $PATH_PREFIX/logs/start/$LOG_FILE_NAME 2>&1 &
count=`jps |grep $P_NAME |grep -v "grep" |wc -l`
if [ $count -gt 0 ]; then
echo $P_NAME "启动成功!"
else
echo $P_NAME "启动失败!"
fi
fi
echo "查看启动日志:\n"
echo "start:\n"tail -n 200 $PATH_PREFIX/logs/start/$LOG_FILE_NAME"\n"
tail -n 200 $PATH_PREFIX/logs/start/$LOG_FILE_NAME
echo "business:\n"tail -n 200 $PATH_PREFIX/logs/tna_$(date +%Y-%m-%d)_0.log还需同步执行守护脚本,用于防止服务意外停止。
#! /bin/bash
# author lyz
# time 2023-01-22 15:04:27
# springboot守护脚本
PATH_PREFIX=/root/autodl-tmp/ai/springboot
P_NAME=tna-service-2.4.9-param-RELEASE.jar
LOG_FILE_NAME=protect_log_$(date +%Y-%m-%d).log
count=`jps |grep $P_NAME |grep -v "grep" |wc -l`
if [ $count -gt 0 ]; then
echo `date "+%Y-%m-%d %H:%M:%S"` Checking >> $PATH_PREFIX/logs/protect/$LOG_FILE_NAME
else
# 若进程已经关闭,则拉起
echo `date "+%Y-%m-%d %H:%M:%S"` Starting >> $PATH_PREFIX/logs/protect/$LOG_FILE_NAME
sh $PATH_PREFIX/scripts_param/start.sh >> $PATH_PREFIX/logs/protect/$LOG_FILE_NAME
fi5.2 版本迭代
小程序在运营过程中免不了要修复各种bug,还要不断推出新功能以追赶市场。所以在运营初期版本迭代频率实际是非常高的,就这款小程序来说基本是一周一次,甚至一周几次。在版本迭代时,会遇到如下问题:
1、新版客户端上线后,旧版依然有流量,如何保证多版本客户端同时正常访问?
2、服务端切换版本时,如何避免服务中断?
3、临时突发bug,如何快速回退版本或无缝HotFix?
4、服务端测试版本和生产版本都在同一台机器上,如何相互隔离访问?
如果是基于K8S和Jenkins那样一套部署流程,上述问题自然不在话下。但对于小程序这类轻量服务,这样的流程显得太重,效率反而不如手动部署。
对于问题一,一个常用做法是做兼容,一个接口同时向下兼容多个版本。但这个方案实际实施起来操作成本很高,经常改一个接口要考虑多个兼容场景,在这么高的迭代频率下,对我个人而言不是很现实。
其实上述4个问题可以被简化成版本切换和多版本共存这2个问题。这两个问题都可以被一个方案解决:上线新版本时,同时保留后端服务的旧版本,前端小程序的多个版本和后端服务的多个版本一一对应。用户发起请求时,Nginx会根据url路径来区分路由,这样用户无论访问什么版本的小程序,请求都能够被正确路由到目标服务端口。一般来说,同时保留三个服务端版本是足够的,后面再发布新版本时,最旧的那个版本已经落后了半个多月,可以安全下线。另外很重要的一点是,Nginx修改配置是无需重启的,所以通过这种方式可以实现各版本间的无中断切换。完美解决以上所有问题。
基于以上方案,一次完整的版本上线操作流程如下:
- 开发测试阶段
服务端
服务端代码提交git,打包生成jar
修改启停脚本中的路径和端口号
rsync命令同步脚本和jar包到云端
运行jar启动脚本,启动新版测试版服务
修改nginx配置,将服务暴露到测试端口号
客户端
在小程序开发者工具中,基于测试版服务端服务,编写调试代码
上传代码,发起审核
- 上线阶段
服务端
运行旧版本jar停止脚本,停止旧版服务,释放端口号
修改nginx配置,将新版测试版端口号改为旧版刚释放出的端口号
客户端
登录小程序后台,点击发布按钮
六、踩过的坑
6.1 SD启动时添加了参数--medvram
当时为了节约显存添加了这个参数,但添加后进程对内存的占用高得离谱,而且无法回落,这个在生产环境绝对是不建议添加的,只有在本地硬件条件较差的机器上测试时,显存过小无法正常运行才需要添加。
6.2 SD高频率出现黑图
出现黑图的原因是半精度浮点溢出,在启动参数后添加-no-half-vae,禁用vae的半精度模式可以解决,官方wiki有对各启动参数的完整说明。
6.3 服务器时间不一致
在将服务迁移到AutoDL的GPU云服务器时,大量用户反馈登录异常。查看日志发现JWTToken大量报错,提示Token校验时间早于生成时间。经排查发现是两台服务器时间不一致(用户在时间较晚的机器上生成了Token,下一个请求又去时间较早的服务器校验)导致,当时急于修复bug,校正服务器时间有点繁琐,由于只影响了token的生成,在生成JWTToken时取巧,将签发时间统一提前了10min,覆盖了两台服务器的时间差值。但后续租用云服务器时,要首先注意不同服务器自己的时间问题。其实服务器之间的时间同步一直也是分布式问题中的一个难点,无法做到完全同步,但至少不应该出现相差几秒甚至几十秒的情况,这方面大的云服务器厂商会做得好一点。
6.4 小程序被关小黑屋
小程序有比网页APP等其它客户端更为严格的审核机制,每次发布都需要人工审核,也会针对用户反馈进行审核。在运营初期时因为只关注于新增内容,忽略了审核的重要性,被用户举报,直接关了7天小黑屋,期间小程序0曝光,0新用户。后来想要给图片加AI审核,发现各大厂商的API都非常贵,好在微信官方也是提供了内容安全识别API的,在接入之后,就没再出现过审核相关问题了。
6.5 为了接入支付无奈变更主体
个人小程序无法做支付!建议一开始就以公司或个体工商户为主体!我就是一开始以个人为主体,后来为了接入支付功能,被迫舍弃已有的用户、好评、星级和流量,新申请了一个小程序(这就是为什么图4-1中会同时出现新旧两个版本的小程序)。
七、一些技巧
7.1 办理营业执照
接入支付功能需要以公司或个体工商户为主体,而这些主体认证都需要营业执照。营业执照可以在淘宝上办,如果怕信息泄露,可以找熟人。营业执照的办法不同城市不同,有些会要求户口或暂住证。在申请营业执照时,会需要填写经营场所,像淘宝或拼多多这类电商平台,会为商户提供线上经营场所证明,但是,小程序是没有的!但好在它属于线上平台,你以自己身份证上的住址来作为经营场所也是可以的。
还有一点要说明的是,在用营业执照认证主体时,记得认证微信公众号,不要直接认证小程序,如果你没有微信公众号,那就申请一个。因为一个公众号可以关联多个小程序,只要公众号经过认证,关联的小程序就都会自动成为已认证的小程序。但是你如果直接认证小程序,那么就只有那个小程序是经过认证的。
7.2 如何挑选GPU
挑选GPU时,只需看半精度,影响生成速度的主要是半精度算力。另外关注下显存,请求量不太大的话,24G显存是足够部署三个SD服务的。当然了,还需要看下价格。
7.3 SD优化参数
以下参数是以Novel AI为模板调的,调完后就能生成接近甚至超过Novel AI的图片。当然了,和使用的大模型及Lora也有很大关系。
点击Settings 选项卡
Stop At last layers of CLlP model改为2
Eta noise seed delta 设置为31337
eta (noise multiplier) for ancestral samplers 设置为 1
点击apply settings八、部署与迁移
8.1 SD部署方式
8.2 迁移
服务部署完成后,自然是希望不再迁移,但服务器总有到期的一天,也总会有更廉价的服务器出现,所以迁移在开发过程中总是无可避免的。
8.2.1 MySQL数据库迁移
数据库迁移最重要的是备份,最好定期将SQL导出做多端备份。
# 备份
# 按库导出
mysqldump --single-transaction --master-data=2 --quick -u root -p db_name > db_name.sql
# 按表导出
mysqldump --single-transaction --master-data=2 --quick -u root -p db_name table_name > table_name.sql
# zip压缩
gzip db_name.sql
# rsync 限速传输
rsync -avz --bwlimit=600KB db_name.sql.gz user@ip:/root/path
# 恢复
gunzip db_name.sql.gz
mysql -u root -p db_name < databases.sql8.2.2 SD服务迁移
安装pytorch
迁移时,注意新老服务器CUDA版本差异,要根据实际的CUDA版本安装对应的pytorch,可通过官网查看不同CUDA版本对应的pytorch版本命令
# 查看当前服务器实际安装的Cuda版本
nvcc --version
(
需要安装nvidia-cuda-toolkit
或
export PATH="/usr/local/cuda-11.4/bin:$PATH"
export LD_LIBRARY_PATH="/usr/local/cuda-11.4/lib64:$LD_LIBRARY_PATH"
)
# 查看当前服务器支持的最高版本的cuda
nvidia-smi
# 查看当前服务器预装pytorch版本
python -c "import torch;print(torch.__version__);print(torch.cuda.is_available());print(torch.version.cuda)"
# 安装CUDA 11.3版本对应的pytorch版本
pip --default-timeout=600000 install torch==1.11.0+cu113 torchvision==0.12.0+cu113 torchaudio==0.11.0 --extra-index-url https://download.pytorch.org/whl/cu113
# 查看安装后的pytorch版本
python -c "import torch;print(torch.__version__);print(torch.cuda.is_available());print(torch.version.cuda)"安装SD
SD服务迁移的关键在于确保新旧服务commit一致。平时没事不要pull,不求最新,但求稳定。
# 在旧机器执行
# 查看当前commit
git rev-parse HEAD
# 在新机器执行
git clone https://github.com/AUTOMATIC1111/stable-diffusion-webui.git
cd stable-diffusion-webui
# 切换到相同commit
git checkout 30b1bcc64e67ad50c5d3af3a6fe1bd1e9553f34e九、结语
从敲下第一行代码到停止运营,从2022年10月至2023年8月,次元吟小程序一共存活了10个月(未满一年略感遗憾)。这个小程序是我第一个独立开发的应用,之前几乎没接触过前端代码,没接触过小程序,更没有接触过AI绘画,可以说每一步都是第一次。过程中我舍弃了无数个周末外出的机会,上下班的路上也都在敲代码(不夸张地说,有20%的内容都是在地铁上开发完成的)。用户半夜报bug,马上起来修复也是常事,更别提还有被营业执照、变更主体、各种认证折腾得焦头烂额的时候。但是回头看这一切都非常值得,这是我人生中独一无二的体验,我得到了很多用户的认可,收到了不少真诚的赞赏红包,认识了各个行业的朋友,学到了不少新技术,还享受到了自由创造的迷醉感。
写这篇文章的时候,为了尽量还原细节,我把代码又从头到尾看了一遍,甚至把关掉的服务器又重新打开。过去的日日夜夜像幻灯片一样又重新展示出来,令人感慨恍惚。隔了两个月才开始记录,也是因为怯于回忆面对。最终决定这么事无巨细地列出来,不只是为了给同样对AI感兴趣、有意做小程序独立开发的小伙伴们一点借鉴,也是为了给自己留下一个尽量完整的回忆。
十、注
[1]MidJourney,简称MJ,是AI绘画应用中公认做得最好的(现在不一定),大概在2023年5月的时候大放光彩。由于国内无法访问,且交互界面寄生于discord,对于国内用户来说,使用有一定门槛。国内各个AI应用就打起了MJ API的主意,纷纷将MJ接入到自己的应用中,借此又收割了一波会员费。
[2]不知道目前是否支持,当时只有一个提供切换操作的api,但响应速度很慢,无法满足在不同用户请求之间无感切换的需求。当时看了官方wiki和各种issue也没找到解决方案。











