☰
从堆提示词到技能体系:AI Agent工程化落地的关键设计
2026/10/28 16:31:55 网站建设 项目流程

做AI Agent一年多,我踩过最深的坑就是技能全堆在主提示词里。一开始觉得"Agent能调用什么工具、什么场景走什么流程"反正大模型都懂,写几个function description就行,结果项目一复杂,改一个技能要连带调整十几处提示词,出个线上问题都不知道是模型理解偏了还是技能定义错了。后面我把这套东西抽出来,做成独立的agent-skills技能体系,整个维护成本才真正降下来。这篇就把我沉淀下来的设计思路、文件结构、注册机制和排障经验完整梳理一遍。

可能有人问,agent-skills和普通的"工具调用""function calling"有什么区别?我的理解是:工具调用只是给模型一个可执行函数,而技能体系是在更高一层做"意图映射、参数契约、执行编排",把单次调用组合成稳定的能力。技能可以复用、可以组合、可以独立测试,甚至可以在不重启整个Agent服务的前提下热更新。对做客服机器人、自动运维Agent、数据查询助手、个人助理这类复杂任务的人来说,这套抽象是刚需。

这篇文章不止讲设计,还会把技能文件长什么样、参数Schema怎么写、加载注册怎么实现、意图识别偏了怎么调、技能老是误触发怎么排查这些实操问题全摊开聊。内容偏工程实践,但我会把关键原理用大白话解释清楚,刚接触Agent开发的朋友也能照着做,做了一段时间但觉得现有方案别扭的朋友,应该能找到一些优化灵感。

1. 与其堆提示词,不如搭一套技能体系

先聊聊我为什么从"大提示词流"转投技能体系。最早我做Agent是典型的一版贪吃蛇:系统提示词里塞满工具说明、业务规则、few-shot示例,Agent一顿分析,然后从一个大函数列表里选工具。Demo阶段跑得挺欢,等到接真实业务就露馅了——每个技能背后其实是完全不同的参数约束和返回值协议,全让模型"自由理解",就像让新来的实习生自己猜公司每个部门的办事流程,不出错才怪。

技能体系的核心思路,是把"Agent能做什么"和"Agent怎么想"这两层拆开。Agent的推理、规划、上下文理解仍然靠大模型,但"能做什么"变成了一份结构化的技能注册表:每条技能自带名称、触发意图描述、入参JSON Schema、执行函数入口、以及返回结果的标准结构。模型要做的,是先判断当前用户请求该命中哪条技能,然后把参数按Schema约束从对话里抽出来,剩下的执行逻辑全部落到后端代码里。

这样拆有个直接的收益:可观测性。以前模型说"我调用了工具A",但参数有没有传对、该走哪个分支,黑盒一样;技能体系下,意图命中、参数抽取、执行结果全部有日志可查。另一个收益是可复用,"订单查询"这个技能放在客服机器人里能用,放在数据报表Agent里稍微映射一下也能用,不用把提示词重写一遍。

你可能觉得,这不就是把function calling包了一层皮吗?我当初也这么想,实际做下来发现差别很大。function calling是"模型尝试调用某个函数",技能体系是"系统根据意图注册表选择一个可执行的技能包"。后者天然支持组合嵌套——一个技能内部还可以按子技能调度,比如"查订单"技能内部可能调度"登录态校验"和"订单库查询"两个子技能,这在纯function calling的扁平结构里很难优雅实现。

适合参考这套方案的场景我总结三条:第一,Agent的技能数量超过十五个,提示词已经开始互相污染;第二,技能变更频率高,产品隔三差五要调规则;第三,团队不止一个人维护Agent,需要清晰的代码边界。满足任何一条,都值得从提示词流迁到技能体系。

1.1 为什么把技能独立成库,而不是继续堆提示词

独立成库最直接的原因,是提示词里藏不下工程逻辑。技能不是一句"你能查订单"就完了,它涉及权限校验、参数补全、接口调用、错误重试、结果格式化。这些代码逻辑和数据定义塞进自然语言提示词,第一是没有类型检查,第二是没法单元测试,第三是每次修改都要重新跑一遍完整链路验证,成本高得离谱。

