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 Noneio.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 message | type=‘messages’ 不认 tuple | 改为 {'path': path} |
| 文字消息匹配不到 | 卡住不响应 | 新版 content 是 list 不是 str | 增加 isinstance(list) 分支 |
| 只有图片没文字 | 模型报错或返回空 | 无文字提示 | 自动追加描述指令 |
相关笔记
- 前置:02_系统架构与回调流程
- 前置:04_LangChain链路与会话历史
- 后续:06_完整注释代码