☰
Agent技能化改造:从文本生成到可执行任务的关键架构与实践
2026/10/13 15:58:51 网站建设 项目流程

Agent技能化改造的核心逻辑,是让模型从“被动生成文本”转向“主动调用能力”。这听起来很玄妙,但只要把套路拆开,你也能在几天内给手头的Agent装上足够好用的“手和脚”。这篇内容来自我最近复盘的一个模拟项目——某企业内部知识问答Agent由纯聊天框改造为可执行轻量操作的智能体的全过程。我会把从架构设计到踩坑修复的完整链路写清楚,适合正在做Agent开发、整天被“本来想让它干活,结果只会唠嗑”困扰的开发者参考。

1. Agent技能栈:为什么“会说话”不等于“会办事”

1.1 从“生成文本”到“完成任务”的鸿沟

绝大多数大语言模型Agent在跑第一个Demo时都会遇到同一个魔咒:你问它“帮我把三楼会议室明天下午的预约改到后天”,它能流利地回复“好的,我已经帮您改好了”,但实际上会议室系统里的预约纹丝不动。原因是模型只学会了“表达完成”,而没有挂接任何“真实执行接口”。

我们在项目里反复讨论过这个问题,最后统一了认知:Agent的智能程度不取决于模型的推理上限,而取决于它外部可调用的技能下限。一个没有技能的Agent,本质上就是个排版精美的聊天记录生成器;只有挂上足够可靠的技能,它才从“嘴替”变成“腿替”。

我见到很多团队在这个阶段会陷入“堆工具”的误区:把一堆API全交给模型,期待它能自己搞明白什么场景调用什么。结果往往是一分钟能回答的问题,模型花了三个来回还在纠结调哪个接口,甚至编出一个不存在的接口名。真正的做法,是把“技能”当作一个一级公民来设计,而不仅仅是“附加工具”。

1.2 技能与工具、插件、工作流的边界

在开始写代码前,先把概念理清,不然团队里每个人嘴上说的是同一个词,脑子里想的是完全不同的东西。我习惯用下面的维度做区分:

  • 工具(Tool):最小粒度的能力单元,比如“获取天气”“发送邮件”“查询订单”。它只做一件事,不做编排。
  • 技能(Skill):围绕某个业务目标封装的能力集合,可以包含多个工具调用、一段固定推理逻辑、若干预置参数。技能是“面向任务”的,不是“面向接口”的。
  • 插件(Plugin):通常是技能的打包分发形式,强调可安装、可卸载、可版本化。可以理解为技能的“发行渠道”。
  • 工作流(Workflow):预先编排好固定顺序、固定分支的多步骤流程,中间一般是确定性的规则判断,模型不参与决策。适合“无脑执行”的场景。

我们可以简单点理解:工具是螺丝刀,技能是“换一个轮胎”的整套流程(包含用千斤顶、拆螺丝、换胎、装回轮胎),插件是装有这套流程的收纳盒,工作流则是汽车生产线上的装配流程。

我们的项目最终选择“技能”作为核心抽象,是因为知识问答Agent的业务逻辑天然需要把“查文档、查权限、提炼摘要、格式化输出”这几个动作组合在一起。把它们封装成技能后,既能单独测试,也能复用到其他场景,比直接堆工具接口好维护得多。

1.3 什么才是一个“好技能”的第一性定义

先说结论:一个好技能,必须让模型在“看不到实现细节”的情况下也能正确决定何时调用、传什么参数。换句话说,技能对模型的暴露面,应该只有“名字、功能描述、参数结构、返回声明”这四件事。

我在给内部团队做培训时长提到一个类比:技能之于Agent,就像一个新员工入职时拿到的岗位说明书——上面写清楚“这个岗位叫什么、负责什么事、需要什么资源、产出什么结果”,但不需要写“你这个岗位要用什么牌子的电脑、具体怎么用Excel”。模型的推理能力确实强,但它不该也不需要去关心实现层面的事务细节,那是代码的职责。

