923 字
5 分钟
图像处理与多模态消息组装

图像处理与多模态消息组装#

本篇讨论项目中最容易写散的一层:如何把来自 Gradio 的不同输入形态,统一组装成 LangChain / 多模态模型可消费的 content 列表。


图像处理模块#

transcribe_image()#

def transcribe_image(image_path: str) -> dict | None:

为什么不直接传文件路径给模型?

  • 本地路径对云端 API 不可见
  • 必须把图片编码后「内嵌」进请求体

处理流程:

def transcribe_image(image_path: str) -> dict | None:
try:
with Image.open(image_path) as img:
# RGBA(含透明通道)和 P(调色板)无法存为 JPEG
# 统一转 RGB 保证格式兼容
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img_format = img.format or 'JPEG' # 读取不到时默认 JPEG
# 用内存流代替临时文件,效率更高
buffered = io.BytesIO()
img.save(buffered, format=img_format)
image_data = base64.b64encode(buffered.getvalue()).decode('utf-8')
return {
"type": "image_url",
"image_url": {
"url": f"data:image/{img_format.lower()};base64,{image_data}",
# detail 控制 token 消耗:
# "low" → 固定 85 token,速度快但粗糙
# "high" → 按图片尺寸计算,精准但贵
"detail": "high",
},
}
except Exception as e:
print(f"[图像处理失败] {e}")
return None
io.BytesIO 是什么?

内存中的「虚拟文件」,用法和磁盘文件一样,但数据存在 RAM 里。

  • img.save(buffered, format=...) → 把图片数据写进内存
  • buffered.getvalue() → 取出全部字节数据

好处:不产生临时文件,程序结束后自动回收内存。


消息组装模块#

get_last_user_messages()#

def get_last_user_messages(history: list) -> list:

核心问题:为什么不直接发全部 history?

RunnableWithMessageHistory 已经自动管理 SQLite 里的完整历史,每次 invoke 只需要传「本轮新增的消息」。如果把全部 history 传进去,历史会被写入两次。

history 示意:
[user₁, assistant₁, user₂, assistant₂, user₃, user₄]
↑ ↑
这两条是本轮新增,需要提取
def get_last_user_messages(history: list) -> list:
if not history:
return []
if history[-1]["role"] == "assistant":
return [] # 最后是 AI 回复,说明本轮已处理完
# next() + 生成器:从后往前找第一个 assistant,比 for 循环更简洁
last_assistant_idx = next(
(i for i in range(len(history) - 1, -1, -1)
if history[i]["role"] == "assistant"),
-1 # 找不到时返回 -1(第一轮对话,全是 user 消息)
)
return history[last_assistant_idx + 1:]

submit_messages() 的 content 组装#

content 是发给 LangChain 的核心数据,支持文字和图片混合:

content = [
{"type": "text", "text": "描述一下图片"},
{"type": "image_url", "image_url": {"url": "data:image/...", "detail": "high"}},
{"type": "text", "text": "🎤 [语音输入]: 这是语音转换的文字"},
]

三种消息格式的处理:

for x in user_messages:
msg_content = x['content']
# 情况 A:纯字符串
# 来源:文字输入 或 音频转换后的文字
if isinstance(msg_content, str):
content.append({'type': 'text', 'text': msg_content})
# 情况 B:列表格式(新版 Gradio type='messages' 的文本消息)
# 结构:[{'type': 'text', 'text': '...'}, ...]
elif isinstance(msg_content, list):
for item in msg_content:
if item.get('type') == 'text':
content.append({'type': 'text', 'text': item['text']})
# 情况 C:字典格式(图片文件路径)
# 结构:{'path': '绝对路径', ...}
elif isinstance(msg_content, dict):
fp = msg_content.get('path') or msg_content.get('url')
if fp:
_append_file(content, fp) # 读取并编码为 base64
兜底机制

如果用户只发了图片没发文字,某些模型会报错(没有文字提示):

if content and not has_text:
content.append({'type': 'text', 'text': '请详细描述这张图片的内容。'})

Gradio 回调链#

chat_input.submit(
fn=add_message, # 第一步:处理输入,立即更新界面
inputs=[chatbot, chat_input],
outputs=[chatbot, chat_input],
).then(
fn=submit_messages, # 第二步:调用 AI,等待回复
inputs=[chatbot],
outputs=[chatbot],
).then(
fn=lambda: gr.MultimodalTextbox(interactive=True), # 第三步:解锁输入框
inputs=None,
outputs=[chat_input],
)

常见格式错误速查#

场景错误原因修复
存图片用 (path,)ValueError: Invalid messagetype=‘messages’ 不认 tuple改为 {'path': path}
文字消息匹配不到卡住不响应新版 content 是 list 不是 str增加 isinstance(list) 分支
只有图片没文字模型报错或返回空无文字提示自动追加描述指令

相关笔记