ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Agent技能管理实战:从工具混乱到统一调度与热更新

Agent技能管理实战:从工具混乱到统一调度与热更新 做 agent 类项目的人应该都有同一个烦恼当模型开始频繁调用工具代理逻辑和工具代码混在一起时改一个工具就要翻半天代码。我在做 agent-skills 这个项目之前团队里好几个机器人助手都是“工具函数埋在业务文件里大模型 prompt 里还要同步维护一份描述”每次加能力都像在做脑力体操。后来我把“技能”这个概念单独抽出来做了一个可注册、可热更新、可观测的技能管理框架也就是现在这个 agent-skills 项目。它可以解决一件事让 agent 的能力不再散落在代码各个角落而是变成一份份结构化的“技能卡”统一加载、统一调度、统一审计。适合正在做 LLM 应用、RAG 机器人、智能客服、自动化操作助手的人参考尤其是那种工具数量开始超过五六个手写 if-else 已经完全失控的场景。1. 项目起源为什么我会把“技能”单独抽出来1.1 核心需求解析agent 缺的不是“能力”而是“技能边界”先说个反直觉的结论大模型本身不缺“能力”缺的是“边界感”。模型知道什么叫天气查询、什么叫发送邮件但它不知道你这个项目里的天气数据源是哪个接口、邮件发送需不需要审批、返回结果要不要脱敏。这些边界信息如果全部塞进 system prompt很快 prompt 就会膨胀到几千 token模型开始选择性失明不是漏工具就是乱选参数。我最早踩坑是在做一个内部客服助手。当时已经写了十几个工具函数查订单、改地址、退换货、查优惠券、转人工……刚开始还挺爽把函数列表一股脑塞给 function calling模型挑得也准。但后来业务规则一变比如“退换货只能在工作日处理”我就需要在工具函数里加一堆判断同时还要同步改 model 看到的函数描述。两边不同步的结果就是函数换了签名描述还是旧的模型按旧参数传程序直接报 TypeError。那天我在生产日志里看到连续十几个报错才意识到真正该管理的不是“函数”而是“技能”。所谓技能我的定义是能完成一个完整任务的最小可复用单元包含输入输出约定、触发条件、执行逻辑和权限边界。它比单个工具函数高一个抽象层次又比一个完整 agent 低一个层次。agent-skills 做的就是把这层管理起来。1.2 方案选型用“技能清单”统一管理工具、提示词和权限市面上做 agent 框架的其实已经有插件机制但我做事有个偏好在小项目里尽量少引重框架尤其是 agent 这种迭代很快的领域框架的抽象反而可能成为约束。agent-skills 没有做成一个大而全的 agent 运行时而是只做“技能层”。它只回答三个问题技能怎么声明、技能怎么加载、技能怎么执行。技术选型上我选了 Python 3.10主要原因不是 Python 多强而是团队里的 AI 应用栈基本就是 Python 生态大模型 SDK、向量库、OCR 这些库都先支持 Python。技能描述用 YAML因为人读起来舒服写注释也方便技能逻辑用 Python 模块因为天然支持动态 import不用搞编译那套。每个技能文件都是独立文件夹里面有 manifest.yaml 和 handler.py两者一一对应。加载器扫描技能目录时读取 manifest 去注册 metadata真正执行时才 import handler这样冷启动时不用把所有技能代码全部加载进内存。为什么不用纯 JSON 描述技能我试过JSON 写注解非常反人类尤其是参数枚举多了之后每一个属性都要补说明写起来像在受刑。YAML 虽然有缩进坑但做好 schema 校验后团队里不太熟悉代码的同事也能照着模板很快加一个新技能。这也符合 agent-skills 的一个隐含目标降低“给 agent 加新能力”的门槛让会写脚本的人都能参与。1.3 目录结构与核心抽象项目目录是这样的agent-skills/ ├── skills/ │ ├── weather_query/ │ │ ├── manifest.yaml │ │ └── handler.py │ ├── order_status/ │ │ ├── manifest.yaml │ │ └── handler.py │ └── ... ├── agent_skills/ │ ├── loader.py │ ├── registry.py │ ├── dispatcher.py │ ├── schema.py │ └── exceptions.py └── examples/核心抽象只有三个SkillRegistry技能注册中心、SkillLoader从目录扫出技能、SkillDispatcher根据模型输出执行对应技能。这三个东西各管一段互不耦合。Registry 维护一张技能名到技能元数据的映射表Loader 负责解析 manifest、校验 schema、延迟加载 handlerDispatcher 接收大模型返回的 tool_calls解析参数找到技能执行并返回结果。整套流程跑下来加一个新技能只需要在 skills/ 下新建一个目录写清 manifest 和 handler其他都不用动。2. 技能定义与注册机制manifest 是一切的入口2.1 技能声明 manifest 里的三个关键字段一个技能是否好用一半看 handler 逻辑一半看 manifest 写得好不好。manifest.yaml 里的字段我没有搞得很复杂核心只有三个name、description、parameters。这三个字段直接决定模型能不能在合适的时候调用到它。name 一定要短且语义清晰避免特殊符号。我见过有人把名字写成 “get_customer_order_by_order_id_and_query_refund_status”模型看到这种名字都来不及理解就被 token 截断了。description 是重中之重它相当于给模型的“使用说明”要写清楚这个技能是干什么的、什么时候用、什么时候不用。比如天气查询技能name: weather_query description: 查询指定城市当前天气和未来预报。当用户问天气、温度、下雨、穿衣建议时使用。 当用户只问某个历史日期的天气且数据源不支持时不要使用。 parameters: type: object properties: city: type: string description: 城市名称如“北京”“上海” days: type: integer description: 预报天数默认1最大7 required: - city我专门在 description 里写了“什么时候不要用”这个细节对降低误调用率非常有效。模型读 description 就像人读招聘 JD写得越像“这份工作适合谁”匹配度越高。parameters 建议直接对齐 JSON Schema因为主流大模型的 function calling 都支持这个格式不用做转换。2.2 动态加载从目录到可执行 handler动态加载这块我踩过不少坑最早用 importlib 直接按文件路径去 import结果发现技能目录里的模块如果重名会互相覆盖。比如两个技能目录里都有 utils.pyPython 的 sys.modules 会记混。后来我把技能 handler 包了一层命名空间每个技能有自己的 package 名加载时才拼接。Loader 的核心逻辑其实不长import importlib.util import sys from pathlib import Path def load_handler(skill_dir: Path): handler_path skill_dir / handler.py spec importlib.util.spec_from_file_location( fskill_{skill_dir.name}_handler, handler_path ) module importlib.util.module_from_spec(spec) sys.modules[spec.name] module spec.loader.exec_module(module) return module这里用 spec_from_file_location 给每个 handler 起一个唯一的名字避免 sys.modules 冲突。这个代码看起来简单但我实际运行时踩过隐藏的坑如果技能目录名字里含中文或者空格spec 名字会出问题所以我在 Initializer 里加了 sanitize 逻辑把非 ASCII 字符全部替换成下划线。类似这种边界情况只有真实用了才会遇到。加载完 handler 模块后Loader 还要做一次“技能自检”确认模块里有没有 run 函数manifest 里的 name 和 handler 里暴露的 skill_name 是否一致。这样做不是为了多此一举而是因为技能多了以后最怕有人复制文件夹却忘了改函数名。2.3 权限与安全边界技能不是越强越好agent 技能和普通函数最大的不同在于技能是给不可信输入调用的。用户跟 agent 说一句话模型决定调哪个技能这个链路里没有人工确认所以技能执行必须带权限控制。agent-skills 的解法是在 manifest 里加一个 permissions 块声明这个技能要访问什么资源。permissions: network: true filesystem: - read: /tmp/agent_workspace environment: - OPENAI_API_KEY权限声明本身不能保证安全但它提供了一个强制检查点。Dispatcher 在执行技能前会检查当前运行上下文是否具备对应权限比如在无网络环境里network: true 的技能会直接被拦截而不是等 handler 跑到一半报超时。这个设计后来救过我一次同事把爬虫技能部署到内网服务结果它偷偷请求外网地址被权限拦截挡下来了。3. 实操过程从零搭一个可运行的 agent-skills 核心3.1 第一步定义基类与技能装饰器我不喜欢强制要求每个技能 handler 都继承一个大基类那样写起来太像 Java。Python 里用装饰器更自然。我先定义了一个 register 装饰器让 handler 可以自报家门from agent_skills.registry import registry def skill(name: str): def wrapper(func): registry.register(name, func) return func return wrapper然后是技能执行的标准入口。每个 handler.py 至少要有一个 run(context, params) 风格的函数skill(weather_query) def run(context, params): city params.get(city) days params.get(days, 1) return context.weather_client.query(city, days)context 是一个 SkillContext 对象包含当前请求的 trace_id、用户身份、限流器、外部客户端等。把客户端放进 context 而不是让每个技能自己 import是为了统一超时和重试策略也方便测试时替换 mock。3.2 第二步写一个注册中心注册中心本质就是个字典但我想让它多一点能力支持按标签查询、支持重名冲突检测、支持查看技能依赖关系。最终抽象出来大概是这样class SkillRegistry: def __init__(self): self._skills {} self._tags {} def register(self, name, handler, tagsNone): if name in self._skills: raise SkillConflictError(fskill {name} already registered) self._skills[name] handler for tag in tags or []: self._tags.setdefault(tag, []).append(name) def get(self, name): return self._skills.get(name) def list_by_tag(self, tag): return self._tags.get(tag, [])这里故意在重名时直接抛异常而不是覆盖。原因是我在早期版本里允许覆盖结果有一次两个技能重名后者悄悄把前者顶掉模型调到一个旧的天气技能但实际执行的是新的查询逻辑数据口径都变了。宁可启动失败也不要运行时静默出错。3.3 第三步接入大模型 function callingagent-skills 不绑定具体的大模型厂商而是把技能列表转换成一个标准格式再交给各家 SDK。以 OpenAI 风格为例def build_tools_from_registry(): tools [] for name, meta in registry.metadata_items(): tools.append({ type: function, function: { name: name, description: meta[description], parameters: meta[parameters], }, }) return tools拿到模型返回值后Dispatcher 会解析 tool_calls逐个执行。这里要注意一个细节多个 tool_calls 可能是并行触发的但技能之间可能有依赖关系比如“先查订单再退货”。我的做法是先尝试并行执行没有依赖冲突的技能一旦发现两个技能同时要写同一个资源文件就退回串行。性能倒是次要的数据一致性才是关键。3.4 第四步执行时的上下文与错误处理技能执行最怕无脑把异常抛回给大模型。早期版本里handler 里如果 requests 超时异常字符串会被原样拼进 messages模型看到的是一段“Connection timed out”的英文堆栈它可能一本正经地照着报错信息给用户解释网络问题场面非常迷惑。现在我在 Dispatcher 里统一做异常捕获和降级try: result handler.run(context, params) except SkillExecutionError as e: result {error: 技能执行失败, reason: e.user_message} except Exception as e: context.logger.exception(skill %s failed, skill_name) result {error: 技能内部错误请稍后重试}把技术细节记录到 logger 里但给模型返回的是用户能理解的话。这个设计让 agent 在故障时至少不会“胡编乱造”而是会老实地告诉用户重试或转人工。4. 把 agent-skills 用稳测试、观测与优化4.1 给技能写单测和模拟评测技能是给模型调的所以它的测试跟普通函数不太一样。除了断言输入输出正确之外我还要测“描述是否让模型找得到”。这个测试很难完全自动化但我用一个折中方案准备一组典型的用户 query调一次大模型看它是否选对技能。pytest.mark.parametrize(query,expected_skill, [ (北京明天会不会下雨, weather_query), (我的订单到哪了, order_status), ]) def test_skill_selection_fixture(query, expected_skill): tools build_tools_from_registry() selected run_model_for_tool_selection(query, tools) assert selected expected_skill这种测试对 manifest 的 description 修改非常敏感。有一次我把天气技能的 description 改短了结果“下雨”这个意图的命中率从 92% 掉到 71%多亏这套评测发现。它不完美但至少能防止“改个描述就带崩另一个意图”的回归问题。4.2 日志与观测每次调用都要能回放agent 应用的问题排查最痛苦的地方在于同样的 prompt模型这次选了技能 A下次可能选了技能 B。没有日志你根本不知道是模型抽风还是技能描述有歧义。agent-skills 里我在 Dispatcher 执行前后都记了结构化日志核心字段是trace_id、skill_name、params、result、duration_ms。完整记录 params 有个隐私问题但我觉得在内部环境里可以先全量记等上线前再对敏感字段做脱敏。这里我踩过的一个坑是最早只记了 skill_name 和返回结果结果排查时发现参数里某个字段影响了结果但日志里根本没存参数只能让用户复现非常痛苦。从那以后参数必记哪怕看着啰嗦。4.3 技能版本与灰度上线技能改代码比改函数风险高因为它影响的不是某个确定调用方而是所有可能触发这个技能的对话。我后来给 agent-skills 加了一个简单版本机制manifest 里加 version 字段Loader 在加载时如果发现同名字技能版本不同会保留两份路由规则决定用哪个版本。这个机制在灰度发布时很好用新技能先 10% 流量确认指标没问题再切 100%。5. 常见问题与排查技巧实录5.1 模型把参数塞错类型描述写清楚还不够最常见的问题是模型把数字塞成字符串。比如 days 字段明明 JSON Schema 写了 type: integer模型还是可能给一个 3 或者 三五天。后来我发现光靠 schema 不够还要在 handler 入口做一次宽松转换和兜底校验。我给参数校验封装了一个 coerce_params 函数def coerce_params(raw_params, schema): for field, props in schema.get(properties, {}).items(): if field not in raw_params: continue expected props.get(type) value raw_params[field] if expected integer and isinstance(value, str): raw_params[field] int(value)这个技巧看起来像是给模型擦屁股但在真实场景里非常管用。模型不是从数据库里读类型它只是从文本里推断出现字符串数字是很正常的。处理完类型之后再做一次范围校验比如 days 必须在 1 到 7 之间超了就默认 7别让模型随便传个 99999。5.2 技能并发执行时互相污染有一次我加了两个技能一个读文件一个写文件结果同一轮对话里两个技能被并行触发写文件把读文件的数据覆盖了。排查过程花了我一晚上最后发现是并行调度的问题。从那以后我在 manifest 里加了 resources 声明Dispatcher 会检查两个技能是否有交集有交集就串行执行。这个方案不算最优但简单可靠。如果你也用 agent-skills建议在调度层加一个最小互斥锁成本低收益高。5.3 加载时间过长 / 热更新失效技能目录多了之后Loader 每次启动要 scan 全部目录解析 manifest虽然 handler 是延迟加载的但几十个技能扫下来也有几百毫秒。后来我加了文件 mtime 缓存只有 manifest 或 handler 发生变化时才重新加载。热更新不是完全安全的因为正在执行的技能可能还是旧代码所以我只在预览环境开热更新生产环境还是走版本切换。5.4 常见问题速查表现象可能原因处理方式模型老是选错技能description 不够具体或边界描述缺失重写 description加入什么时候不要用技能执行时报参数错误模型传了字符串数字或漏参在 handler 入口做 coerce_params 和必填校验并发场景下数据被覆盖两个技能操作同一资源manifest 声明 resources调度层加互斥锁热更新后还是旧逻辑sys.modules 缓存未清理用 spec name 唯一化并执行 clear 缓存逻辑日志查不到某次调用并行 tool_calls 的 trace_id 没打通从入口生成 trace_id全程透传排查问题时我最推荐先看日志里的 params 和 duration_ms 这两项。params 能看出模型是否理解意图duration_ms 能判断是技能卡住还是模型响应慢大部分问题在这两步就能定位掉一半。最后再分享一个我做 agent-skills 时养成的习惯每次给 agent 添新技能我都先只写 description不写 handler让模型先“空跑”一次看看它会不会在需要时调用这个技能。如果描述写得足够好模型会调用但 handler 还没实现这时返回一个“技能暂未上线”的结果整个链路是通的。这个流程帮我挡掉了不少“代码写完但模型根本找不到”的尴尬情况。技能系统做到后面真正难的不是写 Python 函数而是让模型和人之间对“什么情况该用什么技能”达成一致。agent-skills 只是把这层一致性变得可维护、可迭代罢了。
返回列表