ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent-Reach:给LLM Agent补上触达中间件

Agent-Reach:给LLM Agent补上触达中间件

写 Agent 这件事,真正让我头疼的从来不是对话流,而是"触达"。最近半年我一直在折腾一个内部项目,名字就叫 Agent-Reach,含义很直白:让大模型驱动的智能体真正够得着它该够得着的东西——数据库、工单系统、监控接口、知识库,甚至是另一个 Agent。做 Agent 工程化的人都明白,写一个会聊天的 Agent 不难,难的是让它调工具的时候不出乱子。工具注册混乱、鉴权缺失、一个慢接口把整个对话拖死,这些都算轻的。Agent-Reach 就是我把这些坑一个个趟平之后沉淀下来的一层"触达中间件"。如果你正在做多工具调用,或者准备把 Agent 接进内部业务系统,这篇文章里的思路、代码和踩坑记录应该能帮你省下几个月的试错时间。

1. Agent-Reach 原点:Agent 的"会说"与"会做"之间,隔着一层 Reach

1.1 从"会说"到"会做",工具调用为什么是分水岭

到今天为止,大模型的能力边界已经不再取决于它能"说"得多漂亮,而是取决于它能"做"得多靠谱。一个只能输出文字的 Agent 说白了就是个高级聊天框,一旦接上工具调用(Function Calling),它才真正开始拥有"行动力":可以查订单、发工单、改配置、调报表。

工具调用的原理本身并不神秘。模型在生成过程中会额外输出一个结构化的调用意图,比如"调用 search_knowledge_base,参数 query=报销流程",然后由我们这些工程人员去解析这个意图、执行对应函数、把结果塞回上下文,让模型继续推理。这套链路听起来简单,但一旦工具数量从三五个涨到三五十个,问题就接踵而至。

我在项目早期就吃过这个亏。最开始只是给 Agent 挂了四五个 Python 函数,一切都很美好。后来为了让它能查监控、查日志、查审批、查排班,工具列表膨胀到了将近三十个,真正的问题开始浮出水面:模型开始混淆同名函数、编造不存在的参数、调用了超出权限范围的接口,而且没人知道它每一次调用到底干了什么。那段时间我每天都在"它怎么又乱调"和"它为什么该调的没调"之间反复横跳。

1.2 裸调 API 的三个死法,每个都是真实踩过的

死法一:工具注册混乱,模型幻觉调用不存在的工具。早期我把工具定义散落在各个业务代码里,有的用 JSON Schema 写,有的直接写在系统提示词里。模型一旦没在上下文中看到完整的工具清单,就会自己"脑补"一个函数名出来,然后代码侧抛出一个冷冰冰的FunctionNotFoundError,整个 Agent 循环直接中断。

死法二:权限裸奔,一个 API Key 走天下。最早我为了省事,给 Agent 配了一个超级管理员的 Key,什么都调得通。结果有一次测试,我故意让它执行"把生产环境的日志级别改成 DEBUG",它真的去改了。那次之后我意识到,Agent 触达得越多,权限失控的风险就越大,一个万能 Key 等于把整个系统的后门交给了模型的随机性。

死法三:没有超时和熔断,一个慢接口拖死整轮对话。Agent 的推理是串行的,每调用一个工具都要等它返回才能继续生成。我曾经接的一个报表接口平均响应时间 40 秒,Agent 调了三次,每次都在那干等,最后直接把用户的会话超时干爆了。用户反馈"这个 AI 半天憋不出一句话",体验极差。

1.3 Agent-Reach 的定位:补上"触达"这一层

经历了一轮又一轮的翻车之后,我把所有问题归结到一个点上:Agent 缺少一个统一的"触达层"。这个层要在模型和真实世界之间做翻译、过滤、鉴权、限流、审计。Agent-Reach 就是干这个的,只不过我把它的边界划得很清楚:

第一,它不做业务逻辑,所有工具的具体实现仍然下沉在业务系统里; 第二,它做的是"统一出入口",所有工具调用都必须经过它来路由; 第三,它负责回答四个问题:这个 Agent 是谁、它想调用什么、它有没有权限调用、这次调用是否应该被记录。

我在内部给自己定了一个可量化的目标,叫"触达成功率":成功完成的工具调用数,除以模型发起的总工具调用数。 Agent-Reach 上线之前,我的项目触达成功率大概只有 68%,每次失败都要靠人工在日志里捞原因。上线稳定之后,这个数字基本稳定在 95% 以上,剩下的 5% 大多数是模型参数解析错误,而不是底层触达问题。

