上一章使用 Whisper 把语音转换成文字,再交给 Qwen3 回答。这个过程可以实现语音输入,但 Qwen3 实际接收到的仍然只有文字。
如果还希望程序理解图片,就需要增加真正的多模态模型。本文在 M1 Max 上部署 mlx-community/gemma-3n-E2B-it-4bit,让 Gemma 3n 负责理解图片和音频,再由已经部署好的 Qwen3 负责最终回答和多轮对话。
这里的“数字人”指可以通过文字、图片和音频交流的聊天角色。本文不包含虚拟形象驱动、口型同步和语音合成。
1. 为什么需要两个模型
项目根目录的 model 指向 Qwen3-14B 文本模型。它没有视觉编码器和音频编码器,因此只能处理文本令牌。
把图片路径直接写进消息:
/Users/.../photo.png
不会让 Qwen3 看到图片内容。它只能看到一段文件名字符串。
Gemma 3n 可以处理图片和音频,但本次实测发现,它更适合承担媒体理解工作;Qwen3 的中文回答和多轮上下文更稳定。因此本文采用明确的职责划分:
- Gemma 3n:把当前图片或音频转换成文字信息。
- Qwen3:读取媒体识别结果和聊天历史,生成最终回答。
- SQLite:保存用户消息、媒体路径、识别结果和模型回答。
- Gradio:提供文字、图片、录音和文件上传界面。
2. 完整数据流

