
1. Agent-Reach 到底在解决什么问题Agent-Reach 这个项目名字我第一次看到的时候脑子里冒出来的第一个词是“触达”。不是“智能”也不是“推理”而是触达——一个 Agent 到底能不能把手伸到真实系统里把事办成。这两年的 Agent 项目我参与过不少从早期的对话机器人到后来的工具调用框架踩下来最大的感受是模型能力早就不是瓶颈了真正卡住落地的是“触达链路”这一层。Agent-Reach 想干的事情很朴素就是把 Agent 从“能聊”推到“能干活”的那一步把工具、数据、动作这三类触达能力抽象成可注册、可路由、可执行、可观测的工程结构。如果你正在做 Agent 落地或者在评估一个 Agent 产品到底能不能上生产这篇内容应该对你有点用。它不打算讲太多模型原理主要聊工程侧怎么设计工具契约、怎么做路由、怎么控制超时和重试、怎么量化“触达率”、怎么避免重复下单这种要命的坑。基础偏弱的同学也能看懂因为我会尽量用具体的例子和代码说明有经验的同学可以直接跳到第 3、4、5 章那几节是我觉得最值得掏出来的部分。有一点要先说明白Agent-Reach 不是一个具体的开源库也不是某个厂商的产品它更像是一套我们在做 Agent 落地时反复打磨出来的思维框架和工程模板。你可以把它理解成一层“中间件”夹在模型和业务系统之间负责把模型输出的意图翻译成真实世界里的动作并且保证这个过程可控、可查、可回滚。下面的内容里凡是涉及具体参数、具体实现的都是基于常见工程实践的合理补全你可以按自己项目的实际情况做映射。1.1 从“能聊”到“能干活”的那道坎很多人第一次做 Agent路径都差不多拿一个支持 function calling 的模型写几个工具函数塞进系统提示词跑起来发现 Demo 效果惊艳一上真实场景就崩。崩的原因通常不是模型不行而是工程细节没兜住。我梳理过几十次失败案例问题基本集中在四个地方工具描述含糊导致选错工具参数类型对不上导致调用报错外部系统慢或者抖导致整条链路挂死模型看到失败信息后又去重试重试几次上下文炸了最后输出一个看起来很像成功的假结果。这四类问题的共同点是它们都不发生在模型内部而是发生在模型和真实系统之间的那段“触达链路”上。传统的做法是让每一步都靠提示词约束比如在系统提示里写“如果调用失败请重试一次”。这种做法的脆弱性在于它把工程责任交给了自然语言而自然语言是没有执行保证的。Agent-Reach 的核心主张就是把这些责任从提示词里抽出来变成代码里的确定性逻辑超时由执行器管重试由策略管幂等由幂等键管观测由日志管。模型只负责“决定做什么”不负责“保证做到”。这个分工一旦明确很多问题就自然消失了。举个例子工具调用失败的时候执行器会返回一个结构化的错误对象包含错误码、是否可重试、建议的下一步。模型看到的不再是一坨含糊的报错文本而是一个明确的信号。实测下来光是把错误信息结构化这一件事就能把一次成功率从六成多拉到八成以上因为模型不再需要“猜”失败原因。1.2 把“触达”当一等公民核心思路和取舍Agent-Reach 的设计里有一个我认为很关键的决定把“触达”当成一等公民而不是模型的附属品。什么意思就是工具的注册、权限、限流、审计这些能力不是挂在某个 Agent 实例上的临时配置而是一个独立的、跨 Agent 复用的能力层。这个取舍看起来只是目录结构的问题实际上影响很大。如果工具定义散落在各个 Agent 的提示词里你会遇到三个麻烦。第一是重复同一个“查询订单”的工具客服 Agent 写一遍运营 Agent 又写一遍两边的参数命名还不一样。第二是失控你没法统一知道当前系统到底能触达哪些外部资源做安全审计的时候只能一个个翻代码。第三是演化困难外部接口改了字段你得挨个 Agent 去找哪里引用了它。把工具收敛到一个注册表里这些问题就都有了统一的解法。当然这个方案也有代价。收敛意味着抽象抽象意味着要提前想清楚契约长什么样前期投入会变重。我们第一次做的时候为了图快直接让每个 Agent 自己定义工具两周之后工具数量涨到四十多个其中大概有三分之一是功能重复的参数风格五花八门有的用驼峰有的用下划线有的把订单号叫 order_id 有的叫 oid。后来花了差不多同样的时间做重构把工具全部收回到注册表统一命名和字段。所以我的建议是哪怕一开始只有三五个工具也先把注册表的壳搭起来后面会省很多事。1.3 能力边界的三层定义在 Agent-Reach 里我们把触达能力分成三层这个分法对后面做权限和风控特别有用。第一层是只读触达也就是查询类操作比如查订单、查库存、查文档。这类操作风险低可以放开让 Agent 高频调用顶多加点缓存和限流。第二层是写入触达比如创建工单、更新状态、写数据库。这类操作需要幂等保护和权限校验通常还要有人工确认的环节。第三层是外部副作用触达比如发短信、发邮件、发起支付。这类操作基本属于不可逆必须有更强的约束比如金额上限、频率上限、白名单。这三层不是按技术难度分的而是按“出错代价”分的。我在评审一个 Agent 项目的时候最常问的问题就是你这个 Agent 最坏情况下能干出什么事如果答案只是“查了一堆没用的数据”那可以放心上如果答案是“给所有用户发了一条短信”那必须加护栏。把触达分层之后护栏就能挂到对应的层级上而不是散落在每个工具的实现里。2. 整体架构设计与选型考量Agent-Reach 的架构其实不复杂核心就四个模块注册表、路由器、执行器、观测器。看起来像是一个标准的中台结构但每个模块里都藏着不少细节。这一章主要讲设计思路和选型理由代码级的实现放到第 3 章。2.1 四个核心模块的职责边界注册表负责“有什么”路由器负责“选哪个”执行器负责“怎么跑”观测器负责“跑得怎么样”。这四个动词把它们区分得很清楚实际写代码的时候也要守住这个边界否则很快就会变成一锅粥。我见过最常见的越界是执行器里写业务判断。比如在执行器里判断“如果是 VIP 用户就走另一条逻辑”。这种判断一旦进了执行器它就不再是通用的了每个业务都要改一遍执行器最后执行器变成巨型 if-else。正确的做法是把这类判断下沉到工具实现里或者上浮到计划阶段执行器只管超时、重试、熔断这些和业务无关的事。另一个常见的越界是路由器里做参数拼装。路由器的职责是根据意图选工具参数应该是模型产出或者上下文里已有的。如果路由器开始拼装参数那它实际上在做计划器的工作职责就重叠了。职责边界这件事说起来像八股但真到了排查问题的时候边界清晰的项目平均定位时间能短一半以上。因为你知道该去哪个模块看日志。2.2 为什么不能把所有工具塞进一个提示词早期做 Agent 的人大概都干过这件事把所有工具定义序列化成 JSON一股脑塞进系统提示词。工具少于十个的时候还能撑住超过二十个就开始出问题。我在一个项目里做过对比测试工具数量从 8 个增加到 32 个同一个测试集上的选工具准确率从 91% 掉到了 63%而且 token 消耗涨了差不多三倍。掉得最厉害的是那些功能相近的工具比如“查询订单状态”和“查询订单物流”模型经常混着选。Agent-Reach 的做法是把工具选择变成一个检索问题而不是一个阅读理解问题。具体来说每个工具在注册的时候除了 schema还要写一段面向检索的描述和一个向量索引。路由的时候先拿用户意图去做召回取 Top-K 个候选工具再把这 K 个工具的完整定义交给模型去选。K 一般取 5 到 8实测这个区间既能覆盖正确工具又不会让提示词太长。这个改动带来的效果挺明显。还是那个 32 工具的场景加上召回之后准确率回到了 89% 左右token 消耗比全塞进去少了大概六成。代价是你要维护索引工具描述改了要重新向量化。这个代价我觉得完全值。2.3 状态机编排和纯循环的取舍编排这块有两种主流做法。一种是纯 ReAct 式的循环模型想一步执行一步看结果再想下一步直到模型自己说“完成”。另一种是显式的状态机预先定义好阶段每个阶段里模型有一定的自由度但阶段之间的流转是代码控制的。两种做法我们都在不同项目里用过。纯循环的好处是灵活尤其适合那种路径不固定的探索型任务比如“帮我分析一下这个季度的销售异常”。坏处是容易跑飞步数不可控成本不可控排查的时候面对一长串轨迹很难定位问题。状态机的好处是可控每一步的输入输出都很清楚出错能精确定位到阶段坏处是僵化遇到没预设过的路径就卡住了。Agent-Reach 的选择是混合主干用状态机节点内部用有限步数的循环。比如“处理退款申请”这个任务主干阶段是“核实订单、计算可退金额、发起退款、确认结果”这四个阶段由代码流转而“核实订单”这个阶段内部模型可以自己决定是先查订单还是先查支付流水给它两三步的自由度。这样既保证了整体可控又保留了一定的弹性。我的经验是主干阶段超过七个就该考虑拆任务了因为阶段太多说明这个任务本身可能不适合端到端自动化。2.4 技术栈选型对照选型这块没有标准答案我列一下我们踩过之后的倾向供参考。组件方案 A方案 B我们的倾向与理由工具注册代码装饰器配置文件装饰器。类型提示和 IDE 补全能省很多事配置容易写错字段工具检索向量召回关键词 规则混合。纯向量对“查订单”和“查询订单”这种近义还行但对缩写和内部黑话不行状态存储Redis进程内存Redis。Agent 任务经常跨请求进程内存一重启就丢幂等键业务主键哈希随机 UUID业务主键哈希。UUID 每次都不一样做不到真正幂等观测结构化日志全链路追踪两者都要。日志看单点追踪看链路缺一个排查都费劲重试固定次数指数退避指数退避 抖动。固定次数在外部系统抖动时很吃亏表里每一条背后其实都有一次事故。比如幂等键那一条我们早期用随机 UUID结果有一次网络抖动导致重试同一个退款发起了两次虽然最后对账的时候发现了但处理起来相当麻烦。改成业务主键哈希之后同一个退款请求无论重试多少次落到下游都只会生效一次。3. 核心模块的实操实现这一章是重点。我会把注册表、路由、执行器、上下文管理、观测这五块的具体实现写出来代码是 Python 的逻辑可以直接迁移到其他语言。需要说明的是下面这些代码是简化版本重点在结构和思路生产环境还需要补上日志、错误处理、并发控制等细节。3.1 工具注册表契约先行的 schema 设计工具注册的关键是“契约先行”。我要求每个工具在写实现之前先把 schema 定下来包括名称、描述、参数、返回结构、触达层级、是否幂等、预估耗时。这些东西定清楚了后面路由和执行才有依据。from dataclasses import dataclass, field from enum import Enum from typing import Any, Callable, Dict, List, Optional class ReachLevel(Enum): READ read # 只读触达 WRITE write # 写入触达 SIDE_EFFECT side # 外部副作用触达 dataclass class ToolSpec: name: str description: str # 面向模型的描述说清什么时候用 search_text: str # 面向检索的文本堆关键词 parameters: Dict[str, Any] # JSON Schema returns: Dict[str, Any] level: ReachLevel idempotent: bool timeout_ms: int 5000 max_retries: int 2 handler: Optional[Callable] field(defaultNone, reprFalse) def to_model_schema(self) - Dict[str, Any]: return { name: self.name, description: self.description, parameters: self.parameters, } class ToolRegistry: def __init__(self): self._tools: Dict[str, ToolSpec] {} def register(self, spec: ToolSpec): if spec.name in self._tools: raise ValueError(f工具重复注册: {spec.name}) self._tools[spec.name] spec return spec def get(self, name: str) - ToolSpec: if name not in self._tools: raise KeyError(f未注册的工具: {name}) return self._tools[name] def by_level(self, level: ReachLevel) - List[ToolSpec]: return [t for t in self._tools.values() if t.level level]这里有几个设计点值得说。description和search_text分开是我强烈建议的。description是给模型看的要写清楚“什么时候用它”而不是“它是什么”。比如“查询订单状态”这个工具description 应该写“当用户询问订单进度、是否发货、预计到达时间时使用”而不是“调用订单服务获取状态”。后者是给工程师看的模型看了容易在不需要的时候也去调。search_text则是给检索用的可以把内部黑话、同义词、缩写都堆进去反正不占模型的上下文。idempotent这个字段也很有用。它决定了执行器在超时之后敢不敢重试。一个非幂等的工具超时了你不能盲目重试因为可能第一次已经成功了重试会造成重复副作用。这时候应该走“先查询再决定”的路径。注意工具描述里千万别写“这个工具可能会失败请重试”这类话。执行器会处理重试把这类指令写进描述只会让模型在不该重试的时候乱重试。3.2 路由与计划把选工具当成检索问题路由分两步召回和选择。召回用混合检索向量相似度加关键词命中各占一定权重。选择交给模型但只给召回到的候选。import math from typing import List, Tuple def cosine(a: List[float], b: List[float]) - float: dot sum(x * y for x, y in zip(a, b)) na math.sqrt(sum(x * x for x in a)) nb math.sqrt(sum(y * y for y in b)) if na 0 or nb 0: return 0.0 return dot / (na * nb) def keyword_hit(query: str, text: str) - float: q_tokens set(query.lower().split()) t_tokens set(text.lower().split()) if not q_tokens: return 0.0 return len(q_tokens t_tokens) / len(q_tokens) def recall_tools(query: str, registry: ToolRegistry, embed_fn, top_k: int 6, alpha: float 0.7) - List[Tuple[ToolSpec, float]]: q_vec embed_fn(query) scored [] for spec in registry._tools.values(): t_vec embed_fn(spec.search_text) vec_score cosine(q_vec, t_vec) kw_score keyword_hit(query, spec.search_text) # 加权融合向量为主关键词做兜底 final alpha * vec_score (1 - alpha) * kw_score scored.append((spec, final)) scored.sort(keylambda x: x[1], reverseTrue) return scored[:top_k]alpha这个权重我建议从 0.7 起步。为什么向量占大头因为用户的表达方式和工具名称往往对不上纯关键词会漏。但为什么不能给到 1.0因为向量检索对内部术语不敏感比如你们内部把“退款”叫“逆向”纯向量可能召回不到。留 0.3 给关键词能兜住这部分。召回之后把候选工具的to_model_schema()结果拼成提示词让模型输出一个结构化的选择结果包含工具名和参数。这里要注意模型的输出一定要做校验参数类型不对要直接拒绝并返回明确错误而不是硬塞给执行器。我见过太多项目在这一步省事结果下游接口报一堆 500。3.3 执行器超时、重试、幂等与熔断执行器是整条链路里最需要写扎实的部分。它的核心职责是保证每一次触达都在可控范围内。import hashlib import random import time from typing import Any, Dict class Executor: def __init__(self, registry: ToolRegistry, idem_store, breaker): self.registry registry self.idem_store idem_store # 幂等键存储Redis 实现 self.breaker breaker # 熔断器 def _build_idem_key(self, tool: str, args: Dict[str, Any]) - str: raw f{tool}:{sorted(args.items())} return hashlib.sha256(raw.encode()).hexdigest() def call(self, tool_name: str, args: Dict[str, Any], task_id: str, step: int) - Dict[str, Any]: spec self.registry.get(tool_name) if not self.breaker.allow(tool_name): return {ok: False, code: CIRCUIT_OPEN, retryable: False, msg: 下游连续失败已熔断} idem_key self._build_idem_key(tool_name, args) if spec.idempotent: cached self.idem_store.get(idem_key) if cached is not None: return {ok: True, data: cached, from_cache: True} last_err None attempts spec.max_retries 1 for i in range(attempts): start time.time() try: result spec.handler(**args) cost int((time.time() - start) * 1000) if cost spec.timeout_ms: # 超时但已执行完异步任务里这种情况必须记录 last_err {code: TIMEOUT_SOFT, retryable: False} break self.breaker.record_success(tool_name) if spec.idempotent: self.idem_store.set(idem_key, result, ttl86400) return {ok: True, data: result, from_cache: False} except TimeoutError as e: last_err {code: TIMEOUT, retryable: spec.idempotent} except Exception as e: last_err {code: type(e).__name__, retryable: True, msg: str(e)} if not last_err.get(retryable): break self.breaker.record_failure(tool_name) # 指数退避 抖动避免重试风暴 backoff (2 ** i) * 0.2 random.uniform(0, 0.1) time.sleep(backoff) return {ok: False, code: last_err[code], retryable: last_err.get(retryable, False), msg: last_err.get(msg, )}这段代码里有三处是我特别想强调的。第一是幂等键的构造方式用工具名加排序后的参数做哈希同一组参数无论调用多少次键都一样。排序是为了避免参数顺序不同导致键不同。第二是retryable的判断非幂等的工具超时不重试这是原则问题。第三是退避里加了抖动因为大量任务并发重试的时候如果退避时间完全一致会在同一个时间点集体打向下游反而把下游打死。熔断器这块逻辑不复杂就是统计一个滑动窗口内的失败率超过阈值就打开过一段时间进入半开状态试探。参数上我一般设窗口 60 秒、最少样本 10 次、失败率阈值 50%、打开时长 30 秒。这几个值在大多数场景下够用如果你的下游特别脆弱可以把阈值调到 30%。注意给执行器加日志的时候参数里的敏感字段手机号、身份证、银行卡一定要脱敏后再落盘。这个不是合规要求的问题是出了事你兜不住。3.4 上下文管理把长对话压成可计算的状态Agent 跑到七八步之后上下文里堆满了工具返回的原始数据token 涨得飞快而且模型开始抓不住重点。Agent-Reach 的做法是不把原始返回全部塞回上下文而是存到外部上下文里只留一个摘要加一个引用键。具体来说每个工具返回的结果执行器会做两件事完整结果写进任务状态存储key 是task_id:step返回给模型的则是一个压缩后的摘要字段包含关键结论、数据条数、以及那个引用键。如果模型后续需要某一步的详细数据它可以调用一个内置的fetch_step_detail工具去取。这个工具本身是只读的不占额外的触达风险。这个设计的效果挺直观。我们做过一次对比同一个需要十二步的任务全量塞上下文的版本在第九步左右就开始出现重复调用已经调过的工具而用摘要加引用的版本一直跑到结束都没乱。token 消耗大概从 4.8 万降到了 1.6 万。压缩比大约是三比一但关键信息没有丢因为需要细节的时候还能取回来。摘要怎么写也是个活。我的做法是让工具实现自己返回一个summary字段而不是让模型去总结工具结果。原因很简单工具实现最清楚哪些字段重要交给模型总结既慢又可能出错。注册工具的时候把“必须返回 summary”写进契约里。3.5 观测与回放让每一次触达可复盘观测这块我只讲一件事把每一次触达的完整上下文记下来包括任务 ID、步骤序号、模型输入、模型输出、选中的工具、参数、返回、耗时、是否命中缓存、是否重试。这些字段落成结构化日志一条一行方便查询。回放能力是从这些日志里长出来的。给定一个任务 ID你能按步骤还原整个过程看到模型在哪一步选错了工具、哪一步参数填错了、哪一步重试了。做评测的时候回放比重新跑一遍有价值得多因为重新跑会有随机性而回放看到的是真实发生过的事情。我强烈建议在项目早期就把这套日志埋好哪怕当时觉得没什么用。我们吃过一次亏一个线上问题反复出现但是日志里只有“调用失败”具体参数没记排查了两天才定位到是某个字段传了空字符串。补日志的成本很低补不回来的数据成本很高。4. Reach 度量怎么量化“触达能力”做 Agent 项目最怕的一件事是“感觉还行”。感觉还行通常是没度量。Agent-Reach 里我们把触达能力拆成几个可算的指标这样每次改动都能看到数字变化而不是靠拍脑袋。4.1 三个核心指标的定义触达率在评测集里Agent 最终完成任务的比例。注意是“最终完成”中间失败重试成功也算。这个指标反映的是整体能力但会被简单任务拉高所以要配合难度分层看。一次成功率第一次调用就成功的比例不依赖重试。这个指标反映的是链路质量包括路由准不准、参数对不对、下游稳不稳。一次成功率低但触达率高说明重试机制在兜底系统是健康的两个都低说明基础链路有问题。单位任务成本完成一个任务平均消耗的 token 和平均步数。这个指标不直接反映能力但决定了你能不能规模化。我见过不少 Demo 很漂亮的项目单位任务成本高到根本没法上量。指标计算方式健康参考区间低于区间说明什么触达率完成任务数 / 总任务数85% 以上路由或工具覆盖有问题一次成功率首调成功数 / 总调用数75% 以上参数校验或下游稳定性问题平均步数总步数 / 任务数4 到 8 步步数过高说明计划能力差单位成本总 token / 任务数视业务而定上下文管理需要优化这几个参考区间是我们几个项目跑下来的经验值不是硬标准。如果你的任务本身很难触达率 70% 也可能是合理的如果任务很简单85% 就偏低了。4.2 评测集怎么造才有参考价值评测集这件事上我有一个很明确的观点用真实流量的脏数据别用自己编的漂亮数据。自己编的测试用例通常太规整用户会怎么说话你根本想不到。我们第一次做评测集的时候编了 50 条模型准确率 96%上线之后真实触达率只有 61%。后来把线上真实的失败请求捞出来凑了 200 条准确率掉到 68%但改完之后真实数据也涨到 80% 左右这才是有效的评测。具体做法是从线上日志里按失败类型分层抽样每类抽二三十条再加上一部分成功的长尾案例凑够 150 到 300 条。每条标注期望的工具序列和关键参数。这个标注工作量不小但比后面反复瞎改省时间。评测集还要定期更新。用户说话的方式会变业务也会加新工具半年前的评测集可能已经不能反映现状了。我的做法是每个季度从线上捞一批新的失败案例补进去同时把已经稳定通过的案例换掉一部分。4.3 超时预算和步数上限的具体算法参数不能拍脑袋定得有依据。超时预算这块我的算法是这样先定一个任务级别的端到端预算 B比如用户在客服场景能等 30 秒再估一个典型步数 n比如 6 步再给每步留重试次数 r比如 2 次。那么单步超时 T 的估算公式是T B / (n * (1 r) * safety_factor) 30000 / (6 * 3 * 0.8) 2083 mssafety_factor取 0.8 是因为不是每一步都会走到重试留点余量。算出来大约 2 秒再取个整数 2000 毫秒。这个值会随着实际观测调整如果发现某类工具经常在 1.5 秒左右返回可以把它的超时单独设短一点。步数上限我用的是“期望步数乘以二再设一个硬顶”。比如期望 6 步软上限 12 步硬顶 20 步。到软上限的时候给模型一个提示让它收敛到硬顶直接终止返回当前的部分结果。硬顶是一定要有的因为总会有模型陷入循环的时候没有硬顶就是无底洞。成本控制也是同样的思路。先算清楚单步平均 token再乘步数上限得到单任务最坏情况的 token 消耗然后看看这个数字乘上预估的日调用量是什么量级。如果扛不住就得回头优化上下文压缩而不是指望模型自己变便宜。5. 常见问题与排查实录踩坑这块我攒了不少挑几个最典型的说说最后给一张速查表。5.1 工具幻觉和“假成功”工具幻觉有两种表现。一种是模型凭空编出一个不存在的工具名另一种是编造工具返回结果。第一种在执行器那里会被挡住因为注册表里查不到就会报错。第二种更麻烦模型没有真的调用工具但在回复里说“我已经帮您查询到订单状态是已发货”。这个问题在早期项目里特别常见因为模型有很强的“完成任务”倾向。解决办法有三个层次。最基础的是在提示词里明确要求“所有事实性结论必须来自工具返回”但光靠这个不够。第二个层次是在执行器的返回里带一个step_ref字段最终回复生成时检查是否引用了有效的 step没有引用就重生成。第三个层次是在评测集里专门加一类“诱导幻觉”的用例比如问一个系统里根本查不到的信息看模型会不会编。我们加了这类用例之后发现大约有 8% 的请求会编内容优化之后降到 1% 以下。5.2 死循环和步数爆炸死循环的典型模式是调用工具 A 失败模型重试工具 A还是失败换个参数再试 A一直试下去。根因通常是执行器返回的错误信息不够明确模型不知道该怎么换路子。排查的时候主要看两件事同一步内是否有相同工具被反复调用以及调用之间参数的差异有多大。如果参数几乎没变说明模型没有获得新信息。这时候应该检查错误信息里有没有可操作的建议比如“该订单不存在请先确认订单号”这种。如果错误信息只是“调用失败”模型只能瞎试。另外一个容易忽略的点是熔断器的作用。有了熔断之后下游连续失败会直接返回 CIRCUIT_OPEN模型会收到一个“这条路暂时走不通”的强信号反而更容易换路子。实测加了熔断之后平均步数从 9.2 降到了 6.7。5.3 上下文雪崩和 token 成本失控上下文雪崩指的是某一步返回的数据特别大比如查询订单返回了 500 条记录一下子把上下文撑满后续几步模型的表现急剧下降。这个问题在查询类工具上特别常见因为开发的时候用少量数据测试上线后遇到大客户就炸了。解决办法是强制工具实现做分页和截断。注册工具的契约里加一条返回给模型的摘要不超过 500 字超过的部分截断并提示“结果过多已展示前 N 条可使用过滤条件缩小范围”。这条规则加上之后我们再也没遇到过上下文被单次返回撑爆的情况。5.4 幂等缺失导致的重复动作这个坑最疼因为它会造成真实的业务损失。前面提过我们用随机 UUID 做幂等键导致重复退款其实还有一次是重复发工单用户收到两条一样的通知。根因是重试逻辑没有和幂等机制挂钩。修复的思路是把幂等判断放在执行器的最前面而不是放在业务代码里。业务代码里写幂等每个工具都要写一遍迟早会漏。放在执行器里只要工具的idempotent标记为真就自动走幂等路径。同时要保证幂等键的 TTL 足够长至少覆盖最长的重试链路我们一般设 24 小时。5.5 排查速查表现象最可能的根因先查什么处理方式选错工具工具描述含糊或召回不准看召回 Top-K 是否包含正确工具改 description 和 search_text调 alpha参数报错schema 校验缺失看模型原始输出加参数校验错误信息给具体字段一直重试同一个工具错误信息不可操作看返回的 code 和 msg补充可操作提示启用熔断回复内容编造未强制引用工具结果看最终回复是否带 step_ref加引用校验重生成步数异常高计划能力差或路径受阻看步数与步之间的参数变化拆分任务收窄工具范围token 暴涨单次返回过大看每步返回的字符数强制分页截断摘要加引用重复副作用幂等缺失看幂等键构造方式改成业务主键哈希TTL 加长这张表我打印出来贴在工位上过排查的时候照着看比凭经验猜快多了。6. 生产落地的一些经验前面讲的偏技术实现这一章聊几个落地层面的东西都是上线之后才想明白的。6.1 权限最小化和人在环权限这块我的原则是“默认不给按需申请”。Agent 能调用的工具集合应该是它完成当前任务所需的最小集合而不是注册表里的全部。实现上就是在任务开始时根据任务类型生成一个允许的工具白名单执行器只接受白名单内的调用。外部副作用类的触达我建议都要有人工确认环节哪怕会牺牲一点自动化率。具体做法是 Agent 执行到这类工具时生成一个待确认的动作推给人工审核审核通过后再执行。确认环节的设计要点是把“要做什么”说得足够具体比如“向用户 138****1234 发送退款到账通知”而不是“执行发送通知操作”。前者审核的人一眼能判断后者只能盲批。我们在一个场景里做过对比加了确认环节之后自动化率从 100% 降到 78%但人工纠正的错误动作有 40 多个其中有几个如果放出去是会引发投诉的。这个交换比我认为很划算。6.2 灰度上线和影子模式Agent 的上线方式不能像普通服务那样开关一拉就全量。我的做法是三步走。第一步影子模式真实请求进来Agent 也跑一遍但结果不生效只记录。这一步能拿到真实的分布和失败率而且没有任何风险。第二步小流量挑 5% 的低风险请求真实生效同时保留人工复核。第三步逐步放量每放一档观察 24 小时的核心指标。影子模式是我最推荐的一步因为它能让你在零风险的情况下看到 Agent 在真实流量上的表现。很多问题只有在真实流量下才会暴露比如用户输入里混着方言、乱码、多语言。影子跑一周基本能覆盖大部分情况。6.3 成本控制的几个具体做法成本控制不是一句“优化 prompt”能解决的得有具体动作。我列几个实际用过的。第一个是缓存。只读类工具的结果做短时缓存同一个用户短时间内问同样的问题直接命中缓存。我们的场景里这一项省了大约 18% 的调用量。缓存时间要按数据的时效性定库存这种设 30 秒订单状态设 60 秒商品详情可以设 10 分钟。第二个是模型分流。简单任务用小模型复杂任务用大模型。判断依据可以看召回的工具数量和候选分数分布候选集中且分数高说明意图明确走小模型候选分散说明意图模糊走大模型。这一项大概省了 25% 的成本。第三个是提前终止。前面提到的软上限就是干这个的跑到软上限还没完成的任务大概率也不会在剩下的步数里完成了早点终止返回部分结果比让它跑到底更省。三个加起来我们的单位任务成本大概降到了最初版本的 45%。这个数字不是靠某一个技巧而是靠一堆小优化堆出来的。最后分享一个我在 Agent-Reach 这个项目里体会最深的事情Agent 的工程质量八成取决于那些“不智能”的部分。超时、重试、幂等、日志、权限这些东西听起来一点都不酷但它们决定了系统能不能在真实环境里活下去。我见过太多项目把精力全花在调提示词和换模型上最后卡在一个幂等键的设计上。所以如果你现在正在做类似的事情先把这些地基铺好上面盖什么都会稳一些。至于提示词的微调那是个可以慢慢磨的活不着急。