用Python从零手撸一个完整AI Agent智能代理,含全部源码(中)

在系列上篇中,我们介绍了AI代理的核心逻辑和代码模块。在本篇中,我们将继续介绍如何为AI代理定义明确的AI模型输入/输出结构,并创建一个有记忆功能的AI代理。文接上回,我们继续介绍!

代理结构介绍(Agent Structure)

通常大型语言模型(LLM)需要通过API调用生成回复,因此输入和输出都必须遵循特定的数据结构。在Python中比较常用的数据有效性的验证编程库叫Pydantic,它可以确保AI应用使用的数据符合正确的API格式,并且数据值符合我们的要求。

该库的主要作用就是是将LLM的响应(通常是不稳定的)转换为格式化的Agent响应,它是一种经过结构化和验证的数据形式(如下图)。

首先我们需要创建一个描述所需数据的类,包括数据类型以及任何必须遵循的约束条件。Pydantic会检查数据是否符合这些约束条件,如果不符合,它会返回清晰的错误信息。这一点尤为重要,因为大语言模型的响应在每次运行时可能会有所不同、随机变化。输出的格式转换,我们可以通过如下的代码实现。

from pydantic import BaseModel #this is the standard class

# Taking for example the last LLM response, I want this structure:
# {tool_name: 'tool_browser', 
#  tool_input: {'q':'September 9 2024 deaths'}, 
#  tool_output: str( tool_browser({'q':'September 9 2024 deaths'})) }

class AgentRes(BaseModel):
    tool_name: str  #<--must be a string = 'tool_browser'
    tool_input: dict #<--must be a dictionary = {'q':'September 9 2024 deaths'}
    tool_output: str | None = None #can be a string or None, default = None
    
    @classmethod
    def from_llm(cls, res:dict): #<--return the class itself
        try:
            out = json.loads(res["message"]["content"])
            return cls(tool_name=out["name"], tool_input=out["parameters"])
        except Exception as e:
            print(f"Error from Ollama:\n{res}\n")
            raise e

# test
agent_res = AgentRes.from_llm(llm_res)
print("from\n", llm_res["message"]["content"], "\nto")
agent_res

响应输出如下:

接下来Agent通过转换后的输出调用工具,可以通过这样的代码实现:

# test the tool output
AgentRes(tool_name = "tool_browser", 
         tool_input = {'q':'September 9 2024 deaths'}, 
         tool_output = str( tool_browser({'q':'September 9 2024 deaths'})) )

获得以下的输出内容:

从上述的输出结果可以看出模型已经可以理解什么条件下,以及如何使用外部的工具,但我们还可以通过添加记忆系统(Memory System)让它变得更智能。记忆系统将包括来自聊天历史的相关信息,以及用户最初请求的存储在Agent中,用于后续和用户的交互上下文。大家可以通过下面的代码来实现Agent的记忆功能。

'''
Messages in Memory will have this structure:
[{'role':'assistant', 'content':'{"name":"final_answer", "parameters":{"text":"How can I assist you today?"}}'},
 {'role':'user', 'content':None}]
'''

def save_memory(lst_res:list[AgentRes], user_q:str) -> list:
    ## create
    memory = []
    for res in [res for res in lst_res if res.tool_output is not None]:
        memory.extend([
            ### assistant message
            {"role": "assistant", "content": json.dumps({"name":res.tool_name, "parameters":res.tool_input})},
            ### user message
            {"role":"user", "content":res.tool_output}
        ])
    
    ## add a reminder of the original goal
    if memory:
        memory += [{"role":"user", "content":(f'''
                This is just a reminder that my original query was `{user_q}`.
                Only answer to the original query, and nothing else, but use the information I gave you. 
                Provide as much information as possible when you use the `final_answer` tool.
                ''')}]
    return memory

对于过去的聊天历史,我们还可以添加一段预先定义的对话内容,以定义我们这次对话的目的,以确保Agent理解我们的需求。

history=[{"role": "user", "content": "hi there, how are you?"},
         {"role": "assistant", "content": "I'm good, thanks!"},
         {"role": "user", "content": "I have a question"},
         {"role": "assistant", "content": "tell me"}]

现在我们可以将所有内容整合在一起,创建一个具有记忆功能的Agent的完整代码如下:
 

def run_agent(user_q:str, chat_history:list[dict], lst_res:list[AgentRes], lst_tools:list) -> AgentRes:
    ## start memory
    memory = save_memory(lst_res=lst_res, user_q=user_q)
    
    ## track used tools
    if memory:
        tools_used = [res.tool_name for res in lst_res]
        if len(tools_used) >= len(lst_tools):
            memory[-1]["content"] = "You must now use the `final_answer` tool."
        
    ## messages
    messages = [{"role":"system", "content":prompt+"\n"+prompt_tools},
                *chat_history,
                {"role":"user", "content":user_q},
                *memory]
    pprint(messages) #<--print to see prompt + tools + chat_history
    
    ## output
    llm_res = ollama.chat(model=llm, messages=messages, format="json")
    return AgentRes.from_llm(llm_res)


