fix(multimodal): 媒体改挂独立 user message,落实「注入后续对话」的原意

插件三个工具的返回文案一直写着「已将图片注入后续对话」,b777322 的提交
说明也写着「模型在下一轮 LLM 请求里直接看到图」。但实现是把 block 挂在
tool message 的 content 数组上——role=tool 上的多模态 content 不被当作
可视内容。

同一张图、同一个模型、三轮实测:

  图在 user message        → 3/3 读到,prompt_tokens 7089
  图在 tool message        → 0/3(模型答「我没能读到这张图」),tokens 7967
  tool 纯文本 + 后接 user  → 3/3 读到,tokens 7570

tool message 那轮 token 反而更高,说明 base64 确实进了上游,只是模型看
不到它。这解释了为什么此前只有回退链(转文字进 tool message 的纯文本
content)能用,而「主模型直接看图」这条路从 b777322 起就没通过——当时
的验证只看了 prompt_tokens 涨了 8500,没有校验模型答案对不对。

改为:tool message 保持纯文本结果,媒体另起一条紧随其后的 user message
承载,并在首个 text 块标注 [以下是 <tool> 注入的媒体内容],避免模型误
以为是用户新发的图。位置必须紧跟 toolMsg,中间插入其他消息会让
tool_call_id 配对断开。

验证(生产,答案预先封存、生成时不读):
- AUTO 源 vision=true 直视路径:随机三色带 → 答「紫、蓝、黄」,与封存
  答案一致,日志无 modal fallback(确实走的直视),耗时 9.6s
  (回退链同一用例需 ~90s,省掉了绕视觉模型一圈)
- see_video 6 帧直视:23s(回退链合包版 131s,逐块版 363s),模型正确
  描述测试图卡的彩条布局、彩虹带滚动与计数器递增
- 负向:AUTO 源改回 vision=false,回退链仍正常转写,模型如实标注来源
This commit is contained in:
JianFeeeee
2026-09-04 07:49:30 +08:00
parent 09071dc235
commit 3907347cac

View File

@ -313,13 +313,21 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: msgContent, ReasoningContent: resp.ReasoningContent, ToolCalls: []agentAPI.ToolCall{tc}})
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block
// process.go 拾起并追加到 tool message 的 content 数组OpenAI 多模态格式),
// 让下一轮 LLM 请求在 tool message 里看到图/音频。
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block
//
// 主模型不支持该模态时不能直接塞:网关会把 image_url 静默剥离后仍返回 200
// 模型回答「我没有看到图片」而内核以为注入成功。改走回退链转写成文字
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
// 这也是插件文案一直在说的「注入后续对话」
// 为何不能挂 tool message同一张图、同一模型、三轮实测——
// 图在 user message → 3/3 读到
// 图在 tool message → 0/3模型答「没能读到这张图」
// tool 纯文本 + 后接 user → 3/3 读到
// tool message 那轮 prompt_tokens 反而更高7967 vs 7089base64 确实
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
//
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
// 返回 200模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
var mediaMsg *agentAPI.Message
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
var blocks []agentAPI.ContentBlock
for _, b := range rawBlocks {
@ -337,10 +345,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
if len(blocks) > 0 {
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
toolMsg.Blocks = native
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
mediaBlocks := append([]agentAPI.ContentBlock{{
Type: "text",
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
}}, native...)
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
} else if fallbackText != "" {
// 回退链已把媒体转写成文字:并进 tool message 的纯文本 content
// 不再挂 Blocks挂了也会被上游剥掉)。
// 不再另起消息(文字在 tool message 里本来就能被读到)。
toolMsg.Content = result + "\n\n" + fallbackText
result = toolMsg.Content
if len(toolResults) > 0 {
@ -350,6 +364,10 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
}
msgs = append(msgs, toolMsg)
if mediaMsg != nil {
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
msgs = append(msgs, *mediaMsg)
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,