ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent技能系统实战:让大模型可靠调用工具的工程方法论

Agent技能系统实战:让大模型可靠调用工具的工程方法论 说起 agent-skills 这个项目我得先坦白一件事我最初做 Agent 应用的时候一度觉得大模型很聪明只要把工具列表塞进提示词里它自然就会用工具。结果第一个真正跑起来的项目就翻车了——模型频繁选错工具、参数传得乱七八糟、遇到异常就装死。后来我才慢慢明白Agent 能不能落地关键不在模型本身而在于你给这堆模型准备的技能系统设计得够不够可靠。agent-skills 就是我在这段试错过程里沉淀下来的一套方案。它不是一个花哨的框架而是一套关于如何把模型能力之外的可执行操作封装成结构化的、可发现、可调度、可组合的技能单元的实践方法论。这个项目解决的问题很具体让大模型不再靠提示词瞎猜怎么调用外部能力而是通过一套显式的、带 Schema 约束的技能注册与调度机制稳定地完成真实工作任务。这篇文章我准备把整套东西拆开讲清楚——技能定义怎么写、模型怎么识别该用哪个技能、多技能怎么编排成工作流、安全边界怎么划以及我在实测中发现的各种反直觉的坑。如果你正在做大模型应用开发或者被多步工具调用搞到头大这篇内容应该能让你少走很多弯路。我默认你有基本的 Python 能力和对大模型 Function Calling 的认识但下面的源码和配置都是可以直接抄作业的级别。1. 为什么我需要一个技能化的 Agent而不是一个会聊天的 Demo先说一个我踩了很多次才悟透的东西。很多人做 Agent第一步是去注册一个 OpenAI API Key然后写一个 System Prompt里面塞上你现在是一个助手你可以做以下事1. 查天气 2. 订闹钟 3. 发邮件再把函数的描述拼进去。这样确实能跑通一两个 Demo但一旦场景变多、工具变复杂问题就全暴露了模型会忘记你给过哪些工具会把 A 工具的参数塞给 B 工具会在工具返回报错后开始一本正经地编造结果。我前前后后做了二十多个 Agent 相关的小项目规模不大但种类很杂从信息抓取到内部数据查询都有。在这些项目的反复迭代里我确认了一件事实Agent 的本质不是让模型自己想办法而是给模型一份足够清晰的技能清单让它在清单内做选择和组合。模型负责的是意图理解和决策真正落地干的活必须由你定义好的技能模块来执行。所以 agent-skills 的核心定位就变得很清楚它不是某个具体业务 Agent而是介于模型和你的一堆工具函数之间的能力中间层。这个中间层要做四件事技能注册把零散的函数变成带元信息名称、描述、参数约束、安全等级的技能单元。技能发现让模型在每次决策时只看到与当前对话最相关的技能子集。概率上模型选择越少越准token 成本也越低。技能调度统一处理模型返回的调用指令做参数校验、超时控制、重试和异常隔离确保即使技能内部报错对话流也不崩。技能编排支持把多个基础技能组合成一个复合技能让 Agent 能完成查数据 - 汇总 - 生成报告这种多步任务。我打个比方。把大模型比作一位聪明但手很生的新员工你的技能系统就是公司里一整套贴好标签、说明书的工具柜。新人不需要知道工具的螺丝是怎么拧的他只需要按流程挑出正确的工具填好领用单参数干完活把结果交回来。你要管理的不是这位新员工的聪明程度而是工具柜本身标签贴得准不准、说明书写得好不好、工具之间怎么搭配使用。这套思路在很多现实场景里都好用。举几个我自己实际做过的例子内容工作流给定一个主题Agent 自动抓取多篇来源网页提炼结构化要点生成一份带有引用来源的摘要。这个流程里涉及网页抓取、正文提取、去重、LLM 生成四个技能的串联。内部数据助手用户用自然语言问上周上海区的订单金额环比变化Agent 需要把自然语言查询翻译成数据 API 的参数调用接口再把返回的 JSON 渲染成结论。这里面涉及 NL2API 和结果摘要两个技能。运维辅助机器人在收到告警消息时机器人拉取最近的发布记录、错误日志、服务器指标合成一段诊断简报。涉及多个数据源查询技能的并发调用。你能看到不管哪个场景模型都不是最核心的难点——难点全在技能体系的稳定性、清晰度和安全边界。所以下面我直接进入 agent-skills 的设计细节从技能定义讲起。2. 技能定义的 Schema用机器读得懂的方式告诉模型你会什么技能是这个系统的最小单元。一个技能的本质就是一段对能力的结构化描述 一个真正干活的函数。但问题在于描述写得不到位模型就会误解、选错、或者干脆不选描述写得太啰嗦又会白白烧掉大量上下文 token。这中间的平衡是我在 agent-skills 项目里花时间最多调整的地方。2.1 一个技能最少要有哪些字段先给出我最终定下来的技能定义结构用 Python 的 dataclass 表示是这个样子的from dataclasses import dataclass, field from typing import Callable, Any, Dict, List, Optional dataclass class Skill: name: str # 唯一的技能名小写下划线风格 description: str # 给模型看的自然语言描述讲清楚在什么场景用它 parameters_schema: Dict[str, Any] # JSON Schema约束模型输出的参数结构 execute: Callable[..., Any] # 真正执行的函数 timeout: float 10.0 # 超时时间秒 safety_level: str normal # normal 直接执行high 需要人工确认 tags: List[str] field(default_factorylist) # 用于技能检索预筛 dependencies: List[str] field(default_factorylist) # 依赖的其他技能 name编排层会用到这段结构里大多数字段都好理解但有三个地方我想特别强调因为它们是后来反复踩坑才修出来的首先是 name 的命名约束。必须用小写下划线不能有空格和特殊符号。这不是我喜欢这种风格而是模型在生成 Function Name 时对看起来像合法标识符的名字命中率明显更高。我试过用带空格的描述式名字比如 web page fetch结果模型经常原样把这个带空格的名字输出导致调用匹配失败率飙升。改回 fetch_web_page 之后问题基本绝迹。其次是 description 的场景触发式写法。这是整个 Schema 设计里回报率最高的一项优化。我对比了两类描述风格的实测命中情况描述风格写法示例实测模型选对率50 次试跑功能罗列式Get web page content from a URL到位率还行基础场景约 78%场景触发式当用户需要读取某个网页的正文、标题或链接时使用。入参 url 需要以 http/https 开头约 94%为什么差距这么大因为大模型在做工具选择时本质上是在进行意图匹配。它会拿着用户当前的问题去和你提供的描述做语义相似度计算。功能罗列式描述了这个技能能做什么但场景触发式描述了用户说什么话时你应该动用这个技能。后者和用户的自然语言问题之间存在更加直接的语义链路模型自然更容易命中。所以我在每条描述里都坚持用当……时使用这样的句式把触发的用户意图直接写清楚。第三是 parameters_schema 的写法。这里的核心思路是不要让模型自由发挥地决定参数格式而是给它一个钉死的 JSON Schema。比如定义网页提取技能时参数 Schema 是{ type: object, properties: { url: { type: string, description: 目标网页的完整地址必须以 http:// 或 https:// 开头 }, max_length: { type: integer, description: 最多返回的文本字符数默认 2000, minimum: 100, maximum: 10000 } }, required: [url], additionalProperties: false }这个 Schema 有两个细节很重要。第一additionalProperties: false一定要写否则模型偶尔会自作主张往里面塞额外字段而你的解析器要是不够严格这些多出来的字段就会悄悄被忽略导致执行结果不符合预期。第二每个字段的 description 要和技能外层描述一样用场景化、带约束的写法而且把默认值直接写进去。模型特别喜欢在参数不明确时挑默认值处理你要是让默认值隐含在代码里而没写进描述它就会在字符串里编一个看似合理的值塞进去。我在调试中还发现一个反直觉的现象参数 Schema 越严格模型参数生成反而越稳定。很多人担心约束太多会限制模型发挥但参数生成的本质是模式匹配明确给出可选范围和格式要求反而能降低模型的不确定性。你越是让 Schema 保持模糊模型越容易进入自由创作模式随后就是一堆 params 解析报错。2.2 技能描述的信息组织给模型讲清楚边界除了上述字段我还会在 description 里刻意写明这个技能的边界和限制。比如有一个访问外部 HTTP API的技能我给出的 description 是这样的当用户希望请求一个外部 HTTP 接口如拉取天气、股票、新闻数据时使用。 支持 GET 和 POST请求头默认包含 JSON Content-Type超时时间为 5 秒。 对于需要身份验证的接口请先调用 get_api_token 技能获取 token。 不要使用本技能访问本地文件路径或内网地址这类需求应提示用户使用其他技能。这段描述里包含了四个层次的信息触发场景、能力边界、依赖关系、安全禁区。模型的语义匹配不仅会看到你能干什么还会看到什么情况下别用它这在多技能并存的系统里能显著降低误调用概率。我对比过把安全禁区明示进描述之后模型在敏感操作上的误调率下降了不止一个量级。原因也简单模型总是倾向于把最近的语义匹配当作答案如果你不给它足够的上下文告诉你哪些情况不是它的事它就可能主动越权揽活。3. 从模型吐出的函数名到真正跑起来的技能调用注册与调度的完整链路Schema 定义好了接下来就是那个让 Agent 真正干活的核心环节——模型决定了要调哪个技能、给出了参数然后你要做的是把它安全稳定地变成一次真实执行并把执行结果合理地塞回对话。这一整个链路是我花了不少力气调试才跑顺畅的。3.1 全局注册表与装饰器让技能上架每个技能最终都要进到一个全局注册表里。我用的实现方式很朴素但扩展性足够一个模块级的字典 一个装饰器。这个做法没有花哨的框架依赖任何 Python 项目都能直接搬走用。SKILL_REGISTRY: Dict[str, Skill] {} def skill( name: str, description: str, parameters_schema: Dict[str, Any], timeout: float 10.0, safety_level: str normal, tags: Optional[List[str]] None, dependencies: Optional[List[str]] None, ): def decorator(func: Callable[..., Any]) - Callable[..., Any]: SKILL_REGISTRY[name] Skill( namename, descriptiondescription, parameters_schemaparameters_schema, executefunc, timeouttimeout, safety_levelsafety_level, tagstags or [], dependenciesdependencies or [], ) return func return decorator用法很简单直接在一个普通函数头上加上装饰器技能就自动上架到注册表skill( namefetch_web_page, description当用户希望读取某个网页的正文内容、标题或链接时使用。要求入参 url 以 http/https 开头。, parameters_schema{ type: object, properties: { url: {type: string, description: 网页地址}, max_length: {type: integer, description: 最多返回字符数, default: 2000} }, required: [url], additionalProperties: False }, timeout8.0, tags[web, crawler], ) def fetch_web_page(url: str, max_length: int 2000) - str: # 真实执行逻辑请求网页、提取正文、截断 ... return content装饰器的好处是技能定义和业务代码耦合度低你可以在不同模块里分散定义技能只要模块被 import技能就会自动注册。这个模式对中型项目尤其友好不用搞一个巨大的配置中心。这里有个我在工程里特别注意的点注册表要支持动态上下架。比如某些技能只在特定租户或特定权限下开放admin 才能执行删除普通用户不行那么在构造模型请求时就要按当前上下文过滤注册表而不是把全部技能都发给模型。最开始我没设计这层测试阶段模型甚至在一个不该删除数据的场景里得意洋洋地调用了删除接口——还好当时只是测试数据。所以后来我强制加了一个filter_skills_by_context(registry, user_context)的过滤层所有技能请求发出前都要过一遍。3.2 一次完整的技能调用生命周期我直接给出一段极简但可运行的一次技能调用伪代码它展示的是最核心的循环逻辑。你把这套逻辑放到任何 Agent 的 reasoning loop 里都能立刻跑起来import json from typing import List, Dict, Any def agent_loop(user_message: str, available_skills: List[Skill], llm_client, max_rounds: int 5): messages [{role: user, content: user_message}] tools [skill_to_openai_tool(skill) for skill in available_skills] for round_idx in range(max_rounds): response llm_client.chat.completions.create( modelyour-llm-model, messagesmessages, toolstools, tool_choiceauto, ) msg response.choices[0].message messages.append(msg) # 判断模型是否要求调用技能 if not msg.tool_calls: return msg.content # 模型已经给出最终答案 # 处理这个回合里所有并发的技能请求 for tool_call in msg.tool_calls: skill_name tool_call.function.name raw_args tool_call.function.arguments or {} # 层 1JSON 结构解析 try: params json.loads(raw_args) except json.JSONDecodeError: params repair_json(raw_args) # 见下文说明 # 层 2Schema 校验与类型归一化 params validate_and_coerce(skill_name, params) # 层 3执行技能带超时 异常隔离 result run_skill_safely(skill_name, params) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse), }) return 达到最大迭代轮次已中断这个循环看起来很简单但里面每一层都大有文章。我逐个说。层 1JSON 解析的容错。模型传回来的function.arguments理论上应该是合法 JSON但现实世界经常不是。我遇到过的就有缺少一个引号、末尾多一个逗号、把单引号当引号用、整个参数直接被模型压缩成一行丢掉了外层花括号。所以我在解析失败之后会走一个修复器优先尝试下面几个策略按成本从低到高排序第一给字符串首尾补上花括号再解析第二用正则提取所有key: value片段重构字典第三如果参数只是一个裸字符串就直接把它当作第一个必填参数填入。这套修复逻辑虽然看起来不够优雅但在跑量之后确实救回过很多次对话。层 2Schema 校验很关键。我用了jsonschema库来做。它会做三件事检查必填字段是否都到齐了、检查类型是否和声明一致、检查枚举/范围是否有越界。然后做一个系数归一化把字符串形式的数字转成 int/float把字符串true转成布尔值。这一步的目的是把容错做在进入用户代码之前别让那些五花八门的类型错误穿透到业务层面。import jsonschema from jsonschema import ValidationError def validate_and_coerce(skill_name: str, params: Dict[str, Any]): skill_obj SKILL_REGISTRY.get(skill_name) if not skill_obj: raise SkillNotFoundError(skill_name) try: jsonschema.validate(params, skill_obj.parameters_schema) except ValidationError as e: # 尝试粗粒度类型归一化后再次校验 params coerce_types(params) jsonschema.validate(params, skill_obj.parameters_schema) return params层 3超时与异常隔离。技能执行时我用concurrent.futures的ThreadPoolExecutor来实现超时因为大多数技能函数都是 IO 密集型的网络或文件操作。同时用一个 try 包裹将任意 Exception 转成一个结构化的错误提示字符串塞回给模型而不是直接让整个 Agent 崩溃。这里有一个我从实战里总结的经验技能抛出的原始错误信息绝对不能直接拼进对话上下文发给模型因为那里面经常含有文件路径、内部 IP、数据库语句、堆栈信息这些都是要保密的。我在返回模型之前会把堆栈信息过滤掉只保留一个用户可理解 模型可理解级别的错误摘要。对模型也需要知道发生了什么但它不需要知道内部细节。3.3 技能筛选与 token 预算管理一次对话里把注册表里一百多个技能全都塞给模型是不现实的。光是把这些工具的 description 设为 input token一次请求就能吃掉几万 token成本高且命中率因为选择空间过大而下降。所以我在 agent-skills 里实现了一个两层筛选机制。第一层是规则粗筛根据对话的关键词和技能 tags 做匹配。比如用户问上海明天穿什么我先用一些简单的关键词规则把股票查询“发票查询”这类完全不相关的技能排除掉。这层不需要任何 AI 参与纯粹用启发式规则一两毫秒就能跑完。第二层是向量粗排把用户问题转换为 embedding和所有技能的描述算相似度取 top-K。实际操作中这两层叠加之后一般能把技能候选压到 5 到 10 个此时再交给模型做精细工具选择准确率有明显提升。这里要说一下 top-K 的选取策略。我早期的实现就取 top-5后来发现经常缺一些描述很长但相关性高的技能所以改成了 top-10 和按相关性分段的组合同时在 description 上做了截断处理。def select_skills_for_user_query(query: str, registry, top_k: int 10): # 1. 规则粗筛快速排除明显不相干的技能 candidates keyword_filter(query, registry) # 2. embedding 相似度排序 q_vec embed(query) scored [ (cosine_sim(q_vec, embed(skill.description)), skill) for skill in candidates ] scored.sort(reverseTrue, keylambda x: x[0]) return [skill for _, skill in scored[:top_k]]token 预算方面还有个经验模型对tools的解析能力会随工具数量增加而剧烈衰减。当提供给模型的候选技能超过 10 个时模型选错技能的频率就开始上升超过 15 个后即使 top-1 命中率也会明显下降。所以我把候选技能数量的上限卡死到 10 个宁可让模型在少数几个里做决策也不贪多。4. 把简单技能织成工作流编排层是我最满意的部分单技能调用解决了模型想用工具时能把工具用好的问题但真实场景往往是多步骤的查询数据 - 处理数据 - 调用另一个接口 - 生成文本。agent-skills 里的编排层就是干这个的。我最终的实现方案受限于时间没有做成复杂的图状 DAG 引擎而是做了一个线性工作流 条件分支的轻量编排够用且稳定代码不到一百行。4.1 工作流的建模链式 条件分支一个工作流被建模成一组有序节点每个节点要么是调用某个技能要么是调用一个内部函数做上下文处理要么是一个条件跳转。每个节点的输出会作为上下文透传给后续节点。下面是一个结构化的工作流定义用于自动生成日常周报workflow_weekly_report Workflow( nameweekly_report, description当用户要求生成一周工作报告时使用自动从代码托管平台拉取提交记录、聚合统计数据并生成摘要。, steps[ Step(step_idfetch_commits, skillgit_commits, params_from_context{since: {{query.start_date}}, until: {{query.end_date}}}), Step(step_idcompute_stats, skillanalyze_commits, params_from_context{commits: {{steps.fetch_commits.output}}}, WaitTrue), Step(step_idsummarize, skillllm_text_gen, params_from_context{stats: {{steps.compute_stats.output}}, style: 简洁}, user_confirmation_requiredFalse), Step(step_idnotify, skillsend_message, params_from_context{content: {{steps.summarize.output}}}, user_confirmation_requiredTrue), ] )这种声名式的工作流的好处是编排逻辑和技能实现分离工作流本身也可以暴露给上层让 Agent 把整条工作流当成一个超级技能来调用。4.2 决策编排策略让大模型在节点之间当路由我试过两类工作流调度策略。第一类是固定流程无论用户说啥只要触发了这个工作流就从上往下执行所有步骤。优点是稳定、可控、容易调试缺点是太死板用户其实只想要第一部分的时候它也把后面全跑了。第二类是模型引导工作流里的每个分支都交给模型选择。在我做的几个真实项目里第一类占比大约 60%用来处理那些步骤明确、固定不变的流程第二类占比 40%用来处理有一定发散空间的场景比如数据分析 - 报告生成这种如果数据没有达标就自动跳转到告警分支。我个人的偏好是底层步骤尽量固定上层决策尽量放给模型。比如拉取数据和统计聚合这类确定性的操作直接固定顺序执行而生成什么样的报表风格这类开放性问题可以交给模型在多个文本生成技能里做选择。4.3 编排层要注意的两个隐蔽问题跑了几个月编排层有两个问题值得单独拎出来讲因为它们是纯文档里很难一眼看出来的。第一个问题是上下文数据的序列化兼容性。工作流里前一个技能的输出往往是 JSON 结构而后一个技能的参数期望是字符串或者某个特定格式。我在设计 Step 定义时没有考虑到这一点导致经常出现上游返回了 dict下游技能期望一个 string结果硬生生塞进去传参报错的情况。这个问题最终的解法是引入一层adapter每个 Step 在把上游输出注入下游参数之前先过一个轻量的转换函数。比如常见的转换有从 JSON 里提取某个 key 并转为字符串“把数组用逗号连成句子”等。现在我在定义工作流时已经强制要求每个 Step 声明output_adapter哪怕不用也要显式写个 identity 函数防止数据格式问题悄悄溜到运行时。第二个问题是模型参与编排时引入的语义漂移。模型对上游数据的理解是基于文本的它输出中间结果时往往会做无意识的信息压缩或改写。在一环接一环的工作流里每一环都丢失一点信息到最后一环输出时可能已经和原始数据对不上了。为了缓解这个问题我做了两个约束第一凡是数据聚合、格式转换这类有确定性规则的中间步骤一律用普通 Python 函数而不是让模型参与第二模型参与的文本生成步骤必须在系统提示词里明确写上所有输出必须严格基于给定的输入数据不得引入外部知识或对数字进行四舍五入以外的处理。别觉得这很离谱——模型真的会把订单金额 12345.67随手改写成大约 1.2 万元这种数据失真在工作流里是致命的。5. 沙箱、白名单和失败兜底技能系统里最容易翻车的地方聊完怎么让技能跑通接下来必须聊怎么让技能不乱跑。Agent 技能系统的安全边界是我在整个项目中花时间最多、也最有心得的部分。这里我把它分成三个层次输入层、执行层、反馈层。5.1 输入层的白名单校验模型的输入本质上是一个什么都有可能生成的自然语言模型所以它输出的参数天然是不可信的。在执行任何技能之前参数都要过一道白名单校验而不仅仅是 Schema 校验。比如fetch_web_page的 url 参数白名单必须拒绝file://、localhost、169.254.169.254云元数据地址、内网 IP 段。任何技能的参数里都不允许出现路径穿越字符比如../、/etc/passwd。如果技能涉及执行 shell 命令命令字符串必须走命令白名单 参数白名单的方式而不是直接把参数拼进去执行。这些规则写起来不难难的是你必须有这个意识。我第一次做信息抓取技能的时候就没想到要拦内网地址。后来内测时出过一次大问题——模型根据某个用户的需求下载了一个恶意网页里的图片链接进而触发了一次对内网某个服务的探测请求。虽然当时没有造成实际影响但这给我上了一课技能系统暴露给模型的任何能力都要站在这个能力会被怎么滥用的角度重新审视一遍。5.2 执行层的沙箱化如果技能里包含任意代码执行、任意命令执行这类高危能力一定要把执行放在受限环境里。我在实际项目里用到了两种方案轻量方案利用 Python 的subprocess配合resource模块限制子进程 CPU 时间和内存再用容器技术做一个简单的沙箱。这个方案适合那些你信任度不高的代码片段。重量方案直接把高危技能的执行放到独立容器或隔离进程里通过消息队列通信。我实际使用中选了第二种方案因为容器隔离最省心依赖、网络、文件系统都干净不会污染宿主机。为什么执行层一定要隔离因为模型输出参数的不可控性比很多人想象的要严重。我用过一个工具类技能它接受一个表达式参数然后交给 eval 执行。模型生成的表达式十次有三次是正常数学运算但也可能生成一些诡异的字符串去试探环境。这也是很多大模型注入攻击的立足点——给模型开放了一个可执行任意代码的窗口就等于把模型变成了攻击者的代理。所以我对所有带 eval、exec、shell 的技能一律强制跑在隔离容器里而且容器进程没有外网访问权限。这个经验值千金我遇到过有用户故意诱导模型生成读一下服务器上的某个文件内容这样的注入最后因为文件路径白名单 容器隔离双双拦住了。5.3 反馈层的失败兜底技能执行一定会失败而且失败方式千奇百怪超时、接口 500、数据格式不对、模型参数不合法。失败兜底设计的关键不是我要有多么完美的错误处理而是我要让 Agent 在失败后依然保持对话的连贯性和用户的可理解性。我在 agent-skills 里定了一条铁律在把错误信息发给模型之前先转成一段 用户 模型都能理解的自然语言描述。比如技能调用超时我会生成该操作耗时超过 10 秒可能因为目标服务响应缓慢可以稍后重试。这样的描述既不会暴露内部细节又能让模型基于它做出下一轮决策——是重试、换一个技能还是向用户如实解释。另外一个兜底设计是技能调用失败次数上限。在单个 agent 循环里如果一个技能连续失败三次就应该停止循环向用户输出一句明确的失败说明而不是让模型拿着一个已经报错的函数继续空转。模型在循环里有时候会困在同一个技能上反复调用即使错误信息已经很明显了。我实测中见过模型连续 5 次调用同一个坏接口最后直接爆掉整个对话上下文。加上了同一技能轮次内最多失败 2 次的规则之后这种情况就再没出现过。5.4 人工确认与审计日志最后是人工确认机制。所有带safety_levelhigh的技能比如发送消息、删除数据、执行写操作在执行前必须经过用户显式确认。实现方式有两种一种是在 Agent 回复里附带一个确认按钮用户点击后才继续执行另一种是 Agent 先把计划输出给用户收到 是/确认 之类的明确信号后再执行。我强烈建议所有涉及外部副作用尤其是不可回滚的的操作都强制走这一层。日志方面我会把每一次技能调用的记录完整记下来时间、用户 ID、技能名、参数摘要、执行结果状态、耗时。这个日志主要有三个用途排查模型选错技能的问题、做安全审计、以及观察哪个技能被高频调用进而优化 description 的匹配度。日志的格式不用很复杂JSON 行日志就够用。6. 实测跑通的两个场景与参数调优记录理论讲得再多也抵不上拿真实场景压一压。我在 agent-skills 这个体系下跑通了两个比较完整的场景并且记录了调参过程中的具体数值和教训这一节我把关键数据分享出来希望能给你们一些直观参考。6.1 场景 A信息收集与摘要助手这个 Agent 的价值是给定一个话题它自己去搜索引擎或内部索引里找几篇相关文章提取正文然后生成一份结构化的摘要。技能链如下web_search(查询词) - fetch_web_page(选取的结果链接) - extract_main_content(网页正文) - llm_summarize(汇总提炼)指标方面我这个场景最关心的是正确选出要抓取的链接的准确率和最终摘要与源文章内容的一致性。实测下来的调优记录如下最初只给模型web_search技能模型返回了搜索结果列表后再让它根据 result 字段去 http 请求。这里的不稳定点是模型有时会把 search_result 里的 snippet 误当成网页正文传给下游。改成工作流之后每个节点只做一件事且节点的输出被明确标注为search_results、raw_html等结构化类型。准确率从大概 70% 提到了 88%。影响准确率的最大变数还是 description 写得好不好。我把web_search的 description 里加了当用户提到查找最新信息、当前情况、新闻报道等时使用选搜索技能的准确率提升最明显。temperature 从 0.7 降到 0.2 后模型对 URL 的处理更保守不容易把外链拼错、拼接出奇怪的字符。但降到 0 对文本生成反而会带来僵硬化所以我的建议是决策类步骤低温度生成类步骤中等温度。6.2 场景 BNL2API 数据查询助手这个 Agent 是让我真正相信技能化这个思路的场景。用户用自然语言问上周华东区的销售额与目标对比系统需要把它转换成一个内部 BI 平台的 API 调用拉取数据再生成结论。这里涉及最复杂的参数生成任务因为 API 的参数包含时间范围、地域维度、聚合级别等好几个字段。跑这个场景的时候我记录了几个特别值得讲的坑第一个坑模型把自然语言里的上周直接翻译成日期但给的日期经常是错的。它有时会从当天的对话上下文倒推把上周理解成从今天往前数 7 天但业务上上周是自然周周一要重置统计。解决方式是不给出 naive 的日期翻译方案而是让一个技能专门负责把上周本月季度至今这类相对时间转成精确的起止日期。转换规则固化在代码里不再交给模型自由发挥。第二个坑模型会在 API 参数里编造一个不存在的字段。在additionalProperties: false的前提下我最初让 Schema 只做类型约束结果模型偶尔会新生出一个dimension_level字段。后来我给 Schema 加上了严格的枚举约束只允许 day、week、month 三选一这个情况就消失了。可见Schema 越钉死模型的自由度就越低正确率反而越高。第三个坑数据的单位换算。API 返回的销售额是小数点后两位的以万元为单位的值但模型在生成结论时偶尔会把它当成元来描述导致报告里所有数字都错了一个数量级。这种问题很难通过参数 Schema 解决只能靠文本生成时限定背景信息。我在最终结论生成技能的系统提示词里写死一行金额数值一律以万元为单位表述禁止转换或四舍五入此后这类错误基本清零。6.3 关于温度和其他采样参数的一些记录为了这个项目我做了几轮简单的对照把几组采样参数分别用来跑同一批测试问题最终采用的方案是参数决策阶段选技能/选工具生成阶段写摘要/写结论temperature0.1 - 0.20.4 - 0.6top_p0.50.9max_tokens按工具响应长度需求按报告长度需求选技能、选工具这类决策任务是单选题性质temperature 高了模型就容易在几个语义相近的技能之间摇摆生成文本则保留一点随机性可以避免文本显得生硬。这是我目前最顺手的一组配置但说实话不同模型表现差异很大你们换模型之后一定得自己重新调一遍。7. 部分实现代码与可复用的工程细节前几章零散提到了不少实现片段这一章我整合出一段可以直接跑的最小工程骨架包括技能注册、调用循环和工具转换函数。这些代码是我在实际项目里反复精炼过的可以直接复制出来改改就用。7.1 技能转 OpenAI tools 格式大模型产品里的 Function Calling 通常会要求一个统一的 tools 描述格式比如 OpenAI 风格的{type: function, function: {name, description, parameters}}。agent-skills 里做了一个适配函数把内部 Skill 对象统一转成这个格式def skill_to_openai_tool(skill: Skill) - Dict[str, Any]: return { type: function, function: { name: skill.name, description: skill.description, parameters: skill.parameters_schema, }, }如果对接的是 Anthropic 系的模型可能需要把parameters格式做一些映射调整但这个适配函数的思路是一样的——只在出口处转换内部始终保留统一的 Skill 对象。7.2 技能注册的几个工程约束为了让注册表在团队协作和多人开发时不乱掉我加了几个约定在实际开发里非常好使技能名必须以模块前缀开头比如web_、data_、admin_这样看名字就知道归属模块。每个技能文件末尾必须有一小块自测断言代码用 fake 参数跑一遍执行函数确保注册表能正确加载。这相当于一个极简的冒烟测试。不允许在技能执行函数内部访问全局会话对象。所有参数都必须显示地在 parameters_schema 里声明执行函数签名只接受显式参数。这避免了很多隐式耦合导致的难排查问题。这第三点在代码层面是这么约束的执行函数签名按**kwargs收参然后在入口处用inspect.signature校验任何未在声明里的参数都会直接报错并抛出技能参数异常。7.3 并发调用多个技能的注意点在一次 agent 循环里模型可能同时发起多个技能的调用tool_calls 数组里有多项。这里很容易出现的问题是这些技能往往共享同一批数据源而你如果把它们的并发压力全部放到上游服务上上游接口大概率会被瞬间打垮。我在实现里加了一个简单的并发限流器from concurrent.futures import ThreadPoolExecutor import threading class SkillRateLimiter: def __init__(self, max_concurrency: int 3): self._sem threading.Semaphore(max_concurrency) def run_with_limit(self, skill_name: str, params: dict): with self._sem: return SKILL_REGISTRY[skill_name].execute(**params)当然更细粒度的方案是按不同技能配置不同的并发上限比如发送邮件一次只允许 1 个并发抓网页能容忍 5 个并发。考虑到稳定性优先我还是以全局默认并发 3 为主特殊技能再单独覆盖。8. 我在技能系统设计上的一些反思和后续规划讲完了思路、代码和踩坑记录最后想聊点不那么工程的东西。agent-skills 做完后我回头审视整个设计发现有几个地方如果让我重做一遍可能会采用完全不同的方案。第一技能的原子性粒度。我最初把技能设计得特别细比如get_commit_list、get_commit_detail、summarize_commit拆成三个独立技能。好处是模型调用精准、复用灵活坏处是模型在多步流程里需要频繁做选择-调用-再选择每一步都有出错概率累计起来整体成功率并不高。后来我把高频率一起出现的技能合并成了复合技能比如get_commit_report get_commit_list summarize_commit整体成功率反而上去了。所以我对技能粒度的建议是能合并成原子任务的就合并让模型少做决策需要灵活组合时才拆细。这听起来像废话但少做决策这条原则是真的能提升系统稳定性的。第二技能描述的自动化维护。人工维护几十个技能的描述是个体力活而且当技能行为调整时经常忘了同步 description。后续我想加一个 CI 校验每次技能代码变更时自动检查 description 是否仍然和参数 Schema、执行函数签名保持一致。比如execute函数新增了一个required_params但没有同步到parameters_schemaCI 就应该直接报错。这个约束如果能跑起来应该能避免不少静默失配问题。第三跨会话的技能记忆。到目前为止每个对话都是一个独立会话技能执行产生的结果不会被下一个会话复用。但在真实办公场景里用户往往会问和上次那个报告一样把本周数据也拉出来。这需要技能系统支持会话间状态读取和技能执行历史摘要。这算是一个进阶方向我目前只做了初步设计还没有完整实现。如果你们的产品也有类似需求建议从一开始就设计好技能执行的持久化存储后面会省事很多。我在落地这套体系之后最大的感受是Agent 的成败不在模型选得有多聪明而在你愿意为工程稳定性和可运维性投入多少心思。模型今天在一个简单场景里看起来无所不能但一旦放到真实业务里各种边界情况会接踵而至。agent-skills 这套东西不是什么神奇的银弹它只是把大模型应用落地这件事拉回到了认真做工程的正确轨道上——定义清晰的接口、控制好状态、做好隔离与兜底。如果你正打算做一个稍微复杂一点的 Agent我真心建议你从技能设计开始先别急着调 prompt。把技能定义写得扎实一点把调度链路走通一遍后面所有上层交互都会稳得多。
返回列表