2. Agent-Reach 的三件套:Gateway、Registry、Policy Engine

2.1 Reach Gateway:所有工具调用的统一出入口

整个 Agent-Reach 最核心的组件是一个叫 Reach Gateway 的服务。它的作用和电信网络里的信令网关很像:Agent 侧不用关心工具部署在哪里、用什么协议暴露,只要往 Gateway 丢一个标准格式的请求就行。

我选型时用了 Python 3.10 + FastAPI + Pydantic。原因不复杂:团队本来就有 Python 技术栈,Pydantic 对 JSON Schema 的天然支持能省掉大量胶水代码。Gateway 对外暴露一个统一的 HTTP 接口:

POST /v1/reach

请求体长这样:

{ "req_id": "b7f3c2a1-xxx", "agent_id": "ops-assistant-v2", "user_id": "zhang.li", "tool_name": "search_knowledge_base", "arguments": { "query": "报销流程", "top_k": 5 } }

响应体统一为:

{ "req_id": "b7f3c2a1-xxx", "status": "success", "output": { "hits": [], "total": 0 }, "duration_ms": 1234, "permission": "granted" }

这套协议让我在做后续的插件扩展时轻松很多,因为所有工具调用都在一个地方留下了痕迹,不存在"某个工具藏在某个服务里偷偷被调用"的情况。

2.2 Tool Registry:让函数变成模型能够"看见"的工具

Gateway 自己不知道有哪些工具可调,它需要问 Tool Registry 要一份"工具目录"。Registry 的职责是管理所有的工具定义,并把它们以模型能理解的方式暴露出来。

每个工具在注册时都要提供一份完整的 Schema,包括工具名称、功能描述、参数定义、超时时间、所需权限范围、限流大小。我最看重的是"描述"这一项的质量,因为 LLM 是通过描述来决定何时调用工具的。

举个实际的教训。早期我把一个工具描述写成"处理数据",结果模型在用户问"帮我把明天的排期表整理一下"的时候,莫名其妙调用了这个"处理数据"工具,因为它觉得"整理"就是"处理"。后来我把描述改成"从排班系统获取指定日期范围内所有人员的值班安排,返回列表格式,不执行任何写入操作",模型就基本不会再误用了。工具描述写得越像给一个刚入职的实习生看的需求说明,模型的调用准确率就越高。

Registry 对外提供的核心接口很朴素:返回所有工具定义的 JSON Schema 列表。这个列表会被拼进系统提示词,或者通过 function calling 的 tools 参数传给模型。这样模型在生成回复时,会基于当前对话上下文,从可见的工具列表里挑选最匹配的一个来调用。

2.3 Policy Engine:触达权限的第一道闸门

Policy Engine 是整个 Agent-Reach 里最"安全向"的组件,也是我认为最不能偷懒的部分。它的核心思想是:工具不直接对 Agent 开放权限,而是通过 Scope(权限域)来间接管理。

我给每种工具声明一个 scope,比如kb.read、incident.write、config.change,然后给每个 Agent 分配一组 scope 集合。当 Agent 发起工具调用时,Policy Engine 执行一次匹配:该工具声明的 scope 是否在 Agent 的 scope 集合里。在的话放行,不在的话直接拒绝。

这个设计和 Linux 的权限模型有点类似,普通用户不能随便改系统配置,除非通过 sudo 临时提权。对应到 Agent 场景,一个"只读"的运维助手默认只拥有monitor.read、log.read这类 scope,它就算真的产生了"我要去改配置"的意图,也会被 Policy Engine 无情拦下。

我建议所有做 Agent 接入的人尽早把权限模型装上,哪怕工具只有五个。因为 Agent 的调用行为具备"级联放大"效应:一次调用失败,它可能会尝试换一个参数再调;被拒绝一次,它可能换一个语义更模糊的工具名再试。没有权限闸门,这种试错就是在搞破坏的边缘疯狂试探。

3. 从零搭建:一个最小可用的 Reach 层能跑多快

3.1 技术选型与目录结构

如果用一句话概括我当时的选型思路,就是"哪个东西能让我最快跑起来、并且之后不至于推翻重来,就选哪个"。最终我敲定了下面的组合,并保持至今:
技术栈

