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:
JianFeeeee
2026-10-23 15:50:59 +08:00
parent a2a190f663
commit 6327578693
6 changed files with 251 additions and 7 deletions

3
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。

View File

@ -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() {}

View File

@ -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"

View 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)
}
}