Whisper + CAM++ + 本地大模型:一套完全离线的AI会议处理链路

很多人第一次搭 AI 会议助手,思路往往很简单:

text 复制代码
录音
 ↓
Whisper
 ↓
转写文本
 ↓
大模型总结

Demo 确实可以这样做。

拿一段十分钟录音,Whisper 转成文字,再把全文扔给 Qwen、DeepSeek 或其他大模型,让它输出一份"会议总结",很快就能得到一个看起来不错的结果。

但真正拿到企业会议里,很快就会发现问题。

一场两小时的会议可能有八九个人轮流发言,中间夹杂大量静音、讨论、重复确认和中英文专业术语。Whisper 知道大家说了什么,却不知道是谁说的;大模型可以总结会议,却很难凭一整段没有身份信息的文本准确判断"这个任务到底是谁认领的"。

更麻烦的是,一些会议录音并不能上传到公有云。

如果要求音频、声纹、转写文本和会议纪要都留在内部网络,那么整个链路就必须重新设计。

这次我们尝试把三个相对独立的能力组合起来:

text 复制代码
Whisper
负责:听清楚

CAM++
负责:分清楚是谁

本地大模型
负责:整理清楚

最后再由会议业务层把录音、时间轴、发言人、摘要和待办组织到一起。

这才逐渐接近一套真正可用的离线 AI 会议系统。


一、先把整条链路拆开,而不是做一个"大而全"的服务

我们最终没有把 Whisper、CAM++ 和大模型全部塞到同一个 Python 进程里。

原因很简单。

三个模块的资源需求完全不同。

Whisper 长时间占用 GPU 做音频推理;

CAM++ 处理的是声纹特征;

本地大模型又需要一块较大的显存或独立推理设备。

如果把三者强行绑定:

text 复制代码
meeting_server.py
  ├── Whisper
  ├── CAM++
  └── Qwen

任何一个模型发生 OOM,都可能把整个会议服务带崩。

更合理的结构是:

text 复制代码
                 会议录音
                    ↓
              音频预处理
                    ↓
                 VAD
                    ↓
            Whisper ASR
                    ↓
        timestamp + transcript
                    ↓
             CAM++ 声纹
                    ↓
speaker + timestamp + transcript
                    ↓
              本地大模型
                    ↓
       摘要 / 决策 / 待办 / 风险
                    ↓
              会议业务系统
                    ↓
         归档 / 检索 / 原音回听

在本文的实际集成思路中,熙瑾会悟位于最后的业务处理层,接收前面模型产生的结构化结果,而不是直接把所有 AI 模型写进一个服务。

这种拆分方式最大的好处,是以后换模型非常方便。

Whisper 可以换成其他 ASR。

CAM++ 可以升级或者增加声纹库。

总结模型也可以从 7B 换到 32B。

只要接口数据结构保持不变,上层会议流程不用重新设计。


二、第一步:先让 Whisper 把"说了什么"稳定输出

会议 ASR 先从 faster-whisper 开始。

安装环境:

bash 复制代码
conda create -n meeting-asr python=3.10 -y
conda activate meeting-asr

pip install faster-whisper
pip install fastapi uvicorn python-multipart

准备模型:

python 复制代码
from faster_whisper import WhisperModel

model = WhisperModel(
    "large-v3",
    device="cuda",
    compute_type="float16",
)

识别会议:

python 复制代码
segments, info = model.transcribe(
    "/data/meeting.wav",
    language="zh",
    beam_size=5,
    vad_filter=True,
)

segments = list(segments)

for item in segments:
    print(
        f"[{item.start:.2f} - {item.end:.2f}] "
        f"{item.text}"
    )

输出:

text 复制代码
[1.23 - 5.81] 今天主要确认一下项目上线时间。
[6.42 - 12.36] 接口部分我这边周五之前做完。
[13.02 - 18.71] 那测试环境的问题我们运维这边继续处理。

这里我们没有只保存最终全文:

text 复制代码
今天主要确认一下项目上线时间......

而是保留时间轴:

