mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:12:02 +00:00
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 可检索。
161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
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]
|