你真正开会的时候,脑子通常同时在跑三条线程:听人讲话、组织自己的回应、还要把结论记下来。结果就是一个小时过去,记录页上只躺着半页残废的笔记。我前几年在团队里最头疼的就是这个,尤其是需求评审和跨部门对齐会,开完两天后没人能说清楚当时到底定了谁负责哪件事。后来我花了一个下午,用腾讯云 AI 语音识别搭了个会议小帮手:录音文件扔上去,几分钟拿回带时间戳、带说话人标记的转写文本,再稍微做点后处理,就变成一份能直接分发的会议纪要。这篇文章就讲整套方案是怎么落地的:选型思路、接口怎么接、哪些参数值得调、哪些坑我已经替你踩过,以及怎么把一堆原始文本加工成待办清单。想照着搭一套,或者只想评估能不能用、成本如何、效果怎样,这篇都覆盖了。
1. 先想清楚:会议小帮手到底要解决什么问题
1.1 开会的真实痛点,不只是“没人记纪要”
开会没人记纪要只是表面现象,更深的问题是记录这件事本身和开会流程是脱节的。参会人一边听一边写,写下的往往是自己关心的部分,结论、责任人、截止时间很容易漏;会后要追忆只能靠零散的聊天记录。这个问题在只有录音没有转写的情况下也解决不彻底,因为回听一小时音频的成本太高,没人愿意干。我当时的诉求很明确:要一份能检索的、有时间轴的、能指出“谁在什么时候说了什么”的文本,而不是一份凭记忆整理出来的美化版纪要。
1.2 方案选型:为什么用云端 ASR 而不是本地模型
做会议记录转写,市面上大体有两条路线。第一条是用开源的本地语音识别模型,部署在自己机器上,优点是数据不出内网、离线可用、没有单次调用费用,缺点是模型体积大、需要 GPU 或至少不错的 CPU、工程维护成本高,而且中文会议场景常用的说话人分离、时间戳对齐、热词定制这些能力都要自己额外开发。第二条就是用云服务商的成熟语音识别 API。腾讯云 AI 语音识别这类方案的优势是开箱即用:接口稳定、支持说话人分离、可以返回逐句时间戳、有热词机制针对专有名词纠错,收费按实际识别的音频时长算,对中小团队来说成本非常可控。我的选择很直接:先用云 API 在最短时间内把流程跑通,如果后续对隐私有更高要求,再考虑引入本地模型替换识别链路,架构上把识别模块隔离好,替换成本不高。
1.3 我圈定的功能边界
动手之前先把需求砍干净。第一版我只做三件事:录音文件自动上传、异步转写拿到结构化文本、按说话人整理成会议纪要初稿。不做实时字幕,不做自动生成摘要里的智能压缩,不做会议系统对接。把边界切小有个重要原因:腾讯云语音识别接口本身只负责“音频转文本”,会议纪要的质量还需要后处理环节不断调,功能再多容易一手抓不深。事实证明这个优先级是对的,后面我在转写文本的清洗和待办提取上花的时间,远多于接 API 的时间。
2. 整体设计:以录音文件识别为主线,实时识别做备选
2.1 两条技术路线怎么选
腾讯云 AI 语音识别按场景分好几类,会议小帮手主要面对两种:录音文件识别和实时语音识别。录音文件识别是异步任务,先把完整音频传上去,服务端排队处理,几分钟后任务结果可取;它支持长音频、质量更稳,我把它定为主链路。实时语音识别走流式 WebSocket,边说话边出字,适合现场投屏字幕,但客户端复杂度高,而且直播场景需要处理断句和重连,对于“会后再整理纪要”这个目标来说不划算。另外还有一句话识别,处理 60 秒以内的短音频,我一般用来做录音质量自检和单句测试,不承担主流程。
我把两条路线的差异整理成一个直观对比:
| 对比项 | 录音文件识别 | 实时语音识别 | 一句话识别 |
|---|---|---|---|
| 适用音频长度 | 长音频,几小时量级 | 流式持续 | 60 秒以内 |
| 延迟要求 | 分钟级可接受 | 秒级出字 | 低延迟 |
| 说话人分离 | 支持 | 支持(需要额外处理) | 不支持 |
| 工程复杂度 | 低,上传加轮询即可 | 高,需维护连接与重连 | 最低 |
| 推荐场景 | 会后转写纪要 | 现场字幕 | 快速试听测试 |
2.2 这套小帮手的处理链路
整套流程可以画成一条单向流水线:录音设备产出音频文件 → 统一转成 wav 或 mp3 → 上传到对象存储 COS → 提交录音文件识别任务 → 轮询任务状态 → 拿到带时间戳和说话人标签的 JSON → 后处理脚本清洗、分人、提取待办 → 输出 Markdown 会议纪要。关键设计点是不要把识别结果只当成一个字符串,而是要保留住时间戳和说话人维度,这两个维度后面做纪要定位时特别有用。比如“这段结论是谁拍板的”“这个问题在第几分钟开始被讨论”,没有时间戳根本无从查证。
2.3 录音质量是识别效果的上限,先解决这个
第一次测试识别率特别低,后来发现不是 API 的问题,是录音质量太差。会议场景常见的录音问题包括:设备离人太远导致音量低、多人同时发言导致叠音、电话会议里双声道串扰。我的处理方式是:引导大家用手机录音时尽量靠近发言人;如果用的是电脑会议软体录音,导出时尽量选择单声道 16kHz;外接领夹麦或桌面麦对识别率的提升比任何参数都大。音频的采样率建议在预处理时统一确认,声卡导出的某些格式可能是 44.1kHz,语音 API 通常支持重新采样,但在上传前自己转成 16kHz 会让行为更可控。我用了 pydub 或 ffmpeg 做统一转码,脚本里留好接口,后面批量处理录音时非常省事。
3. 准备工作:开通服务、配权限、装环境
3.1 开通服务与控制台快速验证
第一步在控制台开通腾讯云的语音识别服务,并把对象存储 COS 也开通,因为录音文件识别的标准姿势是先把音频放在 COS 上。开通后在语音识别控制台找到“录音文件识别”的功能页,正常会提供音频上传试听功能,建议先传一段 30 秒左右的真实会议录音做一次在线识别。这一步花不了几分钟,但它能提前暴露很多问题:音频格式是否支持、识别引擎选得对不对、语速和噪音对结果的影响有多大。我身边不少人是直接写代码后才发现问题,其实控制台先验证是最快的路径。
3.2 密钥和权限的最小化配置
调用腾讯云语音识别接口需要一组 API 密钥,也就是 SecretId 和 SecretKey。我建议不要用主账号密钥,而是在访问管理里建一个子账号,只授予语音识别服务和 COS 的相关权限,原则上能跑通业务就给最小权限。密钥不要硬编码进脚本,更不要提交到代码仓库;我习惯从环境变量读取,本地开发时放 .env 文件并加入 .gitignore。对象存储桶同样别图省事设成公有读,开发阶段可以用预签名 URL 方式让识别服务访问私人桶里的文件,后面我会给出具体代码,这比把桶开放给公网安全得多。会议录音涉及讨论内容,访问权限和审计要提前想清楚。
3.3 安装依赖
环境用 Python 3.8 以上就够了,依赖主要是两个官方 SDK:语音识别部分用腾讯云的 Python SDK,包含 asr 模块;上传文件用 COS 的 Python SDK。再装一个 pydub 用来做音频格式转换,ffmpeg 作为它的后端。安装命令我放在下面:
pip install tencentcloud-sdk-python-asr cos-python-sdk-v5 pydub # 如果系统没有 ffmpeg,还需要单独安装,macOS 可用 brew install ffmpeg装完之后先做一次最简单的连通性验证,哪怕是调用一句话识别传一个测试音频也行,确认密钥、地域、网络链路都没问题,再开始写正式脚本。
4. 核心实现:用腾讯云 AI 语音识别把录音变成文本
4.1 小文件直连识别:一句话识别兜底
先说说我怎么用一句话识别做快速验证。会议里偶尔会有“某段关键发言只有几十秒”的情况,或者只想确认某段录音能否被识别,直接走文件上传链路太重了。一句话识别接口可以直接传音频数据,返回识别文本。示例代码如下:
import base64 import os from tencentcloud.common import credential from tencentcloud.asr.v20190614 import asr_client, models SECRET_ID = os.getenv("TENCENTCLOUD_SECRET_ID") SECRET_KEY = os.getenv("TENCENTCLOUD_SECRET_KEY") REGION = "ap-guangzhou" _client = None def get_client(): global _client if _client is None: cred = credential.Credential(SECRET_ID, SECRET_KEY) _client = asr_client.AsrClient(cred, REGION) return _client def recognize_short_audio(audio_path: str, fmt: str = "wav") -> str: with open(audio_path, "rb") as f: data = f.read() req = models.SentenceRecognitionRequest() req.ProjectId = 0 req.SubServiceType = 2 req.EngineModelType = "16k_zh" req.VoiceFormat = fmt req.Data = base64.b64encode(data).decode() req.DataLen = len(data) resp = get_client().SentenceRecognition(req) return resp.Result这个接口对音频时长有限制,超过 60 秒的音频不能走这条路。我在脚本里把它定位成一个工具函数,主要用来在开会前快速检查录音设备是否正常,或者在调参时对比同一段音频在不同引擎模型下的识别结果。
4.2 长录音推荐:COS 上传 + 录音文件识别
正式的会议转写走录音文件识别,核心步骤有三个:上传音频到 COS、提交识别任务、轮询任务状态。上传我建议用 COS SDK 的 upload_file 方法,它内部处理了文件分块,比一次性读进内存更省资源。提交任务时把音频的访问地址传给接口,并打开说话人分离。下面是我整理的完整示例:
import os import time from tencentcloud.common import credential from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.http_profile import HttpProfile from tencentcloud.asr.v20190614 import asr_client, models from qcloud_cos import CosConfig, CosS3Client SECRET_ID = os.getenv("TENCENTCLOUD_SECRET_ID") SECRET_KEY = os.getenv("TENCENTCLOUD_SECRET_KEY") REGION = "ap-guangzhou" BUCKET = "your-bucket-1250000000" cos_client = CosS3Client(CosConfig(Region=REGION, SecretId=SECRET_ID, SecretKey=SECRET_KEY)) def upload_and_get_url(local_path: str, cos_key: str, expire: int = 3600) -> str: cos_client.upload_file( Bucket=BUCKET, Key=cos_key, LocalFilePath=local_path, ContentType="audio/wav", ) url = cos_client.get_presigned_url( Method="GET", Bucket=BUCKET, Key=cos_key, Expired=expire ) return url def submit_asr_task(audio_url: str) -> int: cred = credential.Credential(SECRET_ID, SECRET_KEY) http_profile = HttpProfile() http_profile.endpoint = "asr.tencentcloudapi.com" client_profile = ClientProfile() client_profile.httpProfile = http_profile client = asr_client.AsrClient(cred, REGION, client_profile) req = models.CreateRecTaskRequest() req.EngineModelType = "16k_zh" req.SourceType = 0 # 0 表示URL方式,1 表示COS对象方式 req.ChannelNum = 1 req.ResTextFormat = 2 # 中文分词结果,便于后续做关键词提取 req.ConvertNumMode = 1 # 数字统一转成阿拉伯数字 req.SpeakerDiarization = 1 # 开启说话人分离 req.SpeakerNumber = 4 # 预估说话人数 req.FilterDirty = 0 req.FilterModal = 1 # 过滤语气词和停顿 req.Url = audio_url resp = client.CreateRecTask(req) return resp.Data.TaskId这里有个容易忽略的点:如果 COS 桶是私有读写,识别服务访问对象需要可信的地址,预签名 URL 是最直接的开发方案。SourceType 字段在官方文档里区分“语音URL”和“COS对象”两种,开发阶段用 0 配合预签名 URL 排障最快。等流程稳定了,再看是否需要换成 SourceType=1 的标准方式,这个根据你拿到的文档版本调整即可。
4.3 轮询任务结果,解析时间戳和说话人
录音文件识别是异步任务,提交后不能立刻拿结果。腾讯云的接口会返回一个 TaskId,之后轮询查询任务状态。我在脚本里用循环加 sleep 的方式等待,遇到失败状态直接抛出错误信息,方便日志里排查。下面这段代码会拉取结果并解析成结构化对象:
import json import time from tencentcloud.common import credential from tencentcloud.asr.v20190614 import asr_client, models SECRET_ID = os.getenv("TENCENTCLOUD_SECRET_ID") SECRET_KEY = os.getenv("TENCENTCLOUD_SECRET_KEY") REGION = "ap-guangzhou" def fetch_asr_result(task_id: int, max_wait: int = 600) -> dict: cred = credential.Credential(SECRET_ID, SECRET_KEY) client = asr_client.AsrClient(cred, REGION) req = models.DescribeTaskStatusRequest() req.TaskId = task_id waited = 0 while waited < max_wait: resp = client.DescribeTaskStatus(req) status = resp.Data.Status # 状态码含义随 SDK/API 版本可能不同,常见情况:2 表示完成,3 表示失败 if status == 2: return json.loads(resp.Data.Result) if status == 3: raise RuntimeError(resp.Data.ErrorMsg) time.sleep(5) waited += 5 raise TimeoutError("识别任务超时") task_id = submit_asr_task(audio_url) detail = fetch_asr_result(task_id) for item in detail["Result"]: start = item["StartMs"] / 1000 end = item["EndMs"] / 1000 speaker = item.get("SpeakerId", 0) print(f"[{start:7.2f} - {end:7.2f}s] 说话人{speaker}: {item['Text']}")关于状态码,我特别提醒一句:不同 SDK 版本对状态的定义在文档里表述略有出入,第一次运行时先把 status 原样打印出来对照文档确认,不要盲信网上的代码片段。返回的 Result 是一个 JSON 字符串,里面每一项包含句子文本、开始时间、结束时间和说话人编号,这就是后面所有后处理的基础数据。
4.4 必须调好的关键参数
我整理了一张参数速查表,都是会议场景里直接影响效果的:
| 参数 | 建议值 | 原因 |
|---|---|---|
| EngineModelType | 16k_zh | 中文普通话会议,电话录音用 8k 系列 |
| ChannelNum | 1 | 单声道;双声道电话会议可设 2 |
| ResTextFormat | 2 | 返回中文分词结果,方便后续做关键词提取 |
| ConvertNumMode | 1 | 把“二零二四”统一转成“2024”,纪要更干净 |
| SpeakerDiarization | 1 | 开启说话人分离 |
| SpeakerNumber | 预估人数 | 设得越接近真实,分人越准 |
| FilterModal | 1 | 过滤“嗯”“啊”等语气词 |
| FilterDirty | 视情况 | 会议如果比较随意,建议保持 0 以免误杀 |
这里最值得花时间调的是 SpeakerNumber。说话人分离本质是聚类,如果预估人数和实际差太多,会出现两个人被合成一个人,或者一个人被拆成两个身份的情况。我的经验是:固定每周例会填参会人数减一(主持人话少的情况),跨部门拉会对不上号时宁多勿少,宁可多拆再人工合并,也比漏掉身份好。另外,如果团队沟通里中英混杂较多,比如技术术语经常夹英文单词,可以试试把引擎 Model 换成 16k_mix 之类的混语模型,效果会有明显变化。
5. 会议记录的后处理:从一段段文字变成能用的纪要
5.1 按说话人切分,先恢复对话脉络
拿到 JSON 结果后,第一步不是直接拼文本,而是把句子按说话人重新组织。这样一份会议记录不再是混沌的流水账,而是一条条清晰的发言轮次。我用 defaultdict 把同一个 SpeakerId 的句子聚合,按时间排序后输出完整的发言段落:
from collections import defaultdict def group_by_speaker(parsed: dict) -> dict: turns = defaultdict(list) for item in parsed["Result"]: speaker = item.get("SpeakerId", 0) turns[speaker].append(item) grouped = {} for spk, items in turns.items(): start = items[0]["StartMs"] / 1000 end = items[-1]["EndMs"] / 1000 text = "".join(i["Text"] for i in items) grouped[spk] = {"start": start, "end": end, "text": text} return grouped这一步最直观的收益是:纪要里可以说“关于预算的问题,从第 12 分钟开始讨论,某同事在第 14 分钟提出了关键结论”,而不是含糊地说“大家讨论了一下”。时间戳让纪要具备可回溯性,这是录音文本最值钱的部分。
5.2 清洗文本与自动提取待办
原始转写文本里即使开了语气词过滤,也还会有“然后”“那个”“就是说”这类口头语,直接发给所有人既不专业也难读。我做了两层清洗:先删掉常见口头禅,再用规则把重复的短句合并。第二层更关键的是待办提取。会议纪要最后一定要落到“谁、什么时候、做什么”,我写了一个轻量扫描脚本,把包含“负责”“跟进”“确认”“提交”“下周”“本周”等动作词且长度大于一定字数的句子挑出来,人工在这个候选列表里勾选确认。脚本本身不复杂,但能节省大量通读时间:
ACTION_WORDS = ["负责", "跟进", "确认", "提交", "输出", "安排", "尽快", "本周", "下周"] def extract_actions(sentences): todos = [] for s in sentences: if len(s) < 8: continue if any(w in s for w in ACTION_WORDS): todos.append(s) return todos提取结果整理成待办列表后,我一般还会配上对应的时间戳,这样每个人回看时都能直接定位到原始发言。如果团队有统一的任务池,这步生成的结构化文本可以直接转成工单,后期扩展空间很大。
5.3 摘要生成与归档
会议纪要除了全文,还需要一段摘要,核心结论要能在 30 秒内读完。摘要我分两层做:第一层是规则法,把每个人发言段落的首句和末尾总结句拼出来,因为多数人在发言最后会收束观点;第二层是可选的大模型摘要,把清洗后的全文喂给任意国内大模型接口,让模型输出“结论、分歧、待办”三个板块。用大模型时注意一次别喂全量长文本,可以按说话人切片后分别摘要,再合并成总摘要,效果更稳。归档环节我直接把 Markdown 文件按日期命名放进私有仓库,和音频文件关联存储,既方便搜索,也方便事后回溯。
6. 实测踩坑记录与排查速查
6.1 识别率低的几类原因
我第一版上线后遇到的最典型问题是识别率突然下降。排查后发现会议现场换了个会议室,回音变大,麦克风离发言人两米以上,识别文本里大量同音字错误。这类问题靠调参数救不回来,只能从录音源头解决。第二类是引擎模型不匹配,普通话会议用了默认通用模型,专业术语频繁出错,后来把热词表建起来,把项目代号、人名、系统名称都加进去,专有名词识别率明显提升。第三类是说话人分离不准,实际原因是 SpeakerNumber 填少了一位,调整后基本稳定。
6.2 任务排队与回调的问题
录音文件识别是异步机制,偶尔会遇到任务在队列里卡住的情况。实际测试发现,提交任务后可能在几分钟内一直显示处理中,如果超过预期时间还不动,先检查 URL 是否还能访问,预签名 URL 有过期时间,过期后任务会失败,而很多人以为是服务端问题。我的脚本里把轮询超时设成了 10 分钟,并加了重试机制,失败一次就会用新 URL 重新提交一次,正式环境跑下来稳定很多。此外接口还支持回调 URL 方式,如果服务能暴露公网地址,可以注册回调让服务端主动通知,避免轮询消耗,但对大多数内部小工具来说轮询已经足够。
6.3 并发、计费与隐私的一些提醒
批量处理历史录音时很容易踩并发限制。我曾一次性提交了几十条历史会议音频,结果部分任务提示频率超限,后来在脚本里用信号量把并发控制在个位数,一个个排队处理。计费方面,录音文件识别按时长计费,实时识别也是按时长计费,但两者有免费额度和单价差异,建议按自己的会议时长先估算一轮,别等到账单出来才发现路径选错了。隐私方面,如果会议内容敏感,要评估录音合规性,最好在上传前脱敏或明确授权,并且在转写完成后删除云端音频,只保留文本,避免长期留存带来的风险。
6.4 排查速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 识别结果全是同音字 | 录音音质差或噪音大 | 换麦克风位置,先做 16kHz 单声道转码 |
| 专有名词错误频繁 | 缺少热词 | 建热词表,把项目名、人名、缩写加进去 |
| 说话人混乱 | SpeakerNumber 不准 | 调整预估数,宁多勿少 |
| 任务一直处理中 | URL 过期或队列忙 | 重新生成预签名 URL,确认超时时间 |
| 提交任务报错 | 权限或地域不一致 | 核对密钥权限、桶地域、SDK 版本 |
| 费用异常 | 跑了实时识别或重复调用 | 检查账单明细,确认用的路径 |
这张表是我自己在两次排障中沉淀出来的,方向比细节更重要,遇到问题时先判断是“输入音频问题”还是“参数配置问题”还是“权限链路问题”,逐层排查能省很多时间。
7. 给想复刻这套方案的人几点实在建议
如果你也打算搭这么一个小帮手,我建议三点。第一,从一场真实的 15 分钟小会开始,不要一开始就上一个月的历史录音,先用一场会走通全流程,确认格式、参数、输出模板都符合团队习惯,再批量推广。第二,热词表一定要在第一次转写完成后立刻维护一次,把识别错误的项目代号、产品名、英文缩写补进去,第二次跑效果会完全不同。第三,把脚本写成命令行工具而不是一次性脚本,输入音频路径、输出纪要路径做成参数,后面接入新的录音来源时改动最小。我后来还加了两个小扩展:一个是对接每周例会提醒,提前一天自动检查上一场纪要的待办有没有闭环;另一个是把实时语音识别接到现场投屏,给远程参会的人看字幕。这两个功能都是建立在转换链路已经跑通的基础上,加得很轻松。说到底,工具的价值不在于开头那点 API 调用,而在于你愿意为“会后能不能找回现场”这件事投入多少细节。