ARTICLE DETAIL

资讯详情

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

Agent-Reach:AI Agent 生产级工具调用与触达实践

Agent-Reach:AI Agent 生产级工具调用与触达实践 Agent-Reach 这个词第一次出现在我们内部评审会上时会议室里有一半人以为它是某个网络连通性检测工具。等把需求讲完大家才反应过来它说的其实是另一件事让 AI Agent 真正把手伸到系统外面去去查数据、去调接口、去改状态、去把一件事从头到尾办完。前面两年我们见过太多能聊天的 Agent 演示十个里面有九个在演示环节表现惊艳一进生产环境就露馅——参数填错、接口超时、重复下单、权限越界、上下文被工具返回值撑爆。Agent-Reach 就是围绕这一整套问题长出来的工程实践它不关心模型本身有多聪明它关心的是模型决定要做什么之后这套系统能不能稳稳地把事情做成。这篇内容适合三类人看。第一类是把 Agent 从 demo 往生产推的工程师你手上大概率已经有一个能跑通的链路但延迟、成本、稳定性三座大山压得你喘不过气第二类是做平台和基础设施的同学你要给别人提供工具接入能力需要一套契约和治理规范第三类是产品和技术负责人你想搞清楚 Agent 这类项目到底难在哪里、投入该往哪儿放。下面这些内容里有架构判断也有我实际踩过的坑和能直接抄的代码片段代码是 Python 的但思路换任何语言都成立。1. Agent-Reach 到底在解决什么问题1.1 从会说到能办成事中间隔着一整条工程链很多人对 Agent 的第一印象来自那种单轮对话演示用户提问模型思考调用一两个工具返回答案。这个链路在实验室里成功率能到九成以上因为演示用的工具是精心挑选的、参数是稳定的、网络是通畅的。但真实业务链条完全不是这个形状。我接手过一个工单自动处理的需求链路是读取工单内容、判断归属系统、查询客户历史记录、匹配处理规则、调用工单系统接口修改状态、通知相关人员。六步里任何一步失败整件事就卡住而每一步的失败模式都不一样。这里的关键认知是Agent 的能力上限不取决于模型而取决于最弱的那一环。模型再聪明工具返回一个格式混乱的 JSON它也会解析错接口再稳定模型把一个字符串参数填成整型调用照样报错。所以 Agent-Reach 的第一件事是把触达这件事从一句模糊的口号拆成可度量、可观测、可回滚的工程指标。我们当时的定义是四条单步工具调用的成功率、端到端任务完成率、单任务平均成本、以及 P95 端到端延迟。这四条指标一旦被明确下来后面所有的架构决策都有了锚点。这四条指标之间是互相拉扯的。想让工具调用成功率上去最直接的办法是加校验和重试但重试会拉高延迟和成本想压成本就得砍上下文砍得狠了模型判断力下降端到端完成率又掉下来。我在项目初期犯过一个典型错误为了把成本压到预算内把工具返回结果做了激进截断结果模型拿到的信息不完整反复追问同一个工具实际调用次数翻了四倍成本反而更高。这个教训后面会展开讲核心就是别用单点指标做决策。另一个容易被忽略的问题是失败的可归因性。当任务失败时你需要能在三十秒内判断出是模型判断错了、工具挂了、还是编排逻辑本身有 bug。如果做不到这一点排查就会变成一场灾难。我们在第一版里没有做结构化日志一个线上问题排查了四个小时最后发现是某个工具在特定参数下返回了空数组而模型把空数组理解成了没有权限。加上结构化埋点之后同类问题十分钟内定位完成。1.2 三类典型场景触达的深度完全不一样同样是让 Agent 触达外部系统不同的场景对架构的要求差了好几个量级。我把它粗分成三类你可以对照自己的需求看看落在哪一档。场景类型典型特征主要风险架构重点只读查询类查数据、查文档、查状态数据越权、结果过大权限过滤、结果裁剪写操作类下单、改状态、发通知重复执行、误操作幂等、审批闸门长链路任务类多步骤跨系统协同中途断裂、状态漂移状态持久化、断点续跑只读查询类看起来最简单实际上坑在权限。我们做过一个内部知识问答的 Agent想法是让模型自己决定查哪个库。上线第一天就出事了一个销售同学问了一句关于薪资结构的问题模型老老实实去查了 HR 库虽然那个库对当前用户本身是不可见的但工具层没有做用户身份透传用的是服务账号。这类问题必须在工具层拦指望提示词里写一句不要查询敏感数据是没有用的模型无法判断什么是敏感。写操作类的核心是幂等。这不是 Agent 特有的问题任何分布式系统都要处理但 Agent 把它放大了一个数量级因为模型可能因为一次幻觉、一次超时重试、或者一次上下文误解把同一个写操作发起两次。我们做过一个统计在没做幂等保护的早期版本里约 3% 的写操作任务出现了重复调用其中大部分被下游系统的唯一约束挡住了但确实有两笔订单重复创建。这个数字在生产环境里是不可接受的。长链路任务类的难点在状态。一个任务跑了五分钟中间涉及六个工具调用这时候进程重启了任务怎么办如果状态只存在内存里那只能从头再来前面的副作用已经产生了重跑就是灾难。我们后来的做法是把任务状态和已完成步骤持久化到数据库每个步骤带上幂等键重启后从断点继续已完成的步骤直接跳过。1.3 方案选型上我踩过的三个弯路第一个弯路是想找一个万能框架。项目启动时我花了两周时间对比各种 Agent 编排框架试图找到一个能同时解决工具注册、状态管理、重试、观测的方案。结论是没有。通用框架在某个维度上做得很好但总有两三个维度要你自己补而补的过程跟从零写差不多还多了一层抽象要学。后来我们的选择是编排循环自己写大概两百行核心代码观测直接对接现有的链路追踪体系工具注册用一份 YAML 加装饰器。简单可控出问题能直接读代码定位。第二个弯路是追求单 Agent 搞定一切。我们一度把十几个工具塞进同一个 Agent 的工具列表里结果模型的选择准确率随着工具数量增加明显下滑。二十个工具时准确率还能看四十个工具时模型开始频繁选错尤其是那些功能相近的工具。后来的改法是做两层上层是一个路由器负责判断任务属于哪个域下层是按域划分的子 Agent每个只带五到八个工具。工具选择准确率立刻回升而且每个子 Agent 的提示词可以做得很聚焦。第三个弯路是过度依赖模型的自主规划。早期我们让模型自由决定先调哪个工具、后调哪个工具结果同一类任务的执行路径每次都不一样好的时候三步完成差的时候绕七步成本和延迟完全不可预估。现在的做法是混合式对于结构明确的高频任务走预定义的任务图模型只在槽位填充和异常分支上做判断对于开放式问题才放开让模型自主规划。这个比例大概是七三开稳定性和灵活性兼顾。2. 整体架构设计把触达拆成四层2.1 意图层从一句话到一张可执行的任务图意图层要做的事情比想象中复杂。用户说一句帮我把上周那个客户的工单处理一下这里有三个模糊点哪个客户、哪些工单、怎么处理。如果直接把这句话丢给模型让它规划大概率会得到一堆猜测。我们的做法是先做一轮槽位抽取把能确定的确定下来不确定的显式追问然后再生成任务图。任务图不是新的概念本质就是一个有向无环图加条件分支。节点是一次工具调用或者一次模型推理边是依赖关系。举个实际例子处理工单这个任务图大概是节点 A 查询工单列表节点 B 对每个工单查询客户信息节点 C 根据规则判断处理动作节点 D 执行写操作节点 E 发送通知。其中 B 依赖 A 的输出C 依赖 BD 依赖 C。任务图的好处是可预期。同一类请求进来的执行路径基本一致成本和延迟可估算出了问题能定位到具体节点。代价是灵活性下降遇到任务图覆盖不到的情况需要降级到自由规划模式。我们设的降级条件是槽位抽取置信度低于阈值或者任务图匹配不到任何模板。降级之后会打上标记用于后续补充任务图模板。这里有个实操细节值得说任务图不要设计得太细。我们第一版把每个工具调用都做成一个节点结果图特别大维护成本极高改一个规则要动好几处。后来把无分支的连续调用合并成一个复合节点图的规模降了一半可读性大幅提升。判断标准是如果两个步骤之间没有任何条件分支和错误处理差异就合成一个节点。另一个坑是槽位抽取的时机。我们试过两种方案一种是在任务开始前一次性抽完所有槽位另一种是执行到需要时再抽。前者的好处是能提前追问用户体验好坏处是多了一轮模型调用成本和延迟都增加后者省一次调用但可能执行到一半才发现缺参数前面白跑。最后的选择是混合必填槽位提前抽可选槽位执行到再抽。必填槽位通常就两三个追问一次能拿到成本可控。2.2 工具层注册表、Schema 与权限边界工具层是整个体系的地基我在这上面花的精力比在编排逻辑上还多。一个工具进入系统需要提供四样东西名称和描述、参数 Schema、执行函数、以及权限声明。前三个是给模型看的第四个是给系统看的。名称和描述决定了模型能不能在正确的时机想起这个工具。这里有个反直觉的经验描述不是越详细越好。我们试过给每个工具写两百字的详细描述结果提示词里光工具描述就占了大量 token而且模型反而抓不住重点。后来改成一句话说清楚做什么加一句话说清楚什么时候用长度控制在六十个字以内选择准确率反而更高。比如查询订单状态这个工具描述写成根据订单号查询订单当前状态和物流信息。当用户询问订单进度、物流情况时使用足够了。参数 Schema 用标准的 JSON Schema这个东西的价值在于双向校验模型生成参数时能参考结构系统执行前能校验参数。校验这一步绝对不能省。我们统计过模型生成的参数里大约有 5% 到 8% 是类型不对或者必填项缺失的如果直接执行这些都会变成线上错误如果提前拦截并把校验错误返回给模型其中大部分模型能在下一轮自己修正。成本上一次本地校验几乎为零一次模型重试的成本远高于此。权限声明我建议做成三个维度数据域、操作类型、调用者角色。数据域决定这个工具能碰哪些数据操作类型区分读和写调用者角色做最后的过滤。三层过滤的实现在网关层统一做工具开发者不需要关心。这个设计的价值在于新增工具时只要声明清楚安全策略自动生效不会因为开发者疏忽出现越权。另外强烈建议给工具加上预估耗时和是否可重试两个元数据。前者用于编排层的超时设置后者用于失败处理策略的自动选择。我们有过一个教训一个查询外部系统的工具平均耗时八秒编排层默认超时设的是五秒导致这个工具几乎每次都超时而模型看到的错误是超时于是它判定这个工具不可用转而去尝试别的路径白白浪费了 token。加上耗时元数据后超时按工具的实际分布来设问题消失。2.3 执行层调度、重试与幂等怎么配合执行层管的是调用真的发出去之后的事。这里最核心的三个机制是调度、重试、幂等它们必须协同设计单独看任何一个都会出问题。调度层面我们用的是简单的并发模型任务图里没有依赖关系的节点可以并发执行有依赖的串行。并发度设成三到五不要太高因为下游系统通常有自己的限流并发过高会被限流反噬表现为大面积超时。我们设过一个参数叫每工具并发上限同一个工具同时最多三个请求跨工具的并发不受这个限制。重试策略要按错误类型区分不能一刀切。我的分类是这样网络超时、连接失败可重试指数退避最多三次服务端 5xx可重试退避时间加倍参数校验失败不重试直接返回给模型让它改权限拒绝不重试走降级或上报业务规则拒绝比如余额不足不重试把原因告诉模型把参数错误和业务错误当成可重试错误处理是我见过最常见的错误。前者会导致同一个错误反复重试浪费时间后者更糟糕可能触发下游的风控。幂等这块做法是给每个写操作生成一个幂等键键的构成是任务 ID 步骤 ID 参数指纹。幂等键传给下游下游用唯一索引挡重复如果下游不支持自定义幂等键就在我们自己的数据库里建一张表做去重执行前先查执行后记录中间加分布式锁。这个表要定期清理我们保留七天够覆盖绝大多数重试窗口。这里有个细节幂等键里为什么要带参数指纹因为同一个步骤在重试时参数可能被模型改了。如果只带任务 ID 和步骤 ID第二次调用会被误判为重复而跳过实际上模型改对了参数本该执行。带上参数指纹之后参数变了就是新的一次调用参数没变才是重试。2.4 观测层没有 Trace 的 Agent 就是在裸奔我在这个项目里最深刻的体会是Agent 系统的可观测性投入应该占到总投入的三成以上。原因很简单传统服务的执行路径是代码写死的读代码就知道会发生什么Agent 的执行路径是模型生成的同一句话两次进来可能走不同的路不看 Trace 根本不知道发生了什么。我们记录的东西分四类。第一类是决策轨迹每一轮模型看到了什么、输出了什么、为什么选择这个工具。第二类是工具调用记录入参、出参、耗时、状态码。第三类是状态变更任务状态从什么变成什么触发条件是什么。第四类是成本数据每一轮消耗的 token 数按输入输出分开记。这四类数据里我认为最关键的是决策轨迹里的模型看到了什么。很多问题出在上下文构造上而不是模型本身。比如模型反复调用同一个工具你去看它看到的上下文发现工具返回的结果被截断了一半模型以为没查到所以再查一次。这类问题只有完整记录上下文才能发现。回放能力同样重要。我们把完整的决策轨迹存下来可以离线重放给定同样的上下文让模型再做一次决策看结果是否一致。这个能力在调提示词时特别有用改动前后跑同一批历史 case直接看通过率变化。我们维护了一个两百条的历史 case 集每次提示词改动都要过一遍回归通过才允许上线。这个做法把提示词调整从凭感觉变成了看数据。成本核算要细化到单个任务和单个工具。我们最初的成本账是粗粒度的只能看到每天总花费。后来拆到任务级别才发现有一个工具因为返回数据特别大单次调用消耗的 token 是其他工具的五倍而它被调用的频率还很高占了总成本的近四成。针对它做了字段裁剪之后整体成本降了三成。3. 核心细节落地契约、上下文与安全边界3.1 工具契约怎么写才不会坑到未来的自己工具契约是整个系统里最需要向后兼容的部分因为工具是多方提供的改契约的成本极高。我在设计契约时定了几条硬规则写在这里供参考。第一条所有参数要么必填要么有默认值不存在可选但没默认值的中间态。这个规则听起来奇怪但很有用。如果某个参数是可选的又没有默认值模型的处理方式是不确定的有时传有时不传不传时工具的行为可能不符合预期。强制二选一之后模糊地带消失。第二条返回值必须是结构化的且带一个明确的 status 字段。我见过太多工具失败时返回一个错误字符串成功时返回一个对象模型需要靠猜来判断是哪种情况。正确的做法是统一成{status: ok|error, data: {...}, error: {...}}这样的形状模型一看就懂。第三条错误信息要写成给模型看的样子而不是给运维看的堆栈。比如不要返回NullPointerException at line 42而要返回未找到订单号为 XXX 的订单请确认订单号是否正确。模型拿到前者只能瞎猜拿到后者能自己修正或向用户确认。这个改动看起来小实际对端到端成功率的提升很明显。第四条单次返回值大小要有上限超限必须分页或裁剪。这个上限我建议设在两千 token 以内。超了就在工具内部处理不要指望编排层去截断因为工具最清楚哪些字段重要。第五条工具的副作用必须显式声明。读工具和写工具在编排层走的流程完全不同写工具要过审批闸门和幂等保护。声明方式就是在注册时打标不要靠命名约定去判断。3.2 上下文预算算清楚你的钱花在哪上下文管理是 Agent 项目里最容易被低估的部分。我见过不少项目在这里翻车表现是任务越跑越慢、越来越贵最后上下文爆掉直接报错。要管好它先要会算账。基础的换算关系大概是中文文本一个汉字大致对应一到一点五个 token英文一个单词对应的 token 数更少一段 JSON 里标点和 key 名占的比例很高。实践中的经验值是一段一千字的纯中文文本约一千二百到一千五百 token一段结构规整的 JSON每行平均三十个字符的话一百行大约两千五百到三千 token。有了这个换算就可以做预算了。假设你的模型上下文窗口是 128K但你绝对不能用到 128K因为长上下文会带来两个问题推理变慢以及模型对中段信息的注意力下降。我们的实践是给单次请求设一个软上限比如 32K超过就触发裁剪。裁剪的策略我按优先级排了序优先级内容类型处理方式1系统提示词、工具 Schema从不裁剪2当前任务图与已完成步骤摘要只保留摘要3最近三轮对话完整保留4更早的对话压缩成一段摘要5工具返回值按需裁剪与摘要第 5 类最需要下功夫。我们的做法是分级小于五百 token 的返回值原样保留五百到两千的做字段过滤去掉明显无关的字段超过两千的先用规则抽取关键字段抽不出来再让一个小模型做摘要。用一个便宜的小模型做摘要比用主模型硬扛长上下文划算得多我们实测下来成本能降四成左右。还有一个技巧是给工具返回结果加上过期标记。有些数据有时效性比如库存数量、订单状态五分钟前的查询结果就没必要再占着上下文了可以直接替换成该数据已过期如需使用请重新查询。这能有效防止模型基于陈旧数据做决策。3.3 失败重试与幂等的配合细节前面在架构层面提过重试和幂等这里讲讲具体的参数怎么定。退避策略我用的是带抖动的指数退避。第一次重试等 300 毫秒第二次 900 毫秒第三次 2700 毫秒每次加正负 20% 的随机抖动。抖动的作用是避免多个并发任务同时重试造成尖峰。重试次数上限设为三次超过就上报不再重试。为什么是三次因为我们统计过错误分布第一次重试能解决约六成的瞬时故障第二次再解决两成第三次只剩不到一成边际收益递减明显。超时设置按工具的实际耗时分布来定公式是 P99 耗时乘以 1.5。为什么是 1.5 而不是 2因为超时设太长会拖慢整个任务的失败反馈用户体验差设太短会误杀正常请求。1.5 倍是我们在误杀率和反馈速度之间找到的平衡点。有个例外是流式返回的工具超时要按首个响应时间来设而不是总耗时。幂等的实现细节上分布式锁的粒度要小心。我们一开始锁的粒度是任务级别结果同一个任务的不同步骤串行执行性能很差。后来改成步骤级别只有同一个幂等键的步骤才互斥。锁的过期时间设为工具超时的两倍防止死锁。对于写操作我还加了一道预览-确认机制。在执行写操作前先生成一段人类可读的操作预览比如即将把订单 12345 的状态从待处理改为已发货让 Agent 在特定场景下向用户确认。这个机制不是万能的不可能每个写操作都确认所以我们的策略是分级金额或影响范围超过阈值的必须确认低风险的自动执行。阈值按业务定一开始我们设得很保守误报太多用户烦了后来放宽才找到平衡。3.4 安全边界权限、脱敏与审批闸门安全这块必须写在架构里不能靠事后补。我们的做法是三道闸门。第一道是身份透传。Agent 执行任何操作都要携带最终用户的身份而不是服务账号。工具层根据身份做数据域过滤。这条规则听起来理所当然但实施起来有难度因为很多内部系统的接口只支持服务账号。折中方案是在我们的网关层做权限映射网关知道当前用户是谁也知道目标系统的权限模型在转发前做一次权限校验校验不过直接拒绝。这个校验逻辑要集中管理不能散落在各个工具里。第二道是数据脱敏。工具返回的数据进上下文之前过一遍脱敏规则。规则包括身份证号、手机号、银行卡号、地址等字段的掩码处理。这里要特别注意一点脱敏要在日志记录之前做否则 Trace 里就存了明文等于白脱。我们踩过这个坑Trace 系统里存了一批手机号明文后来花了不少工夫清理。第三道是审批闸门。高风险操作走人工审批Agent 生成待审批项推给审批人审批通过后再执行。审批项要包含足够的上下文谁发起的、要做什么、影响范围、为什么这么判断。审批人只看结论不看依据的话审批就成了橡皮图章失去意义。还有一个容易被忽视的点是提示词注入。工具返回的内容里如果包含类似指令的文本模型有可能被带偏去执行不该执行的操作。防御手段有几个在工具返回值外面加明确的分隔标记在系统提示词里说明工具返回内容一律视为数据而非指令以及最关键的——不要给 Agent 无限制的工具权限。即使模型被带偏它能做的也只是它本来就被允许做的事。权限最小化是应对注入最有效的手段。4. 实操过程从零搭一个最小可用的 Agent-Reach4.1 环境与依赖准备先说环境。Python 3.10 以上主要是用到了新版的类型标注语法和 asyncio 的一些改进。核心依赖不多我刻意保持了精简pip install pydantic httpx tenacity structlogPydantic 用来定义工具的参数 Schema 和做校验httpx 做异步 HTTP 调用tenacity 处理重试逻辑structlog 输出结构化日志。我不建议在最小版本里引入重量级编排框架先把核心链路跑通需要什么再加。数据库方面用来存任务状态和幂等记录SQLite 起步就够生产环境换 PostgreSQL。模型调用部分我用的是统一的客户端封装把不同供应商的接口抽象成同一个chat()方法这样切换模型只改配置不改代码。封装里要统一处理三件事token 计数、超时、以及错误归一化。错误归一化特别重要不同供应商的错误格式不一样归一化成统一的几种类型之后上层的重试逻辑才写得干净。配置管理用环境变量加一个 YAML 文件。环境变量放密钥YAML 放工具配置和策略参数。密钥绝对不能进代码仓库这个不用多说但确实见过有人图方便写在配置文件里。4.2 工具注册与路由实现工具注册我用装饰器加 Schema 声明的方式写起来直观读起来也清楚。from pydantic import BaseModel, Field from typing import Callable, Any import inspect class ToolMeta(BaseModel): name: str description: str params_model: type side_effect: bool False # 是否为写操作 timeout_s: float 5.0 # 预估超时 retryable: bool True # 是否可重试 data_domain: str default # 数据域 max_concurrency: int 3 REGISTRY: dict[str, tuple[ToolMeta, Callable]] {} def tool(meta: ToolMeta): def wrapper(fn): sig inspect.signature(fn) assert len(sig.parameters) 1, 工具函数必须只接收一个参数对象 REGISTRY[meta.name] (meta, fn) return fn return wrapper class QueryOrderParams(BaseModel): order_id: str Field(..., description订单号通常是 12 位数字) with_logistics: bool Field(False, description是否同时返回物流信息) tool(ToolMeta( namequery_order, description根据订单号查询订单状态和物流信息。用户询问订单进度时使用。, params_modelQueryOrderParams, side_effectFalse, timeout_s6.0, data_domainorder, )) async def query_order(params: QueryOrderParams) - dict: async with httpx.AsyncClient(timeout5.0) as client: resp await client.get(f/api/order/{params.order_id}) resp.raise_for_status() data resp.json() return { status: ok, data: { order_id: data[id], state: data[state], logistics: data.get(logistics) if params.with_logistics else None, }, }生成给模型看的工具描述时从REGISTRY里读元数据和 Pydantic 模型自动生成 JSON Schema不要手写手写必然和代码不一致。路由那层也就是前面说的哪个域的任务交给哪个子 Agent实现很简单给每个工具打上data_domain路由时看任务涉及的工具落在哪几个域。如果只落在一个域直接用那个域的子 Agent落在多个域走通用 Agent 但工具列表限定在这几个域内。这个策略实现成本很低但工具选择准确率的提升是实打实的。参数校验的代码要写在校验失败返回给模型之前from pydantic import ValidationError async def invoke(name: str, raw_args: dict) - dict: meta, fn REGISTRY[name] try: params meta.params_model(**raw_args) except ValidationError as e: # 关键把校验错误整理成模型能看懂的话 return { status: error, error: { type: invalid_params, retryable: False, message: 参数校验失败 .join( f{..join(str(x) for x in err[loc])} {err[msg]} for err in e.errors() ), }, } try: result await asyncio.wait_for(fn(params), timeoutmeta.timeout_s) return result except asyncio.TimeoutError: return {status: error, error: {type: timeout, retryable: meta.retryable, message: f调用 {name} 超时}}注意校验错误的 message 我写成了自然语言而不是直接把 Pydantic 的原始错误拼进去。实测下来模型对自然语言错误的理解准确率明显更高能自己改对参数的比例从三成提到了七成左右。4.3 编排循环与状态机编排循环是整个系统的心脏我把它写成显式的状态机而不是一个 while 循环里塞满判断。状态机的好处是每个状态的职责清晰出问题容易定位。from enum import Enum class TaskState(str, Enum): PLANNING planning # 生成/加载任务图 EXECUTING executing # 执行节点 WAITING waiting # 等待人工确认或外部回调 DONE done FAILED failed async def run_task(task_id: str, user_input: str, user_id: str): ctx await load_context(task_id) steps 0 while ctx.state not in (TaskState.DONE, TaskState.FAILED): steps 1 if steps MAX_STEPS: # 硬性熔断防止死循环 ctx.state TaskState.FAILED ctx.reason 超过最大步数 break if ctx.state TaskState.PLANNING: plan await build_plan(user_input, ctx.history) ctx.graph plan.graph ctx.state TaskState.EXECUTING elif ctx.state TaskState.EXECUTING: node ctx.graph.next_ready_node() if node is None: ctx.state TaskState.DONE continue result await execute_node(node, ctx, user_id) record_step(ctx, node, result) if result.get(need_confirm): ctx.state TaskState.WAITING await save_context(ctx) # 每步持久化支持断点续跑 elif ctx.state TaskState.WAITING: decision await wait_for_human(ctx) ctx.state TaskState.EXECUTING if decision.approved else TaskState.FAILED return ctx这里面有几个我认为必须有的东西。第一是MAX_STEPS熔断模型陷入循环是很常见的情况没有熔断的话任务会一直跑下去烧钱。我们设的是 20 步。第二是每步持久化save_context一定要在执行副作用之前完成否则重启后会重复执行。第三是need_confirm的处理它把状态切到 WAITING 之后主循环就退出了恢复靠外部事件驱动不要在循环里阻塞等待。execute_node里面是重试和幂等的逻辑async def execute_node(node, ctx, user_id): meta, _ REGISTRY[node.tool_name] args await fill_slots(node, ctx) # 槽位填充可能调模型 if meta.side_effect: idem_key make_idem_key(ctx.task_id, node.node_id, args) cached await get_idem_record(idem_key) if cached: return cached # 已执行过直接返回历史结果 async with step_lock(idem_key): result await invoke_with_retry(node.tool_name, args) await save_idem_record(idem_key, result) return result else: return await invoke_with_retry(node.tool_name, args)invoke_with_retry就是 tenacity 包一层配置成指数退避加抖动只对retryableTrue的错误重试。注意meta.side_effect为真时才走幂等路径读操作不需要加了反而增加延迟。4.4 观测埋点与本地回放埋点我用 structlog 输出 JSON 格式的日志每条记录都带task_id、step_id、node_id三个标识方便串联。日志内容分四类前面提过这里给个具体的记录示例log.info( llm_decision, task_idctx.task_id, stepsteps, modelmodel_name, prompt_tokensusage.prompt_tokens, completion_tokensusage.completion_tokens, context_hashhash_context(messages), # 上下文指纹用于回放比对 context_byteslen(json.dumps(messages)), chosen_toolchosen.name if chosen else None, latency_mselapsed, )context_hash这个字段是我强烈建议加的。它的作用是在回放时判断上下文是否真的完全一致如果哈希不一致回放结果就没有可比性。我们吃过一次亏回放时以为上下文一样结论是模型行为不稳定后来发现是上下文里有一处时间戳每次都变导致哈希不同实际上是上下文变了。加上哈希比对之后回放的结论才可信。本地回放的实现不复杂把历史 Trace 读出来重建调用模型前的那一刻上下文重跑决策对比结果。关键是要能跳过真实的工具调用用历史结果代替否则回放会真的去改生产数据。我们做了一个 mock 层回放模式下所有工具调用都从历史记录里取结果。每次改提示词、换模型、调工具描述都要跑一遍两百条的历史 case看通过率和平均步数的变化。这两个指标要一起看只看通过率不看步数容易被误导。有一次我们把通过率提了两个百分点但平均步数从 3.2 涨到了 4.8单任务成本涨了将近五成这种改进其实是不划算的。4.5 灰度上线与验收标准上线不要一次全量我们的灰度节奏是这样的先在内部测试账号上跑一周只读操作放开写操作全部拦截只记录不执行然后对 5% 的真实流量放开写操作依然拦截再对 20% 流量放开写操作最后全量。每一档的观察期至少三天看四个核心指标加一个兜底指标。兜底指标是异常任务率也就是任务以非预期方式结束的比例包括超步数、状态机卡住、以及未分类的错误。这个指标只要超过千分之五就要停下来查原因不要带着问题上量。验收标准我们是这么定的端到端任务完成率不低于 85%简单任务 95%复杂任务 75%单任务 P95 延迟不超过 20 秒单任务平均成本不超过设定的预算线以及零越权和零重复写操作。最后两条是红线触发了直接回滚不看其他指标。灰度期间一定要有人盯着。我们第一版灰度的时候值班的同学在第三天发现了一个诡异现象某些任务的成本是平均水平的三倍。查下来发现是模型在某类超时场景下会连续重试同一个工具四次而且每次重试前都会重新构造一次上下文导致输入 token 翻倍。这个问题在全量数据里被平均值稀释了只有盯着细分维度才看得出来。5. 常见问题与排查技巧实录5.1 高频故障速查表下面这张表是我这一年多攒下来的基本覆盖了八成以上的线上问题。现象可能原因排查方法处理方式模型反复调同一工具返回值被截断、模型误判失败看 Trace 里模型看到的上下文检查返回值完整性补全 status工具选择错误率高工具数量多、描述模糊统计各工具被误选次数拆分子 Agent重写描述任务中途失败率高无重试、超时设置不合理看失败步骤的错误类型分布按错误类型分别配置重试成本突然上涨上下文膨胀、重试增多看单任务 token 趋势加裁剪查重试原因出现重复写操作幂等键设计不当查幂等表是否有漏记幂等键加参数指纹锁粒度到步骤延迟 P95 飙高并发过高被下游限流看下游接口的限流日志降并发加队列状态机卡在 WAITING审批消息丢失查审批队列与订阅关系加超时自动失败补消息重投回放结果与线上不一致上下文含动态字段比对 context_hash固定时间戳等动态字段关于模型反复调同一工具我在前面提过一句这里展开说下。这个现象的根因有四五种需要靠 Trace 逐个排除。除了返回值被截断还有可能是工具返回的空结果没带明确的查询成功但无数据标识模型不知道该停下来也有可能是系统提示词里写了确保数据准确模型理解成要多次交叉验证。第一种改工具第二种改返回值格式第三种改提示词。同一个表象解法完全不同所以 Trace 记录必须足够详细。工具选择错误率高这个问题除了工具数量和描述还有一个隐蔽原因工具的命名相似。我们有两个工具叫get_user_info和get_user_profile功能确实有区别但名字太像模型经常混。后来改成get_account_basic和get_account_preferences误选率直接降下来了。命名这件事在写代码时是给自己看的在 Agent 场景里是给模型看的标准不一样要按后者的标准来。5.2 那些文档里不会写的坑第一个坑是关于重试的副作用累积。假设任务有五个步骤前四步都成功了第五步失败重试。如果重试是整个任务重跑而不是只重跑第五步前四步的副作用会再执行一遍。我们第一版就犯了这个错因为当时觉得任务级别的重试实现简单。改成步骤级重试之后副作用累积的问题才消失。这里的关键是状态要持久化到步骤粒度不能只存任务级别。第二个坑是并发执行时的上下文污染。任务图里两个无依赖的节点并发执行如果它们都要写同一个上下文对象就会出现竞态。我们遇到过一次诡异的现象某个任务的最终结果里混进了另一个任务的字段。查了两天才发现是并发写共享字典导致的。解法很简单每个节点执行完返回增量结果由主循环单线程合并不要让节点直接改共享状态。第三个坑是模型的过度自信。当工具返回的数据不完整时模型往往不会说数据不足而是基于部分数据编一个看起来合理的答案。这个问题的根因在提示词和工具返回值的设计上。我们的改法是工具返回值里必须明确标注数据的完整性比如{complete: false, missing: [logistics]}同时在系统提示词里写明如果数据完整度为 false必须向用户说明哪些信息缺失不得推测。这个改动之后编造答案的比例明显下降。第四个坑是成本统计的口径。不同供应商对 token 的计数方式不一样有的把系统提示词算进去有的不算有的对缓存命中的部分打折。如果你不做归一化跨模型的成本对比就是错的。我们的做法是在客户端封装里统一按输入 token 输出 token计数缓存命中单独记录成本计算时按各供应商的实际计费规则换算。这样账才是清楚的。第五个坑是提示词里的示例会过拟合。我们曾经在系统提示词里放了三个详细的工具调用示例本意是提高准确率结果模型变得非常依赖这几个示例的形状遇到形状不同的任务反而不太会处理。后来把具体示例删掉换成抽象的原则描述泛化能力反而更好。经验是示例可以放但不要放太多而且示例要覆盖不同的形状不能都是同一种。第六个坑是关于测试数据的。上线初期我们用真实脱敏数据做测试效果很好。后来换了一批新的测试数据成功率突然掉了一大截。查下来发现是新的测试数据里有一批边界情况比如订单号带字母、客户名超长、金额为零这些在老数据里没有。这件事让我意识到测试集必须主动构造边界 case不能只依赖真实数据的自然分布因为真实数据里边界情况的比例极低但线上出错往往就出在这些地方。我们的测试集后来扩充到三百条其中一百条是手工构造的边界 case。最后分享一个我在实际操作中的体会Agent 这类系统的优化永远不要指望一次改到位。它更像是一个持续调优的过程改一点、测一轮、看数据、再改。我自己的节奏是每周固定花两个小时看 Trace随机抽十条失败任务逐条分析。这个习惯帮我发现了不少只有长期观察才能看出来的模式比如某个工具在每周一早上的失败率明显偏高最后查到是那个时段有个批处理任务在抢资源。这类问题不看日志是永远想不到的。
返回列表