评价技能设计好坏的硬指标有三个:

  • 意图命中率:模型看到用户指令后,是否能准确挑中该调用的技能,不犹豫、不错选。
  • 参数完整度:模型是否能一次性地把执行任务所需的参数抽全,不再追问废话。
  • 结果可预期:技能执行完返回的内容,能否被模型直接用来生成最终回复,而不需要二次加工甚至推倒重来。

后面所有设计和代码,都是围绕这三个指标展开的。

2. skill注册与调度机制:从函数调用到能力路由

2.1 技能的四个核心组件

在我们设计的技能体系里,每个技能由四个部分组成:

组件作用示例
身份声明(ID)全局唯一标识,用于路由和日志跟踪meeting_booking_modify
描述卡片(Description)用自然语言说明技能的功能边界和适用场景“修改已有会议预约的时间,不可用于创建新会议”
参数协议(Schema)用结构化方式声明入参、类型、必填项、约束{ meeting_id: string, new_start_time: string }
执行器(Executor)真实干活的代码,接收结构化参数,返回结构化结果调用会议系统API的Python函数

这四部分中,最容易被轻视的是描述卡片。我测过不同写法的效果差异巨大:写“修改会议”和写“仅当用户要求调整已创建会议的日期或时间时使用,若用户要求新建会议请使用meeting_booking_create;本技能可处理单个会议的时间变更,不可处理周期会议”相比,后者意图命中率能提升接近三成。原因是模型在抉择调用点时,基本靠描述来区分相似技能,描述给的信息越足,“决策熵”越低。

2.2 为什么JSON Schema描述技能是关键

很多初学者不理解:函数调用直接写Python签名不就行了吗,干嘛非得整JSON Schema?其实这里涉及一个模型交互机制的问题——模型不是直接执行你的代码,它只是浏览“技能说明书”,它接触技能信息时看到的是纯文本序列,而JSON Schema本质上就是一份“机器与人共同可读的参数说明书”。

它的优势体现在三方面:

第一,结构化约束能显著降低参数幻觉。在Schema里明确写出"required": ["meeting_id", "new_start_time"]和"additionalProperties": false,模型在绝大多数情况下会严格按约束抽参,不再自作聪明地多传一个"reason"字段进去。

第二,类型和枚举本身带有语义信息。比如你声明"time_format": { "enum": ["RFC3339", "datetime-local"] },模型就能据此把“明天下午3点”换算成正确的格式,而不是随心所欲地输出一种模型自创的时间格式。

第三,Schema可以参与自动校验和报错定位。当Agent传回一个不符合Schema的参数时,我们能立刻在调度层拦截下来,返回一个精确的错误提示“参数new_start_time格式应为RFC3339”,而不是把错误一路带到业务API里才炸掉。

我强烈建议团队在实现技能注册时,把JSON Schema作为“唯一事实来源”。所有技能测试、文档生成、参数校验都从这套Schema派生,只维护一份定义,减少不同步的问题。

2.3 双阶段路由:把关键开关交给确定性程序

纯靠模型自由决定“该调用哪个技能”,在Demo阶段没问题,但在生产环境是场灾难。模型不是每次都会遵守规则,哪怕描述写得很清楚,它一紧张或遇到复杂任务还是会乱来。

我们最终采用的是双阶段路由,这也是整套架构里最值得抄作业的设计之一:

  • 第一阶段:规则预过滤。由一段确定性的Python逻辑根据会话上下文做粗筛,比如“用户提到修改会议,但当前会话没有获取过meeting_id,则优先触发meeting_query_autocomplete技能来补获取”“用户明确提到天气,则只把weather_get加入候选列表”。
  • 第二阶段:模型精挑。将预过滤后的候选技能列表和完整Schema交给模型,由它在候选范围内做函数调用级决策。

这个设计的本质,是把“不可控的模型自由选择”降级为“可控范围内的模型选择”。我用一组数据来说明效果——同样50个真实用户请求,在一次完整交付中对比“模型自由选择全部20个技能”和“规则预过滤后再让模型选3-5个技能”:

  • 意图命中率:自由选择为82%,双阶段路由为94%。
  • 平均调用延迟:自由选择为2.8秒(因为要传全部技能描述),双阶段路由为1.3秒。
  • 无效调用(模型选错技能导致执行失败):自由选择出现9次,双阶段路由出现了2次。

