☰
从单体Prompt到技能层:LLM Agent技能注册与调度实践
2026/9/26 8:46:50 网站建设 项目流程

我去年重构自己的一个多智能体项目时,最耗精力的地方不是调模型,而是把散落在 system prompt 里的功能拆成一个个独立模块。这个工程后来被我收进一个叫agent-skills的项目里,本质上是一套给 LLM Agent 用的技能注册、描述与调度方案。这篇文章从我当时遇到的实际问题讲起,把技能定义规范、实现过程、接入后的排坑方法,以及团队协作时的版本管理经验完整过一遍。内容面向正在折腾 Agent 开发的读者,不管你刚开始接触大模型工具调用,还是已经有一堆 Function Calling 想统一管理,都能在这里找到一套可以落地的做法。

1. 从"万能提示词"到技能层:我为什么把 Agent 的能力拆了出来

先交代背景。我的第一个 Agent 原型非常粗暴:把所有业务规则、工具说明、输出格式全部塞进一个 system prompt,加上十几条 if-else 分支逻辑。前期功能少,这样确实跑得通。可当行为分支超过十个、工具函数超过五个之后,问题开始集中爆发。

1.1 单体提示词带来的三个实际瓶颈

第一个瓶颈是上下文预算。每个功能模块平均要消耗 500 到 800 tokens 的描述文本,加上 Few-shot 示例,prompt 很快从 2K 膨胀到 10K 以上。大模型的输入成本是一方面,更要命的是指令太长之后,模型对核心目标的注意力会被稀释,经常出现"用户问 A,模型却在执行 B"的偏差。

第二个瓶颈是参数解析。我把工具调用设计成让模型输出 JSON,然后写正则去解析。最初还很顺利,但工具参数增多后,模型输出的 JSON 结构开始不稳定,有的是字段名拼错,有的是嵌套层级不对。我不得不维护一整套兼容逻辑,解析代码越写越长,测试越补越虚。

第三个瓶颈是职责边界模糊。多个任务在同一个 prompt 里互相干扰,比如用户只是问一句"这个数据能说明什么问题",模型却触发了报告生成流程,连带执行了检索和文件写入。功能之间没有隔离,一个小改动就可能让完全无关的任务行为漂移。

1.2 技能层的本质:把能力从对话文本里抽出来

我解决问题的思路,是给自己引入了一个"技能层"的概念。什么是技能?我的定义很简单,一份技能由三部分组成:描述文件、可执行实现、校验测试。描述文件告诉模型这个技能是干嘛的、什么时候该用、参数怎么传;实现代码负责真正干活;校验测试确保干活的流程稳定可靠。

用生活里的例子类比,传统的单体提示词像一本菜谱,把备菜、切菜、炒菜所有细节都写在纸上,做菜的人每一步都要重新读;而技能库更像中央厨房的预制菜,大模型这个"前台点单员"只需要看到菜单上的菜名和简介,下单之后,后厨按照自己的标准流程出菜。菜单可以经常换,但后厨的流程是独立的、可测试的。

1.3 什么时候你的项目需要引入技能层

不是所有项目都需要这套东西。如果只是写个 Demo,一个 prompt 加两个函数就完事,引入技能层反而徒增复杂度。我在实践里总结了几个判断信号,满足两条以上就值得考虑:

  • Agent 的行为分支超过 10 个,prompt 开始出现明显的职责混杂。
  • 同一个工具函数被多个 Agent 或业务方复用,却各自维护一份 prompt 说明。
  • prompt 的固定文本已经占到了上下文窗口的四分之一以上。
  • 每改一个功能需求,都要重新跑一遍全量回归,因为你不确定哪里会受影响。

当时我的项目四个信号全中,所以重构势在必行。agent-skills这个名字也源于这次重构——它既是我的技能库项目代号,也代表了技能(skill)才是 Agent 能力的基本单位。

