把 Agent 接进真实业务的第一天,十有八九会遇到这种场面:本地 Demo 里工具调用、记忆检索、多轮规划全都跑得通,一上线就出现工具选错、参数拼错、循环停不下来、上下文爆掉。问题往往不在模型,而在模型和外部世界之间那一层——我习惯叫它 Agent-Reach,也就是让 Agent 真正"够得着"外部能力的那套能力层与执行编排层。它负责把意图翻译成一次可执行的动作,把动作结果压缩回上下文,把状态保存到能跨会话复用的地方,最后保证整个链路可观测、可回滚、可测试。这篇文章面向正在做 Agent 开发、Agent 框架选型、Agent 记忆与多 Agent 协作的同学,从链路拆解、记忆分层、A2A 协议落地到工程化测试,把我踩过的坑和调参经验一次讲透,新手能照着搭,有经验的人也能对照着检查自己的实现。
1. Agent-Reach 的能力边界:从"能对话"到"够得着"
1.1 Demo 阶段的 Agent 为什么一上生产就哑火
Demo 环境和生产环境的差别,很多人第一反应是"模型换了"。其实更常见的原因是工具数量从 3 个涨到 30 个、上下文从 2 轮涨到 40 轮、并发从 1 涨到 50。这三件事同时发生的时候,Agent 的行为会集体退化。
我做过一次对比实验:同一套提示词、同一个模型,工具数从 5 增加到 25 之后,工具选择准确率从九成出头掉到七成左右。原因不神秘——工具描述会互相稀释注意力。当注册表里同时存在search_doc、search_kb、query_index三个语义高度重叠的工具时,模型只能靠描述里的细枝末节去猜,猜错的概率自然升高。
所以 Agent-Reach 的第一要务不是"接更多工具",而是控制工具的可达空间。常见的做法是按场景分组,先做一层路由,只把本轮可能用到的那一小撮工具注入上下文。这一步做与不做,效果差距远大于换一个更强的模型。
1.2 Reach 的两层含义:能力可达与执行可达
"够得着"这件事要拆成两层看,很多实现只做了第一层。
能力可达指的是模型知道存在这么个工具、知道它的入参含义,能正确生成调用请求。这一层靠的是描述质量、Schema 清晰度、示例充分度。
执行可达指的是调用真的能落地:鉴权有效、网络能通、被调系统没有限流、参数通过了服务端的强校验、返回值能被正确解析、失败了有降级路径。这一层靠的是工程。
我见过太多案例是能力可达做得很好、执行可达一塌糊涂。模型信心满满地调用了一个工具,返回 401,Agent 看到错误信息后开始胡乱重试,重试三次后编造一个看起来合理的答案交差。用户看到的是"这个 Agent 在胡说",根因却是鉴权配置漏了一个环境变量。
经验:在 Agent-Reach 里给每个工具加一个"健康探针",服务启动时和每隔一段时间做一次轻量真实调用。探针失败的工具直接从注册表摘除,而不是等模型调用时才发现。
1.3 Agent 与 Harness 的分工,别混着写
harness和agent这两个词经常被混用。我的理解是:Agent 是决策主体,负责"下一步做什么";Harness 是执行外壳,负责"把决策安全地做出来"。Harness 干的是脏活——上下文拼装、消息裁剪、工具调用、超时控制、重试、日志埋点、失败兜底。
为什么不建议把它们塞在一个类里?因为它们的变更频率完全不同。提示词和规划策略可能一天调三次,而超时重试这类机制往往几个月不动。耦合在一起之后,你想调一下重试次数,得先把整个 Agent 的初始化逻辑读一遍。
一个清爽的分层大概是这样:
| 层 | 职责 | 变更频率 |
|---|---|---|
| 决策层(Agent) | 意图理解、规划、工具选择、结果判读 | 高 |
| 执行层(Harness) | 上下文组装、工具调用、超时重试、错误归类 | 低 |
| 能力层(Tools) | 具体业务动作、参数校验、副作用控制 | 中 |
| 状态层(Memory) | 短期窗口、工作记忆、长期检索 | 中 |
这个划分不是教条,但它能帮你快速定位问题:Agent 答非所问,去看决策层;Agent 报tool timeout,去看执行层;Agent 说"参数不正确",去看能力层的 Schema。
2. 一次请求穿越 Agent-Reach 的完整链路
2.1 路由识别节点:先判意图还是先判能力
路由识别节点是整条链路的第一跳,也是决定后面对错的关键。我试过两种顺序,结论是先判能力域,再判具体工具更稳。
先判意图的问题是意图空间是开放的。用户说"帮我把上周那份报表补全",意图可以归类成"数据处理""文件操作""办公自动化",你很难穷举。而能力域是封闭的,你的系统就那么多子系统,枚举得完。
实现上就是一个轻量分类步骤,输出 2 到 4 个能力域标签,然后按标签过滤工具集。这一步可以用小模型跑,延迟可控在百毫秒级。有个细节值得注意:允许多标签。用户的一句话经常横跨两个域,强行单选会让下游缺工具。给个上限,比如最多 3 个,避免退化成"全选"。
2.2 工具注册表与参数 Schema
工具注册表不要写成一个大字典,建议做成带元数据的注册项:
from dataclasses import dataclass, field from typing import Callable @dataclass class ToolSpec: name: str domain: str # 能力域,供路由过滤 description: str # 给模型看的,控制在两行内 schema: dict # JSON Schema,服务端强校验用 timeout_ms: int = 8000 idempotent: bool = False # 是否可安全重试 side_effect: str = "none" # none / write / external handler: Callable = field(repr=False, default=None)这里有几个字段是踩坑踩出来的:
idempotent决定重试策略。查询类工具可以无脑重试三次,写操作类重试三次可能就是三条脏数据。我在一个订单场景里吃过这个亏,Agent 因为超时重试了三次,用户收到三封确认邮件。
side_effect决定权限级别。external级别的工具需要在调用前做一次显式确认,或者至少记一条审计日志。
timeout_ms不要全局统一。检索类工具给 3 秒够了,跑批类工具给 60 秒也不过分。统一设置要么拖慢整体,要么误杀慢工具。
描述文本的写法也有讲究。我习惯用"动作 + 对象 + 边界"三段式,比如"根据关键词检索内部知识库,只返回标题与摘要,不返回全文"。最后半句尤其重要——它能让模型知道什么时候不该用这个工具。
2.3 执行循环的终止条件设计
Agent 的执行循环最容易失控的地方就是终止条件。只判断"模型是否还输出工具调用"是不够的,你会遇到模型反复调用同一个工具的情况。
我的做法是三重护栏:
- 步数上限:按任务类型给不同上限。简单问答 3 步,多跳检索 8 步,复杂编排 15 步。超过就强制收敛,把当前已收集的信息交给模型做总结。
- 重复调用检测:对
(工具名, 参数哈希)做窗口内去重。同一个调用出现两次就拦截,把第一次的结果直接返回给模型并提示"该结果已提供,请基于已有信息作答"。 - 无进展检测:如果连续两步的观测结果在语义上高度相似(可以用简单的字符串相似度做粗判),直接结束循环。
这三条加起来,能把疯跑的循环压住九成以上。
2.4 上下文预算的分配与压缩
上下文预算是硬约束,必须提前分,而不是等爆了再裁。我给的分法是:
- 系统提示与工具 Schema:30%
- 历史对话:20%
- 工具返回结果:40%
- 留给模型输出的空间:10%
工具返回结果是大头,也是最容易失控的地方。一个网页抓取工具动辄返回几万字符,两三次调用就把窗口填满了。处理方式是在工具层做结果规整,而不是在 Harness 层做截断。工具自己知道哪些字段是关键,让它返回结构化摘要加一个raw_ref指针,需要全文的时候再单独取。
注意:截断是最后手段。被截断的文本往往丢掉关键数字或否定词,模型基于残句推理的后果比"没拿到信息"严重得多。
3. 记忆层的分层设计:别把三种记忆混成一个向量库
3.1 短期窗口、工作记忆、长期记忆各管什么
很多项目一上来就说"我要做 Agent 记忆",然后接一个向量数据库,把所有对话都灌进去。跑一段时间会发现检索结果嘈杂、命中率低、还越用越慢。根本原因是把三种不同性质的东西混在了一起。
| 记忆类型 | 生命周期 | 存储形态 | 典型用途 |
|---|---|---|---|
| 短期窗口 | 单次会话 | 消息列表 | 维持多轮指代关系 |
| 工作记忆 | 单次任务 | 结构化键值 | 存放中间结论、待办、已确认事实 |
| 长期记忆 | 跨会话 | 向量 + 元数据 | 用户偏好、历史结论、领域知识 |
短期窗口就是常规的消息历史,靠裁剪策略维持。工作记忆是很多人忽略的一层,但它的价值极高——它让 Agent 在长任务里有一块"草稿纸"。比如一个报告生成任务,Agent 把已确认的数据点写进工作记忆,后面写正文时直接引用,而不是每轮都去重读原始材料。
长期记忆才是向量库的用武之地,但它的写入必须克制。
3.2 embedding 与关键词的混合检索
单纯依赖 embedding 的问题在中英文混合、专有名词、编号类查询上特别明显。用户问"REQ-2041 的进展",向量检索很可能返回一堆语义相近但编号完全不同的工单。
我的做法是双路召回 + 加权融合:
- 向量路:取 Top 20,权重 0.6
- 关键词路(倒排索引或 BM25):取 Top 20,权重 0.4
- 合并去重后取 Top 5 交给 Rerank,或者直接用加权分排序
编号、日期、人名、产品代号这类查询,关键词路基本能兜住;开放式语义查询靠向量路。两路都保留了,才谈得上"稳"。
3.3 写入时机、去重与遗忘
长期记忆最容易犯的错是每轮都写。结果是同一个事实被存了十几遍,检索时全是你,噪声比信号还多。
我现在用的是"三步闸门":
- 触发写入的信号要明确。只在用户显式表达偏好("以后都用简写")、任务产生可复用结论("这个接口的限流是每分钟 100 次")、或者用户主动要求记住时才写。
- 写前做相似度检查。和已有记忆比对,超过阈值就走更新而非新增。更新时保留原始时间戳,便于判断新旧。
- 加时间衰减。检索排序时用一个衰减因子降低老记忆的权重,同时对超过一定周期且从未被命中的记忆做归档。
遗忘机制听起来像锦上添花,实际是必需品。我做过的对比里,加了衰减和归档之后,长期记忆的检索准确率提升相当明显,因为噪声被压下去了。
4. 多 Agent 协作与 A2A 协议落地的实际问题
4.1 什么信号出现时才该拆多 Agent
单 Agent 能解决的事,不要拆多 Agent。拆分会带来上下文传递损耗、协调开销、失败面扩大三个代价。我判断是否需要拆的信号有三个:
第一个信号是工具集冲突。当两个场景的工具描述互相干扰,导致路由准确率明显下降,且分组之后各自都能稳定到可接受水平,这时拆是合理的。
第二个信号是上下文需求差异巨大。比如一个子任务需要读完整份合同,另一个只需要看摘要。放一起会让上下文预算永远不够分。
第三个信号是权限边界不同。涉及写操作的部分需要更严格的审批,只读部分不需要。用权限边界来切分,天然清晰。
如果三条都不满足,多半是你想多了。
4.2 Agent Card 怎么写才不浪费
Agent Card 是协作的"名片",描述这个 Agent 能干什么、怎么调、有什么限制。我见过很多 Card 写得像宣传文案,实际对接时什么忙都帮不上。
有用的字段应该包含:能力描述(具体到动作粒度,不要写"擅长数据分析"这种空话)、支持的输入输出格式、调用方式与端点、鉴权方式、限流信息、超时约定、以及明确的失败返回格式。最后一项特别重要,协作场景里下游 Agent 最需要知道的是"你失败了会怎么告诉我",而不是"你成功时多厉害"。
能力描述建议控制在 3 到 5 条,每条一句话,动词开头。多了模型抓不住重点,少了又不够用。
4.3 版本对齐:0.3 与 1.0 的字段差异处理
协议演进是现实问题。新老版本并存期间,字段命名、必填项、嵌套结构都可能变。直接硬编码某一版,早晚要重构。
我用的方式是在接入层做一层适配器,把外部收到的 Card 统一转换成本地内部模型。适配器里维护两张映射表,按版本号分发。这样上层协作逻辑只认内部模型,协议变了只改适配器。
另外要处理能力协商。发起方要先看对方的支持列表,确认目标能力存在再发起调用,而不是先调再说。我遇到过一次跨版本调用失败,排查半天发现是新版把某个必填字段改名了,对方直接拒收——如果提前做一次能力协商,这个错在发起前就能暴露。
4.4 协作中的超时、重试与死锁
多 Agent 协作有三个特有的坑。
超时叠加:A 调 B、B 调 C,如果每层都设 30 秒,最坏情况 A 要等 60 秒以上。做法是倒推预算,A 给 B 的预算必须扣除 B 自己的处理时间,逐层递减,并且把剩余预算通过上下文传下去。
重试放大:A 重试 3 次,每次触发 B 的 3 次重试,C 就收到 9 次请求。解法是只在最靠近失败点的那一层重试,上层收到下层失败时直接走降级或上报,不再重试。同时传递一个retry_depth标记。
死锁:两个 Agent 互相等待对方的结果。避免方式是明确单向依赖,协作图形上不允许出现环。如果业务上确实需要互相调用,就引入一个协调者来打破环。
5. 工程化:可观测、测试与安全边界
5.1 Trace 里必须留下的字段
Agent 出问题时,日志的重要性远超普通服务。因为它的行为是概率性的,没有完整轨迹基本无法复现。我现在每一条 Trace 至少记这些字段:
| 字段 | 说明 |
|---|---|
| trace_id / span_id | 串联整条链路 |
| step_index | 第几步,用于定位循环 |
| prompt_hash | 提示词版本,便于对比不同版本效果 |
| tool_calls | 工具名、参数、耗时、返回码 |
| token_usage | 输入输出 token 数,做成本核算 |
| memory_hits | 命中的记忆 ID 与相似度分 |
| route_labels | 路由输出的能力域标签 |
| termination_reason | 循环为什么停:正常完成、超步数、重复调用 |
termination_reason这个字段看着不起眼,实际排查时是救命稻草。一半以上的"Agent 变笨了"投诉,最后都指向循环被某个护栏提前掐断。
5.2 Agent 测试和单测的区别:用轨迹断言
Agent 测试最大的难点是输出不确定。用等值断言去测,你会得到一堆随机失败的用例,最后没人看。
我的做法是断言轨迹而不是断言文本。具体来说,测这几类:
- 应该调用的工具是否被调用了(调用集合包含关系)
- 不应该调用的工具是否没被调用
- 步数是否在预期范围内
- 参数里的关键字段是否正确
- 终止原因是否正常
文本输出的质量测试单独做,用评分模型或者规则集打分,和功能测试分开跑。混在一起会让 CI 又慢又吵。
另外建议维护一个回归集,把线上出现的真实 Bad Case 定期沉淀进去。这个集子会随着时间变成你最有价值的资产,比任何合成数据集都好用。
5.3 工具权限分级与副作用隔离
安全这件事在 Agent 场景里的特殊性在于:执行者是模型,不是人。人会犹豫,模型不会。所以边界必须由系统兜住。
我用的分级是三档:
- 只读级:直接执行,记日志。
- 写入级:执行前校验参数范围,限制单次影响条数(比如一次最多改 10 条),必须有幂等键。
- 高风险级:需要额外确认步骤,或者只在特定条件下开放,执行前后都要留审计记录。
副作用隔离的另一个手段是沙箱执行。对于会执行代码或者改动文件系统的工具,放到隔离环境里跑,设置资源上限和目录白名单。不要让 Agent 直接拥有宿主机上的写权限。
注意:参数校验必须在服务端做,不能只依赖 Schema 提示。模型可以生成看起来完全合规的参数,但业务规则层面依然不合法,比如给一个已关闭的工单追加评论。
6. 调参、踩坑与排错清单
6.1 常见故障对照表
下面这张表是我从实际项目里整理出来的,遇到问题时可以先对照着看:
| 现象 | 高概率根因 | 处理方向 |
|---|---|---|
| 工具选错 | 工具描述重叠、工具数过多 | 按域过滤、重写描述 |
| 参数拼错 | Schema 太宽松、缺少示例 | 收紧 Schema、加 few-shot |
| 循环停不下来 | 缺重复调用检测、终止条件单一 | 加三重护栏 |
| 上下文爆掉 | 工具返回未规整 | 工具层返回摘要 + 引用指针 |
| 记忆检索不准 | 单路向量召回、无衰减 | 混合召回 + 时间衰减 |
| 跨 Agent 调用失败 | 协议版本字段差异 | 加适配器 + 能力协商 |
| 偶发编造答案 | 工具失败后无降级路径 | 明确失败语义,禁止"猜" |
| 响应忽快忽慢 | 工具超时设置一刀切 | 按工具类型分别设置 |
最后一条特别容易被忽略。"编造答案"往往不是模型的问题,是它收到了一个模糊的失败信息,然后选择了最"讨好"的回应方式。把失败语义写清楚——"该查询在 3 秒内未返回结果,请告知用户稍后重试,不要推测内容"——这类提示词能显著降低编造率。
6.2 几个反直觉的调参经验
第一条:降低温度并不总是让 Agent 更稳。在工具选择任务上,过低的温度会让模型更倾向于重复上一个相似决策,反而在需要切换策略时变迟钝。我的经验是工具选择阶段用中等温度,参数生成阶段用低温度,分阶段设置。
第二条:提示词不是越长越好。我做过一个实验,把工具使用规范从 200 字扩到 1200 字,前几次测试效果提升,但工具数量增加后反而变差。长提示词吃掉了本该留给工具结果的预算。现在的做法是把长规范移到工具描述里,就近生效。
第三条:少即是多,工具数量要主动做减法。与其加一个"万能工具",不如把两个语义重叠的工具合并。工具总数控制在一个较小的规模,能明显改善路由准确率。
第四条:给模型"不做"的选项。很多工具集里没有"无法完成"这条路,模型只能硬着头皮调工具。加一个显式的放弃/澄清分支,反而能减少大量无效调用。
6.3 学习路径上的建议
如果你刚开始接触 Agent 开发,我的建议是先手写一遍完整的执行循环,不要一上来就用框架。手动处理上下文拼装、工具调用、结果回填、循环终止,把这条链路走通一次。走过一遍之后,你再看任何 Agent 框架的源码都会觉得眼熟,选型时也更有判断力。
进阶阶段再去看记忆分层、多 Agent 协作、协议接入这些内容。这些是解决具体问题的工具,不是起点。跳过基础直接上多 Agent,大概率会得到一个又慢又不稳、还特别难调的系统。
至于skill和agent的区别,我的理解是:skill 是能力单元,描述"会做什么";agent 是决策单元,决定"现在做什么"。一个 agent 可以挂载多个 skill,skill 本身不做规划。这个区分看起来是名词之争,但在设计系统时能帮你划清楚哪些逻辑该放在哪一层。
最后分享一个我在实际项目里坚持下来的小习惯:每次给 Agent 加一个新工具,都先只加它一个,跑一轮回归看有没有影响到已有工具的选择。工具之间的干扰是隐性的,等你一次加五个再发现问题,就不知道是谁惹的祸了。