☰
Agent技能系统实战:从Prompt混乱到可插拔工具调度
2026/10/21 6:16:09 网站建设 项目流程

最近在做“agent-skills”这个项目时,我最大的感受是:智能体(Agent)应用真正难的往往不是模型本身,而是如何把模型的能力边界变成一套可以被管理、被复用、被测试的工程模块。这个项目的核心,就是给大模型驱动的Agent设计一套可插拔的“技能(Skill)”层,让模型在对话过程中根据任务描述自主选择技能、拆解步骤并完成调用。它解决的是Agent应用里最常见的三类痛处:prompt越堆越厚导致效果不稳定、工具函数多了之后管理混乱、不同业务场景之间能力无法复用。如果你正在做AI应用开发,或者刚接触函数调用(function calling / tool use),被“模型总是不按预期调用工具”“技能一多就乱成一团”这类问题困扰,这篇内容应该能给你一套可以直接抄作业的参考方案。

1. 项目拆解:Agent技能系统到底在解决什么问题

1.1 从单体Prompt到技能模块:一次被逼无奈的重构

最开始做Agent应用时,我也跟很多人一样,把全部能力塞进一个System Prompt里。比如让对话机器人既能查订单,又能查物流,还能处理退款申请、生成工作报表,所有指令、示例、注意事项全写在Prompt里。起初功能少,效果还行,但随着业务方不断提新需求,Prompt从几百字膨胀到几千字,问题就出来了:模型开始“遗忘”一些早期指令,甚至自己编造能力,明明没有对接某个接口,却假装调用成功了。

这次经历让我意识到,Agent的“能力”如果只以文字形式存在于Prompt中,本质上是在用模型的注意力做不可控的检索。后来我尝试把每个能力独立成函数,在对话时把“技能清单”以结构化方式传给模型,让它在需要时主动调用——效果明显改善。这也就是agent-skills项目最初的形态:一个能让Agent能力做到“即插即用、随时增删、独立维护”的技能调度层。

1.2 技能(Skill)到底是什么:我习惯用三层来定义

在搭这个系统时,我把技能拆成了三个组成部分,缺一不可:

  • 语义描述:告诉模型这个技能是做什么的、在什么场景下使用、需要提供什么信息。这部分决定模型“什么时候想到调用它”。
  • 执行逻辑:技能背后真正干活的函数或服务代码,决定模型调用之后能得到什么结果。
  • 接口契约:入参和出参的格式定义,通常用JSON Schema描述,决定模型“能不能正确地把参数填上”。

这三个部分合在一起,才构成一个完整的技能。如果只写函数、不写清楚描述,模型不知道什么时候该用;如果只写描述、不约束参数格式,模型又会频繁传错参。

这里顺便区分一下容易混淆的另外两个概念:插件(Plugin)和工具(Tool)。在我看来,插件更偏产品化的封装,往往带有自己的UI或配置项;工具则更偏过程调用,比如一个HTTP接口封装。而技能更像两者之间的中间层,既可以被模型动态发现,又可以独立部署和版本管理。这个抽象层次,是我后面所有设计的基础。

2. 整体设计思路:从技能清单到调度内核

2.1 为什么选择“注册表加模型自主调度”,而不是硬编码流程

设计技能系统时,第一个要决定的问题是:谁来决定技能的执行顺序?常见的做法有两类——规则路由和模型调度。规则路由是写死“如果用户说天气,就调用天气接口;说订单,就调用订单接口”,优点是稳定可控,缺点是每加一个技能就要改代码,而且用户的表达千变万化,关键词匹配很容易漏。

我最终选择以模型自主调度作为主流程,核心原因是:技能数量在5到20个这个区间时,模型对“什么时候该用什么技能”的判断能力已经相当可靠,而且描述写得好坏直接决定调度准确率。规则路由可以作为兜底,用在那些“必须走固定流程”的关键业务上。所以在agent-skills里,所有技能统一注册进一个注册表,模型根据用户请求动态选择,系统只保留最大轮数和安全校验这两个保险丝。

2.2 技能注册表:一个带“能力目录”的仓库

技能注册表就是整个系统的能力仓库,我用一个字典来维护,每条技能记录包含四个关键字段:

字段作用示例
name技能唯一标识,模型会用它来发起调用get_weather
description自然语言描述,写明功能和使用场景获取某个城市的实时天气,当用户询问天气、温度、降雨情况时使用
parametersJSON Schema格式的入参约束城市名必填,省份可选
func真正执行的Python函数返回天气JSON数据

