
前面我们讨论过工具调用的一些文章,比如《 MCP 到底是个什么鬼?》,看过的朋友们似乎已经大概搞清楚了 LLM 的 function calling 的是怎么回事、该怎么用了。你说,function calling 的作用不就这样嘛?还有啥好聊的?
但是,你有没有想过,它还能用来干啥?你可能觉得我要开始输出什么奇技淫巧了,不不不,我们这个号不会干这种事情,大可以放心食用。
再开始之前,我们得再回忆一下 function calling 的本质到底是什么?
你说这还不简单嘛,不就是,让 LLM 输出工具调用,然后 APP 真的去调用工具,然后把结果再给到 LLM 进行更精准的推理么?
是的,没错,我们先回忆一下这个过程(如下) 一会你会拍大腿的
1. APP 调用 LLM ,并传入工具的定义 ↓ 2. LLM 返回工具调用的 JSON 描述(工具调用指令) ↓ 3. APP 去调用工具,并得到结果,将结果再次传给 LLM ↓ 4. LLM 根据原始 prompt + 工具结果,推理出结论 ↓ 5. APP 收到结果 然后再问自己一个问题:2 中,LLM 返回的工具调用指令我们能不能拿来干点其他的事情呢?
我们从 instructor 这个库开始聊起吧,Instructor 是一个非常轻量级的库,他的作用是 Structured Outputs for LLMs ,让 LLM 输出结构化的数据,什么意思呢,正常情况下,我们调用 LLM 期望它输出结构化的数据是非常恼火的,有些模型是支持的,但是输出的结果也不一定是准确的,要么是格式问题,要么缺胳膊少腿,虽然你用提示词去约束它,但是它依然是有可能会出错的,Instructor 干了一件非常简单的事情,就是保证 LLM 输出的就是你想要的结构化数据,它的代码大概是这样的(来自它的 Github ):
import instructor from pydantic import BaseModel # Define what you want class User(BaseModel): name: str age: int # Extract it from natural language client = instructor.from_provider("openai/gpt-4o-mini") user = client.chat.completions.create( response_model=User, messages=[{"role": "user", "content": "John is 25 years old"}], ) print(user) # User(name='John', age=25) 他是怎么做到的呢?难道里面偷偷摸摸的套了一个流程,while loop 直到 LLM 输出正确的结构化信息?当然不是了,其实它用了一个 function calling 的 trick ,什么意思呢?
它是这样做的:首先 User 必须是一个 pydantic 的 BaseModel ,这样,Instructor 就能拿到这个数据结构的 json 描述了,对吧?比如是这样的:
# Instructor 内部会把它转成这样: function_schema = { "name": "User", "parameters": { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }, "required": ["name", "age"] } } 然后呢?然后它就把这个数据结构“伪装”成一个函数调用,具体怎么做呢?根据不同的 LLM 的数据格式要求,将这个 json 转为一个“函数”传给 LLM ,让 LLM 必须调用这个"函数",比如,当 LLM 是 openai 的时候,他就偷偷的在 tool_choice 里面放上:
# 先把 Pydantic 模型转换为 tool 定义 new_kwargs["tools"] = [ { "type": "function", "function": {"name": "User", "parameters": {"name": "string", "age": "int"}}, # 关键在这里 } ] # 然后,强制让 LLM 调用这个"函数", new_kwargs["tool_choice"] = { "type": "function", "function": {"name": "User", "parameters": {"name": "string", "age": "int"}}, } “傻乎乎”的 LLM 看到一定要调用这个函数,它就会在推理的过程中输出一个 function calling 的返回,这时候 Instructor 顺其自然的就捕获到 LLM 返回的这个 tool_call 了,比如是这样的:
"tool_call":{"name": "User", "arguments": '{"name": "John", "age": 25}'} 然后呢?然后 Instructor 就自己构建一个 User 的对象再返回给你呗。
于是,这就是你在开头看到的“魔法”的那一幕 传一个数据的定义,哇塞,它真的就给你返回来了,严丝合缝,不出错,还节省了 token (不然你自己还得反复的 call LLM 对吧?)。
有意思吧?你看,这个过程是不是就是利用了 function calling 的能力?虽然最后并没有真的去 call 什么函数,但是这个机制是可以被我们用作结构化数据的。Instructor 用了一个“欺骗”LLM 的办法拿到了自己想要的东西,
你说,这不就是个雕虫小技么?登不上什么大雅之堂吧?
呵呵,其实有不少著名的框架里面的一些巧思其实都是这么做的,我再给你举几个例子吧。
Langchain 不久前发布了 V1 版本,其中有一个重要的更新就是:Sructured output
from langchain.agents import create_agent from langchain.agents.structured_output import ToolStrategy from pydantic import BaseModel class Weather(BaseModel): temperature: float condition: str def weather_tool(city: str) -> str: """Get the weather for a city.""" return f"it's sunny and 70 degrees in {city}" agent = create_agent( "gpt-4o-mini", tools=[weather_tool], response_format=ToolStrategy(Weather) ) result = agent.invoke({ "messages": [{"role": "user", "content": "What's the weather in SF?"}] }) print(repr(result["structured_response"])) # results in `Weather(temperature=70.0, cOndition='sunny')` 他是怎么做的?有兴趣的去看下它的代码,其实和 Instructor 的做法几乎如出一辙。这里稍微吐槽一下(其实社区里面都有这样的吐槽),Langchain 的这个接口设计的多少是有点“业余”的 你发现没,tool 和 response_format 并没有什么对应关系,如果我传了多个 tool 和多个 response_format 呢?我怎么知道里面是怎么处理的?最后会返回什么给我?站在开发者的角度看,很晦涩很不透明。我估计后面还得改进。
我们再举一个例子,比如大名鼎鼎的 autoGen ,也用了这个“小技巧”。
autoGen 里面的多智能体合作是怎么实现的呢?难道真的想像营销号说的那样,框架实现了让多个智能体在里面“群聊”么?
当然不是的,这都是营销话术,真正是怎么实现的呢?还是用 function calling 的 trick ,这个过程大概是这样的:
首先,autoGen 的 AssistantAgent 有一个参数叫做:handoffs ,虽然你可能不怎么会用到它,这是什么东西呢,其实本质上它就是描述了“在何种情况下将发言权转移给哪个 Agent”,所谓的发言权也是个营销话术,其实就是 autoGen 的调度引擎决定运行哪个 agent ,这是第一步
然后,autoGen 的 Swarm (就是负责调度的)就开始“演戏了”,当某一个 agentA 被调度起来的时候(内部就是一个 ReAct ),它一定会去跟 LLM 交互吧,关键来了,跟 LLM 交互的时候,它偷偷的将 handoffs 这种描述包装成了一个“假函数”传给 LLM ,例如函数名叫 xxx ,描述是“假设遇到这样这样的情况,请调用该函数,参数是 AgentB”,LLM 一看,哦,现在是这种情况,所以我要调用 xxx 函数,于是,这个 xxx 的调用指令就被 Swarm 捕捉到了,然后它一看,LLM 上套了,那么我们现在就要转而去调度 B 智能体,你看,本质上就是用“欺骗”LLM 的办法,让 LLM 通过 function calling 做了一次路由的调度,是不是很巧妙?
整个过程的基本思想就是这样的:
# 给 LLM 看到的"工具": tools = [ { "name": "calculator", # 真工具 "description": "计算数学表达式" }, { "name": "transfer_to_agent_BBB", # 假工具 "description": "转交给 agent_BBB" }, { "name": "transfer_to_agent_CCC", # 假工具 "description": "转交给 agent_CCC" } ] # LLM 以为自己在"调用工具" # 实际上是在"做路由决策" 所以,哪里有什么“群聊”?表面上看起来是多个 Agent 在"讨论",实际上呢,都是Swarm耍的花招,让 LLM 被忽悠的在后面吭哧吭哧的干活,Swarm 在前面出尽了风头,哈哈。
类似的例子还有很多,比如 agno 这个框架,也在用这样的方式刷花招,agno 里面有一个东西叫做 ReasoningTools,看起来也是一个很神奇的东西,仿佛加上这个参数,LLM 就能进行“深度思考”了,他是怎么做到的呢?也是用了 function calling 的原理来忽悠“老实巴交”的 LLM 进行中间过程的输出,有兴趣的小伙伴自己去探索一下吧,评论区见,哈哈哈。
好啦,今天就聊到这里吧,Agent 的领域其实很多东西并没有大家想的那么神奇,都是在工程上利用了 LLM 的特性和机制做了事情,有些事情很有趣,比如我们今天讨论的,绝大部分事情都是苦哈哈的事情。
以上,全文。感谢大家
最后再为自己写的一个框架做个广告,求一波啊大神们
chak ( https://github.com/zhixiangxue/chak-ai ),一个极简的 LLM 调用工具,轻量级,内置上下文管理和工具调用,使用起来非常简单、顺手、优雅。
点它点它点它点它
1 mooncakeSec 20 小时 19 分钟前 模型支持的 Sructured output 和 function call 不一样,如果模型支持就不要用 function call 了 |
2 neteroster 20 小时 5 分钟前 via Android 其实 function call 或者 structure output 区别没那么大,推理后端没做约束解码的话,function call 的参数也不能保证准确... 做了约束解码的话,structure output 和 function call 都是保证准确的。 当然,唯一的例外的是,部分提供商只做了 function call ,或者只有 function call 用了约束解码 |
3 pmpmp OP @mooncakeSec 嗯是的,所以一般框架里面都会做 fallback ,支持的就直接用,不支持的 LLM 框架他们会这样做 |
4 flyme2them00n 19 小时 35 分钟前 已 star ,希望多发一些这类型的文章 |
5 andyskaura 19 小时 24 分钟前 @pmpmp 估计 Sructured output 的实现方式本质上和 function call 差不多的 ,要严格准确的格式输出对于 llm 来说有点辛苦了,像之前的 deepseek 的 json output ,总是给我少几个大括号。 |
6 uncleroot 18 小时 24 分钟前 点赞!挺有意思 |
7 kulove 18 小时 20 分钟前 via Android 以后模型应该都会支持结构化输出的 |