json 复制代码
[
  {
    "start_ms": 1230,
    "end_ms": 5810,
    "text": "今天主要确认一下项目上线时间。"
  },
  {
    "start_ms": 6420,
    "end_ms": 12360,
    "text": "接口部分我这边周五之前做完。"
  }
]

这是一个很重要的决定。

因为后面的 CAM++ 需要知道每句话对应哪一段音频。

如果 ASR 阶段只留下纯文本,后面再想恢复说话人和原音定位,会麻烦很多。


三、会议录音进入 Whisper 前,最好统一处理一次

实际录音来源非常混乱。

手机可能是:

text 复制代码
m4a
44.1kHz
双声道

会议终端可能是:

text 复制代码
wav
48kHz
双声道

浏览器录音又可能是:

text 复制代码
webm
opus

为了减少后续模型变量,统一成:

text 复制代码
16kHz
单声道
PCM WAV

处理命令:

bash 复制代码
ffmpeg -y \
  -i input.m4a \
  -ar 16000 \
  -ac 1 \
  -c:a pcm_s16le \
  meeting.wav

检查:

bash 复制代码
ffprobe \
  -v error \
  -select_streams a:0 \
  -show_entries stream=codec_name,sample_rate,channels \
  -show_entries format=duration \
  -of default=noprint_wrappers=1 \
  meeting.wav

输出:

text 复制代码
codec_name=pcm_s16le
sample_rate=16000
channels=1
duration=7364.28

这一步看起来很基础,却非常重要。

因为一旦输入格式不统一,后面出现识别差异时,很难判断到底是模型问题还是音频本身的问题。


四、Whisper听懂了,但它还不知道"是谁说的"

假设一场会议最终得到:

text 复制代码
今天主要确认项目上线时间。

接口部分我这边周五之前做完。

测试环境的问题我们运维继续处理。

从语义上已经能看懂。

但作为会议纪要,还缺一项非常关键的信息:

text 复制代码
谁说的?

因此第二步需要引入说话人处理。

这里可以使用 CAM++ 提取声纹特征,再结合聚类完成 speaker diarization。

我们可以先通过 FunASR 快速验证:

bash 复制代码
pip install -U funasr
pip install modelscope

加载:

python 复制代码
from funasr import AutoModel

model = AutoModel(
    model="paraformer-zh",
    vad_model="fsmn-vad",
    punc_model="ct-punc",
    spk_model="cam++",
    device="cuda:0",
)

识别:

python 复制代码
result = model.generate(
    input="/data/meeting.wav",
    batch_size_s=60,
)

for item in result[0].get(
    "sentence_info",
    [],
):
    print(
        item.get("spk"),
        item.get("start"),
        item.get("end"),
        item.get("text"),
    )

输出可能变成:

text 复制代码
0 1230 5810 今天主要确认一下项目上线时间。
1 6420 12360 接口部分我这边周五之前做完。
2 13020 18710 测试环境的问题我们运维继续处理。

于是结构就从:

text 复制代码
timestamp + text

升级成了:

text 复制代码
speaker + timestamp + text

五、ASR 和说话人识别不要各做各的

这里有一个工程上非常容易踩的坑。

Whisper 有自己的分段结果:

text 复制代码
1.23 - 5.81
6.42 - 12.36
13.02 - 18.71

说话人系统又可能给出:

text 复制代码
Speaker 0:1.10 - 5.95
Speaker 1:6.20 - 12.52
Speaker 2:12.85 - 18.90

两个模型对语音边界的判断不会完全一致。

因此不能简单按照:

text 复制代码
Whisper第1句 = CAM++第1段
Whisper第2句 = CAM++第2段

硬匹配。

比较稳妥的方式,是按照时间区间重叠程度寻找 speaker。

python 复制代码
def overlap_duration(
    start_a: int,
    end_a: int,
    start_b: int,
    end_b: int,
) -> int:
    return max(
        0,
        min(end_a, end_b)
        - max(start_a, start_b),
    )


