从聊天框到能干活的智能体:Agent技能体系搭建实战
Agent 这个概念这两年翻来覆去讲了很多但说真的能落地的没几个——原因很简单大多数团队拿着大模型 API做出来的东西还是聊天框不是干活的智能体。我自己折腾了快一年感觉真正的差距不在模型选得够不够好而在你有没有一套完整的agent-skills工程体系。所谓 agent-skills说白了就是给大模型装上一组可复用、可管理的外部技能让模型能查数据、发邮件、写文件、做分析而不是光用嘴回答。这篇文章写给想自己搭 Agent 的算法工程师、被老板要求搞个 AI 助理的开发者以及对 Agent 落地感兴趣但还不太清楚入口在哪的产品同学。内容会拆解技能体系的底层逻辑也会直接给一套能跑的最小实现方便你照着抄。1. 先理解agent-skills 到底在解决什么问题1.1 大模型是大脑技能才是手脚大语言模型的本质是文本概率生成它内部没有任何执行能力。你让它帮我把这份数据画成图它能写出一段很完美的 Python 代码但如果你不把这段代码拿到某个环境里去执行那张图永远也不会出现用户拿到的仍然是一堆文字。这就是 Agent 和聊天机器人的分界点聊天机器人输出内容Agent 输出结果而从输出内容到输出结果这一步必须靠外部技能来补。举一个生活化的例子。一个新员工再聪明如果没有公司邮箱、没有项目管理系统账号、不知道报销流程他只能说好的我来处理一下实际上什么都办不成。技能就是提前把这些账号、流程、工具准备好再写一份清晰的操作说明书。大模型就是这个新员工agent-skills 就是他办公桌上的一套信息系统。那为什么不把所有操作细节写在大 prompt 里呢我之前试过把工具用法写进 system prompt模型确实能看但每次对话都要重复传一大段工具说明token 消耗高而且模型经常理解偏。技能化之后传给模型的是一个带参数约束的接口 schema模型只需要按 JSON 格式填写参数发起调用正确率比口头描述工具高一大截。另外技能化还有四个天然好处可复用、可测试、可组合、可降级。写一次多个场景复用没有用户现场也能单测复杂任务可以编排多个技能协同某个技能挂了系统还能走 fallback 提示用户换条路。1.2 技能和工具真不是一回事很多初学 Agent 的人容易混淆工具和技能。工具是最小执行单元比如发送 HTTP 请求执行一段 Python 代码查询数据库。技能是高层的编排它把多个工具串起来加上参数校验、上下文约束、输出格式要求和兜底逻辑。举个例子数据分析这个技能背后至少有四个工具读取 CSV、执行 Python、画图表、生成报告。如果直接把四个工具丢给模型模型大概率会漏步骤或者生成一个格式不统一的报告。但封装成一个数据分析技能后模型只要说一句帮我分析这份销售数据技能内部就能按固定流程把数据读进来、跑统计、出图表、生成 Markdown 报告。我习惯把一个技能拆成两部分元信息技能名、触发描述、参数 JSON Schema、触发示例、互斥技能列表。实现体真正干活的函数或 API包含入参校验、执行逻辑、异常捕获、返回结构化 JSON。元信息是给模型看的说明书必须写得像产品文档而不是技术注释。实现体是给系统用的必须稳定、可降级。一个技能做得像不像样一半看描述一半看 handler 的异常处理——很多 Agent 跑崩不是因为模型笨而是因为某个工具抛了个非 JSON 异常直接把整个循环搞死了。2. 拆解一套可用的技能体系核心技能组逐个过一遍2.1 工具调用技能组让模型真的能操作外部系统工具调用依赖大模型 API 的 function calling 机制。模型不直接执行代码而是在判断该用某个工具时返回一个结构化的调用请求包括工具名和参数 JSON真正执行的是你应用层的代码。下面是一个发邮件技能的工具定义我通常写成 JSON Schema{ name: send_email, description: 发送邮件。当用户要求发邮件、回复邮件、转发邮件时使用。如果收件人邮箱未指定必须先向用户确认不要猜测。, parameters: { type: object, properties: { to: {type: string, description: 收件人邮箱地址}, subject: {type: string, description: 邮件主题}, body: {type: string, description: 邮件正文支持 Markdown 格式} }, required: [to, subject, body] } }这个 schema 看起来简单实际写的时候有三个坑。第一description 不能只写发送邮件这种废话必须写清楚什么时候触发、有什么前置条件比如当收件人未指定时先反问用户。第二必填参数要克制不是所有字段都非填不可能做成可选的尽量可选减少模型在第一步就因为缺参数而反复问用户。第三所有工具返回结果必须是结构化 JSON至少带一个 status 字段比如{status: ok, data: {...}}或者{status: error, message: ...}模型才能可靠判断下一步是继续还是换方案。工具调用组是 agent-skills 的底座没有它后面什么都谈不上。最优先要实现的三个工具是HTTP 请求工具、文件读写工具、代码执行工具有了这三样模型就能访问主流的外部系统了。2.2 记忆管理技能组让 Agent 不说完就忘很多 Agent 做出来的demo 看起来聪明一开多轮对话就露馅因为它没有记忆体系。大模型的上下文窗口是有限的短期记忆只能靠对话消息硬撑长对话必然溢出。记忆管理技能组要解决的是哪些信息值得长期记住存在哪怎么在需要的时候读出来。我常用的分层方案是记忆类型存储载体读取方式典型用途短期记忆对话上下文消息列表模型自然读取当前任务的前后文工作记忆任务运行时的变量存储代码内部读取中间结果、临时状态长期记忆向量库 摘要存储语义检索 top_k用户偏好、历史决策、事实信息事件记忆时序数据库或日志时间范围查询操作审计、自动补全上下文长期记忆落地时别一股脑把每轮对话全部塞进向量库那样检索质量会很差。我的做法是每轮任务结束后让模型做一次记忆提炼把用户偏好事实信息未完成事项抽取成简短条目再写入向量库。查询时设置 top_k5相似度阈值 0.7 左右低于阈值的宁可不召回也不要拿一堆不相关的旧记忆污染上下文。还有一个必须强调的细节记忆必须带会话隔离每条记忆都要存 user_id 和 session_id查询时带着过滤条件否则多用户场景会串数据这是真实线上事故换来的教训。2.3 规划与反思技能组让 Agent 少走弯路任务复杂到一定程度直接让模型想到哪做到哪会非常不稳定。我说一个常见场景让 Agent从数据库里拉出上个月的订单数据做异常分析然后生成日报发给老板。如果是自由发挥模型可能第一步就去调发邮件技能邮件发出去里面什么都没有。所以规划技能很重要。规划有两种主流路线。ReAct 模式是边执行边思考模型观察环境反馈再决定下一步灵活但容易走偏。Plan-and-Execute 是先让模型列出完整的执行计划然后按计划逐步调用技能可控性好得多。我的经验是凡是超过三步的操作型任务强制先出计划把计划展示给用户确认后再执行这样既省 token又不会让模型中途放飞。反思技能是配套的它负责在技能执行失败时兜底。执行出错后把工具返回的 error 信息拼进一个反思提示词让模型看日志、分析原因、换一个不同的方案重试。我用的提示词模板是根据执行日志刚才尝试失败了。请分析失败原因可以调整参数、换工具或换执行顺序但不要重复刚才已经失败过的调用。如果分析后认为任务无法完成直接回答无法完成并说明原因。这里最关键的工程约束是最大重试次数。不要相信模型能无限自我纠正默认 max_retry3超过就把控制权交还给用户。我见过最夸张的一次是模型连续八次调用同一个搜索技能每次返回都一模一样纯粹在烧 token。2.4 检索技能组把 RAG 封装成技能把 RAG 做进技能体系之后它就不再是外挂的知识库而是一个标准的、带参数约束的技能。这个技能本质上就三个参数query、top_k、filters。用户的原始问题进来后要进行一次 query 改写把口语问题变成适合检索的表达这比直接拿原文去向量检索准得多。检索技能里最关键的参数是 chunk 切分方式。我实测下来中文场景固定字符切 300-500 字、overlap 50-80 字按标题结构切比纯长度切稳定很多。如果知识库里一个章节特别长按标题切可能还是超限就先切出一级标题块再对二级标题做二次切分。召回阶段如果资料库超过一万条建议先召回 50 条候选再用一个重排模型压缩到 5 条直接向量相似度 top 5 往往会在开头几条命中文档质量不高时翻车。还有一点容易被忽略什么时候该走 RAG什么时候该走实时 API。静态的政策文档、产品手册适合进 RAG价格、库存、订单这类高频变化的数据应该走 API 技能不要同步进知识库否则第二天数据就过期了。2.5 多模态理解技能组提前占坑如果产品场景里有 PDF、图片、音视频需要一个独立的多模态理解技能组。不要直接把几十 MB 的 PDF 丢给模型帮我读一下绝大多数模型上下文窗口扛不住即使能读token 成本也很离谱。我通常的做法是PDF 先做解析层拆成文本块、表格块、图片块表格交给表格抽取模型处理成结构化数据图片交给多模态模型做摘要只有最终提炼出来的文本走主模型成本能省一个数量级。3. 实操从 0 到 1 搭建一套自己的 agent-skills3.1 技术选型不急着上框架先搭一个最小闭环搭 agent-skills 之前我建议先做一次减法不要第一步就上 LangChain、LlamaIndex 或者 Semantic Kernel。这些框架确实封装了很多东西但抽象层级太高出了 bug 你连模型为什么这样调用都很难查清楚。我自己的路线是先用 Python 配 OpenAI SDK 或 Anthropic SDK 搭一个最小闭环跑通之后再上框架那时候看框架源码都会容易很多。一个最小可用的 Agent 闭环至少要包含四块模型客户端、技能注册表、循环控制器、记忆存储。架构逻辑很简单——用户输入进来系统先做技能匹配选出当前任务相关的 Top K 个技能连同这些技能的 schema 一起发给模型模型决定是否调用某个技能如果调用则返回工具调用请求应用代码执行它并把结果以 tool 消息喂回模型模型再根据结果决定下一步直到它不再请求调用工具直接输出最终答案。3.2 第一步定义技能注册表技能注册表是整个体系的骨架。我用 Pydantic 定义技能元信息用注册表统一管理所有 handlerfrom typing import Any, Callable, Dict, List, Optional from pydantic import BaseModel, Field class SkillInfo(BaseModel): name: str Field(..., description技能名全局唯一) description: str Field(..., description给模型的技能说明写清楚触发条件和边界) parameters: Dict[str, Any] Field(default_factorydict, descriptionJSON Schema 参数定义) handler: Callable[..., Any] Field(..., description实际执行的函数) examples: List[str] Field(default_factorylist, description触发示例帮助模型理解何时调用) conflict_with: List[str] Field(default_factorylist, description互斥技能列表) embedding: List[float] Field(default_factorylist, description技能描述的向量用于技能匹配)注意 handler 的类型是Callable所以注册进去的必须是一个真实函数而不是字符串。参数里的examples字段很重要模型对示例比抽象描述敏感得多我后面会展开讲。conflict_with是防止技能误触发的保险丝两个技能描述相似时这个字段能帮模型避开错误选项。注册表本身就是一个字典skill_map: Dict[str, SkillInfo] {} def register_skill(skill: SkillInfo) - None: if skill.name in skill_map: raise ValueError(fskill {skill.name} already exists) skill_map[skill.name] skill3.3 第二步实现 Agent 主循环技能注册表准备好了下一步是实现核心循环。下面是一个我精简后的最小 Agent 循环不依赖框架直接调用 OpenAI SDKimport json from openai import OpenAI client OpenAI() MODEL gpt-4o MAX_STEPS 10 def run_agent(user_input: str, active_skills: list) - str: system_prompt ( 你是一个会使用工具完成任务的人工智能助手。 你可以调用下面的工具来辅助完成任务。 如果工具返回错误请分析错误并尝试其他方式。 当你确认任务完成或无法继续时停止调用工具直接输出最终答案。 ) messages [ {role: system, content: system_prompt}, {role: user, content: user_input}, ] for step in range(MAX_STEPS): response client.chat.completions.create( modelMODEL, messagesmessages, tools[ { type: function, function: { name: skill.name, description: skill.description, parameters: skill.parameters, }, } for skill in active_skills ], ) msg response.choices[0].message # 模型不再请求工具说明它认为任务已完成 if not msg.tool_calls: return msg.content or # 记录模型发起的工具调用 messages.append(msg) for tool_call in msg.tool_calls: skill_name tool_call.function.name skill skill_map[skill_name] arguments json.loads(tool_call.function.arguments) try: result skill.handler(**arguments) result_payload json.dumps( {status: ok, result: result}, ensure_asciiFalse ) except Exception as e: result_payload json.dumps( {status: error, error: str(e)}, ensure_asciiFalse ) messages.append({ role: tool, tool_call_id: tool_call.id, content: result_payload, }) return 已达到最大执行步数任务可能未完成请调整后重试。这段代码每个部分都值得说清楚。首先tools参数从 active_skills 动态生成不是把所有技能一股脑全塞进去这是省 token 和防误触发的关键做法。其次工具调用结果重发时role必须写tool并且tool_call_id必须和模型返回的一致否则 OpenAI 接口会直接报错。这个 ID 对不上的问题是我刚接触 function calling 时踩过的最莫名其妙的坑。第三handler 执行必须包异常。真实业务里任何外部依赖都可能抛异常不 try 的话整个 Agent 循环直接崩用户什么都拿不到。返回的 payload 里的status字段是给模型看的当前状态信号灯模型会根据它决定下一步是继续还是换方向。最后MAX_STEPS10是熔断器。不管模型觉得自己多能干最多让它调用 10 次工具防止死循环烧钱。3.4 第三步加一个技能发现机制别把技能库全塞进去技能数量超过十个之后每次请求都把全部技能塞给模型token 消耗会急剧上升而且模型选择出错率也会上升因为它在太多选项里容易混淆。解法是加一层技能发现机制先把用户的问题向量化从技能索引里召回最相关的 Top K 个技能再把这些技能塞进工具列表。from numpy import dot from numpy.linalg import norm def select_skills(user_input: str, top_k: int 5) - list: query_vec embed_text(user_input) # 调用 embedding 模型 scored [] for skill in skill_map.values(): if not skill.embedding: continue score dot(query_vec, skill.embedding) / ( norm(query_vec) * norm(skill.embedding) 1e-9 ) scored.append((skill, score)) scored.sort(keylambda x: x[1], reverseTrue) return [skill for skill, _ in scored[:top_k]]技能匹配做得好不好功夫在注册表阶段的描述质量。我建议把每个技能 description 的开头 30 个字当成搜索标题来写因为向量召回对开头部分的权重往往更高。比如文档搜索技能用于检索公司内部知识库中的文档当用户在提问中涉及公司政策、产品手册、流程规范时触发这比该技能可以帮你搜索内部知识库以便于快速获取信息这种软绵绵的描述好用得多。3.5 第四步一个完整实测例子——让 Agent 做周报理论讲太多容易飘直接看一个完整例子。我给 Agent 注册了三个技能list_dev_logs读取开发日志、generate_report生成 Markdown 周报、send_email发送邮件。用户输入是帮我把这个星期的开发日志整理成周报然后发给组长。技能匹配阶段select_skills召回了三个技能全部进入工具列表。Agent 循环的实测日志简化如下Step 1模型调用list_dev_logs参数{date_range: 2025-01-13..2025-01-19}工具返回一周的开发日志。Step 2模型看到日志后调用generate_report参数{format: markdown, title: 本周开发周报, content_from: log_text}工具返回一份完整的 Markdown 周报。Step 3模型把周报放进邮件正文调用send_email参数{to: leadercompany.com, subject: 本周开发周报, body: ...}工具返回发送成功。Step 4模型收到{status: ok}后不再请求工具直接输出周报已发送给组长。这个流程看起来自然但我在实测中确实踩过一个坑Step 2 结束后模型经常直接输出周报告诉用户这是生成好的周报而不是继续调用send_email。因为模型的系统指令里没有把继续执行未完成目标写进去它默认任务在生成报告那一刻就结束了。后来我在 system prompt 末尾加了一句当你的前置工具已经生成了用户需要的内容而用户原始请求中还存在后续动作比如发送、保存、更新 请继续调用对应工具完成动作除非用户明确表示只需要先预览。这句提示词加完之后Step 3 的完成率从不到 50% 提到了 95% 以上。这个经验说明很多 Agent 的不聪明其实是系统提示词和技能编排的问题不是模型能力的问题。4. 常见问题与排查技巧实录4.1 模型死活不调用技能这是 Agent 开发里最让人抓狂的问题。用户说帮我查天气模型回复好的你可以在天气应用里查看——它根本不调用天气技能。我的排查顺序是这样的先确认模型版本是否支持 function calling。这个时代基本都支持但如果你是自建微调模型很可能不支持。检查技能是否真的传进了tools参数。技能发现机制如果返回空模型当然没有工具可用。我排查过不下三次最后发现是select_skills里 embedding 为空导致技能被过滤了。看技能描述有没有写清楚触发条件。只写天气查询四个字太笼统要写当用户询问天气、气温、降水、风力时使用如果用户没有提供城市先反问城市名再调用。在 description 里加两个触发示例模型对例子的理解速度远超抽象描述。比如加一句例如北京明天天气怎么样。这里要给一个我自己验证过的技巧给技能写触发示例比把触发条件写满一页 A4 纸更管用。大模型在 function calling 时的表现跟工具描述里的例子数量强相关尤其是新模型三个例子以内通常就有质的提升。4.2 工具返回结果太大上下文爆了Agent 跑着跑着突然报上下文长度超限多半是某个工具返回了一个巨大的结果比如数据库查询拉出来一万行。模型不可能读完这么多东西token 消耗还直接爆炸。我通用的处理方案分三层第一层工具内部限制返回规模。查询类的接口必须有limit参数默认最多返回 100 条。第二层返回前做裁剪。超过 2000 字符时只返回前 2000 字符并在后面拼一句已截断共 XX 条记录给模型一个整体感知。第三层对大结果做摘要。如果数据本身就是长篇文档先让摘要模型压缩成要点再把要点给主模型。有一个常见误解是让主模型自己决定要不要截断这是不行的模型在调用工具前无法预知返回体有多大。必须在 handler 层把返回体控制好这是开发者的责任不是模型的。4.3 Agent 陷入死循环反复调用同一个技能现象很典型Agent 一直调search_docs每轮返回的结果都差不多但它就是不走下一步。排查时先看工具返回的 status 是不是ok如果模型从返回体里判断任务没有价值它可能就一直尝试换个参数再查。这种情况我建议做两件事。第一在系统提示词里写一条硬规则如果连续两次调用同一个工具且参数相同说明该路径行不通。此时必须更换策略可以换参数、换工具 或者直接向用户说明任务无法完成。不要重复调用同一工具超过三次。第二在循环控制器加熔断计数。工具层统计同一技能在单轮任务里的调用次数超过 3 次就主动返回{status: error, error: too_many_retries}强制模型换方向。这个计数不用做得很重一个 dict 就行call_count {} ... call_count[skill_name] call_count.get(skill_name, 0) 1 if call_count[skill_name] 3: result_payload json.dumps( {status: error, error: 该技能已被多次调用且未解决问题请换一个思路。} )4.4 技能匹配错乱两个技能同时被触发技能库大了以后一定会遇到search_web和search_kb同时被模型选中的情况然后它把内部知识库的搜索结果当成外部实时信息用输出内容自然出错。我排查后发现问题不在模型而在技能描述没有写互斥条件。后来我给技能元信息加了conflict_with字段并在技能匹配阶段做一次过滤如果两个技能互斥并且同时被召回系统层面就先用一个轻量模型做意图路由只保留一个。更彻底的方案是如果两个技能的操作本质相近不如直接合并成一个技能加一个mode参数比如search技能的 mode 可以选web或kb。从根上消灭模棱两可的选项比优化提示词更有效。4.5 多用户记忆串场这个坑我是在一次线上体验中发现的印象极深。用户 A 刚问完自己账户的余额用户 B 紧接着问我的余额呢Agent 直接把 A 的数字报给了 B。原因很简单长期记忆的向量查询没有做 user_id 过滤。从那之后我把所有记忆相关操作的代码里都强制带上用户维度def read_user_memory(user_id: str, query: str, top_k: int 5) - list: filter_expr {user_id: user_id} results vector_store.search( query, top_ktop_k, filterfilter_expr, ) return results同时向量库里的文档 metadata 必须包含 user_id 和 session_id。这条必须在一开始就设计好不然后面数据已经写乱了再补迁移成本很高。多租户产品尤其要注意记忆串场不是效果不好的问题是严重的信息安全问题。关于 agent-skills我最后的体会可能有点反直觉代码反而不是最难的最难的是你愿不愿意把每个技能当成一个产品去打磨。描述怎么写得让模型秒懂参数边界怎么定不会让模型乱填失败时怎么兜底用户不骂街这些都是文档不写、框架不教的脏活。我建议第一次做的人不要贪多先挑三个技能跑通一个完整的闭环比如查资料、写文件、发消息等这个环稳定了再去铺更多的技能。技能多了维护成本是线性往上走的但模型误触发的概率是指数级往上走的这个尺度自己拿捏好。最后说一个小技巧每次技能描述改完我都会用十组真实用户问题做回归测试专门看模型有没有在错误场景调用这个技能。回归测试能拦住百分之八十的描述改坏了问题比事后看日志高效得多。