2. 技能库的结构设计:从目录到注册表的完整链路

技能库不是随便把几个 Python 文件堆在一个文件夹里。为了让大模型能准确选技能、让代码能稳定调度技能、让团队成员能快速新增技能,我设计了一套固定的组织规范。

2.1 一个技能的最小完整形态

每个技能在我的项目里都独占一个目录,内部结构统一:

skills/ meeting_archive/ skill.yaml main.py tests/ test_meeting_archive.py fixtures/ sample_transcript.txt examples/ call_example.md web_search/ skill.yaml main.py ...

其中skill.yaml是整个技能库的核心,它记录了模型和调度器都需要的全部元信息。我给这个文件设置了这样一组字段:

字段是否必填作用说明
name必填技能唯一标识,全库不得重复
description必填用自然语言描述技能能力,模型靠这个选技能
when_to_use推荐明确列出适用场景
when_not_to_use推荐反向排除,降低误调用率
version必填语义化版本号,用于发布和排障
author推荐负责人标识,便于问责和沟通
runtime必填指明执行环境,python 或 node
timeout必填单次调用的超时时间,单位秒
input_schema必填参数定义,按 JSON Schema 规范
output_schema推荐返回值结构定义,方便下游解析
dependencies可选依赖的其他技能或外部包

肯定会有人问:description、when_to_use、when_not_to_use 不都是给模型看的吗,写这么细有意义吗?我的回答是,这些字段直接决定模型的选择准确率。大模型选择技能的过程,本质上是在一堆候选描述里做语义匹配。描述越精确、边界越清晰,误选和漏选的概率就越低。

2.2 技能注册表:从文件系统到内存索引

目录只是存储形态,真正运行时要靠一个注册表把技能加载进内存。我写了一个加载器,启动时扫描skills/目录,逐个解析 YAML 文件,再通过约定好的入口函数完成绑定。

import yaml from pathlib import Path from agent_skills.core import Skill, SkillRegistry def load_skills_from_directory(skills_root: str) -> SkillRegistry: registry = SkillRegistry() root = Path(skills_root) for skill_dir in root.iterdir(): manifest_path = skill_dir / "skill.yaml" if not manifest_path.exists(): continue manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) entry = skill_dir / "main.py" # 这里使用 importlib 动态导入,将技能实现注册进 registry skill = Skill( name=manifest["name"], description=manifest["description"], when_to_use=manifest.get("when_to_use", ""), when_not_to_use=manifest.get("when_not_to_use", ""), version=manifest["version"], timeout=manifest.get("timeout", 30), input_schema=manifest["input_schema"], output_schema=manifest.get("output_schema"), module_path=entry, entry_function=manifest.get("entry_function", "run"), ) registry.register(skill) return registry

运行时,主 Agent 的调度逻辑会把这个注册表里所有技能的描述信息汇总成一份候选清单。具体实现中,你可以选择把候选清单塞进 tool schema,也可以直接在 system prompt 里用文本块描述,两种方式我都试过,效果差别不大,核心还是描述质量。

2.3 为什么"何时不用"比"何时用"更能提升准确率

这里有个细节容易被忽略。早期我的技能描述只写正面用例,结果模型经常在边缘场景下错误调用。比如一个"会议纪要归档"技能,描述里写了"整理会议转写文本",结果用户只是说"帮我定个明天的会议",模型也去调用了这个技能,因为没有会议转写文本输入,直接报错。

后来我在所有技能描述里都加上when_not_to_use,例如写上"仅用于已有转写文本的结构化整理,不用于日程创建、会议邀请"。效果立竿见影,误调用率大约降了四成。原理不复杂:大模型在不确定的时候倾向于选择看起来最接近的候选,反向描述相当于给它划了禁区,把那些"看似相关但实际不符"的请求拦在外面。

3. 手写一个"会议纪要归档"技能的完整实操

