LangChain 系列 9:支持图片和音频的多模态聊天数字人


上一章使用 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 才能把这些信息与历史消息一起处理。

一轮请求会经过以下步骤:

  1. Gradio 接收文字、图片或音频,并从 gr.State 取得当前页面的 session_id。
  2. 图片和音频先复制到 data/uploads//,避免依赖 Gradio 临时文件。
  3. RunnableLambda 把本轮新媒体交给 Gemma 3n,得到文字形式的识别结果。
  4. 程序把用户文字、识别结果和受控文件路径组合成当前 HumanMessage。
  5. SQLite 按 session_id 读取最近 10 条历史消息,Qwen3 根据历史和当前消息生成回答。
  6. 程序把当前 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。每一轮请求按以下顺序处理:

  1. 把 Gradio 临时文件复制到当前会话目录。
  2. 使用 Gemma 3n 理解本轮图片或音频。
  3. 把识别结果追加到本轮用户消息。
  4. 使用 Qwen3 结合最近 10 条消息生成最终回答。
  5. 将文字、文件路径、识别结果和回答写入 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 开始,继续解决外部知识的表示、检索和问答问题。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录