# test
agent_res = run_agent(user_q=q, chat_history=chat_history, lst_res=[], lst_tools=dic_tools.keys())
print("\nagent_res:", agent_res)

Agent的输出内容如下:

如大家所见,我们的Agent的创建是由提示词(prompt)+ 可用工具(tools)+ 聊天历史(chat history) 共同组成的。

Agent图形工作流(Graph Workflow)

LangGraph是一个工作流编排框架,可以更精细地控制Agent执行的工作流。其核心概念之一是状态(State),每次执行都会创建一个包含状态信息的字典(dictionary),并在工作流的节点之间传递该状态,每个节点的输出都会更新该状态。

我们使用Python的Typing库来定义初始状态的数据类型,如下方的代码。

import typing

class State(typing.TypedDict):
    user_q: str
    chat_history: list 
    lst_res: list[AgentRes]
    output: dict

# test
state = State({"user_q":q, "chat_history":chat_history, "lst_res":[agent_res], "output":{}})
state

代码运行的输出如下:

对于图形工作流中的每个节点(Agent和调用工具)以及每条边(Agent下一步执行的方向),我们都需要编写一个函数来定义模型的行为。首先我们先定义Agent节点:

# Agent
def node_agent(state):
    print("--- node_agent ---")
    agent_res = run_agent(prompt=prompt, 
                          dic_tools={k:v for k,v in dic_tools.items() if k in ["tool_browser","final_answer"]},
                          user_q=state["user_q"], 
                          chat_history=state["chat_history"], 
                          lst_res=state["lst_res"])
    print(agent_res)
    return {"lst_res":[agent_res]} #<--must return a the list of agent_res

# test
node_agent(state)

Agent的初始化状态的输出如下:

然后我们为工具节点执行相同的初始化定义:

def node_tool(state):
    print("--- node_tool ---")
    res = state["lst_res"][-1]
    print(f"{res.tool_name}(input={res.tool_input})")
    
    agent_res = AgentRes(tool_name=res.tool_name, 
                         tool_input=res.tool_input, 
                         tool_output=str(dic_tools[res.tool_name](res.tool_input)) )
    
    return {"output":agent_res} if res.tool_name == "final_answer" else {"lst_res":[agent_res]}

# test
node_tool(state)

得到输出如下:

工作流中的边(Edges),也就是Agent下一步执行的方向有两种类型:

  1. 条件边(Conditional Edge) —— 调用一个函数来决定下一个执行的节点(例如,Agent来决定是否调用工具)。

  2. 普通边(Normal Edge) —— 直接从一个节点跳转到另一个节点(例如,从外部工具的输出返回到Agent)。

举一个条件边的代码示例:

def conditional_edges(state):
    print("--- conditional_edges ---")
    last_res = state["lst_res"][-1]
    next_node = last_res.tool_name if isinstance(state["lst_res"], list) else "final_answer"
    print("next_node:", next_node)
    return next_node #<--must return the next node to go

# test
conditional_edges(state)

根据Agent的输入判断出,下一个执行的节点为tool_browser:

最后,我们通过以下代码创建一个完整工作流并可视化整个图形流程。

from langgraph.graph import StateGraph, END

## start the graph
workflow = StateGraph(State)

## add Agent node
workflow.add_node(node="Agent", action=node_agent) 
workflow.set_entry_point(key="Agent")  #<--user query

## add Tools nodes
for k in dic_tools.keys():
    workflow.add_node(node=k, action=node_tool)

## conditional_edges from Agent
workflow.add_conditional_edges(source="Agent", path=conditional_edges)

## normal_edges to Agent
for k in dic_tools.keys():
    if k != "final_answer":
        workflow.add_edge(start_key=k, end_key="Agent")

## end the graph
workflow.add_edge(start_key="final_answer", end_key=END)
g = workflow.compile()

## plot
from IPython.display import Image, display
from langchain_core.runnables.graph import MermaidDrawMethod

display(Image(
    g.get_graph().draw_mermaid_png(draw_method=MermaidDrawMethod.API)
))

输出的可视化的图形工作流如下:

在启动Agent工作流时,必须传入初始状态作为输入。大家可以通过两种不同的方式(一次性运行和流式运行)启动Agent:

state = {'user_q':q,
         'chat_history':chat_history, 
         'lst_res':[], 
         'output':{} }

## 1) invoke function
out = g.invoke(input=state)
agent_out = out['output'].tool_output

## 2) stream function
steps = g.stream(input=state) 
for n,step in enumerate(steps):
    print("--- step", n, "---")
    print(step)

两种不同方式都可以得到如下的输出:

通过本文的讲解,我们成功构建了一个具备记忆能力的 AI 代理,并利用LangGraph构建了完整的工作流,使其能够更灵活地调用工具并进行决策。整个系统通过提示词(prompt)+ 工具(tools)+ 聊天历史(chat history)的组合,让AI代理具备了上下文理解能力,并能根据不同的输入动态调整执行路径。希望这篇文章能为大家在 AI 代理的开发中带来启发,让 AI 变得更加智能、高效。本系列最后一章,我们将介绍多智能体的技术解决方案,大家快关注我,不要错过更多海外前沿AI方案和技术分享。