尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从单体Prompt到技能层:LLM Agent技能注册与调度实践

发布时间:2026/9/26 8:46:52

资讯中心
01
ARTICLE

从单体Prompt到技能层:LLM Agent技能注册与调度实践

从单体Prompt到技能层:LLM Agent技能注册与调度实践
我去年重构自己的一个多智能体项目时最耗精力的地方不是调模型而是把散落在 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 或 nodetimeout必填单次调用的超时时间单位秒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(encodingutf-8)) entry skill_dir / main.py # 这里使用 importlib 动态导入将技能实现注册进 registry skill Skill( namemanifest[name], descriptionmanifest[description], when_to_usemanifest.get(when_to_use, ), when_not_to_usemanifest.get(when_not_to_use, ), versionmanifest[version], timeoutmanifest.get(timeout, 30), input_schemamanifest[input_schema], output_schemamanifest.get(output_schema), module_pathentry, entry_functionmanifest.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( contextctx, messages[{role: user, content: prompt}], json_modeTrue, max_tokens2000, ) # 第三层防护解析结果校验宁可报错也不返回脏数据 record parse_and_validate(extraction) if error in record: return record ref await archive_record( namespacectx.namespace, contentrecord, titlemeeting_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(namespacetest, trace_idunit-test) result await run(ctx, transcript) assert result[error] EMPTY_INPUT pytest.mark.asyncio async def test_meeting_archive_normal_transcript(): ctx SkillContext(namespacetest, trace_idunit-test) transcript open( skills/meeting_archive/tests/fixtures/sample_transcript.txt, encodingutf-8, ).read() result await run(ctx, transcripttranscript) 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 折磨不妨试着把功能拆成技能你也会体验到那种终于把大象装进冰箱的轻松感。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。