用 744 行替代 Open WebUI:llama.cpp + 本地 Qwen3 聊天栈实录

一、起点:我不是需要一个聊天平台,我是需要一个能聊天的窗口

先说清楚问题。我要的能力只有两条:

  1. 用本地模型聊天,数据不出本机;
  2. 聊过的东西能被留下来,以后翻得到。

结果我第一版方案上的是 Open WebUI。功能确实全------多用户、知识库、RAG、工具调用、插件系统,一样不缺。

代价是:为了"聊天"这两个字,我引入了一个完整的应用平台。

它的崩溃方式也很有代表性。某天它起不来了,启动脚本报的是这样一行:

swift 复制代码
FileNotFoundError: [Errno 2] No such file or directory:
'/Users/<me>/.workbuddy/open-web-ui-venv/bin/open-web-ui'

看起来像"入口文件丢了",实际是整个 venv 没了 。而 venv 没了意味着 requests、向量库客户端、open_webui.utils.auth 这一串依赖全没了------换一个解释器根本跑不起来。于是它从那天起一直躺到现在,我的自动任务每天照点跑、每天跳过,日志里连续好几天写着同一句"WebUI 未运行"。

这段经历给我的结论很直接:只想要窗口,就别请一整套平台。要平台能力,就别指望它只有一个二进制。

下面是我换成的结构。


二、新架构:三层,每层都可以单独换掉

vbscript 复制代码
┌─────────────┐   fetch + SSE    ┌──────────────────┐
│  index.html │ ───────────────▶ │  llama-server     │
│  (366 行)   │ ◀─────────────── │  :8080 / :8082    │
└──────┬──────┘   OpenAI 兼容     └──────────────────┘
       │ POST /api/turn                    ▲
       ▼                                   │ llmctl use 30|38
┌─────────────┐                    ┌──────┴───────┐
│  server.py  │                    │   llmctl     │
│  (378 行)   │                    │  (183 行)    │
└──────┬──────┘                    └──────────────┘
       ▼
  chats/YYYY-MM-DD.jsonl ──▶ 每日同步脚本 ──▶ Markdown 笔记

拆开说:推理 交给 llama.cpp 的 llama-server;界面 就是一个 HTML 文件;调度交给一个 shell 脚本。三层之间只通过 HTTP 和文件通信------任何一层我不满意,单独换掉,不用动另外两层。


三、推理层:为什么选 llama.cpp

关键理由是它不需要 Python。

  • 直接吃 GGUF 量化权重,一个二进制就是一台服务;
  • Apple Silicon 上走 Metal 后端(-ngl 99 把全部层丢给 GPU);
  • 自带 OpenAI 兼容端点 /v1/chat/completions,前端不用为它写适配;
  • 可选多模态:加一个 --mmproj 指向视觉投影文件,就能直接发图。

启动命令就一行核心:

bash 复制代码
llama-server \
  --model /path/to/Qwen3.8-27B-Q8_0.gguf \
  --alias qwen3.8-27b-local \
  -ngl 99 -c 16384 \
  --host 127.0.0.1 --port 8082 \
  --mmproj /path/to/mmproj-Qwen3.8-27B-Q8_0.gguf

对比 Open WebUI 那条链,这里没有 venv、没有 pip、没有迁移脚本。一个二进制起不来,就只有一个原因。


四、取舍一:64GB 内存装不下"全都要",那就错峰

我这台机器是 64GB 统一内存。实际要跑的两个模型:

模型 量化 体积
Qwen3-Coder-30B-A3B-Instruct Q8_0 32.5 GB
Qwen3.8-27B Q8_0 28.6 GB(+ 视觉模块 629 MB)

两个随便并排就超过 60GB。加上系统、浏览器、各种常驻服务,同时开必然触发 swap,然后就是全程卡顿。

所以我把设计从"服务多模型"改成"服务一个模型":同一时刻只跑一个,靠一个总控脚本切换。

