Tool Calling:让 LLM 从'动嘴'到'动手

0 阅读56分钟

摘要

LLM 明明只会生成 token,为什么却能调用天气 API、搜索知识库、甚至执行真实操作? 本文从最朴素的 Prompt 方案讲起,系统梳理 Function Calling、Tool Calling、Structured Outputs 的演进逻辑,并进一步讨论工具过多、并行执行、上下文膨胀、工具规模化管理和高危操作审批等工程问题。 如果你正在做 AI Agent、RAG 或 LLM 应用开发,希望这篇文章会帮你建立一张完整的工具调用知识地图。

Tool Calling:让 LLM 从"动嘴"到"动手"

一、LLM 怎么"做事"?—— 工具调用的基本原理

大家都使用过 DeepSeek、Kimi、豆包、ChatGPT 等 AI 聊天工具。你输入一个问题,它给你一个答案。看起来都挺聪明的。有时我们平时遇到不知道的命令,比如说我想查一下我电脑现在内存的使用情况,提问:我要查一下我电脑(win11)现在内存使用情况,用 PowerShell 怎么查。它会回答怎么查,给你一串命令。我们就可以复制这段命令去 PowerShell 里面执行,然后就能看到电脑的内存使用情况。

1.png

大家有没有发现一个问题,这个过程的交互方式始终遵循同一模式:你问,它答,你再问。这个 AI 看起来很聪明博学,但也就是个狗头军师,它只是动"嘴",只能回答问题,它自己不干活,干活的还得是我自己。

2.png

那能不能给大模型装上"手脚",让它不仅可以自己想,还可以自己做,而不仅仅是回答问题而已?答案是:能!那接下来的问题就是怎么给大模型装"手脚"?我们知道 LLM 的唯一输入是 文字,唯一输出也是 文字。它不能直接"运行代码",不能直接"调用 API",它只会"说话"。怎么才能让它通过"说话"来驱动外部系统呢?

原始方案:纯提示词工程

在 2022 年 — 2023 年初的上古时代(按照 AI 的进化速度,算得上是上古时代了),普遍的做法是在 LLM 外层开发一个应用程序,然后依赖提示词工程——开发者在 system prompt 或 user prompt 中用自然语言描述工具的"说明书",告诉 LLM 它有哪些工具可以用,由LLM 自己根据用户的意图输出一段结构化的"意图声明",告诉外层的应用程序"我需要你帮我调用这个工具,参数是这些"。但真正的执行权,还是在开发者的外层应用程序手里。

它大体的流程是这样的:

首先开发者写好一份工具"说明书",说明工具名称、工具的场景使用描述、使用示例等等信息,例如:

你是一个 Windows 运维命令助手。你可以帮助用户查询系统运维信息和执行运维操作。

## 可用工具
 
你有以下工具可以使用:

  1. **check_memory_usage**
  - 功能:查看当前系统内存使用情况,包括总内存、已用内存和可用内存
  - 参数:无
  
## 调用规则
  
  当用户的问题需要使用工具时,你必须严格按以下 JSON 格式回复,不要包含任何其他内容:
  
  {"tool": "工具名", "params": {参数对象}}
  
  示例:
  - 查询内存使用情况:{"tool": "check_memory_usage", "params": {}}

这其实就是一份"工具菜单",纯文本写的。

然后当用户开始像平常一样提问:"帮我看一下电脑内存使用情况"。开发者开发的外层应用程序这边,就把工具说明书附在用户消息的前面,作为发给 LLM 的提示词的一部分,一起发送给 LLM,类似:

你是一个 Windows 运维命令助手。你可以帮助用户查询系统运维信息和执行运维操作。

## 可用工具
 
你有以下工具可以使用:

  1. **check_memory_usage**
  - 功能:查看当前系统内存使用情况,包括总内存、已用内存和可用内存
  - 参数:无
  
## 调用规则
  
  当用户的问题需要使用工具时,你必须严格按以下 JSON 格式回复,不要包含任何其他内容:
  
  {"tool": "工具名", "params": {参数对象}}
  
  示例:
  - 查询内存使用情况:{"tool": "check_memory_usage", "params": {}}
  
问题:帮我看一下电脑内存使用情况。

LLM 收到之后,它自己琢磨:这事儿我自己答不了,我又没办法访问用户的电脑,但我有个 check_memory_usage 工具,看功能描述好像是用来查看系统内存使用情况的,这能用啊。于是它不直接回答用户,而是输出一段特定格式的文本,大概长这样:

{"tool": "check_memory_usage", "params": {}}

到这一步为止,LLM 什么都没"做"。它就是输出了一串字符而已。跟你在聊天框里打字没有本质区别。

那真正干活的是谁?是开发者写的那个外层程序。这个程序一直盯着 LLM 的输出,判断输出是否是合法的 JSON,如果是,它就知道:"哦,LLM 想让我调用 check_memory_usage 这个工具"。然后这个外层程序自己去调用 check_memory_usagecheck_memory_usage 可以是外层程序的一个方法,拿到 check_memory_usage 调用的返回结果后,再把结果塞回给 LLM,说:"check_memory_usage 工具返回了,结果是这个,你继续吧。"

LLM 看到结果之后,就能组织语言回答用户了:"目前系统内存使用率:……"

整个过程你捋一下,其实就是一个循环:

用户提问 → 应用程序拼接提示词 → LLM 思考要不要用工具 → 输出工具调用的"意图声明" → 外层程序解析并执行 → 把结果喂回 LLM → LLM 继续思考 → 最终回答用户

说白了,LLM 是个"指挥官",它说"给我查这个",但真正跑腿的是外层程序。LLM 全程只是在"说话",它的输出从头到尾都是文本 token,只不过有些 token 不是给用户看的,是给外层程序看的。相当于说原先是 LLM 输出要执行的命令,我们复制到命令行执行。现在是我们写了个程序帮我们执行 LLM 输出的命令,让整个思考+行动自动完成。整个过程如下图所示:

3.png

这就是AI 应用的工具调用核心机制:LLM 负责"想"和"说",外层程序负责"做"。 这个基本模式从上古时代沿用至今,基本不变。到这里,大家可能乍一看,感觉AI 的应用开发很简单呀,就是围绕 API 调用做一些胶水代码而已。但就是这个简单的工具调用模式,却从一开始就面临着不少的问题。

二、引入工具调用后面临的问题

在提示词中提供工具说明后,LLM 就可以通过输出结构化文本来驱动外部程序调用工具,解决了LLM只能说话不能"做事"的问题。但是工具调用要从"能用"走向"生产可用",面临着诸多问题。这些问题大体上分为六大类:输出格式脆弱性问题、调用意图不确定性问题、多工具调用效率问题、多步工具调用导致上下文膨胀问题、工具选择与规模化管理问题、工具调用的安全风险问题。下面对这六大类问题逐一进行说明:

输出格式脆弱性

LLM 本质上是一个文本生成器,存在幻觉,它有时会不按约定格式输出。比如你期望它输出 {"tool": "check_memory_usage", "params": {}},它可能在前面多一句"我来帮你调用工具",输出好的,我来帮你调用工具:{"tool": "check_memory_usage", "params": {}}。也可能参数名拼错,tool它输出变tool_name,例如{"tool_name": "check_memory_usage", "params": {}}。应用侧只能靠正则来猜测判断"这一段是工具调用还是普通文本",所以有时就会因为输出格式错误而导致解析失败,导致整个应用变得脆弱不稳定。

调用意图不确定性

这个问题和格式脆弱性一样,都源于 LLM 生成的不确定性,两者在工程中常常交织出现,但本质上是不同维度的问题。LLM 有时候在 该调工具的时候它可能直接编造答案直接输出(不调工具),有时候呢在不该调工具的时候又可能产生幻觉去调一个根本不存在的工具。开发者无法确定性地控制"什么时候调、调哪个"工具。

