100行代码手写一个reAct Agent

100行代码手写一个reAct Agent


什么是agent,在LLM Powered Autonomous Agents这篇文章中的总结就很好,Agent=LLM+Memory+Plan+Tool Use

那么如何去完成一个实实在在能解决问题的agent呢?在之前,我想可能是去学习一个agent框架,例如langchain,去阅读他的文档,了解框架用法,再去拆解问题,写代码

但是在读了Building Effective AI Agents这篇文章后,按照A社的指南,如无必要,勿增实体

最佳的实践是在实现一个Agent的时候,尽量保持它的简洁,最成功的实现是使用简单,可组合的模式,而不是复杂的框架

这些复杂的框架的封装会导致你失去对底层的控制,掩盖底层的响应和提示,使调试变得困难

另外,它也会导致简单的问题变的复杂

那么,手写一个reAct Agent就变的很重要了,既然针对问题我们第一步不应该想到框架,我们应该把握好对于Agent底层的写法,而不是对于任何问题都使用庞大的框架,于是我写了一个小玩具。

下面是本文的代码仓库地址:

https://github.com/Maizsh/reAct-agent-demo

目标

我们的目标是实现一个一百行代码左右的reAct Agent,他拥有两个工具,一个数学工具可以计算,另外一个工具是web搜索可以让他获取外部信息,以便于这个Agent可以回答诸如2024年广州的gdp是多少人民币?换算成美元是多少美元?这种问题。

实现过程

先理解 ReAct 的本质:

while True:
    response = llm(messages)        # 问 LLM
    if response 想调工具:
        result = 执行工具(response)   # Act
        messages.append(result)      # Observe
        continue                     # 回到 Think
    else:
        print(response)              # 最终回答
        break

大道至简,所有Agent的底层本质都是这个while循环

那么思路就很清晰了

LLM初始化

按照Agent的定义,首先我们实现LLM这个环节

#注意OpenAI,AI都是大写
from openai import OpenAI

client = OpenAI(
    base_url = "",
    api_key = ""
)

工具初始化

之后按照顺序,我们先完成两个工具的书写

def calculator(expression:str)->str:
    try:
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"计算错误:{e}"


def web_search(query: str) -> str:
    import re
    try:
        resp = httpx.get(
            "https://html.duckduckgo.com/html/",
            params={"q": query},
            headers={"User-Agent": "Mozilla/5.0"},
            timeout=10.0,
        )
        # 提取标题和摘要
        titles = re.findall(r'class="result__a".*?>(.*?)</a>', resp.text)
        snippets = re.findall(r'class="result__snippet".*?>(.*?)</a>', resp.text, re.DOTALL)
        
        results = []
        for i in range(min(3, len(titles))):
            title = re.sub(r'<.*?>', '', titles[i]).strip()
            snippet = re.sub(r'<.*?>', '', snippets[i]).strip() if i < len(snippets) else ""
            results.append(f"[{i+1}] {title}\n    {snippet}")
        
        return "\n\n".join(results) if results else "未找到相关结果"
    except Exception as e:
        return f"搜索错误: {e}"

在计算器方法中,直接使用python的eval去执行代码,这是非常危险的,容易被攻击,所以只适用于本地演示,反正我们只是做个小玩具嘛

在web_search这个方法中,我们使用duckduckgo搜索引擎,因为它无需配置api key,非常适合我们这个小玩具项目,这样我们就可以把所有精力放到实现agent中了

在完成工具的逻辑代码后,我们需要定义工具的schema,这一步是为了告诉LLM有哪些工具可用

tools = [
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "计算数学表达式,如 '2+3*4' 或 'sqrt(144)'",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {"type": "string", "description": "数学表达式"}
                },
                "required": ["expression"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "web_search",
            "description": "使用 Bing 搜索需要的内容",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "检索内容"}
                },
                "required": ["query"]
            }
        }
    }
]

实现Agent loop

消息初始化

这是Agent实现最关键的一步,首先定义方法,添加两条初始化的消息

