☰
让Agent稳定干活:技能注册表与参数契约设计
2026/10/13 18:55:20 网站建设 项目流程

1. 项目整体设计:为什么我给 Agent 装了一套“技能包”

如果你最近在折腾大语言模型应用,大概率已经被“Agent”这个概念轰炸过一轮了。但真正上手之后你会发现,Agent 的骨架搭起来并不难——无非是模型、记忆、工具调用三板斧,难的是让这个 Agent 在真实任务里稳定地干活,而不是聊着聊着就开始胡说八道。

我做的这个“agent-skills”项目,本质上就是为了解决这个问题:给 Agent 预装一套结构化的“技能包”,让它知道自己能干什么、该怎么干、干到一半遇到问题该找谁。这套设计不是从零发明的,而是把工程上常见的“模块化 + 注册表 + 参数契约”的思路搬到了 Agent 场景里。

先说结论:把技能做成可注册、可发现、可校验的独立模块,Agent 的稳定性会有质的提升。如果你正在做客服机器人、自动化工作流、代码助手或者任何需要调用外部工具的 LLM 应用,这套思路可以直接复用。

1.1 从 Prompt 模板到技能抽象:Agent 开发的核心痛点

早期做 Agent 的时候,我是踩过坑的。最早的方案特别原始——把所有工具的描述、调用方式、注意事项全部塞进 system prompt,然后用 few-shot 示例引导模型调用。小规模跑通没问题,但工具一多就出乱子。

举一个真实的例子。我做过一个内部运维助手,一开始挂了 12 个工具:查日志、查指标、发通知、查工单、执行脚本、查配置、查权限……这些工具的完整说明加起来已经能撑满半个上下文窗口。结果模型经常出现三种问题:该调“查指标”的时候调了“查日志”;需要传时间参数的时候把参数格式写错;两个工具都能满足需求时,选择完全随机。

这说明一个核心问题:大模型本身并不天然理解“工具边界”。它只能根据描述去猜,而描述一旦含糊,猜测就会出错。技能抽象就是为了解决这个问题的——把工具的调用约定、适用场景、参数约束全部结构化,让模型在做选择时有据可依,而不是靠“感觉”。

1.2 技能系统的边界划分:什么该进技能库,什么不该进

在设计技能库之前,我花了大量时间思考一个问题:到底什么样的能力应该封装成一个“技能”?

我的标准很简单,满足以下条件就值得做:

  • 职责单一:一个技能只做一件事,比如“查询用户订单状态”,不要做“查询订单 + 退款 + 催单”这种复合技能。
  • 参数可契约化:技能的输入输出能明确描述,有清晰的参数结构。
  • 可复用:同一个技能能在多个流程里被反复使用,而不是只服务于一个特定场景。

反之,一些能力就不适合做成技能——比如需要长上下文理解的推理任务、依赖多轮对话才能确定意图的事情、以及包含大量主观判断的决策类工作。这类任务强行做成技能,反而会让 Agent 的调度变得笨重。

另外还有一条边界值得注意:技能和插件的区别。插件是完整的功能模块,技能则是插件内部的一个可执行动作。拿浏览器插件举例,插件是“网页数据采集器”,技能则是“翻到下一页”、“提取当前页面的标题”、“点击确定按钮”——是更细粒度的能力单元。

1.3 技术选型:参考主流框架的成熟经验

我的方案选型上没有标新立异,基本是站在巨人的肩膀上做的。先对比了市面上的主流思路:

方案方向优点缺点适合场景
全量工具说明注入 Prompt实现简单上下文浪费大、易混淆工具数量 < 5 的原型验证
Function Calling 原生支持结构规范、生态成熟灵活度受平台限制快速接入标准模型能力
自建技能注册表 + 动态加载可控性强、扩展灵活开发工作量大中大型项目、跨模型复用

我最终选了第三种:自建一套轻量级的技能注册和调度机制。原因在于我的目标不是做一个 Demo,而是一个能长期演进、多模型复用的框架。技能注册表的好处是:模型的部署平台可以随时切换(比如从 GPT 切到开源的国产模型),技能库本身不去依赖某个具体平台的消息格式,这样整套工具不会被厂商锁定。

