手写一个最小 Agent:50 行代码搞懂工具调用循环

📑 文章目录

看过 Anthropic 的《Building Effective Agents》之后,我们知道 Agent 的本质就是 LLM 在循环里根据环境反馈使用工具

但说归说,不看代码总觉得心里没底。今天我们就从零手写一个最小 Agent,50 行 Python 代码搞懂 Agent 的核心循环。


一、Agent 到底在循环什么?

在上篇文章里我们画了 OTA 循环:Observe → Think → Act。翻译成代码就是:

  1. Observe:拿到用户输入 + 历史对话
  2. Think:调用 LLM,让它决定下一步做什么
  3. Act:如果 LLM 要调用工具,执行工具并把结果喂回去
  4. 重复,直到 LLM 给出最终答案,或者达到最大步数

就这么简单。所有 Agent 框架(LangChain、CrewAI、AutoGen⋯⋯)的核心都是这个循环,只是外面套了各种抽象层。


二、准备工作

2.1 环境

  • Python 3.10+
  • 只需要一个依赖:openai
pip install openai

2.2 API Key

你需要一个 OpenAI API Key(或者兼容 OpenAI 接口的其他服务,比如 DeepSeek、Moonshot 等)。

export OPENAI_API_KEY="sk-xxx"
# 如果用国内服务,还需要设置 base url
export OPENAI_BASE_URL="https://api.deepseek.com/v1"

三、定义工具

Agent 的能力来自工具。我们定义三个简单工具:

  • search:模拟搜索(实际项目中替换成真搜索 API)
  • calculator:计算数学表达式
  • read_file:读取本地文件
import json
import math

# ---- 工具实现 ----
def search(query: str) -> str:
    """模拟搜索,实际项目替换成真 API"""
    results = {
        "Python": "Python 是一种广泛使用的高级编程语言,由 Guido van Rossum 于 1991 年创建",
        "Agent": "AI Agent 是能自主感知环境、做出决策并执行动作的智能体",
        "天气": "今天北京晴,25°C;上海多云,28°C",
    }
    for key, val in results.items():
        if key in query:
            return val
    return f"未找到关于「{query}」的结果"

def calculator(expression: str) -> str:
    """安全计算数学表达式"""
    try:
        allowed = set("0123456789+-*/.() ")
        if not all(c in allowed for c in expression):
            return "错误:只支持数字和 +-*/ 运算符"
        return str(eval(expression))
    except Exception as e:
        return f"计算错误:{e}"

def read_file(filepath: str) -> str:
    """读取本地文件内容"""
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            return f.read()[:2000]  # 最多读 2000 字符
    except FileNotFoundError:
        return f"文件不存在:{filepath}"
    except Exception as e:
        return f"读取错误:{e}"

# ---- 工具注册表 ----
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search",
            "description": "搜索信息,获取知识。当需要查找你不确定的事实、数据或信息时使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "搜索关键词"}
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "计算数学表达式。当需要精确计算时使用,如加减乘除。",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {"type": "string", "description": "数学表达式,如 2+3*4"}
                },
                "required": ["expression"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取本地文件内容。当需要查看文件内容时使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "filepath": {"type": "string", "description": "文件路径"}
                },
                "required": ["filepath"]
            }
        }
    }
]

TOOL_MAP = {
    "search": search,
    "calculator": calculator,
    "read_file": read_file,
}

工具定义分两部分:工具实现(Python 函数)和工具声明(JSON Schema,告诉 LLM 这个工具干什么、需要什么参数)。

这就是 Anthropic 说的 ACI(Agent-Computer Interface)——工具描述写得好不好,直接决定 Agent 能不能正确使用工具


四、Agent 核心:50 行循环

核心代码就一个函数,包含完整循环、tool call 解析、最大步数和错误处理:

from openai import OpenAI

client = OpenAI()  # 自动读取 OPENAI_API_KEY 和 OPENAI_BASE_URL

