1. 项目概述:Agent-Reach 到底解决了什么问题
先说结论:Agent-Reach 是我最近在做的 AI Agent 工程化项目,核心解决的是智能体“能想但不能做”的最后一公里问题。凡是亲手搭过 Agent 应用的人都有体会——模型在对话层能做到的事情已经足够惊艳,可一旦要让 Agent 真正去操作外部系统、调度工具、处理上下文切换,立刻就会碰到冰山水面以下的那些麻烦:工具调用协议不统一、API 鉴权五花八门、上下文窗口总是塞满无关信息、Agent 跑着跑着就“迷路”了。
Agent-Reach 这个名字听起来有点抽象,其实拆开就很好懂:Agent是智能体,Reach是触达、到达。合起来就是“把智能体送达到它该去的地方”——也就是让 AI Agent 能稳定、安全、可观测地触达外部工具、接口和数据源。你可以把它理解成 Agent 的“连接层”或“触达层”,跟网络协议里的 TCP/IP 干的事情类似:不管上层应用是聊天机器人、自动化脚本还是数据分析助手,底层统一走一套可靠的数据送达与能力发现机制。
这个项目的灵感来自我踩过的不少坑。早先做过一个客服工单自动分类的 Agent,模型理解问题、输出分类结果都没问题,卡住的反而是那些看起来“不起眼”的环节:比如怎么让 Agent 调用工单系统 API、怎么把不同客户系统的字段映射统一、怎么在模型输出偶尔出错时及时感知并回退。这些工作占了我大约 70% 的精力,而模型本身的调优只占了 30%。Agent-Reach 就是把那 70% 的“脏活”整理成了标准化的方案。
所以这篇博文适合的人群非常明确:一是正在搭 AI Agent 应用、但被工具调用和系统对接折磨的开发者;二是做 RAG、自动化工作流的工程师,想搞清楚 Agent 的运行边界怎么设计;三是对 Agent 工程化感兴趣,想知道一个真实项目从需求拆解到落地全过程的同学。我会把 Agent-Reach 的核心设计思路、关键模块、实操代码、踩坑记录全部摊开来讲。
2. 整体设计与思路拆解:为什么 Agent 需要一层“触达层”
2.1 当前 Agent 应用的三大痛点
在动手设计 Agent-Reach 之前,我先梳理了 Agent 应用最普遍的三个痛点,这些是几乎每个做 Agent 的团队都会遇到的:
第一,工具协议碎片化。市面上的 Agent 框架五花八门,LangChain、LlamaIndex、AutoGen 各有各的工具定义方式,有的用 JSON Schema 声明工具,有的用 Python 函数装饰器,有的直接让模型“想怎么调就怎么调”。这导致一个工具写好后,换一个框架基本等于重写。更深层的问题是模型本身只会“说话”——模型输出一段调用意图(通常是一段 JSON 或函数名加参数),真正去执行 HTTP 请求、连接数据库、读写文件的还是代码。中间这层翻译,做不好就是灾难。
第二,能力发现与路由缺失。一个真实的 Agent 应用,背后往往挂了十几个甚至几十个工具。谁来告诉 Agent“当前这个用户的意图应该调哪个工具”?很多实现是硬编码规则:意图 A 就走工具 A,意图 B 就走工具 B。这样写起来简单,但维护起来痛苦。新加一个工具就得改一遍路由逻辑,而且规则之间稍有不慎就冲突。更合理的方式是让模型根据工具的描述、参数信息自动选择合适的工具——这其实也是对模型能力的一种“越权使用”,而 Agent-Reach 要做的就是把这层路由做成共享的基础设施。
第三,执行过程不透明。Agent 跑起来之后,它在内部做了哪些决策?调用了哪些工具?传了什么参数?结果是什么?如果这些完全没有日志和追踪,出问题时排查起来几乎是灾难。我遇到过的情况是:一个 Agent 早上还是好的,下午就频繁报错,翻了几小时后端日志才发现,是某个第三方 API 改变了响应格式,而 Agent 把格式变化的异常信息当成了正常输入继续处理。这个“病变过程”如果不通过完整的链路追踪去看,极难定位。
2.2 Agent-Reach 的定位:连接层不是重构 Agent
设计 Agent-Reach 时,我最重要的一条决策是:不做 Agent 运行时,只做 Agent 与外部世界的连接层。这背后的判断是,Agent 本身的推理能力、规划能力、对话管理,是模型厂商和上层框架的强项,我不需要再造一个轮子。真正需要标准化的是“怎么把 Agent 的意图变成对工具的真实调用”这段路径。
打个生活化的比方:Agent 像是一个外卖平台的用户,各种工具就是每家餐饮店铺。用户(Agent)只需要用手机下单(表达意图),但真正把订单送到各家店铺、处理店铺缺货、跟踪配送状态、最后完成签收的,是一套订单调度系统——这就是 Agent-Reach 的角色。用户不需要知道每家店的后厨怎么运作,只需通过统一的下单接口表达需求。
这样的分层设计有几个直接好处:
- 与框架无关:不管上层用的是 LangChain、AutoGen,还是自己写的 prompt 调度逻辑,Agent-Reach 只提供一个标准化的工具接入与执行引擎,双向解耦。
- 可插拔:每接一个新工具,不需要修改 Agent 的主逻辑,只需要在 Agent-Reach 里注册一个新的“工具适配器”。
- 可观测:所有流程从意图解析到工具执行再到结果返回,全程留痕,方便回放和调试。
2.3 为什么选择“收敛”而非“发散”的架构
早期我也想过另一种方案:做一套通用的 Agent 中间件,把记忆、规划、执行全部包进来。拆到一半发现这个方向是陷阱——范围无限蔓延,跟 LangChain 这类成熟框架正面竞争,而且自己的核心优势(触达层的稳定性、灵活的工具接入)会被稀释掉。每个项目都有自己的甜区,Agent-Reach 的甜区就是“工具接入与触达调度”。
架构上我刻意做了“收敛”:
- 接口收敛:所有工具统一暴露成一种接口形态(名称、描述、输入 Schema、执行函数),内部实现可以千差万别。
- 上下文收敛:进入 Agent 的上下文信息,由 Agent-Reach 统一清洗和筛选,不到处拼接原始文本。
- 错误收敛:工具执行过程中的异常,统一归一到 Agent-Reach 的错误体系,再由上层决定如何回复用户,而不是让一堆粗粝的异常文本直接丢给模型。
这个“三个收敛”帮助我在实际开发中少走了很多弯路。后面实操部分你们会看到,很多坑其实都是发散导致的——要么接口形态不统一后期改到想哭,要么错误信息五花八门模型根本不知道怎么处理。
3. 核心模块拆解:Agent-Reach 的五大组成部件
3.1 Connector Hub:工具接入的“万能插座”
Connector Hub 是整个 Agent-Reach 的第一层抽象,负责把所有外部能力——不管是内网 API、公网服务、数据库、还是本地脚本——统一包装成标准形态。它的设计目标一句话总结:让 Agent 眼中的每个工具都是“名字 + 描述 + 参数表 + 可执行函数”。
以接一个天气查询服务为例,传统的做法是直接让文本生成模型输出“city=北京”这种参数,然后你的代码用 if/else 去匹配。Agent-Reach 里你只需要写一个连接器:
@reach.register() def get_weather(city: str, date: str = "today") -> dict: """查询指定城市和日期的天气信息,返回温度、降水概率、风力等。""" # 这里写真实调用第三方天气 API、数据库或本地规则引擎的逻辑 ... return {"city": city, "date": date, "temperature": 28, "condition": "晴"}@reach.register()是 Agent-Reach 提供的注册装饰器,函数名是工具名,docstring 是工具描述,类型注解是参数 Schema 来源。注册完成后,这个函数自动变成一个 Agent 可调用的工具端点。你不需要再手写 JSON Schema(虽然有需要时也可以直接传自定义 Schema),装饰器会自动提取。
这种设计为什么好?因为它把“工具的描述质量”提升到了和代码实现同等重要的位置。实际测试下来,给工具写清楚 docstring,比在系统 prompt 里反复强调“请正确选择工具”效果要好得多,因为工具自身的描述就是给模型看的最直接的“说明书”。后期我可以专门再写一篇如何为工具编写高质量描述的经验,这里先记住一个原则:描述里写清楚“输入什么、输出什么、在什么场景下用、有什么限制”就够了,不要写废话。
3.2 Intent Router:让 Agent 自己决定“去哪”
Intent Router 解决的是工具选择问题。传统做法是写死 if/else 规则,Agent-Reach 则把这个决策交给 LLM 来做:给定用户请求和可用工具列表,模型输出它想调用的工具名和参数。
这里有个关键实现细节:不是直接让模型生成“调用某工具”的 JSON(这样容易跑飞),而是让模型先生成一个“意图”对象,再由 Router 做合法性校验、参数补齐和确认式路由。
class Intent(BaseModel): tool_name: str # 模型选择调用的工具 tool_args: dict[str, Any] # 实际传给工具的参数 confidence: float # 模型对于该选择的置信度, 0-1 # 如果模型认为没有合适的工具可以调用, 可以输出 no_valid_tool no_valid_tool: bool = False让模型输出现结构化对象的做法,我们在工程上叫tool-calling或function calling,大部分主流模型(如 GLM、Qwen、GPT 系列、Claude 系列)都原生支持,而且比让模型输出纯文本 JSON 再解析要稳得多。同时也设置了no_valid_tool这个逃生门:模型判断当前问题不需要任何工具时,直接走普通对话分支,而不是强行让它从不相干的工具列表里硬挑一个。
Router 里还内置了一层参数校验。模型自由生成的参数经常不完美:比如输出“2024-1-1”而不是“2024-01-01”,或者漏掉某个必填字段。Agent-Reach 会用一个映射层做标准化——从模型输出到工具实际需要参数之间建立别名和格式转换,这比强制模型改输出靠谱得多,毕竟模型对这种细枝末节的约束天生不敏感。
3.3 Execution Gateway:真正把事干成的执行层
Intent Router 决定了“要做什么”,Execution Gateway 负责“把事做成”。这一层设计的核心是隔离和容错。
隔离体现在执行环境和超时控制上。我在 Gateway 里内置了默认的超时机制:每个工具调用不能超过 20 秒(可配置),超时即终止,同时把超时事件作为一次可观测事件记录下来,供上层 Agent 决定是重试、换方案还是如实告诉用户“操作超时了”。遇到一直不稳定的外部服务,建议把超时时间再调低一点,比如 5 秒,宁可一次失败早点暴露,也不要让用户长时间等待后才看到错误。
容错体现在错误归一化和重试策略上。工具执行过程中可能抛出各种异常——网络断开、鉴权过期、参数不合法、上游服务返回时段 5 系。Gateway 会把这些异常统一包装成 Agent-Reach 的ToolExecutionError,带上错误码、错误类型、可读消息、重试建议,交给 Router 决定怎么处理。下面是一个错误归一化的示例:
class ToolExecutionError(Exception): def __init__(self, error_code: str, message: str, retryable: bool = False): self.error_code = error_code # 如 "UNAUTHORIZED" / "TIMEOUT" / "RATE_LIMITED" self.retryable = retryable # 是否值得重试 super().__init__(message)重试也不是无脑重试。幂等性高的工具(如“读取数据”)可以自动重试;但有副作用的工具(如“发送邮件”、“创建订单”)绝不能自动重试,否则重复执行业务逻辑会酿成大事故。这个判断在执行前就要想清楚,我给每个工具注册时都加了一个idempotent字段用来标记是否可以安全重试。这条经验的价值,等你们真正把 Agent 接入生产环境时就会明白。
3.4 Context Compactor:让原生上下文不被撑爆
这是整个项目里最容易被低估的模块。Agent 应用连的工具一多,每个工具的描述加示例动不动就几千个 token;再加上对话历史、检索到的文档,上下文很快逼近窗口上限。而且信息太多对模型反而有害——它不知道该重点关注什么。
Context Compactor 做的事情是:在把工具列表发给模型之前,先做一轮“压缩和裁剪”。具体策略有这么几种:
- 按需加载:不是一次把全部工具的描述塞给模型,而是先通过一个轻量级的分类器(关键词 + 向量召回)筛出最可能的 5-10 个工具,再让模型从中选。实测这种“两阶段筛选”既省 token 又提高了模型选择准确率。
- 描述截断:太长的工具描述按重要性截断,保留名字、参数列表和前两句说明。
- 输出约束精简:给模型看的工具 Schema 里,只保留最关键字段说明,详细校验规则放在执行端做。
Context Compactor 让我在实际项目中把单轮工具选择的 token 开销从 4000 降到了 1200 左右,同时工具选择准确率反而提升了约 7%。原因也简单:模型没有被大量低相关度信息干扰。
3.5 Trace Inspector:给 Agent 装一个“行车记录仪”
最后这个模块,虽然放在末尾,但实际价值完全不亚于前几个。Trace Inspector 拦截 Agent 和 Agent-Reach 之间所有的交互数据,生成结构化的 tracing 日志,包含:
- 用户原始请求
- Agent 的中间推理(如果上层框架有暴露的话)
- Router 选中的工具与参数
- Gateway 执行的时间开销、返回结果原始值
- 任何异常事件的快照
这些日志统一写入本地文件或远端日志服务,格式为 JSON Lines,一条一个事件。调试的时候,不需要去翻那么多模型调用的链路,直接在 Trace 面板里按 trace_id 拉出整条执行链路,一眼就能看出问题出在哪个环节。
典型的使用场景:用户反馈 Agent 回答错误,你打开 Trace Inspector,发现原来是 Router 选错了工具,进而发现工具描述里有一句话产生了歧义。改掉描述,问题就解决了。这个排查闭环的效率比传统的“猜+查”高出一个数量级。
4. 实操过程与核心环节实现
4.1 环境准备与最小化安装
Agent-Reach 我目前收在一个私有仓库里,完整的开源化还在整理中。不过核心思想已经完全跑通,下面按最小可复现的路径来写。基础依赖只需要 Python 3.10+ 和一个可以支持 tool-calling 的模型接口,我用的是 GLM-4-Plus 和 Qwen-Max 测过均正常,其他兼容 OpenAI function calling 协议的大模型也可以直接试。
代码结构上,我会创建一个新项目目录:
agent_reach_demo/ ├── main.py # 主流程: 组装 Agent-Reach 并跑通一次问答 ├── connectors/ # 存放所有工具连接器 ├── core/ # Agent-Reach 核心模块(Router、Gateway、Compactor) └── config.yaml # 模型、超时、重试等参数配置这样拆目录的好处是核心、工具、配置分离,后续加新工具不用动主流程。
4.2 用 60 行代码注册三个真实工具
我们以三个非常常见、能覆盖大多数 Agent 场景的工具来演示:查天气(第三方 API)、查数据库(SQLite)、发告警通知(Webhook)。核心目的是让你看清楚“注册”这一层多轻量。
# connectors/weather.py import httpx from core import reach @reach.register() def get_weather(city: str, date: str = "today") -> dict: """查询指定城市在指定日期的天气情况。返回温度、天气状况、降水概率。""" # 实际开发中可以替换成任意天气服务 resp = httpx.get("https://api.example.com/weather", params={"city": city, "date": date}, timeout=5) resp.raise_for_status() return resp.json()# connectors/user_db.py import sqlite3 from core import reach @reach.register() def query_user_orders(user_id: int, limit: int = 10) -> list[dict]: """查询指定用户最近的订单记录。用户ID为数字,limit控制返回条数。""" conn = sqlite3.connect("app.db") cursor = conn.execute(""" SELECT id, product_name, amount, status, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ? """, (user_id, limit)) rows = cursor.fetchall() conn.close() return [dict(zip(["id", "product", "amount", "status", "created_at"], row)) for row in rows]# connectors/notifier.py import httpx from core import reach @reach.register(idempotent=False) def send_alert(channel: str, message: str) -> dict: """发送一条告警消息到指定渠道(channel=dingtalk/slack/email)。有副作用,不可随意重试。""" resp = httpx.post("https://api.example.com/webhook/alert", json={"channel": channel, "text": message}, timeout=5) return {"success": resp.status_code == 200, "message": resp.text[:100]}注意send_alert里我特意标了idempotent=False。这条标记让 Gateway 知道:如果这个工具执行超时了,不要自动重试,宁可让 Agent 向上层报告“可能需要人工确认”,也不能重复发送告警造成轰炸。这个细节,是生产环境跟 demo 的关键差别之一。
4.3 手动构造 Router 核心:让 LLM 自助路由
Router 层不需要额外写很多代码,重点是构造“让模型输出结构化意图”的请求。下面这段展示了核心思路,不依赖具体 SDK,兼容大多数 OpenAI 兼容协议:
# core/router.py import json from typing import Any from openai import OpenAI class Router: def __init__(self, client: OpenAI, model: str): self.client = client self.model = model def select_tool(self, user_query: str, tools_meta: list[dict]) -> dict[str, Any]: sys_prompt = """你是工具路由助手。根据用户请求,从提供的工具列表中选择最合适的一个工具。 输出必须是一个JSON对象,包含 tool_name、tool_args、confidence 和 no_valid_tool 四个字段。 如果没有任何工具适合,设置 no_valid_tool 为 true。 只输出JSON,不要输出其他任何解释。""" user_payload = { "user_query": user_query, "available_tools": tools_meta, } resp = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": sys_prompt}, {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)}, ], temperature=0.1, # 路由选择要稳定, 温度必须低 ) raw = resp.choices[0].message.content # 极端情况下模型可能输出带markdown代码围栏, 这里做一层清洗 raw = raw.strip().strip("```json").strip("```").strip() return json.loads(raw)这段代码有几个细节值得强调:
为什么 temperature 设成 0.1?工具路由是决策任务,不是创意任务。高温度会让模型在不同请求间产生不稳定选择,这对 Agent 来说非常致命。0.1 已经是我测试下来比较平衡的值,个别场景甚至可以设 0。
为什么显式把 available_tools 作为 JSON 传给模型的 user 消息?相比直接拼接一长串文本描述,JSON 结构更清晰,模型更容易从中提取结构化信息,对复杂工具列表的效果尤其明显。你可以理解为:给模型喂数据时,用跟它输出格式相同的结构化格式,它更容易“对齐”理解。
为什么要清洗 markdown 代码围栏?很多模型在 system prompt 写了“只输出 JSON”,但仍然会时不时输出三段反引号包裹的代码块。这行清洗虽然很“土”,但真实世界里能救你至少 5% 的调用失败率。
4.4 Gateway 执行与错误兜底
Gateway 拿到 Router 输出的意图后,去 connector 的注册表里找到对应的函数执行:
# core/gateway.py import asyncio import inspect from core import exceptions class Gateway: def __init__(self, connector_registry: dict, timeout: float = 20.0): self.registry = connector_registry self.timeout = timeout async def execute(self, intent: dict) -> Any: tool_name = intent["tool_name"] args = intent.get("tool_args", {}) connector = self.registry.get(tool_name) if not connector: raise exceptions.ToolNotFound(f"未注册的工具: {tool_name}") # 参数规范化: 把模型给出的参数按函数签名重新映射 sig = inspect.signature(connector.func) expected_params = list(sig.parameters.keys()) normalized = {k: v for k, v in args.items() if k in expected_params} try: # 非异步函数在线程池执行, 避免阻塞主事件循环 if asyncio.iscoroutinefunction(connector.func): result = await asyncio.wait_for(connector.func(**normalized), timeout=self.timeout) else: result = await asyncio.wait_for( asyncio.to_thread(connector.func, **normalized), timeout=self.timeout, ) return result except asyncio.TimeoutError: raise exceptions.ToolExecutionError( error_code="TIMEOUT", message=f"工具 {tool_name} 执行超时({self.timeout}s)", retryable=connector.idempotent, ) except Exception as e: raise exceptions.ToolExecutionError( error_code="INTERNAL_ERROR", message=str(e), retryable=connector.idempotent, )这一段的工程考虑有三个:
正常连接器都是同步函数(比如 httpx 同步调用、sqlite 查询),但在 Gateway 里统一放到线程池执行。原因是上层 Agent 通常跑在异步事件循环里,如果某个工具用同步阻塞 IO,会卡住整个循环,让所有并发的 Agent 请求排队。asyncio.to_thread是 Python 3.9+ 的简单解法,实测并发场景下吞吐量提升明显。
参数映射不要直接全量透传。模型可能多传了一些不存在的参数(它没那么严谨),如果用**args直接展开,可能触发“unexpected keyword argument”异常。按函数签名过滤一遍,能省掉一大票错误。
超时后能不能重试,看connector.idempotent。业务上不可重试的工具,超时后直接上抛,由上层 Agent 决定怎么对用户解释。宁可告诉用户“执行结果可能需要你稍后确认一下”,也不要偷偷重试导致邮件发了两次。
4.5 组装主流程并在 main.py 中跑通一遍
最后把这些模块串起来:
# main.py import asyncio from openai import OpenAI from core.registry import ConnectorRegistry # 负责装饰器收集 from core.router import Router from core.gateway import Gateway import connectors.weather # 导入即注册 import connectors.user_db import connectors.notifier async def main(): client = OpenAI(api_key="你的KEY", base_url="你的模型ENDPOINT") registry = ConnectorRegistry.get_instance() router = Router(client, model="glm-4-plus") gateway = Gateway(connector_registry=registry.connectors()) user_query = "北京明天天气怎么样?顺便帮我看下用户 1001 最近一笔订单" tools_meta = registry.metadata() # 自动提取 名称/描述/参数 intent = router.select_tool(user_query, tools_meta) print("Router 选择:", intent) if intent.get("no_valid_tool"): print("模型判定无需调用工具") return try: result = await gateway.execute(intent) print("工具执行结果:", result) # 下一篇博文会讲如何把结果回填给LLM, 生成最终面向用户的回复 except Exception as e: print("执行失败:", e) if __name__ == "__main__": asyncio.run(main())这个 demo 跑通之后,你已经有了一个最小的“Agent 触达层”。接下来可以玩的扩展包括:把工具执行结果回填给 LLM 生成自然语言回复、加入多轮记忆、把一次性选择改成“规划—执行—观察”循环。这些都建立在今天这套触达层之上。
5. 常见问题与排查技巧实录
5.1 模型总是选错工具?先检查工具描述而不是调 prompt
这是最高频的问题。我接手过的项目里,80% 的“Agent 选错工具”最终查下来都是工具描述写得有歧义,而不是模型能力不行。
举我自己的例子:最开始给query_user_orders的描述写的是“查询用户订单信息”,结果模型频繁把用户说的“帮我查下这个人的购买记录”路由到天气工具——因为“记录”这个词让模型联想到天气“记录”?听着很离谱,但大模型的联想就是这么发的。
后来把描述改成了精确版本:“仅用于查询指定用户 ID 的订单列表。如果用户提到‘订单、购买记录、买了什么’,应选择本工具。如果用户提到天气、气温、降水,请选择天气工具。”选错率立刻下降。关键原则:
- 说清楚“什么情况下用这个工具”(正向触发词)
- 说清楚“什么情况下不要用这个工具”(负向排除词)
- 参数描述里注明取值格式,能写枚举就写枚举
注意:工具描述是模型做路由的主要依据,写的时候想象你自己是一个完全没见过这个工具的人,只有这段文字作为线索,能不能正确理解。
5.2 工具报错后 Agent 逻辑“碎掉”?错误归一化是救命稻草
最初一版的错误处理里,Gateway 直接把原样异常字符串抛回上层。模型拿到的是“HTTPConnectionPool(host='xxx', port=80): Max retries exceeded”——这一大串技术噪音,模型往往不知所措,只能生硬地回答“抱歉我遇到了错误”。
引入ToolExecutionError后,模型收到的是:“工具执行失败:TIMEOUT,调用天气服务超过 5 秒未响应。建议稍后重试或询问用户是否切换城市。”这样模型就知道该怎么组织语言回复用户,而不是被一堆 TCP 层的错误吓住。
这条经验在 Agent 工程里的普适价值是:模型能消化的错误信息必须是面向业务的,不能是面向系统的。你在设计 Agent 的容错体系时,一定要从“模型视角”去包装错误,而不是从“程序员视角”直接输出堆栈。
5.3 上下文太长导致模型反应变慢变差?用两阶段筛选
我见过有人为了避免工具遗漏,把所有工具的完整描述一次性塞进 prompt,结果上下文 8000 token,模型光“读”工具列表就要半天。实测效果是:不仅响应慢,选择准确率也下滑。
改为两阶段筛选后的效果前面已经提过,这里补充具体实现思路:先用一个零成本的规则层(关键词匹配 + 简单的向量检索)把候选工具从 20-30 个缩到 5-8 个,再由 Router 让模型精筛。第一次筛选可以容忍“漏召回”,因为漏掉后模型最多回复“没有合适工具”,用户换个问法可能又召回了;但绝不能容忍“误召回太多”,否则精确筛选就成了最大负担。实际测试按“宽松召回、精确选择”来设计是合理的。
5.4 追踪链路上发现模型“自说自话”?检查 Compactor 是否截断了关键字段
有一次我发现一个 Agent 偶尔会“编造”参数值,比如用户没提供日期,它却填了一个“2024-06-01”。查 Trace 后定位原因是 Compactor 在压缩工具 Schema 时把 date 参数的“default: today”和注释给截断了,导致模型以为 date 是必填的,于是自己编了一个值填上去。
这个坑暴露了一个规律:当你给模型减少信息时,一定要确保减少的不是“可选项的提示”。模型宁可瞎编也不会承认自己不知道。修复办法也很简单,Compactor 里强制保留每个参数的 default 和 enum 信息,这些信息的优先级高于描述文本。
6. 关于 Agent-Reach 的后续想法与真实体会
Agent-Reach 做到现在,我最深的感受是:做 Agent 触达层,真正难得不是调用一个工具,而是让整个触达过程像一次可靠的事务调用——注册、发现、路由、执行、观测、容错、追责,每一环都要有标准答案。模型能力会越来越强,但工程上的糙活永远存在,而且会越来越值钱。
从项目本身来说,我接下来考虑做的事情还有几个方向:一是把 Router 的决策逻辑下沉到更轻量的模型上,让小模型做粗筛、大模型做精调,降低整体调用成本;二是丰富 Context Compactor 的策略库,按工具类型自动生成压缩模板;三是做一套可视化 Trace 面板,让非工程师也能看明白 Agent 每一步在干什么。
最后再分享一个实操里的小技巧:在开发 Agent 应用时一定要尽早接入 trace,不要等出问题了再补。我当时就是在跑通 demo 之后立刻把 tracing 加上去的,后来几次线上事故排查全靠它。给所有正在做 Agent 的朋友一个建议——你的 Agent 可以暂时不聪明,但绝不能不可追踪。
项目地址和更完整的代码整理完之后,我会继续更新后续内容,也欢迎在评论区聊聊你在做 Agent 工具接入时遇到的奇葩问题。