1. 项目概述:当会议有了“记忆”

你有没有过这样的经历?开完一个长达一小时的会议,大家讨论得热火朝天,定下了七八个待办事项,结果散会后,每个人对“谁、在什么时候、做什么”的理解都出现了微妙的偏差。或者,一周后你想回顾上次会议关于某个技术方案的决策细节,却发现会议纪要写得语焉不详,关键信息早已模糊不清。更常见的是,作为会议记录者,你一边要参与讨论,一边要奋笔疾书,手忙脚乱,最后发现记录下来的内容支离破碎,重点全无。

“AI Meeting Memory Assistant”这个项目,就是为了解决这些痛点而生的。它不是一个简单的录音转文字工具,而是一个真正意义上的“会议记忆体”。其核心是利用人工智能技术,特别是大语言模型(LLM)和自动语音识别(ASR),为线上或线下会议构建一个可查询、可分析、可执行的“第二大脑”。想象一下,会议结束后,你不仅能获得一份逐字稿,还能立刻得到一份结构清晰的纪要,自动提炼出的待办事项,甚至能随时向这个“助理”提问:“我们刚才关于预算部分是怎么讨论的?”或者“张三承诺的交付物是什么时候?”,它都能从会议录音中精准定位并给出答案。

这个项目非常适合有一定Python基础的开发者、产品经理、团队负责人,或者任何对AI应用落地感兴趣的人。它涉及的技术栈清晰且前沿,包括语音处理、自然语言理解、向量数据库等,是一个绝佳的练手项目,能让你亲手打造一个解决真实工作痛点的智能工具。接下来,我将拆解整个项目的实现思路、技术细节和避坑指南,手把手带你从零构建属于你自己的“会议记忆助理”。

2. 核心架构与工具选型

构建一个AI会议记忆助理,我们需要一个清晰、可扩展的架构。整个系统可以看作一个数据处理管道,从原始的音频输入开始,经过多道工序,最终产出结构化的知识和便捷的查询接口。

2.1 整体技术栈设计

一个稳健的架构是项目成功的基础。我设计的核心流程如下:

  1. 音频采集与预处理 :获取会议录音。对于线上会议(如腾讯会议、Zoom),可以通过系统音频录制或官方API;对于线下会议,则使用录音设备。得到的音频文件(通常是 .wav .mp3 )需要进行降噪、归一化等预处理,提升后续识别准确率。
  2. 语音转文本(ASR) :这是将声音转化为可处理文本的关键一步。我们需要选择一个准确率高、支持中文、并且最好能区分说话人(声纹识别或说话人分离)的ASR服务或模型。
  3. 文本后处理与结构化 :原始的转写文本是杂乱无章的,包含口语化词汇、重复、语气词等。这一步需要利用大语言模型(LLM)的强大能力,对文本进行清洗、分段,并提取核心信息,如生成会议纪要、提炼行动项(Action Items)、识别关键决策点等。
  4. 知识存储与索引 :结构化的信息需要被有效存储,以便快速检索。这里不能只用传统数据库,因为我们的查询可能是自然语言,比如“上次说的那个营销方案有什么风险?”。因此,需要引入 向量数据库 ,将文本转化为向量(Embedding),实现语义级别的相似度搜索。
  5. 交互查询接口 :为用户提供一个自然的方式与“会议记忆”交互。这可以是一个简单的命令行工具,一个Web界面,或者集成到Slack、钉钉等办公软件中的机器人。用户通过提问,系统从向量数据库中检索相关片段,并让LLM生成一个精准、连贯的答案。

基于这个流程,我的技术选型如下:

  • 编程语言 Python 。这是AI和数据处理领域的事实标准,拥有最丰富的库和社区支持。
  • 语音转文本(ASR)
    • 首选方案 OpenAI Whisper 。这是一个开源模型,准确率极高,支持多语言,且能进行说话人分离(需要额外标注数据或结合其他工具)。它可以在本地运行,无需网络,保障数据隐私。对于大多数场景, whisper-1 (基础模型)或 whisper-2 (中等模型)在精度和速度上取得了很好的平衡。
    • 备选方案 :各大云服务商的ASR API,如阿里云、腾讯云、百度云。它们通常更稳定,集成说话人分离功能更成熟,但会产生持续费用,且数据需上传至云端。
  • 大语言模型(LLM)
    • 云端方案 OpenAI GPT-4/3.5-Turbo API Claude API 。它们能力强大,使用简单,是快速验证想法和构建原型的首选。需要注意API调用成本和网络稳定性。
    • 本地方案 Ollama + 开源模型 (如 Qwen2.5 Llama 3.2 DeepSeek-Coder )。如果你对数据隐私有极高要求,或希望零成本运行,这是一个绝佳选择。Ollama简化了本地大模型的部署和管理。虽然效果可能略逊于顶级云端模型,但对于会议纪要总结、信息提取等任务已完全足够。
  • 向量数据库 ChromaDB 。它轻量、易用,纯Python实现,非常适合嵌入到应用程序中。它提供了简单的API来存储文档、生成嵌入向量并进行相似性搜索。对于入门和中等规模的项目,ChromaDB是完美选择。
  • 嵌入模型 :为了将文本存入向量数据库,我们需要一个模型将文本转换为向量。可以使用 OpenAI的 text-embedding-3-small API ,也可以使用 本地模型 ,如 BAAI/bge-small-zh-v1.5 ,这是一个在中文上表现优异的开源嵌入模型,可以通过 Hugging Face sentence-transformers 库调用。
  • 应用框架 Gradio Streamlit 。这两个库能让你用极少的代码快速构建出交互式的Web界面,非常适合演示和内部使用。Gradio更轻快,Streamlit在数据展示上更强大一些。