我拿一个真实案例说明。之前做一个内部数据查询Agent,业务方提了个需求:查询某业务线的日活数据时,如果结果环比波动超过20%,要在返回里附一条提醒。这个规则写在提示词里的话,模型经常时灵时不灵,有时候忘了加提醒,有时候波动只有5%它也瞎提醒。我把这条规则抽成一个技能的后置钩子,参数里校验"current_value、previous_value、threshold",技能执行完统一走一次波动检测逻辑,命中就自动在结果里append提醒文本。从那以后这类问题再也没有出现过。

技能库本质上是在Agent和大模型之间加了一层确定性。大模型负责理解自然语言、抽取意图、填充参数,这是它的强项;但大模型不擅长稳定地执行一套固定的、带条件分支的业务规则。技能库把这部分确定性逻辑接了过去,两边各干各擅长的活,整体稳定性一下就上来了。

1.2 这套思路适合谁、能解决什么具体问题

如果你是自己写脚本调API做Agent Demo,技能体系其实有些过度设计。但如果是下面这几种情况,我建议尽早切换:

第一,多技能场景的意图冲突问题。技能一多,模型很容易混淆。比如"查订单"和"退订单"是两个技能,请求是"我刚买的那个东西不想要了,能退吗",模型可能命中"查订单"只查不办,或者直接命中"退订单"跳过确认流程。技能库里可以定义技能之间的依赖和互斥关系,在意图选择时做一次偏好约束。

第二,权限边界问题。客服Agent和运维Agent如果共用同一套大模型和工具层,危险操作必须隔离。技能体系可以在加载时给技能打标签,按用户角色动态开放技能列表,没权限的技能根本不进意图候选集,模型再聪明也调不到。

第三,测试和验收问题。有了技能文件,我可以对每个技能写独立的测试用例:给一段用户消息,断言命中哪个技能、抽出哪些参数。这种测试比端到端Chat测试稳定得多,也快得多。我现在每次改完技能文件先跑一遍技能级测试,通过再上线,上线翻车率明显降了一截。

2. 技能体系的设计思路:三层结构怎么拆

我这套agent-skills方案,从逻辑上分成三层:意图映射层、参数契约层、执行实现层。每一层解决一个问题,层与层之间通过约定好的接口通信,改动任何一层不影响另外两层。这是整个技能体系最核心的架构决策,下面把每一层为什么这么设计、需要注意什么,一次说清楚。

这三层不是拍脑袋分的。最开始我试过把意图描述、参数定义、执行逻辑全写在一个Python类里,结果维护到三十几个技能的时候,看一个技能要滚动好几屏,而且一个技能里这三种信息变更频率完全不同——意图描述可能一个月改一次,参数定义一周改一次,执行实现可能一天改三次。放在一起必然互相污染。拆成三层之后,每一层的改动都被限制在自己的边界内。

2.1 意图映射层:技能描述决定模型能不能"看懂"你的技能

意图映射层是整个技能的"门面"。它的核心产物是一段触发意图描述(trigger_description),这段话直接决定大模型在什么情况下会选择这条技能。我在这个环节踩过的坑最多,一开始写描述特别喜欢堆说法:"用户可以查询订单信息,订单包括已下单、待发货、已发货、已完成、已取消五种状态,也可以查物流信息,物流信息包括揽收、运输中、已签收等"——这样写看起来很全,但模型反而容易错乱。

后来我改用**"用户问题类型 + 信息要素 + 排除条件"**三段式结构。举个例子,查订单技能的描述是:"当用户想查询订单的状态、订单列表、订单详情、物流进度时使用本技能。信息要素包括订单号、商品名、时间范围。不适用于下单、退款、修改地址、催发货等操作。" 这等于我告诉模型:什么情况该找我、我需要什么信息、什么情况你别来找我。效果比单纯堆场景好很多。

排除条件这个写法容易被忽略,但非常关键。甚至可以说排除条件比命中条件更能减少误触发。因为模型对"否定"比"列举"更敏感,你列了五种订单状态它不一定记住,但你写了"不适用于退款",遇到退款请求它反而不容易误命中查订单。而且排除条件还能起到技能之间分流的作用——我常常在两个相似技能的描述里互相写"本技能不负责xxx,请使用另一技能",模型在两个候选纠结时,反而能选对。

