一篇从镜像拉取到接入本地大模型、再到填平 6 个坑的保姆级部署指南。适合想在内网/无公网环境下自建 AI 提示词优化工具的开发者。
📑 目录
- 为什么要把提示词优化工具搬到本地
- Prompt Optimizer 是什么
- 整体架构与准备工作
- 第一步:拉取镜像与编写 Compose 配置
- 第二步:让本地大模型被识别
- 第三步:配置 nginx 反向代理(关键)
- 踩坑实录:6 个问题与解决方案
- 5 条可直接复用的经验
- 最终配置文件一览
- 常见问题 FAQ
- 后续可扩展方向
为什么要把提示词优化工具搬到本地
用过 ChatGPT、DeepSeek 的人都知道:提示词(Prompt)写得好不好,直接决定 AI 输出质量的天花板。 于是各种”提示词优化工具”应运而生——你丢进去一句大白话,它帮你改写成结构清晰、角色明确、约束完备的专业提示词。
但市面上的在线工具大多有个共同问题:你的提示词和 API Key 要经过别人的服务器。 对于涉及商业机密、客户数据或单纯不想联网的场景,这就很劝退。
有没有办法做到——
- ✅ 提示词全程不出局域网
- ✅ AI 模型跑在自己显卡上,零 API 费用
- ✅ 工具开源、可自托管、可二次集成
答案是:用 Docker 部署开源的 Prompt Optimizer,再接一个本地 llama.cpp 模型服务。 这篇文章记录的就是一次完整的实战部署——包括最后填平的 6 个坑。如果你也想在内网搭一套,照着走一遍能省下至少 3 小时。
Prompt Optimizer 是什么
Prompt Optimizer 是一个基于 Vue 3 + Vite + Naive UI 的开源 AI 提示词优化工具。核心能力如下:
| 功能 | 说明 |
|---|---|
| 一键优化 | 多轮迭代改进提示词,支持”系统模式”和”用户模式”两种思路 |
| 对比评估 | 优化前后效果 A/B 对比,直观看到提升 |
| 多模型支持 | OpenAI / Gemini / DeepSeek / 智谱 / Ollama / 自定义模型通吃 |
| 图像生成 | 文生图(T2I)、图生图(I2I)、风格迁移 |
| 提示词管理 | 模板、收藏、版本历史、导入导出 |
| 安全架构 | 纯客户端处理,API Key 不经过服务器 |
最后一行是重点:它的”纯前端”架构意味着配置都存在浏览器本地(localStorage),这点后面会成为一个坑。
Github地址:https://github.com/linshenkx/prompt-optimizer