选型心路 :为什么选择这套组合?核心是 平衡效率、成本与隐私 。Whisper解决ASR的“有和无”问题,且免费。LLM方面,初期用云端API快速迭代功能,验证价值;待核心流程跑通后,可以平滑迁移到本地Ollama方案以控制长期成本并保障隐私。ChromaDB和Gradio极大地降低了开发门槛,让我们能聚焦在业务逻辑而非基础设施上。

2.2 开发环境准备

工欲善其事,必先利其器。一个干净的开发环境能避免很多依赖冲突。

# 1. 创建并激活虚拟环境 (推荐使用 conda 或 venv)
conda create -n meeting-ai python=3.10
conda activate meeting-ai

# 2. 安装核心依赖
pip install openai-whisper  # 语音转文字
pip install openai          # 如需使用GPT API
pip install chromadb       # 向量数据库
pip install sentence-transformers # 使用开源嵌入模型
pip install gradio         # 构建Web界面
pip install pydub          # 音频处理
pip install langchain      # (可选)用于编排LLM应用链,初期可不用

# 3. 安装Ollama (如果选择本地LLM方案)
# 访问 https://ollama.com/ 下载并安装对应操作系统的版本
# 安装后,在终端拉取一个模型,例如:
# ollama pull qwen2.5:7b

注意事项

  • Whisper模型第一次运行时会自动下载,模型文件较大(如 medium 模型约1.5GB),请确保网络通畅和磁盘空间。
  • 如果使用本地嵌入模型(如 BAAI/bge-small-zh-v1.5 ),第一次运行 SentenceTransformer 时也会下载模型文件。
  • 对于音频处理, pydub 依赖 ffmpeg 。在Mac上可以用 brew install ffmpeg 安装;在Ubuntu上用 sudo apt install ffmpeg ;在Windows上需要从官网下载二进制文件并配置环境变量。

3. 核心模块实现详解

有了清晰的架构和准备好的环境,我们就可以开始动手实现各个核心模块了。我会按照数据处理流程,逐一拆解。

3.1 音频处理与高精度转写

会议录音的质量直接决定了后续所有环节的上限。我们的目标是获得一份带时间戳、且尽可能区分说话人的文本。

import whisper
from pydub import AudioSegment
import numpy as np

class AudioTranscriber:
    def __init__(self, model_size="medium"):
        """
        初始化Whisper模型。
        :param model_size: tiny, base, small, medium, large。 精度和速度的权衡。
        """
        print(f"正在加载Whisper-{model_size}模型...")
        self.model = whisper.load_model(model_size)
        print("模型加载完毕。")

    def transcribe(self, audio_path, language="zh"):
        """
        转录音频文件。
        :param audio_path: 音频文件路径。
        :param language: 音频语言,'zh'代表中文。
        :return: Whisper的完整结果字典,包含'segments'(带时间戳的句子列表)等信息。
        """
        # 使用Whisper进行转录
        result = self.model.transcribe(audio_path, language=language, fp16=False) # fp16=False在某些CPU上更稳定
        return result

    def format_transcript(self, result):
        """
        将Whisper结果格式化为带时间戳的文本。
        :param result: Whisper.transcribe()返回的结果。
        :return: 格式化后的字符串。
        """
        transcript_text = ""
        for segment in result["segments"]:
            start = segment["start"]
            end = segment["end"]
            text = segment["text"].strip()
            # 格式:[00:01:23 -> 00:01:45] 这里是说话内容。
            start_str = f"{int(start//3600):02d}:{int((start%3600)//60):02d}:{int(start%60):02d}"
            end_str = f"{int(end//3600):02d}:{int((end%3600)//60):02d}:{int(end%60):02d}"
            transcript_text += f"[{start_str} -> {end_str}] {text}\n"
        return transcript_text

# 使用示例
if __name__ == "__main__":
    transcriber = AudioTranscriber(model_size="medium")
    # 假设我们有一个 meeting_20240520.wav 文件
    result = transcriber.transcribe("meeting_20240520.wav")
    formatted_text = transcriber.format_transcript(result)
    print(formatted_text[:500]) # 打印前500字符预览
    # 保存完整转录文本
    with open("meeting_transcript.txt", "w", encoding="utf-8") as f:
        f.write(formatted_text)

实操要点与避坑指南

  1. 模型大小选择 tiny base 模型速度快,但中文准确率一般,适合演示或英文会议。 small medium 是中文场景的 甜点区 ,准确率和速度平衡得很好。 large 模型最准,但速度慢、显存占用高,除非对精度有极致要求,否则不推荐。
  2. 音频预处理至关重要 :Whisper对音频质量有一定要求。如果原始录音环境嘈杂,建议先用 pydub 进行预处理。
    from pydub import AudioSegment
    from pydub.effects import normalize
    
    audio = AudioSegment.from_file("noisy_meeting.mp3")
    # 简单降噪:这里以压缩动态范围为例,实际复杂降噪需专用库如noisereduce
    audio = audio.low_pass_filter(3000) # 低通滤波,切掉部分高频噪音
    audio = normalize(audio) # 归一化音量
    audio.export("cleaned_meeting.wav", format="wav")
    
  3. 说话人分离的挑战 :Whisper本身不直接输出说话人标签。实现说话人分离(又称“语音分割聚类”)是一个独立课题,可以使用 pyannote.audio 这样的专业库,但其使用(特别是训练好的模型)有一定门槛。 一个实用的折中方案是:在会议开始时,让每位参与者按顺序报一下自己的名字,后期人工或通过简单规则结合时间戳来区分。对于小型固定团队会议,这通常是可接受的。
  4. 处理长音频 :超过30分钟的会议音频,直接喂给Whisper可能会内存不足。需要将其分割成15-20分钟的小段,分别转录后再合并。注意处理分段处语句不完整的问题。