双阶段路由真正解决的问题,是把技能选择的概率性失误压缩到规则能覆盖的范围内。规则可能不完美,但它确定性强、可测试、可迭代。你要做的是把规则越修越细,而不是指望模型突然变聪明。

2.4 技能调用的上下文管理

另一个容易被忽视的点是上下文窗口的占用。每个技能描述加上Schema动辄几百个token,如果注册20个技能,光是描述就要占掉接近万级token。这会让本来就紧张的上下文窗口雪上加霜,还会造成“注意力稀释”——模型在处理用户问题时被大量无关技能描述干扰,反而忽略了真正的指令。

我们的处理策略是分层缓存:

  • 会话开始时,只加载被规则预过滤命中的候选技能的完整描述,其余技能只保留一句话摘要。
  • 进入某个技能的流程后,将技能的调用历史、参数状态、内部结果挂在专门的上下文槽位里,会话主线程只保留最终结论,不保留原始执行过程。
  • 技能完成后,将执行摘要(“会议已修改,原时间14:00,新时间15:30”)回填给主线程,原始API响应体直接丢弃。

这套做法相当于给Agent做了“工作记忆与长期记忆分离”,主线程始终只保留决策所需的最小信息。实测在跑十几轮连续技能调用后,回复质量没有明显下降,这在纯聊天式Agent里基本不可能做到。

3. 一个最小Agent技能系统的完整实现

3.1 项目结构与核心数据模型

为了让概念落到实处,我基于一个模拟项目X实现了完整的技能套件代码。这个项目模拟的是一个企业内部的“行政助手Agent”,需要支持查会议室、预约会议室、修改预约、查看日程等动作。下面是最核心的项目结构:

agent_skills/ ├── core/ │ ├── registry.py # 技能注册中心 │ ├── router.py # 双阶段路由逻辑 │ ├── schema_utils.py # JSON Schema加载与校验 │ ├── context.py # 技能上下文槽管理 │ └── executor.py # 技能执行器基类 ├── skills/ │ ├── meeting_create.py │ ├── meeting_modify.py │ ├── meeting_query.py │ ├── schedule_query.py │ └── mail_send.py ├── main.py # 编排入口 └── tests/

核心数据模型非常简单,用字典和dataclass就能支撑:

from dataclasses import dataclass, field from typing import Any, Dict, Optional @dataclass class SkillDefinition: skill_id: str description: str input_schema: Dict[str, Any] output_schema: Dict[str, Any] tags: list[str] = field(default_factory=list) @dataclass class SkillResult: skill_id: str status: str # success / error / blocked data: Optional[Dict[str, Any]] = None error_message: Optional[str] = None trace_id: str = ""

这里有个设计细节:SkillDefinition和SkillResult是纯数据对象,它们不携带任何执行逻辑。执行逻辑放在各自技能的执行器里,通过注册中心登记。这样做的目的,是让技能的“声明”和“实现”彻底解耦——在执行器还没写好的时候,声明可以先写好让模型侧联调,后台实现并行开发,大大压缩整体进度。

3.2 Step1: 技能注册与声明加载

注册中心的实现很轻量,本质上就是个带检索能力的字典:

class SkillRegistry: def __init__(self): self._skills: Dict[str, SkillDefinition] = {} self._executors: Dict[str, Any] = {} def register(self, definition: SkillDefinition, executor: Any): self._skills[definition.skill_id] = definition self._executors[definition.skill_id] = executor def get_definition(self, skill_id: str) -> Optional[SkillDefinition]: return self._skills.get(skill_id) def list_definitions(self, tag_filter: Optional[str] = None) -> list[SkillDefinition]: if tag_filter is None: return list(self._skills.values()) return [s for s in self._skills.values() if tag_filter in s.tags] def execute(self, skill_id: str, params: Dict[str, Any], trace_id: str) -> SkillResult: executor = self._executors.get(skill_id) if executor is None: return SkillResult(skill_id=skill_id, status="error", error_message=f"skill {skill_id} not found", trace_id=trace_id) try: data = executor.run(params) return SkillResult(skill_id=skill_id, status="success", data=data, trace_id=trace_id) except Exception as e: return SkillResult(skill_id=skill_id, status="error", error_message=str(e), trace_id=trace_id)