多工具调用效率低

按照前面的描述工具的调用过程,每次调用一个工具,都要两轮往返。如果在一次任务中要调用多个工具,比如用户说

"查一下北京和上海的天气"

那么流程可能是:

  1. 先查北京
  2. 等结果回来
  3. 再查上海
  4. 再等结果回来
  5. 最后再总结

如果每次只能调一个工具,总体的时间延迟就会随着工具的增加而线性积累,多工具调用场景下整个响应就相当的慢。

上下文膨胀

在一些复杂的任务中,往往需要多轮调用工具,每一轮工具返回的原始数据都会被追加到上下文窗口中,供模型继续推理。随着轮次增加,上下文里塞满了大量中间过程数据,而最终用户需要的结论可能只有一句话。比如用户说:

帮我查一下,上个季度哪些员工的差旅报销超过了 5 万元?

LLM经过推理分析后,工作流程可能是这样的:

  1. 查员工列表 工具返回全公司 200 名员工的姓名、工号、部门、职级……大约 8,000 tokens

  2. 对 200 人分批调用报销系统,返回每个人上季度的每一笔报销单:日期、出发地、目的地、交通费、住宿费、餐饮费、发票编号……累计大约 50,000 tokens

  3. 查部门预算上限,工具返回各部门预算表,大约 3,000 tokens

    到这一步,上下文里已经堆了 6 万多 tokens 的原始数据。而最终答案可能就只有一句:

"超标的有 3 人:销售部 Alice(6.2 万)、市场部 Bob(5.8 万)、技术部 Charlie(5.1 万)。"

这些中间数据不仅浪费 token 费用,让token 成本直线上升,还可能撑爆上下文窗口、干扰模型的注意力,导致模型准确率开始下降。

工具选择与规模化管理问题

如果要给LLM使用的工具非常多,且有些工具语义相近的,也会导致一些额外的问题。

  • 工具描述挤占上下文空间

    假设每个工具的定义(name + description + parameters schema)可能占 100300 tokens。如果有 100 个工具,光工具定义就占了 1000030000 tokens,严重挤占了用户输入和对话历史的空间,而且模型的注意力被大量工具描述稀释,选择准确率会急剧下降。

  • 语义重叠,误导LLM对工具的选择

    比如你同时有 search_websearch_knowledge_base,用户说"帮我查一下量子计算的最新进展",模型不确定该用哪个。

  • 缺少必要参数时瞎猜

    有时候工具选择对了,但用户忘了提供必要参数,按道理LLM应该追问,参数是什么,但是有时候LLM会自己随意编一个参数,返回给用户。比如用户说:

    "帮我查一下订单状态",

    但用户没说订单号。模型应该反问用户要订单号,而不是随便编一个。

安全风险问题

在没有工具调用 之前,LLM 最坏的结果是说错话,给你一个不准确的回答、编造一段不存在的引用。你看到后觉得不对,关掉对话,什么都没有发生。但是引入工具调用之后,性质就变了,如果LLM给出了错误判断工具调用意图,就可能会导致真实世界不可逆的后果

为了解决这些问题,各大模型厂商、AI Agent应用开发者、研究人员,分别从模型侧、应用侧提出的各种解决方案,在工程实践演进的过程中有些解决方案慢慢成为最佳工程实践、有些则慢慢被放弃。下面我们将详细展开说说这些解决方案都是这么解决上述提到的问题的。

三、解决格式脆弱性与意图不确定性

这两个问题其实是高度耦合的——格式不对,应用侧就解析不了调用意图;意图不确定,你格式对了也没用。所以业界对这两个问题的解决也是一条连续的技术线:从 Function Calling 到 Tool Calling 再到 Structured Outputs。

Function Calling:从提示词到 API 级支持

下面的 functions 示例用于说明历史演进中的接口形态;在新项目中,通常优先使用更新的 tools 接口。

为了解决输出格式的脆弱性,在 2023 年 6 月份,OpenAI 在 API 中引入了 functions 参数。通过这个参数,开发者不再需要在 prompt 里用自然语言描述工具,而是在 API 请求中通过结构化的 JSON Schema 来注册工具:

## 示例
response = client.chat.completions.create(
    model="gpt-3.5-turbo-0613",
    messages=[{"role": "user", "content": "北京今天天气怎么样?"}],
    functions=[
        {
            "name": "get_weather",
            "description": "获取指定地点的当前天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "城市名称,例如:北京"
                    },
                    "unit": {
                        "type": "string", 
                        "enum": ["celsius", "fahrenheit"]
                    }
                },
                "required": ["location"]
            }
        }
    ],
    function_call="auto"  # auto / none / 指定函数名
)

下面对关键字段逐个解释:

functions(顶层参数):值是一个数组,每个元素直接就是一个函数定义对象。

functions[].name:函数名称。模型在决定调用时会返回这个名字。

functions[].description:函数的自然语言描述。模型依赖这个描述来判断什么时候该调用这个函数。

functions[].parameters:JSON Schema 格式的参数定义。注意,这里的参数 type 描述,并不会保证让大模型生成的参数严格符合 schema——相比纯文本时代有了大幅提升,但模型仍然只是"尽力而为",并非 100% 可靠。

function_call(顶层参数):控制模型是否调用函数——这个就是为了解决调用意图不确定性。它的可选值如下:

function_call = "auto"                          # 模型自己决定(默认)
function_call = "none"                          # 禁止调用任何函数
function_call = {"name": "get_weather"}         # 强制调用指定函数

模型的响应不再是自由文本,而是结构化的函数调用对象

## 示例
{
    "id": "chatcmpl-abc123",
    "object": "chat.completion",
    "model": "gpt-3.5-turbo-0613",
    "choices": [
        {
            "index": 0,
            "message": {
                "role": "assistant",
                "content": null,
                "function_call": {
                    "name": "get_weather",
                    "arguments": "{\"location\": \"Beijing\", \"unit\": \"celsius\"}"
                }
            },
            "finish_reason": "function_call"
        }
    ]
}

message.function_call(单数,不是数组):它是一个对象,不是数组。也就是说模型一次只能返回一个函数调用。想调两个?对不起,先调一个,等结果回来,再调下一个。

function_call.name:模型选择的函数名。

function_call.arguments:参数,JSON 格式的字符串。需要 json.loads() 解析。

finish_reason:值为 "function_call"(单数)。

最终结果回传给大模型:

messages = [
    {"role": "user", "content": "北京今天天气怎么样?"},
    
    # 模型的响应——原样放回
    {
        "role": "assistant",
        "content": None,
        "function_call": {
            "name": "get_weather",
            "arguments": "{\"location\": \"Beijing\", \"unit\": \"celsius\"}"
        }
    },
    
    # 函数执行结果
    {
        "role": "function",            # ← 注意:是 "function",不是 "tool"
        "name": "get_weather",         # ← 用 name 关联,不是 tool_call_id
        "content": "{\"temp\": 22, \"condition\": \"sunny\"}"
    }
]

role: "function":使用专门的 "function" 角色。

name:通过函数名来关联调用和结果。

那这种 API 的方式是怎么解决我们前面说的格式脆弱性和意图不确定性的呢?其实是通过对模型进行了专门的微调训练来实现的,训练数据包含大量"用户问题 → 函数调用 JSON"的配对样本。使模型能够理解函数定义的 JSON Schema 格式,判断用户意图是否匹配某个函数,知道何时该调用函数、何时该直接回答,并生成符合 Schema 约束的合法 JSON 参数,从而降低输出格式的脆弱性、提高调用意图的确定性。OpenAI 把这种方式称为 Function Calling。

