ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent-Reach:解决Agent落地难的企业级触达层设计与实践

Agent-Reach:解决Agent落地难的企业级触达层设计与实践

我不是第一次被问到同一个问题:你的Agent挺聪明的,为什么一次真实业务都没跑通过?

这个问题的答案,最终沉淀成了我一套内部组件的名字:Agent-Reach。它不是为了提升模型智商,而是为了解决"触达"——让Agent真正够得着订单库、物流接口、人事系统、FTP目录这些真实的业务系统。在做企业内部智能助手时,模型侧调优了很久,意图识别准确率已经非常可观,但落地仍频频翻车。原因是这些系统的调用方式五花八门:订单库在MySQL里,物流接口是对方指定的HTTP服务,人事系统只能通过消息中间件触发,还有个老系统要生成文件放进FTP目录等人导入。模型再聪明,当触达通道各自为战、混乱不堪时,一切智能都停留在Demo阶段。

Agent-Reach不是一个模型,也不是一个Agent框架,而是一个位于Agent与业务系统之间的触达层。它的职责是让任何Agent,无论底层是哪个厂商的模型,都能以统一、安全、可观测的方式去调用企业内部风格迥异的系统。这篇文章会把从零搭建Agent-Reach的思路、核心抽象、落地细节和踩坑经验完整写出来,特别适合正在做Agent实战落地、纠结"要不要在模型和业务系统之间加一层"的朋友。

1. Agent-Reach要解决的不是"智能"问题,而是"触达"问题

1.1 一个让Agent实际跑通前后的真实对比

先举一个具体例子。拿"查最近一周订单总量"这个任务来说,不加Agent-Reach时,Agent要做的事情是:先从用户的话里抽取出日期实体,然后猜测应该调哪个接口,再去翻接口文档找参数格式,同时还得处理鉴权。如果系统文档不齐全,模型就会在幻觉里编出一个不存在的接口名,或者把参数名写错。我在客户现场见过太多次类似的问题。

加上Agent-Reach之后,Agent面对的不再是几百个分散的接口,而是一套语义化的触达目标名,比如oms.order.stats。Agent只需要表达"我要查oms.order.stats,时间范围是最近一周",剩下的事情由触达层负责。这里有一个核心转变:Agent不再是"学着调用接口",而是"声明意图,触达层负责达成"。这个转变决定了整个架构的走向。触达层也因此成为模型与系统之间的翻译器、守门人和调度员。

实际联调的时候我发现,模型在输出这个结构化指令时几乎不需要额外训练,因为它的格式足够简单,简单到模型看一眼示例就能稳定复现。而这一点恰恰是很多Agent项目最容易被忽视的地方——你给了模型太多自由,它反而不知道该怎么表现。

1.2 三层的"触达"分别指什么

很多人在设计Agent时只关注"意图触达",也就是用户的需求能不能被模型理解。但落地之后就会发现,真正的难点在后面两层。

第一层是意图触达,把用户自然语言转成一个明确的任务描述,这一层通常是模型的主场,各家大模型都做得不错。第二层是动作触达,把任务描述变成可执行的指令,比如"查订单量"变成查询语句或者API调用参数。这一层如果完全交给模型自由发挥,会导致指令格式千奇百怪,今天输出JSON,明天输出自然语言,后天又变成Markdown代码块,解析逻辑很容易被逼疯。第三层是系统触达,指令最终落到某个真实系统上并拿到结果。这一层最容易被忽略,却是翻车重灾区:认证方式不一致、接口超时、数据格式不匹配、目标系统临时不可用,任何一个环节都能让整条链路崩掉。

Agent-Reach主要管的是第二层和第三层,尤其是第三层。模型侧只需要负责第一层,输出一个结构化的"触达意图",触达层把这个意图翻译成系统能懂的调用。这种分工方式在团队协作上也有好处:算法团队只需要关注模型表现,后端团队只需要维护触达目标注册表。

1.3 为什么不能直接让Agent连API

我知道有人会说:让模型直接调API不就行了吗,多一层不是增加延迟吗?这个质疑合理,但直接连通在企业内部落地时基本走不通。

