ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent技能库:从提示词堆砌到可维护的智能体能力编排

Agent技能库:从提示词堆砌到可维护的智能体能力编排

“agent-skills”这个名字,第一眼看去平平无奇,但用过的人都知道,它背后藏的是一整套关于智能体能力组织的方法论。我把它落地成了一个轻量的技能注册与调度系统:所有Agent能干的“事”,都被拆成一个一个带名称、带参数契约、带具体实现的独立技能,统一登记在一张注册表里,运行时由大模型通过函数调用机制按需挑选并执行。这套东西解决的核心问题,恰恰是LLM Agent从Demo走向生产时最头疼的:上千行的System Prompt越堆越臃肿、十几个工具函数散落各处没人敢改、出了错只能靠肉眼在日志里大海捞针。如果你正在做Agent应用,或者正准备把原型阶段的功能收拢成一套可维护、可灰度、可观测的能力集合,这篇文章值得你从头看到尾。

1. 告别提示词堆砌:Agent技能库到底解决了什么

1.1 单体Agent的三大痛点

我在很多项目里看到过同一种写法的Agent:一个越来越长的System Prompt,加上十几个直接散落在代码里的工具函数。前期跑Demo确实爽,等要上真实业务时,问题就像叠buff一样涌出来。

第一个是提示词膨胀。为了让模型表现得聪明一点,你不自觉往System Prompt里塞规则、塞示例、塞背景知识。到上千行以后,模型开始抓不住重点,指令之间互相冲突,光是调Prompt就能耗掉好几天。更麻烦的是,上下文窗口就那么大,你塞了太多规则,留给真实用户输入和工具返回的空间就少了,对长文档、多轮对话场景几乎是致命伤。

第二个是工具函数散落。每个函数自己的参数、错误处理、鉴权方式都不一样,调用方想复用非常难受。今天A项目写了一个“发送告警消息”的函数,明天B项目要用,要么复制粘贴改一版,要么在代码里import一个根本没人维护的公共模块。时间一长,谁都不敢动这些函数,改一个牵出三个bug。

第三个是不可测试、不可观察。Prompt是黑盒,工具调用链也是黑盒。用户问了一句“帮我总结一下上个月的数据”,模型可能调用了三个工具,中间某一步失败了,最后返回一句“抱歉,我做不到”。你连它到底调用到哪一步失败都不知道,只能靠加日志、反复复现,效率极低。

技能化拆解就是冲着这三个问题去的:把Agent会做的事拆成技能,每个技能都有自己的边界、参数契约、实现、校验和返回结构,就像工厂里给每道工序明确工位一样,出问题能精准定位到是哪个工位的事。

1.2 技能化拆解带来的三个直接收益

技能化之后,收益是肉眼可见的。第一是复用。我在一个内部平台里维护了一套技能库,大概四十多个技能,覆盖文档处理、数据查询、消息发送、信息检索这些高频能力。新开一个Agent项目时,团队几乎不用重新写工具,只需要声明自己依赖哪几个技能,配置好权限,直接就能跑。开发效率提升得非常明显。

第二是隔离。技能内部出错,不会拖垮整个会话流程。技能可以单独调试,比如某个表格抽取技能,我可以直接调用它传一份PDF进去,看输出的表格结构对不对,不需要启动完整的Agent链路。这对持续集成、自动化测试特别友好。

第三是可观测。因为所有调用都经过注册表转发,我可以在这一层统一记录调用时间、参数摘要、返回状态、错误信息。每个Agent会话用了哪些技能、成功率多少、平均耗时多少,全部可以统计成指标。有一次客户反馈某个场景经常失败,我直接看技能调用统计,发现是其中一个数据查询技能超时率高,前后花了十分钟就定位了根因,这在以前是不可想象的。

这三个收益,本质上来源于一个转变:把“写死的能力”变成了“可注册、可发现、可编排的资源”。

1.3 技能库和普通工具集不是一回事

