Files
HomeAgent/internal/plugins/multimodal/plugin.go
JianFeeeee 09071dc235 fix(multimodal): 修多模态假成功 + 落地视觉回退链 + see_video 帧数语义
## 起因

生产盲测:模型调 multimodal_see_picture 后声称看到了图,实际一个字
都没收到。工具却返回「[已将图片注入后续对话]」。

链路:core.llm.model=AUTO → llmsproxy 按优先级选 big-pickle(prio=100)
→ 转 opencode zen。llmsproxy 的 opencode.lua 明写着:

    -- zen 上游 schema 只接受 text content part(无视觉/音频能力)
    if part.type ~= nil and part.type ~= "text" then  -- 丢弃

判据:256x256 纯红 PNG,带图与不带图的 prompt_tokens 都是 256。
图片贡献零 token,即根本没进上游。

内核序列化与注入链本身是对的(Message.MarshalJSON 正确产出 content
数组,SetToolBlocks → IOManager → ConsumeToolBlocks → toolMsg.Blocks
全通)。缺的是「主模型能否消费这些块」这一判断——内核此前完全没有
多模态能力的概念(grep supportsVision|multimodal 在 agent/ 零命中)。

这与 v1.0.0 修的 output_send 假成功同类:告诉调用方成功而实际未送达。

## 1. 能力声明

新增 core.llm.sources.<name>.vision / .audio(走既有 sourceFieldDefs,
WebUI 配置页自动出现),types.LLMSource 与 api.BaseConfig 同步加字段。

新增 agentAPI.ModalProvider 接口 + ProviderSupportsVision/Audio 判定:
未实现该接口的 provider 一律按不支持处理。保守侧是刻意的——宁可多走
一次文字回退,也不能把图默默扔给会剥掉它的上游。

为何是声明而非探测:探测需额外真实调用且结果不稳定(取决于 AUTO 当次
路由到哪);而 200 响应 + 相同 token 数从响应侧无法区分「看到了但没
内容」和「被剥掉了」。

## 2. 回退链(modalfallback.go)

实现了 config/registry.go 里注册但从未被读取的 image/audio
fallback_provider + fallback_model(此前 0 处读取点)。

prepareToolBlocks 在 process.go 注入前判定:能直视就原样透传;不能就
调声明了该能力的源转写成文字,带 [由 X 转写,非当前模型直接感知] 标注。

几处刻意的设计:
- 逐模态判定,不一刀切。很多视觉模型能看图但听不到音频,全部降级会
  白白把可直视的图变成二手描述
- 混合场景下转写文字作为 text 块并入 native,两部分同时到达模型
- 配置指向未声明能力的源时拒绝并继续找——照用只会重演静默剥离
- 未配 fallback_provider 但某源声明了 vision 时自动扫出来用;静默失败
  比多找一个能用的源更糟
- 空回复算失败。上游剥掉媒体后模型往往回「我没看到图片」或空串,两种
  都说明回退链也没真看到
- 多媒体块按模态合包为一次请求(见下)

## 3. 批量合包(生产实测驱动的返工)

首版逐块调用,生产 see_video 6 帧实测:4 帧里 3 帧超时,整轮 363 秒。
改为按模态合包一次请求后同一用例 131 秒、6/6 成功。

顺带把 modalFallbackTimeout 从 90s 提到 180s:生产经网关转
claude-opus-5 看一张 400x400 图要 ~81s,90s 贴着上限。
多张时 detail 默认 low 控体积,单张用 high 看细节;插件显式给了
detail 则尊重它。

## 4. see_video 帧数语义

fps=1/N 是频率(每 N 秒一帧)不是数量。20s 视频实测:
frames=4 → 5 帧、frames=10 → 2 帧、frames=1 → 20 帧,要得越多拿得越少;
长视频下 frames=4 会产出 时长/4 帧,靠 i>=9 的 break 兜着才没炸上下文,
而那个 break 用的是 ReadDir 索引,跳过条目后与实际帧数错位。

改为 ffprobe 取时长 → fps=N/时长 + -frames:v N 硬封顶。
0.4s/3s/20s/120s × frames=1/2/4/7/10 全部精确。

极短视频的坑:fps=1 在 0.4s 素材上产出 0 帧(不足一秒抽不出),所以
时长探测失败时不能退化成 fps=1,改为不传 -vf 只靠 -frames:v。

## 验证

- modalfallback_test.go 14 例:直视透传 / 回退转写 / 无源如实报告 /
  未实现接口按不支持 / 混合模态拆分 / 空回复算失败 / 块数上限 /
  多图合一次调用 / detail 策略 / 拒绝未声明能力的源 / 未配置时自动扫源
- go test ./... 全绿,go vet 无警告
- 生产盲测(答案预先封存、生成时不读):随机三色带 → 模型答
  「紫、蓝、红」,与封存答案完全一致
