很多人以为 Agent 项目最难的是模型选型,我做了 Agent-Reach 这个项目以后才明白:真正卡住落地的是“触达”——让 Agent 真正够得着工具、数据、系统,并在安全可控的范围内完成动作。这是个围绕智能体触达能力设计的工程实践项目,主要解决 Agent 从“会聊天”到“会干活”的过程中,工具接入混乱、权限失控、调用不可观测、上下文被撑爆这一类问题。适合正在做 Agent 落地的工程师、技术负责人,以及被工具调用链路折腾过的人。
Agent 在这里指 AI Agent,也就是智能体;Reach 是触达半径的意思。我希望通过这套设计,把 Agent 的行动边界做得既宽又稳,宽到能覆盖业务系统,稳到每次触达都有授权、有记录、有兜底。
1. 为什么要做 Agent-Reach
1.1 从“会聊天”到“会干活”的最后一公里
大模型真的很能聊,但你让它“帮我从销售系统导一下昨天的数据,按区域汇总后发到群里”,它大概率只能给你一段 SQL 或者一份操作说明,而不是真的把数据拉出来。原因是模型本身不连接业务系统,它没有工号,没有门禁卡,不知道你的报表服务地址是什么。
我见过太多团队把 Agent 做成“带工具的高级聊天框”:接了 Function Calling,注册了十几个函数,demo 时看起来很惊艳,一上生产就翻车。为什么?因为工具调用不是简单地给模型加几个函数。模型要完成一次真实任务,至少要走通这条链路:理解用户请求、找到可用工具、填对参数、通过权限校验、调用真实接口、处理返回结果、把结果转成用户能理解的回答。每一环都是一个触点,任何一个触点断了,整个任务就废了。
Agent-Reach 这个项目要解决的,正是这条链路的工程化问题。不碰模型训练,不碰业务流程改造,只做连接这一层。你可以理解为:模型是大脑,Reach 是手脚和感官。没有手脚的 Agent,再聪明也只能停留在建议阶段。
1.2 现有方案为什么不够用
市面上常见的做法有几类,我都试过,各有各的坑。
直接用 Function Calling 接工具,最省事,但工具数量一多,模型就开始糊涂。你塞给它 30 个函数定义,它经常挑错、漏参,甚至编造一个不存在的函数名。RAG 能解决“知识触达”,让 Agent 能引用文档内容,但它解决不了“动作触达”——查完文档之后还是要调接口、点按钮、发消息,这一步 RAG 帮不上忙。RPA 能模拟人工操作,但它本质是固定流程的自动化,缺一个“根据上下文灵活决策”的前端。Agent-Reach 的思路,是把触达能力本身当成一个独立工程域来做,而不是散落在 prompt 和业务代码里的边角料。
具体来说,Agent-Reach 做了四层设计:接入层负责对接模型和用户入口,触达层维护一套统一工具协议,路由层决定“这次任务该用哪些工具”,治理层管权限、审计、熔断和可观测。每一层各管一摊,互不越界。
1.3 我给这个项目定的目标和边界
动手之前,我先定了三个原则,后面所有设计都是围绕这三条展开的。
第一,模型做主。具体选择哪个工具、怎么组织参数,交给模型决策,不硬编码业务流程。第二,框架做边界。工具注册、协议校验、权限强制检查都在框架层完成,不能依赖模型自觉。第三,平台做审计。每一次触达行为都要留痕,能回放、能复盘、能追溯。这三条看起来简单,但越往后做越发现,边界感是 Agent 工程化最容易出问题的地方。
另外我也划了清晰的边界:不训练微调模型,不改造现有业务系统的内部逻辑,不替代现有权限系统。Agent-Reach 的目标是当一个合格的“连接总线”,而不是什么都往里装。
2. 整体设计与核心机制
2.1 “工牌、门禁、监控”三层设计
我给 Agent-Reach 定下的架构,你可以想象成一个园区。模型是员工,业务系统是园区里的一间间办公室,Agent-Reach 负责发工牌、设门禁、装监控。
工牌对应工具注册中心。每个工具进来的时候都要登记,登记内容包括工具名称、功能描述、参数 Schema、所需权限、超时时间、执行方式。没有工牌的工具,模型再想用也调不动。门禁对应当路由与权限服务。模型提交“我想用哪个工具、带什么参数”之后,门禁先查权限,再查参数合法性,最后才放行。这个顺序不能乱。监控对应当审计与追踪服务,负责记录每一次触达的完整上下文,包括模型决策依据、执行耗时、返回结果、错误信息。
这套架构最大的好处,是模型、工具、权限三者解耦。模型只管“决定做什么”,至于“能不能做、做了会有什么影响”,由 Reach 层来判断。这样即使换了模型,工具侧完全不用动。
2.2 触达的四种类型
在实际项目里,Agent 需要触达的目标五花八门。我把它们归成四类,分别对应不同的风险等级和权限策略。
| 触达类型 | 典型动作 | 风险级别 | 权限策略 |
|---|---|---|---|
| 读操作 | 查销售数据、检索订单、拉取报表 | 低 | L1 只读授权 |
| 写操作 | 发通知、创建工单、修改配置 | 中 | L2 写操作授权 |
| 流程操作 | 启动审批流、调用 RPA 执行脚本 | 高 | L3 高危授权 + 人工确认 |
| 协同操作 | 把任务转给另一个 Agent 或人工处理 | 中 | L2 授权 + 明确交接记录 |
分类看起来简单,但直接影响后面的权限模型设计。比如读操作可以走自动授权,写操作要看数据影响范围,流程操作最好加一道人工确认。这个分类不是拍脑袋定的,而是根据“一次错误触达造成的最大损失”来划分的。
2.3 为什么用“语义路由 + 候选精排”而不是全靠模型
最早的版本,我把所有工具描述直接塞进 system prompt,让模型自由选择。工具在 15 个以内时效果还行,超过 20 个就开始翻车。后来我把工具描述换成 OpenAPI 格式精简版,情况好一点,但模型仍然会在冷门工具上产生幻觉,甚至出现“明明该用 A 工具,却因为 A 描述排在后面选了 B”的情况。
Agent-Reach 最终采用的是“两步走”路由:第一步,用语义检索召回候选工具,把几十个工具缩小到五六个;第二步,把候选工具的描述交给模型,由模型精排决定最终使用哪个、参数是什么。这个过程像查地图:先通过语义缩小范围,再在局部做精细导航。
关于候选数量 top_k 怎么定,我做过一组对比实验。top_k 取 3 的时候,召回率明显偏低,经常漏掉正确工具;取 8 或 10,准确率几乎没有提升,但 prompt 长度增加了约 40%,模型决策延迟多了 300 毫秒左右。最后定在 6,兼顾准确率和响应速度。相似度阈值则不是拍脑袋定的,我拿 300 条真实用户语句跑了一遍,统计“正确工具”的相似度分布,取 P25 分位的值作为默认阈值,大概是 0.3,低于这个值的召回结果宁可不要,也不能误导模型。
2.4 权限模型:最小权限、按需授权、动态回收
权限是 Agent 项目里最不能省的一环。我在 Agent-Reach 里做了三级权限:L1 只读,L2 写操作,L3 高危操作。每个工具注册时绑定所需级别,执行器调用前强制校验。
L3 操作必须走二次确认。比如 Agent 打算执行“批量删除过期订单”,系统会先把参数回显给用户,用户确认后才真正执行。这个确认不能做成 prompt 里的“你确定吗”,因为模型可能替用户回答“确定”。正确做法是中断执行流,把控制权交还给前端,等用户在界面上点了确认按钮再继续。
动态回收也值得提一嘴。有些工具授权是一次性的,有些是会话级的。如果是“查询今日库存”这种低风险工具,会话内第一次授权后后续可以直接放行;但“修改价格”这种操作,每次都要重新校验,即使同一会话内也一样。这个策略我在权限服务里做了可配置项,按工具粒度控制。
3. 触达层协议与核心代码实现
3.1 工具描述协议:让模型看得懂、填得对
我建议工具描述统一用 JSON Schema,主要原因是模型对结构化描述的理解能力远好于自然语言,而且 OpenAPI 生态可以直接复用。一个查询销售数据的工具,描述长这样:
name: query_sales_data description: 查询指定时间范围的销售数据,按区域汇总。适合“销量怎么样”“营收多少”这类需求。 parameters: type: object properties: start_date: type: string description: 开始日期,格式 YYYY-MM-DD end_date: type: string description: 结束日期,格式 YYYY-MM-DD region: type: string enum: [华东, 华南, 华北, 西南] description: 区域名称,不传则全部区域 required: [start_date, end_date]这里有个很容易忽视的细节:description 字段不要只写“查询销售数据”,要写清楚“什么时候该用这个工具、什么时候不该用”。比如这个工具的描述里加了“适合‘销量怎么样''营收多少’这类需求”,模型在做语义匹配时就有了锚点。我对比过,加了使用场景描述之后,工具选择准确率提升了大概 12 个百分点。
3.2 工具注册中心:基础数据结构与检索
注册中心是 Agent-Reach 的核心。所有工具进来先注册,注册完自动建索引。索引的目的是给语义检索用,轻量方案直接用 FAISS,工具量在几百个以内完全够用,没必要一上来就上向量数据库。
# tool_registry.py from dataclasses import dataclass, field from typing import Optional @dataclass class ToolSpec: name: str description: str parameters: dict permission_level: int executor: str timeout_ms: int = 5000 need_confirm: bool = False class ToolRegistry: def __init__(self, embedder): self._tools: dict[str, ToolSpec] = {} self._embeddings = {} self._embedder = embedder def register(self, tool: ToolSpec): self._tools[tool.name] = tool self._embeddings[tool.name] = self._embedder.embed(tool.description) def retrieve(self, query: str, top_k: int = 6) -> list[ToolSpec]: query_vec = self._embedder.embed(query) scored = [] for name, vec in self._embeddings.items(): score = cosine_similarity(query_vec, vec) scored.append((score, name)) scored.sort(reverse=True) return [self._tools[name] for _, name in scored[:top_k]]retrieve 返回的是候选列表,不是最终决定。最终决定交给模型去做,注册中心只负责缩小范围。这一步很重要,因为注册中心不该有“决策”逻辑,它一旦介入决策,就又把业务逻辑耦合进来了。
3.3 语义路由 + 精排决策
路由决策服务是整个项目里改动最频繁的模块。它做的事分三步:召回候选工具、构建候选工具提示、让模型输出结构化决策。
async def route(user_request: str, session_id: str) -> ToolDecision: candidates = registry.retrieve(user_request, top_k=6) schemas = [c.to_json_schema() for c in candidates] messages = [ {"role": "system", "content": "你是工具调度助手。根据用户请求,从候选工具中选择一个最合适的工具并给出参数。如果候选工具都不合适,返回 tool_name=null。只使用提供的工具,不要虚构工具名。"}, {"role": "user", "content": f"用户请求:{user_request}\n候选工具:{json.dumps(schemas, ensure_ascii=False)}"} ] resp = await llm.chat(messages, response_format={"type": "json_object"}) decision = json.loads(resp) return ToolDecision(**decision)这里有一个我踩过坑的地方:如果没有“tool_name 可以为 null”这个约束,模型在候选工具全不合适时也会强行挑一个最接近的,导致错误触达。加上之后,模型更愿意“承认自己找不到合适的工具”,这时系统会走兜底对话流程,而不是硬调。
3.4 执行器统一抽象:参数校验、结果化简、错误标准化
工具真正执行时,不能直接让模型去调 HTTP 接口。中间要加一层执行器,统一处理参数校验、权限检查、超时控制、结果化简和错误标准化。
class BaseExecutor: def __init__(self, registry, permission_service): self.registry = registry self.permission_service = permission_service async def execute(self, tool_name: str, params: dict, user_ctx: UserContext): spec = self.registry.get(tool_name) self.permission_service.enforce(user_ctx, spec) validate(spec.parameters, params) raw = await self._call(spec, params) return self._simplify(raw)单个方法挺好理解,大部分团队都会写。关键在于结果化简,这一步直接决定 Agent 后续对话的质量。工具返回的原始结果可能是很大的 JSON,比如一次销售报表查询能返回 800KB 数据。模型上下文窗口是有限的,把原始 JSON 塞进上下文,轻则浪费 token,重则直接触发窗口溢出。执行器应该把原始结果先做摘要,只把“模型需要知道”的部分放进上下文。
我通常的做法是保留三个部分:核心结论、关键明细、原始数据的查询入口。比如销售数据查询,化简后就留下总营收、Top 区域列表、明细报表的下载链接。模型拿这些信息足以回答用户,用户想深入看明细,再走一次检索式工具拉取。
4. 权限、审计与可观测
4.1 权限校验绝不能只写在 prompt 里
这是 Agent 项目最容易踩的坑,而且是致命的坑。很多人做演示版时,把“只有管理员才能删除数据”这句话写进 system prompt,看起来没问题,但生产环境里这根本挡不住。
原因有两点。第一,prompt 本身可以被对话内容污染,用户可以通过一系列话术让模型忽略之前的规则,这在业界已经有大量案例。第二,即便模型遵守了规则,也无法防止模型在工具参数上传入越权数据,比如传了不属于当前用户权限范围的订单 ID。权限判断必须落在代码层,在工具调用真正发生之前强制执行。
def enforce_permission(user, tool_name, params): spec = registry.get(tool_name) required = spec.permission_level allowed = permission_service.check(user.uid, required, params) if not allowed: raise PermissionDenied( f"当前账号缺少权限:{required},需要联系管理员开通" )任何绕过权限服务直接执行工具的做法都应该被视为 bug。我在项目里加了一个强制约束:所有 Executor 必须继承 BaseExecutor,权限检查在父类里完成,子类无法跳过。
4.2 全链路追踪:每次触达都要留痕
Agent 出问题不可怕,可怕的是出了问题不知道是哪一环导致的。Agent-Reach 从第一版开始就要求全链路追踪。每条会话有一个 session_id,每次工具调用生成一个 request_id,所有日志都要带上这两个 ID,再配合时间戳和 trace_id,形成一次触达事件的完整脉络。
我用 OpenTelemetry 做底层的 trace 管理,同时在应用层额外落一份结构化 JSON 日志,方便直接用日志检索排障。日志字段大致是这样:
def log_reach(session_id, request_id, tool_name, params, status, latency_ms, model_decision): logger.info(json.dumps({ "event": "agent_reach", "session_id": session_id, "request_id": request_id, "tool": tool_name, "params": sanitize(params), "status": status, "latency_ms": latency_ms, "model_decision": model_decision }, ensure_ascii=False))日志里必须要脱敏。params 里的手机号、身份证、token 等敏感字段在写入日志前要做掩码处理,否则审计系统本身就变成了一个数据泄露出口。
4.3 审计回放:出问题时能还原现场
全链路追踪解决了“知道发生了”,审计回放解决的是“知道为什么发生”。除了请求日志之外,Agent-Reach 还会记录每一个关键决策节点的上下文快照,包括系统提示词、候选工具列表、模型原始输出、最终执行参数。
有一次生产事故让我印象很深:用户投诉 Agent 误发了一封全员邮件。通过审计回放发现,模型决策层选择了 send_notification 工具,参数里的收件人分组被模型填成了“all_members”,而实际上用户只提到了“通知一下项目群”。根因是工具描述里没有说清楚收件人分组字段应该从通讯录服务获取,而不是让模型自由填。后来我在工具描述里加了一条约束:“recipients_group 必须调用通讯录服务解析,禁止模型自行推断”,问题就消失了。
复盘时我们把日志分成了三个分析层次,排障效率提高了不少:
| 分析层次 | 观察内容 | 典型结论 |
|---|---|---|
| 模型决策层 | 用户输入、模型输出的 tool_name 与参数 | 模型选错了工具 / 参数理解有偏差 |
| 框架校验层 | 权限校验结果、参数校验结果、拦截原因 | 权限配置不当 / 参数 Schema 定义不全 |
| 执行层 | 外部系统响应状态、耗时、返回数据 | 上游系统超时 / 返回结构变化导致解析失败 |
这个三层日志体系也是每个新同事上手排障时的第一份参考资料,比哪份文档都管用。
5. 实操中的典型问题与排查技巧
5.1 工具数量膨胀导致决策漂移
项目上线两个月后,注册工具从 12 个涨到了 47 个,模型开始频繁选错工具。症状挺迷惑的:单看每一次决策都像模像样,但整体准确率掉了快 15 个百分点。
排查后定位到两个原因。一是工具描述同质化,比如“查询订单”“查询退款”“查询售后单”这三个工具的描述都包含“查询”“订单”这类词,语义向量距离很近,召回阶段经常混在一起。二是模型精排阶段一次要看的候选变多了,注意力分散。
解决办法有两个。第一个是增加“业务域路由”:先按域分组,比如交易域、库存域、营销域,语义路由先定域,再在域内召回工具,这样每次实际送入模型的工具不会超过 4 个。第二个是合并同类工具,把“查询订单”“查询退款”“查询售后单”合并成一个“查询交易数据”的能力入口,由执行器根据参数内部再路由。两招组合下来,准确率不仅恢复了,还比早期版本高了一截。
5.2 工具调用超时会话挂起
外部系统响应慢,Agent 干等,用户端看起来就是“卡死了”。这个问题第一版就出现了。报表系统偶尔要跑十几秒,Agent 又设置了 60 秒超时,结果就是用户对着对话框干瞪眼。
后来我按触达类型分别设了超时:读操作默认 5 秒,写操作默认 10 秒,流程类操作 30 秒,全部可配置。更关键的是超时后的处理逻辑。超时不能简单地给模型返回“调用失败”,模型收到失败后会尝试重试,连续几次失败又触发新的超时,体验更糟。现在超时后返回一段结构化信息,比如“系统响应超时,建议先让用户确认数据是否需要实时获取,或者主动降级为缓存数据”。
写操作超时的处理是最麻烦的,因为请求可能已经发出去了,只是响应没回来。这种情况我会把状态标记为“执行结果未知”,不会直接告诉用户“失败”,而是提示“命令已发出,确认结果需要等待”。这个细节虽然小,但在金融对账场景里能避免很大的麻烦。
5.3 权限校验被串联调用绕过
有个测试场景让我警觉了:用户让 Agent“先查一下订单详情,然后把订单状态改为已取消”。Agent 先调用了查询工具,又调用了取消工具。查询工具的权限是 L1,取消工具的权限是 L3,按设计应该触发二次确认,但测试中发现如果前一个工具调用刚刚成功,第二个工具可能被某些实现绕过校验。
根因是我第一版把“会话内已通过更高权限”错误地缓存了。修复方案:每个工具独立校验,L3 操作每次都要校验并强制二次确认,不设置会话级缓存。高危操作连参数里的“订单号”范围也要校验,防止模型把用户 A 的订单取消指令作用到用户 B 的订单上。权限校验最安全的策略就是:宁可每次多查一次,也不要在边缘场景留下一个口子。
5.4 上下文被工具返回结果塞满
这个坑几乎每个 Agent 项目都会遇到。有一次用户让 Agent 汇总上个月所有渠道的广告投放数据,工具返回了一个 2MB 的 JSON,直接塞进了上下文,后面的对话质量立刻崩了。模型的注意力被海量原始数据稀释,连用户最初的意图都快忘了。
解决链路有三层。第一层是在执行器里做结果摘要,只保留结论和关键指标,细节进仓库。第二层是上下文管理加滑动窗口,旧消息逐步压缩,只保留摘要和决策链。第三层是给用户提供“继续追问”的入口,用户想看明细时,Agent 通过专门的检索工具按需拉取,而不是一次性把全部数据加载进上下文。
打个比方:项目里的上下文机制就像人处理信息的方式——记住结论,细节放在笔记本里,需要再翻。这个方向也是 Agent 项目从 demo 走向生产的必经之路。
5.5 模型编造工具名
生产环境里出现过一次“工具不存在”的调用:模型输出一个 tool_name,但注册中心里根本没有这个工具。发生这种情况的原因是模型在候选工具都不匹配时强行编造了一个“看起来合理”的工具名。这跟大模型的幻觉机制有关,被追问时它会倾向于给一个答案而不是承认自己不知道。
处理办法是三层防御。首先,注册中心在收到未知工具名时返回“未注册工具”并附带当前可用工具的候选列表;其次,路由决策阶段允许 tool_name 为 null,不给模型“硬选”的机会;最后,把错误信息原样返回给模型,让它基于真实反馈做自我纠正。实测下来,加上这套机制之后,模型编造工具名的次数从大约每 20 次出现 1 次,降到了几乎为零。
6. 踩坑之后留下的体会
Agent-Reach 做到现在,我最深的体会是:Agent 项目的复杂度和工具数量不是线性关系,而是指数关系。工具 10 个以内怎么设计都顺,到 30 个以上,协议、权限、路由、可观测全部得重新审视一遍。
如果你是刚开始做类似项目,我强烈建议不要一上来就上微服务、消息队列、向量数据库全家桶。第一版就用单进程把注册中心、路由、执行器、日志串起来,先把链路跑通,把工具协议定好,再逐步拆模块。工具协议这件事越早定越好,它像 API 规范,后面改起来成本极高。
还有一点是关于 prompt 的。工程项目里,不要指望一条写得很完美的 prompt 解决所有问题,更不要指望模型“自觉遵守规则”。规则类约束一定要落进代码,prompt 只负责表达意图,不负责执行安全。每次做 Agent 排障,我会先问三个问题:这个动作模型有能力做吗?框架允许它做吗?做了之后能追踪到吗?三个问题都是肯定回答,系统才算是真正可控。
如果以后扩展,我会把 Agent-Reach 往多 Agent 协作的方向推。当一个 Agent 的能力不够用时,它需要把任务交给另一个 Agent,这套触达协议同样可以当作 Agent 之间的协作总线。另外,把工具调用结果加上统计反馈,持续优化语义路由的召回效果,这个方向也值得深耕。模型决定 Agent 能想到什么,触达层决定 Agent 能做到什么。把触达这件事做扎实,Agent 才真正从玩具变成生产力。