Function Calling 的背后:模型到底「看」到了什么
所谓高深复杂的 Function Calling,说到底,不过是一段 JSON而已。我的agent里,是怎样实现精准、恰如其分的工具调用的? 带着原理与代码,带你聊聊 Function Calling
前言
设想这样一个场景。
你给自己的 Agent 加了个 get_weather 工具,然后随口问了一句:「北京今天天气怎么样」。
它没有回答你,而是吐出来这么一段:
{
"name": "get_weather",
"arguments": { "city": "北京", "date": "2026-09-14" }
}
你有点意外。提示词里你没写过「有个工具叫 get_weather」,更没交代过参数该怎么填。它是怎么知道的?
更离谱的是 date。你问的是「今天」,它自己换算成了具体日期填进去。
到这一步,很多人的结论是:模型会调用函数。
这个结论是错的,而且错得挺关键。模型从头到尾没有调用过任何函数。它只是生成了一段 JSON,剩下的活全是你的代码干的。
想清楚这件事,你才知道 Function Calling 那堆坑是从哪冒出来的。
模型看到的不是函数,是一份菜单
把一次请求整个拆开看,事情立刻就清楚了。
{
"system": "你是一个有工具调用能力的 AI 助手。",
"messages": [
{ "role": "user", "content": "北京今天天气怎么样" }
],
"tools": [
{
"name": "get_weather",
"description": "查询指定城市某一天的天气",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名,例如 北京" },
"date": { "type": "string", "description": "日期,格式 YYYY-MM-DD" }
},
"required": ["city"]
}
}
]
}
模型收到的,就是这三样东西:系统提示、对话历史、一份工具菜单。
那份 tools 数组就是菜单。每一项都是一个 JSON Schema(一种描述数据结构的格式),写清楚了工具叫什么、干什么用、参数长什么样。模型并没有"拿到"你的 get_weather 函数——它只拿到了这份说明书。
于是前面那两个问题都有答案了。它知道有 get_weather,是因为你在 tools 里写了;它知道要填 city 和 date,是因为 schema 里写了,而且每个参数还带着 description。
顺着这个思路往下看,会得到一个不太舒服的结论:工具选不准、参数填不对,大多数时候问题不在模型,在你写的那段 description。
这份菜单,每一轮都要重付一次
模型是无状态的(stateless)。它不记得上一轮你给它发过什么,所以每一轮请求,你都得把整份 tools 重新塞进去。
而它们全都算 token。
一个工具无所谓。几十个工具就是另一回事了。我写了个脚本量这笔账(脚本在文末,零依赖):
| 工具数量 | 菜单大小 | 约合 token | 占本次请求 |
|---|---|---|---|
| 1 个 | 228 字符 | 75 | 82% |
| 10 个 | 2442 字符 | 837 | 98% |
| 50 个 | 12360 字符 | 4247 | 100% |
用户那句话,从头到尾都是 39 个字符、约 17 个 token,一个字没多。
50 个工具的时候,菜单吃掉了 12360 个字符。你得先花四千多个 token 把菜单铺开,才能让模型开始读用户那句话。
这是后面所有"工具太多"问题的物理根源。
它凭什么保证吐出来的 JSON 一定能解析
模型本质上是个文字接龙机器。它凭什么每次都能吐出一段格式正确的 JSON?
两个原因。一个是训练,另一个更硬核,叫约束解码(constrained decoding)。
约束解码的意思不难理解。模型每生成一个 token,其实是在整个词表上打了一遍分,正常情况下挑分数最高的那个。但做工具调用的时候,可以在采样环节把不符合 JSON 语法的候选直接过滤掉,只放合法的 token 过关。
从第一个 { 到最后一个 },中间根本不可能出现语法错误。
换句话说,它不是"学会了写 JSON",而是被物理屏蔽了写错 JSON 的可能。
这里有个限制必须记住:约束解码约束的是格式,不是值。
它能保证「city 是一个字符串」,但它不能保证「city 是一个真实存在的城市」。
这个区别看着不起眼,但它是下一节所有问题的来源。
格式对了,值可以是假的
举个真实的例子。
你让模型去读 src/utils.ts,它给你吐回来一个:
src/helper/utils.ts
路径格式完美,字符串类型正确,schema 校验一百遍都通过。唯一的毛病是——这个文件根本不存在。
这就是幻觉参数(hallucinated arguments)。它不是格式问题,是语义问题。约束解码对此无能为力,因为它压根不管语义。
怎么治?三层,一层比一层重。
最划算的一层是把语义约束降维成格式约束。如果某个参数的可选值是有限的,别写 type: "string",直接把范围钉死:
{
"action": { "type": "string", "enum": ["read", "write", "delete"] }
}
这下模型在物理层面就没法输出第四个值了。凡是能用枚举表达的范围,都比"校验 + 报错"便宜得多。
枚举覆盖不到的地方,才轮到执行前校验。模型说要用 read_file 读 src/helper/utils.ts,你在真正执行之前先看一眼:这个路径存不存在。
最后一层是把错误说清楚。真校验失败了,返回给模型的不能是一句「参数错误」。你得告诉它错在哪、应该是什么样——因为这段错误信息会进到上下文里,成为它下一轮的输入。写得含糊,它下轮还犯;写得具体,它大概率自己就改对了。
三层是接力关系。第一层拦不住的,第二层能拦;第二层拦不住的,第三层用模型的智能兜底。
工具一多,模型就开始瞎选
现在回到那笔 token 账。它带来的后果,比"贵"严重得多。
业内的经验值是:工具数量超过 50 个,选择准确率会掉到 50% 以下。
一半的概率选错。这不是模型变笨了,是三个机制同时出了问题。
先说注意力稀释。工具越多,每个工具的 description 在整个上下文里占的比例越小,模型对它的注意力权重就越低。一份菜单上只有三道菜,你一眼就记住了;菜单上有三百道菜,你反而不知道吃啥。
然后是语义碰撞。两个工具的描述如果很接近,模型就容易分不清该用哪个。你有一个 search_code 和一个 grep_file,描述都写着「在代码里搜索内容」——人能靠经验区分,模型只能靠文字,它就懵了。
最隐蔽的是第三个,预算挤压。工具描述把上下文占满了,留给用户消息和历史对话的空间就少了。前面那个实验里,50 个工具时菜单占掉 100% 的请求,这个数字夸张,但方向是对的:工具在挤用户。
那怎么办?
Claude Code 的做法叫 Tool Search(工具检索),核心思路一句话:不是把工具全丢掉,而是默认不把它们塞进上下文。
具体是三步:
- 常用工具照常加载,模型随时能看到
- 不常用的工具标成延迟加载(deferred),只把名字留给模型
- 模型需要的时候,调一个
tool_search工具去把完整定义"捞"出来
这样,50 个工具的菜单只占 50 行名字,而不是 12000 个字符的完整 schema。
另外还有两个配套动作值得一起看。一个是命名空间隔离,MCP 来的工具统一命名成 mcp__<serverName>__<toolName>,从名字上就不可能和内置工具撞车。另一个是MCP 工具全部延迟加载——它是外来户,默认不占上下文。
从「模型说要调」到「真的执行」,中间隔着一条管线
现在你知道了:模型输出的只是一段 JSON,一段格式可信、值不可信的 JSON。
那么从这段 JSON 到工具真正被执行,中间该发生什么?
这里我想引用一句话,我觉得它是整个工具系统的第一原则:LLM 生成的输入是不可信的。
注意,不是说模型在骗你。是说它的输出在工程意义上等同于用户输入——你不能因为"是我们自己模型吐的"就跳过校验。
按照这个原则,从意图到执行之间应该有一条完整的管线:
flowchart TD
A[模型吐出 JSON] --> B[验证 schema]
B --> C[业务校准]
C --> D[补全参数]
D --> E[前置 Hook]
E --> F[权限检查]
F --> G[执行工具]
G --> H[结果截断]
H --> I[后置 Hook]
八个环节,每个都在解决一个具体问题。挑几个容易做漏的说。
补全参数这一步很关键,也最容易被忽略。模型说"读 src/index.ts",但你的 readFile 需要绝对路径——那就在这一步把相对路径补成绝对路径,而不是报错退回去。拦下来报错,模型得重来一轮;顺手补全,任务继续走。
权限检查通常分三层,一层比一层重:
| 层级 | 判断方式 | 典型场景 |
|---|---|---|
| 规则匹配 | 硬规则 | 读文件的工具一律放行,删库的一律拦下 |
| 分类器判断 | 一个轻量模型 | 规则覆盖不到的灰区 |
| 交互式询问 | 直接问用户 YES/NO | 前两层都拿不准 |
中间那层有意思:这个分类器本身往往也是一个 LLM。 用模型来判断模型想干的事安不安全,是这一层最常见的实现。
结果截断被低估得最厉害。工具返回的结果可能巨大,一个 cat 大文件就能把上下文撑爆。Claude Code 的做法是设 50k 字符的阈值,超过就写到磁盘上,只把「摘要 + 文件路径」交给模型——模型想看细节,自己去读那个文件。
最后是后置 Hook。结果拿到了,交给模型之前还能做三件事:改输出(比如过滤敏感信息)、触发后续动作(比如文件改完自动 lint)、记审计日志。
八个环节看下来,你会发现一个规律:每一步都在给上一步的输出上保险。第 1 步信不过模型的值,所以要校验;第 3 步信不过模型的路径,所以要补全;第 5 步信不过模型的目的,所以要权限;第 7 步信不过工具的输出量,所以要截断。
这篇文章里我引用了不少 Claude Code 的做法,claude 做的考虑更全面:
执行前的语义校验。我的agent简化了一些场景,这点后续版本迭代我再搞搞。
ToolSearch 的触发条件。Claude Code 是按比例判断的:schema 超过上下文窗口 10% 才启动延迟加载。这个阈值我做的粗暴一些——标了
shouldDefer的一律延迟,所有 MCP 工具一律延迟。还有
tool_search本身,我做的是包含匹配(工具名或描述里含不含这个字符串),claude采用模糊语义匹配,这样准确率高一些,当然花销也大一些。结果截断我的方式不一样。Claude Code 是超阈值落磁盘、只给摘要和路径,我是直接把超出部分截掉(默认 3000 字符)。这个差异是有代价的:截断意味着信息真的丢了,模型再也看不到;落磁盘只是"暂时不给它看"。
完整代码
下面是量那笔 token 账的脚本。零依赖、不需要 API key,node request-payload.js 直接跑:
// 一次 Function Calling 请求的完整报文:模型到底看到了什么
// 运行:node request-payload.js 零依赖,不需要 API key
// 1. 一个工具定义长什么样
const getWeather = {
name: 'get_weather',
description: '查询指定城市某一天的天气',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名,例如 北京' },
date: { type: 'string', description: '日期,格式 YYYY-MM-DD' }
},
required: ['city']
}
}
// 2. 拼出真正发给模型的报文
function buildRequest(question, tools) {
return {
system: '你是一个有工具调用能力的 AI 助手。',
messages: [{ role: 'user', content: question }],
tools: tools.map(t => ({
name: t.name,
description: t.description,
parameters: t.parameters
}))
}
}
// 3. 粗暴估算 token:中文约 1 字 1 token,其余约 4 字符 1 token
function estimateTokens(text) {
const cjk = (text.match(/[一-龥]/g) || []).length
return cjk + Math.ceil((text.length - cjk) / 4)
}
// 4. 造一批凑数的工具,模拟"工具装多了"的场景
function fakeTool(i) {
return {
name: `tool_${i}`,
description: `这是第 ${i} 个工具,用来处理某一类任务,参数需要仔细填写`,
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: '输入内容' },
mode: { type: 'string', enum: ['fast', 'safe'], description: '执行模式' }
},
required: ['input']
}
}
}
const question = '北京今天天气怎么样'
// 5. 实验:工具从 1 个涨到 50 个,菜单占了多少上下文
for (const n of [1, 10, 50]) {
const tools = n === 1
? [getWeather]
: [getWeather, ...Array.from({ length: n - 1 }, (_, i) => fakeTool(i))]
const payload = buildRequest(question, tools)
const toolsJson = JSON.stringify(payload.tools)
const toolsTokens = estimateTokens(toolsJson)
const userTokens = estimateTokens(JSON.stringify(payload.messages))
const ratio = Math.round(toolsTokens / (toolsTokens + userTokens) * 100)
console.log(`${n} 个工具:菜单 ${toolsJson.length} 字符(约 ${toolsTokens} tokens),占本次请求 ${ratio}%`)
}
console.log('用户那句话:39 字符,约 17 tokens,全程没变')
// 6. 打印一份完整报文,看看模型真正收到的东西
console.log('\n=== 完整报文(只有 1 个工具)===')
console.log(JSON.stringify(buildRequest(question, [getWeather]), null, 2))
跑出来是这样:
1 个工具:菜单 228 字符(约 75 tokens),占本次请求 82%
10 个工具:菜单 2442 字符(约 837 tokens),占本次请求 98%
50 个工具:菜单 12360 字符(约 4247 tokens),占本次请求 100%
用户那句话:39 字符,约 17 tokens,全程没变
第 5 段是重点。它只做一件事:把同一句问话配上不同数量的工具,然后量菜单占多少。工具从 1 个加到 50 个,菜单从 75 个 token 涨到 4247 个,而用户那句话始终是 17 个 token。
第 6 段把完整报文原样打出来。建议你真跑一遍,看着那段 JSON 想想:模型收到的就是这个,没有别的。它对世界的全部认知就是这段文本。
总结、咱们干了些啥
- 拆开一些 Function Calling 请求,确认模型的输入只有三样:系统提示、对话历史、工具菜单。所谓"调用函数"是错觉,模型只是生成了一段 JSON。
- 量化一些 工具菜单的 token 成本:一句 17 个 token 的问话,配 50 个工具时要先付 4247 个 token 的菜单费,菜单占请求比例一路涨到 100%。
- 分清一些 格式和值:约束解码保证 JSON 语法不出错,但完全不保证值是真的。幻觉参数就是这么来的。
- 给出一些 三层防御:能用
enum就把语义约束降维成格式约束,不能就用执行前校验兜底,再不行就把错误写清楚让模型自己改。 - 梳理出来意图到执行的八步管线,并逐条对照了我自己实现的差距。
核心价值
- "模型会调用函数"是个危险的错觉。 它的输出在工程意义上一律等同于不可信输入——想通这一点,那条八步管线就不是过度设计,而是必需品。
- 工具菜单是每轮都要重付的固定成本。 工具一多,挤掉的不只是钱,还有用户消息和历史的生存空间。这就是 ToolSearch 延迟加载存在的理由。
- 能用格式约束解决的,就不要用语义校验解决。 把
string换成enum,就是把一个"需要判断"的问题变成一个"物理不可能"的问题。这是性价比最高的一层防御。
可延伸的方向
- 我的agent最近把 ToolSearch 的触发条件从"标了
shouldDefer就延迟"改成按比例判断,比如 schema 超过上下文 10% 才启动。这样可以避免"误杀"工具 - 把
tool_search从精准匹配,升级为模糊搜索。我是用的关键词搜索,因为如果要求名字说得准,对模型不够友好,毕竟模型生成有一定随机性。 - 补上执行前的参数校验,尤其是路径类的语义校验。这里的逻辑也不算复杂。
一句话收尾:模型不是会调用工具的程序员,它只是一个照着菜单写订单的顾客。菜单是你写的,订单能不能下对,责任在你。