将 Hermes Agent 的历史会话文件(46,000+ 文件,3.17GB)批量导入 L0 记忆数据库并生成向量嵌入的全流程。涵盖 Session 碎片化、L1 管道瓶颈、向量嵌入 BUG 等踩坑经验。
不走 HTTP API,直接 SQL 写入,实现 38x 加速。
| 格式 | 示例 | 说明 | 处理 |
|---|---|---|---|
| session_*.json | session_20260423_174126_e808b10d.json | 含 system_prompt + messages | json.load() 提取 |
| *.jsonl | 20260427_081652_0db72b.jsonl | JSON Lines,每行一个对话轮次 | 逐行 json.loads() |
| request_dump_*.json | request_dump_20260424_*.json | API 请求转储,不含对话 | 跳过 |
将同一批导入的大量碎片会话赋予相同的 session_key(如 "history-qqbot-full"),便于 L1 管道按 session_key 分组提取记忆,避免每个碎片文件独立成 session 导致提炼不出有效记忆。
原始文件名保留为 session_id 字段用于追溯。
通过比对消息文本前 60 字符判断是否已存在:
# 从数据库中加载已有消息的签名
existing = set()
for row in conn.execute(
"SELECT substr(message_text,1,60) FROM l0_conversations WHERE session_key=? AND role='user'",
(SESSION_KEY,)
):
existing.add(row[0])
# 跳过已在库中的轮次
new_rounds = [r for r in all_rounds if r['u'][:60] not in existing]
使用 DatabaseSync 类(gateway 内置)以事务方式批量写入,避免逐条 insert 的性能开销:
conn = DatabaseSync('/root/.memory-tencentdb/memory-tdai/vectors.db')
conn.run('BEGIN')
try:
for round in new_rounds:
conn.run(
"INSERT INTO l0_conversations (record_id, session_key, session_id, role, message_text, recorded_at, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)",
[record_id, session_key, session_id, role, text, timestamp, timestamp]
)
conn.run('COMMIT')
except:
conn.run('ROLLBACK')
raise
全文搜索表 l0_fts 使用 SQLite FTS5 虚拟表,需在插入主表后同步写入:
conn.run(
"INSERT INTO l0_fts (rowid, message_text) VALUES (?, ?)",
[last_rowid, message_text]
)
使用本地 GGUF 模型(如 bge-m3)通过 node-llama-cpp 生成嵌入向量,不走外部 API:
import { getLlama, LlamaEmbedding } from 'node-llama-cpp'
const llama = await getLlama()
const model = await llama.loadModel({ modelPath: '/path/to/bge-m3.Q4_K_M.gguf' })
const context = await model.createEmbeddingContext()
async function getEmbeddingFor(text) {
const embedding = await context.getEmbeddingFor(text)
return embedding.vector // Float32Array
}
context.getEmbeddingFor() 的数组重载版本存在隐含截断 BUG——传入数组 [text1, text2, ...] 时,只有最后一轮的 embedding 被返回,中间轮次的输出在 C++ 层被覆盖。getEmbeddingFor(text),每条传入单个字符串,不传数组。超过模型上下文窗口的文本需要截断或分段处理:
MAX_TOKENS = 512 # bge-m3 上下文限制
def truncate_to_tokens(text, max_tokens=MAX_TOKENS):
# 按 token 数截断,保留前 max_tokens
tokens = tokenizer.encode(text)
if len(tokens) > max_tokens:
return tokenizer.decode(tokens[:max_tokens])
return text
INSERT INTO l0_vec_rowids (rowid, vector_blob) VALUES (?, ?)
-- vector_blob 为 Float32Array 的 Buffer
直接写入 SQL 导入的数据不会被调度器自动识别。需在 pipeline_states 表中插入状态记录:
INSERT OR REPLACE INTO pipeline_states
(session_key, state, updated_at)
VALUES (?, 'pending', ?)
即使注入了 pipeline_states,调度器也需要被手动唤醒。发送一个空 POST 到 /capture 端点:
curl -X POST http://127.0.0.1:8420/capture \
-H "Content-Type: application/json" \
-d '{
"user_content": ".",
"assistant_content": ".",
"session_key": "wakeup-trigger",
"session_id": "wakeup"
}'
这会触发调度器的 isTimeToExtract() 判断,进而执行 L1 提取队列。
导入完成后重启 gateway 以确保所有内部状态刷新:
systemctl restart memory-tdai-gateway
SELECT COUNT(*) as total_msg,
(SELECT COUNT(*) FROM l0_vec_rowids) as total_vec,
(SELECT COUNT(*) FROM l0_fts) as total_fts
FROM l0_conversations;
正常状态:total_msg ≈ total_vec ≈ total_fts。若 total_vec 明显少于 total_msg,说明部分消息未生成嵌入,需排查 Phase 3 的截断或数组 BUG。
shouldExtractL1() 检查函数有 warmup 阈值条件——新 session 需要达到一定消息数量(默认 10 条)才触发提取。如果每次 POST /capture 只发送 1-2 轮对话,调度器认为"消息不够多"而跳过。maxMessagesPerExtraction 降低阈值(config.yaml)journalctl -u memory-tdai-gateway --since "5 min ago" | grep -i 'extract\|warmup\|threshold'
| 方法 | 44,271 条消息 | 备注 |
|---|---|---|
| HTTP /capture(逐条) | ~7 小时 | 30s PHP-FPM 超时 + 限流 |
| SQL 批量写入 | ~2 分钟 | 事务提交 + 原始 timestamp 保留 |
| 向量嵌入(逐条) | ~11 分钟 | node-llama-cpp GGUF 本地推理 |
| 总耗时 | ~13 分钟 | vs 7h HTTP 方式 → 38x 加速 |
| 症状 | 根因 | 排查 |
|---|---|---|
| L0 消息数远少于源文件数 | 质量门过滤了 cron 噪音会话 | 检查 shouldExtractL1() 的过滤条件 |
| 向量数少于消息数 | 尾巴消息的 embedding 被跳过 | 检查 auto-capture.ts 的 maxNewMessages 上限 |
| 时间戳全是同一天 | 用了 /capture 的自动时间戳 | 直接 SQL 写入时传原始时间 |
| L1 只有几十条 | maxMessagesPerExtraction=10 + MiniMax JSON 截断 | 用 DeepSeek V4 批量重提 / 放宽阈值 |
| getEmbeddingFor 数组只返回最后一条 | node-llama-cpp 数组重载 BUG | 逐条调用,不传数组 |
| 调度器触发后 L1 为 0 | warmup 阈值未达到 | 统一 session_key 或直接写 pipeline_states |
| /capture 返回 502 | PHP-FPM 30s 超时 kill 了进程 | 用后台 worker 模式分批发送 |
| API 密钥 403 | config.yaml 中密钥为占位符 | echo $TDAI_LLM_API_KEY 取运行时注入值 |
| JSON 被截断无输出 | MiniMax M2.7 的 <think> 标签消耗 token | 设 max_tokens=4096,优化 system prompt |
| 去重过严过滤了太多 | bigram 相似度阈值 90% 太高 | 降阈值或跳过重复检查 |
| DB 文件过大 | 未清理过期数据 | DELETE + VACUUM 或分表策略 |
| Gateway 内存持续增长 | 向量嵌入上下文未释放 | 重启 gateway 或启用批次 GC |
| # | 检查项 | 验证方法 |
|---|---|---|
| 1 | 源文件分析完成 | 统计文件数 = session_*.json + *.jsonl,request_dump 已排除 |
| 2 | 去重计数正确 | new_rounds + existing == all_rounds |
| 3 | SQL 写入事务完整 | COMMIT 后 SELECT COUNT(*) 验证行数 |
| 4 | timestamp 为原始值 | SELECT MIN(timestamp), MAX(timestamp) 验证时间范围 |
| 5 | 角色平衡 | SELECT role, COUNT(*) GROUP BY role → user ≈ assistant |
| 6 | FTS 完整 | l0_fts 表行数 = l0_conversations 表行数 |
| 7 | 向量嵌入完整 | l0_vec_rowids 表行数 ≈ l0_conversations 表行数 |
| 8 | pipeline_states 已注入 | SELECT * FROM pipeline_states WHERE state='pending' |
| 9 | /capture 唤醒成功 | curl POST 返回 200,gateway 日志出现 extract |
| 10 | Gateway 重启后运行正常 | systemctl is-active memory-tdai-gateway |
| 11 | 向量覆盖率 ≥ 95% | total_vec / total_msg * 100 |
| 12 | 无孤儿消息 | session_id 分组,每组 ≥ 2 条 |
| 13 | L1 提取已触发 | l1_records 表有数据 |
| 14 | 无重复导入 | 相同前 60 字符的消息 count = 1 |
| # | 教训 | 说明 |
|---|---|---|
| 1 | Session 碎片化 → 记忆提炼不足 | 每批导入文件分配独立 session_id 导致 L1 管道每批只看 2-10 条消息,提炼不出有效记忆。统一 session_key 解决。 |
| 2 | HTTP API 方式极慢(7h vs 2min) | /capture 端点 30s 超时 + 每秒限流,44K 消息需要 7 小时。直接 SQL 写入仅需 2 分钟。 |
| 3 | 原始时间戳丢失 | /capture 用当前时间覆盖 recorded_at。直接 SQL 写入传入源文件中的原始 timestamp。 |
| 4 | getEmbeddingFor 数组传参 BUG | node-llama-cpp 的数组重载版本只返回最后一轮 embedding。必须逐条调用。 |
| 5 | Warmup 阈值未达到 → L1 为 0 | shouldExtractL1() 需要 session 累计 10+ 条消息才触发。直接写 pipeline_states 绕过。 |
| 6 | API 密钥不可见(403) | config.yaml 中 key 显示为 sk-xxx-xxx 占位符,实际值在环境变量中。用 echo $TDAI_LLM_API_KEY 获取。 |
| 7 | MiniMax JSON 截断 | M2.7 模型的 <think> 标签消耗大量 token 导致 JSON 被截断。设 max_tokens=4096。 |
| 8 | Cron 噪音污染 | Hermes 会话中 83% 是 cron/自动化会话,需要过滤。用 shouldExtractL1() 的质量门条件。 |
| 9 | 去重过程过严 | bigram 相似度 90% 阈值过滤了非重复但有语义差异的消息。适当降低或跳过。 |
| 10 | 向量上下文未释放 → 内存泄漏 | LLM 嵌入上下文在批量处理后未释放,gateway 内存持续增长。定时重启或批次 GC。 |