Runbook · 2026-08 实测

小智全栈装机手册

把小智服务端和四个配套服务,远程部署到一台国内网络环境下的 Windows 机器。 这份文档写给执行部署的 agent——每一步都给到可直接执行的命令、验证方式,以及上一次实际踩到的坑。

目标环境
Windows 10/11 + 国内网络
服务数量
5 个
远程方式
Tailscale + OpenSSH
上次实测
2026-08-25
00

开工前:给 agent 的执行须知

你要操作的是别人的电脑。机主已经授权,但这不代表可以随便动——下面三条是硬规矩,上一次部署时每一条都付出过代价。

规矩一 · 不要把委托人的账号发过去

音乐服务的 API key、小智服务端的 LLM/TTS 凭据、B站和小宇宙的登录态,一律让机主自己注册、自己扫码。 你的角色是把服务跑起来并留好填凭据的入口,不是把自己这边的账号复制一份过去。

规矩二 · 打包前必须读 .gitignore,不要凭记忆列排除项

三个私有服务要从委托人的机器打包传过去。上次我凭记忆写 --exclude, 漏掉了 app/allow.json(设备 MAC 白名单),把它一起打进包传到了对方机器。 正确做法:tar 前先 cat .gitignore,按文件里写的来;打完包先列一遍包内容确认没有 state/cookies/凭据,再传。

规矩三 · 私有仓库 clone 不了,只能打包传

下面五个服务里有三个是私有仓库(git@github.com:tankxu/…),对方机器没有权限。 唯一的路径是在委托人的机器上 tarscp → 对方机器解压。 公开的那两个(小智服务端、CLIProxyAPI)可以直接在对方机器上拉。

开工前要跟机主要到的东西

要什么用途拿不到的后果
Tailscale 上的 IP建立 SSH 通道只能靠 ToDesk 手动点,效率差一个数量级
Windows 用户名配置计划任务的 -UserIdDocker 拉镜像那一步会卡死
局域网 IP(建议固定)设备要连的地址DHCP 一换设备就失联
是否装了代理软件排查网络问题的前提会把代理造成的现象错怪到别的地方
大模型 / TTS 的 key小智服务端服务能起但不出声

机器硬指标

上次那台是 Win10 专业版 22H2 / 16 核 / 32G / C 盘剩 125G,跑满五个服务无压力。 家庭版装不了 Hyper-V,但 WSL2 可以,所以家庭版也能跑——只是 Docker Desktop 只能走 WSL2 后端。 磁盘至少留 40G。

01

建立远程通道

先把 SSH 打通,后面所有操作都在 SSH 里做。ToDesk 只在两个场景用:装 Tailscale 之前的引导,和后面需要人在桌面上点确认的时刻(Docker Desktop 首次启动、B站扫码登录)。

验证

从你这边连过去,应当不问密码直接进,并且 IsAdminTrue

ssh w@100.x.x.x "powershell -c \"([Security.Principal.WindowsPrincipal] \
  [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrators')\""

别把 sshd 的 DefaultShell 改成 PowerShell

网上教程很爱这么写,但那会破坏 scp——而三个私有服务全靠它传过去。 保持默认的 cmd.exe。需要 PowerShell 时用 ssh host 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\Windows\Temp\x.ps1', 把脚本先 scp 过去再执行——这样也顺便躲开了在 SSH 命令行里拼引号的地狱。

这条通道重启后依然可用

sshdtailscaled 都是系统服务,开机自启,不需要有人登录 Windows 桌面。 但下一节的 Docker Desktop 是用户级程序——机器重启后必须有人登录桌面,容器才会起来。这个区别后面会反复咬人。

02

四个 Windows 专有坑

这一节放在装东西之前,因为它们是整个部署里最耗时间的部分。上次五个服务里,每一个都在其中某一条上卡过。 先读完再动手,能省掉大半天。

如果 DISM 报「错误 2 找不到指定的文件」,直接跳到坑四

那一条比其余三条都靠前、都致命:WSL2 根本装不上,整条 Docker 路线归零。 它和功能名、Windows 版本、payload 都没关系,五秒钟就能确认是不是它。

坑一:SSH 会话里 Docker 拿不到 registry 凭据

任何碰 registry 的命令(docker pulldocker compose build)在 SSH 会话里都会报:

error getting credentials - err: exit status 1,
out: `A specified logon session does not exist.`

根因是 Docker Desktop 的凭据助手依赖 Windows 的交互式登录会话, 而 SSH 是非交互式会话,拿不到那个 token。这是设计限制,不是配置问题—— DOCKER_CONFIG--config 指向干净目录、删掉 credsStore 字段,这三种办法我都试了,全部无效

唯一可行的解法:注册一个 LogonType Interactive 的计划任务,把命令推回用户会话执行。