3.2 基于LLM的智能信息萃取

拿到逐字稿只是第一步,一堆文字不是“记忆”,而是“噪音”。我们需要LLM来充当“理解者”和“整理者”,从中提炼出有价值的结构化信息。

我们将设计几个核心的提炼任务:

  1. 会议纪要生成 :用简洁、正式的语言总结会议的核心内容、讨论过程和结论。
  2. 行动项提取 :自动识别出所有带有承诺性质的待办事项,包括负责人、内容和时间(如果提及)。
  3. 关键问答对生成 :预判用户可能会问的问题,并从会议内容中生成对应的答案。这能为后续的向量检索提供高质量的“种子”数据。
import openai # 或者使用 ollama 的客户端
import json
import os

class MeetingAnalyzer:
    def __init__(self, llm_type="openai", model_name="gpt-3.5-turbo"):
        """
        初始化LLM分析器。
        :param llm_type: 'openai' 或 'ollama'
        :param model_name: 模型名称,如 'gpt-4', 'qwen2.5:7b'
        """
        self.llm_type = llm_type
        self.model_name = model_name
        if llm_type == "openai":
            # 假设OPENAI_API_KEY已设置在环境变量中
            self.client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
        elif llm_type == "ollama":
            # Ollama 通常通过本地HTTP API调用
            self.client = openai.OpenAI(
                base_url='http://localhost:11434/v1',
                api_key='ollama', # ollama不需要真实的key,但openai库要求有
            )
            model_name = model_name # Ollama的模型名如 'qwen2.5:7b'
        else:
            raise ValueError("llm_type must be 'openai' or 'ollama'")

    def _call_llm(self, prompt, system_message="你是一个专业的会议助理,擅长从会议记录中提取和总结信息。"):
        """统一的LLM调用函数"""
        messages = [
            {"role": "system", "content": system_message},
            {"role": "user", "content": prompt}
        ]
        if self.llm_type == "openai":
            response = self.client.chat.completions.create(
                model=self.model_name,
                messages=messages,
                temperature=0.2, # 低温度,输出更确定、更专注
                max_tokens=1500
            )
            return response.choices[0].message.content
        else: # ollama
            response = self.client.chat.completions.create(
                model=self.model_name,
                messages=messages,
                temperature=0.2
            )
            return response.choices[0].message.content

    def generate_summary(self, transcript_text):
        """生成会议纪要"""
        prompt = f"""
        请根据以下的会议转录文本,生成一份简洁、专业的会议纪要。
        纪要需包含:会议主题、主要参会人(如果文本中有提及)、核心讨论点、做出的决策、以及下一步行动计划概要。
        请使用中文输出。

        会议转录文本:
        {transcript_text[:6000]} # 限制长度,避免超出token限制
        """
        summary = self._call_llm(prompt, system_message="你是一个专业的秘书,擅长撰写会议纪要。")
        return summary

    def extract_action_items(self, transcript_text):
        """提取行动项"""
        prompt = f"""
        请仔细阅读以下会议记录,并提取出所有明确的行动项(Action Items)。
        每个行动项请按照以下JSON格式输出一个对象:
        {{
            "task": "具体的任务描述",
            "assignee": "负责人(如果提及)",
            "deadline": "截止时间(如果提及,否则写‘未明确’)",
            "source_context": "提取出该行动项的原始文本片段"
        }}
        请将所有的行动项放在一个JSON数组里输出。
        会议记录:
        {transcript_text[:6000]}
        """
        response_text = self._call_llm(prompt, system_message="你擅长从对话中识别承诺和待办事项。")
        # LLM可能不会返回纯净的JSON,我们需要尝试解析
        try:
            # 尝试找到JSON数组部分
            import re
            json_match = re.search(r'\[\s*\{.*\}\s*\]', response_text, re.DOTALL)
            if json_match:
                action_items = json.loads(json_match.group())
            else:
                # 如果找不到,尝试直接解析整个响应(风险较高)
                action_items = json.loads(response_text)
            return action_items
        except json.JSONDecodeError as e:
            print(f"解析行动项JSON失败: {e}")
            print(f"LLM返回的原始文本:\n{response_text}")
            # 返回一个空列表或包含错误信息的结构
            return [{"error": "解析失败", "raw_response": response_text[:200]}]

    def generate_qa_pairs(self, transcript_text, num_pairs=10):
        """生成潜在的问答对,用于丰富知识库"""
        prompt = f"""
        假设你是参会者,在会后需要回顾这场会议。请根据以下会议记录,生成{num_pairs}个最可能被问到的、有明确答案的问题。
        并为每个问题提供基于会议记录的准确答案。
        请以JSON格式输出,格式如下:
        {{
            "qa_pairs": [
                {{"question": "问题1", "answer": "答案1"}},
                {{"question": "问题2", "answer": "答案2"}}
            ]
        }}
        会议记录:
        {transcript_text[:8000]} # 可以给更多上下文来生成更好的问题
        """
        response_text = self._call_llm(prompt)
        try:
            qa_data = json.loads(response_text)
            return qa_data.get("qa_pairs", [])
        except json.JSONDecodeError:
            # 简易回退:按行分割,假设奇数行是问题,偶数行是答案(不推荐用于生产)
            return []

