0-qwen3-vl-demo.ipynb 代码说明

这份 notebook 在做什么

这份 notebook 做的事情其实很简单:

  1. 读入一张本地图片
  2. 把图片转成模型能接收的 base64 字符串
  3. 构造一个标准的 Ollama chat 请求
  4. 把文字问题和图片一起发给 qwen3.5:0.8b
  5. 显示模型返回的视觉理解结果

如果你把它和前面 3/2-prog/0-qwen3-chat-demo.ipynb 对照着看,会发现:

也就是说,视觉模型的调用方式并没有“完全换一套”,只是输入结构多了一层图片。

先看整体结构

这份 notebook 可以分成 6 步:

  1. 导入依赖,指定图片路径
  2. 展示图片,先让人眼确认输入
  3. 把图片转成 base64
  4. 构造 payload
  5. 请求本地 Ollama
  6. 改 prompt,观察模型输出如何变化

Cell 0:标题页

这一格只是告诉你:

它本身不执行代码,但它很重要,因为它定义了 notebook 的任务边界:
不是训练模型,而是调用现成模型。

Cell 1:说明图片路径

这一格告诉你默认图片在哪里:

这一步的意义是:

Cell 2:导入依赖并检查图片

这一格的代码是:

from pathlib import Path
import base64
import json
import mimetypes
import urllib.request
from IPython.display import Image, display
import argparse
import os
import sys
import urllib.error

image_path = Path('examples/example.png')
image_path.exists()

逐块看:

这里的:

image_path = Path('examples/example.png')

表示:

而:

image_path.exists()

只是做一个最简单的检查:

Cell 3:显示图片

display(Image(filename=str(image_path)))

这一格特别适合课堂展示,因为它做了两件很实际的事:

  1. 让学生先看见输入图片
  2. 避免“模型说错了,到底是图错了还是模型错了”这种混乱

逐行解释:

Cell 4:说明要转成 base64

这一格在提醒学生:

这里最重要的概念是:

Cell 5:把图片转成 base64

代码是:

mime_type, _ = mimetypes.guess_type(str(image_path))
if mime_type is None:
    mime_type = 'image/png'

image_base64 = base64.b64encode(image_path.read_bytes()).decode('utf-8')
image_base64[:80]

逐行解释:

真正关键的是:

image_path.read_bytes()

它表示:

再看:

base64.b64encode(...)

它表示:

最后:

.decode('utf-8')

表示:

这一格最后只显示:

image_base64[:80]

不是为了看完整图片内容,而是为了确认:

Cell 6:说明要构造 payload

这一格的重点是提醒学生:

这是理解多模态接口最重要的一步。

Cell 7:构造请求体

代码是:

payload = {
    'model': 'qwen3.5:0.8b',
    'stream': False,
    'messages': [
        {
            'role': 'user',
            'content': '请描述这张图片的主要内容,并指出你最有把握的三个视觉细节。',
            'images': [image_base64],
        }
    ],
}

payload

逐项解释:

为什么是列表 []

Cell 8:说明要请求本地 Ollama

这一格的作用是把思路从“组织 Python 数据”切换到“真正发 HTTP 请求”。

学生在这里要建立一个概念:

Cell 9:请求本地 Ollama

这是整份 notebook 最核心的一格。

第一部分:

request = urllib.request.Request(
    'http://localhost:11434/api/chat',
    data=json.dumps(payload).encode('utf-8'),
    headers={'Content-Type': 'application/json'},
    method='POST',
)

逐行解释:

第二部分:

for key in [
    "http_proxy",
    "https_proxy",
    "HTTP_PROXY",
    "HTTPS_PROXY",
    "all_proxy",
    "ALL_PROXY",
]:
    os.environ.pop(key, None)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))

这是给初学者特别值得讲清楚的一段。

它的作用是:

为什么要这样写?

os.environ.pop(key, None) 的意思是:

而:

urllib.request.ProxyHandler({})

表示:

第三部分:

try:
    with opener.open(request) as response:
        result = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
    print(exc.read().decode("utf-8", errors="ignore"), file=sys.stderr)
except urllib.error.URLError as exc:
    print(f"Failed to reach Ollama: {exc}", file=sys.stderr)

这部分是在做错误处理。

最后:

message = result.get("message", {})
print(message.get("content", "").strip())

表示:

Cell 10:说明查看输出

这格只是一个过渡,告诉学生:

Cell 11:读取模型回答

result['message']['content']

这格很简单,但教学上很重要。

因为它把“完整 JSON 返回”和“我们真正关心的回答内容”分开了。

学生要知道:

Cell 12:第二轮 prompt

这里开始进入更有趣的部分:

这一步特别适合课堂互动。

Cell 13:改 prompt 再请求一次

这格本质上是在重复前面的请求流程,只是把:

payload['messages'][0]['content']

换成了新的问题:

这一步想让学生看到:

这份 notebook 最值得学生带走什么

  1. 视觉模型的调用接口和文本模型很像,只是多了 images 字段。
  2. 图片必须先转成 base64,才能通过 JSON 请求发送。
  3. 本地调用失败时,常见问题不是“模型不会”,而是代理、服务、模型名、图片路径这些工程细节。
  4. 同一张图,换一个 prompt,模型的输出层次会明显变化。

最常见的 4 个报错

1. 图片路径错误

现象:

说明:

2. 本地服务没开

现象:

说明:

3. 模型名写错

现象:

说明:

4. 代理干扰本地请求

现象:

说明:

这也是为什么这里专门清理代理变量。