首先,企业内部接口风格完全不统一。有些是RESTful API,有些是gRPC,有些只能走FTP文件交互,甚至有些系统只有存储过程能用。模型面对这些异构接口,每次调用都要现场从文档里学,出错率极高,很多模型对不常见的鉴权协议完全没有经验。其次,安全边界没办法收敛。一旦Agent直接暴露在各系统面前,权限控制只能散落在各个系统里,一个系统配一套权限,审计的时候要到处翻日志。最后,可观测性几乎没有。Agent到底调了谁?改了什么?输入输出是什么?没有统一通道的话,日志散落各处,排障基本靠猜。

把这些事情集中到一个触达层里,才可能统一鉴权、统一审计、统一纠错。这也是Agent-Reach存在的根本理由。它不是性能优化方案,而是治理方案。

2. Agent-Reach的核心设计:把"够得着"做成一门工程

2.1 最小的核心模型:触达目标(Reachable Target)

设计Agent-Reach时,我第一个建模的不是路由,也不是权限,而是"触达目标"。什么是触达目标?就是Agent可以请求的一个最小能力单元。它不是一个接口,而是一个语义化的工作单元。举个例子,oms.order.create是一个触达目标,底层可能对应一个HTTP POST接口,也可能对应往消息队列里丢一条消息,甚至可能对应执行一段RPA脚本。对Agent来说,它不需要知道,也不用关心。

在实现里,我定义了一个很轻量的数据结构来描述触达目标:

@dataclass class ReachTarget: name: str # 触达目标唯一名,如 "oms.order.create" protocol: str # "http" | "sql" | "mq" | "ftp" | "rpa" endpoint: str # 解析后的目标系统地址 auth_ref: str # 指向密钥存储里的一个凭据ID schema: dict # 参数的JSON Schema,用于校验和提示模型 idempotent: bool # 该操作是否幂等,影响重试策略 timeout_ms: int # 建议的超时时间 fallback: str | None # 失败时可用的回退目标名

这个数据结构看着简单,但它把后面一切机制都锚定了。比如schema字段,不仅用来做参数校验,还可以在系统允许的情况下反哺给模型做输入输出示例。auth_ref字段意味着Agent永远拿不到真实凭据,只有触达层才持有,这是安全边界的基础。idempotent字段决定重试策略——如果是扣费或建单这类非幂等操作,触达层会拒绝自动重试,转人工确认。

2.2 统一触达协议:把REST、SQL、MQ包装成同一句话

有了触达目标之后,Agent和触达层之间需要一种统一的对话协议。我把这种协议叫做"触达指令"(Reach Instruction),格式刻意做得非常简单,简单到让模型几乎不需要学习就能稳定输出:

{ "request_id": "req_8f2a1c9e", "action": "invoke", "target": "oms.order.stats", "params": { "date_from": "2024-01-01", "date_to": "2024-01-07", "dimension": "total_amount" } }

为什么用这种"目标名+参数"的扁平结构,而不是让模型直接输出一串URL或者SQL?因为URL和SQL都是跟具体系统绑定的方言,模型每遇到一个新系统就要重学一遍。而触达目标名是经过抽象的业务词表,模型只需要记住"说目标名,给参数"。这就像我出差到一个新城市,不需要会说当地方言,只需要在打车软件上输入目的地名称,剩下的路线、计价、支付都有平台处理。

参数部分用JSON Schema做约束。我强烈建议把schema写得完整一点,不要偷懒。模型对参数类型、必填项的遵循程度,和schema的清晰度直接相关。我给订单查询目标写schema时,会把枚举值、格式示例都写进去,比如date_from的格式写明"YYYY-MM-DD",模型输出出错的概率明显下降。

2.3 注册与发现:触达层怎么知道系统里有哪些能力

触达目标从哪来?两种方式,一种是配置驱动,一种是动态注册。Agent-Reach在设计时同时支持两者。

配置驱动的做法是把触达目标写在一个YAML文件里,启动时加载。这种方式适合绝大多数内部系统,因为企业系统变化不快,配置式的声明足够用了。一个典型的配置片段:

targets: - name: oms.order.stats protocol: sql endpoint: "mysql://oms-readonly-user@order-db:3306/oms" auth_ref: "secret://cred/oms-readonly" schema: type: object properties: date_from: { type: string, format: "YYYY-MM-DD" } date_to: { type: string, format: "YYYY-MM-DD" } required: [date_from, date_to] idempotent: true timeout_ms: 3000

动态注册则是给那些提供了明确API的系统用的,比如新上线的履约服务,可以用SDK直接向注册中心申报自己的触达目标。我实现了一个很简单的watchdog,每30秒做一次健康检查,对连续三次失败的注册目标进行标记,并在触达层路由时自动绕开不健康的目标。

