
最近在帮团队搭一个会议效率工具需求其实很简单开完一场会自动生成一份还能看的会议纪要把谁说了什么、最后定了什么、下一步谁做什么都拎出来。市面上的方案不是没有但要么太贵要么太重于是我干脆自己用腾讯云 AI 语音识别做了一版“会议小帮手”。这个项目做的事情用一句话概括录下会议音频丢给语音识别服务转成带时间戳和说话人标记的文字稿再通过一小段后处理脚本把文字稿整理成包含讨论过程、结论和待办事项的结构化纪要。整个过程不需要买硬件不需要训练模型一个人花两三天就能把最小闭环跑通。适合正在做办公自动化、智能会议系统或者单纯想了解一下语音识别接口怎么接到业务里的开发者参考。整个项目从设计到落地踩了不少坑我把完整过程、接口调用细节、参数调优经验和最终实测效果都整理在这篇里尽量写成可以直接照着做的手册。1. 会议记录为什么需要“帮手”痛点和整体设计1.1 手动整理会议纪要的三个真实痛点先说痛点不然你感受不到这东西的价值。我以前开会是这么记录的拿个笔记本听到结论写一句听到待办写一句全程低着头散会之后笔记本上大概有半页字剩下的全靠脑子回忆。实际结果就是——会议中讨论的很多细节根本没记下来尤其是多人讨论时A说了一个方案B补了一句风险C说可以试试这种信息流转速度根本跟不上手写速度漏掉的信息往往就是后面最关键的。第二个痛点是整理时间太长。一场一个小时的会录音如果要整理成文字纪要逐句听写基本要花两到三倍时间。我有一次为了赶一份项目会纪要晚上九点开始对着录音敲字敲到十一点半结果第二天发现有个结论听错了又返工。这种低效的劳动完全不值得人去做。第三个痛点是信息失真。手动整理纪要等于“转述”转述就会加入记录者自己的理解偏差。尤其是技术评审会一个“我觉得可以试试”和“我确认这个方法可行”语义差距很大但快速记录时很容易混淆。后来我意识到会议记录的核心需求不是“打字快”而是“完整还原现场”这正好是语音识别擅长的事情。1.2 小帮手的技术路线与功能边界想清楚痛点之后我把目标定得很明确做一个会后自动整理工具不追求实时转写不追加上屏展示核心就是“录音进纪要出”。技术路线分成两段。第一段用腾讯云 AI 语音识别的录音文件识别能力把会议音频转成带时间戳的文字流第二段用 Python 脚本做后处理把文字流按说话人和时间切成段落去掉“嗯”“那个”这类口语词最后用规则引擎提取待办事项和结论。为什么选录音文件识别而不是实时语音识别因为开会场景下录音是现成的可以会后统一处理不需要在会议过程中维持长连接。录音文件识别是异步任务制提交之后后台跑跑完回来拉结果对服务端的压力小得多代码写起来也更简单。后面会有专门的对比这里先记住结论。功能边界上我也做了一些取舍。不做会议录像解析不做实时字幕不做自动发起开会。这些功能听起来很酷但会显著拉长开发周期。“小帮手”的定位是补足“记录-整理-分发”这条链路里最耗人力的“整理”环节先把这一件事做透。2. 接入腾讯云语音识别前必须搞清楚的几件事2.1 开通服务与密钥管理的正确姿势接入的第一步是在腾讯云控制台开通语音识别服务。这个操作本身不复杂进入产品页开通即可但有几个细节容易被忽略。首先是密钥权限。很多初学者会直接拿主账号的 SecretId 和 SecretKey 写到代码里图省事。如果这个项目只在本地跑、不对外发布问题倒不大但如果要部署到服务器上甚至给别人用主账号密钥泄露的风险会非常大。我的做法是专门在访问管理里创建一个子账号只授予语音识别相关的读写权限然后把子账号的密钥配到环境变量里不硬编码进代码。这样即使服务器被拖库别人拿到的也只是一个受限密钥影响面可控。其次是地域选择。语音识别接口的地域参数要和资源所在的区域匹配不同地域的接入点不一样。我一开始用的默认地域也能跑通但发现延迟有点高后来把地域切到服务器同区域的接入点调用耗时降了三分之一左右。具体选哪个地域看你服务器部署在哪里就近原则基本不会错。还有一个容易被忽略的间隔时间点开通服务后密钥和控制台的权限策略可能需要一两分钟才完全生效。我遇到过刚创建完子账号立刻调用接口报“鉴权失败”的情况排查了半天发现是权限同步延迟等了一会儿再试就正常了。遇到鉴权报错别急着怀疑代码先确认一下权限是否生效。2.2 两种识别模式怎么选实时语音识别 vs 录音文件识别腾讯云的语音识别产品线里和会议场景最相关的是两个能力实时语音识别和录音文件识别。新手很容易混淆这里直接用表格对比。对比维度实时语音识别录音文件识别调用方式建立 WebSocket 长连接边传音频边收文字提交任务后台异步处理完成后轮询或回调延迟毫秒级适合上屏秒级到分钟级取决于音频长度适合场景会议实时字幕、直播字幕会议录音归档、语音质检、内容分析后端成本需要维护长连接处理流式数据提交任务后可以去做别的事开发复杂度中高需要处理连接状态和重连低一次提交加几次查询“会议小帮手”选择录音文件识别核心原因是场景匹配。会议的产出是“会后纪要”不是“实时字幕”没有人需要在一场项目会进行到一半的时候看逐字稿大家要的是会后能查、能分发、能执行的结构化内容。录音文件识别把整个识别过程包成异步任务代码里只需要提交、轮询、拉取结果三个步骤异常处理也简单很多。2.3 环境准备Python SDK 安装与最小调用框架我的后处理脚本用 Python 写所以 SDK 选了官方提供的 tencentcloud-sdk-python。安装没有什么特别的直接 pip 安装就行。pip install tencentcloud-sdk-python装完之后初始化客户端有几处细节值得注意。接入点endpoint要显式指定避免默认值在某些网络环境下解析慢区域要和开通服务时选的一致超时时间按实际音频时长适当放宽避免大文件提交时客户端先行超时。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 cred credential.Credential( os.environ.get(TENCENT_SECRET_ID), os.environ.get(TENCENT_SECRET_KEY) ) http_profile HttpProfile() http_profile.endpoint asr.tencentcloudapi.com client_profile ClientProfile() client_profile.httpProfile http_profile client asr_client.AsrClient(cred, ap-guangzhou, client_profile)我在这里踩过一个低级坑SecretId 和 SecretKey 如果从环境变量读取一定要确保环境变量真的生效而不是在终端里 export 一次就以为万事大吉。我有一段时间代码在 IDE 里跑得好好的一到定时执行就报鉴权失败最后发现是定时任务的环境变量没有配置。这类问题排查起来很耗时间项目一开始就统一用配置文件加环境变量的方式管理密钥后面会省心很多。3. 从录音到文字录音文件识别的完整调用链路3.1 提交任务CreateRecTask 的关键参数音频处理好之后提交识别任务是整个流程的第一步。调用的接口是 CreateRecTask核心参数不算多但每一个都影响最终结果质量逐个拆一下。import json req models.CreateRecTaskRequest() params { EngineModelType: 16k_zh, ChannelNum: 1, ResTextFormat: 0, SourceType: 1, Data: https://your-cos-bucket.com/meeting-2024-1101.wav, CallbackUrl: https://your-server.com/asr/callback, SpeakerDiarization: 1, SpeakerNumber: 4, FilterDirty: 1, FilterModal: 1, ConvertNumMode: 1, } req.from_json_string(json.dumps(params)) resp client.CreateRecTask(req) task_id resp.Data.TaskId参数里最重要的是 EngineModelType。会议场景我选的是 16k_zh它是针对中文普通话、16k 采样率的通用模型。如果你的录音是电话会议、采样率可能只有 8k那要考虑用对应的电话模型否则识别率会明显下降。还有 16k_zh_edu 这类面向教育场景的模型虽然词汇覆盖广但不一定适合通用会议不要盲选。SourceType 和 Data 是一对组合决定音频怎么传。SourceType1 表示传 URLData 字段填音频文件的访问地址SourceType0 表示直接传 Base64 编码的音频数据但这个方式对大文件不友好音频越大 Base64 膨胀越严重而且接口对数据大小有限制。我最终选择先把会议录音传到对象存储的私有读 bucket生成临时访问链接再提交既绕开了大小限制也方便后续做数据归档。要注意的是URL 必须能公网访问鉴权时间也要留足不然提交任务后服务端拉不到音频任务会直接失败。我当时试过一个过期时间 10 分钟的链接结果音频有 40 分钟长后台还没开始拉文件链接就失效了。临时链接有效期建议设置成至少音频时长的好几倍。3.2 轮询还是回调查询任务状态的两种策略任务提交成功后会返回一个 TaskId接下来要等识别完成。这个阶段有两种做法主动轮询和回调通知。轮询就是每隔几秒调一次查询接口看任务状态有没有从“进行中”变成“成功”。我先把轮询逻辑写了出来代码很直白import time req models.DescribeTaskStatusRequest() req.from_json_string(json.dumps({TaskId: task_id})) while True: resp client.DescribeTaskStatus(req) status resp.Data.Status if status 1: print(任务成功) break if status 2: print(任务失败) break time.sleep(5)轮询的间隔我建议设置在 5 到 10 秒左右太频繁没有必要接口有 QPS 限制太疏又会让整个流程的等待时间变长。实际测试下来一分钟以内的音频基本几秒钟就能出结果一小时的长音频可能要等几分钟。回调方式更省事提交任务时在 CallbackUrl 填一个自己的接口地址识别完成后服务端会往这个地址发一个 HTTP POST 请求里面带任务状态和结果。这个方案适合处理大批量转写比如一天要转几十个会议录音而不是一次只处理一个。我的项目初期用轮询因为逻辑最简单后来接入到定时任务里批量处理历史录音时才改成回调因为回调不会占着调用进程干等着。如果轮询过程中走到任务失败状态别急着放弃先查一下 StatusDetail里面通常有失败原因。比较常见的是音频解码失败也就是你传的文件不是有效的音频格式或者编码格式和 EngineModelType 不匹配。把录音转成标准 WAV 或 MP3PCM 编码能规避九成以上的解码问题。3.3 拉取识别结果从 JSON 里拿到带时间戳的文字任务状态变成成功之后调用 DescribeRecTaskResult 拉取结果。这一步返回的数据是核心资产包含逐句的文字、开始时间、结束时间、说话人编号还有一些过滤标记位。req models.DescribeRecTaskResultRequest() req.from_json_string(json.dumps({TaskId: task_id})) resp client.DescribeRecTaskResult(req) for item in resp.Data.ResultDetail: print(item.Text) print(item.StartMs, item.EndMs, item.SpeakerId)ResultDetail 是一个数组每个元素对应一句话或者一个意群。StartMs 和 EndMs 的单位是毫秒表示这句话在音频里的时间范围SpeakerId 在开启了说话人分离时才有值代表当前这句话是第几个说话人。写这段逻辑时有一个常见的误解很多人以为 ResultDetail 里的顺序就是时间顺序直接按数组顺序拼接文本。实际不完全是因为带过滤选项时有些被识别为脏字的话会被去掉数组元素之间的时间可能不是连续递增的。稳妥的做法是始终按 StartMs 排序后再处理不要依赖数组下标。拉取结果之后原始转写稿就已经躺在手里了但离“会议纪要”还差得远。别急着去读那些文字真实的转写稿里充满了口语重复、语气助词和语序颠倒下一章专门讲怎么把这些文字加工成能看的东西。4. 文字只是开始会后处理才是“会议小帮手”的精华4.1 按说话人和时间线切分让转写稿变成对话流拿到 ResultDetail 数组之后第一步不是清洗而是重组结构。原始数据是一句一句的通过 SpeakerId 和 StartMs 可以还原出“谁先说了什么谁又接了什么”的对话流。sentences [] for item in resp.Data.ResultDetail: sentences.append({ speaker: item.SpeakerId, start_ms: item.StartMs, end_ms: item.EndMs, text: item.Text }) sentences.sort(keylambda x: x[start_ms]) dialog_lines [] current_speaker None current_line current_start 0 for s in sentences: if s[speaker] ! current_speaker: if current_line: dialog_lines.append({ speaker: current_speaker, start_sec: current_start // 1000, text: current_line.strip() }) current_speaker s[speaker] current_start s[start_ms] current_line s[text] else: current_line s[text] if current_line: dialog_lines.append({ speaker: current_speaker, start_sec: current_start // 1000, text: current_line.strip() })处理完之后输出就是这样的结构每个说话人的一段话带一个开始时间。看起来很像剧本对白这就对了。把这段结构化文本发给任何同事都比发一坨没有分段的转写稿要清楚得多。不过我要提醒一个现实问题SpeakerId 只是编号不是真实姓名。要映射到人名需要额外的校准逻辑比如在会议开始前让每个人单独说一句话来建立音色档案或者在生成纪要用“说话人 1”“说话人 2”代替让用户事后自己替换。我选的是后者因为实现简单而且大部分场景下编号足够用了。4.2 清洗口语词和标点把转写稿变成可读文本真实的会议录音转写文本长什么样大概是这样“嗯就是说我们那个接口呢就是下周二之前然后就是联调嗯一定要完成”。这种文本直接发给谁都会被嫌弃所以清洗环节必不可少。中文口语词主要分几类。一类是纯粹的语气填充比如“嗯”“呃”“啊”“那个”“这个”直接删掉不影响语义。一类是口头禅比如“就是说”“然后”“其实呢”这类词在某些语境里有一点点语义比如“然后”连接前后逻辑关系直接全删可能会让句子衔接变得生硬但会议纪要场景下删掉可读性提升更明显该舍弃就舍弃。我整理了一个清洗函数核心思路是用正则把高频口语词替换成空字符串然后把多个空格和重复标点压缩掉。import re def clean_transcript(text: str) - str: filler [ 嗯, 呃, 啊, 那个, 这个, 就是说, 然后呢, 然后, 其实就是, 对吧, 是吧 ] for w in filler: text text.replace(w, ) text re.sub(r[,、], , text) text re.sub(r[。.], 。, text) text re.sub(r\s{2,}, , text) return text.strip(。 )这里有一个细节不要把所有写法的“那个”都删掉。“那个功能”“那个接口”里的“那个”是指示代词删了句子就残缺了。所以我用的替换词表里“那个”其实是风险最高的一个。后来我改成只删“那个”出现在句首且后跟语气停顿的情况还有一些更谨慎的匹配规则。如果你不想花太多精力打磨最简单的办法是只删纯语气词“嗯”“呃”效果也能接受因为头上的“就是说”这类口头禅出现频率最高的是在句首。清洗完之后的文本已经接近一个记性很好的人手动整理的会议记录了。但还差最关键的两步提取待办事项生成摘要。4.3 用规则引擎一键提取待办事项与会议结论会议纪要里最有价值的永远是“下一步谁做什么”。转写稿里这些信息通常藏在特定的表达结构里比如“需要在下周五之前完成接口联调”“小王负责测试方案”“这件事记得跟进一下”。既然表达结构如此固定完全可以用规则去抓。我一开始用的是正则匹配关键词看句子里有没有“需要”“必须”“记得”“负责”“跟近”“跟进”“尽快”“最晚”这些词。命中之后把关键词后面的部分截取出来作为候选待办。import re TODO_PATTERNS [ r需要([^。]{2,}), r必须([^。]{2,}), r记得([^。]{2,}), r负责([^。]{2,}), r尽快([^。]{2,}), r最晚([^。]{2,}), r下一步([^。]{2,}), ] def extract_todos(text: str) - list[str]: todos [] for pattern in TODO_PATTERNS: matches re.findall(pattern, text) for m in matches: candidate clean_transcript(m).strip() if len(candidate) 4: todos.append(candidate) return todos效果还是比较直观的。“我们最晚周四前把方案初稿发出来”这句话会被抓出“周四前把方案初稿发出来”。也有误抓的情况比如“这个功能不需要太多人参与”里的“需要”后面跟的是否定内容它实际上不是待办。这类问题用纯正则很难完全规避但可以在生成候选之后做一个后置过滤如果提取出的内容里含有“不需要”“不用”“不必”这类否定词直接丢弃。在真实项目里这套规则能覆盖大概六成以上的待办信息剩下的四成需要靠大模型。如果你不想接大模型可以把待办候选输出成列表让操作者人工确认如果愿意接把整篇对话流和“请提取待办”这句话一起放进 Prompt效果会好很多。我的观点是规则引擎负责兜底大模型负责补漏两者结合的成本和效果最均衡。4.4 摘要生成的低成本方案最后是摘要。做摘要最省事的方案是把转写稿直接丢给大语言模型但如果你没有接大模型的预算或者延迟要求可以先用一个土办法按说话人的段落切分之后取每段的开头和结尾句再按词频排序挑出核心句子。from collections import Counter import jieba # 仅用于示例实际项目可用轻量分词 def generate_summary(dialog_lines: list[dict], max_sentences: int 5) - list[str]: texts [line[text] for line in dialog_lines] word_counter Counter() for text in texts: words jieba.lcut(text) word_counter.update([w for w in words if len(w) 1]) summary_sentences [] for line in dialog_lines: sentences re.split(r[。], line[text]) for i, sent in enumerate(sentences): if i in (0, len(sentences) - 1): summary_sentences.append((sum(word_counter[w] for w in jieba.lcut(sent) if len(w) 1), line[start_sec], sent)) summary_sentences.sort(reverseTrue) return [f[{s[1]}s] {s[2]} for s in summary_sentences[:max_sentences]]这个方法的思路是一段讨论里开头通常抛出问题结尾通常收敛结论所以优先取这两处的句子再用词频统计找出整个会议中的高频关键词给句子加权让包含核心词汇的句子更容易排到前面。效果谈不上惊艳但给出一份 5 条左右的要点概览完全够用。多说一句摘要的质量很大程度取决于前两步做得好不好。如果 4.1 的分段乱了4.2 的口语词没洗干净再好的摘要方案也救不回来。数据前处理永远是这类项目的重头戏。5. 实测数据与排查实录文档没写清的坑5.1 第一次测试就翻车双声道会议录音识别率暴跌我以为最顺利的环节是“上传音频转文字”结果第一次完整测试就翻了车。当时拿了一段真实的双声道会议录音一个声道是现场麦克风一个声道是远程接入的音频流。提交识别任务之后返回的文字惨不忍睹一句话里频繁跳出无意义的字段落结构完全错乱。排查后发现原因在 ChannelNum 参数。这段录音是双声道文件但我提交任务时 ChannelNum 填了默认的 1系统按单声道去解码两个不同来源的声音被混在一起处理互相干扰识别率自然崩了。后来把 ChannelNum 调成 2 或者 -1表示按实际通道数自动判定识别结果立刻恢复正常。这里要记住一个原则音频通道数必须和你提交的参数一致。建议拿到录音先看一眼声道信息如果是双声道两个人的声音互不干扰直接识别反而效果好如果不知道会议录音的声道情况就先用工具降成单声道再识别省得在参数上猜来猜去。5.2 长录音任务超时的真实处理经历42 分钟的会议录音前几次测试基本都能在 3 到 5 分钟内转完可后来有一次任务提交后迟迟没有结果。轮询了十分钟状态还是“进行中”最后等来一个失败状态。问题出在音频源链接的访问权限上。我把录音放在对象存储的私有桶里生成的临时链接有效期设成了 30 分钟。录音本身 42 分钟后台排队加识别的时间超过了链接有效期服务端中途拉取音频时发现链接失效任务直接失败。把临时链接有效期改成 12 小时之后这个情况再没出现过。如果你也打算传 URL 让服务端拉取一定要算好“音频时长 排队等待 识别耗时 余量”的总时间URL 有效期至少覆盖两个小时以上才稳。另外如果录音超过两小时建议先切割成 20 分钟左右的小段再分别识别最后按时间戳拼接既能避免任务超时也方便对某一段重新识别。5.3 热词表的作用边界别指望热词解决所有问题会议里免不了出现产品代号、专业术语、人名的英文缩写比如“A模块”“V3.2 SDK”“XX协议”。语音识别模型对生僻词和专有名词的识别率天然偏低这时候需要用到热词表。腾讯云语音识别的热词表功能可以给一组自定义词汇设置权重让模型在识别时优先往这些词上靠。我在项目里建了一张热词表把会议经常出现的产品名、项目代号、关键人名都加了进去权重设置成比较高的档位。实测效果是热词覆盖到的词识别正确率显著提升之前经常识别错的“XX模块与平台名”基本不再出错。但热词表不是万能的。第一热词表对常见通用词没什么帮助别把“会议”“项目”这类词往里塞。第二热词表的更新不是即时生效的我测试过改了热词之后要等一段时间再重新提交任务才能看到效果。第三热词表的条目数量和单条长度有限制不要想着一口气塞几百个词。我的建议是把高价值的 30 到 50 个专有名词维护成热词表剩下的靠清洗规则兜底。如果某个专有名词已经稳定识别正确就把它从热词表里挪出去给其他词留出空间。5.4 调优后的实测效果与费用控制走完整个流程后我用三段真实会议录音做了验证每段时长在 30 到 50 分钟之间包含远程接入和现场收音混合的情况。默认参数下通用会议内容的识别准确率大约在 86% 左右开启热词表、清洗后处理之后关键专有名词的准确率能提升到 95% 以上整体可读性已经接近人工整理的七八成水平。费用方面腾讯云语音识别按录音时长计费录音文件识别模式下一小时音频的成本并不高。但要注意的是费用是按“识别时长”算的不是按任务数算。如果同一段录音提交了五次就按一段录音的五倍时长收费。我测试时因为反复调参数同一个段落在两周内提交了十几次回头对账才发现这部分开销比预想多不少。所以测试阶段一定要用小段音频比如先拿 5 分钟片段调试参数确认稳定后再整段提交。另外任务提交后在短时间内重复轮询不会产生额外识别费用但会有接口调用量的限制轮询间隔别设太短。如果需要批量处理历史录音建议加上一个简单的队列和并发控制避免一次性提交太多任务把配额打满。最后分享一个实际经验这个项目的价值不在于“语音转文字”这一个动作而在于从音频到结构化纪要的完整链路。识别准确率只要有 85% 以上配合清洗和规则提取产出的纪要就足以支撑“会后 10 分钟过一遍待办”这个核心需求。如果你也打算做类似工具我建议先跑通最小闭环也就是“一段音频进一份纪要出”再考虑提升识别率、接入大模型摘要、做成定时批量任务这些优化项。先把流程立起来后面每一步优化都能看到立竿见影的效果。