
前四篇走下来我们已经把Agent的主循环、记忆管理、LLM调用封装、上下文构建这些底座都搭得差不多了。如果只是到这里这个Agent其实更像一个能聊天的API壳子——它能理解话但做不了事。所以这一篇我要做的事情非常明确给Agent接上手也就是把Skill技能能力落地。我不打算堆概念咱们直接按照工程落地的顺序来先搞清楚Skill到底是什么、怎么设计接口然后写注册表和调度器最后接两个实际技能跑通全流程再把我实际操作中踩过的坑一并交代干净。1. Skill是什么先给Agent装上手如果你一直在做LLM应用大概率已经听过Function Calling或者Tool Calling这几个词。Skill和它们的关系我尽量用一句话讲明白Function Calling是一种协议Skill是Agent侧的能力抽象。两者不是替代关系Skill实现完之后底层仍然可以借助Function Calling把参数从LLM那边要过来。1.1 为什么不是直接复用Function Calling最早做Agent功能扩展的时候我也图省事直接在系统提示词里堆了一堆你可以调用以下函数然后把函数塞给LLM。结果一旦技能数量超过5个模型就开始犯迷糊选错函数、编造参数、重复调用什么毛病都有。这个问题的根子在于——你把底层的函数列表直接暴露给了模型但模型并不知道这些函数之间的边界、适用优先级和失败处理策略。Skill做的事情是给这些可调用能力加上一层边界抽象。一个Skill是完整的行为单元它包含名称、描述、输入参数定义、实际执行逻辑、超时策略、返回结果规范。Agent在调度技能时看到的是一份技能清单而不是一堆裸函数。好处有三点技能的增删不影响主循环代码结构完全是配置化。每个技能可以独立测试不用为了验一个函数把整个Agent跑起来。调度策略可以放在Agent这层统一控制比如并发限制、幂等控制、优先级排序。1.2 一个Skill到底长什么样我设计Skill的时候参考了插件系统的常见做法把它定义为描述符 执行器两部分。描述符负责说明技能是干嘛的、什么时候该用、需要什么参数执行器负责真正干活。两者分开很重要因为调度决策完全依赖描述符执行器只在实际被选中时才触发。下面是我目前用的Skill基类定义参考了业界常见的Skill抽象思路class BaseSkill(ABC): 技能基类所有Agent技能都需要继承并实现以下接口 # 技能唯一标识全工程不得重复例如 weather_query name: str # 技能的自然语言描述必须写清楚什么时候该调用 # 这是调度器的核心判断依据能用两句说清就别写三句 description: str # 技能适用标签用于按场景过滤例如 [tool, query] tags: list[str] [] # 参数JSON Schema按OpenAPI规范 # 尽量把required、enum、minimum这些约束写全 parameters_schema: dict {} # 超时时间单位秒防止技能卡死拖垮主循环 timeout: int 10 # 技能版本号方便后续热更新排查问题 version: str 1.0.0 abstractmethod def execute(self, params: dict) - str: 执行技能params为校验后参数返回值必须是可被LLM理解的字符串 ...描述符字段里我最想强调的就是description。这一串字的优先级比代码实现本身还高。调度器是做自然语言匹配description写得含糊Agent就会在错误的时候调用这个技能。新加入团队的开发者最容易忽略这一块——代码跑通就觉得完事了实际上description信息质量不行后面调度准确率一定崩这就是Agent的技能够用但不好用的源头。2. Skill的中心化注册表设计有了Skill定义下一步是让Agent知道我有哪些技能可用。我直接采用中心化注册表的方案主要原因是简单可靠。所有技能在启动阶段注册到一个统一Registry中Agent运行过程中不再动态增删避免了一堆并发问题。2.1 为什么非要注册制有人可能会问为什么不直接扫目录、遇到py文件就自动加载省得每次都要手动注册我一开始也这么干过搞了一个自动发现机制结果模块加载顺序、循环依赖、重复注册这三个问题轮番咬人排查起来非常痛苦。注册制看着多写一行代码但收益非常明确启动时能立即发现冲突技能列表全局可见调试的时候可以直接打印所有注册项。像这种规模不大的架构显式优于隐式能省掉后面大量的灵异问题。2.2 注册表的数据结构与核心API我给的注册表实现如下核心数据结构就两个_skills字典加上_sorted_names列表。class SkillRegistry: 技能注册表管理所有可用技能支持按名称和场景查询 def __init__(self): self._skills: dict[str, BaseSkill] {} self._sorted_names: list[str] [] self._initialized False def register(self, skill: BaseSkill): if not skill.name: raise ValueError(Skill name cannot be empty) if skill.name in self._skills: raise ValueError(fDuplicate skill: {skill.name}) self._skills[skill.name] skill self._rebuild_sorted_names() def unregister(self, skill_name: str): if skill_name in self._skills: del self._skills[skill_name] self._rebuild_sorted_names() def get(self, skill_name: str) - BaseSkill | None: return self._skills.get(skill_name) def list_skills(self) - list[BaseSkill]: return [self._skills[name] for name in self._sorted_names] def _rebuild_sorted_names(self): # 按名称排序保证每次遍历顺序稳定后续可在此扩展优先级逻辑 self._sorted_names sorted(self._skills.keys())list_skills返回的顺序就是后续调度器构建提示词的顺序所以稳定性很重要。每跑一次技能顺序乱跳LLM就很容易选错。所以我干脆在注册表内部统一排好序。2.3 启动时的自动扫描装配注册表有了Agent初始化时要把所有技能装配进来。这里我建议单独写一个build_agent_with_default_skills()这样的工厂函数不要在__init__里一股脑塞逻辑。def build_agent_with_default_skills(): agent Agent() registry SkillRegistry() # 注册内建技能 registry.register(WeatherSkill()) registry.register(CalculatorSkill()) registry.register(SystemInfoSkill()) # 挂载到Agent agent.attach_registry(registry) return agent跑起来之后我通常会加一个自检方法把所有技能列出来打印一遍。以前我懒觉得没必要结果后来有次部署少注册了一个技能线上日志里Agent反复说我不能查询天气排查了半小时才发现是漏注册。先自检再出门这个习惯现在雷打不动。3. 调度器Agent如何命中并调用Skill注册表是骨架调度器才是让技能活起来的关键。我的调度设计分三层先粗筛再精排最后执行校验。这样比一次性让LLM从N个技能里挑一个靠谱得多——N超过10的时候一次性选择的准确率下降非常明显。3.1 触发链路简化版一次完整的技能调用链路是这样的Agent从LLM拿到用户意图文本比如北京今天冷不冷。调度器遍历注册表做初步场景过滤去掉明显不相关的技能。剩余技能的描述与意图做相似度排序取名。Agent把候选技能列表用户输入交给LLM让它做最终选择并产出参数JSON。调度器校验参数执行技能把结果塞回上下文。这五步里第二步和第四步最容易出问题。第二步筛得太激进会把正确答案筛掉筛得太松等于没筛第四步则完全依赖LLM对JSON的理解能力所以参数Schema一定要写得像教科书一样清楚。3.2 描述相似度匹配的具体实现我不想引入重型的向量检索前期用轻量文本相似度就够了。我采用基于difflib的SequenceMatcher实现一个粗糙的相似度打分再配合关键词命中加权日常使用已经能拿到比较靠谱的候选集了。from difflib import SequenceMatcher def compute_similarity(query: str, skill_description: str) - float: 计算用户输入与技能描述之间的基础相似度 base_score SequenceMatcher(None, query, skill_description).ratio() # 关键词加权如果技能描述中的核心词出现在用户输入里加分 keyword_boost 0.0 for kw in [查询, 计算, 打开, 设置, 天气, 日期]: if kw in query and kw in skill_description: keyword_boost 0.1 return round(min(1.0, base_score keyword_boost), 4) def filter_candidates( query: str, skills: list[BaseSkill], top_k: int 3, threshold: float 0.25 ) - list[BaseSkill]: 先按相似度粗筛返回得分最高的top_k个候选技能 scored [(compute_similarity(query, s.description), s) for s in skills] scored.sort(keylambda x: x[0], reverseTrue) return [s for score, s in scored if score threshold][:top_k]阈值0.25是我试出来的经验值。设太高语料稍微偏一下就什么都筛不到设太低候选集总是混着七八个不相关技能增加了LLM误选概率。需要说明的是SequenceMatcher的优势是零依赖、跑得快缺点是它只做字面相似遇到同义词就歇菜。如果技能多了且用户的表达风格变化很大后期还是得上向量检索。但这不影响现在的架构迁移——把compute_similarity换成向量模型接口就行了上层不用动。3.3 参数绑定与LLM自动填充参数这块是最能体现Agent自动化魅力的环节。传统函数调用要求调用者显式传参但Skill调度可以让LLM根据上下文自动生成参数。我推荐的做法是让LLM按这个JSON格式输出{ skill_name: weather_query, params: { city: 北京, date: 2025-03-10 } }收下这段JSON之后调度器先做几个检查技能是否存在、参数是否齐全、类型是否正确。全部通过才执行。为了保险我还包了一个手动校验函数def validate_params(skill: BaseSkill, params: dict) - dict: 根据技能参数Schema校验并返回清洗后的参数失败抛出ValueError props skill.parameters_schema.get(properties, {}) required skill.parameters_schema.get(required, []) missing [r for r in required if r not in params] if missing: raise ValueError(fSkill {skill.name} missing required params: {missing}) cleaned {} for key, value in params.items(): if key not in props: continue cleaned[key] value return cleaned之前图省事跳过了校验直接执行结果LLM生成了一个负数温度给天气查询技能返回结果直接不符合业务预期排查半天发现源头是参数没校验。校验这步就是给Agent加保险丝必须留着。4. 完整实战给Agent接入天气与计算Skill原理解完来点实际的。我选两个特色鲜明的技能天气查询适合展示描述符怎么写才能被准确命中计算器适合展示参数校验与返回值格式化。4.1 实现一个本地天气查询Skill真实天气服务需要申请第三方API这里我用一个本地写死的模拟数据源来演示。这样你复现的时候不用配任何密钥跑起来马上就能看到效果。class WeatherSkill(BaseSkill): name weather_query description ( 查询指定城市在指定日期的天气情况包括天气状况、气温和风力。 适用于用户问某地今天/明天/后天天气或某地某日天气的请求。 如果用户没有明确日期默认返回今天的天气。 ) tags [query, weather] timeout 8 version 1.0.0 parameters_schema { type: object, properties: { city: { type: string, description: 城市名如北京、上海、广州 }, date: { type: string, description: 日期YYYY-MM-DD格式不传则取当天 } }, required: [city] } def execute(self, params: dict) - str: city params[city] # 实际项目中这里替换为真实API请求即可 mock_data { city: city, date: 2025-03-10, weather: 晴, temperature: 18°C ~ 24°C, wind: 东北风3级 } return f{mock_data[city]} {mock_data[date]}天气{mock_data[weather]} \ f气温{mock_data[temperature]}{mock_data[wind]}。这里有个细节值得注意我把description写得非常具体不只是说查天气还加了适用于……的正例。为什么因为早期版本我没写后半句结果用户问北京冷不冷时模型完全不触发这个技能而是自己脑补回答。技能描述里加触发条件示例命中率立刻上了一个台阶。另一个关键点execute函数的返回值必须是纯文本字符串不要返回dict。因为后续Agent要把这个结果拼进上下文重新喂给LLM剥掉格式层能让LLM理解得更顺畅。如果你确实需要结构化数据在外层封装一处序列化就行不要扔给LLM一个dict。4.2 实现一个计算Skill计算器技能看起来简单但它是检验参数校验能力的好场景。class CalculatorSkill(BaseSkill): name calculator description ( 执行数学计算支持加减乘除、幂运算等基本算术。 适用于用户要求计算数值、表达式求值的场景例如15乘以3等于多少。 ) tags [tool, math] timeout 5 version 1.0.0 parameters_schema { type: object, properties: { expression: { type: string, description: 数学表达式如 (12 4) * 3 } }, required: [expression] } def execute(self, params: dict) - str: expr params[expression] # 注意这里使用eval存在安全风险仅限可信场景使用 # 生产环境建议换成表达式解析库 try: result eval(expr, {__builtins__: {}}, {}) return f{expr} {result} except Exception as e: return f表达式计算失败: {e}这里的eval我特意注释了风险。实际工程里不要直接裸用eval用一个第三方表达式解析库更稳妥。但我写这个Skill的目的是展示技能描述和参数Schema的写法所以未做重型安全处理。如果你要复现这个示例把计算器里加一个安全限制只允许数字、四则运算符、括号和小数点。恶意的表达式输入在真实项目里不是玩笑一旦暴露到线上Agent这就是代码执行漏洞。4.3 跑通全流程的实测技能写好了我把它们接入Agent后跑了一轮完整测试。用户输入是我想算一下45和67的和另外北京天气怎么样Agent主循环跑出来的过程日志大致如下1. parse intent: 用户同时请求了计算和天气两个技能 2. skill scheduler: candidate skills [calculator, weather_query] 3. llm choose: skill_namecalculator, params{expression: 4567} 4. calculator result: 4567112 5. scheduler: candidate skills [weather_query] 6. llm choose: skill_nameweather_query, params{city: 北京} 7. weather result: 北京 2025-03-10天气晴气温18°C ~ 24°C东北风3级 8. final answer: 计算结果是112北京今天天气晴气温18°C ~ 24°C。一个比较惊艳的点是Agent能自动拆分两个连续请求并分别调度技能不需要用户把指令拆成两句话。这是因为调度器把候选技能列表 请求原文一起给LLM时模型能推理出应该连续调用两个技能。如果你的LLM版本在复杂多技能场景下效果差建议把一次只处理一个技能写进提示词宁可多轮也不要让Agent贪心一把抓。5. 接入Skill后必踩的四个坑功能跑通只是第一步。以下几个问题是我把技能数量从2个扩到15个之后陆续遇到的每一个都是真金白银换来的教训单独列出来供后来人参考。5.1 描述写得太笼统导致误触发有个技能叫reminder我最初description写的是提醒用户事项。听起来没毛病但实际运行中你记得今天下午有会吗这句话被命中成了需要创建提醒。直接后果Agent把用户的询问当作建立提醒的指令回复变成了“已为您创建下午会议提醒”。最后我把description改成了只有在用户明确要求设置一个提醒、闹钟、定时通知时使用。查询已有提醒、询问日程内容时不要使用本技能。多加了不要使用之类的反向约束后误触发几乎绝迹。给技能写描述的时候一定同时写正例和反例让调度器知道不该做什么和知道该做什么同等重要。5.2 参数定义不清晰让LLM传参靠猜早期天气技能的Schema里city字段我写的是城市两个字。看着没毛病但LLM经常传成北京市或者北京(朝阳区)。后面改成标准城市名不要带省/市后缀例如北京、上海参数清洁度立刻从及格变成优秀。凡是容易让模型自由发挥的字段都要白纸黑字标注例子。尤其是日期、时间、数量这类取值范围宽泛的参数必须明确格式。比如日期统一YYYY-MM-DD数字统一小数或整数布尔值统一true/false。你不规定模型就会用最顺手的方式帮你发挥。5.3 技能执行超时拖死主循环有一次天气API上游服务慢原本几十毫秒的响应变成了40秒超时。Agent主循环是同步等待技能返回的这期间整个Agent卡死用户问题也没人回答。解决方案是在调度器里给技能执行加asyncio.wait_for或者用线程池配合concurrent.futures.TimeoutError。技能执行必须跑在独立上下文环境里即使卡死也只影响当前这一轮调用不能拖垮整个进程。我还在执行之后统一记录耗时日志。后面做性能分析时这堆日志救了大命——一眼就能看出谁的P95耗时在恶化。5.4 同义技能冲突时的优先级策略技能多了之后会遇到两个人做了差不多功能的技能。典型例子是calendar_query和scheduler_check都能回答我明天有没有会议。两个技能同时进入候选集LLM经常随便选一个但返回信息格式不一样导致Agent下一个环节处理出错。我最终的解法很简单给技能增加一个priority字段数值越高越优先。调度时如果候选集里多个技能都命中按优先级取出最高的1个即可。优先级的设计原则是核心工具类技能 领域业务技能 通用推理技能。底层思路是重覆盖轻确保最直接、最可靠的能力优先被选择。写在最后Skill接入是Agent从能聊天跨越到能干活的关键一步。注册表保证了技能的可管理性调度器保证了技能的选择和执行可控描述符的规范化直接决定了技能的命中准度。这一篇涉及到的接口设计、调度策略目前都在我自己的模拟项目X里稳定跑着代码虽然不算复杂但边界清晰扩展新技能基本就是写一个类 注册一行的事。接下来如果要继续往前走我建议优先级最高的是给Skill层加上缓存与重试机制。同一个问题多次查询天气没必要每次都请求上游API而技能执行失败时能不能自动重试或降级到备选技能这将是Agent稳定性的下一块试金石。