十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI Agent 触达层工程实践:工具调用、路由鉴权与结果回收

AI Agent 触达层工程实践:工具调用、路由鉴权与结果回收 Agent-Reach 这个词拆开看其实很朴素Agent 是那个会拆解意图、会调工具的执行体Reach 是它伸出去的那只手——能不能真的够到外部的接口、数据库、文件系统、浏览器、消息通道乃至另一个正在干活的 Agent。我见过太多团队把精力全砸在提示词和模型选型上结果上线后卡住的地方根本不是模型不够聪明而是模型想做的事系统里没人替它按下那个按钮。所以这篇不聊玄学聊的是触达层的工程结构工具怎么描述、请求怎么路由、权限怎么收口、结果怎么回收、翻车了怎么查。如果你正在做内部工具型 Agent、客服 Agent、运维 Agent或者只是想让模型帮你自动跑一段固定流程下面这些内容应该能直接用上。1. Agent-Reach 的字面拆解一只手的半径决定了一个 Agent 的上限1.1 会说话和办得成之间隔着一条完整的链路模型本身只会输出 token这是它的物理边界。你问它上周的退款率是多少它能做的只是把这句自然语言拆成一个意图加上一个看起来合理的工具调用。真正要把那个数字拿出来后面还跟着一长串动作构造 HTTP 请求、拿到短期凭据、处理分页、把三页数据合并、算一个除法、再格式化成人类能读的一句话。这条链路上任何一环断了用户看到的结果就是抱歉我无法访问你的数据——而这句话会让整个项目在业务方那边的信任度直接归零。Agent-Reach 关心的就是这条链路。它不是一个新模型也不是什么提示词黑魔法而是把模型想做的事稳定翻译成系统真正执行的动作的那层工程结构。我习惯把它叫做触达层因为它解决的问题不是智能问题是通路问题。通路修好了一个中等能力的模型也能干活通路没修好再强的模型也只能在对话框里空谈。这也是为什么很多团队做完 POC 觉得很惊艳一上真实业务就拉胯——POC 阶段工具是写死的、权限是全开的、返回是手工截图的这些脏活都被藏起来了。1.2 触达半径的三个维度数量、深度、可信度聊触达能力不能只数工具个数那是最容易虚胖的指标。我一般从三个维度看一只手的半径任何一个维度塌了整体能力都会跟着塌。维度含义典型问题观察方式数量Agent 可调用的工具总数与覆盖场景工具少导致频繁我做不到统计一周内被拒绝的用户请求类型深度每个工具能操作到的粒度只能重新部署服务不能只重启某个实例看工具参数是否支持细粒度定位可信度返回结果是否可被当作事实使用结果被截断、丢字段、返回旧缓存抽样比对工具直调与 Agent 输出的差异数量最容易堆加个接口包一层就算一个工具但堆到三十个之后你会发现模型选错工具的概率飙升因为它在一堆语义相近的描述里迷路了。深度最难做因为它要求后端愿意开口子给你细粒度操作很多时候是组织问题而不是技术问题。可信度最容易被忽略也是上线后投诉最多的地方——Agent 说得斩钉截铁数字却是三小时前的缓存。1.3 什么情况下值得专门搭这层什么情况下别折腾判断标准很简单看动作的确定性。如果整个流程只有一两个固定动作比如读一份固定格式的日报然后转发那就写死调用一个定时任务加一段脚本就够了别为了时髦套一层 Agent 框架那是给自己加维护成本。但如果满足下面任意两条搭一层正经的触达结构基本是划算的工具数量超过八个或者工具列表每季度都在变需要动态注册而不是改代码发版。流程需要多步串联中间某一步失败要能重试、能降级、能回滚。存在写操作而且写错了有真实后果比如发通知、改配置、扣额度。同一套能力要被多个入口复用比如客服后台、内部 IM 机器人、定时任务都调同一批工具。最后一条经常被低估。我见过一个团队先在 IM 机器人里写了一套调用逻辑后来客服系统要复用又抄了一遍两边参数校验规则还不一致结果同一笔操作在两边产生不同结果排查了大半天。触达层本质上是一个共享的执行底座它的价值恰恰在复用这两个字上。2. 触达层的三个硬部件描述、路由、回收2.1 工具描述模型眼里只有一个 JSON Schema很多人把工具描述当成文档来写这是最常见的偏差。文档是给人看的可以含糊、可以靠上下文补全工具描述是给模型看的它必须自解释、无歧义、边界清晰。模型没有你的业务背景它看到的只是一段字符串加一个参数结构。我踩过最典型的一个坑工具叫get_info参数叫id。模型永远猜不出这个 id 是订单号还是用户号于是它开始编——编得有模有样格式还对调用返回 404然后它告诉用户该订单不存在。把模糊描述改成可执行描述差别大得惊人。下面这张表是我自己整理的对照基本每次做工具评审都会拿出来用反例问题改法工具名get_info语义太宽模型无法区分改成order.query_refund_rate动词加对象参数名id不知道是什么 id改成order_no并在描述里给出格式示例参数名time不知道是时间点还是区间拆成start_date和end_date明确是否含端点描述查询数据没有返回信息写明返回百分比数值保留两位小数没有枚举约束模型自由发挥写channelAPP端用 enum 限定为all/app/web没有失败说明模型不知道出错该怎么办描述里写清哪些情况返回空、哪些情况报错描述里还有一个高频被忽略的东西返回结构的说明。模型需要知道返回里有哪些字段、字段含义是什么否则它就只能凭字段名硬猜。字段名是cnt还是count对模型的理解影响很大。2.2 路由与鉴权永远不要让模型碰到凭据这是安全上的铁律。凭据必须留在执行侧模型侧只应该看到工具的逻辑名称。整体的映射关系是单向的模型给出tool_id加参数执行侧根据调用者身份去凭据池取对应的短期令牌再发起真实请求。模型从头到尾不知道有密钥这回事哪怕它被提示注入攻击也吐不出任何东西因为它压根就没见过。路由层要处理的另一件事是调用者身份的传递。谁发起的这次请求决定了这次调用能用哪档权限。同一个order.query_refund_rate客服角色调用时只能查自己负责的渠道运营角色调用时可以查全量。这个判断必须放在执行侧不能放在提示词里让模型自己遵守——把权限规则写进系统提示词然后指望模型照做等同于把门锁挂在门旁边的墙上。还有一个细节令牌要有明确的有效期最好是单次任务级别。我习惯给每个任务上下文发一个短有效期令牌任务结束就作废。这样做的好处是即便日志里不小心落了什么片段也不构成长期风险。2.3 结果回收把两百 KB 的响应压成两 KB真实接口的返回往往又大又脏几十个字段、嵌套三层、还带一堆内部标识。直接整个塞进上下文后果有两个一是烧钱二是模型被噪声淹没抓不住重点。结果回收这一步做的就是把响应裁剪成模型真正需要的那部分。我的做法是给每个工具配一份字段白名单只保留业务语义明确的字段其余全丢。列表类结果做条数截断并在返回里明确告诉模型这是前二十条避免它误以为这就是全部。错误也做规范化统一成{code, msg, retryable}三件套模型看到retryable: false就不会傻乎乎地重试。KEEP_FIELDS {refund_rate, order_cnt, refund_cnt, channel} def normalize(payload, limit20): 把上游返回裁剪成模型可读的精简结构 note [] if isinstance(payload, dict) and data in payload: payload payload[data] if isinstance(payload, list): total len(payload) if total limit: payload payload[:limit] note.append(f结果共 {total} 条这里只展示前 {limit} 条) if isinstance(payload, dict): dropped [k for k in payload if k not in KEEP_FIELDS] payload {k: v for k, v in payload.items() if k in KEEP_FIELDS} if dropped: note.append(f已省略 {len(dropped)} 个非必要字段) return {data: payload, note: .join(note) if note else None}裁剪的尺度需要拿捏。裁太狠模型缺信息会开始编裁太松上下文被撑爆。我一般的经验值是单次工具返回控制在两 KB 以内超过就说明该加聚合接口了而不是让模型去遍历原始数据。3. 手搓一条最小可用触达链路3.1 注册表把工具当成数据而不是代码第一件事是把工具定义从代码里抽出来变成可配置的数据。这样加工具不用改调度逻辑评审也有统一入口。最小形态就是一个字典字段包括描述、参数、风险等级、超时时间、是否幂等。TOOLS { order.query_refund_rate: { desc: 查询指定时间区间内的订单退款率返回百分比数值保留两位小数, params: { start_date: { type: string, format: date, required: True, desc: 起始日期格式 YYYY-MM-DD包含当天 }, end_date: { type: string, format: date, required: True, desc: 结束日期格式 YYYY-MM-DD不包含当天 }, channel: { type: string, enum: [all, app, web], required: False, default: all, desc: 渠道筛选默认 all 表示全渠道 }, }, risk: read, # read / limited_write / full_write timeout_ms: 8000, idempotent: True, keep_fields: [refund_rate, order_cnt, refund_cnt], }, # ... 其他工具 }把risk和idempotent放进注册表是这套结构里最值钱的两个字段。前者决定要不要走人工确认闸门后者决定失败后能不能自动重试。这两个字段如果靠调用方临时判断早晚会出事故。3.2 调度器校验、鉴权、超时、幂等一个都别省调度器是触达层的心脏。它接收模型的原始参数做校验、做权限判断、做超时控制、做幂等保护最后把规范化结果返回。参数校验必须自己写不能指望模型给的数据是干净的——实测下来模型给出的参数里出现start_date大于end_date、日期格式是2024/1/1、枚举值写中文的情况一点都不罕见。import time, uuid, logging from datetime import date def validate(spec, raw): 严格校验宁可拒绝也不要带着脏参数往下走 out, errs {}, [] for name, rule in spec[params].items(): val raw.get(name, rule.get(default)) if val is None: if rule.get(required): errs.append(f缺少必填参数 {name}) continue if rule[type] string and not isinstance(val, str): errs.append(f参数 {name} 应为字符串) continue if enum in rule and val not in rule[enum]: errs.append(f参数 {name} 只能是 {rule[enum]} 之一) continue if rule.get(format) date: try: date.fromisoformat(val) except ValueError: errs.append(f参数 {name} 不是合法日期应为 YYYY-MM-DD) continue out[name] val if out.get(start_date) and out.get(end_date): if out[start_date] out[end_date]: errs.append(start_date 必须早于 end_date) return out, (; .join(errs) if errs else None) def dispatch(tool_id, raw_args, caller, trace_idNone): trace_id trace_id or str(uuid.uuid4()) spec TOOLS.get(tool_id) if spec is None: return {ok: False, trace_id: trace_id, error: {code: TOOL_NOT_FOUND, msg: f未知工具 {tool_id}, retryable: False}} args, err validate(spec, raw_args) if err: return {ok: False, trace_id: trace_id, error: {code: BAD_ARGS, msg: err, retryable: True}} if not gate_check(tool_id, args, caller, spec): return {ok: False, trace_id: trace_id, error: {code: NEED_CONFIRM, msg: 该动作需要人工确认后执行, retryable: False}} started time.time() try: raw call_upstream(tool_id, args, caller, timeout_msspec[timeout_ms]) except TimeoutError: return {ok: False, trace_id: trace_id, error: {code: TIMEOUT, msg: 上游超时, retryable: spec[idempotent]}} except Exception as e: return {ok: False, trace_id: trace_id, error: {code: UPSTREAM_ERROR, msg: str(e)[:200], retryable: spec[idempotent]}} result normalize(raw, spec[keep_fields]) log_reach(trace_id, tool_id, caller, args, started, result) return {ok: True, trace_id: trace_id, data: result[data], note: result[note]}注意retryable这个字段的来源是注册表里的idempotent不是邮件里的经验判断。只有明确幂等的工具超时后才允许自动重试。非幂等工具的失败必须抛给上层处理绝不能自作主张再来一次。3.3 观测每一次触达都要留下可复原的痕迹没有观测的触达层出了问题是查不动的。我要求的日志字段固定这几项缺一个都不行字段说明用途trace_id全链路追踪标识把用户请求与后端调用串起来tool_id被调用的工具统计工具热度与错误分布caller发起者身份追责与权限审计args_digest参数摘要脱敏后复盘时还原模型给的是什么latency_ms耗时发现变慢的工具status成功/失败码计算触达成功率result_size返回字节数发现结果膨胀的工具参数摘要要脱敏手机号、证件号这类字段统一做掩码只保留结构。日志里同时记录参数原文和脱敏版是最危险的做法迟早有人把日志导出去。查询链路用 trace_id 就够了需要看原文就去看上游系统的审计日志别在触达层留全量副本。4. 权限边界够得着不代表该够4.1 三档权限模型把风险显性化我在注册表里坚持标risk就是为了让风险在代码评审阶段就暴露出来。分三档足够用再多就没人认真填了read只读不改任何状态。可以自动执行可以重试失败也基本无害。limited_write有限写影响范围受限比如给单个工单加备注、给单个实例重启。需要校验参数是否落在调用者的可操作范围内。full_write全权写影响面大或不可逆比如批量改配置、对外群发。必须人工确认且必须记录确认人与时间。三档之间的边界要有人拍板不能由写工具的人自己决定。我见过一个团队把给用户退积分标成了 limited_write因为它一次只影响一个用户但从业务角度看这个动作不可逆、涉及资金实际情况更接近 full_write。风险等级判断要站在业务后果上不是站在技术影响面上。4.2 人工确认闸门怎么设计才不招人烦确认闸门最怕做成每步都弹窗那样用户三天就会绕开你去做别的方案。我的做法是三条规则叠加按风险等级触发read 永不弹limited_write 只在越出个人范围时弹full_write 一律弹。按批量阈值触发单次操作数超过阈值时升级确认比如一次影响超过十条记录就必须确认。按时间窗批处理把五分钟内的同类待确认动作聚合到一起展示用户一次点完而不是被连续打断十次。确认界面要给足上下文Agent 打算做什么、影响谁、影响多少、失败会怎样。只写一句是否执行此操作用户只能盲点出了问题他会说我又看不懂是它自己说要做的。把决策依据摆到用户面前责任归属才清楚。4.3 凭据管理与日志脱敏的具体做法凭据统一放在密钥管理服务里执行侧按需取用绝不写进配置文件提交到仓库绝不进模型上下文。这一点听起来是常识但真出事的时候往往就栽在临时图省事写在环境变量里然后被日志打印了。日志脱敏我建议做成拦截层而不是每个地方手写因为手写的版本迟早会漏而漏的那一次就够呛。5. 实测中最容易翻车的四件事5.1 参数幻觉最危险的调用往往是看起来对的模型编参数这件事真正可怕的不是它编得离谱而是编得太像。它会把订单号的后四位改一位然后一本正经地告诉你查询成功、金额是多少。这种错误用传统监控根本发现不了因为接口返回 200链路全绿。我的应对办法有两层一是把参数格式约束写死在描述里包括长度、前缀、校验位规则二是在执行侧加参数合理性校验比如订单号必须通过校验位算法不通过直接拒绝并返回BAD_ARGS让模型知道它给的不对。宁可多拒绝几次也不要静默地查到另一个人的数据。5.2 重试把一次写变成了三次写这个坑我栽过。一个发通知的工具超时了调度器看到idempotent没填默认按可重试处理连发三次收件人被刷屏。修复方案有三个层次第一非幂等工具绝不允许自动重试这是底线第二写类接口必须支持幂等键让上游自己去重第三重试要有退避策略间隔至少几百毫秒起不要连续猛打。第三点很多人觉得是性能优化其实它同时是稳定性保护——上游抖动的那几百毫秒正好是它恢复的时间。5.3 长尾超时与上下文被结果撑爆八个工具里通常有七个响应很快剩下一个偶尔要跑十几秒。这一个就把整体体验拉垮了。我的处理是把超时分成两段先给一个较短的软超时到点返回正在处理稍后告知同时把任务转成异步处理完再回推。这样用户不会干等着模型也不会因为长时间阻塞而产生奇怪的行为。结果撑爆上下文是另一个反向问题。有的工具返回一个几百行的列表模型读完就失忆了前面聊过的内容全忘了。解法就是前面说的结果裁剪加上一条硬规则单次触达返回一律不超过预设字节数超了就说明这个工具的设计有问题需要增加聚合参数而不是靠模型硬啃原始数据。5.4 权限漂移上线两周后没人知道它到底能干什么工具是陆续加上去的权限是陆续放开过了两周再回头看没人能说清当前这套系统总共能操作哪些东西。这是最隐蔽的风险。我的土办法是每周跑一次权限清单导出把当下所有工具的tool_id、risk、调用者范围打出来人工扫一遍。听着很笨但它真的抓到过两次不该有的权限残留——一次是临时调试开的写权限忘了收回一次是已下线的工具还在注册表里留着。6. 触达质量怎么衡量别只盯成功率6.1 四个指标缺一个都会误判成功率这个指标太粗它会把调用了但结果没用的情况算成成功。我用四个指标组合看指标定义关注点触达率用户请求中成功触发工具的比例反映工具覆盖是否够参数一次通过率首次调用参数校验就通过的比例反映工具描述写得清不清楚有效结果率返回结果被后续步骤实际使用的比例反映结果裁剪是否合理越权拦截率被权限闸门拦截的调用占比异常升高说明模型在试探边界这里面最值得盯的是参数一次通过率。它低说明工具描述有问题加再多工具也是白加它高说明模型能准确理解你的接口语义。我一般把这条线的及格线设在 85% 左右低于这个数就回去改描述而不是去换更大的模型。6.2 用回放集做回归别靠临时抽测每次改工具描述、改权限规则、换模型版本都要跑一遍固定的回放集。回放集就是一批历史请求加上期望的工具调用与参数包含正常请求、边界请求和一批故意埋的坏参数。跑完对比通过率变化跌了就不许发布。这套东西搭起来花不了多少时间但它能挡住绝大多数改了一处、坏了一片的回归问题。我自己的回放集里有一半是曾经出过事故的案例每出一次事故就往里加一条这是它最有价值的部分。7. 长期维护这层东西的几条土办法工具描述不要一次性写完就封存我一般要求每个季度做一次描述评审把使用频率最低但参数通过率也低的工具挑出来重点改通常问题都出在描述而不是实现。注册表里加一个owner字段标明谁负责这个工具出现异常时能直接找人比在群里喊半天有效得多。还有个小习惯每次上线新工具前我自己拿三种不同的自然语言说法去试它看模型是否都能正确调用这个五分钟的测试挡住过好几次语义歧义的问题。最后说个我自己的判断触达层的复杂度应该低于业务复杂度。如果这层东西本身已经复杂到需要专人维护那多半是抽象做过头了或者工具粒度切得太碎。我见过把一次数据库查询拆成连接、执行、格式化三个工具的设计模型得串三步才能拿到一个数字这种拆法只会增加失败点没有任何收益。工具粒度应该按业务动作切而不是按技术步骤切这条经验我用了很多次基本没翻过车。
返回列表