讲了这么多结构设计,用一个具体技能串一遍更直观。我以项目里的meeting_archive为例,演示从需求拆解到写码、再到本地自测的全过程。

3.1 第一步:拆需求,明确输入和输出

meeting_archive的需求来自我的实际使用场景:拿到一份会议转写文本,自动生成摘要、提炼行动项、并归档到指定数据库。拆下来有三个核心任务,但不需要拆成三个技能,因为它们总是串行执行,合在一起反而省一次模型调用。这个判断标准很重要——什么时候拆技能、什么时候合技能,看的不是"功能数量",而是"调用频率和组合方式"。如果任务 A 经常单独被调用,就应该拆开;如果 A、B、C 总是成组出现,就合成一个。

3.2 第二步:写 skill.yaml,给模型一份高质量说明书

这一步是最能体现经验差距的地方,描述写得好不好,直接决定模型用得对不对。我最终定稿的 YAML 长这样:

name: meeting_archive description: 将会议转写文本整理为结构化会议纪要,包括内容摘要、关键决策和可执行的行动项。 when_to_use: 用户提供会议录音转写、聊天记录或笔记,要求整理纪要、提炼行动项、生成周报素材时。 when_not_to_use: 用户尚未提供转写内容,只是在安排会议、创建日程提醒,或者询问某场会议的时间地点时。 version: 1.2.0 author: agent-skills runtime: python timeout: 30 entry_function: run input_schema: type: object properties: transcript: type: string description: 会议转写原始文本,必填 meeting_title: type: string description: 会议名称,用于归档检索,可为空 attendee_hint: type: string description: 参会人列表提示,辅助行动项归属判断,可为空 required: - transcript output_schema: type: object properties: summary: type: string description: 会议摘要,不超过200字 key_decisions: type: array items: type: string description: 关键决策列表 action_items: type: array items: type: object properties: owner: type: string task: type: string due_date: type: string archive_ref: type: string description: 归档后的记录ID

这里最重要的设计是transcript字段被设为必填。实践中我发现,如果输入字段不是必填,模型在用户没提供材料时也会硬凑一个空调用,技能层收到空字符串后容易产生语义不明的返回值。与其在代码里防,不如在 schema 层就拦住。

3.3 第三步:写实现代码,注意参数校验和错误返回

技能的实现函数本身并不复杂,核心是用一次模型调用做信息抽取,再把结果写入数据库。但有几个工程细节我吃过亏,代码里直接体现出来:

from typing import Any from agent_skills.core import SkillContext from agent_skills.llm import chat_completion from agent_skills.storage import archive_record async def run( ctx: SkillContext, transcript: str, meeting_title: str = "", attendee_hint: str = "", ) -> dict[str, Any]: # 第一层防护:参数校验,避免空输入 if not transcript or not transcript.strip(): return { "error": "EMPTY_INPUT", "message": "transcript must not be empty", } # 第二层防护:控制输入长度,防止超长文本导致超时 if len(transcript) > 20000: transcript = transcript[:20000] prompt = ( "你是一个会议纪要整理助手。请从下面的会议转写文本中提取摘要、" "关键决策和行动项。行动项必须包含负责人、任务描述和截止时间。" "如果文本中没有明确提及,字段填空字符串或空列表。\n\n" f"会议名称:{meeting_title or '未知'}\n" f"参会人提示:{attendee_hint or '无'}\n\n" f"转写文本:\n{transcript}" ) extraction = await chat_completion( context=ctx, messages=[{"role": "user", "content": prompt}], json_mode=True, max_tokens=2000, ) # 第三层防护:解析结果校验,宁可报错也不返回脏数据 record = parse_and_validate(extraction) if "error" in record: return record ref = await archive_record( namespace=ctx.namespace, content=record, title=meeting_title or "未命名会议", ) record["archive_ref"] = ref return record