2.2 参数契约层:Schema写不好,Agent就像没有表格的数据库

意图层解决"调用哪个技能"的问题,参数契约层解决"调用的时候传什么参数"的问题。我强烈建议每个技能都定义一份JSON Schema描述入参,字段名、类型、必填性、默认值、枚举值、描述、以及字段之间的关联约束,全部写清楚。

为什么参数契约这么重要?因为大模型抽取参数的幻觉问题比意图误判还严重,特别是当用户说话含糊、指代词多的时候。一个中文口语请求"我上周买的那两本书,有一本还没发货,帮我看看啥情况",模型要正确抽出来:时间范围、品牌名、订单状态。如果Schema里没有明确"book_title"字段描述和格式约束,模型容易猜个大概就填进去,后面执行层拿到脏数据,要么查不到,要么查错。

参数Schema还有一个作用,就是执行前校验。模型抽完参数,后端最好先跑一次校验再执行业务逻辑。必填字段缺失就问用户,枚举值不在列表里就尝试做值映射,日期格式不对就做解析修正。这套校验逻辑和业务逻辑是分离的,所以参数契约层同时服务了意图抽取和执行实现两层,是整个体系的稳定锚点。

2.3 执行实现层:技能不是一次函数调用,是一个有生命周期的任务

最后一层是执行实现层。这一层所谓的"实现",不止是写一个Python函数,而是一个完整的执行生命周期,我习惯把它拆成五个阶段:预校验 -> 数据获取 -> 业务处理 -> 结果格式化 -> 后置钩子。

预校验阶段检查参数、权限、前置状态;数据获取阶段调用各种接口、数据库;业务处理阶段按规则计算或拼装;结果格式化阶段把内部数据结构转成用户能看懂的自然语言或卡片;后置钩子阶段做一些附加处理,比如刚才提到的波动提醒、敏感信息脱敏、操作日志记录。五个阶段各有各的职责,但在接口上统一为"输入一个技能调用上下文,输出一个技能执行结果",这样Agent调度层可以非常统一地去编排这些技能。

给一个执行实现层的通用接口伪代码,小项目可以直接拿来改:

class BaseSkill: name = "skill_name" def pre_check(self, ctx: SkillContext) -> CheckResult: """参数校验、权限校验、状态前置校验,返回校验结果。""" def fetch_data(self, ctx: SkillContext) -> Any: """拉取外部数据。""" def process(self, ctx: SkillContext, data: Any) -> Any: """核心业务逻辑。""" def format(self, ctx: SkillContext, result: Any) -> SkillResponse: """把结果格式化成统一结构。""" def post_hook(self, ctx: SkillContext, response: SkillResponse) -> SkillResponse: """后置处理:脱敏、提醒、记录、埋点等。""" def execute(self, ctx: SkillContext) -> SkillResponse: check = self.pre_check(ctx) if not check.passed: return SkillResponse.need_info(check.missing_params) data = self.fetch_data(ctx) result = self.process(ctx, data) response = self.format(ctx, result) return self.post_hook(ctx, response)

这五个阶段拆开的核心原因是:每种阶段的变化频率和风险等级都不同。业务规则天天改,数据获取偶尔改,格式化看运营需要改,后置钩子涉及审计和合规,基本不敢动。混在一个函数里,改一个格式化逻辑还要担心会不会动到数据获取逻辑,拆开之后彻底解除这种耦合。

3. 核心实现细节:技能定义文件与注册机制

设计归设计,落到工程实现上,我最建议用技能定义文件 + 技能注册器的组合。技能定义文件负责声明"技能是什么",注册器负责"把技能装进运行环境并开放给Agent"。这个组合的好处是,每一个技能都是可单独审查、单独测试、独立版本的数字资产,而不是散落在代码文件里的一堆类。

选择定义文件格式时,我用的是YAML。JSON太啰嗦,写复杂嵌套注释还费劲;Python dict直接写在代码里又没法独立做配置校验。YAML在可读性和表达力之间比较平衡,而且配合Pydantic做Schema校验非常顺。技能文件放在独立的目录下,目录结构是这样的:

skills/ ├── order_query/ │ ├── skill.yaml │ └── impl.py ├── refund_apply/ │ ├── skill.yaml │ └── impl.py └── common/ ├── order_service.py └── user_service.py

每个技能一个目录,skill.yaml声明元信息和参数契约,impl.py实现执行逻辑,公共的Service放在common里由各技能复用。这套结构我跑了近一年,基本不用为找代码发愁。

3.1 一个技能文件的结构拆解:照着改就能用

下面是我线下项目里一个精简过但完整的skill.yaml,拿"订单查询"技能当例子:

name: order_query description: > 当用户想查询订单状态、订单列表、订单详情、物流进度时使用。 信息要素包括订单号、商品名、商品品类、下单时间范围。 不适用于下单、退款、改地址、催发货、开发票等操作。 version: 2.3.1 author: agent-team enabled: true tags: [order, query, user-facing] parameters: type: object properties: order_ids: type: array items: { type: string, description: "订单号,格式 ORDER2024xxxx" } description: "用户提到的订单号,数量不超过10个" product_name: type: string description: "用户提到的商品名称或关键词" time_range: type: object properties: start: { type: string, format: date, description: "开始日期" } end: { type: string, format: date, description: "结束日期" } status: type: string enum: [pending, paid, shipped, completed, cancelled] description: "订单状态,用户明确提到时才有" required: [order_ids] timeout_ms: 3000 retry_policy: { max_retries: 1, backoff_ms: 200 } rate_limit: { max_calls_per_min: 60 } excluded_from: [refund_apply, order_cancel]

注意到几个我在实战中反复调整出来的要点。第一个是参数描述要写成"给大模型看的抽取提示",比如"用户提到的商品名称或关键词"就比"商品名"清楚得多,模型抽取时知道该去哪里捞这个信息。第二个是name字段和文件目录名保持一致,否则注册器加载时对不上,容易出隐蔽问题。第三个是excluded_from字段,它声明了这个技能和哪些技能是互斥的,注册器加载时会做一次交叉检查,发现两个技能互相排除但又允许同时候选时告警。

impl.py那边,核心就是实现BaseSkill的五个阶段:

class OrderQuerySkill(BaseSkill): name = "order_query" def pre_check(self, ctx): missing = [p for p in ["order_ids"] if p not in ctx.params] if missing: return CheckResult.fail(missing) return CheckResult.pass_() def fetch_data(self, ctx): order_ids = ctx.params["order_ids"] return order_service.batch_query(order_ids, user_id=ctx.user_id) def process(self, ctx, orders): # 业务规则:未发货的订单优先展示,已完成的排最后 priority = {"pending": 0, "paid": 1, "shipped": 2, "completed": 3, "cancelled": 4} return sorted(orders, key=lambda o: priority.get(o.status, 9)) def format(self, ctx, orders): cards = [self._to_card(o) for o in orders] return SkillResponse( content=cards, prompt_hint="如需退款或取消订单,请使用技能 refund_apply 或 order_cancel" ) def post_hook(self, ctx, response): # 脱敏:订单号中间几位打码后再返回 response.mask_fields(["order_ids"]) return response

这块代码不是展示语法,是想体现执行实现层里你能嵌入多少业务稳定逻辑。排优先级、脱敏、提示下一步动作,这些如果全部丢给模型自由发挥,效果一定忽好忽坏,但落到技能代码里,结果永远稳定。

3.2 技能加载与注册机制:让Agent手里永远只有"该有的牌"

技能文件写好了,接下来是一整个Agent能正常工作的关键枢纽——注册器。注册器负责扫描技能目录、解析skill.yaml、把impl.py里的技能类实例化,并维护一张全量技能注册表。Agent拿到这张注册表之后,才能开始做意图匹配。