我们再回头来看下,和纯提示词方案相比,Function Calling 至少带来了一些改善:

针对格式脆弱性——因为使用Function Calling 让工具调用与普通回复有了结构性区分,模型返回的不是需要正则解析的自由文本,而是明确的 function_call 对象,包含函数名和 JSON 格式的参数。应用层直接反序列化即可。同时 finish_reason 字段会标明本次响应是 "function_call" 还是 "stop"(普通文本),工具调用与普通回复有了明确的结构性区分。参数也有了 JSON Schema 约束(虽然不是 100% 保证,但远好于自由文本)。

针对意图不确定性——通过 function_call 参数,开发者对调用行为有了更强控制,可以控制模型是自主决定、禁止调用还是强制调用指定函数。

但 Function Calling 也还存在一些问题:

  • 每次只能调用一个工具
  • 参数仍然不是严格 schema 遵循,虽然比纯提示词时代稳定很多,仍非 100% 可靠

因此 2023年11月份OpenAI 引入了 tools 来改善这两个问题。

Tool Calling:并行调用与 ID 关联

2023 年 11 月 OpenAI 引入了 tools 作为新的统一工具接口,并逐步将 functions 标记为 deprecated。,所以 functions 这个参数目前有些大模型厂商已经不支持了,一般都用新的 tools。从这个版本开始,OpenAI 把 Function Calling 改称 Tool Calling。

tools参数和functions相比,有两个关键的升级,第一个是并行函数调用(Parallel Function Calling)——模型可以在一次响应中同时输出多个工具调用请求。比如"查一下北京和上海的天气"不再需要两轮往返,一次就能拿到两个调用指令,大幅度减少延迟。这是为了解决多工具调用效率低的问题(不过完整的并行方案还需要应用层配合,这一点我们在后面详细展开)。其次是每个调用有独立的 id——并行调用中的每个 tool_call 都有唯一 id,返回结果时通过 tool_call_id 关联,不再依赖顺序。

tools = [
    {
        "type": "function",          # 工具类型,目前只有 "function"
        "function": {                # 具体的函数定义
            "name": "get_weather",
            "description": "Get current weather for a given city",
            "parameters": {          # 函数参数的 JSON Schema
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "City name, e.g. Beijing"
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "Temperature unit"
                    }
                },
                "required": ["location"],
            }
        }
    }
]

对比 functionstools 的结构多了一层包装——外层有 type 字段标识工具类型,具体的函数定义嵌套在 function 里面。各字段含义如下:

type:工具的类型。目前 Chat Completions API 中唯一合法的值是 "function"。之所以设计这个字段,是为未来扩展预留的——在 Assistants API 中已经出现了 "code_interpreter""file_search" 等内置工具类型。对于开发者自定义的工具,永远填 "function"

function.name:函数名称。模型在决定调用工具时会返回这个名字,你的代码靠它来做路由分发。命名要求是只能包含字母、数字、下划线和连字符(a-zA-Z0-9_-),最长 64 个字符。好的命名应该是自描述的,比如 get_weatherfunc1 好得多,因为模型会参考名字来理解这个工具的用途。

function.description:函数的自然语言描述。这是模型决定"要不要调用这个工具"时最重要的依据之一。描述越清晰、越具体,模型选择工具的准确率越高。比如 "获取指定城市的当前天气" 就比 "天气查询函数" 好。还可以补充必要的使用场景说明,比如 "当用户询问天气、气温或气候状况时使用此功能"

function.parameters:用 JSON Schema 格式定义函数的参数。

parameters.type:顶层必须是 "object",因为函数参数本质上是一组键值对。

parameters.properties:定义每个参数。每个参数本身也是一个 JSON Schema,可以指定 type(数据类型)、description(参数描述)、enum(可选值枚举)等。description 对模型非常重要——它帮助模型理解应该往这个参数里填什么值。

parameters.required:一个数组,列出所有必填参数的名称。不在这个数组里的参数是可选的,模型可能会填也可能不填。

parameters.additionalProperties:设为 false 时,禁止模型生成 schema 中未定义的额外参数。在后文即将介绍的 strict: true 模式下这是必须的。

tools 版本用 tool_choice 替换了原来的 function_call 参数,并且提供了更丰富的控制选项:

## 示例
tool_choice = "auto"       # 模型自己决定是否调用工具(默认值)
tool_choice = "none"       # 禁止调用任何工具,强制模型只生成文本
tool_choice = "required"   # 强制模型必须调用某个工具(但不指定哪个)——2024年4月新增
tool_choice = {            # 强制调用指定的工具
    "type": "function",
    "function": {"name": "get_weather"}
}

"auto" 是最常用的——让模型根据对话内容自主判断。用户问"北京天气怎么样",模型会调用天气工具;用户说"你好",模型就正常回复文本。

"none" 用于你明确不想让模型调工具的场景。比如你在多轮对话中已经拿到了工具返回结果,现在只想让模型基于结果生成总结性回答。

"required" 强制模型必须调用工具,但让模型自己选择调哪个。适用于你明确知道这一步一定需要工具介入的场景。这个选项是 OpenAI 在 2024 年 4 月才新增的,初始的 tools 版本中并没有。

指定函数名 是最强的约束——模型被强制调用你指定的那个函数,唯一的自由度是填参数。适用于你已经明确知道需要调什么、只需要模型从用户输入中提取参数的场景。

响应格式也从 function_call 变成了 tool_calls(注意是复数):

## 示例
{
    "id": "chatcmpl-abc123",
    "object": "chat.completion",
    "model": "gpt-4o",
    "choices": [
        {
            "index": 0,
            "message": {
                "role": "assistant",
                "content": null,
                "tool_calls": [
                    {
                        "id": "call_abc123",
                        "type": "function",
                        "function": {
                            "name": "get_weather",
                            "arguments": "{\"location\": \"Beijing\", \"unit\": \"celsius\"}"
                        }
                    }
                ]
            },
            "finish_reason": "tool_calls"
        }
    ]
}

message.content:当模型决定调用工具时,content 通常为 null。模型把"精力"放在了生成工具调用上,而非文本回复。但有些场景下模型可能同时生成一些文本(比如 "Let me check the weather for you")和工具调用,这时 content 不为 null。

message.tool_calls:一个数组,包含模型想要调用的所有工具。数组长度可以是 1(单个调用)也可以大于 1(并行调用)。

tool_calls[].id:每个工具调用的唯一标识符,由 OpenAI 生成。这个 id 的核心作用是关联调用和结果——当你把工具执行结果返回给模型时,必须通过 tool_call_id 指明这是哪个调用的结果。在并行调用场景下这尤其重要。

tool_calls[].type:目前始终为 "function"

tool_calls[].function.name:模型选择调用的函数名,和你在 tools 中注册的 name 对应。你的代码根据这个值做分发。

tool_calls[].function.arguments:函数参数,是一个 JSON 格式的字符串(注意不是 JSON 对象,而是字符串)。你需要用 json.loads() 解析后才能使用。之所以是字符串而非对象,是因为在流式输出(streaming)场景下,arguments 是逐 token 拼接出来的,只有完整接收后才能解析。

finish_reason:值为 "tool_calls" 表示模型因为要调用工具而停止生成(而非正常结束)。其他可能的值包括 "stop"(正常结束)、"length"(达到 token 上限)等。你的代码应该检查这个字段来决定下一步流程。

最终结果回传给大模型:

messages = [
    {"role": "user", "content": "北京和上海今天天气怎么样?"},
    
    # 模型的响应(包含工具调用)——必须原样放回
    {
        "role": "assistant",
        "content": None,
        "tool_calls": [
            {
                "id": "call_abc123",
                "type": "function",
                "function": {
                    "name": "get_weather",
                    "arguments": "{\"location\": \"Beijing\"}"
                }
            },
            {
                "id": "call_def456",
                "type": "function",
                "function": {
                    "name": "get_weather",
                    "arguments": "{\"location\": \"Shanghai\"}"
                }
            }
        ]
    },
    
    # 工具返回结果——每个 tool_call 对应一个 tool message
    {
        "role": "tool",
        "tool_call_id": "call_abc123",     # 关联到哪个调用
        "content": "{\"temp\": 22, \"condition\": \"sunny\"}"  # 工具返回值
    },
    {
        "role": "tool",
        "tool_call_id": "call_def456",
        "content": "{\"temp\": 18, \"condition\": \"cloudy\"}"
    }
]

role: "tool":专门用于承载工具执行结果的消息角色。这是区别于 "user""assistant""system" 的第四种角色。

tool_call_id:必须和模型响应中对应 tool_calls[].id 完全一致。这是关联机制的核心。

content:工具执行的返回值,必须是字符串。如果工具返回的是结构化数据,你需要序列化成 JSON 字符串。模型会读取这个内容来生成最终回答。

一个关键的规则:模型的 assistant 消息(包含 tool_calls)必须原样保留在 messages 中。你不能只发 tool 消息而跳过前面的 assistant 消息——API 要求 tool 消息前面必须有对应的 assistant 消息,否则会报错。

2024 年 4 月 Anthropic Claude 正式开放 Tool Use API beta,在消息中通过 tools 参数注册工具,模型返回 tool_use content block,机制类似。因此我们经常看到的 Function Calling、Tool Calling、Tool Use 差不多是一个意思。它们在理念上都属于“让模型输出结构化的工具调用意图”,但不同厂商在消息格式、结果回传、内置工具能力和角色设计上并不完全相同。

到这里,格式脆弱性和意图不确定性已经有了大幅改善:通过 function.parameters 的 JSON Schema 约束输出格式,通过 tool_choice 控制调用意图。但格式仍然不是 100% 可靠——模型偶尔还是会生成不合法的 JSON、漏掉 required 字段、给 enum 填一个不存在的值。

Structured Outputs(Strict 模式)

到了2024 年 8 月,OpenAI 推出了 Structured Outputs。通过在工具定义中开启 strict: true,模型在支持的 schema 子集内可以更稳定地按 JSON Schema 输出参数。相比传统的“尽力而为”式 tool calling,格式可靠性得到了进一步的提升,已经接近100%:

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather for a given city",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "City name"
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"]
                    }
                },
                "required": ["location", "unit"],       # ← strict 模式下所有字段必须列入 required
                "additionalProperties": False            # ← strict 模式下必须设为 false
            },
            "strict": True                               # ← 开启 Structured Outputs
        }
    }
]

开启 strict: true 后有几个注意事项:所有参数字段都必须列入 required 数组,additionalProperties 必须设为 false,并且对 JSON Schema 的复杂度有一定限制(不支持所有 JSON Schema 特性)。

strict 模式是基于**受约束解码(constrained decoding)**技术——在模型推理阶段,系统会将 JSON Schema 预编译为有限状态机或上下文无关文法,然后在每一步 token 生成时,实时将不符合当前状态的 token 从候选集中排除,从而在数学意义上保证输出一定符合 schema 结构。所以目前使用strict 模式会存在以下现象:首次使用某个 schema 时会有额外延迟(因为需要编译),以及 schema 复杂度必须受限(因为过于复杂的约束会导致状态空间爆炸,无法高效地实时过滤候选 token),所以不支持所有 JSON Schema 特性。

需要注意的是,strict: true它解决的主要是输出的格式合规问题,而不是业务语义的正确性问题。就是说模型保证输出的 JSON 结构一定是对的,但至于填入的值是否正确是另一回事——例如:它仍然可能把供应商 A 的账号填成供应商 B 的,或者在 "age" 字段里填入一个不合理的负数。因此,应用侧仍然需要处理拒答(模型因安全原因拒绝生成)、截断(达到 max_tokens 导致输出不完整)、以及业务规则校验等异常情况。

应用层兜底:带反馈的自动重试

我们前面提到的functions callingTool CallingStructured Outputs等方法都是在模型侧发力,要么微调模型、要么在推理阶段约束输出,这些对于应用侧的开发工程师没什么发力点。那对于应用侧的开发工程师,在应用大模型输出不确定性的情况下要保证工具调用的稳定性和准确性,在应用侧还需要做什么呢?在生产环境中仍然需要应用层自己的防御机制。原因有两个:第一,并非所有模型都支持 strict 模式——你可能使用的是开源模型、较旧的 API 版本、或者其他不提供受约束解码能力的推理服务;第二,即使格式完全正确,模型在"调用意图"层面的判断——该不该调用工具、该选哪个工具、参数的语义是否正确——始终不是 100% 可控的,这些问题 strict 模式管不了。因此在生产环境中仍然需要应用层的防御。这就引出了一个非常重要的工程模式——带反馈的自动重试(Retry with Validation Feedback)。它的核心思路是:当模型的输出未通过校验时,不要简单地丢弃或静默重试,而是把校验失败的具体错误信息结构化地拼接回下一轮的 prompt 中,让模型能够"看见"自己错在哪里,从而有针对性地自我纠正。LLM 看到自己的错误信息后,通常在第二次调用时就能修正。从而在应用层实现"调用 → 校验 → 自动重试"的完整流程。

比如说假设模型第一次输出了一个工具调用,但参数 date 的格式不对——我们期望的是 "2025-03-15",模型却输出了 "三月十五号"。如果直接重试,模型可能还会犯同样的错误。而带反馈的重试会在下一轮 prompt 中追加类似这样的信息:

你上一次的工具调用未通过校验,错误如下:
- 参数 "date":期望格式为 ISO 8601(如 "2025-03-15"),但你输出了 "三月十五号"。
请修正后重新调用。

模型看到这段具体的错误描述后,通常在第二次调用时就能准确修正。实践中,绝大多数格式和参数错误在 1-2 轮反馈内就能被解决。

这个流程可以概括为一个简单的循环:模型生成输出 → 应用层校验 → 如果通过则正常执行 → 如果未通过则将错误信息结构化反馈给模型 → 模型重新生成 → 再次校验……直到通过或达到最大重试次数后走降级逻辑(比如返回错误提示、转人工处理)。如下图所示:

4.png

到这里,格式脆弱性和意图不确定性的问题已经得到了从模型层到应用层的多级保障,也是目前AI应用的常规组合做法。

接下来我们来看看另外一个问题多工具调用效率低是怎么解决的。

四、解决并行效率与上下文膨胀

前面我们介绍 Tool Calling 的时候提到,模型可以一次返回多个调用意图。但这里要进一步讲的是:模型返回了多个调用意图,不代表你的代码就自动并发执行了。 如果模型返回了 3 个工具调用,但是你的程序用 for 循环一个个执行,那就根本没有并发。所以说真正实现并发调用不仅需要模型侧一次返回了多个调用意图,还需要应用层能够并发执行这些工具。怎么高效、安全、稳定的实现并发执行这些工具,就是AI应用开发者这边要考虑的地方。

换句话说:并发 Tool 调用 = LLM 层一次返回多个调用意图(模型侧行为) + 应用层真正并发执行这些工具(你的代码行为)

因此,对于模型返回的多个互相独立的工具调用,应用层必须使用异步并发或多线程机制来真正并行派发和执行它们。 具体的实现方式因语言和框架而异,但核心思路都是一样的——将多个 I/O 密集型的工具调用同时发出去,等待所有结果返回后再统一交给模型处理。