def agent(user_input: str):
    messages = [
        {"role": "system", "content": "你是一个有工具可用的助手。需要搜索或计算时调用工具。"},
        {"role": "user", "content": user_input}
    ]

按照OpenAi的规范,role的合法值共有四种

[table]

system message必须放到第一条,虽然没有强制规定,但是如果不放在第一条效果会变差且只能有一条

message列表里的assistant与user必须交替出现,不然会行为异常

user的content可以是数组或者字符串,数组是用来多模态

函数分发表

    tool_fuctions = {
        "calculator":calculator,
        "web_search":web_search,
    }

把工具名映射到相应的py函数,方便之后调用

func = tool_fuctions[name]  # name 是 LLM 返回的字符串,如 "calculator"
result = func(**args)        # 通过字典拿到函数,然后调用

如果没有这个分发表,就只能使用if elif,丑得很,而且如果后期加工具,也只需在分发表里加一行,逻辑不需要动

if name == "calculator":
    result = calculator(**args)
elif name == "web_search":
    result = web_search(**args)

while循环

做了如此多的工作,我们终于要来到核心部分,loop循环,先放代码,再一一解释

for i in range(10):
        #1.调用llm
        response = client.chat.completions.create(
            model = "gpt-5.5",
            messages = messages,
            tools = tools
        )
        choice = response.choices[0]
        if(choice.finish_reason=="tool_calls"):
            print(choice.message.tool_calls)
            messages.append(choice.message)
            for tool_call in choice.message.tool_calls:
                name = tool_call.function.name
                id = tool_call.id
                args = json.loads(tool_call.function.arguments)
                func = tool_fuctions[name]
                result = func(**args)
                messages.append(
                    {
                        "role":"tool",
                        "tool_call_id":id,
                        "content":str(result)
                    }
                )
                print(f"问题:{choice.message},工具: {name}, 结果: {result}")
            continue
        elif(choice.finish_reason=="stop"):
            print(f"结束:{choice}")
            break
        else:
            print(f"异常:{choice.message}")
    pass

10次循环是因为我们目前只是个玩具项目,防止它陷入死循环

每一轮的流程是调用llm,判断是否使用工具,是的话使用工具,把结果放入message返回,这也是最简单memory机制,如果不用的话就结束整个while循环了

client

对于这个实例,有以下用法

client.chat.completions.create()   # 对话
client.embeddings.create()         # 向量嵌入
client.images.generate()           # 图片生成
client.audio.transcriptions.create() # 语音转文字
client.models.list()               # 列出可用模型

response

在response这个返回对象中,有以下字段

response.id              # 本次请求的唯一ID,如 "chatcmpl-xxx"
response.model           # 实际使用的模型名
response.created         # 时间戳
response.object          # 固定值 "chat.completion"
response.choices         # 回复列表(重点)
response.usage           # token 用量统计

response.usage — token 消耗

response.usage.prompt_tokens      # 输入消耗的 token 数
response.usage.completion_tokens  # 输出消耗的 token 数
response.usage.total_tokens       # 合计

response.choices — 核心内容

通常只有一个元素,代码里取的是 choices[0]

choice = response.choices[0]

choice.index           # 序号,一般是 0
choice.finish_reason   # 结束原因(见下表)
choice.message         # 模型返回的消息对象

finish_reason 的取值

[table]

choice.message — 消息对象

choice.message.role        # 固定是 "assistant"
choice.message.content     # 文字回复内容,tool_calls 时一般是 None
choice.message.tool_calls  # 工具调用列表,stop 时是 None

tool_calls 里每个元素的结构

tool_call.id                    # 工具调用ID,回传给 tool role 时要用
tool_call.type                  # 固定 "function"
tool_call.function.name         # 函数名,如 "calculator"
tool_call.function.arguments    # JSON 字符串,需要 json.loads() 解析

完结撒花🎉

如此一来,我们就完成了一个最基础的Agent,感谢你的阅读~