你有没有遇到过这种情况:大模型聊起天来头头是道,一旦让它去执行具体任务——查库存、算排期、批量发消息——就开始自由发挥,要么步骤错乱,要么停顿在那假装自己做不到。我们团队去年做某跨平台系统的时候,被这个问题折腾了将近两个月。后来我们从零搭了一套叫 agent-skills 的机制,核心思路就一句话:把智能体要干的活,拆成有标准接口、有生命周期、可独立评估的一组技能,让模型只在“决定下一步做什么”这件事上做判断,具体动作全部收敛到技能层。这篇文章就是这套机制从设计、落地到踩坑的完整复盘。
如果你也在做智能体类项目,正在纠结“技能到底怎么定义”“多个技能怎么编排”“调不稳怎么排查”,这篇内容可以直接借鉴。我会把技能描述规范、注册中心、调度链路、评估闭环、可观测性设计逐步拆开讲,里面包括可以直接抄走的 JSON 结构、伪代码、参数建议和故障处理手册,偏实战。
1. 项目拆解:agent 技能体系到底在解决什么问题
1.1 为什么通用大模型搞不定复杂任务
先说一个很现实的场景。你让模型“帮我把本周的销售数据整理成周报,然后发给相关同事”。模型本身确实理解这句话,也能生成一段像模像样的 Markdown 周报。但只要涉及真实系统操作,问题就接踵而来:
- 它怎么知道销售数据存在哪个数据库、哪张表、用什么字段名?
- 它怎么调用内部的报表服务?
- 它怎么拿到同事的通讯录信息?
- 它怎么确认邮件发送成功而不是“假装发送成功”?
通用大模型擅长的是语义理解和文本生成,它并不天生具备操作外部系统的能力。这就是智能体框架存在的价值:给模型装上手和脚。可问题在于,很多框架把“手和脚”做得太碎了——工具函数上百个,每个都是裸函数,没有版本、没有超时、没有权限边界。模型在这么多选择面前很容易挑错,业务方也根本没法追踪一次任务到底调了哪些工具、每个工具干了什么。
我们做 agent-skills 的出发点,就是不想再维护一堆裸函数,而是把每个能力都封装成标准化的“技能单元”。技能单元要具备描述性元数据、输入输出校验、生命周期管理、可观测性注入、失败重试策略,这样模型和平台都有了一套统一的交互协议。
1.2 技能化思维:从“会对话”到“会干活”
对话能力解决的是“说什么”,技能体系解决的是“做什么”。这两件事不能混在一起。
在 agent-skills 的设计里,一个技能代表一个可完成的最小业务闭环。注意“最小”不是指代码量少,而是语义上的原子性。比如“发送邮件”是一个技能,“生成周报并发送邮件”就不应该是一个技能,而是两个技能的组合,因为生成和发送的失败场景、权限要求、评估指标完全不同。
一个技能必须满足以下条件:
- 输入输出可由 JSON Schema 描述;
- 具备明确触发条件和成功判定标准;
- 可以独立运行、独立测试、独立计费;
- 有描述性说明,可供模型理解何时该选用;
- 有显式超时、重试、降级策略。
模型面对的不再是几百个函数签名,而是几十个语义清晰的技能说明。选技能的准确性会明显提升,这是我们在试过两种形态后都认可的结论。
1.3 技能与工具、工作流、插件的边界怎么划
这三个概念经常混在一起,但边界不划清楚,系统就会腐烂。我们内部的划分是这样的:
| 概念 | 粒度 | 典型特征 | 是否独立部署 |
|---|---|---|---|
| 工具 | 最小函数操作 | 无状态、单一动作,例如读文件、查时间 | 否 |
| 技能 | 业务原子闭环 | 有输入输出校验、超时重试、权限边界 | 是 |
| 工作流 | 多技能的组合 | 有明确状态流转、分支判断 | 是 |
| 插件 | 能力的打包分发 | 内含多个工具/技能,可安装卸载 | 视情况 |
工具是技能的内部实现细节,技能对外暴露的是标准协议。工作流负责编排技能,自身不实现具体业务逻辑。插件是技能的分发载体。这样一个四层结构让职责非常清晰:模型不直接调工具,只选技能;技能内部可以自行决定用哪些工具完成动作。
2. 系统架构设计与技能模型定义
2.1 技能描述规范:让智能体看懂技能说明书
技能描述是模型做决策的依据,描述写得好不好,直接影响选错率。我们把技能描述拆成两部分:面向机器的 schema 和面向模型要语义描述。
面向机器的 schema 长这样:
{ "name": "send_email", "version": "2.3.0", "description": "Send an email to an internal or external recipient. Use this when the user asks to contact someone via email.", "tags": ["email", "notify"], "owner": "platform-team", "invocation": "http://skill-registry/skills/send_email/2.3.0/invoke", "timeout_ms": 15000, "retry_policy": { "max_attempts": 2, "backoff_ms": 500 }, "permission_scope": "normal", "requires_confirmation": true, "input_schema": { "type": "object", "properties": { "to": {"type": "string", "description": "Recipient email address"}, "subject": {"type": "string", "description": "Email subject"}, "content_markdown": {"type": "string", "description": "Email body in markdown"} }, "required": ["to", "subject", "content_markdown"] }, "output_schema": { "type": "object", "properties": { "message_id": {"type": "string"}, "status": {"type": "string", "enum": ["sent", "queued"]} }, "required": ["status"] } }这个 schema 有几个细节容易被忽略,但实际非常重要:
version每次发布必须递增或者保证向前兼容,否则线上模型可能读到旧描述去调新接口。description不要写功能说明,要写“什么时候该用”。例如"use this when the user asks to..."这种句式,模型更容易命中。timeout_ms不是随便填的。我们的经验值:外部 API 调用给 10-15 秒,内部数据库查询给 5-8 秒,纯计算给 2-3 秒。太短容易误杀,太长会卡住整个调度队列。requires_confirmation是最重要的安全开关之一,凡是发送消息、删除数据、修改权限这类不可逆操作,必须置为 true。调度器遇到这个字段,会先暂停并向用户侧发送确认请求。
2.2 注册中心与调度链路:万事皆可注册,注册就要可控
所有技能统一注册到一个中心服务,注册信息写到数据库并同步到内存缓存。模型在对话开始时通过一个/list-skills接口获取当前用户可见的所有技能描述,这个过程很快,但我们加了一层索引缓存,避免每一次对话都全量拉取。
调度链路分这几步:
- 用户请求进入 Agent 主进程,构建上下文窗口;
- 主进程根据用户的 prompt,从注册中心拉取候选技能描述列表;
- 模型输出技能选择结果,格式必须是结构化的 JSON,例如
{"intent": "send_email", "arguments": {...}}; - 调度器校验 JSON,检查技能状态、版本、权限、是否需确认;
- 执行技能调用,记录调用链 trace_id;
- 技能结果返回到上下文,模型继续生成最终回复。
伪代码大致是:
def agent_task(user_request: str, user_context: dict) -> str: skills = registry.list_visible_skills(user_context) plan = llm_plan(user_request, skills) result_bag = {} for step in plan.steps: if step.type == "skill": skill = registry.get(step.skill_name, step.version) if skill.requires_confirmation: confirmed = request_user_confirmation(step.arguments) if not confirmed: return "已取消操作" trace_id = observability.start_trace(step.skill_name) try: output = skill.invoke(step.arguments, timeout=skill.timeout_ms) result_bag[step.id] = {"status": "ok", "output": output} except TimeoutError: output = retry_with_backoff(skill, step.arguments) result_bag[step.id] = {"status": "retried", "output": output} finally: observability.end_trace(trace_id) elif step.type == "branch": pass # 分支判断逻辑 return llm_final_answer(result_bag, user_request)有一种常见的实现误区是让模型自由输出“用什么工具+参数”,然后靠 prompt 强行约束 JSON 格式。我们早期也这么干,结果三个问题反复出现:字段名拼错、参数类型传错、把枚举值写成自由文本。后来改成让模型只输出技能编号,参数通过再一轮“参数填写”提示来生成,虽然多一次模型调用,但成功率显著提升。
2.3 上下文边界与权限隔离:技能不是插件,不能任意共享状态
这是我们在设计中被低估的一环。最初我们把技能做成类似插件的形态,一个技能执行后可以直接改全局的上下文变量,方便下一个技能读取。结果就是技能之间隐式耦合,排查问题基本靠猜。
后来改成严格的上下文隔离:
- 每个技能只接收显式传入的参数,不能访问 Agent 全局上下文;
- 技能返回值必须符合 output_schema,任何多余字段都会被丢弃;
- 技能如果需要跨步骤共享数据,必须通过“工作流状态对象”显式声明;
- 技能内部禁止访问外部网络(白名单域名除外)。
权限隔离也单独拿出来做了。每个技能有一个 permission_scope,枚举值包括read_only、normal、sensitive、admin。Agent 主进程拿到用户身份后,会计算该用户在某个技能上的权限矩阵。比如普通员工可以读数据(read_only),主管可以导出(normal),只有管理员可以删除(admin)。这一层如果不做,技能的复用面越广,风险就越高,因为某个团队的技能可能被另一个团队误用。
3. 技能编排实战:从单技能到多技能协同
3.1 技能拆解的颗粒度怎么定
这个问题的答案,直接决定整个系统的灵活性和维护成本。
拆得太粗,比如“处理订单”这种技能,内部代码会膨胀,没办法做局部优化,模型也没法灵活组合。拆得太细,比如“解析时间字符串”这种技能,模型选择路径增多,出错概率变大,而且很多技能实际上根本不会被单独调用。
我们实践下来有三个判断标准:
- 这个技能描述能否让模型在三个句子以内判断该不该选它?如果不能,说明边界不清。
- 这个技能是否可能被两个以上场景复用?如果只在一个场景出现,先不急着拆。
- 技能内部是否包含两个独立失败域?比如“发送通知”里既发邮件又发短信,只要有一类失败另一类也重试,与其拆开,不如合并成优先级策略。
举个例子。“会议室预订”我们拆了三个技能:query_room_availability、book_room、cancel_booking。查询和预订必须分开,因为查询是高频的、只读的,可以并行调用;预订是低频的、需要确认权限的。取消则是高风险操作,必须二次确认。如果硬合成一个“manage_meeting_room”,模型在“帮我看看明天下午两点有没有空房”这种查询请求下也要走到确认流程,体验会很差。
3.2 编排引擎如何按步骤调度
单技能好说,难的是多个技能按顺序协作。我们的编排引擎支持三种基础执行单元:
- 顺序执行:上一个技能输出中的字段作为下一个技能的输入参数;
- 条件分支:根据某一步骤的输出结果,走不同的技能路径;
- 并行执行:多个互不依赖的技能同时发起,等待全部返回。
为了不让模型自由编排太过奔放,我们引入了步骤模板的概念。系统预置了几个常用编排模板,“先查后写”“先校验后提交”“先分析再分发”。模型负责在模板里填充具体技能和参数,而不是从零设计完整流程图。这样做的目的是把不可控的自由度限制在一个自定义的范围内,同时保留灵活性。
编排定义文件我们用的是声明式 YAML,示例:
workflow: generate_and_send_report steps: - id: fetch_data skill: query_sales_data params: date_from: "$user.date_from" date_to: "$user.date_to" - id: generate_report skill: render_markdown_report params: data: "$steps.fetch_data.output.rows" - id: send_email skill: send_email params: to: "$user.manager_email" subject: "周报" content_markdown: "$steps.generate_report.output.markdown" requires_confirmation: true注意第 19 行的requires_confirmation: true不是技能上的属性,而是编排实例上的覆盖物。同一个send_email技能,在“自动发送系统告警”场景可能不需要确认,但在“替领导发周报”场景必须确认。所以确认逻辑放在编排层,而不是技能层,这就让技能本身的复用性更高了。
3.3 失败重试、降级与人工介入:异常路径必须显式设计
这是整个系统里最考验工程经验的地方,也是最容易偷懒的地方。写代码时,大家默认“技能一定会成功”,但真实情况是技能可能会超时、限流、返回脏数据、依赖的下游系统直接宕掉。
我们为每个技能标配了三种异常处理策略:
retry:针对瞬时故障,如网络抖动、目标服务 503、超时。建议最多重试 2 次,间隔 500ms-2s,重试次数多了反而会把下游打爆。fallback:针对可替代方案。比如send_email失败后,可以降级成send_dm或create_ticket,这个降级动作由工作流定义,不藏在技能内部。escalate:当重试和降级都失败,自动转入人工介入队列,并生成一条结构化错误信息,附上 trace_id、技能名、参数摘要。
建议所有会写入生产数据的技能都配escalate。一次自动任务失败如果没有人工兜底,数据坏了就只能回滚,代价比你想的大得多。我们有一段时间“自动清理缓存”技能静默失败,等到发现时磁盘已经满了,再后来就统一配置了人工介入队列。
4. 技能评估与持续迭代:上线只是开始
4.1 评估维度:完成率、准确率、稳定性、成本
技能上线后,怎么衡量它好不好?我们每周会生成一份技能运营看板,主要关注以下几项:
- 任务完成率:该技能在被选中的场景里,最终成功执行的占比;
- 参数准确率:模型为技能填写的参数,通过 JSON Schema 校验且语义正确的占比;
- 平均执行时长:从调度器开始调用到技能返回结果的耗时;
- 单位调用成本:包含模型决策 token 消耗、技能内部外部 API 费用、数据库查询开销;
- 确认率:需要二次确认的技能里,用户点击“允许”的占比。确认率突然掉下来,通常说明描述误导用户,或权限太激进。
这四个维度要一起看,不能只看完成率。我们见过一个“生成任务清单”技能,完成率 98%,但参数准确率不到 70%。模型经常把时间格式填错,技能还是强行执行了,输出结果驴唇不对马嘴。后来我们给 input_schema 增加了严格的datetime格式和示例,参数准确率才慢慢上来。
4.2 线上数据回流与技能版本管理
技能不是一成不变的。每次修改技能描述、参数约束、超时时间,都是一次版本变化。我们对技能的变更要求强制向上兼容,同一个技能大版本内不能删除字段,不能改字段类型,不能改枚举值。
数据回流方面,我们每个技能调用都会记录四份数据:入参快照、出参快照、模型选技能时的完整上下文、人工介入的处理结果。每周我们抽取 500 条样本做人工标注,看模型选技能是否正确、参数是否合理、返回值是否被正确消费。这些标注数据会成为下一轮 prompt 和 schema 优化的依据。
版本管理还有一个经验:永远不要直接覆盖线上版本。即使只改了一个词的描述,也应该发布新版本,先在 shadow 模式跑 24 小时,用线上流量模拟调用但不影响真实结果,确认描述改动不会造成选择率波动后再全量切换。这个流程一开始觉得麻烦,后来救了我们好几次。
4.3 技能衰退与淘汰机制:不是所有技能都要留着
没有哪套体系会越做越精简,通常都是技能越加越多,最后变成一锅粥。我们每季度做一次技能盘点,用三个问题来决定技能是否保留:
- 最近 90 天调用次数是否超过某阈值?低于阈值的划入“待观察”;
- 是否与其他技能功能重叠?重叠后保留描述更清晰的那个;
- 维护者是否有精力持续跟进?没人维护的技能直接下线。
淘汰机制很重要。我们线上的“查询天气”技能曾经是一个示例 Demo,后来团队里没人维护,模型又在很多对话里频繁选中它,导致一次简单查询要额外付出一次外部 API 调用。下线前我们在系统里把它标记为 deprecated,让模型在描述里明确看到“该技能不可用,别选我”,再逐步从注册中心移除。
5. 可观测性与调试实录
5.1 日志链路:技能调用的可追踪设计
技能调用涉及多个系统,如果没有追踪机制,排查问题就是大海捞针。我们每个技能请求都会分配一个全局 trace_id,从用户输入进入 Agent 开始一直透传到技能内部的外部 API 调用。日志索引必须包含技能名、版本、用户标识、trace_id、状态、耗时。
推荐用结构化日志,至少包含以下字段:
{ "timestamp": "2025-01-15T10:30:00Z", "trace_id": "abc123", "skill_name": "send_email", "version": "2.3.0", "user_id_hash": "f3a1", "status": "success", "latency_ms": 862, "retry_count": 0, "error": null }有一个细节值得提醒:日志里的用户标识必须做哈希脱敏,不要记录原始邮箱或手机号。合规风险和实际安全需求都不能忽视。我们曾经在日志里打过完整的客户邮箱,后来被安全团队要求全部清理。
5.2 典型的三个故障排查现场
我挑三个真实处理过的故障案例,它们在实际项目中非常有代表性。
第一个案例:技能超时引发连锁失败。某个业务线的技能batch_update_record底层依赖一个数据处理服务,平时运行 3-5 秒。某天上游数据量翻了 5 倍,这个技能执行了 40 秒都没返回,导致后面排队的 20 个任务全部超时。排查时发现技能配置的 timeout 是 10 秒,重试策略允许最多 4 次,结果同一份数据被下游重复写了 4 遍。后来我们把重试策略改成“POST 类请求禁止自动重试或改为跨任务去重”,超时后直接转人工。
第二个案例:模型幻觉导致技能参数错乱。用户说“帮我改一下明天的日程”,模型选择了update_calendar_event技能,但把日期参数填成了去年的某一天。因为那一天的日历上是空白的,技能返回“事件不存在”,模型为了圆场直接回复用户“日程已更新”,用户差点误了会议。这个问题靠两层解决:一是技能在执行前会检查数据是否存在,不存在则返回明确错误码;二是提示模板里强约束模型,没有确认实际改动前不能宣告成功。
第三个案例:上下文过长导致技能描述被截断。有一段时间我们用的模型输入窗口受限,用户会话内容一长,技能描述列表就被截掉了。结果模型看不到候选技能,直接用自己的记忆编造了一个根本不存在的技能名。排查时是从错误日志里的“unknown_skill”字段发现的。后来,我们把技能列表从“跟随全量对话上下文”改为“基于意图召回”,只保留与当前任务可能相关的 3-5 个技能描述,这个问题没有再出现过。
5.3 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 模型选错技能 | 技能描述语义太泛或相似技能太多 | 重写 description,增加使用场景示例,必要时合并相似技能 |
| 参数频繁校验失败 | input_schema 约束过严或描述与 schema 不一致 | 增加示例值,放宽枚举约束,或提示模板里附参数模板 |
| 技能执行超时 | 超时配置太短或下游服务变慢 | 先看 trace 检查耗时分布,再决定调 timeout 还是修下游 |
| 用户拒绝二次确认 | 确认弹窗出现频率过高或文案误导 | 降低敏感度分级,同时优化确认文案,讲清楚操作后果 |
| 技能列表加载慢 | 注册中心缓存失效或技能元数据过大 | 启用本地缓存,按需拉取,避免全量返回描述 |
| 某个技能调用量骤降 | 描述被截断、版本标记 deprecated 或排位被挤掉 | 检查对话上下文长度和技能召回策略 |
| 重试后数据重复写入 | 重试策略没有区分写操作和读操作 | 写操作禁用自动重试,或用幂等键去重 |
这些场景里,最有价值的是最后一个:重试造成的重复写。如果你现在维护的技能系统允许自动重试写操作,建议尽快处理,别等到被生产事故教育时才记得。
写在最后的体会
agent-skills 这套东西不是某个灵光一现的架构,而是一路被在线问题逼出来的产物。它让我最深刻的感受是:智能体的上限不取决于模型有多聪明,而取决于你给它搭的能走多稳,技能体系决定了模型的下限。技能不是简单包一层接口就能叫技能,必须有标准协议、生命周期管理、权限隔离、可观测性。只要你守住这些约束,模型就能在有限边界里做出最优选择。
最后再分享一个小技巧:技能描述里,把“什么时候不要用”也写上去。比如send_email可以加一句“Do not use if user wants to send a text message”。有了负向示例,模型选错的概率会明显下降,这个操作成本极低,但效果往往比改几十次 prompt 都明显。你可以在自己的项目里先试一版,看看选技能准确率的变化。