ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent技能管理:用注册表与执行容器解决工具调用失控

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_factory=list) 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(encoding="utf-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 里最关键的转换环节。每次对话循环开始前,系统把注册表中所有enabled=True的技能转成模型可读的工具列表,然后发给模型接口。

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 tools
messages = [ {"role": "system", "content": "你是一个调用技能的助手,只能从给定技能中选择合适的一个,自行生成参数。"}, {"role": "user", "content": "帮我把这份报告里的销售数据提取出来,做成表格。"}, ] tools = build_tool_schema(registry) response = model_client.chat(messages, tools=tools)

不同模型服务商对工具调用的格式有细微差异,但共同点都是让模型返回一个结构化动作:技能名、参数键值、调用意图。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(encoding="utf-8", errors="ignore") 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 这个项目后续还能怎么扩展

如果继续迭代,我会优先补三块能力。第一块是技能评估集:每个技能配一套真实用户请求样本和期望结果,再定期用对应的模型做回归测试,防止模型升级或技能改动导致调用质量回退。第二块是技能编排模板:允许业务人员把多个技能串成固定流程,同时保留自由调用模式,适合更复杂的业务场景。第三块是技能包分发:把已验证的技能打包成带元数据、测试用例、版本声明的模块,可以跨团队复用。

最后再分享一个小技巧,是我在几个项目里都验证过的:给每个技能标注“不要使用”的场景,比只描述“能做什么”更能降低模型误调用率。这套系统我还会继续跑下去,后续有新坑、新解法再来记录。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进