「Agent」是 LLM 应用从「能聊天」到「能干活」的关键一跃。本文先用 Python + OpenAI 兼容接口实现一个最小可运行的 Agent,再详细拆解 AI 是如何知道 Agent 提供了哪些工具、如何决定调用、又如何把结果回流到对话


一、什么是 Agent?30 秒理解

Agent = LLM + 工具 + 循环

  • LLM —— 大脑,负责理解、推理、决策
  • 工具(Tools) —— 手脚,负责实际干活(查数据库、调 API、跑代码)
  • 循环(Loop) —— 持续「思考 → 行动 → 观察」直到任务完成

经典 ReAct 模式(Reasoning + Acting):

循环开始
  ├─ Thought:  LLM 想一步
  ├─ Action:  LLM 决定调哪个工具
  ├─ Observation: 工具执行结果回传
  └─ 判断: 任务完成?没完成就回循环开头
循环结束 → 最终回答

与「单次 function calling」的区别:

  • function calling —— 一轮就结束(LLM 调一次工具,拿到结果,直接答)
  • Agent —— 多轮循环(LLM 可以连续调多个工具,中间还能自我修正)

二、最小可运行 Agent 示例

2.1 环境准备

pip install openai

本文使用 OpenAI 兼容接口(支持 OpenAI / DeepSeek / 通义 / 智谱 / 本地 vLLM 等)。

2.2 完整代码(agent.py)

# -*- coding: utf-8 -*-
"""
最小 Agent 示例
- 2 个工具:加法、天气查询
- ReAct 循环:LLM 决策 → 执行工具 → 结果回流 → 直到 LLM 不再要工具
"""
import sys
import json
from openai import OpenAI

sys.stdout.reconfigure(encoding="utf-8")

# ============ 1. 初始化 LLM 客户端 ============
client = OpenAI(
    base_url="https://newapi-ai.xxx.com/v1",  # 替换成你的 OpenAI 兼容端点
    api_key="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
)
MODEL = "glm-5.2-local"


# ============ 2. 定义工具(实际可执行的 Python 函数)============
def add(a: float, b: float) -> str:
    """两个数字相加,返回它们的和。

    Args:
        a: 第一个数字
        b: 第二个数字
    """
    return f"{a} + {b} = {a + b}"


def get_weather(city: str) -> str:
    """查询某个城市的当前天气(模拟数据)。

    Args:
        city: 城市名称,如 "北京"
    """
    mock = {
        "北京": "晴,18℃",
        "上海": "多云,22℃",
        "深圳": "雷阵雨,28℃",
    }
    return mock.get(city, f"未收录 {city} 的天气,默认晴 20℃")


# 工具名 → 实际函数的映射表
TOOL_FUNCTIONS = {
    "add": add,
    "get_weather": get_weather,
}


# ============ 3. 工具的 JSON Schema(给 LLM 看的「菜单」)============
TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "add",
            "description": "两个数字相加,返回它们的和。",
            "parameters": {
                "type": "object",
                "properties": {
                    "a": {"type": "number", "description": "第一个数字"},
                    "b": {"type": "number", "description": "第二个数字"},
                },
                "required": ["a", "b"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询某个城市的当前天气(模拟数据)。",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名称,如 \"北京\""},
                },
                "required": ["city"],
            },
        },
    },
]


# ============ 4. Agent 主循环(ReAct)============
def run_agent(user_query: str, max_steps: int = 10) -> str:
    """运行 Agent 循环,直到 LLM 不再请求工具或达到最大步数"""
    messages = [
        {"role": "system", "content": "你是一个有用的助手,可以调用工具回答问题。"},
        {"role": "user", "content": user_query},
    ]

    for step in range(1, max_steps + 1):
        print(f"\n────── Agent Step {step} ──────")

        # 4.1 把对话历史 + 工具 schema 一起发给 LLM
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS_SCHEMA,          # 关键:注入工具清单
            tool_choice="auto",          # auto:让 LLM 自己决定调不调工具
        )
        msg = response.choices[0].message

        # 4.2 把 LLM 这轮的回答加回对话历史
        messages.append(msg)

        # 4.3 如果 LLM 没要求调工具 → 任务完成,直接返回
        if not msg.tool_calls:
            print(f"[最终回答] {msg.content}")
            return msg.content

        # 4.4 LLM 要求调工具 → 逐个执行
        for tool_call in msg.tool_calls:
            name = tool_call.function.name
            args = json.loads(tool_call.function.arguments)

            print(f"[调用工具] {name}({args})")
            # 真正执行 Python 函数
            result = TOOL_FUNCTIONS[name](**args)
            print(f"[工具返回] {result}")

            # 4.5 把执行结果作为 role=tool 的消息回传给 LLM
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,   # 关键:把结果和请求对应起来
                "content": result,
            })

    return "达到最大步数,任务未完成"


