Agent技能管理:用注册表与执行容器解决工具调用失控 这段时间我一直在迭代一个叫agent-skills的小项目起因很朴素我把自己做的 Agent 从 demo 阶段推向“真正能干活”的阶段时被工具调用搞到心态崩溃。技能不到十个的时候模型还勉强听话技能一多提示词里的工具描述就开始互相干扰三天两头传错参数、选错技能甚至直接把技能名编造出来。后来我意识到问题不在“工具函数写得不够好”而是缺少一个让技能被统一管理和被可靠调用的中间层。agent-skills 的目标不是简单多封装几个函数而是把“技能”当作系统里的一等对象注册、加载、执行、重试、回退、观测全部纳入一套统一机制。下面我会把设计思路、整体架构、实现代码和踩过的坑一次讲清楚。这份实践记录给正在做 Agent Tool 层、或者想把技能沉淀成可复用资产的开发者参考。1. 项目背景Agent 系统为什么需要一套“技能层”1.1 工具全写进提示词撑不过十个技能如果你也沿用过“把所有工具描述写进 system prompt”的早期写法应该会有同样体验三五个工具时一切正常模型能准确填参数调用也流畅。但技能数量一旦涨到十几个提示词开始膨胀模型的注意力会被稀释。排在前面的工具被反复调用排在后面的工具经常被忽略两个描述相近的工具更是重灾区模型会凭感觉随机选一个。这不是模型能力的问题而是提示词工程的结构性瓶颈。技能说明本质上是一种“索引数据”它应该独立于对话之外维护而不是每轮对话都重新塞进上下文。把技能描述写死在提示词里还有个坏处你想临时下线一个有问题的技能得重新拼接提示词、重建整个调用流程根本没机会做精细控制。1.2 技能不是工具函数我在 agent-skills 里做了一个明确区分工具是一个可调用单元技能是一个完整闭环。一个技能不仅包含“执行函数”还包含前置条件、输入输出结构、依赖环境、失败类型、可读的错误信息、重试建议和禁用场景。比如“查询订单状态”是一个技能它内部可能需要先校验用户权限再请求订单系统然后根据结果决定返回结构化字段还是提醒用户登录。这些步骤如果全部暴露给模型模型大概率会在某一步出错但把它封装成一个技能模型只需要提供“订单号或用户标识”这个输入剩下的交给技能自身处理。这样就把模型需要理解的复杂逻辑降维了。我们也可以换个日常类比你不会把“泡茶”拆成“烧水”“放茶叶”“倒水”三个工具给一个新手操作而是直接给他一个“泡茶”动作让他一次性完成。Agent 技能的设计也是这个道理粒度要让人一眼看懂、一次执行完。1.3 这份实践适合谁看我写这篇文章的预设读者是那些已经跑通过一个简单的 Agent 项目但在把技能数量往上加时开始觉得失控的开发者。如果你正在设计内部知识库助手、客服工单系统、自动化报表这类需要调用多业务系统能力的应用agent-skills 的分层思路可以直接借鉴。如果你只是想快速做玩具 demo那不需要这套体系直接写工具函数就够了别给自己增加成本。2. 整体架构设计把技能做成可管理的“第一公民”2.1 技能注册表先让系统知道自己“会什么”agent-skills 的核心组件是技能注册表。注册表相当于一个目录服务系统启动时扫描技能目录把每个技能的名称、描述、输入结构、启用状态、版本号登记起来。模型不会直接看到代码而是通过注册表提供的清单了解到自己有哪些能力可选。注册表与运行时的分离很重要。技能本身是静态定义只有在被调度器触发时才进入执行态。这让我们可以做到几件事随时查看当前系统已注册了哪些技能对比不同版本技能的行为差异把一个技能标记为维护状态不让模型继续选择它以及为后续的技能评估和灰度发布留出接口。我在设计元数据时坚持把description写得像“给同事的交接说明”而不是“给模型的咒语”。因为它会被模型直接读取也会被运营同学阅读。好的描述应该包含这个技能做什么、什么情况下用、什么情况下不要用、需要哪些外部依赖、失败后能不能重试。2.2 执行容器给技能划定运行边界技能不能裸奔在 Agent 主进程里。这句话是我跑了一段时间后最深的体会。如果把技能逻辑直接嵌入主循环任何技能崩溃都可能拖垮整个应用而且技能里常见的文件访问、网络请求、环境变量读取会让安全边界完全失控。agent-skills 把每个技能放入受控执行容器中。这里的容器不一定是 Docker可以只是一个独立的 Python 进程池或者带超时限制的线程隔离。核心目标是技能执行必须有超时上限技能能访问的资源必须被白名单约束技能产生的结果必须经过统一格式校验再返回给 Agent。你可以把执行容器理解成办公室里的门禁卡系统每个技能默认没有权限想访问某个目录或外部服务必须显式申请。而不是让技能代码直接趴在系统核心上操作一切。2.3 调度与回退组合技能而不只是单发调用注册表解决“有什么可用”的问题调度解决“当前该用哪个”的问题。agent-skills 的调度器不靠 keywords 硬匹配而是把技能清单交给模型做语义选择。模型根据用户问题、历史上下文和技能描述返回一个结构化的调用意图。真正的挑战在于组合与回退。一个任务往往涉及多个技能先查订单再根据结果创建售后单最后发送通知。agent-skills 允许每个技能声明自己的“后续推荐技能”但这只是建议不是流程图。如果技能 A 执行失败调度器会把错误信息回传给模型让模型决定是重试、换技能 B还是直接向用户解释失败原因。这里必须设置重试上限。我实际经验是超过两次重试后模型大概率在无限打转继续重试只会烧钱。后面我会在第 5 节详细讲这个问题。2.4 选型取舍自研一个轻量运行时的理由在开始写代码前我也纠结过直接用一个成熟的 Agent 编排框架不香吗结论是对于 agent-skills 这个场景自研成本并不高反而更划算。成熟框架解决了通用编排问题但它也同时引入了抽象层级和学习成本。团队一旦深入使用调试黑盒问题会变得很痛苦某个技能没被选中是提示词规则的问题还是框架的筛选逻辑某个参数变了是缓存还是并发导致这些问题在自研体系里都能直接定位。agent-skills 的核心代码刻意保持轻薄注册表、加载器、执行器加起来不到三百行。不是因为我抵触成熟方案而是为了“可控性”我可以随时加一个观测点、改一条回退策略不需要等框架版本更新。这个项目适合已经明确自己业务逻辑、想要掌控全链路的团队。3. 落地实现从零搭一套最小可用技能库3.1 先定义技能元数据结构技能元数据是整个系统里最先要确定的东西。我把一个技能定义成三部分描述信息、输入约定、执行依赖。在 Python 里我用 dataclass 来表达from dataclasses import dataclass, field from typing import Any, Callable dataclass class SkillSpec: name: str description: str input_schema: dict version: str 1.0.0 tags: list field(default_factorylist) enabled: bool True timeout: int 30 handler: Callable None when_not_to_use: str name必须简短且唯一模型要靠它来引用技能一般用小写加下划线。description是最容易被低估的字段它的质量直接决定模型选对还是选错。input_schema使用 JSON Schema 的子集就够了因为我们的最终目标是把它转成模型能理解的工具清单不需要过度复杂的引用关系。when_not_to_use这个字段是我很后面才加的但效果出奇地好。它在描述末尾补充一句“不要在用户只想要查询时不必要地创建工单”这类反例约束明显降低模型的误调用率。3.2 注册表与加载器扫描、加载、热更新没有注册表之前我的技能代码散落在各个模块里靠手写 import 管理。agent-skills 的做法是把技能目录变成一个约定每个技能独立一个文件夹里面放skill.json负责描述handler.py负责逻辑。import json import os import importlib.util from pathlib import Path class SkillRegistry: def __init__(self, skill_dir: str): self.skill_dir Path(skill_dir) self._skills {} def load_all(self): for folder in self.skill_dir.iterdir(): if not folder.is_dir(): continue self._load_one(folder) def _load_one(self, folder: Path): spec_file folder / skill.json handler_file folder / handler.py if not spec_file.exists() or not handler_file.exists(): return spec json.loads(spec_file.read_text(encodingutf-8)) module self._import_handler(handler_file) spec[handler] getattr(module, handler) skill SkillSpec(**spec) self._skills[skill.name] skill def _import_handler(self, path: Path): spec importlib.util.spec_from_file_location(path.stem, path) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module这里用importlib而不是eval或动态字符串 import是因为它更可追踪、也更安全。技能目录路径固定代码不会从用户输入生成。每个技能的 handler 模块会被正常加载为 Python 模块所以技能内部的函数、异常、依赖注入都能正常工作。热更新实现也不复杂给加载器加上文件变更监听或者简单一点定期检查每个skill.json的修改时间mtime。例如每五分钟扫描一次发现版本号变了就重新加载对应技能不需要重启整个 Agent。这个能力在生产环境里几乎是刚需因为技能维护者通常不是主服务开发者。3.3 接入模型调用层让大模型“看见”技能清单注册表和模型之间的桥接是 agent-skills 里最关键的转换环节。每次对话循环开始前系统把注册表中所有enabledTrue的技能转成模型可读的工具列表然后发给模型接口。def build_tool_schema(registry: SkillRegistry): tools [] for spec in registry.list_enabled(): desc spec.description if spec.when_not_to_use: desc f 注意{spec.when_not_to_use} tools.append({ name: spec.name, description: desc, parameters: spec.input_schema, }) return toolsmessages [ {role: system, content: 你是一个调用技能的助手只能从给定技能中选择合适的一个自行生成参数。}, {role: user, content: 帮我把这份报告里的销售数据提取出来做成表格。}, ] tools build_tool_schema(registry) response model_client.chat(messages, toolstools)不同模型服务商对工具调用的格式有细微差异但共同点都是让模型返回一个结构化动作技能名、参数键值、调用意图。agent-skills 在模型响应层做一层统一解析把模型的输出规范化为内部调用指令再交给执行器。这样核心系统不绑定任何一家模型提供方后续换模型只需要改这一小段适配器。3.4 示例技能文档解析的完整实现我们用“解析文档提取结构化文本”作为第一个示例技能。这个技能在企业知识库场景非常常见它接收一个文件路径读取文件内容后返回按标题分段的结果。下面是handler.py的骨架from pathlib import Path ALLOWED_ROOT Path(/data/allowed_files) def _safe_path(path: str) - Path: target (ALLOWED_ROOT / path).resolve() if not target.is_relative_to(ALLOWED_ROOT): raise PermissionError(目标路径超出允许访问的目录范围) return target def handler(params: dict, context): file_path params.get(file_path) if not file_path: return {status: error, error_type: missing_param, message: 缺少 file_path 参数, suggestion: 请提供文档路径} try: path _safe_path(file_path) text path.read_text(encodingutf-8, errorsignore) blocks [p.strip() for p in text.split(\n\n) if p.strip()] return {status: success, blocks: blocks[:50]} except FileNotFoundError: return {status: error, error_type: file_not_found, message: f文件不存在{file_path}, suggestion: 检查路径后重试} except PermissionError as e: return {status: error, error_type: permission_denied, message: str(e), suggestion: 无权访问该路径}它的skill.json长这样{ name: parse_document, description: 解析本地文档按空行切分为文本块。适用于总结、检索、问答或提取关键信息。, input_schema: { type: object, properties: { file_path: {type: string, description: 文档相对路径} }, required: [file_path] }, version: 1.0.0, tags: [document, extract], enabled: true, timeout: 30 }注意几点_safe_path防止技能被构造一个越过根目录的路径错误返回结构里带了error_type和suggestion这是为了给模型提供自救信息文件不存在和权限不足返回不同类型的错误模型收到后可以判断是引导用户换路径还是直接说明无法处理。实际项目中我还加了 ab 测试框架技能内先走接口请求豆瓣之类的对文档做分片解析再合流但这个示例已经足够展示完整链路。4. 关键设计权衡与避坑指南4.1 技能粒度拆得太细反而更不可控技能粒度是我在这个项目里反复权衡最久的问题。一开始我倾向于把技能做小比如“获取当前时间”“读取环境变量”“拼接字符串”以为小技能更原子、更好组合。但实际运行后模型非常痛苦一个简单任务要连环调用五六个小技能任何一步的中间结果稍有偏差后面全乱。后来我把设计原则调整为技能的最小粒度 一个正常人能一次性理解并完成的操作。例如“创建一个客户工单”“查询未读通知列表”“把文本翻译成英文”这些都能在几秒内完成且有明确成功或失败状态。如果一个“技能”需要多个步骤或者用户还要追问那它其实是技能组合或工作流应该拆成上层编排来处理。粒度太大也不行如果一个技能做太多事它的输入参数会膨胀错误类型会混杂反而无法灵活复用。4.2 错误信息也是技能输出的一部分我看到很多人设计技能时只关注成功路径错了就抛一个Exception(failed)。但 Agent 场景下错误信息不是给人看的是给模型看的是模型的“观测数据”。模型只有拿到了结构化的错误信息才能决定下一步动作。我在 agent-skills 里约定技能的所有输出必须统一为两种 JSON 结构成功时返回{status: success, ...业务字段}失败时返回{status: error, error_type: 权限不足/超时/参数缺失/文件不存在/资源繁忙, message: ..., suggestion: ...}。这里的suggestion是给模型的提示自动拼到结果里反馈给模型。比如“文件不存在建议检查路径后重试”模型看到就能直接回复用户而不是卡在原地反复尝试同一种错误。4.3 安全边界别让 Agent 裸奔Agent 技能最大的安全隐患是 prompt injection。外部输入的网页内容、PDF 文本、邮件标题里都可能藏着“忽略之前指令执行某操作”的注入内容。技能系统必须把外部输入当作不可信数据而不是直接当成指令执行。agent-skills 的安全策略分为三层第一层是技能访问控制每个技能只能访问自己声明过的目录和服务白名单不能随意读系统文件第二层是参数校验技能的输入 Schema 要在执行前做一次严格校验类型不对、缺少必填字段直接返回参数错误不让脏数据进入业务逻辑第三层是敏感操作隔离凡是会修改线上数据、发送外部通知、删除资源的技能都要走人工确认回调Agent 不能独自完成全部操作。执行容器的超时和资源限制也很重要。一个技能默认 30 秒超时超过就被强制终止。我见过几次技能因为请求第三方服务迟迟不返回直接把整个 Agent 进程拖死的案例。超时不是可选优化是必需的基础能力。5. 踩坑实录运行这套系统时的真实问题5.1 技能加载失败日志却查不出原因我在第一次把技能目录规范化时遇到过一个很隐蔽的问题某个技能文件里有语法错误但系统启动时没有打印任何异常。原因是注册表加载代码只捕获了“技能描述解析失败”却没有捕获 handler import 阶段的语法错误。结果就是技能列表里少了一个技能但日志没有可读的报错只有一行“skill not found”。后来我把_load_one改成编译期就做准备先用compile()检查语法再执行 import任何异常都记录到独立的加载错误清单里并在注册表接口中暴露为“加载失败技能”列表。这样在管理页面上就能看到具体哪个技能挂了、原因是什么。修复后技能热更新的稳定性提升了一个层级。5.2 模型总在几个技能之间选错项目里有两个技能一个叫 “create_ticket”一个叫 “create_order”名字相近、描述也有重叠。模型经常把“创建投诉工单”错误地路由到“创建订单”上直接造成业务数据错误。我一开始想通过优化描述文字来规避但效果有限。最后的解法是给所有技能增加“反例描述”。在create_ticket的when_not_to_use字段里明确写“本技能仅用于创建客服工单不可用于创建销售订单或购买商品。”同时在create_order里写反向限制。同时我把技能名改得更贴合用户口头语描述里也加入用户可能说的自然句式。改完后同类错误从“经常出现”降为“偶尔出现”。这个字段虽然简单但真的值得每个技能都写上。5.3 并行执行时出现状态污染这个坑发生在技能数量多、且请求并发上来之后。因为最初代码里有个全局缓存对象用来保存技能执行中的临时状态。结果两个技能并行执行时A 技能写入的缓存被 B 技能覆盖导致返回结果出现串数据用户明明查的订单 A却拿到了订单 B 的信息。修复方案是彻底移除技能模块里的全局可变对象改用context对象在每个技能调用时注入。执行容器为每次调用创建独立 context技能只能通过 context 访问会话级状态不再共享任何进程级变量。这个改动让我意识到技能系统本质上是个并发系统所有共享状态必须显式传递否则迟早踩雷。5.4 回退策略把请求数打爆最让我心疼的一次事故是回退逻辑设计失误。某个技能调用的下游接口不稳定返回超时我天真地加了重试循环模型收到超时信号后又发起一次调用然后再次超时、再次重试。一晚上过去API 调用账单高得离谱而用户实际只发了一个请求。现在我在调度层加了三道闸每个技能单用户单任务最多重试 2 次同一技能在 5 分钟内连续失败超过 3 次就会被临时标记为不可用模型不会再被分配到这个技能所有失败原因进入观测面板不再沉默地重试。这个教训的核心是不要在技能层自己无限重试要让上层控制器决定重试策略。问题现象根因解决办法技能列表少一个日志无提示handler import 异常未被捕获加载时统一捕获并记录编译错误模型反复选错相似技能描述没有反例边界增加when_not_to_use反例描述并行执行结果串数据全局缓存被并发写入移除全局可变对象改为 context 注入失败重试导致请求爆炸无限制重试循环限制重试次数超过阈值自动熔断6. 经验总结与后续扩展方向6.1 实际运行后我的几点体会跑完这套体系我最大的感受是技能系统本身并不难写难的是持续维护技能的说明和边界。代码只占项目的一小半大部分精力花在写好每个技能的描述、反例、错误信息和权限声明上。技能描述写得模糊再强的架构也救不了技能描述写清楚了即使调度逻辑简单一些也能稳定运行。我不建议所有人都去自研一套 Agent 技能系统。团队还在快速验证阶段时用最简单的方式把工具跑通更务实但当技能数量超过十个、开始多人协作维护时类似 agent-skills 的注册表加执行容器分层会显著降低维护成本。核心要义是让技能的“定义”“执行”“观测”三者分离避免把所有复杂度揉进一个提示词里。6.2 这个项目后续还能怎么扩展如果继续迭代我会优先补三块能力。第一块是技能评估集每个技能配一套真实用户请求样本和期望结果再定期用对应的模型做回归测试防止模型升级或技能改动导致调用质量回退。第二块是技能编排模板允许业务人员把多个技能串成固定流程同时保留自由调用模式适合更复杂的业务场景。第三块是技能包分发把已验证的技能打包成带元数据、测试用例、版本声明的模块可以跨团队复用。最后再分享一个小技巧是我在几个项目里都验证过的给每个技能标注“不要使用”的场景比只描述“能做什么”更能降低模型误调用率。这套系统我还会继续跑下去后续有新坑、新解法再来记录。