引言
随着大语言模型逐步具备"理解---推理---行动"的能力,如何让模型稳定、可控地调用外部工具 ,已成为构建智能体(Agent)系统的关键一环。相比早期基于文本协议的工具调用方式,OpenAI 推出的 Function Calling(Tools)机制,为模型与程序世界之间建立了一套结构化、可验证的交互接口。
本文将从工程实践出发,完整展示一个 OpenAI 兼容 Function Calling 智能体 的实现过程:从消息协议(Message)、模型封装(ChatOpenAI)、工具抽象(Tool),到支持多轮、多工具调用的 Agent 主循环设计。通过真实的搜索与计算场景示例,你将看到模型如何在最少提示干预的情况下,自主规划、调用工具并整合结果,最终给出可靠答案。
如果你希望构建一个可扩展、可调试、可流式演进的 Agent 系统,本文的实现将是一个坚实的起点。
代码: vero
Message类
为了兼容OpenAI的消息格式,我们重写一下Message类:
py
from typing import Dict, Any, Optional, List, Self, Union, Literal
import time
from pydantic import BaseModel, Field
class Message(BaseModel):
content: Optional[Union[str, List[Dict[str, Any]]]] = None
role: Literal["system", "user", "assistant", "tool"]
name: Optional[str] = None
tool_call_id: Optional[str] = None
tool_calls: Optional[List[Dict[str, Any]]] = None
timestamp: int = Field(default_factory=lambda: int(time.time()))
metadata: Dict[str, Any] = Field(
default_factory=dict, description="token counts"
)
@classmethod
def user(cls, content: str, **kw) -> Self:
return cls(role="user", content=content, **kw)
@classmethod
def system(cls, content: str, **kw) -> Self:
return cls(role="system", content=content, **kw)
@classmethod
def assistant(
cls, content: Optional[str] = None, tool_calls: List[dict] = None, **kw
) -> Self:
return cls(role="assistant", content=content, tool_calls=tool_calls, **kw)
@classmethod
def tool(cls, content: str, tool_call_id: str, **kw) -> Self:
return cls(
role="tool",
content=content,
tool_call_id=tool_call_id,
**kw,
)
def to_dict(self) -> Dict[str, Any]:
d = {
k: v
for k, v in self.__dict__.items()
if k not in ("timestamp", "metadata") and v is not None
}
return d
ChatOpenAI类
同时,我们封装了工具调用的入参和出参,就像上篇文章中介绍的一样:
py
from typing import Optional, Iterator, List, Union, Dict, Any
from openai import OpenAI
from .message import Message
from vero.config import settings
from vero.core.exceptions import LLMCallError, LLMConfigError
class ChatOpenAI:
"""
基于 OpenAI Python SDK 的聊天模型封装,用于与对话式 LLM 进行交互。
属性:
model_name: 使用的 LLM 模型名称。
temperature: 文本生成的采样温度。
max_tokens: 单次响应的最大 token 数。
timeout: 请求超时时间(秒)。
api_key: OpenAI API Key。
base_url: OpenAI API Base URL。
"""
def __init__(
self,
model_name: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
timeout: Optional[int] = None,
**kwargs,
) -> None:
"""
初始化 LLM 封装类。
如果未显式传入参数,则回退到配置文件中的默认值。
Raises:
LLMConfigError: 当缺少 api_key、base_url 或 model_name 时抛出。
"""
self.model_name = model_name or settings.model_name
print(f"🤖 Initializing LLM with model: {self.model_name}")
self.temperature = temperature
self.max_tokens = max_tokens
self.timeout = timeout or getattr(settings, "timeout", None)
self.kwargs = kwargs
self.api_key = api_key or settings.openai_api_key
self.base_url = base_url or settings.openai_base_url
if not all([self.api_key, self.base_url, self.model_name]):
raise LLMConfigError(
"Missing api_key, base_url, or model_name for LLM client"
)
self._client = self._create_client()
def _create_client(self) -> OpenAI:
"""
创建并返回 OpenAI 客户端实例。
"""
return OpenAI(
api_key=self.api_key,
base_url=self.base_url,
timeout=self.timeout,
)
def generate(
self,
messages: List[Union[Message, dict]],
stream: bool = False,
temperature: Optional[float] = None,
tools: Optional[List[dict]] = None,
tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
**kwargs,
) -> Union[Message, Iterator[str]]:
"""
调用 LLM 生成响应,支持流式输出与非流式完整输出。
Args:
messages: 对话历史消息列表,可以是 Message 对象或原始 dict。
stream: 是否启用流式输出;为 True 时返回一个迭代器。
temperature: 可选参数,用于覆盖默认 temperature。
tools: OpenAI Tool / Function Calling 的 schema 定义。
tool_choice: 工具选择策略("auto"、"none" 或指定工具)。
**kwargs: 其他 OpenAI 参数(如 max_tokens)。
Returns:
Message:
非流式场景下返回 Assistant Message。
若模型触发了工具调用,tool_calls 会挂载在 Message 上,
此时 content 可能为 None。
Iterator[str]:
流式模式下,按 token / chunk 迭代返回生成文本。
Raises:
LLMCallError: 当 LLM API 调用失败时抛出。
"""
# 将 Message 对象统一转换为 OpenAI API 可接受的 dict 结构
messages_dict = [
msg.to_dict() if isinstance(msg, Message) else msg
for msg in messages
]
try:
# 构造基础请求参数
payload = {
"model": self.model_name,
"messages": messages_dict,
"temperature": temperature or self.temperature,
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
}
# 如果提供了 tools / tool_choice,则注入到请求中
if tools:
payload["tools"] = tools
if tool_choice:
payload["tool_choice"] = tool_choice
# 透传其他 OpenAI 支持的参数
for k, v in kwargs.items():
if k not in {"temperature", "max_tokens"}:
payload[k] = v
response = self._client.chat.completions.create(
stream=stream,
**payload,
)
if stream:
# 使用内部生成器封装流式响应
# 避免整个 generate 方法本身变成 generator
def _stream_generator():
for chunk in response:
content = chunk.choices[0].delta.content or ""
if content:
yield content
return _stream_generator()
# -----------------------------
# 非流式模式:构造 Message 对象
# -----------------------------
resp_msg = response.choices[0].message
usage = response.usage or {}
assistant_msg = Message.assistant(
content=resp_msg.content,
metadata={
"usage": {
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
}
},
)
# 处理 Function / Tool Calling 结果(如果存在)
if resp_msg.tool_calls:
assistant_msg.tool_calls = [
{
"id": call.id,
"type": "function",
"function": {
"name": call.function.name,
"arguments": call.function.arguments,
},
}
for call in resp_msg.tool_calls
]
return assistant_msg
except Exception as e:
# 统一封装为自定义异常,便于上层处理
raise LLMCallError(f"LLM call failed: {str(e)}") from e
Tool类
主要增加to_openai_schema()方法可以转换为上篇文章看到的OpenAI兼容的JSON Schema定义:
py
import inspect
from typing import Any, Dict, List, Optional, Union, get_origin, get_args
class Tool:
"""
表示一个可被 LLM 智能体系统调用的工具(函数封装)。
该 Tool 同时支持:
1. 人类可读的描述(name / description)
2. Python 直接调用(__call__)
3. OpenAI Function Calling Schema
"""
# Python 类型到 JSON Schema 类型的映射
PYTHON_TO_JSON = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
dict: "object",
list: "array",
}
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 # [(param_name, type_str, default), ...]
self.outputs = outputs
# 同时保留 signature,方便生成 Function Calling schema
self.signature = inspect.signature(func)
def __call__(self, *args, **kwargs):
"""
直接调用底层的 Python 函数
"""
return self.func(*args, **kwargs)
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"Tool Name: {self.name}, "
f"Description: {self.description}, "
f"Arguments: {args_str}, "
f"Outputs: {self.outputs}"
)
# ================================
# OpenAI Function Calling
# ================================
def to_openai_schema(self) -> dict:
"""
将当前 Tool 转换为 OpenAI / Qwen 兼容的 Function Calling Schema。
返回格式示例:
{
"type": "function",
"function": {
"name": "...",
"description": "...",
"parameters": {
"type": "object",
"properties": {...},
"required": [...]
}
}
}
"""
properties: Dict[str, Any] = {}
required: List[str] = []
for name, param in self.signature.parameters.items():
if name == "self":
continue
annotation = param.annotation
default = None if param.default is inspect._empty else param.default
schema, is_required = self._annotation_to_schema(annotation, default)
# OpenAI 要求每个参数都有 description
schema["description"] = name
properties[name] = schema
if is_required:
required.append(name)
parameters = {
"type": "object",
"properties": properties,
"required": required,
# 如果你希望严格模式,可打开:
# "additionalProperties": False,
}
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description or "",
"parameters": parameters,
},
}
def _annotation_to_schema(self, annotation: Any, default: Any):
"""
将 Python 类型注解转换为 JSON Schema,并判断参数是否必填
"""
origin = get_origin(annotation)
args = get_args(annotation)
# Optional[T] 或 Union[T, None] → 非必填
if origin is Union and type(None) in args:
non_none = [a for a in args if a is not type(None)]
if len(non_none) == 1:
inner_schema, _ = self._annotation_to_schema(non_none[0], None)
return {
"anyOf": [inner_schema, {"type": "null"}]
}, False
# List[T]
if origin in (list, List):
item_type = args[0] if args else Any
item_schema, _ = self._annotation_to_schema(item_type, None)
return {
"type": "array",
"items": item_schema,
}, default is None
# Dict[str, T]
if origin in (dict, Dict):
value_type = args[1] if len(args) == 2 else Any
value_schema, _ = self._annotation_to_schema(value_type, None)
return {
"type": "object",
"additionalProperties": value_schema,
}, default is None
# 基础类型
if annotation in self.PYTHON_TO_JSON:
return {
"type": self.PYTHON_TO_JSON[annotation]
}, default is None
# 兜底:无法识别的类型统一视为 string
return {"type": "string"}, default is None
def tool(func):
"""
装饰器:将普通 Python 函数转换为 Tool 对象。
自动解析:
- 函数名
- 参数名 / 类型 / 默认值
- 返回值类型
- 函数 docstring 作为工具描述
"""
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)
)
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)
)
description = inspect.getdoc(func) or "未提供工具描述"
name = func.__name__
return Tool(
name=name,
description=description,
func=func,
arguments=arguments,
outputs=outputs,
)
Function Calling 智能体
终于可以进入本文的主题,我们实现一个通过Function Calling进行工具调用的智能体。
如果看完了最近的几篇文章,理解下面的代码应该不难:
py
import json
import time
from typing import Optional, List, Dict, Any
from vero.tool import Tool
from vero.core.message import Message
from vero.core.chat_openai import ChatOpenAI
from vero.core.agent import Agent
from vero.core.exceptions import ToolNotFoundError
class OpenAIFunctionAgent(Agent):
"""
一个支持 OpenAI 兼容 Function Calling(tools)的 Agent 实现。
特性:
- 支持在单个 assistant 消息中包含多个 tool_calls
- 自动执行模型请求的工具
- 将工具执行结果自动注入回对话上下文
- 持续循环,直到模型输出最终的纯文本答案
"""
def __init__(
self,
name: str,
llm: ChatOpenAI,
tools: Optional[List[Tool]] = None,
system_prompt: Optional[str] = None,
max_turns: int = 5,
tool_choice: str = "auto",
) -> None:
"""
初始化 OpenAIFunctionAgent。
参数:
name: Agent 的可读名称标识。
llm: 用于推理的 ChatOpenAI 实例。
tools: 可选的 Tool 列表,表示 Agent 可调用的工具。
system_prompt: 可选的 system prompt 覆盖文本。
max_turns: 最大推理 / 工具执行循环次数。
tool_choice: OpenAI 的 tool_choice 参数("auto"、"none" 或强制指定工具)。
"""
print(f"🚀 Initializing OpenAIFunctionAgent `{name}` ...")
super().__init__(
name=name,
llm=llm,
tools=tools,
system_prompt=system_prompt,
max_turns=max_turns,
)
self.tool_choice = tool_choice
self.tools_schema = self._build_tool_schemas()
print(f"🛠️ Registered tools: {self.tools}")
print(f"⚙️ Tool choice mode: {self.tool_choice}")
# 使用 system prompt 初始化对话历史
self.add_message(Message.system(self._build_system_prompt()))
# ------------------------------------------------------------------
# System prompt
# ------------------------------------------------------------------
def _build_system_prompt(self) -> str:
"""
构建 Agent 使用的 system prompt。
"""
return (
self.system_prompt
or "You are an intelligent agent capable of using external tools to help solve user queries."
)
# ------------------------------------------------------------------
# Tool schemas
# ------------------------------------------------------------------
def _build_tool_schemas(self) -> List[Dict[str, Any]]:
"""
将已注册的 Tool 转换为 OpenAI 兼容的 function calling schema。
"""
if not self.tools:
return []
return [t.to_openai_schema() for t in self.tools]
# ------------------------------------------------------------------
# Main execution loop
# ------------------------------------------------------------------
def run(self, user_query: str) -> str:
"""
执行 Agent 的主推理循环。
执行步骤:
1. 将用户输入追加到对话历史
2. 携带工具 schema 调用 LLM
3. 如果 assistant 返回了 tool_calls:
- 逐个执行工具
- 将工具结果以 Message.tool 的形式注入历史
4. 重复以上过程,直到模型返回纯文本结果
"""
print("\n==============================")
print(f"👤 User Input: {user_query}")
print("==============================\n")
self.add_message(Message.user(user_query))
for turn_idx in range(1, self.max_turns + 1):
print(f"🔁 Turn {turn_idx}/{self.max_turns}")
assistant_msg: Message = self.llm.generate(
messages=self._history,
tools=self.tools_schema,
tool_choice=self.tool_choice,
)
print(
f"📤 LLM Assistant Message | "
f"content={assistant_msg.content!r}, "
f"tool_calls={bool(assistant_msg.tool_calls)}"
)
self.add_message(assistant_msg)
# -------------------------------------------------
# 情况 A:最终文本回复(无工具调用)
# -------------------------------------------------
if not assistant_msg.tool_calls:
print("💬 未检测到工具调用,返回最终答案。\n")
return assistant_msg.content or ""
# -------------------------------------------------
# 情况 B:检测到工具调用
# -------------------------------------------------
for tc in assistant_msg.tool_calls:
func = tc["function"]
tool_name = func["name"]
args_text = func["arguments"]
tool_call_id = tc["id"]
print(
f"🧩 检测到工具调用 → "
f"name={tool_name}, id={tool_call_id}, raw_args={args_text}"
)
# 解析参数(OpenAI 保证 arguments 为 JSON 字符串)
try:
args = json.loads(args_text)
print("📦 工具参数解析成功。")
except Exception as e:
print(f"❌ 工具参数解析失败: {e}")
args = {}
# 查找工具对象
tool: Tool | None = self.tool_by_names.get(tool_name)
if not tool:
print("❌ 未找到对应工具!")
raise ToolNotFoundError(f"Unknown tool: {tool_name}")
# 执行工具
print(f"🔧 执行工具 `{tool_name}`,参数={args}")
try:
start = time.perf_counter()
output = tool(**args)
cost = time.perf_counter() - start
print(f"📦 工具输出: {output} | ⏱️ 耗时: {cost:.3f}s")
except Exception as e:
output = f"Tool execution failed: {e}"
print(f"💥 工具执行失败: {e}")
# 将工具结果注入回对话历史
print("📥 将工具结果注入对话历史。")
self.add_message(
Message.tool(
content=str(output),
tool_call_id=tool_call_id,
)
)
# 继续下一轮,让模型基于工具结果继续推理
raise RuntimeError("已达到 max_turns,但仍未生成最终答案")
函数调用智能体实战
py
import time
from vero.core import ChatOpenAI, Agent
from vero.agents import SimpleAgent, OpenAIFunctionAgent
from vero.tool.buildin import math_evaluate, duckduckgo_search
from vero.config import settings
def run_agent(agent_class: Agent, input_text: str, max_turns=5):
llm = ChatOpenAI()
agent: Agent = agent_class(
"test-agent",
llm,
tools=[duckduckgo_search, math_evaluate],
max_turns=max_turns,
)
return agent.run(input_text)
if __name__ == "__main__":
settings.model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"
start = time.perf_counter()
answer = run_agent(
OpenAIFunctionAgent,
"2025年12月13日为止,国内票房最高的三部动画片是什么?导演分别是谁?",
)
print(f"🏁 Final LLM Answer: {answer}\n")
print(f"⏳ Elapsed: {time.perf_counter() - start:.1f} s")
🤖 Initializing LLM with model: Qwen/Qwen3-30B-A3B-Instruct-2507
🚀 Initializing OpenAIFunctionAgent `test-agent` ...
🛠️ Registered tools: [<Tool duckduckgo_search>, <Tool math_evaluate>]
⚙️ Tool choice mode: auto
==============================
👤 User Input: 2025年12月13日为止,国内票房最高的三部动画片是什么?导演分别是谁?
==============================
🔁 Turn 1/5
📤 LLM Assistant Message | content='', tool_calls=True
🧩 Tool call detected → name=duckduckgo_search, id=019b17e045acef53358fa8ae588ace97, raw_args={"query": "2025年12月13日 国内票房最高的三部动画片 导演", "max_results": 5}
📦 Tool arguments parsed successfully.
🔧 Executing tool `duckduckgo_search` with args={'query': '2025年12月13日 国内票房最高的三部动画片 导演', 'max_results': 5}
📦 Tool output: Title: 中国内地最高电影票房收入列表 - 维基百科,自由...
Link: https://zh.wikipedia.org/zh-hans/中国内地最高电影票房收入列表
Snippet: 2 days ago - 下列为电影作品在中国内地电影院上映的票房收入相关列表。票房收入以人民币为单位,不考虑通货膨胀因素,仅以当时售价计量。 ... 下表罗列了2015年《捉妖记》成为中国内地电影总票房冠军之前的华语电影总票房记录...
Title: 哪吒之魔童闹海 - 维基百科,自由的百科全书
Link: https://zh.wikipedia.org/zh-hans/哪吒之魔童闹海
Snippet: 2 weeks ago - 社会人士方面,游戏科学创办人冯骥对本片予以推荐,称本片的故事和技术达到全球顶尖水准,足以称为"国产动画片天花板"。影评人周黎明认为本片依托经典神话的创新改编精准触达年轻群体,其成功源于文化共鸣而非单纯政策激励。《环球时报》前总编辑胡锡进则将本片视为中国动画工业化里程碑,称"中国人仰望《功夫熊猫》那些好莱坞动画片并啧啧称奇的时代结束了"。导演陶海认为本片重视剧本创作但仍有提升空间,表示"'发狠'的人物动机以及'硬拔'式的情节推动是剧作大忌"。漫画家郭竞雄肯定了该片在技术上的成功,但批评其带有"粉红战狼思维"和"抖音化认知"。
Title: 影片总票房排行榜
Link: https://piaofang.maoyan.com/rankings/year
Snippet: 全部 · 2025年 · 2024年 · 2023年 · 2022年 · 2021年 · 2020年 · 2019年 · 2018年 · 2017年
Title: 全球动画电影票房榜
Link: https://piaofang.maoyan.com/i/globalBox/realtimeRank/anime
Snippet: 全球动画电影票房榜 · 2025-12-08 13:26:19 更新 · 影片排名 · 含预售总票房(单位:元) · 哪吒之魔童闹海 · 2025-1-29 中国大陆首映 · 159.49亿 · 中国(含港澳台) · 海外 · 155.05亿
Title: 2025年动画电影为何能够"三连爆"?-中新网
Link: https://www.chinanews.com.cn/cul/2025/12-02/10525112.shtml
Snippet: 电影市场迎来了"动画大年"。2025年的年初、年中、年末,《哪吒之魔童闹海》(以下简称《哪吒2》)、《浪浪山小妖怪》(以下简称《小妖怪》)、《疯狂动物城2》(以下简称《动物城2》)三部动画电影破圈。 · 《哪吒2》以154.46亿元的票房成为中国影史票房榜冠军、全球影史动画票房榜冠军,同时位列2025年全球影史票房榜第五。《小妖怪》以17.19亿元的票房,成为中国2D动画电影票房冠军。《动物城2》上映6日票房突破20亿元(截至12月1日记者发稿数据),成为中国影史进口动画票房冠军,该影片单日吸金突破7亿元,多个平台预测《动物城2》在中国的票房有望突破40亿元。
| ⏱️ Cost: 1.217s
📥 Injecting tool result into conversation history.
🔁 Turn 2/5
📤 LLM Assistant Message | content='根据截至2025年12月13日的票房数据,中国内地票房最高的三部动画片及其导演如下:\n\n1. **《哪吒之魔童闹海》(简称《哪吒2》)** \n - **票房**:159.49亿元人民币 \n - **导演**:饺子(原名杨宇) \n - **备注**:该片不仅成为中国影史票房冠军,也位列全球影史动画电影票房榜首,是2025年全球票房第五的影片。\n\n2. **《浪浪山小妖怪》(简称《小妖怪》)** \n - **票房**:17.19亿元人民币 \n - **导演**:李炜 \n - **备注**:该片是中国2D动画电影票房的冠军,凭借独特的画风与情感共鸣获得广泛好评。\n\n3. **《疯狂动物城2》(简称《动物城2》)** \n - **票房**:预计突破40亿元人民币(截至12月13日已超20亿元,上映仅6天即突破20亿) \n - **导演**:拜伦·霍华德(Byron Howard)、里奇·摩尔(Rich Moore) \n - **备注**:作为进口动画电影,该片成为中国影史进口动画票房冠军,单日票房曾突破7亿元。\n\n> 📌 **说明**:以上信息综合自猫眼专业版、维基百科及中新社报道,数据截至2025年12月13日,其中《动物城2》仍在上映中,最终票房可能进一步上升。', tool_calls=False
💬 No tool calls detected. Returning final answer.
🏁 Final LLM Answer: 根据截至2025年12月13日的票房数据,中国内地票房最高的三部动画片及其导演如下:
1. **《哪吒之魔童闹海》(简称《哪吒2》)**
- **票房**:159.49亿元人民币
- **导演**:饺子(原名杨宇)
- **备注**:该片不仅成为中国影史票房冠军,也位列全球影史动画电影票房榜首,是2025年全球票房第五的影片。
2. **《浪浪山小妖怪》(简称《小妖怪》)**
- **票房**:17.19亿元人民币
- **导演**:李炜
- **备注**:该片是中国2D动画电影票房的冠军,凭借独特的画风与情感共鸣获得广泛好评。
3. **《疯狂动物城2》(简称《动物城2》)**
- **票房**:预计突破40亿元人民币(截至12月13日已超20亿元,上映仅6天即突破20亿)
- **导演**:拜伦·霍华德(Byron Howard)、里奇·摩尔(Rich Moore)
- **备注**:作为进口动画电影,该片成为中国影史进口动画票房冠军,单日票房曾突破7亿元。
> 📌 **说明**:以上信息综合自猫眼专业版、维基百科及中新社报道,数据截至2025年12月13日,其中《动物城2》仍在上映中,最终票房可能进一步上升。
⏳ Elapsed: 12.8 s