一、为什么模型需要工具

LLM 的能力边界,在需要实时或外部信息的问题上体现得很明显:

  • 问今天天气,它答不上来:训练数据有截止时间,模型也不知道"今天"是几号;
  • 123 * 456 + 789 这类多步算术,中间步骤容易出错,数字越大越明显;
  • 实时信息查询、接口调用这类操作,模型根本不具备执行能力。

提示词工程能缓解一部分问题,但解决不了根本:聊天模型只能生成文字,不能"做事",这是架构决定的。

Agent 的思路是绕过这个限制:给模型配几件工具,让它回答之前先查一查、算一算,拿到真实结果之后再组织答案。天气问题调天气接口,算术问题调计算器,是否使用工具由模型自己判断。

之所以自己实现而不是直接用框架,原因在于框架把最关键的机制封装起来了:模型是怎么知道该调用工具的?工具返回的结果又是如何回到模型上下文里的?这两个问题不弄清楚,用框架始终隔一层。自己写一遍,代码量其实不大。

二、ReAct 的工作原理

ReAct 来自 2022 年的论文 ReAct: Synergizing Reasoning and Acting in Language Models(Yao et al.)。核心思路是让模型把推理过程显式写出来,并在推理中间插入工具调用,形成如下循环:

Thought: 我缺什么信息,下一步怎么办
Action: 调哪个工具、传什么参数
Observation: 工具返回了什么
(重复……直到能回答为止)
Final Answer: 最终答案

与更早的 Chain-of-Thought(思维链)相比:CoT 引导模型"多想几步再回答",但推理中的猜想没有机会被外部验证;ReAct 让"想"与"做"交替进行,模型说"北京现在 25 度"不作数,天气接口返回 18 度才算数,Observation 会把它拉回现实。

这套循环能工作,依赖一个朴素的机制:模型在训练语料中见过大量"先思考后行动"的文本格式,提示词再给出充分示例后,模型会按 Thought / Action / PAUSE / Observation 的固定格式输出。于是模型的"意图"变成可解析的文本,程序只需要做机械的工作:定位 Action: 行、取出工具名和参数、调用真实的 Python 函数、把结果拼成 Observation: 塞回对话。下一轮模型就能看到上一步的真实结果,决定下一步做什么。

关于 PAUSE:它是一个语义信号,告诉模型"Action 已经发出,程序正在执行工具,等待 Observation 返回即可",避免模型在 Action 之后自行编造结果。

三、项目结构

react-agent-from-scratch/
├── agent.py                 # 核心:ReAct 循环、工具注册、记忆管理
├── web_app.py               # Streamlit 网页版界面
├── prompts/
│   ├── system_prompt.txt    # 协议提示词(含工具清单占位符和示例)
│   └── summary_prompt.txt   # 记忆压缩用的提示词
├── tools/
│   ├── base_tool.py         # 工具基类
│   ├── calculator.py        # 计算器
│   ├── weather.py           # 天气(OpenWeatherMap)
│   ├── web_search.py        # 网页搜索(Tavily)
│   └── wikipedia.py         # 百科(wikipedia-api)
└── utils/
    └── message.py           # 消息类,就是个 dict 子类

仓库地址:https://github.com/zwzhangyu/ai-agent-lab

当前项目目录:awesome-agentic-ai-zh/react-agent-from-scratch

提示词独立放在 prompts/ 目录而不是硬编码在代码里,修改协议不用动逻辑。工具是可插拔的:新增能力只需在 tools/ 下添加一个继承 BaseTool 的类,Agent 代码无需修改。

四、核心循环:几十行足以跑通

在分析仓库代码之前,先看一个去掉工程细节的最小版本,把循环机制看清楚。

import re
from openai import OpenAI

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

# 玩具计算器,仅演示协议用
TOOLS = {
    "calculator": {"fn": lambda q: str(eval(q)), "desc": "执行数学计算"},
}

SYSTEM_PROMPT = """你是一个会使用工具的助手,按下面的循环工作:
Thought: 思考接下来做什么
Action: calculator: 数学表达式
PAUSE
Observation: 工具返回的结果
...循环...
当你得到最终答案时,输出:
Final Answer: 你的答案"""


