mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 生产现状(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
98 lines
3.5 KiB
Go
98 lines
3.5 KiB
Go
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)
|
||
}
|
||
}
|