Agent_项目学习_day02

follow up datawhalechina/Agent-Learning-Hub: AI Agent 学习路线与资料库收集

Stage 2: Learn Tool Use, RAG, And Memory

目标:

  • 实现检索增强生成、将搜索、数据库、文件、浏览器、代码执行接成工具
  • 区分短期记忆上下文、会话记忆、长期记忆
  • 处理工具失败、空结果、重复调用、幻觉引用
  • 让agent回答里给出来源

这一块作者提供了一些开源项目,准备先自己搓了,然后再看一下优秀的开源项目

RAG

这里选择的是ChromaDB数据库,轻量化适合个人任务

RAG主要做的几件事情就是chunk(切分)→ embed(向量化)→ store(存储)→ retrieve(检索),其中前两个代码实现并且本地调个开源模型就可以,后两个则需要用到向量数据库。

切分chunk

简易文本切分,默认每一块大小1500,有100的字符重叠

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import re
from typing import List

class SimpleChunker:
def __init__(self, chunk_size: int = 1500, overlap: int = 100):
self.chunk_size = chunk_size
self.overlap = overlap

def chunk(self, text: str) -> List[str]:
blocks = [b.strip() for b in re.split(r'\n\s*\n', text) if b.strip()]

chunks = []
current = ""

for block in blocks:
# 小块:直接合并
if len(current) + len(block) <= self.chunk_size:
current += ("\n\n" + block if current else block)
continue

if current:
chunks.append(current)
#大块按照句子切分
if len(block) > self.chunk_size:
sents = re.split(r'([。!?.!?…]\s*)', block)
sents = [''.join(sents[i:i+2]) for i in range(0, len(sents)-1, 2)]

current = ""
for s in sents:
if len(current) + len(s) <= self.chunk_size:
current += s
else:
if current:
chunks.append(current)
# 保留 overlap
current = current[-self.overlap:] + s if self.overlap > 0 else s
else:
current = block

if current:
chunks.append(current)

return chunks

向量化、存储、查询

1
2
3
4
import chromadb
from sentence_transformers import SentencdTransformer
import uuid
from typing import List, Dict, Any

然后是关键类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class RAGStore:
def __init__(self, collection_name: str , presist_dir = "./chromadb")
self.client = chromadb.PersystentClient(path = persist_dir)
self.collection = self.client.get_or_create_collection(name = collection_name)
#注册数据库和知识库名字
self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
#比较轻量级的模型,但是本地跑也要好久,让我路边捡500W然后换个新电脑吧
self,chunker = chunker(chunk_size = 1500, overlap = 100)
#一块1500大小,同时还和上一段有100的重叠内容

def add_vector(self, text: str, name: str = "这是一个示例知识库名称" )
#按照init参数切分
chunks = self. chunker.chunk(text)

documents = []
metadatas = []
ids = []
#准备好元数据
for i,chunk in enumerate[Any](chunks):
documents.append(chunk['text'])
metadatas.append({
'dbname': name,
'type' : chunk['metadata'].get('type','unknow'),
'chunk_index' : i
})
id.append(f"{name}_{i}_{uuid.uuid4().hex[:8]}")

#向量化并且存储
embeddings = self.embedder.encode(documents).tolist()
self.collection.add(
embeddings = embeddings,
documents = documents,
metadatas = metadatas,
ids = ids
)


def query(self, query_text:str, n_results: int = 3 ) -> list[Dict[str,Any]]:
#把问题进行编码
query_embedding = self.embedder.encode([query_text]).tolist()
#然后开始查询了
results = self.collection.query(
query_embeddings = query_embedding,
n_results = n_results,
include = ["documents","metadatas","distances"]
)

retrieved = []
#解析向量数据库搜索的结果
for i in range(len(results['documents'][0])):
meta = results['metadatas'][0][i] if results['metadatas'][0] else {}
retrieved.append({
"text" : results['documents'][0][I],
"metadata" : meta,
"score" : 1 - results['distance'][0][i]
})
return retrieved

工具

对比上一个简易agent,这里加入了搜索、代码执行,读取文件等功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
用户: 请你帮我搜索一下,湖南大学信息科学与工程学院现在叫什么名字

