From 3907347cac57d674e5fc044f3e352d1f11229cca Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 07:49:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(multimodal):=20=E5=AA=92=E4=BD=93=E6=94=B9?= =?UTF-8?q?=E6=8C=82=E7=8B=AC=E7=AB=8B=20user=20message=EF=BC=8C=E8=90=BD?= =?UTF-8?q?=E5=AE=9E=E3=80=8C=E6=B3=A8=E5=85=A5=E5=90=8E=E7=BB=AD=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E3=80=8D=E7=9A=84=E5=8E=9F=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件三个工具的返回文案一直写着「已将图片注入后续对话」,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 块标注 [以下是 注入的媒体内容],避免模型误 以为是用户新发的图。位置必须紧跟 toolMsg,中间插入其他消息会让 tool_call_id 配对断开。 验证(生产,答案预先封存、生成时不读): - AUTO 源 vision=true 直视路径:随机三色带 → 答「紫、蓝、黄」,与封存 答案一致,日志无 modal fallback(确实走的直视),耗时 9.6s (回退链同一用例需 ~90s,省掉了绕视觉模型一圈) - see_video 6 帧直视:23s(回退链合包版 131s,逐块版 363s),模型正确 描述测试图卡的彩条布局、彩虹带滚动与计数器递增 - 负向:AUTO 源改回 vision=false,回退链仍正常转写,模型如实标注来源 --- internal/agent/core/process.go | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 7d8041a..1528b4b 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -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 7089),base64 确实 + // 进了上游,但 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,