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


上一篇使用 Whisper 把语音转换成文字,再交给 Qwen3 回答。这个过程可以实现语音输入,但 Qwen3 实际接收到的仍然只有文字。

如果还希望程序理解图片,就需要增加真正的多模态模型。本文在 M1 Max 上部署 mlx-community/gemma-3n-E2B-it-4bit,让 Gemma 3n 负责理解图片和音频,再由已经部署好的 Qwen3 负责最终回答和多轮对话。

这里的“数字人”指可以通过文字、图片和音频交流的聊天角色。本文不包含虚拟形象驱动、口型同步和语音合成。

1. 为什么需要两个模型

项目根目录的 Qwen3-14B-AWQ-4bit-MLX 指向 Qwen3-14B 文本模型。它没有视觉编码器和音频编码器,因此只能处理文本令牌。

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 /path/to/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

服务器依赖由 requirements-server.txt 统一管理。其中 PyTorch 用于 Gemma 3n 音频处理器的张量转换,模型推理仍由 MLX 在 Apple Silicon 上执行。

4. 下载 Gemma 3n

模型保存在博客仓库外:

~/Documents/git/llm/gemma3n/model

下载代码固定了模型 revision,避免模型仓库后续更新改变文章结果。

# 这个文件用于下载支持文字、图片和音频的 Gemma 3n MLX 模型。
# 模型保存到下方 model_dir 指定的目录(与 02_test_image_input.py 中的 model_path 保持一致)。

from pathlib import Path

from modelscope import snapshot_download

# 多模态模型本地目录;权重文件会下载到这个文件夹里。
model_dir = Path("/Users/bianhn/Documents/git/llm/gemma3n/model")
model_dir.mkdir(parents=True, exist_ok=True)

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 /path/to/llm-learning
source .venv_langchain/bin/activate

"$VIRTUAL_ENV/bin/python" -m mlx_lm server \
  --model Qwen3-14B-AWQ-4bit-MLX \
  --host 127.0.0.1 \
  --port 18080 \
  --chat-template-args '{"enable_thinking": false}'

另开终端检查端口:

curl http://127.0.0.1:18080/v1/models

模型列表中的 data 可能为空,因此最终仍要以聊天请求能否返回内容为准。

6. 测试识别图片

测试识别的图片

# 这个文件使用 MLX-VLM 原生 Python API,把本地图片交给多模态模型识别。
# 修改下方 image_path 和 prompt 变量,然后直接运行本文件即可。

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]

# 在这里填写本地多模态模型目录(相对路径或绝对路径均可)。
model_path = Path("/Users/bianhn/Documents/git/llm/gemma3n/model")
# 在这里填写要识别的本地图片路径(相对路径或绝对路径均可)。
image_path = project_root / "data" / "test.png"
# 发给模型的问题。
prompt_text = "请描述这张图片的内容。"

model_path = model_path.expanduser().resolve()
image_path = image_path.expanduser().resolve()
if not image_path.exists():
    raise FileNotFoundError(f"没有找到图片文件:{image_path}")

if not model_path.exists():
    raise FileNotFoundError(
        f"没有找到多模态模型目录:{model_path}\n"
        "请先运行 01_download_multimodal_model.py 下载模型。"
    )

# 加载本地模型,并根据图片数量生成 Gemma 3n 需要的聊天提示词。
model, processor = load(str(model_path))
prompt = apply_chat_template(
    processor,
    model.config,
    prompt_text,
    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("提问内容:", prompt_text)
print("模型回答:", result.text)

运行:

.venv_vlm/bin/python \
  langchain/p09_multimodal_digital_human/02_test_image_input.py

实测回答的关键部分为:

测试图片: /Users/bianhn/Documents/git/llm-learning/data/test.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 = Path("/Users/bianhn/Documents/git/llm/gemma3n/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

真实输出:

测试音频: /Users/bianhn/Documents/git/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="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=256,
)

9. 完整 Gradio 代码

# 这个文件实现一个可接收文字、图片和音频的 Gradio 多模态聊天页面。
#
# 运行前需要先在项目根目录启动 Qwen3 的 mlx_lm.server(与 p08 相同)。
# Gemma 3n 模型目录见下方 model_path(与 01、02 保持一致)。
#
# 架构(双模型分工):
#   Gradio          → UI,收集文字 / 图片 / 音频
#   Gemma 3n (MLX)  → 理解当前轮次的图片和音频,输出识别文字
#   Qwen3 (mlx_lm)  → 结合 SQLite 历史 + 识别结果,生成最终回答
# 浏览器地址:http://127.0.0.1:7862

from pathlib import Path
from shutil import copy2, rmtree
from uuid import uuid4

import gradio as gr
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

# parents[2]:p09_multimodal_digital_human -> langchain -> llm-learning(项目根目录)
project_root = Path(__file__).resolve().parents[2]

# 多模态聊天专用 SQLite 库(与 p08 的 chat_history.db 分开,避免混用)。
database_path = project_root / "data" / "multimodal_history.db"
# Gradio 上传的临时文件会复制到这里,按 session_id 分子目录持久保存。
upload_root = project_root / "data" / "uploads"
# 本地 Gemma 3n 模型目录(与 01_download、02_test_image_input 保持一致)。
model_path = Path("/Users/bianhn/Documents/git/llm/gemma3n/model")

database_path.parent.mkdir(parents=True, exist_ok=True)
connection = f"sqlite:///{database_path}"

# 页面启动时加载一次 Gemma 3n,后续每轮对话复用同一份 model + processor。
# 注意:模型较大,首次 load 会占用数 GB 内存,启动需等待片刻。
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="Qwen3-14B-AWQ-4bit-MLX",
    temperature=0,
    max_tokens=256,
)


