Files
AgentLab/backend/app/memory/store.py
T
ThearchyHelios 761117344b 沙箱会话生命周期与记忆检索
## 每跑一次图就多一台常驻 VM,没有任何自动回收路径

_reap_idle 只在 run() 里调,也就是说没人再跑代码时闲置 VM 永远收不掉,
microvm_idle_seconds 形同虚设;而 thread_id 每个 run 都是新的,runner 的
finally 又从不调 cleanup,于是每跑一次带代码节点的图就多一台几百 MB 的常驻
microVM(seatbelt 那边同样积了几十个历史会话目录)。

两处一起补:run 到终态就 cleanup(run_id)(interrupted 除外,人回来还要接着跑),
外加 lifespan 里起一个后台回收任务定期 _reap_idle——回收本来也不该占着请求
路径的时间。实测连跑三次 live_vms 稳定回到 0。

## 超时会连人带文件销毁整台 VM

会话契约是"同 session_id 共享文件系统,可以先写文件下一步再读回来",而
microVM 超时直接 _destroy,工作区连同用户数据一起没了,返回结果里没有任何
提示。另外三个后端超时只 killpg、工作目录照留——换到 microVM 之后同一张图的
行为就变了。现在超时先在 VM 内 pkill 掉跑飞的进程(入口名带唯一后缀,不会
误伤同会话的并发执行),确认 VM 还活着就保住工作区;收拾不动才销毁。

限额变更那条没法避免重建(VM 内存是启动时定死的),但不能不吭声:同一个 run
里两个代码节点填了不同的 memory_mb 就会触发。ExecResult 新增 session_reset,
代码节点在时间线上明确告警,否则下游只会看到一个无从追查的 FileNotFoundError。

## 新建 scope 写入第一条后永远搜不到

hybrid_rank 的 _norm 在 hi==lo 时把所有分数抹成 0,而候选只有一条时 hi 恒等于
lo,合并分 0.0 会被 min_score 过滤掉。"先写一条、下一轮再 recall"恰恰是 agent
最常见的用法。全都一样分是"都一样好",不是"都一样差",改成返回 1.0。

## 记忆去重误判,写入被静默丢弃且返回不相关的旧记忆

remember 拿 recall 的分数判重,但那是 min-max 排名归一化的结果——排第一的那条
在两路信号上都会被归成 1.0,与它和查询像不像毫无关系。于是 scope 里只要已有
两条记忆,新内容随便撞一个字排到第一就被判成重复丢掉,接口还返回 201 并把那条
不相关的旧记忆当成"刚保存的"交回去。改成直接比未归一化的余弦相似度。

验证:三条互不相关的记忆都能写进去、完全相同的仍被去重、单条 scope 可检索。
2026-09-14 15:41:05 +08:00

161 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import MemoryItem
from app.memory.embeddings import cosine, embed_text, from_blob, hybrid_rank, to_blob
# 判重阈值:余弦相似度高于它才算同一件事。比排名分数严格得多,
# 因为这里比的是真实语义距离,不是"谁排第一"。
_DEDUPE_THRESHOLD = 0.92
async def remember(
session: AsyncSession,
*,
scope: str,
content: str,
kind: str = "fact",
meta: dict[str, Any] | None = None,
importance: float = 0.5,
dedupe: bool = True,
) -> MemoryItem:
"""写一条长期记忆。
dedupe 会先看同 scope 下有没有近乎重复的内容 —— agent 很容易把同一件事
反复记下来,不去重的话记忆库几轮就被同义句灌满了。
"""
content = content.strip()
vec = await embed_text(content)
if dedupe:
# 判重必须看**真实相似度**,不能用 recall 的分数。recall 走 hybrid_rank,
# 那里的分数是 min-max 排名归一化的结果:排第一的那条在两路信号上都会
# 被归成 1.0,与它和查询像不像毫无关系。于是 scope 里只要已有两条记忆,
# 新内容随便撞上一个字排到第一,就会被判成"重复"而丢弃,接口还返回 201
# 并把那条不相关的旧记忆当成"刚保存的"交回去——静默丢数据。
stmt = select(MemoryItem).where(MemoryItem.scope == scope)
if kind:
stmt = stmt.where(MemoryItem.kind == kind)
candidates = list((await session.execute(stmt)).scalars())
best, best_score = None, 0.0
for row in candidates:
other = from_blob(row.embedding)
if other is None:
continue
score = cosine(vec, other)
if score > best_score:
best, best_score = row, score
if best is not None and best_score > _DEDUPE_THRESHOLD:
best.importance = max(best.importance, importance)
best.use_count += 1
await session.commit()
await session.refresh(best)
return best
item = MemoryItem(
scope=scope,
kind=kind,
content=content,
meta=meta or {},
importance=importance,
embedding=to_blob(vec),
)
session.add(item)
await session.commit()
await session.refresh(item)
return item
async def recall(
session: AsyncSession,
*,
scope: str,
query: str,
limit: int = 5,
kind: str | None = None,
min_score: float = 0.05,
) -> list[dict[str, Any]]:
"""按混合相关度取回记忆,并按重要性和使用次数做轻微加权。"""
stmt = select(MemoryItem).where(MemoryItem.scope == scope)
if kind:
stmt = stmt.where(MemoryItem.kind == kind)
rows = list((await session.execute(stmt)).scalars())
if not rows:
return []
query_vec = await embed_text(query)
ranked = hybrid_rank(
query,
[r.content for r in rows],
[from_blob(r.embedding) for r in rows],
query_vec,
)
results: list[dict[str, Any]] = []
now = datetime.now(timezone.utc)
for idx, score, parts in ranked:
row = rows[idx]
# 重要的、被用得多的记忆略微上浮
boosted = score * (0.8 + 0.4 * row.importance) * (1 + min(row.use_count, 10) * 0.01)
if boosted < min_score:
continue
results.append(
{
"id": row.id,
"content": row.content,
"kind": row.kind,
"meta": row.meta,
"importance": row.importance,
"score": round(boosted, 4),
"signals": {k: round(v, 4) for k, v in parts.items()},
"created_at": row.created_at.isoformat() if row.created_at else None,
}
)
if len(results) >= limit:
break
# 记一下被召回过,作为下次排序的信号
for r in results:
item = await session.get(MemoryItem, r["id"])
if item:
item.use_count += 1
item.last_used_at = now
await session.commit()
return results
async def forget(session: AsyncSession, memory_id: str) -> bool:
item = await session.get(MemoryItem, memory_id)
if not item:
return False
await session.delete(item)
await session.commit()
return True
async def clear_scope(session: AsyncSession, scope: str) -> int:
result = await session.execute(delete(MemoryItem).where(MemoryItem.scope == scope))
await session.commit()
return result.rowcount or 0
async def list_memories(
session: AsyncSession, *, scope: str | None = None, limit: int = 200
) -> list[MemoryItem]:
stmt = select(MemoryItem).order_by(MemoryItem.created_at.desc()).limit(limit)
if scope:
stmt = stmt.where(MemoryItem.scope == scope)
return list((await session.execute(stmt)).scalars())
async def list_scopes(session: AsyncSession) -> list[dict[str, Any]]:
rows = await session.execute(
select(MemoryItem.scope, func.count(MemoryItem.id)).group_by(MemoryItem.scope)
)
return [{"scope": scope, "count": count} for scope, count in rows]