| >skills/ common/ # 通用基础技能 send_message.py get_current_time.py document/ # 文档处理域 extract_text.py summarize_document.py data/ # 数据查询域 query_metrics.py export_table.py agent/ # Agent编排相关 run_chain.py 命名上尽量用“动词_宾语”的蛇形形式,比如extract_text、send_message。这是因为大模型对“动作+对象”的命名理解最准,而且调用方在日志里看到技能名时,一眼能知道这个技能做了什么。 单个技能内部,我会拆成四个文件:skill.py(实现和注册)、schema.py(参数定义)、description.md(描述文档,经过评审的版本放这里)、tests/(针对该技能的单元测试和回放用例)。这样的好处是,改描述不用动代码,改代码不用动描述,评审流程也能分开走。 2.3 自研还是全家桶:我的取舍这个项目最初考虑过直接使用现成的Agent框架,后来还是决定自研一个轻量核心。原因有两个。 第一,现成框架都会带来生态绑定。你用了它的工具定义方式,后面想换一个模型平台、换一种编排逻辑,改造成本非常高。技能库本意是解决“被写死的东西困住”的问题,如果核心本身也被框架捆死,就违背初衷了。 第二,技能库的核心逻辑其实不复杂。注册、描述、校验、转发、日志,核心部分大概两百行代码就能写完。自研可以完全掌控行为,做权限控制和指标采集都很顺手;现成框架为了兼容各种场景,反而会给你塞很多用不到的概念。 取舍之后的方案是:核心注册与调度完全自研,只管“技能注册、参数校验、调用转发、技能链执行”这四件事;具体的模型调用、文档解析、消息发送这些业务能力,都作为技能来实现。这样核心的逻辑保持稳定,而能力可以无限扩展。 还有一点选型心得:不要一开始就追求“完美架构”。我建议先实现最小闭环——一个注册表、一个装饰器、一个执行函数——跑通“模型选技能、技能执行、结果回填”这个链路,再逐步加校验、指标、灰度这些工程化能力。大部分项目死在前期过度设计上,而不是死在能力不够上。 3. 从注册到调用:核心代码链路这样打通3.1 用装饰器把函数变成技能我最初设计时注册逻辑比较简单,就是用注册表加一个装饰器。翻译成核心代码大概是这个样子: # skills/registry.py class SkillRegistry: def __init__(self): self._skills = {} def register(self, name, description, parameters, handler): self._skills[name] = { "name": name, "description": description, "parameters": parameters, "handler": handler, } def list_for_llm(self): return [ { "type": "function", "function": { "name": item["name"], "description": item["description"], "parameters": item["parameters"], }, } for item in self._skills.values() if item.get("enabled", True) ] def execute(self, name, arguments): item = self._skills.get(name) if item is None: raise KeyError(f"skill not found: {name}") return item["handler"](**arguments)
装饰器就负责把元信息登记到注册表里,同时保留原函数可以直接调用: # skills/decorator.py def skill(name, description, parameters): def decorator(func): registry.register(name, description, parameters, func) return func return decorator
我把注册表做成单例,方便各模块import。装饰器有一个容易被忽略的设计点:它返回的仍然是原函数,而不是包装后的函数。这样技能既可以被Agent通过注册表调用,也可以被普通代码直接函数调用,便于测试和复用。 3.2 参数校验与执行上下文:让技能不再裸奔技能被调用时,参数不一定可靠。模型可能编造参数值、漏掉必填字段,甚至给完全无关的键。所以在执行之前,我会做一道强制校验。常见的做法是对照JSON Schema做一次检查,不通过就直接返回结构化错误,而不是让异常把整个会话炸掉。校验还可以做值归一化,比如把模型返回的“一百”“500字左右”这类自然语言转成合理的数字。 执行上下文也是我踩过坑之后才补上的。最初每个技能都是裸函数,内部直接读环境变量或全局配置,结果技能一多、并发一起来,请求之间互相污染,排查起来痛不欲生。后来我引入了一个显式的Context对象,携带request_id、调用者身份、技能配置、会话摘要等字段,在每个调用入口构造好之后传给技能。技能内部只能通过Context读取环境信息,不能再私自访问全局变量。这样既隔离了请求,也让日志能串起来:看到一条报错,顺着request_id就能找到完整调用链。 3.3 对接LLM函数调用:描述写得好,模型才不跑偏注册表与模型调用的对接,是这套系统里最关键的环节。一次典型调用流程是这样: - 组装消息列表,把注册表里的技能列表通过函数调用接暴露给模型。
- 模型判断用户意图后,返回一个或多个技能调用请求。
- 我解析调用请求,从注册表里找到对应技能,执行后把结果回填给模型。
- 模型基于工具结果继续作答。
代码如下,这段基本是固定的模板: functions = registry.list_for_llm() resp = llm.chat(messages=messages, functions=functions) while resp.tool_calls: for call in resp.tool_calls: name = call.function.name args = json.loads(call.function.arguments or "{}") try: result = registry.execute(name, args) except Exception as exc: result = {"error": str(exc)} messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False), }) resp = llm.chat(messages=messages, functions=functions)
描述怎么写给模型看,这个直接影响到选技能准确率。我的经验是:描述必须写成“动词+宾语+场景说明”,最好再带一个典型例子。比如一个文档摘要技能,我会写成“读取指定路径的文档并生成摘要,适用于会议纪要、研究报告、论文等文本内容,输入文档路径,返回摘要文本”。不太好的写法是“文档摘要”,模型看到这种描述,根本不知道这个技能是干嘛的、什么时候该用它。 还有一个细节:给参数的description也要写得具体。模型不是人,它看到“max_length”这个参数,不知道这个“最大长度”是字数还是字节、单位是多少。我会在参数描述里明确写“摘要的最大字数,默认500”,模型就不会乱传。 3.4 技能链:把多个技能编排成一个流程单个技能解决单一问题,但业务场景往往是“多步连环”的。技能链要解决的问题,就是把这些技能按顺序编排起来。 我用的是一份JSON配置,把链ID、执行步骤、分支条件都写在里面。执行器按顺序调用技能,前一步的输出可以作为后一步的入参。比如一个“生成日报并通知”的链: { "chain_id": "daily_report_chain", "name": "日报生成与通知", "steps": [ {"skill": "collect_metrics", "output_as": "metrics_data"}, {"skill": "summarize_metrics", "args": {"data_ref": "metrics_data"}}, {"skill": "send_im_message", "args": {"channel": "team_group"}} ] }
技能链的好处是,流程变成配置,而不是代码。业务调整时只改JSON,不用动代码、不用重新发布。执行器还可以在链级别统一做重试、超时、失败分支处理。实际用下来,我一般把链路配置放在配置中心,哪个环节需要调整,直接改配置热加载,运维体验好了不少。 4. 实操复盘:从零落地一个“文档摘要”技能4.1 边界先行:一个技能只做一件事我想用一个“文档摘要”技能作为例子,完整走一遍从需求到上线的过程。 需求其实很简单:给Agent一个文档路径,它能把文档内容结构化摘要出来。但需求越是看着简单,越容易掉进“顺手多做一点”的坑。比如有人会顺手加一个“保存摘要到数据库”的功能,这就会让技能变得难以复用。我的原则是:一个技能只做一件事,输入输出都尽量纯粹。 所以最终技能边界定义如下: - 输入:文档本地路径doc_path、摘要长度max_length(可选,默认500)
- 输出:摘要文本summary、源文档来源source
- 不做的事:不发送消息、不保存结果、不处理远程上传——这些全部交给其他技能组合
边界清晰之后,实现难度大降,测试也好写。 4.2 描述与Schema:先写给人看,再写给模型看技能注册时,description和schema是给模型看的接口,重要性不比实现低。我先写一版给人看的描述,找团队同事帮我评审:假设你是一个没有背景知识的模型,看到这个描述,你知道什么时候调用它吗? 最终敲定的描述是这样: 读取指定路径的文档并生成结构化摘要,适用场景包括会议纪要、研究报告、论文、合同等文本内容; 当用户要求总结、概括、提炼文档要点时,可调用此技能。 输入文档路径后返回摘要文本,摘要长度通过max_length控制。
这里有一个很实用的技巧:描述里加一句“适用场景”,再加一句“当用户要求……时可调用”,等于给了模型正反两个锚点,能明显降低误调用率。 Schema部分,doc_path标记为必填,max_length给默认值并写清单位。我还会给模型补充说明,让它在不确定时留空而不是硬编造。 4.3 干跑、回放与验收写完技能后,我会做三轮验证,而不是直接扔给Agent回归。 第一轮是干跑:不真调用原始业务接口,只用构造的假数据跑注册、校验、执行逻辑,确认参数能正确解析、流程能走通。这块用普通单测就能覆盖。 第二轮是回放:录一段历史会话里模型对这个技能的调用记录(包括参数和预期结果),修改代码之后直接重放,看行为是否一致。回放这招对技能库特别有用,因为模型调用技能的参数五花八门,你很难事先想到所有边界情况,从真实日志里捞数据回放是最快的方式。 第三轮是端到端验收:开一个临时Agent会话,用真实需求让模型自己选技能、调用技能、组织答案。我特别关注三件事——模型有没有选错技能、参数传对没有、返回结果能否支撑回答。验收通过后,这个技能才进入可发布状态。 4.4 上线那点事:开关、日志、灰度技能上线不是把代码合进去就完了。我的习惯是三步走。 先加开关。每个技能都有enabled字段,上线时先默认关闭,确认环境无误后再打开。有人觉得开关多此一举,但真实世界里,一个技能可能被几十个会话同时使用,你没有开关就等于没有退路。 再定日志规范。每一次技能调用都要记录:技能名、参数摘要(不用记录完整参数,避免敏感信息泄露)、执行耗时、返回状态、request_id。这一层日志能支撑很多事后排查,没有它,问题定位就是纯靠猜。 最后做灰度。先让内部测试Agent加载新技能,观察指标没问题后,再放开给少量线上Agent,逐步扩量。对于非关键技能,这步可以轻一点,但涉及写操作或外部系统对接的技能,灰度是必须的。 5. 上线后避坑:常见问题与排查技巧实录5.1 问题速查表:一次看全我整理了一张速查表,覆盖技能库上线后最常遇到的几类问题: | 问题现象 | 可能原因 | 排查思路 / 解法 |
|---|
| 模型从不调用某个技能 | 描述太模糊、缺少适用场景 | 重写描述,加“动词+宾语+场景”,附典型调用示例 | | 模型把参数传错类型 | Schema缺少类型约束、description没说清单位 | 严格JSON Schema校验,增加参数description | | 技能返回了模型看不懂的结果 | 返回结构不规范、缺少关键字段 | 统一结构化返回,必要时在返回里加自然语言说明 | | 多个技能同时命中 | 描述语义重叠 | 明确技能边界,让描述聚焦各自场景 | | 技能内部报错中断会话 | 异常未被捕获、未返回结构化错误 | 统一错误捕获,返回{"error": "..."} | | 线上技能行为发生突变 | 代码改动未走灰度、版本未管理 | 加版本号、加开关、先灰度后放开 | | 技能调用链路查不到 | 日志查询未走索引、request_id缺失 | 统一request_id,日志走索引或时序库 |
5.2 模型老是不选某个技能?先检查描述有一次,我上线了一个“查询经营数据”的技能,功能都正常,但业务方反馈模型几乎不用它,老是用一个功能相近的旧技能。我一开始以为是模型问题,后来做了个实验:分别用两个技能描述去调模型,让它判断用户问题该用哪个。结果发现,新技能描述里只写了“查询经营数据并返回表格”,而旧技能描述里写清了“适用于销售、运营、财务等数据查询场景,返回DataFrame格式”。差距一目了然——模型是根据语义匹配选技能的,你的描述越具体、越接近用户会说的话,命中率越高。 后来我把所有技能的描述都重新梳理了一遍,格式统一为“功能一句话+适用场景两三句+输入输出说明+典型触发语”。模型选技能的准确率明显提升,这个经验我复用到所有项目上。 5.3 参数幻觉:模型编造了不存在的参数模型在调用技能时,偶尔会编造一些不存在的参数。我记得有一次,技能只接受doc_path和max_length,模型却传了一个source_format: "pdf"进来。原因在于模型对技能的参数理解不够,或者从上下文推断出了一些似是而非的信息。 对付参数幻觉,我做了三层防护。第一层是Schema校验兜底,不认识的参数直接忽略或报错,绝不能让它们插进代码执行;第二层是提示约束,在调用前给模型补一句“只能使用给定参数,不要猜测额外参数”;第三层是示例兜底,如果确实有高频传错的参数,我会在参数description里加上“如无必要不要传此字段”之类的负面约束。三层下来,参数幻觉基本能压到忽略不计。 5.4 技能卡死与超时控制技能里只要涉及外部IO,比如调接口、读文件、请求第三方服务,就存在卡死的可能。我在最初版本里没有做超时控制,结果有一次某个查询技能调外部接口时长时间无响应,整个Agent会话被拖死。从那次以后,我把超时控制当成技能库的标配能力。 我给所有技能默认设置一个超时时间,普通技能5秒,写操作或外部依赖重的技能可以配到15秒。超时后返回结构化错误,会话还能继续,不至于整体崩溃。对于特别耗时的技能,我还会改造成异步执行加轮询结果的模式,或者用流式输出分段返回进度,避免让用户干等。 还有一个细节:技能内部的网络请求也要单独设置超时,不要只依赖外层超时兜底。外层超时只能兜住最坏情况,内层超时能让技能快速失败、快速报错,用户体验完全不同。 如果让我分享一个个人感想,那就是“技能库最值钱的不是代码,是那套契约”。当初我花在梳理技能边界和写描述上的时间,远超过写注册表实现的时间,但正是这些“看起来不酷”的工作,让整个系统从能跑变成了好用。最后再送大家一个小技巧:在技能描述末尾加一个“不适合什么场景”的负面说明,模型误调用的概率会再降一截。这个技巧我从一开始就用,到现在依然觉得是性价比最高的一行字。 RELATED READING 延伸阅读 更多一线实战笔记与深度复盘,助您持续精进 |