目标:在自己的机器上跑一个断网的本地模型,用来处理西门子博图(TIA Portal)的工程问题。 要求不只是"能跑",而是要能向别人证明数据没出去过。 环境:Windows 11 + RTX 4060 Laptop 8GB + WSL2 + Docker。
先说结论:能做成,但过程中 90% 的时间不是在写代码,是在跟环境搏斗。
下面这些坑,每一个都真实卡住过我至少半小时,有几个卡了三轮。
写出来是因为它们在网上基本搜不到——报错信息很具体,但没人写过原因。
现象非常有迷惑性:
[06:34:41] transformers 后端已加载: Qwen3-4B-Base(设备 cuda:0)
[06:34:41] 培养皿就绪,后端=transformers显存占用 4390 MiB,nvidia-smi 能看到,日志一片正常。然后第一次提问:
File "triton/runtime/jit.py", line 713, in run
device = driver.active.get_current_device()
RuntimeError: Failed to find C compiler. Please specify via CC environment variable原因:bitsandbytes 的 4bit 反量化走 Triton,而 Triton 是在运行时把 kernel 编译成机器码的。容器里没装 gcc,加载阶段不需要编译所以一切正常,一推理才炸。
解法:镜像里装 gcc g++ build-essential,并给 Triton 一个固定可写的缓存目录:
RUN apt-get install -y gcc g++ build-essential
ENV TRITON_CACHE_DIR=/tmp/triton-cache教训:加载成功给的"没问题"错觉非常强。验证要验到链路末端,不能看到日志正常就宣布完工。
wsl --install 在脚本里静默失败非交互环境(脚本、CI、被程序调用)下执行:
wsl --install -d Ubuntu-24.04 --no-launch退出码 0,没有任何输出,什么也没装。 wsl -l -v 里根本没有这个发行版。
加 --web-download 换 CDN 也一样。
解法:手动下 rootfs 再导入。
curl -L -o ubuntu.tar.gz \
https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz
wsl --import Ubuntu-24.04 D:\WSL\Ubuntu-24.04 ubuntu.tar.gz注意:cdimages.ubuntu.com/ubuntu-wsl/noble/daily-live/ 下的 daily 构建版导入会失败:
var/spool/mail: Failed to create dir 'var': No such file or directory
bsdtar: Error exit delayed from previous errors.daily 包的 tar 里缺 var/ 这类目录条目本身、只有目录下的文件条目,WSL 用的 bsdtar 不会自动补父目录。换 releases/ 下的正式版就好了。
更优解:直接用 ubuntu-base(28 MB),比 WSL 专用包小十倍,自己装 docker 反而更快。
gpg --dearmor 在没有 tty 的环境里挂起装 NVIDIA Container Toolkit 的官方文档第一步:
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg在脚本里跑,它会挂起,没有任何报错。key 明明下载成功了 3195 字节,下一行就再无输出。
我为此白跑了三次,每次都以为是网络问题。
原因:gpg 初始化钥匙环时需要 tty,非交互环境下直接卡住。
解法:现代 apt 支持 ASCII armored 的 .asc,压根不需要转换:
curl -sSL https://nvidia.github.io/libnvidia-container/gpgkey \
-o /etc/apt/keyrings/nvidia-container-toolkit.asc
echo "deb [signed-by=/etc/apt/keyrings/nvidia-container-toolkit.asc] \
https://nvidia.github.io/libnvidia-container/stable/deb/amd64 /" \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list顺带一条:清华镜像没有这个仓库(试过 404)。而 WSL 里访问 nvidia.github.io 实测 200 ——
连不上的是 Windows 侧的 Git Bash,不是 WSL。别想当然地把所有源都换成国内镜像。
--internal 网络下 docker -p 完全不生效我要让容器彻底断网,但又要能从宿主访问里面的服务(noVNC 桌面)。
第一反应是 --network internal 加 -p 端口映射,理由听起来很对:
-p 是入站规则,不改出站路由。
实测:完全不生效。
$ docker port dish
(空)
$ docker ps
dish ... 6080/tcp ← 只有 EXPOSE,没有 -> 映射原因:--internal 网络禁用了 NAT,而 -p 依赖 NAT。
解法:宿主侧自己转一道。
socat TCP-LISTEN:6080,fork,reuseaddr,bind=0.0.0.0 TCP:172.18.0.2:6080但这里还有一个坑:nohup socat ... & 在 wsl.exe 执行完命令后会被一起带走——
wsl 会话结束会清理整个进程组。要用 setsid 让它脱离会话:
setsid nohup socat TCP-LISTEN:6080,fork,reuseaddr TCP:$IP:6080 </dev/null &>/log &顺便说,--internal 的隔离强度比防火墙规则高:
socket.create_connection(('223.5.5.5', 53), 6)
# OSError: [Errno 101] Network is unreachableunreachable 而不是超时 —— 内核层面压根没有到外网的路由,比 DROP 更硬。
这一条我每次启动容器都会自动重测一遍,因为"隔离"这种事不能靠假设。
为了让实验可回滚,我用硬链接做目录快照——秒级完成、几乎不占空间,看起来很聪明。
然后发现:改完配置,快照里的那一份跟着变了,回滚等于没回滚。
原因:硬链接指向同一个 inode。只有当写入方"写临时文件再改名"(产生新 inode)时快照才安全;
谁要是原地改写(配置文件、日志、某些框架存 checkpoint),链过去的快照会被一起改掉。
解法:小文件一律实拷,只有大文件才硬链接;自己的写入统一走原子替换。
tmp = path.with_suffix(".tmp")
tmp.write_text(data, encoding="utf-8")
os.replace(tmp, path) # 原子替换,产生新 inode.wslconfig 的默认值能把你坑死。我的机器上不知何时被写成了
defaultVhdSize=512MB、memory=1GB —— 结果在 WSL 里装两个包就
No space left on device。查了半天磁盘,其实是虚拟盘上限太小。
Git Bash 会转换路径。df -h / 会变成 df -h C:/Users/Git/,
docker exec dish ls /app 里的 /app 也会被改写。加 MSYS_NO_PATHCONV=1。
多层引号会被打散。wsl -- bash -c '...' 里带变量和引号的复杂命令,
经过 Git Bash → wsl.exe → bash 三层会面目全非。复杂命令一律写成 .sh 文件再执行。
NAME 这个变量名在 WSL 里已被占用(是主机名)。我写 ${NAME:-dish} 想给容器起名,
结果容器被命名成了 DESKTOP-BUQ9C4C。变量名要够独特。
curl -C - 续传会给你一个"看起来完整"的坏文件:大小对得上、退出码 0,
gzip -t 一测却是损坏的。校验必须做内容级,不能只看大小和退出码。
.bat 文件不能存成 UTF-8。cmd.exe 按系统 ANSI(GBK)解析,
UTF-8 的中文注释会把整行吃掉——连 echo、set 这种内置命令都会报
"不是内部或外部命令"。这跟写别的文件的规矩正好相反。
想在容器里跑一个中文的 XFCE 桌面(用 noVNC 从浏览器进)。
装了 locales、生成了 zh_CN.UTF-8、环境变量也设了,界面依然全是英文,
而且所有常规检查都显示"没问题"。
原因:官方 slim 镜像在 /etc/dpkg/dpkg.cfg.d/ 里写了
path-exclude=/usr/share/locale/*。不删这条规则,装再多语言包翻译文件也不会落盘。
解法(必须排在装桌面之前,顺序反了就白装):
RUN sed -i '/path-exclude.*\/usr\/share\/locale/d' /etc/dpkg/dpkg.cfg.d/*验证方式:别看有没有报错,直接数翻译文件:
ls /usr/share/locale/zh_CN/LC_MESSAGES/ | wc -l # 32输入法还有个连环坑:fcitx5 装好了、能打出中文,但 Ctrl+空格 切换永远没反应。
两个原因叠在一起:
profile(选哪个输入法)不够,快捷键在 config 文件里,那个文件我压根没建第 2 条绕了我三轮。原因是 dbus-launch --exit-with-session xfce4-session
把 dbus 地址关在了包装进程里,脚本自己拿不到;我试图从 /proc/<pid>/environ 反查,
但 pgrep -f xfce4-session 先匹配到的是 dbus-launch 那个包装进程,读到的地址是错的。
结果两次诊断的结论正好相反,一次"fcitx5 没有终端有",一次"fcitx5 有终端没有"。
正确做法:让启动脚本自己持有 dbus 会话,两个子进程都从它继承,不去猜别人的环境。
eval "$(dbus-launch --sh-syntax)"
export DBUS_SESSION_BUS_ADDRESS DBUS_SESSION_BUS_PID
xfce4-session &
fcitx5 -d &pkill dockerd 之后不能 sleep 3 就重启pkill dockerd
sleep 3
nohup dockerd & # 起不来dockerd 优雅关闭要先停掉所有容器,3 秒根本不够。新旧两个进程撞在一起,两边都起不来,
而日志里全是旧进程的 shutdown 记录,看着像是新进程的错误。
解法:等条件,不要等时间。
pkill -TERM dockerd
for i in $(seq 1 30); do pgrep dockerd >/dev/null || break; sleep 1; done
pgrep dockerd >/dev/null && pkill -KILL dockerd
rm -f /var/run/docker.pid
nohup dockerd &这条是通用的:凡是"等某个东西就绪",都该轮询条件而不是拍脑袋 sleep。
环境跑通之后,我做了一件事——让模型答错的题自动进"错题本",
下次遇到相似问题时把上次的正确答法注入上下文。然后同一批题跑两遍:
闭卷(不给参考) 0.225
开卷(给错题本) 0.80010 道题里,6 道原本 0 分的全部翻到满分——正是进过错题本的那 6 道;
没进错题本的 4 道分数纹丝不动。后面这半句才是关键:它排除了随机波动。
但必须说清楚:这不代表模型变强了,权重一个字节都没动。
把错题本清空,它立刻回到 0.225。这个数字衡量的是"检索注入"这条通路有没有生效,
不是模型能力。
真要让模型内化这些知识得靠微调改权重。而错题本攒下来的,
正好就是微调需要的问答数据集——用着用着,训练集就攒出来了。
整套东西的目的不是"跑个本地模型"——那用 Ollama 五分钟就能搞定,效果还更好。
目的是能证明:数据没出去过(每次启动实测出网被挡)、
模型见过什么(每份投喂都有来源和哈希)、答错了怎么处理(错题本 + 奖惩留痕)。
Ollama 和各种一键工具解决的是"能用",不解决"敢交付"。
如果你也在琢磨把 AI 装进工厂、装进客户内网,大概会撞上同样这堆问题。
有一起在做这个方向的,欢迎交流。
完整代码与复现脚本:https://github.com/feiyuez638-cloud/plc-dish
同类隔离部署踩过别的坑的,欢迎评论区补充。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。