做Agent开发的朋友,对“agent-skills”这个词应该不陌生。简单说,它不是某个框架的专属组件,而是一套关于“智能体技能”的设计思路与管理实践。我最近在某个内部工具链里完整落地了一套基于这个理念的技能体系,从最初的“给模型塞一堆工具函数”到后来形成清晰的技能注册、编排和调用机制,踩了不少坑,也沉淀了一些实打实的经验。这篇文章就把整个过程拆开来讲,从为什么需要技能系统,到怎么设计、怎么写描述、怎么调参、怎么排查,一次性聊透。
先给还不熟悉这块的朋友打个底。所谓“技能”,你可以把它理解成给Agent预装的一套“操作手册”。大模型本身只会生成文本,它没法直接查数据库、没法调API、没法操作文件系统。要让Agent真正干活,就得给它提供一批可调用的外部能力,同时让它知道“什么场景该用哪个能力、参数怎么填、结果怎么理解”。把这些能力以结构化的方式组织起来,让模型能够自主识别、选择、调用,这套东西就是技能系统。它解决的核心问题有三个:怎么让模型选对技能、怎么让技能乖乖执行、怎么让执行结果真正帮到模型完成最终任务。
1. 为什么Agent需要一套“技能系统”
很多人一开始做Agent,习惯把所有工具函数一股脑丢给模型,再把提示词写得又长又细,指望模型自己“悟”出调用方式。实测下来,这条路在小规模试验时还能跑通,一旦技能数量超过十个、参数超过三五个、任务复杂起来,模型就开始频繁选错工具、填错参数,甚至完全忽略可用工具,直接凭幻觉硬编一个结果。这背后是模型在长上下文里对工具意图的识别精度会显著下降,同时没有约束的开放性调用会让错误被迅速放大。
技能系统本质上是在模型和真实世界之间加了一道“结构化缓冲层”。它把每个技能的用途、触发条件、参数约束、返回值格式、副作用说明全部显式化,让模型不需要“猜”,只需要“匹配”。这和人类工作的逻辑是一样的:新员工拿到一本清晰的操作手册,知道什么情况走什么流程,比全靠老员工口传心授要可靠得多。这也是我后来坚持把技能描述当成一等公民来对待的原因——写描述花的时间,比写执行函数本身还多。
1.1 模型的瓶颈与技能补位的关系
大模型的上下文窗口不管多大,都是有限资源。你把二十个工具的说明文档塞进系统提示词,真正留给任务相关信息的空间就被压缩了。而且模型在生成调用时,需要在长长的工具列表里做注意力决策,工具越多、描述越长,决策准确率就越低。这不是模型不够聪明,而是信息架构出了问题。
技能系统通过“先粗筛、再精调”的思路绕开这个瓶颈。外层维护一个技能注册中心,每个技能只暴露精简后的“线索信息”——技能名、一句话描述、关键参数、调用条件。模型先基于这些线索快速定位最相关的两到三个候选技能。真正完整的调用说明和使用示例,放在技能执行时由运行时动态注入。这样既保证了模型有足够的决策依据,又不会让上下文被无关信息淹没。
另外,技能系统还能天然承担“能力边界管理”的职责。有些操作我们并不希望Agent擅自执行,比如删除数据、发送邮件、调用计费接口。通过在技能注册表里显式标记权限级别,并在执行链路上做强校验,就能在模型层之上加一道可控的安全闸门。这一点在面向生产环境的Agent里几乎是刚需。
1.2 技能系统要解决的三类问题
第一类问题是技能识别。用户说“帮我把这个月的销售数据做个汇总”,模型需要知道应该去调“数据查询”技能而不是“报表生成”技能。识别的准确度取决于技能描述的清晰度和差异化程度。第二类问题是参数解析。用户的话是口语化的,但技能需要的是结构化参数。技能系统要在运行时完成从自然语言到参数的转换、校验和兜底,缺哪个参数要能明确追问。第三类问题是执行编排。复杂任务往往需要按顺序调用多个技能,前一个技能的输出要能作为后一个技能的输入。技能系统需要支持这种链式调用和中间状态的缓存管理。
这三个问题任何一个处理不好,Agent都会变成“看起来很聪明,一用就露馅”。技能系统的价值就在于把这三个问题的处理逻辑从模型提示词里抽离出来,变成独立的、可控的、可测试的代码逻辑。
2. 技能描述体系的整体设计思路
我最早做的版本,只是把技能名和函数说明拼成一段文字塞进Prompt,效果飘忽不定。后来把一个供应商的API文档结构搬过来做参考,才意识到技能描述应该是有“Schema”的,最好能做到字段级别的结构化。经过多次调整,最后沉淀下来的模板包括核心七个部分:
skill: name: query_sales_report display_name: 销售数据查询 description: 查询指定时间段、指定区域的销售汇总数据,返回订单量、销售额、同比环比等指标 tags: [数据查询, 销售, 报表] triggers: - 销售汇总 - 销售额查询 - 月度销售数据 parameters: - name: start_date type: string format: YYYY-MM-DD required: true description: 查询起始日期 - name: end_date type: string format: YYYY-MM-DD required: true description: 查询结束日期 - name: region type: string enum: [华东, 华南, 华北, 西南] required: false description: 区域筛选,默认全国 return: schema: [...] permissions: level: read_only handler: functions.query_sales_report这个模板看起来很费事,但这些结构化的字段在全流程里有明确的用处。后面我详细拆解。
2.1 技能描述怎么写才不容易“翻车”
技能描述最忌讳的是写得像招聘JD——“负责完成相关数据的查询工作”这种说法模型看了跟没看一样。要写成给一个急性子的同事做交接那样:他只需要在十秒内弄清楚“这个技能能干吗、什么时候用、需要什么材料”。描述的前半句先讲功能,后半句再补充边界,这样模型在快速扫描时更容易命中。
trigger字段是实战中提升识别准确率最明显的一招。模型对“用户请求里出现了什么词,应该联想到什么技能”这件事,直接写在trigger里比光靠模型自己推理靠谱得多。我试过在加了trigger字段之后,同一批测试用例的选错率降了一半以上。trigger最好是具体的中文短语、口语表达,别写成“数据相关”这种大而空的词。
边界条件的说明一定要写明。比如查询技能只能查最近三年的数据,或者报表导出只支持PDF格式,这些不写清楚,模型就会在用到时硬生成一个不存在的参数,最后执行的时候报错,反馈体验很不好。写清边界不是限制能力,而是帮模型在碰壁前就调整期望。
2.2 参数解析与默认值策略
参数定义看着是给机器看的,实际上是给两方看的:一方是模型,它需要知道该提取哪些信息;另一方是校验器,它负责在调用真正发生前挡住非法参数。我在这块踩过一个大坑——早期参数定义里没写format,结果用户说“从去年十一月到今年二月”,模型提取成了“2024-11-1”,后端Date对象解析直接崩了。后来所有时间字段一律在描述里强调“统一为YYYY-MM-DD格式”,并让参数校验器在拿到参数后先做标准化解析,再交给业务函数。
对于可选参数,不要只依赖模型“自己判断要不要填”,而是给一个合理的默认值规则。比如“不指定区域时默认全国”,这个默认逻辑可以在参数定义里用default字段显式标注,也可以在handler内部做兜底处理。两种方式我都试过,最终选了双保险:模型层的描述给出默认规则,执行层再用代码强制兜底。
2.3 权限控制和无副作用设计
技能系统上线给真实用户用之后,权限问题会立刻暴露出来。只读技能、写操作技能、高权限管理技能,如果混在一起不加区分,模型很容易在意图模糊时选到危险的那个。我的做法是给每个技能配置permissions字段,并在技能调用链路里放一个鉴权拦截器——只有当前会话上下文具备对应权限的请求,才会被放行到handler。
另外一个很有价值的习惯是把技能设计成“尽量无副作用”。也就是说,执行同一个技能、传入同样的参数,应该返回同样的结果,不要在执行过程中偷偷改状态。这样有几个好处:出了问题可以重复复现;多个Agent并发调用时不会互相踩;后续做技能测试和回归也更方便。查询类技能天然符合这个特性,写操作技能则建议先拆成“确认预览”和“确认提交”两步,让Agent在中间有一道和用户确认的环节,这样安全性和体验都会好很多。
3. Agent执行循环与技能编排的衔接
技能设计得再好,最终还是要嵌入到Agent的运行循环里才能生效。我用的主循环结构不算复杂,核心是“决策→执行→观察→再决策”的循环。每一步循环里都包含模型生成、技能调用、结果观察、记忆更新这四个环节。技能系统在这里面起了两个作用:一个是给模型提供可选的行动集合,另一个是把模型的决策翻译成真实世界可执行的操作。
我整理过一份伪代码,把执行循环的核心逻辑概括成了这样:
# 初始化用户目标和上下文 messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_query}] for step in range(max_steps): # 1. 从技能注册中心加载当前可用的技能摘要列表 available_skills = load_available_skills(session_context) # 2. 将技能摘要注入消息,并请求模型生成下一步决策 decision = llm.generate(messages, tools=available_skills) # 3. 如果模型输出的是技能调用意图 if decision.type == "skill_call": skill_name = decision.skill_name raw_params = decision.parameters skill = fetch_skill_by_name(skill_name) # 4. 参数解析与权限校验 parsed_params = validate_and_parse(skill, raw_params) check_permission(skill, session_context) # 5. 执行技能处理器,收集结果 result = skill.handler(**parsed_params) # 6. 把执行结果作为上下文反馈给模型 messages.append({ "role": "tool", "name": skill_name, "content": json.dumps(result, ensure_ascii=False) }) else: # 模型直接输出最终回复 final_answer = decision.text return final_answer # 超时保护 raise TimeoutError("Agent run exceeded max steps")这一步的关键在于“如何让模型的决策意图可靠地被代码理解”。我用的是工具调用协议的方式,模型输出结构化的技能名和参数,代码侧再解析执行。这里还有一个被我保留到现在的设计点:每次技能执行完毕,把结果原样不删减地反馈给模型,哪怕是执行出错,也要把错误信息原封不动传给模型,让模型有机会自己调整参数或者换一条路。很多开发者在出错时会选择直接抛异常给用户,这其实有点浪费,给模型一个“自己补救”的机会往往能把任务完成率拉高不少。
3.1 技能编排的两种模式:自动编排与预设流程
编排有两种模式,我把它们分别叫“自由编排”和“流程编排”。自由编排是模型自行决定调哪个技能、调几次、什么顺序。这种方式灵活,适合探索性的问题,缺点是结果不稳定,同样的输入可能走不同的路径。流程编排则相反,它把一个多步骤任务预先拆成有向的步骤链,每个步骤对应一个技能,Agent只能沿着预设流程走,不能跳步。
我在实际项目里两种模式都在用。偏稳定执行的任务,例如周报生成,按“取数→加工→排版→发送”的流程图一步步走,又快又不容易出错。偏发散的任务,例如“帮我想一个产品推广方案”,让Agent自己调检索、参考模板、查阅案例,反而能撞出一些意想不到的思路。在设计技能系统时,两种模式需要配置不同的运行时策略:流程编排要增加步骤状态管理,自由编排则要限制技能跳转的幅度,避免Agent自己绕进死胡同。
3.2 技能调用的记忆与上下文管理
一个完整的Agent会话里,一个技能往往会被重复调用多次。比如用户先问“上个月销售额”,又问“那华东区呢”,这时候模型如果每次都从零开始理解“华东区”指什么,就会绕远路。技能调用结果的记忆机制就是为了缩短这个路径。
我采用的做法是:在技能执行后,把结果存进会话的短期记忆区,并给记忆打上技能的标签,注明时间范围、对象实体等关键元信息。下一次模型需要相关数据时,可以先用“记忆检索”技能把已有结果取出来,判断数据是否仍然可用,如果可用就直接基于旧的执行结果展开,而不是重新调一次完整技能。这个机制在数据量大的场景能省下不少token,更重要的是,它减少了重复调用导致的口径不一致问题。
上下文管理还有另一个维度:在把技能执行结果注入消息时,不要让原始JSON原样堆进上下文,而是先做一层摘要或字段裁剪。对于那些动辄几百KB的查询结果,直接塞回给模型会让上下文爆炸。我通常会加一个“观察压缩”环节——把结果核心字段抽出来、超长列表截断到前N条,再用一句话总结数据规律。这样模型既能拿到底层数据语义,又不会被巨大文本拖住注意力。
4. 实操:搭建一个最小可复用的技能系统
前面聊了这么多设计思路,现在进入实操环节。下面这部分我会给出一个可以直接参考的极简实现,用Python跑通“用户提问→Agent决策→技能调用→结果反馈”的完整链路。这个实现没有引入重型框架,依赖关系尽量少,方便你在自己的项目里迁移和改装。
动手之前先想清楚最小闭环需要哪些组件。第一个是技能注册表,负责存放所有技能的定义和对应的handler映射。第二个是技能摘要渲染器,负责把技能定义压缩成适合注入对话上下文的简明描述。第三个是Agent决策循环,负责调用模型做决策。第四个是参数解析与执行器,负责将决策里的参数校验后传给handler并拿到结果。这四个组件串起来就是一个最小的技能系统。
4.1 项目结构与技能数据模型
我建议用如下目录结构组织代码,简单而且便于后续扩展:
agent-skills/ ├── skills/ │ ├── __init__.py │ ├── registry.py # 技能注册与管理 │ ├── renderer.py # 技能摘要渲染 │ ├── query_sales.py # 示例技能:销售数据查询 │ └── report_gen.py # 示例技能:报表生成 ├── core/ │ ├── __init__.py │ ├── context.py # 会话上下文与记忆管理 │ ├── runner.py # Agent执行循环 │ └── executor.py # 参数解析与调用执行 ├── configs/ │ └── skills.yaml # 技能定义的YAML配置源 └── main.py # 演示入口技能注册表的核心数据结构,我直接用Python字典来表示,方便大家理解。每个技能的条目长这样:
# skills/registry.py SKILL_REGISTRY = {} def register_skill(skill_def): SKILL_REGISTRY[skill_def["name"]] = { "definition": skill_def, "handler": skill_def["handler"], "permissions": skill_def.get("permissions", "read_only"), }技能定义的加载,我写了两个渠道:一个是从YAML配置文件中读取静态定义,另一个是代码里动态注册。静态配置适合那些稳定不变的基础技能,动态注册适合那些依赖特定上下文的临时技能。两者最终都会汇入同一个注册表,执行逻辑不区分来源。
4.2 让模型看懂技能摘要的渲染策略
刚才介绍设计时提到“不要把完整定义塞进Prompt”,但具体来说要保留哪些信息呢?我的渲染策略是给每个技能生成一个精简块,只包含技能名、一句话作用、触发关键词、参数名称及约束。完整说明、示例、边界细节全部留在执行阶段按需加载。这样在技能数量多的时候,上下文里这部分信息量是可控的。
# skills/renderer.py def render_skill_summary(skill_def) -> str: params_desc = ", ".join( f"{p['name']}({'必填' if p.get('required') else '可选'}: {p['description']})" for p in skill_def["parameters"] ) triggers = " / ".join(skill_def.get("triggers", [])) return ( f"- {skill_def['name']}: {skill_def['description']}。" f"触发词: {triggers}。参数: {params_desc}。" )将技能摘要拼接成系统提示词时,我会在前后加上“可用技能如下”和“只能选择上述技能中合适的技能,不能臆造技能名”的约束语。实测这种方式比零散地贴工具列表更稳。另外不要忘了,在每轮决策时,RE-渲染当前可用技能——因为有些技能会随会话阶段失效,比如某些管理类技能只能在特定上下文触发,其余时候就不该出现在摘要里。
4.3 决策循环中的核心代码细节
决策循环里最值得打磨的地方,是如何解析模型输出。我用的模型推理结果里有专门的工具调用字段,格式相对标准。但为了兼容不同模型,我还在代码里做了一层兼容解析器——如果没拿到结构化工具调用,就去尝试从返回文本里正则提取“技能名”和“参数JSON”。这层兜底在换模型时特别有用,不至于因为模型输出格式差异导致整条链路断裂。
# core/executor.py def execute_skill_call(skill_registry, skill_name, raw_params, context): skill = skill_registry.get(skill_name) if not skill: return {"ok": False, "error": f"未找到技能: {skill_name}"} # 参数解析与校验 try: parsed = parse_and_validate(skill["definition"]["parameters"], raw_params) except Exception as e: return {"ok": False, "error": f"参数错误: {str(e)}"} # 鉴权 if skill["permissions"] not in context["allowed_permissions"]: return {"ok": False, "error": "当前会话无权调用该技能"} # 执行并捕获异常 try: result = skill["handler"](context, **parsed) # 结果标准化包装 return {"ok": True, "data": result, "skill": skill_name} except Exception as e: return {"ok": False, "error": f"执行失败: {str(e)}"}另外一个我比较坚持的细节是对技能执行全程记录日志。每次调用都记录:用户原始输入、模型决策携带的技能名、参数解析前后的内容、执行耗时、返回值摘要。这条日志在问题排查时价值极大,尤其是当模型决策与用户意图出现偏差时,顺着日志能很快定位到是描述问题、参数问题还是执行问题。
4.4 一个完整示例:从用户提问到最终输出
下面我用一个具体的例子走一遍全流程。用户提问是:“帮我查一下今年3月华东区的销售总额,跟2月比怎么样?”。
Agent主循环收到的消息包括系统提示词、技能摘要列表和用户的问题。模型决策时看到“销售总额”“华东区”这些线索,结合触发词匹配,选中了query_sales_report技能,并生成参数:start_date=2025-03-01、end_date=2025-03-31、region=华东。
参数解析器将日期标准化,确认必填参数齐全,校验器通过。技能handler执行,去后端查询该时间段华东区的销售数据,返回一段JSON:总额、订单量,以及用于对比的2月数据。执行器把这段结果压缩成一句汇总:“华东区2025年3月销售总额为1280万,较2月增长8.3%”,作为工具执行结果添加进消息队列。
模型拿到压缩后的结果,结合用户的原始问题,生成面向用户的最终回复:“华东区3月销售总额为1280万元,环比2月增长了8.3%。”整个过程看似简单,但每一步都在技能系统的约束下进行,模型不需要知道数据在哪里、查询语句怎么写,它只负责“决策和表达”,数据和执行都由技能层负责。
5. 高频踩坑点与排查思路
技能系统跑起来不难,但想跑得稳,确实要靠踩坑积累经验。下面这些坑基本都是在真实使用中遇到的,我按出现频率排个序,每一个都给出了排查路径和最终的解决思路。
5.1 模型选错技能:多为描述模糊或触发词覆盖不足
这类问题最突出的表现是,用户明明在问“这个文档能不能帮我翻译一下”,模型却调用了“文档摘要”技能,选错了还一本正经地输出摘要。查日志后通常会发现,技能摘要里的“翻译”触发词没写全,或者“文档摘要”技能描述里用了“处理文档”这种含混词。修法是补充触发词,同时把技能描述改成“针对已有文档的内容进行提取、结构化、概括,不改变原始语言”。
还有一个技巧——把容易混淆的技能之间的差异明确标注出来。比如“查天气”和“查空气质量”两个技能,如果描述都是“查询环境信息”,模型很难分辨。改成“查询实时天气情况(温度、降水、风力)”和“查询实时空气质量指数(PM2.5、AQI、污染等级)”,差异一下就清晰了。
5.2 参数反复校验不通过:需要细化数据类型与格式说明
有一段时间系统频繁报“参数错误”,排查下来大部分原因是模型把数字参数填成了中文字符。比如“查询前5条”被提取成“五”,或者时间填了“去年三月份”。解决办法是把参数类型和格式写得非常死板,同时在解析器里做大写数字转阿拉伯数字、月份转具体日期一类的容错处理。与其指望模型不出错,不如在解析层多做一些宽容度设计。
5.3 技能执行超时或循环调用:要加步数上限和单步时间限制
韧性强的技能系统必须考虑失控的可能。我给Agent执行循环设置了最大步数,这里可以按任务复杂度设置为8到15,同时给每次技能调用设置了10秒的超时上限,超时直接返回“执行超时”的提示,把这个结果反馈给模型作为重试或换路线的依据。模型有时候会因为拿不到结果而反复调用同一个技能,形成死循环,这时候步数限制就是最后的刹车。
超时兜底以外,还可以加一个“技能调用频率限制”,同一技能在单轮会话中最多调用三次。这个限制尤其适合查询类技能,因为重复查同一段数据几乎不会带来新信息,反而浪费时间和token。如果真的需要不同口径的数据,应该通过参数变化来体现,而不是原样重调。
5.4 权限边界被绕过:不要只靠模型自觉
权限控制这块,我起初想的是在技能描述里写“此技能需要管理员权限,非管理员不可用”,结果发现模型并不会严格执行这行描述。后来的方案是完全不在模型可见的摘要里暴露高权限技能,只有在满足条件时才动态注入对应技能的摘要。这样模型连“有这个技能”都不知道,自然也就不会去调它。这个“物理级”的隔离,比在描述里反复强调管用得多。
5.5 日志查询困难的补救:给每次调用加trace_id
当技能调用链路变深了,排查问题的核心抓手就是链路追踪。我在执行入口给每次任务分配一个trace_id,全程贯穿消息队列、技能执行和日志记录。这样即使一个任务里调了七八次技能,有任何一次出问题,也能顺着trace_id把所有相关日志一次性拉出来,对照上下文看是哪一步决策出了问题。这个习惯是从后端微服务排查里迁移过来的,在Agent场景同样适用。
6. 经验沉淀与更多扩展方向
这套技能系统从设计到落地,我最大的体会是:技能系统的难点不在写代码,而在定义“边界感”。技能边界厘清楚了,模型的行为就稳定;边界模糊,模型就给你表演“自由发挥”。所以每新增一个技能,我都会先写一份技能说明书,然后花十分钟自问几个问题:用户会在什么情境下想用这个技能?哪些词可能会被误解成别的技能?参数最容易被填错的格式是什么?权限上会不会有越界风险?这些问题想清楚了才动手写实现。
对我个人来说,还有一个比较受用的工作习惯:每次在测试集上跑完一批用例,我会专门挑出那些“模型决策错误”的case,不急着改代码,而是先去看错选的技能描述里有没有模糊表达。大多数情况下,修一修描述文本和trigger词,比在模型层面调整参数更有效,也更可控。模型能力固然重要,但在确定性任务上,外部的结构化约束带来的收益往往比想象中更大。
后续要做扩展的话,有几个方向我列在这里供参考:
技能自动发现。把技能定义放到一个远端配置中心,运行时自动下发新增技能,减少每次上线新能力都要发版的繁琐过程。
技能质量评估体系。为每个技能建立准确率、调用率、失败率、异常类型分布等指标,定期复盘整个技能库的健康度。
组合技能模板库。在技能之上抽象“工作流模板”,例如“数据周期报表生成三步法”,把常用的编排模式沉淀为可复用模板。
多Agent技能共享。当一个系统里存在多个Agent角色时,把技能系统做成共享服务,不同角色按需挂载不同技能组,既能复用又能隔离。
技能系统不是一个“做完就扔”的工具,它是一个持续生长的能力层。只要你还在往Agent上加新功能,它就会一直有被优化的空间。希望这篇文章能把我的思路和教训完整传给你,帮你少走一些弯路。如果你正在搭自己的技能系统,欢迎对照这篇内容做个体检,看看有没有遗漏的设计点。