注册器的实现上,我强调三个点:按标签隔离、按角色过滤、按版本热更新。按标签隔离指的是给每个技能打tags,比如order、payment、admin,某些场景启动Agent时只加载特定标签的技能。按角色过滤是更细的权限控制,用户是普通消费者还是客服专员,能看到和调用的技能明显不同,技能列表在放进提示词之前先过一次角色的过滤条件。这两点能保证Agent手里的"牌"始终是当前用户该拿到的牌,模型没有机会调不该调的东西。

热更新这块,我用的方案是版本号 + 注册表快照。每次技能代码或者yaml变更,CI自动构建新的技能包并推进版本号,Agent运行时检测到版本变化,会做一次全量重载。重载时不是在原注册表上改,而是先构建一个全新的注册表快照,全部技能实例化成功之后再原子切换指针。这样做的好处是避免技能在加载一半时被请求命中,产生半新半旧状态。

class SkillRegistry: def __init__(self): self._skills: dict[str, BaseSkill] = {} self._meta: dict[str, SkillMeta] = {} def load_dir(self, path: str): snapshot = {} for meta_file in Path(path).rglob("skill.yaml"): meta = SkillMeta.parse_yaml(meta_file) impl = import_skill_module(meta.name, meta_file.parent) snapshot[meta.name] = instantiate_skill(meta, impl) # 全部加载成功后再整体替换 self._skills = snapshot self._meta = {n: m for n, m in self._load_metas(path).items()} def get_available_skills(self, user_role: str, tags: set[str]) -> list[SkillMeta]: metas = [] for m in self._meta.values(): if m.enabled and m.has_tag(tags): if m.access_control.allow(user_role): metas.append(m) return metas def execute(self, skill_name: str, ctx: SkillContext) -> SkillResponse: skill = self._skills.get(skill_name) if not skill: raise SkillNotFound(skill_name) return skill.execute(ctx)

这段代码里有个容易被忽略的细节:get_available_skills只返回meta信息,而不是返回技能实例。因为Agent在做意图匹配的时候只需要技能名和描述,不需要执行函数;等模型命中了某个技能,再由调度层从注册表取实例执行。这个分离很符合"描述和执行隔离"的原则,也让每次请求时传给模型的技能描述尽可能精简,不浪费token。

3.3 技能冲突与优先级:两个技能都想接单,怎么办

技能一多,意图冲突几乎必然出现。比如用户说"帮我把那笔待发货的订单取消掉",按字面理解,"取消订单"是order_cancel技能的活,但需要先定位订单,也可以走order_query技能查出来再调order_cancel。两个技能都可能被模型选中,怎么让模型做正确决策?

我的经验是三层保障。第一层是技能描述互斥,每个技能都写清楚边界,这也是2.1里强调的。第二层是注册器统一定优先级,我在技能meta里加了priority字段,当模型对两个技能打分差距不大时,取优先级高的。实际生产中order_cancel优先级高于order_query,因为处理一个"取消"请求比展示一个查询结果更符合用户真实意图。第三层是编排兜底,在Agent调度层配了规则:命中order_cancel技能之前,如果参数里没有明确order_id,先自动补一个order_query内部调用,拿到order_id再继续取消流程。这个大招基本能覆盖上一层的漏网之鱼。

还需要提一个优先级和互斥的正确用法。在skill.yaml里可以加一个interactions块来显式声明,建议写成允许的并列组合,而不是禁止的组合,因为禁止描述容易遗漏边界情况。例如:

interactions: compose_with: [order_query] conflict_with: [refund_apply, order_cancel]

compose_with声明了这个技能可以内嵌调用哪些技能,conflict_with声明了意图冲突时需要做抉择的技能。调度层读到这两条,就会在决策时做顺序控制,而不是靠模型完全自由的发挥。

4. 实操过程:从零配一个"订单服务Agent"的技能

前面讲了设计原则和核心组件,这些是"道",这一节我们就真刀真枪搭一个"订单服务Agent",从场景拆解、技能清单、逐个实现到联调回归,把整个操作过程原原本本走一遍。这个案例我做过不下十次,每一次都会发现"看着简单、做着踩坑"的地方,所以会事无巨细地把关键细节写出来。

假设业务方需求是:做一个微信客服机器人,用户可以在对话框里查订单、催发货、申请退款、改收货地址。不需要下单,因为下单走小程序原生流程。这其实是特别典型的一个场景,四五个技能,十几个参数,正好能检验技能体系设计的合理性。