# 使用示例
if __name__ == "__main__":
    # 读取之前保存的转录文本
    with open("meeting_transcript.txt", "r", encoding="utf-8") as f:
        transcript = f.read()

    analyzer = MeetingAnalyzer(llm_type="openai", model_name="gpt-3.5-turbo") # 或使用 ollama

    print("正在生成会议纪要...")
    summary = analyzer.generate_summary(transcript)
    print("=== 会议纪要 ===\n", summary)

    print("\n正在提取行动项...")
    actions = analyzer.extract_action_items(transcript)
    print("=== 行动项 ===")
    for i, action in enumerate(actions, 1):
        print(f"{i}. 任务:{action.get('task')}")
        print(f"   负责人:{action.get('assignee')}")
        print(f"   截止时间:{action.get('deadline')}")
        print(f"   来源:{action.get('source_context')[:100]}...\n")

    # 保存结构化结果
    with open("meeting_structured_output.json", "w", encoding="utf-8") as f:
        output = {
            "summary": summary,
            "action_items": actions
        }
        json.dump(output, f, ensure_ascii=False, indent=2)

核心技巧与经验之谈

  1. Prompt工程是关键 :LLM的输出质量极大程度上依赖于你的提示词(Prompt)。上面的示例Prompt已经过优化,但你可以根据自己会议的风格(是脑暴会、决策会还是周例会)进一步调整。例如,对于技术评审会,可以要求LLM额外提取“提出的风险”和“建议的解决方案”。
  2. 处理长文本 :LLM有上下文长度限制(如GPT-3.5-Turbo是16K token)。如果转录文本很长,你需要将其分割成有重叠的块,分别分析后再合并结果,或者使用“Map-Reduce”策略:先让LLM总结每一段,再总结这些总结。
  3. 输出格式控制 :明确要求LLM以JSON等结构化格式输出,可以极大简化后续的代码处理。但LLM有时会“说废话”,在JSON外加解释。因此,代码中需要包含健壮的解析逻辑,比如用正则表达式提取JSON部分。
  4. 本地LLM的调优 :使用Ollama运行本地模型时,如果发现提取效果不佳,可以尝试:
    • 换模型 qwen2.5:7b 在中文理解和指令跟随上表现不错。 llama3.2 deepseek-coder (如果会议涉及代码)也是好选择。
    • 调参数 :适当提高 temperature (如到0.7)可能让模型更有创造性,但也会更不稳定。对于总结任务,低温度(0.1-0.3)通常更好。
    • 写更详细的Prompt :本地小模型需要更具体、步骤更清晰的指令。

3.3 构建可查询的向量记忆库

现在,我们有了原始的转录文本、结构化的纪要、行动项。如何让用户能像对话一样自然地问询呢?这就需要向量数据库。它的原理是:将文本转化为高维空间中的点(向量),语义相似的文本,其向量在空间中的距离也近。

import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
import uuid

class MeetingMemory:
    def __init__(self, persist_directory="./meeting_memory_db"):
        """
        初始化记忆库。
        :param persist_directory: 向量数据库持久化存储路径。
        """
        # 初始化嵌入模型(使用开源本地模型)
        print("正在加载嵌入模型...")
        self.embed_model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
        print("嵌入模型加载完毕。")

        # 初始化Chroma客户端,并持久化
        self.client = chromadb.PersistentClient(path=persist_directory)
        # 获取或创建一个集合(collection),类似于数据库的表
        self.collection = self.client.get_or_create_collection(
            name="meeting_transcripts",
            metadata={"hnsw:space": "cosine"} # 使用余弦相似度进行搜索
        )

    def _chunk_text(self, text, chunk_size=500, chunk_overlap=50):
        """
        将长文本分割成重叠的小块,以适应嵌入模型的上下文窗口并提高检索精度。
        :param text: 原始文本。
        :param chunk_size: 每个文本块的大致字符数。
        :param chunk_overlap: 块之间的重叠字符数,避免切断完整句子。
        :return: 文本块列表。
        """
        # 简单的按句子分割实现(生产环境建议用更专业的分句工具,如 spaCy)
        sentences = text.replace('\n', ' ').split('。')
        chunks = []
        current_chunk = ""
        for sentence in sentences:
            if len(current_chunk) + len(sentence) < chunk_size:
                current_chunk += sentence + "。"
            else:
                if current_chunk:
                    chunks.append(current_chunk.strip())
                current_chunk = sentence + "。" # 开始新块
                # 处理重叠:将当前块的开头部分作为下一个块的开头(简化逻辑)
                # 更优方案是保留一个滑动窗口
        if current_chunk:
            chunks.append(current_chunk.strip())
        # 简单的重叠处理:复制部分内容到下一个块的开头(这里简化,实际可更精细)
        if chunk_overlap > 0 and len(chunks) > 1:
            adjusted_chunks = []
            for i in range(len(chunks)):
                if i == 0:
                    adjusted_chunks.append(chunks[i])
                else:
                    # 取前一个块的最后一部分作为当前块的前缀
                    overlap_part = chunks[i-1][-chunk_overlap:] if len(chunks[i-1]) > chunk_overlap else chunks[i-1]
                    adjusted_chunks.append(overlap_part + " " + chunks[i])
            chunks = adjusted_chunks
        return chunks

    def add_meeting(self, meeting_id, transcript_text, summary="", action_items=[]):
        """
        将一次会议的记忆存入向量数据库。
        :param meeting_id: 会议唯一标识符(如日期+主题)。
        :param transcript_text: 会议转录文本。
        :param summary: 会议摘要。
        :param action_items: 行动项列表。
        """
        # 1. 准备要存储的文档
        documents_to_store = []
        metadatas_to_store = []
        ids_to_store = []

        # 将转录文本分块存储
        transcript_chunks = self._chunk_text(transcript_text)
        for i, chunk in enumerate(transcript_chunks):
            chunk_id = f"{meeting_id}_transcript_{i}"
            documents_to_store.append(chunk)
            metadatas_to_store.append({"type": "transcript_chunk", "meeting_id": meeting_id, "chunk_index": i})
            ids_to_store.append(chunk_id)

        # 存储摘要(作为一个独立文档)
        if summary:
            summary_id = f"{meeting_id}_summary"
            documents_to_store.append(summary)
            metadatas_to_store.append({"type": "summary", "meeting_id": meeting_id})
            ids_to_store.append(summary_id)

        # 存储每个行动项
        for j, item in enumerate(action_items):
            # 将行动项对象转换为描述性文本
            action_text = f"行动项:{item.get('task', '')}。负责人:{item.get('assignee', '未指定')}。截止时间:{item.get('deadline', '未明确')}。"
            action_id = f"{meeting_id}_action_{j}"
            documents_to_store.append(action_text)
            metadatas_to_store.append({"type": "action_item", "meeting_id": meeting_id, "task": item.get('task', '')[:50]})
            ids_to_store.append(action_id)

        # 2. 生成嵌入向量并存入集合
        if documents_to_store:
            # 使用本地嵌入模型生成向量
            embeddings = self.embed_model.encode(documents_to_store).tolist()
            self.collection.add(
                embeddings=embeddings,
                documents=documents_to_store,
                metadatas=metadatas_to_store,
                ids=ids_to_store
            )
            print(f"会议 '{meeting_id}' 的记忆已存入,共 {len(documents_to_store)} 个文档块。")

    def query(self, question, n_results=5, filter_meeting_id=None):
        """
        向记忆库提问。
        :param question: 自然语言问题。
        :param n_results: 返回最相关的文档数量。
        :param filter_meeting_id: 可选,仅搜索特定会议的记录。
        :return: 相关文档列表和它们的元数据。
        """
        # 将问题也转化为向量
        query_embedding = self.embed_model.encode([question]).tolist()[0]

        # 构建查询过滤器
        where_filter = None
        if filter_meeting_id:
            where_filter = {"meeting_id": filter_meeting_id}

        # 执行相似度搜索
        results = self.collection.query(
            query_embeddings=[query_embedding],
            n_results=n_results,
            where=where_filter,
            include=["documents", "metadatas", "distances"]
        )
        return results

