轻量级通用AI Agent框架的设计与实践——从架构到部署

轻量级通用AI Agent框架的设计与实践——从架构到部署 hermes-agent 是我在春节前后开始折腾的一个个人项目断断续续做了一个多月目前已经在几个日常场景里跑得比较稳定了。这个项目本质上是一个轻量级的通用 AI Agent 框架核心思路是把大模型当成大脑通过一套可扩展的工具注册机制和任务调度逻辑让模型能自主完成查资料、写代码、调接口这类需要多个步骤才能搞定的活儿。我给它取名叫 Hermes一方面借的是希腊神话里信使之神的名号——恰好跟 Agent 做信息传递和任务分发这件事对得上另一方面是初期做原型的时候用的就是 Hermes 系列模型后面虽然换成了可配置的多模型后端名字也就这么留下来了。如果你刚开始接触 AI Agent 开发或者写过多轮 Prompt 但总觉得差点意思这篇文章应该能帮到你。我会把整个项目的设计思路、核心模块代码细节、部署过程中踩过的坑以及几个真实场景下遇到的问题排查过程都过一遍尽量做到你拿着这份记录就能复现一套自己的 Agent。1. 项目定位与整体设计思路1.1 从会对话到能干活Agent 到底在解决什么问题先说个背景。过去一年里我见过太多拿着大模型聊天的项目本质上就是把 Prompt 写得复杂一点然后期待模型输出正确答案。但真正在实际场景里跑过就会发现单轮对话式的交互应付不了稍微复杂一点的需求。举个例子你想让 AI 帮你查一下某只股票的近期走势生成一份分析报告并转发到工作群。这个任务至少拆成三步搜索行情数据、调用分析脚本、发送 webhook 消息。每一步需要不同的工具每一步的输出又是下一步的输入光靠一段 Prompt 是搞不定的。hermes-agent 要解决的核心问题就是把这套多步骤、需要调用外部工具、需要保持上下文连贯的任务从人工编排变成模型自主编排。你可以把它理解成一个会使用工具的实习生——你只需要告诉他目标他自己决定先干什么、后干什么、用什么工具干遇到缺参数还会主动问你。这不是什么玄学本质上是把模型的推理能力、工具执行能力和任务状态管理能力组装成一个闭环。1.2 设计目标轻量、模型无关、可增量扩展项目从一开始就定了几个原则后面所有设计决策都是围绕这几个原则做的。第一个原则是轻量。我不想用那种一上来就要部署一堆中间件的框架一个 Agent 而已拆开看无非是模型对话 工具执行 状态管理完全可以用单进程解决。所以 hermes-agent 的核心代码没有引入 Celery、Redis 这类重组件一个 Python 进程、一套简单的配置就能启动部署成本基本为零。第二个原则是模型无关。虽然名字里带 Hermes但我不希望它被绑定在某个具体模型上。OpenAI 的接口、Anthropic 的接口、本地部署的 Qwen 和 Llama只要实现一个统一的 message 协议都能作为后端接入。这样做的好处很明显模型迭代太快今天是 Hermes明天可能有更强的开源模型框架不能跟着模型一起换代。第三个原则是增量扩展。工具是 Agent 的手工具生态决定了 Agent 的上限。所以工具注册机制必须足够简单新加一个工具只需要写一个 Python 函数加一行注册装饰器不需要改任何核心代码。后面我会详细展示这套注册机制。1.3 开发语言与环境选型我选了 Python原因很直接AI 生态里 Python 的工具链最完整不管是调用模型 SDK、做数据处理、还是接第三方 API都能找到现成的库。版本上用了 Python 3.10主要是为了用上match语法和更友好的类型标注。虚拟环境用uv管理比 pip 快不少锁定依赖也方便。依赖方面核心就四个openai兼容大多数 OpenAI 格式的模型服务、httpx处理 webhook 和 HTTP 请求、pydantic做配置校验和工具参数定义再加一个PyYAML读配置文件。整个项目的 requirements 加上注释不到二十行这个重量在同类框架里算是相当克制了。2. 核心架构拆解2.1 四层架构调度层、工具层、记忆层、执行层hermes-agent 的架构我分成四个层每层各管一件事边界尽量清晰。调度层是整个 Agent 的大脑。它维护一个主循环不断把当前的对话历史和可用工具列表打包成请求发给模型模型返回一个决策结果调度层再根据结果决定下一步是调用工具还是结束任务。这个循环看起来简单但有个关键点必须控制单次请求的 token 上限和最大迭代次数否则模型可能在长任务里陷入死循环或者把上下文撑爆后面排查章节我会详细讲。工具层是 Agent 的手。每个工具都是一个注册过的 Python 函数带有名字、描述、参数 schema 三件套。模型看到的是这些工具的 JSON 描述真正执行时由工具层做参数校验和调用然后把执行结果返回给调度层。工具层还负责异常兜底——工具报错了不能直接让 Agent 崩溃要把错误信息转化为模型能理解的文本。记忆层解决多轮任务中信息怎么存的问题。我用了双轨设计短期记忆就是一个消息列表保存当前任务的对话轮次长期记忆是一个轻量级的 key-value 存储把任务中产生的关键信息比如查询到的股票代码、用户偏好、中间结果按语义键值存下来跨任务复用。执行层是最薄的一层负责把调度层的决策真正落地包括调用工具函数、发送 HTTP 请求、触发 webhook、管理子进程等。它的设计原则是什么都不懂只负责干活所有业务逻辑尽量放在工具函数里执行层只做机械调用。2.2 为什么选 ReAct 模式而不是 Plan-and-Execute做 Agent 绕不开一个选型问题用 ReAct 模式还是 Plan-and-Execute 模式。这两种思路我都在实验里跑过最终选了 ReAct 作为主力执行模式原因比较实际。ReAct 模式是边想边做模型在每一轮先输出思考过程再输出一个动作动作执行完拿到结果后继续下一轮直到任务结束。它的优点是灵活中间过程可以根据实际反馈随时调整不需要提前把所有步骤规划完整。缺点也明显如果模型能力不够容易在小步骤上反复横跳。Plan-and-Execute 模式是先让模型生成完整计划然后逐条执行。优点是高层的任务分解比较稳定缺点是计划一旦执行到一半发现环境变了或者某个步骤缺少必要信息调整起来很麻烦相当于计划作废重来。我做了一个对比实验用同一个任务收集三个城市一周内的天气趋势并生成对比报告分别跑两种模式。ReAct 模式在遇到某个城市天气接口返回异常时会自动降级到备用数据源全程没中断Plan-and-Execute 模式直接卡死在获取城市 B 数据这一步上因为计划里没有处理失败的分支。所以 my conclusion 是中小型个人项目里ReAct 模式的鲁棒性远高于 Plan-and-Execute。大厂做那种流程极其固定的业务场景可能更适合后者但对个人开发者来说ReAct 是投入产出比最高的方案。2.3 主循环Agent 最核心的一段逻辑主循环是整个框架的心脏。我直接贴一段简化过的核心代码async def run_agent(task: str, max_iterations: int 20): messages [{role: user, content: task}] for step in range(max_iterations): response await llm.chat( messagesmessages, toolsregistry.get_tool_schemas(), temperature0.2, ) if response.tool_calls: messages.append({ role: assistant, tool_calls: response.tool_calls, }) for call in response.tool_calls: result await registry.execute(call.name, call.arguments) messages.append({ role: tool, tool_call_id: call.id, content: result, }) else: # 模型没有请求工具调用认为任务完成 return response.content raise AgentLoopExceededError(f超过最大迭代次数 {max_iterations})这段代码只有几个关键点temperature我设了 0.2因为是任务执行场景希望模型的输出尽量稳定和贴近工具执行结果不需要太多创造性max_iterations是硬保护防止任务失控工具调用的结果通过tool_call_id跟原始调用关联这是 OpenAI 接口的标准协议模型才能把结果对应到正确的调用上。3. 关键模块实现细节3.1 LLM 调度模块消息协议与上下文窗口管理调度模块虽然是大脑但实现上其实很直接——它就是维护消息列表、调用模型接口、解析响应。真正的难点在上下文窗口管理。每个模型都有 token 上限对话一长历史消息迟早会超过窗口。我的方案是分层裁剪先在消息列表头部把最早的助手回复和工具结果合并成一个摘要用之前的对话中你已经完成了以下操作...这样的压缩方式替代原始内容如果还超就把更早的消息直接丢弃只保留每一条的 tool_call_id 和结果摘要。这中间有个细节千万不要只裁剪用户消息而不动工具结果。之前我犯过这个错结果模型在后续推理中引用了已经不在上下文里的工具结果逻辑直接断掉。正确的做法是成对裁剪——用户消息和对应的工具结果要一起处理。另外一个经验是给模型配置里加上max_tokens限制否则模型在某些场景下会一直输出直到把上下文撑满。我设了 2048对大多数任务来说足够又不会让单轮回复太长导致上下文快速膨胀。3.2 工具注册机制装饰器让扩展变得超简单工具层是 hermes-agent 里使用频率最高的模块所以我花了不少心思做体验优化。设计目标一句话新工具就是一个函数加一个装饰器零额外样板代码。直接看代码from hermes_agent.tools import register_tool, ToolResult register_tool( namefetch_weather, description查询指定城市当前天气和未来三天预报, parameters{ type: object, properties: { city: {type: string, description: 城市中文名如 北京}, }, required: [city], }, ) async def fetch_weather(city: str) - ToolResult: async with httpx.AsyncClient() as client: resp await client.get( https://api.example.com/weather, params{city: city}, timeout10, ) data resp.json() return ToolResult( summaryf城市{city}未来三天最高温{data[high]}℃, rawdata, )这个设计的奥妙在于ToolResult分了两部分summary是给模型看的精简文本raw是完整数据。因为模型上下文是稀缺资源工具返回的数据如果特别大比如一份几十 KB 的 JSON全塞给模型会把上下文撑爆。所以工具作者可以在summary里只写最关键的信息完整数据放到raw里由执行层决定怎么处理。这算是我在试错中总结出来的一个比较实用的模式。工具注册表内部用了一个dict键是工具名值是包含了函数引用、描述、参数 schema 的ToolSpec。模型看到的 JSON schema 就是从这里生成的所以参数描述一定要写清楚这对模型正确调用工具帮助巨大。实测下来参数描述写得详细与否直接决定模型调用工具的准确率尤其是有多个同类型参数的时候。3.3 记忆系统双轨设计与跨任务复用记忆系统一开始我忽略了等到跑真实任务才发现没记忆的 Agent 就是个金鱼脑。比如你跟它说我住在深圳以后查天气默认加上海运提醒下一次对话它就忘了。于是我用一个 JSON 文件做持久化存储键值对的形式保存长期记忆。短期记忆其实不需要额外实现就是主循环里的messages列表。长期记忆我设计了一个MemoryStoreclass MemoryStore: def __init__(self, path: str memory.json): self.path path self.data self._load() def get(self, key: str) - str: return self.data.get(key, ) def set(self, key: str, value: str) - None: self.data[key] value self._save() def _load(self) - dict: if Path(self.path).exists(): return json.loads(Path(self.path).read_text()) return {} def _save(self) - None: Path(self.path).write_text(json.dumps(self.data, ensure_asciiFalse, indent2))长期记忆的写入策略很关键不能什么东西都往里塞。我实现了一个记忆提炼工具由模型自己决定哪些信息值得记住。比如用户说我的 API key 是 xxx服务器 IP 是 1.2.3.4模型就会调用记忆写入工具把这些信息存起来。其他闲聊内容就不会进入长期记忆。这样做的好处是记忆内容高质量、可检索不会越积越乱。3.4 Webhook 与外部接口对接Agent 怎么干活光会思考没用Agent 必须能对外部系统产生实际影响。hermes-agent 里最常用的外部接口就是 Webhook。无论是往群聊发消息、触发 Jenkins 构建、还是给 CI 系统回传结果Webhook 是通用性最强的方式。我封装了一个send_webhook工具参数包括 URL、请求方法、body、headers底层用 httpx 实现。这里有个必须注意的点Webhook 调用如果失败不能让 Agent 装作无事发生。我在工具实现里做了异常捕获如果 HTTP 状态码不是 2xx就返回一段错误描述比如请求失败HTTP 500服务器内部错误请稍后重试或检查目标服务。模型看到这个错误后会自动决定是重试还是换一种方式完成目标。有一次我让 Agent 给一个不支持 JSON 的老旧系统发 Webhook对方只接受application/x-www-form-urlencoded格式。第一次调用失败后模型在下一轮主动调整了请求格式自动把 JSON 转成了 keyvalue 串任务就这么顺下来了。这就是 Agent 相比固定脚本的最大优势——它能在运行时自己适应环境反馈。4. 从零部署一次完整任务4.1 环境准备与项目初始化克隆项目后第一步是创建虚拟环境并安装依赖。我习惯用uvuv venv .venv source .venv/bin/activate uv pip install -r requirements.txt配置文件是 YAML 格式核心配置如下model: provider: openai base_url: http://localhost:8000/v1 api_key: sk-local model_name: qwen2.5-7b-instruct max_tokens: 2048 agent: max_iterations: 20 temperature: 0.2 memory_path: memory.json tools: enabled: - fetch_weather - search_web - send_webhook - execute_python这里base_url指向本地部署的模型服务如果你用云 API 就改成对应的地址和模型名。enabled列表决定启动时加载哪些工具没启用的工具不会出现在模型的工具列表中可以避免模型在不需要的场景下乱调用。4.2 编写一个简单的自定义工具假设你希望 Agent 能帮你执行一个简单的 Redis 查询。只需要新建一个文件写上工具函数然后在配置的enabled里加上工具名# my_tools/redis_tool.py import redis from hermes_agent.tools import register_tool, ToolResult register_tool( nameredis_get, description从 Redis 中根据 key 获取字符串值, parameters{ type: object, properties: { key: {type: string, description: Redis 键名}, }, required: [key], }, ) def redis_get(key: str) - ToolResult: client redis.Redis.from_url(redis://localhost:6379/0) value client.get(key) if value is None: return ToolResult(summaryfkey {key} 不存在, rawNone) return ToolResult(summaryfkey {key} 的值是 {value.decode()}, rawvalue.decode())工具函数可以是同步的内部会自动放到线程池执行。重要的是summary的写法要对模型友好——直接说清楚结果是什么别让模型再去猜。4.3 跑一个真实的任务收集信息并发送报告部署完成后我实际跑了一个综合任务验证整体效果。任务指令是查询杭州、上海、深圳今天和明天的天气情况生成一句话总结发送到我的工作群 webhook。这个任务在 hermes-agent 中的执行轨迹是这样第一轮模型没有直接查天气而是先调用了fetch_weather工具分别传入三个城市参数。这里我观察到一个细节模型把三个城市拆分成了三次独立调用而不是一次性并发执行。因为工具描述里没写支持多城市参数模型宁可分三次也不会自己发明请求格式这是好事保持了一致性。第二轮模型拿到了三份天气数据接下来调用send_webhook发送总结消息。消息内容是模型根据三个城市的数据自己生成的今日杭州多云转晴最高温 29 度上海小雨午后转阴深圳炎热有雷阵雨外出请带伞整体还比较通顺。整个任务总耗时 18 秒迭代 4 轮没有人工干预。这个结果比我预想的好因为早期的版本经常在第三轮就出现工具参数错误或者乱编数据。后来我把工具 schema 的描述字段全面重写了一遍给每个参数都加上了格式说明和取值范围模型的工具调用准确率从 70% 出头提升到了 90% 左右。5. 常见问题与排查技巧实录5.1 模型持续输出无效工具调用这是我在测试阶段被坑得最惨的问题模型反复调用同一个工具或者调用一个根本不存在的工具名。排查下来主要有三个原因。第一是温度设置太高。模型在生成工具调用时会随机采样温度越高越容易产生幻觉参数。解决方案是任务执行场景把temperature降到 0.1~0.2。第二是工具描述与用户指令不匹配。我一开始给工具的 description 写得太简短或者太模糊模型理解不了什么时候该用什么时候不该用。把描述写清楚之后问题改善很多。第三是最大迭代次数设置过大给了模型太多试错机会。如果模型连续多次返回同样的失败调用应该主动停止而不是一直循环下去。我在主循环里加了一个检查如果同样的工具加上同样参数的调用连续出现三次强制终止并返回错误提示。这个机制算是救了不少次场。5.2 上下文溢出长任务跑到一半就报错长任务最头疼的问题就是上下文溢出。有次我让 Agent 做一个数据分析任务需要读取一个比较大的 CSV 文件工具返回了完整的 2000 行数据直接把上下文撑爆了。这个问题要从两个方向解决。第一个方向是在工具端控制返回数据量数据大时只返回摘要、前几十行样例、以及统计信息完整数据写到临时文件把文件路径返回给模型必要时再用文件读取工具分段读取。第二个方向是裁剪历史消息。我上面提到的分层裁剪方案就是这么来的。核心实现是def compress_messages(messages: list, keep_last: int 6): if len(messages) keep_last: return messages old messages[:-keep_last] recent messages[-keep_last:] # 将 old 中的用户指令和工具结果压缩成摘要 summary summarize_old_messages(old) return [ {role: user, content: f在这之前的任务历史中你已经完成了{summary}} ] recent裁剪后模型虽然失去了部分细节但保留了对任务状态的全局理解实测任务完成率没有明显下降。说到底上下文管理不是越多越好关键是让模型始终掌握任务进展到哪了、还差什么。5.3 任务死循环的快速定位手段Agent 跑着跑着进入死循环是另一个高频问题。最常见的死循环模式是模型反复调用工具但每次的结果都一样而模型又不停重试。我加了一个运行时指标收集功能在循环里记录每一步的工具名、参数、结果摘要、耗时。一旦发现死循环查看最后的轨迹记录就能定位问题。有一次我发现模型卡在搜索天气这个工具上因为工具返回的 summary 格式是城市{name}最高温{high}℃模型每次都把温度数值当成另一个城市名传回去无限套娃。排查后发现是参数描述里写的city字段示例用了{high}这种带花括号的字符串模型误解了。改成明确示例后就恢复正常。所以我的建议是Agent 调试时一定要把运行轨迹可视化地打印出来每轮的工具调用、参数、结果都要留痕。错误不可怕可怕的是错误发生后你根本不知道模型在想什么。写在最后的一些实操体会如果你也要做一个自己的 Agent 项目我最想分享的体会是模型选型和工具设计要同步考虑。别先花两周把模型调得完美再去做工具那样出来会发现模型和工具之间沟通不畅。最有效率的路径是先写一个很简陋的工具和主循环用最小的闭环跑通一个任务然后读一遍运行轨迹看模型在哪些环节理解困难再逐轮优化工具描述和返回格式。我自己前前后后改了四版工具 schema模型调用准确率才从勉强可用提高到稳定可用。另一个体会是不要贪多。Agent 的能力边界很大程度取决于工具数量和质量但工具越多模型选择错误的概率也越大。初期控制在五六个核心工具以内把每个工具的返回格式打磨到极致效果比盲目堆二十个工具要好得多。等基础跑稳了再慢慢扩展每加一个工具都要实测它在真实任务里被调用的效果而不是只测工具函数本身跑不跑得通。这个项目后续我计划继续加一块多智能体协作的能力让不同角色的 Agent 之间通过消息传递来分工比如一个 Agent 负责检索资料另一个负责写代码第三个人负责审查最后由一个主 Agent 汇总结果。当然这也意味着要重新设计通信协议和任务分配机制。如果你也在折腾类似的东西欢迎照着我这套思路先试试肯定能少走不少弯路。