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_ascii=False) 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_ascii=False)) 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(encoding="utf-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_threshold=5, cool_down=30): 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(timeout=timeout) 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, retried=False): record = { "ts": round(time.time(), 3), "tenant": tenant_id, "tool": tool, "param_fp": hashlib.md5( json.dumps(params, sort_keys=True).encode() ).hexdigest()[:12], "cost_ms": int((time.time() - start) * 1000), "status": status, "size": size, "retried": retried, } logger.info(json.dumps(record, ensure_ascii=False))指标层面,我最关注四个:调用总量、失败率、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 是 120ms,P95 是 480ms,P99 是 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*2+0.3 = 3300ms。
第三步定并发容量。假设单实例的目标是每分钟处理 6000 次调用,平均耗时按 P50 算 120ms,那么并发需求约6000/60 * 0.12 = 12个并发槽位。但这是平均值,考虑到峰值是均值的 3 倍,实际要准备 36 个槽位,再留 50% 余量,配置 54 个。这个算法很粗糙,但比"先给 100 个""先给 500 个"这种拍脑袋靠谱得多。
第四步算成本。假设每个工具描述平均 150 token,30 个工具每轮带一次,每次对话平均 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 节的教训来的,现在我在归一化层强制走预算控制,不允许任何一个工具绕过。
第五条,上线前必须做一次依赖失联演练。把所有外部依赖逐个断掉,看系统的表现是不是符合预期:熔断有没有生效、错误信息是不是可读、会不会雪崩。这个演练大概两小时,但能让上线当晚睡个好觉。
最后分享一个我在排查时常用的小技巧:给每个工具维护一份"黄金用例",就是十条左右的典型参数加期望结果,放在仓库里。怀疑哪里出问题的时候,先把这十条例一遍,五分钟内就能判断是这一层的问题还是外部依赖的问题。这个小东西看着简单,但在我手上省下的时间可能是几十个小时。如果后续要继续扩展,我会在这份黄金用例的基础上做自动化回归,每次描述变更自动跑一遍,用数据来判断这次改动到底有没有变好,而不是凭感觉。