很多人第一次搭 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 会议链路真正值得做的地方。