mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 12:54:29 +00:00
262 lines
8.8 KiB
Markdown
262 lines
8.8 KiB
Markdown
|
|
# Agent 接入指南(Python)
|
|||
|
|
|
|||
|
|
本文讲怎么把 `tencentdb-agent-memory-sdk-python` 接到一个 AI Agent 里。SDK 的 14 个 API 速查见 [`README.md`](./README.md),本文讲**怎么把它们组装成一套长期记忆**。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 接入要做的四件事
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户输入 → ① 召回(注入 prompt) → LLM → ② 捕获(写 L0)
|
|||
|
|
↑
|
|||
|
|
③ 工具:让 LLM 自己再查
|
|||
|
|
↑
|
|||
|
|
④ 错误降级:失败不挂主流程
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 0. 初始化
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from tencentdb_agent_memory import MemoryClient, AsyncMemoryClient
|
|||
|
|
|
|||
|
|
# 同步
|
|||
|
|
client = MemoryClient(
|
|||
|
|
endpoint="https://your-memory-gateway",
|
|||
|
|
api_key=os.environ["MEMORY_API_KEY"],
|
|||
|
|
service_id="your-instance-id",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 异步(推荐 Agent 场景用)
|
|||
|
|
async with AsyncMemoryClient(
|
|||
|
|
endpoint="https://your-memory-gateway",
|
|||
|
|
api_key=os.environ["MEMORY_API_KEY"],
|
|||
|
|
service_id="your-instance-id",
|
|||
|
|
) as client:
|
|||
|
|
...
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`service_id` 决定 memory space 隔离粒度,同 id 数据共享、不同 id 完全隔离。Agent 场景几乎都用 async,别用同步版(会阻塞事件循环)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 召回(Recall)
|
|||
|
|
|
|||
|
|
在用户消息发给 LLM 前,并行拉三类记忆,拼到 system prompt 里。
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import asyncio
|
|||
|
|
|
|||
|
|
async def recall(client: AsyncMemoryClient, user_query: str) -> dict:
|
|||
|
|
l1, persona, scenes = await asyncio.gather(
|
|||
|
|
client.search_atomic(query=user_query, limit=5),
|
|||
|
|
client.read_core(), # L3 用户画像
|
|||
|
|
client.list_scenarios(), # L2 场景索引
|
|||
|
|
return_exceptions=True, # 关键:单路挂不影响其它
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
l1_items = l1["items"] if not isinstance(l1, Exception) else []
|
|||
|
|
persona_text = persona["content"] if not isinstance(persona, Exception) else None
|
|||
|
|
scene_list = scenes["entries"] if not isinstance(scenes, Exception) else []
|
|||
|
|
|
|||
|
|
return format_prompt(l1_items, persona_text, scene_list)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`asyncio.gather(..., return_exceptions=True)` 是关键——任何一路超时/失败,其它两路结果照常用,不影响主对话。
|
|||
|
|
|
|||
|
|
### 拼 prompt 的两个区块
|
|||
|
|
|
|||
|
|
- **prepend_context(动态)**:L1 召回结果,每轮都变,放在用户消息前。
|
|||
|
|
- **append_system_context(稳定)**:Persona + Scene 索引 + 工具调用指南,放在 system prompt 末尾,KV cache 友好。 _(待确定:放到 system prompt 末尾仍可能造成 KV cache miss,需要继续讨论。)_
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
def format_prompt(l1_items, persona, scenes) -> dict:
|
|||
|
|
prepend = None
|
|||
|
|
if l1_items:
|
|||
|
|
lines = [f"- [{m['type']}] {m['content']}" for m in l1_items]
|
|||
|
|
prepend = "<relevant-memories>\n" + "\n".join(lines) + "\n</relevant-memories>"
|
|||
|
|
|
|||
|
|
parts = []
|
|||
|
|
if persona:
|
|||
|
|
parts.append(f"<user-persona>\n{persona}\n</user-persona>")
|
|||
|
|
if scenes:
|
|||
|
|
parts.append("## Scene Navigation\n*以下场景可用 tdai_read_file 读取详情*")
|
|||
|
|
parts.extend(f"- `{s['path']}`" for s in scenes)
|
|||
|
|
parts.append(MEMORY_TOOLS_GUIDE) # 见下文
|
|||
|
|
|
|||
|
|
return {"prepend": prepend, "append": "\n\n".join(parts)}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> 实现要点:在召回阶段**缓存原始用户文本**(清洁版,未注入 recall),后面 capture 阶段要用——见第 2 节。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. 捕获(Capture)
|
|||
|
|
|
|||
|
|
在 agent 一轮跑完后,把这一轮新增的 user/assistant 消息清洗后写回 L0。
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
async def capture(
|
|||
|
|
client: AsyncMemoryClient,
|
|||
|
|
session_key: str,
|
|||
|
|
raw_messages: list, # 框架给的完整消息历史
|
|||
|
|
original_user_text: str, # 召回阶段缓存的清洁版用户文本
|
|||
|
|
original_user_message_count: int, # 召回阶段缓存的消息数
|
|||
|
|
):
|
|||
|
|
# ① 位置切片:只保留这一轮新增的消息
|
|||
|
|
new_messages = raw_messages[original_user_message_count:]
|
|||
|
|
|
|||
|
|
# ② 提取 user/assistant,去掉 tool calls / system / 多模态噪声
|
|||
|
|
extracted = extract_user_assistant(new_messages)
|
|||
|
|
|
|||
|
|
# ③ 把被 recall 污染的用户消息换回原始版
|
|||
|
|
for m in extracted:
|
|||
|
|
if m["role"] == "user" and m["timestamp"] == new_messages[0].get("timestamp"):
|
|||
|
|
m["content"] = original_user_text
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# ④ 文本清洗:去图片 base64、去代码块、过滤太短/纯符号
|
|||
|
|
cleaned = [
|
|||
|
|
{**m, "content": sanitize(m["content"])}
|
|||
|
|
for m in extracted
|
|||
|
|
if len(sanitize(m["content"]).strip()) > 5
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
if not cleaned:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# ⑤ 提交
|
|||
|
|
await client.add_conversation(
|
|||
|
|
session_id=session_key,
|
|||
|
|
messages=[
|
|||
|
|
{
|
|||
|
|
"role": m["role"],
|
|||
|
|
"content": m["content"],
|
|||
|
|
"timestamp": datetime.fromtimestamp(m["timestamp"] / 1000).isoformat(),
|
|||
|
|
}
|
|||
|
|
for m in cleaned
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 为什么要替换"被污染的用户消息"
|
|||
|
|
|
|||
|
|
召回阶段会往用户消息前 prepend 一段 `<relevant-memories>...</relevant-memories>`。如果不还原成原始文本就写 L0,下一轮召回就会基于这段被污染的文本去 search/embedding——形成**反馈环**,记忆会越来越乱。
|
|||
|
|
|
|||
|
|
### 为什么要位置切片
|
|||
|
|
|
|||
|
|
agent 一轮结束时框架给的是**完整历史**,不是本轮新增。直接全发会重复写。召回阶段记一下消息数 N,结束时 `messages[N:]` 就是新增的。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 工具暴露
|
|||
|
|
|
|||
|
|
只靠 prompt 注入的记忆有限。再注册三个工具让 LLM 自己查:
|
|||
|
|
|
|||
|
|
| 工具 | 何时用 | 实现 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `tdai_memory_search` | 找结构化偏好/事实 | `client.search_atomic(query=..., limit=...)` |
|
|||
|
|
| `tdai_conversation_search` | 找原始对话片段 | `client.search_conversation(query=..., limit=...)` |
|
|||
|
|
| `tdai_read_file` | 读 persona / scene block 全文 | `client.read_file(path)` |
|
|||
|
|
|
|||
|
|
在 system prompt 里说清楚什么时候调,加上次数上限:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
## 记忆工具
|
|||
|
|
- tdai_memory_search:搜结构化记忆(用户偏好、规则、历史事件)
|
|||
|
|
- tdai_conversation_search:搜原始对话原文
|
|||
|
|
- tdai_read_file:读取场景文件(用 Scene Navigation 列出的路径)
|
|||
|
|
|
|||
|
|
⚠️ memory_search + conversation_search 一轮总共最多调 3 次。
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
不限次数 LLM 会反复瞎搜。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. 错误降级
|
|||
|
|
|
|||
|
|
记忆服务挂了**不能挂主对话**。三条原则:
|
|||
|
|
|
|||
|
|
1. **召回**用 `asyncio.gather(..., return_exceptions=True)`,单路失败不影响其它。
|
|||
|
|
2. **捕获**包 try/except,失败只记日志:
|
|||
|
|
```python
|
|||
|
|
try:
|
|||
|
|
await capture(...)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"capture failed: {e}")
|
|||
|
|
```
|
|||
|
|
3. **工具**返回错误字符串而不是抛异常,让 LLM 自己看到 "memory unavailable" 然后继续聊。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. 错误处理
|
|||
|
|
|
|||
|
|
非零 code 抛 `TDAMError`:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from tencentdb_agent_memory import TDAMError
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
content = await client.read_file("scene_blocks/x.md")
|
|||
|
|
except TDAMError as e:
|
|||
|
|
if e.code == 404:
|
|||
|
|
pass # 文件不存在,正常情况
|
|||
|
|
else:
|
|||
|
|
logger.warning(f"memory error code={e.code} request_id={e.request_id}")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`request_id` 在 server 端也有日志,排障时给后端就行。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. 性能建议
|
|||
|
|
|
|||
|
|
- **召回总预算 < 200ms**:三路并行后取最快可用结果,超时的丢掉。
|
|||
|
|
- **prompt 注入控制大小**:L1 ≤ 5 条、Scene 列表只列 path 不列内容、Persona 一份就够。让 LLM 不够用时再用工具拉详情。
|
|||
|
|
- **session 粒度**:`session_key` 是 L0 partition key,长期对话用稳定 id(用户 id + 会话 id),不要每轮换。
|
|||
|
|
- **不要在主线程同步调**:用 `AsyncMemoryClient`,别用 `MemoryClient`,否则会阻塞事件循环。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 附:sanitize 实现参考
|
|||
|
|
|
|||
|
|
清洗函数处理这几类噪声:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import re
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
_IMAGE_DATA_URI = re.compile(r"data:image/[a-z+]+;base64,[A-Za-z0-9+/=]+", re.IGNORECASE)
|
|||
|
|
_CODE_BLOCK = re.compile(r"```[\s\S]*?```")
|
|||
|
|
|
|||
|
|
def sanitize(text: str) -> str:
|
|||
|
|
# 去 base64 图片
|
|||
|
|
text = _IMAGE_DATA_URI.sub("[image]", text)
|
|||
|
|
# 去代码块(assistant 输出常见,对 embedding 是噪声)
|
|||
|
|
text = _CODE_BLOCK.sub("[code]", text)
|
|||
|
|
return text.strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def extract_user_assistant(messages: list) -> list:
|
|||
|
|
"""从原始消息列表里提取 user/assistant 文本,丢掉 tool / system / 空内容。"""
|
|||
|
|
out = []
|
|||
|
|
for m in messages:
|
|||
|
|
role = m.get("role")
|
|||
|
|
if role not in ("user", "assistant"):
|
|||
|
|
continue
|
|||
|
|
content = m.get("content")
|
|||
|
|
if isinstance(content, list):
|
|||
|
|
# 多模态消息:拼接 text 部分
|
|||
|
|
content = "\n".join(p.get("text", "") for p in content if p.get("type") == "text")
|
|||
|
|
if not isinstance(content, str) or not content.strip():
|
|||
|
|
continue
|
|||
|
|
out.append({
|
|||
|
|
"role": role,
|
|||
|
|
"content": content.strip(),
|
|||
|
|
"timestamp": m.get("timestamp", int(time.time() * 1000)),
|
|||
|
|
})
|
|||
|
|
return out
|
|||
|
|
```
|