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 帧:单次转写,模型正确描述测试图卡的计数器递增与
彩虹带滚动
162 lines
4.2 KiB
Go
162 lines
4.2 KiB
Go
package sdk
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||
)
|
||
|
||
type llmImpl struct {
|
||
mgr *agentAPI.ProviderManager
|
||
cfgReg *internalConfig.ConfigRegistry
|
||
lua *luaVM.VM
|
||
baseAPIKey string
|
||
}
|
||
|
||
func NewLLM(mgr *agentAPI.ProviderManager, cfgReg *internalConfig.ConfigRegistry, lua *luaVM.VM, baseAPIKey string) LLMAPI {
|
||
return &llmImpl{mgr: mgr, cfgReg: cfgReg, lua: lua, baseAPIKey: baseAPIKey}
|
||
}
|
||
|
||
func (l *llmImpl) ListSources() []string {
|
||
if l.mgr == nil {
|
||
return nil
|
||
}
|
||
return l.mgr.List()
|
||
}
|
||
|
||
func (l *llmImpl) SetSource(name string) error {
|
||
if l.mgr == nil {
|
||
return nil
|
||
}
|
||
return l.mgr.SetDefault(name)
|
||
}
|
||
|
||
func (l *llmImpl) CurrentSource() string {
|
||
if l.mgr == nil {
|
||
return ""
|
||
}
|
||
p := l.mgr.Default()
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return p.Name()
|
||
}
|
||
|
||
func (l *llmImpl) Chat(ctx context.Context, req *LLMCompletionRequest) (*LLMCompletionResponse, error) {
|
||
if l.mgr == nil {
|
||
return nil, fmt.Errorf("llm: provider manager not available")
|
||
}
|
||
p := l.mgr.Default()
|
||
if p == nil {
|
||
return nil, fmt.Errorf("llm: no default provider")
|
||
}
|
||
apiReq := &agentAPI.CompletionRequest{
|
||
Model: req.Model,
|
||
Temperature: req.Temperature,
|
||
MaxTokens: req.MaxTokens,
|
||
Stream: req.Stream,
|
||
Tools: req.Tools,
|
||
ToolChoice: req.ToolChoice,
|
||
DisableThinking: req.DisableThinking,
|
||
}
|
||
if len(req.Messages) > 0 {
|
||
apiReq.Messages = make([]agentAPI.Message, len(req.Messages))
|
||
for i, m := range req.Messages {
|
||
msg := agentAPI.Message{
|
||
Role: m.Role,
|
||
Content: m.Content,
|
||
ReasoningContent: m.ReasoningContent,
|
||
ToolCallID: m.ToolCallID,
|
||
}
|
||
// 多模态 Blocks:text/image_url → agentAPI.ContentBlock
|
||
for _, b := range m.Blocks {
|
||
switch b.Type {
|
||
case "text":
|
||
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{Type: "text", Text: b.Text})
|
||
case "image_url":
|
||
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
|
||
Type: "image_url",
|
||
ImageURL: &agentAPI.ImageURL{URL: b.ImageURL, Detail: "high"},
|
||
})
|
||
}
|
||
}
|
||
if len(m.ToolCalls) > 0 {
|
||
msg.ToolCalls = make([]agentAPI.ToolCall, len(m.ToolCalls))
|
||
for j, tc := range m.ToolCalls {
|
||
msg.ToolCalls[j] = agentAPI.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
|
||
}
|
||
}
|
||
apiReq.Messages[i] = msg
|
||
}
|
||
}
|
||
resp, err := p.Chat(ctx, apiReq)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := &LLMCompletionResponse{
|
||
Content: resp.Content,
|
||
ReasoningContent: resp.ReasoningContent,
|
||
FinishReason: resp.FinishReason,
|
||
TokenUsage: LLMTokenUsage{
|
||
Prompt: resp.TokenUsage.Prompt,
|
||
Completion: resp.TokenUsage.Completion,
|
||
Total: resp.TokenUsage.Total,
|
||
},
|
||
}
|
||
for _, tc := range resp.ToolCalls {
|
||
out.ToolCalls = append(out.ToolCalls, LLMToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (l *llmImpl) ReloadFromConfig() error {
|
||
if l.mgr == nil || l.cfgReg == nil || l.lua == nil {
|
||
return nil
|
||
}
|
||
cfg := l.cfgReg.ToConfig()
|
||
if cfg == nil {
|
||
return nil
|
||
}
|
||
l.mgr.Reset()
|
||
adapterConcurrency := map[string]int{}
|
||
for _, src := range cfg.LLM.Sources {
|
||
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
|
||
continue
|
||
}
|
||
key := src.APIKey
|
||
if key == "" {
|
||
key = l.baseAPIKey
|
||
}
|
||
provider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||
Model: src.Model,
|
||
BaseURL: src.BaseURL,
|
||
APIKey: key,
|
||
Temperature: cfg.LLM.Temperature,
|
||
MaxTokens: cfg.LLM.MaxTokens,
|
||
ContextWindow: src.ContextWindow,
|
||
MaxConcurrent: src.MaxConcurrent,
|
||
Priority: src.Priority,
|
||
Vision: src.Vision,
|
||
Audio: src.Audio,
|
||
}, l.lua, src.Name, src.Adapter)
|
||
l.mgr.Register(src.Name, provider)
|
||
if src.Adapter != "" {
|
||
adapterConcurrency[src.Adapter] += src.MaxConcurrent
|
||
}
|
||
}
|
||
if l.lua != nil {
|
||
l.lua.ConfigureConcurrency(adapterConcurrency)
|
||
}
|
||
if cfg.LLM.Provider != "" {
|
||
if l.mgr.Get(cfg.LLM.Provider) != nil {
|
||
_ = l.mgr.SetDefault(cfg.LLM.Provider)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var _ LLMAPI = (*llmImpl)(nil)
|