动手实现 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 编写的清单)。
- 核心循环。思路就是维护一个消息列表:
- 将初始的用户消息放进列表。
- 把整个消息列表发送给 LLM,拿到响应。
- 把 LLM 返回的消息打印出来,同时把这条消息放进消息列表。
- 如果有工具调用,则调用工具,然后把工具结果放进消息列表,然后跳转到 2
- 如果没有,结束循环。
- 一个用来和 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,它仅在 role 为 tool 时才设置,表示这个工具调用的结果对应了哪个工具调用请求。
到此为止,发送给 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 应该总是 assistant,content 可以有也可以没有,如果模型决定调用工具,那它可以什么都不说。观察 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!

浙公网安备 33010602011771号