ARTICLE DETAIL

资讯详情

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

为AI Agent打造可视化技能管理器:统一沉淀工具、流程与提示词

为AI Agent打造可视化技能管理器:统一沉淀工具、流程与提示词

1. 为什么我会去做一个"技能管理器",而不是继续堆 Agent

先交代一下背景。我大概从去年年初开始接触 AI Agent,前后做了几个 Rag 问答机器人、自动写周报的工作流、还有给运营团队用的竞品信息收集器。做到第三四个项目的时候,一个特别明显的问题开始冒出来:Agent 的"技能"散得到处都是。

什么是技能?说白了,就是 Agent 能调用的工具、函数、提示词模板、外部 API 接口,甚至是一套固定的多步处理流程。比如我的周报 Agent 里面有"整理聊天记录"的技能,竞品分析 Agent 里面有"抓取指定网页正文"的技能,问答机器人里面有"查数据库表结构"的技能。每一个单独拿出来都能跑,但当我准备做一个新 Agent 的时候,才发现这些能力全都没有被好好沉淀。最直观的体验是:同一个"抓网页正文"的函数,我在三个项目里写过几乎一模一样的代码,只是参数名改了改。这个状态实在说不上健康。

那段时间我也试用过几款别人的 Agent 平台,有的确实内置了工具市场,但问题也很明显:要么绑定某个大模型厂商,要么技能包上传格式奇奇怪怪,要么只能在它们自家的沙箱里跑。我需要的其实很简单——本地方案,能让我把所有 Agent 技能放在同一个收纳盒里面,可视化地查看、编辑、启用和停用,最好还有一个 HTTP 接口,方便任何项目随时调用。

于是这个"可视化技能管理器"就从一个内部工具变成了一个正经项目。它的定位很简单:通用、不绑架大模型、纯本地部署。今天把这半个多月的开发过程、技术选型、踩坑记录和一些设计思考完整写出来,希望能给同样在折腾 Agent 的朋友一点参考。

2. 先想清楚:技能管理器到底管的是什么

在动手写第一行代码之前,我花了整整两天时间梳理"技能"这个概念的边界。这个阶段想得越清楚,后面数据结构设计就越省事。

2.1 技能的身位:工具、流程、提示词的三合一

我先给技能定义了一个比较宽的范围,一共分三层:

  • 单次工具调用:比如"URL 抓正文""执行 SQL""发 HTTP 请求",这类技能一次入参一次出参,是最小的原子能力。
  • 多步流程编排:比如"读取本地日报文件 -> 调用大模型做摘要 -> 发到钉钉群",这是把多个原子能力串起来的组合技能。
  • 提示词模板与上下文策略:比如"代码评审专家""客服话术生成器",这类技能本身不调用外部系统,但决定了大模型怎么思考、怎么措辞。

很多 Agent 框架把这三类东西分开管理,工具是工具,流程是流程,提示词是提示词。但对我来说,它们本质上是同一个东西的不同复杂度表现——都是一个 Agent 在完成某类任务时需要的"能力描述"。统一管理的一个直接好处是:我在界面里为任意技能打上 tags,就能通过标签组合出"带有代码评审口吻、能读取 Git 提交记录、能调用静态分析工具"这样的复合能力,而不用去改任何代码。

2.2 命名方式:为什么用"技能"而不是"工具"

坦白说,早期原型里我一直叫它 Tool Registry,后来在一次内部使用中彻底改了名。

当时我给我妈做了一个买菜清单 Agent,她的原话是:"这个东西是不是会自动挑便宜的菜?" 我解释了半天说 Agent 会调用比价工具,她似懂非懂。后来我换了个说法:"它会用你教它的挑菜技能。" 她立刻理解了。

这就是我的感受:对使用者来说,"工具"是开发视角,"技能"是使用视角。一个卖菜 Agent 可能内部要调用 5 个工具,但在用户看来它只做一件事——挑菜。如果把视角定在用户,那用户需要被管理的就不是工具,而是"它获得了哪些能力"。这个措辞的转变,后来也影响了整个 UI 交互设计:展示给用户的永远是"这个技能能干什么、什么时候被触发、当前是否启用",而不是"这个函数接收什么参数、内部调了什么 API"。

2.3 最小信息结构到底长什么样

我最终的技能定义包含这几个核心字段,类似"技能身份证":

字段作用示例
技能 ID唯一标识,供 Agent 代码引用fetch_webpage
技能名称人类可读的中文名抓取网页正文
描述一句话说明能干什么,供 LLM 自动选择输入网址,返回页面正文 Markdown
入参 Schema参数名、类型、是否必填、默认值{url: {type: string, required: true}}
执行方式local_function/http/workflow/promptlocal_function
执行入口指向具体的函数名、API 地址或工作流 IDplugins.web_scraper.fetch
依赖标签所需模型能力、外部服务、权限级别["network", "no_auth"]
启用状态是否允许 Agent 调用true