def run(query: str) -> str:
    messages = [{"role": "user", "content": query}]
    for _ in range(10):                          # 迭代上限,防止死循环
        resp = client.chat.completions.create(
            model="qwen-max",
            messages=[{"role": "system", "content": SYSTEM_PROMPT}] + messages,
        ).choices[0].message.content

        print("=== LLM 输出 ===\n", resp)

        if "Final Answer:" in resp:              # ① 模型给出最终答案
            return resp.split("Final Answer:")[-1].strip()

        m = re.search(r"Action:\s*(\w+):\s*(.+)", resp)
        if not m:                                # ② 输出不符合协议格式
            return "无法解析模型的行动指令"

        name, arg = m.group(1), m.group(2).strip()
        observation = TOOLS[name]["fn"](arg)     # ③ 执行真实函数
        messages.append({                        # ④ 结果回填给模型
            "role": "user",
            "content": f"Observation: {observation}",
        })
    return "超过最大迭代次数,未能得到答案"


print(run("23 * 17 等于多少?"))

这段代码虽然短,Agent 的机制已经完整:步骤①判断是否得到最终答案;步骤②从输出文本中提取工具名和参数;步骤③执行真实函数;步骤④把结果回填到消息历史,让循环继续。

实际决定行为的是提示词和示例,而不是循环代码本身。模型以什么格式输出、何时调用工具,都由 prompt 决定,程序只负责忠实执行。

五、完整实现

5.1 工具接口

为了让工具可插拔、可被模型理解,先约定一个抽象基类。每个工具定义三样东西:name(模型调用时的标识)、description(给模型的说明)、run(query)(字符串输入、结果输出):

from abc import ABC, abstractmethod

class BaseTool(ABC):
    """所有工具的抽象基类。"""

    def __init__(self):
        self.name = ""          # 唯一名称,模型通过它调用
        self.description = ""   # 描述,注入 prompt 帮助模型决策

    @abstractmethod
    def run(self, query: str) -> str:
        """输入字符串,返回执行结果。"""
        raise NotImplementedError

5.2 四个工具

工具

功能

数据来源

输入格式

返回

calculator

数学计算

本地执行

JSON:{"operation": "...", "params": {"a": .., "b": ..}}

结果

weather

实时天气

OpenWeatherMap

城市名,如 Beijing

自然语言描述

web_search

实时搜索

Tavily

搜索词

标题/摘要/URL 列表

wikipedia

百科查询

wikipedia-api

词条名

标题 + 摘要

四个工具分别演示了 Agent 工具的不同形态:本地计算、外部 HTTP API、搜索服务、百科知识库。逐个看实现。

先看 CalculatorTool。计算器需要明确的二元运算和参数,自由文本表达不清楚,所以提示词强制模型输出 JSON,代码端负责解析与校验:

def run(self, query: str) -> str:
    data = json.loads(query)                       # 解析失败 → 返回错误字符串
    operation = data["operation"]
    params = data["params"]                        # 校验 {"a": .., "b": ..}

    if hasattr(self, operation):                   # 白名单方法分发
        method = getattr(self, operation)          # add / subtract / multiply ...
        return str(method(**params))
    return f"Error: Unknown operation '{operation}'"

参数解析、校验、执行和异常处理都收敛在工具内部,任何失败都以字符串返回,而不是抛异常中断整个循环。

再看 WeatherTool,它是外部 HTTP API 的典型:请求 OpenWeatherMap(units=metric 返回摄氏温度),校验响应状态,把字段整理成自然语言返回:

def run(self, query):
    if not query or not query.strip():
        return "Error: City name cannot be empty."

    api_key = os.getenv("OPENWEATHER_API_KEY", "")
    if not api_key:
        return "Error: OPENWEATHER_API_KEY not configured."

    url = f"{self.base_url}?q={query}&appid={api_key}&units=metric"
    try:
        response = requests.get(url, timeout=5)
        if response.status_code != 200:
            return f"Error: Unable to fetch weather data. Server responded with {response.status_code}"

        data = response.json()
        if "main" not in data or "weather" not in data:
            return f"Could not find weather data for '{query}'. Please check the city name."

        temperature = data["main"]["temp"]
        description = data["weather"][0]["description"]
        humidity = data["main"]["humidity"]
        wind_speed = data["wind"]["speed"]

        return (f"The temperature in {query} is {temperature}°C. "
                f"The weather is {description}. "
                f"The humidity is {humidity}%. "
                f"The wind speed is {wind_speed} m/s.")
    except requests.exceptions.RequestException as req_err:
        return f"Request failed: {str(req_err)}"

