ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

模型负责想,框架负责做:自研信使式Agent框架hermes-agent全解析

模型负责想,框架负责做:自研信使式Agent框架hermes-agent全解析 开头得从一个真实场景说起。今年年初我想在团队内部搭一套“让大模型自己调工具干活”的系统当时市面上的Agent框架试了个遍要么太重、要么模型调度逻辑写死在框架里出了问题根本没法查。后来我干脆自己写了一个极简的信使式代理框架取名hermes-agent——Hermes在神话里是传递讯息的信使这正好对应它的核心定位不替模型做决定只负责把意图安全、可靠地传达到工具层。这篇文章就把这个项目从头到尾拆开讲清楚包括我为什么不用现成框架、核心模块怎么设计、一个最小可用系统怎么落地以及接入真实业务后踩到的一堆坑。如果你也在做私有化部署的Agent应用或者正在纠结“到底要不要自研框架”这篇应该能给你一些很实在的参考。1. 为什么不用现成的Agent框架偏要自己写一个1.1 现成框架的“三宗罪”重、黑盒、掉链子先说结论不是现成的框架不好是它们跟我的使用场景不匹配。我当时的业务诉求其实很简单——让大模型根据用户的一句话去调用内部订单系统、库存系统、通知服务这几个API把一个跨系统的查询或操作链路串起来。听起来是不是很像LangChain或者AutoGPT能干的事但实际用下来有几个问题非常难受。第一个是“重”。框架为了覆盖各种可能的需求光依赖就装了一堆部署到内网环境里还经常遇到依赖冲突一个项目光环境就得调半天。第二个是“黑盒”。模型调用之后发生了什么工具执行的结果怎么被回传这些内部逻辑框架都封装得很死。一旦执行链路出现问题日志打出来根本看不懂是哪一步出了问题排查效率极低。第三个问题最关键——“掉链子”。框架内部默认的prompt策略、模型调度逻辑未必适配你的业务。模型一旦多绕几步整个链路就崩了而且你根本没办法干预它的中间过程。如果你只是在公网环境跑个Demo、玩一玩这些问题都不算事。但如果是把Agent做到公司内部的生产系统里稳定性和可控性比花哨的功能重要得多。我当时就下定决心核心调度自己写模型只做它擅长的事——理解意图和规划步骤执行和约束全交给代码来控制。1.2 “信使”式设计模型负责想框架负责做hermes-agent的核心设计理念可以浓缩成一句话模型是大脑框架是神经和手脚但手脚绝不替大脑做决定。具体来说我默认假设大模型是一个“智商很高但完全不了解你公司内部系统的实习生”。你跟它说“帮我查一下订单OD20240001的状态然后通知库存部门”它心里清楚应该分两步但它不知道怎么查订单、也不知道库存部门在哪个系统里。这时候hermes-agent就相当于一个“中间人”做这么几件事把用户的目标翻译成模型能理解的任务描述把内部系统的API包装成“工具”并告诉模型每个工具是干什么的、有什么参数模型决定调用哪个工具、传什么参数框架负责安全地执行执行结果返回给模型模型再决定下一步做什么直到任务完成。这种设计最大的好处是模型永远不会直接碰到内部系统调用权限、参数校验、结果过滤全部在框架层完成。模型就算“乱说话”也最多是传了错误的参数不会造成越权操作。这个边界对生产系统来说非常重要。1.3 hermes-agent适合谁、不适合谁说实话这个框架不是万能的。它的定位非常清晰——面向内部系统的私有化部署、任务链路相对固定的场景。如果你要做下面这些事它非常合适企业内部的知识库问答 工单自动处理内部系统间的数据查询、汇总和通知推送需要对接大模型API又要求数据不出内网的场景。但它不适合做开放式、自由度极高的创作型Agent比如让它写小说、做复杂的多轮情感对话。这类场景需要的是持续演进的Prompt策略和复杂的记忆系统我选择了不做因为那不是它的核心价值。接下来进入正题把项目架构和核心模块逐个拆开讲。2. hermes-agent的架构设计与核心模块拆解从宏观视角看hermes-agent运行时由四部分组成入口网关、任务调度内核、工具注册中心、模型网关。下面这张表可以先帮助你建立整体印象然后再逐个模块拆模块职责核心文件/组件入口网关接收任务请求做格式校验dispatcher.py任务调度内核维护任务状态机控制执行顺序scheduler.py工具注册中心注册、校验、调用各种工具registry.py上下文管理器管理对话历史与任务上下文context.py模型网关统一封装大模型API调用llm.py在具体展开之前说清楚一个原则:这五个模块之间是单向依赖的关系入口网关只跟调度内核通信调度内核只通过工具注册中心调用外部API上下文管理器独立于执行链路之外。单向依赖保证了我后面想替换任何一层都不会把其他模块牵连进来。2.1 任务调度内核状态机与容错机制调度内核是整个框架的心脏。我并没有采用复杂的图编排引擎而是用了一个极简的四态状态机四个状态分别是pending等待执行、running执行中、succeeded已完成、failed失败/终止。每个任务从进入框架到结束都会严格经过这几个状态。状态流转的逻辑长这样用户请求进入 ↓ 解析出目标描述与工具清单 ↓ pending —— 进入队列等待 ↓ 调度器按顺序取出 running —— 执行工具调用 ↓ succeeded 或 failed这个模型看起来非常简单但它解决了一个关键问题模型是多步推理的每一步之间可能都有依赖关系。如果没有一个明确的状态机来管理“当前走到哪一步”一旦某一步突然失败整个任务就不知道从哪里恢复。状态机就相当于给任务装了一个“进度存档点”。容错机制方面我做了三层兜底。第一层是工具超时控制每个工具调用默认超时10秒超过就标记失败并让模型换一条路走。第二层是失败重试只有网络类错误会触发重试业务参数错误绝不重试避免重复执行产生脏数据。第三层是熔断保护——同一工具在短时间内连续失败超过5次就把它暂时从工具列表里摘掉防止系统雪崩。2.2 工具注册中心一切皆插件的核心工具注册中心是hermes-agent和现成框架差别最大的地方。它的设计思路借鉴了RPC框架的注册中心概念每个工具就是一个具有输入输出声明的函数注册之后才能被模型看到和调用。工具采用装饰器声明方式注册。在代码里看起来是这样from hermes import tool tool( namequery_order_status, description根据订单号查询订单当前状态返回状态码和状态描述, params{ order_id: { type: string, description: 订单号格式为OD开头加数字, required: True } } ) def query_order_status(order_id: str) - dict: # 这里是内部系统的调用逻辑 result internal_api.query_order(order_id) return result这些工具声明会被框架收集起来转换成模型能理解的JSON Schema格式在每轮对话时随系统提示词一起发送给模型。模型并不真正执行函数它只是输出一个结构化的“函数调用指令”。这里有一个非常重要的细节——工具的描述信息写的越精确模型调用的准确率越高。不要小看description字段我实测过同一个工具把描述从“查询订单状态”改成“当用户询问订单当前处于什么状态、是否发货、是否签收时使用该工具需要订单号参数”之后误调用率降低了将近一半。模型本质上是靠语义匹配来决定用哪个工具的描述越接近用户的真实问法匹配越准。2.3 上下文管理器记忆窗口不设限就是灾难刚开始做Agent的人最容易犯的一个错误是把每一轮对话的所有历史记录全部塞给模型。这个事情一开始看着没问题但跑一段时间就会发现两个严重后果——第一是Token消耗急剧上升成本受不了第二是模型注意力被历史信息稀释经常“忘记”当前任务的重点。hermes-agent的上下文管理器采用了一个简单的策略“滚动窗口 关键信息保留”。它不会把每轮对话都完整保留而是只保留最近两轮完整对话外加一个“任务状态摘要”——每次工具执行结束后框架会用一个独立的精简模型调用或关键词提取规则把“已经完成了什么、目标是什么、下一步待办”压缩成100字以内的摘要放进下一轮的上下文。这种设计的收益非常明显上下文体积稳定模型永远能看到“目前进度”和“下一步目标”不会迷失在历史里。代价是需要额外一次小型模型调用来生成摘要但在私有化部署场景下这个成本完全可接受。2.4 模型网关屏蔽不同模型商的API差异最后是模型网关层。它的作用非常朴素不管底层接的是OpenAI接口、通义千问、Ollama本地部署的模型还是公司内部自研的模型服务对外都暴露同一个调用接口。from hermes import LLMGateway gateway LLMGateway( provideropenai_compatible, # 兼容OpenAI协议 base_urlhttp://internal-model-service:8000/v1, api_keyinternal-key, modelhermes-7b-local, temperature0.1, max_tokens2048 ) messages gateway.build_messages(system_prompt, tool_schemas, user_query) response gateway.chat(messages)模型网关做的一件事很关键它会自动判断模型的返回内容是“正常回复”还是“希望调用工具”。如果模型返回的是工具调用指令就解析出工具名和参数传给调度内核如果只是普通回复就直接返回给用户。这个逻辑判断不能只靠字符串匹配因为不同模型的返回格式五花八门必须做类型识别和容错解析。3. 从零跑通一个最小可用用例私有化部署实战上一章讲的是架构这一章直接动真格的。我以“用户查订单状态并通知管理员”这个最简单的跨系统链路为例一步步展示从环境准备到跑通全流程的操作过程你可以照着做一遍运行起来的完整代码已经在项目examples目录下。3.1 环境准备与依赖清单我的运行环境是Python 3.10Linux服务器。安装方式很简单pip install hermes-agent她的核心依赖非常少我特意控制过依赖包版本要求用途pydantic2.0参数校验与数据建模httpx0.24异步HTTP请求工具调用PyYAML6.0配置文件解析rich13.0日志与调试信息美化没有把任何大模型SDK写进核心依赖这是有意为之。SDK版本更新太频繁一旦写死就会出现“框架升级导致模型调用方式全变”的情况。模型调用统一走模型网关的HTTP API只认OpenAI兼容协议这样任何模型服务只要支持这个协议就能接入。3.2 配置文件每一个字段都是踩坑换来的hermes-agent全局配置使用YAML文件下面这份配置是最小可用的模板scheduler: max_iterations: 8 # 单个任务最大推理轮数防止模型死循环 tool_timeout: 10 # 单次工具调用超时时间秒 max_retries: 2 # 网络类错误的重复次数 circuit_breaker_threshold: 5 # 触发熔断的连续失败次数 context: max_dialog_rounds: 2 # 保留最近N轮完整对话 summary_enabled: true # 是否启用任务摘要压缩 summary_max_length: 100 # 摘要最大字符数 llm: provider: openai_compatible base_url: http://127.0.0.1:8000/v1 api_key: ${INTERNAL_API_KEY} # 从环境变量读取密钥 model: qwen2.5-7b-instruct temperature: 0.1 # 固定低温减少模型自由发挥 max_tokens: 2048 registry: tools_path: ./tools # 自动加载该目录下注册的工具 allowlist: [query_order_status, notify_admin, check_inventory]配置里最容易踩坑的是scheduler.max_iterations这个参数。我第一次跑通流程时把它设成3结果模型在一次链路里可能需要两轮工具调用才能出结果3轮根本不够用但设到50又太危险模型一旦陷入错误循环会白白消耗大量Token。我自己跑了大量用例后建议从8开始调如果你的任务链路更简单6也够用。circuit_breaker_threshold如果设得太低网络抖动时工具被误熔断影响体验太高又起不到保护作用5到8是比较平衡的区间。allowlist是工具白名单。只有出现在这个列表里的工具才会被模型看到其他的即使注册了也不会加载。设置白名单主要是出于安全考虑——你肯定不希望模型在用户诱导下调用一个删除数据库的工具。3.3 注册三个核心工具并跑通链路我把最小用例的三个工具写在一个文件里# tools/order_tools.py from hermes import tool import httpx import os tool( namequery_order_status, description根据订单号查询订单当前状态当用户询问订单是否发货、是否签收、到哪了时使用。参数为订单号。, params{ order_id: { type: string, description: 订单号格式OD开头加数字, required: True } } ) async def query_order_status(order_id: str) - dict: async with httpx.AsyncClient() as client: resp await client.post( os.environ[INTERNAL_ORDER_API], json{order_id: order_id}, timeout8 ) return resp.json() tool( namecheck_inventory, description查询某个商品的当前库存数量当用户问到库存、存货、有没有货时使用。参数为商品SKU。, params{ sku: { type: string, description: 商品的SKU编码, required: True } } ) async def check_inventory(sku: str) - dict: # 内部库存系统调用逻辑 return {sku: sku, inventory: 352, unit: 件} tool( namenotify_admin, description向管理员发送一条通知消息当订单出现异常或需要人工介入时使用。, params{ message: { type: string, description: 要通知管理员的具体内容, required: True } } ) async def notify_admin(message: str) - dict: # 调用内部IM通知服务 return {status: sent, message: message}注意工具函数我用了async def定义。因为工具调用通常是IO密集型操作如果用同步函数在高并发场景下会阻塞事件循环导致其他任务排队等待。刚开始写框架时我图省事全用的同步函数后来压测发现并发一上来整个系统响应变慢改成异步之后才有了质的提升。启动时只需要在入口文件里指定配置项即可from hermes import HermesAgent agent HermesAgent.from_config(config.yaml) if __name__ __main__: agent.run( query帮我查一下订单OD20240001的状态然后给管理员发一条消息就说这个订单已发货 )这条指令在大模型中应该触发两次工具调用先调query_order_status拿到结果后调notify_admin。第一次跑通时看到日志里两条工具调用记录依次执行那种感觉挺爽的——这个Agent链路真的像个信使一样把人的指令翻译成了实际系统动作。3.4 耗时与Token消耗的实测数据为了给你一个直观的性能参考我在内网环境下用Qwen 2.5 7B模型跑完上述链路统计结果如下指标数值总耗时1.8秒 ~ 3.2秒系统提示词Token约850用户问题Token约30模型生成的中间规划Token约280每轮上下文平均Token约1200相比公网大模型动辄3到5秒的单次响应内网部署的最大好处就是延迟可控。整条链路包含四次模型调用一次规划加两次工具调用后各一次总结、最后一次收尾总耗时也能控制在3秒左右这个体验在内部系统里完全够用。4. 接入真实业务时最容易踩的坑与排查方法框架开发完拿到真实业务环境里跑才是真正让人成长的阶段。这一章把我遇到过的、且具备普遍参考价值的四个问题完整还原出来每个问题我会讲清楚现象、排查思路、最终根因和解决方案。4.1 工具串行执行时的阻塞陷阱事件循环被卡死现象接入了库存查询工具之后系统出现一个怪毛病——第一个用户问了库存问题返回正常第二个用户再问Agent直接不响应。重启服务后又能撑几个请求然后再次卡死。排查过程第一反应是并发问题检查了调度器代码没发现明显的竞态条件。然后在测试环境用10个并发请求压测问题稳定复现还发现一个规律——卡死的请求全都调用了工具纯文本问答的请求毫秒级返回。这就把范围缩小到了工具调用链路上。我打开日志发现工具执行阶段的事件循环阻塞时间跟工具响应时间几乎一样。再深挖下去发现库存查询工具用的是requests库同步请求它阻塞了事件循环导致后续所有入站任务全部排队看起来就像“卡死”了。根因所有工具函数都必须适配事件循环不能因为某个工具是内网调用就忽略IO模型。requests发起的同步调用会阻塞整个Python进程的事件循环即使你用的是async框架也没用阻塞发生在底层。解决方案把所有的工具都改成了异步HTTP客户端同时在工具注册中心做了函数类型检查——如果是同步函数就在单独的线程池里跑避免阻塞主循环。修复之后压测FQA10并发稳定单次响应最慢2200毫秒无卡死。这个坑给我留下的经验是Agent框架里每个工具都可能成为整个系统的性能瓶颈。写工具的人只关心“能不能调到数据”但框架的调度器必须关心“这个调用会不会阻塞别人的任务”。4.2 大模型返回JSON不合法prompt约束解决了90%剩下10%靠容错现象跑通基本链路之后我在模型网关里加了一段解析逻辑——把模型返回的函数调用指令从字符串转成结构化JSON。结果发现大概每20次请求就有1次解析失败日志里报json.decoder.JSONDecodeError。排查过程我把解析失败的原始文本打出来发现了三种典型情况模型返回的JSON外面包了markdown代码块json ...JSON字符串末尾多了逗号模型把参数里的换行符直接输出成了真实换行。第一种情况最常见。即便是明确要求只输出JSONQwen系列模型偶尔还是会“画蛇添足”地加上markdown标记。解决方案三层兜底。第一层系统提示词里增加严格的输出格式说明并且给出一个完整的JSON示例few-shot效果远比空泛的指令好。第二层解析前先做预处理——去掉markdown标记、修复末尾逗号。第三层如果还是解析失败就把模型这次输出当作普通文本回复返回给用户并记录一条解析警告日志。修复后统计解析失败率从5%降到了0.3%以下剩下的偶发失败不再影响主流程。我也专门看过那些由Qwen 2.5通过的工具调用规约示例发现带有exact schema示例的prompt显著降低了非法输出概率。4.3 上下文Token爆掉摘要策略要分场景调参现象Agent在跑一个长链路任务时每次推理轮数一多模型就开始“答非所问”。打开日志发现上下文窗口被打满了最离谱的一次四个工具调用之间塞了3000多字的中间结果。排查过程我最初设定上下文管理器只会保留最近两轮对话但没限制工具返回结果的大小。库存系统某一次返回了一个包含完整商品详情列表的结果整整2000多字单这一个工具结果就占用了上下文的大半。后面几轮模型想再读用户最初的问题已经读不到了。根因上下文管理器只管理“对话层”没有延伸到“工具结果层”。工具返回的大段数据会被原封不动塞进下一轮对话。这等于开了一个上下文后门。解决方案给工具结果增加了“裁剪钩子”机制允许每个工具声明它的返回结果中哪些字段应该被保留、哪些可以截断。对于库存查询工具我只保留了总数和Top 5明细省略了全部明细。修改后上下文体积从平均2600 Token降到了1100 Token左右。另外在调度器里增加了一个硬性保护单轮工具结果超过800 Token就强制截断并加一行提示“返回结果过长已截断”。4.4 工具白名单被绕过权限边界要设两层现象一次安全巡检发现模型在回答“你连接了哪些数据库”这类诱导性问题上会尝试调用配置里没有注册的数据库查询工具。虽然因为工具不存在而执行失败但这个尝试本身就说明一个问题——模型的工具选择不由用户单方面控制。排查过程白名单明明已经生效为什么模型还会尝试调用不存在的工具后来我理解了原因模型并不“知道”工具的完整名单它看到的是注册中心发给它的JSON Schema清单。如果这个清单里只有三个工具模型理论上不会提起其他工具。但问题出在系统提示词里那个兜底描述——“如果需要其他工具也请列出工具名和相关参数”这句原本是为了容错而写的话反而诱导了模型乱编。解决方案删掉了这句兜底描述同时在工具注册中心新增了第二层防护——执行前校验。即使模型真的输出了一个白名单之外的工具名调度器也必须检查它是否在allowlist里不在就直接返回错误并且不让它进入执行阶段。现在权限边界是双保险模型看不到不存在的工具模型看到了也调不了不在白名单里的工具。5. 从单Agent扩展到多Agent协作的演进方向5.1 为什么单Agent不够用最小可用用例跑通之后我开始尝试更复杂的业务场景。很快就发现单Agent的局限当一个任务需要同时访问财务系统、CRM系统、库存系统并且还要做大量判断时把所有工具塞给一个模型会让它的“决策负担”过重。工具一多模型选错工具的概率明显上升上下文里塞满各种工具定义有效信息被稀释。这就像你让一个新员工同时管理财务、销售、库存三块业务——他大概率手忙脚乱。但如果每个业务配一个专员你只需要当一个“分配任务的经理”整个效率就上来了。5.2 分层调度的协作架构hermes-agent的多Agent版本采用了一个非常朴素的分层调度模型一个主Agent负责理解和拆解目标若干个专业子Agent各管一摊。主Agent不直接调用具体业务工具它只做两件事把用户的复杂目标拆成子任务然后把子任务抛给对应的子Agent。每个子Agent维护自己的工具清单和上下文管理器只处理自己那一个领域内的任务。拿“分析本月退货率最高商品的前三名”这个需求举例用户输入进入主Agent主Agent拆解出“获取退货数据”和“统计排序”两个子任务分别派发给订单数据子Agent和分析子Agent订单数据子Agent调用数据查询工具拿到原始退货明细分析子Agent接收明细计算排序返回结论主Agent汇总成一句话回复用户。每个子Agent都在自己的上下文里独立工作任务之间的数据通过主Agent中转。这样做最大的优势是隔离性——某一个子Agent的上下文爆了或者工具出错不会影响到另一个子Agent的现场。5.3 多Agent场景下的编排与重试策略多Agent架构的难点在于协调。我给主Agent设计了两种调度模式——串行模式和并行模式。串行模式适合有依赖的任务比如先查数据再做分析。并行模式适合互相独立的子任务比如同时查库存和查物流状态主Agent会等待所有子Agent返回再统一汇总。并行调度的核心代码简要示意如下from hermes.agent import Agent from hermes.scheduler import parallel_run async def main(): # 创建三个子Agent order_agent Agent.create(order_agent, configorder_config.yaml) logistics_agent Agent.create(logistics_agent, configlogistics_config.yaml) inventory_agent Agent.create(inventory_agent, configinventory_config.yaml) # 并行执行三个独立子任务 results await parallel_run( order_agent.run(获取订单OD20240001的详细信息), logistics_agent.run(获取订单OD20240001的物流轨迹), inventory_agent.run(查询商品SKU10023的库存) ) for result in results: print(result.output)并行模式里有一个特别值得注意的坑——一个子Agent的失败可能影响整个主任务的判断。比如三个子任务有两个成功、一个超时主Agent在汇总时如果不知道第三个任务是失败的就会产出错误结论。因此我给每个子任务的结果都加了状态标记success、failed、timeout结果传入主Agent时主Agent会优先处理异常状态。最后续写到这里hermes-agent从设计动机到架构拆解、从最小用例到真实业务踩坑再到多Agent扩展整条脉络都过了一遍。如果让我说一个最值得借鉴的地方那就是框架的边界感哪些事交给模型做哪些事必须由代码控制从一开始就划分得清清楚楚。这个边界感可能才是Agent项目最难的取舍点。模型擅长的是理解与规划代码擅长的是执行与约束——把一个系统做扎实不是让哪一方做更多而是让各自都待在自己最合适的位置上。如果你也正在做类似的Agent项目遇到问题可以直接在项目GitHub仓库的Issue区聊我看到会回。后续我计划把模型网关做的再通用一些让它能适配更多的本地推理服务同时把多Agent编排的web可视化界面排上日程。
返回列表