4.1 场景拆解与技能清单:一句话需求背后藏着多少边界

接需求第一天,我先把不太严谨的产品需求"用户能在对话框里处理订单问题"拆成可落地、可命中的技能清单。很多人直接开写代码,后面被模型误触发表情包式的错误引导,本质问题就是没做这一层拆解。

订单服务Agent,我最终拆成了五个技能:

技能名触发场景核心参数边界排除
order_query查订单状态、订单列表、物流order_ids / status / time_range不含退款
order_ship_notify催发货、问什么时候发货order_id不含查状态本身
refund_apply申请退款、不想要了想退order_id / reason不含取消整单
address_change修改收货地址order_id / new_address不含订单状态变更
human_service投诉、人工客服issue_desc不含常规咨询

这个表看着简单,拆的过程其实反复了三次。第一次我把"催发货"并进了order_query,后来发现业务逻辑完全不同——催发货要调物流触达接口给仓库发催发工单,order_query只读展示,不能混在一起。这也印证了前面说的"技能是按执行逻辑和业务目标拆的,不是按对话主题拆的"。第二次我犹豫要不要把refund_apply和order_cancel分开,业务方说取消整单走小程序,机器人只处理退款不退货不取消,那就只留refund_apply,并且明确order_query排除退款。第三次是human_service,这个技能没有实际API调用,但必须有——模型遇到维权、投诉类请求时,与其硬编一个不存在的"高级退款",不如稳定地把用户转接给人工,安全第一。

拆完之后,每个技能的skill.yaml和impl.py就是按模板填充的问题了。但填充时有一个坑必须提醒:huma_service这个技能不要给reason参数做枚举限制,用户申诉的理由千奇百怪,你用枚举就等于强制模型把用户的话扭曲成固定选项,反而丢失了转人工时最有用的原始描述。正确做法是reason直接收自由文本原样透出。

4.2 逐技能实现与参数设计:五个技能各有各的坑

一个一个来。第一个order_query,核心参数order_ids必填,但用户表达方式可能有几十种:"我的订单""我刚买的""那两本书""上周那单"。模型要能从这些表达里抽取订单号,唯一的办法是提供关联查询通道:技能里允许只传用户标识,由执行层通过user_id去查订单列表再过滤。所以我给order_query的required字段留了一条退路:order_ids和user_scope至少填一个,两条路都能查出结果。这个设计在参数契约层用oneOf表达,Pydantic的Schema可以校验。

第二个order_ship_notify,参数只有一个order_id。但这里业务规则有讲究:仓库每天只处理两批催发货请求,上午十点和下午四点,同一个订单24小时内不允许重复催。这两个规则我放在pre_check和后置钩子里做,不靠模型判断。pre_check时先查一遍Redis里的催发记录,命中24小时内已催发就直接返回"仓库已经在处理,请耐心等待",不进fetch_data。后置钩子负责写入催发流水。这些"规则感"极强的逻辑一旦交给模型,一定会出现有人催了八遍、有人反复催同一单的情况。

第三个refund_apply,参数是order_id和reason。这技能的第一个坑是要不要自动通过。直接把退款申请提交到财务系统风险太大,但先让模型回复"已提交退款申请"又是欺骗用户。最后我做成两段式:技能先做退款资格预判(订单是否可退、是否在退款时效内),可退则进入确认流程,等用户确认后再真正提交;不可退则说明原因并提供转人工通道。这个两段式不是靠模型对话去切,而是在技能执行里显式返回一个stage字段,调度层看到stage=pending_confirm就让Agent等用户确认,看到stage=confirmed才真正执行提交。

第四个address_change,参数是order_id和new_address。这个技能最隐蔽的坑是new_address必须做结构化解析校验。用户说"帮我改成我家地址"或者"寄到公司前台",如果模型把原话整个塞进地址字段,执行层拿着"我家地址"这种模糊信息去做修改,就是事故。所以我在格式化阶段之前加了一个地址标准化子流程,把模型抽出来的地址原串送地址解析服务,解析成功才继续,解析失败则反问用户要省市区+详细地址。真实场景里这一步骤起码拦截了一半以上的"地址修改"请求,防住了大量客服客诉。

