
先说个我最近的真实经历。团队里有个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( namename, descriptiondescription, parametersparameters, executorfunc ) 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( namesales.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, toolstools) 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_asciiFalse) }) 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这套东西真正的难点从来不是写代码而是后续一轮一轮地打磨触发边界。别指望一次设计到位把它当成一个持续维护的产品你会省掉很多半夜上线的痛苦。