
1. 为什么我们需要自己搭一个Agent框架市面上开源的Agent框架已经多到挑花眼从轻量级的单文件实现到企业级的多Agent编排平台似乎每个星期都有新项目冒出来。但真正拿来做业务落地的时候你会发现一个很尴尬的现实大部分框架要么抽象层级太高改一行代码要翻五层继承要么把核心逻辑藏在黑盒里出了问题只能靠猜。我在过去一年里陆续用不同框架做过客服助手、文档问答、自动化数据采集这几类项目踩过的坑基本都指向同一个结论——你需要一个自己能完全掌控的Agent框架。这一章要聊的就是从零构建一个Agent框架的整体架构设计。我给这个框架起名叫HelloAgents名字很朴素目标也很朴素让一个刚接触Agent开发的工程师能在半天内理解全部核心代码并且能按自己的需求随意替换任何一个模块。它不是一个追求功能大而全的框架而是一个结构清晰、职责分明、可拆可换的基础骨架。适合谁看如果你已经写过几个Agent的demo但每次都是从零复制粘贴或者你正在用某个开源框架但被它的抽象搞得晕头转向那这篇内容就是写给你的。核心关键词会贯穿全文Agent框架的整体分层、架构设计的取舍逻辑、工具抽象的接口定义、以及Agent运行时最关键的几个环节。我不会只给你一张架构图就完事而是会把每个模块为什么这样设计、不这样设计会出什么问题、实际写代码时要注意什么全部拆开讲清楚。2. 框架整体架构设计2.1 从能跑到好改的设计目标在动手画架构图之前先想清楚一件事你搭这个框架到底是为了什么。如果只是为了跑通一个demo那一个Python文件两百行就够了没必要搞什么架构。但如果你希望这个框架能支撑后续十几个不同场景的Agent应用那设计目标就必须从能跑升级到好改。我给HelloAgents定了三个硬性目标。第一是模块边界清晰每个模块只做一件事模块之间的依赖关系是单向的不允许出现循环依赖。第二是核心接口稳定Agent、Tool、Memory、LLM这几个核心抽象一旦定下来后续扩展新功能时不应该修改这些接口而是通过新增实现类来完成。第三是可观测Agent运行过程中的每一步决策、每一次工具调用、每一轮对话的上下文变化都要能被记录下来方便排查问题。这三个目标听起来很普通但实际做的时候很容易跑偏。比如很多人一开始会把LLM调用和Agent逻辑混在一起写结果想换个模型提供商的时候发现要改几十处代码。又比如工具调用的参数校验和实际执行耦合在一起想加个重试机制就得动核心逻辑。这些问题的根源都是边界没有提前划清楚。2.2 分层架构四层结构各司其职HelloAgents采用四层架构从上到下依次是应用层、编排层、能力层、基础设施层。这个分层方式参考了经典的分层架构思想但针对Agent场景做了调整。应用层是面向具体业务场景的比如一个客服Agent、一个代码审查Agent它们都是直接使用编排层提供的接口来组装自己的行为。编排层是框架的核心负责Agent的运行时逻辑包括任务规划、工具选择、上下文管理、多Agent协作等。能力层提供Agent需要的基础能力比如LLM调用、工具执行、记忆存储、检索增强等。基础设施层则是更底层的支撑包括日志、配置、异常处理、序列化等。这样分层的最大好处是替换成本低。比如你想把默认的LLM从某个云端API换成自己部署的本地模型只需要在能力层新增一个LLM实现类编排层和应用层的代码完全不用动。又比如你想换一种记忆存储方案从内存存储换成向量数据库同样只需要在能力层做替换。注意分层不是目的而是手段。如果你的项目规模很小强行分层反而会增加复杂度。分层的价值在于当项目变大时修改一个地方不会引发连锁反应。2.3 核心模块划分与依赖关系在四层架构的基础上HelloAgents进一步拆分为六个核心模块Agent运行时、工具系统、记忆系统、LLM适配器、消息总线、配置中心。Agent运行时是编排层的核心它负责接收用户输入决定下一步做什么调用工具或LLM然后根据结果决定是继续循环还是返回答案。工具系统负责注册、发现、执行工具并且处理工具调用的参数校验和结果格式化。记忆系统管理短期对话历史和长期知识存储提供统一的读写接口。LLM适配器封装不同模型提供商的API差异对外暴露统一的调用接口。消息总线负责模块之间的异步通信特别是在多Agent协作场景下。配置中心集中管理所有可配置项支持从环境变量、配置文件、代码默认值三个来源按优先级加载。这六个模块的依赖关系是单向的Agent运行时依赖工具系统、记忆系统、LLM适配器工具系统和记忆系统依赖配置中心消息总线被Agent运行时和工具系统使用。没有任何模块反向依赖Agent运行时这保证了核心逻辑的可测试性。2.4 为什么选择这样的架构你可能会问为什么不用插件式架构或者微内核架构插件式架构确实灵活但它的代价是模块之间的通信成本高而且插件之间的依赖关系很难管理。微内核架构适合操作系统级别的场景对于Agent框架来说太重了。我选择分层加模块化的混合方式是因为Agent框架有一个特殊之处它的核心逻辑是相对稳定的但外围能力是快速变化的。Agent的基本循环——观察、思考、行动——这个模式在过去几年里没有本质变化。但LLM的API在变工具的种类在变记忆的存储方案在变。所以把稳定的核心和易变的外围分开是最符合实际需求的做法。另一个考虑是调试友好。Agent出问题的时候你往往需要知道是哪一层出了错。是LLM返回了格式不对的内容是工具执行超时了还是记忆检索返回了不相关的上下文分层架构让每一层都可以独立测试和替换排查问题时可以快速定位到具体模块。3. 核心模块的接口设计与实现要点3.1 Agent运行时循环、状态与决策Agent运行时的核心是一个循环我把它叫做Agent Loop。这个循环的基本逻辑是接收输入构建提示词调用LLM解析LLM的输出如果输出中包含工具调用请求就执行工具把工具结果追加到上下文然后再次调用LLM直到LLM输出最终答案或者达到最大循环次数。这个循环看起来简单但有几个关键设计点需要仔细考虑。首先是状态管理。Agent在循环过程中需要维护多种状态当前对话历史、已执行工具的结果、循环计数、以及可能的中间变量。这些状态不能散落在各个方法里必须集中管理。我的做法是定义一个AgentState数据类包含所有运行时状态每次循环开始时读取循环结束时更新。其次是终止条件。除了最大循环次数还需要考虑其他终止情况LLM明确表示任务完成、工具执行返回了致命错误、或者外部信号要求停止。这些条件需要统一在一个地方判断避免逻辑分散导致遗漏。第三是错误恢复。LLM调用可能超时工具执行可能抛异常这些错误不应该直接让整个Agent崩溃。我的策略是区分可恢复错误和不可恢复错误。可恢复错误比如LLM返回格式不对会触发重试重试次数用完后降级处理。不可恢复错误比如配置缺失则直接终止并抛出明确异常。class AgentRuntime: def __init__(self, llm, tools, memory, config): self.llm llm self.tools tools self.memory memory self.config config self.max_iterations config.get(max_iterations, 10) def run(self, user_input: str) - str: state AgentState(inputuser_input) self.memory.add_user_message(user_input) for i in range(self.max_iterations): prompt self._build_prompt(state) response self.llm.generate(prompt) if self._is_final_answer(response): self.memory.add_assistant_message(response) return response tool_calls self._parse_tool_calls(response) for call in tool_calls: result self.tools.execute(call.name, call.arguments) self.memory.add_tool_result(call.name, result) return 达到最大循环次数任务未完成这段代码是简化版实际实现中还需要处理流式输出、并发工具调用、以及更复杂的提示词构建逻辑。但核心结构就是这样一个循环加三个判断判断是否最终答案、判断是否有工具调用、判断是否达到终止条件。3.2 工具抽象让Agent的手伸得更远工具是Agent与外部世界交互的桥梁。没有工具Agent只能靠LLM自身的知识回答问题有了工具Agent可以查数据库、调API、读文件、执行代码。工具抽象的设计质量直接决定了框架的扩展能力。HelloAgents的工具抽象围绕三个核心概念工具定义、工具注册、工具执行。工具定义描述一个工具是什么包括名称、描述、参数schema。工具注册负责管理所有可用工具支持按名称查找和按标签过滤。工具执行负责实际调用工具包括参数校验、超时控制、结果格式化。工具定义我选择用Pydantic模型来描述参数schema而不是手写JSON Schema。原因是Pydantic在Python生态里更自然而且能自动生成JSON Schema还能在运行时做参数校验。一个典型的工具定义长这样from pydantic import BaseModel, Field class SearchArgs(BaseModel): query: str Field(description搜索关键词) max_results: int Field(default5, description最大返回数量) class SearchTool(BaseTool): name web_search description 搜索互联网获取最新信息 args_schema SearchArgs def execute(self, query: str, max_results: int 5) - str: # 实际搜索逻辑 return formatted_results工具注册我采用装饰器加注册表的方式。装饰器让工具的定义和注册在同一个地方完成避免忘记注册的问题。注册表是一个单例支持按名称查找和按标签批量获取。标签机制在多Agent场景下很有用比如一个Agent只能使用标记为safe的工具另一个Agent可以使用所有工具。工具执行是最容易出问题的环节。我总结了几个必须处理的点参数校验要在执行前做避免工具内部因为参数问题崩溃超时控制必须要有防止某个工具卡死整个Agent结果截断也要考虑如果工具返回了几兆的文本直接塞进上下文会撑爆LLM的token限制异常包装要统一工具抛出的异常应该被包装成框架定义的异常类型方便上层统一处理。实操心得工具的描述文本非常重要。LLM是根据工具描述来决定是否调用以及如何调用的。描述写得好LLM的调用准确率能提升一大截。我的经验是描述里要包含三个要素这个工具做什么、什么时候该用、参数怎么填。不要写搜索工具这种模糊描述要写当需要获取实时信息或最新数据时使用此工具输入搜索关键词返回相关网页摘要。3.3 记忆系统短期对话与长期知识的统一管理记忆系统负责管理Agent的记忆包括短期记忆和长期记忆。短期记忆就是当前对话的上下文长期记忆则是跨对话的知识积累。很多框架把这两个东西分开处理结果导致接口不统一使用起来很别扭。HelloAgents的做法是用统一的接口管理两种记忆但底层用不同的存储实现。短期记忆的存储我选择用滑动窗口加摘要的方式。滑动窗口保留最近N轮对话超出的部分通过LLM生成摘要摘要作为系统消息保留在上下文中。这样既控制了token消耗又不会丢失太久之前的重要信息。窗口大小和摘要触发阈值都是可配置的。长期记忆的存储我选择用向量数据库加元数据过滤。每条记忆包含文本内容、向量表示、以及元数据时间戳、来源、标签等。检索时先做向量相似度搜索再用元数据过滤最后按相关性排序返回。向量数据库的选择上我建议开发阶段用内存实现生产环境再换成专门的向量数据库。记忆系统的接口设计要特别注意读写分离。写入记忆和读取记忆的语义完全不同写入通常是追加操作读取则涉及检索和排序。把这两个操作放在同一个接口里会导致实现混乱。我的做法是定义MemoryWriter和MemoryReader两个接口分别负责写入和读取具体实现类可以同时实现两个接口。class MemoryWriter(Protocol): def add_message(self, role: str, content: str) - None: ... def add_knowledge(self, content: str, metadata: dict) - None: ... class MemoryReader(Protocol): def get_recent_messages(self, limit: int) - list[Message]: ... def search_knowledge(self, query: str, top_k: int) - list[Knowledge]: ...这种接口分离的好处是你可以很容易地替换其中一部分。比如你想把短期记忆从内存换成Redis只需要实现一个新的MemoryWriter和MemoryReader其他代码完全不用动。3.4 LLM适配器屏蔽模型差异的统一入口LLM适配器的职责很简单让上层代码不感知具体使用的是哪个模型。但实现起来有几个细节需要处理好。首先是消息格式的统一。不同模型提供商的消息格式有差异有的用role和content有的用messages数组有的支持多模态输入。适配器需要把这些差异屏蔽掉对外暴露统一的消息结构。其次是参数映射。温度、最大token数、top_p这些参数在不同API里的名称和取值范围可能不同。适配器需要做一层映射让上层可以用统一的参数名。第三是流式输出的处理。流式输出对于Agent场景很重要因为用户希望看到Agent在思考的过程。但不同提供商的流式接口实现方式不同有的用SSE有的用WebSocket有的用回调。适配器需要把这些统一成生成器接口。class LLMAdapter(Protocol): def generate(self, messages: list[Message], **kwargs) - str: ... def stream(self, messages: list[Message], **kwargs) - Iterator[str]: ... class OpenAIAdapter: def generate(self, messages, temperature0.7, max_tokens1000): response self.client.chat.completions.create( modelself.model, messages[m.to_openai_format() for m in messages], temperaturetemperature, max_tokensmax_tokens ) return response.choices[0].message.content适配器的实现类应该尽量薄只做格式转换和API调用不包含任何业务逻辑。这样当新的模型提供商出现时新增一个适配器类只需要几十行代码。4. 实操过程与核心环节实现4.1 项目初始化与目录结构动手写代码之前先把项目结构定下来。HelloAgents的目录结构如下hello_agents/ ├── core/ │ ├── agent.py # Agent运行时 │ ├── state.py # 状态定义 │ └── exceptions.py # 自定义异常 ├── tools/ │ ├── base.py # 工具基类 │ ├── registry.py # 工具注册表 │ └── builtin/ # 内置工具 ├── memory/ │ ├── base.py # 记忆接口 │ ├── short_term.py # 短期记忆实现 │ └── long_term.py # 长期记忆实现 ├── llm/ │ ├── base.py # LLM适配器接口 │ └── providers/ # 各提供商实现 ├── config/ │ └── settings.py # 配置管理 └── utils/ └── logger.py # 日志工具这个结构的关键是按职责分目录而不是按类型分。很多项目喜欢把所有接口放一个目录所有实现放另一个目录结果找一个功能要跳好几个文件夹。按职责分目录的好处是当你需要修改工具系统时所有相关代码都在tools/下面。初始化项目用标准的Python包管理方式。我推荐用pyproject.toml而不是setup.py因为前者更现代而且能统一管理依赖、构建、工具配置。依赖方面核心框架只依赖pydantic和httpx其他都是可选的。这样做的目的是保持核心的轻量让使用者按需安装。4.2 配置中心的实现配置中心看起来简单但实际做的时候很容易变成一团乱麻。我的做法是三层优先级代码默认值 配置文件 环境变量。加载时按优先级从低到高覆盖最终得到一个合并后的配置对象。配置项按模块分组比如agent.max_iterations、llm.temperature、memory.window_size。分组的好处是避免配置项名称冲突也方便按模块查找。class Config: def __init__(self): self._data {} self._load_defaults() self._load_file() self._load_env() def get(self, key: str, defaultNone): keys key.split(.) value self._data for k in keys: if isinstance(value, dict) and k in value: value value[k] else: return default return value环境变量的命名规则是HELLO_AGENTS_前缀加上大写下划线分隔的路径比如HELLO_AGENTS_LLM_TEMPERATURE对应llm.temperature。这样既避免了和其他环境变量冲突又能直观地看出配置项的归属。注意配置中心不要做成全局单例。全局单例在测试时很难替换而且多Agent场景下不同Agent可能需要不同的配置。我的做法是配置对象作为参数传递需要的地方显式注入。4.3 工具注册与发现的完整流程工具注册的流程分为三步定义工具类、注册到注册表、在Agent中启用。定义工具类时继承BaseTool实现execute方法声明name、description、args_schema三个类属性。注册到注册表可以用装饰器自动完成也可以手动调用registry.register(tool_instance)。register_tool class CalculatorTool(BaseTool): name calculator description 执行数学计算支持加减乘除和幂运算 args_schema CalculatorArgs def execute(self, expression: str) - str: try: result eval(expression, {__builtins__: {}}, {}) return str(result) except Exception as e: return f计算错误: {e}工具发现有两种方式按名称精确查找和按标签模糊匹配。按名称查找用于Agent明确知道要调用哪个工具的场景。按标签匹配用于Agent需要根据任务类型动态选择工具的场景。比如一个Agent配置了tags[search, retrieval]那么所有带这些标签的工具都会被加载。工具执行时的参数校验用Pydantic自动完成。args_schema定义了参数的类型和约束执行前调用args_schema(**arguments)做校验校验失败会抛出明确的错误信息。这个错误信息会被返回给LLM让LLM重新生成正确的参数。4.4 Agent循环的完整实现与调试把前面几个模块串起来Agent循环的完整实现如下class HelloAgent: def __init__(self, name, llm, tools, memory, config): self.name name self.llm llm self.tools tools self.memory memory self.config config self.max_iterations config.get(agent.max_iterations, 10) def run(self, user_input: str) - str: self.memory.add_message(user, user_input) for iteration in range(self.max_iterations): messages self._build_messages() response self.llm.generate(messages) tool_calls self._extract_tool_calls(response) if not tool_calls: self.memory.add_message(assistant, response) return response self.memory.add_message(assistant, response) for call in tool_calls: result self._execute_tool(call) self.memory.add_message(tool, result, tool_namecall.name) return 任务未在限定步数内完成 def _build_messages(self): system_prompt self._build_system_prompt() history self.memory.get_recent_messages(self.config.get(memory.window_size, 20)) return [{role: system, content: system_prompt}] history def _build_system_prompt(self): tool_descriptions self.tools.get_descriptions() return f你是一个智能助手可以使用以下工具 {tool_descriptions} 请根据用户需求决定是否调用工具。如果需要调用工具请以JSON格式输出工具名称和参数。 如果不需要调用工具直接回答用户问题。调试Agent循环时我强烈建议打开详细日志。日志要记录每一轮循环的输入消息、LLM的原始输出、解析出的工具调用、工具执行结果。这样当Agent行为不符合预期时你能快速定位是哪一步出了问题。我常用的调试技巧是单步模式。在配置里加一个debug.step_by_step开关打开后每执行完一步就暂停等待手动确认再继续。这个功能在排查复杂问题时非常有用比看日志高效得多。5. 常见问题与排查技巧实录5.1 Agent不调用工具怎么办这是最常见的问题。Agent收到用户请求后直接用自己的知识回答完全不调用你精心准备的搜索工具或数据库工具。原因通常有三个工具描述不够清晰、系统提示词没有强调工具的使用、或者LLM本身的能力限制。排查步骤先检查工具描述是否包含了什么时候该用这个信息。如果描述只写了搜索工具LLM可能觉得不搜索也能回答。改成当需要获取实时信息、最新数据或你不确定的事实时使用此工具调用率会明显提升。然后检查系统提示词确保明确告诉LLM优先使用工具获取信息不要依赖内部知识。最后如果用的是能力较弱的模型可能需要用few-shot示例来引导。5.2 工具调用参数格式错误LLM生成的工具调用参数格式不对比如该传字符串的传了数字该传数组的传了对象。这个问题在参数结构复杂时尤其常见。解决方案是在工具描述里给出参数示例让LLM照着格式生成。另外参数校验失败时返回的错误信息要足够具体告诉LLM哪个参数错了、期望什么类型、实际收到了什么。# 不好的错误信息 参数校验失败 # 好的错误信息 参数query期望是字符串类型实际收到的是列表。请重新生成确保query是一个字符串。5.3 上下文长度超限对话轮数多了之后上下文会越来越长最终超过LLM的token限制。解决方案是前面提到的滑动窗口加摘要。窗口大小根据模型限制和任务复杂度调整一般保留最近10到20轮对话。摘要的触发阈值设为窗口大小的1.5倍超过就生成摘要并清空窗口。实操心得摘要的质量直接影响Agent的长期表现。我试过用简单的截断代替摘要结果Agent经常忘记之前讨论过的关键信息。后来改成用LLM生成结构化摘要包含已确认的事实、待解决的问题、用户偏好三个部分效果好了很多。5.4 工具执行超时或卡死某个工具因为网络问题或内部bug卡住导致整个Agent循环停滞。解决方案是给每个工具执行加超时控制。Python里可以用concurrent.futures的ThreadPoolExecutor来实现超时。from concurrent.futures import ThreadPoolExecutor, TimeoutError def execute_with_timeout(tool, args, timeout30): with ThreadPoolExecutor(max_workers1) as executor: future executor.submit(tool.execute, **args) try: return future.result(timeouttimeout) except TimeoutError: return f工具执行超时{timeout}秒超时时间应该按工具类型分别配置。搜索类工具可以给30秒数据库查询给10秒本地计算给5秒。不要用一个全局超时值那样要么太短导致正常操作被中断要么太长导致卡死时等待过久。5.5 多Agent协作时的消息混乱当多个Agent通过消息总线通信时容易出现消息顺序错乱、重复处理、或者消息丢失的问题。核心原因是消息总线没有做好幂等性和顺序保证。我的做法是给每条消息分配唯一ID和递增序号接收方根据序号判断是否有消息丢失根据ID判断是否重复处理。另外多Agent场景下要特别注意上下文隔离。每个Agent应该有自己的记忆空间共享的信息通过显式的消息传递而不是隐式地共享全局变量。这样当某个Agent行为异常时不会污染其他Agent的状态。问题现象可能原因排查方法解决方案Agent不调用工具工具描述模糊检查工具description字段补充使用场景说明参数格式错误缺少参数示例查看LLM原始输出在描述中加示例上下文超限窗口设置过大统计token数量启用摘要机制工具执行卡死无超时控制查看工具执行日志加超时和降级多Agent消息乱序无序号机制检查消息ID加序号和幂等处理6. 框架的扩展方向与个人体会HelloAgents的基础架构到这里就搭完了。它目前支持单Agent的基本循环、工具调用、记忆管理和LLM适配。但一个框架的价值在于它能长成什么样而不是它现在是什么样。基于这个架构后续可以很自然地扩展出几个方向。多Agent协作可以在消息总线的基础上实现。每个Agent作为一个独立的运行时实例通过消息总线交换信息。编排层可以增加一个协调者角色负责分配任务和汇总结果。工具生态可以通过插件机制扩展第三方开发者只需要实现BaseTool接口就能贡献工具。可观测性可以在现有日志基础上增加指标采集和链路追踪方便生产环境监控。我个人在实际搭建和使用这个框架的过程中最大的体会是框架的价值不在于功能多而在于边界清。一个边界清晰的框架即使功能少使用者也能很容易地按自己的需求扩展。相反一个功能多但边界模糊的框架使用者往往要花大量时间理解内部逻辑最后还不如自己重写。HelloAgents的每个模块都尽量做到只做一件事并且做好这个原则在后续扩展时也要坚持。另外一个小技巧在框架开发阶段多写测试用例。Agent的行为很难预测同样的输入在不同LLM版本下可能产生不同输出。测试用例应该覆盖核心路径和边界情况比如工具调用成功、工具调用失败、达到最大循环次数、LLM返回空响应等。这些测试在后续修改代码时能帮你快速发现回归问题。