ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Agent Skills实战:从System Prompt膨胀到技能化编排

Agent Skills实战:从System Prompt膨胀到技能化编排 在AI Agent落地这件事上我踩过最大的坑就是“什么都想直接塞进System Prompt里”。早期做一个内部知识库问答Agent功能越加越多提示词从500字膨胀到3000字最后模型开始答非所问排错排到怀疑人生。后来接触了agent-skills这一套把能力模块化的思路才意识到问题不在于模型不够强而在于我没有给Agent一套结构化的“职业技能体系”。这篇博文就围绕agent-skills聊聊我对Agent技能化的理解、架构拆解、从零落地的完整过程以及那些文档里不会写、但实战一定会遇到的坑。1. 项目定位agent-skills究竟在解决什么问题1.1 从“单体智能体”到“技能编排”的必然演进早期做Agent的方式特别粗暴把所有工具函数、规则、知识片段一股脑写进系统提示词再给模型挂上几个Python函数当Tools。这种“单体智能体”模式在Demo阶段跑得很欢一旦进入生产环境问题立刻暴露——加一个新需求要么改提示词导致其他功能劣化要么在工具列表里再塞一个函数模型选工具时经常选错定位问题无比困难。本质问题在于我们还停留在线性指令时代没有给智能体建立“按需调用能力”的结构。agent-skills代表的是另一个方向把Agent能做的事拆成细粒度、可独立维护、可复用的“技能单元”。每个技能像人身上的肌肉记忆一样只负责一件明确的事。有的技能负责解析文档有的技能负责提取日程有的技能负责把结构化数据渲染成自然语言。Agent需要什么就加载什么不需要的完全隔离。它的核心价值是一句话——从“教模型怎么做”变成“给模型一套能干活的工具箱”。1.2 技能化设计与传统Function Calling的差异很多人看到skill会问这不就是Function Calling吗还真不是。Function Calling是模型在推理时临时决定调用某个函数的机制属于“调用层”能力而agent-skills更像是“组织层”能力。它关注的是技能的声明、加载、调度、版本、依赖甚至技能与技能之间如何协作。举个例子一份PDF采购合同Agent要完成“提取关键条款并生成摘要”这个任务。普通人会写两个函数一个是解析PDF一个是生成摘要。但技能化的思路是先将“读取PDF并转成结构化文本”做成一个底层的文档解析技能再将“摘要生成”做成一个依赖解析技能的语义层技能最后再通过一个“任务编排技能”负责流程控制。中间任何一层要替换算法或升级模型都不影响其他层。这就是技能编排的组合优势——低耦合、高内聚、可独立迭代。1.3 这个思路适合谁、不适合谁如果你正在做这些场景agent-skills的思路会非常有帮助办公自动化Agent需要处理文档、表格、邮件、日历技能天然可拆分。垂直领域问答系统在通用大模型之上叠加专有领域的处理流程。数据分析助手涉及数据库查询、代码生成、图表描述等多步骤任务。客服工单系统需要识别意图、查库、组织话术、跟进流转。但如果是纯闲聊型助手或者单轮简单问答硬套技能框架完全是过度设计加了一层复杂度反而影响响应速度。工具永远是服务于场景的别为了架构而架构。2. 技术拆解技能的表示、注册与调度机制2.1 一个技能的标准结构长什么样在agent-skills体系里一个技能通常不只包含一段函数代码它由三部分组成描述文件manifest、执行逻辑implementation和校验用例test cases。manifest是技能的“身份证”一般是YAML或JSON格式。里面最重要的字段包括name技能唯一标识比如document_parser全局不能重名。description给大模型看的语义描述要写清楚“这个技能在什么场景下做什么事”因为模型不会看实现代码它完全靠这段描述决定是否调用该技能。描述写模糊了技能再强也是摆设。parameters入参定义包括类型、是否必填、格式约束。我强烈建议这一步用类似JSON Schema的规范而不是自己随手定义几个字段便于后续校验和自动生成调用代码。returns返回值结构定义同样需要明确的schema。dependencies依赖的其他技能或外部服务例如 “上传文件前先调用file_downloader”。依赖声明可以避免运行时才发现技能链路断裂。一个经验是manifest的description不要太长但一定要把“边界”说清楚。例如file_downloader的描述不能只写“下载文件”而要写“根据给定的URL下载文件到本地临时目录并返回路径仅支持HTTP/HTTPS协议不支持FTP”。模型有了明确边界才不会在收到FTP链接时还硬调这个技能调完报错还得迭代半天。2.2 技能注册中心与动态加载技能放到目录里是开发态要变成Agent可用的资产需要经过注册中心统一管理。注册中心本质上是一个内存映射表技能名称到加载后的执行类实例之间的映射。启动时扫描指定目录读取所有manifest校验合法性后懒加载执行类。这里有两个关键设计懒加载而不是全量加载。一个大型系统的技能可能上百个如果启动时全部加载光是依赖初始化就会拖慢启动流程更重要的是大语言模型在每次推理时看到的工具数量是有限制的工具列表太长选路准确率会急剧下降。所以注册中心要支持按需加载即启动时只加载全局必需的基础技能。收到任务后先做一次意图粗筛把候选技能范围缩小到3-8个。再将范围内技能的manifest拼接到当次推理的上下文中。版本挂载而不是覆盖升级。技能会迭代同一天内可能更新多次。如果注册中心里一个技能名只对应一份代码那么压测中一部分请求命中新版本、一部分命中旧版本结果会非常不稳定。实践上我采用“name version”双标识在分发策略里显式指定什么时候切换流量。比如先在预发环境把新版本技能和旧版本技能同时注册跑一个小时的影子流量对比确认指标没问题再灰度切流量。2.3 技能调度链路模型怎么知道该用哪个技能这是我把agent-skills应用到一个内部项目时最花心思的地方。最初我天真地以为把技能列表一股脑传给模型就行结果技能超过15个之后模型选工具的正确率明显下降。后来我调整了调度策略分三层做决策第一层规则过滤。根据任务类型用硬规则过滤掉明显无关的技能。比如用户输入是语音转文字任务就不用加载图像增强技能。这一步能大幅缩小候选集。第二层语义检索。把用户意图用Embedding向量化与技能描述的向量做相似度检索选出Top-5相关技能。这层解决的是“描述近似但实际不同”的技能区分问题。第三层模型决策。把前两层筛选出的候选技能列表交付给大模型让它基于对用户问题的理解选择最合适的技能或技能组合。这种三级调度的效果非常明显一个原本有40个技能的项目最终单次任务实际参与决策的技能只有5个左右工具选择准确率从76%提升到94%以上。注意这个数据是我们的封闭测集结果不同业务会有浮动但思路可以复用。2.4 技能之间如何通信、如何共享状态技能不是孤岛。很多时候技能之间需要传递数据。我见过最糟糕的设计是用全局变量共享数据技能执行顺序稍微一变取到的数据就错了。在agent-skills体系里我推荐两种共享方式显式上下文对象。每个技能接收一个context参数维护一个类似KV存储的状态容器。技能A执行完把结果写入context.set(parsed_docs, docs)技能B声明依赖后通过context.get(parsed_docs)读取。数据流完全显式问题链路好追踪。事件总线。适合异步场景技能A发布一个事件技能B订阅该事件并触发执行。这种方式解耦强但排错难度高小团队不建议一上来就用事件驱动。我个人的建议是初期先把90%的技能通信做成“显式上下文”代码可读性强新人接手也容易上手。事件总线等到真有必要时再引入不迟。3. 实操落地从零构建一个技能化Agent3.1 环境准备与工程脚手架看再多的架构图都不如真正动手跑一遍。我先用一个最小工程说明怎么搭技术栈是我个人实践的方案不一定对所有人都最优但足够清爽Python 3.11 FastAPI pydantic v2。第一步是初始化工程目录我通常这样组织agent-skills-demo/ ├── skills/ │ ├── __init__.py │ ├── calendar/ │ │ ├── skill.yaml │ │ ├── __init__.py │ │ └── impl.py │ └── document_parser/ │ ├── skill.yaml │ ├── __init__.py │ └── impl.py ├── registry.py ├── dispatcher.py ├── main.py └── pyproject.toml先把依赖管理做清爽用uv创建一个虚拟环境核心依赖只有三样fastapi、pydantic、pyyaml。真正跑大模型调用的时候再按需引入openai或对应的SDK。我见过太多人一开始就把一堆依赖丢进项目最后依赖冲突看得脑子疼工程上先做减法总没错。3.2 定义第一个技能以“会议记录整理”为例一个具体例子胜过千言万语。假设我们要做一个技能meeting_minutes职能是把一段会议发言转写文本整理成结构化的会议纪要。先在skills/meeting_minutes/skill.yaml里写清楚技能描述name: meeting_minutes version: 0.1.0 description: 把会议录音转写后的原始文本整理为结构化会议纪要 包含参会人、议题、结论、待办事项四个部分。 适合输入为纯文本的会议记录不适合直接处理音视频文件。 parameters: type: object properties: raw_text: type: string description: 会议转写文本UTF-8编码 attendees: type: array items: type: string description: 已知参会人列表可为空 required: - raw_text returns: type: object properties: summary: type: string description: 会议整体摘要 action_items: type: array items: type: string description: 待办事项列表 dependencies: []注意几点description字段里我专门加了“适合纯文本”“不适合音视频”这两个边界信息这个习惯在后续减少很多误调用parameters里的required必须只列真正必要字段让模型拼参数的负担尽可能小。然后是执行逻辑impl.py核心函数长这样from pydantic import BaseModel, Field class MeetingMinutesInput(BaseModel): raw_text: str Field(..., min_length10, max_length50000) attendees: list[str] Field(default_factorylist) class MeetingMinutesOutput(BaseModel): summary: str action_items: list[str] def execute(params: dict, context: dict) - dict: data MeetingMinutesInput(**params) llm_client context[llm_client] prompt build_prompt(data) response llm_client.chat_completion( messages[{role: user, content: prompt}], temperature0.2, max_tokens1024, ) result parse_response(response) output MeetingMinutesOutput(**result) return output.model_dump()这里有个需要补充说明的点我在execute里用了context[llm_client]说明LLM客户端不是技能自己new的而是由框架通过context注入。这样技能本身不关心上游用的是哪家大模型以后换模型或做多模型路由都方便。实际项目中不要在每个技能里都各自初始化一个OpenAI客户端连接池和认证统一管理会比散落各处的客户端稳定得多。3.3 注册中心与调度器的代码骨架技能写好了要进注册中心。注册中心的核心逻辑是扫描目录、解析manifest、建立映射。import yaml from pathlib import Path from importlib import import_module class SkillRegistry: def __init__(self, skills_dir: str): self.skills_dir Path(skills_dir) self._skills {} def scan(self): for manifest_path in self.skills_dir.rglob(skill.yaml): with open(manifest_path, encodingutf-8) as f: meta yaml.safe_load(f) module_path manifest_path.parent.name impl import_module(fskills.{module_path}.impl) self._skills[meta[name]] { meta: meta, execute: impl.execute, } def get(self, name: str): if name not in self._skills: raise KeyError(fskill {name} not registered) return self._skills[name] def search_by_keywords(self, query: str, top_k: int 5) - list[str]: # 这里可以是向量检索也可以先用关键词粗过滤 scored [] for name, skill in self._skills.items(): desc skill[meta][description] score len(set(query) set(desc)) scored.append((score, name)) scored.sort(reverseTrue) return [name for _, name in scored[:top_k]]调度器则负责把用户请求路由到技能执行class Dispatcher: def __init__(self, registry: SkillRegistry): self.registry registry async def dispatch(self, user_input: str, context: dict): candidates self.registry.search_by_keywords(user_input, top_k5) if not candidates: raise ValueError(no suitable skill found) # 在实际项目中这里可以先让LLM从candidates里选出最合适的技能 # 再构建参数这里为了演示直接取第一个 skill_name candidates[0] skill self.registry.get(skill_name) params {raw_text: user_input, attendees: []} return skill[execute](params, context)当然生产环境不可能这么简单你需要在这里集成语义检索、LLM意图识别、参数抽取和多轮上下文管理。但脚手架的意义在于先把链路打通。链路通了后面都是一点一点迭代的事。3.4 性能与成本优化每轮调用到底花多少Token技能化架构能控制成本但前提是你会算Token账。我以meeting_minutes这个技能为例粗算一次调用的消耗技能manifest拼接后约300 token。用户原始文本按每字约1.5 token计算一份30分钟会议的转写文本大概3000字折合4500 token。指令模板和输出约束说明约500 token。模型的输出会议纪要一般800-1200 token。单次技能调用总Token在6000-6500左右。注意这里只算了LLM推理的对话上下文还没算你上一次意图识别和技能检索的开销。如果使用三级调度每次任务还需要额外花一轮200-400 token的“模型选技能”调用很多项目在评估成本的时候漏掉这笔账结果月底账单出来了吓一跳。所以建议做好两个缓存技能选路缓存相同意图的请求在短时间内直接复用上一次的选路结果不需要每次都让模型重新选技能。业务场景变化不快的话准确率下降幅度完全可以接受。中间结果缓存比如某份文档解析结果当天没变技能A解析后可以把结构化结果缓存到Redis技能B再次调用时直接读缓存省掉重复解析的开销。我在实际项目中把这两个缓存加上之后整体Token成本下降了约40%而且响应时延也在提升。这不是玄学是实打实的账单数字。4. 常见问题与排查技巧实录4.1 技能调用冲突两个技能都觉得自己该干活这是技能多了之后最容易出现的问题。比如项目里同时有meeting_minutes和meeting_notes_extractor功能高度重叠模型对同一个输入时而选A时而选B输出风格不一致下游处理就会出问题。排查思路是先找重叠把每个技能的description和测试样例导出算一遍相似度矩阵。两条技能描述向量相似度超过0.85就要警惕功能重叠。解决办法有三个方向合并技能把两个能力收编成一个更通用的技能同时把边界写在description里。职责细分一个偏“整理结论与待办”另一个偏“抽取关键时间点”让模型按需选择。加规则兜底针对输入中带“时间”“排期”等关键词的请求强制路由到后者。另外每次技能变更后要跑一遍意图分流的回归集防止改了一个技能的description导致其他任务被错误路由过去。这个坑我踩过不止一次T1回测不可省。4.2 技能执行超时重试机制反而让问题更严重技能执行超时的原因通常不是大模型太慢而是上游服务抖动或者技能内部在等待某个同步接口响应。这时候如果不加控制的简单重试会导致多个请求同时阻塞服务吞吐量下降甚至雪崩。我的做法是把超时与熔断分开设置单次技能执行设置硬超时比如30秒超时直接返回错误结果并把这次执行标记为失败。技能级熔断器1分钟内失败次数超过阈值比如5次熔断该技能10秒熔断期间直接返回兜底文案不给上游继续施加压力。class CircuitBreaker: def __init__(self, fail_threshold5, cooldown10): self.fail_threshold fail_threshold self.cooldown cooldown self.fail_count 0 self.opened_at None def call(self, fn, *args, **kwargs): if self.opened_at and time.time() - self.opened_at self.cooldown: self.opened_at None self.fail_count 0 if self.opened_at: raise RuntimeError(circuit opened) try: result fn(*args, **kwargs) self.fail_count 0 return result except Exception: self.fail_count 1 if self.fail_count self.fail_threshold: self.opened_at time.time() raise这段代码很朴素但内部的稳定性比起裸重试好了不止一个量级。4.3 大模型上下文窗口溢出很多人在Agent技能链条拉长之后会把每一轮的对话历史、每个技能的输出都一股脑塞进上下文最后发现自己陷入“窗口溢出—截断—丢失重要信息—技能执行错误”的恶性循环。Skill场景下上下文管理要遵循一个原则——只保留当前任务最小集。具体操作上我会做三件事技能执行完毕只把结构化结果放回上下文原始长文本归档到外部存储。对话历史做滑动窗口裁剪只保留最近2轮的用户意图和最终结果。如果技能A的输出要喂给技能B只传递与B的入参schema匹配的字段而不是把A的完整输出对象整个传过去。这样即使单条回复内容再大上下文中的有效信息始终维持在可控区间。4.4 技能回归测试表面跑通不等于真的没问题技能开发最容易忽视的是测试但这类系统一旦上线问题往往是多技能交互时才会暴露的。我建议每个技能维护一组测试样例分为三类类型样例特征期望结果正向用例典型正常输入输出符合schema边界用例极长输入、空字段、非法格式优雅报错或安全兜底负向用例明显超出技能范围的输入不调用该技能或返回明确拒绝信息每次技能升级时把这组测试样例跑一遍同时额外跑一遍“技能分流正确率”测试——针对50条带意图标签的请求检查最终路由选择是否符合预期。只要这轮测试通过率不下降就可以放心走灰度。还有个小技巧把测试中所有技能的真实执行输入输出录成快照下次升级时对比快照差异。模型升级、提示词调整导致的行为漂移在快照对比下一目了然调试效率能提升很多。一些实操心得讲真把技能架构引入项目之后前期开发速度反而变慢了。因为你需要花时间设计manifest、写用例、搭注册中心和调度器。但好处也扎扎实实体现在后面新增一个技能不需要改动老链路排查问题的时候按技能边界切分调试模块复杂度被限制在局部。如果你是第一次尝试我强烈建议别一上来就设计几十个技能先挑3-5个你业务里最高频的动作做技能化比如文档解析、日程提取、信息检索跑通一两个真实的端到端任务。等这个链路稳定了再逐步把其他能力往这个框架里搬。技能不是越多越好一个能被正确调用的技能远比十个躺在目录里没人用的技能有价值。
返回列表