从 0 开始手写 AI Agent 框架:nano-agentscope(二)框架搭建

本章目标

这一章只解读 nano-agentscope最小可运行 Agent 闭环,不讲 MCP/RAG/多智能体等扩展能力。

我们要把这一行拆到"源码级别可复刻"的程度:

python 复制代码
response = await agent(user_msg)

本章只依赖这些文件:

  • examples/01_hello_world.py
  • src/nano_agentscope/agent.py
  • src/nano_agentscope/message.py
  • src/nano_agentscope/memory.py
  • src/nano_agentscope/formatter.py
  • src/nano_agentscope/model.py

核心组件架构图

外部服务
核心组件
Agent 层
用户层
Msg

  1. 存储输入 2. 获取历史 list[Msg]
  2. 格式化 list[dict]
  3. 调用 API SDK Response
    ChatResponse
  4. 构造响应 Msg 6. 存储响应 Msg
    Msg
    用户代码

await agent(user_msg)
ReActAgent
call()
reply()
_reasoning()
Msg

消息协议
Memory

上下文容器
Formatter

协议转换
Model

模型适配
LLM API

DashScope / OpenAI

调用链总结:

  1. 用户调用await agent(user_msg) 触发 __call__()reply()
  2. 存储输入Memory.add(user_msg)
  3. 推理阶段_reasoning() 从 Memory 获取历史 → Formatter 转换 → Model 调用 LLM
  4. 响应处理 → 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 转成 SDK messages
  • 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 解读:这里的"最小框架设计"到底最小在哪里?

  • modelformatter 是必填:因为它们构成了"把上下文喂给模型并拿回结果"的最小边界。
  • 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)内部消息协议:ContentBlockMsg

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)最小记忆实现:MemoryBaseInMemoryMemory

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)模型适配:ChatResponseChatModelBaseDashScopeChatModel

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.py
    • response = await agent(user_msg)
  • src/nano_agentscope/agent.py
    • AgentBase.__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.py
    • DashScopeChatModel.__call__() 构造 request_kwargs 调用 SDK
    • _parse_response() / _parse_stream_response() → 统一成 ChatResponse

到这里,你已经把"最小 Agent 框架"拆成了可以逐层替换/改造的 5 个模块:MsgMemoryFormatterModelAgent

相关推荐
人工智能培训1 分钟前
10分钟了解向量数据库(2)
人工智能·深度学习·机器学习·cnn·智能体
Jelena157795857929 分钟前
实战解析:京东关键词搜索 item_search_pro —— 按关键字搜索商品
开发语言·数据库·python
2501_9418705616 分钟前
从日志泛滥到结构化可观测体系落地的互联网系统工程实践随笔与多语言语法思考
开发语言·python
咕噜企业分发小米17 分钟前
阿里云与华为云AI教育产品有哪些未来发展规划?
人工智能·阿里云·华为云
五度易链-区域产业数字化管理平台25 分钟前
五度易链「生物医药智能决策系统」(AI智能体)上线啦
大数据·人工智能
寂寞恋上夜25 分钟前
字段校验规则清单:必填/范围/唯一/组合唯一/正则(附校验表)
人工智能·prompt·测试用例·markdown转xmind·deepseek思维导图
BitaHub202430 分钟前
Google 开源 A2UI 协议:让 AI Agent 告别“纯文字对话”,开启原生交互新时代
人工智能
ekkoalex30 分钟前
Qwen3-vl使用到的Timemaker方法
人工智能
席万里32 分钟前
1. 两数之和
python
低调小一40 分钟前
Google A2UI 入门:让 Agent “说 UI”,用声明式 JSON 安全渲染到原生界面
人工智能·安全·ui·json