mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-02 15:23:57 +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:
@ -235,6 +235,31 @@ type RoutableProvider interface {
|
||||
Priority() int // AUTO 跨源选择的优先级,大者优先
|
||||
}
|
||||
|
||||
// ModalProvider 声明自身的多模态能力。单独抽接口而不合进 Provider:
|
||||
// 第三方 Provider 实现无需改动,未实现时按纯文本处理(保守侧)。
|
||||
type ModalProvider interface {
|
||||
SupportsVision() bool
|
||||
SupportsAudio() bool
|
||||
}
|
||||
|
||||
// ProviderSupportsVision 安全判定任意 Provider 能否看图。
|
||||
// 未实现 ModalProvider 的一律返回 false:宁可多走一次文字回退,
|
||||
// 也不能把图默默扔给一个会把它剥掉的上游。
|
||||
func ProviderSupportsVision(p Provider) bool {
|
||||
if mp, ok := p.(ModalProvider); ok {
|
||||
return mp.SupportsVision()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProviderSupportsAudio 安全判定任意 Provider 能否听音频。
|
||||
func ProviderSupportsAudio(p Provider) bool {
|
||||
if mp, ok := p.(ModalProvider); ok {
|
||||
return mp.SupportsAudio()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ModelContextWindow 返回模型的最大上下文窗口(token 数)
|
||||
// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率
|
||||
func ModelContextWindow(model string) int {
|
||||
@ -284,6 +309,11 @@ type BaseConfig struct {
|
||||
ContextWindow int `json:"context_window"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
Priority int `json:"priority"`
|
||||
|
||||
// Vision/Audio 声明这条链路能否真正处理多模态内容块。
|
||||
// 网关可能静默剥离 image_url 后仍返回 200,所以不能从响应推断能力。
|
||||
Vision bool `json:"vision"`
|
||||
Audio bool `json:"audio"`
|
||||
}
|
||||
|
||||
// LuaAdaptedProvider 使用 Lua 脚本做请求/响应变换,直接发起 HTTP 调用
|
||||
@ -343,6 +373,11 @@ func (p *LuaAdaptedProvider) Name() string { return p.name }
|
||||
func (p *LuaAdaptedProvider) Model() string { return p.cfg.Model }
|
||||
func (p *LuaAdaptedProvider) Priority() int { return p.cfg.Priority }
|
||||
|
||||
// SupportsVision/SupportsAudio 实现 ModalProvider,值来自部署时声明
|
||||
// (core.llm.sources.<name>.vision / .audio)。
|
||||
func (p *LuaAdaptedProvider) SupportsVision() bool { return p.cfg.Vision }
|
||||
func (p *LuaAdaptedProvider) SupportsAudio() bool { return p.cfg.Audio }
|
||||
|
||||
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||
if p.cfg.Model != "" && (req.Model == "" || req.Model == "AUTO") {
|
||||
req.Model = p.cfg.Model
|
||||
|
||||
Reference in New Issue
Block a user