跳到主内容
快讯直播
AI智模界
教程

用 Qwen3.8-Omni 搭建能看视频听语音的全模态智能体

全模态模型和"文字模型 + 语音转文字 + 图像描述"的拼接方案,差别在哪里?差在信息在进入模型之前就被翻译成了文字,语气、停顿、画面里的动作全丢了。这篇教程把一个能真正"看"视频、"听"语音的智能体从头搭出来:给它一段录屏或一段口述录音,它能一边流式吐字回答,一边在需要的时候调用工具去查数据。

具体目标是做出一个命令行程序,支持三种输入:本地视频文件、本地音频文件、直接打字。程序把视频拆成画面帧和音轨,按时间轴编排成一次请求发给模型,输出以流式方式实时打印。模型判断需要外部信息时,会发起工具调用,程序执行工具后把结果回填,继续生成。

前置条件清单

  • Python 3.10 及以上(以官方文档当前版本要求为准)
  • 一个可用的多模态模型 API Key,且模型支持视频/音频输入
  • 本机安装 ffmpeg,命令行能直接调用
  • 大约 20 分钟,以及一段 30 秒到 2 分钟的自测素材

关于模型标识:不同平台的模型 ID 命名不一样,代码里统一通过环境变量传入。具体填哪个 ID,请看官方文档当前版本的模型列表,选择带 Omni 或明确标注支持音视频输入的那一个。接口地址同理,本文按 OpenAI 兼容协议写,如果你的平台提供兼容端点,直接换 base_url 即可。

```bash

python -m venv .venv

source .venv/bin/activate # Windows 用 .venv\Scripts\activate

pip install openai python-dotenv

ffmpeg -version

```

ffmpeg -version 能打印出版本信息就说明装好了。如果提示找不到命令,先用系统包管理器安装(macOS 用 brew install ffmpeg,Ubuntu/Debian 用 apt install ffmpeg,Windows 可以下载官方构建包并把 bin 目录加进 PATH)。

接着建一个 .env 文件,注意不要把它提交到代码仓库:

```dotenv

OMNI_API_KEY=你的密钥

OMNI_BASE_URL=你的兼容端点地址

OMNI_MODEL=你的多模态模型ID

```

步骤 1:把视频拆成模型能接收的两种模态

全模态模型接收的输入,本质上还是"一段一段的媒体单元"。视频要做的事就是拆开:画面按固定间隔抽成图片,音轨切成若干秒一段的音频片段。

```bash

mkdir -p work/frames work/audio

每 2 秒抽一帧,宽度压到 640

ffmpeg -y -i demo.mp4 -vf "fps=1/2,scale=640:-2" -q:v 3 work/frames/frame_%04d.jpg

抽出 16kHz 单声道 wav,每 30 秒切一段

ffmpeg -y -i demo.mp4 -vn -ac 1 -ar 16000 -f segment -segment_time 30 -c:a pcm_s16le work/audio/chunk_%03d.wav

```

两个参数值得留意。fps=1/2 是"每 2 秒一帧",2 分钟的视频就是 60 帧;改成 fps=1 是每秒一帧,帧数翻倍,效果更细但请求体积也翻倍。scale=640:-2 里的 -2 表示高度按比例自动算,并且保证是偶数——编码器对奇数高度会报错。

音频统一转成 16kHz 单声道,是因为多数语音前端都按这个采样率工作,采样率对不上容易出现音调异常或者被接口直接拒绝。

步骤 2:按时间轴把帧和音频编排成一次请求

这一步是"统一输入编排"的核心。不要把所有帧和所有音频片段无脑堆进一个数组,而是按时间顺序交错排列,并在每段前面加一行时间戳说明。模型看到"第 0 秒的画面""第 0 到 30 秒的语音",后面回答"第 12 秒发生了什么"才有依据。

