代理只需要两种方法——LLM 作为“魔法函数”与 LLM 进行“函数调用”

一般来说,代理指的是能够与环境交互的 AI 模型。
不过我们可以再深入一点。我曾在推特上看到过两个实验,出现在 ChatGPT 刚发布的时候:
- 你扮演电脑终端。我输入命令,你给出响应。
- 我扮演电脑终端。你输入命令,我给出响应。
在实践中,我发现这两种方式几乎就足以让一个代理工作。先从第一种方式开始,用程序化的方式描述:
提示词是:
你是一位俳句诗人。你会得到以下信息:
城市: {city}
天气: {weather}
你需要写一首关于天气的俳句。输出格式应为如下 JSON 字符串:
{
"haiku": "俳句的第一行。\n俳句的第二行。\n俳句的第三行。"
}然后我们写一段(伪)代码:
def invoke(city: str, weather: str) -> str:
prompt = template.format(city=city, weather=weather)
json_ans = llm.generate(prompt)
json_ans = clear_json_ans(json_ans)
ans = json.loads(json_ans)
return ans['haiku']核心思想是把 LLM 当作一个魔法函数。它通过一些未知的“魔法步骤”解决问题,我们只需要给它输入,然后拿到输出。
你可以在 LlamaIndex output parser 中找到类似的思想和工具。
接下来从函数开始说起。一个函数基本由三部分组成:
- 输入:给函数传入参数。
- 输出:函数返回结果。
- 副作用:函数可能改变环境。
所谓副作用,是指函数调用过程中产生了非输出的其他结果。例如函数可能改变全局变量的值,或者在屏幕上打印内容。对除函数式语言之外的大多数编程语言来说,这是一个重要概念。
在 AI 代理的语境里,最重要的副作用之一可能来自硬件,它能在现实世界产生物理变化,也就是我们所说的“机器人”。
然而,由于 LLM 只是根据输入预测下一个 token,它并不会产生副作用。要让一个能够与环境交互的代理出现,就需要一些方法让“魔法函数”产生副作用。OpenAI 引入了“函数调用”API 来实现这一目标。
下面定义一个能产生副作用的函数:
def send_haiku_to_twitter(haiku: str):
twitter_api.post(haiku)然后更新提示词:
你是一位俳句诗人。你会得到以下信息:
城市: {city}
天气: {weather}
你需要写一首关于天气的俳句,然后将它发送到 Twitter。(伪)代码:
def invoke(city: str, weather: str) -> None:
prompt = template.format(city=city, weather=weather)
llm.generate_with_function(prompt, functions=[send_haiku_to_twitter])它仍然是一个魔法函数,但现在代码借助函数调用 API 调用 send_haiku_to_twitter,从而间接地产生了副作用:Twitter 服务器收到了一条新内容。
你可以在 LlamaIndex function tools 找到相关工具。
当这个魔法函数能调用其他函数时,它的目的就不再只是产生间接副作用。
它也可以使用函数调用的输出,帮助代理获取更多信息或作出决策。
例如,代理可以调用一个函数获取城市的当前天气,再用这些信息生成俳句。
def get_location() -> str:
return "Shanghai"
def get_temperature(location: str, unit: Literal["C", "F"] = "C") -> float:
return 15.5 if unit == "C" else 60修改提示词:
你是一位俳句诗人。
使用给定工具获取天气信息。
然后写一首关于天气的俳句。
最后将俳句发送到 Twitter。(伪)代码:
def invoke():
llm.generate_with_function(
prompt,
functions=[get_location, get_temperature, send_haiku_to_twitter]
)这样我们就得到一个简单的代理。它是一个没有输入输出的魔法函数,但可以调用其他函数制造副作用,把俳句发到 Twitter。
沿着“魔法函数”和“函数调用”的概念继续推演,我们会得到一个惊人的事实:魔法函数可以调用另一个魔法函数。
这意味着代理拥有无限可能,因为一个代理可以指挥其他代理,而那些代理又能继续指挥更多代理。
这种无限的控制结构与程序本身一样强大——只要程序员能写出代码,就能实现任何事情。
我们再进一步思考“魔法函数”。
在编程世界里,函数最强大的能力之一是:递归。
如果一个魔法函数能调用它自己,会发生什么?理论上,它可以做任何事。它可以是一个通用的魔法函数,用来解决任何任务,也就是所谓的 AGI。
来试试!
def task_solve(task: str) -> str:
"""
这个函数可以用来解决一个任务。
"""
TEMPLATE = """
这里有一个需要被解决的任务。任务是:
{{ text }}
首先,你应当判断这个任务是否可以在不借助工具的情况下自行解决。
如果可以,就尽量直接解决,不使用工具。
否则,你可以把任务拆解成一个更简单的任务,然后用工具解决这个更简单的任务。
不过你被禁止用工具直接解决原始任务。工具只能用于解决那个更简单的任务。
在用工具解决了更简单的任务之后,你再利用其结果来解决原始任务。
使用与原始任务相同的语言。
"""
prompt = Template(TEMPLATE).render(text=task)
ans = llm.generate_with_tools_react(
prompt, [FunctionTool.from_defaults(task_solve)]
)
return ans很有前景,对吧?但在实践中它行不通。递归是强大的工具,但也很危险。它很容易导致无限循环,而 AI 模型并不擅长处理这种情况。
AI 会越钻越深,去查每个词的每个定义,却永远不会回到解决任务本身。
这有点令人失望,但并不意外。至少在当前阶段,单靠递归很难同时实现专业性和通用性。
最近我在 NebulaGraph 的 GenAI 团队工作,探索 AI 与图数据库结合的可能性。
本文解释了只用“魔法函数”和“函数调用”构建代理的思路,这是我从团队工作中得到的启发。
文中的代码都是伪代码,你可以用 LlamaIndex 在实践中实现这一思路。我也有部分代码在 AgentPath。
欢迎关注我们的后续进展。你可以在 Twitter、Telegram 和 GitHub 上找到我,链接见 https://yanli.one