Agent-Reach 这个词第一次出现在我视野里的时候我脑子里蹦出来的是智能体触达四个字。做了几年智能体落地我越来越确信一件事模型本身的能力早就不是瓶颈了真正的瓶颈是它能不能把手伸出去准确地碰到外部的接口、数据库、工单系统、消息通道并且碰完之后拿到一个可信的结果。Agent-Reach 瞄准的就是这件事——它想做的是一层统一的触达层把工具注册、身份授权、调用编排、错误处理、结果归一化这些脏活累活收拢到一处让上层的智能体只关心我下一步要做什么而不用操心这个接口的鉴权头怎么拼、超时了要不要重试、返回的 JSON 嵌套了七层怎么读。我自己带过两个智能体项目第一个项目就是把工具调用直接写死在业务代码里结果半年后维护成本高得离谱第二个项目才补上这层抽象。所以这篇东西我想把 Agent-Reach 这类触达层的设计思路、关键参数怎么算、代码怎么落地、坑在哪里一次讲透。适合三类人看正在写智能体应用的后端工程师、做智能客服或智能运营方向的产品技术团队、以及正在纠结要不要自研编排层的技术负责人。不管你现在用的是哪家模型、哪套框架触达层这层逻辑都是相通的。1. Agent-Reach 到底解决什么问题1.1 从能聊到能办成事的那道坎很多人第一次搭智能体的体验都差不多接上模型写好提示词让它回答几个问题效果惊艳。然后你试着让它帮我查一下上周那张工单处理到哪一步了它开始胡说八道或者干脆告诉你自己没有这个能力。问题不出在模型身上出在它和真实系统之间隔着一道鸿沟。这道鸿沟由四段组成。第一段是描述问题模型不知道你系统里有哪些能力可以调用也不知道每个能力的入参格式。第二段是授权问题就算知道它凭什么代表某个用户去调这个接口第三段是可靠性问题接口超时了、返回 500 了、限流了谁来兜。第四段是可追溯问题调完之后出了问题谁来判断是哪一步做错了。Agent-Reach 这类触达层的价值就是把这四段拆成四个独立的、可单独测试和迭代的模块。我见过太多团队在这四个问题上反复横跳。今天把工具描述写在系统提示词里明天改成函数调用后天发现权限管不住又加一层网关改到最后代码里到处是if tool_name xxx的硬编码。触达层存在的意义就是让这类改动收敛到一个地方。后面第 4 节我会给一份可以直接抄的代码骨架但在那之前先把概念理清楚更重要因为概念不清楚抄了代码也只是换个地方堆砌。1.2 Reach 的三层含义够得着、信得过、查得到我把 Agent-Reach 里的 Reach 理解成三层递进的能力这三层缺一层整套东西就撑不起来。够得着指的是工具体系的完整性和可发现性。一个团队的业务系统里能做的事情可能有几百个但真正适合交给智能体的可能只有二三十个。挑选标准很朴素输入输出结构化、副作用可控、调用频率可预估。挑出来之后还要给每个工具写清楚什么时候该用它这句话比接口签名重要十倍因为模型判断用不用某个工具靠的就是这句自然语言描述。信得过指的是权限和副作用的边界。读操作和写操作必须分开管写操作里还要再分可回滚和不可回滚。我一般要求任何不可回滚的操作都必须经过人工确认这一步这个设计不是技术妥协是业务底线。查得到指的是全链路可观测。每次调用都要留下 trace谁触发的、模型怎么想的、用了哪个工具、参数是什么、耗时多少、返回了什么、有没有被裁剪。出了事故你能在五分钟内定位到具体是哪一步而不是靠猜。这三层听起来抽象落到工程上其实很具体我在第 3 节会把每一层的实现细节拆开讲。1.3 什么样的团队现在就该考虑它不是所有团队都需要现在就上触达层。如果你的智能体只做问答压根不调外部系统那大可不必。但只要满足下面任意两条就值得认真考虑工具数量超过 5 个且还会增长有多个业务系统需要打通需要区分不同用户或不同租户的权限或者已经踩过一次模型误调了写接口的坑。还有一个更现实的信号你的提示词工程已经开始失控了。系统提示词超过三千字里面塞满了工具说明、格式约定、注意事项改一处要回归测试半天。这种时候引入触达层把工具描述从提示词里搬到结构化的清单文件里收益是立竿见影的。我自己实测过一次迁移系统提示词从 3200 字压到 700 字工具选择准确率反而从 78% 提升到了 91%。原因很简单结构化的描述比散文式的描述更容易被模型正确解析而且清单里的字段可以按需注入不相关的工具根本不会出现在当轮上下文里。2. 整体架构设计与选型思路2.1 为什么必须把触达层从业务逻辑里剥出来最常见的做法是业务代码里写一个函数handle_user_message里面调用模型模型返回工具名然后一个巨大的 switch 分发到各个真实接口。这个结构在前两周非常好用在第三个月开始变成灾难。原因有三个。第一测试困难。权限校验、超时处理、结果裁剪全混在业务分支里你想单独测一下超时后重试一次这个逻辑得先构造一个假的用户会话。第二策略无法统一。今天要求所有写操作加二次确认你得改二十个分支。第三观测数据不完整。日志格式各写各的聚合分析的时候根本对不齐字段。剥出来的方式很简单在智能体和真实系统之间插一层网关智能体只跟网关说话网关统一负责鉴权、限流、重试、幂等、裁剪、埋点。这层的输入是一个标准化的调用请求工具名 参数 调用者上下文输出是一个标准化的调用结果状态 数据 元信息 错误码。所有策略都在这层实现业务系统保持原样不动。注意网关这层不要试图理解业务语义。它的职责是把请求安全地送过去把结果完整地带回来一旦开始在里面写业务判断这层就会重新长成一个新的泥球。2.2 四个核心组件的职责边界落地的时候我一般拆成四块边界清晰到可以分给不同的人维护。第一块是工具注册表。存放所有工具的清单文件负责版本管理、灰度开关、按场景筛选。它不执行任何调用只回答现在有哪些工具可用。第二块是调用网关。真正执行调用的地方负责鉴权、限流、超时、重试、幂等、结果裁剪和归一化。它不关心里程碑式的业务逻辑只看单次调用。第三块是编排器。管理智能体的多步循环组装上下文、调用模型、解析工具意图、把结果回填、判断是否终止。它是唯一有多步状态的组件。第四块是观测与评测。收集 trace、计算指标、跑回归测试集。这块最容易在项目初期被忽略然后在出问题的时候被临时补上补出来的东西通常很难用。职责边界的价值在上线之后才体现出来。有一次我们遇到模型疯狂重复调用同一个查询接口排查下来发现是结果裁剪把关键字段裁掉了模型拿到的返回值里没有它要找的信息于是再调一次。这种问题如果四块混在一起你得从提示词一路看到数据库分开之后看 trace 里裁剪前 vs 裁剪后的对比两分钟就定位了。2.3 选型对照自研、编排框架、写死在业务里到底要不要自己写我的建议是先看团队规模和迭代节奏。下面这张表是我这几年踩下来总结的对照你可以直接拿去当决策依据。方案开发成本可控性多租户隔离可观测适合场景写死在业务代码极低低靠人肉保证基本没有单工具、只读、验证期原型通用工作流编排中等中等框架能力决定框架自带流程固定、步骤可枚举的场景现成智能体框架内置工具链低偏低通常较弱一般快速验证、工具少、无强权限需求自研触达层高高自己说了算自己设计工具多、权限复杂、长期维护我的实际选择是混合核心的鉴权、幂等、观测三块自己写因为这三块和业务安全直接挂钩工具调用的编排循环优先用现成框架因为这部分逻辑通用度高自己写不划算工具清单用纯配置文件不写代码方便产品和运营也能改。还有一个容易被忽略的维度是故障域。写死在业务里意味着工具挂了你的业务流程就挂了加了网关之后网关可以做降级返回当前该能力暂时不可用请稍后再试智能体可以据此换一个方案比如改成提交一个待办而不是实时查询。这个能力在真实生产环境里的价值比省下来的那点开发成本高得多。3. 关键机制拆解与参数怎么定3.1 工具清单怎么写才不会被模型误用工具描述写得好不好直接决定了整套系统的准确率上限。我总结了一条硬标准描述里必须包含什么时候用和什么时候不要用两句。只写功能说明的工具模型误用率会高出一大截。字段设计上我一般固定这几个名称用域.动作的格式比如ticket.query、order.refund前缀能帮模型建立领域感描述一句话讲清做什么触发条件写用户问什么的时候用排除条件写什么情况下别用这个改用那个入参用 JSON Schema 描述并且标注哪些是必填副作用等级标 read / write / destructive幂等标记决定能不能重试。name: ticket.query version: 1.2.0 description: 按工单号或客户手机号查询工单的当前状态、处理人和最近一次流转时间 when_to_use: 用户询问某张工单的进度、处理人或在创建工单前需要确认是否已有同类工单 when_not_to_use: 用户只是问工单的办理规则或时限政策不要调用本工具直接用知识库回答 scope: [ticket:read] side_effect: read idempotent: true timeout_ms: 3000 retry: {max: 1, backoff_ms: 200} rate_limit: {qps: 20, burst: 40} redact: [phone, id_card] input_schema: type: object properties: ticket_id: {type: string, description: 工单编号形如 T2024xxxx} phone: {type: string, description: 客户手机号与 ticket_id 二选一} anyOf: - required: [ticket_id] - required: [phone]when_not_to_use这个字段是我加得最值的一个。上线前统计过加了它之后该查知识库却去查工单的误调用下降了大概六成。原因是模型看到一个查询类工具就容易条件反射地调用明确告诉它这种情况别用比事后在提示词里补一句规则有效得多。提示描述文本的长度要控制。我试过把触发条件写成三行准确率反而下降因为关键信息被稀释了。每个字段控制在 40 到 80 字之间效果最好。3.2 权限 scope 的最小授权设计权限这块核心原则只有一条按调用者身份授权不按智能体身份授权。很多团队图省事给智能体一个超级账号让它代查所有数据这是埋雷。正确做法是网关在发起调用时把当前用户的身份凭据透传下去接口侧按用户身份做校验。scope 的粒度怎么定我一般按数据域 操作类型两级。比如ticket:read、ticket:write、order:refund。粒度太细会导致配置爆炸太粗会导致越权。两级基本够用特殊场景再加一维租户标识就行。还有一个实操细节写操作必须绑定确认令牌。流程是智能体先生成一个待确认动作网关返回一个带签名的令牌和一句人类可读的描述前端展示给用户确认后再拿令牌发起真正的写调用。令牌有效期给 60 到 120 秒一次性使用。这样做的好处是写操作的确认和真正的执行解耦了用户看清楚了再点智能体也没法绕过这一步。3.3 幂等、重试与超时预算的算法这三个参数是最容易拍脑袋定的也是最容易出事的。我讲一下我的算法。幂等键的计算把租户标识、工具名、业务主键、规范化之后的参数 JSON 拼起来取 SHA-256 前 16 位。参数规范化指的是键按字母序排列、去掉空值、数字统一转字符串。同一个用户对同一张工单连续点两次键是一样的第二次直接返回第一次的缓存结果不会重复创建。重试策略只对读操作和幂等写操作开放重试最多 1 次退避 200 毫秒加随机抖动。不可回滚的写操作一次都不重试宁可报错让用户重来。我踩过一个坑早期给退款接口加了自动重试结果网络抖动导致同一笔退款发起了两次虽然接口侧有去重但用户收到了两条通知投诉了一大轮。超时预算的计算思路是自顶向下拆。假设单轮对话的总预算是 25 秒那么先扣掉模型推理的时间一次典型对话大概 3 轮模型调用每轮 4 秒共 12 秒。再扣掉结果回填和上下文组装的开销大概 1.5 秒。剩下的 11.5 秒留给工具调用按最坏情况 3 次调用算单次上限就是 3.8 秒打八折取 3 秒作为单工具超时。这个数字写进清单文件里网关强制执行超了就中断并返回超时错误。参数推荐值依据单工具超时2 到 3 秒总预算扣掉模型耗时后按最大调用次数均分读操作重试最多 1 次重试能覆盖大部分瞬时抖动再多收益递减退避间隔200ms 随机抖动避免同一时刻大量请求同时重试写操作重试0 次副作用不可控宁可失败幂等键有效期24 小时覆盖用户重复操作的常见时间窗确认令牌有效期60 到 120 秒够用户看清描述并决策又不会长期暴露3.4 结果回填与上下文瘦身这一步是最容易被低估的。真实接口返回的数据往往大得离谱一个查询接口返回 200KB 的 JSON 是常事。全塞回上下文一是贵二是模型会被淹死。我的处理是三级裁剪。第一级是字段白名单在清单里声明这个工具的结果只保留哪几个字段其余的丢掉。第二级是行数截断列表类结果最多保留 20 条超出的部分用一句共 N 条已展示前 20 条代替。第三级是大对象外置超过 8KB 的结果整体存到对象存储回填给模型的是一个句柄加一段摘要模型如果需要更多细节再用句柄调另一个工具取。裁剪最关键的一点是不能裁掉模型判断下一步所需的字段。我前面提到的那个重复调用的坑就是因为裁剪时把状态字段漏掉了模型看不到结果状态以为查询失败了。后来我的做法是在每个工具的清单里显式声明must_keep字段这些字段无论多大都不裁剪这个列表在代码评审时会被重点检查。4. 动手搭一个最小可用的 Agent-Reach 触达层4.1 环境与目录结构先给一份我常用的目录结构不用完全照抄但分层思路建议保留。agent-reach/ manifests/ # 工具清单纯 YAML产品也能改 ticket.query.yaml ticket.create.yaml reach/ registry.py # 加载清单、版本管理、场景筛选 gateway.py # 鉴权、限流、超时、重试、幂等 normalize.py # 结果裁剪与归一化 trace.py # 埋点与链路追踪 adapters/ # 真实系统的适配层一个系统一个文件 ticket_system.py orchestrator/ loop.py # 智能体多步循环 tests/ cases/ # 评测用例依赖上Python 3.11 起步httpx做异步调用pydantic做参数校验pyyaml读清单tenacity可选我倾向自己写重试因为要跟幂等逻辑耦合。缓存用内存字典起步就行多实例部署再换 Redis。4.2 工具网关注册、鉴权、幂等、重试下面是网关的核心骨架我把四个职责都收在一个类里靠装饰器注册适配器。这段代码可以直接跑通你只要把适配器换成自己的接口就行。import asyncio, hashlib, json, time, random from dataclasses import dataclass, field dataclass class ToolSpec: name: str side_effect: str # read | write | destructive idempotent: bool timeout_ms: int retry_max: int retry_backoff_ms: int required_scope: str must_keep: list[str] field(default_factorylist) class ReachGateway: def __init__(self, specs: dict[str, ToolSpec], idem_store, tracer): self.specs specs self.idem idem_store self.tracer tracer self.adapters {} def adapter(self, name): def wrap(fn): self.adapters[name] fn return fn return wrap def idem_key(self, tenant, tool, biz_key, params): norm json.dumps(params, sort_keysTrue, separators(,, :), ensure_asciiFalse) raw f{tenant}|{tool}|{biz_key}|{norm} return hashlib.sha256(raw.encode()).hexdigest()[:16] async def call(self, tool, params, ctx): spec self.specs[tool] # 1. 权限 if spec.required_scope not in ctx.scopes: return {status: denied, error: fmissing scope {spec.required_scope}} # 2. 幂等 key self.idem_key(ctx.tenant, tool, params.get(biz_key, ), params) if spec.idempotent: hit await self.idem.get(key) if hit is not None: self.tracer.log(tooltool, idem_hitTrue) return hit # 3. 调用 重试 max_try 1 (spec.retry_max if (spec.side_effect read or spec.idempotent) else 0) last_err None for attempt in range(max_try): try: result await asyncio.wait_for( self.adapters[tool](params, ctx), timeoutspec.timeout_ms / 1000, ) if spec.idempotent: await self.idem.set(key, result, ttl86400) self.tracer.log(tooltool, attemptattempt 1, okTrue) return result except Exception as e: last_err e self.tracer.log(tooltool, attemptattempt 1, okFalse, errstr(e)) if attempt max_try - 1: await asyncio.sleep(spec.retry_backoff_ms / 1000 random.random() * 0.1) return {status: failed, error: type(last_err).__name__}几个值得说明的地方。biz_key是业务主键比如工单号参数里没有的时候传空串退化成参数完全一致才算重复这个降级是合理的。重试条件里我用了spec.side_effect read or spec.idempotent意思是写操作只有在明确标注幂等时才允许重试这个判断不要放松。asyncio.wait_for负责超时中断注意它是取消任务而不是杀死任务适配器内部如果有清理逻辑要放在 try/finally 里。4.3 接一个真实业务动作工单查询与创建适配器这层是唯一需要接触真实系统的地方尽量写薄把转换逻辑全部丢给normalize。下面是一个查询和一个创建的示例。gateway.adapter(ticket.query) async def query_ticket(params, ctx): resp await ticket_client.get( /api/ticket/detail, params{ticketId: params.get(ticket_id), phone: params.get(phone)}, headers{Authorization: ctx.user_token}, ) resp.raise_for_status() return normalize_ticket(resp.json()) def normalize_ticket(raw): return { status: ok, data: { ticket_id: raw.get(id), state: raw.get(statusName), # must_keep assignee: raw.get(handlerName), last_update: raw.get(updateTime), summary: (raw.get(content) or )[:120], }, meta: {source: ticket_system, truncated: False}, }创建类操作要多两步先走确认令牌再落库。适配器本身只负责最后一跳。gateway.adapter(ticket.create) async def create_ticket(params, ctx): if not ctx.confirm_token or not verify_token(ctx.confirm_token, ctx.user_id, params): return {status: need_confirm, preview: f将为客户创建一张【{params[category]}】工单 f内容{params[summary][:60]}} resp await ticket_client.post(/api/ticket/create, jsonto_api(params), headers{Authorization: ctx.user_token}) return {status: ok, data: {ticket_id: resp.json()[id]}}注意确认令牌的校验函数里必须绑定用户 ID 和参数摘要光签名不够。否则用户 A 拿到的令牌可能被用户 B 复用这在多租户场景下是明确的越权。4.4 把智能体循环串起来编排循环的骨架比想象中简单关键是要有步数上限和预算控制不能让模型无限循环。async def run(user_input, ctx): ctx.scopes load_scopes(ctx.user_id) messages [{role: system, content: build_system_prompt(ctx.scopes)}] messages.append({role: user, content: user_input}) for step in range(MAX_STEPS): # MAX_STEPS 6 if time.monotonic() - ctx.start BUDGET_SEC: # BUDGET_SEC 25 return 当前请求处理时间过长我已终止操作请稍后重试。 resp await llm.chat(messages, toolsregistry.available(ctx.scopes)) if not resp.tool_calls: return resp.content messages.append(resp.as_message()) for call in resp.tool_calls: result await gateway.call(call.name, call.args, ctx) messages.append({role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse)}) return 这个问题需要多步操作我已暂停建议您换一种方式描述需求。这里有两个参数值得说。MAX_STEPS设 6 是我实测的经验值超过 6 步还不收敛的任务绝大多数是提示词或工具描述有问题让它继续跑只会浪费钱。BUDGET_SEC是兜底防止某个工具卡住导致整轮对话挂起。系统提示词通过build_system_prompt(ctx.scopes)动态生成只注入当前用户有权限的工具这样既省 token也避免模型去尝试它没权限的操作。4.5 可观测性日志、Trace 与埋点埋点做得好不好决定了你排查问题的速度。我要求每条 trace 至少包含这些字段会话 ID、步骤序号、工具名、参数摘要脱敏后、状态、耗时、重试次数、命中幂等、裁剪前后字节数、模型 token 用量。class Tracer: def log(self, **kw): kw[ts] time.time() kw[trace_id] self.trace_id kw[step] self.step LOG.info(json.dumps(kw, ensure_asciiFalse)) self.spans.append(kw) def log_trim(self, tool, before, after): # 裁剪比例是个非常灵敏的指标突然变大通常意味着上游返回结构变了 self.log(tooltool, bytes_beforebefore, bytes_afterafter, trim_ratioround(1 - after / max(before, 1), 3))trim_ratio这个字段是我后来加的帮了大忙。有一次上游接口改版把原来平铺的字段套进了一层data里裁剪逻辑按原来的路径取不到值trim_ratio从平时的 0.8 一下子掉到 0.05监控告警直接指出来了比等用户投诉早了两个小时。5. 评测与灰度怎么证明触达能力真的变强了5.1 三张表把效果说清楚上线前一定要有一套固定的评测集。我的做法是从真实会话日志里抽样人工标注成输入 期望工具序列 期望结果初期 50 条就够稳定后扩到 300 条。每次改提示词、改工具描述、换模型版本都跑一遍。评测结果看三张表。第一张是效果表任务成功率、工具选择准确率、参数填充准确率。第二张是效率表平均步数、平均耗时、平均 token 消耗。第三张是安全表越权拦截率、误触发写操作次数、确认令牌绕过尝试次数。指标迁移前迁移后说明任务成功率71%89%抽样 120 条真实会话工具选择准确率78%91%首轮选对工具的比例平均步数4.32.9一次完成不再反复试探单任务 token11.2k6.8k上下文瘦身的主要贡献误触发写操作3 次/千次0 次确认令牌机制生效P95 触达延迟6.4s3.1s超时预算与并发控制生效这些数字是我在一个真实项目里迁移前后对比出来的口径可能和你不一样但趋势有参考价值。特别想说一下平均步数这个指标它的改善往往比成功率更能说明触达层设计得好不好因为步数下降意味着模型想得更清楚而不是靠多试几次蒙对。5.2 灰度发布与熔断降级工具清单是配置改配置比改代码容易也就更容易出事。我的做法是给每个工具加一个enabled开关和rollout_percent字段新工具先在 5% 的会话里放量观察三天确认没有异常再全量。熔断策略按工具独立配置连续 10 次调用失败进入 60 秒熔断期期间所有对该工具的调用直接返回当前能力暂时不可用。熔断期内有一次半开探测成功就恢复。这个策略写死在网关里不给业务配置的权利因为业务总会觉得我这次很急别熔断然后一起把上游打挂。降级路径也要提前设计好。查询类工具挂了降级成抱歉暂时查不到实时状态我帮您提交一个待办稍后有人联系您创建类工具挂了降级成我已记录您的需求稍后重试。降级话术写在清单文件里跟着工具走不写在代码里。5.3 人工兜底的设计再好的系统也要有兜底。我的原则是只要用户表达了不满或者连续两轮没解决问题立刻转人工不要试图用更多轮对话去挽回。这个判断逻辑很简单检测关键词加轮次计数就够了别搞复杂的意图识别。转人工的时候要把上下文一起带过去包括用户说了什么、智能体调了哪些工具、拿到了什么结果。坐席看到完整链路不用再问一遍用户您刚才说的是什么问题体验差别非常大。这块我一开始没做被业务方投诉了两次之后才补上补上之后人工处理时长平均缩短了 40%。6. 常见问题与排查实录6.1 故障速查表现象可能原因排查动作修复方向模型反复调用同一工具结果被裁掉了关键字段看 trace 里裁剪前后对比把该字段加进 must_keep工具明明存在却选不中描述里触发条件太模糊拿评测集单测该工具重写 when_to_use写操作被重复执行幂等键没算上业务主键查幂等键生成日志补 biz_key 字段大量超时单工具超时设得太紧看耗时分布 P99调预算或加缓存上下文突然变贵某工具返回体积暴涨看 trim_ratio 指标加行数截断或字段白名单权限校验总是失败scope 命名不一致对比清单与实际授权统一 scope 命名规范确认令牌无效有效期过短或被复用查令牌签发与使用记录延长有效期、绑定用户 ID6.2 踩过的坑与独家经验第一个坑是工具描述写得越详细越好。这个直觉是错的。我试过给一个工具写了 400 字的说明结果它在评测集里的表现反而变差。后来分析发现长描述引入了太多干扰信息模型抓不住重点。控制在 40 到 80 字说清楚做什么、什么时候用、什么时候不用三件事足够了。第二个坑是把业务判断塞进网关。我们有个需求是如果是 VIP 客户退款额度自动提高团队顺手就写进了网关。三个月后网关里堆了十几个类似的规则每次改业务都要动它网关变成了新的泥球。正确做法是把这类规则做成网关可调用的策略接口规则本身放在业务侧。第三个坑是忽略了时区。时间字段在适配器里没有统一转成 ISO 格式带时区模型拿到本地时间字符串之后无法比较大小出现过上周的工单被理解成未来三天的情况。现在我的规范是所有时间字段一律转 ISO 8601 带时区偏移在适配器出口统一处理不依赖上游。第四个坑说起来有点丢人早期为了省事网关里的重试用的是同步 sleep。线上并发一上来线程池直接被打满。改成异步退避之后就好了。这个教训是触达层天生是 IO 密集的全异步是基本要求。提示每接一个新工具我都会强制走一遍超时、限流、返回异常、返回超大结果、权限不足这五个异常场景的手工测试。绝大多数线上事故都出在异常分支上正常路径反而是最不容易出问题的。6.3 成本失控的三种典型形态第一种是上下文膨胀。表现是单任务 token 从 6k 涨到 20k原因通常是某个工具开始返回长文本或者多轮对话历史没有做窗口裁剪。解法是给会话历史设硬上限超过就做摘要压缩同时守住trim_ratio这个指标。第二种是无效循环。表现是步数超过 4 的会话占比突然上升。多数情况下是工具描述和用户表达之间存在语义鸿沟模型找不到合适的工具就在那里反复试探。解法是定期看失败案例把高频的用户说法补进when_to_use里。第三种是重试放大。上游接口变慢重试次数上升并发翻倍上游更慢形成正反馈。解法是给重试加全局配额单位时间内全局重试次数超过阈值就暂停重试只保留熔断逻辑。这个配额我一般设成正常调用量的 5%。成本这块我的态度是不要等到账单来了才优化。把单任务 token和平均步数放进日常看板涨了就去查原因成本问题本质上都是设计问题的滞后表现。最后分享一个我在多个项目里都验证过的小技巧把工具清单的变更和评测集跑分绑定成一条流水线。任何人改了清单文件自动触发评测分数下降超过 5 个百分点就打回。这个机制上线之后工具描述的质量基本不用人操心了因为没人敢随手改。至于后续扩展我建议优先补的是工具之间的依赖关系声明让网关知道 A 工具的结果可以喂给 B 工具这样能在编排层省下不少步数我现在正在自己项目里试这个方向效果出来后可以再单独写一篇。