
1. 我为什么动手做 Agent-Reach 这个项目先说说背景。这两年做大模型应用大家应该都有同一个感受大模型本身已经没什么神秘感了API 一调能写文案、能总结文档、能聊天但真要让它在业务里干点实事就卡住了。卡在哪卡在“手不够长”。我团队当时接了一个内部工单系统自动化的需求让智能体根据用户提交的工单内容自动去查询订单状态、核对库存、生成处理建议再回填到工单里。目标听着并不复杂真正做起来才发现大模型根本不知道订单数据在哪、库存接口长什么样、工单回填走什么协议。它就像一个学识渊博但手脚被绑住的顾问什么都懂什么都摸不着。这其实就是 Agent 落地最普遍的那个坎Agent 的规划能力再强如果触达不到外部工具和数据源一切推理都停留在纸上。我做的这个项目叫 Agent-Reach核心就一句话把大模型从“会说”变成“能办”让智能体真正触达业务系统中的工具、接口和数据。整个项目围绕“触达层”展开包括工具注册与管理、调用路由、参数校验、上下文回灌、异常隔离、安全边界这六块最终跑通了一个可复用的 Agent 接入框架。做这个项目之前我也试过两种现成方案。一种是纯手写 workflowif else 写死结果业务一变场景就崩另一种直接上重框架依赖太多、概念太多团队学习成本极高而且 debug 起来像走迷宫。Agent-Reach 的定位是“中间路线”不重新发明轮子但把 Agent 的能力边界和外部系统的对接细节做扎实让大模型在可控的范围内自由选择工具而不是把所有逻辑用代码写死。这篇文章适合谁看如果你正在做 LLM 应用、智能客服、自动化流程编排或者你只是想搞清楚“Agent 到底怎么落地”这件事这篇内容应该能给你省不少时间。我会把设计思路、核心代码、踩坑实录全部摊开讲包括那些论文和官方文档里不会写的事。2. 触达层设计把大模型和真实系统之间的缝隙填平2.1 工具注册别把接口描述当成说明书Agent 调用工具第一件事是让模型“知道有什么工具可用”。这一步看似简单实际是整条链路里最影响效果的地方。模型不是人它不会翻你几十页的 API 文档它只能看到你喂给它的工具描述文本。这个描述写得好不好直接决定模型能不能在正确的时候选中正确的工具。我当时踩的第一个坑就是直接把接口文档里的参数说明复制过来当工具描述。结果模型经常把参数填错或者完全忽略某些可选参数。后来我把工具描述当成“给一个聪明但完全不了解你系统的实习生写的操作手册”来写。每一步、每个参数的取值范围、每个字段的业务含义都要写清楚。我整理了一个工具描述模板后续所有工具接入都按这个来{ name: query_order_status, description: 根据订单号查询订单的实时状态。订单号格式为ORD开头的12位字符串。调用前必须先确认订单号格式正确。返回结果为JSON包含status字段取值范围为[PENDING, PAID, SHIPPED, COMPLETED, CANCELLED]。如果订单不存在返回error字段。, parameters: { type: object, properties: { order_id: { type: string, description: 订单号必须为ORD开头的12位字符串 } }, required: [order_id] } }注意 description 里我刻意写了“调用前必须先确认订单号格式正确”这句话。这不是废话这是在告诉模型如果用户给的订单号不像是合法的你要先让用户确认而不是拿一串错误格式去请求接口。模型读了这句话之后出现“把聊天文本里提取的乱码当订单号直接调用”的情况少了很多。另外工具数量也要控制。GPT-4 这类模型单次能感知的工具数量是有限的我实测超过 15 个工具之后模型选错工具的概率会明显上升。不是它“笨”而是提示词里的工具列表太长注意力被稀释了。我的做法是做两层路由外层先让模型根据用户意图做粗分类再传入对应分类下的细粒度工具列表。这比把所有工具一股脑塞给模型要稳得多。2.2 工具调用链路模型、执行器、结果回灌三者怎么协作Agent 调工具不是一个“模型发出请求-拿到结果”这么简单的事。实操中我把调用链路拆成了四个环节意图识别、工具选择、参数生成与校验、结果解析与回灌。每一步都很容易出幺蛾子我逐一说说。意图识别环节模型要判断“当前这轮对话用户到底想让我干什么”。这个阶段不需要模型调用工具只需要输出一个计划。我用了 ReAct 模式的简化版让模型先输出“思考过程”再决定动作。这样做的附加好处是当调用出错时能从思考过程里看出模型是为什么选错了工具方便后续修正。工具选择环节模型输出一个结构化的 JSON指定要调用的工具名和参数。我在这里做了严格的数据校验用的是 JSON Schema。这个校验必不可少模型生成的参数经常会出现类型不对、字段拼写错误、超出枚举范围等问题。不校验直接发给下游系统轻则报错重则污染数据。结果解析与回灌是最容易被忽视的一环。工具返回的数据往往是给机器看的字段名缩写、嵌套结构、状态码直接扔回给大模型它会读得云里雾里。我单独写了一个结果清洗层把原始返回转成“给模型看的白话文摘要”。举个例子# 原始返回 {order_id: ORD20240115001, st: SHP, est_arr: 2024-01-20} # 清洗后回灌给模型 订单 ORD20240115001 已于2024年1月15日发货预计送达日期为2024年1月20日。当前状态为运输中。这一步的效果立竿见影。做这个清洗之前模型的回答经常出现“接口返回了状态字段具体含义需要进一步确认”这种废话做了之后模型直接给用户输出有信息量的答复体验完全不一样。2.3 状态记忆多轮对话里触达能力怎么保持连续性Agent 和单轮问答最大的区别在于它要做的是“多步骤任务”。用户说“帮我查一下最近买的东西到哪了”模型查完订单列表还要查物流查完物流还要根据物流状态决定是否推送售后提示。每一步的结果都是下一步的输入如果状态管理做不好整条链路就断了。我采用的方式是围绕“任务上下文”做快照。每一轮工具调用之后把工具返回的清洗结果、模型对结果的解读、下一步计划都写进一个结构化的上下文对象里。这个对象就像一个流水单记录了这个任务从开始到现在的完整脉络。class TaskContext: def __init__(self, session_id): self.session_id session_id self.tool_results [] # 每次工具调用的原始结果和清洗结果 self.model_plans [] # 模型每轮的思考输出 self.final_answer None # 最终回给用户的内容 def append_result(self, tool_name, raw, cleaned): self.tool_results.append({ tool: tool_name, raw: raw, cleaned: cleaned, ts: datetime.now().isoformat() })模型每次继续推理时我都会把这个上下文里最近几条关键信息重新拼接回提示词。这里有个参数要控制好不是所有历史记录都适合回流给模型。我原来把全部工具结果都塞回去很快就把上下文窗口撑爆了。后来做了摘要压缩对于超过三轮之前的信息让模型先做一次总结把总结存下来原始记录只留最近两轮。这部分的体验总结是记忆管理不是“越多越好”而是“该留的留、该压缩的压缩、该丢的丢”。把管理记忆的职责交给模型自己有风险因为模型有时候不知道自己该忘什么所以我在代码层面做了硬控制比如超过 N 轮的信息强制摘要化不允许原始结果继续驻留。3. 实操记录从零搭一个具备触达能力的 Agent关键节点全梳理3.1 技术选型为什么不用重型框架而是自己搭轻量核心动手初期我就面临一个选择是用 LangChain、AutoGen 这类现成框架还是自己写一套轻量的核心逻辑。我当时两条路都走了一小段最后选择了“骨架自定义、零件复用”的方式。不是说框架不好而是框架的抽象层次和更新速度都有风险。Agent 场景发展太快框架的 API 一个月变一次依赖链条又长出了问题很难判断是你写的代码有问题还是框架层面有问题。我自己搭核心逻辑能完全掌控每一步的行为调试的时候只需要看自己的代码。但这不代表所有东西都自己造。工具的 HTTP 调用、重试、限流这些基础能力我直接用现成的httpx和tenacity库不重复造轮子。大模型接口层面我封装了一个统一的LLMClient方便日后切换不同厂商的模型。整体结构是核心逻辑自己写保持可控周边能力调库保持效率。项目跑起来之后的目录结构大概是这样的agent_reach/ ├── core/ │ ├── agent.py # Agent 主循环 │ ├── planner.py # 任务规划器 │ ├── executor.py # 工具执行器 │ ├── context.py # 任务上下文管理器 │ └── validator.py # 参数校验器 ├── tools/ │ ├── registry.py # 工具注册中心 │ ├── order_tool.py # 订单查询工具 │ ├── inventory_tool.py # 库存查询工具 │ └── notify_tool.py # 消息通知工具 ├── prompts/ │ ├── system_prompt.py # 系统提示词 │ └── tool_descriptions.py # 工具描述汇总 └── main.py # 入口3.2 Agent 主循环控制 Agent 在“思考-行动-观察”之间循环Agent 的核心是一个循环。我参考了 ReAct 模式的思路但做了一些工程化改造。每一步循环模型会输出一个 JSON 格式的决策包含thought思考、action动作、action_input动作输入三个字段。如果没有需要调用的工具就输出final_answer最终回答。关键代码长这样def agent_loop(self, user_query: str) - str: context TaskContext(self.session_id) messages self.build_initial_messages(user_query, self.tool_schemas) for step in range(self.max_steps): response self.llm.chat(messages) decision self.parse_decision(response) if decision.get(final_answer): context.final_answer decision[final_answer] return decision[final_answer] if decision.get(action): tool_name decision[action] tool_args self.validator.validate( tool_name, decision.get(action_input, {}) ) raw_result self.executor.execute(tool_name, tool_args) cleaned_result self.cleaner.clean(tool_name, raw_result) context.append_result(tool_name, raw_result, cleaned_result) messages self.build_continuation_messages( messages, context, tool_name, cleaned_result ) return self.build_fallback_answer(context)有几个工程细节我要特别提一下。max_steps必须限制。我最初没设上限结果模型在一个死循环里来回调用同一组工具白白烧了几百万 token 之后才发现。现在默认上限是 5 步复杂的任务到第 4 步还没收敛就让模型基于已有信息给出阶段性回答。这个限制写在系统提示词里告诉模型让它在有限步骤内尽量一次性完成更多工作。全局容器。3.3 参数设计与调优温度、步数、超时、重试的取值依据Agent 应用和普通对话应用在参数上有几个不同点我逐个说下我调出来的值以及背后的依据。temperature 取 0 还是 0.2 我在 Agent 的决策环节把 temperature 设为 0。原因很简单选工具、填参数这种动作要的是稳定性和可复现性不是创造力。同一个任务跑两次如果一次选了 A 工具、一次选了 B 工具测试都没法做了。创意写作你让模型放飞工具调用你让它守规矩。如果模型在 temperature0 时仍然频繁出错那说明是提示词或工具描述的问题调温度只是掩盖病情。超时设置我给工具调用分了两个级别内部接口 5 秒超时外部依赖 10 秒超时。任何一个工具调用超时整个步骤记为失败把错误信息返回给模型让模型决定是重试还是换策略。这里有个细节超时设置不能太保守否则一些正常但较慢的批量查询会被误杀但也不能太长否则用户体验会很差。重试策略我只对“可重试的异常”做重试。网络超时、5xx 错误可以重试最多 3 次指数退避0.5 秒起倍增。但参数校验失败、业务逻辑错误绝对不重试直接返回错误信息。这两种错误的性质完全不同混为一谈会导致大量无效重试请求打爆下游系统。并发控制Agent 任务往往涉及多个工具调用有些场景下可以并行。但我初期出于稳妥考虑全部串行执行。后来熟悉了各个接口的承压能力之后才开放了部分只读类工具的并发调用并发数控制在 3~5。并行带来的收益很明显但风险也很直接下游接口扛不住就会把监控告警打满。3.4 接入真实业务场景从工单自动化到跨系统协作Agent-Reach 第一次完整跑通是在工单自动化场景。业务方给的需求是当用户提交一个“我的货怎么还没到”的工单时Agent 要自动完成以下动作从工单文本里提取订单号调用订单查询工具确认订单当前状态调用物流查询工具获取物流轨迹根据物流状态生成处理建议如“已发货预计 3 天内送达”或“物流异常建议人工介入”将建议回填到工单系统并标记优先级。这个过程涉及两个外部系统订单系统、物流系统、一个内部系统工单系统恰好是验证 Agent 触达能力的好场景。实际运行中我发现了几个业务层面的问题。比如有些老订单在订单系统里查不到返回的是空结果模型就会一脸茫然。后来我在订单查询工具的清洗层里加入了“查无此单”的特殊标记并提示模型“如果订单号格式正确但查不到可能是历史数据未迁移建议引导用户提供更多信息”。这样模型就不会傻傻地把空结果处理成“订单不存在”而误伤用户。再比如工单回填有一个动作给用户发送一条服务通知。这个通知的内容是模型生成的但格式模板必须符合工单系统的规范。我的处理方法是通知工具接受两个参数——template_id模板编号和variables变量字典而不是让模型直接发一段自由文本。这样既保留了模型的语言能力又约束了格式。涉及对外发送的消息绝不能让模型自由发挥。这一步的经验是工具的输入输出协议设计决定了 Agent 在真实业务里的上限。把工具设计成“业务动作”而不是“裸接口”模型的表现会好很多。4. 踩坑排查实录Agent 触达层最常见的四个故障和应对方案4.1 模型“乱选工具”三类根因与定位方法项目上线后第一个周报里出现最多的线上问题是“模型选错了工具”。比如用户问“你们几点下班”模型居然去调了订单查询工具。表面看是模型抽风实际排查下来根因主要有三类。第一类是工具描述里缺少“适用场景”的明确边界。模型不知道订单查询工具不适合回答营业时间问题因为描述里只写了“查询订单状态”没写“仅当用户提到订单时使用”。我后来在每个工具描述的第一句都加上触发条件比如“仅当用户查询订单状态时使用与订单无关的问题不要调用此工具”。这个措辞一加误选率直接降了一半。第二类是意图识别环节太弱。用户说“我那个快递显示签收了但没收到”包含“签收”“快递”等多个信号模型可能会同时触发多个工具。我在规划器里加了“最多选择一个主工具”的约束如果多个语义信号同时出现优先定位最核心的实体而不是一上来就并行调用。第三类是上下文污染。前一轮对话聊到过订单模型在下一轮默认用户还在说订单。这个问题的修复靠的是状态管理每轮开始把对话意图做一次重置判定除非用户明确说“继续刚才的话题”否则不要带着上一轮的预设去理解新问题。排查这类问题我建议先把决策 JSON 全部落盘。我线上部署时给每轮 Agent 运行都写了一份运行日志包含模型思考过程、选中的工具、参数和结果。第一次排查时光靠“猜”效率太低日志一开问题当场现形。4.2 工具调用超时与幂等设计线上稳定性最大的敌人Agent 调工具和普通后端接口互相调用不一样普通调用失败可以重试但 Agent 调用失败之后模型可能会换一种方式再调一次于是同一个“动作”可能被执行了两次。举个例子Agent 调用“发送工单通知”工具第一次调用超时了模型判断失败于是又调了一次。但实际上第一次调用已经在用户那边触发了短信发送用户收到了两条一模一样的通知。这就是典型的重复执行问题。我的解决方案是为每次工具执行生成一个全局唯一的execution_id随请求一起发给下游。下游在收到带相同execution_id的请求时不重复执行直接返回第一次的执行结果。class ToolExecutor: def __init__(self, idempotency_store): self.idempotency_store idempotency_store # Redis 或内存存储 def execute(self, tool_name, args, execution_id): # 幂等校验 if self.idempotency_store.exists(execution_id): return self.idempotency_store.get_result(execution_id) result self._do_execute(tool_name, args) self.idempotency_store.set(execution_id, result, ttl3600) return result这个改造属于“不上线不知道上线了后悔没早做”的类型。特别是当 Agent 的步骤数变多、工具链变长之后重复执行的风险是成倍增加的。超时问题则需要在两个层面处理。一是在代码层面用asyncio.wait_for给每个协程加超时二是在 Agent 规划层面告诉模型“工具有响应时限超时后不要无限重试如果重试两次仍超时请向用户说明系统暂时繁忙”。把技术约束表达成模型能理解的行为规则Agent 的表现会自然趋近于一个理智的操作员。4.3 上下文窗口膨胀中间结果如何压缩而不丢信息Agent 跑了几步之后提示词里会塞满各种中间结果。早期我见过最离谱的一次一个 5 步任务最后发给模型的提示词超过了 3 万字符其中大量是没用的工具返回原文。这既浪费 token又让模型抓不住重点。我的压缩策略是三级递进。第一级工具返回结果在清洗层就做摘要废弃不必要字段第二级最近两步的清洗结果完整保留更早的结果做三段式摘要目标-动作-结论第三级全局只保留所有步骤的“最终结论性信息”比如订单号、最终状态、下一步行动。这三级的切换规则写死在代码里不是让模型自由裁量。这里多说一句摘要生成也要调用模型会有成本。我实测下来给每个中间结果做一次摘要的成本和把这些结果原样塞给主模型的成本相比反而更低因为摘要能有效减少主模型的输入长度和输出困惑度。尤其使用按 token 计费的模型时这笔账算下来是划算的。4.4 安全与权限边界Agent 能触达的地方必须设防Agent 的价值在于“触达”但触达也意味着风险。一个模型如果拥有所有工具的调用权限一旦被注入恶意提示词后果不堪设想。我在 Agent-Reach 里做了三层防护。第一层是工具分级。我把所有工具分为只读类、写入类、关键操作类三个等级。只读类工具查询订单状态在普通 Agent 流程中可直接调用写入类工具回填工单需要系统级校验比如确认当前对话确实来自已认证用户关键操作类工具发送通知、修改数据除了校验用户身份还要走一个“二次确认”流程模型必须先输出确认文案用户确认后再执行。第二层是参数白名单和敏感词过滤。模型的输入参数不是直接传给下游的而是先过一层过滤器。比如传入的订单号必须匹配格式传入的文件路径必须在白名单内。对于可能引起歧义的自由文本参数再加一层敏感词过滤。第三层是会话权限绑定。每个 Agent 会话只绑定一套工具集。普通用户会话不挂载管理员工具运营人员会话不挂载用户隐私查询工具。做不到“最小权限”就不要谈 Agent 的安全性。权限配置我放在独立的配置中心里不允许 Agent 自己改自己的权限保证权限逻辑独立于推理逻辑。5. 实测数据、性能观察与后续演进方向项目上线满一个月时我拉了几组数据做复盘。先说业务效果的工单自动化场景共处理 1287 个工单Agent 完全无人介入完成的有 863 个占比 67%。剩下 33% 里有 12% 是用户输入信息严重缺失模型主动转交人工有 15% 是业务异常订单模型识别出异常后升级处理还有 6% 是真的处理错了需要在后续复盘里修正。token 消耗这块也挺值得说。我发现 Agent 每完成一个工单平均消耗约 4800 token其中约 55% 花在工具返回结果和中间摘要上真正用于“生成回答”的 token 只占 20% 左右。这个比例反映了 Agent 应用的客观规律它不停在读工具结果、做判断、再读新结果而不是简单生成一段话。如果只看产出结果觉得“几百 token 怎么够”那是对 Agent 机制有误解。性能方面单任务平均耗时是 3.8 秒其中 2.1 秒花在模型推理上1.2 秒是工具调用0.5 秒是校验和清洗。随着工具并行改造完成平均耗时降到了 2.6 秒已经接近人工处理效率的下限。逼得太快反而会牺牲稳定性在 Agent 这种链条式任务里稳定比速度更重要。后续演进我计划做三件事。一是把工具注册从代码级改成配置级运营人员能在后台直接添加新工具的描述和参数不用改代码重新部署。二是引入更细粒度的成本控制给每个工具调用做计费和告警超预算的会话自动降级为只读模式。三是把运行时日志和模型决策过程接入可视化平台让业务方能看到“Agent 为什么这么处理”这是让业务方信任 Agent 的关键一步。6. 我的真实体会做 Agent 项目难的不是模型是边界做完 Agent-Reach 之后我对 Agent 工程化这件事有了全新的认知。过去我以为难点在提示词、在模型能力实际做下来发现模型能力已经够用了工程的难点全在边界管理——工具边界、数据边界、权限边界、状态边界。一个 Agent 能不能在业务里稳定跑下去不取决于它有多聪明而取决于你把它关进了一个多合理的笼子里。最后分享一个执行细节。调试 Agent 和调试传统程序是两种完全不同的体验。传统程序出 bug你沿着调用栈追就行Agent 出问题是模型的决策出了问题而模型的决策又受提示词、上下文、工具描述多种因素影响。我的经验是不要靠猜每一步都留日志。决策日志、token 计数、工具耗时、校验命中情况全记录下来。Agent 是一个黑盒但工程上要努力让它成为一个“可回溯的黑盒”。这一点做到了后面所有的问题排查都会顺很多。如果你也在做 Agent 相关的事情遇到最多的问题是什么欢迎在评论里交流。我是从实际项目里爬出来的你踩过的坑我大概率也踩过。