FastAPI 作为基础框架、Pydantic 做数据校验、Redis 做工具注册表的缓存、SQLite 做审计日志存储。日志量上来后可以考虑换 PostgreSQL。

此处无内容
此处无内容
此处无内容
此处无内容
此处无内容
此处无内容
此处无内容

整个目录结构非常简单:

agent-reach/ ├── gateway.py # Reach Gateway 主服务 ├── registry.py # 工具注册与发现 ├── policy.py # 权限判断引擎 ├── tools/ # 具体的工具实现 │ ├── __init__.py │ ├── knowledge_base.py │ └── monitor.py ├── schemas.py # 数据模型定义 └── audit.py # 审计日志写入

第一版从动手写代码到跑通第一个工具调用,用了一个下午。不是因为代码量少,是因为边界切得足够清楚:Gateway 只做路由,Registry 只做注册,Policy 只做判断。

3.2 核心数据结构:ToolSchema 与 ReachRequest

工具注册的数据结构是根,它直接决定了后续做权限、做限流、做超时控制的成本。我用 Pydantic 定义了一套简化的 ToolSchema:

from typing import Any, Callable, Dict, Optional from pydantic import BaseModel, Field class ToolSchema(BaseModel): name: str = Field(description="工具唯一名称,使用点分命名,如 kb.search") description: str = Field(description="给模型看的工具功能描述,要具体到行为边界") parameters: Dict[str, Any] = Field(description="JSON Schema 格式的参数定义") timeout_ms: int = Field(default=15000, description="单次调用的最大等待时间") scope: str = Field(description="该工具所需的权限域,如 kb.read") rate_limit: int = Field(default=60, description="每分钟最大调用次数")

ReachRequest 则保持精简,只携带调用方身份、目标工具、参数和请求追踪 ID。一个额外的原则:任何涉及写入的工具,必须在请求体里带上require_confirmation: bool,供上层决定是否需要人工确认。

3.3 工具注册与动态发现的核心实现

工具注册我用了一个装饰器实现,简洁而且侵入性小:

# registry.py _TOOL_SCHEMAS: Dict[str, ToolSchema] = {} _TOOL_FUNCTIONS: Dict[str, Callable] = {} def register_tool(schema: ToolSchema): def decorator(func: Callable): _TOOL_SCHEMAS[schema.name] = schema _TOOL_FUNCTIONS[schema.name] = func return func return decorator def list_tool_schemas() -> list[ToolSchema]: return list(_TOOL_SCHEMAS.values()) def get_tool_function(name: str) -> Callable: return _TOOL_FUNCTIONS[name]

在实际的工具文件中,使用方式是这样的:

# tools/knowledge_base.py from registry import register_tool from schemas import ToolSchema from typing import Any, Dict @register_tool( ToolSchema( name="kb.search", description="在内部知识库中搜索与关键词相关的文档片段,返回标题、摘要和原文链接", parameters={ "type": "object", "properties": { "query": {"type": "string", "description": "搜索关键词,可以包含多个短语"}, "top_k": {"type": "integer", "description": "返回条数", "default": 5} }, "required": ["query"] }, timeout_ms=20000, scope="kb.read", rate_limit=120 ) ) def search_knowledge_base(query: str, top_k: int = 5, **kwargs) -> Dict[str, Any]: # 这里实际调用企业内部的知识库检索服务 return {"hits": [], "total": 0}

模型侧的"发现"完全依赖list_tool_schemas()的输出。我在 Gateway 启动时把全量工具 Schema 加载一次,并缓存到 Redis,模型请求进入时直接读取,不需要每次查库。这样做的额外好处是:新上线一个工具,只需要注册进 Registry 并刷新缓存,Agent 下一次会话就能"看见"它,完全不需要改 Agent 的代码。

3.4 护栏参数:超时、重试、熔断的工程参数

这里分享一组我压测之后定下来的护栏参数,可以直接抄作业:

护栏项参数值设置依据
单工具超时默认 15s,写操作放宽到 30s超过 30s 的工具调用,多数是业务本身的问题,不应该拖垮对话
重试策略最多重试 1 次,且仅限只读工具重试写操作会造成重复执行,幂等没做好的情况下风险极高
熔断条件连续失败 5 次进入半开状态,暂停该工具 30 秒给下游服务恢复留出时间,避免 Agent 疯狂重试一个坏掉的接口
单 Agent 并发最多 3 个并发工具调用防止 Agent 面对多个子任务时把下游系统打爆,尤其是数据库类工具