def match_speaker(
    asr_segment: dict,
    speaker_segments: list[dict],
):
    best_speaker = "unknown"
    best_overlap = 0

    for speaker in speaker_segments:
        overlap = overlap_duration(
            asr_segment["start_ms"],
            asr_segment["end_ms"],
            speaker["start_ms"],
            speaker["end_ms"],
        )

        if overlap > best_overlap:
            best_overlap = overlap
            best_speaker = speaker[
                "speaker_id"
            ]

    return best_speaker

然后进行合并:

python 复制代码
def merge_asr_and_speaker(
    asr_segments: list[dict],
    speaker_segments: list[dict],
):
    result = []

    for item in asr_segments:
        speaker = match_speaker(
            item,
            speaker_segments,
        )

        result.append(
            {
                "speaker_id": speaker,
                "start_ms": item["start_ms"],
                "end_ms": item["end_ms"],
                "text": item["text"],
            }
        )

    return result

最终得到:

json 复制代码
[
  {
    "speaker_id": "speaker_0",
    "start_ms": 1230,
    "end_ms": 5810,
    "text": "今天主要确认一下项目上线时间。"
  },
  {
    "speaker_id": "speaker_1",
    "start_ms": 6420,
    "end_ms": 12360,
    "text": "接口部分我这边周五之前做完。"
  },
  {
    "speaker_id": "speaker_2",
    "start_ms": 13020,
    "end_ms": 18710,
    "text": "测试环境的问题我们运维继续处理。"
  }
]

到了这里,一份会议数据已经开始有真正的结构。


六、Speaker 0还不是"张工",声纹身份需要再做一层

CAM++ 能帮助判断:

text 复制代码
这段声音和上一段是不是同一个人。

但系统一开始并不知道:

text 复制代码
Speaker 0是谁?

如果需要把:

text 复制代码
Speaker 0

变成:

text 复制代码
张主任

还需要建立声纹库。

基本流程:

text 复制代码
用户注册声音
    ↓
提取CAM++ embedding
    ↓
保存声纹向量
    ↓
会议中提取说话人embedding
    ↓
计算相似度
    ↓
匹配已注册用户

一个简单的声纹记录可以设计成:

json 复制代码
{
  "user_id": "U1024",
  "name": "张主任",
  "embedding": [
    0.0135,
    -0.2291,
    0.0862
  ]
}

匹配时可以使用余弦相似度:

python 复制代码
import numpy as np


def cosine_similarity(
    a: np.ndarray,
    b: np.ndarray,
) -> float:
    return float(
        np.dot(a, b)
        /
        (
            np.linalg.norm(a)
            * np.linalg.norm(b)
        )
    )

最终会议数据就可能变成:

json 复制代码
{
  "speaker_id": "speaker_0",
  "speaker_name": "张主任",
  "start_ms": 1230,
  "end_ms": 5810,
  "text": "今天主要确认一下项目上线时间。"
}

当然,真实会议里还会遇到远场拾音、多人抢话、声音变化、麦克风距离不同等问题。

因此声纹身份匹配不能简单理解成:

text 复制代码
相似度最高 = 一定是这个人

正式系统通常还需要设定阈值,并允许:

text 复制代码
unknown

存在。

宁可暂时显示"未知发言人",也不要强行匹配成一个错误身份。


七、第三步:本地大模型不是"总结一下",而是结构化会议内容

完成 ASR 和 speaker 以后,输入已经类似:

text 复制代码
张主任:
今天主要确认一下项目上线时间。

李工:
接口部分我这边周五之前做完。

王工:
测试环境的问题我们运维继续处理。

接下来才轮到本地大模型。

很多 Demo 到这里会直接写:

python 复制代码
prompt = f"""
请总结下面的会议内容:

{transcript}
"""

可以用,但对于企业会议来说还不够。

我们真正希望模型输出的是结构化结果:

json 复制代码
{
  "summary": "...",
  "topics": [],
  "decisions": [],
  "todos": [],
  "risks": []
}

Prompt 可以设计成:

