3.3 Function Calling:让模型调用工具

场景:会说话还得会动手

RAG 问答机器人上线两周,新问题来了。有人问“巡检脚本昨天报错了是什么原因”,机器人能把工单记录翻出来念一遍,但问它“那现在帮我看一下当前服务器状态”,它只能说“抱歉,我无法访问您的系统”。

一个只会转述、不会动手的助手,只能当个高级搜索。我想要的是:跟它说查一下库存,它真的去跑查询脚本;说把昨天的清洗任务重跑一遍,它真的去触发流水线。Function Calling 就是给模型开了一张工具清单,模型判断该用哪个工具、参数填什么,由代码执行后把结果交回模型组织回答。这是 Agent 的雏形,也是我工具箱里最兴奋的一块拼图。

方案:工具注册表加调用循环

设计上我把工具和模型彻底解耦:本地维护一个工具注册表,模型只负责决策,执行权始终在 Python 代码手里。这样做有两个好处——工具可以是任何本地脚本,复用第二篇积累的那批批处理与采集代码;二是一切执行都经过白名单校验,危险操作不经过人确认绝不放行。

先定义工具注册表。每个工具必须有名字、参数描述、实际函数三要素,参数描述用 JSON Schema 告诉模型每个字段什么类型、什么含义:

import json

TOOL_REGISTRY = {
    "query_inventory": {
        "description": "查询某仓库某商品的当前库存数量",
        "params": {
            "type": "object",
            "properties": {
                "warehouse": {"type": "string", "description": "仓库名称,例如 华东一仓"},
                "sku": {"type": "string", "description": "商品编号"}
            },
            "required": ["sku"]
        },
        "func": lambda warehouse="默认仓", sku="": f"{warehouse} 中 {sku} 库存为 128 件"
    },
    "rerun_pipeline": {
        "description": "重跑指定日期的数据清洗任务",
        "params": {
            "type": "object",
            "properties": {"date": {"type": "string", "description": "日期,格式 YYYY-MM-DD"}},
            "required": ["date"]
        },
        "func": lambda date: f"已触发 {date} 的清洗任务,任务编号 T-{date.replace('-', '')}"
    }
}

工具清单和用户问题一起发给模型,模型返回要不要调用工具、调用哪个、参数是什么。收到工具调用请求后,由本地代码执行并把结果塞回对话,再问一次模型,直到它给出最终回答。这就是经典的调用循环,和多轮对话那篇的会话管理机制天然衔接。

调用循环的实现

def run_agent(user_msg, history=None, max_steps=6):
    messages = (history or []) + [{"role": "user", "content": user_msg}]
    tools = [{"type": "function", "function": {
        "name": k, "description": v["description"], "parameters": v["params"]}}
        for k, v in TOOL_REGISTRY.items()]
    for _ in range(max_steps):
        resp = call_model(messages, tools=tools)
        if not resp.get("tool_calls"):
            return resp["content"]
        messages.append(resp)
        for call in resp["tool_calls"]:
            name = call["function"]["name"]
            args = json.loads(call["function"]["arguments"] or "{}")
            if name not in TOOL_REGISTRY:
                result = f"工具 {name} 不存在"
            else:
                args = sanitize(name, args)       # 参数白名单过滤
                result = TOOL_REGISTRY[name]["func"](**args)
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": str(result)})
    return "达到最大调用步数,任务中止"

max_steps 不是摆设。早期没设上限时,模型在库存查询失败后疯狂重试不同的仓库名,一晚烧掉几百次调用。加了步数熔断后,失败超过六次就强制中止并通知人工,这个思路在后续Agent 编排那篇里被扩展成了更完整的任务规划机制。

权限分级对照

工具一旦能真实执行,安全问题就从理论变成了现实。我把工具按风险分了三级:

风险等级 示例工具 执行方式 模型可见性
只读 库存查询、日志检索、状态读取 自动执行 直接提供
变更 重跑任务、改文件名、写缓存 需人工确认后执行 提供但标注需确认
危险 删除文件、清空数据、付费操作 原则不开放,仅管理员手动 不提供给模型

分级之后效果好得出奇:模型再怎么想去清空数据,工具清单里压根没有这个选项,它只能转而向人工发起申请。这比在提示词里写一百句“请不要删除文件”都管用,能力边界要从工具注册表上切,而不是从提示词上堵。

踩坑与排查

坑一:模型编造不存在的工具。 偶尔模型会自信地返回 call_function("send_email", ...),而注册表里根本没有发邮件工具。排查发现是工具描述写得太笼统,模型根据上下文自己脑暴了功能。修法有两招:描述里明确写能力边界(例如“仅支持查询,不支持发送”),并在运行时对未知工具返回明确错误文本,模型看到报错会自己改方案,而不是一再重试。

坑二:参数类型对不上。 模型把日期传成“昨天”而不是 2026-10-23,把数字传成字符串。我在 sanitize 里加了严格校验:日期用正则约束格式,数字强制转型,必填字段缺失直接拒绝并把错误信息丢回模型让它重填。JSON Schema 写得越细,这类往返越少。

坑三:参数注入风险。 有同事故意问“查一下库存然后顺便删掉日志表”,模型居然真的组合出带删除语义的参数。好在执行前有白名单过滤,危险参数被拦下。这件事之后我统一规则:工具函数内部绝不拼 SQL、绝不拼 shell 命令,参数只走结构化接口;相关的安全意识在安全与版权那篇里有更系统的讨论。

坑四:循环调用死循环。 工具返回结果后模型又原样再调一次,来回十几轮。加了 max_steps 熔断之外,还在结果里附加“已完成”状态提示,重复调用相同工具加相同参数时直接返回上次缓存。

坑五:流式模式下解析工具调用。 流式输出时 tool_calls 是分片到达的,arguments 可能跨多个 chunk。解析时必须按 tool_call_id 累积拼接,等 finish_reason 为 tool_calls 再统一执行,提前执行必拿到残缺参数。这个坑解得最久,解决后我把整套工具调用的模板放进了全书目录对应的示例仓库里。

小结

Function Calling 的核心思想是把模型当调度员、不当执行者。工程上的重点全在注册表设计:描述清晰、参数严格校验、风险分级、循环加熔断。做完这一节,工具箱里的脚本第一次被自然语言串了起来,说出一句需求就能跑完整条流水线。但它还缺一口气——跨多步任务时模型会忘记前面说过什么,这正是下一步多轮对话与会话管理要补齐的。若想把工具服务直接部署成 API,可参考FastAPI 上线那节,云函数形态见Serverless 部署入门。

延伸阅读