工具产出图片的多模态读取设计与实现路径
状态:部分实现。用户附件图片链路和 image_understand 已可用;工具产出图片的 media artifact 注册、自动注入和跨轮持久化仍待实现。
背景日期:2026-05-14 最近审计:2026-05-15
1. 问题背景
当前 Nahida Bot 已经有一套面向“用户消息附件”的多模态图片处理链路:
- 用户消息中的图片会被标准化为
InboundAttachment。 SessionRunner._build_user_parts()根据当前模型的ModelCapabilities.image_input决定是否直接传图。- 支持视觉输入的模型会收到
ContextPart(type="image_base64")或ContextPart(type="image_url")。 - 不支持视觉输入的模型会按
multimodal.image_fallback_mode走auto、tool或off。 auto会调用 fallback vision 模型,把图片转成文字描述。tool会暴露image_understand工具,由主模型按需调用视觉模型分析图片。
这个链路对“外部平台发来的图片”基本成立,但对“工具调用过程中下载、生成、搜索、抓取到的图片”还不成立。
典型场景:
- 用户说:“从网上下载这张图,然后看一下里面是什么。”
- 模型调用某个下载工具,工具返回
{ "path": "./data/temp/media/x.png" }。 - 后续模型上下文里只看到 JSON 文本和本地路径。
- 当前运行时不会自动把这个路径重新解析为图片输入。
- 如果主模型是视觉模型,它也不会自动看到图片像素。
- 如果主模型不是视觉模型,也不会自动走 fallback vision 描述。
因此,当前系统存在一个边界不一致:
- 用户附件图片:是一等多模态对象。
- 工具产出图片:只是普通文本结果。
2. 当前实现现状
2.0 实现审计(2026-05-15)
已完成:
- 用户消息附件已经统一为
InboundAttachment,Telegram/Milky 转换器会提取图片、文件等附件。 MediaResolver已支持本地 path / URL 图片解析、MIME 和大小校验、缓存、base64 编码。SessionRunner._build_user_parts()已按ModelCapabilities.image_input分流:视觉模型直接注入 image part,非视觉模型走image_fallback_mode=auto/tool/off。image_understand工具已能读取当前 turn 附件和历史用户附件 metadata 中的图片。- 用户附件 metadata 会持久化 path、mime、尺寸、描述等信息,历史恢复时可重新构造 image part 或 image description。
- OpenAI-compatible / OpenAI Responses 等 provider 已能序列化用户图片 part。
仍未实现:
- 工具返回值中还没有统一
media/media_artifacts协议,也没有extract_media_artifacts()。 AgentLoop._build_tool_message()仍只把工具结果序列化成 JSON 文本,没有在 metadata 中记录图片 artifact。AgentLoop没有工具结果后的 media hook,SessionRunner也没有_build_tool_media_context_messages()。- 工具产出的 path/url 不会自动变成下一轮 provider 的 image part,也不会自动触发 fallback vision 描述。
image_understand不能按受控media_id查找工具产出图片;它仍只查当前/历史用户附件。- MCP image content、下载工具、图片生成结果还没有接入统一媒体 registry;OpenAI Responses generated image 当前只会变成占位文本
[generated image available]。 - 没有跨轮 tool media 持久化表,也没有
/media list、/media clear等调试命令。
2.1 用户附件图片链路
关键代码:
nahida_bot/plugins/base.pyInboundAttachmentMediaDownloadResult
nahida_bot/core/session_runner.py_build_user_parts()_build_vision_parts()_build_fallback_parts()_auto_describe_image()handle_image_understand_tool()_resolve_attachment()
nahida_bot/agent/media/resolver.pyMediaResolverResolvedMediaMediaPolicy
nahida_bot/agent/providers/base.pyModelCapabilities_serialize_openai_part()
nahida_bot/agent/providers/openai_responses.py_serialize_user_part()
当前行为:
SessionRunner.run_stream()拿到本轮attachments。- 解析当前 session 使用的 provider/model。
- 读取该模型的
ModelCapabilities。 - 若
image_input=true,走_build_vision_parts()。 _build_vision_parts()通过_resolve_attachment()调用MediaResolver。MediaResolver从本地路径或 URL 读取图片,校验大小与 MIME,编码为 base64。- provider 把
ContextPart序列化为上游 API 支持的图片输入块。
对于非视觉模型:
_build_user_parts()发现image_input=false。- 有图片附件时走
_build_fallback_parts()。 image_fallback_mode=auto时,自动调用 fallback vision provider。image_fallback_mode=tool时,暴露image_understand工具并插入图片提示。image_fallback_mode=off时,不处理图片。
2.2 工具结果链路
关键代码:
nahida_bot/agent/loop.py_execute_tools()_build_tool_message()
nahida_bot/plugins/tool_executor.pyRegistryToolExecutor.execute()
nahida_bot/plugins/mcp/tool_adapter.pyserialize_mcp_result()
nahida_bot/channels/telegram/plugin.pydownload_media
nahida_bot/channels/milky/plugin.pymilky_get_resource_temp_url
当前行为:
provider 发出 tool call。
AgentLoop执行工具。工具返回普通对象或字符串。
_build_tool_message()把结果包成 JSON 字符串:json{ "status": "ok", "output": "...", "error": null, "logs": [] }这个 tool message 作为
role="tool"的文本消息放回 conversation。后续 provider call 只看到文本内容。
Telegram 的 download_media 工具当前返回:
{
"path": "...",
"file_name": "...",
"file_size": 12345
}Milky 的资源工具当前返回:
{
"resource_id": "...",
"url": "...",
"expires_hint": 300
}MCP image content 当前会被序列化成类似:
[Image: image/png, ~12345 chars base64]这些结果都没有被运行时识别为“新的图片媒体对象”。
3. 模型能力标记的意义
ModelCapabilities.image_input 当前表达的是:
这个模型是否原生接受图片输入。
如果为 true:
- 用户附件图片可以直接进入模型请求。
- 图片 part 会按 provider 协议序列化。
- 模型可直接基于像素内容回答。
如果为 false:
- 图片不会直接传给主模型。
- 运行时应当把图片转换为文字描述,或者提供按需分析工具。
- 主模型只基于文字描述、OCR、元信息继续推理。
目前这个能力判断只作用于用户附件图片,不作用于工具产出图片。
理想情况下,image_input 应该统一作用于所有图片来源:
- 用户上传图片。
- 平台附件下载后的图片。
- 工具下载的图片。
- MCP 返回的图片。
- 内置工具生成的图片。
- 网页截图、浏览器截图、代码生成图表等运行时产物。
4. Codex 式处理方式对照
Codex/类似 agent 的做法可以抽象为:
- 文件路径是文本引用。
- 图片内容是多模态输入块。
- 模型不会因为看到路径就自动获得图片像素。
- 必须通过运行时显式把图片注册为可读媒体,并在下一次模型调用时作为 image part 注入。
这有几个重要性质:
- 工具可以下载文件,但下载动作不等于视觉读取动作。
- 运行时需要决定该图片是否安全、是否太大、是否支持 MIME。
- 若当前模型支持视觉输入,运行时可以直接附图。
- 若当前模型不支持视觉输入,运行时可以调用视觉 fallback 生成描述。
- 若用户或模型只是需要保存文件,不应强制读图。
这与当前 Nahida Bot 的用户附件链路一致,但与工具结果链路还没有打通。
5. 设计目标
5.1 功能目标
- 工具产出的图片可以被系统识别为媒体对象。
- 后续模型轮次可以按当前模型能力正确读取该图片。
- 支持视觉模型时,图片以原生 image part 进入请求。
- 不支持视觉模型时,复用
image_fallback_mode生成描述或提供工具。 image_understand能分析“当前用户附件”和“工具产出图片”。- 对 MCP image content、下载工具、生成图片工具提供统一接入方式。
5.2 非目标
- 不在每个工具里重复实现视觉分析。
- 不让模型仅凭路径字符串假装读过图片。
- 不把任意本地路径自动暴露给模型读取。
- 不绕过现有
MediaResolver的大小、MIME、URL 安全策略。 - 不要求所有工具一次性迁移,可以先兼容旧格式。
6. 方案对比
方案 A:工具自己调用视觉模型并返回描述
做法:
- 每个会下载图片的工具都自行调用 vision provider。
- 工具结果直接返回图片描述。
优点:
- 实现局部简单。
- 主模型无需多模态能力。
- 对现有 agent loop 改动少。
缺点:
- 每个工具都要重复一套图片解析、限流、大小校验、fallback 路由。
- 用户可能只是想下载图片,不一定想分析图片。
- 无法复用
image_understand。 - 难以支持“先下载,后续追问这张图”的会话体验。
- 工具层会侵入 provider 路由逻辑。
结论:
不推荐作为主方案。可以作为个别工具的临时兼容方式。
方案 B:工具返回路径,提示模型再调用 image_understand
做法:
- 工具继续返回路径或 URL。
- prompt 告诉模型:“如果需要看图片,请调用 image_understand。”
image_understand增加按路径读取图片的能力。
优点:
- 改动中等。
- 保留按需分析。
- 主模型可以控制是否分析。
缺点:
- 模型需要从 JSON 文本中理解哪个路径是图片。
image_understand如果接受任意 path,会引入本地文件访问边界问题。- 对视觉模型也不能自动原生传图。
- 工具产物没有统一生命周期和 media_id。
- 历史追问时容易丢失引用。
结论:
可以作为过渡方案,但不应让 image_understand 直接接受任意本地路径。更好的方式是接受受控 media_id。
方案 C:Agent loop 识别工具结果中的图片并注册为媒体对象
做法:
- 定义工具结果中的媒体描述格式。
AgentLoop或SessionRunner在工具执行后扫描结果。- 发现图片后注册到本轮或 session 的 media registry。
- 后续模型调用时按能力自动注入图片或描述。
优点:
- 与用户附件链路统一。
- 可以复用
MediaResolver。 - 可以复用
image_fallback_mode。 - 支持视觉模型原生读图。
- 支持非视觉模型 fallback 描述。
- 支持后续追问。
- 支持不同来源:path、url、base64、MCP image、生成图片。
缺点:
- 需要新增 media registry 或扩展现有 attachments 上下文。
- 需要定义工具媒体结果协议。
- 需要处理注入时机,避免每轮重复注入过多图片。
- 需要测试工具循环中的多模态上下文。
结论:
推荐主方案。
方案 D:把所有工具结果都转换为 ContextPart
做法:
- 工具执行结果不再只是文本,而是可以返回
ContextPart列表。 - tool message 可携带 text、image、file 等多种 part。
- provider 序列化时直接处理 tool role 的多模态 part。
优点:
- 抽象最完整。
- 对未来音频、视频、文件也更自然。
- 更接近 Responses API 的 item 模型。
缺点:
- 改动面大。
- 不同 provider 对
role=tool的图片支持并不一致。 - OpenAI-compatible/Anthropic/Responses 的 tool result 多模态格式差异较大。
- 很容易破坏当前稳定的 tool call round-trip。
结论:
长期可考虑,但不适合作为第一阶段实现。
7. 推荐方案
推荐采用方案 C:
工具结果继续以文本/JSON 形式回传给模型,但运行时额外识别并登记其中的图片媒体。下一轮模型调用时,SessionRunner 根据当前模型能力决定把这些媒体以原生图片、fallback 描述或
image_understand可引用媒体的方式注入。
这相当于引入一个“工具产出媒体注册层”:
tool call
-> tool result
-> parse media artifacts
-> register media_id
-> next provider call
-> image_input=true: attach image part
-> image_input=false + auto: attach description
-> image_input=false + tool: attach hint, image_understand(media_id)
-> off: only keep textual metadata8. 数据模型设计
8.1 ToolMediaArtifact
建议新增内部数据类:
@dataclass(slots=True, frozen=True)
class ToolMediaArtifact:
kind: str # image, audio, video, file
media_id: str
source_tool: str
path: str = ""
url: str = ""
base64_data: str = ""
mime_type: str = ""
file_size: int = 0
width: int = 0
height: int = 0
description: str = ""
metadata: dict[str, object] = field(default_factory=dict)第一阶段只实现 kind="image"。
8.2 工具结果推荐协议
工具可以返回如下结构:
{
"media": [
{
"kind": "image",
"path": "./data/temp/media/x.png",
"mime_type": "image/png",
"file_size": 12345,
"description": ""
}
],
"text": "Downloaded image to ./data/temp/media/x.png"
}也可以支持单个对象:
{
"kind": "image",
"path": "./data/temp/media/x.png",
"mime_type": "image/png"
}为了兼容旧工具,可以启发式识别:
path后缀为.png、.jpg、.jpeg、.webp。url的 content-type 或后缀看起来是图片。- MCP image content。
- OpenAI image generation result。
启发式识别应当保守,不能把任意本地路径都当成可读文件。
8.3 media_id 生成
建议格式:
tool:{tool_name}:{short_hash}例如:
tool:download_media:a1b2c3d4
tool:mcp_browser_screenshot:91ef32ab
tool:image_generation:77aa1010hash 输入可以包含:
- tool name
- path/url/base64 hash
- session id
- timestamp 或 monotonic sequence
需要避免泄露完整本地路径。
8.4 与 InboundAttachment 的关系
为了复用现有链路,第一阶段可以把 ToolMediaArtifact 转换为 InboundAttachment:
InboundAttachment(
kind="image",
platform_id=artifact.media_id,
url=artifact.url,
path=artifact.path,
mime_type=artifact.mime_type,
file_size=artifact.file_size,
width=artifact.width,
height=artifact.height,
alt_text=artifact.description,
metadata={
"source": "tool",
"source_tool": artifact.source_tool,
...
},
)这样可以直接复用:
_resolve_attachment()MediaResolver_build_vision_parts()_build_fallback_parts()_auto_describe_image()handle_image_understand_tool()
9. 上下文注入策略
9.1 注入范围
建议第一阶段只注入“最近一轮工具调用产出的图片”,避免历史图片反复进入上下文。
后续再扩展为:
- 最近 N 张工具图片。
- 用户明确引用的 media_id。
- 当前 conversation 中未消费的媒体。
- 被收藏/持久化的媒体。
9.2 视觉模型
如果当前模型 image_input=true:
- 自动把最近工具图片作为
ContextPart(type="image_base64")或image_url注入下一轮。 - 同时保留 tool result 的文本 JSON,方便模型知道图片来源、路径、文件名等。
- 遵守
max_image_count和max_image_bytes。
9.3 非视觉模型 + auto
如果当前模型 image_input=false 且 image_fallback_mode=auto:
- 对最近工具图片调用 fallback vision。
- 把描述作为
image_description注入。 - 描述中包含
media_id和来源工具摘要。
9.4 非视觉模型 + tool
如果当前模型 image_input=false 且 image_fallback_mode=tool:
注入文本提示:
text[Image produced by tool download_image: media_id=tool:download_image:a1b2c3d4. Use image_understand to analyze it.]image_understand必须能查到这些 tool media。
9.5 off
如果 image_fallback_mode=off:
- 不自动分析。
- 只保留 tool result 的文本输出。
- 可选:仍记录 media registry,供后续用户切换模式或显式命令使用。
10. 存储与生命周期
10.1 短期上下文
当前有 current_attachments ContextVar,用于本轮用户附件。
可以新增:
current_tool_media: ContextVar[tuple[InboundAttachment, ...]]或者扩展一个更通用的:
current_media_artifacts: ContextVar[tuple[InboundAttachment, ...]]第一阶段建议新增独立变量,降低对用户附件链路的影响。
10.2 会话历史
如果希望后续追问“刚才下载的那张图”,需要持久化媒体元数据。
可选方案:
- 写入 tool message metadata。
- 写入 assistant turn metadata。
- 写入单独的 media registry 表。
第一阶段建议:
- 在 tool message metadata 中记录
media_artifacts。 SessionRunner._find_attachment_in_history()扩展为也查 tool/assistant metadata。- 后续再考虑单独表。
10.3 缓存
继续复用 MediaResolver 和 MediaCache:
- URL 下载走 cache。
- 本地 path 读取后转 base64。
- max size、MIME 校验保持一致。
对于工具已经下载到本地的文件,不应二次复制,除非需要统一 TTL 管理。
11. 安全边界
11.1 本地路径
风险:
- 工具可能返回任意 path。
- 模型可能诱导工具返回敏感路径。
- 如果
image_understand接受 path,就可能成为本地文件读取通道。
建议:
image_understand不接受任意 path,只接受media_id。- 只有运行时登记过的媒体才能被分析。
- 对工具返回的 path 做 allowlist:
- workspace 目录
- configured media cache/temp 目录
- 工具声明的输出目录
- 不允许读取任意绝对路径,除非该工具被明确标记为可信。
11.2 URL
当前 MediaResolver 已经有 SSRF 防护:
- 只允许 http/https。
- 默认拒绝 localhost、private、loopback、link-local 等地址。
- 只有
attachment.metadata["trusted_url"]为真时才允许私有网络。
工具产出 URL 应继续走这个逻辑。
11.3 MIME 与大小
继续遵守:
multimodal.max_image_bytessupported_image_mime_types- 模型级
max_image_bytes - 模型级
max_image_count
11.4 prompt 注入
图片本身可能包含恶意 OCR 文本。
fallback vision 描述建议明确标注为“图片内容描述”,并在系统层避免把图中文字当作指令执行。
12. 实现路径
阶段 1:文档与协议冻结
目标:
- 确认工具媒体结果协议。
- 确认 media_id 格式。
- 确认注入策略。
产物:
- 本文档。
- 可选:更新
docs/guide/configuration.md,说明image_fallback_mode也适用于工具产出媒体。
阶段 2:内部媒体提取器
新增模块建议:
nahida_bot/agent/media/artifacts.py职责:
- 从工具结果
output中提取图片 artifact。 - 支持 dict、JSON string、list。
- 支持显式
media字段。 - 保守支持旧格式
path/url。 - 生成
media_id。 - 转换为
InboundAttachment。
建议 API:
def extract_media_artifacts(
output: object,
*,
tool_name: str,
session_id: str = "",
) -> list[InboundAttachment]:
...测试:
- 显式
media数组。 - 单个 image 对象。
- Telegram 旧格式 path。
- Milky 旧格式 url。
- 非图片 path 不提取。
- 不合法 JSON 不提取。
阶段 3:AgentLoop 记录工具媒体
修改 AgentLoop._build_tool_message():
从
ToolExecutionResult.output提取媒体 artifact。在 tool message metadata 中写入:
pythonmetadata={ "tool_call_id": ..., "tool_name": ..., "media_artifacts": [...], }
注意:
- 不改变现有
contentJSON,避免破坏模型看到的工具结果。 - 不在这里做图片解析或调用 vision。
- 只做结构化登记。
可能的问题:
AgentLoop当前不直接知道session_id。- media_id 生成可以先不依赖 session id。
- 或在
run_stream()参数中增加session_id,但这会扩大改动。
更低改动方式:
- media_id 基于 tool call id + tool name + path/url hash。
阶段 4:SessionRunner 从 tool messages 收集媒体
修改 SessionRunner.run_stream() 的 agent loop 调用与事件处理:
- 当前
AgentLoop内部生成 tool messages 并继续下一轮 provider call。 - 如果要让下一轮 provider call 立即看到工具图片,仅在
SessionRunner外层收集done事件已经太晚。
因此阶段 4 有两个选择:
选择 4A:在 AgentLoop 内部完成注入
需要让 AgentLoop 在工具执行后把 media artifact 转为下一轮 conversation 的额外 context。
问题:
AgentLoop不知道模型 capabilities。AgentLoop不知道MediaResolver。- 会让 loop 层变胖。
不推荐。
选择 4B:让 AgentLoop 暴露 hook,由 SessionRunner 提供工具媒体处理器
建议新增可选接口:
class ToolResultMediaHandler(Protocol):
async def build_context_messages(
self,
artifacts: list[InboundAttachment],
capabilities: ModelCapabilities | None,
) -> list[ContextMessage]:
...或者更简单:
AgentLoop.run_stream(..., tool_result_context_builder=callable)执行流程:
_execute_tools()得到 tool messages。- 从 metadata 提取
media_artifacts。 - 调用 hook 生成额外
ContextMessage。 - 把这些 context messages 加入 conversation,供下一轮 provider call 使用。
优点:
SessionRunner仍负责多模态策略。AgentLoop只负责在合适时机调用 hook。
推荐。
阶段 5:SessionRunner 实现工具媒体上下文构建
新增方法:
async def _build_tool_media_context_messages(
self,
artifacts: list[InboundAttachment],
*,
capabilities: ModelCapabilities | None,
) -> list[ContextMessage]:
...逻辑:
- 如果无 artifact,返回空。
- 如果
capabilities.image_input=true:- 调用
_build_vision_parts("", artifacts, ...)。 - 返回
ContextMessage(role="user", source="tool_media", content="", parts=parts)。
- 调用
- 如果
image_input=false:- 调用
_build_fallback_parts("", artifacts)。 - 返回
ContextMessage(role="user", source="tool_media", content=..., parts=parts)。
- 调用
需要注意:
role是否用user还是system要谨慎。- 图片 part 通常只有 user role 才被 provider 接受。
- 推荐用
role="user",source="tool_media",content 明确说明“以下图片来自工具结果,不是用户新消息”。
阶段 6:扩展 image_understand 查找范围
当前 handle_image_understand_tool() 通过 _find_attachment_in_history() 查当前附件和历史用户附件。
需要扩展:
- 当前工具媒体 registry。
- tool message metadata 中的
media_artifacts。 - assistant metadata 中可能保存的 generated images。
新增方法可以命名为:
async def _find_media_attachment(self, media_id: str) -> InboundAttachment | None:
...并让 _find_attachment_in_history() 变成它的子路径。
阶段 7:工具适配迁移
优先迁移:
- Telegram
download_media - Milky
milky_get_resource_temp_url - MCP
serialize_mcp_result()image content - OpenAI Responses image generation result
- 浏览器截图或网页下载类工具
迁移方式:
- 保留旧字段
path/url。 - 新增
media数组。
示例:
{
"path": "./data/temp/media/photo.png",
"file_name": "photo.png",
"file_size": 12345,
"media": [
{
"kind": "image",
"path": "./data/temp/media/photo.png",
"mime_type": "image/png",
"file_size": 12345
}
]
}阶段 8:测试
单元测试:
extract_media_artifacts()显式协议。extract_media_artifacts()旧工具格式。- 非图片不提取。
- 超大图片被
MediaResolver拒绝。 - unsupported MIME 被跳过。
image_understand可读取 tool media。
集成测试:
- 视觉模型:工具返回本地图片 path,下一轮 provider 收到
image_base64。 - 非视觉 + auto:工具返回图片,下一轮 provider 收到 image description。
- 非视觉 + tool:下一轮 provider 看到
image_understandhint,调用工具后得到描述。 - MCP image content 不再只变成占位文本,而是登记为 media artifact。
回归测试:
- 普通工具文本结果不受影响。
- 工具调用 JSON round-trip 不受影响。
- 用户附件图片链路不受影响。
- 模型
tool_calling=false时不暴露工具,但 auto fallback 仍可工作。
13. 建议的第一版最小实现
为了降低风险,第一版可以只做:
这个最小版本可以验证核心价值:
- 工具下载图片后,视觉模型能在下一轮真正看到图片。
- 非视觉模型能自动拿到 fallback 描述。
14. 开放问题
- 工具产出图片是否应默认自动注入,还是只有用户意图包含“看/分析/识别”时才注入?
- 一次工具调用返回多张图片时,默认传几张?
- 工具返回图片后,是否应立即触发 fallback vision,还是等主模型下一轮需要时再触发?
tool_media用role="user"是否会影响对话语义?是否需要 provider 层支持非用户多模态 context?- 是否需要单独的
media_registry数据库表? - 是否需要
/media list、/media clear等调试命令? - 图片生成结果应该作为 outbound attachment 发给用户,还是只作为上下文媒体?
15. 当前结论
当前系统已经有较完整的“用户附件图片”多模态处理能力,且 image_understand 可以读取当前/历史用户附件。但“工具产出图片”仍停留在文本结果层。后续推荐引入工具媒体 artifact 注册机制,把工具产出的图片统一转为受控 media_id 和 InboundAttachment,再复用现有 MediaResolver、ModelCapabilities.image_input、image_fallback_mode 和 image_understand。
这样可以保持架构边界清晰:
- 工具负责获取媒体。
- 运行时负责登记、校验、缓存和按模型能力注入。
- provider 负责序列化成上游 API 格式。
- 模型只根据自己真实获得的输入进行回答。