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_k=5,相似度阈值 0.7 左右,低于阈值的宁可不召回,也不要拿一堆不相关的旧记忆污染上下文。还有一个必须强调的细节:记忆必须带会话隔离,每条记忆都要存 user_id 和 session_id,查询时带着过滤条件,否则多用户场景会串数据,这是真实线上事故换来的教训。
2.3 规划与反思技能组:让 Agent 少走弯路
任务复杂到一定程度,直接让模型"想到哪做到哪"会非常不稳定。我说一个常见场景:让 Agent"从数据库里拉出上个月的订单数据,做异常分析,然后生成日报发给老板"。如果是自由发挥,模型可能第一步就去调发邮件技能,邮件发出去里面什么都没有。所以规划技能很重要。
规划有两种主流路线。ReAct 模式是边执行边思考,模型观察环境反馈再决定下一步,灵活但容易走偏。Plan-and-Execute 是先让模型列出完整的执行计划,然后按计划逐步调用技能,可控性好得多。我的经验是:凡是超过三步的操作型任务,强制先出计划,把计划展示给用户确认后再执行,这样既省 token,又不会让模型中途放飞。
反思技能是配套的,它负责在技能执行失败时兜底。执行出错后,把工具返回的 error 信息拼进一个反思提示词,让模型看日志、分析原因、换一个不同的方案重试。我用的提示词模板是:
根据执行日志,刚才尝试失败了。请分析失败原因,可以调整参数、换工具或换执行顺序,但不要重复刚才已经失败过的调用。如果分析后认为任务无法完成,直接回答"无法完成"并说明原因。这里最关键的工程约束是最大重试次数。不要相信模型能无限自我纠正,默认 max_retry=3,超过就把控制权交还给用户。我见过最夸张的一次是模型连续八次调用同一个搜索技能,每次返回都一模一样,纯粹在烧 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-skills
3.1 技术选型:不急着上框架,先搭一个最小闭环
搭 agent-skills 之前,我建议先做一次减法:不要第一步就上 LangChain、LlamaIndex 或者 Semantic Kernel。这些框架确实封装了很多东西,但抽象层级太高,出了 bug 你连"模型为什么这样调用"都很难查清楚。我自己的路线是先用 Python 配 OpenAI SDK 或 Anthropic SDK 搭一个最小闭环,跑通之后再上框架,那时候看框架源码都会容易很多。
一个最小可用的 Agent 闭环至少要包含四块:模型客户端、技能注册表、循环控制器、记忆存储。架构逻辑很简单——用户输入进来,系统先做技能匹配,选出当前任务相关的 Top K 个技能,连同这些技能的 schema 一起发给模型;模型决定是否调用某个技能,如果调用则返回工具调用请求,应用代码执行它并把结果以 tool 消息喂回模型;模型再根据结果决定下一步,直到它不再请求调用工具,直接输出最终答案。
3.2 第一步:定义技能注册表
技能注册表是整个体系的骨架。我用 Pydantic 定义技能元信息,用注册表统一管理所有 handler:
from 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_factory=dict, description="JSON Schema 参数定义") handler: Callable[..., Any] = Field(..., description="实际执行的函数") examples: List[str] = Field(default_factory=list, description="触发示例,帮助模型理解何时调用") conflict_with: List[str] = Field(default_factory=list, description="互斥技能列表") embedding: List[float] = Field(default_factory=list, 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(f"skill {skill.name} already exists") skill_map[skill.name] = skill3.3 第二步:实现 Agent 主循环
技能注册表准备好了,下一步是实现核心循环。下面是一个我精简后的最小 Agent 循环,不依赖框架,直接调用 OpenAI SDK:
import 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( model=MODEL, messages=messages, 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_ascii=False ) except Exception as e: result_payload = json.dumps( {"status": "error", "error": str(e)}, ensure_ascii=False ) 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_STEPS=10是熔断器。不管模型觉得自己多能干,最多让它调用 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(key=lambda x: x[1], reverse=True) 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": "leader@company.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_k=top_k, filter=filter_expr, ) return results同时向量库里的文档 metadata 必须包含 user_id 和 session_id。这条必须在一开始就设计好,不然后面数据已经写乱了再补,迁移成本很高。多租户产品尤其要注意,记忆串场不是"效果不好"的问题,是严重的信息安全问题。
关于 agent-skills,我最后的体会可能有点反直觉:代码反而不是最难的,最难的是你愿不愿意把每个技能当成一个产品去打磨。描述怎么写得让模型秒懂,参数边界怎么定不会让模型乱填,失败时怎么兜底用户不骂街,这些都是文档不写、框架不教的脏活。我建议第一次做的人不要贪多,先挑三个技能跑通一个完整的闭环,比如查资料、写文件、发消息,等这个环稳定了再去铺更多的技能。技能多了,维护成本是线性往上走的,但模型误触发的概率是指数级往上走的,这个尺度自己拿捏好。最后说一个小技巧:每次技能描述改完,我都会用十组真实用户问题做回归测试,专门看模型有没有在错误场景调用这个技能。回归测试能拦住百分之八十的"描述改坏了"问题,比事后看日志高效得多。