整体架构与准备工作
目标架构
我们要搭的是一个”双机协作”的最小可用架构:
┌─────────────────────────────────────────────────────┐
│ 局域网 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 你的电脑 │ │ Docker 服务器 │ │
│ │ (浏览器) │ │ <服务器IP> │ │
│ │ │────────▶│ Portainer │ │
│ └──────────────┘ │ ┌───────────┐ │ │
│ │ │prompt-opt │ │ │
│ │ │:28081→:80 │ │ │
│ │ └─────┬─────┘ │ │
│ └───────┼───────┘ │
│ │ │
│ ┌──────────────┐ │ │
│ │ llama.cpp │◀────────────────┘ │
│ │ <模型机IP> │ nginx 反代 /llama/ │
│ │ :8080 │ │
│ │ 本地大模型 │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────┘
局域网你的电脑浏览器Docker 服务器Portainer 管理prompt-optimizer:28081 → :80llama.cpp server本地大模型 :8080访问 :28081nginx 反代 /llama/▲ 图 1:局域网部署架构拓扑——浏览器、Docker 服务器与 llama.cpp 模型机的连接关系
一句话概括:浏览器访问 Docker 服务器上的 Prompt Optimizer,它再通过 nginx 反代去调用另一台机器上的本地大模型。
说明:下文中的
<服务器IP>、<模型机IP>是占位符,请替换成你自己局域网里的实际地址。两台机器也可以是同一台。
你需要准备什么
- 一台装了 Docker + Portainer 的服务器(Linux 即可)
- 一台跑着 llama.cpp server 的机器(有显卡更好,CPU 也能跑小模型)
- 局域网内网络互通(能
ping通彼此) - 基本的命令行操作能力
本地大模型这边
llama.cpp server 的启动不在本文重点范围内,但有几个要点:
- 模型建议用 GGUF 量化格式(如 Gemma、Qwen 系列的 Q4 量化版,显存占用友好)
- 启动时监听
0.0.0.0而非127.0.0.1,否则局域网访问不到 - 上下文长度按显存量力而行(12G 显存跑 12B 模型 + 32K 上下文比较稳)
第一步:拉取镜像与编写 Compose 配置
1.1 拉取镜像
官方镜像为 linshen/prompt-optimizer:latest。直接拉:
docker pull linshen/prompt-optimizer:latest
如果拉取报 TLS 握手失败,多半是 Docker daemon 配了失效的镜像加速器。两个办法二选一:
# 办法一:换个能用的加速器,写入 /etc/docker/daemon.json
{
"registry-mirrors": ["https://docker.1ms.run", "https://docker.xuanyuan.me"]
}
# 然后 sudo systemctl restart docker
# 办法二:手动指定源拉取(临时绕过加速器)
docker pull linshen/prompt-optimizer:latest
1.2 编写 docker-compose
在 Portainer Stacks(或本地 docker-compose.yml)里写如下配置:
services:
prompt-optimizer:
image: linshen/prompt-optimizer:latest
container_name: prompt-optimizer
restart: unless-stopped
ports:
- "28081:80"
environment:
# —— 注册一个名为 "llama" 的自定义模型提供商 ——
- VITE_CUSTOM_API_KEY_llama=dummy_key # 本地服务不需要真 Key
- VITE_CUSTOM_API_BASE_URL_llama=http://<服务器IP>:28081/llama/v1
- VITE_CUSTOM_API_MODEL_llama=your-model.gguf # 你的模型文件名
# MCP 默认走自定义模型
- MCP_DEFAULT_MODEL_PROVIDER=custom
# 访问认证(强烈建议设置)
- ACCESS_USERNAME=admin
- ACCESS_PASSWORD=<设置一个强密码>
volumes:
- /opt/prompt-optimizer/nginx-iframe.conf:/etc/nginx/http.d/default.conf:ro
几个要点先记住,后面会解释为什么这么写:
- 端口
28081:80:把容器内 nginx 的 80 映射到宿主机 28081,避免和别的服务抢 80。 VITE_CUSTOM_API_*环境变量:Prompt Optimizer 启动时会扫描这些变量,自动注册成一个模型提供商。命名规则是VITE_CUSTOM_API_KEY_<名字>、VITE_CUSTOM_API_BASE_URL_<名字>、VITE_CUSTOM_API_MODEL_<名字>,三件套配套使用。- API Base URL 指向反代路径而不是直接指向模型机——这是绕过 CORS 的核心,下一节详说。
- volumes 挂载自定义 nginx 配置:为了支持 iframe 嵌入和反代。
第二步:让本地大模型被识别
Prompt Optimizer 注册自定义模型的机制是:容器启动时有个 generate-config.sh 脚本,扫描所有 VITE_CUSTOM_API_* 环境变量,把它们注入到前端的 window.runtime_config 里。
所以你只要把三件套写进环境变量,前端就会多出一个叫 llama 的模型选项。无需改源码、无需重新构建镜像。
- VITE_CUSTOM_API_KEY_llama=dummy_key # 占位即可
- VITE_CUSTOM_API_BASE_URL_llama=http://<服务器IP>:28081/llama/v1 # 反代地址
- VITE_CUSTOM_API_MODEL_llama=your-model.gguf # 模型名
小贴士:本地 llama.cpp 默认不校验 Key,所以
dummy_key随便填。但如果以后接云端 API,这里要填真实 Key。
第三步:配置 nginx 反向代理(关键)
这一步是整个部署的”心脏”。如果你跳过它,大概率会卡在”测试连接失败”上(详见踩坑实录第 2 个问题)。
我们要用一份自定义 nginx 配置替换掉镜像自带的,实现两件事:
- 允许 iframe 嵌入:移除
X-Frame-Options,方便以后把工具嵌进你的工具站 - 反代
/llama/路径:让浏览器访问同源地址,彻底绕过 CORS
3.1 在宿主机创建配置文件
sudo mkdir -p /opt/prompt-optimizer
sudo tee /opt/prompt-optimizer/nginx-iframe.conf << 'EOF'
server {
listen 80; # 注意:硬编码端口,不要用 ${NGINX_PORT}(原因见踩坑第5个)
# 允许 iframe 嵌入
add_header Content-Security-Policy "frame-ancestors *" always;
# —— Llama API 反向代理(核心)——
location /llama/ {
auth_basic off; # 反代路径不走 Basic Auth
proxy_pass http://<模型机IP>:8080/; # 你的 llama.cpp server
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 完整 CORS 头
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
# 处理 OPTIONS 预检请求
if ($request_method = OPTIONS) {
return 204;
}
}
# SPA 路由(保留原镜像的认证)
location / {
include /etc/nginx/http.d/auth.conf;
try_files $uri $uri/ /index.html;
}
}
EOF
3.2 验证反代是否生效
部署后,直接在浏览器访问:
http://<服务器IP>:28081/llama/v1/models
如果返回一段 JSON(里面是你的模型列表),说明反代通了 ✅。
踩坑实录:6 个问题与解决方案
部署过程并非一帆风顺。下面这 6 个坑,每一个都真实踩过,按解决顺序排列。强烈建议先通读一遍再动手,能少走大量弯路。
坑 1:Docker 镜像拉取失败
现象
failed to copy: httpReadSeeker: ... remote error: tls: handshake failure
原因:Docker daemon 配了已失效的镜像加速器,TLS 握手直接挂。
解决:见上文”第一步 1.1″,换可用加速器或手动拉取。
耗时:约 10 分钟|严重度:🔴 阻断
坑 2:模型连接测试失败(CORS 跨域)—— 最烧脑的一个
现象:在 Prompt Optimizer 里点「测试连接」,报:
Llama连接测试失败:API错误: Connection test failed: Connection error.
但奇怪的是,直接 curl 模型机的接口却能正常返回模型列表。
排查过程(建议照着这个顺序套用到任何”连接失败”问题):
| 步骤 | 结果 | 结论 |
|---|---|---|
curl 测 GET /v1/models | ✅ 返回 JSON | 模型服务网络通 |
| 浏览器打开 llama Web UI | ✅ 正常显示 | 浏览器到模型机网络通 |
给 llama.cpp 加 --cors 参数重启 | ❌ 仍失败 | --cors 不够用 |
根因(关键洞察):
通过读 Prompt Optimizer 源码(追了 8 个文件)才发现——
curl测的是GET /v1/models,这是”简单请求”,不触发 CORS 预检;- 工具测的是
POST /v1/chat/completions,还带了Authorization: Bearer dummy_key请求头; - 带自定义头的 POST 会触发浏览器的 CORS 预检(OPTIONS 请求);
- 而 llama.cpp 的
--cors参数对”带自定义头的预检请求”支持不完整,返回的 CORS 响应头缺胳膊少腿,浏览器直接拦截。
浏览器 ──POST + Authorization──▶ 模型机:8080
│
├─ 先发 OPTIONS 预检
├─ llama.cpp 返回的 CORS 头不完整
└─ 浏览器拦截 → Connection error
最终方案:用 nginx 反向代理,让浏览器访问同源路径,从根上消灭 CORS:
浏览器 ──同源──▶ <服务器IP>:28081/llama/v1 ──nginx反代──▶ 模型机:8080/v1
直连模型机(触发 CORS)浏览器POST + Auth 头模型机 :8080带 Authorization 头的 POST→ 触发 OPTIONS 预检→ CORS 响应头不完整→ 浏览器拦截,连接失败nginx 反代(同源)浏览器同源请求Docker + nginx服务器转发模型机 :8080无 CORS 预检请求直达,连接成功▲ 图 2:CORS 跨域问题与 nginx 反代方案对比——直连触发预检被拦截 vs 反代同源直达
核心原则:浏览器 CORS 是客户端安全策略,唯一可靠的绕过方式是让请求变成同源。别在源服务上折腾
--cors参数了,反代才是正解。
耗时:约 2 小时(含源码分析)|严重度:🔴 阻断
坑 3:Portainer Stacks 文件挂载失败
现象:
error mounting ".../nginx-iframe.conf" to rootfs at "...":
not a directory: Are you trying to mount a directory onto a file?
原因:Portainer Stacks 的 Web 编辑器会把相对路径解析到它自己的内部目录(类似 /data/compose/<id>/),而不是你本地的工作目录。结果它找不到文件,就把挂载点当成空目录,容器起不来。
解决:所有 volume 挂载一律用绝对路径:
volumes:
- /opt/prompt-optimizer/nginx-iframe.conf:/etc/nginx/http.d/default.conf:ro
并在宿主机上手动创建好该文件(见第三步 3.1)。
耗时:约 30 分钟|严重度:🟡 中断
坑 4:网站完全打不开
现象:部署后 http://<服务器IP>:28081/ 直接访问不了。
原因:上个坑的 volumes 挂载失败导致容器没起来,而旧容器已经被销毁,新容器又起不来——服务真空了。
解决:应急处理——先注释掉 volumes 让服务恢复,deploy 起来后,再去宿主机创建配置文件,最后取消注释重新 deploy:
# volumes:
# - /opt/prompt-optimizer/nginx-iframe.conf:/etc/nginx/http.d/default.conf:ro
教训:改配置有风险时,先保证”能跑起来”的基线,再逐步叠加。别一步到位把服务搞挂。
耗时:约 15 分钟|严重度:🔴 阻断
坑 5:Nginx 崩溃(环境变量没被替换)
现象:挂上自定义 nginx 配置后,容器起来了但网站还是打不开。日志显示:
WARN exited: nginx (exit status 1; not expected)
原因:我照着原版配置抄了 listen ${NGINX_PORT};,但——
- 原版镜像的 entrypoint 用
envsubst处理 nginx 模板文件(在/etc/nginx/templates/目录); - 而我直接把配置挂载到了最终路径
/etc/nginx/http.d/default.conf,跳过了模板处理; - 于是 nginx 把
${NGINX_PORT}当成字面字符串,解析不出端口号,启动崩溃。
解决:把变量硬编码为实际值:
sudo sed -i 's/listen ${NGINX_PORT};/listen 80;/' /opt/prompt-optimizer/nginx-iframe.conf
重新 deploy 后,访问 /llama/v1/models 返回了模型 JSON,反代正式生效 ✅。
最佳实践:挂载自定义 nginx 配置到最终路径时,把所有
${VARIABLE}替换成实际值,别指望 envsubst 帮你处理。
耗时:约 30 分钟|严重度:🔴 阻断
坑 6:测试连接仍失败(localStorage 缓存)
现象:反代明明生效了(浏览器直访 /llama/v1/models 返回 JSON),但工具 UI 里的「测试连接」还是失败。
原因:Prompt Optimizer 是纯前端应用,模型配置存在浏览器 localStorage 里。之前手动测试时保存了一个错误的旧地址,而环境变量注入的默认值不会覆盖已保存的 localStorage。
解决:在工具的模型设置 UI 里,手动把 API Base URL 改成反代地址:
http://<服务器IP>:28081/llama/v1
(或者直接清浏览器 localStorage / 用无痕窗口重试。)
耗时:约 15 分钟|严重度:🟡 中断
踩坑速查表
| # | 问题 | 严重度 | 耗时 | 根因类别 |
|---|---|---|---|---|
| 1 | Docker 镜像拉取失败 | 🔴 阻断 | 10min | 网络环境 |
| 2 | CORS 跨域连接失败 | 🔴 阻断 | 2h | 浏览器安全策略 |
| 3 | Portainer 文件挂载失败 | 🟡 中断 | 30min | Portainer 路径解析 |
| 4 | 网站完全打不开 | 🔴 阻断 | 15min | 容器未启动 |
| 5 | Nginx 崩溃 | 🔴 阻断 | 30min | 环境变量替换 |
| 6 | 测试连接仍失败 | 🟡 中断 | 15min | localStorage 缓存 |
总耗时约 3.5 小时(含源码分析)。如果提前看了这篇,估计 40 分钟搞定。
5 条可直接复用的经验
经验 1:CORS 问题,反代永远是正解
❌ 在源服务加 --cors 参数 → 对预检请求支持不完整
❌ 在源服务加 --allow-origin → 同上
✅ 中间层加 nginx 反向代理 → 同源请求,彻底解决
经验 2:Portainer Stacks 不认相对路径
Web 编辑器会把所有相对路径解析到内部目录。记住一句话:在 Portainer Stacks 里,volume 挂载永远用绝对路径。
经验 3:Docker 镜像的模板变量会”失灵”
很多官方镜像用 envsubst 处理 nginx 模板变量(如 ${NGINX_PORT}),但直接挂载到最终路径会跳过模板处理。挂载自定义配置时,把所有 ${VARIABLE} 写成实际值。
经验 4:纯前端应用的 localStorage 是”隐形缓存”
像 Prompt Optimizer 这类纯前端应用,配置存在 localStorage,环境变量注入的默认值不会覆盖已保存值。改了环境变量后,记得清 localStorage 或在 UI 里手动更新。
经验 5:分层排查法——连接失败的万能套路
遇到”连接失败”类问题,按这个顺序排查最高效:分层排查法:连接失败排查顺序1curl 测试排除网络层问题2浏览器直接访问排除浏览器到目标的网络3F12 Network 抓包看 Method · URL · Status · Response4看容器日志看服务端是否收到请求5阅读源码看前端到底发了什么请求适用于几乎所有「前端调后端调不通」的场景▲ 图 3:连接失败分层排查法——从 curl 测试到阅读源码的五步排查顺序
这套打法适用于几乎所有”前端调后端调不通”的场景。
最终配置文件一览
部署完成后,你手上应该有这两个核心文件:
| 文件 | 路径 | 作用 |
|---|---|---|
| Docker Compose | Portainer Stacks / docker-compose.yml | 服务编排与环境变量注入 |
| Nginx 配置 | /opt/prompt-optimizer/nginx-iframe.conf | iframe 支持 + 模型反代 |
运行状态检查清单:
| 组件 | 检查方式 | 期望结果 |
|---|---|---|
| Prompt Optimizer | 浏览器访问 http://<服务器IP>:28081 | 打开登录页 |
| llama.cpp server | curl http://<模型机IP>:8080/v1/models | 返回模型 JSON |
| nginx 反代 | 浏览器访问 http://<服务器IP>:28081/llama/v1/models | 返回模型 JSON |
| 模型连接 | 工具内「测试连接」 | 通过 ✅ |
| 一键优化 | 输入一句提示词点优化 | AI 生成结构化提示词 ✅ |
常见问题 FAQ
Q1:不用 Portainer,纯命令行能部署吗?
能。把 docker-compose.yml 放任意目录,docker compose up -d 即可。volumes 路径用相对路径也没问题(相对路径的坑只在 Portainer Stacks 里出现)。
Q2:模型机和 Docker 服务器是同一台行不行?
完全可以。把 <模型机IP> 换成 127.0.0.1 或 localhost,反代照常工作。注意容器内访问宿主机要用 host.docker.internal 或宿主机的局域网 IP。
Q3:没有显卡,能用 CPU 跑模型吗?
能,但慢。建议选小模型(7B 以下)+ 低量化,上下文调小。纯 CPU 跑 12B 模型优化一个提示词可能要等一两分钟。
Q4:不想自建模型,能接云端 API 吗?
当然。Prompt Optimizer 原生支持 OpenAI / DeepSeek / 智谱等。把环境变量换成对应云端的 Key 和 Base URL 即可。DeepSeek 性价比很高,适合做备用。
Q5:iframe 嵌入到自己的网站报错怎么办?
确认 nginx 配置里加了 add_header Content-Security-Policy "frame-ancestors *" always;,并移除了 X-Frame-Options: SAMEORIGIN。本文第三步的配置已经处理好这两点。
Q6:改了环境变量为什么没生效?
大概率是 localStorage 缓存(见坑 6)。清浏览器缓存、用无痕窗口,或在工具 UI 里手动改配置。
Q7:支持多个模型同时切换吗?
支持。注册多个 VITE_CUSTOM_API_*_<名字> 三件套即可,比如同时注册 llama 和 qwen 两个提供商,UI 里可下拉切换。
后续可扩展方向
部署跑通只是起点,后续还能往这些方向扩展:
- 工具站集成:把 Prompt Optimizer 作为新标签页嵌入到你的个人工具站首页
- 多模型切换:同时挂载 Gemma、Qwen 等多个本地模型,按场景切换
- 云端兜底:加一个 DeepSeek 等云端 API 作为备用,本地模型挂了自动顶上
- 自动更新:在 Portainer 配置 Watchtower 之类的工具实现镜像自动升级
- HTTPS:通过反代或 Let’s Encrypt 给内网服务加上 HTTPS
- 模型负载均衡:跑多个 llama 实例,用 nginx 做轮询负载均衡
- MCP 集成:把内置的 MCP Server 接入工作流自动化
写在最后
这次部署最深刻的体会是:“连接失败”这四个字背后,可能藏着五层问题。 从网络层、浏览器安全策略、容器路径解析、模板变量处理,到前端缓存——任何一环掉链子,表现都是同样的”失败”。
好在每填一个坑,就多攒一条可复用的经验。希望这篇整理能帮你绕过这些弯路,把精力花在真正有意思的事上——比如调教出更厉害的提示词。
如果你也在折腾本地 AI 部署,欢迎在评论区交流你的踩坑故事。部署愉快 🚀
本文基于一次真实部署实践整理,配置均已脱敏。如需引用请注明出处。