llmctl 的核心就是 use <key> 这四步:

bash 复制代码
use() {
  # 1) 先杀掉当前所有本地模型,等端口真正释放
  pkill -f "llama-b11306/llama-server"
  while lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1 && [ $i -lt 20 ]; do
    sleep 0.5; i=$((i+1))
  done
  # 2) 起新模型
  nohup "$LLAMA" --model "$PATH_" --alias "$ALIAS" -ngl 99 -c 16384 \
    --host 127.0.0.1 --port "$PORT" $EXTRA > "/tmp/llm_${key}.log" 2>&1 &
  # 3) 轮询 /health 直到真的就绪(最多 120 秒)
  while ! curl -s -o /dev/null -m 2 "http://127.0.0.1:$PORT/health" && [ $i -lt 120 ]; do
    sleep 1; i=$((i+1))
  done
  # 4) 落一份 active_model.json,让前端知道现在是谁在服务
  cat > "$STATE" <<JSON
{ "port": $PORT, "model": "$ALIAS", "name": "$NAME", "vision": $VISION }
JSON
}

这里有两个细节值得单独说:

第一个是"等端口真正释放"。 pkill 之后立刻起新模型,端口可能还被旧进程攥着,结果是新进程绑不上、静默退出,你还以为是模型加载慢。所以必须 lsof 轮询到端口真空。

第二个是 active_model.json。 前端不该写死模型名和端口------那样每换一次模型都要改前端。让总控产出一个状态文件、前端读它,切换就成了单边动作:

js 复制代码
fetch('./active_model.json', {cache:'no-store'})
  .then(r => r.json())
  .then(s => {
    const key = Object.keys(MODELS).find(k => MODELS[k].port === s.port);
    if (key) modelSel.value = key;
  });

错峰的代价要讲清楚 :换模型不是瞬时的。杀掉旧模型 + 加载 30GB 权重,一次大约要等十几秒到几十秒。所以这个设计适合"一段时间内用同一个模型",不适合"每句话换个模型聊"。如果你的内存够同时装下,那就不需要这套东西------这是我为了 64GB 做的妥协,不是通用最优解。


五、取舍二:界面不引框架,366 行够用

聊天窗口要做的事其实很少:流式渲染回答、能发图、能连上正确的端口。原生就够了。

流式部分用 ReadableStream 手动切 SSE,不需要任何 SDK:

js 复制代码
const resp = await fetch(`http://127.0.0.1:${m.port}/v1/chat/completions`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer sk-local' },
  body: JSON.stringify({ model: m.model, messages, stream: true })
});
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += dec.decode(value, { stream: true });
  const lines = buf.split('\n');
  buf = lines.pop();                 // 最后一段可能不完整,留到下一轮
  for (const line of lines) {
    const s = line.trim();
    if (!s.startsWith('data:')) continue;
    const data = s.slice(5).trim();
    if (data === '[DONE]') continue;
    acc += JSON.parse(data).choices?.[0]?.delta?.content || '';
  }
}

两个容易踩的点:buf = lines.pop() 不能省 (网络分片会把一行 JSON 从中间切开,不缓存残片就会 JSON.parse 炸);[DONE] 要显式跳过(它不是 JSON)。

多模态也简单------模型带视觉时把消息体换成数组形态:

js 复制代码
[{ type: 'text', text: '描述一下这张截图' },
 { type: 'image_url', url: 'data:image/png;base64,...' }]

界面按钮只在该模型 vision: true 时才出现。


六、取舍三:对话要留得下来,落盘得自己补

这一节是我这次新补的,之前确实没有。 原来的界面是纯静态托管(python -m http.server),刷新页面、关掉标签页,对话就没了------一个不能留存对话的聊天窗口,严格说只是个调试工具。

静态服务器收不了 POST,所以要换掉。我写了一个 154 行的后端,做三件事:

