
写实际项目之前最烦的就是“万事俱备只欠环境”。上一篇文章我们把 LangGraph 语音助手的整体思路拆了一遍这篇就是正式动手前的第二道工序把开发环境、项目脚手架、目录结构和核心依赖全部敲定。我尽量按自己搭过多次实战项目的顺序来讲不光是列命令还会把每个选择的理由和踩过的坑一起说清楚。这篇适合两类人一类是刚看完 LangGraph 概念准备上手的新手另一类是已经写过一些 LangChain 脚本但没跑过完整语音助手项目的开发者。读完你会有整个项目的骨架地图后续往图形里塞节点就顺畅多了。1. 动手之前先把整体思路理顺1.1 语音助手不是“语音转文字大模型”的简单拼接很多第一次做语音助手的同学第一反应是“录音→转文字→丢给大模型→拿回文字→转语音”看起来确实是一个线性流程。但真实场景里没这么简单。用户可能在说话中间停顿、可能随口说了一句废话、可能上一句和下一句之间有依赖也可能某个问题需要先查天气再回答。如果只是单线程调用大模型所有交互都变成“一问一答”体验会非常生硬。语音助手本质上是一个带状态的对话系统它需要管理“当前这句话在对话流里处于什么位置”“用户是不是中途插话了”“这个意图需不需要调用工具”。这些需求落到工程上就是状态流转。状态流转如果自己写会是一堆 if-else 加全局变量第一版能跑加两个功能就炸。这就是我选择 LangGraph 的根本原因它让状态转移变成显式的、可维护的图结构。1.2 为什么选 LangGraph而不是自己写状态机自己写状态机不是不行但你要维护的东西比想象中多。对话上下文、重试逻辑、错误恢复、分支判断、最终结束条件每一块都是重复代码。LangGraph 的核心是 StateGraph它把一次对话的运行过程抽象成“节点”和“边”节点做事情边决定做完事情后下一步去哪里。节点函数的输入输出都是同一个 State天然适合承载多轮对话的上下文。另一个优势是可视化。我把节点和边定义好之后可以直接打印出图的结构开发和排查问题的时候非常直观。团队协作时新成员看一遍图基本就懂业务流转不用去啃几千行的状态管理代码。1.3 这个项目的完整工作流长什么样在这个系列里语音助手默认走这样一条链路麦克风采集音频 → 语音识别ASR得到文本 → 文本交给大模型节点处理 → 模型产生回复文本和可能的工具调用指令 → 文本转语音TTS输出 → 通过扬声器播放。整个过程被抽象成 LangGraph 的若干个节点顺序执行也可以根据条件跳转。比如检测到用户说“等等、别说了”就走打断分支而不是傻傻地继续播放当前的 TTS 结果。这篇作为系列第二篇核心任务就是把这个项目运行所需的环境、目录、依赖全部铺好并建立一个“最小可运行骨架”。后续每个节点ASR、LLM、TTS、中断处理都只是在这个骨架上做填充和深化。2. 环境准备版本选型、虚拟环境与依赖管理2.1 Python 版本选择与虚拟环境创建先说结论这个项目我用 Python 3.11这也是目前 LangGraph、faster-whisper、edge-tts 这一套组合兼容性最稳的选择。LangGraph 官方支持 3.9 到 3.12但 3.9 的 typing 特性用起来局促3.12 对部分音频库的 wheel 支持还不完整3.11 是中间的安全牌。如果你用的是 3.10 也没有问题只是部分依赖锁版本时要注意别锁到必须 3.11 才能装的版本。虚拟环境一定要建不要图省事直接装进系统 Python。我见过太多人把项目依赖和系统依赖混在一起最后连系统自带工具都被搞坏。创建方式很简单python3.11 -m venv .venv source .venv/bin/activateWindows 下激活命令是.venv\Scripts\activate。激活后命令行前面会出现(.venv)说明已经进入隔离环境。这一步是后面所有操作的基础省了这一步后面装一万个包都要还债。2.2 依赖管理我为什么从 pip 换到 uv早期我习惯用pip install加requirements.txt后来切换到 uv体验明显更好。uv 的底层是 Rust 实现的解析依赖速度比 pip 快一个量级而且它的依赖锁文件做得更严格不会出现在 A 机器能跑、B 机器跑不起来的情况。如果你的机器上还没有 uv安装它只需要一条命令pip install uv然后初始化项目并添加依赖uv init uv add langgraph langchain-core faster-whisper edge-tts sounddevice python-dotenvuv 会自动把依赖写入pyproject.toml并生成uv.lock锁文件。以后别人拿到项目只需要执行uv sync就能复现同样的环境。如果你暂时不想引入 uv用传统的 pip 方式也是可以的只是记得一定要生成requirements.txt并记录准确的版本号。2.3 核心依赖清单与安装顺序这个项目的依赖清单我整理成了一张表后面逐个解释用途包名版本建议用途langgraph最新稳定版对话流程编排与状态管理langchain-core随 langgraph 自动安装提供消息模型、链式调用基础抽象faster-whisper最新稳定版本地语音识别速度快且占用小edge-tts最新稳定版文本转语音不需要申请额外密钥sounddevice最新稳定版麦克风录音与音频播放python-dotenv最新稳定版加载 .env 环境变量安装顺序不用特意分开一条命令全部装完即可。但有一个细节要注意不要手动锁定 langchain-core 的旧版本除非你明确知道某个接口从哪个版本开始变了。langgraph 对 langchain-core 的版本有约束让它自动解析最安全。如果你在国内网络环境可以把 pip 源切到常用镜像源安装速度快很多。装 faster-whisper 时它依赖 ctranslate2这个包比较大耐心等待即可。3. 项目脚手架目录、配置与模块边界3.1 目录结构怎么分才不丑项目结构在别人眼里是“风格问题”在后续维护的自己眼里就是“生存问题”。我见过把所有代码都堆在一个 main.py 里的项目三千行文件改起来和下刀山一样。这个语音助手项目我采用下面的划分方式voice-assistant/ ├── .env.example # 环境变量模板 ├── .gitignore ├── README.md ├── pyproject.toml ├── app/ │ ├── __init__.py │ ├── main.py # 程序入口负责编排 │ ├── config.py # 集中读取配置 │ ├── graph.py # LangGraph 图定义 │ ├── nodes/ │ │ ├── __init__.py │ │ ├── asr.py # 语音识别节点 │ │ ├── llm.py # 大模型调用节点 │ │ └── tts.py # 语音合成节点 │ ├── audio/ │ │ ├── __init__.py │ │ ├── recorder.py # 麦克风录音 │ │ └── player.py # 音频播放 │ └── utils/ │ ├── __init__.py │ └── logger.py # 统一日志 ├── data/ │ ├── audio/ # 临时音频文件 │ └── cache/ # 模型缓存与临时文件 └── tests/ └── test_graph.py # 图结构的冒烟测试这个结构的原则是“按职责分目录”。nodes 目录放所有状态转移的节点函数audio 目录放跟语音硬件打交道的逻辑config 单独拆出来是因为配置读写几乎每个模块都会用。data 目录虽然现在是空壳但后面跑 ASR 会产生临时 wav 文件、跑 TTS 会产生临时 mp3 文件提前占位能避免后面到处乱放临时文件。3.2 配置管理.env 与密钥安全语音助手需要配置的东西不少大模型接口地址、API Key、模型名称、ASR 用哪个规格模型、TTS 用哪个语音角色。这些内容不要硬编码在代码里也不要提交进 Git 仓库。标准做法是放在.env文件里然后程序运行时用 python-dotenv 加载。.env.example是给团队协作用的模板里面不要写真实密钥# 大模型接口 MODEL_BASE_URLhttp://127.0.0.1:8000/v1 MODEL_API_KEYsk-your-key MODEL_NAMEyour-model-name # 语音识别 ASR_MODEL_SIZEbase ASR_DEVICEcpu ASR_COMPUTE_TYPEint8 # 语音合成 TTS_VOICEzh-CN-XiaoxiaoNeural.gitignore里一定要加一行.env否则真实密钥会被提交出去。我见过一次把密钥发布到公开仓库的事故轻则被薅流量重则账号被封这个坑不要踩。对应的 config.py 写起来非常简洁import os from dotenv import load_dotenv load_dotenv() MODEL_BASE_URL os.getenv(MODEL_BASE_URL, http://127.0.0.1:8000/v1) MODEL_API_KEY os.getenv(MODEL_API_KEY, ) MODEL_NAME os.getenv(MODEL_NAME, ) ASR_MODEL_SIZE os.getenv(ASR_MODEL_SIZE, base) ASR_DEVICE os.getenv(ASR_DEVICE, cpu) ASR_COMPUTE_TYPE os.getenv(ASR_COMPUTE_TYPE, int8) TTS_VOICE os.getenv(TTS_VOICE, zh-CN-XiaoxiaoNeural)用 os.getenv 的好处是缺失配置时可以给一个默认值本地调试时不容易因为遗漏配置而直接崩掉。3.3 模块边界哪些代码该放哪模块边界的原则就一句话每个文件只做一类事情。语音识别节点不要自己去读麦克风它只负责“拿到音频路径输出识别文本”。录音模块不要关心后续是进了大模型还是直接丢弃。在整个图里每个节点是纯粹的函数输入是 State输出是 State 的增量。这样设计的好处是既可以在完整语音链路里跑也可以单独写测试脚本验证某个节点。我用 tests/test_graph.py 做图结构的冒烟测试不依赖真实麦克风和大模型接口只验证节点连得对不对。4. 核心依赖逐个讲清楚为什么要选这几个4.1 LangGraph 与 LangChain 生态LangGraph 自带的 StateGraph 提供了状态对象和节点调度能力。我把它的核心概念拎出来讲一下State 就是一个 TypedDict每个节点接收 State 并返回部分更新的字典add_node 注册一个节点add_edge 把两个节点连起来set_entry_point 指定入口节点还有条件边可以按返回值跳到不同节点。整个流程非常像一张流程图写起来和读起来都很直观。很多人问为什么不直接用 LangChain 的 AgentExecutor原因在于 AgentExecutor 是一套预设好的循环逻辑定制性有限。LangGraph 是更底层的编排工具你可以自由决定“识别完成后如果文本为空就返回等待状态而不是强行走大模型”。这种灵活度是做语音助手必备的因为语音场景里确实会出现“用户说了半句就停了”这类非正常分支。4.2 语音识别faster-whisper 的几个关键参数语音识别我选择 faster-whisper而不是原版 Whisper。原版 Whisper 用的是 Python 逐层调用 PyTorch处理一句话可能都要等一两秒在语音助手的对话节奏里太慢。faster-whisper 基于 CTranslate2 实现CPU 上跑 small 模型基本能做到接近实时而且显存占用小很多。三个关键参数要解释一下。第一个是模型规格tiny、base、small、medium、large-v3几档。纯中文对话我建议从base起步识别准确率够用速度很快环境嘈杂的场景再上small。第二个是设备参数devicecpu或devicecuda没有独立显卡就老老实实用 CPU。第三个是精度参数compute_typeCPU 上用int8显存有限的情况下用int8_float16同样能压缩显存占用。加载模型和转写的最小代码长这样from faster_whisper import WhisperModel model WhisperModel(base, devicecpu, compute_typeint8) segments, info model.transcribe(sample.wav, languagezh) text .join(seg.text for seg in segments) print(text)如果模型下载总是超时可以直接把模型文件手动放到用户目录下的.cache/huggingface缓存里再把 transcribe 前加一行local_files_only的标志让它不再访问网络。4.3 语音合成edge-tts 与音频播放TTS 我选择 edge-tts理由很现实它免费、支持中文语音质量不错、不需要单独申请密钥。它调用的是微软 Edge 浏览器内置的在线合成接口使用方式是命令行或 Python 异步接口。命令行试效果是最快的edge-tts --text 你好我是你的语音助手。 --voice zh-CN-XiaoxiaoNeural --write-media output.mp3支持的中文字色号很多比如zh-CN-XiaoxiaoNeural是女声zh-CN-YunxiNeural是男声我项目里默认用前者。合成结果是 MP3 格式但 sounddevice 播放时需要的是原始音频流所以后续在 TTS 节点里要把 MP3 解码成 wav 或者用 pydub 转一下格式这一步等到做 TTS 节点时再展开。音频播放这一层我选 sounddevice 而不是 pygame。pygame 虽然也能播放音频但它是一个游戏库依赖一堆图形环境的东西在服务器上安装很容易踩坑。sounddevice 是精简的音频 IO 库接口简单跨平台表现也稳。4.4 大模型接口兼容哪套协议这一块很多人会模糊到底应该接哪家大模型我的建议是接通用大模型协议。现在绝大多数模型服务商都支持/v1/chat/completions这种格式你用配置项注入了 base_url、api_key 和 model_name就能在多家服务之间无缝切换。本地部署的模型服务也走同一套接口格式所以开发时可以先连本地服务联调通过后再切换到云端服务。接入大模型我用 langchain-core 提供的基础封装它会把消息格式、返回内容解析都处理好不需要自己手动拼 HTTP 请求。等写到 LLM 节点那一篇我们会用BaseChatModel统一封装避免代码跟某一家厂商深度绑定。5. 实操从零搭出最小可运行项目骨架5.1 创建项目与虚拟环境我在这里给出完整的操作序列你可以直接照做。假设你的 Python 3.11 已经装好并且有 uv。mkdir voice-assistant cd voice-assistant uv init python3.11 -m venv .venv source .venv/bin/activate如果你用 uv 初始化项目它会生成一个基础的pyproject.toml。我习惯保留这个文件因为后续可以配置项目元数据和 dev 依赖。然后统一用 uv 添加依赖uv add langgraph langchain-core faster-whisper edge-tts sounddevice python-dotenv5.2 安装依赖并验证版本安装完成后验证一下关键包能不能正常导入python -c import langgraph; print(langgraph.__version__) python -c import faster_whisper; print(faster_whisper.__version__) python -c import edge_tts; print(edge_tts.__version__)如果你能看到版本号输出说明核心依赖已经就绪。如果 import 阶段就报错别急着往下写代码先排查依赖问题。这是最典型的“环境没搭好后面全崩”的排查节点。5.3 写一个最简 LangGraph 工作流做冒烟测试环境就绪后我建议先写一个不依赖真实语音设备和大模型的最简工作流验证 LangGraph 本身跑得通。文件放在app/graph.pyfrom typing import TypedDict from langgraph.graph import StateGraph, END class AgentState(TypedDict): input_text: str asr_text: str final_text: str def asr_node(state: AgentState) - dict: # 冒烟测试阶段直接返回输入后续替换为真实识别 return {asr_text: state[input_text]} def llm_node(state: AgentState) - dict: # 冒烟测试阶段直接把文本转大写验证状态传递 return {final_text: state[asr_text].upper()} def build_graph(): graph StateGraph(AgentState) graph.add_node(asr, asr_node) graph.add_node(llm, llm_node) graph.set_entry_point(asr) graph.add_edge(asr, llm) graph.add_edge(llm, END) return graph.compile() if __name__ __main__: app build_graph() result app.invoke({input_text: hello langgraph, asr_text: , final_text: }) print(result)跑一下python app/graph.py如果输出里的final_text是HELLO LANGGRAPH说明图能正常编译、节点能正常执行、状态能正常传递。这是整个项目后续所有功能的地基。到这里暂时不需要引入任何音频设备先不要让“能不能跑通”和“有没有麦克风”绑在一起。等这块稳定了再逐步把真实节点替换进去。5.4 分别验证 ASR 与 TTS 链路LangGraph 骨架跑通之后单独验证两个语音链路。先准备一个测试音频文件test.wav内容随便说一句中文。然后运行from faster_whisper import WhisperModel model WhisperModel(base, devicecpu, compute_typeint8) segments, info model.transcribe(test.wav, languagezh) print(.join(s.text for s in segments))如果识别出来的是乱码大概率是 wav 的采样率或声道数不符合模型预期。Whisper 一般期望 16kHz 单声道音频建议先用工具转格式再测试。TTS 链路用命令行验证mkdir -p data/audio edge-tts --text 环境验证通过 --voice zh-CN-XiaoxiaoNeural --write-media data/audio/test.mp3如果文件生成成功且能正常播放说明 TTS 服务可用。这一步不依赖 LangGraph先把每个外部能力单独验证稳妥再接入图里排查问题时就不用猜是哪个环节坏了。6. 避坑实录环境搭建阶段最常见的 6 个问题6.1 依赖版本冲突langchain-core 别手动锁旧版本别手动安装旧版 langchain-core 去“适配”某个接口langgraph 和 langchain 生态更新频繁旧版本接口很可能不兼容新图的内部调用。正确做法是让包管理器解析出了问题再定位具体错误信息。用 uv 管理时它会自动锁一个兼容集合遇到冲突会提前报错不会到运行时才炸。6.2 麦克风没声音或音频设备选不对sounddevice 默认设备有时不是你想要的麦克风。可以先用下面的代码列出所有设备看清输入设备的 indexpython -m sounddevice如果默认设备不对可以在代码里显式指定设备编号或名称。Linux 系统上还需要确认音频服务是否正常ALSA、PulseAudio 裸奔很容易出现“设备存在但打不开”的情况Windows 上则要注意麦克风权限是否打开开发时经常在系统设置里授权后才有输入流。6.3 大模型接口连不上这个错误我遇到过好几种形态base_url 写错导致 404api_key 没填或填错导致 401跨域或 SSL 证书问题导致请求超时。排查步骤就按顺序来先用 curl 直接发一个最简单的对话请求确认接口本身没问题然后再回来查代码里的配置读取。curl 通了但代码里不通基本就是环境变量没加载进进程检查一下是不是忘了调load_dotenv()。6.4 环境变量不生效如果你在.env里改了配置但程序读到的还是旧值多半是进程没重启。python-dotenv 只在进程启动时读取一次改完文件要重启开发进程。还有一个容易被忽略的问题.env文件里不要写引号MODEL_NAMEabc这种写法会把引号带进值里接口调用时可能因为意外的引号而失败。6.5 显存不够怎么跑 Whisper没有独显或者显存只有 2G 的机器不要去加载 medium 以上的模型。实际测试中base 模型在 CPU 上跑中文短句大概零点几秒到一秒完全可以接受。如果还是慢就考虑tiny模型或者把 audio 片段先做降噪和归一化减少无效内容的识别开销。另外compute_type一定要设置成 int8默认 float32 在 CPU 上不仅慢还吃内存。6.6 uv 和 pip 混用导致的虚拟环境错乱这是很隐蔽的坑。如果你用 uv 创建虚拟环境又手动用 pip 往同一个环境里装包有概率出现“两边各写各的”的状态最终 import 到的包和uv sync声明不一致。建议统一用 uv 管理依赖不要混用。实在要跑某些老脚本的 pip 命令先确认当前环境是哪个用which python看一下路径别稀里糊涂装错环境。我之前在搭第一个语言助手雏形的时候就是因为环境没有隔离把系统自带的 Python 环境弄得乱七八糟重装了两轮才彻底干净。后来统一用 uv重来一遍也就一分钟的事。脚手架这部分看起来不刺激但它决定你后面写节点的时候是“一路畅通”还是“处处踩雷”。这篇的环境和骨架稳定之后下一篇就可以开始写真正的语音识别节点把麦克风里的声音变成 LangGraph 状态流里的文本到时候我们接着聊。