很多人看到 AI 回答“已经查询库存”,就以为它真的连接了仓库系统。实际上,语言模型可以生成一句像查询结果的话,也可以提出一个工具调用请求;只有你的程序真正执行了经过允许的查询,并拿到结果,才能说明发生过查询。

本文用一个只读、离线、虚构库存的小练习讲清 Function Calling:模型提出查什么,程序决定能不能查,工具返回什么,再把结果交回模型。最终得到的是一份可运行的参数校验与查询演示,不需要 API 密钥,不收费,也不会连接企业数据库。接入真实模型的消息结构会单独说明,本文没有实际调用收费模型,示例请求不是模型实测输出。

一、Function Calling 解决什么问题

Function Calling 常被译为函数调用,在模型 API 中也会看到 Tool Calls。开发者先描述可用工具的名称、用途和参数结构,模型根据用户问题生成工具名称与参数;宿主程序收到请求后,验证参数、检查权限、执行对应函数。

例如用户问“帮我查 P100 这个商品”,模型可以提出调用 lookup_stock,参数是商品编号 P100。它不能因为说了函数名,就自动获得数据库权限;也不会替你创建这个函数。工具实现、数据范围和执行决定都在应用一侧。

这适合把自然语言转换为结构明确的操作请求,比如查订单状态、读取知识条目、计算运费。本文只做查询库存,不延伸到下单、扣款或修改库存。后者涉及写入和外部后果,需要另外设计确认、幂等和审计机制。

它与 JSON 输出的区别也很实用:JSON 输出强调“答案长什么样”,工具调用强调“请求哪个能力以及传哪些参数”。参数符合格式,不代表这个操作已经获得业务许可;结构化输出和权限控制不能互相替代。

二、先确定练习的输入与边界

准备一个安装了 Python 3 的电脑、文本编辑器和空文件夹。代码只使用标准库,不需要安装第三方包。Windows 可先运行 python --version,macOS 或 Linux 通常使用 python3 --version。下文以 python 为例,如果你的系统使用 python3,请替换命令开头。

我们约定两条虚构记录:P100 剩余十二件,P200 剩余零件。这里的十二是本地教学数据,并非实时库存;输出中同时写明来源,避免把演示结果当作线上查询。

参数只允许一个名为 sku 的字符串,格式是大写 P 加三位数字;不接受额外字段,不接受表名、SQL、文件路径或服务器地址。查询结果区分 found 和 not_found,商品不存在不等于库存为零。P200 是存在但无库存,P999 才是未找到。

实际项目还应在服务端根据登录身份决定可访问的仓库、租户和商品范围。不能让模型传入 tenant_id 就直接切换租户。本文没有用户系统,因此不把格式检查宣称为完整的生产鉴权。

模型提出工具请求,程序验证后查询,再带调用编号返回结果的原创示意

三、写出可读的工具定义

下面是教学用工具描述,采用常见的函数工具结构。把名称、参数和用途写具体,比给工具写“万能查询”更容易检查。

{
  "type": "function",
  "function": {
    "name": "lookup_stock",
    "description": "查询教学库存,只读;需要明确的商品编号,例如P100",
    "parameters": {
      "type": "object",
      "properties": {"sku": {"type": "string"}},
      "required": ["sku"],
      "additionalProperties": false
    }
  }
}

description 用来帮助模型选择工具,不是安全沙箱。即使描述里写着只读,也必须让真正的函数只做读取。additionalProperties 用于表达额外字段的限制,但服务端仍要自行验证,不能假设所有模型、模式和版本都会严格遵守定义。

DeepSeek 当前官方文档的普通工具调用示例使用 tools 参数;其 strict 模式有专门的 Beta 入口和约束。本文不启用 strict,也不混用 Beta 配置。以后接 API 时,先按你采用的服务、模型和 SDK 核对完整文档,不能只把参数名字照搬过去。

四、实现离线校验与只读查询

把下面内容保存为 tool_demo.py。这里人为构造工具调用消息,用它演示程序接到请求以后的行为;它不是伪装成真实模型响应。

import json
import re

STOCK = {"P100": 12, "P200": 0}

def execute_call(call):
    if not isinstance(call, dict):
        raise ValueError("call must be an object")
    call_id = call.get("id")
    if not isinstance(call_id, str) or not call_id:
        raise ValueError("missing call id")
    if call.get("type") != "function":
        raise ValueError("unsupported call type")
    fn = call.get("function")
    if not isinstance(fn, dict):
        raise ValueError("invalid function")
    if fn.get("name") != "lookup_stock":
        raise ValueError("tool not allowed")
    raw = fn.get("arguments")
    if not isinstance(raw, str) or len(raw) > 200:
        raise ValueError("invalid arguments size")
    args = json.loads(raw)
    if not isinstance(args, dict) or set(args) != {"sku"}:
        raise ValueError("only sku is accepted")
    sku = args["sku"]
    if not isinstance(sku, str) or not re.fullmatch(r"P[0-9]{3}", sku):
        raise ValueError("invalid sku")
    result = {"sku": sku, "source": "local_demo"}
    if sku in STOCK:
        result.update(status="found", quantity=STOCK[sku])
    else:
        result.update(status="not_found")
    return {
        "role": "tool", "tool_call_id": call_id,
        "content": json.dumps(result, ensure_ascii=False)
    }

