我最早做Agent项目的时候,习惯把所有能力直接平铺成普通函数,往模型的工具列表里一排就完事,当时也没觉得有什么问题。等技能数量过了十个,问题就接踵而至:有的函数描述写得模棱两可,模型在几个名字相近的函数之间来回猜;参数格式也不统一,今天用day明天用date,校验逻辑散落在各个handler里;更麻烦的是,两个技能明明要共用同一份用户上下文,却各自从自己的参数里取一遍,最后数据对不上。后来我把这层能力单独拆出来,统一了技能的定义、注册、调度和观测,慢慢形成了一套可以复用的“技能层”。这篇agent-skills整理记录就围绕这套体系展开,把近期在技能规范化上的思考、代码骨架和踩坑记录一次性写清楚,想自己搭一套Agent技能层的朋友可以直接复制思路。
1. Agent技能和普通函数调用,差的不是一层包装
1.1 为什么不能继续把函数当成技能
普通函数的调用关系在设计期就固定了。开发者写了get_order_status(order_id),那就只有开发者自己知道什么时候该调它、参数从哪来、返回值怎么处理。但Agent场景完全反过来,调用方是模型,它在运行期才拿到用户的话术,需要自己判断“当前用户到底想查订单还是想查物流”,然后从一堆候选里选一个函数,再生成结构化参数。这个变化看着不大,实际影响很深。
首先,函数本来不需要向调用方解释自己,但模型需要。模型只能通过函数名和描述来建立“这个函数在什么场景下用”的认知。描述写得不清,模型就猜。猜错的代价不是报一个编译错误,而是用户提问后的整条链路走错路,回复完全跑偏。
其次,函数参数天然是强类型的,传错了编译器直接报错。但模型的输出是文本序列,它需要在“生成参数”这一步就严格按照结构来。今天让它传city,它可能写成{"city": "北京市"},明天可能写成{"city": "北京"},后天可能是{"location": "北京"}。技能层必须承担参数校验和纠偏的职责,不能指望模型每次都能稳定输出。
最后是错误处理。普通函数抛出异常,开发者可以自己接住。但Agent场景里,技能执行失败后,下一步动作通常还是模型做决定。它需要知道“这个技能是参数错了,还是数据源挂了”,才能决定修正参数重试还是换别的技能。技能层如果直接把异常吞掉,或者抛给调用栈,模型只能瞎猜,整个对话的鲁棒性会变得极差。
所以,技能不是函数加一层壳,而是一套围绕“模型作为调用方”重新设计的接口协议。这也是我后来把所有技能从业务代码里拆出来的根本原因。
1.2 技能协议在解决三个具体问题
第一个问题叫可发现性。模型在决策时必须能准确知道当前有哪些技能可用、每个技能是干什么的、边界在哪里。可发现性不是要求技能数量少,而是要求模型在几十个技能里做选择时,不会产生歧义。这里最典型的现象就是两个技能名字一个叫query_order_status,另一个叫query_refund_progress,订单尾号都差不多的场景。可发现性做得不好,模型大概率选到定义模糊的那个。
第二个问题叫参数转换。自然语言里的实体和技能参数之间不是一一对应的。用户说“帮我看看上周三那个单子到哪了”,技能需要的是order_id,那就得有机制把“上周三那个单子”转成标准订单号。这个转换通常分两步:第一步召回相关的订单候选,第二步由模型选择或让用户确认。技能协议要能把这种转换规则表达出来,并让模型知道该不该追问、该在什么时候追问。
第三个问题叫反馈闭环。技能执行完,结果要么成功要么失败。成功还好,失败就必须给模型足够的错误语义,让模型能做下一步决策。比如“订单号格式不对”和“订单不存在”和“接口超时”,对应的策略是不一样的。技能协议应该在返回结构上就区分这些情况,而不是把所有错误统一揉成一个字符串。
这三个问题,其实普通函数一个都接不住。这也是我一开始踩坑最多的原因。
1.3 一个普通函数是怎么变成技能的
举个最简单的天气查询例子。
普通函数大概长这样:
def get_weather(city): return fetch_weather(city)看起来没问题,但把这个函数塞给模型用,问题就来了。模型不知道城市名只支持中文还是支持拼音,不知道能不能查历史天气,也拿不到“如果城市不存在该返回什么”的说明。如果调用失败,模型无从判断是该换个城市名再试,还是直接告诉用户查不了。
技能化之后,同样这个能力会长成这样:
weather_skill = SkillSpec( name="query_weather", description="根据城市中文名称查询当天天气。只支持中国城市中文名,不支持拼音、英文、地名别名。若城市不存在,返回明确错误。", parameters={ "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名,例如:北京、上海", "examples": ["北京"] } }, "required": ["city"] }, handler=query_weather_handler )对比一眼就能看出来,技能化之后,city字段的所有约定都变得可供模型解析。参数类型、取值示例、边界条件、错误预期,全部显式化。这套格式一开始有点啰嗦,但等技能到了几十个,你会发现这些信息是大模型正确调用的基础,值得投资。
2. 技能定义做成标准件:字段、Schema与元信息
2.1 技能描述结构的最小集
我反复调整过技能字段,最后沉淀下来的最小集是:技能名、技能描述、参数Schema、处理函数、超时时间、重试次数、依赖技能、输出上下文声明。
@dataclass class SkillSpec: name: str description: str parameters: dict required: list[str] handler: Callable timeout: int = 30 retry: int = 2 dependencies: list[str] = field(default_factory=list) outputs: dict = field(default_factory=dict)逐个说为什么需要这些字段。
name必须是一个动词短语,一眼能看出“这个技能会做什么”。命名上要尽量区分场景,比如query_order_status和query_all_orders一眼就能分辨,但process_order这种就太含糊,模型很容易猜错。
description是整个技能里最重要的字段。它决定了模型到底会不会选择并调用这个技能。描述不能只写功能,还要写边界条件和反例。我见过很多技能描述只写“查询某个订单的状态”,完全不提订单号格式、是否支持模糊匹配,也没说明只支持普通订单不支持售后单,最后模型在售后场景里也去调它,整个链路就错了。
parameters用JSON Schema描述。这个字段是给模型做结构化输出用的,所以必须在每个属性里补充description和examples,帮模型把自然语言转成参数。
handler是真正的执行函数,接收已经校验过的参数,返回标准结果结构。
timeout和retry配置执行的兜底策略,防止某个技能卡住拖慢整个Agent。
dependencies声明技能执行前需要先完成的技能,这是技能编排的基础。
outputs声明技能执行完成后会向共享上下文写入哪些字段,供下游技能读取。
这套结构并不复杂,难的是坚持给每个技能都填满这些信息。一旦开始偷懒,后续调度出错的概率会直线上升。
2.2 输入输出Schema怎么定才能不返工
参数Schema的设计,很大程度上决定了技能命中率和高层调用的稳定性。我总结了几条比较实用的经验。
第一,能用枚举就用枚举,能加正则就加正则。如果订单号格式固定为ORD加10位数字,那参数Schema里就应该写死pattern。如果城市列表是固定的,就直接用enum。这样模型生成参数时,从第一步就被约束在合法范围内,而不是等执行阶段做校验再回退。后者成本太高,还可能让用户等一个毫无意义的失败反馈。
第二,每个字段都要写“给模型看的说明”。不是注释,而是告诉模型这个字段应该填什么、怎么从用户话术里抽取。比如order_id的description写“用户的订单编号,通常形如ORD开头加10位数字”,模型抽取值时就会主动去找这个格式的片段,而不是把“最近一单”填进去。
第三,参数个数尽量控制在五个以内。参数越多,模型生成时的出错率越高。有些参数其实可以从会话上下文获取,比如用户ID、店铺ID,这些在进入技能调度前就应该由上下文注入,不需要模型重新生成。把参数精简到模型必须从用户话术里抽取的核心字段,整套调度会更稳。
第四,输出结构也要显式声明。不是让handler随便跑完就行,而是要明确写出它会往上下文里写入哪些字段。比如订单查询技能,输出声明可能是order_status、current_step、events,这样下游的客服话术生成技能才知道去哪里读取数据。
下面是一个参数设计对比,很直观:
| 设计问题 | 错误做法 | 正确做法 |
|---|---|---|
| 订单号 | 只写string | 加pattern: "^ORD\d{10}$" |
| 城市取值 | 不设限制 | 加enum或examples |
| 时间范围 | 只写date | 写ISO格式并给示例 |
| 可选字段 | 不说明默认值 | 在description里写明默认值 |
| 边界情况 | 不说明 | 写明“只支持最近30天” |
这些规范化工作,前期看起来有点琐碎,但一旦技能数超过二十个,它带来的稳定性提升会非常明显。
2.3 注册中心的组织方式
技能定义好之后,需要一个注册中心统一管理。最简单的做法就是用内存字典,启动时从配置加载。
class SkillRegistry: def __init__(self): self._skills: dict[str, SkillSpec] = {} def register(self, spec: SkillSpec): self._skills[spec.name] = spec def get(self, name: str) -> SkillSpec: return self._skills[name] def list(self) -> list[SkillSpec]: return list(self._skills.values()) def available(self): return [s for s in self._skills.values() if s.enabled]从YAML或JSON配置文件加载技能描述,可以让新技能上线时不用改调度代码。我一般用YAML维护技能元信息,handler的部分单独注册,通过技能名做关联。
- name: query_order_status description: 根据订单号查询订单当前状态。只支持标准订单,不支持售后单。 parameters: type: object properties: order_id: type: string pattern: "^ORD\\d{10}$" required: - order_id timeout: 30 retry: 1 outputs: order_status: string current_step: string这种组织方式的另一个好处是,候选集召回、评测、日志统计都能基于同一份技能元信息来做,不至于元信息散落在各个handler里。
3. 调度链路:从一次自然语言请求到技能执行
3.1 全量候选还是召回缩小范围
技能数量少的时候,把全部技能的定义塞给模型让它选,问题不大。但技能数量超过二十个,全量塞的做法就会出现两个麻烦:一个是token成本上升,每次请求都要携带大段JSON Schema;另一个是模型在选择时容易混淆语义相近的技能。
我的做法是先做一次召回。把用户当前消息和每个技能的短摘要做一个语义匹配,取top-k作为候选集,再让模型从top-k里面精确选。召回这一步不需要太强的模型,基于embedding的向量相似度就够用。
def recall_skills(user_query, registry, top_k=5): candidates = registry.available() scored = [] for skill in candidates: score = semantic_similarity(user_query, skill.summary) scored.append((skill, score)) scored.sort(key=lambda item: item[1], reverse=True) return [skill for skill, _ in scored[:top_k]]召回之后,候选集从几十个变成三五个,模型的决策准确率会提高很多。key是“召回要快,排序要粗”,最后的选择权交给模型。
如果技能量级只有十几二十个,不召回问题也不大,这个需要根据实际场景取舍,不必教条。
3.2 让模型做技能选择的统一消息结构
召回出候选技能以后,需要把它们的定义结构化地交给模型。核心思想是给模型一份技能选择清单,让它返回它想调用的技能名和参数。
def build_skill_prompt(recalled_skills): blocks = [] for idx, skill in enumerate(recalled_skills): blocks.append( f"{idx}. name: {skill.name}\n" f"description: {skill.description}\n" f"parameters: {json.dumps(skill.parameters, ensure_ascii=False)}\n" ) return "\n".join(blocks)模型返回的结构大概是:
{ "skill": "query_order_status", "arguments": { "order_id": "ORD1234567890" } }这部分用大模型自带的工具调用能力通常更稳。把候选技能列表设置成工具列表,让模型走原生函数调用流程,输出的结构会比纯文本更可靠。不过不管用哪种方式,技能调度器都要对模型输出做一次“二次校验”,因为模型可能生成了候选列表里不存在的技能名,也可能参数Schema校验不通过。
3.3 参数校验、执行与上下文回写
模型返回技能名和参数后,调度器先做校验,校验通过再执行handler。
def execute_skill(skill, parsed_args, session_context): validated = validate_against_schema(parsed_args, skill.parameters) if not validated.ok: return error_result("PARAM_INVALID", validated.message) enriched = inject_context_params(parsed_args, session_context) try: result = skill.handler(**enriched) except TimeoutError: return error_result("TIMEOUT", "技能执行超时,请稍后重试") except Exception as exc: return error_result("INTERNAL_ERROR", str(exc)) for key, value in result.context_updates.items(): session_context.set(key, value) return success_result(result.data)inject_context_params是一个容易被忽略的步骤。有些参数,比如登录用户ID、当前店铺ID,不需要模型生成,直接从会话上下文里取出来注入就行。这样做的好处是减少模型需要生成的参数数量,自然也就降低了出错概率。
执行完成后还有一个关键动作:把技能输出写入会话上下文。这个上下文是所有技能共享的一块内存区域,相当于技能之间传话的白板。比如订单查询技能写入了order_status,下游的“给用户生成物流进展话术”技能就能直接读取,不需要让模型再查一次。
如果校验失败或者执行失败,调度器不应该直接终止对话。把错误信息转换成结构化结果反馈给模型,让模型决定是改参数重试,还是换一个技能,还是直接向用户说明无法处理。这个反馈回路做得好,Agent的容错能力会明显提升。
4. 多技能协作:编排、冲突仲裁与回退
4.1 依赖声明与顺序控制
单技能调度相对简单,真正复杂的是多技能协作。当一个任务要拆成多段来执行,比如先解析用户意图、再查订单、最后生成话术,就需要技能编排。
我在设计里给技能加了dependencies字段,声明这个技能执行前依赖哪些技能先执行。调度器在执行某个技能前,先检查依赖的技能有没有在本次会话里完成过,没有就递归执行依赖技能。
- name: generate_order_reply description: 根据订单状态生成用户回复文案 dependencies: - query_order_status调度器做依赖解析时,可以按依赖关系做一次简单的拓扑排序。如果依赖形成环,直接报错。
def resolve_dependencies(target_skill, registry, visited): if target_skill.name in visited: raise CycleError() visited.append(target_skill.name) chain = [] for dep_name in target_skill.dependencies: dep = registry.get(dep_name) chain.extend(resolve_dependencies(dep, registry, visited)) chain.append(target_skill) return chain弄完依赖解析之后,技能协作就从一个手忙脚乱的过程变成一个有明确次序的流水线。每一段技能都能拿到上游写入上下文的数据,也清楚自己要往上下文里写什么。这个模式在客服类Agent里面尤其好用。
4.2 意图置信度校验解决相似技能选错
技能数量越多,相似技能选错的问题就越突出。比如“查询订单状态”和“查询售后进度”,对用户来说也许只是一句话的区别,但模型很容易选到那个名字更泛化的技能上。
我的经验是给技能选择加一道置信度门槛。让模型不只返回“选哪个技能”,还要返回“对这个选择的置信度”。如果最高分技能和次高分技能差距不大,说明模型也不是很确定,这时候与其硬猜,不如向用户追问一句确认需求。
def should_ask_for_confirmation(decisions, threshold=0.25): if len(decisions) < 2: return False score1 = decisions[0]["score"] score2 = decisions[1]["score"] return (score1 - score2) < threshold这个方法代价很小,但能有效减少因相似技能搞混导致的无效调用。虽然多一次提问会让对话变长,但比执行错了之后再来回纠错要高效得多。
4.3 失败回退策略:区分三类错误
技能执行失败,不能统一当成“重试一下”。我把错误分成三类,分别对应不同的回退策略。
第一类是参数校验失败。模型生成的参数不符合Schema,这属于生成层面的问题。处理方式是给模型返回详细的校验错误,让它修正参数再调一次,一般最多再试一次。第二类是执行层超时,比如依赖的外部接口没有响应。这种重试毫无意义,只会让用户等更久,应该直接报错并跳出,提示用户“当前系统繁忙”。第三类是结果为空或数据源异常。这类可以做横向回退,比如查主数据源失败,就尝试查备份源,再不行就问用户“你确定这个订单号是正确的吗”。
| 错误类型 | 回退策略 | 说明 |
|---|---|---|
| 参数校验失败 | 让模型修正参数重试,最多1次 | 错误信息里要带清楚的人类可读说明 |
| 执行超时 | 不重试,直接报错 | 重试只会拖慢体验 |
| 数据源返回空 | 换备用数据源或让用户确认参数 | 不是一定失败,需要动态判断 |
把这三类错误区分开以后,整个Agent的容错率提高了一大截。至少用户不会遇到“同一个订单查了三次,每次都等了好一会儿,最后还报错”这种体验。
5. 技能观测与评测:让选择准确率可量化
5.1 技能调用链路的观测点设计
技能层没有观测,就等于蒙着眼睛开车。我最早做Agent的时候,用户说某句话没答对,我只能看日志文本一点点猜,效率极低。后来我总结出几个核心观测点,每个都值得记日志。
- 候选技能召回时,记录召回来源和得分。
- 模型决策后,记录选中的技能名和生成参数。
- 参数校验结果,记录校验失败的原因。
- 执行结果,记录耗时、错误类型、重试次数。
- 上下文更新,记录执行前后context的变化。
实现上,只需要一个统一包裹:
logger.info("skill_call", extra={ "trace_id": trace_id, "query": user_message, "candidates": candidate_names, "candidate_scores": candidate_scores, "selected_skill": skill_name, "parsed_args": parsed_args, "validation": validation_result, "exec_time_ms": exec_time_ms, "exec_status": status, "retry_count": retry_count, })每条技能调用都打一个这样的结构化日志,后续做统计和复盘会非常方便。
5.2 用十行代码做命中率统计
有了结构化日志,统计技能命中率就简单了。对应关系很直接:日志里的selected_skill与日志里的expected_skill比对即可。评测时手工维护一个期望标注,批量跑一遍,命中率、参数完整率、执行成功率全部算得出来。
stats = { "total": 0, "skill_hit": 0, "param_valid": 0, "exec_success": 0, "total_retries": 0, } for item in eval_cases: stats["total"] += 1 stats["skill_hit"] += int(item["selected"] == item["expected_skill"]) stats["param_valid"] += int(item["validation"] == "ok") stats["exec_success"] += int(item["status"] == "success") stats["total_retries"] += item["retry_count"] print(stats)每天跑一次,技能改进前后的差异一眼就能看出来。之前我总觉得某个技能模型选不准,后来一统计才发现,它选不准的比例其实只有5%。真正的问题出在另一个技能描述写太差。量化结果帮我把注意力放到了正确的地方。
5.3 低负担评测集维护法
评测集不能只靠手工编写,否则很快就会陷入“只有理想话术、没有真实话术”的失真状态。我的做法是从真实日志里抽样,把用户实际表达中比较有代表性的话术挑出来,人工标注期望技能和期望参数,凑成一个评测集。
评测集起步五十条,每周补十条新的,慢慢沉淀。数据格式很简单:
eval_cases = [ { "query": "帮我查一下订单 ORD1234567890 到哪了", "expected_skill": "query_order_status", "expected_args": {"order_id": "ORD1234567890"} }, { "query": "我那个退货申请什么时候能好", "expected_skill": "query_refund_progress", "expected_args": {} } ]这个评测集的核心价值不是一次性的,而是持续回归。每次修改某个技能的描述或者参数Schema,都用它跑一遍,确认没有让其他技能的命中率下降。很多Agent项目走到后期,稳定性都是靠这套回归兜底的。
6. 让我反复返工的几个技能设计坑
6.1 description写得越“全能”越容易误导
我犯过最典型的错误,是把技能功能写得过于宽泛。一个本意是“查询最近30天订单销量”的技能,我在description里写了“可以查询任意时间段销量数据”,结果模型就认为它什么都能查,用户一问上个月的销量,这个技能就被选中了。然后执行层发现能力不足,又不得不走错误反馈流程,白白浪费两轮交互。
后来我吸取了教训:description必须写死能力边界,还要写反例。“只支持最近30天”“不收订单后面的坐标”这类限制句子越多,模型调错的概率越低。把边界写清楚并不会让模型困惑,反而是在帮它排除干扰项。
6.2 参数Schema不给约束,模型就自由发挥
有一次技能参数里的订单号只声明了string类型,没有加正则约束。模型生成时完全可以把这个字段理解成任意字符串。于是有一大票用户消息里不带标准订单号的情况,模型都硬凑了一个参数出来,然后校验必失败。
后来统一改了所有参数Schema,凡是格式能定义的都加上pattern或format,凡是取值有限范围的就用enum。改完以后,参数校验失败率肉眼可见地降下来了。
6.3 技能版本无人管理,旧技能变成噪音
技能改过几版之后,旧的技能定义还留在注册中心里,也没有下线机制。结果模型在候选集里总是看到新旧两套定义差不多的技能,来回犹豫该选哪个。这个问题隐蔽但影响不小,属于“看起来每个技能都很合理,实际选择准确率被拉低”的情况。
现在我在技能注册表里加了enabled、version字段,废弃技能直接下线,不参与召回。新技能上线前做一次回归测试,确认旧技能的替换没有引发连锁问题。
6.4 错误信息不语义化,模型像在猜谜
最早期我把错误统一抛成Exception("调用失败"),模型收到这个信息后完全不知道该怎么处理。它只能返回一句“抱歉,出错了”,用户体验很差。后来我把所有错误都转成结构化结果,带错误码和人类可读信息,比如“订单号格式不正确,ORD后面应该接10位数字”,模型看到这种信息后,能自己判断纠正参数重试,整个Agent的自主性马上就不一样了。
这套技能层我现在基本稳定下来了,回头再看,最核心的一条原则就是:把技能当成给大模型看的接口,而不是给自己看的函数。描述、Schema、错误返回,全都要按模型的消费习惯去设计;边界写得越死,选择准确率就越高。最后再分享一个小技巧:每次新增技能,我都会先写两条错误例句,也就是预判模型如果调错会怎么错、哪种场景最容易误调。如果写不出来,说明这个技能的边界还不够清楚。这个小习惯替我挡住了不少后期返工。