通用模板 —— 凡是要拉镜像 / build 的命令都套这个
$dir  = 'C:\xiaozhi-music-mcp'
$log  = "$dir\build.log"
$act  = New-ScheduledTaskAction -Execute 'cmd.exe' `
        -Argument "/c cd /d $dir && docker compose up -d --build > $log 2>&1"
$prin = New-ScheduledTaskPrincipal -UserId 'DESKTOP-XXXX\w' -LogonType Interactive

Register-ScheduledTask -TaskName 'dc-build' -Action $act -Principal $prin -Force
Start-ScheduledTask -TaskName 'dc-build'

计划任务是异步的,你拿不到退出码

Start-ScheduledTask 立刻返回,命令在后台跑。判断进度要靠轮询副作用docker images 里镜像有没有出现、build.log 的尾部、docker ps 的状态。

写轮询脚本时注意不要用宽泛的 grep 判成功——上次我用 grep ota 结果匹配到了日志里的 Total, 用 grep TTS 匹配到的其实是一条 TTS 失败日志,两次都误报了成功。 匹配要够具体(POST /xiaozhi/ota/ 这种),并且收集一个时间窗口的完整日志再判断,不要一命中就 break。

2026-08-24 复测:计划任务这条解法也可能救不了

另一台机器(Win11 25H2 26200 / Docker Desktop 4.87 / 用户是 administrator)上,上面那个模板 不起作用:任务确实执行了——build.logPulling 都开始打印了—— 然后照样报同一句 logon session does not exist

那台机器的 config.jsoncredsStoredesktopauths空的:它根本没登录过任何 registry,拉的又全是公开镜像, 凭据助手纯属累赘——但把 credsStore 删掉依然无效, 和本页上面列的那三种办法一样。

WSL 那条路是死的wsl -d docker-desktop -e docker pull 会被明确拒绝 (「This is not supported,请从 Windows 侧调用;要用第三方 WSL2 发行版请先启用 Docker Desktop 的 WSL 集成」)。 走通它得先装一个 Ubuntu 发行版再开集成,不比找人点一下划算。

真正可行的是这两条

  • 把所有碰 registry 的命令攒成一个脚本,请桌面前的人跑一次。拉完镜像后容器 restart: always 会自己活着,之后不用再麻烦他。脚本里每步打印耗时和日志路径, 失败时你能直接从 SSH 读日志定位。
  • 记住 docker compose up -d --no-build 在 SSH 里是能用的——它不碰 registry。 所以改完 .env 重建容器、改配置重启、execlogsrestart 这些事,agent 自己全能做完,只有「拉新镜像」和「build」需要借人手。

用 Tee-Object 包 docker 输出会看起来像卡死

让人在桌面跑脚本时别用 | Tee-Object 收集 docker 的输出:PowerShell 5.1 对 docker 的 ANSI 进度条处理很差,会一路憋到命令结束才吐出来。对方看到的是标题打完就没动静, 十有八九来问你「是不是卡住了」。要么直接让它输出到终端,要么在脚本里自己打阶段性进度。

坑二:Windows 防火墙的 Block 规则优先于 Allow

Docker Desktop 安装时会自己写入一对规则:Allow / PrivateBlock / Public。 你后加的「Profile=Any 放行 8000」会被那条 Block 整个压住——因为 Windows 防火墙里 Block 永远赢 Allow,与添加顺序无关。

现象极其反直觉

你从 Tailscale 连得上,同一个局域网里的设备反而连不上。 因为 Tailscale 虚拟网卡被识别成 Private(走 Allow),而 WLAN 被识别成 Public(撞上 Block)。 排查时很容易怀疑到端口、容器、路由上去,实际上是网络配置文件的问题。

解法:把 WLAN 改成专用网络
Get-NetConnectionProfile          # 先看当前是 Public 还是 Private
Set-NetConnectionProfile -InterfaceAlias 'WLAN' -NetworkCategory Private

这比删掉 Block 规则更安全:笔记本带出门连公共 WiFi 时,那条 Public 的 Block 会继续保护。 另外仍然要为服务端口加放行规则:

New-NetFirewallRule -DisplayName 'xiaozhi-stack' -Direction Inbound -Action Allow `
  -Protocol TCP -LocalPort 8000,8002,8003,2233,2234,8080,8317,23020,23021

坑三:PowerShell 5.1 的编码

操作默认行为后果
Get-Content -Raw 按 ANSI 读 UTF-8 中文注释读成乱码,并且吃掉换行——compose 文件 88 行变 85 行,YAML 直接塌掉
> 重定向 写 UTF-16LE JSON 文件首字节是 0xFF,任何解析器都读不了
数组里拼引号 [char]34 被当独立元素 生成的字符串结构错乱
远端 .ps1 脚本文件本身 按 ANSI 读脚本源码 脚本里只要有中文(注释、字符串、正则),解析就被破坏:$(...) 子表达式静默不求值、 ParserError: 字符串缺少终止符传给远端执行的 ps1 一律写纯 ASCII
一律改用 .NET 方法读写
$enc = New-Object System.Text.UTF8Encoding $false   # $false = 不带 BOM
$txt = [System.IO.File]::ReadAllText($path, $enc)
[System.IO.File]::WriteAllText($path, $txt, $enc)

