by 雪隐_上班了 from juejin.cn/user/143341...
欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权。
专栏主旨 :用我那台 RTX 5060 Ti 16G + 64GB 内存 的"丐帮战车",做点不枉费电费的新奇事情。
前几章我们让显卡"听懂人话"(Whisper)、"画画"(ComfyUI)、当"AI 程序员"(Claude Code + Bonsai)。这一章来个更接地气的------让 AI 替我们去开会(准确地说,是替我们记会议纪要)。
一、会议纪要,职场第一酷刑
每个公司都有一项没有写入 JD、却人人有份的工作:记会议纪要。
一场会开一小时,整理纪要两小时。整理完发群里,没人看。下周开会,上次说的全忘了,再开一次,再记一次,再没人看。如此循环,史称 "纪要闭环" ------闭环的意思是,没有人能从里面逃出来。
我决定把这项酷刑外包给 AI。需求很明确:
- 丢进去一段会议录音(录音笔直出的那种几十 MB 的 WAV),直接吐出结构化纪要
- 得知道 谁说了什么------不然"这个需求是谁提的"永远是一笔糊涂账
- 能本地跑就本地跑,会议内容是公司机密,不能随便往外送
- 顺便,把我之前做的视频字幕生成也统一进来,一个工具全搞定
最终效果:上传录音 → 喝口水 → 拿到一份带说话人标注的 Markdown 纪要,主题、要点、决议、待办一应俱全。本文就讲讲这套东西是怎么搭起来的,核心代码长什么样,以及途中踩爆的那颗显存大雷。
二、选型:为什么不用 Whisper 了
我之前一直用 Whisper Large V3 做字幕。它很好,但它有个硬伤:它不知道谁在说话。
Whisper 只管把语音变成文字,至于这句话是老板说的还是你说的------它不在乎,它只是一台没有感情的听写机器。你给它一段三小时的辩论录音,它给你一篇"某个人从头说到尾"的文字稿。谁说了什么?不知道。谁怼了谁?不知道。谁拍板了?不知道。
要做会议纪要,说话人分离(Diarization) 是刚需。传统方案是 ASR + 独立的 diarization 管线拼起来------你先跑 Whisper 转文字,再跑一个 pyannote 分说话人,最后用脚本把两套系统的输出对齐。两套系统对接的酸爽,谁接谁知道。时间戳对不上、说话人编号串了、分段逻辑打架......调试这种管线,比开会本身还痛苦。
然后我发现了 MOSS-Transcribe-Diarize 0.9B(OpenMOSS,2026 年 7 月发布,拿了 INTERSPEECH 2026 MLC-SLM 挑战赛第一):
- 端到端一把梭:转写 + 说话人分离 + 时间戳,单次推理最长 90 分钟。不用拼管线,不用对时间戳,一个模型全搞定。
- 输出直接就是
[开始时间][S01]说话内容[结束时间]这种格式,拿来就能用。 - 支持热词提示:产品名、人名、项目代号往里一塞,识别率立竿见影。再也不用担心"我们的项目代号'Project Phoenix'"被听成"普罗杰克特菲尼克斯"了。
- 0.9B 参数,bf16 才 2GB 显存------Whisper Large V3 都有 1.55B,这货比 Whisper 还小,还能分说话人,离谱。
官方评测里它的 cpCER(说话人标注错误率)把 Doubao、ElevenLabs、Gemini 2.5 Pro 全打了一遍。更要命的是在 Alimeeting 数据集上 Δcp = -2.69 ,意思是它标的说话人归属比人工参考标注还准。
这相当于什么?学生批改了老师的作业,还改对了。 老师拿着参考答案核对,发现学生标得比参考答案还准------那到底谁是老师?
LLM 整理层,我的策略是本地优先(LM Studio):
| 任务 | 模型 | 上下文 | 理由 |
|---|---|---|---|
| 会议纪要 / 课件整理 | google/gemma-4-26b-a4b-qat |
200K | 一小时会议的转写文本随便装,完全不用截断 |
| 字幕翻译 | hy-mt2-7b |
4K | 翻译专用小模型,快且省,译体育术语也不翻车 |
| 兜底 | DeepSeek API | --- | 本地挂了才用,花钱但救命(目前还没用上过) |
三、整体架构(一张图看懂)
ini
录音/视频
│
├─[1] ffmpeg 归一化 → 16kHz 单声道 WAV core/media.py
│ (把各种乱七八糟的格式统一成模型能吃的饭)
│
├─[2] MOSS 转写 + 说话人分离 core/transcribe.py
│ → [S01] 14:32 我觉得这个需求很简单
│ → [S02] 14:35 上次你也是这么说的
│
└─[3] 本地 gemma 整理成结构化纪要 core/meeting_minutes.py
(LLM 调度:core/llm_client.py)
→ # 会议纪要 → 主题 → 参会人 → 讨论要点 → 决议 → 待办
整个项目是一个 Flask Web 应用(三个页签:字幕生成 / 会议纪要 / 课件整理),但核心全在 core/ 包里。下面逐个解剖。
四、核心代码解剖(程序员时间)
4.1 MOSS 转写后端:懒加载单例
模型加载一次好几秒,显然不能每转一段音频就加载一回。老规矩,懒加载 + 单例------只有第一次调用时才加载,之后秒回:
python
# core/transcribe.py
class MossTranscriber:
_instance = None
_lock = threading.Lock()
@classmethod
def get_instance(cls) -> "MossTranscriber":
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _load_model(self):
if self.model is not None: # 已加载就直接返回,不重复造轮子
return
from transformers import AutoModelForCausalLM, AutoProcessor
self.dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
self.model = AutoModelForCausalLM.from_pretrained(
self.model_path,
trust_remote_code=True, # MOSS 是自定义架构,必须开
dtype="auto",
attn_implementation="sdpa", # ← 重点!后面雷一那里会考
).to(dtype=self.dtype).to(self.device).eval()
self.processor = AutoProcessor.from_pretrained(
self.model_path, trust_remote_code=True,
)
设计模式小课堂 :这叫单例模式。翻译成人话就是------整个程序里只养一只猫,所有人都用同一只。 省猫粮,也省显存。
转写本身借助官方工具包就两步:构造 messages,generate:
python
def transcribe(self, audio_path, hotwords=None, max_new_tokens=8192, prompt=None):
self._load_model()
from moss_transcribe_diarize.inference_utils import (
build_transcription_messages, generate_transcription,
)
# 官方默认 prompt + 可选热词
final_prompt = prompt or DEFAULT_PROMPT
if hotwords:
final_prompt += "热词提示:" + ", ".join(hotwords)
messages = build_transcription_messages(audio_path, prompt=final_prompt)
result = generate_transcription(
self.model, self.processor, messages,
max_new_tokens=max_new_tokens,
do_sample=False, # 转写要确定性,不许模型自由发挥
device=torch.device(self.device),
dtype=self.dtype,
)
segments = self._parse_segments(result["text"])
...
do_sample=False 这行值得划重点。转写不是写诗,不需要创造性,不需要"换个说法"。你要是让模型自由发挥,它能把"老板说加个需求"听成"老板说要加薪"------差一个字,差一个世纪。
模型吐出来的原始文本长这样:
css
[2.80][S01]怎么样怎么样。[4.20][3.80][S02]因为也对照不上......[11.70]
parse_transcript 负责把它拆成 {start, end, speaker, text} 的结构化分段。我只需要过滤掉 end <= start 的非法时间戳(模型偶尔也会说梦话,比如结束时间比开始时间还早------时空错乱了属于是)。
4.2 说话人文本:喂给 LLM 的"剧本"
MOSS 的输出已经是结构化的了,但直接扔给 LLM 太啰嗦。压成 "剧本"格式------说话人 + 时间戳 + 内容,一行一条:
python
# core/meeting_minutes.py
def _format_speaker_transcript(segments: list) -> str:
lines = []
for seg in segments:
m, s = divmod(int(seg["start"]), 60)
h, m = divmod(m, 60)
ts = f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
speaker = seg.get("speaker") or "S??"
lines.append(f"[{speaker}] {ts} {seg['text']}")
return "\n".join(lines)
效果:
csharp
[S01] 00:32 这个需求很简单,怎么实现我不管
[S02] 00:45 上次你也是这么说的,然后我们做了一个月
[S01] 01:03 那是技术问题,我们今天不讨论技术细节
看着像剧本,读着像剧本,LLM 处理起来也像剧本------每个人说了什么,什么时候说的,清清楚楚。
4.3 纪要的灵魂:System Prompt
LLM 整理纪要,九成功力在 prompt。结构定死,嘴也堵死:
python
MEETING_SYSTEM_PROMPT = """你是专业的会议纪要助手。我会提供一份带说话人标签
和时间戳的会议转写文本([S01]、[S02] 等为匿名说话人编号),请你整理成结构化的会议纪要。
输出要求(严格按以下结构):
# 会议纪要
## 会议主题
(一句话概括;如转写中无法判断主题,写"未能从转写中确定")
## 参会人
(按说话人编号列出;能推断角色可标注,例如"S01(主持)",推断不出就只列编号)
## 讨论要点
(按话题分小节,每个要点标注主要发言人,例如:- (S01)......)
## 决议事项
(明确达成的结论,逐条列出;没有就写"无")
## 待办事项
(格式:- [ ] 事项(负责人:S0x,时间:如有提及);没有就写"无")
**写作规范**:
- 不允许编造转写中不存在的内容、数据、人名
- 不要输出开场白,直接从 # 会议纪要 开始"""
两个设计细节值得说:
- "没有就写无":不给模型留"硬凑"的余地。AI 编决议的能力比人强多了,必须按住------不然它能给你编出十条"达成共识"来,实际上全场都在吵架。
- "推断不出就只列编号":防止模型脑补"S01 是张总"。万一人家是实习生呢?人名错了,这纪要就废了。
实测效果很好。我故意喂了一段单人棒球评论(根本不是会议),模型老老实实写道:
"转写内容为单人独白,未涉及会议讨论或决策,可能为采访或评论片段"
决议事项:无。待办事项:无。
没有戏精附体,好评。 要知道有些模型哪怕你喂它一段雨声,它都能给你编出"与会人员一致认为今天下雨了"的决议。
4.4 LLM 调度器:本地优先,按任务分配模型
这是整个项目里我最满意的一段代码。需求很复杂:
- 本地模型优先(免费、隐私、不求人)
- 但本地小模型上下文有限,超了就塞不进去
- 字幕翻译和会议纪要用的是两个不同的本地模型(一个快、一个能装)
- 本地万一挂了,DeepSeek 兜底
python
# core/llm_client.py
TASK_PROFILES = {
# 字幕翻译:hy-mt2-7b,4096 上下文
"translate": lambda: (config.LOCAL_LLM_MODEL_TRANSLATE,
config.LOCAL_LLM_MAX_INPUT_TOKENS_TRANSLATE),
# 纪要/课件:gemma-4-26b,200K 上下文
"reasoning": lambda: (config.LOCAL_LLM_MODEL_REASONING,
config.LOCAL_LLM_MAX_INPUT_TOKENS_REASONING),
}
def _backend_order(user: str, task: str = "reasoning") -> list:
"""根据任务类型和输入长度决定后端尝试顺序"""
local_model, local_max_tokens = TASK_PROFILES[task]()
local = ("local", local_model, 900)
deepseek = ("deepseek", config.DEEPSEEK_MODEL, 120)
backends = []
if config.LOCAL_LLM_ENABLED:
est = estimate_tokens(user) # 中文字符 × 0.7 粗略估算
if est <= local_max_tokens:
backends.append(local) # 装得下 → 本地优先
else:
print(f"输入约 {est} tokens,超过本地模型上限,直接使用 DeepSeek")
if config.DEEPSEEK_API_KEY:
backends.append(deepseek) # DeepSeek 永远是最后一道保险
return backends
设计思路 :不搞"先试本地、超时再切"的笨办法,而是先算一下输入有多长。能装下就用本地,装不下直接走云端。省了一次无效调用,也省了 900 秒的超时等待。
调用层的容错逻辑也有讲究:
python
for name, model, timeout in _backend_order(user, task):
client = _get_client(name)
for i in range(max_retries):
try:
return client.chat.completions.create(...)
except openai.APIConnectionError:
break # 连接失败 = 服务没起,重试没意义,直接换后端
except Exception:
time.sleep(2 ** i) # 其他错误(限流等)→ 指数退避重试
raise RuntimeError("所有 LLM 后端均调用失败")
连接错误和其他错误分开处理:
- LM Studio 没启动 → 重试三次纯属浪费时间(每次超时 900 秒你就哭吧),直接切 DeepSeek
- 限流、偶发 5xx → 值得退避重试,给服务器一点喘息时间
一节课的课件素材约 48K tokens,以前只能走 DeepSeek(钱包在滴血),换了 200K 上下文的 gemma 之后本地一口吞------这就是按任务分模型的意义:翻译用小模型(快),推理用大模型(能装),各司其职,绝不浪费。
4.5 字幕:转写成果的另一种打开方式
同一个 MOSS 后端,字幕就是顺手的事。值得一提的是重复片段合并------ASR 模型偶尔会"口吃",连续输出几条几乎一样的字幕:
csharp
[0.0-1.0] 今天我们来聊聊
[1.0-2.0] 我们来聊聊 Python
[2.0-3.0] Python 装饰器
用 LCS 相似度合并掉:
python
# core/srt_writer.py
def merge_duplicate_subtitles(segments, similarity_threshold=0.85):
while i < len(segments):
current = segments[i]
merged_text, merged_start, merged_end = current["text"], current["start"], current["end"]
while i + 1 < len(segments):
next_seg = segments[i + 1]
similarity = _calculate_similarity(merged_text, next_seg["text"]) # LCS
if similarity >= similarity_threshold:
merged_end = max(merged_end, next_seg["end"]) # 吞掉下一条
i += 1
continue
break
...
LCS 最长公共子序列:说人话就是------"今天天气不错"和"今天天气不错啊"有 80% 的字是一样的,大概率是重复了,合并掉。
说话人标注是个开关:[S01] 台词 直接拼在字幕文本前,看美剧英剧能分清谁在说话。
翻译走本地 hy-mt2-7b,小批次跑(每批 5 条------本地模型有合并连续句子的坏习惯,批次大了它会自作主张把三句并成一句,行数对不上全乱)。实测把 "walk-off home run" 精准译成 "再见本垒打" ------连棒球术语都懂,比我懂。
课件整理同理:课程视频批量转写 + PDF 文本提取(PyMuPDF)+ gemma 融合成带 LaTeX 公式的 Markdown 课件。一节 BHB 归因模型的课生成了 1.6 万字,公式全对------要知道 LLM 在数学公式上翻车是常态,这次居然没翻,我感动得差点给它颁个奖。
五、踩雷实录:49.55 GiB 的显存炸弹
第一次拿真实录音测试------录音笔的 REC001.WAV,132.9 MB,36 分钟。信心满满点下"生成会议纪要",十秒后:
css
CUDA out of memory. Tried to allocate 49.55 GiB.
GPU 0 has a total capacity of 15.93 GiB
49.55 GiB。
我的显卡总共 16G,它张口就要 49.5G。这已经不是预算超标了,这是要拿我的显卡去抵押贷款。
我当时的心情:
"你要 49.5G?我总共才 16G。要不你等等,我去隔壁借一张?"
------显卡没有回应,它死了。
原因:模型远程代码默认用全量注意力,显存随音频长度平方增长。36 分钟音频展开成几万 token 的序列,注意力矩阵直接原地起飞。更何况我的显存还要跟 LM Studio 里的 gemma 26B 合租------本来房租就紧张,你还要住总统套房。
修复分两层(就是 4.1 节埋的伏笔):
python
# 第一层:加载时换 SDPA 高效注意力,干掉 O(n²) 的显存分配
model = AutoModelForCausalLM.from_pretrained(
..., attn_implementation="sdpa") # 一行省几十 GB
SDPA(Scaled Dot-Product Attention)是 PyTorch 的高效注意力实现,把 O(n²) 的显存占用降到了 O(n)。一行代码,从 49.5G 降到可以接受的范围。
如果还是装不下呢?
python
# 第二层:自动切成 10 分钟一段,分段转写再拼起来
try:
result = generate_transcription(...)
except torch.cuda.OutOfMemoryError:
torch.cuda.empty_cache()
return self._transcribe_chunked(audio_path, chunk_seconds=600, ...)
分段的核心就一件事------时间戳加偏移:
python
for idx, chunk_path in enumerate(chunks):
offset = idx * chunk_seconds # 第几段就偏移几个 600 秒
result = generate_transcription(...)
for seg in self._parse_segments(result["text"]):
seg["start"] += offset
seg["end"] += offset
all_segments.append(seg)
torch.cuda.empty_cache() # 段间清理显存碎片
分段方案有个已知副作用:说话人编号是每段独立的------第 1 段的 S01 和第 3 段的 S01 可能不是同一个人。相当于每十分钟换一批匿名群众演员,你以为是四个人开会,其实是四组人轮流出场。
要全局一致怎么办?把 LM Studio 的 gemma 先卸载,腾出显存让单次推理跑通。取舍给你了,自己选。
最终战果:36 分钟录音 → 4 段 → 895 个片段,4 位说话人,时间戳从 2.8 秒一路标到 2167 秒,严丝合缝。
六、成果与账单
- 36 分钟会议录音 → 结构化纪要:转写约 31 分钟(和 LM Studio 抢显卡的情况下),整理 1 分钟
- 成本 :全本地跑通,0 元。DeepSeek 兜底只在本地挂掉时启用(目前还没用过)
- 隐私:音频不出本机,文本默认也不出本机。公司的"新项目预算分配会"只有你和你的显卡知道
- 人力解放:会议纪要从"两小时酷刑"变成"上传后去泡杯茶"
技术栈一览:
| 组件 | 用途 |
|---|---|
| MOSS-Transcribe-Diarize 0.9B | 转写 + 说话人分离 |
| LM Studio(gemma-4-26b + hy-mt2-7b) | 纪要整理 + 字幕翻译 |
| DeepSeek API | 兜底(目前纯摆设) |
| Flask | 三个页签的小 Web 界面 |
| ffmpeg | 永远的神(音频格式归一化) |
最后,把这套系统生成的第一份纪要发到群里后,同事们纷纷表示:
"写得真好!""真详细!""以后要常用!"
至于他们会不会看------那就是另一个闭环的故事了。
七、真实感受:快是快了,但也没那么快
上面吹了那么多,这里说几句大实话。
速度方面:36 分钟的录音,MOSS 分段转写花了约 31 分钟------基本是 1:1 的耗时。而 Whisper Large V3 处理同样长度的音频,大概只要 10-15 分钟。MOSS 慢了将近一倍。
原因也不难理解:端到端的多任务模型(转写 + 分离同时做)本来就比纯转写模型重,再加上分段带来的额外开销,慢是意料之中的。只是第一次跑完看到时间,还是愣了一下------"怎么这么久?" 然后看了一眼进度条,确认它没卡死,才放心去泡了第二杯茶。
显存方面 :MOSS 的显存占用是动态的------短音频可能只要 2-3GB,但 36 分钟的长音频展开成几万 token 后,加上注意力矩阵,直接奔着爆显存去了。这也是为什么我必须上分段方案:不分段根本跑不动。
更要命的是,MOSS 和 gemma 在 16G 显存上塞不下 ------两个模型同时加载,显存直接爆掉。所以实际流程只能是串行:先跑 MOSS 转写,等它完全结束、释放显存之后,再加载 gemma 做纪要整理。整个过程 MOSS 和 gemma 永远碰不上面,像两班倒的保安,谁也不见谁。
所以这套方案的实际状态是:MOSS 转写慢,gemma 加载重,两者还无法并行,全在排队。 但好在能跑通,分段方案扛住了显存压力,不至于让整个过程崩掉。
那么问题来了:我为什么不直接用 Whisper + 第三方说话人分离?
其实 Whisper 生态里已经有成熟的说话人分离方案了,比如 whisper-diarization 这个项目,就是 Whisper + pyannote.audio 的组合拳。理论上能同时拿到高质量转写和说话人标注,速度还可能更快。
我后面准备试一下。如果效果好,可能又会换回去。毕竟技术选型这件事,没有最好的,只有当前阶段最合适的。
到时候有了结果,再跟大家汇报------说不定又是一篇文章的素材。
写在最后
这篇文章的技术含量,比我之前几篇都要高一点。但核心思想没变:用有限的硬件,做有用的事情。 5060 Ti 16G 虽然不是顶配,但合理分配、巧妙调度,照样能跑通端到端的会议纪要系统。
当然,这套方案不是完美的。速度不够快、显存吃紧、MOSS 和 gemma 无法并行导致总耗时更长。但对我来说,能跑通、能交付、能省下两小时的纪要整理时间,已经值回票价了。
如果后面 Whisper + 说话人分离的方案跑通了,我再来更新。说不定到时候标题就变成《我又换回了 Whisper,因为 MOSS 实在太慢了》------打脸也是技术博客的一部分嘛。
如果你也受够了"谁来记会议纪要"这个世纪难题,欢迎照着本文抄作业。
如果这篇文章让你笑了一下,或者帮你省了一下午的纪要整理时间,点赞、评论、转发都行。
谢谢大家 🙏
祝你们的录音永远清晰,显存永远够用,会议永远不用开第二次。