首页 / 资讯中心 / 文章详情

Agent技能体系设计:从工具调用到可复用能力编排的工程实践

Agent技能体系设计:从工具调用到可复用能力编排的工程实践 ★ FEATURED ARTICLE
上个季度我把一个内部项目从“玩具级Agent”改造成真正能稳定干活的系统最大的转折点不是换了更强的模型也不是疯狂调prompt而是把重心从“让模型想得更聪明”挪到了“让模型有更多可复用的技能”上。这个思路转变直接改变了我对Agent工程的看法——很多人搭Agent时把精力全放在对话编排和大模型推理上结果做出来的东西像是一个只会聊天、不会动手的实习生问什么都能答真正让它完成跨系统操作、数据处理、报告生成这些任务时就开始手忙脚乱。“agent-skills”这个方向解决的就是这个问题把Agent的“动手能力”从零散的代码调用升级为一套可注册、可组合、可复用、可管控的技能体系。它适合三类人看一是正在做Agent但发现工具调用越来越难维护的开发者二是想给自己的Agent产品增加“技能商店”式扩展能力的团队三是对Agent工程化有兴趣、想知道除了提示词工程还需要什么的技术爱好者。这篇文章我不会只讲概念会把我实际设计技能框架时的数据结构、注册机制、编排模式以及踩过的坑都摊开来讲。1. 先想清楚一个前提技能不是工具更不是函数做Agent一段时间后你会发现一个扎心的事实大模型本身就是个很聪明的“大脑”但如果你只给它一堆裸函数它干活的质量取决于你“临时教”它的程度。今天告诉它能调A接口明天又加了B函数后天模型开始混淆这两个东西的适用场景。本质问题在于你给模型的是“零件”而不是“完整的操作能力”。1.1 工具调用和技能体系差了哪一层很多项目的第一步都是给Agent接上工具调用比如查天气、发邮件、算个数学表达式。这种模式在单个功能场景下够用但一旦涉及多步操作、条件分支、数据校验、异常恢复你就会发现工具调用有几个结构性问题工具是“无状态”的每个函数都是独立的Agent调用完一次就结束不能自然衔接前后步骤。比如“读取CSV → 清洗数据 → 生成图表”三个函数之间没有任何记忆和协议中间出错了也不知道从哪里恢复。工具描述是“临时”的你在系统提示里塞一大段JSON Schema给模型模型理解多少全看运气。描述写得再详细模型还是会选错工具、填错参数。工具没有“完成标准”一个工具执行完了结果符不符合预期需不需要重试要不要换条路裸函数完全不关心这些。而“技能”是对工具、上下文规则、前置条件、执行流程、失败处理、完成标准的一个整体封装。如果说工具是“会做某一件事的接口”那么技能就是“知道在什么条件下、按什么流程、做到什么标准算完成的一套完整能力”。1.2 可以类比成“给临时工发SOP”我一直觉得Agent调用工具的样子很像你临时抓一个人说“帮我处理一下这个表格。”他可能懵着用Excel点半天。但如果你递给他一份标准作业程序SOP上面写着先检查表头有没有空值发现空值就填0并记录到日志再按日期排序最后生成汇总透视表哪些情况算异常、异常该怎么上报——他干活的稳定性和效率就会完全不同。技能体系本质上就是给Agent准备的一整套SOP。每一个技能都携带着“怎么判断自己适不适合出手”“如何规范地执行”“出了问题走哪条退路”的完整上下文。模型只需要判断“用哪个技能”然后把执行细节交给技能本身推理压力大幅下降。1.3 我最终定义的“技能”边界在我这个项目里我给技能划定了一个边界技能是Agent能够主动调用的、带输入输出协议的最小完整能力单元。它不可以再往下拆成独立技能但可以依赖其他技能来组合它必须有明确的触发条件、前置校验、执行体、退出条件和错误恢复策略。一个反直觉的经验是技能粒度宁大勿小。一开始我也喜欢拆细一个“读取文件”一个“写入文件”一个“格式转换”结果模型光决定调用顺序就耗费大量token而且一旦顺序错了后面全错。后来我把“读取-处理-写入”合并为“数据处理”一级的技能内部用子步骤控制整体稳定性立刻上来了。模型需要做的决策变少了出错的概率自然就降低。2. 技能骨架的数据结构每个字段都有它的用处想清楚了技能和工具的区别接下来就是落地的第一步把技能形式化。如果你的技能只是嘴上说说写一堆函数然后告诉模型“这些是技能”那你等于没做。真正可用的技能体系首先得有结构化的技能描述和注册规范。2.1 一份可落地的技能定义我用的技能基类是Python的dataclass把每个技能元信息写清楚。下面是一个简化但完整的技能骨架示例from dataclasses import dataclass, field from enum import Enum from typing import Callable, Any, Dict, Optional class SkillCategory(str, Enum): DATA_PROCESSING data_processing REPORT_GENERATION report_generation SYSTEM_INTERACTION system_interaction EXTERNAL_API external_api dataclass class AgentSkill: skill_id: str # 全局唯一标识如 skill.data.pipeline_v3 name: str # 人类可读名称会提供给模型 description: str # 模型选择技能时的主要依据必须写清适用场景 version: str # 语义化版本号如 1.4.0 category: SkillCategory # 技能分类用于路由和权限管控 tags: list[str] # 检索标签辅助模型快速匹配 input_schema: Dict[str, Any] # 输入参数的JSON Schema output_schema: Dict[str, Any] # 输出结构定义Agent会依据它解析结果 prerequisites: list[str] # 前置条件检查项例如 data_loaded dependencies: list[str] # 依赖的其他技能ID列表 executor: Callable # 实际执行体可以是函数、类方法或远程调用 timeout_seconds: int 30 # 超时控制防止技能长时间卡死 max_retries: int 2 # 失败重试次数 require_confirmation: bool False # 是否需用户确认之后才能执行 permission_level: str user # 权限等级例如 user/admin context_required: list[str] field(default_factorylist) # 模型不可见仅用于框架内部的状态记录 execution_count: int field(default0, reprFalse)你可以看到这个结构里有很多字段并不是模型直接关心的比如timeout_seconds、max_retries、permission_level。它们解决的是工程问题技能执行时能不能被强制中止失败了要不要自动重试这个操作有没有越权这些都是裸函数时代完全没人管的事。2.2 description字段是模型分流的关键在给模型看的技能信息里最重要的就是description和input_schema。description写得不好模型根本不知道该什么时候用这个技能。我踩过一个很典型的坑有个技能是“生成周报”我写的是“生成用户的周报数据”结果模型在用户问“帮我总结一下这周干了什么”的时候迟迟不调用反而自己从对话里生生编了一份总结。后来我把description改成“当用户需要回顾一周工作、按周汇总进度、或要求生成周期性工作报告时使用可以读取工作日志、项目系统数据并生成结构化Markdown或PDF报告”模型立刻就能正确匹配了。这里有个经验公式技能描述 触发场景When 执行内容What 输出形式Output。三个要素缺一不可。只有触发场景模型不知道结果长什么样只有执行内容模型碰到边界场景不知道要不要出手只有输出形式模型还是不清楚什么时候该调。2.3 输入输出Schema的重要性不亚于代码本身输入输出Schema是模型与技能执行体之间的桥梁。模型用自然语言理解用户需求后必须把需求映射成结构化的技能参数。如果Schema写得含糊模型就会“自由发挥”。我的建议是所有必填参数必须标注required并给出明确的示例值比如{start_date: 2024-03-01, end_date: 2024-03-07}。输出结构建议固定为统一信封格式{status: success|error, data: {...}, error_message: ..., execution_trace: [...]}。对于枚举值列出合法的选项并解释每个选项的业务含义而不是让模型猜。这个信封格式我用了很久它最大的价值在于技能执行成功后Agent不需要解析复杂结果直接看status失败时error_message可以成为模型下一步决策的依据。这相当于技能和Agent之间建立了标准协议而不是依赖模型临场理解各种不同的返回格式。3. 技能注册与动态加载别把技能写死在代码里设计好数据结构之后下一个问题是这些技能怎么被Agent发现和加载最朴素的做法是把技能实例塞进一个列表运行时遍历描述信息构建给模型的工具列表。初期可以这么干但技能超过十几个之后这个方案就开始拖后腿了——每次调用都把几十个技能的完整Schema塞进上下文模型选择和推理的负担直线上升token开销也Hold不住。3.1 注册中心让技能可被发现、可被路由我实现了一个轻量级的技能注册中心核心就三件事注册、检索、装载。注册时校验技能定义是否合法检索时根据用户请求和Agent上下文筛选出候选技能装载时才把技能的执行代码加载到运行时环境。class SkillRegistry: def __init__(self): self._skills: dict[str, AgentSkill] {} self._skills_by_tag: dict[str, list[str]] {} def register(self, skill: AgentSkill) - None: if skill.skill_id in self._skills: raise ValueError(fDuplicate skill_id: {skill.skill_id}) self._skills[skill.skill_id] skill for tag in skill.tags: self._skills_by_tag.setdefault(tag, []).append(skill.skill_id) def lookup(self, skill_id: str) - AgentSkill: return self._skills[skill_id] def search(self, query: str, category: Optional[SkillCategory] None, limit: int 10) - list[AgentSkill]: # 基于标签 描述关键词召回候选技能 query_tags set(query.lower().split()) candidates [] for skill in self._skills.values(): if category and skill.category ! category: continue score len(query_tags set(skill.tags)) if any(word in skill.description.lower() for word in query_tags): score 1 if score 0: candidates.append((score, skill)) candidates.sort(keylambda x: -x[0]) return [skill for _, skill in candidates[:limit]]这套注册中心的检索逻辑虽然简单但已经足够支撑几十个技能规模的项目。关键是它把“模型该看哪些技能”这件事从一次性塞入变成了按需检索先根据当前query粗筛出候选技能再把候选技能的描述和Schema交给模型去精确挑选。上下文里的工具列表瞬间从几十项缩减到三五项模型的决策准确率反而更高了。3.2 插件化加载与热更新技能必须支持动态加载不能每次加技能都重新发布整个Agent应用。我在注册中心外面套了一层插件管理器它扫描指定目录下的技能包每个技能包是一个Python文件或者一个目录包含技能定义文件skill.yaml和执行体executor.py动态导入模块并注册到中心里。新增一个技能只需要丢一个目录进去不需要改主进程代码重启后自动生效。实际做的时候要注意一个问题动态导入的模块一旦加载Python解释器默认不会卸载所以“热更新”一个同名技能时老的模块还在内存里占着资源。我的处理方式是用importlib.reload强制刷新同时维护一个版本号只有版本号大于已加载版本时才执行重载。这个机制对交付模式的影响很大。以前每改一个技能行为就要更新整个应用现在技能包是独立迭代的单元可以单独测试、单独发布还能针对不同用户群体装载不同技能组合。事实上我在一个模拟项目里就实现了“技能订阅”的概念基础用户只有数据处理和通用查询技能专业用户多一个报表生成和跨系统同步技能。同一个Agent内核通过技能配置实现能力差异化产品形态立刻灵活了很多。3.3 权限控制与沙箱化执行技能既然是一段可执行代码就一定要考虑安全边界。我的设计里每个技能都有permission_level本地执行时用最小权限的进程用户运行远程执行时通过独立工作节点调用。涉及外部系统写入操作比如发邮件、改数据库记录的技能必须设置require_confirmationTrueAgent会先把执行计划和目标输出给用户确认确认后才真正触发执行体。这个“确认前置”看起来让流程多了一步但对用户体验反而是加分项。因为用户看到Agent在动手改数据之前先问了一句“我准备把A表里所有状态为pending的记录改为done共123条是否执行”他对Agent的信任度会大幅提升。从工程角度说这一步也天然拦截了大量误操作——模型理解错需求时用户还有机会在动手前拦下来。4. 技能编排Agent不应该是技能的“一行行调用器”技能单体做得再完善如果Agent只能按顺序一个一个调用效率还是上不去。真实任务往往是多步骤的先收集数据再清洗然后分析同时可能还要拉取另一个系统的配置作为参考最后汇总成报告。如果把每个步骤都交给模型自己按对话轮次推进Agent会变得非常“话痨”——每做一步都要确认一次token消耗巨大。4.1 从“模型一步步决策”到“技能编排图提前定义”我的做法是引入技能编排层让一部分固定流程从模型的对话决策中剥离出来。每个技能在定义时可以声明自己的编排依赖框架根据依赖关系自动决定哪些技能可以并行、哪些必须串行、哪些需要条件判断。这里说的“编排”和用LangChain、LangGraph这类现成框架的Graph模式很接近但我不建议一开始就上重型框架。自己用一个轻量字典描述依赖关系就够了比如# 技能编排定义示例 pipeline_config { pipeline_id: weekly_report_pipeline, steps: [ {step: fetch_logs, skill: skill.data.log_reader, mode: serial}, {step: clean_data, skill: skill.data.cleaner, mode: serial, depends_on: [fetch_logs]}, {step: compute_stats, skill: skill.analysis.descriptive_stats, mode: parallel, depends_on: [clean_data]}, {step: render_chart, skill: skill.report.chart_renderer, mode: parallel, depends_on: [clean_data]}, {step: compose_report, skill: skill.report.markdown_composer, mode: serial, depends_on: [compute_stats, render_chart]} ], fallback: skill.report.simple_summary }用这个配置Agent只需要做两件事第一判断当前用户请求匹配哪个Pipeline第二对Pipeline里那些没有默认值的入参进行确认或补充。中间的串行依赖和并行分支完全由框架接管。这相当于把一部分Agent推理降维成了流程执行。4.2 编排执行器串行、并行、条件分支、失败回退编排执行器不复杂核心是一个有向无环图DAG的拓扑排序。我把每个技能的执行包装成一个PipelineStep由执行器按依赖关系调度。这里给出一个简化实现思路import asyncio from concurrent.futures import ThreadPoolExecutor class SkillOrchestrator: def __init__(self, registry: SkillRegistry): self.registry registry async def run_pipeline(self, pipeline: dict, input_context: dict) - dict: steps pipeline[steps] # 解析依赖关系 dependencies {} for step in steps: deps step.get(depends_on, []) dependencies[step[step]] deps completed {} # 按拓扑顺序迭代执行这里省略了真正的DAG判断仅示意 while len(completed) len(steps): for step in steps: step_id step[step] if step_id in completed: continue deps dependencies.get(step_id, []) # 仅当前置依赖完成时执行 if all(dep in completed for dep in deps): skill self.registry.lookup(step[skill]) step_input self._prepare_input(step, completed, input_context) if step[mode] parallel: # 并发执行可以走线程池或者异步调用 completed[step_id] await self._execute_with_retry( skill, step_input, retriesskill.max_retries ) else: completed[step_id] await self._execute_with_retry( skill, step_input, retriesskill.max_retries ) # 如果一轮扫描后没有新任务完成说明存在循环依赖或者不可达 # 实际工程中要对这种情况报错 # 如果关键技能失败调用fallback if compose_report not in completed and pipeline.get(fallback): fallback_skill self.registry.lookup(pipeline[fallback]) completed[fallback] await fallback_skill.executor( input_contextcompleted ) return completed真实项目里我不会手写DAG调度毕竟成熟库和框架已经做得很好。但我把编排层单独拎出来的核心原因是编排层让Agent的技能组合方式成为可配置、可测试、可观测的资产。直接让模型自由调用技能出了错你只能怪模型但有了编排层你可以在监控面板上看到每一步花了多久、哪一步挂了、是数据问题还是模型判断问题。这个可观测性在Agent调试里是无价之宝。4.3 参数在技能间的传递上下文快照与显式命名编排执行时最容易被忽略的是技能之间怎么传参数。如果一个技能的输出要成为另一个技能的输入你光靠函数调用时的return值是不够的——因为编排器并不知道每个技能返回的哪个字段是下游需要的。我的方案是引入“上下文快照”每个技能执行后的输出都会以其step_id为键名写进共享上下文下游技能在定义时用context_required声明需要的上游字段。比如compose_report技能声明需要compute_stats.summary和render_chart.chart_path编排器就会从上下文快照里提取这两个字段合并成它的输入参数。这样每个技能只需要关心自己需要什么不需要关心别的技能内部实现解耦效果立竿见影。这个设计对模型也很友好因为它在生成参数时不需要猜测“上一个技能到底返回了什么”而是直接按照context_required字段的声明去上下文里取参数填错的概率大幅下降。5. 真实踩坑技能体系落地的六个典型问题设计框架和写代码是相对“干净”的阶段真正的问题在接上真实业务数据、让Agent长时间运行之后才暴露出来。这一节我会按踩坑的先后顺序记录前三个是运行时稳定性的后三个是模型决策质量的。5.1 技能返回值格式不统一模型解析频频出错最早期我把输出协议做得不够严格。部分技能返回Python字典部分直接返回字符串还有几个技能执行成功后打印了一堆日志却不返回显式结果。模型有时候能猜对结果有时候把日志当输出给用户看体验很糟糕。排查思路是先看监控里模型调用技能后的下一轮回复凡是出现“根据上一个技能的执行结果”这种模糊表述大概率就是返回格式不明确。修复方案只有一个所有技能必须走统一信封格式返回并且我在注册时加了校验器技能执行体返回的对象如果不满足信封格式注册直接被拒绝。宁可让开发时多一步封装也不要让模型在线解析混乱。5.2 技能复制粘贴式重试把错误无限放大max_retries这个参数最开始被我设置成3想着“多试几次总能成功”。结果有个技能是调外部接口同步订单数据那个接口本身限流技能连续重试三次次次触发限流错误反而拖慢了后续所有任务的调度。更隐蔽的问题是重试时input还是原始输入如果原始输入本身就是错的重试除了浪费资源毫无意义。后来我把重试策略改成了“指数退避 输入变更才重试”只有错误类型属于瞬时错误如超时、连接中断才重试业务逻辑错误如参数非法、权限不足立即失败不重试重试间隔按次数递增第一次5秒、第二次20秒、第三次60秒。这套策略上线后技能因外部依赖抖动导致的失败率下降了80%而且不会在参数错误时做无意义的重复尝试。提示在技能定义里max_retries不是越高越好。真实经验是宁可失败上报给Agent让它换一条路也不要让技能在同一路径上无限重试。5.3 循环依赖两个技能互相等对方结果我遇到过一次死锁技能A的输出需要技能B的结果技能B的输出又需要技能A的结果。直接在编排层配置依赖时拓扑排序根本检测不出来因为两个技能都在等对方执行器扫了一圈发现没有新任务可执行就卡死了。这个问题的根因是技能边界划得不清楚——A和B其实可以合并成一个技能或者引入第三个技能C来做中间协调。我在编排执行器里加了一个“无进展检测”如果一轮扫描后没有任何新任务完成立即中止整个pipeline并返回依赖关系快照方便我定位是谁和谁互相等待。加了这一层之后问题从“线上卡死”变成了“开发期报错”体验天差地别。5.4 Agent反复调用同一个技能形成“原地打转”这是模型问题不是框架问题但技能体系可以缓解它。现象是Agent接了技能A之后发现结果不符合预期不换一种思路而是回过头来用同样的参数再调一遍A然后对用户说“再试一次”如此反复三次token烧完还没解决。我发现这个现象的诱因往往不是模型笨而是技能A的description里写了类似“当结果不符合预期时可重试本技能”的模糊表述。修复方式包括第一技能描述里明确写清理操作边界第二在技能执行结果的信封里增加allow_retry标志由执行体根据业务语义决定是否建议重试模型每次调用完技能后都能看到这个标志第三在编排层对“同一技能连续调用超过2次”做强制干预把用户拉进来确认。三者叠加这个行为基本消失了。5.5 技能越多模型选择越发散而不是更精准技能数量从10个涨到40个之后我发现模型的技能选择准确率不升反降。原因很直白技能多了描述之间的语义距离变小模型容易混淆近邻技能。比如“数据清洗”和“数据校验”看似不同但在模型眼里trigger场景高度重叠。解决思路是给技能描述做“差异化约束”每个技能在description的触发场景里主动写“不适用于什么场景”把易混淆的边界提前排除。比如“数据清洗”的描述末尾加一句“本技能不处理数据合法性校验如需检查字段格式和取值范围请使用数据校验技能”。这个“负向描述”看似浪费几个token实际效果非常显著。从40个技能里选错的情况减少了一半以上。5.6 上下文快照越来越大最终拖垮整条链路的响应速度技能编排层的上下文快照本来设计得很好但跑了一周后我开始看到响应变慢。查监控发现快照里堆积了大量中间结果特别是技能返回的原始数据一份几千行的Excel读进来快照里就存了几千行内容下游技能根本用不到白白拖慢调度和内存。修复方案是给上下文快照加上生命周期策略每个步骤的输出在进入快照时都会登记一个expires_after_steps参数比如compute_stats的输出只对下一步的render_chart有用过了这一步就直接从快照里移除只保留summary这种需要跨多步的轻量汇总结果。轻量的原则是快照里只存“描述性摘要 关键路径”不存完整原始数据。需要原始数据时技能自己去数据源重新读取反而比从快照里拿更可靠。注意技能的设计不能因为想省事就把全量数据拼在返回里。输出越“重”Agent后续的处理就越“笨”因为上下文窗口是有限的而真实业务数据往往远超模型能消化的量。6. 技能体系的进阶方向评估、沉淀与抗体型技能技能框架稳定运行之后我最大的体会是这套体系的价值不只是“让Agent更会干活”而是把Agent的“能力进化”变成了一件可管理的事。最值得投入的是技能评测。以前Agent能力好不好只能靠人工聊几轮试试。有了技能体系之后可以针对每个技能写独立的评测用例集用一批固定输入跑回归测试看输出是否稳定符合预期。我把每个评测用例组织为一个(输入, 期望行为, 可接受输出模式)三元组任何技能的修改都必须先过评测集才能上线。这个做法保证了技能是可持续迭代的资产而不是一次性的代码片段。另一个方向是“从对话中沉淀新技能”。运行一段时间之后我发现用户经常要求Agent做一些类似但不在技能列表里的事。过去我们需要人肉分析日志、写代码、加技能。后来我在日志分析阶段接入了一套方案把对话中Agent每次成功完成多步任务的过程抽取成模板人工审核后转成编排配置再配上一个轻量执行体一个新技能就沉淀出来了。这个过程把“一次性的成功对话”变为了“可复用的标准能力”越用技能库越丰富Agent的整体能力也随之上升而不是每轮对话都从零开始推理。最后一个实用建议是给技能留“抗体”位。这个灵感来源于运维领域的熔断机制——如果一个技能连续失败次数超过阈值自动把它标记为不可用Agent绕开它选择替代路径而不是硬着头皮往里冲。我实现的是一个简单的状态机正常运行 → 连续失败N次 → 熔断开 → 冷却时间后 → 半开试探性放一个请求 → 成功则恢复失败则继续熔断。这个机制上线后Agent面对依赖系统不稳定时的整体任务完成率提升了近20%而且不会再因为外部服务抖动而把整条对话拖进泥潭。技能体系做完之后我回头看这个项目最核心的成果其实不是某段代码或者某个框架而是把“Agent的能力”从模糊的、靠模型自由发挥的东西变成了结构化的、可测试的、可扩展的工程资产。现在团队里任何人想给Agent加一种新能力不需要重新调模型、改提示词、碰主流程只需要按技能规范和编排规范写一个技能包扔进插件目录跑一遍评测用例完成。这个过程标准化以后Agent的能力扩展速度明显加快了这也让我意识到大模型负责的是“聪明地判断”而工程系统负责的是“稳定地执行”两者配合才能真正让Agent持续产出可靠的价值。
阅读完成 · 觉得有帮助?
咨询建站