ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

智能体技能层设计:让工具调用稳定可靠的关键实践

智能体技能层设计:让工具调用稳定可靠的关键实践 1. agent-skills 的缘起智能体开发最容易被低估的一层做 AI Agent 这两年我踩过最多的坑不在模型选型也不在提示词调优而在技能层。什么叫技能层就是你给智能体装的那些“手”——查天气、读文件、调数据库、发消息、操作浏览器这些具体能力的集合。早期我做的 Agent 都是裸奔的把十几个工具函数一股脑塞进 system prompt让模型自己看着办。结果呢模型倒是挺聪明知道该调哪个工具但工具的参数格式五花八门有的用 JSON有的用纯文本有的要 base64模型时而把字段名写错时而把必填项漏掉偶尔还自作主张把两个工具串起来调用产出完全没法看的中间结果。后来我意识到缺的不是工具而是一套技能管理体系。这个体系要解决的不是“某个工具怎么写”而是“一堆技能怎么组织、怎么描述、怎么被模型可靠地调用”。我把它做成了一个独立模块项目代号就叫agent-skills。它不是某个模型专属的框架而是一套和模型解耦的技能层规范每个技能都有标准化的元信息、参数 schema、执行包装和错误协议模型通过统一入口发现技能并触发调用开发者通过配置文件就能增删技能不需要动业务代码。这篇文章就是我在设计和反复迭代 agent-skills 时的完整复盘包含设计思路、目录结构、关键代码片段、测试方法以及我在实际跑项目时碰到的一堆真实问题。如果你正在做带工具调用的智能体、正在被 Prompt Engineering 之外的系统工程问题折磨这篇文章应该能帮你少走不少弯路。先说清楚一个前提agent-skills 的定位是“技能层基础设施”它假设你已经选好了模型GPT、Claude、Qwen、Llama 都行也假设你有明确的任务流程它只负责把“技能”这件事做扎实。2. 核心设计技能不该是函数列表而是一等公民2.1 从“工具函数”到“技能对象”的思维转换很多人第一次做 Agent 技能时天然地把技能等同于函数。比如def get_weather(city: str): ...然后把它加进 tools 数组完事。这种思路在 demo 里没问题一旦技能数量超过 20 个、多个 Agent 共用一套技能、或者同一个技能在不同场景下需要不同策略时函数列表式管理就会全面失控。我在 agent-skills 里做的最重要决定是把每个技能建模成一个独立对象而不是一个函数。技能对象包含的不只是执行逻辑还有身份信息技能 ID、名称、版本、所属领域、作者描述信息给模型看的自然语言说明包括适用场景、触发条件、边界提示接口契约参数 schemaJSON Schema 格式、返回值结构、错误结构执行策略超时时间、重试次数、并发限制、缓存策略权限分组该技能归属的权限域决定哪些 Agent 可以用测试用例每个技能内置 smoke test注册时自动跑一遍这个设计的核心动机是模型在调用技能时不是靠“看函数名猜意思”而是靠技能对象的描述和 schema 来做决策。描述写得好的技能模型选对的概率高schema 定义得严的技能模型填参的错误率低。把技能当成“被模型阅读的文档对象”来设计而不是“被执行代码的函数”这是 agent-skills 和普通工具插件最大的区别。举个例子同样是“读取文件”这个能力。普通函数版本长这样def read_file(path: str) - str: ...agent-skills 里的技能版本是这样注册的skill Skill( idfile.read, version1.2.0, description( 读取文本文件的内容。适用于用户请求查看文件内容、 分析日志、读取配置等场景。不支持图片等二进制文件 如需读取项目中的多个文件请一次只读一个避免上下文过大。 ), params_schema{ type: object, properties: { path: { type: string, description: 文件绝对路径或相对项目根目录的路径 }, encoding: { type: string, default: utf-8, description: 文件编码默认 utf-8 } }, required: [path] }, handlerread_file_impl, timeout10, retries2, permission_groupfs_read, )看清楚区别没有后者并不是更啰嗦而是把所有模型做决策所需的信息全部显性化了。模型不用猜“path 到底传什么格式”因为 description 里写了不用猜“该不该一次读多个文件”因为描述里明确说了“一次只读一个”。这是我在大量实测后总结出来的经验模型在工具选择上的失误90% 以上源于技能描述信息不足或歧义而不是模型本身能力不够。2.2 技能分类四大类覆盖绝大多数 Agent 场景我在 agent-skills 里把技能分成四大类这个分类不是随便拍的而是根据实际项目中 Agent 的不同职责归纳出来的感知类技能负责从外部世界获取信息比如读取文件、查询数据库、调用搜索 API、抓取网页、获取系统状态。这类技能的共同特征是“只读不写”副作用最小因此也最适合做缓存和并行调用。在设计时我会特别注意返回体量的控制——查数据库的技能如果默认返回 500 行一次调用就能把上下文窗口冲爆。所以 agent-skills 里所有感知类技能都强制要求分页参数或限制条数参数并且默认值设得很保守。操作类技能负责对系统或外部服务执行修改操作比如写文件、发邮件、执行命令、下单、更新数据库记录。这类技能是风险最高的也是权限控制的重点。agent-skills 对操作类技能有一套强制约定凡是产生不可逆影响的技能都必须支持dry_run参数或confirm_before_execute标记。具体做法见后面的权限章节。推理类技能负责完成需要额外计算或结构化思考的子任务比如代码解释、正则生成、数学计算、文本摘要。这类技能本质上是在 Agent 内部再调一次模型或专用算法。把它们做成独立技能而不是让主 Agent 直接思考好处有两个一是可以把某个具体任务的 prompt 上下文隔离避免主 Agent 的上下文被碎片信息污染二是可以对子任务做专门的评估和优化。复合类技能负责编排前几类技能形成固定流程的工作流。比如“生成项目周报”这个技能内部会调用“读取 git log”“读取 TODO”“调用文本摘要”“写入 Markdown 文件”四个子技能。复合类技能是 agent-skills 里最复杂的部分它相当于把一段 Agent 的思考过程固化成可复用模板适用于确定性高、重复性强的任务。它的关键点是错误处理——任何一个子技能失败整个复合技能要有明确的降级策略而不是无限重试。这四类的划分并不影响接口统一性。对外所有技能都遵循同一个调用协议模型不需要区分自己正在调用的是哪一类对内每一类有不同的开发规范和安全约束。分类的本质是让“人”好管理而不是让“模型”区别对待。2.3 Schema 设计的细节把模型的出错率打下来我见过太多人轻视 schema 设计导致模型反复填出无效参数。在 agent-skills 里参数 schema 是整个技能对象里投入精力最多的部分。几个原则属性名要自解释。别用p、d、cnt这种缩写直接用path、date、count模型对完整单词的把握远高于缩写。中文场景下尤其要注意有些开发者喜欢用拼音缩写比如wjlj文件路径模型十次有九次猜不出来。每个属性都得有 description且要写明格式要求和取值范围。比如日期参数你写“日期”和写“日期格式 YYYY-MM-DD例如 2025-06-01只接受今天之前的日期”模型传参的准确率是截然不同的。我做过一次对比测试加了详细描述后日期参数的错误率从 18% 降到 3% 左右。required 字段宁多勿少。有些开发者为了“给模型更大自由度”把所有参数都设为可选结果模型经常不传关键参数技能执行时报错。我的经验是凡是业务逻辑上必须有的参数一律设为 required真正的可选参数再用default兜底。模型对“必填”的感知很强标注为必填后它几乎不会漏。返回值结构也要写成 schema。这不仅是给模型看的也是给下游流程用的。明确返回值结构之后复合技能在串联多个子技能时可以依赖稳定的数据结构做字段映射。agent-skills 里要求每个技能必须声明returns_schema做法和参数 schema 一样用 JSON Schema 描述。枚举值能写就写。如果某个参数只有有限几个合法选项直接用enum限定死。比如“排序方式”限定为[asc, desc]别让模型自由发挥。这套 schema 规范直接决定了 Agent 的稳定性。我常跟人说模型就像一个新来的实习生技能描述就是员工手册schema 就是规范表单。你手册写得含糊、表单没格子实习生当然到处犯错。3. 目录结构与注册机制让技能库像插线板一样易插拔3.1 一个可落地的目录布局agent-skills 本身的代码是一个 Python 包但技能是声明式的不绑定 Python 类型。完整的项目目录长这样agent-skills/ ├── skills/ │ ├── __init__.py │ ├── registry.py # 技能注册中心核心逻辑 │ ├── base.py # Skill 数据类与校验函数 │ ├── permissions.py # 权限分组与审批逻辑 │ ├── executor.py # 技能执行器处理超时/重试/并发 │ ├── cache.py # 感知类技能缓存层 │ └── contrib/ # 内置常用技能集 │ ├── file_ops.py │ ├── web_search.py │ ├── db_query.py │ ├── http_request.py │ └── code_sandbox.py ├── config/ │ ├── skills.yaml # 技能启用/禁用开关 │ ├── permissions.yaml # 权限组定义 │ └── models.yaml # 模型配置 ├── tests/ │ ├── test_registry.py │ ├── test_schema_valid.py │ ├── test_permissions.py │ └── test_e2e_flows.py └── examples/ ├── single_skill_demo.py ├── multi_skill_flow.py └── custom_skill_template.pycontrib/目录是官方维护的一批常用技能按领域分了模块每个模块里可能包含多个技能。比如file_ops.py里就有file.read、file.write、file.list、file.delete四个技能。这种布局的用意是把“技能定义”和“技能执行”分离。开发者新增一个技能时绝大多数情况下只需要在contrib/下新建一个文件或者往已有文件里加一个Skill(...)对象然后把它注册进 registry。业务代码、Agent 主流程、模型配置统统不用动。3.2 注册中心的实现思路注册中心registry.py是整个 agent-skills 的枢纽。它维护一张全局技能表提供注册、查询、加载、校验四大能力。核心数据结构是一张dictkey 是技能 IDvalue 是 Skill 对象。class SkillRegistry: def __init__(self): self._skills: dict[str, Skill] {} def register(self, skill: Skill) - None: # 同名技能冲突检查 if skill.id in self._skills: raise DuplicateSkillError(fskill {skill.id} already registered) # schema 合法性校验 validate_json_schema(skill.params_schema) validate_json_schema(skill.returns_schema) # 执行 smoke test if not skill.run_smoke_test(): raise SkillValidationError(fsmoke test failed for {skill.id}) self._skills[skill.id] skill def get_skill(self, skill_id: str) - Skill | None: return self._skills.get(skill_id) def list_skills(self, group: str | None None) - list[Skill]: ...这里有个关键设计注册时强制执行 smoke test。每个技能必须自带一个不需要外部依赖的迷你测试用例注册时真实跑一次。这么做的目的是防止“技能写好了但环境依赖不对”这种问题在运行时才暴露。我吃过一次亏有个技能在本地跑得好好的部署到容器里才发现少了个系统库Agent 在执行时直接崩溃。有了注册期 smoke test这类问题能在服务启动时就暴露。技能加载还支持从 YAML 配置做运行时开关。config/skills.yaml里的写法是skills: file.read: enabled: true cache_ttl: 300 file.delete: enabled: false web.search: enabled: true max_results: 5注册中心在启动时读取配置把enabled: false的技能过滤掉这样就能做到单个技能粒度的灰度发布。比如file.delete风险高先在测试环境全量开启验证没有问题后再在生产环境打开。3.3 技能执行的统一包装超时、重试、并发一个都不能少有了注册中心之后接下来是执行器。执行器的职责是保证所有技能以一致的方式被调用并把脏活累活超时、重试、信号处理、并发控制统一收口。agent-skills 的执行器对每个调用都套一层统一的协议def execute(skill_id: str, params: dict, context: CallContext) - SkillResult: skill registry.get_skill(skill_id) if skill is None: return SkillResult.failure(funknown skill: {skill_id}) if not permission_ok(skill, context): return SkillResult.failure(permission denied) # 感知类技能走缓存 cache_key make_cache_key(skill_id, params) if skill.cache_ttl and (cached : cache.get(cache_key)): return SkillResult.success(cached, from_cacheTrue) # 操作类技能 dry_run 检查 if skill.is_mutating and params.get(dry_run): return SkillResult.dry_run(skill.estimate_effects(params)) start time.monotonic() try: result skill.handler(**params) except TimeoutError: return SkillResult.failure(ftimeout after {skill.timeout}s) except Exception as e: # 自动重试 for attempt in range(skill.retries): try: result skill.handler(**params) break except Exception as retry_err: last_err retry_err else: return SkillResult.failure(str(last_err)) return SkillResult.success(result, elapsedtime.monotonic() - start)这个包装层的价值在日常运行中非常明显。没有它的时候每个技能自己处理超时、自己想重试逻辑写出来五花八门有了它之后所有技能的行为基线是一致的排错的时候只需要检查统一的 SkillResult 结构。3.4 权限模型让每个技能都知道自己的边界Agent 技能的权限管理是我觉得最不能省的部分尤其当 Agent 能操作文件系统、调用外部 API 的时候。agent-skills 的权限模型分成三层权限组permission group每个技能声明自己属于哪个组比如fs_read、fs_write、network、code_exec。组是静态定义的在permissions.yaml里声明。角色role每个 Agent 实例有一个角色角色决定了它可以使用哪些权限组。比如一个“数据分析助手”角色可能拥有fs_read、db_query、code_sandbox这三个组但没有fs_write和network。审批策略approval policy对高风险操作不是直接放行而是要经过一层确认。agent-skills 支持三级策略auto自动放行、confirm返回一个待确认信号由上游 UI 或人工决定、deny直接拒绝。比如file.delete这类不可逆操作我在配置里会设成permissions: roles: assistant_default: groups: [fs_read, db_query, code_sandbox] admin_assistant: groups: [fs_read, fs_write, db_query, code_sandbox, network] skills: file.delete: approval: confirm db.query.update: approval: confirm这个三层模型执行起来很直接执行器检查技能所属组是否在角色组的白名单里是则继续否则拒绝对于approval: confirm的技能执行器会先返回confirmation_required状态由 Agent 主循环决定是询问用户还是跳过。这套机制让我在给客户交付 Agent 时省了太多口水安全合规问题被提前挡在了架构层。4. 技能编排与上下文管理把多条技能串起来的正确姿势4.1 从“单次调用”到“多步流程”单个技能调用只会产生一次工具结果但实际业务里的 Agent 几乎都要走多步先查数据再分析再写文件。agent-skills 不强制你用某一种编排方式但我总结了两种最常用的组合模式。线性串联模式适合步骤固定、依赖关系明确的流程。用代码写进复合技能里Agent 只需要触发一次“生成周报”技能技能内部顺序执行四个子技能。skill Skill( idreport.weekly, description生成项目周报。自动收集 git 提交记录、TODO 状态、测试覆盖率并生成 Markdown 报告。, params_schema{type: object, properties: {}}, handlerhandle_weekly_report, )在这个 handler 内部你可以直接调用execute(git.log, {...})、execute(db.query, {...})等子技能。复合技能的好处是流程被固化不会被模型临场改动输出稳定。动态规划模式适合开放性任务Agent 需要根据 user query 自己决定技能调用序列。这是更接近“Agentic”的用法。在这种模式里agent-skills 提供的不是既定流程而是技能发现接口# 让模型从一堆技能中选择最合适的 candidate_skills registry.search(queryuser_query, limit10)registry.search会基于技能描述做一次向量检索或关键词匹配把最可能相关的技能挑出来塞进模型的 visible tools。这一步是我特别推荐做的——不要把几十个技能一次性全暴露给模型暴露太多技能会显著拉低选择准确率。根据我实测技能数量超过 30 个后模型选错工具的概率会明显上升。用检索先过滤一道把候选缩小到 5 到 10 个准确率和响应速度都会好很多。4.2 跨技能通信共享上下文还是传递结果编排多技能时另一个绕不开的问题是子技能之间的数据怎么共享。我试过两种方案。方案一是共享内存型——所有技能读写同一个上下文字典后执行的技能可以直接取前面的技能结果。优点是灵活缺点是隐性耦合严重技能 A 改了一个字段名技能 B 就炸了而且排查起来很痛苦。我在早期版本里用过这个方案后来彻底放弃了。方案二是显式数据传递——每个技能的输入输出都通过参数和返回值表达子技能之间的数据转换在编排层handler显式处理。比如“生成周报”这个复合技能先git.log拿到 commits再自己写几行代码把 commits 整理成摘要需要的格式传给text.summarize。这样数据流是透明的每个子技能可以独立测试。强烈推荐方案二唯一的代价是多写几行胶水代码但换来的可维护性是值得的。4.3 控制上下文溢出技能结果要“瘦身”这是我在实际运行中最常头疼的问题。大模型上下文窗口有限而技能返回值往往很长。一次全量文件读取可能就有几十 KB更别提数据库查询结果。如果 Agent 在对话过程中反复调用这类技能上下文很快就会被顶爆后续对话质量急剧下降。agent-skills 对这种问题有三个内置手段返回截断技能返回前先按上限截断超过的部分丢弃只在结果里加一个truncated: true标记。比如文件读取技能默认只返回前 8000 个字符避免整个文件全塞进上下文。摘要替代有些技能支持summarize参数。如果模型只需要文件大意就可以请求技能返回摘要而不是全文。这个技能在内部会调用摘要模型处理但对外接口一致。上下文压缩器在 Agent 主循环里对已经使用过的旧技能结果做压缩只保留与当前任务相关的关键信息。这部分我放在编排层做agent-skills 只提供压缩回调接口。这三个手段配合下来上下文溢出问题基本能控制住。我的经验数据是接入这些手段后长对话场景下上下文超限报警次数减少了 80% 以上。5. 测试与评估技能库不是写完就完事5.1 分级测试体系技能库是我见过最容易“裸奔”的模块很多人写完技能丢给 Agent 就不管了。agent-skills 的做法是至少分三级测试单技能测试每个技能有独立的单元测试mock 掉外部依赖验证参数校验、返回值结构、异常分支。这一步主要保底。模拟执行测试用真实 handler 但不联网、不写真实文件把返回结果记录到测试账户或临时目录验证和预期一致。这一步能抓出依赖问题。端到端评测这是最关键的。在真实模型上跑一组标准任务检查模型是否选择了正确的技能、是否填了合法参数、最终结果是否令用户满意。比如对“查询天气”“写周报”这类任务各准备 20 条测试用例跑完后统计技能选择准确率、参数合法率、任务完成率。我强烈建议把端到端评测自动化至少做到“每次改完技能描述或 schema 后跑一遍回归”。我遇到过太多次这种情况改了一个技能的 description本来是想让模型更准确选择它结果它反而把其他技能的调用也带偏了。没有回归测试这种细微劣化很难察觉。5.2 评测指标不能只盯“完成率”单看任务完成率是不够的。我常用的指标有四个技能选择准确率正确技能被选中的比例。低了说明描述有问题或技能间边界模糊。参数合法率模型传的参数通过 schema 校验的比例。低了说明 schema 描述不清。平均技能调用次数完成任务平均触发几次技能。异常偏高说明技能描述导致模型反复试探。失败恢复率技能执行失败后Agent 能正确重试或换用替代方案的比例。这些指标的作用在调优时非常明显。有一阵我感觉 Agent 表现“很笨”怎么都定位不到问题后来一看参数合法率只有 60%才发现是 schema 里有个属性的描述和实际实现不一致。改了描述后参数合法率直接跳到 92%整体任务完成率也上去了。所以别小看这类中间指标它们是定位问题的重要线索。5.3 迭代策略小步快跑灰度验证技能库的迭代不像普通代码改一个描述就可能影响模型行为所以不能“一把梭”。我的做法是改技能描述或 schema 时先在离线评测集上跑一遍对比改动前后的指标指标不降反升的再看日志抽样确认上线采用灰度策略比如新版技能先在 10% 的流量上用对比 24 小时指标后再全量每次改动都记录 changelog方便回溯这套流程看起来重但对一个超过 50 个技能的库来说没有它真的会乱。我见过同事直接在线上改了技能描述结果模型整体行为跑偏排查了半天才想起来是自己改的锅。6. 常见问题与排查技巧6.1 模型反复不选某个技能怎么办先别急着怀疑模型能力。按这个顺序排查技能 ID 是否简洁易读description 里是否说清了适用场景有没有和另一个技能的描述高度重叠我遇到最多的是描述太泛比如“查询信息”这种万能描述模型根本不知道什么时候该用它。把描述改具体加上触发条件示例往往就好了。6.2 技能参数频繁填错怎么办优先检查 schema缺省值有没有给格式有没有在 description 里写清楚取值范围有没有用 enum 限制常见错误是“日期只传了年月日但实现需要时分秒”“路径没写绝对还是相对”。把这类注意点写进 description参数准确率能提高一大截。6.3 复合技能总在某个子技能上卡住建议给复合技能加失败降级逻辑比如子技能失败三次后跳过它继续执行并在最终结果里注明哪些步骤降级了。还有一个常见坑子技能出现在主模型的可见工具列表里导致模型绕过复合技能直接调用子技能整个流程被打乱。解决方法是把复合技能内部使用的子技能标记为internal: true不让它们出现在模型可见工具中。6.4 技能执行慢拖累整个 Agent两个手段一是给慢技能设置缓存同参数的调用直接命中缓存二是对非关键路径技能改为异步预取Agent 在处理别的任务时预先加载数据。另外并行调用多个独立感知技能也能显著加速——agent-skills 的batch_execute接口就是为此设计的在保证技能间无依赖的前提下并发执行。6.5 技能权限配置被绕过的风险虽然模型本身没有主观恶意但模型可能因为理解偏差而调用权限之外的技能。你的配置不能写成“只要有白名单就全部放行”。操作类技能一律加approval: confirm对不可逆操作默认 deny这是基本底线。还有一个细节不要给 Agent 暴露它不需要的技能list_skills(group...)这个过滤接口就是为了让不同角色的 Agent 只看得到自己的技能子集。7. 说点我的真实体会agent-skills 这套体系做下来我最深的一个感受是Agent 开发里最花时间、最体现功力的不是怎么调 prompt也不是怎么选模型而是怎么把技能层这件“苦活”做好。技能描述写得详细一点、schema 定义得严格一点、错误处理做得统一一点、权限边界设计得清楚一点——这些细节单看都很琐碎但叠在一起就决定了一个 Agent 是能用还是好用。如果你也要搭自己的技能层我给你的建议很直接别追求一次设计到位先拿两三个技能跑通全流程再用一段时间积累问题然后迭代第二个版本。agent-skills 现在的目录结构和注册机制也不是一稿定下来的是在真实项目里磨了三轮才稳定。另外每个技能都配 smoke test 这个习惯建议从一开始就养成后面技能多了你一定会感谢当初这个决定。这套技能层的思路不仅限于 OpenAI 兼容接口的模型也不限于 Python 技术栈。核心思想——把技能当作可描述、可测试、可权限控制的一等公民——放到任何语言的 Agent 项目里都成立。你完全可以把其中的注册思路、schema 规范、测试分级照搬过去。如果你也在做 Agent 技能层拿这份复盘当个参考把适合自己场景的部分用起来就够了。
RELATED READING

延伸阅读

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