目录:
用 Copilot 学 Agent ------ AI 编程的新范式
深入认识 Agent ------ 实现你自己的 Agent
深入认识 Agent ------ 构建简单的 Agent 框架
前文介绍了市面上的主流 Agent 开发框架,如 LangGraph、Microsoft Agent Framework 等,这些框架固然好用,但也存在一些问题:
- 过度抽象的复杂性,为了追求通用型,这些框架都引入了大量抽象层和配置选项。
- 快速迭代带来的不稳定性,商业化框架为了抢占市场,API 接口变更频繁,不够稳定。
- 黑盒化的实现逻辑,很多框架的核心逻辑封装得过于严密,开发者难以理解 Agent 的内部工作机制,缺乏深度定制能力。遇到问题只能依赖文档和社区支持。
- 依赖关系的复杂性,框架中引入了大量依赖包,安装包体积庞大,在需要与别的项目代码配合使用时可能出现依赖冲突问题。
基于以上原因,某些情况下可能需要定制化自己的 Agent 框架,这也是本文将介绍的内容:如何设计一个 Agent 框架。
LLM 客户端
在前文介绍三种不同的 Agent 模式时创建过一个 LLM 客户端 HelloAgentsLLM,但只支持 AzureOpenAI,接下来对其进行扩展以便支持更多的 LLM Provider:
python
import os
from openai import AzureOpenAI, OpenAI
from dotenv import load_dotenv
from typing import List, Dict
load_dotenv()
class HelloAgentsLLM:
def __init__(self, provider: str = "azure", model: str = None, api_key: str = None, base_url: str = None, **kwargs):
if provider == "azure":
self.model = model or os.getenv("AZURE_MODEL_ID")
api_key = api_key or os.getenv("AZURE_API_KEY")
base_url = base_url or os.getenv("AZURE_BASE_URL")
api_version = kwargs.get('azure_api_version') or os.getenv("AZURE_API_VERSION")
if not all([self.model, api_key, base_url]):
raise ValueError("azure llm api parameters not found")
self.temperature = kwargs.get('temperature', 0.5)
self.max_tokens = kwargs.get('max_tokens')
self._client = AzureOpenAI(api_key=api_key, azure_endpoint=base_url, api_version=api_version)
elif provider == "modelscope":
self.model = model or os.getenv("MODELSCOPE_MODEL_ID") or "Qwen/Qwen3.5-35B-A3B"
self.api_key = api_key or os.getenv("MODELSCOPE_API_KEY")
self.base_url = base_url or "https://api-inference.modelscope.cn/v1/"
if not self.api_key:
raise ValueError("ModelScope API key not found.")
self.temperature = kwargs.get('temperature', 0.7)
self.max_tokens = kwargs.get('max_tokens')
self.timeout = kwargs.get('timeout', 60)
self._client = OpenAI(api_key=self.api_key, base_url=self.base_url, timeout=self.timeout)
else:
print("不支持的 provider")
def invoke(self, messages: List[ChatCompletionMessageParam], temperature: float = 0) -> str:
print(f"🧠正在调用 {self.model} 模型...")
collected_content = []
try:
with self._client.chat.completions.stream(
model=self.model,
messages=messages,
temperature=temperature
) as stream:
for event in stream:
if event.type == "content.delta":
content = event.delta
print(content, end="", flush=True) # 实时输出结果
collected_content.append(content)
print() # 结束后换行
return "".join(collected_content)
except Exception as e:
print(f"❌调用LLM API时发生错误: {e}")
return "错误: 调用语言模型服务时出错"
这里扩展了对 modelscope 的支持,ModelScope 是阿里推出的大模型平台,有点类似于 HuggingFace Hub。要想使用其模型推理服务,需要注册账号并绑定阿里云,创建 API Token。注册用户每天可使用 2000 次的 API-Inference 调用,每个模型均有额外每日使用限额(不超过 200)。
现在来调用 modelscope 的模型:
env 在项目根目录,内容如下
# MODELSCOPE_MODEL_ID="deepseek-ai/DeepSeek-V4-Flash"
# MODELSCOPE_API_KEY=ms-f0cfaa08-4247-462b-8823-xxxxxx
python
from llm import HelloAgentsLLM
llm = HelloAgentsLLM(provider="modelscope")
messages = [ChatCompletionUserMessageParam(role="user", content="给我讲个笑话吧")]
llm.invoke(messages)
由于 openai sdk 版本的升级,与大模型交互时的消息格式也发生了变更,新版本对 message 类型收紧了,不再像旧版那么宽泛。
结果如下:
🧠正在调用 deepseek-ai/DeepSeek-V4-Flash 模型...
当然!这是一个简短的笑话:
**程序员和酒**
一个程序员去酒吧,点了一杯酒。
服务员问:"要加冰吗?"
程序员想了想,说:"不用了,我喝常温的,免得Bug(冰)又冒出来。"
------
笑点在于"冰"和"Bug"的谐音梗,程序员常把程序错误叫"Bug",所以连喝酒都要避讳"冰(Bug)"。😄
需要更冷或者更热门的笑话吗?我可以继续讲!
使用本地模型
上面的例子均使用的云端模型,如果要使用本地模型,可以通过 VLLM(高性能 LLM 推理库)或者 Ollama 在本地运行大模型。
这里以 Ollama 为例,假设已经运行了本地模型 "deepseek-r1:7b",默认情况下其本地访问地址为:http://localhost:11434/v1。增加对 Ollama 本地模型的支持:
python
from typing import Optional
from openai import OpenAI
from llm import HelloAgentsLLM
from openai.types.chat import (
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam,
ChatCompletionAssistantMessageParam, ChatCompletionDeveloperMessageParam
)
class MyLLM(HelloAgentsLLM):
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
provider: Optional[str] = None,
**kwargs
):
if provider == "ollama":
self.provider = "ollama"
self.api_key = "NotNeed"
self.base_url = "http://localhost:11434/v1"
self.model = model
self.temperature = kwargs.get('temperature', 0.7)
self.max_tokens = kwargs.get('max_tokens')
self.timeout = kwargs.get('timeout', 60)
self._client = OpenAI(api_key=self.api_key, base_url=self.base_url, timeout=self.timeout)
else:
super().__init__(model=model, api_key=api_key, base_url=base_url, provider=provider, **kwargs)
llm = MyLLM(provider="ollama", model="deepseek-r1:7b")
messages = [ChatCompletionUserMessageParam(role="user", content="给我讲个笑话吧")]
llm.invoke(messages)
由于本地模型受限于本地硬件设备而性能较差,所以后面将不会使用本地模型。这里只是通过子类扩展来演示对本地模型的支持。
核心组件
上文中的例子只是解决了与大语言模型通信的问题,但还远远称不上实现了 Agent。在前文实现你自己的 Agent 中有介绍,Agent 有一些非常重要的组成部分,如工具调用和对话记忆。因此接下来将添加这些核心组件。
Message
首先创建 Message 类来表示对话消息:
python
from typing import Optional, Dict, Any, Literal
from datetime import datetime
from pydantic import BaseModel
MessageRole = Literal["user", "assistant", "system", "tool"]
class Message(BaseModel):
content: str
role: MessageRole
timestamp: datetime = None
metadata: Optional[Dict[str, Any]] = None
def __init__(self, content: str, role: MessageRole, **kwargs):
super().__init__(
content=content,
role=role,
timestamp=kwargs.get('timestamp', datetime.now()),
metadata=kwargs.get('metadata', {})
)
def to_dict(self) -> Dict[str, Any]:
return {
"role": self.role,
"content": self.content
}
def __str__(self) -> str:
return f"[{self.role}] {self.content}"
发送消息的角色被严格限制为四种:user、asistant、system、tool,这直接对应 OpenAI API 的规范,保证了类型安全。另外 timestamp 和 metadata 字段是为了后续扩展用的。
Agent
对话记忆的管理放在 Agent 基类中:
python
from abc import ABC, abstractmethod
from typing import Optional, List, Dict, Any
from openai.types.chat import (
ChatCompletionToolParam,
ChatCompletionFunctionToolParam
)
from .llm import HelloAgentsLLM
from .message import Message
from ..tools import ToolRegistry
class Agent(ABC):
def __init__(
self,
name: str,
llm: HelloAgentsLLM,
system_prompt: Optional[str] = None,
tool_registry: Optional['ToolRegistry'] = None
):
self.name = name
self.llm = llm
self.system_prompt = system_prompt
self.tool_registry = tool_registry
self._history: list[Message] = []
def __str__(self) -> str:
return f"Agent(name={self.name}, provider={self.llm.provider})"
@abstractmethod
def run(self, input_text: str, **kwargs) -> str:
pass
def add_message(self, message: Message):
self._history.append(message)
def clear_history(self):
self._history.clear()
def get_history(self) -> list[Message]:
return self._history.copy()
Agent 基类通过继承 ABC 被定义为一个不能直接实例化的抽象类,run 方法保证所有子类智能体都有统一的执行入口。对话记忆通过 _history 属性进行管理。
Tool
接下来需要定义工具接口 Tool:
python
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from pydantic import BaseModel
class ToolParameter(BaseModel):
name: str
type: str
description: str
required: bool = True
default: Any = None
class Tool(ABC):
def __init__(self, name: str, description: str):
self.name = name
self.description = description
@abstractmethod
def run(self, parameters: Dict[str, Any]) -> str:
"""执行工具,返回 str"""
pass
@abstractmethod
def get_parameters(self) -> List[ToolParameter]:
"""获取工具参数定义"""
pass
工具接口是一个抽象类,以 run 方法作为工具调用的统一入口,接受字典参数并返回字符串结果。
ToolRegistry
再通过工具注册表来对工具进行管理:
python
from typing import Any, Callable
from .base import Tool
class ToolRegistry:
"""HelloAgents 工具注册表"""
def __init__(self):
self._tools: dict[str, Tool] = {}
self._functions: dict[str, dict[str, Any]] = {}
def register_tool(self, tool: Tool):
if tool.name in self._tools:
print(f"⚠️ 工具 '{tool.name}' 已存在,将被替换")
self._tools[tool.name] = tool
def register_function(self, name: str, description: str, func: Callable[[str], str]):
"""
直接注册函数作为工具
Args:
name: 工具名称
description: 工具描述
func: 工具函数,接受字符串参数,返回字符串结果
"""
if name in self._functions:
print(f"⚠️ 工具 '{name}' 已存在,将被覆盖")
self._functions[name] = {
"description": description,
"func": func
}
def get_tools_description(self) -> str:
"""获取所有可用工具的格式化描述字符串"""
descriptions = []
for tool in self._tools.values():
descriptions.append(f"- {tool.name}: {tool.description}")
for name, info in self._functions.items():
descriptions.append(f"- {name}: {info['description']}")
return "\n".join(descriptions) if descriptions else "暂无可用工具"
def get_all_tools(self) -> list[Tool]:
return list(self._tools.values())
def get_tool(self, name: str) -> Optional[Tool]:
return self._tools.get(name)
ToolRegistry支持两种注册方式:Tool 对象注册和函数直接注册。
SimpleAgent
上文虽然定义了 Tool 和 ToolRegistry,但可以先不使用自定义工具,直接实现一个最简单的自定义 Agent:
python
from typing import Optional
from openai.types.chat import ChatCompletionMessageParam
from ..core.agent import Agent
from ..core.llm import HelloAgentsLLM
from ..core.message import Message
class SimpleAgentNonTool(Agent):
def __init__(
self,
name: str,
llm: HelloAgentsLLM,
system_prompt: Optional[str] = None
):
super().__init__(name, llm, system_prompt)
def run(self, input_text: str, **kwargs) -> str:
messages = self._build_messages(input_text)
llm_response = self.llm.invoke(messages, **kwargs)
response_text = llm_response.content if hasattr(llm_response, 'content') else str(llm_response)
self.add_message(Message(input_text, "user"))
self.add_message(Message(response_text, "assistant"))
return response_text
def _build_messages(self, input_text: str) -> list[ChatCompletionMessageParam]:
messages = []
# 添加系统提示词
if self.system_prompt:
messages.append({
"role": "system",
"content": self.system_prompt
})
# 添加历史消息
for msg in self._history:
messages.append({
"role": msg.role,
"content": msg.content
})
# 添加用户消息
messages.append({
"role": "user",
"content": input_text
})
return messages
写一个测试用例:
python
from pathlib import Path
from .core import HelloAgentsLLM
from .agents import SimpleAgentNonTool
from dotenv import load_dotenv
# 相同目录下的 .env 文件中配置:
load_dotenv(Path(__file__).with_name(".env"))
llm = HelloAgentsLLM(provider="modelscope")
simpleAgent = SimpleAgentNonTool(
"AI 助手",
llm,
"你是一个友好的 AI 助手,请用简洁明了的方式回答问题"
)
simpleAgent.run("请介绍一下你自己")
simpleAgent.run("复述我的上一个问题")
输出结果如下:
🧠正在调用 deepseek-ai/DeepSeek-V4-Flash 模型...
你好!我是你友好的 AI 助手,擅长用简洁明了的方式回答问题。无论是查询知识、解决问题,还是聊天陪伴,我都能快速给你准确、有用的信息。我的知识不限时,可以为你解释概念、提供建议、写点东西,或者帮你理清思路。有什么想了解的,尽管问我吧!我随时在这儿,乐意为你提供帮助。
🧠正在调用 deepseek-ai/DeepSeek-V4-Flash 模型...
你的上一个问题是:"请介绍一下你自己"。
Agent 框架扩展
可以看到加上对话历史后,Agent 就有记忆,可以连续交互了,但是还缺少工具调用。下面先创建一个计算器工具:
python
import ast
import operator
import math
from typing import Dict, Any
from ..base import Tool
class CalculatorTool(Tool):
OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.BitXor: operator.xor,
ast.USub: operator.neg,
}
FUNCTIONS = {
'abs': abs,
'round': round,
'max': max,
'min': min,
'sum': sum,
'sqrt': math.sqrt,
'sin': math.sin,
'cos': math.cos,
'tan': math.tan,
'log': math.log,
'exp': math.exp,
'pi': math.pi,
'e': math.e,
}
def __init__(self):
super().__init__(
name="python_calculator",
description="执行数学计算。支持基本运算、数学函数等。例如:2+3*4, sqrt(16), sin(pi/2)等。"
)
def run(self, parameters: Dict[str, Any]) -> str:
expression = parameters.get("input", "") or parameters.get("expression", "")
if not expression:
return "expression should not be empty"
try:
node = ast.parse(expression, mode='eval')
result = self._eval_node(node.body)
return str(result)
except SyntaxError as e:
print(f"❌ 表达式语法错误: {str(e)}")
return "无效的表达式"
except Exception as e:
print(f"❌ 计算失败: {str(e)}")
return "执行失败"
def _eval_node(self, node):
if isinstance(node, ast.Constant):
return node.value
elif isinstance(node, ast.BinOp):
return self.OPERATORS[type(node.op)](
self._eval_node(node.left),
self._eval_node(node.right)
)
elif isinstance(node, ast.UnaryOp):
return self.OPERATORS[type(node.op)](self._eval_node(node.operand))
elif isinstance(node, ast.Call):
func_name = node.func.id
if func_name in self.FUNCTIONS:
args = [self._eval_node(arg) for arg in node.args]
return self.FUNCTIONS[func_name](*args)
else:
raise ValueError(f"Not supported function: {func_name}")
elif isinstance(node, ast.Name):
if node.id in self.FUNCTIONS:
return self.FUNCTIONS[node.id]
else:
raise ValueError(f"Undefined: {node.id}")
else:
raise ValueError(f"Not supported expression: {type(node)}")
def get_parameters(self):
from ..base import ToolParameter
return [
ToolParameter(
name="input",
type="string",
description="the expression of Math",
required=True
)
]
接下来需要改造 Agent 和 SimpleAgent 以支持工具调用。
在 Agent 中初始化方法中添加 tool_registry: Optional['ToolRegistry'] = None 参数并赋值给 self.tool_registry。还需要添加工具调用模式构建方法,用于从工具注册表生成工具的调用 schema,以及工具执行方法:
python
def _build_tool_schemas(self) -> list[ChatCompletionToolParam]:
"""
Generate Tools Schema
returns:
Tool Schema list
"""
if not self.tool_registry:
return []
schemas: list[ChatCompletionToolParam] = []
for tool in self.tool_registry.get_all_tools():
properties: Dict[str, Any] = {}
required: List[str] = []
try:
parameters = tool.get_parameters()
except Exception:
parameters = []
for param in parameters:
properties[param.name] = {
"type": self._map_parameter_type(param.type),
"description": param.description or ""
}
if param.default is not None:
properties[param.name]["default"] = param.default
if getattr(param, "required", True):
required.append(param.name)
schema: ChatCompletionFunctionToolParam = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description or "",
"parameters": {
"type": "object",
"properties": properties
}
}
}
if required:
schema["function"]["parameters"]["required"] = required
schemas.append(schema)
function_map = getattr(self.tool_registry, "_functions", {})
for name, info in function_map.items():
schemas.append({
"type": "function",
"function": {
"name": name,
"description": info.get("description", ""),
"parameters": {
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "input text"
}
},
"required": ["input"]
}
}
})
return schemas
@staticmethod
def _map_parameter_type(param_type: str) -> str:
normalized = (param_type or "").lower()
if normalized in {"string", "number", "integer", "boolean", "array", "object"}:
return normalized
return "string"
def _execute_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> str:
"""执行工具调用并返回字符串结果"""
if not self.tool_registry:
return "❌ 错误:未配置工具注册表"
tool = self.tool_registry.get_tool(tool_name)
if tool:
try:
response = tool.run(arguments)
return response
except Exception as exec:
return f"❌ 工具调用失败:{exec}"
return f"❌ 错误:未找到工具 '{tool_name}'"
在 SimpleAgent 中加上工具调用相关的内容:
import json
from typing import Optional
from openai.types.chat import (
ChatCompletionMessageParam,
ChatCompletionAssistantMessageParam, ChatCompletionToolMessageParam
)
from ..core.agent import Agent
from ..core.llm import HelloAgentsLLM
from ..core.message import Message
from ..tools import ToolRegistry
class SimpleAgent(Agent):
def __init__(
self,
name: str,
llm: HelloAgentsLLM,
system_prompt: Optional[str] = None,
tool_registry: Optional['ToolRegistry'] = None,
enable_tool_calling: bool = True,
max_tool_iterations: int = 3
):
super().__init__(name, llm, system_prompt, tool_registry=tool_registry)
self.enable_tool_calling = enable_tool_calling and tool_registry is not None
self.max_tool_iterations = max_tool_iterations
def run(self, input_text: str, **kwargs) -> str:
messages = self._build_messages(input_text)
if not self.enable_tool_calling or not self.tool_registry:
llm_response = self.llm.invoke(messages, **kwargs)
response_text = llm_response.content if hasattr(llm_response, 'content') else str(llm_response)
self.add_message(Message(input_text, "user"))
self.add_message(Message(response_text, "assistant"))
return response_text
tool_schemas = self._build_tool_schemas()
current_iteration = 0
final_response = ""
while current_iteration < self.max_tool_iterations:
current_iteration += 1
try:
response = self.llm.invoke_with_tools(
messages=messages,
tools=tool_schemas,
tool_choice="auto",
**kwargs
)
except Exception as e:
print(f"❌ LLM 调用失败:{e}")
break
# 处理工具调用
tool_calls = response['tool_calls']
if not tool_calls:
final_response = response['content'] or "sorry,i can't answer the question"
break
# 将助手消息添加到历史
messages.append(
ChatCompletionAssistantMessageParam(
role="assistant",
content=response['content'],
tool_calls=response['tool_calls']
)
)
# 执行所有工具调用
for tool_call in tool_calls:
tool_name = tool_call.function.name
tool_call_id = tool_call.id
try:
arguments = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
print(f"❌ 工具调用参数解析失败: {e}")
messages.append(ChatCompletionToolMessageParam(
role="tool",
tool_call_id=tool_call_id,
content=f"错误:参数格式不正确 - {str(e)}"
))
continue
# 执行工具调用
result = self._execute_tool_call(tool_name, arguments)
# 添加工具执行结果到消息
messages.append(ChatCompletionToolMessageParam(
role="tool",
tool_call_id=tool_call_id,
content=result
))
# 如果超过最大迭代次数,获取最后一次回答
if current_iteration >= self.max_tool_iterations and not final_response:
llm_response = self.llm.invoke(messages, **kwargs)
final_response = llm_response.content if hasattr(llm_response, 'content') else str(llm_response)
# 保存到历史记录
self.add_message(Message(input_text, "user"))
self.add_message(Message(final_response, "assistant"))
return final_response
def _build_messages(self, input_text: str) -> list[ChatCompletionMessageParam]:
messages = []
# 添加系统提示词
if self.system_prompt:
messages.append({
"role": "system",
"content": self.system_prompt
})
# 添加历史消息
for msg in self._history:
messages.append({
"role": msg.role,
"content": msg.content
})
# 添加用户消息
messages.append({
"role": "user",
"content": input_text
})
return messages
当未开启工具调用或者工具注册表为空时,仍然按照原来的方式直接调用 llm 并返回结果,否则通过工具注册表来生成工具调用 schema,并将其传给 llm 供大模型调用。这里用到了一个新的大模型调用方法 invoke_with_tools,需要在 llm 中添加:
python
def invoke_with_tools(
self,
messages: list[ChatCompletionMessageParam],
tools: list[ChatCompletionToolParam],
tool_choice: ChatCompletionToolChoiceOptionParam = "auto",
**kwargs):
call_kwargs = {
"temperature": kwargs.pop("temperature", self.temperature),
"tool_choice": tool_choice,
}
if self.max_tokens:
call_kwargs["max_tokens"] = kwargs.pop("max_tokens", self.max_tokens)
call_kwargs.update(kwargs)
try:
response = self._client.chat.completions.create(
model=self.model,
messages=messages,
tools=tools,
**call_kwargs)
"""
message:
{
"role": "assistant",
"content": "好的,我来帮你计算这个表达式",
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": {
"name": "python_calculator",
"arguments": '{"input": "15 * 8 + 32"}'
}
}
]
}
"""
message = response.choices[0].message
tool_calls = message.tool_calls or []
return {"content": message.content, "tool_calls": tool_calls, "model": response.model}
except Exception as e:
print(f"OpenAI Function Calling调用失败: {str(e)}")
现在写一个测试用例:
python
tool_registry = ToolRegistry()
calculator = CalculatorTool()
tool_registry.register_tool(calculator)
enhanced_agent = SimpleAgent(
name="增强助手",
llm=llm,
system_prompt="你是一个智能助手,可以使用工具来帮助用户。",
tool_registry=tool_registry,
enable_tool_calling=True
)
enhanced_agent.run("帮我计算 15 * 8 + 32")
由于只注册了一个计算器工具,所以获取工具 schemas 时只有该工具的 scehma:
json
[{
"function": {
"description": "执行数学计算。支持基本运算、数学函数等。例如:2+3*4, sqrt(16), sin(pi/2)等。",
"name": "python_calculator",
"parameters": {
"properties": {
"input": {
"description": "the expression of Math",
"type": "string"
}
},
"required": [
"input"
],
"type": "object"
}
},
"type": "function"
}]
将该 schemas 传给大模型进行调用时,大模型返回的结果示例所示:
json
{
"role": "assistant",
"content": "好的,我来帮你计算这个表达式",
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": {
"name": "python_calculator",
"arguments": '{"input": "15 * 8 + 32"}'
}
}
]
}
也就是说,当存在可用工具时,与大模型交互的第一步是让大模型来判断该使用哪一个工具,返回该工具的调用方式。然后将大模型的返回结果加入到对话记忆中,再次发起调用。如果还需要继续调用其他工具,那么就循环此过程,否则就返回大模型的响应的 content。
上文例子最终输出结果如下:
工具增强响应: 15 × 8 + 32 = **152** ✅
计算步骤:
1. 先算乘法:15 × 8 = **120**
2. 再加 32:120 + 32 = **152**
免费的大模型 API 调用不太稳定,偶尔会出现调用失败的情况。