python 复制代码
SYSTEM_PROMPT = """
你是一套会议内容整理系统。

请根据输入的带发言人会议记录,
整理会议结果。

要求:
1. 不添加原文没有的信息;
2. 区分讨论内容和最终决策;
3. 待办事项尽量保留责任人;
4. 没有明确截止日期时不要编造;
5. 只输出JSON。

输出结构:

{
  "summary": "",
  "topics": [],
  "decisions": [],
  "todos": [
    {
      "owner": "",
      "task": "",
      "deadline": ""
    }
  ],
  "risks": []
}
"""

构造会议文本:

python 复制代码
def build_meeting_text(
    segments: list[dict],
) -> str:
    lines = []

    for item in segments:
        speaker = (
            item.get("speaker_name")
            or item["speaker_id"]
        )

        lines.append(
            f"{speaker}:{item['text']}"
        )

    return "\n".join(lines)

调用本地模型:

python 复制代码
from openai import OpenAI

client = OpenAI(
    base_url="http://127.0.0.1:1025/v1",
    api_key="EMPTY",
)

meeting_text = build_meeting_text(
    segments
)

response = client.chat.completions.create(
    model="qwen3",
    temperature=0.1,
    messages=[
        {
            "role": "system",
            "content": SYSTEM_PROMPT,
        },
        {
            "role": "user",
            "content": meeting_text,
        },
    ],
)

print(
    response.choices[0]
    .message.content
)

结果可能是:

json 复制代码
{
  "summary": "会议确认项目上线时间及后续接口、测试环境安排。",
  "topics": [
    "项目上线计划",
    "接口开发",
    "测试环境准备"
  ],
  "decisions": [
    "项目按计划推进上线准备"
  ],
  "todos": [
    {
      "owner": "李工",
      "task": "完成接口开发",
      "deadline": "周五"
    },
    {
      "owner": "王工",
      "task": "继续处理测试环境问题",
      "deadline": ""
    }
  ],
  "risks": []
}

这和一句"帮我总结会议"已经是完全不同的使用方式。


八、为什么一定要把 speaker 信息交给大模型

可以做一个很简单的对比。

没有 speaker:

text 复制代码
接口周五前完成。
测试环境由运维继续处理。
上线时间暂定下周三。

模型只能知道:

text 复制代码
有这些事情。

加入 speaker:

text 复制代码
李工:
接口周五前完成。

王工:
测试环境由运维继续处理。

张主任:
上线时间暂定下周三。

模型才有机会整理出:

text 复制代码
李工 → 接口
王工 → 测试环境
张主任 → 确认上线时间

因此,说话人识别对会议系统真正有价值的地方,并不只是让界面上出现几个不同颜色的头像。

它会直接影响:

text 复制代码
责任人提取
观点归属
决策来源
待办生成
历史追溯

这也是为什么我们没有把 CAM++ 当成一个独立"炫技功能",而是把它放在 ASR 和会议大模型中间。


九、一场两小时会议,不能把全部文字一次塞给大模型

这里又会遇到另一个现实问题:

长会议。

假设两小时会议产生:

text 复制代码
约3万~5万字转写文本

直接一次送给模型不一定是最好的方案。

即便上下文窗口足够,也容易出现:

text 复制代码
前半段细节被忽略
待办事项遗漏
重复总结
责任人关联错误
模型生成时间明显增加

更稳妥的方式是分层总结。

text 复制代码
完整会议
   ↓
按照时间或议题切块
   ↓
每块生成局部摘要
   ↓
局部摘要汇总
   ↓
生成最终纪要

比如:

python 复制代码
CHUNK_SIZE = 40


def split_segments(
    segments: list[dict],
):
    return [
        segments[i:i + CHUNK_SIZE]
        for i in range(
            0,
            len(segments),
            CHUNK_SIZE,
        )
    ]

先得到:

text 复制代码
第1段摘要
第2段摘要
第3段摘要
第4段摘要

再让模型处理:

text 复制代码
请基于下面的阶段摘要,
生成整场会议的:

1. 总体摘要
2. 主要议题
3. 最终决策
4. 待办事项
5. 风险点

这种 Map-Reduce 式的处理,对长会议通常更容易控制。


十、把三个模型全部服务化

当三条能力都跑通以后,可以分别监听不同端口:

text 复制代码
Whisper ASR
127.0.0.1:18140

