参考资料
在构建智能对话系统时,我们常常面临一个核心问题:如何让 AI 助手能够基于特定领域的知识或私有数据进行准确回答?传统的做法有两种:要么微调大模型,要么在提示词中直接提供上下文。但这两种方法都有明显的局限性。微调成本高昂且需要持续更新,而提示词有长度限制,且无法处理大规模知识库。
RAG(Retrieval-Augmented Generation,检索增强生成)技术解决这个问题的核心思想是:在回答用户问题时,先从一个知识库中检索相关文档,然后将检索到的内容作为上下文提供给大模型,最后生成回答。
在实际工作场景中我需要使用 Strands Agent SDK 作为主要的开发框架。然而Strands SDK 本身虽然具备了RAG tools,但是Strands 本身似乎更倾向于和Bedrock等官方服务集成。如果从零开始实现 RAG的集成,则需要用户处理本分块、向量嵌入生成、向量数据库存储、相似度检索等一系列复杂问题。与此同时,AgentScope 作为一个开源成熟且活跃的多智能体框架,已经内置了完整的 RAG 实现。它提供了开箱即用的文本读取器、多种向量存储后端、以及简单的知识库管理接口(如果可能的话直接拥抱AgentScope)。
那么如果将 AgentScope 的 RAG 能力集成到 Strands Agent 中,就能够复用 AgentScope 的 RAG 组件和实现了。这个过程有几个关键挑战:
-
架构差异。AgentScope 的 RAG 组件是基于异步设计的,而 Strands 的工具调用机制默认是同步的。需要协调这两种不同的编程范式
-
工具封装。Strands 使用装饰器(@tool)将函数转换为可被 Agent 调用的工具,需要确保 AgentScope 的 RAG 功能能够以这种形式被暴露。
-
状态管理。知识库是有状态的,需要在多次对话中持久化。如何管理这些知识库实例?
-
错误处理。RAG 涉及网络调用、向量计算等复杂操作,如何优雅地处理各种可能的错误?
带着这些挑战和思考,我们开始了集成实践。
AgentScope的RAG架构
AgentScope 的RAG 实现遵循一个清晰的架构模式,由三个核心组件构成:
- Reader:负责从数据源读取数据并进行分块。将长文本切分成合适的块是后续有效检索的基础。AgentScope 提供了多种 Reader,比如 TextReader 用于纯文本,PDFReader 用于 PDF 文档,甚至支持 ImageReader 处理图像。Reader 的输出是统一的 Document 对象,包含了原始内容、元数据等信息。
- Knowledge:负责知识库查询检索和数据存储的逻辑。这是 RAG 的核心组件,封装了如何使用嵌入模型和向量存储。
- Store:负责与向量数据库的交互。这是 RAG 的底层存储层,实际保存和检索向量数据。AgentScope 目前内置了对 Qdrant 的支持。Qdrant 是一个性能优异的开源向量数据库,提供了多种部署方式:内存模式、本地持久化模式、云端托管模式。对于测试和开发可以选择内存模式,不需要额外的配置,数据保存在内存中,程序退出后就消失。但是在生产环境中,应该使用本地持久化或云端模式。
通过 AgentScope 简洁的设计,创建一个完整的 RAG 知识库只需要几行代码。关于SimpleKnowledge的示例代码参考,通过继承KnowledgeBase并实现retrieve和add_documents方法
py
from agentscope.rag import SimpleKnowledge
from agentscope.embedding import OpenAITextEmbedding
from agentscope.rag import QdrantStore
# 创建嵌入模型
emb_model = OpenAITextEmbedding(
model_name="text-embedding-3-small",
api_key="your-api-key",
dimensions=1536,
base_url="http://localhost:4000/v1",
)
# 创建知识库
knowledge = SimpleKnowledge(
embedding_model=emb_model,
embedding_store=QdrantStore(
location=":memory:", # 内存模式
collection_name="my_kb",
dimensions=1536,
),
)
在文档加载方面,TextReader 提供了两个关键参数:chunk_size 控制每个文本块的大小,split_by 决定按什么方式切分(比如按段落、按句子等)。因为文本块的大小直接影响 RAG 的效果。块太大,检索时可能包含太多无关信息,导致回答不够聚焦;块太小,又可能丢失重要的上下文,导致回答不完整。通常,对于中文文档,512 到 1024 个字符的块大小是比较合适的起点。
切分方式也很关键。按段落切分可以保持语义的完整性,按句子切分则可以提供更精细的检索粒度。根据具体的应用场景,可以选择不同的策略。
Strands的工具调用机制
在 Strands 中,将一个函数转换为 Agent 可调用工具只需使用 @tool 装饰器。
当你用 @tool 装饰一个函数时,Strands 会自动提取函数的元数据:包括函数名、参数类型、返回类型、以及docstring。这些元数据被转换成 OpenAI 兼容的工具规范,这使得大模型能够理解工具的能力和使用方法。装饰后的函数既是普通的 Python 函数,又是一个 Strands 工具。用户可以直接调用它,也可以将它传递给 Agent,让 Agent 在适当的时候自动调用它。
Strands 对工具的返回值有明确的规范:必须是一个字典,包含 "status" 字段(表示成功或失败)和 "content" 字段(包含实际的内容)。content 通常是一个列表,每个元素是一个字典,包含 "text" 字段。这种规范确保了工具调用的结果能够被正确地格式化和展示给用户。同时,它也让错误处理变得统一:工具只需要捕获异常并返回错误状态,上层逻辑可以统一处理。
将AgentScope RAG注册为Stands的Tool
基于对 AgentScope RAG 和 Strands 工具机制的理解,我们可以通过如下方式实现将AgentScope RAG注册为Stands的工具,并由agent自主决定RAG动作,效果如下

