Hermes-Agent:轻量级多模型编排框架的设计与工程实践
发布时间:2026/9/9 11:19:13 作者:尧图编辑部 阅读量:1,286

接了个需求要在现有系统里塞进一个能协调多个AI模型、又能让业务方自己编排工具的中间层。评估了一圈现成的Agent框架要么绑定特定大模型厂商要么把工具调用逻辑焊死在代码里扩展一次要发一版。最后决定自己写一个轻量的Agent编排核心项目代号叫hermes-agent取希腊神话里信使神Hermes的名字——它的定位就是那个在各模型和工具之间传递消息、协调调度的“信使”。如果你也在做类似的Agent系统被多模型切换、工具编排、上下文管理这些问题折腾过这篇文章应该能帮你少走不少弯路。我会把整个项目的设计思路、核心代码结构、关键参数调优和踩过的坑都摊开讲。1. 为什么没直接选现成框架单体Agent在真实业务里的三个痛点先说结论现成的Agent框架在Demo阶段很香进了生产环境就开始露怯。我梳理了一下当时试过的几类方案共性问题集中在三个方面。1.1 模型绑定导致的路由僵化不少Agent框架把“调用哪个模型”直接写死在了配置中心或者环境变量里切换模型意味着重启服务。但真实业务里一个用户问题可能同时涉及意图分类、工具调用、结果总结三个环节每个环节对模型能力的要求完全不一样。比如一个查询工单状态的需求意图分类用轻量模型就能做准确率能到95%以上成本却只有大模型的十分之一但涉及多条件模糊匹配的工具参数抽取轻量模型就经常抽错字段必须上更强的模型兜底。如果整个链路只能绑死一个模型要么成本失控要么效果不达标。1.2 工具调用的硬编码问题很多框架把工具定义放在一个大的function list里每次请求全部塞给模型。模型要在这几十个工具描述里做选择不仅token消耗大而且工具之间如果存在隐式依赖比如先创建订单才能查询订单详情纯靠模型自己去理解这种顺序关系失败率相当高。更麻烦的是业务方的工具由不同团队维护更新频率各不相同。今天A团队加了一个新接口明天B团队改了参数结构如果工具注册逻辑耦合在Agent主流程里每次变动都要动核心代码这违背了“编排层”应该保持稳定的初衷。1.3 上下文管理基本靠手动拼接这是我个人最不能忍的一点。Agent跑多轮对话时每轮工具返回的结果都要拼进上下文时间一长Prompt越来越长响应越来越慢费用越来越高。常见做法是粗暴截断——丢最早的消息但这样经常把关键前置信息丢了模型在下几轮就开始“失忆”。后来我意识到要做的不只是一个能跑通的Agent而是一个上下文可控、路由可配、工具可插拔的编排框架这是hermes-agent立项的初衷。2. hermes-agent的核心架构消息流转与有限状态机设计整个系统的设计原则就一句话模型是执行者Hermes是调度者工具是资源。它本身不实现任何业务逻辑只负责把“用户意图”翻译成“工具调用序列”再把“工具结果”组装成“模型可理解的上下文”。2.1 四个核心组件的职责边界拆开来看hermes-agent由四个相互独立的模块组成Scribe会话流写入器负责管理多轮对话的上下文读写支持自定义压缩策略。它不是一个简单的消息数组而是一个带索引的会话流每条消息都有独立的token占用量和重要性权重。Router意图路由器负责决定当前对话应该交给哪个模型。支持策略路由和语义路由两种模式策略路由靠规则匹配比如关键词、用户标签语义路由靠embedding相似度。Courier工具执行器负责执行模型选定的工具调用。它从工具注册表里取工具定义做参数校验、敏感字段脱敏、超时控制然后把结果标准化返回给模型。Registry工具注册中心所有工具的统一入口支持声明式注册和动态加载。工具在这里只做描述不写实现。这四个模块全部通过事件总线通信。Scribe写入一条消息Router根据消息内容做路由决策Courier拿到模型返回的工具调用指令后去执行执行结果再回到Scribe。整个链路里每个组件都不知道其他组件的内部细节只认消息协议。2.2 Agent生命周期的状态机单个Agent任务我设计了一个有限状态机比让模型自由发挥要稳得多IDLE → PLANNING → EXECUTING → OBSERVING → REFLECTING → TERMINATED用户消息进来Agent先进入PLANNING状态Router决定用哪个模型、要不要查历史上下文进入EXECUTING后Courier执行工具调用OBSERVING阶段观察工具返回结果是否合理如果结果不满足预期进入REFLECTING模型根据失败原因调整参数或换一个工具如果满足直接TERMINATED。这里最关键的改动是模型不直接决定最终状态状态机根据工具执行的成功与失败来判定。比如模型调用了一个查询工具返回结果是空那不应该直接告诉用户“查无数据”而应该进入OBSERVING让模型判断是不是参数有问题然后REFLECTING重新尝试。这个机制本质上借鉴了编程里try-catch的设计思路但做在了Agent层。2.3 上下文管理器的滑动窗口策略为了解决之前提到的上下文失忆问题我写了ContextManager组件核心是一个带权重的环形缓冲每条消息根据来源用户、模型、工具结果和类型指令、事实、噪音分配不同的保留优先级。缓冲区满时优先丢弃低优先级的噪音消息和过时的工具输出而不是简单地丢最早的消息。每轮结束将关键信息比如用户确认过的订单号、日期范围冗余写入一个独立持久化槽位保证后续轮次无论如何裁剪都不会丢失。这里有一个参数可以配置KEEPALIVE_K表示保留的高优消息槽位数量。默认值是5意思是最多保留5条不可裁剪的关键消息。如果业务场景比较简单可以调小如果涉及长流程多工具协作建议调到10以上。3. 从零搭建hermes-agent关键代码逻辑与工程化落地架构讲完该动手了。这节我把最核心的几个模块的代码骨架贴出来给出完整可运行的思路配套结构说明。3.1 注册中心一个带校验的工具声明体系# registry.py from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional dataclass class ToolSpec: name: str description: str parameters: Dict[str, Any] required: List[str] timeout_seconds: int 10 needs_confirmation: bool False allowed_roles: List[str] field(default_factorylist) handler: Optional[Callable] None class ToolRegistry: def __init__(self): self._tools: Dict[str, ToolSpec] {} def register(self, spec: ToolSpec): if spec.name in self._tools: raise ValueError(fTool {spec.name} 重复注册请检查Registry配置) # 做一个基础参数校验required的参数必须在parameters里声明 for r in spec.required: if r not in spec.parameters.get(properties, {}): raise ValueError(fTool {spec.name} 缺少必填参数 {r}) if spec.timeout_seconds 30: raise ValueError(fTool {spec.name} 超时时间超过上限30s请评估是否应该拆分为异步任务) self._tools[spec.name] spec def get(self, name: str) - Optional[ToolSpec]: return self._tools.get(name) def snapshot(self) - List[Dict]: # 这个方法生成模型的工具描述文件 return [{ type: function, function: { name: t.name, description: t.description, parameters: t.parameters, required: t.required, } } for t in self._tools.values()]注册中心看起来简单但有几个细节值得注意。第一是重复注册必须直接拒绝否则静默覆盖会在线上出大问题——你排查了半天“为什么工具行为不对”最后发现是多个插件注册了同名工具。第二是系统启动时必须做一个自检遍历所有已注册工具的handler用一个假参数调用一次确保工具本身可用。我遇到过工具注册成功但实际调用时发现依赖的Redis没连上这事必须提前暴露不能等用户触发才知道。3.2 路由层多策略组合而不是单一规则# router.py from abc import ABC, abstractmethod class RoutingStrategy(ABC): abstractmethod def route(self, context) - str: 返回模型标识如 local-7b / pro-xl / legacy-v2 pass class RuleRouter(RoutingStrategy): 基于规则的快速路由优先级最高 def __init__(self, rules: List[Dict]): self.rules rules # rules示例: [{keyword: 查订单, target: pro-xl, priority: 10}] def route(self, context) - Optional[str]: text context.get(query, ) for rule in sorted(self.rules, keylambda r: r[priority], reverseTrue): if rule[keyword] in text: return rule[target] return None class SemanticRouter(RoutingStrategy): 语义路由基于embedding相似度首次调用需要初始化向量库 def __init__(self, model: str, threshold: float 0.82): self.model model self.threshold threshold self._routes [] # [(embedding, target), ...] def route(self, context) - Optional[str]: query_emb embed(context.get(query, )) best_sim, best_target 0.0, None for emb, target in self._routes: sim cosine_similarity(query_emb, emb) if sim best_sim: best_sim, best_target sim, target return best_target if best_sim self.threshold else None class CompositeRouter: 组合路由先规则再语义最后默认兜底 def __init__(self, rules, semantic, default_target): self.rules rules self.semantic semantic self.default_target default_target def route(self, context) - str: target self.rules.route(context) if not target: target self.semantic.route(context) return target or self.default_target组合路由的设计意图很明确规则路由永远优先因为它可解释、可控、可测试语义路由是规则的补充用来覆盖那些“说不清但确实同类”的问题兜底策略保证永远有响应。这里有个工程化的细节规则路由的keyword不要直接匹配用户消息原文而是先过一个简单的归一化层——把全角转半角、大小写统一、去除多余空格。不然“查订单”和“查 订 单”就要写两条规则维护成本翻倍。3.3 执行器超时、重试、脱敏三件套# courier.py import asyncio from typing import Any, Dict class ToolTimeoutError(Exception): pass class ToolExecutionError(Exception): pass class Courier: def __init__(self, registry): self.registry registry async def execute(self, name: str, arguments: Dict[str, Any]) - Dict[str, Any]: spec self.registry.get(name) if not spec: return {error: fTool {name} 不存在或未注册, ok: False} # 1. 参数脱敏把敏感字段替换为*** sanitized_args self._sanitize(spec, arguments) # 2. 超时执行 try: if asyncio.iscoroutinefunction(spec.handler): result await asyncio.wait_for(spec.handler(**sanitized_args), timeoutspec.timeout_seconds) else: loop asyncio.get_running_loop() result await asyncio.wait_for( loop.run_in_executor(None, lambda: spec.handler(**sanitized_args)), timeoutspec.timeout_seconds) except asyncio.TimeoutError: raise ToolTimeoutError(fTool {name} 执行超时{spec.timeout_seconds}s) except Exception as e: raise ToolExecutionError(fTool {name} 执行异常: {str(e)}) # 3. 结果标准化 if not isinstance(result, dict): result {value: result} return {ok: True, result: result}执行器这边最容易踩的坑是同步与异步混用。很多团队的工具函数是同步写的但在Agent的异步主循环里直接调用会阻塞事件循环导致其他请求饿死。所以我在asyncio.iscoroutinefunction分支之外用loop.run_in_executor把同步函数丢到线程池去跑这样既兼容了存量工具又不阻塞主循环。另一个重要的点是脱敏。工具参数里常常有用户手机号、身份证、地址等信息这些内容如果原样传给模型token里就留下了用户敏感数据。我在执行前把所有高敏字段替换成占位符模型拿不到真实值工具拿到的才是原始值。有人说这样模型会理解错实际上不会——模型需要的只是“格式”不是“内容”。3.4 主循环把编排逻辑跑通# agent.py from enum import Enum from typing import Dict, List, Optional class AgentState(str, Enum): IDLE idle PLANNING planning EXECUTING executing OBSERVING observing REFLECTING reflecting TERMINATED terminated class HermesAgent: def __init__(self, registry, router, context_manager, llm_factory): self.registry registry self.router router self.context_manager context_manager self.llm_factory llm_factory self.state AgentState.IDLE self.history: List[Dict] [] async def handle(self, user_message: str) - Dict: self.state AgentState.PLANNING # 路由决策 target_model self.router.route({query: user_message, history: self.history}) # 组装上下文 messages self.context_manager.build_messages(user_message, self.history) # 模型推理 llm self.llm_factory.get(target_model) response await llm.chat(messages, toolsself.registry.snapshot(), tool_choiceauto) # 工具调用分发 if response.tool_calls: self.state AgentState.EXECUTING for tool_call in response.tool_calls: exec_result await self.courier.execute(tool_call.name, tool_call.arguments) if not exec_result[ok]: self.state AgentState.REFLECTING messages.append(self._format_tool_error(tool_call, exec_result)) else: # 无工具调用直接回复 self.state AgentState.TERMINATED return {reply: response.content, state: self.state} # 把工具结果回传给模型让模型生成最终回复 self.state AgentState.OBSERVING final_messages messages self._format_tool_results(exec_result) final_reply await llm.chat(final_messages) self.state AgentState.TERMINATED return {reply: final_reply.content, state: self.state}上面这段是最简版的主循环实际生产环境还多了失败重试上限、工具调用轮数限制、状态历史审计三个机制。尤其是工具调用轮数限制默认上限是5轮防止模型陷入死循环——如果不设上限模型可能因为某个工具一直返回错误而反复重试一次请求跑20多次工具调用账单直接起飞。4. 路由与编排的进阶玩法人工介入闸口与多事务一致性基础版能跑通之后我遇到一个新需求某些工具调用必须经过人工确认才能执行。比如对外发送邮件、转账付款、删除线上数据这些场景如果模型直接调工具出了事故是没人担得起责的。4.1 人工确认闸口的实现我实现了三层确认机制第一层工具声明时的needs_confirmation标志。注册工具时只要这个标志为True执行器会在真正调用前暂停把待批准的任务推送到一个确认队列。第二层模型的置信度阈值。模型在计划调用敏感工具时如果返回的置信度低于0.8系统会主动升级为人工确认不按照模型原计划执行。第三层业务规则硬编码。比如任何涉及“删除”操作的工具不论模型和人工怎么想系统都要求双重确认确认指令验证码这是代码层面写死的不随配置变化。每层确认之间用事件驱动解耦。确认队列里的任务状态变化会触发Agent状态的迁移——从WAITING_APPROVAL到APPROVED或者REJECTED。4.2 多工具链路的原子性处理多工具协作场景链路可能有5到10个步骤比如“生成报表→发送邮件→记录发送日志→更新发送状态”。任何一个步骤失败前面的副作用要么回滚要么打标记。这里我的做法是引入一个Compensation Queue补偿队列每执行完一个非幂等操作就往队列里压入一个补偿动作。如果后续步骤失败系统自动执行补偿动作来回滚已经完成的操作。这个机制借用了分布式事务里Saga模式的思想但做在了Agent编排层实现成本低很多。class CompensationQueue: def __init__(self): self._actions [] def add(self, action: Callable): self._actions.append(action) async def compensate(self): # 按逆序执行补偿动作 for action in reversed(self._actions): try: await action() except Exception as e: # 补偿动作自身也可能失败写入审计日志 audit.log(compensation_failed, errorstr(e))要注意的是不是所有工具操作都需要补偿。比如一个只读查询工具失败了毫无副作用没必要入队。只有写操作、外部API调用、数据变更类操作才需要。5. 参数调优实测从默认配置到生产可用的调参记录这个部分聊点实操的东西。hermes-agent跑通后我开始在生产环境压测发现默认参数完全不能直接用。以下是我实测后调整过的关键参数各位可以参考然后根据自己业务的负载特点再微调。5.1 路由缓存与时效性的平衡我的路由缓存踩过一个大坑。第一次使用语义路由时我把embedding结果缓存了24小时结果当天下午上线的业务变动第二天早上用户的问题还是被路由到旧模型。原因是缓存命中后根本没走实时路由逻辑。修复方案是给缓存加两层失效逻辑——绝对过期时间TTL 数据版本号。注册中心每次有工具或模型变更时全局缓存版本号加一路由缓存发现版本号不匹配自动作废重算。这属于“时间换正确性”的取舍。语义路由一次embedding计算平均耗时80ms实时算完全能接受缓存优化不是必需品。但如果你的路由此处要反向依赖大模型做一次LLM调用那就另说。5.2 核心调参模型几个影响最明显的参数参数名默认值实测建议值影响说明KEEPALIVE_K58关键上下文槽位数量调高后长对话稳定性明显提升MAX_TOOL_ROUNDS35最大工具调用轮数过低会导致复杂任务频繁失败CONFIDENCE_THRESHOLD0.750.85人工介入置信度阈值调高后误判减少但漏判增加CONTEXT_WINDOW_SIZE1624保留消息条数太多导致响应变慢太少导致信息丢失SEMANTIC_ROUTE_THRESHOLD0.800.82语义路由相似度阈值太高容易路由不到太低容易误分其中影响体感最明显的是MAX_TOOL_ROUNDS。默认3轮时一些需要三步以上工具调用的任务经常执行到一半就被截断了用户只看到一句“抱歉处理失败”但实际上工具链路已经跑到了80%。调到5之后成功率从71%升到93%左右而且没有观察到死循环激增——因为我在主循环里加了异常检测连续三轮调用同一个工具且都失败的话会强制终止。5.3 一个典型的长会话优化记录运营团队用hermes-agent做“周报自动生成助手”每个周五下午集中并发。上线第一周就出问题了60%的会话在第三轮对话后开始乱答答案里经常出现“根据之前的分析……”但之前的分析根本不存在。排查后发现是上下文窗口溢出。周报生成任务本身要查很多数据工具结果塞满上下文后最早的对话被物理裁剪掉了。而周报任务恰恰是那种前置条件极多、后文强依赖的场景——用户第一轮说“统计上周华东区销售数据”第三轮问“和上上周比呢”此时“上周华东区”这个关键限定已经被裁掉了。后来我把KEEPALIVE_K从5调到12并且给“时间范围”“地区”“渠道”这三类信息加了高优标记。优化后同类问题的答非所问率降到了8%左右。这个案例给我一个经验上下文裁剪的优先级标记不能只靠系统默认要允许业务方配置。6. 生产环境踩坑实录三个典型的深坑与排查链路每个项目都会遇到一些“当时觉得不可能”的问题能复现的坑就是有价值的坑。这里完整记录三个我印象最深的希望能帮各位省去排查时间。6.1 工具描述越长模型越爱用并不一定上线初期我给工具描述写得很详细一段描述200到300字生怕模型看不懂。实测发现模型反而经常忽略某些工具而选择那些描述简短、关键词明确的工具。一开始我怀疑是模型的问题后来测了多个模型都有这个倾向。分析后找到了原因当工具列表里有多个长描述工具时模型注意力分布会偏向开头和结尾的条目中间的长描述会被“平滑忽略”。修复方式是把工具描述控制在100字以内而且首句就写出“什么时候用这个工具”的触发条件。比如查询订单当用户询问订单状态、物流信息、发货时间时使用。参数order_id必填字符串类型订单编号。而不是写一大段“该工具可以查询订单的各种状态信息包括但不限于……”这种话。模型在工具选择时做的是快速匹配不是阅读理解。6.2 工具返回JSON太大模型直接“卡死”某个工具返回了一份30KB的JSON数据模型调用后生成响应的时间从2秒变成了25秒token消耗翻了5倍。排查时序栈发现工具结果拼进上下文后模型需要在生成回复时处理这30KB的原始数据即使它最终只用到了其中两个字段。这是典型的“信息过载”问题。修复方案是在工具执行器里加了一个结果裁剪层每个工具除了定义入参还要声明result_fields——只把模型真正需要的字段返回给模型其他字段留在日志里备查。比如订单查询工具模型只需要订单号、状态、金额那就只返回这三个字段剩下的渠道来源、内部备注、更新时间全部过滤掉。这个改动对响应速度的提升是立竿见影的。裁剪前的长结果是响应慢的头号元凶裁剪后大多数工具结果都控制在2KB以内。6.3 多实例部署时Agent路由表不一致项目从单实例扩展到多实例时出现了一个诡异的问题同一个用户请求打到A实例和B实例路由结果不一样。有时候路由到轻量模型有时候路由到大模型。排查发现是每个实例的注册中心各自加载了配置有一台机器的配置文件是旧版本。路由规则依赖注册中心的服务列表配置不一致导致路由结果漂移。修复方式是我后来给注册中心加了一个配置版本校验实例启动时向配置中心拉取版本号如果本地版本落后直接终止启动不让带旧配置的实例混入负载均衡池。这个机制简单粗暴但确实管用。从这之后我体会到Agent系统的编排层和传统微服务在这一点上没有区别——配置永远要当成代码一样做版本管理和一致性校验。7. 观测与审计Agent系统比传统服务更需要指标埋点Agent系统是个典型的黑盒用户请求进来模型自己决定怎么调用工具最终输出结果。如果结果不对你要知道是路由错了、工具错了、还是模型生成错了观测链路必不可少。7.1 我建议至少接入这几个指标路由分布每个模型/策略接收了多少请求占比多少用于成本分析和策略优化。工具调用成功率每个工具的成功率、失败率、超时率低成功率工具需要专项治理。平均工具调用轮数每个任务平均触发几次工具调用显著高于基线说明模型的工具选择能力有问题或者工具描述引导不到位。上下文窗口利用率每次请求的上下文token数和预设上限的比值超过80%就要考虑扩容或优化消息管理策略。状态机卡死次数Agent进入某一个状态后长时间未流转比如一直在EXECUTING这是bug的第一信号。7.2 用结构化日志还原完整链路在日志里我用一个trace_id贯穿整个Agent生命周期从消息进来直到最终回复。每个关键节点输出一条结构化日志{ trace_id: hx_8f3a..., event: tool_execution_start, tool: query_order, arguments: {order_id: SO-2024-0318}, model: pro-xl, context_tokens: 4821, timestamp: 2024-05-20T15:03:22.481Z }配合统一的日志查询平台就能按trace_id把一次完整请求的路由决策、模型选择、工具调用、结果裁剪全部捞出来复盘。这套体系搭好之后线上问题的定位时间从“小时级”降到了“分钟级”。8. 后续演进方向与插件化扩展项目目前的版本稳定运行了一段时间接下来有三个明确的方向。第一个是支持更多模型协议。目前内置了OpenAI兼容协议和部分国产模型的HTTP接口但企业内部还有一些自研的推理服务协议各不相同。我计划做一个标准的协议适配层让任何模型只要实现了chat()和embed()两个方法就能接入不改动编排核心。第二个是更细粒度的工具依赖编排。现在工具调用是模型自由选择的虽然能用但在关键链路上不够稳。下一步打算引入“技能模板”机制——运营或业务方可以预定义一个流程模板比如工单处理先查用户信息再查历史工单最后生成摘要模板里规定了工具调用的顺序和分支条件模型在模板框架内做细节填充而不是完全自由发挥。这会大幅提升复杂任务的稳定率。第三个是离线评测集建设。Agent系统最大的痛点是“感觉好了但说不清好在哪里”。我计划沉淀一个业务相关的问题评测集每轮发布前跑一遍把路由准确率、工具调用成功率、最终回复满意度这三个指标量化有数据才能谈优化。如果你也在做类似的Agent编排系统或者正准备从单体Agent迁移到可编排架构我的建议很直接先不要追求大而全把一个核心场景跑通把观测指标搭起来再逐步加能力。Agent系统的复杂度是累积出来的一开始把状态机、路由、工具注册这些骨架立住后面的扩展都只是往骨架上填肉而已。hermes-agent这个项目名字我一直挺满意——它就是那个跑腿送信的角色模型和工具之间最可靠的传递者不越位做业务决策只保证消息精准抵达。