```python

import base64, os, glob, json

from typing import Iterable

def to_data_url(path: str) -> str:

ext = os.path.splitext(path)[1].lower()

mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg",

".png": "image/png", ".wav": "audio/wav",

".mp3": "audio/mpeg"}.get(ext, "application/octet-stream")

with open(path, "rb") as f:

return f"data:{mime};base64," + base64.b64encode(f.read()).decode()

def build_media_parts(frames_dir: str, audio_dir: str,

frame_every: float = 2.0, audio_chunk: float = 30.0) -> list:

parts = []

frames = sorted(glob.glob(os.path.join(frames_dir, "*.jpg")))

for i, p in enumerate(frames):

t = i * frame_every

parts.append({"type": "text", "text": f"[画面 {t:.0f}s]"})

parts.append({"type": "image_url", "image_url": {"url": to_data_url(p)}})

chunks = sorted(glob.glob(os.path.join(audio_dir, "*.wav")))

for j, p in enumerate(chunks):

t = j * audio_chunk

parts.append({"type": "text", "text": f"[语音 {t:.0f}s-{t + audio_chunk:.0f}s]"})

parts.append({"type": "input_audio",

"input_audio": {"data": to_data_url(p).split(",", 1)[1], "format": "wav"}})

return parts

```

音频字段的写法在不同平台之间有差异,有的用 input_audiodata + format,有的用 audio_url 配 data URL。以你所用平台的官方文档为准,改 build_media_parts 里那一行就够了,其余流程不受影响。

如果接口原生支持直接传视频地址或视频文件,可以省掉前面的抽帧步骤,但仍建议保留时间戳提示文本,模型对"第几秒"的定位会稳一些。

步骤 3:先跑通一次非流式请求

别急着上流式,先用最小代码确认模态输入是被接受的。

```python

import os

from dotenv import load_dotenv

from openai import OpenAI

load_dotenv()

client = OpenAI(api_key=os.environ["OMNI_API_KEY"],

base_url=os.environ["OMNI_BASE_URL"])

MODEL = os.environ["OMNI_MODEL"]

parts = build_media_parts("work/frames", "work/audio")

parts.insert(0, {"type": "text", "text": "描述这段录屏里发生了什么,并指出讲解人提到的关键数字。"})

resp = client.chat.completions.create(

model=MODEL,

messages=[{"role": "user", "content": parts}],

)

print(resp.choices[0].message.content)

```

能打印出一段带时间点的描述,说明输入编排这条路是通的。如果报"内容过长"之类的错误,回到步骤 1 把 fps 调小、把 scale 的宽度调小,或者缩短素材。

步骤 4:改成流式输出

流式输出对全模态场景尤其重要,因为一段带画面的回答可能要生成十几秒,用户需要立刻看到反馈。

```python

def stream_once(messages: list):

stream = client.chat.completions.create(

model=MODEL, messages=messages, stream=True)

buf, finish = [], None

for chunk in stream:

if not chunk.choices:

continue

choice = chunk.choices[0]

delta = choice.delta

if delta and delta.content:

print(delta.content, end="", flush=True)

buf.append(delta.content)

if choice.finish_reason:

finish = choice.finish_reason

print()

return "".join(buf), finish

```

几个容易踩的点:chunk.choices 偶尔是空数组,必须判空;delta.content 在只传工具调用时是 None,不能直接字符串相加;finish_reason 只在最后一个分片里出现,中途读到 None 是正常的。

步骤 5:加上工具调用

工具调用在流式模式下有个必须处理的细节:tool_calls 是分片下发的,函数名和参数 JSON 会被拆成好几段。参数必须在全部收完之后再 json.loads,中途解析一定失败。合并时要按 index 字段归位,不能简单按到达顺序追加。

```python

import json

TOOLS = [{

"type": "function",

"function": {

"name": "get_metric",

"description": "查询某个业务指标的当前数值",

"parameters": {

"type": "object",

"properties": {"name": {"type": "string", "description": "指标名"}},

"required": ["name"],

},

},

}]

def merge_tool_calls(acc: dict, deltas: Iterable):

for d in deltas:

idx = d.index if d.index is not None else 0

slot = acc.setdefault(idx, {"id": "", "name": "", "arguments": ""})

if d.id:

slot["id"] = d.id

if d.function and d.function.name:

slot["name"] = d.function.name

if d.function and d.function.arguments:

slot["arguments"] += d.function.arguments

```

工具本体先用一个假实现顶上,接真实系统时替换函数体即可:

```python

def get_metric(name: str) -> dict:

fake = {"日活": 128400, "转化率": "3.7%", "平均响应时长": "420ms"}

return {"name": name, "value": fake.get(name, "暂无数据")}

TOOL_IMPL = {"get_metric": get_metric}

```

步骤 6:串成完整的智能体主循环

