扩展全景
第 13 章的 mini-mem 走通了「捕获 → 存储 → 注入 / 检索」的最小闭环。本章在这条主链路上挂三个扩展:AI 压缩、向量检索和实时 Viewer。图 14-1 标出了每个扩展的挂接位置和数据通路。
图 14-1:三个扩展模块与 mini-mem 主链路的挂接关系。实线是第 13 章已有链路,虚线是本章新增的数据通路。
配套代码 examples/ch14-extensions/src/ 下只有三个扁平文件:ai-compressor.ts、vector-search.ts、viewer-server.ts,各自是可独立运行的演示(对应 npm run demo:compress / demo:search / demo:viewer)。把它们真正接进 mini-mem 涉及代码组织和异步改造,属于设计层面的讨论。正文会明确区分两类代码块:标注了配套文件名的是可运行节选,标注「设计示意」的则留作练习。
添加 AI 压缩(接入 Claude API)
mini-mem 的基础版使用规则提取标题,信息密度有限。接入 Claude API 后可以实现真正的语义压缩。
实现思路
配套代码 examples/ch14-extensions/src/ai-compressor.ts 是可直接运行的压缩演示(npm run demo:compress),它把一批 observation 批量压缩成一段摘要。挂进 mini-mem 的主链路时,更实用的形态是按单条工具调用压缩、输出结构化 observation,下面给出这一版的设计:
// 设计示意:单条工具调用的语义压缩,接入 mini-mem 的完整实现留作练习
// 可运行的批量压缩演示见 examples/ch14-extensions/src/ai-compressor.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic(); // 读取 ANTHROPIC_API_KEY 环境变量
export async function compressObservation(
toolName: string,
toolInput: Record<string, unknown>,
toolResponse: Record<string, unknown>
): Promise<{ type: string; title: string; narrative: string; facts: string[] }> {
const response = await client.messages.create({
model: 'claude-haiku-4-5-20251001', // 用最新的 Haiku 模型降低成本(请替换为当前可用的模型 ID)
max_tokens: 300,
messages: [{
role: 'user',
content: `分析以下工具调用,提取一条结构化观察。
工具: ${toolName}
输入: ${JSON.stringify(toolInput).slice(0, 1000)}
输出: ${JSON.stringify(toolResponse).slice(0, 500)}
以 JSON 格式返回:
{"type": "change|bugfix|discovery|decision|how-it-works", "title": "10字以内的标题", "narrative": "50字以内的叙述", "facts": ["事实1", "事实2"]}`
}]
});
const text = response.content[0].type === 'text' ? response.content[0].text : '';
return JSON.parse(text);
}异步化改造
AI 调用需要 5-15 秒,不能放在 Hook 同步路径中。改造方案:将 save-hook 改为入队模式,单独启动一个 Worker 处理队列。这部分在配套代码中没有对应实现,下面是设计示意——其中 getPending、updateObservation、markProcessed 三个方法也需要在 ch13 的 ObservationStore 上补充:
// 设计示意:队列消费循环,完整实现留作练习
import { ObservationStore } from '../db/store.js';
import { compressObservation } from '../services/compressor.js';
export async function processQueue(): Promise<void> {
const store = new ObservationStore();
const pending = store.getPending(10); // 取最多 10 条待处理
for (const item of pending) {
try {
const compressed = await compressObservation(
item.tool_name,
JSON.parse(item.tool_input),
JSON.parse(item.tool_response)
);
store.updateObservation(item.id, compressed);
store.markProcessed(item.id);
} catch (error) {
// 失败不阻塞,下次重试
console.error(`Failed to compress #${item.id}:`, error);
}
}
store.close();
}Worker 用 setInterval 定时拉取队列:
// 设计示意:Worker 入口,完整实现留作练习
import { processQueue } from './processor.js';
// 每 5 秒处理一次队列
setInterval(processQueue, 5000);实现 Timeline 时间线视图
Timeline 提供某条 Observation 前后的上下文视图:
// 设计示意:在 ch13-mini-mem 的 src/db/store.ts 中添加,完整实现留作练习
getTimeline(anchorId: number, before: number = 3, after: number = 3) {
const anchor = this.db.prepare('SELECT created_at, project FROM observations WHERE id = ?').get(anchorId) as any;
if (!anchor) return { before: [], anchor: null, after: [] };
const beforeRows = this.db.prepare(`
SELECT id, type, title, created_at FROM observations
WHERE project = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?
`).all(anchor.project, anchor.created_at, before);
const afterRows = this.db.prepare(`
SELECT id, type, title, created_at FROM observations
WHERE project = ? AND created_at > ? ORDER BY created_at ASC LIMIT ?
`).all(anchor.project, anchor.created_at, after);
return {
before: beforeRows.reverse(),
anchor: this.db.prepare('SELECT id, type, title, narrative, created_at FROM observations WHERE id = ?').get(anchorId),
after: afterRows
};
}在 MCP Server 中注册 timeline 工具:
{
name: 'timeline',
description: 'Get observations before/after a specific observation.',
inputSchema: {
type: 'object',
properties: {
anchor: { type: 'number', description: 'Observation ID' },
before: { type: 'number', description: 'Count before (default 3)' },
after: { type: 'number', description: 'Count after (default 3)' }
},
required: ['anchor']
}
}向量搜索集成
用 ChromaDB 或 Qdrant 实现语义搜索。配套代码 examples/ch14-extensions/src/vector-search.ts 用 ChromaDB 的 Node.js 客户端实现了写入和检索,核心部分节选如下:
// examples/ch14-extensions/src/vector-search.ts(节选)
import { ChromaClient } from 'chromadb';
const COLLECTION_NAME = 'observations';
// 初始化 ChromaDB 客户端(连接本地实例)
const chroma = new ChromaClient({
path: process.env.CHROMA_HOST || 'http://localhost:8000',
});
// 初始化 collection 并写入 observation 数据
async function seedObservations(observations: Observation[]) {
// ChromaDB 默认使用内置 embedding 函数,无需额外调用 embedding API
const collection = await chroma.getOrCreateCollection({
name: COLLECTION_NAME,
metadata: { description: 'User observation memory store' },
});
await collection.add({
ids: observations.map((obs) => obs.id),
documents: observations.map((obs) => obs.content),
metadatas: observations.map((obs) => ({
category: obs.category,
timestamp: obs.timestamp,
})),
});
return collection;
}
// 语义搜索:根据自然语言查询返回最相关的 observation
async function semanticSearch(query: string, topK: number = 3) {
const collection = await chroma.getCollection({ name: COLLECTION_NAME });
const results = await collection.query({
queryTexts: [query],
nResults: topK,
});
return results;
}完整文件还实现了 filteredSearch(metadata 过滤加语义搜索,where: { category: { $eq: category } }),并附带一段可运行的演示数据,npm run demo:search 可以直接跑通。接入 mini-mem 时,在 save-hook 写入 SQLite 后同步调用一次 collection.add,MCP 搜索工具里将向量召回结果与 FTS5 结果合并即可。
ChromaDB 需要单独启动服务:
# 使用 Docker
docker run -p 8000:8000 chromadb/chroma
# 或用 pip
pip install chromadb
chroma run --path ./chroma-dataViewer UI:React 实时展示面板
添加一个简单的 Web UI 展示 Observation 流。配套代码 examples/ch14-extensions/src/viewer-server.ts 实现了完整的 SSE 服务(npm run demo:viewer 启动),核心部分节选如下:
// examples/ch14-extensions/src/viewer-server.ts(节选)
import express from 'express';
import type { Request, Response } from 'express';
const app = express();
app.use(express.json());
// 内存中的 observation 存储(演示用)
const observations: Observation[] = [];
const sseClients: Set<Response> = new Set();
// SSE 端点:连接时先推全量数据,之后接收增量广播
app.get('/api/stream', (req: Request, res: Response) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ type: 'init', observations })}\n\n`);
sseClients.add(res);
req.on('close', () => {
sseClients.delete(res);
});
});
// 向所有 SSE 客户端广播新 observation
function broadcast(observation: Observation) {
const payload = `data: ${JSON.stringify({ type: 'new', observation })}\n\n`;
for (const client of sseClients) {
client.write(payload);
}
}
// 写入端点:POST 一条 observation 并广播给所有在线客户端
app.post('/api/observations', (req: Request, res: Response) => {
const { content, category } = req.body;
const observation: Observation = {
id: `obs-${Date.now()}`,
content,
category: category || 'general',
timestamp: new Date().toISOString(),
};
observations.push(observation);
broadcast(observation);
res.status(201).json(observation);
});演示版把 observation 存在内存数组里,通过 POST 写入并实时广播。接到 mini-mem 时,把内存数组换成 ObservationStore,用 store.getAfter(lastId) 轮询 SQLite 中的新数据即可。
配套代码的前端是内嵌在 viewer-server.ts 里的一个纯 HTML 页面,用原生 EventSource 消费 SSE,浏览器打开服务地址就能看到实时刷新的 observation 列表。如果想换成 React,消费组件的骨架如下:
// 设计示意:React 版消费组件,完整实现留作练习
// 配套代码的可运行版本是 viewer-server.ts 内嵌的原生 HTML 页面
function ObservationFeed() {
const [observations, setObservations] = useState<Observation[]>([]);
useEffect(() => {
const es = new EventSource('/api/stream');
es.onmessage = (event) => {
const obs = JSON.parse(event.data);
setObservations(prev => [obs, ...prev].slice(0, 100));
};
return () => es.close();
}, []);
return (
<div className="feed">
{observations.map(obs => (
<div key={obs.id} className="observation-card">
<span className="type">{obs.type}</span>
<h3>{obs.title}</h3>
<p>{obs.narrative}</p>
</div>
))}
</div>
);
}多项目隔离与切换
支持同时追踪多个项目的记忆,在 Context Injection 时只注入当前项目相关的内容。ch13 的 src/hooks/context-hook.ts 里项目识别只取 cwd 的最后一段目录名(input.cwd.split('/').pop()),可以升级成三级回退策略:
// 设计示意:升级 ch13-mini-mem src/hooks/context-hook.ts 的项目识别,完整实现留作练习
function getProjectName(cwd: string): string {
// 策略 1:如果有 package.json,用其 name 字段
const pkgPath = path.join(cwd, 'package.json');
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg.name) return pkg.name;
}
// 策略 2:用 git 仓库根目录名
try {
const gitRoot = execSync('git rev-parse --show-toplevel', { cwd }).toString().trim();
return path.basename(gitRoot);
} catch {}
// 策略 3:用当前目录名
return path.basename(cwd);
}查询时自动按项目过滤:
SELECT * FROM observations WHERE project = ? ORDER BY created_at DESC LIMIT ?思考题
- AI 压缩每条 Observation 的成本约 $0.001(基于 Claude Haiku)。如果一天产生 200 条 Observation,一年的压缩成本是多少?如何在质量和成本之间取舍(比如只压缩超过一定长度的 Observation)?
- 向量搜索和 FTS5 全文搜索各有优劣。设计一个混合排序策略:什么情况下优先用向量搜索,什么情况下优先用 FTS5?
- 多项目支持中,项目识别依赖 package.json name 和 git 仓库名。如果用户在 monorepo 中工作(多个子项目共享一个 git root),如何正确识别当前子项目?
本书开源发布于 inferloop.dev,转载请注明出处。
至此,mini-mem 的四条扩展路径都已经走通:AI 压缩、向量搜索、实时 UI 各有可独立运行的演示(examples/ch14-extensions/),异步 Worker 和多项目识别给出了落地设计。下一部分将讨论如何将这类系统从个人工具升级到企业级平台。
本章来自《Agent Memory 工程实战》开源版 · 作者「递归客」
在线阅读完整书系:inferloop.dev
源码仓库:github.com/diguike/book-claude-mem
本书资源
- 源码仓库 · github.com/diguike/book-claude-mem
- 在线阅读 · inferloop.dev/claude-mem
- 所有书目 · inferloop.dev
继续阅读 · 同作者其他书
- 《Transformer 工程实战》从注意力机制到生产部署
- 《自己动手写 AI Agent》从 Claude Code 开源架构到你的第一个编程助手
- 《AI 时代的 CLI 工具开发实战》用 TypeScript 构建现代 CLI 工具
- 《LLM Infra 工程实战》从入门到实践
- 《Hermes Agent 实战》构建会成长的个人 AI Agent
- 《OpenClaw 源码解析》现代 Agent 系统的架构设计与工程实践
- 《AI Token 中转站实战》从 0 搭建企业级 LLM 网关
- 《LangChain.js Agent 开发权威指南》从 1.x 抽象到生产级 Agent
- 《百万级 AI Agent 平台架构》智能客服 SaaS 实战
- 《AI Agent 评测工程实战》从 0 用 TypeScript 构建你的评测平台
- 《Agent Harness 评测工程》用评测建设并守护一个 agent harness
- 《源码精读》每章一个开源仓库 · 从架构到品味
- 《Claude Code Skill 指南》
- 《Claude 插件官方指南》