ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent技能化实战:用Skills架构告别不稳定Prompt,实现可编排的工程化智能体

Agent技能化实战:用Skills架构告别不稳定Prompt,实现可编排的工程化智能体

接手 agent-skills 这个方向,最初是因为一个非常朴素的烦恼:同一个模型,同一套系统提示词,昨天还能稳定完成任务的行为,今天需求调了几行就变得飘忽不定。后来转向技能化(skills)的思路,把 agent 的行为从“靠一段长文约束”变成“靠一组结构化的技能模块去调度”,实测稳定度提升非常明显。这篇博客把 agent-skills 的设计思路、最小实现和踩坑记录完整展开,适合正在做智能体应用、想摆脱纯 prompt 脆弱性、又不想一上来就扛重型框架的开发者参考。

很多人觉得 agent 开发最难的是模型能力不够,但真正做过一段时间就会发现,更头疼的是不可控。你不知道模型下一步会调用什么、会输出什么格式、会不会在一个简单环节上突然发散。agent-skills 的核心,就是把“模型自由发挥”这件事压缩到最小范围:把常用的、稳定的、需要约束的行为固化成技能模块,让模型只负责“选哪个技能”和“填哪些参数”,而不是“现场想一套解决方案”。这样工程上可测、可追踪、可回滚,比纯提示词方案可靠得多。

1. 为什么我盯上了“agent skills”这个方向

1.1 agent-skills 到底解决什么问题

先说一个真实场景。我之前维护一个数据处理类的智能体项目,主要让模型读取结构化文件、做简单清洗、生成统计摘要。一开始所有流程都写在系统提示词里,效果也还行,但遇到几个非常尴尬的问题:一是稍微改一下业务规则,那一段提示词就得重写,牵一发动全身;二是模型偶尔会跳过清洗步骤直接去生成摘要,导致下游输出全是脏数据;三是很难做单元测试,你没法说“这段逻辑变化是模型造成的还是流程造成的”。

后来我看到技能化(skills)的思路,才意识到这其实是一个工程问题,不是模型调优问题。把任务拆成一个一个技能,每个技能内部有固定的步骤、参数、输出格式,整个 agent 做的事情就是根据用户意图去匹配技能、执行技能、组合结果。模型在其中的角色被限制成“意图理解 + 参数提取”,而真正干活的全是确定性的代码逻辑。这样一来,第一步要解决的其实是“确定性与灵活性的边界”问题:哪些环节应该固定死,哪些环节应该留给模型发挥。

1.2 技能化方案比单纯堆 prompt 好在哪

打个比方,传统 prompt 方案就像让一个厨师看着一段手写菜谱去炒菜,菜谱写得再细,火候和盐量还是靠厨师的临场感觉;而技能化方案是把“配料称重”“热锅”“下菜”“调味”都做成标准工序,厨师只需要根据客人需求决定先走哪个工序、选中哪条线路。

具体优势有这么几个方向。

第一是可复用性。同一个技能可以服务多个业务场景。比如“内容摘要”技能既可以用在信息流产品里,也可以用在日报自动生成里。你不需要为每个场景重写一遍提示词描述,只需要注册同一个技能,然后引用它。

第二是可测试性。每个技能是一个独立的函数,输入输出都有明确的 Schema,可以直接写单元测试跑回归。而传统提示词方案里,隐含的行为规则没法单测,只能靠端到端的人工体验去碰运气。

第三是可观测性。技能执行的过程会留下清晰的调用记录:模型选了哪个技能、传入什么参数、技能返回什么结果。出了问题可以直接定位到具体模块,不用对着对话日志反复猜。

第四是模型切换成本低。因为模型只负责技能选择和参数提取,这部分行为比较容易通过描述优化来适配,真正涉及业务逻辑的部分都在代码层完成,与模型厂商没有耦合。

这套思路让我重新审视了 agent 项目的架构:与其把希望寄托在“模型变聪明”,不如先把系统自身的确定性做扎实。模型永远是那个擅长举一反三但也会发挥失常的成员,而技能体系则是一个把成员行为规范成流程的协作者。

2. 一套技能系统的核心设计思路

2.1 skill 的最小单元应该长什么样

在设计 skill 结构时,我参考了不少公开资料里的做法,同时结合自己的项目反复迭代。一个 skill 最终被我收敛成下面这几个核心字段。

from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional @dataclass class Skill: name: str # 技能唯一标识,如 "web_search" description: str # 给模型看的说明,描述何时使用、能做什么 parameters: Dict[str, Any] # 参数 JSON Schema,约束模型提取的字段 handler: Callable[..., Any] # 实际执行技能的函数 dependencies: List[str] = field(default_factory=list) # 依赖的其他技能名 max_retries: int = 2 # 失败重试次数 timeout: float = 30.0 # 超时控制

