数据截至 (上游 commit fd6b9a6352af)
让 agent 动手与记忆:工具生态、记忆、RAG 与人机协作
30 秒导读: 前几章讲的是 agent 怎么"想"和"跑"(Agent/Action、工作流、自动构建、自演化)。 这一章讲的是让 agent 走出模型、连上真实世界的横向能力层:用统一契约调用工具、跨对话轮 记住东西、从大文档库里检索知识、并在危险动作前插入人工审批。它们不属于某条主执行线, 而是被各层"取用"的公共货架。
本章是能力层总览, 和这些兄弟章互补、不重叠:
- 核心地基 ——
BaseModule/消息/LLM 输出解析(本章的类都继承BaseModule)。 - 执行单元 —— Agent 与 Action(工具/HITL 最终被 Action 调用)。
- 工作流引擎 —— HITL 拦截器就挂在工作流的执行点上。
- 自动构建 / 自演化 —— 主执行/优化线。
1. 这是什么(零基础也能懂)
一句话定义: 这一章讲 EvoAgentX 里让 agent"手能动、脑能记、事能查、险能拦"的四组基础设施。
用一个类比先建立直觉。 把一个 LLM 想成一个很聪明但被关在小黑屋里、还有严重健忘症的人:
| 它缺什么 | 这一层补什么 | 对应模块 |
|---|---|---|
| 手脚(只能说话,不能动) | 工具:执行代码、搜网页、开浏览器、查数据库 | tools/ |
| 记性(说完就忘) | 记忆:短期滑动窗口 + 长期可检索存储 | memory/ + storages/ |
| 查资料的能力 | RAG:把大文档切块、嵌入、按相似度召回 | rag/ |
| 危险动作的刹车 | 人机协作:关键点暂停,等人批准 | hitl/ |
它能做什么(功能):
- 用一套统一的 JSON schema 契约暴露几十种工具(搜索族、代码沙箱、浏览器、数据库、MCP……)。
- 给 agent 挂上短期记忆(最近 N 条消息)和长期记忆(向量库 + 数据库,支持增删改查 + 语义搜索)。
- 把任意文件读进来、切块、嵌入、索引,之后按查询召回相关片段喂给 LLM。
- 在工作流的某个节点前后拦下来,弹出 CLI 审批框,让人 approve / reject / 修改。
用起来什么样(最小示例):
# 示意,非源码 —— 感受一下"工具"这一层怎么用
from evoagentx.tools import WikipediaSearchToolkit
toolkit = WikipediaSearchToolkit() # 一个 Toolkit 打包了若干 Tool
tool = toolkit.get_tool("wikipedia_search") # 按名字取出具体工具
schema = tool.get_tool_schema() # 得到 OpenAI function-calling 格式的 JSON schema
result = tool(query="EvoAgentX") # 直接像函数一样调用
一句话直觉: 工具是"函数 + 一份能喂给 LLM 的说明书";记忆是"上下文窗口当内存、数据库当磁盘"; RAG 是"给 agent 配了个可搜索的图书馆";HITL 是"在流水线上装了个人工闸门"。
2. 顶层全景(它大概怎么转)
这四组能力彼此独立,但共享同一条底座:都建在 BaseModule 上,
长期记忆和 RAG 又共享同一个持久化后端 StorageHandler。
怎么读这张图: 上半区是四组能力,下半区是它们共用的存储底座;箭头表示"依赖 / 数据流向"。
┌──────────────────────────────────────────────────────────┐
│ Agent / Action 层 │ ← 第 2 章
│ (真正发起"调用工具 / 读写记忆 / 请人审批") │
└───┬───────────────┬──────────────┬──────────────┬─────────┘
│ 调用 │ 读写 │ 检索 │ 拦截
▼ ▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌───────────┐ ┌────────────┐
│ 工具 tools │ │ 记忆 memory │ │ RAG rag │ │ 人机 hitl │
│ Tool/Toolkit│ │ Short/Long │ │ RAGEngine │ │ HITLManager│
└─────┬─────┘ └─────┬──────┘ └─────┬─────┘ └────────────┘
│ │ 长期记忆 │ 复用
│ └───────┬───────┘
│ ▼
│ ┌─────────────────────┐
└───────────▶│ StorageHandler │ 统一持久化后端
│ DB / 向量 / 图 存储 │ storages/
└─────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Tool / Toolkit | 所有工具的契约:JSON schema + 签名校验 + __call__ | tools/tool.py |
| 各工具族 | 搜索 / 代码沙箱 / 浏览器 / 数据库 / MCP 的具体实现 | tools/search_*.py、interpreter_*.py… |
ShortTermMemory | 内存里的滑动窗口,只留最近 N 条消息 | memory/memory.py |
LongTermMemory | 消息落库 + 索引 + 语义检索(去重、增删改查) | memory/long_term_memory.py |
MemoryManager | 更高层:用 LLM 决策记什么、组装带记忆上下文的消息 | memory/memory_manager.py |
StorageHandler | 统一读写 DB / 向量 / 图三种后端 | storages/base.py |
RAGEngine | 读→切块→嵌入→索引→检 索的完整管线 | rag/rag.py |
HITLManager | 在关键点请求人工审批 / 收集输入 | hitl/approval_manager.py |
主线走一遍(高层): 一次"带记忆的、可动手的、需审批的"回合大概是——
Agent 把用户输入交给 MemoryManager 组装出"输入 + 召回的历史"→ LLM 决定调某个工具 →
Toolkit 按名字取出 Tool 并 __call__ → 若该动作被 HITL 盯上,先经 HITLManager 等人批 →
执行结果作为新消息写回 LongTermMemory(经 RAGEngine 索引、经 StorageHandler 落库)。
3. 核心机制一:工具契约 Tool / Toolkit
这一节讲整个工具货架里工程含量最高、也最该读透的一支:所有工具遵守的那份契约。
3.1 它要解决的小问题
LLM 要"调用工具",前提是拿到一份它能读懂的说明书(参数叫什么、什么类型、哪些必填),
业界事实标准就是 OpenAI function-calling 的 JSON schema。同时工程上还要保证:说明书和真实
函数签名不会对不上——否则 LLM 按 schema 传参、Python 却抛 TypeError。
Tool 就是把这两件事焊死的契约:一份 schema,一次签名校验,一个统一入口 __call__。
3.2 契约的三根支柱
Tool 继承 BaseModule,声明四个类属性:name、description、inputs、required
(tools/tool.py:24-28,class Tool)。它做三件事:
支柱一:生成 schema。 get_tool_schema() 把这几个属性拼成标准的 type: function 结构
(tools/tool.py:38-50),这正是喂给 LLM 的说明书:
# 示意,非源码 —— get_tool_schema() 产出的形状
{
"type": "function",
"function": {
"name": "wikipedia_search",
"description": "Search Wikipedia ...",
"parameters": {"type": "object", "properties": {...}, "required": ["query"]}
}
}
支柱二:统一入口。 基类的 __call__ 直接 raise NotImplementedError
(tools/tool.py:94-95)——强制每个工具把"怎么执行"实现成 __call__,于是所有工具对调用方
长一个样:tool(**kwargs)。
支柱三:构造期签名校验(最巧的一环)。 Tool.__init_subclass__ 在子类一被定义时就调用
validate_attributes()(tools/tool.py:34-36)。这个类方法逐条核对(tools/tool.py:52-92):
怎么读:从上到下是校验顺序,任一条不过就 raise ValueError,类根本定义不出来
① name/description/inputs 必须存在且类型正确
② 每个 input 必须有 "type" 和 "description"
③ type 必须 ∈ ALLOWED_TYPES(string/number/integer/boolean/object/array)
④ ★ 每个 input 名必须出现在 __call__ 的参数签名里
⑤ ★ input 的 JSON 类型必须和 __call__ 里该参数的 Python 注解一致
⑥ required 里的名字必须都在 inputs 里
第 ④⑤ 条是精髓:它用 inspect.signature(cls.__call__) 反射出真实签名,把 JSON 类型
("string")映射回 Python 类型(str)逐一比对(tools/tool.py:83-87):
# tools/tool.py:83-87 —— schema 与真实签名必须对齐,否则类定义即失败
call_signature = inspect.signature(cls.__call__)
if input_name not in call_signature.parameters:
raise ValueError(f"Input '{input_name}' is not found in __call__")
if call_signature.parameters[input_name].annotation != json_to_python[input_content["type"]]:
raise ValueError(f"Input '{input_name}' has a type mismatch in __call__")
为什么这很妙: schema 漂移(说明书和代码不一致)是工具生态最常见、最难查的 bug。EvoAgentX 把它提前到类定义时就炸——写错的工具连 import 都过不了,根本进不了运行时。
3.3 Toolkit:工具的打包与命名空间
单个工具往往共享一份"重资源"(一个搜索客户端、一个解释器实例)。Toolkit 就是把一组相关
Tool + 共享后端打包成一个可挂载单元(tools/tool.py:97-127)。它提供按名字取工具
(get_tool)、批量拿 schema(get_tool_schemas)等便捷方法。
典型模式(以维基搜索为例):Toolkit.__init__ 先建一个共享的 SearchWiki 实例,再把它注入到
薄薄的 WikipediaSearchTool 里(search_wiki/search_wiki.py 即 search_wiki.py:140-167)。
于是**"重活在 SearchBase、契约在 Tool、打包在 Toolkit"**三层分工清晰。
Toolkit (WikipediaSearchToolkit)
├── 持有共享后端: SearchWiki 实例 ( 继承 SearchBase,含 html2text/BeautifulSoup)
└── tools: [ WikipediaSearchTool ] ← 每个 Tool 只负责"schema + 把调用转发给后端"
4. 核心机制二:代表性工具族(一览 + 深读一个)
这一节按"能力类型"把工具货架分门别类,只对代码沙箱做深读,其余列举——因为它们都遵守第 3 节的 同一契约,读懂一个就懂全部。
4.1 五大工具族速览
| 族 | 代表实现(文件) | 干什么 | 代表 Toolkit |
|---|---|---|---|
| 搜索 | search_wiki.py、search_google.py、search_ddgs.py、search_exa.py、search_serpapi.py、search_serperapi.py、search_google_f.py | 检索网页/维基/学术,抓正文 | WikipediaSearchToolkit 等 |
| 代码执行 | interpreter_python.py、interpreter_docker.py | 在受控/沙箱环境跑代码 | PythonInterpreterToolkit、DockerInterpreterToolkit |
| 浏览器 | browser_tool.py、browser_use.py | 驱动真实浏览器点点点、抓页面 | BrowserToolkit、BrowserUseToolkit |
| 数据库 | database_base.py、database_faiss.py、database_mongodb.py、database_postgresql.py | 增删改查关系库/文档库/向量库 | MongoDBToolkit、PostgreSQLToolkit |
| MCP 接入 | mcp.py | 把外部 MCP 服务器的工具动态接进来 | MCPToolkit |
货架远不止这些:还有
request_arxiv.py、gmail_tools.py、telegram_tools.py、google_maps_tool.py、finance_tool.py、image_tools/、rss_feed.py、cmd_toolkit.py、file_tool.py等,全部在tools/__init__.py里统一导出(tools/__init__.py:1-74)。
4.2 深读:Python 代码沙箱 PythonInterpreterToolkit
它要解决的小问题: 让 agent 能跑一段它自己写的 Python,但不能让它 import os; os.system(...)
把机器搞崩。所以核心矛盾是"能执行"和"安全"的平衡。
思路: 用一份导入白名单 allowed_imports 做静态门禁——先 AST 静态分析代码里所有
import,凡不在白名单里的直接拒绝,再谈执行。
真实实现: execute() 是主入口(interpreter_python.py:285-342,PythonInterpreter.execute)。
它的门禁—执行两段值得看:
# interpreter_python.py:324-327 —— 先静态查违规,有违规就直接返回、不执行
if self.allowed_imports:
violations = self._analyze_code(code)
if violations:
return "\n".join(violations)
真正执行时用 exec() 并重定向 stdout 捕获输出(interpreter_python.py:330-342):
# interpreter_python.py:330-339 —— 捕获 stdout;异常则把 traceback 当结果返回
stdout_capture = io.StringIO()
with contextlib.redirect_stdout(stdout_capture):
try:
exec(code, {"__builtins__": __builtins__})
except Exception:
... # 组装 traceback 字符串返回
关键细节 / 坑(诚实): 源码里明确写了警告——exec() 以完整权限运行,白名单只挡 import,
不是真正的沙箱(interpreter_python.py:293-295 的 WARNING 注释:"Do not use with untrusted
code")。若 allowed_imports 为空,if self.allowed_imports: 为假,门禁整段被跳过——即默认
放行一切导入。真要隔离,应改用基于容器的 interpreter_docker.py(DockerInterpreterToolkit)。
契约怎么落地: PythonExecuteTool 就是第 3 节契约的教科书实例——声明 inputs
(code/language)、required=["code"],把 __call__ 转发给持有的 PythonInterpreter
(interpreter_python.py:374-401)。它的 __call__(self, code: str, language: str = "python")
签名和 inputs 完全对齐,所以能通过 3.2 的校验。
4.3 MCP:把外部工具生态动态接进来
它要解决的小问题: 别人已经用 MCP(Model Context Protocol,模型上下文协议——一套让工具服务器
和 LLM 客户端对话的标准)写好了工具服务器,EvoAgentX 想直接复用,而不是每个都手写 Tool。
思路: MCPToolkit 读一份配置(含 mcpServers 键),为每个服务器建 MCPClient 连接,
把服务器自报的工具 schema 动态包成 MCPTool(mcp.py:28-49,class MCPTool),再由
get_toolkits() 汇总成标准 Toolkit 列表(mcp.py:377)——于是外部工具和本地工具 长一个样。
配置解析见 mcp.py:346-365(_from_config):要求顶层必须有 mcpServers 键,再把每个命名服务器
拆成独立配置。连接失败的服务器会被剔除、不至于拖垮整体(mcp.py:317-332)。
config(mcpServers) ──▶ MCPToolkit ──▶ 每个服务器一个 MCPClient ──▶ _connect()
│
动态读取服务器上报的 inputSchema ◀──────────────────────┘
│
▼
包成 MCPTool(遵守 Tool 契约) ──▶ get_toolkits() 返回标准 Toolkit
5. 核心机制三:记忆(短期 vs 长期 vs 管理器)
这一节讲让 agent"跨轮记住东西"的三层。先给一张对比表建立心智模型,再逐层看。
| ShortTermMemory | LongTermMemory | MemoryManager | |
|---|---|---|---|
| 存哪 | 纯内存 deque | 向量库 + 数据库(经 RAG/Storage) | 不存,编排 LongTermMemory |
| 保留多少 | 最近 max_size 条(默认 5) | 全部,可检索 | —— |
| 能语义搜索吗 | 否 | 是 | 是(转发) |
| 典型用途 | 当前对话上下文 | 跨会话知识/历史 | 用 LLM 决策记什么、组装上下文 |
| 位置 | memory/memory.py:188 | memory/long_term_memory.py:16 | memory/memory_manager.py:17 |
5.1 短期记忆:一个会自动丢旧的滑动窗口
ShortTermMemory 就是包了个定长 deque 的对象(memory/memory.py:188-262)。它在
model_post_init 里把 buffer 转成 deque(maxlen=max_size)(memory/memory.py:217-222)——于是
超过容量的旧消息自动被挤掉,天然实现"只记最近 N 条"。它刻意不做持久化(类 docstring 明说
"does not persist to storage_handler or vector DB",memory/memory.py:194-195)。
5.2 长期记忆:落库 + 索引 + 检索的一整套 CRUD
LongTermMemory 继承 BaseMemory,把每条 Message 变成一个 RAG Chunk 存进向量库,同时
维护内存副本(memory/long_term_memory.py:16-34)。它对外提供完整的 add/get/delete/update/search。
一次 add 的路径(读它就懂整套设计):
add(messages) long_term_memory.py:77
│
├─ 归一化:str → Message,过滤空内容 :81-82
├─ ★ 基于 SHA-256 内容哈希去重 :88-109
│ 从 storage 读出已存 content_hash 集合
│ 命中 → 复用旧 memory_id,不重复索引
├─ Message → Chunk (_create_memory_chunk) :36-59
│ 元数据带上 action/wf_goal/agent/timestamp...
├─ super().add_message() 写内存副本 :121-122
└─ rag_engine.add(...) 索引进向量库 :125-126
精髓:内容哈希去重。 add 先把已有记录的 content_hash 收成集合,新消息算 SHA-256,命中就
复用旧 id、跳过索引(long_term_memory.py:100-109)——避免同一段内容反复占用向量库。
检索是异步的。 search_async 把查询包成 Query、调 rag_engine.query_async,再把召回的
Chunk 还原成 Message(long_term_memory.py:232-257);search 只是 asyncio.run 的同步壳
(long_term_memory.py:259-262)。注意 delete/update 内部也靠 asyncio.run(self.get(...))
把异步检索拉回同步(long_term_memory.py:168、:200)。
5.3 记忆管理器:让 LLM 决定"记什么"
MemoryManager 是长期记忆之上的"大脑"(memory/memory_manager.py:17-30)。它多做两件事:
其一,LLM 门控。 当 use_llm_management=True 且有 llm 时,add/update/delete 前会先问 LLM
"该不该做这个记忆操作",LLM 返回结构化 JSON(action ∈ add/update/delete),被拒的就跳过
(memory_manager.py:35-56 的 _prompt_llm_for_memory_operation,以及 :105-116 的用法)。
LLM 出错时回退到原始输入,不阻断主流程(memory_manager.py:54-56)。
其二,组装带记忆的消息。 create_message / create_conversation_message 先按 user_prompt
检索相关记忆,再把"用户输入 + 召回上下文/历史"拼成一条新 Message 交给 agent
(memory_manager.py:188-206、:208-250)——这就是"让模型看见它记得的东西"那一步。
6. 核心机制四:统一持久化 StorageHandler
长期记忆、RAG 索引、agent/workflow 的存档,底层都走同一个 StorageHandler
(storages/base.py:15-24)。这一节讲它怎么把"三种后端"藏在一个门面后。
思路:一个门面,三个工厂。 init_module 依配置分别初始化 DB / 向量 / 图三种存储,各自用工厂
按名字创建(storages/base.py:27-75):
| 后端 | 支持的实现 | 配置来源 |
|---|---|---|
| 关系型 DB | SQLite / PostgreSQL | DBConfig(storages_config.py:8) |
| 向量库 | FAISS / Qdrant | VectorStoreConfig(storages_config.py:18) |
| 图库 | Neo4j | GraphStoreConfig(storages_config.py:30) |
读写按"表类型"分发。 数据分成五张逻辑表,由枚举 TableType 定义:agent/workflow/
memory/history/indexing(storages/schema.py:7-16)。StorageHandler 为每类提供
load_*/save_* 对(如 save_memory、load_index,storages/base.py:153-187、:329-344),
内部统一走 "存在则 update、否则 insert" 的 upsert 逻辑。
一个易读细节:JSON 字段自动反序列化。 parse_result 遍历 Pydantic 模型字段,凡不是纯 str
的字段就尝试 json.loads(storages/base.py:133-151)——于是存进去的 dict/list 取出来还是
dict/list,调用方不用手动解析。
7. 核心机制五:RAG 检索引擎 RAGEngine
这一节讲"给 agent 配一个可搜索图书馆"的那台机器。长期记忆的索引/检索全靠它。
思路:一条五段管线,每段可插拔。 RAGEngine 在构造时按 RAGConfig 把五个工厂各装配一个实现
(rag/rag.py:25-93):
读取 Reader ─▶ 切块 Chunker ─▶ 嵌入 Embedding ─▶ 索引 Index ─▶ 检索 Retriever ─▶ 重排 Postprocessor
readers/ chunkers/ embeddings/ indexings/ retrievers/ postprocessors/
每段的策略都由 RAGConfig 的子配置决定(rag/rag_config.py:61-69):ChunkerConfig(块大小
1024 / 重叠 20)、EmbeddingConfig(默认 OpenAI text-embedding-ada-002)、IndexConfig
(默认 VECTOR)、RetrievalConfig(top_k=5、similarity_cutoff=0.7)。
两个核心动作:
add:把内容索引进去。 按 (corpus_id, index_type) 惰性建索引和检索器,首次见到某个 corpus
才创建(rag/rag.py:158-206,RAGEngine.add)。给每个 node 打上 corpus_id/index_type 元数据
再插入。corpus_id 是"库分区"的概念——不同会话/不同来源可隔离检索。
query_async:检索 + 重排。 值得读它怎么并发检索多个索引(rag/rag.py:608-697,
query_async):
# rag/rag.py:660-669 —— 把每个 (corpus, index) 的检索任务并发跑,再收集
retrieval_tasks = [task for task, _, _ in tasks]
retrieval_results = await asyncio.gather(*retrieval_tasks, return_exceptions=True)
for (_, cid, idx_type), result in zip(tasks, retrieval_results):
if isinstance(result, Exception):
logger.error(...) # 单个失败不拖垮整体
else:
results.append(result)
检索完再过一遍 postprocessor 重排(rag/rag.py:679-683),最后按 metadata_filters 做精确过滤
(rag/rag.py:685-691)。query 是 asyncio.run(query_async) 的同步壳(rag/rag.py:699-702)。
一处巧妙:维度自愈。 构造时若发现向量库配置的维度和实际嵌入模型维度不一致,会改配置并重建
向量库(rag/rag.py:71-75)——避免"1536 维索引塞 768 维向量"这类静默错误。
8. 核心机制六:人机协作 HITL(简述)
最后一层是"刹车":在工作流的危险动作前后插入人工闸门。这一节简述其拦截机制。
数据模型(hitl/hitl.py)。 三个枚举定义了"人能怎么回、在哪拦、拦来干嘛":