会议纪要自动整理——从录音到结构化纪要,全流程实现
会议纪要自动整理——从录音到结构化纪要,全流程实现
适读人群:有大量会议、想提升效率的工程师/产品经理 | 阅读时长:约17分钟 | 核心价值:完整的会议纪要自动化方案,代码可直接运行
我有一个习惯:每次重要会议都录音。
但录音文件的问题是:你很少回去听。一个一小时的会议,听录音回顾要再花一个小时,根本没人干这事。所以录音文件就堆在那,真到需要翻的时候,根本找不到说了什么。
直到我把整个流程自动化了——录音上传,自动出一份结构化纪要:谁说了什么,决定了什么,谁负责什么,截止日期是哪天。
这一套方案我自己用了半年,现在也部署给几个客户团队用了。这篇把完整的技术实现写出来。
整体流程
会议录音(MP3/MP4/WAV)
|
v
[语音转文字] -- Whisper
|
v
[发言人识别] -- pyannote.audio 或 基于时间戳的启发式方法
|
v
[文字清理] -- 去除转录噪音
|
v
[LLM结构化整理] -- 生成纪要
|
v
[结构化输出]
- 会议概述
- 主要讨论点
- 决议事项
- 行动项(负责人+截止日期)
- 待跟进问题第一步:语音转文字
Whisper是目前最好的开源语音识别方案,对中文的识别质量相当不错。
import whisper
from pathlib import Path
import json
def transcribe_audio(audio_path: str, language: str = "zh") -> dict:
"""
使用Whisper转录音频,返回带时间戳的转录结果
"""
model = whisper.load_model("large-v3") # 中文推荐large-v3
result = model.transcribe(
audio_path,
language=language,
word_timestamps=True, # 词级时间戳,用于后续发言人分配
verbose=False
)
# 整理输出格式
segments = []
for seg in result["segments"]:
segments.append({
"id": seg["id"],
"start": seg["start"],
"end": seg["end"],
"text": seg["text"].strip(),
"words": seg.get("words", [])
})
return {
"language": result["language"],
"duration": result.get("duration", 0),
"segments": segments,
"full_text": result["text"]
}
# 使用示例
result = transcribe_audio("/path/to/meeting.mp3")
print(f"语言: {result['language']}")
print(f"时长: {result['duration']/60:.1f}分钟")
print(f"片段数: {len(result['segments'])}")关于模型选择:
tiny/base/small:速度快,精度差,中文识别错漏较多,不建议用于正式场景medium:速度和精度平衡,中文一般,英文不错large-v3:精度最好,速度慢,1小时录音大约需要5-10分钟处理,推荐
处理长录音的内存优化:
Whisper处理长音频时会占用大量内存。对于超过2小时的录音,建议先切片:
from pydub import AudioSegment
import os
def split_audio(audio_path: str, chunk_minutes: int = 30) -> list[str]:
"""
将长音频切分为多个片段,避免内存溢出
"""
audio = AudioSegment.from_file(audio_path)
chunk_ms = chunk_minutes * 60 * 1000
chunks = []
base_path = Path(audio_path)
for i, start in enumerate(range(0, len(audio), chunk_ms)):
chunk = audio[start:start + chunk_ms]
chunk_path = str(base_path.parent / f"{base_path.stem}_chunk{i:03d}.mp3")
chunk.export(chunk_path, format="mp3")
chunks.append(chunk_path)
return chunks第二步:发言人识别
这是整个流程里技术复杂度最高的部分,也是效果最参差不齐的部分。
方案一:pyannote.audio(准确但配置复杂)
from pyannote.audio import Pipeline
def diarize_speakers(audio_path: str, num_speakers: int = None) -> list[dict]:
"""
使用pyannote进行说话人分离
需要先在HuggingFace接受使用协议并获取token
"""
pipeline = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1",
use_auth_token="your_huggingface_token"
)
params = {}
if num_speakers:
params["num_speakers"] = num_speakers
diarization = pipeline(audio_path, **params)
speakers = []
for turn, _, speaker in diarization.itertracks(yield_label=True):
speakers.append({
"start": turn.start,
"end": turn.end,
"speaker": speaker # "SPEAKER_00", "SPEAKER_01" 等
})
return speakers
def assign_speakers_to_segments(
transcription: dict,
diarization: list[dict]
) -> list[dict]:
"""
把发言人信息和转录文本对应起来
基于时间重叠判断每段话属于哪个发言人
"""
result = []
for segment in transcription["segments"]:
seg_start = segment["start"]
seg_end = segment["end"]
# 找到时间段重叠最多的发言人
best_speaker = "未知"
best_overlap = 0
for d in diarization:
overlap_start = max(seg_start, d["start"])
overlap_end = min(seg_end, d["end"])
overlap = max(0, overlap_end - overlap_start)
if overlap > best_overlap:
best_overlap = overlap
best_speaker = d["speaker"]
result.append({
"speaker": best_speaker,
"start": seg_start,
"end": seg_end,
"text": segment["text"]
})
return result方案二:不做发言人识别,直接用时间戳(适合小团队)
如果你的会议人数少(3人以内)、且参与者声音特征明显不同,可以在LLM整理阶段让AI从上下文推断发言人,而不做技术层面的说话人分离:
def merge_continuous_speech(segments: list[dict], gap_threshold: float = 1.0) -> str:
"""
将连续的转录片段合并,加入时间戳
gap_threshold: 超过这个秒数的间隔,认为是不同发言段
"""
if not segments:
return ""
merged = []
current_group = [segments[0]]
for seg in segments[1:]:
prev = current_group[-1]
gap = seg["start"] - prev["end"]
if gap <= gap_threshold:
current_group.append(seg)
else:
# 合并当前组
group_text = ' '.join(s["text"] for s in current_group)
time_str = format_time(current_group[0]["start"])
merged.append(f"[{time_str}] {group_text}")
current_group = [seg]
# 处理最后一组
if current_group:
group_text = ' '.join(s["text"] for s in current_group)
time_str = format_time(current_group[0]["start"])
merged.append(f"[{time_str}] {group_text}")
return '\n'.join(merged)
def format_time(seconds: float) -> str:
"""将秒数格式化为 MM:SS"""
minutes = int(seconds // 60)
secs = int(seconds % 60)
return f"{minutes:02d}:{secs:02d}"第三步:LLM结构化整理
这是整个流程的核心。把转录文本(有或没有发言人标注)送给LLM,生成结构化纪要:
from openai import OpenAI
from datetime import date
client = OpenAI(api_key="your_api_key")
MEETING_SUMMARY_PROMPT = """你是一名专业的会议助手,擅长整理会议纪要。
请根据以下会议转录内容,生成一份完整的结构化会议纪要。
会议信息:
- 会议日期:{meeting_date}
- 会议主题:{meeting_topic}(如未知请根据内容推断)
- 时长:{duration_minutes}分钟
转录内容:
{transcript}
请生成以下格式的会议纪要:
## 会议概述
[2-3句话概括本次会议的核心内容和背景]
## 主要讨论点
[按讨论顺序,每个主要议题一个条目,包含主要观点和分歧]
## 决议事项
[本次会议确定的所有决定,格式:「决定:XXX(决策依据/背景)」]
## 行动项
[所有需要后续跟进的任务,格式:
| 任务 | 负责人 | 截止日期 | 优先级 |
|------|--------|---------|--------|]
## 待跟进问题
[未达成共识或需要进一步讨论的问题]
## 下次会议
[如有提到下次会议的安排]
注意:
1. 行动项里的负责人从转录内容中提取,如未明确指定则填"待定"
2. 截止日期从转录内容提取,如仅提"下周"等相对日期,根据会议日期推算具体日期
3. 如转录质量较差导致某部分内容不清晰,请在该部分标注【转录不清晰,建议人工核实】
4. 如果某个分类没有相关内容,填"本次会议无相关内容",不要省略该分类"""
def generate_meeting_summary(
transcript: str,
meeting_date: str = None,
meeting_topic: str = "未指定",
duration_minutes: int = 0
) -> str:
"""
生成结构化会议纪要
"""
if not meeting_date:
meeting_date = date.today().strftime("%Y年%m月%d日")
# 如果转录太长,先做摘要
if len(transcript) > 15000:
transcript = preprocess_long_transcript(transcript)
prompt = MEETING_SUMMARY_PROMPT.format(
meeting_date=meeting_date,
meeting_topic=meeting_topic,
duration_minutes=duration_minutes,
transcript=transcript
)
response = client.chat.completions.create(
model="deepseek-chat", # 中文效果好,成本低
messages=[
{"role": "user", "content": prompt}
],
max_tokens=3000,
temperature=0.3 # 适度的创造性,但保持准确
)
return response.choices[0].message.content
def preprocess_long_transcript(transcript: str, max_length: int = 12000) -> str:
"""
对超长转录内容做预处理:保留首尾,压缩中间
实际项目建议做分段摘要再合并
"""
if len(transcript) <= max_length:
return transcript
# 保留前1/3和后1/3,压缩中间部分
keep_each = max_length // 3
return (
transcript[:keep_each] +
"\n\n[...中间部分已压缩,如需完整内容请查看原始转录...]\n\n" +
transcript[-keep_each:]
)整合成完整的处理管道
import os
from pathlib import Path
def process_meeting_recording(
audio_path: str,
output_dir: str = None,
meeting_topic: str = "",
meeting_date: str = None,
num_speakers: int = None
) -> dict:
"""
完整的会议录音处理流程
返回处理结果字典
"""
audio_path = Path(audio_path)
if not output_dir:
output_dir = audio_path.parent / "meeting_notes"
Path(output_dir).mkdir(exist_ok=True)
print("1/4 语音转文字...")
transcription = transcribe_audio(str(audio_path))
duration_minutes = int(transcription["duration"] / 60)
print(f" 完成,时长{duration_minutes}分钟,{len(transcription['segments'])}个片段")
print("2/4 整理转录文本...")
# 合并连续语音片段
formatted_transcript = merge_continuous_speech(transcription["segments"])
# 保存原始转录
transcript_file = Path(output_dir) / f"{audio_path.stem}_transcript.txt"
transcript_file.write_text(formatted_transcript, encoding='utf-8')
print(f" 原始转录已保存: {transcript_file}")
print("3/4 AI整理会议纪要...")
summary = generate_meeting_summary(
transcript=formatted_transcript,
meeting_date=meeting_date,
meeting_topic=meeting_topic or audio_path.stem,
duration_minutes=duration_minutes
)
print("4/4 保存结果...")
summary_file = Path(output_dir) / f"{audio_path.stem}_summary.md"
summary_file.write_text(summary, encoding='utf-8')
print(f" 会议纪要已保存: {summary_file}")
return {
"transcript_file": str(transcript_file),
"summary_file": str(summary_file),
"duration_minutes": duration_minutes,
"summary": summary
}
# 使用示例
result = process_meeting_recording(
audio_path="2024-03-15-产品规划会.mp3",
meeting_topic="Q2产品规划讨论",
meeting_date="2024年3月15日"
)
print("\n=== 生成的会议纪要预览 ===")
print(result["summary"][:500] + "...")真实效果示例
用一次实际的产品评审会议录音测试(45分钟,4人参与,中文):
原始转录片段(前200字):
[00:02] 好那我们开始吧,今天主要是看一下上个版本的用户反馈,然后讨论一下下个月的排期
[00:18] 对,我先说一下,上周我看了一下用户访谈的结果,有三个问题被提得最多
[00:31] 哪三个
[00:33] 一个是搜索太慢,一个是分类不合理,还有一个就是通知太多用户很烦
[00:45] 通知这个我们已经有排期了吧AI生成的纪要(节选):
## 会议概述
本次会议主要回顾上一版本用户反馈,并讨论下个月产品迭代排期。
会议重点关注搜索性能、内容分类和通知系统三个核心问题。
## 主要讨论点
1. **用户反馈问题**
- 搜索响应速度慢:用户反映等待时间超过3秒的情况频繁出现
- 分类体系不合理:用户找不到想要的内容,分类层级过深
- 通知过多:用户觉得打扰频率过高,已有部分用户关闭通知
## 决议事项
- 决定:搜索优化列为下月P0需求,目标响应时间降到1秒以内
- 决定:通知降噪功能按已有排期推进,不延期
## 行动项
| 任务 | 负责人 | 截止日期 | 优先级 |
|------|--------|---------|--------|
| 搜索性能分析报告 | 小李 | 3月22日 | P0 |
| 分类重构方案初稿 | 待定 | 3月29日 | P1 |
| 通知频率算法调整 | 小王 | 3月20日 | P0 |行动项的提取准确率在我测试的20次会议里,平均约82%——也就是说约18%的行动项会有遗漏或人名搞错。所以纪要生成后,我的使用习惯是:会后发给所有与会人,让他们在纪要上直接标注补充,5分钟完成核实。
部署成Web服务(可选)
如果团队多人用,可以做成简单的Web界面:
from fastapi import FastAPI, File, UploadFile, Form
from fastapi.responses import HTMLResponse
import tempfile
import shutil
app = FastAPI(title="会议纪要助手")
@app.post("/process")
async def process_meeting(
audio: UploadFile = File(...),
topic: str = Form(default=""),
date: str = Form(default="")
):
# 保存上传文件到临时目录
with tempfile.NamedTemporaryFile(
delete=False,
suffix=Path(audio.filename).suffix
) as tmp:
shutil.copyfileobj(audio.file, tmp)
tmp_path = tmp.name
try:
result = process_meeting_recording(
audio_path=tmp_path,
meeting_topic=topic,
meeting_date=date
)
return {
"status": "success",
"summary": result["summary"],
"duration_minutes": result["duration_minutes"]
}
finally:
os.unlink(tmp_path)这套流程我们公司内部每周会议的纪要整理时间,从平均45分钟降到5分钟(上传+等待处理+快速核实)。
最有价值的不是省时间,是行动项的跟踪变清晰了——以前会后说好谁负责什么,一周后追问的时候常常有"我以为你负责的"这种扯皮,现在纪要里白纸黑字,跑不了。
