Agent-Reach:为AI智能体构建稳定可控的触达层
最近在折腾一个叫 Agent-Reach 的小项目。先一句话说清楚它是什么它是一层专门给 AI 智能体用的“触达层”负责把大模型的意图翻译成真实能执行的工具调用再把工具调用结果回填给模型让模型真正意义上“办成事”。为什么要做这个因为这两年我用了不少 Agent 类应用一个非常典型的尴尬局面是模型推理能力很强但模型“手太短”。你让它查天气、订会议室、查订单它也能说出一套流程但实际动作一样都没发生。Agent-Reach 想解决的就是这个“最后一公里”问题——能不能让智能体稳定、安全、可控地去触达外部世界比如数据库、内部 API、第三方服务。这篇文章既是给这个项目写的实操记录也是给想从零搭 Agent 触达链路的朋友一份参考。1. 项目定位一个 AI 智能体的“触达层”到底在解决什么1.1 从“会聊天”到“能办事”模型要的不是聪明是抓手我见过不少团队做大模型应用第一版 demo 永远惊艳第二版就卡住。为什么因为第一版做的是“对话”第二版要做“行动”。对话只需要模型理解用户行动却需要模型触达系统。拿最简单的事举例。用户说“帮我查一下上海明天会不会下雨”如果你只接一个通用大模型它大概率会直接给你一段文本回答告诉你“预计上海明天有雨”。但这句话是模型编出来的不是从天气接口拿到的真实数据。这就是触达缺失模型在“凭记忆回答”而不是“调用工具回答”。Agent-Reach 的思路是把这两件事彻底分开。模型负责“决策”触达层负责“执行”。模型说“用户想问天气我推测需要调用 get_weather 工具参数是 city上海datetomorrow”然后触达层去查真实接口、拿到结构化数据、回填给模型最后由模型组织成自然语言回复。整个过程里模型不需要知道天气接口的 URL、不需要管鉴权、不需要解析返回 JSON它只负责“选工具”和“看结果”。这个拆分的价值在复杂场景里会更明显。你要做一个企业内部助理可能涉及查考勤、订会议室、提请假、查项目进度十几个系统各有各的接口协议。如果让模型直接裸调接口Prompt 会膨胀到不可维护token 成本高到你哭安全性更是无从谈起。有了触达层所有系统细节都被封装成统一的工具描述模型只需要在一个能力列表里做选择。所以 Agent-Reach 的定位不是“又一个大模型应用”而是一层基础设施。它解决的核心问题是让智能体的能力边界变得明确、可度量、可扩展——换句话说把“模型能干什么”变成一张可以被审计和控制的清单而不是一句模糊的“它能帮你做很多事”。1.2 四个设计目标可控、可测、可扩展、可回溯动工之前我先给 Agent-Reach 定了四条硬性要求后面所有设计决策都围绕这四件事展开。分享出来因为我觉得任何做 Agent 落地的人都会遇到同样的问题模型结果不稳定你怎么敢让它接触生产系统第一条是可控。Agent 调用工具不能是“自由发挥”。哪些工具能调、哪些不能调、哪些操作必须经过人工确认这些必须在触达层强制约束而不是靠模型自觉。想一想如果模型在错误判断下把“删除订单”接口调用了一句“对不起我说错了”根本弥补不了。所以可控不是可选项是准入门槛。第二条是可测。模型在不同上下文里会不会选错工具、参数提取对不对这些不能靠肉眼观察要有离线评测集。我后来的实操经验是给 Agent 做一套工具召回测试集就像给搜索引擎做相关度评测一样每个样本包含“用户原始问题 期望调用的工具 期望的参数字段”。每次改 Prompt、换模型、调阈值先跑一遍测试集分数涨了才敢上。第三条是可扩展。每接入一个新系统成本应该被压到最低。理想情况下只需要写一个工具类文件、填好描述和输入输出 Schema然后注册进去Agent 第二天就能用。如果接一个工具要改主流程代码说明触达层的抽象设计是失败的。第四条是可回溯。任何一次工具调用都要能回答五个问题哪个用户、哪一轮对话、模型为什么选这个工具、传了什么参数、外部系统返回了什么。这既是排查问题的线索也是责任追溯的依据。我用结构化日志和 trace_id 把整条链路串起来后面排查问题省了太多事。这四项目标也直接决定了 Agent-Reach 的架构形态工具注册中心负责可扩展权限校验中间件负责可控路由评分负责可测因为评分逻辑可以做单测全链路日志服务负责可回溯。听起来复杂实际拆开做并不难后面我会一步步讲。2. Reach 层架构拆解智能体如何触达外部世界2.1 能力地图工具描述做得越细召回越准整个触达层的入口是一个“工具注册中心”我把 Agent 能调用的所有能力都登记在里面。每个工具包含四部分工具名称、功能描述、输入参数 Schema、执行函数。其中最关键、也最容易被低估的是“功能描述”。一开始我以为工具描述写个三五句就够了后来发现模型选错工具绝大多数时候不是模型笨而是描述写得太模糊。举个反例我注册了一个get_user_info工具描述写“获取用户信息”。结果用户问“我这个月余额还剩多少”模型死活不调用这个工具因为它不知道“余额也属于用户信息的一种”。后来我把描述改成获取指定用户的账户信息、余额、会员等级和消费记录。 适合回答“我还有多少钱”“我的会员是什么等级”“我最近买了什么”等问题。同一个工具召回率从 60% 直接跳到 92%。所以工具描述的本质是“给模型看的检索文档”它决定了模型在能力地图里能不能命中正确入口。输入参数的描述也一样重要。每个字段除了类型和必填标记还要写清楚枚举值、格式、示例。比如日期字段写上“格式为 YYYY-MM-DD例如 2025-06-01”模型按示例生成参数的错误率会低很多。我还给每个参数加了别名英文参数名city后面注明“支持中文城市名如北京、上海”。别嫌这些信息冗余模型对模糊字段的猜测能力比你想的差得多。工具注册的数据结构我推荐用 JSON Schema它天然适合描述结构化参数各大模型厂商的 function calling 也都兼容这种格式。下面是一个最小示例{ name: get_weather, description: 查询指定城市在指定日期的天气情况适合回答天气预报、穿衣建议、出行计划相关问题时使用, parameters: { type: object, properties: { city: { type: string, description: 城市中文名称例如北京、上海、广州 }, date: { type: string, description: 查询日期格式 YYYY-MM-DD默认今天 } }, required: [city] } }这套 JSON 会拼到系统 Prompt 里随每次请求发给模型。注意工具描述是会真实占用 token 的注册几百个工具时你的 Prompt 会非常庞大。这个矛盾后面在路由部分专门讲。2.2 意图路由让 Agent 在几十个工具里选对那一个当你只有五个工具时把所有工具描述直接塞给大模型让它在里面选没问题。但当工具数量到几十个描述文本加起来超过上下文窗口时这个朴素方案就失效了。Agent-Reach 在这里加了一个“路由层”用来缩小工具的候选范围。这个路由层的思路和搜索引擎很像。先离线把每个工具的描述、参数名、关键词做成索引用户问题进来后先用 BM25 和向量检索各跑一遍找出 TOP 5 到 TOP 10 的候选工具再把候选工具的完整描述拼进 Prompt 交给大模型做最终决策。换句话说大模型每一次只需要“二选一”或“五选一”而不是“一百选一”。为什么不能只用向量检索让机器直接决定用哪个工具我试过效果不稳定。向量检索对短查询、同义改写很敏感比如用户说“我想看下这个月账单”向量检索可能召回账单工具但召回不到用户查询工具因为“账单”和“用户信息”的语义距离并不近。而大模型虽然擅长理解模糊意图但面对上百个工具描述时会注意力涣散、选错。这两个方案结合之后就很稳。检索负责“粗筛”保证召回不丢大模型负责“精排”保证选择准确。我给路由层加了评分日志每次请求都能看到最终选了哪个工具、候选池里有哪些、落选的原因是什么。这为离线评测积累了数据。这里还有一个不那么常见但很重要的点路由层要加“拒绝机制”。当用户问题明显不在任何工具能力范围内时不要硬选一个工具打发用户而是进入默认处理流程明确告诉用户“这类问题我暂时处理不了”。很多 Agent 的问题不是“选错工具”而是“硬要选一个工具去套”。例如用户问“南京到北京的高铁有几趟”如果你只接了机票查询工具模型可能会去调用机票工具返回一个看似相关但完全错误的结果。路由层需要设置一个置信度阈值低于阈值就拒绝触发外部调用。2.3 参数归一化把“后天下午”变成接口认得的字段路由决定调用哪个工具之后紧接着的问题是参数。模型的原始输出是自然语言比如“后天下午”而接口要的是标准时间戳。参数归一化就是把这层“方言”翻译成“普通话”。这块的挑战在于模型经常输出格式不完整的参数。要么日期是相对说法需要计算要么枚举值说法不规范要么干脆漏了必填字段。Agent-Reach 的做法是工具执行前先做一次参数校验和清洗不通过就直接返回“参数错误”而不把脏数据发给外部系统。校验分三层。第一层是类型检查city必须是字符串price必须是数字类型不对直接拒绝。第二层是格式清洗日期、电话、邮箱这类有明确格式的字段用标准化函数处理。比如date字段我会把“明天”“后天”“下周一”这些相对日期先换算成绝对日期再校验格式。第三层是枚举值映射比如“男/女/未知”映射成M/F/U工具内部统一用枚举码外部系统不用关心用户怎么说。下面是我在项目里写的一个日期归一化片段用来处理“明天”“后天”“本周五”这类相对时间表达from datetime import date, timedelta def normalize_date(text: str) - str: text text.strip() today date.today() if text in (今天, 今日): return today.isoformat() if text in (明天, 明日): return (today timedelta(days1)).isoformat() if text in (后天): return (today timedelta(days2)).isoformat() # 这里可以继续扩展“下周一”“月底”等规则 return text # 已经是 YYYY-MM-DD 则原样返回单看这段代码很不起眼但它规避了大量线上事故。我见过模型把“后天”直接传成后天两个汉字给日期接口也见过把不确定的日期硬算成当前时间戳。参数归一化就是触达层的“守门员”宁可多写几个规则也不能让脏参数流向真实系统。3. 从零搭一个可跑的 Agent-Reach 链路3.1 环境准备与项目骨架我实际的技术栈是 Python 3.11 FastAPI OpenAI 兼容的 function calling 协议。之所以选这套组合是因为它在开源社区和商业模型之间兼容性最好底层大模型可以随时切换工具协议不用改。项目骨架长这样agent-reach/ ├── core/ │ ├── __init__.py │ ├── registry.py # 工具注册中心 │ ├── router.py # 路由与召回 │ ├── executor.py # 工具执行与结果回填 │ └── audit.py # 日志与权限校验 ├── tools/ │ ├── __init__.py │ ├── weather.py │ ├── calendar.py │ └── user_info.py ├── agent.py # 主循环入口 └── config.yaml # 模型、工具、权限配置依赖安装只需要几个库pip install fastapi uvicorn openai pydantic pyyaml我没有引入重量级框架因为触达层的核心逻辑不是很复杂自己控制主循环反而更透明。等你跑通以后再根据需求加 Redis 缓存、消息队列也不迟。3.2 定义工具协议业务逻辑和模型解耦在 Agent-Reach 里每个工具都是独立的一个类对外暴露统一的name、description、input_schema和execute方法。业务逻辑全部封装在execute内部外部系统细节不需要暴露给模型。下面是一个最小工具类。为了演示我写一个假的日历查询工具from pydantic import BaseModel, Field class CalendarInput(BaseModel): date: str Field(description查询日期格式 YYYY-MM-DD) user_id: str Field(description用户ID格式为 U 开头加数字) class QueryCalendar: name query_calendar description 查询指定用户在某一天的日程安排适合回答“我今天有什么会”“明天什么安排”等问题 input_schema CalendarInput.model_json_schema() async def execute(self, date: str, user_id: str) - str: # 这里实际会请求内部日历系统 # 返回值是给模型看的结构化文本越简洁越好 return f{date} 的日程10:00 产品评审会14:30 客户回访18:00 提交周报这里有一个细节值得注意工具返回值不是原始 JSON而是一段经过整理的结构化文本。因为模型是拿这段文字去生成最终回复的如果返回一坨嵌套 JSON模型容易读乱token 也浪费。我一般会把层级深的 JSON 压平成key: value形式的短句让模型一眼看懂。注册工具也很简单在registry.py里维护一个字典from tools.calendar import QueryCalendar from tools.weather import WeatherTool TOOL_REGISTRY { QueryCalendar.name: QueryCalendar, WeatherTool.name: WeatherTool, }每个新工具接进来只需要写类 注册两件事。这就是我前面说的“可扩展”落地方式入口统一新增工具不动主流程。3.3 主循环决策、执行、回填、再决策Agent 的主循环是整个触达层的发动机。它遵循标准的 ReAct 模式模型先思考要调什么工具、生成工具调用请求触达层执行工具、把结果回填模型再基于结果继续思考直到不再需要调用工具为止。我用一个简化版本说明白async def run_agent(user_input: str, trace_id: str): messages [{role: user, content: user_input}] for step in range(MAX_STEPS): resp await llm.chat( messagesmessages, tools[tool_schema for tool in TOOL_REGISTRY.values()], ) # 模型不再请求调用工具说明可以生成最终回复 if not resp.tool_calls: return resp.content # 先追加模型那条带工具调用的消息 messages.append(resp.message) # 逐个执行工具 for call in resp.tool_calls: tool TOOL_REGISTRY[call.function.name] result await tool.execute(**call.function.arguments) # 关键一步按 tool_call_id 回填结果 messages.append({ role: tool, tool_call_id: call.id, content: result, }) return f已执行 {MAX_STEPS} 轮仍未完成请重试或补充信息几个需要特别说明的坑第一tool_call_id必须原样返回。这是 OpenAI 协议里模型关联“哪个工具调用对应哪个结果”的凭证如果你回填结果时丢了 id模型就分不清结果属于哪次调用。第二必须设置MAX_STEPS上限。没有上限的 Agent 可能会陷入死循环模型反复调用同一个工具每次结果一样它还是想再调一次。我一开始图省事没限制结果有个测试用例跑了 40 多轮token 烧穿。后来设成 5 轮超时就明确告诉用户“暂时无法完成”体验反而更可控。第三工具执行异常不要直接抛出。触达层应该捕获异常把错误信息作为工具结果回填给模型让它自己决定下一步怎么处理。比如某个接口超时模型看到“查询日历服务超时”后可能会换一种问法或向用户解释这比直接报错友好得多。3.4 权限边界与可观测性别让 AI 乱碰系统如果说路由是触达层的“大脑”权限就是触达层的“安全带”。Agent 的运行结果天然不可预测所以能做什么、不能做什么必须在链路层锁死。我在 Agent-Reach 里给每个工具打上了权限标签分为只读、写入、危险三类并在执行前做校验PERMISSION_POLICY { query_calendar: read, get_weather: read, create_meeting: write, delete_order: dangerous, } async def check_permission(tool_name: str, user_role: str) - bool: level PERMISSION_POLICY[tool_name] if level dangerous and user_role ! admin: return False if level write and user_role guest: return False return True对于dangerous级别的操作我还会再加一道二次确认机制先调用工具返回一个“预执行结果”把结果发给用户确认用户点了同意才真正执行写操作。这一步在真实业务里极重要因为模型有可能在错误的上下文里决定删除一条数据人工确认是最后止损线。可观测性方面我给每个请求生成了trace_id从进入触达层开始一路透传到工具执行结束。结构化日志长这样eventrouter, trace_id7f3a2c, tools_candidates[query_calendar,get_weather,create_meeting], selectedquery_calendar eventtool_exec, trace_id7f3a2c, toolquery_calendar, params{date:2025-06-10,user_id:U123}, latency_ms180, statussuccess eventreply, trace_id7f3a2c, steps2, tokens1120, finish_reasonstop有了这套日志出问题时你能精确复现现场是哪一轮、哪个工具、传了什么参数、返回了什么。我后来处理线上问题90% 都是靠 trace_id 倒查日志定位的而不是靠猜。4. 我把常见坑踩了一遍整理出一份排查实录4.1 召回不准八成是描述写得“太像人话”我最开始写工具描述走的是“产品文档风”字段、类型、取值说明一应俱全但缺少用户视角。结果模型该调用的时候不调用我一度怀疑是模型能力不行后来把描述改成“带常见问题例句”的风格后效果好得惊人。具体来说一个工具描述里至少要有三层信息它能做什么、什么场景该用它、什么场景千万别用它。第三点尤其重要。比如query_order工具我加了一句“仅用于查询订单状态不支持修改、退货等操作”模型就不会在用户要求退款时误调它。下面是我优化前后的对比你可以直接照抄这个模式维度差描述好描述能力说明获取订单信息查询订单状态、物流信息、金额适合回答“我的快递到哪了”“这单多少钱”场景边界无不处理退款、不处理修改地址这些操作请转人工或调用售后工具参数示例customerIdcustomerId用户ID例如 C10086这背后的原理很简单大模型的工具选择依赖语义匹配描述越接近“用户会问的话”匹配越准。我建议你每个工具都写 3 到 5 个用户问题样例直接贴在描述里效果立竿见影。4.2 上下文太长Agent 开始编造参数当 Agent 对话轮数变多消息列表越来越长模型会出现一个隐蔽的毛病它不再从工具返回结果里提取信息而是凭上下文猜测参数值。比如用户上一轮报了订单号这一轮问“那这个订单多少钱”模型可能直接把上一轮订单一字不差地传给了订单查询工具——这是对的。但如果用户问“另一个订单呢”模型可能编一个格式相似但根本不存在的订单号传进去。问题根源是上下文太长导致注意力分散模型抓错了参照物。我的解法是两层第一限制送入模型的对话轮数只保留最近 N 轮更早的关键信息用摘要代替第二工具参数校验增加“存在性检查”比如订单号必须能在系统里查到查不到就不执行下一步直接告诉模型“参数不存在”。另外模型在长时间对话里可能会产生“幻觉触达”——它认为某次工具调用发生过但实际上没有。排查这类问题时最有效的手段就是我前面提的 trace 日志逐轮检查tool_call_id对应的真实执行记录一查便知是模型编造还是链路漏了。4.3 权限失控危险操作一定要二次确认我在测试阶段遇到过最惊险的一次模拟用户连续追问后模型判断用户想“清空购物车”于是调用了批量删除接口。虽然当时是测试数据但这个行为在真实环境里就是事故起点。后来我在触达层加了两道硬规则。第一任何批量删除、退款、改价、发送消息等高风险操作必须带confirm_token参数这个 token 只能通过前置确认步骤获取不存在于用户的原始输入里。第二同一用户在单位时间内的高风险操作数量设上限超过就强制走人工审批。这两道规则直接把“模型误操作”变成“模型提议、人工批准”。我还建议给每个工具的execute方法内部加“审计留痕”记录操作前后的业务字段快照。万一出了问题你可以精确看到执行前是什么样、执行后变成什么样恢复数据也有了依据。4.4 串行调用太慢异步并发才是正确姿势模型的tool_calls有时候会一次返回多个工具调用比如用户问“帮我查一下北京和上海的天气顺便看看明晚有没有电影”一个回合里会有三个工具请求。我最初的实现是for循环逐个执行实测下来平均响应时间超过 8 秒体验极差。后来改成asyncio.gather并发执行同一批次里的工具调用响应时间缩短到 3 秒以内。不过这引出一个新问题如果多个工具之间存在先后依赖比如先查用户 ID 再查订单就不能简单并发。我的处理是让模型自己判断依赖关系有依赖的拆成多轮无依赖的并发执行。results await asyncio.gather( *[execute_tool(call) for call in calls], return_exceptionsTrue, )这里要小心一个细节return_exceptionsTrue不能省否则一个工具报错会导致整批调用全部取消。单个工具失败时我会把错误信息封装成结果回填给模型让它决定是重试还是走兜底而不是让整个 Agent 崩掉。5. 一些阶段性的个人体会与可扩展方向5.1 我从 Agent-Reach 里学到的三个认知误区第一个误区是“模型越强越不需要触达层”。实际上模型越强它就越会“自信地编造”工具执行结果。小模型能力弱至少会老老实实说自己查不到强模型反而会一本正经地告诉你“订单已修改成功”实际上什么也没发生。所以触达层的存在不是为了帮模型而是为了替模型“验明正身”。第二个误区是“工具描述越专业越好”。技术背景的人写描述时容易用内部术语fetchDataByUId、queryOrderListV2。大模型虽然读得懂但用户不会用这些词提问语义匹配依然会失败。工具描述是给模型看的而模型是站在用户视角做匹配的所以描述必须用“用户的话”。第三个误区是“权限控制是安全团队的事”。做个人项目时没有安全团队兜底你只能自己做。我在触达层把权限、审计、二次确认拆成了独立组件虽然前期多写了不少代码但后面接入敏感系统时完全不用重构这节省的时间远超开发成本。5.2 下一步还能怎么扩展Agent-Reach 目前的形态已经能支撑“用户发起请求 → 模型决策 → 触达工具 → 回填结果”这个闭环。我下一步计划加两个方向。第一个是“事件驱动触达”。现在触达只能由用户消息触发属于被动模式。实际场景里很多触达应该是主动的每天早上给用户推当日日程、库存低于阈值自动提醒补货、订单长时间未发货自动升级工单。这需要触达层增加定时任务和事件订阅机制让 Agent 不仅能“接单”还能“主动找活干”。第二个是“多智能体共享 Reach 层”。多个专业 Agent 共用同一套工具注册中心和权限体系每个 Agent 只暴露自己领域内的能力子集。这样工具可以统一治理、统一审计Agent 各自的业务逻辑保持独立。我倾向于把触达层做成独立服务通过内部 API 对外提供工具调用能力而不是把工具注册表硬编码进每个 Agent。最后分享一个我最近养成的习惯每次改完工具描述或新增路由规则我都会随手写一个回归测试用例记录用户问题、期望工具、期望参数。一个月下来这个测试集就是判断“这个改动到底有没有变好”的客观尺子。Agent-Reach 这种项目最怕的就是“全靠感觉调”有了测试集你至少知道每次调整是在变好还是在变坏。