器人實(shí)戰(zhàn):異步調(diào)度與WebUI API集成指南)
Stable Diffusion 本地玩得再開心也只是一個(gè)人的游戲。真正讓它在團(tuán)隊(duì)里發(fā)光還得靠一個(gè)所有人都能伸手就用的入口。我這段時(shí)間折騰的 AIYA就是把 Stable Diffusion 包裝成一個(gè) Discord 機(jī)器人接口群友在聊天框里發(fā)一條斜杠命令機(jī)器人排隊(duì)調(diào)顯卡生成后把圖直接丟回群里。很多人第一反應(yīng)是“這不就是套殼”但實(shí)際做下來并發(fā)、交互、網(wǎng)絡(luò)、資源管理這些問題一點(diǎn)都不少。這篇文章會(huì)把 AIYA 從零到落地整個(gè)拆開包括架構(gòu)、關(guān)鍵代碼、部署和踩坑記錄給想自己做同類的朋友一份能直接抄作業(yè)的參考。AIYA 解決的痛點(diǎn)很明確不是所有人都愿意打開 Stable Diffusion WebUI 一點(diǎn)點(diǎn)調(diào)參也不是每個(gè)人都有能跑 SD 的顯卡。把它放在 Discord 上之后生成圖片變成了一次聊天交互命令天然帶參數(shù)圖片結(jié)果有存檔討論也都在頻道里比截圖傳文件高效得多。下面我按自己的實(shí)現(xiàn)順序從設(shè)計(jì)思路、任務(wù)調(diào)度、具體代碼到常見故障排查一條條說清楚。1. 項(xiàng)目概述與設(shè)計(jì)思路1.1 為什么要給 Stable Diffusion 做 Discord 接口大多數(shù)人一開始用 SD 都是在本地開 WebUI自己玩沒問題但一旦同事朋友也想“來一張”麻煩就來了。要么把 WebUI 暴露到公網(wǎng)要么讓對方自己裝一套環(huán)境結(jié)果顯存不夠、模型版本不一致、想復(fù)現(xiàn)同一張圖還得一層層對參數(shù)折騰一下午效率全沒了。Discord 機(jī)器人能把這套流程收斂成一次聊天操作大家在同一個(gè)頻道里互相參考 prompt機(jī)器人統(tǒng)一排隊(duì)出圖后還能直接回復(fù)調(diào)整參數(shù)這種體驗(yàn)比截圖溝通自然得多。另一個(gè)很實(shí)際的原因是人多之后顯卡資源需要分配。如果幾個(gè)人同時(shí)點(diǎn)生成WebUI 會(huì)同時(shí)開好幾個(gè)推理任務(wù)顯存很容易被撐爆輕則報(bào)錯(cuò)重則整卡掛掉。AIYA 做了一層調(diào)度器所有請求進(jìn)隊(duì)列一次只讓一張卡干一件事出完圖再接下一條。這個(gè)設(shè)計(jì)不是可有可無的裝飾而是保證多人場景下機(jī)器人能持續(xù)干活的根基。1.2 需求拆解一個(gè)機(jī)器人要管哪些事動(dòng)手寫代碼前我先把需求拆成了清單。AIYA 至少要覆蓋以下這些事情接收用戶輸入的 prompt 和常用參數(shù)比如步數(shù)、尺寸、種子、CFG。對這些參數(shù)做合法性校驗(yàn)非法值直接提示而不是甩給后端報(bào)錯(cuò)。把任務(wù)放進(jìn)全局隊(duì)列保證同時(shí)只有一個(gè)訓(xùn)練或生成任務(wù)占用顯卡。生成成功之后把圖片作為 Discord 消息附件發(fā)送失敗時(shí)告訴用戶失敗原因。管理員能限制每個(gè)用戶的使用頻率防止有人惡意刷圖把顯卡打滿。提供基本的狀態(tài)查詢方便知道當(dāng)前排了幾條任務(wù)、顯卡是否空閑。第 4 點(diǎn)和第 5 點(diǎn)很容易被忽略。很多人第一次做機(jī)器人只寫了“接收指令、調(diào)用 API、返回圖片”這三步上線后才發(fā)現(xiàn)某個(gè)人連著發(fā)二十條指令整臺服務(wù)器卡死或者 SD WebUI 已經(jīng)出錯(cuò)但機(jī)器人還在傻等最后用戶看到的是“命令無響應(yīng)”。把異常處理和頻率控制放在需求階段后面會(huì)省很多事。1.3 技術(shù)選型調(diào)用 SD WebUI API還是自己寫推理AIYA 的后端生成沒有自己寫采樣器而是直接調(diào)用 Stable Diffusion WebUI 自帶的 HTTP API。原因很簡單WebUI 已經(jīng)幫我們處理了模型加載、VAE、采樣器、Lora 這些底層細(xì)節(jié)我只需要向/sdapi/v1/txt2img發(fā)一個(gè) POST 請求傳進(jìn)常規(guī)參數(shù)就能拿到圖。這樣做風(fēng)險(xiǎn)低、文檔全、排查也容易。如果換成 ComfyUI也能做但要在 Python 代碼里維護(hù) workflow JSON參數(shù)映射更繁瑣適合本來就深度使用 ComfyUI 的團(tuán)隊(duì)。AIYA 的目標(biāo)是“把 SD 能力快速變成多人可用的服務(wù)”所以我優(yōu)先選了最穩(wěn)妥的路徑。機(jī)器人框架方面我用的是 py-cord而不是老牌的 discord.py。py-cord 對斜杠命令的支持更直觀bot.slash_command裝飾器直接聲明參數(shù)和類型Discord 客戶端會(huì)自動(dòng)生成參數(shù)輸入框比解析純文本消息可靠得多。網(wǎng)絡(luò)請求用httpx.AsyncClient因?yàn)樗钱惒綆觳粫?huì)像requests那樣阻塞整個(gè)機(jī)器人事件循環(huán)。2. 核心原理與架構(gòu)拆解2.1 一條指令從發(fā)出到出圖的完整鏈路AIYA 的一次完整生成流程是這樣的用戶在 Discord 輸入/aiya prompta silver fox in cyberpunk desert steps40Discord 服務(wù)器把這條命令事件推送給機(jī)器人進(jìn)程。機(jī)器人先調(diào)用ctx.defer()告訴 Discord “我收到了正在處理”避免客戶端顯示操作失敗。緊接著機(jī)器人把參數(shù)打包成一個(gè)任務(wù)對象放入asyncio.Queue這一步是瞬時(shí)的不會(huì)卡住交互。后臺有一個(gè)常駐的 worker 協(xié)程等待隊(duì)列里出現(xiàn)任務(wù)。拿到任務(wù)后它會(huì)向本機(jī) Stable Diffusion WebUI 的/sdapi/v1/txt2img接口發(fā)送請求。WebUI 完成推理后返回 base64 編碼的圖片worker 解碼保存到本地文件最后再通過filediscord.File(...)把圖片作為消息附件發(fā)送到頻道里。整個(gè)過程可以用一張表格概括每個(gè)環(huán)節(jié)的職責(zé)環(huán)節(jié)職責(zé)關(guān)鍵技術(shù)點(diǎn)Discord 網(wǎng)關(guān)接收命令、回調(diào)交互斜杠命令、Application CommandBot 交互層解析參數(shù)、返回狀態(tài)ctx.defer、ctx.respond任務(wù)隊(duì)列排隊(duì)、限制并發(fā)asyncio.Queue、SemaphoreSD 后端執(zhí)行實(shí)際推理SD WebUI HTTP API文件回傳保存圖片并發(fā)送Discord File 附件這個(gè)鏈路里最容易出問題的不是 SD 本身而是機(jī)器人異步邏輯。如果直接用同步代碼執(zhí)行 WebUI 請求整個(gè)機(jī)器人事件循環(huán)會(huì)被阻塞其他人發(fā)指令也沒反應(yīng)。所以從第二步開始所有耗時(shí)操作都必須放在異步函數(shù)里。2.2 異步任務(wù)隊(duì)列與多用戶并發(fā)Stable Diffusion WebUI 的常見部署方式是單進(jìn)程API 同時(shí)接收到多個(gè)請求時(shí)會(huì)各自嘗試加載模型并占有顯存最終出現(xiàn)CUDA out of memory。解決辦法不是把顯卡拆開而是讓所有任務(wù)串行化。AIYA 里我維護(hù)了一個(gè)asyncio.Queue任務(wù)生產(chǎn)者和消費(fèi)者完全解耦。用戶在斜杠命令里提交任務(wù)只是往隊(duì)列里塞了一個(gè)帶有asyncio.Future的任務(wù)對象worker 消費(fèi)隊(duì)列完成后把結(jié)果寫回 Future。這樣用戶協(xié)程可以await這個(gè) Future既能拿到結(jié)果又有超時(shí)控制。核心結(jié)構(gòu)大致是這樣class TaskManager: def __init__(self, concurrency: int 1): self.queue asyncio.Queue(maxsize100) self.semaphore asyncio.Semaphore(concurrency) self.worker_task asyncio.create_task(self._worker()) async def submit_and_wait(self, params: dict, timeout: int 240): loop asyncio.get_running_loop() future loop.create_future() job {params: params, future: future} try: self.queue.put_nowait(job) except asyncio.QueueFull: raise RuntimeError(當(dāng)前排隊(duì)任務(wù)已滿請稍后再試) return await asyncio.wait_for(future, timeouttimeout) async def _worker(self): while True: job await self.queue.get() async with self.semaphore: try: result await generate_image(job[params]) if not job[future].done(): job[future].set_result(result) except Exception as exc: if not job[future].done(): job[future].set_exception(exc) finally: self.queue.task_done()為什么用asyncio.Future而不是直接回調(diào)因?yàn)?Future 可以安全地跨協(xié)程傳遞結(jié)果用戶協(xié)程在await時(shí)會(huì)被掛起等 worker 拿到結(jié)果后再喚醒。隊(duì)列滿的時(shí)候用put_nowait拋出QueueFull直接告訴用戶“排隊(duì)滿了”而不是無限等待。2.3 資源控制并發(fā)限制、頻率限制與超時(shí)策略隊(duì)列本身只解決了“任務(wù)有序”還需要配合并發(fā)信號量控制同時(shí)推理的數(shù)量。AIYA 默認(rèn)concurrency1也就是同一時(shí)刻只允許一個(gè) SD 任務(wù)在跑。如果顯卡顯存比較大、模型比較小可以調(diào)整成 2但要注意 WebUI 后端多開任務(wù)時(shí)的穩(wěn)定性。我測試下來concurrency1是最保守也最不容易出問題的選擇。頻率限制我做了用戶維度。每個(gè)用戶每 30 秒只能提交一次生成任務(wù)防止一個(gè)人連發(fā)命令刷爆隊(duì)列。簡單實(shí)現(xiàn)如下import time last_used: dict[int, float] {} async def check_rate_limit(user_id: int): now time.monotonic() interval now - last_used.get(user_id, 0) if interval 30: raise RuntimeError(生成頻率太快請 30 秒后再試) last_used[user_id] now超時(shí)策略也很重要。SD WebUI 偶爾會(huì)卡死HTTP 請求無限掛起。我在調(diào)用生成 API 的httpx.AsyncClient里設(shè)置了 300 秒的超時(shí)同時(shí)給用戶協(xié)程的 Future 設(shè)置 240 秒的等待上限。一旦 Future 超時(shí)用戶會(huì)收到失敗提示worker 那邊的邏輯在下一次循環(huán)里自然丟棄異常結(jié)果不會(huì)影響后面的任務(wù)。3. 從零落地AIYA 的具體實(shí)現(xiàn)與部署3.1 準(zhǔn)備環(huán)境SD WebUI 開啟 API 模式AIYA 運(yùn)行在一臺帶 NVIDIA GPU 的 Linux 服務(wù)器上。先安裝好顯卡驅(qū)動(dòng)和 CUDA再按官方方式安裝 Stable Diffusion WebUI。啟動(dòng) WebUI 時(shí)一定要帶上--api參數(shù)否則/sdapi接口不會(huì)開放./webui.sh --api --listen 127.0.0.1 --port 7860 --xformers --medvram各參數(shù)的含義--api開啟 HTTP API--listen 127.0.0.1表示只監(jiān)聽本機(jī)地址因?yàn)闄C(jī)器人和 WebUI 在同一臺機(jī)器上不需要把端口暴露到公網(wǎng)--xformers開啟內(nèi)存優(yōu)化--medvram是中等顯存優(yōu)化類似情況下很管用。如果使用 Windows 上的 webui-forge 啟動(dòng)腳本run.bat卡在installing requirements這類階段通常和依賴下載網(wǎng)絡(luò)有關(guān)系可以多等一會(huì)兒或者手動(dòng)安裝對應(yīng)的requirements_versions.txt。接下來要?jiǎng)?chuàng)建 Discord 機(jī)器人。在 Discord Developer Portal 新建 Application添加 Bot復(fù)制 Token。邀請機(jī)器人到服務(wù)器時(shí)需要勾選applications.commands作用域并賦予發(fā)送消息、上傳文件、使用應(yīng)用命令這幾項(xiàng)權(quán)限。Token 要保存好后續(xù)通過環(huán)境變量讀取不要硬編碼進(jìn)代碼里。3.2 編寫機(jī)器人Slash 命令、參數(shù)解析與調(diào)度器AIYA 的主體代碼放在/opt/aiya/main.py下。機(jī)器人使用 py-cord 聲明斜杠命令參數(shù)直接用 Discord 的 Option 類型定義這樣用戶輸入時(shí)客戶端會(huì)自動(dòng)校驗(yàn)類型和范圍。import uuid import discord from discord.ext import commands from task_manager import TaskManager bot commands.Bot(command_prefix!, intentsdiscord.Intents.default()) task_manager TaskManager(concurrency1) bot.slash_command(nameaiya, description調(diào)用 Stable Diffusion 生成圖片) async def aiya( ctx: discord.ApplicationContext, prompt: discord.Option(str, 提示詞支持英文和自然語言), negative: discord.Option(str, 負(fù)面提示詞, default), steps: discord.Option(int, 采樣步數(shù), default30, min_value10, max_value80), width: discord.Option(int, 寬度, default768, min_value512, max_value1536), height: discord.Option(int, 高度, default768, min_value512, max_value1536), seed: discord.Option(int, 隨機(jī)種子-1 表示隨機(jī), default-1), cfg: discord.Option(float, CFG Scale, default7.0, min_value1.0, max_value30.0), ): await ctx.defer() try: params { id: uuid.uuid4().hex, prompt: prompt, negative_prompt: negative, steps: steps, width: width, height: height, seed: seed, cfg_scale: cfg, } result await task_manager.submit_and_wait(params, timeout240) await ctx.respond(filediscord.File(result[path])) except Exception as exc: embed discord.Embed(title生成失敗, descriptionstr(exc), color0xe74c3c) await ctx.respond(embedembed)這里最容易被忽略的是await ctx.defer()。Discord 的交互響應(yīng)只有 3 秒窗口如果 SD 生成需要半分鐘不先 defer客戶端早就顯示“交互失敗”了。defer 之后后續(xù)ctx.respond或ctx.followup.send都可以繼續(xù)使用這次交互。Slash 命令的好處是參數(shù)結(jié)構(gòu)固定。不需要自己寫解析--steps這類參數(shù)的邏輯Discord 客戶端會(huì)自動(dòng)處理空格和引號prompt 內(nèi)容也不容易被截?cái)?。如果后面想兼容普通聊天消息再單?dú)寫一個(gè)解析器也不遲。3.3 接入生成服務(wù)調(diào)用 SD WebUI 的 txt2img 接口生成函數(shù)的實(shí)現(xiàn)是把任務(wù)參數(shù)組裝成 WebUI API 需要的 JSON發(fā)送到/sdapi/v1/txt2img然后把返回的 base64 圖片解碼保存。import base64 import os import httpx async def generate_image(params: dict): api_url http://127.0.0.1:7860/sdapi/v1/txt2img payload { prompt: params[prompt], negative_prompt: params.get(negative_prompt, ), steps: params.get(steps, 30), width: params.get(width, 768), height: params.get(height, 768), cfg_scale: params.get(cfg_scale, 7.0), seed: params.get(seed, -1), sampler_name: DPM 2M Karras, } async with httpx.AsyncClient(timeout300) as client: resp await client.post(api_url, jsonpayload) resp.raise_for_status() data resp.json() image_b64 data[images][0] path os.path.join(generated, f{params[id]}.png) os.makedirs(generated, exist_okTrue) with open(path, wb) as f: f.write(base64.b64decode(image_b64)) return {path: path, info: data.get(info, )}需要說明兩點(diǎn)。第一images是一個(gè) base64 字符串列表常規(guī) txt2img 只需要取第一個(gè)如果 WebUI 開啟了批量生成會(huì)有多個(gè)。第二sampler_name不同版本的 WebUI 有輕微差異比如DPM 2M Karras是常用選項(xiàng)如果 API 報(bào)采樣器不存在可以在 WebUI 的界面里把選中采樣器的內(nèi)部名稱抄回來。返回的info字段里包含這次生成的真實(shí)參數(shù)和隨機(jī)種子需要做“一鍵復(fù)現(xiàn)”時(shí)可以把info解析并存到數(shù)據(jù)庫。3.4 部署與守護(hù)用 systemd 管理機(jī)器人進(jìn)程本地調(diào)試沒問題后把代碼放到服務(wù)器目錄。我習(xí)慣的目錄結(jié)構(gòu)是/opt/aiya ├── main.py ├── task_manager.py ├── config.py └── generated/運(yùn)行機(jī)器人時(shí)不能只靠nohup因?yàn)檫M(jìn)程崩了沒人會(huì)手動(dòng)拉起。我給 AIYA 寫了一個(gè) systemd 服務(wù)[Unit] DescriptionAIYA Discord Bot Afternetwork-online.target [Service] Userubuntu WorkingDirectory/opt/aiya EnvironmentDISCORD_TOKEN你的機(jī)器人Token ExecStart/usr/bin/python3 /opt/aiya/main.py Restarton-failure RestartSec5 [Install] WantedBymulti-user.target啟動(dòng)命令sudo systemctl daemon-reload sudo systemctl enable --now aiya systemctl status aiya日志用journalctl -u aiya -f查看。如果 Bot 進(jìn)程崩潰Restarton-failure會(huì)在 5 秒后重新拉起來。SD WebUI 也要保證開機(jī)自啟否則機(jī)器人還在跑但后端推理接口掛了所有生成任務(wù)都會(huì)失敗。我建議把 WebUI 也做成一個(gè) service并用Aftersd-webui.service指定啟動(dòng)順序。4. 常見問題與排查技巧實(shí)錄4.1 機(jī)器人無法上線或命令不響應(yīng)機(jī)器人不上線最常見的原因是 Token 錯(cuò)誤或網(wǎng)絡(luò)不通。先檢查代碼啟動(dòng)日志里有沒有Logged in as字樣。如果一直停留在連接階段可以用命令行測一下網(wǎng)關(guān)curl -I https://discord.com/api/v10/gateway正常會(huì)返回 200 或 400 系列的響應(yīng)。如果超時(shí)或連接失敗說明運(yùn)行環(huán)境和 Discord API 之間的網(wǎng)絡(luò)鏈路有問題。這種問題通常不是代碼 bug而是部署機(jī)器的網(wǎng)絡(luò)訪問條件受限。建議把機(jī)器人部署在網(wǎng)絡(luò)鏈路正常、能穩(wěn)定訪問 Discord API 的服務(wù)器上保證 HTTPS 出站暢通這也是最合規(guī)穩(wěn)妥的做法。命令不響應(yīng)還有一個(gè)很隱蔽的原因邀請機(jī)器人時(shí)沒有勾選applications.commands作用域?qū)е滦备苊罡緵]注冊到服務(wù)器。去 Developer Portal 重新生成邀請鏈接勾上這個(gè) scope再用新鏈接邀請一次就好了。4.2 并發(fā)出圖導(dǎo)致 CUDA 顯存不足我上線后第一個(gè)崩潰場景是群里三個(gè)人同時(shí)點(diǎn)生成WebUI 直接報(bào)CUDA out of memory。原因就是并發(fā)限制沒生效多個(gè) HTTP 請求同時(shí)進(jìn)入 SD API。解決辦法是把TaskManager的concurrency參數(shù)設(shè)為 1同時(shí)加上 WebUI 啟動(dòng)時(shí)的--medvram參數(shù)。如果還是爆顯存可以考慮降低默認(rèn)出圖分辨率。AIYA 默認(rèn)是 768x768對 6GB 顯存的卡已經(jīng)有點(diǎn)緊張??梢园达@卡實(shí)際情況把默認(rèn)調(diào)整到 512x768再把--lowvram加上。限制用戶側(cè)頻率也有用防止單個(gè)人在短時(shí)間內(nèi)批量提交 20 個(gè)任務(wù)。這里有一個(gè)細(xì)節(jié)Semaphore 控制的只是 AIYA 進(jìn)程內(nèi)的并發(fā)。如果外部有人直接訪問 WebUI 的 API 端口繞過 AIYA 也能觸發(fā)推理。所以我始終在 WebUI 啟動(dòng)參數(shù)里用--listen 127.0.0.1只允許本機(jī)調(diào)用。4.3 WebUI 返回 500、黑圖、采樣器報(bào)錯(cuò)速查SD WebUI 的 API 并不是時(shí)刻都穩(wěn)定常見報(bào)錯(cuò)基本可以歸納成下面幾類現(xiàn)象可能原因處理方式HTTP 500 Internal Server ErrorWebUI 進(jìn)程顯存爆掉或模型加載失敗查看 WebUI 日志確認(rèn)模型路徑、顯存占用圖片全黑或滿是噪點(diǎn)CFG 過高、采樣器不支持把 CFG 調(diào)到 7 左右換用 DPM 2M KarrasModuleNotFoundErrorPython 環(huán)境依賴缺失使用 WebUI 自帶的 venv 啟動(dòng)或手動(dòng)安裝對應(yīng) requirementsRuntimeError: CUDA error驅(qū)動(dòng)或顯存異常降低分辨率、開啟 medvram重啟服務(wù)器返回圖片尺寸不對傳入?yún)?shù)被 WebUI 拒絕檢查 width/height 是否為 64 的倍數(shù)如 768x768 是800x600 不是這里要特別提醒width和height必須是 64 的倍數(shù)。用戶傳 800x600 時(shí)WebUI API 可能會(huì)返回一個(gè)奇怪的編解碼錯(cuò)誤。AIYA 在參數(shù)校驗(yàn)時(shí)就約束了步長前端只能選 512、768、1024 這類數(shù)值。采樣器名字踩坑概率也很大。不同 WebUI 版本內(nèi)置的 sampler 名稱不完全一樣最好先從/sdapi/v1/samplers拉一份當(dāng)前環(huán)境支持的列表再用這份列表校驗(yàn)參數(shù)而不是像我第一次那樣寫死一個(gè)字符串。4.4 關(guān)于 Discord 交互超時(shí)和圖片大小限制的避坑Discord 對交互響應(yīng)有嚴(yán)格的時(shí)間限制如果不調(diào)用defer3 秒內(nèi)沒響應(yīng)就會(huì)失敗調(diào)用了defer之后還有 15 分鐘的窗口可以繼續(xù)回消息。所以我在所有生成型命令里第一件事就是await ctx.defer()然后才進(jìn)入隊(duì)列。另一個(gè)限制是附件大小。Discord 普通服務(wù)器單個(gè)文件上限是 8MBStable Diffusion 生成的高分辨率 PNG 很容易超過這個(gè)值。我的處理是在發(fā)送前用 Pillow 壓縮成 JPEGfrom PIL import Image def compress_to_jpeg(path: str, quality: int 85): img Image.open(path).convert(RGB) out_path path.rsplit(., 1)[0] .jpg img.save(out_path, JPEG, qualityquality) return out_path壓縮后的圖片體積會(huì)小很多畫質(zhì)損失肉眼幾乎看不出來。生成目錄也要定期清理否則長時(shí)間跑下來磁盤會(huì)被大量 PNG 填滿。我設(shè)了一個(gè)定時(shí)任務(wù)刪除兩天前的生成文件避免服務(wù)器出空間問題。最后分享一個(gè)我自己的小習(xí)慣在機(jī)器人里加一個(gè)/status命令返回當(dāng)前隊(duì)列長度、正在生成的任務(wù)數(shù)量、WebUI 是否在線。排查“命令明明提交了但沒出圖”這類問題時(shí)會(huì)非常有用不用每次都去翻 WebUI 日志直接在 Discord 里就能看到任務(wù)卡在哪個(gè)環(huán)節(jié)。AIYA 的核心其實(shí)不在 Stable Diffusion 本身而在“調(diào)度”和“交互”這兩個(gè)工程細(xì)節(jié)上把這兩件事做扎實(shí)機(jī)器人才能真正從玩具變成工具。