本章目标
这一章只解读 nano-agentscope 的最小可运行 Agent 闭环,不讲 MCP/RAG/多智能体等扩展能力。
我们要把这一行拆到"源码级别可复刻"的程度:
python
response = await agent(user_msg)
本章只依赖这些文件:
examples/01_hello_world.pysrc/nano_agentscope/agent.pysrc/nano_agentscope/message.pysrc/nano_agentscope/memory.pysrc/nano_agentscope/formatter.pysrc/nano_agentscope/model.py
核心组件架构图
外部服务
核心组件
Agent 层
用户层
Msg
- 存储输入 2. 获取历史 list[Msg]
- 格式化 list[dict]
- 调用 API SDK Response
ChatResponse - 构造响应 Msg 6. 存储响应 Msg
Msg
用户代码
await agent(user_msg)
ReActAgent
call()
reply()
_reasoning()
Msg
消息协议
Memory
上下文容器
Formatter
协议转换
Model
模型适配
LLM API
DashScope / OpenAI
调用链总结:
- 用户调用 →
await agent(user_msg)触发__call__()→reply() - 存储输入 →
Memory.add(user_msg) - 推理阶段 →
_reasoning()从 Memory 获取历史 → Formatter 转换 → Model 调用 LLM - 响应处理 → Model 返回
ChatResponse→ 构造Msg→ 存入 Memory → 返回给用户
1)最小可运行代码:examples/01_hello_world.py
先把"能跑起来"摆在桌面上。下面这段就是最小示例的核心(原样摘录)。
1.1 示例源码(examples/01_hello_world.py)
python
import asyncio
import os
import sys
# 添加源码路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from nano_agentscope import (
ReActAgent,
DashScopeChatModel,
OpenAIChatModel,
OpenAIFormatter,
InMemoryMemory,
Msg,
)
def create_model(use_openai: bool = False):
"""创建 LLM 模型
Args:
use_openai: 是否使用 OpenAI 模型,否则使用 DashScope
"""
if use_openai:
return OpenAIChatModel(
model_name="gpt-4o-mini",
stream=True,
)
else:
return DashScopeChatModel(
model_name="qwen-max", # 通义千问 qwen-max / qwen-plus / qwen-turbo
stream=True,
)
async def main(use_openai: bool = False):
"""主函数:创建智能体并进行对话"""
# 1. 创建 LLM 模型
model = create_model(use_openai)
print(f"使用模型: {model.model_name}")
# 2. 创建格式化器
# DashScope 和 OpenAI 使用相同的消息格式
formatter = OpenAIFormatter()
# 3. 创建记忆模块
memory = InMemoryMemory()
# 4. 创建智能体
agent = ReActAgent(
name="小助手",
sys_prompt="你是一个友好的 AI 助手,用简洁的中文回答问题。",
model=model,
formatter=formatter,
memory=memory,
)
# 5. 创建用户消息
user_msg = Msg(
name="用户",
content="你好!请用一句话介绍一下你自己。",
role="user",
)
print("=" * 50)
print("用户:", user_msg.content)
print("=" * 50)
# 6. 调用智能体获取回复
response = await agent(user_msg)
# 7. 打印回复
print("=" * 50)
print(f"回复完成!")
print(f"消息 ID: {response.id}")
print(f"时间戳: {response.timestamp}")
1.2 最小闭环里每个对象的"不可替代职责"
Msg:框架内部消息协议(后面你会看到它为什么必须支持ContentBlock)InMemoryMemory:上下文容器(多轮时就是"对话历史")OpenAIFormatter:协议转换层(把内部Msg转成 SDKmessages)DashScopeChatModel/OpenAIChatModel:模型适配层(把 SDK 响应统一成ChatResponse)ReActAgent:控制流编排(把推理流程固定成可运行的代码结构)
接下来按调用链从 await agent(user_msg) 往下拆。
2)调用入口:AgentBase.__call__()
2.1 源码(src/nano_agentscope/agent.py)
python
class AgentBase:
"""智能体基类 - 定义智能体的基本接口
所有智能体都应继承此类并实现 reply 和 observe 方法。
核心方法:
- reply: 生成回复(主要逻辑)
- observe: 观察消息(不产生回复)
- __call__: 调用入口,包装 reply
设计原则:
- 异步优先:所有方法都是异步的
- 可扩展:通过继承实现不同类型的 Agent
"""
name: str # 智能体名称
@abstractmethod
async def reply(self, msg: Msg | list[Msg] | None = None) -> Msg:
...
@abstractmethod
async def observe(self, msg: Msg | list[Msg] | None) -> None:
...
async def __call__(self, msg: Msg | list[Msg] | None = None) -> Msg:
"""调用智能体
这是调用智能体的主要入口,内部调用 reply 方法。
"""
return await self.reply(msg)
2.2 解读:为什么要多一层 __call__?
这层的意义是把 Agent 变成"可 await 的 callable ":外部永远是 await agent(msg),内部统一走 reply()。
工程上这很关键:你后面无论写什么编排(哪怕只是你自己写个 for-loop),都可以把 Agent 当函数一样组合和替换。
3)依赖注入与默认值:ReActAgent.__init__()
3.1 源码(src/nano_agentscope/agent.py)
python
class ReActAgent(AgentBase):
def __init__(
self,
name: str,
sys_prompt: str,
model: ChatModelBase,
formatter: FormatterBase,
toolkit: Toolkit | None = None,
memory: MemoryBase | None = None,
max_iters: int = 10,
) -> None:
self.name = name
self.sys_prompt = sys_prompt
self.model = model
self.formatter = formatter
self.toolkit = toolkit or Toolkit()
self.memory = memory or InMemoryMemory()
self.max_iters = max_iters
3.2 解读:这里的"最小框架设计"到底最小在哪里?
model、formatter是必填:因为它们构成了"把上下文喂给模型并拿回结果"的最小边界。memory有默认:最小闭环只需要一个容器保存历史消息。max_iters有默认:这是 Agent 控制流的"保险丝",不属于扩展能力,是最基本的工程防护。
注意:toolkit 在本章不展开;最小示例不注册工具时,控制流会自然停在第一轮推理返回。
4)最关键的控制流:ReActAgent.reply()
4.1 源码(src/nano_agentscope/agent.py)
python
async def reply(self, msg: Msg | list[Msg] | None = None) -> Msg:
# Step 1: 存储输入消息
await self.memory.add(msg)
# Step 2-6: ReAct 循环
for _ in range(self.max_iters):
# 推理步骤
response_msg = await self._reasoning()
# 检查是否有工具调用
tool_use_blocks = response_msg.get_content_blocks("tool_use")
if not tool_use_blocks:
# 没有工具调用,直接返回
return response_msg
# 执行工具调用
for tool_call in tool_use_blocks:
await self._acting(tool_call)
# Step 7: 超过最大迭代,强制总结
return await self._summarize()
4.2 解读:为什么停机条件要依赖 tool_use 块?
这里不是"约定模型说一句结束就结束",而是用结构化信号控制流程:
response_msg.get_content_blocks("tool_use")为空 → 直接返回- 不为空 → 进入
_acting()执行(本章不展开)
这使得控制流可验证:你只要检查 Msg.content 的块类型,就能知道 Agent 会不会继续跑。
5)推理阶段:ReActAgent._reasoning()
5.1 源码(src/nano_agentscope/agent.py)
python
async def _reasoning(self) -> Msg:
# 构建消息列表
msgs = [
Msg(name="system", content=self.sys_prompt, role="system"),
*await self.memory.get_memory(),
]
# 格式化消息
formatted_msgs = await self.formatter.format(msgs)
# 获取工具 schema
tools = self.toolkit.get_json_schemas() or None
# 打印请求日志
self._print_llm_request(formatted_msgs, tools)
# 调用模型
response = await self.model(
messages=formatted_msgs,
tools=tools,
tool_choice="auto" if tools else None,
)
# 处理响应(流式或非流式)
if isinstance(response, AsyncGenerator):
# 流式响应:累积所有 chunk
final_response = None
async for chunk in response:
final_response = chunk
# 可以在这里添加流式输出逻辑
self._print_streaming(chunk)
response = final_response
print() # 换行
# 转换为 Msg
response_msg = Msg(
name=self.name,
content=list(response.content) if response else [],
role="assistant",
metadata=response.metadata if response else None,
)
# 存储到记忆
await self.memory.add(response_msg)
# 打印非流式响应
if not self.model.stream:
self._print_response(response_msg)
# 打印 Token 使用统计
if response and response.usage:
self._print_token_usage(response.usage)
return response_msg
5.2 解读:这段代码把"Agent 推理"拆成了三层边界
- 上下文来源 :
Msg(name="system", ...) + await self.memory.get_memory() - 协议转换 :
await self.formatter.format(msgs)(把内部Msg变成 SDK 可用的messages) - 模型适配 :
await self.model(messages=..., ...)(返回统一ChatResponse或流式生成器)
另外:流式处理逻辑也写在这一层,而不是散落在 model/formatter 里------这样 Model 只需专注于"如何把 SDK 响应解析成 ChatResponse"。
6)内部消息协议:ContentBlock 与 Msg
6.1 ContentBlock 定义(src/nano_agentscope/message.py)
python
class TextBlock(TypedDict, total=False):
type: Required[Literal["text"]]
text: str
class ToolUseBlock(TypedDict, total=False):
type: Required[Literal["tool_use"]]
id: Required[str]
name: Required[str]
input: Required[dict[str, object]]
class ToolResultBlock(TypedDict, total=False):
type: Required[Literal["tool_result"]]
id: Required[str]
name: Required[str]
output: Required[str | list]
class ImageBlock(TypedDict, total=False):
type: Required[Literal["image"]]
url: str
ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ImageBlock
6.2 Msg 的关键行为(src/nano_agentscope/message.py)
6.2.1 初始化时生成 id/timestamp
python
def __init__(
self,
name: str,
content: str | Sequence[ContentBlock],
role: Literal["user", "assistant", "system"],
metadata: dict | None = None,
timestamp: str | None = None,
) -> None:
self.name = name
self.content = content
self.role = role
self.metadata = metadata
# 自动生成 ID 和时间戳
self.id = str(uuid.uuid4())[:8]
self.timestamp = timestamp or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
6.2.2 把 content 统一成"可按块处理"的形态
python
def get_content_blocks(
self,
block_type: Literal["text", "tool_use", "tool_result", "image"] | None = None,
) -> Sequence[ContentBlock]:
# 如果 content 是字符串,转换为 TextBlock
if isinstance(self.content, str):
blocks = [TextBlock(type="text", text=self.content)]
else:
blocks = list(self.content) if self.content else []
# 按类型筛选
if block_type:
blocks = [b for b in blocks if b.get("type") == block_type]
return blocks
6.2.3 为什么 get_text_content() 很重要
python
def get_text_content(self, separator: str = "\n") -> str | None:
if isinstance(self.content, str):
return self.content
texts = []
for block in self.content:
if block.get("type") == "text":
texts.append(block["text"])
return separator.join(texts) if texts else None
6.3 解读:最小框架为什么仍然要支持块结构?
即使你只做"最小对话",Msg.content 也不能永远是字符串------因为 ReActAgent.reply() 的停机条件需要依赖结构化块:
- 继续/停止取决于
tool_use块是否存在(见reply()里get_content_blocks("tool_use"))。
这就是"最小闭环"里最硬核的一点:控制流条件来自结构化数据,而不是来自 prompt 的自然语言约定。
7)最小记忆实现:MemoryBase 与 InMemoryMemory
7.1 源码(src/nano_agentscope/memory.py)
python
class MemoryBase:
@abstractmethod
async def add(self, msg: Msg | list[Msg] | None) -> None:
pass
@abstractmethod
async def get_memory(self) -> list[Msg]:
pass
@abstractmethod
async def clear(self) -> None:
pass
@abstractmethod
async def size(self) -> int:
pass
class InMemoryMemory(MemoryBase):
def __init__(self) -> None:
self.content: list[Msg] = []
async def add(
self,
msg: Msg | list[Msg] | None,
allow_duplicates: bool = False,
) -> None:
if msg is None:
return
# 统一转换为列表
if isinstance(msg, Msg):
messages = [msg]
else:
messages = list(msg)
# 检查重复
if not allow_duplicates:
existing_ids = {m.id for m in self.content}
messages = [m for m in messages if m.id not in existing_ids]
self.content.extend(messages)
async def get_memory(self) -> list[Msg]:
return self.content
7.2 解读:为什么 add() 默认要去重?
最小闭环里 reply()、_reasoning() 都会往 Memory 里写消息;一旦你在编排时重复 observe/add,去重能避免上下文被重复污染。
这不是"高级记忆策略",而是最小工程实现里一个很实用的护栏。
8)协议转换:OpenAIFormatter.format()
8.1 源码(src/nano_agentscope/formatter.py)
这段代码的价值在于:它把内部
Msg(块结构)转换成"OpenAI 风格 messages"。即使你底层模型是 DashScope,只要它接受相同的 message 结构,这层就复用。
python
class FormatterBase:
@abstractmethod
async def format(self, msgs: list[Msg]) -> list[dict[str, Any]]:
pass
@staticmethod
def _assert_msgs(msgs: list[Msg]) -> None:
if not isinstance(msgs, list):
raise TypeError(f"msgs 必须是列表,但收到 {type(msgs)}")
for msg in msgs:
if not isinstance(msg, Msg):
raise TypeError(f"列表元素必须是 Msg,但收到 {type(msg)}")
class OpenAIFormatter(FormatterBase):
async def format(self, msgs: list[Msg]) -> list[dict[str, Any]]:
self._assert_msgs(msgs)
formatted_msgs = []
for msg in msgs:
content_blocks = []
tool_calls = []
# 处理每个 ContentBlock
for block in msg.get_content_blocks():
block_type = block.get("type")
if block_type == "text":
# 文本块
content_blocks.append({
"type": "text",
"text": block["text"],
})
elif block_type == "tool_use":
# 工具调用块 -> 转换为 OpenAI tool_calls 格式
tool_calls.append({
"id": block["id"],
"type": "function",
"function": {
"name": block["name"],
"arguments": json.dumps(
block.get("input", {}),
ensure_ascii=False,
),
},
})
elif block_type == "tool_result":
# 工具结果块 -> 单独的 tool 消息
output = block.get("output", "")
if isinstance(output, list):
# 如果输出是列表,提取文本
texts = [
b["text"] for b in output
if isinstance(b, dict) and b.get("type") == "text"
]
output = "\n".join(texts)
formatted_msgs.append({
"role": "tool",
"tool_call_id": block["id"],
"name": block.get("name", ""),
"content": str(output),
})
elif block_type == "image":
# 图片块(简化版)
if "url" in block:
content_blocks.append({
"type": "image_url",
"image_url": {"url": block["url"]},
})
# 构建 OpenAI 消息
if content_blocks or tool_calls:
openai_msg = {
"role": msg.role,
"name": msg.name,
}
if content_blocks:
openai_msg["content"] = content_blocks
else:
openai_msg["content"] = None
if tool_calls:
openai_msg["tool_calls"] = tool_calls
formatted_msgs.append(openai_msg)
return formatted_msgs
8.2 解读:为什么 Formatter 必须独立出来?
最小框架要解决的不是"能不能拼出 messages",而是:不要让 ReActAgent 依赖任何 SDK 的 message 细节。
- 只要内部协议(
Msg/ContentBlock)不变 - 外部协议差异(OpenAI message 字段、tool_calls 形态等)就被锁在
Formatter一处
这就是最小框架里最"省未来成本"的设计。
9)模型适配:ChatResponse、ChatModelBase、DashScopeChatModel
9.1 统一响应结构:ChatResponse
python
@dataclass
class ChatResponse:
content: list[TextBlock | ToolUseBlock] = field(default_factory=list)
usage: ChatUsage | None = None
metadata: dict | None = None
9.2 统一调用接口:ChatModelBase.__call__()
python
class ChatModelBase:
def __init__(self, model_name: str, stream: bool = True) -> None:
self.model_name = model_name
self.stream = stream
@abstractmethod
async def __call__(
self,
messages: list[dict],
tools: list[dict] | None = None,
tool_choice: Literal["auto", "none", "required"] | None = None,
**kwargs: Any,
) -> ChatResponse | AsyncGenerator[ChatResponse, None]:
pass
9.3 DashScope 的最关键实现:请求构造与响应解析
9.3.1 构造请求(DashScopeChatModel.__call__)
python
async def __call__(
self,
messages: list[dict],
tools: list[dict] | None = None,
tool_choice: Literal["auto", "none", "required"] | None = None,
**kwargs: Any,
) -> ChatResponse | AsyncGenerator[ChatResponse, None]:
# 延迟导入
import dashscope
from dashscope.aigc.generation import AioGeneration
start_time = datetime.now()
# 构建请求参数
request_kwargs = {
"model": self.model_name,
"messages": messages,
"api_key": self.api_key,
"stream": self.stream,
"result_format": "message", # 使用 message 格式
"incremental_output": self.stream, # 流式时使用增量输出
**self.generate_kwargs,
**kwargs,
}
# 添加工具
if tools:
request_kwargs["tools"] = tools
# 工具选择(DashScope 不支持 required,转为 auto)
if tool_choice:
if tool_choice == "required":
tool_choice = "auto"
request_kwargs["tool_choice"] = tool_choice
# 调用 API
response = await AioGeneration.call(**request_kwargs)
if self.stream:
return self._parse_stream_response(response, start_time)
else:
return self._parse_response(response, start_time)
9.3.2 非流式解析:把 SDK response 统一成 ChatResponse
python
def _parse_response(self, response: Any, start_time: datetime) -> ChatResponse:
from http import HTTPStatus
if response.status_code != HTTPStatus.OK:
raise RuntimeError(f"DashScope API 错误: {response}")
content_blocks = []
message = response.output.choices[0].message
# 解析文本内容
content = message.get("content")
if content:
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and "text" in item:
content_blocks.append(
TextBlock(type="text", text=item["text"])
)
else:
content_blocks.append(
TextBlock(type="text", text=str(content))
)
# 解析工具调用
for tool_call in message.get("tool_calls", []) or []:
args_str = tool_call.get("function", {}).get("arguments", "{}")
try:
input_dict = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
input_dict = {}
content_blocks.append(
ToolUseBlock(
type="tool_use",
id=tool_call.get("id", ""),
name=tool_call.get("function", {}).get("name", ""),
input=input_dict,
)
)
# 构建 usage
usage = None
if response.usage:
usage = ChatUsage(
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
time=(datetime.now() - start_time).total_seconds(),
)
return ChatResponse(content=content_blocks, usage=usage)
9.3.3 流式解析:为什么 Agent 端要识别 AsyncGenerator?
DashScopeChatModel 在流式模式下会 yield ChatResponse(...),因此 Agent 端 _reasoning() 才需要:
isinstance(response, AsyncGenerator)async for chunk in response累积最终结果
对应的流式解析实现(摘录核心部分):
python
async for chunk in response:
...
# 构建响应
content_blocks = []
if text:
content_blocks.append(TextBlock(type="text", text=text))
for tc in tool_calls.values():
...
content_blocks.append(
ToolUseBlock(
type="tool_use",
id=tc["id"],
name=tc["name"],
input=input_dict,
)
)
yield ChatResponse(content=content_blocks, usage=usage)
9.4 解读:最小框架里 Model 层必须解决什么?
- 接口统一 :Agent 永远按
await model(messages=...)调用,不关心具体 SDK。 - 响应统一 :无论 SDK 返回什么字段,最终都落到
ChatResponse(content=[...blocks])。 - 流式统一 :流式时输出
AsyncGenerator[ChatResponse],非流式时输出ChatResponse。
有了这一层,ReActAgent 才能用同一套控制流处理不同模型与输出模式。
10)端到端调用链:从 await agent(user_msg) 到模型请求
把上面所有源码拼回去,最小闭环的真实调用链就是:
examples/01_hello_world.pyresponse = await agent(user_msg)
src/nano_agentscope/agent.pyAgentBase.__call__()→ReActAgent.reply()ReActAgent.reply()await memory.add(user_msg)response_msg = await self._reasoning()
ReActAgent._reasoning()msgs = [system_msg] + await memory.get_memory()formatted_msgs = await formatter.format(msgs)response = await model(messages=formatted_msgs, ...)response_msg = Msg(... content=list(response.content) ...)await memory.add(response_msg)
src/nano_agentscope/model.pyDashScopeChatModel.__call__()构造request_kwargs调用 SDK_parse_response()/_parse_stream_response()→ 统一成ChatResponse
到这里,你已经把"最小 Agent 框架"拆成了可以逐层替换/改造的 5 个模块:Msg、Memory、Formatter、Model、Agent。