if __name__ == "__main__":
    call = {
        "id": "demo_call_1", "type": "function",
        "function": {"name": "lookup_stock", "arguments": '{"sku":"P100"}'}
    }
    print(json.dumps(execute_call(call), ensure_ascii=False, indent=2))

进入文件所在目录,运行 python tool_demo.py。预期输出包含 role 为 tool、tool_call_id 为 demo_call_1,以及 content 中的 P100、found、quantity 为十二和 local_demo。content 是 JSON 字符串,终端看到转义引号是正常的,不表示多执行了一次查询。

这里通过固定函数名实现白名单。不要用 eval 执行模型参数,不要用 getattr 把任意字符串映射到程序函数,也不要把 arguments 直接拼成 SQL。若以后连接数据库,应使用参数化查询和受限的数据库账号,并把超时与异常转换成明确结果。

五、接入真实 API 时消息怎样流转

真实对话的基本顺序是:把用户消息与工具定义发给模型;取得 assistant 消息;检查其中是否有 tool_calls;对每个允许的请求执行校验与查询;把结果作为 tool 消息加入历史,再让模型解释结果。

必须保留模型原来那条 assistant 工具调用消息,并用同一次返回的调用编号配对结果。 不要把本地练习的 demo_call_1 填到真实请求里。多个调用也不能全部复用一个编号,否则模型无法可靠对应哪条结果属于哪个请求。

没有 tool_calls 时,不执行查询。模型可能需要追问商品编号,也可能直接回答一般知识;应用不能为了“让流程看起来成功”而自行假造调用。返回 length、网络超时或格式不完整时,应视为未完成,记录失败原因,不把半截参数送到业务函数。

接入阶段先使用隔离的测试数据和只读凭据,限制每轮工具调用次数和整个任务的耗时。本文的 JSON 示例是解释消息协议;实际序列化、异常处理以及 thinking 模式对历史消息的要求,应以服务商当前 SDK 和官方指南为准。不要只复制一小段伪代码就上线生产查询。

六、用反例检验程序是否真的守住边界

按下面顺序改动输入,每次记录“输入、是否执行、输出或异常”,比只看成功案例更能发现问题。

  1. 查询 P100:应返回 found,数量十二,来源为 local_demo。
  2. 查询 P200:应返回 found,数量零,不能解释成商品不存在。
  3. 查询 P999:应返回 not_found,不应补造数量。
  4. 把 sku 改成数字或小写 p100:应拒绝,不能悄悄猜测编号。
  5. 加入 sql、warehouse 等额外参数:应拒绝,不能扩大数据范围。
  6. 把函数名改成 delete_stock:应拒绝,不能执行其他函数。
  7. 把 arguments 改成损坏的 JSON 或数组:应拒绝,不进入查询。
  8. 删除调用 id:应拒绝,避免无法对应结果的消息混入历史。

库存查询正常、零库存、未找到和非法请求的验收分支原创示意

这些练习验证的是本地程序,不说明某个模型一定会稳定选对工具。真实集成还要分别检查“模型有没有选对工具”和“程序有没有安全执行”。前者靠代表性问题评测,后者靠权限、参数、超时、日志与业务测试,两个层面都不能省略。

七、常见故障怎么排查

提示找不到文件,先确认终端当前目录和文件名,不要把 Markdown 代码围栏复制进 Python。提示 JSON 解码失败,检查传给 arguments 的是完整字符串,不是截断内容或 Python 单引号字典。

结果里有库存而自然语言回答却说“已经下单”,说明最后解释阶段越过了工具结果的范围。系统提示可要求只说明查询结果,但应用更应只提供只读工具,并在界面明确区分“查到信息”和“完成操作”。自然语言不能成为业务状态的唯一依据。

如果一次问题生成多个调用,逐项校验并设置数量上限,不能只取第一项又假装全部完成。若改为真实接口后失败,先区分模型请求失败、工具参数失败和数据库查询失败,日志保留调用编号与错误类别即可,不打印密钥、完整客户数据或数据库口令。

完成本练习的验收标准很明确:成功输入得到有来源的只读结果;零库存与未找到能区分;非法输入在执行前被拒绝;消息编号能正确配对。做到这些,才适合继续学习真实服务的连接、鉴权和稳定性设计。

八、什么情况暂时不要接入真实业务

如果你还不能解释一次查询使用了哪个身份、查询了哪份数据、失败以后给用户看什么,就先保留这个本地练习。一个看似聪明的对话界面,并不能弥补数据权限与错误处理的缺口。对已有业务系统,最稳妥的下一步通常是新增一个受限的只读接口,让程序通过固定接口查询,而不是把整个数据库直接暴露给模型。

同时记录数据的新鲜程度。工具返回的是五分钟前的缓存,最终回答就不能写“当前实时库存”;工具只查了一个仓库,也不能概括为全公司库存。来源、范围和更新时间决定结果能支持多大的结论。教学代码用 local_demo 标明来源,真实业务可增加更新时间和受控的数据范围说明,但不要把内部连接信息放入面向用户的结果。

最后给交互保留可理解的失败文案,例如“商品编号不完整,请确认后重试”,而不是只显示一长串异常。错误详情留在受控日志里,用户界面说明能做什么以及下一步怎么处理。这样才能让工具调用从一次技术演示变成可使用的产品能力。

官方资料