# ============ 5. 跑起来 ============
if __name__ == "__main__":
    # 单工具调用
    run_agent("北京天气怎么样?")

    # 多工具调用 + 推理(需要循环)
    run_agent("北京和上海哪个温度更高?高多少?")

2.3 跑一遍看输出

输入:北京和上海哪个温度更高?高多少?

────── Agent Step 1 ──────
[调用工具] get_weather({"city": "北京"})
[工具返回] 晴,18℃

────── Agent Step 2 ──────
[调用工具] get_weather({"city": "上海"})
[工具返回] 多云,22℃

────── Agent Step 3 ──────
[最终回答] 上海温度更高,比北京高 4℃(上海 22℃,北京 18℃)。

注意 Step 1 和 Step 2 —— LLM 没有一次性把两个工具都调完,而是分两轮:第一轮调北京,第二轮调上海。这是因为某些模型在 tool_choice="auto" 下默认每轮只调一个工具。多轮循环正是 Agent 区别于单次 function calling 的价值。


三、代码结构剖析

整个 Agent 由 5 部分组成:

┌──────────────────────────────────────────┐
│ 1. LLM Client                            │ ← OpenAI 兼容接口
├──────────────────────────────────────────┤
│ 2. Tools(Python 函数)                   │ ← 真正干活的代码
├──────────────────────────────────────────┤
│ 3. Tools Schema(JSON Schema)            │ ← 给 LLM 看的「菜单」
├──────────────────────────────────────────┤
│ 4. Agent Loop(ReAct)                     │ ← 思考-行动-观察循环
├──────────────────────────────────────────┤
│ 5. Entrypoint                           │ ← 跑起来
└──────────────────────────────────────────┘

核心是第 3 和第 4 部分:

  • 第 3 部分回答「AI 怎么知道有哪些工具」
  • 第 4 部分回答「AI 怎么调、结果怎么回流」

四、AI 是如何知道 Agent 提供了哪些方法的?

这是本文的核心问题。拆成 3 个阶段:

阶段 1: 工具注入(把工具菜单交给 LLM)
   → 阶段 2: LLM 决策(读菜单,决定要不要用、用哪个)
      → 阶段 3: 结果回流(执行结果以 role=tool 回传)

4.1 阶段一:工具注入 —— tools 字段

Agent 调 LLM 时,把 TOOLS_SCHEMA 放进 tools 参数:

response = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    tools=TOOLS_SCHEMA,      # ← 工具清单在这里
    tool_choice="auto",
)

LLM 实际收到的请求长这样(简化):

{
  "model": "glm-5.2-local",
  "messages": [
    {"role": "system", "content": "你是一个有用的助手..."},
    {"role": "user", "content": "北京天气怎么样?"}
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "add",
        "description": "两个数字相加,返回它们的和。",
        "parameters": {
          "type": "object",
          "properties": {
            "a": {"type": "number", "description": "第一个数字"},
            "b": {"type": "number", "description": "第二个数字"}
          },
          "required": ["a", "b"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "查询某个城市的当前天气(模拟数据)。",
        "parameters": {
          "type": "object",
          "properties": {
            "city": {"type": "string", "description": "城市名称,如 \"北京\""}
          },
          "required": ["city"]
        }
      }
    }
  ],
  "tool_choice": "auto"
}

LLM 看到这份菜单后,知道了 3 件事:

字段 LLM 据此知道什么
name 工具叫什么 → 决定要不要选它
description 工具干嘛用的 → 判断是否匹配用户意图
parameters (JSON Schema) 要传什么参数、什么类型、哪些必填 → 决定怎么调

关键认知:LLM 不知道你的 Python 函数长什么样,它只看到这份 JSON Schema。Schema 写得越清晰,LLM 调用越准;Schema 模糊,LLM 就乱调或瞎传参数。

4.2 阶段二:LLM 决策 —— 输出 tool_calls

LLM 内部经过推理,如果觉得需要调工具,返回的 message 里会带 tool_calls 字段:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"北京\"}"
      }
    }
  ]
}

注意几个细节:

  1. content 可能为 null —— LLM 决定调工具时,不一定说话(部分模型会同时输出"思考过程",content 就不为空)
  2. arguments 是字符串化的 JSON —— 不是 dict,需要 json.loads() 解析
  3. id 是这次调用的唯一标识 —— 回传结果时要用它把结果和请求对应起来
  4. 可能同时调多个工具 —— tool_calls 是数组,LLM 可以一次要多个工具的结果(并行调用)