有一个参数我花了两周才调明白:工具调用并发数。一开始我允许 Agent 并行调用 8 个工具,结果下游某系统的连接池被打满,间接导致了一个线上报表服务超时。后来我把并发限制调整到 3,丢掉的只是几秒钟的并行时间,换来的是下游系统的稳定。这个取舍我认为非常划算。

4. 权限与审计:给 Agent 的触达边界提前画好红线

4.1 最小权限原则在 Agent 场景下的变体

传统软件里的最小权限原则是"给每个程序只开它需要的那几个权限",但到了 Agent 场景,这件事有了新的麻烦:Agent 自己也不知道它会走哪条调用路径。同一个对话里,用户上一句问"查一下这个月工单量",下一句可能是"把这个工单指派给我"——那就是写操作。

所以在 Agent-Reach 里,我把最小权限拆成两层来落地。第一层是 Agent 的静态权限池,决定了它能触碰的范围上限;第二层是工具自身的动态校验,某些涉及跨用户数据访问的调用,还需要额外校验操作者与数据属主的关系。

比如一个工单查询工具,除了 scope 判断之外,我会在函数内部再加一道数据级校验:用户传进来的assignee_id如果和当前user_id对应的身份不一致,直接返回"无权限查看该工单"。这样就算 Agent 被诱导着传入了别人的 ID,也会被挡在数据层之外。

4.2 审计日志该记录哪些字段

很多团队做 Agent 审计的时候只记录"它调了什么",这远远不够。我现在的审计日志至少包含下列字段:

字段示例为什么非记不可
req_idb7f3c2a1-xxx贯穿全链路排查问题的唯一线索
agent_id / user_idops-assistant-v2 / zhang.li定位"谁通过哪个 Agent 发起的"
tool_name 与参数摘要kb.search / query=报销流程敏感参数做脱敏后再落库,比如密码、令牌只记录前 4 位
调用结果与耗时success / 1234ms排查性能问题和上下文膨胀的核心依据
模型消耗 token 数1评估 Agent 调用成本,防止失控循环
权限判断结果granted / denied分析恶意或误用调用请求的来源

有的团队会质疑"参数摘要脱敏以后没法复现问题了"。我的做法是保留一份高权限的可查询原始日志,但审计系统默认只读脱敏版本,两边通过 req_id 关联。既保证了排查能力,又不会让敏感参数在普通日志系统里裸奔。

4.3 Human-in-the-loop:高危操作必须有人确认

有些工具调用不能让 Agent 自己拍板,尤其是写操作。我在 Gateway 里加了一个"确认队列"的机制:当 Policy Engine 判定某个请求属于高影响操作,比如"修改配置""删除数据""发送消息给外部用户",Gateway 不会直接调用工具,而是返回一个特殊状态给 Agent:"等待用户确认"。

Agent 收到这个状态后,会向用户展示一个确认卡片,等用户点击确认后,真正触发工具调用。整个过程在协议层强制,而不是靠提示词约束。这一点很重要:如果你只是告诉模型"你执行删除前要先问用户",它大概率会在某些模糊场景下忘了这条规则,直接把删库行为当成普通操作执行掉。协议层强制,模型就不可能出现违反规则的随机性。

5. 跑通之后的真实复盘:三个落地场景和一堆坑

5.1 场景一:企业内部知识库检索自动化

第一个真实落地的场景是把 Agent-Reach 接到企业的内部知识库。Agent 面对的工具有三个:kb.search、kb.get_article、wiki.recent_changes。全部是只读操作,权限模型最简单。

实测下来的提升很明显:以前人工翻知识库找一条流程说明平均要 6 分钟,Agent 端到端返回带引用的答案只要 40 秒左右。而且通过审计日志,我们能看到用户最终点击了哪些答案里的链接,反推出来 Agent 的检索质量。最深的感触是:工具返回的格式要稳定,否则 Agent 的后续推理会被带偏。

5.2 场景二:运维工单自动诊断

第二个场景接入的是运维工单自动诊断。Agent 可以查询监控指标、检索最近日志、读取告警规则,然后根据这些信息输出可能的根因和建议动作。在权限设计上,这组工具占据了monitor.read、log.read两个 scope,另外一个"触发自动化修复"的工具则单独要求ops.action权限,并且强制走人工确认流程。