CAM++ Speaker
127.0.0.1:18100

Local LLM
127.0.0.1:1025

业务层依次调用。

ASR:

python 复制代码
requests.post(
    "http://127.0.0.1:18140/api/asr",
    files={"audio": audio_file},
)

Speaker:

python 复制代码
requests.post(
    "http://127.0.0.1:18100/api/speaker",
    files={"audio": audio_file},
)

LLM:

python 复制代码
client = OpenAI(
    base_url=(
        "http://127.0.0.1:1025/v1"
    ),
    api_key="EMPTY",
)

最后形成:

text 复制代码
                 ┌─ Whisper :18140
                 │
Meeting Service ─┼─ CAM++   :18100
                 │
                 └─ LLM     :1025

熙瑾会悟在这条链路里承担的就是这一层会议业务编排:会议开始和结束、音频文件管理、模型调用、任务状态、纪要结果以及后续查询都由业务层统一组织。

这样模型服务本身不需要关心:

text 复制代码
这是谁的会议?
用户有没有权限?
这场会议属于哪个项目?
纪要保存在哪里?

各层职责会清晰很多。


十一、完全离线部署真正要检查的,不只是"拔网线还能不能跑"

很多项目说自己支持离线部署,验证方式是:

text 复制代码
拔掉外网
模型还能推理

这只能证明模型本身可以离线。

真正的完整离线链路还要继续检查:

text 复制代码
模型启动是否偷偷访问Hub

Python依赖是否已经全部打包

前端是否引用公网CDN

字体和JS资源是否依赖互联网

ASR是否调用在线语言检测

大模型是否调用外部API

日志系统是否上传云端

错误监控是否连接第三方SaaS

软件许可证是否需要联网激活

因此正式交付前,可以直接做一次网络级验证:

bash 复制代码
sudo tcpdump \
  -i any \
  -n \
  'not host 127.0.0.1'

然后完整跑一遍:

text 复制代码
上传会议
→ ASR
→ 说话人
→ 大模型
→ 纪要
→ 回听
→ 检索

观察有没有异常外联。

模型也建议提前固定成本地路径:

text 复制代码
/data/models/
├── whisper-large-v3
├── campplus
└── qwen

并保存 SHA256:

bash 复制代码
find /data/models \
  -type f \
  -exec sha256sum {} \; \
  > model_sha256.txt

对于内网部署来说,"不调用云 API"只是第一步。

真正重要的是整个软件链路都不存在运行时外部依赖。


十二、完全离线不等于自动满足涉密要求

这一点必须单独说明。

本地模型确实可以让:

text 复制代码
原始会议录音
声纹信息
转写文本
会议纪要

全部留在内部设备或网络。

这对安全敏感场景非常重要。

但"模型完全离线运行"和"系统符合某一级涉密要求"不是一回事。

正式进入高安全场景,还需要继续考虑:

text 复制代码
用户身份认证
权限划分
文件加密
数据库访问控制
日志审计
USB与外设管理
网络分区
操作系统加固
数据销毁
备份策略
整机和软硬件资质

因此更准确的说法是:

Whisper + CAM++ + 本地大模型为数据不出域提供了技术基础,而完整安全等级仍取决于整套系统和管理体系。

这比简单写一句"支持涉密部署"更严谨。


十三、真正运行起来以后,最容易出问题的是这四件事

1. ASR和Speaker时间轴对不齐

不要按照数组下标直接合并。

应该按照时间重叠度进行匹配,否则一句长发言被 CAM++ 切成两段时,很容易把后面的文本分给错误的人。

2. 多人同时说话

两个人抢话时,无论 ASR 还是 speaker diarization 都会明显变难。

会议室环境中,更好的麦克风、合理的拾音距离和降噪,往往比单纯升级模型更有效。

3. 大模型会"补全"不存在的待办

比如原文:

text 复制代码
这个问题后面再看看。

模型可能整理成:

text 复制代码
待办:技术组后续解决该问题。

实际上原会议没有明确责任人。

因此 Prompt 里最好明确:

text 复制代码
没有明确责任人则留空。
没有明确截止时间禁止推断。
讨论意见不能自动写成最终决策。

