尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从零实现AI工具调用:基于OpenAI Function Calling的智能体开发指南

从零实现AI工具调用:基于OpenAI Function Calling的智能体开发指南 你开发的AI助手是不是还停留在“动嘴皮子”的阶段你问它“现在几点”它只能回答“我无法获取实时信息”你让它“计算一下这个月的开销”它也只能抱歉地说“我没有计算功能”。这种对话体验就像和一个知识渊博但手脚被束缚的专家聊天他知道一切却什么也做不了。问题的核心在于大语言模型LLM本身是一个“纯文本”的预测引擎。它擅长理解和生成语言但天生缺乏执行外部动作、获取实时数据或操作系统的能力。这就是为什么我们需要“工具调用”Tool Use或“函数调用”Function Calling技术。这不仅仅是给AI加一个插件那么简单而是从根本上改变了AI与世界的交互范式——从“被动应答”转向“主动执行”。本文将带你从零开始为你的AI助手装上“手”实现真正的工具调用能力。我们将聚焦于最核心、最通用的实现方案基于OpenAI格式的Function Calling与本地工具执行。无论你是想集成网络搜索、数据库查询、发送邮件还是控制智能家居其底层逻辑都是相通的。读完本文你将能透彻理解工具调用的核心原理与工作流程Agent的“思考-行动”循环。亲手实现一个支持多工具调用的本地AI助手后端。掌握最佳实践包括错误处理、状态管理、安全边界等工程化细节。我们不会停留在概念而是通过一个完整的、可运行的示例项目让你看到代码如何一步步将“想法”转化为“行动”。让我们开始吧。1. 工具调用AI从“大脑”到“手脚”的关键一跃在深入代码之前我们必须先厘清一个根本问题为什么工具调用如此重要它解决了什么痛点传统AI助手的局限传统的聊天机器人或基于Prompt的AI其能力完全封闭在训练数据之内。它无法获取实时信息如天气、股价、新闻。操作外部系统如创建日历事件、发送邮件、查询数据库。执行复杂计算虽然能做简单算术但涉及复杂公式或需要调用专业库如numpy时便无能为力。与用户环境交互如读取本地文件、分析上传的图片。工具调用带来的范式转变工具调用将LLM定位为“决策中心”或“大脑”而将各种API、函数、脚本定义为可供其调用的“工具”或“手脚”。LLM的工作流程变为理解用户意图。规划判断是否需要调用工具、调用哪个工具、传入什么参数。执行以标准化格式如JSON请求外部工具执行。观察接收工具执行的结果。整合将结果整合进自然语言回复返回给用户。这个过程被称为“ReAct”Reasoning and Acting模式是智能体Agent最基础也最核心的运行逻辑。本文实现的正是这样一个具备ReAct能力的AI助手内核。2. 核心概念与架构理解OpenAI Function Calling要实现工具调用我们需要一个标准化的“协议”让LLM和外部工具能互相理解。OpenAI的Function Calling规范因其简洁和广泛的支持已成为事实上的行业标准。2.1 核心角色LLM大语言模型负责理解、规划和决策。它根据对话历史和用户问题决定是否调用工具及如何调用。工具Tool/Function任何可执行的外部功能。例如get_current_time,search_web,calculate_expression。每个工具都有明确的名称、描述和参数规范。工具执行器Tool Executor负责接收LLM的调用指令定位对应的工具函数传入参数并执行最后将结果返回给LLM。2.2 通信格式JSON SchemaLLM如何知道有哪些工具可用每个工具需要什么参数答案是通过JSON Schema来描述。一个工具的描述通常包含name: 工具名称LLM通过这个名称来指定调用哪个工具。description: 工具功能的自然语言描述。这个描述至关重要它直接决定了LLM是否能在正确场景下选择该工具。parameters: 一个遵循JSON Schema的对象定义了工具所需的参数及其类型、是否必填、描述等。示例一个获取时间的工具描述{ name: get_current_time, description: 获取当前的日期和时间。当用户询问时间、日期、今天星期几或现在几点时调用此工具。, parameters: { type: object, properties: { timezone: { type: string, description: 时区例如 Asia/Shanghai 或 UTC。如果用户未指定默认为 Asia/Shanghai。 } }, required: [] } }2.3 工作流程ReAct循环一次完整的工具调用交互通常包含两轮API通信第一轮LLM决定调用工具用户输入“上海现在几点了”你将工具列表以JSON Schema格式和用户消息一起发送给LLM。LLM返回一个结构化响应其中包含它决定调用的工具名称和参数{tool_call: “get_current_time”, “arguments”: {timezone: Asia/Shanghai}}。注意此时LLM并没有真正执行工具它只是“说”它想调用。第二轮执行并返回结果你的程序工具执行器解析LLM的响应找到本地的get_current_time函数传入参数timezoneAsia/Shanghai并执行。获取执行结果例如2024-05-27 15:30:00 CST。你将这个工具执行结果作为一条新的“系统”或“工具”消息连同之前的对话历史再次发送给LLM。LLM收到工具结果后生成最终面向用户的自然语言回复“上海现在是下午3点30分。”这个“用户提问 - LLM决定调用 - 执行工具 - 返回结果 - LLM整合回复”的循环就是智能体Agent与普通聊天机器人的本质区别。3. 环境准备与项目初始化我们将使用Python作为开发语言因为它拥有最丰富的AI生态库。本项目不依赖特定云服务可以在本地完全运行。3.1 基础环境Python 版本 3.8包管理工具pipIDEVS Code, PyCharm 或任何你熟悉的编辑器。3.2 核心依赖库我们将使用openai库兼容多种本地模型API和pydantic库用于数据验证和序列化。创建项目目录并初始化虚拟环境mkdir ai-assistant-with-tools cd ai-assistant-with-tools python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # Linux/Mac: source venv/bin/activate # 安装核心依赖 pip install openai pydantic3.3 选择LLM后端你可以选择OpenAI API最稳定对Function Calling支持最好。需要API Key。兼容OpenAI API的本地模型如使用ollama,lmstudio,vllm等部署的本地模型。它们通常提供了与OpenAI兼容的API端点。其他云厂商API如DeepSeek, 智谱AI等许多也支持类似功能。为了演示的通用性我们将以OpenAI格式的API为例。如果你使用本地模型只需将代码中的base_url和api_key替换为你本地服务的地址即可。创建一个.env文件来管理配置记得将其加入.gitignore# .env OPENAI_API_KEYyour_openai_api_key_here # 如果使用OpenAI OPENAI_BASE_URLhttps://api.openai.com/v1 # 如果使用本地模型改为如 http://localhost:11434/v1 MODEL_NAMEgpt-3.5-turbo # 或你本地模型的名称如 qwen2.5:7b安装python-dotenv来读取环境变量pip install python-dotenv4. 核心流程拆解与实现我们的目标是构建一个Agent类它能够管理对话历史、维护工具列表、与LLM交互并执行工具调用循环。4.1 定义工具基类与示例工具首先我们需要一个统一的方式来定义和描述工具。我们将使用Pydantic来创建强类型的工具定义。创建一个tools.py文件# tools.py from datetime import datetime import json from typing import Any, Dict, Type, Optional from pydantic import BaseModel, Field import requests # 工具参数的基类 class ToolParameters(BaseModel): 所有工具参数模型的基类 pass # 工具调用的结果 class ToolResult(BaseModel): content: Any # 工具执行返回的原始内容 is_error: bool False error_message: Optional[str] None # 工具基类 class BaseTool: name: str description: str parameters_model: Type[ToolParameters] ToolParameters # 参数对应的Pydantic模型 def __init__(self): if not self.name: raise ValueError(Tool must have a name) def get_schema(self) - Dict[str, Any]: 生成符合OpenAI Function Calling格式的JSON Schema schema self.parameters_model.model_json_schema() # 清理schema使其符合OpenAI格式 cleaned_schema { type: object, properties: schema.get(properties, {}), required: schema.get(required, []) } # 移除Pydantic特有的title字段 cleaned_schema[properties] { k: {sub_k: sub_v for sub_k, sub_v in v.items() if sub_k ! title} for k, v in cleaned_schema[properties].items() } return { type: function, function: { name: self.name, description: self.description, parameters: cleaned_schema } } async def execute(self, **kwargs) - ToolResult: 执行工具的核心方法子类必须实现 raise NotImplementedError # 具体工具实现 # 1. 获取当前时间工具 class GetCurrentTimeParams(ToolParameters): timezone: str Field(defaultAsia/Shanghai, description时区例如 Asia/Shanghai 或 UTC) class GetCurrentTimeTool(BaseTool): name get_current_time description 获取当前的日期和时间。当用户询问时间、日期、今天星期几或现在几点时调用此工具。 parameters_model GetCurrentTimeParams async def execute(self, timezone: str Asia/Shanghai) - ToolResult: try: # 这里简化处理实际应根据timezone计算时间 # 可以使用pytz库进行更精确的时区转换 current_time datetime.now().strftime(%Y-%m-%d %H:%M:%S) return ToolResult(contentf当前时间{timezone}: {current_time}) except Exception as e: return ToolResult(contentNone, is_errorTrue, error_messagestr(e)) # 2. 计算器工具 class CalculateParams(ToolParameters): expression: str Field(description数学表达式例如 2 3 * (4 - 1)) class CalculatorTool(BaseTool): name calculate description 计算一个数学表达式的结果。支持加减乘除和括号。 parameters_model CalculateParams async def execute(self, expression: str) - ToolResult: try: # 警告直接使用eval有安全风险仅用于演示。 # 在生产环境中应使用安全的表达式求值库如 asteval或进行严格的白名单过滤。 result eval(expression, {__builtins__: {}}, {}) return ToolResult(contentf{expression} {result}) except Exception as e: return ToolResult(contentNone, is_errorTrue, error_messagef计算表达式 {expression} 时出错: {str(e)}) # 3. 网络搜索工具示例需要API Key class WebSearchParams(ToolParameters): query: str Field(description搜索查询关键词) max_results: int Field(default3, description返回的最大结果数量) class WebSearchTool(BaseTool): name search_web description 在互联网上搜索信息。当用户询问实时新闻、最新事件或未知知识时使用此工具。 parameters_model WebSearchParams async def execute(self, query: str, max_results: int 3) - ToolResult: # 此处为示例实际需要接入搜索引擎API如SerperAPI、Google Custom Search等 # 这里模拟返回结果 mock_results [ f关于 {query} 的结果 1: 这是模拟的摘要内容。, f关于 {query} 的结果 2: 另一个模拟的摘要。, ] return ToolResult(content\n.join(mock_results[:max_results])) # 工具注册表 TOOL_REGISTRY: Dict[str, BaseTool] {} def register_tool(tool_class: Type[BaseTool]): 注册工具到全局注册表 tool_instance tool_class() TOOL_REGISTRY[tool_instance.name] tool_instance return tool_class # 注册我们定义的工具 register_tool(GetCurrentTimeTool) register_tool(CalculatorTool) register_tool(WebSearchTool) def get_all_tool_schemas() - list: 获取所有已注册工具的JSON Schema列表 return [tool.get_schema() for tool in TOOL_REGISTRY.values()]4.2 构建智能体Agent核心接下来创建agent.py文件实现Agent的核心循环逻辑。# agent.py import json from typing import List, Dict, Any, Optional from openai import OpenAI from pydantic import BaseModel import asyncio from tools import TOOL_REGISTRY, ToolResult # 配置从环境变量读取 import os from dotenv import load_dotenv load_dotenv() class Message(BaseModel): 对话消息 role: str # system, user, assistant, tool content: Optional[str] None tool_calls: Optional[List[Dict]] None # 仅当roleassistant且包含工具调用时 tool_call_id: Optional[str] None # 仅当roletool时 name: Optional[str] None # 仅当roletool时工具名称 class Agent: def __init__(self, model: str None, api_key: str None, base_url: str None): self.model model or os.getenv(MODEL_NAME, gpt-3.5-turbo) api_key api_key or os.getenv(OPENAI_API_KEY) base_url base_url or os.getenv(OPENAI_BASE_URL, https://api.openai.com/v1) self.client OpenAI(api_keyapi_key, base_urlbase_url) self.conversation_history: List[Message] [] # 初始化系统提示词告诉模型它可以使用的工具 self.system_prompt 你是一个有帮助的AI助手可以调用工具来获取信息或执行任务。 你可以使用的工具如下 - get_current_time: 获取当前时间。 - calculate: 计算数学表达式。 - search_web: 在互联网上搜索信息。 如果用户的问题需要调用工具请直接调用相应的工具。 调用工具时请确保参数完整且格式正确。 工具返回结果后请根据结果用友好、自然的方式回答用户。 self._init_conversation() def _init_conversation(self): 初始化对话历史加入系统提示 self.conversation_history [ Message(rolesystem, contentself.system_prompt) ] def _format_messages_for_api(self) - List[Dict[str, Any]]: 将内部Message格式转换为OpenAI API所需的格式 formatted_messages [] for msg in self.conversation_history: formatted_msg {role: msg.role, content: msg.content or } # 处理助手消息中的工具调用 if msg.role assistant and msg.tool_calls: formatted_msg[tool_calls] msg.tool_calls # 处理工具返回消息 if msg.role tool: formatted_msg[tool_call_id] msg.tool_call_id formatted_msg[name] msg.name formatted_messages.append(formatted_msg) return formatted_messages async def _call_llm(self, tools: List[Dict] None) - Dict[str, Any]: 调用LLM API messages self._format_messages_for_api() try: response self.client.chat.completions.create( modelself.model, messagesmessages, toolstools, # 传入工具定义 tool_choiceauto, # 让模型自动决定是否调用工具 ) return response.choices[0].message except Exception as e: raise Exception(f调用LLM API失败: {str(e)}) async def _execute_tool(self, tool_name: str, arguments: Dict) - ToolResult: 执行指定的工具 if tool_name not in TOOL_REGISTRY: return ToolResult( contentNone, is_errorTrue, error_messagef工具 {tool_name} 未注册或不可用。 ) tool TOOL_REGISTRY[tool_name] try: # 使用Pydantic模型验证参数 params_model tool.parameters_model validated_args params_model(**arguments).model_dump() result await tool.execute(**validated_args) return result except Exception as e: return ToolResult( contentNone, is_errorTrue, error_messagef执行工具 {tool_name} 时参数验证或执行出错: {str(e)} ) async def process_user_input(self, user_input: str) - str: 处理用户输入的主循环 # 1. 将用户输入加入历史 self.conversation_history.append(Message(roleuser, contentuser_input)) # 获取所有可用工具的schema available_tools [tool.get_schema() for tool in TOOL_REGISTRY.values()] max_turns 5 # 防止无限循环 for turn in range(max_turns): # 2. 调用LLM llm_response await self._call_llm(toolsavailable_tools if turn 0 else None) # 3. 检查LLM响应是否包含工具调用 tool_calls getattr(llm_response, tool_calls, None) if not tool_calls: # 没有工具调用直接返回LLM的回复 assistant_reply llm_response.content self.conversation_history.append( Message(roleassistant, contentassistant_reply) ) return assistant_reply # 4. LLM要求调用工具 # 将助手的工具调用请求加入历史 assistant_msg Message( roleassistant, contentllm_response.content, tool_calls[tc.model_dump() for tc in tool_calls] # 转换Pydantic模型为字典 ) self.conversation_history.append(assistant_msg) # 5. 并行执行所有被请求的工具 tool_results [] for tc in tool_calls: tool_name tc.function.name try: arguments json.loads(tc.function.arguments) except json.JSONDecodeError: arguments {} result await self._execute_tool(tool_name, arguments) tool_results.append((tc.id, tool_name, result)) # 6. 将所有工具执行结果作为“tool”角色消息加入历史 for tool_call_id, tool_name, result in tool_results: tool_message Message( roletool, tool_call_idtool_call_id, nametool_name, contentjson.dumps({ result: result.content, is_error: result.is_error, error_message: result.error_message }) if result.content is not None else json.dumps({ error: result.error_message }) ) self.conversation_history.append(tool_message) # 7. 循环继续下一轮LLM将看到工具执行结果并生成最终回复 # 注意这里不break继续下一轮循环 # 如果循环达到最大轮次仍未得到最终回复返回超时提示 return 对话轮次过多可能陷入了循环。请尝试重新表述您的问题。 def reset(self): 重置对话历史 self._init_conversation()4.3 创建主程序与交互界面最后创建一个main.py文件作为程序入口提供一个简单的命令行交互界面。# main.py import asyncio import sys from agent import Agent async def main(): print( * 50) print(AI助手工具调用版已启动) print(支持的工具获取时间、数学计算、网络搜索模拟) print(输入 quit 或 exit 退出程序) print(输入 reset 清空对话历史) print( * 50) agent Agent() while True: try: user_input input(\n您: ).strip() if user_input.lower() in [quit, exit, q]: print(再见) break if user_input.lower() reset: agent.reset() print(对话历史已清空。) continue if not user_input: continue print(助手: 思考中..., end\r) response await agent.process_user_input(user_input) print(f助手: {response}) except KeyboardInterrupt: print(\n\n程序被中断。) break except Exception as e: print(f\n发生错误: {e}) if __name__ __main__: asyncio.run(main())5. 运行与效果验证现在让我们运行这个AI助手看看它如何从“只会动嘴”变成“能查会算”。5.1 启动程序在项目根目录下确保虚拟环境已激活然后运行python main.py5.2 测试对话程序启动后你可以尝试以下对话测试1查询时间您: 现在几点了 助手: 思考中...稍等片刻 助手: 当前时间Asia/Shanghai: 2024-05-27 16:45:22幕后过程LLM识别出意图调用了get_current_time工具执行后获得时间字符串最后整合成回复。测试2数学计算您: 帮我算一下 (15 7) * 3 等于多少 助手: (15 7) * 3 66幕后过程LLM识别出数学表达式调用calculate工具工具使用eval计算演示用生产环境需替换返回结果。测试3组合任务您: 先告诉我现在时间然后计算从2020年1月1日到今天有多少天。 助手: 当前时间Asia/Shanghai: 2024-05-27 16:46:10。关于计算天数我需要更具体的日期计算工具目前我只有基础计算器无法直接计算日期差。您可以提供两个具体日期我帮您计算差值吗幕后过程LLM识别出两个意图。它先调用get_current_time然后发现第二个请求无法用现有工具完成于是诚实地告知了限制并尝试提供替代方案。这展示了Agent的规划能力。测试4触发网络搜索模拟您: 搜索一下今天AI领域有什么新闻。 助手: 关于 今天AI领域新闻 的结果 1: 这是模拟的摘要内容。 关于 今天AI领域新闻 的结果 2: 另一个模拟的摘要。幕后过程LLM识别出对实时信息的需求调用了search_web工具。由于我们实现的是模拟版本返回了预设结果。你可以轻松替换为真实的搜索引擎API。5.3 验证关键点运行测试时请关注以下方面以确保工具调用正常工作工具选择准确性LLM是否在正确的场景下选择了正确的工具参数解析正确性LLM是否生成了格式正确、内容完整的参数JSON执行循环对于复杂问题Agent是否进行了多轮“思考-行动”循环错误处理当工具执行出错或参数不合法时Agent是否能给出合理的错误反馈6. 常见问题与排查思路在开发和使用工具调用AI助手时你可能会遇到以下典型问题问题现象可能原因排查方式解决方案LLM不调用工具直接回答1. 工具描述description不清晰。2. 系统提示词未引导模型使用工具。3. 模型能力不足某些小参数模型对工具调用支持差。1. 检查工具description是否准确描述了使用场景。2. 在系统提示词中明确要求模型使用工具。3. 尝试更复杂的提问或换用更强的模型如GPT-4。1. 优化工具描述使用“当用户询问...时调用”的句式。2. 在系统提示词中加入“你必须使用工具来回答问题”。3. 升级模型或使用专门微调过工具调用的模型。工具调用参数错误或缺失1. LLM未能从用户问题中提取完整参数。2. 参数JSON Schema定义过于复杂或模糊。1. 查看LLM返回的argumentsJSON字符串是否完整。2. 简化参数定义使用更明确的description。1. 在对话历史中提供更详细的上下文。2. 为参数设置合理的default值和清晰的描述。工具执行失败1. 工具函数内部代码有bug。2. 依赖的服务如搜索API不可用。3. 参数验证失败。1. 在工具execute方法内添加详细的日志和异常捕获。2. 检查网络连接和API密钥。3. 检查Pydantic模型验证错误。1. 对工具函数进行单元测试。2. 实现重试机制和降级策略。3. 在ToolResult中返回清晰的错误信息供LLM处理。对话陷入无限循环1. 工具返回的结果无法让LLM生成最终答案。2. 最大循环次数设置过高或未设置。1. 观察每轮对话历史看工具结果是否有效。2. 检查代码中的max_turns限制。1. 优化工具返回结果的格式和内容使其更易于理解。2. 务必设置最大循环次数如5-10次并在达到时终止。本地模型响应慢或格式不符1. 本地模型未正确支持OpenAI的tool_calls格式。2. 模型输出被错误解析。1. 直接打印LLM API的原始响应检查其结构。2. 查阅本地模型服务的文档确认其Function Calling支持情况。1. 可能需要适配非标准的响应格式。2. 考虑使用LangChain、LlamaIndex等框架它们集成了多种模型的工具调用适配器。7. 最佳实践与工程化建议将原型转化为健壮、可维护的生产级应用需要考虑以下方面7.1 工具设计与描述单一职责每个工具应只做一件事并做好。避免创建“万能工具”。描述即指令工具的description字段是给LLM看的“使用说明书”。务必清晰、具体包含典型使用场景。例如“当用户需要计算两个日期之间的天数差时调用此工具。”安全的参数验证始终使用像Pydantic这样的库进行参数验证和类型转换。永远不要将未经处理的用户输入直接传递给eval()或exec()等危险函数。计算器工具示例中的eval仅用于演示生产环境必须替换。7.2 会话与状态管理控制历史长度长时间对话会导致token数激增增加成本和延迟。需要实现对话历史摘要或滑动窗口。分离核心逻辑与状态将Agent的推理逻辑与对话状态存储如数据库解耦便于扩展和持久化。用户会话隔离为每个用户或对话会话创建独立的Agent实例或会话ID避免状态混淆。7.3 安全与边界工具权限控制不是所有用户都能调用所有工具。实现基于用户角色或上下文的工具权限过滤。输入输出过滤与审查对工具接收的参数和返回的内容进行安全检查防止注入攻击或输出不当内容。设置资源限制对计算密集型、网络IO型工具设置超时和调用频率限制。审计日志记录所有的工具调用请求、参数和结果便于问题追踪和安全审计。7.4 性能与可靠性异步执行如示例所示使用async/await处理IO密集型工具调用如网络请求避免阻塞主线程。并行化工具调用当LLM同时请求多个独立工具时应并行执行以降低延迟。实现重试与降级对于可能失败的工具如外部API实现指数退避的重试机制和优雅降级方案。使用缓存对结果变化不频繁的工具如某些查询可以添加缓存层减少不必要的调用。7.5 扩展性动态工具注册允许在运行时动态添加或移除工具而无需重启服务。工具发现与文档可以创建一个元工具让AI助手能向用户解释自己具备哪些能力。工作流编排对于复杂任务可以引入工作流引擎将多个工具调用按特定顺序和逻辑组织起来超越简单的ReAct循环。8. 总结与进阶方向通过本文我们完成了一个具备工具调用能力的AI助手从零到一的构建。你现在已经掌握了其核心原理LLM作为决策大脑通过标准化的Function Calling协议调度外部工具执行并通过多轮对话整合结果。这个基础的ReAct循环是构建一切更复杂智能体Agent的基石。在此基础上你可以向多个方向深入集成更强大的工具将数据库客户端、企业内部API、云服务SDK、硬件控制接口等封装成工具极大扩展AI的能力边界。引入记忆机制为Agent添加长期记忆向量数据库和短期记忆精炼的对话历史使其能进行更深度的多轮对话和个性化服务。实现复杂规划让Agent不仅能调用单个工具还能为复杂目标制定多步骤计划Plan并动态调整。探索多智能体协作创建多个具有不同专长的Agent让它们通过通信协作解决超复杂问题。使用成熟框架当项目复杂度提升时考虑采用LangChain、LlamaIndex、AutoGen等开源框架。它们提供了更高级的Agent抽象、丰富的工具集成、以及稳定的底层通信机制能让你更专注于业务逻辑而非基础设施。记住工具调用的本质是连接LLM的“智能”与外部世界的“能力”。成功的AI应用往往是找到了一个高价值、高频率的场景并通过精心设计的工具将LLM的通用语言理解能力转化为具体、可靠的生产力。现在你的AI助手已经拥有了“手”是时候为它规划更有价值的任务了。
返回列表