mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
## 起因
生产盲测:模型调 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 帧:单次转写,模型正确描述测试图卡的计数器递增与
彩虹带滚动
336 lines
10 KiB
Go
336 lines
10 KiB
Go
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/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 {
|
||
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 ""
|
||
}
|