做Agent落地这段时间,我最大的感受是:Agent能不能用,七成不在模型的推理能力,而在它到底能不能稳定地“触达”外部世界。我们内部把专门解决这件事的工程层命名为Agent-Reach——直白一点说,它是Agent和工具、数据库、第三方服务之间的一条可靠通路。这篇文章不聊大概念,只聊Agent-Reach从0到1落地过程中,我反复踩过的坑、最后沉淀下来的设计和代码逻辑。如果你正在做Agent应用,或者打算把Agent接进公司内部系统,这篇文章应该对你有用。
1. 为什么Agent需要一条独立的“可靠通路”
1.1 Demo时代假装能跑,生产环境立刻现形
最早我们做一个内部问答Agent时,思路非常简单:把十几个工具的描述直接塞进system prompt,告诉大模型“你可以调用这些工具”,然后让模型自己输出一段JSON,代码再去解析JSON并执行。Demo效果看着不错,模型确实会“选择”工具,甚至能模拟出完整的调用链。
但一旦进入生产环境,问题就像潮水一样涌出来。
第一个问题是上下文爆炸。工具描述本身就是几百上千个token,工具一多,system prompt越来越长;而模型执行完工具后,我们又把一整套返回结果原封不动塞回对话历史,一轮问答下来几千token没了。更难受的是,模型经常从工具返回的JSON里提取出错误字段,然后自信满满地告诉你“订单已经取消”。
第二个问题是模型会脑补调用结果。有一次我们让它查一个订单状态,工具服务端明明报了连接超时,我们的代码却因为超时异常没被正确捕获,把一段空字符串当成结果返回给了模型。模型拿到空字符串后,居然自己编了一段“订单已发货,预计明天送达”的回复。这种问题在Demo里几乎不会暴露,因为没有用户会因为一个假结果去投诉你。
第三个问题是权限和审计完全失控。所有工具都跑在同一个服务账号下,模型想调哪个就调哪个。有一次它连续调用了三次“发送营销通知”的接口,我们发现时已经来不及追查是谁、在哪一步、为什么触发了这次调用。
这些问题的本质是:我们把“触达动作”交给了模型自由发挥,但模型并不真正理解网络、超时、幂等、权限、数据结构这些工程约束。它只是在扮演一个“会调用工具的角色”,而不是真的在执行一次可靠的动作。
1.2 模型负责决策,Agent-Reach负责交付
后来我们想明白了一件事:在Agent架构里,模型应该管“做什么、按什么顺序做”,但“能不能真的碰到目标系统”这件事,必须由一层独立的基础设施来保证。
这层基础设施,我们内部起名叫Agent-Reach。它的职责范围很清晰:
- 维护所有可触达能力的注册信息,包括参数Schema、返回Schema、超时阈值、权限级别
- 接收模型给出的“意图描述”,把它路由到具体工具
- 执行真实的网络请求或内部调用,并把原始返回结果做校验、清洗、摘要
- 把最终结果以模型友好的形式回传给对话逻辑
我把它理解成“手和脚”。模型是大脑,大脑说要喝水,Agent-Reach就是那只拿杯子、倒水、尝一口水温、再端到面前的双手。大脑不需要关心水龙头在哪、水温是多少度,它只需要接收一个干净的结果:“水已经倒好,温度大约60度,可以用。”
这样分离之后,模型的上下文占用大幅下降,代码层面也能把超时、重试、幂等、权限这些工程能力集中管理。之后Agent无论接多少新工具,模型侧的变化都只是多了一条描述而已。
2. Agent-Reach的骨干设计:注册中心、意图路由与触达协议
2.1 为什么选“插件式注册中心”
Agent-Reach的第一步,是把所有能被Agent触达的能力集中登记。我们没有用传统的配置文件,而是一个插件式注册中心。每个工具是一个实现了统一接口的适配器,注册中心负责加载、校验、索引这些适配器。
每个触达能力在注册中心里保存的核心字段如下:
| 字段 | 作用 | 示例 |
|---|---|---|
| name | 唯一id,用于路由和审计 | order.query |
| version | 适配器版本,支持灰度 | 1.2.0 |
| description | 给路由层用的人类可读描述 | 查询订单状态 |
| parameters_schema | JSON Schema,描述入参结构 | JSON Schema串 |
| result_schema | JSON Schema,描述出参结构 | JSON Schema串 |
| timeout_ms | 默认超时阈值 | 3000 |
| idempotent | 是否支持幂等 | true |
| permission_level | 最小权限级别 | read_only |
选插件而不是配置文件,是因为Agent接入的工具数量增长太快。上半年我们只有20个工具,到年底已经超过80个。配置文件会变成上千行的巨型文档,而且无法动态上下线。插件化的好处是每个工具由专人维护,注册中心启动时自动扫描、校验Schema、生成索引。某个工具要下线,直接移除插件包标记即可,不用改动Agent-Reach主流程。
2.2 触达协议的字段设计
路由之前,先要定义一套统一的触达协议。我们的原则是:模型侧不要直接面对原始工具的私有参数,Agent-Reach必须做一次“翻译”。
模型向Agent-Reach发送的触达请求,统一结构如下:
{ "request_id": "req_41a2f8", "agent_id": "agent_refund_01", "intent": { "goal": "查询订单状态", "args": {"order_id": "SO20250201"} }, "permission_token": "token_string" }这里有一个关键设计:模型不用指定“调用哪个工具”,它只需要描述目标。模型说“我要查订单状态”,路由层在注册中心里找到order.query这个能力,再通过参数映射器把{"order_id": "SO20250201"}转换成工具实际需要的请求格式。这样即便工具的底层实现从HTTP接口换成了RPC服务,模型侧完全不需要感知。
返回协议同样统一,不直接透传第三方服务的原始响应:
{ "request_id": "req_41a2f8", "status": "success", "data": { "bound_fields": {"status": "shipped", "eta": "2025-03-05"}, "summary": "订单已发货,预计3月5日送达" }, "trace_id": "trace_98de1f32" }统一协议最大的收益是模型侧稳定性。它永远只面对一个稳定结构,不会因为某天某供应商把字段从status改成orderStatus就突然抽风。
2.3 三级意图路由:不把选路全交给模型
意图路由是整个Agent-Reach里我改过最多轮次的部分。一开始也是偷懒,让模型直接输出工具名,然后我们查注册中心看有没有这个工具。结果死得很惨:模型经常把工具名拼错、混淆相似工具,或者编造一个看起来很像但根本不存在的工具名。
后来我改成三级路由:
- 向量召回:先对用户意图做embedding,跟注册中心里所有工具的description做相似度召回,取TopK候选。
- LLM择路:让模型从TopK候选里选择最合适的一个。这一步比直接从全量工具里选择要稳定得多,因为候选集已经被限定在5个左右,模型犯错的概率大幅下降。
- 规则兜底:对一些高频且参数高度确定的工具(比如订单查询、天气查询),直接写一个正则或就字典映射,不走LLM路由。命中兜底规则时,甚至可以省掉一次模型调用。
实测下来,纯LLM选工具的准确率大概在85%,走完三级路由后能到97%以上。剩下3%,Agent-Reach会拒绝执行并把拒绝理由回传给模型:“无法确定想触达的是order.query还是order.refund,请补充参数。”
2.4 触达生命周期里最容易被忽略的一环
每个触达请求,Agent-Reach都会推进一个显式状态机:
| 状态 | 含义 |
|---|---|
| PENDING | 请求已受理,等待路由 |
| ROUTING | 正在匹配触达能力 |
| REACHING | 正在调用目标工具 |
| VALIDATING | 校验返回结果是否符合Schema |
| DONE | 触达完成 |
| FAILED | 触达失败 |
这个状态机不是为了好看,是真被坑过之后才加的。早期没有VALIDATING阶段,第三方服务返回一个200但JSON结构完全不对,我们直接把结果喂给模型了。模型也不管,照样基于垃圾数据生成回答。后来我们坚持在REACHING之后必须走VALIDATING,用result_schema强校验,校验不过直接降级为FAILED,绝不把脏数据注入模型的上下文。
3. 最小可运行接入:把第一只触手接到真实系统
3.1 定义一个工具适配器
Agent-Reach的接入成本,核心取决于适配器模板的成熟度。我们用Python管理适配器,基于pydantic做Schema校验,接口尽可能简单:
# reachability/order_query_adapter.py from pydantic import BaseModel from agent_reach import BaseCapability class OrderQueryParams(BaseModel): order_id: str class OrderQueryResult(BaseModel): status: str eta: str | None = None class OrderQueryCapability(BaseCapability): name = "order.query" version = "1.2.0" description = "查询订单状态,按订单ID返回当前物流状态和预计送达时间" timeout_ms = 3000 idempotent = True permission_level = "read_only" def reach(self, params: OrderQueryParams) -> OrderQueryResult: # 内部调用订单系统的HTTP接口 resp = requests.get("https://internal.order/api/v1/query", params={ "order_id": params.order_id }, timeout=2.5) resp.raise_for_status() raw = resp.json() return OrderQueryResult(status=raw["status"], eta=raw.get("eta"))注册中心启动时扫描所有继承BaseCapability的类,自动加载到路由索引里。这是Agent-Reach和普通函数调用的本质区别:它不只是一个Python方法,而是带有完整Schema、权限、超时和幂等语义的“可触达实体”。
我强烈建议新接入的工具遵循一个规则:适配器里只做网络通信和数据结构转换,不塞业务判断逻辑。判断逻辑应该放在Agent-Reach的校验阶段,或者干脆留给模型层。
3.2 在Agent主流程里调用Agent-Reach
接入之后,Agent主流程的代码就非常收敛。下面是改写后的调用逻辑:
from agent_reach.client import ReachClient reach_client = ReachClient() def handle_tool_call(intent: dict, agent_id: str, permission_token: str): # 先把意图交给Agent-Reach,完全不用关心具体调哪个工具 request = reach_client.create_request( goal=intent["goal"], args=intent["args"], agent_id=agent_id, permission_token=permission_token, ) result = reach_client.sync_call(request) # 只把summary和关键字段回传模型,原始data留在存储层 context_note = ( f"工具触达完成: {result.status}\n" f"关键信息: {json.dumps(result.data.bound_fields, ensure_ascii=False)}\n" f"摘要: {result.summary}" ) return context_note写到这里我想强调一个容易被忽视的小事:sync_call并不是唯一选择。对于耗时的触达,比如批量导出报表、触发跨天任务,Agent-Reach提供过reach_job_id异步任务的方式。模型侧收到的是一个“任务已提交,预计3分钟后完成”的状态,之后通过轮询或者回调拿到最终结果。这比让模型死死等一个5秒以上的同步响应要可靠得多。
3.3 实测链路:模型感知到的只是摘要
配好一个工具后,我通常会跑这样一条链路来验证:
- 用户在对话里说“我的订单SO20250201现在到哪了?”
- 模型生成意图描述:goal=“查询订单状态”,args里带着order_id
- Agent-Reach路由到order.query,真实请求内部订单系统
- VALIDATING阶段用OrderQueryResult的schema做校验,确认status字段存在
- 把summary返回给模型,模型据此生成最终回复
这条链路最爽的地方是:调一个工具如此规整,调80个工具也一样底层机制。模型侧永远只理解“goal + args → status + summary”,不需要理解每一个内部系统的数据结构。这特别适合那种有大量内部系统的公司,因为每个系统都被一个适配器包起来,统一成同一种触达风味。
3.4 接入时最容易翻车的地方:参数不再是模型说了算
第一次给Agent-Reach接新工具,翻车基本都发生在参数映射上。常见情况是模型输出的字段名跟工具实际需要的参数名不一致。例如工具的Schema要求user_mobile,模型喜欢给mobile。你反复在prompt里说“必须使用user_mobile”,仍然会漏。
Agent-Reach的解法是在参数映射器里建一层别名表,把常见别名统一映射到规范字段。这比训练模型守规矩省钱得多。我的经验是:不要试图让模型百分之百遵守某个参数命名规范,而是让底层系统去适应模型的自然表达。
4. 触达失败的八种姿势与修复策略:我们逐一把坑填平
4.1 超时、重试、幂等:三者必须一起设计
Agent-Reach上线初期,触达失败绝大多数来自网络抖动。一开始我们天真地以为设置了一个3秒超时,然后失败就重试,问题就解决了。结果连续出了两个事故:
- 第一个事故:某次查询订单接口响应了9秒,模型侧已经显示“查询失败”,但服务端后来实际完成了扣款。
- 第二个事故:一个发送短信的工具,因为重试逻辑,同一个手机号被连发三次验证码。
这就是典型的超时、重试、幂等没一起设计带来的问题。之后的规范是:凡是写入型工具,必须在入参里带上request_id作为幂等键,服务端保存这个键,收到相同键就返回上次结果,不重复执行;凡是查询型工具,重试最多两次,且重试间隔有指数退避。Agent-Reach在路由层强制要求:没有幂等键的写入型工具,不允许注册,直接在注册阶段就打回。
超时阈值也要分工具差异化设置。查询订单给3秒,批量生成报表给20秒,异步任务则直接走任务ID轮询。只用一个全局超时是最省事但也是最危险的,因为短超时会让慢但是合法的请求大量失败,长超时又会堵住关键线程池。
4.2 结果验证:防止模型拿着脏数据自嗨
我们统计过生产环境的一批坏Case,发现相当大比例的“模型乱编”,根因不是模型幻觉,而是上游返回数据本身就是脏的,模型只是顺着脏数据往合理方向脑补。
比如订单系统返回了{"status": "cancelled"},但模型之前已经说过订单将送达,它为了自洽,会忽略status字段或者强行解释成“已取消待重新下单”。这类问题靠prompt完全解决不了,必须靠Agent-Reach的VALIDATING阶段强制性挡掉。
我们做法很简单:对每个触达能力,定义返回结果里哪些字段是“关键真值字段”,校验时只要关键字段缺失或状态码异常,就直接标记为触达失败。这样模型拿到的永远是经过认可的干净数据。宁可让模型说“我不知道”,也不能让它拿着垃圾数据自信地回答。
4.3 权限池:模型不应该拥有比人更大的权限
Agent-Reach需要一个独立的权限体系。我见过很多团队直接把模型的服务账号当成所有工具的唯一凭证,这意味着模型一旦被诱导,就能访问所有系统。这个设计非常危险。
我们这层的做法是“权限令牌池”。Agent发起触达前,上层业务先根据用户的权限来签发一个短期permission_token,Agent-Reach只认这个token,并且会在FINAL一步校验该token是否有触达某工具的权限。token有效期一般10分钟,用完即销毁,确保模型不能长时间拿着一个万能凭证到处跑。
4.4 Agent-Reach最常见的失败类型速查表
我把我们生产环境里出现过的触达失败整理成一个速查表,新接入的团队可以直接对照:
| 失败姿势 | 典型原因 | 修复策略 |
|---|---|---|
| LinkCallTimeout | 目标服务响应超过阈值 | 快慢超时分离、异步化 |
| SchemaMismatch | 返回结果不符合result_schema | VALIDATING阶段强制拦截 |
| NoRouteFound | 意图无法匹配注册中心任何能力 | 拒绝执行并回传模型要求补充 |
| PermissionDenied | permission_token权限不足 | 上层业务重新授权 |
| NonIdempotentRetry | 重试导致重复执行 | 强制写入型工具带幂等键 |
| IntentConflict | 一个意图可路由到多个工具 | LLM择路+规则兜底 |
| StaleResult | 缓存命中但结果已过期 | 缓存TTL策略 |
| AdapterBugs | 适配器里抛出未预期异常 | 统一异常收口,错误上报 |
我建议每个Agent-Reach维护团队都建一张类似的表,因为真实的排障过程百分之八十都在查这张表。
5. 上下文通胀治理:别让触达结果淹没模型的主脑
5.1 一坨JSON塞回去,既贵又蠢
有一段时间我们只治理触达稳定性,没管上下文占用。后果就是Agent每轮对话携带的token数量直线上升。一个查询接口返回200KB的JSON,我们原封不动塞给模型,结果:
- 模型根本看不过来,大量字段被忽略,注意力被噪音稀释
- prompt越滚越长,每次调用成本成倍上升
- 响应延迟高到不可接受
这也暴露了Agent-Reach一个很重要但容易被低估的功能:触达结果必须经过“瘦身”才能返回给模型。
5.2 白名单字段+摘要器两步瘦身
我们给每个触达能力定义了两类输出元数据:
bound_fields:必须返回给模型的关键字段,一般是状态、时间、金额这类决定下一步动作的数据summary_template:由一小段模板文本拼出来的摘要说明
实现上很简单,例如订单查询的摘要模板是:
ORDER_QUERY_SUMMARY_TMPL = ( "订单{order_id}当前状态为{status}," "预计送达时间{eta}。" "如需更多信息,可补充查询详细物流轨迹。" )模型拿到的上下文就是这一句话。原始200KB的JSON数据被存到独立的存储层,模型需要细节时再通过一次新的触达去取。这个设计和搜索引擎返回片段摘要的哲学差不多:直接给最可用的答案,不要哗啦啦地把整本字典倒出来。
5.3 缓存复用:相同触达不重复烧钱
Agent-Reach内置了轻量LRU缓存,缓存key由“能力名+幂等键+参数+TTL窗口”组成。像订单状态这种大概率几分钟内不变的查询,TTL设为5分钟。实测下来,热点查询的缓存命中率能到45%左右,对成本控制非常可观。
但有两点必须注意:第一,缓存不能用在写入型工具上;第二,带了缓存的结果必须标注“cached”,让模型知道它拿到的不一定是最新数据,避免模型对“为什么刚才还这么说”产生困惑。
5.4 多Agent场景:触达层本身成为公共设施
我们的Agent不止一个,有客服Agent、运营Agent、数据Agent。最初每个Agent各自实现一套工具调用逻辑,后来发现大家调的目标系统大量重叠。我们终于把Agent-Reach封装成一个内部公共组件,所有Agent通过同一个注册中心和路由层对外触达。好处非常明显:
- 新Agent上线不再需要逐个接内部系统,只要在Agent-Reach激活对应的能力
- 权限审计集中到一个点
- 新增工具只改注册中心,不用改每个Agent
这大概是Agent-Reach从“技术方案”变成“平台能力”的关键转折:当一个公司的多个Agent都依赖同一层触达能力时,这一层就已经是Agent基础设施了。
6. 在真实业务里落地Agent-Reach的五个建议
6.1 建议一:从只读能力开始,写好能力再开放写能力
我们踩过最疼的一次跟钱有关。营销Agent通过Agent-Reach调优惠券发放接口,由于我们当时只校验了“触达成功”,没有校验“发放对象是否符合条件”,导致一批高价值用户收到小额券,而新用户反而收到了大额券。挨了投诉之后,我把全部写能力下线,重新规定了触达校验规则。
如果你想稳妥落地,先从只读工具开始,比如订单查询、库存查询、天气信息。只读工具对权限和一致性要求低,方便打磨路由和校验链路。等摸透了Agent的调用习惯,再加上带幂等键的写能力。
6.2 建议二:给每个触达能力单独建可观测性看板
Agent-Reach如果没有可观测性,你真的会在一堆失败里瞎猜。我们在每个能力上记录四个核心指标:触达成功率、P50/P95延迟、平均重试次数、校验失败率。这些指标按Agent和租户维度聚合。
有一次某地区网络出现抖动,订单查询P95延迟从600ms飙到6s,就是靠看板提前发现的。没有这些指标,你可能要等到用户投诉才知道出了问题。排障时我习惯先看触达成功率和P95,再看校验失败率,这两个指标能定位80%的问题。
6.3 建议三:灰度发布不能只按用户量,要按能力
技术产品相比业务系统有个特点:同一个Agent-Reach服务承载了多种工具能力。如果按流量灰度Agent-Reach本身,新版本路由逻辑变更可能只影响order.query,但你把所有流量都切过去了。稳妥的做法是:Agent-Reach版本灰度时,按能力名称做白名单,先只有5%的order.query流量走新版本,稳定24小时后再扩至全部。
6.4 建议四:被模型拒绝的触达请求要进复盘库
每次Agent-Reach拒绝执行某个触达请求,都是一次免费的路由质量报告。我们把拒绝请求存到一个表里,每周人工抽看。你会发现很多有趣的问题,比如:
- 模型把“查余额”描述成“重置密码”
- 用户说“给我退钱”,模型意图是“触发退款”,但参数缺退款原因
- 注册中心里明明有
payment.status,模型却总想调用不存在的payment.detail
这些复盘数据反过来指导我们优化工具的description,以及补充参数映射规则。Agent-Reach的进化不靠拍脑袋,就靠这些真实的拒绝记录。
6.5 建议五:触达层和业务层隔离,不要互相调用内部方法
架构上我坚持一个硬边界:Agent-Reach的适配器只能通过配置和注册中心发现,业务层不允许绕过Agent-Reach直接调用适配器的内部方法。因为一旦绕过了触达层,超时、幂等、验证、审计全部失效,Agent-Reach就变成一个只挂名字的装饰品。这个边界要靠代码评审和CI检查去强制,靠自觉基本守不住。
最后想说的话
Agent-Reach真正解决的问题,不是“让Agent能调用工具”,而是“让工程团队敢让Agent调用工具”。回头看,很多能力并不是模型不够好,而是外部世界的不可靠、权限的混乱、上下文的通胀让模型没法稳定发挥。把这些问题沉到一层专门的设施里去处理之后,Agent的稳定性和可解释性会明显上一个台阶。
如果只带走一个经验,我会选择“把工具能调用”和“调用成功且结果可信”分开来看。Agent-Reach的关键不是触达,而是触达之后可验证、可审计、可回滚。做到这一点,Agent才算是真正长出了可靠的双手。