第一层防护解决"模型传错参数"的问题;第二层防护解决"输入过大"的问题;第三层防护是我在项目里反复强调的原则:技能返回的数据结构必须经过程序化校验,不能直接信任模型输出。宁可在技能内部返回一个结构化的error字段,也不要让异常一路抛到主 Agent 那里,否则模型站在用户面前会给出非常离谱的回复。

3.4 第四步:本地自测,先把技能本身调对

技能的测试分成两层。第一层不经过大模型,直接在本地用固定输入调用run(),验证参数校验、超长截断、数据库写入这些逻辑是否正确。我准备了一份fixtures/sample_transcript.txt,用 pytest 写用例:

import pytest from agent_skills.core import SkillContext from skills.meeting_archive.main import run @pytest.mark.asyncio async def test_meeting_archive_empty_transcript(): ctx = SkillContext(namespace="test", trace_id="unit-test") result = await run(ctx, transcript="") assert result["error"] == "EMPTY_INPUT" @pytest.mark.asyncio async def test_meeting_archive_normal_transcript(): ctx = SkillContext(namespace="test", trace_id="unit-test") transcript = open( "skills/meeting_archive/tests/fixtures/sample_transcript.txt", encoding="utf-8", ).read() result = await run(ctx, transcript=transcript) assert "action_items" in result assert isinstance(result["action_items"], list)

第二层测试才是关键,要模拟大模型调用:把技能描述和用户问题拼在一起,让模型决定是否调用以及传什么参数,然后走完整的技能执行链路。这层测试暴露的往往是描述文件的问题,而不是代码的问题。

4. 接入主 Agent 后,我踩过的四类高频坑

技能和主 Agent 之间的协作并没有想象中顺畅。这里我把真实排障过程里最常见的四类问题按频率列出来,每一类都附上排查思路和最终解法。

4.1 技能列表太长,模型出现"选择困难"

接入十几个技能之后,我把所有技能描述一次性暴露给模型,结果模型频繁选错技能。举个例子,用户问"帮我把这篇文档存档",模型先是选了"文档摘要"技能,执行完摘要后又调用了"会议纪要归档",把摘要结果当成会议转写文本处理。

排查链路是这样的:先看日志里模型实际选择了哪些技能,发现错误集中在语义相近的技能之间。解法不是压缩描述,而是调整暴露策略。我引入了一个"意图路由"技能,第一步先用一个很小的分类任务判断用户请求属于哪个领域,然后只把该领域下的两到三个技能候选暴露给模型。实测下来,技能选择准确率从 76% 提升到了 92%。

4.2 技能返回结构不稳定,下游解析频繁崩溃

有一次某个技能偶尔会返回错误的 JSON 结构,排查发现是模型在抽取阶段把某个列表字段输出成了空字符串,而不是空列表。技能内部虽然启用了 JSON 模式,但模型在 edge case 上仍然会犯错。

这个问题靠"逼模型输出规范 JSON"是不够的,我最终在技能里加了一道程序化修正逻辑:定义完整的输出字段默认值,解析时逐个字段校验,缺失或类型不对就用默认值补齐。类似数据库里的 schema-on-read 策略。从那以后,所有技能的返回结构都经过了parse_and_validate(),下游解析崩溃基本绝迹。

4.3 长时间运行的技能让整个 Agent 卡死

早期的技能实现都是同步函数,遇到耗时操作时直接阻塞事件循环。一次调用外部 API 选了 5 秒超时,整个 Agent 的响应全部排队,用户端表现为"转圈十几秒没反应"。

排查时用链路追踪定位到是同步阻塞,解法是把所有耗时操作改成异步,并在skill.yaml里设置合理的timeout。这里有个小技巧:超时时间不是越长越好,从用户体验角度看,单技能超过 30 秒就应该返回一个"执行中"的状态,让主 Agent 先给用户一个反馈,再通过回调或轮询拿结果。

4.4 技能描述太长,反倒挤占了上下文空间

