mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
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:
@ -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/N:fps 是**频率**(每 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) {
|
||||
|
||||
Reference in New Issue
Block a user