- 负向验证:拿掉回退源后模型如实回答「没看到图片内容」并引用工具返回
  的配置提示,且主动纠正了上一轮的答案
- 生产 see_video 6 帧:单次转写,模型正确描述测试图卡的计数器递增与
  彩虹带滚动
2026-09-04 06:25:51 +08:00

336 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package multimodal
import (
"encoding/base64"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func init() {
plugin.RegisterPluginMeta("multimodal", "多模态感知", "Multimodal Perception")
plugin.RegisterFactory("multimodal", NewPluginFactory)
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
tp := p.name + "_"
s.RegisterTool(tp+"see_picture", sdk.ToolDef{
Name: tp + "see_picture",
Description: "让模型看到一张图片。输入文件路径或 URL图片以 image_url 格式注入后续对话,模型可看到并描述/分析图片内容。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "图片的本地文件路径或 HTTP URL",
},
},
"required": []string{"path"},
},
}, p.handleSeePicture)
s.RegisterTool(tp+"see_video", sdk.ToolDef{
Name: tp + "see_video",
Description: "让模型看到一段视频的关键帧。输入视频文件路径ffmpeg 提取 N 帧作为 image_url 注入后续对话,模型可分析视频内容。需要 ffmpeg 已安装。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "视频的本地文件路径",
},
"frames": map[string]interface{}{
"type": "integer",
"description": "均匀抽取的关键帧数量(默认 4最大 10。按视频总时长均分不是每几秒一帧。",
},
},
"required": []string{"path"},
},
}, p.handleSeeVideo)
s.RegisterTool(tp+"listen", sdk.ToolDef{
Name: tp + "listen",
Description: "让模型听到一段音频。输入音频文件路径mp3/wav/ogg/m4a音频注入后续对话支持音频的模型可识别语音/声音内容。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "音频文件路径",
},
},
"required": []string{"path"},
},
}, p.handleListen)
log.Printf("[%s] multimodal perception tools registered", p.name)
return nil
}
func (p *Plugin) Stop() error { return nil }
// ── see_picture ──────────────────────────────────────────────────
func (p *Plugin) handleSeePicture(args map[string]interface{}) (interface{}, error) {
path := getArgStr(args, "path")
if path == "" {
return "path is required", nil
}
var dataURL string
var mime string
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
// 远程 URL直接用作 image URL不下载
dataURL = path
mime = "image/png"
} else {
// 本地文件
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Sprintf("文件不存在: %s", path), nil
}
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".jpg", ".jpeg":
mime = "image/jpeg"
case ".gif":
mime = "image/gif"
case ".webp":
mime = "image/webp"
default:
mime = "image/png"
}
b, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("读取文件失败: %v", err), nil
}
// 检查大小上限3MB防止 context 爆炸)
if len(b) > 3*1024*1024 {
return fmt.Sprintf("图片过大(%d bytes超过 3MB无法注入上下文", len(b)), nil
}
dataURL = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
}
// 注入多模态块:模型下一轮可看到图片
p.sdk.SetToolBlocks([]pubsdk.ContentBlock{
{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: dataURL, Detail: "auto"}},
})
text := fmt.Sprintf("[已将图片注入后续对话] %s", path)
return text, nil
}
// ── see_video ────────────────────────────────────────────────────
func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error) {
path := getArgStr(args, "path")
if path == "" {
return "path is required", nil
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Sprintf("文件不存在: %s", path), nil
}
// 检查 ffmpeg
ffmpegPath := ""
for _, c := range []string{"ffmpeg", "/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg"} {
if _, err := os.Stat(c); err == nil {
ffmpegPath = c
break
}
}
if ffmpegPath == "" {
if _, err := exec.LookPath("ffmpeg"); err == nil {
ffmpegPath = "ffmpeg"
} else {
return "ffmpeg 未安装,无法提取视频关键帧。请先安装: apt install ffmpeg", nil
}
}
nFrames := 4
if n, ok := args["frames"].(float64); ok && n > 0 {
nFrames = int(n)
if nFrames > 10 {
nFrames = 10
}
}
// 用 ffprobe 拿时长,才能把「抽 N 帧」翻译成 ffmpeg 的帧率。
//
// 为何不能直接写 fps=1/Nfps 是**频率**(每 N 秒一帧),不是**数量**。
// 20 秒视频实测fps=1/4 → 5 帧fps=1/10 → 2 帧fps=1/1 → 20 帧——
// 要得越多拿得越少,且长视频下 frames=4 会产出时长/4 帧直接炸上下文。
// 正确写法是 fps=N/时长 配 -frames:v N实测 N=1/4/10 均精确)。
dur := probeDuration(ffmpegPath, path)
var vfArgs []string
if dur > 0 {
vfArgs = []string{"-vf", fmt.Sprintf("fps=%d/%.3f", nFrames, dur)}
}
// 拿不到时长(无 ffprobe / 容器无时长元数据):不传 -vf只靠 -frames:v
// 取开头 N 帧。不能退化成 fps=1不足 1 秒的素材一帧也抽不出来(实测
// 0.4s 视频 fps=1 → 0 帧),而 fps=N/dur 在 0.4s 上依然精确。
// 用 ffmpeg 提取关键帧
tmpDir, err := os.MkdirTemp("", "mm_video_*")
if err != nil {
return fmt.Sprintf("创建临时目录失败: %v", err), nil
}
defer os.RemoveAll(tmpDir)
outPattern := filepath.Join(tmpDir, "frame_%03d.jpg")
// -frames:v 硬封顶:即使 fps 计算因时长误差多给了帧,也不会超出请求数量。
ffArgs := []string{"-v", "error", "-i", path}
ffArgs = append(ffArgs, vfArgs...)
ffArgs = append(ffArgs, "-q:v", "5", "-frames:v", strconv.Itoa(nFrames), outPattern)
cmd := exec.Command(ffmpegPath, ffArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Sprintf("ffmpeg 提取帧失败: %v\n%s", err, string(out)), nil
}
// 读取提取的帧。按 blocks 长度而非目录索引封顶:
// 跳过的条目(非 jpg / 读失败 / 过大)会让索引与实际帧数错位。
entries, _ := os.ReadDir(tmpDir)
var blocks []pubsdk.ContentBlock
var skippedLarge int
for _, entry := range entries {
if len(blocks) >= nFrames {
break
}
if !strings.HasSuffix(entry.Name(), ".jpg") {
continue
}
b, err := os.ReadFile(filepath.Join(tmpDir, entry.Name()))
if err != nil {
continue
}
if len(b) > 2*1024*1024 {
skippedLarge++
continue
}
dURL := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(b)
blocks = append(blocks, pubsdk.ContentBlock{
Type: "image_url",
ImageURL: &pubsdk.ImageURL{URL: dURL, Detail: "low"},
})
}
if len(blocks) == 0 {
if skippedLarge > 0 {
return fmt.Sprintf("提取到 %d 帧但全部超过 2MB 单帧上限,未注入", skippedLarge), nil
}
return "视频中未提取到有效帧", nil
}
// 全部帧注入(一次 SetToolBlocks 调用,下一轮 LLM 可看到)
p.sdk.SetToolBlocks(blocks)
text := fmt.Sprintf("[已将 %d 个视频关键帧注入后续对话] %s", len(blocks), path)
if skippedLarge > 0 {
text += fmt.Sprintf("(另有 %d 帧超 2MB 已跳过)", skippedLarge)
}
if len(blocks) < nFrames {
text += fmt.Sprintf("(请求 %d 帧,实际只取到 %d 帧,视频可能过短)", nFrames, len(blocks))
}
return text, nil
}
// probeDuration 用 ffprobe 取视频时长(秒),拿不到返回 0。
//
// ffprobe 与 ffmpeg 同包同目录,所以从已找到的 ffmpeg 路径推导而非重新搜一遍。
func probeDuration(ffmpegPath, videoPath string) float64 {
probe := "ffprobe"
if strings.Contains(ffmpegPath, "/") {
probe = filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
if _, err := os.Stat(probe); err != nil {
probe = "ffprobe"
}
}
out, err := exec.Command(probe, "-v", "error",
"-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", videoPath).Output()
if err != nil {
return 0
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil {
return 0
}
return d
}
// ── listen ───────────────────────────────────────────────────────
func (p *Plugin) handleListen(args map[string]interface{}) (interface{}, error) {
path := getArgStr(args, "path")
if path == "" {
return "path is required", nil
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Sprintf("文件不存在: %s", path), nil
}
ext := strings.ToLower(filepath.Ext(path))
var mime string
switch ext {
case ".mp3":
mime = "audio/mpeg"
case ".wav":
mime = "audio/wav"
case ".ogg":
mime = "audio/ogg"
case ".m4a", ".aac":
mime = "audio/mp4"
default:
mime = "audio/ogg" // 默认
}
// 检查大小5MB 限制,避免上下文爆炸)
info, _ := os.Stat(path)
if info != nil && info.Size() > 5*1024*1024 {
return fmt.Sprintf("音频过大(%d bytes超过 5MB无法注入上下文", info.Size()), nil
}
b, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("读取音频文件失败: %v", err), nil
}
dataURL := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
p.sdk.SetToolBlocks([]pubsdk.ContentBlock{
{Type: "audio_url", AudioURL: &pubsdk.AudioURL{URL: dataURL}},
})
text := fmt.Sprintf("[已将音频注入后续对话] %s%s%.1fKB", path, mime, float64(len(b))/1024)
return text, nil
}
// ── helpers ──────────────────────────────────────────────────────
func getArgStr(args map[string]interface{}, key string) string {
if v, ok := args[key].(string); ok {
return v
}
return ""
}