python 复制代码
do_GET:  /api/health          → {"ok": true, "date": "...", "turns": 12}
do_GET:  /api/turns?date=...  → 回读当天落盘结果
do_POST: /api/turn            → 追加一行到 chats/YYYY-MM-DD.jsonl

(说明:这个文件现在是 378 行,多出来的部分是后来加的本地语义检索端点------那是另一条链路,单独一篇写。)

写入这段是重点:

python 复制代码
with _lock:
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps(rec, ensure_ascii=False) + "\n")
        f.flush()
        os.fsync(f.fileno())     # 不加这行,进程被 kill 时最后几轮会丢

为什么 fsync 不能省 :flush() 只把数据推给操作系统,掉电或进程被强杀时仍可能丢。聊天记录这种"丢了就不可重建"的东西,值得多花这点 IO。

幂等 :每条记录带 turn_id,同一天内重复投递直接返回 duplicated: true 不入库。这样前端重试逻辑可以放心写。

前端一侧,每轮回答结束后 POST 一次,失败就进 localStorage 队列、下次打开页面自动补交:

js 复制代码
async function logTurn(userText, assistantText, m) {
  const rec = { turn_id: 生成ID(), ts: new Date().toISOString(),
                model: m.model, model_name: m.name,
                user: userText, assistant: assistantText };
  try { await postTurn(rec); }
  catch (e) {
    const q = JSON.parse(localStorage.getItem(LS_KEY) || '[]');
    q.push(rec); localStorage.setItem(LS_KEY, JSON.stringify(q));
  }
}