有人会问,把函数集中管理,这不就是工具集吗?我的理解是,技能库和普通工具集有一个关键差异:普通工具集重实现、轻契约,技能库把“给模型看的描述”当成一等公民。

普通工具集里,一个函数的核心是它的实现逻辑,调用方是人,可以直接看源码、看注释、调函数签名。但在Agent场景里,核心调用方变成了大模型,它看不懂你的实现,只能通过你写的描述和参数Schema来理解“这个技能什么时候该用、怎么用、传什么参数”。所以,技能库中一段写不好的描述,会让模型完全不知道该调用这个技能,或者把参数传错——这与代码本身写得好不好无关。

另一个差异是动态发现。Agent要求的是“根据用户意图,在运行时选择合适的工具”,这本质上是个发现机制。普通工具集是“你把接口暴露出来,我手动去import”,技能库是“把所有能力统一登记,模型通过语义匹配来发现并调用”。后者的开销当然更高,但带来的灵活性也完全不是一个量级。

还有一点是治理层面的。技能库天然要求你做注册、版本、权限、审计,因为一个技能可能被几十个Agent复用,你不能允许它像个人函数那样想改就改。我见过团队因为一个工具变更导致下游多个Agent行为变化的事故,技能库配上版本号和启用开关之后,这种事故基本绝迹。

2. 一张注册表管住所有能力:整体设计与关键选型

2.1 技能注册表:一张结构化的能力清单

技能库的核心,就是一张注册表。我心目中一个健康的技能注册表,至少包含下面这些字段:

字段作用示例值
skill_name技能唯一标识,模型调用时使用summarize_document
description给模型看的语义描述,决定模型何时调用读取文档并生成摘要...
parametersJSON Schema,描述入参结构{...}
handler实际的Python实现函数引用
version技能版本号1.2.0
enabled启用开关true
owner负责人,方便追责>skills/ common/ # 通用基础技能 send_message.py get_current_time.py document/ # 文档处理域 extract_text.py summarize_document.py data/ # 数据查询域 query_metrics.py export_table.py agent/ # Agent编排相关 run_chain.py

命名上尽量用“动词_宾语”的蛇形形式,比如extract_text、send_message。这是因为大模型对“动作+对象”的命名理解最准,而且调用方在日志里看到技能名时,一眼能知道这个技能做了什么。

单个技能内部,我会拆成四个文件:skill.py(实现和注册)、schema.py(参数定义)、description.md(描述文档,经过评审的版本放这里)、tests/(针对该技能的单元测试和回放用例)。这样的好处是,改描述不用动代码,改代码不用动描述,评审流程也能分开走。

2.3 自研还是全家桶:我的取舍

这个项目最初考虑过直接使用现成的Agent框架,后来还是决定自研一个轻量核心。原因有两个。

第一,现成框架都会带来生态绑定。你用了它的工具定义方式,后面想换一个模型平台、换一种编排逻辑,改造成本非常高。技能库本意是解决“被写死的东西困住”的问题,如果核心本身也被框架捆死,就违背初衷了。

第二,技能库的核心逻辑其实不复杂。注册、描述、校验、转发、日志,核心部分大概两百行代码就能写完。自研可以完全掌控行为,做权限控制和指标采集都很顺手;现成框架为了兼容各种场景,反而会给你塞很多用不到的概念。

取舍之后的方案是:核心注册与调度完全自研,只管“技能注册、参数校验、调用转发、技能链执行”这四件事;具体的模型调用、文档解析、消息发送这些业务能力,都作为技能来实现。这样核心的逻辑保持稳定,而能力可以无限扩展。

还有一点选型心得:不要一开始就追求“完美架构”。我建议先实现最小闭环——一个注册表、一个装饰器、一个执行函数——跑通“模型选技能、技能执行、结果回填”这个链路,再逐步加校验、指标、灰度这些工程化能力。大部分项目死在前期过度设计上,而不是死在能力不够上。

3. 从注册到调用:核心代码链路这样打通

3.1 用装饰器把函数变成技能

我最初设计时注册逻辑比较简单,就是用注册表加一个装饰器。翻译成核心代码大概是这个样子:

# skills/registry.py class SkillRegistry: def __init__(self): self._skills = {} def register(self, name, description, parameters, handler): self._skills[name] = { "name": name, "description": description, "parameters": parameters, "handler": handler, } def list_for_llm(self): return [ { "type": "function", "function": { "name": item["name"], "description": item["description"], "parameters": item["parameters"], }, } for item in self._skills.values() if item.get("enabled", True) ] def execute(self, name, arguments): item = self._skills.get(name) if item is None: raise KeyError(f"skill not found: {name}") return item["handler"](**arguments)

装饰器就负责把元信息登记到注册表里,同时保留原函数可以直接调用:

# skills/decorator.py def skill(name, description, parameters): def decorator(func): registry.register(name, description, parameters, func) return func return decorator

我把注册表做成单例,方便各模块import。装饰器有一个容易被忽略的设计点:它返回的仍然是原函数,而不是包装后的函数。这样技能既可以被Agent通过注册表调用,也可以被普通代码直接函数调用,便于测试和复用。

3.2 参数校验与执行上下文:让技能不再裸奔

技能被调用时,参数不一定可靠。模型可能编造参数值、漏掉必填字段,甚至给完全无关的键。所以在执行之前,我会做一道强制校验。常见的做法是对照JSON Schema做一次检查,不通过就直接返回结构化错误,而不是让异常把整个会话炸掉。校验还可以做值归一化,比如把模型返回的“一百”“500字左右”这类自然语言转成合理的数字。

执行上下文也是我踩过坑之后才补上的。最初每个技能都是裸函数,内部直接读环境变量或全局配置,结果技能一多、并发一起来,请求之间互相污染,排查起来痛不欲生。后来我引入了一个显式的Context对象,携带request_id、调用者身份、技能配置、会话摘要等字段,在每个调用入口构造好之后传给技能。技能内部只能通过Context读取环境信息,不能再私自访问全局变量。这样既隔离了请求,也让日志能串起来:看到一条报错,顺着request_id就能找到完整调用链。

3.3 对接LLM函数调用:描述写得好,模型才不跑偏

注册表与模型调用的对接,是这套系统里最关键的环节。一次典型调用流程是这样:

  1. 组装消息列表,把注册表里的技能列表通过函数调用接暴露给模型。
  2. 模型判断用户意图后,返回一个或多个技能调用请求。
  3. 我解析调用请求,从注册表里找到对应技能,执行后把结果回填给模型。
  4. 模型基于工具结果继续作答。

代码如下,这段基本是固定的模板:

functions = registry.list_for_llm() resp = llm.chat(messages=messages, functions=functions) while resp.tool_calls: for call in resp.tool_calls: name = call.function.name args = json.loads(call.function.arguments or "{}") try: result = registry.execute(name, args) except Exception as exc: result = {"error": str(exc)} messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False), }) resp = llm.chat(messages=messages, functions=functions)

描述怎么写给模型看,这个直接影响到选技能准确率。我的经验是:描述必须写成“动词+宾语+场景说明”,最好再带一个典型例子。比如一个文档摘要技能,我会写成“读取指定路径的文档并生成摘要,适用于会议纪要、研究报告、论文等文本内容,输入文档路径,返回摘要文本”。不太好的写法是“文档摘要”,模型看到这种描述,根本不知道这个技能是干嘛的、什么时候该用它。

还有一个细节:给参数的description也要写得具体。模型不是人,它看到“max_length”这个参数,不知道这个“最大长度”是字数还是字节、单位是多少。我会在参数描述里明确写“摘要的最大字数,默认500”,模型就不会乱传。

3.4 技能链:把多个技能编排成一个流程

单个技能解决单一问题,但业务场景往往是“多步连环”的。技能链要解决的问题,就是把这些技能按顺序编排起来。

我用的是一份JSON配置,把链ID、执行步骤、分支条件都写在里面。执行器按顺序调用技能,前一步的输出可以作为后一步的入参。比如一个“生成日报并通知”的链:

{ "chain_id": "daily_report_chain", "name": "日报生成与通知", "steps": [ {"skill": "collect_metrics", "output_as": "metrics_data"}, {"skill": "summarize_metrics", "args": {"data_ref": "metrics_data"}}, {"skill": "send_im_message", "args": {"channel": "team_group"}} ] }

技能链的好处是,流程变成配置,而不是代码。业务调整时只改JSON,不用动代码、不用重新发布。执行器还可以在链级别统一做重试、超时、失败分支处理。实际用下来,我一般把链路配置放在配置中心,哪个环节需要调整,直接改配置热加载,运维体验好了不少。

4. 实操复盘:从零落地一个“文档摘要”技能

4.1 边界先行:一个技能只做一件事

我想用一个“文档摘要”技能作为例子,完整走一遍从需求到上线的过程。

需求其实很简单:给Agent一个文档路径,它能把文档内容结构化摘要出来。但需求越是看着简单,越容易掉进“顺手多做一点”的坑。比如有人会顺手加一个“保存摘要到数据库”的功能,这就会让技能变得难以复用。我的原则是:一个技能只做一件事,输入输出都尽量纯粹。

所以最终技能边界定义如下:

  • 输入:文档本地路径doc_path、摘要长度max_length(可选,默认500)
  • 输出:摘要文本summary、源文档来源source
  • 不做的事:不发送消息、不保存结果、不处理远程上传——这些全部交给其他技能组合

边界清晰之后,实现难度大降,测试也好写。

4.2 描述与Schema:先写给人看,再写给模型看

技能注册时,description和schema是给模型看的接口,重要性不比实现低。我先写一版给人看的描述,找团队同事帮我评审:假设你是一个没有背景知识的模型,看到这个描述,你知道什么时候调用它吗?

最终敲定的描述是这样:

读取指定路径的文档并生成结构化摘要,适用场景包括会议纪要、研究报告、论文、合同等文本内容; 当用户要求总结、概括、提炼文档要点时,可调用此技能。 输入文档路径后返回摘要文本,摘要长度通过max_length控制。

这里有一个很实用的技巧:描述里加一句“适用场景”,再加一句“当用户要求……时可调用”,等于给了模型正反两个锚点,能明显降低误调用率。

Schema部分,doc_path标记为必填,max_length给默认值并写清单位。我还会给模型补充说明,让它在不确定时留空而不是硬编造。

4.3 干跑、回放与验收

写完技能后,我会做三轮验证,而不是直接扔给Agent回归。

第一轮是干跑:不真调用原始业务接口,只用构造的假数据跑注册、校验、执行逻辑,确认参数能正确解析、流程能走通。这块用普通单测就能覆盖。

第二轮是回放:录一段历史会话里模型对这个技能的调用记录(包括参数和预期结果),修改代码之后直接重放,看行为是否一致。回放这招对技能库特别有用,因为模型调用技能的参数五花八门,你很难事先想到所有边界情况,从真实日志里捞数据回放是最快的方式。

第三轮是端到端验收:开一个临时Agent会话,用真实需求让模型自己选技能、调用技能、组织答案。我特别关注三件事——模型有没有选错技能、参数传对没有、返回结果能否支撑回答。验收通过后,这个技能才进入可发布状态。

4.4 上线那点事:开关、日志、灰度

技能上线不是把代码合进去就完了。我的习惯是三步走。

先加开关。每个技能都有enabled字段,上线时先默认关闭,确认环境无误后再打开。有人觉得开关多此一举,但真实世界里,一个技能可能被几十个会话同时使用,你没有开关就等于没有退路。

再定日志规范。每一次技能调用都要记录:技能名、参数摘要(不用记录完整参数,避免敏感信息泄露)、执行耗时、返回状态、request_id。这一层日志能支撑很多事后排查,没有它,问题定位就是纯靠猜。

最后做灰度。先让内部测试Agent加载新技能,观察指标没问题后,再放开给少量线上Agent,逐步扩量。对于非关键技能,这步可以轻一点,但涉及写操作或外部系统对接的技能,灰度是必须的。