这段代码体现了两点。一是返回刻意整理成完整句子:Observation 的"读者"是模型而不是程序,把结构化 JSON 翻译成自然语言,模型下一轮推理会更顺畅,所以工具返回值应尽量易读。二是错误处理:key 缺失、请求失败、城市名找不到,全部以字符串返回而不是抛异常,模型看到 Error: ... 后会自行修正重试(比如把拼音城市名换成英文)。

WebSearchTool 是反向的例子,返回保持结构化。Tavily 客户端在首次调用时惰性初始化,这样即使 API key 未配置,工具也能正常注册、不会在启动阶段报错;搜索结果整理成字典列表:

def run(self, query: str) -> str:
    client = self._get_client()                # 惰性初始化,key 未配置时返回 None
    if not client:
        return [{"error": "TAVILY_API_KEY not configured."}]

    search_results = client.search(query=query, max_results=2)
    formatted_results = []
    for result in search_results["results"]:
        formatted_results.append({
            "title": result.get("title"),
            "content": result.get("content"),
            "url": result.get("url"),
            "score": result.get("score"),
        })
    return formatted_results if formatted_results else [{"error": "No results found."}]

WikipediaTool 更简单:用 wikipedia-api 按词条名取页(默认英文版),页面存在则返回标题与摘要,否则返回错误字典。

四个工具的返回值类型并不统一(字符串、字典、列表都有)。Agent 不要求统一类型:拼接 Observation 时会统一做 str() 转换,内容可打印即可。两种形态各有用途,天气这种"结论型"结果适合自然语言,搜索结果这种"素材型"结果适合结构化数据,模型需要从中筛选引用。

5.3 工具的自动发现

Agent 通过扫描目录获知可用工具。register_tools()pkgutil 遍历 tools/ 包下的模块并动态导入,把继承 BaseTool 的类实例化后登记到字典:

def register_tools(self):
    tool_modules = [name for _, name, _ in pkgutil.iter_modules(["tools"])]

    for module_name in tool_modules:
        module = importlib.import_module(f"tools.{module_name}")
        for attr_name in dir(module):
            attr = getattr(module, attr_name)
            if isinstance(attr, type) and issubclass(attr, BaseTool) and attr is not BaseTool:
                tool = attr()
                self.tools[tool.name.lower()] = tool

随后 get_tools() 把每个工具的 name 和 description 拼成清单,通过模板注入 system prompt:

- calculator: Performs mathematical calculations. Supports: add, subtract, multiply, divide, power, modulus.
- weather: Fetches current weather conditions for a specified city.
- web_search: Searches the web for up-to-date and real-time information.
- wikipedia: Searches Wikipedia for general knowledge and factual information.

模型对"有哪些工具、参数怎么传"的认知全部来自这份清单。新增工具只需添加文件,Agent 本体无需修改,比在代码里维护注册列表省事;代价是行为相对隐式,目录结构本身成了注册表。

5.4 提示词模板:协议的定义

prompts/system_prompt.txt 是整个系统的核心,节选如下:

You run in a loop of Thought, Action, PAUSE, and Observation until you obtain a final answer.

Use the following format:
Thought: you should always think about what to do next
Action: the action to take, must be one of: {tools}
PAUSE
... wait for observation ...
Observation: the result of the action

When you have a final answer, output:
Final Answer: your answer here

Important rules:
1. For greetings or farewells, respond directly without using any tools.
2. If you already know the answer, respond directly with a Final Answer.
3. If you need external information, use the appropriate tool.
...
Action format for calculator (MUST use JSON):
Action: calculator: {{"operation": "add", "params": {{"a": 5, "b": 3}}}}

这里有两个容易踩坑的地方。

一是规则 1、2。寒暄和常识性问题不需要调用工具,模板明确要求"知道答案就直接答",避免模型无谓地发起工具调用,既慢又费 token。

二是花括号转义。模板中的 {tools}{date} 是留给代码用 str.format() 填充的占位符,因此模板里字面的 JSON 花括号必须写成 {{ }} 双重转义,格式化后才会还原成 { }。如果想避开这个坑,也可以换用 f-string 或普通字符串替换来完成注入。

