mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
fix(media): 支持 webp 解码 + 向量迁移失败必须留下原因
## 生产现状(2026-10-23 排查)
启动日志只有一行,没有任何细节:
[media] 启动向量迁移: 4 个图像块 → fp=cd2a495cf990 dim=512 workers=4
[media] 向量迁移完成: 成功=0 失败=4 总计=4
反查 `memory/graph.db` 才找到根因:
| mime | 块数 | fingerprint |
|---|---|---|
| image/jpeg | 153 | 当前 fp |
| image/png | 394 | 当前 fp |
| **image/webp** | **4** | **空**(从未嵌入成功) |
而 `providers/chineseclip/image.go` 只注册了 gif/jpeg/png:
_ "image/gif"
_ "image/jpeg"
_ "image/png"
⇒ `image.Decode` 对 webp 返回 `image: unknown format` ⇒ 这 4 个块
**永远**拿不到向量 ⇒ 语义检索永远搜不到它们,且没有任何报错。
## 两个独立缺陷
### ① 功能缺口:webp 不可解码
加 `_ "golang.org/x/image/webp"`。这是纯 Go 实现、无 cgo,不改变二进制形态。
代价:`x/image v0.25.0` 硬性要求 `x/text >= v0.23.0`,因此 `x/text` 从
`v0.3.8` 升到 `v0.23.0`。已实测 `providers/chineseclip` 全绿(分词器用
`x/text/unicode/norm` 做 NFC 归一化,升级未改变行为)。
### ② 可观测性缺口:失败被 `continue` 吞掉
原实现:
if err := a.reembedOneBlock(b, fp); err != nil {
atomic.AddInt64(&failed, 1)
continue ← 原因就此消失
}
现在按 `mime | error` 聚合后打印(不逐条打,上千张图会刷屏):
[media] 向量迁移完成: 成功=0 失败=0 不支持=4 总计=4 fp=cd2a495cf990
[media] 失败原因分类(按 mime | error 聚合):
[media] 4 个: image/webp | chineseclip: 解码图像: image: unknown format
同时区分 `ErrModalityUnsupported`(**永久**无向量,重试无意义)与临时失败,
单独计为 `不支持=N` —— 混在"失败"里会让人以为重试就能恢复。
## 判据(4 条,全部变异自证)
| 判据 | 覆盖 | 变异 |
|---|---|---|
| `TestWebpIsDecodable` | webp 能被 `image.Decode` 识别 | 注释掉 webp 注册 ⇒ 红并指名「image.go 里缺少 ...」 |
| `TestWebpPreprocessProducesTensor` | webp 能走完整预处理(缩放+归一化),不只解码 | 同上 ⇒ 红 |
| `TestPngStillDecodable` | 注册表改动没弄坏 png | — |
| `TestReembedStaleMedia_LogsFailureReasons` | 失败原因(mime + 错误原文 + 计数)必须进日志 | 删掉打印 ⇒ 红 |
| `TestReembedStaleMedia_SeparatesUnsupportedFromFailed` | 不支持模态单独计数 | — |
webp 夹具用 ffmpeg 生成的真实 2×2 webp(44 字节)。手写字节序列构造不出
合法容器——第一版手写就失败了,这是实测教训。
## 验证
- `providers/chineseclip` / `internal/agent/core` / `internal/memory/vector` 全绿
- `go build ./...` / `go vet ./...` 干净
- 全量回归:见提交后 CI
This commit is contained in:
3
go.mod
3
go.mod
@ -18,7 +18,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.3.8
|
||||
golang.org/x/text v0.23.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -41,6 +41,7 @@ require (
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/image v0.25.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk
|
||||
|
||||
4
go.sum
4
go.sum
@ -56,12 +56,16 @@ github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose"
|
||||
)
|
||||
|
||||
@ -67,19 +69,41 @@ func (a *Agent) reembedStaleMedia() {
|
||||
|
||||
var jobsOnce sync.Once
|
||||
jobs := make(chan memory.MemoryBlock, workers*2)
|
||||
var done, failed int64
|
||||
var done, failed, unsupported int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 失败原因按错误文本聚合 —— 不逐条打日志(上千张图会刷屏),
|
||||
// 但**必须留下"为什么失败"**。
|
||||
//
|
||||
// ★ 2026-10-23 实测教训:原先这里只 `failed++` 然后 continue,于是
|
||||
// 「向量迁移完成: 成功=0 失败=4」成了唯一的线索 —— 没有任何日志说明
|
||||
// 这 4 个是 webp(当时 chineseclip 未注册 webp 解码器)。失败不可观测
|
||||
// 直接导致这次排查要从数据库里反查 mime 才找到根因。
|
||||
var failMu sync.Mutex
|
||||
failReasons := map[string]int{}
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for b := range jobs {
|
||||
if err := a.reembedOneBlock(b, fp); err != nil {
|
||||
atomic.AddInt64(&failed, 1)
|
||||
err := a.reembedOneBlock(b, fp)
|
||||
if err == nil {
|
||||
atomic.AddInt64(&done, 1)
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&done, 1)
|
||||
// 模态不受支持是**永久**状态(不是本次网络/服务抖动):
|
||||
// 这类块永远不会有向量,语义检索也永远搜不到它。
|
||||
// 单独计数,避免它混在"失败"里让人以为重试就能好。
|
||||
if errors.Is(err, vector.ErrModalityUnsupported) {
|
||||
atomic.AddInt64(&unsupported, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&failed, 1)
|
||||
}
|
||||
key := b.MIME + " | " + err.Error()
|
||||
failMu.Lock()
|
||||
failReasons[key]++
|
||||
failMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -93,8 +117,17 @@ func (a *Agent) reembedStaleMedia() {
|
||||
}
|
||||
safeclose.Safe(&jobsOnce, jobs)
|
||||
wg.Wait()
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
|
||||
done, failed, len(targets), shortFP(fp))
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不支持=%d 总计=%d fp=%s",
|
||||
done, failed, unsupported, len(targets), shortFP(fp))
|
||||
// 逐类打印失败原因(聚合后通常只有 1~2 类)。
|
||||
// 没有这段,运维只能看到"失败=N"而不知为何 —— 本次 webp 问题的排查成本
|
||||
// 全花在这里。
|
||||
if len(failReasons) > 0 {
|
||||
log.Printf("[media] 失败原因分类(按 mime | error 聚合):")
|
||||
for reason, n := range failReasons {
|
||||
log.Printf("[media] %d 个: %s", n, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reembedOneBlock 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@ -102,3 +103,100 @@ func TestReembedStaleMedia_NilSpaceNoop(t *testing.T) {
|
||||
t.Errorf("无空间可用时不该写向量: vec=%v model=%q", got.Vector, got.Fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// failSpace 让指定 mime 的图像嵌入失败,用来验证「失败必须留下原因」。
|
||||
type failSpace struct {
|
||||
failMIME string
|
||||
}
|
||||
|
||||
func (failSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
|
||||
|
||||
func (f failSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
|
||||
if mime == f.failMIME {
|
||||
return nil, fmt.Errorf("模拟解码失败: %s", mime)
|
||||
}
|
||||
return []float64{1, 0}, nil
|
||||
}
|
||||
|
||||
func (failSpace) Fingerprint() string { return "fail-space" }
|
||||
func (failSpace) Dim() int { return 2 }
|
||||
func (failSpace) Loaded() bool { return true }
|
||||
func (failSpace) Close() {}
|
||||
|
||||
// 迁移失败必须留下**可诊断的原因**,而不只是一个计数。
|
||||
//
|
||||
// ★ 2026-10-23 生产事故:日志只有
|
||||
//
|
||||
// [media] 向量迁移完成: 成功=0 失败=4 总计=4
|
||||
//
|
||||
// 没有任何信息说明为什么。反查数据库才发现那 4 个块是 image/webp,
|
||||
// 而 provider 没注册 webp 解码器。**失败不可观测直接导致排查成本翻倍**。
|
||||
//
|
||||
// 本判据钉住:失败原因按 "mime | error" 聚合后必须出现在日志里。
|
||||
//
|
||||
// 变异:把 failReasons 的采集与打印删掉 ⇒ 红。
|
||||
func TestReembedStaleMedia_LogsFailureReasons(t *testing.T) {
|
||||
a := newMediaLoopAgent(t)
|
||||
// 两个都会失败(同一 mime)—— 应聚合成一条 "2 个: ..."
|
||||
for i := 0; i < 2; i++ {
|
||||
b := mediaBlock("image/webp", []byte("webp-bytes"))
|
||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
logs := captureLog(t, func() {
|
||||
a.multimodalSpace = failSpace{failMIME: "image/webp"}
|
||||
a.reembedStaleMedia()
|
||||
})
|
||||
|
||||
if !strings.Contains(logs, "失败原因分类") {
|
||||
t.Fatalf("迁移失败时必须打印失败原因分类,实际日志:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "image/webp") {
|
||||
t.Fatalf("失败原因里必须出现具体 mime(否则无从定位是哪种格式),实际日志:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "模拟解码失败") {
|
||||
t.Fatalf("失败原因里必须带上 provider 返回的错误原文,实际日志:\n%s", logs)
|
||||
}
|
||||
// 计数必须出现(格式 "N 个: ...")—— 具体数字取决于去重后的块数,
|
||||
// 这里只要求"有计数",不绑死数字(绑死会让判据随去重策略变化而脆)。
|
||||
if !regexp.MustCompile(`\d+ 个: image/webp`).MatchString(logs) {
|
||||
t.Fatalf("失败原因应带聚合计数(形如「N 个: image/webp | ...」),实际日志:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// 不支持模态与临时失败必须**分开计数**:前者永远好不了(重试无意义),
|
||||
// 混在"失败"里会让人误以为重试即可恢复。
|
||||
func TestReembedStaleMedia_SeparatesUnsupportedFromFailed(t *testing.T) {
|
||||
a := newMediaLoopAgent(t)
|
||||
// 音频走 fakeSpace 的 ErrModalityUnsupported 分支;但 reembedStaleMedia
|
||||
// 只挑 image 模态,所以用图像 mime + 让 provider 返回 unsupported 来验证分类。
|
||||
img := mediaBlock("image/tiff", []byte("tiff-bytes"))
|
||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{img}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
logs := captureLog(t, func() {
|
||||
a.multimodalSpace = unsupportedImageSpace{}
|
||||
a.reembedStaleMedia()
|
||||
})
|
||||
|
||||
if !strings.Contains(logs, "不支持=1") {
|
||||
t.Fatalf("不受支持的模态应单独计数(不支持=1),实际日志:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// unsupportedImageSpace 对任何图像都返回 ErrModalityUnsupported。
|
||||
type unsupportedImageSpace struct{}
|
||||
|
||||
func (unsupportedImageSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
|
||||
|
||||
func (unsupportedImageSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
|
||||
return nil, fmt.Errorf("%w: %s", vector.ErrModalityUnsupported, mime)
|
||||
}
|
||||
|
||||
func (unsupportedImageSpace) Fingerprint() string { return "unsupported-space" }
|
||||
func (unsupportedImageSpace) Dim() int { return 2 }
|
||||
func (unsupportedImageSpace) Loaded() bool { return true }
|
||||
func (unsupportedImageSpace) Close() {}
|
||||
|
||||
@ -6,6 +6,17 @@ import (
|
||||
"image"
|
||||
|
||||
// 契约要求 provider 自行解码 Data,所以这里注册常见图像格式。
|
||||
//
|
||||
// ★ webp 必须注册(2026-10-23 实测):线上 551 个图像块里有 4 个是
|
||||
// `image/webp`(由 see_image 抓取的第三方图片),它们**永远嵌不进去** ——
|
||||
// 启动时向量迁移报「失败=4」,但错误被 continue 吞掉、没有任何日志,
|
||||
// 只看到数字对不上。根因就是这里少了 webp 注册:
|
||||
//
|
||||
// image.Decode(...) → "image: unknown format"
|
||||
//
|
||||
// Go 标准库不含 webp 解码器(那是 x/image 的子包),所以这是新增依赖的
|
||||
// 唯一理由。x/image 是纯 Go、无 cgo,不改变二进制形态。
|
||||
_ "golang.org/x/image/webp"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
97
providers/chineseclip/webp_test.go
Normal file
97
providers/chineseclip/webp_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package chineseclip
|
||||
|
||||
// webp 解码支持与「失败必须可观测」的判据。
|
||||
//
|
||||
// ## 为什么这两条要放在这里
|
||||
//
|
||||
// 2026-10-23 线上排查:启动日志只有一行
|
||||
//
|
||||
// [media] 向量迁移完成: 成功=0 失败=4 总计=4
|
||||
//
|
||||
// 没有任何信息说明为什么失败。反查数据库才发现那 4 个块的 mime 是
|
||||
// `image/webp`,而本 provider 只注册了 gif/jpeg/png 三个解码器 ⇒
|
||||
// `image.Decode` 报 "unknown format" ⇒ 它们**永远**拿不到向量,
|
||||
// 语义检索也永远搜不到它们。
|
||||
//
|
||||
// 两个独立缺陷:
|
||||
// 1. 功能缺口:webp 不可解码
|
||||
// 2. 可观测性缺口:失败被 `continue` 吞掉,只有计数没有原因
|
||||
//
|
||||
// 判据分开钉住,避免以后只修其中一个。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// webpBytes 是一个最小的合法 webp 文件(1×1 白色,RIFF/WEBP/VP8L)。
|
||||
//
|
||||
// 手写而不依赖测试夹具:夹具文件容易被误删,而"能解码 webp"这件事
|
||||
// 必须一直成立 —— 线上确实存在 webp 记忆块。
|
||||
var webpBytes = []byte{
|
||||
// 由 ffmpeg 生成的真实 2×2 白色 webp(44 字节,VP8 有损编码)。
|
||||
// 手写字节序列容易构造出不合法容器 —— 必须用真实编码器产物。
|
||||
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
|
||||
0x56, 0x50, 0x38, 0x20, 0x18, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00, 0x9d,
|
||||
0x01, 0x2a, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x34, 0x25, 0xa4, 0x00,
|
||||
0x03, 0x70, 0x00, 0xfe, 0xfb, 0x94, 0x00, 0x00,
|
||||
}
|
||||
|
||||
// ① webp 必须能被解码(否则 memory_blocks 里的 webp 块永远无向量)。
|
||||
//
|
||||
// 变异:注释掉 image.go 里的 `_ "golang.org/x/image/webp"` ⇒ 红。
|
||||
func TestWebpIsDecodable(t *testing.T) {
|
||||
_, format, err := image.Decode(bytes.NewReader(webpBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("webp 解码失败: %v —— image.go 里缺少 _ \"golang.org/x/image/webp\""+
|
||||
"(线上 4 个 webp 记忆块因此永远拿不到向量)", err)
|
||||
}
|
||||
if format != "webp" {
|
||||
t.Fatalf("格式识别错误: got %q want \"webp\"", format)
|
||||
}
|
||||
}
|
||||
|
||||
// ② webp 必须能走完整的预处理链路(不只是解码,还要变成 NCHW 张量)。
|
||||
//
|
||||
// 只验解码不够:预处理里有缩放/归一化,任何一步对 webp 不适用都算没支持。
|
||||
func TestWebpPreprocessProducesTensor(t *testing.T) {
|
||||
const size = 32
|
||||
mean := []float64{0.48145466, 0.4578275, 0.40821073}
|
||||
std := []float64{0.26862954, 0.26130258, 0.27577711}
|
||||
|
||||
tensor, err := preprocessImage(webpBytes, size, mean, std)
|
||||
if err != nil {
|
||||
t.Fatalf("webp 预处理失败: %v", err)
|
||||
}
|
||||
if want := 3 * size * size; len(tensor) != want {
|
||||
t.Fatalf("张量长度 %d,期望 %d(3×%d×%d)", len(tensor), want, size, size)
|
||||
}
|
||||
// 1×1 白色图放大后应接近 (1-mean)/std 的白点值;只验非全零,
|
||||
// 具体像素值由 jpeg/png 的测试覆盖(同一套代码路径)。
|
||||
nonZero := false
|
||||
for _, v := range tensor {
|
||||
if v != 0 {
|
||||
nonZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !nonZero {
|
||||
t.Fatal("张量全为零 —— 预处理没有真正读到像素")
|
||||
}
|
||||
}
|
||||
|
||||
// ③ png 仍必须可用(防止注册表改动把其它格式弄坏)。
|
||||
func TestPngStillDecodable(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
img.Set(0, 0, color.RGBA{255, 255, 255, 255})
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("构造 png 失败: %v", err)
|
||||
}
|
||||
if _, format, err := image.Decode(&buf); err != nil || format != "png" {
|
||||
t.Fatalf("png 解码异常: format=%q err=%v", format, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user