对所有技能统一走execute方法,是为了把异常捕获、日志埋点、参数校验全部收敛到一个入口。这样排查问题的时候,不用去翻每个技能内部的错误处理,直接在注册中心就能看到统一格式的报错。

技能加载我用了“目录自动发现”的方式:扫描skills/目录下的每个子模块,如果模块内定义了definition和executor两个变量,就自动注册。好处是每新增一个技能只需要新建一个文件,不用改注册中心代码,团队协作时减少冲突概率。

3.3 Step2: 意图识别与技能选择

路由模块是整个系统的“大脑”。我按双阶段设计拆成两个函数:rule_based_prefilter做规则预筛,model_based_select做精挑。

class SkillRouter: def __init__(self, registry: SkillRegistry): self.registry = registry def route(self, user_message: str, context: Dict[str, Any]) -> list[str]: # 第一阶段:规则预筛 candidates = self._rule_based_prefilter(user_message, context) # 第二阶段:模型精挑(实际使用时这里通过LLM接口调用) if len(candidates) <= 1: return candidates chosen = self._model_based_select(user_message, candidates, context) return [chosen] def _rule_based_prefilter(self, message: str, context: Dict[str, Any]) -> list[str]: # 简单规则示例:关键词 + 上下文状态 msg_lower = message.lower() candidates = [] if "会议" in message or "meeting" in msg_lower: if context.get("current_meeting_id"): candidates.append("meeting_modify") else: candidates.extend(["meeting_query", "meeting_create"]) if "日程" in message or "schedule" in msg_lower: candidates.append("schedule_query") if "邮件" in message or "mail" in msg_lower: candidates.append("mail_send") # 兜底:全量技能描述如果不超过阈值也可以进候选 if not candidates: candidates = [s.skill_id for s in self.registry.list_definitions()] return candidates

这里有个点值得展开:为什么meeting_modify要依赖current_meeting_id上下文?因为修改一个会议的前提是“得先知道改哪个会议”,如果用户只说“把明天的会改到下午”,第一个要做的技能应该是查会议列表,而且是在规则层强制排序的。这种前置依赖如果全交给模型判断,它偶尔会跳步骤,直接调改期接口然后报“缺少meeting_id”。规则层把这类“步骤依赖”锁死,是从架构层面杜绝低智错误。

_model_based_select的实现是组装一段精简提示词,把候选技能的description和input_schema全部暴露给模型,让它选出最佳的技能ID。

def _model_based_select(self, user_message: str, candidates: list[str], context: Dict[str, Any]) -> str: prompt = "请从以下技能中选择最符合用户意图的一个,只输出技能ID:\n\n" for skill_id in candidates: definition = self.registry.get_definition(skill_id) prompt += f"技能: {skill_id}\n描述: {definition.description}\n" prompt += f"参数: {json.dumps(definition.input_schema, ensure_ascii=False)}\n\n" prompt += f"用户消息: {user_message}\n\n" prompt += "候选技能ID列表: " + ", ".join([f'"{sid}"' for sid in candidates]) + "\n" prompt += "请直接输出一个技能ID,不要任何解释。" # 调用LLM接口 response = llm_completion(prompt, temperature=0) return response.strip().strip('"') or candidates[0]

这里我特意设置temperature=0,并明确要求“直接输出一个技能ID,不要任何解释”,属于模型交互里的经典“越约束越稳定”技巧。实测如果不加这句话,模型偶尔会输出一整段话,解析逻辑就得费劲处理。

3.4 Step3: 参数抽取与结果回填

技能选定后,还需要从用户消息中抽取出符合Schema的参数。这一环节我同样不是让模型自由发挥,而是先用Schema约束,再做规则修正:

def extract_params(user_message: str, skill_id: str, schema: Dict[str, Any]) -> Dict[str, Any]: prompt = ( f"根据用户消息提取调用{skill_id}技能所需的参数。\n" f"参数规范如下(JSON Schema):\n{json.dumps(schema, ensure_ascii=False)}\n\n" f"用户消息:{user_message}\n\n" "请只输出JSON对象,不要包含其他内容。\n" "注意:如果用户消息没有提供某个必填参数,请将该字段设为null,并不要编造。" ) response = llm_completion(prompt, temperature=0) params = json.loads(response) # 规则修正:处理相对时间表达 for key, spec in schema.get("properties", {}).items(): if "relative_time" in spec.get("x-extension", {}): if params.get(key): params[key] = resolve_relative_time(params[key]) return params

参数抽取是整个链路里最体现细节的地方。举例来说,用户说“明天下午3点”,直接让模型把它翻译成 “2025-01-15T15:00:00” 不是不行,但不同模型对“今天/明天”的基准日理解有时会出错。所以我在Schema里增加了x-extension扩展字段,标记这类“相对时间”参数,抽取完再做一次规则修正。把“语言表达”和“机器格式”之间的转换永远放在最底层,不交给模型去自由发挥。

执行完技能后,结果不会直接裸奔到用户对话里,而是先经过“可读化摘要”这一步。这也是我们在实践中发现的重要设计:模型用自然语言组织最终回复时,给它一份“已经整理过的结果”远比给它“原始JSON响应”靠谱。

def format_result_for_reply(result: SkillResult) -> str: if result.status != "success": return f"很抱歉,操作未能完成。原因:{result.error_message}" data = result.data if "message" in data: return data["message"] # 兜底:把JSON转成简单陈述 summary = ";".join(f"{k}={v}" for k, v in data.items()) return f"操作已完成:{summary}"

这个函数的存在,是让“模型组织自然语言”和“技能返回结构化数据”之间有一条清晰的翻译边界。你可以把技能想象成“后端接口”,把模型想象成“前端页面”,后端接口不能直接渲染到页面上,必须经过前端组件的适配层。

3.5 Step4: 循环推进与终止条件

Agent和多步技能的另一个核心难点,是“一个技能执行完,是否要继续调用下一个技能”。比如用户要求“帮我查下明天有什么会议,如果是项目评审会就改成下午3点”。这个任务天然包含查询和修改两个技能,且修改是否执行取决于查询结果。

我们采用了一个非常克制但足够有效的循环机制:每一步执行完后,将最新状态喂给模型,让模型决定是继续调用技能、还是生成最终回复。循环必须设置最大步数上限和强制终止条件。

MAX_STEPS = 5 def run_agent_pipeline(user_message: str): context = {} steps = 0 router = SkillRouter(registry) while steps < MAX_STEPS: # 1. 判断是否该结束 should_finish = analyze_whether_finish(user_message, context) if should_finish: break # 2. 路由选择技能 candidate_ids = router.route(user_message, context) if not candidate_ids: break skill_id = candidate_ids[0] # 3. 抽取参数 definition = registry.get_definition(skill_id) params = extract_params(user_message, skill_id, definition.input_schema) # 4. 执行技能 result = registry.execute(skill_id, params, trace_id=f"trace_{steps}") # 5. 更新上下文 context["last_skill"] = skill_id context["last_result"] = result.data if result.status == "success" else None if skill_id == "meeting_query" and result.status == "success": meetings = result.data.get("meetings", []) # 将查询结果中最匹配的一项设为当前会议ID context["current_meeting_id"] = meetings[0]["id"] if meetings else None steps += 1

这套循环逻辑有两个关键设计:一是每次执行都会把结果写进context,为后续技能提供前置数据;二是设置了最大步数,避免Agent在复杂任务里陷入“调用-失败-重试-再失败”的死循环。

3.6 实测跑通的效果对比

我用20个真实业务请求分别跑了“纯LLM工具调用”和“上述技能系统”两个版本,汇总对比如下:

评测维度纯LLM工具调用技能系统
任务完成率75%95%
平均交互轮次3.5轮1.8轮
参数错误率15%5%
无意义调用占比12%2%
单任务平均延迟6.2秒4.1秒

这个对比是在小样本下调出来的,虽然谈不上严谨的AB测试,但至少能说明一个趋势:结构化技能系统配合规则路由,在可执行任务的可靠性和效率上,是明显优于把所有工具堆给模型自由选择的。关键是它还把“不可控的模型随机失误”往“可控的规则确定性失误”方向推了一大步——后者可以快速修规则修掉,前者只能干瞪眼。

4. 为什么我最终放弃了“所有技能平铺”的注册表

4.1 技能平铺带来的选择灾难

项目初期,我把全部技能注册到一个公共注册表里,每个Agent都能看到全部技能。这样做的好处是“民主”,坏处却是“灾难”。印象最深的一次,是用户让Agent“写一封邮件给项目组成员”,Agent却鬼使神差地调用了schedule_query去查日程——因为它被一大堆技能描述绕花了眼。

为什么会出现这种“低级失误”?因为当候选技能超过15个时,模型在“区分相似技能”上会变得非常不稳定。这本质上是一个检索问题,不是推理问题——技能描述过长、过相似、过密集,都会稀释模型的注意力。

我一度试图通过“把描述写得更长更细”来解决,结果适得其反:上下文越塞越满,模型选择准确率反而下降。后来才想明白,正确的解法不是把说明书写得更厚,而是让候选清单变得更短。只有先通过规则把技能候选压缩到3到5个,精确选择才成为可能。

4.2 技能分层的核心:按角色区分技能

重新设计后,我把技能分成了三个层次:

  • 基础技能:大多数任务都通用的,比如search_docs、get_user_info、log_action。这些技能比较“底层”,一般不直接响应用户意图,而是作为中间步骤被其他技能调用。
  • 领域技能:针对某个业务域封装完整的任务能力,比如meeting_booking、expense_apply、mail_send。它们是面向用户意图的“主入口”。
  • 编排技能:负责调度其他技能的“元技能”,比如trip_planning会依次调用flight_search、hotel_search、schedule_check。这类技能里有定制逻辑,不是简单组合。

分层之后,每个Agent不再看到全量技能,而是按自己的任务角色挂载不同的技能视图:只处理行政任务的Agent只暴露行政相关领域技能和少量基础能力;只处理研发辅助的Agent则挂载代码相关技能。这其实对应业界常说的“RAG式的技能检索”:先通过角色过滤技能,再通过规则过滤技能,最后才轮到模型精挑。

4.3 权限与依赖注入:技能的自控边界

实践中最让我头疼的问题,是技能之间的“越权调用”。举个例子,在早期版本里meeting_query可以直接调user_info_query查某个用户手机号,这在技术上没问题,但在业务里是个“权限后门”——用户问“查一下会议列表”,技能却悄悄捞了一大堆参会人手机号。

要堵住这个口子,靠“技能自觉”是不现实的。我的做法是把技能执行器的所有外部依赖,全改成显式注入:

class MeetingQueryExecutor: def __init__(self, auth_service, meeting_repo): self._auth = auth_service self._meeting_repo = meeting_repo def run(self, params): user = self._auth.get_current_user() if "user_info_query" in params: # 此处彻底禁止:执行器内部不允许直接发起另一个技能 raise PermissionError("meeting_query cannot call user_info_query directly") return self._meeting_repo.query(user, params)

更彻底的设计,是在注册中心维护一张“技能依赖矩阵”,声明“技能A允许调技能B”,调度层直接拦截矩阵外的调用。虽然这套矩阵维护起来有点繁琐,但老年间出过一行“技能相互调用成环”导致的调度死循环事故,从那以后我觉得这张矩阵省不得。

4.4 版本化升级:直接修改技能是最高风险操作

最后一个想分享的教训是:千万别直接修改一个已经在线上运行的技能。项目中期,因为会议室字段加了楼层信息,开发同事直接改了meeting_query的返回结构,结果所有依赖“旧字段名”的上游技能一夜之间全部报错,用户收到一堆“找不到rooms.floors”的异常信息。

现在我对技能实施“只增不改”的版本策略:每个技能带version字段,升级时新建一个版本ID,例如meeting_query_v2,并保留meeting_query_v1至少一个迭代周期。路由层通过配置中心决定流量分配——先切5%流量到v2,观察报错率,没有异常再逐步放量,最后再下线v1。

虽然这样做在代码冗余上不太“优雅”,但它的价值在于:技能升级的“爆炸半径”永远是可控的。Agent技能体系最尴尬的地方不是功能少,而是某天你改了一个“无关紧要的小地方”,然后整个链路静悄悄地坏给所有人看。

5. 坑与解法:实测中遭遇的三类典型故障

5.1 故障一:上下文膨胀导致模型“看不到”关键指令

现象非常典型:Agent跑着跑着,用户明明说“把会议改到明天下午”,它却回答“请问您要改哪个会议?”,甚至把用户一开始就给出的会议名称给“忘”了。排查下来发现,不是模型智力下降,而是上下文里被技能描述和过程日志塞得满满当当——模型在处理超长上下文时,对尾部用户指令的注意力权重会显著变低。

我采取的解法是“上下文精简三层过滤”:

  1. 主线程只保留最近2轮用户-助手对话摘要,不再保留完整的历史消息。
  2. 技能描述只在候选路由阶段出现,路由完成立即从上下文回收,不让描述“残留在”后续流程里。
  3. 技能执行日志不回流到主线程,只保留一份精简的“事务摘要”(改了什么、什么时间、结果如何)。

这个优化上线后,连续长会话的指令跟随准确率恢复到了短期会话的95%以上。我的通用建议是:给Agent的每一段信息设定“有效期”,过期即删,绝不让历史垃圾静默地带偏当前决策。

5.2 故障二:参数幻觉——模型偷偷补必填参数

另一个让人抓狂的故障是:用户发来一条简单的“帮我记录一下明天上午10点和李四开会”,模型在抽取meeting_create参数时,不仅填了会议时间、会议人,还自作主张地填了"location": "会议室A"和"reminder_minutes": 15,而这两个字段用户根本没提过。看起来“贴心”,实际是灾难——用户不在会议室A,且他压根没要求提醒。

这事的根因是模型在训练时养成了“补全缺失信息”的习惯,但任务执行场景里“编造参数”远比你想象的危险。我的修复组合拳有三招:

  • 在Schema里显式声明"additionalProperties": false,不给模型多传字段的空间。
  • 在抽取提示词里加硬约束:“如果用户未提供某参数,你必须将该字段设为null,禁止猜测或使用默认值。”
  • 增加后置校验层,凡是Schema校验不通过、或者包含未声明字段的请求,一律拦截并让模型重新抽取。

三招一上,参数编造率从我观察到的10%直接降到1%以下。建议所有做Agent技能调用的团队,把这套约束当成默认配置,而不是等出了事故再加。

5.3 故障三:无限循环与成本失控

有一次测试人员提交了一个诡异需求:“帮我安排一场会,时间就用邮件里提到的那个”,Agent当场陷入死循环:先调mail_search找邮件,找到了一个含“会议邀请”的邮件,然后调meeting_query查会议,结果查到了另一个会,又触发mail_send发邮件通知,通知完又调schedule_query查日程……一轮又一轮,直到我们手动终止。

这类故障的本质是“没有终止条件”,Agent把“永远有下一步”当成了使命。我加了三个保险:

  • 循环计数器:单次用户请求,最多允许5步技能调用,超出即终止并生成“抱歉,这个任务需要拆分为多个子任务后再试”的回复。
  • 代价看门狗:统计每个技能调用的token消耗和API调用次数,单次请求累计调用成本超过设定阈值时,自动熔断并生成“成本超限”提示。
  • 去重保护:在上下文里记录已经调用过的技能ID和参数摘要,如果同一技能的同一参数组合被重复调用超过2次,直接判定为死循环并中断。

这些都是“土办法”,但非常管用。大型语言模型应用里最贵的bug不是答错,而是没人喊停的无限自转。

5.4 踩坑排障的快速定位法

最后总结一下定位技能调用故障的通用链路。无论问题表现为“技能没调用”“调用了但参数错”“参数对了但执行失败”,都按下面这张表逐层检查:

故障层检查点常见根因
路由层规则预过滤的候选是否符合预期关键词规则不完整,兜底全量导致选择困难
抽取层模型抽取的参数是否符合Schema提示词约束不严,缺少additionalProperties=false
校验层参数是否通过了后置校验相对时间未解析,枚举值格式不统一
执行层技能内部是否抛异常API密钥失效、依赖服务变更、字段不兼容
回填层结果是否正常转成摘要返回结构里缺少message字段,兜底逻辑写错

