如果你正在做 Agent 类应用,大概率遇过这样一种情况:模型本身能力不错,逻辑推理也到位,任务却还是莫名其妙地失败。不是它不会做,而是它“够不着”——上下文窗口被历史记录塞满,该找的资料找不到,该用的工具没被选中,目标绕了七八步之后早就漂移了。我把这个问题统称为“智能体可达性缺失”,并且用了几周时间做了一个叫 Agent-Reach 的轻量框架来解决它。这篇文章是 Agent-Reach 从需求到落地、从踩坑到评测的完整复盘,适合正在做智能体应用的开发者、对 Agent 架构感兴趣的产品同学,以及被工具调用折磨过的每一个人。
1. 先说说智能体为什么总翻车——Agent-Reach 的目标
1.1 大多数失败不是能力问题,而是“够不着”的问题
很多团队在调智能体时有个误区:任务做不好,第一反应是换更强的模型,或者把提示词写得更加“苦口婆心”。这确实能解决一部分问题,但解决不了另一类更隐蔽的问题——信息可达性。
大模型本身是“无状态”的。它就像一个记忆力极好但桌面却极其混乱的员工:你说什么它都听得懂,但如果你不给它工具、不给它材料、不告诉它当前进展到哪一步,它就只能在脑子里空转。你让它处理一个月前的对话、查找一个没有出现在上下文里的订单号、调用一个它在工具列表里根本没见过的接口,它当然会失败。
这里的“够不着”又可以拆成三件事:
- 信息够不着:历史记录、知识库内容、中间计算结果没有进入模型上下文,或者被无关信息淹没。
- 工具够不着:工具数量多了以后,模型根本不知道应该调用哪一个,选错工具甚至编造工具名。
- 目标够不着:多步骤任务执行到一半,模型忘了终局目标,开始做“看起来合理但方向错误”的操作。
我在做 Agent-Reach 时,核心思路就是围绕这三层可达性做工程化增强。不追求让模型更聪明,而是追求让模型的每一步操作都有据可依、有路可走。
1.2 我做 Agent-Reach 之前踩过的两个翻车现场
先说第一次翻车。当时我负责一个客服工单自动处理机器人,工具列表大概有四十多个,包括查订单、查物流、退款、转接人工、查询优惠券等。最开始我用 ReAct 风格的提示词把所有工具定义直接塞进 system prompt,模型在简单场景里表现还行。一旦聊天记录超过十几轮,prompt 长度逼近窗口上限,模型就开始“糊涂”:它会把物流查询工具当成订单查询工具来调用,参数漏填、乱填的情况频繁出现。
最离谱的一次,用户问“我上周买的东西什么时候能到”,模型调用了“查询优惠券”工具,然后一本正经地回答“您的优惠券已过期”。这明显不是模型智商问题,而是它眼前的信息太多了,反而不知道该看哪一行。
第二次翻车是长流程任务。一个数据分析智能体需要连续执行“读取数据表 -> 清洗字段 -> 聚合统计 -> 生成图表 -> 产出报告”五个步骤。到第三步时数据里出现了一个空值,工具返回了一个异常。模型没有回退重试,也没有跳过这个空值,而是直接脑补出一组结果,继续往下生成报告。最后报告数据跟真实数据完全对不上。
这两件事让我确定了一个判断:智能体应用的瓶颈往往不在模型,而在工程层没有解决“可达性”问题。于是 Agent-Reach 立项了——一个不折腾提示词技巧,而是通过状态管理、记忆分层、工具路由和失败恢复来提升任务完成率的轻量级框架。
2. Agent-Reach 的整体逻辑:三层可达 + 核心架构
2.1 三层可达模型,怎么对应解决实际痛点
Agent-Reach 把智能体的运行过程抽象成三条“通路”:信息通路、工具通路、目标通路。每条通路是否畅通,直接决定任务能否完成。
信息通路解决的是“模型眼前有什么”。传统做法是把所有信息一股脑倒进上下文,Agent-Reach 的做法是分层:工作记忆(working memory)只保留当前步骤必须的信息,历史信息通过压缩摘要和结构化索引存入长期记忆,需要时按相关性检索。这样可以保证模型看到的 prompt 始终是精简、高相关的。
工具通路解决的是“模型手里有什么”。工具注册表不再只是静态文本,而是把每个工具的名称、描述、参数 Schema、触发关键词、适用场景标签全部结构化存储。执行任务时,Agent-Reach 先做一次工具预检索,只把最相关的五到八个工具描述放进提示词。模型面前永远是“精选菜单”,而不是一本五百页的产品手册。
目标通路解决的是“模型要去哪”。Agent-Reach 为每个任务维护一个目标任务状态机,记录目标描述、已完成步骤、当前卡点、剩余依赖。每轮模型输出后,状态机都会更新,并把这部分状态压缩成一小段结构化 JSON 反馈给模型。模型在任何时刻都清楚自己在整条路径上的位置,不会跑偏。
这三层不是互相独立的。信息通路负责提供粮食,工具通路负责提供武器,目标通路负责提供地图。Agent-Reach 的核心就是把这三样东西在正确的时间送到模型手边。
2.2 整体架构:不追求大而全,只承担“调度中枢”职责
Agent-Reach 的架构非常直白,核心进程可以分成五个模块:
- 记忆服务(Memory Service):管理短期上下文和长期向量存储,负责信息压缩、检索、去重。
- 工具注册表(Tool Registry):管理工具清单、描述、Schema、健康状态。
- 路由决策器(Tool Router):根据任务描述、当前意图、上下文关键词,计算工具候选列表。
- 任务状态机(Task State Machine):跟踪目标进度、维护子任务列表、生成状态摘要。
- 恢复策略器(Recovery Strategy):捕获工具异常、分析错误类型、决定重试还是降级还是询问用户。
用户或者上层应用只需要把任务丢给 Agent-Reach Core,Core 会自己反复执行“推理 -> 调用工具 -> 更新状态 -> 再次推理”的循环,直到任务达成、用户取消或者进入人工兜底。
这个设计的核心决策是:不让模型直接管理自己的上下文和状态,而是由外围系统代管。大模型不擅长长线记忆,那就交给数据库和向量索引;不擅长管理复杂状态,那就交给状态机;不擅长从几百个工具里精确选择,那就交给检索路由器。让模型只做它最擅长的事——理解和生成。
2.3 两个我坚持的选型原则
第一,能不塞进 prompt 的,就不要塞。很多人迷信“给模型更多信息它就能做得更好”,实际上当无关信息超过一定比例,模型的表现会急剧下降。Agent-Reach 遵循最小充分原则:只把当前步骤必不可少的上下文、最相关的工具描述、浓缩后的目标状态放进窗口,其余一律外置。
第二,宁可让外部逻辑多干活,不让模型瞎猜。比如工具参数缺失时,传统做法是让模型凭经验去生成一个参数值。Agent-Reach 的做法是,先到记忆服务里检索历史对话,看看这个参数是否已经存在;检索不到就明确要求模型向用户提问,绝不脑补。这套规则听起来简单,但对结果可信度的提升极其明显。
3. 核心模块实现:关键代码与设计思路
3.1 记忆层:什么信息该进上下文,什么信息该进仓库
记忆层是 Agent-Reach 的第一个核心模块。我实现了一个ReachMemory类,职责很简单:
- 短期记忆:保存在运行周期内的关键观测结果,比如最近一次工具返回数据。
- 长期记忆:把重要事实(用户信息、业务规则、历史结论)写入结构化存储或者向量库,按需检索回上下文。
# memory.py 伪代码示意 from dataclasses import dataclass, field from typing import Any, Optional @dataclass class MemoryItem: key: str content: Any kind: str # "fact" / "observation" / "conclusion" created_at: float score: float = 0.0 class ReachMemory: def __init__(self, max_working_items: int = 12): self.working: list[MemoryItem] = [] self.long_term: dict[str, MemoryItem] = {} self.max_working_items = max_working_items def add_fact(self, key: str, content: Any) -> None: item = MemoryItem(key=key, content=content, kind="fact", created_at=time.time()) self.long_term[key] = item self.working.append(item) self._trim_working() def add_observation(self, content: Any) -> None: self.working.append( MemoryItem(key=f"obs_{len(self.working)}", content=content, kind="observation", created_at=time.time()) ) self._trim_working() def recall(self, query: str, top_k: int = 3) -> list[MemoryItem]: scored = [ item for item in self.working + list(self.long_term.values()) if self._match(query, item) ] scored.sort(key=lambda x: x.score, reverse=True) return scored[:top_k] def context_summary(self) -> str: # 把工作记忆列表转成一段紧凑文本,供模型阅读 lines = [] for item in self.working[-self.max_working_items:]: lines.append(f"[{item.kind}] {item.key}: {item.content}") return "\n".join(lines) def _trim_working(self) -> None: if len(self.working) <= self.max_working_items: return # 把最老的 observation 先沉入 long_term,或者做摘要合并 old = self.working.pop(0) self.long_term[old.key] = old def _match(self, query: str, item: MemoryItem) -> bool: # 简单实现可以基于关键词重合度 query_terms = set(query.split()) item_terms = set(str(item.content).split()) return len(query_terms & item_terms) > 0 or query in item.key你可能会问,为什么不直接全文检索向量数据库?我在早期版本确实试过引入向量检索,后来发现大部分业务场景的关键词重叠匹配已经能覆盖八成需求,向量库反而增加了查询延迟和运维成本。最终实现是先用关键词粗筛,如果召回结果不足,再启用向量检索兜底。
这里有一个很关键的调整:不要把原始对话全部塞进长期记忆库。我一开始把用户说的所有话都存进去,结果检索出来一堆无关内容,反而污染上下文。后来改成只存三种东西:用户明确给出的关键信息、工具返回的有效结果、已经生成的阶段结论。信息密度立刻提升了一大截。
3.2 工具注册表与路由决策:让模型永远面对“精选菜单”
工具模块是 Agent-Reach 里改动最大的部分。第一版实现里,我把所有工具的定义拼接成一个巨型字符串放进 system prompt,测试工具超过三十个之后效果直线下滑。第二版加入了检索路由机制,效果有了质的飞跃。
工具注册表的核心数据结构是非常简单的:
@dataclass class ToolSpec: name: str description: str parameters_schema: dict keywords: list[str] enabled: bool = True callable: Any = None路由决策器每次在调用模型之前,先根据用户请求和当前任务目标,从注册表里筛选候选工具:
class ToolRouter: def __init__(self): self.tools: dict[str, ToolSpec] = {} def register(self, spec: ToolSpec) -> None: self.tools[spec.name] = spec def select_candidates(self, query: str, max_candidates: int = 8) -> list[ToolSpec]: scored_specs = [] query_terms = set(query.lower().split()) for spec in self.tools.values(): if not spec.enabled: continue score = 0.0 desc_terms = set(spec.description.lower().split()) kw_terms = set(spec.keywords) score += len(query_terms & desc_terms) * 0.6 score += len(query_terms & kw_terms) * 1.2 scored_specs.append((score, spec)) scored_specs.sort(key=lambda x: x[0], reverse=True) return [spec for _, spec in scored_specs[:max_candidates]]路由策略需要注意三个细节:
一是关键词表的维护比想象中重要。工具描述是给模型看的,关键词表才是给路由用的。关键词表应该包含业务侧的自然表达方式,比如“查物流”对应“物流、快递、到货、配送、运输”。
二是把候选数控制在五到八个。太少容易漏选,太多又等于没过滤。经过多轮测试,八个工具描述加上参数 Schema,对模型来说是性价比最高的信息量。
三是设置“无匹配”的下坠路径。如果所有工具的匹配得分都很低,不要硬选,直接把完整工具列表交给模型,并提示“候选工具较少,建议从列表中选择或向用户确认”。这个兜底逻辑避免了模型因为选错工具而连环出错。
3.3 任务状态机:让模型记住“走到哪了”
任务状态机解决了长流程中目标漂移的问题。每个任务在初始化时都会生成一个状态对象:
@dataclass class TaskState: task_id: str goal: str # 最终目标 subtasks: list[str] # 规划出来的子任务 completed_subtasks: list[str] # 已完成的子任务 current_step: str # 当前执行步骤 last_tool_call: dict | None # 最近一次工具调用记录 retry_count: int = 0 status: str = "running" # running / blocked / done / failed def progress_brief(self) -> str: return ( f"目标: {self.goal}\n" f"子任务: {len(self.completed_subtasks)}/{len(self.subtasks)}\n" f"当前步骤: {self.current_step}\n" f"最近工具: {self.last_tool_call}" )每次模型生成行动后,Agent-Reach 会先执行工具,再把结果写入状态机,然后把状态机摘要注入下一轮的上下文。这个摘要不需要很长,两三行就可以:
目标: 生成一份本周订单异常分析报告 子任务: 3/4 已完成,正在执行步骤: 汇总退款原因 最近工具: get_refund_reasons 返回 45 条记录,正常别小看这段摘要。我在对比测试中发现,没有状态摘要时,模型在第八轮之后的很多操作都开始偏离主目标,比如突然开始分析无关指标、重复执行已经完成的步骤。加上状态摘要后,这类偏离大幅减少。模型有了一个“当前坐标”,就不会凭感觉乱跑。
至于状态持久化,我直接把 TaskState 序列化成 JSON 存入 SQLite。为什么要落地到数据库?因为中间如果服务重启或者任务被暂停,恢复时还能继续跑,不用从头再来。这种高频状态写入的场景,SQLite 完全足够,不需要上 Redis。
3.4 恢复策略:模型出错时,系统不跟着摆烂
恢复策略器负责拦截工具调用异常。工具调用异常大致分成四类,处理方式完全不同:
| 异常类型 | 典型表现 | 处理策略 |
|---|---|---|
| 可重试 | 超时、网络抖动、上游返回 5xx | 指数退避重试,最多三次 |
| 参数缺失 | 工具必需的参数没有传全 | 查记忆、查历史对话,没有就向用户提问 |
| 业务异常 | 订单不存在、余额不足、权限不足 | 终止当前分支,重新规划或者转人工 |
| 模型幻觉 | 调用了不存在的工具名、参数格式错误 | 回退到路由候选列表,重新构造工具调用 |
这里有一个很多教程不会提到的坑:模型在工具返回异常之后,经常选择“强行完成流程”而不是“处理异常”。比如查订单失败,它会在报告里写“订单可能不存在,建议用户核对订单号”,而不是重新查一次或者直接请用户提供正确订单号。Agent-Reach 的办法是在恢复策略器里增加一个中断规则:任何工具返回异常时,模型必须优先执行异常处理分支,禁止继续生成后续内容。
量化一点说,这个规则把我在客服场景下的异常任务完成率从 41% 提到了 67%。模型不脑补,不糊弄,异常就是异常,该重试就重试,该问用户就问用户。
4. 实操:从 0 到 1 搭一个 Agent-Reach 实例
4.1 工程结构:小而清晰的布局
为了方便调试,我按模块拆分工程,整体结构大概长这样:
agent-reach/ ├── agent_reach/ │ ├── __init__.py │ ├── core.py # 主循环逻辑 │ ├── memory.py # 记忆服务 │ ├── router.py # 工具路由 │ ├── state.py # 任务状态机 │ ├── recovery.py # 异常恢复 │ └── llm.py # LLM 调用封装 ├── tools/ │ ├── order.py │ ├── logistics.py │ └── analysis.py ├── config.yaml ├── main.py └── data/ └── task_store.sqlite这种结构的好处是:每个模块的职责很单纯,测试时可以单独跑记忆服务和路由服务的单测,不用把模型调用串起来。等你部署到线上,如果某个环节出问题,也能快速定位到具体模块。
4.2 配置文件:把不常变的参数外置
Agent-Reach 的配置用 YAML 管理。核心配置包括模型端点、工具目录、存储路径、候选工具数量等。
# config.yaml llm: provider: openai model: gpt-4o-mini temperature: 0.1 max_tokens: 2048 memory: max_working_items: 12 use_vector: false router: max_candidates: 8 fallback_to_all: true state: db_path: data/task_store.sqlite recovery: max_retry: 3 retry_base_delay: 1.0 require_question_on_missing_param: true有几个参数值得展开说一下。temperature我设定在 0.1,Agent 场景和创意写作不同,需要的是确定性和可控性,温度太高会让工具调用格式不稳定。max_working_items是 12,这个数字不是拍脑袋定的,我用 6/8/12/16 四档做过对比,12 左右任务成功率最高,再往上会因为上下文无关信息过多而下降。fallback_to_all表示路由候选不足时回退到完整工具列表,这是一个安全阀。
4.3 实际运行:让 Agent-Reach 处理一个真实任务
我拿客服工单场景做演示。假设用户发来一条消息:
我上周三买的手机壳到现在还没收到,能帮我查一下物流吗?订单号是 20250619001。
Agent-Reach 的主循环会这样运转:
第一步,意图解析。路由决策器根据关键词“查物流、订单号、还没到”,从工具注册表里选出候选工具:query_logistics、query_order、query_product。
第二步,任务状态机创建TaskState,目标是“查询订单物流状态并回复用户”,子任务列表生成。
第三步,模型收到精简后的上下文,里面包含工具描述和状态摘要,决定先调用query_order核对订单信息。工具返回订单正常,但物流单号尚未绑定。
第四步,状态机更新:子任务 1 完成,当前步骤变为查询物流。模型调用query_logistics,但是参数tracking_no缺失。
第五步,恢复策略器介入。它先到记忆服务里检索——没有这个 tracking_no,然后生成一个要求澄清的消息反馈给模型,模型转而向用户提问:“您的订单还未生成物流单号,请确认是否已发货,或者提供快递单号。”
整个流程没有一步是模型“猜”出来的。每个决定都有上下文依据,每一步都有状态记录。这就是 Agent-Reach 想达到的效果。
从运行日志上看,你会看到类似这样的记录:
[state] task 20250619001 created, goal=query_logistics [router] candidates: query_logistics, query_order, query_product [llm] tool_call -> query_order(params={"order_id": "20250619001"}) [tool] query_order returned: order exists, tracking_no missing [state] completed_subtasks=[order_check], current_step=query_logistics [recovery] missing param tracking_no, memory lookup failed -> ask user [llm] response -> 请提供快递单号很直观,对不对?每一行日志你都能看懂,不像以前调试 Agent 时只能看模型的一堆输出猜测它想干嘛。
5. 评测:Agent-Reach 到底提升了多少
5.1 我的评测指标:不看单点指标,看任务完成率
Agent 类项目不好评测,因为你很难用单个指标说清楚好坏。我自己的评测体系包含四个指标:
- 任务成功率:预设任务中,最终干净完成的比例。所谓“干净完成”,是指结果正确、没有脑补、没有多余的工具调用。
- 平均工具调用次数:完成一个任务平均需要调用多少次工具。次数越多,说明模型走弯路的概率越高。
- 平均上下文消耗:整个任务跑完实际消耗的 token 数。这个指标直接关系到成本。
- 异常处理率:任务过程中出现工具异常后,能被正确处理而不是糊弄过去的比例。
5.2 测试集与对比结果
我构造了一个混合测试集,包含 60 个客服场景任务、30 个数据分析任务、15 个多工具串联任务。对比的对象很简单:一组是不加任何工程增强的裸 ReAct 提示,一组是叠加了基础记忆的 Agent,还有一组是完整的 Agent-Reach。
| 方案 | 任务成功率 | 平均工具调用次数 | 平均上下文消耗 | 异常处理率 |
|---|---|---|---|---|
| 裸 ReAct | 51.2% | 6.8 次 | 较高 | 38.7% |
| 基础记忆 Agent | 63.4% | 5.1 次 | 中 | 52.3% |
| Agent-Reach(完整) | 78.7% | 3.6 次 | 低 | 67.5% |
数据说得挺清楚。工具数量越多、任务链条越长,Agent-Reach 的领先幅度越大。在只有三个工具的场景,裸 ReAct 和 Agent-Reach 差距其实不大;到了三十个以上工具的场景,Agent-Reach 的成功率优势能超过二十个百分点。
这里我要强调一句:这个测试集是我自己组装的,不是公开 benchmark,所以你看到的数字绝对值意义有限。但横向对比的差距方向很明确——只要工具数量上来了,工程层面对可达性的投入,回报远大于换模型。
5.3 一个让我印象深刻的边界案例
有一个测试任务是“查询上周退款申请的审核进度,并整理成表格发给运营”。这个任务涉及四个工具:查退款单、查审核员、查审核记录、生成表格。
裸 ReAct 在这个任务上连续三次失败。前两次是模型把“审核员”和“审核记录”两个工具搞混了,第三次是执行到一半的时候,模型突然开始输出“本周退款原因分析”,完全跑偏。
Agent-Reach 在这个任务上一次性通过。关键差异不是模型变聪明了,而是路由决策器根据关键词“审核进度”准确候选了审核相关工具,状态机在模型跑偏之前已经把“当前步骤:查询审核记录”注入到上下文里,模型收到提示后立刻回归。你看,模型不需要提示词教育它“你要记住目标”,你把目标放在它眼前就行。
6. 落地避坑指南:这些问题我帮你踩过了
6.1 常见问题速查表
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 模型反复调用同一个工具 | 工具返回结果没有有效写入状态机 | 工具结果要经过去重、摘要后再入工作记忆 |
| 模型编造工具参数 | 参数值未存在于上下文中,模型被迫猜测 | 开启恢复策略器的参数缺失澄清模式 |
| 路由召回不准确 | 工具关键词表覆盖不够 | 定期从历史错误调用中挖掘新词并补充关键词 |
| 任务中途重启后状态丢失 | TaskState 未持久化 | 启用 SQLite 状态存储,并在启动时恢复未完成任务 |
| prompt 长度仍然超标 | 候选工具数太多或工具描述过大 | 压缩工具描述模板,限制候选数 |
| 模型拿到异常后继续生成 | 没有中断规则 | 在恢复策略器中加入“工具异常时必须先处理异常”的硬规则 |
6.2 我在长期迭代后总结的三条深层经验
第一条,工具不是越多越好,工具描述也不是越详细越好。每个工具的 description 控制在三行以内,参数 Schema 只保留必填项和关键可选参数。多余说明既浪费 token,又会在路由匹配时引入噪音。
第二条,不要让所谓“智能”替代系统约束。很多 Agent 系统设计者喜欢把所有行为逻辑都交给模型理解,认为模型会自己处理好边界。但事实是,给定足够的自由度,模型一定会以某种意想不到的方式“创新”。Agent-Reach 的哲学是:能为模型画好边界的地方,一律用代码画好;模型能自己发挥的空间,才交回给模型。
第三条,所有工程优化都要回到底层指标上验证。我做 Agent-Reach 的过程中,有一段时间沉迷于调整记忆摘要的各种花哨表达,比如给摘要添加情绪标签、段落结构优化,看起来挺有用,但任务成功率的提升不到两个百分点。后来我把精力放到工具路由和异常恢复上,效果立刻翻倍。少做自我感动式的优化,多做能被数据验证的改动。
6.3 如果你要自己实现,有个捷径可以走
不需要完全复刻 Agent-Reach 的全部模块。如果你现在被 Agent 工具调用问题困扰,我建议先做最小改造包:只加两层——工具路由预筛选和任务状态注入。这两层的代码量加起来不到三百行,对现有系统侵入性很小,但能给整体成功率带来肉眼可见的提升。
具体做法就是:把你现在塞在 system prompt 里的二十个工具描述挪到注册表里,写一个简单的关键词匹配路由;再把你现有任务循环里维护的进度变量,每次循环末尾格式化成一两行摘要,拼进下一轮 prompt。做完了这两个改动,再跑一遍你的评测集,你会回来感谢我的。
个人经验里还有一个始终受用的判断标准:任何一个 Agent 框架,只要它让模型的决策依据变得可见、可追踪、可回滚,就比那些依赖“模型自觉”的设计要可靠得多。Agent-Reach 不解决所有问题,但它至少让我在无数个睡不着觉的深夜,能够从日志里一眼看出系统到底在想什么。