
一、为什么做这件事
用蓝牙耳机开会,会后自动拿到一份结构化的会议纪要——这件事完全可以在自己的 VPS 上免费实现,不需要买带 AI 的专属耳机,也不需要按分钟付费的商业订阅。
自建的三个直接收益:
- 零按分钟费用:转录用本地 faster-whisper,摘要走自有 OneAPI 网关,没有时长上限。
- 数据自持:音频与转写文本只在自己的 VPS 上处理,不进入第三方云。
- 完全可控:摘要模板、语言、输出格式随时可改。
本文记录从零搭建到跑通的完整过程,包括中间踩过的网络坑和最终解法。
二、整体架构
VPS 在云端,无法直接”听”到你 Mac 上的会议声音,所以流程必然是本地录音 → 上传 VPS → 转写 → 摘要:
[BlackHole 聚合设备] --录音--> [QuickTime 导出 wav/m4a]
|
v (文件落到 ~/Downloads/Meetings)
[Mac: meeting_watcher.py (launchd 常驻)]
| HTTP POST (Tailscale 内网)
v
[VPS: FastAPI /summarize]
|
v
[faster-whisper 本地转写]
|
v
[OneAPI → LLM 生成结构化摘要]
|
v
[同目录生成 *_SUMMARY.txt] <--+
核心只有两个组件:VPS 上的处理 API,和 Mac 上的文件夹监控脚本。
三、VPS 端部署
3.1 项目结构
/opt/meeting-processor/
├── app/main.py
└── docker-compose.yml
3.2 main.py
import os
import threading
from fastapi import FastAPI, UploadFile, File
import requests
from faster_whisper import WhisperModel
app = FastAPI()
ONEAPI_URL = os.getenv("ONEAPI_URL")
ONEAPI_KEY = os.getenv("ONEAPI_KEY")
LLM_MODEL = os.getenv("LLM_MODEL")
print("Loading Whisper Model...")
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
print("Whisper model ready.")
_transcribe_lock = threading.Lock()
@app.get("/health")
def health():
return {"ok": True}
@app.post("/summarize")
async def summarize_meeting(file: UploadFile = File(...)):
temp_path = f"temp_{file.filename}"
with open(temp_path, "wb") as f:
f.write(await file.read())
with _transcribe_lock:
segments, info = whisper_model.transcribe(temp_path, beam_size=5)
detected_lang = info.language
transcript = " ".join([s.text for s in segments])
os.remove(temp_path)
headers = {"Authorization": f"Bearer {ONEAPI_KEY}",
"Content-Type": "application/json"}
system_instruction = (
"You are an expert executive secretary. Analyze the transcript, detect the "
"primary language (English, Japanese, or Chinese) and answer in THAT language. "
"Structure the output exactly as: 1. Executive Summary / 核心摘要 / 概要, "
"2. Key Decisions / 关键决策 / 決定事項, "
"3. Action Items with owners / 待办事项与负责人 / タスクと担当者."
)
payload = {
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system_instruction},
{"role": "user", "content": f"Transcript:\n{transcript}"},
],
"temperature": 0.2,
"max_tokens": 2000,
}
r = requests.post(f"{ONEAPI_URL}/chat/completions",
json=payload, headers=headers, timeout=600)
try:
summary = r.json()["choices"][0]["message"]["content"]
except Exception:
return {"success": False, "error": f"OneAPI error {r.status_code}: {r.text[:500]}"}
return {"success": True, "summary": summary, "language": detected_lang}
3.3 docker-compose.yml(带资源限制)
services:
meeting-processor:
image: python:3.11-slim
container_name: meeting_processor
ports:
- "100.99.4.120:8001:8000" # 只绑 Tailscale IP,公网不可达
environment:
- ONEAPI_URL=http://172.17.0.1:3000/v1
- ONEAPI_KEY=sk-xxx
- LLM_MODEL=gpt-oss:120b
- OMP_NUM_THREADS=2
volumes:
- ./app:/app
- meeting_models:/root/.cache
working_dir: /app
command: >
bash -c "pip install --no-cache-dir fastapi uvicorn requests python-multipart faster-whisper && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1"
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2.0'
memory: 3G
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
meeting_models:
资源限制是关键——VPS 上还跑着 nginx、MySQL、n8n、OneAPI 等一堆服务,转写任务不能把 CPU 吃满。
四、Mac 端录音与监控
4.1 BlackHole 聚合设备
brew install blackhole-2ch,重启后在”音频 MIDI 设置”里建两个虚拟设备:
- Meeting Output(多输出设备):BlackHole 2ch + 耳机/扬声器
- Meeting Input(聚合设备):BlackHole 2ch + 麦克风,时钟源选 BlackHole 2ch
开会时系统输出选 Meeting Output,QuickTime 录音输入选 Meeting Input,录完存到 ~/Downloads/Meetings/。
4.2 watcher 脚本(launchd 常驻)
~/meeting_watcher.py 监控文件夹,文件大小稳定后上传,摘要写回同目录:
import os, time, requests
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
WATCH_FOLDER = os.path.expanduser("~/Downloads/Meetings")
VPS_API_URL = "http://100.99.4.120:8001/summarize"
class Handler(FileSystemEventHandler):
def on_created(self, event):
if event.is_directory or os.path.basename(event.src_path).startswith('.'):
return
path = event.src_path
if not path.lower().endswith(('.mp3', '.m4a', '.wav')):
return
size = -1
while True:
time.sleep(3)
try:
cur = os.path.getsize(path)
if cur == size and cur > 0:
break
size = cur
except OSError:
time.sleep(1)
with open(path, 'rb') as f:
files = {'file': (os.path.basename(path), f, 'audio/mpeg')}
r = requests.post(VPS_API_URL, files=files, timeout=900)
if r.status_code == 200 and r.json().get("success"):
out = os.path.splitext(path)[0] + "_SUMMARY.txt"
with open(out, "w", encoding="utf-8") as o:
o.write(r.json()["summary"])
os.system('osascript -e \'display notification "Summary ready!" with title "Meeting Assistant"\' 2>/dev/null')
用 launchd plist 让它开机自启、崩溃自重启。注意:macOS 的 Homebrew Python 受 PEP 668 保护,pip3 install 会被拒。正确做法是给 watcher 建一个专用 venv:
python3 -m venv ~/.meeting-watcher-venv
~/.meeting-watcher-venv/bin/pip install watchdog requests
plist 里的 ProgramArguments 指向 ~/.meeting-watcher-venv/bin/python3。
五、踩坑记录:容器为什么连不上 OneAPI
这是整个项目耗时最长的部分。VPS 上 OneAPI 跑在 0.0.0.0:3000,Mac 从公网访问完全正常,但 meeting_processor 容器内 requests.post("http://172.17.0.1:3000/...") 一直报 Connection refused。
5.1 逐层排除
| 尝试 | 结果 | 原因 |
|---|---|---|
ufw allow from 172.17.0.0/16 to any port 3000 |
失败 | 容器实际在 172.21.0.0/16,不是 172.17 |
ufw allow from 172.21.0.0/16 to any port 3000 |
失败 | 流量走 FORWARD,不是 INPUT |
| DOCKER-USER 放行 172.16.0.0/12 互访 | 失败 | UFW 的 DEFAULT_FORWARD_POLICY=DROP 在 DOCKER-USER 之前就丢包 |
| 改 DEFAULT_FORWARD_POLICY=ACCEPT | 失败 | 容器到宿主机网关走 INPUT,不是 FORWARD |
host.docker.internal:host-gateway |
超时 | 最终仍经 docker-proxy 转发,路径没变 |
| extra_hosts 写死 one-api 容器 IP | 超时 | OneAPI 有多个 IP,取到的那个从容器不可达 |
| socat 转发 172.21.0.1:3001 → 127.0.0.1:3000 | 超时 | 3001 没在 INPUT 链放行 |
iptables -I INPUT 1 -s 172.21.0.0/16 --dport 3001 -j ACCEPT |
成功 | 插到 INPUT 最前面,绕过 UFW 链式结构 |
5.2 根因
meeting_processor 在 172.21.0.0/16 网桥,它访问 172.21.0.1:3001(socat 转发端口)时,流量进入宿主机 INPUT 链。UFW 默认 deny (incoming),而 ufw allow 添加的规则在 ufw-user-input 链里,包在到达那条规则前就被 before-input 链或默认策略丢了。
关键教训:ufw allow 不保证生效——它只是往 UFW 自己的链里加规则,如果包在到达那条链之前就被别的规则处理了,放行规则形同虚设。直接用 iptables -I INPUT 1 插到最前面,是验证”是不是 INPUT 拦的”最快方式。
5.3 最终方案
# 容器访问宿主机 socat 端口
iptables -I INPUT 1 -s 172.21.0.0/16 -p tcp --dport 3001 -j ACCEPT
iptables -I INPUT 1 -i br-<网桥名> -p tcp --dport 3001 -j ACCEPT
# socat 转发
# /etc/systemd/system/oneapi-forward.service
# ExecStart=/usr/bin/socat TCP-LISTEN:3001,bind=172.21.0.1,fork,reuseaddr TCP:127.0.0.1:3000
# 持久化
apt-get install -y iptables-persistent
netfilter-persistent save
meeting_processor 的 ONEAPI_URL 改成 http://172.21.0.1:3001/v1。OneAPI 容器全程未动,StartedAt 不变。
5.4 为什么 Mac 能访问但容器不能
Mac 通过公网 162.43.92.249:3000 访问,走外部 → 宿主机 INPUT,UFW 里已有 3000/tcp ALLOW Anywhere,所以通。
容器访问 172.17.0.1:3000 走内部网桥 → 宿主机,被 UFW 的 deny (incoming) 拦。两者路径完全不同,不能互相推断。
六、验证结果
真实中文测试(say -v Tingting 生成 30 秒会议音频):
耗时: 5s
{"success":true,
"summary":"**1. 概要** 今天下午召开了产品评审会议,决定在下周一上线新的移动端功能。
**2. 关键决策** - 新功能的上线时间定为下周一…
**3. 待办事项与负责人** - **功能测试**:张三…",
"language":"zh"}
watcher 常驻后,丢文件 10 秒内自动出 _SUMMARY.txt,右上角弹通知。
七、日常使用
- 系统声音输出选 Meeting Output,QuickTime 录音输入选 Meeting Input
- 录完存到
~/Downloads/Meetings/ - 10 秒后同目录出现
_SUMMARY.txt - 查看状态:
launchctl list | grep meeting-watcher、tail -f /tmp/meeting_watcher.log
八、踩坑清单
| # | 坑 | 解法 |
|---|---|---|
| 1 | Homebrew Python 拒绝 pip3 install | 给 watcher 建专用 venv |
| 2 | launchd 加载后立刻生成测试文件,watcher 没捕捉到 | 真实使用时无此问题;测试时先确认服务在跑再丢文件 |
| 3 | ufw allow from 172.21.0.0/16 不生效 | 流量走 INPUT,用 iptables -I INPUT 1 插到最前 |
| 4 | DEFAULT_FORWARD_POLICY 改了仍不通 | 容器到宿主机走 INPUT,不是 FORWARD |
| 5 | extra_hosts 写死容器 IP | OneAPI 多网络有多个 IP,取到的可能不可达 |
| 6 | socat 端口没放行 | 同样用 iptables -I INPUT 1 |
| 7 | iptables -I 重启丢失 | iptables-persistent + netfilter-persistent save |
| 8 | 容器镜像没有 curl,诊断脚本假阴性 | 用镜像自带的 python -c 测连通性 |
| 9 | say 和 watcher 启动竞态 | 真实场景不存在;测试时等 watcher 就绪 |
| 10 | 推理模型 max_tokens 太小导致 content: null | max_tokens=2000,或换非推理模型 |
九、已知限制
- CPU 转写速度:base 模型 + int8,1 小时音频约 2–5 分钟。长会议可换 small 模型,代价是内存。
- 单任务锁:并发上传会排队,避免 CPU 峰值。
- Tailscale 依赖:Mac 和 VPS 需在同一 Tailscale 网络。公网访问需另行开放端口并加认证。
- OneAPI 上游限流:DeepSeek 偶尔 429,OneAPI 会自动重试其他渠道,不是代码问题。
十、小结
整套方案的核心是分层解耦:本地负责录音,VPS 负责转写和摘要,OneAPI 负责模型接入。每一层都可以独立替换——换录音工具、换 Whisper 模型、换 LLM 都不影响其他层。
最耗时的不是代码,而是 Docker 容器访问宿主机服务的网络路径。ufw allow 不等于放行,172.17.0.1 不是万能网关,容器 IP 会变——这些只有逐层实测才能定位。最终用”socat 转发 + iptables -I INPUT 1 放行 + 持久化”这套组合拳解决,OneAPI 全程未动。
开完会丢个文件,10 秒后拿到结构化纪要,零按分钟费用,数据全在自己 VPS 上。
参考环境:Ubuntu VPS(CPU-only,约 6GB 内存,含 nginx/MySQL/n8n/OneAPI/Tailscale)、macOS + BlackHole 2ch、Python 3.11、faster-whisper base + int8、OneAPI 网关、gpt-oss:120b


讨论
订阅评论 RSS加入讨论
分享你的想法与经验,也可以登录后参与讨论。
使用 WordPress 登录