第五个human_service,前面说了核心是透传用户的原始问题描述,不要做任何结构化压缩。另外这个技能还有一个容易被忽略的后置钩子:如果用户情绪词命中规则列表(比如"投诉""生气""太差""再也不"这类),或者触发条件成立,要自动附带一个用户侧"等待时间"提示,比如"预计排队3分钟",然后生成一条转人工工单推给运营后台。这个后置逻辑写在技能代码里,模型唯一要做的,是判断该不该使用这个技能,一旦用了,剩下的事全部确定性完成。

4.3 联调与回归验证:不是测"能不能通",是测"该不该偏"

技能全部实现完,联调阶段有三个层面要测。第一层是技能级单测,直接给Context调execute,断言返回结构。第二层是意图命中测试,输入一批用户话术,断言命中技能名是否和期望一致。第三层是端到端对话测试,模拟完整用户旅程,看Agent在技能切换、参数追问、结果反馈上是否自然。

意图命中测试这一步,很多人做得太随意,随便拿五六句话测一下就以为没问题。我建议至少准备50条话术,覆盖每个技能的正面用例、反面用例和模糊用例。反面用例尤其重要,它专门测试那些"看似能命中但实际不该命中"的场景。我举个例子,order_query的排除条件是"退款",那么测试集里必须有"我不想要了能退吗""这单必须退"这类话术,断言它们不能被order_query命中。模糊用例是"我那单被退了一部分,剩下的啥时候发",这种话术同时涉及refund_apply和order_query,实际应命中order_query去查剩余货物发货状态,或者命中refund_apply查退款进度,关键在于技能描述里对边界写得多清楚。

整个联调测完,已经不是"能不能跑通"的问题,而是预期行为的准确率。我在一张回归表里统计每个技能的正负样例命中率,低于90%的一律回到描述层去调。调完一轮再跑,通常三四轮能把正样例命中率提到95%以上,负样例误判率压到5%以下。这里有一个不太直观的经验:负样例误判一般不是描述写得不够细,而是描述写得太细——你把所有不该命中的场景都写进去,可能引起模型过度联想,误判反而更多。我通常上限只保留三条排除条件,多出来的用注册器层的conflict_with字段来控制,效果更好。

5. 常见问题与排查实录:这半年我踩过的坑全记录

实操部分讲完,我整理了这半年在技能体系落地过程中反复踩到的典型问题,每一条都附排查思路和最终解法。这些问题如果你提前知道,至少能少熬三个通宵。

5.1 技能描述越改越长,命中率反而下降

说到技能描述,有个特别反直觉的现象:很多人在意图命中率不达标时,第一反应是给描述加更多话,"既然是查订单的,我干脆把下单也算进去吧,反正都是订单相关"。我在真实项目里测过,描述加得越多,越容易把相近的意图吸过来,命中率不升反降。

排查思路:把技能的描述精简到只剩三句话——触发条件、必备信息要素、排除场景。尤其排除场景不要超过三条。做完之后用一组"最容易混淆"的用例去复测。我印象最深的是做电商客服Agent时,一个叫"售后退款进度查询"的技能和一个叫"申请退款"的技能,前者是查退款到哪一步了,后者是发起退款。一开始我给两个技能都写了长长一段描述,结果"退款到哪了""我的钱怎么还没退回来"这些话术经常被错误分配到"申请退款"技能,导致用户明明在查进度却被当成要发起退款,场面一度很尴尬。精简描述之后,两个技能各自命中率分别提升了12%和9%。

5.2 参数抽取:用户说"上一单"模型就是抽不出来

另一个高频问题是参数抽取的模糊指代处理。用户说"帮我看看上一单到哪儿了",模型经常把order_id抽成null。你可能会想,这个"上一单"模型应该推理出是最近一笔订单。但实测大多数模型配置下,它就是不会去自动做"查最近订单"这一步。

