ARTICLE DETAIL

资讯详情

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

Agent技能管理实战:从注册表设计到分发执行

Agent技能管理实战:从注册表设计到分发执行 做Agent开发这半年我在生产环境里遇到的最痛问题不是模型理解能力差而是模型压根不知道该在什么时候调用哪个能力。这个问题在团队里有个统一的黑话叫“skills 管理”也就是现在圈子里经常提到的 agent-skills。简单说它就是一套让大模型能看得到、选得对、调得动各种外部能力的骨架系统你把能力注册成一个个带描述、带参数、带处理函数的技能模型在推理时看到技能目录选中并传入参数系统分发执行后把结果返回给模型继续推理。这套东西解决的问题很直接Agent 不再是一个只会聊天的对话框而是变成了一个能真正干活的执行体。适合正在搭建 Agent 应用、被工具调用和任务编排搞得焦头烂额的开发者参考。1. 动手之前先想清楚Agent的“技能”到底是什么1.1 技能、工具与工作流的边界很多同学刚开始做 Agent 时会把技能skill、工具tool、工作流workflow三个概念混在一起。我一开始也分不清后来被业务逼着梳理才总结出一套比较顺的判断标准。工具是最小单位本身没有语义就像一把锤子它只管“锤”不关心你现在是要钉钉子还是砸核桃。放到代码里一个 HTTP 请求封装、一个数据库查询函数、一个文件读写操作都是工具。技能则是在工具之上包了一层“会使用工具的上下文”它包含触发条件、输入输出的定义、使用边界和背后的处理函数。比如“查询天气”是一个技能它内部可能要调用多个工具定位城市、请求气象API、解析返回结果、把温度单位统一。对模型来说技能是一个能完整理解并执行的最小任务单元。工作流又不一样它是一组技能的编排序列强调的是“先后关系”和“分支条件”。比如“报销审批”这个工作流可能是解析邮件附件 → 提取金额和发票信息 → 校验是否符合规则 → 推送审批通知。每一步本身可以是一个技能但串起来之后整体是一个有确定走向的业务过程。在规划 agent-skills 时边界想不清楚就会出问题如果把工作流当成技能注册调用时会因为参数太多、状态太杂而让模型频繁出错如果只注册裸工具模型又缺乏“什么时候用”的判断依据。我的经验是技能层解决“模型怎么选”工作流层解决“选完之后怎么串”工具层只解决“最后怎么执行”。三者职责清晰后面维护才不会打架。1.2 为什么需要一套统一的注册体系没有注册体系的时候团队常见做法是把所有函数直接塞进 system prompt 里或者写死在代码的 if-else 里。这样做在小 Demo 阶段没问题一旦技能数量超过十个问题就全来了。首先是可发现性问题。模型只能“看到”你塞给它的那部分信息如果技能列表是分散写在各处的它根本不知道系统里还有“查询库存”这个能力于是遇到相关任务时只能胡编或者直接拒绝。我见过最典型的场景是用户在对话里问“帮我看看还有没有货”模型回答“我暂时无法查询库存信息”其实系统里早就接了库存接口只是没告诉模型。其次是可测试性问题。技能散落在代码各处你没法一次性对全部技能做回归验证。每次新增一个技能都要担心会不会影响其他技能的描述和触发逻辑。有了注册表所有技能的定义集中在一个列表里写单测、做评估、算 token 消耗都方便得多。最后是演进问题。Agent 的技能一定是从少到多长出来的不是一开始设计出来的。今天三个技能能跑通明天业务方加了新需求就要加第四个、第五个。如果没有注册机制每加一次技能可能都要重写一遍工具调用分发逻辑。而注册表的本质就是统一入口注册、查询、转换、执行、日志全走同一套接口新技能只是往表里多填一行配置。2. 技能设计的第一步注册信息怎么写2.1 五个核心字段在 agent-skills 体系里每个技能本质上是一份结构化定义。我给技能注册设计的核心字段如下字段类型作用编写要点namestring技能的唯一标识模型调用时使用的 ID动词开头全小写下划线分隔descriptionstring向模型说明技能用途、触发场景、使用限制核心中的核心直接影响选型正确率parameterslist技能需要的入参定义遵循 JSON Schema 风格字段名、类型、必填、枚举、描述都要完整handlercallable实际执行技能逻辑的函数保持无状态入参由注册表统一注入tagslist用于检索和归类的辅助标签方便按域筛选也帮助关键词匹配name 看起来简单但命名风格很容易埋坑。我吃过亏的是用驼峰命名比如queryStock模型偶尔会生成query_stock这种下划线版本导致分发时找不到对应技能。后来统一成小写下划线并在注册时做了归一化处理模型返回的 name 先转小写再去查表基本不会再出现这种找不到函数的问题。tags 字段很多人会忽略但它是支撑“技能检索”和“按域裁剪”的关键。比如天气、日历、邮件这三类技能打上weather、calendar、email的标签后在做轻量分类时可以直接把无关技能排除掉减少注入 prompt 的体量。2.2 技能描述才是最大的杠杆如果只允许我优化一个字段我会毫不犹豫选 description。模型选择技能的行为本质上是一次“基于文本匹配的决策”它不看你函数内部写得多优雅只看你给它的描述能不能和当前用户意图对上。一个好的技能描述应该包含三个部分这个技能干什么、什么场景下触发、什么情况下别用。拿“查询天气”举例查询指定城市当前天气和未来三天的天气预报。当用户询问天气、温度、降水、风力、出行是否需要带伞等问题时使用。参数 city 必须是标准中文城市名例如“北京”“上海”。如果用户没有指定城市且无法从上下文中推断请先向用户询问城市后再调用。这段描述把“触发条件”和“参数规则”都写进去了模型在大多数情况下都能正确触发。相比之下如果只写“查询天气”当用户问你“今天适合跑步吗”模型可能不知道要不要调天气技能或者调了但传不进城市名。描述里的“适合带伞”这种场景化触发词其实就是给模型的提示让它更容易把你挂载的能力和真实语言表达对上。反面的典型也会导致误触发。比如我做过一个“获取当前时间”的技能描述里没写边界结果用户说“我现在没时间聊这个”模型也调用了。后来我在描述末尾补了一句“当用户表达的是‘没空、没时间’等主观状态时不要调用”误触发率立刻降了下来。还有一个容易被忽视的点description 写太长也不行。模型的注意力有限大量描述挤在一起反而稀释关键信息。我建议单条描述控制在 80~150 个汉字左右把最核心的触发场景和参数规则说清楚就够了冗余的细节放到参数描述里。2.3 参数设计要能管住模型的自由发挥模型生成参数是一个自由度极高的过程如果你不给约束它能给你生成出各种离谱的值。比如城市名用户说“去深圳出差”模型可能给你传Shenzhen也可能传深圳甚至可能传深圳市。如果你的 handler 只认一种格式调用就会失败。解决方式是三层约束联动。第一层是参数描述里写清楚格式预期比如必须是标准中文城市名例如北京、上海不要使用拼音或英文。第二层是用 JSON Schema 的 enum 约束可选值比如审批状态字段只允许approved、rejected、pending三个值。第三层是在 handler 入口做防御性校验非法参数一律返回结构化错误而不是抛出异常让整个对话流程崩溃。这里要提醒一点不要过度约束。我之前给某个技能加了一堆正则校验稍微有点偏差就拒绝执行结果模型重试好几次都过不了用户体验非常差。正确的做法是容错优先handler 内部对入参做归一化处理比如城市名去掉省份后缀、时间统一转成时间戳、数字保留两位小数实在无法处理时才返回带retry: true标记的错误给模型。3. 从零搭建一套可用的 agent-skills 技能库3.1 目录结构怎么组织我推荐一种按领域分目录、按技能建文件的结构它能让技能库在规模变大之后依然可维护agent-skills/ ├── src/ │ ├── registry.py # 注册表核心 │ ├── dispatcher.py # 执行分发 │ ├── openai_adapter.py # 转换成模型可识别的 tools 格式 │ └── skill_base.py # 基础数据类 ├── skills/ │ ├── weather/ │ │ ├── __init__.py │ │ └── query.py # 一个文件一个技能 │ ├── calendar/ │ │ ├── __init__.py │ │ └── schedule.py │ └── email/ │ ├── __init__.py │ └── digest.py ├── tests/ │ ├── test_registry.py │ └── test_skills.py └── main.py # 应用入口初始化注册表一个文件一个技能的好处是业务方想新增能力时照着现有文件的骨架复制一份只需要改掉描述、参数和处理逻辑然后在__init__.py里调一次register不需要知道注册表内部实现。这个模式在团队协作时特别好用新人几乎不需要培训就能上手注册新技能。3.2 注册表核心实现注册表的数据结构不需要复杂关键是把定义和执行两部分干净地拆开。下面是一个精简可用的实现基于 dataclass 定义技能结构用字典做存储# skill_base.py from dataclasses import dataclass, field from typing import Any, Callable, Optional dataclass class ParamSpec: name: str type: str string required: bool True description: str enum: Optional[list[str]] None dataclass class SkillDefinition: name: str description: str handler: Callable[..., Any] parameters: list[ParamSpec] field(default_factorylist) tags: list[str] field(default_factorylist)# registry.py from typing import Optional class SkillRegistry: def __init__(self): self._skills: dict[str, SkillDefinition] {} def register(self, skill: SkillDefinition) - None: name skill.name.strip().lower() if name in self._skills: raise ValueError(f技能 {name} 重复注册) self._skills[name] skill def get(self, name: str) - Optional[SkillDefinition]: return self._skills.get(name.strip().lower()) def all(self) - list[SkillDefinition]: return list(self._skills.values()) def search(self, query: str, top_k: int 5) - list[SkillDefinition]: tokens set(query.lower().replace(, ).replace(,, ).split()) scored [] for skill in self._skills.values(): text f{skill.name} { .join(skill.tags)} {skill.description}.lower() score sum(1 for token in tokens if token in text) scored.append((score, skill)) scored.sort(keylambda x: x[0], reverseTrue) return [skill for score, skill in scored[:top_k] if score 0]search是一个轻量关键词匹配实现如果你有条件接入向量检索可以用 embedding 算相似度替换它。但我的实践感受是关键词匹配在技能少于 50 个时完全够用而且速度更快、逻辑可解释。真正做语义检索的收益要到技能规模很大时才明显初期没必要引入额外组件。3.3 让模型“看到”全部技能模型看不到 Python 对象它只能看到 JSON 结构。所以注册表的下一个重要功能是把技能定义批量转换成模型 API 所需的 tools 格式。以 OpenAI 风格的 function calling 为例# openai_adapter.py def to_openai_tools(skills: list[SkillDefinition]) - list[dict]: tools [] for skill in skills: properties {} required [] for param in skill.parameters: prop {type: param.type, description: param.description} if param.enum: prop[enum] param.enum properties[param.name] prop if param.required: required.append(param.name) tools.append({ type: function, function: { name: skill.name, description: skill.description, parameters: { type: object, properties: properties, required: required, }, }, }) return tools这份 tools 列表会作为 API 请求的一部分传给模型。模型拿到之后如果判定某个技能适合当前任务就会在返回结果里给出结构化的tool_calls其中包含技能名和参数 JSON。这里最需要注意的通告长度问题。每多一个技能tools 列表就会变长占用的上下文 tokens 线性增加。一个 20 个技能的系统光技能声明可能就要吃掉三四千 tokens。所以实际项目中我很少直接把registry.all()全量转成 tools而是先做一轮裁剪根据当前会话的关键词调用search找到候选技能再传给模型。这样既节省上下文又能降低模型挑选时的干扰。3.4 分发执行与结果回传模型返回 tool_calls 之后系统需要找到对应的技能并执行。分发器要做的就是用技能名查注册表解析参数调用 handler然后把执行结果包装成模型可以继续推理的内容# dispatcher.py import json from typing import Any class ToolCall: def __init__(self, name: str, arguments: dict): self.name name self.arguments arguments def dispatch(registry: SkillRegistry, tool_call: ToolCall) - dict: skill registry.get(tool_call.name) if skill is None: return { success: False, error: f技能 {tool_call.name} 不存在, retry: False, } try: result skill.handler(**tool_call.arguments) return {success: True, result: result} except TypeError as exc: return { success: False, error: f参数错误: {exc}, retry: True, } except Exception as exc: # 业务异常统一包装避免堆栈直接暴露给模型 return { success: False, error: f执行失败: {exc}, retry: False, }执行结果回传给模型时文案要尽量结构化。模型需要从结果里提取信息来生成答案所以返回的 JSON 里字段名要清晰。比如查询天气返回{temp: 28, condition: 多云}模型很容易转述如果返回一大段没有结构的字符串模型也可能答出错。一个非常实用的细节retry字段。当技能返回参数错误、调用条件不满足这类可恢复的异常时置为true上层对话循环收到后会把错误信息连同原始对话一起再发给模型让它重新选择合适的参数发起调用。这比直接结束流程体验好得多模型通常能在第二次或者第三次尝试中自我纠正。3.5 技能编排的实战写法单一技能调用能解决“点状需求”但真实业务更多是“链状需求”。比如报销流程用户发来一封邮件附件系统需要先解析附件提取金额再校验金额是否符合规则最后推送给审批人。这个过程如果用模型自己完成全部编排会不稳定我的做法是让模型只做第一步判断和参数收集流程串联放在代码侧。# workflow.py from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout def expense_workflow(email_body: str) - dict: step parse_email try: email_content skill_parse_email.run(email_body) expense_info skill_extract_expense.run(email_content) valid, reason skill_validate_amount.run(expense_info[amount]) if not valid: return {status: rejected, reason: reason} skill_send_approval.run(expense_info) return {status: approved, info: expense_info} except Exception as exc: return {status: failed, step: step, error: str(exc)}代码侧编排有几个明显优势流程固定每一步的输入输出可以在编译期就检查出错定位快step字段直接告诉你卡在哪一环更重要的是它不消耗模型的推理 tokens成本低且结果可预期。为了让流程更健壮我给所有外部调用统一加了超时控制def run_with_timeout(handler, timeout: int 10, *args, **kwargs): with ThreadPoolExecutor(max_workers1) as executor: future executor.submit(handler, *args, **kwargs) try: return future.result(timeouttimeout) except FutureTimeout: future.cancel() raise TimeoutError(f技能执行超过 {timeout} 秒)超时控制在真实世界中非常重要。某个技能调第三方 API 时网络卡住如果不设超时整个 Agent 会话会卡死在等待中用户觉得“这个机器人死了”。设了超时后至少可以给用户返回一句“这个操作超时了请稍后重试”体验完全不一样。4. 实操中踩过的坑与排查技巧实录4.1 模型总是选错技能怎么办这是我在 agent-skills 里遇到最多的现象用户要查快递模型却调用了订单查询。排查这类问题第一件事不是去看模型参数而是去看你自己发的 tools 列表。打印出来模拟模型视角如果两个技能描述里有相同的关键词、相近的触发场景模型选错几乎不可避免。我处理选错问题的顺序是把候选技能和匹配分数打印到日志里。如果两个技能分数接近说明描述区分度不够。重写 description把彼此的边界写清楚。比如订单查询强调“查询订单状态、物流轨迹”快递查询强调“查快递单号对应的配送进度”并分别补充“不要用于另一个场景”的负例描述。如果还是冲突就合并技能。有时候用户根本分不清订单和快递的区别强行拆成两个技能反而是给模型挖坑不如合一个“查订单及物流”的技能内部自己处理状态。日志这个习惯一定要养成我在生产环境里排查过的所有选型问题几乎都是靠日志锁定的。每次调用时把注入模型的技能清单、模型返回的 tool_calls、分发结果全部记录下来出了问题回看是最快路径。4.2 技能太多导致上下文爆炸前面提过tools 声明会消耗上下文当技能列表膨胀到几十个时开销已经到了不能忽略的程度。我统计过一份数据一个包含 35 个技能的完整 tools 声明转换成 JSON 后接近 9000 个字符大约占用 3000~4000 tokens每次对话都要带一遍一个月下来 API 成本非常可观。我的解法是三层裁剪。第一层是粗筛用search按标签和关键词过滤把候选集缩小到 5~8 个。第二层是场景绑定不同业务入口只挂载对应的技能分组比如客服会话挂订单、售后、退换货销售会话挂客户、商品、报价互不污染。第三层是动态加载当模型先发出某个领域的意图时再把该领域的完整技能列表注入后续轮次。这套组合拳打下来上下文占用能压掉一半以上吞吐和响应速度都会有明显提升。注意裁剪的前提仍然是描述质量过硬否则裁掉一个本来该用的技能模型就只能说“抱歉我做不到”了。4.3 模型传参不合规导致执行失败这类问题在测试阶段特别多。比如技能声明需要一个date参数模型传了个 “明天”你的 handler 直接报错或者声明city是字符串模型传了个数组。我建议在 handler 里统一做参数归一再执行。拿时间举例先定义解析规则# param_utils.py from datetime import datetime, timedelta def parse_date(value): if isinstance(value, datetime): return value.date() text str(value).strip() today datetime.now().date() if text in (今天, 今日): return today if text in (明天, 明日): return today timedelta(days1) if text in (昨天, 昨日): return today - timedelta(days1) return datetime.strptime(text, %Y-%m-%d).date()“明天”这种表达在中文对话里极其常见模型很难每次都能转换好我们在参数侧兜底一次执行成功率能提升不少。如果某个字段真的无法归一化返回错误时一定要带上 retry 标记并且在错误信息里说明正确格式。例如{error: date 格式无法识别请提供 YYYY-MM-DD 格式, retry: true}模型看到后会在下一轮重试时给出合规参数。这一点是让整个系统具备自愈能力的关键设计。4.4 并发、状态与技能设计原则Agent 的技能函数必须保持无状态这是一个铁律。技能内部不要保存跨请求的数据所有输入从参数来所有结果通过返回值输出。用户登录态、会话中间结果这些应该放进外部存储比如 Redis 或数据库。原因很简单同一时刻可能有多个用户会话在并发执行同一个技能只要技能内部碰了共享变量就一定会出现互相污染。代码层面我用的是线程池加时间片隔离。每次分发执行都走独立的线程handler 内部禁止使用全局可变变量。如果某个技能确实需要状态比如“创建一个定时任务”这种带有生命周期的东西也应该把状态持久化到数据库技能本身只负责“创建任务”这一动作。另外技能接口要做幂等设计。模型或上游异常重试时同一技能可能被重复调用。实现幂等最简单的方式是给每个请求带一个request_id技能在处理前先查一下这个 ID 是否处理过处理过直接返回上一次的结果避免重复扣费、重复发送等副作用。4.5 常见问题速查表现象可能原因排查方法解决建议模型反复调用不存在的技能名命名大小写或格式不一致查看模型实际返回的 tool_call name注册表 get 时统一做小写归一化两个技能经常选错描述中有重叠触发词打印候选技能匹配分数重写描述加入负例与边界技能声明占用上下文过大技能数量多且全量注入统计单次请求 tools 字符数关键词粗筛 场景分组 动态加载调用成功但结果与预期不符handler 内部逻辑有误检查执行日志和输入输出给技能补单测记录入参样本执行超时导致会话卡死第三方 API 响应慢看链路耗时分布统一超时控制失败降级提示技能之间参数格式不统一设计时无人对齐规范检查每个 handler 的入参标注沉淀 ParamSpec 规范评审时统一把关排查的核心原则只有一个先把过程日志留全了再谈优化。没有日志的 Agent 系统出问题时就像闭着眼修电路只能靠猜。我现在每个技能在入口和出口各打一条结构化日志包含请求 ID、技能名、入参、出参、耗时排查问题基本能做到分钟级定位。5. 最后再分享一点维护心得技能库不是一次性设计出来的而是跟着真实使用场景长出来的。我自己的习惯是每跑一批新任务就把模型选错、卡住、超时的现象整理成一份问题清单然后定期对描述和参数做一轮重写。这比一开始追求“完美的架构”要实在得多。另外一个小技巧在注册表里加一个skill_version字段。每次改完技能描述或 handler不是直接覆盖而是新注册一个带版本号的副本先在灰度流量上跑一版观察选型正确率和执行成功率稳定了再切成默认版本。这样即使新描述把模型带偏了也能一键回滚到上一个版本不用停机改代码。做 agent-skills 这件事最大的乐趣在于它把模型从“什么都懂但什么都做不了”变成了“随时准备干活的执行体”。它不复杂核心就是一个注册表加一份好描述但正是这层薄薄的骨架决定了你的 Agent 是靠谱的搭档还是一个只会耍嘴皮子的聊天机器人。
返回列表