ARTICLE DETAIL

资讯详情

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

Agent技能体系设计:从协议注册表到调度器的完整落地实践

Agent技能体系设计:从协议注册表到调度器的完整落地实践 最近在复盘一个agent项目时我把一套叫agent-skills的技能库方案完整重写了一遍。坦白说项目前两个月的主要精力都花在跟模型的“发挥不稳定”作斗争指令藏在超长的system prompt里技能边界模糊改一处要牵动全局线上时不时就出现漏步骤或者串功能的情况。后来我把每个能力拆成独立技能统一注册、统一调度模型只负责选技能只负责执行整个系统的稳定性才真正稳下来。这篇文章会把我在设计技能协议、实现注册表、接入调度器以及处理各种坑的过程完整写出来给正在做agent应用、尤其是准备做多技能架构的朋友做一份参考。1. 为什么Agent需要一套“技能体系”而不是堆Prompt1.1 堆Prompt的老路是怎么一步步走死的刚开始做agent的时候几乎所有人都会走同一条捷径把所有能干的事写进system prompt让模型自己在上下文里找答案。比如告诉它“你可以查天气、可以写周报、可以发邮件、可以查询订单”再附上每个功能的调用格式和注意事项。早期功能少这条路线跑得很顺模型表现得像个聪明的实习生。但功能一多问题就接踵而至。我那个项目做到第四周system prompt从几百字膨胀到了六千字模型开始出现三种典型的失效模式第一指令互相污染查天气的注意事项会跑到写周报的流程里去第二漏步骤尤其是多步骤流程模型经常做完第一步就直接输出忘了第二步需要调用另一个接口第三上下文预算爆炸每次请求都要携带全部指令成本和延迟肉眼可见地涨。这还不是最致命的。最致命的是你根本没法定位问题。线上跑出一个错误结果你分不清是模型理解错了、参数传错了、还是指令描述本身有歧义。没有一个地方能独立查看某个功能的定义和测试结果所有东西都糊在一团prompt里。1.2 agent-skills想解决的四个具体痛点我后来整理了一下把项目里所有痛苦归结成四类痛点这四类痛点就是技能库方案要解决的目标能力不可发现模型在超长上下文里找不到自己该用哪个工具或者找到了相似描述的工具却选错。能力不可复用同一个“生成周报”的逻辑在A项目里写了一遍到B项目又要重写换了模型更是全部作废。能力不可评测没有独立的入口对单个功能做回归测试只能端到端地跑完整流程出错了不知道是哪一环的问题。能力不可治理谁改了这个功能的逻辑当前线上用的是哪个版本技能调用成功率是多少这些问题在纯prompt方案里完全无解。有了这四类痛点答案就清晰了把能力从“提示词”里解放出来做成一份独立的、可注册的、可被模型发现的技能资产。这就是agent-skills的核心思路。1.3 技能一词在这里到底指什么需要先明确一下这里说的“技能”和平时讲的“function calling”略有不同。function calling通常指一个函数声明模型根据函数定义生成参数并调用。技能是更大粒度的封装它在函数调用外面又包了一层包括对模型可见的描述层技能什么时候该用、什么时候不该用、需要什么参数、会返回什么结果。对系统可见的实现层真实执行的代码或流程可以是调用内部API可以是跑一段Python脚本也可以是编排多个函数。对运行时可见的元信息层技能名称、版本、依赖、调用权限、成功回调、失败处理。打个比方function calling相当于给了模型一本工具目录技能库则相当于给工具目录配了使用说明书、质检报告和维修手册。模型不需要理解工具内部怎么运转只需要知道“什么时候该拿哪把工具”。我建议的架构里模型永远只做一件事根据用户请求在技能注册表里挑选最合适的技能并生成符合要求的调用参数。挑选之外的事情全部交给技能执行器。这套分工让模型更轻松也让工程师能在完全脱离模型的情况下调试每一个技能。2. 技能协议设计先定义标准再写实现代码2.1 技能元信息五个字段缺一不可代码写之前先把协议定清楚。我定义技能元信息时只保留了五个必填字段不多不少字段类型作用示例namestring全局唯一技能名用户/模型引用它的IDweekly_reportdescriptionstring面向模型的自然语言描述说明用途和触发条件汇总指定时间范围内的周报生成摘要与待办清单parametersobject调用技能所需的参数声明遵循简化版JSON Schema{ start_date: { type: string } }constraintsarray使用限制比如需要管理员权限、超时时间、最大输入长度[require_auth:admin, timeout:5s]versionstring语义化版本号技能更新时递增1.2.0这里我想特别强调description这个字段的设计。很多人会把description写成一堆形容词比如“这是一个强大的汇总工具可以帮助用户快速整理数据”结果模型逮着什么都调它。正确写法应该是“触发条件 输入要求 不做什么”。我在实际项目里加的description经常带一句“不要在其他场景使用本技能”这能明显降低误触发率。2.2 参数声明JSON Schema的子集够用就行参数声明我没用完整版JSON Schema那玩意儿字段太多模型和校验器都容易晕。我只保留几种字段类型string、number、integer、boolean、array、object外加几种常用于约束的子字段required、properties、items、enum、description、pattern。一个完整的技能参数声明长这样{ type: object, required: [start_date, end_date], properties: { start_date: { type: string, description: 开始日期格式YYYY-MM-DD, pattern: ^\\d{4}-\\d{2}-\\d{2}$ }, end_date: { type: string, description: 结束日期格式YYYY-MM-DD, pattern: ^\\d{4}-\\d{2}-\\d{2}$ }, include_done: { type: boolean, description: 是否包含已完成的任务, default: true } } }这里有个经验参数的description一定要写给模型看要写清楚值的格式和取值范围。模型看不懂“日期范围”这种模糊描述它需要的是“格式YYYY-MM-DD”这种精确约束。如果参数比较多一定要用required把必填项和选填项分隔开否则模型经常漏传。2.3 技能分类工具型、流程型与知识型各有各的脾气虽然协议统一但我建议内部还是把技能分成三类因为它们的调度策略和实现方式差别很大工具型技能单次调用、无状态、结果直接返回给用户。典型例子是查天气、查汇率、算运费。这类技能最好写参数少返回快。流程型技能多步骤、有内部状态、可能需要访问外部系统。比如“创建订单并通知仓库”它内部要调多个接口还可能要做事务回滚。这类技能需要额外加超时控制和幂等设计。知识型技能本质上是一个检索或问答能力从一个知识库里找出答案并返回必须附上引用来源。比如“查询公司报销制度”。这类技能的返回格式里我强制要求带一个sources字段。分类这事不是写文档给别人看的是写给调度器用的。后面我会讲调度器对不同分类的技能候选排序权重、超时策略、结果返回方式都不一样。2.4 版本与指纹避免技能更新时出现脏差分技能不是写完就不动了业务一改技能就得跟着改。这里最容易踩的坑是模型上一秒还在用旧版技能下一秒技能实现更新了两边对不上。我在协议里加了一个“技能指纹”机制每个技能的元信息文件里有一个fingerprint字段每次技能更新时必须重新计算计算规则是对技能名、版本号、描述、参数声明做一次稳定哈希。注册表启动时会把指纹缓存下来调度器调用技能前会先对比指纹。如果指纹不匹配说明元信息更新了但实现代码没同步或者反过来这时候直接拒绝调用走告警通道。这个设计看似多了一道校验实则帮我挡掉了很多“现场排查半天发现是版本错位”的问题。3. 注册表与调度器的落地实现代码级拆解3.1 目录即约定一个技能装进一个文件夹我采用的是“约定优于配置”的目录结构。每个技能一个文件夹里面放三样东西元信息文件、执行代码、附加资源。标准结构如下skills/ weekly_report/ skill.json main.py assets/ template.md currency_exchange/ skill.json main.py为什么不用数据库存技能定义说实话用文件夹有一个巨大优势版本控制和代码评审变得极其自然。每个技能的变更就是一次git提交diff清晰可见review可以按技能维度进行。加上文件夹天然支持多环境部署开发环境、测试环境、生产环境只要用不同的技能根目录就行。3.2 注册表核心流程扫描、校验、建立索引注册表的核心逻辑其实不难三步走第一步扫描。遍历技能根目录找出所有skill.json文件。第二步校验。用上面那套简化JSON Schema做格式校验检查必填字段、参数声明、指纹一致性。校验失败的技能直接标记为disabled不影响其他技能加载。第三步建立索引。这一步是注册表的价值所在。不能只是把所有技能塞进一个列表要建立可查询的索引。我用了倒排索引把每个技能的description和参数description拆成语义token建立关键词到技能ID的映射。这样调度器查询时不用遍历全部技能。注册表代码核心部分长这样import hashlib import json from pathlib import Path class SkillRegistry: def __init__(self, root_dir: str): self.root_dir Path(root_dir) self.skills {} self.index {} def load(self): for skill_file in self.root_dir.rglob(skill.json): skill_id self._validate(skill_file) if skill_id: self._index_skill(skill_id) return self.skills.keys() def _validate(self, skill_file: Path): data json.loads(skill_file.read_text(encodingutf-8)) required {name, description, parameters, version} if not required.issubset(data.keys()): print(f[registry] skip {skill_file}, missing fields) return None fp hashlib.sha256( json.dumps(data, sort_keysTrue).encode() ).hexdigest()[:16] data[fingerprint] fp self.skills[data[name]] data return data[name] def _index_skill(self, skill_id: str): desc self.skills[skill_id][description] tokens desc.replace(,, ).split() for token in tokens: self.index.setdefault(token, set()).add(skill_id) def search(self, query: str, top_k: int 8): scores {} tokens query.replace(,, ).split() for token in tokens: for skill_id in self.index.get(token, []): scores[skill_id] scores.get(skill_id, 0) 1 ranked sorted(scores.items(), keylambda x: -x[1])[:top_k] return [sid for sid, _ in ranked]3.3 调度策略先规则过滤再交给模型挑选这里到了整个方案里最有争议的地方技能到底谁来选让模型自由选还是用规则硬编码我的结论是两阶段结合。规则负责过滤模型负责挑选。规则过滤解决的是“候选集太大”的问题。如果注册表里有80个技能全部塞给模型会让它选择困难也会消耗大量context。先用简单的关键词匹配和权限校验把候选集缩到8个以内再把这8个技能的name和description喂给模型让模型输出最终选中的技能名和参数。这样做还有个好处可以在规则层做安全约束。比如某个技能只有管理员能用规则层直接把它从候选集里剔除模型根本没有机会选它。这是让AI系统可控的关键。如果规则过滤后候选集为空我建议不要硬调某个技能而是直接返回一个“技能未匹配”的状态给对话层让助手自然地说“我暂时没有这个能力”。宁可拒绝也别瞎猜这是我在生产环境里学到的教训。3.4 最少可用代码一个能跑通的技能调度器调度器本身不复杂复杂的是各种异常分支。我先写一个最精简但能跑通的版本import json class SkillDispatcher: def __init__(self, registry: SkillRegistry): self.registry registry def dispatch(self, query: str, context: dict): candidates self.registry.search(query, top_k8) if not candidates: return {status: no_match, message: 未找到匹配技能} prompt self._build_selection_prompt(candidates, query) chosen self._llm_select(prompt) # 调用你的模型接口 skill self.registry.skills.get(chosen) params self._llm_build_params(skill, context) result self._execute(skill, params) return {status: ok, skill: chosen, result: result} def _execute(self, skill, params): module __import__(fskills.{skill[name]}.main, fromlist[run]) return module.run(params)这个版本我刻意省略了参数校验、超时、重试、日志但骨架是对的。真正用的时候我在_execute前多加一道参数schema校验在_execute外再加一层异常捕获和fallback。骨架比花活重要先把链路走通再谈优化。4. 实战案例给内部助手装一个“周报汇总”技能4.1 把一句业务需求翻译成技能定义拿我们项目里一个真实场景举例给内部运维助手新增一个“汇总周报”的能力。业务上的原始需求只有一句话——“把大家上周写的周报汇总一下按人归类再生成一段摘要”。这句话翻译成技能定义时我把它拆成四个要素输入是时间范围输出是汇总文本和待办清单约束是只能查企业内网版本是1.0.0。写成skill.json就是下面这样{ name: weekly_report, version: 1.0.0, description: 汇总指定时间范围内的周报内容。当前置条件包括用户明确要求汇总周报、且提供了时间范围时使用。不要在未提供时间范围时调用。, parameters: { type: object, required: [start_date, end_date], properties: { start_date: { type: string, description: 开始日期格式YYYY-MM-DD, pattern: ^\\d{4}-\\d{2}-\\d{2}$ }, end_date: { type: string, description: 结束日期格式YYYY-MM-DD, pattern: ^\\d{4}-\\d{2}-\\d{2}$ } } }, constraints: [network:internal, timeout:10s] }注意description里的最后一句话是我特意加的负面约束。实测下来如果description里没有“不要在未提供时间范围时调用”这句模型经常会在用户只是随口提了一句“周报写得好烦”的时候触发技能。加完这句误触发率下降了大概一半。4.2 技能执行体一个不依赖外部服务的示例技能执行代码我写得很简单重点是展示输入输出契约。真实场景里这里会去调内部周报系统的API但为了演示我先用本地文件模拟import json def run(params: dict) - dict: start_date params.get(start_date) end_date params.get(end_date) # 模拟从周报系统拉取数据 raw_reports [ {user: 张三, content: 完成了登录模块的重构, date: 2024-11-11}, {user: 李四, content: 修复了支付超时的问题, date: 2024-11-12}, ] filtered [ r for r in raw_reports if start_date r[date] end_date ] # 按人归类 grouped {} for r in filtered: grouped.setdefault(r[user], []).append(r[content]) summary 本周共 str(len(filtered)) 条周报主要涉及模块重构和问题修复。 todos [跟进支付超时问题复测] return { grouped_reports: grouped, summary: summary, todos: todos, sources: [ f周报系统 {start_date} 至 {end_date} ] }这里有一个设计细节无论技能内部执行成什么样返回给上层的永远是JSON对象至少包含status、data和sources三个字段。sources是给对话层做可信引用用的用户问“你怎么知道的”助手能指着来源回答这在企业内部场景几乎是刚需。4.3 接入后的调度日志与效果观察接入技能库之后整个调用链路就变成了用户说“汇总一下这周的周报”查询词被送到注册表关键词匹配命中weekly_report候选集里就它一个。模型只需生成两个参数start_date2024-11-11、end_date2024-11-17。调度器执行技能返回汇总结果。我在日志里加了一条关键观测数据同一场景下方案改造前模型生成回复的平均耗时是4.2秒改造后技能直接执行、模型只负责组织语言平均耗时降到2.1秒整整砍了一半。原因很简单模型不再需要在6000字的指令里找到底怎么调用周报接口这个检索过程被注册表和规则层替代了。5. 真实项目里踩过的坑以及我现在的调参习惯5.1 描述写得太“文学化”技能被反复误触发第一版技能库里有个“订单查询”技能description我写的是“这是一个非常好用的订单查询工具可以帮你看到所有你想知道的订单情况”。听起来没问题结果上线后被疯狂误触发。用户问“你好”它触发问“今天天气怎么样”它也触发因为这个描述里的“帮你”“所有你想知道”这些词跟太多query匹配上了。后来我把所有技能的description都重写了一遍统一成“当用户【明确表达】需要X时使用否则不要使用”。还是那句话明确的负面约束比正面的赞美词有用得多。现在我的技能描述平均长度大概90到120字正面触发条件占六成负面约束占四成误触发率降到了可接受的范围。5.2 参数校验松散模型开始编参数有段时间技能执行器经常报错排查发现模型经常生成不符合要求的时间格式比如把start_date传成“这周”或者传一个null值。问题出在参数声明里我写了pattern但没在运行时校验。后来我在调度器里加了一个硬性的运行时参数校验不通过就直接返回“参数错误”并附上正确格式示例让模型有机会自我修正。这一步加上之后技能调用成功率从82%提到了91%。提升幅度最大的其实是那几类日期、金额、枚举值参数——凡是格式固定的字段都要用pattern和enum卡死。5.3 技能状态污染共享上下文的连锁反应项目早期我为了图省事让多个技能读写同一个上下文对象。结果出现了一个特别诡异的bug用户先查询了订单再生成周报周报里居然带出了订单查询时的筛选条件。技能之间共享状态在一次多技能调用里造成了连锁污染。修复方案是加一条铁律技能执行必须无状态。每次调用只接收调度器显式传入的参数只返回调度器显式接收的结果内部不得读写任何全局变量。需要状态的时候就通过参数传参数不存在的状态就不要用。这个约束让技能代码写起来不那么“自由”但排错成本直线下降。5.4 评测只盯着成功率掩盖了交互质量的恶化技能库上线后我配了一个监控面板主要看成功率。一开始数字很漂亮95%往上。后来随手翻了翻用户真实对话记录发现不少调用虽然“成功”了但结果根本不是用户想要的。模型选对了技能但为了一句包含歧义的请求硬是生成了不合理的参数技能执行“成功”了用户体验却很糟糕。从此我不再只看成功率还加了三项指标用户对结果的点赞/点踩数据、技能返回后被对话层改写的情况、以及老用户复购率即同一用户一周内再次向同一技能求助的比例。评测一个技能系统要同时看“选得对不对”“参数填得好不好”“结果有没有被用户认可”只看一道指标一定会被骗。5.5 一套我目前一直在用的技能库运行基线最后分享一组我在多个项目里反复验证过的运行基线供你起步时参考单个技能的description长度控制在150字以内包含触发条件、参数要求、负面约束三段。候选技能集合并入模型context的数量不要超过8个超过就加强规则过滤。技能超时默认5秒外部依赖多的流程型技能放宽到10秒。参数校验规则必须写进协议并在运行时二次校验不能只靠模型自律。每个技能的调用日志独立保存按技能名做归因而不是只记在会话日志里。新技能上线前必须用至少20条历史真实请求做回放测试确认不会误触发、不会漏触发。这套基线不是理论推出来的是每一次线上事故之后磨出来的。技能库的价值在于让agent的每一项能力都变得可管理、可观测、可追溯——这比单纯让模型“看起来聪明”重要得多。如果你正准备给自己的agent建一套技能库我的建议是先别急着写业务代码把技能协议和注册表这层地基打好。地基歪了后面每加一个技能都是给自己埋一颗雷。等协议稳定了技能越加越多的时候你会感谢当初花了三天时间做标准化的自己。
返回列表