2024 年下半年我做一个"会干活"的客服 AI——不只是回答问题,还能真的查订单、改地址、发优惠券。技术上用的是大模型的 Function Calling(函数调用):我把几个内部接口描述给模型,模型遇到该调接口的问题时,就告诉我"该调哪个接口、传什么参数",我的代码再去真正执行。Demo 阶段一切顺利,我演示给老板看,他问"我上周买的那单到哪了",AI 流畅地调了查询接口、报出物流状态,全场鼓掌。结果灰度上线第二天,一条监控告警让我后背发凉:一个用户只是在对话框里抱怨"这东西真难用,想退钱",AI 直接调用了退款接口,给他发起了一笔真实退款。用户本来只是发句牢骚,根本没申请退款。我赶紧把退款工具下了线,复盘的时候才想明白几件我之前完全没当回事的事:第一,Function Calling 不是"模型帮你执行操作",模型自始至终只是"提议"调用某个函数,真正按不按这个提议去执行,是我的代码决定的——我之前是无脑执行;第二,模型决定调哪个函数、传什么参数,本质还是它在"预测最可能的输出",它完全可能调错函数,也完全可能把参数编得有模有样;第三,查询类操作调错了顶多是答非所问,但退款、改密码这种有副作用的操作调错了,就是真实的损失。那次之后我才认真重做了整套工具调用的工程框架。这篇文章就把它从头梳理一遍:Function Calling 到底是什么、一次完整调用要走几步、模型会怎么"调错",以及怎么在模型不可靠的前提下,把一个能动手的 AI Agent 做得安全可控。
问题背景
先把那次事故的现象和我的误判摆清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:给客服 AI 接入了"查订单""改地址""发起退款"等几个能调用内部接口的工具。一个用户只是在对话里抱怨"想退钱",并未正式申请,AI 却直接调用退款接口、发起了一笔真实退款。
我当时的错误认知:"把接口描述给模型,模型就能像一个靠谱员工一样,该调的时候调、不该调的时候不调,我只要把工具接上就行。"
真相:模型在 Function Calling 里做的事,和它生成普通文本没有本质区别——它都是在"预测最可能的输出"。只不过这次输出的不是一段话,而是一个"函数名 + 参数"的结构。这意味着:它可能在不该调的时候调、调错函数、把参数编得像模像样。模型只负责"提议",真正"执行不执行、怎么执行",必须由你的代码来把关。
要把一个能动手的 AI Agent 做安全,需要几块认知:
- Function Calling 的真实机制:模型只产出"调用提议",执行权在你手里;
- 工具定义(schema)写得好不好,直接决定模型调得准不准;
- 一次完整的工具调用要走的几步,以及多轮调用怎么循环;
- 模型会幻觉参数,入参必须当成不可信输入来校验;
- 有副作用的危险操作,必须加人工确认这道闸。
一、Function Calling 是什么:模型只"提议",不"执行"
要把工具调用做对,第一件事是纠正一个几乎人人都会有的直觉误解:很多人以为 Function Calling 是"模型自己去调了接口"。不是的。
真实的机制是这样:你在请求里告诉模型"我这边有这么几个函数,各自是干什么的、需要什么参数";模型在回答时,如果判断当前问题需要某个函数,它不会、也没有能力真的去执行那个函数,它只会在返回结果里给你一个结构化的"提议"——"我建议你调用 query_order 这个函数,参数是 order_id=12345"。然后,你的代码收到这个提议,由你决定要不要执行、怎么执行;执行完,你再把函数的返回结果作为一条新消息发回给模型,模型据此组织最终的自然语言回答。
把这个机制想清楚,有两个推论非常关键。第一,执行的闸门完全在你手里。模型说"调退款",你的代码完全可以拦下来、可以要求确认、可以拒绝。我之前的事故,根源就是我把模型的"提议"无条件当成了"指令"。第二,模型的"提议"本身是不可靠的。它选哪个函数、填什么参数,走的还是"预测下一个 token"那套机制,该错的时候照样错。
下面这段代码,展示一次最基础的 Function Calling 请求长什么样——重点看返回结果,模型给的是 tool_calls,而不是执行结果:
from openai import OpenAI
client = OpenAI()
# 把"我有哪些工具"用 JSON Schema 描述给模型
tools = [{
"type": "function",
"function": {
"name": "query_order",
"description": "根据订单号查询订单的物流和状态。仅用于查询,无副作用。",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号"},
},
"required": ["order_id"],
},
},
}]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "帮我查下订单 SO20240815 到哪了"}],
tools=tools,
)
msg = resp.choices[0].message
# 关键:模型没有执行任何东西,它只是返回了一个"调用提议"
if msg.tool_calls:
call = msg.tool_calls[0]
print("模型提议调用:", call.function.name)
print("提议的参数:", call.function.arguments)
# 执行与否、怎么执行,完全是接下来你的代码说了算
二、定义工具:schema 写得好不好,决定模型调得准不准
模型靠什么决定"该不该调、调哪个、传什么"?它唯一的依据,就是你给的工具定义(schema)。schema 写得含糊,模型就猜得离谱;schema 写得清楚,模型的准确率会高得多。可以说,工具定义就是你给模型的"岗位说明书"。
写好一个工具定义,有几条经验。函数名要见名知意,用 query_order 而不是 func1。description 是重中之重:不光要写这个函数干什么,更要写清楚它的边界——什么情况下该用、什么情况下不该用、有没有副作用。模型很大程度上是靠读 description 来做判断的。每个参数都要有清晰的描述和正确的类型,该用枚举(enum)限定取值范围的就用枚举,别让模型自由发挥。required 要如实标注,哪些参数必填要写准。
还有一条容易被忽略的:给有副作用的工具,在 description 里明确写出来。比如退款工具,description 里就该写明"此操作会发起真实退款,有资金副作用,调用前需用户明确确认"。这既是给模型的提示,也是给你自己代码的提醒。下面用代码把一组工具定义集中管理,并区分出"安全"和"危险"两类:
from typing import Callable, Dict
# 用一个注册表集中管理所有工具:定义、实现、是否危险
class ToolRegistry:
def __init__(self):
self._schemas: list = []
self._impls: Dict[str, Callable] = {}
self._dangerous: set = set()
def register(self, schema: dict, impl: Callable, dangerous: bool = False):
"""注册一个工具。dangerous=True 表示有副作用,后续要走确认闸。"""
name = schema["function"]["name"]
self._schemas.append(schema)
self._impls[name] = impl
if dangerous:
self._dangerous.add(name)
def schemas(self) -> list:
return self._schemas
def is_dangerous(self, name: str) -> bool:
return name in self._dangerous
def get_impl(self, name: str) -> Callable:
return self._impls[name]
有了这个注册表,注册工具时就顺手把"危险与否"标注好。下面注册两个工具——一个只读的查询工具,一个有资金副作用的退款工具,注意它们 description 的写法差别:
registry = ToolRegistry()
# 查询类工具:无副作用,description 里写明"仅查询"
registry.register(
schema={"type": "function", "function": {
"name": "query_order",
"description": "根据订单号查询订单状态。只读,无任何副作用,可放心调用。",
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string", "description": "订单号"}},
"required": ["order_id"]}}},
impl=lambda order_id: {"order_id": order_id, "status": "运输中"},
dangerous=False,
)
# 退款类工具:有资金副作用,description 里必须明确警示
registry.register(
schema={"type": "function", "function": {
"name": "refund_order",
"description": ("对订单发起退款。【危险操作】会产生真实资金流动,"
"仅当用户明确说'我要退款/申请退款'时才可提议调用,"
"用户只是抱怨或询问时绝对不要调用。"),
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string", "description": "订单号"},
"amount": {"type": "number", "description": "退款金额(元)"}},
"required": ["order_id", "amount"]}}},
impl=lambda order_id, amount: {"refunded": amount, "order_id": order_id},
dangerous=True,
)
三、调用闭环:一次完整的工具调用要走几步
第一节说模型只"提议",那从"提议"到用户最终看到一句人话,中间要走一个完整的闭环。把这个闭环走清楚,是写对 Agent 代码的基础。
一次完整的工具调用是这样循环的:第一步,把用户问题和工具定义一起发给模型;第二步,模型返回——要么直接是自然语言回答(不需要工具),要么是一个或多个 tool_call 提议;第三步,如果是提议,你的代码执行对应的函数,拿到结果;第四步,把函数结果作为一条 role=tool 的消息,连同之前的对话一起再发给模型;第五步,模型读到函数结果,组织出最终的自然语言回答。
这里有个关键点:这个循环可能要转好几圈。模型可能先调一个工具,看到结果后,发现还需要再调另一个工具——所以代码要写成一个循环,而不是"调一次就结束"。同时循环必须有一个最大轮次上限,防止模型陷入反复调用工具的死循环。下面是这个闭环的完整实现:
import json
def run_agent(user_input: str, max_rounds: int = 5) -> str:
"""一次完整的 Agent 对话:可能多轮调用工具,直到模型给出最终回答。"""
messages = [
{"role": "system", "content": "你是客服助手,需要时调用工具获取信息。"},
{"role": "user", "content": user_input},
]
for _ in range(max_rounds): # 必须有轮次上限,防止无限调用
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=registry.schemas(),
)
msg = resp.choices[0].message
if not msg.tool_calls:
# 模型不再需要工具,这就是最终的自然语言回答
return msg.content
# 把模型这条"提议消息"先存进对话历史
messages.append(msg)
for call in msg.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments)
# 执行工具(下一节会在这里插入校验和确认)
result = execute_tool(name, args)
# 把工具结果作为 role=tool 的消息回传给模型
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
return "处理超时:工具调用轮次过多,请转人工。"
def execute_tool(name: str, args: dict) -> dict:
"""最朴素的执行:直接调对应实现。下一节会给它加上防线。"""
impl = registry.get_impl(name)
return impl(**args)
四、模型会幻觉参数:入参必须当成不可信输入来校验
第三节那个 execute_tool 是"裸奔"的——它无条件相信模型给的函数名和参数。但模型给的参数,本质和它生成的任何文本一样,是"预测"出来的,完全可能出错。
模型在参数上会犯几类错。一是幻觉参数值:用户没提订单号,模型可能凭空编一个格式完全正确的订单号填进去。二是类型或格式错误:schema 要求 amount 是数字,模型可能给个字符串;要求是枚举值,模型给个枚举外的值。三是缺失必填参数,或者把参数张冠李戴。四是调了不存在的函数——模型偶尔会"发明"一个你根本没注册的工具名。
所以正确的做法是:把模型给的函数名和参数,完全当成来自外部的、不可信的输入来对待,就像对待用户从前端传来的表单一样。执行前必须逐项校验:函数名是否真实注册过、必填参数是否齐全、每个参数的类型和取值范围是否合法。任何一项不过,就不执行,而是把一条清晰的错误信息回传给模型——让模型自己去纠正,比如重新向用户询问缺失的订单号。下面给 execute_tool 加上这道校验防线:
def validate_call(name: str, args: dict) -> str:
"""校验模型给的工具调用,返回空字符串=通过,否则=错误原因。
模型给的 name 和 args 都是'预测'出来的,必须当不可信输入校验。"""
# 1. 函数名是否真实注册过 —— 模型可能"发明"不存在的工具
if name not in registry._impls:
return f"工具 {name} 不存在,请只使用已提供的工具。"
# 2. 必填参数是否齐全 —— 模型可能漏参,或幻觉一个假值
schema = next(s["function"] for s in registry.schemas()
if s["function"]["name"] == name)
params = schema["parameters"]
for req in params.get("required", []):
if req not in args or args[req] in (None, ""):
return f"缺少必填参数 {req},请向用户询问后再调用。"
# 3. 每个参数的类型是否匹配 schema
type_map = {"string": str, "number": (int, float), "boolean": bool}
for key, val in args.items():
spec = params["properties"].get(key)
if spec is None:
return f"参数 {key} 不在工具定义中。"
expected = type_map.get(spec["type"])
if expected and not isinstance(val, expected):
return f"参数 {key} 类型错误,应为 {spec['type']}。"
return ""
def execute_tool(name: str, args: dict) -> dict:
"""带校验的执行:校验不过就把错误回传给模型,而不是硬执行。"""
error = validate_call(name, args)
if error:
# 不执行,把错误原因回给模型,让它自己纠正(比如重新问用户)
return {"ok": False, "error": error}
result = registry.get_impl(name)(**args)
return {"ok": True, "result": result}
五、危险操作:必须加一道人工确认的闸
校验解决的是"参数对不对",但解决不了开头那个事故的核心问题——参数完全合法,函数也存在,只是这个操作本就不该在此刻发生。用户抱怨一句"想退钱",模型提议 refund_order(order_id=..., amount=...),这个调用在格式上挑不出任何毛病,校验全过。
对这类有副作用的危险操作,唯一可靠的防线是:不要让模型的提议直接变成执行,中间插入一道人工确认。具体做法是,把工具分成两类。查询类、只读、无副作用的工具(查订单、查物流),模型提议了就可以直接执行。而有副作用的危险工具(退款、改密码、发券、删除数据),模型的提议不直接执行,而是转化成一个明确的确认请求抛给用户:"您是要对订单 X 发起 Y 元退款吗?请回复确认。"只有用户明确确认后,才真正执行。
这道闸的意义在于:它把"要不要做这个有后果的动作"这个决定权,从不可靠的模型手里,交还给了真正该负责的人。模型可以提议,但拍板的是用户。下面用代码实现这道确认闸——它就插在 execute_tool 里,危险工具一律先返回"待确认":
def execute_tool_with_gate(name: str, args: dict,
user_confirmed: bool = False) -> dict:
"""带确认闸的执行:危险工具在用户确认前一律不执行。"""
error = validate_call(name, args)
if error:
return {"ok": False, "error": error}
# 危险操作:没有用户明确确认,绝不执行 —— 这是开头事故的正解
if registry.is_dangerous(name) and not user_confirmed:
return {
"ok": False,
"need_confirm": True,
# 把这个动作翻译成人话,抛给用户去拍板
"confirm_prompt": _describe_action(name, args),
}
result = registry.get_impl(name)(**args)
return {"ok": True, "result": result}
def _describe_action(name: str, args: dict) -> str:
"""把一个待执行的危险动作,翻译成给用户确认的自然语言。"""
if name == "refund_order":
return (f"您确认要对订单 {args['order_id']} "
f"发起 {args['amount']} 元退款吗?此操作不可撤销,"
f"请回复'确认'或'取消'。")
return f"您确认要执行 {name} 操作吗?请回复'确认'或'取消'。"
# 用户只是抱怨"想退钱"时:模型即便提议了 refund_order,
# 这道闸也会拦下来,先问"您确认要退款吗" —— 用户说"不是,我就吐槽下",
# 退款就不会发生。决定权回到了用户手里。
六、工程坑:多轮调用、工具报错、超时、可观测性
把 AI Agent 真正放进生产环境,还有几个绕不开的工程坑。它们大多不是"模型问题",而是"系统设计"问题——但恰恰决定了你的 Agent 是稳定可用还是状况百出。
坑 1:工具自己报错了,要把错误"喂回"给模型,而不是直接崩。你的接口会超时、会返回业务错误(订单不存在)。这些错误不应该让整个 Agent 流程异常中断,而应该被捕获、整理成一条清晰的错误消息,作为工具结果回传给模型。模型读到"订单不存在"这个结果,就能自然地回复用户"没查到这个订单,您再核对下单号"。
坑 2:给每个工具调用设超时,给整个会话设轮次上限。单个工具调用要有超时,不能让一个卡住的接口拖垮整个对话。整个 Agent 循环要有最大轮次上限(第三节已埋下),防止模型陷入"调工具→看结果→再调工具"的死循环,把 token 烧光。
坑 3:可观测性,每一次工具调用都要落日志。模型提议了什么、参数是什么、校验过没过、是否危险、有没有确认、最终执行结果——这一整条链路都要记下来。出了问题(比如又一次误调),你得能从日志里完整还原"模型当时为什么这么提议、闸为什么没拦住"。没有日志,Agent 就是个黑盒。
坑 4:工具不是越多越好。给模型挂的工具越多,它选错的概率越高,description 之间也容易相互干扰。保持工具集精简,每个工具职责单一、边界清晰,比堆一大堆工具更可靠。
下面把"坑 1"和"坑 3"落地——一个带错误捕获和全链路日志的执行包装:
import logging
import time
logger = logging.getLogger("agent")
def execute_tool_safe(name: str, args: dict,
user_confirmed: bool = False) -> dict:
"""生产级执行:确认闸 + 错误捕获 + 全链路日志。"""
start = time.time()
log = {"tool": name, "args": args,
"dangerous": registry.is_dangerous(name),
"confirmed": user_confirmed}
try:
result = execute_tool_with_gate(name, args, user_confirmed)
log["outcome"] = ("need_confirm" if result.get("need_confirm")
else "ok" if result.get("ok") else "rejected")
return result
except Exception as e:
# 工具自身报错(接口超时 / 业务异常):不让流程崩,
# 把错误整理成结果回传给模型,让它据此回复用户
log["outcome"] = "error"
log["error"] = str(e)
return {"ok": False,
"error": f"工具执行失败:{e},请告知用户稍后再试或转人工。"}
finally:
log["cost_ms"] = round((time.time() - start) * 1000)
# 整条链路落日志:出问题时能完整还原模型为什么这么调
logger.info("tool_call %s", json.dumps(log, ensure_ascii=False))
"坑 2"里的单工具超时,单独拎出来也值得一段代码。它的关键和错误回喂是一致的——超时不能抛异常崩掉对话,而要整理成结果回传给模型:
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FTimeout
_pool = ThreadPoolExecutor(max_workers=8)
def call_with_timeout(fn: Callable, args: dict,
timeout_s: float = 3.0) -> dict:
"""给单个工具调用套上超时:某个接口卡住时,
不能让它拖垮整个 Agent 对话。超时同样整理成错误结果回喂模型。"""
future = _pool.submit(lambda: fn(**args))
try:
return {"ok": True, "result": future.result(timeout=timeout_s)}
except FTimeout:
future.cancel()
# 超时不抛异常,而是变成一条模型能理解的错误结果
return {"ok": False,
"error": f"工具响应超时(超过 {timeout_s}s),"
f"请告知用户稍后再试或转人工。"}
except Exception as e:
return {"ok": False, "error": f"工具执行失败:{e}"}
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| Function Calling | 模型只产出"调用提议",不真正执行,执行权在你代码 |
| tool schema | 用 JSON Schema 描述工具,是模型判断"调不调、怎么调"的唯一依据 |
| description | 工具定义的重中之重,要写清边界、适用场景、有无副作用 |
| 调用闭环 | 问→提议→执行→结果回传→最终回答,可能多轮循环 |
| 轮次上限 | Agent 循环必须设最大轮次,防止反复调工具死循环 |
| 参数校验 | 模型给的函数名/参数当不可信输入,校验函数名、必填、类型 |
| 参数幻觉 | 模型会凭空编出格式正确的参数值,必须校验拦截 |
| 确认闸 | 危险操作不直接执行,转成确认请求交用户拍板 |
| 错误回喂 | 工具报错整理成结果回传给模型,而非中断流程 |
| 可观测性 | 每次工具调用全链路落日志,出问题能完整还原 |
避坑清单
- Function Calling 不是"模型执行操作",模型只产出"调用提议",执行不执行、怎么执行由你的代码决定。
- 绝不能把模型的工具调用提议无条件当指令执行,这是 AI Agent 出安全事故的头号原因。
- 工具定义就是给模型的"岗位说明书",函数名见名知意、description 写清边界和副作用、参数类型用枚举约束。
- 有副作用的工具,在 description 里明确写出"危险操作""会产生真实影响",既提示模型也提醒自己。
- 调用闭环要写成带轮次上限的循环,模型可能多轮调用工具,但必须防止反复调用的死循环。
- 模型给的函数名和参数是"预测"出来的,当成不可信外部输入校验:函数名是否存在、必填是否齐、类型是否对。
- 模型会幻觉参数,用户没给订单号它可能凭空编一个格式正确的,校验不过就回传错误让模型纠正。
- 查询类只读操作可直接执行,但退款、改密码、删数据等有副作用的危险操作必须加人工确认闸。
- 工具自身报错(超时、业务异常)要捕获并整理成结果回喂给模型,不要让整个 Agent 流程崩掉。
- 每次工具调用全链路落日志(提议、参数、校验、确认、结果),并保持工具集精简,工具越多模型越容易选错。
总结
回头看那笔不该发生的退款,它给我上的最重要一课,是纠正了我对"AI Agent"这四个字的浪漫想象。我原本以为,接入 Function Calling 就等于雇了一个能干活的员工——你把工具交给他,他自己会判断分寸。但模型不是员工。它没有责任感,没有"这个操作有后果、我得慎重"这种意识,它做的事情自始至终只有一件:根据上下文,预测一个最像样的输出。这次的输出恰好长得像一个函数调用而已。把这一点想透,你就不会再问"怎么让模型别乱调接口",而会问"我的系统该怎么设计,才能在模型一定会乱调的前提下,依然安全"。
所以做 AI Agent,真正的工程重心根本不在模型那一侧,而在模型和真实世界之间的那一层——也就是你的代码。模型负责"提议",你的代码负责"治理":校验它的提议合不合法、判断这个动作危不危险、危险的就拦下来交给人确认、执行出错了就把错误翻译给模型听、整个过程留下完整的日志。模型越强,提议的质量越高,但这层治理一层都不能省——因为"提议质量高"和"提议绝对可靠"之间,差的就是那些会出真实事故的小概率事件。
这篇文章的几节,其实是顺着"一次调用的生命周期"展开的:先认清模型只提议、不执行;再讲怎么用 schema 把工具描述清楚,让提议尽量准;然后走通"提议→校验→执行→回传"的闭环;接着用校验挡住幻觉参数,用确认闸挡住危险操作;最后用错误回喂和日志把整套东西做得健壮、可观测。你会发现,这一整套思路,和我们做传统后端 API 时对待"客户端请求"的态度几乎一模一样——我们从不相信客户端传来的参数,要校验;我们对危险操作要二次确认;我们要捕获异常、要打日志。AI Agent 没有发明新的工程纪律,它只是把"不可信的输入源"从"前端用户"换成了"大模型",而你那套老老实实的防御性编程,一条都不该丢。
最后想说的是,给 AI "动手能力"是一件激动人心、但必须心怀敬畏的事。一个只会聊天的 AI,答错了顶多是信息错误;一个能动手的 AI,做错了就是真实世界里的损失——一笔退款、一次误删、一个被改掉的密码。能力的边界扩大多少,治理的边界就要跟着扩大多少。所以在给你的 Agent 接入下一个工具之前,不妨先问自己三个问题:这个工具有副作用吗?如果模型在最不该调它的时候调了,会发生什么?我有没有一道闸,能在那一刻把它拦住?能从容回答这三个问题,你才算真正准备好,让 AI 不只是动嘴,而是动手。
—— 别看了 · 2026