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

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