注册表的这种设计有几个好处。第一,技能的新增和下线都只是增删一个字典条目,不影响其他技能;第二,每个技能自带描述和参数约束,生成模型调用指令时可以直接序列化成标准格式;第三,后续做权限控制、日志追踪都基于这份目录,比较容易扩展。

2.3 模型怎么“看到”技能:把技能清单变成一份菜单

技能清单不会一次性全部塞给模型吗?不一定。技能数量少时可以全量推送,技能一多,全量推送反而会稀释模型的注意力。我的做法是分两层:

  • 轻量场景:技能少于10个时,直接把完整清单拼进请求里,让模型在每轮对话中都看到所有可选项。
  • 重量场景:技能超过20个时,先对用户消息做一次轻量意图匹配或向量检索,只把最相关的5到8个技能注入当前请求。

本质上,这跟餐厅点菜一个道理:菜单只有一页时顾客可以全看完;菜单变成一本书时,你得先给顾客推荐几道招牌菜。模型也是一样的,一次给太多选项反而容易选错。

3. 核心实操:从零搭一个可用的技能调度层

3.1 技术选型:我为什么坚持轻量起步

这个项目我没有引入重型Agent框架,理由很简单:在验证核心逻辑阶段,框架带来的抽象和配置成本远大于收益。我只需要一个普通的Python环境、一个能发起HTTP请求的客户端,以及任意一家支持函数调用协议的大模型接口。先把主干流程跑通,再根据实际需要决定要不要引入更重的方案。

这里也给你一个建议:如果你打算在自己业务里落地Agent能力,初期尽量用最小依赖实现。技能调度的本质就是“模型出指令、系统执行、结果回填”,这个循环本身非常朴素,任何框架最终都要落到这个模式上。轻量实现反而更容易逐行调试。

3.2 技能注册表与装饰器:先让“挂技能”变得足够简单

为了让添加新技能的成本降到最低,我用Python装饰器封装了注册逻辑。下面是注册表的核心代码:

# registry.py from __future__ import annotations from typing import Callable, Any class SkillRegistry: def __init__(self): self._skills: dict[str, dict] = {} def register(self, name: str, description: str, parameters: dict | None = None): def decorator(func: Callable[..., Any]): self._skills[name] = { "name": name, "description": description, "parameters": parameters or {}, "func": func, } return func return decorator def list_manifest(self) -> list[dict]: """返回给模型看的技能清单,去掉func字段。""" return [ {"name": s["name"], "description": s["description"], "parameters": s["parameters"]} for s in self._skills.values() ] def run(self, name: str, arguments: dict) -> Any: skill = self._skills.get(name) if skill is None: raise KeyError(f"skill not found: {name}") return skill["func"](**arguments)

接着定义两个示例技能。这里有个细节:JSON Schema里的description同样重要,模型会根据字段说明来理解该传什么值。

# skills.py import json import time from registry import SkillRegistry registry = SkillRegistry() @registry.register( name="get_weather", description="获取指定城市的实时天气信息,当用户询问气温、降雨、风力、空气质量等情况时使用。", parameters={ "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如北京、上海、广州。" } }, "required": ["city"] } ) def get_weather(city: str): # 这里只做模拟,真实场景中可以替换成天气服务API调用 mock_db = { "北京": {"temperature": 18, "rain": False, "wind_level": 3}, "上海": {"temperature": 22, "rain": True, "wind_level": 4}, } data = mock_db.get(city, {"temperature": 20, "rain": False, "wind_level": 2}) return {"city": city, "forecast": data, "updated_at": time.time()} @registry.register( name="save_to_file", description="将一段文本内容保存到本地记录文件中,当用户要求记录、存档、写入文件时使用。", parameters={ "type": "object", "properties": { "content": {"type": "string", "description": "需要保存的文本内容。"}, "filename": {"type": "string", "description": "文件名,默认为notes.txt。"} }, "required": ["content"] } ) def save_to_file(content: str, filename: str = "notes.txt"): with open(filename, "a", encoding="utf-8") as f: f.write(f"{content}\n") return {"status": "ok", "filename": filename, "saved": True}

这段代码体现了两个设计要点:一是注册表统一管理技能的生命周期,二是技能函数本身保持纯粹的“入参出参”风格,不关心模型如何调度它。这让我可以单独对每个技能做单元测试,不需要启动整个Agent。

