7.4 工具能力实现体系《AI智能体应用开发》
AI智能体应用开发 鲍亮崔江涛李倩范涛 清华大学出版社【行情 报价 价格 评测】-京东
本节将围绕大模型工具接口的设计与使用展开,以广泛使用的OpenAI /v1/chat/completions API为例,重点讨论以下内容。
在7.4.1节中介绍OpenAI提出的工具描述协议,分析其字段含义及设计原则,说明如何通过结构化描述引导模型正确理解并选择工具。
在7.4.2节中说明工具定义如何随请求一同传入模型服务提供商,以及模型在响应中如何表达工具调用结果。需要指出的是,OpenAI已提出新的ResponseAPI,但当前仍保持对/v1/chat/completions接口的兼容性,相关内容可参考官方文档[5]。
在7.4.3节中讨论工具调用过程中的异常情况,强调大模型输出并不总是严格符合工具定义,需要在工程层面对JSON解析失败、参数类型错误等问题进行防御性处理。
有了前面3节的基础,我们已经有能力脱离LangChain实现一个智能体框架,在7.4.4节中,我们以一个LangChain-Like Agent的最小原型为例,仅使用HTTP Request和其余较为基础的依赖,脱离LangChain框架,实现一个提供@tool装饰器以封装工具、支持智能体多次循环调用工具,最终完成任务的智能体框架。
在7.4.5节讨论大模型请求发送到大模型服务提供商后,服务器端处理请求的大致流程,主要介绍上下文和工具描述是如何被嵌入提示词中的。
7.4.1 工具描述协议
OpenAI使用一种基于JSON Schema思想的工具描述协议,用于向大模型声明可调用工具的能力边界、参数信息。该协议并非用于直接执行函数,而是作为提示信息,引导模型在生成过程中判断是否需要调用工具以及如何构造调用参数。
一个典型的工具描述示例如下。
代码清单7.3:工具描述示例
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取某个城市当前的天气情况",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
},
"unit": {
"type": "string",
"enum": [
"celsius",
"fahrenheit"
],
"description": "温度单位"
}
},
"required": [
"city"
]
}
}
}
该协议中各字段含义如下。
- type:工具类型,当前固定为"function",表示该工具以函数形式被调用。
- function:工具的具体定义,Object类型。
- name:工具函数名称,通常建议与后端实际函数名称保持一致,如get_weather、get_current_time等。
- description:对工具功能的自然语言描述。模型会重点参考该字段,以判断在当前对话语境下是否应当调用该工具。
- parameters:对函数入参的结构化描述,其设计遵循JSON Schema的基本形式。
- type固定为"object"。
- properties描述各入参的名称、数据类型及含义。
- required指定必填参数列表。
需要注意的是,工具描述协议本质上是一种提示工程(Prompt Engineering)手段。描述越清晰、语义越明确,模型越有可能在合适的时机生成正确的工具调用结构。了解了如何表达一个工具定义后,接下来介绍如何将工具定义和上下文信息传递到大模型。
7.4.2 IO格式设计与工具调用流程
智能体工具交互(调用)流程如图7-2所示,其中与大模型的交互部分是通过发起一次HTTP POST请求完成的,本小节以基本所有大模型服务提供商都支持的OpenAI /v1/chat/completions API为例,了解客户端(即LangChain等)是如何与大模型进行交互的。在OpenAI/v1/chat/completions API中,上下文信息和工具定义作为请求参数的一部分,随请求一同发送给模型。开发者可通过tools字段向模型声明当前可用的工具集合,通过tool_choice控制工具调用策略,通过messages字段向模型传递上下文信息。

