
过去半年我一直在跟 Agent 项目的稳定性较劲。模型选得再好prompt 写得再讲究最后卡壳的地方往往出在模型跟外部工具的连接上工具一多就乱、接口超时没有兜底、返回的 JSON 把上下文撑爆、权限边界形同虚设。这些问题的本质不是某个工具写得烂而是缺少一层专门负责“触达”的基础设施。后来我把这层抽出来做成了一个小框架叫 Agent-Reach。它的定位特别简单在 LLM 和所有外部能力之间放一个负责注册、路由、调度、降级和审计的薄层。如果你正在做 Agent 应用尤其是不满足于 demo、想上生产的同学下面这些思路可以直接抄进自己的项目里。1. 为什么 Agent 真正缺的是“触达”能力1.1 工具多不是问题连接方式才是我见过很多同学做 Agent 的第一步就是给模型接上一堆工具搜索引擎、数据库查询、订单详情、天气、日历、邮件发送、代码执行、向量检索……看起来能力很全跑起来却经常翻车。最典型的场景是工具列表越来越长模型越来越容易选错工具某个第三方 API 飘了超时之后整个 Agent 卡住不动工具返回值动辄几千 token塞进上下文之后模型“只看见树、看不见森林”。你可能会说这些是工程问题慢慢调就行。但实际上它们共同指向一个更底层的设计缺失Agent 的模型层和工具层之间缺少一个明确的“连接管理层”。Agent 要跟外部世界打交道不只是会写 function calling 的 JSON 就够了还要回答几个更实际的问题有多少个工具可用它们的调用协议是什么一次请求进来到底该路由到哪个工具工具失败时是重试、降级还是直接返回错误返回值如何控制尺寸避免挤爆上下文谁有权限调用哪一个工具如果这些逻辑散落在业务代码里刚开始没问题工具到 20 个之后基本就失控了。Agent-Reach 想解决的就是把这一层单独拎出来做成一个可复用、可观测、可配置的“触达层”。1.2 Agent-Reach 的本质一个总机接线员打个比方没有触达层时Agent 更像是拿着一个巨大通讯录挨个自己拨电话拨不通就干等拨错号就得重来。Agent-Reach 做的事情像一个总机接线员外面的人只知道一个号码接线员根据意图帮你转接到正确分机分机占线就换线路线路挂了就给你明确反馈需要高级权限时先按一下确认按钮。这个类比基本概括了它的核心职责。落到技术架构上Agent-Reach 不是一个新的 Agent 框架它不替你写 prompt不帮你选模型也不规定你的业务逻辑。它只做一件事把“调用外部工具”变成一条有策略、有审计、有兜底的标准通道。这样设计有一个明显好处它跟具体模型解耦单纯站在 function calling 的外围。你可以用 GPT 系模型也可以用开源模型甚至用非模型的方式触发工具。只要你的系统需要对外部能力发起请求Agent-Reach 都可以夹在中间。2. Agent-Reach 的整体设计与关键模块2.1 工具注册用一套 schema 统一表达Agent-Reach 的第一个基础能力是工具注册。没有注册中心的情况下每接一个新工具就要写一段新的调用逻辑工具之间没有统一标准后面做路由、审计都非常痛苦。统一表达这件事我的经验是直接基于 JSON Schema 扩展。LLM 生态里 function calling 已经习惯用 JSON Schema 描述参数Agent-Reach 继续沿用这套语言但额外增加几个工程字段比如超时、失败率、幂等性、是否需要人工确认。这样模型侧解析几乎没有额外成本工程侧又拿到了运维管理需要的信息。一个最简单的注册写法类似这样from agent_reach import register register( namesearch_web, description搜索公开网页内容返回标题和摘要列表, timeout5.0, max_retries2, idempotentTrue, need_confirmFalse, ) def search_web(query: str, top_k: int 10): # 内部调用搜索服务 return search_service.search(query, top_k)选择装饰器而不是写配置类是我刻意做的决定。代理工具通常已经存在于业务代码里用装饰器可以做到最小侵入不要求开发者为接入 Agent-Reach 重写业务逻辑。注册完成后框架自动采集元信息入参类型、出参格式、预计延迟等级、历史失败率这些信息后面路由时会用到。2.2 路由策略别把选择压力全给模型让模型直接从几十个工具里做选择看似简单实战中翻车率极高。两个工具名称相近、描述不够明确、模型上下文里工具说明被截断都会导致路由错误。Agent-Reach 的路由设计采用了“预筛 动态选择”的组合。预筛阶段框架会根据用户输入的语义先用一个轻量级 embedding 模型计算候选工具的相关度把工具数量从 20 个缩减到 3 到 5 个再把候选列表交给 LLM 做最终决策。这样做有几层好处降低 LLM 的选择熵准确率明显提升。减少工具描述占用的 prompt token。可以顺便剔除明显不合适的工具减少误调用。如果某些调用关系完全确定比如用户已经明确说了“查深圳的天气”那就不必再把选择权交给模型。可以配置“显式绑定路由”把用户语义中的地点、对象映射到固定函数。Agent-Reach 支持在注册工具时声明路由规则例如register( nameget_weather, routing_rules[ {match: 城市天气查询, target: get_weather} ], ) def get_weather(city: str): ...这一步真正把路由从“让模型自由发挥”升级成“模型 规则双重保障”。规则负责确定性部分模型负责开放性部分。2.3 稳定性策略超时、重试、熔断三位一体Agent 调外部工具稳定性比功能更影响体验。一个工具超时轻则拖慢整个任务重则让 Agent 进入反复重试的死循环。Agent-Reach 的做法是在触达层内置超时、重试、熔断三种策略并且它们之间互相配合。超时不是简单地“设个 3 秒”而是基于工具的历史延迟动态计算。重试也不是无脑重试只对 idempotentTrue 的工具重试写操作一旦超时绝不重试宁可报错也不能重复下单。熔断则更像保险丝某个工具连续失败率超过阈值比如最近 1 分钟失败 5 次触达层直接摘除该工具后续请求走降级逻辑。这三个策略的优先级非常关键先判断熔断再判断重试最后判断超时。熔断期间即使重试次数没到也应该直接短路返回错误而不是继续浪费时间。举个例子如果搜索服务持续 500 错误再重试 5 次只会把故障时间拖得更长用户任务也一直挂在“等待工具”状态。2.4 上下文瘦身返回值是最大的隐形杀手很多人优化 Agent 时只盯着 prompt 和模型参数却忽略了工具返回值对上下文的影响。一个普通搜索接口可能返回一整个 JSON包含商家信息、价格区间、评论条数、图片链接、内部 ID真正对决策有用的可能只有标题和摘要。如果不加控制一次工具调用吃掉 3000 token五个工具轮询下来上下文就没地方放对话历史了。Agent-Reach 针对返回值做三层处理。第一层是字段白名单注册工具时声明哪些字段用户真正需要框架自动裁剪返回 JSON第二层是数量上限把 top_k、limit 等参数在触达层强制改写第三层是自动摘要如果返回值超过一定 token 阈值就调用文本摘要模型把结果压成一小段。这一步对最终效果的影响往往比换一个更大的模型更明显。上下文更干净模型注意力更集中工具调用结果反而更准。3. 实操过程与核心实现3.1 搭一个最小闭环从注册到调用为了让你有更直观的体感我拿一个具体例子走一遍。假设我们要给 Agent 接入两个工具查天气和查日历。最小接入只需要三步。第一步定义工具函数并注册from agent_reach import ReachAgent reach ReachAgent() reach.register(weather_service.get_weather) reach.register(calendar_service.get_schedule)第二步配置触达参数reach.configure({ weather_service.get_weather: { timeout: 3.0, idempotent: True, max_retries: 1, max_return_tokens: 200, }, calendar_service.get_schedule: { timeout: 5.0, idempotent: True, max_retries: 1, max_return_tokens: 500, } })第三步在 Agent 的 function calling 请求里把原来直接拼给模型的工具列表替换成 reach.get_tool_schema()模型返回 tool_call 之后调用 reach.execute() 执行resp_tool_calls llm.chat_with_tools( messagesmessages, toolsreach.get_tool_schema(), ) for call in resp_tool_calls: result await reach.execute(call) messages.append({ role: tool, tool_call_id: call.id, content: result.text, })整个闭环里模型侧不需要知道工具内部怎么实现也不需要自己处理超时重试这些全部被触达层接管了。我建议第一次跑通时先在日志里观察 reach 的决策链路route 命中哪个工具、是否发生重试、返回值瘦身后的 token 数这些字段能够帮你快速定位很多后续问题。3.2 超时与重试的正确实现方式实现超时和重试最怕的是异步场景下把事情做复杂。Python 中我推荐基于 asyncio 包一层统一的执行器避免每个工具自己写 try/except。大致思路是async def execute_tool_with_policy(tool_call): tool registry.get(tool_call.name) if breaker.is_open(tool.name): return ErrorResult(service unavailable) for attempt in range(tool.max_retries 1): try: result await asyncio.wait_for( tool.func(**tool_call.arguments), timeouttool.timeout, ) breaker.record_success(tool.name) return normalize_result(result) except asyncio.TimeoutError: breaker.record_failure(tool.name) if attempt tool.max_retries: return ErrorResult(f{tool.name} timeout) except Exception as exc: breaker.record_failure(tool.name) if attempt tool.max_retries: return ErrorResult(str(exc)) # 如果请求本身不允许重试 if not tool.idempotent: return ErrorResult(non-idempotent tool cannot retry)这里有两个容易被忽略的细节。第一wait_for 的 timeout 应该由 Agent-Reach 统一管理而不是依赖工具内部自己的 timeout。很多第三方 SDK 内部有自己的超时但那通常是“建立连接”的超时不是“完整执行”的超时两层需要取最小值才能保证整体可控。第二ErrorResult 不是一串普通字符串它要走统一的错误格式明确告诉模型“这个工具失败了原因是超时建议换其他方式”。否则模型看到一句语义模糊的失败描述可能继续用同一个工具重试反而浪费更多时间。3.3 返回值裁剪与标准化返回值裁剪不是简单截字符串那样会把 JSON 弄坏。我的做法是先拿到原始返回 dict然后根据注册时声明的“输出字段白名单”做深度过滤。比如搜索工具返回结构是{ items: [ { title: ..., snippet: ..., url: ..., score: 0.98, cached_page: ..., raw_html: ... } ], total: 1000 }但 Agent 只需要 title、snippet、url那就在注册时声明register( namesearch_web, output_fields[items.title, items.snippet, items.url, total], )触达层执行完之后会递归保留白名单字段丢弃 raw_html 和 cached_page 这类大体积内容。之后再计算返回值的 token 数超过阈值就只保留前 N 条并把 total 改成实际返回数避免模型误以为拿到了全量结果。标准化是另一个容易忘记的问题。不同工具返回格式差异很大有返回 dict 的、返回字符串的、返回自定义对象的。Agent-Reach 规定所有工具执行结果统一转成 text 之外还需要带一个正文字段给模型展示另外带一个 payload 字段纯 JSON 结构化数据方便系统后续做延迟分析。这样上层应用即使要对接别的 Agent 框架也能轻松适配。3.4 参数计算超时阈值不是拍脑袋定的很多人问 timeout 到底该设多少。我的经验是不要凭感觉设常数而要先收集工具的正常延迟分布。最简单的方式是在触达层记录每次工具调用的延迟然后统计 P95 分位数。一个比较稳的经验公式是timeout max(P95 * 3, 2s)为什么取 P95 乘以 3 而不是 P99因为网络抖动是客观存在的P99 本身波动大直接按 P99 设置会让超时时间偏大任务整体变慢。P95 相对稳定乘以 3 之后基本能覆盖异常尖刺又不会让用户等太久。重试次数不建议超过 2 次。一次超时通常是网络抖动两次超时往往意味着服务已经有问题了第三次再重试不仅大概率失败还会让任务整体响应时间翻倍。我用过的一个搜索接口延迟数据是这样工具场景P95 延迟建议 timeout重试次数高频内部缓存150ms2s保底1低频外部搜索2s6s2超长文档解析5s15s0非幂等写操作1s3s0需要特别说明非幂等写操作无论延迟多低都不要重试。这个规则我在工程里写成硬编码如果工具声明 idempotentFalse即使 max_retries 配置了数字触达层也会强制置为 0避免有人手滑开重试后造成重复扣款或重复发送。4. 常见问题与排查技巧实录4.1 上下文被撑爆Agent 可能不是“笨”是“忙”有段时间我接了一个文档问答 Agent模型用的是当时很强的一个版本但总是答非所问。日志一打开发现它每次调用文档解析工具返回值都带上了完整章节全文光一次工具调用就吃掉 8000 token。三次工具调用之后后续对话基本没有有效信息。这是 Agent-Reach 刚上线时最常见的问题。排查思路很简单在日志里输出每次工具调用的返回 token 数和总 token 消耗。一旦发现某一个工具的单次返回超过 500 token就要考虑裁剪或摘要。后来我在返回值裁剪模块里加了一个开关return_tokens, 值为 0 表示不限制为正数表示强制压缩。文档解析工具设置成 return_tokens600搜索工具设置成 return_tokens300之后再看着上下文占用曲线终于恢复正常。4.2 路由选错模型不是故意的是工具描述太像我们曾经同时接入“查日历”和“查天气”。某次线上反馈用户问“明天有空吗”模型居然调了天气工具返回一堆气温降雨彻底答非所问。一开始怪模型看不出语义后来才发现两个工具的 description 在 prompt 里展示效果非常接近模型确实难判断。Agent-Reach 的预筛模块这时候就起作用了。我让系统对用户输入和工具描述同时做 embedding然后过滤出相似度最高的候选集。用户问题“明天有空吗”跟“获取日程安排”相似度远高于“查询天气”预筛阶段就可以把天气工具排掉。再结合之前提过的显式路由规则把“有空/日程/日历/安排”这类词直接绑定到日历工具从此这个问题就绝迹了。经验是不要只依赖模型做语义判断规则和向量预筛能更稳定地处理长尾场景。4.3 死循环式重试没有 global timeout 的后果某一个工具单次 timeout 设了 5 秒重试 2 次按理说最多 15 秒。但实际跑起来Agent 每次拿到 timeout 错误后会重新生成 tool_call再次调用同一个工具结果整个任务卡了 3 分钟。原因很简单模型发现工具失败但没有其他可替代方案只能死磕。触达层里有一个经常被忽略的参数global_timeout。它限制的是“整个 Agent 任务中所有工具调用的总时间”而不是单次工具执行时间。我把 global_timeout 设为 30 秒后超过 30 秒不管进度如何直接返回给上层“当前任务因工具调用超时而终止”模型就被迫换个路径比如从搜索改成直接回答用户“暂时无法获取最新数据”。这个全局超时也顺带解决了另一个问题多工具协作时某个工具卡住会导致整个编排卡住现在到了全局上限就果断放弃至少能够给用户一个反馈而不是无限等。4.4 高风险工具误调用给 Agent 装上安全锁有一次测试Agent 为了完成“清理测试数据”的指令真的去调了删除数据库记录的接口。虽然只是测试环境但让我意识到权限控制不能只停留在“信任模型”阶段。Agent-Reach 里实现了一个简单的 HITLhuman in the loop机制注册工具时如果标记 need_confirmTrue触达层在执行前会先返回一个 pending 状态上层应用可以弹出确认框等用户点击确认后才真正执行。被标记为高风险的场景包括删除、修改、发送消息、下单、支付等。写着简单但真的有效。另外还要在路由层做一层护栏即使 LLM 的 function calling 结果指向了某个高风险工具如果该工具不在当前用户的白名单里触达层直接拒绝执行并返回“无权限调用该工具”。模型再聪明都不能越过这道硬边界。5. 个人经验总结与后续扩展踩过这些坑之后我最大的体会是Agent 能不能稳定跑起来很大程度取决于“触达层”有多克制。Agent-Reach 的原则是只做通道、不越权、不留死角。所谓不越权是它不替你决定业务逻辑所谓不留死角是每一次工具调用都能讲清楚“调了谁、为什么调、结果如何、花了多久”。后面在这个基础上可以继续扩展两层能力一层是按用户维度做配额和计费防止某个会话把外部 API 额度打爆另一层是把触达层的日志接入可观测系统按工具维度统计延迟、失败率和 token 消耗。做到这两点Agent 项目才是真正从“能跑”变成了“能运维”。