从零搭建一个最小的 Agent Loop

这是什么

一个用不到 100 行 Python 写的 demo,演示大模型 agent 的核心机制:让模型自己决定调用哪个工具、解析它的调用请求、执行后把结果喂回去,循环直到它给出最终答案

不涉及框架、不涉及 RAG、不涉及联网,只打通"模型 ↔ 工具"这根最基础的循环。

环境准备

先装 OpenAI 官方 SDK(DeepSeek 兼容这个 SDK,不用装额外的包):

bash 复制代码
pip install openai

然后用 DeepSeek 的 API。选它的理由:

  • 支持 OpenAI 兼容的 function calling 协议,代码和用 OpenAI 一模一样。
  • 便宜,适合练手时随便跑。
  • 国内可直连,不用折腾代理。

初始化 client:

python 复制代码
client = OpenAI(
    api_key="你的 key",                      # DeepSeek 后台申请
    base_url="https://api.deepseek.com",      # 换成 DeepSeek 端点
)
# model 用 "deepseek-v4-flash"

场景

"北京和上海、天津今天多少度,两地温差多少?顺便考虑下北京要不要带伞?"

一句话里藏了几个子任务:查北京天气、查上海天气、查天津天气(假数据里没有,会触发错误回传)、算温差。模型需要自己拆解,分多次调用工具,再综合回答------刚好够把 agent loop 的关键点全踩一遍。

前置认知:Agent 到底在循环什么

普通对话是单轮:用户问 → 模型答。

带工具的 agent 是多轮,但循环的不是用户,而是模型和工具之间的来回:

scss 复制代码
用户提问
   ↓
模型思考 → "我需要调 get_weather('北京')"
   ↓
本地执行 get_weather('北京') → 返回数据
   ↓
数据喂回模型 → "还要查上海"
   ↓
本地执行 get_weather('上海') → 返回数据
   ↓
数据喂回模型 → "还要算温差,调 calculator('25-20')"
   ↓
本地执行 calculator → 返回 5
   ↓
模型综合所有结果 → 给出最终文本回复

关键点:模型不能直接执行代码,它只能输出"我想调哪个工具、传什么参数"的结构化请求。真正执行的是你的本地代码。你的职责是解析这个请求、执行、把结果塞回上下文。

工具定义:模型怎么知道有哪些工具

用 OpenAI 的 function calling 格式,每个工具是一个 JSON Schema:

python 复制代码
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的天气,返回温度和是否下雨。",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称,如北京、上海",
                    },
                },
                "required": ["city"],
            },
        },
    },
    # calculator 同理
]

核心循环

python 复制代码
MAX_STEPS = 10

def run_agent(user_input):
    messages = [{"role": "user", "content": user_input}]

    for step in range(1, MAX_STEPS + 1):
        response = client.chat.completions.create(
            model="deepseek-v4-flash",
            messages=messages,
            tools=tools,
            timeout=30,
        )
        message = response.choices[0].message
        messages.append(message)

        # 没有工具调用 → 模型给出最终回复,退出循环
        if not message.tool_calls:
            print("最终回复:", message.content)
            return

        # 有工具调用 → 逐个执行,把结果回传
        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)
            result = available_functions[func_name](**func_args)

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })

上下文是怎么累积的

