
最近我把一个内部Agent项目重构成了以技能Skill为核心的架构项目代号就叫agent-skills。说实话这个项目做的不是什么花哨的事就是把Agent能够执行的每一项底层能力从散落的工具函数里捞出来封装成一张张带说明、带参数协议、带约束条件的能力卡片再由模型在合适的时机按需加载和调用。如果你正在做一个会调用工具、插件的Agent应用或者在设计自研Agent框架时被“工具越来越多、描述越来越乱、模型不知道选哪个”这个问题卡住这篇内容会比较对胃口。下面全是这次重构过程中沉淀下来的思路、代码和踩坑记录。1. 为什么Agent需要一个独立的“技能层”而不是一堆函数1.1 从插件到技能抽象层级到底差在哪很多Agent项目一开始都是从“函数调用”起步的。你给模型几十个函数每个函数配上名字和描述模型输出一个JSON结构说我要调哪个函数、传什么参数系统去执行再把结果塞回上下文。这个模式在工具数量少的时候很好使我早期就是这么干的。但等到函数数量超过二三十个的时候问题开始压不住了命名空间越来越乱一个函数叫什么、参数叫什么都靠人肉约定同一个能力在两个模块里各实现了一遍模型经常选错最麻烦的是函数的描述只能写个大概模型根本不知道这个函数在什么场景不该用。所以agent-skills想做的第一个改变是提高抽象层级。函数描述的是“一段可执行逻辑”技能描述的却是“一种可复用的解决问题能力”。差别在于技能在“做什么”之外还显式声明了“什么时候做”“什么时候绝对不能做”“做了之后有什么副作用”“需要哪些权限”。模型面对的不再是一堆参数签名而是一本能力说明书。这个思路有点像你带新人他不会喜欢工作第一天就被丢到控制台前按几十个按钮但你给他一份岗位手册他很快就知道什么情况找谁、什么情况不能乱碰。我当时跟团队里的小伙伴解释时打了个比方普通工具接口是“开锁的钥匙”技能层是“带使用说明的门禁卡”。钥匙只解决能不能开门禁卡还告诉你怎么开、开了之后能进哪些区域、哪些房间禁止进入。Agent技能层本质上就是给模型发一张张写清楚权限边界和行为规范的门禁卡。1.2 agent-skills的三个核心设计目标第一个目标是选得准。模型面对一个用户请求时能准确选中一个或几个技能而不是靠猜。要做到这一步技能描述的信息密度和边界清晰度必须非常高后面我会详细讲怎么写。第二个目标是藏得住。技能的内部实现细节不对模型暴露模型只需要知道能力的存在、入参和出参不关心它的底层调了什么接口、访问了什么数据库、用了什么第三方库。这样可以降低模型的认知负担也让技能内部的实现可以随时换只要协议不变模型侧的提示词一行都不用改。第三个目标是玩得转。团队里多个人可以并行维护不同技能每个技能有独立的版本、维护人和测试用例互不干扰。这个目标直接决定了技能的注册、编排和治理方式也决定了技能不是简单写个函数就完事而是要有一套生命周期管理。这三个目标听起来简单但在实际落地时每一项都需要在上面中间层做文章。后面章节你会看到这些目标如何一层层转化成具体的数据结构和路由逻辑。2. 技能定义把能力折叠成一份模型看得懂的说明书2.1 一个技能文件里应该有什么我最终把技能的数据模型收敛成了五个必备字段技能名称、触发条件描述、参数协议、约束标签、执行入口。用一个Python字典来表达就是{ name: doc_to_markdown, description: 把DOCX或PDF文本片段转换成结构清晰的Markdown保留标题层级、表格和代码块。用于需要抽取正文内容、做文档清洗或下游文本分析的场景。, parameters: { type: object, properties: { doc_path: {type: string, description: 文档路径或对象ID}, ocr: {type: boolean, description: 是否需要开启OCR识别默认false} }, required: [doc_path] }, tags: [read_only, file_access, data_domain:general], execute: handlers/doc_to_markdown.py }在实际工程里我会把这段定义放到技能目录下的skill.yaml里执行函数放在同目录的handler.py里这样每个技能就是一个自包含的文件夹。新增技能时不需要改Agent主程序也不需要动模型提示词只要把文件夹放进技能目录并重启注册中心即可。为了让模型能读到注册中心会把所有启用的技能描述合并成一份JSON拼接进系统提示词。我强烈建议给每个技能加上一个tags字段哪怕一开始看着多余。这个字段对权限控制和技能路由非常有用。比如某类技能被标记为read_only就可以在调用链上直接拒绝写操作某个技能访问的数据库与其他技能完全不同就可以在中间层做数据域隔离。不要把这个字段当摆设它会在冲突消解时救你一命。2.2 技能描述怎么写才不会让模型瞎猜这是agent-skills项目里最核心的经验。技能描述写不好后续所有路由、编排都会变成玄学。我见过很多团队把描述写成一句话比如“处理文档”“获取天气”然后模型就各种误调用。模型不是人它不会自动理解你的意图它只能按描述里的线索做决策。我后来总结出一套技能描述的固定结构四条功能定义这个技能到底能做什么建议带上输入输出示例。触发场景用户在什么需求下会用到它列举典型的请求句式或关键词。禁用场景什么情况下一定不要选择它这个模块最容易被人忽略但尤其重要。边界说明它做不到什么或者它只会处理输入中的哪一部分避免模型期望过高后把错误结果当作正确结果返回。举个我改过的实际例子。之前有个翻译技能描述最初写的是“把原文翻译成中文”结果模型在遇到“帮我把这段话润色一下”时也调用翻译功能输出了一堆很奇怪的“润色版”。我把描述改成下面这个风格之后误调用率明显降下来description: | 将输入的英文文本翻译成简体中文保留原文格式和术语风格。 当用户明确要求翻译、或者提到“translate”“翻译成中文”“英文转中文”时使用。 当用户是要求改写润色、总结摘要或回答问题时不要使用本技能。 本技能不处理口语对话翻译不负责校稿和润色。这段描述的长度大概是原来的五倍。很多人担心描述太长会浪费上下文窗口但实测下来这多出来的篇幅远比让模型选错功能后重新纠正的代价低得多。你甚至可以理解为一份优秀的技能描述相当于给模型一份“触发决策说明书”它在每一轮调用时都在替你帮模型做大方向的筛选。这个投资必须做。参数协议一般就用JSON Schema模型生态对它的理解最成熟。要点是必须给参数加上enum、default、minimum这类约束别只写类型。模型生成参数的时候本质是“按描述猜格式”你把可选值范围写得越死它幻觉出一个非法值的概率越低。顺带一个经验参数描述尽量写成“模型视角才能懂的语言”比如doc_path不要写“语义过一遍再传给下游”而要写“必填字符串传入文档的绝对路径或对象ID”。3. 技能编排从“会调用工具”到“多技能配合干活”3.1 四种常见的技能编排方式单技能直调是最常见也最简单的编排方式。模型从技能清单里挑一个最匹配的技能解析出参数直接调用。适用于“翻译”“摘要”“查订单”这类职责边界特别清晰的能力。大多数情况下你应该先保证这一层可靠再去想复杂编排。串行流水排适合处理“文档从原始到成品”这种多阶段任务。我把文档处理Agent拆成了三个技能doc_extract负责把PDF/DOCX抽成结构化文本doc_clean负责去噪、统一格式doc_summarize负责最终摘要。每个技能只管自己这一步输出结构固定下游技能再把上游结果作为输入。好处是每个环节都能单独测试、单独替换坏处是延迟会累加中间某一步挂了要有重试。并行分发则适合那种“一个输入要同时跑多个独立分析”的场景。比如一个用户上传一份合同Agent同时调用clause_extract、risk_flag、loan_terms_parse三个技能各自产出自己的维度结果最后再由一个汇总技能把三份结果合并成报告返回。并行模式下一定要注意写权限隔离如果三个技能都去同一个内存对象里累积结果大概率会因为覆盖而丢失。动态路由是一种把“选择技能”这件事本身做成技能的做法。更适合技能数量很多的情况比如超过30个技能时与其让大模型从30个选项中硬选不如让一个skill_router技能先根据用户意图输出一个候选技能列表再由后续任务级技能去执行。相当于多了一层“先粗筛后精调”的机制。缺点是多了模型调用延迟增加但换来的是在大技能池下命中率稳定。3.2 冲突消解与上下文隔离技能一旦多了一定会出现“两个技能都能做同一件事”的场景。比如keyword_extract和topic_tagging都能从一段文本里抽关键信息模型经常随机挑一个。我的做法是给技能定义增加优先级不现实真正起作用的是禁用场景描述。你必须在两个技能的描述里互相标注当用户在做A场景时请使用另一个技能。这听起来很笨但实测是让模型自动收敛的最有效手段因为模型不会像人一样主动比较两个技能的优劣你帮它把决策路径写清楚它就少出错。上下文隔离是另一个重灾区。多技能配合时每个技能的输出回到主上下文后如果不做标记模型很快会分不清哪段内容来自哪个技能。我后来要求每个技能的输出都带一个统一格式的前缀帧类似[skill:doc_extract]加上版本号。这样模型能清晰看到当前上下文里每一块数据的来源不会被两个技能在同一轮产生的高相似度输出搞晕。权限隔离方面每个技能执行时我会注入只包含该技能所需权限的最小凭证而不是把主Agent凭证直接交给技能。这样即使某个技能有Bug或者在非预期场景被调用它也无法越权访问别的业务模块。对使用公有云API资源的技能这个约束尤其关键能防止一个失控的循环调用把预算打爆。下面是当时文档处理Agent的技能编排简表可以比较直观地看到每个技能职责、输入输出结构技能名职责输入输出依赖doc_extract抽取原文文档ID结构化段落列表文件服务doc_clean去噪与格式统一段落列表清洗后的Markdown无doc_summarize生成摘要清洗后文本摘要正文无merge_report合并多维度报告多技能输出最终报告JSON上游技能表格看下来会有一个直觉每个技能就像一条流水线上的工位工位之间传什么、传多久必须提前约定好。这就是技能编排的本质。4. 实战20分钟搭一个轻量技能注册中心4.1 用装饰器搞定技能注册表前面讲了这么多抽象的思考和模式不落点代码总觉得空。接下来我带你搭一个最小可用的技能注册中心。核心目标不是做一个生产级框架而是让你能亲手摸一摸技能层的运作流程。代码用Python写依赖只用到标准库加一个jsonschema校验库。第一步是定义注册表。我用一个模块级别的字典存放所有已注册技能并用一个装饰器把技能自动注册进去SKILL_REGISTRY {} def skill(name, description, parametersNone, tagsNone, enabledTrue): def decorator(fn): SKILL_REGISTRY[name] { name: name, description: description, parameters: parameters or {}, tags: tags or [], enabled: enabled, handler: fn, } return fn return decorator这个装饰器看起来简单但它解决了工程上的一个大痛点技能的描述、实现和注册近距离放在一起而不是散落在两个文件里。你维护技能时改完函数顺手就能更新描述不用再跳转去翻配置文件。如果你想做热更新可以让注册表支持目录扫描并监听某个文件夹新增或修改技能文件时自动重载这一步可以在后续版本里逐步完善。4.2 把技能清单喂给模型再按输出做路由注册好技能之后要把技能清单转成模型可以“读懂”的提示词片段。我会生成一个精简版清单控制在一个合理的长度范围内def build_skill_prompt(): lines [你可以使用以下技能选择技能时必须严格依据description的触发场景判断] for skill_name, meta in SKILL_REGISTRY.items(): if not meta[enabled]: continue lines.append(f- {skill_name}: {meta[description]}) return \n.join(lines)模型输出格式上我直接要求它输出如下JSON{ skill: doc_to_markdown, parameters: { doc_path: s3://bucket/reports/2025-01.docx, ocr: false } }路由函数负责接收模型输出的JSON查找注册表并校验参数。校验我直接用jsonschema库因为它能清晰地把“哪个参数有问题”报出来def route_skill(skill_name, params): meta SKILL_REGISTRY.get(skill_name) if meta is None: raise SkillNotFound(funknown skill: {skill_name}) jsonschema.validate(params, meta[parameters]) return meta[handler](**params)这里有个关键点路由之前一定要再检查一次技能是否启用并记录每次调用的来源会话号。因为一旦模型出现“自己创造了一个不存在的技能名”这种情况至少你能在日志里定位到是哪个会话引发的幻觉。路由函数不要直接执行不存在的技能宁可抛异常让上层做兜底回答也不能静默失败。4.3 日志、灰度与版本管理技能注册中心跑起来之后你会立刻发现日志比普通函数调用重要得多。我的每个技能调用都会输出一条结构化日志包含session_id、技能名、参数哈希、执行耗时、成功标志、失败原因。这样出问题时可以直接按会话号拉全链路调用记录而不是翻半天print输出。灰度发布可以参考这样设计每个技能在注册表里可以同时存在两个版本比如doc_extract_v1和doc_extract_v2。路由时根据一个简单的流量比例决定走哪个版本def route_with_canary(skill_name, params, canary_percent0.1): if skill_name in CANARY_MAP and random.random() canary_percent: return route_skill(CANARY_MAP[skill_name], params) return route_skill(skill_name, params)灰度比例开始设成10%观察两天调用成功率和服务延迟稳定后再把流量切到50%最后全量。这个机制不复杂但能避免“新版技能今天上线明天全体用户就遇到格式崩坏”这种事故。版本管理方面我建议技能目录里给每个版本加一个变更记录文件哪怕只有一两行回归时候会非常有用。有一个很容易踩的坑不要把所有技能的完整描述一次全塞进系统提示词。技能数量上百之后描述加在一起会吃掉大量上下文也会给模型增加决策噪音。我实际的策略是把技能清单分层高频技能全量描述进提示词低频技能只保留一两句话摘要只有被摘要命中时才动态查询完整描述。这一步收益非常明显上下文消耗能降三到五成。5. 上线后踩过的坑多技能调度排查实录5.1 高频问题速查表问题现象可能原因解决建议模型频繁选错技能描述写得过于宽泛缺少禁用场景补全触发条件和负面约束参考2.2节结构模型凭空编造不存在的技能名技能清单注入不全或模型产生幻觉路由层兜底抛异常并记录会话日志两个技能互相覆盖输出结果缺少上下文隔离统一给技能输出加来源前缀帧技能调用形成循环直到超时技能A调技能BB又调A设置最大调用深度并在路由处做循环检测同一份参数不同轮次结果差异大技能内部依赖了全局可变状态收敛到只读状态技能实现保持无副作用新技能上线后老请求报错参数Schema不兼容灰度发布新老版本并存多技能同时运行内存被修改并行写同一个共享对象改成隔离变量或用不可变数据传递这张表里的问题我基本都真实遇到过。如果你的Agent应用一次没踩过大概率是技能数量还不够多等规模上来后这些都躲不开。5.2 三个让我改代码的典型案例第一个案例是误调用。我们有个text_translate技能原本只负责翻译但描述里写了“可以处理多语言的客户问题”结果模型在面对一个需要“判断客户情绪”的请求时也调用了这个翻译技能输出的当然是一堆答非所问的内容。排查后发现根因就是描述里多写了那句“可以处理多语言客户问题”这行字把intent_classify技能的职责边界冲掉了。改法很简单把描述里的模糊性词汇全部删掉换成精确的触发条件“仅当用户明确要求把一种语言翻译成另一种语言时启用”。第二个案例是参数幻觉。有一次模型在处理文档转换时强行给doc_path填了一个null还神秘地传了一个我们从未定义过的formatlatex参数。后来我在JSON Schema里加上了additionalProperties: false并把doc_path设为必填参数幻觉的概率立刻降下来。模型确实会在参数上自由发挥你必须通过Schema把边界锁死不能给模型留发挥空间。第三个案例是共享变量问题。我一度把技能执行结果追加到一个全局列表里再由汇总技能读取。结果并行执行的时候两个技能经常同时写入同一个列表元素后面写入的覆盖前面的导致输出错乱。改成每个技能返回不可变对象、再由汇总技能手动合并之后问题彻底消失。这提醒了我技能一旦并行化实现层面的副作用必须清零。最后再分享一个我个人的习惯。每次修改某个技能的描述或参数Schema后我不会直接上线而是把历史会话里与该技能相关的调用记录找出来做成一批回归用例重放一遍。AI模型的行为有随机性技能层的改动也一样回归重放能让你在上线前就发现行为漂移。这个习惯花不了多少时间但在技能池变大的情况下可以省掉很多线上排查的苦功夫。