4.3 阶段三:结果回流 —— role=tool

Agent 执行完 Python 函数,把结果以 role=tool 的消息塞回 messages:

messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,   # ← 用 id 对应上 Step 2 的请求
    "content": result,
})

为什么 tool_call_id 这么重要?

设想 LLM 一次调了 3 个工具(call_1call_2call_3),你执行完回传 3 条 role=tool 消息。LLM 怎么知道哪条结果对应哪个工具?tool_call_id。少了这个字段,API 会直接报错。

回传后,下一轮调 LLM 时,它看到的对话历史变成:

[
  {"role": "system", "content": "你是一个有用的助手..."},
  {"role": "user", "content": "北京天气怎么样?"},
  {"role": "assistant", "content": null, "tool_calls": [{"id": "call_abc123", ...}]},
  {"role": "tool", "tool_call_id": "call_abc123", "content": "晴,18℃"}
]

LLM 读到工具返回的「晴,18℃」,再生成最终回答:「北京现在是晴天,气温 18℃。」


五、完整时序图

用户              Agent Code            LLM              工具函数
 │                   │                    │                  │
 │ "北京天气?"       │                    │                  │
 │──────────────────>│                    │                  │
 │                   │ 1. messages + tools schema             │
 │                   │───────────────────>│                  │
 │                   │                    │                  │
 │                   │ 2. LLM 推理 → 决定调 get_weather        │
 │                   │<───────────────────│                  │
 │                   │   tool_calls: [get_weather("北京")]    │
 │                   │                    │                  │
 │                   │ 3. 解析 arguments,调 Python 函数        │
 │                   │──────────────────────────────────────>│ get_weather()
 │                   │<──────────────────────────────────────│ "晴,18℃"
 │                   │                    │                  │
 │                   │ 4. 把结果作为 role=tool 回传            │
 │                   │───────────────────>│                  │
 │                   │                    │                  │
 │                   │ 5. LLM 生成最终回答                    │
 │                   │<───────────────────│                  │
 │                   │   "北京晴天 18℃"    │                  │
 │                   │                    │                  │
 │ "北京晴天 18℃"    │                    │                  │
 │<──────────────────│                    │                  │

六、关键设计点

6.1 tool_choice 的 3 种取值

tool_choice="auto"       # LLM 自己决定调不调(推荐)
tool_choice="none"       # 强制不调(用于纯对话场景)
tool_choice="required"   # 强制必须调一个(确保 LLM 行动)
tool_choice={"type": "function", "function": {"name": "add"}}  # 指定调某个

6.2 Schema 是 LLM 唯一依据

LLM 完全不知道你的 Python 函数实现,它只看 schema。所以:

  • description 要写清「这个工具干嘛的」「什么时候该用」「不该用的时候怎么办」
  • 参数 description 要写清楚含义、单位、格式(如 "时间戳,毫秒" 而不是 "时间")
  • required 要准确,该必填的别漏

6.3 错误处理:工具执行失败也要回传

错误的做法:工具抛异常就 raise,Agent 崩了。
正确的做法:工具失败时,把错误信息作为 content 回传,让 LLM 自己决定怎么办:

try:
    result = TOOL_FUNCTIONS[name](**args)
except Exception as e:
    result = f"工具执行失败:{type(e).__name__}: {e}"

messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": result,   # 失败信息也回流
})

LLM 看到「工具执行失败」后,可能会:

  • 换个参数重试
  • 换另一个工具
  • 直接告诉用户「这个我做不了」

这就是 Agent 的自我修正能力,前提是错误信息要回流。

6.4 工具粒度

太粗 太细 适中
run_anything(query) —— LLM 不会用 add_one(x) —— 上下文爆掉 add(a, b) / get_weather(city)

经验:一个工具做一件事,工具之间可组合

6.5 最大步数兜底

for step in range(1, max_steps + 1):
    ...

LLM 偶尔会陷入「调用 → 失败 → 再调用」的死循环,设 max_steps=10 兜底,防止 token 烧爆。


七、MCP 与 Agent 的关系

很多人混淆 MCP 和 Agent,这里厘清一下:

维度 MCP Agent
是什么 协议(JSON-RPC) 应用模式(LLM + 工具 + 循环)
解决什么 工具的标准化接入 多步推理 + 工具调用
关系 提供 tools 的来源 消费 tools 的主体

打个比方:

  • MCP = USB 标准 —— 任何设备按 USB 协议做接口,任何电脑都能用
  • Agent = 用 USB 接了一堆设备的电脑 —— 真正干活的是电脑

Agent 的工具不一定来自 MCP,可以直接在代码里定义(本文示例就是)。但 MCP 让 Agent 能动态接入任何符合协议的工具服务器,扩展性强得多。


