Function Calling 与 Python 实战完整指南
技术手册 | OpenAI Responses API、LangChain / LangGraph、PydanticAI、MCP
Function Calling 让模型负责"理解意图与生成参数",让应用程序负责"验证、授权、执行与记录"。它不是模型直接执行函数,而是一套由应用编排的结构化协议。
目录
- 概念与边界
- 完整调用链路
- [Tool Schema:给模型的契约](#Tool Schema:给模型的契约)
- [完整可运行样例:OpenAI Python SDK](#完整可运行样例:OpenAI Python SDK)
- [Python 框架怎么选](#Python 框架怎么选)
- 四种常见实现模式
- 安全、可靠性与成本控制
- 排错与测试方法
- 上线前清单
概念与边界
工具调用适合把语言模型接到确定性能力上:查数据库、调 HTTP 接口、读取内部知识、创建工单、执行受控操作。模型给出调用建议,业务代码才是唯一的执行者。
三个职责
| 环节 | 职责 | 说明 |
|---|---|---|
| 模型选择 | 选择工具、生成 JSON 参数 | 根据用户问题和工具描述输出工具名称与参数,不应自己编造外部系统结果。 |
| 应用执行 | 验证、鉴权、限流、调用真实函数 | 服务器解析参数后决定是否执行;高风险动作必须要求用户确认。 |
| 结果回注 | 将工具结果交还模型 | 以协议消息回传结果,再由模型结合上下文组织最终回答。 |
核心原则: Function Calling 不是 RPC 的替代品,也不是"让大模型拥有数据库权限"。模型只提出意图;工具层必须决定该请求是否可执行、以谁的身份执行、可见哪些字段、能否产生副作用。
完整调用链路
一次成功的调用至少有两次模型交互:第一次产生 function_call,第二次消费 function_call_output 后回答用户。模型的 arguments 永远应当视作不可信输入。
text
用户问题
"查一下 A1001 订单"
|
v
模型返回调用建议
get_order(order_id="A1001")
|
v
应用校验并执行
解析 -> 鉴权 -> 查询 -> 审计
|
v
工具结果回传
function_call_output (携带对应 call_id)
|
v
模型最终回答
"订单已支付,预计明天送达"
完整循环的关键点:
- 应用把可用工具及其 JSON Schema 发送给模型。
- 模型返回一个或多个
function_call项,其中包含name、arguments与call_id。 - 应用仅从白名单中选择工具,并对
arguments进行结构、类型、权限和业务规则校验。 - 应用执行工具,把每个结果作为
function_call_output回传给对应的call_id。 - 模型基于工具结果输出最后的自然语言回答;若需要,可继续请求工具。
Tool Schema:给模型的契约
一个工具定义通常含 type、name、description 和 JSON Schema 参数。名称用动词开头;描述说清何时使用、何时不用;参数必须写清单位、枚举和限制。
好工具的共同点
- 粒度恰当:
get_order比万能的execute_sql更可控。 - 输入有界:用
enum、minimum、pattern限制可选值。 - 语义不重叠:不要同时提供
search_user与含糊的find。 - 返回结构稳定:工具输出 JSON,包含成功状态、数据和可给用户展示的错误码。
不要依赖模型保证的事项
- 不要假设必填字段一定存在或类型正确。
- 不要把参数直接拼进 SQL、Shell 或 URL。
- 不要把账号、令牌、内部异常栈回传给模型。
- 不要让删除、支付、发消息等操作无确认地执行。
一个受约束的工具定义
python
TOOLS = [{
"type": "function",
"name": "get_order",
"description": "查询单个订单的状态和物流。仅在用户提供订单号时使用;不能用于退款或修改订单。",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单号,格式为一个大写字母后接 4 位数字,例如 A1001",
"pattern": "^[A-Z][0-9]{4}$"
}
},
"required": ["order_id"],
"additionalProperties": False
},
"strict": True
}]
完整可运行样例:OpenAI Python SDK
这个样例实现订单查询与退款申请两种工具,包含多轮循环、Pydantic 参数校验、模拟鉴权、工具错误回传和高风险动作确认。需要 Python 3.10+、openai 与 pydantic。
安装依赖并设置密钥:
bash
pip install openai pydantic
export OPENAI_API_KEY="你的密钥"
将以下代码保存为 function_call_demo.py,然后执行 python function_call_demo.py。
python
import json
import os
from typing import Literal
from openai import OpenAI
from pydantic import BaseModel, ConfigDict, Field, ValidationError
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
# 真实项目中这些函数应访问受控的业务服务,不应把数据库账号交给模型。
ORDERS = {
"A1001": {"status": "已支付", "item": "无线耳机", "delivery": "预计明天送达"},
"B2002": {"status": "已签收", "item": "机械键盘", "delivery": "已于 7 月 24 日签收"},
}
class GetOrderArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
order_id: str = Field(pattern=r"^[A-Z][0-9]{4}$")
class RequestRefundArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
order_id: str = Field(pattern=r"^[A-Z][0-9]{4}$")
reason: Literal["不想要了", "商品损坏", "发错商品"]
# 必须由应用层确认,模型不能自行替用户确认。
user_confirmed: bool
TOOLS = [
{
"type": "function",
"name": "get_order",
"description": "查询订单状态、商品和物流。用户给出订单号时使用。",
"parameters": GetOrderArgs.model_json_schema(),
"strict": True,
},
{
"type": "function",
"name": "request_refund",
"description": "为已支付订单提交退款申请。只在用户明确提出退款且确认原因后使用。",
"parameters": RequestRefundArgs.model_json_schema(),
"strict": True,
},
]
def get_order(args: GetOrderArgs, user_id: str) -> dict:
# 示例鉴权:真实项目应使用会话/JWT 的订单归属校验。
order = ORDERS.get(args.order_id)
if not order:
return {"ok": False, "code": "ORDER_NOT_FOUND", "message": "未找到该订单"}
return {"ok": True, "order_id": args.order_id, **order}
def request_refund(args: RequestRefundArgs, user_id: str) -> dict:
if not args.user_confirmed:
return {"ok": False, "code": "CONFIRMATION_REQUIRED", "message": "退款需要用户明确确认"}
order = ORDERS.get(args.order_id)
if not order:
return {"ok": False, "code": "ORDER_NOT_FOUND", "message": "未找到该订单"}
if order["status"] != "已支付":
return {"ok": False, "code": "REFUND_NOT_ALLOWED", "message": "当前订单状态不能申请退款"}
# 真实项目:此处调用幂等的退款服务,并记录 request_id、用户和审计日志。
return {"ok": True, "refund_request_id": "R-90001", "message": "退款申请已提交"}
TOOL_HANDLERS = {
"get_order": (GetOrderArgs, get_order),
"request_refund": (RequestRefundArgs, request_refund),
}
def execute_tool(name: str, raw_arguments: str, user_id: str) -> dict:
"""模型输出只是一份候选参数;在这里完成解析、白名单和校验。"""
handler = TOOL_HANDLERS.get(name)
if not handler:
return {"ok": False, "code": "TOOL_NOT_ALLOWED", "message": "未授权的工具"}
model, function = handler
try:
args = model.model_validate_json(raw_arguments)
except ValidationError as exc:
return {"ok": False, "code": "INVALID_ARGUMENTS", "message": str(exc.errors())}
try:
return function(args, user_id=user_id)
except Exception:
# 日志可保留完整异常;不要把敏感堆栈交给模型或用户。
return {"ok": False, "code": "TOOL_EXECUTION_FAILED", "message": "服务暂时不可用"}
def answer(user_text: str, user_id: str = "demo-user") -> str:
conversation = [{"role": "user", "content": user_text}]
for _ in range(5): # 防止模型/工具意外循环
response = client.responses.create(
model="gpt-4.1-mini",
instructions="你是订单助手。仅根据工具结果回答;没有工具结果时不要编造订单状态。",
input=conversation,
tools=TOOLS,
)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
return response.output_text
# 保留模型本轮输出,再把每个 call_id 对应的结果回传给下一轮。
conversation.extend(response.output)
for call in calls:
result = execute_tool(call.name, call.arguments, user_id)
print(f"[audit] tool={call.name} result={result}")
conversation.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result, ensure_ascii=False),
})
return "请求步骤超过限制,请稍后重试。"
if __name__ == "__main__":
print(answer("请查一下 A1001 订单的状态"))
print(answer("我要退款 A1001,原因是商品损坏,已确认提交"))
运行时会发生什么
- 第一次
responses.create返回一个或多个function_call。 - 程序用
call.name查白名单,以call.arguments做 Pydantic 校验。 - 工具结果写回匹配的
call.call_id。 - 下一次模型调用根据工具结果输出用户答案。
查询 A1001 时控制台会看到 get_order 的审计记录;退款例子会看到 request_refund 的审计记录。若模型未明确传入 user_confirmed=true,业务函数会拒绝执行。
Python 框架怎么选
不要为了"有 Agent"而上复杂编排。一个或两个工具的同步工作流,直接用模型官方 SDK 最透明;有状态图、人工介入或多步骤恢复需求,再引入框架。
| 方案 | 适用情况 | 工具定义方式 | 取舍 |
|---|---|---|---|
| OpenAI Python SDK | 最小闭环、需要完全掌控协议 | JSON Schema / Pydantic Schema | 推荐起步,依赖少,循环需自行维护。 |
| PydanticAI | Python 类型优先、依赖注入、快速业务 Agent | @agent.tool + 类型注解 |
类型友好,框架抽象较多。 |
| LangChain | 已有 LangChain 生态、基础 tool binding | @tool / Pydantic |
生态广,需理解消息格式。 |
| LangGraph | 长流程、状态机、人工审批、恢复执行 | 复用 LangChain tools | 适合生产编排,学习和运行复杂度更高。 |
| MCP Python SDK | 将工具独立为可复用服务,供多个客户端使用 | @mcp.tool() |
它是协议/服务层,不替代模型循环。 |
| LlamaIndex | 以数据检索、索引和 RAG 为主的应用 | FunctionTool / QueryEngineTool | RAG 集成强,不适合作为纯工具层首选。 |
PydanticAI:类型化工具
python
from pydantic_ai import Agent
agent = Agent("openai:gpt-4.1-mini")
@agent.tool_plain
def get_weather(city: str) -> str:
"""查询城市天气;city 必须是城市名称。"""
return f"{city}:晴,26°C"
result = agent.run_sync("北京天气如何?")
print(result.output)
LangChain:绑定工具
python
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
@tool
def get_weather(city: str) -> str:
"""查询城市天气。"""
return f"{city}:晴,26°C"
llm = ChatOpenAI(model="gpt-4.1-mini")
ai_msg = llm.bind_tools([get_weather]).invoke("北京天气?")
# ai_msg.tool_calls 是调用建议;应用仍需执行并回传 ToolMessage。
MCP:把能力发布成工具服务
python
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("order-tools")
@mcp.tool()
def get_order(order_id: str) -> dict:
"""查询订单状态。"""
return {"order_id": order_id, "status": "已支付"}
if __name__ == "__main__":
mcp.run(transport="stdio")
LangGraph:显式状态编排
python
# 核心思想:把模型、工具、审批设计成图中的节点。
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
agent = create_react_agent(
ChatOpenAI(model="gpt-4.1-mini"),
tools=[get_weather],
)
state = agent.invoke({"messages": [("user", "北京天气?")]})
四种常见实现模式
| 模式 | 用法 | 注意事项 |
|---|---|---|
| 单轮查询 | 模型调用一个只读工具后直接回答;适合天气、订单、库存、指标查询。 | 简单、低延迟,应优先采用。 |
| 并行只读调用 | 模型一次输出多个独立调用,例如查航班与天气。 | 应用可并发执行,但每条结果必须回传原始 call_id。 |
| 多步工具循环 | 前一个结果决定下一次调用,例如先检索客户,再查询其订单。 | 限制轮数和每轮工具数,防止循环与成本失控。 |
| 确认式副作用 | 创建退款、发邮件、删除资源等先生成待执行计划。 | 展示关键字段,收到用户确认后才调用写操作工具。 |
所有模式都应有最大轮数、超时和可观测日志。
安全、可靠性与成本控制
工具调用将语言输入连接到系统边界,安全控制不能只放在提示词中。
必须在工具层实现
- 工具白名单和按用户/租户的授权校验。
- JSON Schema 与服务端类型/业务规则的双重校验。
- 数据库参数化查询、命令参数白名单、URL 域名允许列表。
- 超时、重试、熔断、幂等键和最大调用轮数。
- 调用审计:用户、工具、参数摘要、结果码、耗时、
request_id。
敏感信息处理
- 密钥仅从环境变量或密钥服务读取,不写入 prompt、代码或工具输出。
- 对工具输出做字段级脱敏,只返回回答必需的数据。
- 工具异常对模型返回稳定错误码,完整堆栈仅写内部日志。
- 高风险动作添加二次确认、额度限制或人工审批节点。
- 将外部网页、邮件、检索内容视作不可信数据,防范提示注入。
反例: 把
run_sql(sql: str)、shell(command: str)或带管理员凭据的 HTTP 通道直接提供给模型,等于把模型生成文本升级为系统执行权限。应改为领域工具,例如get_monthly_sales(region, month),并在服务端固定查询模板。
排错与测试方法
先记录结构化调用事件,后调模型描述。大多数问题可归为 Schema、工具实现、消息回传或授权四类。
| 现象 | 常见原因 | 检查与修复 |
|---|---|---|
| 模型从不调用工具 | 描述不清、工具与问题不匹配、工具选择策略受限 | 在 description 写清触发条件;打印实际发送的 tools;用明确问题做最小验证。 |
| 参数经常错误 | Schema 太宽、缺少枚举/格式/示例 | 缩小参数空间;增加 required、pattern、enum;服务器仍要校验。 |
| 模型忽略工具结果 | 没有把结果按 call_id 以正确消息类型回传 |
记录 response.output、call_id、function_call_output;保证结果 JSON 可解析。 |
| 重复调用或死循环 | 工具输出不明确、模型不断尝试、没有最大步数 | 返回稳定错误码;设置循环上限;对相同调用做去重或幂等。 |
| 生产数据泄露 | 工具返回过量字段或日志记录原始敏感参数 | 最小化 DTO;日志脱敏;添加租户过滤和审计回放测试。 |
建议的单元测试
- 无效 JSON、缺少字段、非法枚举、越界长度。
- 无权限用户不能查询他人资源。
- 写操作未确认、重复请求、下游超时。
- 未知工具名、工具抛异常时返回稳定错误码。
建议记录的字段
request_id、模型、token 用量、轮次、耗时。- 工具名、已脱敏参数摘要、用户/租户、结果码。
- 模型的
call_id与下游服务 trace id,便于串联追踪。
上线前清单
协议与功能
- 每个工具有唯一名字、明确描述和收紧的 Schema。
- 工具调用与结果通过正确的
call_id配对。 - 有最大循环次数、单请求超时和明确失败回复。
- 至少覆盖成功、参数错误、权限拒绝、下游失败四类测试。
生产治理
- 权限、租户隔离、限流和审计在服务端生效。
- 写操作需要确认、幂等键或人工审批。
- 敏感字段已脱敏,密钥不进入模型上下文。
- 具备指标、告警和可回放的调用日志。
推荐落地顺序: 先用官方 SDK 写一个只读单工具闭环,完善参数校验和日志;再引入多个工具;当流程出现条件分支、人工审批、长时间任务或恢复需求时,使用 LangGraph 等状态编排框架。这样能始终看清模型、工具和业务权限之间的边界。