并行执行节省了延迟,但也带来了复杂性。比如说在并行执行的批次中,一个工具失败了怎么办?当多个工具并发调用同一个外部 API 时,一个工具的超时或失败不应该级联影响到其他工具,这种怎么处理?具体的并发实现代码,我们会专门写一篇来讲讲这块的内容。

我们这里先来讲讲多轮工具调用情况下带来的另外一个问题——上下文膨胀。

更深层的问题:多轮调用的延迟与上下文膨胀

并行执行解决了"同一轮中多个工具的并发"问题,但在需要多轮次工具调用的复杂任务中,还有两个更深层的问题:

延迟累积: 每次工具调用都要做一次完整的 LLM 推理。如果轮次多了,虽然每次几百毫秒到数秒,但累积起来延迟就非常严重。

上下文膨胀: 每次调用的中间结果全部堆积在上下文窗口里。可能把上下文窗口撑爆,或者干扰模型对重要信息的注意力。

我们举具体的例子来说明。假设一个任务:"查一下工程部 20 个人中,谁的 Q3 差旅费超标了?"。传统 Tool Calling 方式下,LLM 和你的应用之间要做这样的乒乓球式来回

第1轮 LLM推理 → "我需要调用 get_team_members('engineering')"
      ↓ 你的应用执行,返回20人的数据
第2轮 LLM推理 → "我需要调用 get_expenses('emp_001', 'Q3')"
      ↓ 你的应用执行,返回该员工50条消费记录
第3轮 LLM推理 → "我需要调用 get_expenses('emp_002', 'Q3')"
      ↓ ...
      ... 重复20多次 ...
第23轮 LLM推理 → "我需要调用 get_budget_by_level('senior')"
      ↓ ...
第25轮 LLM推理 → "综合以上所有数据,超标的人是 Alice 和 Bob"

25 次工具调用 = 25 次推理 pass,延迟累积非常严重。同时 20 个人的信息 + 2000 多条消费记录 + 预算表,全部作为 token 塞进上下文,可能有 200KB 的数据,但最终其实我们只需要知道"Alice 和 Bob 超标了"这 1KB 的结论。

Programmatic Tool Calling:用代码编排工具

针对这些问题,Anthropic 2025 年 11 月推出了 Programmatic Tool Calling。它的核心思路是:让大模型写一段编排代码(Python ),在沙箱中执行,由代码去调用工具、处理中间数据,最终只把精炼的结果返回给 Claude 的上下文。 流程变成了这样:

第1轮 LLM推理 →  生成一段 Python 代码:
  ┌──────────────────────────────────────────────┐
  │ team = await get_team_members("engineering")  │
  │ expenses = await asyncio.gather(*[            │
  │     get_expenses(m["id"], "Q3")               │
  │     for m in team                             │
  │ ])                                            │
  │ budgets = ...                                 │
  │ exceeded = [过滤出超标的人]                      │
  │ print(json.dumps(exceeded))                   │
  └──────────────────────────────────────────────┘
      ↓ 代码在沙箱环境中执行
      ↓ 代码遇到 get_team_members() → 暂停,向你的应用请求
      ↓ 你返回结果 → 代码继续运行(结果由代码处理,不回 Claude)
      ↓ 代码遇到 get_expenses() × 20 → 并行请求
      ↓ 你返回结果 → 代码继续运行(中间数据在代码里处理)
      ↓ 代码执行完毕,print 出最终结果
      ↓
第2轮 LLM推理 → Claude 只看到最终输出:
      "[{name: Alice, spent: 12500, limit: 10000}, ...]"
      → 生成自然语言回答

对应流程图如下:

5.png

具体 Anthropic 是怎么做的呢?其实是通过 tools 参数注册一个特殊的"代码执行工具",它的本质其实和普通的 Tool Calling 是同一套机制——代码执行本身就是一个"工具"。Claude 在决定使用这个工具时,它的"参数"就是一段 Python 代码。具体来看:

第一步:在 API 请求中注册代码执行工具

注册一个 Anthropic 内置的 code_execution 工具,这等于告诉 Claude:"你有一个 Python 沙箱可以用。" 第二,注册你的业务工具(比如 get_team_membersget_expenses),并在每个工具上设置 allowed_callers: ["code_execution_20260120"],这等于告诉 Claude:"这些工具不要走传统的一个个调用的方式,而是通过写代码来调用。"

response = client.beta.messages.create(
    betas=["advanced-tool-use-2025-11-20"],
    model="claude-sonnet-4-5-20250929",
    max_tokens=4096,
    tools=[
        # 1. 注册一个"代码执行"工具(Anthropic 内置的)
        {
            "type": "code_execution_20260120",
            "name": "code_execution"
        },
        # 2. 业务工具,关键是 allowed_callers 字段
        {
            "name": "get_team_members",
            "description": "Get all members of a department...",
            "input_schema": {...},
            "allowed_callers": ["code_execution_20260120"]  # ← 允许从代码中调用
        },
        {
            "name": "get_expenses",
            "description": "...",
            "input_schema": {...},
            "allowed_callers": ["code_execution_20260120"]
        },
        {
            "name": "get_budget_by_level",
            "description": "...",
            "input_schema": {...},
            "allowed_callers": ["code_execution_20260120"]
        }
    ],
    messages=[{"role": "user", "content": "工程部谁的Q3差旅费超标了?"}]
)

这里有两个关键设置:

code_execution 工具:这是 Anthropic 提供的一个内置工具类型。它告诉 Claude:"你有一个 Python 沙箱可以用。"

allowed_callers:这个字段标记了哪些业务工具可以从代码内部调用,而不是走传统的"LLM 推理 → 请求工具 → 结果回到上下文"路径。

第二步:Claude 生成编排代码(第 1 次 LLM 推理)

Claude 收到用户请求后,判断这个任务涉及大量数据遍历和聚合,决定不走传统的逐个工具调用路径,而是选择调用 code_execution 这个工具。它的输出和普通 tool call 结构完全一样——工具名是 code_execution,输入参数是一段 Python 代码。这段代码里包含了循环、并发、条件判断、数据聚合等完整的编排逻辑。

到这一步为止,Claude 的工作就暂时结束了。它只做了一次推理,产出了一段代码。:

{
  "type": "server_tool_use",
  "id": "srvtoolu_abc",
  "name": "code_execution",
  "input": {
    "code": "import asyncio\nimport json\n\nteam = await get_team_members('engineering')\n\nlevels = list(set(m['level'] for m in team))\nbudget_results = await asyncio.gather(*[\n    get_budget_by_level(level) for level in levels\n])\nbudgets = {level: b for level, b in zip(levels, budget_results)}\n\nexpenses = await asyncio.gather(*[\n    get_expenses(m['id'], 'Q3') for m in team\n])\n\nexceeded = []\nfor member, exp in zip(team, expenses):\n    total = sum(e['amount'] for e in exp)\n    if total > budgets[member['level']]['travel_limit']:\n        exceeded.append({'name': member['name'], 'spent': total, 'limit': budgets[member['level']]['travel_limit']})\n\nprint(json.dumps(exceeded))"
  }
}

你看,这和普通的 tool_call 结构完全一样——工具名是 code_execution,输入参数是 {"code": "..."}。Claude 并没有使用什么特殊的输出模式,它就是在用标准的 Tool Calling 机制,只不过调用的恰好是一个"执行代码"的工具。

第三步:代码在沙箱中执行

上面输出的代码片段被送入 Anthropic 托管的沙箱容器中运行。注意,这个沙箱不在你的机器上,而是在 Anthropic 的服务器上。

代码开始逐行执行。当它跑到一个普通的 Python 语句(比如变量赋值、数据处理、循环逻辑),沙箱自己就处理了。但当代码执行到你注册的业务工具调用(比如 await get_team_members("engineering"))时,沙箱无法自己完成这个操作——因为它不知道怎么查你的数据库。于是代码暂停,API 把这个工具调用请求返回给你的应用程序。