排查思路:不要把"上一单"这类指代完全寄希望于模型。我在参数Schema里给order_ids加了一个"取数兜底"标签,只要order_ids为空但存在user_context,就自动走"最近订单"查询逻辑。也就是说,模型抽不到order_id没关系,执行层拿user_id再查一次。这个兜底看起来简单,但把order_query技能的用户体验提升了一大截。

不过这个方案也有副作用:用户说"帮我看看别人的上一单"时,系统会直接用当前user检索,容易查出错误订单。所以兜底逻辑里必须加一个确认步骤,查出最近订单后先展示摘要并问用户"您指的是这笔订单吗",确认后才继续。多一次确认,少一大半客诉。

5.3 技能热更新踩坑:老技能还没退场,新技能就上了

技能迭代上线的时候,我遇到过更新完成后请求还在跑旧代码的问题。排查下来发现是注册器重载时机的问题:进程还在处理旧请求,技能实例被直接整体替换,导致执行到一半的请求在半新半旧的上下文里崩了。

排查思路:不能一次性load_dir替换注册表,要分阶段做。先把新技能实例化并放入"预备区",等存量请求处理完,再原子切换指针。这个切换窗口通常十几毫秒,配合负载均衡几乎无感。同时,技能实例内部要避免保存跨请求的状态,例外是像Redis/DB连接池这类全局资源,技能实例内创建的都是无状态对象。

我现在的热更新流程是:CI压缩技能包 -> 发布平台推送到目标机器 -> Supervisor触发reload接口 -> 注册器校验新技能包完整性 -> 原子切换。整个流程走完大约两秒,线上服务完全不用停。

5.4 Agent卡在"需要用户确认"的循环里出不来

这是我在多轮对话场景遇到的另一个经典问题,属于技能状态设计缺失。refund_apply技能是两段式的,第一段让用户确认,第二段真正提交。一开始我用ctx.params里的一个flag来回传,但模型经常把上一轮已经确认的状态搞丢,导致用户明明说了"确认退款",Agent却还停留在"您确认要退款吗"。用户重复确认两遍就开始发脾气了。

排查思路:把技能状态从模型传参里挪出来,放进会话级上下文存储。技能在pending_confirm阶段,把order_id和确认状态持久化到session store;用户下一次发消息时,意图识别发现命中refund_apply,先去session store取上一次的状态,如果已确认就直接跳到提交逻辑,不再问用户。这个状态迁移逻辑放在技能调度器统一处理,而不是让模型自己记住,是一个很关键的架构决策。状态一旦交还给代码管理,多轮对话就变得异常稳。

5.5 技能级排查速查手册

我把上面这些问题收敛成一张排查速查表,配合日志系统使用,能快速定位大多数线上问题。排查技能问题,只需要对技能命中日志、参数抽取日志、执行日志三级做对照。

现象可能原因排查动作
技能A总被错误命中描述排除条件不足精简描述,收紧排除条件
技能A和B频繁混淆两者场景重叠增加priority或conflict_with
参数老抽漏Schema描述不具体字段描述改为"抽取提示"句式
同一技能反复调用失败后置钩子或状态持久化异常查session store里的状态迁移
技能不生效/报错技能实例加载失败检查skill.yaml和impl类名是否一致

日志是这个排查流程的地基。我强烈建议在技能执行生命周期埋好三类日志:意图命中时记录模型选中的技能名和置信度;参数抽取完成后记录完整参数JSON;执行完记录执行耗时、阶段耗时和返回结构摘要。有了这三份对照日志,任何一次错误行为都能在三分钟内定位到是意图问题、参数问题还是执行问题。有关键事件的时候,最好再补一个全链路traceId贯穿Agent的整个会话流程,这样排查多技能编排时能串起完整上下文。

做技能体系这半年,我最大的一个体会是:Agent工程的复杂度不在模型,而在你怎么把确定性和不确定性恰当地切开。大模型负责它擅长的模糊理解,工程代码负责它不擅长的稳定执行,两者通过agent-skills这套技能库衔接起来,整个系统才谈得上可维护、可测试、可演进。每次看到有人还在用一个两万字的主提示词硬扛所有Agent能力,我都想劝一句:抽个半天,把技能列出来独立成库,收益远比想象中来得直接。

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

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

立即咨询