这套结构看起来简单,但它解决了一个很关键的问题——让"技能是什么"与"技能怎么实现"彻底分离。界面上管理的是前者,代码里执行的是后者,两者通过技能 ID 关联。这样我新建 Agent 时只需要查看技能列表、挑选需要的技能 ID,然后在业务代码里像插积木一样调用管理器提供的统一执行入口即可。

3. 技术选型:不追流行,只看这四件事

这一节我会把选型的理由说透。因为市面上成熟的 Agent 框架太多了,很多人不是没有得选,而是不知道怎么选。

3.1 后端:为什么用 FastAPI 而不是 Django

项目本身是个带 UI 的管理器,核心操作是技能的增删改查、执行日志查看、启用状态切换,说白了是一个偏重 API 和异步任务的系统。FastAPI 天然契合:类型提示转 OpenAPI 文档是零成本的,我前端调接口时能直接拿到自动生成的/docs页面,省去了手动维护接口文档的时间。

另一个让我坚定选 FastAPI 的原因是Pydantic 的 Schema 校验。技能描述里每个入参都是有类型要求的,Backend 收到用户保存的 Schema 后,需要在执行前做严格校验。Pydantic v2 在这块的性能和数据类体验都很好,我几乎不需要额外写参数校验逻辑。

注意:这不是说 Django 不好。如果你做的是偏后台管理、需要大量 ORM 内置功能、团队熟 Django 的体系,Django 完全没毛病。但对我这种单人开发、接口优先、要快速迭代的场景,FastAPI 的轻量优势非常明显。

后端目录结构非常直白:

skill_manager/ ├─ app/ │ ├─ main.py # FastAPI 实例 + 路由注册 │ ├─ models.py # SQLModel 数据模型 │ ├─ schemas.py # Pydantic 请求/响应结构 │ ├─ skill_registry.py # 技能注册与查找核心逻辑 │ ├─ executors.py # 不同执行方式的分发器 │ ├─ workflows.py # 组合流程的解释器 │ └─ api/ │ ├─ skills.py # 技能 CRUD 接口 │ ├─ execute.py # 技能执行接口 │ ├─ logs.py # 日志与统计接口 │ └─ system.py # 健康检查、配置管理 ├─ plugins/ # 所有本地函数技能放这里 │ ├─ web_scraper.py │ ├─ sql_executor.py │ └─ ... └─ storage/ ├─ skills.db └─ logs.db

3.2 存储层:SQLite 起步,小项目不要过早引入 Postgres

这可能是本篇文章最有争议的选择。我见过不少人在项目第一天就 docker-compose 起一个 Postgres + Redis,原因无外乎"以后肯定用得着"。

我的态度是:如果后期真的需要,迁移成本是可控的,但在项目成型前,复杂存储带来的心智负担不值得。

所以这个项目用了 SQLModel + SQLite,一个文件解决全部存储问题:

  • 为什么不用纯 JSON 文件:技能之间有依赖关系,比如组合技能会引用原子技能,纯 JSON 我得自己处理引用一致性。SQL 用外键就解决得很干净。
  • 为什么不用 Postgres:单用户工具,同时在线执行量低,SQLite 的写锁问题几乎碰不到。而且备份特别简单——拷贝一个.db文件就完事。

如果某天技能数量上十万、需要多进程并发写,我才会考虑迁到 Postgres。到那时候代码层面只需要把数据库连接串改一下,因为 SQLModel 的 ORM 设计对数据库切换是比较友好的。

3.3 前端:不用 npm 工程,一个 HTML 加 CDN 就够

这是让我最"土"的一个决定,但也是我在真实运维中觉得最舒服的决定。

技术栈是Vue 3 + Element Plus,通过 CDN 引用,不用构建工具。很多人会觉得不严谨,先听我解释原因。

这个工具的使用者假设只有我一个人(最多加团队两三个人),它不需要复杂的路由、状态管理、权限控制,核心页面就三个:技能列表、技能详情编辑、执行日志。如果我用 Vite + Vue 工程化搭建,光 node_modules 装依赖就要几分钟,更别提每次改完代码要构建、热更新,为了一个 3 页面的 UI 搞出 200MB 的项目依赖,明显是杀鸡用牛刀。

CDN 方案启动后的效果是:我只需要用 FastAPI 挂载一个静态 HTML 文件,编辑器里直接改源码,刷新浏览器立刻生效。开发体验和调试效率都非常高。

不过,我必须说清楚这个方案的边界。如果你的技能管理器要给多角色使用、要处理复杂路由和状态同步,那还是老老实实走工程化吧。我这个方案是为了"自用工具"这个特定场景量身定的。

3.4 技能执行隔离:Subprocess 还是 In-Process

技能的"执行方式"字段里有local_function,意味着某个技能会直接执行一段 Python 代码。这里有个安全的灵魂拷问:到底是在主进程里直接调用,还是丢到子进程里执行?

我先用了一个最省事的方式:直接在进程内调用。直到有一次,我写了一个抓取网页的技能,里面用到了playwright,因为某个网站弹窗卡住,脚本直接阻塞了 40 秒,而整个 FastAPI 服务是单进程的,所有请求全部排队等着。

那次之后我立刻改成了asyncio.create_subprocess_exec的子进程方式:每个技能都在独立子进程里跑,带超时控制。超时直接 kill,主服务丝毫无感。参数传递走 JSON,结果回收走 stdout,错误单独走一个管道。

好,这节的结论是:选型不要对标大厂的架构,要对标自己的真实使用场景。只有自己才是系统唯一的长期用户时,简单直接的方案往往就是最好的方案。

4. 核心功能拆解:列表、编辑、执行、日志的闭环

现在进入正题,逐一介绍这个管理器实现的核心闭环。

4.1 技能总览界面:一张表看清所有 Agent 能力

技能总览作为首页,目标是用一张表格回答三个问题:现在有哪些技能?能不能用?最近跑得怎样?

表格每一行展示:技能名称、技能描述(截断一行)、类型、标签、启用状态、最近执行时间、最近执行结果。启用状态直接用开关切换,前端调接口只更新这一个字段。表头支持按类型筛选、按标签筛选、按最近执行时间排序。

这里有一个细节我觉得很值得分享:表格里面一定要放"最近执行结果"这一列。因为很多时候技能不是"不可用",而是"上次执行失败了但我没发现"。如果列表页直接显示红色失败,你一眼就知道哪个技能需要排查,而不是等到 Agent 调用时报错才去翻日志。实操中这个直觉真的很好用。

表格右侧是操作按钮:编辑、运行、复制、删除。

  • 编辑:进入技能配置页。
  • 运行:弹窗让你填入参,即时执行一次,用于验证该技能是否正常。
  • 复制:基于现有技能复制一份,方便生成变体(例如在"抓取正文"基础上改个超时参数变成"慢速抓取")。
  • 删除:需要二次确认,如果该技能被其他组合技能引用,弹窗会直接列出来告诉你"删了它会导致下面的流程失灵"。

4.2 配置编辑器:写人话和写 Schema 两不误

技能配置页是整个管理器技术含量最集中的地方,分为三个区域。

第一区域是"基本信息",包括技能名称、技能 ID、描述、标签、启用状态。这里我特别推动自己把描述写成"对人友好、对机器也可读"的一句话。比如不要写"通过 requests 库获取页面内容"(这是实现细节),要写"输入网址,返回网页正文(自动去除导航和页脚)"(这是能力描述)。大模型做 Function Calling 时会依赖这段描述决定要不要选这个技能,所以描述质量直接影响 Agent 的自动调用准确度。

第二区域是"入参定义",用键值对形式定义参数。前端的交互是一个可视化表格,每一行一个参数:参数名、类型、是否必填、默认值、说明。配置完后端会把这张表转成 JSON Schema。这里我顺手做了一个小功能——校验"必填参数是否设置了默认值"的矛盾情况,避免出现"必填但又有默认值"这种模棱两可的状态。

第三区域是"执行配置",根据技能类型不同而不同:

执行方式配置项
本地函数模块名、函数名、超时时间、Python 环境路径
HTTP 调用URL、方法、Header 模板、超时时间
组合流程从已有技能里按顺序添加步骤,每个步骤可复制上一个步骤的输出作为参数
提示词模板提示词内容、变量占位符、输出格式要求

本地函数类型有一个我很喜欢的交互:输入模块名和函数名后,点击"检查可用性",后端会直接用importlib去加载该函数并返回签名信息。前端拿到签名后自动解析出参数列表,然后和人工填写的 Schema 比对,提示"哪些参数在函数签名里有定义但 Schema 里漏了"。这个小功能帮我省了大量参数对齐的时间。

4.3 手动执行测试:不用写代码就能验证技能

每个技能的"运行"按钮背后,是一个执行测试弹窗。输入参数以 JSON 形式填入,点运行,后端走统一的技能执行链路,返回执行结果和耗时。