def get_session_history(session_id: str) -> SQLChatMessageHistory:
    """按 session_id 读写 multimodal_history.db 中的 LangChain 消息列表。"""
    return SQLChatMessageHistory(session_id=session_id, connection=connection)


def save_upload(source: str, session_id: str, allowed_suffixes: set[str]) -> str:
    """把 Gradio 临时文件复制到 data/uploads/<session_id>/ 下的受控目录。

    Gradio 的 type=\"filepath\" 会把上传文件放在系统临时目录,进程结束后可能消失;
    复制到 upload_root 后,SQLite 里保存的路径才长期有效。
    """
    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 把本轮图片和/或音频转换成文字描述。

    values 结构:
        prompt  用户文字(无文字时 chat() 会填默认提示)
        images  本地图片路径列表
        audios  本地音频路径列表

    apply_chat_template 的 num_images / num_audios 需与实际列表长度一致。
    """
    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


# RunnableLambda 把普通函数包装成 LangChain Runnable,便于 .invoke() 统一调用。
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,
):
    """Gradio 回调:组合本轮文字、图片、音频,完成「感知 → 回答 → 存历史」。

    参数:
        text        gr.Textbox 文字
        image_path  gr.Image(type=\"filepath\") 服务器临时路径
        audio_path  gr.Audio(type=\"filepath\") 服务器临时路径
        ui_history  gr.Chatbot 当前显示的消息(OpenAI role/content 格式)
        session_id  gr.State 会话 ID

    返回值对应 outputs [chatbot, text, image, audio, session_id]:
        ui_history  更新后的聊天显示
        \"\"          清空文字框
        None          清空图片组件
        None          清空音频组件
        session_id    原样带回

    注意:ui_history(页面展示)与 SQLite 中的 LangChain messages 是两套数据。
    """
    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 = []  # 页面 Chatbot 上用户气泡的展示文案
    images = []
    audios = []

    # 无文字时给 Gemma 3n 一个默认问题,避免空 prompt。
    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 组装成一条 HumanMessage,写入 SQLite 供 Qwen3 后续轮次读取。
    user_record = [prompt_text]

    # 有图片或音频时,先让 Gemma 3n 产出识别/描述文字,再交给 Qwen3。
    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 读取最近 10 条历史 + 本轮 HumanMessage,生成最终回答。
    # 这里不用 RunnableWithMessageHistory,而是手动读写 SQLChatMessageHistory。
    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):
    """清空当前 session 的 SQLite 记录,并删除已复制的上传文件。"""
    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("## 本地多模态聊天机器人")

    # gr.State:每个浏览器标签页一份 session_id,刷新页面会重新生成。
    session_id = gr.State(lambda: str(uuid4()))

    # type=\"messages\" 使用 OpenAI 风格的 role/content 字典列表。
    chatbot = gr.Chatbot(type="messages", height=440)
    text = gr.Textbox(label="文字", placeholder="可以只发文字,也可以配合图片或音频")

    with gr.Row():
        # type=\"filepath\" 返回服务器上的临时文件路径,供 save_upload / mlx_vlm 读取。
        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("清空")

    # 点击发送:inputs/outputs 顺序必须与 chat() 参数和返回值一一对应。
    send_button.click(
        chat,
        [text, image, audio, chatbot, session_id],
        [chatbot, text, image, audio, session_id],
    )
    # 清空:同时清 UI 和 SQLite + uploads 目录
    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

image-20260725125116437

10. 为什么媒体理解与多轮回答要分开

当前媒体与多轮历史的边界

MLX-VLM 的原生 generate() 可以正确处理单轮图片和单轮音频。实测中,如果连续把上一轮图片占位符和下一轮音频占位符交给 Gemma 3n,它容易混淆两种媒体上下文。

因此本文只让 Gemma 3n 处理当前轮的新媒体。识别结果被转换成普通文字后写入 SQLite,下一轮纯文字追问由 Qwen3 读取历史。

这里需要注意:SQLite 中虽然保存了图片或音频路径,但 Qwen3 不会因为看到路径就获得媒体内容。下一轮能够回答媒体相关问题,是因为历史消息中同时保存了 Gemma 3n 的文字识别结果。旧图片和旧音频不会在每次追问时重新发送给 Gemma 3n。

例如,音频识别结果写入历史后,再问:

我刚才的音频中提到了哪个城市?

真实回答:

您刚才的音频中提到的城市是杭州。

这个结果证明多轮记忆来自 Qwen3 和 SQLite,而不是把旧音频反复发送给多模态模型。

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 已通过依赖文件安装 PyTorch。

12.2 为什么提示没有安装 torchvision

缺少 torchvision 时,图片处理器会回退到慢速实现并继续执行。本文的图片测试已经在该回退路径下正确识别颜色和形状,因此没有为了消除提示而改变已经锁定的依赖环境。

12.3 为什么不使用 mlx_vlm.server

当前环境中的 MLX-VLM 自带服务使用 /chat/completions,并且对 Gemma 3n 的聊天内容块和纯音频请求存在兼容问题。

原生 load() + apply_chat_template() + generate() 已经通过图片和音频实测,因此本文直接使用 Python API,不把后期服务行为提前写进文章。

12.4 模型回答与音频原文不完全一致

多模态模型会生成概率性回答,音频转写可能出现同音字、中英文混写和漏词。如果业务要求准确字幕,应继续使用上一篇的 Whisper;如果需要模型同时理解声音内容和上下文,可以使用本文的多模态路径。

12.5 为什么不把文件转成 Base64 存进数据库

Base64 会让数据库快速膨胀,也不利于查看和清理。本文保存受控文件路径,数据库只保存结构化消息和文字结果。

12.6 为什么 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 !
评论
  目录