1. 我为什么需要Agent-Reach:智能体的“触达半径”问题
1.1 模型再强,够不到业务系统也是白搭
过去一年我一直在做AI Agent相关的落地项目,有一个体会越来越深:模型的推理能力进步飞快,但真正卡住项目进度的,往往不是“它想不想得到”,而是“它够不够得着”。
你可以把一个Agent当成一个很聪明但没有手脚的人。它能基于训练数据里的知识给出漂亮的回答,但当你让它去查一下某个订单在内部系统里的真实状态、让它把一段文本写进企业微信的某个群、让它去翻一遍三个月前沉淀在语雀里的方案文档——它就懵了。因为它没有通道,没有触达能力。它只能基于你喂给它的上下文去“猜”,而猜出来的东西,业务方是不敢用的。
这个痛点在我做的几个项目里反复出现。最初我给Agent接的是Function Calling,对,就是OpenAI那套工具调用。但用下来有几个问题:每个函数都要写一遍JSON Schema,Agent一多、工具一多,维护成本直线上升;函数之间完全没有统一的路由和权限控制,谁都能调谁;而且Function Calling本质上还是“模型主动去调”,一旦模型的工具选择出错,整个链路就歪了。后来我也试过用MCP(Model Context Protocol)去标准化工具接入,方向是对的,但MCP解决的是“工具怎么被调用”的协议问题,它没有解决“Agent该怎么知道自己有哪些东西可以用、哪些可以用、哪些必须拦下来”的问题。MCP是“最后一公里”的传输协议,而我需要的是一个更上层的、统一描述和管控“触达动作”的东西。
这就是我为什么花了两个周末的晚上,自己搭了一套轻量级的“触达层”框架,取名Agent-Reach。它解决的核心问题可以概括成一句话:让Agent知道自己能触达什么、通过什么方式触达、以及触达到什么边界为止。这篇文章就是把这套东西的完整思路、核心设计、踩坑过程和最终代码形态分享出来,给同样被Agent“够不着系统”折磨的团队一个可参考的落地样本。
1.2 实际项目里最常遇到的三个触达断层
先说我在真实项目里遇到的三个“断层”,你会发现它们根本不是模型能力问题,而是工程问题。
第一个是系统断层。Agent需要读数据库、调内部API、写工单、发消息。这些系统各有各的鉴权方式、参数格式、返回结构。有的老系统甚至没有API,只有一套内部HTTP接口,文档还缺一半。Agent要触达这类系统,本质上是在做一件“系统集成”的活,只是执行人从工程师变成了AI。
第二个是知识断层。知识不都在模型脑子里,大量项目知识和经验散落在Wiki、语雀、Confluence、本地Markdown仓库、PDF文档里。Agent如果只靠上下文窗口里的那点资料,回答出来的东西往往是“正确的废话”。它需要能主动去检索,而且要检索得准——不是随便拿一个向量库就往上怼,而是要能区分“这个知识属于哪个项目域”“这个文档的权限等级是什么”“这个信息是实时更新的还是历史归档的”。
第三个是协作断层。我做的项目里从来不只跑一个Agent。有做需求分析的、有写代码的、有做测试用例的、有负责回复客户的。它们如果各自为战,每个Agent都从原始材料开始处理,效率极低。但如果允许Agent之间互相触达,又立刻出现一个新的问题:到底谁有权限发起协作?调度的结果怎么回传?A给B派了个活,B做完了怎么告诉A?这其实也是一种“触达”,只是触达的对象从“系统”变成了“另一个Agent”。
1.3 Agent-Reach不是Agent框架,而是触达层
做Agent框架的团队很多,LangChain、LlamaIndex、各类编排引擎都解决“Agent怎么想”的问题。但我的观察是:Agent的决策能力已经够用了,至少在垂直场景里,难的不是让模型做出正确的决策,而是让它做出决策之后,真的能把它想做的事做出来。
所以Agent-Reach的定位很明确:它不是大脑,不是身体,它是连接大脑和身体的那套神经系统和四肢。大脑决定干什么,Reach决定怎么触达、触达哪、触达之后带回来什么。
在这套设计里,我把“触达”定义成一次有明确边界、有清晰返回值、有可观测性的调用动作。它可以是调用一个Python函数,可以是请求一个REST API,可以是检索一次向量数据库,也可以是给另一个Agent发消息。所有这些动作,统一用一套描述规范挂在同一个Hub(调度中心)上,由Agent执行循环在需要的时候发起,执行结果再回填给模型。整个链路走完,模型才真正“摸到了”它想摸的东西。
我见过很多团队在Agent项目上投入巨大,最后死于“模型的嘴巴和系统的手没有连上”。Agent-Reach就是我从这个坑里爬出来之后,自己做的那根“连接管”。
2. Reach Spec:一套描述“触达”的通用规范
2.1 一次触达就是一个“快递面单”
刚开始设计的时候,我脑子里很乱。函数、API、知识库、Agent消息,这些东西形态差异太大了,硬把它们塞进同一个框架里,很容易变成一个大杂烩。后来我想清楚了一个类比:一次触达,本质上就是一次快递配送。
快递要正常送到,面单上必须写清楚:收件人是谁、地址在哪、要送什么东西、有什么特殊要求(比如生鲜要冷链)、谁付的钱、寄件人是谁。我把这个思路搬到Agent-Reach里,设计了一套统一的“触达描述”格式,叫Reach Spec。每一个可被Agent调用的东西,都对应一份Reach Spec,就像每一个可送达的目的地都对应一张面单。
一份Reach Spec长这样:
id: reach_kb_order_status type: knowledge name: 查询订单实时状态 description: 根据订单ID查询订单的当前状态(待支付/已支付/已发货/已完成/已取消) input_schema: order_id: type: string required: true desc: 14位订单编号,形如 20250101000123 env: type: enum values: [prod, test] default: prod desc: 环境标识 auth: scope: order_service.read owner: fulfillment-team timeout_ms: 3000 handler_type: python_function handler: order_repo.fetch_status这段描述告诉Agent几个关键信息:这个触达动作能干什么(查询订单状态)、需要什么参数(订单ID)、属于什么权限范围、最长等待多久、底层由哪个函数执行。模型看到这份描述,就知道“如果要查询订单状态,应该用reach_kb_order_status,需要提供order_id”。
你可能会问,为什么不直接把Python函数给它?因为函数是给机器看的,Reach Spec是给模型看的。模型需要的是语义化、结构化、带说明的自然语言接口描述。字段名称、值域、示例越清晰,模型选错工具的概率就越低。实践下来,一份好的Reach Spec,比把一堆函数签名丢给模型的效果好得多。
2.2 三类触达的归类:工具、知识、消息
我把所有触达动作归成了三大类,每一类在Reach Spec里用type字段区分,处理的侧重点也不同:
| 类型 | 说明 | 典型场景 | 关键关注点 |
|---|---|---|---|
| tool | 调用工具/函数/API | 创建工单、查询库存、发企业微信消息 | 参数校验、幂等性、超时 |
| knowledge | 检索知识库/数据库/文档 | 查订单状态、检索内部Wiki、查价格策略 | 相关性、权限过滤、来源引用 |
| reach | Agent间协作 | 委托代码评审、请求法务判断、同步任务状态 | 会话上下文传递、去重、死锁防护 |
为什么要做这个区分?因为三类触达的“成功标准”不一样。tool类型看重的是执行结果准不准,调用后系统状态有没有被正确改变;knowledge类型看重的是检索到的内容对不对、有没有权限、来源能不能溯源;reach类型看重的是消息有没有被目标Agent接收、响应是否超时。如果不做区分,把它们全部当成“调一个函数”,你就没法针对性地做超时策略、结果评估和权限管控。
2.3 Resolver:模型怎么知道该用哪个Endpoint
有了Reach Spec,下一步就是让模型在决策时能“选中”正确的那个。这里我踩过一个大坑:一开始我把所有Reach Spec全部塞进系统提示词里,让模型自己挑。结果项目跑了两个月,注册了30多个Endpoint的时候,prompt直接膨胀到接近1万token,延迟暴增一倍,模型的选择准确率反而下降了——信息太多,它在几十个工具描述里很容易挑花眼。
后来我把“让模型选”改成了“先检索再投喂”。在系统里加了一个组件叫Reach Resolver,它的职责是:根据用户当前的问题和对话上下文,提前筛选出一个“候选Endpoint集合”,只把这个集合的Reach Spec喂给模型。
Resolver的筛选我用了三层策略:
- 关键词规则兜底:问题里出现“订单”这个词,就优先匹配描述里带“订单”的Endpoint池子。
- embedding语义检索:把Reach Spec的description字段用向量表示,和当前用户问题做相似度匹配,Top K召回。
- 小模型分类器兜底:针对模糊场景,用一个很轻量的分类模型把问题归到某个业务域,再取该域内的Endpoint。
实测下来,这三层组合的效果最好。规则层保证了准确性,语义层保证了覆盖面,分类器层处理了那些跨域的复杂问题。最终每次Agent做触达决策时,候选Endpoint数量稳定在3到5个,模型的选择准确率从不足70%提升到92%以上。
2.4 权限边界:触达不等于放开
我最担心的一件事,是模型在触达时的“越权”。LLM有一个特性:它倾向于“帮忙”,它会把用户的话当成最高指令去执行。如果用户在对话里说“把那个订单删了”,模型如果找到了一个疑似“删除订单”的触达端点,它很可能会毫不犹豫地去调用,哪怕实际业务规则要求“删除订单必须走审批”。
所以在Agent-Reach里,权限是硬编码在Reach Spec里的,模型没有“临时授权”的能力。每次触达会经过一个Reach Gate(权限网关),它做三件事:
- 校验当前会话的scope是否覆盖该Reach Spec要求的auth.scope。
- 对input_schema里声明了enum、pattern、range的字段做白名单校验,超出值域直接拒绝,不把非法请求发到业务系统。
- 记录完整审计日志:谁在什么时间通过哪个Agent发起了哪次触达,传了什么参数,返回了什么结果。
这套权限模型一开始被团队吐槽“太啰嗦”,但后来有一次模型在测试环境里生成了一笔异常金额的订单请求,被Reach Gate拦下来了,团队才真正认可:模型是会犯错的,而且它犯错的方式非常“自然”,你还真不能拿它当正式员工去信任。
3. Agent-Reach落地实操:从零到跑通一次完整触达
3.1 环境准备与最小化部署
先说环境。Agent-Reach依赖并不多,核心就三件:一个放Reach Spec的注册中心(我直接用了SQLite加内存表,没上etcd)、一个执行触达逻辑的Python进程(用的FastAPI,理由就一个词:异步顺手)、以及一个和LLM对接的桥接层(兼容OpenAI格式的接口都行,我实测对自定义Agent框架同样适用)。
安装过程非常简单:
python -m venv .venv source .venv/bin/activate pip install "agent-reach>=0.2.0" fastapi uvicorn sqlalchemy uvicorn agent_reach.hub:app --port 8000跑起来之后,默认会暴露两个HTTP接口:一个是POST /reach/dispatch,供Agent执行循环调用;另一个是POST /reach/register,用来注册新的Reach Spec。我开发调试时习惯本地起这个服务,连一个debug模式的LLM,这样每次改完Reach Spec能看到完整的触达链路日志。
3.2 第一步:注册一个“查订单状态”的触达点
我们用刚才那个查询订单状态的例子,走一遍注册流程。业务侧的查询逻辑可以长这样:
# order_repo.py from agent_reach import register @register( id="reach_kb_order_status", type="knowledge", description="根据订单ID查询订单的当前状态:待支付/已支付/已发货/已完成/已取消", input_schema={ "order_id": {"type": "string", "required": True, "desc": "14位订单编号"}, }, auth={"scope": "order_service.read", "owner": "fulfillment-team"}, timeout_ms=3000, ) def fetch_order_status(order_id: str) -> dict: """内部函数,从订单数据库取状态""" row = db.query("select status, updated_at from orders where order_id=?", order_id) if row is None: return {"found": False, "message": "订单不存在"} return {"found": True, "status": row["status"], "updated_at": row["updated_at"]}这里有几个值得注意的设计细节:
id是全局唯一的,格式我统一用reach_tool_、reach_kb_、reach_agent_前缀,方便Resolver做类型过滤。description老老实实把这个触达能做什么、不能做什么写清楚。我见过有人写“一个订单函数”,模型看了根本不知道什么场景该选它。timeout_ms不是随便填的。知识库查询如果超过3秒,说明数据源有问题,Agent不如直接告诉用户“暂时查不到”,也好过等一个慢查询拖垮整个对话。
注册完成后,可以通过另一个脚本验证:
curl -X POST http://localhost:8000/reach/dispatch \ -H "Content-Type: application/json" \ -d '{"reach_id": "reach_kb_order_status", "params": {"order_id": "20250101000123"}}'标准输出会返回{"status": "ok", "data": {"found": true, "status": "已发货"}}。走到这一步,你的Agent-Reach服务已经被业务侧打通了。
3.3 第二步:改造Agent执行循环
真正让Agent能动起来,核心在于把Reach调用嵌进它的思考-行动循环里。我用到的执行循环结构大概是这样的(伪代码):
def run_agent(user_input): pico_state = {"messages": [{"role": "user", "content": user_input}]} for step in range(MAX_STEPS): # 1. Resolver 筛选候选 Reach Spec candidates = reach_resolver.filter(user_input, pico_state) # 2. 组装上下文:把候选 Spec 注入系统提示词 prompt = build_prompt_with_reaches(candidates) # 3. 模型决策:返回文本回复 或 一个 Reach 调用请求 response = llm.chat({**pico_state, "messages": [system(prompt), *pico_state["messages"]]}) # 4. 如果模型决定触达 if response.tool_calls: for call in response.tool_calls: result = reach_gate.dispatch(call.reach_id, call.params) pico_state["messages"].append({"role": "tool", "content": result}) continue # 继续下一轮思考 # 5. 如果模型给出最终回复 return response.content这个循环看起来很简单,但有一个关键点必须强调:每次模型触达之后,返回结果必须作为新的“工具消息”追加进上下文,让模型看到触达结果,再基于结果生成最终回复。我见过不少团队在跑循环时把工具返回结果丢掉了,模型等于“空手回复”,触达动作做了个寂寞。
为了让模型能正确产出触达请求,我在它的系统提示词里固定加了一段说明:
当你需要查询实时信息、调用内部工具、或者向其他Agent发起协作时, 请输出一个Reach调用请求,格式如下: {"reach_call": {"reach_id": "候选Id", "params": {}}} 只能使用Reach候选列表里给出的reach_id,不要编造不存在的id。加一段Few-shot示例,模型基本一次就能学会。
3.4 第三步:让Agent之间通过Reach互相触达
单Agent跑通之后,我开始做多Agent协作,这也是Agent-Reach名字里“Reach”最有意思的部分。我在Hub上注册了三个Agent:一个负责需求理解,一个负责技术方案设计,一个负责代码审查。它们之间通过type: reach类型的Reach点进行消息传递。
实际注册一个Agent协作端点,和其他类型没有本质区别:
id: reach_agent_code_review type: reach name: 发起代码审查 description: 将待审查的代码片段或PR链接发送给代码审查Agent,返回审查意见列表 input_schema: review_id: type: string required: true code_ref: type: string required: true desc: Git仓库文件路径或PR URL priority: type: enum values: [low, normal, high] default: normal handler_type: python_function handler: agent_coordinator.request_review这个agent_coordinator.request_review内部做的事情,是把任务内容塞进审查Agent自己的消息队列,然后异步等待它的返回结果。为了不让发起方长时间干等,我把这个协作用的是一次2分钟的“带超时轮询”,超时返回“审查排队中,稍后可查”。这个设计很好地避免了一个Agent长时间阻塞整个执行循环的尴尬。
3.5 调试工具箱:三条必用的排查命令
实际开发里,不是写了代码就能一次跑通。我给自己配了三个调试利器,建议你也照着配一份:
- 查看当前所有已注册的Reach Spec清单:
GET /reach/list,输出JSON格式,方便检查有没有重复id、description写得是否模糊。 - 模拟一次触达但跳过模型:
POST /reach/dispatch?dry_run=true,直接把参数喂给某个Endpoint,看业务侧返回是否正常,这样可以快速判断问题出在模型决策还是业务系统。 - 看最近N条触达审计日志:
GET /reach/audit?limit=50,每一条触达的参数、结果、耗时、发起会话都记录在案,排查线上问题全靠它。
有一次线上故障排查,我花了两个小时定位到是某个旧端点的超时配置写得太短,业务侧逻辑处理到一半就被Agent-Reach主动断开了。后来全靠审计日志里的timeout_ms列一眼看到了问题。有审计和没审计,Agent项目的线上运维是两种体验。
4. 实测中遇到的四个坑:现象、原因、解决办法
4.1 上下文膨胀:Reach Spec清单越塞越多,模型选不准
最初版本我会把所有已注册的Reach Spec全部投喂给模型,让它在候选里做选择。项目跑到第3个月,Endpoint数量到了27个,每天早上10点高峰期的对话延迟明显比两周前高。我用消融法逐步排查:一开始以为是对话历史变长了,压缩历史后延迟还是高;然后把系统提示词里的Reach Spec清单截断了一半,延迟立刻降了40%。再细看日志,模型在选择Endpoint时,面对20多个候选经常犹豫,有时还会输出一个不存在的id。
根因是信息过载。模型面对一堆相似描述的工具,选择难度指数级上升。解决方案就是我前面提到的“先检索再投喂”三层Resolver:上线后,每次实际投喂的候选不超过5个,模型选择准确率从69%升到了92%,prompt体积缩到原来的三分之一。
4.2 慢触达拖垮对话:Agent等结果等到“失忆”
有个触达是调用外部CRM系统的API,查询客户历史沟通记录。CRM那边时不时响应超过5秒。问题来了:Agent发出触达请求后,整个执行循环会同步等待;等待期间上下文里的临时信息不会刷新,等结果回来时模型已经“忘了”最初用户说的是什么,回复质量直线下滑,甚至有两次模型在等待期间自己脑补了一个结果,完全没看真实返回值。
我做的修复是给触达分了三档SLA:
| 级别 | 典型场景 | 超时上限 | 等待策略 |
|---|---|---|---|
| 快速 | 本地函数、缓存查询 | 300ms | 同步等待 |
| 标准 | 内部接口、知识检索 | 3s | 同步等待 |
| 慢速 | 外部CRM、跨Agent协作 | 10s以上 | 异步回调,先回进度消息 |
对于慢速触达,我改成了“先给用户一个中间反馈,例如‘正在为您查询CRM记录,预计需要15秒’,然后触达完成后把结果作为一条新消息追加进会话,让模型再基于结果生成最终回复”。这样用户的体验是“Agent先应答、后出结果”,而不是“整个对话卡死十秒”。
4.3 模型“发明”了一个不存在的触达点
这是个很好笑也很吓人的坑。有一次模型在回答用户“帮我查一下供应商到货记录”时,输出了一份Reach调用请求,reach_id是reach_kb_supplier_arrival,但它根本不存在。我的代码直接用这个id去查注册表,返回了空,结果模型居然还在那里一本正经地编了一份“供应商到货记录”出来。
排查链路是这样的:我先在审计日志里输入输出比对,发现调用失败但模型没有感知到失败,依然生成了答复。进一步看系统提示词,发现里面只有一句“使用候选列表中的reach_id,不要编造不存在的id”,模型在候选列表语义不够清晰时,会“合理想象”一个名字。
修复做了两件事。第一,在Reach Gate里强制加入了“only_dispatch_known=1”的配置,遇到未知id直接中断流程并明确返回“该触达点不存在,请从候选中选择”,让模型看到这个错误后自纠。第二,给每个候选Reach Spec加上了更加明确的示例和“不能做什么”的负面说明,减少模型“自由发挥”的空间。从那以后,编造id的情况基本清零。
4.4 Agent互相等待:协作型死锁
做多Agent协作的时候,最复杂的一个问题是死锁。有一次线上跑着跑着,一个需求分析Agent在等代码审查Agent的审查结果,而代码审查Agent又在等需求分析Agent提供补充材料,两边互相等待,各自执行循环都卡在超时边缘,请求越积越多,整个Hub的响应都变慢了。
我用两条机制解决了这个问题:
- 给每个
type: reach的协作端点加了max_hops限制,即一次协作触达最多允许嵌套转发3次,超过直接返回“协作链路过深,请拆分子任务”。 - 给协作请求加了一个全局唯一的
request_chain_id,发现同一链路里出现循环等待时,立即熔断,返回“检测到循环协作,已终止”。
这个机制上线后,再没出现过Agent互相等到天荒地老的状况。多Agent协作看起来自由,其实比单Agent更需要纪律。Agent-Reach的协作端点就是给这种“有纪律的自由”兜底。
5. Agent-Reach的操作心得:哪些设计值得坚持,哪些地方还该优化
5.1 这个方案解决了什么问题
整套Agent-Reach跑下来大半年,我最满意的不是某个Agent的回答准确率提升了多少,而是它把“Agent触达”这件事从“每个Agent自己随便发挥”变成了“一套可注册、可筛选、可审计的规范动作”。团队里新来的同学接手Agent项目,不再需要翻遍代码找某个功能写在哪,而是打开/reach/list,看Reach Spec清单就知道系统里有哪些触达能力、每个能力需要什么参数、有什么权限要求。
这套思路放大了说,其实就是把Agent当成一个有“规范API”的员工在管理,而不是当成一个“你说什么它猜什么”的应急工具。模型负责理解和生成,Agent-Reach负责把理解和生成落到真实世界的动作上。两者各管一段,出了问题也好定位。
5.2 目前妥协的地方与值得尝试的优化
说得务实一点,Agent-Reach现在还有几个不完美的地方。
一是Resolver的语义检索依赖embedding模型的质量,遇到专业术语很多的场景,召回结果偶尔会跑偏。目前我是靠规则层兜底纠正的,但如果团队有预算,用一个针对垂直领域微调的embedding模型效果会更好。
二是触达结果没有做“语义级缓存”。同一个用户隔了一天问同样的订单状态,Reach还是会打一次数据库。如果做一个按“参数+时间窗口”的缓存层,很多查询类触达的响应速度还能再上一个台阶。
三是审计日志目前只记录到了“参数和结果”级别,没有记录“这次触达前后模型上下文的差异”,也就是说我看不到“模型因为这次触达,回复质量到底变好了多少”。下一篇技术分享我想做的方向,就是给每条触达算一个“上下文增益”指标,用来自动评估每个Endpoint的真实价值,把那些“模型经常选错、触达后回复质量没有提升”的冗余端点清理掉。
5.3 给准备做同类体系的团队三个建议
如果你们团队也在搭自己的Agent触达层,无论叫不叫Agent-Reach,有三点我强烈建议从第一天就坚持:
- 从第一个触达点开始就带权限和审计,别想着后补。模型一旦习惯了“什么事都能直接调”,再收权限会非常痛苦。
- Reach Spec的description请认真写,写清楚“能做什么、不能做什么、参数怎么填”,在这里花十分钟,能省下后面几周的调参时间。
- 一切触达的请求和返回值都走结构化JSON,不要图省事直接用自然语言文本回传。结构化数据模型能直接消化,自然语言回传还要额外做一层“理解”,白白增加延迟和出错概率。
我自己的感受是:Agent项目到了中后期,拼的不是谁的模型更强,而是谁的Agent更能“够得着”。你给它触达能力之前,它只是一个聪明的文本生成器;你给它一套收放有度的触达层之后,它才真正像一个能干活、能担责、能被管理的数字员工。Agent-Reach这个名字,起的其实就是这个意思。