
这几天在整理 agent-skills 的落地代码时总想起半年前那次线上演示一个看起来很聪明的 AI 助手硬是在会议室预定这一步卡了十分钟。原因很简单——它不知道“会议室”在系统里的真实状态没有可用的技能去查询。后来我把整套能力拆成标准化的 agent-skills问题才真正解决。所谓 agent-skills就是给大模型驱动的代理预定义的一套可复用、可组合的能力模块每个模块都有明确的输入输出约定和可执行逻辑。很多人刚接触这个概念时容易觉得是换汤不换药无非就是“工具调用”换了个说法。但实际用下来你会发现它和随手写几个 function 给模型调用完全不是一回事技能必须有清晰的契约、独立的生命周期、可观测的执行结果还要能在一套统一规则下被代理调度和组合。这篇文章不聊概念包装直接讲清楚我如何设计、实现、调试这套技能体系以及这半年踩过的坑。读完你至少能知道一个可以真正在业务里跑起来的 agent-skills 项目长什么样为什么参数契约是命门以及代理反复调用失败时该怎么排查。1. Agent技能化为什么必须重新看待Agent的能力边界1.1 从“会说话”到“会办事”的差距单纯的大模型应用本质上是一个“对话文本进、对话文本出”的系统。你问它一个周末 schedule 的建议它能给得头头是道但如果让它直接查一下某个会议室在明天下午 3 点是否空闲它就无能为力了因为它没有权限也没有办法去访问你的会议室管理系统。传统做法是“提示词里塞数据”把会议室列表贴在上下文里让模型做选择。这在数据量小、状态变化不频繁时勉强可行但一旦系统数据是动态的比如会议室状态实时变化、参会人日程在多人之间同步这种笨办法立刻失控。Agent-skills 的核心思路恰好反过来让代理手里握着一张能力清单遇到问题时先决定调用哪个技能再由技能完成真实世界的读取或操作最后把结果喂回给模型的上下文。这种“感知-决策-执行”的分层把模型从“什么都得自己算”的压力里解放出来。模型只需要负责任务拆分、判断该做什么、解释结果脏活累活全交给背后的技能函数。说句实在话没有技能化的 Agent 更像一个嘴强王者的顾问有了 agent-skills 之后才像一个能开工干活的项目专员。1.2 技能化收益和代价必须一起看我见过不少人第一次搭完技能模块时特别兴奋觉得万事大吉。但我要泼一盆冷水技能化不是免费的它有明确的收益和隐藏的维护成本。收益侧很直观可复用性同一个“查询空闲会议室”技能今天用在会议助手明天可以挂到行政答复机器人上函数不变登记表不变。可调试性当代理动作出错时你可以直接看技能日志知道它调了哪个接口、传了什么参数、返回了什么结果而不是对着黑盒模型猜。可控制性权限、限流、审计都可以在技能执行层统一做你不需要指望模型自觉。代价侧容易被低估接口设计成本技能越多参数契约之间的冲突也越多维护这类注册表的复杂度会非线性增长。上下文占用成本每个技能描述都要写进 prompt 或函数列表里技能数量太多模型反而会“选择困难”甚至漏掉正确选项。所以真正靠谱的做法不是“把所有功能都变成技能”而是只隔离那些边界清晰、可独立验证、有稳定输入输出的操作。凡是需要长链路推理才能得出的结论应该继续交给模型思考而不是强行拆成一个技能。这是我第一个要强调的取舍原则技能是给代理减少不确定性不是给代理增加负担。2. agent-skills 的核心设计细节与接口约定2.1 技能的本质输入输出契约 可执行逻辑一个标准的 agent-skills 模块拆开来看就三块技能描述、参数 Schema、执行函数。这三者缺一不可。技能描述是给模型看的说明书。模型拿到这份文本后需要理解“这个技能是干嘛的、什么时候该调用、调用后能获得什么”。描述写得太含糊模型要么不敢调要么在不该调的时候乱调。描述写得太啰嗦又会占用上下文空间还会冲淡其他技能的位置。参数 Schema 是给校验器看的契约。它定义了每个参数的名称、类型、是否必填、取值范围。这个看起来像数据结构的玩意儿其实是整个技能体系里最容易翻车的地方。我后面会用具体案例讲这里先记住一条Schema 的每个字段都要精确到“如果用户没说怎么办”的程度。执行函数是真正干活的代码。它负责访问数据库、调用第三方 API、完成业务操作然后把结果整理成模型能读的文本或结构化数据返回。执行函数里需要处理异常、超时、限流不能直接把底层报错原样抛给模型否则模型会一脸茫然。2.2 参数设计别让“可选参数”变成灾难参数设计是我踩坑最多的地方。以“查询空闲会议室”为例你很容易写出一个包含日期、开始时间、结束时间、会议室编号、参会人数、是否投屏、楼层等十几个字段的 Schema。但你要明白身后的模型不是按照你的业务文档来填参数的它是靠语言描述来猜的。字段一多它就会开始创造根本不存在的参数值比如把日期写成“明天”把会议室编号写成字符串而不是 ID甚至把开始时间和结束时间倒过来。我的建议是每个技能的参数数量尽量控制在 3-5 个再多就要考虑拆分技能。比如“查询会议室”和“一键预定会议室”分开“预定”技能里可以用“上一查询返回的 room_id 列表”做快捷选择不必让模型每次都重新报一遍所有属性。所有可选参数都要给出明确的默认行为。假如用户说“查一下明天下午有空的白板教室”模型如果不知道“空”需要多久你怎么判断用 60 分钟还是 30 分钟作为最低空闲时长所以可选参数必须有 default并且描述里明确指出“如果用户没指定时长默认按 60 分钟查询空闲时段”。枚举值要写清楚。比如会议室类型这个参数应该明确传“TRAINING_ROOM”“MEETING_ROOM”而不是指望模型输入“培训室”“会议室”的中文变体。你可以在描述里给一个“合法值列表”或者在执行层做一遍归一化映射。注意参数校验放在执行函数入口做绝不要依赖模型一定听话。模型给出的原始参数必须经过一次严格的类型转换和范围检查才能进入后端逻辑。2.3 技能注册表与动态加载接下来聊落地结构。我见过各种五花八门的实现有人在主程序里写 if-else有人把技能列表写进 JSON 配置文件有人用装饰器自动注册。从维护性角度看我强烈建议做一个集中式的技能注册表让每个技能是一个独立文件通过装饰器或声明式配置自动注册。一个简单的 Python 版注册表长这样import inspect from dataclasses import dataclass from typing import Callable, Any dataclass class SkillSpec: name: str description: str parameters: dict function: Callable SKILL_REGISTRY: dict[str, SkillSpec] {} def register_skill(name: str, description: str, parameters: dict): def decorator(func: Callable) - Callable: SKILL_REGISTRY[name] SkillSpec( namename, descriptiondescription, parametersparameters, functionfunc, ) return func return decorator register_skill( nameget_free_slots, description查询指定日期和时长内的空闲会议室时段。, parameters{ type: object, properties: { date: { type: string, description: 要查询的日期格式YYYY-MM-DD例如2026-06-02 }, duration_minutes: { type: integer, description: 空闲时长下限单位分钟默认60, default: 60 }, room_id: { type: string, description: 会议室编号可选为空时返回所有会议室空闲情况 } }, required: [date] } ) def get_free_slots(date: str, duration_minutes: int 60, room_id: str None): # 这里写真实查询逻辑 return [ {room_id: A201, start_time: 2026-06-02T10:00:00, end_time: 2026-06-02T11:00:00} ]这个结构的好处是新技能只需新增一个文件写上函数和注册装饰器再 import 一次就能被统一发现。技能列表自动变成注册表的 keys模型调用时直接按名字索引。后续你还可以从数据库或配置文件动态加载注册表让运维人员在不发版的情况下临时启用或禁用某个技能。3. 从零实现一个可用的技能系统3.1 场景设定会议安排助手的技能清单为了把抽象概念落到地上我们用一个具体的例子来贯穿做一个会议安排助手代理要能根据一句话完成“查空闲时段-建会议-发通知”整个流程。这个场景我大概设计了五个技能。每个技能都满足“边界清晰、可独立验证、参数少”的原则get_free_slots查询指定日期、时长、可选会议室下的空闲时段。create_meeting根据 title、start_time、room_id、attendees 创建会议。get_attendee_schedule查询某位参会人的空闲遮挡时间。cancel_meeting取消指定 meeting_id 的会议。notify_attendees给参会人发送会议通知。按前面说的原则我没有把“判断会议是否与参会人冲突”做成技能。这个判断需要跨多个日历数据源做逻辑推理既涉及 get_free_slots又涉及 get_attendee_schedule适合让模型调用前者拿到结果后自己推理而不是再套一层函数。等模型输出“发现参会人在 10:00-10:30 有会”时下一轮再调技能做时间避让反而更灵活。3.2 技能字段的完整定义示例以 create_meeting 为例它的 Schema 我最终定义成这样{ name: create_meeting, description: 在指定会议室创建一个会议并返回包含meeting_id的完整会议信息。会议室必须在get_free_slots的有效会话内。, parameters: { type: object, properties: { title: { type: string, description: 会议主题建议不超过100字 }, start_time: { type: string, description: 会议开始时间ISO8601格式例如2026-06-02T14:00:00, format: date-time }, duration_minutes: { type: integer, description: 会议时长单位分钟默认60, default: 60 }, room_id: { type: string, description: 会议室编号必须来自get_free_slots返回结果 }, attendees: { type: array, items: {type: string}, description: 参会人邮箱列表至少1人 } }, required: [title, start_time, room_id, attendees] }, returns: { type: object, properties: { meeting_id: {type: string}, status: {type: string, enum: [CONFIRMED, PENDING]} } } }这个 Schema 里可能让你皱眉的是 duration_minutes 居然在参数里但又不属于 required。我特意这么设计因为参会人可能会说“订下午两点的会开一个小时”声明default 是为了让模型在用户没给出时长时不用瞎编同时它也能把这个值填进调用参数里。required 里不包含它相当于告诉模型“你可以不传后台记 60 分钟就行”。这样用户意图和系统默认值就能统一映射。执行层在拿到参数后还要再做一遍防御性校验。比如 start_time 不能是过去的时间duration_minutes 必须大于等于 15attendees 里不能有重复邮箱。这些校验结果不是“强制拒绝”就好而是要返回一个结构化的错误让模型能够根据错误调整参数后重新调用。def normalize_start_time(raw: str) - str: # 如果模型传了 2026-06-02 14:00就转成 ISO8601 return datetime.fromisoformat(raw.replace( , T)).isoformat()类似这样的归一化函数每个执行函数入口都应该放。你永远不知道模型会给你什么格式能在代码层兜底的就不要指望提示词。不过要注意归一化必须保守——如果你发现模型传的日期是“下周二”这种语义信息没法可靠转换应该直接返回“参数无效必须提供具体日期”而不是自己瞎猜导致订错会议。3.3 执行层函数签名、日志与超时控制执行函数本身没有太多花活但有几个工程细节必须处理好。第一统一日志。所有技能入口和出口都要打日志内容包含技能名、传参、返回摘要、耗时、异常信息。别小看这些日志后面排查“代理为什么反复调同一个错了三次的技能”时靠的就是它们。我通常用一个简单装饰器包一层import time import logging def skill_call_logger(func): def wrapper(*args, **kwargs): start time.time() logging.info([skill] %s called, args%s kwargs%s, func.__name__, args, kwargs) try: result func(*args, **kwargs) logging.info([skill] %s ok, result_count%s, cost%.2fms, func.__name__, len(result) * 1000 if result else result, (time.time() - start) * 1000) return result except Exception as exc: logging.error([skill] %s error%s, cost%.2fms, func.__name__, exc, (time.time() - start) * 1000) raise return wrapper第二超时控制。真实技能可能会调用外部 API一个慢接口就能让 Agent 的整个推理循环卡住。我建议执行层默认超时设为 10 秒超过就返回“技能执行超时”。有些业务操作比如取消会议不能盲目重试所以还要给每个技能单独配置“是否允许重试”。查询类技能一般允许重试写操作比如 create_meeting/cancel_meeting必须加幂等键。幂等键这点非常关键。如果模型觉得某个会议创建失败了又发起第二次调用第一次其实已经在后端创建成功了就会产生重复会议。解决办法是在参数里加一个 client_request_id由代理自己生成一个随机值后端数据库这个字段上建唯一索引。同一个 request_id 重复提交第二次直接返回第一次的会议信息。第三返回值要“适配模型而不只是适配业务”。业务接口通常返回的是完整字段列表但模型上下文的 token 是有限的。搜索会议室接口如果返回 50 个时段模型非但用不过来还容易淹没关键信息。我在每个技能返回前会做一个裁剪查询类技能默认返回 top 5 条结果并在结果里放一个 total_count 字段创建类技能则只返回 id、status、下一个可取消的截止时间等模型最需要的信息。剩余详情可以通过一个新的“查询会议详情”技能去取这样上下文始终被控制在一个合理范围。3.4 Agent接入提示词怎么写才不打架技能系统搭好了最后一步就是把注册表里的技能描述“喂”给 Agent。这里最常见的错误是把技能描述写成 API 文档。模型并不关心底层如何实现它只关心“这个技能能帮你干什么、什么条件下用、需要你提供什么信息”。我用现成技能表来做示例。在系统提示词里我会放这样一段结构化的说明你可以调用以下技能完成实际业务操作 - get_free_slots查询空闲会议室时段。请在任何“订会议室”动作前使用此技能。需要用户提供日期最好预留会议时长。 调用示例调用参数 {date: 2026-06-02, duration_minutes: 90} - create_meeting确认会议室和时段后创建会议。必须先从get_free_slots拿到room_id和时间段再调用。title、start_time、room_id、attendees都是必填。 调用示例如果用户说“明天下午2点用A201订个60分钟的会通知张三”参数为 {title: 日程, start_time: 2026-06-02T14:00:00, duration_minutes: 60, room_id: A201, attendees: [zhangsancorp.com]} 规则 1. 用户要求不明确时先追问不要直接调用。 2. 如果技能返回错误根据错误信息修正参数后重新调用。 3. 查询结果里total_count超过5条时只需向用户展示前5条并提示可缩小范围。这里有个很容易被忽略的细节技能说明的顺序一定要按照业务习惯来。比较复杂的 Agent 任务是有流水线顺序的比如“先查时间再创建会议”如果把 create_meeting 放在 get_free_slots 前面模型有可能跳过第一步直接用用户猜测的时间去建会议导致订错。我在实际项目里会把两行说明的先后顺序调成和正常业务流一致。这不算什么高深技术但对模型决策质量影响很大。4. 调试与排查实录这些坑我真的反复踩过4.1 模型总是漏传参数或者传错格式这是 agent-skills 系统上线后第一周最让我头疼的问题。明明 Schema 里写了 date 是必填模型还是会在某些情况下只传一个 duration_minutes 就要求调接口明明描述里写了要 ISO8601 格式它还是会传“YYYY-MM-DD HH:mm”。排查思路分三步走看日志确认是不是模型真的只拿到了不完整的参数。很多时候是上游对话里用户没说日期模型又不敢追问于是硬着头皮少传参数。看 Schema 的 required 定义是不是被模型可见。如果你用 OpenAI 风格函数调用函数参数 Schema 本身就是传给模型的一般没问题但如果你是自己拼字符串塞进提示词就要检查 required 字段有没有被真正格式化进文本。在 execution 层做“友好缺失”处理。当模型漏传可选参数时用默认值补当漏传必填参数时不要直接抛异常而是返回一个结构化错误码让模型知道“date是必填请获取后再调用”。例如我设计了一个统一错误格式{error: MISSING_REQUIRED_PARAMETER, message: 参数 date 必填请在用户给出具体日期后再调用。, expected: {date: YYYY-MM-DD}}模型看到这种错误后通常会向用户追问日期而不是陷入死循环。你会发现把“错误信息写成给模型的提示而不只是给开发者的报错”这里的调试效率能提升一个量级。4.2 技能返回内容太长把上下文塞爆会议室查询返回 80 条空闲时段、参会人日程返回一长串时间块这种时候模型不是更聪明而是更晕。我后来做了一个“返回压缩层”。查询类技能统一遵循这样的返回模板{ total_count: 80, summary: 次日09:00-18:00有空闲时段其中下午14:00-15:00可用会议室最多(5个), items: [ {room_id: A201, start_time: 10:00, end_time: 11:00}, {room_id: A202, start_time: 10:00, end_time: 11:00} ] }summary 字段由代码生成直接给模型一个压缩后的“结论”items 只放前 5 条。如果模型需要更多明细它可以引导用户“选择更窄的时间段”或调用一个分页参数。这个压缩层让整个 Agent 的推理次数明显减少也不会因为上下文超长而报错。4.3 写操作失败之后怎么防止重复提交会议创建、订单提交这类写操作一旦发生超时代理很容易重试。如果后端没有幂等保护重试就会产生脏数据。我的具体做法是在技能入口加一个“幂等键”参数让模型在调用写操作前自行生成一个 UUID。如果模型没有生成执行层也可以在前面加一个中间步骤让它声明一个“请求编号”。这个编号在后端数据库建唯一索引。第一次请求正常返回结果第二次请求携带同一个幂等键时后端直接返回第一次的结果而不是再插一条记录。另外写操作的日志要加“重试标记”。排查时一旦发现同一个人对同一个 meeting_id 发起第二次取消你就知道是模型哪一轮决策出了问题而不是用户点了两次按钮。4.4 回归测试清单一个技能库必须常备的用例最后分享一个实战中沉淀下来的测试清单每次新增或修改技能我都会按这几类跑一遍测试类别典型输入期望结果正常调用“查一下明天下午2点到4点A201会议室有没有空”返回对应时段模型成功调用 get_free_slots缺参补全“帮我订个会半小时后”模型追问主题或参会人不直接调用 create_meeting格式归一“2026-6-2 14:00”执行层转换为 ISO8601会议建立成功越界值duration_minutes1440返回 RANGE_ERROR 或自动封顶为 240错误恢复调用 create_meeting 返回会议室占用冲突模型根据错误提示改期或换会议室而不是放弃超时重试外部接口 5 秒未响应触发超时错误查询类技能可重试写操作不可盲目重试上下文长度查询返回 80 条时段返回压缩摘要top5不爆上下文幂等同一 client_request_id 连续提交两次第二次返回第一次结果不产生重复数据这套清单不需要自动化程度很高可以先人工按脚本跑但每次上线新技能之前必须过一遍。你会发现真实环境的坑往往不是模型不会选技能而是执行层的边界条件没有兜住。我个人在实际操作中的最后一条体会是agent-skills 的能力边界一定要比想象中更保守。很多看起来“顺手”的功能比如自动判断会议是否冲突、自动汇总日程应该交给模型在已有技能返回结果之上做推理而不是把业务规则藏进技能代码里。因为技能的职责是提供事实和完成动作而不是替模型思考。边界划得越清晰这套系统就越容易维护排查问题时也越快。