从零构建大模型智能体:实现可扩展工具系统

引言

在智能体系统(Agent)中,工具(Tool)是让大模型从"纯文本回答"升级为"可执行任务"的关键能力。本章将从最简单、最直观的方式出发,演示如何仅通过提示词让 LLM 理解你的函数并主动调用它们。随后,我们会进一步封装工具类与装饰器,使工具体系更加规范、可扩展,为构建更复杂的智能体打下基础。

新增本文内容后的完整代码: Vero

用提示词教会模型调用你的函数

在智能体(Agent)体系里,"工具(Tool)"本质上就是你提供给模型的一段可执行逻辑,通常表现为:

  • 一个普通的 Python 函数
  • 或一个带方法的类
  • 或一个 API 调用包装

对模型来说,它不关心你的工具由什么构成,只关心两件事:

  1. 名字 (如 add
  2. 可用参数 (如 a: int, b: int

只要模型知道"有哪些动作可以选",它就能按照提示词主动调用这些工具。

在真正实现可扩展的工具系统之前,我们从最朴素、也最容易理解的一步开始:使用提示词,让模型按照我们约定的格式主动调用工具。这种方法不依赖任何框架或协议,只需要自然语言告诉模型:

  • 有哪些函数
  • 每个函数的名字与参数
  • 什么时候需要调用它

只要提示词设计得足够清晰,模型就能根据你的要求自动输出一段"函数调用结构",你的程序只需要解析这段结构并执行相应函数即可。

假设我们有两个函数:

python 复制代码
def multiply(a: int, b: int):
    """返回 a 与 b 的乘积"""
    return a * b

def get_weather(location: str):
    """返回location的天气"""
    return f"{location}的天气:多云转阴. 23/19℃"

当我们需要计算乘法的时候,只要模型能输出函数名和参数,我们就可以真正去调用它,把结果返回给大模型。

下面是一个最小可行的提示词模板:

复制代码
你可以调用工具。以下是可用的工具:

```
1. multiply(a: int, b: int)
   - 返回 a 与 b 的乘积。
2. get_weather(location: str):
   - 返回location的天气
```

当需要使用工具时:
- 请使用格式: TOOL_CALL:tool_name:{"param1": 1, "param2": "abc"}
- parameters 必须是标准 JSON 对象,包含工具所需的所有参数
- 如果不需要使用工具,直接回答文本
"""

这里把这两个函数的签名以及docstr写到提示词中,然后指定调用函数的格式。以关键词TOOL_CALL触发,并且通过JSON格式明确指出每个参数的取值,方便后续解析。

我们来真正调用一下:

py 复制代码
from vero.core import ChatOpenAI, Message
# 确保.env配置好所需的环境变量,可以参考上一篇文章
llm = ChatOpenAI()

def multiply(a: int, b: int):
    """返回 a 与 b 的乘积"""
    return a * b

def get_weather(location: str):
    """返回location的天气"""
    return f"{location}的天气:多云转阴. 23/19℃"

system_prompt = """你可以调用工具。以下是可用的工具:
```
1. multiply(a: int, b: int)
   - 返回 a 与 b 的乘积。
2. get_weather(location: str):
   - 返回location的天气
```

当需要使用工具时:
- 请使用格式: TOOL_CALL:tool_name:{"param1": 1, "param2": "abc"}
- parameters 必须是标准 JSON 对象,包含工具所需的所有参数
- 如果不需要使用工具,直接回答文本
"""

messages = [Message.system(system_prompt)]
messages.append(Message.user("12346乘以98765等于多少"))

output = llm.generate(messages)
print(output)
复制代码
'TOOL_CALL:multiply:{"a": 12346, "b": 98765}'

模型不会尝试自己去瞎答,而是输出了我们指定的调用工具的格式。那这里怎么去调用工具呢?

当然首先需要解析出工具名称和参数列表,我们可以编写一个函数完成:

py 复制代码
from typing import Optional, Tuple, Dict
import re
import ast

def parse_tool_call(text: str) -> Tuple[bool, Optional[str], Optional[Dict]]:
    """
    解析 TOOL_CALL:{tool_name}:{parameters} 格式的字符串。

    参数:
        text (str): 模型输出文本

    返回:
        Tuple:
            - has_tool_call (bool): 是否包含工具调用
            - tool_name (str | None): 工具名称
            - params (dict | None): 参数字典
    """
    pattern = r"TOOL_CALL:(\w+):(.+)"
    match = re.search(pattern, text)
    if not match:
        return False, None, None

    tool_name = match.group(1)
    params_str = match.group(2)
    try:
        # ast.literal_eval 更安全,能解析 dict 风格参数
        params = ast.literal_eval(params_str)
    except Exception:
        params = None

    return True, tool_name, params

针对模型的输出,尝试解析出工具名称和参数:

py 复制代码
has_tool, tool_name, params = parse_tool_call(output)
print(has_tool, tool_name, params)
复制代码
True multiply {'a': 12346, 'b': 98765}

那我们怎么调用呢,我们基于python中函数也是一等公民,可以当成字典的value,然后基于抽取到的名称作为key去获取:

py 复制代码
# 简单工具注册
TOOLS = {
    "multiply": multiply,
    "get_weather": get_weather
}

if has_tool and tool_name in TOOLS:
  	# result = 1219352690
    result = TOOLS[tool_name](**params) # 把参数传递进去,得到函数调用结果
    messages.append(Message.assistant(f"TOOL_RESULT:{result}"))
    messages.append(Message.user("根据结果回答用户的问题"))
    # 再次调用模型生成最终回答
    final_output = llm.generate(messages)
    print("模型回答:", final_output)    
else:
    print("直接回答:", output)
复制代码
模型回答: 12346 乘以 98765 等于 1219352690。

这样就完成了一次工具的调用。我们可以打印出整个消息列表:

py 复制代码
for msg in messages:
    print(msg)
复制代码
[system] 你可以调用工具。以下是可用的工具:

```
1. multiply(a: int, b: int)
   - 返回 a 与 b 的乘积。
2. get_weather(location: str):
   - 返回location的天气
```

当需要使用工具时:
- 请使用格式: TOOL_CALL:tool_name:{"param1": 1, "param2": "abc"}
- parameters 必须是标准 JSON 对象,包含工具所需的所有参数
- 如果不需要使用工具,直接回答文本

[user] 12346乘以98765等于多少
[assistant] TOOL_RESULT:1219352690
[user] 根据结果回答用户的问题

如果改成询问天气,那么它会调用我们定义的获取天气函数:

py 复制代码
messages.append(Message.user("我想知道深圳明天的天气怎么样"))
output = llm.generate(messages)
复制代码
'\n\nTOOL_CALL:get_weather:{"location": "深圳"}'

最终回答:

复制代码
模型回答: 深圳明天的天气将多云转阴,气温在19℃到23℃之间。

这就是 所有现代 Agent 框架的核心能力让模型知道"什么时候需要行动",以及"如何输出行动的结构"。

通过这样一个简单的提示词协议,你就已经拥有了一个可扩展的工具系统雏形。后续你只要不断增加工具即可,模型也会自动根据语义挑选正确的工具。

封装工具类

我们把刚才调用工具所需的函数名称、参数列表、函数描述(docstr)封装到一个叫做Tool的类中,同时增加工具名称和工具输出类型:

py 复制代码
class Tool:
    """
    表示一个可被 LLM 智能体系统调用的"工具"。

    属性:
        name (str): 工具名称(默认为被包装函数的函数名)。
        description (str): 对工具功能的中文描述。
        func (callable): 实际执行逻辑的 Python 函数。
        arguments (list): 参数列表,每项为 (参数名, 参数类型, 默认值)。
        outputs (str): 返回值类型(根据函数返回注解解析)。
    """

    def __init__(self, name: str, description: str, func: callable,
                 arguments: list, outputs: str):
        self.name = name
        self.description = description
        self.func = func
        self.arguments = arguments
        self.outputs = outputs

    def to_string(self) -> str:
        """
        返回工具的结构化字符串形式描述,便于用于提示词或调试。
        """
        args_str = ", ".join(
            [
                f"{n}: {t}" + (f" = {d}" if d is not None else "")
                for (n, t, d) in self.arguments
            ]
        )

        return (
            f"工具名: {self.name}, "
            f"描述: {self.description}, "
            f"参数: {args_str}, "
            f"返回: {self.outputs}"
        )

    def __call__(self, *args, **kwargs):
        """
        调用包装的底层 Python 函数。
        """
        return self.func(*args, **kwargs)

既然有了Tool类,那么我们要怎么初始化呢?如果还是像前面那样每次手动编写代码太复杂了,所以我们可以定义一个装饰器,让它自动为我们抽取。

定义装饰器

如果你用过Langchain就会知道它有一个@tool装饰器可以直接加到函数上,非常方便。我们下面实现一个简化版的:

py 复制代码
import inspect

def tool(func):
    """
    将一个普通 Python 函数转换为 Tool 对象的装饰器。

    装饰器负责自动解析函数的结构信息,包括:
      - 函数名
      - 参数名称、类型、默认值
      - 返回值类型注解
      - 函数的 docstring 作为工具描述

    通过这种方式,工具接口更加规范、清晰,便于让 LLM 正确调用。

    返回:
        Tool: 构建完成的 Tool 实例。
    """

    signature = inspect.signature(func)
    arguments = []

    # 解析所有参数
    for param in signature.parameters.values():
        annotation = param.annotation
        type_str = annotation.__name__ if hasattr(annotation, "__name__") else str(annotation)

        # 解析默认值,没有默认值则设为 None
        default = None if param.default is inspect._empty else param.default

        arguments.append((param.name, type_str, default))

    # 解析返回值类型
    return_annotation = signature.return_annotation
    if return_annotation is inspect._empty:
        outputs = "None"
    else:
        outputs = (
            return_annotation.__name__
            if hasattr(return_annotation, "__name__")
            else str(return_annotation)
        )

    # 使用 docstring 作为描述
    description = func.__doc__ or "无描述。"

    name = func.__name__
    
    return Tool(
        name=name,
        description=description,
        func=func,
        arguments=arguments,
        outputs=outputs,
    )

应用tool装饰器

py 复制代码
from vero.tool import tool

# ----------------------------------------------------------------------
# 示例工具 1:multiply
# ----------------------------------------------------------------------
@tool
def multiply(a: int, b: int) -> int:
    """返回两个整数的乘积。"""
    return a * b


# 示例工具 2:get_weather
# ----------------------------------------------------------------------
@tool
def get_weather(location: str) -> str:
    """根据地点返回天气(模拟版)。"""
    fake_weather = {
        "北京": "晴转多云 5℃ ~ 12℃",
        "上海": "小雨 8℃ ~ 15℃",
        "广州": "多云 16℃ ~ 25℃",
        "深圳": "大部多云 19℃ ~ 26℃",
    }
    return fake_weather.get(location, "天气未知")


tools = [multiply, get_weather]

tool_descriptions = "\n".join([f"{tool.name}: {tool.description}" for tool in tools])
tool_by_names = {tool.name: tool for tool in tools}

print(tool_descriptions) # 所有工具的描述
print(tool_by_names) # 工具名称到工具对象的映射
复制代码
multiply: 返回两个整数的乘积。
get_weather: 根据地点返回天气(模拟版)。
{'multiply': <vero.tool.tool.Tool object at 0x1220ab4d0>, 'get_weather': <vero.tool.tool.Tool object at 0x1220aac10>}

还是和上面的例子一样,下篇文章我们将会把这些逻辑封装到Agent类中:

py 复制代码
system_prompt = f"""你可以调用工具。以下是可用的工具:

```
{tool_descriptions}
```

当需要使用工具时:
- 请使用格式: TOOL_CALL:tool_name:{{"param1": 1, "param2": "abc"}}
- parameters 必须是标准 JSON 对象,包含工具所需的所有参数
- 如果不需要使用工具,直接回答文本
"""

messages = [Message.system(system_prompt)]

messages.append(Message.user("我想知道深圳明天的天气怎么样"))

output = llm.generate(messages)
has_tool, tool_name, params = parse_tool_call(output)
if has_tool and tool_name in TOOLS:
    result = tool_by_names[tool_name](**params)
    messages.append(Message.user(f"TOOL_RESULT:{result} , 根据结果回答用户的问题"))
    # 再次调用模型生成最终回答
    final_output = llm.generate(messages)
    print("模型回答:", final_output)    
else:
    print("直接回答:", output)
复制代码
模型回答: 深圳明天的天气将是大部多云,气温在19℃到26℃之间。
相关推荐
NAGNIP17 小时前
一文搞懂深度学习中的通用逼近定理!
人工智能·算法·面试
冬奇Lab18 小时前
一天一个开源项目(第36篇):EverMemOS - 跨 LLM 与平台的长时记忆 OS,让 Agent 会记忆更会推理
人工智能·开源·资讯
冬奇Lab18 小时前
OpenClaw 源码深度解析(一):Gateway——为什么需要一个"中枢"
人工智能·开源·源码阅读
AngelPP1 天前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年1 天前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
九狼1 天前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS1 天前
Kimi Chat Completion API 申请及使用
前端·人工智能
warm3snow1 天前
Claude Code 黑客马拉松:5 个获奖项目,没有一个是"纯码农"做的
ai·大模型·llm·agent·skill·mcp
天翼云开发者社区1 天前
春节复工福利就位!天翼云息壤2500万Tokens免费送,全品类大模型一键畅玩!
人工智能·算力服务·息壤
知识浅谈1 天前
教你如何用 Gemini 将课本图片一键转为精美 PPT
人工智能