做Agent开发这一年多,我最大的感受是:真正决定一个助手能不能从“Demo里的玩具”变成“生产环境里的工具”,不是模型选得多强,不是Prompt写得花,而是它到底能不能稳定地调用外部能力。这个问题往深了挖,最终都会落到同一个东西上——agent-skills。
所谓agent-skills,说白了就是给AI智能体准备的一套“技能库”。你可以把它理解成给Agent装上了手和脚:模型负责思考,技能负责执行。没有技能的Agent只会跟你聊天,有技能的Agent才能帮你查服务器、改配置、发工单、分析数据。这篇文章我会用一套完整的设计思路和可落地代码,把技能的定义、注册、编排、调优整个链路讲一遍。适合正在做Agent应用、或者准备把AI接入业务流程的开发者和技术负责人参考。
1. 先把概念讲透:Agent“能说话”和“能办事”之间差着一层技能层
1.1 为什么单独搞一个“技能层”,而不是把工具写进Prompt
很多人刚开始做Agent的时候,会走一条弯路:把所有工具说明和调用规则直接塞进System Prompt。事实证明这条路走不长。
Prompt的上下文窗口是有限的,你塞进去10个工具说明书可能还凑合,塞到50个、100个的时候,模型的选择准确率会肉眼可见地往下掉,更重要的是Prompt每次请求都要重复加载,Token成本暴涨。我见过一个团队把60多个工具写进Prompt,结果单次请求光系统提示词就吃掉近万Token,调用延迟直接翻倍,而且模型经常把相似功能的工具搞混。
技能层的核心价值就是把“工具定义”从“运行时上下文”里剥离出来。模型上下文中只保留一份精简的技能清单:技能编号、名称、一句话描述。真正完整的参数Schema和执行逻辑放在外部的技能注册中心里,由运行时按需加载。这相当于给Agent做了一次“应用冷启动热加载”——它不需要一开始就记住所有技能细节,真正要干某件事的时候再去把对应技能“提出来”。
另一个关键原因是安全和权限。技能层天然是一个收口的地方:所有外部能力都要在这个边界上做鉴权、做参数校验、做执行审计。如果工具散落在Prompt里,每个工具的逻辑可能各自为政,安全策略根本没法统一管理。
1.2 三种主流技能范式,别急着选,先理解差异
我在实操过程中,接触到的技能实现大致分三类,各有适用场景。
第一类是函数调用范式,也就是OpenAI/Anthropic等主流模型支持的Function Calling。开发者定义好JSON Schema描述的函数,模型在生成回复时输出结构化的函数调用请求,由你的代码真正执行。这种方式最成熟,可控性最强,适合交易类、配置变更类等高精度场景。
第二类是协议/标准范式,也就是类似MCP(Model Context Protocol)这样的统一协议。它把“技能”标准化成可通过服务端动态发现和调用的资源,好处是生态互通性强——你写的技能能力可以被不同Agent复用,对方的技能你也能挂载过来。缺点是需要维护额外的基础设施,小团队初期会感觉有点重。
第三类是自然语言技能范式,也就是把技能写成“带自然语言说明的指令包”加配套脚本。模型读说明之后自己决定要不要调用脚本、怎么传参。它比较轻量,适合快速验证想法、批量做数据处理的场景,但结果的稳定性相对差一些,本地开发和调试都要把握好尺度。
我做生产项目的时候通常不押注单一范式,而是以“函数调用范式”为主线,然后把MCP兼容层作为一个适配器来接外部生态。这样既能保证核心技能的执行确定性,又不会被某一家厂商锁定。
1.3 技能平台选型:导演和演员要分开
还有一个容易被忽略的思路:技能层里其实是两类“角色”在协作。一类是技能的执行实现,也就是真正的代码干活的部分;另一类是技能的编排驱动,也就是Agent大脑决定何时用哪个技能。这两个角色最好解耦,用我常说的话讲就是“导演不要上场演戏”。
这意味着你在设计技能API的时候,要让Agent侧的调用非常简单:一个技能ID、一组业务参数。至于这个技能内部是走HTTP调用远端服务、读本地数据库还是执行一段Python脚本,Agent完全不需要关心。我在一次实践中把技能执行端重构成了插件机制,新增技能不需要改动Agent核心代码,只丢一个技能定义文件加一个实现模块进去就行,上线周期从几天缩短到几小时。
2. 技能的核心结构:一份能被模型读懂的技能定义
2.1 技能描述怎么写,决定Agent能不能调对
技能结构里最容易被低估的就是那几句自然语言描述。很多开发者照着函数注释随手写几句就完事了,结果模型频繁选错技能。我总结出一个原则:描述是要写给模型看的,不是写给同事看的。
写技能描述时要覆盖三层信息:这个技能干什么、什么时候应该用、什么时候不应该用。比如一个“系统健康巡检”技能,我会这样写:
name: "system_health_check" description: | 对指定主机执行健康巡检,获取CPU、内存、磁盘使用率以及核心服务运行状态。 当用户提到“服务器卡顿”“CPU满了”“磁盘空间不足”“服务挂了”“机器状态” 等场景时,优先调用此技能。 仅用于线上已知主机,若用户询问不存在的机器,应直接反馈未登记,不要猜测。“什么时候应该用”这部分尤其重要。模型不是不知道工具列表,它经常是因为不知道“当前用户的哪句话对应哪个工具”而选错。把触发场景写明确,就是在帮模型做路由判断,而且能大幅减少无效调用。
2.2 参数Schema:把幻觉拦在进场之前
技能参数用JSON Schema定义,这是函数调用范式的标准做法。但很多团队把Schema当成一种“格式要求”,只定义了字段名和类型,别的什么都不管。实际上,Schema是模型避免幻觉的最后一道天然屏障。
举一个真实的例子:我们的“重启服务”技能,参数里有一个service_name字符串字段。模型经常会把用户随口说的“那个东西”“主业务”原样填进去,然后在执行时发现根本找不到对应服务。后来我在Schema里做文章:
{ "name": "service_name", "type": "string", "description": "需要重启的服务名,必须是运维平台已经登记的服务标识", "enum": ["api-gateway", "crm-core", "order-worker", "search-svc"] }加了enum枚举后,模型只能从已知服务里选择,否则就不会产生调用请求,而是追问用户“请提供正确的服务名”。一个字段规则,把一次必然失败的执行提前拦下来,这就是Schema的价值。
更复杂的参数场景还可以用oneOf、嵌套对象、additionalProperties: false等约束。我的经验是:Schema写得多严谨,运行时做的脏活就少多少。参数校验这件事,必须在技能执行端再做一次,不能完全信模型输出的参数。
2.3 返回值与失败协议:给Agent“看得见”的反馈
技能执行完返回什么,直接决定了Agent下一步判断的质量。很多开发者的返回结构就是一坨字符串,成功失败全靠自然语言描述,这对Agent来说极其不友好。
我推荐的技能返回结构统一抽象成三块:
{ "status": "success | failed | partial", "data": {}, "message": "给Agent看的一句摘要" }status字段让Agent一眼知道结果状态,data是结构化结果,message是给Agent继续推理用的摘要信息。这三者缺一不可,尤其data里面千万不能只放拼好的文本,必须放结构化数据,让Agent可以从中抽取、计算、汇总。
失败协议也很重要。技能执行失败的返回里必须包含失败原因的分类——是“参数不对”“权限不足”“远端超时”还是“业务规则不满足”。Agent看到分类之后,才知道是自己应该换参数重试,还是直接告诉用户办不了,避免无意义的重试循环。
3. 技能注册与运行时编排:Agent是怎么“学会”用技能的
3.1 注册中心:一切技能的中枢
技能不能散落在代码仓库里,需要一个注册中心来统一管理。对中小团队,完全不需要上庞大系统,用一个简单的注册中心就足够了,我自己实践下来,最实用的形式是:一份YAML/JSON清单文件加一个Python模块自动载入器。
技能清单文件长这样:
skills: - id: "system.health_check" name: "system_health_check" description: "对指定主机执行健康巡检..." entry: "skills/system_health_check" version: "1.2.0" enabled: true timeout_ms: 10000加载器启动的时候会遍历所有启用的技能项,动态import对应的执行模块,然后把这个清单传给模型作为可调用工具的上下文。这样每次新增技能只需要三步:写执行模块、加一行清单声明、重启加载。不需要改任何一行核心调度代码。
3.2 一次技能调用的完整生命周期
我用一段伪代码来说明Agent调用技能的前后链路,这决定了你的系统边界放在哪:
async def handle_agent_turn(user_message, session): # 1. 组装带技能清单的上下文 messages = build_messages(session, skills_catalog) # 2. 模型决策,可能返回工具调用请求 response = await llm.chat(messages, tools=skills_catalog) # 3. 如果没有工具调用,直接返回给用户 if not response.tool_calls: return response.content # 4. 有了技能调用,逐个执行 for call in response.tool_calls: skill = skills_registry.get(call.name) # 执行前:鉴权、参数校验、限流 validate_permission(session.user, skill.id) validated_args = validate_args(call.arguments, skill.schema) # 执行中:带超时熔断 result = await asyncio.wait_for( skill.execute(validated_args), timeout=skill.timeout_ms ) # 执行后:记录审计日志,把结果回填给上下文 session.add_tool_result(call.id, result) # 5. 带着执行结果再做一轮模型推理,生成最终回复 final_reply = await llm.chat(session.messages) return final_reply这段流程最核心的思想是:技能执行结果必须回灌到对话上下文里,让模型基于结果做下一步决策。很多第一次做Agent的同学在这里会犯错,执行完工具直接把原始返回丢给用户,不走第二次模型推理,结果用户看到一个巨大的JSON堆在屏幕上,那体验非常糟糕。
3.3 状态与上下文:多步技能执行的粘合剂
Agent真实业务场景里极少只调一个技能,更多是“查主机状态→发现磁盘满→找到大文件→清理临时目录→再确认状态”这种多步链路。
多步链路的难点在于状态的衔接。每一个技能执行的结果都是下一步技能的输入前提,但模型在长链路推理中容易出现“记忆漂移”,尤其是在上下文变长之后,它可能会忘记之前查到的IP地址。
我目前在用且效果不错的方法是:在做技能结果回灌时,额外注入一个“事实卡片”区域——把本轮对话中已经确认的关键事实,比如目标主机IP、项目代号、当前状态,以key-value列表独立存放在上下文尾部。这样即使对话历史很长,核心事实也一直在模型可见范围内。这相当于给Agent配了一个外部备忘录,比单纯堆积聊天记录要可靠得多。
4. 从零落地一套技能:以“系统巡检Agent”为例
4.1 需求拆解:不要一上来就写代码
技能开发的第一件事不是写代码,而是把需求拆成技能粒度。我把这个环节叫“技能切分”。
比如“系统巡检Agent”这个需求,如果做成一个巨大的全能技能,参数可能有十几个,逻辑几百行,模型很难正确填参,改动任何一个子模块都得整体回归,非常痛苦。正确的做法是按业务动作切分成多个小技能:
| 技能ID | 技能名称 | 对应动作 |
|---|---|---|
| system.host_list | 查询主机清单 | 列出已登记的主机分组 |
| system.health_check | 健康巡检 | 获取CPU/内存/磁盘/服务状态 |
| system.disk_top | 磁盘排行 | 查看占空间最大的文件列表 |
| system.clean_temp | 清理临时文件 | 按规则清理指定目录 |
| notify.send | 发送通知 | 把结果推送到群/邮箱 |
一个技能只干一件事,参数尽量少于4个,这是我自己做技能设计的一条铁律。动作越小,模型调用越准,调试越容易。
4.2 技能文件设计与实现:脚手架这样搭
我用system.health_check这个技能来演示具体实现。
技能定义文件skill.yaml:
id: "system.health_check" name: "system_health_check" description: "对指定主机执行健康巡检,返回CPU、内存、磁盘使用率及核心服务状态。用户提到服务器卡顿、CPU高、磁盘满、服务异常时使用。" version: "1.2.0" timeout_ms: 15000 parameters: type: object properties: host_id: type: string description: "主机ID,必须来自system.host_list返回的列表" enum_source: "system.host_list" check_items: type: array items: type: string enum: ["cpu", "memory", "disk", "service"] description: "需要检查的项目,默认全部检查" required: ["host_id"] returns: type: object properties: status: { type: string } data: type: object properties: cpu_usage: { type: number } mem_usage: { type: number } disk_usage: { type: number } services: type: array items: { type: string } message: { type: string }执行模块简化版system_health_check.py:
import psutil def execute(host_id: str, check_items=None): # 真实场景这里会走远端agent/SSH通道, # 本地演示直接读本机监控数据 data = {} if not check_items or "cpu" in check_items: data["cpu_usage"] = psutil.cpu_percent(interval=1) if not check_items or "memory" in check_items: data["mem_usage"] = psutil.virtual_memory().percent if not check_items or "disk" in check_items: data["disk_usage"] = psutil.disk_usage("/").percent if not check_items or "service" in check_items: data["services"] = check_all_services() ok = all(v < 90 for k, v in data.items() if isinstance(v, (int, float))) return { "status": "success" if ok else "warning", "data": data, "message": f"主机{host_id}健康检查完成,CPU {data.get('cpu_usage')}%," f"内存 {data.get('mem_usage')}%,磁盘 {data.get('disk_usage')}%" }这个例子里有个细节值得注意:check_items参数在Schema中是可选的,但真实环境里我会在代码里先做一次筛选确认,即便没有check_items,也会默认返回CPU/内存/磁盘/服务四个核心指标,防止字段缺失导致模型拿到不完整数据后瞎猜。
4.3 联调与回归测试:聪明人在发布前做的事
技能开发完不是扔给Agent就完事了,必须做联调和回归测试。我的联调流程分三层。
第一层是执行模块单元测试,直接用真实参数调用execute(),确认返回JSON结构完整,值合理。这一层最基础,变量错误在这一层暴露得最快。
第二层是用例集测试。我维护了一个test_cases.json文件,里面放了针对每个技能的20-40个典型用户输入,包括正向场景、边界场景和故意刁难的场景。比如对system.health_check,至少会有这些输入:“帮我查一下生产环境的机器状态”“api-gateway那台服务器是不是卡了”“查一下不存在的主机ID”等等。跑一遍用例集,看Agent是否能在正确的时机调用正确的技能、传正确的参数。
第三层是回归脚本自动化。到了后面技能数量变多,手动跑用例太慢,我建议直接写脚本把模型输出和工具调用序列全部记录下来,与上一个版本的基线对比。有时候改动了一个技能的描述,可能导致另外两个相似技能的选型准确率变化,这种“牵一发动全身”的影响只能靠自动化回归才能发现。
5. 常见问题与排查技巧:我在实战中踩过的坑
5.1 模型就是不肯调用技能,怎么办
这是频率最高的问题。模型明明看到了技能清单,却非要自己编一个答案,或者跟用户说“我无法访问外部信息”。排查思路很固定,按优先级检查三件事。
第一,技能描述是不是太“代码化了”。如果你把描述写成“此函数用于获取CPU利用率并返回浮点数”,模型就很难把它跟用户的真实意图关联起来。改成“当用户问到机器卡不卡、资源占用高不高时使用”立马不一样。
第二,技能清单是不是太长了。模型可用工具数量在30个以上时,选型准确率会快速下降。这时候要做技能分组,把相似功能收拢进一个“子路由技能”,让模型先选大类,再根据子路由返回的细分清单决定具体动作。
第三,检查示例是不是缺失。我在Prompt里总会给模型至少1-2条完整的“用户问题→技能调用→结果反馈→最终答案”链路的示例。对强逻辑模型来说,一个清晰的示例比十行规则描述都管用。
5.2 技能参数幻觉和误传,如何从流程上堵漏
模型传参经常“自作主张”,把用户没说过的信息补进去。比如用户只说“看一下那台机器”,模型可能会从历史消息里挖一个host_id填上,或者干脆编一个看上去很像的ID。
从流程上堵漏需要做两件事。第一,在Schema层面尽量用enum或const把可选值锁死,无法枚举的值就写清楚“必须从某技能返回列表中选取”,并且这个参数设为必填。第二,在技能执行端加“前置校验钩子”,发现参数有可疑的地方——格式不对、列表中没有、超出正常范围——立刻返回参数错误,绝不往下执行。
这一步看起来笨重,但能在生产环境里挡掉大量因为幻觉引发的脏数据。我做过的服务重启Agent上线后,因为加了“必须先查询再重启”的强制前置,异常重启事故直接从每周3次降到0。
5.3 技能调用的性能与稳定性控制
最后提一下性能和稳定性,核心就两个字:控制。
控制超时:每个技能必须有独立的超时时间,不能让一个慢查询把整个Agent响应拖死。我的经验值是查询类技能10-15秒,变更类技能30-60秒。
控制并发:Agent常常会一次性发起多个技能调用,如果不限制并发数,可能瞬间对下游系统造成压力。我通常设一个全局并发信号量,最大同时执行2-3个技能。
控制重试:技能失败后,要让Agent基于错误信息判断要不要重试,而不是无脑重试。我的规则是:只对超时和网络错误自动重试一次,参数错误和业务规则错误直接停止。
稳定性的终极保障是审计日志。每一次技能调用,谁触发的、传了什么参数、返回了什么结果、耗时多少、成功与否,全部记录。有了这份日志,任何线上问题都能在5分钟内定位到具体链路,而不是靠猜。
写在最后:技能是Agent的肌肉记忆
我自己在维护这套技能体系时最深的体会是:写代码不难,难的是持续保持技能库的“肌肉记忆”。今天加一个技能,明天调一句描述,都可能打破Agent原有的路径依赖。所以我现在养成了一个习惯——每次改动技能定义之后,都会把全量用例回归跑一遍,然后把新旧两版的调用序列对比着看一遍。
对于那些刚起步的团队,我还有一个很朴素的建议:先把三个核心技能打磨到极致,好过铺开二十个半吊子技能。一个技能能稳定解决一类问题,Agent就已经能扛下很大一部分重复性的、标准化的工作了。等跑通了从定义、注册、编排到回归测试的这条链路,剩下的技能扩充就是体力活。希望你也能在这条路上少走一些我走过的弯路。