1. 从一场开完就忘的评审会说起
先说个真实场景。上周三下午,我们团队开了一场产品评审会,从两点开到快五点,三小时的会,信息量极其密集:新版本的功能范围、三个待定的技术方案、两个需要下周验证的风险点、还有运营那边催着要的排期承诺。我坐在会议室里,前半程还在认真记,后半程手已经跟不上了。散会之后看自己的笔记,字迹潦草到只有我自己能勉强认出来,关键决策散落在各种箭头和涂改之间,想复盘的时候脑子里只剩下“好像讨论过,但具体结论是什么”的模糊印象。
这种事情发生一次两次还能忍,每周都来一次就真的受不了。于是我决定动手做个工具,核心思路就是把腾讯云 AI 语音识别接进会议场景,让录音自动变成文字存档,再从文字里提炼出决策和待办。这个“会议小帮手”没有做得特别复杂,但用到现在,我的会议记录质量和复盘效率都明显上了一个台阶。这篇文章就把我这个项目的完整过程写出来,包括为什么选腾讯云、核心流程怎么设计、代码怎么落地、以及我实际跑了几十场会之后踩过的一些坑。
如果你是那种每周要开好几个会、记笔记永远跟不上语速、或者开完会总得靠回忆才能写纪要给同事的人,这篇内容应该能直接帮你省下不少时间。不需要你有多深的编程基础,我会把每一步的原理和注意事项都讲清楚,你照着抄作业也能跑通。
2. 选型对比:为什么把目光停在腾讯云 AI 语音识别上
要做会议语音转写,市面上能走的方案其实不少,但每个方案的适用场景差别很大。我先说结论:在“长音频、中文为主、要求省心”这三个条件下,腾讯云的录音文件识别接口是目前我认为性价比最稳的选择。
2.1 主流语音识别方案,我把能跑的试了一圈
先说开源离线方案。本地部署语音识别模型这条路,我之前在实验室折腾过一段时间,优点是不用联网、数据不出本地,但缺点是硬件门槛明显。一套识别效果过得去的模型,光加载就吃掉不少GPU显存,在普通办公电脑上基本跑不动。而且中文会议场景里有大量口语化表达、专有名词和多说话人重叠,开源模型的默认效果说难听点就是“每个字都认识,连在一起不知道在说什么”,要调到能用的程度,需要准备大量领域语料做微调。对于我只是想解决会议记录这件事来说,投入产出比太低。
再说其他云厂商的做法。市面上确实有几家提供类似的语音转写服务,坦白讲基础功能都大差不差,我试用下来真正拉开差距的主要是三件事:长音频任务的管理方式、中文场景下的标点补齐和数字转化专业程度、以及周边生态的配合度。有些平台把长音频识别做成同步接口,音频稍微长一点就超时,必须自己切段再拼接,拼接处还经常丢字;有些平台虽然支持异步任务,但中文标点恢复做得一般,转写出来一整段没有断句,读起来相当费劲。
腾讯云这边是我最后测试的,反而是最贴合我需求的。它的录音文件识别接口专门为长音频设计,一次能提交最长几百分钟的任务,异步处理不用我自己操心超时问题。中文引擎在标点恢复、数字转化这类细节上做得比较成熟,比如“预算从两百万增加到三百万”这种口语数字,转写出来直接是阿拉伯数字,省掉大量后处理功夫。关键是我把一段含大量产品名和同事昵称的录音丢进去试,准确率明显比另外几家高一个档位。
2.2 录音文件识别接口的硬指标
我特意去翻了接口文档,把和自己这个场景最相关的几个参数整理了一下。这个接口提交任务时主要有几个字段需要关注:
| 参数 | 含义 | 我的推荐值 | 备注 |
|---|---|---|---|
| EngineModelType | 引擎模型类型 | 16k_zh | 16k采样率中文普通话模型 |
| ChannelNum | 音频声道数 | 1 | 单声道转写更稳定 |
| ResTextFormat | 返回结果格式 | 1 | 返回带句子列表和时间戳的JSON |
| SourceType | 音频来源 | 0 或 1 | 0表示URL,1表示Base64上传 |
| VadSilenceTime | 语音活动检测静音时长 | 1800-3000 | 控制句子切分的力度 |
| HotwordId | 热词表ID | 按需配置 | 用于提升专有名词识别率 |
| SpeakerDiarization | 说话人分离开关 | 按会议人数开启 | 配合SpeakerNumber使用 |
这些参数里最有价值的是HotwordId和SpeakerDiarization,前者能通过热词表大幅提升产品名、术语的识别准确率,后者能把不同发言人的内容分开返回。这两个功能我在后面的踩坑章节会展开说,因为它们是“能转写”和“转写好用”之间的分水岭。
2.3 成本怎么算:一场会到底花多少钱
费用这块我刚开始也担心,毕竟会开得多,要是按分钟计费很贵,项目就做不下去。查了计费方式之后发现录音文件识别是按音频时长计费,不是按返回文字量计费,折算下来一场一小时的会议,成本基本就是一次音频识别的费用,完全在可接受范围内。对比一下请一个速记员或者买一套商用转写软件的订阅费,这个方案便宜太多了。
我还特意算过一笔账:假设一个团队每周开5个小时的会议,一个月下来大约20小时音频,折算成费用几乎可以忽略不计。但省下来的时间非常客观——过去我开完会至少花半小时整理纪要,现在自动转写加后处理,五分钟内就能拿到一份带时间戳的完整文档。这种投入产出比,我觉得值得给每个人都安利一下。
3. 完整流程拆解:一段录音如何变成一篇会议纪要
整个会议小帮手的核心链路可以拆成四步:拾音、预处理、识别、后处理。前面两步很多人会忽略,但它们恰恰决定了最终识别率的上限。录音质量如果不行,后面调用再好的识别接口也会打折扣。
3.1 拾音端:设备与摆放直接影响识别率
我最早直接用手机放在会议桌中间录音,效果说实话一般。圆桌会议还好,要是长条桌,坐在远端的人声音会被严重衰减,转写出来经常缺句子。后来换了带麦克风阵列的录音笔放在桌子中央,情况好了很多。我的经验是,如果条件允许,尽量用带多麦克风阵列的设备,它对远场声音的收集能力是普通手机麦克风比不了的。
设备摆放位置也有讲究。会议室如果有投影仪或空气净化器这种持续噪声源,录音设备要尽量远离它们。另外提醒一句,如果会议中有人频繁翻纸质材料或者敲键盘,声音会被一并收进去,VAD阶段有可能会把这些声音当成有效语音切出来。这种情况不用太焦虑,毕竟我们做的是会议纪要辅助工具,不是录音笔质检,杂音只要不影响主要语音路径就问题不大。
3.2 音频预处理:采样率、编码和分段
录音文件不是拿来就能直接丢给API的,预处理这步不能跳。音频格式上,我一般都用ffmpeg转成16kHz采样率、单声道、WAV或MP3格式。语音识别接口对采样率有明确要求,16k中文模型就得配16k采样率的音频,拿44.1kHz的高品质音乐采样率去提交反而是浪费,接口内部也会做重采样,不如我们在本地一步到位。
转码命令很简单,我自己是这样用的:
ffmpeg -i meeting_original.m4a -ar 16000 -ac 1 -f wav meeting_16k.wav这个命令的意思是:把输入文件强制转换为16k采样率、单声道,输出WAV格式。中间那个-ac 1很容易漏,如果忘记设置,转出来的音频还是双声道,ChannelNum参数就得跟着调成2,不然识别时声道对不上,结果会乱。
分段则要看场景。腾讯云的录音文件识别本身支持长音频异步处理,我一般不动它,让它自己处理整段音频。但如果是现场会议想边开边出文字,那就得走实时语音识别接口,那是另一套调用逻辑。我的项目是会后整理,所以用异步长音频方案就够了,处理起来也稳。
3.3 异步识别任务的生命周期:提交、轮询与拉取
录音文件识别接口的调用模型是异步的,这个设计一开始容易让新手懵:提交任务后拿到的不是识别结果,而是一个TaskId,然后你得不停去查这个任务的执行状态,直到它成功或失败。
生命周期大概是这样的:提交任务后返回TaskId,这时任务进入排队或处理中状态;处理完成后状态变为成功,此时响应体里会带一份JSON格式的识别结果;如果处理中失败,也会有明确的状态码返回。整个过程对一小时音频来说,一般两三分钟能出结果,长音频可能需要更久。我的轮询间隔设的是10秒,不要太频繁,避免无谓的请求。
这里说个我个人的习惯:轮询逻辑一定要写超时和最大等待时间,不然遇到网络抖动或者服务端任务堆积,客户端可能无限等下去。我在核心代码里设了900秒上限,超出就主动报错,宁可重新提交一次任务,也不让脚本挂在那里。
4. 核心代码落地:把链路变成能跑的工具
原理讲清楚了,动手写代码的过程相对就顺了。我用Python来写这个会议小帮手,因为腾讯云官方有Python SDK,调用起来最省事,而且后处理文本的时候Python处理JSON也方便。整个项目不需要很复杂的架构,一个脚本文件加一个依赖清单就够了。
4.1 环境准备与依赖安装
先装SDK,这一步没啥花头。
pip install tencentcloud-sdk-python-common pip install tencentcloud-sdk-python-asrSDK安装好之后,还需要在环境变量里配置访问密钥。这几个密钥值在腾讯云控制台的访问管理里可以生成。我强烈建议不要把它们硬编码在脚本里,而是通过环境变量传入,免得代码不小心泄露出去。
export TENCENTCLOUD_SECRET_ID="你的ID" export TENCENTCLOUD_SECRET_KEY="你的KEY"4.2 会议小帮手的核心类:识别任务封装
核心逻辑就是提交任务、轮询状态、拉取结果。我封装了一个类,把创建任务和等待结果串起来。下面这段代码是整个工具的主干线,我加了比较详细的注释。
import os import json 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 class MeetingHelper: def __init__(self, region="ap-shanghai"): cred = credential.Credential( os.environ.get("TENCENTCLOUD_SECRET_ID"), os.environ.get("TENCENTCLOUD_SECRET_KEY"), ) http_profile = HttpProfile() http_profile.endpoint = "asr.tencentcloudapi.com" client_profile = ClientProfile() client_profile.httpProfile = http_profile self.client = asr_client.AsrClient(cred, region, client_profile) self.hotword_id = os.environ.get("MEETING_HOTWORD_ID", "") def create_task(self, audio_url): req = models.CreateRecTaskRequest() req.EngineModelType = "16k_zh" req.ChannelNum = 1 req.ResTextFormat = 1 req.SourceType = 0 req.Url = audio_url req.ConvertNumMode = 1 req.FilterPunc = 0 if self.hotword_id: req.HotwordId = self.hotword_id resp = self.client.CreateRecTask(req) return resp.Data.TaskId def wait_result(self, task_id, max_wait=900): req = models.DescribeTaskStatusRequest() req.TaskId = task_id deadline = time.time() + max_wait while time.time() < deadline: resp = self.client.DescribeTaskStatus(req) status = resp.Data.Status if status == 2: return json.loads(resp.Data.Result) if status == 3: raise RuntimeError(f"识别任务失败: {resp.Data.ErrorMsg}") time.sleep(10) raise TimeoutError("等待识别结果超时") def recognize(self, audio_url): task_id = self.create_task(audio_url) print(f"识别任务已提交: {task_id}") return self.wait_result(task_id)这段代码里有两个容易被忽略的地方。一个是FilterPunc = 0,我故意不过滤标点,因为标点符号对后续做摘要断句有帮助;另一个是ConvertNumMode = 1,这个参数会把口语数字转成阿拉伯数字,比如“三百万”变成“300万”,后续做数据统计分析会方便很多。
4.3 从流水文本到结构化纪要的后处理
接口返回的JSON在ResTextFormat=1时包含一个SentenceList,里面是每个句子的开始时间、结束时间、说话人ID(如果开说话人分离的话)和文本内容。我的后处理脚本会把句子按时间顺序拼成完整文本,同时按说话人ID分桶,再按60秒一个区间切块,生成带时间戳的分段纪要素材。
def post_process(result): sentences = result.get("SentenceList", []) full_text = "".join(s["Text"] for s in sentences) speakers = {} for s in sentences: sid = s.get("SpeakerId", 0) speakers.setdefault(sid, []).append(s["Text"]) timeline = [] current_start = 0 current_end = 0 current_text = [] for s in sentences: if not current_text: current_start = s["BeginTime"] current_end = s["EndTime"] current_text.append(s["Text"]) if current_end - current_start >= 60000: timeline.append({ "start": current_start, "end": current_end, "text": "".join(current_text), }) current_text = [] if current_text: timeline.append({ "start": current_start, "end": current_end, "text": "".join(current_text), }) return { "full_text": full_text, "speaker_texts": speakers, "timeline": timeline, }这样处理的输出,已经可以直接作为会议纪要的底稿了。我在实际使用中会再根据发言片段的时间戳快速定位某段讨论,比如“这个结论大概是在第28分钟左右出现的”,回听录音确认的时候非常方便。
5. 实测踩坑:VAD、热词表和说话人分离
代码能跑通只是第一步,真正让它“好用”还差得很远。我连续用这个工具处理了几十场会议录音,识别质量参差不齐,后面复盘才发现问题大多出在三个地方。
5.1 VadSilenceTime 到底要不要调
VAD参数控制的是“间隔多久没人说话就认为这句话结束了”。默认值是几百毫秒到一两秒这个量级,短对话场景很好用,但放到会议场景就容易出问题。我第一版直接用默认值,结果是一个人连续讲很长一段时,中间偶尔停顿一下换口气,VAD就把这段切成了好几个短句,返回的句子列表碎得不行,后处理拼接虽然能拼回去,但每个句子的时间戳变得没有参考价值。
后来我把VadSilenceTime调到了2000到3000毫秒,断句明显更接近“一个人完整表达一个意思”的粒度。但这个值也不是越大越好,如果两位参会者对话节奏紧凑,A刚说完B马上接上,过大的静音阈值可能把两个人的话合并成一句,说话人分离的判断也会出错。我的建议是先用默认值识别一版,看看句子粒度是否符合预期,再根据会议风格微调。
这里有一个经验,常规的“快问快答”式评审会,建议用1500毫秒左右;偏演讲、报告式的会议,可以放宽到3000毫秒。没有绝对正确的参数,只有和场景匹配的参数。
5.2 热词表:让产品名、术语不再被“写错”
最让我头疼的是专有名词。我们的项目里有个功能模块叫“X-Bridge”,这个英文词在默认模型下经常被识别成各种莫名其妙的中文组合,而且每次错的还都不一样。产品名这种词错了,可能还能根据上下文猜出来,但如果是数字ID或者代码命名,错了就真的找不回来了。
解决办法是热词表。在控制台的语音识别产品里可以创建热词表,把项目里常见的产品名、同事昵称、技术术语都填进去,每种词可以配一个权重,权重越高识别时越优先按这个词匹配。热词表创建完成会生成一个ID,把这个ID填到HotwordId参数里就行。
我总结的填表经验是:中文词条效果比英文词条好,短词条效果比长词条好。太长的一句话需求描述建议拆成几个词组分开填,尽量避免填整句。热词表不是一次性的事,每开几次会发现某个词被写错了,就追加进去,它是可以持续迭代的。
5.3 说话人分离:多人会议的角色归属没那么简单
会议纪要只到“谁说了什么”还不够,最好能知道是哪位同事说的。腾讯云的接口支持说话人分离,开启SpeakerDiarization并设置SpeakerNumber后,返回的句子里会带SpeakerId字段。
我刚开始天真的以为它会自动识别出每个人叫什么名字,实际用下来发现它只做声纹聚类——也就是说它能把不同的音色区分开,但并不知道谁是谁。比如一场会有5个人说话,它可能会返回5个SpeakerId,但具体哪个ID对应会议室里的哪个人,需要自己建立一个映射。
我的做法是:会议前半段录音里请每个人轮流说一句话作为“注册样本”,然后根据时间顺序把SpeakerId和发言人对应上。这个方法听起来土,但非常管用。还有个坑就是SpeakerNumber这个参数,人多的会议如果设置的人数比实际说话人数少,声纹聚类容易把不同的人合并成一个ID;设置多了又会把同一个人在不同时段的音色差异拆成两个人,所以这个值要稍微留点余量,宁可多不可少。
6. 让工具真正进入日常会议:三个改造方向
目前这个会议小帮手已经能稳定输出带时间戳、带说话人分组的转写文档,但离“小帮手”这个定位还有距离。把它从“语音转写工具”升级成“会议记录助理”,我觉得有三个改造方向很值得做。
6.1 与日历联动:会议结束三分钟收到纪要
最实用的一步是接入日历和通知渠道。我的方案是写了一个定时任务,每天会议开始前检查日历中的会议列表,会议结束时间一到,就自动去拉取云存储里的录音文件,跑一轮识别和后处理,最后把生成的纪要文本推送到团队聊天工具。
这部分没有用太复杂的框架,就是基础的定时任务加文件监听。录音文件的获取我做了个约定,会议室的录音笔连上电脑后会自动把文件传到指定的COS路径,命名格式包含会议ID,这样定时任务扫目录就能精准知道要处理哪个文件。自动化程度高了之后,最直接的改变是参会人不用再开口问“纪要什么时候出”,纪要自己会出现在每个人面前。
6.2 二次提炼:从转写文本里挖出决策和待办
纯转写文本不等于会议纪要,它只是材料。真正值钱的信息是决策和待办,而这些信息淹没在大量的对话里。我在后处理环节加了一个轻量级的提炼逻辑,用规则去匹配常见决策句式,比如“那就定了”“我们确认一下”“下周三之前完成”,同时结合时间戳上下文,把这些句子单独拎出来放在纪要最前面。
这个方案比单纯的关键词匹配稍微聪明一点,因为它是按句子的上下文位置和说话人分组的,不会把闲聊里的“好像定了”误判成决策。如果以后需要更高质量的结构化信息,还有一个思路是把完整转写文本丢给大模型做二次归纳,让模型帮忙输出“讨论主题、主要分歧、最终结论、行动项”四个板块。我在部分非敏感的内部会议上试过,效果确实好,但要注意数据隐私的边界。
6.3 隐私与安全:录音数据的边界
这一点必须单独说。会议录音属于敏感数据,整理完纪要之后录音文件的保存和销毁策略要提前想清楚。我的做法是:转写完成且纪要确认无误后,原始音频默认在七天后自动清理,只有在需要回溯细节时才选择保留。开会前也会提前告知参会者有录音和自动转写,让大家知情。
如果是涉密程度更高的会议,更稳妥的方案是把服务部署在私有化环境,音频文件不走公网对象存储,直接用Base64上传给识别接口。接口本身返回的结果也可以加密存储。别看这些都是“额外工作量”,在这个场景里,安全和便捷同等重要,少了这一层我不敢把这个工具推广给全团队用。
最后再分享一个小技巧
项目跑了一段时间之后,我发现自己对“会议小帮手”最大的依赖反而不是转写本身,而是它的回看功能。以前开会开完就完了,很多细节当时没注意,之后也没法翻回去查;现在每次生成纪要时都会自动带一条时间戳索引,哪句话是哪位同事在什么时间点说的,一目了然。
如果你也要做类似工具,我的建议是不要一上来就追求大而全。先用最简单的链路把录音转写跑通,用一个月左右积累真实会议数据,你会发现哪些参数需要调、哪些后处理规则值得加,都会自己冒出来。等基础功能稳定了,再按我的三个方向逐步迭代。工具是给自己用的,顺手比花哨重要得多。