实操心得:关于模型选型,如果你做的 Agent 相对简单,任务类型固定、工具数量不超过 8 个,直接用 Function Calling 就够了,别折腾自建框架。等你真正开始做跨场景的复杂 Agent 时,再考虑自研技能层不迟。我这个项目就是在工具数量爆炸到 20 多个之后才下决心重构的。

2. 核心细节解析:技能描述、参数契约与上下文管理

框架定了之后,真正的工作量其实在细节设计上。“agent-skills”这套体系看起来就是“存了一堆函数声明”,实际上这里有三个最核心的机制,直接决定了 Agent 的命中率和准确性。

2.1 技能描述(Skill Descriptor):决定模型能否选对

技能描述是最容易被忽略但最关键的环节——这是模型判断“什么时候该调用什么技能”的主要依据。写得好不好,直接决定选择命中率。

我的技能描述包含四个部分:技能名称(name)、适用场景(description)、参数定义(parameters)、输出说明(returns)。前面两个好理解,后面两个需要展开讲讲。

参数定义我直接用 JSON Schema 格式,不做过度设计。原因是 JSON Schema 在代码侧容易校验,在模型侧也容易理解。描述里最关键的是每个参数的description字段——相信我,模型的参数填充准确性,和你对参数的描述措辞呈强相关。举个对比:

  • 模糊写法:"start_time": "起始时间"
  • 推荐写法:"start_time": "查询起始时间,格式为 yyyy-MM-dd HH:mm:ss,如果不传则默认当天零点,必须早于 end_time"

同样一个参数,第二种写法的填充错误率下降了大概六成。原因很简单,模型不知道该填什么的时候,它会从描述里推断。描述越具体,推断越容易命中。

输出说明我通常写成一段自然语言描述,告诉模型这个技能会返回什么样的数据。这一点的价值在于:如果技能返回了模型预期之外的数据,它会自己判断要不要换个技能重试。

技能描述是一个典型的“磨刀不误砍柴工”的事情。我花了大量时间打磨每个技能的描述,特别是对边界情况的限制说明。比如:

提示:技能描述中必须写明“什么时候不该调用”。例如查询类技能要注明“仅提供查询,不支持修改操作;如果用户表达的是修改需求,请调用更新类技能”。这类负向描述能显著减少误调用概率。

2.2 技能注册与发现机制:不把所有技能塞进 Prompt

技能数量一旦超过 10 个,全部塞进 Prompt 就不现实了。我采用的是注册表 + 动态检索的方案,核心逻辑是:

  1. 系统启动时,所有技能模块通过装饰器或注册函数登记到全局注册表。
  2. 当用户请求到达时,先经过一个“技能预选器”,根据用户请求和技能描述做一次粗筛,选出最可能相关的 3~5 个技能。
  3. 只把预选出的技能描述注入到本次调用的上下文中。

这个设计借鉴了信息检索里的粗排-精排思路。粗筛阶段可以用 embedding 相似度计算,也可以写简单的关键词匹配规则。我的实操经验是:初期先用规则做粗筛就够用,后续再逐步替换成向量检索效果更好。

对动态检索而言,关键点是技能数量别硬往上限顶。如果某个用户请求命中了超过 8 个技能,大概率是技能粒度太粗,应该拆。另外,预选器本身需要保证召回率优先——宁可多选几个无关的,也别漏掉真正需要的。漏选的代价远大于误选。

2.3 技能执行体与依赖注入的设计

技能描述只是“说明书”,真正干活的是技能的执行函数。这一层我的核心设计是依赖注入。

每个技能的执行函数只负责接收标准化的输入参数并返回标准化的输出结构,至于外部依赖(比如 HTTP 客户端、数据库连接、Redis 实例),全部通过注入方式传入。这样设计有几个好处:

  • 可测试性:单测时可以直接注入 mock 对象,不用真的连数据库。
  • 可替换性:同一个技能可以接不同的数据源,改配置就行。
  • 上下文隔离:技能之间不会共享可变的内部状态,减少互相干扰。

看一个具体的例子。假设我有一个“查询用户账户余额”的技能:

@skill_register def get_balance(user_id: str, currency: str = "CNY") -> dict: """查询指定用户的钱包余额。 当用户询问“我还有多少钱”“余额是多少”时使用。 """ client = container.resolve("payment_client") try: data = client.get_balance(user_id, currency) return {"code": 0, "data": data, "message": "ok"} except PaymentServiceError as e: return {"code": 1001, "data": None, "message": f"查询余额失败: {str(e)}"}

你注意到区别了吗?函数内部不直接import一个全局客户端,而是从container中解析。这就是依赖注入的实际应用。好处体现在测试的时候——我可以轻松换一个 mock 客户端。

2.4 技能调用的上下文管理机制

Agent 的一个天然弱点是“记性不好”。技能调用过程中产生的中间结果、历史对话摘要、外部数据,都需要统一管理。我把上下文分为三层:

  • 会话级上下文:记录用户与 Agent 的多轮对话摘要,保存在内存或 Redis 中。
  • 任务级上下文:当前任务执行过程中技能之间传递的数据,比如“用户查询了订单 A,又要求退款”,需要把订单信息从上下文里捞出来接力。
  • 技能级上下文:单个技能执行时的输入输出历史,主要用于排错和日志审计。

上下文管理的核心原则是“能用结构化数据就不用自然语言”。技能执行完返回的数据,我会把它结构化存起来,而不是把模型生成的回复原文塞进上下文。这样后续技能读取时,不需要再从一大段文字里做信息抽取——既能节省 Token,又能降低出错率。

这个设计我在前期吃过亏。最早为了省事,直接把 Agent 的完整回复作为上下文传递,结果第二个技能需要订单号时,模型要从已经生成过的自然语言里“猜测”订单号。后来改成结构化传递之后,这个环节的错误率直线下降。

3. 实操过程:从零实现一个轻量级 agent-skills 库

理论说了一大堆,下面直接上实操。我会用一段可运行的 Python 示例代码,带你走完从定义技能到跑通全程的关键步骤。

3.1 定义技能基类和注册装饰器

第一步要做的是技能的基类和注册机制。基类定义了技能的基础属性,装饰器负责把技能登记到全局注册表。

from dataclasses import dataclass, field from typing import Callable, Dict, Any, List import inspect import json @dataclass class Skill: name: str description: str parameters: Dict[str, Any] required: List[str] returns_schema: Dict[str, Any] = field(default_factory=dict) handler: Callable = None tags: List[str] = field(default_factory=list) class SkillRegistry: def __init__(self): self._registry: Dict[str, Skill] = {} def register(self, skill: Skill) -> None: if skill.name in self._registry: raise ValueError(f"技能 {skill.name} 已经被注册过了") self._registry[skill.name] = skill def get(self, name: str) -> Skill: return self._registry.get(name) def all(self) -> List[Skill]: return list(self._registry.values()) # 全局注册表 registry = SkillRegistry() def skill( name: str, description: str, parameters: Dict[str, Any], required: List[str], returns_schema: Dict[str, Any] = None, tags: List[str] = None, ): def decorator(func): skill_obj = Skill( name=name, description=description, parameters=parameters, required=required, returns_schema=returns_schema or {}, handler=func, tags=tags or [], ) registry.register(skill_obj) return func return decorator

这段代码里有两个细节值得注意。一个是注册时检查重名——技能名称重复是一个隐藏的深坑,如果两个技能同名,后面的会把前面的覆盖掉,排错时非常难发现。另一个是required和parameters单独拆开,因为模型可能只填了有效参数的一部分,这给后续的参数校验提供了条件。

3.2 技能预选器:从全量技能中选出最相关的几个

注册表建好了,接下来实现预选器。这个模块的职责是:输入用户请求,输出该请求最可能用到的技能子集。前期默认用关键词匹配,加入少量规则。