上线初期踩的最大一个坑是日志检索的超时。日志系统查询的响应时间波动极大,平时 300 毫秒,高峰期秒级返回,偶尔直接 30 秒超时。最终我把它单独设置了 20 秒超时 + 只读重试 1 次,并且把结果截断到前 100 条,避免模型被几万条日志塞满上下文失去判断力。

5.3 场景三:对外信息聚合与日常报告生成

第三个场景是让 Agent 每天定时聚合多个只读数据源,生成一份简报。整个过程涉及大约 10 个工具调用,Agent 需要自己编排顺序:先拉基础数据,再拉对比数据,最后生成总结。

这里我学到的最有价值的一件事是:不要试图让 Agent 一个多轮循环内完成全部工作,而是把它拆成多个子任务,每个子任务有独立的工具调用上下文。我最终实现了一个简单的编排规则,Agent 每完成一次阶段性调用后,会生成一个结构化的中间结果,下一个阶段的工具调用只用引用这个结果,而不是把全部原始数据带进上下文。这套"阶段性摘要"的思路,让长任务稳定了不少。

5.4 踩坑实录:上下文膨胀、幂等性与工具幻觉

上下文膨胀是最普遍的坑。工具返回结果动辄几千字,Agent 一轮对话只能消化有限的信息。我的解决办法是两层截断:工具端先做业务截断,比如只返回 top 10 条;网关再做格式截断,把超过 2000 字的内容自动压成摘要,并附上"需要查看完整内容请调用 get_article 并传入以下 ID"。这样模型既拿到了足够推理的信息,又不会淹没在原始文本里。

幂等性是写工具绕不开的问题。有一次我们测试"创建告警规则"这个工具,Agent 在一次对话里因为网络重试调用了两次同一个接口,直接生成了两条相同的告警规则。教训就是:所有会写入的工具,请求参数里必须携带幂等键,业务侧依据幂等键做去重。我在 ToolSchema 里增加了可选字段idempotent_key,并在 Gateway 层强制写入操作的请求生成该键。

工具幻觉则是需要持续压制的:无论 Registry 怎么完善,LLM 还是偶尔会生成一个不存在的工具名。我的兜底方案是 Gateway 在工具不存在的响应里,返回相近工具列表,比如"你想调用的 'kb.search_doc' 不存在,你是不是想用 'kb.search'?"让模型有机会自我修正,而不是直接断掉整轮对话。

6. 下一步路线和给新手的几句实在话

6.1 和 MCP 标准的关系:不冲突,反而互补

聊 Agent 工程避不开 MCP 这个标准协议。我的观点很明确:Agent-Reach 不等于 MCP,它们是两个维度的东西。MCP 解决的是"工具提供商和 Agent 之间怎么对话"的协议标准问题,而 Agent-Reach 解决的是"这些工具调用怎么被管理、怎么被授权、怎么被审计"的工程治理问题。

最典型的落地组合是:底层接各种 MCP Server 作为工具实现,上层由 Agent-Reach 负责统一注册、鉴权和审计。也就是说,MCP 负责把工具"翻译"成统一格式,Agent-Reach 负责决定"谁可以用、怎么用、用了要被记录"。这个组合在后续做外部工具生态接入时会很有优势,因为第三方工具只需提供 MCP 描述,Agent-Reach 不用关心工具内部实现。

6.2 给准备做 Agent 工程化的朋友几句实在话

第一,别急着上复杂框架,先用几十行代码把自己的 Gateway 跑通,把"调用—鉴权—审计"的最小闭环建立起来,再考虑扩展。第二,工具描述值得花时间和业务方一起打磨,描述质量直接决定模型的调用准确率,这个投入的性价比极高。第三,权限模型从第一天就设计好,后面再补的代价比你想的大得多,尤其是涉及写操作的 Agent。第四,必须把审计日志当成核心功能来做,而不是事后补的辅助功能,否则出问题的时候你连问题源头都定位不到。第五,不要让 Agent 一次调用返回超大数据,永远给结果加上限和摘要,这是保护模型推理质量最有效的手段。

Agent-Reach 这套东西我还在继续迭代,眼下最想补的是多 Agent 之间的触达路由:当一个 Agent 发现自己处理不了某个请求时,它能把上下文和意图安全地转交给另一个更专业的 Agent,而这中间的权限传递和审计也仍然要落在同一个 Reach 层里。这个方向让我觉得,Agent 真正成熟的样子,可能就是像今天微服务治理那样,每个节点都能被安全、可控、透明地触达。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进