2. 项目定位与整体思路拆解
2.1 Agent 开发最难的从来不是模型,而是"触达"
做 AI Agent 开发这两年,我最大的感受是:模型能力早就不是瓶颈了,真正让人掉头发的是让 Agent 可靠地触达外部世界。
你让 GPT-4 或者 Claude 写一首诗、总结一篇文档,那确实很稳。可一旦你让它去调用订单系统查单号、去天气接口查数据、去日历里创建会议,问题就全冒出来了——接口文档千奇百怪,有 REST、有 GraphQL、有 WebSocket,参数格式有的要 snake_case、有的要 camelCase,鉴权方式更是五花八门,有些要 Header 里带 token,有些要签名,有些还要走 OAuth 两步。更别提那些动不动超时、限流、返回结构说变就变的外部服务。
这就是我搭建 Agent-Reach 的初衷。它不是又一个 Agent 编排框架,不是让你去定义"智能体怎么思考"的;它是一个纯粹的触达层,解决的是"Agent 的命令怎么变成对外部服务稳定的调用"这个问题。类比一下,你可以把 Agent-Reach 想成一个总机接线员:LLM 只需要说出意图("帮我查一下订单 SF20240001 的物流状态"),剩下的——找哪个服务、用什么协议、怎么鉴权、超时了怎么处理——全都由 Agent-Reach 在后端替它完成。
这个项目尤其适合正在做 Agent 落地的工程师、想给自己的 AI 应用接入第三方能力的独立开发者,以及被一堆 API 集成搅得焦头烂额的技术负责人。看完这篇文章,你会拿到一套可以照抄的架构方案和可运行的代码骨架。
2.2 核心定位:做"接入层",而不是"思考层"
在设计 Agent-Reach 的第一天,我就给自己定了一条死规矩:它只负责触达,不负责思考。
市面上的 Agent 框架太多了,LangChain、LangGraph、AutoGen,每个都在帮你抽象"Agent 怎么做决策"。但做了几个真实项目后就发现,这些框架把编排层做得很重,却把最底层的那层胶水——怎么稳定地连接外部工具——扔给开发者自己写。结果就是每个项目里都有几百行打满补丁的apiClient.ts,每个工具的入参校验靠 if-else 堆,模型输出稍微歪一点就整条链路崩掉。
Agent-Reach 把注意力集中在接入层,按照下面的分层思路来部署:
| 层级 | 职责 | Agent-Reach 的定位 |
|---|---|---|
| 表现层 | 用户对话、前端交互 | 不关心 |
| 思考层 | LLM 推理、意图规划、多步决策 | 不关心(可直接搭配任意 Agent 框架) |
| 触达层 | 工具注册、意图路由、协议转换、执行容错、可观测 | 核心关注区 |
| 资源层 | 第三方 API、公司内部服务、数据库 | 通过适配器连接 |
这样的好处是边界清晰。思考层出问题,你换模型、换提示词就行;触达层出问题,你只动 Agent-Reach 内部的适配器和路由规则,两者互不干扰。
2.3 设计哲学:为什么是"协议适配 + 意图路由"而不是写死调用
早期我也写过很朴素的工具调用方案:给每个 API 写一个函数,放进一个大对象里,让 LLM 根据函数名去选。三个工具的时候很好用,十个工具的时候勉强能用,到三十个工具的时候就是灾难——
模型经常选错函数、参数必须要模型输出得一字不差、某个 API 改了返回结构你得手动去改那条链路上的所有代码。而且最致命的是,这套东西没有容错,外部服务抖一下,整个 Agent 对话就死了。
所以我做了两个关键设计:
第一,加一层"语义路由"。让模型输出的不是一个编死的函数名,而是一个自然语言意图描述(或者按我设计的意图 Schema 输出结构化槽位),Agent-Reach 内部用意图匹配器去找到合适的工具。有点像一个路由器——它不关心数据包里是什么内容,只关心你要去哪,然后帮你把包转给正确的出口。这样做的好处是,你新接一个工具,根本不触动模型侧的东西,路由规则加一条就行。
第二,把"调用外部 API"变成"遵循内部协议的适配器"。我定义了一套内部工具调用协议,外部服务一律通过适配器翻译成这套协议。REST 也好、SOAP 也好、SDK 也好,都包一层,统一成{ action, params }的消息格式。这样做完之后,Agent-Reach 中央的逻辑永远不需要变,代价只是每个外部服务写一个适配器,这个成本是一次性的。
后面我会详细拆解这套架构在代码里是怎么落地的。
3. 核心架构与五个关键模块
3.1 接入网关:把一百种协议翻译成一种语言
接入网关是 Agent-Reach 的"嘴唇"和"耳朵",负责协议双向转换。对外它连接各类外部服务和数据源,对内它统一输出标准化的工具消息。
为什么需要这一层?我举个例子。假设你的 Agent 要查天气,真实情况是:你对接的是和风天气(REST + JSON)、彩云天气(REST + 自定义鉴权)、以及一个公司内部的天气服务(gRPC)。没有网关的话,你的 Agent 代码里会出现齐刷刷的三段逻辑,每段逻辑的异常处理、超时设置、字段映射全都不一样。
有了网关,统一变成这样:
type InternalToolRequest = { toolId: string; // 工具唯一标识 action: string; // 动作名,如 "queryWeather" params: Record<string, unknown>; // 已校验的标准化参数 traceId: string; // 链路追踪 ID }; type InternalToolResponse = { success: boolean; data?: unknown; error?: { code: string; message: string; retryable: boolean; // 是否可重试,这是关键 }; metrics: { durationMs: number; attempt: number; }; };网关的核心是一套适配器体系。我建议用 TypeScript 写是因为它有强大的类型系统,能让每个适配器都清晰地声明"我输入什么、输出什么"。每个适配器要裸奔出四个方法,这是我在多次重构后固定下来的接口:
interface Adapter { // 把内部参数翻译成外部 API 要求的格式(可能改 key 名、改嵌套结构) transformRequest(req: InternalToolRequest): ExternalRequest; // 调用外部服务,注意这里只做传输,不处理业务逻辑 execute(externalReq: ExternalRequest): Promise<ExternalResponse>; // 把外部返回翻译回内部标准结构 transformResponse(res: ExternalResponse): InternalToolResponse; // 声明这个适配器的健康状态,供注册中心巡检 healthCheck(): Promise<boolean>; }这个设计坚持下来之后,收益特别明显:接入第 20 个工具的时候,接入第 1 个工具还要快——因为套路全部固定了,新适配器就是"抄上一个的模板改改映射关系"而已。
3.2 意图路由:从"帮我干件事"到"调用哪个工具"
意图路由是 Agent-Reach 里最有技术含量的模块,也是我优化时间花得最多的部分。
先说一个很多人踩过的坑:直接让 LLM 返回工具名,然后代码里 switch-case。这在小型 Demo 里看似直接,但真实场景会出两个问题。第一,工具多了之后,模型对工具名记忆混乱,经常瞎选一个;第二,工具如果将来改名或者升级,模型侧的记忆就得跟着动。
我在 Agent-Reach 里的做法是,让 LLM 输出两层东西:
- 短意图描述,比如 "查订单物流";
- 槽位参数,比如
{ orderId: "SF20240001" }。
然后路由模块用意图关键词 + 槽位结构去匹配工具注册中心里的工具清单。
早期我用过向量相似度匹配(embedding + cosine),效果还行,但缺点是响应慢、需要额外维护向量库。后来我改造成基于工具声明的匹配规则,每个工具在注册时要声明自己的触发模式和必填槽位:
// 工具注册时声明路由规则 const weatherToolManifest = { id: "weather_query", name: "天气查询", description: "查询指定城市的实时天气和未来三天预报", triggers: [ { keyword: ["天气", "气温", "下雨", "温度", "穿衣指数"] }, { slot: ["city"] }, // 含城市槽位时优先 ], requiredSlots: ["city"], optionalSlots: ["date"], keywords: ["气象", "weather", "forecast"], };匹配算法由三部分得分加权组成:关键词命中分、槽位完整性分、描述相似度分。别小看这个看似"土"的设计,它在真实项目中比纯向量匹配要稳得多、快得多,而且完全可解释——出问题时你能清楚地告诉别人"这单是因为缺了 city 槽位才没匹配上"。
以天气这个工具为例,一条真实的意图解析与路由记录长这样:
| 用户原始输入 | 意图解析结果 | 匹配到的工具 | 置信度 |
|---|---|---|---|
| 明天下雨吗 | { intent: "天气查询", slots: { city: "北京", date: "明天" } } | weather_query | 0.94 |
| 帮我查一下上海的空气质量 | { intent: "空气查询", slots: { city: "上海" } } | air_quality_query | 0.91 |
| 把周五三点的会改到四点 | { intent: "修改日程", slots: { date: "周五", from: "15:00", to: "16:00" } } | calendar_update | 0.97 |
路由不到工具时,我不会让它直接失败返回,而是触发一条兜底逻辑:把未匹配的意图 + 可用工具清单重新塞给 LLM,让它给出一个调解后的结果,或者诚实地告诉用户"这个我还做不了"。
3.3 工具注册中心:接口的"户口本"和"健康档案"
如果说网关是嘴、路由是脑,那工具注册中心就是 Agent-Reach 的记忆。它记录了所有可用工具的原信息、参数 Schema、健康状态、调用统计。
这个模块平时不显眼,但一旦工具数量超过 20 个,你就知道它的价值了。我用一个简单的存储表来维护:
| 字段 | 示例 | 说明 |
|---|---|---|
id | express_query | 工具唯一 ID,全局不变 |
version | v3 | 工具升级不影响上层 |
owner | 订单组 | 负责方,出问题好找人 |
status | active / deprecated / disabled | 生命周期状态 |
rateLimit | { windowMs: 60000, max: 120 } | 调用配额 |
errorRate | 0.21 | 动态计算的错误率,用于熔断 |
注册中心不只是"存",它还跑一个后台巡检任务,每隔 30 秒调用所有适配器的healthCheck(),一旦某个工具的连续失败率达到阈值,自动把它标记为降级状态——后续路由会把命中请求摘走,让这个服务先喘口气。
这里特别推荐一个做法:给每个工具加上semanticVersion和deprecatedAt。那天有个同事问我"工具版本有什么好管理的",我回了一句:你试过模型已经在按新参数调工具、而你的服务端还在用旧逻辑解析,两边悄悄对不上、排查了半天以为是 AI 玄学的滋味吗?版本管理解决的正是这种问题。
3.4 执行引擎:超时、重试和降级
外部 API 是全世界最不靠谱的东西。我统计过真实项目里的调用数据:完全稳定、从不超时的上游服务只占四成,剩下的要么偶尔 5xx,要么是超时几百毫秒、要么是限流。让 Agent 直接暴露在这样的上游环境里,对话体验会非常糟糕。
Agent-Reach 的执行引擎给每次工具调用做了三层防护:
第一层:超时治理。默认所有调用有 3 秒硬超时,但可以通过timeoutMs字段按工具覆盖。有的查询类工具我给 8 秒,有的写入类工具我给 2 秒。不是随便拍拍脑袋定的——我统计过每个工具的正常 P95 响应时间,然后乘以 1.5 倍加上一点缓冲,这样绝大部分正常调用能从容完成,异常慢调用又不会拖垮整个 Agent 对话。
第二层:分级重试。error.retryable为 true 的错误(典型的是 429 限流、503 临时不可用、连接重置)才会触发重试,业务错误(比如"订单号不存在")不重试,因为重试也没用。重试采用指数退避 + 抖动,第一次等 200ms,第二次 400ms,第三次 800ms,每次加上 ±50ms 的随机抖动。这个抖动太重要了——不加抖动的重试会导致多个请求同时打向限流源,俗称"惊群效应",反而加重限流。
第三层:服务降级。如果三次重试全部失败,执行引擎会按预设的fallbackChain尝试备选方案。比如查天气的weather_query失败,自动降级到weather_cached,返回一份 15 分钟前缓存的天气数据;连缓存都没有,就把上一次成功的结果放上并标注"数据可能不是最新"。模型拿到标注后的数据,会主动向来对话的人解释"这个数据可能不是最新的",这就是 Agent 体验好的真相——不是它聪明,是底层做了兜底。
执行完的每一次调用,都会完整记下耗时、尝试次数、结果和异常明细,作为可观测性的原始日志。
3.5 可观测模块:不追踪就谈不上迭代
可观测性是我最早补上的模块,也是我强烈建议任何做 Agent 项目的人不要拖后腿的部分。没有追踪,出了问题你压根不知道该看 LLM 还是看 API。
Agent-Reach 为每次完整的"用户意图 → 工具调用 → 结果返回"生成一个traceId,链路里每个环节都打点:
- 意图路由耗时(毫秒)
- 路由命中的工具 ID
- 网关转换耗时
- 外部 API 调用耗时
- 重试次数
- 最终结果
有一个指标我特别关注:工具调用成功率分布。把它按工具 ID 聚合,用可视化面板展示最近 7 天的趋势,你能清楚地发现某个工具在周三下午成功率骤降到 60%——再一查,原来是那个上游服务每周三下午做发布。这种问题不靠数据靠猜的话,猴年马月才能定位。
日志我统一用结构化 JSON,其中traceId、toolId、statusCode、durationMs必带。多啰嗦一句:很多团队觉得加日志麻烦,但等线上出了诡异问题,你跪求的往往就是一条带 traceId 的完整日志。
4. 从零搭建一套 Agent-Reach,完整实操过程
4.1 技术选型:TypeScript、Express、Zod 的组合
在动手前我先定好技术栈。选 TypeScript 是因为这个系统的核心就是类型安全——工具参数、外部返回结构、内部消息格式,这些一旦类型写明白,一半的 bug 在编译期就被拦下来了。
Express 选它没有特别高大上的理由,就是生态成熟、中间件丰富,大家上手零门槛。Zod 是重点,我用它来定义工具的参数 Schema,理由有两个:
- 它是声明式的,可以输出 JSON Schema 给 LLM 当工具描述;
- 它有强大的解析能力,LLM 返回的参数有偏差时,
safeParse能告诉你"city 字段类型不对"而不是直接崩溃。
安装依赖:
npm install express zod openai npm install -D typescript tsx @types/express简单说下各依赖的职责:express负责起 HTTP 服务,承载 Agent-Reach 的控制面和 API;zod负责所有参数的运行时校验;openai用来接 LLM 做意图识别,当然你也可以换成任何其他模型提供商的 SDK。
4.2 第一步:定义工具契约(Schema)
按照 Agent-Reach 的设计,所有工具都要先创建一个 Schema。这里我以一个真实的天气查询工具为例,带你走一遍完整定义流程。
import { z } from "zod"; // 1. 定义外部 API 的响应结构(和风天气 API 为例) const WeatherExternalResponse = z.object({ code: z.string(), now: z.object({ temp: z.string(), text: z.string(), humidity: z.string(), }), updateTime: z.string(), }); // 2. 定义工具参数 Schema const WeatherToolParams = z.object({ city: z.string().describe("城市中文名,如:北京"), date: z.string().optional().describe("日期,格式 YYYY-MM-DD,默认今天"), }); // 3. 定义内部响应结构 const WeatherToolResult = z.object({ temperature: z.number(), condition: z.string(), humidity: z.number(), updatedAt: z.string(), isCached: z.boolean().default(false), });这个 Schema 有三个角色:校验模型输出的参数、生成 LLM 工具定义 JSON、定义内部标准响应。
4.3 第二步:实现一个协议适配器
有了 Schema,接下来写天气工具的适配器。这是 Agent-Reach 最好玩的环节——你会看到"外部乱七八糟的 API 如何被驯化成内部统一协议"。
import axios from "axios"; // 外部 API 基础路径(用环境变量管理密钥,别写死在代码里) const QWEATHER_API_KEY = process.env.QWEATHER_API_KEY; const QWEATHER_BASE = "https://devapi.qweather.com/v7/weather"; class WeatherAdapter implements Adapter { // 入参 transformRequest:内部参数 -> 外部 API 查询参数 async transformRequest(req: InternalToolRequest) { const { city, date } = req.params as z.infer<typeof WeatherToolParams>; // 关键点:调用一个本地的"城市编码映射表",把中文城市名翻译成气象服务商用的 cityId // 这个映射表可以是静态 JSON,也可以是个数据库表,由注册中心维护 const cityId = getCityId(city); // 如 "北京" -> "101010100" if (!cityId) { throw new ToolInputError(`不支持的城市: ${city}`); } return { url: `${QWEATHER_BASE}/now`, params: { location: cityId, key: QWEATHER_API_KEY, }, timeout: 5000, }; } // 执行外部调用 async execute(externalReq: ExternalRequest) { const resp = await axios.get(externalReq.url, { params: externalReq.params, timeout: externalReq.timeout, }); return { statusCode: resp.status, data: resp.data }; } // 出参 transformResponse:外部 API 响应 -> 内部标准结构 async transformResponse(res: ExternalResponse) { const parsed = WeatherExternalResponse.parse(res.data); if (parsed.code !== "200") { return { success: false, error: { code: `UPSTREAM_${parsed.code}`, message: "上游天气服务返回错误", retryable: false, // 这种业务错误不重试 }, metrics: { durationMs: res.durationMs, attempt: 1 }, }; } return { success: true, data: { temperature: Number(parsed.now.temp), condition: parsed.now.text, humidity: Number(parsed.now.humidity), updatedAt: parsed.updateTime, }, metrics: { durationMs: res.durationMs, attempt: 1 }, }; } async healthCheck() { // 轻量探测:只需要确认上游服务能连通即可 try { await axios.get(`${QWEATHER_BASE}/now`, { params: { location: "101010100", key: QWEATHER_API_KEY }, timeout: 2000, }); return true; } catch { return false; } } }这只是一个适配器,你再接携程的订单查询、企业微信的日程接口、数据库查询,都是复制这个模板改映射逻辑而已。我建议目录结构按工具分文件夹:
adapters/ weather/ schema.ts // 参数和响应 Schema adapter.ts // 协议适配器实现 express/ schema.ts adapter.ts calendar/ schema.ts adapter.ts这种组织结构在你同时维护五个外部接口时,能让你清晰地知道"改哪、看哪"。
4.4 第三步:意图路由的落地实现
路由模块是"模型输出"和"工具调用的握手点"。以 OpenAI 为例,我把工具清单用 JSON Schema 格式传给模型,让它输出结构化的intent和slots。
import OpenAI from "openai"; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); async function parseIntent(userInput: string) { const toolList = registry.listActiveTools(); // 只拿状态为 active 的工具 // 构造 LLM 的工具定义 const toolsDefs = toolList.map((tool) => ({ type: "function", function: { name: "parse_intent", description: "解析用户输入,生成意图和槽位", parameters: { type: "object", properties: { intentId: { type: "string", enum: toolList.map((t) => t.id), description: "最匹配的工具 ID", }, slots: { type: "object", description: "从用户输入中提取的关键槽位", additionalProperties: true, }, confidence: { type: "number", description: "匹配置信度 0~1", }, notCovered: { type: "boolean", description: "用户请求没有覆盖任何可用工具时为 true", }, }, required: ["intentId", "slots", "confidence", "notCovered"], }, }, })); const response = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: "你是意图解析器。只输出 JSON,不要额外解释。根据用户输入选择最匹配的工具 ID 并提取槽位。", }, { role: "user", content: userInput }, ], tools: toolsDefs, tool_choice: { type: "function", function: { name: "parse_intent" } }, temperature: 0, }); const intent = JSON.parse(response.choices[0].message.tool_calls[0].function.arguments); return intent; }你会发现,这里实际用的手段是Function Calling,而它返回的"工具名"是被我当成"意图标签"在用的。这是一种关键的心态转变:weather_query不是你要执行的函数名,而是一个意图类别。真正对应的适配器,是由注册中心来解析的。
温度设为 0 是因为意图解析不希望有任何"创意",必须尽量确定。模型选出来的未必真的对,所以还要做一步后置校验:
function verifyIntent(intent, userInput) { const tool = registry.getTool(intent.intentId); if (!tool) return { ok: false, reason: "工具不存在" }; // 必填槽位是否齐全? const missingSlots = tool.requiredSlots.filter((s) => !intent.slots?.[s]); if (missingSlots.length > 0) { return { ok: false, reason: "缺少必要槽位: " + missingSlots.join(", "), // 这里返回给模型去反问用户,比如"请问您要查哪个城市?" }; } // 有一个小技巧:字典匹配校验 // 如果工具声明了允许值集合,直接做字符串归一化比对 return { ok: true }; }如果校验失败,Agent-Reach 不会直接杀掉对话,而是把缺少的信息原样交还给 LLM,让 LLM 用一个自然的反问句向用户收集缺失槽位。这就是所谓的"对话式补全"——用户的体验是"这个 Agent 在一步步引导我",而不是"它好像卡住了"。
4.5 第四步:端到端跑通一个天气查询闭环
现在把上面所有模块串起来,整个调用流程是这样的:
app.post("/api/agent", async (req, res) => { const { message, traceId = randomUUID() } = req.body; // 1. 意图解析 const intent = await parseIntent(message); // 2. 后置校验与槽位补全 const check = verifyIntent(intent, message); if (!check.ok) { // 这里可以回调 LLM 追问用户缺失槽位,也可以直接返回让前端去问 return res.json({ traceId, reply: check.reason, needMoreInfo: true, missingSlots: check.missingSlots, }); } // 3. 构造内部请求并走网关 const internalReq: InternalToolRequest = { toolId: intent.intentId, action: "query", params: intent.slots, traceId, }; const result = await executionEngine.execute(internalReq); // 4. 结果是结构化数据,还需要转回自然语言答复 if (result.success) { const reply = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: "把工具结果转成自然友好的回复,控制在一两句内。" }, { role: "user", content: `用户问题:${message}\n工具返回:${JSON.stringify(result.data)}` }, ], temperature: 0.7, }); return res.json({ traceId, reply: reply.choices[0].message.content }); } // 5. 失败时返回降级结果 return res.json({ traceId, reply: "抱歉,查询服务暂时不可用,这是最近一次缓存的数据:" + JSON.stringify(result.data), }); });这套流程跑通之后,你会直观感受到 Agent-Reach 带来的三个变化:
- 接新工具时,改动范围只在"注册中心 + 适配器 + 路由定义",模型侧和对话流程完全不动;
- 外部服务抖动时,Agent 不会突然变成废物,降级机制能保住基本体验;
- 排查问题时有 traceId 全程追踪,你能一眼看出问题到底出在哪一段。
5. 真实踩坑与排查实录
5.1 模型输出总是不合 Schema?三招治它
第一个让我崩溃的问题,就是模型输出的参数和定义的 Schema 对不上。比如我定义了date字段格式是YYYY-MM-DD,模型非给你输出一个"明天"或者"2024/01/01"。
踩过几次坑后,我的方案是三层防线:
- 提示词里明确告知格式,并给出一个示例、一个反例;
- Zod 的
safeParse做宽松解析,比如日期类字段我用z.coerce.date()而不是z.string(),让 Zod 自动做类型转换; - 写一个
repairParams函数作为兜底,当第一次解析失败时,把错误信息重新发给模型,让它自己修正一次。
function repairParams(schema: ZodSchema, rawParams: object) { const parsed = schema.safeParse(rawParams); if (parsed.success) return { ok: true, params: parsed.data }; // 把错误细节回传给模型,让它照着错误改 const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); return { ok: false, issues }; }你可以单独设一个maxRepairTimes = 1,限制修复次数,防止模型无限自嗨。这是我自己项目里配置的参数,你可以按自己对延迟的敏感度调整,但建议不要超过两次——超过之后,多半是该加工具而不是修参数。
5.2 路由命中率低不是模型的错,是"槽位设计"的锅
有一次我上线了一个"查快递"的工具,槽位我设计成{ trackNumber: string }。结果发现用户实际输入的往往是"帮我看看我妈给我寄的到哪了",根本没有单号。路由自然就失败了,模型返回"缺少必要槽位",整个功能形同虚设。
后来我反思,问题不在模型,在于我把工具期待的参数强加给了用户。后来我在路由层加了一个延迟收集策略:当用户输入里缺少必填槽位时,不立刻判定失败,而是根据对话上下文去近几轮消息里寻找可能的值。再找不到,才用追问的方式向用户收集。同时,我把槽位定义改宽了:允许传入{ sender: "我妈" }这种模糊描述,然后由执行层的"单号解析器"去后台关联。
这类问题在设计 Schema 时就要多问自己一句:真实用户的输入长什么样,而不是我的后台接口需要什么参数。这两个角度之间的差距,就是 Agent 体验的天堑。
5.3 外部 API 超时引发"Agent 崩溃链"
有个版本我对超时设置非常宽松,一个查询类工具给了 15 秒超时。结果那天上游服务挂了,整个 Agent 接口被拖了 15 秒才返回错误,而上游一挂,50 个并发直接把我看板上的错误率拉红。
这个问题的教训是:Agent 的外部调用不是内部异步任务,它在用户对话的路径上,晚一秒都会体现在体验上。我的建议是:查询类工具统一 3~5 秒,写入类工具可以稍长但不超过 10 秒;一旦超时,立刻触发降级分支,绝不让用户在盲等中度过。
还有一次"崩溃链"是重试参数配得太大:某个工具 5 次重试 + 每次等待 1 秒,再加上本身 4 秒超时,一次失败调用就是 9 秒起步,还把上游打到半死。后来我学乖了,重试上限一律不超过 3 次,而且每次重试前都会检查一次error.retryable——之前提到过的那个字段,就是为这个准备的。
5.4 安全边界:让 Agent 只触达该触达的
做触达层,最大的责任是不要让 Agent 触达不该触达的东西。
我遇到过一个真实教训:给某内部系统接了一个"查询用户信息"的工具,Schema 里传user_id就能查到对应手机号。后来有用户变着法子让 Agent 查询其他用户的信息,被提示词注入 + 槽位猜测钻了空子。从那时起,我给 Agent-Reach 加了一套强制规则:
- 敏感字段脱敏:查询结果里的手机号、身份证等,在网关
transformResponse阶段直接打码,Agent 压根看不到原始值; - 权限令牌与工具绑定:每个工具的 API Key 或 token 是独立管理的,调用时按用户角色组装权限,而不是让一个万能 token 贯穿所有工具;
- 禁调名单:注册中心支持
dangerousActions声明,比如"删除""批量导出",这些 action 在路由阶段就直接拒绝,除非请求上下文中带有 admin 标识。
别觉得这是过度设计——等到出安全事故再去补,代价就大了。我给所有使用 Agent-Reach 的朋友一个建议:工具能读到的最小权限,就是你该给的最小权限。一个只读天气查询,就不要给它传读数据库的凭据。
5.5 问题排查速查表
| 现象 | 大概率原因 | 排查路径 |
|---|---|---|
| 路由到一个完全不对的工具 | 槽位 Schema 覆盖不足、意图解析温度过高 | 看意图解析原始输出,检查temperature=0是否被改动 |
| 工具调用成功但返回内容不相关 | 适配器字段映射错误、外部 API 返回结构变化 | 对比外部 API 文档与transformResponse映射关系 |
| Agent 回复并发线高 | 场景为长时间的外部调用 | 检查重试等待和超时配置,看图表追踪durationMs |
| 同一个工具部分用户可用、部分不可用 | 权限令牌隔离导致 | 查看请求上下文中的用户角色与工具权限绑定关系 |
| 偶发失败但重试后成功 | 上游限流或瞬时抖动 | 检查retryable错误码是否为 429/503,增大退避抖动 |
日志里大量ZodError | LLM 输出参数不合 Schema | 启用repairParams兜底,优化提示词中的格式示例 |
| 某个工具突然从路由里消失 | 注册中心健康巡检判定为降级 | 查看status是否为disabled以及errorRate数值 |
6. 进阶扩展:从单机到多 Agent 触达的世界
Agent-Reach 目前还是一个单体接入层,但如果你在团队里使用,有几件事值得做。
第一,让多个 Agent 共享同一个触达层。与其每个 Agent 项目都重新接一遍工具,不如把 Agent-Reach 部署成一个独立的服务,多个 Agent 应用通过 HTTP/gRPC 调用它。这样工具的所有权归一,任何一个工具升级,只改一处。我目前就在公司里这么用,每周节省的重复对接工时肉眼可见。
第二,工具配额与费用治理。接入的工具越多,你会发现成本越不可控。有些外部 API 按次计费,模型短路反复调用时能烧掉不少钱。解决办法是在执行引擎里加配额中间件,按用户维度设置每日调用上限,超限自动降级为缓存或拒绝。我自己的做法是给每个工具定义costPerCall字段,在仪表盘上按天汇总,看到某个工具的费用异常增长时,能第一时间溯源到是哪类用户、哪个意图在触发。
第三,连接 MCP 生态。MCP(Model Context Protocol)这两年已经成为模型接入外部工具的标准化协议。Agent-Reach 的适配器体系天然适合加一个 MCP 适配器——把 MCP 服务器暴露的工具自动注册进来,当作普通工具一样路由、执行、监控。等于说,你的 Agent-Reach 可以吸收整个 MCP 生态里的现成工具,这是扩展性最强的一条路。
第四,多 Agent 互相触达。当你有多个职责不同的 Agent 时,可以让一个 Agent 以一个"工具"的姿态出现在注册中心里。比如"供应链策略 Agent" 可以被 "订单 dashboard Agent" 当作工具来调用。这个设计其实不需要额外发明东西——把 Agent 的对外接口包一层适配器,它就变成一个工具了。
做了一段时间之后,我认为 Agent-Reach 最重要的一份价值,不在于某个具体算法或模块,而是它把"接入外部世界的这种脏活"变成了一个工程化、可管理、可观测的过程。如果你正在做的 Agent 项目正处于"接口乱成一团、模型经常犯错、出了问题不知道怎么查"的阶段,不妨按这篇文章的路线搭一个简化版的接入层,你会立刻感受到差别。
最后一个小建议:不要一开始就接三十个工具。先把三个不同类型的工具(一个查询类、一个写入类、一个列表类)跑通闭环,感受一下路由、重试、降级、追踪这一整套机制,再逐步扩容。这个节奏,比我当初一上来就铺全量工具要舒服得多。