xiaogpt 怎么接管小爱音箱:轮询原理与 homelab 部署
目录
背景#
家里那台小米 AI 音箱是第一代,小米云里的 hardware 代号是 S12A,大陆版设备,绑的是大陆区账号;我在新加坡。homelab 里有一个 OpenAI 兼容的 LiteLLM 网关,后面是两台 DGX Spark 以 TP=2 跑的 Qwen3.8-Flash-Next(vLLM)。想做的事是让小爱回答问题时换成这个模型来答。
用的是 xiaogpt。这次有三条限制:不刷机、不拆音箱;人在海外,账号和设备都在大陆区;最终要跑在 homelab 的 K3s 里,配置进 git,由 ArgoCD 同步。我先在 Mac 上用 docker 跑通,再把它搬进集群。
xiaogpt 的工作方式:轮询小米云的对话记录#
xiaogpt 不跑在音箱上,也不跟音箱直接通信。它用 MiService(PyPI 上的包名是 miservice_fork)以小米账号登录,换到小爱音箱服务 micoapi 的 serviceToken,再去调小米云的 mina 接口。音箱照常把语音传给小米云,小爱照常回答,xiaogpt 在云的另一头看着:
主循环每轮请求一次对话记录接口,请求返回后处理不满 1 秒就补睡到 1 秒,所以周期是 1 秒加一次请求的耗时:
https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}×tamp={timestamp}&limit=2
每条记录里有 query(小爱识别出来的文字)、answers(小爱自己的回答)和毫秒时间戳,时间戳比上次处理过的新,就是一条新问题。要不要接管只看前缀:query 以配置里某个关键词开头才处理(query.startswith(keyword)),处理前把关键词去掉。默认关键词是「帮我」和「请」,我改成了「请问」「帮我」「回答」,所以「请帮我看一下天气」不会被接管:它不以「请问」开头(第二个字是「帮」不是「问」),也不以「帮我」开头(第一个字是「请」),三个关键词都匹配不上。
接管以后,mute_xiaoai 打开时的动作依次是:
- 调
player_get_status看音箱在不在播。返回里的info是一段字符串化的 JSON(源码对这个返回格式的注释原文是# WTF xiaomi api)。在播就通过 ubus 发player_pause,把小爱自己的回答打断。 - 让音箱先念一句
正在问ChatGPT请耐心等待。 - 把问题发给 OpenAI 兼容接口。配置里的
prompt会用逗号拼在会话第一个问题的后面,跟用户的话一起作为 user 消息发出去。这条首轮消息之后一直钉在历史里,此外只保留最近 5 轮,所以 prompt 对整个会话都有效,但它的身份是「用户说的话」,不是 system 消息。 - 用 mina 的
text_to_speech念出回答,念完按字数估一个时长再处理下一段。
mute_xiaoai 关着时,第 1 步换成固定等 8 秒,等小爱自己说完。use_command 是另一个独立的开关:打开后第 4 步改走米家的 miot action,型号到指令的对应表是 config.py 里的 HARDWARE_COMMAND_DICT,S12A 对应 ("5-1", "5-5"),前一个用于 TTS,后一个用于唤醒。
第 3 步的拼接方式决定了 prompt 适合写什么:只写对回答方式的要求(如口语化、控制字数、不要输出 Markdown 或表情符号),不要写身份声明。因为配置里的 prompt 是直接拼在首轮 user 消息末尾,而不是单独的 system 消息。
这个结构带来几个直接的后果。一次回答的延迟是轮询间隔(最多 1 秒左右)加小米云往返再加模型生成;小爱总会先开口,再被打断,这是旁路架构本身决定的。xiaogpt 拿到的只有小爱识别出来的文字,识别要是只听到「请问」,它就收到一个空问题:
问题:?
以下是小爱的回答: 哎呀,你说什么了,要不再说一遍呢?
以下是 ChatGPT 的回答:你好呀!我是你的家庭智能语音助手,很高兴为你服务。……
日志里显示的 ChatGPT 是 chatgptapi 这个 bot 的固定名字;它只认 OpenAI 兼容协议,api_base 指到哪就问谁。
另外两处行为跟后面的排障有关。text_to_speech 的异常被 except Exception: pass 吞掉,音箱没出声,日志里照样打「回答完毕」;对话记录接口返回空数据时,它一行日志都不打。日志安静证明不了它在工作,要确认只能用耳朵听,或者直接去问小米云。
「开始持续对话」用的是另一条链路:每答完一句,xiaogpt 要用米家 xiaomiio 服务的 miot 指令把音箱重新唤醒。单次问答只需要 micoapi 登录成功,持续对话还要 xiaomiio 也登录得上。
海外登录:密码登录被小米风控拒绝#
第一次在 Mac 上跑,启动就失败(账号已打码):
Exception on login <小米ID>: 'userId'
Traceback (most recent call last):
File "/app/.venv/lib/python3.12/site-packages/miservice/miaccount.py", line 74, in login
self.token["userId"] = resp["userId"]
~~~~^^^^^^^^^^
KeyError: 'userId'
接着是查设备列表时的:
Exception: Error https://api2.mina.mi.com/admin/v2/device_list?master=0&requestId=app_ios_…: Login failed
MiService 的 login() 分两步:先带着 token 文件里的 passToken 请求 serviceLogin,code 为 0 就直接换到 serviceToken;不为 0 才走 serviceLoginAuth2,提交账号和密码的 MD5。从我家的网络走到第二步,响应里没有 userId,代码在 resp["userId"] 这一行抛出 KeyError。xiaogpt 的 README 在常见问题里对这个报错的解释是「这是由于小米风控导致,海外地区无法登录大陆的账户,请尝试 cookie 登录」。
有 passToken?} B -->|有| C["serviceLogin
带 passToken"] B -->|没有| D["serviceLoginAuth2
提交账号 + 密码 MD5"] C --> E{code == 0?} E -->|"0"| F["拿到 serviceToken
写回 token 文件 ✅"] E -->|"≠ 0"| D D --> G{从哪登录?} G -->|海外 IP| H["风控拒绝
响应里没有 userId"] H --> I["KeyError: 'userId'"] I --> J["token 置空
os.remove token 文件"] G -->|"国内 IP(多数情况)"| F
办法是让它停在第一步。我在浏览器里登录小米账号后,从 cookie 里取出 passToken,连同数字 userId 写进 token 文件(issue #332 讨论的就是这个文件的格式)。xiaogpt 读的路径是 Path.home() / ".mi.token":
{
"deviceId": "0123456789ABCDEF",
"userId": 123456789,
"passToken": "V1:xxxx"
}
deviceId 填一个随机的 16 位字符串就行,MiService 在完全没有 token 时也是这样自己生成一个。登录成功后,换到的 serviceToken 按服务名(micoapi、xiaomiio)写回同一个文件。
这条路有三个副作用,后面的排障和部署都绕着它们走:
- token 文件决定登录身份。 只要
passToken有效,配置里的账号密码完全不参与登录,token 属于哪个账号,xiaogpt 就以谁的身份查设备。下一节的弯路就绕在这里。 - passToken 会轮换。 登录响应会带回一个新的
passToken,MiService 把它写回文件。跑过一次之后,文件里的值已经和我粘进去的不一样了;粘进去的那个在几分钟后的另一次登录里仍然有效,更早拿到的一个则已经返回code=70016。旧值多久失效我没摸出规律,所以把这个文件当成程序会改写的状态,不当成一次性的配置。 - 登录失败会删 token 文件。
login()的异常分支先把self.token置空,再调save_token(),后者在 token 为空时执行os.remove(self.token_path)。一个失效的 passToken 就能让文件消失,下次启动没有 token,退回密码登录,再被风控拒绝。
验证一个 passToken 时别调 login(),它失败后会顺带发起一次密码登录,而这一步在海外注定失败。只调第一步看 code 就够了:
import asyncio
import secrets
from aiohttp import ClientSession
from miservice import MiAccount
async def main():
async with ClientSession() as session:
acc = MiAccount(session, "unused", "unused", None)
acc.token = {
"deviceId": secrets.token_hex(8).upper(),
"userId": 123456789,
"passToken": "V1:xxxx",
}
resp = await acc._serviceLogin("serviceLogin?sid=micoapi&_json=true")
print(resp.get("code"), resp.get("userId"))
asyncio.run(main())
输出 0 加上自己的 userId 就是有效;70016 表示这个 passToken 不被接受,得回浏览器重新拿。
音箱一直显示离线:token 里是另一个账号#
用 passToken 登录成功后,xiaogpt 日志打出 Running xiaogpt now,之后再没有输出。对音箱说话,小爱回了一句「网络服务遇到问题」。
xiaogpt 不打日志,拿同一份 token 直接问小米云发现:mina 的设备列表里这台音箱的 presence 是 offline;通过 ubus 给它发一个只读的 player_get_play_status,返回:
{"msg":"Device is offline","code":608}
对话记录接口返回 0 条,米家的设备列表也是空的。
先查网络:小米在海外有加速节点#
人在新加坡,账号在大陆,最先怀疑的是跨境网络。音箱自己没问题:局域网里 ping 延迟约 6ms。问它内部的 dnsmasq 可以看到,语音识别 speech.ai.xiaomi.com 在海外有就近节点,IPv4 建连仅 7–13ms;米家长连接 ot.io.mi.com 从新加坡也能解析到专属加速 IP。小米给在海外的大陆版设备单独做了海外加速节点,网络通路本身没有问题。
token 里的 userId 和配置的账号不一致#
网络排除后,转向账号检查。音箱本身能正常与小爱对话,说明设备已连上小米云,只是不在 xiaogpt 查的那个账号下。这时对比 token 文件里的 userId 和配置里的 account:
config account : <账号 A>
token userId : <账号 B>
same account : False
配置里写的一直是账号 A,token 文件里存的却是账号 B 的 passToken。按上一节说的登录逻辑,只要 token 有效,xiaogpt 从头到尾都在以 B 的身份查;而音箱实际绑在账号 A 下,B 名下只剩一条旧的离线记录。换上账号 A 的 passToken 以后,设备列表里的 presence 变成 online,ubus 返回 code: 0,附带一句:
Msg has been successfully proxy to the device, this service is a simple proxy, if you encounter any problems pls contact ROM's developers directly!!!
对话记录里也出现了刚才问的两句天气。
教训:凭据文件里的身份能盖过配置时,排障第一步是确认程序实际以哪个账号登录,再去查网络。
部署到 homelab 的 K3s#
清单放在 homelab 集群的 personal-services namespace,由 ArgoCD 按目录同步,一共四个对象:ConfigMap、ExternalSecret、PVC 和 Deployment。前四个小节各讲一处部署里的取舍,最后一节是上线后怎么确认它在工作。
账号 / 密码 / key"] end subgraph K3s["K3s — personal-services"] ESO["ExternalSecret"] Sec["Secret"] CM["ConfigMap
prompt / model /
api_base / 关键词"] subgraph Pod Init["initContainer
seed-token"] App["xiaogpt"] end PVC["PVC
mi-token.json"] end subgraph External["外部服务"] Mi["小米云 micoapi"] LLM["LiteLLM → vLLM"] end Vault -->|"ESO 同步"| ESO --> Sec Sec -->|"env: MI_USER
MI_PASS …"| App CM -->|"volume"| App Sec -->|"volume /seed"| Init Init -->|"首次: cp → PVC"| PVC PVC -->|"subPath 单文件挂载
.mi.token"| App App -->|"登录后写回新 passToken"| PVC App -->|"轮询对话记录
TTS / ubus"| Mi App -->|"chat completions"| LLM
token 落盘:PVC 加 subPath 单文件挂载#
passToken 会轮换,所以 token 得放在 Pod 重建后还在的地方,Vault 里那份只能当首次启动的种子。此外,MiService 登录失败时会执行 os.remove 删掉 token 文件,而用 PVC 的 subPath 挂载成单个文件正好能挡住这一步:一方面单文件挂载点本身无法被 unlink(报 EBUSY),另一方面非 root 运行的 Pod 对挂载点父目录没有写权限。删除必然失败,进程虽然会异常退出,但 token 文件得以保留,避免了误删后退回密码登录的死循环。清单里对应的部分(节选):
initContainers:
- name: seed-token
command:
- sh
- -c
- |
set -eu
if [ -s /data/mi-token.json ]; then
echo "seed-token: keep existing token on PVC"
else
cp /seed/mi-token.json /data/mi-token.json
chmod 600 /data/mi-token.json
echo "seed-token: seeded from Vault"
fi
volumeMounts:
- name: data
mountPath: /data
- name: seed
mountPath: /seed
readOnly: true
containers:
- name: xiaogpt
env:
- name: HOME
value: /home/xiaogpt
volumeMounts:
- name: data
mountPath: /home/xiaogpt/.mi.token
subPath: mi-token.json
initContainer 只在 PVC 里还没有 token 时才从种子拷,已有的那份一定比 Vault 里的新,覆盖回去等于塞回一个可能已经失效的 passToken。文件名用 .json 结尾是给备份看的:worker 节点的夜间 restic 只捞 *.db*、*.sqlite* 和 *.json,叫 mi.token 就会静默不进备份。
单实例:Recreate#
两个 xiaogpt 同时在线,会各自轮询、各自回答,同一个问题念两遍,还会各自登录、各自轮换同一个账号的 passToken。所以 Deployment 只有 1 个副本,strategy 用 Recreate,滚动更新时新旧 Pod 并存的窗口也不留。
Mac 上那个 docker 容器也算一个实例。切换时的顺序是:先停掉 Mac 上的容器,再把它最后写回的 token 存进 Vault,最后才推清单。顺序反过来的话,交接那几分钟里两边会同时回答,也会各自轮换 token,存进 Vault 的那份可能一写进去就旧了。
配置拆成两半:ConfigMap 和 Vault#
xiaogpt 的 Config 里,账号、密码、LLM key、设备 ID 这四项的默认值分别取环境变量 MI_USER、MI_PASS、OPENAI_API_KEY、MI_DID。于是 prompt、模型、关键词、api_base 进 git 里的 ConfigMap,这四项放 Vault,经 External Secrets Operator 落成 Secret,再以环境变量注入:
env:
- name: MI_USER
valueFrom: { secretKeyRef: { name: xiaogpt-secret, key: mi-user } }
- name: MI_PASS
valueFrom: { secretKeyRef: { name: xiaogpt-secret, key: mi-pass } }
- name: OPENAI_API_KEY
valueFrom: { secretKeyRef: { name: xiaogpt-secret, key: openai-key } }
- name: MI_DID
valueFrom: { secretKeyRef: { name: xiaogpt-secret, key: mi-did } }
小米账号和设备 ID 算不上密码,但属于身份信息,一起放 Vault。ConfigMap 里的 JSON 不能再写这四个字段,配置文件里的值会覆盖环境变量。ExternalSecret 的写法和 external-dns 那篇里的一样,api_base 指向集群内的 LiteLLM Service(http://litellm.litellm.svc.cluster.local:4000/v1),不绕 Cloudflare 公网。
xiaogpt 只在启动时读一次配置。集群里 ConfigMap 挂载的文件虽然会随更新同步,但进程不会热重载,所以 Pod 模板上加了一个 xiaogpt/config-rev 注解,更新配置时递增该版本号触发 Deployment 重建。
运行身份、镜像和节点#
镜像的入口是 pdm run xiaogpt.py,以 root 运行。清单里改成 uid 1000,HOME 指到 /home/xiaogpt(token 路径跟着它走),命令直接用 venv 里的 python:/app/.venv/bin/python /app/xiaogpt.py --config /config/xiaogpt.json,不用考虑 pdm 在非 root 下要写哪些目录。镜像按多架构 index digest 固定:homelab 的 Kyverno 会拒绝 :latest 标签,而上游 latest(2026-02-24 推送)比最新的版本标签 v3.23(2025-10-26)还新。
Pod 用 nodeSelector 钉在 NAS 上的 worker 节点,确保出口使用家庭宽带原生 IP,避免机房 IP 触发小米风控。资源方面,空闲时常驻内存约 95–130MiB、CPU 2m 左右,request 给 160Mi、limit 给 384Mi,留足模型回答时的瞬时峰值余量。
上线后怎么确认它真在工作#
这套部署里有两处失败不会报错:TTS 出错被吞掉,对话记录为空也不打日志。只看 Pod 是 Running 不够,要按这个顺序看:
- initContainer 的日志:第一次启动应该是
seed-token: seeded from Vault,之后每次重建都应该是seed-token: keep existing token on PVC; - xiaogpt 的日志里出现
Running xiaogpt now; - 用同一份 token 问小米云:设备
presence为online,ubus 的只读调用返回code: 0; - 对音箱问一句「请问……」,用耳朵确认念出来的是模型的回答。
kubectl -n personal-services logs deploy/xiaogpt -c seed-token
kubectl -n personal-services logs deploy/xiaogpt -c xiaogpt --tail=20
其他技术方案:不只有轮询这一条路#
xiaogpt 的轮询架构带来两个绕不开的限制:小爱总会先开口再被打断,一次回答的延迟至少多一个轮询周期。这不是 xiaogpt 的 bug,是旁路方案本身的代价。如果想改善体验,得换一条路。目前能达到类似功能的方案分三类:同样走旁路的其他项目、刷机直接接管音箱硬件、彻底不用小爱音箱。
xiaogpt / mi-gpt / xiaobot"] A1["音箱"] -->|"语音上传"| A2["小米云"] A3["中间件"] -->|"轮询对话记录"| A2 A3 -->|"TTS / ubus"| A2 A2 -->|"播放"| A1 A3 -->|"chat"| A4["LLM"] end subgraph B["刷机方案
Open-XiaoAI Bridge"] B1["音箱
Rust 客户端"] -->|"音频流
局域网"| B2["服务端"] B2 --> B3["ASR"] B2 --> B4["LLM"] B2 --> B5["TTS"] B2 -->|"音频流"| B1 end subgraph C["自建方案
ESP32 + Home Assistant"] C1["ESP32 卫星
麦克风 + 扬声器"] -->|"音频流
局域网"| C2["HA Voice Pipeline"] C2 --> C3["Whisper ASR"] C2 --> C4["LLM"] C2 --> C5["Piper TTS"] C2 -->|"音频流"| C1 end
同类旁路方案:mi-gpt 和 xiaobot#
mi-gpt(Node.js)和 xiaobot(Go)跟 xiaogpt 原理一样,都是轮询小米云的对话记录接口,区别在功能侧重。mi-gpt 做了流式响应、角色扮演、长短期记忆和米家设备联动,文档和集成度比 xiaogpt 高一档;原版仓库已归档,社区 fork 出了 MiGPT-Next 继续维护。xiaobot 反过来走轻量路线,提供 Web 配置界面,TTS 直接用音箱原生输出,不走独立的语音合成服务。
三个项目共享同一个天花板:轮询间隔 + 小米云往返带来的 1–2 秒延迟,以及小爱先开口再被打断的交互模式。换哪个项目都一样,这是旁路架构本身决定的。
刷机方案:Open-XiaoAI Bridge#
Open-XiaoAI Bridge 走的是完全不同的路。给音箱刷固件、开 SSH,在音箱上跑一个 Rust 客户端直接接管麦克风和扬声器,音频流通过局域网送到服务端做 ASR → LLM → TTS,不经小米云。
好处是延迟显著低于轮询方案,可以自定义唤醒词,不存在小爱「抢答」。代价是需要刷机,会丢保修,有变砖风险。目前适配最好的型号是小爱音箱 Pro(LX06)和 Xiaomi 智能音箱 Pro(OH2P),我这台 S12A 第一代不在已知的适配列表里。
完全自建:ESP32 语音卫星 + Home Assistant#
第三条路是不用小爱音箱,用 ESP32-S3 开发板(ESP32-S3-BOX-3 开箱即用,或 ReSpeaker Lite + 功放模块 DIY)刷 ESPHome,配置成 Home Assistant 的 Voice Satellite。语音链路全部在局域网内完成:
- 唤醒词检测(OpenWakeWord)
- 语音转文字(Whisper,跑在 homelab 的 Mac Studio 上,走 MLX 加速)
- 问大模型(走已有的 LiteLLM → vLLM)
- 文字转语音(Piper)
ESP32 卫星本身只负责收音和播放,计算全卸到 homelab 里的机器上。不依赖小米的任何云端接口,不会因为小米改 API 或风控策略而失效。代价是要额外买硬件、配 Home Assistant 的语音管道,初始配置工作量比直接跑 xiaogpt 大不少。Mac Studio 适合在这条路里充当 ASR 加速后端:MLX Whisper 在 Apple Silicon 上跑 large-v3 模型实时率能到 10x 以上,这是 ESP32 做不到的,收音和播放交给便宜的 ESP32 卫星就够了。
小结#
xiaogpt 在小米云的另一头每秒轮询一次对话记录,按关键词前缀截下问题,再借 mina 的 ubus 和 TTS 接口打断小爱、念出模型的回答。它离不开一个能登录的大陆账号:在海外,密码登录过不了小米风控,只能用 passToken,而 token 文件里的身份优先于配置。这次排障绕的弯路就在这里,音箱绑在一个账号下,xiaogpt 拿着另一个账号的 token 在找它。部署进 K3s 时,passToken 会轮换、登录失败会删文件这两点,决定了 token 要放 PVC、用 subPath 挂成单文件,并且只跑一个实例。
旁路方案的天花板在轮询延迟和小爱「抢答」。如果想进一步,刷机方案(Open-XiaoAI Bridge)能把音频流拉到局域网内处理,但要看型号适配;ESP32 + Home Assistant 是不依赖小米云的终局方案,homelab 里已有的 Mac Studio 和 vLLM 可以直接充当语音管道的计算后端。
目前尚存的一个限制是米家(xiaomiio)服务登录尚未打通,「开始持续对话」功能暂时无法唤醒,后续仍需补充该服务的独立凭据。
参考资料#
- yihong0618/xiaogpt:README 常见问题里有海外登录那一条;轮询、关键词和打断逻辑在 xiaogpt/xiaogpt.py,型号指令表在 xiaogpt/config.py
- xiaogpt issue #332:讨论用 Cookie 登录和 token 文件的格式(2023-08-07 提出)
- yihong0618/MiService:PyPI 包
miservice_fork的源码,登录和 token 读写在 miservice/miaccount.py - idootop/mi-gpt:Node.js 实现的同类旁路方案,支持流式响应和米家设备联动
- coderzc/open-xiaoai-bridge:刷机方案,Rust 客户端接管音箱麦克风和扬声器,Server-Client 架构
- ESPHome Voice Assistant:ESP32 语音卫星的官方文档和适配硬件列表
- vincent861223/wyoming-mlx-whisper:在 Apple Silicon 上用 MLX 框架跑 Whisper 的 Wyoming 服务端