Agent触达层设计:解决多Agent协作中的服务发现与上下文传递难题
开头我在实际项目里最头疼的不是让Agent想清楚什么而是让它够得着要用的东西。去年我们跑一个多Agent协同的内部项目业务侧的逻辑已经拆得很细规划、拆分、执行、验证各司其职可真到了某个Agent需要触发另一个服务、调取另一组数据或者把结果交给下游的时候链路总是断的——要么工具参数对不上要么Agent手上的消息格式跟目标服务的接口完全不兼容要么干脆不知道该找谁、用什么协议去找。这个痛点折腾了我将近两个月后来把所有Agent之间、Agent与外部系统之间的交互全部收敛到一个统一的触达层里也就是Agent-Reach这类思路之后整个系统的稳定性和可追踪性才真正立起来。这篇文章不聊大模型本身怎么调优也不聊Prompt怎么写得更花哨专门讲Agent在真实工程环境里的触达问题Agent怎么发现服务、怎么完成握手、怎么传递上下文、怎么处理超时和失败。适合正在做多Agent系统、或者准备把Agent接入业务系统的团队参考无论是用现成编排框架还是自己写底层这些思路都能直接套用。1. Agent-Reach的定位为什么Agent之间触达比生成更关键先抛一个反直觉的结论在一个由大模型驱动、多个Agent协作的系统里真正决定上限的往往不是单体的推理能力而是Agent之间以及Agent与外部世界的触达能力。生成文本这件事大模型已经做得够好但可靠地找到目标资源、正确地发起调用、完整地传递状态这件事目前依然是碎片化的。我接手那个项目时团队已经搭了三个独立Agent分别负责意图识别、任务拆解和结果汇总。单看每个Agent内部的Prompt质量其实都还不错。问题是这三个Agent要协作时基本靠互相扔一串JSON字段命名全凭各写各的A发出去的task_idB那边读的是jobId。后来加了个外部订单系统接口文档里要求传入merchant_order_no而任务拆解Agent只会生成order_number。这些字段对不上、类型对不上、上下文对不上的情况凑在一起就成了一个巨大的维护黑洞。Agent-Reach的定位恰好落在这一层它不是一个更好的模型也不直接负责业务语义而是充当Agent之间、Agent与系统之间的统一会话边界。它至少解决三件事服务发现让Agent能回答我现在该调用谁而不是把所有目标地址硬编码进Prompt协议转换把Agent输出的自然语言意图或半结构化字段转成目标系统真正能识别的请求格式状态透传把一次协作链路里的上下文trace_id、会话状态、安全令牌、业务标识完整地从一个Agent传到下一个而不是中断在某个中间环节。换句话说传统Agent框架解决的是思考的问题Agent-Reach解决的是伸手的问题。你让一个Agent写一份活动策划文案很简单但让它把策划文案自动提交到内容管理系统、顺带触发另一个分析Agent做数据预估再把这个预估结果回传给用户确认——这中间每一步都涉及触达层的设计与兜底。触达做好了整个编排才谈得上稳定可扩展。1.1 现有的Agent编排方案差在哪市面上的编排框架通常把注意力放在编排图上定义节点、连接关系、状态流转。它们默认Agent之间用函数调用或者事件总线就能对上话可到了真实业务环境问题就暴露出来了。首先是消息方言问题。同一个企业内部不同服务可能是不同团队维护的接口字段风格、命名规范、日期格式各不相同。A服务用2018-01-01 12:30:00B服务偏要2018/01/01T12:30:00Z。Agent本身根本无从知道这些约定它只有一个大致的意图。其次是依赖关系问题。Agent B需要的数据必须在Agent A执行完成后才能拿到如果A超时了、或者返回的数据格式不符合B的预期整个链路就会卡住。很多编排框架在这时候只会简单抛出一个超时错误而不会尝试重新协商或者降级到更简单的消息格式。第三个问题是可观测性。Agent之间的调用如果没有一个统一边界日志就是散的。排查一个问题要同时翻三个Agent的日志、两套中间件的链路追踪非常痛苦。有了统一的触达层之后所有跨Agent的交互都会经过这个边界日志、指标、监控自然就聚合了。从这些角度看Agent-Reach不是要做成一个大而全的运行时而是要做成一个薄而稳的会话干线。它把Agent之间的每一次接触都视为一次有协议、有状态、有兜底的远程调用而不是两个黑盒之间的随机摸鱼。2. 核心机制拆解路由、握手与上下文传递构建Agent-Reach最重要的三个机制是路由Routing、握手Handshake和上下文传递Context Propagation。这三件事分别回答找谁怎么接和怎么延续的问题。2.1 路由Agent如何知道该找谁路由的核心是服务注册表也就是一个轻量级的服务目录。每个Agent在启动时向目录注册自己提供的动作Action、接收的消息类型Schema、以及调用的地址。这个目录可以放在Redis、内存、或者一个简单的PostgreSQL表里关键不在于存储介质而在于Agent在发起调用前会先查目录。我建议把路由查询结果缓存到Agent进程内部而不是每次都走一次目录服务。例如一个意图识别Agent需要调用任务拆解Agent的parse_and_split动作第一次调用时会查询服务目录拿到目标地址、协议版本、超时时间等元信息然后缓存五分钟。这种设计能大幅降低目录服务的压力也能在目标Agent升级协议时通过版本号失效缓存。路由此表优化还有一个很实用的点按意图收敛。一个Agent对外暴露的动作可能很多如果每个动作都单独注册一个端点注册表会变得极难维护。更好的做法是只注册两到三个动作query查询、execute执行变更、subscribe订阅事件。动作细节放在Schema里描述由触达层做二次分发。这样服务目录保持精简Agent之间的语义关系也不会过度细分。2.2 握手建立可信会话不管Agent之间传输的是什么都建议在正式业务数据之前进行一次握手。握手的意义不是验证网络通不通而是让双方确认协议版本兼容吗、Schema能解析吗、数据里涉及的实体是否存在、有没有权限。我实现握手时用的是一个轻量级协议类似这样# Agent-Reach握手报文示例 POST /agents/task-splitter/handshake Content-Type: application/json { protocol_version: 2.1, agent_id: intent-parser-01, actions: [parse_and_split, query_schema], schemas: { parse_and_split: { input: [intent_text, session_id], output: [task_list, dependency_graph] } }, session_id: 6f8a2c1e-9b4d-4a6e-8d2f-c1a9b0e5d7f3 }对端收到后会返回一个会话令牌handshake token后续业务报文都携带这个令牌。这样触达层可以在网关处做一层轻量的校验令牌不对的直接拒绝不用等大模型解析完才发现前面的数据都白传了。握手还有一个隐藏好处Schema协商。如果你用的是大模型生成结构化输出字段名经常漂移。握手阶段让目标Agent把期望的字段名和格式下发调用方Agent可以动态调整自己的生成格式而不是约定一份永远不变的全局Schema。这个设计在面对大模型输出波动时特别管用我后面会展开讲。2.3 上下文传递别让链路断在中间A给B传数据时如果只传业务字段不传链路上下文那么B在出现问题时就不知道该去找谁、也不知道它处理的事务归属于哪一次用户请求。上下文传递要解决的就是这件事。我用的方式是为每一次端到端的请求生成一个全局唯一的trace_id所有Agent之间的报文都携带它。同时把关键会话状态用户ID、租户ID、业务标识放在一个context对象里随报文流动。这个context在设计上要满足三个要求轻量、结构化、可演进。轻量是指不要把一大堆调试信息塞进去结构化是指字段名有约定别一会儿用下划线一会儿用驼峰可演进是指新增字段不能破坏老版本必须向前兼容。这里有一个容易被忽略的细节上下文传递不只是报文里带个字段还包括跨Agent的状态恢复。比如Agent A执行到一半挂了Agent B在接续时需要有办法问A执行到哪里了、产出了什么。所以Agent-Reach最好提供两个接口get_state(session_id)和set_state(session_id, patch)。状态以增量方式写入而不是整体覆盖这样即使两个Agent同时往状态里写东西也不会互相覆盖。3. 搭建最小可用链路从单体Agent到多Agent触达理论讲了这么多下面给一个可以直接跑通的最小实现思路。这个链路不需要引入特别重的框架用Python加一个轻量HTTP服务就能搭起来。目标是两个Agent之间通过Agent-Reach完成一次携带上下文、有握手、有路由、有超时处理的真实调用。3.1 环境准备与组件划分建议的组件是服务目录一个简单的Python字典或者Redis、网关用FastAPI写一个中转层、Agent实例两个模拟Agent各自实现一个/handshake和/invoke接口。我这里给出一个非常精简的实现骨架# service_registry.py # 服务目录注册与发现 import time import threading from collections import OrderedDict class ServiceRegistry: def __init__(self, ttl300): self._services {} self._ttl ttl self._lock threading.Lock() def register(self, agent_id, endpoint, actions, protocol_version): with self._lock: self._services[agent_id] { endpoint: endpoint, actions: actions, protocol_version: protocol_version, ts: time.time() } def discover(self, agent_id, action): with self._lock: svc self._services.get(agent_id) if not svc: return None if time.time() - svc[ts] self._ttl: self._services.pop(agent_id, None) return None if action not in svc[actions]: return None return svc[endpoint]这个目录虽然简陋但核心逻辑已经具备注册、心跳更新、按动作筛选、超时失效。3.2 网关层的消息转换网关是触达层的中枢。它接收来自Agent的请求解析出target_agent_id、action、payload和context然后调用服务目录找到目标地址再把请求转发过去。# gateway.py import httpx from fastapi import FastAPI, Request from service_registry import ServiceRegistry app FastAPI() registry ServiceRegistry() app.post(/reach) async def reach(req: Request): body await req.json() target_agent body[target_agent] action body[action] payload body[payload] context body.get(context, {}) handshake_token body.get(handshake_token) endpoint registry.discover(target_agent, action) if endpoint is None: return {ok: False, error: target_action_not_found} headers { X-Handshake-Token: handshake_token or , X-Trace-Id: context.get(trace_id, ) } async with httpx.AsyncClient(timeout10.0) as client: resp await client.post(f{endpoint}/invoke, json{action: action, payload: payload, context: context}, headersheaders) return resp.json()这段代码最关键的三个点是超时时间按业务等级分开查询类3秒、执行类30秒、链路标识随请求头传播、目标不可达时返回可读的错误码而不是裸的HTTP异常。3.3 用两个Agent跑通一次调用模拟两个Agentweather-agent查询天气和planner-agent根据天气安排日程。planner要调用weather获取明日天气然后生成一条日程建议。# planner_agent.py import httpx class PlannerAgent: def __init__(self, gateway_url): self.gateway_url gateway_url def plan(self, city, date_str): # 第一步向目标Agent握手获取令牌 handshake_resp httpx.post( f{self.gateway_url}/agents/weather-agent/handshake, json{ protocol_version: 2.1, agent_id: planner-agent, actions: [query_weather] } ) token handshake_resp.json()[handshake_token] # 第二步携带上下文发起业务调用 resp httpx.post( f{self.gateway_url}/reach, json{ target_agent: weather-agent, action: query_weather, payload: {city: city, date: date_str}, context: {trace_id: demo-trace-001, session_id: session-abc}, handshake_token: token }, timeout15.0 ) return resp.json()跑通之后整个链路就变成了planner发起握手网关拿着令牌去找weather-agentweather-agent返回天气数据planner拿到后用一段Prompt加规则组装一份日程建议。从代码量上看并不复杂但已经把路由、握手、上下文、超时全部串起来了。3.4 为什么这个最小链路值得先落地可能有人会觉得这层网关看起来就是加了个中间人有必要吗我建议你先别急着下结论在直接Agent互调的方案上跑两周再说。直接调用在Demo阶段很爽代码短、链路清晰但业务量一上来或者Agent数量一多问题就集中爆发每个Agent都要自己处理认证、超时、重试代码重复率极高服务地址散落在各个Agent的配置里改一个地址要改三四个服务一旦某个Agent崩溃调用方只知道报错了完全不知道是对端满了、超时了还是参数不匹配。有了统一触达层之后这些逻辑被收敛到一处Agent本身只需要关心发起请求和拿结果两个动作稳定性从架构层面就有了保障。4. 踩坑实录工具描述过长、超时和回调地狱框架搭起来只是第一步真正折磨人的是上线后的细节问题。这一节分享我在Agent-Reach落地过程中踩过的几个最典型的坑每一个背后都有真实的排查过程和修复思路。4.1 大模型生成的工具描述过长导致路由超时卡死Agent要调用工具时需要先把我要做什么翻译成结构化的请求。很多团队为了让大模型理解得更好会把工具描述写得很长字段里加各种说明、例子和边界条件。结果就是一个请求里光是工具描述就占了2000多个token路由网关要把这些内容解析成JSON时频繁触发超时或者截断。排查链路是这样走的先看网关日志发现大量请求卡在body parsing阶段平均耗时超过8秒远超正常的毫秒级解析。一开始怀疑是网关性能问题后来逐个报文对比发现凡是超过800 token的工具描述请求解析时间呈指数增长。再往前挖是大模型发生工具调用时把一长段Prompt模板原封不动地塞进了请求参数而不是只塞实际调用所需的最小字段。修复方案一是把工具描述从给大模型看的长文本拆成给路由看的短标识大模型侧放一份完整说明触达层只传tool_id和必要参数二是在网关入口设置报文大小上限超过阈值直接拒绝并提示精简请求而不是让它进入解析流程拖垮服务。这个坑的根因其实是一个很常见的认知偏差以为信息越多越好但在Agent之间的通信里信息越精确越好。你给大模型展示详细说明是有价值的但让这份说明在服务间流转就是纯负担。4.2 超时时间一刀切导致低频查询频繁失败最开始我们把所有Agent调用的超时都设为10秒短业务和大业务混在一起。结果运行一周后发现低频高计算量的任务比如生成月度分析报告频繁超时而高频的简单查询比如查用户昵称也时不时卡顿因为对端负载高10秒内没响应。后来把超时改成按动作分级配置动作类型超时时间重试策略简单查询3秒不重试标准执行15秒最多重试2次长时间分析90秒异步回调这个表格看起来很简单但在实际系统里是救命的。尤其是长任务如果坚持同步等待90秒网关的线程池很容易被打满整体服务都会跟着假死。改成异步回调之后调用方发完请求立刻返回任务已受理等目标Agent完成后再通过回调地址把结果推回来。用户侧感知反而更快系统负载也降下来了。4.3 回调地狱结果无回调事件或重复回调不做异步回调不知道一做全是坑。首先是回调地址根本没有注册调用方说你完成后叫我但目标Agent执行完根本不知道往哪回调其次是重复回调网络抖动时同一个结果被回调了五六次调用方手里的状态机直接被冲垮。修复回调问题我做三件事第一回调订阅必须显式登记。调用方在发起长任务时除了传业务参数还必须传一个callback_url和callback_token目标Agent只有匹配到登记信息才允许发起回调。第二回调结果带上事件ID和去重字段接收方在触达层统一做幂等处理同一个事件ID只处理一次。第三增加一条兜底轮询接口。如果回调失败目标Agent没收到或者回调地址挂了调用方可以在超时后主动调用get_task_result(task_id)去拿结果。这个兜底让系统不会因为回调问题完全卡死。4.4 字段漂移是常态别指望大模型始终按JSON Schema输出做Agent之间的通信最理想的情况是大模型严格按照JSON Schema输出结构化结果。但实际跑起来你会发现同一个模型在不同温度参数、不同批次下生成的字段名都可能不一样。今天输出{ city: Beijing }明天可能就成了{ city_name: Beijing }。Agent-Reach的触达层如果只做透传这类差异就会直接变成对端解析失败。我建议在触达层增加一个输出规整模块目标Agent返回数据后先经过一次格式校准用白名单字段映射的方式把常见别名统一成标准字段同时过滤掉无用的多余字段。这个规整模块不依赖大模型只用轻量规则处理速度在毫秒级却能把字段漂移的影响降到最低。规整规则要做得聪明一点不能写死city就是city_name的映射就完了而是要支持按目标Agent的Schema动态生成映射表。在握手阶段目标Agent下发自己的标准字段名调用方在发起请求前先按这套标准生成数据返回阶段再按同一套标准做校验和清洗。这样等于把字段方言的差异拦在触达层Agent之间始终用同一种语言对话。5. 效果度量与后续演进Agent-Reach这类触达层上线之后怎么判断它是不是真的有用我的经验是别看功能做得多炫先看三个核心指标——触达成功率、链路时延和失败恢复时长。触达成功率是指Agent发起一次调用后能在限定时间内收到预期结果的概率。在被字段漂移和各种超时问题折磨的版本里我们的触达成功率只有67%也就是三成左右的调用需要人工介入或者重试。经过路由、握手、输出规整和超时分级这几轮改造之后这个数字到了94%左右。剩下6%基本集中在外部系统短暂不可用和罕见的协议版本冲突上已经是相对健康的分布了。链路时延方面原先最慢的一跳意图识别到任务拆解平均要6.7秒其中大头是大模型在思考该用哪个工具、要怎么编字段。引入Agent-Reach后通过在候选工具不超过三个时直接让Agent按路由结果发起调用不做多余的内部纠结这一跳的时延降到了2.8秒。失败恢复时长是最容易忽略的指标。早期一次调用失败后业务侧要人工排查、提取Trace、重新发起平均要20分钟。有了统一触达层后失败时会自动生成诊断信息调用目标、超时项、报文大小、字段漂移对比排障时间大幅缩短到一个比较理想的区间。后续如果要在这个方向继续演进我建议关注三件事触达层引入语义级别的静默降级当目标Agent不可用时网关可以根据服务目录里的同名异质实现自动把请求转发到备用的轻量规则引擎而不是直接报错。多租户隔离一旦Agent-Reach承载多个业务线的Agent必须做到租户间的路由、上下文和令牌完全隔离避免跨租户串数据。动态Schema学习从历史成功调用里统计字段名出现的频率和上下文自动推断某个动作在当前环境下最有可能是哪个字段名把它们提前加进规整映射表。这些年做Agent系统的最大体会是模型的能力已经在以肉眼可见的速度增长瓶颈早已转移到那些看起来没那么光鲜的工程细节上——路由怎么设计、握手怎么协商、上下文怎么传递、失败怎么恢复。Agent-Reach这个方向其实就是用一个比较笨的中间层来承接这些细节问题。我的建议是不管你是否最终采用Agent-Reach这个名字或这套实现在做多Agent问题时都要把触达层当成一个独立的、配套完整工程机制的组件去设计而不是把它散落在各个Agent的胶水代码里。把够得着这件事做扎实了想得清的价值才能真正发挥出来。