# 使用示例:构建记忆库
if __name__ == "__main__":
    memory = MeetingMemory()

    # 假设我们已经有了分析结果
    meeting_id = "20240520_产品需求评审"
    with open("meeting_transcript.txt", "r", encoding="utf-8") as f:
        transcript = f.read()
    with open("meeting_structured_output.json", "r", encoding="utf-8") as f:
        import json
        structured_data = json.load(f)

    # 将会议记忆存入数据库
    memory.add_meeting(
        meeting_id=meeting_id,
        transcript_text=transcript,
        summary=structured_data["summary"],
        action_items=structured_data["action_items"]
    )

    # 进行查询
    print("\n=== 记忆库查询测试 ===")
    query_results = memory.query("我们讨论了关于用户登录功能的哪些问题?", n_results=3)
    print(f"查询到 {len(query_results['documents'][0])} 个相关片段:")
    for i, (doc, meta, dist) in enumerate(zip(query_results['documents'][0], query_results['metadatas'][0], query_results['distances'][0])):
        print(f"\n--- 结果 {i+1} (距离:{dist:.4f}) ---")
        print(f"类型:{meta['type']}")
        print(f"内容:{doc[:200]}...") # 预览前200字符

深度解析与优化策略

  1. 分块的艺术 :直接存入整篇转录文本,检索时可能会返回不相关的长文。分块(Chunking)是向量检索系统的核心技巧。 chunk_size 不宜过大(通常200-1000字符),以保证检索精度; chunk_overlap (如50-200字符)能防止完整的句子被切断,保留上下文。更高级的分块策略可以按语义(如段落)或句子边界进行。
  2. 元数据的力量 :注意我们在存储时为每个文档块附加了 metadata ,如 type (是转录片段、摘要还是行动项)和 meeting_id 。这允许我们在查询时进行过滤,例如:“只搜索行动项”或“只搜索某次特定会议的内容”。这极大地提升了查询的精准度。
  3. 嵌入模型的选择 BAAI/bge-small-zh-v1.5 是针对中文优化的开源模型,在中文语义相似度任务上表现优异,且完全本地运行,无隐私顾虑。如果你的会议主要是英文,可以考虑 all-MiniLM-L6-v2 等模型。 关键点:用于生成存储向量的模型(嵌入模型)和用于生成查询向量的模型必须是同一个!
  4. 相似度度量 :ChromaDB默认使用余弦相似度( cosine ),这对于文本语义相似度是合适的。距离值越小,表示越相似。

3.4 打造自然语言问答接口

最后一步,我们将向量检索和LLM的能力结合起来,创造一个能理解问题、查找资料并组织答案的智能问答接口。这被称为“检索增强生成”(RAG)。

