动手实现 Agent(一):一个基于 ReAct 的基础 Agent

笔者承诺本文除直接的 LLM 消息原文外,其余内容的 AI 含量为 0。

本系列的源代码可以在 https://github.com/Eslzzyl/tinyagent 找到。

虽然自己也是做过一些 Agent 项目了,但是都是高强度 Vibe Coding 的产物,在这个系列中我会通过古法编程来重温 Agent 的实现。

Agent 的本质?

比较推荐阅读 https://github.com/shareAI-lab/learn-claude-code 这个仓库,让我学到很多。截至本文撰写时,此仓库有 20 章,当时我看的时候应该是只有少数几章。其实只看前两章就够了。

目前主流的所谓 Agent 就是 LLM+工具调用的循环。用户发一条消息,LLM 决定下一步,是调用工具还是直接回复。如果调用工具,那么框架调用对应的工具,把结果返回给 LLM,然后 LLM决定下一步,直到不再调用工具,一轮结束。仅此而已。剩余的所有 上下文管理、TODO、Skills、MCP、记忆、压缩、Soul、Subagent、Goal 这些都是锦上添花的东西。

一个最简的 Agent

在本文中,我们将会实现一个最简的 Agent:只有读和写两个工具。我们将会基于 OpenAI Python SDK 来请求 LLM。实际上也可以手搓请求的,但是那会增加额外的复杂度。

在写代码之前,我们可以先构思一下这个最简的 Agent 需要包含哪些东西:

  • 工具。包含读和写这两个工具的具体代码实现,以及一个提供给 LLM 的工具清单(LLM 无从得知我们直接通过代码定义的工具,它只接受我们通过 JSON 编写的清单)。
  • 核心循环。思路就是维护一个消息列表:
    1. 将初始的用户消息放进列表。
    2. 把整个消息列表发送给 LLM,拿到响应。
    3. 把 LLM 返回的消息打印出来,同时把这条消息放进消息列表。
    4. 如果有工具调用,则调用工具,然后把工具结果放进消息列表,然后跳转到 2
    5. 如果没有,结束循环。
  • 一个用来和 LLM API 交互的客户端
  • 一些必要的类型抽象,例如 Role、Message 等。

这就是全部!现在我们开始写代码。

工具

我们先写工具,因为这是一个相对独立的部分。

读写文件的代码都很简单:

def read(path: Path | str) -> str:
    with open(path, mode="r", encoding="utf-8") as f:
        lines = f.readlines()
        return "".join(lines)


def write(path: Path | str, content: str) -> str:
    with open(path, mode="w", encoding="utf-8") as f:
        f.write(content)
    return f"Wrote file {path}"

这里有一个需要注意的地方,作为可供 LLM 调用的工具,它们必须明确返回一个结果,即使对于 write 工具来说结果可能没有实际意义,但是 LLM 必须知道这个工具是成功了还是失败了。

当然,正经的 Agent 实现中这两个工具都不会这么简单,例如 read 就应该至少支持截断(基于字节数量的截断和基于行数的截断)、指定读取偏移量和行数、附加行号等功能。工具也应该带有比较完善的错误处理,但是为了简化实现,这些我们都先不做。

接下来我们需要编写工具的 Schema。任何一个成熟的 Agent 框架都应该支持自动从工具的签名和 docstring 中提取工具的参数和说明,但是为了简化实现,我们选择先手动写一下这两个工具的 Schema。

对照 OpenAI 文档 https://developers.openai.com/api/reference/python/resources/chat/subresources/completions/methods/create 可以写出下面的 Schema:

tools = [
    {
        "type": "function",
        "function": {
            "name": "read",
            "description": "read a file from filesystem",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "file path",
                    },
                },
                "required": ["path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write",
            "description": "write a file to filesystem",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "file path",
                    },
                    "content": {
                        "type": "string",
                        "description": "content to write",
                    },
                },
                "required": ["content"],
            },
        },
    },
]

这个东西会作为上下文的一部分发送给 LLM。

类型抽象

接下来我们写类型抽象,这是其他几个模块的基础,实际上如果简化到极致,这部分也可以不写,但是它对古法编程比较友好,能利用 IDE 提供的语法补全。而且代码看起来也清晰。

关于这部分,我非常推荐读者看一下上面的 OpenAI 的文档。OpenAI SDK 对请求体和响应体中的几乎所有对象都有类型抽象,但是那又长又丑,不适合我们的实现,而且我们应该尽可能和 OpenAI SDK 保持独立。不管怎么说,文档对我们的设计还是有指导作用的。

我们先写 Role 和 Message:

from enum import Enum
from pydantic import BaseModel

class Role(str, Enum):
    Assistant = "assistant"
    User = "user"
    System = "system"
    Tool = "tool"


class Message(BaseModel):
    role: Role
    content: str
    tool_call_id: str | None = None