这个界面的价值在于把所有验证操作可视化。以前我要测一个技能,得写一个 Python 脚本调用函数、打印结果、断点调试。现在直接在页面上填入参、看输出、看报错,尤其适合测试那些需要大模型参与的技能——比如"根据输入生成周报",我直接在弹窗里输入这周的流水,就能看到输出效果。一次次的运行记录都会进入执行日志,方便对比不同参数下的效果差异。

对于组合流程类技能,执行弹窗还支持"分步骤调试":执行完第 1 步,停在中间结果上,确认没问题后再执行第 2 步。这个是我在调试"从财报页面到生成分析摘要"这类流程时的救命功能,不然每次都从零跑到最后一步,中间哪里出错根本不知道。

4.4 执行日志与统计:用数据发现"从没考虑过的坏技能"

执行日志页不只是流水账,我加了两个实用维度:按技能维度统计成功率和平均耗时,按时序输出完整调用链。

按技能维度统计让我发现了几个有意思的事实。比如"抓取竞品官网"这个技能成功率只有 87%,失败集中在几个特定网站的反爬拦截上,一看就知道需要使用专门的浏览器伪装技能。再比如"发送钉钉消息"这个技能平均耗时才 1.2 秒,但偶尔会跑到 4 秒,后来发现是某个代理节点不稳定导致重连。

完整调用链则是为组合流程准备的。前端把一条流程里每个步骤、每个子技能的执行时间都放在同一时间轴上展开,一眼能看出整个链路被哪个环节拖慢了。这套基础的可观测性,比单纯的"技能能跑"想得深一步,做 Agent 的人一定能体会到这有多重要——只有你知道哪个环节慢,才有资格谈优化。

5. 执行引擎:用统一入口串起四类技能的调度逻辑

技能管理器如果只是录入和展示,那和 Excel 表格没有本质区别。它的灵魂在于执行——通过统一入口,让任何技能在任何场景下被调用。

5.1 接口设计:任何 Agent 都只会看到一个execute

我提供的一个核心 API 是:

POST /api/execute { "skill_id": "weekly_report_generator", "params": { "start_date": "2026-05-01", "end_date": "2026-05-07" } }

返回值统一为:

{ "status": "success", "data": { ... }, "duration_ms": 352 }

之所以设计成"只暴露一个统一执行入口",是要让调用方不需要关心技能内部的四种执行方式。Agent 项目里只需要做一件事——在需要某种能力时,调用/api/execute,传入技能 ID 和参数,剩下的调度全部由管理器处理。

实践中最常用的场景是在 Agent 的业务代码里写一个call_skill函数:

import httpx def call_skill(skill_id: str, params: dict): resp = httpx.post("http://localhost:8600/api/execute", json={ "skill_id": skill_id, "params": params }, timeout=120) resp.raise_for_status() return resp.json()["data"]

这个函数可以直接打包给多个项目用。以后 Agent 想增加能力,我不改业务代码,只要去管理器里新登记一个技能,然后在代码里加一行call_skill("新技能ID", {...})即可。

5.2 执行分发逻辑:根据类型路由到不同的执行器

服务端收到请求之后,核心分发逻辑长这样:

async def dispatch_skill(skill: SkillModel, params: dict): if skill.execution_type == "local_function": return await run_local_function(skill, params) elif skill.execution_type == "http": return await run_http_call(skill, params) elif skill.execution_type == "workflow": return await run_workflow(skill, params) elif skill.execution_type == "prompt": return await run_prompt_template(skill, params) else: raise SkillExecutionError(f"未知执行方式: {skill.execution_type}")

分发逻辑本身一点都不复杂,真正的复杂度在四个执行器内部。我来分别说一下各自最需要注意的部分。

5.3 本地函数执行器:子进程+超时+返回值规约

本地函数执行器的第一步是动态加载:

import importlib, inspect module = importlib.import_module(skill.module_path) func = getattr(module, skill.function_name) signature = inspect.signature(func)

第二步是参数过滤。因为前端 Schema 里定义的可能比函数签名多,我把两者做交集,只传函数签名里存在的参数:

allowed_keys = set(signature.parameters.keys()) filtered_params = {k: v for k, v in params.items() if k in allowed_keys}

第三步是子进程隔离,我用 Python 自带的asyncio.create_subprocess_exec启动一个子解释器,将函数名和参数通过 json.dumps 传给 stdin,子进程执行完把结果 json.dumps 到 stdout。超时由主进程的asyncio.wait_for控制:

proc = await asyncio.create_subprocess_exec( sys.executable, "-c", RUNNER_SCRIPT, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for( proc.communicate(input=json.dumps(payload).encode()), timeout=skill.timeout_seconds ) if proc.returncode != 0: raise SkillExecutionError(f"技能执行失败: {stderr.decode()}")

这里我要强调一个很重要但容易被忽略的问题:返回值必须能被 JSON 序列化。我早期写技能的时候,有的函数返回 datetime、Decimal、numpy 数组,json.dumps 直接报错。后来我统一在每个技能函数出口加了一个 sanitize 函数,把特殊情况全部转成字符串或者 ISO 格式,才彻底解决了"技能本身跑得好好的,但管理器里无法展示结果"的困惑。

经验:技能函数约定俗成的规范是——入参只能是 JSON 可序列化的字典,返回值也只能是 JSON 可序列化的对象。这个约定一定要写在团队的技能开发规范里,不然名目繁多的类型会让你在执行器里疲于应付。

5.4 HTTP 调用执行器:模板变量注入与响应兼容

HTTP 类技能因为我几个爬虫类的需求而存在,核心功能是根据配置拼接 URL、请求头、请求体。这里有一个我踩过的坑:很多 API 的请求头里要带上时间戳、签名等动态内容。

我的解决方案是在执行器里做一个简单模板引擎——配置里可以用{{param}}的形式引用请求参数:

def render_template(template: str, context: dict): for k, v in context.items(): template = template.replace("{{" + k + "}}", str(v)) return template

这只算一个轻量的字符串替换,但已经覆盖了我日常 90% 的动态头需求。更复杂的变量提取则交给前置流程技能去处理,而不是堆在 HTTP 执行器里。

响应兼容方面,我的执行器会根据Content-Type自动决定返回格式:JSON 直接返回字典,HTML 则提取文本并做精简处理,其他二进制内容返回大小和下载提示。这样技能的输出不会因为格式差异让上层 Agent 困惑。

5.5 组合流程执行器:把"技能编排"变成数据

组合流程技能是我花了最多心思的部分,它的本质是一个轻量级工作流引擎。

配置界面允许用户选择若干已有技能作为步骤,支持三种特殊操作:

  • 变量传递:上一个技能的输出可以被下一个技能通过{{step1.output.max_price}}引用为输入。
  • 条件分支:可以写一个简单的表达式,比如{{step1.status}} == "no_result"时走另一个技能。
  • 迭代执行:针对列表型输出,可以循环执行某个步骤。

这个执行器的解释器核心逻辑大致是:

async def run_workflow(skill, params): context = {"input": params} for index, step in enumerate(skill.workflow_steps): step_params = resolve_step_params(step.params_template, context) result = await dispatch_skill(step.skill, step_params) context[f"step{index + 1}"] = result if break_condition_met(step, context): break return context.get("step_last_output", context)

这里的resolve_step_params就是做模板引用解析的,break_condition_met则是把条件表达式简单求值。我没有引入规则引擎,绝大部分场景下简单的字符串表达式已经够用。

组合流程最大的意义在于,它把技能从"单一函数"升维到了"一套方法论"。比如我的"每周行业简报"就是一个组合流程技能:先抓取若干信息源、然后对每篇内容做摘要、再按固定模板汇总成 Markdown、最后发送到邮件。这样一个复合能力在管理器里就是一行数据,我可以随时调整其中任何一步,而不用改动业务代码。

5.6 提示词模板执行器:技能和 LLM 的桥接

最后一类技能是"提示词模板"。它的执行方式不需要调用外部系统,而是通过管理器配置好的 API Key 来调用大模型。

配置数据是一个模板字符串,变量占位符用${variable}形式。执行时:

async def run_prompt_template(skill, params): prompt = render_template(skill.prompt_template, params) response = await call_llm(skill.model_name, prompt, skill.llm_params) return {"text": response, "usage": ...}

这个执行器帮我统一了所有"文字生成"类的技能。无论是写邮件、生成周报、做代码评审,它们的本质都是"一个大模型调用",但在管理器里它们有不同的描述、不同的模板、不同参数。由于它们共用同一个底层调用逻辑,我可以在管理器层面做统一并发控制、失败重试、token 消耗统计。

提示:如果你有多个 LLM 供应商,可以在技能配置里指定 model 名称,执行器再统一路由到不同网关。这也是"技能与实现分离"的价值——上层业务逻辑完全不用知道背后的模型商是谁。

6. 安全与边界:本地工具也不能裸奔

技能管理器本质是一个"可以直接控制本机工具、可以直接调用外部 API、可以直接促发任意本地函数"的入口,安全上不能大意。我做了几层防护,给各位做个参考。

6.1 本地访问绑定:不做鉴权,但绝对不开放公网

第一个决策是:这个服务只监听127.0.0.1:8600,不做公网映射。FastAPI 起服务时绑定:

uvicorn app.main:app --host 127.0.0.1 --port 8600

这样只有本机能调用。团队多人协作时,可以用 Tailscale 之类的组网工具把几台机器组进一个虚拟内网,再改成监听内网 IP。我强烈不建议图省事直接暴露公网——技能管理器比一般的 Web 应用更容易被恶意利用,因为执行任意函数这个能力太危险了。

6.2 技能来源校验:对"新增技能"做人工确认

因为技能的执行代码最终落在我本机,所以我给"新增技能"这个行为设置了一个心理门槛。在代码层面,第二重防护就是签名校验。调用/api/execute的请求头里要求带一个预设的 API Token,这个 Token 通过环境变量注入,不写死在配置文件里。哪怕服务被内网的其他设备扫描到,没有 Token 也调不了执行接口。

第三重防护是技能来源校验。在管理器界面上,技能分两类来源:

  • 内置技能:项目里由我手动编写的插件函数,质量有保证。
  • 自定义技能:用户通过界面上传的 Python 代码或 API 配置,执行前需要手动点击"验证签名"。

我在每个本地函数技能上强制要求一个元数据声明,包含开发者 ID、技能用途、期望权限级别。注册进管理器时如果元数据缺失,系统会直接标红。这个约定俗成的机制帮我避免了无数次"后来翻代码不知道当时为什么写这个"的尴尬。

6.3 超时与资源限制:防止技能跑飞

技能执行超时是最基础的限制,我一般设 10 秒,爬虫类技能 60 秒,长文档处理技能 120 秒。特别重的任务,比如训练类、批量生成类,我会建议走异步任务队列,而不是直接把执行器卡在 HTTP 请求里。

另外我在本地函数执行器里加了一个 output_size 限制,超过 2MB 的输出会被截断并打上"输出过大"标记。毕竟很多技能的输出是为了让上层 Agent 决策,塞一个 10MB 的 HTML 进去除了拖慢响应,没有任何价值。

7. 实测一场:从注册一个技能到业务里真正调用

光说结构可能还是有点抽象,这里我完整走一遍"注册技能 -> 配置测试 -> 业务调用"的全流程,让大家看到整个系统是怎么运转的。

7.1 第一步:写一个本地函数技能并注册

我拿一个非常常见的技能做例子:查询单词释义。首先在plugins/dictionary.py里面写一个函数:

def lookup_word(word: str, lang: str = "en") -> dict: # 模拟查词逻辑,实际可接入词典 API 或本地词库 result = {"word": word, "lang": lang} # ...省略具体实现 return result

然后我在管理器页面上点击"新增技能":

  • 技能名称:词典查词
  • 技能 ID:dictionary_lookup
  • 描述:输入单词,返回释义、音标、例句
  • 类型:本地函数
  • 模块路径:plugins.dictionary
  • 函数名:lookup_word
  • 入参 Schema:
    • word,string,必填
    • lang,string,可填,默认 "en"
  • 超时:5 秒

保存后,在列表页点击"运行",弹窗里填{"word": "agent"},执行成功,返回结果,整个注册流程就结束了。

7.2 第二步:在业务项目里接入技能

现在我在一个新项目daily_learning_bot里,需要让 Agent 能够回答"用户问一个英文单词是什么意思"的场景。我不需要 import 任何词典函数,只需要在 Agent 的工具列表里加一个技能调用入口:

tools = [ { "type": "function", "function": { "name": "dictionary_lookup", "description": "输入单词,返回释义、音标、例句", "parameters": { "type": "object", "properties": { "word": {"type": "string", "description": "要查询的单词"}, "lang": {"type": "string", "enum": ["en", "zh"]} }, "required": ["word"] } } } ]

当 Agent 的 LLM 判定"这需要查词典"时,就会给出{"word": "agent"}这个参数。我再调用管理器的统一入口执行:

result = call_skill("dictionary_lookup", {"word": "agent"})

整个接入过程大约只花 3 分钟,而且不需要改动任何词典逻辑。以后如果我改了词典 API,只需要在管理器里编辑那个技能的配置,业务项目的代码完全不用动。

7.3 第三步:用组合流程技能把多个动作串起来

单技能接入是基本功,组合流程才是价值爆发点。我把"每日学习卡片"做成了一个组合流程技能:

  1. dictionary_lookup:查词
  2. example_sentence_generator(提示词模板类技能):用 LLM 生成例句
  3. send_notification:把卡片发到通知渠道

配置时,我把第 2 步的参数模板写成{"word": "{{step1.output.word}}"},表示自动引用第 1 步的输出。第 3 步则引用前两步的内容拼成一个消息文本。整个流程的配置都是页面操作,没有一行代码。

这个组合流程保存后,我每天早上定时执行一次,获取当天的一个单词、例句、学习说明,推送给学习群。中间任何一步想换逻辑,比如说想把例句生成换成"从语料库检索真实例句",我只需要在管理器里把它替换成另一个技能,执行器会自动处理。

7.4 第四步:查看日志,发现薄弱环节

跑了一周之后,我看了一眼执行日志统计,发现两个值得注意的数据:example_sentence_generator的平均耗时是 2.8 秒,成功率 99%;但send_notification偶尔会失败,失败原因指向通知服务商限流。于是我给send_notification加了一个失败重试配置,再给整个组合流程加了一个更长的超时上限。

这个工作流如果不用管理器,我得改代码、本地重启服务、跑测试、重新部署,一整套下来至少半天。现在全是在界面上点几下解决的,我认为这就是"技能管理器"真正的价值所在——不是炫技,而是把 Agent 开发中的常见操作从代码层搬到配置层。

8. 避坑实录:开发过程中最折磨我的五个问题

这一节集中写我在开发过程中遇到的坑,按折磨程度排序,给正在做类似系统的朋友提前打个预防针。

8.1 用 importlib 加载嵌套模块时的相对路径地狱

本地函数执行器第一次加载插件时,不断有人报"ModuleNotFoundError"。原因是我在插件代码里使用了相对导入,比如:

from .utils import normalize_url

但子进程执行脚本的工作目录和我项目的根目录不一致,导致相对导入完全失效。

解决方法有两个,我都试过:

  • 方法一:在子进程脚本里手动把项目根目录加入sys.path,再改成绝对导入。
  • 方法二:把所有插件目录变成一个正规 Python 包,并在入口文件里显式注册。

我最终选了第一种,因为配合良好了——只需要在执行器创建子进程之前把环境变量PYTHONPATH设置为项目根目录,所有插件都按绝对导入写,问题彻底解决。

8.2 前端 JSON Schema 编辑器的"自适应难题"

我一开始天真地以为入参 Schema 就是一个普通 JSON 编辑器,让用户自己写 JSON 就行。用了几天发现体验极其糟糕:写错一个逗号,整个技能就废了;写一个嵌套很深的参数,我在手机上根本没法定制。

后来我把编辑器换成了可视化表格模式,只处理两层结构:参数名 + 类型 + 是否必填 + 默认值 + 说明。两层以上的复杂嵌套,我才允许切到 JSON 模式手动编辑。这个"默认表格、进阶 JSON"的双模方案,让我自己用起来舒服多了。

建议:如果你的技能管理器目标是普通用户(甚至非程序员),一定优先考虑表单式编辑。程序员容易高估自己写 JSON 的耐心。

8.3 子进程通信的序列化边界

前文已经提到返回值规范化,但它值得单独列为一个坑。早期的技能有返回datetime的、有返回float('inf')的、有返回自定义对象的,子进程的 stdout 根本不可能直接 dumps 出去。每次运行都能看到一个TypeError: Object of type ... is not JSON serializable。

我的最终方案是在执行器里包裹一个 normalize 函数,递归遍历返回值:

def make_jsonable(obj): if isinstance(obj, dict): return {k: make_jsonable(v) for k, v in obj.items()} if isinstance(obj, (list, tuple, set)): return [make_jsonable(v) for v in obj] if isinstance(obj, (datetime, date)): return obj.isoformat() if isinstance(obj, (Decimal,)): return float(obj) if hasattr(obj, "to_dict"): return make_jsonable(obj.to_dict()) if obj is None or isinstance(obj, (str, int, float, bool)): return obj return str(obj)

注意这里有一句return str(obj)兜底,看似粗暴,实际救了无数次命。因为有些第三方库返回的对象既没有to_dict,也不是基础类型,但str()输出仍然有可读性。与其报错,不如保留信息。

8.4 并发技能执行时的 SQLite 锁竞争

管理器支持多 Agent 并发调用技能,早期我偷懒直接用 SQLite 同步写经验记录,结果在高并发场景(比如两个 Agent 同时执行好几个技能)遇到了database is locked错误。

我的解决方案是给 SQLite 开启 WAL 模式,同时把写操作通过一个全局锁限制为单线程串行:

import sqlite3 conn = sqlite3.connect("skills.db") conn.execute("PRAGMA journal_mode=WAL")

另外在 FastAPI 里,我把数据库写入封装成一个线程池执行器,避免事件循环被阻塞。这个改动让我从"偶发数据库锁死"变成了"稳定不报错"。

8.5 界面里对"技能依赖"的展示与阻止删除

组合流程技能会引用原子技能,我在设计技能模型时用了外键关系,所以在删除原子技能时需要先检查有没有被引用。我一开始只是用后端校验,删掉时返回错误提示。但这样界面上体验不够好——用户不知道是谁引用了我。

后来我在前端列表页加了一个二级联动,点击"查看引用者"可以展开引用该技能的所有组合流程。删除前弹窗里直接列出来,并给出"解除引用"的快捷跳转。这个功能虽然不起眼,却让组合流程的维护从"盲人摸象"变成了"一望而知"。

9. 如何使用、扩展与我踩完坑后的最终心得

管理器现在已经在我本地稳定运行了两周多,我每天都会用,也把几个主力 Agent 全部切到了这个统一调用入口。如果你也想搞一个,我整理了最终的部署要求和扩展思路。

9.1 部署清单:100 行代码以内的最小起步

一套最简部署大概需要以下内容:

  • Python 3.10+,安装fastapi、uvicorn、sqlmodel、httpx;
  • 一个静态 HTML 文件,从 CDN 加载 Vue 3 和 Element Plus;
  • 一个plugins/目录,放你的本地函数;
  • 一个启动脚本。

启动命令就是:

uvicorn app.main:app --host 127.0.0.1 --port 8600

如果你想把它打成服务,可以写一个 systemd unit,或者直接放 tmux 里跑。我用的 tmux,因为改代码后重启方便(虽然我这个"CDN 前端 + 编辑器修改"的方案其实大部分前端改动不需要重启后端)。

9.2 扩展方向一:把技能管理器接入 OpenAPI 工具列表

当前比较顺滑的用法已经演示过了:在业务项目里手动写tools数组。但如果你想更彻底一点,可以在管理器里加一个接口:

GET /api/llm/openapi.json

这个接口会读取所有启用中的技能,自动把它们转换成 OpenAI Function Calling 格式的 tools 数组。这样,任何新的 Agent 项目只需要拉一次这个地址,就能拿到全部技能列表,然后直接塞进 LLM 的 tools 参数里。

这个扩展我还没有完全做完,但计划是必然要做的。一旦完成,新建 Agent 就可以完全不用手写工具定义了。

9.3 扩展方向二:给技能加上"版本"概念

我接下来要做的改进是技能版本记录。每个技能可能被多个 Agent 引用,如果我改了描述或入参,可能对已上线 Agent 造成破坏。所以我会在做编辑时记录一份当前版本副本,组合流程里可以选择固定引用某个版本,而不是永远跟随最新版。

具体设计是增加一张 skill_versions 表,存放每个技能的 JSON 快照。展示时有一个"版本历史"按钮,可以对比不同版本之间的变化。这个需求是我给线上 Agent 改技能时踩到过一次坑才想清楚的——改了描述,由于 description 字段变了,LLM 对技能的选择倾向整个变了。

9.4 扩展方向三:技能市场的局域网共享

目前管理器是单机使用,但我计划增加一个简单的"技能市场"页面。团队里任何人写好技能后,可以发起到市场的"发布申请",其他人看到后一键复制到自己的管理器。

这个功能如果能做出来,团队内部沉淀技能会非常高效。以后新同学接入时不需要私聊我问"你的抓取函数能发我一份吗",直接在技能市场里搜"网页抓取",一键复制就完事。

9.5 最后一点真实的体会

做完这个管理器,我最大的感受不是"代码写得多漂亮",而是——做 Agent 的过程,本质上是在做一套关于"技能如何被沉淀、被复用、被管理"的机制。

项目之前,我轻视了"技能管理"这个环节,觉得 Agent 写出来能跑就行。但真到了做第三个、第四个 Agent 的时候,面对一沓散乱脚本,才开始后悔没有提前统一管理。现在有了这个管理器,我每写一个新 Agent,第一件事不是写代码,而是打开技能列表,看看有哪些可以复用,缺了哪些只需要"登记"一下就能用。

这个习惯一旦养成,Agent 开发的速度和稳定性都会有质的提升。

如果你正在经历和我类似的阶段——写了好几个 Agent 却感到重复劳动严重——我强烈建议你花一两天时间,照着上面的思路,搭一个属于自己的技能管理器。不用一开始就做得面面俱到,把技能列表、统一执行、基础日志三件事跑通就够用了。等你自己在真实项目里用上一个月,你再回来审视这个工具的改进方向,自然就会知道下一步该加什么了。

返回列表