class MeetingQABot:
    def __init__(self, memory: MeetingMemory, analyzer: MeetingAnalyzer):
        """
        初始化问答机器人。
        :param memory: 已初始化的MeetingMemory实例。
        :param analyzer: 已初始化的MeetingAnalyzer实例(用于最终答案生成)。
        """
        self.memory = memory
        self.analyzer = analyzer

    def ask(self, question, meeting_id=None, use_llm_for_answer=True):
        """
        向会议记忆提问。
        :param question: 自然语言问题。
        :param meeting_id: 可选,限定在特定会议中搜索。
        :param use_llm_for_answer: 是否使用LLM基于检索结果生成答案。若为False,则直接返回检索到的文本片段。
        :return: 答案字符串和相关来源片段。
        """
        # 1. 从记忆库中检索相关上下文
        search_results = self.memory.query(question, n_results=5, filter_meeting_id=meeting_id)
        retrieved_docs = search_results['documents'][0]
        retrieved_metas = search_results['metadatas'][0]

        if not retrieved_docs:
            return "抱歉,在会议记录中没有找到相关信息。", []

        # 2. 如果不需要LLM加工,直接返回最相关的片段
        if not use_llm_for_answer:
            most_relevant = retrieved_docs[0]
            return f"根据会议记录,相关信息如下:\n\n{most_relevant}", list(zip(retrieved_docs, retrieved_metas))

        # 3. 使用LLM基于检索到的上下文生成一个连贯、准确的答案
        context_str = ""
        for i, (doc, meta) in enumerate(zip(retrieved_docs, retrieved_metas)):
            context_str += f"[来源{i+1}: 类型-{meta['type']}] {doc}\n\n"

        prompt = f"""
        你是一个专业的会议助理。请根据以下提供的会议记录片段,回答用户的问题。
        你的回答必须严格基于提供的上下文。如果上下文信息不足以回答问题,请如实说明“根据现有会议记录,无法确定”。
        请用清晰、简洁的中文给出答案。

        用户问题:{question}

        相关会议记录上下文:
        {context_str}

        请开始你的回答:
        """
        answer = self.analyzer._call_llm(
            prompt,
            system_message="你是一个严谨的会议信息查询助手,你的回答必须基于给定的事实,不 extrapolate。"
        )

        return answer, list(zip(retrieved_docs, retrieved_metas))

# 使用示例:集成与问答
if __name__ == "__main__":
    # 初始化各个组件
    memory = MeetingMemory()
    analyzer = MeetingAnalyzer(llm_type="openai", model_name="gpt-3.5-turbo") # 问答可以用更强的模型,如gpt-4
    bot = MeetingQABot(memory, analyzer)

    # 模拟用户提问
    questions = [
        "上次会议决定下一步要做什么?",
        "关于预算超支的问题,大家是怎么讨论的?",
        "张三负责的任务是什么?"
    ]

    for q in questions:
        print(f"\n用户提问:{q}")
        answer, sources = bot.ask(q, meeting_id="20240520_产品需求评审")
        print(f"助理回答:{answer}")
        print("--- 参考来源(前2个)---")
        for doc, meta in sources[:2]:
            print(f"  [{meta['type']}] {doc[:150]}...")
        print("-" * 50)

RAG流程的精髓

  1. 检索(Retrieve) :将用户问题向量化,在向量数据库中查找语义最相关的文本片段。这步解决了LLM“不知道”内部知识的问题。
  2. 增强(Augment) :将检索到的相关片段作为额外的上下文,和用户问题一起构建成一个新的Prompt。
  3. 生成(Generate) :LLM基于这个“增强后”的Prompt来生成答案。由于答案的依据来自检索到的真实会议记录,其准确性和可信度远高于LLM凭空编造。

注意事项

  • 幻觉问题 :即使提供了上下文,LLM有时仍会“捏造”事实。在Prompt中强调“严格基于上下文”和“无法确定时请说明”至关重要。对于关键信息,可以设计让系统直接引用原文。
  • 上下文长度 :检索到的多个片段加起来可能很长,需注意不要超过LLM的上下文窗口。需要设计策略,比如只取前3个最相关的片段,或者对检索到的内容再进行一次摘要。
  • 多轮对话 :当前的实现是单轮的。要实现多轮对话(记住之前的问答历史),需要将历史对话也纳入到检索和Prompt构建中,复杂度会显著增加。

4. 集成与部署:打造可用产品

至此,核心功能模块都已就绪。但要让其成为一个真正的“助理”,我们需要一个友好的用户界面,并将其部署成可长期运行的服务。

4.1 使用Gradio快速构建Web界面

Gradio能让我们在半小时内搭出一个功能完整的演示界面。

import gradio as gr
from datetime import datetime

# 假设我们已经有了上面定义的 MeetingMemory, MeetingAnalyzer, MeetingQABot 类
# 这里我们创建一个集成的系统类
class MeetingAISystem:
    def __init__(self):
        self.memory = MeetingMemory()
        self.analyzer = MeetingAnalyzer(llm_type="ollama", model_name="qwen2.5:7b") # 示例用本地模型
        self.qa_bot = MeetingQABot(self.memory, self.analyzer)
        self.transcriber = AudioTranscriber(model_size="small") # 使用较小的模型以加快Web界面响应

    def process_meeting(self, audio_file, meeting_topic):
        """处理上传的会议音频,并存入记忆库"""
        if audio_file is None:
            return "请先上传会议录音文件。", None, None, None

        meeting_id = f"{datetime.now().strftime('%Y%m%d_%H%M')}_{meeting_topic}"
        try:
            # 1. 转写
            yield f"正在转写音频 '{meeting_topic}'...", None, None, None
            result = self.transcriber.transcribe(audio_file)
            transcript = self.transcriber.format_transcript(result)

            # 2. 分析
            yield f"音频转写完成,正在分析内容...", transcript, None, None
            summary = self.analyzer.generate_summary(transcript)
            actions = self.analyzer.extract_action_items(transcript)

            # 3. 存储
            yield f"分析完成,正在存入记忆库...", transcript, summary, actions
            self.memory.add_meeting(meeting_id, transcript, summary, actions)

            return f"会议 '{meeting_topic}' 处理完成!已存入记忆库。", transcript, summary, actions

        except Exception as e:
            return f"处理过程中出现错误:{str(e)}", None, None, None

    def ask_question(self, question, history=None):
        """回答用户关于会议的问题"""
        if not question.strip():
            return "请输入问题。", ""
        answer, sources = self.qa_bot.ask(question)
        # 将来源格式化为易读的字符串
        source_text = "\n\n--- 参考来源 ---\n"
        for i, (doc, meta) in enumerate(sources[:3]): # 显示前3个来源
            source_text += f"{i+1}. [{meta.get('type', 'N/A')}] {doc[:120]}...\n"
        return answer, source_text

