第 5 章:让它“懂规矩“ — Skills 与 Memory
一句话定位:你会把“怎么做某类事“沉淀成可复用的 Skill,把“用户的偏好和过往结论“沉淀成跨会话的 Memory —— agent 不再每次都重新学一遍。
想要什么
第 4 章给了 agent “手”(工具)和“脑子写代码“的能力(Code Mode)。但它还缺两样东西:
第一,做事的风格。你说“帮我 review 一下 src/agent.ts“,它会读文件,会评论,但每次评论的角度都不一样 —— 这次盯命名,下次盯异常处理,再下次盯性能。你心里有一个稳定的 checklist,它没有。
第二,跨会话的记忆。你在 thread A 跟它说“我用 npm,不用 pnpm“,thread B 它又问你一遍包管理器。你说“我们的代码 lint 走 biome,不走 eslint“,换个会话它又给你写 eslint 配置。这些是“关于你“的稳定事实,不该淹在某个会话历史里。
我们要让 agent 同时拥有:Skill(方法论,可复用,声明式)和 Memory(事实,跨会话,自动抽取)。
为什么
很多人把这两件事都叫“prompt engineering“,然后塞进 system prompt 一刀切 —— 结果 system prompt 长到 5KB,每次调用都全文重发,贵且难维护。
更好的拆法,跟着 Project Think 的设计来:
- Skill:任务方法论。用文本描述“做某类任务时,怎么思考、按什么步骤、检查什么“。它是对所有用户、所有会话都一样的内容,可以版本化、可以 review、可以多个 Skill 按需挂载。
- Tool:单步动作。
writeFile、grep、sendEmail。它做事,不思考。 - Memory:关于这个用户/项目的稳定事实。“用户偏好 npm”,“项目用 biome”,“上次部署在 thursday 失败过”。它跨会话存在,从对话里自动抽取,需要时按相关性召回。
三者各管一摊,组合起来才能让 agent 既“懂方法“,又“会动手“,又“记得人“。
跳过这一章,你的 agent 就是个“通才打工人“:每次都要从零交代背景。加上,它就是个“懂你的项目老搭档“。
图 5-1:Skill / Tool / Memory 三件套
方案选择
| 需求 | 选 | 理由 |
|---|---|---|
| 教 agent “如何做 code review” | Skill | 方法论稳定,所有用户都用同一份;configureSession 一次插入 |
| 教 agent “如何调用我的 GitHub API” | Tool | 是动作,不是思考;LLM 决定何时调 |
| 让 agent 记住 “我用 npm” | Memory | 跨会话事实;用户告诉过一次,以后都该记得 |
| 给 agent 一个一次性的“这次任务的背景“ | system prompt 临时段 | 一次性,不值得抽 Skill / Memory |
简单决策路径:稳定方法 → Skill;运行动作 → Tool;关于人/项目的事实 → Memory。
Memory 在 2026 Q2 是 私测 (private beta),需要 waitlist。本章给出标准用法和 binding 形态;你可以现在就先把代码写好,等 binding 开放直接生效。生产前请先看 Agent Memory blog 的 GA 时间表。
落地
第一部分:文本 Skill — 通过 Session context block
写一个 Skill
按 BLUEPRINT_v2,Skill 放 src/skills/:
src/
└── skills/
└── code-review.md
<!-- src/skills/code-review.md -->
# Skill: code-review
当用户要求你 review 代码时,按以下流程做。
## 1. 先看入口
- 找到这个文件被谁调用、它调用谁。先理解上下文,再点评。
- 如果是 class,先看 public 方法。
## 2. 按 checklist 评
依次检查:
- 命名:函数/变量名能不能直接读出意图?
- 异常:外部 IO 是否都包了 try/catch?是否吞了原始错误信息?
- 资源:文件句柄、stream、subscription 是否在所有路径上都释放?
- 边界:空数组、null、超长字符串、并发是否考虑?
- 测试可达:有没有副作用嵌入到难以测试的位置?
## 3. 输出格式
- 严重问题(可能产 bug):列在 ## Critical
- 改进建议(风格/可读性):列在 ## Suggestions
- 不要给"看起来不错"这种话 —— 没问题就直接说"无需改动"。
## 4. 不做的事
- 不重写整个文件,只指出问题 + 给最小改动建议。
- 不引入新依赖。
- 不评论格式问题(交给 formatter)。
这是一个纯文本文件,本质是一段 system prompt 的增量。不是代码,不是 schema,就是写给 LLM 看的方法论说明。
把 Skill 装进项目
Workers 不能直接读运行时文件系统。我们用 ESM 的字符串 import:
// src/skills/index.ts
// 用 wrangler 的 rules 把 .md 当 text 引入
import codeReview from "./code-review.md";
export const SKILLS = {
"code-review": {
description:
"Code review 任务的步骤与 checklist。涉及'review/审查/检查/评审 代码'时启用。",
content: codeReview,
},
};
还要给 TypeScript 一个声明,让它认得 import xxx from "./xxx.md":
// src/types/markdown.d.ts
declare module "*.md" {
const content: string;
export default content;
}
wrangler.jsonc 加 rules,告诉 wrangler .md 当文本编译进 worker:
// wrangler.jsonc(增量)
{
// ... 前面字段不变
"rules": [
{ "type": "Text", "globs": ["**/*.md"], "fallthrough": true }
]
}
在 configureSession 里插入 Skill
// src/agent.ts —— 在 ch04 基础上,在 configureSession 里加一个 context block
import { SKILLS } from "./skills";
// configureSession 里追加:
.withContext("code-review-skill", {
description: SKILLS["code-review"].description,
// 重要:Session.withContext 的 SDK 实际签名是 { description, provider }
// 不是 { description, content }(后者是 announcement 里的速记写法)
// provider 必须实现 .get():Promise<string>
provider: { get: async () => SKILLS["code-review"].content },
})
.withCachedPrompt(); // 让重复 prompt 走 prompt caching,省 token
session.withContext(name, { description, provider }) 是 Project Think 的核心 API:它把 provider.get() 返回的字符串作为一个独立的 context block 挂到这个 session 上,框架在每轮请求时拼到 LLM 的 system 段。name 是稳定的 key,后续可以替换或删除同名 block。
实测踩过:announcement 里写的
withContext(name, { content })是简化记法,真 SDK 必须传provider: { get: async () => string },不然 tsc 报“missing required property ‘provider’“。我们的SKILLS["code-review"].content是个静态字符串,所以 provider 写一行 lambda 即可。
description 给 LLM 看 —— 它是元描述,告诉模型“这块是干嘛的“,在多 Skill 共存时帮模型更准确地用上正确那块。
多个 Skill 按需挂载
Skill 多了之后,不要全挂 —— 每挂一个就是几百 token 的常驻 system 内容。按用户意图挑:
// src/agent.ts(更精细的版本)
configureSession(session: Session) {
// 默认挂 code-review,因为这是 CoderAgent 主战场
return session
.withContext("code-review-skill", SKILLS["code-review"])
.withCachedPrompt();
}
// 在 beforeTurn 钩子里按 user 意图动态加挂
async beforeTurn(ctx) {
const lastUserText = ctx.messages.at(-1)?.parts
?.find(p => p.type === "text")?.text ?? "";
if (/重构|refactor/i.test(lastUserText) && SKILLS["refactor-plan"]) {
ctx.session = ctx.session.withContext(
"refactor-skill",
SKILLS["refactor-plan"],
);
}
}
beforeTurn 是 Think 的每轮入口钩子(think.d.ts:490),在 LLM 看到消息之前给你一次改 session 的机会。
第二部分:Agent Memory(私测)
以下基于 Agent Memory 公告 的 binding 形态。2026-04-30 实测确认 Memory binding 仍未对所有账号开放,本节代码无法在普通账号上跑通,字段名也以 waitlist 释出后的官方 d.ts 为准 —— 本节当架构参考读,生产前请重新校对实际签名。
binding
// wrangler.jsonc(增量,Memory 申请通过后)
{
// ... 前面字段不变
"memory": [
{ "binding": "MEMORY" }
]
}
// 在 src/agent.ts 里 —— Env 加上 MEMORY(片段)
export type Env = {
AI: Ai;
CODER_AGENT: DurableObjectNamespace<CoderAgent>;
LOADER: WorkerLoader;
MEMORY: AgentMemory; // 由 wrangler types 生成
};
抽取 — 在 onChatResponse 里 ingest
每轮 LLM 回复完成后,把这段对话喂给 Memory 让它自动抽事实:
// src/agent.ts(增量)
import type { ChatResponseResult } from "@cloudflare/think";
export class CoderAgent extends Think<Env> {
// ... 前面方法不变
// Memory profile 名 = userId(跨会话共享)
private profileName(): string {
// 从 DO name 解析 user;约定 name = "<userId>:<conversationId>"
const [userId] = this.name.split(":");
return userId || "anonymous";
}
async onChatResponse(result: ChatResponseResult) {
if (!this.env.MEMORY) return; // 私测期间可能未配置
const profile = await this.env.MEMORY.getProfile(this.profileName());
// 把这一轮的消息(user + assistant)交给 Memory
// ingest 是异步抽取 —— 走 LLM 把"事实"摘出来存
await profile.ingest(result.messages, {
sessionId: this.name,
});
}
}
这里有几个要点:
- profile 名 = userId:同一个 user 跨多个 conversation 会共享 memory。如果你想“每个项目独立 memory“,就用
${userId}:${projectId}。 - 跨用户共享:多个不同的
name共用同一个 profile 名 = 共享同一份 memory(blog 没明说,行为以官方文档为准)。 ingest不会立刻有结果:它在后台跑 Llama 4 Scout 做 extract、Nemotron 3 做 synth,把“事实“写进 profile 的 vector store。下一轮才能召回。
召回 — 在 beforeTurn 里 recall + 塞 context
// src/agent.ts(增量)
import type { TurnContext, TurnConfig } from "@cloudflare/think";
export class CoderAgent extends Think<Env> {
// ... 前面方法不变
async beforeTurn(ctx: TurnContext): Promise<TurnConfig | void> {
if (!this.env.MEMORY) return;
const lastUserText = ctx.messages.at(-1)?.parts
?.find((p: any) => p.type === "text")?.text ?? "";
if (!lastUserText) return;
const profile = await this.env.MEMORY.getProfile(this.profileName());
// recall 走自然语言查询,返回匹配的事实摘要
const ans = await profile.recall(lastUserText);
if (!ans?.result) return;
// 把召回结果作为这一轮的 context block 临时挂上
return {
session: ctx.session.withContext("user-memory", {
description: "关于这个用户的已知偏好与上下文事实",
content: ans.result,
}),
};
}
}
profile.recall(query) 返回 { result: string }(也可能带 sources,以 d.ts 为准)。它是自然语言总结,不是 raw 记忆条目 —— 直接塞 context 就行,不用再 prompt 整理。
显式存 / 列 / 删
除了 ingest 自动抽,还有手动 API:
// 显式记一条事实(用户在 UI 上点"记住这个")
await profile.remember({
content: "用户的 GitHub username 是 easychen",
sessionId: this.name,
});
// 列出所有 memory(给用户看"agent 记得我什么")
const all = await profile.list();
// 删除一条(用户点"忘掉这个")
await profile.forget(memoryId);
私测的话怎么办
binding 没下来之前,加一层 fallback 让代码先跑起来:
// 在 src/agent.ts 里 —— defensive(片段)
async beforeTurn(ctx: TurnContext) {
if (!this.env.MEMORY) {
// 私测期间:用本地 SQLite 模拟一个 "fake memory"
return; // 或者从 this.sql 查个简单 KV 表
}
// ... 走真 Memory
}
申请 waitlist:blog.cloudflare.com/introducing-agent-memory 文末。
验证
Skill 生效
启动 npx wrangler dev,curl:
# Terminal
curl -N -X POST http://localhost:8787/api/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"帮我 review 一下 src/agent.ts"}]}]}'
回复应该按 Skill 里规定的 ## Critical / ## Suggestions 两段格式输出,而不是随意发散。如果还是散的,检查两件事:
wrangler.jsonc的rules是否生效(看 build log 有Compiled module: src/skills/code-review.md)。configureSession是否真的被调用(在方法里临时console.log("session configured"))。
Memory 生效
第一轮:
curl -N -X POST http://localhost:8787/agents/coder-agent/easychen:thread-1 \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"我用 npm,不用 pnpm 也不用 yarn。"}]}]}'
agent 回 OK。
换一个会话(thread-2,同 user):
curl -N -X POST http://localhost:8787/agents/coder-agent/easychen:thread-2 \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"给我生成一个安装步骤。"}]}]}'
回复里应该自动用 npm install,而不是问你“用哪个包管理器“。如果没有,说明 ingest 还在后台跑(等 5-10 秒再试),或者 recall(query) 没匹到 —— 把召回的 ans.result log 出来看看。
边界与坑
- Skill 不是越多越好。每挂一个 context block,就多几百 token 常驻 system。多 Skill 时优先按用户意图按需挂,而不是全挂。
withCachedPrompt()必开。Skill 内容稳定,正适合 prompt caching。不开等于每轮都全文重算。- Memory 是私测。没下来之前,代码用
if (!this.env.MEMORY) return做软降级,不要让缺 binding 把整个 agent 弄崩。 - profile 名是 PII 边界。把 userId 当 profile name 是最简形式;如果你的产品里“项目“是更自然的隔离单元,就用
${userId}:${projectId}。不要全用"global"一个 profile —— 跨用户串记忆会出大问题。 recall有 latency。它在后台跑向量检索 + LLM 总结,每次大约 200-500ms。beforeTurn里调它,等于每轮加这点延迟 —— 可以接受,但别再往里塞别的同步 LLM 调用了。- Skill 与 Memory 之间不交叉。Skill 是方法,Memory 是事实。不要把 “用户偏好” 写进 Skill,也不要把“做事步骤“塞进 Memory 让它记。两者搞混会让两边都失效。
延伸阅读
- Project Think Sessions —
withContext/withCachedPrompt的设计意图 - Agent Memory 公告 — Memory 的架构、模型选择、waitlist
- Sessions 中文文档 — Session API 完整签名
- Anthropic Skills 设计 — 同思路的“方法论封装“,可参考其元数据格式
下一章预告
到这里,agent 已经有“手“(工具)、“脑”(Code Mode)、“方法”(Skill)、“记忆”(Memory)。但所有动作都还局限在 Worker / DO 内 —— 它不能真的跑 npm install、不能起 dev server、不能克隆仓库改文件。下一章我们引入 Cloudflare Sandboxes(2026-04 GA),给 agent 一个真 Linux 环境:shell、文件系统、长进程、PTY、port 暴露,一应俱全。