0-qwen3-vl-demo.ipynb 代码说明这份 notebook 做的事情其实很简单:
chat 请求qwen3.5:0.8b如果你把它和前面 3/2-prog/0-qwen3-chat-demo.ipynb 对照着看,会发现:
images也就是说,视觉模型的调用方式并没有“完全换一套”,只是输入结构多了一层图片。
这份 notebook 可以分成 6 步:
payload这一格只是告诉你:
qwen3.5:0.8b它本身不执行代码,但它很重要,因为它定义了 notebook 的任务边界:
不是训练模型,而是调用现成模型。
这一格告诉你默认图片在哪里:
examples/example.png这一步的意义是:
这一格的代码是:
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()逐块看:
from pathlib import Path 作用:更方便地处理文件路径。import base64 作用:把图片二进制内容转成文本字符串,方便放进 HTTP 请求。import json 作用:把 Python 字典变成 JSON 请求体。import mimetypes 作用:根据文件后缀猜图片类型,比如 png、jpg。import urllib.request 作用:用 Python 标准库发 HTTP 请求。from IPython.display import Image, display 作用:在 notebook 里直接显示图片。import os 作用:后面用来清理代理环境变量。import sys 作用:处理错误输出。import urllib.error 作用:捕获 HTTP 和网络请求错误。这里的:
image_path = Path('examples/example.png')表示:
而:
image_path.exists()只是做一个最简单的检查:
True,说明图片路径有效False,后面肯定跑不起来display(Image(filename=str(image_path)))这一格特别适合课堂展示,因为它做了两件很实际的事:
逐行解释:
str(image_path) 把 Path 对象转成普通字符串路径。Image(filename=...) 告诉 notebook 去加载这张图片。display(...) 让它直接显示在输出区。这一格在提醒学生:
这里最重要的概念是:
代码是:
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]逐行解释:
mimetypes.guess_type(str(image_path)) 根据文件名猜类型。 例如图片可能被识别成 image/png。mime_type, _ = ... 左边第一个变量拿到 MIME 类型,第二个变量这里不用,所以写成 _。if mime_type is None: 如果没猜出来,就手动给一个默认值。mime_type = 'image/png' 把默认值设成 PNG。真正关键的是:
image_path.read_bytes()它表示:
再看:
base64.b64encode(...)它表示:
最后:
.decode('utf-8')表示:
这一格最后只显示:
image_base64[:80]不是为了看完整图片内容,而是为了确认:
这一格的重点是提醒学生:
messages这是理解多模态接口最重要的一步。
代码是:
payload = {
'model': 'qwen3.5:0.8b',
'stream': False,
'messages': [
{
'role': 'user',
'content': '请描述这张图片的主要内容,并指出你最有把握的三个视觉细节。',
'images': [image_base64],
}
],
}
payload逐项解释:
'model': 'qwen3.5:0.8b' 指定本次要调用哪个本地模型。'stream': False 表示不要流式返回,而是一次性拿完整结果。 这样更适合教学和调试。'messages': [...] 这是 Ollama 的对话输入结构。'role': 'user' 说明这条消息来自用户。'content': '...' 这是用户提出的问题。'images': [image_base64] 这里是视觉版本和文本版本最大的区别。 它表示:除了文字问题,再给模型一张图片。为什么是列表 []?
这一格的作用是把思路从“组织 Python 数据”切换到“真正发 HTTP 请求”。
学生在这里要建立一个概念:
这是整份 notebook 最核心的一格。
第一部分:
request = urllib.request.Request(
'http://localhost:11434/api/chat',
data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'},
method='POST',
)逐行解释:
'http://localhost:11434/api/chat' 这是本地 Ollama 的聊天接口。json.dumps(payload) 把 Python 字典转成 JSON 字符串。.encode('utf-8') 再把字符串转成字节,方便 HTTP 发送。headers={'Content-Type': 'application/json'} 告诉服务器:这次发的是 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({}))这是给初学者特别值得讲清楚的一段。
它的作用是:
为什么要这样写?
urllib 有时会自动读这些环境变量localhostsocks5 或别的代理上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)这部分是在做错误处理。
HTTPError 表示服务器收到了请求,但返回了错误状态。 例如模型名不存在。URLError 表示连本地服务都没连上。 例如 Ollama 没启动。最后:
message = result.get("message", {})
print(message.get("content", "").strip())表示:
messagestrip() 去掉首尾空白这格只是一个过渡,告诉学生:
result['message']['content']这格很简单,但教学上很重要。
因为它把“完整 JSON 返回”和“我们真正关心的回答内容”分开了。
学生要知道:
message -> content这里开始进入更有趣的部分:
这一步特别适合课堂互动。
这格本质上是在重复前面的请求流程,只是把:
payload['messages'][0]['content']换成了新的问题:
这一步想让学生看到:
images 字段。现象:
image_path.exists() 返回 False说明:
现象:
URLError说明:
现象:
HTTPErrormodel not found说明:
现象:
localhost说明:
这也是为什么这里专门清理代理变量。