# 拼引号用反引号转义,不要用 [char]34
$s = "key=`"value`""

坑四:组件存储被「清理软件」破坏,Windows 功能全部装不上

这一条排在最后,但它发生得最早、卡得最死——WSL2 装不上,后面整条 Docker 路线归零。

症状:DISM 连自我修复都做不到

dism /online /enable-feature /featurename:VirtualMachinePlatform /all错误: 2 系统找不到指定的文件;换 Enable-WindowsOptionalFeature 一样; Get-WindowsOptionalFeature -Online 直接抛 COMException; 连 dism /online /cleanup-image /scanhealth 都在 4.9% 崩掉

去掉 /all、显式指定 /packagenamesfc /scannowRestoreHealth——全都没用,因为 DISM 自己就死在那个损坏项上。

不要在功能名、payload、Windows 版本上找原因。答案在 C:\Windows\Logs\CBS\CBS.log 里, 长这样:

CBS.log 里的真凭实据
on:[27]'\SystemRoot\WinSxS\Catalogs'  →  STATUS_OBJECT_NAME_NOT_FOUND
Failed to get CSI system store           [HRESULT = 0x80070002]
CSI store consistency check fails
Failed to load component store

C:\Windows\WinSxS\Catalogs 整个目录不见了。 CBS 每次加载组件存储都要先打开它,打不开就整个 CSI store 加载失败,于是所有功能安装动作 统一报「找不到指定的文件」。上次那台机器上装着 360——它的「系统瘦身 / 深度清理」正是会动 WinSxS 的那类工具, 而 .cat 签名文件体积大,恰好是这类工具的首选目标。

五秒钟确认是不是这一条

dism /online /get-packages正常列完(packages 表是好的), /get-featureinfo 配合 /packagename:Microsoft-Windows-Foundation-Package~... 也能查出功能状态——只有真正要写入的动作才崩。 「查得动、装不了」就是它。再看一眼 Test-Path 'C:\Windows\WinSxS\Catalogs' 即可定案。

修法:把目录建回去,ACL 照 Manifests 抄
$cat = 'C:\Windows\WinSxS\Catalogs'
New-Item -ItemType Directory -Path $cat -Force | Out-Null

# 连 owner 一起抄(owner 必须是 NT SERVICE\TrustedInstaller)
$sddl = (Get-Acl 'C:\Windows\WinSxS\Manifests').Sddl
$acl  = Get-Acl $cat
$acl.SetSecurityDescriptorSddlForm($sddl)
Set-Acl -Path $cat -AclObject $acl

# 立刻验证:这条不再报 Error 2 就是修好了
dism /online /enable-feature /featurename:VirtualMachinePlatform /norestart /english

上次几秒钟就修好了enable-feature 直接跑到 100%。 Set-Acl 用 SDDL 已经把 owner 一并设成 TrustedInstaller, 后面若再补一句 icacls /setowner 报「拒绝访问」可以无视。 修完 Manifests(4.6 万个文件)和 FileMaps 都在,说明组件存储本身是健康的, 只是丢了一个目录。启用完两个功能要真重启vmcompute 服务是重启后才被创建出来的。

这不是我们造成的,必须写进交付说明

丢掉的 .cat 文件补不回来,重建空目录只是让功能安装恢复。那台机器在我们接手之前, Windows 功能安装和部分累积更新就已经是坏的,只是没人装过功能所以没暴露。 要彻底修得用同版本 ISO 做就地修复安装——那是机主自己的决定,别替他做,但要告诉他。

顺带一条:sfc /scannow 在 SSH 里是空转的

它返回 exit 0,但 CBS.log 里连一条 [SR] 记录都没有—— 等于什么都没做。要跑它得推回交互式会话(同坑一)。别拿它的 exit 0 当「系统没问题」的证据。

03

装 WSL2 与 Docker Desktop

验证

ssh w@100.x.x.x "docker version && docker run --rm docker.m.daocloud.io/library/hello-world"

hello-world 那条报 logon session 错误,说明必须走计划任务(坑一),这是预期内的,不是装错了。

04

国内网络镜像总表

每一个服务都在这张表里的某一行卡过。建议在 build 之前就把 Dockerfile 全部改好, 而不是等它失败再改——一次 build 失败在国内网络下可能要等好几分钟才超时。

依赖症状解法
Docker Hub 解析不了 production.cloudfront.docker.com 基础镜像全改 docker.m.daocloud.io/library/xxx;deno 用 docker.m.daocloud.io/denoland/deno:bin
Debian apt Unable to connect to deb.debian.org 见下方 sed。时通时不通——上次 B站服务侥幸过了,音乐服务就卡死,所以不要因为一个服务通了就跳过这步
PyPI 极慢或超时 pip install -i https://pypi.tuna.tsinghua.edu.cn/simple
Go 模块 proxy.golang.org 不通 Dockerfile 里加 ENV GOPROXY=https://goproxy.cn,direct
npm npm config set registry https://registry.npmmirror.com
Debian 换源 —— 注意新版是 debian.sources 不是 sources.list
RUN sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g; \
            s|security.debian.org|mirrors.tuna.tsinghua.edu.cn|g' \
    /etc/apt/sources.list.d/debian.sources

如果机器上装了代理软件

上次那台装了 0dcloud(Meta Tunnel,mihomo 内核)。TUN 模式下走 fake-ip, 所有域名解析到 198.18.x.x,HTTP 代理口在 127.0.0.1:17891。 容器出站也被它接管(不配代理也能连上 YouTube),但显式配 http://host.docker.internal:17891 更快更稳。

它的路由表是干净的(192.168.1.0/24 正常走 WLAN)——排查局域网连不通时不要错怪代理, 先按坑二查防火墙。

2026-08 那台是 clash-verge / verge-mihomo,系统代理 127.0.0.1:7897。 一个必须知道的前提:WSL 是 NAT 模式,容器里的 127.0.0.1 到不了宿主的代理wsl 自己会提示「检测到 localhost 代理配置,但未镜像到 WSL」)。 容器里要用代理只能写 http://host.docker.internal:7897

往对方机器传大文件:别走明文 HTTP 跨境,走他自己的代理

私有仓库的包只有几 MB,scp 走 Tailscale 就够。但固件、安装包这类几十 MB 到几百 MB 的东西, Tailscale 那条链路实测只有 ~5KB/s(3MB 传了十分钟),完全不能用。

换成「自己起 HTTP + UPnP 映射 + 对方机器直连公网 IP 下载」快得多,但如果你在境外、 对方在国内,明文 HTTP 传大文件会在中途被 reset: 第一次 WebClient 传到一半断(它还有 100 秒默认超时), 换 curl 直连则是 Recv failure: Connection was resetdownloaded=0

解法是让对方机器走它自己的代理来下——加密隧道不受干扰。同一个文件、同一条链路, curl.exe -x http://127.0.0.1:7897 一次就传完(14MB / 63 秒 / 226KB/s;3.9MB 只要 8 秒)。 另外服务端要支持 Range(curl -C - 才能续传),文件名用随机串、传完立刻关掉端口映射—— 固件里编译着机主的 WiFi 密码,别让它在公网上多待一秒。

05

五个服务

目录统一放 C 盘根下,凭据各自存在同目录的 KEYS.txt 里,方便机主自己查改。 下面按建议的部署顺序排列——小智服务端是核心,其余四个是它的能力扩展,可按需取舍。

先建好目录结构

C:\xiaozhi-server       # 小智服务端(全模块)
C:\xiaozhi-music-mcp    # 音乐
C:\stackchan-bili       # B站转码
C:\xiaoyuzhou-server    # 小宇宙播客
C:\cli-proxy-api        # CLIProxyAPI

私有仓库的传输方式

在委托人的机器上执行
# 1. 先看清楚要排除什么 —— 不要凭记忆写
cat .gitignore

# 2. 打包(--exclude 按 .gitignore 来,state/data/cookies 一律不带)
tar --exclude='.git' --exclude='state' --exclude='data' \
    --exclude='__pycache__' --exclude='.env' \
    -czf /tmp/svc.tgz -C /path/to/repo .

# 3. 传之前先列一遍包内容,确认没有凭据混进去
tar -tzf /tmp/svc.tgz | grep -iE 'cookie|allow|token|secret|\.env|state/'

# 4. 传过去解压
scp /tmp/svc.tgz w@100.x.x.x:/C:/tmp/
ssh w@100.x.x.x "tar -xzf C:/tmp/svc.tgz -C C:/xiaozhi-music-mcp"

小智服务端(全模块)

8000 ws 8002 智控台 8003 视觉
来源
xinnan-tech/xiaozhi-esp32-server(公开,v0.9.6 全模块)
目录
C:\xiaozhi-server
官方文档
全模块部署 Deployment_all.md · 常见问题 FAQ.md · xiaozhi.biz
组成
Python 主服务 + Java manager-api + Vue manager-web + MySQL + Redis
传输
公开仓库,对方机器直接拉
要机主自备
火山引擎账号(ASR / TTS 分别开通)+ 一个 LLM 的 key

启动顺序有依赖,不能一把 up:

  1. 先下 SenseVoice 的 model.pt(见下方红框),不然主服务起不来
  2. 先起 MySQL + Redis + manager-api + manager-web
  3. 取出 server.secret(智控台「参数管理」里有,但直接查库更快,见下)
  4. 把 secret 填进主服务的 data/.config.yaml,再起主服务
  5. server.websocket / server.ota / server.fronted_url 三个参数(默认全是 null,见下方红框),清 Redis
  6. 浏览器开 http://<局域网IP>:8002注册的第一个账号自动是管理员(密码让机主自己定)
  7. 重启设备 → 屏幕出激活码 → 机主在智控台绑定设备

必须先下 model.pt(约 893MB),否则主服务起不来

compose 里主服务挂了 ./models/SenseVoiceSmall/model.pt。这个文件不存在的话 Docker 会把挂载点建成一个目录,主服务随即失败。就算你打算用云端 ASR(火山引擎), 这个挂载点也得是个真文件。

# modelscope 会拒绝 curl 的默认 UA,而且是【静默返回 0 字节】不报错
# 另外 curl 不会自己建目录 —— 目录不存在时同样得到 0 字节
New-Item -ItemType Directory -Force -Path 'D:\...\models\SenseVoiceSmall'
curl.exe -L -A 'Mozilla/5.0' -o '...\model.pt' `
  'https://modelscope.cn/models/iic/SenseVoiceSmall/resolve/master/model.pt'

