尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Agent-Reach:智能体工具调用、权限沙箱与幂等实践

Agent-Reach:智能体工具调用、权限沙箱与幂等实践 智能体这东西我前后折腾了两年多最深的感受不是模型不够聪明而是它够不着。模型能推理、能规划、能写出一段漂亮的方案但让它去查一下库存、发一条通知、改一个表单字段十有八九卡在最后一公里——接口对不上、参数是错的、权限没有、失败了也不知道为什么。Agent-Reach 这个项目标题我理解它要解决的正是这一层问题给智能体装一套触达能力让它从会想真正走到能做。这套东西不是某个单一库而是围绕智能体与外部世界交互的一条完整链路包含工具契约、通道适配、权限沙箱、状态幂等、可观测性这么几个部分。如果你正在做智能体落地、被工具调用折磨过、或者只是想让自己的 Agent 真正跑在生产环境里而不是停在演示视频里那接下来这些内容应该对你有用。我会按为什么这么设计—每个模块怎么拆—怎么从零跑通一条链路—踩过哪些坑的顺序讲尽量说人话能给代码的地方给代码能算数的地方算数。1. Agent-Reach 要解决的核心问题与整体设计思路1.1 从会聊天到能触达智能体能力断层的真实痛点大部分团队做智能体的路径都差不多先接一个大模型写一段系统提示词让它扮演某个角色然后拿几个问答样例试一试效果惊艳汇报的时候大家都很兴奋。紧接着进入第二阶段——接真实业务。这时候问题就来了。模型说要查询订单状态但它拿不到订单系统说帮我创建一个工单但工单系统的接口需要一个十六位的项目 key还得带上租户标识说发给相关同事可它根本不知道相关是谁。于是整个项目就卡在了一个非常尴尬的位置智能部分已经足够好执行部分几乎是零。我把这个断层拆成三层来看。第一层是语义到动作的翻译层模型输出的是自然语言或者一段结构化文本而外部系统要的是明确的函数名和参数中间的映射如果没有约束模型就会自由发挥参数名随手编、类型随手填。第二层是异构接口的收敛层一个真实业务里往往同时存在 REST 接口、RPC 调用、消息队列、数据库直连、甚至只是一个需要点按钮的网页后台形态千奇百怪如果每个都单独写一套对接逻辑代码会迅速腐化。第三层是风险与状态控制层也就是能碰到和碰坏了怎么办的问题——重复下单、越权读取、失败后无限重试这些都不是模型能自己兜住的。Agent-Reach 的价值就在于把这三层从业务代码里抽出来做成一个相对独立的中间层。它的定位很像早年的 ORM——不是让业务不会写 SQL而是把怎么连、怎么映射、怎么回滚这些事情统一管起来。这一点想清楚了后面所有的模块划分就顺了有契约层负责翻译有适配层负责收敛有安全与状态层负责兜底再加上观测层让整个链路可见。少了任何一层系统在演示阶段看不出来一上量就会暴露。1.2 分层架构选型的取舍为什么把触达单独抽一层有人会问为什么不直接在业务代码里 if-else 判断一下模型说要干什么就调哪个函数多简单。我一开始也是这么干的一个几百行的调度函数switch 到二十几个分支。前两个月还行到第三个月就彻底失控了新增一个工具要改三处代码一处改错全链路报错日志里看不出是哪一步出的问题测试覆盖率低得可怜。那次重构之后我才坚定了一个想法——触达逻辑必须和业务逻辑解耦。具体怎么分层我试过两种方案。一种是薄适配每个工具写一个独立的 Python 函数注册到一个全局注册表模型通过工具名调用参数用 Pydantic 校验。另一种是厚网关所有调用统一走一个内部网关服务注册表存在数据库里参数用 JSON Schema 描述调用经过网关做鉴权、限流、审计。前者的优点是上手快、调试方便、没有额外网络开销适合工具数量在三十个以内、团队规模小的场景后者的优点是集中治理、可以动态下发配置、审计完整适合多团队共用、工具上百个的场景。我最后选的是两者的混合控制面走网关数据面走本地适配器。控制面负责工具注册、权限配置、灰度开关、审计日志这部分变化频率低做成集中式完全值得数据面就是实际的调用执行尽量在进程内完成避免每一次工具调用都多一跳网络。这个取舍的核心判断依据是延迟预算如果一次对话里模型要连续调用三到五个工具每个工具多五十毫秒的网络往返加起来就是两百多毫秒用户体验上是能感觉出来的。而控制面的配置读取可以本地缓存加失效通知几乎不影响主链路。还有一点值得说分层之后测试变得可写了。契约层可以单独用 schema 做单元测试适配层可以用 mock 服务做集成测试安全层可以做边界用例。在没有分层之前你想测试模型说了一句模糊指令系统会怎么处理几乎无从下手因为逻辑缠在一起。分层之后这就是一个非常清晰的输入输出问题。1.3 协议与适配器的边界划分分层之后紧接着的一个问题是层与层之间用什么协议通信。这里的协议不是指网络协议而是指接口契约的表达方式。我见过团队用自然语言描述工具比如这个工具可以查询天气参数是城市名然后指望模型理解。这种做法在简单场景下能用但太脆弱了——模型会给你加一个日期参数而你根本没实现调用直接报错。比较稳的做法是用结构化的 Schema把工具名、描述、参数列表、每个参数的类型和约束、必填与否、返回值结构全部写清楚。这份 Schema 就是契约层和适配层之间的边界。契约层负责把模型输出对齐到这份 Schema适配层负责把这份 Schema 翻译成真实接口的调用。两边都只认这份中间表示谁也不用关心对方的内部实现。这样做还有一个隐性好处工具的 Schema 可以被自动生成文档、自动生成测试用例、自动做参数覆盖率统计工程效率提升很明显。边界划清楚之后还有一个细节容易忽略错误的表达方式也要统一。真实调用会失败失败的原因可能是网络超时、可能是参数不合法、可能是上游返回了业务错误码。如果适配层直接把上游的原始错误抛给契约层模型看到的可能是一段 HTML 错误页它根本不知道该怎么办。我一般在契约层定义一组标准错误类型比如INVALID_ARGUMENT、NOT_FOUND、RATE_LIMITED、UPSTREAM_ERROR、TIMEOUT适配层负责把千奇百怪的上游错误映射到这几种。映射做得好模型重试的成功率会明显上升因为它能看懂这次是参数错了我改一下还是这次是限流我等一会儿。2. 核心模块拆解触达链路上的四个关键环节2.1 意图到动作工具描述与参数契约的设计要点工具描述写得好不好直接决定模型能不能选对工具。我踩过的最典型的坑是两个工具的功能高度重叠描述都写得很含糊结果模型在两者之间反复横跳一会儿调 A 一会儿调 B。后来我总结了几条经验。第一条是描述里必须包含什么时候用和什么时候不用。比如查询订单物流信息——当用户询问包裹位置、预计送达时间时使用不要用于查询订单支付状态那属于另一个工具。这一句话能消掉大部分误选。第二条是参数名要有业务含义不要用缩写。order_id比oid好start_date比sd好模型在跨语言场景下对缩写的理解非常不稳定。参数契约的设计上我倾向于宁可严格一点。能用枚举就不要用自由字符串能用明确的时间格式就不要让模型自己猜。举个真实例子一个创建会议的工具时间参数如果写成time: string模型会给出下周三下午两点明天上午这种表达你还得再写一层解析。如果写成start_time: ISO8601 格式例如 2024-03-15T14:00:0008:00模型给出来的就基本可以直接用。约束写清楚了后面省下的是大量兜底代码。还有一个容易被忽视的点返回值结构也要设计。有些工具返回一个巨大的 JSON几百个字段全部塞回上下文不仅浪费 token还会干扰模型后续推理。我的做法是在适配层做一层裁剪只返回模型真正需要的字段同时约定一个summary字段放人类可读的摘要。这样模型既能看到结构化数据做判断也能拿到一句话结论做回复。2.2 通道适配层把异构接口收敛成统一语义适配层是整个 Agent-Reach 里最脏最累的部分但也是最有价值的部分。它要面对的现实是有的系统提供 REST有的只有 SDK有的只能通过数据库读有的干脆是一个需要登录的后台页面。这几种形态的调用方式完全不同但对上层的语义应该是一致的——调query_order就是拿到订单信息不管底下是 HTTP 还是 SQL。我的实现方式是给每一类通道写一个基类定义统一的方法签名然后具体的工具去继承并实现细节。REST 通道封装了请求构造、认证头注入、超时设置、重试策略SDK 通道封装了客户端初始化、连接复用、异常转换数据库通道封装了连接池、参数化查询、结果集映射。上层拿到的是一个统一的invoke(tool_name, params)接口完全不用关心底下是什么。这里有个实操经验值得分享认证信息的生命周期管理。很多系统的 token 是有有效期的两小时或者一天。如果每次调用都重新获取 token会带来额外的延迟还可能触发上游的频率限制如果缓存 token 但不管过期又会在某个时间点集中报错。我的做法是在适配器里维护一个带过期时间的凭证缓存提前五分钟刷新并且加一把锁避免并发刷新。这个细节看起来小但在生产环境里能省掉大量半夜突然全部失败的事故。还有一个坑是数据格式的方言。日期时间、金额、布尔值这几个类型不同系统的表达方式差异巨大时间可能是时间戳、可能是yyyy-MM-dd、可能是带时区的 ISO 串金额可能是分、可能是元、可能是字符串加货币符号布尔值可能是true/false、可能是1/0、可能是Y/N。适配层必须做归一化统一成内部标准格式否则这些脏数据会一路传到模型那里导致推理错误。2.3 权限与沙箱让智能体能碰到但不碰坏这一步是很多项目最容易忽略、出事后代价最大的地方。智能体和传统程序最大的区别是它的调用路径是动态生成的你没法像写普通代码那样把每一个分支都静态审查一遍。它今天调query_order明天可能因为提示词或者上下文的变化去调delete_order。如果没有权限隔离后果非常直接。我的做法分三层。第一层是工具白名单每个智能体实例只注册它需要的工具不需要的一个都别给。这一层最粗暴也最有效能在源头上砍掉大部分越权可能。第二层是参数级约束比如查询订单这个工具强制注入当前用户的 ID 作为过滤条件模型传什么user_id都会被覆盖掉这样它就没法查别人的数据。第三层是写操作的二次确认所有会产生副作用的调用创建、修改、删除、发送先落一条待确认记录由人工或者规则引擎确认后再真正执行。沙箱方面如果工具涉及执行代码或者命令必须跑在隔离环境里限制文件系统访问范围、限制网络出口、限制执行时长和内存。我见过一个团队让智能体直接执行 shell 命令来做数据分析结果模型生成了一条带通配符的删除命令虽然最后因为权限不足没删成但那次之后他们把整个执行链路重做了一遍。这个教训很贵但也很值。注意权限设计的默认原则应该是默认拒绝而不是默认允许再加限制。凡是没显式授权的操作一律不允许执行。2.4 状态与幂等重试、超时与去重的实现细节智能体的调用是非确定性的同一个任务它可能因为上下文微小的变化而重复调用同一个工具。我遇到过最夸张的一次模型在一个循环里连续调了七次创建工单原因只是它在等一个异步结果而每次轮询都触发了一次创建。这就是幂等要解决的问题。幂等键的生成方式我一般用工具名 业务标识 会话内的调用序号拼一个哈希。关键在于业务标识必须是稳定的不能包含时间戳这种每次都变的东西。更稳的做法是从参数里提取真正代表业务实体的字段比如订单号、用户 ID 加操作类型。有了幂等键之后服务端维护一张短期记录表同一个键在窗口期内重复请求直接返回上次的结果不重新执行。超时和重试要一起设计。我的经验是分层设置超时单次调用超时、单轮对话中所有工具调用的总超时、整个任务的总超时。单次超时设短一点比如三到五秒快速失败总超时设长一点给模型留出换策略的空间。重试不能无脑重试要区分错误类型——参数错误重试一百次也没用限流和超时值得退避重试业务错误要根据具体错误码判断。退避策略我用的是带抖动的指数退避避免多个实例在同一时刻集中重试把上游打垮。3. 实操过程从零搭一个最小可用的触达链路3.1 环境准备与依赖清单先说我用的技术栈这套组合是我试过几轮之后觉得比较顺手的。运行环境用 Python 3.11理由是新版的异步支持和类型系统都比较成熟asyncio.TaskGroup在并发编排上很好用。校验层用 Pydantic v2它把 JSON Schema 的生成和运行时校验结合得很自然。HTTP 客户端用 httpx异步和同步一套 API省得在两个库之间切换。配置管理用 pydantic-settings环境变量、配置文件、密钥管理能统一起来。依赖清单大致是这样pip install pydantic2.6 httpx0.27 pydantic-settings2.2 \ tenacity8.2 structlog24.1 orjson3.9这里重点说三个选择理由。tenacity是专门做重试的库它的退避策略、重试条件判断写起来比手写while循环清晰得多而且支持异步。structlog负责结构化日志智能体链路的日志必须是结构化的因为你要按trace_id、tool_name、duration_ms这些字段去聚合分析用普通文本日志根本没法查。orjson是拿来做快速序列化的工具调用的参数和返回值序列化非常频繁换掉标准库的json在压测里能省下可观的 CPU。目录结构我一般这么组织contracts/放工具 Schemaadapters/放各类通道实现runtime/放调度、重试、幂等observability/放日志和埋点。这个划分的好处是每块职责单一改契约不会动到适配器换日志后端不会影响调度。3.2 定义一个工具契约附完整示例契约是整个链路的起点我拿一个查询物流的工具来做示例。这份 Schema 会被三处使用注入到模型的工具列表、适配器的参数解析、以及自动生成的文档和测试。from pydantic import BaseModel, Field from typing import Literal from datetime import datetime class QueryLogisticsParams(BaseModel): 查询物流参数契约 order_id: str Field( ..., min_length6, max_length32, description订单号纯数字或字母数字组合例如 202403150001 ) detail_level: Literal[summary, full] Field( defaultsummary, description返回详细程度。summary 只返回最新状态full 返回完整轨迹 ) class LogisticsNode(BaseModel): time: str Field(descriptionISO8601 格式的节点时间) status: str Field(description节点状态描述) location: str | None Field(defaultNone, description节点所在地可能为空) class QueryLogisticsResult(BaseModel): order_id: str current_status: str Field(description当前状态用于直接回复用户) estimated_arrival: str | None Field(defaultNone) nodes: list[LogisticsNode] Field(default_factorylist) summary: str Field(description一句话摘要便于模型直接引用)这份契约里有几个设计点值得展开。order_id加了长度约束看起来多余实际上很有用——模型有时候会把一整句话当成订单号传进来max_length能在校验阶段就拦下这种明显错误避免一次无意义的网络调用。detail_level用枚举而不是布尔值是因为未来可能要加只返回异常节点这种选项枚举更好扩展。summary字段是我强烈建议每个工具都加的它让模型在需要快速回复时有现成的材料不用自己从结构化数据里拼。契约写完之后可以写一个函数把它转成模型能看懂的格式。大部分模型接口都接受 JSON Schema 形式的工具定义Pydantic 的model_json_schema()直接就能用。这一步不用手写省了很多对齐成本。3.3 注册通道与路由配置工具定义好了接下来要告诉运行时这个工具实际怎么调。我用的是一份声明式的路由配置用 YAML 写启动时加载支持热更新。tools: query_logistics: channel: rest endpoint: https://internal-api.example.com/logistics/query method: POST timeout_ms: 4000 retry: max_attempts: 3 backoff: exponential base_ms: 200 jitter: true idempotent: true auth: type: bearer secret_ref: logistics_token request_mapping: order_id: $.params.order_id detail: $.params.detail_level response_mapping: current_status: $.data.status_text estimated_arrival: $.data.eta nodes: $.data.tracks[*] summary: $.data.brief rate_limit: qps: 50 burst: 100这份配置里的字段每一个都对应一个真实需求。timeout_ms设 4000 是因为业务侧对物流查询的容忍度大概在五秒以内留一秒钟给后续处理。retry的退避基数设 200 毫秒三次重试总耗时大约 200 400 800 加上抖动最坏情况下不到两秒配合总超时是安全的。idempotent: true告诉运行时这个工具可以安全重试写操作则要设成 false 并加幂等键。rate_limit是保护上游的避免模型在异常情况下疯狂重试把上游打挂。映射部分我用了一套简化的 JSONPath 表达式$.params.xxx指向契约层的参数$.data.xxx指向上游返回的字段。这层映射是适配层最核心的工作它把上游的字段名变化隔离在配置里上游改了字段名只需要改配置不用改代码不用重新发版。这个设计在我们对接第三方接口时救过好几次场。3.4 跑通第一条端到端链路含参数计算配置齐了现在把整条链路串起来。核心的调度逻辑大概是这样import asyncio, httpx, hashlib, time from tenacity import retry, stop_after_attempt, wait_exponential_jitter from pydantic import ValidationError class ToolRuntime: def __init__(self, registry, http: httpx.AsyncClient, store): self.registry registry self.http http self.store store async def invoke(self, tool_name: str, raw_params: dict, session_id: str, caller_id: str): spec self.registry.get(tool_name) if spec is None: return {error: TOOL_NOT_FOUND, message: f未注册的工具 {tool_name}} if not self.registry.allowed(caller_id, tool_name): return {error: FORBIDDEN, message: 当前调用方无权使用该工具} try: params spec.param_model.model_validate(raw_params) except ValidationError as e: return {error: INVALID_ARGUMENT, message: self._humanize(e)} # 强制注入的字段覆盖模型传入值 for key, value in spec.injected_fields(caller_id).items(): setattr(params, key, value) idem_key self._idem_key(tool_name, params, session_id) \ if spec.idempotent else None if idem_key: cached await self.store.get(idem_key) if cached: return cached started time.perf_counter() try: result await self._call_with_guard(spec, params) finally: cost_ms (time.perf_counter() - started) * 1000 await self._emit_metric(tool_name, cost_ms) if idem_key: await self.store.set(idem_key, result, ttl300) return result def _idem_key(self, tool_name, params, session_id): business |.join( f{k}{v} for k, v in sorted(params.model_dump().items()) if k not in (detail_level,) ) raw f{tool_name}::{session_id}::{business} return idem: hashlib.sha256(raw.encode()).hexdigest()[:32]这里有几个计算和判断值得说明。幂等键的 TTL 设 300 秒依据是绝大多数异步重试都发生在五秒内留三百秒的窗口远远够用同时不会让存储无限增长。幂等键的构造排除了detail_level这类只影响返回详略、不影响业务结果的参数因为同一个订单查概要和查详情在业务上可以被认为是同一次操作没必要重复落两条记录但也有人选择全部纳入这取决于你的业务对相同操作的定义没有绝对正确的答案。并发控制我单独抽了一层用一个信号量限制同时进行的工具调用数量。这个数字怎么定我的算法是上游接口的 QPS 上限 × 平均调用耗时秒 × 安全系数。假设上游允许 50 QPS平均耗时 0.3 秒安全系数取 0.7那并发数约为50 × 0.3 × 0.7 ≈ 10.5取 10。这个公式本质上是利特尔法则的应用能保证在稳态下不打满上游配额。设得太小会拖慢整体响应设得太大则容易在流量尖峰时触发上游限流。3.5 观测与日志埋点接入链路跑通只是第一步能观测才算能运营。我在三个位置埋点调用前记录请求意图工具名、参数摘要、会话 ID、调用方调用后记录结果状态码、耗时、返回大小、是否命中幂等缓存异常时记录完整上下文原始错误、重试次数、当时的并发水位。日志我用结构化输出每条日志是一个 JSON关键字段固定。这样做的好处是排查时可以直接按字段过滤比如查所有耗时超过两秒的query_logistics调用一句查询就能定位。我还会在每个工具调用上带一个trace_id这个 ID 从用户发消息那一刻生成贯穿整轮对话里的所有工具调用事后复盘时可以完整还原模型当时看到了什么、决定了什么、执行结果如何。指标方面我最关注的四个是成功率、P95 耗时、幂等命中率、参数校验失败率。成功率骤降通常意味着上游出问题或者凭证过期P95 耗时上升可能是上游变慢或者并发被打满幂等命中率高说明模型在重复调用提示词可能需要优化参数校验失败率高则说明工具描述写得不够清楚模型理解不了。这四个指标基本能覆盖八成的线上问题。4. 常见问题与排查技巧实录4.1 工具调用看起来成功但没生效这是最高频也最迷惑人的一类问题。日志里显示调用成功、返回 200、模型也说已完成但业务数据就是没变。排查这类问题我有一套固定顺序。先看调用是否真的到达了上游。在适配层加一条请求日志记录实际发出的 URL、方法、请求体。有相当一部分情况是映射配置写错了比如本该 POST 的写成了 GET参数放在了 query 而上游只认 body或者 URL 里少了一段路径。这类问题在上游日志里表现为根本没有这条请求而在本地日志里一切正常。再看上游是否返回了业务层面的失败。这是最隐蔽的一种HTTP 状态码 200但返回体里{code: 5001, msg: 参数校验失败}。如果适配层只判断 HTTP 状态码就会把这种响应当成成功返回给模型。解决办法是在配置里显式声明业务成功码比如success_code_path: $.code和success_code_value: 0不匹配就按错误处理。最后看是否被幂等逻辑拦掉了。如果幂等键构造得过于宽泛两次本应不同的操作会被认为是同一次第二次直接返回了缓存的成功结果但实际什么都没做。判断方法很简单把幂等键打出来看或者在缓存命中时加一条 warn 日志。我一般建议在开发阶段把幂等缓存关闭上线前再打开避免调试期被这个逻辑干扰。4.2 参数类型不匹配与隐式转换的坑模型传参数的类型是不可靠的明明 schema 写的是整数它可能传5明明写的是数组它可能传一个单元素而不是数组[x]。Pydantic 在宽松模式下会做隐式转换5能转成5这在方便的同时也埋了雷如果上游对类型敏感转换后的值可能不符合预期。我的处理原则是在契约层尽量严格在边界处显式处理。对字符串形式的数字如果需要接受就在字段上写field_validator明确转换并记录一条日志而不是依赖默认的宽松行为。对单个值还是数组这种常见歧义我用BeforeValidator统一包一层把单值变成单元素列表。还有一个更麻烦的情况日期时间。模型给出的2024-03-15到底指哪一天的哪个时刻如果是查询类接口通常需要补全成当天零点如果是创建类接口可能默认要补成当天的某个业务时间点。这类语义歧义没法靠类型系统解决只能在工具描述里写清楚或者在参数校验后做一次规范化补全。我倾向于在描述里明确要求带时区的完整时间实在拿不到就在适配层补默认值并在返回值里回显实际使用的时间让用户能发现偏差。4.3 超时、重试风暴与并发限流超时和重试如果设计不当会互相放大造成雪崩。我遇到过的情况是上游某个接口变慢本地超时触发重试重试的请求又占用了并发额度导致其他正常请求排队排队又造成更多超时形成正反馈。防御手段有三个层次。第一层是熔断当某个工具的失败率在滑动窗口内超过阈值直接快速失败一段时间不再发起真实调用。第二层是并发隔离给不同的工具分配独立的并发额度避免一个慢工具把整个池子占满。第三层是重试预算限制整轮对话内的总重试次数超过就不再重试让模型知道这次不行换个方式。重试的退避参数我踩过坑。最初用的是固定间隔结果所有实例在同一时刻重试把上游的瞬时压力放大三倍。后来改成指数退避加随机抖动抖动幅度取退避时间的一半左右效果立竿见影。公式大致是sleep base * 2^(attempt-1) * (0.5 random() * 0.5)这样既保证了退避的有效性又打散了重试的同步性。4.4 排查速查表把上面这些经验整理成一张表出问题时可以按表快速定位。现象最可能的原因优先排查位置处理方式模型说完成但数据没变业务错误码被当成成功适配层的成功判定逻辑显式配置业务成功码请求根本没到上游映射配置错误实际发出的 URL 和方法打印原始请求核对配置同一操作执行多次幂等键不稳定或未启用幂等键构造与缓存查询用稳定业务字段构造键参数老是校验失败工具描述不清或类型过严契约字段描述与校验规则补充示例值放宽可容忍类型P95 耗时突然上升上游变慢或并发打满上游耗时分布与并发水位开启熔断隔离并发额度半夜集中报错凭证过期凭证缓存与刷新逻辑提前刷新加锁避免并发刷新模型选错工具工具描述重叠各工具的适用场景描述补充何时不用的说明返回内容过长未做字段裁剪响应映射的字段范围只保留必要字段加摘要这张表是我在实际运维中反复用到的基本上新同学遇到问题对照着走一遍就能定位到七成以上的原因。剩下三成通常是多个因素叠加那就需要结合trace_id把整轮对话的所有调用捞出来看看模型的决策链条在哪一步开始偏了。5. 性能与成本调优的实战经验5.1 上下文瘦身与工具裁剪工具数量对模型的影响比想象中大。当可用工具从十个增加到五十个模型选错的概率会明显上升因为它要在更多的描述里做区分。我做过一次粗略统计工具数超过三十之后选错率的上升开始变得明显。所以工具裁剪是必要的每次对话只挂载与当前场景相关的工具子集用规则或者一个轻量的分类器来决定挂哪些。上下文瘦身还有几个具体手段。工具的返回值做裁剪前面提过历史消息做摘要压缩把很久之前的工具调用结果替成一句话结论参数的示例值只在描述里保留一个不要列五六个那样反而干扰模型。我算过一笔账一个中等复杂度的对话做完这些优化之后输入 token 大概能减少三到四成对应的成本和延迟都会下降。另一个技巧是把稳定的信息前置。系统提示词、工具定义、业务规则这些变化少的内容放在前面利用缓存机制降低重复计算的开销。动态的部分比如用户输入和工具结果放在后面。这个顺序在支持前缀缓存的场景下能省钱具体能省多少取决于缓存命中率我实测下来在一个高频场景里能到六成左右。5.2 缓存与批量化缓存不只用在幂等上还有几处值得做。工具结果的短时缓存像查询商品信息这种读多写少、变化不频繁的工具同一参数在几十秒内重复查询完全可以返回缓存。判断依据是数据的时效性要求商品基础信息缓存一分钟没问题库存数量就不行。凭证和配置的缓存前面提过这里不重复。批量化是另一个省时间的手段。如果模型在一轮里要查十个订单逐个调用是十次网络往返如果能合并成一次批量接口耗时能压缩很多。实现上需要适配层支持批量语义同时在契约里暴露一个批量工具让模型知道可以一次查多个。不过要注意批量接口对错误的处理更复杂可能部分成功部分失败返回值结构要把每一项的成功失败都标清楚否则模型会误以为整批都成功了。5.3 评估与回归最后说一个容易被跳过但极其重要的环节评估。智能体系统的行为是非确定的靠人工点点点根本测不完。我的做法是维护一个评估集里面是若干条真实场景的输入和期望的工具调用序列比如用户问包裹到哪了期望调用query_logistics一次参数里包含正确的订单号。每次改提示词、改工具描述、改适配逻辑都把这个评估集跑一遍看通过率有没有下降。评估指标我关注两个工具选择准确率和参数填充准确率。这两个指标分开看很有必要因为它们的成因不同。工具选错了要改描述参数填错了要改 schema 或者补示例。混在一起看你只知道总体准确率是 78%不知道从哪儿下手。分开看之后优化方向立刻清晰了。还有一点评估集要用真实数据。我第一次搭评估集的时候图省事自己编了二十条样例跑出来准确率 95%感觉良好。上线之后才发现真实用户的问法千奇百怪编的样例完全覆盖不到。后来把线上真实请求脱敏后抽样进来准确率立刻掉到 70% 出头那才是真实的起点。评估集的质量决定了你优化方向的有效性这件事上偷懒后面会加倍还回来。我个人在这一整套东西上的体会是Agent-Reach 这类触达层的价值不在于让智能体多会几件事而在于让它会不会做对变成一件可观测、可测试、可回滚的事情。模型本身的能力你控制不了但契约写多清楚、权限收多紧、失败怎么兜、日志留多细这些全在你自己手里也是真正决定一个智能体能不能上生产的分水岭。工具描述里多写一句什么时候不要用可能比换一个更强的模型还管用。
返回列表