这层兜底很关键:后端没起来的时候,用户不需要知道,也不该因此丢数据。 顺带一个设计细节------后端是同一个进程顺带托管静态文件的,所以前端调用 /api/* 是同源请求,不用碰 CORS。

有了 jsonl 之后,把它们变成人看得懂的东西就是纯加工了。每天一个同步脚本(323 行):

  1. 读当天 chats/YYYY-MM-DD.jsonl;
  2. 优先调本机 llama-server 逐轮出 2--4 条要点;三个端点依次探测 /health,全不通就降级为抽取式摘要(截首句 + 截回答首段);
  3. 写两份 Markdown------摘要版进知识库、原文版进归档目录;
  4. 落一份状态文件,记录当天轮数、摘要引擎、产出路径。

同一份原始数据出两种粒度,是有意的:摘要用来"扫一眼今天聊了什么",原文用来"当时那句话到底是怎么说的"。


七、最硬的一个坑:Codex 新版把 chat 接口禁了

我除了聊天页,还希望桌面版 Codex 能直接跑本地模型。这一步踩的坑最多。

问题 :Codex 新版不再接受 chat completions 作为 wire API,只认 responses(参见 openai/codex#7782)。而本地 llama.cpp 虽然号称 OpenAI 兼容,实际对接时会撞三堵墙:

墙一:System message must be at beginning

Codex 会把 system / developer 消息混在 input 数组里下发,而 llama.cpp 要求 system 必须在最前面。必须在代理里把它们抽出来、合并进 instructions 字段:

python 复制代码
# 伪码:把 input 里的 system/developer 合并进 instructions
sys_msgs = [m for m in payload.get("input", []) if m.get("role") in ("system", "developer")]
if sys_msgs:
    payload["instructions"] = "\n\n".join(提取文本(m) for m in sys_msgs) + "\n\n" + payload.get("instructions", "")
    payload["input"] = [m for m in payload["input"] if m.get("role") not in ("system", "developer")]

墙二:Unsupported tool type: namespace

Codex 会下发一些 namespace 形态的工具组,llama.cpp 不认。得在转发前把非 function 类型的工具整个丢掉。

墙三:OpenAI 专有字段

store、reasoning、include 这类字段 llama.cpp 不认识,会直接报错。与其逐字段排雷,不如重建一个最小 payload ------只保留 model / input / instructions / tools(仅 function) / stream / max_output_tokens。

最后写成 207 行代理,监听 :8083 转发到 :8082:/v1/responses 消毒后转发、SSE 原样透传,GET /v1/models 直接放行。

这里有个通用教训 :所谓"XX 兼容 OpenAI 接口",兼容的是协议形状 ,不是字段全集 。真实客户端总会带上你没预料的字段。代理的正确写法是白名单重建,不是黑名单剔除------黑名单永远漏。


八、现状、代价和没做完的

不吹,说具体状态:

跑通的:两个本地模型错峰切换;聊天页流式输出 + 多模态附图;每轮对话落盘 jsonl;每日自动生成摘要版 + 原文版两份笔记;模型不运行时摘要自动降级为抽取式,链路不会断。

有代价的:

  • 换模型要等十几秒到几十秒,不适合高频切换;
  • 聊天记录落在应用同目录下,外接盘没挂载时落盘目录直接不存在------脚本会明确报错并退出,而不是假装成功(这点我特意做成硬失败:宁可让你看到红字,也不要你以为在存其实全丢);
  • 单机单用户设计,没有权限、没有多账号。不需要,也不打算加。

没做完的:

  • 前端还没有对话列表/历史管理,只有"今天"这一个维度;
  • 摘要仍是一轮一轮做,没做跨轮的话题聚合;
  • 原文归档目前一天一个文件,聊得多了会很长,该按话题切。

九、小结

如果重来一次,我的判断标准会是这样:

  • 只想要一个本地模型的聊天窗口:llama-server 加一个 HTML 就够了,别上平台;
  • 确实需要多用户、RAG、插件:老老实实用成熟平台,但请接受它"依赖一断就整体趴下"的运维成本;
  • 内存装不下多个模型:别硬并排,做错峰,并且把"切换有成本"这件事写进使用习惯里;
  • 说了"兼容 OpenAI":按白名单重建请求,别按黑名单删字段。

从 Open WebUI 换成这套,功能是变少的,但我清楚每一行为什么在那儿,也知道它坏的时候会怎么坏。 对我这种一个人维护一条链路的情况,这个确定性比功能数量值钱。


复现清单

文件 行数 职责
index.html 366 聊天界面(原生 fetch + SSE + 多模态 + 检索)
server.py 378 静态托管 + 对话落盘 API + 语义检索端点
llmctl 183 模型错峰总控(status / use / chat / stop / codex)
llm38-proxy.py 207 Codex ↔ llama.cpp 协议适配
llm-chat-sync.py 323 对话 → Markdown 每日沉淀

合计 1457 行。除 llama-server 外,只有检索部分用到 numpy,其余全部是 Python 标准库。

行数为发布当日实测(wc -l)。其中 index.html 与 server.py 含后来追加的检索部分;本文正文只讲替换与落盘,检索那条链路另篇详述。

相关推荐
jinyishu_1 小时前
RAG 文本分块:七种 Chunking 策略与选型方法
人工智能
youdexiang1 小时前
AI 生成会议纪要好用吗?多款 APP 功能分析
java·人工智能·音视频
阡陌数智1 小时前
大模型领域自适应微调:小样本场景下过拟合抑制与数据构建方法论
人工智能·深度学习·机器学习
AI创界者1 小时前
【开源实战】MiniMax-H3 本地离线高自由度 ComfyUI 工作流搭建指南(含规则松绑与提示词映射)
人工智能·aigc·音视频
程序猿老A1 小时前
GPU云服务器怎么安装AI
运维·服务器·人工智能
库拉大叔1 小时前
从 0 到 1 做一部 AI 漫剧,知漫剧完整制作流程
人工智能·aigc
Omics Pro1 小时前
之江实验室NAR|虚拟细胞3阶段训练范式
数据库·人工智能·算法·机器学习·自然语言处理
AIGC小尼1 小时前
8G 显存零基础本地部署 AI 漫剧全流程|ComfyUI+Wan2.2+FFmpeg 离线成片完整方案(含代码 / 指令 / 排坑)
人工智能·ffmpeg·php·comfyui·ai漫剧