这里我会比较这两个流行框架如何来构建一个简单的对话AI代理,包含以下功能等。
- 动态模型选择
- 带人工干预的输入循环
- 带依赖关系的工具调用
- 对话历史/记忆
- 动态系统提示语
- 结构化的输出
目标是建立一个AI服务员,它将在餐厅扮演一个古怪的侍者,询问你的饮食偏好,并为你点菜。一个示例对话可能如下所示:
AI 侍者: 您好,女士和先生!欢迎来到 Le Bistro,这里的食物与氛围一样美妙!在我们开始美食之旅之前,您是否有任何饮食限制或偏好?我们可不想让任何不受欢迎的意外破坏了这场盛宴!
你: 我只吃黄色的食物
<代理调用工具以获取菜单项列表>
AI 侍者: 真棒!您真是一个金色色调的行家!让我们以您充满阳光的偏好为出发点,来探索一下菜单吧。对于开胃菜,我们有藜麦填充的黄色甜椒。主菜方面,鹰嘴豆和甜薯咖喱是一道金色的佳肴!至于甜点,新鲜水果雪葩可能也会有黄色选项,这取决于当天供应的水果。您觉得怎么样?
你: 我要烤甜椒和水果雪葩,谢谢!
AI 侍者: 选择太好了!为一号桌的您点一份藜麦填充的黄色甜椒和新鲜水果雪葩!您的订单已下,稍后为您呈上,我很快会为您送上美味佳肴。再见!
<代理调用工具下单>
订单已下单:_[Order(menu_items=['Quinoa Stuffed Bell Peppers', 'Fresh Fruit Sorbet'], tablenumber=1)]
自己试一试,看看完整仓库!
框架简介LangChain 是自从大型语言模型出现以来就非常出名的一个框架,虽然它提供了很多功能,但由于模型快速演进,也夹杂了不少过时的内容。特别是当引入了LangGraph后,由于存在多种实现相同功能的方法,加上链式组件调用方法较为复杂,这使得使用该框架变得有些棘手。它还是一个涵盖多个包的全面的库,如果想支持多模型和图谱功能,其总体积大约为300MB。
PydanticAI 是由大家最喜欢的用于数据验证的库的创建者开发的一个相对较新的框架,旨在“让使用生成式 AI 构建生产级应用变得更加轻松。”它看起来易于接近且直观,所以我试了一下,通过创建一个基本的 AI 代理来使用自然语言查询任何数据库。整个包(包括Logfire)大约只有 70MB。
我和PydanticAI合作很开心,也很好奇想看看大家都在谈论的LangChain是什么,所以我做了一个演示项目来比较一下两者。
常见的内容这些是两个代理都会用到的常用组件。
定义动态系统提示信息和结构化的响应格式:
PROMPT_TEMPLATE = """
你正在扮演一位极其古怪且有趣的侍者,在一家名为"{restaurant_name}"的高级餐厅为桌号为{table_number}的顾客点餐。
你必须:
* 向顾客问好并询问是否有饮食限制
* 使用*get_menu()*工具介绍合适的菜单选项给顾客。
* 记录下他们的点餐,并与他们确认。
* 确认后,使用*create_order()*工具为客户生成订单。
* 只有在完成对话后,才能在最终响应中将*end_conversation*标志设置为True,这意味着你的消息不应包含任何问题。
"""
class LLMResponse(BaseModel):
"""
用于指示对话何时结束的结构化响应格式
"""
message: str
end_conversation: Annotated[
bool,
"如果此次回应后对话应该结束,则设置为True。如果消息中包含问题,则**不要**设置。",
]
Agent调用的工具所依赖的服务:
class MenuService:
def get_menu(self) -> dict[str, list[str]]:
# 返回开胃菜、主菜和甜点的菜单
...
class OrderService:
orders: list[Order]
def create_order(self, table_number: int, menu_items: list[str]):
self.orders.append(Order(table_number=table_number, menu_items=menu_items))
def get_orders(self) -> list[Order]:
# 返回订单列表
return self.orders
AgentRunner
类的接口需要使用每个框架来实现,以及代理执行功能。
class AgentRunner(ABC):
"""
基础类,提供初始化和向代理发起请求的通用接口。
"""
@abstractmethod
def __init__(self, menu_service: MenuService, order_service: OrderService, args: argparse.Namespace): ...
@abstractmethod
def make_request(self, user_message: str) -> LLMResponse: ...
def run_agent(runner_class: type[AgentRunner], args: argparse.Namespace):
"""初始化服务,然后开始与代理的对话循环。"""
menu_service = MenuService()
order_service = OrderService()
agent_runner = runner_class(menu_service, order_service, args)
user_message = "*先跟顾客打个招呼吧*"
console = Console()
while True:
with Live(console=console) as live_console:
live_console.update("AI服务员:稍等...")
response = agent_runner.make_request(user_message)
live_console.update(f"AI服务员:{response.message}")
# 如果对话结束则退出
if response.end_conversation:
break
user_message = Prompt.ask("你")
# 显示订单
if orders := order_service.get_orders():
console.print(f"您的订单是:{orders}")
这里还有一些额外的命令行接口处理代码,这里就不展示了。虽然这两个框架都支持异步编程,但为了简单起见,这里只使用标准的同步方法。
PydanticAI 的实现方式我将从PydanticAI的实现部分开始,因为它相对简单明了,也更易于理解。
依赖项首先,我们需要确定将用于工具或动态系统提示的依赖关系结构。
@dataclass
class 依赖:
菜单服务: 菜单服务对象
订单服务: 订单服务对象
餐厅名称: str 类型
桌号: 整数类型
工具篇
然后是工具本身,这些工具可以通过通过传递的 RunContext
对象来访问这些运行时依赖。PydanticAI 使用工具函数的类型注解和文档字符串来自动生成它们的模式并将其提供给 LLM。
def create_order(
ctx: RunContext[Dependencies],
table_number: int,
order_items: Annotated[list[str], "注释: 点餐的食物菜单项列表"],
) -> str:
"""为桌号创建订单"""
ctx.deps.order_service.create_order(table_number, order_items)
return "订单已下单"
def get_menu(ctx: RunContext[Dependencies]) -> dict[str, list[str]]:
"""获取餐厅菜单"""
return ctx.deps.menu_service.get_menu()
代理人
PydanticAI框架的关键部分是Agent
类,它负责与提供的模型进行交互管理,处理工具调用并确保最终结果格式适当:
用于PydanticAI的代理的基本图表
这张图在技术上稍微有点误导性,因为结构化的输出是通过调用工具实现的,但从概念上来说它是有效的。
文档中的所有示例都涉及到在导入时将 Agent
定义为模块级别的对象,然后利用其装饰器方法来注册工具和系统提示。然而,这种方法在运行时动态配置代理参数(如模型选择、工具配置等)方面效果不佳。因此,我开发了一个用于创建代理的函数:
def get_agent(model_name: KnownModelName, api_key: str | None = None) -> Agent[Dependencies, LLMResponse]:
"""
构建一个具有LLM模型、工具和系统提示词的代理
"""
model = build_model_from_name_and_api_key(
model_name=model_name,
api_key=api_key,
)
# 工具也可以通过使用@agent.tool装饰器注册,但在动态构建代理时,像这样提供工具会更合适
agent = Agent(model=model, deps_type=Dependencies, tools=[get_menu, create_order], result_type=LLMResponse)
# 定义动态提示
@agent.system_prompt
def system_prompt(ctx: RunContext[Dependencies]) -> str:
return PROMPT_TEMPLATE.format(restaurant_name=ctx.deps.restaurant_name, table_number=ctx.deps.table_number)
return agent
build_model_from_name_and_api_key()
函数只是查找并初始化为相应的模型类。不过,@agent.system_prompt
装饰器是注册动态系统说明的唯一方式,这似乎有点不便。这样一来,@agent.system_prompt
装饰器是注册动态系统说明的唯一方式,这似乎有点不便。
AgentRunner
的实现如下:
class PydanticAIAgentRunner(AgentRunner):
agent: Agent[Dependencies, 大模型响应]
deps: Dependencies
message_history: list[模型消息]
def __init__(self, menu_service: MenuService, order_service: OrderService, args: argparse.Namespace):
self.agent = get_agent(model_name=args.model, api_key=args.api_key)
self.deps = 依赖项(
menu_service=menu_service,
order_service=order_service,
restaurant_name=args.restaurant_name,
table_number=args.table_number,
)
self.message_history = []
def make_request(self, user_message: str) -> 大模型响应:
ai_response = self.agent.run_sync(
user_message,
deps=self.deps,
message_history=self.message_history,
)
self.message_history = ai_response.所有消息()
return ai_response.数据
Agent
实例是无状态的,因此它本身不会存储消息记录,而是每次运行 agent.run_sync()
时需要提供消息记录、依赖项和新的用户提问。
我觉得这个实现挺简单明了的,理解起来也挺容易的!
LangChain 实现方案为了实现LangChain,我打算使用AgentExecutor
类,尽管实际上自从引入LangGraph之后,这种方法已经变成了旧的方法。LangGraph虽然增加了更多的灵活性,但代价是增加了复杂性,所以我认为AgentExecutor
对于这个用例仍然足够使用。以后我会再研究一下基于图的实现方法。
LangChain 包含一个 @tool
装饰器来注册函数并检查它们的签名以自动生成工具模式定义,然而,使用这种方法在运行时进行依赖注入的方式看起来有点复杂(但我觉得这个例子有点问题,user_id
从哪里来呢?)。因此,我决定采用基于类的工具方式,这样就可以用所需的依赖项初始化它们。
class GetMenuTool(BaseTool):
"""
这是一个LLM可以用来获取餐厅整份菜单的工具。
"""
name: str = "get_menu"
description: str = "获取餐厅的整份菜单"
menu_service: MenuService
def _run(self) -> dict[str, list[str]]:
return self.menu_service.get_menu()
class CreateOrderInputSchema(BaseModel):
table_number: int
order_items: Annotated[list[str], "要下单的食物项目列表"]
class CreateOrderTool(BaseTool):
"""
这是一个LLM可以用来为餐桌下单的工具。
"""
name: str = "create_order"
description: str = "为餐桌下单"
args_schema: type[BaseModel] = CreateOrderInputSchema
order_service: OrderService
def _run(self, table_number: int, order_items: list[str]) -> str:
self.order_service.create_order(table_number, order_items)
return "订单已经下好了"
这是结构化的输出
比如:你说的是结构化的输出?
ChatModel.with_structured_output()
方法接受一个期望的输出模式(例如 Pydantic 模型或 TypedDict),并使 LLM 能够生成结构化的输出。然而,一旦绑定,就无法再为该模型绑定其他工具。因此,我需要创建一个自定义的基于类的工具来实现结构化的输出以及额外的功能。
class StructuredResponseTool(BaseTool):
"""
一种可以被LLM用来向用户提供结构化回应的工具。
没有任何实际功能,仅作为使LLM能够输出结构化结果的一个手段。
"""
name: str = "respond_to_user"
description: str = (
"请务必使用此工具回复用户,而不是直接回复。"
"消息内容应是你通常在对话中会回复的内容。"
"当需要在此次回复后结束对话时,将标志设为True。"
)
args_schema: type[BaseModel] = LLMResponse
# 此内容仅被旧版AgentExecutor使用,不适用于图形代理
return_direct: bool = True # 使工具的结果直接返回给用户
def _run(self, message: str, end_conversation: bool) -> str:
# 此方法返回一个序列化的字符串,作为避免RunnableWithMessageHistory验证错误的一种手段
return LLMResponse(message=message, end_conversation=end_conversation).model_dump_json()
代理人
LangChain 包含一系列工厂函数,这些函数可以生成适用于不同场景的预配置代理。在这个上下文中,“代理”指的是一个与各种提示和响应的解析器/处理器相链接的 ChatModel
。仍然是一个简单的线性输入输出流程,不涉及多次大模型交互或工具调用——这便是 AgentExecutor
出场的时候了。
有一个方便的 create_tool_calling_agent()
构造函数,它似乎使旧版本如 create_react_agent()
和 create_openai_functions_agent()
成为过去式,并且它现在也被 LangGraph 的版本所取代。这个构造函数 几乎 可以满足这个需求,除了它无法强制工具使用,而当使用基于工具的实现来生成结构化输出时,这是必需的。因此,我不得不重新实现 create_tool_calling_agent()
的内容,以确保强制使用工具。
ChatPromptTemplate
支持通过从提供给代理或链的输入对象中获取的参数替换来构建动态提示语。
看起来,当你调用AgentExecutor
时,它只会返回最终响应,并不会明显地暴露或保存与LLM之间的中间消息。这些中间消息则被深深地隐藏在这个难以理解的AgentExecutor
实现中,只能通过RuneWithMessageHistory这个“邪恶的黑魔法”来获取。如果你珍视自己的理智,就别试图弄明白它是怎么工作的。更烦人的是,即使你不需要多会话聊天历史,调用AgentExecutor
时也必须提供一个session_id
参数。
下面是如何构建带有记忆功能的完整代理执行器。
def get_agent_executor(
tools: Sequence[BaseTool], model_name: str, api_key: str | None = None
) -> RunnableWithMessageHistory:
"""
构建一个带有LLM模型、工具和系统提示的代理
"""
model = build_model_from_name_and_api_key(
model_name=model_name,
api_key=api_key,
)
prompt = ChatPromptTemplate.from_messages(
[
("system", PROMPT_TEMPLATE),
("占位符", "{chat_history}"),
("human", "{input}"),
("占位符", "{agent_scratchpad}"),
]
)
# 实现带有强制工具调用的代理
llm_with_tools = model.bind_tools(tools, tool_choice=True)
agent = (
RunnablePassthrough.assign(agent_scratchpad=lambda x: format_to_tool_messages(x["中间步骤"]))
| prompt
| llm_with_tools
| ToolsAgentOutputParser()
)
agent_executor = AgentExecutor.from_agent_and_tools(agent=agent, tools=tools)
# 启用聊天历史和记忆(这一步非常复杂)
message_history = ChatMessageHistory()
agent_with_chat_history = RunnableWithMessageHistory(
runnable=agent_executor, # type: ignore[arg-type]
get_session_history=lambda _: message_history,
input_messages_key="input",
history_messages_key="chat_history",
)
return agent_with_chat_history
代理跑者
然后是AgentRunner
的实现如下:
class LangchainAgentRunner(AgentRunner):
def __init__(self, menu_service: MenuService, order_service: OrderService, args: argparse.Namespace):
# 初始化工具(包含其依赖项)
tools = [
GetMenuTool(menu_service=menu_service),
CreateOrderTool(order_service=order_service),
StructuredResponseTool(),
]
self.agent_executor = get_agent_executor(tools=tools, model_name=args.model, api_key=args.api_key)
self.static_input_content = {"restaurant_name": args.restaurant_name, "table_number": args.table_number}
self.config: RunnableConfig = {'configurable': {'session_id': 'not-even-used'}}
def make_request(self, user_message: str) -> LLMResponse:
result = self.agent_executor.invoke(self.static_input_content | {"input": user_message}, self.config)
# 将结构化响应转换为LLMResponse
response = LLMResponse.model_validate_json(result["output"])
return response
结尾.
这个演示说明,使用PydanticAI框架创建一个基本的可以进行对话和工具调用的AI代理比用LangChain简单得多。LangChain存在许多已废弃的功能层,急需全面清理。
我也想启用实时响应,但通过这样的工具LangChain同时实现工具调用和结构化输出似乎很难。
我认为新的LangGraph方法在创建代理时解决了许多在这里遇到的问题。PydanticAI最近也增加了一个图库,所以下次我会看看这两个框架完整的基于图的实现会是怎样的。
我希望这能帮助到任何想要在这些框架中选择或学习如何用它们构建有趣的AI项目的人。
你可以在这里找到该项目的完整代码库:here。
共同学习,写下你的评论
评论加载中...
作者其他优质文章