一个常见的误解
刚开始接触 LangChain 的时候,我对 @tool 这个装饰器的理解是这样的:
"给函数加上它,模型就能调用这个函数了。" 用起来也确实如此——写个查天气的函数,
装饰一下,扔给 Agent,模型真的会在需要的时候去查天气。
于是自然而然地产生了一个印象:模型具备调用外部函数的能力。
这个印象是错的。而且如果不纠正它,后面很多问题都会想不明白—— 比如"为什么工具一多模型就选错","为什么改了 docstring 效果就好转了"。
真相:模型只会输出文本
大语言模型唯一会做的事,是根据已有的 token 序列,预测下一个 token。 它没有网络请求能力,没有文件读写能力,也没有执行代码的能力。 它输出的永远只是一串文本。
那 Tool Calling 是怎么实现的?答案是:约定一种文本格式,然后诱导模型按格式输出。
具体来说,框架会做三件事:
- 把你的工具定义(名字、描述、参数结构)转换成一段提示词,塞进请求里
- 告诉模型:"如果你需要调用工具,就输出这样格式的 JSON"
- 拿到模型回复后,用解析器判断这是普通回答还是工具调用请求
看看真实的请求长什么样
与其空谈,不如直接打印一次真实请求。下面这段代码会拦截发给模型的原始 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 和参数类型是提示词,不是注释
- 工具数量、对话长度都直接影响成本和准确率
想真正理解这套机制,最好的办法是自己不用框架写一遍。 下一篇文章就做这件事。