def agent(user_input: str, max_steps: int = 10) -> str:
    """最小 Agent:工具调用循环"""
    messages = [{"role": "user", "content": user_input}]

    for step in range(max_steps):
        print(f"--- Step {step + 1} ---")

        try:
            # Think:调用 LLM
            response = client.chat.completions.create(
                model="gpt-4o-mini",  # 可替换成其他模型
                messages=messages,
                tools=TOOLS,
                tool_choice="auto",
            )
        except Exception as e:
            return f"LLM 调用失败:{e}"

        choice = response.choices[0]
        message = choice.message

        # 把助手回复加入历史
        messages.append(message)

        # 如果没有工具调用,说明 LLM 给出了最终答案
        if not message.tool_calls:
            return message.content

        # Act:执行所有工具调用
        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)

            print(f"  调用工具:{func_name}({func_args})")

            try:
                result = TOOL_MAP[func_name](**func_args)
            except Exception as e:
                result = f"工具执行错误:{e}"

            print(f"  工具结果:{result[:100]}")

            # 把工具结果喂回 LLM
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result),
            })

    return "⚠️ 达到最大步数限制,Agent 终止"

代码解读

步骤代码说明
Thinkclient.chat.completions.create(...)把对话历史 + 工具定义发给 LLM
判断if not message.tool_callsLLM 没调工具 → 给出最终答案,循环结束
ActTOOL_MAP[func_name](**func_args)解析 tool call,执行对应工具
Observemessages.append({"role": "tool", ...})把工具结果喂回 LLM,进入下一轮
安全max_steps + try/except防止无限循环和崩溃

整个循环的流程就是:

用户输入 → LLM 思考 → 要调工具?→ 执行工具 → 结果喂回 → LLM 再思考 → ... → 最终答案

五、跑一下

# 简单计算
print(agent("123 * 456 等于多少?"))

# 搜索 + 计算
print(agent("Python 是什么时候创建的?距今多少年了?"))

# 读取文件
print(agent("帮我看看 hugo.toml 的前几行内容"))

运行效果

--- Step 1 ---
  调用工具:calculator({"expression": "123 * 456"})
  工具结果:56088
--- Step 2 ---
123 * 456 等于 56088。

--- Step 1 ---
  调用工具:search({"query": "Python"})
  工具结果:Python 是一种广泛使用的高级编程语言,由 Guido van Rossum 于 1991 年创建
--- Step 2 ---
  调用工具:calculator({"expression": "2026 - 1991"})
  工具结果:35
--- Step 3 ---
Python 由 Guido van Rossum 于 1991 年创建,距今已经 35 年了。

看到了吗?第二个问题里,Agent 自动先搜索再计算——它自己决定了需要两步:先查 Python 创建时间,再算差值。这就是 Agent 的「自主决策」。


六、三大平台的 Function Calling 对比

我们用的是 OpenAI 的 API 格式,其他平台也类似:

平台API 参数名Tool Call 格式结果回传方式
OpenAItools + tool_choicemessage.tool_calls{"role": "tool", "tool_call_id": "..."}
Claudetools + tool_choicecontent block type: "tool_use"{"role": "user", "content": [{"type": "tool_result", ...}]}
Geminitools + tool_configfunction_call partfunction_response part

核心流程都一样:定义工具 → LLM 决定调哪个 → 执行 → 结果喂回。区别只在 API 格式不同。


七、这个最小 Agent 缺什么?

这是能跑的最小实现,但离生产级还差得远:

缺失能力说明解决方案
并行工具调用OpenAI 支持一次调多个工具遍历 message.tool_calls 已经处理了
流式输出用户等的时候能看到过程stream=True + SSE
记忆跨对话记住上下文加 memory 模块
多轮对话当前只支持单次问答while True 循环接用户输入
安全沙箱eval() 和文件读取有风险沙箱执行、权限控制
重试机制网络超时、限流指数退避重试

理解核心循环不需要这些。先搞懂 50 行的版本,再加功能才是正道。


总结

Agent 的核心就是三件事:

  1. 定义工具:告诉 LLM 有什么工具可用(JSON Schema)
  2. 解析 tool call:LLM 说要调哪个工具、传什么参数
  3. 循环:执行工具 → 结果喂回 → LLM 再思考 → 直到出答案

50 行代码,搞定。与其花时间学 LangChain 的抽象层,不如先把这几行代码跑通,理解了底层逻辑,上面套什么框架都不怕


参考链接

  1. OpenAI Function Calling
  2. Gemini API Function Calling
  3. Claude Tool Use
  4. Anthropic: Building Effective Agents

未经作者禁止转载!


本文链接: https://hyuzz-nuc.github.io/posts/build-minimal-agent-from-scratch/

未经作者禁止转载!