接下来我们将要步入Agent领域。

function Calling是?

在正式开始之前,我们先来看看一切AI Agent的基础——「Function Calling」

这个东西的本质就是给LLM了解和调用外界函数的能力,LLM会根据他的理解,在最合适的时间,返回对函数的调用与参数,然后根据函数调用的结果进行回答。

比如,在构建一个旅游计划的chatbot,用户给出问题:“我计划于12月25日到日本涩谷参加圣诞节,请帮我查询那个时间的天气和当地节日规定”。

这样,LLM就会判断需要调用12月25日的实时天气API来获取日本涩谷的那一天的天气情况,并且查询涩谷对于圣诞节的安排。根据返回的结果,进行回答。

这里,我们先不用Langchain,来用OpenAI官方API去尝试理解一下Function Calling这个API。

OpenAI已经将Function Calling改名成了tools,不过目前大多数主流资料依然将其成为Function Calling,我们这里谨遵OpenAI的标准,之后都会以tools来称呼这个API

function Calling 案例

我们就用获取天气这个经典操作来作为使用案例吧,这个需要实时获取外部API的结果,LLM无法回答。

这里我们就继续用回NoteBook+deno,因为可以更细致化地观察每一步运行的过程。

我们来使用OpenAI官方库,尝试引入并且初始化:

import { load } from "dotenv"

const env = await load()

... 

import OpenAI from "openai";

const openai = new OpenAI({
    apiKey: env["OPENAI_API_KEY"],
    baseURL: env["OPENAI_API_URL"]
});

创建一个假的获取天气的函数

function getCurrentWeather({location, unit="fahrenheit"}) {
    const weather_info = {
        "location": location,
        "unit": unit,
        "temperature": 72,
        "forecast": ["sunny","windy"]
    }
    return JSON.stringify(weather_info)
}

然后我们按照OpenAI官方API文档指定的格式,创建这个函数的描述信息:

const tools = [
    {
        type: "function",
        function: {
            name: "getCurrentWeather",
            description: "Get the current weather in a given location",
            parameters: {
                type: "object",
                properties: {
                    location: {
                        type: "string",
                        description: "The city and state, eg. 'San Francisco, CA'"
                    },
                    unit: {
                        type: "string",
                        description: "The unit for temperature",
                        enum: ["fahrenheit", "celsius"], // 限定取值范围
                    }
                },
                require : ["location"]
            }
        }
    }
]

我们来解析一下这里的定义:

  • type: "function":这里目前只支持Function,是必须指定的内容,没有的话会抛出错误。
  • function:对具体函数的描述
  • name:函数名字,需要和你那边要调用的函数的名字一样,我们才可以实现对函数名的调用
  • description:对函数的描述,可以理解成,提供对工具功能的简要描述,帮助模型理解并正确使用工具
  • parameters:函数的参数,OpenAI使用的是通用的JSON Schema去描述函数的各个参数

关于parameters,这里分别详细说一下:

  1. 外层的type描述参数的顶层数据结构类型,通常就是Object
  2. properties列出对象中的所有参数,并定义每个参数的名称、类型和约束
  3. require,定义必需参数的数组

properties里又包含了每个参数属性的详细信息,分别是:

  1. type:参数的数据类型(如 string、number、boolean)
  2. description:描述参数的用途
  3. enum:限定参数值的可选范围
  4. default:为参数提供默认值(如果适用)
  5. format:限制参数的格式(如 email、uri)

然后我们来试试调用LLM的tools功能:

const messages = [
    {
        "role": "user",
        "content": "成都天气如何?"
    }
]

const result = await openai.chat.completions.create({
    model: env["OPENAI_MODEL"],
    messages,
    tools
})

console.log(result.choices[0])

我们得到的结果如图:

可以看到,content是空的,也就是说大模型没有返回文本信息,只是在tool_calls里声明了一下需要调用的函数内容,参数location变成了“成都,中国”,有意思的是,他给定了我圈出的另一个参数unit,指定为摄氏度。

我们来试试英文提问:

const messages = [
    {
        "role": "user",
        // "content": "成都天气如何?"
        "content": "What's the weather in Chengdu?"
    }
]

