去年我们在给某个内部运营中台接智能体(Agent)能力时,一开始非常天真——把十几个函数的JSON Schema全部塞进System Prompt,再配上一句"请根据用户问题选择合适的函数"。结果模型经常选错函数,参数传得乱七八糟,prompt稍长一点就开始丢行为约束。后来我们花了差不多一个迭代周期,把"工具函数"重新梳理成了一套独立的技能系统(agent-skills),改完之后整个链路才真正稳定下来。这篇就把我们踩过的坑、最后沉淀出来的设计和实现思路完整分享出来。
这套方案的核心不是某个花哨框架,而是一套可复用的工程范式:技能如何描述、如何注册、如何调度、如何编排、如何测试。如果你正在做Agent相关开发,尤其是让模型去调用外部API、操作后台系统、执行复杂任务的场景,下面这些内容可以直接拿来参考。
1. 别再把函数塞进System Prompt了:技能层到底在解决什么问题
先聊聊我们当初为什么会从"堆函数声明"切换到"技能系统"。表面上的理由是prompt变长了模型容易"迷路",但深挖下去,其实是三种问题在所有工具调用型Agent里都会暴露出来。
第一个是上下文膨胀。一个函数的JSON Schema动辄几十行,描述里还要写清楚每个参数的取值范围、默认值、边界条件。塞十个函数就是几万字,模型每次请求都要重新处理一遍,延迟和幻觉都会上升。更无语的是,很多函数其实只在特定业务分支里用到,比如"售后改单"这个能力,用户没提售后的时候根本不该进入模型的选择范围,但堆在提示词里它就会产生干扰。
第二个是选择漂移。函数一多,模型经常在语义相近的技能之间摇摆。比如"查询订单列表"和"查询历史账单",这两个函数在描述里都写了"订单""查询",模型经常选错。这种问题靠改提示词很难根治,因为底层是描述文本的相似度干扰了模型的决策。
第三个是缺乏运行时约束。直接调用裸函数,参数校验、超时、重试、错误回传这些逻辑全散落在业务代码里。一旦模型生成一个不存在的参数值,函数直接抛异常,Agent链路就断了。更危险的是,如果某个技能依赖的外部接口响应慢,整个请求线程会被拖垮。
所以技能系统要解决的不只是"让模型选对函数",而是把一组能力封装成带边界、带协议、带生命周期的独立单元。类比一下:函数像是散落在桌面上的工具,而技能是每件工具都有专属的盒子,盒子上写着适用场景、使用禁忌、参数规则。模型不是从一堆裸工具里挑,而是从一个结构清晰的工具箱里取,取错了盒子上的标签也会提醒它。
2. 让模型看得懂技能:描述、Schema与边界的编写规范
这一步是整个技能系统的地基。很多团队做技能系统时犯的第一个错误,就是把普通接口文档直接拿来做技能描述。接口文档是给人看的,而技能描述是给大模型看的,两者的信息组织和表述逻辑完全不同。
2.1 技能命名的三原则
命名决定了模型在决策路径里的"记忆锚点"。我们最初用过一堆很"开发化"的名字,比如get_order_data、handle_refund_request,后来发现模型对这类名字不敏感,经常混淆。调整后的命名遵循三个原则:
- 动作前置:所有技能名以动词开头,
query_orders、create_refund、export_bill,让模型第一眼就知道这是"能做什么"的能力。 - 业务限定:名字里必须带业务域关键词,避免通用词。
get_order这种太模糊,改成get_trade_order和get_aftersale_order,两者立刻区分开。 - 拒绝缩写:模型对不常见的缩写很迟钝,
qry_ord_dtl这种名字等于给自己挖坑。宁可长一点,也要用完整单词。
2.2 描述的"触发条件优先"写法
这是我们在实践中学到的最重要的一条:描述的第一句话必须写清"什么时候该用这个技能",而不是"这个技能能做什么"。
拿我们后来沉淀的标准模板来说:
当用户想查看商品物流信息时使用。输入订单号或物流单号,返回快递轨迹和当前位置。 不要在其他场景使用,不要根据用户模糊的'查一下'来推测。 前置条件:订单号必须是12位数字;物流单号必须包含2个大写字母前缀。你看,第一句直接告诉模型触发条件,第二句是负向约束,第三句是前置校验。这样模型的工具选择准确率明显提升,因为它的强项是根据用户意图做匹配,而不是理解一段功能说明书。
2.3 参数Schema的字段学
参数Schema是模型最容易"自由发挥"的地方。我们吃过亏之后,定了几条硬规则:
required数组必须显式列出所有必要字段,缺失参数时技能直接拒绝执行,而不是让模型自己瞎补。- 给每个参数写
description,并且写清楚取值范围。比如status字段写"可选值:pending / paid / shipped / completed,不要传入其他值"。 - 设置
additionalProperties: false,从机制上禁止模型发明schema里不存在的字段。
下面是我们一个真实技能的注册描述,你可以直接照着这个结构调整:
{ "name": "query_trade_order", "description": "当用户想查看订单状态、物流信息或订单详情时使用。需要用户提供完整的订单号,订单号是12位数字。用户没有提供订单号时不要调用本技能,先向用户询问。", "parameters": { "type": "object", "properties": { "order_no": { "type": "string", "description": "12位数字订单号,例如:202401012345", "pattern": "^\\d{12}$" }, "include_items": { "type": "boolean", "description": "是否返回商品明细,默认false", "default": false } }, "required": ["order_no"], "additionalProperties": false } }2.4 好描述与坏描述的对比
| 维度 | 坏描述示例 | 好描述示例 |
|---|---|---|
| 第一句 | 该技能用于查询订单信息 | 当用户想查看订单状态或物流轨迹时使用 |
| 触发边界 | 无 | 用户没有提供订单号时不要调用,先询问 |
| 参数约束 | 订单号(字符串) | 12位数字订单号,例如202401012345,必须匹配^\d{12}$ |
| 负向约束 | 无 | 不要根据订单金额大小来推测订单状态 |
| 返回说明 | 返回订单对象 | 返回订单状态、创建时间、商品列表、物流轨迹四个字段 |
别小看这些差异。同样一个技能,描述写得好不好,直接影响模型在决策时是"一次选中"还是"反复试错"。
3. 技能注册表与运行时调度:一个最小可用的核心实现
技能描述解决的是"模型看得懂"的问题,这一节解决"代码跑得起来"的问题。我们最后实现的架构核心是三个组件:技能抽象基类、技能注册表、运行时调度器。
3.1 技能基类:把技能定义从业务代码里解耦
一个技能不仅仅是一个函数,它需要携带元信息、校验逻辑、执行逻辑和错误处理。我们用Python实现了一个版本,核心结构大概是这样的:
from dataclasses import dataclass, field from typing import Callable, Any, Dict import time @dataclass class SkillSpec: name: str description: str parameters: dict required: list[str] timeout: int = 10 class SkillResult: def __init__(self, ok: bool, data: Any = None, error: str = ""): self.ok = ok self.data = data self.error = error self.ts = time.time() class Skill: def __init__(self, spec: SkillSpec, handler: Callable): self.spec = spec self.handler = handler def validate(self, arguments: Dict) -> SkillResult: # 基于jsonschema做参数校验 try: from jsonschema import validate, ValidationError validate(instance=arguments, schema={ "type": "object", "properties": self.spec.parameters, "required": self.spec.required, "additionalProperties": False }) return SkillResult(ok=True) except ValidationError as e: return SkillResult(ok=False, error=f"参数校验失败: {e.message}") async def invoke(self, arguments: Dict) -> SkillResult: check = self.validate(arguments) if not check.ok: return check try: data = await asyncio.wait_for( self.handler(**arguments), timeout=self.spec.timeout ) return SkillResult(ok=True, data=data) except asyncio.TimeoutError: return SkillResult(ok=False, error=f"技能执行超时({self.spec.timeout}s)") except Exception as e: return SkillResult(ok=False, error=f"技能执行异常: {str(e)}")这里面有几个设计点是长期打磨出来的:
additionalProperties: False强制模型不能发明字段,之前因为没开这个开关,模型给订单接口传了一个transaction_id,接口直接500。- 超时控制放在
invoke里而不是业务函数里,保证每个技能都有兜底,不会拖垮Agent主流程。 - 校验失败返回结构化错误,后续调度器可以把这个错误原样回传给模型,让它自己修正参数。
3.2 注册表:技能世界的"索引"
注册表负责存储和查找技能。它不关心技能内部实现,只关心name -> Skill的映射和基于关键词的检索。
class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def register(self, skill: Skill): if skill.spec.name in self._skills: raise ValueError(f"技能重复注册: {skill.spec.name}") self._skills[skill.spec.name] = skill def get(self, name: str): return self._skills.get(name) def all_names(self): return list(self._skills.keys())注册表还有一个隐藏作用:方便做技能自助扫描。我们有个启动脚本会遍历技能目录,自动注册所有带@skill标记的模块,新加技能不需要改任何调度代码。
3.3 调度器:模型输出与技能执行之间的桥梁
调度器是整个运行时最核心的部分。它做的事情可以拆成四步:
- 接收模型返回的
tool_calls(或者我们自己约定的函数调用JSON)。 - 从注册表查找对应的技能。
- 执行参数校验并调用技能。
- 把执行结果(包括错误)拼装成观察(observation)消息,回传给模型继续推理。
伪代码如下:
async def dispatch(tool_call, registry, conversation): skill = registry.get(tool_call["name"]) if not skill: return { "role": "tool", "content": f"技能 {tool_call['name']} 不存在,请从已提供的技能中选择", "tool_call_id": tool_call["id"] } arguments = tool_call.get("arguments", {}) result = await skill.invoke(arguments) if not result.ok: return { "role": "tool", "content": f"技能执行失败: {result.error},请根据错误信息修正参数后重试", "tool_call_id": tool_call["id"] } return { "role": "tool", "content": json.dumps(result.data, ensure_ascii=False), "tool_call_id": tool_call["id"] }注意错误回传的措辞。我们一开始写的错误消息是"系统错误",模型看见这种消息直接懵,不知道该怎么修正。后来改成"请根据错误信息修正参数后重试",模型就会去看错误详情,自己把参数补对或者掉头向用户提问。
3.4 技能接入的目录约定
为了让团队协作更顺畅,我们把每个技能做成了一个独立的文件夹:
skills/ ├── __init__.py # 自动注册入口 ├── trade_order/ │ ├── spec.json # 技能描述与Schema │ ├── handler.py # 业务执行逻辑 │ └── test_cases.json # 回归测试用例 ├── aftersale_refund/ │ ├── spec.json │ ├── handler.py │ └── test_cases.json代码自动扫描skills/下所有含spec.json的目录并注册,这样新技能从开发到上线不需要动调度器主流程。我一个同事说这像"插U盘",写完技能代码放进去就能用。
4. 多技能协作:从单次Call到可编排的执行链路
单个技能能跑通只是万里长征第一步。真实业务里,用户一句话往往要触发多个技能按顺序执行。比如"帮我把上周发货但还没签收的订单找出来,汇总成一张表格发给我"——这句话至少涉及查询订单、筛选状态、生成表格三个能力。
4.1 三种编排模式
我们在实际项目中遇到过三种不同的多技能协作需求,分别用了不同方案:
顺序调用:上一个技能的输出作为下一个技能的输入。这是最基础的模式。比如先query_trade_order拿到订单详情,再把订单号传给query_logistics。实现方式就是让模型自己在多轮工具调用里完成,调度器只做透传。
条件分支:根据技能返回结果决定下一步走哪个分支。比如调用check_inventory之后,库存充足走create_order,库存不足走notify_user。这种逻辑如果完全靠模型自由推理,结果不稳定,我们倾向用代码写死分支规则,把模型从决策压力里解放出来。
并行调用:多个技能之间没有依赖,可以同时执行。比如用户既想查订单又想查优惠券,两个调用完全可以并发。OpenAI和Anthropic的函数调用协议都支持一次返回多个tool_call,调度器要做的是并发执行而不是串行等待。我们用asyncio.gather处理并发,整体时延几乎等于最慢的那个技能。
4.2 技能组合策略:让模型只决策,不推演
最初我们试图让模型自己决定"先查什么再查什么",结果非常不可控。模型经常跳步,比如还没查到订单号就直接去查物流,导致参数幻觉。后来我们发现一个规律:模型擅长选择下一个动作,不擅长规划一串动作。于是我们改了策略,把多技能流程拆成"决策-执行-再决策"的循环:
用户消息 → 模型决策(返回一个tool_call) → 调度器执行 → 把结果追加进上下文 → 模型再次决策 → 直到模型返回最终回答这个策略的收益是可控性大幅提升。模型每一步只做一个决定,错了也能在上一步基础上修正,不会一口气错到底。
4.3 状态传递与临时变量
多技能协作绕不开状态传递。我们做了一个轻量的SessionState,本质是一个字典,挂在会话上下文里,技能可以通过context.state读写共享数据。
class SessionState: def __init__(self): self._data = {} def set(self, key, value): self._data[key] = value def get(self, key, default=None): return self._data.get(key, default)一个典型的多技能链路是这样的:
用户: 把订单 202401012345 的商品列表生成一个摘要 模型: 调用 query_trade_order,参数 {"order_no": "202401012345"} 调度器: 执行查询,得到商品列表,写入 state["items"] 模型: 调用 summarize_text,参数 {"content": state["items"]} 调度器: 执行摘要,得到结果 模型: 返回最终回答如果不做状态传递,模型第二步就必须自己携带第一步的全部输出,一个长列表塞进参数里,既浪费token又容易截断。有了SessionState,模型只需要引用上下文里的key,轻量很多。
4.4 避免编排死循环的护栏
模型在多技能循环里偶尔会转不出来,比如查了三次订单都报错,它还在原样重试。我们给调度器加了三道护栏:
- 最大工具调用次数:单轮对话内最多执行8次技能调用,超出后强制结束,让模型直接给用户一个总结性回答。
- 重复调用检测:如果模型短时间内用相同参数调用同一个技能三次,并且每次结果相同,调度器直接报错:"检测到重复调用,请换一种方式解决问题或向用户澄清。"
- 错误累计阈值:连续三次技能执行失败,就不再回传错误给模型,改为回复"技能调用多次失败,请向用户说明无法完成并建议人工处理"。
这些护栏看着简单,但真实救过我们的命。有一次某个上游接口返回异常数据,模型卡在"查询-失败-再查询"的循环里,差点把下游服务打爆,最后是靠重复调用检测拦下来的。
5. 真实踩坑记录:误选、幻觉参数与超时风暴的排查链路
这一节全是真金白银的教训。每个问题我们都经历了从"线上告警"到"根因定位"再到"方案落地"的完整过程,写出来帮你少走弯路。
5.1 坑一:模型总是选错相似技能
现象:用户说"帮我查一下我买的东西到哪了",模型选了query_aftersale_order(售后订单查询),而不是query_trade_order(交易订单查询)。两个技能的命中场景差异很大,这个误选导致下游报错。
排查过程:我们先是翻了模型决策日志,确认每次误选都发生在两个技能描述文本高度相似的情况。然后把两个技能的description做了一下对比:
- 技能A:"当用户想查看订单状态、物流信息时使用。"
- 技能B:"当用户想查看售后订单处理进度、退款状态时使用。"
问题一目了然。用户说"买的东西到哪了"这个意图里,既出现了"订单"义群,又出现了"物流"义群,与两个技能描述都有重叠,模型就被绕晕了。
修复方案:我们做了一组改动,叠加之后效果很明显。首先,把技能A的触发句改成"当用户想查看已购买商品的物流轨迹时使用",把"物流"这个核心词前置。其次,在技能B的负向约束里加了一句"用户咨询商品配送进度时不要使用本技能,应选择交易订单查询"。最后还做了一个互斥规则:同一轮对话内,如果模型已经调用了A,就把B从候选集里临时摘除。
5.2 坑二:参数幻觉——模型编造不存在的订单号
现象:用户问"帮我看看所有待发货的订单",模型调query_trade_order时传了一个order_no: "202401999999",这个号码纯属捏造。
根因分析:查日志发现模型把用户消息里的"待发货"错误理解成了具体订单标识。更深层的原因是参数Schema里的order_no没有跟"可选/必填"语义表达清楚,模型误以为先随便填一个让它查询也行。
修复链路:
required数组里只保留必填参数,order_no在用户未提供时就不在required里。- 在
description中加一句"仅当用户明确提供12位订单号时才填写该字段,否则调用前先向用户询问订单号"。 - 调度器对模型返回的参数做了"用户消息回声校验"——把模型传入的关键参数值去用户原话里匹配,匹配不上且非枚举值时,打回让模型重新生成参数或者直接要求用户澄清。
第三个改动一开始只是为了治"参数幻觉",后来发现它顺带解决了很多无中生有的参数值问题。
5.3 坑三:超时风暴拖垮整个Agent服务
现象:某个技能依赖的第三方物流接口突然变慢,单次响应从200ms涨到8秒,然后Agent服务的请求队列开始堆积,最终所有技能响应都变慢,形成雪崩。
根因分析:问题不在技能本身,而在于调度器没有做"故障隔离"。当时我们的实现是每个技能请求分配一个线程池,但线程池大小没有限制,慢接口的请求全卡在等待上,占满了线程池,其他技能根本没有线程可用。
修复链路:
- 给所有技能加超时控制,单一技能最多等待5秒。
- 引入信号量(Semaphore)限制技能并发数,防止无限量请求冲进下游。
- 加熔断器:某个技能连续失败5次后,直接进入半开状态,10秒内不再真实调用,而是立刻返回"服务暂不可用,请稍后重试"。
- 把慢技能降级成异步任务,返回"正在查询,稍后通知"的中间结果,不让用户干等。
这次故障之后,我们养成一个习惯:每个技能上线前都必须压测,画出一条"最大并发-响应时间"的基线,高于基线就不允许随意外扩。
5.4 排查链路表格
| 问题现象 | 根因 | 修复手段 | 验证方式 |
|---|---|---|---|
| 技能误选 | 描述文本语义重叠度过高 | 触发条件前置、互斥规则 | 用100条历史用户问题做回归选型测试 |
| 参数幻觉 | 必要参数语义不清、缺少回声校验 | required精确化、用户消息回声校验 | 回放测试,统计参数合法率 |
| 超时风暴 | 无超时、无并发限制、无熔断 | asyncio.wait_for + Semaphore + 断路器 | 注入慢接口模拟故障 |
| 重复死循环 | 缺少工具调用次数上限 | 最大调用次数+重复检测 | 构造连续失败用例 |
6. 技能系统的体检方案:回归测试、日志与效果评估
技能系统上线后,真正拉开差距的其实是维护能力。Agent类项目最大的痛点就是"效果时好时坏",今天能跑通明天可能就挂。我们最终沉淀了一套"体检方案"。
6.1 基于历史用户问题的回归回放
我们做了一个离线回放工具:把线上用户的真实问题(脱敏后)作为测试集,每次技能系统有改动,就跑一遍回放,统计三个指标:
- 技能选择准确率:模型选的技能和标注技能是否一致。
- 参数合法率:模型生成的参数是否通过Schema校验。
- 任务完成率:最终是否回到用户消息且生成了合理的最终回答。
这个工具不需要真的请求外部接口,所有的技能执行都用mock数据,跑一次大概两分钟。我们规定:技能选择准确率低于90%就不允许发布。
6.2 技能级单元测试与Mock策略
每个技能目录下的test_cases.json是我们自己约定的一种轻量测试格式:
[ { "name": "订单号缺失时应该询问用户而不是调用", "user_utterance": "帮我查一下快递到哪了", "expected_skill": null, "expected_ask_params": ["order_no"] }, { "name": "完整订单号时应直接查询物流", "user_utterance": "订单202401012345的物流到哪了", "expected_skill": "query_trade_order", "expected_args": {"order_no": "202401012345"} } ]写清楚expected_skill和expected_args,改技能描述之后如果测试挂了,说明你的改动影响到了模型决策,通常能第一时间暴露问题。
6.3 日志链路与可观测性
Agent系统的调试比普通后端难很多,因为中间多了一次模型推理。我们给每个请求分配了一个trace_id,从用户消息开始,到模型决策、技能调用、结果回传,全程打日志。关键日志项包括:
[2025-01-15 10:23:45] [trace=abc123] model_decision 技能=query_trade_order 参数={"order_no": "202401012345"} 耗时=1200ms [2025-01-15 10:23:46] [trace=abc123] skill_invoke 技能=query_trade_order 状态=ok 耗时=210ms 返回记录数=3 [2025-01-15 10:23:47] [trace=abc123] model_final 回答=订单正在运输中,预计明天送达没有这套日志,很多问题根本没法定位。比如模型误选技能这种事,不把model_decision打出来,你根本不知道模型当时在想什么。
6.4 技能效果分版本评估
技能系统的效果会随模型版本升级波动。我们做过一个实验:同一个技能描述体系,在旧版模型上选择准确率是92%,换个新版本模型直接掉到78%。不是描述写得不好,而是新模型对描述的理解方式变了。
所以我们现在有一个惯例:每次上游模型服务商更换版本,不管他们承诺多大的性能提升,我们都会用自己的技能测试集完整回归一遍。这个动作的成本不高,但能避免很多"上线当天突然变笨"的尴尬。
最后分享一个我觉得很值钱的细节。写技能描述时,第一句永远不要写"该技能用于……",而是写"当用户提到……时使用"。我后来把这套写法教给组里的工程师,大家都说改完之后技能选择准确率涨了一截。技能系统的门槛不在代码,而在于你愿不愿意把模型的思考方式当作一种需要认真对待的设计输入。