记忆系统:recall 结果接入 prompt + 可观测性(既有改动,代为提交)
## 说明
**这批改动不是本次会话写的**,它们在会话开始前就已在工作区里、一直未提交。
我做的是**验证**它确实成立,然后按你的指示代为提交。
出处:`docs/演示用/记忆系统排查报告-2026-09-14.md` 与同目录
`记忆系统修复文档-2026-09-14.md`(两份都在本次一并入库)。
排查报告的结论是「记忆系统没有坏」——库里有真实数据、170 条抽取事件全部消费成功;
真正的问题是「观测不到」+「召回结果没人消费」。
## 改动内容(按两份文档的编号)
- **F1 `RecalledMemory.content` 断头路**:`base.py` 新增 `memory_context_text()`,
`risk_agent._agent_system_prompt` 接收并注入记忆段。无记忆时返回空串,
因此 prompt 逐字不变 —— 这也是它能安全接线的理由。
- **F3 `governance.recall` 员工身份恒空**:补一条明确的语义日志。
员工身份下召回的是"该用户自身作为客户"的记忆,恒为空属预期,
但此前没有任何提示,运维看到 `count=0` 只会以为记忆坏了。
- **F4 可观测性**:`GET /api/v1/users/me/memories`(`stored` / `recalled` /
`downstream` / `pending_events` 四段)+ 抽取与召回的 6 处日志 +
三个只读探针 `tools/probe_memory_state.py`、`probe_memory_detail.py`、
`probe_agent_types.py`。
**未实施**(文档明确留作待决,我也不代为决定):F2 `known` 引用校验永不触发
(需架构确认 memory 类 `source_references` 由业务填还是底座统一附加)、
F5 客服是否读写长期记忆(涉脱敏与复核,需产品+合规)。
## 我做的验证(会话内实测,非照录文档)
- 新接口 `GET /users/me/memories` 以 `cust_t` 调用 -> **HTTP 200**:
stored: total=2, by_status={'active': 2}
recalled: count=2, degraded=False
两条记忆:preference:horizon='约三年'(0.95)、preference:risk_level='稳健型'(0.98)
与排查报告 §〇 列出的那两条**完全吻合**。
- `pytest tests/unit tests/contract` 全绿(这批改动没有破坏既有测试)。
## 未验证的部分
`memory_context_text()` 接进 prompt 后的**端到端效果没有实测** —— 文档自己说明了
原因:当前 `risk` Agent 的召回恒空(员工身份不是客户),所以接线后行为不变,
要用测试替身才能验证注入。我没有为此编造证据。
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""按 agent_type 统计运行分布 + 记忆写入时间点的对照(只读)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.infrastructure.db import SessionFactory
|
||||
|
||||
|
||||
async def q(session: Any, sql: str) -> list[Any]:
|
||||
try:
|
||||
return list((await session.execute(text(sql))).all())
|
||||
except Exception as exc:
|
||||
print(f" ERROR {type(exc).__name__}: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with SessionFactory() as session:
|
||||
print("=" * 66)
|
||||
print("A) agent_run 按 agent_type 分布")
|
||||
print("=" * 66)
|
||||
for r in await q(session, """
|
||||
SELECT agent_type, status, COUNT(*) c, MIN(created_at), MAX(created_at)
|
||||
FROM agent_run GROUP BY agent_type, status ORDER BY c DESC
|
||||
"""):
|
||||
print(f" {str(r[0]):<20} {str(r[1]):<12} {r[2]:<5} {r[3]} ~ {r[4]}")
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print("B) 记忆写入时刻前后,有没有非客服的 run")
|
||||
print("=" * 66)
|
||||
for r in await q(session, """
|
||||
SELECT r.agent_type, r.status, r.created_at, r.run_id
|
||||
FROM agent_run r
|
||||
WHERE r.created_at < '2026-09-10 14:00:00'
|
||||
ORDER BY r.created_at DESC LIMIT 12
|
||||
"""):
|
||||
print(f" {r[2]} | {str(r[0]):<20} | {str(r[1]):<10} | {str(r[3])[:8]}")
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print("C) 记忆抽取事件与 run 的对应(最近 8 条 extraction 事件)")
|
||||
print("=" * 66)
|
||||
for r in await q(session, """
|
||||
SELECT e.aggregate_id, e.status, e.retry_count, e.occurred_at, r.agent_type
|
||||
FROM domain_event_outbox e
|
||||
LEFT JOIN agent_run r ON r.run_id = e.aggregate_id
|
||||
WHERE e.event_type = 'memory.extraction_requested'
|
||||
ORDER BY e.id DESC LIMIT 8
|
||||
"""):
|
||||
print(f" {r[3]} | run={str(r[0])[:8]} | {r[1]:<9} | agent={r[4]}")
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print("D) 客户 9001 的角色")
|
||||
print("=" * 66)
|
||||
for r in await q(session, """
|
||||
SELECT r.role_code FROM sys_user_role ur
|
||||
JOIN sys_role r ON r.id = ur.role_id WHERE ur.user_id = 9001
|
||||
"""):
|
||||
print(f" {r[0]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,101 @@
|
||||
"""记忆系统明细探针(只读):回答「写入了什么 / 今天有没有活动 / 为什么 dead」。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.infrastructure.db import SessionFactory
|
||||
|
||||
|
||||
async def q(session: Any, sql: str, **params: Any) -> list[Any]:
|
||||
try:
|
||||
return list((await session.execute(text(sql), params)).all())
|
||||
except Exception as exc:
|
||||
print(f" ERROR {type(exc).__name__}: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with s_factory() as session:
|
||||
print("=" * 66)
|
||||
print("A) memory_unit 全部内容(写入是否成功的直接证据)")
|
||||
print("=" * 66)
|
||||
rows = await q(session, """
|
||||
SELECT id, customer_id, memory_key, content, memory_type, status,
|
||||
confidence, source_type, evidence_count, created_at, updated_at
|
||||
FROM memory_unit ORDER BY updated_at DESC LIMIT 20
|
||||
""")
|
||||
if not rows:
|
||||
print(" (memory_unit 为空 —— 一条记忆都没写入)")
|
||||
for r in rows:
|
||||
print(f" id={r[0]} customer={r[1]} [{r[5]}]")
|
||||
print(f" key = {r[2]}")
|
||||
print(f" value = {r[3]!r}")
|
||||
print(f" type={r[4]} conf={r[6]} src={r[7]} evidence={r[8]}")
|
||||
print(f" created={r[9]} updated={r[10]}")
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print("B) 今天(2026-09-14)有没有任何新活动")
|
||||
print("=" * 66)
|
||||
for label, sql in (
|
||||
("agent_run 今天创建", "SELECT COUNT(*) FROM agent_run WHERE created_at >= '2026-09-14'"),
|
||||
("agent_run 最新一条", "SELECT MAX(created_at) FROM agent_run"),
|
||||
("conversation_message 最新", "SELECT MAX(created_at) FROM conversation_message"),
|
||||
("domain_event_outbox 最新", "SELECT MAX(created_at) FROM domain_event_outbox"),
|
||||
("outbox pending 总数", "SELECT COUNT(*) FROM domain_event_outbox WHERE status='pending'"),
|
||||
("outbox failed 总数", "SELECT COUNT(*) FROM domain_event_outbox WHERE status='failed'"),
|
||||
):
|
||||
rows = await q(session, sql)
|
||||
print(f" {label:<26} {rows[0][0] if rows else '?'}")
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print("C) 最近 10 条 outbox 事件(看最新动向)")
|
||||
print("=" * 66)
|
||||
rows = await q(session, """
|
||||
SELECT event_type, status, retry_count, last_error, occurred_at
|
||||
FROM domain_event_outbox ORDER BY id DESC LIMIT 10
|
||||
""")
|
||||
for r in rows:
|
||||
err = (str(r[3])[:70] + "...") if r[3] and len(str(r[3])) > 70 else r[3]
|
||||
print(f" {r[4]} | {r[0]:<34} | {r[1]:<9} | retry={r[2]}")
|
||||
if err:
|
||||
print(f" last_error: {err}")
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print("D) agent.run_requested 死信的原因分布(取 5 条样本)")
|
||||
print("=" * 66)
|
||||
rows = await q(session, """
|
||||
SELECT retry_count, last_error, occurred_at
|
||||
FROM domain_event_outbox
|
||||
WHERE event_type='agent.run_requested' AND status='dead'
|
||||
ORDER BY id DESC LIMIT 5
|
||||
""")
|
||||
for r in rows:
|
||||
err = (str(r[1])[:150] + "...") if r[1] and len(str(r[1])) > 150 else r[1]
|
||||
print(f" {r[2]} retry={r[0]}")
|
||||
print(f" {err}")
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print("E) 最近 5 次 agent_run 的状态")
|
||||
print("=" * 66)
|
||||
rows = await q(session, """
|
||||
SELECT run_id, agent_type, status, error_code, created_at
|
||||
FROM agent_run ORDER BY id DESC LIMIT 5
|
||||
""")
|
||||
for r in rows:
|
||||
print(f" {r[4]} | {r[1]:<18} | {r[2]:<10} | err={r[3]} | {r[0][:8]}")
|
||||
|
||||
|
||||
def s_factory():
|
||||
return SessionFactory()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
"""记忆系统状态探针(只读):一次看清「库里有没有」「事件消费到哪了」。
|
||||
|
||||
用途:排查"记忆系统是否真的在工作"时,先跑这个脚本拿到事实基线,
|
||||
再去看日志定位是哪一段断了。全部是 SELECT,不写任何数据。
|
||||
|
||||
用法:
|
||||
D:/conda/envs/jr_py313/python.exe tools/probe_memory_state.py [customer_id]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.infrastructure.db import SessionFactory
|
||||
|
||||
TABLES = (
|
||||
"memory_unit",
|
||||
"memory_evidence",
|
||||
"memory_conflict",
|
||||
"memory_sync_outbox",
|
||||
"user_facts",
|
||||
"profile_snapshots",
|
||||
)
|
||||
|
||||
|
||||
async def _scalar(session: Any, sql: str, **params: Any) -> Any:
|
||||
try:
|
||||
return await session.scalar(text(sql), params)
|
||||
except Exception as exc: # 表不存在/连不上库都要能看,而不是整个脚本崩掉
|
||||
return f"ERROR {type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
customer_id = int(sys.argv[1]) if len(sys.argv) > 1 else None
|
||||
async with SessionFactory() as session:
|
||||
print("=" * 60)
|
||||
print("1) 记忆相关表的总行数")
|
||||
print("=" * 60)
|
||||
for table in TABLES:
|
||||
print(f" {table:<22} {await _scalar(session, f'SELECT COUNT(*) FROM {table}')}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("2) memory_unit 按状态分组")
|
||||
print("=" * 60)
|
||||
try:
|
||||
rows = (await session.execute(
|
||||
text("SELECT status, COUNT(*) c FROM memory_unit GROUP BY status"))).all()
|
||||
for row in rows:
|
||||
print(f" {row[0]:<12} {row[1]}")
|
||||
except Exception as exc:
|
||||
print(f" ERROR {type(exc).__name__}: {exc}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("3) domain_event_outbox 按 (event_type, status) 分组")
|
||||
print(" —— 这里能看到事件是不是堆在 pending(= Worker 没跑 / 处理失败)")
|
||||
print("=" * 60)
|
||||
try:
|
||||
rows = (await session.execute(text(
|
||||
"SELECT event_type, status, COUNT(*) c, MAX(occurred_at) latest "
|
||||
"FROM domain_event_outbox GROUP BY event_type, status ORDER BY c DESC"
|
||||
))).all()
|
||||
if not rows:
|
||||
print(" (空)")
|
||||
for row in rows:
|
||||
print(f" {row[0]:<38} {row[1]:<10} {row[2]:<6} latest={row[3]}")
|
||||
except Exception as exc:
|
||||
print(f" ERROR {type(exc).__name__}: {exc}")
|
||||
|
||||
if customer_id is not None:
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"4) 客户 {customer_id} 的记忆明细")
|
||||
print("=" * 60)
|
||||
try:
|
||||
rows = (await session.execute(text(
|
||||
"SELECT memory_uuid, memory_key, content, memory_type, status, "
|
||||
"confidence, source_type, updated_at "
|
||||
"FROM memory_unit WHERE customer_id=:cid ORDER BY updated_at DESC LIMIT 20"
|
||||
), {"cid": customer_id})).all()
|
||||
if not rows:
|
||||
print(" (该客户没有任何 memory_unit 行)")
|
||||
for row in rows:
|
||||
print(f" [{row[4]}] {row[1]} = {row[2]!r}")
|
||||
print(f" type={row[3]} conf={row[5]} src={row[6]} at={row[7]}")
|
||||
except Exception as exc:
|
||||
print(f" ERROR {type(exc).__name__}: {exc}")
|
||||
|
||||
print()
|
||||
print(f" user_facts:")
|
||||
try:
|
||||
rows = (await session.execute(text(
|
||||
"SELECT fact_key, confidence FROM user_facts "
|
||||
"WHERE customer_id=:cid LIMIT 20"), {"cid": customer_id})).all()
|
||||
for row in rows:
|
||||
print(f" {row[0]} (conf={row[1]})")
|
||||
if not rows:
|
||||
print(" (无)")
|
||||
except Exception as exc:
|
||||
print(f" ERROR {type(exc).__name__}: {exc}")
|
||||
|
||||
print(f" profile_snapshots:")
|
||||
try:
|
||||
rows = (await session.execute(text(
|
||||
"SELECT version, is_current, generated_at FROM profile_snapshots "
|
||||
"WHERE customer_id=:cid ORDER BY id DESC LIMIT 5"), {"cid": customer_id})).all()
|
||||
for row in rows:
|
||||
print(f" v{row[0]} current={row[1]} at={row[2]}")
|
||||
if not rows:
|
||||
print(" (无)")
|
||||
except Exception as exc:
|
||||
print(f" ERROR {type(exc).__name__}: {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user