ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI Agent 开发实战:用 Skill 构建记忆、角色与主动性的最小系统

AI Agent 开发实战:用 Skill 构建记忆、角色与主动性的最小系统 最近不少人问我同一个问题为什么自己照着示例写的 AI Agent调完一次接口、记完一件事下次对话就全忘了我在这上面走了很长的弯路。我的第一个 Agent说白了就是个会调用函数的聊天框第二个有了点记忆但完全没有性格第三个能扮演角色了却永远不会主动干活。直到我把改造重点从“换个更大的模型”转移到 Skill 层的设计上才真正做出了一个像样的 Agent。如果你也卡在这一步这篇内容应该能帮到你。我会用一整个可运行的落地思路拆开讲 Agent 和 Skill 之间到底怎么分工、Skill 如何读记忆写记忆、角色靠什么稳住不“漂移”、主动性又是从哪里来的并给出一份能跑通的最小项目骨架。目标是让你写完第一个 Agent 之后不再觉得它只是个带对话界面的脚本。1. Skill 不是函数是 Agent 的能力合同1.1 先把 Agent 主程序和 Skill 的边界划清楚很多入门教程喜欢把 Agent 定义为“大模型 工具 while 循环”。这个定义没错但也正因为太简短大家写出来的东西基本都是同一个套路把用户输入、历史消息、工具返回一股脑儿塞进系统提示词然后让模型决定调用哪个工具。这样做出来的程序本质上只是 function calling不是真正的 Agent。我的理解是Agent 是一个拥有决策器的执行主体它负责理解目标、安排步骤、调度资源、判断什么时候该停下。而 Skill 是挂在 Agent 身上的“能力单元”。一个 Skill 不仅要包含程序怎么调用的函数逻辑还要包含这份能力在什么条件下生效、能访问哪些记忆、应该以什么角色口吻来完成工作、做完之后要不要更新记忆。简而言之Skill 是一份带元数据、带约束、带记忆规则和输出规范的能力包。这个区分非常重要。如果你把 Agent 看成操作系统Skill 就是安装在系统里的应用程序。应用程序有权限声明、有额定的使用场景、有自己的数据读写范围而操作系统负责调度它们而不是把每个应用的代码都塞进内核里。1.2 一个最小 Skill 描述文件应该长什么样我目前比较习惯用一个结构化描述文件来定义 Skill。下面是一份简化后的 YAML 示例你可以把它理解成 Skill 的“使用说明书”name: weekly_report_review description: 每周五汇总本周工作记录输出结构化周报草稿 version: 1.0.0 trigger: type: scheduled cron: 0 17 * * 5 memory: read: - work_logs - user_preferences write: - weekly_reports role: profile: project_assistant tone: concise_professional permission: level: low_risk can_execute: true can_notify: true看到这里你可能会问这不就是给一个函数加了一段注释吗有必要搞这么复杂吗有必要。原因有两个。第一Agent 的判断要靠这些元数据。没有元数据模型只能靠猜现在到底该不该用这个 Skill这次调用能碰哪些数据如果每次都要靠提示词里的自然语言去“说服”模型换一个场景就很容易失灵。第二记忆和主动行为不能由函数自己决定必须由框架按照元数据来施加边界。我们常说 Agent 要“可控”可控的第一步就是每个 Skill 先声明自己能干什么、不能干什么、什么时候干而不是把一切都交给模型的临场发挥。Skill 和工具之间还有一个容易被忽略的差别工具的函数是无状态的而 Skill 天然要操作状态。写销售周报的函数只是生成文本而一个“销售周报 Skill”还需要判断本周有没有需要特别提醒的风险项、周报生成后是否要从工作日志中删除已汇总的数据片段、是否要把摘要写回长期记忆里。这就是带有状态管理的能力封装本质上是 Agent 的最小行为单元。我给你的建议是不要写代码前先抽象技能而是先尝试给自己的 Agent 列出将来可能需要的 5 到 10 个 Skill每个都写下触发条件、读写记忆的命名空间和可执行权限。这份清单写完了Agent 的调度逻辑其实已经完成了一大半。2. 给 Skill 装上记忆短期工作区与长期档案的双层设计2.1 为什么不能把“所有历史对话”都塞进系统提示词第一个坑很典型不少人默认上下文窗口越长模型越聪明于是把过去几个月的聊天记录全塞进提示词里以为这样 Agent 就有了记忆。但模型真的能记住吗如果你的上下文足够长模型确实能“看到”几千条历史记录。但注意是看到不是记住。注意力机制下中间段落的信息很容易被稀释回复时模型通常更关注离当前问题最近的内容和开头的系统指令。你翻出两周前用户提到的一个偏好放到 120k token 的中间位置模型大概率会忽略它。更重要的是工程问题无差别地把历史消息塞进每次请求不仅每次请求的 token 成本剧增响应延迟也会变高而且无法支持记忆的定点更新。比如用户告诉你“我现在改喝美式了”你要做的是修改偏好档案里某一项而不是让 Agent 在下一次回复时同时看到“用户以前爱喝拿铁”和“用户现在爱喝美式”这两条互相矛盾的记录。所以我的做法是引入双层记忆短期工作区只放当前任务正在处理的上下文例如当前这轮对话、当前正在生成的代码、刚拿到的 API 返回结果。任务结束后清空或者压缩。长期档案区存放经过提炼的事实、偏好、项目状态和关键事件供 Skill 按需读取。这个设计很像大脑的工作记忆和长期记忆。工作记忆的容量有限但灵活长期记忆容量大但需要检索机制。Agent 的开发重点不在短期工作区而在长期档案怎么沉淀、怎么召回。2.2 长期记忆该记什么不该记什么我见过很多人做 Agent 记忆时把日志直接存成文件每条记录都是“用户说了什么”从来不区分记忆类型。结果 Agent 的“记忆库”最终变成一个巨大的垃圾桶搜得到内容但搜不到真正有用的信息。我现在会把长期记忆里的内容至少分成三类类型含义示例事实型记忆用户的偏好、属性、项目背景用户早上 10 点前不安排会议事件型记忆已经发生过的事情、操作结果昨天已向客户发出合同草稿技能状态型记忆某些 Skill 的执行状态、待办事项周报 Skill 已生成等待人工确认只有区分了类型Skill 在读写时才知道该去哪个命名空间里找数据。例如memory: read: - user_profile # 事实型 - completed_tasks # 事件型 write: - weekly_reports # 产物型有一点必须提醒不要贪多。长期记忆不是记录得越全越好而是越精准越好。如果你允许每个 Skill 在执行完后把中间计算的全部输出随手写进记忆库第二次检索时这些没头没尾的数据就会污染结果Agent 反而像一个记忆力太好但分不清重点的人。2.3 用“写前提炼 检索召回”把记忆接口做扎实记忆接口需要刻意设计“写前提炼”这一步。简单说不是每条聊天记录都要写入记忆库而是要让模型或规则从新信息里抽出“值得以后用”的语义单元再落库。比如用户说“以后发周报之前先给我看一眼别直接发给老板。”这句话应该被提炼成用户偏好周报提交前必须经过用户确认。而不是原样保存。下面是一份可以直接跑的最小实现骨架我用的是 JSON 文件存储加简单的向量编码实际项目里你可以替换成任何向量数据库import json import uuid from datetime import datetime class MemoryStore: def __init__(self, namespace: str, storage_path: str ./memory): self.namespace namespace self.storage_path storage_path def _collection_file(self): return f{self.storage_path}/{self.namespace}.json def write(self, content: str, memory_type: str fact, source_skill: str ): record { id: str(uuid.uuid4()), type: memory_type, content: content, source_skill: source_skill, created_at: datetime.utcnow().isoformat() } # 单文件追加真实项目可按 id 分片或使用向量库 with open(self._collection_file(), a, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n) return record[id] def recall(self, query: str, top_k: int 5): # 这里用最简单的关键词分粗筛真实项目建议用 embedding 做相关性召回 records [] try: with open(self._collection_file(), r, encodingutf-8) as f: for line in f: if line.strip(): records.append(json.loads(line)) except FileNotFoundError: return [] scored [] qwords set(query.lower().split()) for rec in records: words set(rec[content].lower().split()) score len(qwords words) if score 0: scored.append((score, rec)) scored.sort(reverseTrue) return [rec for _, rec in scored[:top_k]]写下来你就发现记忆模块的设计核心其实不复杂让 Skill 先声明自己要读写哪个命名空间然后框架负责把提炼后的有效信息存入正确的地方。这里的关键难点是提炼。要做到精确不能靠简单的 if 关键词而是要用一次带格式约束的模型调用把对话内容映射成结构化的记忆条目。搜索召回也有讲究。关键词匹配适合演示真实项目里我会用向量库做语义召回再对结果做一个轻量级的重排rerank。为什么非要向量召回因为用户后来的提问往往不会和原始记录使用完全相同的词。你记录的是“用户偏好简洁回复”用户后来问的是“能不能说话直接点”关键词匹配靠同义词兜底向量召回则能直接把意思相近的内容捞出来。3. 角色不是嘴上说说三个锚点把 Agent 人设立稳3.1 角色拆成 System、Skill、会话三层而不是只靠一句人设很多人给 Agent 加角色就是在系统提示词里写一句“你是一个乐于助人的项目经理”。刚开始还行对话一长角色感就淡了。这不是模型能力的问题而是角色没有和实际的执行模块绑定。我会把角色拆成三个层级来设计系统人格层整个 Agent 在绝大多数情况下的基本姿态比如严谨、简洁、有边界感。Skill 角色层某个 Skill 被触发时需要转换成的特定身份或表达方式。比如“写周报时是项目助理做代码审阅时是资深工程师安慰用户情绪时是共情型伙伴”。会话口吻层在当前这次对话中根据用户情绪调整的语气、详略和交互频率。这三层最终会组合成“角色上下文”注入模型。具体做法是不要只在开场时把角色档案写一次而是在每一轮构造请求时都把角色档案放在系统提示词的最前面。因为上下文窗口是滑动的历史消息、工具返回结果会不断把后面的内容推出去角色档案如果只出现一次它很快就可能被挤到中间位置权重下降角色感自然就淡了。角色档案本身要像一个结构化文件不能被轻易改动。我建议把它从普通提示词中独立出来{ role_id: project_assistant, core_identity: 你是一位经验丰富的项目助理善于拆解任务、追踪进度习惯提前判断风险。, core_principles: [ 任何对外发送的内容都要先提醒用户确认, 汇报进度时先给结论再给依据, 不编造未发生的事件和未验证的数据 ], tone_rules: { default: 简洁、稳重、不说废话, deadline_near: 更主动地给出可执行建议但仍需用户确认, user_anxious: 先共情再谈方案不制造额外压力 } }把角色档案中的“不可漂移内容”和“可变内容”分开来控制是我在实际测试中认为最有效的方法。核心身份和原则每次对话都必须保持原样而语气、情绪状态等则可以由模型根据当前上下文动态改变。3.2 用角色事件记忆制造连续性Agent 记得自己刚刚做过什么只靠人设描述Agent 依然缺少一种重要的记忆对自己角色经历的连续感。什么叫角色经历例如“上一条周报已经发给用户确认了用户没有回复”或者“他刚才情绪不太好所以这轮回复需要更柔和一些”。这些信息如果不存在记忆里Agent 的每个回合都像第一次认识你即使是同一个 Skill 也会重复问相同的问题。所以我会在记忆库里单独划一个命名空间agent_self_state。每个 Skill 开始执行时读取它执行结束后把关键状态写回。这个状态的更新有点像游戏里角色属性的持久化它会直接影响 Agent 下一回合的表现。下面是一个典型的角色状态更新片段def update_agent_state(memory_store, key, value): memory_store.write( json.dumps({key: key, value: value}), memory_typeagent_state, source_skillagent_core )这件事看起来很简单但带来的效果非常明显。哪怕只是记录“当前已经生成了周报但尚未发送”Agent 在下一回合被问“周报发了吗”时就能给出正确的上下文而不是假装自己知道或者再次从头生成一遍。3.3 角色漂移的排查与校准即便做了这么多设计角色还是可能出现漂移。常见表现有三种人设前后不一致前一刻还稳重专业后一刻突然用大量网络用语。无视边界原则明明设置了“对外发送内容必须先确认”模型一旦接到类似请求就直接输出了。记忆污染导致的行为偏移记忆库里的某条历史知识被当成新的行动准则。遇到角色漂移我的排查顺序是先看最近写入的记忆有没有污染再看 Skill 的角色层是否覆盖了系统人格层最后检查模型版本和温度参数。其中记忆污染导致的角色漂移最隐蔽。比如某个外部信息源包含了“客户喜欢 24 小时随时联系”的记录Agent 读到这条记忆后可能会认为这是该用户的偏好从而改变自己的交互习惯。要防止这一点就必须在记忆检索结果上打标签哪条记忆来自用户本人声明哪条来自外部观察。只有用户本人声明类的记忆才允许覆盖角色设定中的默认行为。这部分的本质是角色的稳定性不能只靠提示词工程去解决要靠代码给出硬约束。人设的底线应该写在记忆库和 Skill 调度器里而不只是写在模型的指令里。4. 主动性从哪来事件监听、任务自检和人工兜底4.1 主动不等于自作主张先分清三类主动行为标题里写了“主动性”这是很多人做 Agent 时最想要、也最容易做崩的能力。我的建议是先把主动行为分成三类再决定各自用什么样的实现方式主动行为类型例子实现方式基于当前任务的推进用户让写方案Agent 写完初稿后主动整理待确认问题清单回合结束自检基于时间或外部事件每周五下午生成周报或收到新邮件时提醒用户定时器 / Webhook基于长期目标的主动请求发现用户日程中连续三天没有安排休息主动询问是否需要调整周期性状态扫描 规则过滤第一类是最容易做的也是新手能最快感受到 Agent“变聪明”的地方。第二类需要一个事件框架。第三类最容易失控一定要配合规则门槛不能让模型每轮对话都去自由猜测用户是否需要“建议”。4.2 用 after_turn hook 让 Agent 在每个回合结束时“想一想”我实现第一类主动性时用的核心机制是 after_turn hook。用户在某一轮对话结束后Agent 主程序不急着返回而是先运行一个自检当前任务是否已经完成如果完成是否产生了值得写入长期记忆的结论当前任务还有没有下一步如果用户的目标还没有完全达成Agent 是否能主动发起一个推进动作有没有新信息需要和旧记忆发生关联如果有是否应该更新旧记录而不是只追加新纪录这段逻辑写成伪代码大致是def run_turn(user_input): context assemble_context(user_input) reply chat_model(context) after_turn_hooks(reply) return reply def after_turn_hooks(reply): reflection_result reflection_model(reply) for skill in registered_skills: if skill.should_act(reflection_result): skill.execute()这里不能用“让模型自由判断要不要执行动作”来做主判断。我在实践中踩过坑模型每轮都觉得自己应该做点什么结果一个 Agent 只是帮用户查了天气后面跟着来了四条“主动建议”。实际上正确做法是让模型只做两件事第一从刚结束的回合里抽取结构化状态第二对照那些 waiting 状态的任务看是否满足了推进条件。具体哪些行为可以触发应该由 Skill 的 trigger 配置决定而不是由模型的临场情绪决定。对于定时器类主动行为我给出一个简单的调度注册方式trigger: type: scheduled cron: 0 17 * * 5 timezone: Asia/Shanghai主循环定期扫描这些注册信息到点后唤起对应 Skill。这个实现比让模型一直“盯着”更可靠也更省 token。4.3 安全边界哪些行为可以主动执行哪些必须停下来等人主动性的安全边界是这个话题里最值得讨论的部分。我给自己定了一条原则凡是可撤销、低成本的动作比如生成草稿、整理资料、总结摘要Agent 可以直接主动执行并展示结果凡是会对外产生副作用、难以撤销的动作比如发送邮件、创建外部工单、修改共享文档Agent 只能主动请求用户确认不能自己动手。在 Skill 描述文件的 permission 字段里我会把动作分成三个级别permission: level: low_risk # 可选 low_risk / medium_risk / high_risk can_execute: true # low_risk 才允许 true can_notify: truemedium_risk 的动作可以自动生成但必须停留为草稿high_risk 的动作则必须走人工审批通道。这个机制的本质是主动性的价值不等于把越来越多的事情交给 Agent 全自动跑而是让 Agent 能在对的时间把对的事情推到合适的位置最终决策权留在人那里。有人担心这样是不是削弱了 Agent 的自主性。我反而认为主动性最可靠的表现是“知道什么时候该把决定权还给用户”这比每次都自作主张要高级得多。5. 完整案例手写一个会记忆、有角色、能主动推进的 Agent5.1 项目结构与角色设计理论讲了不少现在我把三件事合成一个能跑的最小项目。我打算做一个“个人事项助理 Agent”它需要能做到记住用户偏好比如“发送提醒时语气要简短”。记住任务状态比如“方案初稿已完成尚未做终稿检查”。扮演一个擅长拆解任务的角色在不同场景下保持说话风格稳定。在每个回合结束后自检一次主动推进未完成任务。目录结构如下my_agent/ ├── main.py ├── agent_core/ │ ├── memory.py │ ├── context.py │ └── runner.py ├── skills/ │ ├── remember_preference/ │ │ ├── skill.yaml │ │ └── skill.py │ ├── checklist_checker/ │ │ ├── skill.yaml │ │ └── skill.py │ └── progress_report/ │ ├── skill.yaml │ └── skill.py └── memory_store/ ├── user_profile.json └── task_state.json角色档案放在 context.py 中包含三条硬性规则先结论后细节、不编造进度、对外文本需要用户确认。这三条规则会出现在每一轮构造给模型的 system prompt 里。Skill 文件的结构保持统一。例如 remember_preference 的 skill.yamlname: remember_preference description: 当对话中出现明确、可执行的用户偏好的时候将偏好写入 user_profile memory: read: [] write: [user_profile] trigger: type: after_turn condition: contains_clear_preference permission: level: low_risk5.2 核心代码记忆存储与 Skill 调度memory.py 我直接复用第 2 节的 MemoryStore再补充一个查询接口方便 Skill 读取状态from agent_core.memory import MemoryStore user_memory MemoryStore(user_profile, storage_path./memory_store) task_memory MemoryStore(task_state, storage_path./memory_store)runner.py 负责组合整个调用链路class AgentRunner: def __init__(self, skills_registry): self.skills_registry skills_registry self.reply_history [] def run_turn(self, user_input): context self._build_context(user_input) reply self._call_model(context) self.reply_history.append(reply) self._run_after_turn_hooks() return reply def _run_after_turn_hooks(self): for skill in self.skills_registry: if skill.is_triggered(after_turn): skill.execute()一个后台调度线程每分钟检查一次是否存在 scheduled 类型的 Skill时间到了就调用对应 execute。Skill 本身的写法和普通函数最大的不同是execute() 之前会从记忆库加载它需要的数据执行完毕后再将结果写回。比如 progress_report 的 executedef execute(self): tasks task_memory.recall(pending, top_k20) unfinished [t for t in tasks if t[status] ! done] if not unfinished: return None draft self._generate_report(unfinished) return draft需要注意的是我把 Agent 用来感知“下一步该做什么”的能力放进了各个 Skill 的 is_triggered 方法而不是放在模型提示词里。这样做的原因是可测试、可控制。如果触发条件出了问题你只需要看 Skill 里的条件判断代码而不是去猜测模型为什么做出多余动作。5.3 跑起来之后观察到的现象我之前跑这类结构的 Agent 时印象最深的不是它某个具体任务完成得多好而是它的交互方式发生了质变。一次测试里我告诉它“我平时不喜欢把方案写太长”它在下一次生成方案时自动把篇幅压到了两页以内并且回复中说“按照你之前提到的偏好我把方案精简到核心结论加风险附录”。这说明偏好不仅被记住了而且被正确应用到了新任务中。另一次我给它的任务里有两个待确认问题没有处理完它在下一次用户无关对话结束后主动追加了一句“你还剩两个关于预算口径的待确认问题会影响今天下午的方案终稿是否现在处理”这个效果就是第 4 节说的 after_turn hook 带来的。对话系统第一次给了我一种“它真的有在推进这件事”的感觉而不是一个只会回答的问答机。如果你也想自己动手跑通这套逻辑我建议先别追求复杂的界面和外部消息接入就把所有交互放在命令行里。Agent 的核心价值在决策链路不在漂亮的 UI。6. 实际使用中踩过的坑记忆污染、角色串台与主动误触发6.1 坑一所有 Skill 都能写长期记忆记忆库被垃圾填满第一次把记忆权限放开之后一天内记忆库就多了几千条记录。日志、工具返回的原始 JSON、临时计算结果全部被当成记忆写进库里。到了第二天检索结果大部分都是垃圾。后来我把记忆写入权限改成显式声明制。Skill 只有在自己的 skill.yaml 里声明了 write 字段才具备写入对应命名空间的资格。运行中的中间数据只能放在任务上下文中不允许自动落库。这个规则写进框架之后记忆库的规模立刻回归合理检索精度也明显上升。任何记忆系统的核心问题都是“该记的没记不该记的全记了”权限声明是对治这个问题的第一道闸门。6.2 坑二多个 Agent 共享同一个记忆库角色和目标互相污染我测试多 Agent 协同的时候给两个 Agent 接进了同一个 MemoryStore结果第二个 Agent 开始沿用第一个 Agent 的目标设定甚至主动做起了本不属于自己的任务。原因是它们读到了同一个命名空间模型无法区分哪些记录属于自己。解决办法是引入 namespace 和 owner_id 两层隔离。每个 Agent 在记忆库中有一个全局唯一的命名空间Skill 声明 read 时只能引用本 Agent 命名空间下的记忆如果确实需要跨 Agent 共享某个结论要显式地通过“共享白名单”复制到自己的命名空间而不是直接读同一个库。多 Agent 共享记忆这件事本质不是技术做不到而是容易读到自己不该读的上下文。如果读过一条其他 Agent 记下的目标状态主模型的 prompt 里就会混入杂音角色的行为边界就会模糊。隔离不是功能上的限制反而是让每个 Agent 的行为保持一致性的必要条件。6.3 坑三主动性写得太宽Agent 变成了碎嘴子我在 after_turn hook 上线后的第一次测试里发现Agent 每隔几个回合就会“好心”提供一条建议。它并不真的确定这些建议有意义只是模型天生倾向于完成“给出下一步行动”这类指令。这个体验很糟。我的修正有两条。第一给主动行为加上冷却期。同一个主题的主动提醒在一小时内至多触发一次。冷却时间的状态写入记忆的 agent_self_state 命名空间而不是放在进程内存中否则重启后就失效。第二触发条件要非常具体尽量避免模糊表述。不要把“用户提到计划”作为触发条件而要把“用户的任务清单中存在过期项且当前时间距离 deadline 小于 24 小时”作为触发条件。条件具体到这种程度才能保证主动性用在刀刃上。说到底主动性的设计与模型无关只和事件条件的精确度有关。一个条件模糊但模型很大的 Agent很容易变成过度主动的麻烦制造者一个条件清晰但模型普通的 Agent反而能持续给出合理的行为。这轮做下来我最大的体会是当 Skill 真正拥有了记忆读写能力、稳定角色约束和明确的主动触发机制之后Agent 才不再是一个“收到消息就回复”的死循环而变成了一个在持续推进目标的系统。你写的不是一次对话的响应逻辑而是一整套关于“什么时候该做什么事”的决策网络。如果让我给刚入门的开发者一个最后建议我会说先别急着完善记忆算法或引入更复杂的自主决策框架。花点时间把两三个 Skill 调好让它们能稳定读写记忆、守住角色边界、只在真正需要的时候主动触发。等这条路走顺了你自然知道下一步真正缺的是什么。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进