Agent Skill开发实战:从Function Calling到记忆、角色与主动性的三层进化

Agent Skill开发实战:从Function Calling到记忆、角色与主动性的三层进化 最近老有朋友问我“Agent 和普通的大模型 API 封装到底差在哪”我做这类应用的时间不算短过去一年里最大的感受就是区别不在于你调了几次模型而在于你有没有把“能力”做出“人感”来。这里说的“人感”落到工程上就是三件事Skill 有没有记忆、有没有角色、有没有主动性。很多刚接触 AI Agent 的人以为 Skill 就是一段可以被大模型调用的函数给它一个名字、一段描述、一个执行逻辑完事。但当你把 Skill 做成这样你得到的只是一个工具不是一个能帮忙做事的智能体。今天这篇文章我就拿一个真实能跑的例子——“任务推进助手”Skill手把手把它写出来。这个 Skill 会记住你的偏好、给自己设定角色还会在合适的时机主动提醒你该做的事情。文章面向两类人一类是刚会调用大模型 API、想学 Agent 开发的新手另一类是已经在做 Agent 框架、但一直觉得“工具不够聪明”的开发者。本文不要求你有很深的机器学习基础只要你有 Python 基础能跑通 OpenAI 兼容接口就基本能跟上。代码偏工程向我会把所有关键选择的“为什么”都说清楚。1. 动手之前先拆解 Skill 的三层能力1.1 Skill 和普通函数的边界在哪里先说个你可能踩过的坑很多 Agent 框架只是把 Function Calling 包装成了 Skill。底层逻辑是这样框架把工具的 JSON Schema 喂给大模型模型觉得该调工具时就返回一个 tool call然后 Python 执行完再喂回去。这套机制本身没问题但请你想一个问题函数是无状态的Skill 如果也只是无状态的那它充其量是“工具箱里的扳手”而不是“能独当一面的帮手”。我理解中的 Skill至少要包含三部分元信息name、description、version、调用条件。这决定了大模型在什么场景下会选中它。执行逻辑真正做事的那段代码、提示词、工具调用链。生命周期它在被调用前要准备什么调用后要记住什么运行完之后会不会主动触发其他动作。把这三部分补齐Skill 才从一个“函数”变成一种“带状态的自主能力”。我们下面要写的任务推进助手就会同时拥有这三个层面。为什么不能直接用 Function Calling 解决因为 Function Calling 的天然边界是“等用户问”。它只能被动响应而一个会做任务管理的 Skill必须在用户没有明确说“帮我记任务”的时候也知道把关键信息沉淀到记忆里在用户没有问“今天有什么待办”的时候也能根据时间戳和上下文判断要不要主动提醒。这已经超出了普通函数的职责范围。1.2 记忆、角色、主动性能力层级逐层递进这三样东西不是并列关系而是递进关系。记忆是最底层。没有记忆Agent 每一次对话都是“陌生人”。你在星期二告诉它“这个项目周五要交第一版”到星期四你再问它“我最近忙什么”它应该能回忆起这个 deadline。这里的记忆分为两层短期记忆是当前会话的上下文长期记忆是跨会话沉淀下来的用户偏好和事实。角色在记忆之上。角色本质上是一套行为约束和表达风格。同样一句话让同一个模型扮演“严肃项目经理”和“亲切的同事”写出来的任务提醒完全不一样。有了角色Skill 的答案才稳定而不是每次跟着 prompt 随机漂移。主动性在最上层。主动性不是让模型像闹钟一样每天定时乱叫而是让它拥有“判断什么时候该开口”的能力。一个任务推进助手如果所有提醒都等用户来问那它根本不叫助手。真正的主动触发点在工程上是事件驱动的用户新提了一个任务、某个任务的截止时间快到了、用户连续两天没更新状态……这些事件到达时Skill 要能自动决定要不要打扰用户。1.3 本项目落地形态与功能拆解我把示例 Skill 命名为TaskFollowerSkill中文名叫“任务推进助手”。它的能力拆解如下角色一名话不多、但逻辑严谨的项目推进助理。它不替用户做决定只负责帮用户把任务拆成可执行步骤并追踪风险。记忆记录用户的核心偏好比如“每天提醒不要超过三次”“重要任务要排序”记录任务状态和截止时间。主动性当检测到有任务在今天到期或者已经逾期时Skill 会返回一条非用户主动询问的提醒文本交给 Agent 主循环推送出去。扩展点为了不把事情做复杂这个版本我不集成日历、邮件等外部系统而是把这些都抽象成“外部事件输入”你在自己的项目里替换成真实接口即可。下面我们从零开始把这段代码写出来。2. 环境准备与 Skill 骨架先跑通最小闭环2.1 安装依赖与项目目录先准备好 Python 环境。建议用 3.10 以上版本避免类型注解写法上遇到兼容问题。在任意目录执行mkdir agent-skill-demo cd agent-skill-demo python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install openai1.0 python-dotenv sentence-transformers numpy这里用到的依赖说明一下openai用来调用大模型 API我用的是 OpenAI 兼容协议后续换成其他兼容服务也基本不用改代码。sentence-transformers用来把记忆片段转成向量实现长期记忆中的语义检索。numpy做向量点积计算。python-dotenv管理 API Key 等环境变量。目录结构我建议按能力边界分文件而不是把 Skill 都堆在一个文件里agent-skill-demo/ ├── .env ├── main_loop.py # Agent 主循环负责调度 Skill ├── skills/ │ ├── __init__.py │ ├── base.py # Skill 基类和元数据结构 │ └── task_follower.py # 任务推进助手 Skill └── memory/ ├── __init__.py ├── vector_store.py # 向量记忆存储与检索 └── memory_utils.py # 序列化、加载等辅助函数这样分层的理由是memory 模块不依赖具体 Skill可复用skills 模块只依赖 BaseSkill 和 MemoryStore互相解耦。以后你想加第二个 Skill不需要动记忆模块的代码。2.2 Skill 元信息给大模型一张“说明书”Skill 的第一步是定义好它的身份。在大模型驱动的 Agent 里Skill 的 description 不是给人看的而是给调度器看的。如果描述不清晰模型会在错误的场景里调用它或者在合适的场景里忽略了它。# skills/base.py from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Optional dataclass class SkillMeta: name: str description: str version: str 1.0 priority: int 100 tags: list[str] field(default_factorylist) # 可选的触发条件描述例如“当检测到用户提到截止日期时” trigger_hint: str class BaseSkill: meta: SkillMeta def __init__(self) - None: # 这里预留 run_hooks不同扩展点会在不同时机被调度器调用 self.pre_hooks: list[Callable[[], Awaitable[None]]] [] self.post_hooks: list[Callable[[], Awaitable[None]]] [] async def execute(self, user_input: str, context: dict[str, Any]) - str: Skill 的主执行入口子类必须实现。 raise NotImplementedError async def build_context(self, user_input: str) - dict[str, Any]: 执行前构建上下文包括从记忆里召回相关内容。 raise NotImplementedError async def proactive_check(self, context: dict[str, Any]) - Optional[str]: 主动检查返回需要主动推送的文本没有则返回 None。 return None我特意把proactive_check从execute里拆出来是因为主动性并不总是“用户刚说完话”的那个瞬间。Agent 主循环可以定时调用一次或者在其他事件到来时调用。这样 Skill 既是响应式的也是事件驱动的。2.3 description 的写法别写功能写场景一个常见的坏习惯是把 description 写成“任务管理工具用于管理任务”这种描述对模型来说信息量太低了。更好的写法是把“什么时候不该调用”也写进去因为负例能显著降低误调用率。举个例子TaskFollowerSkill.meta SkillMeta( nametask_follower, description( 当用户提到任务、待办、截止日期、提醒、计划推进时使用。 它负责帮用户拆分任务、记录进度和偏好并在截止日期临近时主动提醒。 如果用户只是闲聊或者问百科知识不要调用本 Skill。 ), version1.0, priority90, tags[task, reminder, memory], trigger_hint新任务写入或截止时间临近时返回 proactive 文本, )看起来只是文字问题但实际影响很大。有一次我在一个测试项目里把 description 写成了“任务管理工具”结果用户问“帮我整理一下今天的会议地点”模型调用了任务管理 Skill却不知道该提取什么字段输出了垃圾结构。后来我把触发场景写清楚立刻好很多。这里有一个元层面的经验Skill 的撰写本质是在给模型写“路由规则”。description 越接近真实场景路由就越准确。3. 核心工程给 Skill 接入长期记忆3.1 短期记忆与长期记忆的分工做 Agent 的初期我试过把所有历史对话一股脑塞进上下文很快发现两个问题一是窗口装不下二是信息越多模型越容易受无关内容干扰。所以在任务推进助手里面我们必须把记忆分成两层短期记忆Session Memory保留当前对话轮次的最近 10 条左右外加完整会话的压缩摘要。长期记忆Long-term Memory跨会话保存用户偏好、任务事实、重要结论以结构化记录落盘。短期记忆很简单就是维护一个messages列表每次执行完把 user 消息和 assistant 回复 append 进去。这个不难。困难的是长期记忆如何判断什么值得记住、如何存储、如何在需要的时候找回来。3.2 长期记忆存储用向量 结构化的双层结构我推荐的结构是“向量检索为主结构化字段为辅”。向量负责语义召回结构化字段负责精确过滤比如按日期、按类型。这样既避免了纯关键词搜索的呆板也避免了纯向量检索的日期混乱问题。先实现一个记忆条目# memory/vector_store.py from __future__ import annotations import json import os import numpy as np from dataclasses import dataclass, field dataclass class MemoryEntry: content: str kind: str fact created_at: str # ISO 格式时间 due_date: str | None None # 可选例如 2026-02-28 embedding: list[float] field(default_factorylist)每个字段的解释content真正需要记忆的一句话比如“用户偏好每天下午两点后不打扰”。kind记忆类型。这里我定义了preference、todo、fact三种。类型的作用不是给人看的是给 recall 时过滤用的。due_date只有todo类型需要填方便做硬性的日期过滤。embedding把 content 向量化后得到的数组用于语义检索。注意记忆条目在落盘时要转成 JSON。numpy.ndarray不能直接被 json 序列化所以我统一存list类型class MemoryStore: def __init__(self, path: str memory_store.jsonl): self.path path os.makedirs(os.path.dirname(self.path), exist_okTrue) def _to_dict(self, entry: MemoryEntry) - dict: return { content: entry.content, kind: entry.kind, created_at: entry.created_at, due_date: entry.due_date, embedding: entry.embedding, } def _append(self, entry: MemoryEntry) - None: with open(self.path, a, encodingutf-8) as f: f.write(json.dumps(self._to_dict(entry), ensure_asciiFalse) \n)用 JSONL 而不是单个 JSON 文件是因为每条追加写入的方式最简单崩溃恢复也容易生产环境如果量大再替换成 SQLite 或专门的向量数据库即可。3.3 embedding 计算与语义召回接下来是向量化。我是用本地方案避免每次调远程 embedding 服务的延迟和成本。模型我选择了BAAI/bge-small-zh-v1.5体积小中文效果足够用。模型第一次加载会比较慢所以我用模块级单例避免重复加载。# memory/vector_store.py 追加 from sentence_transformers import SentenceTransformer _model None def get_embedding(text: str) - np.ndarray: global _model if _model is None: _model SentenceTransformer(BAAI/bge-small-zh-v1.5) embedding _model.encode(text, normalize_embeddingsTrue) return np.asarray(embedding, dtypenp.float32)为什么要 normalize因为后面用点积表示余弦相似度时归一化之后可以直接算q emb省去一次除法。大多数 embedding 模型都会建议归一化如果有精度问题可以检查一下是否做了这一步。召回逻辑如下def recall(self, query: str, top_k: int 3, kind: str | None None) - list[dict]: if not os.path.exists(self.path): return [] query_emb get_embedding(query) scored [] with open(self.path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue row json.loads(line) if kind and row.get(kind) ! kind: continue emb np.asarray(row[embedding], dtypenp.float32) score float(query_emb emb) scored.append((score, row)) scored.sort(keylambda x: x[0], reverseTrue) return [row for score, row in scored[:top_k] if score 0.35]这里的阈值 0.35 不是拍脑袋定的而是我用一小批测试句子调出来的低于这个值召回的条目经常和当前意图完全无关高于 0.5很多相关的偏好又漏掉了。不同 embedding 模型阈值不一样你换模型后必须重新做一次范围测试。召回之后怎么用在 Skill 的build_context中我按类型分别召回。召回 preference 是为了构建系统人设召回 fact 是为了给模型补充背景召回 todo 是为了在主动检查时用。4. 注入角色再给 Skill 装上“主动性”4.1 角色提示词不写空话写决策偏好角色本身并不神秘它就是一段 system prompt。但这里有个反直觉的点角色 prompt 不是越长越像人而是越能影响具体输出越好。比如“你是一位耐心的助手”这种话模型很难根据它做出行为差异但“当用户表现出焦虑时先共情再给建议”就能影响行为。我为任务推进助手写的角色原则是你是“小助”一名任务推进助理。你说话风格简洁不废话不客套。 你的工作信条 1. 把模糊目标拆成下一步行动而不是只给宏大建议。 2. 记住用户的偏好并主动调用记忆里的信息来调整表达方式。 3. 当发现任务有截止日期的风险时直接点出风险不拐弯抹角。 4. 每天主动提醒不超过三次除非用户明确要求更频繁。注意第三条和第四条它们在后面会被工程逻辑影响。角色 prompt 决定了模型“输出什么气质”工程逻辑决定了模型“在什么情况下开口”。两者配合才叫有主动性的 Skill光靠 prompt 里的“你要主动”没有用。把记忆注入角色 prompt 的方式def _build_system_prompt(self, mem_rows: list[dict]) - str: if mem_rows: lines \n.join( f- {row[content]} for row in mem_rows[:8] ) user_memory_section f\n用户的重要信息\n{lines} else: user_memory_section \n用户的重要信息暂无。 return ROLE_TEMPLATE user_memory_section这里有个约束召回的记忆不能全塞进去最多 8 条。否则长的记忆会让 system prompt 变得很臃肿既浪费 token又会让模型抓不住重点。4.2 如何判断要不要“主动开口”主动性先要定义一个“触发检查”。我把触发场景拆成两类规则型触发代码判断比如“任务 due_date 等于今天/已逾期”这种判断必须交给代码不能让模型去算日期。语义型触发需要理解上下文比如“用户提到某个项目而这个项目在记忆里已经有待办”这种判断由模型做。真实项目里两者要结合。我简化成一个proactive_checkasync def proactive_check(self) - str | None: today datetime.now().strftime(%Y-%m-%d) overdue_items [] for row in self.memory.iter_all(kindtodo): due_date row.get(due_date) if not due_date: continue if due_date today: overdue_items.append(row) if not overdue_items: return None messages [] for item in overdue_items[:3]: messages.append(f任务{item[content]}截止时间{item[due_date]}) return 主动提醒你有任务需要关注。\n \n.join(messages)这段代码的关键是日期比较用字符串是因为 ISO 格式YYYY-MM-DD的字典序和真实时间顺序一致不需要额外转 datetime。这个细节很实用。不过只靠规则会有 bug比如用户说“下周二前给我初稿”模型并不一定会把 due_date 填对。所以在保存任务时我会先调用一次大模型做一个抽取动作把自然语言里的截止时间转成 ISO 格式再写入记忆。4.3 Skill 的完整实现与调用链路现在把角色、记忆、主动性串起来。下面是TaskFollowerSkill的核心代码# skills/task_follower.py from __future__ import annotations import json from datetime import datetime from .base import BaseSkill, SkillMeta from memory.vector_store import MemoryStore, get_embedding ROLE_TEMPLATE 你是“小助”一名任务推进助理。你说话风格简洁不废话不客套。 你的工作信条 1. 把模糊目标拆成下一步行动而不是只给宏大建议。 2. 记住用户的偏好并主动调用记忆里的信息来调整表达方式。 3. 当发现任务有截止日期的风险时直接点出风险不拐弯抹角。 4. 每天主动提醒不超过三次除非用户明确要求更频繁。 class TaskFollowerSkill(BaseSkill): meta SkillMeta( nametask_follower, description( 当用户提到任务、待办、截止日期、提醒、计划推进时使用。 它负责帮用户拆分任务、记录进度和偏好并在截止日期临近时主动提醒。 如果用户只是闲聊或者问百科知识不要调用本 Skill。 ), version1.0, priority90, ) def __init__(self, llm_client): super().__init__() self.llm llm_client self.memory MemoryStore(memory_store.jsonl) def _save_preference_from_text(self, text: str): # 实践中这里会让 LLM 判断是否值得记再决定写入。 # 为了演示简单我用一个规则当包含“我喜欢/我不喜欢/尽量/别”时写入。 if any(kw in text for kw in [我喜欢, 我不喜欢, 尽量, 别, 不要]): self.memory.save( contenttext.strip(), kindpreference ) async def build_context(self, user_input: str) - dict: prefs self.memory.recall(用户的偏好, kindpreference, top_k3) facts self.memory.recall(user_input, kindfact, top_k3) return {prefs: prefs, facts: facts} async def execute(self, user_input: str, context: dict | None None) - str: context context or await self.build_context(user_input) system_prompt ROLE_TEMPLATE if context[prefs]: system_prompt \n用户相关偏好 for m in context[prefs]: system_prompt f\n- {m[content]} # 调用大模型让助手给出回应 messages [ {role: system, content: system_prompt}, {role: user, content: user_input}, ] reply await self.llm.chat(messages) # 抽取可能存在的任务/偏好并写入长期记忆 self._save_preference_from_text(user_input) return reply你要注意execute 返回给用户的是模型的回复但它私下还会悄悄做两件事检索偏好、写入新偏好。这就是“记忆”的开始。那么主动性是怎么被调用的是在 main loop 里。注意主循环不能只在用户发消息后调用proactive_check还要周期性地调用一次。最简单的方式是让 main loop 每次处理用户请求前都先检查一次同时后台定时任务每 30 分钟也检查一次。为了不打扰用户我可以在 Skill 内维护一个“今天已提醒次数”的计数超过三次就不再触发这个计数逻辑需要落到一个状态文件里重启程序后才能保留。# main_loop.py 片段 async def run_once(skill: TaskFollowerSkill, user_msg: str): # 在执行用户消息前先看有没有需要主动提醒的内容 proactive await skill.proactive_check() if proactive: print([主动提醒], proactive) # 再执行主任务 context await skill.build_context(user_msg) answer await skill.execute(user_msg, context) print([助手], answer)看到没有这只是在用户开口前进实际上就体现出了 Skill 的主动性。更进一步如果你的 Agent 跑在群聊或自动任务平台上可以加一个 cron 触发器每 10 分钟调用一次proactive_check。这里不一定要做很重的调度系统把主动性做成 Skill 的一个方法剩下的就是外部什么时候想叫它的问题。5. 调试实录把容易踩的坑一次说清5.1 大模型会把“偏好”和“事实”混为一谈我早期把偏好数据全存成了fact后来发现召回时背景信息和用户偏好混在一起导致模型在生成回复时把偏好当成客观事实做出错误的推断。比如用户说“我不喜欢太长的提醒”模型如果没有区分出这句话是 preference可能把“提醒不能太长”理解成任务内容回复风格就乱了。解决方法是给recall加kind参数并用“用户偏好”作为查询词去召回。你可以做一个简单的对照实验把同一句话分别按fact和preference保存然后召回“用户的偏好”高分的命中基本是preference类型的这证明类型过滤是有效且必要的。5.2 向量检索对短句不敏感长句反而有用embedding 适合做语义相似度但它不是万能的。我发现如果让用户输入“提醒我周五交周报”然后直接拿这个整句去向量库里召回往往匹配不到历史偏好“用户习惯周五上午交周报”因为两个句子的表面形式差得有点远。更稳的做法不是拿原始输入做 query而是先用大模型把用户的输入转成“记忆检索词”。也就是说与其问向量库“提醒我周五交周报”不如问“用户周五有哪些交材料的习惯”这样召回的准确率会高不少。我在build_context里加上了一步调用模型生成短检索词再执行记忆召回。多了这一步延迟但效果提升非常大值得为它多花一两秒。5.3 主动性触发不能只看“日期 今天”还要考虑时区“今天”是一个随时间变化的概念。如果你的 Agent 部署在服务器上服务器时区可能和用户时区不一样。比如服务器 UTC 时间是 2 月 28 日 16 点国内已经是 2 月 29 日 0 点一个在 2 月 29 日截止的任务就会因为判断逻辑跑在 UTC 时区而错误地“还不是今天”。我的方案是把截止时间统一按用户时区存储并在比较今天日期时指定用户时区from zoneinfo import ZoneInfo def get_user_today(user_tz: str Asia/Shanghai) - str: return datetime.now(ZoneInfo(user_tz)).strftime(%Y-%m-%d)别小看这个细节很多远程提醒类 Avatar 早期版本出错最后查下来全是时区问题。把日期处理的边界钉死比增加各种提示词更能保证可靠性。5.4 上下文里塞太多召回内容模型会“迷路”我刚做这个 Skill 时觉得召回越多越好模型知识多总比少好。测试后发现完全不是这样。系统 prompt 加了一堆记忆之后模型在处理简单任务时反而会“过度引用”历史信息比如用户单纯问“把简报发我”它居然会把以前记的“用户喜欢用红色标题”这种偏好强行塞进回复里。后来我把召回数量限制得很严格偏好最多 3 条事实最多 5 条且每条后面标注类型和触发场景。模型反而表现更好。记忆系统最重要的是做减法不是做加法这条经验同样适用于几乎所有的 Agent 系统。5.5 标准问题排查速查表现象可能原因排查与解决模型在无关场景调用 Skilldescription 写得太宽泛补充负面触发条件如“闲聊时不要调用”记忆召回结果和主题不相关阈值过低或 query 不精准提高阈值先用 LLM 生成检索词再召回主动提醒永远不触发时区不同导致日期判断错统一用用户时区判断“今天”模型记住了不该记的信息缺少记忆写入审批环节保存前让 LLM 判断是否值得记并限制 content 长度长期记忆文件越来越大JSONL 只追加没有合并去重定期压缩对重复记忆按向量相似度合并Skill 重启后主动提醒计数丢失计数只存在内存中把每天提醒次数写入本地状态文件这是我在跑这个演示项目时遇到的最典型的六个问题基本覆盖了从路由、记忆到主动性的全过程。当你把它扩展到自己的项目时如果场景复杂建议先单独测试记忆召回再测试主动触发不要一上来就连起来调否则很难定位问题出在哪个环节。6. 向前一步把 Skill 升级成真正可落地的 Agent 能力到这你已经有了一个会记忆、会扮演角色、会主动检查任务的 Skill。但如果你想把它接到生产环境我认为接下来要补三块工作。第一给记忆系统加上合并更新能力。当前演示是每次都追加一条记忆里面容易有重复。实际操作中当向量召回发现相似度大于 0.9 的已有条目时应该替换旧条目而不是新增。你可以使用一个简单的去重函数def upsert(self, entry: MemoryEntry) - None: similarities self._search(entry.content, top_k1) if similarities and similarities[0][score] 0.9: # 更新相似条目 self._delete_by_content(similarities[0][content]) self._append(entry)第二主动性触发要做“抑制机制”。如果你希望 Skill 每天早上 9 点主动提醒一次那么必须有触发状态控制否则用户在 9 点整正好发了条消息主循环调用一次proactive_check后台任务再调用一次就会重复打扰。解决方式是在 Skill 内维护一个“最后一次主动提醒时间”的字段两次提醒之间至少间隔 4 小时。第三把 Skill 的输入从“单条文本”扩展为“结构化事件”。比如日历同步、邮件到达、代码仓库 push这些都是外部事件。你可以在参数里加入event_type和event_data让 Skill 在不同事件下走不同的处理分支。这一步做完你就不再只是做一个“聊天工具”而是真正把 Agent 嵌进工作流里了。我在实际测试这个 Skill 时最明显的感觉是它开始像一个“有审美的同事”了。它不是每次都有问必答而是在合适的时间打扰你它能记得你说过的话并且用你舒服的方式回应。当你把这三层能力加到任意 Skill 上你写的就不再是“一段 API 调用”而是一个有自己工作方式的数字员工。最后提醒一句启动新项目时别急着堆功能先把记忆召回和主动检查这两个循环调稳整体体验一定会超过你预期。