这几个字段并不是随意定的。name 是用来在程序里定位技能的稳定标识,绝不能让模型自由生成;description 是给模型看的,写得好不好直接决定模型能不能准确调用;parameters 用 JSON Schema 来定义,这能直接利用模型厂商对结构化参数提取的支持,大幅降低解析出错概率;handler 是真正的执行函数,里面可以写任何确定性逻辑;dependencies 用来声明技能间的依赖关系,为后续做图编排预留空间。

实际项目中我会把 description 写得非常“功能导向”,重点是触发条件和注意事项。比如一个搜索技能的描述,我不会写“这是一个搜索函数”,而是写“当用户提出查资料、找新闻、获取最新信息等需求时使用本技能;输入应该是一个简短的字符串关键词,不要包含多余的祈使句”。这样模型在意图匹配时命中率会高很多。

2.2 技能注册与发现机制

有了一堆技能定义之后,关键问题是怎么让模型在运行时“看到”它们。这就需要一个注册中心,也就是一个技能库,把技能按照一定规则组织起来,供意图匹配环节查询。

我最初的做法非常简单:用一个全局字典,把所有技能按 name 存起来,然后在生成模型上下文时,把全部技能的描述信息打包塞给模型。这个方案在技能数量少于 10 个时完全可用,一旦技能数量增长到几十个,就会出现上下文占用过多、模型选择困难等问题。后来我做了分层:热技能常驻上下文,冷技能通过关键词索引按需加载,只有用户意图命中索引时才把对应技能描述注入。

class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} self._index: Dict[str, List[str]] = {} def register(self, skill: Skill, tags: Optional[List[str]] = None) -> None: self._skills[skill.name] = skill for tag in tags or []: self._index.setdefault(tag, []).append(skill.name) def get(self, name: str) -> Optional[Skill]: return self._skills.get(name) def search(self, text: str) -> List[Skill]: # 简化实现:按包含关键词匹配技能名与标签 matched = [] for tag, skills in self._index.items(): if tag in text: for sname in skills: if sname not in matched: matched.append(sname) return [self._skills[n] for n in matched]

注册中心的一个重要细节是:description 和索引词要分开维护。description 是给模型做意图匹配的,索引词是给自己做程序过滤的。两者互补,缺一不可。项目中期我曾经只维护 description,导致每轮都要把所有技能描述塞给模型,既不经济又容易干扰选择,加入标签索引之后整体效果立刻改善。

2.3 技能编排的两种范式

单个技能只能解决单一任务,真实业务往往是把多个技能串起来。从我的实践看,编排主要落在两种范式上。

第一种是固定流水线。技能之间的先后顺序是确定的,业务方在配置阶段就写死。比如“理解用户问题—查询数据—生成摘要—发送通知”这条链路,每一步对应一个技能,顺序不随模型意图而改变。这种做法适合业务流程稳定的场景,优点是可靠,缺点是缺乏弹性。

第二种是模型驱动的动态编排。模型根据用户意图选择技能,并且可以把多个技能组合起来执行。比如用户说“把昨天运营日报里的核心指标整理成周报”,模型需要先调用“读取数据”技能、再调用“指标分析”技能、最后调用“报告生成”技能。动态编排的灵活性高,但对技能描述的质量和上下文管理的要求也高。真实项目里我通常两种混用:主干流程固定,分支节点让模型动态选择。

有一个容易被忽略的点:技能内部的步骤也可以是“小流程”。不要把技能理解成一个单一函数,它完全可以是一个子 Agent,内部有自己的提示词和工具。这样项目会形成“大 Agent 编排技能,技能内部再编排子步骤”的嵌套模式。我建议在初期控制嵌套深度,最多两层,否则排查问题时会非常痛苦。

3. 从零搭一个 agent-skills 最小可用系统

3.1 基础结构定义

前面已经把 Skill 数据类定义出来了,这一节直接上可运行的最小系统。我用纯 Python 实现了一个极简版本,不依赖任何特定模型框架,核心逻辑都聚在三个模块里:技能定义、注册中心、执行引擎。

假设我们的 agent 需要处理一个很典型的场景:用户给一段文本,agent 要判断它属于“咨询”还是“投诉”,然后分别走不同处理路径。常规做法是让模型直接输出分类标签,但实际业务里分类之后往往还要接后续处理动作,这就适合拆成两个技能:“意图分类技能”和“意图处理技能”。

import json from typing import Any, Dict, List, Union def classify_intent(text: str) -> Dict[str, str]: # 实际项目中这里一般会调用模型或规则引擎 # 这里用一个示例,只接受模型提取的文本后进行本地规则判断 if "退款" in text or "投诉" in text: return {"intent": "complaint", "level": "high"} return {"intent": "consult", "level": "normal"} def handle_by_intent(intent: str, text: str) -> Dict[str, Any]: # 模拟下游处理 if intent == "complaint": return {"action": "create_ticket", "priority": "high"} return {"action": "reply_template", "priority": "normal"}

