两难抉择 自己定制LLM代理还是经常使用现有LLM代理框架
本文旨在协助你在经常使用自己定制的LLM代理还是经常使用现有LLM代理框架之间作出正确的选用。
简介
首先,要感谢John Gilhuly对本文的奉献。
当下,人工智能代理临时处在大休整期间。随着多个新的AI开发框架的不时出现和人们对该畛域不时启动新的投资,现代人工智能代理正在克制不稳固的初始阶段,迅速取代RAG而成为实施重点。那么,2024年最终会成为什么样的年份呢?是自客人工智能系统接收咱们人工来书写电子邮件、预订航班、处置数据,还是与任何其余年份一样以相似形式口头上述义务呢?
兴许状况与前者一样,但是要到达这种水平还有很多上班要做。任何构建LLM代理的开发人员不只必定选用基础开发设备——经常使用哪种模型、经常使用场景和架构——还必定选用要应用哪种开发框架。你是选用常年经常使用的LangGraph,还是新进入市场的LlamaIndex上班流?还是你走传统路途,自己编写整个代码呢?
这篇文章旨在让这个选用变得更容易一些。在过去的几周里,我经常使用干流的人工智能开发框架构建了相反的LLM代理,以便在技术层面审核每个框架的优缺陷。本文中触及的每个代理的一切源代码都可以在 仓库地址
LLM代理类型
,业界关键用于测试目的的LLM代理开发触及到很多方面的内容,例如函数调用、多种关系工具或技艺、与外部资源的衔接以及共享形态或内存,等等。
演绎起来看,简直一切LLM代理都具有以下配置:
为了成功这些义务,LLM代理须要具有三个基础技艺:经常使用产品文档的RAG、在跟踪数据库上生成SQL和数据剖析。一种典型的成功打算是,经常使用开源的Python包Gradio来极速构建一个代理用户界面,而LLM代理自身被结构为聊天机器人。
基于定制代码的代理(无框架打算)
开发LLM代理时,你的第一个选用很或许是齐全跳过市场上现有框架,而齐全由自己来构建一个代理。在最开局着手做这种名目时,这是我驳回的方法。
纯代码架构
上方展现的基于代码的代理是由一个OpenAI驱动的路由器组成的,该路由器经常使用函数调用来选用要经常使用的正确技艺。该技艺成功后,它将前往路由器以调用另一个技艺或许是对用户作出照应。
在这个代理中,一直坚持一个继续的信息和照应列表,在每次调用时将其齐全传递到路由器中,以便在循环中保管相应的高低文信息。
def router(messages):if not any(isinstance(message, dict) and message.get("role") == "system" for message in messages):system_prompt = {"role": "system", "content": SYSTEM_PROMPT}messages.append(system_prompt)response = client.chat.completions.create(model="gpt-4o",messages=messages,tools=skill_map.get_combined_function_description_for_openai(),)messages.append(response.choices[0].message)tool_calls = response.choices[0].message.tool_callsif tool_calls:handle_tool_calls(tool_calls, messages)return router(messages)else:return response.choices[0].message.content
技艺自身是在自己的类中定义的(例如GenerateSQLQuery),而一切这些技艺信息独特保管在SkillMap类中。路由器自身只与SkillMap类交互,它经常使用SkillMap类成功来加载技艺称号、形容和可调用函数。这种方法象征着,向代理参与新技艺就像将该技艺编写为自己的类一样便捷,而后将其参与到SkillMap类中的技艺列表中。这里的想法是,在不搅扰路由器代码的状况下轻松参与新技艺。
class SkillMap:def __init__(self):skills = [AnalyzeData(), GenerateSQLQuery()]self.skill_map = {}for skill in skills:self.skill_map[skill.get_function_name()] = (skill.get_function_dict(),skill.get_function_callable(),)def get_function_callable_by_name(self, skill_name) -> Callable:return self.skill_map[skill_name][1]def get_combined_function_description_for_openai(self):combined_dict = []for _, (function_dict, _) in self.skill_map.items():combined_dict.append(function_dict)return combined_dictdef get_function_list(self):return list(self.skill_map.keys())def get_list_of_function_callables(self):return [skill[1] for skill in self.skill_map.values()]def get_function_description_by_name(self, skill_name):return str(self.skill_map[skill_name][0]["function"])
总体而言,这种方法实施起来相当便捷,不过也存在一些应战。
纯代码代理打算的应战
第一个难点在于构建路由器系统揭示。通常,上述示例中的路由器坚持自己生成SQL,而不是将其委托给适宜的技艺。假设你曾经试图不让LLM做某事,你就会知道这种教训有如许令人丧气;找到一个可用的揭示须要启动多轮调试。思考到每个步骤的不同输入格局也是很辣手的。由于我选用不经常使用结构化输入,因此我必定为路由器和技艺中每个LLM调用的多种不同格局做好预备。
纯代码代理打算的优势
基于代码的方法提供了一个很好的基础架构和终点,提供了一种学习代理如何上班的好方法,而不是依赖于干流框架中的现成的代理教程。虽然压服LLM的行为或许具有应战性,但代码结构自身足够便捷,可以经常使用,并且或许对某些经常使用场景也极无心义。无关这些经常使用场景的更多信息,请参阅接上去的剖析。
LangGraph是历史最悠久的代理框架之一,于2024年1月初次发布。该框架旨在经过驳回Pregel图结构来处置现有管道和链的非循环性。LangGraph经过参与节点、边和条件边的概念来遍历图,使得在代理中定义循环变得愈加容易。LangGraph构建在LangChain之上,并经常使用LangChain框架中的对象和类型。
LangGraph架构
LangGraph代理看起来与其原论文中的基于代码的代理相似,但它后盾的实现代码却一模一样。LangGraph在技术上依然经常使用“路由器”,由于它经过函数调用OpenAI,并经常使用照应继续启动新的步骤。但是,程序在技艺之间移动的形式齐全不同。
tools = [generate_and_run_sql_query,, temperature=0).bind_tools(tools)def create_agent_graph():workflow = StateGraph(MessagesState)tool_node = ToolNode(tools)workflow.add_node("agent", call_model)workflow.add_node("tools", tool_node)workflow.add_edge(START, "agent")workflow.add_conditional_edges("agent",should_continue,)workflow.add_edge("tools", "agent")checkpointer = MemorySaver()app = workflow.compile(checkpointer=checkpointer)return app
这里定义的图有一个用于初始OpenAI调用的节点,上方称为“agent”,还有一个用于工具处置步骤的节点,称为“tools”。LangGraph提供了一个名为ToolNode的内置对象,它担任失掉可调用工具的列表,并依据ChatMessage照应触发它们,而后再次前往“agent”节点。
def should_continue(state: MessagesState):messages = state["messages"]last_message = messages[-1]if last_message.tool_calls:return "tools"return ENDdef call_model(state: MessagesState):messages = state["messages"]response = model.invoke(messages)return {"messages": [response]}
在每次调用“agent”节点(换句话说:基于代码的代理中的路由器)后,should_concontinue边选择是将照应前往给用户还是传递给ToolNode来处置工具调用。
在每个节点中,“state”存储来自OpenAI的信息和照应列表,这一点相似于基于代码的代理的方法。
LangGraph打算的应战
示例中LangGraph成功的大局部艰巨在于LangChain对象的经常使用,此打算须要借助这个对象来使事情顺利启动。
应战#1:函数调用验证
为了经常使用ToolNode对象,我必定重构我现有的大局部Skill代码。ToolNode接受一个可调用函数列表,这最后让我以为我可以经常使用现有的函数,但由于我设计的函数参数方面的要素,事情出现了一些变动。
这些技艺都被定义为具有可调用成员函数的类。这象征着,它们的第一个参数是“self”。GPT-4o足够痴呆,不会在生成的函数调用中蕴含“self”参数,但可怜的是LangGraph将其解读为由于缺少参数而造成的验证失误。
这花了我几个小时才弄分明,由于失误信息将函数中的第三个参数(数据剖析技艺上的“args”)标志为缺少的参数:
pydantic.v1.error_wrappers.ValidationError: 1 validation error for>
值得一提的是,失误信息来自Pydantic,而不是LangGraph。
最终,我咬紧牙关,用Langchain的@tool装璜器将我的技艺从新定义为一些基本方法,终于使得代码反常启动。
@tooldef generate_and_run_sql_query(query: str):"""依据揭示符生成并运转一个SQL查问。参数:query (str): 一个蕴含原始用户揭示符的字符串。前往值:str: SQL查问的结果。"""
应战#2:调试
正如前文所述,在框架内启动调试是颇为艰巨的事情。这关键归结为令人困惑的失误信息和形象概念,使检查蜕变变得愈加艰巨。
形象概念关键出如今尝试调试代理周围发送的信息时。LangGraph将这些信息存储在形态[“messages”]中。图中的一些节点会智能从这些信息中提取信息,这或许会使节点访问信息时难以了解信息的含意。
LangGraph打算的优势
LangGraph的关键优势之一是易于经常使用,由于图形结构代码洁净且易于访问。特意是假设你有复杂的节点逻辑时,只经过图的繁多视图有助于更容易地理解代理是如何衔接在一同的。LangGraph打算也使得转换现有的基于LangChain构建的运行程序变得十分便捷。
小结
假设你仅经常使用LangGraph框架中的一切内容,那么LangGraph会顺利地上班。但是,假设你还想经常使用框架外的一些内容启动开发的话,那么你须要为调试一些难题做好预备。
LlamaIdex上班流
上班流是LLM代理框架畛域的新打算,于往年夏天早些时刻初次亮相。与LangGraph一样,它旨在使循环代理更容易构建。另外,LLM上班流还特意关注异步运转形式。
LLM上班流的一些元素仿佛是对LangGraph的间接照应,特意是它经常使用事情而不是边和条件边。上班流经常使用步骤(相似于LangGraph中的节点)来容纳逻辑,并且在步骤之间发送和接纳事情。
上方的结构看起来与LangGraph结构相似,只是参与了一点内容。我在上班流中参与了一个设置步骤来担任预备代理高低文,上方将进一步引见这方面内容。值得留意的是,虽然这两种打算的结构相似,但是驱动它们的代码却大不相反。
上班流架构
上方的代码定义了一个上班流结构。与LangGraph打算中代码相似,这是我预备形态并将技艺附加到LLM对象的中央。
class AgentFlow(Workflow):def __init__(self, llm, timeout=300):super().__init__(timeout=timeout)self.llm = llmself.memory = ChatMemoryBuffer(token_limit=1000).from_defaults(llm=llm)self.tools = []for func in skill_map.get_function_list():self.tools.append(FunctionTool(skill_map.get_function_callable_by_name(func),metadata=ToolMetadata(name=func, description=skill_map.get_function_description_by_name(func)),))@stepasync def prepare_agent(self, ev: StartEvent) -> RouterInputEvent:user_input = ev.inputuser_msg = ChatMessage(role="user", content=user_input)self.memory.put(user_msg)chat_history = self.memory.get()return RouterInputEvent(input=chat_history)
这也是我定义额外步骤“prepare_agent”的中央。此步骤依据用户输入创立ChatMessage并将其参与到上班流内存中。将其拆分为一个独自的步骤象征着,当代理循环口头步骤时,咱们确实会前往它,这样就防止了将用户信息重复参与到内存中。
在LangGraph的例子中,我用一个位于图外的run_agent方法成功了雷同的事情。这种变动关键是格调上的,但在我看来,像咱们在这里所做的那样,用上班流和图形来容纳这种逻辑会更明晰。
设置好上班流后,我定义了路由代码:
@stepasync def router(self, ev: RouterInputEvent) -> ToolCallEvent | StopEvent:messages = ev.inputif not any(isinstance(message, dict) and message.get("role") == "system" for message in messages):system_prompt = ChatMessage(role="system", content=SYSTEM_PROMPT)messages.insert(0, system_prompt)with using_prompt_template(template=SYSTEM_PROMPT, version="v0.1"):response = await self.llm.achat_with_tools(model="gpt-4o",messages=messages,tools=self.tools,)self.memory.put(response.message)tool_calls = self.llm.get_tool_calls_from_response(response, error_on_no_tool_call=False)if tool_calls:return ToolCallEvent(tool_calls=tool_calls)else:return StopEvent(result=response.message.content)
以及工具调用途理代码:
@stepasync def tool_call_handler(self, ev: ToolCallEvent) -> RouterInputEvent:tool_calls = ev.tool_callsfor tool_call in tool_calls:function_name = tool_call.tool_namearguments = tool_call.tool_kwargsif "input" in arguments:arguments["prompt"] = arguments.pop("input")try:function_callable = skill_map.get_function_callable_by_name(function_name)except KeyError:function_result = "Error: Unknown function call"function_result = function_callable(arguments)message = ChatMessage(role="tool",content=function_result,additional_kwargs={"tool_call_id": tool_call.tool_id},)self.memory.put(message)return RouterInputEvent(input=self.memory.get())
上方两局部代码看起来都比LangGraph代理更相似于基于代码的代理。这关键是由于上班流将条件路由逻辑保管在步骤中,而不是保管在条件边中——其中的局部代码行以前是对应于LangGraph中的条件边,而如今它们只是路由步骤的一局部——而且LangGraph有一个ToolNode对象,它简直可以智能口头tool_call_handler方法中的一切操作。
经过路由步骤,我很快乐看到的一件事是,我可以将我的SkillMap和基于代码的代理中的现有技艺与上班流一同经常使用。这些不须要更改即可经常使用上班流,这让我的上班变得愈加轻松。
上班流程打算的应战
应战#1:同步与异步
虽然异步口头更适宜实时代理,但调试同步代理要容易得多。上班流被设计为异步上班;因此,试图强迫同步口头变得十分艰巨。
我最后以为我可以删除“async”方法称号,并从“achat_with_tools”切换到“chat_with_tools”。但是,由于Workflow类中的底层方法也被标志为异步,因此有必要从新定义这些方法以便同步运转。我最终坚持经常使用异步方法,但这并没有使调试变得愈加艰巨。
在LangGraph打算的困境重演环节中,围绕技艺上令人困惑的Pydantic验证失误出现了相似的疑问。幸运的是,这次这些疑问更容易处置,由于上班流能够很好地处置成员函数。最终,我不得不愈加规范地为我的技艺创立LlamaIndex FunctionTool对象:
for func in skill_map.get_function_list():self.tools.append(FunctionTool(skill_map.get_function_callable_by_name(func),metadata=ToolMetadata(name=func, description=skill_map.get_function_description_by_name(func))))
这段代码摘自构建FunctionTools工具类的函数AgentFlow__init__。
上班流打算的优势
我构建上班流代理比构建LangGraph代理容易得多,关键是由于上班流依然要求我自己编写路由逻辑和工具处置代码,而不是提供内置函数。这也象征着,我的上班流代理看起来与我的基于代码的代理十分相似。
最大的区别在于事情的经常使用。我经常使用了两个自定义事情在代理中的步骤之间移动:
class ToolCallEvent(Event):tool_calls: list[ToolSelection]class RouterInputEvent(Event):input: list[ChatMessage]
基于事情的“发射器-接纳器”架构取代了间接调用代理中的一些方法,如工具调用途理程序。
假设你有更复杂的系统,其中有多个异步触发的步骤的话,或许会收回多个事情,那么这种架构关于明晰地控制这些步骤十分有协助。
上班流的其余好处包括:此打算十分轻量级,不会给你强加太多的结构(除了经常使用某些LlamaIdex对象),而且它基于事情的架构为间接函数调用提供了一种有用的代替打算,特意是关于复杂的异步运行程序而言。
框架比拟
纵观上述三种方法,每种方法都各有其优势。
无框架方法是最容易成功的。由于任何形象都是由开发人员自己定义的(即上例中的SkillMap对象),所以坚持各种类型和对象的繁复是很容易的。但是,代码的可读性和可访问性齐全取决于单个开发人员。很容易看出,在没有驳回一些强迫的结构定义的状况下,越来越复杂的代理会变得一团糟。
LangGraph框架自身提供了相当多的结构,这使得代理的定义十明显晰。假设一个更大的团队正在协作开发一个代理,这种结构将提供一种有助于实施架构的方法。LangGraph也或许为那些不相熟结构的人提供一个很好的代理终点。但是,有一个掂量——由于LangGraph为你做了很多上班,假设你不齐全接受该框架,或许会觉得有些头痛;代码或许十分洁净,但你或许会为其付出更多的调试代价。
在上述三种方法中,上班流打算位于两边。基于事情的架构或许对某些名目十分有协助;理想上,在经常使用LlamaIdex类型方面所需的编码量更少,这为那些在运行程序中没有齐全经常使用过该框架的人提供了更大的灵敏性。
最终,外围疑问或许归结为“你曾经在经常使用LlamaIndex或LangChain来编排你的运行程序了吗?”LangGraph和上班流都与各自的底层框架严密相连,以致于每个特定于代理的框架的额外好处或许不会让你只凭优势来切换它们。
不过,纯代码方法或许永远是一个具有吸引力的选用。假设你有足够的谨严性来记载和口头任何创立的形象的话,那么就很容易确保外部框架中没有任何设置会减缓你的开发速度。
选用代理框架的关键疑问
当然,“视状况而定”素来不是一个令人满意的答案。上方的三个疑问可以协助你选择在下一个代理名目中经常使用哪个框架。
假设是,请先优先剖析这种选用打算。
假设你大抵属于后一种情景,请尝试上班流打算。不过,假设你真的属于后一种情景,试试LangGraph打算吧。
框架打算的好处之一是,每个框架都有许多教程和示例。相比而言,可用于构建纯代码代理的示例代码要少得多。
论断
无论如何,选用一个代理框架只是影响生成式人工智能系统消费结果的泛滥选用之一。与平常一样,构建弱小的包全措施并启动 LLM跟踪 是十分值得介绍的做法,并且随着新的代理框架、钻研效果和模型不时推翻既定技术,这样做也变得更为机动灵敏。
译者引见
朱先忠,社区编辑,专家博客、讲师,潍坊一所高校计算机老师,自在编程界老兵一枚。
Choosing Between LLM Agent Frameworks