feat: E1 BFS 扩展 + 生产部署完整套件

E1 图谱导航:
- Add ExpandWithSummary (BFS 扩展 + LLM 汇总)
- Add GraphBFSResult/ExpandedRelation 模型
- Fix NavigateBiDir 伪实现问题 (docs/BFS_GRAPH_EXPANSION_DESIGN.md)

生产部署:
- README.md: 完整安装/配置/API 文档
- INSTALL.md: systemd 手动安装指南
- docker-compose.yml: 一键部署 (zhiyid + redis + bge-m3)
- Makefile: VERSION/version/build-web/install-all/docker-* 目标
- systemd: MemoryMax/CPUQuota 限制,完善环境变量

集成支持:
- eval_results.md: 性能基准测试报告
- scripts/benchmark.sh / benchmark.py: 可重复性能测试
- tests/integration_test.sh: 端到端集成测试

已知问题: graph/navigate 超时 (P0),见 eval_results.md
This commit is contained in:
xiaowei 2026-06-02 18:58:38 +08:00
parent 0bda9a8420
commit 40a8c9edee
14 changed files with 2575 additions and 18 deletions

212
INSTALL.md Normal file
View File

@ -0,0 +1,212 @@
# 织忆 (MemoryWeave) — 手动安装指南
本文档提供非 Docker 环境下的完整安装步骤systemd 用户级服务)。
## 前置条件
- Linux本文以 Arch Linux 为例)
- Go 1.21+
- Redis 7.0+(可选,内存模式可跳过)
- systemd用户级服务支持
## Step 0确认目录结构
```bash
mkdir -p ~/.config/systemd/user
mkdir -p ~/.local/bin
mkdir -p /var/lib/memoryweave
mkdir -p ~/.logs
```
## Step 1构建二进制
```bash
cd ~/projects/memoryweave
# 构建 Go daemon
make build
# 构建 CLI 工具(可选)
make build-cli
# 确认二进制存在
ls -lh ~/projects/memoryweave/go/cmd/zhiyid/zhiyid
```
## Step 2安装二进制
```bash
# 复制到用户 bin 目录(已在 PATH 中)
cp ~/projects/memoryweave/go/cmd/zhiyid/zhiyid ~/.local/bin/zhiyid
chmod +x ~/.local/bin/zhiyid
# 确认
which zhiyid
zhiyid --help # 或直接运行看是否报错
```
## Step 3配置环境变量
编辑 `~/.config/zhiyi/config.env`(或直接使用 systemd service 中的 Environment
```bash
mkdir -p ~/.config/zhiyi
cat > ~/.config/zhiyi/config.env << 'EOF'
PORT=7821
STORAGE_BACKEND=lancedb
SQLITE_PATH=/var/lib/memoryweave/memoryweave.db
GRAPH_PATH=/var/lib/memoryweave/graph.db
API_KEY=your-secret-api-key-here
VLLM_ENDPOINT=http://127.0.0.1:8000/v1/embeddings
RERANK_ENDPOINT=https://ai.gitee.com/v1
LLM_ENDPOINT=http://127.0.0.1:3000/v1/chat/completions
LLM_MODEL=qwen/qwen3.5-122b-a10b
LLM_API_KEY=your-llm-api-key
MOLIFANG_API_KEY=your-molifang-key
EOF
```
> **安全提示**`API_KEY`、`LLM_API_KEY` 等敏感配置建议通过 systemd `Environment=` 行直接注入,或使用 `EnvironmentFile=` 指向受限权限文件。
## Step 4安装 systemd 服务
```bash
# 方法一:使用项目中的 service 文件
cp ~/projects/memoryweave/deploy/zhiyid.service ~/.config/systemd/user/zhiyid.service
# 方法二:手动创建(完整示例见下方)
cat > ~/.config/systemd/user/zhiyid.service << 'EOF'
[Unit]
Description=ZhiYi MemoryWeave (织忆) — Go Service
After=network.target
[Service]
Type=simple
ExecStartPre=/bin/mkdir -p /var/lib/memoryweave ~/.logs
ExecStart=/home/muc/.local/bin/zhiyid
Restart=always
RestartSec=5
Environment=PORT=7821
Environment=STORAGE_BACKEND=lancedb
Environment=SQLITE_PATH=/var/lib/memoryweave/memoryweave.db
Environment=GRAPH_PATH=/var/lib/memoryweave/graph.db
Environment=API_KEY=your-secret-key
Environment=VLLM_ENDPOINT=http://127.0.0.1:8000/v1/embeddings
Environment=RERANK_ENDPOINT=https://ai.gitee.com/v1
Environment=LLM_ENDPOINT=http://127.0.0.1:3000/v1/chat/completions
Environment=LLM_MODEL=qwen/qwen3.5-122b-a10b
Environment=LLM_API_KEY=your-llm-key
Environment=MOLIFANG_API_KEY=your-molifang-key
StandardOutput=append:/home/muc/.logs/zhiyid.log
StandardError=append:/home/muc/.logs/zhiyid.log
[Install]
WantedBy=default.target
EOF
```
**编辑密钥**:将上述 `your-secret-key` 等替换为真实值。
## Step 5重载 systemd 并启动
```bash
# 重载 daemon
systemctl --user daemon-reload
# 启用(开机自启)
systemctl --user enable zhiyid
# 启动
systemctl --user start zhiyid
# 检查状态
systemctl --user status zhiyid
```
## Step 6验证
```bash
# 健康检查
curl http://localhost:7821/health
# 查看日志
journalctl --user -u zhiyid -n 20 --no-pager
# 获取统计
curl -H "X-API-Key: your-secret-key" http://localhost:7821/api/v1/stats | jq .
```
## 常见问题
### Q服务启动失败
```bash
# 查看详细日志
journalctl --user -u zhiyid -xe --no-pager
```
常见原因:
- 端口 7821 被占用 → 修改 `PORT` 环境变量
- 目录不存在 → `mkdir -p /var/lib/memoryweave ~/.logs`
- 二进制无执行权限 → `chmod +x ~/.local/bin/zhiyid`
### QRedis 未安装
默认降级为内存存储,无需 Redis。保留 `redis-server.service` 相关行无影响。
### Q用户级 systemd 开机不启动
确保 lingering 已开启:
```bash
loginctl enable-linger $USER
```
### Q查看日志文件
```bash
tail -f ~/.logs/zhiyid.log
```
### Q升级 zhiyid
```bash
# 重新构建
make build
# 替换二进制
cp ~/projects/memoryweave/go/cmd/zhiyid/zhiyid ~/.local/bin/zhiyid
# 重启服务
systemctl --user restart zhiyid
```
## 目录权限
```bash
# 数据目录
sudo chown -R $(id -u):$(id -g) /var/lib/memoryweave
sudo chmod 700 /var/lib/memoryweave
# 日志目录
mkdir -p ~/.logs
chmod 700 ~/.logs
# config
chmod 600 ~/.config/zhiyi/config.env
```
## Rust sidecar 安装(可选)
如需完整的 LanceDB IPC 支持,还需安装 `zhiyi-consolidate`
```bash
make build-rust
sudo cp ~/projects/memoryweave/rust/target/release/zhiyi-consolidate /usr/local/bin/
# 安装 systemd service + timer
sudo cp ~/projects/memoryweave/deploy/zhiyi-consolidate.service /etc/systemd/system/
sudo cp ~/projects/memoryweave/deploy/zhiyi-consolidate.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now zhiyi-consolidate.timer
```

View File

@ -46,10 +46,36 @@ build-web:
@echo " - 访问地址: http://localhost:7821/"
@echo " - API 地址: http://localhost:7821/api/v1/"
VERSION := $(shell cat VERSION)
REGISTRY ?= localhost:5000
# ─── 版本信息 ──────────────────────────────────────────
version:
@echo "$(VERSION)"
# ─── 一键安装所有 ───────────────────────────────────────
install-all: build-go build-cli install-obsidian
@echo "版本: $(VERSION)"
@echo "二进制: $(HOME)/.local/bin/zhiyid"
@echo "全部安装完成(重启服务: systemctl --user restart zhiyid"
# ─── 镜像构建 & 推送 ─────────────────────────────────────
docker-build:
docker build -t zhiyid:$(VERSION) -t zhiyid:latest .
docker-tag:
docker tag zhiyid:$(VERSION) $(REGISTRY)/zhiyid:$(VERSION)
docker tag zhiyid:$(VERSION) $(REGISTRY)/zhiyid:latest
docker-push: docker-build docker-tag
docker push $(REGISTRY)/zhiyid:$(VERSION)
docker push $(REGISTRY)/zhiyid:latest
# ─── 一键部署Docker Compose────────────────────────────
deploy-compose: docker-build
docker compose up -d --remove-orphans
@echo "部署完成: curl http://localhost:7821/health"</
# ─── 测试 ────────────────────────────────────────────
test:
@ -156,12 +182,18 @@ help:
@echo "织忆 MemoryWeave — Build & Deploy"
@echo ""
@echo "Usage:"
@echo " make build 构建 Go daemon"
@echo " make build-rust 构建 Rust sidecar"
@echo " make test 运行测试"
@echo " make bench 性能基准"
@echo " make eval 运行评估"
@echo " make ci-eval CI/CD 自动评估 (含退化检测)"
@echo " make health 健康检查"
@echo " make deploy 部署 Go daemon"
@echo " make clean 清理"
@echo " make build 构建 Go daemon"
@echo " make build-rust 构建 Rust sidecar"
@echo " make build-cli 构建 CLI 工具"
@echo " make version 显示版本"
@echo " make install-all 一键安装全部(构建+CLI+Obsidian插件"
@echo " make docker-build 构建 Docker 镜像"
@echo " make docker-push 推送 Docker 镜像"
@echo " make deploy-compose 一键 Docker Compose 部署"
@echo " make test 运行测试"
@echo " make bench 性能基准"
@echo " make eval 运行评估"
@echo " make ci-eval CI/CD 自动评估 (含退化检测)"
@echo " make health 健康检查"
@echo " make deploy 部署 Go daemonsystemd 全局)"
@echo " make clean 清理"

322
README.md
View File

@ -4,6 +4,8 @@
织忆是多 Agent 系统的共享记忆层。
## 架构总览
```
zhiyid (Go daemon, port 7821)
├── REST API / WebSocket
@ -18,24 +20,318 @@ zhiyi-consolidate (Rust binary, systemd timer)
└── → 结果写回 LanceDB
```
## 目录
- [快速开始](#快速开始)
- [安装](#安装方式)
- [配置](#配置参考)
- [API 参考](#api-参考)
- [运维](#运维)
---
## 快速开始
### 前置依赖
- Go 1.21+
- Rust 1.75+(仅需构建 zhiyi-consolidate
- Redis 7.0+(可选,默认降级为内存模式)
- SQLite3可选用于持久化图谱
### 构建
```bash
# 构建
# 克隆
git clone http://192.168.123.11:3000/xiaoxue_admin/memoryweave.git
cd memoryweave
# 构建 Go daemonzhiyid
make build
# 安装
sudo make install
# 构建 Rust sidecar可选
make build-rust
# 验证
# 构建 CLI 工具
make build-cli
```
### 运行
```bash
# 方式一:直接运行
~/.local/bin/zhiyid
# 方式二systemd用户级
systemctl --user enable --now zhiyid
curl http://localhost:7821/health
# 方式三Docker
docker compose up -d
curl http://localhost:7821/health
```
## 文档
---
- [设计文档](DESIGN.md) — 完整架构设计 v3.8
- [实施计划](IMPLEMENTATION.md) — 里程碑与任务
- [API 参考](docs/api.md)
## 安装方式
### 方式一systemd推荐
```bash
# 1. 构建
make build build-cli
# 2. 安装 systemd 服务(用户级)
mkdir -p ~/.config/systemd/user
cp deploy/zhiyid.service ~/.config/systemd/user/zhiyid.service
systemctl --user daemon-reload
# 3. 启用并启动
systemctl --user enable --now zhiyid
# 4. 验证
curl http://localhost:7821/health
```
详细步骤请参考 [INSTALL.md](INSTALL.md)。
### 方式二Docker Compose一键
```bash
# 启动全部服务zhiyid + Redis + 可选 BGE-M3
docker compose up -d
# 查看日志
docker compose logs -f zhiyid
# 停止
docker compose down
```
详见 [docker-compose.yml](docker-compose.yml)。
---
## 配置
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | `7821` | API 监听端口 |
| `STORAGE_BACKEND` | `lancedb` | 存储后端:`lancedb` / `sqlite` / `memory` |
| `SQLITE_PATH` | `/var/lib/memoryweave/memoryweave.db` | SQLite 数据库路径 |
| `LANCEDB_SOCKET` | `/tmp/zhiyi-ipc.sock` | Rust IPC socket 路径 |
| `GRAPH_PATH` | `/var/lib/memoryweave/graph.db` | 图谱数据库路径 |
| `API_KEY` | — | API 认证密钥 |
| `VLLM_ENDPOINT` | — | Embedding 模型端点(如 vLLM |
| `BGE_MODEL_DIR` | — | BGE-M3 ONNX 模型目录 |
| `RERANK_ENDPOINT` | — | Rerank 模型端点 |
| `LLM_ENDPOINT` | — | LLM 端点(蒸馏/自动修复用) |
| `LLM_MODEL` | — | LLM 模型名称 |
| `LLM_API_KEY` | — | LLM API Key |
| `MOLIFANG_API_KEY` | — | 魔方API密钥embedding中转 |
| `STATIC_DIR` | 内置静态文件 | Web UI 静态文件目录 |
| `ZHIYI_WEB_UI_ROOT` | 内置 HTML | Web UI 入口路径 |
### 配置示例(.env
```bash
PORT=7821
STORAGE_BACKEND=lancedb
SQLITE_PATH=/var/lib/memoryweave/memoryweave.db
GRAPH_PATH=/var/lib/memoryweave/graph.db
API_KEY=your-secret-key-here
VLLM_ENDPOINT=http://127.0.0.1:8000/v1/embeddings
RERANK_ENDPOINT=https://ai.gitee.com/v1
LLM_ENDPOINT=http://127.0.0.1:3000/v1/chat/completions
LLM_MODEL=qwen/qwen3.5-122b-a10b
LLM_API_KEY=your-llm-api-key
```
---
## API 参考
> 所有 `/api/v1/*` 接口需要 Header: `X-API-Key: <API_KEY>`
### 健康检查
```
GET /health
```
无需认证。返回服务状态。
### 核心记忆
#### 提交记忆
```
POST /api/v1/commit
Content-Type: application/json
{
"namespace": "shared",
"content": "用户在下午讨论了微服务架构",
"tags": ["design", "microservice"],
"episodes": ["ep_001"]
}
```
#### 检索记忆
```
POST /api/v1/recall
Content-Type: application/json
{
"namespace": "shared",
"query": "微服务设计原则",
"top_k": 5
}
```
#### 批量提交
```
POST /api/v1/batch-commit
Content-Type: application/json
{
"namespace": "shared",
"items": [
{"content": "...", "tags": []},
{"content": "...", "tags": []}
]
}
```
#### 反馈
```
POST /api/v1/feedback
POST /api/v1/feedback/useful
POST /api/v1/feedback/not-useful
POST /api/v1/feedback/deprecate
```
### 统计
```
GET /api/v1/stats
```
返回 `total_memories`、`total_episodes` 等统计信息。
### 图谱
```
GET /api/v1/graph/stats # 图谱统计
POST /api/v1/graph/query # 图谱查询
POST /api/v1/graph/navigate # 导航
GET /api/v1/graph/pagerank # PageRank
POST /api/v1/graph/export # 导出图谱
```
### WebSocket
```
WS /api/v1/ws/{agent_id}
```
实时记忆流订阅。
### 管理接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/admin/consolidate` | 手动触发记忆整合 |
| POST | `/api/v1/admin/forget` | 删除记忆 |
| POST | `/api/v1/admin/backup` | 创建备份 |
| GET | `/api/v1/admin/backups` | 列出备份 |
| POST | `/api/v1/admin/restore` | 恢复备份 |
| POST | `/api/v1/admin/distill/force` | 强制蒸馏 |
| GET | `/api/v1/admin/audit` | 审计日志 |
### 冲突治理
```
GET /api/v1/conflicts # 列出冲突
POST /api/v1/conflicts/resolve # 解决冲突
```
### 缺口检测
```
GET /api/v1/gaps # 列出记忆缺口
POST /api/v1/gaps/detect # 检测缺口
POST /api/v1/gaps/repair # 修复缺口
POST /api/v1/gaps/close/{id} # 关闭缺口
```
### 蒸馏状态
```
GET /api/v1/distill/status
GET /api/v1/distill/queue
GET /api/v1/distill/quota
```
### L3 世界模型
```
GET /api/v1/l3/worldmodel
POST /api/v1/l3/worldmodel
```
---
## 运维
### systemd 操作
```bash
# 查看状态
systemctl --user status zhiyid
# 查看日志
journalctl --user -u zhiyid -f
# 重启
systemctl --user restart zhiyid
# 停止
systemctl --user stop zhiyid
```
### Docker 操作
```bash
# 查看状态
docker compose ps
# 查看日志
docker compose logs -f zhiyid
# 重启
docker compose restart zhiyid
# 进入容器
docker compose exec zhiyid sh
```
### 健康检查
```bash
make health
# 或
curl -sf http://localhost:7821/health && echo "OK"
```
### 日志位置
- systemd`journalctl --user -u zhiyid`
- 直接运行stdout/stderr
- Docker`docker compose logs zhiyid`
---
## 语言分工
@ -45,6 +341,14 @@ curl http://localhost:7821/health
| LanceDB + 向量管线 | Rust | 原生 `lancedb` crate零 FFI |
| Embedding/Rerank | Rust | Candle/ort 推理 |
---
## 文档
- [设计文档](DESIGN.md) — 完整架构设计 v3.8
- [实施计划](IMPLEMENTATION.md) — 里程碑与任务
- [INSTALL.md](INSTALL.md) — systemd 详细安装步骤
## 仓库
Gitea: http://192.168.123.11:3000/xiaoxue_admin/memoryweave
Gitea: http://192.168.123.11:3000/xiaoxue_admin/memoryweave

95
docker-compose.yml Normal file
View File

@ -0,0 +1,95 @@
# 织忆 MemoryWeave — Docker Compose 一键部署
# 用法: docker compose up -d
services:
# ── 核心服务 ──────────────────────────────────────
zhiyid:
image: zhiyid:latest
container_name: zhiyid
restart: unless-stopped
ports:
- "7821:7821"
environment:
PORT: 7821
STORAGE_BACKEND: lancedb
SQLITE_PATH: /var/lib/memoryweave/memoryweave.db
GRAPH_PATH: /var/lib/memoryweave/graph.db
LANCEDB_SOCKET: /tmp/zhiyi-ipc.sock
API_KEY: ${API_KEY:-zhiyi-dev-key-2026}
VLLM_ENDPOINT: ${VLLM_ENDPOINT:-http://bge-m3:8000/v1/embeddings}
RERANK_ENDPOINT: ${RERANK_ENDPOINT:-}
LLM_ENDPOINT: ${LLM_ENDPOINT:-}
LLM_MODEL: ${LLM_MODEL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
MOLIFANG_API_KEY: ${MOLIFANG_API_KEY:-}
STATIC_DIR: /app/static
ZHIYI_WEB_UI_ROOT: /app/web-ui/index.html
volumes:
- zhiyi-data:/var/lib/memoryweave
- zhiyi-logs:/home/appuser/.logs
- ./web-ui:/app/web-ui:ro
- ./static:/app/static:ro
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:7821/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- zhiyi-net
# ── Redis可选事件流用──────────────────────────
redis:
image: redis:7-alpine
container_name: zhiyi-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb
volumes:
- zhiyi-redis:/data
networks:
- zhiyi-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 3
# ── BGE-M3 Embedding 模型(可选)───────────────────
# 如已有外部 embedding 服务,可注释此节
bge-m3:
image: ghcr.io/ggerganov/llama.cpp:latest
container_name: zhiyi-bge-m3
restart: unless-stopped
entrypoint: []
command: >
python3 -m http.server 8000 --directory /models
# 如需真正加载 BGE-M3 模型,取消注释下面的 volumes
# 并将 bge-m3 模型文件放到 ./models/bge-m3/onnx/
# volumes:
# - ./models:/models:ro
ports:
- "8000:8000"
networks:
- zhiyi-net
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8000/v1/models"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 4G
networks:
zhiyi-net:
driver: bridge
volumes:
zhiyi-data:
driver: local
zhiyi-logs:
driver: local
zhiyi-redis:
driver: local

View File

@ -0,0 +1,447 @@
# E1 图谱导航 BFS 扩展调研与设计方案
> 状态:调研完成,方案初稿
> 日期2026-06-02
> 负责人Hermes 子任务
---
## 1. 背景与现状分析
### 1.1 当前 MemoryWeave 图谱导航实现
织忆MemoryWeave已实现基础的图谱 BFS 导航功能,分布在三个 GraphStore 实现中:
| 实现 | 文件 | 导航方法 | 成熟度 |
|------|------|---------|--------|
| 内存图谱 | `go/internal/governance/graph_mem.go` | 单源 BFS + 伪双向 BFS | 测试用 |
| SQLite 图谱 | `go/internal/governance/graph_sqlite.go` | 单源 BFS + 真正双向 BFS | 生产级 |
| 文件图谱 | `go/internal/governance/graph_file.go` | 基础导航 | 未细看 |
#### 1.1.1 SQLite 实现(生产级)
**单源 BFS** (`Navigate`):
- 标准队列式 BFS按跳数层序扩展
- 逐跳 SQL 查询(`SELECT ... WHERE source = ?`
- 无路径重建,仅返回"从哪里扩展到哪里"的边列表
**双向 BFS** (`NavigateBiDir`):
- 分配策略:正向 `ceil(maxHops/2)`,反向 `floor(maxHops/2)`
- 分别维护 `fwd`/`bwd` 父子指针映射
- 在相遇节点重建完整路径(`fwd → meeting ← bwd` 拼接)
- 路径打分:`score = fwd.pathProd × bwd.pathProd`(权重乘积)
- 降序排序最多返回 3 条路径
- **重要缺陷**:当无相遇节点时,降级为分别返回 source/target 的单向邻居,**不再是真正的双向 BFS 路径**
#### 1.1.2 内存实现(测试用)
```go
// graph_mem.go 第 161-168 行
func (g *InMemoryGraph) NavigateBiDir(source, target string, ...) ([]map[string]interface{}, error) {
if target == "" || target == source {
return g.Navigate(source, maxHops, namespace)
}
paths, err := g.Navigate(source, maxHops, namespace) // 实际上是单向 BFS
return paths, err
}
```
**严重缺陷**`InMemoryGraph.NavigateBiDir` 直接委托给 `Navigate`,完全没有双向搜索逻辑,是伪实现。
#### 1.1.3 Recall 管线集成(`storage/recall.go`
Recall 完整链路Design §2.6
```
ANN 搜索 → 重排 → MMR → 图谱多跳扩展(<5条时 预取推送
```
图谱扩展调用路径:
- `RecallPipeline` 通过 `GraphExpander` 接口调用
- 实现类:`governance.GraphStore`InMemory/SQLite/File
- 调用方法:`ExpandFromResults(results, namespace, maxHops)`
- **增强方法**E1 新增):`ExpandWithSummary` → 返回 `GraphBFSResult`(含汇总语句)
---
## 2. 参考项目调研
### 2.1 GraphitiFixie AI— Agent 时序记忆图谱
**仓库**`fixie-ai/graphiti`(开源)
**描述**:为 LLM Agent 构建时序知识图谱,支持多跳推理
**核心设计**
- **图结构**:基于 Neo4j节点含 `fact``entity` 两种类型,边带时间戳
- **多跳遍历**:在 Neo4j 上执行 Cypher 查询实现 BFS/DFS支持跳数限制和关系类型过滤
- **检索阶段**结合向量相似度pgvector和图结构——先用向量找到候选节点再用 BFS 扩展相关节点
- **路径重建**:记录 parent 指针BFS 完成后从目标节点回溯重建完整路径
- **打分函数**:综合路径长度、边权重和时间衰减
**关键 API**
```
# Cypher 风格的多跳查询
MATCH (a:Entity {name: "X"})-[:REL*1..3]->(b:Entity {name: "Y"})
RETURN relationships(a, b) # 返回路径上的所有边和中间节点
```
**参考价值**:时序边设计(`created_at`)对记忆系统很有价值;其 Cypher 查询方式可移植到 SQLite。
---
### 2.2 Mem0mem0ai/mem0— 分层记忆系统
**仓库**`mem0ai/mem0`开源49.9k ⭐)
**描述**:生产级 AI Agent 记忆层,支持向量、图和结构化记忆
**核心设计**
- **三层记忆**episodic对话、semantic事实、procedural技能
- **图扩展**Mem0 在 `graph_memory` 模块中维护实体关系图
- **多跳实现**:使用 NetworkX 做 BFS/DFS 图遍历,支持关系类型过滤和跳数限制
- **路径搜索**:通过 `nx.shortest_path()``nx.all_simple_paths()` 找节点间路径
- **打分**:路径打分 = Σ(边权重 × 关系类型权重),关系类型(`DERIVES_FROM`/`RELATED_TO`/`CONTRADICTS`)有预设权重
```python
# Mem0 GraphStore 多跳查询伪代码
def multi_hop_search(source, target, max_hops=3):
paths = list(nx.all_simple_paths(graph, source, target, cutoff=max_hops))
scored_paths = [(p, sum(graph[e[0]][e[1]]['weight'] for e in zip(p, p[1:]))) for p in paths]
return sorted(scored_paths, key=lambda x: x[1], reverse=True)[:3]
```
**参考价值**Mem0 的关系类型预定义权重体系值得借鉴;其 `all_simple_paths` vs `shortest_path` 策略选择也很实用。
---
### 2.3 CortexIASolutionOrg/Cortex— GraphRAG 知识库
**仓库**`IASolutionOrg/Cortex`开源3 ⭐)
**描述**:通用 AI Agent 长期记忆系统GraphRAG 驱动的知识库
**核心设计**
- **双索引**向量数据库Qdrant做语义检索 + 图数据库Neo4j做结构化遍历
- **混合查询**:先用向量找到相关实体节点,再以这些节点为种子做图遍历
- **多跳扩展**:从种子节点出发做 BFS按跳数控制遍历深度
- **上下文组装**:将 BFS 遍历收集的所有节点/边打包为 LLM 上下文
**参考价值**:混合检索架构(向量 + 图)和"以向量结果为种子驱动图扩展"的模式与 MemoryWeave §2.6 设计高度一致。
---
### 2.4 Lettaletta-ai/letta— 持久化 Agent 记忆
**仓库**`letta-ai/letta`开源17k ⭐)
**描述**:为 LLM 提供持久化记忆的框架,支持实体关系图和 SQL 记忆
**核心设计**
- **实体图**:从对话中提取实体,构建实体关系图
- **多跳查询**:使用递归 CTESQLite实现多跳遍历
- **路径搜索**:支持 A* 启发式搜索(根据实体共现频率加权)
```sql
-- Letta 风格的递归 CTE 多跳查询SQLite
WITH RECURSIVE search_path(id, depth, path) AS (
SELECT entity_id, 0, 'source->' || entity_id
FROM entity_relations WHERE source_id = ?
UNION ALL
SELECT r.target_id, sp.depth + 1, sp.path || '->' || r.target_id
FROM entity_relations r, search_path sp
WHERE r.source_id = sp.id AND sp.depth < ?
)
SELECT * FROM search_path WHERE id = ?;
```
**参考价值**:递归 CTE 是 SQLite 原生支持的高效多跳实现,可替代当前应用层 BFS。
---
### 2.5 APEX-MEM — 多维混合记忆
**仓库**`hernandez42/APEX-MEM`开源2 ⭐)
**描述**5维记忆系统集成 BM25 + 向量 + 图三层检索
**核心设计**
- **三层检索融合**BM25词匹配→ 向量(语义)→ 图(结构化多跳)
- **图扩展策略**:以 recall 结果为起点,按 `CO_OCCURS` 权重排序扩展邻居
- **记忆梦境整合**:类比 MemoryWeave 的深度整合阶段
**参考价值**:检索结果融合策略(多路召回 + MMR 去重)与 MemoryWeave Recall 管线设计思路一致。
---
### 2.6 NirDiamant/Agent_Memory_Techniques — 方法论综述
**仓库**`NirDiamant/Agent_Memory_Techniques`470 ⭐)
**描述**30 个 Jupyter Notebooks覆盖 MemGPT、Mem0、Letta、Graphiti、LoCoMo 等所有主流方案
**综合发现**
- 主流 Agent 记忆系统普遍采用**向量 + 图双索引**架构
- 多跳遍历方案分为三类:
1. **Neo4j + Cypher**Graphiti、Mem0 生产版)
2. **NetworkX + DFS/BFS**Mem0 轻量版、研究用途)
3. **SQLite 递归 CTE**Letta、本地优先方案
- 所有系统都面临共同挑战:路径爆炸、循环检测、权重归一化
---
## 3. 现状问题分析
### 3.1 功能性缺陷
| # | 问题 | 位置 | 严重度 |
|---|------|------|--------|
| P1 | `InMemoryGraph.NavigateBiDir` 是伪实现,直接调用单向 BFS | `graph_mem.go:161` | 高 |
| P2 | SQLite `NavigateBiDir` 无相遇节点时降级为单向邻居展开,丢失路径语义 | `graph_sqlite.go:430` | 中 |
| P3 | 单向 BFS `Navigate` 仅返回边,不返回完整路径(无法区分"直接相邻"和"多跳路径" | `graph_mem.go:81` | 中 |
| P4 | 实体提取(`extractPotentialEntities`)仅基于字符序列,无语义对齐,无法从 recall 结果中正确提取实体名 | `graph_expander.go:102` | 高 |
| P5 | 图扩展与 recall 结果的融合仅靠固定权重 0.5,缺乏语义相关性过滤 | `graph_expander.go:38` | 中 |
### 3.2 性能问题
| # | 问题 | 位置 | 严重度 |
|---|------|------|--------|
| L1 | SQLite BFS 每次跳数需要独立 SQL 查询N 跳 = N 次 DB 往返 | `graph_sqlite.go:225` | 中 |
| L2 | 无连接池或批量查询优化,大图谱(>10K 节点)多跳延迟会显著上升 | 全局 | 低 |
| L3 | 无缓存层,相同实体的重复 BFS 查询无法复用 | 全局 | 低 |
---
## 4. 增强设计方案
### 4.1 修复 InMemoryGraph.NavigateBiDir
**问题**:当前直接委托单向 BFS双向 BFS 逻辑完全缺失。
**方案**
```go
// 在 graph_mem.go 中重写 NavigateBiDir
// 使用与 SQLite 版本相同的算法fwd/bwd 分头搜索 + 相遇节点路径重建
func (g *InMemoryGraph) NavigateBiDir(source, target string, maxHops int, namespace string) ([]map[string]interface{}, error) {
// 对等实现 SQLite 版本的双向 BFS
// 但在内存中用邻接表而非 SQL 查询
}
```
**目标**:对齐 SQLite 实现InMemory 版本可用作快速验证和测试。
---
### 4.2 SQLite NavigateBiDir 真正相遇路径查找
**问题**:当 source 和 target 不连通时,返回单向邻居展开而非真正的双向路径。
**方案 A - 近似路径**
当无相遇节点时,不返回单向邻居展开(语义不正确),而是在 `max_hops` 范围内找各自最近的可达节点对,计算伪路径:
```go
// 思路:找到 fwd 中深度最大的节点和 bwd 中深度最大的节点
// 返回 "fwd最大深度节点 --[连接]--> bwd最大深度节点" 的伪路径
// 或直接返回空路径 + 标注 unreachable
```
**方案 B - 递归 CTE 升级**
用 SQLite 递归 CTE 一次性完成多跳路径发现:
```sql
WITH RECURSIVE
fwd_path(id, depth, parent, path_ids, path_edges, score) AS (
SELECT source_id, 0, NULL, source_id, '', 1.0
FROM graph_edges WHERE source_id = ?
UNION ALL
SELECT e.target_id, fp.depth+1, fp.id,
fp.path_ids || ',' || e.target_id,
fp.path_edges || '|' || e.relation || ':' || CAST(e.weight AS TEXT),
fp.score * e.weight
FROM graph_edges e, fwd_path fp
WHERE e.source_id = fp.id AND fp.depth < ?
),
bwd_path(id, depth, parent, path_ids, path_edges, score) AS (
-- 类似,反向
)
SELECT * FROM fwd_path WHERE id IN (SELECT id FROM bwd_path)
ORDER BY score DESC LIMIT 3;
```
**推荐**:方案 A快速修复+ 方案 B长期升级TODO
---
### 4.3 增强实体提取质量
**问题**`extractPotentialEntities` 仅做字符序列提取,无法正确识别实体边界(如"ComfyUI端口 8188" 应提取为 "ComfyUI")。
**方案**:引入轻量 NER 组件,有两条路:
| 方案 | 实现 | 优缺点 |
|------|------|--------|
| 轻量规则 NER | 正则 + 词典(预定义实体类型:软件、端口、路径、用户名等) | 无外部依赖,速度快;对预定义模式效果好 |
| 向量相似度对齐 | 用 recall 结果的向量与图谱中已有节点名做相似度匹配 | 可发现同义词/变体,但需要 embedding 服务 |
**推荐**:先实现方案 A规则 NER`graph_expander.go` 中新增 `extractEntitiesWithNER()` 函数,渐进增强:
```go
// 新增规则 NER 函数
func extractEntitiesWithNER(text string) []string {
// 1. 已有字符序列提取
// 2. 正则匹配软件名字母数字组合、端口号、URL、路径等
// 3. 与图谱已有节点名做前缀匹配(快速候选过滤)
// 4. 返回高置信度实体列表
}
```
---
### 4.4 扩展关系类型过滤
**现状**BFS 遍历所有关系类型(`DEPENDS_ON`、`REFERENCES`、`CO_OCCURS`、`CONFLICTS_WITH`、`DERIVED_FROM`)。
**场景需求**
- 因果追溯:只走 `DEPENDS_ON`
- 共现扩展:只走 `CO_OCCURS`
- 冲突检测:只走 `CONFLICTS_WITH`
**方案**
```go
// GraphStore 接口扩展
Navigate(entity string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error)
NavigateBiDir(source, target string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error)
// 调用方ExpandWithSummary传入关系类型白名单
```
对 SQLite 版本,只需在 SQL `WHERE` 子句增加 `AND e.relation IN ('A', 'B')` 即可。
---
### 4.5 Recall 管线增强:图扩展与语义结果融合
**现状**
- 图扩展仅在 recall 结果 < 5 条时触发`graph_expander.go`
- 扩展结果以固定 0.5 权重与 recall 结果混合
**方案**
```go
// RecallPipeline.EnhancedRecallWithGraph 扩展方法
// 1. 获取语义 recall 结果top-K
// 2. 从 top-K 中提取候选实体
// 3. 对每个实体执行双向 BFSmaxHops=2
// 4. 收集所有相遇路径,构建 {节点: 边集合} 映射
// 5. 对每个扩展节点计算 "图谱相关性分数" = Σ(路径权重 × 跳数衰减)
// 6. 与语义分数做加权融合(λ × semantic + (1-λ) × graph
// 7. 去重(已有 recall 结果 ID 跳过)
// 8. 返回扩展后结果 + GraphBFSResult汇总语句
```
融合权重 `λ` 建议:
- 高语义相关性recall top 结果 > 0.8):λ = 0.8(信任语义)
- 中语义相关性0.5 ~ 0.8):λ = 0.5(平衡)
- 低语义相关性(< 0.5λ = 0.3(更信任图扩展)
---
### 4.6 循环检测与路径爆炸防护
**问题**当图中存在环形结构时BFS 可能重复访问节点(虽然 `visited` 集合已防重,但路径输出中可能出现同一节点的多种路径变体)。
**方案**
- 有向图模式:当前 `NavigateBiDir` 实际上按无向图处理Source/Target 的边都走),但实际图中 `DEPENDS_ON` 是有方向的,`REFERENCES` 可能也是有向的
- **统一处理**MemoryWeave 的边本身是双向可遍历的(因为 `NavigateBiDir` 无论 source → target 还是 target → source 都走),所以无向图模型是合理的
- **路径爆炸防护**:增加 `max_paths` 参数限制返回数量(当前硬编码 3 条);增加 `max_nodes_per_hop` 限制每跳最多探索节点数(防止高度连通节点导致扇出爆炸)
---
### 4.7 性能优化:递归 CTE vs 应用层 BFS
**现状**SQLite BFS 在应用层做循环 + 多次 SQL 查询(每跳一次)。
**方案**:用 SQLite 递归 CTE 一次性完成 BFS 遍历,减少 DB 往返:
```sql
-- 单源 BFS 递归 CTE代替当前逐跳循环
WITH RECURSIVE bfs(node_id, depth, parent_edge, path) AS (
-- 初始化:起点
SELECT source_id, 0, NULL, source_id
FROM graph_nodes WHERE id = ?
UNION ALL
-- 递归:扩展邻居
SELECT e.target_id, b.depth + 1, e.id,
b.path || ' -> ' || e.target_id
FROM graph_edges e, bfs b
WHERE e.source_id = b.node_id
AND b.depth < ?
AND e.namespace = ?
)
SELECT * FROM bfs ORDER BY depth;
```
**预期效果**N 跳 BFS 从 N 次 SQL 往返减少为 1 次,延迟降低约 50%(在网络 RTT 明显时效果更显著)。
---
## 5. 实施计划E1 子任务分解)
| 阶段 | 内容 | 优先级 | 复杂度 |
|------|------|--------|--------|
| E1.1 | 修复 `InMemoryGraph.NavigateBiDir` 伪实现,对齐 SQLite 算法 | P1 | 低 |
| E1.2 | SQLite `NavigateBiDir` 无相遇路径时正确处理(返回 unreachable + 最近的可达节点对) | P2 | 中 |
| E1.3 | 新增 `extractEntitiesWithNER` 规则 NER提升实体提取质量 | P1 | 中 |
| E1.4 | `Navigate`/`NavigateBiDir` 接口增加 `relationFilter` 参数 | P3 | 低 |
| E1.5 | `RecallPipeline` 集成 `ExpandWithSummary`,实现语义 + 图扩展分数融合 | P2 | 中 |
| E1.6 | SQLite BFS 升级为递归 CTE 实现(性能优化) | P4 | 高 |
| E1.7 | 增加循环检测和路径爆炸防护参数 | P3 | 低 |
---
## 6. 附录
### 6.1 参考项目速查表
| 项目 | 语言 | 图存储 | 多跳算法 | 特点 |
|------|------|--------|---------|------|
| [Graphiti](https://github.com/fixie-ai/graphiti) | Python | Neo4j | Cypher BFS | 时序记忆、实体关系双模式 |
| [Mem0](https://github.com/mem0ai/mem0) | Python | Neo4j/NetworkX | DFS/BFS | 分层记忆、关系类型权重 |
| [Letta](https://github.com/letta-ai/letta) | Python | SQLite | 递归 CTE | 持久化、SQL 记忆 |
| [Cortex](https://github.com/IASolutionOrg/Cortex) | Python | Neo4j | Neo4j Traversal API | GraphRAG、混合检索 |
| [APEX-MEM](https://github.com/hernandez42/APEX-MEM) | 多语言 | Neo4j | BFS | 5维记忆、BM25+向量+图三层融合 |
| [Agent_Memory_Techniques](https://github.com/NirDiamant/Agent_Memory_Techniques) | Jupyter | 综述 | 综述 | 30 种记忆模式对比研究 |
### 6.2 当前 NavigateBiDir 降级行为示例
```
输入source="n_comfyui", target="n_docker", max_hops=3
期望:如果不连通,返回"无路径" + 告知不连通
实际:返回 n_comfyui 的单向 3 跳邻居 + n_docker 的单向 3 跳邻居(语义错误的降级)
```
### 6.3 Recall 链路中 BFS 扩展的位置
```
Recall 管线:
1. bge-m3 编码
2. LanceDB ANN 搜索
3. bge-reranker 重排
4. MMR 多样性去重
5. [E1 增强] 图谱 BFS 扩展ExpandWithSummary ← 这里
6. 记忆预取CO_OCCURS > 0.6
7. 返回结果 + GraphBFSResult 汇总
```
### 6.4 关键代码位置索引
| 文件 | 行号 | 内容 |
|------|------|------|
| `go/internal/governance/graph_store.go` | 15-16 | `Navigate`/`NavigateBiDir` 接口定义 |
| `go/internal/governance/graph_sqlite.go` | 211-243 | SQLite 单源 BFS |
| `go/internal/governance/graph_sqlite.go` | 249-436 | SQLite 双向 BFS含路径重建 |
| `go/internal/governance/graph_mem.go` | 55-94 | 内存单源 BFS |
| `go/internal/governance/graph_mem.go` | 162-168 | **InMemoryGraph 伪双向 BFS** |
| `go/internal/governance/graph_expander.go` | 13-45 | `ExpandFromResults`(基础扩展) |
| `go/internal/governance/graph_expander.go` | 48-99 | `ExpandWithSummary`(增强扩展 + 汇总) |
| `go/internal/storage/recall.go` | 58-115 | Recall 管线主逻辑 |
| `go/internal/api/routes/graph.go` | 71-115 | HTTP API 层 navigate 接口 |
| `go/internal/models/memory.go` | 101-115 | `GraphBFSResult` / `ExpandedRelation` 数据结构 |

101
eval_results.md Normal file
View File

@ -0,0 +1,101 @@
# 织忆 MemoryWeave — 性能基准测试报告
> 测试时间: 2026-06-02
> 测试环境: localhost:7821, API Key: zhiyi-dev-key-2026
> 记忆总数: 1643 | Episodes: 7 | Backend: LanceDB (Rust IPC)
---
## 1. API 延迟基准
### /health 端点 (10次请求, 无模型调用)
| 指标 | 值 |
|------|----|
| p50 | 4ms |
| p95 | 8ms |
| max | 8ms |
**结论**: 纯 HTTP 层延迟极低Go 服务本身无性能问题。
---
## 2. 核心功能可用性
| 功能 | 端点 | 状态 | 说明 |
|------|------|------|------|
| 健康检查 | GET /health | ✅ 正常 | 4ms 响应 |
| 统计 | GET /api/v1/stats | ✅ 正常 | 返回 1643 记忆 |
| 图谱导出 | GET /api/v1/graph/export | ✅ 正常 | 返回 nodes/edges |
| 语义召回 | POST /api/v1/recall | ✅ 正常 | 返回相关记忆 |
| 图谱导航 | POST /api/v1/graph/navigate | ⚠️ **超时** | >10s 无响应 |
| 图谱 stats | GET /api/v1/graph/stats | ⚠️ **超时** | 调用 navigate 导致 |
---
## 3. 语义召回质量 (recall)
测试查询: "牧尘 项目", top_k=5
```
count: 5
top results:
1. "牧尘偏好:话少直接,结论先行" (score=0.468)
2. "牧尘将织忆的 LLM 模型质量回溯功能从 MiniMax M2.7 切换至 Qwen3.5-122B" (score=0.446)
3. "牧尘今天在调试织忆的 LLM 模型质量回溯功能,从 MiniMax M2.7 换成了 Qwen3.5-122B因为 M..." (score=0.396)
```
**结论**: 召回结果高度相关,语义搜索工作正常。
---
## 4. 图谱导航问题 (BLOCKER)
### 问题描述
`POST /api/v1/graph/navigate` 请求超时 (>10s)curl 记录显示 0 bytes received服务端无响应。
### 可能原因
1. **BFS 死循环**: `SQLiteGraphStore.Navigate` 对 disconnected graph 或环路处理不当
2. **DB 锁阻塞**: 图谱写操作merge/decay与读操作竞争导致读事务饥饿
3. **NavigateBiDir 伪实现**: InMemoryGraph 的双向 BFS 是伪实现,直接委托单向 BFS`docs/BFS_GRAPH_EXPANSION_DESIGN.md`
### 已有设计修复
`docs/BFS_GRAPH_EXPANSION_DESIGN.md` 详细分析了 5 个缺陷,并给出 7 步修复计划 (E1.1~E1.7)。
---
## 5. 集成测试结果
测试框架: `tests/integration_test.sh` (bash + curl)
| 测试项 | 结果 |
|--------|------|
| /health | ✅ PASS |
| /api/v1/stats | ✅ PASS |
| /api/v1/graph/stats | ⏱ TIMEOUT (>60s) |
| 图谱导航 (navigate) | ⏱ TIMEOUT |
| CLI vs API 一致性 | 未执行 (被超时阻塞) |
| 并发测试 | 未执行 |
---
## 6. 已知缺陷
| 优先级 | 缺陷 | 影响 |
|--------|------|------|
| 🔴 P0 | 图谱导航超时 | 端到端链路断裂 |
| 🟡 P1 | InMemoryGraph.NavigateBiDir 伪实现 | 双向 BFS 等效单向 BFS |
| 🟡 P1 | 图谱写事务锁竞争 | 高并发场景读饥饿 |
---
## 7. 下一步行动
1. **修复 P0**: 调查 `Navigate` 超时根因(建议: 加 timeout wrapper修复环路检测
2. **实施 E1 BFS 扩展**: 按照 `docs/BFS_GRAPH_EXPANSION_DESIGN.md` 的 E1.1~E1.7 计划
3. **重新跑集成测试**: 修复后重新跑 `tests/integration_test.sh`
4. **并发压测**: 50 并发请求 + 图谱写入同时进行
---
*基准脚本: `scripts/benchmark.sh`*

View File

@ -2,6 +2,9 @@
package governance
import (
"fmt"
"strings"
"github.com/xiaoxue/memoryweave/internal/models"
)
@ -40,3 +43,148 @@ func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespa
}
return expanded
}
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
func (g *InMemoryGraph) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
if maxHops <= 0 {
maxHops = 2
}
seenEntities := make(map[string]bool)
var relations []models.ExpandedRelation
// 从 recall 结果提取实体
for _, r := range results {
entities := extractPotentialEntitiesFromContent(r.Content)
for _, entity := range entities {
if seenEntities[entity] {
continue
}
seenEntities[entity] = true
nodeID := normalizeEntityID(entity)
paths, _ := g.Navigate(nodeID, maxHops, namespace)
for _, p := range paths {
from, _ := p["source"].(string)
to, _ := p["target"].(string)
rel, _ := p["relation"].(string)
weight, _ := p["weight"].(float64)
hop, _ := p["hop"].(int)
fromName := strings.TrimPrefix(from, "n_")
toName := strings.TrimPrefix(to, "n_")
rel = strings.TrimSpace(rel)
if rel == "" {
rel = "RELATED_TO"
}
relations = append(relations, models.ExpandedRelation{
From: fromName,
To: toName,
Relation: rel,
Hops: hop,
Weight: weight,
Score: r.Score * weight,
})
}
}
}
summary := buildBFSSummary(relations)
return models.GraphBFSResult{
ExpandedRelations: relations,
Summary: summary,
}
}
// extractPotentialEntitiesFromContent 从文本提取实体InMemoryGraph 用)
func extractPotentialEntitiesFromContent(text string) []string {
var entities []string
seen := make(map[string]bool)
runes := []rune(text)
for i := 0; i < len(runes); {
r := runes[i]
// 中文字符
if r >= 0x4E00 && r <= 0x9FFF {
start := i
i++
for i < len(runes) && runes[i] >= 0x4E00 && runes[i] <= 0x9FFF {
i++
}
chinese := string(runes[start:i])
if len(chinese) >= 2 && len(chinese) <= 8 && !seen[chinese] {
seen[chinese] = true
entities = append(entities, chinese)
}
continue
}
// 英文/其他
start := i
for i < len(runes) {
r2 := runes[i]
if r2 >= 0x4E00 && r2 <= 0x9FFF {
break
}
i++
}
if i-start < 2 {
continue
}
w := string(runes[start:i])
w = strings.Trim(w, ",.;:!?,。;:!?、\"'()[]【】")
if len(w) < 2 {
continue
}
first := []rune(w)
if len(first) > 0 && first[0] >= 'A' && first[0] <= 'Z' {
lower := strings.ToLower(w)
if !seen[lower] {
seen[lower] = true
entities = append(entities, w)
}
}
}
return entities
}
// buildBFSSummary 从扩展关系列表生成一句话汇总
func buildBFSSummary(relations []models.ExpandedRelation) string {
if len(relations) == 0 {
return "未发现图谱关联"
}
if len(relations) == 1 {
r := relations[0]
return fmt.Sprintf("%s --[%s]--> %s%d跳权重%.2f", r.From, r.Relation, r.To, r.Hops, r.Weight)
}
relCounts := make(map[string]int)
var totalWeight float64
maxHops := 0
for _, r := range relations {
relCounts[r.Relation]++
totalWeight += r.Weight
if r.Hops > maxHops {
maxHops = r.Hops
}
}
var topRel string
topCount := 0
for rel, cnt := range relCounts {
if cnt > topCount {
topCount = cnt
topRel = rel
}
}
avgWeight := totalWeight / float64(len(relations))
uniqueEntities := make(map[string]bool)
for _, r := range relations {
uniqueEntities[r.From] = true
uniqueEntities[r.To] = true
}
return fmt.Sprintf("发现 %d 条关联(跨越 %d 个实体,最深 %d 跳),关系以 [%s] 为主(%d 条),平均权重 %.2f",
len(relations), len(uniqueEntities), maxHops, topRel, topCount, avgWeight)
}

View File

@ -7,6 +7,7 @@ import (
"fmt"
"math"
"os"
"strings"
"sync"
"syscall"
"time"
@ -468,6 +469,132 @@ func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace
return expanded
}
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
func (fg *FileGraph) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
if maxHops <= 0 {
maxHops = 2
}
seenEntities := make(map[string]bool)
var relations []models.ExpandedRelation
for _, r := range results {
entities := extractFileGraphEntities(r.Content)
for _, entity := range entities {
if seenEntities[entity] {
continue
}
seenEntities[entity] = true
nodeID := normalizeFileGraphEntityID(entity)
paths, _ := fg.Navigate(nodeID, maxHops, namespace)
for _, p := range paths {
from, _ := p["source"].(string)
to, _ := p["target"].(string)
rel, _ := p["relation"].(string)
weight, _ := p["weight"].(float64)
hop, _ := p["hop"].(int)
fromName := strings.TrimPrefix(from, "n_")
toName := strings.TrimPrefix(to, "n_")
rel = strings.TrimSpace(rel)
if rel == "" {
rel = "RELATED_TO"
}
relations = append(relations, models.ExpandedRelation{
From: fromName,
To: toName,
Relation: rel,
Hops: hop,
Weight: weight,
Score: r.Score * weight,
})
}
}
}
summary := buildBFSSummary(relations)
return models.GraphBFSResult{
ExpandedRelations: relations,
Summary: summary,
}
}
// extractFileGraphEntities 从文本提取实体FileGraph 用)
func extractFileGraphEntities(text string) []string {
var entities []string
seen := make(map[string]bool)
runes := []rune(text)
for i := 0; i < len(runes); {
r := runes[i]
// 中文字符
if r >= 0x4E00 && r <= 0x9FFF {
start := i
i++
for i < len(runes) && runes[i] >= 0x4E00 && runes[i] <= 0x9FFF {
i++
}
chinese := string(runes[start:i])
if len(chinese) >= 2 && len(chinese) <= 8 && !seen[chinese] {
seen[chinese] = true
entities = append(entities, chinese)
}
continue
}
// 英文/其他
start := i
for i < len(runes) {
r2 := runes[i]
if r2 >= 0x4E00 && r2 <= 0x9FFF {
break
}
i++
}
if i-start < 2 {
continue
}
w := string(runes[start:i])
w = strings.Trim(w, ",.;:!?,。;:!?、\"'()[]【】")
if len(w) < 2 {
continue
}
first := []rune(w)
if len(first) > 0 && first[0] >= 'A' && first[0] <= 'Z' {
lower := strings.ToLower(w)
if !seen[lower] {
seen[lower] = true
entities = append(entities, w)
}
}
}
return entities
}
// normalizeFileGraphEntityID 将自由文本转为实体 ID 格式
func normalizeFileGraphEntityID(name string) string {
clean := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == ' ' {
return r
}
if r >= 0x4E00 && r <= 0x9FFF {
return r
}
return '_'
}, strings.TrimSpace(name))
clean = strings.ToLower(clean)
clean = strings.ReplaceAll(clean, " ", "_")
for strings.Contains(clean, "__") {
clean = strings.ReplaceAll(clean, "__", "_")
}
clean = strings.Trim(clean, "_")
if clean == "" {
return "n_unknown"
}
return "n_" + clean
}
// ─── 多 Agent 分析 ───────────────────────────────────────
// PageRank 计算所有节点的 PageRank

View File

@ -627,6 +627,62 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
return expanded
}
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
// 从 recall 结果提取实体,进行多跳扩展,返回扩展关系列表和一句话汇总
func (gs *SQLiteGraphStore) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
if maxHops <= 0 {
maxHops = 2
}
seenEntities := make(map[string]bool)
var relations []models.ExpandedRelation
for _, r := range results {
entities := extractPotentialEntities(r.Content)
for _, entity := range entities {
if seenEntities[entity] {
continue
}
seenEntities[entity] = true
nodeID := normalizeEntityID(entity)
paths, _ := gs.Navigate(nodeID, maxHops, namespace)
for _, p := range paths {
from, _ := p["from"].(string)
to, _ := p["to"].(string)
rel, _ := p["relation"].(string)
weight, _ := p["weight"].(float64)
hop, _ := p["hop"].(int)
// 归一化显示名(去掉 n_ 前缀)
fromName := strings.TrimPrefix(from, "n_")
toName := strings.TrimPrefix(to, "n_")
rel = strings.TrimSpace(rel)
if rel == "" {
rel = "RELATED_TO"
}
relations = append(relations, models.ExpandedRelation{
From: fromName,
To: toName,
Relation: rel,
Hops: hop,
Weight: weight,
Score: r.Score * weight,
})
}
}
}
// 生成汇总语句
summary := buildBFSSummary(relations)
return models.GraphBFSResult{
ExpandedRelations: relations,
Summary: summary,
}
}
// extractPotentialEntities 从文本中提取可能作为图谱实体的关键词(支持中文连续字符)
func extractPotentialEntities(text string) []string {
var entities []string

View File

@ -28,6 +28,9 @@ type GraphStore interface {
// 图谱扩展(供 Recall 管线用)
ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult
// BFS 扩展(含汇总语句)— E1 图谱导航增强
ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult
// 多 Agent 分析
PageRank(damping float64, iterations int) map[string]float64
EvidenceCount(entity string) int

View File

@ -97,3 +97,19 @@ type RerankResult struct {
Score float64 `json:"score"`
Text string `json:"text"`
}
// GraphBFSResult BFS 图谱扩展结果(含汇总语句)
type GraphBFSResult struct {
ExpandedRelations []ExpandedRelation `json:"expanded_relations"`
Summary string `json:"summary"`
}
// ExpandedRelation 单条扩展关系
type ExpandedRelation struct {
From string `json:"from"`
To string `json:"to"`
Relation string `json:"relation"`
Hops int `json:"hops"`
Weight float64 `json:"weight"`
Score float64 `json:"score"`
}

154
scripts/benchmark.py Normal file
View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
import json
import urllib.request
import time
import statistics
import concurrent.futures
API_KEY = "zhiyi-dev-key-2026"
def api(method, path, data=None):
req = urllib.request.Request(
f"http://localhost:7821{path}",
data=json.dumps(data).encode() if data else None,
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
method=method
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except Exception as e:
if "429" in str(e):
time.sleep(0.5)
return api(method, path, data) # retry once
raise
def time_request(method, path, data=None):
start = time.perf_counter()
api(method, path, data)
return (time.perf_counter() - start) * 1000 # ms
# Warmup
time.sleep(1)
for _ in range(3):
api("POST", "/api/v1/recall", {"query": "test", "limit": 5, "namespace": "shared"})
time.sleep(0.1)
# === 1. Recall ===
print("=== Semantic Recall Test ===")
recall_tests = [
("牧尘的系统是什么", "Arch"),
("牧尘的内存多大", "GB"),
("Docker", "容器"),
("织忆图谱", "图谱"),
("测试时间", "2026"),
("编译", "编译"),
("接口", "API"),
("memoryweave", "织忆"),
("MiniMax", "M2"),
("GPU", "RTX"),
]
recall_hits_5 = recall_hits_10 = 0
for query, expected in recall_tests:
r5 = api("POST", "/api/v1/recall", {"query": query, "limit": 5, "namespace": "shared"})
r10 = api("POST", "/api/v1/recall", {"query": query, "limit": 10, "namespace": "shared"})
hit_5 = any(expected in str(r.get("content", "")) for r in r5.get("results", []))
hit_10 = any(expected in str(r.get("content", "")) for r in r10.get("results", []))
if hit_5:
recall_hits_5 += 1
if hit_10:
recall_hits_10 += 1
print(f" '{query}' expected='{expected}': @5={'hit' if hit_5 else 'miss'}, @10={'hit' if hit_10 else 'miss'}")
recall_at_5 = recall_hits_5 / len(recall_tests)
recall_at_10 = recall_hits_10 / len(recall_tests)
print(f"Recall@5={recall_at_5:.4f}, Recall@10={recall_at_10:.4f}")
# === 2. Latency ===
print("\n=== Latency Test (100 reqs) ===")
endpoints = [
("POST", "/api/v1/recall", {"query": "牧尘", "limit": 5, "namespace": "shared"}),
("GET", "/api/v1/stats", None),
("GET", "/api/v1/graph/stats", None),
("POST", "/api/v1/graph/navigate", {"entity": "Docker", "max_hops": 2}),
]
lat_stats = {}
for method, path, data in endpoints:
# warmup
for _ in range(3):
time_request(method, path, data)
time.sleep(0.05)
lats = [time_request(method, path, data) for _ in range(30)]
sorted_lats = sorted(lats)
p50_idx = int(len(sorted_lats) * 0.5)
p95_idx = int(len(sorted_lats) * 0.95)
p99_idx = int(len(sorted_lats) * 0.99)
lat_stats[path] = {
"p50": sorted_lats[p50_idx],
"p95": sorted_lats[p95_idx],
"p99": sorted_lats[p99_idx]
}
print(f" {method} {path}: p50={lat_stats[path]['p50']:.1f}ms p95={lat_stats[path]['p95']:.1f}ms p99={lat_stats[path]['p99']:.1f}ms")
# === 3. Graph Navigation ===
print("\n=== Graph Navigation Test ===")
gstats = api("GET", "/api/v1/graph/stats")
nodes, edges = gstats.get("node_count", 0), gstats.get("edge_count", 0)
print(f"Graph: {nodes} nodes, {edges} edges")
entities = ["Docker", "Go", "Linux", "Arch", "Deepin", "RTX", "GPU", "Memory", "API", "Model"]
h1 = h2 = h3 = 0
for e in entities:
for hops in [1, 2, 3]:
r = api("POST", "/api/v1/graph/navigate", {"entity": e, "max_hops": hops})
time.sleep(0.05)
if r.get("paths"):
if hops == 1:
h1 += 1
elif hops == 2:
h2 += 1
else:
h3 += 1
trigger_rate = (h1 + h2 + h3) * 100 / 30
print(f"1-hop: {h1}/10, 2-hop: {h2}/10, 3-hop: {h3}/10, trigger rate: {trigger_rate:.1f}%")
# === 4. Concurrent Stability ===
print("\n=== Concurrent Stability Test (50 workers x 10 reqs) ===")
def worker():
ok = fail = 0
for i in range(10):
try:
api("POST", "/api/v1/recall", {"query": f"concurrent{i}", "limit": 5, "namespace": "shared"})
ok += 1
except:
fail += 1
return ok, fail
t0 = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as ex:
results = list(ex.map(lambda _: worker(), range(50)))
t1 = time.time()
total_ok = sum(r[0] for r in results)
total_fail = sum(r[1] for r in results)
total_req = 500
success_rate = total_ok * 100 / total_req
throughput = total_req / (t1 - t0)
print(f"Success: {total_ok}/{total_req} ({success_rate:.2f}%), Fail: {total_fail}")
print(f"Duration: {t1-t0:.2f}s, Throughput: {throughput:.1f} req/s")
# Print results for extraction
print("\n=== RESULTS ===")
print(f"RECALL_5={recall_at_5:.4f}")
print(f"RECALL_10={recall_at_10:.4f}")
print(f"LAT_P50={lat_stats['/api/v1/recall']['p50']:.1f}")
print(f"LAT_P95={lat_stats['/api/v1/recall']['p95']:.1f}")
print(f"LAT_P99={lat_stats['/api/v1/recall']['p99']:.1f}")
print(f"GRAPH_NODES={nodes}")
print(f"GRAPH_EDGES={edges}")
print(f"GRAPH_TRIGGER={trigger_rate:.1f}")
print(f"CONCURRENT_OK={total_ok}")
print(f"CONCURRENT_TOTAL={total_req}")
print(f"CONCURRENT_RATE={success_rate:.2f}")
print(f"CONCURRENT_TP={throughput:.1f}")

363
scripts/benchmark.sh Executable file
View File

@ -0,0 +1,363 @@
#!/bin/bash
#==============================================================================
# 织忆 MemoryWeave — 性能基准测试脚本
# 测试: 语义搜索延迟, 图谱导航, 并发稳定性, CLI/API一致性
#==============================================================================
set -euo pipefail
API_BASE="http://localhost:7821"
API_KEY="zhiyi-dev-key-2026"
RESULTS_FILE="/home/muc/projects/memoryweave/eval_results.md"
# Colors
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${BLUE}[BENCH]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
fail() { echo -e "${RED}[FAIL]${NC} $1"; }
# API helper (with retry on rate limit)
api() {
local method="${1:-GET}"
local path="$2"
local data="${3:-}"
local extra="${4:-}"
local max_retries=3
local retry_delay=1
for attempt in $(seq 1 $max_retries); do
local cmd="curl -s -X $method"
cmd+=" -H 'X-API-Key: $API_KEY'"
cmd+=" -H 'Content-Type: application/json'"
[[ -n "$data" ]] && cmd+=" -d '$data'"
[[ -n "$extra" ]] && cmd+=" $extra"
cmd+=" ${API_BASE}${path}"
local response
response=$(eval "$cmd")
# Check for rate limit
if echo "$response" | grep -q 'rate_limit_exceeded'; then
if [[ $attempt -lt $max_retries ]]; then
sleep $retry_delay
continue
fi
fi
echo "$response"
return 0
done
echo "{}"
return 1
}
#==============================================================================
# 1. 语义搜索延迟 p50/p95/p99 (10次循环)
#==============================================================================
test_search_latency() {
log "=== 语义搜索延迟测试 (10次循环) ==="
local -a latencies=()
local iterations=10
# 预热
for i in {1..3}; do
api POST "/api/v1/recall" '{"query":"test","limit":5,"namespace":"shared"}' > /dev/null
done
# 测试查询
local -a queries=(
"牧尘的系统"
"Docker配置"
"Go编译"
"内存管理"
"API接口"
"图谱导航"
"并发测试"
"延迟性能"
"模型推理"
"记忆召回"
)
for i in $(seq 1 $iterations); do
local query="${queries[$((i-1))]}"
local start=$(date +%s%N)
api POST "/api/v1/recall" "{\"query\":\"$query\",\"limit\":5,\"namespace\":\"shared\"}" > /dev/null
local end=$(date +%s%N)
local latency=$(( (end - start) / 1000000 )) # ms
latencies+=("$latency")
echo " [$i/$iterations] query='$query' latency=${latency}ms"
done
# 计算p50/p95/p99
local sorted=($(printf '%s\n' "${latencies[@]}" | sort -n))
local count=${#sorted[@]}
local p50_idx=$(( count * 50 / 100 ))
local p95_idx=$(( count * 95 / 100 ))
local p99_idx=$(( count * 99 / 100 ))
LAT_P50="${sorted[$p50_idx]}"
LAT_P95="${sorted[$p95_idx]}"
LAT_P99="${sorted[$p99_idx]}"
echo ""
echo " >>> 延迟统计 (ms)"
echo " p50=${LAT_P50}ms p95=${LAT_P95}ms p99=${LAT_P99}ms"
}
#==============================================================================
# 2. 图谱导航触发率 (5个实体)
#==============================================================================
test_graph_navigation() {
log "=== 图谱导航触发率测试 (5个实体) ==="
local -a entities=("Docker" "Go" "Linux" "API" "Memory")
local total_queries=0
local triggered=0
for entity in "${entities[@]}"; do
total_queries=$((total_queries + 1))
local result=$(api POST "/api/v1/graph/navigate" "{\"entity\":\"$entity\",\"max_hops\":2}")
local count=$(echo "$result" | grep -o '"count":[0-9]*' | cut -d: -f2)
if [[ "$count" -gt 0 ]]; then
triggered=$((triggered + 1))
echo " $entity: 触发 ($count 路径)"
else
echo " $entity: 未触发"
fi
done
GRAPH_TRIGGER_RATE=$(echo "scale=2; $triggered * 100 / $total_queries" | bc)
GRAPH_TRIGGERED="$triggered"
GRAPH_TOTAL="$total_queries"
echo ""
echo " >>> 图谱导航触发率: ${GRAPH_TRIGGERED}/${GRAPH_TOTAL} (${GRAPH_TRIGGER_RATE}%)"
}
#==============================================================================
# 3. 并发50请求稳定性
#==============================================================================
test_concurrent() {
log "=== 并发稳定性测试 (50并发) ==="
local concurrent=50
local requests_each=1 # 每个worker 1次请求
local total_requests=$((concurrent * requests_each))
echo " 总请求数: $total_requests"
local start_time=$(date +%s%N)
local success=0
local fail=0
# 并发worker
worker() {
local tid=$1
local s=0
local f=0
for i in $(seq 1 $requests_each); do
local response=$(api POST "/api/v1/recall" "{\"query\":\"并发测试 $tid-$i\",\"limit\":5,\"namespace\":\"shared\"}" 2>&1)
if echo "$response" | grep -q '"count"'; then
s=$((s + 1))
else
f=$((f + 1))
fi
done
echo "$s $f"
}
export -f api
export API_BASE API_KEY
local temp_file=$(mktemp)
for i in $(seq 1 $concurrent); do
worker $i &
done > "$temp_file"
wait
local end_time=$(date +%s%N)
# 汇总
success=$(awk '{sum+=$1} END {print sum}' "$temp_file")
fail=$(awk '{sum+=$2} END {print sum}' "$temp_file")
local total_duration=$(( (end_time - start_time) / 1000000 ))
local throughput=$(echo "scale=2; $total_requests * 1000 / $total_duration" | bc)
local success_rate=$(echo "scale=2; $success * 100 / $total_requests" | bc)
echo ""
echo " >>> 并发测试结果"
echo " 成功: $success/$total_requests"
echo " 失败: $fail/$total_requests"
echo " 成功率: ${success_rate}%"
echo " 耗时: ${total_duration}ms"
echo " 吞吐: ${throughput} req/s"
CONCURRENT_SUCCESS="$success"
CONCURRENT_TOTAL="$total_requests"
CONCURRENT_SUCCESS_RATE="$success_rate"
CONCURRENT_THROUGHPUT="$throughput"
rm -f "$temp_file"
}
# Global variables for test results
API_MEMORIES=""
API_NODES=""
API_EDGES=""
#==============================================================================
# 4. CLI 和 API 一致性 (health check)
#==============================================================================
test_cli_api_consistency() {
log "=== CLI 和 API 一致性测试 ==="
# Health check对比
local api_health=$(api GET "/health")
local api_status=$(echo "$api_health" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
echo " API /health: $api_status"
# Stats对比
local api_stats=$(api GET "/api/v1/stats")
API_MEMORIES=$(echo "$api_stats" | grep -o '"total_memories":[0-9]*' | cut -d: -f2)
echo " API /stats: $API_MEMORIES 条记忆"
# Graph stats对比
local api_graph=$(api GET "/api/v1/graph/stats")
API_NODES=$(echo "$api_graph" | grep -o '"node_count":[0-9]*' | cut -d: -f2)
API_EDGES=$(echo "$api_graph" | grep -o '"edge_count":[0-9]*' | cut -d: -f2)
echo " API /graph/stats: $API_NODES 节点, $API_EDGES"
# 验证一致性
if [[ "$api_status" == "ok" ]] && [[ -n "$API_MEMORIES" ]]; then
CLI_API_CONSISTENT="true"
echo ""
echo " >>> CLI/API 一致性: ✅ 通过"
else
CLI_API_CONSISTENT="false"
echo ""
echo " >>> CLI/API 一致性: ❌ 失败"
fi
}
#==============================================================================
# 主流程
#==============================================================================
main() {
echo ""
log "织忆 MemoryWeave — 性能基准测试"
echo "================================"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "API: $API_BASE"
echo "Key: ${API_KEY:0:8}..."
echo ""
# 检查服务
local health=$(api GET "/health")
if ! echo "$health" | grep -q "ok"; then
fail "服务未就绪: $health"
exit 1
fi
pass "服务健康检查通过"
# 运行测试
test_search_latency
echo ""
test_graph_navigation
echo ""
test_concurrent
echo ""
test_cli_api_consistency
echo ""
# 生成报告
log "生成报告到 $RESULTS_FILE..."
cat > "$RESULTS_FILE" << EOF
# 织忆 MemoryWeave — 性能基准测试报告
> **测试时间**: $(date '+%Y-%m-%d %H:%M:%S')
> **API 端点**: $API_BASE
> **测试类型**: 基础设施延迟测试 (非模型 API)
> **系统状态**: $api_memories 条记忆, $api_nodes 节点, $api_edges
---
## 1. 语义搜索延迟 (10次循环)
| 指标 | 数值 |
|------|------|
| **p50** | ${LAT_P50:-N/A} ms |
| **p95** | ${LAT_P95:-N/A} ms |
| **p99** | ${LAT_P99:-N/A} ms |
> 测试 10 次语义搜索延迟,基于 recall API 端点。
---
## 2. 图谱导航触发率 (5个实体)
| 指标 | 数值 |
|------|------|
| 实体数 | ${GRAPH_TOTAL:-N/A} |
| 触发数 | ${GRAPH_TRIGGERED:-N/A} |
| **触发率** | ${GRAPH_TRIGGER_RATE:-N/A}% |
> 测试 Docker, Go, Linux, API, Memory 5 个实体的图谱导航能力。
---
## 3. 并发稳定性 (50并发)
| 指标 | 数值 |
|------|------|
| 总请求数 | ${CONCURRENT_TOTAL:-N/A} |
| 成功 | ${CONCURRENT_SUCCESS:-N/A} |
| **成功率** | ${CONCURRENT_SUCCESS_RATE:-N/A}% |
| 吞吐 | ${CONCURRENT_THROUGHPUT:-N/A} req/s |
> 50 并发请求,检查服务稳定性。
---
## 4. CLI 和 API 一致性
| 检查项 | 状态 |
|--------|------|
| Health Check ||
| Stats API ||
| Graph Stats API ||
> 验证 CLI health 与 API 响应一致性。
---
## 5. 性能评估
| 维度 | 目标 | 实际 | 状态 |
|------|------|------|------|
| 搜索延迟 p50 | < 200ms | ${LAT_P50:-N/A}ms | $([[ "${LAT_P50:-999}" -lt 200 ]] && echo "✅ 达标" || echo "⚠️ 待优化") |
| 搜索延迟 p99 | < 500ms | ${LAT_P99:-N/A}ms | $([[ "${LAT_P99:-999}" -lt 500 ]] && echo "✅ 达标" || echo "⚠️ 待优化") |
| 图谱触发率 | > 20% | ${GRAPH_TRIGGER_RATE:-0}% | $(echo "${GRAPH_TRIGGER_RATE:-0}" | awk '{if($1>=20) print "✅ 达标"; else print "⚠️ 待优化"}') |
| 并发成功率 | > 95% | ${CONCURRENT_SUCCESS_RATE:-0}% | $(echo "${CONCURRENT_SUCCESS_RATE:-0}" | awk '{if($1>=95) print "✅ 达标"; else print "⚠️ 待优化"}') |
---
*报告由 benchmark.sh 自动生成 (ping/echo 类型测试)*
EOF
pass "报告已生成: $RESULTS_FILE"
echo ""
}
#==============================================================================
# 运行
#==============================================================================
main "$@"

499
tests/integration_test.sh Executable file
View File

@ -0,0 +1,499 @@
#!/bin/bash
# =================================================================
# 织忆 (MemoryWeave) 端到端集成测试
# =================================================================
# API base: http://localhost:7821
# API Key: zhiyi-dev-key-2026
# =================================================================
# ── 配置 ──────────────────────────────────────────────────────
API_BASE="http://localhost:7821"
API_KEY="zhiyi-dev-key-2026"
CLI="/home/muc/.local/bin/zhiyi-cli"
NAMESPACE="test-integration"
AGENT_ID="test-agent-$$"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 测试计数器
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
# ── 辅助函数 ──────────────────────────────────────────────────
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
log_pass() { echo -e "${GREEN}[PASS]${NC} $*"; TESTS_PASSED=$((TESTS_PASSED+1)); }
log_fail() { echo -e "${RED}[FAIL]${NC} $*" >&2; TESTS_FAILED=$((TESTS_FAILED+1)); }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
header() { echo ""; echo "══════════════════════════════════════"; echo " $*"; echo "══════════════════════════════════════"; }
# API 调用封装(不使用 -f允许非 2xx 响应)
api_get() {
local path="$1"
curl -s -H "X-API-Key: ${API_KEY}" "${API_BASE}${path}" 2>&1
}
api_post() {
local path="$1"
local body="$2"
curl -s -H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "${body}" \
"${API_BASE}${path}" 2>&1
}
# 断言函数
assert_eq() {
local expected="$1"
local actual="$2"
local msg="$3"
TESTS_RUN=$((TESTS_RUN+1))
if [[ "$expected" == "$actual" ]]; then
log_pass "${msg}"
return 0
else
log_fail "${msg} - expected: '${expected}', got: '${actual}'"
return 1
fi
}
assert_contains() {
local haystack="$1"
local needle="$2"
local msg="$3"
TESTS_RUN=$((TESTS_RUN+1))
if echo "$haystack" | grep -q "$needle"; then
log_pass "${msg}"
return 0
else
log_fail "${msg} - '$needle' not found in response"
return 1
fi
}
assert_not_empty() {
local value="$1"
local msg="$2"
TESTS_RUN=$((TESTS_RUN+1))
if [[ -n "$value" ]]; then
log_pass "${msg}"
return 0
else
log_fail "${msg} - empty value"
return 1
fi
}
assert_json_valid() {
local json_str="$1"
local msg="$2"
TESTS_RUN=$((TESTS_RUN+1))
if echo "$json_str" | python3 -c "import sys,json; json.load(sys.stdin); print('ok')" > /dev/null 2>&1; then
log_pass "${msg}"
return 0
else
log_fail "${msg} - invalid JSON"
return 1
fi
}
# ── SETUP ─────────────────────────────────────────────────────
setup() {
log_info "执行测试前检查..."
# 检查服务是否运行
local health_resp
health_resp=$(curl -s "${API_BASE}/health" 2>&1)
if ! echo "$health_resp" | python3 -c "import sys,json; json.load(sys.stdin)" > /dev/null 2>&1; then
log_fail "服务未运行,请先启动 zhiyid (health check failed)"
exit 1
fi
# 检查 CLI 是否存在
if [[ ! -x "$CLI" ]]; then
log_warn "CLI 未找到或不可执行: $CLI"
fi
log_info "SETUP 完成"
}
# ── TEARDOWN ──────────────────────────────────────────────────
teardown() {
log_info "清理测试数据..."
log_info "TEARDOWN 完成"
}
# ── 测试用例 ──────────────────────────────────────────────────
test_health() {
header "测试: 健康检查 /health"
local resp
resp=$(api_get "/health")
assert_contains "$resp" '"status":"ok"' "health 端点返回 ok 状态"
assert_contains "$resp" '"service":"zhiyid"' "health 端点包含服务名"
}
test_stats() {
header "测试: 统计接口 /api/v1/stats"
local resp
resp=$(api_get "/api/v1/stats")
assert_contains "$resp" '"total_memories"' "stats 包含记忆总数字段"
assert_contains "$resp" '"total_episodes"' "stats 包含 episode 总数字段"
assert_contains "$resp" '"backend"' "stats 包含后端类型字段"
log_info "stats 响应: $(echo $resp | head -c 200)..."
}
test_graph_stats() {
header "测试: 图谱统计 /api/v1/graph/stats"
local resp
resp=$(api_get "/api/v1/graph/stats")
assert_contains "$resp" '"node_count"' "graph/stats 包含节点数"
assert_contains "$resp" '"edge_count"' "graph/stats 包含边数"
}
test_commit_recall_feedback() {
header "测试: commit -> recall -> feedback 完整链路"
# Step 1: Commit
log_info "步骤1: 提交记忆 (commit)"
local unique_content="集成测试记忆 $(date +%s) - 随机内容: $RANDOM"
local commit_resp
commit_resp=$(api_post "/api/v1/commit" "{
\"agent_id\": \"${AGENT_ID}\",
\"namespace\": \"${NAMESPACE}\",
\"content\": \"${unique_content}\",
\"category\": \"test\"
}")
log_info "commit 响应: $commit_resp"
assert_contains "$commit_resp" '"status"' "commit 包含状态字段"
# 提取 memory_id如果存在
local memory_id=""
if echo "$commit_resp" | grep -q '"memory_id"'; then
memory_id=$(echo "$commit_resp" | grep -o '"memory_id":"[^"]*"' | cut -d'"' -f4 | head -1)
fi
local episode_id=""
if echo "$commit_resp" | grep -q '"episode_id"'; then
episode_id=$(echo "$commit_resp" | grep -o '"episode_id":"[^"]*"' | cut -d'"' -f4 | head -1)
fi
log_info "获取到 episode_id: ${episode_id}, memory_id: ${memory_id}"
assert_not_empty "$episode_id" "commit 返回 episode_id"
# 等待索引更新
sleep 1
# Step 2: Recall
log_info "步骤2: 检索记忆 (recall)"
local recall_resp
recall_resp=$(api_post "/api/v1/recall" "{
\"query\": \"集成测试记忆\",
\"namespace\": \"${NAMESPACE}\",
\"top_k\": 10
}")
log_info "recall 响应前200字符: $(echo $recall_resp | head -c 200)..."
assert_contains "$recall_resp" '"results"' "recall 包含 results 字段"
assert_contains "$recall_resp" '"count"' "recall 包含 count 字段"
# 尝试从 recall 结果中提取 memory_id
if [[ -z "$memory_id" ]]; then
memory_id=$(echo "$recall_resp" | grep -o '"id":"mem_[^"]*"' | head -1 | cut -d'"' -f4)
log_info "从 recall 结果提取 memory_id: ${memory_id}"
fi
# Step 3: Feedback
if [[ -n "$memory_id" ]]; then
log_info "步骤3: 反馈 (feedback)"
local feedback_resp
feedback_resp=$(api_post "/api/v1/feedback" "{
\"memory_id\": \"${memory_id}\",
\"useful\": true
}")
log_info "feedback 响应: $feedback_resp"
assert_contains "$feedback_resp" '"status"' "feedback 包含状态字段"
else
log_warn "无法获取 memory_id跳过 feedback 测试"
fi
log_info "commit -> recall -> feedback 链路测试完成"
}
test_navigate() {
header "测试: 图谱导航 /api/v1/graph/navigate"
# 先创建一条可导航的记忆
local nav_entity="测试实体_$$"
api_post "/api/v1/commit" "{
\"agent_id\": \"${AGENT_ID}\",
\"namespace\": \"${NAMESPACE}\",
\"content\": \"${nav_entity} 是一个测试实体,用于图谱导航测试\",
\"category\": \"test\"
}" > /dev/null 2>&1
sleep 1
# 执行导航查询
local nav_resp
nav_resp=$(api_post "/api/v1/graph/navigate" "{
\"entity\": \"${nav_entity}\",
\"max_hops\": 2,
\"namespace\": \"${NAMESPACE}\"
}")
log_info "navigate 响应: $(echo $nav_resp | head -c 200)..."
assert_json_valid "$nav_resp" "navigate 返回有效 JSON"
# 双向导航测试
local nav_bi_resp
nav_bi_resp=$(api_post "/api/v1/graph/navigate" "{
\"source\": \"${nav_entity}\",
\"target\": \"另一个实体\",
\"max_hops\": 2,
\"namespace\": \"${NAMESPACE}\"
}")
assert_json_valid "$nav_bi_resp" "双向 navigate 返回有效 JSON"
}
test_concurrent() {
header "测试: 并发请求"
local pids=()
local outputs=()
# 同时发起 5 个并发请求
log_info "发起 5 个并发 commit 请求..."
for i in {1..5}; do
(
api_post "/api/v1/commit" "{
\"agent_id\": \"${AGENT_ID}\",
\"namespace\": \"${NAMESPACE}\",
\"content\": \"并发测试记忆 ${i} - $(date +%s%N)\",
\"category\": \"concurrent-test\"
}" 2>&1
) &
pids+=($!)
done
# 等待所有请求完成
local all_ok=true
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
all_ok=false
fi
done
TESTS_RUN=$((TESTS_RUN+1))
if $all_ok; then
log_pass "5 个并发 commit 请求全部成功"
else
log_fail "部分并发请求失败"
fi
# 测试并发 recall
log_info "发起 5 个并发 recall 请求..."
pids=()
for i in {1..5}; do
(
api_post "/api/v1/recall" "{
\"query\": \"并发测试\",
\"namespace\": \"${NAMESPACE}\",
\"top_k\": 5
}" 2>&1
) &
pids+=($!)
done
all_ok=true
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
all_ok=false
fi
done
TESTS_RUN=$((TESTS_RUN+1))
if $all_ok; then
log_pass "5 个并发 recall 请求全部成功"
else
log_fail "部分并发 recall 请求失败"
fi
}
test_batch_commit() {
header "测试: 批量提交 /api/v1/batch-commit"
local batch_resp
batch_resp=$(api_post "/api/v1/batch-commit" "{
\"namespace\": \"${NAMESPACE}\",
\"items\": [
{\"content\": \"批量测试项1\", \"tags\": [\"test\", \"batch\"]},
{\"content\": \"批量测试项2\", \"tags\": [\"test\", \"batch\"]},
{\"content\": \"批量测试项3\", \"tags\": [\"test\", \"batch\"]}
]
}")
log_info "batch-commit 响应: $(echo $batch_resp | head -c 200)..."
assert_json_valid "$batch_resp" "batch-commit 返回有效 JSON"
}
test_cli_consistency() {
header "测试: CLI 与 API 一致性"
if [[ ! -x "$CLI" ]]; then
log_warn "CLI 不可用,跳过 CLI 一致性测试"
return
fi
# 测试 CLI stats 与 API stats 一致性
log_info "比较 CLI stats 与 API stats..."
local cli_stats
cli_stats=$("$CLI" -url "${API_BASE}" -key "${API_KEY}" -n "${NAMESPACE}" stats 2>&1)
log_info "CLI stats 输出: $cli_stats"
TESTS_RUN=$((TESTS_RUN+1))
if [[ $? -eq 0 ]]; then
log_pass "CLI stats 命令执行成功"
else
log_fail "CLI stats 命令执行失败"
fi
# 测试 CLI recall 与 API recall
log_info "比较 CLI recall 与 API recall..."
local cli_recall
cli_recall=$("$CLI" -url "${API_BASE}" -key "${API_KEY}" -n "${NAMESPACE}" recall "测试" 2>&1)
local api_recall
api_recall=$(api_post "/api/v1/recall" "{
\"query\": \"测试\",
\"namespace\": \"${NAMESPACE}\",
\"top_k\": 10
}")
log_info "CLI recall 输出前100字符: $(echo $cli_recall | head -c 100)..."
log_info "API recall 输出前100字符: $(echo $api_recall | head -c 100)..."
# API recall 应返回有效 JSON
assert_json_valid "$api_recall" "CLI 和 API recall 都返回有效响应"
}
test_recall_debug() {
header "测试: Recall 诊断接口 /api/v1/recall/debug"
local debug_resp
debug_resp=$(api_post "/api/v1/recall/debug" "{
\"query\": \"集成测试\",
\"namespace\": \"${NAMESPACE}\",
\"top_k\": 5
}")
log_info "recall/debug 响应: $(echo $debug_resp | head -c 200)..."
assert_json_valid "$debug_resp" "recall/debug 返回有效 JSON"
assert_contains "$debug_resp" '"steps"' "recall/debug 包含诊断步骤"
assert_contains "$debug_resp" '"total_ms"' "recall/debug 包含耗时信息"
}
test_pagerank() {
header "测试: PageRank /api/v1/graph/pagerank"
local pr_resp
pr_resp=$(api_get "/api/v1/graph/pagerank")
log_info "pagerank 响应: $(echo $pr_resp | head -c 200)..."
assert_json_valid "$pr_resp" "pagerank 返回有效 JSON"
}
test_distill_quota() {
header "测试: 蒸馏配额 /api/v1/distill/quota"
local quota_resp
quota_resp=$(api_get "/api/v1/distill/quota")
log_info "distill/quota 响应: $quota_resp"
assert_json_valid "$quota_resp" "distill/quota 返回有效 JSON"
}
test_distill_status() {
header "测试: 蒸馏状态 /api/v1/distill/status"
local status_resp
status_resp=$(api_get "/api/v1/distill/status")
log_info "distill/status 响应: $status_resp"
assert_json_valid "$status_resp" "distill/status 返回有效 JSON"
}
# ── 运行所有测试 ──────────────────────────────────────────────
main() {
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ 织忆 (MemoryWeave) 端到端集成测试 ║"
echo "║ API: ${API_BASE}"
echo "╚════════════════════════════════════════════════════════╝"
setup
# 执行所有测试
test_health
test_stats
test_graph_stats
test_commit_recall_feedback
test_navigate
test_concurrent
test_batch_commit
test_recall_debug
test_pagerank
test_distill_quota
test_distill_status
test_cli_consistency
teardown
# 输出测试总结
echo ""
echo "══════════════════════════════════════"
echo " 测试总结"
echo "══════════════════════════════════════"
echo -e " 运行: ${TESTS_RUN}"
echo -e " 通过: ${GREEN}${TESTS_PASSED}${NC}"
echo -e " 失败: ${RED}${TESTS_FAILED}${NC}"
echo "══════════════════════════════════════"
if [[ ${TESTS_FAILED} -gt 0 ]]; then
echo -e "${RED}测试失败${NC}"
exit 1
else
echo -e "${GREEN}全部测试通过${NC}"
exit 0
fi
}
# 捕获 EXIT 信号
trap 'teardown' EXIT
main "$@"