资讯动态

大模型Agent技能层设计:从函数调用到Pipeline编排的工程实践

发布时间:2026/10/21 18:30:54 来源:尧图企业网站定制
这两年做大模型应用最深的体会就是Agent 的能力边界往往不取决于模型的聪明程度而取决于你给它配了多少个趁手的“技能”。所谓 agent-skills不是什么高深的理论就是把 Agent 能做的事拆成一个个可注册、可调用、可组合的标准模块——查天气是一个技能算报表是一个技能发邮件是一个技能。这样一来模型只负责理解和决策具体动作全部交给技能模块执行工程上才玩得转。这篇文章我就围绕这个思路把自己做 Skills 层时踩过的坑、总结的设计方法和可直接抄的代码结构一次性讲透给正在做 AI 应用工程化的朋友一点参考。1. 先搞清楚一件事Skills 层到底解决了什么问题1.1 纯提示词路线为什么走不远最早做 Agent大家的第一反应都是在系统提示词里拼命堆。你希望 Agent 会查天气就把天气工具的用法写进 prompt希望它会算账就在 prompt 里塞一套计算规则。一开始跑通 demo 没问题但项目一上线问题就开始排队了。首先是上下文膨胀。一个工具的描述两三百字十个工具就是两三千字加上历史对话、用户输入一次请求的 token 量直线上升。我见过一个项目提示词里塞了二十多个工具说明每次调用的成本直接翻了三倍响应速度肉眼可见地变慢。其次是维护地狱。工具逻辑变了要改 prompt工具新增了要改 promptprompt 一改之前跑通的用例可能就崩了。更麻烦的是同一个动作在不同场景下描述不一致模型可能偶尔自己发挥直接调用了一个错误入参的函数——这类问题在纯文本提示词里几乎没法系统排查。1.2 函数调用路线的本质缺陷后来 OpenAI 带火了 Function Calling大家终于知道可以让模型输出结构化的调用请求然后由代码去执行函数。这个思路方向是对的但直接把“技能”等同于“函数”在实践中会遇到两个硬伤。第一函数是代码视角技能是业务视角。一个业务技能往往需要组合多个函数。比如“整理今日销售简报”这个技能既要查数据库又要格式化文本可能还要生成图表。如果把每个函数都暴露给模型模型就要自己决定怎么组合而这恰恰是模型最不擅长的地方——组合的步数一多出错的概率指数级上升。第二函数的入参和返回都是强类型但模型对参数的理解充满不确定性。你给模型一个 Python 函数参数要求是 datetime 类型模型很可能会传一个字符串“明天”过来项目里不得不再写一堆清洗和纠错逻辑。1.3 Skills 层的价值定位Skills 层的本质是在模型和具体代码之间插一个中间管理层。模型不需要知道技能的内部实现细节只需要知道“有什么技能可用”“每个技能是干什么的”“该怎么调用”。而技能模块本身是独立封装好的输入、输出、校验、容错都有统一规范。这一层带来的实际好处我总结为三点可控性每个技能都有明确的描述、参数 Schema 和校验逻辑模型不能被模糊的提示词带偏也不会有机会发明一个不存在的工具。可维护性技能逻辑被封装成独立模块新增技能就是新增一个文件删除技能就是删掉一个注册项互不影响。可组合性高级技能可以基于基础技能编排复现一套“多步任务流”而不需要把复杂逻辑全塞给模型。2. 技能模块的核心设计先定好规范再写实现2.1 技能定义的结构化一个类就是一份契约我的经验是每个技能必须先定义一份结构化契约而不是直接写一个裸函数。这里我用一个简单的 Python 类来说明。class Skill: name: str # 技能唯一名称如 fetch_weather description: str # 给模型看的解释说明该技能做什么、什么时候用 parameters: list[ParameterSchema] # 参数 Schema描述参数名、类型、是否必填、含义 def execute(self, **kwargs): 技能的实际逻辑模型调用的终点 raise NotImplementedError这份契约要同时提供给两方一方是人——维护者能快速看懂一方是模型——通过序列化工具描述喂给模型作为调用依据。别小看这个结构它能避免 90% 的参数歧义。参数 Schema 是另一个值得细心打磨的地方。一个“查天气”的技能参数可能是城市名字符串但一个“订阅监控”的技能参数里可能包含协议类型、目标地址、超时时间这些细节。我建议参数 Schema 至少包含这些字段。字段含义示例name参数名模型输出时对应 keycitytype参数类型string / integer / number / boolean / objectstringdescription给模型看的说明尽量包含边界情况城市名称注意是中文城市名如“北京”。若无法识别则返回错误required是否必填trueenum可枚举的值能枚举尽量枚举[city_a, city_b]有些朋友会嫌 description 写得啰嗦但站在模型的角度这个 description 就是它做决策唯一的依据宁可多写一点约束也不要让它自由发挥。实测下来把参数描述写精确之后模型传错参数的概率能下降一半以上。2.2 技能注册中心核心是查找与去重有了 Skill 类下一件事就是注册。注册中心解决的关键问题有两个根据名称快速找到技能以及避免技能名冲突。我用的方案很简单维护一个技能注册表支持按 name 存取class SkillRegistry: def __init__(self): self._skills {} def register(self, skill: Skill): skill_name skill.name if skill_name in self._skills: raise ValueError(fduplicate skill: {skill_name}) self._skills[skill_name] skill def get(self, name: str) - Skill: if name not in self._skills: raise KeyError(fskill not found: {name}) return self._skills[name] def list_skills(self) - list[str]: return list(self._skills.keys())注册中心为什么不能省掉因为在实际项目里你往往不是只在一个文件里导入技能。不同模块可能各自定义技能如果相互之间硬调用依赖关系直接就乱了。通过一个统一注册中心做管理和编排技能与技能、技能与模型之间都不会产生不必要的强耦合。顺带说一句注册中心最好还支持按组管理。一个 Agent 不可能同时需要所有技能——帮助机器人需要查知识库数据分析助手需要跑脚本如果不管三七二十一全部注册模型看到一大堆无关技能反而更难决策。按组注册是个好习惯比如repo_tools、data_analysis、communication这样分组挂载按需加载。3. 编排与调度模型怎么知道该用哪个技能3.1 把技能清单转成模型能读懂的格式技能注册好之后要交给模型。以 OpenAI 的 Function Calling 为例就是将每个技能描述转成 tools 数组里的一项。这里有一个关键设计细节给模型看的 description 必须区分“该技能能干什么”和“什么时候该用”两种情况。我写过一个大而全的工具描述模板后来发现效果很差。模型分不清技能之间的边界会在只需要“查消息”的时候去调用一个“处理消息生成报告”的重型技能。后来我调整了描述风格把 description 拆成三个部分What技能做什么一句话说清楚。Use Case什么场景下值得调用把典型触发条件列出来。Constraints不要做什么比如“不要用这个技能生成图表”。例如一个“生成统计摘要”的技能name: summarize_statistics description: 基于一份数值型数据集生成文字摘要包括均值、中位数、趋势判断。 Use Case: 当用户询问数据整体情况、趋势、关键统计指标时使用。 Constraints: 不负责生成图表不做原始数据展示若数据非数值型则直接报错。这种三段式描述看起来没什么特别的但它确实能过滤掉一大类“方向不对”的调用模型的决策准确率肉眼可见地提升。3.2 技能调用的执行链参数校验与容错模型决定调用某个技能之后代码端会拿到一个 JSON 格式的调用参数。这时候千万别直接丢给 execute()。实战经验告诉我模型给出来的参数必须走一道校验——它不是一个可靠的程序员而是一个经常记错 API 的新手。校验我一般分三层必填字段检查缺参数立刻报错不主动脑补。类型检查字符串、数字、布尔类型是否匹配不匹配就尝试转换一次失败则报错。值域检查定义了 enum 的字段必须查表定义范围的一定要检查范围。下面是一个精简的执行框架class SkillRunner: def __init__(self, registry: SkillRegistry): self.registry registry def execute(self, skill_name: str, params: dict): skill self.registry.get(skill_name) # 1. 基础校验 for schema in skill.parameters: value params.get(schema.name) if schema.required and value is None: return { status: error, message: fmissing required param: {schema.name} } if value is not None and not self._validate(schema, value): return { status: error, message: finvalid param: {schema.name} } # 2. 执行技能 try: result skill.execute(**params) return {status: ok, result: result} except Exception as e: # 3. 容错处理把异常转成模型可读的错误信息 return {status: error, message: str(e)}这套执行链的关键是把错误处理收敛到统一出口。如果每个技能内部自己去 raise 不同的异常模型拿到一堆五花八门的错误信息根本没法继续决策。统一出口的好处是模型下一轮能读到稳定的错误格式从而进行修正或者切换方案。3.3 组合技能让 Agent 拥有“多步思考”能力单技能只能做一件事Agent 之所以叫 Agent就是因为它能把多步操作串起来。Skills 层对这种场景最好的支持方式是提供一个Pipeline 技能——一个技能内部按顺序调用多个子技能。举个例子我们的一个“日报生成”技能内部其实分了四步调用query_logs从日志仓库取数据。调用aggregate_metrics做指标聚合。调用template_render套用日报模板。调用send_message推送到群里。从模型视角看它只需要调用一个generate_daily_report技能剩下的编排逻辑完全封装在代码里。这背后的思路是模型应当尽可能做“决策”而不是做“执行”。让模型去一步步规划“先查日志再聚合再渲染再推送”中间每一步都可能出错而把这些写成编排好的管线稳定性会高得多。Pipeline 的编排实现可以很朴素无非是把子技能的返回值传给下一个技能class PipelineSkill(Skill): def __init__(self, name, description, steps): super().__init__(namename, descriptiondescription, parameters[]) self.steps steps # [(query_logs, {date: ..., group: ...}), ...] def execute(self, **kwargs): context {} for skill_name, params_template in self.steps: resolved_params { key: (context.get(val, val)) for key, val in params_template.items() } result runner.execute(skill_name, resolved_params) if result[status] error: return result context[skill_name] result[result] return {status: ok, result: context}这段代码里我用了一个小技巧params_template里允许写{previous_skill_name}.field这样的模板引用运行时解析成实际值。这就解决了“第二步要用第一步的输出”这种常见依赖问题整个管线像流水线一样把参数传递下去。4. 常见问题与排查技巧实录4.1 模型“幻觉技能”调用了不存在的技能名这是上线初期最频繁的问题。明明已经定义了三个技能模型却输出一个get_weather_info跟定义的fetch_weather不一样。排查思路很简单先把模型输出的原始内容完整记录下来对比它调用的名字和注册中心里的技能名你会发现大多数情况是“同义转换”——模型把描述里的词改写成它自认为更自然的叫法。解决办法也很直接第一个是给技能名定义好别名注册中心支持从多个名称映射到同一个技能对象第二个是在技能描述里明确写上“调用本技能时请使用 name 字段指定的名称不要自行改名”。还有一个思路是在系统提示词里加一句强约束。这三板斧下来幻觉调用基本能压到 1% 以下。4.2 技能描述互相干扰模型选错技能当技能数量超过十几个模型选错的概率会明显上升。我之前做过一次追查发现两个技能描述里都出现了“数据”这个关键词结果模型经常在需要“更新数据”的时候调用了一个“读写数据库”的技能绕了一大圈。这类问题的根治方法不是让描述更复杂而是让描述更独立。每个技能的核心描述里尽量突出它独有的触发场景重复出现的高频词要加限定词区分。比如同样是数据库技能一个强调“查询、检索、读取”另一个强调“写入、修改、更新”模型在选择时就能捕捉到差异。另外技能名本身也要有辨识度。我之前图省事用过get_data和fetch_records这种命名后来全改成带领域前缀的风格比如db_query_orders、db_update_user_status。虽然名字长了点但模型的选择准确率确实上来了。4.3 技能执行报错模型陷入死循环模型拿到一个技能返回的错误后可能会反复用相同的方式重试形成死循环。最典型的一个场景是查库存接口返回了一个超时错误模型不知道如何恢复就连续调了五次同样的接口。我的处理办法是在技能返回结果里附加可恢复的提示。比如在错误信息后面追加一句“如果需要请尝试调整参数后重试若持续失败请转用备选技能”模型就能据此调整策略而不是原地打转。另一个跟死循环相关的坑是重试次数没有上限。即便模型很聪明网络抖动也会导致同样的调用失败多次白白消耗 token。标准做法是在调用层记录每个技能的失败次数连续失败超过 3 次就禁止该技能再次被调用并在回复中主动告诉模型“当前技能不可用请尝试其他方案”。4.4 上下文太长技能描述占了大头技能描述虽然管用但每个技能描述几百字注册 20 个技能就是上万字一次请求光工具描述就消耗了大量 token还会影响模型对其他内容的注意力。这里我推荐一个比较实用的策略按需注入技能描述。先用一个轻量的“技能预选器”——它本身也可以是一个模型调用——根据用户的问题快速判断可能涉及的技能组然后把这一组的描述注入到 tools 数组里其他组的描述不进上下文。这样既保持了技能调用的准确性又把 token 开销砍掉 70% 左右。比如一个客户支持机器人技能分成“订单查询”“售后流程”“物流追踪”三组。用户一上来问“我的包裹到哪了”预选器只加载“物流追踪”这组技能描述信息量大幅减少响应速度和准确率都能得到改善。5. 技能层和外部工具的关系先统一接口再谈对接5.1 统一接口适配器别让外部 API 绑架你的技能技能内部要连的东西五花八门数据库、内部 HTTP 服务、第三方平台、文件系统……如果每个技能都直接写对应的请求代码问题就来了——对接方稍微改一点接口你的技能就得跟着改而且这种改动是分散的改漏一个就可能出事故。我的做法是给所有外部依赖套一层适配器。适配器统一暴露一个极简的接口内部再分别实现 HTTP 请求、数据库查询、RPC 调用等细节。class BaseAdapter: def invoke(self, payload: dict) - dict: 统一的调用入口返回统一格式的 dict raise NotImplementedError class HttpAdapter(BaseAdapter): def __init__(self, url, methodPOST): self.url url self.method method def invoke(self, payload): resp requests.request(self.method, self.url, jsonpayload) return {code: resp.status_code, data: resp.json()}有了适配器技能模块只依赖 BaseAdapter 的接口写技能的同事根本不用关心下游是一个 HTTP 服务还是一个内部函数。测试的时候也可以轻松替换成 Mock 适配器整个测试链路一下子清爽很多。5.2 跨语言与跨平台场景怎么处理如果整个技术栈是统一语言那用上面的类结构没问题。但现实是Agent 工程化和业务系统往往不在一套技术栈里。比如我们的 Agent 端是 Python但底层的一个订单系统是 Java 服务另一个数据平台是 Go 写的。这种跨语言场景统一接口的方式依然适用只是把适配器的实现换成 RPC 调用比如 gRPC 或 HTTP JSON协议上统一一个嵌套 JSON 格式两边按同一个 Schema 解析。这里我强烈建议提前定义一份协议文档请求体包含request_id、skill_name、params响应体包含status、data、error。就算两边语言不同只要按这份协议对接就不会出现“这边查不到那边传过来的字段”这种尴尬问题。6. 技能层的测试策略没有自动化测试就别上线6.1 单元测试验证每个技能输入输出技能模块是可复用组件攒了一大堆之后再回头补测试会很痛苦所以测试要和开发同步做。单元测试主要覆盖这几种场景参数合法、必填字段齐全时返回正常结果。参数缺失、类型错误、值域不合法时返回统一脚手架格式的错误而不是直接抛异常。技能内部调用外部依赖失败时是否能稳定返回“可读错误”并且不会带崩整个进程。用 pytest 写这类测试很方便关键是把适配器 mock 掉保证测试不依赖真实网络环境。6.2 端到端测试验证模型到技能的全链路单元测试只能保证代码逻辑没问题但模型与技能的配合对不对需要端到端测试来兜底。做法是准备一组固定的用户问题跑一次真实模型调用看模型输出的技能调用是否正确。我一般会把这组用例分成三类用例类别覆盖场景目标指标正常流程用户直接触发技能使用调用正确率≥95%边界情况用户问题模糊需要模型询问澄清或自行判断误调用率≤10%异常恢复技能执行报错模型是否能正确重试或切换恢复成功率≥80%这三组指标不是拍脑袋定的而是根据实际数据反复调过。如果正常流程的正确率上不去优先检查技能描述是否清晰如果边界情况误调率高增加“不调用技能并主动澄清”的选项反而效果更好异常恢复成功率低重点优化错误信息的可读性。7. 部署细节与上线后的持续迭代7.1 技能配置独立于主流程上线后你会频繁地调整技能描述、参数约束甚至整个技能模块。我强烈建议把技能的元信息做成配置文件而不是写死在代码里。最简单的做法是每个技能目录里放一个skill.yaml内容包含 name、description、parameters、enabled 等字段代码加载时统一读取并注册。这样产品同学要调整描述直接改 YAML 文件重新加载即可完全不需要动主程序代码也大大降低了部署风险。7.2 可观测性每个技能调用都留下痕迹技能层的调试异常依赖日志。我的习惯是每次技能调用都打一条结构化日志包含这些字段用户会话 ID。用户问题的原始文本。模型选择调用的技能名。实际执行的技能名。请求参数与返回结果。调用耗时与错误信息。这套日志最大的价值是支持事后复盘模型为什么选错技能某个技能为什么响应这么慢某个错误为什么反复出现有了日志不用靠猜。7.3 技能灰度与回滚技能不是静态的每次修改都要像发版本一样谨慎。我们内部的做法是给技能注册中心加一个简单的版本字段上线时先灰度到 20% 的流量观察错误率和调用分布稳定后再全面铺开一旦发现问题通过配置中心一键回滚到上一个稳定版本。这个机制看起来朴素但在真实线上环境里非常顶用。没有版本管理和灰度一个技能改动就可能引发连锁事故而有了这套保护迭代就敢放开手脚。8. 一些心里话Skills 层是 Agent 工程化的分水岭做了这么久 Skills 层我把最大的体会落成一句话决定 Agent 天花板的不是模型而是你给它搭的技能体系。模型的能力是通用的但你怎么把业务逻辑拆成模型能理解、执行起来又稳的技能才真正拉开差距。具体到实践上有几点是走了不少弯路才悟出来的技能描述一定要精修迭代。它是最容易被忽略却又最影响模型决策的地方每次调整描述都要用端到端用例回归。参数校验是底线。不要相信模型会传正确的参数宁可多写几个校验分支也不要留裸奔的口子。组合技能比让模型自由编排更可靠。凡是能被封装成步骤序列的任务一律写成 Pipeline别给模型发挥的空间。日志是一切调试的基础没有可观测性的 Skills 层等于没有。最后分享一个我现在一直在用的小习惯每周定期回看技能调用日志挑出那些调用失败率最高的技能逐个问自己“是描述不清参数约束不够还是这个技能根本不该暴露给模型”——这个过程看起来笨但真的能稳步把整个系统的稳定性推上去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