# 创建系统实例
system = MeetingAISystem()

# 构建Gradio界面
with gr.Blocks(title="AI会议记忆助理", theme=gr.themes.Soft()) as demo:
    gr.Markdown("# 🎙️ AI会议记忆助理")
    gr.Markdown("上传会议录音,获得智能纪要、行动项,并随时向它提问。")

    with gr.Tab("📥 处理新会议"):
        with gr.Row():
            with gr.Column():
                audio_input = gr.Audio(label="上传会议录音", type="filepath")
                topic_input = gr.Textbox(label="会议主题(可选)", placeholder="例如:5月20日产品需求评审会")
                process_btn = gr.Button("开始处理会议", variant="primary")
            with gr.Column():
                status_output = gr.Textbox(label="处理状态", interactive=False)
                transcript_output = gr.Textbox(label="会议转录文本", lines=10, interactive=False)
        with gr.Row():
            summary_output = gr.Textbox(label="智能会议纪要", lines=6, interactive=False)
            actions_output = gr.JSON(label="提取的行动项", interactive=False)

        process_btn.click(
            fn=system.process_meeting,
            inputs=[audio_input, topic_input],
            outputs=[status_output, transcript_output, summary_output, actions_output]
        )

    with gr.Tab("❓ 问答记忆库"):
        with gr.Row():
            with gr.Column():
                question_input = gr.Textbox(label="输入你的问题", placeholder="例如:上次会议谁负责API接口开发?", lines=3)
                ask_btn = gr.Button("提问", variant="primary")
            with gr.Column():
                answer_output = gr.Textbox(label="助理回答", lines=6, interactive=False)
                sources_output = gr.Textbox(label="回答依据", lines=4, interactive=False)

        ask_btn.click(
            fn=system.ask_question,
            inputs=[question_input],
            outputs=[answer_output, sources_output]
        )

    gr.Markdown("---\n*提示:首次使用需要加载模型,处理时间可能较长。后续提问会很快。*")

# 启动界面
if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=7860, share=False) # share=True 可生成临时公网链接

这个界面提供了两个核心功能页签:“处理新会议”用于上传和分析录音;“问答记忆库”用于与历史会议对话。Gradio会自动处理文件上传、进度更新和结果展示。

4.2 部署与优化建议

一个在本地运行的脚本和一个可分享的Web服务是两回事。以下是让项目更“产品化”的建议:

  1. 持久化与数据库 :我们虽然用 ChromaDB 持久化了向量数据,但会议元数据(如会议主题、时间、参会人列表、原始音频文件路径等)最好再用一个关系型数据库(如SQLite或PostgreSQL)来管理,方便做列表展示和高级筛选。
  2. 异步处理 :处理长音频和调用LLM API可能是耗时的。在Web服务中,应该使用异步任务队列(如Celery + Redis),让文件上传后立即返回,处理在后台进行,完成后通过通知或刷新页面告知用户。
  3. 安全与权限 :如果部署到内网供团队使用,需要添加简单的用户认证和权限管理,确保会议记录只能被授权人员访问。
  4. 性能优化
    • 缓存 :对常见的查询结果进行缓存。
    • 嵌入模型量化 :使用量化的嵌入模型(如 BAAI/bge-small-zh-v1.5 本身就有量化版)可以大幅减少内存占用和提高推理速度。
    • LLM API超时与重试 :网络调用需设置合理的超时和重试机制。
  5. 扩展功能
    • 多模态输入 :除了音频,支持直接上传会议录像,从中提取音频轨进行处理。
    • 集成日历 :与Google Calendar或Outlook日历集成,自动抓取会议链接并录制。
    • 实时字幕 :将转写模块稍加修改,可以做成实时字幕工具,用于线上会议辅助。
    • 情感分析 :在分析阶段加入对发言情绪(积极、消极、中性)的判断,帮助回顾会议氛围。

5. 常见问题与排查实录

在实际开发和运行中,你肯定会遇到各种各样的问题。这里记录了一些典型问题及其解决方法。

5.1 音频处理相关问题

问题1:Whisper转写中文准确率不高,特别是专业术语和人名。

  • 排查 :首先检查音频质量。背景噪音、多人同时说话、说话人距离麦克风远都会严重影响准确率。
  • 解决
    • 预处理 :务必进行降噪和音量归一化。可以尝试更专业的降噪工具如 noisereduce 库。
    • 选择更大模型 :从 base 升级到 small medium 模型,精度提升显著。
    • 提供提示词 :Whisper的 transcribe 方法支持 initial_prompt 参数。你可以传入一段包含本次会议可能出现的专业术语、参会人姓名的文本,能有效引导模型。
    result = model.transcribe(audio_path, language="zh", initial_prompt="本次会议涉及机器学习、神经网络、Transformer等术语。参会人有张三、李四、王五。")
    