def pre_select_skills(user_query: str, max_skills: int = 5) -> List[Skill]: """ 根据用户请求粗选技能,早期版本不需要复杂算法。 """ candidates = [] query_lower = user_query.lower() for item in registry.all(): score = 0 # 匹配技能名称 if item.name.lower() in query_lower: score += 10 # 匹配描述中的关键词(简单分词后交叉计数) desc_words = set(item.description.split(" ")) for word in desc_words: if word.lower() in query_lower: score += 1 if score > 0: candidates.append((score, item)) candidates.sort(key=lambda x: x[0], reverse=True) return [item for _, item in candidates[:max_skills]]

这个粗选器很粗糙,但对启动阶段已经足够。它的价值在于把候选集缩小,为后续精排减少计算压力。你完全可以把这段逻辑替换成向量检索——先对全量技能做 embedding 预计算,再用用户请求的 embedding 做相似度 top-k。前期用规则,后期上向量,这样的演进路径不会浪费你的工作量。

我当时先用规则跑了两个星期,积累了足够的真实用户请求和对应的技能命中情况。之后把这些历史数据作为训练集去调向量检索,效果比直接凭空调要好得多。

3.3 模型调用与参数提取

预选出技能子集后,下一步是交给模型做“选择和参数填充”。我用的方式是构建一个紧凑的 system prompt,只包含候选中技能的描述。

def build_call_prompt(user_query: str, skills: List[Skill]) -> str: system_parts = [ "你是一个智能助手,请根据用户请求选择最合适的技能并提取参数。", "只从给定的技能列表中选择,不要编造不存在的技能或参数。", "", "可用的技能如下:", ] for i, item in enumerate(skills): system_parts.append(f"[{i + 1}] {item.name}") system_parts.append(f"描述:{item.description}") system_parts.append(f"参数:{json.dumps(item.parameters, ensure_ascii=False)}") system_parts.append(f"必填参数:{item.required}") system_parts.append("") system_parts.append("请以 JSON 格式输出,格式为 {\"skill\": \"技能名\", \"arguments\": {参数名: 值}}。") return "\n".join(system_parts)

这里有一个特别重要的设计:不给模型提供技能描述之外的任何工具调用方式。也就是说,模型只能从列出的候选里选,不能自己“发明”新函数。这能最大化避免模型脑补不存在的技能。

填充参数时还需要做一轮后处理校验。模型输出的 JSON 不一定完全符合 Schema——最常见的问题是缺少必填项、参数名大小写写错、数字被写成字符串。我用一段简单的校验函数兜底:

def validate_arguments(skill: Skill, arguments: dict) -> dict: errors = [] for req in skill.required: if req not in arguments or arguments[req] in (None, "", []): errors.append(f"缺少必填参数: {req}") # 类型转换:schema 里声明为 number 的参数允许从字符串转 for key, value in arguments.items(): if key in skill.parameters: schema_type = skill.parameters[key].get("type") if schema_type == "number" and isinstance(value, str): try: arguments[key] = float(value) except ValueError: errors.append(f"参数 {key} 无法转换为数字") if errors: raise ValueError("; ".join(errors)) return arguments

这段校验脚本看起来很简单,但线上的价值非常大。模型输出的格式五花八门,而且同一个技能在不同模型上输出风格的差异也很大。没有这层校验,后续的执行函数会经常因为拿到奇怪的数据类型而崩溃。

3.4 技能执行与结果合成

参数校验通过后,进入执行阶段。执行逻辑很简单,就是调用注册好的 handler 函数。但这里有一个细节容易被忽略:技能返回值要不要经过二次加工才给模型生成回复。

我的方案是:技能返回值先做结构化存放,生成一个“执行摘要”给模型,而不是把完整原始数据全扔给模型。举例来说,查询订单接口可能返回一个包含大量字段的 JSON,但用户实际上只关心“订单是否已发货、预计什么时候到”。因此执行摘要要把关键信息提取出来:

def run_skill(skill_name: str, arguments: dict) -> dict: skill_obj = registry.get(skill_name) if not skill_obj: return {"code": 404, "data": None, "message": f"技能 {skill_name} 不存在"} try: validated = validate_arguments(skill_obj, arguments) result = skill_obj.handler(**validated) # 结构化存结果:统一包装成固定格式 return { "code": 0, "data": result, "message": "成功", "skill": skill_name, "arguments": validated, } except Exception as e: return {"code": 500, "data": None, "message": f"执行技能 {skill_name} 出错: {str(e)}"}