图中将一轮请求分成“当前媒体理解”和“文本会话处理”两个阶段。图片与音频需要先变成文字信息,Qwen3 才能把这些信息与历史消息一起处理。
一轮请求会经过以下步骤:
- Gradio 接收文字、图片或音频,并从 gr.State 取得当前页面的 session_id。
- 图片和音频先复制到 data/uploads/
/,避免依赖 Gradio 临时文件。 - RunnableLambda 把本轮新媒体交给 Gemma 3n,得到文字形式的识别结果。
- 程序把用户文字、识别结果和受控文件路径组合成当前 HumanMessage。
- SQLite 按 session_id 读取最近 10 条历史消息,Qwen3 根据历史和当前消息生成回答。
- 程序把当前 HumanMessage 与最终 AIMessage 写回 SQLite,并更新 Gradio 聊天窗口。
纯文字请求会跳过第 2、3 步,直接进入文本会话处理。数据库只保存文字和文件路径,不保存体积较大的 Base64 数据。
3. 创建独立环境
多模态阶段同时需要 MLX-VLM、PyTorch、Gradio 和 LangChain。为了不改变前面已经验证的主环境,本文使用独立的 .venv_vlm:
cd ~/Documents/git/blog/source/_posts/llm_learning
python3.12 \
-m venv \
--prompt llm_learning_vlm \
.venv_vlm
.venv_vlm/bin/python -m pip install -U pip
.venv_vlm/bin/python -m pip install \
-r langchain/p09_multimodal_digital_human/requirements-server.txt
.venv_vlm/bin/python -m pip check
核心直接依赖固定为:
mlx-vlm==0.3.9
mlx==0.29.3
mlx-lm==0.28.3
transformers==4.57.1
starlette==0.50.0
torch==2.9.1
numpy==2.2.6
其中 torch==2.9.1 用于 Gemma 3n 音频处理器的张量转换。模型推理仍由 MLX 在 Apple Silicon 上执行。
4. 下载 Gemma 3n
模型保存在博客仓库外:
~/Documents/git/llm/gemma3n/model
下载代码固定了模型 revision,避免模型仓库后续更新改变文章结果。
# 这个文件用于下载支持文字、图片和音频的 Gemma 3n MLX 模型。
# 模型保存在博客仓库外,避免把数 GB 的权重文件放进项目目录。
import os
from pathlib import Path
from modelscope import snapshot_download
model_dir = Path.home() / "Documents/git/llm/gemma3n/model"
model_dir.mkdir(parents=True, exist_ok=True)
# ModelScope 可以直接访问,避免数 GB 权重经过系统 VPN 后降速或断流。
os.environ.setdefault("NO_PROXY", "*")
os.environ.setdefault("no_proxy", "*")
downloaded_path = snapshot_download(
model_id="mlx-community/gemma-3n-E2B-it-4bit",
revision="8a6e532400c0385b14908b963b728f593fa476f2",
local_dir=str(model_dir),
)
print("多模态模型保存目录:", downloaded_path)
运行:
.venv_vlm/bin/python \
langchain/p09_multimodal_digital_human/01_download_multimodal_model.py
模型目录实测约为 4.2G。下载完成后,在项目根目录建立统一入口:
ln -s \
~/Documents/git/llm/gemma3n/model \
multimodal_model
后续代码只使用 multimodal_model,不依赖个人绝对路径。
5. 启动 Qwen3 文本服务
Gemma 3n 由 Gradio 进程通过 Python API 直接加载。Qwen3 继续使用 LangChain 系列 3 中已经验证的 OpenAI 兼容服务。
在一个终端执行:
cd ~/Documents/git/blog/source/_posts/llm_learning
source .venv/bin/activate
"$VIRTUAL_ENV/bin/python" -m mlx_lm server \
--model model \
--host 127.0.0.1 \
--port 18080 \
--chat-template-args '{"enable_thinking": false}'
另开终端检查端口:
curl --noproxy '*' http://127.0.0.1:18080/v1/models
mlx-lm==0.28.3 的 data 可能为空,因此最终仍要以聊天请求能否返回内容为准。
6. 使用原生 API 测试图片
图片测试脚本会创建一张白底图片:左边是蓝色正方形,右边是黄色圆形。这样可以直接判断模型是否真的读取了图像。
# 这个文件创建一张简单测试图片,并使用 MLX-VLM 原生 Python API 描述图片。
from pathlib import Path
from PIL import Image, ImageDraw
from mlx_vlm import generate, load
from mlx_vlm.prompt_utils import apply_chat_template
project_root = Path(__file__).resolve().parents[2]
image_path = project_root / "data" / "multimodal_sample.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
model_path = project_root / "multimodal_model"
# 创建内容确定的测试图片,便于判断模型是否真的读取了图像。
image = Image.new("RGB", (640, 360), "white")
draw = ImageDraw.Draw(image)
draw.rectangle((70, 80, 270, 280), fill="royalblue")
draw.ellipse((370, 80, 570, 280), fill="gold")
image.save(image_path)
# 加载本地模型,并根据图片数量生成 Gemma 3n 需要的聊天提示词。
model, processor = load(str(model_path))
prompt = apply_chat_template(
processor,
model.config,
"请分别回答:左边图形是什么颜色和形状?右边图形是什么颜色和形状?",
num_images=1,
)
# image 接收本地图片路径列表,返回值的 text 字段是模型回答。
result = generate(
model,
processor,
prompt,
image=[str(image_path)],
temperature=0,
max_tokens=128,
verbose=False,
)
print("测试图片:", image_path)
print("模型回答:", result.text)
运行:
.venv_vlm/bin/python \
langchain/p09_multimodal_digital_human/02_test_image_input.py
实测回答的关键部分为:
测试图片: .../llm_learning/data/multimodal_sample.png
模型回答:
* 左边:有一个蓝色的正方形。
* 右边:有一个黄色的圆形。
颜色、形状和位置都与测试图片一致。
本次运行还会看到一条回退提示:
Using `use_fast=True` but `torchvision` is not available. Falling back to the slow image processor.
这条信息表示处理器没有使用 torchvision 的快速实现,已经自动切换到慢速图片处理器。它不是推理失败,是否成功仍应以脚本退出状态和模型回答为准。
7. 使用原生 API 测试音频
这一节不先调用 Whisper,而是把 WAV 文件直接交给 Gemma 3n。
# 这个文件使用 MLX-VLM 原生 Python API,把本地音频直接交给多模态模型理解。
# 与上一章的 Whisper 不同,这里不先把音频单独转换成文字。
from pathlib import Path
from mlx_vlm import generate, load
from mlx_vlm.prompt_utils import apply_chat_template
project_root = Path(__file__).resolve().parents[2]
audio_path = project_root / "data" / "sample_voice.wav"
model_path = project_root / "multimodal_model"
if not audio_path.exists():
raise FileNotFoundError(f"没有找到测试音频:{audio_path}")
# 音频输入需要在模板中声明 num_audios=1。
model, processor = load(str(model_path))
prompt = apply_chat_template(
processor,
model.config,
"请转述这段音频表达的内容。",
num_audios=1,
)
result = generate(
model,
processor,
prompt,
audio=[str(audio_path)],
temperature=0,
max_tokens=128,
verbose=False,
)
print("测试音频:", audio_path)
print("模型回答:", result.text)
运行:
.venv_vlm/bin/python \
langchain/p09_multimodal_digital_human/03_test_audio_input.py
真实输出:
测试音频: .../llm_learning/data/sample_voice.wav
模型回答: 你好,我叫小明,我今天想去 Hangzhou 西湖三元。
原音频内容是“你好,我叫小明,我今天想去杭州西湖散步”。模型识别出了主要语义,但把“杭州”写成英文,并把“散步”误识别成“三元”。这说明接口和音频输入已经打通,同时也说明多模态模型的音频识别结果并不一定比专用 Whisper 更准确。
8. 构建 Gradio 多模态聊天页面
页面启动时只加载一次 Gemma 3n。每一轮请求按以下顺序处理:
- 把 Gradio 临时文件复制到当前会话目录。
- 使用 Gemma 3n 理解本轮图片或音频。
- 把识别结果追加到本轮用户消息。
- 使用 Qwen3 结合最近 10 条消息生成最终回答。
- 将文字、文件路径、识别结果和回答写入 SQLite。
RunnableLambda 只包装媒体理解函数,没有抽取公共模型类:
def call_multimodal_model(values: dict) -> str:
prompt = apply_chat_template(
processor,
vlm_model.config,
values["prompt"],
num_images=len(values["images"]),
num_audios=len(values["audios"]),
)
result = generate(
vlm_model,
processor,
prompt,
image=values["images"] or None,
audio=values["audios"] or None,
temperature=0,
max_tokens=256,
verbose=False,
)
return result.text
multimodal_model = RunnableLambda(call_multimodal_model)
Qwen3 使用标准 LangChain ChatOpenAI:
text_model = ChatOpenAI(
base_url="http://127.0.0.1:18080/v1",
api_key="not-needed",
model="default_model",
temperature=0,
max_tokens=256,
http_client=httpx.Client(trust_env=False),
)
9. 为什么媒体理解与多轮回答要分开

