做了大半年 AI Agent 项目,我最大的感受是:大模型从来都不缺"脑子",缺的是"手脚"。你让它写一首诗、总结一份文档、解释一段代码,它都能干得不错,可一旦涉及"帮我把这份数据存进数据库""帮我查一下这个接口的通话状态""根据这个表格自动生成下周的排班表"这类需要真实操作外部系统的活儿,它就卡住了——因为它只会输出文本。那文本怎么变成一次真实的 API 调用?怎么变成一条 SQL 的执行?这正是我启动 Agent-Reach 这个项目时最想解决的问题:把一个只会"说话"的模型,变成一个"说到做到"的执行者。
Agent-Reach 本质上是一层工具触达中间件,夹在大模型和外部系统之间。它接收模型的输出意图,匹配对应的工具,执行真实的调用,再把执行结果送回模型做下一轮决策。听起来其实不复杂,但当你真正把 Agent 接到生产环境时,会发现这一层要做好得涉及协议设计、参数校验、安全边界、异常兜底、上下文管理等一大堆细节。这篇文章就把我这段时间的设计思路、踩坑记录和一些实测数据整理出来,给正在做同类项目的朋友当个参考。
1. 大模型不缺脑子,缺的是"手脚":Agent-Reach 要补的那块短板
1.1 只靠对话生成,Agent 走不到真实业务里
先聊一个很多刚接触 Agent 开发的人容易忽略的点。大模型本身是"文本进、文本出"的闭环:你输入一段 prompt,它输出一段答案。这个闭环对聊天、写作、分析这些场景完全够用,但现实业务不是这样的。现实业务里有数据库、有第三方服务、有定时任务、有审批流,每一项都是"动作",不是"话术"。
我见过不少团队做出来的 Agent demo,看起来挺惊艳:你说"帮我查一下这个客户的信息",它真的在回答里写出一段看似合理的客户资料。但仔细追究会发现,这些资料全是模型自己"编"出来的,因为它根本没权限去查真实数据库。Demo 没问题,生产环境就完蛋了,你不可能让一个胡编数据的 Agent 对接真实客户系统。
所以 Agent-Reach 的第一步设计思路很明确:把模型和外部系统彻底隔离,模型永远不知道数据库密码、没有 API Key、不直接接触任何真实外部资源。它只做一件事——输出结构化调用意图,剩下的事情全部由触达层去完成。这个隔离其实就是 Agent 能不能上生产的关键分界线。
1.2 触达能力的四个层次
我在设计 Agent-Reach 的过程中,把 Agent 的"触达能力"拆成了四个层次,这也是后来整个架构的底层逻辑:
- 工具层:封装好的函数,比如"查天气""发邮件""生成工单"。这是最细粒度的能力单元。
- API 层:对已有外部服务的 HTTP 封装,比如调用企业微信接口、调用内部订单系统接口。工具层是通用的,API 层是面向具体系统的。
- 环境层:让 Agent 能够感知当前上下文,比如当前用户是谁、当前时间、当前文件目录里有什么。很多人会把环境信息直接写死在 prompt 里,但动态维护会更灵活。
- 反馈闭环:触达不只是"执行一次动作",还要把动作结果拿回来、让模型基于结果做二次决策。这一步做不好,Agent 就永远是"单发指令",不是真正的自动化。
这四个层次是我后续所有开发工作的基础框架。每个层都有一个独立的模块来负责,模块之间通过统一的协议通信,后面我会详细讲这个协议的设计。
2. Agent-Reach 的骨架:把"模型意图"翻译成"外部动作"
2.1 工具注册:一切能力都变成同一套 schema
开发 Agent-Reach 的时候,我做的第一件事就是设计工具注册机制。目的很简单:让每个外部能力以统一的、计算机可读的描述方式暴露给触达层,再由触达层暴露给模型。这个描述不是给人看的,是给模型看的——模型必须"理解"有哪些工具、每个工具接受什么参数,才能正确发起调用。
我用的是 OpenAPI 的 JSON Schema 风格。
比如一个查询工单状态的工具,注册信息大概是这样的:
{ "type": "function", "function": { "name": "query_ticket_status", "description": "根据工单编号查询当前处理状态,用于客服响应客户催单场景", "parameters": { "type": "object", "properties": { "ticket_id": { "type": "string", "description": "工单编号,格式为 TKT-2024-XXXX" } }, "required": ["ticket_id"] } } }字段不多,但有一个很容易被忽视的地方:description写的好坏,直接决定模型选择工具的准确率。我在实测中发现,如果 description 写得含糊,比如只写"查询工单状态",模型在"查询订单状态""查询物流状态"这些相似工具之间就会频繁选错。后来我把 description 全部加上调用场景提示,比如"用于客服响应客户催单场景",准确率有明显提升。
所有工具统一注册到一个中心列表里,触达层每次请求模型时把这个列表注入系统 prompt。工具数量少的时候没问题,工具一旦多了就要考虑动态裁剪,后面我会专门说这个问题。
2.2 意图路由:模型怎么知道该用哪个工具
工具注册表有了之后,核心问题就变成了:模型给出的意图,如何被正确路由到对应工具的执行逻辑上。
路由这块我试过两条路线。第一条是完全依赖模型的 Function Calling 能力,让模型直接输出一个结构化的tool_calls对象,指定工具名和参数。第二种是自己实现一个语义匹配路由层,先用 embedding 对用户请求和工具描述做向量召回,缩小候选范围后,再让模型做最终决策。
实际用下来,两条路线不是互斥关系。当工具数量在 5 个以内,纯 Function Calling 就够用了,响应快、不用维护向量索引。工具数量超过 10 个,尤其是工具之间功能有重叠的时候,模型开始频繁选错,这时候就需要先用语义路由缩小候选集,再把筛选后的 5 个左右工具交给模型精调选择。
我目前的方案是混合式的。请求进来先经过一个轻量级路由模块,根据工具的 name、description、历史调用频率做 top-K 筛选,然后把筛选后的工具 schema 拼进 prompt 让模型做最终决策。这样既控制了 prompt 体积,又保持了模型决策的准确性。
2.3 执行引擎与结果回填:让 Agent"看得见"自己的动作结果
意图翻译成工具调用之后,接下来是执行环节。这一步看起来简单,实际是最容易埋坑的。
Agent-Reach 的执行引擎维护着一个线程安全的工具执行器,每个工具都是一个实现了统一接口的 handler。接口定义只有两个方法:validate(params)和execute(context)。validate做参数格式校验,execute接收一个上下文对象,这个对象里装着用户身份、会话 ID、追踪 ID、上一轮的调用结果等。
执行完的结果不直接返回给用户,而是要回填给模型。这是 Agent 能否"连续干活"的核心。我最初的实现一愣,工具执行完只把最终结果拿回来拼在回复里。比如工具返回一个 JSON{"status": "processing"},模型就会直接对用户说"您的工单正在处理中"。看起来没问题,但遇到复杂任务时就会出问题:如果后续还要根据这个状态做进一步操作,模型的中间推理过程就断掉了。
我的做法是在结果回填时提供两个文本块:action_result和action_summary。action_result是工具返回的原始结构化数据,action_summary是系统自动生成的一段摘要(或者由工具开发者手动指定)。这两块都会拼进模型上下文,模型既能拿到精确数据,又不会因为原始数据过长而"迷路"。
3. Function Calling 没你想的那么玄:触发机制与参数校验的实战细节
3.1 模型如何决定"该调用工具了"
很多刚接触 Agent 开发的朋友会有一个错觉:模型调用工具是"它自己想通了要用"。其实背后就是一个概率分布的问题。模型在训练阶段就学会了这样的模式:当上下文中存在"可供调用的工具列表",并且用户请求与某个工具的 description 高度相关时,模型会以更高的概率输出一个特定的结构化调用指令。
触发这个行为的关键因素有三个。第一是系统提示词里是否明确说明"你是一个可以使用工具完成任务的助手,当需要实时信息或需要操作外部系统时,请调用工具"。第二是工具 schema 的准确性,尤其是 description 部分不能有歧义。第三是生成参数的设置,temperature建议设置在 0 到 0.3 之间,温度越高模型越容易"自由发挥",输出一些不在工具列表里、或者参数格式不标准的伪调用指令。
我踩过的一个坑是:把 temperature 设置为 0.7,结果模型虽然说出了"我要调用查询接口"这句话,但输出的是普通文字,完全没有按照 tool_calls 的结构化格式来。整条链路直接断掉,解析器找不到合法的 tool_call 对象。排查了半天,最后发现就是温度太高。
3.2 参数约束与校验:宁可让模型多问一次,也不要传错参数
参数校验是 Agent-Reach 里我最重视的环节之一。模型输出了工具调用并不代表参数就一定正确。实际上,模型经常出现"参数缺失""参数格式偏了""传了一个根本不存在的字段"这三类问题。
有一个很典型的例子:工具定义里ticket_id是必填参数,格式是TKT-2024-XXXX。模型有时会直接传20240715这样的数字。如果执行器不做校验直接把参数透传给下游系统,轻则接口报错,重则可能因为类型不匹配造成脏数据。
所以我的校验策略分两层。第一层是结构校验,用 schema 里的type、required、pattern做基础拦截。这一层不过,直接返回参数错误给模型,让它重新生成调用指令。第二层是语义校验,在工具 handler 内部做,比如格式为TKT-2024-XXXX的工单号,我会在业务逻辑里再检查一下前缀和年份。两层都过,才允许真实调用外部系统。
这里我想强调一个原则:当模型给的参数不够确定的时候,宁可让它多追问用户一次,也不要擅自用默认值去执行。擅自补参数是 Agent 开发里最危险的行为之一,因为你不知道那个默认值会把操作带向哪里。Agent-Reach 的校验失败返回信息设计得也很明确,直接告诉模型"参数 t icket_id 不合法,请向用户确认正确的工单编号",避免模型在错误参数上原地打转。
3.3 实测:平行工具太多时指令遵循会退化,怎么解决
工具数量对模型指令遵循的影响,我做过一组对比测试。同一个小型开源模型背景下,工具数量从 3 个增加到 15 个时,模型正确调用工具的准确率从 97% 左右掉到了 82% 左右。工具数量到 25 个时,掉到了 71% 上下。这个退化趋势非常明显。
原因不复杂:工具列表越长,模型在生成时需要"注意力"覆盖的信息就越多,而上下文空间是有限的。排在列表尾部的工具,模型往往会"看不见"或者"想不起来"。还有一个更隐蔽的问题:工具描述之间存在语义干扰,比如"创建订单"和"确认订单"这两个工具,description 里都有"订单"两个字,模型就容易混淆该选哪个。
针对这个问题,我的解决思路是前面提到的混合路由。动态裁剪工具列表,每次只保留与当前用户请求最相关的 top-K 个工具。这样模型的注意力被聚焦在少数候选上,准确率能够稳定回到 95% 左右。代价是每次请求需要额外做一次检索,大约增加几十毫秒的延迟,对于绝大多数业务场景来说完全可以接受。
4. 触达不等于乱跑:超时、重试、沙箱与权限收敛
4.1 外部调用失败的 N 种姿势与兜底策略
Agent 一旦开始真实触达外部系统,"失败"就成了常态,不是例外。我在 Agent-Reach 里总结了外部调用的几类典型故障,每类都要有专门的兜底处理。
第一类是超时。外部接口响应慢,模型会一直等在那里。我最初的实现是同步等待,结果遇到一个下游接口偶尔要跑 30 秒的情况,整个 Agent 会话被拖住,用户体验极差。后来我统一给所有外呼设置了超时时间,默认 8 秒,超时直接返回一个标准超时错误给模型,让模型告诉用户"系统繁忙,请稍后再试"。
第二类是幂等性。工具被重试的时候,得确保不会因为重复调用造成重复扣费、重复建单。这个必须在工具 handler 层每个参数上带上一个全局唯一的trace_id,下游系统要支持基于trace_id的幂等判断。如果你的下游系统不支持幂等,那就只能人工兜底,或者干脆不要做自动重试。
第三类是部分成功。举个例子,Agent 要批量给 10 个用户发送通知,发到第 5 个时接口报错了。这时候正确的做法是,记录成功的 4 个、失败的 1 个,把"部分成功"的状态如实回填给模型,让模型自行决定是重新尝试失败的单个用户,还是放弃并告知用户整体情况。千万不要让整体任务因为局部失败而全部回滚——这在有些场景下成本太高,而且 Agent 天然应该具备"局部容错"的调度能力。
我设置了一个统一异常类型表,整理了几种最常见的兜底策略,直接内嵌在触达层的配置里:
| 异常类型 | 默认策略 | 说明 |
|---|---|---|
| 网络超时 | 重试一次,上限 8 秒 | 重试仍然失败则返回明确错误 |
| 限流(429) | 退避重试一次 | 等 1 秒再试,再失败则放弃 |
| 参数不合法 | 不重试,返回校验反馈 | 让模型重新生成参数 |
| 下游 5xx | 重试一次 | 若工具定义里标明支持幂等,可重试两次 |
| 业务规则拒绝 | 不重试,返回业务原因 | 比如订单状态不允许取消,让模型转述原因 |
4.2 把 Agent 关进"笼子":最小权限与审计日志
让 Agent 触达外部系统之前,必须想清楚权限边界。Agent 不是一个普通用户,它的操作速度快、并发高、行为可能充满试探性。给 Agent 分配权限应该坚持最小权限原则。
比如一个 Agent 被用来处理售后客服,那它应该只能查询工单、提交仲裁申请、修改工单备注,而绝对不应该有删除工单、修改退款金额的权限。我遇到过一些团队图省事,直接把一个管理员权限的 API Key 挂在 Agent 配置里,结果 Agent 的一次误调用把所有测试订单全删了,这种事故真的不少。
Agent-Reach 的做法是在触达层维护一个权限路由表,每个工具都标注需要的权限级别,每次调用执行前都会校验当前会话的授权范围。而且我不会把真实的下游 API Key 直接暴露给工具 handler,而是在触达层将 Key 统一在 vault 里管理,handler 只面向密钥存储的引用做操作,映射关系对 agent 会话来说是不可见的。
审计日志同样不能省略。Agent-Reach 每一次工具调用都会记录:谁(会话 ID)、调用了哪个工具、传了什么参数、返回了什么结果、耗时多久。日志往结构化存储里写,既方便线上问题回溯,也能用来做 Agent 行为切割分析,后面你赵调 prompt 或评测模型能力时,这份日志就是最真实的样本集。
4.3 一个真实生产事故的复盘:死循环调用烧掉 token
说一个我印象特别深刻的线上问题。当时 Agent-Reach 接了一个内部的"订单状态查询"工具,工具逻辑是:如果订单状态变化,则调用"消息通知"工具通知用户。看起来很正常对吧?但某个异常订单的数据里,状态字段反复在两个值之间跳动,导致 Agent 每查一次就会触发一次下发通知,通知完又被用户反馈触发新的查询,形成了死循环。
等我发现的时候,这次事故烧掉了快 12 万个 token,下游的消息服务也被连续打了几轮。复盘时总结出三个教训:
- 工具调用次数必须设置上限。我在触达层加了一个全局的
max_iterations限制,默认每个会话最多执行 8 次工具调用,超出后模型必须转入"总结现有信息并回复用户"的模式。 - 副作用类操作必须二次确认。像"发消息"这种会产生外部影响的操作,最好定义一个
confirmation机制,在模型发起这类调用时默认先触发会话确认,而不是直接执行。 - 状态判断不能只看瞬时值。对于状态反复跳变的场景,要给 Agent 一个"稳定观察"的机会,比如连续两次查询状态一致才认为变化实际发生了。
死循环这个问题如果不在触达层预设防护,往上依赖模型"自己意识到循环"是完全不可靠的。Agent 执行行为的最后一道战线,必须建在基础设施上。
5. 从单工具到复合任务:Agent-Reach 的多步编排实测
5.1 任务拆解:大任务切成小工具调用
单工具调用跑通之后,Agent 的价值其实才刚显现。真实业务几乎全是复合任务。比如:"帮我查一下这周的销售订单里有哪些客户还没有发货,然后给这些客户的对接人发一封催货提醒。"这一句话里就包含了:查订单列表、筛选未发货、查客户对接人信息、生成提醒内容、逐条发送消息,至少五个步骤。
Agent-Reach 不试图直接让模型"一次生成完整步骤序列",因为实测下来,预生成长步骤序列在执行中途一旦外部状态变化,整条链路就崩了。我采用的是"动态规划"式的多步编排:模型每一步只生成下一个工具调用,执行完拿到真实结果后,再决定接下来怎么做。这更贴近人在真实工作时的思考方式——你在处理事务的时候,也是拿一步的结果再推进下一步,而不是一上来就背完所有动作。
5.2 上下文记忆:别再每步都重新理解用户意图
多步执行最容易出现的一个问题是上下文丢失。模型每执行一次工具调用,中间就会插入一批来自工具的结果文本。等到第三个工具执行完,模型很可能已经忘了最开始用户提出的原始诉求中某个关键细节。不少 Agent 项目做出来的多步调用,越跑越像"无头苍蝇",就是这个原因。
我解决这个问题用的是"记忆压缩"的思路。在每次工具结果回填时,同时回填一个由较早期的系统摘要和原始用户请求的前置摘要组成的"锚点"。具体做法是:会话开始时就生成一段非常简短的目标摘要,每次模型要开始下一步决策时,这段摘要必然存在于上下文的最前面。工具结果放在中段,最新的模型指令放在尾部。
这样做的好处是,无论中间插入了多少工具调用结果,模型每轮的"注意力焦点"始终有一个稳定参照物,不会跑偏。
5.3 我实测过的两个典型链路
这里放两个我在开发 Agent-Reach 过程中实际跑过的链路作为参考。
第一个是"客服工单分类与自动回复"链路。用户提交工单后,Agent 首先调用文本分类工具判断工单类型,然后调用相似工单检索工具找到历史类似 Case,再把检索结果和历史解决方案交给模型,最后由模型生成拟回复内容。整个链路下来,一次完整任务平均要调用 3 到 4 次工具,耗时在 6 到 10 秒之间,其中耗时大头在模型推理,工具执行本身基本在毫秒级。
第二个是"跨系统数据核对"链路。需求是核对 CRM 系统中的客户地址和物流系统中的收货地址是否一致。Agent 先调用 CRM 查询接口拿地址 A,再调用物流查询接口拿地址 B,对比后向用户返回不一致的字段。这个链路的难点在于,模型需要对两个结构化 JSON 做字段级对比,如果直接让模型读原始 JSON,地址字符串里的细微差异(比如"XX路 1 号"和"XX路1号")会被模型认为是不同地址。我的做法是在工具层就做好地址归一化,再交给模型做最终判断。能交给工具的清洗工作,不要留给模型的"眼力"。
6. 接生产前必须做的事:落地清单与我的几点体会
6.1 上线前必须检查的清单
把 Agent-Reach 这类能力接到生产环境之前,我整理了一份自检清单,这里分享出来给大家参考。每一条都是我踩过或用实际案例验证过的,不一定适用于所有团队,但用来做 baseline 还是可以的:
- 每个工具调用是否有审计日志?日志是否包含请求 ID、调用时间、参数摘要、结果摘要?
- 是否所有外呼接口都设置了统一超时?
- 是否梳理过每个工具的幂等能力?不幂等的工具是否禁止自动重试?
- 是否设置了单会话最大工具调用次数?
- 是否定义了"副作用类工具"的二次确认机制?
- 是否存在 Agent 默认参数兜底策略?防止"擅自补参"行为?
- 生产环境的 Agent 权限是否收到了最小必要范围?
- 是否对模型做了工具注入数量的评估?超过 10 个工具时是否配置了动态筛选?
- 是否每一个工具 schema 的 description 都写清了调用场景和参数格式示例?
- 上线后的评测集是否包含"输入参数不完整""外部接口超时""工具调用失败"这三类反例?
其中最后一项经常会被忽略。很多团队只拿正常的成功样例来评测 Agent,结果一上线面对各种异常场景就措手不及。没有失败样例的评测集不能用于 Agent 生产评估,这一点我认为应该成为团队的基本共识。
6.2 我的几点体会
整个 Agent-Reach 做下来,最大的体会是:Agent 开发的前期,精力投入要放到"触达层"而不是"模型本身"。模型能力和推理策略当然重要,但对于大多数业务场景,外接一个中学配置的模型完全够用,真正决定 Agent 能不能干活的是工具触达层的质量——工具粒度是否恰当、schema 是否准确、异常处理是否完整、权限边界是否清晰。这些工作朴素、繁琐,不会出现在论文里,但它们才是生产级 Agent 的地基。
另外一个感受是:触达层的设计要预留"人机协作"的接口。不是所有动作都适合让 Agent 全自动执行,某些高风险操作(比如发送批量邮件、删除数据、修改金额)保留人工确认入口,会让业务方更愿意接受 Agent 这个"新同事"。
项目还在持续迭代,后续我打算重点搞两块无关趣味的部分:让 Agent 在做复合任务时拥有更长跨度的局部记忆能力,以及基于真实历史调用日志构建一套更细粒度的工具评测指标集。希望这篇分享能给同样在折腾 Agent 触达能力的朋友带来一些参考。