4. 三个模型不要抢同一块显存

如果机器资源有限:

text 复制代码
Whisper 运行
CAM++ 运行
LLM 同时运行

非常容易出现峰值显存不足。

一种简单的调度方式是:

text 复制代码
会议进行中:
优先 ASR

会议结束:
ASR完成
 ↓
Speaker
 ↓
释放部分资源
 ↓
LLM总结

如果设备较多,再将不同模型分配到不同加速卡。

这比所有模型无脑常驻一张卡更容易控制。


十四、最终得到的已经不再是一份TXT

当整条链路打通以后,一场会议留下来的数据应该类似:

json 复制代码
{
  "meeting_id": "M20260811001",
  "duration": 7265,
  "participants": [
    "张主任",
    "李工",
    "王工"
  ],
  "segments": [
    {
      "speaker": "张主任",
      "start_ms": 1230,
      "end_ms": 5810,
      "text": "今天主要确认一下项目上线时间。"
    }
  ],
  "summary": "...",
  "decisions": [
    "项目计划下周三上线"
  ],
  "todos": [
    {
      "owner": "李工",
      "task": "完成接口开发",
      "deadline": "周五"
    }
  ]
}

用户之后可以:

text 复制代码
阅读摘要

查看某个人的全部发言

点击一句话回听原音

搜索历史会议

查询某个项目过去的全部决策

统计还有哪些待办没有完成

这才是会议 AI 真正从"录音转文字"向"会议信息管理"迈出的那一步。


十五、总结:三个模型各做一件事,反而更容易把系统做好

重新看整套架构:

text 复制代码
Whisper
↓
解决"说了什么"

CAM++
↓
解决"谁说的"

本地大模型
↓
解决"哪些内容值得留下"

会议系统
↓
解决"这些内容以后怎么使用"

没有哪个模型单独构成一套 AI 会议助手。

Whisper 再强,本质上仍然是 ASR。

CAM++ 再准确,也只是提供说话人相关信息。

本地大模型能够总结长文本,却并不知道录音文件在哪里、用户是谁、哪些会议可以查看。

真正的工程工作,是把这些模型组合成一条稳定的数据链路。

对熙瑾会悟来说,这种模块化架构还有一个额外好处:底层模型并不需要永久固定。今天可以使用 Whisper + CAM++,以后 ASR、声纹或者大模型升级时,只要保持统一的接口和数据结构,上层会议业务仍然可以继续运行。

而对于内网和安全敏感会议来说,这套路线最重要的意义也不是"用了三个开源模型"。

而是:

从音频进入设备的那一刻开始,到最终形成会议纪要,整个过程都可以留在自己的计算环境里。

这才是完全离线 AI 会议链路真正值得做的地方。

相关推荐
小润nature1 小时前
AI 与嵌入式三时简报|早报
人工智能
今天AI了吗1 小时前
MCP 协议打通 AI 与国产数据库,SQL 调优全流程一站式闭环
数据库·人工智能·sql
大任视点1 小时前
“我与秦岭”主题征文与故事及“秦岭微镜头”短视频、摄影作品征集活动通知
大数据·人工智能·业界资讯
FBI HackerHarry浩1 小时前
Pandas 库中用于分类变量独热编码(One-Hot Encoding)的函数 pd.get_dummies() 函数
开发语言·人工智能·python·pandas
manyingAi1 小时前
AI漫剧制作全流程技术拆解:从NLP剧本解析到一致性角色生成
人工智能·自然语言处理
Bzc11456231 小时前
守正笃行 匠心护康:以中医智慧赋能新时代慢病长效管理
大数据·人工智能·生活
高洁011 小时前
工信部教考中心证书-人工智能系列本系列
人工智能·python·深度学习·算法
生命涌现1 小时前
【生命涌现植物健康分析】在火山云Agent平台的使用教程
人工智能·计算机视觉·agent·多模态大模型·openclaw小龙虾技能
小润nature1 小时前
AI × 嵌入式工程晚报|端侧智能体开始以“可观测闭环”而非模型大小取胜
人工智能