模板后半段给出了完整的 few-shot 示例((7 + 2) * 4 先加后乘,两次 Action、两次 Observation,最后给出 Final Answer)。示例质量直接决定模型对协议的遵循程度,这是文本协议方案中最值得调整的部分。

5.5 主循环:think / determine_action / execute_action

循环被拆成三个方法(代码为节选,determine_action 中 calculator 的 JSON 特判分支略去,完整实现见 agent.py):

def think(self):
    """核心推理步骤:调 LLM、记录输出、解析并执行行动。"""
    self.current_iteration += 1
    if self.current_iteration > self.max_iterations:      # 迭代上限兜底
        self.add_message("assistant",
                         "I'm sorry, but I couldn't find a satisfactory answer...")
        return

    prompt = self.system_prompt.format(tools=self.get_tools(),
                                       date=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
    if self.old_chats_summary:                             # 注入历史摘要
        prompt += f"\n\nOld messages summary:\n{self.old_chats_summary}"

    response = self.get_llm_response(prompt)
    self.add_message("assistant", response)                # 回答进入消息历史
    self.format_output(response)                           # 终端着色打印
    self.determine_action(response)

def determine_action(self, response):
    """解析 LLM 输出:发现 Final Answer 就终止,否则提取 Action 并执行。"""
    if "Final Answer:" in response:
        return                                            # 终局,不再递归

    action_start = response.find("Action:")
    if action_start == -1:
        print("[WARN] No action or final answer found.")
        return

    action_line = response[action_start:].split("\n")[0].strip()
    action_parts = action_line.replace("Action:", "").strip().split(":", 1)
    tool_name, query = action_parts[0].strip().lower(), action_parts[1].strip()
    self.execute_action(tool_name, query)

def execute_action(self, tool_name, query):
    tool = self.tools.get(tool_name)
    if tool:
        result = tool.run(query)                           # 调用真实工具
        observation = f"Observation: {tool_name} tool output: {result}"
        self.add_message("system", observation)            # 观察回填消息历史
        self.think()                                       # 递归进入下一轮
    else:
        error_msg = f"Error: Tool '{tool_name}' not found"
        self.add_message("system", error_msg)              # 错误也回填,让模型自纠
        self.think()

think() 负责一轮推理:迭代计数、组装 prompt、调用 LLM、把回答存入消息历史、解析输出。发现 Final Answer: 则停止;否则把 Action: 行解析成工具名和参数,交给 execute_action() 执行,将 Observation 存入消息历史后递归调用 think() 进入下一轮。

实现中有几个取舍需要说明。

关于递归。用 while 写循环更常规,仓库里两种写法都存在:CLI 版 agent.py 使用递归,每轮处理逻辑一致,递归是最直接的表达,配合 10 次迭代上限,栈深度可控;网页版 web_app.py 为了逐轮刷新界面,用 while 重写了同一套解析。循环的具体写法是次要的,协议、工具表和消息历史才是 ReAct 的核心。

关于 Observation 的角色。代码把观察结果作为 system 角色消息追加进历史,是一种省事的写法。严格来说 system 消息应只出现在对话开头,部分 API 会校验甚至拒绝中段的 system 消息,这里使用的 DashScope 兼容端点可以接受。规范做法是拼进 user 消息,或使用 API 原生的 tool 消息。学习项目可以接受,但需要了解这个取舍。

关于错误处理。工具名不存在、格式错误、工具内部异常,都以文本形式回填给模型。模型看到 Error: Tool 'xxx' not found 后,通常会在下一轮自行修正,换工具或换表述重试。把错误作为 Observation 反馈而不是直接中断,让 Agent 具备基本的自纠能力。

5.6 长对话与记忆管理

消息历史会随多轮对话和 Observation 的累积不断变长,token 费用和上下文上限都是实际问题。仓库采用最直接的摘要压缩策略:当 user 消息超过 3 条且总 token 超过 1000 时,把最早几轮对话交给模型,按 summary_prompt 的要求压缩成一段话,删除原文;摘要累积在 old_chats_summary 中,每轮推理时拼在 system prompt 之后重新注入:

def memory_management(self, chat_history):
    user_messages = [msg for msg in chat_history if msg["role"] == "user"]
    if (len(user_messages) > self.messages_to_summarize
            and self.num_tokens_from_messages(chat_history) > self.max_messages_tokens):
        start, end = self.get_indices(chat_history)      # 定位最早 N 轮用户消息区间
        chats = chat_history[start:end]
        new_summary = self.summarize_old_chats(chats)    # 让 LLM 生成一段摘要
        self.old_chats_summary = f"{self.old_chats_summary} {new_summary}".strip()
        del self.messages[start:end]                     # 删除原文,释放上下文

token 数量通过 tiktoken 估算。该方案的本质是用信息密度换取上下文长度,代价是摘要会丢失细节,例如用户之前明确提到的某个数字。更精细的做法是向量检索,按相关性召回历史片段而不是一刀切压缩,工程复杂度会高一个量级。

5.7 运行方式:CLI 与 Web 界面

同一个 Agent 提供两种运行方式。运行前先做环境准备:进入项目目录后创建并激活虚拟环境,执行 pip install -r requirements.txt 安装依赖;在仓库的 .env 中配置 DASHSCOPE_API_KEY(参考 .env.example,模型走 DashScope 的 OpenAI 兼容接口,默认 qwen-max,可用环境变量 LLM_MODEL 覆盖)。详细步骤见同目录 README.md

第一种是终端交互模式,直接运行 agent.py,启动后以对话方式输入问题,支持 quit / exit 退出、reset 清空会话历史:

python agent.py

第二种是 Streamlit Web 界面,浏览器访问 http://localhost:8501

streamlit run web_app.py

CLI 模式用 colorama 给 Thought、Action、PAUSE、Final Answer 分别着色,推理过程在终端直接可见。Web 版在侧边栏逐条展示推理过程(Thought / Action / PAUSE / Observation / Final Answer),主区是聊天界面。

实现上有一些重复:终端版通过 print 输出思考过程,网页版无法捕获 stdout,所以 web_app.py 用 while 重写了解析逻辑,每解析一步写入 st.session_state 触发界面刷新。同一份协议逻辑在仓库里存在两份,维护时需要注意同步。

5.8 运行示例

以 README 中的测试问题为例(calculator 的结果可以精确验算),下面是终端里的示意输出:

User: 计算 123 * 456 + 789

[ASSISTANT]:
Thought: 我需要先计算 123 * 456 的乘法结果。
[ACTION]: calculator: {"operation": "multiply", "params": {"a": 123, "b": 456}}
[PAUSE]:

[SYSTEM]: Observation: calculator tool output: 56088

[ASSISTANT]:
Thought: 乘法结果是 56088,接下来再与 789 相加。
[ACTION]: calculator: {"operation": "add", "params": {"a": 56088, "b": 789}}
[PAUSE]:

[SYSTEM]: Observation: calculator tool output: 56877

[FINAL ANSWER]: 123 * 456 + 789 = 56877

验算:123 × 456 = 56088,加 789 等于 56877,结果正确。多步计算问题被拆成两次独立的工具调用,每一步都基于真实计算结果,而不是模型的心算。

天气查询的流程(示意输出,具体数值以接口返回为准):

User: 北京今天天气怎么样?

[ASSISTANT]:
Thought: 天气是实时信息,我需要调用 weather 工具查询北京的情况。
[ACTION]: weather: Beijing
[PAUSE]:

[SYSTEM]: Observation: weather tool output: The temperature in Beijing is 18.0°C. The weather is clear sky. The humidity is 40%. The wind speed is 3.0 m/s.

[FINAL ANSWER]: 北京今天天气晴朗,气温 18 摄氏度,湿度 40%,风力不大。

注意模型把城市名从中文"北京"翻译成了 Beijing 再传给工具,这是提示词示例与工具描述共同作用的结果。

搜索类问题走同样的循环,区别在于 Observation 是结构化的结果列表,模型从中筛选后再总结作答(示意):

User: 搜索 Python 3.13 的新特性

[ASSISTANT]:
Thought: 这需要最新信息,我的知识可能过时,调用 web_search 搜索。
[ACTION]: web_search: Python 3.13 new features
[PAUSE]:

[SYSTEM]: Observation: web_search tool output: [{'title': "What's New In Python 3.13", 'content': '...', 'url': 'https://docs.python.org/3/whatsnew/3.13.html', 'score': ...}]

[FINAL ANSWER]: 根据搜索结果,Python 3.13 的主要新特性包括……

 仓库地址:https://github.com/zwzhangyu/ai-agent-lab

当前项目目录:awesome-agentic-ai-zh/react-agent-from-scratch

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