排查时永远先往上游看。因为下游执行层的错误往往只是“表象”——真正的根因发生在路由选错技能,或者参数抽错,把错误一路带到了服务端才爆出来。

6. 性能、安全与可观测性:技能系统进入生产环境的三个前提

6.1 降低技能选择噪音的三个工程实践

一是技能描述的“语义索引”置于规则之后。不要一上来就做向量搜索找技能,先用业务关键词规则粗筛一遍,再对剩余少量候选做相似的语义匹配。这样既快又不易跑偏。

二是相似技能的“对比式描述”。如果两个技能非常容易混淆,与其分别写各自的描述,不如直接在描述里加上“与XX的区别”。例如meeting_modify里写明“本技能只修改已有会议,不创建会议,需要创建请用meeting_create;本技能不直接接受meeting_id,请先调用meeting_query获取”。这一步能让容易踩踏的技能边界清晰很多。

三是冷却机制。同一会话内同一个技能如果已经连续失败两次,自动将该技能标记为“不可用”并禁止选择,同时触发一次上层兜底逻辑。这能有效避免Agent在同一条错误路径上反复打转。

6.2 技能安全边界:看不见的风险最致命

技能即权限。这句话怎么强调都不过分。每新增一个技能,都意味着Agent多了一个能影响真实系统状态的入口。我们项目里做过的安全加固,可以简单列一下:

  • 技能白名单:Agent能调用的技能必须显式配置,禁止基于模型“自由发现”获得调用权。
  • 执行前确认:对“发邮件、修改数据、删除资源”这类高影响技能,一律要求用户在对话中明确确认后,才真正执行。
  • 审计日志:每个技能调用都记录操作者、时间、参数内容、结果状态、trace_id。这份日志不仅是排查故障的依据,也是安全追责的底线。
  • 权限矩阵:根据用户身份限制技能的可调用范围,比如普通员工不能调“审批通过”类技能,管理员用户则拥有额外能力。

这四件套里最容易偷懒的是审计日志,但它恰恰最不能省——一旦“无日志可查”,问题性质就直接从“技术事故”升级为“安全事故”了。

6.3 可观测性:技能链路追踪的全景视图

技能调用链的排障,最缺的往往不是日志,而是“把多次调用串起来看”的能力。我们用的方案是给每个业务请求设置一个trace_id,在技能注册中心的execute入口和出口都打上结构化日志,并记录parent_trace_id和skill_id以及“该技能调用了哪几个技能”。

之后排查问题时就不再是“大海捞针”式地翻日志,而是直接按trace_id拉出一整条技能链路,像看一条调用链一样找到耗时最长或报错最早的节点。实测一个原本要花两小时才能定位的“奇怪并发冲突”,在有链路追踪后,十分钟就锁定了根因:两个技能几乎同时修改了同一个缓存键,后写覆盖了先写。

可观测性建设不是“上生产之后再说”的锦上添花。越早做,后面遇到的每一桩诡异问题越容易解释和复盘。

6.4 后续演进方向

技能体系这套骨架搭好后,可扩展的方向很多。我自己最看好的两个:一个是“技能模板化”,把高频业务场景沉淀为可参数化的技能模板,让新场景可以像填表一样快速生成新技能;另一个是“技能组合策略”,在编排技能里引入更智能的依赖规划,而不是目前这种偏硬编码的前置条件。

另外,也可以把技能接入评测环境,沉淀成一套“回归用例集”,每次修改技能描述或路由规则,都能自动跑一遍基准评测,防患于未然。这一步做完,整个Agent技能体系就能从“能跑”升级到“能放心迭代”。

最后聊一句心得:做Agent技能化改造,真正的门槛不在于模型选型或代码难度,而在于你是否愿意把“人认为理所当然的规则层”老老实实地落到代码里。规则不酷,但它能兜底;模型很强,但它会飘。两条腿走路,踏踏实实把每一个技能做成可测试、可回滚、可观测的独立单元,整个系统自然就稳了。

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

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

立即咨询