5. 上线后避坑:常见问题与排查技巧实录

5.1 问题速查表:一次看全

我整理了一张速查表,覆盖技能库上线后最常遇到的几类问题:

问题现象可能原因排查思路 / 解法
模型从不调用某个技能描述太模糊、缺少适用场景重写描述,加“动词+宾语+场景”,附典型调用示例
模型把参数传错类型Schema缺少类型约束、description没说清单位严格JSON Schema校验,增加参数description
技能返回了模型看不懂的结果返回结构不规范、缺少关键字段统一结构化返回,必要时在返回里加自然语言说明
多个技能同时命中描述语义重叠明确技能边界,让描述聚焦各自场景
技能内部报错中断会话异常未被捕获、未返回结构化错误统一错误捕获,返回{"error": "..."}
线上技能行为发生突变代码改动未走灰度、版本未管理加版本号、加开关、先灰度后放开
技能调用链路查不到日志查询未走索引、request_id缺失统一request_id,日志走索引或时序库

5.2 模型老是不选某个技能?先检查描述

有一次,我上线了一个“查询经营数据”的技能,功能都正常,但业务方反馈模型几乎不用它,老是用一个功能相近的旧技能。我一开始以为是模型问题,后来做了个实验:分别用两个技能描述去调模型,让它判断用户问题该用哪个。结果发现,新技能描述里只写了“查询经营数据并返回表格”,而旧技能描述里写清了“适用于销售、运营、财务等数据查询场景,返回DataFrame格式”。差距一目了然——模型是根据语义匹配选技能的,你的描述越具体、越接近用户会说的话,命中率越高。

后来我把所有技能的描述都重新梳理了一遍,格式统一为“功能一句话+适用场景两三句+输入输出说明+典型触发语”。模型选技能的准确率明显提升,这个经验我复用到所有项目上。

5.3 参数幻觉:模型编造了不存在的参数

模型在调用技能时,偶尔会编造一些不存在的参数。我记得有一次,技能只接受doc_path和max_length,模型却传了一个source_format: "pdf"进来。原因在于模型对技能的参数理解不够,或者从上下文推断出了一些似是而非的信息。

对付参数幻觉,我做了三层防护。第一层是Schema校验兜底,不认识的参数直接忽略或报错,绝不能让它们插进代码执行;第二层是提示约束,在调用前给模型补一句“只能使用给定参数,不要猜测额外参数”;第三层是示例兜底,如果确实有高频传错的参数,我会在参数description里加上“如无必要不要传此字段”之类的负面约束。三层下来,参数幻觉基本能压到忽略不计。

5.4 技能卡死与超时控制

技能里只要涉及外部IO,比如调接口、读文件、请求第三方服务,就存在卡死的可能。我在最初版本里没有做超时控制,结果有一次某个查询技能调外部接口时长时间无响应,整个Agent会话被拖死。从那次以后,我把超时控制当成技能库的标配能力。

我给所有技能默认设置一个超时时间,普通技能5秒,写操作或外部依赖重的技能可以配到15秒。超时后返回结构化错误,会话还能继续,不至于整体崩溃。对于特别耗时的技能,我还会改造成异步执行加轮询结果的模式,或者用流式输出分段返回进度,避免让用户干等。

还有一个细节:技能内部的网络请求也要单独设置超时,不要只依赖外层超时兜底。外层超时只能兜住最坏情况,内层超时能让技能快速失败、快速报错,用户体验完全不同。

如果让我分享一个个人感想,那就是“技能库最值钱的不是代码,是那套契约”。当初我花在梳理技能边界和写描述上的时间,远超过写注册表实现的时间,但正是这些“看起来不酷”的工作,让整个系统从能跑变成了好用。最后再送大家一个小技巧:在技能描述末尾加一个“不适合什么场景”的负面说明,模型误调用的概率会再降一截。这个技巧我从一开始就用,到现在依然觉得是性价比最高的一行字。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进