这里有一个非常关键的工程决策:注册表里只存元数据,不存业务数据。每个触达目标对应的凭据都放在独立的密钥管理系统里引用,而不是直接写到数据库里。这样即使某个内部数据库泄露,密钥也不会跟着泄露。

3. 一条完整的触达链路:从意图解析到业务系统响应

3.1 意图到触达目标:规则兜底、模型泛化的两阶段匹配

有了注册表之后,下一个问题是:Agent的用户意图怎么变成具体的触达目标名?

我采用了两阶段匹配。第一阶段是精确匹配,直接从Agent结构化输出里取"target"字段,和注册表比对。只要Agent输出的是注册表里存在的目标名,就走这条短路。第二阶段是语义匹配,当Agent输出的目标名不精确,或者干脆给了一个自然语言描述时,触达层会尝试用向量相似度或规则把描述映射到最近的目标名。

第二阶段我刚开始想到的是直接用大模型做匹配,效果不错但延迟和成本不好控制。后来改成"注册表内嵌每个目标的别名列表+向量库语义检索"的组合方案。比如oms.order.stats有两个别名:"订单量"和"订单统计",当用户说"最近订单多不多"时,模型可能输出"订单统计"而不是目标名,语义检索能把它捞回来。如果检索置信度低于阈值,触达层会把候选列表发回给模型让它选,而不是自作主张地硬匹配。硬匹配造成的错误调用,比调用失败可怕得多。

顺便提一句,这个兜底的存在让模型幻觉的伤害面被限定了。就算模型编造了一个看起来合理的名字,触达层也只会返回"未注册目标",不会真的去执行某个来历不明的操作。幻觉依然可能发生,但它不再具备破坏力。

3.2 触达指令的执行引擎

执行引擎是触达层的核心循环,它的职责是把一条触达指令变成一次真实调用并拿到结果。我在实现时的主流程分五步。

第一步,验权。检查这个触达目标对当前Agent是否开放。第二步,验参。按schema校验参数格式,缺参数或类型不对直接返回"参数校验失败",不会把脏请求发到下游。第三步,查目标。从注册表取出目标定义,解析endpoint和auth_ref。第四步,执行调用。根据protocol类型分发到不同的适配器:http适配器走REST调用,sql适配器走查询,mq适配器往队列投递消息。第五步,错处理与返回。成功就直接把结果结构化返回;失败则判断是否可重试、是否需要回退、是否需要人工介入。

伪代码大致是这样:

def execute_reach(instruction): target = registry.resolve(instruction.target) guard.authorize(target, agent_id) params = validator.validate(target.schema, instruction.params) if target.need_human_confirm(): return "AWAIT_HUMAN_CONFIRM" trace_id = tracer.start(target.name) try: result = adapter_for(target.protocol).invoke(target, params) return normalize(result) except RetryableError: if instruction.allow_retry and target.idempotent: return execute_reach(instruction_with_new_id()) return "RETRY_EXCEEDED"

执行引擎整体逻辑不算复杂,但每一步的边界条件必须想清楚。尤其是"什么错误是可以重试的"这个问题,如果定义得太宽,下游系统会被重试淹没;如果定义得太窄,偶发超时就直接失败,体验很差。我用的判断标准是:连接类错误、超时类错误可重试,业务类错误、校验类错误不可重试。

3.3 权限与安全:触达守卫的粒度设计

关于权限,一个最常见的错误做法是把Agent当成一个普通用户来处理,给它一套用户权限,然后让各系统按这个权限来判断。实际跑起来会出问题:Agent的一个请求可能同时涉及多个系统,而且它的调用是跨系统的,单系统里的权限判断无法阻止"先查A再改B"这样的组合动作。

我采用的方案是三层边界。第一层,触达目标级授权:Agent本身没有底层凭据,触达层决定它能不能碰某个目标。第二层,参数级限制:即便允许调用,也要约束参数的取值范围。比如物流系统只开放了这个Agent查询特定客户范围的能力,那么在参数校验阶段就会拒掉其他客户ID。第三层,敏感操作二次确认:对于创建、删除、转账、批量更新这类操作,触达层返回一个确认请求,由人工在客户端点确认后才真正执行。这个流程看起来多了一步,但在生产环境里救过我太多次了。

