返回首页

LLM 是怎么"调用"工具的?拆开 Tool Calling 的黑盒

很多人以为模型天生会调用函数。真相是:它只是被提示词诱导着吐出了一段 JSON, 剩下的活全是框架用正则和状态机干的。

一个常见的误解

刚开始接触 LangChain 的时候,我对 @tool 这个装饰器的理解是这样的: "给函数加上它,模型就能调用这个函数了。" 用起来也确实如此——写个查天气的函数, 装饰一下,扔给 Agent,模型真的会在需要的时候去查天气。

于是自然而然地产生了一个印象:模型具备调用外部函数的能力。

这个印象是错的。而且如果不纠正它,后面很多问题都会想不明白—— 比如"为什么工具一多模型就选错","为什么改了 docstring 效果就好转了"。

真相:模型只会输出文本

大语言模型唯一会做的事,是根据已有的 token 序列,预测下一个 token。 它没有网络请求能力,没有文件读写能力,也没有执行代码的能力。 它输出的永远只是一串文本。

那 Tool Calling 是怎么实现的?答案是:约定一种文本格式,然后诱导模型按格式输出。

具体来说,框架会做三件事:

  1. 把你的工具定义(名字、描述、参数结构)转换成一段提示词,塞进请求里
  2. 告诉模型:"如果你需要调用工具,就输出这样格式的 JSON"
  3. 拿到模型回复后,用解析器判断这是普通回答还是工具调用请求

看看真实的请求长什么样

与其空谈,不如直接打印一次真实请求。下面这段代码会拦截发给模型的原始 messages:

from openai import OpenAI

client = OpenAI(
    api_key="sk-xxx",
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "查询指定城市的当前天气",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "城市名称,例如 北京"}
            },
            "required": ["city"],
        },
    },
}]

resp = client.chat.completions.create(
    model="qwen-plus",
    messages=[{"role": "user", "content": "北京今天天气怎么样?"}],
    tools=tools,
)

# 关键:看看模型返回了什么
msg = resp.choices[0].message
print(msg.tool_calls)

你会看到类似这样的输出:

[
  ChatCompletionMessageToolCall(
    id='call_abc123',
    function=Function(
      name='get_weather',
      arguments='{"city": "北京"}'
    ),
    type='function'
  )
]

注意 arguments 这个字段——它的类型是字符串, 内容是一段 JSON 文本。模型输出的是一段文本,不是一次函数调用。

真正执行 get_weather("北京") 的,是 SDK 或者框架里的代码。 模型只负责"说出"应该调用谁、参数是什么。

完整链路:四步走

第一步:工具定义被塞进提示词

上面 tools=[...] 里的那个 JSON,最终会被服务端转换成模型能理解的提示词片段。 不同的模型有不同的格式约定——有的用特殊的 <tool> 标签, 有的直接内联 JSON Schema。这就是为什么同一个工具定义,换一个模型效果可能差很多。

第二步:模型决定输出什么

模型读到用户问题"北京今天天气怎么样",结合工具列表, 判断出"我应该调用 get_weather", 于是生成文本 {"city": "北京"} 以及一个标记表示这是工具调用。

这里有个关键点:模型是"被诱导"而非"被强制"。 如果你工具描述写得含糊,模型完全可能不调用工具,而是直接编一个天气答案给你。 这就是所谓的幻觉。

第三步:框架解析并执行

LangChain 拿到这个响应后,会:

  • 取出 name,在自己的工具注册表里查找同名函数
  • 把 arguments 字符串 json.loads() 成字典
  • 调用该函数,拿到返回值

如果 JSON 解析失败(模型偶尔会吐出不合法 JSON),框架还要负责兜底—— 有的会重试,有的会报错。这就是"解析器"存在的意义。

第四步:结果回填,再次请求

函数执行结果会被包装成一条 role="tool" 的消息, 追加到 messages 里,再发一次请求给模型:

messages = [
    {"role": "user", "content": "北京今天天气怎么样?"},
    {"role": "assistant", "tool_calls": [...]},          # 上一步模型说的
    {"role": "tool", "tool_call_id": "call_abc123",
     "content": '{"temp": 18, "desc": "晴"}'},           # 我们执行的结果
]

# 第二次请求,模型基于真实数据生成自然语言回答
resp = client.chat.completions.create(model="qwen-plus", messages=messages)
print(resp.choices[0].message.content)
# → 北京今天晴,气温 18℃ 左右,适合外出。

一次工具调用,实际上是两次(或更多次)独立的模型请求。 这一点很多人没意识到,而它直接解释了后面要说的成本问题。

三个由此推出的结论

1. docstring 质量决定工具选择准确率

既然模型是靠读工具描述来决定调不调的,那描述写得烂,模型自然选错。 好的描述应该包含:做什么、什么时候用、参数含义。

# 差的写法:模型不知道什么时候该用它
def search(q):
    ...

# 好的写法
def search_web(query: str) -> str:
    """在互联网上搜索实时信息。

    当用户询问当前事件、最新数据或你不确定的事实时使用本工具。
    不要用它查询常识性知识。

    Args:
        query: 搜索关键词,尽量具体,例如 "2026年诺贝尔物理学奖得主"
    """
    ...

2. 工具太多会导致选择困难 + token 爆炸

所有工具定义都在每一次请求里被完整发送。30 个工具, 每个描述 100 token,那就是每轮 3000 token 的固定开销—— 而且模型要在 30 个选项里做选择题,准确率必然下降。

3. 多轮对话的成本是累积的

每轮工具调用的结果都会留在 messages 里。对话越长,每次请求携带的上下文越大, 费用越高。这就是为什么生产环境需要上下文裁剪或摘要压缩策略。

回到 LangChain:它替你做了什么

LangChain 的 @tool 装饰器和 create_agent 封装了上述全部流程:

from langchain.tools import tool

@tool
def get_weather(city: str) -> str:
    """查询指定城市的当前天气。"""
    return f"{city}:晴,18℃"

@tool 做的事情,其实是读取函数的签名和 docstring, 自动生成那份 JSON Schema。这就是为什么函数名、参数类型、docstring 会直接影响模型行为——它们不是"注释",而是提示词的一部分。

理解这一点之后,我写工具函数的心态完全变了: 不再是"写个函数给程序调用",而是"写一段说明给模型读"。

小结

  • 模型不会执行函数,它只会输出文本
  • Tool Calling = 提示词诱导 + 格式约定 + 框架解析
  • 一次工具调用 = 至少两次模型请求
  • docstring 和参数类型是提示词,不是注释
  • 工具数量、对话长度都直接影响成本和准确率

想真正理解这套机制,最好的办法是自己不用框架写一遍。 下一篇文章就做这件事。