mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +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 帧:单次转写,模型正确描述测试图卡的计数器递增与
彩虹带滚动
174 lines
6.1 KiB
Go
174 lines
6.1 KiB
Go
package types
|
||
|
||
import "time"
|
||
|
||
type AgentState int
|
||
|
||
const (
|
||
AgentStateStopped AgentState = 0
|
||
AgentStateRunning AgentState = 1
|
||
AgentStateDegraded AgentState = 2
|
||
AgentStatePanic AgentState = 3
|
||
)
|
||
|
||
type HealthStatus int
|
||
|
||
const (
|
||
HealthUnknown HealthStatus = 0
|
||
HealthHealthy HealthStatus = 1
|
||
HealthUnstable HealthStatus = 2
|
||
HealthDown HealthStatus = 3
|
||
HealthDegraded HealthStatus = 4
|
||
)
|
||
|
||
type AgentID string
|
||
|
||
type SnapshotID string
|
||
|
||
type Snapshot struct {
|
||
ID SnapshotID `json:"id"`
|
||
AgentID AgentID `json:"agent_id"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
Reason string `json:"reason"`
|
||
Size int64 `json:"size_bytes"`
|
||
DockerImage string `json:"docker_image,omitempty"`
|
||
Valid bool `json:"valid"`
|
||
}
|
||
|
||
type Heartbeat struct {
|
||
AgentID AgentID `json:"agent_id"`
|
||
Timestamp time.Time `json:"timestamp"`
|
||
State AgentState `json:"state"`
|
||
Health HealthStatus `json:"health"`
|
||
Uptime time.Duration `json:"uptime"`
|
||
LLMConnected bool `json:"llm_connected"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type NetworkCheckResult struct {
|
||
LLMAPIReachable bool `json:"llm_api_reachable"`
|
||
EndpointsConfigured bool `json:"endpoints_configured"`
|
||
DNSResolving bool `json:"dns_resolving"`
|
||
TCPReachable bool `json:"tcp_reachable"`
|
||
Latency time.Duration `json:"latency_ms"`
|
||
LatencyDegraded bool `json:"latency_degraded"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type SnapshotPolicy struct {
|
||
Interval time.Duration `json:"interval"`
|
||
MaxSnapshots int `json:"max_snapshots"`
|
||
PreAction bool `json:"pre_action"`
|
||
PostAction bool `json:"post_action"`
|
||
}
|
||
|
||
type RollbackPolicy struct {
|
||
MaxRetries int `json:"max_retries"`
|
||
HealthThreshold HealthStatus `json:"health_threshold"`
|
||
CooldownPeriod time.Duration `json:"cooldown_period"`
|
||
AutoRollback bool `json:"auto_rollback"`
|
||
}
|
||
|
||
type AgentConfig struct {
|
||
ID AgentID `json:"id"`
|
||
Image string `json:"image"`
|
||
Name string `json:"name"`
|
||
LLMEndpoints []string `json:"llm_endpoints"`
|
||
SnapshotPolicy SnapshotPolicy `json:"snapshot_policy"`
|
||
RollbackPolicy RollbackPolicy `json:"rollback_policy"`
|
||
ResourceLimit ResourceLimit `json:"resource_limit"`
|
||
OpenClawEnabled bool `json:"openclaw_enabled"`
|
||
}
|
||
|
||
type ResourceLimit struct {
|
||
CPU string `json:"cpu"`
|
||
Memory string `json:"memory"`
|
||
Disk string `json:"disk"`
|
||
Network bool `json:"network"`
|
||
}
|
||
|
||
type OperationLog struct {
|
||
ID string `json:"id"`
|
||
AgentID AgentID `json:"agent_id"`
|
||
Timestamp time.Time `json:"timestamp"`
|
||
Action string `json:"action"`
|
||
SnapshotID SnapshotID `json:"snapshot_id,omitempty"`
|
||
Success bool `json:"success"`
|
||
}
|
||
|
||
type LLMSource struct {
|
||
Name string `json:"name"`
|
||
BaseURL string `json:"base_url"`
|
||
Model string `json:"model"`
|
||
APIKey string `json:"api_key,omitempty"`
|
||
Adapter string `json:"adapter"`
|
||
AdapterPath string `json:"adapter_path,omitempty"`
|
||
ContextWindow int `json:"context_window,omitempty"`
|
||
MaxConcurrent int `json:"max_concurrent,omitempty"`
|
||
Priority int `json:"priority,omitempty"`
|
||
ThinkingEnabled bool `json:"thinking_enabled,omitempty"`
|
||
|
||
// Vision/Audio 声明该源能否真正处理多模态内容块。
|
||
//
|
||
// 为何必须显式声明而不是探测:网关(如 llmsproxy)会把 image_url 块静默剥离后
|
||
// 转发给纯文本上游,请求依然 200,带图与不带图的 prompt_tokens 完全相同。
|
||
// 模型于是回答「我没有看到图片」,而内核以为注入成功——这正是 v1.0.0 之前
|
||
// output_send 假成功的同一类缺陷:告诉调用方成功而实际未送达。
|
||
// 探测需要额外一次真实调用且结果不稳定(取决于 AUTO 路由到哪个上游),
|
||
// 因此改为部署时声明。留空(false)按不支持处理,走文字回退链。
|
||
Vision bool `json:"vision,omitempty"`
|
||
Audio bool `json:"audio,omitempty"`
|
||
}
|
||
|
||
type LLMConfig struct {
|
||
Provider string `json:"provider"`
|
||
Model string `json:"model"`
|
||
BaseURL string `json:"base_url"`
|
||
APIKey string `json:"api_key"`
|
||
Adapter string `json:"adapter"`
|
||
Temperature float64 `json:"temperature"`
|
||
MaxTokens int `json:"max_tokens"`
|
||
ContextWindow int `json:"context_window,omitempty"`
|
||
ThinkingEnabled bool `json:"thinking_enabled"`
|
||
Sources []LLMSource `json:"sources,omitempty"`
|
||
}
|
||
|
||
type ImageProcessingConfig struct {
|
||
FallbackProvider string `json:"fallback_provider" yaml:"fallback_provider"`
|
||
FallbackModel string `json:"fallback_model" yaml:"fallback_model"`
|
||
DescribePrompt string `json:"describe_prompt" yaml:"describe_prompt"`
|
||
OCREnabled bool `json:"ocr_enabled" yaml:"ocr_enabled"`
|
||
OCRPrompt string `json:"ocr_prompt" yaml:"ocr_prompt"`
|
||
}
|
||
|
||
type AudioProcessingConfig struct {
|
||
FallbackProvider string `json:"fallback_provider" yaml:"fallback_provider"`
|
||
FallbackModel string `json:"fallback_model" yaml:"fallback_model"`
|
||
DescribePrompt string `json:"describe_prompt" yaml:"describe_prompt"`
|
||
}
|
||
|
||
type InputProcessingConfig struct {
|
||
Image ImageProcessingConfig `json:"image" yaml:"image"`
|
||
Audio AudioProcessingConfig `json:"audio" yaml:"audio"`
|
||
}
|
||
|
||
type PluginDirConfig struct {
|
||
Dir string `json:"dir"`
|
||
}
|
||
|
||
type Config struct {
|
||
Daemon DaemonConfig `json:"daemon"`
|
||
LLM LLMConfig `json:"llm"`
|
||
Plugin PluginDirConfig `json:"plugin"`
|
||
InputProcessing InputProcessingConfig `json:"input_processing"`
|
||
Defaults AgentConfig `json:"defaults"`
|
||
Agents []AgentConfig `json:"agents"`
|
||
}
|
||
|
||
type DaemonConfig struct {
|
||
DataDir string `json:"data_dir"`
|
||
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
|
||
CheckInterval time.Duration `json:"check_interval"`
|
||
LogLevel string `json:"log_level"`
|
||
}
|