图7-2 智能体工具交互流程[6]
可以通过curl命令(任何能进行POST请求的工具均可)与模型进行交互,代码清单7.4展示了如何通过curl命令与模型进行交互,在测试这条命令时,需要替换OPENAI_API_BASE为读者采用的大模型服务提供商的地址,OPENAI_API_KEY类似,以及model替换为想要使用的模型。例子仅提供了message字段,并未提供tools,一个完整的、使用工具的例子可以查看7.4.4节。
代码清单7.4:请求体示例
curl "$OPENAI_API_BASE/chat/completions" `
-H "Content-Type: application/json" `
-H "Authorization: Bearer $OPENAI_API_KEY" `
-d '{
"model": "gpt-5.2",
"messages": [
{
"role": "developer",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Hello!"
}
]
}'
Header部分的含义是显然的,而请求体部分,代码清单7.4并未展示太多,代码清单7.5展示了一个较为完整的请求体的例子。在本小节的剩余部分,我们会详细介绍有关请求体和响应体的部分参数,主要是和上下文传递和工具定义的参数,具体关于所有字段的含义和如何设置,可以参考OPENAI的API文档[7]。
代码清单7.5:请求体示例
{
"model": "qwen-plus",
"messages": [
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "查询西安的天气"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取某个城市当前的天气情况",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位"
}
},
"required": ["city"]
}}
}
],
"temperature": 0,
"parallel_tool_calls": true
}
请求中与工具相关的核心参数如表7-2所示。

表7-2是关于请求体的部分相关参数描述,messages是传递上下文消息的途径,tools是传递工具定义的途径。tool_choice描述了希望大模型采取的工具调用策略,它是一个枚举值。tool_choice可选值:auto(大模型自主选择工具策略)、none(不进行工具调用),以及{"type": "function", "function": {"name": "the_function_to_call"}},表示希望一定调用某个工具,“希望一定”看起来有些奇怪,这是因为部分模型不能指定工具调用。其中,the_function_to_call是指定的工具函数名称。parallel_tool_calls指定在设置tools时,是否可以一次生成多个工具调用。注意还有一个类似tools的参数functions,是过去用于传递工具定义的,目前仍能使用,但指定functions而不是tools时,不会并行工具调用,推荐采用tools。
表7-3展示了响应体的部分重要字段,当模型认为需要调用工具时,其返回结果并不会直接给出自然语言答案,而是在响应的choices中,通过tool_calls字段描述具体的工具调用信息。

choices是一个object类型的数组,其中每个choice元素的结构如表7-4所示。


一个choices数组的例子见代码清单7.6。此时,模型的职责已经完成。真正的函数执行、参数反序列化以及执行结果回传,均由应用层代码负责。这种设计清晰地区分了模型决策与系统执行两个阶段,是当前主流大模型工具调用机制的核心思想。
代码清单7.6:响应体choices示例
"choices": [
{
"message": {
"content": "",
"role": "assistant",
"tool_calls": [
{
"index": 0,
"id": "call_8f47cb704a7840cf947d0a",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"西安\", \"unit\": \"celsius\"}"
}
}
]
},
"finish_reason": "tool_calls",
"index": 0,
"logprobs": null
}
],
7.4.3 异常处理
在实际工程中,需要特别强调的一点是:大模型生成的工具调用结果并不具备强一致性保证。即使工具描述协议定义明确,模型仍可能输出不符合预期的调用格式。
常见问题包括但不限于:arguments字段不是合法的JSON,导致解析失败;参数缺失或多余,未满足required约束;参数类型错误,例如将字符串输出为数字,或将对象输出为数组;工具名称拼写错误,或返回未声明的工具名称。
因此,在工具接口设计中,开发者不应假设模型输出“必然正确”,而应在系统层面实现完整的校验与异常处理机制。例如,对arguments进行JSON解析与Schema校验;在函数执行前进行参数类型检查与默认值补齐;对异常情况进行兜底处理,必要时将错误信息反馈给模型或用户。
从工程视角看,大模型工具调用更类似于一种高置信度建议机制,而非严格的函数调用协议。只有通过完善的异常处理与防御性编程,才能在复杂应用场景中保证系统的稳定性与可控性。
7.4.4 实现一个LangChain-Like Agent的最小原型
在7.4.1节和7.4.2节介绍了如何描述一个工具,以及如何通过POST请求将工具描述和上下文传递给模型,获取响应后,如何解析响应体也已经了解。我们目前已经有能力脱离LangChain,或者其他的Agent框架,仅使用HTTP调用和其余基础依赖实现一个Agent。如标题所说,本小节将实现一个可以使用@tool装饰器注册工具、智能体可以多轮交互解决问题的最小框架原型。标题所说的LangChain-Like或许不太准确,LangChain在V1.0版本做出了较大的改动,Agent的实现迁移到了LangGraph[8],所以在实现层面有较大差别,但核心原理是类似的。之所以保留这个标题,是因为我们实现的tinyLangChain的基本用法和7.3节使用LangChain的例子几乎一致。
首先导入必要的依赖,见代码清单7.7,可以看到并没有LangChain、OpenAI等比较高层次的依赖。
代码清单7.7:tinylangchain.py依赖导入
from typing import List
import inspect
import requests
import os
import json
然后是工具注册部分,见代码清单7.8。这部分定义了一个get_function_description函数,该函数接收另一个函数func作为参数,返回func的结构化描述信息,结构实际上和代码清单7.3描述的工具定义相同。这里对输入参数func作出了足够强的限制,假设该函数有描述工具的docstring,参数只有一个,且是pydantic的BaseModel的子类。作出上述假设对于演示Agent的工作原理并没有太大影响,只是为了能够更简单地将一个函数转换为工具。在LangChain的@tool装饰器的实现中,也对能够注册为工具的函数作出了一定的限制。
接下来定义了一个工具类Tool,内部保存了函数func、函数名func_name、函数参数类型arg_type、函数的描述description。成员函数invoke可以接收一个字符串,将该字符串转换为dict后,构造出func能接收的实际参数(这就是为什么要使用pydantic的BaseModel作为函数参数类型,允许dict直接构造出参数)。之后函数被实际调用,并将调用结果结构化,对应请求体参数messages的元素(见表7-2),当role为tool时,有两个附加字段tool_name和tool_call_id,tool_call_id暂时并未放到字典中,稍后会看到这一步。_ _str_ _函数仅作为debug使用。
tool函数是一个装饰器,符合装饰器的定义,但是在内部wrapper中没有任何行为,该装饰器仅将被装饰的函数转换为Tool类型并返回。
这样就实现了@tool装饰器的全部逻辑,使用@tool装饰的函数,被包装为Tool实例返回,在Tool实例中保存了工具调用所需的全部信息。
代码清单7.8:tinylangchain.py工具部分
def get_function_description(func):
description = {"type": "function"}
func_description = {}
func_description['name'] = func.__name__
func_description['description'] = inspect.getdoc(func)
sig = inspect.signature(func)
func_description['parameters'] = list(sig.parameters.items())[0][1].annotation.model_json_schema()
description['function'] = func_description
return description
class Tool:
def __init__(self, func, arg_type, description):
self.func = func
self.func_name = self.func.__name__
self.arg_type = arg_type
self.description = description
def invoke(self, args):
try:
modeld_args = json.loads(str(args))
argument = self.arg_type(**modeld_args)
return {"role": "tool",
"tool_name": str(self.func_name),
"content": str(self.func(argument))}
except Exception as e:
return {"role": "tool",
"tool_name": str(self.func_name),
"content": str(e)}
def __str__(self):
return str({
"func": self.func_name,
"arg_type": self.arg_type,
"description": self.description
})
def tool(func):
def wrapper(*args, **kwargs):
pass
sig = inspect.signature(func)
assert len(sig.parameters.items()) == 1
arg_type = list(sig.parameters.items())[0][1].annotation
return Tool(func, arg_type, get_function_description(func))
在Tool类的基础上,已经可以实现将函数注册为工具了(可以获取如例7.3[wy1] 所示的工具定义,可以根据大模型返回的工具调用信息进行实际调用并返回结果)。接下来要实现和大模型的交互逻辑,还记得7.4.2节所讲的,与大模型的交互实际上就是一个符合API规范的POST请求吗?
代码清单7.9实现了POST请求与大模型交互的逻辑。逻辑并不复杂,初始化部分重要参数后(实际上可配置的参数还有很多,详见OpenAI文档[7]),chat_completion成员函数进行POST请求,构造出符合7.4.2节所展示的请求体后,发出一个POST请求即可。openai_api_base和openai_api_key从环境变量获取。tools字段的值直接从Tool实例的description成员获取即可,它已经符合API要求的结构。
代码清单7.9:tinylangchain.py模型交互部分
class LLM:
def __init__(self, model, temperature):
self.model = model
self.temperature = temperature
self.openai_api_base = os.getenv("OPENAI_API_BASE")
self.openai_api_key = os.getenv("OPENAI_API_KEY")
def chat_completion(self, messages, tools):
return self.post(messages, tools)
def post(self, messages, tools: List[Tool]):
headers = {
"Authorization": f"Bearer {self.openai_api_key}",
"Content-Type": "application/json"
}
body = {
"model": self.model,
"messages": messages,
"tools": [tool.description for tool in tools],
"function_call": "auto",
"temperature": self.temperature
}
resp = requests.post(self.openai_api_base + "/chat/completions", headers = headers, json = body).json()
return resp['choices'][0]
工具和模型交互逻辑已经准备完毕,接下来要实现程序-工具-大模型的多次交互过程,这就是Agent的核心。
代码清单7.10:tinylangchain.py Agent循环部分
class Agent:
def __init__(self, model: LLM, tools: List[Tool], system_prompt: str = None):
self.model = model
self.tools = tools
self.tools_dict = {tool.func_name: tool for tool in tools}
self.system_prompt = {"role": "system", "content": "You are a helpful assistant"}
if not system_prompt is None and self.validate_msg(system_prompt, 'system'):
self.system_prompt = system_prompt
self.history = [self.system_prompt]
def invoke_tool(self, tool_name, tool_args):
return self.tools_dict[tool_name].invoke(tool_args)
def validate_msg(self, msg: dict, role: str):
## 详细见代码仓库 tool-example/client/tinylangchain.py
return True
def append_msg(self, new_msg):
self.history.append(new_msg)
return self.history
def run(self, input):
if self.validate_msg(input, "user"):
yield self.append_msg(input)
resp = self.model.chat_completion(self.history, self.tools)
while resp['finish_reason'] != 'stop':
finish_reason = resp['finish_reason']
message = resp['message']
yield self.append_msg(message)
if finish_reason == 'tool_calls':
tool_calls = message['tool_calls']
for tool_call in tool_calls:
func_call = tool_call['function']
tool_result = self.invoke_tool(func_call['name'], func_call['arguments'])
tool_result['tool_call_id'] = tool_call['id']
yield self.append_msg(tool_result)
resp = self.model.chat_completion(self.history, self.tools)
yield self.append_msg(resp['message'])
def create_agent(model, tools, system_prompt = None):
return Agent(model, tools, system_prompt)
代码清单7.10展示了如何实现一个Agent。可以暂时忽略validate_msg函数,它只是简单地校验用户输出的system_prompt和input是否包含role和content。主要关注对成员history的维护,它对应请求体中的messages字段。history的元素是一个包含role,content(role为tool时,还有tool_name和tool_call_id)的字典。
对history的维护主要体现在:
(1)在Agent类的构造器向history添加了system_prompt。
(2)run函数是构造后[wy2] ,维护history的唯一逻辑。run函数接收用户input并添加到history中,之后获取POST请求的结果(响应体可以回顾7.4.2节),动态添加message。
循环的过程可以描述如下,当finish_reason不是stop时,假设只有tool_calls,也就是只有工具调用。在工具调用部分,首先获取function字段的name和arguments的值(全部工具调用的信息),然后进行工具调用,获取结构化的工具调用结果,设置tool_call_id(允许并行工具调用)后,添加到history中。注意这里并行进行工具调用时,一个工具要对应一个message。
当finish_reason为stop时,说明模型已经可以回答用户问题了,将最后一次消息添加到history后,主要的Agent循环就结束了。这只叙述了单轮用户--Agent交互的逻辑。有了上面的基础,相信对于多轮交互的逻辑,也就不难理解了,核心仍然是对history(也就是请求体messages字段)的维护。
yield self.append_msg模拟了流式传输的过程。
我们来使用一下tinyLangChain,例子见代码清单7.11。
代码清单7.11:使用tinyLangChain
from tinylangchain import *
from pydantic import BaseModel, Field
from typing import Literal
from dotenv import load_dotenv
load_dotenv('../.env')
class WeatherArgs(BaseModel):
location: str = Field(description = "想要查询的城市")
units: Literal["celsius", "fahrenheit"] = Field(
description="温度单位,celsius:摄氏度,fahrenheit:华氏度")
class AddArgs(BaseModel):
x: int = Field(description = "加法运算的参数 x")
y: int = Field(description = "加法运算的参数 y")
@tool
def get_weather(args: WeatherArgs):
'''获取某个城市的天气信息'''
if "西安" in args.location:
return "5 摄氏度" if args.units == 'celsius' else "41 华氏度"
else:
return "6 摄氏度" if args.units == 'celsius' else "42.8 华氏度"
@tool
def add(args: AddArgs):
'''加法运算 x + y'''
return args.x + args.y
llm = LLM(model = "qwen-plus", temperature = 0)
agent = create_agent(
model = llm,
tools = [add, get_weather]
)
example_query = "计算一下北京和西安的气温的总和"
events = agent.run(
{"role": "user", "content": example_query},
)
def format(msg):
print("====================== " + msg['role'] + "========================")
print()
print(msg)
print()
for e in events:
format(e[-1])
与使用LangChain进行智能体构造和交互基本一致。这里使用@tool注册了两个工具,查询天气和加法。让智能体帮我们计算一下“北京气温+西安气温”是多少。
代码清单7.12展示了运行结果,这里直接展示原始输出,能够更好地展示消息不断添加到history的过程。第一次工具调用展示了并行工具调用。
代码清单7.12:使用tinyLangChain
====================== user========================
{'role': 'user', 'content': '计算一下北京和西安的气温的总和'}
====================== assistant========================
{'content': '', 'role': 'assistant', 'tool_calls': [{'index': 0, 'id': 'call_68107c19eab94580826e4d', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"location": "北京", "units": "celsius"}'}}, {'index': 1, 'id': 'call_5beca6573c8a49dbb57d07', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"location": "西安", "units": "celsius"}'}}]}
====================== tool========================
{'role': 'tool', 'tool_name': 'get_weather', 'content': '6 摄氏度', 'tool_call_id': 'call_68107c19eab94580826e4d'}
====================== tool========================
{'role': 'tool', 'tool_name': 'get_weather', 'content': '5 摄氏度', 'tool_call_id': 'call_5beca6573c8a49dbb57d07'}
====================== assistant========================
{'content': '', 'role': 'assistant', 'tool_calls': [{'index': 0, 'id': 'call_3ce23993280e4df483b042', 'type': 'function', 'function': {'name': 'add', 'arguments': '{"x": 6, "y": 5}'}}]}
====================== tool========================
{'role': 'tool', 'tool_name': 'add', 'content': '11', 'tool_call_id': 'call_3ce23993280e4df483b042'}
====================== assistant========================
{'content': '北京和西安的气温总和为11摄氏度。', 'role': 'assistant'}
7.4.5 揭秘服务器端
7.4.4节向读者展示了Agent的核心工作原理,主要是对上下文的维护,role唯一标识了一条消息的来源,帮助大模型生成正确的内容。那么,自然而然会有一些问题,比如请求体的messages、tools是如何输入大模型的,大模型本质上只是根据所给提示词不断输出next-token;获取到大模型的输出后,如何识别这是一个工具调用而不是普通的回答。带着这样的问题,本小节将展示POST请求发送到服务器后,messages、tools是如何被组织为提示词输入大模型的,大模型的输出如何解析为工具调用。
图7-3展示了基本的逻辑,这里只关注messages和tools,关于推理的细节和其他参数略去,详细内容可以参考vLLM[9]。首先从请求体中获取参数messages和tools,然后使用分词器提供的提示词模板并嵌入messages和tools,这就是提示词的核心原理,它使用一个预先定义的提示词模板,将不同role的message以及tools放到提示词中,这样就完成了提示词的构造。具体逻辑这里不再详细解释,参考huggingface对于chat template的介绍[10]。一个极简的例子参考代码仓库tool-example/server/main.py。

图7-3 服务器端流程
让我们看看实际提示词是什么样子的。代码清单7.14展示了一个提示词实例,采用的模板为Qwen2.5-3B-Instruct模型的原始模板。实例分为提示词部分和模型输出部分。我们观察到,工具被放到了system部分,并提示大模型使用<tool_call/>包裹工具调用,而输出部分正是这样做的。而messages数组,则是根据role被组织到了一段一段的<|im_start|>和<|im_end|>中间,它们是Qwen的特殊Token,用于区分system / user / assitant的不同角色,明确一条消息从哪开始、到哪结束,帮助模型更好地理解对话结构。提示词最后的<|im_start|>assistant是自动添加的。
代码清单7.14:提示词实例
<|im_start|>system
You are a helpful assistant
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "get_weather", "description": "获取某个城市当前的天气情况", "parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "城市名称"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位"}}, "required": ["city"]}}}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
<|im_start|>user
查询西安的天气<|im_end|>
<|im_start|>assistant
// 以上是提示词部分,以下是模型输出
<tool_call>
{"name": "get_weather", "arguments": {"city": "西安", "unit": "celsius"}}
</tool_call>
注意到对于Qwen 2.5系列模型来说,生成部分并没有tool_call_id相关的内容,笔者查阅相关资料,以及vLLM的tool parser[12](Qwen2.5采用Hermes Parser)[11]后,发现id实际上是获取到大模型输出后,系统随机生成的uuid,这是为了兼容OpenAI的API格式做的工作。另外,role为tool的message,实际上在提示词中的表现为<|im_start|>user\n<tool_response>\n...\n</tool_response><|im_
end|>(省略的内容是工具结果)。并行工具调用时,连续的role为tool的message,会被合并为一条user消息包含多个<tool_response/>标签的形式。代码清单7.15展示了这个结果,我们注意到也没有tool_call_id的相关内容。
代码清单7.15:提示词实例
<|im_start|>user
<tool_response>
6 摄氏度
</tool_response>
<tool_response>
5 摄氏度
</tool_response><|im_end|>
有关服务端的原理我们浅尝辄止,并没有太过深入。大模型理解提示词的机制与其架构和训练过程密切相关,而如何更好地进行工具调用也是当前学术界的热门话题。本书主要关注应用部分,了解部分原理能够更好地理解书中的内容,但继续有关大模型训练和推理的探讨超出了本书的范围,如果读者感兴趣,这里有一些参考文献供学习(架构和预训练[29][15][16][17][18][19][20][21]、后训练[22][23][24][25][26][30]、智能体[1][27][28]、推理参考vLLM[9])。
更多推荐


所有评论(0)