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 帧:单次转写,模型正确描述测试图卡的计数器递增与
  彩虹带滚动
This commit is contained in:
JianFeeeee
2026-09-04 06:25:51 +08:00
parent 9b92a04230
commit 09071dc235
9 changed files with 935 additions and 22 deletions

View File

@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -61,7 +62,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
"frames": map[string]interface{}{
"type": "integer",
"description": "提取关键帧数量(默认 4最大 10",
"description": "均匀抽取的关键帧数量(默认 4最大 10。按视频总时长均分,不是每几秒一帧。",
},
},
"required": []string{"path"},
@ -175,6 +176,21 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error
}
}
// 用 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 {
@ -183,36 +199,46 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error
defer os.RemoveAll(tmpDir)
outPattern := filepath.Join(tmpDir, "frame_%03d.jpg")
cmd := exec.Command(ffmpegPath, "-i", path, "-vf", fmt.Sprintf("fps=1/%d", nFrames),
"-q:v", "5", outPattern)
// -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
for i, entry := range entries {
if strings.HasSuffix(entry.Name(), ".jpg") {
b, err := os.ReadFile(filepath.Join(tmpDir, entry.Name()))
if err != nil {
continue
}
if len(b) > 2*1024*1024 {
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 i >= 9 { // 最多 10 帧
break
}
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
}
@ -220,9 +246,39 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error
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) {