把流式、工具调用、消息回填串起来。循环的退出条件是:模型这一轮没有发起工具调用,只有文本输出。

```python

def run_agent(messages: list, max_steps: int = 6):

for _ in range(max_steps):

text_buf, tool_acc, finish = [], {}, None

stream = client.chat.completions.create(

model=MODEL, messages=messages, tools=TOOLS, stream=True)

for chunk in stream:

if not chunk.choices:

continue

choice = chunk.choices[0]

delta = choice.delta

if delta and delta.content:

print(delta.content, end="", flush=True)

text_buf.append(delta.content)

if delta and getattr(delta, "tool_calls", None):

merge_tool_calls(tool_acc, delta.tool_calls)

if choice.finish_reason:

finish = choice.finish_reason

print()

assistant_msg = {"role": "assistant", "content": "".join(text_buf) or None}

if tool_acc:

assistant_msg["tool_calls"] = [

{"id": s["id"], "type": "function",

"function": {"name": s["name"], "arguments": s["arguments"]}}

for _, s in sorted(tool_acc.items())

]

messages.append(assistant_msg)

if not tool_acc:

return messages

for _, s in sorted(tool_acc.items()):

fn = TOOL_IMPL.get(s["name"])

try:

args = json.loads(s["arguments"] or "{}")

result = fn(**args) if fn else {"error": f"未知工具 {s['name']}"}

except Exception as e:

result = {"error": str(e)}

messages.append({"role": "tool", "tool_call_id": s["id"],

"content": json.dumps(result, ensure_ascii=False)})

return messages

```

调用入口:

```python

parts = build_media_parts("work/frames", "work/audio")

parts.insert(0, {"type": "text", "text": (

"这是一段产品演示录屏。请结合画面和讲解语音,说明演示了什么功能,"

"并用 get_metric 查一下讲解人提到的指标现在是多少。")})

msgs = [{"role": "user", "content": parts}]

run_agent(msgs)

```

一个典型的执行过程是:模型先流式输出"这段录屏从第 0 秒开始展示后台界面,讲解人提到日活……",然后停下发起 get_metric 调用,程序返回真实数值,模型接着把工具结果和画面里的信息缝合起来继续输出。这就是原生全模态的价值——它引用画面、语音和工具结果时用的是同一套上下文。

常见坑与排错

报内容超长。 主要来源是图片和音频的 base64。base64 会让原始字节膨胀约三分之一,60 帧 640 宽的 JPEG 加上两分钟音频,体积很容易上去。优先调小 fpsscale 宽度,其次缩短素材。

音频被拒绝或听起来不对。 检查是不是漏了 -ac 1 -ar 16000。另外,视频本身没有音轨时 ffmpeg 的音频命令会报错,抽音轨前可以先用 ffprobe 判断是否存在音频流,或者在脚本里 try/except 掉这一步。

工具参数解析失败。 十有八九是在流式过程中就想解析 JSON。确认参数是拼完整之后才 json.loads,并且用 index 归位。另外要在代码里兜住 json.loads 的异常,把错误信息作为 tool 消息回填,模型通常能自己纠正参数重试。

模型说的时间点和你抽的帧对不上。 时间戳提示里的秒数是按抽帧间隔推算的,不是真实关键帧时间。想更准,可以改成在文件名里带上 ffmpeg 输出的真实时间戳,再把真实时间写进提示文本。

只输出了半句就停了。 检查是否达到了 max_steps 上限,或者单次请求超时。把 max_steps 调到 6 以上,并给客户端配置合理的超时时间。

密钥泄露。 .env 写进 .gitignore,日志里不要打印完整请求体,尤其是带 base64 的那部分。

下一步建议

想要更实用,可以在几个方向上加固。做长视频时,先分段摘要再合并,避免一次塞满上下文;做实时对话时,把麦克风采集接到流式上传,配合语音合成让智能体"说"出来;做生产运行时,把每轮请求的耗时、输入输出 token 数、工具调用次数记下来,这是排查成本与延迟问题的依据。另外,同一段视频的抽帧结果建议做本地缓存,反复调试提示词时不必每次重跑 ffmpeg

模型 ID、接口字段名与配额限制都可能调整,把这篇里的 OMNI_MODEL、音频字段这类可变量集中在一处,之后按官方文档更新一处就够了。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。