1. 只会对话的Agent没有生产力:Agent-Reach的缘起
过去一年多,我前前后后做了十几个Agent项目,最深的体会是:大多数Agent死在"只会聊天"这一步。你让模型写诗、做摘要、编邮件,它表现惊艳;一旦让它去查个库存、调个接口、更新个表格,立刻露馅——要么答非所问,要么一本正经地编造结果。问题不在模型本身,而在于Agent的手和脚被绑住了。
我启动Agent-Reach这个项目的初衷,就是想解决这个核心矛盾:如何让大语言模型真正"够得着"外部世界。项目名里的Reach,取的是"触达范围"的意思。在我眼里,一个Agent的实用价值不取决于它的对话能力,而取决于它能够触达多少工具、多少数据源、多少业务系统。同一个模型,只接一个聊天窗口,和接了二十个API、三个数据库、一套知识库,产出的价值天差地别。
这个项目适合谁?适合那些已经跑通了基础Agent Demo、正在思考"下一步怎么让它干实事"的人。也适合团队里负责做Agent基础设施的工程师——我踩过的坑、总结的踩坑链路,你大概率会再踩一遍。
先说清楚,Agent-Reach本身不是什么高深算法,它更像一套夹在模型与外部系统之间的扩展层协议和运行时。我在项目里把它拆成了三件事:接入(让外部能力变成标准工具)、注册(把工具写成模型能看懂的描述)、路由(让模型生成的调用意图落到正确的执行路径上)。后面所有章节,都是围绕这三件事展开的。
1.1 大多数Agent项目究竟死在哪个环节
我复盘过自己项目里Agent翻车的案例,也看过不少同行的开源代码,发现翻车点高度集中:
- 工具接入是临时的、碎片的。很多人直接在Prompt里把API文档贴进去,模型一长就上下文爆炸,换一个工具就要改一遍Prompt,根本没法规模化接第二批、第三批工具。
- 没有人想清楚"模型怎么知道该调哪个工具"。你给了它十个函数,它选错函数的概率跟函数数量成正比,尤其两个函数参数长得像的时候,几乎必错。
- 执行结果没有校验回路。Agent拿着工具返回的内容直接回答用户,工具返回"查询失败"它也会当成正常结果复述出来,甚至自己脑补一个成功的结果。
这些问题叠加在一起,给人的感觉就是:Agent用起来像玻璃人,看着全能,一碰就碎。Agent-Reach的扩展层设计,就是针对这三个环节逐个加固。
1.2 我为"触达"下的定义
在给Agent-Reach设计功能清单之前,我先逼自己把"触达"两个字切成可度量的维度,不然没法谈优化:
- 工具触达:能否调用外部API、命令行、浏览器操作等执行类能力。衡量指标是成功率、延迟、参数准确率。
- 知识触达:能否读取文档、数据库、实时数据流等信息类能力。衡量指标是命中率、引用准确性、覆盖范围。
- 动作触达:能否在调用之后继续跟进(比如先查单号,再根据结果发起退款流程)。衡量指标是多步任务的完整执行率。
- 权限触达:能否在合理的权限边界内使用上述能力,既不越权,也不因为权限不足而频繁失败。
Agent-Reach一期只重点做前三项,权限触达放在二期。但这个定义框架帮我避免了一个大坑——很多人做Agent扩展,做着做着就变成了"接了一堆API的聊天机器人",就是因为没有定义清楚每种触达的验收标准。
2. Agent-Reach的扩展层架构:接入、注册与路由三件套
Agent-Reach最核心的设计决策是:模型不直接和工具对话,中间必须隔一层"扩展层"。这一层不是可有可无的胶水代码,而是整个项目的心脏。
为什么必须隔一层?直接调用的写法在Demo里很爽,模型输出一个函数名,代码里if else一把梭,但一旦工具数量超过五个,你就要开始在调用逻辑里维护一堆边界情况:参数格式不统一、返回结构千奇百怪、有的接口要重试、有的接口要鉴权……把这些逻辑全堆在主流程里,项目迟早变成一锅粥。
扩展层做了三件事,我称之为"三件套"。
2.1 扩展层的位置:夹在LLM和外部世界之间
架构图在我脑子里是这样的(没有画图软件,用文字描述):
用户请求 → LLM推理(决定意图)→ 扩展层(Agent-Reach) ├── 工具注册表(有哪些能力可用) ├── 意图路由(该调哪个工具) ├── 执行器(真正发HTTP请求/查库/跑命令) └── 结果归一化(统一返回格式) ↓ 外部系统/API/数据库/文档库LLM只负责回答一个问题:"根据用户需求,在注册表暴露的能力里,哪一项最合适?"它不关心HTTP怎么发、鉴权头怎么加、数据库连接池够不够。这些脏活全交给扩展层。
这个拆分的直接收益是:我可以随时更换底层模型,对扩展层毫无影响;也可以随时新增工具,不需要动任何Agent的Prompt。工具是插拔式的,Agent是稳定的,这让我后几轮迭代几乎没吃过"牵一发动全身"的亏。
2.2 工具注册表:一张描述一切的JSON Schema
注册表是扩展层的地基。每个接入的能力,都必须提交一份标准的JSON Schema描述,包含以下字段:
| 字段 | 作用 | 示例 |
|---|---|---|
name | 工具唯一标识 | query_order_status |
description | 给模型看的功能说明 | 根据订单号查询物流状态,返回最新节点 |
parameters | 参数定义(类型、必填、枚举、描述) | order_id: string, required |
returns | 返回结构说明 | 包含 status, nodes, updated_at |
auth | 需要的鉴权方式 | token / apikey / oauth2 |
timeout | 超时上限 | 5000ms |
retry | 重试策略 | 2次退避重试 |
写description是最容易被忽视的环节。我后来发现,description的质量直接决定模型选工具的准确率。你用"查订单接口"这种模糊描述,模型就敢在用户问"我的包裹到哪了"时,去调"查订单列表"而不是"查最新物流节点"。项目走到中期,我把每个description都按"这个工具在什么场景下用、什么场景下不要用"两段式重写了一遍,选型错误率降了将近一半。
2.3 路由策略:先匹配再兜底
有了注册表,下一步是让模型选工具。我试过两种路子,最后选了"先匹配再兜底"的组合方案:
- 第一层:语义预筛。用Embedding把用户请求和每个工具的description做相似度计算,选出Top 3候选。这一步不做二选一的判断,只做粗筛,目的是压缩模型的选择空间。
- 第二层:LLM精排。把Top 3候选的工具Schema拼进Prompt,让模型在候选里做最终决策并给出参数值。
- 兜底:显式失败。如果预筛的相似度全部低于阈值,直接告诉模型"没有可用工具,请如实回复用户无法处理",禁止瞎编。
这个设计解决了一个很实际的问题:当注册表里有二十个工具时,把全部Schema塞进Prompt既费Token又容易让模型迷糊。预筛之后,模型每次只需要看3个候选,准确率和响应速度都有明显提升。我对比过直接全量给模型选的效果,全量方案在工具数超过8个时准确率掉得很快,而预筛方案在20个工具时依然稳定在90%以上。
3. 工具触达实战:把外部API变成Agent的"手"
扩展层跑通之后,我开始批量接入真实工具。这里挑最有代表性的两个讲:一个是HTTP API的封装,一个是带状态的多步工具(比如先查询再操作)。
3.1 一个最小可用的工具封装
以"查订单物流"为例,工具封装的核心代码逻辑是:
@tool_registry.register( name="query_order_status", description="根据订单号查询物流状态,返回最新节点。适用于用户询问包裹位置、配送进度、签收时间等场景。不要用于查询订单金额、商品明细等非物流信息。", parameters={ "order_id": {"type": "string", "required": True, "description": "订单号,通常以字母O开头后跟数字"} }, returns={"status": "string", "nodes": "array", "updated_at": "string"}, auth="apikey", timeout=5000, retry=2 ) def query_order_status(order_id: str) -> dict: resp = api_client.get(f"/orders/{order_id}/tracking", timeout=5) if resp.status_code == 404: return {"error": "订单不存在,请核实订单号"} if resp.status_code == 401: return {"error": "物流接口鉴权失败,需要检查apikey"} data = resp.json() return { "status": data["status"], "nodes": data["nodes"], "updated_at": data["updated_at"], }几个细节我需要展开讲,因为这些都是我实际踩出来的。
第一,工具内部必须处理错误分支,不能把异常抛给Agent。Agent看到抛出的HTTPError只会懵,然后编一句"系统暂时不可用"。而你在工具内部把404转成"订单不存在"这种文本,Agent就知道该如何向用户反馈。工具返回的每一条错误信息,都是在替模型省一次幻觉机会。
第二,description里明确写"不要用于什么场景"比只写"用于什么场景"更重要。负向约束能有效防止模型拿错工具。这是我在一次事故里总结出来的:有次用户问"发货地址是哪",模型去调了物流查询接口,因为该接口的description里写了"返回订单收货信息"。我后来把所有description都加了负向场景,这类错误大幅减少。
第三,超时设置必须短。模型在等工具返回的时候,整个链路的延迟都算在用户头上。我统一把外部API的超时压在5秒内,超过就返回"查询超时,请稍后重试",而不是无限期等待。用户体验差的本质不是工具慢,是Agent不给一个确定的说法。
3.2 参数幻觉:最大的敌人
工具触达做得越深,我越发现一个讽刺的事实:很多时候模型的意图选对了,但参数填错了。比如用户说"帮我查一下上个星期那个退货订单到哪了",模型把"上周的退货订单号"凭空填成了一个不存在的ID,接口返回404,它竟然顺着404说"您的订单不存在"。这就是标准的参数幻觉。
我的应对方案是三层:
- 参数从用户上下文里抓取。在Agent的Prompt里明确要求:工具参数必须能从对话历史中找到依据,找不到就反问用户,禁止推测。这一条写进系统Prompt之后,凭空编参数的案例少了很多。
- 必要时做参数二次确认。对高风险工具(比如转账、删除、改库存),当参数的置信度低于阈值时,Agent先输出"我准备执行XXX操作,参数是XXX,请确认",等用户点头再执行。处理资金类工具时,我甚至要求用户完整复述关键参数,杜绝"我发错了订单号但你照做了"的惨剧。
- 失败后不重试同参数。如果工具返回404或"记录不存在",扩展层会把这个信号原样传给模型,并追加提示"当前参数可能不正确,请重新询问用户或核对参数来源"。这能防止模型在同一条错路上反复打转。
这三层做下来,我这边工具调用的整体准确率从最初的七成左右提升到九成以上,最关键的是不再有"一本正经地错"的情况。
3.3 权限与审计:触达越多责任越大
工具接入多了以后,权限问题浮出水面。最开始我以为只要在工具内部判断"有没有token"就行,后来发现远远不够。Agent-Reach在权限上做了三档:
- 只读工具:任何对话都可以调用,比如查天气、查公开资讯。
- 用户级工具:必须验证当前对话的用户身份,只允许操作该用户自己的数据。比如查自己的订单、改自己的备注。
- 管理级工具:需要二次确认+操作审计,比如批量导出、删除数据、修改配置。
每次调用都会记录一条审计日志:哪次会话、哪个用户、哪个工具、什么参数、什么结果、耗时多少。日志相当重要——当用户投诉"Agent乱操作"时,你能在5分钟内给出完整调用链,而不是跟用户扯皮。有一次一个用户坚称Agent删了他的数据,我拉审计日志一看,是他自己在一个多轮对话里给了确认指令,误会当场解除。
4. 知识触达:把文档、数据库与实时数据接进Agent
工具解决的是"Action",知识触达解决的是"Information"。如果只有工具,Agent就像只有手没有眼睛;接上知识源之后,它才算真正睁开眼。Agent-Reach里我把知识分成三层来接入。
4.1 三层知识模型
| 层级 | 数据形态 | 接入方式 | 典型场景 |
|---|---|---|---|
| 静态文档 | 使用手册、FAQ、Wiki | 向量化+RAG | "XX功能怎么用" |
| 结构化数据 | 业务数据库、订单表 | Schema映射+SQL生成 | "帮我统计本月各品类销量" |
| 实时数据 | 监控指标、行情、日志 | 工具化封装 | "现在线上QPS多少" |
分层的意义在于,每一层有各自的最佳接入方式,不能混着来。我见过有人把数据库整表导出成向量去检索,结果既慢又不准,这就是没有分层的后果。
4.2 向量检索+RAG的落地细节
静态文档接入走的是标准RAG路线:文档切块、向量化、查询召回、拼接上下文。但我踩了几个文档里不会写的坑,值得单独说。
- 切块不能按固定字数硬切。我一开始按500字切,结果把很多表格、代码示例从中间劈开,检索出来的片段残缺不全。后来改成按Markdown标题和段落边界切,表格整块保留,召回质量明显提升。
- 必须返回原文定位。每次检索结果都带上文档ID和页码/章节号,Agent在回答时引用这些定位信息。这样用户能去原文核对,避免"Agent貌似说得通但找不到出处"的信任危机。
- 检索阈值宁严勿松。当用户的问题在知识库里没有对应内容时,正确的做法是Agent说"这个内容我没有查到你需要的,建议联系人工",而不是硬从最不相关的片段里凑答案。我设定了一个相似度下限,低于下限直接拒答,这条规则挽救了无数个本会胡说八道的回答。
4.3 让Agent直接查数据库:SQL生成的安全护栏
比RAG更激进的,是让Agent直接对业务库执行查询。这一步风险高、收益也高——一旦成功,Agent就从"查文档"升级成了"查真相"。Agent-Reach的做法不是让Agent裸写SQL,而是加了一道编译型的护栏:
def execute_query(question: str, sql: str) -> dict: # 1. 语法检查 parsed = sql_parser.parse(sql) # 2. 只允许SELECT assert parsed.kind == "select", "仅支持查询操作" # 3. 表白名单检查 for table in parsed.tables: assert table in ALLOWED_TABLES, f"表 {table} 不在授权范围" # 4. 强制行数限制 sql = add_limit(sql, max_rows=100) # 5. 超时保护 return db.query(sql, timeout=10)这一步把"模型自由发挥"变成了"模型在围栏里发挥"。护栏之外再做两件事:一是把数据库Schema(表名、字段名、字段注释)拼进Prompt,让模型生成的SQL能对上真实的库结构;二是设计"先试后看"的策略——先小范围跑一次验证结果合理,再给用户展示。我还发现,字段注释写得越详细,SQL的准确率越高。这也是个简单但容易忽略的道理:模型的SQL都是照着你的注释猜的,注释不写清楚,它只能瞎猜。
数据库接入让我最惊喜的场景是:用户随口问"上个月退款率最高的三个区域是哪几个",Agent真正去跑了聚合查询再回答,而不是根据常识瞎编。那一刻我才觉得,Agent从"玩具"变成了"工具"。
5. 触达失败排查:超时、幻觉、权限三类事故的完整处理链路
接入的系统和工具多了,事故就成了日常。这里我把Agent-Reach上线后最典型的三类事故完整复盘一遍,每一条都是我实际处理过的,排查思路可以直接抄。
5.1 场景一:工具调用超时,Agent卡死在"我以为"
事故现象:用户问"帮我生成一份本季度的销售报表",Agent调用报表接口后一直转圈,最后丢出一句"系统繁忙,请稍后再试"。用户体验极差。
排查链路:
- 先看扩展层日志,确认工具调用确实发出去了,耗时8.3秒,超时上限是5秒,所以扩展层主动终止并返回了超时错误。
- 再把超时错误喂回给模型,此时模型应该向用户解释"报表生成较慢",但它给出的回复是"系统繁忙"——这不算错,但信息量不够。
- 进一步查接口本身为什么慢。发现报表接口是同步计算,当数据量大时必然超过5秒。问题不在扩展层,而在接口设计。
修复方案分两步:
- 短期:把超时上限从5秒调整到10秒,并在工具的description里注明"该工具可能耗时较长,请提醒用户耐心等待"。
- 长期:把同步接口改成异步任务,先生成任务ID立即返回,Agent轮询任务状态,完成后拉取结果。这一步改造后,这类"卡死"事故基本绝迹。
这里有个经验:超时不是越小越好。超时太大,用户等得抓狂;超时太小,很多正常但稍慢的操作会被误杀。合理的做法是每个工具单独设超时,并让模型根据工具特性向用户预设心理预期。
5.2 场景二:参数幻觉,查错对象的尴尬
事故现象:用户说"帮我查一下我昨天买的那个耳机发货了没"。Agent调用query_order_status,填的order_id是O202411031234,返回404,Agent回复"您的耳机订单不存在"。
排查链路:
- 查看审计日志,找到这次调用的参数,发现
order_id并不是用户提供的,对话记录里根本没有这个单号。 - 查看模型当时的完整上下文,发现模型是从一句"给我看下订单"的模糊表达里自行脑补了一个单号。
- 翻出系统Prompt,确认"参数必须来自用户上下文,否则反问"这条约束没有被严格遵循——原因是这条约束放在系统Prompt的末尾,被更靠前的指令覆盖了。
修复方案:
- 把"参数来源约束"提升到系统Prompt的顶部,并加粗强调。
- 在扩展层加了一道校验:
order_id必须和对话中出现的订单号完全匹配,否则返回冲突错误,并提示模型反问用户。 - 增加一条人工兜底规则:单号匹配失败时,不得回复"订单不存在",必须回复"我这边没有找到对应订单,能再确认一下单号吗"。
修复后,这类"查错单还下结论"的事故下降非常明显。参数幻觉的根因在模型,但缓解方案可以放在扩展层里——不要在模型犯错后才纠正,要在模型犯错前堵住路径。
5.3 场景三:权限边界被顶穿
事故现象:某用户通过Agent查询了其他用户的订单详情。订单数据属于用户级工具,理论上只能查自己的,但Agent拿到了两个订单号,一个是用户的,一个来自对话里的历史消息(别人的单号),它全查了。
排查链路:
- 审计日志显示,调用
query_order_detail时传入的order_id不属于当前登录用户。 - 检查工具内部,发现当时只校验了"是否有token",没有校验"token对应的用户是否有权访问这个订单号"。
- 说白了,扩展层的权限校验只做了粗粒度(能不能查订单),没做细粒度(能不能查这条订单)。
修复方案:
- 在用户级工具的执行器里加入属主校验:从token解析出
user_id,和订单的owner_id比对,不一致直接返回"无权访问"。 - 同时加了另一条规则:对话历史中出现的敏感数据(订单号、手机号、地址)在传参前必须做二次脱敏校验。防止Agent拿着上一轮别人的数据来查下一轮自己的数据,形成跨会话的数据混淆。
这个事故让我对权限问题有了敬畏心。Agent触达的能力越强,越需要细到数据行的权限控制。偷懒只做"能调/不能调"的粗粒度控制,迟早出大事。
6. 扩展方向:Agent-Reach的边界与后续路线
如今Agent-Reach已经稳定运行了几个月,接入工具近二十个、知识源四个、数据库三张表,支撑了日常运营类问答和报表生成场景。回看这个项目,我最大的收获反而不是技术方案,而是对"边界"的理解。
Agent-Reach目前有一些明确解决不了的问题,我如实说出来,免得大家照搬时踩坑:
- 它不解决模型推理能力的上限。如果模型本身逻辑混乱,扩展层再完善,输出的结论也是错的。扩展层只能保证"调用正确、数据真实",不能保证"思考正确"。
- 它不适合毫秒级的交互场景。每多一层调用,就多几十到几百毫秒的延迟。如果业务对延迟极敏感,直接走确定性代码,别绕道Agent。
- 它不适合高风险自动化。涉及资金、法务、医疗等需要强责任边界的场景,我目前不建议让Agent直接操作,最多让它出建议,人来做决策。
后续的路我计划分三条线走:一是把权限触达做成完整的RBAC体系,支持按用户、按角色、按数据范围做细粒度管控;二是给扩展层加"学习回路",把每次失败的工具调用沉淀成规则,自动改进路由策略;三是把触达能力做成可对外暴露的服务,让团队里其他项目也能复用这套扩展层。
Agent-Reach的代码并不炫技,它解决的是一个很朴素的工程问题:让Agent的"手"够得着该够的东西,并且够的过程中不出错。如果你正在做Agent类项目,我的建议是:别急着上复杂算法,先把触达层做扎实。工具描述写好、权限控好、失败路径设计好,你的Agent就已经超过市面上大半的Demo了。