资讯详情

资讯详情

智能体技能工程化:从工具调用到参数契约的标准化实践

做智能体快两年我越来越觉得真正卡住项目进度的往往不是模型本身的智力而是怎么把模型的能力稳定地用在具体业务上。最近在重构一套智能体系统时我把整块重心压在了“技能工程化”上花了不少力气把散落的工具调用、提示词模板、业务逻辑收敛成一套统一的技能体系也就是这个项目——agent-skills。这篇文章想把整个设计思路、代码结构、踩坑过程完整记录下来给正在做类似工作的朋友一个可以直接参考的样本。先说结论技能体系这套东西核心不是“教模型多干活”而是“给模型的每一份能力配上清晰的说明书、参数契约和边界围栏”。模型本身并不缺“智能”缺的是稳定、可复用、可观测的工程化封装。1. 为什么需要一套技能体系从“能聊天”到“能干活”1.1 智能体开发正在撞上的三堵墙先描述一个很常见的场景你给智能体接了查询天气、设置提醒、查知识库、生成报表四个工具。单测每个工具时都工作正常模型也能准确调用。可一旦系统里工具数量超过二十个、三十个问题开始成片出现。第一堵墙是描述不一致。工具A的描述写在函数注释里工具B的描述写在prompt里工具C的描述干脆是老同事离职前留下的半段需求文档。模型在一次对话里能看到这些五花八门的描述却很难理解每个工具“什么时候用、什么时候不该用”。第二堵墙是参数不可信。模型确实能按照人写的字段名填参数但字段的格式、范围、默认值没有任何约束。用户说“提醒我明早八点开会”模型可能填出“08:00”“8:00”“2024-06-10 08:00:00”三种结果后端收到之后还得各自解析一遍解析出错时错误信息又晦涩难懂。第三堵墙是逻辑无法组合。单看每个工具都没问题但业务场景很少是“单个工具一次搞定”。比如“查一下本周所有任务把截止日期在三天内的筛选出来然后批量发送提醒”——这需要检索、过滤、格式化、发送四个能力协同。没有技能层这段编排逻辑就只能写死在应用代码里每个新场景都要重新开发。这三堵墙有个共同根源能力和使用方式之间缺少一层中间契约。工具本身是纯函数、纯接口模型要正确使用它靠的是对“说明文字”的理解。而说明文字恰恰是代码里最不被重视、最容易腐烂的部分。1.2 技能化设计与传统工具调用的差别传统的“工具调用”Function Calling方案解决的是“模型能输出结构化调用指令”这一步它默认开发者已经有了一个不错的工具集合只需要把工具的JSON Schema喂给模型。实际上绝大多数团队卡在更前面的地方工具的语义该如何沉淀模型的错误调用该如何反馈多个工具如何按业务流组织技能化设计把重心从“接口能力”移到“使用能力”上。一个技能不是一段函数代码而是一个完整的使用单元通常包含以下几个部分技能描述用自然语言写清楚这个技能解决什么问题、在什么条件下触发、有哪些边界。参数契约严格定义参数名、类型、取值范围、缺省行为让模型没有“自由发挥”的空间。执行逻辑技能背后的实际代码可以是调用第三方API也可以是一段本地处理流程。校验与反馈执行前的参数校验、执行后的结果校验以及模型错误调用时返回的可读错误提示。示例与元信息一两个“输入-调用-输出”的完整示例帮模型理解这个技能到底怎么用。有了这层结构模型面对的不再是散落的函数而是一份份“带说明书的标准化接口”。这些说明书可以统一管理、可检索、可做版本对比、可事后观测——天然就比一个prompt里塞几十段工具描述可靠得多。1.3 agent-skills项目定位给技能建一个“标准化流水线”我最初想做agent-skills动机特别简单我需要一套能支持“多种智能体、不断新增场景”的技能管理体系而不是每次写一个新智能体就要把所有工具重新封装一遍。项目目标是三层建模层定义技能的标准数据结构包括描述、参数、执行入口、校验规则、示例。运行时层实现技能注册、发现、路由、调用链路让模型能稳定地命中并执行正确技能。治理层提供技能的版本管理、质量统计、灰度切换手段让技能像业务代码一样被维护。这套体系可以直接套在多种模型和框架上不绑定某一家模型服务。实际落地时我把抽象层做得比预期薄很多核心就是“一份YAML描述 一个Python类 一个注册表”。真正的工作量在细节里——描述怎么写、参数怎么约束、路由怎么回退、报错怎么反馈给模型。下面分别展开。2. 技能模型设计一个技能到底长什么样一个技能首先要解决的就是“让模型正确理解它”的问题。这个“理解”不是靠模型聪明而是靠我们把信息组织得足够清晰。2.1 技能描述区模型靠什么理解你技能描述是整个体系里投入产出比最高的一块。写描述时最容易犯的错是把描述当成“功能说明书”来写比如“本技能用于查询天气信息”。这句话信息量很低因为模型不知道的是什么场景下该用不同粒度的问题该怎么处理参数缺失时怎么处理我更推荐用类似“使用场景 行为边界 参数说明”的结构来写。举个例子一个“查询用户账户余额”的技能描述技能名称: 查账户余额 使用场景: 用户询问当前账户可用余额、冻结余额、总资产时使用。 行为边界: - 不处理转账、充值等资金变动操作。 - 不支持查询历史余额历史数据请走“查账单明细”技能。 参数说明: - account_id: 必须。用户账户ID如未提供需向用户询问。 - include_frozen: 可选。布尔值是否包含冻结金额默认true。 常用示例: - 用户: “我卡里还有多少钱” 调用: account_id当前用户ID, include_frozentrue这段描述让模型拿到后至少知道三件事什么情况触发、什么情况绝对不能做、参数从哪里来。很多“模型总是乱调用工具”的问题其实是描述里没有写明“不要做什么”造成的。2.2 参数契约区用JSON Schema封死幻觉空间参数定义我直接采用JSON Schema格式这是大模型工具调用领域事实上的标准。但标准归标准写的时候有几个容易被忽略的点。第一每个字段都要写description。模型理解字段含义不靠字段名靠的是description。像start_date这种字段写清楚“格式YYYY-MM-DD含当天”和只写“开始日期”效果天差地别。第二能用enum约束的就用enum。比如排序方向、账单类型、任务状态这些如果只写字符串类型模型很容易输出一个你后端根本不支持的取值。用枚举直接封死。第三required字段要克制。只有真正无法缺省的字段才放进required列表。我以前总习惯把能自动推断的字段也标成必填结果模型频繁为了满足必填而自作主张填值反而增加出错概率。比如用户ID如果可以从会话上下文拿到就不要让模型在参数里猜。第四为“参数缺失”定义好默认策略。要么模型向用户追问要么用默认值兜底。最怕的是模型猜一个看似合理的值然后后端用一个并不存在的用户或者日期去查数据最后返回一堆无效信息。2.3 执行体与校验器业务逻辑的安全围栏技能的执行体就是一段普通函数但我不建议直接在函数里写业务逻辑。每个技能建议拆成三层参数清洗层、核心逻辑层、结果包装层。参数清洗层负责把模型传来的参数做一次现实世界的“格式化”字符串去空格、日期字符串转标准对象、ID走一次合法性校验。这一层是防御模型参数幻觉的桥头堡。核心逻辑层就是正常写业务代码调数据库、调API都行不需要为“AI”做特殊处理。结果包装层则要做一件关键的事把返回结构尽量稳定化。比如统一返回{status, data, message}结构其中message是给模型看的人类可读说明data是结构化数据。这样模型在用结果做后续推理时不需要从一段人话里抽数据。校验器我在早期版本里没做结果被坑得不轻。后来每笔调用都会走同一套校验流程参数校验、超时控制、异常捕获。校验器返回的错误信息必须足够“面向模型”——它会原样喂回给模型作为下一次尝试的依据。比如“日期格式错误正确格式应为YYYY-MM-DD比如2025-01-15”比“datetime parse error”好用一百倍。2.4 示例库与元信息让路由从“猜”变成“选”路由阶段模型其实在做一道匹配题给你当前对话从技能列表里选一个最合适的。这个匹配在一些语义接近的技能之间经常出错比如“查余额”和“查账单明细”、“创建任务”和“创建提醒”。消除歧义最有效的手段不是把描述写得更长而是给每个技能配一两个高质量“调用示例”。这里说的示例不是文档里的示例而是喂给模型时的few-shot样例直接附在技能定义里。我在项目里给每个技能维护了2到3个典型输入输出对覆盖最常出现的用户问法和少见的边界问法。模型临近判断时如果看到既往的“输入-调用”对应关系命中率会明显上升。元信息区则主要存版本号、负责人、创建时间、调用权限等级这些治理字段。这些字段不直接参与推理但后续做质量统计、灰度发布、权限管控时都是依据。3. 从零实现一套技能完整实操记录3.1 最小可运行技能以“查天气”为例纸上谈兵没意思直接拷一份我在项目里用的简化版代码出来做一个“按城市查天气”的技能。这个技能背后接的是一个模拟天气服务重点看整个技能是怎么组织和注册的。先定义一个技能基类统一入口# skill_base.py from abc import ABC, abstractmethod from typing import Any, Dict class BaseSkill(ABC): # 技能元信息 skill_name: str description: str parameters: Dict[str, Any] {} def __init__(self): self.validator self._build_validator() def _build_validator(self): # 用 jsonschema 校验参数 import jsonschema schema { type: object, properties: self.parameters, required: [name for name, meta in self.parameters.items() if meta.get(required)], } return {schema: schema} def validate_params(self, params: Dict[str, Any]) - Dict[str, Any]: import jsonschema jsonschema.validate(instanceparams, schemaself.validator[schema]) # 清洗去掉空白字符 cleaned {k: (v.strip() if isinstance(v, str) else v) for k, v in params.items()} return cleaned abstractmethod def execute(self, params: Dict[str, Any], context: Dict[str, Any]) - Dict[str, Any]: pass def run(self, params: Dict[str, Any], context: Dict[str, Any]) - Dict[str, Any]: 统一入口校验-执行-包装 try: params self.validate_params(params) result self.execute(params, context) return {status: success, data: result, message: } except Exception as e: return {status: error, data: None, message: str(e)}然后实现具体的“查天气”技能# weather_skill.py from skill_base import BaseSkill from typing import Any, Dict from datetime import datetime class WeatherSkill(BaseSkill): skill_name query_weather description ( 使用场景: 用户询问某城市当前天气、温度、降水概率时使用。\n 行为边界: 不处理未来一周之外的预报查询不提供穿衣建议。\n 参数说明: city为中文城市名必须date可选默认今天。 ) parameters { city: { type: string, description: 城市中文名例如北京、上海、广州, required: True }, date: { type: string, description: 查询日期格式YYYY-MM-DD不填默认为今天, required: False } } def execute(self, params: Dict[str, Any], context: Dict[str, Any]) - Dict[str, Any]: city params[city] date params.get(date, datetime.now().strftime(%Y-%m-%d)) # 这里模拟调用外部天气服务 weather_map { 北京: {temp: 32, condition: 晴, rain_prob: 10}, 上海: {temp: 29, condition: 多云, rain_prob: 30}, } if city not in weather_map: raise ValueError(f暂未收录该城市天气数据: {city}) data weather_map[city] return { city: city, date: date, temperature: data[temp], condition: data[condition], rain_probability: data[rain_prob] }这段代码里有两个细节值得留意。一是run方法里统一做了异常捕获并把错误信息转成字符串返回——这个字符串最终会作为“模型可读的错误信息”被带回推理循环让模型有机会自我修正。二是参数清洗里做了strip处理避免模型在城市名首尾加空格之类的问题。3.2 技能注册与发现机制怎么搭有了技能类的定义下一步就是注册表。注册表承担的责任有三个存技能、按技能名取技能、给模型生成“可调用技能清单”。# skill_registry.py class SkillRegistry: def __init__(self): self._skills {} self._skill_list [] def register(self, skill): if skill.skill_name in self._skills: raise KeyError(f技能重复注册: {skill.skill_name}) self._skills[skill.skill_name] skill self._skill_list.append(skill) return skill def get(self, skill_name: str): return self._skills.get(skill_name) def describe_for_model(self) - str: 给模型看的所有技能的描述总览用于路由选择 lines [] for skill in self._skill_list: lines.append(f技能名: {skill.skill_name}) lines.append(f描述: {skill.description}) lines.append(f参数schema: {json.dumps(skill.parameters, ensure_asciiFalse)}) lines.append(---) return \n.join(lines)实际部署时我更建议把技能列表做成一个目录扫描机制每个技能放一个文件夹skills/weather/里面有skill.py和一个meta.yaml。程序启动时扫描目录、自动注册这样新加一个技能只需要新建文件夹不需要改注册代码。这个发现机制还要处理一个现实问题当技能数量超过二三十个时把所有技能描述一股脑塞给模型会增加token消耗并干扰路由准确率。我最后的方案是加了一层轻量级预筛用关键词匹配把候选技能压缩到三到五个再把这几个候选的详细描述交给模型做精准选择。实战下来直接降了将近一半的路由错误。3.3 多技能编排让规划器学会“先查后算”只有一个技能的场景太少更多时候是“完成一个任务需要走多个技能”。这里我把编排逻辑和技能执行解耦了用一套简单的规划器来驱动。用户: 统计一下上个月所有已完成任务的平均完成时长 规划器步骤: 1. 调用 search_tasks(状态已完成, 时间范围上月) 2. 从结果中提取每个任务的结束时间和创建时间 3. 调用 compute_average(上一步的时间差列表) 4. 把平均值格式化后回复给用户这个规划器本身不需要太复杂我甚至不建议一开始就上重型Agent框架。在agent-skills里我采用的是“状态机 记忆栈”的轻量方案每一步从注册表里选技能执行执行结果写回上下文字典下一个技能既能读取上下文又能拿到前一步的结构化数据。这种编排方式最大的好处是每个技能保持无状态它们不关心自己是第几步只关心“输入参数够不够、能不能执行”。组合逻辑全部由规划器负责技能可以独立测试、独立复用。3.4 这套结构带来的两个直接收益第一是新增技能的成本大幅下降。以前新接一个能力要写prompt、改后端接口、调参数格式现在只写一个技能类、定义好描述和schema、注册进目录就结束了。第二是技能质量可度量。因为每个技能都有统一入口和统一的返回结构我可以在入口处做全量日志记录哪次调用、模型传了什么参数、校验通过没有、执行耗时多少、结果是否成功。把日志聚合成报表之后一眼就能看出哪个技能使用率低、哪个技能总是报错、哪种参数经常填错——这对后续迭代是决定性的输入。4. 真实踩坑记录五大高频故障与排查思路这套体系并不是一次调通的我在实际开发中积累了不少问题症状和解决方法整理成五大类方便大家对照排查。4.1 模型总是选错技能选错技能最常见的两个原因一是描述太像二是缺少反面示例。比如“查余额”和“查账单明细”模型经常在“用户想看最近消费记录”时去调用“查余额”。我把它归因到描述设计的层面后做了一件事给每个技能加了一个“不要用于”的显式字段。以“查账户余额”为例明确写了“用户问某笔交易、某类消费统计时不使用本技能应转交查账单明细”。这个改动只花了几分钟但把混淆场景的错误率降了一大截。另外如果发现某几个技能频繁互相误触我会启用一个编译期检查对技能描述做相似度计算找出描述语义过近的技能对手动改写描述边界。4.2 参数幻觉导致反馈指令执行失败模型传的参数看起来“合理”但实际不可用。典型情况用户没提供日期模型自作主张填了当前日期用户说“查我的余额”模型把用户ID填成一段猜测值。这个问题靠模型自身几乎无法根治必须在技能层兜底。我采取的方案是两条把必填参数尽量从模型参数里移除放到上下文里由后端从会话状态中注入。比如用户ID、当前时间、用户所在城市都不需要模型推断直接由调用框架塞进参数。对可选参数如果值不在合法范围内不抛异常而是回退到默认值并在返回message里注明“参数date无效已使用默认值2025-01-01”。这样模型不会因为一次报错中断整条链路。这个策略特别重要让模型继续往下走比让它停下来反复猜测要好。4.3 技能冲突与覆盖问题技能多了以后难免出现同义技能或替代技能。比如不同部门各做了一版“查订单”规则还不一样都注册进来就会发生冲突。我在注册表里加了一套优先级和版本机制。同一技能名下允许存在多个实现但只有一个“活跃版本”。新技能注册时默认置为候选只能通过显式的上线指令切换。切上线之前还有一层灰度逻辑按用户ID哈希取模让一小部分流量走新版本观察成功率后才全量切换。这套机制虽然增加了部署成本但对于线上稳定运行几乎是必须的。“碰运气式”的技能替换早晚会在某个用户身上踩出事故。4.4 长任务中“做完就忘”多步编排时前一步的结果在后续步骤里经常被遗忘。原因是中间结果要么没写回上下文要么写回的格式对模型太不友好。我的解决方式是把每一步的产物统一做成“结构化记忆条目”。比如查询任务列表的结果除了给规划器的原始数据还会生成一条自然语言摘要“检索到12个已完成任务最早开始时间3月1日最晚结束时间3月25日”。后续步骤优先使用结构化的摘要做上下文填充减少模型翻找原始结果的过程。另外还有一个不起眼但很关键的细节中间结果不要全部提交给模型。我见过很多长任务失败是因为上下文里堆了太多查询结果模型注意力散掉了。在编排时我只把当前步骤依赖的最小字段写进下一轮提示其余的留在内存里需要时再按引用取。4.5 测试覆盖不足技能也需要单测技能代码可能是项目里最容易出现“没测就上线”的模块。我自己在早期就吃过苦头一个技能在单元测试里完美通过结果模型传了一个带引号的日期字符串直接崩了。后来我养成了一个习惯每个技能的测试不需要写很多用例但至少要覆盖四类正常输入、边界输入、缺失参数、错误类型。尤其要模拟模型传来的“脏参数”比如带中文括号的日期、全角逗号、前后空格。这些在真实模型输出里出现频率远高于人工测试预判。测试不通过时优先修技能参数清洗层而不是修模型提示词。因为清洗层的修复是确定性的模型提示词改了之后的行为是不确定的。5. 技能运营与演化上线只是开始技能上线只是起点一个技能从“能用”到“好用”是个持续运营的过程。5.1 技能质量度量命中率、成功率、修复率我在项目里定义三个核心指标用来衡量每个技能的健康度指标含义计算方式命中率技能被选中的次数 / 对话总次数中应该被选中的次数抽样判断成功率技能执行成功的次数 / 被调用总次数日志统计修复率模型收到错误信息后重试并成功的次数 / 报错总次数日志统计成功率好理解命中率需要人工抽样标注。我会定期拉取一批模型选择技能的日志人工判断“是不是应该选这个技能”把结果反馈回注册表的描述优化。修复率则反映报错信息质量——如果模型总是修复失败说明错误信息不够明确应该重写异常提示。这三个指标归一后可以给每个技能打个综合分。得分过低的技能要么优化描述要么降级为仅手动调用不再参与模型的路由选择。5.2 版本管理与灰度发布技能业务逻辑更新时最怕的是“改了A需求炸了B场景”。我在技能层次上做了版本封装每个版本带一个兼容字段和变更记录。参数Schema严格遵循向后兼容原则只允许新增可选字段不允许删除或改类型。必要时新增字段名带版本前缀比如address_v2避免解析歧义。灰度发布按用户维度做初期放5%的流量观察成功率、平均耗时、模型修复率。这三个指标稳定后再逐步放开。别看这跟普通服务发布的流程一样在技能场景里特别容易被人忽略因为“技能不就是一个函数吗”的错觉太强了。实际上一次技能更新影响的是一条语义链路影响范围比单个接口大得多。5.3 沉淀可复用技能块技能经过一段时间迭代会沉淀出一批跟具体业务无关的通用能力比如“日期解析”“金额格式化”“城市名标准化”。这类技能块我单独放一个库作为新技能的“基础件”。写新技能时很多参数清洗工作可以直接引用基础件不用重写。有一次我需要在两个不同业务线里各做一个“查库存”的技能业务逻辑完全不同但参数清洗都用了同一套“商品编码标准化”模块。这部分复用省下了大量开发时间也让两边的参数格式保持了一致。这个思路还可以进一步推广把“技能的描述模式”也沉淀下来。我总结过几套描述模板比如“查询类技能怎么写边界”“操作类技能怎么写示例”“汇总类技能怎么写默认策略”。写新技能时套模板描述质量的底线下限就守住了一大半。最后再分享一个实际感受技能体系真正难做的不是技术实现而是持续维护的纪律。技能描述、参数Schema、错误提示这些看似“软”的部分才是决定整个智能体质量的关键。我踩过无数坑之后最深的体会是——不要指望模型自己变聪明要把每一步都做成它不用猜的样子。技能化这条路本质上是在用软件工程的纪律对冲大模型的不确定性越往后走这套纪律带来的复利越明显。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →