如果你现在正在调一个 Agent 项目,多半会遇到一个奇怪的现象:模型聊起天来头头是道,但一到让它"真的做点什么"——查订单、改配置、发通知、写数据库——就开始掉链子。要么参数传错,要么调了一个不存在的工具,要么成功执行了但没人知道它干了什么。这不是模型能力的问题,而是 Agent 的"触达层"没做好。
Agent-Reach 是我最近在做的一个触达层项目,核心就做一件事:把 Agent 与外部系统之间的每一次交互,变成可控、可观测、可回退的基础设施。这篇文章我会从设计思路、协议定义、权限控制、可观测性到线上踩坑,完整讲一遍。适合那些正在把 Agent 从 demo 推向生产环境,想少走弯路的人。
1. 为什么我觉得 Agent 缺的不是脑子,而是触达能力
1.1 从 demo 到生产,最先崩掉的往往是"调用外部系统"这一段
过去一年多我见过不少 Agent 项目,也包括我自己早期写的那些。在 demo 阶段,大家展示的通常是"多轮对话 + 上下文记忆",看起来很聪明。可一旦要把 Agent 接到真实业务里,也就是让它去操作订单系统、CRM、内部文档库、数据库的时候,问题就全冒出来了。
典型的现象有几种。
第一种,模型确实决定要调用工具了,但参数是乱写的。比如工具定义里写明user_id是 integer 类型,模型偏偏给你传一个"user_123"字符串,系统直接抛异常,Agent 却不知道发生了什么,还会一本正经地告诉你"操作已完成"。
第二种,工具调用成功了,但是副作用不可控。一个 Agent 被授权调用"删除未支付订单"的接口,它可能在一次推理循环里连续调用三次,因为模型误判第一次没成功。真实业务里这种重复副作用是致命的。
第三种,也是最普遍的:整个调用过程是黑盒。Agent 调了哪个工具、传了什么参、花了多少钱、卡在哪一步,全都没有记录。出了问题只能靠猜。
这些问题不是靠换一个更强的模型能解决的。GPT 级别的模型照样会把参数传错,照样会在一个失败的工具上反复重试。核心问题在于,Agent 的"大脑"(推理模型)和"手脚"(外部系统)之间缺一层可靠的管道。
1.2 Agent-Reach 的定位:给 Agent 装一层可控的触达基础设施
Agent-Reach 这个名字,Reach 取的是"触达、够到"的意思。它解决的就是 Agent 能力边界的问题:模型能思考,但能不能"够到"外部的数据和服务,取决于这层触达管道是否健壮。
我把这个管道的职责定义为四件事:
- 协议统一:不管外部是 REST API、Python 脚本、数据库还是内部 RPC,统一成一套工具描述 Schema,让模型只需要学会一种调用语法。
- 权限收敛:Agent 不直接持有数据库密码、API Key,所有触达动作经过统一鉴权,按动作等级做分级审批。
- 执行治理:超时、重试、并发限制、结果裁剪、成本计量全部在触达层完成,不让模型自己瞎猜。
- 全量观测:每一次触达请求都有 trace,能回答"这个 Agent 在什么时间、以什么参数、调了什么工具、花了多少钱、结果如何"。
一句话总结:Agent 负责决定做什么,Agent-Reach 负责让这个决定安全、正确、可追溯地落地。
2. Agent-Reach 的整体架构:一个四层触达管线的设计
2.1 四层结构:意图解析、工具路由、安全执行、观测反馈
Agent-Reach 的架构并不复杂,我用的是四层管线,每层只干一件事。
第一层是协议层,也叫接入层。所有工具在这里注册,以统一的 JSON Schema 描述自己的能力。这一层决定了模型"看到的世界"长什么样。
第二层是路由层。模型在推理时输出一个tool_call请求,里面包含工具名和参数。路由层负责把这个请求映射到真正能执行的 handler 上。如果模型把工具名写错了一两个字母,路由层还要有模糊匹配能力,而不是直接抛一个"tool not found"给模型。
第三层是执行层。这是真正干活的地方。它负责加载 handler、校验参数、执行调用、处理超时和重试、触发权限确认流程。所有外部副作用都在这一层发生,所以这也是管控最严格的一层。
第四层是观测层。执行层产生的每一次调用,都会以结构化日志的形式写入 trace 存储。之后可以做指标聚合、失败分析、成本账单。
层与层之间通过内部事件总线通信,不直接互相调用方法。这样做的原因是:Agent 场景里高频出现"模型误判后疯狂重试"的情况,事件驱动能让我们在观测层更容易把所有调用串联起来,也方便随时插一个 rate limiter 或熔断器。
2.2 触达层和普通 API 网关的本质区别
你可能觉得这听起来有点像 API 网关。确实有相似之处,但有一个本质区别:API 网关处理的是"人"的请求,请求方知道自己要什幺——一个确定的 URL、确定的参数。而 Agent 发出的请求是不确定的,同一个意图可能映射到不同工具,同一个工具可能被用错误的方式调用,甚至模型会在一次对话里反复横跳,先说要查 A 再说要查 B,最后又回到 A。
所以 Agent-Reach 这类触达层必须多做三件事:
- 理解工具描述而非仅转发请求:它能读取工具 Schema,在模型调用前做参数预校验,在调用失败后给模型返回"结构化错误信息"而不是裸的堆栈。
- 管理多步交互的会话状态:一次 Agent 任务往往包含多次工具调用,触达层需要维护会话上下文,知道当前是第几轮、某个工具是否已经调过、结果是否已被消费。
- 对"撤销"和"补偿"有意识:虽然这次调用无法撤销,但你必须知道它属于哪个人物流程,好安排后续补偿操作。
这些都不是一个普通 API 网关会考虑的事情。
2.3 一次典型触达流程的完整时序
我直接用一次"用户让 Agent 统计上月订单总额"的请求来描述完整链路。
- 用户的自然语言请求进入 Agent 主循环,LLM 推理出需要调用
query_orders工具。 - LLM 返回一个 tool_call 对象,包含
tool_name="query_orders"和参数{ "start_date": "2025-05-01", "end_date": "2025-05-31", "group_by": "category" }。 - Agent-Reach 接收这个对象,先做协议校验:参数是否有缺失、类型是否正确、是否在允许枚举范围内。校验不通过就返回一个
INVALID_ARGUMENT错误给模型,让它自己修正。 - 校验通过后进入鉴权环节。
query_orders被标记为只读级别(L0),无需人工确认,直接放行。 - Executor 找到对应的 handler,执行 SQL 查询或调用订单服务 API。执行过程中如果超过 10 秒未返回,触发超时机制,向模型返回
TIMEOUT错误,并附带一个提示"可尝试缩小时间范围"。 - 执行成功的原始结果先经过结果裁剪:超过 2000 字的输出做摘要和截断,只保留前几条关键数据。
- 裁剪后的结果作为 observation 返回给 LLM,LLM 生成自然语言回答给用户。
- 整个过程的 trace 写入观测存储,包含请求 ID、工具名、参数摘要、返回长度、耗时、状态、成本。
这套流程看起来每一步都很朴素,但真正落地时每个环节都有讲究,下面分章节仔细说。
3. 工具注册与函数调用协议:把所有"手"统一成一种格式
3.1 工具描述 Schema:字段怎么设计才有用
所有触达能力的第一步,是让模型"知道"你有哪些工具。这一步做得不好,后面全是连锁问题。我见过很多人随便写个工具描述就丢给模型,然后抱怨模型总是选错工具。实际上根子往往在工具描述质量上。
我自己实践下来,一个好的工具 Schema 至少要包含这些字段:
| 字段 | 作用 | 注意事项 |
|---|---|---|
name | 工具唯一标识 | 用 snake_case,避免拼音缩写 |
description | 模型的"使用说明书" | 写清楚什么场景用、什么场景不用 |
parameters | 参数定义,符合 JSON Schema 规范 | 类型、必填、枚举、格式都要写全 |
timeout_ms | 单次调用最大耗时 | 不能为 0,否则线程直接卡死 |
idempotent | 是否幂等 | 非幂等工具要触发防重逻辑 |
permission_level | 权限等级 | L0/L1/L2,后面详细讲 |
cost_estimate | 预估单次成本 | 用于预算控制 |
result_schema | 返回结果的结构说明 | 帮模型更好理解返回内容 |
这是我推荐的一个 JSON 示例:
{ "name": "query_orders", "description": "查询指定日期范围内的订单。用于需要统计订单数量、金额、分类汇总的场景。不要用于修改订单状态,不要用于查询用户信息。", "parameters": { "type": "object", "properties": { "start_date": { "type": "string", "format": "date", "description": "开始日期,格式 YYYY-MM-DD,含当天" }, "end_date": { "type": "string", "format": "date", "description": "结束日期,格式 YYYY-MM-DD,含当天" }, "group_by": { "type": "string", "enum": ["category", "channel", "none"], "description": "聚合维度,不传则返回总体汇总" } }, "required": ["start_date", "end_date"] }, "timeout_ms": 8000, "idempotent": true, "permission_level": "L0", "cost_estimate": 0.002 }一个容易被忽略的点是description里的负向引导。"不要用于修改订单状态"这种话,我一开始觉得多余,但实测下来非常有用。因为模型在没有把握时总会倾向于用"看起来最像"的工具,如果工具描述里明确说不该用它,选错的概率会低很多。
3.2 三类高频工具的接入范式:HTTP、Python 脚本、数据库
不同团队的工具形态千差万别,但归纳起来无非三类:调外部 HTTP 接口、跑内部脚本逻辑、操作数据库。分类的目的是为了复用治理策略。
HTTP API 封装类
这是最基础的一类工具。内部服务有现成的 REST API,Agent 要调用,不能直接给它一个裸 URL 让模型自己去拼,因为模型可能把 GET 和 POST 搞混,也可能把鉴权 header 传错。正确做法是写一个薄封装:
import httpx from agent_reach import register_tool @register_tool( name="create_ticket", description="创建技术支持工单。用于用户上报问题时创建处理单,不要用于查询已有工单。", params_schema={...}, timeout_ms=5000, idempotent=True, permission_level="L1", ) async def create_ticket(user_id: str, title: str, description: str, priority: str = "low"): async with httpx.AsyncClient(timeout=5) as client: resp = await client.post( "https://cs.internal.example.com/api/tickets", json={ "user_id": user_id, "title": title, "description": description, "priority": priority, }, headers={"X-Internal-Auth": get_credential("cs", "agent")}, ) resp.raise_for_status() return resp.json()这里的关键是:Agent 永远接触不到真实的 URL 和密钥。它只需要知道工具名和参数,实际请求细节全部藏在 handler 里。这样即使模型发疯了乱传参数,最坏也就是调用工具失败,不会把内部地址泄露出去。
Python 脚本执行类
有些操作没有现成 API,比如"对这批数据做异常值检测然后生成报告"。这类逻辑你可以以函数形式注册,内部包含业务规则。注意一点:不要让 Agent 任意拼接 Python 代码执行。虽然自制 agent 里很多人图省事直接装个 Python 解释器让模型写代码,但生产环境务必不要这样做,因为不可控。正确做法是把可复用的逻辑封装成固定函数,模型只能选择"调用哪个函数",不能自己发明代码。
数据库操作类
数据库类工具最危险,也最常用。我的建议是:Agent 不要直连数据库,而是在触达层做一层只读 SQL 校验,写操作单独走经过 review 的存储过程或 API。
只读查询也不是直接透传 SQL,要加限制:强制LIMIT、禁止没有条件的UPDATE/DELETE(这类写操作根本不放给 Agent)、查询超时控制在 5 秒以内、返回行数上限默认 100 条。另外所有 SQL 都要校验是否是单条语句,防止模型通过分号拼多条指令。
@register_tool( name="query_customer_orders", description="按客户编号查询历史订单列表。仅支持只读查询。", params_schema={...}, permission_level="L0", ) async def query_customer_orders(customer_id: str, limit: int = 20): if limit > 100: limit = 100 # 参数化查询,绝不拼接字符串 sql = "SELECT order_id, order_date, total_amount, status FROM orders WHERE customer_id = ? LIMIT ?" async with db_pool.acquire() as conn: rows = await conn.fetch(sql, customer_id, limit) return [dict(r) for r in rows]只要不是特殊业务需求,写操作一律不往 Agent 这边开放。即便开放,也必须是经过专门封装的、带人工确认的 L2 工具。
3.3 流式进度透出与结果裁剪:别让 Agent 被一坨大输出噎住
工具执行有两种耗时形态。一种是"秒回",比如查个用户信息,一两秒出结果。另一种是"长任务",比如生成一份周报,或者跑批量分析,可能几十秒甚至几分钟。长任务如果让模型傻等,往往会触发它的超时重试,越重试越乱。
解决办法是把长工具设计成两段式:先返回一个PENDING状态,附带一个task_id,然后异步执行。Agent 后续可以通过poll_task工具主动查询进度。这套逻辑在很多工具平台里有现成模式,但自己实现也不难,核心是要在触达层维护一个任务状态表。
结果裁剪同样重要。一个查询工具如果返回一万行数据,直接塞回模型上下文里,问题不是"浪费 token"这么简单——大段无用的数字会污染模型对后续推理的判断,甚至导致它把一条无关记录当成关键信息。我的实践经验是:
- 返回给模型的内容,默认限制在 2000 字符以内。
- 超过的部分截断,并附带一行提示:
结果已截断,共 15230 行,仅展示前 10 行。可调用 query_detail 获取指定记录的完整信息。 - 对于本身是列表的结果,优先返回总数和关键的几条摘要。
这样既保住了信息量,又不会把模型上下文变成垃圾场。
4. 权限分级与成本闸门:能力越大,越要管得住
4.1 动作分级与最小权限原则
给 Agent 开放触达能力前,必须想清楚一件事:每个工具到底有多大的破坏半径。破坏半径和代码行数无关,和它能影响的业务范围有关。我按这个原则把工具分成三级。
| 等级 | 定义 | 典型工具 | 管控方式 |
|---|---|---|---|
| L0 | 只读、无副作用 | 查订单、查库存、查天气 | 自动放行 |
| L1 | 普通写操作,影响单个业务对象,可补偿 | 下单、改备注、发通知 | 自动放行 + 幂等校验 |
| L2 | 高危操作,影响面大或难以撤销 | 批量删除、改价格配置、退款 | 必须人工确认 |
刚开始接触 Agent 的团队往往会走极端:要么全部放开,模型想干嘛干嘛;要么全部卡死,每个动作都要人点一下确认,结果 Agent 的自动化价值丧失殆尽。分级的意义就在中间地带:L0 自动,L1 自动但带防重,L2 人工。
4.2 高风险动作的人工确认通道怎么设计
L2 工具的人工确认机制,很多人以为是"弹一个窗口让人选是或否"那么简单。实际要考虑的问题多得多。
第一,确认动作必须独立于 Agent 主循环。如果确认动作也走 LLM 推理,那模型完全可以自己"确认"自己——这不是人工确认。我用的方案是触达层直接对接企业微信/钉钉/邮件通知,生成一个带 token 的确认链接,用户点"同意"或"拒绝"后,结果写回任务状态表,Agent 主循环轮询到这个结果再继续。
第二,确认请求里必须包含足够的信息,让人不用点进去看原始对话就能判断。必要字段:谁发起的任务、要调用什么工具、参数是什么(脱敏后)、预计影响范围、成本预估。这样审批人三秒就能判断。
第三,要加确认超时。一个 L2 操作如果十分钟没人确认,默认拒绝而不是默认通过。这个默认行为必须写在代码里而不是文档里,因为实际运行时没人看文档。
await notify_approval( task_id=task_id, to=["ops-oncall@internal"], title=f"Agent 请求执行退款操作", body=( f"发起人: {user_display_name}\n" f"工具: refund_order\n" f"参数: order_id={order_id}, amount={amount}\n" f"预估影响: 退款给客户,资金流水将产生一条退款记录\n" f"确认超时: 10 分钟,超时自动拒绝\n" ), approve_url=f"https://reach.internal.example/approve/{token}", )4.3 速率与成本的双层限额
成本失控是 Agent 项目上线后最常见的"惊喜"。模型的一次错误重试循环,可能十分钟烧掉几百块。所以触达层必须同时做两层限额。
第一层是速率限额,限制的是"单位时间内允许触达多少次"。按agent_id和user_id两个维度分别限。比如单个 Agent 每分钟最多 60 次工具调用,单个用户每分钟最多 30 次。一旦超过,后续工具调用请求直接返回RATE_LIMITED错误,并且这个错误信息要能传回给模型,让它"换个思路,不要疯狂重试"。
第二层是成本限额。每次工具调用的成本可以估算:LLM 推理 token 费 + 工具本身的算力/API 费。在触达层维护一个累加器,当某个会话累计成本超过阈值(比如 20 元)时,后续触达请求全部拒绝,模型只能向用户说明"本次会话预算已用完"。这个机制听起来粗暴,但能防住 90% 的失控跑飞。
我见过太多项目,Agent 测试阶段表现优秀,上线后业绩惊人,月底账单更惊人。成本闸门一定不能省。
5. 可观测性建设:让 Agent 的每一步都留下脚印
5.1 Trace 字段与执行日志的标准结构
Agent 系统天然是黑盒。模型怎么想的你不知道,它为什么调这个工具你也不知道,甚至调了之后结果如何往往也没人关心。而 Agent-Reach 的价值之一,就是把"黑盒"变成"白盒"。
我设计 touch trace 的标准字段如下:
| 字段 | 说明 |
|---|---|
request_id | 一次工具调用请求的唯一 ID |
session_id | 所属会话 ID,串联多轮调用 |
agent_id | 发起调用的 Agent 标识 |
user_id | 实际用户标识,用于鉴权和账单 |
tool_name | 被调用的工具名 |
args_hash | 参数加盐后的 hash,用于幂等判断 |
input_args_summary | 参数的脱敏摘要(不能存全文) |
status | 成功/失败/超时/拒绝/限流 |
error_class | 结构化错误分类 |
duration_ms | 耗时时长 |
cost_usd | 本次调用成本 |
created_at | 触发时间 |
每个字段都有用途。args_hash是我后来加的:模型在重试时可能用完全相同的参数调用同一个工具,靠这个 hash 可以快速识别出"又是同一个动作",从而触发防重逻辑。
5.2 用失败分类和告警规则快速定位 Agent 的"掉链子"点
只有字段没有分析等于白搭。触达层每天会产生几千条 trace,必须有对应的指标和告警才能发挥价值。
我重点看三类指标:
- 工具调用成功率:按工具维度统计。某个工具成功率突然下降,多半是上游 API 出问题了。
- P95 耗时:Agent 卡不卡,看这个。一个查询工具如果 P95 超过 5 秒,模型大概率会判定调用失败并重试,然后产生连锁错误。
- 单会话工具调用次数:正常任务一般 3~8 次调用。如果某个会话的调用次数超过 20 次,基本可以断定模型陷入了"尝试-失败-重试"的循环。这种会话要重点审查,往往能发现不少工具描述或参数设计的缺陷。
告警规则有两类是不可或缺的。一类是技术告警:某个工具连续失败 5 次、单会话成本超过阈值、某类错误码数量突增。另一类是业务告警:L2 工具的拒绝率突然升高,往往说明 Agent 的行为让人不信任了,需要检查它是不是总在尝试做一些不该做的事。
有了这些观测数据,再有人抱怨"Agent 不好用"时,你不再需要靠猜,打开 trace 面板就能看到是模型选错了工具,还是工具本身超时,还是权限配置不合理。所有问题都会原形毕露。
6. 把 Agent-Reach 推到线上之前,我们踩过的坑
6.1 schema 写不严谨,模型就会"一本正经地传错参数"
最大的坑在工具 Schema 上。你以为写清楚了,模型不这么觉得。
有段时间我们的订单查询工具经常报错,查 trace 发现模型经常把start_date传成"start_date": "2025年5月1日"这种中文字符串。我一开始怀疑是模型不识字,后来核查发现是 Schema 里只写了format: date,没有在 description 里明确说明格式。模型在不确定格式时,会倾向于用"人类朋友看起来没问题"的方式填充。
所以我把所有日期、金额、枚举参数都在 description 里补充了明确示例:
"start_date": { "type": "string", "description": "开始日期,严格使用 YYYY-MM-DD 格式,例如 2025-05-01。不要使用中文或其它格式。" }加这一句之后,这类错误明显减少。还有一个相关问题:required字段没写或者写少了。模型会自作多情地漏传参数,然后当你返回校验错误时,它不是去补全参数,而是换一个工具或者直接开始编结果。所以参数预校验必须在触达层做,不能指望模型自觉。
6.2 重试风暴与上下文塞满:钱怎么悄悄烧掉的
这是我印象最深的一个坑。有一版 Agent 在调用一个耗时较长的报表工具时,30 秒超时,模型收到超时错误后,没有迟疑地开始重试。因为超时错误信息里没有说明"这是同一个工具第三次调用",模型每次重试都觉得自己是第一次调用。最终一次任务里,模型连续调用了同一个工具 14 次,每 30 秒一次,烧掉了相当于正常任务 10 倍以上的成本。
修复方案是双管齐下。第一,触达层在返回错误信息时附带重试计数和上下文提示:
tool query_weekly_report failed: TIMEOUT after 30s retry_count: 2/3 session total tool_calls: 7 建议:确认该报表任务已提交后再等待,不要在 30 秒内重复提交相同参数。第二,Agent-Reach 在会话层面维护"已失败工具黑名单"。同一个工具在同一个会话内连续失败超过 3 次后,触达层直接拒绝后续同参数调用,返回TOOL_BLACKLISTED,逼着模型换思路。
上下文塞满是另一个钱包杀手。一次我们没做结果裁剪,某个查询接口返回了约 2 万行明细数据,模型为了"读懂"这些数据,下一轮多消耗了 6 万多 token。那一整天我们都在给模型的高额账单找出路。后来我规定:工具返回内容超过 2000 字符必须截断,截断必须带摘要,摘要默认由触达层提取而不是模型来做——让模型去读全文本来就是让它花你的钱。
6.3 状态一致性与时区问题:触达错了才是真事故
触达层的正确性直接影响业务,有两类一致性细节一定要处理。
一类是"取消后的动作"。用户取消了任务,但 Agent 此前已经发出的一些工具调用还在执行,比如发短信、改状态。如果没有记录"这个会话已经取消",后续返回的结果还会继续触发新的动作。Agent-Reach 在处理每次工具调用前会先检查会话状态,如果已标记为 CANCELLED,直接拒绝新调用,不再进入执行层。
另一类是时区问题。订单系统的日期默认是 UTC,用户问"昨天的订单",如果你把本地时间(UTC+8)的"昨天"传给查询接口,查出来其实是 UTC 前一天,差 8 小时。这个坑我们是在一次对账时发现的。解决办法很简单:所有日期参数在触达层统一做显式时区转换,并且在工具描述里标注"所有日期均按 UTC+8 解释,如订单系统使用 UTC 请在参数中说明"。
这些细节看似琐碎,但在 Agent 场景里,模型的推理能力会让小问题的破坏力成倍放大——它会基于错误数据继续往下推,推出一连串看起来合理但完全错误的结论。
6.4 测试不用真模型:让触达层变成可确定性的系统
Agent 项目的测试一直很尴尬:用真模型测,慢、贵、还容易受模型版本影响;不用真模型,又觉得没测到位。
我现在的做法是:触达层的测试一律不用真模型。在测试环境里注入一个FakeLLM,它的行为是可编程的——你可以指定"这一步它会调用 create_ticket,参数是什么,下一步它会回答一封邮件"。这样触达层的每条路径都能被确定性执行:参数校验、权限拦截、超时重试、人工确认、限流熔断,全部可以在普通 CI 里跑。
另外一个单独测试维度是幂等性。对每个标记为idempotent的工具,测试同样参数调用两次,断言第二次不会产生新的副作用。对标记为 L2 的工具,测试"未确认时拒绝执行"和"确认超时后自动拒绝"两条路径。
把触达层做成确定性系统之后,Agent 应用整体的可测试性大幅提升,因为真正容易出 bug 的环节都被隔离在这一层了,模型的不确定性只影响"决策",不影响"执行"。
开始做 Agent-Reach 之前,我总以为 Agent 项目的难点在模型调优和 prompt 工程。做完这个触达层才发现,模型输出什么只是入口,后续那套工具注册、权限分级、执行治理、观测追踪的链路,才是决定一个 Agent 能不能真正跑在业务线上的关键。现在每接入一个新工具,我都会先问三个问题:它的破坏半径多大?如果模型乱用会怎样?出问题时我能秒级看到现场吗?这三个问题能想清楚,Agent 的上限不一定多高,但下限一定不差。如果你也在做类似的事,希望这份记录能帮你把触达层做得比我第一版更稳。