🤖 思考中...
[RAG] 检索到 3 条相关知识
[决策] 原始输出: TOOL:web_search...
[决策] 选择工具: web_search
[参数] 原始输出: {"tool": "web_search", "arguments": {"query": "湖南大学信息科学与工程学院 现在名称"}}...
[执行] 调用: web_search({'query': '湖南大学信息科学与工程学院 现在名称'})
[执行] 结果: 【湖大计算机学院 - 湖南大学】
湖南大学信息科学与工程学院. ... 版权所有©湖南大学2017 湖南大学党委宣传部 地址:湖南省长沙市岳麓区麓山南路麓山门 邮编:410082 Email:xiao...
[决策] 原始输出: 根据搜索结果,湖南大学信息科学与工程学院目前仍然叫**“湖南大学信息科学与工程学院”**(简称湖大信
息院),该名称自2010年原计算机与通信学院和原软件学院合并后一直沿用至今,并保留软件学院名称。
[来源: 湖南大学官网 csee.hnu.edu.cn | 湖南大学档案与校史馆 dag.hnu....

📤 回答:
根据搜索结果,湖南大学信息科学与工程学院目前仍然叫**“湖南大学信息科学与工程学院”**(简称湖大信息院),该名称自2010年原计算机与通信学院和原软件学院合并后一直沿用至今,并保留软件学院名称。
[来源: 湖南大学官网 csee.hnu.edu.cn | 湖南大学档案与校史馆 dag.hnu.edu.cn | 百度百科]

[系统提示] 引用 [湖南大学官网 csee.hnu.edu.cn | 湖南大学档案与校史馆 dag.hnu.edu.cn | 百度百科] 未经验证,可能为模型幻觉生成。

📚 引用来源:
⚠ 湖南大学官网 csee.hnu.edu.cn | 湖南大学档案与校史馆 dag.hnu.edu.cn | 百度百科

🔧 使用工具:web_search

搜索功能,跑去网站注册了个账号,每天有免费的搜索次数,别的工具懒得放上去了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def web_search(query: str, num_results: int = 5) -> str:
api_key = os.getenv("SERPER_API_KEY")

if not api_key:
return "错误:请设置 SERPER_API_KEY 环境变量"
url = "https://google.serper.dev/search"
headers = {
"X-API-KEY": api_key,
"Content-Type": "application/json"
}
payload = {
"q": query,
"num": num_results,
"hl": "zh-cn", # 中文
"gl": "cn", # 中国区域
}

try:
response = requests.post(url, headers=headers, json=payload, timeout=15)
response.raise_for_status()
data = response.json()

results = data.get("organic", [])
if not results:
return f"未找到关于'{query}'的搜索结果"

output = []
for item in results:
title = item.get("title", "")
snippet = item.get("snippet", "")
link = item.get("link", "")
output.append(f"【{title}】\n{snippet}\n链接:{link}\n")

return "\n".join(output)

except Exception as e:
return f"搜索失败:{str(e)}"

记忆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
==================================================
【短期记忆】(最近3轮)
user: 请你帮我搜索一下,湖南大学信息科学与工程学院现在叫什么名字...
assistant: 根据搜索结果,湖南大学信息科学与工程学院目前仍然叫**“湖南大学信息科学与工程学院”**(Colle...
user: 请你帮我搜索一下,湖南大学信息科学与工程学院现在叫什么名字...
assistant: 根据搜索结果,湖南大学信息科学与工程学院目前仍然叫**“湖南大学信息科学与工程学院”**(简称湖大信...
user: 为什么我记得湖南大学信息科学与工程学院改名字变成计算机学院了,你能帮我搜索确认一下吗...
assistant: 您的记忆是准确的。根据湖南大学信息科学与工程学院官网(csee.hnu.edu.cn)的页面标题显示...

【会话历史】(完整)
共 8 条消息

【长期记忆】
事实数量:0
用户画像:{'Gina': '湖南大学硕士在读,喜欢问东问西'}
==================================================

记忆系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import json
import os
from typing import List,Dict,Any
from datetime import datetime

class MemorySystem:
#记忆系统
def __init__(self, long_term_file: str = "./long_term_memory.json"):
self.short_term : List[Dict] = [] #短期最近的三轮对话
self.session_history: List[Dict] = [] #会话的完整历史
self.long_term_file = long_term_file
self.long_term: Dict[str,Any] = self._load_long_term()


#长期小众知识记忆保存在文件里面
def _load_long_term(self) -> Dict:
if os.path.exists(self.long_term_file):
with open(self.long_term_file, 'r', encoding='utf-8') as f:
return json.load(f)
return {"facts": [], "user_profile": {}}

1
2
3
4
5
6
7
8
9
10
用户: 你知道Gina这个用户的身份,以及平时的爱好吗

🤖 思考中...
[决策] 原始输出: 根据我的长期记忆,**Gina** 是 **湖南大学的硕士在读生**,平时喜欢 **问东问西**,表现出很强的好奇心和求知欲。
[来源: 长期记忆-用户画像]...

📤 回答:
根据我的长期记忆,**Gina** 是 **湖南大学的硕士在读生**,平时喜欢 **问东问西**,表现出很强的好奇心和求知欲。
[来源: 长期记忆-用户画像]
[系统提示] 引用 [长期记忆-用户画像] 未经验证,可能为模型幻觉生成。
1
2
3
4
def _save_long_term(self):
with open(self.long_term_file, 'w', encoding='utf-8') as f:
json.dump(self.long_term, f, ensure_ascii=False, indent=2)
#把term这个字典里面的东西保存成为长期记忆

不光是一些用户要求的记忆,还有模型的对话历史也进行保存

1
2
3
4
5
def add_message(self, role: str, content: str):
msg = {"role": role, "content": content, "time": datetime.now().isoformat()}
self.session_history.append(msg)

self.short_term = self.session_history[-6:]

然后给函数获取短期记忆和长期记忆

1
2
3
4
5
def get_short_term_context(self) -> List[Dict]:
return [{"role": m["role"], "content": m["content"]} for m in self.short_term]

def get_full_history(self) -> List[Dict]:
return [{"role": m["role"], "content": m["content"]} for m in self.session_history]

还有清空对话记忆

1
2
3
def clear_session(self):
self.session_history = []
self.short_term = []

Agent

这是最关键的Agent类,再敲一遍吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class RAGAgent:

def __init__(self, model: str )
self.client = OpenAI(
api_key = os.getenv("OPENAI_API_KEY"),
base_url = os.getenv("OPENAI_BASE_URL")
)
self.model = model

self.rag = RAGStore(collection_name = "knowledge_base"
self.chunker = SimpleChunker(chunk_size = 1500, overlap = 100)
self.memory = MemorySystem()
self.tool_history : List[Dict] = []
self.max_tool_calls = 5

def add_knowledge(self,text:str,source:str = "知识库路径" )
chunks = self.chunker.chunk(text)
metadatas = [{"source":source,"chunk_index":i}] for i in range(len(chunks))]
self.rag.add_documents(chunks,metadatas = metadatas)

def retrieve(self, query:str ,top_k :int = 3) -> List[dict]:
results = self.rag.query(query,n_results = top_k)
return results

def execute_tool(self,tool_name:str ,arguments: Dict) -> str:
call_signature = json.dumps({"tool":tool_name,"args":arguments},sort_keys = Ture)
recent_calls = [json.dumps(c,sort_keys = Ture) for c in self.tool_history[-3:]]
self.tool_history.append({"tool": tool_name, "args": arguments, "time": time.time()})
if tool_name not in TOOLS_REGISTER:
return f"错误:工具 '{tool_name}' 未注册"
try:
result = base_execute_tool(tool_name,arguments)
return results

def call_llm(self,messages: List[Dict], temperate: fload = 0.1) -> str:
try:
response = self.client.caht.completions.create(
model = self.model,
messages = messages,
temperature = temperature
)
return response.choices[0].message.content.strip()

def run(self, user_input: str) -> Dict[str, Any]:
self.memory.add_memory("user", user_input)
retrieved_docs = self.retrieve(user_input, top_k = 3)
rag_context = self._formate_rag_context(retrieved_docs)

base_message = self._build_base_massages(rag_context)
message = base_massage.copy()

tool_used = []
tool_call_count = 0

while tool_call_count < self.max_tool_calls:
decision = self.call_llm(messages)
tool_name = self._extract_tool_name(decision)

if tool_name is None:
不需要工具直接回答,保存记忆
self.memory.add_message("assistant",answer["answer"])
return {
"answer": answer["answer"],
"sources": answer["sources"],
"tools_used": tools_used,
"retrieved_docs": retrieved_docs
}
#有工具调用就先告诉模型应该输入的参数,生成调用json之后解析
tool_call_json = self._generate_tool_call(tool_name, user_input)
parsed = self._extract_json(tool_call_json)
if parsed is None:
messages.append({"role": "assistant", "content": f"TOOL:{tool_name}"})
messages.append({"role": "user", "content": f"工具参数解析失败。请重新输出严格 JSON 格式:{{\"tool\": \"{tool_name}\", \"arguments\": {{...}}}}。不要任何其他文字。"})
actual_tool = parsed.get("tool", tool_name)
arguments = parsed.get("arguments", {})


result = self.execute_tool(actual_tool, arguments)
tools_used.append({"tool": actual_tool, "args": arguments, "result": result[:200]})

# 把结果塞回对话,继续决策
messages.append({"role": "assistant", "content": f"TOOL:{actual_tool}"})
messages.append({"role": "user", "content": f"工具【{actual_tool}】返回结果:\n{result}\n\n请基于以上结果,继续回答原始问题:{user_input}"})

tool_call_count += 1

逻辑就是收到用户的提问,先加入到消息列表,然后向量化用户提问,在rag里面query,之后调用模型选择是否需要调用工具,如果是,再告诉模型它选择的工具参数列表,然后调用工具,把工具的返回值在传回给模型让模型综合工具输出给出标准答案

之后一些解析正则用的函数就暂且掠过了~总而言之也是能够正常完成需求任务

github_Agent项目学习