☰
Agent技能系统设计实战:从Function Calling到可复用技能层
2026/10/8 17:03:17 网站建设 项目流程

先说个我最近的真实经历。团队里有个Agent Demo,平时演示效果很好,能写周报、能做数据分析、能查天气,客户看了都点头。可一到生产环境就露馅:让它“帮我查一下华东区上个月的销售数据,顺便对比前两个月”,它要么直接扫全表,要么编一个根本不存在的表名,要么答非所问。排查到最后,问题不在模型,也不在Prompt,而在于我们根本没把“它能干什么”这件事,用机器能理解的方式告诉它。

这个问题的答案,就是我最近一直在研究和落地的agent-skills——把Agent的能力拆成一个个可命名、可注册、可复用的“技能”,让模型在合适的时机准确调用合适的技能,而不是指望它在对话里即兴发挥。这篇文章不讲太空的概念,讲落地:技能系统长什么样、怎么设计、怎么写代码、上线之后会踩哪些坑,以及什么时候直接用现成框架,什么时候自己造。

1. 技能不是API封装:先理清Agent的调用模型

1.1 为什么在Prompt里写“你可以调用这些功能”不靠谱

早期做Agent,最常见的做法是把所有能力写进系统提示词:“你可以调用以下API:查询库存、创建订单、发送邮件……”。听起来没问题,但实际效果非常不稳定。模型需要自己记忆这堆能力,在对话里猜测用户意图匹配哪一条,然后用自己的语言描述调用过程——这个链路里每一步都有损耗。用户问“还剩多少货”,模型可能写“库存查询功能显示库存不足”,但它根本没触发任何真实调用。

本质原因在于:自然语言描述能力边界,靠的是模型“理解”而不是“执行”。而函数调用(Function Calling)的机制,是把能力以结构化Tool Schema的形式喂给模型,模型输出一个严格的JSON调用请求,由程序解析后执行。这一步从“让模型想起能做什么”变成了“让模型在给定选项里选一个”,准确率完全不在一个量级。

那这和agent-skills有什么关系?关系在于:原生函数调用只解决了“怎么调”,没解决“调哪个、什么时候调、调完怎么处理”。技能层填补的就是这个空档。

1.2 技能、工具、工作流的边界

很多团队把这三个词混着用,结果设计出来的系统边界混乱。我建议按下面这种方式切分:

层级例子特征
工具查询单个接口、执行一段SQL原子操作,无状态,不关心业务上下文
技能“查询销售数据并生成趋势图”封装成一个可调用单元可命名、有明确触发条件、有输入输出规范、可复用
工作流月度经营分析:取数→清洗→分析→写报告→发邮件多步骤、有顺序和分支、面向完整业务结果

技能是中间层,也是最重要的层。一个技能可以封装一个工具,也可以封装多个工具的固定组合。它面向的是“一个完整的、能被业务描述的能力”,而不是“一段代码”。举个具体的例子:“按日期范围查销售汇总”和“查完销售再画一张环比图”是两种粒度,前者是工具,后者才值得做成技能。技能自带使用说明书,模型只需要知道“什么场景调用它”,里面的实现细节完全封死。

1.3 技能化带来的三个实际收益

第一是可测试。单个技能可以独立喂测试用例,统计触发准确率、参数正确率、执行成功率,而不是每次都要端到端跑完整对话才能验证。

第二是可观测。每个技能调用都记一条日志:调用了哪个技能、传入什么参数、返回什么结果、耗了多少token。出问题的时候能定位是触发错了、参数错了还是执行错了。

第三是可复用。同一套技能库可以同时服务销售助手、客服机器人、数据分析Agent,团队里新来的同学直接看技能列表就知道系统能干什么。

2. 设计一个技能:四要素与描述的艺术

2.1 一个技能至少要定义四样东西

我见过的技能系统各有各的写法,但核心逃不出四要素:

  • name:技能的唯一标识,建议用命名空间,比如sales.query、order.create,避免后期技能多了名字撞车。
  • description:给LLM看的使用说明,这是触发准确率的关键,后面单独说。
  • parameters:入参的JSON Schema,卡死类型、枚举、必填项。
  • executor:真正执行技能的函数,接收解析后的参数,返回标准化结果。

有些技能还需要额外的元信息,比如requires_permission(是否需要用户确认)、side_effect(是否产生写操作)、max_output_len(返回内容上限)。这些元信息不要丢在描述里让模型猜,而是作为字段传给调用框架,由框架在调度时强制执行。

2.2 描述决定了80%的触发质量

同一个技能,描述写法不同,触发率能差出一倍。这里的核心原则是:描述不是给人看的文档,而是给模型做“意图匹配”的检索条件。

一段合格的技能描述应该包含三部分:

  • 触发条件:用户问到什么主题、什么意图时可以调用这个技能。
  • 不触发条件:明确排除哪些情况,防止误触发。
  • 输入约定:需要用户提供哪些信息,缺失时是让模型反问,还是用默认值。

我拿“查询销售数据”这个技能举例子。

写法实际效果
“查询销售数据”模糊,所有涉及数据、报表、甚至库存的问题都可能触发,模型还得自己脑补参数
“查询指定区域、指定日期范围的销售汇总数据。当用户询问‘卖了多少钱/多少量/销售额/销量’时使用;只用于销售主题,不要用于库存、采购、退款;日期必须补全到YYYY-MM-DD格式,缺少年份时默认当前年份”模型能清晰判断边界,参数生成正确率也明显更高

模型本质是在做“当前用户问题”和“技能描述”的语义匹配。描述里信息越多、边界越明确,这个匹配就越准。写描述时把自己想象成搜索引擎的索引工程师——你不是在写说明书,你是在帮模型快速命中正确的那张卡片。

2.3 参数用JSON Schema卡死,别让模型自由发挥

函数调用机制允许你给每个参数定义类型、枚举、格式约束。我强烈建议在这一步做足约束,而不是等模型输出了再校验。下面是一个实战中比较完整的技能参数定义:

parameters = { "type": "object", "properties": { "region": { "type": "string", "enum": ["华东", "华北", "华南", "西部"], "description": "销售区域,必须是枚举值之一" }, "start_date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "起始日期,格式YYYY-MM-DD" }, "end_date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "结束日期,格式YYYY-MM-DD,必须晚于或等于start_date" }, "compare_prev": { "type": "boolean", "description": "是否需要同时返回上一周期的同比数字,默认false" } }, "required": ["region", "start_date", "end_date"] }

每个字段都给description,给pattern,给enum。模型在生成参数时会参考这些约束,乱填的概率会大幅下降。但记住:Schema只是第一道防线,运行时校验不能省。模型输出的JSON偶尔还是会违背Schema,这时候要捕获校验错误,把错误信息作为工具结果返回给模型,让它修正后重试。这个重试逻辑后面在调用循环里会看到。

2.4 返回结果标准化

技能执行完,返回给模型的内容不要是裸的原始数据,而是统一的结构。我们团队目前用的协议是三段式:

{ "status": "success", # success / error / empty "data": { ... }, # 数据本体,已经过裁剪、汇总或格式化 "meta": { "rows": 128, # 原始数据量 "truncated": true, # 是否被截断 "exec_ms": 312, # 执行耗时 "version": "1.2.0" # 技能版本 } }

status让调用循环可以快速判断要不要把错误回灌给模型;data控制在合理大小内;meta给日志和排障用。这一步非常关键,因为模型生成最终回答时,只会看你返回给它的那段文本。技能返回得越规整,最终回答的质量越稳定。

3. 一个能跑的轻量技能框架(Python版)

3.1 注册中心

我没用重框架,而是用一个装饰器加一个字典实现注册中心。够用,而且逻辑清晰。

import json from dataclasses import dataclass from typing import Callable, Dict @dataclass class Skill: name: str description: str parameters: dict executor: Callable _SKILL_REGISTRY: Dict[str, Skill] = {} def register_skill(name, description, parameters): def decorator(func): _SKILL_REGISTRY[name] = Skill( name=name, description=description, parameters=parameters, executor=func ) return func return decorator def get_skill(name: str) -> Skill: return _SKILL_REGISTRY[name] def all_skills() -> list[Skill]: return list(_SKILL_REGISTRY.values())

使用的时候,在每个技能函数上标注注册信息:

@register_skill( name="sales.query", description="查询指定区域、指定日期范围的销售汇总数据。当用户询问销售额、销量时使用;只用于销售主题,不用于库存、采购等场景。", parameters={...} # 上面的JSON Schema ) def query_sales(region: str, start_date: str, end_date: str, compare_prev: bool = False): ...

这个设计的好处是:新增一个技能只需要新增一个函数,不碰调用逻辑。后面做技能市场、跨团队共享,本质都是把这个注册表导出来。

3.2 会话循环

核心的调用循环,我按ReAct的简化版实现。每轮对话做三件事:把全部技能Schema发给模型 → 模型决定是否调技能、调哪个 → 执行技能并把结果回灌,然后让模型基于结果生成下一轮。伪代码如下:

def run_agent(user_message: str, llm, max_rounds: int = 8): messages = [{"role": "user", "content": user_message}] tools = [skill_to_tool_schema(s) for s in all_skills()] for _ in range(max_rounds): resp = llm.chat(messages, tools=tools) msg = resp.message messages.append(msg) # 模型没有调用技能,说明已经给出最终答案 if not msg.tool_calls: return msg.content for call in msg.tool_calls: try: result = execute_skill(call.function.name, call.function.arguments) except Exception as e: result = {"status": "error", "data": str(e), "meta": {}} messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False) }) return "达到最大轮次,已停止"

skill_to_tool_schema就是把注册表里的技能转成模型API要求的格式:name、description、parameters一一对应。执行失败时不要把异常吞掉,而是以标准错误结构回灌给模型,让模型知道“这个技能调不通”,从而决定要不要换一种方式处理或者如实告知用户。

3.3 结果回灌与上下文控制

技能返回结果以role: tool的形式塞回对话,这一步有两个容易踩的坑。

第一个坑是上下文爆炸。技能查出来2000行数据,全部回灌,两轮对话之后Token直接超限。我们的做法是:技能在返回前先做一次裁剪或汇总——能聚合的聚合,能算总数的算总数,实在需要明细就写到一个临时文件,返回文件路径而不是数据本体。这样模型拿到的是“结果说明+可操作的下一步”,而不是一坨需要它自己阅读理解的数据。

第二个坑是格式不一致。有的技能返回dict,有的返回list,有的直接返回字符串。模型在解析时会很痛苦。所以上面那个统一的三段式结构一定要强制执行,最好在装饰器里就做一层格式校验,不满足结构的直接抛错。

3.4 组合技能:技能内部再调技能

复杂能力不是靠单个原子技能拼出来的,而是组合出来的。以“生成月度销售分析报告”为例,它可以是一个高级技能,内部依次执行sales.query、sales.trend_chart、sales.summarize三个子技能。组合有两种实现方式:

  • 代码编排:在executor里直接按顺序调用子技能。优点是确定性强、不费额外的模型调用;缺点是灵活性差,分支逻辑要写死。
  • LLM编排:高级技能的executor把子技能列表交给模型,让模型决定顺序。优点是可以应对多变场景;缺点是多一次模型调用,且可能选错。

我的建议是:只要业务分支是可枚举的,就选代码编排。Agent的“智能”应该体现在理解用户意图和生成内容上,而不是体现在实现一个确定性流程上。代码编排出错,你能一行行查;LLM编排出错,你只能看日志猜它当时在想什么。

4. 生产环境翻车实录:技能失效的五种场景

4.1 场景一:模型声称调用了技能,但实际没有

这是上线后我遇到最多的幻觉问题。用户问“查一下订单状态”,模型在最终回答里写“订单状态查询结果如下……”,但日志里根本没有order.query的调用记录。原因通常是描述写得过于宽泛,或者模型在某一轮生成时tools列表没有被正确传递。

排查链路是这样的:先查日志里该轮请求的tools字段有没有带上这个技能;如果带了,再看模型返回里有没有tool_calls;如果没有,说明模型选择了“硬答”。这时需要在系统提示词里加一句硬约束:“如果你没有实际调用技能获取信息,不得声称已查询或已执行。信息缺失时,请明确告诉用户你需要调用某个技能但未能成功。”同时在最终回答生成之前,用程序校验一下:如果模型声称调用过某技能但日志里没有对应记录,就打回重写。

4.2 场景二:参数编造,区域内枚举都能凭空捏造

参数Schema里明明写了enum: ["华东", "华北", "华南", "西部"],模型还是生成了“中原”和“西南”。这类错误在真实用户提问中经常出现,因为用户可能用口语、简称、别名。深层原因是模型把用户的原话直接映射进了参数,而没有走一道语义规整。

我们的解法分两步:第一步,Schema里给每个枚举值配上别名提示,比如在description里写“华东包括上海、江苏、浙江、安徽”;第二步,运行时校验失败时,把错误信息作为工具结果回灌,让模型根据错误修正参数重试。重试一般一次就能成功,如果连续两次失败,就放弃该技能,如实告诉用户参数不对。这里要注意:重试不是无限循环,最多两到三次,否则既浪费Token又拖慢响应。

4.3 场景三:返回体过大,直接把上下文撑爆

有一次线上事故,销售查询技能的接口返回了几万行明细,回灌到对话里,下一轮请求直接超限。根源在于技能设计时只看“能不能查到”,没看“返回多少合适”。

现在的规矩是:所有列表类技能默认限制返回行数,超出部分必须聚合;明细类数据原则上不直接回灌,改写成临时文件路径并在data里放摘要。如果用户真的需要明细,让模型引导用户去查看文件,而不是在对话里逐条读。这条规则要写进技能开发规范,而不是靠每个开发者自觉。

4.4 场景四:多个技能描述重叠,模型选错

技能一旦多了,描述之间很容易出现交叠。比如有个query_sales(查销售额)和一个query_trend(查趋势),用户问“最近三个月销量走势怎么样”,模型可能选了前者而没画趋势图。这类问题很难靠改单个描述解决,因为每个描述单独看都没问题。

我做的是两级策略:第一步,为高频意图组增加一个“路由器技能”,它是一个不实际执行任何操作、只负责选择下一步该调用哪个子技能的高级技能;第二步,每次新增技能时,强制跑一遍已有技能列表,检查是否存在描述重叠,重叠的就往下拆分或加边界词。这项检查我已经写进了代码评审清单,新增技能不带边界说明的不予合并。

4.5 场景五:技能内部报错,模型假装一切正常

最隐蔽的翻车是技能执行抛异常了,错误回灌给模型之后,模型为了“不丢面子”,返回给用户时反而把错误包装成了正常答案。比如技能返回{"status":"error","data":"数据库连接超时"},模型的最终回答却是“查询成功,销售额为1234万元”。

这个数字是模型编的,非常致命。我们的强制约束是:当status为error时,在回灌内容里追加一行“系统标记:本次调用失败,请勿生成任何与结果相关的数据结论,只能向用户说明调用失败并建议稍后重试。”同时在前端展示层做二次校验,如果最终回答里出现了具体数字,而后台日志显示该轮存在错误状态,就拦下来提示重试。

5. 技能系统的进阶:评估、版本和框架选型

5.1 怎么给技能打分

技能不是写完就完事了,它需要持续评估。我们每个月会抽一批真实用户问题,构建一个评估集,每个问题标注期望触发的技能、期望的参数、期望的回答类型。然后跑一遍离线回放,统计四个指标:

  • 触发准确率:该触发时是否触发,不该触发时是否误触发。
  • 参数准确率:触发后生成的参数是否合法、是否符合用户意图。
  • 执行成功率:技能内部实际执行有没有异常。
  • 终答合格率:基于技能返回值生成的最终回答是否满足用户需求。

前三个容易自动化,第四个需要人工抽检。没有评估集的技能系统,就是凭感觉迭代,迟早会在某个改动上整体退化。

5.2 技能版本与灰度

技能一旦上线并被业务依赖,就不能随意改了。我们要求每个技能带版本号,Schema和执行逻辑变更时版本递增。线上跑的时候,同一个技能名可以同时存在旧版本和新版本,按流量比例灰度,比如先放5%的请求走新版,观察触发率和成功率,稳定后再全量。调用日志里必须记录用到的具体版本,否则灰度期间出了问题,你连是哪个版本造成的影响都不知道。

这个版本规范在早期会觉得麻烦,但当技能规模超过几十个的时候,它就是唯一能保证可控变动的机制。

5.3 什么时候该用现成框架,什么时候自己写

agent-skills这个领域现在框架不少,有LangChain的Tools体系,有各家模型的Function Calling原生能力,也有一些专门的技能编排框架。我的选型原则很简单:

你的情况建议
只有几个技能,验证概念直接用模型的Function Calling原生能力,别上框架
技能几十个,需要统一注册、评估、日志自建轻量技能层,参考上文这套设计
需要复杂编排、多Agent协作、状态管理等考虑成熟Agent框架,但仍要把技能层独立出来
团队大、业务线多,需要跨团队共享技能技能做独立仓库,包成内部SDK,按命名空间隔离

框架不是越重越好。我自己实际体会是:技能层这个抽象,自己写一遍对理解系统很有帮助,而且也就一两百行核心代码。等到业务规模撑不住了,再迁移到成熟框架,此时你的技能Schema和评估集都已经沉淀好了,迁移成本并不高。

5.4 别把技能库做成什么都往里塞的垃圾桶

最后提醒一句治理问题。技能库最容易变成“垃圾桶”——每个人把自己最新的想法塞进来,导致管理混乱。我们内部有几条简单规矩:技能必须有明确业务owner;命名必须带命名空间;新增技能必须过一遍描述重叠检查;超过30天无调用记录的技能标记为deprecated,再超过60天下线。技能是Agent的肌肉,不是器官移植清单。少而精,永远比多而杂好用。

最后再分享一个我们团队现在还在用的做法:新技能上线后的第一周,每天人工看一遍当天的调用日志,重点看“没触发但应该触发”和“触发了但参数不对”这两类case,哪怕每天只看半个小时的日志,也比月底一次性复盘发现问题要快得多。agent-skills这套东西,真正的难点从来不是写代码,而是后续一轮一轮地打磨触发边界。别指望一次设计到位,把它当成一个持续维护的产品,你会省掉很多半夜上线的痛苦。

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

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

立即咨询