这是 handler 层,逻辑非常直白。真正的难点在于如何把这两个函数包装成让模型看得懂的技能,并且让模型学会“先分类再处理”的流程。

3.2 注册中心与加载器

注册中心承担两件事:把技能登记到全局库,以及按需生成“技能摘要清单”。摘要清单是真正会被拼进模型上下文的文本,它的格式直接决定模型对技能的理解程度。我通常按下面的模板生成:

def build_skill_summary(skill: Skill) -> str: schema_str = json.dumps(skill.parameters, ensure_ascii=False) return ( f"技能名称: {skill.name}\n" f"功能说明: {skill.description}\n" f"参数结构: {schema_str}\n" "---" )

加载器在系统启动时读取所有技能定义并进行注册。考虑到后续技能数量变大,加载器还需要支持按目录加载、按配置项启用停用,这样上线的技能列表就可以由配置文件控制,不用改代码。

class SkillLoader: def __init__(self, registry: SkillRegistry): self.registry = registry def load_from_dict(self, skills: List[Skill]) -> None: for skill in skills: self.registry.register(skill, tags=[skill.name]) def load_from_config(self, config: dict) -> None: # config 里可以声明需要启用的技能,以及自定义标签 for item in config.get("skills", []): skill = Skill(**item["definition"], handler=item["handler"]) self.registry.register(skill, tags=item.get("tags", []))

这段代码虽然简单,但体现了一个工程原则:技能要能配置化启停。项目早期我把技能全部硬编码注册,每次联调都要动代码,非常麻烦。改成配置驱动之后,产品同事自己就能调整线上技能范围,开发效率明显提升。

3.3 让模型根据意图调用技能

技能注册好之后,下一步就是把技能清单注入到模型消息里,并让模型返回“要调用的技能名称和参数”。这里有一个实现细节:我没有让模型直接自己去执行技能,而是让它返回一个结构化的“工具调用指令”,然后由程序安全地映射到 handler 上执行。这样做有两个好处,一是执行过程可控可打断,二是模型输出如果不符合预期,可以在映射阶段做兜底。

def build_agent_messages(user_query: str, skills: List[Skill]) -> List[Dict[str, str]]: system_content = ( "你是一个技能调度器。请根据用户需求,从可用技能中选择最合适的技能," "并提取调用参数。必须使用如下格式返回:\n" '{"skill": "技能名", "arguments": {...}}\n' ) skill_text = "\n".join([build_skill_summary(s) for s in skills]) return [ {"role": "system", "content": system_content + "\n可用技能:\n" + skill_text}, {"role": "user", "content": user_query}, ]

接着是执行循环。模型返回“调用技能”的指令后,由执行引擎解析参数、调用注册中心里的 handler、拿结果再回传给模型,让模型可以基于结果继续决策。这是 agent 应用惯用的 ReAct 式循环,只是把“action 空间”缩小到注册表里的技能集合,防止模型即兴创造动作。

def run_agent(query: str, registry: SkillRegistry, max_steps: int = 5): messages = [] skills = registry.search(query) or list(registry._skills.values()) messages = build_agent_messages(query, skills[:5]) # 只注入部分技能,避免上下文爆炸 for step in range(max_steps): model_out = call_model(messages) # 伪代码,实际接入某个模型接口 parsed = safe_parse(model_out) if not parsed or "skill" not in parsed: return {"error": "no_valid_skill", "raw": model_out} skill = registry.get(parsed["skill"]) if not skill: return {"error": "skill_not_found", "skill": parsed["skill"]} result = skill.handler(**parsed.get("arguments", {})) messages.append({"role": "assistant", "content": model_out}) messages.append({ "role": "system", "content": f"技能 {skill.name} 执行结果: {json.dumps(result, ensure_ascii=False)}" }) if is_final_answer(result): # 业务逻辑判断任务是否完成 return result return {"error": "max_steps_reached"}

这里的 safe_parse 很关键。模型偶尔会输出非法 JSON,或者输出混在普通文本里,所以我会先尝试从文本中正则提取 JSON 片段,失败再用“召唤”方式要求模型规范输出,再失败就返回错误信息。这个兜底策略在实际运行中能把成功率从 80% 拉到 95% 以上。

3.4 实际跑通一个多技能流程