统一的返回结构非常重要。模型通过 code 字段判断这个技能是否执行成功;如果返回 500,它可以自主决定换个技能尝试,而不是强行基于错误信息编一套回复出来。这个设计直接遏制了模型“死鸭子嘴硬”的行为。

3.5 多技能串联:让 Agent 学会使用“工具链”

单个技能能做的事情有限,真正的 Agent 需要能串联多个技能完成复杂流程。我在“agent-skills”里做了一层简单的编排机制——让模型在返回技能选择的同时,可以附带声明“下一步计划”。

例如,用户请求“查询最近一周的订单,并把总金额统计一下”,这需要两个技能配合:订单查询 + 金额汇总。我的编排处理方式是循环:

  1. 把用户请求交给模型,模型选择技能 A(订单查询)并填充参数。
  2. 执行技能 A,把结果放入上下文。
  3. 把上下文和原始请求再次交给模型,模型看到订单数据后选择技能 B(汇总)并填充参数。
  4. 执行技能 B,把结果交给模型生成最终回复。

这段循环本质上就是 ReAct 模式的简化版。每一步执行后,模型都能看到“技能返回了真实数据”,从而更准确地决定下一步动作。这个模式我在测试中发现,比让模型一次规划完整步骤要稳定得多。

注意:循环不是死循环。每次调用都会带上累计步数限制,超过 6 步还没有拿到用户想要的最终结果,我会让 Agent 主动承认“这个问题太复杂了,建议分步处理”,而不是无限套娃。这一步在线上非常必要——某些用户请求很容易触发无终止的循环调用。

4. 常见问题与排查技巧实录

这块进入正题。我在真实使用这套机制时遇到的典型问题还不少,这里挑四个最有代表性的讲讲,每一个都附了排查思路和解决方案。

4.1 技能选择命中率偏低,模型老选错技能

这是最开始遇到最多的问题。现象是:工具 A 和工具 B 功能相近,用户说“帮我看看订单到哪了”,模型有多大几率选了“查询订单物流”而不是“查询订单状态”。

排查思路是检查技能描述是否足够区分。我最初把两个技能的描述都写在同一个粒度上——“查询订单信息”,这一看就知道模型根本没法学。后来我把描述改成:

  • 查询订单物流:当用户需要了解包裹运输进度、快递轨迹、配送到达时间时使用。
  • 查询订单状态:当用户需要了解订单当前所处阶段、是否已支付、是否已发货等业务状态时使用。

改完之后,命中率提升了三成还多。描述的区别度,就是技能之间的边界。别怕写长,重要的是让模型看见明确的“什么情况用哪个”的信号。

如果改了描述还是选不对,还有一个技巧是给相似技能加负向描述。比如在物流技能里加一句:“如果用户只关心订单是否支付,请勿使用此技能。”这种写法对模型约束效果极好。

4.2 参数提取错误:模型把用户没有给的信息“脑补”进去

另一种高频问题是参数幻觉。比如用户说“查一下订单 12345”,模型自作主张把start_time也填了昨天。这种情况很让人头疼,因为参数本身看起来合法,但完全不是用户的意图。

我的处理是用“可选参数的默认值策略”。所有非必填参数,都不要求模型主动填充,而是靠执行函数侧取默认值:

# 技能函数内部做兜底 def get_order_detail(order_id: str, start_time: str = None, end_time: str = None): if start_time is None: start_time = today_zero() if end_time is None: end_time = now()

这样模型只需要填它确定的信息,不确定的可以留给默认值,避免凭空捏造。同时在描述里明确标注:“用户没有提到的参数一律不要填。”这一句约束在多个模型上实测下来,降低参数幻觉的效果相当显著。

4.3 技能返回数据格式不统一,模型读不懂返回内容

这是在技能数量上去之后才暴露的问题。不同技能由不同的人开发,返回格式各写各的——有的返回{"status": "ok"},有的返回{"success": True},还有的返回裸字符串。模型拿到这些五花八门的返回结果后,特别容易迷失方向。

