Agent-Reach:让 AI Agent 联网查证与可追溯引用
发布时间:2026/9/18 4:15:51 作者:尧图编辑部 阅读量:1,286

上个月帮朋友看一个内部知识助手问题挺典型问它我们产品最新版本的接口有没有变动它能一本正经地给你编出一段看起来很像真的回答连版本号都编得有模有样。我问他这助手能不能连到你们自己的文档站和接口变更日志去看一眼他愣了一下说没做一直是拿模型自己脑子里的东西在答。这就是多数 Agent 落地时最先撞上的墙——模型再强它也只在一个固定的知识快照上说话。而 Agent-Reach 要解决的就是把这堵墙拆掉让 Agent 具备够得着外部信息的能力从搜索、抓取、调接口一直到把结果干净地喂回上下文并给出可追溯的出处。它不是一个具体产品更像是一层能力架构你在做 RAG、做工具调用、做自动化流程时都会碰到它。这篇文章我会按一个真实项目的推进顺序讲先想清楚 Reach 的边界再拆开它的内部关卡接着从零搭一个最小可用版本最后聊聊实测里最容易翻车的地方和参数怎么调。1. Agent-Reach 到底解决什么问题从闭卷考试到带资料进考场1.1 闭卷 Agent 的能力天花板在哪里把现在大多数 Agent 想象成一个闭卷考试的学生。它答得好不好完全取决于两件事训练时背了多少以及你的问题是否落在它背过的范围内。凡是涉及上周刚发的公告昨天刚改的配置某个内部系统的实时状态它就只能靠蒙而蒙出来的东西还特别像真的这才是最麻烦的地方——错误答案的可信度太高了。我在实际项目里踩过最典型的一次坑客户问你们的退款政策是不是改了Agent 从旧版政策的记忆里拼了一段出来语气笃定条款编号都对得上但那个编号在新版里已经被合并了。当天晚上客服主管就把这个功能下线了。事后复盘问题不在模型而在于我们把一个需要查的问题交给了只能回忆的系统。闭卷 Agent 的边界其实很清晰静态知识用推理动态事实必须靠触达。凡是答案会随时间、随环境、随账号状态变化的内容都属于 Reach 的管辖范围。分不清这个界线你会在错误的地方花大量精力去调 Prompt而正确的地方一点没动。1.2 Reach 的三层含义触达、取回、可验证很多人一提到 Agent 联网脑子里只有搜索这一个动作。真做下来你会发现Reach 是三层能力叠在一起缺一层都会让结果变得不可用。第一层是触达也就是知道该去哪儿问。这不只是搜索引擎的事还包括内部文档站、数据库、第三方 API、文件系统。一次触达的成败往往取决于你有没有选对通道而不是通道快不快。第二层是取回也就是把目标位置的东西完整、准确地拿回来。这一层全是工程细节超时、重试、并发、编码、页面结构变化、接口限流。做得粗糙触达了也拿不到东西。第三层是可验证也就是答案要能对回原始出处。这一层最容易被忽略但恰恰决定了 Agent 能不能进生产。用户看到这个版本号来自某某变更日志第 3 条和你看到一段没有出处的断言信任度完全不是一个量级。我在设计自己的模块时会强制要求每个 Reach 结果都携带source、fetched_at、confidence三个字段。哪怕一开始用不上后面排查问题时这三个字段救过我好几次。1.3 什么场景该上 Reach什么场景不该上不是所有 Agent 都需要 Reach硬上反而会让系统变慢变贵。我一般用两个问题来筛答案会不会变会变就必须上比如库存、价格、版本、公告、日程。答错的代价大不大代价大就必须上比如医疗建议、金融条款、内部制度解读。反过来这两类场景我通常不上纯创意类任务写文案、起名字、做头脑风暴以及答案完全封闭在已有上下文里的任务总结一段用户刚粘贴的文本。前者触达了也用不上后者纯粹是浪费一次调用。还有一个中间地带值得单独说看起来静态、其实有版本差异的内容。比如某个开源库的用法模型记得大方向没错但参数名在新版本里改了。这种场景上 Reach 的收益极高因为只需要触达一次官方文档就能消掉一整类错误。2. 拆开看 Reach 的内部结构一次有效触达要经过哪几道关卡2.1 意图到查询Query 改写这一关最容易被低估用户说的是这个功能还能用吗传给检索层之前得先变成能命中目标的一句话。这一转变看起来简单实际是整个链路里最影响成败的一环。我见过最多的做法是直接把用户原话扔给搜索接口。结果就是召回一堆八竿子打不着的东西模型拿到这些垃圾上下文反而比不联网答得更差——因为它现在有了依据会更自信地顺着错误方向走。比较稳的做法是做两步改写。第一步叫指代消解把这个功能它那个接口替换成具体的实体名这些实体通常来自对话历史或会话上下文。第二步叫查询扩展把口语化的问法翻译成目标系统更容易命中的关键词组合。举个例子退款政策改了吗可以改写成退款政策 变更 更新 生效日期四个词覆盖了文档里可能出现的不同表述。一个实操细节改写这步不要用最强的模型用便宜的小模型跑就够了但必须给它明确的约束比如输出限定在一行、不超过 30 个字、不要带问句。我早期没加限制小模型总爱输出一段解释直接污染了查询串。2.2 取回层并发、超时、重试的取舍取回层的核心矛盾只有一个你希望它多快和它能多稳通常是反着来的。把超时设得很短比如 3 秒慢一点的目标直接被掐掉链路整体响应很快但你会漏掉大量本来能拿到的结果。设得很长比如 30 秒成功率上去了但一个卡住的目标会把整轮对话拖到用户失去耐心。我的经验值是单目标超时 8 秒、整轮触达预算 15 秒超过预算就带着已有结果往下走。这个数字不是拍出来的是拿真实目标测出来的把内部文档站、外部搜索、业务 API 三类的响应时间各采样 200 次看 P95 落在哪儿再往上留一点余量。重试也要克制。我一般只对两类错误重试连接超时和 5xx重试 1 次间隔用 1 秒左右。对 4xx 一律不重试——你参数错了重试十次还是错的只是白白烧掉预算。至于并发多目标场景下用线程池或异步任务并发取比串行快得多但并发度别超过目标系统能承受的范围我通常控制在 4 到 8 之间。2.3 清洗与切分把脏数据变成模型能吃的料原始网页拿回来里面 80% 的内容是导航栏、页脚、推荐位、广告位和一堆脚本。直接塞进上下文等于花钱买噪声。清洗这一步我一般做四件事剥掉 HTML 标签只留正文文本去掉连续空行和多余空白按语义段落切分而不是按固定字数硬切最后给每一段打上位置标记比如段落序号和来源标题。第四件事很多人不做但它对后面做引用非常关键没有位置标记你没法告诉用户答案出自第几段。切分长度我倾向 400 到 800 字一段重叠部分留 10% 左右。太短会切断上下文太长会让检索精度下降。这里有个反直觉的点切分粒度是给检索用的不是给最终阅读用的。检索命中某一段之后你可以把它的前后邻居一起带上这样既保住了精度又保住了连贯性。2.4 回填与引用让答案可追溯最后一步是把处理好的内容放回模型上下文并且要求它在回答里带上出处。我的做法是在每段内容前面加一个编号标签形如[S1]、[S2]然后在系统提示里明确写引用外部信息时必须在句末标注对应编号没有编号支撑的结论必须说明这是推测。 这条规则看着朴素但它把幻觉从一个隐性问题变成了显性问题——模型一旦编内容往往会忘记编号用户一眼就能看出来。顺便说个体感很强的细节把抓取时间也带上。用户看到该信息抓取于 14:32和看到一段没有时间戳的断言对答案新鲜度的判断完全不同。这在处理价格、库存这类高频变化的信息时尤其重要。3. 落地实现从零搭一个最小可用的 Agent-Reach 模块3.1 目录结构与环境准备先说清楚这个最小版本不依赖任何特定平台你用什么模型、什么搜索通道都能套进去。我按职责分层目录大概长这样agent_reach/ ├── config.py # 超时、并发、条数等参数集中管理 ├── rewrite.py # 查询改写与指代消解 ├── fetchers/ │ ├── web.py # 网页抓取 │ └── api.py # 业务接口调用 ├── clean.py # 清洗、切分、打标 ├── cache.py # 分层缓存 └── orchestrator.py # 编排改写 - 并发取回 - 清洗 - 回填参数集中放在config.py里这一条是踩坑踩出来的。早期我把超时写在各个 fetcher 里调优的时候改漏了一处结果线上表现和本地测试完全对不上排查了两个小时才发现是配置分裂。环境准备没什么特别的Python 3.10 以上requests、beautifulsoup4、lxml三个包基本够用。缓存我一开始用的内存字典单机跑没问题多实例部署后立刻失效后来换成了带 TTL 的共享存储。3.2 工具注册与描述怎么写模型才不会乱调工具描述是 Reach 能不能被正确调用的开关。写得太笼统模型不知道该用写得太啰嗦模型读了半天抓不到重点。我总结的写法是一句话说清它干什么一句话说清什么时候用它一句话说清什么时候别用。比如{ name: fetch_page, description: ( 抓取指定 URL 的网页正文内容。 当用户问题涉及需要查阅的最新信息、官方文档或具体页面内容时使用。 如果问题只是对已有上下文的总结或改写不要调用此工具。 ), parameters: { type: object, properties: { url: {type: string, description: 要抓取的完整网址需包含协议头}, }, required: [url], }, }第三个什么时候别用看起来多余实测下来是减少无效调用的关键。没有这句话的时候模型遇到总结类任务也会顺手调一次抓取白花钱还拖慢响应。3.3 取回层的代码骨架取回层我写成一个统一的执行器把单个目标的超时和整轮的预算分开控制import time from concurrent.futures import ThreadPoolExecutor, as_completed def reach_all(targets, per_timeout8, total_budget15): started time.time() results [] with ThreadPoolExecutor(max_workers6) as pool: futures {pool.submit(fetch_one, t, per_timeout): t for t in targets} for fut in as_completed(futures, timeouttotal_budget): target futures[fut] if time.time() - started total_budget: break try: results.append({target: target, data: fut.result(), ok: True}) except Exception as exc: results.append({target: target, error: str(exc), ok: False}) return results这里有个容易被忽略的写法细节as_completed上也挂了timeout。只给单个请求设超时是不够的六个目标各 8 秒串起来就是 48 秒必须有一个总的止损线。另外fetch_one内部要做重试但重试必须是幂等的、有次数上限的我一般写成最多 2 次尝试。失败的目标不要直接丢掉要带着错误信息返回。编排层拿到之后可以决定是降级回答还是提示用户。把失败吞掉不报是后面最难查的一类问题。3.4 结果裁剪与上下文预算控制取回来一堆文本不可能全塞进去。我给自己定的规则是所有外部内容加起来不超过总上下文预算的 40%剩下的留给系统提示、对话历史和输出空间。具体怎么裁我用的是一个组合打分检索相关度占 0.5来源可信度占 0.2内容新鲜度占 0.2长度惩罚占 0.1。分数低的直接砍掉砍到预算以内为止。长度惩罚这一项很多方案没有结果是长文档永远挤掉短文档而短平快的公告往往才是用户真正要的。去重也是这一步做的。同一件事被三个来源提到只保留信息量最大的那一段其余作为佐证来源记在元数据里。不这么做模型会把同一件事复述三遍回答看起来又臭又长。3.5 串起来跑第一次第一版跑通的时候我只用了一个场景验证问一个答案明确会变的问题看它能不能给出正确结果并带上出处。验证清单我列一下你可以照着抄检查项期望结果触达是否发生日志里能看到至少一次取回记录内容是否干净回填文本里没有导航、脚本残留出处是否标注回答中带编号且编号能对应到具体来源时间戳是否正确抓取时间与实际时间一致失败是否可见主动断网测试错误信息完整返回这五项里我建议你优先验证第四项和第五项。时间戳错了说明时区或字段映射有问题失败不可见说明异常被吞了这两个问题留到上线后爆发排查成本会翻好几倍。4. 实测中最容易翻车的几个点4.1 工具描述写太全模型反而不会用我最开始写工具描述习惯把能想到的所有场景都列上洋洋洒洒两百字。结果模型的调用准确率反而下降了。后来才想明白描述越长边界越模糊模型要从一堆并列的场景里自己判断该不该用判断成本就高了。改成一句话触发条件 一句话排除条件之后无效调用一下子少了差不多一半。这个结论我在三个不同的项目里都验证过不只是巧合。4.2 超时设置拍脑袋一次 10 秒把整条链路拖死前面提过我最初统一设了 10 秒超时理由是看起来比较保险。上线之后用户反馈慢得离谱一看日志每轮对话平均要 20 多秒。问题出在串行调用上改写一次、取回一次、必要时再补一次取回每次都可能贴着 10 秒走。三个动作串起来就是 30 秒。改成并发加总预算之后P95 从 20 多秒降到 6 秒以内。提示超时参数不要按最坏情况多久能返回来设要按用户能等多久来设然后反过来优化每层的动作数量。4.3 把全文塞进上下文token 账单和噪声同步爆炸有一次为了信息更全我把抓回来的整页正文原样塞了进去一页大概两万字。结果是token 消耗翻了三倍回答质量反而下降了因为关键信息被淹没在大量无关内容里。这个坑的本质是把取回和使用混为一谈了。取回可以宽一些使用必须窄。现在我的做法是取回保留完整原文存在缓存里只把裁剪后的片段喂给模型模型如果需要更多细节再发起一次针对性取回。4.4 结果没做去重同一件事被复述三遍这个问题特别隐蔽因为回答不算错只是很啰嗦。同一份公告被三个来源转载三段内容高度重合模型老老实实把三遍都写进了回答里。修法很简单在清洗阶段做一次相似度过滤阈值我设在 0.85 左右。但也别设太低比如 0.6那就把不同角度的补充信息也误删了。这个值需要拿你自己的语料调没有通用解。5. 参数与策略的调优把 Reach 从能用推到好用5.1 并发度与超时的组合计算这两个参数不能分开调。我给一个我常用的推算思路假设有 N 个目标单目标 P95 响应是 T并发度是 C那么理论耗时大概是ceil(N / C) * T。你要让这个数字小于整轮预算 B。拿具体数字代入N6T3 秒C6算出耗时约 3 秒远小于 15 秒预算配置是合理的。如果 N20C4耗时就是 15 秒正好卡在预算线上这时候要么提高并发要么减少目标数。注意并发度提高不是无代价的目标系统有连接数限制超过之后错误率会陡增。我一般先按 6 试观察错误率再上下调。5.2 检索条数与答案质量的边际效应检索返回几条最合适我做过一轮简单的对照测试结论是条数在 3 到 8 之间收益最明显超过 10 条之后质量基本不再提升成本和噪声却持续上升。具体怎么定看你的问题类型。事实型问题某版本什么时候发布3 到 5 条就够答案通常集中在一两处。分析型问题这三家的方案各有什么取舍需要 6 到 10 条因为要覆盖多个对象。我现在的默认值设 6特殊场景在配置里覆盖。5.3 缓存分层什么时候该缓存什么时候必须绕过缓存能省钱省时间但用错了会给出过期答案。我分三层处理内容类型缓存策略理由官方文档静态页12 小时变化慢缓存收益高公告、变更日志30 分钟变化中等过期代价可控价格、库存、状态不缓存或 60 秒过期代价高宁可重取第三类千万别缓存我见过有人为了省钱把库存接口缓存了十分钟结果用户下单失败率飙升。凡是用户在页面上直接看到的东西都要按不缓存处理。5.4 失败降级的三档策略触达失败是常态不是异常必须设计好怎么退。我一般分三档全失败明确告诉用户这次没能取到最新信息然后给出基于已有知识的回答并标注这是未验证内容。硬撑着装作查到了是最差的选择。部分失败用成功的结果回答同时在末尾说明哪部分信息没取到。超时但已有部分结果直接带结果往下走不要为了凑齐而继续等。这三档的判断逻辑我写在编排层不写在工具里。工具只管失败编排层决定怎么应对职责分开之后逻辑清楚很多。6. 触发时机与成本控制让 Agent 学会什么时候该出去6.1 用规则触发还是让模型自己判断纯规则触发的问题是太死写死一批关键词用户换个说法就漏掉了。纯模型判断的问题是太活简单问题也给你调三次工具。我最后用的是混合方案先用规则做一次粗筛命中时间敏感词最新、今天、现在、有没有变、外部实体词具体产品名、URL、编号就直接触发没命中的交给模型自己判断但给它的提示里明确写了成本约束比如每次触达都消耗资源仅在你自己无法确定答案时使用。这套组合下来无效触达大概降了六成而该触达的场景基本没漏。6.2 复用已有上下文避免重复触达多轮对话里最常见的浪费是重复取同一个东西。用户第二轮问那它的价格呢如果第一轮已经抓过同一个页面就应该从缓存或会话上下文里直接拿而不是重新抓一遍。我的做法是在会话状态里维护一张已触达清单记录每一轮取过哪些源、什么时间取的。下一轮触发前先比对命中且未过期就直接复用。实现不复杂但省下的调用量相当可观尤其是长对话场景。6.3 给每一次触达打上成本标签最后说个习惯问题我要求每一次触达都在日志里记录来源、耗时、token 增量和是否命中缓存。攒上一两周之后你会很清楚地看到钱花在哪儿了。我自己跑出来的数据里最贵的那部分往往不是搜索调用而是把大段无效文本塞进上下文造成的 token 消耗。有了这个账优化方向就明确了——先砍上下文再砍调用次数顺序反了效果差很多。这套东西我前后迭代了四五版最大的体会是Agent-Reach 的难点从来不在能不能连上而在连上之后拿回来的东西干不干净、能不能查证、值不值这个成本。每次上线新场景我都会先跑一轮前面那张验证清单尤其是失败可见性和时间戳这两项它们出问题的时候最安静也最难查。