mlx-vlm==0.3.9 的原生 generate() 可以正确处理单轮图片和单轮音频。实测中,如果连续把上一轮图片占位符和下一轮音频占位符交给 Gemma 3n,它容易混淆两种媒体上下文。
因此本文只让 Gemma 3n 处理当前轮的新媒体。识别结果被转换成普通文字后写入 SQLite,下一轮纯文字追问由 Qwen3 读取历史。
这里需要注意:SQLite 中虽然保存了图片或音频路径,但 Qwen3 不会因为看到路径就获得媒体内容。下一轮能够回答媒体相关问题,是因为历史消息中同时保存了 Gemma 3n 的文字识别结果。旧图片和旧音频不会在每次追问时重新发送给 Gemma 3n。
例如,音频识别结果写入历史后,再问:
我刚才的音频中提到了哪个城市?
真实回答:
您刚才的音频中提到的城市是杭州。
这个结果证明多轮记忆来自 Qwen3 和 SQLite,而不是把旧音频反复发送给多模态模型。
10. 完整 Gradio 代码
# 这个文件实现一个可接收文字、图片和音频的 Gradio 多模态聊天页面。
# MLX-VLM 在当前进程中只加载一次,RunnableLambda 负责统一调用模型。
from pathlib import Path
from shutil import copy2, rmtree
from uuid import uuid4
import gradio as gr
import httpx
from langchain_community.chat_message_histories import SQLChatMessageHistory
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableLambda
from langchain_openai import ChatOpenAI
from mlx_vlm import generate, load
from mlx_vlm.prompt_utils import apply_chat_template
project_root = Path(__file__).resolve().parents[2]
database_path = project_root / "data" / "multimodal_history.db"
upload_root = project_root / "data" / "uploads"
model_path = project_root / "multimodal_model"
database_path.parent.mkdir(parents=True, exist_ok=True)
connection = f"sqlite:///{database_path}"
# 页面启动时加载一次模型,后续每轮对话复用同一份模型和处理器。
vlm_model, processor = load(str(model_path))
# Qwen3 负责最终回答和多轮对话,Gemma 3n 只负责理解当前媒体。
text_model = ChatOpenAI(
base_url="http://127.0.0.1:18080/v1",
api_key="not-needed",
model="default_model",
temperature=0,
max_tokens=256,
http_client=httpx.Client(trust_env=False),
)
def get_session_history(session_id: str) -> SQLChatMessageHistory:
"""取得当前多模态会话的完整历史。"""
return SQLChatMessageHistory(session_id=session_id, connection=connection)
def save_upload(source: str, session_id: str, allowed_suffixes: set[str]) -> str:
"""把 Gradio 临时文件复制到当前会话的受控目录。"""
source_path = Path(source)
if source_path.suffix.lower() not in allowed_suffixes:
raise gr.Error(f"不支持的文件类型:{source_path.suffix}")
session_dir = upload_root / session_id
session_dir.mkdir(parents=True, exist_ok=True)
target = session_dir / f"{uuid4().hex}{source_path.suffix.lower()}"
copy2(source_path, target)
return str(target.resolve())
def call_multimodal_model(values: dict) -> str:
"""使用 Gemma 3n 把本轮图片和音频转换成文字信息。"""
prompt = apply_chat_template(
processor,
vlm_model.config,
values["prompt"],
num_images=len(values["images"]),
num_audios=len(values["audios"]),
)
result = generate(
vlm_model,
processor,
prompt,
image=values["images"] or None,
audio=values["audios"] or None,
temperature=0,
max_tokens=256,
verbose=False,
)
return result.text
multimodal_model = RunnableLambda(call_multimodal_model)
def chat(
text: str,
image_path: str | None,
audio_path: str | None,
ui_history: list[dict],
session_id: str,
):
"""组合本轮的文字、图片和音频,并保存完整聊天历史。"""
if not isinstance(session_id, str) or not session_id:
session_id = str(uuid4())
if not text.strip() and not image_path and not audio_path:
return ui_history, "", None, None, session_id
display_parts = []
images = []
audios = []
prompt_text = text.strip() or "请描述我发送的内容。"
if text.strip():
display_parts.append(text.strip())
if image_path:
saved_image = save_upload(
image_path,
session_id,
{".png", ".jpg", ".jpeg", ".webp"},
)
images.append(saved_image)
display_parts.append(f"[图片:{Path(saved_image).name}]")
if audio_path:
saved_audio = save_upload(
audio_path,
session_id,
{".wav", ".mp3", ".m4a", ".flac"},
)
audios.append(saved_audio)
display_parts.append(f"[音频:{Path(saved_audio).name}]")
history = get_session_history(session_id)
user_record = [prompt_text]
if images or audios:
media_result = multimodal_model.invoke(
{"prompt": prompt_text, "images": images, "audios": audios}
)
user_record.append(f"[多模态模型识别结果:{media_result}]")
if images:
user_record.append(f"[图片路径:{images[0]}]")
if audios:
user_record.append(f"[音频路径:{audios[0]}]")
# Qwen3 读取最近消息和本轮媒体识别结果,负责最终回答与多轮上下文。
user_message = HumanMessage(content="\n".join(user_record))
answer = text_model.invoke(history.messages[-10:] + [user_message]).content
# 数据库保存文件路径和识别文本,不保存大体积 Base64 数据。
history.add_message(user_message)
history.add_message(AIMessage(content=answer))
ui_history = ui_history + [
{"role": "user", "content": "\n".join(display_parts)},
{"role": "assistant", "content": answer},
]
return ui_history, "", None, None, session_id
def clear_chat(session_id: str):
"""清空当前会话的消息和已复制文件。"""
if not isinstance(session_id, str) or not session_id:
session_id = str(uuid4())
get_session_history(session_id).clear()
rmtree(upload_root / session_id, ignore_errors=True)
return [], "", None, None, session_id
with gr.Blocks(title="本地多模态聊天机器人") as demo:
gr.Markdown("## 本地多模态聊天机器人")
session_id = gr.State(lambda: str(uuid4()))
chatbot = gr.Chatbot(type="messages", height=440)
text = gr.Textbox(label="文字", placeholder="可以只发文字,也可以配合图片或音频")
with gr.Row():
image = gr.Image(type="filepath", label="图片")
audio = gr.Audio(sources=["microphone", "upload"], type="filepath", label="音频")
with gr.Row():
send_button = gr.Button("发送", variant="primary")
clear_button = gr.Button("清空")
send_button.click(
chat,
[text, image, audio, chatbot, session_id],
[chatbot, text, image, audio, session_id],
)
clear_button.click(
clear_chat,
session_id,
[chatbot, text, image, audio, session_id],
)
if __name__ == "__main__":
demo.launch(server_name="127.0.0.1", server_port=7862)
运行页面:
.venv_vlm/bin/python \
langchain/p09_multimodal_digital_human/04_gradio_multimodal_chatbot.py
访问:
http://127.0.0.1:7862
截图中的“收到”是一次真实的文字通路检查;图片和音频通路分别由第 6、7 节的原生测试验证。自动化测试浏览器没有麦克风设备,因此音频组件会显示 No microphone found,上传本地音频仍可正常使用。真实录音还需要浏览器授予麦克风权限。
11. 会话与文件如何保存
每个浏览器会话都由 gr.State 创建独立的 session_id。上传文件复制到:
data/uploads/<session_id>/
SQLite 数据库位于:
data/multimodal_history.db
数据库中的用户消息包含:
- 本轮文字。
- 图片或音频的本地路径。
- Gemma 3n 的媒体识别结果。
这样既能让 Qwen3 在后续对话中使用识别结果,也不会把大体积文件内容写入数据库。点击“清空”会删除当前会话消息和对应上传目录,不影响其他浏览器会话。
gr.State 只负责当前页面中的随机 UUID,不是登录身份或账户认证。刷新页面或重新打开页面可能得到新的 session_id,当前示例也不会自动把 SQLite 中的旧消息恢复到 Gradio 聊天窗口。如果需要长期绑定真实用户,应由登录系统提供稳定的用户或会话标识。
12. 常见问题
12.1 音频处理提示没有安装 PyTorch
Gemma 3n 的音频处理器需要把特征转换成 PyTorch 张量。如果环境中没有 PyTorch,会出现:
Unable to convert output to PyTorch tensors format, PyTorch is not installed.
本文在 .venv_vlm 中固定安装 torch==2.9.1。
12.2 为什么提示没有安装 torchvision
缺少 torchvision 时,图片处理器会回退到慢速实现并继续执行。本文的图片测试已经在该回退路径下正确识别颜色和形状,因此没有为了消除提示而改变已经锁定的依赖环境。
12.3 为什么不使用 mlx_vlm.server
本文固定的 mlx-vlm==0.3.9 自带服务使用 /chat/completions,没有后期版本的 /v1 前缀。更重要的是,该版本对 Gemma 3n 的聊天内容块和纯音频请求存在兼容问题。
原生 load() + apply_chat_template() + generate() 已经通过图片和音频实测,因此本文直接使用 Python API,不把后期服务行为提前写进文章。
12.4 模型回答与音频原文不完全一致
多模态模型会生成概率性回答,音频转写可能出现同音字、中英文混写和漏词。如果业务要求准确字幕,应继续使用上一章的 Whisper;如果需要模型同时理解声音内容和上下文,可以使用本章的多模态路径。
12.5 开启 VPN 后模型下载很慢
下载脚本在当前进程中设置了 NO_PROXY=*。这只影响本次 ModelScope 下载,不会永久修改系统代理。
12.6 为什么不把文件转成 Base64 存进数据库
Base64 会让数据库快速膨胀,也不利于查看和清理。本文保存受控文件路径,数据库只保存结构化消息和文字结果。
12.7 为什么 Qwen3 会说自己不能直接查看本地文件
这是两个模型职责分离后的正常现象。Qwen3 接收的是 Gemma 3n 的识别文本和文件路径,不会直接打开图片或音频。如果最终回答没有使用媒体内容,应先检查 media_result 是否已经写入当前 HumanMessage,而不是把本地路径直接当作多模态输入交给 Qwen3。
13. 小结
本文没有让一个模型承担全部工作,而是让每个模型处理更适合的任务:
- Gemma 3n 负责当前图片和音频的理解。
- Qwen3 负责中文回答与多轮上下文。
- LangChain 提供 ChatOpenAI、消息对象和 RunnableLambda。
- SQLite 保存会话,Gradio 提供交互界面。
最后汇总本次验证结果。
| 验证项 | 实测结果 |
|---|---|
| 多模态模型 | mlx-community/gemma-3n-E2B-it-4bit |
| 模型 revision | 8a6e532400c0385b14908b963b728f593fa476f2 |
| MLX-VLM | 0.3.9 |
| 图片输入 | 识别出蓝色正方形和黄色圆形 |
| 音频输入 | 识别出姓名与杭州西湖,个别词存在误识别 |
| 最终回答模型 | 本地 Qwen3-14B |
| 多轮追问 | 能根据 SQLite 历史回答音频提到杭州 |
| 会话隔离 | 使用 gr.State 和独立 session_id |
| 数据保存 | SQLite 保存文字、路径和识别结果,不保存 Base64 |
| 页面布局 | 桌面页面与 390×844 移动视口均无横向溢出 |
| 最终状态 | 图片、音频、文字与多轮聊天均已运行通过 |
至此,LangChain 系列已经完成从模型接入到多轮、多模态聊天应用的完整链路。后续 RAG 系列将从 Embedding 开始,继续解决外部知识的表示、检索和问答问题。