解决的办法就是我在第 3.4 节提到的:统一返回包装。所有技能的外部表现都走同一个函数,强制执行统一格式。这一步不能省,否则技能越多,模型越容易“精神分裂”。

另外还要注意一个细节:返回给模型之前,要做一个“面向用户的摘要”。有些技能返回了大量日志型数据(几十条流水),模型不会去逐条计算,它可能会忽略关键数字。更好的做法是技能内部直接把“本次调用最核心的结果”提取成 1~2 句话,连同结构化数据一起返回。这相当于给模型划了重点。

4.4 多技能串联时上下文互相污染

多技能串联之后发现一个有意思的问题:完成第一步查询之后,第二步汇总时模型把第一步返回的原始信息又听进去了,产生幻觉。比如“订单物流显示已签收,为什么你汇总的时候还说在运输中”。

原因是上下文里保留了多轮技能调用的原始返回,模型容易把最早的结果当成当前事实。解决办法是:每次技能执行完成后,只保留当前技能的执行摘要和最终结果,旧技能的详细返回在进入下一轮之前就压掉。

用术语说,这是“上下文压缩”。实现上不复杂——上一轮结果做摘要后,用一个 short-term memory slot 保存,当新步骤开始时,将其从模型上下文中移除。这需要一个独立的上下文管理器来调度,而不是把所有内容一股脑塞进一个地方。

class ContextManager: def __init__(self): self.current_skill_result = {} self.original_user_query = "" self.history_summary = [] def compress(self): # 把上一次技能结果压缩为一行摘要 if self.current_skill_result: brief = ( f"上一技能 {self.current_skill_result['skill']} 已执行完成,"、 f"结果为:{json.dumps(self.current_skill_result['data'], ensure_ascii=False)[:200]}" ) self.history_summary.append(brief) self.current_skill_result = {}

压缩之后,模型每次看到的上下文焦点都集中在当前这一步,不会被历史数据带偏。

5. 进阶扩展:技能版本、权限与资源控制

基础版本跑通之后,你会慢慢碰到一些更深层的问题——技能要迭代,要控制访问权限,要避免资源损耗。这些内容可能不是 MVP 阶段必须的,但长期运营时绕不开。

5.1 技能版本管理与灰度策略

技能描述和实现会持续优化。问题在于,新版本和老版本不能简单覆盖——万一新版本描述反而变差了,会影响线上所有请求。我采用的做法是给技能注册表加上版本字段,每次修改都以新版本注册,由调度器决定使用哪个版本:

@skill( name="query_order", version="2.0", description="查询订单基础信息。当用户询问订单状态、金额、支付信息时使用。", ) def query_order_v2(order_id: str): # 新的实现 pass

配合一个简单的灰度开关,按流量比例把一部分请求路由到新版本。这个设计参考了后端的灰度发布思路,在不中断服务的情况下验证技能改动效果。实测下来很有用——有一次我把某个技能的描述改得更具体了,结果整体表现反而变差,因为有旧版本顶住了流量,问题才没有扩大。

5.2 技能权限控制与审计日志

如果你的 Agent 要接入真实业务系统,权限控制不可回避。不能让模型根据用户一句话就触发管理员操作。我设计了一个轻量的权限层:

  • 技能注册时声明permission_level,比如read、write、admin。
  • 会话建立时绑定用户的角色和权限列表。
  • 调度器在调用技能前先检查权限,不满足则拒绝执行并告知用户“你没有权限执行这个操作”。

审计日志同样重要。每次技能调用都会记录:用户 ID、会话 ID、技能名、参数、返回结果、模型名、Token 消耗。这个日志不仅用于追溯问题,还是分析哪些技能被频繁调用、哪个技能经常出错的基础数据源。没有日志就没有优化依据。

5.3 技能超时、重试与降级处理

技能执行体是真实的外部调用,大概率会遇到超时、限流、第三方服务宕机等情况。线上运行时间长了你会发现,外部接口不稳定才是常态。给执行层加上超时控制和重试机制,非常关键:

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception_type(TimeoutError), ) def call_external_api(url, payload): # 外部HTTP调用 pass

