Agent消息代理层实战:统一路由与会话管理,构建高效可控的AI服务

Agent消息代理层实战:统一路由与会话管理,构建高效可控的AI服务 做Agent系统最头疼的从来不是模型本身的调用而是那些散落在各个业务角落里、理不清关系的消息流和状态。我在过去半年里折腾了好几版Agent服务从最初一个简单的Chat接口到后来要同时对接多个模型、支持工具调用、管理多轮会话、做权限控制代码越写越重思维链越来越乱。中间踩了无数坑之后我干脆把“消息收口”这件事单独抽了出来做了一个轻量级的代理层取名叫hermes-agent。Hermes在神话里是传递信息的信使我这个项目做的事也差不多——让所有Agent消息从四面八方进来再由它统一调度、路由、分发到对应的模型和处理单元最后把结果送回去。这篇文章就把整个hermes-agent的设计思路、核心模块、部署实操和排坑过程完整拆开聊。无论是你准备自己搭一个Agent消息网关还是纯粹想看看Agent架构里“代理层”到底怎么设计都可以直接参考这里面的方案。1. 为什么需要hermes-agent被混乱逼出来的消息收口方案1.1 我遇到的问题API散乱与Agent状态失控先说我在没有代理层之前是怎么写Agent服务的。最早一版我在业务代码里直接调大模型API每个接口各自维护一套历史记录每个工具函数自己处理超时和重试。一开始只有一个模型、一个会话池跑起来还挺顺。等业务一复杂问题就全冒出来了。第一是模型接入越来越乱A接口用OpenAI格式B接口用Claude格式C接口是国产模型的私有协议业务侧每个调用都要写一堆协议转换第二是会话状态完全失控同一个用户在多轮请求里可能被负载均衡打到不同实例上下文直接丢掉用户问“我刚才说的那个报告呢”系统一脸懵第三是工具调用的权限和限流根本没法统一管每个服务各管各的出了问题要一个个查日志。这个局面特别像一个大公司突然业务变多但是每个部门都自己拉电话线、自己接前台客户打个电话进来转接三四个分机都找不到人。你缺的不是“再写一个业务服务”而是一个统一的总机——所有电话先进来由总机判断该转给谁、怎么转、要不要录音存档。hermes-agent就是给Agent系统做这个总机的。1.2 为什么选“消息代理”而不是“重框架”当时我也考虑过直接上一些重量级的Agent编排框架但评估下来有几个点让我打退堂鼓。第一是侵入性太强。重框架往往要求你把业务逻辑整个挪进它的体系里用它的Agent类、它的工具注册方式、它的会话存储。我现有的服务是一堆Python接口全改一遍成本太高而且万一想换个框架等于重写。第二是学习曲线陡。团队新同学上手成本高一个小功能要理解框架的整体设计。第三是很多重框架在“模型路由”和“消息治理”这两件事上做得并不细它们更关注怎么把复杂任务拆解成多步而我的痛点其实是“消息怎么准确、稳定、可控地送达到正确的模型和工具”。所以我决定自己写一个轻量代理层。它做的事很纯粹接消息、定级、路由、转发、记录。业务服务只需要对接她其他事情一律不管。这样业务侧代码改动量最小模型接入、协议转换、会话管理这些脏活全部下沉到代理层。2. 整体设计与模块拆解一个“语义快递站”的架构思路2.1 接入层统一协议屏蔽模型差异hermes-agent最底层是接入层负责对外统一暴露一套接口协议。不管上游调了多少个模型对外永远只提供一个标准消息格式我采用的是类似OpenAI ChatCompletion风格的请求体但做了一些简化。# 统一请求体示例 { session_id: user-3421, messages: [ {role: system, content: 你是一个电商客服助手}, {role: user, content: 帮我查一下订单20240515的状态} ], prefer_model: null, tools: [order.query, refund.apply], timeout: 30 }这个统一格式的好处在于业务侧只认得这一种请求体就够了。至于这个请求最终是发给GPT还是Claude还是某个开源模型那是代理层内部的事。接入层拿到请求之后会做三件事鉴权、限流、格式化。鉴权就是校验API Key或者内部服务的签名限流是按照用户维度、服务维度做令牌桶限制防止某一个上游把资源打爆格式化则是把统一请求翻译成目标模型需要的协议比如有的模型要求system prompt单独传有的要求tools结构不一样这些全部在这一层消化掉。这样设计之后业务侧再也不会因为“换了一个模型”而改代码。我在实际中把主模型从OpenAI换成国产模型业务服务一行代码都没动只改了代理层配置这种体验是真的舒服。2.2 路由引擎让消息按“意图”而非“代码”流动接入层把消息收进来之后路由引擎就开始干活。路由是整个hermes-agent最核心的部分它决定的一条消息到底应该交给哪个模型、哪个工具、哪个下游处理单元。我开始做的时候犯过一个错误想做一个“全能路由”既要考虑模型能力又要考虑消耗又要考虑用户偏好。结果规则越写越复杂最后连自己都调不动。后来我砍掉大量不必要的东西让路由引擎只做三件事意图分类、模型选择、工具绑定。意图分类我用的是轻量级的方案不引入单独的NLP模型只做关键词匹配和正则规则的组合因为大部分Agent消息的目的其实很明确——用户提到“查物流”“查订单”就是查询意图提到“退款”“退货”就是售后意图。规则覆盖不了的再走默认模型兜底。route_rules: - id: order_query match: keywords: [订单, 查, 物流, 快递] require_all: false model: fast_model tools: [order.query] - id: refund_apply match: keywords: [退款, 退货, 投诉] require_all: false model: smart_model tools: [order.query, refund.apply, customer.history] - id: default match: .* model: default_model tools: [general.chat]这套规则很容易理解和调整。实际运行下来大概80%的消息能命中显式规则剩下20%走默认模型整体体验和之前用复杂NLP路由时差别不大但维护成本低了一个量级。2.3 会话与状态管理把上下文从业务代码里剥出来会话管理是我早期踩坑最深的部分。原本我把历史记录直接存在数据库表里每次业务服务自己取、自己拼结果一旦服务重启、多实例部署、或者用户在一个会话里切换了模型历史就串了。hermes-agent用一个独立的存储模块统一管理会话状态。每个session_id进来代理层先去存储里拉这个会话的历史消息拼接好之后再发给模型。模型返回后再把新的消息追加回存储里。业务侧完全不用关心上下文拼接这个事。存储层我做了两级设计热数据和冷数据。热数据放在Redis里用滑动过期时间控制比如一个会话如果10分钟没有新消息就从热存储降级到冷存储冷数据放到数据库里存的是完整的历史对话内容用于超长会话的恢复和审计。读取时优先热存储没有命中再查冷存储这样性能和数据安全都有保障。这么做还有个额外的好处——会话级的多轮限流和成本统计变得非常简单。我可以精确知道某个会话调用了多少次模型、消耗了多少token、花了多少钱按项目、按用户维度都能对账财务同学找我要数据的时候再也不用手工捞日志算了。3. 核心环节实操从部署到跑通第一轮对话3.1 快速部署与环境准备hermes-agent本身是个Python项目依赖比较克制核心库就是FastAPI、Redis、SQLAlchemy和OpenAI SDK。部署方式我推荐用Docker Compose一条命令把代理服务和Redis起起来。version: 3.8 services: hermes: image: hermes-agent:latest ports: - 8080:8080 environment: REDIS_URL: redis://redis:6379/0 DATABASE_URL: sqlite:///./hermes.db API_KEY: sk-hermes-local-test volumes: - ./config:/app/config - ./data:/app/data depends_on: - redis redis: image: redis:7-alpine ports: - 6379:6379我自己本地开发直接用SQLite代替数据库省去搭Postgres的麻烦。生产环境换成Postgres配置里改一下连接串就行。环境变量里那个API_KEY是所有上游服务调用hermes-agent时都要带的凭证。如果没有其他鉴权系统建议用强随机字符串生成不要用弱密码。别问我为什么强调这点我早期用“hermes123”当Key结果被内部同事扫到拿去刷测试接口跑了上千次模型调用账单花了不少冤枉钱。3.2 配置文件详解三个最关键的区块配置文件是agent的核心这一节我把最常见的三个区块逐个说明照着抄就能用。第一个是模型注册表。这里定义代理层能调用的所有模型以及它们的访问方式。models: - id: fast_model provider: openai model_name: gpt-4o-mini api_base: https://api.openai.com/v1 api_key_env: OPENAI_API_KEY max_tokens: 1024 timeout_ms: 15000 pricing: input_per_million: 0.15 output_per_million: 0.60 - id: smart_model provider: anthropic model_name: claude-3-5-sonnet-20241022 api_key_env: ANTHROPIC_API_KEY max_tokens: 2048 timeout_ms: 30000这里有个容易被忽略的问题api_base这个字段非常重要。如果你接的是某些国产模型的OpenAI兼容接口只要把api_base换成厂商提供的地址就能直接接入因为格式兼容。如果你的目标模型用的是非OpenAI格式那需要在provider这里注册一个自定义转换器代理层启动的时候会自动加载。第二个是会话存储配置。这块控制session数据放哪里、热冷切换的阈值怎么写。session: storage: hot_backend: redis hot_ttl_seconds: 600 cold_backend: sqlite max_history_messages: 50 max_context_tokens: 12000max_history_messages控制一个会话最多保存多少条历史超过之后最老的会被丢弃。max_context_tokens则控制拼接后的上下文不超过多少token。这两个参数要配合着调我一般把max_context_tokens设置成模型输出最大token的10倍左右给系统prompt和工具结果留出余量。第三个是路由策略。前面已经展示过route_rules的结构这里再补充一个级联思路一条消息会从上到下依次匹配规则命中即停止。如果你不想做级联只想强制某个session全部走同一个模型可以在请求体里显式指定prefer_model这样会把路由规则完全跳过。3.3 跑通第一轮对话本地Python客户端示例配置文件写好后启动服务然后用一个简单的Python客户端测一下能不能通。import requests url http://localhost:8080/v1/chat headers { Authorization: Bearer sk-hermes-local-test, Content-Type: application/json } payload { session_id: user-3421, messages: [ {role: user, content: 帮我查一下订单20240515的状态} ], tools: [order.query] } resp requests.post(url, jsonpayload, headersheaders, timeout30) print(resp.json())第一次跑的时候大概率遇到两个问题。一个是配置文件里的模型Key没填对在环境变量里没设置OPENAI_API_KEY启动时模型注册表会加载失败但服务不会直接退出而是在调用时返回500。另一个是路由规则还没匹配上“订单”关键词走了default模型但default模型没有绑定order.query工具然后工具调用就返回了“该功能不可用”。这两个坑其实都很容易排查日志里都会有明确的提示。遇到模型Key问题看启动日志里的WARNING信息遇到工具没绑定看请求日志里route命中是哪条规则。把这两块理顺之后第一轮对话就能正常返回了。4. 路由策略与模型调度的进阶玩法4.1 三种常用路由规则写法与适用场景基础的关键词匹配能覆盖大部分场景但实际用下来还有几种路由写法值得单独说。第一种是语义相似度路由。关键词匹配解决不了“用户表达里没有关键词但意思很明确”的情况。比如用户在聊天中说“我那个东西一直不发货”这段话里没有“订单”、没有“物流”但意图就是查物流。我自己搭了一个轻量embedding服务把用户输入和每个意图的示例句子做向量相似度计算相似度超过阈值再路由。route_rules: - id: order_query_semantic match: embedding: top_k: 1 min_score: 0.72 examples: - 我买的东西怎么还没到 - 快递什么时候能到 - 麻烦查下物流进度 model: fast_model tools: [order.query]第二种是用户分级路由。根据用户的会员等级或内部服务优先级把高价值用户的请求路由到能力更强的模型普通用户走成本更低的模型。这个用请求体里的一个自定义字段实现业务侧传user_level: vip。第三种是按服务维度隔离路由。同一个代理层可能会被多个上游服务共用比如客服系统和内部知识库助手都接这里。可以通过请求体里的service_type字段区分为不同服务配置不同的路由规则和配额避免一个服务的突发流量把另一个服务挤垮。4.2 多模型负载均衡与故障降级模型接入多了以后负载均衡和故障降级就变成刚需。我的方案是在某个模型ID下面挂多个上游配置代理层按权重分发。models: - id: smart_model failover: strategy: weighted_round_robin upstreams: - target: openai weight: 70 - target: anthropic weight: 30 fallback_target: fast_model这个配置的意思是smart_model的请求70%发给OpenAI30%发给Anthropic。当某一个upstream连续报错超过阈值代理层会自动把它临时摘掉所有流量切到另一个。如果两个都挂了就降级到fast_model保证用户至少能得到一个响应而不是直接超时报错。我在做故障降级时最大的心得是降级一定要保留“原模型结果”和“降级结果”的双通道输出。当用户问的是高价值问题时如果降级模型给了一个置信度不高的回答比较稳妥的做法是同时把降级结果返回给用户并在响应里带一个degraded: true标记让业务侧决定要不要展示“答案可能不准确”的提示。这样用户体验虽然打了折扣但至少不会给出一个“看似正确实则错误”的答案。4.3 成本控制低峰低配高峰快响应成本控制这块是我被领导逼出来的。原来所有流量全部走大模型月底账单出来吓一跳。后来我在代理层加了成本调度策略根据时间段和请求类型动态决定走哪个模型。低峰时段比如凌晨2点到早上8点所有非紧急请求全部路由到低价模型反正用户也不在线等着高峰时段查询类请求继续走低价模型只有复杂推理和售后投诉这类请求才走高价模型。每个模型上面都配了pricing参数代理层每次转发成功后会计算当次token消耗和费用写入按小时聚合的统计表里。这样每天早会看一眼昨日成本报告哪个服务烧钱多、哪个模型调用量异常增长一眼就能看出来。我还做了一个报警规则当某个服务的日成本超过预设阈值时自动把它的流量切换到中等价位模型同时给负责人发通知。这套机制上线之后月度模型调用成本降低了大概40%主要就是把大量“其实不需要那么聪明模型”的请求分流到了低价模型上效果非常直接。5. 常见问题与排查实录5.1 消息超时重试导致重复下单这是我在生产环境遇到的第一个严重事故。客服助手接了一个“退款申请”的工具用户发起的申请在代理层转发给模型后模型正常调用工具完成申请但响应在回传的路上因为网络抖动超时了。代理层按照默认的重试策略又发了一次请求模型再次调用退款工具结果就是同一笔订单被提交了两次退款申请。这个问题的根源是工具调用的幂等性没有保证。后来我在工具调用协议里增加了一个request_id字段代理层生成并在整个链路中透传业务侧的工具实现必须按request_id做幂等检查同一个ID只能处理一次。代理层重试时带上同一个request_id业务侧就能识别并直接返回上一次的结果不再重复执行。{ tool: refund.apply, request_id: 2f9d8e1a-6c34-4b7a-9a52-1c3f55e8b2d1, params: { order_id: 20240515, amount: 199.00 } }这个经验在所有Agent系统里都适用任何可能产生副作用的工具调用必须设计成幂等的。不是说你的代理层没做重试就没事网络抖动、客户端超时重发、甚至用户在页面上多点了一次按钮都会触发同一个工具被调用两次。5.2 上下文超限与“失忆”第二个高频问题是会话长了以后直接模型报“context length exceeded”。最开始我设的max_history_messages是20条但用户聊了几轮之后系统就忘了开头的关键信息。排查后发现因为每次请求都带着完整的历史记录而工具返回的结果本身可能很长比如查订单状态返回了一整段JSON里面还有物流轨迹。这些内容全塞进上下文里很快就超了。我后来做了两个优化。第一是给工具结果做摘要把超长的工具返回压缩成关键信息比如物流轨迹只保留最后三条状态第二是在路由引擎里根据当前会话剩余上下文预算动态决定是否裁剪最老的历史消息而不是固定砍条数。def build_context(messages, tool_results, token_budget): context [] remaining token_budget for msg in reversed(messages): msg_tokens estimate_tokens(msg) if remaining - msg_tokens 200: break context.insert(0, msg) remaining - msg_tokens return context这套动态裁剪逻辑上线后“失忆”问题基本被解决。有个关键点需要注意200 token的剩余预算必须留出来因为模型生成响应本身也需要token空间如果预算卡得太死模型可能出现截断。5.3 路由命中率低规则优先级问题第三个问题是路由规则写了但实际命中率不高。排查时发现有两类原因一是新加的规则放在了旧规则后面被前面的默认规则率先匹配吃掉了二是关键词设计得太具体用户表达稍微换一种方式就匹配不上。比如我加了一条“退款进度查询”的规则放在default规则的后面。因为default规则是match: .*会匹配所有消息所以新规则根本轮不到执行。路由引擎从上到下匹配、命中即停止所以规则顺序非常关键。我现在维护规则时有一个简单原则具体规则永远排在兜底规则之前兜底规则永远在最后。每次新增规则必须确认它的位置在所有比它更宽泛的规则之前并且在线上验证至少10条真实用户问题确保都能命中。关键词这块我的做法是从真实对话日志里提取用户说的原话再拆成高频词放进匹配清单。不要自己想当然地造词用户不会说“物流轨迹查询”用户只会说“到哪了”。5.4 常用排查工具与方法最后整理一套我平时排查问题的基本流程。现象优先排查点工具/命令请求超时模型接口响应时间、网络链路hermes-agent自带trace日志路由没有生效规则顺序、关键词覆盖hermes-cli route debug单条消息测试会话失忆上下文预算、历史记录条数Redis里看该session实际存的key重复调用工具幂等request_id是否透传查看日志中同一request_id出现的次数成本异常增长模型路由是否被错误分流到高价模型按小时成本报表查top服务我在代理层里加了一个专门的debug端点输入一条消息和session_id它会输出这条消息最终命中了哪条路由规则、选择了哪个模型、携带了多少历史token、工具调用链是什么。做规则调整的时候拿真实消息去测比对着日志猜快得多。6. 写在最后我的心得和一些可扩展的方向hermes-agent这套东西本质上不是多高深的技术它就是替我把“整理消息、分流消息、记住上下文”这些脏活累活全部包了让我能把精力放在真正的业务逻辑上。这件事做完之后我对Agent系统的架构理解也清晰了很多——不管上层怎么编排底层一定要有个干净、可控、可观测的消息收口层。如果你也想在自己的项目里做类似的东西我的建议是别一上来就追求功能大而全先把消息接入、路由、会话管理这三块做扎实跑通一个最小的闭环再逐步叠加负载均衡、成本控制、测试链路这些能力。另外你可以在做这个代理层的时候多留一些接口和埋点。现在我的hermes-agent已经能导出每个请求的完整trace信息包括模型调用链、token消耗、耗时分布。这些数据不只是排查问题用后续做Agent效果评估、模型选择优化、甚至训练一个更好的路由模型都是最宝贵的数据基础。最后分享一个小技巧如果你对自己的路由规则没信心代理层里可以加一个“影子模式”所有请求正常走线上路由但同时把消息复制一份给新规则、跑新模型在后台对两条链路的结果做对比。我正是靠这个模式在不影响线上服务的情况下验证了好几版新路由策略等确认新规则效果稳定后再正式切换。做Agent系统胆子要大但上线规则要稳。