第四步:代码执行完毕,只有 print 输出回到 Claude

你的应用收到请求后,去查数据库、调外部 API、或者做任何需要做的事情,然后把结果通过 API 返回。

这里的关键是:你返回的结果直接送回沙箱,而不是送回 Claude 的上下文窗口。沙箱拿到结果后,代码从暂停的地方继续往下跑。

如果代码里有 20 个工具调用(比如循环查 20 个人的差旅费),这个"暂停→你执行→返回→沙箱继续"的过程就重复 20 次。但全程 Claude 模型都不参与,不需要做任何推理。所有中间数据都留在沙箱的内存里,被代码程序化地处理(求和、比较、过滤等)。

第五步:代码执行完毕,只有 print 输出进入 Claude 上下文

当整段代码跑完,沙箱中 print() 输出的内容——也只有这些内容——作为 code_execution_tool_result 返回给 Claude。比如最终输出可能就是:

[{"name": "Alice", "spent": 12500, "limit": 10000}]

代码运行中间的 20 个人的详细信息、2000 多条消费记录、预算表数据等等中间信息,全都不会放进 Claude 的上下文里面。

第六步:Claude 基于代码运行的最终结果生成最终回答(第 2 次 LLM 推理) 最终发给Claude只是最后的一小段出,然后用自然语言整理回答用户:"工程部 Q3 差旅费超标的是 Alice,她花了 12500 元,超出了 10000 元的预算上限。"

回顾整个过程中,Claude 模型只做了两次推理:第一次生成代码,第二次根据结果生成回答。而传统的工具调用方式下,可能需要 20 多次推理(每调一个工具就要回到 Claude 做一次决策)。

通过这种方式,就可以把进入 Claude 上下文的数据从可能的 200KB 原始数据缩减到了只有最终结论的那几行,大概不到 1KB。同时解决了延迟和上下文膨胀问题。

这种通过大模型输出代码来执行动作,和传统的输出 JSON 来调用工具有本质的区别。其实这种方式有自己的名字——CodeAct(Code-as-Action)。其核心论点是:传统 Agent 让 LLM 输出 JSON 或 Text 格式来调用工具,而 LLM 在训练过程中见过海量的代码,却只见过少量人工构造的 tool call 样本。因此,让 LLM 用可执行的 Python 代码来表达动作,比用 JSON/Text 格式更自然、更强大。 Programmatic Tool Calling可以看作是更广泛的CodeAct思想的具体产品化实现。目前也有其他一些不同厂商和框架的也在实现类似的能力,比如OpenHands(原 OpenDevin)、 2025 年初爆火的 Manus Agent、 Cloudflare Code Mode 、LangGraph CodeAct、 Letta 、 freeact等等。CodeAct 这里不展开说,后续有机会再写一篇专门介绍CodeAct的文章。

五、解决"选哪个工具"的问题

前面几节讨论的都是"工具调用的机制层面"的问题,现在我们进入另一个维度的问题——工具选择与规模化管理问题

当工具变多以后,模型怎么从一堆工具中选对的那个?

在第三节介绍 Tool Calling 时我们提到了 tool_choice 参数,它可以让开发者在代码层面控制模型"要不要调工具、调哪个"。但它的前提是——作为开发者,你在写代码的时候就得已经知道这个请求应该调哪个工具。比如你做了一个天气查询页面,用户点了"查询"按钮,你明确知道这一步就是要调 get_weather,所以你直接写死 tool_choice: {"type": "function", "function": {"name": "get_weather"}}。这种场景下,工具选择不会出现什么问题。

但在另外一种更常用、更典型的使用场景中——你不知道用户会说什么,需要让模型根据自己的推理从一堆工具中选择合适的那个。 比如用户说"帮我订明天下午从北京到上海的高铁票,顺便查一下上海明天的天气",模型需要自己判断应该调 book_trainget_weather 两个工具。这时候 tool_choice 只能设为 "auto",选择权完全交给模型。在 tool_choice = "auto" 的场景下,会面临3个 常见的工程问题:

工具太多,Token 爆炸

如前文第二节所述,工具数量过多会导致 token 爆炸和选择准确率下降。tool_choice 对这个问题并没有帮助——它控制的是"要不要调",不是"塞多少工具定义进 context"。那怎么办呢?

目前来看,有2中解决方案:Tool Retrieval(工具检索) 和 工具分层组织。

Tool Retrieval(工具检索)

这种方案的核心思路是:把"从 100 个工具中选择"变成"先用向量搜索筛出 3~5 个最相关的工具,再让 LLM 从中选择"。这和 RAG 检索文档的思路一模一样,只不过被检索的对象从文档片段变成了工具描述。

AWS 开发者社区(dev.to)2026 年 3 月发布了一篇基于 Strands Agents + FAISS 的完整实验案例,用 29 个旅行相关工具做测试,结果是:

  • 传统方案(全部 29 个工具塞进 context):平均每次查询消耗 1557 tokens
  • 语义筛选方案(只传入 Top-3 工具):平均消耗 275 tokens,token 降低 82%

具体实现非常简洁,如下图所示:

6.png

生产系统中有报告实现了 89% 的 token 缩减。2026 年初的一篇研究论文(Internal Representations as Indicators of Hallucinations in Agent Tool Selection,arXiv:2601.05214)也验证了:工具数量和工具选择幻觉之间存在正相关,减少工具数量可以显著降低选错的概率。

分层工具组织(Hierarchical Tool Management)