每轮 messages 列表按顺序追加,长这样:

  1. user --- 用户提问
  2. assistant --- 模型决定调工具(这条消息带 tool_calls
  3. tool --- 你执行函数后的结果
  4. assistant --- 模型继续调工具或给出答案
  5. ... 直到某条 assistant 没有 tool_calls

每次请求都把完整历史发回去,模型靠这个知道"我之前查过什么、还要查什么"。

回传工具结果的三要素

python 复制代码
{
    "role": "tool",
    "tool_call_id": tool_call.id,   # 必须配对
    "content": result,              # 字符串
}
  • role 固定是 "tool"
  • tool_call_id 必须等于模型那条 tool_call.id------模型靠它把"我刚才要的结果"和"你给的结果"对应起来。一轮里模型可能并发调多个工具,每个都要单独回传一条 tool 消息。
  • content 是字符串。如果函数返回 dict,要 json.dumps 一下。

三道护栏

裸循环容易跑飞,加三层保护:

python 复制代码
MAX_STEPS = 10          # 1. 最大步数:模型陷入反复调工具时强制停
timeout=30,             # 2. 单次请求超时:API 卡住时别死等
try/except              # 3. 错误处理:处理异常和工具调用失败

错误处理要分两种情况:

  • 模型请求失败(网络、限流)→ 直接 return,没什么可补救的。
  • 工具执行失败 (比如查不到天津)→ 别中断,把错误也回传给模型
python 复制代码
except Exception as e:
    result = f"工具执行失败:{e}"
# 照常 append 成 role=tool 消息

这样模型会知道"天津查不到",自己决定是换个问法还是直接告知用户。这比直接崩掉友好得多。

实际跑起来什么样

输入:"北京和上海、天津今天多少度,两地温差多少?顺便北京要不要带伞?"

lua 复制代码
--- 第 1 步 ---
调用工具:get_weather({'city': '北京'})
调用工具:get_weather({'city': '上海'})
调用工具:get_weather({'city': '天津'})   ← 假数据里没有,触发异常
--- 第 2 步 ---
调用工具:calculator({'expression': '25 - 20'})
--- 第 3 步 ---
最终回复:北京 20 度不用带伞,上海 25 度,温差 5 度;天津暂无数据。

一次对话里串起了并发工具调用、结构化参数、错误回传、最终综合------agent loop 的几个关键环节全过一遍。

完整代码

python 复制代码
from openai import OpenAI
import json

client = OpenAI(
    api_key="你的 key",
    base_url="https://api.deepseek.com",
)

WEATHER_DATA = {
    "北京": {"temp": 20, "rain": False},
    "上海": {"temp": 25, "rain": True},
}

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的天气,返回温度和是否下雨。",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称,如北京、上海",
                    },
                },
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "计算数学表达式,例如 '25 - 20'。",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "要计算的数学表达式",
                    },
                },
                "required": ["expression"],
            },
        },
    },
]


def get_weather(city):
    if city not in WEATHER_DATA:
        raise ValueError(f"暂不支持城市:{city}")
    return json.dumps(WEATHER_DATA[city], ensure_ascii=False)


def calculator(expression):
    return str(eval(expression))


available_functions = {
    "get_weather": get_weather,
    "calculator": calculator,
}

MAX_STEPS = 10


def run_agent(user_input):
    messages = [{"role": "user", "content": user_input}]

    for step in range(1, MAX_STEPS + 1):
        print(f"\n--- 第 {step} 步 ---")
        try:
            response = client.chat.completions.create(
                model="deepseek-v4-flash",
                messages=messages,
                tools=tools,
                timeout=30,
            )
        except Exception as e:
            print(f"模型请求失败:{e}")
            return

        message = response.choices[0].message
        messages.append(message)

        if not message.tool_calls:
            print("最终回复:", message.content)
            return

        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            try:
                func_args = json.loads(tool_call.function.arguments)
                print(f"调用工具:{func_name}({func_args})")
                result = available_functions[func_name](**func_args)
            except Exception as e:
                result = f"工具执行失败:{e}"

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })

    print(f"已达到最大步数 {MAX_STEPS},强制停止。")


run_agent("北京和上海、天津今天多少度,两地温差多少?顺便北京要不要带伞?")
相关推荐
Black蜡笔小新1 小时前
企业AI算力工作站/AI大模型训练工作站DLTM企业私有化AI视觉中台建设方案设计
人工智能
花生智源1 小时前
Java实现Prompt工程的技巧——从模板到工程化,告别字符串拼接
java·人工智能
墨虹雨茶1 小时前
Loop Engineering 正在重新定义"程序员"这个职业
人工智能
夕小瑶1 小时前
字节把 PS 做进了生图模型里,实测 Seedream 5.0 Pro 指哪改哪
人工智能
量化吞吐机1 小时前
2026年AI量化工具评估,先分开学习开发和验证
人工智能·python
IvorySQL1 小时前
PG 日报|新增 VFD 缓存监控视图,精细化数据库调优
大数据·数据库·人工智能·缓存·postgresql·区块链
小白狮ww1 小时前
告别「机械念经」:TADA-1b 让模型「说人话」
人工智能·语音识别·语音生成·ai声音
咏方舟【长江支流】1 小时前
【开源】跨语言·跨平台·跨数据库(3) —— 为用宝框架增加ORM,让AI执行
数据库·人工智能·开源·ai编程·orm·咏方舟-长江支流·用宝框架
AliCloudROS1 小时前
一次真实录屏:我只说每月别超过 200 块,小程序后端就搭好了
运维·人工智能·云计算