# 验货:头两字节必须是 PK(pytorch 是 zip 容器)

上次这一步失败过两次都是 0 字节,因为它不报错:第一次是 UA,第二次是目录不存在。 一定要检查大小和头两字节,不要只看 curl 的退出码。国内直连约 30 秒下完。

server.secret 直接从库里查,不用让机主去翻页面
docker exec xiaozhi-esp32-server-db mysql -uroot -p123456 \
  -D xiaozhi_esp32_server -N -B \
  -e "select param_code,param_value from sys_params where param_code like '%secret%';"

三个默认 null 的参数:不配,设备拿到的是 docker 内网 IP

sys_params 里这三条初始值都是 nullserver.websocketserver.otaserver.fronted_url (后者默认是 http://xiaozhi.server.com)。为空时主服务就用自己探测到的地址—— 而它跑在容器里,探到的必然是 172.18.0.x 这种内网 IP:

视觉分析接口是   http://172.18.0.4:8003/mcp/vision/explain
Websocket地址是  ws://172.18.0.4:8000/xiaozhi/v1/     ← 设备永远连不上

主服务启动时会 POST /config/server-base 试着把自己的地址上报给 manager-api, 这一步失败了也只是重试两次然后继续(日志里一闪而过),参数保持 null。 所以要手动配:

update sys_params set param_value='ws://<局域网IP>:8000/xiaozhi/v1/'      where param_code='server.websocket';
update sys_params set param_value='http://<局域网IP>:8002/xiaozhi/ota/'   where param_code='server.ota';
update sys_params set param_value='http://<局域网IP>:8002'                where param_code='server.fronted_url';

# 改完必须清缓存,否则不生效
docker exec xiaozhi-esp32-server-redis redis-cli FLUSHALL

注意 websocket 是 8000,OTA 是 8002,两个端口不一样。 fronted_url 会印在设备屏幕上让人去绑定,留着默认的 xiaozhi.server.com 机主会一脸茫然。

设备要走激活绑定,OTA 返回里就有激活码

OTA 接口的响应里带一段 activation

"activation": { "code": "158552", "message": "http://<fronted_url>\n158552",
                "challenge": "<设备MAC>" }

设备重启后屏幕上显示这个码,机主要在智控台里输入它完成绑定,绑定后才能连 websocket 对话。 所以「注册管理员账号」这一步不能省,也不能替机主做。

改了数据库要清 Redis 缓存

模型配置、音色(model:data:* / timbre:* / server:config)都在 Redis 里有缓存, 直接改库不清缓存不生效。但 agent 的 system_prompt 没有缓存键,改完设备重连即可。

语音链路首选:火山引擎 / 豆包语音 2.0

这是目前在跑、已实测的组合。火山引擎和豆包是同一家(前者是云平台,后者是模型品牌), 但 ASR 和 TTS 在控制台里是两个独立产品,要分别开通——只开一个,另一个必然失败。

环节智控台里选resource_id状态
ASR 豆包语音识别2.0(流式)
ASR_DoubaoStreamASRV2
volc.seedasr.sauc.duration 已实测
TTS 火山引擎(流式)
TTS_HuoshanDoubleStreamTTS
volc.service_type.10029 已实测
TTS 备选 豆包语音合成2.0(流式)
TTS_HSDSTTS_V2
seed-tts-2.0 系统预置项,未实测

两者都填 appid + access_token,TTS 的 ws_url 保持预置值 wss://openspeech.bytedance.com/api/v3/tts/bidirection 即可。 LLM 与语音链路互相独立,上次配的是 DeepSeek。

最容易卡住的地方:resource_id 和实际开通的产品对不上

resource_id 决定你调的是哪个产品,它必须和控制台里真正开通了的那个对应。 两种报错方向完全相反,先分清再动手:

  • HTTP 400 —— resource_id 字符串本身无效(写错了)。改配置。
  • HTTP 403 —— resource_id 有效,但这个账号没开通该产品。改配置没有用,去控制台开通。

上次整整卡在这里:配的是 volc.seedasr.sauc.duration(语音识别 2.0), 而账号当时只开通了 volc.bigasr.sauc.duration(大模型语音识别),持续 403。 开通对应产品后立刻就通了。不要把 403 当成 key 填错,去反复检查凭据——那是白费时间。

音色

智控台音色下拉的选项来自数据库 ai_tts_voice 表,火山这一路预置了几十个 (TTS_HuoshanDoubleStreamTTS_0001 爽快思思、_0002 温暖阿虎……), 底层是 speaker 字符串如 zh_female_shuangkuaisisi_moon_bigtts

下拉里找不到某个音色,是这张表里没有预置,不是配置写错了——自己往 ai_tts_voice 插一行即可(讯飞的 x6_pro 就属于这种情况)。 插完记得清 Redis 缓存。

备选链路

讯飞(ASR_XunfeiStream + TTS_XunFeiStreamTTS)也实测跑通过,可以作为火山出问题时的退路。 另外系统预置了 ASR_FunASR 等本地方案,不依赖云服务但吃 CPU, 机器性能够、又不想为语音付费时可以考虑。

音乐服务 xiaozhi-music-mcp

2234 8080 备用
来源
git@github.com:tankxu/xiaozhi-music-mcp私有,需打包传)
目录
C:\xiaozhi-music-mcp
音源
网易云 / YouTube / 哔哩哔哩,YTMusic 为主、B站兜底
关键端点
/stream_pcm(固件层)、/mcp(MCP 端点)、/manage?key=(管理页)、/admin?key=(设备白名单)
要机主自备
一组管理账号密码 + 两个令牌(见下)+ 可选的 YouTube cookies

