ARTICLE DETAIL

资讯详情

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

Agent技能系统设计:从提示词到注册表与编排的生产实践

Agent技能系统设计:从提示词到注册表与编排的生产实践 凌晨两点半生产环境的告警把我从床上拽了起来。原因很荒诞一个本该调用天气查询技能的Agent把指令误分发给了邮件发送模块给用户发了二十多封空白邮件。查了半小时日志才定位到根因——我把四十多个技能描述全部堆进了系统提示词上下文一长模型在关键选型时刻出现了幻觉。这不是模型不够聪明是我的Agent架构从一开始就没把技能当成一个独立层来设计。那次事故之后我彻底重构了项目里所有Agent的技能组织方式把agent-skills从提示词里的一串描述升级成了一套带注册表、带校验、带编排、带可观测性的完整子系统。这篇内容就是这次重构的完整复盘会讲清楚技能系统在整个Agent架构里的位置、注册表与调用协议怎么设计、多技能编排怎么做依赖解析、以及我在生产环境里踩过的几个典型坑。适合正在做Agent应用、尤其是准备把Agent从Demo推到生产环境的同学参考。1. 为什么Agent必须要有一层独立的技能系统最早做Agent的时候我和大多数人的做法一样在System Prompt里写上你可以使用以下工具查询天气、发送邮件、创建订单……然后期待模型自己根据场景选择正确的工具。这种做法的本质是把技能的元信息和执行逻辑全部压在提示词和函数调用上模型既要理解任务又要做技能选型还要生成正确的参数。在小规模Demo里这套路能跑通但一旦技能数量超过二十个问题就开始集中爆发。首先是上下文膨胀每个技能描述加上参数Schema折算成Token大概300到600二十个技能就是一万多Token占掉小模型上下文窗口的三分之一以上。其次是选型准确率断崖式下降实测中技能数量超过三十个之后GPT-4级别的模型在相似技能之间的误选率也会明显上升更别说那些参数Schema长得类似的技能。更致命的问题是执行链路的不可控。提示词方式下模型能看到的技能和实际能执行的函数之间是弱绑定的模型完全可能生成一个格式正确但函数库里根本不存在的参数名也可能在技能内部异常时没有任何感知。没有统一注册表就没有办法对技能做超时控制、权限校验、并发限制和依赖管理技能一多整个Agent就是一个只能靠运气工作的黑盒。所以我把技能系统拆成了四个独立的能力层**注册表Registry**负责技能的登记和发现**调用协议Protocol**负责参数校验和执行规范**编排层Orchestrator**负责多技能的组装和调度**观测层Observability**负责全链路的日志和评估。这一层拆完之后Agent的提示词里只需要保留一句你可以根据注册表中的可用技能来完成任务具体的技能清单由系统在运行时动态注入选型交给路由逻辑而不是纯粹的Prompt。1.1 技能与工具、插件的边界到底在哪很多人会把Skill、Tool、Plugin这几个词混着用但在我这套设计里它们分得很清楚。Tool是单体能力比如读取文件调用某个APIPlugin通常指一组Tool的打包比如飞书插件包含发消息、建群、拉人多个操作而Skill在Agent语境下我更愿意把它定义为**面向任务目标的、带语义上下文的能力单元**——Skill描述的是在什么条件下、为了达成什么目标、可以怎么操作而不只是能执行什么函数。举个例子发送邮件是一个Tool它的输入是收件人、标题、正文。撰写并发送客户问候邮件就是一个Skill它内部可能需要调用联系人查询Tool、模板渲染Tool、邮件发送Tool并且还带了一段如何根据客户等级选择语气的规则描述。所以技能系统设计的核心不是把函数包装成类而是把完成这类任务所需的知识和动作封装成一个可复用的单元这也是技能编排能成立的底层原因。2. 技能注册表一切能力的起点注册表是整个技能系统的交通枢纽它决定了Agent知道自己有哪些能力以及每个能力在什么条件下可以被调用。我的注册表核心数据结构如下dataclass class SkillEntry: skill_id: str # 全局唯一ID例如 com.weather.get name: str # 人类可读技能名 description: str # 模型可读的技能语义描述 parameters_schema: dict # JSON Schema格式的参数约束 entrypoint: Callable # 实际执行函数 required_permissions: list[str] # 执行所需权限 dependencies: list[str] # 依赖的其他技能ID timeout_seconds: float 30.0 is_active: bool True version: str 1.0.0每条技能登记的信息不是拍脑袋定的每一点都有用途description是给模型或路由器看的选型依据必须写清楚这个技能解决什么问题、在什么场景下用、不适合什么场景parameters_schema是用来拦截模型参数幻觉的最后一道防线required_permissions解决的是安全问题一个技能能不能访问用户通讯录、能不能发起对外请求必须显式声明而不是让Agent自由发挥。注册的方式我用了一段装饰器代码好处是技能开发者和Agent框架之间完全解耦新技能只要写一个函数、加一段装饰器、跑一次注册就能被全系统发现skills_registry {} def skill( skill_id: str, description: str, parameters_schema: dict, permissions: list[str] | None None, dependencies: list[str] | None None, timeout: float 30.0, version: str 1.0.0, ): def decorator(func): entry SkillEntry( skill_idskill_id, namefunc.__name__, descriptiondescription, parameters_schemaparameters_schema, entrypointfunc, required_permissionspermissions or [], dependenciesdependencies or [], timeout_secondstimeout, versionversion, ) skills_registry[skill_id] entry return func return decorator skill( skill_idorder.create, description创建新订单。适用于用户表示要购买、下单、结账等场景。不适用于查询历史订单和取消订单。, parameters_schema{ type: object, properties: { user_id: {type: string, description: 下单用户ID}, items: {type: array, items: {type: string}}, coupon_code: {type: string, description: 优惠码可选}, }, required: [user_id, items], }, permissions[order:write], timeout15.0, ) def create_order(user_id: str, items: list, coupon_code: str | None None): 实际下单逻辑 pass2.1 description字段是选型准确率的关键而不是参数Schema我在调整注册表的过程中发现一个反直觉的现象很多团队把精力花在把参数Schema写得很严谨却对description字段敷衍了事。但实测下来影响模型选型准确率最大的恰恰是这个看似不起眼的自然语言描述。好的description必须包含两个维度的信息触发条件和负向排除。我举个例子在做一个电商Agent时查询订单和取消订单是两个技能如果两个技能都只写订单操作模型大概率会在用户说帮我退掉昨天买的那个东西时选错。正确的写法是查询订单description查询订单状态和历史订单。适用于用户询问订单到哪了、列出订单列表。不适用于修改或取消订单。取消订单description取消未发货的订单。适用于用户要求取消、退款、不要了等场景。仅当订单未发货时可用。这种写法等于提前帮模型划清了决策边界。我做过一个对比实验同一批四十个技能把description从一句话功能描述升级成触发条件典型场景负向排除三段式之后选型准确率从71%提升到了94%这个提升幅度远比优化模型温度参数来得显著。另外description长度要克制。不要写超过120到200字太长了反而会稀释焦点而且会把上下文拖长。我的经验是能用场景词说清的就不要用长句解释负向排除最多写两条写多了模型反而容易懵。2.2 参数解析模型生成JsonSchema之外的第三态参数校验用的是标准JSON Schema但这里有个容易忽略的细节模型返回的参数可能存在三个状态——符合Schema、不符合Schema、以及极少数情况下参数直接缺失模型没生成参数块。很多实现只处理前两种第三种往往在解析阶段直接抛异常导致Agent整个任务失败。我的做法是在执行入口统一处理三个分支def execute_skill(skill_id: str, raw_args: dict, context: dict): entry skills_registry.get(skill_id) if entry is None: return {ok: False, error: skill_not_found} # 参数归一化把模型可能传错类型的字段做兼容 normalized_args _normalize_arguments(raw_args, entry.parameters_schema) # 校验 errors validate_schema(normalized_args, entry.parameters_schema) if errors: return { ok: False, error: invalid_arguments, details: errors, # 回传一个修正提示给上层让模型自己补参数再重试一次 retryable: True, } # 执行 result entry.entrypoint(**normalized_args) return {ok: True, result: result}参数校验失败时返回retryabletrue上层框架会自动把错误原因拼回提示词让模型重新生成一次参数。加了这一层校验失败重试机制后订票类技能的直接失败率降了60%很多模型笔误比如把phone_number拼错都能在重试环节被自动纠正。3. 从单技能到技能编排组合调用的工程化处理单技能能解决的问题有限真正体现Agent价值的是技能编排——让多个技能按照任务需要组合成一条执行链。我在这一阶段踩过最大的坑是一开始用自然语言让模型直接输出执行顺序指令然后我写了个解释器去执行。结果模型频繁生成不存在的技能依赖关系比如在取消订单之前调用了生成退款凭证技能但该技能在系统里压根没有。后来我把编排环节从让模型自由编排改成了让模型在受限的编排模板里做选择稳定性立刻上来了。具体做法是在编排层预置了四种执行模式——顺序执行、条件分支、并行执行、递归调用模型只需要从这几个模式里PICK而不是发明新流程。3.1 依赖解析不要把技能执行顺序交给运气有依赖关系的技能组合执行时必须做拓扑排序。比如发送促销邮件这个复合技能它依赖三个子技能先筛选用户分群、再渲染邮件模板、最后批量发送。三个子技能之间有严格的前后依赖不能并发。我写了一个简单的DAG调度器来处理这类链路。核心逻辑是from collections import deque, defaultdict def execute_chain(chain: list[str], args_bag: dict, executor, context): # 构建依赖图 graph defaultdict(list) indegree {} for skill_id in chain: entry skills_registry[skill_id] indegree[skill_id] 0 for skill_id in chain: entry skills_registry[skill_id] for dep_id in entry.dependencies: if dep_id in skills_registry: graph[dep_id].append(skill_id) indegree[skill_id] 1 # Kahn拓扑排序 queue deque([sid for sid in chain if indegree[sid] 0]) results {} while queue: current queue.popleft() args { **args_bag.get(current, {}), **{k: v for k, v in results.items() if k in skills_registry[current].dependencies}, } results[current] executor(current, args, context) for nxt in graph[current]: indegree[nxt] - 1 if indegree[nxt] 0: queue.append(nxt) return results这个调度器每次执行前先检查技能依赖是否能满足前序技能的结果会自动注入到后序技能的参数包里。效果最明显的一个场景是用户说帮我把上周四的订单全部导出来并邮件发给我之前模型可能会在导出还没完成时就发起邮件调用依赖解析加上之后这种时序错乱问题基本绝迹。3.2 并行执行收益最大、风险也最隐蔽没有依赖关系的技能比如查询天气和查询日历同时调用可以并行执行能大幅缩短Agent的响应时间。我的DAG调度器里加了一个independent_group检测如果当前队列里同时存在多个入度为零且互相之间没有依赖关系的技能就丢到线程池里并发执行。但这个优化有个隐蔽风险多个技能同时写同一个外部资源时会发生竞态。比如更新订单状态和发送通知看似独立但如果更新订单的数据库事务还没提交发送通知读到的订单状态就是旧的用户会收到一封订单已取消的通知但实际订单还在处理中。解决方法是在编排层加一个write_lock声明任何声明了写权限的技能在并行池里执行时同资源维度的其他技能自动降级为串行等待。实现很简单在SkillEntry里加一个lock_key字段调度器执行并发组前先检查lock_key有冲突就拆成顺序执行。这个改动让我的Agent在并发场景下的数据一致性投诉直接清零。4. 可观测性设计让技能调用从黑盒变成仪表盘技能系统上线之后我很快发现比功能实现更紧迫的问题是出了问题我怎么知道。最初没有可观测性的时候Agent说错话、做错事我只能翻海量聊天日志人工猜。后来我设计了一套三层观测体系这里分享下核心思路。4.1 结构化事件流每个技能调用必须有完整的生命周期第一层是结构化事件流。我把技能调用的全过程拆成了四个必经点位invoke开始执行、validate参数校验、exec函数运行、output结果返回。每个点位都输出一条结构化JSON日志用trace_id串起来{ trace_id: 8f3a9c2e, event: skill_exec, skill_id: order.create, phase: exec, latency_ms: 48, args_snapshot: {user_id: u_123, items_cnt: 2}, success: true }关键心得是不要把完整的参数值打进去尤其不记录用户隐私字段。日志里只保存参数结构和非敏感字段隐私字段一律打[REDACTED]。我之前在日志里完整记录了用户地址结果日志文件泄露后被安全同事约谈后来统一用脱敏快照才解决问题。4.2 选型归因复现模型为什么选了这个技能第二层是选型归因。这是我做可观测性时觉得最有价值、也最容易被忽视的一层。每次模型做技能选择时我在日志里不只是记录选了哪个技能还把候选技能列表、最终选中项、上下文片段摘要一并记录下来。这样做的直接好处是当模型选错技能时我能回溯完整决策链路。有一次用户在月底问我这月花了多少钱Agent选了查询余额而不是生成账单报表。通过归因日志我发现当时上下文里用户之前问过余额还剩多少模型把历史话题带进了新决策。这种上下文污染问题只有在选了归因字段之后才被发现和定位。4.3 技能质量评估用成功率在技能列表里自动排雷第三层是技能质量画像。每个技能连续统计四个指标触发次数、成功率、失败类型分布、平均等待时间。持续一周后就能看到某个技能的画像异常比如账单查询触发次数突然暴涨而成功率只有31%不用猜也知道是模型选型逻辑出了问题。更进一步我自己写了个小工具每天凌晨跑一遍技能评估任务把成功率低于50%、失败类型集中在invalid_arguments上的技能标为degraded系统在技能注入口自动降低它们的优先级必要时直接从活跃列表里暂时下架。做到了这层Agent的自我排雷能力才算真正建立起来。5. 冷启动与自适应技能多了之后路由就成了新的问题技能数量少的时候靠上下文注入全部技能清单还能接受。但当技能库膨胀到上百个一次性注入所有技能描述就开始撑爆上下文了。这个阶段我引入了动态路由的思路不是让模型每次都面对所有技能而是先做一轮技能推荐缩小候选集再让模型做最终选择。具体实现借鉴了RAG的思路。我提前给每个技能生成了向量索引用描述文本加上示例场景做Embedding每次新任务进来时先把用户当前请求做Embedding向量化然后召回最相关的Top N个技能作为候选集再把这N个技能的完整描述注入到模型上下文里。5.1 向量召回和精确匹配的取舍实测下来纯向量召回有两个问题一是相似描述的不同技能容易互相挤占比如发送邮件和发送短信语义太接近召回列表里占了两三个坑反而把真正需要的发送站内信挤出去二是向量召回偏向语义对精确关键词不敏感。我最后的方案是混合召回先用规则层做一遍关键词精确匹配比如技能描述里出现了订单这个词就触发强制召回再把剩余名额给向量检索的Top结果。这个组合把召回的准确率从纯向量的82%提到了95%左右。另外召回数量也有讲究候选集不是越大越好我一般是Top N落在5到8个之间少了容易漏多了又回到了上下文膨胀的老路。5.2 技能冷却与触发频控自适应还有一个很少被提到的点技能冷启动保护。新技能上线后如果立刻对全部请求开放一旦description写得不够好模型就会被误导产生大量错误调用。我给新技能设置了一周的观察期上线前三天只在特定测试会话里开放之后按流量比例逐步放量每次放量20%。新技能触发的错误会优先进入隔离分析队列而不是直接反馈给用户。这套机制帮我挡掉过不少次新技能上线即翻车的事故。6. 生产环境中踩过的坑与排查过程这部分集中写几个我在这套技能系统上真正踩过的坑。每个问题都花了不少时间定位写出来希望能帮大家少走弯路。6.1 上下文污染导致技能误选的完整根因链路开头提到的凌晨事故完整复盘后是这样的用户在昨天晚上问过今天天气怎么样Agent成功调用了天气查询技能。第二天早上用户问给王总发一封邮件确认下午的会议时间由于之前的天气查询结果还残留在上下文里并且系统提示词把天气技能描述排在了最前面模型在选型时把确认会议时间错误映射到了天气技能上生成了一个包含王总和下午两个字符串的天气查询参数然后技能参数校验通过因为天气技能的参数Schema里没有限制字符串是城市名还是人名最后查询了一个根本不存在的城市返回了空数据。这个问题三层叠加才爆发一是技能描述没有写负向排除二是参数Schema没有设置合理的maxLength或枚举约束三是系统提示词的技能排序策略存在缺陷。修复方式是给天气技能强约束输入必须符合城市名称词典把技能候选排序机制从固定顺序改成轮换语义匹配顺序同时每次技能调用结束后自动清理该技能在上下文中的临时数据。6.2 参数幻觉模型生成不存在的枚举值技能生成报表的参数Schema里format字段定义了三个枚举值pdf、xlsx、csv但用户说给我出个PPT格式的报表模型直接生成了format: pptx。参数校验把pptx挡下来了但当时系统的处理逻辑只返回参数错误没有给模型修正提示整个任务直接失败。现在的处理方式是参数校验失败时把错误信息和合法枚举值列表一起返回给模型并明确提示请根据允许的取值范围重新生成参数。加了这层之后这类错误大部分能在一次重试中解决真正无法解决的只剩那种模型确实理解不了业务约束的场景。6.3 技能递归调用死循环不要忽略监控上限有段时间我需要一个自动归类邮件的技能它在实现里调用了读取邮件原文技能而读取邮件原文在没人注意的情况下又反向调用了自动归类邮件。一次测试中一个特殊格式的邮件让两个技能互相触发形成了一个死循环直到超时被掐断。后来我在编排层增加了技能调用深度上限默认5层并且在每个技能执行前检查当前调用链里是否已经存在同名的技能ID存在就立刻报错这才彻底杜绝了递归死循环的问题。这个检查非常简单但往往要到出了事故才会想到加。7. 一套可复用的Agent技能系统搭建清单最后把这套系统的落地顺序整理成一个清单按优先级排列新项目可以照着搭先建注册表再写技能技能注册表是整个系统的地基哪怕只有三个技能也必须走注册流程不要为了省事走直接映射函数的快路径。统一签名和参数规范所有技能入口统一用**kwargs接收参数内部自己解析不要在技能函数里写死位置参数否则编排层的参数注入会非常痛苦。参数校验必须独立于技能逻辑校验放在编排层统一做不要让每个技能自己校验一遍否则同一种错误会有多种表现形态排查时非常混乱。默认开启可观测性从第一天起就在invoke和output两个点位输出日志不要等技术债堆积到几十个技能之后才补。先做串行编排再做并行并行是个优化项不是必需项。先保证串行链路完全正确再考虑用并发节省时间。技能数量超过三十个就上动态路由当技能描述总量超过一万Token时不要硬塞上下文了把动态路由和技能推荐提前安排上。还有一个容易被忽略的软性问题技能描述文档要作为代码的一部分进行评审就和代码评审一样。很多问题比如描述歧义、负向排除缺失、枚举约束过宽在Review阶段就能发现一大半。我对团队的要求是每次技能变更必须同时更新description和测试用例没有测试用例的技能不允许合并。这套系统的价值不在于某一个单独模块多先进而在于它把Agent从靠模型临场发挥变成了在已知边界内有序执行。做Agent应用越往后越会发现可控性比智能感重要得多。技能系统正是承接这份可控性的关键一层希望这份复盘对正在做同类项目的你有帮助。
返回列表