资讯动态

Agent-Reach:智能体工具调用稳定触达层设计

发布时间:2026/9/19 1:20:54 来源:尧图企业网站定制
1. Agent-Reach 到底是什么先把这个名字拆开看Agent-Reach 这个词我第一眼看到的时候脑子里跳出来的不是某个具体产品而是一类被反复踩坑的工程问题智能体Agent怎么可靠地够得着外部世界。Reach 这个词很关键它不是调用不是集成而是触达——触达意味着中间有距离、有障碍、有可能失败、有可能延迟、有可能够不到。我做了几年 Agent 相关的落地项目越来越觉得真正卡住项目上线的从来不是模型够不够聪明而是这套触达链路够不够稳。模型选错工具、参数传歪、接口超时、返回格式千奇百怪、多租户串数据这些才是让一个看起来很酷的 Demo 变成没人敢用的产品的元凶。Agent-Reach 想解决的就是这一整层问题把外部 API、内部数据源、本地脚本、第三方服务统一抽象成一套可被智能体安全、稳定、可观测地触达的能力。需要先说清楚一点下面的内容是我基于这个名字背后的工程问题结合自己实际做过的一类中间层项目做的合理演绎和补全不是某个官方文档的转述。所以你会看到很多如果是我我会这么做的判断以及为什么这么判断。这套东西适合三类人看正在做 Agent 应用但被工具调用稳定性折磨的工程师负责把内部系统开放给智能体、但又担心安全和权限的架构同学还有想快速搭一个能跑起来的最小版本、先看效果的独立开发者。不管你是哪一种我希望你读完能拿到一套可以直接抄的目录结构、一份参数预算的算法、一张故障速查表以及几条我用真金白银换来的硬规矩。1.1 一次线上翻车让我重新理解触达这两个字早期我做的一个内部助手工具调用的逻辑是直接写在业务代码里的模型返回一个函数名我在一个大 if-else 里分发然后直接 status_code 判断成功失败。Demo 阶段特别顺演示的时候百发百中领导看着很满意。上线第三天出事了财务同事问上个月华东区的报销总额是多少模型选了查询接口参数也基本对但那个接口在数据量大时会走一个异步导出流程直接返回 202 和一个任务 ID而我的代码只认 200于是判定失败重试了三次把同一个导出任务触发了三次最后导出了三份重复报表。这件事之后我才真正明白能调用和能稳定触达完全是两回事。从那次起我把这套东西重新梳理核心的转变有三个。第一个转变是把工具从代码里的一个分支变成一个有独立描述、独立契约的注册项模型看到的是描述和参数模式而不是我的 if-else。第二个转变是把执行过程从调用一次看结果变成一次调用有明确的超时预算、重试策略和终止条件202 这种中间态要能被识别和处理而不是简单当成失败。第三个转变是加上可观测性每一次触达都要留下足够的信息谁调的、调了哪个工具、参数是什么脱敏后、耗时多少、结果大小多少、最终状态是什么。没有这三条Agent 的工具层就是黑盒出了问题只能靠猜。顺带说一个我后来才意识到的点触达失败其实是常态不是异常。外部服务会因为维护窗口、限流、网络抖动、鉴权过期而失败这不是出 Bug 了这是分布式系统的正常状态。把失败当异常处理就会写出到处 try-except 的代码把失败当常态处理才会自然地设计出重试、降级、熔断、缓存兜底这些机制。Agent-Reach 这一层存在的意义本质上就是把失败是常态这个认知固化进架构里。1.2 边界划清楚它不做什么比它做什么更重要我见过太多项目死在什么都想做上。Agent-Reach 这类触达层最容易失控的地方就是边界模糊最后变成一个巨型胶水层谁都不敢改。所以我在设计之前会先明确三件事它不做。第一它不做任务编排。多步任务的规划、子任务拆解、依赖关系管理这些是编排层或者说 Agent 主体逻辑的事。触达层只回答一个问题给定工具名和参数可靠地执行并返回结构化结果。如果它开始管先查 A 再查 B 然后合并那就越界了因为编排逻辑跟业务强相关耦合进来之后这一层就没法复用了。第二它不做模型推理。工具选择、参数生成是模型的事触达层的职责是校验和拦截——模型传了不符合模式的参数直接返回一个清晰的错误让模型自己纠正而不是帮模型猜一猜。我早期犯过一个错就是做参数模糊匹配把模型传的华东自动映射成east看着很贴心结果有一次模型想查的是华东大区里的一个子区域被我一映射就错了还错得很隐蔽。从那以后我坚持只做校验不做猜测。第三它不做业务语义。触达层不知道什么叫报销总额它只知道某个工具返回了一个数值字段和一个单位字段。业务语义的解读交给上层。这一条听起来很废话但实际操作中很难守因为顺手在触达层里把单位统一一下这种诱惑太大了而一旦开始这么干三个月后这一层就会长出一堆只有原作者看得懂的特判逻辑。提示把不做什么写成文档放在仓库根目录比写架构图有用得多。新人进来第一件事就是看这份边界清单能省掉大量返工。1.3 三类最适合上手的团队第一类是工具数量已经超过 10 个的团队。经验上讲工具数量在 5 个以内裸调完全没问题到 8 到 10 个模型选错的概率会明显上升超过 10 个还没有统一描述和分类基本就进入加一个工具就要回归测试一遍的状态。这时候抽一层出来收益立刻显现。第二类是有多租户或权限要求的团队。只要存在不同用户能访问的数据范围不一样这个需求就必然需要一层独立的鉴权与上下文透传。把这套逻辑塞进每个工具的实现里是最常见的错误做法因为总会漏掉一个。第三类是需要审计和回溯的团队。金融、医疗、企业内部的合规场景要求能回答三个月前那次回答是基于哪些数据得出的。没有任何可观测性记录的裸调架构面对这个问题只能摊手。反过来说如果你只是周末做个玩具项目工具就两三个那真的不用上这套直接写死反而更快。架构的复杂度要和问题的规模匹配这句话我每年都要提醒自己一遍。2. 架构选型为什么值得单独抽一层出来2.1 三条路线对比裸调、SDK 封装、独立触达层在决定做 Agent-Reach 之前我把常见的三种做法都认真写过一遍也都在真实项目里用过至少一次下面是总结下来的对比。维度裸调业务内分发SDK 封装库形式独立触达层服务形式上手成本极低半小时能跑中等一到两天偏高三到五天工具数量上限5 个左右开始吃力20 个左右较舒适基本无上限靠分类管理多语言支持各自实现易分裂每种语言一套 SDK天然统一走协议权限与审计分散在各处易漏可集中但要侵入宿主完全集中宿主无感灰度与降级改代码重新发版需要宿主配合升级配置化秒级生效可观测性靠日志打印碎片化较好但格式易漂移统一埋点指标天然聚合适合阶段原型验证中小型产品平台化、多团队共用我最后选了第三条路核心原因不是看起来更高级而是变更成本。工具层的需求变化极快接口改字段、加限流、临时下线、加一个字段脱敏这些事每周都在发生。如果每次都要改业务代码重新发版两周之后没人愿意动它工具描述就会和实际能力脱节模型选错的概率随之上升。独立成层之后改一份配置就能生效这个差别在实际运维里是决定性的。代价当然也有多了一个进程要部署、要监控、要值班链路变长多一跳延迟调试的时候不能直接在业务代码里打断点了得看 trace。所以我的建议是如果你的团队没有至少一个人愿意长期维护这一层就不要建它半死不活的中间层比没有中间层更麻烦。2.2 统一工具描述的收益与代价Agent-Reach 最核心的资产其实是那份工具描述。它同时服务于三个消费者模型用它来选择工具、执行器用它来校验参数、文档系统用它来生成说明。一份描述三处复用这是统一的收益。代价是这份描述必须写得非常严谨一旦有歧义三处会同时出问题。我踩过的一个典型坑是描述里写了查询用户信息结果同时存在get_user_profile和get_user_account两个工具描述都很像模型在这两个之间来回横跳一会儿选这个一会儿选那个。后来我把描述改成前者负责基础资料昵称、头像、注册时间后者负责账务状态余额、账单周期、欠费情况并在描述里明确写出当你需要知道用户欠不欠费时用后者当你需要展示用户名片时用前者命中率立刻从六成多提到九成以上。描述不是给同事看的注释是给模型看的接口文档这个心态转变很重要。另一处代价是描述的维护成本。工具一多描述就会漂移实际能力和文字说明对不上。我的做法是加一个校验任务每天跑一次把描述里声明的参数和实际接口的 schema 做对比不一致就告警。这个任务大概两百行代码但救过我很多次。2.3 模块划分与目录结构我会把这一层拆成五个模块各司其职下面是我实际用过、比较顺手的目录结构。agent-reach/ ├── registry/ # 工具注册中心 │ ├── tools/ # 各个工具的描述文件yaml │ ├── loader.py # 加载与校验 │ └── schema.py # 描述的结构定义 ├── gateway/ # 统一入口 │ ├── server.py # 对外的协议入口 │ ├── router.py # 工具名到执行器的路由 │ └── auth.py # 鉴权、租户上下文 ├── executor/ # 执行器 │ ├── runner.py # 单次执行的完整生命周期 │ ├── retry.py # 重试与退避 │ ├── breaker.py # 熔断 │ └── adapters/ # 各类协议适配器HTTP、SQL、本地进程 ├── normalize/ # 结果归一化与裁剪 │ ├── mapper.py # 字段映射 │ └── budget.py # 上下文预算控制 └── observability/ # 埋点与指标 ├── tracer.py └── metrics.py这个划分的关键在于adapters和normalize是分开的。适配器只负责把请求发出去、把原始响应拿回来归一化只负责把五花八门的原始响应变成统一结构。分开的好处是接入一个新的第三方服务时你大概率只需要写一个适配器归一化逻辑可以复用反过来想调整返回给模型的数据结构时也不用碰适配器。注意不要在适配器里做字段裁剪。我早期图省事在适配器里就把不需要的字段删了结果后来想加回某个字段时发现得去翻适配器代码而且那个适配器已经被三个工具共用了改一处影响三处。裁剪放到归一化层配置化控制。3. 核心机制拆解描述、路由、归一化3.1 工具描述文怎么写模型才不选错我把工具描述拆成四个必填部分缺一个我都会打回重写。第一部分是一句话做什么限制在 30 个字以内且必须包含一个动词和一个明确对象。第二部分是什么时候用、什么时候不要用这是提高命中率最有效的一块很多人会省掉我认为不能省。第三部分是参数模式用标准的 JSON Schema 写类型、枚举、范围、必填都要明确。第四部分是返回结构说明让模型知道成功时会拿到什么便于它做后续判断。下面是我实际在用的一个描述文件用 YAML 写加载后转成模型可读的格式。name: query_expense_total summary: 查询指定时间段和区域的报销总额 when_to_use: | 当用户询问报销了多少钱费用总计这类需要汇总金额的问题时使用。 如果用户问的是单笔明细或发票列表请改用 query_expense_list。 when_not_to_use: | 不适用于跨年度的累计统计该场景请用 query_expense_annual。 不适用于工资、社保等非报销类费用。 parameters: type: object properties: start_date: type: string pattern: ^\\d{4}-\\d{2}-\\d{2}$ description: 起始日期含当日 end_date: type: string pattern: ^\\d{4}-\\d{2}-\\d{2}$ description: 结束日期含当日不能早于起始日期 region: type: string enum: [north, south, east, west, central] description: 大区编码不确定时不要填 required: [start_date, end_date] returns: total_amount: 数值单位元 currency: 币种代码 record_count: 参与汇总的单据数 timeout_ms: 3000有个细节值得说region我做成了枚举并且不是必填。之前在另一个项目里我把区域做成必填字符串结果模型遇到全国这种输入时会硬编一个值编出来的值接口不认识直接报错。改成枚举加非必填之后不确定就不填成了一个合法选项错误率降了一大截。给模型留一个我不确定的出口比逼它必须填要好得多。3.2 参数校验与路由把错误挡在模型之外校验这件事我的原则是宁严勿宽且错误信息必须可读。模型拿到参数 start_date 不符合 YYYY-MM-DD 格式你传的是 2024/1/5这样的错误下一次大概率能改对拿到400 Bad Request就只能瞎猜了。所以校验失败返回的不是异常堆栈而是一段结构化的、给模型看的文字。路由部分我用工具名做一级索引但加了一层前置过滤根据当前租户的权限和工具的健康状态先把不可用的工具从候选列表里剔掉再把剩余工具的描述给模型。这样做有两个好处一是模型看不到它无权使用的工具从源头避免选了但没权限的尴尬二是某个外部服务挂了把它标记为不可用之后模型根本不会往那个方向想会自动走别的路。这比让模型选了再报错体验好太多。def build_tool_candidates(tenant_id: str, health: dict) - list: 按租户权限和工具健康状态过滤候选工具 allowed permission_service.list_tools(tenant_id) result [] for tool in allowed: if health.get(tool.name) open: continue # 熔断打开暂时不给模型 result.append(tool) # 超过 30 个时按关键词做一次粗筛避免描述过长 if len(result) 30: result coarse_filter(result, user_query) return result这里有个量的问题需要留意工具描述全部塞进上下文token 消耗是线性的。我实测的经验值是一个描述写得比较完整的工具转成描述文本大约 120 到 200 个 token。30 个工具就是 4000 到 6000 token如果每轮对话都带一遍成本会很难看。所以超过 30 个工具时我会加一层粗筛——用简单的关键词或向量相似度先选出 10 到 15 个最相关的再把它们的完整描述给模型。这个粗筛不需要很准只要不把正确的工具筛掉就行召回率比准确率重要。3.3 结果归一化与上下文预算归一化要解决的是下游拿到的东西长得一样。不管底层是 HTTP 返回的 JSON、数据库返回的行、还是本地脚本打印的文本最终都要变成统一的结构一个status字段、一个data字段、一个meta字段包含耗时、来源、是否命中缓存。这样上层的编排逻辑只需要处理一种格式。比归一化更容易被忽视的是上下文预算。外部接口返回的数据经常是大得离谱的我见过一个查询接口默认返回全部字段一条记录 3KB查 500 条就是 1.5MB直接塞给模型是不可能的。所以归一化层必须有一个裁剪和摘要机制我通常按下面的优先级处理先按白名单保留必要字段这一步能砍掉七成以上体积如果还是超预算对列表类结果做截断并附带总数说明如果单条记录本身就很大对长文本做摘要或截断加省略标记。MAX_CHARS 12000 # 单次工具结果进入上下文的字符上限 def fit_budget(payload: dict, limit: int MAX_CHARS) - dict: text json.dumps(payload, ensure_asciiFalse) if len(text) limit: return payload # 列表优先截断保留前 N 条并说明总数 if isinstance(payload.get(items), list): items payload[items] kept, acc [], 0 for it in items: s len(json.dumps(it, ensure_asciiFalse)) if acc s limit * 0.8: break kept.append(it) acc s payload[items] kept payload[truncated] True payload[total_count] len(items) payload[note] f仅展示前 {len(kept)} 条共 {len(items)} 条 return payload提示截断一定要在结果里明确写出来我只给你看了前 N 条。不加这句说明模型会把截断后的数据当成全量然后算出一个错误的合计而这种错误特别难发现因为表面上一切正常。3.4 鉴权、配额与租户隔离这三个词放在一起是因为它们在实现上是同一套上下文透传机制。请求进来时先解析身份拿到tenant_id和user_id再把它们塞进一个不可变的上下文对象一路透传到适配器。适配器发外部请求时用的是这个租户自己的凭证而不是一个全局的超管账号——这一点非常关键用全局账号意味着一旦这一层被绕过所有租户的数据都暴露了。配额我做的是双层租户级总量和工具级速率。租户级防止某个租户把整体额度吃光工具级防止某个慢接口被疯狂调用把自己打挂。计数用滑动窗口窗口大小按工具的特性定查询类通常 60 秒窗口、上限 100 次写入类窗口更长、上限更低。租户隔离还有一层容易忽略的是缓存隔离。如果你的结果缓存 key 里没有租户维度A 租户查到的数据可能被 B 租户命中这是真实发生过的严重事故。我现在的习惯是缓存 key 的构成固定写成tenant_id tool_name hash(params)任何一项都不能省。4. 从零搭一个最小可用版本4.1 环境与依赖准备最小版本我建议用 Python 做生态成熟、上手快。依赖尽量少核心就几个一个 Web 框架FastAPI 或者 Flask 都行、一个 HTTP 客户端httpx支持异步和超时控制、一个配置解析PyYAML、一个校验库jsonschema。刻意不引入重型的编排框架因为这一层的核心逻辑其实很朴素引入大框架反而看不清里面发生了什么。python -m venv .venv source .venv/bin/activate pip install fastapi uvicorn httpx pyyaml jsonschema版本上我建议锁死尤其是 HTTP 客户端超时行为在不同大版本之间有过变化这个变化会直接影响你重试策略的正确性。生产环境一定用pip freeze生成锁文件别用浮动版本。目录初始化之后第一件事是把registry/tools/建起来先放两个工具描述进去一个查数据、一个写数据覆盖两种典型形态。工具不在多在于把链路走通。4.2 工具注册中心注册中心干三件事启动时扫目录加载所有 YAML、校验描述是否合法、提供一个按名字查询的接口。加载失败要直接让进程起不来而不是打条警告继续跑。这一点我很坚持因为一个描述格式错误的工具如果被静默跳过表现就是模型怎么都不选它排查起来极其费劲远比启动失败难定位。import json from pathlib import Path import yaml from jsonschema import validate, ValidationError DESCRIPTOR_SCHEMA { type: object, required: [name, summary, parameters, timeout_ms], properties: { name: {type: string, pattern: ^[a-z][a-z0-9_]{2,40}$}, summary: {type: string, maxLength: 60}, timeout_ms: {type: integer, minimum: 100, maximum: 30000}, }, } class ToolRegistry: def __init__(self, tool_dir: str): self._tools {} self._load(Path(tool_dir)) def _load(self, root: Path): for path in root.glob(*.yaml): raw yaml.safe_load(path.read_text(encodingutf-8)) try: validate(raw, DESCRIPTOR_SCHEMA) except ValidationError as e: raise RuntimeError(f工具描述非法: {path.name} - {e.message}) if raw[name] in self._tools: raise RuntimeError(f工具名重复: {raw[name]}) self._tools[raw[name]] raw def get(self, name: str) - dict: return self._tools[name] def all(self) - list: return list(self._tools.values())注意我在 schema 里限制了timeout_ms上限是 30000。不做这个限制的话一定会有人写个 300000 上去然后这个工具一旦卡住就会把执行器的线程池占满连带影响所有其他工具。上限就是保护。4.3 执行器超时、重试与熔断执行器是整个链路里最需要小心的地方。我的实现遵循一个固定的顺序先校验参数再检查熔断状态再检查配额然后执行执行时带上超时失败按策略重试最后记录指标。顺序不能乱尤其是校验必须在最前面因为参数错的请求重试一百次也没用白白消耗配额。超时值的设定有个技巧不要在每一层用同一个超时。工具层声明 3000ms执行器给 3500ms留 500ms 缓冲网关层给 4000ms。层层递进的目的是让超时在工具层先触发产生一个语义清晰的错误而不是被上层粗暴切断那样你就不知道到底是哪里慢。import asyncio import httpx class Breaker: def __init__(self, fail_threshold5, cool_down30): self.fail_threshold fail_threshold self.cool_down cool_down self.fails 0 self.open_until 0.0 def allow(self) - bool: return asyncio.get_event_loop().time() self.open_until def on_fail(self): self.fails 1 if self.fails self.fail_threshold: self.open_until asyncio.get_event_loop().time() self.cool_down def on_success(self): self.fails 0 async def execute(descriptor: dict, params: dict, adapter, breaker: Breaker): timeout descriptor[timeout_ms] / 1000 if not breaker.allow(): return {status: circuit_open, data: None} for attempt in range(3): try: async with httpx.AsyncClient(timeouttimeout) as client: resp await adapter.call(client, params) breaker.on_success() return {status: ok, data: resp, attempt: attempt 1} except (httpx.TimeoutException, httpx.ConnectError) as e: breaker.on_fail() if attempt 2: return {status: failed, error: str(e)} await asyncio.sleep(0.3 * (2 ** attempt)) # 指数退避 return {status: unknown}退避我用的是 0.3、0.6、1.2 秒这样的指数序列加上一点随机抖动更好。不要用固定间隔重试那会让多个并发请求在同一时刻一起冲形成脉冲。熔断阈值我习惯设成连续 5 次失败打开冷却 30 秒这个值在大多数场景下够用具体可以按工具的稳定性调整。4.4 可观测性埋点埋点这件事我的最低要求是每次触达产生一条结构化记录字段包括时间、租户、工具名、参数指纹哈希不存原文、耗时、结果状态、结果字节数、是否重试、是否命中缓存。这条记录用 JSON Lines 写一行一条方便后续直接用命令行工具分析。import json, time, hashlib, logging logger logging.getLogger(reach) def emit(tenant_id, tool, params, start, status, size, retriedFalse): record { ts: round(time.time(), 3), tenant: tenant_id, tool: tool, param_fp: hashlib.md5( json.dumps(params, sort_keysTrue).encode() ).hexdigest()[:12], cost_ms: int((time.time() - start) * 1000), status: status, size: size, retried: retried, } logger.info(json.dumps(record, ensure_asciiFalse))指标层面我最关注四个调用总量、失败率、P95 耗时、熔断触发次数。前两个反映健康度第三个反映体验第四个反映你的依赖是不是在拖后腿。这四个指标按工具维度拆开看基本能定位九成的问题。注意参数记录一定不要存原文。我见过一个项目把工具参数原文全量写进日志里面包含用户手机号和身份证号最后被安全审计挑出来返工。存哈希指纹就够了真要复现问题就存脱敏后的模板。5. 稳定性排查故障速查与容量估算5.1 高频故障速查表下面这张表是我从多个项目的值班记录里整理出来的基本覆盖了八成以上的常见问题。现象最可能的原因快速验证方式处理动作模型不选某个工具描述缺失或与另一个工具混淆人工读一遍描述看是否有重叠补 when_to_use / when_not_to_use参数类型总错描述里类型声明不明确检查 schema 是否有 type补类型与示例值偶发超时超时值设置过紧看耗时分布 P95 和 P99按 P99 的 1.5 倍重设结果算错合计结果被截断但未提示检查是否返回 truncated补 note 字段说明重复执行写入重试没做幂等看同一 param_fp 是否多次执行加幂等键写操作不重试某租户数据串了缓存 key 缺租户维度检查 key 构成补 tenant_id半夜大面积失败依赖服务维护窗口对齐对端维护时间加时间窗降级策略内存持续上涨结果未裁剪直接缓存看缓存对象平均大小加大小上限与淘汰表里有一条我要特别强调写操作不要重试。查询重试是安全的写入重试会制造重复数据而且这种重复往往在业务上表现为账对不上排查成本极高。写入类工具我会在描述里标记idempotent: false执行器看到这个标记就只尝试一次失败直接返回让上层决定。5.2 排查顺序出问题的时候人的本能是直接去看代码但我的经验是先看数据。顺序是先看指标面板确认影响面是全挂还是单个工具再看日志里的最后一次成功记录找到时间分界点再看那个时间点前后有什么变更配置、发版、对端通知最后才是看代码。这个顺序能避免大量无效阅读。举一个真实的例子某天早上九点半开始查询类工具失败率从 0.5% 涨到 12%。我先看指标发现只有查询类失败、写入类正常看日志失败的都是超时且集中在某一个大区看变更记录发现八点五十有个配置变更把那个大区的接口地址换成了新域名。问题就清楚了新域名的网络路径不同延迟高了一截原来的超时值不够了。整个过程十分钟一行代码没看。5.3 容量与超时预算的算法这一块我想给具体的算例因为很多人对超时值是拍脑袋定的。假设某个工具的历史耗时分布是P50 是 120msP95 是 480msP99 是 900ms最长观测到 2400ms。第一步定工具级超时。取值原则是覆盖 P99 并留余量我一般取max(P99 * 1.5, P95 * 2)代入得到max(1350, 960) 1350ms向上取整到 1500ms。注意不要按最大值来定按最大值定会导致偶发慢请求长时间占住资源。第二步定重试预算。重试次数 2 次的话最坏总耗时是1500 * 3 退避 0.3 0.6 5400ms接近 5.4 秒。这个数字要拿去看用户体验能不能接受。如果不行就减到重试 1 次最坏 1500*20.3 3300ms。第三步定并发容量。假设单实例的目标是每分钟处理 6000 次调用平均耗时按 P50 算 120ms那么并发需求约6000/60 * 0.12 12个并发槽位。但这是平均值考虑到峰值是均值的 3 倍实际要准备 36 个槽位再留 50% 余量配置 54 个。这个算法很粗糙但比先给 100 个先给 500 个这种拍脑袋靠谱得多。第四步算成本。假设每个工具描述平均 150 token30 个工具每轮带一次每次对话平均 6 轮那么单次对话的描述开销约150 * 30 * 6 27000 token。这个数字相当可观。所以我在第 3.2 节提到要做粗筛筛到 12 个的话就降到 10800 token省了六成。这笔账一定要算不然上线之后账单会让你重新设计一遍。6. 进阶玩法与我的几条硬规矩6.1 多实例与状态分离单机能跑之后下一步是横向扩展。这里的关键是执行器必须无状态所有需要跨请求保留的东西都外置配额计数放 Redis熔断状态放 Redis 或者本地加聚合上报缓存放 Redis。如果熔断状态放在本地内存多实例之间就会不一致A 实例熔断了、B 实例还在打等于没熔断。我通常的做法是本地维护一份快速判断的副本定期从中心同步兼顾性能和一致性。另一个是多实例下的时钟问题。重试退避、配额窗口都依赖时间如果实例之间时钟偏差大配额会算错。所以部署时必须开时间同步这个不是可选项。6.2 缓存与语义去重缓存分两层精确缓存和语义缓存。精确缓存就是tenant tool params 哈希命中率高、绝对安全代价是换一个字就不命中。语义缓存是把参数向量化后做近似匹配能接住上个月报销多少和上月报销总额是多少这类同义表达但风险是可能把不该混的请求混在一起。我对语义缓存的态度是有条件使用只对纯查询类、结果对时间不敏感、且租户内隔离的工具开启相似度阈值设得保守一点我一般用 0.92 以上才命中并且缓存条目带上明确的过期时间。写入类工具永远不开启。开启之后要监控误命中率方法是在命中语义缓存时同时异步执行一次真实调用对比结果是否一致不一致就记录并调高阈值。这个对比机制运行一周基本就能把阈值调到比较稳的位置。6.3 我自己定下的几条硬规矩做这一层几年下来我给自己定了几条规矩每一条都对应过一次教训写在这里供参考。第一条所有外部依赖必须有明确的超时没有例外。包括那些肯定很快的内部服务。我见过一个内部 RPC 平时 5ms 返回某次因为一次全表扫描卡了 26 秒把整个执行器的线程池拖满导致所有工具全部超时。那次之后我把超时检查加进了上线 checklist。第二条错误信息必须给模型看且必须可读。不要让模型收到500 Internal Server Error这种无法行动的信息。我的标准是错误信息里要包含哪个参数错了期望什么格式你可以怎么做这三样凑齐了模型自己纠正的概率相当高。第三条描述变更要当作代码变更走评审。工具描述直接决定模型的行为改一句话可能让命中率掉两成。所以我把描述文件纳入代码评审任何改动都要有理由并且要在测试集上跑一遍回归。这个成本不高但拦住过好几次手滑改坏。第四条任何工具的返回结果都要能被裁剪。这条是从第 3.3 节的教训来的现在我在归一化层强制走预算控制不允许任何一个工具绕过。第五条上线前必须做一次依赖失联演练。把所有外部依赖逐个断掉看系统的表现是不是符合预期熔断有没有生效、错误信息是不是可读、会不会雪崩。这个演练大概两小时但能让上线当晚睡个好觉。最后分享一个我在排查时常用的小技巧给每个工具维护一份黄金用例就是十条左右的典型参数加期望结果放在仓库里。怀疑哪里出问题的时候先把这十条例一遍五分钟内就能判断是这一层的问题还是外部依赖的问题。这个小东西看着简单但在我手上省下的时间可能是几十个小时。如果后续要继续扩展我会在这份黄金用例的基础上做自动化回归每次描述变更自动跑一遍用数据来判断这次改动到底有没有变好而不是凭感觉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价