重试之外,我还给技能设计了降级路径。比如“发送邮件通知”这个技能,如果邮件服务挂了,可以降级为“把通知内容写入消息队列等待恢复”。降级逻辑写在技能内部,不用让模型知道——对模型来说,它只看到调用成功。

安全提示:降级方案不能无条件使用。涉及到支付扣款、资源删除等敏感操作,一律不允许自动降级,宁可直接失败,也不能用模糊的策略绕过去。这个原则要写死在框架里,不能依赖技能开发者自觉。

6. 我在实际项目里踩过的坑

最后分享几个真正困扰过我的细节问题,这些坑在文档里很难找到答案,但线上踩到之后会特别费时间。

6.1 Prompt 里技能过多导致模型“注意力稀释”

早期我为了求稳,预选器选了 8 个技能进 Prompt。结果模型的表现反而比只用 3 个技能更差。原因很好理解——当可选范围变大,模型在每个选项上的“注意力权重”会被稀释,决策质量下降。

实测下来5 个技能以内表现比较稳定,超过 5 个就会出现选择犹豫和错误率上升的趋势。所以预选器的max_skills参数,我最终定在 5,没有再调高过。宁可召回率略低一点,也要保证选择质量。

6.2 技能函数不该偷懒写长注释

我见过一个技能,函数内部注释写得像小作文,结果这些注释全被带到了 Prompt 里。你以为注释只给程序员看?不,prompt 拼接时如果直接把函数体当描述用,注释也会变成模型“参考信息”的一部分,轻则浪费 Token,重则给模型传递错误信号。

所以技能的对外描述和函数的内部注释一定要隔离。对外描述放在description字段里精写,函数内部的注释只服务开发者自己。

6.3 模型输出 JSON 的容错技巧

模型输出极不稳定。同一个模型,上午输出的 JSON 是干净的,下午就可能在 JSON 前后加一段“好的,以下是结果”。我用了一招容错解析:

def safe_json_loads(text: str) -> dict: text = text.strip() # 去掉可能的 markdown 代码块包裹 if text.startswith("```"): text = text.strip("`") if text.startswith("json"): text = text[4:] # 找到第一个 { 和最后一个 } 之间的区间 start = text.find("{") end = text.rfind("}") if start == -1 or end == -1: raise ValueError("未找到 JSON 对象") return json.loads(text[start:end + 1])

这个方法不保证百分百纠正,但能解决九成以上的格式抖动。核心思路是:截取最像 JSON 的子串再解析,而不是直接把整段文本扔给json.loads。

6.4 技能注册表的测试策略

技能数量多了之后,注册表本身的正确性也可能出问题。比如某个技能描述里写的关键词和另一个技能完全重复,或者某个必填参数的 Schema 写错了。我建议写一套自动化的“注册表自检”脚本,在每次部署时执行:

  • 检查技能名称唯一性。
  • 检查所有必填参数是否在 parameters 里都有定义。
  • 检查技能描述是否超过长度限制。
  • 检查返回 Schema 是否合法 JSON。

这个过程花不了多少时间,但能杜绝一批低级错误。特别是多人协作开发技能时,自检脚本几乎是唯一能守住质量底线的工具。

写在最后的个人体会

这套“agent-skills”从最早的纯 Prompt 方案演化到现在的技能注册表加动态调度结构,中间改了大概三次版本。每次重构的触发点都不是“想用新技术”,而是线上数据告诉我哪里卡住了——一开始是工具多了模型分不清,后来是参数乱填,再到后来是多技能串联互相污染。每一个问题都是先观察数据、再涉及方案的顺序推进,不是拍脑袋提前设计的。

如果你想在自己的项目里复刻这套方案,我建议不要一上来就想着“全量技能 + 完美调度 + 复杂上下文”。先挑两三个最核心的技能跑通整个链路——注册、预选、调用、校验、返回包装——然后再逐步加技能。这个节奏会顺畅很多。

另外,技能的迭代是一个持续过程。我的技能描述平均每隔几周就会根据真实用户请求调整一次。模型在变,用户在变,技能描述也永远没有“最终版”。保持迭代心态,在这条路上比什么都重要。

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

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

立即咨询