用一个具体例子说明完整流程。假设我的 agent 接到的用户消息是:“我刚买的东西有质量问题,想退货”。第一步,系统把两个技能摘要注入模型上下文。模型判断这是一个投诉,于是输出技能调用指令:调用 classify_intent,参数是文章中的文本。执行引擎执行后得到 intent=complaint。这个结果回传给模型。第二步,模型看到分类结果后,继续调用 handle_by_intent,拿到下游动作 create_ticket。整个流程只用了两个技能,模型全程没有直接操作业务对象,所有副作用都发生在 handler 里。

这种设计最直接的好处是,如果我想把“投诉处理”的规则改得更复杂,比如增加一个审核步骤,只需要改 handler 逻辑或者调整技能依赖关系,模型部分几乎不用动。也就是说,技能系统把业务知识的维护地点从提示词时代转移到了代码层,工程师可以像维护普通函数一样维护 agent 的行为。

4. 落地过程中的常见坑与排查实录

4.1 模型死活不调用你想要的技能

这个问题出现的频率最高。原因也很有意思:技能的 description 写得像一份给软件开发者的 API 文档,而不是给模型看的“触发指令”。比如有人会写“查询函数,接受一个 query 参数”,模型看了之后只会在用户明说“查询”时才调用它,稍微换个说法就不触发了。

解决办法是在 description 里明确触发条件、触发句式、绝对不使用的场景。比如这样改:

当用户想查找资料、了解新闻、获取信息、确认事实时,调用本技能。 输入是查询关键词,不要包含疑问词(例如:不用传“帮我查一下”)。 如果用户只是闲聊,不要调用本技能。

改了之后,命中率提升非常明显。另外还有一个经验:参数说明要写“对用户原话的提炼”,而不是写“参数定义”。参数描述可以让模型更好地从用户输入里抽取关键词。

4.2 技能嵌套与依赖混乱

虽然前面建议最多两层嵌套,但实际项目里总有人把技能写成 A 调用 B、B 又调用 C、C 又依赖 A,形成循环引用。一旦模型在某个环节选错了依赖链,整个流程就会卡死或者无限重试。

我的处理办法有两个:第一是在执行引擎里加一个调用深度计数器,超过深度直接中断并返回错误信息;第二是在注册中心初始化时做依赖环检测,发现环直接报错拒绝启动。

def detect_cycle(registry: SkillRegistry) -> List[str]: visited, stack = set(), [] def dfs(name): if name in stack: return stack[stack.index(name):] + [name] if name in visited: return [] visited.add(name) stack.append(name) for dep in registry.get(name).dependencies: cycle = dfs(dep) if cycle: return cycle stack.pop() return [] for sname in registry._skills: cycle = dfs(sname) if cycle: return cycle return []

detect_cycle在服务启动时执行一次,开销极小,但省掉了无数个线上事故。

4.3 上下文被技能说明撑爆怎么办

技能数量增长到 20 个以上时,把所有技能的描述全部塞进消息里就不现实了。一是 token 成本快速增长,二是技能太多之后模型的选择准确率反而下降,输出经常混淆不同技能的边界。

我的应对策略是三级索引方案:第一级是一个极简的“技能目录”,只有技能名和一句话摘要;第二级是标签索引,按关键词匹配候选技能;第三级才是完整描述,只有候选技能才会把完整的 description 和参数 Schema 注入上下文。平时上下文里只有第一级目录,候选技能分析时才注入完整描述。这套方案比较适合 20-50 个技能规模的项目,再大的话可能需要引入向量检索,但思路还是一样的。

4.4 排查实录速查表

现象可能原因排查方法解决办法
模型不调用技能,直接自己回答description 缺少触发条件打印该技能注入模型的原文重写 description,加入明确的触发句式
模型调用技能但参数乱传参数 Schema 描述不清晰检查模型输出的 arguments 与真实用户输入的映射在参数描述里写明“从用户原话中提取核心名词”
技能执行报错但 agent 反复重试缺少失败策略看执行日志中的错误类型给每个技能配 max_retries,异常直接返回固定错误信息
多个技能描述互相干扰技能边界模糊把两个技能摘要放一起对比增加“不要使用本技能当……”的排除描述
上下文 token 超限技能描述全量注入统计每次请求的 token 消耗启用索引加载或压缩描述
模型输出解析失败JSON 格式不稳定检查模型原始输出增加 safe_parse 兜底,强制模型重新输出

最后说点个人体会。agent-skills 给我最大的一个改变,是让我把“调模型”这件事变成了“调流程”。以前每次效果波动,第一反应是改提示词,改完也只能靠感觉验证;现在我会先看日志:模型选了哪个技能、参数是不是对的、handler 执行是不是出了问题。这个转变带来的工程化收益,比单纯换一个更强的模型要大得多。如果你也在做 agent 类应用,建议先别急着堆功能,挑一个最小场景,把技能系统搭起来跑一遍,你会回来感谢自己这个决定的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进