权限相关的配置我也放在触达目标定义旁边。它有一个特殊字段,描述"谁、在什么条件下、可以对哪些参数范围做什么"。这套东西虽然复杂,但面对监管审计时很有用:因为所有内容都在触达层集中体现,不用挨个系统去翻配置。

3.4 可观测性:没有trace_id就不要谈Agent落地

Agent的调用链比普通API调用长得多:用户输入、模型推理、工具选择、触达指令生成、目标匹配、系统执行、结果回填。任何一个环节出问题,下游都是受害方。如果触达层不做可观测性,排障时基本靠猜。

我在Agent-Reach里强制要求一条触达指令从生成到结束必须携带同一个trace_id,并且所有日志输出都必须带上这个ID。在每个适配器里埋点记录协议类型、目标地址、耗时、字节数、返回状态,然后汇总到触达层的观测面板上,重点看几个数字:P95耗时、各目标失败率、单目标每日调用量、参数校验失败率。

下面这张表是我从线上面板里摘出来的一个简化样例,供参考:

指标说明我设置的告警阈值
目标调用P95耗时反映下游系统健康度超过500ms告警
失败率某目标近10分钟失败占比超过5%告警
校验失败率参数校验不通过的请求占比超过20%检查schema
重试率自动重试占总量比例超过10%检查幂等配置
权限拒绝比例越权请求占比快速上升时检查Agent授权

可观测性不仅在排障时有用,它还是权限策略调整的依据。比如某目标连续多天被拒绝率偏高,说明Agent的意图习惯和授权配置不对齐,需要更新目标别名或调整授权范围。

4. 实测数据与踩坑记录:触达层最容易翻车的地方

4.1 性能瓶颈:Agent响应快,不代表触达快

第一次联调时我踩到的第一个坑就是性能。当时觉得触达层只是做做转发,开销应该很小,结果实测发现,在一次完整交互里,模型推理大概用时2秒,触达层本身的耗时居然也到了300到500毫秒。什么原因?主要是协议转换和对适配器的初始化开销。比如SQL适配器每次调用都要新建连接,MQ适配器每次都要初始化生产者,这些消耗加在一起就不小了。

优化方式有三个。第一,为SQL适配器加了连接池,并限制最大连接数,避免Agent的并发请求把数据库打死;第二,把常用适配器升级为常驻对象,启动时预初始化,而不是每次调用都new一个新实例;第三,把触达层内部的序列化从JSON换成更快的msgpack,但只用于内部传输,对模型侧仍然保持JSON兼容。做完这三项,触达层P95耗时降到了80毫秒左右,总算不对整体体验造成明显影响。

这个坑给我的教训是:不要因为中间层"看起来简单"就低估它的工程开销。连接复用、对象池、预初始化这些基本功,在中间层场景里是必须做的,不是可选项。

4.2 重试陷阱:Agent一重试,后面全是并发重复请求

第二个坑来自重试策略。模型在调用工具失败时,出于"自我修复"的本能,经常会在很短时间内连续发起多次重试。如果触达层也跟着无脑重试,就会形成一拨一拨的重复请求涌向下游。有一次我在测试环境里给某个下单目标模拟了三次网络超时,结果下游订单系统收到了七条重复订单创建请求。幸好是测试环境,不然就是生产事故。

解决办法就是引入幂等键。每条触达指令都带一个request_id,触达层内部维护一个最近N小时的已执行请求去重表,这个去重表我用Redis实现,键是request_id加目标名,值是上一次的执行结果摘要。对于标记为幂等目标的操作,重复的request_id直接返回上一次的结果;对于非幂等目标,触达层不自动重试,而是返回一个"需要人工确认"的状态,由用户在界面上决定要不要继续。

这里有个细节必须强调:幂等键必须由Agent生成并稳定携带,不能在触达层内部重新生成。如果是在触达层内部为每次重试生成新的幂等键,去重就完全失效了,因为每次重试都是一个新身份。我在代码注释里用一行加粗写明了这个约束:request_id must be stable across retries for the same user intent。

4.3 错误处理:不能在Agent面前暴露原始报错

第三个坑是错误信息层面的。早期版本里,触达层在调用失败时直接把下游异常原样返回给Agent。结果Agent拿到的报错是一长串数据库异常堆栈,比如连接失败之类的内容,它当然看不懂,只能胡乱猜测,然后继续重试,甚至编造一个错误原因回复给用户。

