文章配图封面

摘要

Postiz 这类开源自托管社媒调度平台自带一个 Agent 聊天助手,可惜它的”大脑”默认写死了海外模型。这篇实战记录了我们如何把 Agent 后端完整切换到阿里百炼(qwen3.7-plus / glm-5.3 / deepseek-v4-pro 等),并进一步在聊天页面上注入一个实时模型下拉选择器——不重启容器、不暴露任何密钥、点一下切换下一条消息立即生效。全文按真实排障顺序写,含 6 个连环坑和最终 E2E 验证截图思路,供同类”给闭源 SaaS 化前端+编译后端换模型底座”场景参考。

一、任务背景与整体链路

目标拆解成三层,缺一不可:

  1. 能对话——Agent 聊天页不再报 “OpenAI key not set”,发消息有真实回复;
  2. 换国产——推理全部走百炼 OpenAI 兼容端点(https://<dashscope-compat>/compatible-mode/v1),不再依赖任何海外 API;
  3. 界面选模型——用户在 Agent 页面下拉框里直接选百炼目录中的模型,点”切换”即时生效。

链路上有两个角色:容器内后端(NestJS 编译产物,聊天主链路+标题生成)与宿主机 sidecar(自建小 API,负责拉百炼模型目录 + 存当前模型状态),中间用 bind-mount 的一个纯文本状态文件打通。外层还有一道 CDN 反代(边缘节点 → 入口机 → 内网穿透 → 应用容器),后面会看到这道墙怎么卡了我们一轮。

二、第一层:让 Agent 说上话

硬编码模型常量与多份拷贝示意

翻源码发现好消息:后端 SDK 初始化读的是标准环境变量。于是容器 env 里补三行:

OPENAI_API_KEY=<百炼API-Key>
OPENAI_BASE_URL=https://<dashscope-compat>/compatible-mode/v1
AGENT_MODEL=qwen3.7-plus        # 我们自定义的"当前模型"变量

但坏消息紧随其后:docker exec grep 扫编译产物,四个 js 文件里硬编码着 gpt-4.1(聊天主链路),另一个 load.tools.service.js 里还埋着 gpt-5.2(会话标题生成)。改容器内文件重启即失,标准解法是 bind-mount 覆盖 + compose command 跳过 build:

# 宿主机改完 → node --check → 挂载进容器覆盖同名文件
- /srv/postiz/overrides/chat/copilot-controller.js:/app/apps/backend/dist/.../copilot.controller.js
- /srv/postiz/overrides/chat/agent-config.js:/app/apps/backend/dist/.../agent-config.js
# ...共 4+1 个文件

补丁内容就是把 model: "gpt-4.1" 换成 model: (process.env.AGENT_MODEL||"qwen3.7-plus")。重建后 /agents/new 页面欢迎语正常渲染——第一层通了。

插曲:标题生成那个 gpt-5.2 是这颗暗雷——聊天能回,会话列表却一直报错。单独 curl 实测百炼 /responses 端点返回 200(Responses API 结构完整),确认端点兼容后把标题模块也 patch 成同一个模型变量。经验:硬编码模型名往往不止一处,主链路通了别急着收工,把日志里 Model not found 全扫一遍。

三、第二层:Sidecar——模型目录与切换状态

sidecar与挂载状态文件示意

“界面直接选模型”需要两个新能力:前端能列出百炼当前在售模型、切换动作能写进后端读得到的地方。塞进 Postiz 源码不现实(编译产物),于是起了个 70 行的 stdlib Python sidecar(宿主机 127.0.0.1:14120,systemd/cron 双保险拉起):

# 核心逻辑示意
GET  /agent-model   → 读百炼 /models(缓存300s),返回 {models:[...], current:"qwen3.7-plus"}
POST /agent-model   → 校验鉴权 → 白名单检查 → 原地写 /srv/agent-model/current

三个细节全是坑:

  1. bind-mount 单文件的 inode 陷阱——最初用 tempfile + os.replace() 保存状态,结果发现容器里看到的还是旧内容:os.replace 换了 inode,bind-mount 钉死在旧 inode 上。改成原地 open(path,'w').write()(验证前后 inode 号一致),问题绝迹。
  2. 鉴权不能把 token 写进公网 JS——下拉框的切换请求必须鉴权,但 Postiz 前端资源经 CDN 公开可达,任何写死 token 的方案等于裸奔。最终方案:sidecar 本地验签用户登录 Postiz 时的会话 cookie(HS256 JWT,拿服务端同一把 secret 用 hmac+base64 手撕校验,零依赖),另留一把内部管理 token 兜底。浏览器里用户不需要任何额外登录态。
  3. 白名单——POST 只接受从百炼目录实时拉回的模型名(正则 [\w.\-]{1,64} 再比对列表),防止往状态文件里投毒。

四、后端改造:逐请求热读,切换零重启

有了状态文件,把 5 个编译文件的模型常量统一替换成一个每次请求实时读文件的函数:

function ylmCurrent(){try{const s=require("fs").readFileSync("/agent-model-current","utf8").trim();if(s)return s;}catch(e){}
  return (process.env.AGENT_MODEL||"qwen3.7-plus");}

/agent-model-current 就是那个状态文件的容器内挂载点(ro)。这样 sidecar 一写、后端下次对话立刻读到新值,切换生效不用碰容器。compose 里同时补了 host.docker.internal:host-gateway extra_hosts,给容器回调宿主 sidecar 用。

五、第三层:聊天页上的下拉框

聊天页模型下拉选择器示意

前端是编译后的 SPA,没有配置项可加控件。好在这套部署早就为了换图标往页面注入过一段公共 JS(backend-brand.js,由后端静态目录伺服、每次导航必加载),于是把选择器追加进这个文件——双触发挂载(1.5s 路由轮询 + 全局 MutationObserver),保证任何入口进 Agent 页都能挂上:

// 挂在输入框父容器顶部;fetch GET 模型列表;切换按钮 POST 后刷新
fetch('/api/agent-model',{credentials:'include'})          // 列表 + 当前值
fetch('/api/agent-model',{method:'POST',credentials:'include',body:JSON.stringify({model})})

外部路径 /api/agent-model 要能到达 sidecar,得打通整条反代链:边缘 CDN → 入口机 → 内网穿透口 → 容器内 nginx(4200) → 宿主 14120。容器内那份 nginx 模板也在镜像里,继续走 整文件 bind-mount 覆盖(和图标定制同一套路),加一段:

location /api/agent-model {
    proxy_pass http://host.docker.internal:14120/agent-models;
}

连环坑实录(这一层折腾最久):

  • 先 404:入口机反代的 upstream 配置文件里压根没有这条路由——改了容器内模板忘了宿主链路,两层都要加;
  • 补完变 504:容器 curl 宿主端口直接超时,iptables -L 一看,宿主机防火墙(UFW + 宝塔链)默认拒了 docker 子网 → 宿主。加一条 iptables -I INPUT -s 172.16.0.0/12 -p tcp --dport 14120 -j ACCEPT 并写进 rc.local 持久化;
  • 再验证发现 sidecar 起不来:早前一轮远程补丁把状态写入改坏了(缩进炸出 SyntaxError),日志里躺着一行旧错误差点带偏排查——先 python -m py_compile 再谈玄学;
  • 公网 curl 拿到 200 但 body 是 SPA 的 HTML:CDN 把旧的 404 页面按 300 秒缓存了,绕缓存加 ?cb=时间戳 一发入魂;
  • sidecar 冷启动第一次要现拉百炼目录,公网链路慢时 15s 超时全吃在路上——把缓存线程提前到启动时 + 客户端超时放宽到 65s。

六、端到端验证:全部用真浏览器跑通

端到端验证通过示意

自动化脚本(Playwright)按用户真实路径走了一遍完整闭环:

  1. 登录 → 进 Agent 页 → 轮询断言 select#yl-model-picker 出现,实测列出 14 个模型(deepseek-v4 全系、glm-5.x、qwen3.7/3.8 全系……全部来自百炼 /models 实时返回,缓存 300s 自动更新);
  2. UI 选 glm-5.2 → 点”切换” → 按钮回显”当前: glm-5.2″,同时服务器端 cat /srv/agent-model/current 输出 glm-5.2,UI 动作与服务端状态严格一致;
  3. 再切回 qwen3.7-plus,输入框用真实键盘事件发送「请用一句话介绍你能做什么」→ 10 秒后收到流畅中文回复(”我可以帮助你管理和安排社交媒体帖子……”),日志无 MODEL_NOT_FOUND、无 4xx;
  4. 标题生成会话列表正常,暗雷 gpt-5.2 不复现。

这里还有一个前端自动化特有的坑:聊天输入框是 React 受控组件,用 element.value=... + dispatch input 事件”喂”进去的消息,点发送毫无反应(骗得过 DOM 骗不过 React 合成事件)。换成 locator.fill() + keyboard.press('Enter') 真实输入后立即打通。教训:E2E 报”没回复”先怀疑消息根本没发出去,看用户气泡在不在。

七、收尾与经验清单

顺手做的两件工程卫生:sidecar 加了 cron 每分钟 pgrep || 拉起 的看门狗(flock 防并发);所有补丁文件、状态文件、覆盖挂载集中在一个 /srv/ 目录里,重建容器只需 docker compose up -d --force-recreate,不丢任何定制。

复盘五条可迁移经验:

  1. 编译产物的正确改法 = 拷出 → patch → node --check → bind-mount 覆盖 → compose 跳 build;永远不要手改容器内部署后即失的文件;
  2. 单文件 bind-mount 与原子写互斥——想 os.replace 保平安,先想清楚挂载钉的是 inode;
  3. 给公开页面用的 API,鉴权优先”借用已有登录态”(验签同域 cookie JWT),比在前端藏 token 安全一个数量级;
  4. 多层反代加 CDN 排障要逐跳测(容器→宿主→穿透→入口→边缘→公网带 cache-buster),504 想一层、404 想另一层;
  5. 模型热切换的最小可行架构:一个状态文件 + 一个逐请求读文件的函数 + 一个带目录缓存的小 sidecar,总计不到 200 行,胜过改框架。

至此,一个默认只认海外模型的开源 Agent,变成了”百炼全家桶随点随换”的国产底座,而且切换入口就长在它的聊天界面里。


本文所有服务器地址、密钥、内部域名均已按惯例脱敏;模型名称与开源组件名为真实信息。