问题2:处理长音频时内存溢出(OOM)。

  • 解决 :必须进行音频分割。
    import whisper
    from pydub import AudioSegment
    import tempfile
    import os
    
    def transcribe_long_audio(model, audio_path, chunk_length_ms=20*60*1000): # 20分钟
        audio = AudioSegment.from_file(audio_path)
        chunks = [audio[i:i+chunk_length_ms] for i in range(0, len(audio), chunk_length_ms)]
        full_text = ""
        for i, chunk in enumerate(chunks):
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
                chunk.export(tmp.name, format="wav")
                result = model.transcribe(tmp.name, language="zh")
                full_text += result["text"] + " "
            os.unlink(tmp.name) # 删除临时文件
        return full_text
    

5.2 LLM调用与提示工程问题

问题3:LLM提取的行动项格式混乱,无法解析成JSON。

  • 排查 :小模型或指令不清晰时容易发生。
  • 解决
    • 强化Prompt :在Prompt中更明确地指定JSON格式,甚至给出更具体的例子(Few-shot Learning)。
    • 后处理 :使用更健壮的解析方法,比如先尝试用 json.loads() ,如果失败,再用正则表达式提取可能的结构,或者使用 ast.literal_eval() 作为备选。
    • 降级方案 :如果实在无法解析,可以要求LLM以纯文本的“1. 2. 3.”列表形式输出,然后在代码中按行解析。

问题4:使用本地Ollama模型时,响应速度非常慢。

  • 排查 :检查电脑资源(CPU/内存/GPU)占用。7B参数量的模型在无GPU的CPU上推理确实会慢。
  • 解决
    • 量化 :使用Ollama拉取量化版本的模型,如 qwen2.5:7b-q4_K_M ,能在几乎不损失精度的情况下大幅提升速度、降低内存。
    • GPU加速 :确保Ollama使用了GPU(如果可用)。在拉取模型时,Ollama通常会自动选择兼容的版本。可以通过 ollama run 观察日志确认。
    • 调整参数 :减少生成答案的 max_tokens ,对于摘要和提取任务,通常不需要很长的回复。

5.3 向量检索与问答问题

问题5:问答时,返回的答案与问题不相关,或者“幻觉”严重。

  • 排查
    1. 检索质量差 :问题“我们预算多少?”可能检索到的是“我们预算很紧张”的片段,而不是具体的数字。这可能是嵌入模型对数字不敏感,或者分块不当导致上下文丢失。
    2. LLM不遵从上下文 :即使检索到了正确片段,LLM也可能忽略它,自己编造。
  • 解决
    • 优化检索
      • 尝试不同的 chunk_size chunk_overlap
      • 对存储的文档块进行“丰富”,比如在存储时,不仅存文本,还在前面加上“会议主题:XXX,时间:XXX”等元信息。
      • 使用 混合搜索 :结合向量相似度搜索和关键词(BM25)搜索,ChromaDB支持此功能。
    • 优化Prompt :在给LLM的指令中,用更强烈的语气强调“必须”、“只能”基于上下文,并设计让LLM在答案中引用来源片段编号的机制。
    • 后处理验证 :让LLM在生成答案后,再判断一下答案中的关键事实是否能在提供的上下文中找到依据。

问题6:随着会议增多,向量数据库查询变慢。

  • 解决
    • 索引优化 :ChromaDB默认使用HNSW索引,对于百万级以下的数据量性能很好。确保在创建集合时使用了合适的距离函数。
    • 过滤 :积极使用 where 参数过滤 meeting_id type ,能极大缩小搜索范围。
    • 分集合存储 :可以为不同项目或不同时间段的会议创建不同的集合(Collection)。
    • 硬件 :向量搜索是计算密集型操作,更快的CPU/GPU会有帮助。

5.4 部署与运行问题

问题7:Gradio界面在公网无法访问,或者运行一段时间后崩溃。

  • 解决
    • 端口与防火墙 :确保服务器防火墙开放了 7860 端口(Gradio默认)。启动时使用 demo.launch(server_name="0.0.0.0") 允许所有IP访问。
    • 无头服务器 :在服务器上运行时,可以添加 share=False 并配置Nginx反向代理,或者使用Gradio的 share=True 生成临时公网链接(有效期72小时)。
    • 内存泄漏 :长时间运行,尤其是处理大量音频时,可能会内存累积。确保音频处理完成后,及时释放大对象(如音频数据、模型临时变量)。考虑定期重启服务,或使用像 gunicorn 这样的WSGI服务器配合多进程。

问题8:如何将这个系统自动化,比如自动录制每周例会?

  • 思路 :这需要结合操作系统级的自动化工具。
    • 录制 :在会议电脑上使用自动化脚本(如Python的 pyautogui pynput )控制录音软件开始/结束,或者直接捕获系统音频输出(更复杂)。
    • 触发 :使用定时任务(如cron)或监听日历事件(通过API)来触发录制和分析流程。
    • 管道 :将本项目的核心代码封装成一个接收音频文件路径的函数,由自动化脚本在录制完成后调用。
    • 通知 :分析完成后,将纪要和行动项通过邮件或办公软件(如钉钉/飞书/Slack机器人)自动发送给参会人。

这个项目从概念到实现,涉及了现代AI应用的多个关键环节:语音处理、大语言模型提示工程、向量数据库检索、应用集成。每一步都有值得深挖的细节和优化的空间。我希望这份超详细的指南,不仅能让你成功复现一个AI会议记忆助理,更能为你打开一扇门,去探索如何将AI能力巧妙地融入日常工作流,解决那些真实而具体的效率痛点。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