LLM 工具调用:让模型会调 API 与函数

工具调用(function calling / tool use)让模型以结构化方式请求执行一个函数,由外部代码运行并把结果返回,模型再据此继续。它把模型从”只会说”变成”能动手”——但动手的永远是你的代码,模型只负责”决定调哪个、传什么参”。

是什么

你给模型一组工具描述(名字、用途、参数 schema)。模型在合适时输出调用请求,而不是自然语言;你的代码执行后,把结果作为一条新消息回灌给模型,模型据此生成最终回答。整个过程里,模型不直接触达外部系统,真正的副作用由你控制——这一点对安全至关重要。

需要区分两种形态:一种是”函数调用”专用通道(模型显式返回结构化调用对象,如 OpenAI 的 tool_calls);另一种是让模型在文本里输出特定格式的调用(如 JSON),由你解析。前者更可靠,因为模型被训练成按 schema 输出,解析失败率低。

{
  "name": "get_weather",
  "arguments": { "city": "深圳" }
}

为什么有用

没有工具调用时,模型只能”建议”你运行某段代码,落地要靠人。有了它,模型能串联检索、计算、数据库、第三方 API,构成可执行的智能体。其思想可追溯到 ReAct(Reason + Act)范式:模型交替进行”推理”与”行动”,从环境反馈中修正下一步。典型应用包括:联网问答(调搜索 API)、数据分析(调 Python 执行器)、工单处理(调 CRM 接口)。当任务需要”实时或私有数据”时,工具调用几乎是唯一路径,因为模型权重本身不含这些动态信息。

怎么用

以 OpenAI 风格接口为例,工具描述是一组 JSON Schema。模型返回 tool_calls,你执行函数后,用 role: "tool" 的消息把结果回灌:

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "查询指定城市的当前天气",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "城市名,如 深圳"},
                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
            },
            "required": ["city"]
        }
    }
}]

messages = [
    {"role": "user", "content": "深圳今天多少度?"},
    {"role": "assistant", "content": None,
     "tool_calls": [{"id": "call_1", "type": "function",
                     "function": {"name": "get_weather",
                                  "arguments": '{"city": "深圳"}'}}]},
    {"role": "tool", "tool_call_id": "call_1",
     "content": '{"temp_c": 28, "cond": "晴"}'},  # 外部执行结果
]
# 再把 messages 发回模型,得到最终自然语言回答

一个健壮的调用循环需要”分发器”:根据 tool_calls 里的名字查表执行对应函数,把结果收集回消息列表,再请求模型,直到模型不再要求调用工具。当模型一次返回多个 tool_calls 且无依赖时,可以并行执行再一起回灌,降低延迟。Anthropic 的 Claude 用 tool_use / tool_result 块表达同样语义;LangChain 把工具抽象为统一 Tool 接口,DSPy 用 dspy.Tool 包装可调用对象。无论哪套,骨架都是”描述→请求→执行→回灌”。

# 分发器骨架(示意)
def dispatch(tool_calls, registry):
    results = []
    for call in tool_calls:
        fn = registry[call.function.name]
        args = json.loads(call.function.arguments)
        try:
            out = fn(**args)
            results.append({"role": "tool", "tool_call_id": call.id, "content": str(out)})
        except Exception as e:
            results.append({"role": "tool", "tool_call_id": call.id, "content": f"ERROR: {e}"})
    return results

注意点

设计可靠工具是第一要务:工具描述要清晰、参数用严格 schema 并显式标注必填项,避免歧义;每个工具只做一件事、名字自解释;执行侧做参数校验与超时控制,失败时返回结构化错误而非抛异常中断链路。用 Pydantic 之类做 schema 校验能挡掉大部分畸形输入,例如把 unit 限定为枚举,模型就无法编出 “kelvin”。

常见陷阱有三类:模型可能编造不存在的参数或取值(尤其 enum 约束缺失时);可能重复调用同一工具陷入循环;也可能忽略返回结果自顾自编答案。应对手段包括:对关键操作加确认或权限闸门、限制单轮调用次数、把工具结果明确标注为”观察(observation)“以提示模型据此作答。

工程清单:
- 参数 schema 显式、可校验,enum 防越界
- 工具只做一件事,名字自解释
- 执行失败返回结构化错误,而非抛异常
- 对写操作加审批/幂等保护
- 限制单轮调用次数,防循环
- 工具结果做观测与日志,便于排查

安全与可观测:工具调用依赖模型”理解何时该调”,对罕见或长尾工具,模型可能漏调;多工具并行时的状态一致性需你自己维护;不要假设模型一定按 schema 出参——执行前务必校验。每次工具调用都带来额外延迟与成本,应在”该调才调”与”能缓存就缓存”之间权衡。生产环境建议加调用追踪(如 LangSmith 这类 tracing 工具),记录每一步的请求、参数与返回,便于定位”模型为什么调错工具”。

小结

工具调用把模型从”只会说”变成”能动手”。关键是把工具接口设计得像 API 一样严谨,并在执行侧做好校验、超时、权限与观测。模型决定”调不调、调哪个、传什么”,而副作用与安全性始终握在开发者手里——这条边界划清楚,工具调用才是资产而非隐患。

参考与延伸阅读

本文累计阅读