OpenAI API 规定消息可以有 4 种(也可能不止,但是最常用就这 4 种)角色,见上。System 表示系统提示词,User 表示用户发送的消息,Assistant 表示 LLM 的回复,Tool 表示工具调用的结果(而不是工具调用参数)。

在纯文本模式下(我们先不考虑多模态),一条消息有一个 role 表示这是谁发的,一个 content 表示消息的内容,以及一个可选的 tool_call_id,它仅在 roletool 时才设置,表示这个工具调用的结果对应了哪个工具调用请求。

到此为止,发送给 LLM 的部分我们都涵盖了,消息就是一个 list[Message],而工具清单就是上面那一坨 Schema。但是我们还需要解析 LLM API 返回的内容。

从上面的 OpenAI 文档中我们可以看到 LLM 会返回这样的格式:

{
  "id": "chatcmpl-abc123",
  "object": "chat.completion",
  "created": 1699896916,
  "model": "gpt-4o-mini",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_abc123",
            "type": "function",
            "function": {
              "name": "get_current_weather",
              "arguments": "{\n\"location\": \"Boston, MA\"\n}"
            }
          }
        ]
      },
      "logprobs": null,
      "finish_reason": "tool_calls"
    }
  ],
  "usage": {
    "prompt_tokens": 82,
    "completion_tokens": 17,
    "total_tokens": 99,
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "accepted_prediction_tokens": 0,
      "rejected_prediction_tokens": 0
    }
  }
}

我们真正感兴趣的部分就是 choices,这是一个数组,当然对于用户来说模型的响应应该只有一个,不会有多种响应,所以我们就取choices[0],它的内容如下:

{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": null,
    "tool_calls": [
      {
        "id": "call_abc123",
        "type": "function",
        "function": {
          "name": "get_current_weather",
          "arguments": "{\n\"location\": \"Boston, MA\"\n}"
        }
      }
    ]
  },
  "logprobs": null,
  "finish_reason": "tool_calls"
}

如果不出意外的话这里的 role 应该总是 assistantcontent 可以有也可以没有,如果模型决定调用工具,那它可以什么都不说。观察 tool_calls 字段,这又是一个数组,其中每个元素都是一个工具调用。现在所有主流模型都支持并行工具调用,即在一个响应中同时调用多个工具,所以这里会是一个数组。这里面的 id 就是此次工具调用的 id,我们在后续添加工具结果消息时就需要通过 tool_call_id 来关联这里的请求。type 的值如果不出意外的话应该是固定的 function,表示这个工具是我们提供的(好像在某些情况下 LLM 会自己造工具?但我从未见过)。function 里就是具体的工具名和参数了。

基于以上分析,我们可以抽象两个类型,一是 LLM 返回的响应:

class Response(BaseModel):
    content: str
    tool_calls: list[ToolCall] | None

二是某个工具调用请求:

class ToolCall(BaseModel):
    id: str
    name: str
    arguments: dict[str, str]

需要注意响应体中的原始 arguments 是 JSON 编码过的,我们这里把它转换成 Key:Value 的对。

LLM 客户端

是时候编写一个基础的 LLM 客户端了。首先我们需要一个辅助函数来将我们定义的 list[Message] 转换成可以放进请求体的 dict

def build_messages(messages: list[Message]) -> list:
    result = []
    for msg in messages:
        d = {"role": msg.role, "content": msg.content}
        # 条件性添加 tool_call_id,如果没有就不添加,添加一个 null 反而会导致 API 报错。
        if msg.tool_call_id:
            d["tool_call_id"] = msg.tool_call_id
        result.append(d)
    return result

接下来是剩余的代码:

import json

from openai import OpenAI
from openai.types.chat import ChatCompletionMessageFunctionToolCall

from src.model import Message, Response, ToolCall

class Client:
    def __init__(self, base_url: str, api_key: str, model):
        self.client = OpenAI(base_url=base_url, api_key=api_key)
        self.model = model

    def call_with_tools(
        self, messages: list[Message], tool_spec_list: list
    ) -> Response:
        # 发送请求,为了简化,使用非流式接口
        response = self.client.chat.completions.create(
            model=self.model,
            messages=build_messages(messages),
            tools=tool_spec_list,
            tool_choice="auto",
        )
        # 我们这里直接取 choices[0]
        content = response.choices[0].message.content
        raw_tool_calls = response.choices[0].message.tool_calls
        tool_call_list: list[ToolCall] = []
        if raw_tool_calls:
            for call in raw_tool_calls:
                # 一个来自 OpenAI SDK 的类型,我们需要断言来让类型检查正确工作,这个断言也可以不加,不影响运行时行为。
                assert type(call) is ChatCompletionMessageFunctionToolCall
                raw_arguments = call.function.arguments
                # 解析原始的 JSON 格式的参数
                arguments = json.loads(raw_arguments)
                # 构造 ToolCall 对象
                tool_call = ToolCall(
                    id=call.id,
                    name=call.function.name,
                    arguments=arguments,
                )
                tool_call_list.append(tool_call)

        return Response(content=content if content else "", tool_calls=tool_call_list)