当工具达到数百个量级时,单单靠向量检索有时候还不够。MCP 社区(2025 年 8 月 GitHub Discussion #532)提出了分层管理的方案:先按类别组织工具,LLM 先选类别,再看该类别下的具体工具。Agent 启动时只加载工具类别索引,按需惰性加载具体工具定义,避免一次性把所有工具定义塞进 context。有点像图书馆给图书建分类索引一样,你去图书馆找找书,不需要从一楼开始扫描所有书名,而是先找到"计算机科学"那一架,再在那一架里找你需要的书。

分层工具组织的基本执行模式是把一次工具调用拆分为两轮 LLM 推理,如下面这个旅行 Agent 示例所示:

7.png

第一轮 LLM 调用:
  可选类别 → ["酒店", "航班", "租车", "餐厅"]
  LLM 选择 → "酒店"

第二轮 LLM 调用:
  酒店类别下的工具 → [search_hotels, get_hotel_pricing, check_availability, book_hotel]
  LLM 选择 → get_hotel_pricing

这一思路已在 2025 年 8 月发布的 VS Code 1.103 版本中,以实验性功能的形式引入了 'Tool grouping for large toolsets'。VS Code 的解决方案非常直接:当工具总数超过阈值(通过 github.copilot.chat.virtualTools.threshold 控制)时,系统自动将工具按类别打包成若干"虚拟工具"(每个虚拟工具对应一个类别),把这些虚拟工具作为第一层选项呈现给模型,模型选中某个虚拟工具后再展开该类别的真实工具列表。整个过程对用户透明,无需手动管理。

工具语义重叠

除工具太多导致的问题,另外一个场景问题就是工具语义重叠导致大模型选择工具混乱的问题,比如你同时有 search_websearch_knowledge_base,用户说"帮我查一下量子计算的最新进展",模型会有点懵,不确定该用哪个。这种情况,一般就是要么把工具描述写具体清楚,要么把语义重叠的工具合并,用参数区分。

Description 工程——写好工具描述是第一优先级

把工具描述写具体清楚,这是最直接也是性价比最高的方案。OpenAI 官方 Function Calling 文档和多篇工程实践文章都强调了 description 的重要性。核心原则是明确写出"什么时候该用"和"什么时候不该用":

# 不好的写法——两个工具描述太模糊,LLM 分不清
tools = [
    {"name": "search_web", "description": "搜索信息"},
    {"name": "search_knowledge_base", "description": "在数据库中搜索信息"}
]

# 好的写法——明确划定边界条件
tools = [
    {
        "name": "search_web",
        "description": "在公共互联网上搜索实时的、最新的信息。"
                       "当用户询问近期新闻、实时价格、"
                       "天气或任何频繁变化的内容时使用此工具。"
                       "不要用于查询公司内部数据或文档。"
    },
    {
        "name": "search_knowledge_base",
        "description": "在公司内部知识库中搜索政策、"
                       "流程、产品规格和历史记录。"
                       "当用户询问公司特定信息时使用此工具。"
                       "不要用于查询通用网络信息或时事新闻。"
    }
]
合并重叠工具

合并重叠工具的意思就是如果两个工具的功能确实高度重叠且底层实现相似(只是筛选维度不同),更好的做法是合并成一个工具,用参数来区分行为:

# 合并前:两个容易混淆的工具
{"name": "search_hotel_by_price", "description": "按价格范围搜索酒店"}
{"name": "search_hotel_by_rating", "description": "按评分搜索酒店"}

# 合并后:一个工具,用参数区分
{
    "name": "search_hotels",
    "description": "通过多种筛选条件搜索酒店",
    "parameters": {
        "sort_by": {"type": "string", "enum": ["price", "rating", "distance"]},
        "min_price": {"type": "number"},
        "max_price": {"type": "number"},
        "min_rating": {"type": "number"}
    }
}

但是如果两个工具的后端逻辑完全不同(比如一个调外部 API、一个查内部数据库),合并可能反而增加工具内部的路由复杂度,此时用 Description 来清晰划分边界是更好的选择,所以具体情况还是要具体分析。

缺少必要参数时瞎猜

第三个问题,就是缺少必要参数时瞎猜的问题,比如说用户说"帮我查一下订单状态",但没说订单号。这种情况,预期情况是模型应该反问用户要订单号,而不是随便编一个完事。

System Prompt 明确指令

最简单也最普遍的做法,在 system prompt 中明确告诉模型"不知道就问"。关键是怎么明确,我们举个例子说明。比如说:

# 反例:太模糊,模型容易无视
当参数缺失时,请询问用户。

这种写法的问题在于,它没有告诉模型"什么算缺失"、"怎么问"、"问几个",也没有明确禁止猜测行为。在模型的推理过程中,"询问用户"和"合理推断一个值"都是合法选项,指令没有拉开两者的优先级差距。

下面是一个更完整、更有效的正例

## 工具调用规范

**参数缺失时的处理原则:**
- 绝对不要猜测、假设或推断任何参数值
- 如果必要参数缺失,必须先向用户询问,再调用工具
- 一次只问一个最关键的问题,不要连续追问多个

**判断"参数是否缺失"的标准:**
- 用户没有明确提供该参数的具体值
- 从上下文中无法100%确定该值("可能是"不算确定)

**正确示例:**
用户说"查一下订单"→ 反问"请问您的订单号是多少?"
用户说"帮我发邮件"→ 反问"请问收件人地址是?"

**错误示例(绝对禁止):**
用户说"查一下订单"→ 直接调用 query_order(order_id="unknown")
用户说"查一下订单"→ 直接调用 query_order(order_id="ORD-001")

这新的正例里面明确列出了"禁止行为"的具体形态。有研究表明说在 prompt 中加入负例(告诉模型"不要这样做"的具体例子),比只写正面指令的效果要显著更好——这是因为模型在推理时会把禁止行为和合法行为都计算在内。

System Prompt 的方法作为纯文本约束,其本质上是"软性"的——模型仍然会在特殊情况下不遵循。因此还衍化出一些其他方法。

把"提问"本身做成一个工具

一些系统非常巧妙地把“向用户澄清”设计成一个正式工具,例如 ask_user。这样模型在缺少必要参数时,可以通过统一的工具选择机制触发澄清,而不是在业务工具上擅自补全参数,这个思路的出发点是:与其靠 prompt 文字约束来"提醒"模型要反问,不如让反问行为本身进入工具选择的竞争框架

tool_choice = "auto" 的场景下,模型每次都在做一个选择题:我现在应该调哪个工具?如果反问用户不是一个正式工具,那么"反问"这个行为游离在工具选择框架之外,模型要选择它,需要依靠 prompt 中文字约束的强度。但如果把 ask_user 注册为一个工具,它就变成了选择题里的一个选项——当模型发现其他工具的必填参数拿不到时,ask_user 的选择概率会自然升高,因为选它是"合规的",而带着瞎猜参数调用业务工具是"不合规的"。

tools = [
    {
        "name": "ask_user",
        "description": "当你需要调用某个工具但缺少必要信息时,使用此工具向用户提问。"
                       "每次只问一个问题,问最关键的那个。"
                       "不要在有必要参数缺失的情况下直接调用业务工具。",
        "parameters": {
            "type": "object",
            "properties": {
                "question": {
                    "type": "string",
                    "description": "要向用户提出的具体问题,应当清晰、简短、只涉及一个信息点"
                },
                "missing_param": {
                    "type": "string",
                    "description": "当前缺失的参数名称,例如 order_id、recipient_email"
                }
            },
            "required": ["question", "missing_param"]
        }
    },
    # ... 其他业务工具
]

上面的例子中有个missing_param 字段。它是让模型在调用 ask_user 时必须显式填写"我缺哪个参数",提供这个字段有两个好处:第一,它迫使模型在提问之前做一次明确的参数缺口分析,不容易提出模糊问题;第二,开发者可以在后端拿到这个字段做日志分析——哪些参数最频繁地被用户遗漏,是优化产品引导流程的重要数据来源。

Spring AI 社区在 2026 年 1 月发布的 AskUserQuestionTool(来自 spring-ai-agent-utils 工具库,其设计直接参考了 Anthropic Claude Code 中的 AskUserQuestion tool 的实现思路)将这个模式推进了一步:它支持结构化的多选项问题,而不只是自由文本提问:

# Spring AI AskUserQuestionTool 的问题结构
Question(
    header="订单信息",
    question="请提供您的订单号",
    options=[
        Option(label="查找最近订单", description="查看您最近30天内的订单"),
        Option(label="手动输入订单号", description="如果您知道具体的订单号")
    ],
    multi_select=False
)

这种结构化提问的优势在于:用户不需要知道系统期望的参数格式,只需从选项中点选,大幅降低了用户填错或填不全的概率,同时也约束了返回值的可能空间,让后续参数解析更可靠。

在实践中,System Prompt 和 ask_user 工具通常组合使用:System Prompt 作为模型行为的全局基线约束,ask_user 工具提供结构化的执行通道。前者保证模型的"意识",后者保证模型的"行为路径"。

Schema 层面的防御:让模型"想猜也猜不了"

前两种方案(System Prompt 约束 + ask_user 工具)都属于行为层面的干预——靠 prompt 告诉模型要反问、靠工具结构引导模型去反问。但它们都有一个共同的脆弱点:如果模型选择"无视"这些引导,它仍然可以在调用工具时为必填参数捏造一个值,且该调用在格式上完全合法,系统不会报错。

因此还有一种解决方案:在 Schema 设计层面主动关闭模型"瞎猜"的通道。它的核心手段是区分 requiredoptional 参数,并搭配 enum 约束来压缩参数的合法取值空间:

# 反例:所有字段都是 required,模型在参数缺失时只能瞎猜
{
  "name": "query_order",
  "parameters": {
    "order_id": {
      "type": "string",
      "description": "订单号"
    }
  },
  "required": ["order_id"]   # ← 没有 order_id 时模型会编一个
}

# 正例:将"用于过滤的可选条件"和"真正的必填键"分开设计
{
  "name": "query_order",
  "parameters": {
    "order_id": {
      "type": "string",
      "description": "订单号,格式为 ORD- 开头的字符串,例如 ORD-20250413-001。"
                     "如果用户没有提供,不要猜测,改用 ask_user 工具获取。"
    },
    "query_type": {
      "type": "string",
      "enum": ["status", "detail", "logistics"],  # ← enum 约束:只有这三个值合法
      "description": "查询类型"
    }
  },
  "required": []   # ← 没有任何字段被强制标为必填
}

上面的例子中,把 order_idrequired 列表中移除,并在 description 里明确写出"如果用户没有提供,不要猜测"。Anthropic 内部测试数据显示,在 schema description 里加入具体的行为约束和示例,可以将复杂参数处理的准确率从 72% 提升到 90%。第二,对于有限取值的参数,用 enum 代替开放字符串——这直接压缩了模型能"填入"的值域,把"猜测空间"从无穷大缩小到 3 个合法选项。

六、解决安全风险:Human-in-the-Loop

前面我们讨论的问题,其实本质上还都属于模型判断层面的失误,危害仍然停留在"没有办法正确完成任务"层面。但工具调用还带来了一类更深层的风险,是光靠 Schema 设计和 Prompt 约束无法完全覆盖的,那就是权限的安全问题。就是说即便模型判断完全正确、参数填写准确无误,某些误操作本身一旦执行就无法撤销

在没有工具调用之前,LLM 最坏的结果是说错话,危害仅限于信息层面。引入工具调用之后,LLM 获得了改变真实世界状态的能力,危害上升到了行动层面——从"说错话导致信息损失"变成了"做错事导致真实世界损失"。

因此为了确保安全可控,一般工程实践中,工具权限按操作危险等级分级:

┌──────────┬──────────────────────┬─────────────┬──────────────────┐
│  危险等级  │      操作类型         │   典型示例    │     控制策略      │
├──────────┼──────────────────────┼─────────────┼──────────────────┤
│   低危    │ 只读查询             │ 查天气、搜索  │  自动执行         │
│   中危    │ 可逆写入             │ 发消息、建草稿 │  执行后通知       │
│   高危    │ 不可逆 / 涉及资金     │ 转账、删除数据 │  执行前人工确认   │
└──────────┴──────────────────────┴─────────────┴──────────────────┘

对于不可逆的高危操作,必须引入人工确认节点。这个就是所谓的 Human-in-the-Loop

具体说就是当工具调用触发了高危操作时,系统暂停执行流程,将操作详情呈现给人类审批者,等待确认后才继续执行。这样即使 LLM 的判断出错,也不会直接造成不可逆的损失。工程上 HITL 实现模式是**"提案-审批-执行"(propose → approve → commit)三阶段分离**:

  • 提案阶段:Agent 完成推理,确定要调用哪个工具、用什么参数,但不立即执行,而是生成一个结构化的"操作提案"(Action Proposal)存入队列。
  • 审批阶段:系统将提案内容推送给人工审批者,展示操作详情、预期影响、相关上下文(也叫"证据包",Evidence Pack),等待审批者做出"批准/拒绝/修改"的决策。
  • 执行阶段:收到明确批准信号后,系统才真正调用工具执行操作,并记录执行结果

Human-in-the-Loop 的具体实现方式(如审批流设计、超时策略、降级方案等)我们将在后续的 Agent 架构设计篇中详细展开。

现在我有了所有需要的信息。Programmatic Tool Calling 是 2025 年 11 月 24 日发布的(beta),但 code_execution_20260120 是 2026 年 1 月 20 日更新的版本。你文中写的"2025 年 11 月"是准确的(指 beta 发布),但代码中的版本号 code_execution_20260120 是后续更新版本。这个在你文中需要做个小注释。

七、小结与下篇预告

本文从 LLM"只会动嘴"的痛点出发,梳理了工具调用的基本原理,然后围绕引入工具调用后面临的六大挑战,逐一介绍了业界的解决方案。这些解决方案有些来自模型侧(模型厂商),有些来自应用侧(开发者),还有一些是两侧协同配合才能完成的。下面对照第二节的问题做一个完整回顾,并标注每个方案的发力侧:

格式脆弱性 —— 从纯提示词时代的正则猜测,到 Function Calling 的结构化输出,到 Tool Calling 的 JSON Schema 约束,再到 Structured Outputs(strict 模式)通过受约束解码在数学意义上保证格式合规——这条线的演进主要由模型侧推动(微调训练 + 推理阶段的受约束解码)。而在应用侧,开发者通过带反馈的自动重试(Retry with Validation Feedback) 机制做兜底,确保即使模型偶尔输出异常,系统也能自动纠正。

意图不确定性 —— function_call / tool_choice 参数提供了从 auto 到强制指定函数的完整控制谱。这是模型侧提供能力、应用侧选择策略的协同模式——模型厂商提供控制接口,开发者根据具体业务场景决定使用哪种控制级别。

并行效率 —— Tool Calling 在模型侧支持一次返回多个 tool_calls,但真正的并发执行需要应用侧使用异步/多线程机制来并行派发。这是典型的模型侧和应用侧各负责一半的问题。

上下文膨胀 —— Programmatic Tool Calling 让模型用代码编排工具调用,中间数据在沙箱中处理,只有最终结论回到上下文。这个方案模型侧提供了代码执行沙箱和 allowed_callers 机制,应用侧负责响应沙箱中的工具调用请求并返回结果。两侧紧密配合,共同实现了上下文的大幅瘦身和推理轮次的减少。

工具选择与规模化管理 —— 这一类问题主要靠应用侧发力。Tool Retrieval(工具检索)和分层工具组织需要开发者自己构建向量索引、设计分类体系;Description 工程和工具合并是开发者在定义工具时的设计决策;ask_user 工具和 Schema 防御也是应用侧的工程实践。模型侧在这个维度上提供的直接支持相对较少,主要是 Anthropic 的 Tool Search Tool 等少数厂商级方案。

安全风险 —— 通过操作分级和 Human-in-the-Loop 机制,确保高危操作在人工确认后才执行。这完全是应用侧的工程实践——模型不会主动区分操作的危险等级,需要开发者在应用层设计审批流和权限控制。

回到开头承诺的三个问题:

LLM 明明只会生成 token,为什么却能调用 API、执行工具? —— 因为 LLM 通过输出结构化文本来声明调用意图,真正执行工具的是开发者编写的外层程序。LLM 全程只在"说话",但它说的某些话不是给用户看的,而是给外层程序看的。

Function Calling、Tool Calling、Structured Outputs 分别解决了什么问题? —— Function Calling 把工具调用从自由文本提升到了 API 级别的结构化支持,大幅改善了格式可靠性;Tool Calling 在此基础上增加了并行调用能力和 ID 关联机制;Structured Outputs 通过受约束解码技术,将格式合规性推到了接近 100%。

当工具变多、调用变复杂、操作变危险时,工程上该怎么兜底? —— 工具变多时用 Tool Retrieval 和分层管理缩减候选集,调用变复杂时用 Programmatic Tool Calling 进行代码编排,操作变危险时用 Human-in-the-Loop 确保人工审批。

下一篇,我们将从原理转向实现,看看这些工具调用方案在代码层面是怎么落地的——包括完整的调用循环、并发执行、错误重试等等。

附录:参考资料

OpenAI 官方文档与公告

Anthropic 官方文档与博客

CodeAct 论文

  • Xingyao Wang et al., Executable Code Actions Elicit Better LLM Agents, ICML 2024, arXiv:2402.01030 arxiv.org/abs/2402.01…

工具选择与规模化管理

ask_user 工具模式