1. 为什么叫“Reach”:先从智能体编排的实际痛点说起
去年有段时间,团队内部一直在讨论一个问题:大模型的能力已经这么强了,写 prompt 也能让 ChatGPT 帮我们查资料、写周报、做总结,但为什么一到“让 AI 自动完成一件跨系统的事情”就卡壳?
比如最简单的场景:让 AI 每天早上帮运营盯一下数据大屏,如果转化率异常就发一条告警到钉钉群,并在当晚生成一份简短的分析说明。听起来不复杂,真做起来却发现一连串麻烦——数据接口要鉴权、告警发送要走 webhook、分析说明需要关联前一天的活动数据、如果遇到数据缺失还得让它自己决定是重试还是跳过。传统机器人写法是一大堆 if-else 预设分支,写死了路径,换一个场景就又要改代码。
那段时间我们试过不少方案:直接给 LLM 塞工具函数定义、用 Function Calling 做单步调用、再往后是给模型配各种插件。它们都能解决“某一个环节的智能”,但解决不了“多个环节串起来还能自己兜底”的问题。真正缺的东西,我后来总结成一句话——Agent 够不够得着它该触达的系统与数据。模型再聪明,如果它对内摸不清任务上下文、对外伸不向真实 API,它就是一座孤岛。这个“够得着”的能力,就是我们内部项目取名 Agent-Reach 的由来。
Agent-Reach 的定位很简单:一个面向业务场景的智能体触达框架。它不负责把你变身成 Prompt 工程大师,也不负责帮你训模型,它只解决一件事——让 Agent 的每一步行动真正落到外部系统里,并且能够感知行动结果。这里的“外部系统”包括内部数据服务、第三方 API、数据库、消息通道,也包括需要人工介入的审批流。你可以把它理解成一个连接大脑与肢体的神经通路:大脑负责思考下一步做什么,Reach 负责让这一步执行得出去、反馈得回来。
适合谁看?如果你正在做 AI 应用开发,手头有 LLM 接口但不知道怎么稳定地编排工具调用;或者你已经做过类似 ChatBot 但发现它只能聊天、不能办事;又或者你在企业里负责引入 AI 能力,需要一套可审计、可回滚、可观测的 Agent 执行方案——这篇文章的内容就是围绕这些场景展开的。
2. Agent-Reach 的三层核心架构:连接层、推理层、记忆层怎么分工
整个框架我只设计了三个核心层,刻意没有做得更复杂。复杂的东西听起来厉害,但在实际维护时会变成灾难。三层的职责分开,每一层只做自己该做的事,调试时就能快速定位问题。
2.1 连接层:把工具描述变成 Agent 能看懂的系统调用
连接层解决的是“Agent 如何触达外部能力”的问题。这一层维护一份工具注册表(Tool Registry),里面登记了所有可被 Agent 调用的能力。每个工具的描述信息遵循一套规范化的结构,包含四部分:工具名称、工具用途描述、输入参数 Schema、调用地址与鉴权方式。
tool_schema = { "name": "query_order_stats", "description": "查询指定日期范围内的订单统计指标,包括订单量、销售额、客单价、退款率等。", "parameters": { "type": "object", "properties": { "start_date": {"type": "string", "description": "起始日期,格式YYYY-MM-DD"}, "end_date": {"type": "string", "description": "结束日期,格式YYYY-MM-DD"}, "channel": {"type": "string", "enum": ["all", "mini_program", "app", "h5"], "default": "all"} }, "required": ["start_date", "end_date"] }, "endpoint": "http://internal-dashboard-api/v2/stats/order", "auth_method": "internal_token", "timeout_ms": 5000 }这里有个细节值得单独说:工具描述的正则语义空间。很多人把工具描述写得特别简陋,比如“查询订单统计”,模型在复杂场景里根本不知道什么时候该用这个工具、参数该传什么。我的经验是,描述里宁可多写一句“当用户询问销售额、订单量等经营指标时优先使用本工具”,也不要让模型自己猜。因为现代 LLM 的工具选择本质上是语义匹配,你给的信息越贴近真实使用场景,选错的概率就越低。
连接层还要负责一件事:统一返回结果。不管后端 API 返回的是 JSON、XML 还是纯文本,连接层都会把它们标准化成结构化的执行结果对象(ExecutionResult),其中包括状态码、耗时、数据内容、附加元信息。这样推理层在处理结果时就不用关心每个工具的差异了。
2.2 推理层:让规划不再是一次性的
推理层做的事情,是 Agent 的“大脑”。它负责接收用户请求,拆解任务,决定调用哪些工具、按什么顺序调用,并且根据工具的返回结果动态调整下一步计划。
早期做的时候,我的直觉是让 LLM 一次性生成一整套完整计划,然后按计划顺次执行。结果发现这个方案在真实场景里完全行不通。原因是计划赶不上变化——第一个工具返回的数据可能跟预期不一致,第三步前置条件没满足,某个工具直接报错了,这些情况下原计划半路就得改。
所以 Agent-Reach 的推理层采用动态规划+逐步执行的方式,核心是一个执行循环:
循环开始 1. 将用户目标、当前任务上下文、此前工具调用记录整理为 prompt 2. 调用 LLM 进行推理,模型决定下一步动作(调用某个工具 / 问用户澄清 / 直接生成最终回答) 3. 如果是调用工具:走连接层执行;拿到结果后附加到上下文,回到步骤 1 4. 如果是生成回答:把最终结果返回给用户,循环结束这个循环看起来简单,但它有一个隐含的优势——每一步决策都能看到前一步真实执行的结果,而不是依赖模型脑补。比如模型第一次选择了query_order_stats,结果发现返回里 channel 字段是空值,它可以根据真实结果决定是改用all重新查询,还是直接告诉用户数据源异常。这就是“够得着”的真正含义:不仅能碰到系统,还能根据碰到的反馈调整自己。
2.3 记忆层:短期会话记忆与长期事实记忆的分离
推理层解决“当前这一步怎么做”,记忆层解决“做这件事需要的背景信息从哪来”。我遇到过很多一次性的智能体 Demo 做得很好、多轮对话就露馅的情况,根因就是没有设计记忆层。
Agent-Reach 的记忆层分两块:
短期会话记忆,保存当前任务会话里的所有中间状态。包括用户原始描述、工具调用记录、关键计算结果、已经放弃的备选方案。它存在的意义是让模型在每一步推理时都能“想起”之前发生过什么。实际存储时我用的是 JSON 形式的上下文累积,每一轮执行后把关键信息追加进去。要注意控制短期记忆的体量——超过上限就把早期信息压缩成摘要,否则 token 消耗会失控。
长期事实记忆,保存与具体会话无关的稳定信息。比如团队的业务术语表、常用数据口径、用户偏好、历史任务中总结出的规则。长期记忆的作用是让 Agent 在新任务里不必每次从零开始理解背景。这一块可以用向量数据库存 embedding,需要时做相似度检索拉回上下文。
记忆层设计里最重要的原则是——不把模型当作数据库。模型天生适合做推理,不适合做精确记忆。凡是需要精确读取的信息(比如“上个季度的毛利率是多少”),务必走工具查数,而不是靠模型回忆。凡是只需要大概印象的信息(比如“用户倾向于关注哪些指标”),可以用长期记忆模糊召回。这条原则帮我避免了很多“一本正经胡说八道”的尴尬。
3. 一个最小可用系统的落地过程:从任务规划到服务接入
架构说完了,来点实在的。我带着团队用一个周末搭了一个最小可用版本,端到端跑通了“让 Agent 代替运营盯数据并发告警”的真实场景。下面是完整过程,每一步都标了动机和踩坑点。
3.1 第一步:定义工具清单
我们先梳理了执行这个任务需要的全部外部能力:
| 工具名 | 用途 | 返回内容 |
|---|---|---|
| query_order_stats | 查询订单统计指标 | 订单量、销售额、客单价、退款率 |
| query_campaign_list | 查询进行中的活动列表 | 活动ID、名称、时间、预算 |
| send_dingtalk_alert | 发送钉钉告警消息 | 发送结果、消息ID |
| fetch_previous_report | 读取昨天的分析报告 | 报告文本 |
定义工具清单的时候,有一个比较反直觉的点:工具数量不是越多越好。刚开始我们把十几个内部接口全部注册进去,结果模型经常选错工具——因为它面对太多选项时,选择的区分度降低了。后来我们把工具收敛到与当前任务强相关的几个,正确率立刻上升。所以我的建议是:先按任务维度配置最小工具集,跑通了再逐步增加。
每个工具的入参 Schema 都需要工程师手工确认。这里有个技巧——给参数加合理的枚举值和默认值。比如 channel 参数如果不加枚举,模型可能会传"小程序",而后端只认识"mini_program",一次常见的字符串不一致就引发了串联失败。加了枚举后模型只能从给定选项里选,出错概率大幅降低。
3.2 第二步:搭建执行循环
执行循环是整个系统的心脏。最核心的一段代码如下:
async def agent_reach_loop(user_goal: str, context: AgentContext, max_steps: int = 8): """Agent-Reach 的核心执行循环""" # 初始化短期会话记忆和工具执行历史 context.short_term_memory.add_event("user_goal", user_goal) step_count = 0 while step_count < max_steps: # 1. 组装推理 prompt messages = build_reasoning_messages( goal=user_goal, memory=context.short_term_memory.dump(), long_term_facts=context.long_term_memory.retrieve(user_goal) ) # 2. 调用 LLM 推理层,决定下一步动作 action = await llm_reason(messages) # 3. 根据决策类型分发处理 if action.type == "call_tool": tool = tool_registry.get(action.tool_name) if not tool: context.short_term_memory.add_event("system_error", f"tool not found: {action.tool_name}") continue result = await tool.execute(action.arguments) context.short_term_memory.add_event("tool_result", { "tool": tool.name, "arguments": action.arguments, "result": result.to_dict() }) elif action.type == "ask_user": return AgentResponse(requires_clarification=True, message=action.message) elif action.type == "final_answer": return AgentResponse(requires_clarification=False, result=action.message) else: context.short_term_memory.add_event("system_error", f"unknown action type: {action.type}") step_count += 1 # 达到最大步数仍未收敛,强制终止防止失控 return AgentResponse(requires_clarification=True, message="已达到最大执行步数,请确认流程是否合理")有两处值得解释。
一是build_reasoning_messages。它不是简单地把历史记录堆进 prompt,而是做了一层“上下文压缩”:把工具返回的大段 JSON 提取成关键摘要,避免 token 爆炸。比如查询订单统计返回了 100 行明细,在执行历史里只留下汇总值、异常值和抽样样例即可。
二是提前设计好的max_steps安全上限。真实场景里模型偶尔会陷入循环——反复调用同一个工具、得到同样的结果、再次调用,像掉进死循环。限制最大步数,是为了在失控时优雅退出,而不是无限消耗 API 费用。这个上限的值要根据任务复杂度调,简单任务 6 步以内,复杂任务可以给到 15 步,没有固定答案。
3.3 第三步:让 Agent 处理真实查询
最小系统跑通后,我们做了几个真实场景测试。最典型的输入是:
帮我看看昨天整体订单情况,如果退款率超过 5% 就发个钉钉告警,顺便发一条昨天活动的数据情况。
如果你以为 Agent 会直接调用query_order_stats,那就太小看这个任务的复杂度了。实际执行链路是这样的:
- 模型先识别出“昨天”需要日期计算,于是隐含地需要获取当前日期;
- 然后调用
query_order_stats,传入昨天日期区间,channel 默认为all; - 拿到结果后,模型需要判断退款率是否超过 5%——这一步是数值判断,比较大小,模型做得很稳;
- 因为用户提到了“昨天活动”,模型调用
query_campaign_list查活动列表,再关联查询相关活动的统计; - 最后把综合结果格式化,输出报告文本。如果触发告警条件,则调用
send_dingtalk_alert。
这个过程看起来很顺,但在第 4 步上我们踩了一个有趣的坑:模型先调了query_campaign_list拿到了活动名称,再调用query_order_stats时把活动名写进了 channel 参数,而后端 channel 只接受枚举值,这一下就报参数错误。后来我们修改了query_order_stats的描述,明确注明“如需查询某活动的数据,请使用 activity_id 参数,channel 仅用于渠道维度”,同时还加了入参校验,在连接层直接拦掉非法参数并返回错误信息,让模型自己纠正。这说明给工具的输入做硬校验,其实也是给模型当陪练。
3.4 为什么这一步的工程质量决定上限
我见过很多团队兴致勃勃搭完第一版 Demo,就急着去接更多工具了。我要泼一盆冷水——Demo 能跑和系统稳定是两码事,而这个阶段恰恰决定你能走多远。
第一个要关注的是可观测性。Agent 的推理过程是黑盒,你需要把每一步推理、每一个工具调用、每一次失败重试都记录成日志。我们在执行循环里埋了结构化日志,包含步数、动作类型、工具名、入参、出参、耗时、模型响应。出问题时可以像回放电影一样把整个链路拉出来看。没有这套日志系统,后面排查任何问题都是大海捞针。
第二个是超时与重试策略。一个工具调用可能因为网络抖动、上游服务拥堵而超时。需要区分两种失败:一种是立即失败(比如参数错误),重试没有意义;一种是暂时失败(比如上游 503),重试可能成功。连接层针对不同错误码配置了不同的重试次数和退避策略。这里我的经验是:不要盲目重试超过两次。试过就知道,第三次重试成功率提升极其有限,反而会让系统响应时间飙升。
第三个是 Prompt 的稳定性。LLM 的输出不是确定的,同样的输入可能导致不同的推理路径。我们后来引人了“固定思维链”技巧:在系统提示词里写清楚每一步应该先做什么、遇到异常怎么处理。这本质上是把工程经验注入到模型的思考过程里,让模型少走弯路。实测效果很明显——固定思维链之后,工具选择准确率提升了近二十个百分点。
4. 实战踩坑三连:上下文丢失、超时误判、状态不同步
把最小系统真跑起来之后,问题像雨后春笋一样冒出来。这一节写三个我们花时间最长才定位的坑,尤其是排查链路,比最终答案更有价值。
4.1 多轮调用之后 Agent 突然“失忆”的排查链路
现象:Agent 执行到第 4、5 步时突然像一个“金鱼记忆”的人,明明第 2 步已经查过了订单数据,它却再次调用同样的工具查同样的数据;或者用户在第一轮明确说了“只看小程序渠道”,到后面几步它突然忘了,扩大到了全部渠道。
第一次遇到的时候,我第一反应是 Prompt 里的历史信息被截断了。顺着这个方向查,发现代码里确实做了上下文压缩——build_reasoning_messages里把工具返回的长 JSON 转成了摘要。摘要里保存了“订单量、销售额、退款率”等汇总指标,但丢掉了“channel: mini_program”这个筛选条件。看似无关紧要的细节被压缩掉了,模型自然就忘了。
排查链路总结如下:
- 先确认是不是截断问题:看日志里实际发送给 LLM 的 prompt 上下文长度,与上下文窗口上限比对;
- 再看压缩逻辑:把压缩前后的文本拿来对照,看哪些信息丢了;
- 最后发现是关键约束信息被压缩策略误删了。
修复方案:压缩策略改为“保留所有显式约束条件,压缩数据细节”。也就是说,用户说过的“只看小程序”“只看昨天”这类条件必须原样保留,而工具返回的具体数值列表可以适当省略。这条原则后来写进了代码注释,成了团队的铁律。
还有个隐藏更深的坑:短期记忆里的事件顺序。我们最初用 append-only 方式追加事件,但多轮工具调用后模型对时间顺序会产生混乱。后来给每个事件加上了 step_id,在组装 prompt 时按 step_id 排序,彻底解决了这个顺序错乱问题。
4.2 工具调用超时被误判为“系统不可用”
现象:用户让 Agent 查询一个跨月订单汇总,这个查询在后端数据库里跑了大约 8 秒,超过了我们设定的 5 秒超时阈值。连接层抛出了 TimeoutError,模型看到错误后以为系统完全不可用,于是回答“抱歉,当前系统不可用,请稍后再试”。而实际上数据查询只是慢,不是挂了。
这个坑的本质是错误信息语义不够精细。timeout和unavailable在模型看来差异不够明显,都容易触发“无能为力”的回应。
修复分两层。第一层是从执行结果里区分超时类型和具体错误码,把timeout包装成“查询超时,但服务状态正常,可考虑缩小查询范围或稍后重试”;第二层是把之前提到的 5 秒超时阈值提高到了 15 秒,并增加了“工具执行进度提示”的机制——如果工具执行超过 3 秒,就让模型向用户输出一条中间反馈,比如“我正在查询上个月的订单数据,数据量较大,预计需要几秒钟”。这不只是体验优化,也是在逼着模型理解“慢≠错”。
4.3 多 Agent 并行时的状态边界
后来我们把系统从单 Agent 升级到多 Agent 协作——一个负责查询数据,一个负责撰写报告,一个负责发送消息。问题来了:三个 Agent 共享同一个短期记忆存储,导致它们互相覆盖状态。负责写报告的 Agent 读到的“订单量”可能是负责查询的 Agent 上一个任务遗留的数据。
这个问题的根源是没有给每个 Agent 划清状态边界。我们用session_id + agent_role做记忆隔离,每个 Agent 只读写自己的记忆空间;需要传递信息时,必须通过显式的“消息总线”传递,而不是共享存储。
在实际运行中,这个方案还有一个附带的好处:可追踪性大大增强。哪个 Agent 基于什么数据产生了什么结论,全部有迹可循。做企业级应用时,这一点对审计要求特别重要。
5. 从“能跑”到“敢用”:评估、护栏和扩展思路
系统稳定跑起来,离真正敢在企业环境里大规模使用还差几步。这一步是我建议所有做 Agent 应用的人都要认真对待的。
5.1 离线评测集是“敢用”的入场券
大模型应用的评测不能靠感觉。我们准备了一个小型但真实的评测集,包含三类样本:
- 典型场景:常见的查询、告警、报告生成任务,占比约 60%;
- 边界场景:数据缺失、参数非法、工具超时、上下文超长等,占比约 30%;
- 对抗场景:用户提问模糊、多条件交叉、前后矛盾、故意诱导调用无关工具等,占比约 10%。
每次改动推理层代码或调整 Prompt,就整体跑一遍评测集,看成功率变化。这个习惯帮我拦截了很多“看起来没问题,一上线就翻车”的改动。
评估指标方面,不要只看最终成功率,还要看过程质量。比如工具调用次数是否合理(过度调用说明规划能力差)、失败后模型是否能自我纠正(有纠错能力比一次成功更有价值)、执行耗时是否在可接受范围。我们把这三个维度的得分合成一个“执行健康度”,作为每次迭代的对比基准。
5.2 护栏设计:权限、审计、人工确认
Agent 能执行真实操作之后,安全性的重要程度立刻超出功能本身。在 Agent-Reach 里我们加了三条护栏,缺一不可。
第一是权限控制。连接层里每个工具都绑定了调用权限,不同用户角色只能调用被授权的工具。不能让普通运营的 Agent 拥有删库的能力。这里的实现不能只靠代码层过滤,还要在外部 API 侧做校验,防止绕过框架直连接口。
第二是审计日志。Agent 的每一步执行都要写入独立的审计日志系统,包括发起人、目标、工具、参数、结果、耗时。这不仅仅是合规需求,也是回溯问题时最可靠的数据源。
第三是高风险操作的人工确认。我们将工具按风险等级分类:只读查询类(低风险)、写入类(中等风险,如发送通知)、删除/变更类(高风险,如取消订单)。低风险自动执行,中高风险在执行前必须暂停,等待用户在客户端点击确认。实现上就是在执行循环里增加一个requires_confirmation的状态,触发之后暂停循环并推送确认请求。
5.3 扩展思路:向多智能体的协作网络演进
单 Agent 是一个能干活的手,多 Agent 才是一个能扛事的团队。Agent-Reach 当前版本以单 Agent + 固定的工具集为主,但架构设计上已经为多 Agent 协作留了接口。
一个值得尝试的方向是“规划器-执行器”模式:规划器 Agent 负责拆分任务和调度,执行器 Agent 负责具体工具调用,两边的记忆层物理隔离,通过消息总线传递任务书和执行结果。这样可以做到扩展新执行器时不影响规划器的逻辑,也能针对不同任务类型动态切换执行器组合。
另一个方向是让 Agent 之间能“互相追问”。比如报告撰写 Agent 发现查询结果缺少对比基准,它可以主动向数据查询 Agent 发出补充请求,而不是在信息不完整的情况下硬着头皮写报告。这需要消息总线上支持请求-响应式的语义。
如果要把 Agent-Reach 推向生产环境,前置条件和环境准备是绕不开的功课。框架部署需要一台独立的节点机,建议配置 4 核 8G 以上,跑 LLM 推理可以走内部部署的模型服务,也可以接托管的模型能力,关键在于 API 访问的稳定性和权限隔离。数据库层面推荐 PostgreSQL,用于存储任务记录、审计日志和长期记忆向量;向量检索组件直接在数据库里扩展即可,初期没有必要单独引入一套生态。工具接入涉及内部 API 网关时,提前在网关侧开通独立的调用凭据会省去很多联调后期的效率拉扯。
根据我个人经验,Agent-Reach 最合适的切入场景不是“造一个全知全能的超级助手”,而是“给现有业务里的人手边添一个能办事的副手”。把一条具体业务流程理清楚,把工具打磨好,把护栏立起来,让它在一两个场景里老老实实干活,比追求大而全靠谱得多。
最后再分享一个小技巧:实测在 Agent 的 prompt 里加入固定思维链之后,工具挑选准确率和执行稳定性提升非常明显。你可以试着在系统提示词中写一段类似“先确认用户明确意图,再判断是否需要工具介入;调用工具后先验证返回结果,再决定下一步行动”的引导语——这个细节能让模型减少很多无效推理。后续如果你也准备往多 Agent 的方向扩展,建议提前把每个 Agent 的职责边界定义清楚,接口层做好参数契约,一旦跑起来还想大改,成本就比现在高得多了。