这四个键缺一不可,但它们不是同一类东西

ADMIN_USER / ADMIN_PASS / MCP_TOKEN / MCP_AUDIO_TOKEN 在 compose 里用的是 :? 语法——没设置就直接报错退出,不会取默认值。 代码里的默认值是 admin / changeme / changeme, 这个强制就是为了防止带着默认凭据上线。

.env本质谁在用泄露了能干什么
ADMIN_USER
ADMIN_PASS
HTTP Basic 账号密码 你,浏览器开 /manage /admin 改设备白名单、管歌单、清缓存
MCP_TOKEN MCP 端点鉴权 小智服务端,走 Authorization: Bearer 同上,外加调用点歌工具
MCP_AUDIO_TOKEN 音频 / 封面取件令牌 设备、播放器 只能听歌,动不了管理

后两个必须填成不同的值,这是刻意的权限分级:MCP_TOKEN 走 HTTP 头、只在服务端之间传; MCP_AUDIO_TOKEN 则是明文拼在 audio_url 里发给设备的/audio?f=xxx.mp3&key=…),会出现在设备日志和抓包里,暴露面大得多。 拆开之后即使它泄露,拿到的人也只能听已缓存的歌。

还有个隐藏行为:MCP_AUDIO_TOKEN 同时是设备白名单里的一条记录——服务启动时会自动往 allow.json 塞一条 {"name":"MCP-client","token":…}, 否则 MCP 返回的链接连自己都取不到音频。

C:\xiaozhi-music-mcp\.env
ADMIN_USER=让机主自己定
ADMIN_PASS=让机主自己定
MCP_TOKEN=随机串
MCP_AUDIO_TOKEN=随机串
ALT_PORT=8080
LIVE_STREAM=0

data/ 是 bind mount(音乐缓存 + allow.json 设备白名单),重建容器不丢。 这个目录不要从委托人那边打包过去,让它在对方机器上重新生成。

YouTube 报「Sign in to confirm you're not a bot」

这是出口 IP 信誉问题,不是账号权限问题。所以先试换代理节点—— 换个地区或线路就可能直接绕过,成本远低于折腾 cookies,也没有封号风险。绕不过再走下面这条。

真要上 cookies:yt-dlp --cookies-from-browser chrome 在 2026 年已彻底失效——Chrome 127+ 的 App-Bound Encryption 把 cookies 加密绑定到应用身份,报 Failed to decrypt with DPAPIyt-dlp #10927),设计上挡死,改配置救不了。

两条可行路径:

  • 浏览器扩展「Get cookies.txt LOCALLY」——已实测有效,Chrome 上就能用。 关键在于扩展跑在浏览器内部,不受 App-Bound Encryption 限制。 登录 YouTube 后在 youtube.com 页面导出 Netscape 格式
  • 换用 Firefox——它的 cookies 是明文 SQLite, yt-dlp --cookies-from-browser firefox 直接能读。

导出的文件放 data/cookies.txt,重启容器生效。代码会自动从 android_vr 切回 web 客户端——两者不兼容,带着 cookies 继续用 android 客户端反而更糟

用小号

拿 cookies 跑自动化下载,有被 YouTube 判定异常的可能。不要用主力账号。

在目标机器上导出,不要用你自己机器导的。cookies 与请求出口 IP 不一致时更容易触发风控。

B站转码 stackchan-bili

2233 host 网络
来源
git@github.com:tankxu/stackchan-bili私有
目录
C:\stackchan-bili
网络
仓库里是 network_mode: host但 Windows 上必须改成 ports: ["2233:2233"](见下方红框)
要机主自备
B站账号(扫码登录)

Windows 上 network_mode: host 是无效的,必须改 bridge + ports