八、调试与验证

8.1 打印每一轮的 messages

最直接的调试方式:

for step in range(1, max_steps + 1):
    response = client.chat.completions.create(...)
    msg = response.choices[0].message
    messages.append(msg)

    print(f"[Step {step}] messages:")
    print(json.dumps(messages, ensure_ascii=False, indent=2))

    ...

看 messages 演化,就能看清 Agent 的「思考轨迹」。

8.2 用 response.usage 监控 token

print(f"[Step {step}] tokens: {response.usage.total_tokens}")

如果某轮 token 突增,可能是工具返回了超长内容,该截断或总结。

8.3 工具执行日志

每次调用工具时打印 name(args) → result,方便定位「工具对不对」「参数对不对」「结果格式对不对」。


九、常见坑

坑 1:Schema 里 description 写得太简短

# 反例
"description": "查询天气"

# 正例
"description": "查询某个城市的当前天气。返回格式:'天气描述,温度℃。仅支持中国大陆主要城市,不支持海外。"

LLM 只看 description 判断用不用,描述不清就会乱调或不调。

坑 2:arguments 忘了 json.loads

# 反例:arguments 是字符串,直接 **args 会报错
result = TOOL_FUNCTIONS[name](**tool_call.function.arguments)

# 正例:先解析成 dict
args = json.loads(tool_call.function.arguments)
result = TOOL_FUNCTIONS[name](**args)

坑 3:漏传 tool_call_id

# 反例:不带 id,API 直接报错
messages.append({"role": "tool", "content": result})

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

坑 4:忘记把 msg 加回 messages

# 反例:msg.tool_calls 用了,但没把 msg 加进 messages,LLM 下一轮会困惑
for tool_call in msg.tool_calls:
    ...
    messages.append({"role": "tool", ...})

# 正例:先把 assistant 消息加进去,再加 tool 结果
messages.append(msg)
for tool_call in msg.tool_calls:
    ...

坑 5:工具返回 dict / 对象

# 反例:返回 dict,LLM 收到 [object Object] 之类
def get_weather(city): return {"temp": 18, "weather": "晴"}

# 正例:返回字符串,信息明确
def get_weather(city): return "晴,18℃"

如果非要返回结构化数据,json.dumps(ensure_ascii=False) 一下。

坑 6:LLM 不调工具直接编答案

可能原因:

  • tools 没传 / schema 写错
  • description 不清楚,LLM 觉得自己知道答案
  • tool_choice 设成了 "none"

排查:打印完整请求,看 tools 字段有没有正确传过去。


十、进阶:从 30 行 Agent 到生产级 Agent

进阶项 价值
多工具管理 用注册器自动生成 schema,避免手写(类似 FastAPI)
工具权限控制 危险工具要人工确认(如删数据库)
持久化记忆 把对话历史存向量库,跨会话记忆
RAG 注入 把检索到的文档作为 role=user 上下文
并行工具调用 LLM 一次要多个工具时,asyncio.gather 并行执行
流式输出 stream=True 实时看到 LLM 思考过程
多 Agent 协作 Agent 调 Agent(子任务委派)
MCP 接入 工具来源从「代码里硬编码」升级为「动态加载 MCP Server」

成熟框架:

  • LangChain / LangGraph —— 全家桶,生态完整但偏重
  • LlamaIndex —— RAG 向 Agent 扩展
  • AutoGen —— 微软出品,多 Agent 协作
  • OpenAI Assistants API —— 托管式 Agent
  • Anthropic Claude Agent SDK —— 官方新框架

十一、总结

一个最小 Agent 的核心只有 3 件事:

  1. 把工具清单注入 LLM 上下文(tools 字段 + JSON Schema)
  2. LLM 决策后输出 tool_calls(name + arguments)
  3. Agent 执行后以 role=tool 回流结果(带 tool_call_id)

AI 知道 Agent 有什么工具、怎么调,本质是看你写的 JSON Schema —— name 是工具名,description 是说明,parameters 是入参契约。Schema 是 LLM 唯一能看到的工具文档,写得越清晰,Agent 行为越可靠。

Agent 的「智能」不在循环本身,而在 LLM 看 Schema 后能不能选对工具、传对参数、用好结果。这个能力的下限由 LLM 决定,但上限由你写 Schema 的认真程度决定


十二、参考

  • OpenAI Function Calling 文档:https://platform.openai.com/docs/guides/function-calling
  • ReAct 论文:https://arxiv.org/abs/2210.03629
  • LangChain Agent 概念:https://python.langchain.com/docs/concepts/agents/
  • MCP 与 Agent:https://modelcontextprotocol.io

Logo

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

更多推荐