这里就没有给出第二个参数了,这里再次体现了LLM基于大数据训练出来的涌现的智能,在这种细节也有体现,不过我们并不会完全依赖,算是一个小彩蛋吧,毕竟LLM是一个黑盒,这种小细节值得记录一下。

控制LLM调用函数的行为

这里,tools还有一个可以选的参数,是tool_choice,有几种使用方式:

  • none表示,禁止LLM使用任何函数,也就是说,无论用户怎么输入,LLM都不会调用函数
  • auto表示,让LLM自己决定要不要使用函数,LLM的返回值可能是函数调用,也可能是正常的信息
  • 最后一种,就是指定一个函数,让LLM强制使用函数,类型是Object,有两个属性:
  • type:只能指定为function
  • function:值为一个对象,有且仅有一个key name为函数名称,比如我们上面的例子:

    {
      type: "function",
          function: {
          name: "getCurrentWeather",
      }
    }

有了这个能力,我们就具备了使用更加细致的颗粒度去调用LLM,比如我们直接禁止LLM使用函数功能:

const messages = [
    {
        "role": "user",
        "content": "成都天气如何?"
        // "content": "What's the weather in Chengdu?"
    }
]

const result = await openai.chat.completions.create({
    model: env["OPENAI_MODEL"],
    messages,
    tools,
    tool_choice: "none"
})

console.log(result.choices[0])

我们得到的结果是:

那如果我们来强制要求调用我之前定义的那个函数,并且我不给他提供那个函数需要的信息呢?

const messages = [
    {
        "role": "user",
        "content": "你好?"
        // "content": "What's the weather in Chengdu?"
    }
]

const result = await openai.chat.completions.create({
    model: env["OPENAI_MODEL"],
    messages,
    tools,
    tool_choice: {
        type: "function",
        function: {
            name: "getCurrentWeather",
        }
    }
})

console.log(result.choices[0])

Opoos!好像它产生了很严重的幻觉问题,定位给定位到北京去了

综上所述,我们可以得知,用户并没有提供任何跟天气与城市有关系的信息,但是因为我们强制LLM调用请求天气信息的函数,导致LLM出现了非常严重的幻觉问题,至于,这种强制调用有什么用,据我所知,在我计划学习的周期里,数据提取会需要这个操作。所以我们把这个问题抛给之后的学习过程吧。

然后,我们把LLM返回的调用参数,传递给JS的函数里。

function getCurrentWeather({location, unit="fahrenheit"}) {
    const weather_info = {
        "location": location,
        "unit": unit,
        "temperature": 2,
        "forecast": ["sunny","windy"]
    }
    return JSON.stringify(weather_info)
}

const functions = {
    "getCurrentWeather": getCurrentWeather
}

我们修改定义函数的位置,把其参数设计为类似于React function Component那种写法的Object,方便我们在这里调用对应的函数,我们可以直接这样:

const functionInfo = result.choices[0].message.tool_calls[0].function
const functionName = functionInfo.name
const functionParams = functionInfo.arguments

console.log(JSON.parse(functionParams));


const functionResult = functions[functionName](JSON.parse(functionParams))
console.log(functionResult)

把LLM给出的arguments直接塞给对应函数里,就可以得到响应的结果了:

并发调用函数

在比较新版的Tools里,引入了并发调用函数的特性。

我们可以简单的理解成之前的function Calling每次LLM只会返回对一个函数的调用请求,但是在Tools里可以返回一系列的函数调用,来获取更多的信息,函数之间我们可以并行的调用来节约调用外部API所占用的时间。在旧的function Calling里,只能让LLM依次调用请求,形成串行。

比如我现在再写一个获取当前时间的函数:getCurrentTime

function getCurrentWeather({location, unit="fahrenheit"}) {
    const weather_info = {
        "location": location,
        "unit": unit,
        "temperature": 2,
        "forecast": ["sunny","windy"]
    }
    return JSON.stringify(weather_info)
}

function getCurrentTime({ format = "iso" } = {}) {
    let currentTime;
    switch (format) {
        case "iso":
            currentTime = new Date().toISOString();
            break;
        case "locale":
            currentTime = new Date().toLocaleString();
            break;
        default:
            currentTime = new Date().toString();
            break;
    }
    return currentTime;
}

const functions = {
    "getCurrentWeather": getCurrentWeather,
    "getCurrentTime": getCurrentTime
}

