尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Agent-Skills:从单体Prompt到可复用技能库的智能体架构实践

Agent-Skills:从单体Prompt到可复用技能库的智能体架构实践 1. 先搞清楚 agent-skills 到底在解决什么问题1.1 从单体智能体的痛点说起做 LLM 应用的人应该都有过这种体验你辛辛苦苦写了一个几千字的 system prompt把各种工具的使用说明、输出格式、边界情况全部塞进去模型也确实能在大多数时候按预期生成结果。但一旦任务复杂起来问题就接踵而至——模型偶尔会编造一个不存在的函数名或者把参数的 JSON Schema 写错又或者在多步推理时突然忘了之前用过的工具结果。你不得不一遍遍改 prompt改完一轮又出新的幺蛾子整段逻辑耦合在一起动一处牵全身。我最早遇到这个问题是在做一个批量处理数据的智能体。那个智能体需要同时处理“读取 CSV”“清洗字段”“调用外部 API 做数据增强”“生成汇总报告”四件事。起初我把所有工具描述直接写在 system prompt 里prompt 膨胀到接近两万字。结果模型在长上下文中频繁遗忘工具格式偶尔还把两个工具的输出字段混在一起。最崩溃的一次模型把“清洗字段”和“调用外部 API”的执行顺序完全颠倒导致下游数据全脏了。后来我意识到问题不在模型本身而在我的组织方式。智能体的能力不应该是一大坨揉在一起的文本而应该像乐高积木一样一块一块拆开再按规则组合。这个思路后来被很多人叫做“技能化”也就是项目标题里那个词agent-skills。它的核心不是发明新工具而是把模型执行任务时需要的每一项原子能力从 prompt 中剥离出来封装成带有明确输入、输出和执行逻辑的“技能单元”再通过统一的调度机制让模型按需调用。1.2 技能化的核心心智模型把智能体能力技能化本质上是在做一次“能力解耦”。传统的单体 prompt 是“把说明书全塞给员工”技能化则是“给员工一套标准化的工具库让他自己看说明书挑工具”。这套心智模型有三个关键点我建议一开始就建立起来。第一技能是模型和外部世界的接口。模型本身只产生文本它想做任何实际操作都必须通过技能这个中间层。比如“查询天气”“运行一段 Python 代码”“抓取网页正文”对外部世界的访问都收敛在技能内部。这样一来权限控制、错误处理、数据格式转换等脏活累活就被隔离在技能内部不会污染模型的推理逻辑。第二技能的输入和输出都需要高度结构化。我见过很多失败的技能化尝试问题都出在“接口不严谨”。技能输入应该用严格的 JSON Schema 定义输出也最好统一成 JSON而不是让模型自由发挥散文。道理很简单模型调用技能时本质是在执行一次函数调用函数的参数类型不明确下一层逻辑就没法稳定工作。结构化是技能化的生命线。第三技能之间应该保持低耦合。每个技能只负责一件独立的事不要在技能 A 内部偷偷调用技能 B 的内部函数。如果你发现两个技能经常需要配合使用正确的做法是把它们的共性抽出来作为新的底层技能或者用一个编排层去组合它们。耦合一旦出现测试和排查的复杂度会指数上升。1.3 它到底适合谁用聊到适用人群我想先给个范围。如果你的项目只是调用一次模型、做一次简单的文本总结那完全不需要 agent-skills单体 prompt 反而更轻。但如果你在开发以下这些场景技能化几乎是必需品需要多步骤规划和工具调用的智能体、需要接入多个外部 API 的数据处理流水线、需要长期运行的自动化程序、以及一切要求“模型行为可测试、可回滚、可观测”的生产级应用。换句话说agent-skills 适合那些已经把智能体从 Demo 做成了“正经系统”的人。技能化的收益不是让模型变得更聪明而是让系统的边界更清晰让混乱从“不可控的 prompt 海洋”收敛成“一系列可单独测试的小函数”。这个转变在项目规模小的时候感受不明显一旦技能数量超过十个维护难度和扩展性的差距会立刻拉开。2. 技能化架构的整体设计与拆解2.1 技能的最小构成单元一个能稳定工作的技能至少应该包含四个部分技能的元信息名称和描述、输入参数定义、执行逻辑、以及输出处理。用代码来表示一个技能就是一个带有 schema 的异步函数。# 用 dataclass 定义一个技能的最小结构 from dataclasses import dataclass, field from typing import Callable, Any, Optional dataclass class Skill: name: str # 技能名称最好用英文小写下划线 description: str # 给模型看的自然语言描述 input_schema: dict # JSON Schema定义输入参数 execute: Callable[..., Any] # 实际执行逻辑 timeout: float 10 # 超时时间防止模型误调用拖死主流程 retry: int 1 # 失败重试次数这里最容易被忽略的是description字段。很多初学的朋友在定义技能时名称和 schema 写得很仔细唯独描述随便写了一句话。结果模型在需要这个技能时完全想不起来调用它。描述其实是写给模型看的“使用说明书”它对调用准确性的影响往往比代码本身的正确性还大。我在后面会专门讲这一块的经验。输入参数定义强烈建议直接用 JSON Schema 规范。你不需要自己实现校验逻辑Python 生态里有现成的jsonschema库把input_schema传进去调用前先校验一遍参数不合法就直接返回错误不让它进入执行逻辑。这样能挡住一大批模型幻觉产生的非法调用。执行逻辑就是普通的异步函数需要注意两点一是要抛出异常而不是吞掉异常这样上层才能记录错误并做重试二是尽量保持幂等性。所谓幂等就是同一个输入执行两次和一次结果保持一致。比如“发送邮件”这个技能天然不具有幂等性但“查询订单状态”就是幂等的。非幂等技能在执行前要格外小心最好增加“确认”环节。2.2 技能注册表与工具调用的绑定逻辑有了技能之后下一步是要让模型知道“有哪些技能可以用”。这个机制通常叫注册表registry。注册表维护着一个技能列表模型在每次需要调用工具时会从注册表里选择适合的技能。class SkillRegistry: def __init__(self): self._skills {} def register(self, skill: Skill): # 注册前检查名称冲突避免静默覆盖 if skill.name in self._skills: raise ValueError(fskill {skill.name} already registered) self._skills[skill.name] skill def get_openai_tools(self) - list[dict]: # 转换成模型工具调用接口需要的格式 tools [] for name, skill in self._skills.items(): tools.append({ type: function, function: { name: skill.name, description: skill.description, parameters: skill.input_schema, } }) return tools async def invoke(self, name: str, args: dict) - Any: if name not in self._skills: return {error: funknown skill: {name}} skill self._skills[name] # 调用前做 schema 校验把错误提前拦截 import jsonschema try: jsonschema.validate(args, skill.input_schema) except jsonschema.ValidationError as e: return {error: finvalid args: {e.message}} return await skill.execute(**args)调用模型时把你的tools列表传给大模型接口。现在的模型 API 基本都支持函数调用模型会返回一个tool_calls结构里面包含了模型想调用的技能名和参数。你的代码拿到这个结构之后通过注册表执行对应技能再把执行结果作为新的消息回传给模型形成一个闭环。这里有个关键点工具调用的格式不同厂商的 API 略有差异但本质都在做同一件事。建议在你的代码里只保留一个适配层后面接的模型厂商变了只改适配层技能本体完全不需要动。这个抽象换来的是极大的迁移自由度我后来把应用从一个模型切到另一个模型技能部分一行没改。2.3 项目名里的“skills”到底指什么技能即接口抽象有人可能会问为什么不直接叫 tools为什么名字要用 skills我的理解是tools 强调的是“工具”而 skills 强调的是“能力”。工具是你给智能体配的螺丝刀能力则是“知道在什么场景下用螺丝刀、怎么用、用完之后怎么评估结果”的完整闭环。一个技能可以封装多个底层工具也可以包含一段逻辑处理甚至可以调用另一个技能暴露出的接口。换句话说技能是在工具的上面加了一层“语义化包装”。工具描述的是“我能做什么”技能描述的是“当你需要什么效果时应该调用我”。我比较喜欢的比喻是工具是函数技能是 API。函数只管把事做对API 则要考虑调用方的体验、边界条件、错误信息。你在把工具升级为技能的过程中其实是在做一次 API 设计。这种抽象带来的直接好处是技能可以在不同项目间复用。我在 A 项目里写好的“网页正文提取”技能只要输入输出保持不变可以直接迁移到 B 项目。智能体开发从此不再是每个项目从零开始堆 prompt而是不断积累自己的技能库这个库本身就是你和团队最宝贵的资产。3. 实操从零搭建一套可用的技能体系3.1 先用一个最简单的技能跑通链路我强烈建议第一次实践时不要一上来就做复杂技能。先写一个最简单的、几乎不依赖外部环境的技能把整套链路跑通。我用的“Hello World”是“获取系统时间”。from datetime import datetime async def get_current_time(timezone: str Asia/Shanghai): 获取指定时区的当前时间时区格式为 IANA 名称例如 Asia/Shanghai try: from zoneinfo import ZoneInfo now datetime.now(ZoneInfo(timezone)) return {time: now.isoformat(), timezone: timezone} except Exception as e: return {error: finvalid timezone: {timezone}} time_skill Skill( nameget_current_time, description获取当前时间的精确时间用户可以指定时区。需要知道当前时间时使用。, input_schema{ type: object, properties: { timezone: { type: string, description: IANA 时区名比如 Asia/Shanghai, default: Asia/Shanghai } } }, executeget_current_time, )用这个技能跑通完整的调用闭环注册技能 → 转成 tools 传给模型 → 模型返回调用请求 → 通过注册表执行 → 把结果回传给模型 → 模型生成最终回答。这个链路一旦跑通后面所有技能都只是往注册表里加一项的事。我习惯把这个过程写成一个可复用的run_agent函数async def run_agent(user_query: str, registry: SkillRegistry, model): messages [{role: user, content: user_query}] # 最多允许 10 次模型-技能交替调用防止死循环 for _ in range(10): resp await model.chat.completions.create( modelyour-model-name, messagesmessages, toolsregistry.get_openai_tools(), ) msg resp.choices[0].message messages.append(msg.model_dump()) if not msg.tool_calls: return msg.content for tc in msg.tool_calls: result await registry.invoke(tc.function.name, json.loads(tc.function.arguments)) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse) })注意这个循环的上限。我曾经因为没加次数限制模型在某个分支里反复调用同一个技能直到 API 配额耗尽才被系统强停。这种死循环在生产环境里非常可怕一定要有深度限制。3.2 把技能注册进模型调用流程技能写好了接下来是注册和调试。我实际使用的注册方式有两种一种是编程式注册直接在代码里registry.register(skill)另一种是配置文件驱动的注册我用一个 YAML 文件维护技能清单程序启动时自动加载。用 YAML 的好处是非技术人员也可以往技能库里添加新技能不需要改代码。配置结构大致如下skills: - name: get_current_time module: skills.time_utils enabled: true - name: web_search module: skills.web_tools enabled: true config: api_key_env: SEARCH_API_KEY加载器读取这个文件动态导入对应的模块实例化技能对象并注册。模块里需要暴露一个create_skill(config)函数返回Skill实例。这种方式让我可以在不同环境开发、测试、生产使用不同的技能集合比如生产环境禁掉一些高风险的写操作技能只开放只读技能。配置驱动还有个额外优势方便做 A/B 测试。你可以让一半流量使用带技能 A 的版本另一半使用不带技能 A 的版本对比模型表现差异。对生产环境来说这个能力太关键了。调试阶段建议把模型返回的原始tool_calls结构完整 Log 下来不要只看最终结果。我在早期反复遇到过“模型说了正确的答案但实际没有调用任何技能”的情况如果只看回答内容根本发现不了模型在凭幻觉硬答。只有看了完整日志才能确认技能调用这件事真的发生了。3.3 一个复合技能的完整实现单一技能只能做一件事但真实业务中很多任务需要多步协作。我拿一个常见场景举例用户输入一个问题需要先搜索相关信息再对搜索结果做摘要最后把摘要和原始链接一起返回。这个流程不该写成一个巨型技能而应该拆成三个技能web_search、summarize_text、format_sources。模型在规划时会自动组合它们。但这样有个问题模型是否总是能正确编排实测下来对于这种简单线性流程大多数模型都可以做到但偶尔会跳步。所以我会在系统提示词里加上一段编排提示比如“你需要先搜索然后对搜索到的重要内容进行摘要最后按格式输出结果”。如果你用的是 OpenAI 系的函数调用接口模型天然支持多技能并行调用——一次返回多个tool_calls。但并行调用的技能之间不能有依赖关系。web_search和summarize_text就不能并行因为摘要依赖搜索结果。解决方法是把有依赖关系的步骤封装进同一个复合技能里或者用两轮模型调用第一轮调web_search拿到结果后再让模型决定下一步。复合技能的标准实现方式是在技能内部调注册表async def search_and_summarize(query: str): search_result await registry.invoke(web_search, {query: query}) if error in search_result: return search_result summarized await registry.invoke(summarize_text, {text: search_result[text]}) return {query: query, summary: summarized[summary], source: search_result[url]}这样写的好处是中间步骤完整暴露给测试你可以单独测search_and_summarize也可以分别测两个底层技能发现问题时能快速定位到底哪一步出了错。如果要追踪调用链还可以给每次调用加一个 trace_id把日志串起来。3.4 技能测试与回归的土办法正规一点的团队会引入 pytest 做单元测试但我这里想分享一个成本极低、见效极快的“土办法”准备一个固定的问题集每个问题对应期望的技能调用序列每次改动后把这些问题跑一遍比对调用序列是否仍然正确。CASES [ { query: 今天上海天气怎么样, expected_skills: [get_weather], }, { query: 搜索一下最新的 AI agent 论文总结两句话, expected_skills: [web_search, summarize_text], }, ]跑完之后把模型实际调用的技能列表和期望列表做比对不一致就报警。这个测试集不需要太大二三十个问题就能覆盖大部分核心路径。它测的不是最终回答的质量而是“模型是否在正确的时间、用正确的参数调用了正确的技能”。只要调用序列稳定最终回答的质量通常不会太差。我每次改技能描述、调输入 schema都会先跑一遍这个回归集。很多隐藏的破坏比如描述的措辞变了导致模型不再调用某个技能都能被它第一时间抓住。这套土办法让我在快速迭代时少挨了不少骂。4. 常见问题与实战排查记录4.1 模型死活不调用技能怎么办这是所有人第一次接入函数调用时都会遇到的坑。模型生成了一大段文字把本该“调用工具”的部分用自然语言描述了出来就是不走工具调用接口。排查思路按顺序走先看模型厂商是否真的在本次请求中收到了 tools 参数再检查 tools 的数量和格式是否符合该厂商的规范最后检查技能描述是否足够诱人。其中技能描述是最容易被忽视的变量。我做过对比测试同一个技能描述写“查询天气”和“当用户想了解某个城市当前或未来几天的天气情况、气温、湿度、降水概率时使用此工具获取实时数据。注意如果用户只提到城市名和日期也属于天气查询需求”后者的调用率能提高一倍。模型是靠描述来判断“这个技能对应哪类用户意图”的描述越具体、覆盖的触发场景越丰富调用越准确。还有一种情况是模型认为不需要调用工具就能回答。这在知识类问题上很常见模型用自己的训练记忆直接回答。解决办法没有银弹只能尽量把技能描述写清楚同时在 system prompt 中强调“所有涉及实时信息、外部数据、计算结果的回答都必须先调用对应技能”。4.2 技能输出格式混乱的治理技能执行完返回给模型的 content 字段如果格式混乱模型后续的解读就会跟着乱。我会做以下强制约束技能的输出永远是一个 JSON 字符串内部至少包含status字段取值是success或error。成功时结果放在data字段里失败时错误信息放在error字段里。{status: success, data: {weather: 晴, temp: 28}} {status: error, error: city not found: 上海}为什么这样设计因为模型需要根据技能结果决定下一步动作。如果技能把结果整段输出成一个复杂嵌套的 JSON模型解析的成本很高容易出幻觉。扁平化、明确的status字段能让模型一眼看出这轮调用是否成功。失败状态下模型可以自行决定是否重试、换参数、或者向用户说明错误原因。这个规范要在所有技能里统一执行不能有的返回 JSON、有的返回纯文本。我会在代码评审时把它列为硬性要求这也是我踩过不少次坑才立下的规矩。4.3 并行调用与上下文溢出的坑模型支持多工具并行调用之后消耗 token 的速度会翻倍。尤其是一次并行调用 5 个技能每个技能结果都很长几轮之后上下文可能就撑不住了。我实际遇到过一次比较严重的问题智能体在处理一个网页批量抓取任务时并行抓取了 10 个网页每个网页转成 markdown 后有 8000 多字符一轮就把 32k 上下文窗口吃掉了大半后续模型连基本的推理都开始变得迟钝。我的应对策略有三个一是控制并行数量模型一次返回多个tool_calls时只取前 2 到 3 个其余的塞回提示让模型分轮处理二是技能结果做截断返回给模型的内容默认只保留前 2000 字符超出的部分在技能内部截断并注明“内容过长已截断”三是使用摘要压缩如果一个技能的结果被后续轮次反复引用就把完整内容存在一个外部存储里只把摘要放回上下文。这三招组合下来上下文压力明显降低。尤其是结果截断对 token 消耗的节约非常可观而且绝大多数任务根本不需要完整内容摘要级别的信息就足够模型做决策了。4.4 技能命名与描述的经验法则技能命名看起来是小事但实际影响特别大。模型在生成tool_calls时是自由输出技能名的如果名字又长又像模型产生幻觉的概率就会上升。我的经验法则是技能名必须全部小写用下划线分词长度控制在 3 到 5 个单词之间。比如get_current_time就比getTheCurrentTimeOfTheSystem靠谱得多。描述方面我再补充几条经过实战验证的写法不要写“这是一个用于获取时间的函数”这种毫无信息量的话而是写“当用户询问现在几点、当前日期、或者需要时间戳时调用。支持指定时区。注意不要把这个技能用来计算时间差。”负面描述非常有用——告诉模型“不要在什么场景下用”能显著减少误调用。技能描述还要避免含糊的形容词比如“高效地”“准确地”这类词模型理解不了也不会因此更倾向于调用你。唯一有用的是具体性和边界性。我最近在给一个电商客服智能体做技能库深有体会描述里写着“仅当用户明确表达退货意图时调用”的技能它的调用准确率远高于描述里写着“处理退货相关问题”的技能。最后提醒一个容易忽略的问题技能库的规模不是越大越好。模型面对几十个技能时选择困难的问题是真实存在的。实测中技能总数超过 20 个以后调用准确率会明显下降。解决方案是分组或分层做一个顶层路由技能先判断意图属于哪一类再进入对应的技能子集。5. 再聊几句这个方向的下一步技能化并不是智能体工程的终点它只是一个把混乱变得有序的中间层。我最近的探索方向是给技能加“学习”能力记录每次调用的参数分布和成功率定期分析哪些技能经常被弃用、哪些技能总是被错误调用然后根据这些数据反过来优化技能描述和 schema。另外多个技能之间如果出现重复逻辑也不一定要急着抽成新技能有时候抽得太激进反而会让模型的选择变难。我的建议是顺其自然等真正出现了“两个技能必须同时改”的情况再考虑合并。过早抽象是过早优化的变体在智能体这个领域同样成立。如果你正准备从单体 prompt 迈向技能化架构我给的最直接建议是别设计太庞大的技能体系先挑三个真实任务给它们写三个技能把闭环跑通再逐渐加码。技能库的成长应该来源于真实需求的积累而不是一次性的顶层设计。
返回列表