既然要做 HagiCode Skill 系统,咱们先把话说明白:这套东西解决的不是"能不能接模型"的问题,而是"接到什么程度、往里加新能力要多费劲"的问题。我见过太多团队把 AI Agent 做成了铁板一块,加一个工具函数就要改核心代码,改完还要重新走一遍发布流程,真要命。HagiCode Skill 这套设计思路,核心就是模仿插件生态——让每一个技能像 App 一样独立存在、按需安装、动态生效,主程序只负责调度和兜底。下面我把整套系统的设计思路、核心实现、扩展机制和踩坑记录一次性铺开讲清楚,希望能给正在做同类平台的你省掉几个月的弯路。
1. 整体架构设计:先拆层,再谈扩展
1.1 为什么需要一个独立的技能管理层
先想一个问题:大模型本身会"干活"吗?不会。它只能根据上下文生成文本。所谓 AI 技能,本质上是把我们封装好的函数或工具,通过模型的能力识别用户的意图,然后触发对应函数执行,再把结果反馈给模型组织成最终回答。这套机制当前行业里普遍叫 function calling 或 tool use,但落到工程落地时,大部分团队的实现方式都很粗暴:
- 在代码里硬编码一个 tools 数组;
- 每次新增技能都改代码、加分支;
- 技能之间的依赖、版本、参数校验全靠口头约定。
一开始技能少,这么干没问题。一旦技能上了两位数,维护成本直接起飞。我带的项目就遇到过:想下线一个废弃技能,结果还有十几个 prompt 模板在引用它的名字,改了三天都没改干净。所以,独立的技能管理层不是炫技,而是规模化的必然结果。它的职责很纯粹:定义技能的统一描述格式、管理技能的注册与发现、隔离技能的运行环境、监控技能的调用状态。
一句话总结:没有这个管理层,你构建的不是平台,而是一个不断膨胀的补丁堆。
1.2 分层设计:四层结构让职责边界清晰
HagiCode 这套系统的整体架构我把它分成四层,每一层之间的接口都收得很窄,保证可以独立演进:
| 层级 | 核心职责 | 关键组件 |
|---|---|---|
| 接入层 | 接收用户输入、处理会话状态 | Chat Adapter、Session Manager |
| 编排层 | 理解意图、决策调用哪个技能、管理调用链 | Agent Core、Planner、Context Builder |
| 技能层 | 技能的注册、加载、执行、生命周期管理 | Skill Registry、Loader、Executor |
| 基础设施层 | 存储、日志、监控、配置管理 | Redis、PostgreSQL、Prometheus |
这里面最容易被忽视的是接入层和编排层之间的解耦。很多团队把 prompt 拼装和技能调用逻辑写在同一个函数里,Session 上下文直接被技能实现给污染了。我的建议是:接入层只负责把用户消息转成统一的 AgentMessage 结构,编排层只负责产出决策,技能层只管执行具体函数,三者之间用标准的数据结构传递信息,谁都不准越界调用。这样后续无论是换模型、换 UI,还是加技能,都不至于牵一发动全身。
技能层本身还要再拆成三个子模块:Registry 负责维护"技能元信息中心",Loader 负责把技能代码从文件系统或远程仓库加载进运行时,Executor 负责真正执行技能并返回结构化结果。这种拆分的好处是:Registry 只跟元数据打交道,不关心具体实现;Loader 只负责把"代码"变成"可执行对象",不关心技能做什么;Executor 只管跑,不关心技能怎么被找到的。任何一个环节出了问题,排查范围都特别小。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Skill 定义规范与协议设计:一切扩展性的地基
2.1 技能描述文件:从 manifest 到参数 schema
扩展性的核心不在代码,在协议。HagiCode 里的每个技能都自带一份 manifest 文件,这个文件是技能的唯一"身份证",所有注册、发现、鉴权都围绕它展开。我采用 YAML 格式,原因很简单:相比 JSON,YAML 支持注释,写起来也更容易维护。一个标准 manifest 长这样:
yaml复制name: weather_query
description: 查询指定城市的实时天气信息,包含温度、湿度、风力等
version: 1.2.0
author: hagicode-team
tags:
- weather
- location
parameters:
type: object
properties:
city:
type: string
description: 城市名称,如"北京"、"上海"
required: true
unit:
type: string
enum: ["celsius", "fahrenheit"]
default: "celsius"
description: "温度单位,默认摄氏度"
required:
- city
entrypoint:
module: skill_weather
function: run
handler: webhook
permissions:
network: true
filesystem: false
timeout: 30
这里有几个字段值得重点说说。
parameters 这块是重头戏。它不是给人看的,是给模型看的。大模型要靠这段 JSON Schema 来理解"这个技能需要什么参数、参数各自是什么含义",所以 description 必须写得足够具体,要站在模型的视角,而不是开发者的视角。比如 city 的 description 写成"城市名称,如北京、上海"就比"城市"两个字效果好得多,模型能通过示例推断出正确格式。
entrypoint 定义了技能的执行入口。module 和 function 指向一个可导入的 Python 函数,handler 字段则标记了技能的执行方式——是本地函数调用,还是请求外部 webhook。这个设计天然支持了"本地技能"和"远程技能"两种形态,远程技能甚至不需要在本地安装任何依赖,只要你实现了 webhook 协议,就可以挂载进来。
permissions 部分我非常强调。AI 技能往往需要执行一些敏感操作,比如读文件、访问网络、写数据库。给技能显式声明权限边界,一方面可以在注册时做合法性检查,另一方面在运行时可以做细粒度的拦截。这比在技能代码里自己写"我能不能联网"靠谱得多。
最后,所有人最容易忽略的字段是 timeout。我见过太多技能没有设置超时时间,结果模型调用了一个第三方 API,对方响应卡了 5 分钟,整个 Agent 卡死,用户等得直接关掉页面。每个技能都必须显式声明自己的超时上限,框架在 Executor 层做强制约束,超时直接 Kill,不允许"尽量等一等"这种事。
2.2 执行协议:统一输入输出与错误语义
技能可以千人千面,但执行协议必须统一。HagiCode 里每个技能的标准输入是 SkillRequest,标准输出是 SkillResponse,即便你用 Python 写函数,也要求返回一个符合规范的对象:
python复制@dataclass
class SkillRequest:
skill_id: str
arguments: dict
context: SkillContext
trace_id: str
@dataclass
class SkillResponse:
status: str
data: Any
error: SkillError | None
metrics: SkillMetrics
我见过一种反模式:项目里让技能函数自由返回 dict,有的技能返回 {"temperature": 23},有的返回 {"data": {"temp": "23C"}},还有的直接抛异常。结果就是模型在组织最终回答时,经常被格式不统一的结果搞懵,回答出来的内容驴唇不对马嘴。
更关键的是错误语义。我给技能定义了四类标准异常:SkillNotFoundError(技能不存在)、SkillArgumentError(参数校验失败)、SkillTimeoutError(执行超时)、SkillExecutionError(业务逻辑执行失败)。每类异常都有固定的 code 和 message 模板,框架层面会捕获这些异常并转换成结构化的错误信息交给模型。模型拿到错误信息后,可以自行尝试修正参数重新调用,或者直接告诉用户"这个操作失败了,原因是什么"。这个闭环非常重要,它让 Agent 具备了一定的自我纠错能力,而不是一遇到错误就崩溃或死循环。
参数校验我直接用了 Pydantic。理由很简单:我们的参数 schema 是 JSON Schema 格式,Pydantic 天然支持从 JSON Schema 生成模型类,还能给出非常详细的中文化校验错误。比如用户调用天气技能时把 city 传成了数字,Pydantic 会返回"Input should be a valid string",这个信息在后续反馈给模型时有极高的参考价值。
3. 核心实现细节:注册、加载、调用的完整链路
3.1 技能注册中心的实现
注册中心是整个系统的"大脑"。它的实现我推荐用"内存注册表 + 数据库持久化"的双层结构。内存注册表负责提供高性能的查询能力,数据库负责保存技能的元信息和版本历史。
先看注册中心的主要接口:
python复制class SkillRegistry:
def __init__(self, storage: SkillStorage):
self._storage = storage
self._skills: dict[str, SkillMeta] = {}
self._lock = asyncio.Lock()
async def register(self, manifest: dict) -> SkillMeta:
"""注册新技能"""
meta = SkillMeta.from_manifest(manifest)
self._validate(meta)
async with self._lock:
self._skills[meta.skill_id] = meta
await self._storage.save(meta)
return meta
async def unregister(self, skill_id: str) -> None:
"""卸载技能"""
async with self._lock:
self._skills.pop(skill_id, None)
await self._storage.remove(skill_id)
def get(self, skill_id: str) -> SkillMeta | None:
return self._skills.get(skill_id)
def list(self, tag: str | None = None) -> list[SkillMeta]:
if tag:
return [m for m in self._skills.values() if tag in m.tags]
return list(self._skills.values())
async def reload(self, skill_id: str) -> None:
"""重新加载技能定义"""
meta = await self._storage.fetch(skill_id)
if meta:
self._skills[skill_id] = meta
注册时需要注意 _validate 这一步。我会对 manifest 做以下检查:技能名称是否合法、参数 Schema 是否是合格的 JSON Schema、entrypoint 声明的模块是否存在于指定路径、声明的 permissions 是否在系统白名单内。任何一条不通过,直接拒绝注册,避免"脏数据"污染整个系统。
对于注册中心的存储,数据库我建议用 PostgreSQL,把 manifest 的 YAML 内容存在 JSONB 字段里,方便按标签或技能名做查询。Redis 则用来做技能的列表缓存,每次模型构建可用技能列表时,直接从 Redis 读取序列化后的元信息,不用频繁编排数据库。
3.2 动态加载与热插拔机制
技能管理的核心诉求是"不停机更新"。HagiCode 采用了两级加载策略:
第一级是注册中心层面,当技能 manifest 更新时,只更新元信息,不重新加载代码。第二级是运行时层面,通过 Python 的 importlib 实现模块的动态导入与卸载。动态加载的关键代码如下:
python复制import importlib
import inspect
class SkillLoader:
def load(self, meta: SkillMeta) -> SkillExecutor:
module = importlib.import_module(meta.entrypoint.module)
func = getattr(module, meta.entrypoint.function)
# 校验函数签名,确保符合框架预期
sig = inspect.signature(func)
assert "request" in sig.parameters, "skill function must accept 'request' param"
return LocalSkillExecutor(func, meta)
这里踩过一个大坑:Python 的 importlib.import_module 在重复导入同一模块时,会直接返回缓存,修改技能代码后不重启服务就无法生效。解决方案是加载前先做模块清理:
python复制def force_load_module(module_name: str):
for name in list(sys.modules.keys()):
if name == module_name or name.startswith(f"{module_name}."):
del sys.modules[name]
return importlib.import_module(module_name)
但这里要提醒一句:强制卸载模块会带来"神出鬼没"的全局状态问题。如果技能模块里定义了全局变量或启动了后台线程,旧模块的这些残留不会因为模块引用删除而被清理。最稳妥的方案是要求技能代码必须是无状态的,即所有状态都应该通过 SkillContext 传递,或者存入外部存储。这个约束我在技能开发文档里用加粗大字写出来——"技能必须无状态,不允许在模块级别维护可变全局变量",违反这条的技能在代码评审时直接被拒。
3.3 与模型对接:Function Calling 的统一封装层
有了技能注册中心,最后一步就是把技能元信息翻译成模型可识别的工具描述。不同模型对 function calling 的定义有差异——OpenAI 用 JSON Schema 描述函数参数,Claude 用 input_schema,国产模型可能用不同的字段命名。HagiCode 用一层适配器把这部分差异全部屏蔽掉:
python复制class ModelAdapter(ABC):
@abstractmethod
def to_tool_schema(self, meta: SkillMeta) -> dict:
"""将技能元信息转换为模型工具描述"""
@abstractmethod
def parse_tool_call(self, raw: Any) -> SkillRequest:
"""将模型返回的 tool call 转换为标准技能请求"""
class OpenAIAdapter(ModelAdapter):
def to_tool_schema(self, meta: SkillMeta) -> dict:
return {
"type": "function",
"function": {
"name": meta.skill_id,
"description": meta.description,
"parameters": meta.parameters,
}
}
class ClaudeAdapter(ModelAdapter):
def to_tool_schema(self, meta: SkillMeta) -> dict:
return {
"name": meta.skill_id,
"description": meta.description,
"input_schema": meta.parameters,
}
这个适配层让上层编排逻辑完全不用关心底层接的是哪家模型,切换模型时只需更换适配器配置,技能本身零改动。我在实际项目中就经历过从 OpenAI 切到 Claude 再切换国产模型的过程,这套适配层帮我省了至少一个星期的改动量。
模型返回的 tool call 解析也有讲究。比如 OpenAI 的函数调用参数是 JSON 字符串,需要做一次 json.loads 解析;国内某模型的 tool call 结构里嵌套了一层 arguments: {"params": {...}}。适配层在做 parse_tool_call 时,不仅要解析出参数,还要做类型修复——比如把字符串类型的数字转成 int,把逗号分隔的省份列表按规则切分。这一步"防御性解析"能显著减少模型调用错误时的重试次数。
4. 可扩展性设计:如何让第三方技能无缝接入
4.1 扩展点设计:让第三方开发者不碰核心代码
一个平台能不能形成生态,关键看第三方开发者接入的门槛。HagiCode 的核心做法是:所有扩展都通过"包目录扫描"来发现,把技能包放在指定目录,系统启动时自动扫描注册,不需要任何中心化的注册流程。
目录结构如下:
code复制skills/
├── weather_query/
│ ├── manifest.yaml
│ ├── skill_weather.py
│ └── requirements.txt
├── baike_search/
│ ├── manifest.yaml
│ ├── skill_baike.py
│ └── requirements.txt
框架的扫描器会遍历 skills/ 目录下所有含 manifest.yaml 的子目录,读取 manifest 并执行注册。这意味着第三方开发者只需要遵守协议上传一个目录,平台就能自动识别。对平台方来说,也只需要把新目录同步到服务器,无需改动主程序。
为了让第三方技能声明依赖时不污染主环境,我引入了一个轻量级的虚拟环境隔离方案:
python复制class SkillEnvManager:
def create_env(self, skill_id: str, requirements_path: str):
env_path = f"/opt/skills/{skill_id}/.venv"
subprocess.run(["python", "-m", "venv", env_path], check=True)
subprocess.run([f"{env_path}/bin/pip", "install", "-r", requirements_path], check=True)
return env_path
每个技能在独立 venv 中运行,依赖不会互相打架。这在技能数量多、依赖复杂时是救命级别的好设计。代价是磁盘占用会高一些,每个技能的 venv 动辄几百 MB,但对服务器来说这个成本可以接受。
4.2 权限与安全隔离
AI 技能拥有代码执行能力,意味着安全边界必须显式定义。HagiCode 的权限控制有两道闸门:
第一道闸门是 manifest 声明。技能必须声明自己需要哪些权限,框架在加载时校验权限是否在系统允许范围内。比如一个技能申请 filesystem: true,但系统配置中该分类技能的权限被禁用,那这个技能直接拒绝加载。
第二道闸门是运行时执行拦截。我实现了一个基于 seccomp 的沙箱,限制技能进程可以进行的系统调用。但说实话,纯 Python 的 seccomp 接入比较繁琐,小团队不用一上来就上沙箱,更务实的方案是在进程级别做隔离——用 multiprocessing 或 subprocess 启动技能,并限制子进程的 CPU 时间和内存上限:
python复制def run_in_subprocess(executor, request):
with multiprocessing.Pool(1) as pool:
result = pool.apply_async(executor.execute, (request,))
try:
return result.get(timeout=request.timeout)
except multiprocessing.TimeoutError:
pool.terminate()
raise SkillTimeoutError(request.skill_id)
子进程方案虽然牺牲了一点性能(进程间需要序列化传参),但换来的是崩溃隔离——技能被杀掉不会影响主进程,这在生产环境里比性能更重要。
4.3 技能编排与组合:从单技能到多技能协作
单技能能力有限,真正体现平台价值的是技能编排。HagiCode 支持两种编排模式:
第一种是"链式编排"。前一个技能的输出作为后一个技能的输入。举个例子,用户问"北京今天适合穿什么衣服?"Agent 先调用 weather_query 拿到温度和降水,再把结果作为参数传给 dress_advise 技能,最终生成穿衣建议。我在编排层里用一张 DAG 来描述这种依赖关系,执行时按拓扑序推进。
第二种是"并行编排"。用户一次提多个独立请求,比如"分别查一下北京和上海的天气",编排层会识别出这是两个相互独立的技能调用,创建两个异步任务并发执行,最后合并结果。这里对框架的并发控制能力要求较高,我用的方案是 asyncio.gather 配合 Semaphore 控制最大并发数,避免同时发起的技能调用太多把后端接口打崩。
python复制async def execute_parallel(requests: list[SkillRequest], max_concurrency: int = 5):
sem = asyncio.Semaphore(max_concurrency)
async def _run(req):
async with sem:
return await execute_skill(req)
return await asyncio.gather(*[_run(req) for req in requests])
编排层的决策信息同样会回传给模型。模型根据流程执行结果决定下一步动作,比如某个技能执行失败,它可以尝试调用另一个等价技能替代,或者告知用户需要补充哪些信息。这正是 Agent 相比传统规则引擎的核心优势:它有灵活性,能基于当前状态动态调整策略。
5. 常见问题与排查实录
5.1 技能加载失败与模块路径困境
动态加载技能时,我遇到最频繁的问题是"ModuleNotFoundError: No module named 'skill_weather'"。排查下来,90% 的情况是因为技能模块所在目录没有加进 sys.path。技能目录在 skills/weather_query/ 下,但当前执行目录在项目根目录,importlib.import_module("skill_weather") 自然找不到。
解决办法不是手动改 sys.path,而是在加载前用规范的路径注入:
python复制def load_skill_module(skill_dir: str, module_name: str):
if skill_dir not in sys.path:
sys.path.insert(0, skill_dir)
return importlib.import_module(module_name)
这里有个细节:sys.path 插入完成后,如果技能代码依赖了同目录下的其他模块,也会正常找到。但如果你把技能压缩包上传,解压时目录结构错误,也会导致同样的问题。所以我会在加载流程里先做个目录结构校验,看 entrypoint.module 对应的 .py 文件是否真实存在,提前把问题暴露出来,而不是等执行到一半才报错。
5.2 参数校验的隐藏坑
参数校验看似简单,但实际隐蔽问题不少。最常见的一个坑:JSON Schema 中的 required 字段没有声明,模型调用时只传了一个参数,技能执行时直接访问 arguments["city"] 抛出 KeyError。解决这类问题,我不仅依赖 Pydantic 的自动校验,还在技能函数基类里做一层手动强制校验:
python复制def _ensure_required(meta: SkillMeta, arguments: dict):
missing = [f for f in meta.parameters.get("required", []) if f not in arguments]
if missing:
raise SkillArgumentError(f"missing required fields: {missing}")
另一个容易踩的坑是参数类型隐性转换。模型生成参数时,常常把数字写成字符串,比如 {"temperature": "23"},如果技能内部拿这个值做数值运算,会直接 TypeError。我的建议是在 Executor 层做一次智能类型修复,根据 schema 里声明的 type 尝试安全转换:
python复制def coerce_argument(value, expected_type: str):
try:
if expected_type == "number" and isinstance(value, str):
return float(value)
if expected_type == "integer" and isinstance(value, str):
return int(float(value))
if expected_type == "boolean" and isinstance(value, str):
return value.lower() in ("true", "1")
return value
except ValueError:
raise SkillArgumentError(f"cannot coerce value {value!r} to {expected_type}")
这个防御机制上线后,技能调用的成功率实测提升了接近 15%,因为模型产生不规范参数的概率比你想象的高得多。
5.3 并发冲突与全局状态管理
动态加载技能 + 并发执行时,资源竞争是躲不开的问题。最典型的是本地 JSON 文件作为技能存储——两个技能调用同时写同一个文件,结果互相覆盖。我最初的方案是在技能层做文件锁,但跨进程锁并不可靠。
最终的解决方式有两步:一是框架层面提供的存储组件全部基于 PostgreSQL 或 Redis,禁止技能直接写本地文件;二是对于必须写本地文件的情景,规定文件路径必须包含技能 ID 和 trace ID,从根本上杜绝碰撞:
code复制/repos/skills/{skill_id}/output/{trace_id}.json
全局状态问题还不止文件系统。我还遇到过技能模块内定义了一个全局连接池变量,结果技能重载后旧连接池没有被关闭,导致数据库连接泄漏。这个问题排查了整整两天,最后用 objgraph 工具看到旧对象仍然存活才定位到。所以,技能模块强制约束"模块级不允许初始化连接池,连接必须在函数内部创建并通过 SkillContext 传递",这句话解释起来很简单,但真要落实,还是得靠代码评审和规范文档双管齐下。
6. 生产环境落地:性能优化与稳定性保障
6.1 技能调用的性能瓶颈在哪里
技能系统上线后,最头疼的是延迟。我测过一个典型的天气查询技能,完整链路包括:用户消息 → 模型推理产出 tool call → 技能注册中心查询 → 技能执行 → 结果回传模型二次生成 → 返回用户。一环扣一环,延迟很容易超过 3 秒。其中技能执行本身可能只要 200ms,但模型推理占了 1.5 秒,剩下的时间浪费在序列化、反序列化和上下文传输上。
优化思路是缓存 + 并行。技能注册中心和技能元数据的读取频率极高,但内容几乎不变,我用 Redis 做了三级缓存:一级是本地内存缓存,二级是 Redis 缓存,三级是数据库。命中前两级时,读取元数据的耗时从 30ms 降到 1ms 以下。
另一个性能杀手是 Context 膨胀。模型每次都需要把可用技能列表作为上下文传入,每多一个技能,prompt 就多几百 token。技能上百个的时候,上下文直接爆掉。我在编排层引入了技能检索机制——先根据用户消息和会话历史,用 embedding 做一次召回,只把最相关的 10 个技能放进模型上下文,其余技能不加载。这个方案最早是被逼出来的,实测效果出奇的好,不仅省 token,模型选准技能的概率也提升了。
6.2 稳定性保障:重试、降级与熔断
生产环境里,第三方技能随时可能挂掉。我见过一个天气技能因为上游 API 限流,连续失败了一整天。要保证平台整体稳定,必须有完整的容错策略。
每个技能调用默认开启一次重试,但重试只允许在参数校验错误或上游 API 瞬时超时的情况下触发。技能自身抛错时不重试,因为大概率重试也会失败。重试间隔用指数退避,第一次等 500ms,第二次 1.5s,最多重试三次。
降级策略也值得设计。我在每个技能的 manifest 里保留了 fallback 字段,允许声明一个备选技能。主技能挂掉时,编排层自动调用 fallback 技能。像数学计算技能挂了,可以降级到通用计算器技能;天气挂了,可以降级到另一个天气源技能。这个机制在关键业务场景里帮了大忙。
熔断机制同样不能少。我在 Executor 层实现了一个简单的熔断器,跟踪每个技能的最近 20 次调用,如果错误率超过 50%,熔断器直接打开,后续调用不再执行,而是快速失败并返回"服务暂不可用"。熔断器每 30 秒尝试放行一个请求,探测技能是否恢复。这个设计避免了一个横向扩展的"慢依赖"拖垮整个 Agent 的响应速度。
6.3 上线后必须盯的四个指标
技能系统上线之后,我最关心的指标有四个,一个是调用成功率,一个是平均延迟,一个是技能数量与定期更新率,还有模型工具选择的准确率。
调用成功率好理解,低于 95% 就得排查是哪个环节出了问题。平均延迟分两个维度看,一个是 P50,一个是 P99,P99 大于 5 秒说明有异常长尾。技能数量是生态健康度的指标,长期不更新的技能说明没人用或被遗弃了,该清理就清理。模型工具选择的准确率是最难提升但最关键的,需要记录每次 tool call 和最终用户反馈,在线调优技能描述让模型更精准地选择正确工具。
这套指标我每天都会拉一次报表,任何一个指标连续三天异常,直接触发告警让值班同事进来查。没有数据做支撑的优化,全是拍脑袋。
在做 HagiCode Skill 系统的过程中,我最大的体会是:可扩展的系统从来不是设计出来的,而是被逼出来的。每一次加技能、换模型、调契约、容忍第三方不靠谱的过程,都在逼你抽象、解耦、定规范。真正的技术壁垒不在某个巧妙的算法,而在于那套让别人接入时觉得"自然、顺畅、不别扭"的协议和边界。最后再分享一个小技巧:技能描述文件里的 description 字段,没事就多打磨,哪怕只是多写一个场景示例,都可能让模型选择技能时精准很多。别忘了,这套系统的用户不只是人,还有模型。
