Agent技能层:从Function Calling到可编排技能库的工程实践

发布时间:2026/10/7 4:04:19
Agent技能层:从Function Calling到可编排技能库的工程实践
先说一句实在话我最近翻到一个叫agent-skills的项目正文是空的关键词也只挂了这一个词。但光凭这个命名我基本能猜到它想表达的那件事——Agent 不能只会聊天它得有一双能干活的手。这个项目想做的大概率就是把大模型的意图理解能力和一组可执行、可编排、可复用的技能剥离开来让 Agent 从话痨变成工人。我过去几个月一直卡在这块踩了不少坑今天就把这套东西从原理到代码、从路由策略到维护经验完整拆一遍。不管你是正在写 AI 应用、做私有化 Agent还是单纯对 function calling 之上那层技能层感兴趣这篇都值得你花十分钟读完。1. Agent只靠大模型聊天还不够技能层要解决的真实问题先说个我反复遇到的场景。你用 GPT 也好用开源模型也好开个对话界面问它上海今天适合穿什么它答得头头是道。但你让它帮我查一下我这台服务器上的 Nginx 错误日志找出最近一小时 5xx 最多的接口并按错误次数排序输出绝大多数模型会给你一个建议你 SSH 上去执行这样一条命令的废话而不是真的去查。问题出在哪出在模型本身是个概率生成器它没有可靠的手段去读取服务器状态、去执行命令、去拿到真实返回值并把结果记住。技能的引入本质上是在大模型的模糊的语义空间和工程的确定的外部世界之间架了一座可控的桥。如果没有这一层设计你当然也可以让模型直接输出 shell 命令然后用一个危险的eval()去跑。但这么做有两个致命问题第一模型输出格式稍微漂移你的执行器就崩第二你根本没法限制模型想跑什么权限边界是模糊的。技能层的思路则完全不同——它把允许 Agent 执行的动作提前注册成一个一个的箱子每个箱子有名字、有说明、有参数约束、有对应的执行函数。模型只负责做选择题和填空题选哪个技能、填什么参数。真正落地的动作全部由代码执行。这里就引出一个关键判断Agent 大脑LLM 手技能集 记忆状态与上下文。如果你只有大脑那是哲学家有了手才是工人。而 agent-skills 这种项目要做的就是标准化地管理那双手让技能可以被注册、被发现、被匹配、被调用、被复用而不是散落在业务代码里的各种 if-else。那这个设计到底能解决什么真实问题我总结成三条可控制性。技能的执行逻辑掌握在开发者手里模型永远只会在白名单里做选择不会输出任何任意代码到生产环境。可复用性。一次写好的技能比如读取日志查询订单脱敏文本发邮件可以在不同 Agent 之间平移不用每个项目重新开发。可观测性。每一次模型选了哪个技能、填了什么参数、返回了什么结果都被记录成结构化日志出现问题能回溯而不是对着聊天记录猜。所以你在设计自己的 agent-skills 时第一步要明确的不是怎么写函数而是想清楚你的 Agent 到底需要干哪些事以及哪些动作是允许模型自己做主触发的哪些必须由人工确认后才触发。这个边界不划清楚后面所有路由和权限设计都是空中楼阁。1.1 技能的最小心智模型一次调用的完整生命周期为了方便下文展开我先给一个贯穿全篇的简单例子。假设我们要做一个代码审阅助手它需要三项基础技能read_file读取指定路径文件的内容scan_secrets扫描代码中的敏感信息API Key、密码、Token 等report_draft根据扫描结果生成一份 Markdown 报告一个完整的用户请求可能是这样把/repo/src/auth.py里的密钥扫出来然后帮我写一份风险报告。模型要做的事是先调用read_file拿到文件内容再调用scan_secrets扫描并拿到结果最后调用report_draft把结果汇总成报告。这三步之间是有依赖的后一个技能的输入来自前一个技能的输出。这就是技能调用最基本的形态顺序依赖 结果回填。技能不关心前面是谁它只定义自己的输入输出契约而 Agent 主循环负责把上一个技能的输出作为上下文再次喂给模型让模型决定下一步调用什么。这套机制是下面所有内容的地基。2. 一个能跑通的最小技能系统从注册表到主循环的闭合回路讲完了理念接下来进入正题。我直接给你一份能跑起来的最小实现它由三部分组成技能数据结构、技能注册表、Agent 主循环。这套代码是我在本地反复跑过的你可以直接抄走当骨架。2.1 核心数据结构技能名、描述、参数Schema、执行函数一个技能本质上就是四个字段的绑定。我习惯用 dataclass 把它固定下来from dataclasses import dataclass, field from typing import Any, Callable, Dict dataclass class Skill: name: str # 技能唯一标识例如 scan_secrets description: str # 给模型看的说明必须清晰准确 parameters_schema: dict # JSON Schema描述该技能的参数结构 handler: Callable[..., Any] # 真正执行技能的函数这里的description和parameters_schema是给模型看的name和handler是给开发者用的。为什么要单独设计一个parameters_schema因为当模型决定调用技能时它需要知道该填哪些参数、参数的类型是什么、哪些是必需的。比如scan_secrets的参数 Schema 可以是scan_secrets_schema { type: object, properties: { target_path: { type: string, description: 需要扫描的文件或目录绝对路径 }, rules: { type: array, items: {type: string}, description: 自定义扫描规则可选默认使用内置规则库 } }, required: [target_path] }这个 Schema 的价值在于它把模型如何理解参数这件事完全规范化了。模型会依据这个 Schema 输出结构化的 JSON 参数你的执行器再去解析就远比解析自然语言稳定得多。2.2 技能注册表与 Agent 主循环的交互有了技能类下一步就是注册表。注册表的核心作用是维护一个技能白名单并提供按名称或按语义检索的能力class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] {} def register(self, skill: Skill) - None: if skill.name in self._skills: raise ValueError(f技能重名: {skill.name}) self._skills[skill.name] skill def get(self, name: str) - Skill | None: return self._skills.get(name) def list_skills(self) - list[Skill]: return list(self._skills.values())你可能觉得这个注册表太简单了。但在真实的项目里注册这件事本身就是一种治理手段——不是所有函数都有资格当技能。我一般会在register里再加一层校验检查 handler 是否存在、Schema 是否合法用jsonschema库验证、description 是否为空。这三个检查能挡住绝大多数低级问题。然后是 Agent 主循环。这里我先用最简单的规则路由做演示更复杂的 LLM 路由放在第三节讲。假设我们已经从用户请求里提取出了技能名和参数主循环要做的事就是三步查注册表、执行 handler、把结果回填到上下文class AgentLoop: def __init__(self, registry: SkillRegistry): self.registry registry self.history [] def run_once(self, intent: str, params: dict) - str: # 1. 查找技能 skill self.registry.get(intent) if skill is None: return f未找到技能: {intent} # 2. 执行技能捕获并格式化结果 try: result skill.handler(**params) return f[技能执行成功] {result} except Exception as e: return f[技能执行失败] {e} def invoke_skill(self, skill_name: str, params: dict) - str: result self.run_once(skill_name, params) # 3. 结果回填历史 self.history.append({ type: skill_result, skill: skill_name, params: params, result: result }) return result这套闭环的妙处在于Agent 的长期记忆并不是靠记住执行过程而是靠把每次技能调用的输入输出追加进上下文。当下一次模型需要决策时它能看到上一次技能返回了什么再据此决定下一步动作。这种行动-观察-再决策的循环就是 Agent 区别于普通聊天机器人的核心机制。实际项目里我还会在这个循环中加入超时控制默认 10 秒强制中断防止某个技能卡死导致整个 Agent 不可用。2.3 为什么要做候选技能召回而不直接全量塞给模型当你只有三五个技能时把它们全部塞给模型完全没问题。但技能库一旦超过二十个你会立刻发现一个现象模型开始选择困难症频繁选错技能甚至凭空捏造技能名。原因很简单——你给模型的信息过载了它分不清哪些技能与当前任务相关。所以我在实际项目里都会加一道候选召回步骤先用 embedding 或关键词从技能库里选出 3~5 个最可能相关的技能再把这几个技能的描述和 Schema 塞给模型去精确匹配。这一步其实是在模仿人类专家的做法——你也不会在工具箱里翻找三十件工具你会先凭经验扫一眼锁定额外的两三件再来细看。这个缩小候选范围的过程就是SkillRegistry里最值得下功夫的地方。下面是召回的一种简单实现基于关键词重叠打分def recall_skills(self, user_query: str, top_k: int 3) - list[Skill]: scores [] query_tokens set(user_query.lower().replace(/, ).split()) for skill in self._skills.values(): desc_tokens set(skill.description.lower().replace(/, ).split()) score len(query_tokens desc_tokens) scores.append((score, skill)) scores.sort(keylambda x: x[0], reverseTrue) return [skill for score, skill in scores[:top_k]]它的局限很明显同义词就失效了。所以如果你的技能库规模到几百个建议直接把description字段做 embedding再用向量检索召回。但要注意不管用哪种召回描述的质量都直接决定召回效果。这也就是为什么我在第五节会专门讲技能描述怎么写。3. 意图路由的三种做法规则、LLM、混合各自的适用边界技能系统跑通之后你马上会遇到下一个问题用户的需求是千奇百怪的自然语言怎么把它匹配到正确的技能上我在生产项目里主要试过三种方案各有各的适用场景下面逐个说清楚。3.1 规则路由快、稳但只能处理固定表达规则路由很好理解就是维护一组关键词到技能的映射表。比如用户请求里出现扫描密钥泄露credentials就往scan_secrets上靠出现报告markdown汇总就往report_draft上靠。核心判断逻辑可以用re.search或简单的子串匹配完成import re RULE_ROUTES [ ([扫描, 密钥, secret, token, 泄露], scan_secrets), ([报告, markdown, 汇总], report_draft), ] def route_by_rules(user_query: str) - list[str]: matched [] for keywords, skill_name in RULE_ROUTES: if any(kw.lower() in user_query.lower() for kw in keywords): matched.append(skill_name) return matched它的优点极其突出毫秒级响应、零调用成本、完全可控可调试。但缺点也很明显它只能覆盖你事先想到的表达。用户一句帮我把 auth.py 的硬编码口令揪出来你的关键词列表里没有硬编码口令揪出来路由就失效了。我最初用规则路由跑内网工具时大概只覆盖了 60% 的真实请求剩下的 40% 全部需要人工兜底。所以说规则路由适合技能数量少、用户意图明确、不允许出错的内部工具但不适合开放场景。3.2 LLM 路由灵活、泛化能力强但会撒谎LLM 路由的基本思路是把候选技能的描述和 Schema 全部拼进 prompt让模型输出一个 JSON指明要调用哪个技能、参数是什么。理论上这是最贴合智能体直觉的做法模型理解能力越强路由效果越好。但在我实际测试中它有两个特别棘手的毛病幻觉技能名。当技能库较大时模型偶尔会输出一个不存在的技能名比如把report_draft写成generate_report。你必须在解析层做严格校验先查注册表查不到就拒绝并让模型重新选择而不是直接执行。参数凭空补全。用户请求里没说目标路径模型却自作主张填了一个/home/admin/default.py。这种看似合理的参数是最危险的轻则跑错文件重则触发未授权的操作。所以参数校验这步绝对不能省。既然有这些问题为什么还要用 LLM 路由因为它能处理开放式表达。同一句把 auth.py 里的硬编码口令揪出来规则路由没戏LLM 却可以根据语义理解把请求路由到scan_secrets并且从请求里联想到target_path应该取auth.py的路径。这种泛化能力规则路由永远做不到。3.3 混合路由实战候选召回 LLM 最终决策 参数抽取真实项目里我用的不是上面任何一个而是三层流水线第一层规则召回快 —— 用关键词/embedding 把候选技能从几十个缩到 3~5 个 第二层LLM 最终决策 —— 让模型从候选里选一个并输出结构化参数 第三层Schema 强校验 —— 用 jsonschema.validate() 校验参数任何一个类型不符直接打回重试为什么要这样做原因在于LLM 最不稳定的是海选最稳定的是三选一。你让它从几百个技能里挑一个它容易瞎猜但你只给它三个候选还附带各自的描述和参数格式它的选择准确率会高很多。这就像你让实习生从整个仓库里找螺丝钉他可能随便拿一个交差但你把他带到只有三盒螺丝钉的货架前让他挑规格对的那个他基本不会出错。我举个自己项目里的真实例子。用户说帮我看看当前仓库里有没有需要特别注意的敏感信息。规则召回阶段scan_secrets和read_file命中了看看敏感信息第三技能report_draft没被召回。LLM 决策阶段模型发现有没有意味着需要先读取仓库内容再扫描于是选中了scan_secrets并把target_path参数补成当前目录.。最后 Schema 校验通过任务顺利执行。整个过程模型只做了一次有限范围的判断出错概率被限制在了可控区间。下面是个简化的混合路由伪代码你可以在自己的代码里按这个骨架扩展def route_with_llm(user_query: str, candidates: list[Skill]) - tuple[str, dict]: prompt build_route_prompt(user_query, candidates) raw llm_complete(prompt) # 期望返回 {skill: ..., params: {...}} try: parsed json.loads(raw) skill_name parsed[skill] params parsed[params] skill registry.get(skill_name) jsonschema.validate(params, skill.parameters_schema) # 硬校验 return skill_name, params except Exception: # 反馈给模型重新决策最多重试两次 return retry_route(user_query, candidates)值得单独提醒的是混合路由的参数抽取我强烈建议和技能选择分两个 prompt 做。选择技能只需要语义匹配不涉及具体细节而参数抽取需要模型从用户的原始请求里精准提取字段。两者混在一起模型为了赶工往往输出粗糙参数。分开之后每一步的 prompt 都足够简单稳定性会明显提升。4. 技能系统落地时最容易翻车的四个场景与排查链路代码能跑通只是开始。真正让 Agent 项目变得能用而非能演示你几乎必然要经历下面这四个坑。我把每个坑的完整排查链路写出来你可以照着复现。4.1 技能描述写得太工程化模型根本读不懂我把技能描述称为Agent 的人设说明书。很多团队第一次写描述时习惯用技术视角scan_secrets基于内置正则库与熵检测算法对指定路径下的文件执行敏感信息扫描支持自定义规则返回匹配结果。这个描述对机器检索是友好的正则熵检测路径都是关键词但对模型决策是糟糕的。模型看到一个技能时它关心的是这个技能适不适合回答用户当前的问题上述描述完全没提到密钥Token泄露安全性模型很难把用户随口一句帮我检查一下项目有没有安全问题关联到scan_secrets。我的经验是技能描述要同时满足两个视角用户意图视角用能做什么来打头而不是怎么做的。场景实例视角给出 1~2 个典型用户请求的例子模型一看就懂。改造后的描述scan_secrets扫描指定文件或目录中的敏感信息包括 API 密钥、密码、Token、私钥等。适合用户问帮我看看代码里有没有密钥/检查有没有硬编码凭据/检查项目安全性之类的请求。返回匹配位置和建议修复方式。你可以明显感觉到第二种描述就像给人类同事的任务交接说明。模型本质上是穿着人话外套的匹配器它需要的是什么请求→该用我的显式关联而不是一丝不苟的底层技术讲解。我踩过这个坑之后现在写每个技能的 description 都会做一次自检拿这个描述给模型看再拿几类典型用户请求给它看它能不能一一对应起来。对应不上就改描述而不是抱怨模型太笨。4.2 上下文污染技能返回一万行结果Agent 当场失忆技能执行完要把结果回填给主循环以便模型做下一步决策。但很多技能尤其是日志扫描、代码检索返回的内容非常长。我之前就遇到过scan_secrets扫出两百多处疑似泄露把所有命中行都返回给模型模型直接看不过来了后续的report_draft决策质量断崖式下跌。这本质上是因为你的技能返回结果占据了大量上下文窗口稀释了用户原始意图和其他重要信息。我的解决方式是给技能返回加一道压缩层。具体做法有三种按优先级排限制返回条数在 handler 内部就做截断最多返回 Top 20并附一句共发现 218 处以下为前 20 条。模型写报告只需要知道整体情况和代表案例不需要全部明细。结构化摘要让 handler 输出摘要对象而不是原始字符串。比如{total: 218, high_risk: 3, top_hits: [...]}。模型对结构化数字的感知比对长文本的感知可靠得多。分页或分区提取如果用户明确要逐条看就做成先看目录/摘要再按需展开某一片段的双步交互不要一次性把所有信息灌给模型。这里还有一个我自己常忽略的细节技能返回的信息要有时间属性。日志扫描、股票查询、库存检查这类技能的结果都随时间变化如果 Agent 上下文里存了旧结果模型可能拿旧数据做决策。因此我的回填格式会统一带上时间戳例如[2025-01-15 14:32:05] 扫描完成共发现...让模型知道数据的新鲜度。4.3 技能互相打架功能重叠模型选谁都可以技能库一旦被多人维护功能重叠几乎是必然的。我经历过最典型的一次团队里有同事写了个check_secrets早前又有别人留了个scan_credentials两个技能干的事差不多只是命名和返回格式不同。结果模型每次路由都要在两个之间犹豫最终效果看运气。这个问题没有银弹只能靠治理。我的做法分两步注册时强制查重。在SkillRegistry.register()里比较新技能的description与已有技能描述之间的 embedding 相似度超过阈值就拒绝注册并提示已有相似技能 X请确认是否真的要新增。这个机制能把重复注册挡在源头。定期人工审计。每个迭代版本把技能库全部拉出来按功能域扫描、读取、报告、通知等分组同一个功能域下如果有多个活跃技能就必须合并或废弃。这个动作类似于代码重构丑技能不会自己变干净需要你主动清理。另外我在前面提过技能调用记录全部要落到日志里。把每个技能的被调用频率、成功率、平均耗时拉出来你能立刻发现哪些技能是僵尸技能注册后从没用过哪些是高频必需技能。僵尸技能优先删除——它们存在只会增加模型路由的噪声。4.4 异常输出与脆弱解析格式漂移、超时、幂等性模型输出的东西再可控也有跑偏的时候。我最常在三个地方翻车第一JSON 格式漂移。模型输出{skill:scan_secrets,params:{target_path:./}}看着没问题但换一个模型它可能输出带 markdown 代码块的版本{skill: scan_secrets, params: {target_path: ./}}你的解析器如果直接json.loads(whole_string)就会抛异常。正确的做法是先正则抽取代码块内容再尝试json.loads失败后尝试丢给ast.literal_eval再不行才走重试逻辑。这里有个重要原则解析器要尽量宽容执行器要尽量严格。第二技能执行超时。扫一个大仓库可能要几分钟而 Agent 主循环还在傻等。我的方案是给每个技能设置独立的超时阈值默认 10 秒长任务技能单独配到 30 秒。超时后不直接报错而是回填该技能执行超时请缩小扫描范围或检查输入路径这种可操作提示模型看到后会自动调整参数重试或引导用户修正输入。第三幂等性。服务端 Agent 可能因为网络问题重复调用某个技能。如果技能本身有副作用比如发邮件、改数据库必须保证重复调用不会产生重复动作。我的习惯是在技能 handler 内部加一个request_id参数同一个请求 ID 到达时直接返回上一次的结果。这个设计虽然简单但能挡住非常多的线上事故。5. 把技能库当产品维护Schema设计、回归测试和复合编排最后这部分我想聊点更接近工程管理的内容。很多人把技能库当成一个工具集合写一个扔一个结果三个月后技能库变成了垃圾场——描述过期、参数语义有歧义、依赖的内部接口早就换了。要避免这个局面你必须把技能库当成一个长期产品来维护。5.1 技能参数 Schema 本质上是一个 API 设计问题给技能定义参数 Schema和你设计一个 REST API 的请求体没有本质区别。你是在定义一个别人模型怎么调用它的公开契约。我在设计 Schema 时有两个硬性标准参数名要和描述里的用词一致。如果描述里写目标路径target_path参数名就必须是target_path不能是path或dir。模型的注意力机制对术语一致性极其敏感不一致就很容易填错字段。能不用可选参数就不用可选参数。每个可选参数都是模型需要额外决策的负担。我宁可把一个技能拆成两个一个只扫文件、一个扫目录也不要在同一个技能里堆一堆可选条件。拆开之后每个技能的描述更短路由准确率反而更高。有一个反直觉的例子scan_secrets里的rules参数我希望它支持自定义规则。但实践中模型的执行效果很差它经常不知道该填什么规则或者随便填一个。后来我干脆把它去掉自定义规则改由配置中心管理技能本身只管扫描用什么规则不在 Agent 的决策范围内。这个取舍让scan_secrets的调用成功率从 78% 涨到了 96%。所以你的冷笑话是每次往 Schema 里加一个字段你其实是在给模型的正确率减一分。5.2 技能回归测试用固定意图集跑一轮代码库有单元测试技能库也应该有。我的做法是维护一个意图-技能黄金测试集包含六十到一百条典型用户请求每条规定了期望命中的技能和参数。每次技能库有新增或修改就全量跑一遍算两个指标路由准确率选对了技能的比例和参数有效率生成参数通过 Schema 校验的比例。这个回归测试的价值在于它能立刻暴露新技能污染了旧路由的问题。你有一次很典型加了一个fetch_doc技能后所有包含文档字样的请求都被它吸走了原本应该走scan_secrets的请求扫描文档里的密钥全部偏了。如果没有回归测试这种问题可能在生产环境跑一两个星期才被用户发现。有了回归测试一行命令就能拦住。回归测试跑完后我还会看一眼每个技能的调用失败原因分布。无外乎几类描述泛化不足、参数抽取失败、handler 内部异常。把高频失败原因回写到技能描述或代码里技能库的准确率会保持一个持续上升的趋势这才是维护技能库的正循环。5.3 复合技能与编排把原子技能串成熟练工种单一技能能解决单点问题但真实任务往往是多步骤的。比如第三节的例子扫密钥并生成报告其实涉及read_file、scan_secrets、report_draft三个技能。让模型在每次循环里自己一步步串不是不行但效率低且容易中断。所以我建议你引入复合技能的概念用一个工作流定义把多个原子技能编排成一个新的技能。比如我可以注册一个audit_security_report技能它内部固定编排了读取路径 → 扫描密钥 → 生成 Markdown三步。用户只要一句帮我审计这个仓库的安全状况模型只选这一次后面的步骤由代码确定执行。def audit_security_report(target_path: str) - str: content read_file(target_path) scan_result scan_secrets(target_path) report report_draft(content, scan_result) return report这其实就是把一部分Agent 的思考过程变成了工程上的确定性流程。我的判断标准是一个步骤序列如果十次有八次都是同样的执行路径就值得固化成复合技能。这样既能减少模型多次决策带来的失败率又能让技能调度更快。反之如果任务路径经常变化就不要硬编把决策权留给模型。5.4 沉淀团队技能库的目录结构与文档规范当技能数超过二十个目录结构和管理规范就变得不可回避。我强烈建议按功能域组织技能而不是按业务线组织。原因很简单Agent 路由技能靠的是这个技能干什么不是这个技能属于哪个业务。按业务线划分目录等于让模型在一个跨业务的杂烩里找目标按功能域划分比如scanner、retriever、notifier、reporter目录本身就是语义索引。每个技能我还要求配一份极简的 README 式文档但只写三件事典型用户请求给模型看检查这个目录有没有硬编码密钥扫一下配置文件里的 Token边界与限制给维护者看只扫文本文件二进制自动跳过单次最多扫 2000 个文件超出报错依赖与配置给接手者看依赖pyyaml需要在配置中心注册规则文件路径这三项信息的价值在于第一项直接提升路由准确率第二项能防止别人误用第三项能让你半年后回头看时还知道这个技能凭什么存在。技能库这种东西最大的敌人不是复杂度而是遗忘。文档越薄但越关键技能库的可维护性就越高。我在维护自己这套 agent-skills 的过程中慢慢意识到一件事Agent 的智能上限很少有瓶颈在模型的推理能力上更多是卡在技能层不够干净、不够可靠、不够好用。模型再聪明也架不住一个描述模糊、参数混乱、动不动超时的技能库。反过来一个精心维护的技能库能让普通模型也展现出相当可靠的自动化能力。所以如果你也在做 Agent与其追着模型版本跑不如先把自己的技能库擦拭干净——这个投入的回报远比你想象中更高。