我给一个技能写了 800 字的描述,包含各种示例和注意事项,结果该技能所在领域的对话质量反而下降。原因是描述太长挤占了上下文预算,模型对用户真实诉求的关注度被稀释。

此后我定了一个不含糊的规则:技能描述正文控制在 150 字以内;适用/不适用场景各自不超过 50 字。更细致的示例可以放一个简短的 link 字段,让 debug 时查文档。模型选技能只看精炼摘要,不需要看完整手册。

5. 从单机到团队:技能库的回归测试与版本管理

agent-skills发展到后期不再是我一个人的项目,有三个同事一起往里面贡献技能。人一多,工程化问题就浮现了:改了一个技能描述,怎么确认没影响其他 Agent?怎么回滚一个有问题的版本?怎么避免多个技能的命名冲突?

5.1 回归测试:录真实对话,端到端评估

函数级别的单测只能验证技能内部逻辑,无法验证"模型能不能正确选择并调用技能"。我搭了一套回归集,把真实用户的对话样本分桶存储,每轮变更后跑一次端到端评估,关注三个指标:技能选择准确率、参数正确率、任务完成率。

回归集的样本来源要刻意覆盖正反例。正向样本是"应该调用某个技能"的历史对话,反向样本是"不应该调用某个技能但容易误判"的对话。每改一次技能描述,我都把模型在历史测试中犯过的错误单独截图存下来,沉淀成负样本。这个做法是我在排障过程中觉得性价比最高的一件事。

5.2 日志必须记录的信息:为排障留足证据

没有日志,技能库出问题就是一团迷雾。我在所有技能的统一入口接入了结构化日志,每条调用至少记录几个字段:

字段示例值用途
trace_id8f3a2c7e91关联一次完整对话链路
skill_namemeeting_archive定位具体技能
skill_version1.2.0确认是哪个版本的行为
input_snapshot截断后的参数摘要复现输入条件
output_snapshot返回结果摘要判断输出是否异常
latency_ms2840排查性能问题
token_usage860估算成本与上下文占用
error_codeEMPTY_INPUT快速归类失败原因

有了这些日志,定位问题基本不需要复现,直接按 trace_id 拉全链路记录就能看到模型选了哪个技能、传了哪些参数、技能返回了什么。

5.3 版本管理与命名空间:多人协作的底线

技能一旦被多个 Agent 共享,就不能再用"改完直接覆盖"的方式。我建立了一套简单的规则:版本号遵循语义化,skill.yaml里锁版本;发布时构建只读快照,Agent 配置里明确指定使用哪个版本的技能。

命名空间方面,每个贡献者或团队用前缀隔离,避免撞名。比如zhang/meeting_archive和data_team/report_generator,注册表里以完整路径作为唯一键。这个设计很土但非常有效,省掉了大量协调成本。

5.4 灰度发布:新技能先在低流量环境试跑

最后一个工程化建议是灰度发布。新技能写完后不直接全量上线,先在测试环境跑两三天端到端评估,再把流量切到 10% 灰度,观察日志里的错误率和用户的反馈。一个我认为值得分享的细节是:技能描述变更比代码变更更容易引入回归,因为代码有测试兜底,而描述发生语义偏移时,函数级单测完全测不出来,只有端到端评估才能暴露。

按照我这套流程跑下来,团队里新增一个技能的平均时间从最初的半天缩短到一小时以内,而且很少发生"上线即回滚"的事故。

我的实际操作体会是,技能库带来的最大收益不是省 token,也不是响应变快,而是让 Agent 的架构变得可测试、可维护。调试技能可以像测试普通函数一样单点执行,视角清晰;调试模型选择行为时有结构化日志可看,不再靠猜。这套东西让我后续扩展新 Agent 时基本不需要改动已有技能,能力边界也变得更清晰。如果你正在被复杂 prompt 折磨,不妨试着把功能拆成技能,你也会体验到那种"终于把大象装进冰箱"的轻松感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询