我做了一套错误分类与转译机制,把所有下游异常映射成有限的几类:参数错误、权限不足、目标不存在、目标不可用、超时、未知错误。每类分配一个稳定的错误码和一段面向Agent的用户级说明。例如"目标不可用"对应ERROR_UNAVAILABLE,说明是"系统暂时无法连接,请稍后重试或联系管理员"。对于工程师,日志里仍然保留原始堆栈并关联到trace_id。这样Agent能做出正确的应对策略,人也能快速定位根因,两边都不耽误。

4.4 权限边界实测:让Agent"只读"却越权修改的案例

第四个坑是关于权限的。有一个Agent在原型阶段被授权"查询订单数据",我当时在目标级只开放了oms.order.query。但上线第一天,它竟然尝试调用oms.order.update去修改订单备注。查下来发现原因有两个:一是模型在生成触达目标时,把用户"把订单备注改成已发货"这类请求强行映射到了一个宽松的别名上,精确匹配没拦住;二是我在语义匹配阶段为了减少"未注册目标"日志,把一些模糊描述默认映射到了更新类目标上。

这个事件之后,我把"未注册目标"的兜底策略从"返回候选让模型选"改成了"直接拒绝并返回原因"。同时给所有写操作目标增加了一个强制标记write_only=true,在精确匹配和语义匹配两个阶段都做检查:凡是写入类目标,必须由显式的目标名触发,不允许通过别名或语义检索命中;凡是Agent没有得到写权限却试图触发写目标时,触达层先返回权限拒绝,并记录一条审计日志。

这个案例告诉我,权限配置不能只依赖语义层的"智能",要默认按"最小权限+显式授权"来做。宁可让Agent一开始多碰几次"权限不足"的拒绝,也不能让它悄悄执行了超出边界的操作。

5. 什么样的场景适合Agent-Reach,什么样的不适合

5.1 适合:系统多、接口乱、Agent种类多的环境

我总结下来,Agent-Reach这类触达层最适合三类情况。

第一类是系统多且接口风格迥异的企业内部环境。几十甚至上百个内部系统,有API有MQ有FTP,靠Agent自己学是学不完的,统一抽象是唯一可控的方案。第二类是同一个Agent需要被多个业务方复用的场景,把触达目标沉淀成平台能力,Agent侧不需要关心各个业务系统的差异。第三类是安全审计要求比较高的场景,需要清晰地回答"哪个Agent、在什么时间、调了哪个目标、传了什么参数、结果是什么"。

在这类场景里,Agent-Reach的价值不是加快一次调用,而是让Agent生态可以规模化扩张。你每多接一个新系统,只需要在注册表里多声明几个触达目标,而不需要重新训练模型或改动Agent逻辑。

5.2 不适合:系统极少且稳定的场合

但也要说点实话。如果公司只有两三个系统,接口固定、文档清晰、团队又小,那加一个触达层反而是负资产——它引入额外延迟、额外维护成本、额外的概念负担。我见过一些团队为了追求架构完整,在只有一个数据库的Demo项目里也硬套Agent-Reach,结果光写触达目标的配置就花了两天,纯属自找麻烦。

我的建议是,先以最简单的方式让Agent跑通最小闭环,当你发现调用层开始变乱、安全性难以控制时,再引入Agent-Reach。架构是为了解决问题存在的,不是为了显得高级才存在的。

5.3 后续可以扩展的方向

这个方案还有一些可以继续深挖的方向。一个是面向协议的插件化,把HTTP、SQL、MQ、FTP、RPA都做成标准适配器接口,以插件方式对外扩展,任何团队都可以接入自己的系统类型。另一个是目标自动发现,基于OpenAPI或gRPC反射自动生成触达目标的schema,减少手工配置。还有一个是安全审计的进阶,把触达层的审计日志对接到SIEM平台,并配合合规要求做保留和脱敏。

到目前为止,Agent-Reach已经在我这边接入了6个内部系统、23个触达目标,每天大概处理几千次真实调用。我最大的感受是,真正让Agent变得可用的,往往不是模型本身,而是模型和真实世界之间那条清晰、可控、可观测的通道。不同团队情况不同,但如果你也在做类似的落地,我建议把触达层当作一个一等公民来设计,而不是临时凑合的路由转发。它不一定会让你一夜之间跑通所有场景,但它会帮你把每一个夜里的故障排查时间缩短一大截。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进