3.3 模型调度主循环:把“模型出指令”和“系统执行”串起来

技能调度层的心脏是一个主循环。流程是这样:把用户消息和技能清单一起发给模型;如果模型返回了工具调用指令,就执行对应技能,把结果以角色为tool的消息回填给模型;如果模型直接返回了文本回答,就结束对话。下面是核心代码框架:

# agent.py import json from typing import Any from registry import SkillRegistry def run_agent( user_message: str, registry: SkillRegistry, llm_client: Any, max_rounds: int = 5, ) -> str: messages = [{"role": "user", "content": user_message}] for _ in range(max_rounds): resp = llm_client.chat( messages=messages, tools=registry.list_manifest(), ) # 把assistant的响应追加进上下文,保留tool_calls信息 messages.append(resp.to_message_dict()) if not resp.tool_calls: return resp.content # 执行模型要求的每个技能 for call in resp.tool_calls: fn_name = call.function.name fn_args = json.loads(call.function.arguments or "{}") result = registry.run(fn_name, fn_args) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False), }) return "已达到最大调用轮数,任务终止。"

这里面有几个容易出错的细节,我特意强调一下。第一,assistant的tool_calls消息必须原样保留,否则模型分不清自己刚才说过什么。第二,tool角色消息里的tool_call_id必须跟assistant返回的call.id严格对应,这是模型把结果和调用对应起来的唯一凭证。第三,技能返回的content建议统一用JSON字符串,结构化的返回能让模型更好地提取关键信息去组织回答。

3.4 一个完整用例:让Agent自己走完一条技能链

为了演示效果,我用一个模拟用户请求跑了一遍完整调度:用户说“帮我查一下上海今天的天气,然后把这个结果保存成文件”。下面是实际日志的简化版本:

Round 1: user: 帮我查一下上海今天的天气,然后把这个结果保存成文件 assistant: tool_calls=[get_weather(city="上海")] tool result: {"city": "上海", "forecast": {"temperature": 22, "rain": true, "wind_level": 4}} Round 2: assistant: tool_calls=[save_to_file(content="上海今天温度22度,有雨,风力4级", filename="weather_notes.txt")] tool result: {"status": "ok", "filename": "weather_notes.txt", "saved": true} Round 3: assistant: 已为你查询上海今天的天气,并保存到 weather_notes.txt 文件。

可以看到,模型自己完成了步骤拆解:先查询天气,再根据结果生成保存内容。我没有写任何关于“先查后存”的硬编码,只是把技能清单摆在那里,模型就完成了任务编排。这也是技能层最大的价值:它让Agent从“执行指令”变成了“自己规划步骤”。

4. 排错实录:那些“模型为什么不听话”的瞬间

4.1 模型死活不调用技能?先从技能描述找原因

这是我在项目里遇到最多的问题:技能注册表没问题、代码也没问题,但模型就是不调用,或者在回答里“假装调用”了一通却没真正触发函数。排查下来,九成的原因出现在描述文本上。

对比一下这两种写法:

写法示例效果
含糊写法天气信息模型不清楚什么时候用,可能直接靠训练记忆回答
触发式写法获取指定城市的实时天气,当用户询问气温、降雨、风力、空气质量等情况时使用模型能准确判断触发场景
无参数说明城市必填模型凭感觉传参,甚至传“上海天气”这种整字符串
带Schema说明properties.city.description = 城市名称,例如北京、上海、广州模型按格式补全参数,准确率明显提升

我总结出一条经验:技能描述本质上是在给模型写“使用说明文档”,而且这份文档的读者是一个记性不太好但理解力还不错的人。你要告诉它这个工具什么时候能用、需要哪些信息、信息格式长什么样,最好再用一到两个例子辅助说明。磨刀不误砍柴工,把这一步做扎实,后面能少掉一大半调试时间。

4.2 技能返回结果太大:上下文被撑爆怎么办

第二个常见问题是技能返回的数据量过大。有一次我接了一个订单查询接口,一次能返回上千行记录,模型把这些内容全部读进上下文,后面对话直接报错。解决办法是给技能的执行结果加一道“后处理”:截断长文本、生成摘要,或者把完整结果写入临时文件,回传时只返回文件路径和摘要信息。

我习惯的做法是,在技能函数内部封装一层返回处理:

def truncate_and_summarize(data: Any, max_len: int = 800) -> dict: text = json.dumps(data, ensure_ascii=False) if len(text) <= max_len: return {"summary": text, "truncated": False} return { "summary": text[:max_len] + "...(已截断)", "truncated": True, "total_length": len(text), }

这样既保留了关键信息,又控制了上下文开销。值得注意的是,这个后处理逻辑最好跟技能主逻辑分离,单独写成可复用的工具函数,因为几乎所有技能都可能面临结果过大的问题。

4.3 同一技能被反复执行:幂等设计必须提前考虑

在真实场景里,用户可能在模型还没返回结果时点了重试,或者系统超时后自动重发请求,这会导致同一个技能被重复执行。如果技能本身有副作用,比如写入数据库、扣减库存、发送通知,后果就比较严重。

我在这套系统里引入了request_id机制:一次完整对话生成一个会话级request_id,技能执行时会带上这个ID。对于有副作用的技能,函数内部会先检查是否已经处理过这个ID,如果处理过就直接返回上次的结果,不再重复执行。对于存储型操作,则在上层加一张执行记录表,用request_id加技能名做唯一约束。这个问题越早考虑越好,不能等到上生产了再补。

4.4 死循环怎么掐断:调度层必须有安全兜底

模型在调度过程中也可能陷入循环:反复调用同一个技能,或者在一组技能之间来回切换,但就是不给用户最终答复。我见过最极端的情况,模型连续六轮都在调用同一个查询接口,只是参数微调了一下。

解决办法是在调度主循环里加上几道保险:设置最大轮数,默认5轮;累计token上限,超过就强制终止;连续相同调用检测,如果连续三轮都在调用同一个技能,就中止并提示用户简化需求。这些兜底逻辑我都会打到日志里,排查问题时能看到完整的调用轨迹,定位是描述问题还是模型抽风。

5. 从项目走向产品:还需要补齐哪些能力

5.1 技能权限与安全边界:不是所有函数都能被模型随意调用

当技能层从Demo走向生产,一个绕不开的问题是权限。模型调用技能时,本质上是在代替用户执行行为,如果技能涉及下载文件、读取隐私、发送消息这类敏感操作,就必须有权限控制。

我的方案是把技能分成几档:只读技能,比如查天气、查订单状态,模型可以自主调用;带副作用技能,比如写文件、改配置,需要先经过授权检查;高风险技能,比如删除数据、发送通知,必须走人工确认流程。系统在调度层加一个拦截点,对每类技能执行不同的校验策略。这个拦截逻辑跟注册表完全解耦,通过给技能打标签(skill tag)来实现,不会污染技能本身的代码。

5.2 技能版本管理与灰度发布:别直接改线上技能

技能代码和描述是会被反复迭代的。刚开始我图省事,直接修改线上技能函数,结果出现了“昨天还能用的请求,今天就报错”的尴尬。后来我引入了简单的版本概念:每个技能在注册时带一个version字段,调用方指定使用某个版本。新版本先在灰度环境中跑,用模拟请求验证调度效果,再逐步放量。

技能的语义描述变化尤其需要谨慎,因为模型对技能的理解完全依赖这份描述。改一个关键词,可能就改变了模型触发技能的频率。我的建议是,描述变更必须走评审,变更后至少抽一套历史对话跑回归,看调度准确率是否有波动。

5.3 我后面打算扩展的三个方向

这个项目做到现在,我还有三个明确的扩展方向。一是多模态技能接入,让Agent不仅能查文本数据,也能调用图像生成、语音合成这类能力,技能抽象本身并不排斥这些,只是参数描述会更复杂。二是多Agent协作,让每个Agent维护自己的技能集,通过消息机制把其他Agent的执行结果作为技能输出来共享。三是评测集沉淀,把每次技能调用的日志收集起来,标注正确的调用序列,逐步积累成一个回归测试集,用来验证任何prompt或描述层面的改动。

最后再说点我自己的体会。做完agent-skills这个项目,我最大的感触是:模型的能力调度并没有想象中那么“玄学”,它更像是在跟一个理解力强但需要明确指引的同事合作。你把技能描述当成API文档一样认真写,把返回结果当成接口规范一样认真设计,Agent的表现就会稳定得多。这个项目最初只是一个小实验,现在已经成为我所有Agent应用的基础设施,这种“小而美”的架构,让我在后续功能扩展时心里始终有底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询