Docker Desktop 的 host 网络绑的是 WSL 虚拟机的网络栈,不是 Windows 主机。 结果是端口到不了局域网——你在宿主机上 curl 127.0.0.1:2233 可能还通, 设备却永远连不上,而且这个现象和坑二(防火墙)长得一模一样,很容易查错方向。

# 删掉 network_mode: host,换成:
    ports:
      - "2233:2233"

改了不影响可用性:仓库里用 host 只是性能优化(省掉 docker-proxy 那一跳用户态 NAT), 而 README 的实测表格里 NUC 用 bridge 的结论就是「流畅」。 只要机器 CPU 不比那台 i7-8559U 差,这一跳的开销无所谓——上次那台是 i5-13600KF,实测正常。

另一条出路是把 WSL 改成 mirrored 网络模式(%USERPROFILE%\.wslconfignetworkingMode=mirrored),那样 host 网络就正常了。 但那是给自己机器用的做法——它会改变机主整台机器的 WSL 网络行为, 给别人装机时改 ports 只影响这一个容器,风险小得多。

state/ 必须挂载

B站登录凭据 cookies.json 和播放历史都在 ./state不挂的话容器一重建登录态就没了,每次都要重新扫码。这个目录也不要从委托人那边带过来。

登录要在对方的桌面上完成:用 ToDesk 弹出扫码页,让机主用自己的B站 App 扫。 服务会轮询扫码状态(最长 3 分钟),成功后 state/cookies.json 落盘。

默认参数是给 CoreS3 屏调的

OUT_W=320 OUT_H=240 OUT_FPS=12 JPEG_Q=7、音频 16kHz 单声道。 BILI_QN=32 是 480P(实测免登录上限),要更低画质用 16。

小宇宙 xiaoyuzhou-server

