V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
pmpmp
V2EX  ›  程序员

哈? LLM 的工具调用还能这么玩?!

  •  1
     
  •   pmpmp · 9 天前 · 2158 次点击

    前面我们讨论过工具调用的一些文章,比如《 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 调用工具,轻量级,内置上下文管理和工具调用,使用起来非常简单、顺手、优雅。

    ☝️☝️☝️☝️☝️☝️☝️☝️点它点它点它点它☝️☝️☝️☝️☝️☝️☝️☝️

    7 条回复    2025-11-28 18:49:35 +08:00
    mooncakeSec
        1
    mooncakeSec  
       9 天前
    模型支持的 Sructured output 和 function call 不一样,如果模型支持就不要用 function call 了
    neteroster
        2
    neteroster  
       9 天前 via Android
    其实 function call 或者 structure output 区别没那么大,推理后端没做约束解码的话,function call 的参数也不能保证准确... 做了约束解码的话,structure output 和 function call 都是保证准确的。

    当然,唯一的例外的是,部分提供商只做了 function call ,或者只有 function call 用了约束解码
    pmpmp
        3
    pmpmp  
    OP
       9 天前
    @mooncakeSec 嗯是的,所以一般框架里面都会做 fallback ,支持的就直接用,不支持的 LLM 框架他们会这样做
    flyme2them00n
        4
    flyme2them00n  
       9 天前   ❤️ 1
    已 star ,希望多发一些这类型的文章
    andyskaura
        5
    andyskaura  
       9 天前
    @pmpmp 估计 Sructured output 的实现方式本质上和 function call 差不多的 ,要严格准确的格式输出对于 llm 来说有点辛苦了,像之前的 deepseek 的 json output ,总是给我少几个大括号。
    uncleroot
        6
    uncleroot  
       9 天前
    点赞!挺有意思
    kulove
        7
    kulove  
       9 天前 via Android
    以后模型应该都会支持结构化输出的
    关于   ·   帮助文档   ·   自助推广系统   ·   博客   ·   API   ·   FAQ   ·   Solana   ·   2668 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 20ms · UTC 12:34 · PVG 20:34 · LAX 04:34 · JFK 07:34
    ♥ Do have faith in what you're doing.