在一台 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 是驱动层问题,别往内存不够的方向查。

由此引出两条永久措施:

  1. OLLAMA_KEEP_ALIVE=-1 —— 那块内存只在加载时申请一次,之后一直持有。后面谁再把内存搞碎都影响不到它。
  2. 开机自动预热 —— 守护脚本起来后立刻发一个请求把模型加载进来,趁内存最干净的时候把那 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 费用。

如果你也要搭

按重要性排序的几条建议:

  1. 模型选 MoE,显存不够时这是唯一解(29 倍差距)
  2. 服务化要验证,配置正确不等于能跑起来,一定实际拉起一次
  3. 给基础设施加日志,「到底生效没有」这个问题会反复出现
  4. 杀进程按端口,不要匹配命令行
  5. 尊重软件的安全限制,拒绝绑定 0.0.0.0 的程序通常有它的道理
  6. 调参要控制变量,只改一个东西,否则效应会互相抵消

最花时间的从来不是安装,是搞清楚为什么它没按预期工作。而这里面大半的时间,又花在「以为改了但其实没生效」上。