个人电脑玩AI-13让5060 Ti给你打工——我用 0.9B 小模型终结了"谁来记会议纪要"这个世纪难题

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,时间:如有提及);没有就写"无")

**写作规范**:
- 不允许编造转写中不存在的内容、数据、人名
- 不要输出开场白,直接从 # 会议纪要 开始"""

两个设计细节值得说:

  1. "没有就写无":不给模型留"硬凑"的余地。AI 编决议的能力比人强多了,必须按住------不然它能给你编出十条"达成共识"来,实际上全场都在吵架。
  2. "推断不出就只列编号":防止模型脑补"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 实在太慢了》------打脸也是技术博客的一部分嘛。

如果你也受够了"谁来记会议纪要"这个世纪难题,欢迎照着本文抄作业。

如果这篇文章让你笑了一下,或者帮你省了一下午的纪要整理时间,点赞、评论、转发都行。

谢谢大家 🙏

祝你们的录音永远清晰,显存永远够用,会议永远不用开第二次。

相关推荐
橘子星3 小时前
我一个前端切图仔,凭什么能在浏览器里跑大模型?
前端·javascript·前端框架
无名之辈J3 小时前
Ai开发
后端
-XWB-3 小时前
【 LLM】Agent Planning 完全指南:8 种纯 LLM 范式 + 8 种混合规划模式详解(一)
人工智能·aigc·学习方法·ai编程
Conan在掘金3 小时前
�鸿蒙报错速查:arkts-strict-typing 函数返回值类型必须显式,忘标就炸,根因 + 真解法
后端
70asunflower3 小时前
初学者理解 Web 工作原理(完全教程)
前端
半个落月3 小时前
用 LangChain JS 做可控写作实验:理解温度参数、提示词与异步调用
javascript·人工智能·后端
爱勇宝3 小时前
《道德经》第 7 章:真正厉害的领导者,不抢主角
前端·后端·程序员
用户208046804563 小时前
Python3 条件控制新手实战指南
后端
观远数据3 小时前
Excel到数据资产池:文件数据入湖的治理规范怎么建
前端·javascript·excel