知识库管理
作为示例,使用 AgentScope 的 SimpleKnowledge 作为知识库核心。它内部管理着嵌入模型和向量存储。知识库的创建过程包括:实例化嵌入模型、实例化向量存储、将两者组合成 SimpleKnowledge。
py
def create_knowledge_base(
name: str = "default",
embedding_model: str = "text-embedding-3-small",
dimensions: int = 1536, # text-embedding-3-small 的维度
api_key: Optional[str] = None,
base_url: Optional[str] = None,
) -> SimpleKnowledge:
# 创建嵌入模型
emb_model = OpenAITextEmbedding(
model_name=embedding_model,
api_key=api_key or os.getenv("OPENAI_API_KEY", "sk-D6TQa6a-echLxddGr52kXQ"),
dimensions=dimensions,
base_url=base_url or os.getenv("OPENAI_BASE_URL", "http://localhost:4000/v1"),
)
# 创建知识库(使用内存模式的 Qdrant)
knowledge = SimpleKnowledge(
embedding_model=emb_model,
embedding_store=QdrantStore(
location=":memory:",
collection_name=f"kb_{name}",
dimensions=dimensions,
),
)
return knowledge
此外,实现全局的知识库缓存机制,使用字典来存储不同名称的知识库实例,以支持多个独立的知识库。当创建知识库时,首先检查缓存中是否已存在。懒加载的模式既提高了性能,又允许按需创建知识库。
py
def get_knowledge_base(name: str = "default", **kwargs) -> SimpleKnowledge:
"""获取或创建知识库实例"""
if name not in _knowledge_bases:
_knowledge_bases[name] = create_knowledge_base(name=name, **kwargs)
return _knowledge_bases[name]
工具函数的实现
使用 Strands 的 @tool 装饰器将这些辅助函数包装成工具,使其可以直接被 Strands Agent 使用。实现了五个核心工具:
- rag_query:用于从知识库检索相关文档。这个工具接收查询文本、知识库名称和返回数量等参数,内部调用知识库的 retrieve 方法,然后格式化结果。检索到的文档会包含相似度分数,这有助于评估检索的质量。
- rag_add_document:用于将单个文档添加到知识库。它使用 TextReader 将文本分块,然后调用知识库的 add_documents 方法。这个工具允许用户在对话过程中动态地向知识库添加信息。
- rag_load_file:用于从文件批量加载知识。它支持 .txt 和 .md 文件,读取文件内容后按段落切分,然后批量添加。这对于导入已有的文档库特别有用。
- rag_stats:用于查看知识库的统计信息。它返回知识库名称、文档数量、存储类型等元数据,帮助用户了解知识库的状态。
- rag_clear:用于清空知识库。这在重新开始或清理测试数据时很有用。实现方式是从缓存中删除知识库实例,下次使用时会重新创建一个空的。
此外,AgentScope 的 RAG 操作(添加文档、检索)都是异步的,因为涉及网络 I/O 和向量计算。但 Strands 的工具函数是同步的(似乎可以通过Hook来实现回调,但是不够优雅)。需要创建一组辅助函数封装与知识库交互的异步操作,并在工具函数内部使用 asyncio.run 来桥接异步和strands sdk的同步方法。
在工具函数内部使用 asyncio.run()。这个函数会启动一个事件循环,运行异步函数,然后阻塞等待结果,最后将结果返回。这样就实现了从异步到同步的转换。
例如rag_add_document方法的实现逻辑如下
py
from strands import tool
...
@tool
def rag_add_document(
content: str,
knowledge_base: str = "default",
) -> dict:
"""
添加新文档到知识库
使用此工具将重要信息添加到知识库,以便在后续对话中能够检索和使用这些信息。
适合添加用户提供的知识点、事实陈述或需要记住的内容。
参数:
content: 要添加到知识库的文档内容(文本)
knowledge_base: 要添加到的知识库名称(默认为 "default")
返回:
包含操作结果的字典
"""
try:
count = asyncio.run(add_text_to_kb(content, knowledge_base))
return {
"status": "success",
"content": [
{"text": f"✓ 已添加 {count} 个文档块到知识库 '{knowledge_base}'"}
],
}
except Exception as e:
return {
"status": "error",
"content": [{"text": f"添加文档时出错: {str(e)}"}],
}
文档切分的策略
在实现中,我选择了按段落切分(split_by="paragraph"),块大小设为 512。这是一个经验值,但对于不同的应用场景可能需要调整。
-
如果文档主要是连续的长文本,按段落切分能保持语义完整性。如果文档是结构化的(比如有明确的章节划分),可能需要考虑更智能的切分方式。
-
块大小的选择也需要权衡。太小会导致上下文破碎,太大又会导致检索不够精确。在实践中,可能需要通过实验来找到最优值。
py
async def add_text_to_kb(
text: str,
kb_name: str = "default",
chunk_size: int = 512,
) -> int:
kb = get_knowledge_base(kb_name)
# 使用 TextReader 分块文本
reader = TextReader(chunk_size=chunk_size, split_by="paragraph")
documents = await reader(text=text)
# 添加到知识库
await kb.add_documents(documents)
return len(documents)
此外,AgentScope 允许自定义 Reader。如果内置的 TextReader 不能满足需求,可以继承 BaseReader 类并实现自己的切分逻辑。
为Strands实现通用的RAG方式
agentscope的ReActAgent 还提供了通用的rag方法, 在每次 reply 函数开始执行时检索知识,并将检索到的知识附加到用户消息的提示中。
具体而言每次 reply() 方法开始时都会执行 _retrieve_from_knowledge方法,并通过kb.retrieve(query=query)检索上下文
py
async def reply(self, msg: Msg | list[Msg] | None = None, ...) -> Msg:
# 1. 记录用户消息
await self.memory.add(msg)
# 2. 自动检索知识
# -------------- Retrieval process --------------
# Retrieve relevant records from the long-term memory if activated
await self._retrieve_from_long_term_memory(msg)
# Retrieve relevant documents from the knowledge base(s) if any
await self._retrieve_from_knowledge(msg)
# 3. 进入推理-行动循环
for _ in range(self.max_iters):
msg_reasoning = await self._reasoning(tool_choice)
...
Strands Hook机制
那么如何在Strands中实现类似通用RAG逻辑?可以考虑使用Hook功能。
Strands中的Hook回调函数针对特定的事件类型进行注册,并在代理执行过程中这些事件发生时接收强类型的事件对象。每个事件都包含与代理生命周期该阶段相关的数据。例如,BeforeInvocationEvent 包含代理和请求的详细信息。可以使用 agent.hooks 在事后为特定事件注册回调函数:
py
agent = Agent()
# Register individual callbacks
def my_callback(event: BeforeInvocationEvent) -> None:
print("Custom callback triggered")
agent.hooks.add_callback(BeforeInvocationEvent, my_callback)
具体实现rag的hook示例如下
py
from strands.hooks.events import BeforeModelCallEvent
class RAGAutoInjector(HookProvider):
def __init__(self, knowledge, top_k: int = 3, enable_logging: bool = True):
self.knowledge = knowledge
self.top_k = top_k
self.enable_logging = enable_logging
def register_hooks(self, registry, **kwargs):
registry.add_callback(BeforeModelCallEvent, self.on_before_model_call)
def on_before_model_call(self, event):
messages = event.agent.messages
if not messages:
return
last_msg = messages[-1]
query = self._extract_query(last_msg)
if not query:
return
try:
import nest_asyncio # 允许在已有的事件循环中嵌套运行异步代码,解决 "asyncio.run() cannot be called from a running event loop" 错误
nest_asyncio.apply()
async def retrieve():
docs = await self.knowledge.retrieve(query=query, limit=self.top_k)
docs.sort(key=lambda doc: doc.score or 0.0, reverse=True)
return docs[: self.top_k]
loop = asyncio.get_event_loop()
docs = loop.run_until_complete(retrieve())
if not docs:
return
rag_context = "\n\n".join([doc.metadata["content"]["text"] for doc in docs])
enhanced_query = (
f"{query}\n\n"
f"<retrieved_knowledge>\n"
f"以下是从知识库检索到的相关信息:\n\n"
f"{rag_context}\n"
f"</retrieved_knowledge>"
)
# 修改消息 - 保持原始格式
if isinstance(last_msg, str):
event.agent.messages[-1] = enhanced_query
elif isinstance(last_msg, dict):
content = last_msg.get("content")
if isinstance(content, str):
event.agent.messages[-1]["content"] = enhanced_query
elif isinstance(content, list) and content:
# Strands 格式: {'role': 'user', 'content': [{'text': 'query'}]}
event.agent.messages[-1]["content"][0]["text"] = enhanced_query
except Exception as e:
if self.enable_logging:
print(f"[Hook] ✗ 检索失败: {e}\n")
在agent中注册Hook
py
from strands.models.openai import OpenAIModel
knowledge = get_knowledge_base("default")
hook = RAGAutoInjector(knowledge=kb, top_k=2)
# 创建 Agent,使用 Hook 自动注入
agent = Agent(
model=OpenAIModel(...),
hooks=[hook],
system_prompt="""你是一个智能助手。基于提供的检索知识回答用户问题。如果检索结果中没有相关信息,基于你的训练知识回答""",
)
效果如下