核心 Agent 循环

好了,这是最后一个核心模块,它把上面所有的内容连接起来。

from typing import Callable

from src import tool
from src.client import Client
from src.model import Message, Role


class Agent:
    def __init__(
        self, client: Client, tools: list[Callable], tool_spec_list: list[dict]
    ):
        self.client = client
        self.tools = tools
        self.tool_spec_list = tool_spec_list

    # 运行一个轮次:一轮指的是一条用户消息后 LLM 的多次调用直到停止调用工具
    def run(self, request: str, max_iterations: int = 10):
        iteration = 1
        messages: list[Message] = []
        # 将用户消息放在消息列表的开头
        messages.append(Message(role=Role.User, content=request))
        # 限制最大迭代次数避免无限循环
        while iteration < max_iterations:
            response = self.client.call_with_tools(
                messages=messages, tool_spec_list=self.tool_spec_list
            )
            # 打印 LLM 响应
            print(f"Assistant: {response.content}")
            messages.append(Message(role=Role.Assistant, content=response.content))
            # 如果有工具调用请求
            if response.tool_calls:
                # 依次调用每个工具,这里可以并行(如果都是只读工具的话),但是为了简单,我们串行调用
                for call in response.tool_calls:
                    id = call.id
                    name = call.name
                    arguments = call.arguments
                    # 打印工具调用情况
                    print(f"Tool: {name}({arguments})")
                    # 执行调用,已知函数名和参数我们可以直接用 getattr 调用这个函数
                    result = getattr(tool, name)(**arguments)
                    # 把工具结果放到消息列表,注意对应 tool_call_id
                    messages.append(
                        Message(role=Role.Tool, content=result, tool_call_id=id)
                    )
            # 如果没有工具调用请求,表示一轮结束,退出
            else:
                break

最后,我们需要写一个入口来调用这些东西:

import os

from dotenv import load_dotenv

from src.agent import Agent
from src.client import Client
from src.tool import read, tools, write

load_dotenv()


def main():
    client = Client(
        base_url=os.environ["OPENAI_BASE_URL"],
        api_key=os.environ["OPENAI_API_KEY"],
        model=os.environ["OPENAI_MODEL_NAME"],
    )
    agent = Agent(client=client, tools=[read, write], tool_spec_list=tools)
    request = input("Your Message:")
    agent.run(request=request)


if __name__ == "__main__":
    main()

需要配置下面的环境变量来调用 LLM:

OPENAI_BASE_URL = "https://api.openai.com/v1"
OPENAI_API_KEY = "sk-123456"
OPENAI_MODEL_NAME = "gpt-5.5"

效果

现在让我们测试一下。

Your Message:你好,你是谁?
Assistant: 你好!我是 Qwen3.5,是阿里巴巴最新推出的通义千问大语言模型。我可以帮助你回答问题、创作文字、编写代码、分析数据、表达观点等多种任务。有什么我可以帮你的吗?
Your Message:你有哪些工具可用?
Assistant: 我目前有两个工具可用:

1. **read** - 用于读取文件系统中的文件
   - 参数:`path`(文件路径)
   - 功能:从文件系统中读取指定路径的文件内容

2. **write** - 用于将内容写入文件
   - 参数:`path`(文件路径)、`content`(要写入的内容)
   - 功能:将指定内容写入到指定路径的文件中

这两个工具可以帮助我进行文件读取和写入操作。如果你需要处理文件相关任务,可以告诉我具体需求,我会使用这些工具帮你完成。
Your Message:请你尝试读取pyproject.toml
Assistant:
Tool: read({'path': 'pyproject.toml'})
Assistant: 根据读取到的 `pyproject.toml` 文件内容,以下是该项目的关键信息:

- **项目名称**: `tinyagent`
- **版本**: `0.1.0`
- **描述**: Add your description here
- **Python 要求**: `>=3.14`
- **依赖库**:
  - `openai>=2.38.0`
  - `pydantic>=2.13.4`
  - `python-dotenv>=1.2.2`

该项目的 `readme` 文件路径为 `README.md`。
Your Message:请你尝试写一个简单的文件
Assistant:
Tool: write({'content': '这是一个简单的测试文件\nHello World!', 'path': 'hello.txt'})
Assistant: 好的,我已经成功创建了一个名为 `hello.txt` 的简单文件。

Agent 在当前目录创建了一个 hello.txt 文件,内容如下:

这是一个简单的测试文件
Hello World!
posted @ 2026-05-29 14:03  Eslzzyl  阅读(28)  评论(0)    收藏  举报