From 40a8c9edee6e76e081081e735507e451811225dd Mon Sep 17 00:00:00 2001 From: xiaowei Date: Tue, 2 Jun 2026 18:58:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20E1=20BFS=20=E6=89=A9=E5=B1=95=20+=20?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E9=83=A8=E7=BD=B2=E5=AE=8C=E6=95=B4=E5=A5=97?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- INSTALL.md | 212 ++++++++++ Makefile | 50 ++- README.md | 322 ++++++++++++++- docker-compose.yml | 95 +++++ docs/BFS_GRAPH_EXPANSION_DESIGN.md | 447 ++++++++++++++++++++ eval_results.md | 101 +++++ go/internal/governance/graph_expander.go | 148 +++++++ go/internal/governance/graph_file.go | 127 ++++++ go/internal/governance/graph_sqlite.go | 56 +++ go/internal/governance/graph_store.go | 3 + go/internal/models/memory.go | 16 + scripts/benchmark.py | 154 +++++++ scripts/benchmark.sh | 363 +++++++++++++++++ tests/integration_test.sh | 499 +++++++++++++++++++++++ 14 files changed, 2575 insertions(+), 18 deletions(-) create mode 100644 INSTALL.md create mode 100644 docker-compose.yml create mode 100644 docs/BFS_GRAPH_EXPANSION_DESIGN.md create mode 100644 eval_results.md create mode 100644 scripts/benchmark.py create mode 100755 scripts/benchmark.sh create mode 100755 tests/integration_test.sh diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..f50c0c7 --- /dev/null +++ b/INSTALL.md @@ -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` + +### Q:Redis 未安装 + +默认降级为内存存储,无需 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 +``` \ No newline at end of file diff --git a/Makefile b/Makefile index 9634e53..efe8e59 100644 --- a/Makefile +++ b/Makefile @@ -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" 所有 `/api/v1/*` 接口需要 Header: `X-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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..69cf727 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docs/BFS_GRAPH_EXPANSION_DESIGN.md b/docs/BFS_GRAPH_EXPANSION_DESIGN.md new file mode 100644 index 0000000..3c765e7 --- /dev/null +++ b/docs/BFS_GRAPH_EXPANSION_DESIGN.md @@ -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 Graphiti(Fixie 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 Mem0(mem0ai/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 Cortex(IASolutionOrg/Cortex)— GraphRAG 知识库 + +**仓库**:`IASolutionOrg/Cortex`(开源,3 ⭐) +**描述**:通用 AI Agent 长期记忆系统,GraphRAG 驱动的知识库 + +**核心设计**: +- **双索引**:向量数据库(Qdrant)做语义检索 + 图数据库(Neo4j)做结构化遍历 +- **混合查询**:先用向量找到相关实体节点,再以这些节点为种子做图遍历 +- **多跳扩展**:从种子节点出发做 BFS,按跳数控制遍历深度 +- **上下文组装**:将 BFS 遍历收集的所有节点/边打包为 LLM 上下文 + +**参考价值**:混合检索架构(向量 + 图)和"以向量结果为种子驱动图扩展"的模式与 MemoryWeave §2.6 设计高度一致。 + +--- + +### 2.4 Letta(letta-ai/letta)— 持久化 Agent 记忆 + +**仓库**:`letta-ai/letta`(开源,17k ⭐) +**描述**:为 LLM 提供持久化记忆的框架,支持实体关系图和 SQL 记忆 + +**核心设计**: +- **实体图**:从对话中提取实体,构建实体关系图 +- **多跳查询**:使用递归 CTE(SQLite)实现多跳遍历 +- **路径搜索**:支持 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. 对每个实体执行双向 BFS(maxHops=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` 数据结构 | \ No newline at end of file diff --git a/eval_results.md b/eval_results.md new file mode 100644 index 0000000..8fa01d0 --- /dev/null +++ b/eval_results.md @@ -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`* \ No newline at end of file diff --git a/go/internal/governance/graph_expander.go b/go/internal/governance/graph_expander.go index 7c4623a..1074e55 100644 --- a/go/internal/governance/graph_expander.go +++ b/go/internal/governance/graph_expander.go @@ -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) +} diff --git a/go/internal/governance/graph_file.go b/go/internal/governance/graph_file.go index 70ae050..a33407a 100644 --- a/go/internal/governance/graph_file.go +++ b/go/internal/governance/graph_file.go @@ -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 diff --git a/go/internal/governance/graph_sqlite.go b/go/internal/governance/graph_sqlite.go index 06049a7..8e0fbab 100644 --- a/go/internal/governance/graph_sqlite.go +++ b/go/internal/governance/graph_sqlite.go @@ -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 diff --git a/go/internal/governance/graph_store.go b/go/internal/governance/graph_store.go index 864f731..6205b64 100644 --- a/go/internal/governance/graph_store.go +++ b/go/internal/governance/graph_store.go @@ -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 diff --git a/go/internal/models/memory.go b/go/internal/models/memory.go index d31f4a9..de67e23 100644 --- a/go/internal/models/memory.go +++ b/go/internal/models/memory.go @@ -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"` +} diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..8edd41a --- /dev/null +++ b/scripts/benchmark.py @@ -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}") \ No newline at end of file diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh new file mode 100755 index 0000000..9603cde --- /dev/null +++ b/scripts/benchmark.sh @@ -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 "$@" \ No newline at end of file diff --git a/tests/integration_test.sh b/tests/integration_test.sh new file mode 100755 index 0000000..a877ea9 --- /dev/null +++ b/tests/integration_test.sh @@ -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 "$@" \ No newline at end of file