我们对tools也追加描述:

const tools = [
    {
        type: "function",
        function: {
            name: "getCurrentTime",
            description: "Get the current time",
            parameters: {
                type: "object",
                properties: {
                    format: {
                        type: "string",
                        description: "The format of the time, eg. 'iso', 'locale', 'string'",
                        enum: ["iso", "locale", "string"]
                    }
                },
                require : ['format']
            }
        }
    },
    {
        type: "function",
        function: {
            name: "getCurrentWeather",
            description: "Get the current weather in a given location",
            parameters: {
                type: "object",
                properties: {
                    location: {
                        type: "string",
                        description: "The city and state, eg. 'San Francisco, CA'"
                    },
                    unit: {
                        type: "string",
                        description: "The unit for temperature",
                        enum: ["fahrenheit", "celsius"], // 限定取值范围
                    }
                },
                require : ["location"]
            }
        }
    }
]

那么,我们来试一下:

const messages = [
    {
        "role": "user",
        "content": "请告诉我现在的时间,和成都的天气"
        // "content": "What's the weather in Chengdu?"
    }
]

const result = await openai.chat.completions.create({
    model: env["OPENAI_MODEL"],
    messages,
    tools,
})

console.log(result.choices[0])

得到的结果是:

也是成功获得了两个函数都需要的参数,这就是并行调用。

根据函数结果进行回答

我们把上述所有内容联系到一起,把函数运行的结果输入给LLM,让LLM参考进行回答。


messages.push(result.choices[0].message);

const choices = result.choices[0].message.tool_calls

for (const choice of choices) {
    const cellId = choice.id
    const functionInfo = choice.function
    const functionName = functionInfo.name
    const functionParams = functionInfo.arguments
    const functionResult = functions[functionName](JSON.parse(functionParams))

    messages.push({
        tool_call_id: cellId,
        role: "tool",
        name: functionName,
        content: functionResult
    })
}

console.log(messages);

这样处理之后,最后的样子就是:

[
  { role: "user", content: "请告诉我现在的时间,和成都的天气" },
  {
    role: "assistant",
    content: null,
    tool_calls: [
      {
        id: "call_b1EBd02zeYZYKfLIwlemtoYE",
        type: "function",
        function: { name: "getCurrentTime", arguments: '{"format": "locale"}' }
      },
      {
        id: "call_42TvIMQHWYpDyzkB5ExkLN9f",
        type: "function",
        function: {
          name: "getCurrentWeather",
          arguments: '{"location": "Chengdu, China", "unit": "celsius"}'
        }
      }
    ],
    refusal: null
  },
  {
    tool_call_id: "call_b1EBd02zeYZYKfLIwlemtoYE",
    role: "tool",
    name: "getCurrentTime",
    content: "12/29/2024, 9:51:27 PM"
  },
  {
    tool_call_id: "call_42TvIMQHWYpDyzkB5ExkLN9f",
    role: "tool",
    name: "getCurrentWeather",
    content: '{"location":"Chengdu, China","unit":"celsius","temperature":2,"forecast":["sunny","windy"]}'
  }
]

也就是:

  1. 一条用户的提问
  2. 一条LLM对函数的调用
  3. 一条是我们调用函数的结果

然后我们把messages打包发给LLM:

const res = await openai.chat.completions.create({
    model: env["OPENAI_MODEL"],
    messages,
})

console.log(res.choices[0].message);

得到的结果,就是我们假设的函数得到的结果的总结语句了。

总结

经过上面的这番折腾,我们成功地完成了给LLM提供外部函数,调用外部函数,传递结果给LLM,让LLM根据结果进行回答的逻辑闭环。

但是,发现了吗?为了整个过程,我们需要大量的代码来进行跑通。

而且我做了这个逻辑也只考虑了用户提问完之后LLM一定会调用外界API来进行回答的情况,如果,没有调用API呢?是不是要加一个IF?

真正的完整的Agent要写的东西还差得远,比如我想要实现一个LLM自主决定行动并且根据行动结果进行下一步行动的Agent,想了想或许还远远不够。接下来来试试用Langchain提供的工具来缩减开发复杂度。。

最后修改:2024 年 12 月 30 日
收款不要了,给孩子补充点点赞数吧