在一台 12GB 显卡的旧台式机上,搭一套自己的 AI 智能体
目标
家里有台闲置的 Windows 台式机:RTX 3060(12GB 显存)+ 32GB 内存。目标是把它变成一台随时可用的私有 AI 服务器:
- 手机和笔记本上打开浏览器就能和大模型对话
- 有一个能读文件、执行命令的编程 agent
- 全部在自己的设备上跑,数据不出门
- 开机自启,平时不用管
最后跑起来的是这样:
qwen3.6:35b 23 GB 70%/30% CPU/GPU 上下文 196608 常驻不卸载
生成 38 tok/s prefill 459 tok/s
35B 参数的模型,12GB 的显卡。这句话本身就是这篇文章的主题。
为什么是 MoE:12GB 显存的唯一解
先说最重要的选型判断,因为它决定了后面一切是否成立。
12GB 显存装不下一个 35B 模型 —— Q4 量化后是 22GB,一大半必须放在系统内存里,由 CPU 计算。直觉上这会慢到没法用。
我实测了同等体积的稠密模型和 MoE 模型:
模型 体积 GPU 占比 生成速度
qwen3.6:35b MoE 22 GB 42% 41.0 tok/s
qwen3:32b 稠密 20 GB 42% 1.4 tok/s
qwen3.8:27b 稠密 18 GB 46% 3.5 tok/s
29 倍的差距。 同样的体积、同样的显存溢出比例,MoE 完全可用,稠密模型慢到不能用。
原因在于激活参数。qwen3.6:35b 总参数 35B,但每生成一个 token 只激活其中约 3B —— 被挤到 CPU 的大多是稀疏访问的专家层,一个 token 只碰其中几个;而稠密模型每个 token 都要把全部 32B 权重过一遍,走 PCIe 传输,慢在必然。
结论:显存不够时,选 MoE,别选同等体积的稠密模型。 这条判断值 29 倍。
顺带一个反面参考。我在 M4 Pro / 24GB 的 Mac 上测了同一批模型:
qwen3:4b prefill 308 generate 37.1 100% GPU
qwen3:14b prefill 130 generate 16.9 100% GPU
qwen3.8:27b prefill 24 generate 6.0 20%/80% ← 溢出
统一内存的 Mac 有个专属旋钮 sudo sysctl iogpu.wired_limit_mb=20480(默认约为总内存的 75%),把它调高能让 27B 少溢出一些。但结果很不对称:prefill 从 24 涨到 74 tok/s(3.1 倍),生成只从 6.0 到 6.9(15%)。 因为生成阶段是内存带宽受限的,而 Mac 的 CPU 和 GPU 访问的是同一条内存,搬过去也快不了。
所以那台 Mac 的实用上限是 14B。重活留给有独立显存的机器。
第一层:让模型服务活着
用 Ollama。Windows 版有个陷阱:它是跟着用户登录启动的托盘应用,没人登录时根本不在。当服务器必须自己服务化。
做法是写一个守护脚本,再用计划任务在开机时以 SYSTEM 身份拉起:
# serve-daemon.ps1
$env:OLLAMA_MODELS = 'F:\ollama\models'
$env:OLLAMA_HOST = '100.106.140.35:11434' # 只监听内网地址
$env:OLLAMA_KEEP_ALIVE = '-1' # 永不卸载,原因见下
$env:OLLAMA_CONTEXT_LENGTH = '196608'
$env:OLLAMA_KV_CACHE_TYPE = 'q8_0'
# 等 Tailscale 地址就绪,冷启动时它出现得晚
for ($i = 0; $i -lt 120; $i++) {
if (Get-NetIPAddress -IPAddress '100.106.140.35' -EA SilentlyContinue) { break }
Start-Sleep -Seconds 5
}
while ($true) {
& 'C:\Users\...\ollama.exe' serve *>> 'F:\ollama\serve.log'
Start-Sleep -Seconds 10 # 崩了自动重启
}
注册成开机任务:
schtasks /create /tn OllamaServe /tr "powershell.exe -NoProfile -ExecutionPolicy Bypass -File F:\ollama\serve-daemon.ps1" /sc onstart /ru SYSTEM /rl HIGHEST /f
已验证 SYSTEM 身份下 CUDA 正常工作,不需要桌面会话。
坑一:12GB 的连续内存,不是「有空间」就能拿到
某天 35B 突然加载失败:
failed to allocate buffer of size 13114933248 ← 12.2 GB
error loading model: unable to allocate CUDA_Host buffer
当时系统显示 18GB 可用。重启 Ollama 没用,重启服务没用。
原因是这块内存是 pinned(页锁定)内存 —— 走 cudaHostAlloc,要求物理地址连续、不可换出,专门给 GPU 做 DMA。Windows 对这类分配的限制远比普通内存严。而当时机器上跑着一个 WSL2 虚拟机,即使它只用了 3.6GB,也足以把物理地址空间打散。
wsl --shutdown 之后,立刻加载成功。
这个坑的诊断顺序值得记下来,因为它排除了所有更明显的怀疑:
直连 API 也失败 → 不是上层应用的问题
14B 完全正常 → 不是坏了,是尺寸阈值(14B 全进显存,不需要主机缓冲)
重启服务无效 → 不是进程状态问题
18GB 可用仍失败 → 不是剩余内存不够
关掉 WSL 后成功 → 是地址空间碎片
识别方法:真正的显存不足报 torch.OutOfMemoryError: CUDA out of memory;报 device not ready 或 unable to allocate CUDA_Host buffer 是驱动层问题,别往内存不够的方向查。
由此引出两条永久措施:
OLLAMA_KEEP_ALIVE=-1—— 那块内存只在加载时申请一次,之后一直持有。后面谁再把内存搞碎都影响不到它。- 开机自动预热 —— 守护脚本起来后立刻发一个请求把模型加载进来,趁内存最干净的时候把那 12GB 抢下来。
坑二:任何一个客户端都能顶掉你的常驻模型
Ollama 有个行为:请求里的 num_ctx 和当前加载的不一致时,它会卸载重载。 而重载意味着重做上面那个脆弱的分配。
我被这个坑了三次:dsh 的配置里写着旧的上下文值、评测脚本里写死 num_ctx: 8192、Open WebUI 又是另一个值 —— 每一个都会把辛苦加载的模型顶掉。
解法是在中间加一个小代理,统一剥掉客户端指定的 num_ctx,让服务端配置成为唯一权威:
dropped = payload.get("options", {}).pop("num_ctx", None)
第二层:网页版对话
用 Open WebUI,自托管的 ChatGPT 式界面,原生对接 Ollama。没有 Docker 也能装:
uv venv --python 3.11
uv pip install open-webui
open-webui serve --host <你的内网IP> --port 3000
注意 serve 只认命令行参数,不认 HOST/PORT 环境变量(我一开始设了环境变量,它默默绑在了 0.0.0.0:8080)。
网络上我用 Tailscale 组网,好处是不用碰路由器端口映射,手机在外面也能连。两层防护:
绑定地址 只监听 Tailscale 网卡地址,家里 WiFi 上根本不监听
防火墙 TCP 3000 仅放行 100.64.0.0/10(Tailscale 网段)
坑三:模型在写你看不见的东西
第一次用就发现慢得离谱 —— 一个问题要等一分半。
原因是 Qwen3 的思考模式:它会先生成一大段推理过程,而那段在界面上是折叠的。实测同一个问题:
思考内容 回答内容 总 token 耗时
思考开 6238 字符 3036 字符 3023 90.7 秒
思考关 0 字符 2768 字符 1270 30.4 秒
生成速度完全一样(42 tok/s),差别全在 token 数量。回答长度几乎没变,等待时间三倍。
关掉它比想象的麻烦。试了四种方式:
/api/chat /v1/chat/completions
think: false ✓ ✗
reasoning_effort: "none" ✗ ✓
chat_template_kwargs.enable_thinking ✗ ✗
prompt 里写 /no_think ✗ ✗
Modelfile 里 PARAMETER think false 不支持这个参数
两条 API 路径要用不同的参数名,而且 Open WebUI 0.11.1 的 Ollama 路由里根本没有 think 的处理(我在它源码里搜过,零处),所以它永远不发。
我的解法是一个 40 行的代理,按路径注入对应参数:
INJECT = {
"/api/chat": ("think", False),
"/v1/chat/completions": ("reasoning_effort", "none"),
}
并且让它记日志。因为这类问题反复出现,每次靠推理判断“到底关了没”很浪费时间:
INJECT /v1/chat/completions reasoning_effort=none model=qwen3.6:35b num_ctx_dropped=8192
RESULT /v1/chat/completions reasoning=0 chars content=2440 chars finish=stop
reasoning=0 就是实证。给自己搭的基础设施加可观测性,回报很快。
思考模式并非没用 —— 数学、复杂调试这类多步推理它确实能提高正确率。但对日常问答和 agent 任务,那些草稿基本在复述问题,而 agent 一个任务要发几十轮请求,每轮乘以三倍等待,体感很糟。
第三层:编程 agent
装 DeepSeek Harness(npm install -g @deepseek-ai/dsh),一个「一切皆插件」的 agent harness。
坑四:好的软件会拒绝你的不安全配置
我照着前两个服务的模式,想把它绑到内网地址上。它直接拒绝:
error: --host 0.0.0.0 is intentionally not supported yet for safety:
it would expose remote code execution to the network; use 127.0.0.1 instead
这个限制是对的,不该绕过。 dsh 是会执行命令的 agent,谁能访问那个端口,谁就能在这台机器上跑任意代码。
正确做法是 SSH 隧道:
ssh -N -f -L 3080:127.0.0.1:3080 winpc
流量走加密通道,机器上没有任何端口对外暴露。代价是手机用不了 —— 但它本来就是本地开发工具,不是托管服务。
要自动化,在 macOS 上用 launchd:
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>-o</string><string>ExitOnForwardFailure=yes</string>
<string>-L</string><string>3080:127.0.0.1:3080</string>
<string>winpc</string>
</array>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>30</integer>
KeepAlive 让它断线自动重连(实测 kill 掉后 40 秒内恢复),ThrottleInterval 防止网络抖动时疯狂重试。
一个我踩过的小坑:我一度在 ~/.ssh/config 里也加了 LocalForward,结果每个 ssh winpc 都失败 —— launchd 已经占着那个端口,而 ExitOnForwardFailure=yes 让转发失败直接退出。隧道的所有权只能有一个地方持有。
坑五:SYSTEM 账户读不到用户级 PATH
计划任务建好了,状态显示 Ready,配置全对。一启动就崩:
'dsh' 不是内部或外部命令
因为 dsh 是 npm 用户级全局安装,在 C:\Users\<用户>\AppData\Roaming\npm。而计划任务以 SYSTEM 身份运行,读不到那个用户的 PATH。
写绝对路径就好:
$dsh = "C:\Users\syang\AppData\Roaming\npm\dsh.cmd"
这类问题只有真跑一次才会暴露。 任务状态、触发器、权限全都正确,但服务起不来。所以我现在的习惯是:配完开机任务,一定停掉手动实例、用计划任务实际拉起来验证一次。
坑六:图形对话框弹在没人看的屏幕上
界面里点「新建工作区」,没有任何反应。
原因是 dsh 的目录选择器会调用原生的 Windows 文件夹对话框。而服务跑在 Session 0(没有交互桌面),对话框物理上无法绘制;就算能画出来,也在那台机器自己的显示器上,而我人在另一台电脑前。
有意思的是它本来考虑到了这一点。读它的源码:
function resolveDirectoryPickerBackend(facts) {
if (facts.bindHost !== "127.0.0.1") return "browse";
if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return "browse";
if (facts.platform === "darwin" || facts.platform === "win32") return "native";
...
}
它把「经 SSH 启动」当作「操作者看不见宿主机屏幕」的信号,该自动退回浏览器内的选择器。丢掉这个信号的是我 —— 守护脚本用 WMI 脱离进程树启动(这样服务才不会随 ssh 断开而死),那一步把 SSH_CONNECTION 从环境里抹掉了。
在脚本里补回来即可:
$env:SSH_CONNECTION = "headless-daemon"
这不是欺骗程序 —— 这套部署确实是被一个看不见宿主机屏幕的远程操作者使用的,正是它要检测的条件。
调参:上下文开多大
模型支持 262144 的上下文,但每加大一档,KV cache 就要占更多内存,把更多层挤出显存。我固定 13k 的输入扫了一遍:
上下文 prefill generate GPU 占比 可用内存
131072 541.2 39.0 35% 9.9 GB
163840 499.6 36.4 33% 9.4 GB
196608 458.8 38.0 30% 8.9 GB ← 拐点
229376 312.5 40.1 28% 8.1 GB ← prefill 断崖
262144 309.0 33.1 26% 7.7 GB
196608 之前每档只掉 8-9% 的 prefill,越过去一次掉三分之一。 那里应该是某个关键层被挤出显存的临界点。
拉满到 262144 能跑,但不值:上下文多 33%,prefill 慢 33%,一比一的交换,而多出的 13 万 token 日常根本用不到。
另一个几乎白拿的优化是 KV cache 量化:
f16 q8_0 变化
prefill 13k 493.3 563.5 +14.2%
generate 40k 30.3 36.8 +21.5%
30 题评测 24/30 25/30 没掉
上下文越长收益越大,精度实测没有可测量的损失。
这里我犯了个错,值得写出来。我一开始判断 q8_0 在这台机器上不会有收益,依据是「上下文从 16384 提到 131072 时 GPU 占比掉了 13 个百分点,速度却没变,说明不是带宽受限」。
那个推理的漏洞是:那次比较里上下文长度本身也在变,KV cache 要扫的数据量同时增大,两个效应互相抵消了。只有固定上下文、单独换精度,才是干净的对照实验。
一条贯穿全程的教训:杀进程要按端口杀
今天我在同一件事上栽了三次:
llama-server 孤儿进程 占着 12.9GB 内存和显存,让 35B 加载失败
node.exe 一直占着 3080,新实例反复 EADDRINUSE 崩溃,
浏览器连的还是老进程 —— 排查了很久才发现
代理进程 同样的问题
共同原因:我按命令行字符串匹配进程来杀。但真正持有端口和内存的往往是子进程,它的命令行和你以为的不一样(比如 dsh 的实际进程是 node ...bin.js web,不含 “dsh web”)。
正确做法是按端口占用者杀:
Get-NetTCPConnection -LocalPort $p -State Listen |
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
node.exe 那次尤其阴险:我以为自己重启了三次服务,实际每次新实例都因端口被占而立刻崩溃、守护脚本再拉起、再崩溃,而浏览器全程连着那个老进程 —— 所以我改的配置「看起来没生效」。
最终形态
winpc(RTX 3060 12GB / 32GB RAM)
├─ Ollama qwen3.6:35b 常驻,196608 上下文,q8_0 KV cache
├─ thinkoff 代理 剥离思考、锁定 num_ctx、记录每次请求
├─ Open WebUI :3000 → 手机/笔记本浏览器,经 Tailscale
└─ DeepSeek Harness :3080 → 仅 localhost,笔记本经 SSH 隧道
三个服务都是 SYSTEM 开机自启,崩溃自动重启
Mac 侧 launchd 自动维持隧道,断线 40 秒内重连
日常使用:打开浏览器,和一个 35B 的模型对话,数据不出家门,不花一分钱 API 费用。
如果你也要搭
按重要性排序的几条建议:
- 模型选 MoE,显存不够时这是唯一解(29 倍差距)
- 服务化要验证,配置正确不等于能跑起来,一定实际拉起一次
- 给基础设施加日志,「到底生效没有」这个问题会反复出现
- 杀进程按端口,不要匹配命令行
- 尊重软件的安全限制,拒绝绑定 0.0.0.0 的程序通常有它的道理
- 调参要控制变量,只改一个东西,否则效应会互相抵消
最花时间的从来不是安装,是搞清楚为什么它没按预期工作。而这里面大半的时间,又花在「以为改了但其实没生效」上。