【Bug已解决】GRPOTrainer environment_factory / tools is broken for VLMs whose tools return images 解决方案
【Bug已解决】GRPOTrainer environment_factory / tools is broken for VLMs whose tools return images 解决方案
一、现象长什么样
在用一个视觉语言模型(VLM)做 agentic RL 时,我们给模型配了工具,其中一个工具会返回图片(比如"截图当前页面""生成示意图")。跑GRPOTrainer的environment_factory/tools流程时,训练直接挂,或生成出乱码:
TypeError: can only concatenate str (not "PIL.Image.Image") to str或者更隐蔽地,图片被str(image)序列化成了<PIL.PngImagePlugin.PngImagePlugin object at 0x...>,模型收到一段无意义文本而非图像,多模态上下文被破坏,reward 全失。
现象特征:
- 只在"工具返回图片"的 VLM 任务上出现;纯文本工具(返回字符串)一切正常;
- 报错来自
_tool_call_loop(工具调用循环)里把 tool result 当文本拼回 conversation 的那一步; - 本质是trainer 假设所有 tool result 都是文本,没考虑 multimodal 工具结果。
二、背景
VLM 的对话不是纯文本,而是多模态消息:一条user/assistant消息的content是一个列表,里面混合{"type":"text",...}和{"type":"image",...}等块。当模型调用一个返回图片的工具时,正确的做法应当是把这张图作为一个新的 image 块插回对话,而不是当成字符串拼进 text 块。
GRPOTrainer的environment_factory/tools机制设计时是面向纯文本 LLM 的:_tool_call_loop大概是这样:
for call in tool_calls: result = execute_tool(call) # 假设 result 是字符串 conversation.append({"role": "tool", "content": str(result)}) # ← 文本假设当result是PIL.Image.Image时,str(result)得到对象地址,模型看到的不是图,而是垃圾文本;或者str + content直接TypeError。VLM 的多模态上下文因此被破坏——这正是 issue 描述的"broken for VLMs whose tools return images"。
三、根因
根因一句话:GRPOTrainer 的_tool_call_loop把 tool result 一律当作文本处理(字符串拼接 / 文本消息),没有识别并正确插入多模态(图像)结果,导致 VLM 的工具返回图片时上下文损坏或报错。
具体:
- 文本假设:
content被当字符串,图片被str()或拼接,丢失图像语义; - 消息结构不匹配:VLM 需要
content是[{type:image}, ...]的块列表,但 trainer 塞了个文本串; - environment_factory 没有 content-type 协商:工厂返回 tool result 时没标注"这是图",trainer 无从得知该插 image 块还是 text 块;
- 静默损坏:有时不报错(只是
str(image)变垃圾文本),模型拿到错误上下文,reward 崩。
本质是"工具结果的多模态类型"在 trainer 里没有一等公民地位。
四、最小可运行复现
下面用纯 Python(不依赖 PIL,用占位对象)复现"图片被当文本拼"的崩溃与损坏:
class FakeImage: def __str__(self): return "<FakeImage object>" def tool_call_loop_text_only(conversation, tool_results): """原实现:假设所有 tool result 是文本。""" for r in tool_results: conversation.append({"role": "tool", "content": str(r)}) # 图片被 str return conversation def demo(): conv = [{"role": "user", "content": "截图给我看"}] img = FakeImage() try: out = tool_call_loop_text_only(conv, [img]) print("没报错,但 content =", repr(out[-1]["content"])) # 垃圾文本 except TypeError as e: print("直接 TypeError:", e) if __name__ == "__main__": demo()输出:
没报错,但 content = '<FakeImage object>'这正是"静默损坏":图片没被当图处理,而是变成无意义字符串塞进对话,VLM 拿到的多模态上下文是坏的。某些框架里这一步是str + content拼接,则会直接TypeError挂掉。
五、解决方案(第一层):让 tool result 携带类型,插入正确块
第一层给 tool result 一个明确的类型标记,循环里按类型插入 image 块或 text 块:
from typing import Dict, List, Any, Union def make_tool_result(payload: Any, kind: str = "text") -> Dict[str, Any]: """统一工具结果契约:显式声明类型。""" return {"kind": kind, "payload": payload} def append_tool_result(conversation: List[Dict], result: Dict[str, Any]) -> List[Dict]: """按类型把工具结果插回对话,VLM 友好。""" conversation = list(conversation) if result["kind"] == "image": # VLM:content 是块列表,插入 image 块 block = {"type": "image", "image": result["payload"]} conversation.append({"role": "tool", "content": [block]}) else: conversation.append({"role": "tool", "content": str(result["payload"])}) return conversation def demo(): conv = [{"role": "user", "content": "截图给我看"}] img_result = make_tool_result("<PNG bytes>", kind="image") out = append_tool_result(conv, img_result) print("VLM 对话块:", out[-1]) if __name__ == "__main__": demo()核心改动:make_tool_result显式标kind,append_tool_result对image类型插入{"type":"image","image":...}块,对文本插入字符串。多模态上下文不再损坏。
六、解决方案(第二层):environment_factory 返回结构化结果,trainer 统一分派
第二层让environment_factory返回的就是带类型的结果,trainer 不再猜测:
from typing import Dict, List, Any def environment_factory(task): """工厂按任务返回带类型的工具结果(示意)。""" def run_tool(tool_name, args): if tool_name == "screenshot": image = f"img:{args}" # 真实场景是 PIL.Image return make_tool_result(image, kind="image") return make_tool_result(f"result for {args}", kind="text") return run_tool def tool_call_loop_vlm(conversation, calls, run_tool): for call in calls: result = run_tool(call["name"], call["args"]) conversation = append_tool_result(conversation, result) return conversation def demo(): factory = environment_factory("web_task") conv = [{"role": "user", "content": "截个图"}] calls = [{"name": "screenshot", "args": "home"}] out = tool_call_loop_vlm(conv, calls, factory) print("工厂返回结构化结果,trainer 正确分派:", out[-1]) if __name__ == "__main__": demo()environment_factory返回的结果自带kind,_tool_call_loop直接按类型分派,彻底消除"trainer 猜类型是文本"的假设。VDM/VLM 与文本 LLM 走同一套循环,只是 image 类型走不同插入分支。
七、解决方案(第三层):处理器对齐 + 不变量测试
第三层保证插入的 image 块能被 processor(tokenizer+image_processor)正确编码,并加测试锁住"图片结果不变成文本":
from typing import Dict, List, Any def to_processor_content(message) -> Any: """把 tool 消息转成 processor 能吃的格式(VLM 是块列表,文本是字符串)。""" content = message["content"] if isinstance(content, list): # 已是块列表(含 image),processor 会处理 return content return content def assert_no_image_as_text(conversation: List[Dict]): for m in conversation: if m.get("role") == "tool": c = m["content"] if isinstance(c, str) and "object at 0x" in c: raise AssertionError("图片被错误序列化成文本,多模态上下文损坏!") def test_image_inserted_as_block(): conv = [{"role": "user", "content": "截图"}] res = make_tool_result("<PNG>", kind="image") conv = append_tool_result(conv, res) assert_no_image_as_text(conv) assert isinstance(conv[-1]["content"], list), "图片应作为块列表插入" assert conv[-1]["content"][0]["type"] == "image" print("OK: 图片作为 image 块插入,未被文本化") if __name__ == "__main__": test_image_inserted_as_block()assert_no_image_as_text在训练主循环每步调用,一旦图片被str()成对象地址就立刻断言失败,把"静默损坏"变成显式报错;test_image_inserted_as_block锁住"图片结果为块列表、type=image"的不变量。
八、接入 GRPOTrainer 的建议
如果你要在GRPOTrainer里支持"工具返回图片"的 VLM,建议:
- 改 tool result 契约:工具/工厂返回
{kind, payload},显式标注 image/text。 - 改
_tool_call_loop:按kind分派,image 插{"type":"image","image":...}块。 - 工厂结构化:
environment_factory返回带类型结果,trainer 不猜测。 - processor 对齐:确认 image 块能被
processor.__call__正确编码(配processor_kwargs)。 - 加护栏断言:
assert_no_image_as_text每步检查,防回归。 - 加不变量测试:锁住"图片不文本化、作为块插入"。
九、排查清单
如果你在"VLM 工具返回图片"时遇到挂掉/乱码,按顺序查:
- 看报错是否
can only concatenate str或str(image)变垃圾:是则图片被当文本。 - 搜
_tool_call_loop:tool result 是否被str()或拼进文本 content。 - 确认对话 content 结构:VLM 的 tool 消息应为块列表,而非字符串。
- 改 result 契约为
{kind,payload}:显式标 image/text。 - 改插入逻辑:image 类型插
{"type":"image"}块。 - 确认 processor 能吃:image 块能否被 processor 编码。
- 加断言/测试:锁住"图片不文本化"。
十、小结
GRPOTrainer的environment_factory/tools在 VLM 工具返回图片时崩溃或乱码,根因是工具调用循环把所有 tool result 一律当文本处理(字符串拼接 / 文本消息),没有识别并正确插入多模态图像块。图片被str()成对象地址或触发TypeError,VLM 的多模态上下文被破坏,reward 全失。它有时会静默损坏(不报错只变垃圾文本),更难察觉。
修复分三层:第一层给 tool result 加kind类型标记,append_tool_result按类型把图片插成{"type":"image"}块、文本插成字符串,从契约上消除损坏;第二层让environment_factory返回结构化结果,trainer 直接分派不再猜测类型;第三层加assert_no_image_as_text护栏与"图片作为块插入"不变量测试,把静默损坏变成显式报错。核心心法是:当工具结果可能是多模态时,其类型必须是一等公民——trainer 不能假设结果永远是文本,而应按类型把图像正确插回多模态对话结构。