23020 23021
来源
git@github.com:tankxu/xiaoyuzhou-server私有
目录
C:\xiaoyuzhou-server
技术栈
Go(build 需要 GOPROXY=https://goproxy.cn,direct
要机主自备
自己的小宇宙账号——不要用委托人的

Go 编译约 70 秒。上游 token 很短命,Mac 侧原本有自动 refresh 的机制; 如果对方机器上要长期用,需要确认 refresh 逻辑在 Windows 上也跑得起来——这一条上次没有验证

CLIProxyAPI

8317
来源
router-for-me/CLIProxyAPI(公开)
官方文档
help.router-for.me · 管理 API
目录
C:\cli-proxy-api
要机主自备
上游账号的 OAuth 授权

最后一步只能由人来做

添加上游账号走的是 OAuth 浏览器授权,agent 代劳不了。 把服务跑起来、端口通了之后,剩下的登录动作留给机主,并把操作步骤写清楚交给他。

上游来源与官方文档汇总

服务仓库官方文档
小智服务端 xinnan-tech/xiaozhi-esp32-server
公开
Deployment_all.md(全模块,本手册对应)
Deployment.md(只跑 server 的精简版)
FAQ.md · xiaozhi.biz
CLIProxyAPI router-for-me/CLIProxyAPI
公开
help.router-for.me
管理 API
音乐服务 git@github.com:tankxu/xiaozhi-music-mcp
私有,打包传
仓库内 README.md接入文档.md
B站转码 git@github.com:tankxu/stackchan-bili
私有,打包传
仓库内 README.md
小宇宙 git@github.com:tankxu/xiaoyuzhou-server
私有,打包传
仓库内 README.mddoc/

上游文档和这份手册的分工

上游文档讲的是标准环境下怎么装;这份手册讲的是国内网络 + Windows + 远程操作下额外要处理什么。 两者是叠加关系——先按上游文档理解服务本身的结构,再回来看这里的坑。 上游文档不会告诉你 Docker 凭据在 SSH 会话里拿不到,也不会告诉你防火墙 Block 优先于 Allow。

06

收尾:让它能被找到、且一直活着

交付清单

07

设备侧:StackChan 刷机与配置

服务端跑起来之后,设备要刷固件、再把四个服务地址写进去。固件仓库 git@github.com:tankxu/stackchan-hichan(私有),默认分支 hichan

三种刷法,别搞混

场景命令写哪些分区NVS
新设备 / 迁移 一体包从 0x0
cd firmware && ./make_release.sh
全片连续覆盖 清空
改了代码日常迭代 idf.py build && ./flash_app.sh 只写 app 分区 0x20000,快两倍 保留
设备已联网 ./push_ota.sh 免 USB 推送,带 sha 硬校验 保留

一体包等于恢复出厂

NVS 分区在 0x9000,而一体包里那一段是 0xFF 填充——从 0x0 连续覆盖会把它一并抹掉: WiFi 凭据、小智绑定、音量亮度、舵机零点标定全没。 新设备用它是对的,已经在用的设备别拿它做日常更新

flash_app.sh 按 MAC 认设备,不认端口号

串口号每次插拔都会变(实测拔掉另一台 ESP32 后,StackChan 就占了对方原来的 usbmodem 号), 认端口号迟早刷错机器。换设备记得改脚本顶部的 STACKCHAN_MAC

远程刷别人的机器时这条更要命:上次客户那台 Windows 上有 5 个 COM 口——两个蓝牙串口、 一个摄像头的 CDC 接口、一个 CH340 板子(VID_1A86)、真正的 StackChan 在 VID_303A&PID_1001(303A 是 Espressif 的 VID,1001 = ESP32-S3 内置 USB)。 [System.IO.Ports.SerialPort]::GetPortNames() 第一个返回的恰好是那块 CH340。 先用 esptool --port COMx --no-stub read-mac 认准芯片型号和 MAC 再刷,别信端口顺序。

远程刷机顺序:先刷 app,再擦 NVS —— 反了就白刷

在别人机器上没法用 flash_app.sh(那边没有 IDF),流程是「本地构建 → 传 bin → esptool 刷」。 如果既要换 app 又要清 NVS(比如改了编译期 WiFi 凭据),顺序必须是先刷 app 后擦 NVS

反过来做会这样:esptool erase-region 擦完会自动 Hard resetting, 设备立刻用还没换掉的旧 app 启动,发现凭据表为空, 于是把旧凭据又种回了 NVS;等你刷完新 app 再复位,新 app 看到凭据表非空 (固件的规则是「非空就不种入」),编译期的新凭据永远进不去。

表现是「明明改了 SSID,设备还在扫旧的那个」,而 sdkconfig.h 里查得到新值—— 上次就在这里绕了一圈。判断依据很简单:进设备配网页面看凭据表里是哪个 SSID。

sdkconfig.defaults.local 改了不生效:它不覆盖已存在的 sdkconfig

文件头上写着「改完需要 idf.py reconfigure 才生效」——那句话只对全新的构建目录成立。 IDF 的规则是 sdkconfig.defaults* 仅在 sdkconfig 不存在时用来生成初始配置, 既有的 sdkconfig 优先,reconfigure 也不会去覆盖它。

所以给别人装机改 WiFi / OTA 地址时,直接改 sdkconfig 本身(或者删掉它重新生成)。 改完一定要验证产物,别信输入:

grep -E 'DEFAULT_WIFI_SSID|DEFAULT_WIFI_PASSWORD|CONFIG_OTA_URL' build/config/sdkconfig.h

上次就是漏了这一步:改了 sdkconfig.defaults.local、跑了 reconfigure、构建成功、 一体包也打好了,烧进设备的却还是我自己家的 WiFi,设备一直报 No AP found

设备只能连 2.4GHz:先确认目标 SSID 有 2.4G 在广播

ESP32-S3 没有 5GHz 射频。而现在很多路由器是「双频同名」,机主嘴里的「就这一个 WiFi」 在设备眼里可能只有 5G 那一半。上次客户的 205 扫出来只有一个 BSSID、 波段: 5 GHz / 频道: 40,设备自然永远 No AP found

# 中文系统上 netsh 的字段名是中文,别在远端解析,把原始输出取回本地看
netsh wlan show networks mode=bssid

看目标 SSID 下的 波段 / 频道字段:频道 1-13 是 2.4G,36 以上是 5G。 诊断时注意两件事:① 机主的电脑连在 5G 上时,它扫到的邻居 2.4G AP 可以作为 「网卡 2.4G 扫描功能正常」的旁证;② 隐藏 SSID 也不会出现在扫描列表里, 而固件走的是「扫描后按信号强度匹配凭据表」,扫不到就等于不存在—— 这种情况下用设备自己的 AP 配网页面手动输入 SSID 可以绕过(配网走直连,不依赖扫描)。

刷完写服务地址

统一走设备的 /config 接口(端口 8032),立即生效且断电不丢

curl -X POST http://<设备IP>:8032/config \
  -d '{"music_url":"http://192.168.1.100:2234"}'
服务配置键注意
小智服务端ota_urlhttp://<IP>:8002/xiaozhi/ota/ 全模块部署是 8002,不是 8000要重启才生效
音乐music_urlhttp://<IP>:2234 还要去服务端 /admin 把设备 MAC 加白名单
B站bili_urlhttp://<IP>:2233 指向常开的机器
小宇宙podcast_urlhttp://<IP>:23021 23021 是设备反代口,不是 23020 那个 API 网关

OTA 端点在 8002,写 8000 会得到一个查不出原因的失败

本文档早先版本把 ota_url 写成 :8000那是错的(至少对 v0.9.6 全模块部署)。 8000 是纯 websocket 端口GET / 会回一句 Server is running (websocket 库的默认行为,看着像服务正常),但 POST /xiaozhi/ota/ 得到的是 Empty reply from server——连接被收下、数据被读走、然后直接关闭, 主服务日志里一条记录都不留。这个组合极其难查:端口通、GET 有响应、服务端无日志。

全模块部署里 OTA 端点由 manager-api 提供,也就是 8002。三条一起测一遍就能定案:

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://<IP>:8003/xiaozhi/ota/   # 404
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://<IP>:8002/xiaozhi/ota/   # 200 ← 就是它
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://<IP>:8000/xiaozhi/ota/   # 000 空回复

设备侧的表现是屏幕/串口报 checkNewVersion failed to connect <IP>:8000, 很容易误判成「主服务没起来」而去反复重启容器。先确认端口,再怀疑服务。

ota_url 之后必须重启设备

另外三个都是立即生效的,ota_url 不是。固件为了开机提速做过一件事: 「NVS 里已有 websocket/mqtt 配置就跳过 CheckVersion」——而新服务端的连接配置恰恰要靠那趟请求才下发。 所以写 ota_url 时固件会连带清掉 websocket/urlmqtt/endpoint 两个缓存键, 然后必须重启才会去新服务端取配置。

不重启的表现是「地址改了但一点反应没有」,设备继续连旧服务端——极容易误判成配置压根没写进去, 于是开始反复检查 JSON、查网络,白费时间。

固件的编译期默认值指向的是你自己的服务,不是机主的

music_url 默认 http://home.tankxu.com:2234bili_url 默认 http://192.168.1.20:2233podcast_url 默认 http://192.168.1.100:23021给别人装机时这三个必须全部显式改掉,否则设备会去连不属于他的服务。

把配置键写成空串是「恢复编译期默认」,不是「清空」——别拿它来解绑。

刷完还要往提示词里加一段

固件刷好、服务地址也写对了,设备的点歌行为仍然可能不对——因为工具该怎么用是靠提示词教的。 去小智智控台的智能体配置里,把下面这段追加到角色提示词末尾(落库是 ai_agent.system_prompt):

追加到角色提示词末尾
收到以 [主] 开头的消息时,那不是用户说的话,而是系统给你的触发信号。

音乐播放规则:
- 用户点明了具体某一首歌/某个节目 → 用 self.music.play_song(song_name, artist_name)
  例:「放首告白气球」「我要听小兔子乖乖」「讲个西游记的故事」
- 用户没点明具体某一首,只给了歌手/风格/心情/歌单 → 用 self.music.play_stream(query, kind, mode)
  例:「播放周杰伦的歌」→ query=周杰伦, kind=artist
     「放点安静的音乐」→ query=安静, kind=mood
     「放儿歌」「放贝乐虎」→ query=儿歌/贝乐虎, kind=playlist
     「随便放点歌」→ query=热门, kind=mood
  用户说"连续播放/一直放/循环"时也用它;说"随机"传 mode=random,说"按顺序"传 mode=order。
- 不要使用 search_music。
- 工具会立刻返回"这就为你播放",不要再问用户确认,也不要说"正在为你搜索"之类的等待话术。

为什么必须点名「不要使用 search_music」

因为 LLM 面前同时挂着两套来源不同的 MCP 工具

  • self.music.play_song / play_stream / stop ——设备端工具,固件在 hal_music.cpp 里注册,调用即开始播放。
  • search_music——音乐服务端的 MCP 工具,只返回候选列表,不播放

不点名的话,模型很容易先去 search_music 拿一堆候选、再回头问用户选哪首,结果什么都没放出来。

参数名要和固件对得上

song_name / artist_namequery / kind / mode 都是从固件的工具定义里来的。改了固件的工具签名,这段提示词要跟着改,否则模型会按旧参数名调用而失败。

控制台与文档

08

症状对照表

症状最可能的根因去哪一节
A specified logon session does not existSSH 非交互会话拿不到 Docker 凭据坑一
Tailscale 能连,局域网设备连不上防火墙 Block/Public 压住了 Allow坑二
compose 文件被改后 YAML 报错Get-Content -Raw 吃掉了换行坑三
JSON 文件读不了,首字节 0xFF> 重定向写成了 UTF-16LE坑三
Docker 引擎一直转圈起不来装了 16MB 的内核包而不是 250MB 完整包03-3
sshd 服务不存在但装的时候说成功了Add-WindowsCapability 拉不到组件01-2
密钥认证失败、继续问密码公钥放了 ~/.ssh/ 而不是 administrators_authorized_keys01-3
icacls 报找不到组中文版 Windows,要用 SID 不能用组名01-3
build 卡在 deb.debian.orgDebian 源没换04
音乐容器起来就退出.env 里四个必填键缺了服务卡 2
B站每次都要重新扫码state/ 没挂载服务卡 3
YouTube 报 bot 检测需要 cookies,且不能用 Chrome 导服务卡 2
改了模型配置不生效Redis 缓存没清服务卡 1
点歌时先问「你要听哪一首」,最后什么也没放提示词里没写音乐规则,模型走了 search_music07
改了 ota_url 设备毫无反应没重启——只有它需要重启,另三个立即生效07
DISM 报「错误 2 找不到指定的文件」,ScanHealth 也在 4.9% 崩WinSxS\Catalogs 目录被清理软件删了坑四
功能查得动(get-packages 正常)但装不了同上,只有写入动作走 CSI store坑四
计划任务也报 logon session 错误这条解法并非万能,改用「桌面跑一次」坑一
桌面脚本打完标题就没动静,像卡死Tee-Object 憋住了 docker 的进度输出坑一
远端 ps1 报 ParserError / $(...) 不求值脚本里有中文,PS 5.1 按 ANSI 读源码坑三
设备报 checkNewVersion failed to connect :8000OTA 在 8002,8000 是纯 websocket 口07
OTA 的 POST 得到空回复且服务端无日志同上,POST 打到了 websocket 端口07
设备连不上 ws,日志里地址是 172.18.0.x三个 server.* 参数默认 null,主服务只能报容器内网 IP服务卡 1
设备屏幕上让你去 xiaozhi.server.com 绑定server.fronted_url 没改服务卡 1
主服务起不来,model.pt 挂载点变成了目录SenseVoice 模型没下(约 893MB)服务卡 1
模型下下来是 0 字节但 curl 不报错modelscope 拒绝默认 UA;或目录不存在(curl 不建目录)服务卡 1
B站服务宿主能访问、局域网设备连不上Windows 上 network_mode: host 绑的是 WSL 虚拟机服务卡 3
改了 SSID 但设备还在扫旧的那个擦 NVS 早于刷 app,旧 app 又把旧凭据种回去了07
sdkconfig.defaults.local 改了不生效它不覆盖已存在的 sdkconfig,要直接改后者07
设备一直 No AP found,机主说这个 WiFi 能用目标 SSID 只有 5G 在广播,或 SSID 隐藏(扫描匹配对隐藏 AP 无效)07
公网传大文件传一半被 reset跨境明文 HTTP,改走对方自己的代理04
sfc /scannow 返回 0 但什么都没修SSH 非交互会话里它是空转的坑四
刷完机 WiFi 和小智绑定全没了用了一体包,它会清空 NVS07
设备点歌取不到音频设备 MAC 没加进音乐服务白名单07
重启后所有服务都没了没人登录桌面,Docker Desktop 没启动06-4
09

这份文档没覆盖到的

诚实标注,避免下一个 agent 把没验证的当成已验证的:

上次部署的耗时分布,供估算

远程通道(Tailscale + OpenSSH)约 1 小时,WSL/Docker 底座约 2 小时(大半耗在那个 16MB 的错误安装包上), 五个服务约 4 小时(大半耗在 Docker 凭据和各种换源上)。 照着这份文档走,底座和换源的时间应该能压掉大半。

基于两次真实远程部署整理:2026-08-18(Windows 10 专业版 22H2 / 16 核 / 32G)与 2026-08-24(Windows 11 专业版 25H2 build 26200 / i5-13600KF 20 线程 / 32G / 服务装在 D 盘)。
第二次新增的部分:坑四(组件存储被清理软件破坏)、坑一的失效复测、OTA 端口纠错(8000 → 8002)、 三个默认 null 的 server.* 参数、Windows 上 host 网络不可用、以及固件侧三个坑。
文档里的「上次」按上下文指这两次之一,标注为「没有验证」的条目请自行确认后再依赖。